use std::{collections::BTreeMap, time::Duration}; use crank_core::GraphqlTarget; use crank_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 { 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 { 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) -> Result { 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 { let bytes = response.bytes().await?; if bytes.is_empty() { return Ok(Value::Null); } match serde_json::from_slice::(&bytes) { Ok(value) => Ok(value), Err(_) => Ok(Value::String( String::from_utf8_lossy(&bytes).trim().to_owned(), )), } } fn normalize_headers(headers: &HeaderMap) -> BTreeMap { 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 crank_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) -> Json { 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" } } })) } }