Skip to main content

fastapi_openapi/
spec.rs

1//! OpenAPI 3.1 specification types.
2
3use crate::schema::{Schema, SchemaRegistry};
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    /// Security requirements applied to all operations.
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub security: Vec<SecurityRequirement>,
29}
30
31/// Security requirement object.
32///
33/// Each key is a security scheme name, and the value is a list of scopes
34/// required for that scheme (empty for schemes that don't use scopes).
35pub type SecurityRequirement = HashMap<String, Vec<String>>;
36
37/// API information.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Info {
40    /// API title.
41    pub title: String,
42    /// API version.
43    pub version: String,
44    /// API description.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub description: Option<String>,
47    /// Terms of service URL.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub terms_of_service: Option<String>,
50    /// Contact information.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub contact: Option<Contact>,
53    /// License information.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub license: Option<License>,
56}
57
58/// Contact information.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Contact {
61    /// Name.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub name: Option<String>,
64    /// URL.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub url: Option<String>,
67    /// Email.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub email: Option<String>,
70}
71
72/// License information.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct License {
75    /// License name.
76    pub name: String,
77    /// License URL.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub url: Option<String>,
80}
81
82/// Server information.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct Server {
85    /// Server URL.
86    pub url: String,
87    /// Server description.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub description: Option<String>,
90}
91
92/// Path item (operations for a path).
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94pub struct PathItem {
95    /// GET operation.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub get: Option<Operation>,
98    /// POST operation.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub post: Option<Operation>,
101    /// PUT operation.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub put: Option<Operation>,
104    /// DELETE operation.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub delete: Option<Operation>,
107    /// PATCH operation.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub patch: Option<Operation>,
110    /// OPTIONS operation.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub options: Option<Operation>,
113    /// HEAD operation.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub head: Option<Operation>,
116}
117
118/// API operation.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct Operation {
122    /// Operation ID.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub operation_id: Option<String>,
125    /// Summary.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub summary: Option<String>,
128    /// Description.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub description: Option<String>,
131    /// Tags.
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub tags: Vec<String>,
134    /// Parameters.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub parameters: Vec<Parameter>,
137    /// Request body.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub request_body: Option<RequestBody>,
140    /// Responses.
141    pub responses: HashMap<String, Response>,
142    /// Deprecated flag.
143    #[serde(default, skip_serializing_if = "is_false")]
144    pub deprecated: bool,
145    /// Security requirements for this operation.
146    ///
147    /// Overrides the top-level security requirements when specified.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub security: Vec<SecurityRequirement>,
150}
151
152fn is_false(b: &bool) -> bool {
153    !*b
154}
155
156/// Operation parameter.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct Parameter {
159    /// Parameter name.
160    pub name: String,
161    /// Parameter location.
162    #[serde(rename = "in")]
163    pub location: ParameterLocation,
164    /// Required flag.
165    #[serde(default)]
166    pub required: bool,
167    /// Parameter schema.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub schema: Option<Schema>,
170    /// Title for display in documentation.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub title: Option<String>,
173    /// Description.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub description: Option<String>,
176    /// Whether the parameter is deprecated.
177    #[serde(default, skip_serializing_if = "is_false")]
178    pub deprecated: bool,
179    /// Example value.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub example: Option<serde_json::Value>,
182    /// Named examples.
183    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
184    pub examples: HashMap<String, Example>,
185}
186
187/// Example object for OpenAPI.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct Example {
190    /// Summary of the example.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub summary: Option<String>,
193    /// Long description.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub description: Option<String>,
196    /// Example value.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub value: Option<serde_json::Value>,
199    /// External URL for the example.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub external_value: Option<String>,
202}
203
204/// Parameter metadata for OpenAPI documentation.
205///
206/// This struct captures metadata attributes that can be specified on
207/// struct fields using `#[param(...)]` attributes.
208///
209/// # Example
210///
211/// ```ignore
212/// #[derive(FromRequest)]
213/// struct MyQuery {
214///     #[param(description = "Search term", deprecated)]
215///     q: Option<String>,
216///
217///     #[param(title = "Page Number", ge = 1)]
218///     page: i32,
219/// }
220/// ```
221#[derive(Debug, Clone, Default)]
222pub struct ParamMeta {
223    /// Display title for the parameter.
224    pub title: Option<String>,
225    /// Description of the parameter.
226    pub description: Option<String>,
227    /// Whether the parameter is deprecated.
228    pub deprecated: bool,
229    /// Whether to include in OpenAPI schema.
230    pub include_in_schema: bool,
231    /// Example value.
232    pub example: Option<serde_json::Value>,
233    /// Named examples.
234    pub examples: HashMap<String, Example>,
235    /// Minimum value constraint (for numbers).
236    pub ge: Option<f64>,
237    /// Maximum value constraint (for numbers).
238    pub le: Option<f64>,
239    /// Exclusive minimum (for numbers).
240    pub gt: Option<f64>,
241    /// Exclusive maximum (for numbers).
242    pub lt: Option<f64>,
243    /// Minimum length (for strings).
244    pub min_length: Option<usize>,
245    /// Maximum length (for strings).
246    pub max_length: Option<usize>,
247    /// Pattern constraint (regex).
248    pub pattern: Option<String>,
249    /// Alternative name used in request (query/header/form).
250    /// If set and `validation_alias` is None, this is also used for validation.
251    /// If set and `serialization_alias` is None, this is used for OpenAPI schema.
252    pub alias: Option<String>,
253    /// Name used specifically for validation (overrides `alias` for validation).
254    pub validation_alias: Option<String>,
255    /// Name used specifically for serialization/OpenAPI (overrides `alias` for serialization).
256    pub serialization_alias: Option<String>,
257}
258
259impl ParamMeta {
260    /// Create a new parameter metadata with default values.
261    #[must_use]
262    pub fn new() -> Self {
263        Self {
264            include_in_schema: true,
265            ..Default::default()
266        }
267    }
268
269    /// Set the title.
270    #[must_use]
271    pub fn title(mut self, title: impl Into<String>) -> Self {
272        self.title = Some(title.into());
273        self
274    }
275
276    /// Set the description.
277    #[must_use]
278    pub fn description(mut self, description: impl Into<String>) -> Self {
279        self.description = Some(description.into());
280        self
281    }
282
283    /// Mark as deprecated.
284    #[must_use]
285    pub fn deprecated(mut self) -> Self {
286        self.deprecated = true;
287        self
288    }
289
290    /// Exclude from OpenAPI schema.
291    #[must_use]
292    pub fn exclude_from_schema(mut self) -> Self {
293        self.include_in_schema = false;
294        self
295    }
296
297    /// Set an example value.
298    #[must_use]
299    pub fn example(mut self, example: serde_json::Value) -> Self {
300        self.example = Some(example);
301        self
302    }
303
304    /// Set minimum value constraint (>=).
305    #[must_use]
306    pub fn ge(mut self, value: f64) -> Self {
307        self.ge = Some(value);
308        self
309    }
310
311    /// Set maximum value constraint (<=).
312    #[must_use]
313    pub fn le(mut self, value: f64) -> Self {
314        self.le = Some(value);
315        self
316    }
317
318    /// Set exclusive minimum constraint (>).
319    #[must_use]
320    pub fn gt(mut self, value: f64) -> Self {
321        self.gt = Some(value);
322        self
323    }
324
325    /// Set exclusive maximum constraint (<).
326    #[must_use]
327    pub fn lt(mut self, value: f64) -> Self {
328        self.lt = Some(value);
329        self
330    }
331
332    /// Set minimum length for strings.
333    #[must_use]
334    pub fn min_length(mut self, len: usize) -> Self {
335        self.min_length = Some(len);
336        self
337    }
338
339    /// Set maximum length for strings.
340    #[must_use]
341    pub fn max_length(mut self, len: usize) -> Self {
342        self.max_length = Some(len);
343        self
344    }
345
346    /// Set a regex pattern constraint.
347    #[must_use]
348    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
349        self.pattern = Some(pattern.into());
350        self
351    }
352
353    /// Set an alias for this parameter.
354    ///
355    /// The alias is the alternative name used in the request (query string, header, etc.).
356    /// If `validation_alias` is not set, this value is also used for validation.
357    /// If `serialization_alias` is not set, this value is used in OpenAPI schema.
358    ///
359    /// # Example
360    ///
361    /// ```ignore
362    /// #[param(alias = "q")]
363    /// query: String,  // Accepts ?q=value instead of ?query=value
364    /// ```
365    #[must_use]
366    pub fn alias(mut self, alias: impl Into<String>) -> Self {
367        self.alias = Some(alias.into());
368        self
369    }
370
371    /// Set a validation-specific alias.
372    ///
373    /// This name is used when validating/extracting the parameter from the request.
374    /// Overrides the general `alias` for validation purposes.
375    #[must_use]
376    pub fn validation_alias(mut self, alias: impl Into<String>) -> Self {
377        self.validation_alias = Some(alias.into());
378        self
379    }
380
381    /// Set a serialization-specific alias.
382    ///
383    /// This name is used in the OpenAPI schema for this parameter.
384    /// Overrides the general `alias` for serialization/OpenAPI purposes.
385    #[must_use]
386    pub fn serialization_alias(mut self, alias: impl Into<String>) -> Self {
387        self.serialization_alias = Some(alias.into());
388        self
389    }
390
391    /// Get the effective name for extraction/validation.
392    ///
393    /// Returns `validation_alias` if set, otherwise `alias` if set, otherwise `None`.
394    #[must_use]
395    pub fn effective_validation_name(&self) -> Option<&str> {
396        self.validation_alias.as_deref().or(self.alias.as_deref())
397    }
398
399    /// Get the effective name for OpenAPI/serialization.
400    ///
401    /// Returns `serialization_alias` if set, otherwise `alias` if set, otherwise `None`.
402    #[must_use]
403    pub fn effective_serialization_name(&self) -> Option<&str> {
404        self.serialization_alias
405            .as_deref()
406            .or(self.alias.as_deref())
407    }
408
409    /// Convert to an OpenAPI Parameter.
410    ///
411    /// If a serialization alias is configured (via `serialization_alias` or `alias`),
412    /// it will be used as the parameter name in the OpenAPI schema.
413    #[must_use]
414    pub fn to_parameter(
415        &self,
416        name: impl Into<String>,
417        location: ParameterLocation,
418        required: bool,
419        schema: Option<Schema>,
420    ) -> Parameter {
421        // Use the effective serialization name if set, otherwise use the provided name
422        let effective_name = self
423            .effective_serialization_name()
424            .map_or_else(|| name.into(), String::from);
425        Parameter {
426            name: effective_name,
427            location,
428            required,
429            schema,
430            title: self.title.clone(),
431            description: self.description.clone(),
432            deprecated: self.deprecated,
433            example: self.example.clone(),
434            examples: self.examples.clone(),
435        }
436    }
437}
438
439/// Trait for types that provide parameter metadata.
440///
441/// Implement this trait to enable automatic OpenAPI parameter documentation.
442pub trait HasParamMeta {
443    /// Get the parameter metadata for this type.
444    fn param_meta() -> ParamMeta {
445        ParamMeta::new()
446    }
447}
448
449/// Parameter location.
450#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
451#[serde(rename_all = "lowercase")]
452pub enum ParameterLocation {
453    /// Path parameter.
454    Path,
455    /// Query parameter.
456    Query,
457    /// Header parameter.
458    Header,
459    /// Cookie parameter.
460    Cookie,
461}
462
463/// Request body.
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct RequestBody {
466    /// Required flag.
467    #[serde(default)]
468    pub required: bool,
469    /// Content by media type.
470    pub content: HashMap<String, MediaType>,
471    /// Description.
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub description: Option<String>,
474}
475
476/// Media type content.
477#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct MediaType {
479    /// Schema.
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub schema: Option<Schema>,
482    /// Single example value.
483    #[serde(default, skip_serializing_if = "Option::is_none")]
484    pub example: Option<serde_json::Value>,
485    /// Named examples.
486    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
487    pub examples: HashMap<String, Example>,
488}
489
490/// Response definition.
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct Response {
493    /// Description.
494    pub description: String,
495    /// Content by media type.
496    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
497    pub content: HashMap<String, MediaType>,
498}
499
500/// Security scheme definition.
501#[derive(Debug, Clone, Serialize, Deserialize)]
502#[serde(tag = "type", rename_all = "camelCase")]
503#[allow(clippy::large_enum_variant)] // OAuth2 variant is large but rarely copied
504pub enum SecurityScheme {
505    /// API key authentication.
506    #[serde(rename = "apiKey")]
507    ApiKey {
508        /// Parameter name.
509        name: String,
510        /// Location of the API key.
511        #[serde(rename = "in")]
512        location: ApiKeyLocation,
513        /// Description.
514        #[serde(default, skip_serializing_if = "Option::is_none")]
515        description: Option<String>,
516    },
517    /// HTTP authentication (Basic, Bearer, etc.).
518    #[serde(rename = "http")]
519    Http {
520        /// Authentication scheme (e.g., "basic", "bearer").
521        scheme: String,
522        /// Bearer token format (e.g., "JWT").
523        #[serde(
524            default,
525            skip_serializing_if = "Option::is_none",
526            rename = "bearerFormat"
527        )]
528        bearer_format: Option<String>,
529        /// Description.
530        #[serde(default, skip_serializing_if = "Option::is_none")]
531        description: Option<String>,
532    },
533    /// OAuth 2.0 authentication.
534    #[serde(rename = "oauth2")]
535    OAuth2 {
536        /// OAuth 2.0 flows.
537        flows: OAuth2Flows,
538        /// Description.
539        #[serde(default, skip_serializing_if = "Option::is_none")]
540        description: Option<String>,
541    },
542    /// OpenID Connect authentication.
543    #[serde(rename = "openIdConnect")]
544    OpenIdConnect {
545        /// OpenID Connect discovery URL.
546        #[serde(rename = "openIdConnectUrl")]
547        open_id_connect_url: String,
548        /// Description.
549        #[serde(default, skip_serializing_if = "Option::is_none")]
550        description: Option<String>,
551    },
552}
553
554/// Location of an API key.
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(rename_all = "lowercase")]
557pub enum ApiKeyLocation {
558    /// API key in query parameter.
559    Query,
560    /// API key in header.
561    Header,
562    /// API key in cookie.
563    Cookie,
564}
565
566/// OAuth 2.0 flow configurations.
567#[derive(Debug, Clone, Default, Serialize, Deserialize)]
568#[serde(rename_all = "camelCase")]
569pub struct OAuth2Flows {
570    /// Implicit flow.
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub implicit: Option<OAuth2Flow>,
573    /// Authorization code flow.
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub authorization_code: Option<OAuth2Flow>,
576    /// Client credentials flow.
577    #[serde(default, skip_serializing_if = "Option::is_none")]
578    pub client_credentials: Option<OAuth2Flow>,
579    /// Resource owner password flow.
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub password: Option<OAuth2Flow>,
582}
583
584/// OAuth 2.0 flow configuration.
585#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(rename_all = "camelCase")]
587pub struct OAuth2Flow {
588    /// Authorization URL (for implicit and authorization_code flows).
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub authorization_url: Option<String>,
591    /// Token URL (for password, client_credentials, and authorization_code flows).
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub token_url: Option<String>,
594    /// Refresh URL.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub refresh_url: Option<String>,
597    /// Available scopes.
598    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
599    pub scopes: HashMap<String, String>,
600}
601
602/// Reusable components.
603#[derive(Debug, Clone, Default, Serialize, Deserialize)]
604#[serde(rename_all = "camelCase")]
605pub struct Components {
606    /// Schema definitions.
607    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
608    pub schemas: HashMap<String, Schema>,
609    /// Security scheme definitions.
610    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
611    pub security_schemes: HashMap<String, SecurityScheme>,
612}
613
614/// API tag.
615#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct Tag {
617    /// Tag name.
618    pub name: String,
619    /// Tag description.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub description: Option<String>,
622}
623
624// ============================================================================
625// Tests for ParamMeta
626// ============================================================================
627
628#[cfg(test)]
629mod param_meta_tests {
630    use super::*;
631
632    #[test]
633    fn new_creates_default_with_include_in_schema_true() {
634        let meta = ParamMeta::new();
635        assert!(meta.include_in_schema);
636        assert!(meta.title.is_none());
637        assert!(meta.description.is_none());
638        assert!(!meta.deprecated);
639    }
640
641    #[test]
642    fn title_sets_title() {
643        let meta = ParamMeta::new().title("User ID");
644        assert_eq!(meta.title.as_deref(), Some("User ID"));
645    }
646
647    #[test]
648    fn description_sets_description() {
649        let meta = ParamMeta::new().description("The unique identifier");
650        assert_eq!(meta.description.as_deref(), Some("The unique identifier"));
651    }
652
653    #[test]
654    fn deprecated_marks_as_deprecated() {
655        let meta = ParamMeta::new().deprecated();
656        assert!(meta.deprecated);
657    }
658
659    #[test]
660    fn exclude_from_schema_sets_include_false() {
661        let meta = ParamMeta::new().exclude_from_schema();
662        assert!(!meta.include_in_schema);
663    }
664
665    #[test]
666    fn example_sets_example_value() {
667        let meta = ParamMeta::new().example(serde_json::json!(42));
668        assert_eq!(meta.example, Some(serde_json::json!(42)));
669    }
670
671    #[test]
672    fn ge_sets_minimum_constraint() {
673        let meta = ParamMeta::new().ge(1.0);
674        assert_eq!(meta.ge, Some(1.0));
675    }
676
677    #[test]
678    fn le_sets_maximum_constraint() {
679        let meta = ParamMeta::new().le(100.0);
680        assert_eq!(meta.le, Some(100.0));
681    }
682
683    #[test]
684    fn gt_sets_exclusive_minimum() {
685        let meta = ParamMeta::new().gt(0.0);
686        assert_eq!(meta.gt, Some(0.0));
687    }
688
689    #[test]
690    fn lt_sets_exclusive_maximum() {
691        let meta = ParamMeta::new().lt(1000.0);
692        assert_eq!(meta.lt, Some(1000.0));
693    }
694
695    #[test]
696    fn min_length_sets_minimum_string_length() {
697        let meta = ParamMeta::new().min_length(3);
698        assert_eq!(meta.min_length, Some(3));
699    }
700
701    #[test]
702    fn max_length_sets_maximum_string_length() {
703        let meta = ParamMeta::new().max_length(255);
704        assert_eq!(meta.max_length, Some(255));
705    }
706
707    #[test]
708    fn pattern_sets_regex_constraint() {
709        let meta = ParamMeta::new().pattern(r"^\d{4}-\d{2}-\d{2}$");
710        assert_eq!(meta.pattern.as_deref(), Some(r"^\d{4}-\d{2}-\d{2}$"));
711    }
712
713    #[test]
714    fn builder_methods_chain() {
715        let meta = ParamMeta::new()
716            .title("Page")
717            .description("Page number for pagination")
718            .ge(1.0)
719            .le(1000.0)
720            .example(serde_json::json!(1));
721
722        assert_eq!(meta.title.as_deref(), Some("Page"));
723        assert_eq!(
724            meta.description.as_deref(),
725            Some("Page number for pagination")
726        );
727        assert_eq!(meta.ge, Some(1.0));
728        assert_eq!(meta.le, Some(1000.0));
729        assert_eq!(meta.example, Some(serde_json::json!(1)));
730    }
731
732    #[test]
733    fn to_parameter_creates_parameter_with_metadata() {
734        let meta = ParamMeta::new()
735            .title("User ID")
736            .description("Unique user identifier")
737            .deprecated()
738            .example(serde_json::json!(42));
739
740        let param = meta.to_parameter("user_id", ParameterLocation::Path, true, None);
741
742        assert_eq!(param.name, "user_id");
743        assert!(matches!(param.location, ParameterLocation::Path));
744        assert!(param.required);
745        assert_eq!(param.title.as_deref(), Some("User ID"));
746        assert_eq!(param.description.as_deref(), Some("Unique user identifier"));
747        assert!(param.deprecated);
748        assert_eq!(param.example, Some(serde_json::json!(42)));
749    }
750
751    #[test]
752    fn to_parameter_with_query_location() {
753        let meta = ParamMeta::new().description("Search query");
754        let param = meta.to_parameter("q", ParameterLocation::Query, false, None);
755
756        assert_eq!(param.name, "q");
757        assert!(matches!(param.location, ParameterLocation::Query));
758        assert!(!param.required);
759    }
760
761    #[test]
762    fn to_parameter_with_header_location() {
763        let meta = ParamMeta::new().description("API key");
764        let param = meta.to_parameter("X-API-Key", ParameterLocation::Header, true, None);
765
766        assert_eq!(param.name, "X-API-Key");
767        assert!(matches!(param.location, ParameterLocation::Header));
768    }
769
770    #[test]
771    fn to_parameter_with_cookie_location() {
772        let meta = ParamMeta::new().description("Session cookie");
773        let param = meta.to_parameter("session", ParameterLocation::Cookie, false, None);
774
775        assert_eq!(param.name, "session");
776        assert!(matches!(param.location, ParameterLocation::Cookie));
777    }
778
779    #[test]
780    fn default_param_meta_is_empty() {
781        let meta = ParamMeta::default();
782        assert!(meta.title.is_none());
783        assert!(meta.description.is_none());
784        assert!(!meta.deprecated);
785        assert!(!meta.include_in_schema); // Default::default() sets to false
786        assert!(meta.example.is_none());
787        assert!(meta.ge.is_none());
788        assert!(meta.le.is_none());
789        assert!(meta.gt.is_none());
790        assert!(meta.lt.is_none());
791        assert!(meta.min_length.is_none());
792        assert!(meta.max_length.is_none());
793        assert!(meta.pattern.is_none());
794    }
795
796    #[test]
797    fn string_constraints_together() {
798        let meta = ParamMeta::new()
799            .min_length(1)
800            .max_length(100)
801            .pattern(r"^[a-zA-Z]+$");
802
803        assert_eq!(meta.min_length, Some(1));
804        assert_eq!(meta.max_length, Some(100));
805        assert_eq!(meta.pattern.as_deref(), Some(r"^[a-zA-Z]+$"));
806    }
807
808    #[test]
809    fn numeric_constraints_together() {
810        let meta = ParamMeta::new().gt(0.0).lt(100.0).ge(1.0).le(99.0);
811
812        assert_eq!(meta.gt, Some(0.0));
813        assert_eq!(meta.lt, Some(100.0));
814        assert_eq!(meta.ge, Some(1.0));
815        assert_eq!(meta.le, Some(99.0));
816    }
817
818    // === Alias Tests ===
819
820    #[test]
821    fn alias_sets_alias() {
822        let meta = ParamMeta::new().alias("q");
823        assert_eq!(meta.alias.as_deref(), Some("q"));
824    }
825
826    #[test]
827    fn validation_alias_sets_validation_alias() {
828        let meta = ParamMeta::new().validation_alias("query_param");
829        assert_eq!(meta.validation_alias.as_deref(), Some("query_param"));
830    }
831
832    #[test]
833    fn serialization_alias_sets_serialization_alias() {
834        let meta = ParamMeta::new().serialization_alias("search_query");
835        assert_eq!(meta.serialization_alias.as_deref(), Some("search_query"));
836    }
837
838    #[test]
839    fn effective_validation_name_uses_validation_alias_first() {
840        let meta = ParamMeta::new().alias("a").validation_alias("v");
841        assert_eq!(meta.effective_validation_name(), Some("v"));
842    }
843
844    #[test]
845    fn effective_validation_name_falls_back_to_alias() {
846        let meta = ParamMeta::new().alias("a");
847        assert_eq!(meta.effective_validation_name(), Some("a"));
848    }
849
850    #[test]
851    fn effective_validation_name_returns_none_when_no_alias() {
852        let meta = ParamMeta::new();
853        assert!(meta.effective_validation_name().is_none());
854    }
855
856    #[test]
857    fn effective_serialization_name_uses_serialization_alias_first() {
858        let meta = ParamMeta::new().alias("a").serialization_alias("s");
859        assert_eq!(meta.effective_serialization_name(), Some("s"));
860    }
861
862    #[test]
863    fn effective_serialization_name_falls_back_to_alias() {
864        let meta = ParamMeta::new().alias("a");
865        assert_eq!(meta.effective_serialization_name(), Some("a"));
866    }
867
868    #[test]
869    fn effective_serialization_name_returns_none_when_no_alias() {
870        let meta = ParamMeta::new();
871        assert!(meta.effective_serialization_name().is_none());
872    }
873
874    #[test]
875    fn to_parameter_uses_alias_for_name() {
876        let meta = ParamMeta::new().alias("q");
877        let param = meta.to_parameter("query", ParameterLocation::Query, false, None);
878        // Should use alias "q" instead of "query"
879        assert_eq!(param.name, "q");
880    }
881
882    #[test]
883    fn to_parameter_uses_serialization_alias_for_name() {
884        let meta = ParamMeta::new().alias("a").serialization_alias("search");
885        let param = meta.to_parameter("query", ParameterLocation::Query, false, None);
886        // Should use serialization_alias "search" (overrides alias)
887        assert_eq!(param.name, "search");
888    }
889
890    #[test]
891    fn to_parameter_uses_original_name_when_no_alias() {
892        let meta = ParamMeta::new();
893        let param = meta.to_parameter("query", ParameterLocation::Query, false, None);
894        assert_eq!(param.name, "query");
895    }
896
897    #[test]
898    fn alias_propagation_rules() {
899        // When alias is set, it should propagate to validation and serialization
900        let meta = ParamMeta::new().alias("q");
901        assert_eq!(meta.effective_validation_name(), Some("q"));
902        assert_eq!(meta.effective_serialization_name(), Some("q"));
903
904        // When explicit aliases are set, they override the general alias
905        let meta2 = ParamMeta::new()
906            .alias("q")
907            .validation_alias("query_input")
908            .serialization_alias("query_output");
909        assert_eq!(meta2.effective_validation_name(), Some("query_input"));
910        assert_eq!(meta2.effective_serialization_name(), Some("query_output"));
911    }
912
913    #[test]
914    fn header_alias_example() {
915        // Example: Accept X-Custom-Token header instead of token
916        let meta = ParamMeta::new().alias("X-Custom-Token");
917        let param = meta.to_parameter("token", ParameterLocation::Header, true, None);
918        assert_eq!(param.name, "X-Custom-Token");
919        assert!(matches!(param.location, ParameterLocation::Header));
920    }
921}
922
923// ============================================================================
924// Tests for OpenAPI types serialization
925// ============================================================================
926
927#[cfg(test)]
928mod serialization_tests {
929    use super::*;
930
931    #[test]
932    fn parameter_serializes_location_as_in() {
933        let param = Parameter {
934            name: "id".to_string(),
935            location: ParameterLocation::Path,
936            required: true,
937            schema: None,
938            title: None,
939            description: None,
940            deprecated: false,
941            example: None,
942            examples: HashMap::new(),
943        };
944
945        let json = serde_json::to_string(&param).unwrap();
946        assert!(json.contains(r#""in":"path""#));
947    }
948
949    #[test]
950    fn parameter_location_serializes_lowercase() {
951        let path_json = serde_json::to_string(&ParameterLocation::Path).unwrap();
952        assert_eq!(path_json, r#""path""#);
953
954        let query_json = serde_json::to_string(&ParameterLocation::Query).unwrap();
955        assert_eq!(query_json, r#""query""#);
956
957        let header_json = serde_json::to_string(&ParameterLocation::Header).unwrap();
958        assert_eq!(header_json, r#""header""#);
959
960        let cookie_json = serde_json::to_string(&ParameterLocation::Cookie).unwrap();
961        assert_eq!(cookie_json, r#""cookie""#);
962    }
963
964    #[test]
965    fn parameter_skips_false_deprecated() {
966        let param = Parameter {
967            name: "id".to_string(),
968            location: ParameterLocation::Path,
969            required: true,
970            schema: None,
971            title: None,
972            description: None,
973            deprecated: false,
974            example: None,
975            examples: HashMap::new(),
976        };
977
978        let json = serde_json::to_string(&param).unwrap();
979        assert!(!json.contains("deprecated"));
980    }
981
982    #[test]
983    fn parameter_includes_true_deprecated() {
984        let param = Parameter {
985            name: "old_id".to_string(),
986            location: ParameterLocation::Path,
987            required: true,
988            schema: None,
989            title: None,
990            description: Some("Deprecated, use new_id instead".to_string()),
991            deprecated: true,
992            example: None,
993            examples: HashMap::new(),
994        };
995
996        let json = serde_json::to_string(&param).unwrap();
997        assert!(json.contains(r#""deprecated":true"#));
998    }
999
1000    #[test]
1001    fn openapi_builder_creates_valid_document() {
1002        let doc = OpenApiBuilder::new("Test API", "1.0.0")
1003            .description("A test API")
1004            .server("https://api.example.com", Some("Production".to_string()))
1005            .tag("users", Some("User operations".to_string()))
1006            .build();
1007
1008        assert_eq!(doc.openapi, "3.1.0");
1009        assert_eq!(doc.info.title, "Test API");
1010        assert_eq!(doc.info.version, "1.0.0");
1011        assert_eq!(doc.info.description.as_deref(), Some("A test API"));
1012        assert_eq!(doc.servers.len(), 1);
1013        assert_eq!(doc.servers[0].url, "https://api.example.com");
1014        assert_eq!(doc.tags.len(), 1);
1015        assert_eq!(doc.tags[0].name, "users");
1016    }
1017
1018    #[test]
1019    fn openapi_serializes_to_valid_json() {
1020        let doc = OpenApiBuilder::new("Test API", "1.0.0").build();
1021        let json = serde_json::to_string_pretty(&doc).unwrap();
1022
1023        assert!(json.contains(r#""openapi": "3.1.0""#));
1024        assert!(json.contains(r#""title": "Test API""#));
1025        assert!(json.contains(r#""version": "1.0.0""#));
1026    }
1027
1028    #[test]
1029    fn example_serializes_all_fields() {
1030        let example = Example {
1031            summary: Some("Example summary".to_string()),
1032            description: Some("Example description".to_string()),
1033            value: Some(serde_json::json!({"key": "value"})),
1034            external_value: None,
1035        };
1036
1037        let json = serde_json::to_string(&example).unwrap();
1038        assert!(json.contains(r#""summary":"Example summary""#));
1039        assert!(json.contains(r#""description":"Example description""#));
1040        assert!(json.contains(r#""value""#));
1041    }
1042
1043    #[test]
1044    fn openapi_builder_with_registry_includes_schemas() {
1045        use crate::schema::Schema;
1046
1047        let builder = OpenApiBuilder::new("Test API", "1.0.0");
1048
1049        // Register schemas via the registry
1050        builder.registry().register(
1051            "User",
1052            Schema::object(
1053                [
1054                    ("id".to_string(), Schema::integer(Some("int64"))),
1055                    ("name".to_string(), Schema::string()),
1056                ]
1057                .into_iter()
1058                .collect(),
1059                vec!["id".to_string(), "name".to_string()],
1060            ),
1061        );
1062
1063        let doc = builder.build();
1064
1065        // Components should include the registered schema
1066        assert!(doc.components.is_some());
1067        let components = doc.components.unwrap();
1068        assert!(components.schemas.contains_key("User"));
1069    }
1070
1071    #[test]
1072    fn openapi_builder_registry_returns_refs() {
1073        use crate::schema::Schema;
1074
1075        let builder = OpenApiBuilder::new("Test API", "1.0.0");
1076
1077        // Register and get $ref back
1078        let user_ref = builder.registry().register("User", Schema::string());
1079
1080        if let Schema::Ref(ref_schema) = user_ref {
1081            assert_eq!(ref_schema.reference, "#/components/schemas/User");
1082        } else {
1083            panic!("Expected Schema::Ref");
1084        }
1085    }
1086
1087    #[test]
1088    fn openapi_builder_merges_registry_and_explicit_schemas() {
1089        use crate::schema::Schema;
1090
1091        let builder =
1092            OpenApiBuilder::new("Test API", "1.0.0").schema("ExplicitSchema", Schema::boolean());
1093
1094        // Also register via registry
1095        builder
1096            .registry()
1097            .register("RegistrySchema", Schema::string());
1098
1099        let doc = builder.build();
1100
1101        let components = doc.components.unwrap();
1102        assert!(components.schemas.contains_key("ExplicitSchema"));
1103        assert!(components.schemas.contains_key("RegistrySchema"));
1104    }
1105
1106    #[test]
1107    fn openapi_builder_explicit_schemas_override_registry() {
1108        use crate::schema::Schema;
1109
1110        let builder = OpenApiBuilder::new("Test API", "1.0.0");
1111
1112        // Register via registry first
1113        builder.registry().register("MyType", Schema::string());
1114
1115        // Then add explicitly (should override)
1116        let builder = builder.schema("MyType", Schema::boolean());
1117
1118        let doc = builder.build();
1119        let components = doc.components.unwrap();
1120
1121        // Should be boolean (explicit), not string (registry)
1122        if let Schema::Primitive(p) = &components.schemas["MyType"] {
1123            assert!(matches!(p.schema_type, crate::schema::SchemaType::Boolean));
1124        } else {
1125            panic!("Expected primitive boolean schema");
1126        }
1127    }
1128
1129    #[test]
1130    fn openapi_builder_with_existing_registry() {
1131        use crate::schema::Schema;
1132
1133        // Pre-populate a registry
1134        let registry = SchemaRegistry::new();
1135        registry.register("PreRegistered", Schema::string());
1136
1137        // Use the pre-populated registry
1138        let builder = OpenApiBuilder::with_registry("Test API", "1.0.0", registry);
1139
1140        let doc = builder.build();
1141        let components = doc.components.unwrap();
1142        assert!(components.schemas.contains_key("PreRegistered"));
1143    }
1144
1145    #[test]
1146    fn openapi_builder_registry_serializes_refs_correctly() {
1147        use crate::schema::Schema;
1148
1149        let builder = OpenApiBuilder::new("Test API", "1.0.0");
1150
1151        // Register User schema
1152        let user_ref = builder.registry().register(
1153            "User",
1154            Schema::object(
1155                [("name".to_string(), Schema::string())]
1156                    .into_iter()
1157                    .collect(),
1158                vec!["name".to_string()],
1159            ),
1160        );
1161
1162        // Create a response that uses the $ref
1163        let doc = builder.build();
1164        let json = serde_json::to_string_pretty(&doc).unwrap();
1165
1166        // Should have components/schemas/User
1167        assert!(json.contains(r#""User""#));
1168
1169        // The user_ref should serialize as a $ref
1170        let ref_json = serde_json::to_string(&user_ref).unwrap();
1171        assert!(ref_json.contains(r##""$ref":"#/components/schemas/User""##));
1172    }
1173}
1174
1175/// OpenAPI document builder.
1176pub struct OpenApiBuilder {
1177    info: Info,
1178    servers: Vec<Server>,
1179    paths: HashMap<String, PathItem>,
1180    components: Components,
1181    tags: Vec<Tag>,
1182    /// Global security requirements.
1183    security: Vec<SecurityRequirement>,
1184    /// Schema registry for collecting and deduplicating schemas.
1185    registry: SchemaRegistry,
1186}
1187
1188impl OpenApiBuilder {
1189    /// Create a new builder.
1190    #[must_use]
1191    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
1192        Self {
1193            info: Info {
1194                title: title.into(),
1195                version: version.into(),
1196                description: None,
1197                terms_of_service: None,
1198                contact: None,
1199                license: None,
1200            },
1201            servers: Vec::new(),
1202            paths: HashMap::new(),
1203            components: Components::default(),
1204            tags: Vec::new(),
1205            security: Vec::new(),
1206            registry: SchemaRegistry::new(),
1207        }
1208    }
1209
1210    /// Create a new builder with an existing schema registry.
1211    ///
1212    /// Use this when you want to share schemas across multiple OpenAPI documents
1213    /// or when you've pre-registered schemas.
1214    #[must_use]
1215    pub fn with_registry(
1216        title: impl Into<String>,
1217        version: impl Into<String>,
1218        registry: SchemaRegistry,
1219    ) -> Self {
1220        Self {
1221            info: Info {
1222                title: title.into(),
1223                version: version.into(),
1224                description: None,
1225                terms_of_service: None,
1226                contact: None,
1227                license: None,
1228            },
1229            servers: Vec::new(),
1230            paths: HashMap::new(),
1231            components: Components::default(),
1232            tags: Vec::new(),
1233            security: Vec::new(),
1234            registry,
1235        }
1236    }
1237
1238    /// Get a reference to the schema registry.
1239    ///
1240    /// Use this to register schemas that should be in `#/components/schemas/`
1241    /// and get `$ref` references to them.
1242    #[must_use]
1243    pub fn registry(&self) -> &SchemaRegistry {
1244        &self.registry
1245    }
1246
1247    /// Add a description.
1248    #[must_use]
1249    pub fn description(mut self, description: impl Into<String>) -> Self {
1250        self.info.description = Some(description.into());
1251        self
1252    }
1253
1254    /// Add a server.
1255    #[must_use]
1256    pub fn server(mut self, url: impl Into<String>, description: Option<String>) -> Self {
1257        self.servers.push(Server {
1258            url: url.into(),
1259            description,
1260        });
1261        self
1262    }
1263
1264    /// Add a tag.
1265    #[must_use]
1266    pub fn tag(mut self, name: impl Into<String>, description: Option<String>) -> Self {
1267        self.tags.push(Tag {
1268            name: name.into(),
1269            description,
1270        });
1271        self
1272    }
1273
1274    /// Add a schema component.
1275    #[must_use]
1276    pub fn schema(mut self, name: impl Into<String>, schema: Schema) -> Self {
1277        self.components.schemas.insert(name.into(), schema);
1278        self
1279    }
1280
1281    /// Add a security scheme.
1282    ///
1283    /// Security schemes define authentication methods used by the API.
1284    ///
1285    /// # Example
1286    ///
1287    /// ```ignore
1288    /// use fastapi_openapi::{OpenApiBuilder, SecurityScheme, ApiKeyLocation};
1289    ///
1290    /// let doc = OpenApiBuilder::new("My API", "1.0.0")
1291    ///     .security_scheme("api_key", SecurityScheme::ApiKey {
1292    ///         name: "X-API-Key".to_string(),
1293    ///         location: ApiKeyLocation::Header,
1294    ///         description: Some("API key for authentication".to_string()),
1295    ///     })
1296    ///     .security_scheme("bearer", SecurityScheme::Http {
1297    ///         scheme: "bearer".to_string(),
1298    ///         bearer_format: Some("JWT".to_string()),
1299    ///         description: None,
1300    ///     })
1301    ///     .build();
1302    /// ```
1303    #[must_use]
1304    pub fn security_scheme(mut self, name: impl Into<String>, scheme: SecurityScheme) -> Self {
1305        self.components.security_schemes.insert(name.into(), scheme);
1306        self
1307    }
1308
1309    /// Add a global security requirement.
1310    ///
1311    /// Global security requirements apply to all operations unless overridden
1312    /// at the operation level.
1313    ///
1314    /// # Example
1315    ///
1316    /// ```ignore
1317    /// use fastapi_openapi::OpenApiBuilder;
1318    ///
1319    /// let doc = OpenApiBuilder::new("My API", "1.0.0")
1320    ///     .security_scheme("api_key", /* ... */)
1321    ///     .security_requirement("api_key", vec![])  // No scopes needed for API key
1322    ///     .build();
1323    /// ```
1324    #[must_use]
1325    pub fn security_requirement(mut self, scheme: impl Into<String>, scopes: Vec<String>) -> Self {
1326        let mut req = SecurityRequirement::new();
1327        req.insert(scheme.into(), scopes);
1328        self.security.push(req);
1329        self
1330    }
1331
1332    /// Add a route to the OpenAPI document.
1333    ///
1334    /// Converts a route's metadata into an OpenAPI Operation and adds it
1335    /// to the appropriate path. Multiple routes on the same path with
1336    /// different methods are merged into a single PathItem.
1337    ///
1338    /// # Example
1339    ///
1340    /// ```ignore
1341    /// use fastapi_openapi::OpenApiBuilder;
1342    /// use fastapi_router::Router;
1343    ///
1344    /// let router = Router::new();
1345    /// // ... add routes to router ...
1346    ///
1347    /// let mut builder = OpenApiBuilder::new("My API", "1.0.0");
1348    /// for route in router.routes() {
1349    ///     builder.add_route(route);
1350    /// }
1351    /// let doc = builder.build();
1352    /// ```
1353    pub fn add_route(&mut self, route: &Route) {
1354        let operation = self.route_to_operation(route);
1355        let path_item = self.paths.entry(route.path.clone()).or_default();
1356
1357        match route.method {
1358            Method::Get => path_item.get = Some(operation),
1359            Method::Post => path_item.post = Some(operation),
1360            Method::Put => path_item.put = Some(operation),
1361            Method::Delete => path_item.delete = Some(operation),
1362            Method::Patch => path_item.patch = Some(operation),
1363            Method::Options => path_item.options = Some(operation),
1364            Method::Head => path_item.head = Some(operation),
1365            Method::Trace => {
1366                // OpenAPI PathItem doesn't have a trace field by default,
1367                // but we could add it. For now, skip TRACE methods.
1368            }
1369        }
1370    }
1371
1372    /// Add multiple routes to the OpenAPI document.
1373    ///
1374    /// Convenience method that calls `add_route` for each route.
1375    pub fn add_routes(&mut self, routes: &[Route]) {
1376        for route in routes {
1377            self.add_route(route);
1378        }
1379    }
1380
1381    /// Convert a Route to an OpenAPI Operation.
1382    #[allow(clippy::unused_self)] // Will use self.registry for schema lookups in future
1383    fn route_to_operation(&self, route: &Route) -> Operation {
1384        // Convert path parameters
1385        let parameters: Vec<Parameter> = route
1386            .path_params
1387            .iter()
1388            .map(param_info_to_parameter)
1389            .collect();
1390
1391        // Build request body if present
1392        let request_body = route.request_body_schema.as_ref().map(|schema_name| {
1393            let content_type = route
1394                .request_body_content_type
1395                .as_deref()
1396                .unwrap_or("application/json");
1397
1398            let mut content = HashMap::new();
1399            content.insert(
1400                content_type.to_string(),
1401                MediaType {
1402                    schema: Some(Schema::reference(schema_name)),
1403                    example: None,
1404                    examples: HashMap::new(),
1405                },
1406            );
1407
1408            RequestBody {
1409                required: route.request_body_required,
1410                content,
1411                description: None,
1412            }
1413        });
1414
1415        // Build a default 200 response (can be extended later with response metadata)
1416        let mut responses = HashMap::new();
1417        responses.insert(
1418            "200".to_string(),
1419            Response {
1420                description: "Successful response".to_string(),
1421                content: HashMap::new(),
1422            },
1423        );
1424
1425        // Convert route security requirements to OpenAPI format
1426        let security: Vec<SecurityRequirement> = route
1427            .security
1428            .iter()
1429            .map(|req| {
1430                let mut sec_req = SecurityRequirement::new();
1431                sec_req.insert(req.scheme.clone(), req.scopes.clone());
1432                sec_req
1433            })
1434            .collect();
1435
1436        Operation {
1437            operation_id: if route.operation_id.is_empty() {
1438                None
1439            } else {
1440                Some(route.operation_id.clone())
1441            },
1442            summary: route.summary.clone(),
1443            description: route.description.clone(),
1444            tags: route.tags.clone(),
1445            parameters,
1446            request_body,
1447            responses,
1448            deprecated: route.deprecated,
1449            security,
1450        }
1451    }
1452
1453    /// Build the OpenAPI document.
1454    ///
1455    /// This merges all schemas from the registry into `components.schemas`.
1456    #[must_use]
1457    pub fn build(self) -> OpenApi {
1458        // Merge registry schemas with explicitly added schemas
1459        let mut all_schemas = self.registry.into_schemas();
1460        for (name, schema) in self.components.schemas {
1461            // Explicitly added schemas take precedence
1462            all_schemas.insert(name, schema);
1463        }
1464
1465        OpenApi {
1466            openapi: "3.1.0".to_string(),
1467            info: self.info,
1468            servers: self.servers,
1469            paths: self.paths,
1470            components: if all_schemas.is_empty() && self.components.security_schemes.is_empty() {
1471                None
1472            } else {
1473                Some(Components {
1474                    schemas: all_schemas,
1475                    security_schemes: self.components.security_schemes,
1476                })
1477            },
1478            tags: self.tags,
1479            security: self.security,
1480        }
1481    }
1482}
1483
1484// ============================================================================
1485// Path Parameter Generation
1486// ============================================================================
1487
1488use fastapi_core::Method;
1489use fastapi_router::{Converter, ParamInfo, Route, extract_path_params};
1490
1491/// Convert a router `Converter` type to an OpenAPI `Schema`.
1492///
1493/// Maps path parameter type converters to appropriate JSON Schema types:
1494/// - `Str` → string
1495/// - `Int` → integer (int64)
1496/// - `Float` → number (double)
1497/// - `Uuid` → string (uuid format)
1498/// - `Path` → string (catch-all wildcard)
1499#[must_use]
1500pub fn converter_to_schema(converter: &Converter) -> Schema {
1501    match converter {
1502        Converter::Int => Schema::integer(Some("int64")),
1503        Converter::Float => Schema::number(Some("double")),
1504        Converter::Uuid => Schema::Primitive(crate::schema::PrimitiveSchema {
1505            schema_type: crate::schema::SchemaType::String,
1506            format: Some("uuid".to_string()),
1507            nullable: false,
1508            minimum: None,
1509            maximum: None,
1510            exclusive_minimum: None,
1511            exclusive_maximum: None,
1512            min_length: None,
1513            max_length: None,
1514            pattern: None,
1515            enum_values: None,
1516            example: None,
1517        }),
1518        // Str and Path both map to string type
1519        Converter::Str | Converter::Path => Schema::string(),
1520    }
1521}
1522
1523/// Convert a `ParamInfo` to an OpenAPI `Parameter` object.
1524///
1525/// Creates a path parameter with the appropriate schema type based on
1526/// the converter. All path parameters are required. Metadata (title,
1527/// description, deprecated, examples) is copied from the ParamInfo.
1528#[must_use]
1529pub fn param_info_to_parameter(param: &ParamInfo) -> Parameter {
1530    // Convert named examples from Vec<(String, Value)> to HashMap<String, Example>
1531    let examples: HashMap<String, Example> = param
1532        .examples
1533        .iter()
1534        .map(|(name, value)| {
1535            (
1536                name.clone(),
1537                Example {
1538                    summary: None,
1539                    description: None,
1540                    value: Some(value.clone()),
1541                    external_value: None,
1542                },
1543            )
1544        })
1545        .collect();
1546
1547    Parameter {
1548        name: param.name.clone(),
1549        location: ParameterLocation::Path,
1550        required: true, // Path parameters are always required
1551        schema: Some(converter_to_schema(&param.converter)),
1552        title: param.title.clone(),
1553        description: param.description.clone(),
1554        deprecated: param.deprecated,
1555        example: param.example.clone(),
1556        examples,
1557    }
1558}
1559
1560/// Extract path parameters from a route path pattern and convert them to OpenAPI Parameters.
1561///
1562/// Parses a path pattern like `/users/{id}/posts/{post_id:int}` and returns
1563/// OpenAPI Parameter objects for each path parameter.
1564///
1565/// # Examples
1566///
1567/// ```ignore
1568/// use fastapi_openapi::path_params_to_parameters;
1569///
1570/// let params = path_params_to_parameters("/users/{id}");
1571/// assert_eq!(params.len(), 1);
1572/// assert_eq!(params[0].name, "id");
1573///
1574/// // Typed parameters map to appropriate schemas
1575/// let params = path_params_to_parameters("/items/{item_id:int}");
1576/// // item_id will have an integer schema with int64 format
1577/// ```
1578#[must_use]
1579pub fn path_params_to_parameters(path: &str) -> Vec<Parameter> {
1580    extract_path_params(path)
1581        .iter()
1582        .map(param_info_to_parameter)
1583        .collect()
1584}
1585
1586// ============================================================================
1587// Path Parameter Tests
1588// ============================================================================
1589
1590#[cfg(test)]
1591mod path_param_tests {
1592    use super::*;
1593    use crate::schema::SchemaType;
1594
1595    #[test]
1596    fn converter_to_schema_str() {
1597        let schema = converter_to_schema(&Converter::Str);
1598        if let Schema::Primitive(p) = schema {
1599            assert!(matches!(p.schema_type, SchemaType::String));
1600            assert!(p.format.is_none());
1601        } else {
1602            panic!("Expected primitive schema");
1603        }
1604    }
1605
1606    #[test]
1607    fn converter_to_schema_int() {
1608        let schema = converter_to_schema(&Converter::Int);
1609        if let Schema::Primitive(p) = schema {
1610            assert!(matches!(p.schema_type, SchemaType::Integer));
1611            assert_eq!(p.format.as_deref(), Some("int64"));
1612        } else {
1613            panic!("Expected primitive schema");
1614        }
1615    }
1616
1617    #[test]
1618    fn converter_to_schema_float() {
1619        let schema = converter_to_schema(&Converter::Float);
1620        if let Schema::Primitive(p) = schema {
1621            assert!(matches!(p.schema_type, SchemaType::Number));
1622            assert_eq!(p.format.as_deref(), Some("double"));
1623        } else {
1624            panic!("Expected primitive schema");
1625        }
1626    }
1627
1628    #[test]
1629    fn converter_to_schema_uuid() {
1630        let schema = converter_to_schema(&Converter::Uuid);
1631        if let Schema::Primitive(p) = schema {
1632            assert!(matches!(p.schema_type, SchemaType::String));
1633            assert_eq!(p.format.as_deref(), Some("uuid"));
1634        } else {
1635            panic!("Expected primitive schema");
1636        }
1637    }
1638
1639    #[test]
1640    fn converter_to_schema_path() {
1641        let schema = converter_to_schema(&Converter::Path);
1642        if let Schema::Primitive(p) = schema {
1643            assert!(matches!(p.schema_type, SchemaType::String));
1644        } else {
1645            panic!("Expected primitive schema");
1646        }
1647    }
1648
1649    #[test]
1650    fn param_info_to_parameter_basic() {
1651        let param = param_info_to_parameter(&ParamInfo::new("id", Converter::Str));
1652
1653        assert_eq!(param.name, "id");
1654        assert!(matches!(param.location, ParameterLocation::Path));
1655        assert!(param.required);
1656        assert!(param.schema.is_some());
1657    }
1658
1659    #[test]
1660    fn param_info_to_parameter_int() {
1661        let param = param_info_to_parameter(&ParamInfo::new("item_id", Converter::Int));
1662
1663        assert_eq!(param.name, "item_id");
1664        assert!(param.required);
1665        if let Some(Schema::Primitive(p)) = &param.schema {
1666            assert!(matches!(p.schema_type, SchemaType::Integer));
1667            assert_eq!(p.format.as_deref(), Some("int64"));
1668        } else {
1669            panic!("Expected integer schema");
1670        }
1671    }
1672
1673    #[test]
1674    fn path_params_to_parameters_simple() {
1675        let params = path_params_to_parameters("/users/{id}");
1676        assert_eq!(params.len(), 1);
1677        assert_eq!(params[0].name, "id");
1678        assert!(matches!(params[0].location, ParameterLocation::Path));
1679        assert!(params[0].required);
1680    }
1681
1682    #[test]
1683    fn path_params_to_parameters_multiple() {
1684        let params = path_params_to_parameters("/users/{user_id}/posts/{post_id}");
1685        assert_eq!(params.len(), 2);
1686        assert_eq!(params[0].name, "user_id");
1687        assert_eq!(params[1].name, "post_id");
1688    }
1689
1690    #[test]
1691    fn path_params_to_parameters_typed() {
1692        let params = path_params_to_parameters("/items/{id:int}/price/{value:float}");
1693        assert_eq!(params.len(), 2);
1694
1695        // First param should be integer
1696        if let Some(Schema::Primitive(p)) = &params[0].schema {
1697            assert!(matches!(p.schema_type, SchemaType::Integer));
1698        } else {
1699            panic!("Expected integer schema for id");
1700        }
1701
1702        // Second param should be number
1703        if let Some(Schema::Primitive(p)) = &params[1].schema {
1704            assert!(matches!(p.schema_type, SchemaType::Number));
1705        } else {
1706            panic!("Expected number schema for value");
1707        }
1708    }
1709
1710    #[test]
1711    fn path_params_to_parameters_uuid() {
1712        let params = path_params_to_parameters("/resources/{uuid:uuid}");
1713        assert_eq!(params.len(), 1);
1714
1715        if let Some(Schema::Primitive(p)) = &params[0].schema {
1716            assert!(matches!(p.schema_type, SchemaType::String));
1717            assert_eq!(p.format.as_deref(), Some("uuid"));
1718        } else {
1719            panic!("Expected string/uuid schema");
1720        }
1721    }
1722
1723    #[test]
1724    fn path_params_to_parameters_wildcard() {
1725        let params = path_params_to_parameters("/files/{*filepath}");
1726        assert_eq!(params.len(), 1);
1727        assert_eq!(params[0].name, "filepath");
1728
1729        if let Some(Schema::Primitive(p)) = &params[0].schema {
1730            assert!(matches!(p.schema_type, SchemaType::String));
1731        } else {
1732            panic!("Expected string schema for wildcard");
1733        }
1734    }
1735
1736    #[test]
1737    fn path_params_to_parameters_no_params() {
1738        let params = path_params_to_parameters("/static/path");
1739        assert!(params.is_empty());
1740    }
1741
1742    #[test]
1743    fn path_params_to_parameters_serialization() {
1744        let params = path_params_to_parameters("/users/{id:int}");
1745        let json = serde_json::to_string(&params[0]).unwrap();
1746
1747        // Should have path location
1748        assert!(json.contains(r#""in":"path""#));
1749        // Should be required
1750        assert!(json.contains(r#""required":true"#));
1751        // Should have integer schema
1752        assert!(json.contains(r#""type":"integer""#));
1753        assert!(json.contains(r#""format":"int64""#));
1754    }
1755
1756    #[test]
1757    fn path_params_complex_route() {
1758        let params = path_params_to_parameters("/api/v1/users/{user_id:int}/files/{*path}");
1759        assert_eq!(params.len(), 2);
1760
1761        // user_id is integer
1762        assert_eq!(params[0].name, "user_id");
1763        if let Some(Schema::Primitive(p)) = &params[0].schema {
1764            assert!(matches!(p.schema_type, SchemaType::Integer));
1765        } else {
1766            panic!("Expected integer schema");
1767        }
1768
1769        // path is string (wildcard)
1770        assert_eq!(params[1].name, "path");
1771        if let Some(Schema::Primitive(p)) = &params[1].schema {
1772            assert!(matches!(p.schema_type, SchemaType::String));
1773        } else {
1774            panic!("Expected string schema");
1775        }
1776    }
1777
1778    // =========================================================================
1779    // PARAMETER METADATA TESTS
1780    // =========================================================================
1781
1782    #[test]
1783    fn param_info_with_title() {
1784        let info = ParamInfo::new("user_id", Converter::Int).with_title("User ID");
1785        let param = param_info_to_parameter(&info);
1786
1787        assert_eq!(param.title.as_deref(), Some("User ID"));
1788    }
1789
1790    #[test]
1791    fn param_info_with_description() {
1792        let info =
1793            ParamInfo::new("page", Converter::Int).with_description("Page number for pagination");
1794        let param = param_info_to_parameter(&info);
1795
1796        assert_eq!(
1797            param.description.as_deref(),
1798            Some("Page number for pagination")
1799        );
1800    }
1801
1802    #[test]
1803    fn param_info_deprecated() {
1804        let info = ParamInfo::new("old_id", Converter::Str).deprecated();
1805        let param = param_info_to_parameter(&info);
1806
1807        assert!(param.deprecated);
1808    }
1809
1810    #[test]
1811    fn param_info_with_example() {
1812        let info = ParamInfo::new("user_id", Converter::Int).with_example(serde_json::json!(42));
1813        let param = param_info_to_parameter(&info);
1814
1815        assert_eq!(param.example, Some(serde_json::json!(42)));
1816    }
1817
1818    #[test]
1819    fn param_info_with_named_examples() {
1820        let info = ParamInfo::new("status", Converter::Str)
1821            .with_named_example("active", serde_json::json!("active"))
1822            .with_named_example("inactive", serde_json::json!("inactive"));
1823        let param = param_info_to_parameter(&info);
1824
1825        assert_eq!(param.examples.len(), 2);
1826        assert!(param.examples.contains_key("active"));
1827        assert!(param.examples.contains_key("inactive"));
1828        assert_eq!(
1829            param.examples.get("active").unwrap().value,
1830            Some(serde_json::json!("active"))
1831        );
1832    }
1833
1834    #[test]
1835    fn param_info_all_metadata() {
1836        let info = ParamInfo::new("item_id", Converter::Int)
1837            .with_title("Item ID")
1838            .with_description("The unique identifier for the item")
1839            .deprecated()
1840            .with_example(serde_json::json!(123))
1841            .with_named_example("first", serde_json::json!(1))
1842            .with_named_example("last", serde_json::json!(999));
1843        let param = param_info_to_parameter(&info);
1844
1845        assert_eq!(param.name, "item_id");
1846        assert_eq!(param.title.as_deref(), Some("Item ID"));
1847        assert_eq!(
1848            param.description.as_deref(),
1849            Some("The unique identifier for the item")
1850        );
1851        assert!(param.deprecated);
1852        assert_eq!(param.example, Some(serde_json::json!(123)));
1853        assert_eq!(param.examples.len(), 2);
1854    }
1855
1856    #[test]
1857    fn param_info_metadata_serialization() {
1858        let info = ParamInfo::new("id", Converter::Int)
1859            .with_title("ID")
1860            .with_description("Resource identifier")
1861            .deprecated();
1862        let param = param_info_to_parameter(&info);
1863        let json = serde_json::to_string(&param).unwrap();
1864
1865        assert!(json.contains(r#""title":"ID""#));
1866        assert!(json.contains(r#""description":"Resource identifier""#));
1867        assert!(json.contains(r#""deprecated":true"#));
1868    }
1869
1870    #[test]
1871    fn param_info_no_metadata_skips_fields() {
1872        let info = ParamInfo::new("id", Converter::Str);
1873        let param = param_info_to_parameter(&info);
1874        let json = serde_json::to_string(&param).unwrap();
1875
1876        // Fields with None/false/empty should be skipped
1877        assert!(!json.contains("title"));
1878        assert!(!json.contains("description"));
1879        assert!(!json.contains("deprecated"));
1880        assert!(!json.contains("example"));
1881    }
1882}
1883
1884// ============================================================================
1885// Route-to-OpenAPI Conversion Tests
1886// ============================================================================
1887
1888#[cfg(test)]
1889mod route_conversion_tests {
1890    use super::*;
1891    use crate::schema::SchemaType;
1892    use fastapi_router::Route;
1893
1894    fn make_test_route(path: &str, method: Method) -> Route {
1895        Route::with_placeholder_handler(method, path).operation_id("test_operation")
1896    }
1897
1898    fn make_full_route() -> Route {
1899        Route::with_placeholder_handler(Method::Get, "/users/{id:int}/posts/{post_id:int}")
1900            .operation_id("get_user_post")
1901            .summary("Get a user's post")
1902            .description("Retrieves a specific post by a user")
1903            .tag("users")
1904            .tag("posts")
1905            .deprecated()
1906    }
1907
1908    #[test]
1909    fn add_route_creates_operation_for_get() {
1910        let route = make_test_route("/users", Method::Get);
1911        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
1912        builder.add_route(&route);
1913        let doc = builder.build();
1914
1915        assert!(doc.paths.contains_key("/users"));
1916        let path_item = &doc.paths["/users"];
1917        assert!(path_item.get.is_some());
1918        assert!(path_item.post.is_none());
1919    }
1920
1921    #[test]
1922    fn add_route_creates_operation_for_post() {
1923        let route = make_test_route("/users", Method::Post);
1924        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
1925        builder.add_route(&route);
1926        let doc = builder.build();
1927
1928        let path_item = &doc.paths["/users"];
1929        assert!(path_item.post.is_some());
1930        assert!(path_item.get.is_none());
1931    }
1932
1933    #[test]
1934    fn add_route_merges_methods_on_same_path() {
1935        let get_route = make_test_route("/users", Method::Get);
1936        let post_route = make_test_route("/users", Method::Post);
1937
1938        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
1939        builder.add_route(&get_route);
1940        builder.add_route(&post_route);
1941        let doc = builder.build();
1942
1943        let path_item = &doc.paths["/users"];
1944        assert!(path_item.get.is_some());
1945        assert!(path_item.post.is_some());
1946    }
1947
1948    #[test]
1949    fn add_routes_batch_adds_multiple() {
1950        let routes = vec![
1951            make_test_route("/users", Method::Get),
1952            make_test_route("/users", Method::Post),
1953            make_test_route("/items", Method::Get),
1954        ];
1955
1956        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
1957        builder.add_routes(&routes);
1958        let doc = builder.build();
1959
1960        assert!(doc.paths.contains_key("/users"));
1961        assert!(doc.paths.contains_key("/items"));
1962        assert!(doc.paths["/users"].get.is_some());
1963        assert!(doc.paths["/users"].post.is_some());
1964        assert!(doc.paths["/items"].get.is_some());
1965    }
1966
1967    #[test]
1968    fn route_operation_id_is_preserved() {
1969        let route = Route::with_placeholder_handler(Method::Get, "/test")
1970            .operation_id("my_custom_operation");
1971
1972        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
1973        builder.add_route(&route);
1974        let doc = builder.build();
1975
1976        let op = doc.paths["/test"].get.as_ref().unwrap();
1977        assert_eq!(op.operation_id.as_deref(), Some("my_custom_operation"));
1978    }
1979
1980    #[test]
1981    fn route_summary_and_description_preserved() {
1982        let route = make_full_route();
1983        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
1984        builder.add_route(&route);
1985        let doc = builder.build();
1986
1987        let op = doc.paths["/users/{id:int}/posts/{post_id:int}"]
1988            .get
1989            .as_ref()
1990            .unwrap();
1991        assert_eq!(op.summary.as_deref(), Some("Get a user's post"));
1992        assert_eq!(
1993            op.description.as_deref(),
1994            Some("Retrieves a specific post by a user")
1995        );
1996    }
1997
1998    #[test]
1999    fn route_tags_preserved() {
2000        let route = make_full_route();
2001        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2002        builder.add_route(&route);
2003        let doc = builder.build();
2004
2005        let op = doc.paths["/users/{id:int}/posts/{post_id:int}"]
2006            .get
2007            .as_ref()
2008            .unwrap();
2009        assert!(op.tags.contains(&"users".to_string()));
2010        assert!(op.tags.contains(&"posts".to_string()));
2011    }
2012
2013    #[test]
2014    fn route_deprecated_preserved() {
2015        let route = make_full_route();
2016        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2017        builder.add_route(&route);
2018        let doc = builder.build();
2019
2020        let op = doc.paths["/users/{id:int}/posts/{post_id:int}"]
2021            .get
2022            .as_ref()
2023            .unwrap();
2024        assert!(op.deprecated);
2025    }
2026
2027    #[test]
2028    fn route_path_params_converted_to_parameters() {
2029        let route = make_full_route();
2030        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2031        builder.add_route(&route);
2032        let doc = builder.build();
2033
2034        let op = doc.paths["/users/{id:int}/posts/{post_id:int}"]
2035            .get
2036            .as_ref()
2037            .unwrap();
2038
2039        // Should have two path parameters
2040        assert_eq!(op.parameters.len(), 2);
2041        assert_eq!(op.parameters[0].name, "id");
2042        assert_eq!(op.parameters[1].name, "post_id");
2043
2044        // Both should be path parameters and required
2045        assert!(matches!(op.parameters[0].location, ParameterLocation::Path));
2046        assert!(matches!(op.parameters[1].location, ParameterLocation::Path));
2047        assert!(op.parameters[0].required);
2048        assert!(op.parameters[1].required);
2049
2050        // Both should have integer schemas
2051        if let Some(Schema::Primitive(p)) = &op.parameters[0].schema {
2052            assert!(matches!(p.schema_type, SchemaType::Integer));
2053        } else {
2054            panic!("Expected integer schema for id");
2055        }
2056    }
2057
2058    #[test]
2059    fn route_with_request_body() {
2060        let route = Route::with_placeholder_handler(Method::Post, "/users")
2061            .operation_id("create_user")
2062            .request_body("CreateUserRequest", "application/json", true);
2063
2064        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2065        builder.add_route(&route);
2066        let doc = builder.build();
2067
2068        let op = doc.paths["/users"].post.as_ref().unwrap();
2069        let body = op.request_body.as_ref().expect("Expected request body");
2070
2071        assert!(body.required);
2072        assert!(body.content.contains_key("application/json"));
2073
2074        let media_type = &body.content["application/json"];
2075        if let Some(Schema::Ref(ref_schema)) = &media_type.schema {
2076            assert_eq!(
2077                ref_schema.reference,
2078                "#/components/schemas/CreateUserRequest"
2079            );
2080        } else {
2081            panic!("Expected $ref schema for request body");
2082        }
2083    }
2084
2085    #[test]
2086    fn route_with_custom_content_type() {
2087        let route = Route::with_placeholder_handler(Method::Post, "/upload")
2088            .operation_id("upload_file")
2089            .request_body("FileUpload", "multipart/form-data", false);
2090
2091        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2092        builder.add_route(&route);
2093        let doc = builder.build();
2094
2095        let op = doc.paths["/upload"].post.as_ref().unwrap();
2096        let body = op.request_body.as_ref().unwrap();
2097        assert!(body.content.contains_key("multipart/form-data"));
2098    }
2099
2100    #[test]
2101    fn route_without_request_body() {
2102        let route = make_test_route("/users", Method::Get);
2103        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2104        builder.add_route(&route);
2105        let doc = builder.build();
2106
2107        let op = doc.paths["/users"].get.as_ref().unwrap();
2108        assert!(op.request_body.is_none());
2109    }
2110
2111    #[test]
2112    fn all_http_methods_supported() {
2113        let methods = [
2114            Method::Get,
2115            Method::Post,
2116            Method::Put,
2117            Method::Delete,
2118            Method::Patch,
2119            Method::Options,
2120            Method::Head,
2121        ];
2122
2123        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2124        for method in methods {
2125            builder.add_route(&make_test_route("/test", method));
2126        }
2127        let doc = builder.build();
2128
2129        let path_item = &doc.paths["/test"];
2130        assert!(path_item.get.is_some());
2131        assert!(path_item.post.is_some());
2132        assert!(path_item.put.is_some());
2133        assert!(path_item.delete.is_some());
2134        assert!(path_item.patch.is_some());
2135        assert!(path_item.options.is_some());
2136        assert!(path_item.head.is_some());
2137    }
2138
2139    #[test]
2140    fn default_response_is_added() {
2141        let route = make_test_route("/users", Method::Get);
2142        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2143        builder.add_route(&route);
2144        let doc = builder.build();
2145
2146        let op = doc.paths["/users"].get.as_ref().unwrap();
2147        assert!(op.responses.contains_key("200"));
2148        assert_eq!(op.responses["200"].description, "Successful response");
2149    }
2150
2151    #[test]
2152    fn route_conversion_serializes_to_valid_json() {
2153        let route = make_full_route();
2154        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2155        builder.add_route(&route);
2156        let doc = builder.build();
2157
2158        // Use compact JSON for easier substring matching
2159        let json = serde_json::to_string(&doc).unwrap();
2160
2161        // Verify key elements are in the JSON (camelCase per OpenAPI spec)
2162        assert!(json.contains(r#""operationId":"get_user_post""#));
2163        assert!(json.contains(r#""summary":"Get a user's post""#));
2164        assert!(json.contains(r#""deprecated":true"#));
2165        assert!(json.contains(r#""in":"path""#));
2166        assert!(json.contains(r#""required":true"#));
2167    }
2168
2169    #[test]
2170    fn empty_operation_id_becomes_none() {
2171        let route = Route::with_placeholder_handler(Method::Get, "/test").operation_id("");
2172
2173        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2174        builder.add_route(&route);
2175        let doc = builder.build();
2176
2177        let op = doc.paths["/test"].get.as_ref().unwrap();
2178        assert!(op.operation_id.is_none());
2179
2180        // Verify it doesn't appear in serialized JSON
2181        let json = serde_json::to_string(&doc).unwrap();
2182        assert!(!json.contains("operationId"));
2183    }
2184}
2185
2186// ============================================================================
2187// Security Scheme Tests
2188// ============================================================================
2189
2190#[cfg(test)]
2191mod security_tests {
2192    use super::*;
2193
2194    #[test]
2195    fn api_key_header_security_scheme() {
2196        let scheme = SecurityScheme::ApiKey {
2197            name: "X-API-Key".to_string(),
2198            location: ApiKeyLocation::Header,
2199            description: Some("API key for authentication".to_string()),
2200        };
2201
2202        let json = serde_json::to_string(&scheme).unwrap();
2203        assert!(json.contains(r#""type":"apiKey""#));
2204        assert!(json.contains(r#""name":"X-API-Key""#));
2205        assert!(json.contains(r#""in":"header""#));
2206        assert!(json.contains(r#""description":"API key for authentication""#));
2207    }
2208
2209    #[test]
2210    fn api_key_query_security_scheme() {
2211        let scheme = SecurityScheme::ApiKey {
2212            name: "api_key".to_string(),
2213            location: ApiKeyLocation::Query,
2214            description: None,
2215        };
2216
2217        let json = serde_json::to_string(&scheme).unwrap();
2218        assert!(json.contains(r#""type":"apiKey""#));
2219        assert!(json.contains(r#""in":"query""#));
2220        assert!(!json.contains("description"));
2221    }
2222
2223    #[test]
2224    fn http_bearer_security_scheme() {
2225        let scheme = SecurityScheme::Http {
2226            scheme: "bearer".to_string(),
2227            bearer_format: Some("JWT".to_string()),
2228            description: None,
2229        };
2230
2231        let json = serde_json::to_string(&scheme).unwrap();
2232        assert!(json.contains(r#""type":"http""#));
2233        assert!(json.contains(r#""scheme":"bearer""#));
2234        assert!(json.contains(r#""bearerFormat":"JWT""#));
2235    }
2236
2237    #[test]
2238    fn http_basic_security_scheme() {
2239        let scheme = SecurityScheme::Http {
2240            scheme: "basic".to_string(),
2241            bearer_format: None,
2242            description: Some("Basic HTTP authentication".to_string()),
2243        };
2244
2245        let json = serde_json::to_string(&scheme).unwrap();
2246        assert!(json.contains(r#""type":"http""#));
2247        assert!(json.contains(r#""scheme":"basic""#));
2248        assert!(!json.contains("bearerFormat"));
2249    }
2250
2251    #[test]
2252    fn oauth2_security_scheme() {
2253        let mut scopes = HashMap::new();
2254        scopes.insert("read:users".to_string(), "Read user data".to_string());
2255        scopes.insert("write:users".to_string(), "Modify user data".to_string());
2256
2257        let scheme = SecurityScheme::OAuth2 {
2258            flows: OAuth2Flows {
2259                authorization_code: Some(OAuth2Flow {
2260                    authorization_url: Some("https://example.com/oauth/authorize".to_string()),
2261                    token_url: Some("https://example.com/oauth/token".to_string()),
2262                    refresh_url: None,
2263                    scopes,
2264                }),
2265                ..Default::default()
2266            },
2267            description: None,
2268        };
2269
2270        let json = serde_json::to_string(&scheme).unwrap();
2271        assert!(json.contains(r#""type":"oauth2""#));
2272        assert!(json.contains(r#""authorizationCode""#));
2273        assert!(json.contains(r#""authorizationUrl""#));
2274        assert!(json.contains(r#""tokenUrl""#));
2275        assert!(json.contains(r#""read:users""#));
2276    }
2277
2278    #[test]
2279    fn openid_connect_security_scheme() {
2280        let scheme = SecurityScheme::OpenIdConnect {
2281            open_id_connect_url: "https://example.com/.well-known/openid-configuration".to_string(),
2282            description: Some("OpenID Connect authentication".to_string()),
2283        };
2284
2285        let json = serde_json::to_string(&scheme).unwrap();
2286        assert!(json.contains(r#""type":"openIdConnect""#));
2287        assert!(json.contains(r#""openIdConnectUrl""#));
2288    }
2289
2290    #[test]
2291    fn builder_adds_security_scheme() {
2292        let doc = OpenApiBuilder::new("Test API", "1.0.0")
2293            .security_scheme(
2294                "api_key",
2295                SecurityScheme::ApiKey {
2296                    name: "X-API-Key".to_string(),
2297                    location: ApiKeyLocation::Header,
2298                    description: None,
2299                },
2300            )
2301            .build();
2302
2303        assert!(doc.components.is_some());
2304        let components = doc.components.as_ref().unwrap();
2305        assert!(components.security_schemes.contains_key("api_key"));
2306    }
2307
2308    #[test]
2309    fn builder_adds_global_security_requirement() {
2310        let doc = OpenApiBuilder::new("Test API", "1.0.0")
2311            .security_scheme(
2312                "bearer",
2313                SecurityScheme::Http {
2314                    scheme: "bearer".to_string(),
2315                    bearer_format: Some("JWT".to_string()),
2316                    description: None,
2317                },
2318            )
2319            .security_requirement("bearer", vec![])
2320            .build();
2321
2322        assert_eq!(doc.security.len(), 1);
2323        assert!(doc.security[0].contains_key("bearer"));
2324    }
2325
2326    #[test]
2327    fn builder_adds_security_with_scopes() {
2328        let doc = OpenApiBuilder::new("Test API", "1.0.0")
2329            .security_requirement(
2330                "oauth2",
2331                vec!["read:users".to_string(), "write:users".to_string()],
2332            )
2333            .build();
2334
2335        assert_eq!(doc.security.len(), 1);
2336        let scopes = doc.security[0].get("oauth2").unwrap();
2337        assert_eq!(scopes.len(), 2);
2338        assert!(scopes.contains(&"read:users".to_string()));
2339        assert!(scopes.contains(&"write:users".to_string()));
2340    }
2341
2342    #[test]
2343    fn full_security_document_serializes() {
2344        let doc = OpenApiBuilder::new("Secure API", "1.0.0")
2345            .security_scheme(
2346                "api_key",
2347                SecurityScheme::ApiKey {
2348                    name: "X-API-Key".to_string(),
2349                    location: ApiKeyLocation::Header,
2350                    description: Some("API key authentication".to_string()),
2351                },
2352            )
2353            .security_scheme(
2354                "bearer",
2355                SecurityScheme::Http {
2356                    scheme: "bearer".to_string(),
2357                    bearer_format: Some("JWT".to_string()),
2358                    description: None,
2359                },
2360            )
2361            .security_requirement("api_key", vec![])
2362            .build();
2363
2364        let json = serde_json::to_string_pretty(&doc).unwrap();
2365
2366        // Verify the document structure
2367        assert!(json.contains(r#""securitySchemes""#));
2368        assert!(json.contains(r#""api_key""#));
2369        assert!(json.contains(r#""bearer""#));
2370        assert!(json.contains(r#""security""#));
2371    }
2372
2373    #[test]
2374    fn route_with_security_scheme() {
2375        let route = Route::with_placeholder_handler(Method::Get, "/protected")
2376            .operation_id("get_protected")
2377            .security_scheme("bearer");
2378
2379        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2380        builder.add_route(&route);
2381        let doc = builder.build();
2382
2383        let op = doc.paths["/protected"].get.as_ref().unwrap();
2384        assert_eq!(op.security.len(), 1);
2385        assert!(op.security[0].contains_key("bearer"));
2386        assert!(op.security[0].get("bearer").unwrap().is_empty());
2387    }
2388
2389    #[test]
2390    fn route_with_security_and_scopes() {
2391        let route = Route::with_placeholder_handler(Method::Post, "/users")
2392            .operation_id("create_user")
2393            .security("oauth2", vec!["write:users"]);
2394
2395        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2396        builder.add_route(&route);
2397        let doc = builder.build();
2398
2399        let op = doc.paths["/users"].post.as_ref().unwrap();
2400        assert_eq!(op.security.len(), 1);
2401        let scopes = op.security[0].get("oauth2").unwrap();
2402        assert_eq!(scopes.len(), 1);
2403        assert_eq!(scopes[0], "write:users");
2404    }
2405
2406    #[test]
2407    fn route_with_multiple_security_options() {
2408        let route = Route::with_placeholder_handler(Method::Get, "/data")
2409            .operation_id("get_data")
2410            .security_scheme("api_key")
2411            .security_scheme("bearer");
2412
2413        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2414        builder.add_route(&route);
2415        let doc = builder.build();
2416
2417        let op = doc.paths["/data"].get.as_ref().unwrap();
2418        // Multiple security requirements means OR logic
2419        assert_eq!(op.security.len(), 2);
2420        assert!(op.security[0].contains_key("api_key"));
2421        assert!(op.security[1].contains_key("bearer"));
2422    }
2423
2424    #[test]
2425    fn route_security_serializes_correctly() {
2426        let route = Route::with_placeholder_handler(Method::Get, "/protected")
2427            .operation_id("protected")
2428            .security("oauth2", vec!["read:data", "write:data"]);
2429
2430        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2431        builder.add_route(&route);
2432        let doc = builder.build();
2433
2434        let json = serde_json::to_string(&doc).unwrap();
2435        assert!(json.contains(r#""security""#));
2436        assert!(json.contains(r#""oauth2""#));
2437        assert!(json.contains(r#""read:data""#));
2438        assert!(json.contains(r#""write:data""#));
2439    }
2440
2441    #[test]
2442    fn route_without_security_has_empty_security() {
2443        let route = Route::with_placeholder_handler(Method::Get, "/public").operation_id("public");
2444
2445        let mut builder = OpenApiBuilder::new("Test API", "1.0.0");
2446        builder.add_route(&route);
2447        let doc = builder.build();
2448
2449        let op = doc.paths["/public"].get.as_ref().unwrap();
2450        assert!(op.security.is_empty());
2451    }
2452}
2453
2454// ============================================================================
2455// Response Examples Tests
2456// ============================================================================
2457
2458#[cfg(test)]
2459mod response_example_tests {
2460    use super::*;
2461    use crate::schema::{ObjectSchema, PrimitiveSchema, Schema, SchemaType};
2462
2463    #[test]
2464    fn media_type_with_example_serializes() {
2465        let mt = MediaType {
2466            schema: Some(Schema::string()),
2467            example: Some(serde_json::json!("hello")),
2468            examples: HashMap::new(),
2469        };
2470        let json = serde_json::to_value(&mt).unwrap();
2471        assert_eq!(json["example"], "hello");
2472    }
2473
2474    #[test]
2475    fn media_type_without_example_omits_field() {
2476        let mt = MediaType {
2477            schema: Some(Schema::string()),
2478            example: None,
2479            examples: HashMap::new(),
2480        };
2481        let json = serde_json::to_value(&mt).unwrap();
2482        assert!(!json.as_object().unwrap().contains_key("example"));
2483        assert!(!json.as_object().unwrap().contains_key("examples"));
2484    }
2485
2486    #[test]
2487    fn media_type_with_named_examples() {
2488        let mut examples = HashMap::new();
2489        examples.insert(
2490            "success".to_string(),
2491            Example {
2492                summary: Some("A success response".to_string()),
2493                description: None,
2494                value: Some(serde_json::json!({"id": 1, "name": "Alice"})),
2495                external_value: None,
2496            },
2497        );
2498        let mt = MediaType {
2499            schema: None,
2500            example: None,
2501            examples,
2502        };
2503        let json = serde_json::to_value(&mt).unwrap();
2504        assert_eq!(json["examples"]["success"]["value"]["name"], "Alice");
2505    }
2506
2507    #[test]
2508    fn object_schema_with_example() {
2509        let schema = ObjectSchema {
2510            title: Some("User".to_string()),
2511            description: None,
2512            properties: HashMap::new(),
2513            required: Vec::new(),
2514            additional_properties: None,
2515            example: Some(serde_json::json!({"name": "Bob", "age": 30})),
2516        };
2517        let json = serde_json::to_value(&schema).unwrap();
2518        assert_eq!(json["example"]["name"], "Bob");
2519    }
2520
2521    #[test]
2522    fn primitive_schema_with_example() {
2523        let schema = PrimitiveSchema {
2524            schema_type: SchemaType::String,
2525            format: Some("email".to_string()),
2526            nullable: false,
2527            minimum: None,
2528            maximum: None,
2529            exclusive_minimum: None,
2530            exclusive_maximum: None,
2531            min_length: None,
2532            max_length: None,
2533            pattern: None,
2534            enum_values: None,
2535            example: Some(serde_json::json!("user@example.com")),
2536        };
2537        let json = serde_json::to_value(&schema).unwrap();
2538        assert_eq!(json["example"], "user@example.com");
2539        assert_eq!(json["format"], "email");
2540    }
2541
2542    #[test]
2543    fn response_with_example_content() {
2544        let mut content = HashMap::new();
2545        content.insert(
2546            "application/json".to_string(),
2547            MediaType {
2548                schema: Some(Schema::reference("User")),
2549                example: Some(serde_json::json!({"id": 1, "name": "Alice"})),
2550                examples: HashMap::new(),
2551            },
2552        );
2553        let response = Response {
2554            description: "Success".to_string(),
2555            content,
2556        };
2557        let json = serde_json::to_value(&response).unwrap();
2558        assert_eq!(
2559            json["content"]["application/json"]["example"]["name"],
2560            "Alice"
2561        );
2562    }
2563
2564    #[test]
2565    fn media_type_roundtrip() {
2566        let mt = MediaType {
2567            schema: Some(Schema::string()),
2568            example: Some(serde_json::json!(42)),
2569            examples: HashMap::new(),
2570        };
2571        let json = serde_json::to_string(&mt).unwrap();
2572        let parsed: MediaType = serde_json::from_str(&json).unwrap();
2573        assert_eq!(parsed.example.unwrap(), 42);
2574    }
2575}