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
+5
View File
@@ -7,7 +7,12 @@ version.workspace = true
[dependencies]
mcpaas-core = { path = "../mcpaas-core" }
mcpaas-mapping = { path = "../mcpaas-mapping" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
[dev-dependencies]
axum.workspace = true
tokio.workspace = true
+264
View File
@@ -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> },
}
+7 -3
View File
@@ -1,3 +1,7 @@
pub fn crate_name() -> &'static str {
"mcpaas-adapter-graphql"
}
mod client;
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,
}