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                schemas.insert(name.clone(), transform_schema(schema, spec));
97            }
98        }
99    }
100
101    OpenApiSpec {
102        info,
103        servers,
104        operations,
105        tags,
106        schemas,
107    }
108}
109
110/// Extract operations from a path item.
111fn extract_operations(
112    path: &str,
113    item: &PathItem,
114    spec: &OpenAPI,
115    operations: &mut Vec<ApiOperation>,
116) {
117    let methods = [
118        (HttpMethod::Get, &item.get),
119        (HttpMethod::Post, &item.post),
120        (HttpMethod::Put, &item.put),
121        (HttpMethod::Delete, &item.delete),
122        (HttpMethod::Patch, &item.patch),
123        (HttpMethod::Head, &item.head),
124        (HttpMethod::Options, &item.options),
125    ];
126
127    for (method, op_option) in methods {
128        if let Some(op) = op_option {
129            operations.push(transform_operation(
130                path,
131                method,
132                op,
133                &item.parameters,
134                spec,
135            ));
136        }
137    }
138}
139
140/// Transform an operation.
141fn transform_operation(
142    path: &str,
143    method: HttpMethod,
144    op: &Operation,
145    path_params: &[ReferenceOr<Parameter>],
146    spec: &OpenAPI,
147) -> ApiOperation {
148    // Combine path-level and operation-level parameters
149    let mut parameters: Vec<ApiParameter> = path_params
150        .iter()
151        .filter_map(|p| transform_parameter(p, spec))
152        .collect();
153
154    for param in &op.parameters {
155        if let Some(p) = transform_parameter(param, spec) {
156            // Don't add duplicates (operation params override path params)
157            if !parameters.iter().any(|existing| existing.name == p.name) {
158                parameters.push(p);
159            }
160        }
161    }
162
163    let request_body = op
164        .request_body
165        .as_ref()
166        .and_then(|rb| transform_request_body(rb, spec));
167
168    let responses = op
169        .responses
170        .responses
171        .iter()
172        .map(|(code, resp)| transform_response(code, resp, spec))
173        .collect();
174
175    ApiOperation {
176        operation_id: op.operation_id.clone(),
177        method,
178        path: path.to_string(),
179        summary: op.summary.clone(),
180        description: op.description.clone(),
181        tags: op.tags.clone(),
182        parameters,
183        request_body,
184        responses,
185        deprecated: op.deprecated,
186    }
187}
188
189/// Transform a parameter.
190fn transform_parameter(param_ref: &ReferenceOr<Parameter>, spec: &OpenAPI) -> Option<ApiParameter> {
191    let param = resolve_parameter(param_ref, spec)?;
192
193    let location = match &param.parameter_data_ref().format {
194        openapiv3::ParameterSchemaOrContent::Schema(_) => {
195            // Get location from the parameter kind
196            match param {
197                Parameter::Query { .. } => ParameterLocation::Query,
198                Parameter::Header { .. } => ParameterLocation::Header,
199                Parameter::Path { .. } => ParameterLocation::Path,
200                Parameter::Cookie { .. } => ParameterLocation::Cookie,
201            }
202        }
203        _ => return None,
204    };
205
206    let data = param.parameter_data_ref();
207    let schema = match &data.format {
208        ParameterSchemaOrContent::Schema(s) => Some(resolve_and_transform_schema(s, spec)),
209        _ => None,
210    };
211
212    Some(ApiParameter {
213        name: data.name.clone(),
214        location,
215        description: data.description.clone(),
216        required: data.required,
217        deprecated: data.deprecated.unwrap_or(false),
218        schema,
219        example: data.example.as_ref().map(format_json_value),
220    })
221}
222
223/// Resolve a parameter reference.
224fn resolve_parameter<'a>(
225    param_ref: &'a ReferenceOr<Parameter>,
226    spec: &'a OpenAPI,
227) -> Option<&'a Parameter> {
228    match param_ref {
229        ReferenceOr::Item(param) => Some(param),
230        ReferenceOr::Reference { reference } => {
231            let name = reference.strip_prefix("#/components/parameters/")?;
232            spec.components
233                .as_ref()?
234                .parameters
235                .get(name)
236                .and_then(|p| match p {
237                    ReferenceOr::Item(param) => Some(param),
238                    _ => None,
239                })
240        }
241    }
242}
243
244/// Transform a request body.
245fn transform_request_body(
246    rb_ref: &ReferenceOr<RequestBody>,
247    spec: &OpenAPI,
248) -> Option<ApiRequestBody> {
249    let rb = resolve_request_body(rb_ref, spec)?;
250
251    let content = rb
252        .content
253        .iter()
254        .map(|(media_type, media)| MediaTypeContent {
255            media_type: media_type.clone(),
256            schema: media
257                .schema
258                .as_ref()
259                .map(|s| resolve_and_transform_schema(s, spec)),
260            example: media.example.as_ref().map(format_json_value),
261        })
262        .collect();
263
264    Some(ApiRequestBody {
265        description: rb.description.clone(),
266        required: rb.required,
267        content,
268    })
269}
270
271/// Resolve a request body reference.
272fn resolve_request_body<'a>(
273    rb_ref: &'a ReferenceOr<RequestBody>,
274    spec: &'a OpenAPI,
275) -> Option<&'a RequestBody> {
276    match rb_ref {
277        ReferenceOr::Item(rb) => Some(rb),
278        ReferenceOr::Reference { reference } => {
279            let name = reference.strip_prefix("#/components/requestBodies/")?;
280            spec.components
281                .as_ref()?
282                .request_bodies
283                .get(name)
284                .and_then(|r| match r {
285                    ReferenceOr::Item(rb) => Some(rb),
286                    _ => None,
287                })
288        }
289    }
290}
291
292/// Transform a response.
293fn transform_response(
294    status_code: &StatusCode,
295    resp_ref: &ReferenceOr<Response>,
296    spec: &OpenAPI,
297) -> ApiResponse {
298    let status_str = match status_code {
299        StatusCode::Code(code) => code.to_string(),
300        StatusCode::Range(range) => format!("{}XX", range),
301    };
302
303    let resp = resolve_response(resp_ref, spec);
304
305    let (description, content) = if let Some(r) = resp {
306        let content = r
307            .content
308            .iter()
309            .map(|(media_type, media)| MediaTypeContent {
310                media_type: media_type.clone(),
311                schema: media
312                    .schema
313                    .as_ref()
314                    .map(|s| resolve_and_transform_schema(s, spec)),
315                example: media.example.as_ref().map(format_json_value),
316            })
317            .collect();
318        (r.description.clone(), content)
319    } else {
320        (String::new(), Vec::new())
321    };
322
323    ApiResponse {
324        status_code: status_str,
325        description,
326        content,
327    }
328}
329
330/// Resolve a response reference.
331fn resolve_response<'a>(
332    resp_ref: &'a ReferenceOr<Response>,
333    spec: &'a OpenAPI,
334) -> Option<&'a Response> {
335    match resp_ref {
336        ReferenceOr::Item(resp) => Some(resp),
337        ReferenceOr::Reference { reference } => {
338            let name = reference.strip_prefix("#/components/responses/")?;
339            spec.components
340                .as_ref()?
341                .responses
342                .get(name)
343                .and_then(|r| match r {
344                    ReferenceOr::Item(resp) => Some(resp),
345                    _ => None,
346                })
347        }
348    }
349}
350
351/// Resolve a schema reference and transform it.
352fn resolve_and_transform_schema(
353    schema_ref: &ReferenceOr<Schema>,
354    spec: &OpenAPI,
355) -> SchemaDefinition {
356    match schema_ref {
357        ReferenceOr::Item(schema) => transform_schema(schema, spec),
358        ReferenceOr::Reference { reference } => {
359            // Extract the reference name
360            let ref_name = reference
361                .strip_prefix("#/components/schemas/")
362                .map(|s| s.to_string());
363
364            // Try to resolve the schema
365            let resolved = ref_name.as_ref().and_then(|name| {
366                spec.components
367                    .as_ref()?
368                    .schemas
369                    .get(name)
370                    .and_then(|s| match s {
371                        ReferenceOr::Item(schema) => Some(schema),
372                        _ => None,
373                    })
374            });
375
376            if let Some(schema) = resolved {
377                let mut def = transform_schema(schema, spec);
378                def.ref_name = ref_name;
379                def
380            } else {
381                SchemaDefinition {
382                    ref_name,
383                    ..Default::default()
384                }
385            }
386        }
387    }
388}
389
390/// Resolve a boxed schema reference and transform it.
391fn resolve_and_transform_boxed_schema(
392    schema_ref: &ReferenceOr<Box<Schema>>,
393    spec: &OpenAPI,
394) -> SchemaDefinition {
395    match schema_ref {
396        ReferenceOr::Item(schema) => transform_schema(schema, spec),
397        ReferenceOr::Reference { reference } => {
398            // Extract the reference name
399            let ref_name = reference
400                .strip_prefix("#/components/schemas/")
401                .map(|s| s.to_string());
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                let mut def = transform_schema(schema, spec);
417                def.ref_name = ref_name;
418                def
419            } else {
420                SchemaDefinition {
421                    ref_name,
422                    ..Default::default()
423                }
424            }
425        }
426    }
427}
428
429/// Helper to extract format string from VariantOrUnknownOrEmpty.
430fn extract_format<T: std::fmt::Debug>(format: &VariantOrUnknownOrEmpty<T>) -> Option<String> {
431    match format {
432        VariantOrUnknownOrEmpty::Item(f) => Some(format!("{:?}", f).to_lowercase()),
433        VariantOrUnknownOrEmpty::Unknown(s) => Some(s.clone()),
434        VariantOrUnknownOrEmpty::Empty => None,
435    }
436}
437
438/// Transform a schema.
439fn transform_schema(schema: &Schema, spec: &OpenAPI) -> SchemaDefinition {
440    let mut def = SchemaDefinition {
441        description: schema.schema_data.description.clone(),
442        example: schema.schema_data.example.as_ref().map(format_json_value),
443        default: schema.schema_data.default.as_ref().map(format_json_value),
444        nullable: schema.schema_data.nullable,
445        ..Default::default()
446    };
447
448    match &schema.schema_kind {
449        SchemaKind::Type(t) => match t {
450            Type::String(s) => {
451                def.schema_type = SchemaType::String;
452                def.format = extract_format(&s.format);
453                def.enum_values = s.enumeration.iter().filter_map(|v| v.clone()).collect();
454            }
455            Type::Number(n) => {
456                def.schema_type = SchemaType::Number;
457                def.format = extract_format(&n.format);
458            }
459            Type::Integer(i) => {
460                def.schema_type = SchemaType::Integer;
461                def.format = extract_format(&i.format);
462            }
463            Type::Boolean(_) => {
464                def.schema_type = SchemaType::Boolean;
465            }
466            Type::Array(a) => {
467                def.schema_type = SchemaType::Array;
468                if let Some(items) = &a.items {
469                    def.items = Some(Box::new(resolve_and_transform_boxed_schema(items, spec)));
470                }
471            }
472            Type::Object(o) => {
473                def.schema_type = SchemaType::Object;
474                def.required = o.required.clone();
475                for (name, prop) in &o.properties {
476                    let prop_schema = resolve_and_transform_boxed_schema(prop, spec);
477                    def.properties.insert(name.clone(), prop_schema);
478                }
479                if let Some(ap) = &o.additional_properties {
480                    match ap {
481                        openapiv3::AdditionalProperties::Any(true) => {
482                            def.additional_properties = Some(Box::new(SchemaDefinition::default()));
483                        }
484                        openapiv3::AdditionalProperties::Schema(s) => {
485                            def.additional_properties =
486                                Some(Box::new(resolve_and_transform_schema(s, spec)));
487                        }
488                        _ => {}
489                    }
490                }
491            }
492        },
493        SchemaKind::OneOf { one_of } => {
494            def.one_of = one_of
495                .iter()
496                .map(|s| resolve_and_transform_schema(s, spec))
497                .collect();
498        }
499        SchemaKind::AnyOf { any_of } => {
500            def.any_of = any_of
501                .iter()
502                .map(|s| resolve_and_transform_schema(s, spec))
503                .collect();
504        }
505        SchemaKind::AllOf { all_of } => {
506            def.all_of = all_of
507                .iter()
508                .map(|s| resolve_and_transform_schema(s, spec))
509                .collect();
510        }
511        SchemaKind::Not { .. } => {
512            // Not supported, treat as any
513        }
514        SchemaKind::Any(_) => {
515            // Already defaults to Any
516        }
517    }
518
519    def
520}
521
522/// Format a JSON value as a string.
523fn format_json_value(value: &serde_json::Value) -> String {
524    match value {
525        serde_json::Value::String(s) => s.clone(),
526        other => serde_json::to_string_pretty(other).unwrap_or_default(),
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn test_parse_simple_openapi() {
536        let yaml = r#"
537openapi: "3.0.0"
538info:
539  title: Test API
540  version: "1.0.0"
541  description: A test API
542paths:
543  /users:
544    get:
545      summary: List users
546      responses:
547        "200":
548          description: Success
549"#;
550        let spec = parse_openapi(yaml).unwrap();
551        assert_eq!(spec.info.title, "Test API");
552        assert_eq!(spec.info.version, "1.0.0");
553        assert_eq!(spec.operations.len(), 1);
554        assert_eq!(spec.operations[0].method, HttpMethod::Get);
555        assert_eq!(spec.operations[0].path, "/users");
556    }
557
558    #[test]
559    fn test_parse_with_parameters() {
560        let yaml = r#"
561openapi: "3.0.0"
562info:
563  title: Test API
564  version: "1.0.0"
565paths:
566  /users/{id}:
567    get:
568      summary: Get user
569      parameters:
570        - name: id
571          in: path
572          required: true
573          schema:
574            type: string
575        - name: include
576          in: query
577          schema:
578            type: string
579      responses:
580        "200":
581          description: Success
582"#;
583        let spec = parse_openapi(yaml).unwrap();
584        assert_eq!(spec.operations[0].parameters.len(), 2);
585        assert_eq!(spec.operations[0].parameters[0].name, "id");
586        assert_eq!(
587            spec.operations[0].parameters[0].location,
588            ParameterLocation::Path
589        );
590        assert!(spec.operations[0].parameters[0].required);
591    }
592
593    #[test]
594    fn test_parse_with_request_body() {
595        let yaml = r#"
596openapi: "3.0.0"
597info:
598  title: Test API
599  version: "1.0.0"
600paths:
601  /users:
602    post:
603      summary: Create user
604      requestBody:
605        required: true
606        content:
607          application/json:
608            schema:
609              type: object
610              properties:
611                name:
612                  type: string
613      responses:
614        "201":
615          description: Created
616"#;
617        let spec = parse_openapi(yaml).unwrap();
618        let rb = spec.operations[0].request_body.as_ref().unwrap();
619        assert!(rb.required);
620        assert_eq!(rb.content[0].media_type, "application/json");
621    }
622
623    #[test]
624    fn test_http_method_badge_class() {
625        assert_eq!(HttpMethod::Get.badge_class(), "badge-soft badge-success");
626        assert_eq!(HttpMethod::Post.badge_class(), "badge-soft badge-primary");
627        assert_eq!(HttpMethod::Delete.badge_class(), "badge-soft badge-error");
628    }
629}