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