Skip to main content

nidus_openapi/
document.rs

1use std::collections::BTreeMap;
2
3use axum::{Json, Router, response::Html, routing::get};
4use nidus_http::error::RoutePathError;
5use nidus_http::router::{OpenApiSchemaRegistrar, RouteMetadata};
6use serde_json::{Value, json};
7use utoipa::{PartialSchema, ToSchema};
8
9use crate::html::docs_html;
10use crate::route::OpenApiRoute;
11
12/// Errors emitted while building an OpenAPI document.
13#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
14pub enum OpenApiDocumentError {
15    /// Two routes attempted to register the same path and HTTP method.
16    #[error("duplicate OpenAPI operation `{method}` `{path}`")]
17    DuplicateOperation {
18        /// Lowercase OpenAPI operation method.
19        method: String,
20        /// Normalized OpenAPI path.
21        path: String,
22    },
23    /// Route path normalization failed.
24    #[error(transparent)]
25    RoutePath(#[from] RoutePathError),
26    /// Schema registration failed.
27    #[error("OpenAPI schema registration failed: {message}")]
28    SchemaRegistration {
29        /// Safe diagnostic from the schema registration failure.
30        message: String,
31    },
32}
33
34/// Minimal OpenAPI document metadata builder.
35#[derive(Clone, Debug)]
36pub struct OpenApiDocument {
37    title: String,
38    version: String,
39    routes: Vec<OpenApiRoute>,
40    schemas: BTreeMap<String, Value>,
41}
42
43impl OpenApiDocument {
44    /// Creates an OpenAPI document.
45    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
46        Self {
47            title: title.into(),
48            version: version.into(),
49            routes: Vec::new(),
50            schemas: BTreeMap::new(),
51        }
52    }
53
54    /// Adds route metadata to the document.
55    pub fn route(mut self, route: OpenApiRoute) -> Self {
56        self = self
57            .try_route(route)
58            .unwrap_or_else(|error| panic!("{error}"));
59        self
60    }
61
62    /// Tries to add route metadata to the document.
63    pub fn try_route(mut self, route: OpenApiRoute) -> Result<Self, OpenApiDocumentError> {
64        if self
65            .routes
66            .iter()
67            .any(|existing| existing.path() == route.path() && existing.method() == route.method())
68        {
69            return Err(OpenApiDocumentError::DuplicateOperation {
70                method: route.method().to_owned(),
71                path: route.path().to_owned(),
72            });
73        }
74        self.routes.push(route);
75        Ok(self)
76    }
77
78    /// Adds generated route metadata under a controller prefix.
79    pub fn controller_routes(self, controller_prefix: &str, routes: &[RouteMetadata]) -> Self {
80        self.try_controller_routes(controller_prefix, routes)
81            .unwrap_or_else(|error| panic!("{error}"))
82    }
83
84    /// Tries to add generated route metadata under a controller prefix.
85    pub fn try_controller_routes(
86        mut self,
87        controller_prefix: &str,
88        routes: &[RouteMetadata],
89    ) -> Result<Self, OpenApiDocumentError> {
90        for route in routes {
91            self = self.try_route(OpenApiRoute::try_from_route_metadata_at_path(
92                route,
93                route.try_full_path(controller_prefix)?,
94            )?)?;
95        }
96        Ok(self)
97    }
98
99    /// Registers schemas from route OpenAPI metadata callbacks.
100    pub fn schemas_from_route_metadata(mut self, routes: &[RouteMetadata]) -> Self {
101        self = self
102            .try_schemas_from_route_metadata(routes)
103            .unwrap_or_else(|error| panic!("{error}"));
104        self
105    }
106
107    /// Tries to register schemas from route OpenAPI metadata callbacks.
108    pub fn try_schemas_from_route_metadata(
109        mut self,
110        routes: &[RouteMetadata],
111    ) -> Result<Self, OpenApiDocumentError> {
112        for route in routes {
113            self = self
114                .try_with_schema_registrar(route.request_schema_registrar())?
115                .try_with_schema_registrar(route.response_schema_registrar())?;
116        }
117        Ok(self)
118    }
119
120    /// Adds a DTO schema generated by `utoipa::ToSchema`.
121    pub fn schema<T>(mut self) -> Self
122    where
123        T: ToSchema,
124    {
125        self = self
126            .try_schema::<T>()
127            .unwrap_or_else(|error| panic!("{error}"));
128        self
129    }
130
131    /// Tries to add a DTO schema generated by `utoipa::ToSchema`.
132    pub fn try_schema<T>(mut self) -> Result<Self, OpenApiDocumentError>
133    where
134        T: ToSchema,
135    {
136        self.schemas = self.register_schemas(Self::try_collect_openapi_schemas::<T>()?);
137        Ok(self)
138    }
139
140    fn try_collect_openapi_schemas<T: ToSchema>()
141    -> Result<Vec<(String, Value)>, OpenApiDocumentError> {
142        let mut openapi_schemas: Vec<(
143            String,
144            utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
145        )> = vec![(T::name().to_string(), <T as PartialSchema>::schema())];
146        <T as ToSchema>::schemas(&mut openapi_schemas);
147        let mut schemas = Vec::new();
148        for (name, schema) in openapi_schemas {
149            let value = serde_json::to_value(schema).map_err(|error| {
150                OpenApiDocumentError::SchemaRegistration {
151                    message: format!("schema `{name}`: {error}"),
152                }
153            })?;
154            schemas.push((name, value));
155        }
156        Ok(schemas)
157    }
158
159    /// Creates an OpenAPI document from generated route metadata.
160    pub fn from_route_metadata(
161        title: impl Into<String>,
162        version: impl Into<String>,
163        routes: &[RouteMetadata],
164    ) -> Self {
165        Self::try_from_route_metadata(title, version, routes)
166            .unwrap_or_else(|error| panic!("{error}"))
167    }
168
169    /// Tries to create an OpenAPI document from generated route metadata.
170    pub fn try_from_route_metadata(
171        title: impl Into<String>,
172        version: impl Into<String>,
173        routes: &[RouteMetadata],
174    ) -> Result<Self, OpenApiDocumentError> {
175        let mut document = Self::new(title, version);
176        for route in routes {
177            document = document.try_route(OpenApiRoute::try_from_route_metadata(route)?)?;
178        }
179        Ok(document)
180    }
181
182    /// Creates an OpenAPI document from a controller prefix and generated route metadata.
183    pub fn from_controller_routes(
184        title: impl Into<String>,
185        version: impl Into<String>,
186        controller_prefix: &str,
187        routes: &[RouteMetadata],
188    ) -> Self {
189        Self::try_from_controller_routes(title, version, controller_prefix, routes)
190            .unwrap_or_else(|error| panic!("{error}"))
191    }
192
193    /// Tries to create an OpenAPI document from controller route metadata.
194    pub fn try_from_controller_routes(
195        title: impl Into<String>,
196        version: impl Into<String>,
197        controller_prefix: &str,
198        routes: &[RouteMetadata],
199    ) -> Result<Self, OpenApiDocumentError> {
200        Self::new(title, version).try_controller_routes(controller_prefix, routes)
201    }
202
203    /// Renders the document as JSON.
204    pub fn to_json_value(&self) -> Value {
205        let mut paths = serde_json::Map::new();
206        for route in &self.routes {
207            let entry = paths
208                .entry(route.path().to_owned())
209                .or_insert_with(|| Value::Object(serde_json::Map::new()));
210            if let Value::Object(methods) = entry {
211                methods.insert(route.method().to_owned(), route.to_json_value());
212            }
213        }
214
215        let mut document = json!({
216            "openapi": "3.1.0",
217            "info": {
218                "title": self.title,
219                "version": self.version,
220            },
221            "paths": paths,
222        });
223
224        if !self.schemas.is_empty() {
225            document["components"] = json!({
226                "schemas": &self.schemas,
227            });
228        }
229
230        document
231    }
232
233    /// Builds an Axum router serving OpenAPI JSON and Swagger UI docs.
234    pub fn into_router(self) -> Router {
235        let json = self.to_json_value();
236        let docs = docs_html(&self.title, "/openapi.json");
237
238        Router::new()
239            .route(
240                "/openapi.json",
241                get(move || {
242                    let json = json.clone();
243                    async move { Json(json) }
244                }),
245            )
246            .route(
247                "/docs",
248                get(move || {
249                    let docs = docs.clone();
250                    async move { Html(docs) }
251                }),
252            )
253    }
254
255    fn try_with_schema_registrar(
256        mut self,
257        registrar: Option<OpenApiSchemaRegistrar>,
258    ) -> Result<Self, OpenApiDocumentError> {
259        let Some(registrar) = registrar else {
260            return Ok(self);
261        };
262        let mut schemas = Vec::new();
263        registrar(&mut schemas).map_err(|error| OpenApiDocumentError::SchemaRegistration {
264            message: error.to_string(),
265        })?;
266        self.schemas = self.register_schemas(schemas);
267        Ok(self)
268    }
269
270    fn register_schemas(&self, schemas: Vec<(String, Value)>) -> BTreeMap<String, Value> {
271        let mut registered = self.schemas.clone();
272        for (name, schema) in schemas {
273            registered.entry(name).or_insert(schema);
274        }
275        registered
276    }
277}