Skip to main content

ultraapi/
lib.rs

1pub mod openapi;
2
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use axum::Router;
6use serde::Serialize;
7use std::any::{Any, TypeId};
8use std::collections::HashMap;
9use std::sync::Arc;
10
11// Re-exports
12pub use axum;
13pub use inventory;
14pub use regex;
15pub use schemars;
16pub use serde;
17pub use serde_json;
18pub use ultraapi_macros::{api_model, delete, get, post, put};
19
20pub mod prelude {
21    pub use crate::axum::extract::Query;
22    pub use crate::{api_model, delete, get, post, put};
23    pub use crate::{ApiError, Dep, State, UltraApiApp, UltraApiRouter, Validate};
24}
25
26/// Validation trait generated by api_model attribute
27pub trait Validate {
28    fn validate(&self) -> Result<(), Vec<String>>;
29}
30
31impl Validate for () {
32    fn validate(&self) -> Result<(), Vec<String>> {
33        Ok(())
34    }
35}
36
37#[async_trait::async_trait]
38pub trait AsyncValidate: Send + Sync {
39    async fn validate_async(&self, _state: &AppState) -> Result<(), Vec<String>> {
40        Ok(())
41    }
42}
43
44#[async_trait::async_trait]
45impl AsyncValidate for () {
46    async fn validate_async(&self, _state: &AppState) -> Result<(), Vec<String>> {
47        Ok(())
48    }
49}
50
51#[macro_export]
52macro_rules! impl_async_validate {
53    ($ty:ty, $sync_fn:ident) => {
54        #[async_trait::async_trait]
55        impl $crate::AsyncValidate for $ty {
56            async fn validate_async(&self, _state: &$crate::AppState) -> Result<(), Vec<String>> {
57                $sync_fn(self)
58            }
59        }
60    };
61}
62
63/// Trait for schema patches from validation attributes
64#[doc(hidden)]
65pub trait HasSchemaPatches {
66    fn patch_schema(props: &mut HashMap<String, openapi::PropertyPatch>);
67}
68
69/// Application state holding dependency injection container
70#[derive(Clone)]
71pub struct AppState {
72    deps: Arc<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>,
73}
74
75impl Default for AppState {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl AppState {
82    pub fn new() -> Self {
83        Self {
84            deps: Arc::new(HashMap::new()),
85        }
86    }
87
88    pub fn get<T: 'static + Send + Sync>(&self) -> Option<Arc<T>> {
89        self.deps
90            .get(&TypeId::of::<T>())
91            .and_then(|v| v.clone().downcast::<T>().ok())
92    }
93}
94
95/// Dependency injection extractor
96pub struct Dep<T: 'static + Send + Sync>(Arc<T>);
97
98impl<T: 'static + Send + Sync> Dep<T> {
99    pub fn from_app_state(state: &AppState) -> Result<Self, ApiError> {
100        state.get::<T>().map(Dep).ok_or_else(|| {
101            ApiError::internal(format!(
102                "Dependency not registered: {}",
103                std::any::type_name::<T>()
104            ))
105        })
106    }
107}
108
109impl<T: 'static + Send + Sync> std::ops::Deref for Dep<T> {
110    type Target = T;
111    fn deref(&self) -> &T {
112        &self.0
113    }
114}
115
116/// Axum-style state extractor — alternative to Dep<T>
117/// Both work identically; choose based on your preferred style.
118pub struct State<T: 'static + Send + Sync>(Arc<T>);
119
120impl<T: 'static + Send + Sync> State<T> {
121    pub fn from_app_state(state: &AppState) -> Result<Self, ApiError> {
122        state.get::<T>().map(State).ok_or_else(|| {
123            ApiError::internal(format!(
124                "State not registered: {}",
125                std::any::type_name::<T>()
126            ))
127        })
128    }
129}
130
131impl<T: 'static + Send + Sync> std::ops::Deref for State<T> {
132    type Target = T;
133    fn deref(&self) -> &T {
134        &self.0
135    }
136}
137
138/// API Error type
139#[derive(Debug, Serialize)]
140pub struct ApiError {
141    #[serde(skip)]
142    pub status: StatusCode,
143    pub error: String,
144    #[serde(skip_serializing_if = "Vec::is_empty")]
145    pub details: Vec<String>,
146}
147
148impl ApiError {
149    pub fn unauthorized(msg: impl Into<String>) -> Self {
150        Self {
151            status: StatusCode::UNAUTHORIZED,
152            error: msg.into(),
153            details: vec![],
154        }
155    }
156
157    pub fn bad_request(msg: String) -> Self {
158        Self {
159            status: StatusCode::BAD_REQUEST,
160            error: msg,
161            details: vec![],
162        }
163    }
164
165    pub fn not_found(msg: String) -> Self {
166        Self {
167            status: StatusCode::NOT_FOUND,
168            error: msg,
169            details: vec![],
170        }
171    }
172
173    pub fn internal(msg: String) -> Self {
174        Self {
175            status: StatusCode::INTERNAL_SERVER_ERROR,
176            error: msg,
177            details: vec![],
178        }
179    }
180
181    pub fn validation_error(errors: Vec<String>) -> Self {
182        Self {
183            status: StatusCode::UNPROCESSABLE_ENTITY,
184            error: "Validation failed".into(),
185            details: errors,
186        }
187    }
188}
189
190impl IntoResponse for ApiError {
191    fn into_response(self) -> Response {
192        let body = serde_json::to_string(&self)
193            .unwrap_or_else(|_| r#"{"error":"Internal server error"}"#.to_string());
194        (self.status, [("content-type", "application/json")], body).into_response()
195    }
196}
197
198/// Route information collected by proc macros
199pub struct RouteInfo {
200    pub path: &'static str,
201    pub axum_path: &'static str,
202    pub method: &'static str,
203    pub handler_name: &'static str,
204    pub response_type_name: &'static str,
205    pub is_result_return: bool,
206    pub is_vec_response: bool,
207    pub vec_inner_type_name: &'static str,
208    pub parameters: &'static [openapi::Parameter],
209    pub has_body: bool,
210    pub body_type_name: &'static str,
211    pub success_status: u16,
212    pub description: &'static str,
213    pub tags: &'static [&'static str],
214    pub security: &'static [&'static str],
215    pub query_params_fn: Option<fn() -> Vec<openapi::DynParameter>>,
216    pub register_fn: fn(Router<AppState>) -> Router<AppState>,
217    pub method_router_fn: fn() -> axum::routing::MethodRouter<AppState>,
218}
219
220inventory::collect!(&'static RouteInfo);
221
222/// Schema information collected by api_model attribute
223pub struct SchemaInfo {
224    pub name: &'static str,
225    pub schema_fn: fn() -> openapi::Schema,
226    pub nested_fn: fn() -> std::collections::HashMap<String, openapi::Schema>,
227}
228
229inventory::collect!(SchemaInfo);
230
231/// A resolved route with runtime prefix and merged tags/security
232pub struct ResolvedRoute {
233    pub route_info: &'static RouteInfo,
234    pub prefix: String,
235    pub extra_tags: Vec<String>,
236    pub extra_security: Vec<String>,
237}
238
239impl ResolvedRoute {
240    /// Full path = prefix + route's original path
241    pub fn full_path(&self) -> String {
242        let base = self.route_info.path;
243        if self.prefix.is_empty() {
244            base.to_string()
245        } else {
246            format!("{}{}", self.prefix, base)
247        }
248    }
249
250    /// Full axum path = prefix + route's original axum_path
251    pub fn full_axum_path(&self) -> String {
252        let base = self.route_info.axum_path;
253        if self.prefix.is_empty() {
254            base.to_string()
255        } else {
256            format!("{}{}", self.prefix, base)
257        }
258    }
259
260    /// Merged tags: router-level + route-level
261    pub fn merged_tags(&self) -> Vec<String> {
262        let mut tags: Vec<String> = self.extra_tags.clone();
263        for t in self.route_info.tags {
264            if !tags.contains(&t.to_string()) {
265                tags.push(t.to_string());
266            }
267        }
268        tags
269    }
270
271    /// Merged security: router-level + route-level
272    pub fn merged_security(&self) -> Vec<&str> {
273        let mut sec: Vec<&str> = self.extra_security.iter().map(|s| s.as_str()).collect();
274        for s in self.route_info.security {
275            if !sec.contains(s) {
276                sec.push(s);
277            }
278        }
279        sec
280    }
281}
282
283/// A FastAPI-style router with prefix, shared tags, security, deps, and nested routers
284pub struct UltraApiRouter {
285    prefix: String,
286    routes: Vec<&'static RouteInfo>,
287    tags: Vec<String>,
288    security: Vec<String>,
289    deps: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
290    children: Vec<UltraApiRouter>,
291}
292
293impl UltraApiRouter {
294    pub fn new(prefix: &str) -> Self {
295        Self {
296            prefix: prefix.to_string(),
297            routes: Vec::new(),
298            tags: Vec::new(),
299            security: Vec::new(),
300            deps: HashMap::new(),
301            children: Vec::new(),
302        }
303    }
304
305    pub fn route(mut self, route: &'static RouteInfo) -> Self {
306        self.routes.push(route);
307        self
308    }
309
310    pub fn tag(mut self, tag: &str) -> Self {
311        self.tags.push(tag.to_string());
312        self
313    }
314
315    pub fn security(mut self, scheme: &str) -> Self {
316        self.security.push(scheme.to_string());
317        self
318    }
319
320    pub fn dep<T: 'static + Send + Sync>(mut self, dep: T) -> Self {
321        self.deps.insert(TypeId::of::<T>(), Arc::new(dep));
322        self
323    }
324
325    pub fn include(mut self, child: UltraApiRouter) -> Self {
326        self.children.push(child);
327        self
328    }
329
330    /// Flatten this router tree into resolved routes, accumulating prefix/tags/security
331    pub fn resolve(
332        &self,
333        parent_prefix: &str,
334        parent_tags: &[String],
335        parent_security: &[String],
336    ) -> Vec<ResolvedRoute> {
337        let full_prefix = format!("{}{}", parent_prefix, self.prefix);
338        let mut merged_tags: Vec<String> = parent_tags.to_vec();
339        for t in &self.tags {
340            if !merged_tags.contains(t) {
341                merged_tags.push(t.clone());
342            }
343        }
344        let mut merged_security: Vec<String> = parent_security.to_vec();
345        for s in &self.security {
346            if !merged_security.contains(s) {
347                merged_security.push(s.clone());
348            }
349        }
350
351        let mut resolved = Vec::new();
352        for route in &self.routes {
353            resolved.push(ResolvedRoute {
354                route_info: route,
355                prefix: full_prefix.clone(),
356                extra_tags: merged_tags.clone(),
357                extra_security: merged_security.clone(),
358            });
359        }
360        for child in &self.children {
361            resolved.extend(child.resolve(&full_prefix, &merged_tags, &merged_security));
362        }
363        resolved
364    }
365
366    /// Collect all deps from this router tree
367    pub fn collect_deps(&self) -> HashMap<TypeId, Arc<dyn Any + Send + Sync>> {
368        let mut all = self.deps.clone();
369        for child in &self.children {
370            all.extend(child.collect_deps());
371        }
372        all
373    }
374}
375
376/// Swagger UI serving mode
377#[derive(Debug, Clone)]
378pub enum SwaggerMode {
379    /// Load Swagger UI assets from a CDN URL
380    Cdn(String),
381    /// Use embedded Scalar API reference (works offline)
382    Embedded,
383}
384
385/// The main application struct
386pub struct UltraApiApp {
387    deps: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
388    /// Dependency overrides for testing - these take precedence over regular deps
389    dep_overrides: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
390    title: String,
391    version: String,
392    description: Option<String>,
393    contact: Option<openapi::Contact>,
394    license: Option<openapi::License>,
395    swagger_mode: SwaggerMode,
396    servers: Vec<openapi::Server>,
397    security_schemes: HashMap<String, openapi::SecurityScheme>,
398    routers: Vec<UltraApiRouter>,
399}
400
401impl Default for UltraApiApp {
402    fn default() -> Self {
403        Self::new()
404    }
405}
406
407impl UltraApiApp {
408    pub fn new() -> Self {
409        Self {
410            deps: HashMap::new(),
411            dep_overrides: HashMap::new(),
412            title: env!("CARGO_PKG_NAME").to_string(),
413            version: env!("CARGO_PKG_VERSION").to_string(),
414            description: None,
415            contact: None,
416            license: None,
417            swagger_mode: SwaggerMode::Embedded,
418            servers: Vec::new(),
419            security_schemes: HashMap::new(),
420            routers: Vec::new(),
421        }
422    }
423
424    pub fn title(mut self, title: &str) -> Self {
425        self.title = title.to_string();
426        self
427    }
428
429    pub fn version(mut self, version: &str) -> Self {
430        self.version = version.to_string();
431        self
432    }
433
434    pub fn description(mut self, desc: &str) -> Self {
435        self.description = Some(desc.to_string());
436        self
437    }
438
439    pub fn contact(mut self, name: &str, email: &str, url: &str) -> Self {
440        self.contact = Some(openapi::Contact {
441            name: Some(name.to_string()),
442            email: Some(email.to_string()),
443            url: Some(url.to_string()),
444        });
445        self
446    }
447
448    pub fn license(mut self, name: &str, url: &str) -> Self {
449        self.license = Some(openapi::License {
450            name: name.to_string(),
451            url: Some(url.to_string()),
452        });
453        self
454    }
455
456    pub fn swagger_mode(mut self, mode: SwaggerMode) -> Self {
457        self.swagger_mode = mode;
458        self
459    }
460
461    pub fn swagger_cdn(mut self, url: &str) -> Self {
462        self.swagger_mode = SwaggerMode::Cdn(url.to_string());
463        self
464    }
465
466    pub fn dep<T: 'static + Send + Sync>(mut self, dep: T) -> Self {
467        self.deps.insert(TypeId::of::<T>(), Arc::new(dep));
468        self
469    }
470
471    /// Override a dependency for testing.
472    ///
473    /// This allows replacing registered dependencies with mock values during testing.
474    /// Overrides take precedence over regular dependencies registered via [`dep()`](UltraApiApp::dep).
475    ///
476    /// # Example
477    ///
478    /// ```ignore
479    /// use ultraapi::{UltraApiApp, Dep};
480    ///
481    /// #[derive(Clone)]
482    /// struct DatabasePool { /* ... */ }
483    ///
484    /// #[derive(Clone)]
485    /// struct MockDatabase { pub data: Vec<String> }
486    ///
487    /// #[get("/items")]
488    /// async fn get_items(dep(pool): Dep<DatabasePool>) -> Vec<String> {
489    ///     vec![]
490    /// }
491    ///
492    /// #[tokio::test]
493    /// async fn test_with_mock_db() {
494    ///     let mock_db = MockDatabase { data: vec!["test".into()] };
495    ///
496    ///     let app = UltraApiApp::new()
497    ///         .dep(DatabasePool { /* real config */ })
498    ///         .override_dep(mock_db)
499    ///         .route(get_items);
500    /// }
501    /// ```
502    pub fn override_dep<T: 'static + Send + Sync>(mut self, dep: T) -> Self {
503        self.dep_overrides.insert(TypeId::of::<T>(), Arc::new(dep));
504        self
505    }
506
507    /// Check if a dependency override exists for the given type.
508    pub fn has_override<T: 'static + Send + Sync>(&self) -> bool {
509        self.dep_overrides.contains_key(&TypeId::of::<T>())
510    }
511
512    /// Clear all dependency overrides.
513    pub fn clear_overrides(mut self) -> Self {
514        self.dep_overrides.clear();
515        self
516    }
517
518    pub fn server(mut self, url: &str) -> Self {
519        self.servers.push(openapi::Server {
520            url: url.to_string(),
521        });
522        self
523    }
524
525    pub fn security_scheme(mut self, name: &str, scheme: openapi::SecurityScheme) -> Self {
526        self.security_schemes.insert(name.to_string(), scheme);
527        self
528    }
529
530    pub fn bearer_auth(self) -> Self {
531        self.security_scheme(
532            "bearerAuth",
533            openapi::SecurityScheme {
534                scheme_type: "http".to_string(),
535                scheme: Some("bearer".to_string()),
536                bearer_format: None,
537                name: None,
538                location: None,
539            },
540        )
541    }
542
543    pub fn include(mut self, router: UltraApiRouter) -> Self {
544        self.routers.push(router);
545        self
546    }
547
548    /// Check if routers were explicitly included
549    pub fn has_explicit_routes(&self) -> bool {
550        !self.routers.is_empty()
551    }
552
553    /// Resolve all routes from included routers
554    pub fn resolve_routes(&self) -> Vec<ResolvedRoute> {
555        let mut resolved = Vec::new();
556        for router in &self.routers {
557            resolved.extend(router.resolve("", &[], &[]));
558        }
559        resolved
560    }
561
562    pub fn into_router(self) -> Router {
563        let spec = self.generate_openapi_spec();
564        let swagger_html = self.generate_swagger_html();
565        let has_explicit = self.has_explicit_routes();
566        let resolved = if has_explicit {
567            self.resolve_routes()
568        } else {
569            Vec::new()
570        };
571        let spec_json =
572            serde_json::to_string_pretty(&spec.to_json_with_query_params(&self.routers))
573                .expect("Failed to serialize OpenAPI spec");
574
575        // Merge deps from routers
576        let mut all_deps = self.deps;
577        for router in &self.routers {
578            all_deps.extend(router.collect_deps());
579        }
580
581        let state = AppState {
582            deps: Arc::new(all_deps),
583        };
584
585        let mut app = Router::new();
586
587        if has_explicit {
588            for r in &resolved {
589                let axum_path = r.full_axum_path();
590                let method_router = (r.route_info.method_router_fn)();
591                app = app.route(&axum_path, method_router);
592            }
593        } else {
594            for route in inventory::iter::<&RouteInfo> {
595                app = (route.register_fn)(app);
596            }
597        }
598
599        let spec_json_clone = spec_json.clone();
600        app = app.route(
601            "/openapi.json",
602            axum::routing::get(move || {
603                let spec = spec_json_clone.clone();
604                async move { (StatusCode::OK, [("content-type", "application/json")], spec) }
605            }),
606        );
607
608        app = app.route(
609            "/docs",
610            axum::routing::get(move || {
611                let html = swagger_html.clone();
612                async move { (StatusCode::OK, [("content-type", "text/html")], html) }
613            }),
614        );
615
616        app.with_state(state)
617    }
618
619    pub async fn serve(self, addr: &str) {
620        let app = self.into_router();
621
622        let listener = tokio::net::TcpListener::bind(addr)
623            .await
624            .expect("Failed to bind to address");
625        println!("🚀 Hayai server running at http://{}", addr);
626        println!("📖 Swagger UI available at http://{}/docs", addr);
627        axum::serve(listener, app).await.expect("Server error");
628    }
629
630    fn build_operation(
631        route: &RouteInfo,
632        tags: Vec<String>,
633        security_list: &[&str],
634    ) -> openapi::Operation {
635        let description = if route.description.is_empty() {
636            None
637        } else {
638            Some(route.description.to_string())
639        };
640
641        let status_code = route.success_status.to_string();
642
643        let security: Vec<HashMap<String, Vec<String>>> = security_list
644            .iter()
645            .map(|s| {
646                let scheme_name = match *s {
647                    "bearer" => "bearerAuth",
648                    other => other,
649                };
650                let mut map = HashMap::new();
651                map.insert(scheme_name.to_string(), vec![]);
652                map
653            })
654            .collect();
655
656        let schema_ref_value = if route.success_status == 204 {
657            None
658        } else if route.is_vec_response {
659            Some(serde_json::json!({
660                "type": "array",
661                "items": { "$ref": format!("#/components/schemas/{}", route.vec_inner_type_name) }
662            }))
663        } else {
664            Some(
665                serde_json::json!({ "$ref": format!("#/components/schemas/{}", route.response_type_name) }),
666            )
667        };
668
669        let success_desc = openapi::status_description(route.success_status).to_string();
670
671        openapi::Operation {
672            summary: Some(route.handler_name.replace('_', " ")),
673            description,
674            operation_id: Some(route.handler_name.to_string()),
675            tags,
676            parameters: route.parameters.to_vec(),
677            request_body: if route.has_body {
678                Some(openapi::RequestBody {
679                    required: true,
680                    content_type: "application/json".to_string(),
681                    schema_ref: format!("#/components/schemas/{}", route.body_type_name),
682                })
683            } else {
684                None
685            },
686            responses: {
687                let mut map = HashMap::new();
688                map.insert(
689                    status_code,
690                    openapi::ResponseDef {
691                        description: success_desc,
692                        schema_ref: schema_ref_value,
693                    },
694                );
695                map.insert(
696                    "400".to_string(),
697                    openapi::ResponseDef {
698                        description: "Bad Request".to_string(),
699                        schema_ref: Some(
700                            serde_json::json!({ "$ref": "#/components/schemas/ApiError" }),
701                        ),
702                    },
703                );
704                if route.is_result_return {
705                    map.insert(
706                        "404".to_string(),
707                        openapi::ResponseDef {
708                            description: "Not Found".to_string(),
709                            schema_ref: Some(
710                                serde_json::json!({ "$ref": "#/components/schemas/ApiError" }),
711                            ),
712                        },
713                    );
714                }
715                if route.has_body {
716                    map.insert(
717                        "422".to_string(),
718                        openapi::ResponseDef {
719                            description: "Validation Failed".to_string(),
720                            schema_ref: Some(
721                                serde_json::json!({ "$ref": "#/components/schemas/ApiError" }),
722                            ),
723                        },
724                    );
725                }
726                map.insert(
727                    "500".to_string(),
728                    openapi::ResponseDef {
729                        description: "Internal Server Error".to_string(),
730                        schema_ref: Some(
731                            serde_json::json!({ "$ref": "#/components/schemas/ApiError" }),
732                        ),
733                    },
734                );
735                map
736            },
737            security,
738        }
739    }
740
741    fn generate_swagger_html(&self) -> String {
742        match &self.swagger_mode {
743            SwaggerMode::Cdn(cdn_base) => {
744                format!(
745                    r#"<!DOCTYPE html>
746<html>
747<head>
748    <title>{title} - Swagger UI</title>
749    <link rel="stylesheet" type="text/css" href="{cdn}/swagger-ui.css" >
750</head>
751<body>
752    <div id="swagger-ui"></div>
753    <script src="{cdn}/swagger-ui-bundle.js"> </script>
754    <script>
755    SwaggerUIBundle({{
756        url: "/openapi.json",
757        dom_id: '#swagger-ui',
758        presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
759        layout: "BaseLayout"
760    }})
761    </script>
762</body>
763</html>"#,
764                    title = self.title,
765                    cdn = cdn_base,
766                )
767            }
768            SwaggerMode::Embedded => {
769                format!(
770                    r#"<!DOCTYPE html>
771<html>
772<head>
773    <title>{title} - API Reference</title>
774    <meta charset="utf-8" />
775    <meta name="viewport" content="width=device-width, initial-scale=1" />
776    <style>{css}</style>
777</head>
778<body>
779    <script id="api-reference" data-url="/openapi.json"></script>
780    <script>{js}</script>
781</body>
782</html>"#,
783                    title = self.title,
784                    css = include_str!("../assets/scalar.min.css"),
785                    js = include_str!("../assets/scalar.min.js"),
786                )
787            }
788        }
789    }
790
791    fn generate_openapi_spec(&self) -> openapi::OpenApiSpec {
792        let mut schemas = HashMap::new();
793
794        // Add ApiError schema
795        schemas.insert("ApiError".to_string(), openapi::api_error_schema());
796
797        for info in inventory::iter::<SchemaInfo> {
798            schemas.insert(info.name.to_string(), (info.schema_fn)());
799            for (nested_name, nested_schema) in (info.nested_fn)() {
800                schemas.entry(nested_name).or_insert(nested_schema);
801            }
802        }
803
804        let mut paths = HashMap::new();
805
806        if self.has_explicit_routes() {
807            let resolved = self.resolve_routes();
808            for r in &resolved {
809                let route = r.route_info;
810                let full_path = r.full_path();
811                let tags = r.merged_tags();
812                let sec = r.merged_security();
813                let operation = Self::build_operation(route, tags, &sec);
814                let path_item = paths.entry(full_path).or_insert_with(HashMap::new);
815                path_item.insert(route.method.to_lowercase(), operation);
816            }
817        } else {
818            for route in inventory::iter::<&RouteInfo> {
819                let tags: Vec<String> = route.tags.iter().map(|s| s.to_string()).collect();
820                let sec: Vec<&str> = route.security.to_vec();
821                let operation = Self::build_operation(route, tags, &sec);
822                let path_item = paths
823                    .entry(route.path.to_string())
824                    .or_insert_with(HashMap::new);
825                path_item.insert(route.method.to_lowercase(), operation);
826            }
827        }
828
829        openapi::OpenApiSpec {
830            openapi: "3.1.0".to_string(),
831            info: openapi::Info {
832                title: self.title.clone(),
833                version: self.version.clone(),
834                description: self.description.clone(),
835                contact: self.contact.clone(),
836                license: self.license.clone(),
837            },
838            servers: self.servers.clone(),
839            paths,
840            schemas,
841            security_schemes: self.security_schemes.clone(),
842        }
843    }
844}
845
846impl openapi::OpenApiSpec {
847    /// Enhanced to_json that includes dynamic query parameters
848    pub fn to_json_with_query_params(&self, routers: &[UltraApiRouter]) -> serde_json::Value {
849        let mut val = self.to_json();
850
851        // Build path mapping: for each route, determine the actual spec path
852        let use_routers = !routers.is_empty();
853
854        struct RoutePathInfo {
855            spec_path: String,
856            method: String,
857            query_params_fn: Option<fn() -> Vec<openapi::DynParameter>>,
858        }
859
860        let mut route_paths = Vec::new();
861
862        if use_routers {
863            for router in routers {
864                let resolved = router.resolve("", &[], &[]);
865                for r in &resolved {
866                    if let Some(qfn) = r.route_info.query_params_fn {
867                        route_paths.push(RoutePathInfo {
868                            spec_path: r.full_path(),
869                            method: r.route_info.method.to_lowercase(),
870                            query_params_fn: Some(qfn),
871                        });
872                    }
873                }
874            }
875        } else {
876            for route in inventory::iter::<&RouteInfo> {
877                if let Some(qfn) = route.query_params_fn {
878                    route_paths.push(RoutePathInfo {
879                        spec_path: route.path.to_string(),
880                        method: route.method.to_lowercase(),
881                        query_params_fn: Some(qfn),
882                    });
883                }
884            }
885        }
886
887        for rp in &route_paths {
888            if let Some(qfn) = rp.query_params_fn {
889                let dyn_params = qfn();
890                if !dyn_params.is_empty() {
891                    let escaped = rp.spec_path.replace('~', "~0").replace('/', "~1");
892                    let pointer = format!("/paths/{}/{}", escaped, rp.method);
893                    if let Some(op) = val.pointer_mut(&pointer) {
894                        let params = op
895                            .get("parameters")
896                            .and_then(|v| v.as_array())
897                            .cloned()
898                            .unwrap_or_default();
899                        let mut all_params = params;
900                        for dp in &dyn_params {
901                            let mut schema = serde_json::json!({ "type": dp.schema_type });
902                            if let Some(v) = dp.minimum {
903                                schema["minimum"] = serde_json::json!(v);
904                            }
905                            if let Some(v) = dp.maximum {
906                                schema["maximum"] = serde_json::json!(v);
907                            }
908                            if let Some(v) = dp.min_length {
909                                schema["minLength"] = serde_json::json!(v);
910                            }
911                            if let Some(v) = dp.max_length {
912                                schema["maxLength"] = serde_json::json!(v);
913                            }
914                            if let Some(v) = &dp.pattern {
915                                schema["pattern"] = serde_json::json!(v);
916                            }
917                            let mut param = serde_json::json!({
918                                "name": dp.name,
919                                "in": dp.location,
920                                "required": dp.required,
921                                "schema": schema
922                            });
923                            if let Some(desc) = &dp.description {
924                                param["description"] = serde_json::Value::String(desc.clone());
925                            }
926                            all_params.push(param);
927                        }
928                        op["parameters"] = serde_json::Value::Array(all_params);
929                    }
930                }
931            }
932        }
933
934        val
935    }
936}
937
938// Backward compatibility aliases (deprecated, use UltraApiApp and UltraApiRouter)
939#[allow(deprecated)]
940pub use UltraApiApp as HayaiApp;
941#[allow(deprecated)]
942pub use UltraApiRouter as HayaiRouter;