Files
crank/crates/crank-adapter-graphql/src/client.rs
T
2026-03-28 00:58:56 +03:00

265 lines
7.9 KiB
Rust

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<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 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<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"
}
}
}))
}
}