diff --git a/Cargo.lock b/Cargo.lock index 7381f00..1a2e411 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,10 +7,22 @@ name = "admin-api" version = "0.1.0" dependencies = [ "axum", + "mcpaas-core", + "mcpaas-mapping", + "mcpaas-registry", + "mcpaas-runtime", + "mcpaas-schema", + "reqwest", + "serde", "serde_json", + "serde_yaml", + "sqlx", + "thiserror", + "time", "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -28,6 +40,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "atoi" version = "2.0.0" @@ -220,6 +238,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -398,11 +425,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -656,6 +696,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -685,6 +731,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -725,6 +773,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" @@ -952,6 +1006,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -1029,6 +1089,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1038,6 +1104,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1117,6 +1193,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1322,6 +1404,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1655,6 +1743,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1874,6 +1993,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -1904,6 +2029,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1937,7 +2074,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -2005,6 +2151,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.91" @@ -2222,6 +2402,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 59b32ac..2200459 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ serde_json = "1" serde_yaml = "0.9" sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres"] } thiserror = "2" +time = { version = "0.3", features = ["formatting", "parsing"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +uuid = { version = "1", features = ["serde", "v7"] } diff --git a/TASKS.md b/TASKS.md index 2ad3559..bac9456 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,25 +2,25 @@ ## Current -### `feat/rest-vertical-slice` +### `feat/admin-api-v1` Status: completed DoD: -- REST operation can be created, tested, and published -- runtime executes REST operation end-to-end -- published REST tool is callable through MCP layer -- negative tests for mapping and external errors exist -- at least one demo REST scenario is automated +- CRUD operations are available through admin API +- version creation and publish flow work through HTTP endpoints +- JSON sample upload and draft generation work for REST scenario +- auth profiles and YAML import/export are available +- integration tests cover key admin API scenarios +- handlers do not contain hidden business logic ## Next -- `feat/admin-api-v1` +- `feat/ui-v1` ## Backlog -- `feat/rest-vertical-slice` - `feat/admin-api-v1` - `feat/ui-v1` - `feat/mcp-server` diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 9c5accd..45a5d50 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -7,7 +7,21 @@ version.workspace = true [dependencies] axum.workspace = true +mcpaas-core = { path = "../../crates/mcpaas-core" } +mcpaas-mapping = { path = "../../crates/mcpaas-mapping" } +mcpaas-registry = { path = "../../crates/mcpaas-registry" } +mcpaas-runtime = { path = "../../crates/mcpaas-runtime" } +mcpaas-schema = { path = "../../crates/mcpaas-schema" } +serde.workspace = true serde_json.workspace = true -tokio.workspace = true +serde_yaml.workspace = true +thiserror.workspace = true +time.workspace = true +tokio = { workspace = true, features = ["fs"] } tracing.workspace = true tracing-subscriber.workspace = true +uuid.workspace = true + +[dev-dependencies] +reqwest.workspace = true +sqlx.workspace = true diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs new file mode 100644 index 0000000..4ff5634 --- /dev/null +++ b/apps/admin-api/src/app.rs @@ -0,0 +1,425 @@ +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(), + } + } +} diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs new file mode 100644 index 0000000..6409774 --- /dev/null +++ b/apps/admin-api/src/error.rs @@ -0,0 +1,143 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use mcpaas_mapping::MappingError; +use mcpaas_registry::RegistryError; +use mcpaas_runtime::RuntimeError; +use mcpaas_schema::SchemaError; +use serde_json::{Value, json}; +use thiserror::Error; + +use crate::storage::StorageError; + +#[derive(Debug, Error)] +pub enum ApiError { + #[error("{message}")] + Validation { message: String }, + #[error("{message}")] + NotFound { message: String }, + #[error("{message}")] + Conflict { message: String }, + #[error("{message}")] + Internal { message: String }, +} + +impl ApiError { + pub fn validation(message: impl Into) -> Self { + Self::Validation { + message: message.into(), + } + } + + pub fn not_found(message: impl Into) -> Self { + Self::NotFound { + message: message.into(), + } + } + + pub fn conflict(message: impl Into) -> Self { + Self::Conflict { + message: message.into(), + } + } + + pub fn internal(message: impl Into) -> Self { + Self::Internal { + message: message.into(), + } + } + + fn status_code(&self) -> StatusCode { + match self { + Self::Validation { .. } => StatusCode::BAD_REQUEST, + Self::NotFound { .. } => StatusCode::NOT_FOUND, + Self::Conflict { .. } => StatusCode::CONFLICT, + Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn code(&self) -> &'static str { + match self { + Self::Validation { .. } => "validation_error", + Self::NotFound { .. } => "not_found", + Self::Conflict { .. } => "conflict", + Self::Internal { .. } => "internal_error", + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let body = Json(json!({ + "error": { + "code": self.code(), + "message": self.to_string(), + } + })); + + (self.status_code(), body).into_response() + } +} + +impl From for ApiError { + fn from(value: RegistryError) -> Self { + match value { + RegistryError::OperationNotFound { operation_id } => { + Self::not_found(format!("operation {operation_id} was not found")) + } + RegistryError::OperationVersionNotFound { + operation_id, + version, + } => Self::not_found(format!( + "operation version {version} for {operation_id} was not found" + )), + RegistryError::AuthProfileNotFound { auth_profile_id } => { + Self::not_found(format!("auth profile {auth_profile_id} was not found")) + } + RegistryError::OperationAlreadyExists { operation_id } => { + Self::conflict(format!("operation {operation_id} already exists")) + } + RegistryError::InvalidInitialVersion { .. } + | RegistryError::InvalidVersionSequence { .. } + | RegistryError::ImmutableOperationFieldChanged { .. } + | RegistryError::InvalidEnumRepresentation { .. } + | RegistryError::InvalidNumericValue { .. } + | RegistryError::YamlImportJobNotFound { .. } => Self::validation(value.to_string()), + RegistryError::Storage(_) | RegistryError::Serialization(_) => { + Self::internal(value.to_string()) + } + } + } +} + +impl From for ApiError { + fn from(value: MappingError) -> Self { + Self::validation(value.to_string()) + } +} + +impl From for ApiError { + fn from(value: SchemaError) -> Self { + Self::validation(value.to_string()) + } +} + +impl From for ApiError { + fn from(value: StorageError) -> Self { + match value { + StorageError::InvalidStorageRef { details } => Self::validation(details), + StorageError::Io(_) | StorageError::Serialization(_) => { + Self::internal(value.to_string()) + } + } + } +} + +pub fn runtime_test_failure(error: &RuntimeError) -> Value { + json!({ + "code": "runtime_test_error", + "message": error.to_string() + }) +} diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 9c2c830..90e1a63 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -1,10 +1,18 @@ -use std::{env, net::SocketAddr}; +mod app; +mod error; +mod routes; +mod service; +mod state; +mod storage; -use axum::{Json, Router, routing::get}; -use serde_json::json; +use std::{env, net::SocketAddr, path::PathBuf}; + +use mcpaas_registry::PostgresRegistry; use tokio::net::TcpListener; use tracing::info; +use crate::{app::build_app, service::AdminService, state::AppState}; + #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt() @@ -14,9 +22,17 @@ async fn main() -> Result<(), Box> { ) .init(); + let database_url = env::var("MCPAAS_DATABASE_URL")?; + let storage_root = PathBuf::from( + env::var("MCPAAS_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/rmcp/storage".into()), + ); let bind_addr = env::var("MCPAAS_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into()); let socket_addr: SocketAddr = bind_addr.parse()?; - let app = Router::new().route("/health", get(health)); + let registry = PostgresRegistry::connect(&database_url).await?; + let state = AppState { + service: AdminService::new(registry, storage_root), + }; + let app = build_app(state); let listener = TcpListener::bind(socket_addr).await?; info!("admin-api listening on {}", socket_addr); @@ -25,10 +41,3 @@ async fn main() -> Result<(), Box> { Ok(()) } - -async fn health() -> Json { - Json(json!({ - "service": "admin-api", - "status": "ok" - })) -} diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs new file mode 100644 index 0000000..9746822 --- /dev/null +++ b/apps/admin-api/src/routes.rs @@ -0,0 +1,12 @@ +pub mod auth_profiles; +pub mod operations; + +use axum::Json; +use serde_json::json; + +pub async fn health() -> Json { + Json(json!({ + "service": "admin-api", + "status": "ok" + })) +} diff --git a/apps/admin-api/src/routes/auth_profiles.rs b/apps/admin-api/src/routes/auth_profiles.rs new file mode 100644 index 0000000..c095b72 --- /dev/null +++ b/apps/admin-api/src/routes/auth_profiles.rs @@ -0,0 +1,31 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use serde_json::{Value, json}; + +use crate::{error::ApiError, service::AuthProfilePayload, state::AppState}; + +pub async fn list_auth_profiles(State(state): State) -> Result, ApiError> { + let items = state.service.list_auth_profiles().await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_auth_profile( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let profile = state.service.create_auth_profile(payload).await?; + Ok(Json(json!(profile))) +} + +pub async fn get_auth_profile( + Path(auth_profile_id): Path, + State(state): State, +) -> Result, ApiError> { + let profile = state + .service + .get_auth_profile(&auth_profile_id.as_str().into()) + .await?; + Ok(Json(json!(profile))) +} diff --git a/apps/admin-api/src/routes/operations.rs b/apps/admin-api/src/routes/operations.rs new file mode 100644 index 0000000..541c846 --- /dev/null +++ b/apps/admin-api/src/routes/operations.rs @@ -0,0 +1,168 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode, header}, + response::IntoResponse, +}; +use serde_json::{Value, json}; + +use crate::{ + error::ApiError, + service::{ + ExportQuery, GenerateDraftPayload, ImportQuery, NewVersionPayload, OperationPayload, + PublishPayload, TestRunPayload, + }, + state::AppState, +}; + +pub async fn list_operations(State(state): State) -> Result, ApiError> { + let items = state.service.list_operations().await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_operation( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let created = state.service.create_operation(payload).await?; + Ok(Json(json!(created))) +} + +pub async fn get_operation( + Path(operation_id): Path, + State(state): State, +) -> Result, ApiError> { + let operation = state + .service + .get_operation(&operation_id.as_str().into()) + .await?; + Ok(Json(json!(operation))) +} + +pub async fn get_operation_version( + Path((operation_id, version)): Path<(String, u32)>, + State(state): State, +) -> Result, ApiError> { + let version = state + .service + .get_operation_version(&operation_id.as_str().into(), version) + .await?; + Ok(Json(json!(version))) +} + +pub async fn create_version( + Path(operation_id): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let created = state + .service + .create_version(&operation_id.as_str().into(), payload) + .await?; + Ok(Json(json!(created))) +} + +pub async fn publish_operation( + Path(operation_id): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let published = state + .service + .publish_operation(&operation_id.as_str().into(), payload.version) + .await?; + Ok(Json(json!(published))) +} + +pub async fn run_test( + Path(operation_id): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let result = state + .service + .run_test(&operation_id.as_str().into(), payload) + .await?; + Ok(Json(json!(result))) +} + +pub async fn upload_input_json( + Path(operation_id): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let sample = state + .service + .save_json_sample( + &operation_id.as_str().into(), + mcpaas_registry::SampleKind::InputJson, + &payload, + ) + .await?; + + Ok(Json(json!({ + "sample_id": sample.id.as_str(), + "sample_kind": sample.sample_kind, + "version": sample.version + }))) +} + +pub async fn upload_output_json( + Path(operation_id): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let sample = state + .service + .save_json_sample( + &operation_id.as_str().into(), + mcpaas_registry::SampleKind::OutputJson, + &payload, + ) + .await?; + + Ok(Json(json!({ + "sample_id": sample.id.as_str(), + "sample_kind": sample.sample_kind, + "version": sample.version + }))) +} + +pub async fn generate_draft( + Path(operation_id): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let draft = state + .service + .generate_draft(&operation_id.as_str().into(), payload) + .await?; + Ok(Json(json!(draft))) +} + +pub async fn export_operation( + Path(operation_id): Path, + Query(query): Query, + State(state): State, +) -> Result { + let yaml = state + .service + .export_operation(&operation_id.as_str().into(), query) + .await?; + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/yaml"), + ); + + Ok((StatusCode::OK, headers, yaml)) +} + +pub async fn import_operation( + Query(query): Query, + State(state): State, + body: String, +) -> Result, ApiError> { + let imported = state.service.import_operation(query, &body).await?; + Ok(Json(json!(imported))) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs new file mode 100644 index 0000000..cb9b819 --- /dev/null +++ b/apps/admin-api/src/service.rs @@ -0,0 +1,673 @@ +use std::path::PathBuf; + +use mcpaas_core::{ + AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, + GeneratedDraftStatus, OperationId, OperationStatus, Protocol, SampleId, Samples, Target, +}; +use mcpaas_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; +use mcpaas_registry::{ + CreateVersionRequest, OperationSampleMetadata, OperationSummary, OperationVersionRecord, + PostgresRegistry, PublishRequest, RegistryOperation, SampleKind, SaveAuthProfileRequest, + SaveSampleMetadataRequest, +}; +use mcpaas_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; +use mcpaas_schema::Schema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use uuid::Uuid; + +use crate::{error::ApiError, storage::LocalArtifactStorage}; + +#[derive(Clone)] +pub struct AdminService { + registry: PostgresRegistry, + runtime: RuntimeExecutor, + storage: LocalArtifactStorage, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct OperationPayload { + pub name: String, + pub display_name: String, + pub protocol: Protocol, + pub target: Target, + pub input_schema: Schema, + pub output_schema: Schema, + pub input_mapping: MappingSet, + pub output_mapping: MappingSet, + pub execution_config: mcpaas_core::ExecutionConfig, + pub tool_description: mcpaas_core::ToolDescription, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct NewVersionPayload { + #[serde(flatten)] + pub operation: OperationPayload, + #[serde(default)] + pub change_note: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PublishPayload { + pub version: u32, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct TestRunPayload { + pub version: u32, + pub input: Value, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TestRunResult { + pub ok: bool, + pub request_preview: Value, + pub response_preview: Value, + pub errors: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AuthProfilePayload { + pub name: String, + pub kind: AuthKind, + pub config: AuthConfig, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct GenerateDraftPayload { + #[serde(default)] + pub sources: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DraftGenerationResult { + pub generated_draft: GeneratedDraft, + pub input_schema: Schema, + pub output_schema: Schema, + pub input_mapping: MappingSet, + pub output_mapping: MappingSet, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ImportQuery { + #[serde(default)] + pub mode: ImportMode, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ImportMode { + #[default] + Create, + Upsert, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ExportQuery { + pub version: Option, + #[serde(default = "default_export_mode")] + pub mode: ExportMode, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct YamlOperationDocument { + pub format_version: String, + pub kind: String, + pub operation: RegistryOperation, +} + +#[derive(Clone, Debug, Serialize)] +pub struct CreatedOperationResponse { + pub operation_id: String, + pub version: u32, + pub status: OperationStatus, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublishResponse { + pub operation_id: String, + pub published_version: u32, + pub published_at: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ImportResponse { + pub operation_id: String, + pub version: u32, + pub import_mode: ImportMode, + pub warnings: Vec, +} + +impl AdminService { + pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self { + Self { + registry, + runtime: RuntimeExecutor::new(), + storage: LocalArtifactStorage::new(storage_root), + } + } + + pub async fn list_operations(&self) -> Result, ApiError> { + Ok(self.registry.list_operations().await?) + } + + pub async fn get_operation( + &self, + operation_id: &OperationId, + ) -> Result { + self.registry + .get_operation_summary(operation_id) + .await? + .ok_or_else(|| { + ApiError::not_found(format!("operation {} was not found", operation_id.as_str())) + }) + } + + pub async fn get_operation_version( + &self, + operation_id: &OperationId, + version: u32, + ) -> Result { + self.registry + .get_operation_version(operation_id, version) + .await? + .ok_or_else(|| { + ApiError::not_found(format!( + "operation version {version} for {} was not found", + operation_id.as_str() + )) + }) + } + + pub async fn create_operation( + &self, + payload: OperationPayload, + ) -> Result { + self.validate_operation_payload(&payload)?; + + if self.find_operation_by_name(&payload.name).await?.is_some() { + return Err(ApiError::conflict(format!( + "operation with name {} already exists", + payload.name + ))); + } + + let now = now_string()?; + let operation_id = OperationId::new(new_prefixed_id("op")); + let snapshot = RegistryOperation { + id: operation_id.clone(), + name: payload.name, + display_name: payload.display_name, + protocol: payload.protocol, + status: OperationStatus::Draft, + version: 1, + target: payload.target, + input_schema: payload.input_schema, + output_schema: payload.output_schema, + input_mapping: payload.input_mapping, + output_mapping: payload.output_mapping, + execution_config: payload.execution_config, + tool_description: payload.tool_description, + samples: Some(Samples::default()), + generated_draft: None, + config_export: Some(ConfigExport { + format_version: "1".to_owned(), + export_mode: ExportMode::Portable, + }), + created_at: now.clone(), + updated_at: now, + published_at: None, + }; + + self.registry.create_operation(&snapshot, None).await?; + + Ok(CreatedOperationResponse { + operation_id: operation_id.as_str().to_owned(), + version: 1, + status: OperationStatus::Draft, + }) + } + + pub async fn create_version( + &self, + operation_id: &OperationId, + payload: NewVersionPayload, + ) -> Result { + self.validate_operation_payload(&payload.operation)?; + + let summary = self.get_operation(operation_id).await?; + let now = now_string()?; + let version = summary.current_draft_version + 1; + let snapshot = RegistryOperation { + id: operation_id.clone(), + name: payload.operation.name, + display_name: payload.operation.display_name, + protocol: payload.operation.protocol, + status: OperationStatus::Draft, + version, + target: payload.operation.target, + input_schema: payload.operation.input_schema, + output_schema: payload.operation.output_schema, + input_mapping: payload.operation.input_mapping, + output_mapping: payload.operation.output_mapping, + execution_config: payload.operation.execution_config, + tool_description: payload.operation.tool_description, + samples: Some(Samples::default()), + generated_draft: None, + config_export: Some(ConfigExport { + format_version: "1".to_owned(), + export_mode: ExportMode::Portable, + }), + created_at: summary.created_at, + updated_at: now, + published_at: None, + }; + + self.registry + .create_version(CreateVersionRequest { + snapshot: &snapshot, + change_note: payload.change_note.as_deref(), + created_by: None, + }) + .await?; + + Ok(CreatedOperationResponse { + operation_id: operation_id.as_str().to_owned(), + version, + status: OperationStatus::Draft, + }) + } + + pub async fn publish_operation( + &self, + operation_id: &OperationId, + version: u32, + ) -> Result { + let published_at = now_string()?; + self.registry + .publish_operation(PublishRequest { + operation_id, + version, + published_at: &published_at, + published_by: None, + }) + .await?; + + Ok(PublishResponse { + operation_id: operation_id.as_str().to_owned(), + published_version: version, + published_at, + }) + } + + pub async fn run_test( + &self, + operation_id: &OperationId, + payload: TestRunPayload, + ) -> Result { + let record = self + .get_operation_version(operation_id, payload.version) + .await?; + let runtime = RuntimeOperation::from(record.snapshot.clone()); + let request_preview = + match build_request_preview(&record.snapshot.input_mapping, &payload.input) { + Ok(preview) => preview, + Err(error) => { + return Ok(TestRunResult { + ok: false, + request_preview: Value::Null, + response_preview: Value::Null, + errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping( + error, + ))], + }); + } + }; + + match self.runtime.execute(&runtime, &payload.input).await { + Ok(output) => Ok(TestRunResult { + ok: true, + request_preview, + response_preview: output, + errors: Vec::new(), + }), + Err(error) => Ok(TestRunResult { + ok: false, + request_preview, + response_preview: Value::Null, + errors: vec![crate::error::runtime_test_failure(&error)], + }), + } + } + + pub async fn list_auth_profiles(&self) -> Result, ApiError> { + Ok(self.registry.list_auth_profiles().await?) + } + + pub async fn get_auth_profile( + &self, + auth_profile_id: &AuthProfileId, + ) -> Result { + self.registry + .get_auth_profile(auth_profile_id) + .await? + .ok_or_else(|| { + ApiError::not_found(format!( + "auth profile {} was not found", + auth_profile_id.as_str() + )) + }) + } + + pub async fn create_auth_profile( + &self, + payload: AuthProfilePayload, + ) -> Result { + validate_auth_profile_kind(payload.kind, &payload.config)?; + + let now = now_string()?; + let profile = AuthProfile { + id: AuthProfileId::new(new_prefixed_id("auth")), + name: payload.name, + kind: payload.kind, + config: payload.config, + created_at: now.clone(), + updated_at: now, + }; + + self.registry + .save_auth_profile(SaveAuthProfileRequest { profile: &profile }) + .await?; + + Ok(profile) + } + + pub async fn export_operation( + &self, + operation_id: &OperationId, + query: ExportQuery, + ) -> Result { + let version = match query.version { + Some(version) => version, + None => { + self.get_operation(operation_id) + .await? + .current_draft_version + } + }; + let record = self.get_operation_version(operation_id, version).await?; + let document = YamlOperationDocument { + format_version: "1".to_owned(), + kind: "operation".to_owned(), + operation: RegistryOperation { + config_export: Some(ConfigExport { + format_version: "1".to_owned(), + export_mode: query.mode, + }), + ..record.snapshot + }, + }; + + serde_yaml::to_string(&document).map_err(|error| ApiError::internal(error.to_string())) + } + + pub async fn import_operation( + &self, + query: ImportQuery, + yaml_document: &str, + ) -> Result { + let document: YamlOperationDocument = serde_yaml::from_str(yaml_document) + .map_err(|error| ApiError::validation(error.to_string()))?; + if document.kind != "operation" { + return Err(ApiError::validation("yaml kind must be operation")); + } + + let payload = OperationPayload { + name: document.operation.name.clone(), + display_name: document.operation.display_name.clone(), + protocol: document.operation.protocol, + target: document.operation.target.clone(), + input_schema: document.operation.input_schema.clone(), + output_schema: document.operation.output_schema.clone(), + input_mapping: document.operation.input_mapping.clone(), + output_mapping: document.operation.output_mapping.clone(), + execution_config: document.operation.execution_config.clone(), + tool_description: document.operation.tool_description.clone(), + }; + + match query.mode { + ImportMode::Create => { + let created = self.create_operation(payload).await?; + Ok(ImportResponse { + operation_id: created.operation_id, + version: created.version, + import_mode: ImportMode::Create, + warnings: Vec::new(), + }) + } + ImportMode::Upsert => { + if let Some(existing) = self + .find_operation_by_name(&document.operation.name) + .await? + { + let created = self + .create_version( + &existing.id, + NewVersionPayload { + operation: payload, + change_note: Some("yaml upsert".to_owned()), + }, + ) + .await?; + + Ok(ImportResponse { + operation_id: created.operation_id, + version: created.version, + import_mode: ImportMode::Upsert, + warnings: Vec::new(), + }) + } else { + let created = self.create_operation(payload).await?; + Ok(ImportResponse { + operation_id: created.operation_id, + version: created.version, + import_mode: ImportMode::Upsert, + warnings: Vec::new(), + }) + } + } + } + } + + pub async fn save_json_sample( + &self, + operation_id: &OperationId, + sample_kind: SampleKind, + payload: &Value, + ) -> Result { + let summary = self.get_operation(operation_id).await?; + let version = summary.current_draft_version; + let sample_id = SampleId::new(new_prefixed_id("sample")); + let now = now_string()?; + let file_name = match sample_kind { + SampleKind::InputJson => "input.json", + SampleKind::OutputJson => "output.json", + SampleKind::YamlImportSource => "source.yaml", + }; + let storage_ref = self + .storage + .write_json_sample(operation_id, version, sample_kind, &sample_id, payload) + .await?; + let metadata = OperationSampleMetadata { + id: sample_id, + operation_id: operation_id.clone(), + version, + sample_kind, + storage_ref, + content_type: "application/json".to_owned(), + file_name: Some(file_name.to_owned()), + created_at: now, + }; + + self.registry + .save_sample_metadata(SaveSampleMetadataRequest { sample: &metadata }) + .await?; + + Ok(metadata) + } + + pub async fn generate_draft( + &self, + operation_id: &OperationId, + payload: GenerateDraftPayload, + ) -> Result { + let summary = self.get_operation(operation_id).await?; + let samples = self + .registry + .list_sample_metadata(operation_id, summary.current_draft_version) + .await?; + + let input_sample = latest_sample_ref(&samples, SampleKind::InputJson) + .ok_or_else(|| ApiError::validation("input_json sample was not found"))?; + let output_sample = latest_sample_ref(&samples, SampleKind::OutputJson) + .ok_or_else(|| ApiError::validation("output_json sample was not found"))?; + let input_value = self.storage.read_json(&input_sample.storage_ref).await?; + let output_value = self.storage.read_json(&output_sample.storage_ref).await?; + + let input_schema = Schema::from_json_sample(&input_value); + let output_schema = Schema::from_json_sample(&output_value); + let input_mapping = infer_mapping_from_samples( + &input_value, + JsonPathRoot::Mcp, + &input_value, + JsonPathRoot::RequestBody, + ); + let output_mapping = infer_mapping_from_samples( + &output_value, + JsonPathRoot::ResponseBody, + &output_value, + JsonPathRoot::Output, + ); + let source_types = if payload.sources.is_empty() { + vec![ + "input_json_sample".to_owned(), + "output_json_sample".to_owned(), + ] + } else { + payload.sources + }; + let generated_draft = GeneratedDraft { + status: GeneratedDraftStatus::Available, + source_types, + generated_at: Some(now_string()?), + input_schema_generated: true, + output_schema_generated: true, + input_mapping_generated: true, + output_mapping_generated: true, + warnings: Vec::new(), + }; + + Ok(DraftGenerationResult { + generated_draft, + input_schema, + output_schema, + input_mapping, + output_mapping, + }) + } + + fn validate_operation_payload(&self, payload: &OperationPayload) -> Result<(), ApiError> { + validate_protocol_target(payload.protocol, &payload.target)?; + payload.input_mapping.validate_paths()?; + payload.output_mapping.validate_paths()?; + Ok(()) + } + + async fn find_operation_by_name( + &self, + name: &str, + ) -> Result, ApiError> { + Ok(self + .registry + .list_operations() + .await? + .into_iter() + .find(|operation| operation.name == name)) + } +} + +fn build_request_preview( + mapping: &MappingSet, + input: &Value, +) -> Result { + let mapped = mapping.apply(&json!({ "mcp": input }))?; + let prepared = PreparedRequest::from_mapping_output(&mapped).map_err(|error| { + mcpaas_mapping::MappingError::InvalidJsonPath { + path: error.to_string(), + } + })?; + + Ok(json!({ + "path": prepared.path_params, + "query": prepared.query_params, + "headers": prepared.headers, + "body": prepared.body.unwrap_or(Value::Null) + })) +} + +fn validate_protocol_target(protocol: Protocol, target: &Target) -> Result<(), ApiError> { + let is_match = matches!( + (protocol, target), + (Protocol::Rest, Target::Rest(_)) + | (Protocol::Graphql, Target::Graphql(_)) + | (Protocol::Grpc, Target::Grpc(_)) + ); + + if is_match { + return Ok(()); + } + + Err(ApiError::validation("protocol and target kind must match")) +} + +fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), ApiError> { + let is_match = matches!( + (kind, config), + (AuthKind::Bearer, AuthConfig::Bearer(_)) + | (AuthKind::Basic, AuthConfig::Basic(_)) + | (AuthKind::ApiKeyHeader, AuthConfig::ApiKeyHeader(_)) + | (AuthKind::ApiKeyQuery, AuthConfig::ApiKeyQuery(_)) + ); + + if is_match { + return Ok(()); + } + + Err(ApiError::validation("auth kind and config must match")) +} + +fn latest_sample_ref( + samples: &[OperationSampleMetadata], + sample_kind: SampleKind, +) -> Option { + samples + .iter() + .rev() + .find(|sample| sample.sample_kind == sample_kind) + .cloned() +} + +fn default_export_mode() -> ExportMode { + ExportMode::Portable +} + +fn new_prefixed_id(prefix: &str) -> String { + format!("{prefix}_{}", Uuid::now_v7().simple()) +} + +fn now_string() -> Result { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string())) +} diff --git a/apps/admin-api/src/state.rs b/apps/admin-api/src/state.rs new file mode 100644 index 0000000..6c618ca --- /dev/null +++ b/apps/admin-api/src/state.rs @@ -0,0 +1,6 @@ +use crate::service::AdminService; + +#[derive(Clone)] +pub struct AppState { + pub service: AdminService, +} diff --git a/apps/admin-api/src/storage.rs b/apps/admin-api/src/storage.rs new file mode 100644 index 0000000..cd734ac --- /dev/null +++ b/apps/admin-api/src/storage.rs @@ -0,0 +1,84 @@ +use std::path::{Path, PathBuf}; + +use mcpaas_core::{OperationId, SampleId}; +use mcpaas_registry::SampleKind; +use serde_json::Value; +use thiserror::Error; +use tokio::fs; + +#[derive(Clone)] +pub struct LocalArtifactStorage { + root: PathBuf, +} + +#[derive(Debug, Error)] +pub enum StorageError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Serialization(#[from] serde_json::Error), + #[error("invalid storage reference: {details}")] + InvalidStorageRef { details: String }, +} + +impl LocalArtifactStorage { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + pub async fn write_json_sample( + &self, + operation_id: &OperationId, + version: u32, + sample_kind: SampleKind, + sample_id: &SampleId, + payload: &Value, + ) -> Result { + let file_name = match sample_kind { + SampleKind::InputJson => "input.json", + SampleKind::OutputJson => "output.json", + SampleKind::YamlImportSource => "source.json", + }; + let path = self + .root + .join("samples") + .join(operation_id.as_str()) + .join(format!("v{version}")) + .join(format!("{}_{}", sample_id.as_str(), file_name)); + + write_json_file(&path, payload).await?; + + Ok(to_storage_ref(&path)) + } + + pub async fn read_json(&self, storage_ref: &str) -> Result { + let path = from_storage_ref(storage_ref)?; + let bytes = fs::read(path).await?; + + Ok(serde_json::from_slice(&bytes)?) + } +} + +async fn write_json_file(path: &Path, payload: &Value) -> Result<(), StorageError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + + let bytes = serde_json::to_vec_pretty(payload)?; + fs::write(path, bytes).await?; + Ok(()) +} + +fn to_storage_ref(path: &Path) -> String { + format!("file://{}", path.display()) +} + +fn from_storage_ref(storage_ref: &str) -> Result { + let Some(path) = storage_ref.strip_prefix("file://") else { + return Err(StorageError::InvalidStorageRef { + details: storage_ref.to_owned(), + }); + }; + + Ok(PathBuf::from(path)) +} diff --git a/docs/admin-api.md b/docs/admin-api.md index a18dae2..968279c 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -198,6 +198,26 @@ - загрузить sample выходного JSON. +`admin-api v1` реализует именно JSON samples, потому что они нужны для REST сценария и draft generation уже на первом этапе. + +### `POST /api/admin/operations/{operation_id}/drafts/generate` + +Назначение: + +- построить черновую схему и mappings из сохраненных JSON samples. + +В `admin-api v1` endpoint возвращает: + +- `generated_draft` +- сгенерированные `input_schema` +- сгенерированные `output_schema` +- сгенерированные `input_mapping` +- сгенерированные `output_mapping` + +## 7. gRPC descriptor endpoints + +Следующие endpoints относятся к фазе `grpc-support` и не входят в `admin-api v1`: + ### `POST /api/admin/operations/{operation_id}/descriptors/proto` Назначение: @@ -235,40 +255,6 @@ } ``` -## 7. Черновая генерация схем и mappings - -### `POST /api/admin/operations/{operation_id}/drafts/generate` - -Назначение: - -- построить черновую схему и mappings из samples и schema artifacts. - -Тело: - -```json -{ - "sources": [ - "input_json_sample", - "output_json_sample" - ] -} -``` - -Ответ: - -```json -{ - "generated_draft": { - "status": "available", - "input_schema_generated": true, - "output_schema_generated": true, - "input_mapping_generated": true, - "output_mapping_generated": true, - "warnings": [] - } -} -``` - ## 8. Тестовый запуск ### `POST /api/admin/operations/{operation_id}/test-runs` @@ -442,10 +428,12 @@ operation: - список и чтение operations; - создание новой version; - publish; -- upload samples и descriptors; +- upload JSON samples; - auth profiles; - generate draft; - test run; - YAML import/export. Этого достаточно, чтобы UI полностью управлял жизненным циклом operation без ручного редактирования кода backend. + +`gRPC descriptor` endpoints добавляются отдельным этапом вместе с `grpc-support`.