Skip to main content

rustapi_openapi/
spec.rs

1//! OpenAPI 3.1 specification types
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6use crate::schema::JsonSchema2020;
7pub use crate::schema::SchemaRef;
8
9/// OpenAPI 3.1.0 specification
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct OpenApiSpec {
13    /// OpenAPI version (always "3.1.0")
14    pub openapi: String,
15
16    /// API information
17    pub info: ApiInfo,
18
19    /// JSON Schema dialect (optional, defaults to JSON Schema 2020-12)
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub json_schema_dialect: Option<String>,
22
23    /// Server list
24    #[serde(skip_serializing_if = "Vec::is_empty")]
25    pub servers: Vec<Server>,
26
27    /// API paths
28    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
29    pub paths: BTreeMap<String, PathItem>,
30
31    /// Webhooks
32    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
33    pub webhooks: BTreeMap<String, PathItem>,
34
35    /// Components
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub components: Option<Components>,
38
39    /// Security requirements
40    #[serde(skip_serializing_if = "Vec::is_empty")]
41    pub security: Vec<BTreeMap<String, Vec<String>>>,
42
43    /// Tags
44    #[serde(skip_serializing_if = "Vec::is_empty")]
45    pub tags: Vec<Tag>,
46
47    /// External documentation
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub external_docs: Option<ExternalDocs>,
50}
51
52impl OpenApiSpec {
53    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
54        Self {
55            openapi: "3.1.0".to_string(),
56            info: ApiInfo {
57                title: title.into(),
58                version: version.into(),
59                ..Default::default()
60            },
61            // Use OpenAPI 3.1 default dialect for maximum Swagger UI compatibility
62            json_schema_dialect: Some("https://spec.openapis.org/oas/3.1/dialect/base".to_string()),
63            servers: Vec::new(),
64            paths: BTreeMap::new(),
65            webhooks: BTreeMap::new(),
66            components: None,
67            security: Vec::new(),
68            tags: Vec::new(),
69            external_docs: None,
70        }
71    }
72
73    pub fn description(mut self, desc: impl Into<String>) -> Self {
74        self.info.description = Some(desc.into());
75        self
76    }
77
78    pub fn summary(mut self, summary: impl Into<String>) -> Self {
79        self.info.summary = Some(summary.into());
80        self
81    }
82
83    pub fn path(mut self, path: &str, method: &str, operation: Operation) -> Self {
84        let item = self.paths.entry(path.to_string()).or_default();
85        match method.to_uppercase().as_str() {
86            "GET" => item.get = Some(operation),
87            "POST" => item.post = Some(operation),
88            "PUT" => item.put = Some(operation),
89            "PATCH" => item.patch = Some(operation),
90            "DELETE" => item.delete = Some(operation),
91            "HEAD" => item.head = Some(operation),
92            "OPTIONS" => item.options = Some(operation),
93            "TRACE" => item.trace = Some(operation),
94            _ => {}
95        }
96        self
97    }
98
99    /// Register a type that implements RustApiSchema
100    pub fn register<T: crate::schema::RustApiSchema>(mut self) -> Self {
101        self.register_in_place::<T>();
102        self
103    }
104
105    /// Register a type into this spec in-place.
106    pub fn register_in_place<T: crate::schema::RustApiSchema>(&mut self) {
107        let mut ctx = crate::schema::SchemaCtx::new();
108
109        // Pre-load existing schemas to avoid re-generating or to handle deduplication correctly
110        if let Some(c) = &self.components {
111            ctx.components = c.schemas.clone();
112        }
113
114        // Generate schema for T (and dependencies)
115        let _ = T::schema(&mut ctx);
116
117        // Merge back into components
118        let components = self.components.get_or_insert_with(Components::default);
119        components.schemas.extend(ctx.components);
120    }
121
122    pub fn server(mut self, server: Server) -> Self {
123        self.servers.push(server);
124        self
125    }
126
127    pub fn security_scheme(mut self, name: impl Into<String>, scheme: SecurityScheme) -> Self {
128        let components = self.components.get_or_insert_with(Components::default);
129        components
130            .security_schemes
131            .entry(name.into())
132            .or_insert(scheme);
133        self
134    }
135
136    pub fn to_json(&self) -> serde_json::Value {
137        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
138    }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142#[serde(rename_all = "camelCase")]
143pub struct ApiInfo {
144    pub title: String,
145    pub version: String,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub summary: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub description: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub terms_of_service: Option<String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub contact: Option<Contact>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub license: Option<License>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, Default)]
159pub struct Contact {
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub name: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub url: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub email: Option<String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, Default)]
169pub struct License {
170    pub name: String,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub identifier: Option<String>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub url: Option<String>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct Server {
179    pub url: String,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub description: Option<String>,
182    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
183    pub variables: BTreeMap<String, ServerVariable>,
184}
185
186impl Server {
187    pub fn new(url: impl Into<String>) -> Self {
188        Self {
189            url: url.into(),
190            description: None,
191            variables: BTreeMap::new(),
192        }
193    }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct ServerVariable {
199    #[serde(rename = "enum", skip_serializing_if = "Vec::is_empty")]
200    pub enum_values: Vec<String>,
201    pub default: String,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub description: Option<String>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, Default)]
207pub struct PathItem {
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub summary: Option<String>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub description: Option<String>,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub get: Option<Operation>,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub put: Option<Operation>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub post: Option<Operation>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub delete: Option<Operation>,
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub options: Option<Operation>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub head: Option<Operation>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub patch: Option<Operation>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub trace: Option<Operation>,
228    #[serde(skip_serializing_if = "Vec::is_empty")]
229    pub servers: Vec<Server>,
230    #[serde(skip_serializing_if = "Vec::is_empty")]
231    pub parameters: Vec<Parameter>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, Default)]
235#[serde(rename_all = "camelCase")]
236pub struct Operation {
237    #[serde(skip_serializing_if = "Vec::is_empty")]
238    pub tags: Vec<String>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub summary: Option<String>,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub description: Option<String>,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub external_docs: Option<ExternalDocs>,
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub operation_id: Option<String>,
247    #[serde(skip_serializing_if = "Vec::is_empty")]
248    pub parameters: Vec<Parameter>,
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub request_body: Option<RequestBody>,
251    pub responses: BTreeMap<String, ResponseSpec>,
252    #[serde(skip_serializing_if = "Vec::is_empty")]
253    pub security: Vec<BTreeMap<String, Vec<String>>>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub deprecated: Option<bool>,
256}
257
258impl Operation {
259    pub fn new() -> Self {
260        Self {
261            responses: BTreeMap::from([("200".to_string(), ResponseSpec::default())]),
262            ..Default::default()
263        }
264    }
265
266    pub fn summary(mut self, s: impl Into<String>) -> Self {
267        self.summary = Some(s.into());
268        self
269    }
270
271    pub fn description(mut self, d: impl Into<String>) -> Self {
272        self.description = Some(d.into());
273        self
274    }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct Parameter {
279    pub name: String,
280    #[serde(rename = "in")]
281    pub location: String,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub description: Option<String>,
284    pub required: bool,
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub deprecated: Option<bool>,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub schema: Option<SchemaRef>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct RequestBody {
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub description: Option<String>,
295    pub content: BTreeMap<String, MediaType>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub required: Option<bool>,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, Default)]
301pub struct ResponseSpec {
302    pub description: String,
303    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
304    pub content: BTreeMap<String, MediaType>,
305    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
306    pub headers: BTreeMap<String, Header>,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct MediaType {
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub schema: Option<SchemaRef>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub example: Option<serde_json::Value>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct Header {
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub description: Option<String>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub schema: Option<SchemaRef>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, Default)]
326#[serde(rename_all = "camelCase")]
327pub struct Components {
328    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
329    pub schemas: BTreeMap<String, JsonSchema2020>,
330    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
331    pub responses: BTreeMap<String, ResponseSpec>,
332    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
333    pub parameters: BTreeMap<String, Parameter>,
334    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
335    pub examples: BTreeMap<String, serde_json::Value>,
336    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
337    pub request_bodies: BTreeMap<String, RequestBody>,
338    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
339    pub headers: BTreeMap<String, Header>,
340    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
341    pub security_schemes: BTreeMap<String, SecurityScheme>,
342    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
343    pub links: BTreeMap<String, serde_json::Value>,
344    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
345    pub callbacks: BTreeMap<String, BTreeMap<String, PathItem>>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349#[serde(tag = "type", rename_all = "camelCase")]
350pub enum SecurityScheme {
351    ApiKey {
352        name: String,
353        #[serde(rename = "in")]
354        location: String,
355        #[serde(skip_serializing_if = "Option::is_none")]
356        description: Option<String>,
357    },
358    Http {
359        scheme: String,
360        #[serde(skip_serializing_if = "Option::is_none")]
361        bearer_format: Option<String>,
362        #[serde(skip_serializing_if = "Option::is_none")]
363        description: Option<String>,
364    },
365    Oauth2 {
366        flows: Box<OAuthFlows>,
367        #[serde(skip_serializing_if = "Option::is_none")]
368        description: Option<String>,
369    },
370    OpenIdConnect {
371        open_id_connect_url: String,
372        #[serde(skip_serializing_if = "Option::is_none")]
373        description: Option<String>,
374    },
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize, Default)]
378#[serde(rename_all = "camelCase")]
379pub struct OAuthFlows {
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub implicit: Option<OAuthFlow>,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub password: Option<OAuthFlow>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub client_credentials: Option<OAuthFlow>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub authorization_code: Option<OAuthFlow>,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
391#[serde(rename_all = "camelCase")]
392pub struct OAuthFlow {
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub authorization_url: Option<String>,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub token_url: Option<String>,
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub refresh_url: Option<String>,
399    pub scopes: BTreeMap<String, String>,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct Tag {
404    pub name: String,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub description: Option<String>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub external_docs: Option<ExternalDocs>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct ExternalDocs {
413    pub url: String,
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub description: Option<String>,
416}
417
418// Re-exports/Traits needed for backwards compatibility or easy migration
419pub trait OperationModifier {
420    fn update_operation(op: &mut Operation);
421}
422
423pub trait ResponseModifier {
424    fn update_response(op: &mut Operation);
425}
426
427// Helper implementations for OperationModifier/ResponseModifier
428impl<T: OperationModifier> OperationModifier for Option<T> {
429    fn update_operation(op: &mut Operation) {
430        T::update_operation(op);
431        if let Some(body) = &mut op.request_body {
432            body.required = Some(false);
433        }
434    }
435}
436
437impl<T: OperationModifier, E> OperationModifier for Result<T, E> {
438    fn update_operation(op: &mut Operation) {
439        T::update_operation(op);
440    }
441}
442
443macro_rules! impl_op_modifier_for_primitives {
444    ($($ty:ty),*) => {
445        $(
446            impl OperationModifier for $ty {
447                fn update_operation(_op: &mut Operation) {}
448            }
449        )*
450    };
451}
452impl_op_modifier_for_primitives!(
453    i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64, bool, String
454);
455
456impl ResponseModifier for () {
457    fn update_response(op: &mut Operation) {
458        op.responses.insert(
459            "200".to_string(),
460            ResponseSpec {
461                description: "Successful response".into(),
462                ..Default::default()
463            },
464        );
465    }
466}
467
468impl ResponseModifier for String {
469    fn update_response(op: &mut Operation) {
470        let mut content = BTreeMap::new();
471        content.insert(
472            "text/plain".to_string(),
473            MediaType {
474                schema: Some(SchemaRef::Inline(serde_json::json!({"type": "string"}))),
475                example: None,
476            },
477        );
478        op.responses.insert(
479            "200".to_string(),
480            ResponseSpec {
481                description: "Successful response".into(),
482                content,
483                ..Default::default()
484            },
485        );
486    }
487}
488
489impl ResponseModifier for &'static str {
490    fn update_response(op: &mut Operation) {
491        String::update_response(op);
492    }
493}
494
495impl<T: ResponseModifier> ResponseModifier for Option<T> {
496    fn update_response(op: &mut Operation) {
497        T::update_response(op);
498    }
499}
500
501impl<T: ResponseModifier, E: ResponseModifier> ResponseModifier for Result<T, E> {
502    fn update_response(op: &mut Operation) {
503        T::update_response(op);
504        E::update_response(op);
505    }
506}
507
508impl<T> ResponseModifier for http::Response<T> {
509    fn update_response(op: &mut Operation) {
510        op.responses.insert(
511            "200".to_string(),
512            ResponseSpec {
513                description: "Successful response".into(),
514                ..Default::default()
515            },
516        );
517    }
518}