use std::sync::Arc;
use serde::Serialize;
use serde_json::Value;
use vantage_api_pool::resilient::{ResilientClient, TransportEvent, TransportObserver};
use vantage_core::{Priority, Result, error};
use crate::graphql::condition::FilterDialect;
use crate::transport::AuthHeader;
#[derive(Clone, Debug)]
pub struct GraphqlApi {
endpoint: String,
client: ResilientClient,
auth_header: AuthHeader,
pub(crate) dialect: FilterDialect,
pub(crate) filter_arg_name: Option<String>,
pub(crate) root_args: Option<Value>,
pub(crate) response_path: Vec<String>,
pub(crate) supports: Supports,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Supports {
pub filter: Option<bool>,
pub order: Option<bool>,
pub search: Option<bool>,
pub paginate: Option<bool>,
}
impl GraphqlApi {
pub fn can_filter(&self) -> bool {
self.supports.filter.unwrap_or(true)
}
pub fn can_order(&self) -> bool {
self.supports
.order
.unwrap_or(matches!(self.dialect, FilterDialect::Hasura))
}
pub fn can_search(&self) -> bool {
self.can_filter()
&& self
.supports
.search
.unwrap_or(matches!(self.dialect, FilterDialect::Hasura))
}
pub fn can_filter_operators(&self) -> bool {
self.can_filter() && matches!(self.dialect, FilterDialect::Hasura)
}
pub fn can_paginate(&self) -> bool {
self.supports.paginate.unwrap_or(false)
}
pub fn response_path(&self) -> &[String] {
&self.response_path
}
pub fn root_args(&self) -> Option<&Value> {
self.root_args.as_ref()
}
}
impl GraphqlApi {
pub fn new(endpoint: impl Into<String>) -> Self {
GraphqlApi::builder(endpoint).build()
}
pub fn builder(endpoint: impl Into<String>) -> GraphqlApiBuilder {
GraphqlApiBuilder::new(endpoint.into())
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn dialect(&self) -> FilterDialect {
self.dialect
}
pub fn breaker_state(&self) -> Option<crate::BreakerState> {
self.client.breaker_state()
}
pub fn client(&self) -> &ResilientClient {
&self.client
}
pub(crate) fn report_rows(&self, n: usize) {
self.client.report(TransportEvent::RowsPulled { n });
}
pub async fn post_graphql(
&self,
query: &str,
variables: &serde_json::Map<String, Value>,
) -> Result<Value> {
#[derive(Serialize)]
struct Body<'a> {
query: &'a str,
variables: &'a serde_json::Map<String, Value>,
}
let body = Body { query, variables };
let policy = crate::transport::policy_for(Priority::current());
let response = self
.client
.execute_with(&policy, |http| {
let req = http.post(&self.endpoint).json(&body);
match self.auth_header.value() {
Some(auth) => req.header(reqwest::header::AUTHORIZATION, auth),
None => req,
}
})
.await
.map_err(|e| {
crate::transport::client_error(e, "GraphQL request failed", &self.endpoint)
})?;
let mut envelope: Value = response.json().await.map_err(|e| {
error!(
"Failed to parse GraphQL response as JSON",
detail = e.to_string()
)
})?;
if let Some(errors) = envelope.get("errors")
&& let Some(arr) = errors.as_array()
&& !arr.is_empty()
{
let summary = arr
.iter()
.filter_map(|e| e.get("message").and_then(|m| m.as_str()))
.collect::<Vec<_>>()
.join("; ");
return Err(error!("GraphQL response carried errors", errors = summary));
}
Ok(envelope
.get_mut("data")
.map(std::mem::take)
.unwrap_or(Value::Null))
}
}
#[derive(Clone, Debug)]
pub struct GraphqlApiBuilder {
endpoint: String,
transport: crate::transport::ClientConfig,
auth_header: AuthHeader,
dialect: FilterDialect,
filter_arg_name: Option<String>,
root_args: Option<Value>,
response_path: Vec<String>,
supports: Supports,
}
impl GraphqlApiBuilder {
pub(crate) fn new(endpoint: String) -> Self {
Self {
endpoint,
transport: crate::transport::ClientConfig::default(),
auth_header: AuthHeader::default(),
dialect: FilterDialect::Generic,
filter_arg_name: None,
root_args: None,
response_path: Vec::new(),
supports: Supports::default(),
}
}
pub fn max_parallel(mut self, n: usize) -> Self {
self.transport.max_parallel = n.max(1);
self
}
pub fn rate_limit(mut self, per_second: f64) -> Self {
self.transport.rate_limit = Some(per_second);
self
}
pub fn observer(
mut self,
key: impl Into<Arc<str>>,
observer: Arc<dyn TransportObserver>,
) -> Self {
self.transport.observer = Some((key.into(), observer));
self
}
pub fn root_args(mut self, args: Value) -> Self {
self.root_args = Some(args);
self
}
pub fn response_path(mut self, path: impl AsRef<str>) -> Self {
self.response_path = split_response_path(path.as_ref());
self
}
pub fn supports(mut self, supports: Supports) -> Self {
self.supports = supports;
self
}
pub fn auth(mut self, auth: impl Into<String>) -> Self {
self.auth_header = AuthHeader::new(auth);
self
}
pub fn client(mut self, client: reqwest::Client) -> Self {
self.transport.http = Some(client);
self
}
pub fn http_client(self, client: reqwest::Client) -> Self {
self.client(client)
}
pub fn dialect(mut self, dialect: FilterDialect) -> Self {
self.dialect = dialect;
self
}
pub fn filter_arg_name(mut self, name: impl Into<String>) -> Self {
self.filter_arg_name = Some(name.into());
self
}
pub fn build(self) -> GraphqlApi {
GraphqlApi {
endpoint: self.endpoint,
client: crate::transport::build_client(self.transport),
auth_header: self.auth_header,
dialect: self.dialect,
filter_arg_name: self.filter_arg_name,
root_args: self.root_args,
response_path: self.response_path,
supports: self.supports,
}
}
}
pub(crate) fn split_response_path(path: &str) -> Vec<String> {
path.split('.')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_keeps_endpoint() {
let api = GraphqlApi::new("https://api.spacex.land/graphql/");
assert_eq!(api.endpoint(), "https://api.spacex.land/graphql/");
}
#[test]
fn builder_sets_auth_without_panicking() {
let api = GraphqlApi::builder("https://example.test/graphql")
.auth("Bearer abc")
.build();
assert_eq!(api.endpoint(), "https://example.test/graphql");
}
#[test]
fn debug_masks_auth_header() {
let api = GraphqlApi::builder("https://example.test/graphql")
.auth("Bearer secret-token")
.build();
let text = format!("{api:?}");
assert!(!text.contains("secret-token"), "{text}");
assert!(text.contains("<set>"), "{text}");
}
}