use axum::{ Router, routing::{get, post}, }; use crate::{ routes::{ auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles}, operations::{ create_operation, create_version, export_operation, generate_draft, get_operation, get_operation_version, list_operations, publish_operation, run_test, upload_input_json, upload_output_json, }, }, state::AppState, }; pub fn build_app(state: AppState) -> Router { let admin_router = Router::new() .route("/operations", get(list_operations).post(create_operation)) .route("/operations/import", post(import_operation)) .route("/operations/{operation_id}", get(get_operation)) .route("/operations/{operation_id}/versions", post(create_version)) .route( "/operations/{operation_id}/versions/{version}", get(get_operation_version), ) .route( "/operations/{operation_id}/publish", post(publish_operation), ) .route("/operations/{operation_id}/test-runs", post(run_test)) .route( "/operations/{operation_id}/samples/input-json", post(upload_input_json), ) .route( "/operations/{operation_id}/samples/output-json", post(upload_output_json), ) .route( "/operations/{operation_id}/drafts/generate", post(generate_draft), ) .route("/operations/{operation_id}/export", get(export_operation)) .route( "/auth-profiles", get(list_auth_profiles).post(create_auth_profile), ) .route("/auth-profiles/{auth_profile_id}", get(get_auth_profile)); Router::new() .route("/health", get(crate::routes::health)) .merge(admin_router.clone()) .nest("/api/admin", admin_router) .with_state(state) } use crate::routes::operations::import_operation; #[cfg(test)] mod tests { use std::{ collections::BTreeMap, env, time::{SystemTime, UNIX_EPOCH}, }; use axum::{Json, Router, routing::post}; use mcpaas_core::{ExecutionConfig, HttpMethod, Protocol, RestTarget, Target, ToolDescription}; use mcpaas_mapping::{MappingRule, MappingSet}; use mcpaas_registry::PostgresRegistry; use mcpaas_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; use sqlx::{Executor, postgres::PgPoolOptions}; use tokio::net::TcpListener; use crate::{ app::build_app, service::{AdminService, OperationPayload}, state::AppState, }; #[tokio::test] async fn creates_publishes_and_tests_rest_operation() { let registry = test_registry().await; let storage_root = test_storage_root("lifecycle"); let upstream_base_url = spawn_upstream_server().await; let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = reqwest::Client::new(); let created = client .post(format!("{base_url}/operations")) .json(&test_operation_payload( &upstream_base_url, "crm_create_lead", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap().to_owned(); let listed = client .get(format!("{base_url}/operations")) .send() .await .unwrap() .json::() .await .unwrap(); let published = client .post(format!("{base_url}/operations/{operation_id}/publish")) .json(&json!({ "version": 1 })) .send() .await .unwrap() .json::() .await .unwrap(); let test_run = client .post(format!("{base_url}/operations/{operation_id}/test-runs")) .json(&json!({ "version": 1, "input": { "email": "user@example.com" } })) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(listed["items"][0]["name"], "crm_create_lead"); assert_eq!(published["published_version"], 1); assert_eq!(test_run["ok"], true); assert_eq!( test_run["request_preview"]["body"]["email"], "user@example.com" ); assert_eq!(test_run["response_preview"]["id"], "lead_123"); } #[tokio::test] async fn manages_auth_profiles_and_yaml_upsert() { let registry = test_registry().await; let storage_root = test_storage_root("yaml"); let upstream_base_url = spawn_upstream_server().await; let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = reqwest::Client::new(); let auth_profile = client .post(format!("{base_url}/auth-profiles")) .json(&json!({ "name": "crm-header", "kind": "api_key_header", "config": { "api_key_header": { "header_name": "X-Api-Key", "secret_ref": "secret://crm/api-key" } } })) .send() .await .unwrap() .json::() .await .unwrap(); let created = client .post(format!("{base_url}/operations")) .json(&test_operation_payload( &upstream_base_url, "crm_export_target", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap(); let yaml = client .get(format!("{base_url}/operations/{operation_id}/export")) .send() .await .unwrap() .text() .await .unwrap(); let imported = client .post(format!("{base_url}/operations/import?mode=upsert")) .body(yaml) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(auth_profile["kind"], "api_key_header"); assert_eq!(imported["operation_id"], operation_id); assert_eq!(imported["version"], 2); assert_eq!(imported["import_mode"], "upsert"); } #[tokio::test] async fn uploads_samples_and_generates_draft() { let registry = test_registry().await; let storage_root = test_storage_root("draft"); let upstream_base_url = spawn_upstream_server().await; let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = reqwest::Client::new(); let created = client .post(format!("{base_url}/operations")) .json(&test_operation_payload( &upstream_base_url, "crm_draft_target", )) .send() .await .unwrap() .json::() .await .unwrap(); let operation_id = created["operation_id"].as_str().unwrap(); client .post(format!( "{base_url}/operations/{operation_id}/samples/input-json" )) .json(&json!({ "email": "user@example.com", "name": "Ada" })) .send() .await .unwrap(); client .post(format!( "{base_url}/operations/{operation_id}/samples/output-json" )) .json(&json!({ "id": "lead_123", "status": "created" })) .send() .await .unwrap(); let generated = client .post(format!( "{base_url}/operations/{operation_id}/drafts/generate" )) .json(&json!({ "sources": ["input_json_sample", "output_json_sample"] })) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(generated["generated_draft"]["status"], "available"); assert_eq!( generated["input_schema"]["fields"]["email"]["type"], "string" ); assert_eq!( generated["output_mapping"]["rules"][0]["target"], "$.output.id" ); } fn build_test_app(registry: PostgresRegistry, storage_root: std::path::PathBuf) -> Router { build_app(AppState { service: AdminService::new(registry, storage_root), }) } async fn spawn_upstream_server() -> String { let app = Router::new().route("/crm/leads", post(create_lead)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); format!("http://{}", address) } async fn spawn_admin_api(app: Router) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); format!("http://{}", address) } async fn create_lead(Json(payload): Json) -> Json { Json(json!({ "id": "lead_123", "status": "created", "email": payload["email"] })) } async fn test_registry() -> PostgresRegistry { let database_url = env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://rmcp:rmcp@127.0.0.1:5432/rmcp".to_owned()); let admin_pool = PgPoolOptions::new() .max_connections(1) .connect(&database_url) .await .unwrap(); let schema = format!( "test_admin_api_{}_{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() ); admin_pool .execute(sqlx::query(&format!("create schema {schema}"))) .await .unwrap(); PostgresRegistry::connect(&format!("{database_url}?options=-csearch_path%3D{schema}")) .await .unwrap() } fn test_storage_root(name: &str) -> std::path::PathBuf { env::temp_dir().join(format!( "rmcp_admin_api_{name}_{}_{}", std::process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )) } fn test_operation_payload(base_url: &str, name: &str) -> OperationPayload { OperationPayload { name: name.to_owned(), display_name: "Create Lead".to_owned(), protocol: Protocol::Rest, target: Target::Rest(RestTarget { base_url: base_url.to_owned(), method: HttpMethod::Post, path_template: "/crm/leads".to_owned(), static_headers: BTreeMap::new(), }), input_schema: object_schema("email"), output_schema: object_schema("id"), input_mapping: MappingSet { rules: vec![MappingRule { source: "$.mcp.email".to_owned(), target: "$.request.body.email".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, output_mapping: MappingSet { rules: vec![MappingRule { source: "$.response.body.id".to_owned(), target: "$.output.id".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, }, tool_description: ToolDescription { title: "Create Lead".to_owned(), description: "Creates a CRM lead".to_owned(), tags: vec!["crm".to_owned()], examples: Vec::new(), }, } } fn object_schema(field_name: &str) -> Schema { Schema { kind: SchemaKind::Object, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::from([( field_name.to_owned(), Schema { kind: SchemaKind::String, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::new(), items: None, enum_values: Vec::new(), variants: Vec::new(), }, )]), items: None, enum_values: Vec::new(), variants: Vec::new(), } } }