feat: implement admin api v1
This commit is contained in:
Generated
+270
-2
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use crate::service::AdminService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub service: AdminService,
|
||||
}
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user