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