feat: implement admin api v1
This commit is contained in:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user