1use super::spec::{
4 MediaType, OpenApiSpec, Operation, Parameter, PathItem, RequestBody, Response, Schema, SchemaObject,
5};
6use crate::error::{CodegenError, Result};
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Fixture {
17 pub name: String,
18 pub description: String,
19
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub category: Option<String>,
22
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub handler: Option<FixtureHandler>,
25
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub streaming: Option<FixtureStreaming>,
28
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub background: Option<FixtureBackground>,
31
32 pub request: FixtureRequest,
33 pub expected_response: FixtureExpectedResponse,
34
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub tags: Option<Vec<String>>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct FixtureStreaming {
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub content_type: Option<String>,
44
45 pub chunks: Vec<FixtureStreamChunk>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct FixtureBackground {
51 pub state_path: String,
52 pub state_key: String,
53 pub value_field: String,
54 pub expected_state: Vec<Value>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum FixtureStreamChunk {
60 Text { value: String },
62 Bytes { base64: String },
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct FixtureHandler {
68 pub route: String,
69 pub method: String,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub parameters: Option<Value>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub body_schema: Option<Value>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub response_schema: Option<Value>,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub cors: Option<Value>,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub middleware: Option<Value>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub dependencies: Option<Value>,
89
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub handler_dependencies: Option<Value>,
93
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub route_overrides: Option<Value>,
97
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub injection_strategy: Option<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct FixtureRequest {
105 pub method: String,
106 pub path: String,
107
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub query_params: Option<HashMap<String, Value>>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub headers: Option<HashMap<String, String>>,
113
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub cookies: Option<HashMap<String, String>>,
116
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub body: Option<Value>,
119
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub data: Option<HashMap<String, Value>>,
122
123 #[serde(skip_serializing_if = "Option::is_none")]
124 pub form_data: Option<HashMap<String, Value>>,
125
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub files: Option<Vec<FixtureFile>>,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub content_type: Option<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct FixtureFile {
135 pub field_name: String,
136
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub filename: Option<String>,
139
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub content: Option<String>,
142
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub content_type: Option<String>,
145
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub content_encoding: Option<String>,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub magic_bytes: Option<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct FixtureExpectedResponse {
155 pub status_code: u16,
156
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub body: Option<Value>,
159
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub body_partial: Option<Value>,
162
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub headers: Option<HashMap<String, String>>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub validation_errors: Option<Vec<ValidationError>>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct ValidationError {
172 #[serde(rename = "type")]
173 pub error_type: String,
174 pub loc: Vec<String>,
175 pub msg: String,
176}
177
178#[derive(Debug, Clone)]
180pub struct OpenApiOptions {
181 pub title: String,
182 pub version: String,
183 pub description: Option<String>,
184}
185
186impl Default for OpenApiOptions {
187 fn default() -> Self {
188 Self {
189 title: "Generated API".to_string(),
190 version: "1.0.0".to_string(),
191 description: Some("API generated from test fixtures".to_string()),
192 }
193 }
194}
195
196pub fn fixtures_to_openapi(fixtures: &[Fixture], options: OpenApiOptions) -> Result<OpenApiSpec> {
202 let mut spec = OpenApiSpec::new(options.title, options.version);
203 spec.info.description = options.description;
204
205 let grouped = group_fixtures_by_route(fixtures);
206
207 for ((path, method), route_fixtures) in grouped {
208 let operation = build_operation(&route_fixtures, &method);
209
210 let path_item = spec.paths.entry(path.clone()).or_insert_with(|| PathItem {
211 get: None,
212 post: None,
213 put: None,
214 patch: None,
215 delete: None,
216 parameters: None,
217 });
218
219 match method.to_uppercase().as_str() {
220 "GET" => path_item.get = Some(operation),
221 "POST" => path_item.post = Some(operation),
222 "PUT" => path_item.put = Some(operation),
223 "PATCH" => path_item.patch = Some(operation),
224 "DELETE" => path_item.delete = Some(operation),
225 _ => {}
226 }
227 }
228
229 Ok(spec)
230}
231
232pub fn load_fixtures_from_dir(dir: &Path) -> Result<Vec<Fixture>> {
242 let mut fixtures = Vec::new();
243
244 if !dir.exists() {
245 return Ok(fixtures);
246 }
247
248 for entry in fs::read_dir(dir).map_err(CodegenError::IoError)? {
249 let entry = entry.map_err(CodegenError::IoError)?;
250 let path = entry.path();
251
252 if path.extension().is_none_or(|e| e != "json") {
253 continue;
254 }
255
256 let filename = path.file_name().unwrap().to_str().unwrap();
257 if filename.starts_with("00-") || filename == "schema.json" {
258 continue;
259 }
260
261 let content = fs::read_to_string(&path)?;
262 match serde_json::from_str::<Fixture>(&content) {
263 Ok(fixture) => fixtures.push(fixture),
264 Err(e) => {
265 eprintln!("Warning: Skipping {}: {}", path.display(), e);
266 }
267 }
268 }
269
270 Ok(fixtures)
271}
272
273fn group_fixtures_by_route(fixtures: &[Fixture]) -> HashMap<(String, String), Vec<Fixture>> {
275 let mut grouped: HashMap<(String, String), Vec<Fixture>> = HashMap::new();
276
277 for fixture in fixtures {
278 let path = fixture.request.path.clone();
279 let method = fixture.request.method.to_uppercase();
280
281 grouped.entry((path, method)).or_default().push(fixture.clone());
282 }
283
284 grouped
285}
286
287fn build_operation(fixtures: &[Fixture], method: &str) -> Operation {
289 let first = &fixtures[0];
290
291 let mut operation = Operation {
292 summary: Some(first.description.clone()),
293 description: None,
294 operation_id: Some(format!(
295 "{}_{}",
296 method.to_lowercase(),
297 sanitize_path(&first.request.path)
298 )),
299 parameters: None,
300 request_body: None,
301 responses: IndexMap::new(),
302 tags: first.tags.clone(),
303 };
304
305 if let Some(ref handler) = first.handler {
306 if let Some(ref params) = handler.parameters {
307 operation.parameters = Some(extract_parameters(params));
308 }
309
310 if let Some(ref body_schema) = handler.body_schema {
311 operation.request_body = Some(build_request_body(body_schema));
312 }
313 }
314
315 let mut responses = IndexMap::new();
316 for fixture in fixtures {
317 let status = fixture.expected_response.status_code.to_string();
318
319 if !responses.contains_key(&status) {
320 responses.insert(status.clone(), build_response(&fixture.expected_response));
321 }
322 }
323
324 operation.responses = responses;
325
326 operation
327}
328
329fn extract_parameters(params_schema: &Value) -> Vec<Parameter> {
331 let mut parameters = Vec::new();
332
333 if let Some(obj) = params_schema.as_object() {
334 if let Some(path_params) = obj.get("path").and_then(|v| v.as_object()) {
335 for (name, schema) in path_params {
336 parameters.push(Parameter {
337 name: name.clone(),
338 location: "path".to_string(),
339 description: schema.get("description").and_then(|v| v.as_str()).map(String::from),
340 required: Some(true),
341 schema: Some(json_to_schema(schema)),
342 });
343 }
344 }
345
346 if let Some(query_params) = obj.get("query").and_then(|v| v.as_object()) {
347 for (name, schema) in query_params {
348 parameters.push(Parameter {
349 name: name.clone(),
350 location: "query".to_string(),
351 description: schema.get("description").and_then(|v| v.as_str()).map(String::from),
352 required: schema.get("required").and_then(Value::as_bool),
353 schema: Some(json_to_schema(schema)),
354 });
355 }
356 }
357
358 if let Some(headers) = obj.get("headers").and_then(|v| v.as_object()) {
359 for (name, schema) in headers {
360 parameters.push(Parameter {
361 name: name.clone(),
362 location: "header".to_string(),
363 description: schema.get("description").and_then(|v| v.as_str()).map(String::from),
364 required: schema.get("required").and_then(Value::as_bool),
365 schema: Some(json_to_schema(schema)),
366 });
367 }
368 }
369
370 if let Some(cookies) = obj.get("cookies").and_then(|v| v.as_object()) {
371 for (name, schema) in cookies {
372 parameters.push(Parameter {
373 name: name.clone(),
374 location: "cookie".to_string(),
375 description: schema.get("description").and_then(|v| v.as_str()).map(String::from),
376 required: schema.get("required").and_then(Value::as_bool),
377 schema: Some(json_to_schema(schema)),
378 });
379 }
380 }
381 }
382
383 parameters
384}
385
386fn build_request_body(schema: &Value) -> RequestBody {
388 let mut content = IndexMap::new();
389
390 content.insert(
391 "application/json".to_string(),
392 MediaType {
393 schema: Some(json_to_schema(schema)),
394 example: None,
395 examples: None,
396 },
397 );
398
399 RequestBody {
400 description: None,
401 content,
402 required: Some(true),
403 }
404}
405
406fn build_response(expected: &FixtureExpectedResponse) -> Response {
408 let description = match expected.status_code {
409 200 => "Successful response",
410 201 => "Created successfully",
411 204 => "No content",
412 400 => "Bad request",
413 401 => "Unauthorized",
414 403 => "Forbidden",
415 404 => "Not found",
416 422 => "Validation error",
417 _ => "Response",
418 };
419
420 let mut response = Response {
421 description: description.to_string(),
422 content: None,
423 headers: None,
424 };
425
426 if expected.body.is_some() || expected.validation_errors.is_some() {
427 let mut content = IndexMap::new();
428
429 content.insert(
430 "application/json".to_string(),
431 MediaType {
432 schema: Some(Schema::Object(Box::new(SchemaObject {
433 schema_type: "object".to_string(),
434 properties: None,
435 required: None,
436 format: None,
437 items: None,
438 minimum: None,
439 maximum: None,
440 min_length: None,
441 max_length: None,
442 pattern: None,
443 description: None,
444 }))),
445 example: expected.body.clone(),
446 examples: None,
447 },
448 );
449
450 response.content = Some(content);
451 }
452
453 response
454}
455
456fn json_to_schema(json: &Value) -> Schema {
458 json.as_object().map_or_else(
459 || {
460 Schema::Object(Box::new(SchemaObject {
461 schema_type: "string".to_string(),
462 properties: None,
463 required: None,
464 format: None,
465 items: None,
466 minimum: None,
467 maximum: None,
468 min_length: None,
469 max_length: None,
470 pattern: None,
471 description: None,
472 }))
473 },
474 |obj| {
475 let schema_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("string").to_string();
476
477 Schema::Object(Box::new(SchemaObject {
478 schema_type,
479 properties: obj.get("properties").and_then(|v| {
480 v.as_object().map(|props| {
481 props
482 .iter()
483 .map(|(k, v)| (k.clone(), Box::new(json_to_schema(v))))
484 .collect()
485 })
486 }),
487 required: obj.get("required").and_then(|v| {
488 v.as_array()
489 .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
490 }),
491 format: obj.get("format").and_then(|v| v.as_str()).map(String::from),
492 items: obj.get("items").map(|v| Box::new(json_to_schema(v))),
493 minimum: obj.get("minimum").and_then(Value::as_f64),
494 maximum: obj.get("maximum").and_then(Value::as_f64),
495 min_length: obj
496 .get("minLength")
497 .and_then(Value::as_u64)
498 .map(|v| usize::try_from(v).unwrap_or(0)),
499 max_length: obj
500 .get("maxLength")
501 .and_then(Value::as_u64)
502 .map(|v| usize::try_from(v).unwrap_or(0)),
503 pattern: obj.get("pattern").and_then(|v| v.as_str()).map(String::from),
504 description: obj.get("description").and_then(|v| v.as_str()).map(String::from),
505 }))
506 },
507 )
508}
509
510fn sanitize_path(path: &str) -> String {
512 path.replace('/', "_")
513 .replace(['{', '}'], "")
514 .trim_matches('_')
515 .to_string()
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn test_sanitize_path() {
524 assert_eq!(sanitize_path("/users/{id}"), "users_id");
525 assert_eq!(sanitize_path("/api/v1/posts"), "api_v1_posts");
526 }
527}