mockforge_http/
spec_import.rs

1//! Specification Import API
2//!
3//! Provides REST endpoints for importing OpenAPI and AsyncAPI specifications
4//! and automatically generating mock endpoints.
5
6use axum::{
7    extract::{Multipart, Query, State},
8    http::StatusCode,
9    response::Json,
10    routing::{delete, get, post},
11    Router,
12};
13use mockforge_core::import::asyncapi_import::{import_asyncapi_spec, AsyncApiImportResult};
14use mockforge_core::import::openapi_import::{import_openapi_spec, OpenApiImportResult};
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::sync::Arc;
18use tokio::sync::RwLock;
19use tracing::*;
20
21/// Specification metadata
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct SpecMetadata {
24    pub id: String,
25    pub name: String,
26    pub spec_type: SpecType,
27    pub version: String,
28    pub description: Option<String>,
29    pub servers: Vec<String>,
30    pub uploaded_at: String,
31    pub route_count: usize,
32}
33
34/// Specification type
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36#[serde(rename_all = "lowercase")]
37pub enum SpecType {
38    OpenApi,
39    AsyncApi,
40}
41
42/// Import request body
43#[derive(Debug, Serialize, Deserialize)]
44pub struct ImportSpecRequest {
45    pub spec_content: String,
46    pub spec_type: Option<SpecType>,
47    pub name: Option<String>,
48    pub base_url: Option<String>,
49    pub auto_generate_mocks: Option<bool>,
50}
51
52/// Import response
53#[derive(Debug, Serialize)]
54pub struct ImportSpecResponse {
55    pub spec_id: String,
56    pub spec_type: SpecType,
57    pub routes_generated: usize,
58    pub warnings: Vec<String>,
59    pub coverage: CoverageStats,
60}
61
62/// Coverage statistics
63#[derive(Debug, Serialize)]
64pub struct CoverageStats {
65    pub total_endpoints: usize,
66    pub mocked_endpoints: usize,
67    pub coverage_percentage: u32,
68    pub by_method: HashMap<String, usize>,
69}
70
71/// List specs query parameters
72#[derive(Debug, Deserialize)]
73pub struct ListSpecsQuery {
74    pub spec_type: Option<SpecType>,
75    pub limit: Option<usize>,
76    pub offset: Option<usize>,
77}
78
79/// Shared state for spec import API
80#[derive(Clone)]
81pub struct SpecImportState {
82    pub specs: Arc<RwLock<HashMap<String, StoredSpec>>>,
83}
84
85/// Stored specification
86#[derive(Debug, Clone)]
87pub struct StoredSpec {
88    pub metadata: SpecMetadata,
89    pub content: String,
90    pub routes_json: String, // Serialized routes/channels as JSON
91}
92
93impl SpecImportState {
94    pub fn new() -> Self {
95        Self {
96            specs: Arc::new(RwLock::new(HashMap::new())),
97        }
98    }
99}
100
101impl Default for SpecImportState {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107/// Create spec import router
108pub fn spec_import_router(state: SpecImportState) -> Router {
109    Router::new()
110        .route("/specs", post(import_spec))
111        .route("/specs", get(list_specs))
112        .route("/specs/{id}", get(get_spec))
113        .route("/specs/{id}", delete(delete_spec))
114        .route("/specs/{id}/coverage", get(get_spec_coverage))
115        .route("/specs/{id}/routes", get(get_spec_routes))
116        .route("/specs/upload", post(upload_spec_file))
117        .with_state(state)
118}
119
120/// Import a specification from JSON body
121#[instrument(skip(state, payload))]
122async fn import_spec(
123    State(state): State<SpecImportState>,
124    Json(payload): Json<ImportSpecRequest>,
125) -> Result<Json<ImportSpecResponse>, (StatusCode, String)> {
126    info!("Importing specification");
127
128    // Auto-detect spec type if not provided
129    let spec_type = if let Some(st) = payload.spec_type {
130        st
131    } else {
132        detect_spec_type(&payload.spec_content)
133            .map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to detect spec type: {}", e)))?
134    };
135
136    // Convert YAML to JSON if needed
137    let json_content = if is_yaml(&payload.spec_content) {
138        yaml_to_json(&payload.spec_content)
139            .map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to parse YAML: {}", e)))?
140    } else {
141        payload.spec_content.clone()
142    };
143
144    // Import based on type
145    let (metadata, openapi_result, asyncapi_result) = match spec_type {
146        SpecType::OpenApi => {
147            let result =
148                import_openapi_spec(&json_content, payload.base_url.as_deref()).map_err(|e| {
149                    (StatusCode::BAD_REQUEST, format!("Failed to import OpenAPI spec: {}", e))
150                })?;
151
152            let metadata = SpecMetadata {
153                id: generate_spec_id(),
154                name: payload.name.unwrap_or_else(|| result.spec_info.title.clone()),
155                spec_type: SpecType::OpenApi,
156                version: result.spec_info.version.clone(),
157                description: result.spec_info.description.clone(),
158                servers: result.spec_info.servers.clone(),
159                uploaded_at: chrono::Utc::now().to_rfc3339(),
160                route_count: result.routes.len(),
161            };
162
163            (metadata, Some(result), None)
164        }
165        SpecType::AsyncApi => {
166            let result = import_asyncapi_spec(&payload.spec_content, payload.base_url.as_deref())
167                .map_err(|e| {
168                (StatusCode::BAD_REQUEST, format!("Failed to import AsyncAPI spec: {}", e))
169            })?;
170
171            let metadata = SpecMetadata {
172                id: generate_spec_id(),
173                name: payload.name.unwrap_or_else(|| result.spec_info.title.clone()),
174                spec_type: SpecType::AsyncApi,
175                version: result.spec_info.version.clone(),
176                description: result.spec_info.description.clone(),
177                servers: result.spec_info.servers.clone(),
178                uploaded_at: chrono::Utc::now().to_rfc3339(),
179                route_count: result.channels.len(),
180            };
181
182            (metadata, None, Some(result))
183        }
184    };
185
186    let spec_id = metadata.id.clone();
187
188    // Build response and serialize routes
189    let (routes_generated, warnings, coverage, routes_json) =
190        if let Some(ref result) = openapi_result {
191            let coverage = calculate_openapi_coverage(result);
192            let routes_json = serde_json::to_string(&result.routes).map_err(|e| {
193                (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize routes: {}", e))
194            })?;
195            (result.routes.len(), result.warnings.clone(), coverage, routes_json)
196        } else if let Some(ref result) = asyncapi_result {
197            let coverage = calculate_asyncapi_coverage(result);
198            let routes_json = serde_json::to_string(&result.channels).map_err(|e| {
199                (
200                    StatusCode::INTERNAL_SERVER_ERROR,
201                    format!("Failed to serialize channels: {}", e),
202                )
203            })?;
204            (result.channels.len(), result.warnings.clone(), coverage, routes_json)
205        } else {
206            (
207                0,
208                vec![],
209                CoverageStats {
210                    total_endpoints: 0,
211                    mocked_endpoints: 0,
212                    coverage_percentage: 0,
213                    by_method: HashMap::new(),
214                },
215                "[]".to_string(),
216            )
217        };
218
219    // Store the spec
220    let stored_spec = StoredSpec {
221        metadata: metadata.clone(),
222        content: payload.spec_content,
223        routes_json,
224    };
225
226    state.specs.write().await.insert(spec_id.clone(), stored_spec);
227
228    info!("Specification imported successfully: {}", spec_id);
229
230    Ok(Json(ImportSpecResponse {
231        spec_id,
232        spec_type,
233        routes_generated,
234        warnings,
235        coverage,
236    }))
237}
238
239/// Upload a specification file (multipart form data)
240#[instrument(skip(state, multipart))]
241async fn upload_spec_file(
242    State(state): State<SpecImportState>,
243    mut multipart: Multipart,
244) -> Result<Json<ImportSpecResponse>, (StatusCode, String)> {
245    info!("Uploading specification file");
246
247    let mut spec_content = None;
248    let mut name = None;
249    let mut base_url = None;
250
251    while let Some(field) = multipart
252        .next_field()
253        .await
254        .map_err(|e| (StatusCode::BAD_REQUEST, format!("Failed to read multipart field: {}", e)))?
255    {
256        let field_name = field.name().unwrap_or("").to_string();
257
258        match field_name.as_str() {
259            "file" => {
260                let data = field.bytes().await.map_err(|e| {
261                    (StatusCode::BAD_REQUEST, format!("Failed to read file: {}", e))
262                })?;
263                spec_content = Some(
264                    String::from_utf8(data.to_vec())
265                        .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid UTF-8: {}", e)))?,
266                );
267            }
268            "name" => {
269                name = Some(field.text().await.map_err(|e| {
270                    (StatusCode::BAD_REQUEST, format!("Failed to read name: {}", e))
271                })?);
272            }
273            "base_url" => {
274                base_url = Some(field.text().await.map_err(|e| {
275                    (StatusCode::BAD_REQUEST, format!("Failed to read base_url: {}", e))
276                })?);
277            }
278            _ => {}
279        }
280    }
281
282    let spec_content =
283        spec_content.ok_or((StatusCode::BAD_REQUEST, "Missing 'file' field".to_string()))?;
284
285    // Call import_spec with the extracted data
286    let request = ImportSpecRequest {
287        spec_content,
288        spec_type: None,
289        name,
290        base_url,
291        auto_generate_mocks: Some(true),
292    };
293
294    import_spec(State(state), Json(request)).await
295}
296
297/// List all imported specifications
298#[instrument(skip(state))]
299async fn list_specs(
300    State(state): State<SpecImportState>,
301    Query(params): Query<ListSpecsQuery>,
302) -> Json<Vec<SpecMetadata>> {
303    let specs = state.specs.read().await;
304
305    let mut metadata_list: Vec<SpecMetadata> = specs
306        .values()
307        .filter(|spec| {
308            if let Some(ref spec_type) = params.spec_type {
309                &spec.metadata.spec_type == spec_type
310            } else {
311                true
312            }
313        })
314        .map(|spec| spec.metadata.clone())
315        .collect();
316
317    // Sort by uploaded_at descending
318    metadata_list.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at));
319
320    // Apply pagination
321    let offset = params.offset.unwrap_or(0);
322    let limit = params.limit.unwrap_or(100);
323
324    let paginated: Vec<SpecMetadata> = metadata_list.into_iter().skip(offset).take(limit).collect();
325
326    Json(paginated)
327}
328
329/// Get specification details
330#[instrument(skip(state))]
331async fn get_spec(
332    State(state): State<SpecImportState>,
333    axum::extract::Path(id): axum::extract::Path<String>,
334) -> Result<Json<SpecMetadata>, StatusCode> {
335    let specs = state.specs.read().await;
336
337    specs
338        .get(&id)
339        .map(|spec| Json(spec.metadata.clone()))
340        .ok_or(StatusCode::NOT_FOUND)
341}
342
343/// Delete a specification
344#[instrument(skip(state))]
345async fn delete_spec(
346    State(state): State<SpecImportState>,
347    axum::extract::Path(id): axum::extract::Path<String>,
348) -> Result<StatusCode, StatusCode> {
349    let mut specs = state.specs.write().await;
350
351    if specs.remove(&id).is_some() {
352        info!("Deleted specification: {}", id);
353        Ok(StatusCode::NO_CONTENT)
354    } else {
355        Err(StatusCode::NOT_FOUND)
356    }
357}
358
359/// Get coverage statistics for a spec
360#[instrument(skip(state))]
361async fn get_spec_coverage(
362    State(state): State<SpecImportState>,
363    axum::extract::Path(id): axum::extract::Path<String>,
364) -> Result<Json<CoverageStats>, StatusCode> {
365    let specs = state.specs.read().await;
366
367    let spec = specs.get(&id).ok_or(StatusCode::NOT_FOUND)?;
368
369    // Parse routes to recalculate coverage
370    let coverage = match spec.metadata.spec_type {
371        SpecType::OpenApi => {
372            // For now, return basic stats based on metadata
373            CoverageStats {
374                total_endpoints: spec.metadata.route_count,
375                mocked_endpoints: spec.metadata.route_count,
376                coverage_percentage: 100,
377                by_method: HashMap::new(),
378            }
379        }
380        SpecType::AsyncApi => CoverageStats {
381            total_endpoints: spec.metadata.route_count,
382            mocked_endpoints: spec.metadata.route_count,
383            coverage_percentage: 100,
384            by_method: HashMap::new(),
385        },
386    };
387
388    Ok(Json(coverage))
389}
390
391/// Get routes generated from a spec
392#[instrument(skip(state))]
393async fn get_spec_routes(
394    State(state): State<SpecImportState>,
395    axum::extract::Path(id): axum::extract::Path<String>,
396) -> Result<Json<serde_json::Value>, StatusCode> {
397    let specs = state.specs.read().await;
398
399    let spec = specs.get(&id).ok_or(StatusCode::NOT_FOUND)?;
400
401    let routes: serde_json::Value =
402        serde_json::from_str(&spec.routes_json).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
403
404    Ok(Json(routes))
405}
406
407// Helper functions
408
409fn generate_spec_id() -> String {
410    use std::time::{SystemTime, UNIX_EPOCH};
411    let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis();
412    format!("spec-{}", timestamp)
413}
414
415fn detect_spec_type(content: &str) -> Result<SpecType, String> {
416    // Try parsing as JSON
417    if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
418        if json.get("openapi").is_some() {
419            return Ok(SpecType::OpenApi);
420        } else if json.get("asyncapi").is_some() {
421            return Ok(SpecType::AsyncApi);
422        }
423    }
424
425    // Try parsing as YAML
426    if let Ok(yaml) = serde_yaml::from_str::<serde_json::Value>(content) {
427        if yaml.get("openapi").is_some() {
428            return Ok(SpecType::OpenApi);
429        } else if yaml.get("asyncapi").is_some() {
430            return Ok(SpecType::AsyncApi);
431        }
432    }
433
434    Err("Unable to detect specification type".to_string())
435}
436
437fn is_yaml(content: &str) -> bool {
438    // Simple heuristic: if it doesn't start with '{' or '[', assume YAML
439    let trimmed = content.trim_start();
440    !trimmed.starts_with('{') && !trimmed.starts_with('[')
441}
442
443fn yaml_to_json(yaml_content: &str) -> Result<String, String> {
444    let yaml_value: serde_json::Value =
445        serde_yaml::from_str(yaml_content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
446    serde_json::to_string(&yaml_value).map_err(|e| format!("Failed to convert to JSON: {}", e))
447}
448
449fn calculate_openapi_coverage(result: &OpenApiImportResult) -> CoverageStats {
450    let total_endpoints = result.routes.len();
451    let mocked_endpoints = result.routes.len(); // All routes have mocks
452
453    let mut by_method = HashMap::new();
454    for route in &result.routes {
455        *by_method.entry(route.method.clone()).or_insert(0) += 1;
456    }
457
458    CoverageStats {
459        total_endpoints,
460        mocked_endpoints,
461        coverage_percentage: 100,
462        by_method,
463    }
464}
465
466fn calculate_asyncapi_coverage(result: &AsyncApiImportResult) -> CoverageStats {
467    let total_endpoints = result.channels.len();
468    let mocked_endpoints = result.channels.len();
469
470    let mut by_method = HashMap::new();
471    for channel in &result.channels {
472        let protocol = format!("{:?}", channel.protocol);
473        *by_method.entry(protocol).or_insert(0) += 1;
474    }
475
476    CoverageStats {
477        total_endpoints,
478        mocked_endpoints,
479        coverage_percentage: 100,
480        by_method,
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn test_detect_openapi_json() {
490        let content = r#"{"openapi": "3.0.0", "info": {"title": "Test", "version": "1.0.0"}}"#;
491        assert_eq!(detect_spec_type(content).unwrap(), SpecType::OpenApi);
492    }
493
494    #[test]
495    fn test_detect_asyncapi_json() {
496        let content = r#"{"asyncapi": "2.0.0", "info": {"title": "Test", "version": "1.0.0"}}"#;
497        assert_eq!(detect_spec_type(content).unwrap(), SpecType::AsyncApi);
498    }
499
500    #[test]
501    fn test_is_yaml() {
502        assert!(is_yaml("openapi: 3.0.0"));
503        assert!(!is_yaml("{\"openapi\": \"3.0.0\"}"));
504        assert!(!is_yaml("[1, 2, 3]"));
505    }
506}