vantage-api-client 2.2.2

Vantage extension for REST and GraphQL HTTP API backends
Documentation
//! `GraphqlApi` and its builder: what it reports about itself, what the
//! `Supports` switches default to per dialect, and the transport knobs.

mod support;

use std::time::Duration;

use serde_json::{Map, json};
use support::{client_with_user_agent, mount, mount_with_header, recorder, test_client};
use vantage_api_client::{BreakerState, CountMode, FilterDialect, GraphqlApi, Supports};
use wiremock::matchers::{body_json, header, method};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[test]
fn an_api_reports_its_endpoint_and_defaults_to_the_generic_dialect() {
    let api = GraphqlApi::new("https://api.spacex.land/graphql/");
    assert_eq!(api.endpoint(), "https://api.spacex.land/graphql/");
    assert_eq!(api.dialect(), FilterDialect::Generic);
    assert!(api.root_args().is_none());
    assert!(api.response_path().is_empty());
}

#[test]
fn the_builder_sets_the_dialect_root_args_and_response_path() {
    let api = GraphqlApi::builder("https://example.test/graphql")
        .dialect(FilterDialect::Hasura)
        .root_args(json!({ "input": {} }))
        .response_path("edges.node")
        .build();
    assert_eq!(api.dialect(), FilterDialect::Hasura);
    assert_eq!(api.root_args(), Some(&json!({ "input": {} })));
    assert_eq!(api.response_path(), ["edges", "node"]);
}

#[test]
fn stray_dots_in_a_response_path_name_no_segment() {
    let api = GraphqlApi::builder("https://example.test/graphql")
        .response_path(".edges..node.")
        .build();
    assert_eq!(api.response_path(), ["edges", "node"]);
}

#[test]
fn debug_output_keeps_the_auth_header_secret() {
    let api = GraphqlApi::builder("https://example.test/graphql")
        .auth("Bearer secret-token")
        .build();
    let text = format!("{api:?}");
    assert!(!text.contains("secret-token"), "{text}");
}

#[test]
fn generic_defaults_to_filtering_only() {
    let api = GraphqlApi::new("https://example.test/graphql");
    assert!(api.can_filter());
    assert!(!api.can_order());
    assert!(!api.can_search());
    assert!(!api.can_filter_operators());
    assert!(!api.can_paginate());
    assert!(!api.can_page());
}

#[test]
fn hasura_defaults_to_filter_order_search_and_operators_but_not_paging() {
    let api = GraphqlApi::builder("https://example.test/graphql")
        .dialect(FilterDialect::Hasura)
        .build();
    assert!(api.can_filter());
    assert!(api.can_order());
    assert!(api.can_search());
    assert!(api.can_filter_operators());
    assert!(!api.can_paginate());
    assert!(!api.can_page());
}

#[test]
fn supports_overrides_the_dialect_defaults() {
    let api = GraphqlApi::builder("https://example.test/graphql")
        .supports(
            Supports::default()
                .with_order(true)
                .with_search(true)
                .with_paginate(true),
        )
        .build();
    assert!(api.can_order());
    assert!(api.can_paginate());
    assert!(api.can_page());
    assert!(api.can_search(), "search was switched on for Generic");
    assert!(!api.can_filter_operators(), "operators stay Hasura-only");

    let hasura = GraphqlApi::builder("https://example.test/graphql")
        .dialect(FilterDialect::Hasura)
        .supports(Supports::default().with_order(false).with_search(false))
        .build();
    assert!(!hasura.can_order());
    assert!(!hasura.can_search());
}

#[test]
fn without_filter_push_down_nothing_condition_shaped_is_offered() {
    let api = GraphqlApi::builder("https://example.test/graphql")
        .dialect(FilterDialect::Hasura)
        .supports(Supports::default().with_filter(false).with_paginate(true))
        .build();
    assert!(!api.can_filter());
    assert!(!api.can_search());
    assert!(!api.can_filter_operators());
    assert!(api.can_paginate());
    assert!(
        !api.can_page(),
        "a page cut by local filtering would be short"
    );
}

#[test]
fn supports_setters_record_what_was_set() {
    let supports = Supports::default()
        .with_filter(false)
        .with_order(true)
        .with_search(false)
        .with_paginate(true)
        .with_count(CountMode::Aggregate);
    assert_eq!(supports.filter, Some(false));
    assert_eq!(supports.order, Some(true));
    assert_eq!(supports.search, Some(false));
    assert_eq!(supports.paginate, Some(true));
    assert_eq!(supports.count, CountMode::Aggregate);

    let unset = Supports::default();
    assert_eq!(unset.filter, None);
    assert_eq!(unset.count, CountMode::List);
}

#[test]
fn count_mode_and_dialect_are_lowercase_in_yaml() {
    let mode: CountMode = serde_yaml_ng::from_str("aggregate").unwrap();
    assert_eq!(mode, CountMode::Aggregate);
    let mode: CountMode = serde_yaml_ng::from_str("list").unwrap();
    assert_eq!(mode, CountMode::List);
    assert!(serde_yaml_ng::from_str::<CountMode>("exact").is_err());

    let dialect: FilterDialect = serde_yaml_ng::from_str("hasura").unwrap();
    assert_eq!(dialect, FilterDialect::Hasura);
    assert!(serde_yaml_ng::from_str::<FilterDialect>("Hasura").is_err());
    assert!(serde_yaml_ng::from_str::<FilterDialect>("relay").is_err());
}

