Files
crank/apps/admin-api/src/app.rs
T
2026-03-25 19:12:58 +03:00

426 lines
14 KiB
Rust

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(),
}
}
}