feat: add graphql support

This commit is contained in:
a.tolmachev
2026-03-25 20:21:07 +03:00
parent 6296b04105
commit 1ea75eb824
16 changed files with 824 additions and 30 deletions
+3
View File
@@ -1,3 +1,4 @@
use mcpaas_adapter_graphql::GraphqlAdapterError;
use mcpaas_adapter_rest::RestAdapterError;
use mcpaas_core::Protocol;
use mcpaas_mapping::MappingError;
@@ -11,6 +12,8 @@ pub enum RuntimeError {
#[error(transparent)]
Mapping(#[from] MappingError),
#[error(transparent)]
GraphqlAdapter(#[from] GraphqlAdapterError),
#[error(transparent)]
RestAdapter(#[from] RestAdapterError),
#[error("protocol {protocol:?} is not supported by runtime")]
UnsupportedProtocol { protocol: Protocol },
+174 -3
View File
@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use mcpaas_adapter_graphql::{GraphqlAdapter, GraphqlRequest};
use mcpaas_adapter_rest::{RestAdapter, RestRequest};
use mcpaas_core::Target;
use serde_json::{Map, Value, json};
@@ -8,6 +9,7 @@ use crate::{AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation};
#[derive(Clone, Debug)]
pub struct RuntimeExecutor {
graphql_adapter: GraphqlAdapter,
rest_adapter: RestAdapter,
}
@@ -20,6 +22,7 @@ impl Default for RuntimeExecutor {
impl RuntimeExecutor {
pub fn new() -> Self {
Self {
graphql_adapter: GraphqlAdapter::new(),
rest_adapter: RestAdapter::new(),
}
}
@@ -35,6 +38,25 @@ impl RuntimeExecutor {
let prepared_request = PreparedRequest::from_mapping_output(&mapped_input)?;
let adapter_response = match &operation.target {
Target::Graphql(target) => {
let request = GraphqlRequest {
headers: merge_headers(
&BTreeMap::new(),
&operation.execution_config.headers,
&prepared_request.headers,
),
variables: prepared_request.variables.clone(),
timeout_ms: operation.execution_config.timeout_ms,
};
let response = self.graphql_adapter.execute(target, &request).await?;
AdapterResponse {
status_code: response.status_code,
headers: response.headers,
body: response.body,
data: response.data,
}
}
Target::Rest(target) => {
let request = RestRequest {
path_params: prepared_request.path_params.clone(),
@@ -53,6 +75,7 @@ impl RuntimeExecutor {
status_code: response.status_code,
headers: response.headers,
body: response.body,
data: Value::Null,
}
}
_ => {
@@ -83,6 +106,7 @@ impl PreparedRequest {
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")?,
variables: non_empty_payload(request.get("variables").cloned()),
body: request.get("body").and_then(non_empty_body).cloned(),
})
}
@@ -95,7 +119,7 @@ fn finalize_output(
let mapped = operation.output_mapping.apply(&json!({
"response": {
"body": response.body,
"data": response.body,
"data": response.data,
"headers": response.headers,
"status": response.status_code
}
@@ -159,14 +183,23 @@ fn non_empty_body(value: &Value) -> Option<&Value> {
}
}
fn non_empty_payload(value: Option<Value>) -> Option<Value> {
match value {
None | Some(Value::Null) => None,
Some(Value::Object(object)) if object.is_empty() => None,
other => other,
}
}
#[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,
ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlOperationType, GraphqlTarget,
HttpMethod, Operation, OperationId, OperationStatus, Protocol, RestTarget, Samples, Target,
ToolDescription, ToolExample,
};
use mcpaas_mapping::{MappingRule, MappingSet};
use mcpaas_schema::{Schema, SchemaKind};
@@ -189,6 +222,20 @@ mod tests {
assert_eq!(output, json!({ "id": "lead_123" }));
}
#[tokio::test]
async fn executes_graphql_operation_end_to_end() {
let endpoint = spawn_graphql_server().await;
let executor = RuntimeExecutor::new();
let operation = test_graphql_operation(&endpoint, 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;
@@ -228,6 +275,20 @@ mod tests {
assert!(matches!(error, RuntimeError::Mapping(_)));
}
#[tokio::test]
async fn propagates_graphql_operation_errors() {
let endpoint = spawn_graphql_server().await;
let executor = RuntimeExecutor::new();
let operation = test_graphql_operation(&endpoint, true);
let error = executor
.execute(&operation, &json!({ "email": "fail@example.com" }))
.await
.unwrap_err();
assert!(matches!(error, RuntimeError::GraphqlAdapter(_)));
}
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();
@@ -240,6 +301,18 @@ mod tests {
format!("http://{}", address)
}
async fn spawn_graphql_server() -> String {
let app = Router::new().route("/", post(graphql_handler));
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")
@@ -259,6 +332,30 @@ mod tests {
)
}
async fn graphql_handler(Json(payload): Json<Value>) -> Json<Value> {
let email = payload
.get("variables")
.and_then(|variables| variables.get("email"))
.and_then(Value::as_str)
.unwrap_or_default();
if email == "fail@example.com" {
return Json(json!({
"data": { "createLead": null },
"errors": [{ "message": "lead creation failed" }]
}));
}
Json(json!({
"data": {
"createLead": {
"id": "lead_123",
"status": "created"
}
}
}))
}
fn test_rest_operation(
base_url: &str,
should_fail: bool,
@@ -352,6 +449,80 @@ mod tests {
})
}
fn test_graphql_operation(endpoint: &str, _should_fail: bool) -> RuntimeOperation {
RuntimeOperation::from(Operation {
id: OperationId::new("op_graphql_runtime"),
name: "crm_create_lead_graphql".to_owned(),
display_name: "Create Lead GraphQL".to_owned(),
protocol: Protocol::Graphql,
status: OperationStatus::Published,
version: 1,
target: Target::Graphql(GraphqlTarget {
endpoint: endpoint.to_owned(),
operation_type: GraphqlOperationType::Mutation,
operation_name: "CreateLead".to_owned(),
query_template:
"mutation CreateLead($email: String!) { createLead(email: $email) { id status } }"
.to_owned(),
response_path: "$.response.body.data.createLead".to_owned(),
}),
input_schema: object_schema("email", SchemaKind::String),
output_schema: object_schema("id", SchemaKind::String),
input_mapping: MappingSet {
rules: vec![MappingRule {
source: "$.mcp.email".to_owned(),
target: "$.request.variables.email".to_owned(),
required: true,
default_value: None,
transform: None,
condition: None,
notes: None,
}],
},
output_mapping: MappingSet {
rules: vec![MappingRule {
source: "$.response.data.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 GraphQL".to_owned(),
description: "Creates a CRM lead through GraphQL".to_owned(),
tags: vec!["crm".to_owned(), "graphql".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(), "output_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,
+3
View File
@@ -46,6 +46,8 @@ pub struct PreparedRequest {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variables: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
}
@@ -55,4 +57,5 @@ pub struct AdapterResponse {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
pub body: Value,
pub data: Value,
}