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,
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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36#[serde(rename_all = "lowercase")]
37pub enum SpecType {
38 OpenApi,
39 AsyncApi,
40}
41
42#[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#[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#[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#[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#[derive(Clone)]
81pub struct SpecImportState {
82 pub specs: Arc<RwLock<HashMap<String, StoredSpec>>>,
83}
84
85#[derive(Debug, Clone)]
87pub struct StoredSpec {
88 pub metadata: SpecMetadata,
89 pub content: String,
90 pub routes_json: String, }
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
107pub 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#[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 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 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 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 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 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#[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 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#[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 metadata_list.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at));
319
320 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#[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#[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#[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 let coverage = match spec.metadata.spec_type {
371 SpecType::OpenApi => {
372 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#[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
407fn 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 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 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 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(); 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}