Skip to main content

sova_graphql/
client.rs

1//! Outbound GraphQL client (`req.graphql().query / mutation`).
2
3use crate::error::GraphqlError;
4use crate::fake::FakeGraphql;
5use bytes::Bytes;
6use serde::Serialize;
7use serde_json::{json, Value};
8use sova_core::extend::BoxFuture;
9use std::sync::Arc;
10
11/// Full GraphQL HTTP response JSON (`data` + optional `errors`).
12#[derive(Debug, Clone)]
13pub struct GraphqlResponse {
14    pub raw: Value,
15}
16
17impl GraphqlResponse {
18    pub fn data(&self) -> Option<&Value> {
19        self.raw.get("data")
20    }
21
22    pub fn errors(&self) -> Option<&Value> {
23        self.raw.get("errors")
24    }
25
26    /// Prefer `data`; Err if GraphQL `errors` present or data missing.
27    pub fn into_data(self) -> Result<Value, GraphqlError> {
28        if let Some(errs) = self.raw.get("errors") {
29            if !errs.is_null() && errs.as_array().map(|a| !a.is_empty()).unwrap_or(true) {
30                return Err(GraphqlError::Graphql(errs.to_string()));
31            }
32        }
33        self.raw
34            .get("data")
35            .cloned()
36            .ok_or_else(|| GraphqlError::Decode("missing data field".into()))
37    }
38}
39
40pub(crate) trait GraphqlTransport: Send + Sync {
41    fn post(&self, url: &str, body: Bytes) -> BoxFuture<Result<Bytes, GraphqlError>>;
42}
43
44struct HttpTransport {
45    client: reqwest::Client,
46}
47
48impl GraphqlTransport for HttpTransport {
49    fn post(&self, url: &str, body: Bytes) -> BoxFuture<Result<Bytes, GraphqlError>> {
50        let client = self.client.clone();
51        let url = url.to_string();
52        Box::pin(async move {
53            let res = client
54                .post(url)
55                .header(http::header::CONTENT_TYPE, "application/json")
56                .body(body)
57                .send()
58                .await
59                .map_err(|e| GraphqlError::Transport(e.to_string()))?;
60            let status = res.status().as_u16();
61            let bytes = res
62                .bytes()
63                .await
64                .map_err(|e| GraphqlError::Transport(e.to_string()))?;
65            if !(200..300).contains(&status) {
66                return Err(GraphqlError::Http {
67                    status,
68                    body: String::from_utf8_lossy(&bytes).into_owned(),
69                });
70            }
71            Ok(bytes)
72        })
73    }
74}
75
76/// Shared client stored in app state.
77#[derive(Clone)]
78pub struct GraphQlClient {
79    endpoint: String,
80    transport: Arc<dyn GraphqlTransport>,
81    fake: Option<FakeGraphql>,
82}
83
84impl GraphQlClient {
85    pub fn endpoint(&self) -> &str {
86        &self.endpoint
87    }
88
89    pub fn fake(&self) -> Option<&FakeGraphql> {
90        self.fake.as_ref()
91    }
92
93    pub fn query(&self, query: impl Into<String>) -> PendingGraphql {
94        PendingGraphql {
95            client: self.clone(),
96            query: query.into(),
97            operation_name: None,
98            variables: None,
99        }
100    }
101
102    pub fn mutation(&self, query: impl Into<String>) -> PendingGraphql {
103        self.query(query)
104    }
105
106    pub(crate) fn http(endpoint: impl Into<String>) -> Self {
107        Self {
108            endpoint: endpoint.into(),
109            transport: Arc::new(HttpTransport {
110                client: reqwest::Client::new(),
111            }),
112            fake: None,
113        }
114    }
115
116    pub(crate) fn with_fake(endpoint: impl Into<String>, fake: FakeGraphql) -> Self {
117        Self {
118            endpoint: endpoint.into(),
119            transport: Arc::new(fake.clone()),
120            fake: Some(fake),
121        }
122    }
123
124    pub(crate) async fn execute_raw(
125        &self,
126        query: &str,
127        operation_name: Option<&str>,
128        variables: Option<&Value>,
129    ) -> Result<GraphqlResponse, GraphqlError> {
130        let body = json!({
131            "query": query,
132            "operationName": operation_name,
133            "variables": variables.unwrap_or(&Value::Null),
134        });
135        let bytes = Bytes::from(
136            serde_json::to_vec(&body).map_err(|e| GraphqlError::Decode(e.to_string()))?,
137        );
138        let resp = self.transport.post(&self.endpoint, bytes).await?;
139        let raw: Value =
140            serde_json::from_slice(&resp).map_err(|e| GraphqlError::Decode(e.to_string()))?;
141        Ok(GraphqlResponse { raw })
142    }
143}
144
145/// Fluent GraphQL request builder.
146pub struct PendingGraphql {
147    client: GraphQlClient,
148    query: String,
149    operation_name: Option<String>,
150    variables: Option<Value>,
151}
152
153impl PendingGraphql {
154    pub fn operation_name(mut self, name: impl Into<String>) -> Self {
155        self.operation_name = Some(name.into());
156        self
157    }
158
159    pub fn variables<V: Serialize>(mut self, vars: V) -> Self {
160        self.variables = Some(serde_json::to_value(vars).unwrap_or(Value::Null));
161        self
162    }
163
164    /// Full response JSON.
165    pub async fn send(self) -> Result<GraphqlResponse, GraphqlError> {
166        self.client
167            .execute_raw(
168                &self.query,
169                self.operation_name.as_deref(),
170                self.variables.as_ref(),
171            )
172            .await
173    }
174
175    /// `data` field only (errors → [`GraphqlError::Graphql`]).
176    pub async fn data(self) -> Result<Value, GraphqlError> {
177        self.send().await?.into_data()
178    }
179
180    /// Alias of [`Self::data`].
181    pub async fn await_data(self) -> Result<Value, GraphqlError> {
182        self.data().await
183    }
184}