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 coverage = match spec.metadata.spec_type {
403 SpecType::OpenApi => {
404 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#[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
439fn 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 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 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 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(); 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}