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    /// Response schemas keyed by status code.
55    pub response_schemas: Vec<(String, Option<Value>)>,
56    /// Raw operation object for extension extraction.
57    pub raw: Value,
58}
59
60/// A single parameter definition.
61#[derive(Debug, Clone)]
62pub struct Parameter {
63    /// Parameter name.
64    pub name: String,
65    /// Where the parameter appears.
66    pub location: ParameterLocation,
67    /// Whether the parameter is required.
68    pub required: bool,
69    /// JSON Schema for the parameter value.
70    pub schema: Option<Value>,
71    /// Human-readable description.
72    pub description: Option<String>,
73}
74
75/// Where a parameter appears in the request.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ParameterLocation {
78    Path,
79    Query,
80    Header,
81    Cookie,
82}
83
84impl OpenApiSpec {
85    /// Parse an OpenAPI spec from a string, auto-detecting JSON vs YAML.
86    pub fn parse(input: &str) -> Result<Self> {
87        let trimmed = input.trim_start();
88        let value: Value = if trimmed.starts_with('{') {
89            serde_json::from_str(input)?
90        } else {
91            serde_yml::from_str(input)?
92        };
93        Self::from_value(value)
94    }
95
96    /// Parse an OpenAPI spec from a `serde_json::Value`.
97    pub fn from_value(value: Value) -> Result<Self> {
98        let openapi_version = value
99            .get("openapi")
100            .and_then(|v| v.as_str())
101            .ok_or_else(|| OpenApiError::MissingField("openapi".to_string()))?
102            .to_string();
103
104        // Validate version is 3.x
105        if !openapi_version.starts_with("3.") {
106            return Err(OpenApiError::UnsupportedVersion(openapi_version));
107        }
108
109        let info = value
110            .get("info")
111            .ok_or_else(|| OpenApiError::MissingField("info".to_string()))?;
112
113        let title = info
114            .get("title")
115            .and_then(|v| v.as_str())
116            .unwrap_or("Untitled API")
117            .to_string();
118
119        let description = info
120            .get("description")
121            .and_then(|v| v.as_str())
122            .unwrap_or("")
123            .to_string();
124
125        let api_version = info
126            .get("version")
127            .and_then(|v| v.as_str())
128            .unwrap_or("0.0.0")
129            .to_string();
130
131        let paths_obj = value
132            .get("paths")
133            .and_then(|v| v.as_object())
134            .ok_or_else(|| OpenApiError::MissingField("paths".to_string()))?;
135
136        let mut paths = Vec::new();
137        for (path, path_value) in paths_obj {
138            let path_item = Self::parse_path_item(path_value, &value)?;
139            paths.push((path.clone(), path_item));
140        }
141
142        // Sort paths for deterministic output.
143        paths.sort_by(|a, b| a.0.cmp(&b.0));
144
145        Ok(Self {
146            openapi_version,
147            title,
148            description,
149            api_version,
150            paths,
151            raw: value,
152        })
153    }
154
155    /// Resolve a `$ref` pointer (only `#/components/...` pointers).
156    fn resolve_ref<'a>(root: &'a Value, ref_str: &str) -> Result<&'a Value> {
157        if !ref_str.starts_with("#/") {
158            return Err(OpenApiError::UnresolvedRef(ref_str.to_string()));
159        }
160
161        let pointer = ref_str.replacen('#', "", 1);
162        root.pointer(&pointer)
163            .ok_or_else(|| OpenApiError::UnresolvedRef(ref_str.to_string()))
164    }
165
166    /// If the value is a `$ref` object, resolve it. Otherwise return the
167    /// value as-is.
168    fn maybe_resolve<'a>(root: &'a Value, value: &'a Value) -> Result<&'a Value> {
169        if let Some(ref_str) = value.get("$ref").and_then(|v| v.as_str()) {
170            Self::resolve_ref(root, ref_str)
171        } else {
172            Ok(value)
173        }
174    }
175
176    fn parse_path_item(path_value: &Value, root: &Value) -> Result<PathItem> {
177        let obj = match path_value.as_object() {
178            Some(o) => o,
179            None => {
180                return Ok(PathItem {
181                    common_parameters: Vec::new(),
182                    operations: Vec::new(),
183                })
184            }
185        };
186
187        // Path-level parameters.
188        let common_parameters = if let Some(params) = obj.get("parameters") {
189            Self::parse_parameters(params, root)?
190        } else {
191            Vec::new()
192        };
193
194        let methods = ["get", "post", "put", "patch", "delete", "head", "options"];
195        let mut operations = Vec::new();
196
197        for method in &methods {
198            if let Some(op_value) = obj.get(*method) {
199                let operation = Self::parse_operation(op_value, root)?;
200                operations.push((method.to_uppercase(), operation));
201            }
202        }
203
204        Ok(PathItem {
205            common_parameters,
206            operations,
207        })
208    }
209
210    fn parse_operation(op_value: &Value, root: &Value) -> Result<Operation> {
211        let operation_id = op_value
212            .get("operationId")
213            .and_then(|v| v.as_str())
214            .map(String::from);
215
216        let summary = op_value
217            .get("summary")
218            .and_then(|v| v.as_str())
219            .map(String::from);
220
221        let description = op_value
222            .get("description")
223            .and_then(|v| v.as_str())
224            .map(String::from);
225
226        let tags = op_value
227            .get("tags")
228            .and_then(|v| v.as_array())
229            .map(|arr| {
230                arr.iter()
231                    .filter_map(|v| v.as_str().map(String::from))
232                    .collect()
233            })
234            .unwrap_or_default();
235
236        let parameters = if let Some(params) = op_value.get("parameters") {
237            Self::parse_parameters(params, root)?
238        } else {
239            Vec::new()
240        };
241
242        let request_body_schema = Self::extract_request_body_schema(op_value, root)?;
243
244        let response_schemas = Self::extract_response_schemas(op_value, root)?;
245
246        Ok(Operation {
247            operation_id,
248            summary,
249            description,
250            tags,
251            parameters,
252            request_body_schema,
253            response_schemas,
254            raw: op_value.clone(),
255        })
256    }
257
258    fn parse_parameters(params_value: &Value, root: &Value) -> Result<Vec<Parameter>> {
259        let arr = match params_value.as_array() {
260            Some(a) => a,
261            None => return Ok(Vec::new()),
262        };
263
264        let mut result = Vec::new();
265        for param_value in arr {
266            let resolved = Self::maybe_resolve(root, param_value)?;
267            let param = Self::parse_single_parameter(resolved)?;
268            result.push(param);
269        }
270        Ok(result)
271    }
272
273    fn parse_single_parameter(value: &Value) -> Result<Parameter> {
274        let name = value
275            .get("name")
276            .and_then(|v| v.as_str())
277            .unwrap_or("unknown")
278            .to_string();
279
280        let location = match value.get("in").and_then(|v| v.as_str()) {
281            Some("path") => ParameterLocation::Path,
282            Some("query") => ParameterLocation::Query,
283            Some("header") => ParameterLocation::Header,
284            Some("cookie") => ParameterLocation::Cookie,
285            _ => ParameterLocation::Query, // default fallback
286        };
287
288        let required = value
289            .get("required")
290            .and_then(|v| v.as_bool())
291            // Path parameters are always required per the OpenAPI spec.
292            .unwrap_or(location == ParameterLocation::Path);
293
294        let schema = value.get("schema").cloned();
295
296        let description = value
297            .get("description")
298            .and_then(|v| v.as_str())
299            .map(String::from);
300
301        Ok(Parameter {
302            name,
303            location,
304            required,
305            schema,
306            description,
307        })
308    }
309
310    fn extract_request_body_schema(op_value: &Value, root: &Value) -> Result<Option<Value>> {
311        let body = match op_value.get("requestBody") {
312            Some(b) => Self::maybe_resolve(root, b)?,
313            None => return Ok(None),
314        };
315
316        // Look for application/json content first, then any content type.
317        let content = match body.get("content").and_then(|c| c.as_object()) {
318            Some(c) => c,
319            None => return Ok(None),
320        };
321
322        let media = content
323            .get("application/json")
324            .or_else(|| content.values().next());
325
326        match media {
327            Some(m) => {
328                if let Some(schema) = m.get("schema") {
329                    let resolved = Self::maybe_resolve(root, schema)?;
330                    Ok(Some(resolved.clone()))
331                } else {
332                    Ok(None)
333                }
334            }
335            None => Ok(None),
336        }
337    }
338
339    fn extract_response_schemas(
340        op_value: &Value,
341        root: &Value,
342    ) -> Result<Vec<(String, Option<Value>)>> {
343        let responses = match op_value.get("responses").and_then(|r| r.as_object()) {
344            Some(r) => r,
345            None => return Ok(Vec::new()),
346        };
347
348        let mut result = Vec::new();
349        for (status, resp_value) in responses {
350            let resolved = Self::maybe_resolve(root, resp_value)?;
351            let schema = Self::extract_content_schema(resolved, root)?;
352            result.push((status.clone(), schema));
353        }
354        Ok(result)
355    }
356
357    fn extract_content_schema(resp: &Value, root: &Value) -> Result<Option<Value>> {
358        let content = match resp.get("content").and_then(|c| c.as_object()) {
359            Some(c) => c,
360            None => return Ok(None),
361        };
362
363        let media = content
364            .get("application/json")
365            .or_else(|| content.values().next());
366
367        match media {
368            Some(m) => {
369                if let Some(schema) = m.get("schema") {
370                    let resolved = Self::maybe_resolve(root, schema)?;
371                    Ok(Some(resolved.clone()))
372                } else {
373                    Ok(None)
374                }
375            }
376            None => Ok(None),
377        }
378    }
379
380    /// Access the raw parsed JSON value for extension extraction.
381    #[must_use]
382    pub fn raw(&self) -> &Value {
383        &self.raw
384    }
385
386    /// Extract Chio extensions from an operation's raw value.
387    #[must_use]
388    pub fn extensions_for(operation: &Operation) -> ChioExtensions {
389        ChioExtensions::from_operation(&operation.raw)
390    }
391}
392
393#[cfg(test)]
394#[allow(clippy::unwrap_used, clippy::expect_used)]
395mod tests {
396    use super::*;
397
398    fn minimal_spec_json() -> &'static str {
399        r##"{
400            "openapi": "3.0.3",
401            "info": {
402                "title": "Test API",
403                "version": "1.0.0"
404            },
405            "paths": {
406                "/pets": {
407                    "get": {
408                        "operationId": "listPets",
409                        "summary": "List all pets",
410                        "parameters": [
411                            {
412                                "name": "limit",
413                                "in": "query",
414                                "required": false,
415                                "schema": { "type": "integer" }
416                            }
417                        ],
418                        "responses": {
419                            "200": {
420                                "description": "A list of pets",
421                                "content": {
422                                    "application/json": {
423                                        "schema": {
424                                            "type": "array",
425                                            "items": { "type": "object" }
426                                        }
427                                    }
428                                }
429                            }
430                        }
431                    }
432                }
433            }
434        }"##
435    }
436
437    fn minimal_spec_yaml() -> &'static str {
438        r##"openapi: "3.1.0"
439info:
440  title: Test API
441  version: "1.0.0"
442paths:
443  /items:
444    get:
445      operationId: listItems
446      summary: List items
447      responses:
448        "200":
449          description: OK
450"##
451    }
452
453    #[test]
454    fn parse_json_spec() {
455        let spec = OpenApiSpec::parse(minimal_spec_json()).unwrap();
456        assert_eq!(spec.openapi_version, "3.0.3");
457        assert_eq!(spec.title, "Test API");
458        assert_eq!(spec.api_version, "1.0.0");
459        assert_eq!(spec.paths.len(), 1);
460
461        let (path, item) = &spec.paths[0];
462        assert_eq!(path, "/pets");
463        assert_eq!(item.operations.len(), 1);
464        assert_eq!(item.operations[0].0, "GET");
465
466        let op = &item.operations[0].1;
467        assert_eq!(op.operation_id.as_deref(), Some("listPets"));
468        assert_eq!(op.parameters.len(), 1);
469        assert_eq!(op.parameters[0].name, "limit");
470        assert_eq!(op.parameters[0].location, ParameterLocation::Query);
471        assert!(!op.parameters[0].required);
472    }
473
474    #[test]
475    fn parse_yaml_spec() {
476        let spec = OpenApiSpec::parse(minimal_spec_yaml()).unwrap();
477        assert_eq!(spec.openapi_version, "3.1.0");
478        assert_eq!(spec.paths.len(), 1);
479        let (path, _) = &spec.paths[0];
480        assert_eq!(path, "/items");
481    }
482
483    #[test]
484    fn unsupported_version() {
485        let input = r##"{"openapi": "2.0", "info": {"title": "T", "version": "1"}, "paths": {}}"##;
486        let err = OpenApiSpec::parse(input).unwrap_err();
487        assert!(matches!(err, OpenApiError::UnsupportedVersion(_)));
488    }
489
490    #[test]
491    fn missing_openapi_field() {
492        let input = r##"{"info": {"title": "T", "version": "1"}, "paths": {}}"##;
493        let err = OpenApiSpec::parse(input).unwrap_err();
494        assert!(matches!(err, OpenApiError::MissingField(_)));
495    }
496
497    #[test]
498    fn ref_resolution() {
499        let input = r##"{
500            "openapi": "3.0.3",
501            "info": { "title": "T", "version": "1" },
502            "paths": {
503                "/things": {
504                    "get": {
505                        "operationId": "getThings",
506                        "parameters": [
507                            { "$ref": "#/components/parameters/LimitParam" }
508                        ],
509                        "responses": { "200": { "description": "OK" } }
510                    }
511                }
512            },
513            "components": {
514                "parameters": {
515                    "LimitParam": {
516                        "name": "limit",
517                        "in": "query",
518                        "required": false,
519                        "schema": { "type": "integer" }
520                    }
521                }
522            }
523        }"##;
524
525        let spec = OpenApiSpec::parse(input).unwrap();
526        let (_, item) = &spec.paths[0];
527        let op = &item.operations[0].1;
528        assert_eq!(op.parameters.len(), 1);
529        assert_eq!(op.parameters[0].name, "limit");
530    }
531
532    #[test]
533    fn request_body_schema_extracted() {
534        let input = r##"{
535            "openapi": "3.0.3",
536            "info": { "title": "T", "version": "1" },
537            "paths": {
538                "/pets": {
539                    "post": {
540                        "operationId": "createPet",
541                        "requestBody": {
542                            "content": {
543                                "application/json": {
544                                    "schema": {
545                                        "type": "object",
546                                        "properties": {
547                                            "name": { "type": "string" }
548                                        }
549                                    }
550                                }
551                            }
552                        },
553                        "responses": { "201": { "description": "Created" } }
554                    }
555                }
556            }
557        }"##;
558
559        let spec = OpenApiSpec::parse(input).unwrap();
560        let (_, item) = &spec.paths[0];
561        let op = &item.operations[0].1;
562        assert!(op.request_body_schema.is_some());
563        let schema = op.request_body_schema.as_ref().unwrap();
564        assert_eq!(schema.get("type").and_then(|v| v.as_str()), Some("object"));
565    }
566
567    #[test]
568    fn path_parameters_required_by_default() {
569        let input = r##"{
570            "openapi": "3.0.3",
571            "info": { "title": "T", "version": "1" },
572            "paths": {
573                "/pets/{petId}": {
574                    "get": {
575                        "operationId": "getPet",
576                        "parameters": [
577                            { "name": "petId", "in": "path", "schema": { "type": "string" } }
578                        ],
579                        "responses": { "200": { "description": "OK" } }
580                    }
581                }
582            }
583        }"##;
584
585        let spec = OpenApiSpec::parse(input).unwrap();
586        let (_, item) = &spec.paths[0];
587        let op = &item.operations[0].1;
588        assert!(op.parameters[0].required);
589        assert_eq!(op.parameters[0].location, ParameterLocation::Path);
590    }
591
592    #[test]
593    fn missing_paths_field() {
594        let input = r##"{"openapi": "3.0.3", "info": {"title": "T", "version": "1"}}"##;
595        let err = OpenApiSpec::parse(input).unwrap_err();
596        assert!(matches!(err, OpenApiError::MissingField(ref f) if f == "paths"));
597    }
598
599    #[test]
600    fn missing_info_field() {
601        let input = r##"{"openapi": "3.0.3", "paths": {}}"##;
602        let err = OpenApiSpec::parse(input).unwrap_err();
603        assert!(matches!(err, OpenApiError::MissingField(ref f) if f == "info"));
604    }
605
606    #[test]
607    fn empty_paths_object() {
608        let input =
609            r##"{"openapi": "3.0.3", "info": {"title": "T", "version": "1"}, "paths": {}}"##;
610        let spec = OpenApiSpec::parse(input).unwrap();
611        assert!(spec.paths.is_empty());
612        assert_eq!(spec.title, "T");
613    }
614
615    #[test]
616    fn spec_with_no_operations_on_path() {
617        let input = r##"{
618            "openapi": "3.0.3",
619            "info": {"title": "T", "version": "1"},
620            "paths": {
621                "/empty": {
622                    "parameters": [
623                        {"name": "id", "in": "query", "schema": {"type": "string"}}
624                    ]
625                }
626            }
627        }"##;
628        let spec = OpenApiSpec::parse(input).unwrap();
629        assert_eq!(spec.paths.len(), 1);
630        let (_, item) = &spec.paths[0];
631        assert!(item.operations.is_empty());
632        assert_eq!(item.common_parameters.len(), 1);
633    }
634
635    #[test]
636    fn broken_ref_produces_error() {
637        let input = r##"{
638            "openapi": "3.0.3",
639            "info": {"title": "T", "version": "1"},
640            "paths": {
641                "/things": {
642                    "get": {
643                        "parameters": [
644                            {"$ref": "#/components/parameters/NonExistent"}
645                        ],
646                        "responses": {"200": {"description": "OK"}}
647                    }
648                }
649            }
650        }"##;
651        let err = OpenApiSpec::parse(input).unwrap_err();
652        assert!(matches!(err, OpenApiError::UnresolvedRef(_)));
653    }
654
655    #[test]
656    fn external_ref_produces_error() {
657        let input = r##"{
658            "openapi": "3.0.3",
659            "info": {"title": "T", "version": "1"},
660            "paths": {
661                "/things": {
662                    "get": {
663                        "parameters": [
664                            {"$ref": "https://example.com/params.yaml#/Limit"}
665                        ],
666                        "responses": {"200": {"description": "OK"}}
667                    }
668                }
669            }
670        }"##;
671        let err = OpenApiSpec::parse(input).unwrap_err();
672        assert!(matches!(err, OpenApiError::UnresolvedRef(_)));
673    }
674
675    #[test]
676    fn invalid_json_produces_error() {
677        let input = r##"{not valid json"##;
678        let err = OpenApiSpec::parse(input).unwrap_err();
679        assert!(matches!(err, OpenApiError::InvalidJson(_)));
680    }
681
682    #[test]
683    fn missing_title_defaults_to_untitled() {
684        let input = r##"{
685            "openapi": "3.0.3",
686            "info": {"version": "1"},
687            "paths": {}
688        }"##;
689        let spec = OpenApiSpec::parse(input).unwrap();
690        assert_eq!(spec.title, "Untitled API");
691    }
692
693    #[test]
694    fn missing_version_defaults_to_000() {
695        let input = r##"{
696            "openapi": "3.0.3",
697            "info": {"title": "T"},
698            "paths": {}
699        }"##;
700        let spec = OpenApiSpec::parse(input).unwrap();
701        assert_eq!(spec.api_version, "0.0.0");
702    }
703
704    #[test]
705    fn chio_extensions_extracted() {
706        let input = r##"{
707            "openapi": "3.0.3",
708            "info": { "title": "T", "version": "1" },
709            "paths": {
710                "/admin/reset": {
711                    "post": {
712                        "operationId": "resetSystem",
713                        "x-chio-sensitivity": "restricted",
714                        "x-chio-approval-required": true,
715                        "x-chio-side-effects": true,
716                        "responses": { "200": { "description": "OK" } }
717                    }
718                }
719            }
720        }"##;
721
722        let spec = OpenApiSpec::parse(input).unwrap();
723        let (_, item) = &spec.paths[0];
724        let op = &item.operations[0].1;
725        let ext = OpenApiSpec::extensions_for(op);
726        assert_eq!(
727            ext.sensitivity,
728            Some(crate::extensions::Sensitivity::Restricted)
729        );
730        assert_eq!(ext.approval_required, Some(true));
731        assert_eq!(ext.side_effects, Some(true));
732    }
733}