feat: add graphql support
This commit is contained in:
Generated
+4
@@ -863,10 +863,14 @@ dependencies = [
|
|||||||
name = "mcpaas-adapter-graphql"
|
name = "mcpaas-adapter-graphql"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"axum",
|
||||||
"mcpaas-core",
|
"mcpaas-core",
|
||||||
|
"mcpaas-mapping",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -2,21 +2,21 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/mcp-server`
|
### `feat/graphql-support`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
|
|
||||||
- `mcp-server` exposes a single MCP HTTP endpoint
|
- GraphQL operation can be created, tested and published
|
||||||
- initialize and initialized lifecycle work with session handling
|
- variables mapping and `response_path` work in runtime
|
||||||
- tools are listed and called through MCP JSON-RPC methods
|
- GraphQL `errors` are translated into runtime failures
|
||||||
- published tool catalog refreshes without service restart
|
- published GraphQL tool is callable through MCP
|
||||||
- MCP integration tests cover initialize, list, call and refresh flow
|
- integration tests cover admin-api, runtime and MCP GraphQL scenarios
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/graphql-support`
|
- `feat/grpc-support`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
+141
-1
@@ -67,7 +67,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use axum::{Json, Router, routing::post};
|
use axum::{Json, Router, routing::post};
|
||||||
use mcpaas_core::{ExecutionConfig, HttpMethod, Protocol, RestTarget, Target, ToolDescription};
|
use mcpaas_core::{
|
||||||
|
ExecutionConfig, GraphqlOperationType, GraphqlTarget, HttpMethod, Protocol, RestTarget,
|
||||||
|
Target, ToolDescription,
|
||||||
|
};
|
||||||
use mcpaas_mapping::{MappingRule, MappingSet};
|
use mcpaas_mapping::{MappingRule, MappingSet};
|
||||||
use mcpaas_registry::PostgresRegistry;
|
use mcpaas_registry::PostgresRegistry;
|
||||||
use mcpaas_schema::{Schema, SchemaKind};
|
use mcpaas_schema::{Schema, SchemaKind};
|
||||||
@@ -143,6 +146,59 @@ mod tests {
|
|||||||
assert_eq!(test_run["response_preview"]["id"], "lead_123");
|
assert_eq!(test_run["response_preview"]["id"], "lead_123");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn creates_publishes_and_tests_graphql_operation() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let storage_root = test_storage_root("graphql");
|
||||||
|
let upstream_base_url = spawn_graphql_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_graphql_operation_payload(
|
||||||
|
&upstream_base_url,
|
||||||
|
"crm_create_lead_graphql",
|
||||||
|
))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
|
||||||
|
|
||||||
|
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!(published["published_version"], 1);
|
||||||
|
assert_eq!(test_run["ok"], true);
|
||||||
|
assert_eq!(
|
||||||
|
test_run["request_preview"]["variables"]["email"],
|
||||||
|
"user@example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(test_run["response_preview"]["id"], "lead_123");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn manages_auth_profiles_and_yaml_upsert() {
|
async fn manages_auth_profiles_and_yaml_upsert() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
@@ -288,6 +344,18 @@ mod tests {
|
|||||||
format!("http://{}", address)
|
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 spawn_admin_api(app: Router) -> String {
|
async fn spawn_admin_api(app: Router) -> String {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let address = listener.local_addr().unwrap();
|
let address = listener.local_addr().unwrap();
|
||||||
@@ -307,6 +375,24 @@ 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();
|
||||||
|
|
||||||
|
Json(json!({
|
||||||
|
"data": {
|
||||||
|
"createLead": {
|
||||||
|
"id": "lead_123",
|
||||||
|
"status": "created",
|
||||||
|
"email": email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn test_registry() -> PostgresRegistry {
|
async fn test_registry() -> PostgresRegistry {
|
||||||
let database_url = env::var("TEST_DATABASE_URL")
|
let database_url = env::var("TEST_DATABASE_URL")
|
||||||
.unwrap_or_else(|_| "postgres://rmcp:rmcp@127.0.0.1:5432/rmcp".to_owned());
|
.unwrap_or_else(|_| "postgres://rmcp:rmcp@127.0.0.1:5432/rmcp".to_owned());
|
||||||
@@ -396,6 +482,60 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_graphql_operation_payload(endpoint: &str, name: &str) -> OperationPayload {
|
||||||
|
OperationPayload {
|
||||||
|
name: name.to_owned(),
|
||||||
|
display_name: "Create Lead GraphQL".to_owned(),
|
||||||
|
protocol: Protocol::Graphql,
|
||||||
|
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 email } }"
|
||||||
|
.to_owned(),
|
||||||
|
response_path: "$.response.body.data.createLead".to_owned(),
|
||||||
|
}),
|
||||||
|
input_schema: object_schema("email"),
|
||||||
|
output_schema: object_schema("id"),
|
||||||
|
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::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn object_schema(field_name: &str) -> Schema {
|
fn object_schema(field_name: &str) -> Schema {
|
||||||
Schema {
|
Schema {
|
||||||
kind: SchemaKind::Object,
|
kind: SchemaKind::Object,
|
||||||
|
|||||||
@@ -612,6 +612,7 @@ fn build_request_preview(
|
|||||||
"path": prepared.path_params,
|
"path": prepared.path_params,
|
||||||
"query": prepared.query_params,
|
"query": prepared.query_params,
|
||||||
"headers": prepared.headers,
|
"headers": prepared.headers,
|
||||||
|
"variables": prepared.variables.unwrap_or(Value::Null),
|
||||||
"body": prepared.body.unwrap_or(Value::Null)
|
"body": prepared.body.unwrap_or(Value::Null)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
|||||||
match error {
|
match error {
|
||||||
RuntimeError::Schema(_) => "schema_validation_error",
|
RuntimeError::Schema(_) => "schema_validation_error",
|
||||||
RuntimeError::Mapping(_) => "mapping_error",
|
RuntimeError::Mapping(_) => "mapping_error",
|
||||||
|
RuntimeError::GraphqlAdapter(_) => "adapter_execution_error",
|
||||||
RuntimeError::RestAdapter(_) => "adapter_execution_error",
|
RuntimeError::RestAdapter(_) => "adapter_execution_error",
|
||||||
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
||||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_error",
|
RuntimeError::InvalidPreparedRequest { .. } => "runtime_error",
|
||||||
|
|||||||
+148
-2
@@ -50,8 +50,8 @@ mod tests {
|
|||||||
|
|
||||||
use axum::{Json, Router, http::header, routing::post};
|
use axum::{Json, Router, http::header, routing::post};
|
||||||
use mcpaas_core::{
|
use mcpaas_core::{
|
||||||
ExecutionConfig, HttpMethod, Operation, OperationId, OperationStatus, Protocol, RestTarget,
|
ExecutionConfig, GraphqlOperationType, GraphqlTarget, HttpMethod, Operation, OperationId,
|
||||||
Target, ToolDescription,
|
OperationStatus, Protocol, RestTarget, Target, ToolDescription,
|
||||||
};
|
};
|
||||||
use mcpaas_mapping::{MappingRule, MappingSet};
|
use mcpaas_mapping::{MappingRule, MappingSet};
|
||||||
use mcpaas_registry::{PostgresRegistry, PublishRequest};
|
use mcpaas_registry::{PostgresRegistry, PublishRequest};
|
||||||
@@ -129,6 +129,60 @@ mod tests {
|
|||||||
assert_eq!(call_result["result"]["isError"], false);
|
assert_eq!(call_result["result"]["isError"], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn initializes_and_calls_published_graphql_tool_via_mcp() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let endpoint = spawn_graphql_server().await;
|
||||||
|
let operation = test_graphql_operation(&endpoint, "crm_create_lead_graphql");
|
||||||
|
|
||||||
|
registry
|
||||||
|
.create_operation(&operation, Some("alice"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
registry
|
||||||
|
.publish_operation(PublishRequest {
|
||||||
|
operation_id: &operation.id,
|
||||||
|
version: 1,
|
||||||
|
published_at: "2026-03-26T10:00:00Z",
|
||||||
|
published_by: Some("alice"),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let base_url = spawn_mcp_server(build_app(
|
||||||
|
registry,
|
||||||
|
Duration::from_millis(0),
|
||||||
|
Some("https://rmcp.example.com".to_owned()),
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let initialized_session = initialize_session(&client, &base_url).await;
|
||||||
|
|
||||||
|
let call_result = post_jsonrpc(
|
||||||
|
&client,
|
||||||
|
&base_url,
|
||||||
|
Some(&initialized_session),
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "crm_create_lead_graphql",
|
||||||
|
"arguments": {
|
||||||
|
"email": "user@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
call_result["result"]["structuredContent"],
|
||||||
|
json!({ "id": "lead_123" })
|
||||||
|
);
|
||||||
|
assert_eq!(call_result["result"]["isError"], false);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn requires_initialized_notification_before_tool_methods() {
|
async fn requires_initialized_notification_before_tool_methods() {
|
||||||
let registry = test_registry().await;
|
let registry = test_registry().await;
|
||||||
@@ -321,6 +375,18 @@ mod tests {
|
|||||||
format!("http://{}", address)
|
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 spawn_mcp_server(app: Router) -> String {
|
async fn spawn_mcp_server(app: Router) -> String {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let address = listener.local_addr().unwrap();
|
let address = listener.local_addr().unwrap();
|
||||||
@@ -339,6 +405,23 @@ 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();
|
||||||
|
|
||||||
|
Json(json!({
|
||||||
|
"data": {
|
||||||
|
"createLead": {
|
||||||
|
"id": "lead_123",
|
||||||
|
"email": email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn test_registry() -> PostgresRegistry {
|
async fn test_registry() -> PostgresRegistry {
|
||||||
let database_url = env::var("TEST_DATABASE_URL")
|
let database_url = env::var("TEST_DATABASE_URL")
|
||||||
.unwrap_or_else(|_| "postgres://rmcp:rmcp@127.0.0.1:5432/rmcp".to_owned());
|
.unwrap_or_else(|_| "postgres://rmcp:rmcp@127.0.0.1:5432/rmcp".to_owned());
|
||||||
@@ -426,6 +509,69 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_graphql_operation(endpoint: &str, name: &str) -> Operation<Schema, MappingSet> {
|
||||||
|
Operation {
|
||||||
|
id: OperationId::new(format!("op_{name}")),
|
||||||
|
name: name.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 email } }"
|
||||||
|
.to_owned(),
|
||||||
|
response_path: "$.response.body.data.createLead".to_owned(),
|
||||||
|
}),
|
||||||
|
input_schema: object_schema("email"),
|
||||||
|
output_schema: object_schema("id"),
|
||||||
|
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!["graphql".to_owned()],
|
||||||
|
examples: Vec::new(),
|
||||||
|
},
|
||||||
|
samples: None,
|
||||||
|
generated_draft: None,
|
||||||
|
config_export: None,
|
||||||
|
created_at: "2026-03-26T10:00:00Z".to_owned(),
|
||||||
|
updated_at: "2026-03-26T10:00:00Z".to_owned(),
|
||||||
|
published_at: Some("2026-03-26T10:00:00Z".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn object_schema(field_name: &str) -> Schema {
|
fn object_schema(field_name: &str) -> Schema {
|
||||||
Schema {
|
Schema {
|
||||||
kind: SchemaKind::Object,
|
kind: SchemaKind::Object,
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ version.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mcpaas-core = { path = "../mcpaas-core" }
|
mcpaas-core = { path = "../mcpaas-core" }
|
||||||
|
mcpaas-mapping = { path = "../mcpaas-mapping" }
|
||||||
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
axum.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
use std::{collections::BTreeMap, time::Duration};
|
||||||
|
|
||||||
|
use mcpaas_core::GraphqlTarget;
|
||||||
|
use mcpaas_mapping::JsonPath;
|
||||||
|
use reqwest::{
|
||||||
|
Client,
|
||||||
|
header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue},
|
||||||
|
};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
use crate::{GraphqlAdapterError, GraphqlRequest, GraphqlResponse};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct GraphqlAdapter {
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GraphqlAdapter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GraphqlAdapter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
target: &GraphqlTarget,
|
||||||
|
request: &GraphqlRequest,
|
||||||
|
) -> Result<GraphqlResponse, GraphqlAdapterError> {
|
||||||
|
let endpoint = reqwest::Url::parse(&target.endpoint).map_err(|_| {
|
||||||
|
GraphqlAdapterError::InvalidEndpoint {
|
||||||
|
url: target.endpoint.clone(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let headers = build_headers(request)?;
|
||||||
|
let variables = normalize_variables(request.variables.clone())?;
|
||||||
|
let payload = json!({
|
||||||
|
"query": target.query_template,
|
||||||
|
"operationName": target.operation_name,
|
||||||
|
"variables": variables
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(endpoint)
|
||||||
|
.headers(headers)
|
||||||
|
.timeout(Duration::from_millis(request.timeout_ms))
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = response.status();
|
||||||
|
let headers = normalize_headers(response.headers());
|
||||||
|
let body = decode_body(response).await?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(GraphqlAdapterError::UnexpectedStatus {
|
||||||
|
status: status.as_u16(),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(errors) = body.get("errors").cloned() {
|
||||||
|
return Err(GraphqlAdapterError::OperationErrors {
|
||||||
|
data: body.get("data").cloned(),
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_path = JsonPath::parse(&target.response_path).map_err(|_| {
|
||||||
|
GraphqlAdapterError::InvalidResponsePath {
|
||||||
|
path: target.response_path.clone(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let response_context = json!({
|
||||||
|
"response": {
|
||||||
|
"body": body
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let data = response_path
|
||||||
|
.read(&response_context)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| GraphqlAdapterError::ResponsePathNotFound {
|
||||||
|
path: target.response_path.clone(),
|
||||||
|
})?;
|
||||||
|
let body = response_context["response"]["body"].clone();
|
||||||
|
|
||||||
|
Ok(GraphqlResponse {
|
||||||
|
status_code: status.as_u16(),
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_headers(request: &GraphqlRequest) -> Result<HeaderMap, GraphqlAdapterError> {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||||
|
|
||||||
|
for (name, value) in &request.headers {
|
||||||
|
let header_name = HeaderName::try_from(name.as_str()).map_err(|_| {
|
||||||
|
GraphqlAdapterError::InvalidHeaderName {
|
||||||
|
header: name.clone(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let header_value = HeaderValue::try_from(value.as_str()).map_err(|_| {
|
||||||
|
GraphqlAdapterError::InvalidHeaderValue {
|
||||||
|
header: name.clone(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
headers.insert(header_name, header_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_variables(variables: Option<Value>) -> Result<Value, GraphqlAdapterError> {
|
||||||
|
match variables {
|
||||||
|
None | Some(Value::Null) => Ok(Value::Object(Map::new())),
|
||||||
|
Some(Value::Object(values)) => Ok(Value::Object(values)),
|
||||||
|
Some(_) => Err(GraphqlAdapterError::InvalidVariablesShape),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decode_body(response: reqwest::Response) -> Result<Value, GraphqlAdapterError> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use axum::{Json, Router, routing::post};
|
||||||
|
use mcpaas_core::{GraphqlOperationType, GraphqlTarget};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
use crate::{GraphqlAdapter, GraphqlAdapterError, GraphqlRequest};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn executes_graphql_request_and_extracts_response_path() {
|
||||||
|
let endpoint = spawn_graphql_server().await;
|
||||||
|
let adapter = GraphqlAdapter::new();
|
||||||
|
let target = GraphqlTarget {
|
||||||
|
endpoint,
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
let request = GraphqlRequest {
|
||||||
|
headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]),
|
||||||
|
variables: Some(json!({ "email": "user@example.com" })),
|
||||||
|
timeout_ms: 1_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = adapter.execute(&target, &request).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status_code, 200);
|
||||||
|
assert_eq!(
|
||||||
|
response.data,
|
||||||
|
json!({ "id": "lead_123", "status": "created" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn returns_operation_errors_from_graphql_body() {
|
||||||
|
let endpoint = spawn_graphql_server().await;
|
||||||
|
let adapter = GraphqlAdapter::new();
|
||||||
|
let target = GraphqlTarget {
|
||||||
|
endpoint,
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
let request = GraphqlRequest {
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
variables: Some(json!({ "email": "fail@example.com" })),
|
||||||
|
timeout_ms: 1_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = adapter.execute(&target, &request).await.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
GraphqlAdapterError::OperationErrors {
|
||||||
|
errors: Value::Array(_),
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 graphql_handler(Json(payload): Json<Value>) -> Json<Value> {
|
||||||
|
let email = payload
|
||||||
|
.get("variables")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum GraphqlAdapterError {
|
||||||
|
#[error("invalid graphql endpoint: {url}")]
|
||||||
|
InvalidEndpoint { url: String },
|
||||||
|
#[error("invalid header name {header}")]
|
||||||
|
InvalidHeaderName { header: String },
|
||||||
|
#[error("invalid header value for {header}")]
|
||||||
|
InvalidHeaderValue { header: String },
|
||||||
|
#[error("graphql variables must be a JSON object")]
|
||||||
|
InvalidVariablesShape,
|
||||||
|
#[error("invalid graphql response path: {path}")]
|
||||||
|
InvalidResponsePath { path: String },
|
||||||
|
#[error("graphql response path did not match any value: {path}")]
|
||||||
|
ResponsePathNotFound { path: String },
|
||||||
|
#[error("request failed")]
|
||||||
|
Transport(#[from] reqwest::Error),
|
||||||
|
#[error("graphql endpoint returned status {status}")]
|
||||||
|
UnexpectedStatus { status: u16, body: Value },
|
||||||
|
#[error("graphql operation returned errors")]
|
||||||
|
OperationErrors { errors: Value, data: Option<Value> },
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
pub fn crate_name() -> &'static str {
|
mod client;
|
||||||
"mcpaas-adapter-graphql"
|
mod error;
|
||||||
}
|
mod model;
|
||||||
|
|
||||||
|
pub use client::GraphqlAdapter;
|
||||||
|
pub use error::GraphqlAdapterError;
|
||||||
|
pub use model::{GraphqlRequest, GraphqlResponse};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
pub struct GraphqlRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub variables: Option<Value>,
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct GraphqlResponse {
|
||||||
|
pub status_code: u16,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
pub body: Value,
|
||||||
|
pub data: Value,
|
||||||
|
}
|
||||||
@@ -69,20 +69,7 @@ fn empty_target_context(context: MappingTargetContext) -> Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn read_path<'a>(value: &'a Value, path: &JsonPath) -> Option<&'a Value> {
|
fn read_path<'a>(value: &'a Value, path: &JsonPath) -> Option<&'a Value> {
|
||||||
let mut current = value;
|
path.read(value)
|
||||||
|
|
||||||
for field in path.root.root_fields() {
|
|
||||||
current = current.get(*field)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for segment in &path.segments {
|
|
||||||
current = match segment {
|
|
||||||
JsonPathSegment::Field(field) => current.get(field)?,
|
|
||||||
JsonPathSegment::Index(index) => current.get(*index)?,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_path(target: &mut Value, path: &JsonPath, value: Value) -> Result<(), MappingError> {
|
fn write_path(target: &mut Value, path: &JsonPath, value: Value) -> Result<(), MappingError> {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::MappingError;
|
use crate::MappingError;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
@@ -103,6 +105,23 @@ impl JsonPath {
|
|||||||
|
|
||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn read<'a>(&self, value: &'a Value) -> Option<&'a Value> {
|
||||||
|
let mut current = value;
|
||||||
|
|
||||||
|
for field in self.root.root_fields() {
|
||||||
|
current = current.get(*field)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for segment in &self.segments {
|
||||||
|
current = match segment {
|
||||||
|
JsonPathSegment::Field(field) => current.get(field)?,
|
||||||
|
JsonPathSegment::Index(index) => current.get(*index)?,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn match_root(input: &str) -> Option<(JsonPathRoot, &str)> {
|
fn match_root(input: &str) -> Option<(JsonPathRoot, &str)> {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use mcpaas_adapter_graphql::GraphqlAdapterError;
|
||||||
use mcpaas_adapter_rest::RestAdapterError;
|
use mcpaas_adapter_rest::RestAdapterError;
|
||||||
use mcpaas_core::Protocol;
|
use mcpaas_core::Protocol;
|
||||||
use mcpaas_mapping::MappingError;
|
use mcpaas_mapping::MappingError;
|
||||||
@@ -11,6 +12,8 @@ pub enum RuntimeError {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Mapping(#[from] MappingError),
|
Mapping(#[from] MappingError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
|
GraphqlAdapter(#[from] GraphqlAdapterError),
|
||||||
|
#[error(transparent)]
|
||||||
RestAdapter(#[from] RestAdapterError),
|
RestAdapter(#[from] RestAdapterError),
|
||||||
#[error("protocol {protocol:?} is not supported by runtime")]
|
#[error("protocol {protocol:?} is not supported by runtime")]
|
||||||
UnsupportedProtocol { protocol: Protocol },
|
UnsupportedProtocol { protocol: Protocol },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use mcpaas_adapter_graphql::{GraphqlAdapter, GraphqlRequest};
|
||||||
use mcpaas_adapter_rest::{RestAdapter, RestRequest};
|
use mcpaas_adapter_rest::{RestAdapter, RestRequest};
|
||||||
use mcpaas_core::Target;
|
use mcpaas_core::Target;
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
@@ -8,6 +9,7 @@ use crate::{AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation};
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RuntimeExecutor {
|
pub struct RuntimeExecutor {
|
||||||
|
graphql_adapter: GraphqlAdapter,
|
||||||
rest_adapter: RestAdapter,
|
rest_adapter: RestAdapter,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ impl Default for RuntimeExecutor {
|
|||||||
impl RuntimeExecutor {
|
impl RuntimeExecutor {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
graphql_adapter: GraphqlAdapter::new(),
|
||||||
rest_adapter: RestAdapter::new(),
|
rest_adapter: RestAdapter::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +38,25 @@ impl RuntimeExecutor {
|
|||||||
let prepared_request = PreparedRequest::from_mapping_output(&mapped_input)?;
|
let prepared_request = PreparedRequest::from_mapping_output(&mapped_input)?;
|
||||||
|
|
||||||
let adapter_response = match &operation.target {
|
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) => {
|
Target::Rest(target) => {
|
||||||
let request = RestRequest {
|
let request = RestRequest {
|
||||||
path_params: prepared_request.path_params.clone(),
|
path_params: prepared_request.path_params.clone(),
|
||||||
@@ -53,6 +75,7 @@ impl RuntimeExecutor {
|
|||||||
status_code: response.status_code,
|
status_code: response.status_code,
|
||||||
headers: response.headers,
|
headers: response.headers,
|
||||||
body: response.body,
|
body: response.body,
|
||||||
|
data: Value::Null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -83,6 +106,7 @@ impl PreparedRequest {
|
|||||||
path_params: read_string_map(request.get("path"), "request.path")?,
|
path_params: read_string_map(request.get("path"), "request.path")?,
|
||||||
query_params: read_string_map(request.get("query"), "request.query")?,
|
query_params: read_string_map(request.get("query"), "request.query")?,
|
||||||
headers: read_string_map(request.get("headers"), "request.headers")?,
|
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(),
|
body: request.get("body").and_then(non_empty_body).cloned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -95,7 +119,7 @@ fn finalize_output(
|
|||||||
let mapped = operation.output_mapping.apply(&json!({
|
let mapped = operation.output_mapping.apply(&json!({
|
||||||
"response": {
|
"response": {
|
||||||
"body": response.body,
|
"body": response.body,
|
||||||
"data": response.body,
|
"data": response.data,
|
||||||
"headers": response.headers,
|
"headers": response.headers,
|
||||||
"status": response.status_code
|
"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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use axum::{Json, Router, routing::post};
|
use axum::{Json, Router, routing::post};
|
||||||
use mcpaas_core::{
|
use mcpaas_core::{
|
||||||
ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, HttpMethod, Operation, OperationId,
|
ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlOperationType, GraphqlTarget,
|
||||||
OperationStatus, Protocol, RestTarget, Samples, Target, ToolDescription, ToolExample,
|
HttpMethod, Operation, OperationId, OperationStatus, Protocol, RestTarget, Samples, Target,
|
||||||
|
ToolDescription, ToolExample,
|
||||||
};
|
};
|
||||||
use mcpaas_mapping::{MappingRule, MappingSet};
|
use mcpaas_mapping::{MappingRule, MappingSet};
|
||||||
use mcpaas_schema::{Schema, SchemaKind};
|
use mcpaas_schema::{Schema, SchemaKind};
|
||||||
@@ -189,6 +222,20 @@ mod tests {
|
|||||||
assert_eq!(output, json!({ "id": "lead_123" }));
|
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]
|
#[tokio::test]
|
||||||
async fn rejects_invalid_input_shape() {
|
async fn rejects_invalid_input_shape() {
|
||||||
let base_url = spawn_runtime_server().await;
|
let base_url = spawn_runtime_server().await;
|
||||||
@@ -228,6 +275,20 @@ mod tests {
|
|||||||
assert!(matches!(error, RuntimeError::Mapping(_)));
|
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 {
|
async fn spawn_runtime_server() -> String {
|
||||||
let app = Router::new().route("/leads", post(create_lead));
|
let app = Router::new().route("/leads", post(create_lead));
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
@@ -240,6 +301,18 @@ mod tests {
|
|||||||
format!("http://{}", address)
|
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>) {
|
async fn create_lead(Json(payload): Json<Value>) -> (axum::http::StatusCode, Json<Value>) {
|
||||||
let should_fail = payload
|
let should_fail = payload
|
||||||
.get("fail")
|
.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(
|
fn test_rest_operation(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
should_fail: bool,
|
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 {
|
fn object_schema(field_name: &str, kind: SchemaKind) -> Schema {
|
||||||
Schema {
|
Schema {
|
||||||
kind: SchemaKind::Object,
|
kind: SchemaKind::Object,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ pub struct PreparedRequest {
|
|||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub headers: BTreeMap<String, String>,
|
pub headers: BTreeMap<String, String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub variables: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub body: Option<Value>,
|
pub body: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,4 +57,5 @@ pub struct AdapterResponse {
|
|||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub headers: BTreeMap<String, String>,
|
pub headers: BTreeMap<String, String>,
|
||||||
pub body: Value,
|
pub body: Value,
|
||||||
|
pub data: Value,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user