Skip to main content

spikard_codegen/openapi/
from_fixtures.rs

1//! Convert test fixtures to `OpenAPI` 3.1 specifications
2
3use 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/// Test fixture structure (matching `testing_data`/*.json)
15#[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    /// Optional explicit content type for the stream (overrides headers)
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub content_type: Option<String>,
44
45    /// Stream chunks that will be yielded sequentially
46    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    /// UTF-8 text chunk
61    Text { value: String },
62    /// Arbitrary bytes encoded as base64 for portability
63    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    /// Dependency injection: app-level dependencies
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub dependencies: Option<Value>,
89
90    /// Dependency injection: dependencies required by this handler
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub handler_dependencies: Option<Value>,
93
94    /// Dependency injection: route-level dependency overrides
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub route_overrides: Option<Value>,
97
98    /// Dependency injection: injection strategy
99    #[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/// Options for `OpenAPI` generation
179#[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
196/// Convert test fixtures to `OpenAPI` 3.1 specification
197///
198/// # Errors
199///
200/// Returns an error if the fixtures are invalid or cannot be converted to an `OpenAPI` specification.
201pub 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
232/// Load fixtures from a directory
233///
234/// # Errors
235///
236/// Returns an error if the directory cannot be read or if a fixture file is invalid.
237///
238/// # Panics
239///
240/// Panics if a filename in the directory cannot be converted to a string.
241pub 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
273/// Group fixtures by (path, method)
274fn 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
287/// Build `OpenAPI` operation from fixtures
288fn 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
329/// Extract parameters from handler schema
330fn 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
386/// Build request body from schema
387fn 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
406/// Build response from expected response
407fn 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
456/// Convert JSON Schema to `OpenAPI` Schema
457fn 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
510/// Sanitize path for operation ID
511fn 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}