feat: add graphql support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
"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,
|
||||
}
|
||||
Reference in New Issue
Block a user