Skip to main content

a3s_boot/
openapi.rs

1use crate::{HttpMethod, RouteDefinition};
2use serde::Serialize;
3use serde_json::{json, Value};
4use std::collections::{BTreeMap, BTreeSet};
5
6/// Basic OpenAPI document information.
7#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
8pub struct OpenApiInfo {
9    pub title: String,
10    pub version: String,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub description: Option<String>,
13}
14
15impl OpenApiInfo {
16    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
17        Self {
18            title: title.into(),
19            version: version.into(),
20            description: None,
21        }
22    }
23
24    pub fn with_description(mut self, description: impl Into<String>) -> Self {
25        self.description = Some(description.into());
26        self
27    }
28}
29
30/// OpenAPI document generated from resolved Boot routes.
31#[derive(Clone, Debug, PartialEq, Serialize)]
32pub struct OpenApiDocument {
33    pub openapi: String,
34    pub info: OpenApiInfo,
35    pub paths: BTreeMap<String, OpenApiPathItem>,
36    #[serde(skip_serializing_if = "OpenApiComponents::is_empty")]
37    pub components: OpenApiComponents,
38    #[serde(skip_serializing_if = "Vec::is_empty")]
39    pub tags: Vec<OpenApiTag>,
40}
41
42impl OpenApiDocument {
43    pub fn from_routes(info: OpenApiInfo, routes: &[RouteDefinition]) -> Self {
44        let mut tag_names = BTreeSet::new();
45        let mut paths = BTreeMap::<String, OpenApiPathItem>::new();
46        let mut components = OpenApiComponents::default();
47
48        for route in routes {
49            if route.openapi().hidden {
50                continue;
51            }
52
53            let operation = OpenApiOperation::from_route(route);
54            for tag in &operation.tags {
55                tag_names.insert(tag.clone());
56            }
57            components.merge_schemas(route.openapi().schema_components.clone());
58
59            let path = paths.entry(openapi_route_path(route.path())).or_default();
60            for method in openapi_methods(route.method()) {
61                if route.method().is_wildcard() {
62                    path.operations
63                        .entry(method.to_string())
64                        .or_insert_with(|| operation.clone());
65                } else {
66                    path.operations
67                        .insert(method.to_string(), operation.clone());
68                }
69            }
70        }
71
72        Self {
73            openapi: "3.0.3".to_string(),
74            info,
75            paths,
76            components,
77            tags: tag_names
78                .into_iter()
79                .map(|name| OpenApiTag { name })
80                .collect(),
81        }
82    }
83}
84
85fn openapi_route_path(path: &str) -> String {
86    let path = path.strip_prefix('/').unwrap_or(path);
87    if path.is_empty() {
88        return "/".to_string();
89    }
90
91    let segments = path
92        .split('/')
93        .map(|segment| {
94            segment
95                .strip_prefix("{*")
96                .and_then(|name| name.strip_suffix('}'))
97                .map(|name| format!("{{{name}}}"))
98                .unwrap_or_else(|| segment.to_string())
99        })
100        .collect::<Vec<_>>();
101
102    format!("/{}", segments.join("/"))
103}
104
105/// OpenAPI components generated or registered by routes.
106#[derive(Clone, Debug, Default, PartialEq, Serialize)]
107pub struct OpenApiComponents {
108    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
109    pub schemas: BTreeMap<String, OpenApiSchema>,
110}
111
112impl OpenApiComponents {
113    pub fn is_empty(&self) -> bool {
114        self.schemas.is_empty()
115    }
116
117    pub fn merge_schemas(&mut self, schemas: BTreeMap<String, OpenApiSchema>) {
118        self.schemas.extend(schemas);
119    }
120}
121
122/// OpenAPI path item containing operations keyed by HTTP method.
123#[derive(Clone, Debug, Default, PartialEq, Serialize)]
124pub struct OpenApiPathItem {
125    #[serde(flatten)]
126    pub operations: BTreeMap<String, OpenApiOperation>,
127}
128
129/// OpenAPI operation metadata for one Boot route.
130#[derive(Clone, Debug, PartialEq, Serialize)]
131pub struct OpenApiOperation {
132    #[serde(skip_serializing_if = "Vec::is_empty")]
133    pub tags: Vec<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub summary: Option<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub description: Option<String>,
138    #[serde(rename = "operationId", skip_serializing_if = "Option::is_none")]
139    pub operation_id: Option<String>,
140    #[serde(skip_serializing_if = "Vec::is_empty")]
141    pub parameters: Vec<OpenApiParameter>,
142    #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
143    pub request_body: Option<OpenApiRequestBody>,
144    pub responses: BTreeMap<String, OpenApiResponse>,
145    #[serde(skip_serializing_if = "Vec::is_empty")]
146    pub security: Vec<OpenApiSecurityRequirement>,
147    #[serde(skip_serializing_if = "is_false")]
148    pub deprecated: bool,
149}
150
151impl OpenApiOperation {
152    fn from_route(route: &RouteDefinition) -> Self {
153        let metadata = route.openapi();
154        let mut parameters = metadata.parameters.clone();
155
156        for name in route.path_param_names() {
157            if !parameters.iter().any(|parameter| {
158                parameter.location == OpenApiParameterLocation::Path && parameter.name == name
159            }) {
160                parameters.push(OpenApiParameter::path(name, OpenApiSchema::string()));
161            }
162        }
163
164        let mut responses = metadata.responses.clone();
165        if responses.is_empty() {
166            responses.insert("200".to_string(), OpenApiResponse::description("Success"));
167        }
168
169        Self {
170            tags: metadata.tags.clone(),
171            summary: metadata.summary.clone(),
172            description: metadata.description.clone(),
173            operation_id: metadata.operation_id.clone(),
174            parameters,
175            request_body: metadata.request_body.clone(),
176            responses,
177            security: metadata.security.clone(),
178            deprecated: metadata.deprecated,
179        }
180    }
181}
182
183/// Route-level OpenAPI metadata carried by [`RouteDefinition`].
184#[derive(Clone, Debug, Default, PartialEq)]
185pub struct OpenApiRouteMetadata {
186    pub tags: Vec<String>,
187    pub operation_id: Option<String>,
188    pub summary: Option<String>,
189    pub description: Option<String>,
190    pub parameters: Vec<OpenApiParameter>,
191    pub request_body: Option<OpenApiRequestBody>,
192    pub responses: BTreeMap<String, OpenApiResponse>,
193    pub schema_components: BTreeMap<String, OpenApiSchema>,
194    pub security: Vec<OpenApiSecurityRequirement>,
195    pub deprecated: bool,
196    pub hidden: bool,
197}
198
199/// OpenAPI tag entry.
200#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
201pub struct OpenApiTag {
202    pub name: String,
203}
204
205/// OpenAPI parameter location.
206#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
207#[serde(rename_all = "lowercase")]
208pub enum OpenApiParameterLocation {
209    Path,
210    Query,
211    Header,
212    Cookie,
213}
214
215/// OpenAPI parameter metadata.
216#[derive(Clone, Debug, PartialEq, Serialize)]
217pub struct OpenApiParameter {
218    pub name: String,
219    #[serde(rename = "in")]
220    pub location: OpenApiParameterLocation,
221    pub required: bool,
222    pub schema: OpenApiSchema,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub description: Option<String>,
225}
226
227impl OpenApiParameter {
228    pub fn new(
229        location: OpenApiParameterLocation,
230        name: impl Into<String>,
231        required: bool,
232        schema: OpenApiSchema,
233    ) -> Self {
234        Self {
235            name: name.into(),
236            location,
237            required,
238            schema,
239            description: None,
240        }
241    }
242
243    pub fn path(name: impl Into<String>, schema: OpenApiSchema) -> Self {
244        Self::new(OpenApiParameterLocation::Path, name, true, schema)
245    }
246
247    pub fn query(name: impl Into<String>, required: bool, schema: OpenApiSchema) -> Self {
248        Self::new(OpenApiParameterLocation::Query, name, required, schema)
249    }
250
251    pub fn header(name: impl Into<String>, required: bool, schema: OpenApiSchema) -> Self {
252        Self::new(OpenApiParameterLocation::Header, name, required, schema)
253    }
254
255    pub fn with_description(mut self, description: impl Into<String>) -> Self {
256        self.description = Some(description.into());
257        self
258    }
259}
260
261/// OpenAPI request body metadata.
262#[derive(Clone, Debug, PartialEq, Serialize)]
263pub struct OpenApiRequestBody {
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub description: Option<String>,
266    pub required: bool,
267    pub content: BTreeMap<String, OpenApiMediaType>,
268}
269
270impl OpenApiRequestBody {
271    pub fn json(schema: OpenApiSchema) -> Self {
272        let mut content = BTreeMap::new();
273        content.insert(
274            "application/json".to_string(),
275            OpenApiMediaType::new(schema),
276        );
277        Self {
278            description: None,
279            required: true,
280            content,
281        }
282    }
283
284    pub fn with_description(mut self, description: impl Into<String>) -> Self {
285        self.description = Some(description.into());
286        self
287    }
288
289    pub fn optional(mut self) -> Self {
290        self.required = false;
291        self
292    }
293}
294
295/// OpenAPI response metadata.
296#[derive(Clone, Debug, PartialEq, Serialize)]
297pub struct OpenApiResponse {
298    pub description: String,
299    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
300    pub content: BTreeMap<String, OpenApiMediaType>,
301}
302
303impl OpenApiResponse {
304    pub fn description(description: impl Into<String>) -> Self {
305        Self {
306            description: description.into(),
307            content: BTreeMap::new(),
308        }
309    }
310
311    pub fn json(description: impl Into<String>, schema: OpenApiSchema) -> Self {
312        let mut content = BTreeMap::new();
313        content.insert(
314            "application/json".to_string(),
315            OpenApiMediaType::new(schema),
316        );
317        Self {
318            description: description.into(),
319            content,
320        }
321    }
322}
323
324/// OpenAPI media type metadata.
325#[derive(Clone, Debug, PartialEq, Serialize)]
326pub struct OpenApiMediaType {
327    pub schema: OpenApiSchema,
328}
329
330impl OpenApiMediaType {
331    pub fn new(schema: OpenApiSchema) -> Self {
332        Self { schema }
333    }
334}
335
336/// OpenAPI schema value. This intentionally stays schema-crate neutral.
337#[derive(Clone, Debug, PartialEq)]
338pub struct OpenApiSchema(Value);
339
340impl OpenApiSchema {
341    pub fn from_value(value: Value) -> Self {
342        Self(value)
343    }
344
345    #[cfg(feature = "openapi-schemas")]
346    pub fn json_schema<T>() -> std::result::Result<Self, serde_json::Error>
347    where
348        T: schemars::JsonSchema,
349    {
350        let mut value = serde_json::to_value(schemars::schema_for!(T))?;
351        if let Value::Object(schema) = &mut value {
352            schema.remove("$schema");
353        }
354        Ok(Self(value))
355    }
356
357    pub fn string() -> Self {
358        Self(json!({ "type": "string" }))
359    }
360
361    pub fn integer() -> Self {
362        Self(json!({ "type": "integer" }))
363    }
364
365    pub fn number() -> Self {
366        Self(json!({ "type": "number" }))
367    }
368
369    pub fn boolean() -> Self {
370        Self(json!({ "type": "boolean" }))
371    }
372
373    pub fn object() -> Self {
374        Self(json!({ "type": "object" }))
375    }
376
377    pub fn array(items: OpenApiSchema) -> Self {
378        Self(json!({ "type": "array", "items": items.0 }))
379    }
380
381    pub fn reference(name: impl AsRef<str>) -> Self {
382        Self(json!({ "$ref": format!("#/components/schemas/{}", name.as_ref()) }))
383    }
384
385    pub fn into_value(self) -> Value {
386        self.0
387    }
388}
389
390pub fn openapi_schema_name<T>() -> String {
391    std::any::type_name::<T>()
392        .rsplit("::")
393        .next()
394        .unwrap_or("Schema")
395        .to_string()
396}
397
398impl Serialize for OpenApiSchema {
399    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
400    where
401        S: serde::Serializer,
402    {
403        self.0.serialize(serializer)
404    }
405}
406
407/// OpenAPI security requirement object.
408pub type OpenApiSecurityRequirement = BTreeMap<String, Vec<String>>;
409
410fn openapi_methods(method: HttpMethod) -> &'static [&'static str] {
411    match method {
412        HttpMethod::All => &["get", "post", "put", "patch", "delete", "options", "head"],
413        HttpMethod::Get => &["get"],
414        HttpMethod::Post => &["post"],
415        HttpMethod::Put => &["put"],
416        HttpMethod::Patch => &["patch"],
417        HttpMethod::Delete => &["delete"],
418        HttpMethod::Options => &["options"],
419        HttpMethod::Head => &["head"],
420    }
421}
422
423fn is_false(value: &bool) -> bool {
424    !*value
425}