feat: implement admin api v1

This commit is contained in:
a.tolmachev
2026-03-25 19:12:58 +03:00
parent 0f72f2a6cb
commit aef87f582f
14 changed files with 1880 additions and 57 deletions
Generated
+270 -2
View File
@@ -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"
+2
View File
@@ -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"] }
+8 -8
View File
@@ -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`
+15 -1
View File
@@ -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
+425
View File
@@ -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::<Value>()
.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::<Value>()
.await
.unwrap();
let published = client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.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::<Value>()
.await
.unwrap();
let created = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_export_target",
))
.send()
.await
.unwrap()
.json::<Value>()
.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::<Value>()
.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::<Value>()
.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::<Value>()
.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<Value>) -> Json<Value> {
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(),
}
}
}
+143
View File
@@ -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<String>) -> Self {
Self::Validation {
message: message.into(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::NotFound {
message: message.into(),
}
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::Conflict {
message: message.into(),
}
}
pub fn internal(message: impl Into<String>) -> 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<RegistryError> 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<MappingError> for ApiError {
fn from(value: MappingError) -> Self {
Self::validation(value.to_string())
}
}
impl From<SchemaError> for ApiError {
fn from(value: SchemaError) -> Self {
Self::validation(value.to_string())
}
}
impl From<StorageError> 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()
})
}
+20 -11
View File
@@ -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<dyn std::error::Error>> {
tracing_subscriber::fmt()
@@ -14,9 +22,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
)
.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<dyn std::error::Error>> {
Ok(())
}
async fn health() -> Json<serde_json::Value> {
Json(json!({
"service": "admin-api",
"status": "ok"
}))
}
+12
View File
@@ -0,0 +1,12 @@
pub mod auth_profiles;
pub mod operations;
use axum::Json;
use serde_json::json;
pub async fn health() -> Json<serde_json::Value> {
Json(json!({
"service": "admin-api",
"status": "ok"
}))
}
@@ -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<AppState>) -> Result<Json<Value>, ApiError> {
let items = state.service.list_auth_profiles().await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_auth_profile(
State(state): State<AppState>,
Json(payload): Json<AuthProfilePayload>,
) -> Result<Json<Value>, 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<String>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let profile = state
.service
.get_auth_profile(&auth_profile_id.as_str().into())
.await?;
Ok(Json(json!(profile)))
}
+168
View File
@@ -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<AppState>) -> Result<Json<Value>, ApiError> {
let items = state.service.list_operations().await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_operation(
State(state): State<AppState>,
Json(payload): Json<OperationPayload>,
) -> Result<Json<Value>, ApiError> {
let created = state.service.create_operation(payload).await?;
Ok(Json(json!(created)))
}
pub async fn get_operation(
Path(operation_id): Path<String>,
State(state): State<AppState>,
) -> Result<Json<Value>, 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<AppState>,
) -> Result<Json<Value>, 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<String>,
State(state): State<AppState>,
Json(payload): Json<NewVersionPayload>,
) -> Result<Json<Value>, 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<String>,
State(state): State<AppState>,
Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, 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<String>,
State(state): State<AppState>,
Json(payload): Json<TestRunPayload>,
) -> Result<Json<Value>, 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<String>,
State(state): State<AppState>,
Json(payload): Json<Value>,
) -> Result<Json<Value>, 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<String>,
State(state): State<AppState>,
Json(payload): Json<Value>,
) -> Result<Json<Value>, 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<String>,
State(state): State<AppState>,
Json(payload): Json<GenerateDraftPayload>,
) -> Result<Json<Value>, 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<String>,
Query(query): Query<ExportQuery>,
State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> {
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<ImportQuery>,
State(state): State<AppState>,
body: String,
) -> Result<Json<Value>, ApiError> {
let imported = state.service.import_operation(query, &body).await?;
Ok(Json(json!(imported)))
}
+673
View File
@@ -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<String>,
}
#[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<Value>,
}
#[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<String>,
}
#[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<u32>,
#[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<String>,
}
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<Vec<OperationSummary>, ApiError> {
Ok(self.registry.list_operations().await?)
}
pub async fn get_operation(
&self,
operation_id: &OperationId,
) -> Result<OperationSummary, ApiError> {
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<OperationVersionRecord, ApiError> {
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<CreatedOperationResponse, ApiError> {
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<CreatedOperationResponse, ApiError> {
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<PublishResponse, ApiError> {
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<TestRunResult, ApiError> {
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<Vec<AuthProfile>, ApiError> {
Ok(self.registry.list_auth_profiles().await?)
}
pub async fn get_auth_profile(
&self,
auth_profile_id: &AuthProfileId,
) -> Result<AuthProfile, ApiError> {
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<AuthProfile, ApiError> {
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<String, ApiError> {
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<ImportResponse, ApiError> {
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<OperationSampleMetadata, ApiError> {
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<DraftGenerationResult, ApiError> {
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<Option<OperationSummary>, ApiError> {
Ok(self
.registry
.list_operations()
.await?
.into_iter()
.find(|operation| operation.name == name))
}
}
fn build_request_preview(
mapping: &MappingSet,
input: &Value,
) -> Result<Value, mcpaas_mapping::MappingError> {
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<OperationSampleMetadata> {
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<String, ApiError> {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))
}
+6
View File
@@ -0,0 +1,6 @@
use crate::service::AdminService;
#[derive(Clone)]
pub struct AppState {
pub service: AdminService,
}
+84
View File
@@ -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<String, StorageError> {
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<Value, StorageError> {
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<PathBuf, StorageError> {
let Some(path) = storage_ref.strip_prefix("file://") else {
return Err(StorageError::InvalidStorageRef {
details: storage_ref.to_owned(),
});
};
Ok(PathBuf::from(path))
}
+23 -35
View File
@@ -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`.