Skip to main content

rustapi_openapi/
spec.rs

1//! OpenAPI 3.1 specification types
2
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, HashSet};
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            json_schema_dialect: Some("https://json-schema.org/draft/2020-12/schema".to_string()),
62            servers: Vec::new(),
63            paths: BTreeMap::new(),
64            webhooks: BTreeMap::new(),
65            components: None,
66            security: Vec::new(),
67            tags: Vec::new(),
68            external_docs: None,
69        }
70    }
71
72    pub fn description(mut self, desc: impl Into<String>) -> Self {
73        self.info.description = Some(desc.into());
74        self
75    }
76
77    pub fn summary(mut self, summary: impl Into<String>) -> Self {
78        self.info.summary = Some(summary.into());
79        self
80    }
81
82    pub fn path(mut self, path: &str, method: &str, operation: Operation) -> Self {
83        let item = self.paths.entry(path.to_string()).or_default();
84        match method.to_uppercase().as_str() {
85            "GET" => item.get = Some(operation),
86            "POST" => item.post = Some(operation),
87            "PUT" => item.put = Some(operation),
88            "PATCH" => item.patch = Some(operation),
89            "DELETE" => item.delete = Some(operation),
90            "HEAD" => item.head = Some(operation),
91            "OPTIONS" => item.options = Some(operation),
92            "TRACE" => item.trace = Some(operation),
93            _ => {}
94        }
95        self
96    }
97
98    /// Register a type that implements RustApiSchema
99    pub fn register<T: crate::schema::RustApiSchema>(mut self) -> Self {
100        self.register_in_place::<T>();
101        self
102    }
103
104    /// Register a type into this spec in-place.
105    pub fn register_in_place<T: crate::schema::RustApiSchema>(&mut self) {
106        let mut ctx = crate::schema::SchemaCtx::new();
107
108        // Pre-load existing schemas to avoid re-generating or to handle deduplication correctly
109        if let Some(c) = &self.components {
110            ctx.components = c.schemas.clone();
111        }
112
113        // Generate schema for T (and dependencies)
114        let _ = T::schema(&mut ctx);
115
116        // Merge back into components
117        let components = self.components.get_or_insert_with(Components::default);
118        for (name, schema) in ctx.components {
119            if let Some(existing) = components.schemas.get(&name) {
120                if existing != &schema {
121                    panic!("Schema collision detected for component '{}'. Existing schema differs from new schema. This usually means two different types are mapped to the same component name. Please implement `RustApiSchema::name()` or alias the type.", name);
122                }
123            } else {
124                components.schemas.insert(name, schema);
125            }
126        }
127    }
128
129    pub fn server(mut self, server: Server) -> Self {
130        self.servers.push(server);
131        self
132    }
133
134    pub fn security_scheme(mut self, name: impl Into<String>, scheme: SecurityScheme) -> Self {
135        let components = self.components.get_or_insert_with(Components::default);
136        components
137            .security_schemes
138            .entry(name.into())
139            .or_insert(scheme);
140        self
141    }
142
143    pub fn to_json(&self) -> serde_json::Value {
144        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
145    }
146
147    /// Validate that all $ref references point to existing components.
148    /// Returns Ok(()) if valid, or a list of missing references.
149    pub fn validate_integrity(&self) -> Result<(), Vec<String>> {
150        let mut defined_schemas = HashSet::new();
151        if let Some(components) = &self.components {
152            for key in components.schemas.keys() {
153                defined_schemas.insert(format!("#/components/schemas/{}", key));
154            }
155        }
156
157        let mut missing_refs = Vec::new();
158
159        // Helper to check a single ref
160        let mut check_ref = |r: &str| {
161            if r.starts_with("#/components/schemas/") && !defined_schemas.contains(r) {
162                missing_refs.push(r.to_string());
163            }
164            // Ignore other refs for now (e.g. external or non-schema refs)
165        };
166
167        // Visitor pattern to traverse the spec
168        let mut visit_schema = |schema: &SchemaRef| {
169            visit_schema_ref(schema, &mut check_ref);
170        };
171
172        // 1. Visit Paths
173        for path_item in self.paths.values() {
174            visit_path_item(path_item, &mut visit_schema);
175        }
176
177        // 2. Visit Webhooks
178        for path_item in self.webhooks.values() {
179            visit_path_item(path_item, &mut visit_schema);
180        }
181
182        // 3. Visit Components (including schemas referencing other schemas)
183        if let Some(components) = &self.components {
184            for schema in components.schemas.values() {
185                visit_json_schema(schema, &mut check_ref);
186            }
187            // TODO: Visit other components like parameters, headers, etc. if they can contain refs
188        }
189
190        if missing_refs.is_empty() {
191            Ok(())
192        } else {
193            // Deduplicate
194            missing_refs.sort();
195            missing_refs.dedup();
196            Err(missing_refs)
197        }
198    }
199}
200
201fn visit_path_item<F>(item: &PathItem, visit: &mut F)
202where
203    F: FnMut(&SchemaRef),
204{
205    if let Some(op) = &item.get {
206        visit_operation(op, visit);
207    }
208    if let Some(op) = &item.put {
209        visit_operation(op, visit);
210    }
211    if let Some(op) = &item.post {
212        visit_operation(op, visit);
213    }
214    if let Some(op) = &item.delete {
215        visit_operation(op, visit);
216    }
217    if let Some(op) = &item.options {
218        visit_operation(op, visit);
219    }
220    if let Some(op) = &item.head {
221        visit_operation(op, visit);
222    }
223    if let Some(op) = &item.patch {
224        visit_operation(op, visit);
225    }
226    if let Some(op) = &item.trace {
227        visit_operation(op, visit);
228    }
229
230    for param in &item.parameters {
231        if let Some(s) = &param.schema {
232            visit(s);
233        }
234    }
235}
236
237fn visit_operation<F>(op: &Operation, visit: &mut F)
238where
239    F: FnMut(&SchemaRef),
240{
241    for param in &op.parameters {
242        if let Some(s) = &param.schema {
243            visit(s);
244        }
245    }
246    if let Some(body) = &op.request_body {
247        for media in body.content.values() {
248            if let Some(s) = &media.schema {
249                visit(s);
250            }
251        }
252    }
253    for resp in op.responses.values() {
254        for media in resp.content.values() {
255            if let Some(s) = &media.schema {
256                visit(s);
257            }
258        }
259        for header in resp.headers.values() {
260            if let Some(s) = &header.schema {
261                visit(s);
262            }
263        }
264    }
265}
266
267fn visit_schema_ref<F>(s: &SchemaRef, check: &mut F)
268where
269    F: FnMut(&str),
270{
271    match s {
272        SchemaRef::Ref { reference } => check(reference),
273        SchemaRef::Schema(boxed) => visit_json_schema(boxed, check),
274        SchemaRef::Inline(_) => {} // Inline JSON value, assume safe or valid
275    }
276}
277
278fn visit_json_schema<F>(s: &JsonSchema2020, check: &mut F)
279where
280    F: FnMut(&str),
281{
282    if let Some(r) = &s.reference {
283        check(r);
284    }
285    if let Some(items) = &s.items {
286        visit_json_schema(items, check);
287    }
288    if let Some(props) = &s.properties {
289        for p in props.values() {
290            visit_json_schema(p, check);
291        }
292    }
293    if let Some(crate::schema::AdditionalProperties::Schema(p)) =
294        &s.additional_properties.as_deref()
295    {
296        visit_json_schema(p, check);
297    }
298    if let Some(one_of) = &s.one_of {
299        for p in one_of {
300            visit_json_schema(p, check);
301        }
302    }
303    if let Some(any_of) = &s.any_of {
304        for p in any_of {
305            visit_json_schema(p, check);
306        }
307    }
308    if let Some(all_of) = &s.all_of {
309        for p in all_of {
310            visit_json_schema(p, check);
311        }
312    }
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, Default)]
316#[serde(rename_all = "camelCase")]
317pub struct ApiInfo {
318    pub title: String,
319    pub version: String,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub summary: Option<String>,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub description: Option<String>,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub terms_of_service: Option<String>,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub contact: Option<Contact>,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub license: Option<License>,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, Default)]
333pub struct Contact {
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub name: Option<String>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub url: Option<String>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub email: Option<String>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize, Default)]
343pub struct License {
344    pub name: String,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub identifier: Option<String>,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub url: Option<String>,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct Server {
353    pub url: String,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub description: Option<String>,
356    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
357    pub variables: BTreeMap<String, ServerVariable>,
358}
359
360impl Server {
361    pub fn new(url: impl Into<String>) -> Self {
362        Self {
363            url: url.into(),
364            description: None,
365            variables: BTreeMap::new(),
366        }
367    }
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase")]
372pub struct ServerVariable {
373    #[serde(rename = "enum", skip_serializing_if = "Vec::is_empty")]
374    pub enum_values: Vec<String>,
375    pub default: String,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub description: Option<String>,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize, Default)]
381pub struct PathItem {
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub summary: Option<String>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub description: Option<String>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub get: Option<Operation>,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub put: Option<Operation>,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub post: Option<Operation>,
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub delete: Option<Operation>,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub options: Option<Operation>,
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub head: Option<Operation>,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub patch: Option<Operation>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub trace: Option<Operation>,
402    #[serde(skip_serializing_if = "Vec::is_empty")]
403    pub servers: Vec<Server>,
404    #[serde(skip_serializing_if = "Vec::is_empty")]
405    pub parameters: Vec<Parameter>,
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize, Default)]
409#[serde(rename_all = "camelCase")]
410pub struct Operation {
411    #[serde(skip_serializing_if = "Vec::is_empty")]
412    pub tags: Vec<String>,
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub summary: Option<String>,
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub description: Option<String>,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub external_docs: Option<ExternalDocs>,
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub operation_id: Option<String>,
421    #[serde(skip_serializing_if = "Vec::is_empty")]
422    pub parameters: Vec<Parameter>,
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub request_body: Option<RequestBody>,
425    pub responses: BTreeMap<String, ResponseSpec>,
426    #[serde(skip_serializing_if = "Vec::is_empty")]
427    pub security: Vec<BTreeMap<String, Vec<String>>>,
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub deprecated: Option<bool>,
430}
431
432impl Operation {
433    pub fn new() -> Self {
434        Self {
435            responses: BTreeMap::from([("200".to_string(), ResponseSpec::default())]),
436            ..Default::default()
437        }
438    }
439
440    pub fn summary(mut self, s: impl Into<String>) -> Self {
441        self.summary = Some(s.into());
442        self
443    }
444
445    pub fn description(mut self, d: impl Into<String>) -> Self {
446        self.description = Some(d.into());
447        self
448    }
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct Parameter {
453    pub name: String,
454    #[serde(rename = "in")]
455    pub location: String,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub description: Option<String>,
458    pub required: bool,
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub deprecated: Option<bool>,
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub schema: Option<SchemaRef>,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct RequestBody {
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub description: Option<String>,
469    pub content: BTreeMap<String, MediaType>,
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub required: Option<bool>,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, Default)]
475pub struct ResponseSpec {
476    pub description: String,
477    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
478    pub content: BTreeMap<String, MediaType>,
479    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
480    pub headers: BTreeMap<String, Header>,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct MediaType {
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub schema: Option<SchemaRef>,
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub example: Option<serde_json::Value>,
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct Header {
493    #[serde(skip_serializing_if = "Option::is_none")]
494    pub description: Option<String>,
495    #[serde(skip_serializing_if = "Option::is_none")]
496    pub schema: Option<SchemaRef>,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize, Default)]
500#[serde(rename_all = "camelCase")]
501pub struct Components {
502    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
503    pub schemas: BTreeMap<String, JsonSchema2020>,
504    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
505    pub responses: BTreeMap<String, ResponseSpec>,
506    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
507    pub parameters: BTreeMap<String, Parameter>,
508    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
509    pub examples: BTreeMap<String, serde_json::Value>,
510    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
511    pub request_bodies: BTreeMap<String, RequestBody>,
512    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
513    pub headers: BTreeMap<String, Header>,
514    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
515    pub security_schemes: BTreeMap<String, SecurityScheme>,
516    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
517    pub links: BTreeMap<String, serde_json::Value>,
518    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
519    pub callbacks: BTreeMap<String, BTreeMap<String, PathItem>>,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize)]
523#[serde(tag = "type", rename_all = "camelCase")]
524pub enum SecurityScheme {
525    ApiKey {
526        name: String,
527        #[serde(rename = "in")]
528        location: String,
529        #[serde(skip_serializing_if = "Option::is_none")]
530        description: Option<String>,
531    },
532    Http {
533        scheme: String,
534        #[serde(skip_serializing_if = "Option::is_none")]
535        bearer_format: Option<String>,
536        #[serde(skip_serializing_if = "Option::is_none")]
537        description: Option<String>,
538    },
539    Oauth2 {
540        flows: Box<OAuthFlows>,
541        #[serde(skip_serializing_if = "Option::is_none")]
542        description: Option<String>,
543    },
544    OpenIdConnect {
545        open_id_connect_url: String,
546        #[serde(skip_serializing_if = "Option::is_none")]
547        description: Option<String>,
548    },
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, Default)]
552#[serde(rename_all = "camelCase")]
553pub struct OAuthFlows {
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub implicit: Option<OAuthFlow>,
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub password: Option<OAuthFlow>,
558    #[serde(skip_serializing_if = "Option::is_none")]
559    pub client_credentials: Option<OAuthFlow>,
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub authorization_code: Option<OAuthFlow>,
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
565#[serde(rename_all = "camelCase")]
566pub struct OAuthFlow {
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub authorization_url: Option<String>,
569    #[serde(skip_serializing_if = "Option::is_none")]
570    pub token_url: Option<String>,
571    #[serde(skip_serializing_if = "Option::is_none")]
572    pub refresh_url: Option<String>,
573    pub scopes: BTreeMap<String, String>,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct Tag {
578    pub name: String,
579    #[serde(skip_serializing_if = "Option::is_none")]
580    pub description: Option<String>,
581    #[serde(skip_serializing_if = "Option::is_none")]
582    pub external_docs: Option<ExternalDocs>,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct ExternalDocs {
587    pub url: String,
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub description: Option<String>,
590}
591
592// Re-exports/Traits needed for backwards compatibility or easy migration
593pub trait OperationModifier {
594    fn update_operation(op: &mut Operation);
595}
596
597pub trait ResponseModifier {
598    fn update_response(op: &mut Operation);
599}
600
601// Helper implementations for OperationModifier/ResponseModifier
602impl<T: OperationModifier> OperationModifier for Option<T> {
603    fn update_operation(op: &mut Operation) {
604        T::update_operation(op);
605        if let Some(body) = &mut op.request_body {
606            body.required = Some(false);
607        }
608    }
609}
610
611impl<T: OperationModifier, E> OperationModifier for Result<T, E> {
612    fn update_operation(op: &mut Operation) {
613        T::update_operation(op);
614    }
615}
616
617macro_rules! impl_op_modifier_for_primitives {
618    ($($ty:ty),*) => {
619        $(
620            impl OperationModifier for $ty {
621                fn update_operation(_op: &mut Operation) {}
622            }
623        )*
624    };
625}
626impl_op_modifier_for_primitives!(
627    i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64, bool, String
628);
629
630impl ResponseModifier for () {
631    fn update_response(op: &mut Operation) {
632        op.responses.insert(
633            "200".to_string(),
634            ResponseSpec {
635                description: "Successful response".into(),
636                ..Default::default()
637            },
638        );
639    }
640}
641
642impl ResponseModifier for String {
643    fn update_response(op: &mut Operation) {
644        let mut content = BTreeMap::new();
645        content.insert(
646            "text/plain".to_string(),
647            MediaType {
648                schema: Some(SchemaRef::Inline(serde_json::json!({"type": "string"}))),
649                example: None,
650            },
651        );
652        op.responses.insert(
653            "200".to_string(),
654            ResponseSpec {
655                description: "Successful response".into(),
656                content,
657                ..Default::default()
658            },
659        );
660    }
661}
662
663impl ResponseModifier for &'static str {
664    fn update_response(op: &mut Operation) {
665        String::update_response(op);
666    }
667}
668
669impl<T: ResponseModifier> ResponseModifier for Option<T> {
670    fn update_response(op: &mut Operation) {
671        T::update_response(op);
672    }
673}
674
675impl<T: ResponseModifier, E: ResponseModifier> ResponseModifier for Result<T, E> {
676    fn update_response(op: &mut Operation) {
677        T::update_response(op);
678        E::update_response(op);
679    }
680}
681
682impl<T> ResponseModifier for http::Response<T> {
683    fn update_response(op: &mut Operation) {
684        op.responses.insert(
685            "200".to_string(),
686            ResponseSpec {
687                description: "Successful response".into(),
688                ..Default::default()
689            },
690        );
691    }
692}