Skip to main content

dioxus_mdx/parser/
openapi_parser.rs

1//! OpenAPI specification parser.
2//!
3//! Parses OpenAPI 3.0/3.1 YAML or JSON specs into internal types for rendering.
4
5use std::collections::BTreeMap;
6
7use openapiv3::{
8    OpenAPI, Operation, Parameter, ParameterSchemaOrContent, PathItem, ReferenceOr, RequestBody,
9    Response, Schema, SchemaKind, StatusCode, Type, VariantOrUnknownOrEmpty,
10};
11
12use super::openapi_types::*;
13
14/// Error type for OpenAPI parsing.
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub enum OpenApiError {
18    /// YAML/JSON parsing failed.
19    ParseError(String),
20    /// Invalid or unsupported spec structure.
21    InvalidSpec(String),
22}
23
24impl std::fmt::Display for OpenApiError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
28            Self::InvalidSpec(msg) => write!(f, "Invalid spec: {}", msg),
29        }
30    }
31}
32
33impl std::error::Error for OpenApiError {}
34
35/// Parse an OpenAPI specification from YAML or JSON content.
36pub fn parse_openapi(content: &str) -> Result<OpenApiSpec, OpenApiError> {
37    // Try YAML first, then JSON
38    let spec: OpenAPI = match serde_yaml::from_str(content) {
39        Ok(s) => s,
40        Err(yaml_err) => match serde_json::from_str(content) {
41            Ok(s) => s,
42            Err(json_err) => {
43                // Report the error for the format the content most likely is,
44                // so line/column info points at the real mistake.
45                let msg = if content.trim_start().starts_with(['{', '[']) {
46                    format!("JSON: {json_err}")
47                } else {
48                    format!("YAML: {yaml_err}")
49                };
50                return Err(OpenApiError::ParseError(msg));
51            }
52        },
53    };
54
55    Ok(transform_spec(&spec))
56}
57
58/// Transform an openapiv3 spec into our internal representation.
59fn transform_spec(spec: &OpenAPI) -> OpenApiSpec {
60    let info = ApiInfo {
61        title: spec.info.title.clone(),
62        version: spec.info.version.clone(),
63        description: spec.info.description.clone(),
64    };
65
66    let servers = spec
67        .servers
68        .iter()
69        .map(|s| ApiServer {
70            url: s.url.clone(),
71            description: s.description.clone(),
72        })
73        .collect();
74
75    let tags: Vec<ApiTag> = spec
76        .tags
77        .iter()
78        .map(|t| ApiTag {
79            name: t.name.clone(),
80            description: t.description.clone(),
81        })
82        .collect();
83
84    // Collect all operations from paths
85    let mut operations = Vec::new();
86    for (path, item) in &spec.paths.paths {
87        if let ReferenceOr::Item(path_item) = item {
88            extract_operations(path, path_item, spec, &mut operations);
89        }
90    }
91
92    // Extract schemas
93    let mut schemas = BTreeMap::new();
94    if let Some(components) = &spec.components {
95        for (name, schema_ref) in &components.schemas {
96            if let ReferenceOr::Item(schema) = schema_ref {
97                // Seed the cycle guard with this schema's own name so a direct
98                // self-reference is caught on the first hop.
99                let mut seen = vec![name.clone()];
100                schemas.insert(name.clone(), transform_schema(schema, spec, &mut seen));
101            }
102        }
103    }
104
105    OpenApiSpec {
106        info,
107        servers,
108        operations,
109        tags,
110        schemas,
111    }
112}
113
114/// Extract operations from a path item.
115fn extract_operations(
116    path: &str,
117    item: &PathItem,
118    spec: &OpenAPI,
119    operations: &mut Vec<ApiOperation>,
120) {
121    let methods = [
122        (HttpMethod::Get, &item.get),
123        (HttpMethod::Post, &item.post),
124        (HttpMethod::Put, &item.put),
125        (HttpMethod::Delete, &item.delete),
126        (HttpMethod::Patch, &item.patch),
127        (HttpMethod::Head, &item.head),
128        (HttpMethod::Options, &item.options),
129    ];
130
131    for (method, op_option) in methods {
132        if let Some(op) = op_option {
133            operations.push(transform_operation(
134                path,
135                method,
136                op,
137                &item.parameters,
138                spec,
139            ));
140        }
141    }
142}
143
144/// Transform an operation.
145fn transform_operation(
146    path: &str,
147    method: HttpMethod,
148    op: &Operation,
149    path_params: &[ReferenceOr<Parameter>],
150    spec: &OpenAPI,
151) -> ApiOperation {
152    // Combine path-level and operation-level parameters
153    let mut parameters: Vec<ApiParameter> = path_params
154        .iter()
155        .filter_map(|p| transform_parameter(p, spec))
156        .collect();
157
158    for param in &op.parameters {
159        if let Some(p) = transform_parameter(param, spec) {
160            // Don't add duplicates (operation params override path params)
161            if !parameters.iter().any(|existing| existing.name == p.name) {
162                parameters.push(p);
163            }
164        }
165    }
166
167    let request_body = op
168        .request_body
169        .as_ref()
170        .and_then(|rb| transform_request_body(rb, spec));
171
172    let responses = op
173        .responses
174        .responses
175        .iter()
176        .map(|(code, resp)| transform_response(code, resp, spec))
177        .collect();
178
179    ApiOperation {
180        operation_id: op.operation_id.clone(),
181        method,
182        path: path.to_string(),
183        summary: op.summary.clone(),
184        description: op.description.clone(),
185        tags: op.tags.clone(),
186        parameters,
187        request_body,
188        responses,
189        deprecated: op.deprecated,
190    }
191}
192
193/// Transform a parameter.
194fn transform_parameter(param_ref: &ReferenceOr<Parameter>, spec: &OpenAPI) -> Option<ApiParameter> {
195    let param = resolve_parameter(param_ref, spec)?;
196
197    let location = match &param.parameter_data_ref().format {
198        openapiv3::ParameterSchemaOrContent::Schema(_) => {
199            // Get location from the parameter kind
200            match param {
201                Parameter::Query { .. } => ParameterLocation::Query,
202                Parameter::Header { .. } => ParameterLocation::Header,
203                Parameter::Path { .. } => ParameterLocation::Path,
204                Parameter::Cookie { .. } => ParameterLocation::Cookie,
205            }
206        }
207        _ => return None,
208    };
209
210    let data = param.parameter_data_ref();
211    let schema = match &data.format {
212        ParameterSchemaOrContent::Schema(s) => {
213            Some(resolve_and_transform(s, spec, &mut Vec::new()))
214        }
215        _ => None,
216    };
217
218    Some(ApiParameter {
219        name: data.name.clone(),
220        location,
221        description: data.description.clone(),
222        required: data.required,
223        deprecated: data.deprecated.unwrap_or(false),
224        schema,
225        example: data.example.as_ref().map(format_json_value),
226    })
227}
228
229/// Resolve a parameter reference.
230fn resolve_parameter<'a>(
231    param_ref: &'a ReferenceOr<Parameter>,
232    spec: &'a OpenAPI,
233) -> Option<&'a Parameter> {
234    match param_ref {
235        ReferenceOr::Item(param) => Some(param),
236        ReferenceOr::Reference { reference } => {
237            let name = reference.strip_prefix("#/components/parameters/")?;
238            spec.components
239                .as_ref()?
240                .parameters
241                .get(name)
242                .and_then(|p| match p {
243                    ReferenceOr::Item(param) => Some(param),
244                    _ => None,
245                })
246        }
247    }
248}
249
250/// Transform a request body.
251fn transform_request_body(
252    rb_ref: &ReferenceOr<RequestBody>,
253    spec: &OpenAPI,
254) -> Option<ApiRequestBody> {
255    let rb = resolve_request_body(rb_ref, spec)?;
256
257    let content = rb
258        .content
259        .iter()
260        .map(|(media_type, media)| MediaTypeContent {
261            media_type: media_type.clone(),
262            schema: media
263                .schema
264                .as_ref()
265                .map(|s| resolve_and_transform(s, spec, &mut Vec::new())),
266            example: media.example.as_ref().map(format_json_value),
267        })
268        .collect();
269
270    Some(ApiRequestBody {
271        description: rb.description.clone(),
272        required: rb.required,
273        content,
274    })
275}
276
277/// Resolve a request body reference.
278fn resolve_request_body<'a>(
279    rb_ref: &'a ReferenceOr<RequestBody>,
280    spec: &'a OpenAPI,
281) -> Option<&'a RequestBody> {
282    match rb_ref {
283        ReferenceOr::Item(rb) => Some(rb),
284        ReferenceOr::Reference { reference } => {
285            let name = reference.strip_prefix("#/components/requestBodies/")?;
286            spec.components
287                .as_ref()?
288                .request_bodies
289                .get(name)
290                .and_then(|r| match r {
291                    ReferenceOr::Item(rb) => Some(rb),
292                    _ => None,
293                })
294        }
295    }
296}
297
298/// Transform a response.
299fn transform_response(
300    status_code: &StatusCode,
301    resp_ref: &ReferenceOr<Response>,
302    spec: &OpenAPI,
303) -> ApiResponse {
304    let status_str = match status_code {
305        StatusCode::Code(code) => code.to_string(),
306        StatusCode::Range(range) => format!("{}XX", range),
307    };
308
309    let resp = resolve_response(resp_ref, spec);
310
311    let (description, content) = if let Some(r) = resp {
312        let content = r
313            .content
314            .iter()
315            .map(|(media_type, media)| MediaTypeContent {
316                media_type: media_type.clone(),
317                schema: media
318                    .schema
319                    .as_ref()
320                    .map(|s| resolve_and_transform(s, spec, &mut Vec::new())),
321                example: media.example.as_ref().map(format_json_value),
322            })
323            .collect();
324        (r.description.clone(), content)
325    } else {
326        (String::new(), Vec::new())
327    };
328
329    ApiResponse {
330        status_code: status_str,
331        description,
332        content,
333    }
334}
335
336/// Resolve a response reference.
337fn resolve_response<'a>(
338    resp_ref: &'a ReferenceOr<Response>,
339    spec: &'a OpenAPI,
340) -> Option<&'a Response> {
341    match resp_ref {
342        ReferenceOr::Item(resp) => Some(resp),
343        ReferenceOr::Reference { reference } => {
344            let name = reference.strip_prefix("#/components/responses/")?;
345            spec.components
346                .as_ref()?
347                .responses
348                .get(name)
349                .and_then(|r| match r {
350                    ReferenceOr::Item(resp) => Some(resp),
351                    _ => None,
352                })
353        }
354    }
355}
356
357/// Lets one resolver serve both `ReferenceOr<Schema>` and `ReferenceOr<Box<Schema>>`.
358trait AsSchema {
359    fn as_schema(&self) -> &Schema;
360}
361
362impl AsSchema for Schema {
363    fn as_schema(&self) -> &Schema {
364        self
365    }
366}
367
368impl AsSchema for Box<Schema> {
369    fn as_schema(&self) -> &Schema {
370        self
371    }
372}
373
374/// Resolve a schema reference and transform it.
375///
376/// `seen` holds the component names currently being expanded. A reference back
377/// into that set resolves to a name-only stub, so a self-referential schema
378/// (`Node.children -> [Node]`) terminates instead of recursing until the stack
379/// overflows. Accepts both `Schema` and `Box<Schema>` references.
380fn resolve_and_transform<S: AsSchema>(
381    schema_ref: &ReferenceOr<S>,
382    spec: &OpenAPI,
383    seen: &mut Vec<String>,
384) -> SchemaDefinition {
385    match schema_ref {
386        ReferenceOr::Item(schema) => transform_schema(schema.as_schema(), spec, seen),
387        ReferenceOr::Reference { reference } => {
388            // Extract the reference name
389            let ref_name = reference
390                .strip_prefix("#/components/schemas/")
391                .map(|s| s.to_string());
392
393            // Already expanding this schema further up the stack - stop here
394            if let Some(name) = &ref_name
395                && seen.contains(name)
396            {
397                return SchemaDefinition {
398                    ref_name: ref_name.clone(),
399                    ..Default::default()
400                };
401            }
402
403            // Try to resolve the schema
404            let resolved = ref_name.as_ref().and_then(|name| {
405                spec.components
406                    .as_ref()?
407                    .schemas
408                    .get(name)
409                    .and_then(|s| match s {
410                        ReferenceOr::Item(schema) => Some(schema),
411                        _ => None,
412                    })
413            });
414
415            if let Some(schema) = resolved {
416                if let Some(name) = &ref_name {
417                    seen.push(name.clone());
418                }
419                let mut def = transform_schema(schema, spec, seen);
420                if ref_name.is_some() {
421                    seen.pop();
422                }
423                def.ref_name = ref_name;
424                def
425            } else {
426                SchemaDefinition {
427                    ref_name,
428                    ..Default::default()
429                }
430            }
431        }
432    }
433}
434
435/// Helper to extract format string from VariantOrUnknownOrEmpty.
436fn extract_format<T: std::fmt::Debug>(format: &VariantOrUnknownOrEmpty<T>) -> Option<String> {
437    match format {
438        VariantOrUnknownOrEmpty::Item(f) => Some(format!("{:?}", f).to_lowercase()),
439        VariantOrUnknownOrEmpty::Unknown(s) => Some(s.clone()),
440        VariantOrUnknownOrEmpty::Empty => None,
441    }
442}
443
444/// Transform a schema.
445fn transform_schema(schema: &Schema, spec: &OpenAPI, seen: &mut Vec<String>) -> SchemaDefinition {
446    let mut def = SchemaDefinition {
447        description: schema.schema_data.description.clone(),
448        example: schema.schema_data.example.as_ref().map(format_json_value),
449        default: schema.schema_data.default.as_ref().map(format_json_value),
450        nullable: schema.schema_data.nullable,
451        ..Default::default()
452    };
453
454    match &schema.schema_kind {
455        SchemaKind::Type(t) => match t {
456            Type::String(s) => {
457                def.schema_type = SchemaType::String;
458                def.format = extract_format(&s.format);
459                def.enum_values = s.enumeration.iter().filter_map(|v| v.clone()).collect();
460            }
461            Type::Number(n) => {
462                def.schema_type = SchemaType::Number;
463                def.format = extract_format(&n.format);
464            }
465            Type::Integer(i) => {
466                def.schema_type = SchemaType::Integer;
467                def.format = extract_format(&i.format);
468            }
469            Type::Boolean(_) => {
470                def.schema_type = SchemaType::Boolean;
471            }
472            Type::Array(a) => {
473                def.schema_type = SchemaType::Array;
474                if let Some(items) = &a.items {
475                    def.items = Some(Box::new(resolve_and_transform(items, spec, seen)));
476                }
477            }
478            Type::Object(o) => {
479                def.schema_type = SchemaType::Object;
480                def.required = o.required.clone();
481                for (name, prop) in &o.properties {
482                    let prop_schema = resolve_and_transform(prop, spec, seen);
483                    def.properties.insert(name.clone(), prop_schema);
484                }
485                if let Some(ap) = &o.additional_properties {
486                    match ap {
487                        openapiv3::AdditionalProperties::Any(true) => {
488                            def.additional_properties = Some(Box::new(SchemaDefinition::default()));
489                        }
490                        openapiv3::AdditionalProperties::Schema(s) => {
491                            def.additional_properties =
492                                Some(Box::new(resolve_and_transform(s, spec, seen)));
493                        }
494                        _ => {}
495                    }
496                }
497            }
498        },
499        SchemaKind::OneOf { one_of } => {
500            def.one_of = one_of
501                .iter()
502                .map(|s| resolve_and_transform(s, spec, seen))
503                .collect();
504        }
505        SchemaKind::AnyOf { any_of } => {
506            def.any_of = any_of
507                .iter()
508                .map(|s| resolve_and_transform(s, spec, seen))
509                .collect();
510        }
511        SchemaKind::AllOf { all_of } => {
512            def.all_of = all_of
513                .iter()
514                .map(|s| resolve_and_transform(s, spec, seen))
515                .collect();
516        }
517        SchemaKind::Not { .. } => {
518            // Not supported, treat as any
519        }
520        SchemaKind::Any(_) => {
521            // Already defaults to Any
522        }
523    }
524
525    def
526}
527
528/// Format a JSON value as a string.
529fn format_json_value(value: &serde_json::Value) -> String {
530    match value {
531        serde_json::Value::String(s) => s.clone(),
532        other => serde_json::to_string_pretty(other).unwrap_or_default(),
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn test_parse_simple_openapi() {
542        let yaml = r#"
543openapi: "3.0.0"
544info:
545  title: Test API
546  version: "1.0.0"
547  description: A test API
548paths:
549  /users:
550    get:
551      summary: List users
552      responses:
553        "200":
554          description: Success
555"#;
556        let spec = parse_openapi(yaml).unwrap();
557        assert_eq!(spec.info.title, "Test API");
558        assert_eq!(spec.info.version, "1.0.0");
559        assert_eq!(spec.operations.len(), 1);
560        assert_eq!(spec.operations[0].method, HttpMethod::Get);
561        assert_eq!(spec.operations[0].path, "/users");
562    }
563
564    #[test]
565    fn test_self_referential_schema_terminates() {
566        let yaml = r##"
567openapi: "3.0.0"
568info:
569  title: Tree API
570  version: "1.0.0"
571paths: {}
572components:
573  schemas:
574    Node:
575      type: object
576      properties:
577        name:
578          type: string
579        children:
580          type: array
581          items:
582            $ref: "#/components/schemas/Node"
583"##;
584        let spec = parse_openapi(yaml).unwrap();
585        let node = spec.schemas.get("Node").expect("Node schema");
586
587        // The recursive branch resolves to a name-only stub rather than
588        // expanding forever.
589        let children = node.properties.get("children").expect("children property");
590        let item = children.items.as_ref().expect("array items");
591        assert_eq!(item.ref_name.as_deref(), Some("Node"));
592        assert!(item.properties.is_empty());
593
594        // The non-recursive branch is still expanded normally.
595        assert_eq!(
596            node.properties.get("name").map(|p| p.schema_type.clone()),
597            Some(SchemaType::String)
598        );
599    }
600
601    #[test]
602    fn test_mutually_recursive_schemas_terminate() {
603        let yaml = r##"
604openapi: "3.0.0"
605info:
606  title: Loop API
607  version: "1.0.0"
608paths: {}
609components:
610  schemas:
611    A:
612      type: object
613      properties:
614        b:
615          $ref: "#/components/schemas/B"
616    B:
617      type: object
618      properties:
619        a:
620          $ref: "#/components/schemas/A"
621"##;
622        let spec = parse_openapi(yaml).unwrap();
623        let a = spec.schemas.get("A").expect("A schema");
624        let b_prop = a.properties.get("b").expect("b property");
625        assert_eq!(b_prop.ref_name.as_deref(), Some("B"));
626
627        // B was expanded once; its back-reference to A is the stub.
628        let a_prop = b_prop.properties.get("a").expect("a property");
629        assert_eq!(a_prop.ref_name.as_deref(), Some("A"));
630        assert!(a_prop.properties.is_empty());
631    }
632
633    #[test]
634    fn test_parse_with_parameters() {
635        let yaml = r#"
636openapi: "3.0.0"
637info:
638  title: Test API
639  version: "1.0.0"
640paths:
641  /users/{id}:
642    get:
643      summary: Get user
644      parameters:
645        - name: id
646          in: path
647          required: true
648          schema:
649            type: string
650        - name: include
651          in: query
652          schema:
653            type: string
654      responses:
655        "200":
656          description: Success
657"#;
658        let spec = parse_openapi(yaml).unwrap();
659        assert_eq!(spec.operations[0].parameters.len(), 2);
660        assert_eq!(spec.operations[0].parameters[0].name, "id");
661        assert_eq!(
662            spec.operations[0].parameters[0].location,
663            ParameterLocation::Path
664        );
665        assert!(spec.operations[0].parameters[0].required);
666    }
667
668    #[test]
669    fn test_parse_with_request_body() {
670        let yaml = r#"
671openapi: "3.0.0"
672info:
673  title: Test API
674  version: "1.0.0"
675paths:
676  /users:
677    post:
678      summary: Create user
679      requestBody:
680        required: true
681        content:
682          application/json:
683            schema:
684              type: object
685              properties:
686                name:
687                  type: string
688      responses:
689        "201":
690          description: Created
691"#;
692        let spec = parse_openapi(yaml).unwrap();
693        let rb = spec.operations[0].request_body.as_ref().unwrap();
694        assert!(rb.required);
695        assert_eq!(rb.content[0].media_type, "application/json");
696    }
697
698    #[test]
699    fn test_http_method_badge_class() {
700        assert_eq!(HttpMethod::Get.badge_class(), "badge-soft badge-success");
701        assert_eq!(HttpMethod::Post.badge_class(), "badge-soft badge-primary");
702        assert_eq!(HttpMethod::Delete.badge_class(), "badge-soft badge-error");
703    }
704}