/
githubmirror
/
meilisearch
Обзор
Документация
Войти
/
githubmirror
/
meilisearch
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
crates/openapi-generator/src/main.rs
939 строк
32 KB
curquiza
Fix formatting
07 июл 2026, 18:49
07 июл 2026, 18:49
ab732e4
Код
Авторство
О чём код?
use std::collections::hash_map::Entry; use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::Parser; use meilisearch::routes::MeilisearchApi; use serde_json::{Map, Value}; use utoipa::OpenApi; /// HTTP methods supported in OpenAPI specifications. const HTTP_METHODS: &[&str] = &["get", "post", "put", "patch", "delete"]; /// Alias for OpenAPI path item or operation object (map of string keys to JSON values). type JsonObject = Map<String, Value>; #[derive(Parser)] #[command(name = "openapi-generator")] #[command(about = "Generate OpenAPI specification for Meilisearch")] struct Cli { /// Output file path (default: meilisearch-openapi.json) #[arg(short, long, value_name = "FILE")] output: Option<PathBuf>, /// Pretty print the JSON output #[arg(short, long)] pretty: bool, /// Check that all routes have a summary (useful for CI) #[arg(long)] check_summaries: bool, /// Check that all routes have a description (useful for CI) #[arg(long)] check_descriptions: bool, /// Check for duplicate routes and path issues (useful for CI) #[arg(long)] check_paths: bool, /// Check that parameters have descriptions, 2xx responses have examples, and schema properties have descriptions (useful for CI) #[arg(long)] check_docs: bool, /// Check that query and body parameters have explicit required = true/false in code (no utoipa inference) #[arg(long)] check_params: bool, } fn main() -> Result<()> { let cli = Cli::parse(); // Generate the OpenAPI specification let openapi = MeilisearchApi::openapi(); // Convert to serde_json::Value for modification let openapi_value: Value = serde_json::to_value(&openapi)?; // Check that all routes have summaries if requested if cli.check_summaries { check_all_routes_have_summaries(&openapi_value)?; } // Check that all routes have descriptions if requested if cli.check_descriptions { check_all_routes_have_descriptions(&openapi_value)?; } // Check for path issues (duplicates, malformed paths) if requested if cli.check_paths { check_path_issues(&openapi_value)?; } // Check documentation (param descriptions, response examples, schema properties) if requested if cli.check_docs { check_docs(&openapi_value)?; } // Check that query and body parameters have explicit required = true/false in code if cli.check_params { check_params()?; } // Determine output path let output_path = cli.output.unwrap_or_else(|| PathBuf::from("meilisearch-openapi.json")); // Serialize to JSON let json = if cli.pretty { serde_json::to_string_pretty(&openapi_value)? } else { serde_json::to_string(&openapi_value)? }; std::fs::write(&output_path, &json) .with_context(|| format!("write OpenAPI spec to {}", output_path.display()))?; println!("OpenAPI specification written to: {}", output_path.display()); Ok(()) } /// Returns the top-level `paths` object from the OpenAPI spec. fn get_paths_object(openapi: &Value) -> Result<&JsonObject> { openapi.get("paths").and_then(Value::as_object).context("OpenAPI spec missing 'paths' object") } /// Returns the routes whose operation is missing a non-empty string `field` (e.g. "summary" or "description"). fn routes_missing_operation_field(paths: &JsonObject, field: &str) -> Vec<String> { let mut missing: Vec<String> = paths .iter() .flat_map(|(path, path_item)| { path_item.as_object().map(|path_item| { HTTP_METHODS.iter().filter_map(move |method| { let op = path_item.get(*method)?; let has_field = op.get(field).and_then(Value::as_str).is_some_and(|s| !s.trim().is_empty()); if has_field { None } else { Some(format!("{} {}", method.to_uppercase(), path)) } }) }) }) .flatten() .collect(); missing.sort_unstable(); missing.dedup(); missing } /// Prints the doc-comment example shared by the summary and description checks. fn print_doc_comment_help() { eprintln!("\nTo fix this, add a doc-comment (///) above the route handler function."); eprintln!("The first line becomes the summary, subsequent lines become the description."); eprintln!("\nExample:"); eprintln!(" /// List webhooks"); eprintln!(" ///"); eprintln!(" /// Get the list of all registered webhooks."); eprintln!(" #[utoipa::path(...)]"); eprintln!(" async fn get_webhooks(...) {{ ... }}"); } /// Checks that all routes have a summary field. /// /// Returns an error if any route is missing a summary. fn check_all_routes_have_summaries(openapi: &Value) -> Result<()> { let paths = get_paths_object(openapi)?; let missing_summaries = routes_missing_operation_field(paths, "summary"); if missing_summaries.is_empty() { println!("All routes have summaries."); return Ok(()); } eprintln!("The following routes are missing a summary:"); for route in &missing_summaries { eprintln!(" - {}", route); } print_doc_comment_help(); anyhow::bail!("{} route(s) missing summary", missing_summaries.len()); } /// Checks that all routes have a description field. /// /// Returns an error if any route is missing a description. fn check_all_routes_have_descriptions(openapi: &Value) -> Result<()> { let paths = get_paths_object(openapi)?; let missing_descriptions = routes_missing_operation_field(paths, "description"); if missing_descriptions.is_empty() { println!("All routes have descriptions."); return Ok(()); } eprintln!("The following routes are missing a description:"); for route in &missing_descriptions { eprintln!(" - {}", route); } print_doc_comment_help(); anyhow::bail!("{} route(s) missing description", missing_descriptions.len()); } /// Checks for path issues in the OpenAPI specification. /// /// Validates that: /// 1. All paths start with `/` /// 2. No paths contain double slashes `//` /// 3. No duplicate paths exist (after normalizing slashes) fn check_path_issues(openapi: &Value) -> Result<()> { let paths = get_paths_object(openapi)?; let mut issues: Vec<String> = Vec::new(); let mut normalized_paths: HashMap<String, String> = HashMap::new(); for path in paths.keys() { if !path.starts_with('/') { issues.push(format!("Path does not start with '/': {}", path)); } if path.contains("//") { issues.push(format!("Path contains double slashes '//': {}", path)); } let normalized = normalize_path(path); match normalized_paths.entry(normalized) { Entry::Occupied(entry) if entry.get() != path => { issues.push(format!( "Duplicate routes detected (same path after normalization):\n - {}\n - {}", entry.get(), path )); } Entry::Vacant(entry) => { entry.insert(path.clone()); } _ => {} } } if issues.is_empty() { println!("All paths are valid (no duplicates or malformed paths)."); Ok(()) } else { eprintln!("Path issues found in OpenAPI specification:\n"); for issue in &issues { eprintln!(" - {}", issue); } eprintln!(); anyhow::bail!("{} path issue(s) found", issues.len()); } } /// Resolves a `$ref` like `#/components/schemas/Foo` against the OpenAPI root. fn resolve_ref<'a>(openapi: &'a Value, r#ref: &str) -> Option<&'a Value> { let r#ref = r#ref.strip_prefix("#/")?; let mut current = openapi; for part in r#ref.split('/') { current = current.get(part)?; } Some(current) } /// Returns the properties object of a schema, resolving `$ref` if needed. fn get_schema_properties<'a>(openapi: &'a Value, schema: &'a Value) -> Option<&'a JsonObject> { let schema = schema .get("$ref") .and_then(Value::as_str) .and_then(|r#ref| resolve_ref(openapi, r#ref)) .unwrap_or(schema); schema.get("properties").and_then(Value::as_object) } /// Returns true if the response object has an example (content.application/json.example or .examples). fn response_has_example(response: &Value) -> bool { response .get("content") .and_then(|c| c.get("application/json")) .map(|content| { content.get("example").is_some() || content .get("examples") .and_then(Value::as_object) .is_some_and(|ex| !ex.is_empty()) }) .unwrap_or(false) } /// Returns true if the response has a JSON body (content.application/json present). fn response_has_body(response: &Value) -> bool { response.get("content").and_then(|c| c.get("application/json")).is_some() } /// Returns true if the route returns a 202 Accepted, i.e. it enqueues an async task. /// Such routes do not synchronously return a 404 when the resource is missing: the /// returned task carries the "not found" error instead, so a 404 is not required. fn returns_async_task(responses: &JsonObject) -> bool { responses.contains_key("202") } /// Returns true if the path has at least one parameter whose name contains "uid" (case insensitive). /// E.g. `{indexUid}`, `{taskUid}`, `{batchUid}`, `{uuid}`, `{uidOrKey}`. fn path_has_uid_parameter(path: &str) -> bool { path.split('/') .filter_map(|segment| segment.strip_prefix('{').and_then(|s| s.strip_suffix('}'))) .any(|name| name.to_lowercase().contains("uid")) } /// Returns true if the schema value has a non-empty description, either directly or inside a oneOf/anyOf branch /// (utoipa puts descriptions there for Option-like types like Setting<T>). fn property_has_description(prop_obj: &JsonObject) -> bool { prop_obj.get("description").and_then(Value::as_str).is_some_and(|d| !d.trim().is_empty()) || ["oneOf", "anyOf"].iter().any(|&key| { prop_obj.get(key).and_then(Value::as_array).is_some_and(|arr| { arr.iter().filter_map(Value::as_object).any(|obj| { obj.get("description") .and_then(Value::as_str) .is_some_and(|d| !d.trim().is_empty()) }) }) }) } /// Checks that all properties of a schema have a non-empty description. fn check_schema_properties_have_description( openapi: &Value, schema: &Value, context: &str, errors: &mut Vec<String>, ) { let Some(properties) = get_schema_properties(openapi, schema) else { return; }; for (prop_name, prop_value) in properties { let Some(prop_obj) = prop_value.as_object() else { continue; }; if !property_has_description(prop_obj) { errors .push(format!("{}: property \"{}\" is missing a description", context, prop_name)); } // One level of $ref for nested objects if let Some(nested_ref) = prop_obj.get("$ref").and_then(|r| r.as_str()) { if let Some(resolved) = resolve_ref(openapi, nested_ref) { if let Some(nested_props) = resolved.get("properties").and_then(Value::as_object) { for (nested_name, nested_value) in nested_props { let Some(nested_obj) = nested_value.as_object() else { continue; }; if !property_has_description(nested_obj) { errors.push(format!( "{}: property \"{}.{}\" is missing a description", context, prop_name, nested_name )); } } } } } } } /// Checks documentation: parameters have descriptions, 2xx responses have examples, and schema properties have descriptions. fn check_docs(openapi: &Value) -> Result<()> { let paths = get_paths_object(openapi)?; let mut errors: Vec<String> = Vec::new(); for (path, path_item) in paths.iter() { let Some(path_item) = path_item.as_object() else { continue }; for method in HTTP_METHODS { let Some(operation) = path_item.get(*method) else { continue }; check_operation_docs(openapi, path, method, operation, &mut errors); } } if errors.is_empty() { println!("OpenAPI documentation check passed."); println!(" - Parameters have descriptions"); println!(" - Request/response schema properties have descriptions"); println!(" - 2xx responses have examples where applicable"); println!(" - 401 (except GET /health), 404 (routes with *Uid param, except async 202 routes), and 400 responses have examples"); Ok(()) } else { errors.sort(); eprintln!("OpenAPI documentation check failed:\n"); for e in &errors { eprintln!(" - {}", e); } eprintln!("\nFix the above and re-run the check."); anyhow::bail!("{} documentation issue(s) found", errors.len()); } } fn operation_prefix(operation: &Value, method: &str, path: &str) -> String { let op_id = operation .get("operationId") .and_then(Value::as_str) .map(String::from) .unwrap_or_else(|| format!("{} {}", method, path)); format!("{} {} ({})", method.to_uppercase(), path, op_id) } fn check_delete_no_body(method: &str, operation: &Value, prefix: &str, errors: &mut Vec<String>) { if method == "delete" && operation.get("requestBody").is_some() { errors.push(format!("{}: DELETE route must not have a request body", prefix)); } } fn check_parameters_descriptions(operation: &Value, prefix: &str, errors: &mut Vec<String>) { let params = operation.get("parameters").and_then(Value::as_array).map(|a| a.as_slice()).unwrap_or(&[]); for param in params { let name = param.get("name").and_then(Value::as_str).unwrap_or("(unnamed)"); let param_in = param.get("in").and_then(Value::as_str).unwrap_or("unknown"); if param.get("description").and_then(Value::as_str).is_none_or(|s| s.trim().is_empty()) { errors.push(format!( "{}: parameter \"{}\" ({}) is missing a description", prefix, name, param_in )); } } } fn check_request_body_schema( openapi: &Value, operation: &Value, prefix: &str, errors: &mut Vec<String>, ) { if let Some(schema) = operation .get("requestBody") .and_then(|b| b.get("content")) .and_then(|c| c.get("application/json")) .and_then(|j| j.get("schema")) { check_schema_properties_have_description( openapi, schema, &format!("{} request body", prefix), errors, ); } } fn success_codes_from_responses(responses: Option<&JsonObject>) -> Vec<String> { responses .map(|r| r.keys().filter(|k| k.starts_with('2')).cloned().collect()) .unwrap_or_default() } fn has_2xx_response_example(responses: Option<&JsonObject>, success_codes: &[String]) -> bool { let Some(resps) = responses else { return false }; success_codes.iter().any(|code| { resps .get(code) .and_then(|r| r.get("content").and_then(|c| c.get("application/json"))) .is_some_and(|content| { content.get("example").is_some() || content .get("examples") .and_then(Value::as_object) .is_some_and(|ex| !ex.is_empty()) }) }) } fn success_responses_have_body(responses: Option<&JsonObject>, success_codes: &[String]) -> bool { responses.is_some_and(|r| { success_codes.iter().any(|code| { r.get(code) .and_then(|res| res.get("content").and_then(|c| c.get("application/json"))) .is_some() }) }) } fn check_2xx_has_example( responses: Option<&JsonObject>, success_codes: &[String], prefix: &str, errors: &mut Vec<String>, ) { let has_body = success_responses_have_body(responses, success_codes); let has_example = has_2xx_response_example(responses, success_codes); if has_body && !has_example { errors.push(format!( "{}: at least one 2xx response must have an example (response example required)", prefix )); } } fn check_401_response( path: &str, responses: Option<&JsonObject>, prefix: &str, errors: &mut Vec<String>, ) { if path == "/health" { return; } let Some(resps) = responses else { return }; match resps.get("401") { Some(r401) if !response_has_example(r401) => { errors.push(format!( "{}: response 401 must have an example (e.g. missing_authorization_header)", prefix )); } None => { errors.push(format!("{}: response 401 is required with an example", prefix)); } _ => {} } } fn check_404_response( path: &str, responses: Option<&JsonObject>, prefix: &str, errors: &mut Vec<String>, ) { if !path_has_uid_parameter(path) { return; } let Some(resps) = responses else { return }; match resps.get("404") { Some(r404) if !response_has_example(r404) => { errors.push(format!( "{}: response 404 must have an example (e.g. resource not found by uid)", prefix )); } None if !returns_async_task(resps) => { errors.push(format!( "{}: response 404 is required for routes with a uid path parameter (e.g. resource not found)", prefix )); } _ => {} } } fn check_400_response(responses: Option<&JsonObject>, prefix: &str, errors: &mut Vec<String>) { let Some(r400) = responses.and_then(|r| r.get("400")) else { return }; if response_has_body(r400) && !response_has_example(r400) { errors.push(format!( "{}: response 400 must have an example (e.g. error message and code)", prefix )); } } fn check_response_schemas( openapi: &Value, responses: Option<&JsonObject>, success_codes: &[String], prefix: &str, errors: &mut Vec<String>, ) { let Some(resps) = responses else { return }; for code in success_codes { let Some(schema) = resps .get(code) .and_then(|r| r.get("content")) .and_then(|c| c.get("application/json")) .and_then(|j| j.get("schema")) else { continue; }; check_schema_properties_have_description( openapi, schema, &format!("{} response {}", prefix, code), errors, ); } } fn check_operation_docs( openapi: &Value, path: &str, method: &str, operation: &Value, errors: &mut Vec<String>, ) { let prefix = operation_prefix(operation, method, path); let responses = operation.get("responses").and_then(Value::as_object); let success_codes = success_codes_from_responses(responses); check_delete_no_body(method, operation, &prefix, errors); check_parameters_descriptions(operation, &prefix, errors); check_request_body_schema(openapi, operation, &prefix, errors); check_2xx_has_example(responses, &success_codes, &prefix, errors); check_401_response(path, responses, &prefix, errors); check_404_response(path, responses, &prefix, errors); check_400_response(responses, &prefix, errors); check_response_schemas(openapi, responses, &success_codes, &prefix, errors); } /// Normalizes a path for duplicate detection. /// /// - Removes leading and trailing slashes /// - Collapses multiple consecutive slashes into one fn normalize_path(path: &str) -> String { path.split('/').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("/") } /// Checks that query parameters in Rust source have explicit `required = true` or `required = false`. /// /// Scans crates/meilisearch/src for: /// - Query: structs with `#[into_params(..., parameter_in = Query, ...)]`: every `#[param(...)]` field must contain `required = true` or `required = false`. fn check_params() -> Result<()> { let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").context("CARGO_MANIFEST_DIR not set")?; let meilisearch_src = Path::new(&manifest_dir) .join("../meilisearch/src") .canonicalize() .context("resolve meilisearch/src path (run from workspace root)")?; let mut errors: Vec<String> = Vec::new(); for entry in walk_rs_files(&meilisearch_src)? { let path = entry.path(); let content = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; let rel = path.strip_prefix(&meilisearch_src).unwrap_or(&path); check_query_params_in_file(&content, rel, &mut errors); } if errors.is_empty() { println!("All query parameters have explicit required = true/false."); Ok(()) } else { eprintln!("We do not want utoipa to infer whether a parameter is required or not, as that does not correctly cover our documentation needs. You must define it explicitly with required = true or required = false.\n"); eprintln!( "The following parameters are missing explicit required = true or required = false:\n" ); for e in &errors { eprintln!(" - {}", e); } eprintln!("\nFix the above by adding required = true or required = false in the #[param(...)] attribute."); anyhow::bail!("{} parameter(s) missing explicit required", errors.len()) } } fn walk_rs_files(dir: &Path) -> Result<Vec<std::fs::DirEntry>> { let mut out = Vec::new(); for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { out.extend(walk_rs_files(&path)?); } else if path.extension().is_some_and(|e| e == "rs") { out.push(entry); } } Ok(out) } /// Extract the content of an attribute: from `#[attr(` to the matching `)`. fn extract_attr_content(s: &str, open_pos: usize) -> Option<&str> { let rest = s.get(open_pos..)?; let start = rest.find('(')? + 1; let mut depth = 1u32; let mut i = start; let bytes = rest.as_bytes(); while i < bytes.len() && depth > 0 { match bytes[i] { b'(' => depth += 1, b')' => depth -= 1, _ => {} } i += 1; } if depth != 0 { return None; } rest.get(start..i - 1) } fn has_required_explicit(content: &str) -> bool { let content = content.trim(); content.contains("required = true") || content.contains("required = false") } /// For a struct body, find every field (pub or private). For each, the "block above" is /// the lines between the previous field and this one. Check that block contains required = true/false. fn check_struct_fields_have_required( body: &str, struct_name: &str, kind: &str, rel_path: &Path, errors: &mut Vec<String>, ) { let lines: Vec<&str> = body.lines().collect(); let mut block_above_lines: Vec<&str> = Vec::new(); for line in &lines { let trimmed = line.trim(); let field_name: Option<&str> = if let Some(after_pub) = trimmed.strip_prefix("pub ") { let after_pub = after_pub.trim_start(); let end = after_pub .find(|c: char| !c.is_alphanumeric() && c != '_') .unwrap_or(after_pub.len()); let name = after_pub[..end].trim(); if !name.is_empty() && after_pub.get(end..).is_none_or(|s| s.trim_start().starts_with(':')) { Some(name) } else { None } } else if let Some(colon_pos) = trimmed.find(':') { let before_colon = trimmed[..colon_pos].trim(); if !before_colon.is_empty() && before_colon.chars().all(|c| c.is_alphanumeric() || c == '_') && !before_colon.eq("pub") { Some(before_colon) } else { None } } else { None }; if let Some(name) = field_name { let block_above_str = block_above_lines.join("\n"); let block_above = block_above_str.trim(); if !has_required_explicit(block_above) { errors.push(format!( "{}: {} struct `{}` has parameter `{}` without required = true/false in the attributes above it", rel_path.display(), kind, struct_name, name )); } block_above_lines.clear(); } else { block_above_lines.push(line); } } } /// Check query param structs: #[into_params(..., parameter_in = Query, ...)] and then every #[param(...)] must have required. fn check_query_params_in_file(content: &str, rel_path: &Path, errors: &mut Vec<String>) { let mut i = 0; while let Some(pos) = content[i..].find("#[into_params(") { let abs_pos = i + pos; let attr = match extract_attr_content(content, abs_pos + 2) { Some(a) => a, None => { i = abs_pos + 1; continue; } }; if !attr.contains("parameter_in") || !attr.contains("Query") { i = abs_pos + 1; continue; } let into_params_prefix_len = "#[into_params(".len(); let after_attr_offset = abs_pos + into_params_prefix_len + attr.len() + 2; let after_attr_slice = content.get(after_attr_offset..).unwrap_or(""); let (struct_start, name_offset) = if let Some(p) = after_attr_slice.find("pub struct ") { (after_attr_offset + p, "pub struct ".len()) } else if let Some(p) = after_attr_slice.find("struct ") { (after_attr_offset + p, "struct ".len()) } else { i = abs_pos + 1; continue; }; let name_start = struct_start + name_offset; let name_end = content[name_start..] .find(|c: char| !c.is_alphanumeric() && c != '_') .map(|p| name_start + p) .unwrap_or(content.len()); let struct_name = content[name_start..name_end].trim(); let brace = content[struct_start..].find('{').map(|p| struct_start + p).unwrap_or(struct_start); let body = match extract_brace_content(content, brace) { Some(b) => b, None => { i = abs_pos + 1; continue; } }; check_struct_fields_have_required(body, struct_name, "query", rel_path, errors); i = struct_start + 1; } } /// Extract content inside `{ ... }` starting at the opening brace. fn extract_brace_content(s: &str, open_brace_pos: usize) -> Option<&str> { let rest = s.get(open_brace_pos..)?; if !rest.starts_with('{') { return None; } let mut depth = 1u32; let mut i = 1; let bytes = rest.as_bytes(); while i < bytes.len() && depth > 0 { match bytes[i] { b'{' => depth += 1, b'}' => depth -= 1, _ => {} } i += 1; } if depth != 0 { return None; } rest.get(1..i - 1) } #[cfg(test)] mod tests { use serde_json::json; use super::*; #[test] fn test_normalize_path() { assert_eq!(normalize_path("/indexes"), "indexes"); assert_eq!(normalize_path("/indexes/"), "indexes"); assert_eq!(normalize_path("indexes"), "indexes"); assert_eq!(normalize_path("/indexes/{indexUid}"), "indexes/{indexUid}"); assert_eq!(normalize_path("indexes//{indexUid}"), "indexes/{indexUid}"); assert_eq!(normalize_path("/indexes//{indexUid}/compact"), "indexes/{indexUid}/compact"); assert_eq!(normalize_path("//indexes///compact//"), "indexes/compact"); } #[test] fn test_routes_missing_operation_field() { let openapi = json!({ "paths": { "/indexes": { "get": { "summary": "List indexes", "description": "List all indexes." }, "post": { "summary": "Create an index" } }, "/health": { "get": { "summary": "Get health", "description": " " } } } }); let paths = get_paths_object(&openapi).unwrap(); assert!(routes_missing_operation_field(paths, "summary").is_empty()); assert_eq!( routes_missing_operation_field(paths, "description"), vec!["GET /health", "POST /indexes"] ); } #[test] fn test_check_path_issues_valid() { let openapi = json!({ "paths": { "/indexes": {}, "/indexes/{indexUid}": {}, "/indexes/{indexUid}/documents": {}, "/indexes/{indexUid}/compact": {} } }); let result = check_path_issues(&openapi); assert!(result.is_ok()); } #[test] fn test_check_path_issues_missing_leading_slash() { let openapi = json!({ "paths": { "/indexes": {}, "indexes/{indexUid}": {} } }); let result = check_path_issues(&openapi); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("path issue")); } #[test] fn test_check_path_issues_double_slash() { let openapi = json!({ "paths": { "/indexes": {}, "/indexes//{indexUid}/compact": {} } }); let result = check_path_issues(&openapi); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("path issue")); } #[test] fn test_check_path_issues_duplicate_routes() { let openapi = json!({ "paths": { "/indexes/{indexUid}/compact": {}, "indexes//{indexUid}/compact": {} } }); let result = check_path_issues(&openapi); assert!(result.is_err()); let err = result.unwrap_err().to_string(); // Should report at least the duplicate issue (and possibly the missing slash and double slash) assert!(err.contains("path issue")); } #[test] fn test_check_struct_fields_missing_required() { let body = r#" /// Doc #[deserr(default)] pub q: Option<String>, #[param(value_type = usize)] pub limit: usize, "#; let mut errors = Vec::new(); check_struct_fields_have_required( body, "SearchQuery", "body", Path::new("search/mod.rs"), &mut errors, ); assert!( !errors.is_empty(), "expected errors when no required = true/false in block above pub, got: {:?}", errors ); assert!(errors[0].contains("parameter `q`")); assert!(errors[1].contains("parameter `limit`")); } #[test] fn test_check_struct_fields_with_required() { let body = r#" #[schema(required = false)] pub q: Option<String>, #[param(required = true, value_type = usize)] pub limit: usize, "#; let mut errors = Vec::new(); check_struct_fields_have_required( body, "SearchQuery", "body", Path::new("search/mod.rs"), &mut errors, ); assert!(errors.is_empty(), "expected no errors when required is explicit: {:?}", errors); } }