feat: implement rest vertical slice

This commit is contained in:
a.tolmachev
2026-03-25 18:28:44 +03:00
parent 649a2ede0d
commit 8a7e8dbd64
15 changed files with 1547 additions and 23 deletions
+4
View File
@@ -7,7 +7,11 @@ version.workspace = true
[dependencies]
mcpaas-core = { path = "../mcpaas-core" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
[dev-dependencies]
axum.workspace = true
tokio.workspace = true
+289
View File
@@ -0,0 +1,289 @@
use std::{collections::BTreeMap, time::Duration};
use mcpaas_core::{HttpMethod, RestTarget};
use reqwest::{
Client,
header::{HeaderMap, HeaderName, HeaderValue},
};
use serde_json::Value;
use crate::{RestAdapterError, RestRequest, RestResponse};
#[derive(Clone, Debug)]
pub struct RestAdapter {
client: Client,
}
impl Default for RestAdapter {
fn default() -> Self {
Self::new()
}
}
impl RestAdapter {
pub fn new() -> Self {
Self {
client: Client::new(),
}
}
pub async fn execute(
&self,
target: &RestTarget,
request: &RestRequest,
) -> Result<RestResponse, RestAdapterError> {
let url = build_url(target, request)?;
let headers = build_headers(target, request)?;
let mut builder = self
.client
.request(to_reqwest_method(target.method), url)
.headers(headers)
.timeout(Duration::from_millis(request.timeout_ms));
if let Some(body) = &request.body {
builder = builder.json(body);
}
let response = builder.send().await?;
let status = response.status();
let headers = normalize_headers(response.headers());
let body = decode_body(response).await?;
if !status.is_success() {
return Err(RestAdapterError::UnexpectedStatus {
status: status.as_u16(),
body,
});
}
Ok(RestResponse {
status_code: status.as_u16(),
headers,
body,
})
}
}
fn build_url(target: &RestTarget, request: &RestRequest) -> Result<reqwest::Url, RestAdapterError> {
let base_url =
reqwest::Url::parse(&target.base_url).map_err(|_| RestAdapterError::InvalidBaseUrl {
url: target.base_url.clone(),
})?;
let path = substitute_path_params(&target.path_template, &request.path_params);
let mut url = base_url.join(path.trim_start_matches('/')).map_err(|_| {
RestAdapterError::InvalidBaseUrl {
url: target.base_url.clone(),
}
})?;
{
let mut query = url.query_pairs_mut();
for (key, value) in &request.query_params {
query.append_pair(key, value);
}
}
Ok(url)
}
fn substitute_path_params(path_template: &str, path_params: &BTreeMap<String, String>) -> String {
let mut rendered = path_template.to_owned();
for (key, value) in path_params {
rendered = rendered.replace(&format!("{{{key}}}"), value);
}
rendered
}
fn build_headers(
target: &RestTarget,
request: &RestRequest,
) -> Result<HeaderMap, RestAdapterError> {
let mut headers = HeaderMap::new();
for (name, value) in &target.static_headers {
insert_header(&mut headers, name, value)?;
}
for (name, value) in &request.headers {
insert_header(&mut headers, name, value)?;
}
Ok(headers)
}
fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), RestAdapterError> {
let header_name =
HeaderName::try_from(name).map_err(|_| RestAdapterError::InvalidHeaderName {
header: name.to_owned(),
})?;
let header_value =
HeaderValue::try_from(value).map_err(|_| RestAdapterError::InvalidHeaderValue {
header: name.to_owned(),
})?;
headers.insert(header_name, header_value);
Ok(())
}
async fn decode_body(response: reqwest::Response) -> Result<Value, RestAdapterError> {
let bytes = response.bytes().await?;
if bytes.is_empty() {
return Ok(Value::Null);
}
match serde_json::from_slice::<Value>(&bytes) {
Ok(value) => Ok(value),
Err(_) => Ok(Value::String(
String::from_utf8_lossy(&bytes).trim().to_owned(),
)),
}
}
fn normalize_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
headers
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|value| (name.as_str().to_owned(), value.to_owned()))
})
.collect()
}
fn to_reqwest_method(method: HttpMethod) -> reqwest::Method {
match method {
HttpMethod::Get => reqwest::Method::GET,
HttpMethod::Post => reqwest::Method::POST,
HttpMethod::Put => reqwest::Method::PUT,
HttpMethod::Patch => reqwest::Method::PATCH,
HttpMethod::Delete => reqwest::Method::DELETE,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use axum::{
Json, Router,
extract::{Path, Query},
http::HeaderMap,
routing::{get, post},
};
use mcpaas_core::{HttpMethod, RestTarget};
use serde_json::{Value, json};
use tokio::net::TcpListener;
use crate::{RestAdapter, RestAdapterError, RestRequest};
#[tokio::test]
async fn executes_rest_request_and_normalizes_json_response() {
let base_url = spawn_test_server().await;
let adapter = RestAdapter::new();
let target = RestTarget {
base_url,
method: HttpMethod::Post,
path_template: "/users/{user_id}".to_owned(),
static_headers: BTreeMap::from([("x-static".to_owned(), "static".to_owned())]),
};
let request = RestRequest {
path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]),
query_params: BTreeMap::from([("expand".to_owned(), "true".to_owned())]),
headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]),
body: Some(json!({ "name": "Ada" })),
timeout_ms: 1_000,
};
let response = adapter.execute(&target, &request).await.unwrap();
assert_eq!(response.status_code, 200);
assert_eq!(
response.body,
json!({
"id": "42",
"query": "true",
"trace": "trace-123",
"static": "static",
"payload": { "name": "Ada" }
})
);
}
#[tokio::test]
async fn returns_unexpected_status_with_normalized_body() {
let base_url = spawn_test_server().await;
let adapter = RestAdapter::new();
let target = RestTarget {
base_url,
method: HttpMethod::Get,
path_template: "/fail".to_owned(),
static_headers: BTreeMap::new(),
};
let request = RestRequest {
path_params: BTreeMap::new(),
query_params: BTreeMap::new(),
headers: BTreeMap::new(),
body: None,
timeout_ms: 1_000,
};
let error = adapter.execute(&target, &request).await.unwrap_err();
assert!(matches!(
error,
RestAdapterError::UnexpectedStatus {
status: 502,
body: Value::Object(_)
}
));
}
async fn spawn_test_server() -> String {
let app = Router::new()
.route("/users/{user_id}", post(create_user))
.route("/fail", get(fail));
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_user(
Path(user_id): Path<String>,
Query(query): Query<BTreeMap<String, String>>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Json<Value> {
let trace = headers
.get("x-trace-id")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let static_header = headers
.get("x-static")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
Json(json!({
"id": user_id,
"query": query.get("expand").cloned().unwrap_or_default(),
"trace": trace,
"static": static_header,
"payload": payload
}))
}
async fn fail() -> (axum::http::StatusCode, Json<Value>) {
(
axum::http::StatusCode::BAD_GATEWAY,
Json(json!({ "error": "upstream failed" })),
)
}
}
+20
View File
@@ -0,0 +1,20 @@
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RestAdapterError {
#[error("invalid base url: {url}")]
InvalidBaseUrl { url: String },
#[error("invalid path parameter {key}")]
InvalidPathParameter { key: String },
#[error("invalid query parameter {key}")]
InvalidQueryParameter { key: String },
#[error("invalid header name {header}")]
InvalidHeaderName { header: String },
#[error("invalid header value for {header}")]
InvalidHeaderValue { header: String },
#[error("request failed")]
Transport(#[from] reqwest::Error),
#[error("rest endpoint returned status {status}")]
UnexpectedStatus { status: u16, body: Value },
}
+7 -3
View File
@@ -1,3 +1,7 @@
pub fn crate_name() -> &'static str {
"mcpaas-adapter-rest"
}
mod client;
mod error;
mod model;
pub use client::RestAdapter;
pub use error::RestAdapterError;
pub use model::{RestRequest, RestResponse};
+25
View File
@@ -0,0 +1,25 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct RestRequest {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub path_params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub query_params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
pub timeout_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RestResponse {
pub status_code: u16,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
pub body: Value,
}
+3
View File
@@ -16,3 +16,6 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
[dev-dependencies]
axum.workspace = true
tokio.workspace = true
+19
View File
@@ -0,0 +1,19 @@
use mcpaas_adapter_rest::RestAdapterError;
use mcpaas_core::Protocol;
use mcpaas_mapping::MappingError;
use mcpaas_schema::SchemaError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RuntimeError {
#[error(transparent)]
Schema(#[from] SchemaError),
#[error(transparent)]
Mapping(#[from] MappingError),
#[error(transparent)]
RestAdapter(#[from] RestAdapterError),
#[error("protocol {protocol:?} is not supported by runtime")]
UnsupportedProtocol { protocol: Protocol },
#[error("invalid prepared request: {details}")]
InvalidPreparedRequest { details: String },
}
+381
View File
@@ -0,0 +1,381 @@
use std::collections::BTreeMap;
use mcpaas_adapter_rest::{RestAdapter, RestRequest};
use mcpaas_core::Target;
use serde_json::{Map, Value, json};
use crate::{AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation};
#[derive(Clone, Debug)]
pub struct RuntimeExecutor {
rest_adapter: RestAdapter,
}
impl Default for RuntimeExecutor {
fn default() -> Self {
Self::new()
}
}
impl RuntimeExecutor {
pub fn new() -> Self {
Self {
rest_adapter: RestAdapter::new(),
}
}
pub async fn execute(
&self,
operation: &RuntimeOperation,
input: &Value,
) -> Result<Value, RuntimeError> {
operation.input_schema.validate_shape(input)?;
let mapped_input = operation.input_mapping.apply(&json!({ "mcp": input }))?;
let prepared_request = PreparedRequest::from_mapping_output(&mapped_input)?;
let adapter_response = match &operation.target {
Target::Rest(target) => {
let request = RestRequest {
path_params: prepared_request.path_params.clone(),
query_params: prepared_request.query_params.clone(),
headers: merge_headers(
&target.static_headers,
&operation.execution_config.headers,
&prepared_request.headers,
),
body: prepared_request.body.clone(),
timeout_ms: operation.execution_config.timeout_ms,
};
let response = self.rest_adapter.execute(target, &request).await?;
AdapterResponse {
status_code: response.status_code,
headers: response.headers,
body: response.body,
}
}
_ => {
return Err(RuntimeError::UnsupportedProtocol {
protocol: operation.protocol,
});
}
};
let finalized_output = finalize_output(operation, &adapter_response)?;
operation.output_schema.validate_shape(&finalized_output)?;
Ok(finalized_output)
}
}
impl PreparedRequest {
pub fn from_mapping_output(mapped: &Value) -> Result<Self, RuntimeError> {
let request =
mapped
.get("request")
.ok_or_else(|| RuntimeError::InvalidPreparedRequest {
details: "mapped input must contain request root".to_owned(),
})?;
Ok(Self {
path_params: read_string_map(request.get("path"), "request.path")?,
query_params: read_string_map(request.get("query"), "request.query")?,
headers: read_string_map(request.get("headers"), "request.headers")?,
body: request.get("body").and_then(non_empty_body).cloned(),
})
}
}
fn finalize_output(
operation: &RuntimeOperation,
response: &AdapterResponse,
) -> Result<Value, RuntimeError> {
let mapped = operation.output_mapping.apply(&json!({
"response": {
"body": response.body,
"data": response.body,
"headers": response.headers,
"status": response.status_code
}
}))?;
Ok(mapped
.get("output")
.cloned()
.unwrap_or_else(|| Value::Object(Map::new())))
}
fn merge_headers(
static_headers: &BTreeMap<String, String>,
execution_headers: &BTreeMap<String, String>,
request_headers: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut headers = static_headers.clone();
headers.extend(execution_headers.clone());
headers.extend(request_headers.clone());
headers
}
fn read_string_map(
value: Option<&Value>,
field_name: &str,
) -> Result<BTreeMap<String, String>, RuntimeError> {
let Some(value) = value else {
return Ok(BTreeMap::new());
};
let Some(object) = value.as_object() else {
return Err(RuntimeError::InvalidPreparedRequest {
details: format!("{field_name} must be an object"),
});
};
object
.iter()
.map(|(key, value)| stringify_value(value).map(|value| (key.clone(), value)))
.collect::<Result<BTreeMap<_, _>, _>>()
.map_err(|details| RuntimeError::InvalidPreparedRequest { details })
}
fn stringify_value(value: &Value) -> Result<String, String> {
match value {
Value::Null => Ok("null".to_owned()),
Value::Bool(value) => Ok(value.to_string()),
Value::Number(value) => Ok(value.to_string()),
Value::String(value) => Ok(value.clone()),
Value::Array(_) | Value::Object(_) => {
Err("request path/query/headers accept only scalar values".to_owned())
}
}
}
fn non_empty_body(value: &Value) -> Option<&Value> {
match value {
Value::Null => None,
Value::Object(object) if object.is_empty() => None,
_ => Some(value),
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use axum::{Json, Router, routing::post};
use mcpaas_core::{
ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, HttpMethod, Operation, OperationId,
OperationStatus, Protocol, RestTarget, Samples, Target, ToolDescription, ToolExample,
};
use mcpaas_mapping::{MappingRule, MappingSet};
use mcpaas_schema::{Schema, SchemaKind};
use serde_json::{Value, json};
use tokio::net::TcpListener;
use crate::{RuntimeError, RuntimeExecutor, RuntimeOperation};
#[tokio::test]
async fn executes_rest_operation_end_to_end() {
let base_url = spawn_runtime_server().await;
let executor = RuntimeExecutor::new();
let operation = test_rest_operation(&base_url, false, false);
let output = executor
.execute(&operation, &json!({ "email": "user@example.com" }))
.await
.unwrap();
assert_eq!(output, json!({ "id": "lead_123" }));
}
#[tokio::test]
async fn rejects_invalid_input_shape() {
let base_url = spawn_runtime_server().await;
let executor = RuntimeExecutor::new();
let operation = test_rest_operation(&base_url, false, false);
let error = executor.execute(&operation, &json!({})).await.unwrap_err();
assert!(matches!(error, RuntimeError::Schema(_)));
}
#[tokio::test]
async fn propagates_external_rest_errors() {
let base_url = spawn_runtime_server().await;
let executor = RuntimeExecutor::new();
let operation = test_rest_operation(&base_url, true, false);
let error = executor
.execute(&operation, &json!({ "email": "user@example.com" }))
.await
.unwrap_err();
assert!(matches!(error, RuntimeError::RestAdapter(_)));
}
#[tokio::test]
async fn fails_when_output_mapping_requires_missing_field() {
let base_url = spawn_runtime_server().await;
let executor = RuntimeExecutor::new();
let operation = test_rest_operation(&base_url, false, true);
let error = executor
.execute(&operation, &json!({ "email": "user@example.com" }))
.await
.unwrap_err();
assert!(matches!(error, RuntimeError::Mapping(_)));
}
async fn spawn_runtime_server() -> String {
let app = Router::new().route("/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 create_lead(Json(payload): Json<Value>) -> (axum::http::StatusCode, Json<Value>) {
let should_fail = payload
.get("fail")
.and_then(Value::as_bool)
.unwrap_or(false);
if should_fail {
return (
axum::http::StatusCode::BAD_GATEWAY,
Json(json!({ "error": "upstream failed" })),
);
}
(
axum::http::StatusCode::OK,
Json(json!({ "id": "lead_123", "email": payload["email"] })),
)
}
fn test_rest_operation(
base_url: &str,
should_fail: bool,
invalid_output: bool,
) -> RuntimeOperation {
let mut input_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,
}];
if should_fail {
input_rules.push(MappingRule {
source: "$.mcp.fail".to_owned(),
target: "$.request.body.fail".to_owned(),
required: false,
default_value: Some(Value::Bool(true)),
transform: None,
condition: None,
notes: None,
});
}
let output_source = if invalid_output {
"$.response.body.missing"
} else {
"$.response.body.id"
};
RuntimeOperation::from(Operation {
id: OperationId::new("op_rest_runtime"),
name: "crm_create_lead".to_owned(),
display_name: "Create Lead".to_owned(),
protocol: Protocol::Rest,
status: OperationStatus::Published,
version: 1,
target: Target::Rest(RestTarget {
base_url: base_url.to_owned(),
method: HttpMethod::Post,
path_template: "/leads".to_owned(),
static_headers: BTreeMap::new(),
}),
input_schema: object_schema("email", SchemaKind::String),
output_schema: object_schema("id", SchemaKind::String),
input_mapping: MappingSet { rules: input_rules },
output_mapping: MappingSet {
rules: vec![MappingRule {
source: output_source.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![ToolExample {
input: json!({ "email": "user@example.com" }),
}],
},
samples: Some(Samples::default()),
generated_draft: Some(GeneratedDraft {
status: GeneratedDraftStatus::Available,
source_types: vec!["input_json".to_owned()],
generated_at: Some("2026-03-25T20:00:00Z".to_owned()),
input_schema_generated: true,
output_schema_generated: true,
input_mapping_generated: true,
output_mapping_generated: true,
warnings: Vec::new(),
}),
config_export: None,
created_at: "2026-03-25T20:00:00Z".to_owned(),
updated_at: "2026-03-25T20:00:00Z".to_owned(),
published_at: Some("2026-03-25T20:00:00Z".to_owned()),
})
}
fn object_schema(field_name: &str, kind: SchemaKind) -> Schema {
Schema {
kind: SchemaKind::Object,
description: None,
required: true,
nullable: false,
default_value: None,
fields: BTreeMap::from([(
field_name.to_owned(),
Schema {
kind,
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(),
}
}
}
+7 -3
View File
@@ -1,3 +1,7 @@
pub fn crate_name() -> &'static str {
"mcpaas-runtime"
}
mod error;
mod executor;
mod model;
pub use error::RuntimeError;
pub use executor::RuntimeExecutor;
pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation};
+58
View File
@@ -0,0 +1,58 @@
use std::collections::BTreeMap;
use mcpaas_core::{ExecutionConfig, Operation, OperationId, Protocol, Target, ToolDescription};
use mcpaas_mapping::MappingSet;
use mcpaas_schema::Schema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeOperation {
pub operation_id: OperationId,
pub tool_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: ExecutionConfig,
pub tool_description: ToolDescription,
}
impl From<Operation<Schema, MappingSet>> for RuntimeOperation {
fn from(value: Operation<Schema, MappingSet>) -> Self {
Self {
operation_id: value.id,
tool_name: value.name,
protocol: value.protocol,
target: value.target,
input_schema: value.input_schema,
output_schema: value.output_schema,
input_mapping: value.input_mapping,
output_mapping: value.output_mapping,
execution_config: value.execution_config,
tool_description: value.tool_description,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct PreparedRequest {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub path_params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub query_params: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AdapterResponse {
pub status_code: u16,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
pub body: Value,
}