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)]
54pub enum HttpMethod {
55 Get,
56 Post,
57 Put,
58 Delete,
59 Patch,
60 Head,
61 Options,
62}
63
64impl HttpMethod {
65 pub fn parse(s: &str) -> Option<Self> {
67 match s.to_lowercase().as_str() {
68 "get" => Some(Self::Get),
69 "post" => Some(Self::Post),
70 "put" => Some(Self::Put),
71 "delete" => Some(Self::Delete),
72 "patch" => Some(Self::Patch),
73 "head" => Some(Self::Head),
74 "options" => Some(Self::Options),
75 _ => None,
76 }
77 }
78
79 pub fn as_str(&self) -> &'static str {
81 match self {
82 Self::Get => "GET",
83 Self::Post => "POST",
84 Self::Put => "PUT",
85 Self::Delete => "DELETE",
86 Self::Patch => "PATCH",
87 Self::Head => "HEAD",
88 Self::Options => "OPTIONS",
89 }
90 }
91
92 pub fn badge_class(&self) -> &'static str {
94 match self {
95 Self::Get => "badge-soft badge-success",
96 Self::Post => "badge-soft badge-primary",
97 Self::Put => "badge-soft badge-warning",
98 Self::Delete => "badge-soft badge-error",
99 Self::Patch => "badge-soft badge-info",
100 Self::Head => "badge-soft badge-ghost",
101 Self::Options => "badge-soft badge-ghost",
102 }
103 }
104
105 pub fn bg_class(&self) -> &'static str {
107 match self {
108 Self::Get => "bg-success/10 border-success/30 text-success",
109 Self::Post => "bg-primary/10 border-primary/30 text-primary",
110 Self::Put => "bg-warning/10 border-warning/30 text-warning",
111 Self::Delete => "bg-error/10 border-error/30 text-error",
112 Self::Patch => "bg-info/10 border-info/30 text-info",
113 Self::Head => "bg-base-300 border-base-content/20 text-base-content/70",
114 Self::Options => "bg-base-300 border-base-content/20 text-base-content/70",
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub struct ApiOperation {
122 pub operation_id: Option<String>,
124 pub method: HttpMethod,
126 pub path: String,
128 pub summary: Option<String>,
130 pub description: Option<String>,
132 pub tags: Vec<String>,
134 pub parameters: Vec<ApiParameter>,
136 pub request_body: Option<ApiRequestBody>,
138 pub responses: Vec<ApiResponse>,
140 pub deprecated: bool,
142}
143
144impl ApiOperation {
145 pub fn slug(&self) -> String {
150 if let Some(op_id) = &self.operation_id {
151 slugify_operation_id(op_id)
152 } else {
153 let path_slug = self
155 .path
156 .trim_matches('/')
157 .replace('/', "-")
158 .replace(['{', '}'], "");
159 format!("{}-{}", self.method.as_str().to_lowercase(), path_slug)
160 }
161 }
162
163 pub fn generate_curl(&self, base_url: &str) -> String {
165 let mut parts = vec!["curl".to_string()];
166
167 if !matches!(self.method, HttpMethod::Get) {
169 parts.push(format!("-X {}", self.method.as_str()));
170 }
171
172 let mut url = format!("{}{}", base_url.trim_end_matches('/'), self.path);
174 let mut query_parts = Vec::new();
175
176 for param in &self.parameters {
177 match param.location {
178 ParameterLocation::Path => {
179 let placeholder = if let Some(schema) = ¶m.schema {
180 let val = schema.generate_example_json(0);
181 val.as_str()
182 .map(|s| s.to_string())
183 .unwrap_or_else(|| val.to_string())
184 } else {
185 format!("{{{}}}", param.name)
186 };
187 url = url.replace(&format!("{{{}}}", param.name), &placeholder);
188 }
189 ParameterLocation::Query => {
190 if let Some(schema) = ¶m.schema {
191 let val = schema.generate_example_json(0);
192 let val_str = val
193 .as_str()
194 .map(|s| s.to_string())
195 .unwrap_or_else(|| val.to_string());
196 query_parts.push(format!("{}={}", param.name, val_str));
197 }
198 }
199 _ => {}
200 }
201 }
202
203 if !query_parts.is_empty() {
204 url = format!("{}?{}", url, query_parts.join("&"));
205 }
206
207 parts.push(format!("\"{}\"", url));
208
209 if self.request_body.is_some() {
211 parts.push("-H \"Content-Type: application/json\"".to_string());
212 }
213
214 if let Some(body) = &self.request_body {
216 for content in &body.content {
217 if content.media_type.contains("json") {
218 if let Some(schema) = &content.schema {
219 let example = schema.generate_example_json(0);
220 if let Ok(pretty) = serde_json::to_string_pretty(&example) {
221 parts.push(format!("-d '{}'", pretty));
222 }
223 }
224 break;
225 }
226 }
227 }
228
229 parts.join(" \\\n ")
230 }
231
232 pub fn generate_response_example(&self) -> Option<(String, String)> {
237 for response in &self.responses {
238 if response.status_code.starts_with('2') {
239 for content in &response.content {
240 if let Some(schema) = &content.schema {
241 let example = schema.generate_example_json(0);
242 if let Ok(pretty) = serde_json::to_string_pretty(&example) {
243 return Some((response.status_code.clone(), pretty));
244 }
245 }
246 }
247 }
248 }
249 None
250 }
251}
252
253fn slugify_operation_id(id: &str) -> String {
255 let mut result = String::new();
256 for (i, ch) in id.chars().enumerate() {
257 if ch.is_uppercase() && i > 0 {
258 result.push('-');
259 }
260 result.push(ch.to_lowercase().next().unwrap_or(ch));
261 }
262 result
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum ParameterLocation {
268 Path,
269 Query,
270 Header,
271 Cookie,
272}
273
274impl ParameterLocation {
275 pub fn parse(s: &str) -> Option<Self> {
277 match s.to_lowercase().as_str() {
278 "path" => Some(Self::Path),
279 "query" => Some(Self::Query),
280 "header" => Some(Self::Header),
281 "cookie" => Some(Self::Cookie),
282 _ => None,
283 }
284 }
285
286 pub fn as_str(&self) -> &'static str {
288 match self {
289 Self::Path => "path",
290 Self::Query => "query",
291 Self::Header => "header",
292 Self::Cookie => "cookie",
293 }
294 }
295
296 pub fn badge_class(&self) -> &'static str {
298 match self {
299 Self::Path => "badge-primary",
300 Self::Query => "badge-info",
301 Self::Header => "badge-warning",
302 Self::Cookie => "badge-secondary",
303 }
304 }
305}
306
307#[derive(Debug, Clone, PartialEq)]
309pub struct ApiParameter {
310 pub name: String,
312 pub location: ParameterLocation,
314 pub description: Option<String>,
316 pub required: bool,
318 pub deprecated: bool,
320 pub schema: Option<SchemaDefinition>,
322 pub example: Option<String>,
324}
325
326#[derive(Debug, Clone, PartialEq)]
328pub struct ApiRequestBody {
329 pub description: Option<String>,
331 pub required: bool,
333 pub content: Vec<MediaTypeContent>,
335}
336
337#[derive(Debug, Clone, PartialEq)]
339pub struct MediaTypeContent {
340 pub media_type: String,
342 pub schema: Option<SchemaDefinition>,
344 pub example: Option<String>,
346}
347
348#[derive(Debug, Clone, PartialEq)]
350pub struct ApiResponse {
351 pub status_code: String,
353 pub description: String,
355 pub content: Vec<MediaTypeContent>,
357}
358
359impl ApiResponse {
360 pub fn status_badge_class(&self) -> &'static str {
362 match self.status_code.chars().next() {
363 Some('2') => "badge-success",
364 Some('3') => "badge-info",
365 Some('4') => "badge-warning",
366 Some('5') => "badge-error",
367 _ => "badge-ghost",
368 }
369 }
370}
371
372#[derive(Debug, Clone, PartialEq)]
374pub enum SchemaType {
375 String,
376 Number,
377 Integer,
378 Boolean,
379 Array,
380 Object,
381 Null,
382 Any,
383}
384
385impl SchemaType {
386 pub fn as_str(&self) -> &'static str {
388 match self {
389 Self::String => "string",
390 Self::Number => "number",
391 Self::Integer => "integer",
392 Self::Boolean => "boolean",
393 Self::Array => "array",
394 Self::Object => "object",
395 Self::Null => "null",
396 Self::Any => "any",
397 }
398 }
399}
400
401#[derive(Debug, Clone, PartialEq)]
403pub struct SchemaDefinition {
404 pub schema_type: SchemaType,
406 pub format: Option<String>,
408 pub description: Option<String>,
410 pub items: Option<Box<SchemaDefinition>>,
412 pub properties: BTreeMap<String, SchemaDefinition>,
414 pub required: Vec<String>,
416 pub ref_name: Option<String>,
418 pub enum_values: Vec<String>,
420 pub example: Option<String>,
422 pub default: Option<String>,
424 pub nullable: bool,
426 pub additional_properties: Option<Box<SchemaDefinition>>,
428 pub one_of: Vec<SchemaDefinition>,
430 pub any_of: Vec<SchemaDefinition>,
432 pub all_of: Vec<SchemaDefinition>,
434}
435
436impl Default for SchemaDefinition {
437 fn default() -> Self {
438 Self {
439 schema_type: SchemaType::Any,
440 format: None,
441 description: None,
442 items: None,
443 properties: BTreeMap::new(),
444 required: Vec::new(),
445 ref_name: None,
446 enum_values: Vec::new(),
447 example: None,
448 default: None,
449 nullable: false,
450 additional_properties: None,
451 one_of: Vec::new(),
452 any_of: Vec::new(),
453 all_of: Vec::new(),
454 }
455 }
456}
457
458impl SchemaDefinition {
459 pub fn display_type(&self) -> String {
461 if let Some(ref_name) = &self.ref_name {
462 return ref_name.clone();
463 }
464
465 match &self.schema_type {
466 SchemaType::Array => {
467 if let Some(items) = &self.items {
468 format!("array<{}>", items.display_type())
469 } else {
470 "array".to_string()
471 }
472 }
473 SchemaType::Object if !self.properties.is_empty() => "object".to_string(),
474 other => {
475 let mut s = other.as_str().to_string();
476 if let Some(format) = &self.format {
477 s.push_str(&format!(" ({format})"));
478 }
479 s
480 }
481 }
482 }
483
484 pub fn is_complex(&self) -> bool {
486 matches!(self.schema_type, SchemaType::Object | SchemaType::Array)
487 || !self.one_of.is_empty()
488 || !self.any_of.is_empty()
489 || !self.all_of.is_empty()
490 }
491
492 pub fn generate_example_json(&self, depth: usize) -> serde_json::Value {
497 if depth > 5 {
498 return json!({});
499 }
500
501 if let Some(example) = &self.example {
503 if let Ok(val) = serde_json::from_str(example) {
504 return val;
505 }
506 return json!(example);
507 }
508
509 match &self.schema_type {
510 SchemaType::String => {
511 if !self.enum_values.is_empty() {
512 return json!(self.enum_values[0]);
513 }
514 match self.format.as_deref() {
515 Some("uuid") => json!("550e8400-e29b-41d4-a716-446655440000"),
516 Some("date-time") => json!("2024-01-15T09:30:00Z"),
517 Some("date") => json!("2024-01-15"),
518 Some("uri") | Some("url") => json!("https://example.com"),
519 Some("email") => json!("user@example.com"),
520 _ => json!("string"),
521 }
522 }
523 SchemaType::Integer => {
524 if let Some(default) = &self.default
525 && let Ok(n) = default.parse::<i64>()
526 {
527 return json!(n);
528 }
529 json!(0)
530 }
531 SchemaType::Number => json!(0.0),
532 SchemaType::Boolean => json!(true),
533 SchemaType::Array => {
534 if let Some(items) = &self.items {
535 json!([items.generate_example_json(depth + 1)])
536 } else {
537 json!([])
538 }
539 }
540 SchemaType::Object => {
541 if self.properties.is_empty() {
542 return json!({});
543 }
544 let mut map = serde_json::Map::new();
545 for (name, prop) in &self.properties {
546 map.insert(name.clone(), prop.generate_example_json(depth + 1));
547 }
548 serde_json::Value::Object(map)
549 }
550 SchemaType::Null => json!(null),
551 SchemaType::Any => json!("any"),
552 }
553 }
554}