1use crate::{HttpMethod, RouteDefinition};
2use serde::Serialize;
3use serde_json::{json, Value};
4use std::collections::{BTreeMap, BTreeSet};
5
6#[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#[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#[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#[derive(Clone, Debug, Default, PartialEq, Serialize)]
124pub struct OpenApiPathItem {
125 #[serde(flatten)]
126 pub operations: BTreeMap<String, OpenApiOperation>,
127}
128
129#[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#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
201pub struct OpenApiTag {
202 pub name: String,
203}
204
205#[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#[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#[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#[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#[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#[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
407pub 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}