#[tokio::test]
async fn the_breaker_starts_closed() {
    let api = GraphqlApi::builder("https://example.test/graphql")
        .http_client(test_client())
        .build();
    assert_eq!(api.breaker_state(), Some(BreakerState::Closed));
    let _client = api.client();
}

#[tokio::test]
async fn the_configured_transport_knobs_still_deliver_the_request() {
    let server = MockServer::start().await;
    mount(&server, "POST", 200, r#"{"data":{"ok":true}}"#).await;
    let rec = recorder();
    let api = GraphqlApi::builder(server.uri())
        .max_parallel(1)
        .rate_limit(1000.0)
        .request_timeout(Duration::from_secs(5))
        .no_request_timeout()
        .observer("gql", rec.clone())
        .http_client(test_client())
        .build();
    let data = api.post_graphql("{ ok }", &Map::new()).await.unwrap();
    assert_eq!(data["ok"], json!(true));
    assert_eq!(rec.tags(), ["started", "ok:200"]);
}

#[tokio::test]
async fn an_auth_refresher_supplies_the_token_and_is_asked_again_on_401() {
    use std::sync::Arc;
    use std::sync::atomic::{AtomicUsize, Ordering};

    let server = MockServer::start().await;
    mount_with_header(&server, "POST", ("Authorization", "Bearer stale"), 401, "").await;
    mount_with_header(
        &server,
        "POST",
        ("Authorization", "Bearer fresh"),
        200,
        r#"{"data":{"ok":true}}"#,
    )
    .await;
    let asked = Arc::new(AtomicUsize::new(0));
    let counter = asked.clone();
    let refresher: vantage_api_client::AuthRefresher = Arc::new(move || {
        let n = counter.fetch_add(1, Ordering::SeqCst);
        Box::pin(async move { Ok(if n == 0 { "stale" } else { "fresh" }.to_string()) })
    });
    let api = GraphqlApi::builder(server.uri())
        .auth_refresher(refresher)
        .http_client(test_client())
        .build();

    let data = api.post_graphql("{ ok }", &Map::new()).await.unwrap();
    assert_eq!(data["ok"], json!(true));
    assert_eq!(
        asked.load(Ordering::SeqCst),
        2,
        "once to start, once on the 401"
    );
}

#[tokio::test]
async fn a_fixed_auth_set_after_a_refresher_replaces_it() {
    use std::sync::Arc;
    use std::sync::atomic::{AtomicUsize, Ordering};

    let server = MockServer::start().await;
    mount_with_header(
        &server,
        "POST",
        ("Authorization", "Bearer fixed"),
        200,
        r#"{"data":{"ok":true}}"#,
    )
    .await;
    let asked = Arc::new(AtomicUsize::new(0));
    let counter = asked.clone();
    let refresher: vantage_api_client::AuthRefresher = Arc::new(move || {
        counter.fetch_add(1, Ordering::SeqCst);
        Box::pin(async { Ok("refreshed".to_string()) })
    });
    let api = GraphqlApi::builder(server.uri())
        .auth_refresher(refresher)
        .auth("Bearer fixed")
        .http_client(test_client())
        .build();

    api.post_graphql("{ ok }", &Map::new()).await.unwrap();
    assert_eq!(asked.load(Ordering::SeqCst), 0, "the refresher is gone");
}

#[tokio::test]
async fn a_response_that_is_not_json_is_an_error() {
    let server = MockServer::start().await;
    mount(&server, "POST", 200, "<html>nope</html>").await;
    let api = GraphqlApi::builder(server.uri())
        .http_client(test_client())
        .build();
    let err = api.post_graphql("{ ok }", &Map::new()).await.unwrap_err();
    assert!(err.to_string().contains("JSON"), "{err}");
}

/// The mock answers only a request with this body, this `authorization`
/// (from `.auth`) and this `user-agent` (from the overriding client), so a
/// request missing any of them fails the call.
#[tokio::test]
async fn a_post_carries_the_query_variables_and_configured_headers() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(header("authorization", "Bearer t0k"))
        .and(header("user-agent", "probe-agent"))
        .and(body_json(json!({
            "query": "query ($limit: Int) { x(limit: $limit) { id } }",
            "variables": { "limit": 3 }
        })))
        .respond_with(ResponseTemplate::new(200).set_body_string("{}"))
        .expect(1)
        .mount(&server)
        .await;
    let api = GraphqlApi::builder(server.uri())
        .auth("Bearer t0k")
        .http_client(client_with_user_agent("probe-agent"))
        .build();
    let mut variables = Map::new();
    variables.insert("limit".into(), json!(3));
    let data = api
        .post_graphql(
            "query ($limit: Int) { x(limit: $limit) { id } }",
            &variables,
        )
        .await
        .unwrap();
    assert!(data.is_null(), "a response without `data` reads as null");
}