265 lines
7.9 KiB
Rust
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"
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
}
|