Skip to main content

sekkei/
types.rs

1//! `OpenAPI` 3.0.3 serde types — canonical definitions for the pleme-io ecosystem.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7// ── Root ───────────────────────────────────────────────────────────────────
8
9/// Root `OpenAPI` 3.0 specification object.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct OpenApiSpec {
12    pub info: Info,
13    #[serde(default)]
14    pub paths: BTreeMap<String, PathItem>,
15    #[serde(default)]
16    pub components: Option<Components>,
17    #[serde(default)]
18    pub servers: Vec<Server>,
19    #[serde(default)]
20    pub security: Vec<BTreeMap<String, Vec<String>>>,
21}
22
23// ── Info ───────────────────────────────────────────────────────────────────
24
25/// Metadata about the API (title, version, description).
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Info {
28    pub title: String,
29    #[serde(default)]
30    pub description: Option<String>,
31    pub version: String,
32}
33
34impl std::fmt::Display for Info {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        write!(f, "{} v{}", self.title, self.version)
37    }
38}
39
40// ── Paths & Operations ────────────────────────────────────────────────────
41
42/// Describes the operations available on a single path.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct PathItem {
45    #[serde(default)]
46    pub get: Option<Operation>,
47    #[serde(default)]
48    pub post: Option<Operation>,
49    #[serde(default)]
50    pub put: Option<Operation>,
51    #[serde(default)]
52    pub delete: Option<Operation>,
53    #[serde(default)]
54    pub patch: Option<Operation>,
55    #[serde(default)]
56    pub parameters: Vec<Parameter>,
57}
58
59/// A single API operation on a path (e.g. GET, POST).
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct Operation {
63    #[serde(default)]
64    pub operation_id: Option<String>,
65    #[serde(default)]
66    pub summary: Option<String>,
67    #[serde(default)]
68    pub description: Option<String>,
69    #[serde(default)]
70    pub parameters: Vec<Parameter>,
71    #[serde(default)]
72    pub request_body: Option<RequestBody>,
73    #[serde(default)]
74    pub responses: BTreeMap<String, Response>,
75    #[serde(default)]
76    pub security: Vec<BTreeMap<String, Vec<String>>>,
77    #[serde(default)]
78    pub tags: Vec<String>,
79}
80
81// ── Parameters ─────────────────────────────────────────────────────────────
82
83/// Describes a single operation parameter (query, path, header, cookie).
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct Parameter {
86    pub name: String,
87    #[serde(rename = "in")]
88    pub location: String,
89    #[serde(default)]
90    pub required: bool,
91    #[serde(default)]
92    pub description: Option<String>,
93    #[serde(default)]
94    pub schema: Option<Schema>,
95    /// $ref pointer, e.g. "#/components/parameters/Foo"
96    #[serde(rename = "$ref", default)]
97    pub ref_path: Option<String>,
98}
99
100// ── Request / Response Bodies ──────────────────────────────────────────────
101
102/// Describes a request body with content type mappings.
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
104pub struct RequestBody {
105    #[serde(default)]
106    pub required: bool,
107    #[serde(default)]
108    pub content: BTreeMap<String, MediaType>,
109    #[serde(default)]
110    pub description: Option<String>,
111    /// $ref pointer
112    #[serde(rename = "$ref", default)]
113    pub ref_path: Option<String>,
114}
115
116/// A media type (e.g. `application/json`) with an optional schema.
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct MediaType {
119    #[serde(default)]
120    pub schema: Option<Schema>,
121}
122
123/// Describes a single response from an API operation.
124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125pub struct Response {
126    #[serde(default)]
127    pub description: Option<String>,
128    #[serde(default)]
129    pub content: Option<BTreeMap<String, MediaType>>,
130    /// $ref pointer
131    #[serde(rename = "$ref", default)]
132    pub ref_path: Option<String>,
133}
134
135// ── Schema ─────────────────────────────────────────────────────────────────
136
137/// JSON Schema object used to describe data types in `OpenAPI` specs.
138#[derive(Debug, Clone, Serialize, Deserialize, Default)]
139#[serde(rename_all = "camelCase")]
140pub struct Schema {
141    #[serde(rename = "type", default)]
142    pub schema_type: Option<String>,
143    #[serde(default)]
144    pub format: Option<String>,
145    #[serde(default)]
146    pub description: Option<String>,
147    #[serde(default)]
148    pub properties: BTreeMap<String, Schema>,
149    #[serde(default)]
150    pub items: Option<Box<Schema>>,
151    #[serde(default)]
152    pub required: Vec<String>,
153    #[serde(rename = "enum", default)]
154    pub enum_values: Option<Vec<serde_json::Value>>,
155    /// $ref pointer, e.g. "#/components/schemas/Foo"
156    #[serde(rename = "$ref", default)]
157    pub ref_path: Option<String>,
158    #[serde(rename = "allOf", default)]
159    pub all_of: Vec<Schema>,
160    #[serde(rename = "oneOf", default)]
161    pub one_of: Vec<Schema>,
162    #[serde(rename = "anyOf", default)]
163    pub any_of: Vec<Schema>,
164    #[serde(default)]
165    pub default: Option<serde_json::Value>,
166    #[serde(default)]
167    pub minimum: Option<f64>,
168    #[serde(default)]
169    pub maximum: Option<f64>,
170    #[serde(rename = "minLength", default)]
171    pub min_length: Option<u64>,
172    #[serde(rename = "maxLength", default)]
173    pub max_length: Option<u64>,
174    #[serde(default)]
175    pub nullable: bool,
176    #[serde(default)]
177    pub additional_properties: Option<Box<Schema>>,
178    #[serde(default)]
179    pub title: Option<String>,
180}
181
182// ── Components ─────────────────────────────────────────────────────────────
183
184/// Holds reusable schema, parameter, request body, and response definitions.
185#[derive(Debug, Clone, Serialize, Deserialize, Default)]
186#[serde(rename_all = "camelCase")]
187pub struct Components {
188    #[serde(default)]
189    pub schemas: BTreeMap<String, Schema>,
190    #[serde(default)]
191    pub security_schemes: BTreeMap<String, SecurityScheme>,
192    #[serde(default)]
193    pub parameters: BTreeMap<String, Parameter>,
194    #[serde(default)]
195    pub request_bodies: BTreeMap<String, RequestBody>,
196    #[serde(default)]
197    pub responses: BTreeMap<String, Response>,
198}
199
200/// Defines a security scheme that can be used by the operations.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct SecurityScheme {
203    #[serde(rename = "type")]
204    pub scheme_type: String,
205    #[serde(default)]
206    pub scheme: Option<String>,
207    #[serde(default)]
208    pub description: Option<String>,
209    /// For apiKey type: "header", "query", or "cookie"
210    #[serde(rename = "in", default)]
211    pub location: Option<String>,
212    /// For apiKey type: the header/query parameter name
213    #[serde(default)]
214    pub name: Option<String>,
215}
216
217// ── Server ─────────────────────────────────────────────────────────────────
218
219/// An object representing a server.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct Server {
222    pub url: String,
223    #[serde(default)]
224    pub description: Option<String>,
225}
226
227impl std::fmt::Display for OpenApiSpec {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        write!(
230            f,
231            "OpenAPI spec: {} ({} paths, {} operations)",
232            self.info,
233            self.paths.len(),
234            self.operation_count()
235        )
236    }
237}
238
239// ── PathItem helpers ──────────────────────────────────────────────────
240
241const HTTP_METHODS: [&str; 5] = ["get", "post", "put", "delete", "patch"];
242
243impl PathItem {
244    /// Iterate over all defined operations in this path item as `(method, &Operation)` pairs.
245    ///
246    /// Methods are yielded in standard order: GET, POST, PUT, DELETE, PATCH.
247    pub fn operations(&self) -> impl Iterator<Item = (&'static str, &Operation)> {
248        HTTP_METHODS
249            .into_iter()
250            .zip([
251                self.get.as_ref(),
252                self.post.as_ref(),
253                self.put.as_ref(),
254                self.delete.as_ref(),
255                self.patch.as_ref(),
256            ])
257            .filter_map(|(method, op)| op.map(|o| (method, o)))
258    }
259}
260
261// ── Schema helpers ────────────────────────────────────────────────────
262
263impl Schema {
264    /// Check if this schema is a `$ref`.
265    #[must_use]
266    pub fn is_ref(&self) -> bool {
267        self.ref_path.is_some()
268    }
269
270    /// Check if this is an array type.
271    #[must_use]
272    pub fn is_array(&self) -> bool {
273        self.schema_type.as_deref() == Some("array")
274    }
275
276    /// Check if this is an object type.
277    #[must_use]
278    pub fn is_object(&self) -> bool {
279        self.schema_type.as_deref() == Some("object")
280    }
281
282    /// Check if this is a primitive type (string, integer, number, boolean).
283    #[must_use]
284    pub fn is_primitive(&self) -> bool {
285        matches!(
286            self.schema_type.as_deref(),
287            Some("string" | "integer" | "number" | "boolean")
288        )
289    }
290
291    /// Check if this schema has enum values.
292    #[must_use]
293    pub fn is_enum(&self) -> bool {
294        self.enum_values.as_ref().is_some_and(|v| !v.is_empty())
295    }
296
297    /// Check if this schema uses composition (`allOf`, `oneOf`, or `anyOf`).
298    #[must_use]
299    pub fn is_composed(&self) -> bool {
300        !self.all_of.is_empty() || !self.one_of.is_empty() || !self.any_of.is_empty()
301    }
302
303    /// Get the ref name if this is a `$ref` schema.
304    #[must_use]
305    pub fn ref_name(&self) -> Option<&str> {
306        self.ref_path.as_deref().map(ref_name)
307    }
308}
309
310// ── Operation helpers ─────────────────────────────────────────────────
311
312impl Operation {
313    /// Get the JSON body schema from `request_body`, if any.
314    #[must_use]
315    pub fn json_body_schema(&self) -> Option<&Schema> {
316        self.request_body
317            .as_ref()?
318            .content
319            .get("application/json")?
320            .schema
321            .as_ref()
322    }
323
324    /// Get the success response schema (200 or 201).
325    #[must_use]
326    pub fn success_response_schema(&self) -> Option<&Schema> {
327        let resp = self
328            .responses
329            .get("200")
330            .or_else(|| self.responses.get("201"))?;
331        resp.content
332            .as_ref()?
333            .get("application/json")?
334            .schema
335            .as_ref()
336    }
337}
338
339// ── $ref resolution helpers ────────────────────────────────────────────────
340
341/// Extract the final component name from a JSON pointer.
342///
343/// ```text
344/// "#/components/schemas/Pet" → "Pet"
345/// "#/components/parameters/LimitParam" → "LimitParam"
346/// ```
347#[must_use]
348pub fn ref_name(ref_path: &str) -> &str {
349    ref_path.rsplit('/').next().unwrap_or(ref_path)
350}
351
352impl OpenApiSpec {
353    /// Look up a schema by `$ref` pointer like `#/components/schemas/Foo`.
354    #[must_use]
355    pub fn resolve_schema_ref(&self, ref_path: &str) -> Option<&Schema> {
356        let name = ref_name(ref_path);
357        self.components.as_ref()?.schemas.get(name)
358    }
359
360    /// Look up a parameter by `$ref` pointer like `#/components/parameters/Foo`.
361    #[must_use]
362    pub fn resolve_parameter_ref(&self, ref_path: &str) -> Option<&Parameter> {
363        let name = ref_name(ref_path);
364        self.components.as_ref()?.parameters.get(name)
365    }
366
367    /// Look up a request body by `$ref` pointer.
368    #[must_use]
369    pub fn resolve_request_body_ref(&self, ref_path: &str) -> Option<&RequestBody> {
370        let name = ref_name(ref_path);
371        self.components.as_ref()?.request_bodies.get(name)
372    }
373
374    /// Look up a response by `$ref` pointer.
375    #[must_use]
376    pub fn resolve_response_ref(&self, ref_path: &str) -> Option<&Response> {
377        let name = ref_name(ref_path);
378        self.components.as_ref()?.responses.get(name)
379    }
380}
381
382/// Enumerate all operations across all paths.
383/// Returns `(method, path, &Operation)` triples.
384///
385/// Prefer [`OpenApiSpec::all_operations`] for new code.
386#[must_use]
387pub fn all_operations(spec: &OpenApiSpec) -> Vec<(String, String, &Operation)> {
388    spec.all_operations().collect()
389}
390
391impl OpenApiSpec {
392    /// Iterate over every operation in the spec as `(method, path, &Operation)` triples.
393    pub fn all_operations(&self) -> impl Iterator<Item = (String, String, &Operation)> {
394        self.paths.iter().flat_map(|(path, item)| {
395            let path = path.clone();
396            item.operations()
397                .map(move |(method, op)| (method.to_string(), path.clone(), op))
398        })
399    }
400
401    /// Returns the number of named schemas in `components.schemas`.
402    #[must_use]
403    pub fn schema_count(&self) -> usize {
404        self.components
405            .as_ref()
406            .map_or(0, |c| c.schemas.len())
407    }
408
409    /// Returns the total number of operations across all paths.
410    #[must_use]
411    pub fn operation_count(&self) -> usize {
412        self.paths.values().map(|item| item.operations().count()).sum()
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::test_fixtures::{FULL_SPEC_YAML, MINIMAL_SPEC_YAML};
420
421    #[test]
422    fn parse_minimal_yaml() {
423        let spec: OpenApiSpec = serde_yaml_ng::from_str(MINIMAL_SPEC_YAML).unwrap();
424        assert_eq!(spec.info.title, "Test API");
425        assert_eq!(spec.info.version, "1.0.0");
426        assert!(spec.paths.is_empty());
427        assert!(spec.components.is_none());
428        assert!(spec.servers.is_empty());
429    }
430
431    #[test]
432    fn parse_full_yaml() {
433        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
434        assert_eq!(spec.info.title, "Pet Store");
435        assert_eq!(
436            spec.info.description.as_deref(),
437            Some("A sample pet store API")
438        );
439        assert_eq!(spec.info.version, "2.0.0");
440        assert_eq!(spec.servers.len(), 1);
441        assert_eq!(spec.servers[0].url, "https://api.petstore.example.com/v2");
442        assert_eq!(spec.paths.len(), 2);
443        assert!(spec.paths.contains_key("/pets"));
444        assert!(spec.paths.contains_key("/pets/{petId}"));
445    }
446
447    #[test]
448    fn parse_path_item_methods() {
449        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
450        let pets = &spec.paths["/pets"];
451        assert!(pets.get.is_some());
452        assert!(pets.post.is_some());
453        assert!(pets.put.is_none());
454        assert!(pets.delete.is_none());
455
456        let pet_by_id = &spec.paths["/pets/{petId}"];
457        assert!(pet_by_id.get.is_some());
458        assert!(pet_by_id.delete.is_some());
459        assert!(pet_by_id.post.is_none());
460    }
461
462    #[test]
463    fn parse_operation_fields() {
464        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
465        let list_op = spec.paths["/pets"].get.as_ref().unwrap();
466        assert_eq!(list_op.operation_id.as_deref(), Some("listPets"));
467        assert_eq!(list_op.summary.as_deref(), Some("List all pets"));
468        assert_eq!(list_op.parameters.len(), 1);
469        assert_eq!(list_op.parameters[0].name, "limit");
470        assert_eq!(list_op.parameters[0].location, "query");
471        assert!(!list_op.parameters[0].required);
472    }
473
474    #[test]
475    fn parse_parameters_with_schema() {
476        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
477        let limit_param = &spec.paths["/pets"].get.as_ref().unwrap().parameters[0];
478        let schema = limit_param.schema.as_ref().unwrap();
479        assert_eq!(schema.schema_type.as_deref(), Some("integer"));
480        assert_eq!(schema.format.as_deref(), Some("int64"));
481    }
482
483    #[test]
484    fn parse_path_level_parameters() {
485        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
486        let pet_item = &spec.paths["/pets/{petId}"];
487        assert_eq!(pet_item.parameters.len(), 1);
488        assert_eq!(pet_item.parameters[0].name, "petId");
489        assert_eq!(pet_item.parameters[0].location, "path");
490        assert!(pet_item.parameters[0].required);
491    }
492
493    #[test]
494    fn parse_request_body() {
495        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
496        let create_op = spec.paths["/pets"].post.as_ref().unwrap();
497        let body = create_op.request_body.as_ref().unwrap();
498        assert!(body.required);
499        assert!(body.content.contains_key("application/json"));
500        let schema = body.content["application/json"].schema.as_ref().unwrap();
501        assert_eq!(
502            schema.ref_path.as_deref(),
503            Some("#/components/schemas/CreatePetRequest")
504        );
505    }
506
507    #[test]
508    fn parse_responses() {
509        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
510        let get_op = spec.paths["/pets/{petId}"].get.as_ref().unwrap();
511        assert!(get_op.responses.contains_key("200"));
512        assert!(get_op.responses.contains_key("404"));
513        assert_eq!(
514            get_op.responses["404"].description.as_deref(),
515            Some("Pet not found")
516        );
517    }
518
519    #[test]
520    fn parse_component_schemas() {
521        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
522        let components = spec.components.as_ref().unwrap();
523        assert!(components.schemas.contains_key("Pet"));
524        assert!(components.schemas.contains_key("PetStatus"));
525        assert!(components.schemas.contains_key("CreatePetRequest"));
526
527        let pet = &components.schemas["Pet"];
528        assert_eq!(pet.schema_type.as_deref(), Some("object"));
529        assert_eq!(pet.required, vec!["id", "name"]);
530        assert!(pet.properties.contains_key("id"));
531        assert!(pet.properties.contains_key("name"));
532        assert!(pet.properties.contains_key("tag"));
533        assert!(pet.properties.contains_key("status"));
534    }
535
536    #[test]
537    fn parse_enum_schema() {
538        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
539        let status = &spec.components.as_ref().unwrap().schemas["PetStatus"];
540        assert_eq!(status.schema_type.as_deref(), Some("string"));
541        let variants = status.enum_values.as_ref().unwrap();
542        assert_eq!(variants.len(), 3);
543        assert_eq!(variants[0].as_str(), Some("available"));
544        assert_eq!(variants[1].as_str(), Some("pending"));
545        assert_eq!(variants[2].as_str(), Some("sold"));
546    }
547
548    #[test]
549    fn parse_security_schemes() {
550        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
551        let schemes = &spec.components.as_ref().unwrap().security_schemes;
552        assert!(schemes.contains_key("bearerAuth"));
553        let bearer = &schemes["bearerAuth"];
554        assert_eq!(bearer.scheme_type, "http");
555        assert_eq!(bearer.scheme.as_deref(), Some("bearer"));
556    }
557
558    #[test]
559    fn parse_json_format() {
560        let json = r#"{
561            "info": { "title": "JSON API", "version": "0.1.0" },
562            "paths": {}
563        }"#;
564        let spec: OpenApiSpec = serde_json::from_str(json).unwrap();
565        assert_eq!(spec.info.title, "JSON API");
566        assert_eq!(spec.info.version, "0.1.0");
567    }
568
569    // -- ref_name tests --
570
571    #[test]
572    fn ref_name_extracts_last_segment() {
573        assert_eq!(ref_name("#/components/schemas/Pet"), "Pet");
574        assert_eq!(
575            ref_name("#/components/parameters/LimitParam"),
576            "LimitParam"
577        );
578        assert_eq!(ref_name("#/components/requestBodies/PetBody"), "PetBody");
579    }
580
581    #[test]
582    fn ref_name_handles_no_slash() {
583        assert_eq!(ref_name("Standalone"), "Standalone");
584    }
585
586    #[test]
587    fn ref_name_handles_trailing_slash() {
588        assert_eq!(ref_name("#/components/schemas/"), "");
589    }
590
591    // -- resolve_*_ref tests --
592
593    #[test]
594    fn resolve_schema_ref_found() {
595        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
596        let pet = spec.resolve_schema_ref("#/components/schemas/Pet");
597        assert!(pet.is_some());
598        assert_eq!(pet.unwrap().schema_type.as_deref(), Some("object"));
599    }
600
601    #[test]
602    fn resolve_schema_ref_not_found() {
603        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
604        assert!(
605            spec.resolve_schema_ref("#/components/schemas/Nonexistent")
606                .is_none()
607        );
608    }
609
610    #[test]
611    fn resolve_schema_ref_no_components() {
612        let spec: OpenApiSpec = serde_yaml_ng::from_str(MINIMAL_SPEC_YAML).unwrap();
613        assert!(
614            spec.resolve_schema_ref("#/components/schemas/Anything")
615                .is_none()
616        );
617    }
618
619    #[test]
620    fn resolve_parameter_ref_found() {
621        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
622        let param = spec.resolve_parameter_ref("#/components/parameters/LimitParam");
623        assert!(param.is_some());
624        assert_eq!(param.unwrap().name, "limit");
625    }
626
627    #[test]
628    fn resolve_parameter_ref_not_found() {
629        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
630        assert!(
631            spec.resolve_parameter_ref("#/components/parameters/Missing")
632                .is_none()
633        );
634    }
635
636    #[test]
637    fn resolve_request_body_ref_found() {
638        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
639        let body = spec.resolve_request_body_ref("#/components/requestBodies/PetBody");
640        assert!(body.is_some());
641        assert!(body.unwrap().required);
642    }
643
644    #[test]
645    fn resolve_response_ref_found() {
646        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
647        let resp = spec.resolve_response_ref("#/components/responses/NotFound");
648        assert!(resp.is_some());
649        assert_eq!(
650            resp.unwrap().description.as_deref(),
651            Some("The requested resource was not found")
652        );
653    }
654
655    #[test]
656    fn resolve_response_ref_not_found() {
657        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
658        assert!(
659            spec.resolve_response_ref("#/components/responses/Gone")
660                .is_none()
661        );
662    }
663
664    // -- Schema edge cases --
665
666    #[test]
667    fn parse_schema_with_all_of() {
668        let yaml = r##"
669info:
670  title: AllOf Test
671  version: "1.0.0"
672paths: {}
673components:
674  schemas:
675    Extended:
676      allOf:
677        - $ref: "#/components/schemas/Base"
678        - type: object
679          properties:
680            extra:
681              type: string
682    Base:
683      type: object
684      required:
685        - id
686      properties:
687        id:
688          type: integer
689"##;
690        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
691        let extended = &spec.components.as_ref().unwrap().schemas["Extended"];
692        assert_eq!(extended.all_of.len(), 2);
693        assert_eq!(
694            extended.all_of[0].ref_path.as_deref(),
695            Some("#/components/schemas/Base")
696        );
697    }
698
699    #[test]
700    fn parse_schema_with_nullable() {
701        let yaml = r#"
702info:
703  title: Nullable Test
704  version: "1.0.0"
705paths: {}
706components:
707  schemas:
708    NullableField:
709      type: object
710      properties:
711        value:
712          type: string
713          nullable: true
714"#;
715        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
716        let schema = &spec.components.as_ref().unwrap().schemas["NullableField"];
717        assert!(schema.properties["value"].nullable);
718    }
719
720    #[test]
721    fn parse_schema_with_additional_properties() {
722        let yaml = r#"
723info:
724  title: AdditionalProps Test
725  version: "1.0.0"
726paths: {}
727components:
728  schemas:
729    FreeForm:
730      type: object
731      additionalProperties:
732        type: string
733"#;
734        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
735        let schema = &spec.components.as_ref().unwrap().schemas["FreeForm"];
736        assert!(schema.additional_properties.is_some());
737        let inner = schema.additional_properties.as_ref().unwrap();
738        assert_eq!(inner.schema_type.as_deref(), Some("string"));
739    }
740
741    #[test]
742    fn parse_schema_array_with_items() {
743        let yaml = r#"
744info:
745  title: Array Test
746  version: "1.0.0"
747paths: {}
748components:
749  schemas:
750    StringList:
751      type: array
752      items:
753        type: string
754"#;
755        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
756        let schema = &spec.components.as_ref().unwrap().schemas["StringList"];
757        assert_eq!(schema.schema_type.as_deref(), Some("array"));
758        let items = schema.items.as_ref().unwrap();
759        assert_eq!(items.schema_type.as_deref(), Some("string"));
760    }
761
762    #[test]
763    fn parse_servers() {
764        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
765        assert_eq!(spec.servers.len(), 1);
766        assert_eq!(spec.servers[0].url, "https://api.petstore.example.com/v2");
767        assert_eq!(
768            spec.servers[0].description.as_deref(),
769            Some("Production server")
770        );
771    }
772
773    #[test]
774    fn parse_api_key_security_scheme() {
775        let yaml = r#"
776info:
777  title: ApiKey Test
778  version: "1.0.0"
779paths: {}
780components:
781  securitySchemes:
782    apiKey:
783      type: apiKey
784      in: header
785      name: X-API-Key
786"#;
787        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
788        let schemes = &spec.components.as_ref().unwrap().security_schemes;
789        let key = &schemes["apiKey"];
790        assert_eq!(key.scheme_type, "apiKey");
791        assert_eq!(key.location.as_deref(), Some("header"));
792        assert_eq!(key.name.as_deref(), Some("X-API-Key"));
793    }
794
795    // -- all_operations tests --
796
797    #[test]
798    fn all_operations_returns_correct_tuples() {
799        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
800        let ops = all_operations(&spec);
801        // Full spec has: GET /pets, POST /pets, GET /pets/{petId}, DELETE /pets/{petId}
802        assert_eq!(ops.len(), 4);
803
804        let methods_and_paths: Vec<(&str, &str)> =
805            ops.iter().map(|(m, p, _)| (m.as_str(), p.as_str())).collect();
806        assert!(methods_and_paths.contains(&("get", "/pets")));
807        assert!(methods_and_paths.contains(&("post", "/pets")));
808        assert!(methods_and_paths.contains(&("get", "/pets/{petId}")));
809        assert!(methods_and_paths.contains(&("delete", "/pets/{petId}")));
810    }
811
812    #[test]
813    fn all_operations_empty_paths() {
814        let spec: OpenApiSpec = serde_yaml_ng::from_str(MINIMAL_SPEC_YAML).unwrap();
815        let ops = all_operations(&spec);
816        assert!(ops.is_empty());
817    }
818
819    #[test]
820    fn all_operations_preserves_operation_ids() {
821        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
822        let ops = all_operations(&spec);
823        let ids: Vec<Option<&str>> = ops
824            .iter()
825            .map(|(_, _, op)| op.operation_id.as_deref())
826            .collect();
827        assert!(ids.contains(&Some("listPets")));
828        assert!(ids.contains(&Some("createPet")));
829        assert!(ids.contains(&Some("getPet")));
830        assert!(ids.contains(&Some("deletePet")));
831    }
832
833    // -- global security --
834
835    #[test]
836    fn parse_global_security() {
837        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
838        assert_eq!(spec.security.len(), 1);
839        assert!(spec.security[0].contains_key("bearerAuth"));
840        assert!(spec.security[0]["bearerAuth"].is_empty());
841    }
842
843    // -- Schema min/max constraints --
844
845    #[test]
846    fn parse_schema_with_min_max() {
847        let yaml = r#"
848info:
849  title: MinMax Test
850  version: "1.0.0"
851paths: {}
852components:
853  schemas:
854    Bounded:
855      type: integer
856      minimum: 1
857      maximum: 100
858"#;
859        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
860        let schema = &spec.components.as_ref().unwrap().schemas["Bounded"];
861        assert_eq!(schema.minimum, Some(1.0));
862        assert_eq!(schema.maximum, Some(100.0));
863    }
864
865    #[test]
866    fn parse_schema_with_string_length_constraints() {
867        let yaml = r#"
868info:
869  title: StringLength Test
870  version: "1.0.0"
871paths: {}
872components:
873  schemas:
874    BoundedString:
875      type: string
876      minLength: 3
877      maxLength: 255
878"#;
879        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
880        let schema = &spec.components.as_ref().unwrap().schemas["BoundedString"];
881        assert_eq!(schema.min_length, Some(3));
882        assert_eq!(schema.max_length, Some(255));
883    }
884
885    #[test]
886    fn parse_schema_with_default_value() {
887        let yaml = r#"
888info:
889  title: Default Test
890  version: "1.0.0"
891paths: {}
892components:
893  schemas:
894    WithDefault:
895      type: string
896      default: "hello"
897"#;
898        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
899        let schema = &spec.components.as_ref().unwrap().schemas["WithDefault"];
900        assert_eq!(
901            schema.default.as_ref().and_then(|v| v.as_str()),
902            Some("hello")
903        );
904    }
905
906    #[test]
907    fn parse_schema_with_title() {
908        let yaml = r#"
909info:
910  title: Title Test
911  version: "1.0.0"
912paths: {}
913components:
914  schemas:
915    Named:
916      type: object
917      title: A Named Schema
918"#;
919        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
920        let schema = &spec.components.as_ref().unwrap().schemas["Named"];
921        assert_eq!(schema.title.as_deref(), Some("A Named Schema"));
922    }
923
924    #[test]
925    fn parse_schema_with_one_of() {
926        let yaml = r##"
927info:
928  title: OneOf Test
929  version: "1.0.0"
930paths: {}
931components:
932  schemas:
933    StringOrInt:
934      oneOf:
935        - type: string
936        - type: integer
937"##;
938        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
939        let schema = &spec.components.as_ref().unwrap().schemas["StringOrInt"];
940        assert_eq!(schema.one_of.len(), 2);
941        assert_eq!(schema.one_of[0].schema_type.as_deref(), Some("string"));
942        assert_eq!(schema.one_of[1].schema_type.as_deref(), Some("integer"));
943    }
944
945    #[test]
946    fn parse_schema_with_any_of() {
947        let yaml = r##"
948info:
949  title: AnyOf Test
950  version: "1.0.0"
951paths: {}
952components:
953  schemas:
954    Flexible:
955      anyOf:
956        - type: string
957        - type: number
958"##;
959        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
960        let schema = &spec.components.as_ref().unwrap().schemas["Flexible"];
961        assert_eq!(schema.any_of.len(), 2);
962    }
963
964    // -- Schema ref inside properties --
965
966    #[test]
967    fn parse_property_with_ref() {
968        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
969        let pet = &spec.components.as_ref().unwrap().schemas["Pet"];
970        let status_prop = &pet.properties["status"];
971        assert_eq!(
972            status_prop.ref_path.as_deref(),
973            Some("#/components/schemas/PetStatus")
974        );
975    }
976
977    // -- Component parameter ref --
978
979    #[test]
980    fn parse_component_parameters() {
981        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
982        let params = &spec.components.as_ref().unwrap().parameters;
983        assert!(params.contains_key("LimitParam"));
984        let limit = &params["LimitParam"];
985        assert_eq!(limit.name, "limit");
986        assert_eq!(limit.location, "query");
987        assert!(!limit.required);
988    }
989
990    // -- Component request bodies --
991
992    #[test]
993    fn parse_component_request_bodies() {
994        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
995        let bodies = &spec.components.as_ref().unwrap().request_bodies;
996        assert!(bodies.contains_key("PetBody"));
997        let pet_body = &bodies["PetBody"];
998        assert!(pet_body.required);
999        assert!(pet_body.content.contains_key("application/json"));
1000    }
1001
1002    // -- Component responses --
1003
1004    #[test]
1005    fn parse_component_responses() {
1006        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1007        let responses = &spec.components.as_ref().unwrap().responses;
1008        assert!(responses.contains_key("NotFound"));
1009        assert_eq!(
1010            responses["NotFound"].description.as_deref(),
1011            Some("The requested resource was not found")
1012        );
1013    }
1014
1015    // -- Roundtrip serialization --
1016
1017    #[test]
1018    fn roundtrip_json_serialization() {
1019        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1020        let json = serde_json::to_string(&spec).unwrap();
1021        let roundtrip: OpenApiSpec = serde_json::from_str(&json).unwrap();
1022        assert_eq!(roundtrip.info.title, spec.info.title);
1023        assert_eq!(roundtrip.paths.len(), spec.paths.len());
1024    }
1025
1026    // -- Schema Default trait --
1027
1028    #[test]
1029    fn schema_default_is_empty() {
1030        let schema = Schema::default();
1031        assert!(schema.schema_type.is_none());
1032        assert!(schema.properties.is_empty());
1033        assert!(schema.required.is_empty());
1034        assert!(!schema.nullable);
1035        assert!(schema.all_of.is_empty());
1036        assert!(schema.one_of.is_empty());
1037        assert!(schema.any_of.is_empty());
1038    }
1039
1040    // -- Components Default trait --
1041
1042    #[test]
1043    fn components_default_is_empty() {
1044        let components = Components::default();
1045        assert!(components.schemas.is_empty());
1046        assert!(components.security_schemes.is_empty());
1047        assert!(components.parameters.is_empty());
1048        assert!(components.request_bodies.is_empty());
1049        assert!(components.responses.is_empty());
1050    }
1051
1052    // -- Operation tags --
1053
1054    #[test]
1055    fn parse_operation_with_tags() {
1056        let yaml = r#"
1057info:
1058  title: Tags Test
1059  version: "1.0.0"
1060paths:
1061  /items:
1062    get:
1063      operationId: listItems
1064      tags:
1065        - items
1066        - public
1067      responses:
1068        "200":
1069          description: OK
1070"#;
1071        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1072        let op = spec.paths["/items"].get.as_ref().unwrap();
1073        assert_eq!(op.tags, vec!["items", "public"]);
1074    }
1075
1076    // ── Schema helper tests ─────────────────────────────────────
1077
1078    #[test]
1079    fn schema_is_ref() {
1080        let s = Schema {
1081            ref_path: Some("#/components/schemas/Pet".to_string()),
1082            ..Default::default()
1083        };
1084        assert!(s.is_ref());
1085        assert_eq!(s.ref_name(), Some("Pet"));
1086    }
1087
1088    #[test]
1089    fn schema_is_not_ref() {
1090        let s = Schema::default();
1091        assert!(!s.is_ref());
1092        assert_eq!(s.ref_name(), None);
1093    }
1094
1095    #[test]
1096    fn schema_is_array() {
1097        let s = Schema {
1098            schema_type: Some("array".to_string()),
1099            ..Default::default()
1100        };
1101        assert!(s.is_array());
1102        assert!(!s.is_object());
1103        assert!(!s.is_primitive());
1104    }
1105
1106    #[test]
1107    fn schema_is_object() {
1108        let s = Schema {
1109            schema_type: Some("object".to_string()),
1110            ..Default::default()
1111        };
1112        assert!(s.is_object());
1113        assert!(!s.is_array());
1114        assert!(!s.is_primitive());
1115    }
1116
1117    #[test]
1118    fn schema_is_primitive_string() {
1119        let s = Schema {
1120            schema_type: Some("string".to_string()),
1121            ..Default::default()
1122        };
1123        assert!(s.is_primitive());
1124        assert!(!s.is_object());
1125        assert!(!s.is_array());
1126    }
1127
1128    #[test]
1129    fn schema_is_primitive_integer() {
1130        let s = Schema {
1131            schema_type: Some("integer".to_string()),
1132            ..Default::default()
1133        };
1134        assert!(s.is_primitive());
1135    }
1136
1137    #[test]
1138    fn schema_is_primitive_number() {
1139        let s = Schema {
1140            schema_type: Some("number".to_string()),
1141            ..Default::default()
1142        };
1143        assert!(s.is_primitive());
1144    }
1145
1146    #[test]
1147    fn schema_is_primitive_boolean() {
1148        let s = Schema {
1149            schema_type: Some("boolean".to_string()),
1150            ..Default::default()
1151        };
1152        assert!(s.is_primitive());
1153    }
1154
1155    #[test]
1156    fn schema_is_enum() {
1157        let s = Schema {
1158            enum_values: Some(vec![serde_json::Value::String("a".to_string())]),
1159            ..Default::default()
1160        };
1161        assert!(s.is_enum());
1162    }
1163
1164    #[test]
1165    fn schema_is_not_enum_empty() {
1166        let s = Schema {
1167            enum_values: Some(vec![]),
1168            ..Default::default()
1169        };
1170        assert!(!s.is_enum());
1171    }
1172
1173    #[test]
1174    fn schema_is_not_enum_none() {
1175        let s = Schema::default();
1176        assert!(!s.is_enum());
1177    }
1178
1179    #[test]
1180    fn schema_is_composed_all_of() {
1181        let s = Schema {
1182            all_of: vec![Schema::default()],
1183            ..Default::default()
1184        };
1185        assert!(s.is_composed());
1186    }
1187
1188    #[test]
1189    fn schema_is_composed_one_of() {
1190        let s = Schema {
1191            one_of: vec![Schema::default()],
1192            ..Default::default()
1193        };
1194        assert!(s.is_composed());
1195    }
1196
1197    #[test]
1198    fn schema_is_not_composed_empty() {
1199        assert!(!Schema::default().is_composed());
1200    }
1201
1202    #[test]
1203    fn schema_ref_name_nested_path() {
1204        let s = Schema {
1205            ref_path: Some("#/components/schemas/deeply/Nested".to_string()),
1206            ..Default::default()
1207        };
1208        assert_eq!(s.ref_name(), Some("Nested"));
1209    }
1210
1211    #[test]
1212    fn schema_no_type_is_not_primitive_or_array_or_object() {
1213        let s = Schema::default();
1214        assert!(!s.is_primitive());
1215        assert!(!s.is_array());
1216        assert!(!s.is_object());
1217    }
1218
1219    // ── Operation helper tests ──────────────────────────────────
1220
1221    #[test]
1222    fn operation_json_body_schema() {
1223        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1224        let post_op = spec.paths["/pets"].post.as_ref().unwrap();
1225        let body_schema = post_op.json_body_schema();
1226        assert!(body_schema.is_some());
1227        assert!(body_schema.unwrap().is_ref());
1228    }
1229
1230    #[test]
1231    fn operation_json_body_schema_none_when_no_body() {
1232        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1233        let get_op = spec.paths["/pets"].get.as_ref().unwrap();
1234        assert!(get_op.json_body_schema().is_none());
1235    }
1236
1237    #[test]
1238    fn operation_success_response_200() {
1239        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1240        let get_op = spec.paths["/pets"].get.as_ref().unwrap();
1241        let resp_schema = get_op.success_response_schema();
1242        assert!(resp_schema.is_some());
1243        assert!(resp_schema.unwrap().is_array());
1244    }
1245
1246    #[test]
1247    fn operation_success_response_201() {
1248        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1249        let post_op = spec.paths["/pets"].post.as_ref().unwrap();
1250        let resp_schema = post_op.success_response_schema();
1251        assert!(resp_schema.is_some());
1252        assert!(resp_schema.unwrap().is_ref());
1253    }
1254
1255    #[test]
1256    fn operation_success_response_none_when_no_content() {
1257        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1258        let delete_op = spec.paths["/pets/{petId}"].delete.as_ref().unwrap();
1259        assert!(delete_op.success_response_schema().is_none());
1260    }
1261
1262    // ── all_operations comprehensive ────────────────────────────
1263
1264    #[test]
1265    fn all_operations_returns_correct_methods() {
1266        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1267        let ops = all_operations(&spec);
1268        let methods: Vec<&str> = ops.iter().map(|(m, _, _)| m.as_str()).collect();
1269        assert!(methods.contains(&"get"));
1270        assert!(methods.contains(&"post"));
1271        assert!(methods.contains(&"delete"));
1272    }
1273
1274    #[test]
1275    fn all_operations_empty_spec() {
1276        let spec: OpenApiSpec =
1277            serde_yaml_ng::from_str("info:\n  title: E\n  version: '1'\npaths: {}")
1278                .unwrap();
1279        assert!(all_operations(&spec).is_empty());
1280    }
1281
1282    // ── Schema helpers on parsed spec ───────────────────────────
1283
1284    #[test]
1285    fn parsed_schema_helpers_on_pet() {
1286        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1287        let pet = &spec.components.as_ref().unwrap().schemas["Pet"];
1288        assert!(pet.is_object());
1289        assert!(!pet.is_array());
1290        assert!(!pet.is_primitive());
1291        assert!(!pet.is_ref());
1292        assert!(!pet.is_enum());
1293    }
1294
1295    #[test]
1296    fn parsed_schema_helpers_on_pet_status() {
1297        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1298        let status = &spec.components.as_ref().unwrap().schemas["PetStatus"];
1299        assert!(status.is_primitive());
1300        assert!(status.is_enum());
1301        assert!(!status.is_object());
1302    }
1303
1304    #[test]
1305    fn parsed_schema_helpers_on_ref_property() {
1306        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1307        let pet = &spec.components.as_ref().unwrap().schemas["Pet"];
1308        let status_prop = &pet.properties["status"];
1309        assert!(status_prop.is_ref());
1310        assert_eq!(status_prop.ref_name(), Some("PetStatus"));
1311    }
1312
1313    // ── YAML ↔ JSON round-trip ────────────────────────────────────
1314
1315    #[test]
1316    fn roundtrip_yaml_to_json_to_yaml() {
1317        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1318        let json = serde_json::to_string_pretty(&spec).unwrap();
1319        let from_json: OpenApiSpec = serde_json::from_str(&json).unwrap();
1320        let yaml_again = serde_yaml_ng::to_string(&from_json).unwrap();
1321        let from_yaml: OpenApiSpec = serde_yaml_ng::from_str(&yaml_again).unwrap();
1322
1323        assert_eq!(from_yaml.info.title, "Pet Store");
1324        assert_eq!(from_yaml.info.version, "2.0.0");
1325        assert_eq!(from_yaml.paths.len(), 2);
1326        assert_eq!(
1327            from_yaml.components.as_ref().unwrap().schemas.len(),
1328            3
1329        );
1330    }
1331
1332    #[test]
1333    fn roundtrip_preserves_all_operations() {
1334        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1335        let json = serde_json::to_string(&spec).unwrap();
1336        let roundtrip: OpenApiSpec = serde_json::from_str(&json).unwrap();
1337        let orig_ops = all_operations(&spec);
1338        let rt_ops = all_operations(&roundtrip);
1339        assert_eq!(orig_ops.len(), rt_ops.len());
1340        for (orig, rt) in orig_ops.iter().zip(rt_ops.iter()) {
1341            assert_eq!(orig.0, rt.0);
1342            assert_eq!(orig.1, rt.1);
1343            assert_eq!(orig.2.operation_id, rt.2.operation_id);
1344        }
1345    }
1346
1347    #[test]
1348    fn roundtrip_preserves_schema_fields() {
1349        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1350        let json = serde_json::to_string(&spec).unwrap();
1351        let roundtrip: OpenApiSpec = serde_json::from_str(&json).unwrap();
1352
1353        let orig_pet = &spec.components.as_ref().unwrap().schemas["Pet"];
1354        let rt_pet = &roundtrip.components.as_ref().unwrap().schemas["Pet"];
1355        assert_eq!(orig_pet.schema_type, rt_pet.schema_type);
1356        assert_eq!(orig_pet.required, rt_pet.required);
1357        assert_eq!(orig_pet.properties.len(), rt_pet.properties.len());
1358    }
1359
1360    #[test]
1361    fn roundtrip_preserves_servers() {
1362        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1363        let json = serde_json::to_string(&spec).unwrap();
1364        let roundtrip: OpenApiSpec = serde_json::from_str(&json).unwrap();
1365        assert_eq!(spec.servers.len(), roundtrip.servers.len());
1366        assert_eq!(spec.servers[0].url, roundtrip.servers[0].url);
1367        assert_eq!(spec.servers[0].description, roundtrip.servers[0].description);
1368    }
1369
1370    #[test]
1371    fn roundtrip_preserves_security() {
1372        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1373        let json = serde_json::to_string(&spec).unwrap();
1374        let roundtrip: OpenApiSpec = serde_json::from_str(&json).unwrap();
1375        assert_eq!(spec.security, roundtrip.security);
1376    }
1377
1378    // ── Complex schema compositions ──────────────────────────────
1379
1380    #[test]
1381    fn parse_deeply_nested_schema() {
1382        let yaml = r#"
1383info:
1384  title: Nested Test
1385  version: "1.0.0"
1386paths: {}
1387components:
1388  schemas:
1389    Outer:
1390      type: object
1391      properties:
1392        inner:
1393          type: object
1394          properties:
1395            deep:
1396              type: array
1397              items:
1398                type: string
1399"#;
1400        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1401        let outer = &spec.components.as_ref().unwrap().schemas["Outer"];
1402        let inner = &outer.properties["inner"];
1403        assert!(inner.is_object());
1404        let deep = &inner.properties["deep"];
1405        assert!(deep.is_array());
1406        let items = deep.items.as_ref().unwrap();
1407        assert!(items.is_primitive());
1408        assert_eq!(items.schema_type.as_deref(), Some("string"));
1409    }
1410
1411    #[test]
1412    fn parse_all_of_with_override_properties() {
1413        let yaml = r##"
1414info:
1415  title: AllOf Override
1416  version: "1.0.0"
1417paths: {}
1418components:
1419  schemas:
1420    Base:
1421      type: object
1422      properties:
1423        id:
1424          type: integer
1425    Extended:
1426      allOf:
1427        - $ref: "#/components/schemas/Base"
1428        - type: object
1429          required:
1430            - name
1431          properties:
1432            name:
1433              type: string
1434            role:
1435              type: string
1436              enum:
1437                - admin
1438                - user
1439"##;
1440        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1441        let extended = &spec.components.as_ref().unwrap().schemas["Extended"];
1442        assert_eq!(extended.all_of.len(), 2);
1443        let override_part = &extended.all_of[1];
1444        assert_eq!(override_part.required, vec!["name"]);
1445        assert!(override_part.properties["role"].is_enum());
1446    }
1447
1448    // ── Ref resolution edge cases ────────────────────────────────
1449
1450    #[test]
1451    fn resolve_schema_ref_chain() {
1452        let yaml = r##"
1453info:
1454  title: Ref Chain
1455  version: "1.0.0"
1456paths: {}
1457components:
1458  schemas:
1459    Alias:
1460      $ref: "#/components/schemas/Real"
1461    Real:
1462      type: string
1463"##;
1464        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1465        let alias = spec.resolve_schema_ref("#/components/schemas/Alias").unwrap();
1466        assert!(alias.is_ref());
1467        let real = spec.resolve_schema_ref("#/components/schemas/Real").unwrap();
1468        assert!(real.is_primitive());
1469    }
1470
1471    #[test]
1472    fn resolve_refs_with_empty_components() {
1473        let yaml = r#"
1474info:
1475  title: Empty Components
1476  version: "1.0.0"
1477paths: {}
1478components:
1479  schemas: {}
1480"#;
1481        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1482        assert!(spec.resolve_schema_ref("#/components/schemas/Missing").is_none());
1483        assert!(spec.resolve_parameter_ref("#/components/parameters/Missing").is_none());
1484        assert!(spec.resolve_request_body_ref("#/components/requestBodies/Missing").is_none());
1485        assert!(spec.resolve_response_ref("#/components/responses/Missing").is_none());
1486    }
1487
1488    // ── Operation with operation-level security ──────────────────
1489
1490    #[test]
1491    fn parse_operation_level_security() {
1492        let yaml = r#"
1493info:
1494  title: OpSecurity
1495  version: "1.0.0"
1496paths:
1497  /secure:
1498    get:
1499      operationId: secureEndpoint
1500      security:
1501        - apiKey: []
1502        - oauth2:
1503            - read
1504            - write
1505      responses:
1506        "200":
1507          description: OK
1508"#;
1509        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1510        let op = spec.paths["/secure"].get.as_ref().unwrap();
1511        assert_eq!(op.security.len(), 2);
1512        assert!(op.security[0].contains_key("apiKey"));
1513        let oauth_scopes = &op.security[1]["oauth2"];
1514        assert_eq!(oauth_scopes, &vec!["read".to_string(), "write".to_string()]);
1515    }
1516
1517    // ── Multiple content types ───────────────────────────────────
1518
1519    #[test]
1520    fn parse_multiple_content_types() {
1521        let yaml = r#"
1522info:
1523  title: MultiContent
1524  version: "1.0.0"
1525paths:
1526  /upload:
1527    post:
1528      operationId: upload
1529      requestBody:
1530        required: true
1531        content:
1532          application/json:
1533            schema:
1534              type: object
1535          multipart/form-data:
1536            schema:
1537              type: object
1538      responses:
1539        "200":
1540          description: OK
1541"#;
1542        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1543        let body = spec.paths["/upload"]
1544            .post
1545            .as_ref()
1546            .unwrap()
1547            .request_body
1548            .as_ref()
1549            .unwrap();
1550        assert_eq!(body.content.len(), 2);
1551        assert!(body.content.contains_key("application/json"));
1552        assert!(body.content.contains_key("multipart/form-data"));
1553    }
1554
1555    // ── Operation with no operationId ────────────────────────────
1556
1557    #[test]
1558    fn parse_operation_without_operation_id() {
1559        let yaml = r#"
1560info:
1561  title: NoOpId
1562  version: "1.0.0"
1563paths:
1564  /items:
1565    get:
1566      responses:
1567        "200":
1568          description: OK
1569"#;
1570        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1571        let op = spec.paths["/items"].get.as_ref().unwrap();
1572        assert!(op.operation_id.is_none());
1573        assert!(op.summary.is_none());
1574        assert!(op.description.is_none());
1575        assert!(op.tags.is_empty());
1576        assert!(op.parameters.is_empty());
1577        assert!(op.request_body.is_none());
1578        assert!(op.security.is_empty());
1579    }
1580
1581    // ── all_operations with put and patch ────────────────────────
1582
1583    #[test]
1584    fn all_operations_includes_put_and_patch() {
1585        let yaml = r#"
1586info:
1587  title: PutPatch
1588  version: "1.0.0"
1589paths:
1590  /items/{id}:
1591    put:
1592      operationId: replaceItem
1593      responses:
1594        "200":
1595          description: OK
1596    patch:
1597      operationId: updateItem
1598      responses:
1599        "200":
1600          description: OK
1601"#;
1602        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1603        let ops = all_operations(&spec);
1604        assert_eq!(ops.len(), 2);
1605        let methods: Vec<&str> = ops.iter().map(|(m, _, _)| m.as_str()).collect();
1606        assert!(methods.contains(&"put"));
1607        assert!(methods.contains(&"patch"));
1608    }
1609
1610    // ── Schema with multiple numeric constraints ─────────────────
1611
1612    #[test]
1613    fn parse_schema_with_all_constraints() {
1614        let yaml = r#"
1615info:
1616  title: Constraints
1617  version: "1.0.0"
1618paths: {}
1619components:
1620  schemas:
1621    ConstrainedString:
1622      type: string
1623      minLength: 1
1624      maxLength: 100
1625      default: "hello"
1626      title: A constrained string
1627      description: Must be between 1 and 100 chars
1628      nullable: true
1629"#;
1630        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1631        let schema = &spec.components.as_ref().unwrap().schemas["ConstrainedString"];
1632        assert_eq!(schema.min_length, Some(1));
1633        assert_eq!(schema.max_length, Some(100));
1634        assert_eq!(
1635            schema.default.as_ref().and_then(|v| v.as_str()),
1636            Some("hello")
1637        );
1638        assert_eq!(schema.title.as_deref(), Some("A constrained string"));
1639        assert_eq!(
1640            schema.description.as_deref(),
1641            Some("Must be between 1 and 100 chars")
1642        );
1643        assert!(schema.nullable);
1644        assert!(schema.is_primitive());
1645    }
1646
1647    // ── ref_name edge cases ──────────────────────────────────────
1648
1649    #[test]
1650    fn ref_name_empty_string() {
1651        assert_eq!(ref_name(""), "");
1652    }
1653
1654    #[test]
1655    fn ref_name_single_slash() {
1656        assert_eq!(ref_name("/"), "");
1657    }
1658
1659    #[test]
1660    fn ref_name_multiple_trailing_slashes() {
1661        assert_eq!(ref_name("a/b/c/"), "");
1662    }
1663
1664    // ── Parameter with $ref ──────────────────────────────────────
1665
1666    #[test]
1667    fn parameter_ref_path_field_deserialized() {
1668        let json = r##"{
1669            "name": "limit",
1670            "in": "query",
1671            "$ref": "#/components/parameters/LimitParam"
1672        }"##;
1673        let param: Parameter = serde_json::from_str(json).unwrap();
1674        assert_eq!(
1675            param.ref_path.as_deref(),
1676            Some("#/components/parameters/LimitParam")
1677        );
1678        assert_eq!(param.name, "limit");
1679    }
1680
1681    // ── Request body with $ref ───────────────────────────────────
1682
1683    #[test]
1684    fn parse_request_body_with_ref() {
1685        let yaml = r##"
1686info:
1687  title: BodyRef
1688  version: "1.0.0"
1689paths:
1690  /items:
1691    post:
1692      requestBody:
1693        $ref: "#/components/requestBodies/ItemBody"
1694      responses:
1695        "201":
1696          description: Created
1697components:
1698  requestBodies:
1699    ItemBody:
1700      required: true
1701      content:
1702        application/json:
1703          schema:
1704            type: object
1705"##;
1706        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1707        let body = spec.paths["/items"]
1708            .post
1709            .as_ref()
1710            .unwrap()
1711            .request_body
1712            .as_ref()
1713            .unwrap();
1714        assert_eq!(
1715            body.ref_path.as_deref(),
1716            Some("#/components/requestBodies/ItemBody")
1717        );
1718    }
1719
1720    // ── Response with $ref ───────────────────────────────────────
1721
1722    #[test]
1723    fn parse_response_with_ref() {
1724        let yaml = r##"
1725info:
1726  title: RespRef
1727  version: "1.0.0"
1728paths:
1729  /items:
1730    get:
1731      responses:
1732        "404":
1733          $ref: "#/components/responses/NotFound"
1734components:
1735  responses:
1736    NotFound:
1737      description: Not found
1738"##;
1739        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1740        let resp = &spec.paths["/items"].get.as_ref().unwrap().responses["404"];
1741        assert_eq!(
1742            resp.ref_path.as_deref(),
1743            Some("#/components/responses/NotFound")
1744        );
1745    }
1746
1747    // ── PartialEq for security requirements ──────────────────────
1748
1749    #[test]
1750    fn multiple_servers_parsed() {
1751        let yaml = r#"
1752info:
1753  title: MultiServer
1754  version: "1.0.0"
1755paths: {}
1756servers:
1757  - url: https://prod.example.com
1758    description: Production
1759  - url: https://staging.example.com
1760    description: Staging
1761  - url: http://localhost:8080
1762"#;
1763        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1764        assert_eq!(spec.servers.len(), 3);
1765        assert_eq!(spec.servers[0].url, "https://prod.example.com");
1766        assert_eq!(spec.servers[2].url, "http://localhost:8080");
1767        assert!(spec.servers[2].description.is_none());
1768    }
1769
1770    // ── Info description ─────────────────────────────────────────
1771
1772    #[test]
1773    fn info_without_description() {
1774        let spec: OpenApiSpec = serde_yaml_ng::from_str(MINIMAL_SPEC_YAML).unwrap();
1775        assert!(spec.info.description.is_none());
1776    }
1777
1778    // ── Schema with additional_properties box ────────────────────
1779
1780    #[test]
1781    fn parse_schema_additional_properties_nested() {
1782        let yaml = r#"
1783info:
1784  title: NestedAdditional
1785  version: "1.0.0"
1786paths: {}
1787components:
1788  schemas:
1789    Map:
1790      type: object
1791      additionalProperties:
1792        type: array
1793        items:
1794          type: integer
1795"#;
1796        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1797        let map = &spec.components.as_ref().unwrap().schemas["Map"];
1798        let ap = map.additional_properties.as_ref().unwrap();
1799        assert!(ap.is_array());
1800        let items = ap.items.as_ref().unwrap();
1801        assert_eq!(items.schema_type.as_deref(), Some("integer"));
1802    }
1803
1804    // ── Operation json_body_schema with non-JSON content ─────────
1805
1806    #[test]
1807    fn json_body_schema_none_when_xml_only() {
1808        let yaml = r#"
1809info:
1810  title: XmlBody
1811  version: "1.0.0"
1812paths:
1813  /items:
1814    post:
1815      requestBody:
1816        content:
1817          application/xml:
1818            schema:
1819              type: object
1820      responses:
1821        "201":
1822          description: Created
1823"#;
1824        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1825        let op = spec.paths["/items"].post.as_ref().unwrap();
1826        assert!(op.json_body_schema().is_none());
1827    }
1828
1829    // ── Success response with non-JSON content ───────────────────
1830
1831    #[test]
1832    fn success_response_schema_none_when_no_json() {
1833        let yaml = r#"
1834info:
1835  title: NoJsonResp
1836  version: "1.0.0"
1837paths:
1838  /items:
1839    get:
1840      responses:
1841        "200":
1842          description: OK
1843          content:
1844            text/plain:
1845              schema:
1846                type: string
1847"#;
1848        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1849        let op = spec.paths["/items"].get.as_ref().unwrap();
1850        assert!(op.success_response_schema().is_none());
1851    }
1852
1853    #[test]
1854    fn success_response_schema_none_when_only_404() {
1855        let yaml = r#"
1856info:
1857  title: Only404
1858  version: "1.0.0"
1859paths:
1860  /items:
1861    get:
1862      responses:
1863        "404":
1864          description: Not found
1865"#;
1866        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
1867        let op = spec.paths["/items"].get.as_ref().unwrap();
1868        assert!(op.success_response_schema().is_none());
1869    }
1870
1871    // ── PathItem::operations() tests ────────────────────────────
1872
1873    // ── Display and PartialEq tests ─────────────────────────────
1874
1875    #[test]
1876    fn spec_display_format() {
1877        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1878        let display = spec.to_string();
1879        assert_eq!(display, "OpenAPI spec: Pet Store v2.0.0 (2 paths, 4 operations)");
1880    }
1881
1882    #[test]
1883    fn spec_display_minimal() {
1884        let spec: OpenApiSpec = serde_yaml_ng::from_str(MINIMAL_SPEC_YAML).unwrap();
1885        let display = spec.to_string();
1886        assert_eq!(display, "OpenAPI spec: Test API v1.0.0 (0 paths, 0 operations)");
1887    }
1888
1889    #[test]
1890    fn info_display_format() {
1891        let info = Info {
1892            title: "Pet Store".to_string(),
1893            description: None,
1894            version: "2.0.0".to_string(),
1895        };
1896        assert_eq!(info.to_string(), "Pet Store v2.0.0");
1897    }
1898
1899    #[test]
1900    fn info_partial_eq() {
1901        let a = Info {
1902            title: "API".to_string(),
1903            description: None,
1904            version: "1.0".to_string(),
1905        };
1906        let b = a.clone();
1907        assert_eq!(a, b);
1908    }
1909
1910    #[test]
1911    fn server_partial_eq() {
1912        let a = Server {
1913            url: "https://example.com".to_string(),
1914            description: Some("prod".to_string()),
1915        };
1916        let b = a.clone();
1917        assert_eq!(a, b);
1918    }
1919
1920    // ── Default trait tests ──────────────────────────────────────
1921
1922    #[test]
1923    fn path_item_default_has_no_operations() {
1924        let item = PathItem::default();
1925        assert!(item.get.is_none());
1926        assert!(item.post.is_none());
1927        assert!(item.put.is_none());
1928        assert!(item.delete.is_none());
1929        assert!(item.patch.is_none());
1930        assert!(item.parameters.is_empty());
1931        assert_eq!(item.operations().count(), 0);
1932    }
1933
1934    #[test]
1935    fn operation_default_has_empty_fields() {
1936        let op = Operation::default();
1937        assert!(op.operation_id.is_none());
1938        assert!(op.summary.is_none());
1939        assert!(op.description.is_none());
1940        assert!(op.parameters.is_empty());
1941        assert!(op.request_body.is_none());
1942        assert!(op.responses.is_empty());
1943        assert!(op.security.is_empty());
1944        assert!(op.tags.is_empty());
1945    }
1946
1947    #[test]
1948    fn request_body_default_is_empty() {
1949        let body = RequestBody::default();
1950        assert!(!body.required);
1951        assert!(body.content.is_empty());
1952        assert!(body.description.is_none());
1953        assert!(body.ref_path.is_none());
1954    }
1955
1956    #[test]
1957    fn media_type_default_has_no_schema() {
1958        let mt = MediaType::default();
1959        assert!(mt.schema.is_none());
1960    }
1961
1962    #[test]
1963    fn response_default_is_empty() {
1964        let resp = Response::default();
1965        assert!(resp.description.is_none());
1966        assert!(resp.content.is_none());
1967        assert!(resp.ref_path.is_none());
1968    }
1969
1970    #[test]
1971    fn spec_all_operations_iterator() {
1972        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1973        let ops: Vec<_> = spec.all_operations().collect();
1974        assert_eq!(ops.len(), 4);
1975        let methods_and_paths: Vec<(&str, &str)> =
1976            ops.iter().map(|(m, p, _)| (m.as_str(), p.as_str())).collect();
1977        assert!(methods_and_paths.contains(&("get", "/pets")));
1978        assert!(methods_and_paths.contains(&("post", "/pets")));
1979        assert!(methods_and_paths.contains(&("get", "/pets/{petId}")));
1980        assert!(methods_and_paths.contains(&("delete", "/pets/{petId}")));
1981    }
1982
1983    #[test]
1984    fn spec_all_operations_empty() {
1985        let spec: OpenApiSpec = serde_yaml_ng::from_str(MINIMAL_SPEC_YAML).unwrap();
1986        assert_eq!(spec.all_operations().count(), 0);
1987    }
1988
1989    #[test]
1990    fn spec_schema_count() {
1991        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
1992        assert_eq!(spec.schema_count(), 3);
1993    }
1994
1995    #[test]
1996    fn spec_schema_count_no_components() {
1997        let spec: OpenApiSpec = serde_yaml_ng::from_str(MINIMAL_SPEC_YAML).unwrap();
1998        assert_eq!(spec.schema_count(), 0);
1999    }
2000
2001    #[test]
2002    fn spec_operation_count() {
2003        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
2004        assert_eq!(spec.operation_count(), 4);
2005    }
2006
2007    #[test]
2008    fn spec_operation_count_empty() {
2009        let spec: OpenApiSpec = serde_yaml_ng::from_str(MINIMAL_SPEC_YAML).unwrap();
2010        assert_eq!(spec.operation_count(), 0);
2011    }
2012
2013    #[test]
2014    fn path_item_operations_returns_defined_methods() {
2015        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
2016        let pets = &spec.paths["/pets"];
2017        let methods: Vec<&str> = pets.operations().map(|(m, _)| m).collect();
2018        assert_eq!(methods, vec!["get", "post"]);
2019    }
2020
2021    #[test]
2022    fn path_item_operations_empty_when_no_methods() {
2023        let item = PathItem {
2024            get: None,
2025            post: None,
2026            put: None,
2027            delete: None,
2028            patch: None,
2029            parameters: vec![],
2030        };
2031        assert_eq!(item.operations().count(), 0);
2032    }
2033
2034    #[test]
2035    fn path_item_operations_all_five_methods() {
2036        let yaml = r#"
2037info:
2038  title: AllMethods
2039  version: "1.0.0"
2040paths:
2041  /resource:
2042    get:
2043      responses:
2044        "200":
2045          description: OK
2046    post:
2047      responses:
2048        "201":
2049          description: Created
2050    put:
2051      responses:
2052        "200":
2053          description: OK
2054    delete:
2055      responses:
2056        "204":
2057          description: Deleted
2058    patch:
2059      responses:
2060        "200":
2061          description: OK
2062"#;
2063        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2064        let item = &spec.paths["/resource"];
2065        let methods: Vec<&str> = item.operations().map(|(m, _)| m).collect();
2066        assert_eq!(methods, vec!["get", "post", "put", "delete", "patch"]);
2067    }
2068
2069    // ── Schema construction and serde roundtrip ─────────────────
2070
2071    #[test]
2072    fn schema_programmatic_construction_roundtrip_string() {
2073        let schema = Schema {
2074            schema_type: Some("string".to_string()),
2075            format: Some("date-time".to_string()),
2076            description: Some("A timestamp".to_string()),
2077            min_length: Some(1),
2078            max_length: Some(64),
2079            ..Default::default()
2080        };
2081        let json = serde_json::to_string(&schema).unwrap();
2082        let roundtrip: Schema = serde_json::from_str(&json).unwrap();
2083        assert_eq!(roundtrip.schema_type.as_deref(), Some("string"));
2084        assert_eq!(roundtrip.format.as_deref(), Some("date-time"));
2085        assert_eq!(roundtrip.description.as_deref(), Some("A timestamp"));
2086        assert_eq!(roundtrip.min_length, Some(1));
2087        assert_eq!(roundtrip.max_length, Some(64));
2088    }
2089
2090    #[test]
2091    fn schema_programmatic_construction_roundtrip_integer() {
2092        let schema = Schema {
2093            schema_type: Some("integer".to_string()),
2094            format: Some("int32".to_string()),
2095            minimum: Some(0.0),
2096            maximum: Some(999.0),
2097            default: Some(serde_json::Value::Number(42.into())),
2098            ..Default::default()
2099        };
2100        let json = serde_json::to_string(&schema).unwrap();
2101        let roundtrip: Schema = serde_json::from_str(&json).unwrap();
2102        assert_eq!(roundtrip.schema_type.as_deref(), Some("integer"));
2103        assert_eq!(roundtrip.format.as_deref(), Some("int32"));
2104        assert_eq!(roundtrip.minimum, Some(0.0));
2105        assert_eq!(roundtrip.maximum, Some(999.0));
2106        assert_eq!(roundtrip.default.unwrap().as_i64(), Some(42));
2107    }
2108
2109    #[test]
2110    fn schema_programmatic_construction_roundtrip_number() {
2111        let schema = Schema {
2112            schema_type: Some("number".to_string()),
2113            format: Some("double".to_string()),
2114            ..Default::default()
2115        };
2116        let json = serde_json::to_string(&schema).unwrap();
2117        let roundtrip: Schema = serde_json::from_str(&json).unwrap();
2118        assert_eq!(roundtrip.schema_type.as_deref(), Some("number"));
2119        assert_eq!(roundtrip.format.as_deref(), Some("double"));
2120    }
2121
2122    #[test]
2123    fn schema_programmatic_construction_roundtrip_boolean() {
2124        let schema = Schema {
2125            schema_type: Some("boolean".to_string()),
2126            default: Some(serde_json::Value::Bool(true)),
2127            ..Default::default()
2128        };
2129        let json = serde_json::to_string(&schema).unwrap();
2130        let roundtrip: Schema = serde_json::from_str(&json).unwrap();
2131        assert_eq!(roundtrip.schema_type.as_deref(), Some("boolean"));
2132        assert_eq!(roundtrip.default.unwrap().as_bool(), Some(true));
2133    }
2134
2135    #[test]
2136    fn schema_programmatic_construction_roundtrip_array_with_ref_items() {
2137        let schema = Schema {
2138            schema_type: Some("array".to_string()),
2139            items: Some(Box::new(Schema {
2140                ref_path: Some("#/components/schemas/Item".to_string()),
2141                ..Default::default()
2142            })),
2143            ..Default::default()
2144        };
2145        let json = serde_json::to_string(&schema).unwrap();
2146        let roundtrip: Schema = serde_json::from_str(&json).unwrap();
2147        assert!(roundtrip.is_array());
2148        let items = roundtrip.items.as_ref().unwrap();
2149        assert!(items.is_ref());
2150        assert_eq!(items.ref_name(), Some("Item"));
2151    }
2152
2153    #[test]
2154    fn schema_programmatic_construction_roundtrip_object_with_properties() {
2155        let mut props = BTreeMap::new();
2156        props.insert(
2157            "name".to_string(),
2158            Schema {
2159                schema_type: Some("string".to_string()),
2160                ..Default::default()
2161            },
2162        );
2163        props.insert(
2164            "age".to_string(),
2165            Schema {
2166                schema_type: Some("integer".to_string()),
2167                ..Default::default()
2168            },
2169        );
2170        let schema = Schema {
2171            schema_type: Some("object".to_string()),
2172            properties: props,
2173            required: vec!["name".to_string()],
2174            ..Default::default()
2175        };
2176        let json = serde_json::to_string(&schema).unwrap();
2177        let roundtrip: Schema = serde_json::from_str(&json).unwrap();
2178        assert!(roundtrip.is_object());
2179        assert_eq!(roundtrip.properties.len(), 2);
2180        assert_eq!(roundtrip.required, vec!["name"]);
2181        assert_eq!(
2182            roundtrip.properties["name"].schema_type.as_deref(),
2183            Some("string")
2184        );
2185        assert_eq!(
2186            roundtrip.properties["age"].schema_type.as_deref(),
2187            Some("integer")
2188        );
2189    }
2190
2191    #[test]
2192    fn schema_programmatic_construction_roundtrip_nullable() {
2193        let schema = Schema {
2194            schema_type: Some("string".to_string()),
2195            nullable: true,
2196            ..Default::default()
2197        };
2198        let json = serde_json::to_string(&schema).unwrap();
2199        let roundtrip: Schema = serde_json::from_str(&json).unwrap();
2200        assert!(roundtrip.nullable);
2201    }
2202
2203    #[test]
2204    fn schema_programmatic_construction_roundtrip_enum() {
2205        let schema = Schema {
2206            schema_type: Some("string".to_string()),
2207            enum_values: Some(vec![
2208                serde_json::Value::String("active".to_string()),
2209                serde_json::Value::String("inactive".to_string()),
2210                serde_json::Value::String("pending".to_string()),
2211            ]),
2212            ..Default::default()
2213        };
2214        let json = serde_json::to_string(&schema).unwrap();
2215        let roundtrip: Schema = serde_json::from_str(&json).unwrap();
2216        assert!(roundtrip.is_enum());
2217        let values = roundtrip.enum_values.unwrap();
2218        assert_eq!(values.len(), 3);
2219        assert_eq!(values[0].as_str(), Some("active"));
2220    }
2221
2222    #[test]
2223    fn schema_programmatic_construction_roundtrip_composition() {
2224        let schema = Schema {
2225            all_of: vec![
2226                Schema {
2227                    ref_path: Some("#/components/schemas/Base".to_string()),
2228                    ..Default::default()
2229                },
2230                Schema {
2231                    schema_type: Some("object".to_string()),
2232                    properties: {
2233                        let mut p = BTreeMap::new();
2234                        p.insert(
2235                            "extra".to_string(),
2236                            Schema {
2237                                schema_type: Some("string".to_string()),
2238                                ..Default::default()
2239                            },
2240                        );
2241                        p
2242                    },
2243                    ..Default::default()
2244                },
2245            ],
2246            ..Default::default()
2247        };
2248        let json = serde_json::to_string(&schema).unwrap();
2249        let roundtrip: Schema = serde_json::from_str(&json).unwrap();
2250        assert!(roundtrip.is_composed());
2251        assert_eq!(roundtrip.all_of.len(), 2);
2252        assert!(roundtrip.all_of[0].is_ref());
2253        assert!(roundtrip.all_of[1].is_object());
2254    }
2255
2256    #[test]
2257    fn schema_programmatic_construction_roundtrip_additional_properties() {
2258        let schema = Schema {
2259            schema_type: Some("object".to_string()),
2260            additional_properties: Some(Box::new(Schema {
2261                schema_type: Some("integer".to_string()),
2262                ..Default::default()
2263            })),
2264            ..Default::default()
2265        };
2266        let json = serde_json::to_string(&schema).unwrap();
2267        let roundtrip: Schema = serde_json::from_str(&json).unwrap();
2268        let ap = roundtrip.additional_properties.as_ref().unwrap();
2269        assert_eq!(ap.schema_type.as_deref(), Some("integer"));
2270    }
2271
2272    // ── Header and cookie parameter types ───────────────────────
2273
2274    #[test]
2275    fn parse_header_parameter() {
2276        let yaml = r#"
2277info:
2278  title: HeaderParam
2279  version: "1.0.0"
2280paths:
2281  /items:
2282    get:
2283      parameters:
2284        - name: X-Request-ID
2285          in: header
2286          required: true
2287          description: Unique request identifier
2288          schema:
2289            type: string
2290            format: uuid
2291      responses:
2292        "200":
2293          description: OK
2294"#;
2295        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2296        let param = &spec.paths["/items"].get.as_ref().unwrap().parameters[0];
2297        assert_eq!(param.name, "X-Request-ID");
2298        assert_eq!(param.location, "header");
2299        assert!(param.required);
2300        assert_eq!(
2301            param.description.as_deref(),
2302            Some("Unique request identifier")
2303        );
2304        let schema = param.schema.as_ref().unwrap();
2305        assert_eq!(schema.schema_type.as_deref(), Some("string"));
2306        assert_eq!(schema.format.as_deref(), Some("uuid"));
2307    }
2308
2309    #[test]
2310    fn parse_cookie_parameter() {
2311        let yaml = r#"
2312info:
2313  title: CookieParam
2314  version: "1.0.0"
2315paths:
2316  /items:
2317    get:
2318      parameters:
2319        - name: session_id
2320          in: cookie
2321          required: false
2322          description: Session cookie
2323          schema:
2324            type: string
2325      responses:
2326        "200":
2327          description: OK
2328"#;
2329        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2330        let param = &spec.paths["/items"].get.as_ref().unwrap().parameters[0];
2331        assert_eq!(param.name, "session_id");
2332        assert_eq!(param.location, "cookie");
2333        assert!(!param.required);
2334        assert_eq!(param.description.as_deref(), Some("Session cookie"));
2335    }
2336
2337    #[test]
2338    fn parse_all_four_parameter_locations() {
2339        let yaml = r#"
2340info:
2341  title: AllParamLocations
2342  version: "1.0.0"
2343paths:
2344  /items/{id}:
2345    get:
2346      parameters:
2347        - name: id
2348          in: path
2349          required: true
2350          schema:
2351            type: string
2352        - name: filter
2353          in: query
2354          schema:
2355            type: string
2356        - name: Authorization
2357          in: header
2358          schema:
2359            type: string
2360        - name: tracking
2361          in: cookie
2362          schema:
2363            type: string
2364      responses:
2365        "200":
2366          description: OK
2367"#;
2368        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2369        let params = &spec.paths["/items/{id}"].get.as_ref().unwrap().parameters;
2370        assert_eq!(params.len(), 4);
2371        let locations: Vec<&str> = params.iter().map(|p| p.location.as_str()).collect();
2372        assert_eq!(locations, vec!["path", "query", "header", "cookie"]);
2373    }
2374
2375    // ── Schema format variants ──────────────────────────────────
2376
2377    #[test]
2378    fn parse_schema_format_int32() {
2379        let yaml = r#"
2380info:
2381  title: Formats
2382  version: "1.0.0"
2383paths: {}
2384components:
2385  schemas:
2386    Int32Field:
2387      type: integer
2388      format: int32
2389"#;
2390        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2391        let schema = &spec.components.as_ref().unwrap().schemas["Int32Field"];
2392        assert_eq!(schema.format.as_deref(), Some("int32"));
2393    }
2394
2395    #[test]
2396    fn parse_schema_format_float() {
2397        let yaml = r#"
2398info:
2399  title: Formats
2400  version: "1.0.0"
2401paths: {}
2402components:
2403  schemas:
2404    FloatField:
2405      type: number
2406      format: float
2407"#;
2408        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2409        let schema = &spec.components.as_ref().unwrap().schemas["FloatField"];
2410        assert_eq!(schema.format.as_deref(), Some("float"));
2411    }
2412
2413    #[test]
2414    fn parse_schema_format_date_and_datetime() {
2415        let yaml = r#"
2416info:
2417  title: Formats
2418  version: "1.0.0"
2419paths: {}
2420components:
2421  schemas:
2422    DateField:
2423      type: string
2424      format: date
2425    DateTimeField:
2426      type: string
2427      format: date-time
2428"#;
2429        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2430        let components = spec.components.as_ref().unwrap();
2431        assert_eq!(
2432            components.schemas["DateField"].format.as_deref(),
2433            Some("date")
2434        );
2435        assert_eq!(
2436            components.schemas["DateTimeField"].format.as_deref(),
2437            Some("date-time")
2438        );
2439    }
2440
2441    #[test]
2442    fn parse_schema_format_byte_and_binary() {
2443        let yaml = r#"
2444info:
2445  title: Formats
2446  version: "1.0.0"
2447paths: {}
2448components:
2449  schemas:
2450    ByteField:
2451      type: string
2452      format: byte
2453    BinaryField:
2454      type: string
2455      format: binary
2456"#;
2457        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2458        let components = spec.components.as_ref().unwrap();
2459        assert_eq!(
2460            components.schemas["ByteField"].format.as_deref(),
2461            Some("byte")
2462        );
2463        assert_eq!(
2464            components.schemas["BinaryField"].format.as_deref(),
2465            Some("binary")
2466        );
2467    }
2468
2469    #[test]
2470    fn parse_schema_format_password() {
2471        let yaml = r#"
2472info:
2473  title: Formats
2474  version: "1.0.0"
2475paths: {}
2476components:
2477  schemas:
2478    PasswordField:
2479      type: string
2480      format: password
2481"#;
2482        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2483        let schema = &spec.components.as_ref().unwrap().schemas["PasswordField"];
2484        assert_eq!(schema.format.as_deref(), Some("password"));
2485    }
2486
2487    // ── Ref resolution edge cases ───────────────────────────────
2488
2489    #[test]
2490    fn ref_name_extracts_from_various_component_types() {
2491        assert_eq!(ref_name("#/components/schemas/User"), "User");
2492        assert_eq!(ref_name("#/components/parameters/PageSize"), "PageSize");
2493        assert_eq!(
2494            ref_name("#/components/requestBodies/CreateUser"),
2495            "CreateUser"
2496        );
2497        assert_eq!(ref_name("#/components/responses/ErrorResponse"), "ErrorResponse");
2498        assert_eq!(
2499            ref_name("#/components/securitySchemes/BearerAuth"),
2500            "BearerAuth"
2501        );
2502    }
2503
2504    #[test]
2505    fn schema_ref_name_returns_none_for_non_ref() {
2506        let s = Schema {
2507            schema_type: Some("string".to_string()),
2508            ..Default::default()
2509        };
2510        assert!(!s.is_ref());
2511        assert_eq!(s.ref_name(), None);
2512    }
2513
2514    #[test]
2515    fn resolve_schema_ref_follows_simple_pointer() {
2516        let yaml = r##"
2517info:
2518  title: RefResolve
2519  version: "1.0.0"
2520paths: {}
2521components:
2522  schemas:
2523    Address:
2524      type: object
2525      properties:
2526        street:
2527          type: string
2528        city:
2529          type: string
2530      required:
2531        - street
2532        - city
2533"##;
2534        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2535        let address = spec
2536            .resolve_schema_ref("#/components/schemas/Address")
2537            .unwrap();
2538        assert!(address.is_object());
2539        assert_eq!(address.properties.len(), 2);
2540        assert_eq!(address.required, vec!["street", "city"]);
2541    }
2542
2543    // ── Operation with description ──────────────────────────────
2544
2545    #[test]
2546    fn parse_operation_with_description() {
2547        let yaml = r#"
2548info:
2549  title: DescTest
2550  version: "1.0.0"
2551paths:
2552  /items:
2553    get:
2554      operationId: listItems
2555      summary: List items
2556      description: Returns a paginated list of all items in the system
2557      responses:
2558        "200":
2559          description: OK
2560"#;
2561        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2562        let op = spec.paths["/items"].get.as_ref().unwrap();
2563        assert_eq!(
2564            op.description.as_deref(),
2565            Some("Returns a paginated list of all items in the system")
2566        );
2567    }
2568
2569    // ── RequestBody with description ────────────────────────────
2570
2571    #[test]
2572    fn parse_request_body_with_description() {
2573        let yaml = r#"
2574info:
2575  title: BodyDesc
2576  version: "1.0.0"
2577paths:
2578  /items:
2579    post:
2580      requestBody:
2581        description: The item to create
2582        required: true
2583        content:
2584          application/json:
2585            schema:
2586              type: object
2587      responses:
2588        "201":
2589          description: Created
2590"#;
2591        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2592        let body = spec.paths["/items"]
2593            .post
2594            .as_ref()
2595            .unwrap()
2596            .request_body
2597            .as_ref()
2598            .unwrap();
2599        assert_eq!(body.description.as_deref(), Some("The item to create"));
2600    }
2601
2602    // ── Full spec roundtrip preserves all fields ────────────────
2603
2604    #[test]
2605    fn roundtrip_preserves_enum_values() {
2606        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
2607        let json = serde_json::to_string(&spec).unwrap();
2608        let roundtrip: OpenApiSpec = serde_json::from_str(&json).unwrap();
2609        let status = &roundtrip.components.as_ref().unwrap().schemas["PetStatus"];
2610        assert!(status.is_enum());
2611        let values = status.enum_values.as_ref().unwrap();
2612        assert_eq!(values.len(), 3);
2613        assert_eq!(values[0].as_str(), Some("available"));
2614        assert_eq!(values[1].as_str(), Some("pending"));
2615        assert_eq!(values[2].as_str(), Some("sold"));
2616    }
2617
2618    #[test]
2619    fn roundtrip_preserves_parameter_details() {
2620        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
2621        let json = serde_json::to_string(&spec).unwrap();
2622        let roundtrip: OpenApiSpec = serde_json::from_str(&json).unwrap();
2623        let param = &roundtrip.paths["/pets"].get.as_ref().unwrap().parameters[0];
2624        assert_eq!(param.name, "limit");
2625        assert_eq!(param.location, "query");
2626        assert!(!param.required);
2627        let schema = param.schema.as_ref().unwrap();
2628        assert_eq!(schema.schema_type.as_deref(), Some("integer"));
2629        assert_eq!(schema.format.as_deref(), Some("int64"));
2630    }
2631
2632    #[test]
2633    fn roundtrip_preserves_request_body() {
2634        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
2635        let json = serde_json::to_string(&spec).unwrap();
2636        let roundtrip: OpenApiSpec = serde_json::from_str(&json).unwrap();
2637        let body = roundtrip.paths["/pets"]
2638            .post
2639            .as_ref()
2640            .unwrap()
2641            .request_body
2642            .as_ref()
2643            .unwrap();
2644        assert!(body.required);
2645        assert!(body.content.contains_key("application/json"));
2646    }
2647
2648    #[test]
2649    fn roundtrip_preserves_path_level_parameters() {
2650        let spec: OpenApiSpec = serde_yaml_ng::from_str(FULL_SPEC_YAML).unwrap();
2651        let json = serde_json::to_string(&spec).unwrap();
2652        let roundtrip: OpenApiSpec = serde_json::from_str(&json).unwrap();
2653        let params = &roundtrip.paths["/pets/{petId}"].parameters;
2654        assert_eq!(params.len(), 1);
2655        assert_eq!(params[0].name, "petId");
2656        assert_eq!(params[0].location, "path");
2657        assert!(params[0].required);
2658    }
2659
2660    // ── Complex composition with refs in anyOf ──────────────────
2661
2662    #[test]
2663    fn parse_any_of_with_refs() {
2664        let yaml = r##"
2665info:
2666  title: AnyOfRefs
2667  version: "1.0.0"
2668paths: {}
2669components:
2670  schemas:
2671    Cat:
2672      type: object
2673      properties:
2674        meow:
2675          type: boolean
2676    Dog:
2677      type: object
2678      properties:
2679        bark:
2680          type: boolean
2681    Pet:
2682      anyOf:
2683        - $ref: "#/components/schemas/Cat"
2684        - $ref: "#/components/schemas/Dog"
2685"##;
2686        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2687        let pet = &spec.components.as_ref().unwrap().schemas["Pet"];
2688        assert!(pet.is_composed());
2689        assert_eq!(pet.any_of.len(), 2);
2690        assert!(pet.any_of[0].is_ref());
2691        assert_eq!(pet.any_of[0].ref_name(), Some("Cat"));
2692        assert!(pet.any_of[1].is_ref());
2693        assert_eq!(pet.any_of[1].ref_name(), Some("Dog"));
2694    }
2695
2696    #[test]
2697    fn parse_one_of_with_discriminator_like_pattern() {
2698        let yaml = r##"
2699info:
2700  title: OneOfDiscriminator
2701  version: "1.0.0"
2702paths: {}
2703components:
2704  schemas:
2705    Circle:
2706      type: object
2707      required:
2708        - radius
2709      properties:
2710        shape_type:
2711          type: string
2712        radius:
2713          type: number
2714    Square:
2715      type: object
2716      required:
2717        - side
2718      properties:
2719        shape_type:
2720          type: string
2721        side:
2722          type: number
2723    Shape:
2724      oneOf:
2725        - $ref: "#/components/schemas/Circle"
2726        - $ref: "#/components/schemas/Square"
2727"##;
2728        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2729        let shape = &spec.components.as_ref().unwrap().schemas["Shape"];
2730        assert!(shape.is_composed());
2731        assert_eq!(shape.one_of.len(), 2);
2732        assert_eq!(shape.one_of[0].ref_name(), Some("Circle"));
2733        assert_eq!(shape.one_of[1].ref_name(), Some("Square"));
2734        // Verify the referenced schemas resolve correctly
2735        let circle = spec
2736            .resolve_schema_ref("#/components/schemas/Circle")
2737            .unwrap();
2738        assert!(circle.is_object());
2739        assert_eq!(circle.required, vec!["radius"]);
2740    }
2741
2742    // ── Null/missing field edge cases ───────────────────────────
2743
2744    #[test]
2745    fn schema_with_null_json_values_deserializes() {
2746        let json = r#"{
2747            "type": "object",
2748            "format": null,
2749            "description": null,
2750            "title": null,
2751            "default": null
2752        }"#;
2753        let schema: Schema = serde_json::from_str(json).unwrap();
2754        assert!(schema.is_object());
2755        assert!(schema.format.is_none());
2756        assert!(schema.description.is_none());
2757        assert!(schema.title.is_none());
2758        assert!(schema.default.is_none());
2759    }
2760
2761    #[test]
2762    fn schema_from_empty_json_object() {
2763        let schema: Schema = serde_json::from_str("{}").unwrap();
2764        assert!(schema.schema_type.is_none());
2765        assert!(schema.format.is_none());
2766        assert!(schema.properties.is_empty());
2767        assert!(schema.items.is_none());
2768        assert!(schema.required.is_empty());
2769        assert!(schema.enum_values.is_none());
2770        assert!(schema.ref_path.is_none());
2771        assert!(schema.all_of.is_empty());
2772        assert!(schema.one_of.is_empty());
2773        assert!(schema.any_of.is_empty());
2774        assert!(!schema.nullable);
2775        assert!(schema.additional_properties.is_none());
2776        assert!(schema.title.is_none());
2777    }
2778
2779    #[test]
2780    fn operation_from_minimal_json() {
2781        let json = r#"{"responses":{}}"#;
2782        let op: Operation = serde_json::from_str(json).unwrap();
2783        assert!(op.operation_id.is_none());
2784        assert!(op.summary.is_none());
2785        assert!(op.parameters.is_empty());
2786        assert!(op.tags.is_empty());
2787    }
2788
2789    // ── SecurityScheme apiKey type with cookie location ─────────
2790
2791    #[test]
2792    fn parse_api_key_security_scheme_in_cookie() {
2793        let yaml = r#"
2794info:
2795  title: CookieApiKey
2796  version: "1.0.0"
2797paths: {}
2798components:
2799  securitySchemes:
2800    cookieAuth:
2801      type: apiKey
2802      in: cookie
2803      name: JSESSIONID
2804      description: Session cookie authentication
2805"#;
2806        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2807        let scheme = &spec.components.as_ref().unwrap().security_schemes["cookieAuth"];
2808        assert_eq!(scheme.scheme_type, "apiKey");
2809        assert_eq!(scheme.location.as_deref(), Some("cookie"));
2810        assert_eq!(scheme.name.as_deref(), Some("JSESSIONID"));
2811        assert_eq!(
2812            scheme.description.as_deref(),
2813            Some("Session cookie authentication")
2814        );
2815    }
2816
2817    // ── Spec with deeply nested refs across operations ──────────
2818
2819    #[test]
2820    fn all_operations_with_refs_in_request_and_response() {
2821        let yaml = r##"
2822info:
2823  title: RefOps
2824  version: "1.0.0"
2825paths:
2826  /users:
2827    get:
2828      operationId: listUsers
2829      responses:
2830        "200":
2831          description: OK
2832          content:
2833            application/json:
2834              schema:
2835                type: array
2836                items:
2837                  $ref: "#/components/schemas/User"
2838    post:
2839      operationId: createUser
2840      requestBody:
2841        required: true
2842        content:
2843          application/json:
2844            schema:
2845              $ref: "#/components/schemas/CreateUserRequest"
2846      responses:
2847        "201":
2848          description: Created
2849          content:
2850            application/json:
2851              schema:
2852                $ref: "#/components/schemas/User"
2853components:
2854  schemas:
2855    User:
2856      type: object
2857      properties:
2858        id:
2859          type: integer
2860        name:
2861          type: string
2862    CreateUserRequest:
2863      type: object
2864      required:
2865        - name
2866      properties:
2867        name:
2868          type: string
2869"##;
2870        let spec: OpenApiSpec = serde_yaml_ng::from_str(yaml).unwrap();
2871        let ops: Vec<_> = spec.all_operations().collect();
2872        assert_eq!(ops.len(), 2);
2873
2874        // GET response is array with $ref items
2875        let get_op = ops.iter().find(|(m, _, _)| m == "get").unwrap();
2876        let resp_schema = get_op.2.success_response_schema().unwrap();
2877        assert!(resp_schema.is_array());
2878        let items = resp_schema.items.as_ref().unwrap();
2879        assert!(items.is_ref());
2880        assert_eq!(items.ref_name(), Some("User"));
2881
2882        // POST request body is $ref
2883        let post_op = ops.iter().find(|(m, _, _)| m == "post").unwrap();
2884        let body_schema = post_op.2.json_body_schema().unwrap();
2885        assert!(body_schema.is_ref());
2886        assert_eq!(body_schema.ref_name(), Some("CreateUserRequest"));
2887
2888        // POST response is also $ref
2889        let post_resp = post_op.2.success_response_schema().unwrap();
2890        assert!(post_resp.is_ref());
2891        assert_eq!(post_resp.ref_name(), Some("User"));
2892
2893        // Resolve the refs
2894        let user = spec
2895            .resolve_schema_ref("#/components/schemas/User")
2896            .unwrap();
2897        assert!(user.is_object());
2898        assert_eq!(user.properties.len(), 2);
2899    }
2900
2901    // ── Info PartialEq inequality ───────────────────────────────
2902
2903    #[test]
2904    fn info_partial_eq_different_versions() {
2905        let a = Info {
2906            title: "API".to_string(),
2907            description: None,
2908            version: "1.0".to_string(),
2909        };
2910        let b = Info {
2911            title: "API".to_string(),
2912            description: None,
2913            version: "2.0".to_string(),
2914        };
2915        assert_ne!(a, b);
2916    }
2917
2918    #[test]
2919    fn info_partial_eq_with_description() {
2920        let a = Info {
2921            title: "API".to_string(),
2922            description: Some("desc".to_string()),
2923            version: "1.0".to_string(),
2924        };
2925        let b = Info {
2926            title: "API".to_string(),
2927            description: None,
2928            version: "1.0".to_string(),
2929        };
2930        assert_ne!(a, b);
2931    }
2932}