1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct SpecMetadata {
24 pub id: String,
26 pub name: String,
28 pub spec_type: SpecType,
30 pub version: String,
32 pub description: Option<String>,
34 pub servers: Vec<String>,
36 pub uploaded_at: String,
38 pub route_count: usize,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44#[serde(rename_all = "lowercase")]
45pub enum SpecType {
46 OpenApi,
48 AsyncApi,
50}
51
52#[derive(Debug, Serialize, Deserialize)]
54pub struct ImportSpecRequest {
55 pub spec_content: String,
57 pub spec_type: Option<SpecType>,
59 pub name: Option<String>,
61 pub base_url: Option<String>,
63 pub auto_generate_mocks: Option<bool>,
65}
66
67#[derive(Debug, Serialize)]
69pub struct ImportSpecResponse {
70 pub spec_id: String,
72 pub spec_type: SpecType,
74 pub routes_generated: usize,
76 pub warnings: Vec<String>,
78 pub coverage: CoverageStats,
80}
81
82#[derive(Debug, Serialize)]
84pub struct CoverageStats {
85 pub total_endpoints: usize,
87 pub mocked_endpoints: usize,
89 pub coverage_percentage: u32,
91 pub by_method: HashMap<String, usize>,
93}
94
95#[derive(Debug, Deserialize)]
97pub struct ListSpecsQuery {
98 pub spec_type: Option<SpecType>,
100 pub limit: Option<usize>,
102 pub offset: Option<usize>,
104}
105
106#[derive(Clone)]
108pub struct SpecImportState {
109 pub specs: Arc<RwLock<HashMap<String, StoredSpec>>>,
111}
112
113#[derive(Debug, Clone)]
115pub struct StoredSpec {
116 pub metadata: SpecMetadata,
118 pub content: String,
120 pub routes_json: String,
122}
123
124impl SpecImportState {
125 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
139pub 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#[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 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 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 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 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 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#[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 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#[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 metadata_list.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at));
351
352 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#[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#[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#[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 let by_method =
403 if let Ok(routes) = serde_json::from_str::<Vec<serde_json::Value>>(&spec.routes_json) {
404 let mut method_counts: HashMap<String, usize> = HashMap::new();
405 for route in &routes {
406 if let Some(method) = route.get("method").and_then(|m| m.as_str()) {
407 *method_counts.entry(method.to_uppercase()).or_insert(0) += 1;
408 } else if let Some(operation) = route.get("operation").and_then(|o| o.as_str()) {
409 *method_counts.entry(operation.to_string()).or_insert(0) += 1;
411 }
412 }
413 method_counts
414 } else {
415 HashMap::new()
416 };
417
418 let total_endpoints = spec.metadata.route_count;
419 let mocked_endpoints = by_method.values().sum::<usize>().max(total_endpoints);
420 let coverage_percentage = if total_endpoints > 0 {
421 ((mocked_endpoints as f64 / total_endpoints as f64) * 100.0).min(100.0) as u32
422 } else {
423 0
424 };
425
426 let coverage = CoverageStats {
427 total_endpoints,
428 mocked_endpoints,
429 coverage_percentage,
430 by_method,
431 };
432
433 Ok(Json(coverage))
434}
435
436#[instrument(skip(state))]
438async fn get_spec_routes(
439 State(state): State<SpecImportState>,
440 axum::extract::Path(id): axum::extract::Path<String>,
441) -> Result<Json<serde_json::Value>, StatusCode> {
442 let specs = state.specs.read().await;
443
444 let spec = specs.get(&id).ok_or(StatusCode::NOT_FOUND)?;
445
446 let routes: serde_json::Value =
447 serde_json::from_str(&spec.routes_json).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
448
449 Ok(Json(routes))
450}
451
452fn generate_spec_id() -> String {
455 use std::time::{SystemTime, UNIX_EPOCH};
456 let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis();
457 format!("spec-{}", timestamp)
458}
459
460fn detect_spec_type(content: &str) -> Result<SpecType, String> {
461 if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
463 if json.get("openapi").is_some() {
464 return Ok(SpecType::OpenApi);
465 } else if json.get("asyncapi").is_some() {
466 return Ok(SpecType::AsyncApi);
467 }
468 }
469
470 if let Ok(yaml) = serde_yaml::from_str::<serde_json::Value>(content) {
472 if yaml.get("openapi").is_some() {
473 return Ok(SpecType::OpenApi);
474 } else if yaml.get("asyncapi").is_some() {
475 return Ok(SpecType::AsyncApi);
476 }
477 }
478
479 Err("Unable to detect specification type".to_string())
480}
481
482fn is_yaml(content: &str) -> bool {
483 let trimmed = content.trim_start();
485 !trimmed.starts_with('{') && !trimmed.starts_with('[')
486}
487
488fn yaml_to_json(yaml_content: &str) -> Result<String, String> {
489 let yaml_value: serde_json::Value =
490 serde_yaml::from_str(yaml_content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
491 serde_json::to_string(&yaml_value).map_err(|e| format!("Failed to convert to JSON: {}", e))
492}
493
494fn calculate_openapi_coverage(result: &OpenApiImportResult) -> CoverageStats {
495 let total_endpoints = result.routes.len();
496 let mocked_endpoints = result.routes.len(); let mut by_method = HashMap::new();
499 for route in &result.routes {
500 *by_method.entry(route.method.clone()).or_insert(0) += 1;
501 }
502
503 CoverageStats {
504 total_endpoints,
505 mocked_endpoints,
506 coverage_percentage: 100,
507 by_method,
508 }
509}
510
511fn calculate_asyncapi_coverage(result: &AsyncApiImportResult) -> CoverageStats {
512 let total_endpoints = result.channels.len();
513 let mocked_endpoints = result.channels.len();
514
515 let mut by_method = HashMap::new();
516 for channel in &result.channels {
517 let protocol = format!("{:?}", channel.protocol);
518 *by_method.entry(protocol).or_insert(0) += 1;
519 }
520
521 CoverageStats {
522 total_endpoints,
523 mocked_endpoints,
524 coverage_percentage: 100,
525 by_method,
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn test_detect_openapi_json() {
535 let content = r#"{"openapi": "3.0.0", "info": {"title": "Test", "version": "1.0.0"}}"#;
536 assert_eq!(detect_spec_type(content).unwrap(), SpecType::OpenApi);
537 }
538
539 #[test]
540 fn test_detect_asyncapi_json() {
541 let content = r#"{"asyncapi": "2.0.0", "info": {"title": "Test", "version": "1.0.0"}}"#;
542 assert_eq!(detect_spec_type(content).unwrap(), SpecType::AsyncApi);
543 }
544
545 #[test]
546 fn test_is_yaml() {
547 assert!(is_yaml("openapi: 3.0.0"));
548 assert!(!is_yaml("{\"openapi\": \"3.0.0\"}"));
549 assert!(!is_yaml("[1, 2, 3]"));
550 }
551}