Skip to main content

ferro_rs/api/
openapi.rs

1//! OpenAPI specification builder and documentation handlers.
2//!
3//! Generates OpenAPI specs from Ferro route metadata and serves
4//! interactive API documentation via ReDoc.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use ferro_rs::{build_openapi_spec, OpenApiConfig, get_registered_routes};
10//!
11//! // Build spec from registered routes
12//! let config = OpenApiConfig {
13//!     title: "My API".to_string(),
14//!     version: "1.0.0".to_string(),
15//!     description: Some("My application API".to_string()),
16//!     api_prefix: "/api/".to_string(),
17//! };
18//! let spec = build_openapi_spec(&config, &get_registered_routes());
19//!
20//! // Serve JSON spec
21//! let json = openapi_json_response(&config, &get_registered_routes());
22//!
23//! // Serve ReDoc HTML
24//! let html = openapi_docs_response(&config, &get_registered_routes());
25//! ```
26
27use crate::http::HttpResponse;
28use crate::routing::RouteInfo;
29use std::sync::OnceLock;
30use utoipa::openapi::extensions::ExtensionsBuilder;
31use utoipa::openapi::path::{HttpMethod, OperationBuilder, ParameterBuilder, ParameterIn};
32use utoipa::openapi::schema::{Object, Type};
33use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
34use utoipa::openapi::{
35    ComponentsBuilder, InfoBuilder, OpenApiBuilder, PathItem, PathsBuilder, Required,
36};
37
38/// Configuration for OpenAPI spec generation.
39#[derive(Debug, Clone)]
40pub struct OpenApiConfig {
41    /// API title displayed in documentation
42    pub title: String,
43    /// API version (e.g., "1.0.0")
44    pub version: String,
45    /// Optional description displayed in documentation
46    pub description: Option<String>,
47    /// Route path prefix filter (only routes starting with this are included)
48    pub api_prefix: String,
49}
50
51impl Default for OpenApiConfig {
52    fn default() -> Self {
53        Self {
54            title: "API Documentation".to_string(),
55            version: "1.0.0".to_string(),
56            description: None,
57            api_prefix: "/api/".to_string(),
58        }
59    }
60}
61
62/// Build an OpenAPI spec from route metadata.
63///
64/// Filters routes matching `config.api_prefix`, generates operations with
65/// auto-summaries and path parameters, and adds API key security scheme.
66pub fn build_openapi_spec(
67    config: &OpenApiConfig,
68    routes: &[RouteInfo],
69) -> utoipa::openapi::OpenApi {
70    let mut paths = PathsBuilder::new();
71
72    for route in routes
73        .iter()
74        .filter(|r| r.path.starts_with(&config.api_prefix))
75    {
76        let http_method = parse_http_method(&route.method);
77        let tag = extract_tag(&route.path);
78        let summary = auto_summary(&route.method, &route.path);
79
80        let mut op = OperationBuilder::new()
81            .tag(tag)
82            .summary(Some(summary))
83            .operation_id(route.name.clone());
84
85        // Add path parameters extracted from {param} patterns
86        for param_name in extract_path_params(&route.path) {
87            op = op.parameter(
88                ParameterBuilder::new()
89                    .name(param_name)
90                    .parameter_in(ParameterIn::Path)
91                    .required(Required::True)
92                    .schema(Some(Object::with_type(Type::String)))
93                    .build(),
94            );
95        }
96
97        // Emit x-mcp vendor extensions for AI tool discovery
98        let extensions = if route.mcp_hidden {
99            // Hidden routes only emit x-mcp-hidden, no tool name or description
100            ExtensionsBuilder::new().add("x-mcp-hidden", true).build()
101        } else {
102            let tool_name = route
103                .mcp_tool_name
104                .clone()
105                .unwrap_or_else(|| mcp_tool_name(&route.method, &route.path));
106            let description = route
107                .mcp_description
108                .clone()
109                .unwrap_or_else(|| mcp_description(&route.method, &route.path));
110
111            let mut ext = ExtensionsBuilder::new()
112                .add("x-mcp-tool-name", tool_name)
113                .add("x-mcp-description", description);
114
115            if let Some(hint) = &route.mcp_hint {
116                ext = ext.add("x-mcp-hint", hint.clone());
117            }
118
119            ext.build()
120        };
121        op = op.extensions(Some(extensions));
122
123        let operation = op.build();
124
125        // Convert route path from {param} to OpenAPI format (already correct)
126        let openapi_path = route.path.clone();
127
128        let path_item = PathItem::new(http_method, operation);
129        paths = paths.path(openapi_path, path_item);
130    }
131
132    let components = ComponentsBuilder::new()
133        .security_scheme(
134            "api_key",
135            SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("Authorization"))),
136        )
137        .build();
138
139    let info = InfoBuilder::new()
140        .title(&config.title)
141        .version(&config.version)
142        .description(config.description.as_deref())
143        .build();
144
145    OpenApiBuilder::new()
146        .info(info)
147        .paths(paths.build())
148        .components(Some(components))
149        .build()
150}
151
152/// Global cache for the generated OpenAPI spec.
153static CACHED_SPEC_JSON: OnceLock<String> = OnceLock::new();
154static CACHED_DOCS_HTML: OnceLock<String> = OnceLock::new();
155
156/// Return an HttpResponse with the OpenAPI spec as JSON.
157///
158/// The spec is generated once and cached via OnceLock.
159pub fn openapi_json_response(config: &OpenApiConfig, routes: &[RouteInfo]) -> HttpResponse {
160    let json = CACHED_SPEC_JSON.get_or_init(|| {
161        let spec = build_openapi_spec(config, routes);
162        spec.to_json()
163            .unwrap_or_else(|e| format!("{{\"error\": \"Failed to serialize spec: {e}\"}}"))
164    });
165
166    HttpResponse::text(json.clone()).header("Content-Type", "application/json")
167}
168
169/// Return an HttpResponse with ReDoc HTML documentation.
170///
171/// The HTML is generated once and cached via OnceLock.
172pub fn openapi_docs_response(config: &OpenApiConfig, routes: &[RouteInfo]) -> HttpResponse {
173    let html = CACHED_DOCS_HTML.get_or_init(|| {
174        let spec = build_openapi_spec(config, routes);
175        utoipa_redoc::Redoc::new(spec).to_html()
176    });
177
178    HttpResponse::text(html.clone()).header("Content-Type", "text/html; charset=utf-8")
179}
180
181fn parse_http_method(method: &str) -> HttpMethod {
182    match method {
183        "GET" => HttpMethod::Get,
184        "POST" => HttpMethod::Post,
185        "PUT" => HttpMethod::Put,
186        "PATCH" => HttpMethod::Patch,
187        "DELETE" => HttpMethod::Delete,
188        "OPTIONS" => HttpMethod::Options,
189        "HEAD" => HttpMethod::Head,
190        _ => HttpMethod::Get,
191    }
192}
193
194/// Extract tag from route path (first non-version segment after api prefix).
195///
196/// Examples:
197/// - "/api/v1/users/{id}" -> "users"
198/// - "/api/v1/posts" -> "posts"
199/// - "/api/users" -> "users"
200fn extract_tag(path: &str) -> String {
201    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
202
203    // Skip "api" prefix and optional version segment ("v1", "v2", etc.)
204    let start = segments.iter().position(|s| *s == "api").unwrap_or(0) + 1;
205    let start = if segments
206        .get(start)
207        .is_some_and(|s| s.starts_with('v') && s[1..].chars().all(|c| c.is_ascii_digit()))
208    {
209        start + 1
210    } else {
211        start
212    };
213
214    segments
215        .get(start)
216        .filter(|s| !s.starts_with('{'))
217        .map(|s| s.to_string())
218        .unwrap_or_else(|| "default".to_string())
219}
220
221/// Extract the resource name from the last non-parameter path segment.
222///
223/// Examples:
224/// - "/api/v1/users" -> "users"
225/// - "/api/v1/users/{id}" -> "user" (singularized)
226/// - "/api/v1/posts/{id}/comments" -> "comments"
227fn extract_resource_name(path: &str) -> String {
228    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
229
230    let resource = segments
231        .iter()
232        .rev()
233        .find(|s| !s.starts_with('{'))
234        .unwrap_or(&"resource");
235
236    resource.to_string()
237}
238
239/// Check if the path ends with a parameter (e.g., "/{id}").
240fn has_path_param(path: &str) -> bool {
241    path.split('/')
242        .next_back()
243        .is_some_and(|s| s.starts_with('{'))
244}
245
246/// Generate a human-readable summary from HTTP method and path.
247///
248/// Examples:
249/// - ("GET", "/api/v1/users") -> "List users"
250/// - ("GET", "/api/v1/users/{id}") -> "Get user"
251/// - ("POST", "/api/v1/users") -> "Create user"
252/// - ("PUT", "/api/v1/users/{id}") -> "Update user"
253/// - ("DELETE", "/api/v1/users/{id}") -> "Delete user"
254fn auto_summary(method: &str, path: &str) -> String {
255    let resource = extract_resource_name(path);
256    let singular = singularize(&resource);
257
258    match (method, has_path_param(path)) {
259        ("GET", false) => format!("List {resource}"),
260        ("GET", true) => format!("Get {singular}"),
261        ("POST", _) => format!("Create {singular}"),
262        ("PUT" | "PATCH", _) => format!("Update {singular}"),
263        ("DELETE", _) => format!("Delete {singular}"),
264        _ => format!("{method} {path}"),
265    }
266}
267
268/// Naive singularization: strip trailing 's' if present.
269fn singularize(word: &str) -> String {
270    if word.ends_with('s') && word.len() > 1 {
271        word[..word.len() - 1].to_string()
272    } else {
273        word.to_string()
274    }
275}
276
277/// Generate a snake_case tool name for MCP consumption.
278///
279/// Examples:
280/// - ("GET", "/api/v1/users") -> "list_users"
281/// - ("GET", "/api/v1/users/{id}") -> "get_user"
282/// - ("POST", "/api/v1/users") -> "create_user"
283/// - ("DELETE", "/api/v1/users/{id}") -> "delete_user"
284fn mcp_tool_name(method: &str, path: &str) -> String {
285    let resource = extract_resource_name(path);
286    let singular = singularize(&resource);
287
288    match (method, has_path_param(path)) {
289        ("GET", false) => format!("list_{resource}"),
290        ("GET", true) => format!("get_{singular}"),
291        ("POST", _) => format!("create_{singular}"),
292        ("PUT" | "PATCH", _) => format!("update_{singular}"),
293        ("DELETE", _) => format!("delete_{singular}"),
294        _ => format!("{}_{resource}", method.to_lowercase()),
295    }
296}
297
298/// Generate an AI-optimized description for MCP consumption.
299///
300/// Examples:
301/// - ("GET", "/api/v1/users") -> "List all users with optional pagination."
302/// - ("GET", "/api/v1/users/{id}") -> "Get a single user by ID."
303/// - ("POST", "/api/v1/users") -> "Create a new user."
304fn mcp_description(method: &str, path: &str) -> String {
305    let resource = extract_resource_name(path);
306    let singular = singularize(&resource);
307
308    match (method, has_path_param(path)) {
309        ("GET", false) => format!("List all {resource} with optional pagination."),
310        ("GET", true) => format!("Get a single {singular} by ID."),
311        ("POST", _) => format!("Create a new {singular}."),
312        ("PUT" | "PATCH", _) => format!("Update an existing {singular} by ID."),
313        ("DELETE", _) => format!("Permanently delete a {singular} by ID."),
314        _ => format!("{method} {path}"),
315    }
316}
317
318/// Extract path parameter names from `{param}` patterns.
319fn extract_path_params(path: &str) -> Vec<String> {
320    path.split('/')
321        .filter(|s| s.starts_with('{') && s.ends_with('}'))
322        .map(|s| s[1..s.len() - 1].to_string())
323        .collect()
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn auto_summary_list() {
332        assert_eq!(auto_summary("GET", "/api/v1/users"), "List users");
333    }
334
335    #[test]
336    fn auto_summary_get() {
337        assert_eq!(auto_summary("GET", "/api/v1/users/{id}"), "Get user");
338    }
339
340    #[test]
341    fn auto_summary_create() {
342        assert_eq!(auto_summary("POST", "/api/v1/users"), "Create user");
343    }
344
345    #[test]
346    fn auto_summary_update_put() {
347        assert_eq!(auto_summary("PUT", "/api/v1/users/{id}"), "Update user");
348    }
349
350    #[test]
351    fn auto_summary_update_patch() {
352        assert_eq!(auto_summary("PATCH", "/api/v1/users/{id}"), "Update user");
353    }
354
355    #[test]
356    fn auto_summary_delete() {
357        assert_eq!(auto_summary("DELETE", "/api/v1/users/{id}"), "Delete user");
358    }
359
360    #[test]
361    fn auto_summary_fallback() {
362        assert_eq!(
363            auto_summary("OPTIONS", "/api/v1/health"),
364            "OPTIONS /api/v1/health"
365        );
366    }
367
368    #[test]
369    fn extract_resource_name_collection() {
370        assert_eq!(extract_resource_name("/api/v1/users"), "users");
371    }
372
373    #[test]
374    fn extract_resource_name_with_param() {
375        assert_eq!(extract_resource_name("/api/v1/users/{id}"), "users");
376    }
377
378    #[test]
379    fn extract_resource_name_nested() {
380        assert_eq!(
381            extract_resource_name("/api/v1/posts/{id}/comments"),
382            "comments"
383        );
384    }
385
386    #[test]
387    fn extract_tag_simple() {
388        assert_eq!(extract_tag("/api/v1/users"), "users");
389    }
390
391    #[test]
392    fn extract_tag_with_param() {
393        assert_eq!(extract_tag("/api/v1/users/{id}"), "users");
394    }
395
396    #[test]
397    fn extract_tag_no_version() {
398        assert_eq!(extract_tag("/api/users"), "users");
399    }
400
401    #[test]
402    fn extract_tag_nested() {
403        assert_eq!(extract_tag("/api/v1/posts/{id}/comments"), "posts");
404    }
405
406    #[test]
407    fn extract_tag_default() {
408        assert_eq!(extract_tag("/api/v1/{id}"), "default");
409    }
410
411    #[test]
412    fn extract_path_params_none() {
413        assert!(extract_path_params("/api/v1/users").is_empty());
414    }
415
416    #[test]
417    fn extract_path_params_single() {
418        assert_eq!(extract_path_params("/api/v1/users/{id}"), vec!["id"]);
419    }
420
421    #[test]
422    fn extract_path_params_multiple() {
423        assert_eq!(
424            extract_path_params("/api/v1/users/{user_id}/posts/{post_id}"),
425            vec!["user_id", "post_id"]
426        );
427    }
428
429    #[test]
430    fn singularize_plural() {
431        assert_eq!(singularize("users"), "user");
432    }
433
434    #[test]
435    fn singularize_already_singular() {
436        assert_eq!(singularize("user"), "user");
437    }
438
439    #[test]
440    fn singularize_single_char() {
441        assert_eq!(singularize("s"), "s");
442    }
443
444    #[test]
445    fn mcp_tool_name_list() {
446        assert_eq!(mcp_tool_name("GET", "/api/v1/users"), "list_users");
447    }
448
449    #[test]
450    fn mcp_tool_name_get() {
451        assert_eq!(mcp_tool_name("GET", "/api/v1/users/{id}"), "get_user");
452    }
453
454    #[test]
455    fn mcp_tool_name_create() {
456        assert_eq!(mcp_tool_name("POST", "/api/v1/users"), "create_user");
457    }
458
459    #[test]
460    fn mcp_tool_name_update_put() {
461        assert_eq!(mcp_tool_name("PUT", "/api/v1/users/{id}"), "update_user");
462    }
463
464    #[test]
465    fn mcp_tool_name_update_patch() {
466        assert_eq!(mcp_tool_name("PATCH", "/api/v1/users/{id}"), "update_user");
467    }
468
469    #[test]
470    fn mcp_tool_name_delete() {
471        assert_eq!(mcp_tool_name("DELETE", "/api/v1/users/{id}"), "delete_user");
472    }
473
474    #[test]
475    fn mcp_tool_name_nested() {
476        assert_eq!(
477            mcp_tool_name("GET", "/api/v1/posts/{id}/comments"),
478            "list_comments"
479        );
480    }
481
482    #[test]
483    fn mcp_tool_name_fallback() {
484        assert_eq!(mcp_tool_name("OPTIONS", "/api/v1/health"), "options_health");
485    }
486
487    #[test]
488    fn mcp_description_list() {
489        assert_eq!(
490            mcp_description("GET", "/api/v1/users"),
491            "List all users with optional pagination."
492        );
493    }
494
495    #[test]
496    fn mcp_description_get() {
497        assert_eq!(
498            mcp_description("GET", "/api/v1/users/{id}"),
499            "Get a single user by ID."
500        );
501    }
502
503    #[test]
504    fn mcp_description_create() {
505        assert_eq!(
506            mcp_description("POST", "/api/v1/users"),
507            "Create a new user."
508        );
509    }
510
511    #[test]
512    fn mcp_description_update_put() {
513        assert_eq!(
514            mcp_description("PUT", "/api/v1/users/{id}"),
515            "Update an existing user by ID."
516        );
517    }
518
519    #[test]
520    fn mcp_description_update_patch() {
521        assert_eq!(
522            mcp_description("PATCH", "/api/v1/users/{id}"),
523            "Update an existing user by ID."
524        );
525    }
526
527    #[test]
528    fn mcp_description_delete() {
529        assert_eq!(
530            mcp_description("DELETE", "/api/v1/users/{id}"),
531            "Permanently delete a user by ID."
532        );
533    }
534
535    #[test]
536    fn mcp_description_nested() {
537        assert_eq!(
538            mcp_description("GET", "/api/v1/posts/{id}/comments"),
539            "List all comments with optional pagination."
540        );
541    }
542
543    #[test]
544    fn mcp_description_fallback() {
545        assert_eq!(
546            mcp_description("OPTIONS", "/api/v1/health"),
547            "OPTIONS /api/v1/health"
548        );
549    }
550
551    #[test]
552    fn build_spec_basic() {
553        let config = OpenApiConfig {
554            title: "Test API".to_string(),
555            version: "1.0.0".to_string(),
556            description: Some("Test description".to_string()),
557            api_prefix: "/api/".to_string(),
558        };
559
560        let routes = vec![
561            RouteInfo {
562                method: "GET".to_string(),
563                path: "/api/v1/users".to_string(),
564                name: Some("api.users.index".to_string()),
565                middleware: vec![],
566                ..Default::default()
567            },
568            RouteInfo {
569                method: "POST".to_string(),
570                path: "/api/v1/users".to_string(),
571                name: Some("api.users.store".to_string()),
572                middleware: vec![],
573                ..Default::default()
574            },
575            RouteInfo {
576                method: "GET".to_string(),
577                path: "/api/v1/users/{id}".to_string(),
578                name: Some("api.users.show".to_string()),
579                middleware: vec![],
580                ..Default::default()
581            },
582            RouteInfo {
583                method: "PUT".to_string(),
584                path: "/api/v1/users/{id}".to_string(),
585                name: Some("api.users.update".to_string()),
586                middleware: vec![],
587                ..Default::default()
588            },
589            RouteInfo {
590                method: "DELETE".to_string(),
591                path: "/api/v1/users/{id}".to_string(),
592                name: Some("api.users.destroy".to_string()),
593                middleware: vec![],
594                ..Default::default()
595            },
596        ];
597
598        let spec = build_openapi_spec(&config, &routes);
599
600        assert_eq!(spec.info.title, "Test API");
601        assert_eq!(spec.info.version, "1.0.0");
602        assert_eq!(spec.info.description, Some("Test description".to_string()));
603
604        // Should have paths for both /api/v1/users and /api/v1/users/{id}
605        assert_eq!(spec.paths.paths.len(), 2);
606        assert!(spec.paths.paths.contains_key("/api/v1/users"));
607        assert!(spec.paths.paths.contains_key("/api/v1/users/{id}"));
608
609        // Check GET /api/v1/users has correct operation
610        let users_path = spec.paths.paths.get("/api/v1/users").unwrap();
611        let get_op = users_path.get.as_ref().unwrap();
612        assert_eq!(get_op.summary, Some("List users".to_string()));
613        assert_eq!(get_op.operation_id, Some("api.users.index".to_string()));
614
615        // Check POST /api/v1/users
616        let post_op = users_path.post.as_ref().unwrap();
617        assert_eq!(post_op.summary, Some("Create user".to_string()));
618
619        // Check path parameters on /api/v1/users/{id}
620        let user_path = spec.paths.paths.get("/api/v1/users/{id}").unwrap();
621        let get_user_op = user_path.get.as_ref().unwrap();
622        assert_eq!(get_user_op.summary, Some("Get user".to_string()));
623        let params = get_user_op.parameters.as_ref().unwrap();
624        assert_eq!(params.len(), 1);
625        assert_eq!(params[0].name, "id");
626
627        // Check x-mcp extensions on GET /api/v1/users
628        let get_ext = get_op.extensions.as_ref().unwrap();
629        assert_eq!(
630            get_ext.get("x-mcp-tool-name").unwrap(),
631            &serde_json::Value::String("list_users".to_string())
632        );
633        assert_eq!(
634            get_ext.get("x-mcp-description").unwrap(),
635            &serde_json::Value::String("List all users with optional pagination.".to_string())
636        );
637
638        // Check x-mcp extensions on DELETE /api/v1/users/{id}
639        let delete_op = user_path.delete.as_ref().unwrap();
640        let delete_ext = delete_op.extensions.as_ref().unwrap();
641        assert_eq!(
642            delete_ext.get("x-mcp-tool-name").unwrap(),
643            &serde_json::Value::String("delete_user".to_string())
644        );
645        assert_eq!(
646            delete_ext.get("x-mcp-description").unwrap(),
647            &serde_json::Value::String("Permanently delete a user by ID.".to_string())
648        );
649
650        // Check security scheme
651        let components = spec.components.as_ref().unwrap();
652        assert!(components.security_schemes.contains_key("api_key"));
653    }
654
655    #[test]
656    fn build_spec_filters_non_api_routes() {
657        let config = OpenApiConfig::default();
658
659        let routes = vec![
660            RouteInfo {
661                method: "GET".to_string(),
662                path: "/api/v1/users".to_string(),
663                name: None,
664                middleware: vec![],
665                ..Default::default()
666            },
667            RouteInfo {
668                method: "GET".to_string(),
669                path: "/dashboard".to_string(),
670                name: None,
671                middleware: vec![],
672                ..Default::default()
673            },
674            RouteInfo {
675                method: "GET".to_string(),
676                path: "/login".to_string(),
677                name: None,
678                middleware: vec![],
679                ..Default::default()
680            },
681        ];
682
683        let spec = build_openapi_spec(&config, &routes);
684
685        // Only the /api/ route should be included
686        assert_eq!(spec.paths.paths.len(), 1);
687        assert!(spec.paths.paths.contains_key("/api/v1/users"));
688    }
689
690    #[test]
691    fn build_spec_empty_routes() {
692        let config = OpenApiConfig::default();
693        let spec = build_openapi_spec(&config, &[]);
694
695        assert!(spec.paths.paths.is_empty());
696        assert_eq!(spec.info.title, "API Documentation");
697    }
698
699    #[test]
700    fn spec_serializes_to_json() {
701        let config = OpenApiConfig::default();
702        let routes = vec![RouteInfo {
703            method: "GET".to_string(),
704            path: "/api/v1/health".to_string(),
705            name: None,
706            middleware: vec![],
707            ..Default::default()
708        }];
709
710        let spec = build_openapi_spec(&config, &routes);
711        let json = spec.to_json().unwrap();
712
713        assert!(json.contains("\"openapi\""));
714        assert!(json.contains("\"info\""));
715        assert!(json.contains("\"paths\""));
716        assert!(json.contains("/api/v1/health"));
717    }
718
719    #[test]
720    fn spec_extensions_in_json() {
721        let config = OpenApiConfig::default();
722        let routes = vec![
723            RouteInfo {
724                method: "GET".to_string(),
725                path: "/api/v1/users".to_string(),
726                name: Some("users.index".to_string()),
727                middleware: vec![],
728                ..Default::default()
729            },
730            RouteInfo {
731                method: "POST".to_string(),
732                path: "/api/v1/users".to_string(),
733                name: Some("users.store".to_string()),
734                middleware: vec![],
735                ..Default::default()
736            },
737        ];
738
739        let spec = build_openapi_spec(&config, &routes);
740        let json_str = spec.to_json().unwrap();
741        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
742
743        // Verify GET operation has x-mcp extensions
744        let get_op = &parsed["paths"]["/api/v1/users"]["get"];
745        assert_eq!(get_op["x-mcp-tool-name"], "list_users");
746        assert_eq!(
747            get_op["x-mcp-description"],
748            "List all users with optional pagination."
749        );
750
751        // Verify POST operation has x-mcp extensions
752        let post_op = &parsed["paths"]["/api/v1/users"]["post"];
753        assert_eq!(post_op["x-mcp-tool-name"], "create_user");
754        assert_eq!(post_op["x-mcp-description"], "Create a new user.");
755    }
756
757    #[test]
758    fn spec_explicit_mcp_tool_name_override() {
759        let config = OpenApiConfig::default();
760        let routes = vec![RouteInfo {
761            method: "GET".to_string(),
762            path: "/api/v1/users".to_string(),
763            name: None,
764            middleware: vec![],
765            mcp_tool_name: Some("fetch_all_users".to_string()),
766            ..Default::default()
767        }];
768
769        let spec = build_openapi_spec(&config, &routes);
770        let json_str = spec.to_json().unwrap();
771        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
772
773        let get_op = &parsed["paths"]["/api/v1/users"]["get"];
774        assert_eq!(get_op["x-mcp-tool-name"], "fetch_all_users");
775        // Description should still be auto-generated
776        assert_eq!(
777            get_op["x-mcp-description"],
778            "List all users with optional pagination."
779        );
780    }
781
782    #[test]
783    fn spec_mcp_hidden_route() {
784        let config = OpenApiConfig::default();
785        let routes = vec![RouteInfo {
786            method: "GET".to_string(),
787            path: "/api/v1/internal/health".to_string(),
788            name: None,
789            middleware: vec![],
790            mcp_hidden: true,
791            ..Default::default()
792        }];
793
794        let spec = build_openapi_spec(&config, &routes);
795        let json_str = spec.to_json().unwrap();
796        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
797
798        let get_op = &parsed["paths"]["/api/v1/internal/health"]["get"];
799        assert_eq!(get_op["x-mcp-hidden"], true);
800        // Hidden routes must not emit tool-name or description
801        assert!(get_op.get("x-mcp-tool-name").is_none());
802        assert!(get_op.get("x-mcp-description").is_none());
803    }
804
805    #[test]
806    fn spec_mcp_hint_extension() {
807        let config = OpenApiConfig::default();
808        let routes = vec![RouteInfo {
809            method: "POST".to_string(),
810            path: "/api/v1/users".to_string(),
811            name: None,
812            middleware: vec![],
813            mcp_hint: Some("Requires admin role. Email must be unique.".to_string()),
814            ..Default::default()
815        }];
816
817        let spec = build_openapi_spec(&config, &routes);
818        let json_str = spec.to_json().unwrap();
819        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
820
821        let post_op = &parsed["paths"]["/api/v1/users"]["post"];
822        assert_eq!(
823            post_op["x-mcp-hint"],
824            "Requires admin role. Email must be unique."
825        );
826        // Tool name and description should still be present
827        assert_eq!(post_op["x-mcp-tool-name"], "create_user");
828        assert_eq!(post_op["x-mcp-description"], "Create a new user.");
829    }
830
831    #[test]
832    fn spec_no_mcp_overrides_uses_auto_generated() {
833        let config = OpenApiConfig::default();
834        let routes = vec![RouteInfo {
835            method: "DELETE".to_string(),
836            path: "/api/v1/users/{id}".to_string(),
837            name: None,
838            middleware: vec![],
839            ..Default::default()
840        }];
841
842        let spec = build_openapi_spec(&config, &routes);
843        let json_str = spec.to_json().unwrap();
844        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
845
846        let delete_op = &parsed["paths"]["/api/v1/users/{id}"]["delete"];
847        assert_eq!(delete_op["x-mcp-tool-name"], "delete_user");
848        assert_eq!(
849            delete_op["x-mcp-description"],
850            "Permanently delete a user by ID."
851        );
852        // No hint or hidden flags
853        assert!(delete_op.get("x-mcp-hint").is_none());
854        assert!(delete_op.get("x-mcp-hidden").is_none());
855    }
856
857    #[test]
858    fn spec_mcp_description_override() {
859        let config = OpenApiConfig::default();
860        let routes = vec![RouteInfo {
861            method: "GET".to_string(),
862            path: "/api/v1/users".to_string(),
863            name: None,
864            middleware: vec![],
865            mcp_description: Some("Retrieve all active users, sorted by name.".to_string()),
866            ..Default::default()
867        }];
868
869        let spec = build_openapi_spec(&config, &routes);
870        let json_str = spec.to_json().unwrap();
871        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
872
873        let get_op = &parsed["paths"]["/api/v1/users"]["get"];
874        assert_eq!(
875            get_op["x-mcp-description"],
876            "Retrieve all active users, sorted by name."
877        );
878        // Tool name should still be auto-generated
879        assert_eq!(get_op["x-mcp-tool-name"], "list_users");
880    }
881}