Skip to main content

fastapi_openapi/
spec.rs

1//! OpenAPI 3.1 specification types.
2
3use crate::schema::Schema;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// OpenAPI 3.1 document.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct OpenApi {
10    /// OpenAPI version.
11    pub openapi: String,
12    /// API information.
13    pub info: Info,
14    /// Server list.
15    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16    pub servers: Vec<Server>,
17    /// Path items.
18    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
19    pub paths: HashMap<String, PathItem>,
20    /// Reusable components.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub components: Option<Components>,
23    /// API tags.
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub tags: Vec<Tag>,
26}
27
28/// API information.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Info {
31    /// API title.
32    pub title: String,
33    /// API version.
34    pub version: String,
35    /// API description.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub description: Option<String>,
38    /// Terms of service URL.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub terms_of_service: Option<String>,
41    /// Contact information.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub contact: Option<Contact>,
44    /// License information.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub license: Option<License>,
47}
48
49/// Contact information.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Contact {
52    /// Name.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub name: Option<String>,
55    /// URL.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub url: Option<String>,
58    /// Email.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub email: Option<String>,
61}
62
63/// License information.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct License {
66    /// License name.
67    pub name: String,
68    /// License URL.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub url: Option<String>,
71}
72
73/// Server information.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Server {
76    /// Server URL.
77    pub url: String,
78    /// Server description.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub description: Option<String>,
81}
82
83/// Path item (operations for a path).
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85pub struct PathItem {
86    /// GET operation.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub get: Option<Operation>,
89    /// POST operation.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub post: Option<Operation>,
92    /// PUT operation.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub put: Option<Operation>,
95    /// DELETE operation.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub delete: Option<Operation>,
98    /// PATCH operation.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub patch: Option<Operation>,
101    /// OPTIONS operation.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub options: Option<Operation>,
104    /// HEAD operation.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub head: Option<Operation>,
107}
108
109/// API operation.
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111pub struct Operation {
112    /// Operation ID.
113    #[serde(
114        rename = "operationId",
115        default,
116        skip_serializing_if = "Option::is_none"
117    )]
118    pub operation_id: Option<String>,
119    /// Summary.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub summary: Option<String>,
122    /// Description.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub description: Option<String>,
125    /// Tags.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub tags: Vec<String>,
128    /// Parameters.
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub parameters: Vec<Parameter>,
131    /// Request body.
132    #[serde(
133        rename = "requestBody",
134        default,
135        skip_serializing_if = "Option::is_none"
136    )]
137    pub request_body: Option<RequestBody>,
138    /// Responses.
139    pub responses: HashMap<String, Response>,
140    /// Deprecated flag.
141    #[serde(default, skip_serializing_if = "is_false")]
142    pub deprecated: bool,
143}
144
145fn is_false(b: &bool) -> bool {
146    !*b
147}
148
149/// Create default 200 OK response map.
150fn default_responses() -> HashMap<String, Response> {
151    let mut responses = HashMap::new();
152    responses.insert(
153        "200".to_string(),
154        Response {
155            description: "Successful response".to_string(),
156            content: HashMap::new(),
157        },
158    );
159    responses
160}
161
162/// Operation parameter.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct Parameter {
165    /// Parameter name.
166    pub name: String,
167    /// Parameter location.
168    #[serde(rename = "in")]
169    pub location: ParameterLocation,
170    /// Required flag.
171    #[serde(default)]
172    pub required: bool,
173    /// Parameter schema.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub schema: Option<Schema>,
176    /// Title for display in documentation.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub title: Option<String>,
179    /// Description.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub description: Option<String>,
182    /// Whether the parameter is deprecated.
183    #[serde(default, skip_serializing_if = "is_false")]
184    pub deprecated: bool,
185    /// Example value.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub example: Option<serde_json::Value>,
188    /// Named examples.
189    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
190    pub examples: HashMap<String, Example>,
191}
192
193/// Example object for OpenAPI.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct Example {
196    /// Summary of the example.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub summary: Option<String>,
199    /// Long description.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub description: Option<String>,
202    /// Example value.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub value: Option<serde_json::Value>,
205    /// External URL for the example.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub external_value: Option<String>,
208}
209
210/// Parameter metadata for OpenAPI documentation.
211///
212/// This struct captures metadata attributes that can be specified on
213/// struct fields using `#[param(...)]` attributes.
214///
215/// # Example
216///
217/// ```ignore
218/// #[derive(FromRequest)]
219/// struct MyQuery {
220///     #[param(description = "Search term", deprecated)]
221///     q: Option<String>,
222///
223///     #[param(title = "Page Number", ge = 1)]
224///     page: i32,
225/// }
226/// ```
227#[derive(Debug, Clone, Default)]
228pub struct ParamMeta {
229    /// Display title for the parameter.
230    pub title: Option<String>,
231    /// Description of the parameter.
232    pub description: Option<String>,
233    /// Whether the parameter is deprecated.
234    pub deprecated: bool,
235    /// Whether to include in OpenAPI schema.
236    pub include_in_schema: bool,
237    /// Example value.
238    pub example: Option<serde_json::Value>,
239    /// Named examples.
240    pub examples: HashMap<String, Example>,
241    /// Minimum value constraint (for numbers).
242    pub ge: Option<f64>,
243    /// Maximum value constraint (for numbers).
244    pub le: Option<f64>,
245    /// Exclusive minimum (for numbers).
246    pub gt: Option<f64>,
247    /// Exclusive maximum (for numbers).
248    pub lt: Option<f64>,
249    /// Minimum length (for strings).
250    pub min_length: Option<usize>,
251    /// Maximum length (for strings).
252    pub max_length: Option<usize>,
253    /// Pattern constraint (regex).
254    pub pattern: Option<String>,
255}
256
257impl ParamMeta {
258    /// Create a new parameter metadata with default values.
259    #[must_use]
260    pub fn new() -> Self {
261        Self {
262            include_in_schema: true,
263            ..Default::default()
264        }
265    }
266
267    /// Set the title.
268    #[must_use]
269    pub fn title(mut self, title: impl Into<String>) -> Self {
270        self.title = Some(title.into());
271        self
272    }
273
274    /// Set the description.
275    #[must_use]
276    pub fn description(mut self, description: impl Into<String>) -> Self {
277        self.description = Some(description.into());
278        self
279    }
280
281    /// Mark as deprecated.
282    #[must_use]
283    pub fn deprecated(mut self) -> Self {
284        self.deprecated = true;
285        self
286    }
287
288    /// Exclude from OpenAPI schema.
289    #[must_use]
290    pub fn exclude_from_schema(mut self) -> Self {
291        self.include_in_schema = false;
292        self
293    }
294
295    /// Set an example value.
296    #[must_use]
297    pub fn example(mut self, example: serde_json::Value) -> Self {
298        self.example = Some(example);
299        self
300    }
301
302    /// Set minimum value constraint (>=).
303    #[must_use]
304    pub fn ge(mut self, value: f64) -> Self {
305        self.ge = Some(value);
306        self
307    }
308
309    /// Set maximum value constraint (<=).
310    #[must_use]
311    pub fn le(mut self, value: f64) -> Self {
312        self.le = Some(value);
313        self
314    }
315
316    /// Set exclusive minimum constraint (>).
317    #[must_use]
318    pub fn gt(mut self, value: f64) -> Self {
319        self.gt = Some(value);
320        self
321    }
322
323    /// Set exclusive maximum constraint (<).
324    #[must_use]
325    pub fn lt(mut self, value: f64) -> Self {
326        self.lt = Some(value);
327        self
328    }
329
330    /// Set minimum length for strings.
331    #[must_use]
332    pub fn min_length(mut self, len: usize) -> Self {
333        self.min_length = Some(len);
334        self
335    }
336
337    /// Set maximum length for strings.
338    #[must_use]
339    pub fn max_length(mut self, len: usize) -> Self {
340        self.max_length = Some(len);
341        self
342    }
343
344    /// Set a regex pattern constraint.
345    #[must_use]
346    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
347        self.pattern = Some(pattern.into());
348        self
349    }
350
351    /// Convert to an OpenAPI Parameter.
352    #[must_use]
353    pub fn to_parameter(
354        &self,
355        name: impl Into<String>,
356        location: ParameterLocation,
357        required: bool,
358        schema: Option<Schema>,
359    ) -> Parameter {
360        Parameter {
361            name: name.into(),
362            location,
363            required,
364            schema,
365            title: self.title.clone(),
366            description: self.description.clone(),
367            deprecated: self.deprecated,
368            example: self.example.clone(),
369            examples: self.examples.clone(),
370        }
371    }
372}
373
374/// Trait for types that provide parameter metadata.
375///
376/// Implement this trait to enable automatic OpenAPI parameter documentation.
377pub trait HasParamMeta {
378    /// Get the parameter metadata for this type.
379    fn param_meta() -> ParamMeta {
380        ParamMeta::new()
381    }
382}
383
384/// Parameter location.
385#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
386#[serde(rename_all = "lowercase")]
387pub enum ParameterLocation {
388    /// Path parameter.
389    Path,
390    /// Query parameter.
391    Query,
392    /// Header parameter.
393    Header,
394    /// Cookie parameter.
395    Cookie,
396}
397
398/// Request body.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct RequestBody {
401    /// Required flag.
402    #[serde(default)]
403    pub required: bool,
404    /// Content by media type.
405    pub content: HashMap<String, MediaType>,
406    /// Description.
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub description: Option<String>,
409}
410
411/// Media type content.
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct MediaType {
414    /// Schema.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub schema: Option<Schema>,
417}
418
419/// Response definition.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct Response {
422    /// Description.
423    pub description: String,
424    /// Content by media type.
425    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
426    pub content: HashMap<String, MediaType>,
427}
428
429/// Reusable components.
430#[derive(Debug, Clone, Default, Serialize, Deserialize)]
431pub struct Components {
432    /// Schema definitions.
433    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
434    pub schemas: HashMap<String, Schema>,
435}
436
437/// Schema registry for `#/components/schemas`.
438///
439/// This owns a schema map and provides `register()` helpers that return `$ref`s.
440#[derive(Debug, Default)]
441pub struct SchemaRegistry {
442    schemas: HashMap<String, Schema>,
443}
444
445impl SchemaRegistry {
446    /// Create an empty registry.
447    #[must_use]
448    pub fn new() -> Self {
449        Self {
450            schemas: HashMap::new(),
451        }
452    }
453
454    /// Register `schema` under `name` if it doesn't already exist, and return a `$ref`.
455    ///
456    /// This does **not** overwrite an existing entry, which enables stable deduplication.
457    pub fn register(&mut self, name: impl Into<String>, schema: Schema) -> Schema {
458        let name = name.into();
459        self.schemas.entry(name.clone()).or_insert(schema);
460        Schema::reference(&name)
461    }
462
463    /// Consume the registry and return the underlying schema map.
464    #[must_use]
465    pub fn into_schemas(self) -> HashMap<String, Schema> {
466        self.schemas
467    }
468}
469
470/// A mutable view into an existing component schema map.
471pub struct SchemaRegistryMut<'a> {
472    schemas: &'a mut HashMap<String, Schema>,
473}
474
475impl SchemaRegistryMut<'_> {
476    /// Register `schema` under `name` if it doesn't already exist, and return a `$ref`.
477    pub fn register(&mut self, name: impl Into<String>, schema: Schema) -> Schema {
478        let name = name.into();
479        self.schemas.entry(name.clone()).or_insert(schema);
480        Schema::reference(&name)
481    }
482}
483
484/// API tag.
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct Tag {
487    /// Tag name.
488    pub name: String,
489    /// Tag description.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub description: Option<String>,
492}
493
494// ============================================================================
495// Tests for ParamMeta
496// ============================================================================
497
498#[cfg(test)]
499mod param_meta_tests {
500    use super::*;
501
502    #[test]
503    fn new_creates_default_with_include_in_schema_true() {
504        let meta = ParamMeta::new();
505        assert!(meta.include_in_schema);
506        assert!(meta.title.is_none());
507        assert!(meta.description.is_none());
508        assert!(!meta.deprecated);
509    }
510
511    #[test]
512    fn title_sets_title() {
513        let meta = ParamMeta::new().title("User ID");
514        assert_eq!(meta.title.as_deref(), Some("User ID"));
515    }
516
517    #[test]
518    fn description_sets_description() {
519        let meta = ParamMeta::new().description("The unique identifier");
520        assert_eq!(meta.description.as_deref(), Some("The unique identifier"));
521    }
522
523    #[test]
524    fn deprecated_marks_as_deprecated() {
525        let meta = ParamMeta::new().deprecated();
526        assert!(meta.deprecated);
527    }
528
529    #[test]
530    fn exclude_from_schema_sets_include_false() {
531        let meta = ParamMeta::new().exclude_from_schema();
532        assert!(!meta.include_in_schema);
533    }
534
535    #[test]
536    fn example_sets_example_value() {
537        let meta = ParamMeta::new().example(serde_json::json!(42));
538        assert_eq!(meta.example, Some(serde_json::json!(42)));
539    }
540
541    #[test]
542    fn ge_sets_minimum_constraint() {
543        let meta = ParamMeta::new().ge(1.0);
544        assert_eq!(meta.ge, Some(1.0));
545    }
546
547    #[test]
548    fn le_sets_maximum_constraint() {
549        let meta = ParamMeta::new().le(100.0);
550        assert_eq!(meta.le, Some(100.0));
551    }
552
553    #[test]
554    fn gt_sets_exclusive_minimum() {
555        let meta = ParamMeta::new().gt(0.0);
556        assert_eq!(meta.gt, Some(0.0));
557    }
558
559    #[test]
560    fn lt_sets_exclusive_maximum() {
561        let meta = ParamMeta::new().lt(1000.0);
562        assert_eq!(meta.lt, Some(1000.0));
563    }
564
565    #[test]
566    fn min_length_sets_minimum_string_length() {
567        let meta = ParamMeta::new().min_length(3);
568        assert_eq!(meta.min_length, Some(3));
569    }
570
571    #[test]
572    fn max_length_sets_maximum_string_length() {
573        let meta = ParamMeta::new().max_length(255);
574        assert_eq!(meta.max_length, Some(255));
575    }
576
577    #[test]
578    fn pattern_sets_regex_constraint() {
579        let meta = ParamMeta::new().pattern(r"^\d{4}-\d{2}-\d{2}$");
580        assert_eq!(meta.pattern.as_deref(), Some(r"^\d{4}-\d{2}-\d{2}$"));
581    }
582
583    #[test]
584    fn builder_methods_chain() {
585        let meta = ParamMeta::new()
586            .title("Page")
587            .description("Page number for pagination")
588            .ge(1.0)
589            .le(1000.0)
590            .example(serde_json::json!(1));
591
592        assert_eq!(meta.title.as_deref(), Some("Page"));
593        assert_eq!(
594            meta.description.as_deref(),
595            Some("Page number for pagination")
596        );
597        assert_eq!(meta.ge, Some(1.0));
598        assert_eq!(meta.le, Some(1000.0));
599        assert_eq!(meta.example, Some(serde_json::json!(1)));
600    }
601
602    #[test]
603    fn to_parameter_creates_parameter_with_metadata() {
604        let meta = ParamMeta::new()
605            .title("User ID")
606            .description("Unique user identifier")
607            .deprecated()
608            .example(serde_json::json!(42));
609
610        let param = meta.to_parameter("user_id", ParameterLocation::Path, true, None);
611
612        assert_eq!(param.name, "user_id");
613        assert!(matches!(param.location, ParameterLocation::Path));
614        assert!(param.required);
615        assert_eq!(param.title.as_deref(), Some("User ID"));
616        assert_eq!(param.description.as_deref(), Some("Unique user identifier"));
617        assert!(param.deprecated);
618        assert_eq!(param.example, Some(serde_json::json!(42)));
619    }
620
621    #[test]
622    fn to_parameter_with_query_location() {
623        let meta = ParamMeta::new().description("Search query");
624        let param = meta.to_parameter("q", ParameterLocation::Query, false, None);
625
626        assert_eq!(param.name, "q");
627        assert!(matches!(param.location, ParameterLocation::Query));
628        assert!(!param.required);
629    }
630
631    #[test]
632    fn to_parameter_with_header_location() {
633        let meta = ParamMeta::new().description("API key");
634        let param = meta.to_parameter("X-API-Key", ParameterLocation::Header, true, None);
635
636        assert_eq!(param.name, "X-API-Key");
637        assert!(matches!(param.location, ParameterLocation::Header));
638    }
639
640    #[test]
641    fn to_parameter_with_cookie_location() {
642        let meta = ParamMeta::new().description("Session cookie");
643        let param = meta.to_parameter("session", ParameterLocation::Cookie, false, None);
644
645        assert_eq!(param.name, "session");
646        assert!(matches!(param.location, ParameterLocation::Cookie));
647    }
648
649    #[test]
650    fn default_param_meta_is_empty() {
651        let meta = ParamMeta::default();
652        assert!(meta.title.is_none());
653        assert!(meta.description.is_none());
654        assert!(!meta.deprecated);
655        assert!(!meta.include_in_schema); // Default::default() sets to false
656        assert!(meta.example.is_none());
657        assert!(meta.ge.is_none());
658        assert!(meta.le.is_none());
659        assert!(meta.gt.is_none());
660        assert!(meta.lt.is_none());
661        assert!(meta.min_length.is_none());
662        assert!(meta.max_length.is_none());
663        assert!(meta.pattern.is_none());
664    }
665
666    #[test]
667    fn string_constraints_together() {
668        let meta = ParamMeta::new()
669            .min_length(1)
670            .max_length(100)
671            .pattern(r"^[a-zA-Z]+$");
672
673        assert_eq!(meta.min_length, Some(1));
674        assert_eq!(meta.max_length, Some(100));
675        assert_eq!(meta.pattern.as_deref(), Some(r"^[a-zA-Z]+$"));
676    }
677
678    #[test]
679    fn numeric_constraints_together() {
680        let meta = ParamMeta::new().gt(0.0).lt(100.0).ge(1.0).le(99.0);
681
682        assert_eq!(meta.gt, Some(0.0));
683        assert_eq!(meta.lt, Some(100.0));
684        assert_eq!(meta.ge, Some(1.0));
685        assert_eq!(meta.le, Some(99.0));
686    }
687}
688
689// ============================================================================
690// Tests for OpenAPI types serialization
691// ============================================================================
692
693#[cfg(test)]
694mod serialization_tests {
695    use super::*;
696
697    #[test]
698    fn parameter_serializes_location_as_in() {
699        let param = Parameter {
700            name: "id".to_string(),
701            location: ParameterLocation::Path,
702            required: true,
703            schema: None,
704            title: None,
705            description: None,
706            deprecated: false,
707            example: None,
708            examples: HashMap::new(),
709        };
710
711        let json = serde_json::to_string(&param).unwrap();
712        assert!(json.contains(r#""in":"path""#));
713    }
714
715    #[test]
716    fn parameter_location_serializes_lowercase() {
717        let path_json = serde_json::to_string(&ParameterLocation::Path).unwrap();
718        assert_eq!(path_json, r#""path""#);
719
720        let query_json = serde_json::to_string(&ParameterLocation::Query).unwrap();
721        assert_eq!(query_json, r#""query""#);
722
723        let header_json = serde_json::to_string(&ParameterLocation::Header).unwrap();
724        assert_eq!(header_json, r#""header""#);
725
726        let cookie_json = serde_json::to_string(&ParameterLocation::Cookie).unwrap();
727        assert_eq!(cookie_json, r#""cookie""#);
728    }
729
730    #[test]
731    fn parameter_skips_false_deprecated() {
732        let param = Parameter {
733            name: "id".to_string(),
734            location: ParameterLocation::Path,
735            required: true,
736            schema: None,
737            title: None,
738            description: None,
739            deprecated: false,
740            example: None,
741            examples: HashMap::new(),
742        };
743
744        let json = serde_json::to_string(&param).unwrap();
745        assert!(!json.contains("deprecated"));
746    }
747
748    #[test]
749    fn parameter_includes_true_deprecated() {
750        let param = Parameter {
751            name: "old_id".to_string(),
752            location: ParameterLocation::Path,
753            required: true,
754            schema: None,
755            title: None,
756            description: Some("Deprecated, use new_id instead".to_string()),
757            deprecated: true,
758            example: None,
759            examples: HashMap::new(),
760        };
761
762        let json = serde_json::to_string(&param).unwrap();
763        assert!(json.contains(r#""deprecated":true"#));
764    }
765
766    #[test]
767    fn openapi_builder_creates_valid_document() {
768        let doc = OpenApiBuilder::new("Test API", "1.0.0")
769            .description("A test API")
770            .server("https://api.example.com", Some("Production".to_string()))
771            .tag("users", Some("User operations".to_string()))
772            .build();
773
774        assert_eq!(doc.openapi, "3.1.0");
775        assert_eq!(doc.info.title, "Test API");
776        assert_eq!(doc.info.version, "1.0.0");
777        assert_eq!(doc.info.description.as_deref(), Some("A test API"));
778        assert_eq!(doc.servers.len(), 1);
779        assert_eq!(doc.servers[0].url, "https://api.example.com");
780        assert_eq!(doc.tags.len(), 1);
781        assert_eq!(doc.tags[0].name, "users");
782    }
783
784    #[test]
785    fn openapi_serializes_to_valid_json() {
786        let doc = OpenApiBuilder::new("Test API", "1.0.0").build();
787        let json = serde_json::to_string_pretty(&doc).unwrap();
788
789        assert!(json.contains(r#""openapi": "3.1.0""#));
790        assert!(json.contains(r#""title": "Test API""#));
791        assert!(json.contains(r#""version": "1.0.0""#));
792    }
793
794    #[test]
795    fn example_serializes_all_fields() {
796        let example = Example {
797            summary: Some("Example summary".to_string()),
798            description: Some("Example description".to_string()),
799            value: Some(serde_json::json!({"key": "value"})),
800            external_value: None,
801        };
802
803        let json = serde_json::to_string(&example).unwrap();
804        assert!(json.contains(r#""summary":"Example summary""#));
805        assert!(json.contains(r#""description":"Example description""#));
806        assert!(json.contains(r#""value""#));
807    }
808}
809
810/// OpenAPI document builder.
811pub struct OpenApiBuilder {
812    info: Info,
813    servers: Vec<Server>,
814    paths: HashMap<String, PathItem>,
815    components: Components,
816    tags: Vec<Tag>,
817}
818
819impl OpenApiBuilder {
820    /// Create a new builder.
821    #[must_use]
822    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
823        Self {
824            info: Info {
825                title: title.into(),
826                version: version.into(),
827                description: None,
828                terms_of_service: None,
829                contact: None,
830                license: None,
831            },
832            servers: Vec::new(),
833            paths: HashMap::new(),
834            components: Components::default(),
835            tags: Vec::new(),
836        }
837    }
838
839    /// Add a description.
840    #[must_use]
841    pub fn description(mut self, description: impl Into<String>) -> Self {
842        self.info.description = Some(description.into());
843        self
844    }
845
846    /// Add a server.
847    #[must_use]
848    pub fn server(mut self, url: impl Into<String>, description: Option<String>) -> Self {
849        self.servers.push(Server {
850            url: url.into(),
851            description,
852        });
853        self
854    }
855
856    /// Add a tag.
857    #[must_use]
858    pub fn tag(mut self, name: impl Into<String>, description: Option<String>) -> Self {
859        self.tags.push(Tag {
860            name: name.into(),
861            description,
862        });
863        self
864    }
865
866    /// Add a schema component.
867    #[must_use]
868    pub fn schema(mut self, name: impl Into<String>, schema: Schema) -> Self {
869        self.components.schemas.insert(name.into(), schema);
870        self
871    }
872
873    /// Access the component schema registry for in-place registration.
874    pub fn registry(&mut self) -> SchemaRegistryMut<'_> {
875        SchemaRegistryMut {
876            schemas: &mut self.components.schemas,
877        }
878    }
879
880    /// Add a metadata-rich route (from `fastapi-router`) as an OpenAPI operation.
881    ///
882    /// This is a convenience bridge used by integration tests and by higher-level crates.
883    #[allow(clippy::too_many_lines)]
884    pub fn add_route(&mut self, route: &fastapi_router::Route) {
885        use fastapi_router::Converter as RouteConverter;
886
887        fn param_schema(conv: RouteConverter) -> Schema {
888            match conv {
889                RouteConverter::Str | RouteConverter::Path => Schema::string(),
890                RouteConverter::Int => Schema::integer(Some("int64")),
891                RouteConverter::Float => Schema::number(Some("double")),
892                RouteConverter::Uuid => Schema::Primitive(crate::schema::PrimitiveSchema {
893                    schema_type: crate::schema::SchemaType::String,
894                    format: Some("uuid".to_string()),
895                    nullable: false,
896                }),
897            }
898        }
899
900        let mut op = Operation {
901            operation_id: if route.operation_id.is_empty() {
902                None
903            } else {
904                Some(route.operation_id.clone())
905            },
906            summary: route.summary.clone(),
907            description: route.description.clone(),
908            tags: route.tags.clone(),
909            deprecated: route.deprecated,
910            ..Default::default()
911        };
912
913        // Path parameters.
914        for p in &route.path_params {
915            let mut examples = HashMap::new();
916            for (name, value) in &p.examples {
917                examples.insert(
918                    name.clone(),
919                    Example {
920                        summary: None,
921                        description: None,
922                        value: Some(value.clone()),
923                        external_value: None,
924                    },
925                );
926            }
927
928            op.parameters.push(Parameter {
929                name: p.name.clone(),
930                location: ParameterLocation::Path,
931                required: true,
932                schema: Some(param_schema(p.converter)),
933                title: p.title.clone(),
934                description: p.description.clone(),
935                deprecated: p.deprecated,
936                example: p.example.clone(),
937                examples,
938            });
939        }
940
941        // Request body.
942        if let Some(schema_name) = &route.request_body_schema {
943            let content_type = route
944                .request_body_content_type
945                .clone()
946                .unwrap_or_else(|| "application/json".to_string());
947            let mut content = HashMap::new();
948            content.insert(
949                content_type,
950                MediaType {
951                    schema: Some(Schema::reference(schema_name)),
952                },
953            );
954            op.request_body = Some(RequestBody {
955                required: route.request_body_required,
956                content,
957                description: None,
958            });
959        }
960
961        // Responses.
962        let mut responses = HashMap::new();
963        if route.responses.is_empty() {
964            responses = default_responses();
965        } else {
966            for r in &route.responses {
967                let mut content = HashMap::new();
968                content.insert(
969                    "application/json".to_string(),
970                    MediaType {
971                        schema: Some(Schema::reference(&r.schema_name)),
972                    },
973                );
974                responses.insert(
975                    r.status.to_string(),
976                    Response {
977                        description: r.description.clone(),
978                        content,
979                    },
980                );
981            }
982        }
983        op.responses = responses;
984
985        let path_item = self.paths.entry(route.path.clone()).or_default();
986        match route.method.as_str() {
987            "GET" => path_item.get = Some(op),
988            "POST" => path_item.post = Some(op),
989            "PUT" => path_item.put = Some(op),
990            "DELETE" => path_item.delete = Some(op),
991            "PATCH" => path_item.patch = Some(op),
992            "OPTIONS" => path_item.options = Some(op),
993            "HEAD" => path_item.head = Some(op),
994            _ => {}
995        }
996    }
997
998    /// Add multiple routes.
999    pub fn add_routes<'a, I>(&mut self, routes: I)
1000    where
1001        I: IntoIterator<Item = &'a fastapi_router::Route>,
1002    {
1003        for r in routes {
1004            self.add_route(r);
1005        }
1006    }
1007
1008    /// Add a path operation (GET, POST, etc.).
1009    ///
1010    /// This is the primary method for registering routes with OpenAPI.
1011    ///
1012    /// # Example
1013    ///
1014    /// ```ignore
1015    /// let builder = OpenApiBuilder::new("My API", "1.0.0")
1016    ///     .operation("GET", "/users", Operation {
1017    ///         operation_id: Some("get_users".to_string()),
1018    ///         summary: Some("List all users".to_string()),
1019    ///         responses: HashMap::from([
1020    ///             ("200".to_string(), Response {
1021    ///                 description: "Success".to_string(),
1022    ///                 content: HashMap::new(),
1023    ///             })
1024    ///         ]),
1025    ///         ..Default::default()
1026    ///     });
1027    /// ```
1028    #[must_use]
1029    pub fn operation(
1030        mut self,
1031        method: &str,
1032        path: impl Into<String>,
1033        operation: Operation,
1034    ) -> Self {
1035        let path = path.into();
1036        let path_item = self.paths.entry(path).or_default();
1037
1038        match method.to_uppercase().as_str() {
1039            "GET" => path_item.get = Some(operation),
1040            "POST" => path_item.post = Some(operation),
1041            "PUT" => path_item.put = Some(operation),
1042            "DELETE" => path_item.delete = Some(operation),
1043            "PATCH" => path_item.patch = Some(operation),
1044            "OPTIONS" => path_item.options = Some(operation),
1045            "HEAD" => path_item.head = Some(operation),
1046            _ => {} // Ignore unknown methods
1047        }
1048
1049        self
1050    }
1051
1052    /// Add a simple GET endpoint with default 200 response.
1053    #[must_use]
1054    pub fn get(self, path: impl Into<String>, operation_id: impl Into<String>) -> Self {
1055        let operation_id = operation_id.into();
1056        self.operation(
1057            "GET",
1058            path,
1059            Operation {
1060                operation_id: if operation_id.is_empty() {
1061                    None
1062                } else {
1063                    Some(operation_id)
1064                },
1065                responses: default_responses(),
1066                ..Default::default()
1067            },
1068        )
1069    }
1070
1071    /// Add a simple POST endpoint with default 200 response.
1072    #[must_use]
1073    pub fn post(self, path: impl Into<String>, operation_id: impl Into<String>) -> Self {
1074        let operation_id = operation_id.into();
1075        self.operation(
1076            "POST",
1077            path,
1078            Operation {
1079                operation_id: if operation_id.is_empty() {
1080                    None
1081                } else {
1082                    Some(operation_id)
1083                },
1084                responses: default_responses(),
1085                ..Default::default()
1086            },
1087        )
1088    }
1089
1090    /// Add a simple PUT endpoint with default 200 response.
1091    #[must_use]
1092    pub fn put(self, path: impl Into<String>, operation_id: impl Into<String>) -> Self {
1093        let operation_id = operation_id.into();
1094        self.operation(
1095            "PUT",
1096            path,
1097            Operation {
1098                operation_id: if operation_id.is_empty() {
1099                    None
1100                } else {
1101                    Some(operation_id)
1102                },
1103                responses: default_responses(),
1104                ..Default::default()
1105            },
1106        )
1107    }
1108
1109    /// Add a simple DELETE endpoint with default 200 response.
1110    #[must_use]
1111    pub fn delete(self, path: impl Into<String>, operation_id: impl Into<String>) -> Self {
1112        let operation_id = operation_id.into();
1113        self.operation(
1114            "DELETE",
1115            path,
1116            Operation {
1117                operation_id: if operation_id.is_empty() {
1118                    None
1119                } else {
1120                    Some(operation_id)
1121                },
1122                responses: default_responses(),
1123                ..Default::default()
1124            },
1125        )
1126    }
1127
1128    /// Add a simple PATCH endpoint with default 200 response.
1129    #[must_use]
1130    pub fn patch(self, path: impl Into<String>, operation_id: impl Into<String>) -> Self {
1131        let operation_id = operation_id.into();
1132        self.operation(
1133            "PATCH",
1134            path,
1135            Operation {
1136                operation_id: if operation_id.is_empty() {
1137                    None
1138                } else {
1139                    Some(operation_id)
1140                },
1141                responses: default_responses(),
1142                ..Default::default()
1143            },
1144        )
1145    }
1146
1147    /// Build the OpenAPI document.
1148    #[must_use]
1149    pub fn build(self) -> OpenApi {
1150        OpenApi {
1151            openapi: "3.1.0".to_string(),
1152            info: self.info,
1153            servers: self.servers,
1154            paths: self.paths,
1155            components: if self.components.schemas.is_empty() {
1156                None
1157            } else {
1158                Some(self.components)
1159            },
1160            tags: self.tags,
1161        }
1162    }
1163}