Skip to main content

dioxus_mdx/parser/
openapi_types.rs

1//! Internal type definitions for parsed OpenAPI specifications.
2//!
3//! These types provide a simplified view of OpenAPI specs for rendering.
4
5use serde_json::json;
6use std::collections::BTreeMap;
7
8/// Parsed OpenAPI specification.
9#[derive(Debug, Clone, PartialEq)]
10pub struct OpenApiSpec {
11    /// API info (title, version, description).
12    pub info: ApiInfo,
13    /// Server URLs.
14    pub servers: Vec<ApiServer>,
15    /// API operations grouped by tag.
16    pub operations: Vec<ApiOperation>,
17    /// Unique tags in order of appearance.
18    pub tags: Vec<ApiTag>,
19    /// Reusable schema definitions.
20    pub schemas: BTreeMap<String, SchemaDefinition>,
21}
22
23/// API metadata.
24#[derive(Debug, Clone, PartialEq, Default)]
25pub struct ApiInfo {
26    /// API title.
27    pub title: String,
28    /// API version.
29    pub version: String,
30    /// API description.
31    pub description: Option<String>,
32}
33
34/// Server configuration.
35#[derive(Debug, Clone, PartialEq)]
36pub struct ApiServer {
37    /// Server URL.
38    pub url: String,
39    /// Server description.
40    pub description: Option<String>,
41}
42
43/// Tag metadata.
44#[derive(Debug, Clone, PartialEq)]
45pub struct ApiTag {
46    /// Tag name.
47    pub name: String,
48    /// Tag description.
49    pub description: Option<String>,
50}
51
52/// HTTP method.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum HttpMethod {
56    Get,
57    Post,
58    Put,
59    Delete,
60    Patch,
61    Head,
62    Options,
63}
64
65impl HttpMethod {
66    /// Parse from string.
67    pub fn parse(s: &str) -> Option<Self> {
68        match s.to_lowercase().as_str() {
69            "get" => Some(Self::Get),
70            "post" => Some(Self::Post),
71            "put" => Some(Self::Put),
72            "delete" => Some(Self::Delete),
73            "patch" => Some(Self::Patch),
74            "head" => Some(Self::Head),
75            "options" => Some(Self::Options),
76            _ => None,
77        }
78    }
79
80    /// Convert to uppercase string.
81    pub fn as_str(&self) -> &'static str {
82        match self {
83            Self::Get => "GET",
84            Self::Post => "POST",
85            Self::Put => "PUT",
86            Self::Delete => "DELETE",
87            Self::Patch => "PATCH",
88            Self::Head => "HEAD",
89            Self::Options => "OPTIONS",
90        }
91    }
92
93    /// DaisyUI badge class for the method.
94    pub fn badge_class(&self) -> &'static str {
95        match self {
96            Self::Get => "badge-soft badge-success",
97            Self::Post => "badge-soft badge-primary",
98            Self::Put => "badge-soft badge-warning",
99            Self::Delete => "badge-soft badge-error",
100            Self::Patch => "badge-soft badge-info",
101            Self::Head => "badge-soft badge-ghost",
102            Self::Options => "badge-soft badge-ghost",
103        }
104    }
105
106    /// Tailwind background class for the method.
107    pub fn bg_class(&self) -> &'static str {
108        match self {
109            Self::Get => "bg-success/10 border-success/30 text-success",
110            Self::Post => "bg-primary/10 border-primary/30 text-primary",
111            Self::Put => "bg-warning/10 border-warning/30 text-warning",
112            Self::Delete => "bg-error/10 border-error/30 text-error",
113            Self::Patch => "bg-info/10 border-info/30 text-info",
114            Self::Head => "bg-base-300 border-base-content/20 text-base-content/70",
115            Self::Options => "bg-base-300 border-base-content/20 text-base-content/70",
116        }
117    }
118}
119
120/// API endpoint operation.
121#[derive(Debug, Clone, PartialEq)]
122pub struct ApiOperation {
123    /// Unique operation ID.
124    pub operation_id: Option<String>,
125    /// HTTP method.
126    pub method: HttpMethod,
127    /// URL path.
128    pub path: String,
129    /// Short summary.
130    pub summary: Option<String>,
131    /// Full description.
132    pub description: Option<String>,
133    /// Tags for grouping.
134    pub tags: Vec<String>,
135    /// Parameters (path, query, header).
136    pub parameters: Vec<ApiParameter>,
137    /// Request body.
138    pub request_body: Option<ApiRequestBody>,
139    /// Response definitions.
140    pub responses: Vec<ApiResponse>,
141    /// Whether the endpoint is deprecated.
142    pub deprecated: bool,
143}
144
145impl ApiOperation {
146    /// Generate a URL-friendly slug for this operation.
147    ///
148    /// Uses `operation_id` if present (camelCase → kebab-case), otherwise
149    /// falls back to `method-path` format.
150    pub fn slug(&self) -> String {
151        if let Some(op_id) = &self.operation_id {
152            slugify_operation_id(op_id)
153        } else {
154            // Fallback: method-path format
155            let path_slug = self
156                .path
157                .trim_matches('/')
158                .replace('/', "-")
159                .replace(['{', '}'], "");
160            format!("{}-{}", self.method.as_str().to_lowercase(), path_slug)
161        }
162    }
163
164    /// Generate a curl command for this endpoint.
165    pub fn generate_curl(&self, base_url: &str) -> String {
166        let mut parts = vec!["curl".to_string()];
167
168        // Method
169        if !matches!(self.method, HttpMethod::Get) {
170            parts.push(format!("-X {}", self.method.as_str()));
171        }
172
173        // Build URL with path params
174        let mut url = format!("{}{}", base_url.trim_end_matches('/'), self.path);
175        let mut query_parts = Vec::new();
176
177        for param in &self.parameters {
178            match param.location {
179                ParameterLocation::Path => {
180                    let placeholder = if let Some(schema) = &param.schema {
181                        let val = schema.generate_example_json(0);
182                        val.as_str()
183                            .map(|s| s.to_string())
184                            .unwrap_or_else(|| val.to_string())
185                    } else {
186                        format!("{{{}}}", param.name)
187                    };
188                    url = url.replace(&format!("{{{}}}", param.name), &placeholder);
189                }
190                ParameterLocation::Query => {
191                    if let Some(schema) = &param.schema {
192                        let val = schema.generate_example_json(0);
193                        let val_str = val
194                            .as_str()
195                            .map(|s| s.to_string())
196                            .unwrap_or_else(|| val.to_string());
197                        query_parts.push(format!("{}={}", param.name, val_str));
198                    }
199                }
200                _ => {}
201            }
202        }
203
204        if !query_parts.is_empty() {
205            url = format!("{}?{}", url, query_parts.join("&"));
206        }
207
208        parts.push(format!("\"{}\"", url));
209
210        // Content-Type header if there's a request body
211        if self.request_body.is_some() {
212            parts.push("-H \"Content-Type: application/json\"".to_string());
213        }
214
215        // Request body
216        if let Some(body) = &self.request_body {
217            for content in &body.content {
218                if content.media_type.contains("json") {
219                    if let Some(schema) = &content.schema {
220                        let example = schema.generate_example_json(0);
221                        if let Ok(pretty) = serde_json::to_string_pretty(&example) {
222                            parts.push(format!("-d '{}'", pretty));
223                        }
224                    }
225                    break;
226                }
227            }
228        }
229
230        parts.join(" \\\n  ")
231    }
232
233    /// Generate a response example from the first 2xx response.
234    ///
235    /// Returns `Some((status_code, pretty_json))` if a 2xx response with
236    /// content schema is found, `None` otherwise.
237    pub fn generate_response_example(&self) -> Option<(String, String)> {
238        for response in &self.responses {
239            if response.status_code.starts_with('2') {
240                for content in &response.content {
241                    if let Some(schema) = &content.schema {
242                        let example = schema.generate_example_json(0);
243                        if let Ok(pretty) = serde_json::to_string_pretty(&example) {
244                            return Some((response.status_code.clone(), pretty));
245                        }
246                    }
247                }
248            }
249        }
250        None
251    }
252}
253
254/// Convert a camelCase operation ID to kebab-case slug.
255fn slugify_operation_id(id: &str) -> String {
256    let mut result = String::new();
257    for (i, ch) in id.chars().enumerate() {
258        if ch.is_uppercase() && i > 0 {
259            result.push('-');
260        }
261        result.push(ch.to_lowercase().next().unwrap_or(ch));
262    }
263    result
264}
265
266/// Parameter location.
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268#[non_exhaustive]
269pub enum ParameterLocation {
270    Path,
271    Query,
272    Header,
273    Cookie,
274}
275
276impl ParameterLocation {
277    /// Parse from string.
278    pub fn parse(s: &str) -> Option<Self> {
279        match s.to_lowercase().as_str() {
280            "path" => Some(Self::Path),
281            "query" => Some(Self::Query),
282            "header" => Some(Self::Header),
283            "cookie" => Some(Self::Cookie),
284            _ => None,
285        }
286    }
287
288    /// Convert to string.
289    pub fn as_str(&self) -> &'static str {
290        match self {
291            Self::Path => "path",
292            Self::Query => "query",
293            Self::Header => "header",
294            Self::Cookie => "cookie",
295        }
296    }
297
298    /// Badge class for the location.
299    pub fn badge_class(&self) -> &'static str {
300        match self {
301            Self::Path => "badge-primary",
302            Self::Query => "badge-info",
303            Self::Header => "badge-warning",
304            Self::Cookie => "badge-secondary",
305        }
306    }
307}
308
309/// API parameter.
310#[derive(Debug, Clone, PartialEq)]
311pub struct ApiParameter {
312    /// Parameter name.
313    pub name: String,
314    /// Parameter location.
315    pub location: ParameterLocation,
316    /// Parameter description.
317    pub description: Option<String>,
318    /// Whether the parameter is required.
319    pub required: bool,
320    /// Whether the parameter is deprecated.
321    pub deprecated: bool,
322    /// Parameter schema.
323    pub schema: Option<SchemaDefinition>,
324    /// Example value.
325    pub example: Option<String>,
326}
327
328/// Request body definition.
329#[derive(Debug, Clone, PartialEq)]
330pub struct ApiRequestBody {
331    /// Description.
332    pub description: Option<String>,
333    /// Whether the body is required.
334    pub required: bool,
335    /// Content by media type.
336    pub content: Vec<MediaTypeContent>,
337}
338
339/// Content for a specific media type.
340#[derive(Debug, Clone, PartialEq)]
341pub struct MediaTypeContent {
342    /// Media type (e.g., "application/json").
343    pub media_type: String,
344    /// Schema for the content.
345    pub schema: Option<SchemaDefinition>,
346    /// Example value.
347    pub example: Option<String>,
348}
349
350/// API response definition.
351#[derive(Debug, Clone, PartialEq)]
352pub struct ApiResponse {
353    /// HTTP status code or "default".
354    pub status_code: String,
355    /// Response description.
356    pub description: String,
357    /// Content by media type.
358    pub content: Vec<MediaTypeContent>,
359}
360
361impl ApiResponse {
362    /// Get badge class based on status code.
363    pub fn status_badge_class(&self) -> &'static str {
364        match self.status_code.chars().next() {
365            Some('2') => "badge-success",
366            Some('3') => "badge-info",
367            Some('4') => "badge-warning",
368            Some('5') => "badge-error",
369            _ => "badge-ghost",
370        }
371    }
372}
373
374/// Schema type.
375#[derive(Debug, Clone, PartialEq)]
376#[non_exhaustive]
377pub enum SchemaType {
378    String,
379    Number,
380    Integer,
381    Boolean,
382    Array,
383    Object,
384    Null,
385    Any,
386}
387
388impl SchemaType {
389    /// Convert to string.
390    pub fn as_str(&self) -> &'static str {
391        match self {
392            Self::String => "string",
393            Self::Number => "number",
394            Self::Integer => "integer",
395            Self::Boolean => "boolean",
396            Self::Array => "array",
397            Self::Object => "object",
398            Self::Null => "null",
399            Self::Any => "any",
400        }
401    }
402}
403
404/// Schema definition for a type.
405#[derive(Debug, Clone, PartialEq)]
406pub struct SchemaDefinition {
407    /// Schema type.
408    pub schema_type: SchemaType,
409    /// Format (e.g., "int64", "email", "date-time").
410    pub format: Option<String>,
411    /// Description.
412    pub description: Option<String>,
413    /// For arrays, the item schema.
414    pub items: Option<Box<SchemaDefinition>>,
415    /// For objects, property schemas.
416    pub properties: BTreeMap<String, SchemaDefinition>,
417    /// Required property names.
418    pub required: Vec<String>,
419    /// Reference name (for $ref).
420    pub ref_name: Option<String>,
421    /// Enum values.
422    pub enum_values: Vec<String>,
423    /// Example value.
424    pub example: Option<String>,
425    /// Default value.
426    pub default: Option<String>,
427    /// Nullable flag.
428    pub nullable: bool,
429    /// Additional properties schema (for objects).
430    pub additional_properties: Option<Box<SchemaDefinition>>,
431    /// OneOf schemas.
432    pub one_of: Vec<SchemaDefinition>,
433    /// AnyOf schemas.
434    pub any_of: Vec<SchemaDefinition>,
435    /// AllOf schemas.
436    pub all_of: Vec<SchemaDefinition>,
437}
438
439impl Default for SchemaDefinition {
440    fn default() -> Self {
441        Self {
442            schema_type: SchemaType::Any,
443            format: None,
444            description: None,
445            items: None,
446            properties: BTreeMap::new(),
447            required: Vec::new(),
448            ref_name: None,
449            enum_values: Vec::new(),
450            example: None,
451            default: None,
452            nullable: false,
453            additional_properties: None,
454            one_of: Vec::new(),
455            any_of: Vec::new(),
456            all_of: Vec::new(),
457        }
458    }
459}
460
461impl SchemaDefinition {
462    /// Get a display type string (e.g., "string", "array`<User>`", "object").
463    pub fn display_type(&self) -> String {
464        if let Some(ref_name) = &self.ref_name {
465            return ref_name.clone();
466        }
467
468        match &self.schema_type {
469            SchemaType::Array => {
470                if let Some(items) = &self.items {
471                    format!("array<{}>", items.display_type())
472                } else {
473                    "array".to_string()
474                }
475            }
476            SchemaType::Object if !self.properties.is_empty() => "object".to_string(),
477            other => {
478                let mut s = other.as_str().to_string();
479                if let Some(format) = &self.format {
480                    s.push_str(&format!(" ({format})"));
481                }
482                s
483            }
484        }
485    }
486
487    /// Check if this is a complex type (object or array with object items).
488    pub fn is_complex(&self) -> bool {
489        matches!(self.schema_type, SchemaType::Object | SchemaType::Array)
490            || !self.one_of.is_empty()
491            || !self.any_of.is_empty()
492            || !self.all_of.is_empty()
493    }
494
495    /// Generate example JSON for this schema.
496    ///
497    /// Uses explicit `example` if present, otherwise generates placeholder values by type.
498    /// `depth` prevents infinite recursion from circular refs (max 5).
499    pub fn generate_example_json(&self, depth: usize) -> serde_json::Value {
500        if depth > 5 {
501            return json!({});
502        }
503
504        // Use explicit example if available
505        if let Some(example) = &self.example {
506            if let Ok(val) = serde_json::from_str(example) {
507                return val;
508            }
509            return json!(example);
510        }
511
512        match &self.schema_type {
513            SchemaType::String => {
514                if !self.enum_values.is_empty() {
515                    return json!(self.enum_values[0]);
516                }
517                match self.format.as_deref() {
518                    Some("uuid") => json!("550e8400-e29b-41d4-a716-446655440000"),
519                    Some("date-time") => json!("2024-01-15T09:30:00Z"),
520                    Some("date") => json!("2024-01-15"),
521                    Some("uri") | Some("url") => json!("https://example.com"),
522                    Some("email") => json!("user@example.com"),
523                    _ => json!("string"),
524                }
525            }
526            SchemaType::Integer => {
527                if let Some(default) = &self.default
528                    && let Ok(n) = default.parse::<i64>()
529                {
530                    return json!(n);
531                }
532                json!(0)
533            }
534            SchemaType::Number => json!(0.0),
535            SchemaType::Boolean => json!(true),
536            SchemaType::Array => {
537                if let Some(items) = &self.items {
538                    json!([items.generate_example_json(depth + 1)])
539                } else {
540                    json!([])
541                }
542            }
543            SchemaType::Object => {
544                if self.properties.is_empty() {
545                    return json!({});
546                }
547                let mut map = serde_json::Map::new();
548                for (name, prop) in &self.properties {
549                    map.insert(name.clone(), prop.generate_example_json(depth + 1));
550                }
551                serde_json::Value::Object(map)
552            }
553            SchemaType::Null => json!(null),
554            SchemaType::Any => json!("any"),
555        }
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    // The fixtures below are built by parsing a spec, which needs `openapi`.
563    #[cfg(feature = "openapi")]
564    use crate::parser::openapi_parser::parse_openapi;
565
566    #[cfg(feature = "openapi")]
567    const SPEC: &str = r#"
568openapi: "3.0.0"
569info:
570  title: Test API
571  version: "1.0.0"
572paths:
573  /users/{id}/posts:
574    post:
575      operationId: createUserPost
576      summary: Create post
577      parameters:
578        - name: id
579          in: path
580          required: true
581          schema:
582            type: string
583        - name: dryRun
584          in: query
585          schema:
586            type: boolean
587      requestBody:
588        content:
589          application/json:
590            schema:
591              type: object
592              properties:
593                title:
594                  type: string
595      responses:
596        "200":
597          description: OK
598  /health:
599    get:
600      summary: Health
601      responses:
602        "200":
603          description: OK
604"#;
605
606    #[cfg(feature = "openapi")]
607    fn find_op<'a>(spec: &'a OpenApiSpec, path: &str) -> &'a ApiOperation {
608        spec.operations.iter().find(|op| op.path == path).unwrap()
609    }
610
611    #[test]
612    #[cfg(feature = "openapi")]
613    fn slug_kebab_cases_operation_id() {
614        let spec = parse_openapi(SPEC).unwrap();
615        assert_eq!(
616            find_op(&spec, "/users/{id}/posts").slug(),
617            "create-user-post"
618        );
619    }
620
621    #[test]
622    #[cfg(feature = "openapi")]
623    fn slug_falls_back_to_method_path() {
624        let spec = parse_openapi(SPEC).unwrap();
625        assert_eq!(find_op(&spec, "/health").slug(), "get-health");
626    }
627
628    #[test]
629    #[cfg(feature = "openapi")]
630    fn generate_curl_includes_method_url_headers_and_body() {
631        let spec = parse_openapi(SPEC).unwrap();
632        let curl = find_op(&spec, "/users/{id}/posts").generate_curl("https://api.example.com/");
633        assert!(curl.starts_with("curl"));
634        assert!(curl.contains("-X POST"));
635        assert!(curl.contains("https://api.example.com/users/"));
636        assert!(curl.contains("dryRun="));
637        assert!(curl.contains("-H \"Content-Type: application/json\""));
638        assert!(curl.contains("-d '"));
639        assert!(curl.contains("\"title\""));
640    }
641
642    #[test]
643    #[cfg(feature = "openapi")]
644    fn generate_curl_omits_method_for_get() {
645        let spec = parse_openapi(SPEC).unwrap();
646        let curl = find_op(&spec, "/health").generate_curl("https://api.example.com");
647        assert!(!curl.contains("-X"));
648        assert!(curl.contains("https://api.example.com/health"));
649    }
650
651    #[test]
652    fn display_type_formats_arrays_refs_and_formats() {
653        let string_schema = SchemaDefinition {
654            schema_type: SchemaType::String,
655            ..Default::default()
656        };
657        let array = SchemaDefinition {
658            schema_type: SchemaType::Array,
659            items: Some(Box::new(string_schema.clone())),
660            ..Default::default()
661        };
662        assert_eq!(array.display_type(), "array<string>");
663
664        let reference = SchemaDefinition {
665            ref_name: Some("User".to_string()),
666            ..Default::default()
667        };
668        assert_eq!(reference.display_type(), "User");
669
670        let email = SchemaDefinition {
671            schema_type: SchemaType::String,
672            format: Some("email".to_string()),
673            ..Default::default()
674        };
675        assert_eq!(email.display_type(), "string (email)");
676    }
677
678    #[test]
679    fn generate_example_json_stops_at_depth_limit() {
680        let schema = SchemaDefinition::default();
681        assert_eq!(schema.generate_example_json(6), json!({}));
682    }
683
684    #[test]
685    fn generate_example_json_prefers_explicit_example() {
686        let schema = SchemaDefinition {
687            schema_type: SchemaType::Integer,
688            example: Some("42".to_string()),
689            ..Default::default()
690        };
691        assert_eq!(schema.generate_example_json(0), json!(42));
692    }
693}