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