Add code samples to generated openAPI file

This commit is contained in:
curquiza
2025-12-03 19:50:48 +01:00
parent 82adabc5a0
commit d795ade246
3 changed files with 948 additions and 304 deletions

977
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,3 +10,5 @@ serde_json = "1.0"
clap = { version = "4.5.52", features = ["derive"] }
anyhow = "1.0.100"
utoipa = "5.4.0"
reqwest = { version = "0.12", features = ["blocking"] }
regex = "1.10"

View File

@@ -1,10 +1,15 @@
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::Result;
use anyhow::{Context, Result};
use clap::Parser;
use meilisearch::routes::MeilisearchApi;
use regex::Regex;
use serde_json::{json, Value};
use utoipa::OpenApi;
const CODE_SAMPLES_URL: &str = "https://raw.githubusercontent.com/meilisearch/documentation/6a694aad651772cc3805fb39ab4f0d92273795de/.code-samples.meilisearch.yaml";
#[derive(Parser)]
#[command(name = "openapi-generator")]
#[command(about = "Generate OpenAPI specification for Meilisearch")]
@@ -16,6 +21,10 @@ struct Cli {
/// Pretty print the JSON output
#[arg(short, long)]
pretty: bool,
/// Skip fetching code samples (offline mode)
#[arg(long)]
no_code_samples: bool,
}
fn main() -> Result<()> {
@@ -24,14 +33,23 @@ fn main() -> Result<()> {
// Generate the OpenAPI specification
let openapi = MeilisearchApi::openapi();
// Convert to serde_json::Value for modification
let mut openapi_value: Value = serde_json::to_value(&openapi)?;
// Fetch and add code samples if not disabled
if !cli.no_code_samples {
let code_samples = fetch_code_samples()?;
add_code_samples_to_openapi(&mut openapi_value, &code_samples)?;
}
// Determine output path
let output_path = cli.output.unwrap_or_else(|| PathBuf::from("meilisearch.json"));
// Serialize to JSON
let json = if cli.pretty {
serde_json::to_string_pretty(&openapi)?
serde_json::to_string_pretty(&openapi_value)?
} else {
serde_json::to_string(&openapi)?
serde_json::to_string(&openapi_value)?
};
// Write to file
@@ -41,3 +59,252 @@ fn main() -> Result<()> {
Ok(())
}
/// Fetch and parse code samples from the documentation repository
fn fetch_code_samples() -> Result<HashMap<String, String>> {
println!("Fetching code samples from documentation...");
let response = reqwest::blocking::get(CODE_SAMPLES_URL)
.context("Failed to fetch code samples")?
.text()
.context("Failed to read code samples response")?;
parse_code_samples_yaml(&response)
}
/// Parse the code samples YAML file
/// The format is:
/// ```yaml
/// # key_name
/// some_id: |-
/// curl \
/// -X GET 'URL'
/// ```
/// We extract the comment as the key and everything after `|-` as the value
fn parse_code_samples_yaml(content: &str) -> Result<HashMap<String, String>> {
let mut samples: HashMap<String, String> = HashMap::new();
let mut current_key: Option<String> = None;
let mut current_value: Vec<String> = Vec::new();
let mut in_code_block = false;
let mut base_indent: Option<usize> = None;
// Regex to match the start of a code sample (key: |-)
let code_start_re = Regex::new(r"^[a-zA-Z0-9_]+:\s*\|-\s*$")?;
// Regex to match comment lines that define keys
let comment_re = Regex::new(r"^#\s*([a-zA-Z0-9_]+)\s*$")?;
for line in content.lines() {
// Check if this is a comment line defining a new key
if let Some(caps) = comment_re.captures(line) {
// Save previous sample if exists
if let Some(key) = current_key.take() {
if !current_value.is_empty() {
samples.insert(key, current_value.join("\n").trim_end().to_string());
}
}
current_key = Some(caps[1].to_string());
current_value.clear();
in_code_block = false;
base_indent = None;
continue;
}
// Check if this starts a new code block
if code_start_re.is_match(line) {
in_code_block = true;
base_indent = None;
continue;
}
// If we're in a code block and have a current key, collect the content
if in_code_block && current_key.is_some() {
// Empty line or line with only whitespace
if line.trim().is_empty() {
// Only add empty lines if we've already started collecting
if !current_value.is_empty() {
current_value.push(String::new());
}
continue;
}
// Calculate indentation
let indent = line.len() - line.trim_start().len();
// Set base indent from first non-empty line
if base_indent.is_none() {
base_indent = Some(indent);
}
// If line has less indentation than base, we've exited the block
if indent < base_indent.unwrap_or(0) && !line.trim().is_empty() {
in_code_block = false;
continue;
}
// Remove base indentation and add to value
let base = base_indent.unwrap_or(0);
let dedented = if line.len() > base { &line[base..] } else { line.trim_start() };
current_value.push(dedented.to_string());
}
}
// Don't forget the last sample
if let Some(key) = current_key {
if !current_value.is_empty() {
samples.insert(key, current_value.join("\n").trim_end().to_string());
}
}
println!("Parsed {} code samples", samples.len());
Ok(samples)
}
/// Convert an OpenAPI path to a code sample key
/// Path: /indexes/{index_uid}/documents/{document_id}
/// Method: GET
/// Key: get_indexes_indexUid_documents_documentId
fn path_to_key(path: &str, method: &str) -> String {
let method_lower = method.to_lowercase();
// Remove leading slash and convert path
let path_part = path
.trim_start_matches('/')
.split('/')
.map(|segment| {
if segment.starts_with('{') && segment.ends_with('}') {
// Convert {param_name} to camelCase
let param = &segment[1..segment.len() - 1];
to_camel_case(param)
} else {
// Keep path segments as-is, but replace hyphens with underscores
segment.replace('-', "_")
}
})
.collect::<Vec<_>>()
.join("_");
if path_part.is_empty() {
method_lower
} else {
format!("{}_{}", method_lower, path_part)
}
}
/// Convert snake_case to camelCase
fn to_camel_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = false;
for (i, c) in s.chars().enumerate() {
if c == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_ascii_uppercase());
capitalize_next = false;
} else if i == 0 {
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}
/// Add code samples to the OpenAPI specification
fn add_code_samples_to_openapi(
openapi: &mut Value,
code_samples: &HashMap<String, String>,
) -> Result<()> {
let paths = openapi
.get_mut("paths")
.and_then(|p| p.as_object_mut())
.context("OpenAPI spec missing 'paths' object")?;
let mut added_count = 0;
for (path, path_item) in paths.iter_mut() {
let path_item = match path_item.as_object_mut() {
Some(p) => p,
None => continue,
};
// HTTP methods we care about
let methods = ["get", "post", "put", "patch", "delete", "head", "options"];
for method in methods {
if let Some(operation) = path_item.get_mut(method) {
let key = path_to_key(path, method);
if let Some(sample) = code_samples.get(&key) {
// Create x-codeSamples array according to Redocly spec
let code_sample = json!([{
"lang": "cURL",
"source": sample
}]);
operation
.as_object_mut()
.map(|op| op.insert("x-codeSamples".to_string(), code_sample));
added_count += 1;
}
}
}
}
println!("Added code samples to {} operations", added_count);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_to_key() {
assert_eq!(path_to_key("/indexes", "GET"), "get_indexes");
assert_eq!(path_to_key("/indexes/{index_uid}", "GET"), "get_indexes_indexUid");
assert_eq!(
path_to_key("/indexes/{index_uid}/documents", "POST"),
"post_indexes_indexUid_documents"
);
assert_eq!(
path_to_key("/indexes/{index_uid}/documents/{document_id}", "GET"),
"get_indexes_indexUid_documents_documentId"
);
assert_eq!(path_to_key("/indexes/{index_uid}/settings/stop-words", "GET"), "get_indexes_indexUid_settings_stop_words");
}
#[test]
fn test_to_camel_case() {
assert_eq!(to_camel_case("index_uid"), "indexUid");
assert_eq!(to_camel_case("document_id"), "documentId");
assert_eq!(to_camel_case("task_uid"), "taskUid");
}
#[test]
fn test_parse_code_samples() {
let yaml = r#"
# get_indexes
list_all_indexes_1: |-
curl \
-X GET 'MEILISEARCH_URL/indexes'
# post_indexes
create_an_index_1: |-
curl \
-X POST 'MEILISEARCH_URL/indexes' \
-H 'Content-Type: application/json' \
--data-binary '{
"uid": "movies"
}'
"#;
let samples = parse_code_samples_yaml(yaml).unwrap();
assert_eq!(samples.len(), 2);
assert!(samples.contains_key("get_indexes"));
assert!(samples.contains_key("post_indexes"));
assert!(samples["get_indexes"].contains("curl"));
assert!(samples["post_indexes"].contains("POST"));
}
}