1use serde_json::json;
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, PartialEq)]
10pub struct OpenApiSpec {
11 pub info: ApiInfo,
13 pub servers: Vec<ApiServer>,
15 pub operations: Vec<ApiOperation>,
17 pub tags: Vec<ApiTag>,
19 pub schemas: BTreeMap<String, SchemaDefinition>,
21}
22
23#[derive(Debug, Clone, PartialEq, Default)]
25pub struct ApiInfo {
26 pub title: String,
28 pub version: String,
30 pub description: Option<String>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct ApiServer {
37 pub url: String,
39 pub description: Option<String>,
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub struct ApiTag {
46 pub name: String,
48 pub description: Option<String>,
50}
51
52#[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 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 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 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 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#[derive(Debug, Clone, PartialEq)]
122pub struct ApiOperation {
123 pub operation_id: Option<String>,
125 pub method: HttpMethod,
127 pub path: String,
129 pub summary: Option<String>,
131 pub description: Option<String>,
133 pub tags: Vec<String>,
135 pub parameters: Vec<ApiParameter>,
137 pub request_body: Option<ApiRequestBody>,
139 pub responses: Vec<ApiResponse>,
141 pub deprecated: bool,
143}
144
145impl ApiOperation {
146 pub fn slug(&self) -> String {
151 if let Some(op_id) = &self.operation_id {
152 slugify_operation_id(op_id)
153 } else {
154 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 pub fn generate_curl(&self, base_url: &str) -> String {
166 let mut parts = vec!["curl".to_string()];
167
168 if !matches!(self.method, HttpMethod::Get) {
170 parts.push(format!("-X {}", self.method.as_str()));
171 }
172
173 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) = ¶m.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) = ¶m.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 if self.request_body.is_some() {
212 parts.push("-H \"Content-Type: application/json\"".to_string());
213 }
214
215 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 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
254fn 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#[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 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 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 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#[derive(Debug, Clone, PartialEq)]
311pub struct ApiParameter {
312 pub name: String,
314 pub location: ParameterLocation,
316 pub description: Option<String>,
318 pub required: bool,
320 pub deprecated: bool,
322 pub schema: Option<SchemaDefinition>,
324 pub example: Option<String>,
326}
327
328#[derive(Debug, Clone, PartialEq)]
330pub struct ApiRequestBody {
331 pub description: Option<String>,
333 pub required: bool,
335 pub content: Vec<MediaTypeContent>,
337}
338
339#[derive(Debug, Clone, PartialEq)]
341pub struct MediaTypeContent {
342 pub media_type: String,
344 pub schema: Option<SchemaDefinition>,
346 pub example: Option<String>,
348}
349
350#[derive(Debug, Clone, PartialEq)]
352pub struct ApiResponse {
353 pub status_code: String,
355 pub description: String,
357 pub content: Vec<MediaTypeContent>,
359}
360
361impl ApiResponse {
362 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#[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 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#[derive(Debug, Clone, PartialEq)]
406pub struct SchemaDefinition {
407 pub schema_type: SchemaType,
409 pub format: Option<String>,
411 pub description: Option<String>,
413 pub items: Option<Box<SchemaDefinition>>,
415 pub properties: BTreeMap<String, SchemaDefinition>,
417 pub required: Vec<String>,
419 pub ref_name: Option<String>,
421 pub enum_values: Vec<String>,
423 pub example: Option<String>,
425 pub default: Option<String>,
427 pub nullable: bool,
429 pub additional_properties: Option<Box<SchemaDefinition>>,
431 pub one_of: Vec<SchemaDefinition>,
433 pub any_of: Vec<SchemaDefinition>,
435 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 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 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 pub fn generate_example_json(&self, depth: usize) -> serde_json::Value {
500 if depth > 5 {
501 return json!({});
502 }
503
504 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 #[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}