Skip to main content

chio_openapi/
parser.rs

1//! OpenAPI 3.0 / 3.1 spec parser.
2//!
3//! Parses both YAML and JSON inputs into a simplified intermediate
4//! representation that the manifest generator consumes. The parser uses
5//! `serde_json::Value` internally and resolves simple `$ref` pointers within
6//! the `#/components/schemas` and `#/components/parameters` namespaces.
7
8use serde_json::Value;
9
10use crate::extensions::ChioExtensions;
11use crate::{OpenApiError, Result};
12
13/// A parsed OpenAPI specification.
14#[derive(Debug, Clone)]
15pub struct OpenApiSpec {
16    /// The OpenAPI version string (e.g. "3.0.3" or "3.1.0").
17    pub openapi_version: String,
18    /// API title from `info.title`.
19    pub title: String,
20    /// API description from `info.description`.
21    pub description: String,
22    /// API version from `info.version`.
23    pub api_version: String,
24    /// Parsed path items keyed by route path.
25    pub paths: Vec<(String, PathItem)>,
26    /// The raw JSON value -- retained for $ref resolution.
27    raw: Value,
28}
29
30/// A single path entry containing one or more HTTP operations.
31#[derive(Debug, Clone)]
32pub struct PathItem {
33    /// Path-level parameters shared by all operations on this path.
34    pub common_parameters: Vec<Parameter>,
35    /// Operations defined on this path (method, operation).
36    pub operations: Vec<(String, Operation)>,
37}
38
39/// A single HTTP operation (e.g. GET /pets).
40#[derive(Debug, Clone)]
41pub struct Operation {
42    /// The `operationId`, if present.
43    pub operation_id: Option<String>,
44    /// Human-readable summary.
45    pub summary: Option<String>,
46    /// Longer description.
47    pub description: Option<String>,
48    /// Tags for grouping.
49    pub tags: Vec<String>,
50    /// Parameters (path, query, header, cookie).
51    pub parameters: Vec<Parameter>,
52    /// Request body schema, if any.
53    pub request_body_schema: Option<Value>,
54    /// Whether requestBody.required is true. Defaults false per OpenAPI.
55    pub request_body_required: bool,
56    /// Response schemas keyed by status code.
57    pub response_schemas: Vec<(String, Option<Value>)>,
58    /// Raw operation object for extension extraction.
59    pub raw: Value,
60}
61
62/// A single parameter definition.
63#[derive(Debug, Clone)]
64pub struct Parameter {
65    /// Parameter name.
66    pub name: String,
67    /// Where the parameter appears.
68    pub location: ParameterLocation,
69    /// Whether the parameter is required.
70    pub required: bool,
71    /// JSON Schema for the parameter value.
72    pub schema: Option<Value>,
73    /// Human-readable description.
74    pub description: Option<String>,
75}
76
77/// Where a parameter appears in the request.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum ParameterLocation {
80    Path,
81    Query,
82    Header,
83    Cookie,
84}
85
86impl OpenApiSpec {
87    /// Parse an OpenAPI spec from a string, auto-detecting JSON vs YAML.
88    pub fn parse(input: &str) -> Result<Self> {
89        let trimmed = input.trim_start();
90        let value: Value = if trimmed.starts_with('{') {
91            serde_json::from_str(input)?
92        } else {
93            parse_yaml_value(input)?
94        };
95        Self::from_value(value)
96    }
97
98    /// Parse an OpenAPI spec from a `serde_json::Value`.
99    pub fn from_value(value: Value) -> Result<Self> {
100        let openapi_version = value
101            .get("openapi")
102            .and_then(|v| v.as_str())
103            .ok_or_else(|| OpenApiError::MissingField("openapi".to_string()))?
104            .to_string();
105
106        // Validate version is 3.x
107        if !openapi_version.starts_with("3.") {
108            return Err(OpenApiError::UnsupportedVersion(openapi_version));
109        }
110
111        let info = value
112            .get("info")
113            .ok_or_else(|| OpenApiError::MissingField("info".to_string()))?;
114
115        let title = info
116            .get("title")
117            .and_then(|v| v.as_str())
118            .unwrap_or("Untitled API")
119            .to_string();
120
121        let description = info
122            .get("description")
123            .and_then(|v| v.as_str())
124            .unwrap_or("")
125            .to_string();
126
127        let api_version = info
128            .get("version")
129            .and_then(|v| v.as_str())
130            .unwrap_or("0.0.0")
131            .to_string();
132
133        let paths_obj = value
134            .get("paths")
135            .and_then(|v| v.as_object())
136            .ok_or_else(|| OpenApiError::MissingField("paths".to_string()))?;
137
138        let mut paths = Vec::new();
139        for (path, path_value) in paths_obj {
140            let path_item = Self::parse_path_item(path_value, &value)?;
141            paths.push((path.clone(), path_item));
142        }
143
144        // Sort paths for deterministic output.
145        paths.sort_by(|a, b| a.0.cmp(&b.0));
146
147        Ok(Self {
148            openapi_version,
149            title,
150            description,
151            api_version,
152            paths,
153            raw: value,
154        })
155    }
156
157    /// Resolve a `$ref` pointer (only `#/components/...` pointers).
158    fn resolve_ref<'a>(root: &'a Value, ref_str: &str) -> Result<&'a Value> {
159        if !ref_str.starts_with("#/") {
160            return Err(OpenApiError::UnresolvedRef(ref_str.to_string()));
161        }
162
163        let pointer = ref_str.replacen('#', "", 1);
164        root.pointer(&pointer)
165            .ok_or_else(|| OpenApiError::UnresolvedRef(ref_str.to_string()))
166    }
167
168    /// If the value is a `$ref` object, resolve it. Otherwise return the
169    /// value as-is.
170    fn maybe_resolve<'a>(root: &'a Value, value: &'a Value) -> Result<&'a Value> {
171        if let Some(ref_str) = value.get("$ref").and_then(|v| v.as_str()) {
172            Self::resolve_ref(root, ref_str)
173        } else {
174            Ok(value)
175        }
176    }
177
178    fn parse_path_item(path_value: &Value, root: &Value) -> Result<PathItem> {
179        let obj = match path_value.as_object() {
180            Some(o) => o,
181            None => {
182                return Ok(PathItem {
183                    common_parameters: Vec::new(),
184                    operations: Vec::new(),
185                })
186            }
187        };
188
189        // Path-level parameters.
190        let common_parameters = if let Some(params) = obj.get("parameters") {
191            Self::parse_parameters(params, root)?
192        } else {
193            Vec::new()
194        };
195
196        let methods = ["get", "post", "put", "patch", "delete", "head", "options"];
197        let mut operations = Vec::new();
198
199        for method in &methods {
200            if let Some(op_value) = obj.get(*method) {
201                let operation = Self::parse_operation(op_value, root)?;
202                operations.push((method.to_uppercase(), operation));
203            }
204        }
205
206        Ok(PathItem {
207            common_parameters,
208            operations,
209        })
210    }
211
212    fn parse_operation(op_value: &Value, root: &Value) -> Result<Operation> {
213        let operation_id = op_value
214            .get("operationId")
215            .and_then(|v| v.as_str())
216            .map(String::from);
217
218        let summary = op_value
219            .get("summary")
220            .and_then(|v| v.as_str())
221            .map(String::from);
222
223        let description = op_value
224            .get("description")
225            .and_then(|v| v.as_str())
226            .map(String::from);
227
228        let tags = op_value
229            .get("tags")
230            .and_then(|v| v.as_array())
231            .map(|arr| {
232                arr.iter()
233                    .filter_map(|v| v.as_str().map(String::from))
234                    .collect()
235            })
236            .unwrap_or_default();
237
238        let parameters = if let Some(params) = op_value.get("parameters") {
239            Self::parse_parameters(params, root)?
240        } else {
241            Vec::new()
242        };
243
244        let request_body_required = Self::request_body_required(op_value, root)?;
245        let request_body_schema = Self::extract_request_body_schema(op_value, root)?;
246
247        let response_schemas = Self::extract_response_schemas(op_value, root)?;
248
249        Ok(Operation {
250            operation_id,
251            summary,
252            description,
253            tags,
254            parameters,
255            request_body_schema,
256            request_body_required,
257            response_schemas,
258            raw: op_value.clone(),
259        })
260    }
261
262    fn parse_parameters(params_value: &Value, root: &Value) -> Result<Vec<Parameter>> {
263        let arr = match params_value.as_array() {
264            Some(a) => a,
265            None => {
266                return Err(OpenApiError::InvalidSpec(
267                    "parameters must be an array".to_string(),
268                ))
269            }
270        };
271
272        let mut result = Vec::new();
273        for param_value in arr {
274            let resolved = Self::maybe_resolve(root, param_value)?;
275            let param = Self::parse_single_parameter(resolved, root)?;
276            result.push(param);
277        }
278        Ok(result)
279    }
280
281    fn parse_single_parameter(value: &Value, root: &Value) -> Result<Parameter> {
282        let name = value
283            .get("name")
284            .and_then(|v| v.as_str())
285            .filter(|name| !name.trim().is_empty())
286            .ok_or_else(|| OpenApiError::MissingField("parameter.name".to_string()))?
287            .to_string();
288
289        let location_value = value
290            .get("in")
291            .and_then(|v| v.as_str())
292            .filter(|location| !location.trim().is_empty())
293            .ok_or_else(|| OpenApiError::MissingField("parameter.in".to_string()))?;
294
295        let location = match location_value {
296            "path" => ParameterLocation::Path,
297            "query" => ParameterLocation::Query,
298            "header" => ParameterLocation::Header,
299            "cookie" => ParameterLocation::Cookie,
300            _ => {
301                return Err(OpenApiError::InvalidSpec(format!(
302                    "parameter.in has unsupported location `{location_value}`"
303                )))
304            }
305        };
306
307        let required = if let Some(required_value) = value.get("required") {
308            required_value.as_bool().ok_or_else(|| {
309                OpenApiError::InvalidSpec("parameter.required must be a boolean".to_string())
310            })?
311        } else {
312            // Path parameters are always required per the OpenAPI spec.
313            location == ParameterLocation::Path
314        };
315
316        let schema = value
317            .get("schema")
318            .map(|schema| Self::maybe_resolve(root, schema).cloned())
319            .transpose()?;
320
321        let description = value
322            .get("description")
323            .and_then(|v| v.as_str())
324            .map(String::from);
325
326        Ok(Parameter {
327            name,
328            location,
329            required,
330            schema,
331            description,
332        })
333    }
334
335    fn extract_request_body_schema(op_value: &Value, root: &Value) -> Result<Option<Value>> {
336        let body = match op_value.get("requestBody") {
337            Some(b) => Self::maybe_resolve(root, b)?,
338            None => return Ok(None),
339        };
340
341        // Look for application/json content first, then any content type.
342        let content = match body.get("content").and_then(|c| c.as_object()) {
343            Some(c) => c,
344            None => return Ok(None),
345        };
346
347        let media = preferred_content_media(content);
348
349        match media {
350            Some(m) => {
351                if let Some(schema) = m.get("schema") {
352                    let resolved = Self::maybe_resolve(root, schema)?;
353                    Ok(Some(resolved.clone()))
354                } else {
355                    Ok(None)
356                }
357            }
358            None => Ok(None),
359        }
360    }
361
362    fn request_body_required(op_value: &Value, root: &Value) -> Result<bool> {
363        let body = match op_value.get("requestBody") {
364            Some(body) => Self::maybe_resolve(root, body)?,
365            None => return Ok(false),
366        };
367        Ok(body
368            .get("required")
369            .and_then(Value::as_bool)
370            .unwrap_or(false))
371    }
372
373    fn extract_response_schemas(
374        op_value: &Value,
375        root: &Value,
376    ) -> Result<Vec<(String, Option<Value>)>> {
377        let responses = match op_value.get("responses").and_then(|r| r.as_object()) {
378            Some(r) => r,
379            None => return Ok(Vec::new()),
380        };
381
382        let mut result = Vec::new();
383        for (status, resp_value) in responses {
384            let resolved = Self::maybe_resolve(root, resp_value)?;
385            let schema = Self::extract_content_schema(resolved, root)?;
386            result.push((status.clone(), schema));
387        }
388        Ok(result)
389    }
390
391    fn extract_content_schema(resp: &Value, root: &Value) -> Result<Option<Value>> {
392        let content = match resp.get("content").and_then(|c| c.as_object()) {
393            Some(c) => c,
394            None => return Ok(None),
395        };
396
397        let media = preferred_content_media(content);
398
399        match media {
400            Some(m) => {
401                if let Some(schema) = m.get("schema") {
402                    let resolved = Self::maybe_resolve(root, schema)?;
403                    Ok(Some(resolved.clone()))
404                } else {
405                    Ok(None)
406                }
407            }
408            None => Ok(None),
409        }
410    }
411
412    /// Access the raw parsed JSON value for extension extraction.
413    #[must_use]
414    pub fn raw(&self) -> &Value {
415        &self.raw
416    }
417
418    /// Extract Chio extensions from an operation's raw value.
419    #[must_use]
420    pub fn extensions_for(operation: &Operation) -> ChioExtensions {
421        ChioExtensions::from_operation(&operation.raw)
422    }
423}
424
425fn parse_yaml_value(input: &str) -> Result<Value> {
426    Ok(serde_yaml::from_str(input)?)
427}
428
429fn preferred_content_media(content: &serde_json::Map<String, Value>) -> Option<&Value> {
430    content
431        .get("application/json")
432        .or_else(|| content.values().next())
433}
434
435#[cfg(test)]
436#[allow(clippy::unwrap_used, clippy::expect_used)]
437mod tests {
438    use super::*;
439
440    fn op_parameter_schema<'a>(operation: &'a Operation, name: &str) -> Option<&'a Value> {
441        operation
442            .parameters
443            .iter()
444            .find(|parameter| parameter.name == name)
445            .and_then(|parameter| parameter.schema.as_ref())
446    }
447
448    fn minimal_spec_json() -> &'static str {
449        r##"{
450            "openapi": "3.0.3",
451            "info": {
452                "title": "Test API",
453                "version": "1.0.0"
454            },
455            "paths": {
456                "/pets": {
457                    "get": {
458                        "operationId": "listPets",
459                        "summary": "List all pets",
460                        "parameters": [
461                            {
462                                "name": "limit",
463                                "in": "query",
464                                "required": false,
465                                "schema": { "type": "integer" }
466                            }
467                        ],
468                        "responses": {
469                            "200": {
470                                "description": "A list of pets",
471                                "content": {
472                                    "application/json": {
473                                        "schema": {
474                                            "type": "array",
475                                            "items": { "type": "object" }
476                                        }
477                                    }
478                                }
479                            }
480                        }
481                    }
482                }
483            }
484        }"##
485    }
486
487    fn minimal_spec_yaml() -> &'static str {
488        r##"openapi: "3.1.0"
489info:
490  title: Test API
491  version: "1.0.0"
492paths:
493  /items:
494    get:
495      operationId: listItems
496      summary: List items
497      responses:
498        "200":
499          description: OK
500"##
501    }
502
503    #[test]
504    fn parse_json_spec() {
505        let spec = OpenApiSpec::parse(minimal_spec_json()).unwrap();
506        assert_eq!(spec.openapi_version, "3.0.3");
507        assert_eq!(spec.title, "Test API");
508        assert_eq!(spec.api_version, "1.0.0");
509        assert_eq!(spec.paths.len(), 1);
510
511        let (path, item) = &spec.paths[0];
512        assert_eq!(path, "/pets");
513        assert_eq!(item.operations.len(), 1);
514        assert_eq!(item.operations[0].0, "GET");
515
516        let op = &item.operations[0].1;
517        assert_eq!(op.operation_id.as_deref(), Some("listPets"));
518        assert_eq!(op.parameters.len(), 1);
519        assert_eq!(op.parameters[0].name, "limit");
520        assert_eq!(op.parameters[0].location, ParameterLocation::Query);
521        assert!(!op.parameters[0].required);
522    }
523
524    #[test]
525    fn parse_yaml_spec() {
526        let spec = OpenApiSpec::parse(minimal_spec_yaml()).unwrap();
527        assert_eq!(spec.openapi_version, "3.1.0");
528        assert_eq!(spec.paths.len(), 1);
529        let (path, _) = &spec.paths[0];
530        assert_eq!(path, "/items");
531    }
532
533    #[test]
534    fn preferred_content_media_selects_application_json_when_present() {
535        let content = serde_json::json!({
536            "text/plain": { "schema": { "type": "string" } },
537            "application/json": { "schema": { "type": "object" } }
538        });
539        let media = preferred_content_media(content.as_object().unwrap()).unwrap();
540
541        assert_eq!(
542            media
543                .get("schema")
544                .and_then(|schema| schema.get("type"))
545                .and_then(Value::as_str),
546            Some("object")
547        );
548    }
549
550    #[test]
551    fn unsupported_version() {
552        let input = r##"{"openapi": "2.0", "info": {"title": "T", "version": "1"}, "paths": {}}"##;
553        let err = OpenApiSpec::parse(input).unwrap_err();
554        assert!(matches!(err, OpenApiError::UnsupportedVersion(_)));
555    }
556
557    #[test]
558    fn missing_openapi_field() {
559        let input = r##"{"info": {"title": "T", "version": "1"}, "paths": {}}"##;
560        let err = OpenApiSpec::parse(input).unwrap_err();
561        assert!(matches!(err, OpenApiError::MissingField(_)));
562    }
563
564    #[test]
565    fn ref_resolution() {
566        let input = r##"{
567            "openapi": "3.0.3",
568            "info": { "title": "T", "version": "1" },
569            "paths": {
570                "/things": {
571                    "get": {
572                        "operationId": "getThings",
573                        "parameters": [
574                            { "$ref": "#/components/parameters/LimitParam" }
575                        ],
576                        "responses": { "200": { "description": "OK" } }
577                    }
578                }
579            },
580            "components": {
581                "parameters": {
582                    "LimitParam": {
583                        "name": "limit",
584                        "in": "query",
585                        "required": false,
586                        "schema": { "type": "integer" }
587                    }
588                }
589            }
590        }"##;
591
592        let spec = OpenApiSpec::parse(input).unwrap();
593        let (_, item) = &spec.paths[0];
594        let op = &item.operations[0].1;
595        assert_eq!(op.parameters.len(), 1);
596        assert_eq!(op.parameters[0].name, "limit");
597    }
598
599    #[test]
600    fn parameter_schema_ref_is_resolved() {
601        let input = r##"{
602            "openapi": "3.0.3",
603            "info": { "title": "T", "version": "1" },
604            "paths": {
605                "/things": {
606                    "get": {
607                        "operationId": "getThings",
608                        "parameters": [
609                            {
610                                "name": "limit",
611                                "in": "query",
612                                "schema": { "$ref": "#/components/schemas/Limit" }
613                            }
614                        ],
615                        "responses": { "200": { "description": "OK" } }
616                    }
617                }
618            },
619            "components": {
620                "schemas": {
621                    "Limit": { "type": "integer", "minimum": 1 }
622                }
623            }
624        }"##;
625
626        let spec = OpenApiSpec::parse(input).unwrap();
627        let (_, item) = &spec.paths[0];
628        let schema = op_parameter_schema(&item.operations[0].1, "limit").unwrap();
629
630        assert_eq!(schema.get("type").and_then(Value::as_str), Some("integer"));
631        assert_eq!(schema.get("minimum").and_then(Value::as_i64), Some(1));
632        assert!(schema.get("$ref").is_none());
633    }
634
635    #[test]
636    fn request_body_schema_extracted() {
637        let input = r##"{
638            "openapi": "3.0.3",
639            "info": { "title": "T", "version": "1" },
640            "paths": {
641                "/pets": {
642                    "post": {
643                        "operationId": "createPet",
644                        "requestBody": {
645                            "content": {
646                                "application/json": {
647                                    "schema": {
648                                        "type": "object",
649                                        "properties": {
650                                            "name": { "type": "string" }
651                                        }
652                                    }
653                                }
654                            }
655                        },
656                        "responses": { "201": { "description": "Created" } }
657                    }
658                }
659            }
660        }"##;
661
662        let spec = OpenApiSpec::parse(input).unwrap();
663        let (_, item) = &spec.paths[0];
664        let op = &item.operations[0].1;
665        assert!(op.request_body_schema.is_some());
666        assert!(!op.request_body_required);
667        let schema = op.request_body_schema.as_ref().unwrap();
668        assert_eq!(schema.get("type").and_then(|v| v.as_str()), Some("object"));
669    }
670
671    #[test]
672    fn request_body_required_is_extracted_after_ref_resolution() {
673        let input = r##"{
674            "openapi": "3.0.3",
675            "info": { "title": "T", "version": "1" },
676            "paths": {
677                "/pets": {
678                    "post": {
679                        "operationId": "createPet",
680                        "requestBody": { "$ref": "#/components/requestBodies/PetBody" },
681                        "responses": { "201": { "description": "Created" } }
682                    }
683                }
684            },
685            "components": {
686                "requestBodies": {
687                    "PetBody": {
688                        "required": true,
689                        "content": {
690                            "application/json": {
691                                "schema": {
692                                    "type": "object",
693                                    "properties": {
694                                        "name": { "type": "string" }
695                                    }
696                                }
697                            }
698                        }
699                    }
700                }
701            }
702        }"##;
703
704        let spec = OpenApiSpec::parse(input).unwrap();
705        let (_, item) = &spec.paths[0];
706        let op = &item.operations[0].1;
707        assert!(op.request_body_schema.is_some());
708        assert!(op.request_body_required);
709    }
710
711    #[test]
712    fn path_parameters_required_by_default() {
713        let input = r##"{
714            "openapi": "3.0.3",
715            "info": { "title": "T", "version": "1" },
716            "paths": {
717                "/pets/{petId}": {
718                    "get": {
719                        "operationId": "getPet",
720                        "parameters": [
721                            { "name": "petId", "in": "path", "schema": { "type": "string" } }
722                        ],
723                        "responses": { "200": { "description": "OK" } }
724                    }
725                }
726            }
727        }"##;
728
729        let spec = OpenApiSpec::parse(input).unwrap();
730        let (_, item) = &spec.paths[0];
731        let op = &item.operations[0].1;
732        assert!(op.parameters[0].required);
733        assert_eq!(op.parameters[0].location, ParameterLocation::Path);
734    }
735
736    #[test]
737    fn parameter_missing_name_is_rejected() {
738        let input = r##"{
739            "openapi": "3.0.3",
740            "info": { "title": "T", "version": "1" },
741            "paths": {
742                "/pets": {
743                    "get": {
744                        "operationId": "listPets",
745                        "parameters": [
746                            { "in": "query", "schema": { "type": "string" } }
747                        ],
748                        "responses": { "200": { "description": "OK" } }
749                    }
750                }
751            }
752        }"##;
753
754        let err = OpenApiSpec::parse(input).unwrap_err();
755
756        assert!(matches!(err, OpenApiError::MissingField(ref field) if field == "parameter.name"));
757    }
758
759    #[test]
760    fn parameter_missing_in_is_rejected() {
761        let input = r##"{
762            "openapi": "3.0.3",
763            "info": { "title": "T", "version": "1" },
764            "paths": {
765                "/pets": {
766                    "get": {
767                        "operationId": "listPets",
768                        "parameters": [
769                            { "name": "limit", "schema": { "type": "string" } }
770                        ],
771                        "responses": { "200": { "description": "OK" } }
772                    }
773                }
774            }
775        }"##;
776
777        let err = OpenApiSpec::parse(input).unwrap_err();
778
779        assert!(matches!(err, OpenApiError::MissingField(ref field) if field == "parameter.in"));
780    }
781
782    #[test]
783    fn non_array_parameters_rejected() {
784        let input = r##"{
785            "openapi": "3.0.3",
786            "info": {"title": "T", "version": "1"},
787            "paths": {
788                "/pets": {
789                    "get": {
790                        "parameters": {"name": "limit", "in": "query"},
791                        "responses": { "200": { "description": "OK" } }
792                    }
793                }
794            }
795        }"##;
796
797        let err = OpenApiSpec::parse(input).unwrap_err();
798
799        assert!(
800            matches!(err, OpenApiError::InvalidSpec(ref message) if message.contains("parameters must be an array"))
801        );
802    }
803
804    #[test]
805    fn unsupported_parameter_location_rejected() {
806        let input = r##"{
807            "openapi": "3.0.3",
808            "info": {"title": "T", "version": "1"},
809            "paths": {
810                "/pets": {
811                    "get": {
812                        "parameters": [
813                            {"name": "petId", "in": "pat", "required": true}
814                        ],
815                        "responses": { "200": { "description": "OK" } }
816                    }
817                }
818            }
819        }"##;
820
821        let err = OpenApiSpec::parse(input).unwrap_err();
822
823        assert!(
824            matches!(err, OpenApiError::InvalidSpec(ref message) if message.contains("unsupported location"))
825        );
826    }
827
828    #[test]
829    fn non_boolean_parameter_required_rejected() {
830        let input = r##"{
831            "openapi": "3.0.3",
832            "info": {"title": "T", "version": "1"},
833            "paths": {
834                "/pets": {
835                    "get": {
836                        "parameters": [
837                            {"name": "limit", "in": "query", "required": "false"}
838                        ],
839                        "responses": { "200": { "description": "OK" } }
840                    }
841                }
842            }
843        }"##;
844
845        let err = OpenApiSpec::parse(input).unwrap_err();
846
847        assert!(
848            matches!(err, OpenApiError::InvalidSpec(ref message) if message.contains("parameter.required"))
849        );
850    }
851
852    #[test]
853    fn missing_paths_field() {
854        let input = r##"{"openapi": "3.0.3", "info": {"title": "T", "version": "1"}}"##;
855        let err = OpenApiSpec::parse(input).unwrap_err();
856        assert!(matches!(err, OpenApiError::MissingField(ref f) if f == "paths"));
857    }
858
859    #[test]
860    fn missing_info_field() {
861        let input = r##"{"openapi": "3.0.3", "paths": {}}"##;
862        let err = OpenApiSpec::parse(input).unwrap_err();
863        assert!(matches!(err, OpenApiError::MissingField(ref f) if f == "info"));
864    }
865
866    #[test]
867    fn empty_paths_object() {
868        let input =
869            r##"{"openapi": "3.0.3", "info": {"title": "T", "version": "1"}, "paths": {}}"##;
870        let spec = OpenApiSpec::parse(input).unwrap();
871        assert!(spec.paths.is_empty());
872        assert_eq!(spec.title, "T");
873    }
874
875    #[test]
876    fn spec_with_no_operations_on_path() {
877        let input = r##"{
878            "openapi": "3.0.3",
879            "info": {"title": "T", "version": "1"},
880            "paths": {
881                "/empty": {
882                    "parameters": [
883                        {"name": "id", "in": "query", "schema": {"type": "string"}}
884                    ]
885                }
886            }
887        }"##;
888        let spec = OpenApiSpec::parse(input).unwrap();
889        assert_eq!(spec.paths.len(), 1);
890        let (_, item) = &spec.paths[0];
891        assert!(item.operations.is_empty());
892        assert_eq!(item.common_parameters.len(), 1);
893    }
894
895    #[test]
896    fn broken_ref_produces_error() {
897        let input = r##"{
898            "openapi": "3.0.3",
899            "info": {"title": "T", "version": "1"},
900            "paths": {
901                "/things": {
902                    "get": {
903                        "parameters": [
904                            {"$ref": "#/components/parameters/NonExistent"}
905                        ],
906                        "responses": {"200": {"description": "OK"}}
907                    }
908                }
909            }
910        }"##;
911        let err = OpenApiSpec::parse(input).unwrap_err();
912        assert!(matches!(err, OpenApiError::UnresolvedRef(_)));
913    }
914
915    #[test]
916    fn external_ref_produces_error() {
917        let input = r##"{
918            "openapi": "3.0.3",
919            "info": {"title": "T", "version": "1"},
920            "paths": {
921                "/things": {
922                    "get": {
923                        "parameters": [
924                            {"$ref": "https://example.com/params.yaml#/Limit"}
925                        ],
926                        "responses": {"200": {"description": "OK"}}
927                    }
928                }
929            }
930        }"##;
931        let err = OpenApiSpec::parse(input).unwrap_err();
932        assert!(matches!(err, OpenApiError::UnresolvedRef(_)));
933    }
934
935    #[test]
936    fn invalid_json_produces_error() {
937        let input = r##"{not valid json"##;
938        let err = OpenApiSpec::parse(input).unwrap_err();
939        assert!(matches!(err, OpenApiError::InvalidJson(_)));
940    }
941
942    #[test]
943    fn invalid_yaml_produces_error() {
944        let input = "openapi: [unclosed\n";
945        let err = OpenApiSpec::parse(input).unwrap_err();
946        assert!(matches!(err, OpenApiError::InvalidYaml(_)));
947    }
948
949    #[test]
950    fn invalid_yaml_does_not_invoke_outer_hook() {
951        use std::sync::atomic::{AtomicBool, Ordering};
952        use std::sync::Arc;
953
954        let input = "openapi: [unclosed\n";
955        let hook_called = Arc::new(AtomicBool::new(false));
956        let hook_called_clone = Arc::clone(&hook_called);
957        let previous_hook = std::panic::take_hook();
958        std::panic::set_hook(Box::new(move |_| {
959            hook_called_clone.store(true, Ordering::SeqCst);
960        }));
961
962        let err = OpenApiSpec::parse(input).unwrap_err();
963        std::panic::set_hook(previous_hook);
964
965        assert!(matches!(err, OpenApiError::InvalidYaml(_)));
966        assert!(!hook_called.load(Ordering::SeqCst));
967    }
968
969    #[test]
970    fn fuzz_malformed_yaml_rejected_without_panic() {
971        let input = "openapi:ets:\n    get:\n      ope: integer\n      responses:\n        \"201\":\n          description:ope: integer\n      responses:A        \"201\":\n  A list of pets\n";
972
973        assert!(OpenApiSpec::parse(input).is_err());
974    }
975
976    #[test]
977    fn missing_title_defaults_to_untitled() {
978        let input = r##"{
979            "openapi": "3.0.3",
980            "info": {"version": "1"},
981            "paths": {}
982        }"##;
983        let spec = OpenApiSpec::parse(input).unwrap();
984        assert_eq!(spec.title, "Untitled API");
985    }
986
987    #[test]
988    fn missing_version_defaults_to_000() {
989        let input = r##"{
990            "openapi": "3.0.3",
991            "info": {"title": "T"},
992            "paths": {}
993        }"##;
994        let spec = OpenApiSpec::parse(input).unwrap();
995        assert_eq!(spec.api_version, "0.0.0");
996    }
997
998    #[test]
999    fn chio_extensions_extracted() {
1000        let input = r##"{
1001            "openapi": "3.0.3",
1002            "info": { "title": "T", "version": "1" },
1003            "paths": {
1004                "/admin/reset": {
1005                    "post": {
1006                        "operationId": "resetSystem",
1007                        "x-chio-sensitivity": "restricted",
1008                        "x-chio-approval-required": true,
1009                        "x-chio-side-effects": true,
1010                        "responses": { "200": { "description": "OK" } }
1011                    }
1012                }
1013            }
1014        }"##;
1015
1016        let spec = OpenApiSpec::parse(input).unwrap();
1017        let (_, item) = &spec.paths[0];
1018        let op = &item.operations[0].1;
1019        let ext = OpenApiSpec::extensions_for(op);
1020        assert_eq!(
1021            ext.sensitivity,
1022            Some(crate::extensions::Sensitivity::Restricted)
1023        );
1024        assert_eq!(ext.approval_required, Some(true));
1025        assert_eq!(ext.side_effects, Some(true));
1026    }
1027}