#![cfg(test)]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use apollo_compiler::Schema;
use futures::StreamExt;
use http::HeaderName;
use http::HeaderValue;
use http::header::CACHE_CONTROL;
use rstest::rstest;
use tokio_stream::wrappers::IntervalStream;
use tower::Service;
use tower::ServiceExt;
use uuid::Uuid;
use super::plugin::CacheSubgraph;
use super::plugin::ResponseCache;
use crate::Context;
use crate::MockedSubgraphs;
use crate::TestHarness;
use crate::configuration::subgraph::SubgraphConfiguration;
use crate::graphql;
use crate::metrics::FutureMetricsExt;
use crate::plugin::test::MockSubgraph;
use crate::plugins::response_cache::debugger::CacheKeysContext;
use crate::plugins::response_cache::debugger::CdnInvalidationDebug;
use crate::plugins::response_cache::invalidation::InvalidationRequest;
use crate::plugins::response_cache::invalidation_endpoint::InvalidationIndexes;
use crate::plugins::response_cache::invalidation_endpoint::SubgraphInvalidationConfig;
use crate::plugins::response_cache::invalidation_labels::InvalidationLabels;
use crate::plugins::response_cache::metrics::CacheMetricContextKey;
use crate::plugins::response_cache::plugin::CACHE_DEBUG_HEADER_NAME;
use crate::plugins::response_cache::plugin::CONTEXT_CACHE_KEY;
use crate::plugins::response_cache::plugin::CdnInvalidationConfig;
use crate::plugins::response_cache::plugin::INVALIDATION_SHARED_KEY;
use crate::plugins::response_cache::plugin::Subgraph;
use crate::plugins::response_cache::storage::CacheStorage;
use crate::plugins::response_cache::storage::redis::Config;
use crate::plugins::response_cache::storage::redis::Storage;
use crate::services::subgraph;
use crate::services::supergraph;
const SCHEMA: &str = include_str!("../../testdata/orga_supergraph_cache_key.graphql");
const SCHEMA_CACHE_TAG: &str =
include_str!("../../testdata/orga_supergraph_cache_key_cache_tag.graphql");
const SCHEMA_REQUIRES: &str = include_str!("../../testdata/supergraph_cache_key.graphql");
const SCHEMA_NESTED_KEYS: &str =
include_str!("../../testdata/supergraph_nested_fields_cache_key.graphql");
async fn wait_for_cache(storage: &Storage, keys: Vec<String>) {
if keys.is_empty() {
return;
}
let keys_strs: Vec<&str> = keys.iter().map(|s| s.as_str()).collect();
let mut interval_stream =
IntervalStream::new(tokio::time::interval(Duration::from_millis(100))).take(50);
while interval_stream.next().await.is_some() {
if let Ok(values) = storage.fetch_multiple(&keys_strs, "").await
&& values.iter().all(Option::is_some)
{
return;
}
}
panic!("insert not complete");
}
pub(super) fn create_subgraph_conf(
subgraphs: HashMap<String, Subgraph>,
) -> SubgraphConfiguration<Subgraph> {
SubgraphConfiguration {
all: Subgraph {
invalidation: Some(SubgraphInvalidationConfig {
enabled: true,
shared_key: INVALIDATION_SHARED_KEY.to_string(),
..Default::default()
}),
..Default::default()
},
subgraphs,
}
}
fn expected_cached_keys(cache_keys_context: &CacheKeysContext) -> Vec<String> {
cache_keys_context
.iter()
.filter(|context| context.cache_control.should_store())
.map(|context| context.key.clone())
.collect()
}
fn get_cache_keys_context(response: &supergraph::Response) -> Option<CacheKeysContext> {
let mut cache_keys: CacheKeysContext = response
.context
.get(super::plugin::CONTEXT_DEBUG_CACHE_KEYS)
.ok()??;
cache_keys.iter_mut().for_each(|ck| {
ck.invalidation_keys.sort();
ck.cache_control.zero_out_created();
});
cache_keys.sort_by(|a, b| a.invalidation_keys.cmp(&b.invalidation_keys));
Some(cache_keys)
}
async fn get_cdn_invalidation_debug(
mut response: supergraph::Response,
) -> Option<CdnInvalidationDebug> {
let body = response.next_response().await?;
let debugging = body
.extensions
.get(super::plugin::CACHE_DEBUG_EXTENSIONS_KEY)?;
let cdn_invalidation = debugging.get("cdnInvalidation")?;
serde_json_bytes::from_value(cdn_invalidation.clone()).ok()
}
fn get_cache_control_header(response: &supergraph::Response) -> Option<Vec<String>> {
let cache_control_headers: Vec<String> = response
.response
.headers()
.get_all(CACHE_CONTROL)
.iter()
.flat_map(|header| header.to_str().unwrap().split(','))
.map(ToString::to_string)
.collect();
if cache_control_headers.is_empty() {
return None;
}
Some(cache_control_headers)
}
fn get_cache_tag_header(response: &supergraph::Response) -> Option<Vec<String>> {
let header = response.response.headers().get("Cache-Tag")?;
Some(
header
.to_str()
.unwrap()
.split(',')
.map(ToString::to_string)
.collect(),
)
}
fn cache_control_contains_no_store(cache_control_header: &[String]) -> bool {
cache_control_header.iter().any(|h| h == "no-store")
}
fn cache_control_contains_public(cache_control_header: &[String]) -> bool {
cache_control_header.iter().any(|h| h == "public")
}
fn cache_control_contains_private(cache_control_header: &[String]) -> bool {
cache_control_header.iter().any(|h| h == "private")
}
fn cache_control_contains_max_age(cache_control_header: &[String]) -> bool {
cache_control_header
.iter()
.any(|h| h.starts_with("max-age="))
}
fn remove_debug_extensions_key(response: &mut graphql::Response) -> bool {
response
.extensions
.remove(super::plugin::CACHE_DEBUG_EXTENSIONS_KEY)
.is_some()
}
#[tokio::test]
async fn insert() {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let subgraphs_conf = create_subgraph_conf(
[
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect(),
);
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is in status 'new' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
wait_for_cache(&storage, expected_cached_keys(&cache_keys)).await;
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is in status 'cached' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
}
#[tokio::test]
async fn insert_with_custom_key() {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
context.insert_json_value(
CONTEXT_CACHE_KEY,
serde_json_bytes::json!({
"all": {
"locale": "be"
},
"subgraphs": {
"user": {
"foo": "bar"
}
}
}),
);
let request = supergraph::Request::fake_builder()
.query(query)
.context(context.clone())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is with source 'subgraph' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
wait_for_cache(&storage, expected_cached_keys(&cache_keys)).await;
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs, }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is with source 'subgraph' because we didn't pass the context and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
}
#[tokio::test]
async fn already_expired_cache_control() {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public", "age": "5"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "public", "age": "1000000"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is in status 'new' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
wait_for_cache(&storage, expected_cached_keys(&cache_keys)).await;
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure only root field query is in status 'cached' and entities are not cached"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
}
#[tokio::test]
async fn insert_without_debug_header() {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
assert!(get_cache_keys_context(&response).is_none());
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(!remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
assert!(get_cache_keys_context(&response).is_none());
let mut response = response.next_response().await.unwrap();
assert!(!remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
}
#[tokio::test]
async fn insert_with_requires() {
let valid_schema =
Arc::new(Schema::parse_and_validate(SCHEMA_REQUIRES, "test.graphql").unwrap());
let query = "query { topProducts { name shippingEstimate price } }";
let subgraphs = MockedSubgraphs([
("products", MockSubgraph::builder().with_json(
serde_json::json! {{"query":"{ topProducts { __typename upc name price weight } }"}},
serde_json::json! {{"data": {"topProducts": [{
"__typename": "Product",
"upc": "1",
"name": "Test",
"price": 150,
"weight": 5
}]}}},
).with_header(CACHE_CONTROL, HeaderValue::from_static("public")).build()),
("inventory", MockSubgraph::builder().with_json(
serde_json::json! {{
"query": "query($representations: [_Any!]!) { _entities(representations: $representations) { ... on Product { shippingEstimate } } }",
"variables": {
"representations": [
{
"weight": 5,
"upc": "1",
"price": 150,
"__typename": "Product"
}
]
}}},
serde_json::json! {{"data": {
"_entities": [{
"shippingEstimate": 15
}]
}}},
).with_header(CACHE_CONTROL, HeaderValue::from_static("public")).build())
].into_iter().collect());
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let map: HashMap<String, Subgraph> = [
(
"products".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"inventory".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA_REQUIRES)
.extra_private_plugin(response_cache.clone())
.extra_plugin(subgraphs.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is in status 'new' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"topProducts": [
{
"name": "Test",
"shippingEstimate": 15,
"price": 150
}
]
}
}
"#);
wait_for_cache(&storage, expected_cached_keys(&cache_keys)).await;
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA_REQUIRES)
.extra_private_plugin(response_cache)
.extra_plugin(subgraphs.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is in status 'cached' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"topProducts": [
{
"name": "Test",
"shippingEstimate": 15,
"price": 150
}
]
}
}
"#);
}
#[tokio::test]
async fn insert_with_nested_field_set() {
let valid_schema =
Arc::new(Schema::parse_and_validate(SCHEMA_NESTED_KEYS, "test.graphql").unwrap());
let query = "query { allProducts { name createdBy { name country { a } } } }";
let subgraphs = serde_json::json!({
"products": {
"query": {"allProducts": [{
"id": "1",
"name": "Test",
"sku": "150",
"createdBy": { "__typename": "User", "email": "test@test.com", "country": {"a": "France"} }
}]},
"headers": {"cache-control": "public"},
},
"users": {
"entities": [{
"__typename": "User",
"email": "test@test.com",
"name": "test",
"country": {
"a": "France"
}
}],
"headers": {"cache-control": "public"},
}
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let map = [
(
"products".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"users".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA_NESTED_KEYS)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is in status 'new' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"allProducts": [
{
"name": "Test",
"createdBy": {
"name": "test",
"country": {
"a": "France"
}
}
}
]
}
}
"#);
wait_for_cache(&storage, expected_cached_keys(&cache_keys)).await;
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA_NESTED_KEYS)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is in status 'cached' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"allProducts": [
{
"name": "Test",
"createdBy": {
"name": "test",
"country": {
"a": "France"
}
}
}
]
}
}
"#);
}
#[tokio::test]
async fn no_cache_control() {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
}
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
]
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let response_cache = ResponseCache::for_test(
storage.clone(),
Default::default(),
valid_schema.clone(),
false,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_no_store(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_no_store(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
}
#[tokio::test]
async fn no_store_from_request() {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
}
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
]
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let response_cache = ResponseCache::for_test(
storage.clone(),
Default::default(),
valid_schema.clone(),
false,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone(), "headers": {
"all": {
"request": { "operations": [{
"propagate": {
"named": "cache-control"
}
}]}
}
} }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.header(CACHE_CONTROL, HeaderValue::from_static("no-store"))
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_no_store(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let invalidations_by_subgraph = storage
.invalidate(
vec![
"user".to_string(),
"organization".to_string(),
"currentUser".to_string(),
],
vec!["orga".to_string(), "user".to_string()],
"test_bulk_invalidation",
)
.await
.unwrap();
assert_eq!(invalidations_by_subgraph.into_values().sum::<u64>(), 0);
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone(), "headers": {
"all": {
"request": { "operations": [{
"propagate": {
"named": "cache-control"
}
}]}
}
} }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.header(CACHE_CONTROL, HeaderValue::from_static("no-store"))
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_no_store(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let invalidations_by_subgraph = storage
.invalidate(
vec![
"user".to_string(),
"organization".to_string(),
"currentUser".to_string(),
],
vec!["orga".to_string(), "user".to_string()],
"test_bulk_invalidate",
)
.await
.unwrap();
assert_eq!(invalidations_by_subgraph.into_values().sum::<u64>(), 0);
}
#[tokio::test]
async fn no_cache_from_request() {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
}
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
]
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let response_cache = ResponseCache::for_test(
storage.clone(),
Default::default(),
valid_schema.clone(),
false,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone(), "headers": {
"all": {
"request": [{
"propagate": {
"named": "cache-control"
}
}]
}
} }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let response = response.next_response().await.unwrap();
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone(), "headers": {
"all": {
"request": [{
"propagate": {
"named": "cache-control"
}
}]
}
} }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let no_cache_context = Context::new();
let request = supergraph::Request::fake_builder()
.query(query)
.context(no_cache_context.clone())
.header(CACHE_CONTROL, HeaderValue::from_static("no-cache"))
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let response = response.next_response().await.unwrap();
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let orga_metric = no_cache_context
.get::<_, CacheSubgraph>(CacheMetricContextKey::new("orga".to_string()))
.ok()
.flatten();
assert!(
orga_metric.is_none(),
"no-cache requests should not record cache hit/miss metrics"
);
}
#[tokio::test]
async fn private_only() {
async {
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "private"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "private"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false,"private_only"), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache =
ResponseCache::for_test(storage.clone(), subgraphs_conf, valid_schema.clone(), true, drop_tx, true)
.await
.unwrap();
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
context.insert_json_value("sub", "1234".into());
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
assert_gauge!("apollo.router.response_cache.private_queries.lru.size", 1);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
context.insert_json_value("sub", "1234".into());
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_private(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let context = Context::new();
context.insert_json_value("sub", "5678".into());
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_private(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
}.with_metrics().await;
}
#[tokio::test]
async fn private_and_public() {
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } orga(id: \"2\") { name } }";
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"query": {
"orga": {
"__typename": "Organization",
"id": "2",
"name": "test_orga"
}
},
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "private"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
context.insert_json_value("sub", "1234".into());
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
},
"orga": {
"name": "test_orga"
}
}
}
"#);
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
context.insert_json_value("sub", "1234".into());
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_private(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
},
"orga": {
"name": "test_orga"
}
}
}
"#);
let context = Context::new();
context.insert_json_value("sub", "5678".into());
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_private(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
},
"orga": {
"name": "test_orga"
}
}
}
"#);
}
#[tokio::test]
async fn polymorphic_private_and_public() {
async {
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } orga(id: \"2\") { name } }";
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"query": {
"orga": {
"__typename": "Organization",
"id": "2",
"name": "test_orga"
}
},
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "private"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false,"polymorphic_private_and_public"), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache =
ResponseCache::for_test(storage.clone(), subgraphs_conf, valid_schema.clone(), true, drop_tx, true)
.await
.unwrap();
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
context.insert_json_value("sub", "1234".into());
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is in status 'new' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
},
"orga": {
"name": "test_orga"
}
}
}
"#);
let subgraphs_public = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"query": {
"orga": {
"__typename": "Organization",
"id": "2",
"name": "test_orga_public"
}
},
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 3
}
}
],
"headers": {"cache-control": "public"},
},
});
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs_public.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_public(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 3
}
}
},
"orga": {
"name": "test_orga_public"
}
}
}
"#);
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
context.insert_json_value("sub", "1234".into());
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_private(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
},
"orga": {
"name": "test_orga"
}
}
}
"#);
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs_public.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_public(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 3
}
}
},
"orga": {
"name": "test_orga_public"
}
}
}
"#);
assert_gauge!("apollo.router.response_cache.private_queries.lru.size", 1);
let context = Context::new();
context.insert_json_value("sub", "1234".into());
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_private(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
},
"orga": {
"name": "test_orga"
}
}
}
"#);
assert_gauge!("apollo.router.response_cache.private_queries.lru.size", 1);
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_public(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 3
}
}
},
"orga": {
"name": "test_orga_public"
}
}
}
"#);
assert_gauge!("apollo.router.response_cache.private_queries.lru.size", 1);
}.with_metrics().await;
}
#[tokio::test]
async fn private_without_private_id() {
async {
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "private"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "private"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false,"private_without_private_id"), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache =
ResponseCache::for_test(storage.clone(), subgraphs_conf, valid_schema.clone(), true, drop_tx, true)
.await
.unwrap();
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_private(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
assert_gauge!("apollo.router.response_cache.private_queries.lru.size", 1);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let mut service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let context = Context::new();
let request = supergraph::Request::fake_builder()
.query(query)
.context(context)
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.ready().await.unwrap().call(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_private(&cache_control_header));
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
}.with_metrics().await;
}
#[tokio::test]
async fn no_data() {
let query = "query { currentUser { allOrganizations { id name } } }";
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json! {{"query":"{currentUser{allOrganizations{__typename id}}}"}},
serde_json::json! {{"data": {"currentUser": { "allOrganizations": [
{
"__typename": "Organization",
"id": "1"
},
{
"__typename": "Organization",
"id": "3"
}
] }}}},
).with_header(CACHE_CONTROL, HeaderValue::from_static("no-store")).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json! {{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on Organization{name}}}",
"variables": {
"representations": [
{
"id": "1",
"__typename": "Organization",
},
{
"id": "3",
"__typename": "Organization",
}
]
}}},
serde_json::json! {{
"data": {
"_entities": [{
"name": "Organization 1",
},
{
"name": "Organization 3"
}]
}
}},
).with_header(CACHE_CONTROL, HeaderValue::from_static("public, max-age=3600")).build())
].into_iter().collect());
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.extra_plugin(subgraphs)
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys, {
"[].cache_control" => insta::dynamic_redaction(|value, _path| {
let cache_control = value.as_str().unwrap().to_string();
assert!(cache_control.contains("max-age="));
assert!(cache_control.contains("public"));
"[REDACTED]"
})
});
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"allOrganizations": [
{
"id": "1",
"name": "Organization 1"
},
{
"id": "3",
"name": "Organization 3"
}
]
}
}
}
"#);
let subgraphs = MockedSubgraphs(
[(
"user",
MockSubgraph::builder()
.with_json(
serde_json::json! {{"query":"{currentUser{allOrganizations{__typename id}}}"}},
serde_json::json! {{"data": {"currentUser": { "allOrganizations": [
{
"__typename": "Organization",
"id": "1"
},
{
"__typename": "Organization",
"id": "2"
},
{
"__typename": "Organization",
"id": "3"
}
] }}}},
)
.with_header(CACHE_CONTROL, HeaderValue::from_static("no-store"))
.build(),
)]
.into_iter()
.collect(),
);
let drain_drivers = std::sync::Arc::new(std::sync::Mutex::new(Vec::<
tokio::task::JoinHandle<()>,
>::new()));
let drain_drivers_clone = drain_drivers.clone();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache)
.subgraph_hook(move |name, service| {
if name == "orga" {
let (mock, mut handle) =
tower_test::mock::pair::<subgraph::Request, subgraph::Response>();
let driver = tokio::spawn(async move {
while let Some((_req, responder)) = handle.next_request().await {
responder.send_error("orga not found");
}
});
drain_drivers_clone.lock().unwrap().push(driver);
mock.boxed_clone()
} else {
service
}
})
.extra_plugin(subgraphs)
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"allOrganizations": [
{
"id": "1",
"name": "Organization 1"
},
{
"id": "2",
"name": null
},
{
"id": "3",
"name": "Organization 3"
}
]
}
},
"errors": [
{
"message": "HTTP fetch failed: orga not found",
"path": [
"currentUser",
"allOrganizations",
1
],
"extensions": {
"code": "SUBREQUEST_HTTP_ERROR",
"service": "orga",
"reason": "orga not found"
}
}
]
}
"#);
for driver in std::sync::Arc::try_unwrap(drain_drivers)
.unwrap()
.into_inner()
.unwrap()
{
crate::plugin::test::await_mock_driver(driver).await;
}
}
#[tokio::test]
async fn missing_entities() {
let query = "query { currentUser { allOrganizations { id name } } }";
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json! {{"query":"{currentUser{allOrganizations{__typename id}}}"}},
serde_json::json! {{"data": {"currentUser": { "allOrganizations": [
{
"__typename": "Organization",
"id": "1"
},
{
"__typename": "Organization",
"id": "2"
}
] }}}},
).with_header(CACHE_CONTROL, HeaderValue::from_static("no-store")).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json! {{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on Organization{name}}}",
"variables": {
"representations": [
{
"id": "1",
"__typename": "Organization",
},
{
"id": "2",
"__typename": "Organization",
}
]
}}},
serde_json::json! {{
"data": {
"_entities": [
{
"name": "Organization 1",
},
{
"name": "Organization 2"
}
]
}
}},
).with_header(CACHE_CONTROL, HeaderValue::from_static("public, max-age=3600")).build())
].into_iter().collect());
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let namespace = Uuid::new_v4().to_string();
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &namespace), drop_rx)
.await
.unwrap();
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.extra_plugin(subgraphs)
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response);
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &namespace), drop_rx)
.await
.unwrap();
let response_cache = ResponseCache::for_test(
storage.clone(),
Default::default(),
valid_schema.clone(),
false,
drop_tx,
true,
)
.await
.unwrap();
let subgraphs = MockedSubgraphs([
("user", MockSubgraph::builder().with_json(
serde_json::json! {{"query":"{currentUser{allOrganizations{__typename id}}}"}},
serde_json::json! {{"data": {"currentUser": { "allOrganizations": [
{
"__typename": "Organization",
"id": "1"
},
{
"__typename": "Organization",
"id": "2"
},
{
"__typename": "Organization",
"id": "3"
}
] }}}},
).with_header(CACHE_CONTROL, HeaderValue::from_static("no-store")).build()),
("orga", MockSubgraph::builder().with_json(
serde_json::json! {{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on Organization{name}}}",
"variables": {
"representations": [
{
"id": "3",
"__typename": "Organization",
}
]
}}},
serde_json::json! {{
"data": null,
"errors": [{
"message": "Organization not found",
}]
}},
).with_header(CACHE_CONTROL, HeaderValue::from_static("public, max-age=3600")).build())
].into_iter().collect());
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache)
.extra_plugin(subgraphs)
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response);
}
#[tokio::test(flavor = "multi_thread")]
async fn invalidate_by_cache_tag() {
async move {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false,"test_invalidate_by_cache_tag"), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache =
ResponseCache::for_test(storage.clone(), subgraphs_conf, valid_schema.clone(), true, drop_tx, true)
.await
.unwrap();
let invalidation = response_cache.invalidation.clone();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
assert_histogram_sum!("apollo.router.operations.response_cache.fetch.entity", 1u64, "subgraph.name" = "orga");
wait_for_cache(&storage, expected_cached_keys(&cache_keys)).await;
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.clone().oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
assert_histogram_sum!("apollo.router.operations.response_cache.fetch.entity", 2u64, "subgraph.name" = "orga");
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let res = invalidation
.invalidate(vec![InvalidationRequest::CacheTag {
subgraphs: vec!["orga".to_string()].into_iter().collect(),
cache_tag: String::from("organization-1"),
}])
.await
.unwrap();
assert_eq!(res, 1);
assert_counter!("apollo.router.operations.response_cache.invalidation.entry", 1u64, "subgraph.name" = "orga", "kind" = "cache_tag", "cache.tag" = "organization-1");
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache)
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.clone().oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
assert_histogram_sum!("apollo.router.operations.response_cache.fetch.entity", 3u64, "subgraph.name" = "orga");
}.with_metrics().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn complex_cache_tag() {
async move {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA_CACHE_TAG, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { ... on Organization { id creatorUser { __typename id } } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false,"test_complex_cache_tag"), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache =
ResponseCache::for_test(storage.clone(), subgraphs_conf, valid_schema.clone(), true, drop_tx, true)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
}.with_metrics().await;
}
async fn setup_cdn_invalidation_gating_test(
cache_tag_index_enabled: bool,
cdn_invalidation_enabled: bool,
extension_tag: Option<&str>,
) -> supergraph::Response {
let valid_schema =
Arc::new(Schema::parse_and_validate(SCHEMA_CACHE_TAG, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { ... on Organization { id creatorUser { __typename id } } } } }";
let mut orga_entity = serde_json::json!({
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
});
if let Some(tag) = extension_tag {
orga_entity
.as_object_mut()
.unwrap()
.insert("__cacheTags".to_string(), serde_json::json!([tag]));
}
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [orga_entity],
"headers": {"cache-control": "public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let subgraph_config = || Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
invalidation: Some(SubgraphInvalidationConfig {
indexes: InvalidationIndexes {
cache_tag: cache_tag_index_enabled,
..Default::default()
},
..Default::default()
}),
};
let map = [
("user".to_string(), subgraph_config()),
("orga".to_string(), subgraph_config()),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache = ResponseCache::for_test_with_cdn_invalidation(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
CdnInvalidationConfig {
enabled: cdn_invalidation_enabled,
..Default::default()
},
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache)
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.build()
.unwrap();
service.oneshot(request).await.unwrap()
}
#[rstest]
#[case::cdn_only_still_emits_header(false, true, true)]
#[case::redis_index_alone_does_not_emit_header(true, false, false)]
#[case::both_enabled(true, true, true)]
#[tokio::test(flavor = "multi_thread")]
async fn cdn_invalidation_header_gates_independently_of_cache_tag_index(
#[case] cache_tag_index_enabled: bool,
#[case] cdn_invalidation_enabled: bool,
#[case] expect_header_present: bool,
) {
async move {
let response = setup_cdn_invalidation_gating_test(
cache_tag_index_enabled,
cdn_invalidation_enabled,
None,
)
.await;
let header = get_cache_tag_header(&response);
assert_eq!(
header.is_some(),
expect_header_present,
"cache_tag_index_enabled={cache_tag_index_enabled}, cdn_invalidation_enabled={cdn_invalidation_enabled}: got header {header:?}"
);
if let Some(header) = header {
assert!(
header.contains(&"currentUser".to_string()),
"missing root tag in {header:?}"
);
assert!(
header.contains(&"organization".to_string()),
"missing entity tag in {header:?}"
);
}
}
.with_metrics()
.await;
}
#[rstest]
#[case::cdn_only_still_emits_header(false, true, true)]
#[case::redis_index_alone_does_not_emit_header(true, false, false)]
#[tokio::test(flavor = "multi_thread")]
async fn cdn_invalidation_header_for_extension_tags_gates_independently_of_cache_tag_index(
#[case] cache_tag_index_enabled: bool,
#[case] cdn_invalidation_enabled: bool,
#[case] expect_header_present: bool,
) {
async move {
let response = setup_cdn_invalidation_gating_test(
cache_tag_index_enabled,
cdn_invalidation_enabled,
Some("extension-tag"),
)
.await;
let header = get_cache_tag_header(&response);
assert_eq!(
header.is_some(),
expect_header_present,
"cache_tag_index_enabled={cache_tag_index_enabled}, cdn_invalidation_enabled={cdn_invalidation_enabled}: got header {header:?}"
);
if let Some(header) = header {
assert!(
header.contains(&"extension-tag".to_string()),
"missing apolloEntityCacheTags-derived tag in {header:?}"
);
}
}
.with_metrics()
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn fine_grained_tags_stay_off_context_when_cdn_invalidation_disabled() {
async move {
let response = setup_cdn_invalidation_gating_test(true, false, Some("extension-tag")).await;
let invalidation_labels = InvalidationLabels::get_or_create(&response.context);
assert!(
invalidation_labels.tags.is_empty(),
"expected no fine-grained tags on context when cdn_invalidation is disabled, got {:?}",
invalidation_labels.tags
);
assert!(!invalidation_labels.subgraphs.is_empty());
assert!(!invalidation_labels.types.is_empty());
}
.with_metrics()
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn cdn_invalidation_debug_reflects_outcome_and_truncation() {
async move {
let valid_schema =
Arc::new(Schema::parse_and_validate(SCHEMA_CACHE_TAG, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { ... on Organization { id creatorUser { __typename id } } } } }";
let orga_entity = serde_json::json!({
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
});
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [orga_entity],
"headers": {"cache-control": "public"},
},
});
let map = || {
[
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect()
};
let build_request = |send_debug_header: bool| {
let builder = supergraph::Request::fake_builder()
.query(query)
.context(Context::new());
if send_debug_header {
builder
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap()
} else {
builder.build().unwrap()
}
};
let run = |cdn_invalidation: CdnInvalidationConfig, send_debug_header: bool| {
let valid_schema = valid_schema.clone();
let subgraphs = subgraphs.clone();
async move {
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let response_cache = ResponseCache::for_test_with_cdn_invalidation(
storage,
create_subgraph_conf(map()),
valid_schema,
true,
drop_tx,
true,
cdn_invalidation,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache)
.build_supergraph()
.await
.unwrap();
service.oneshot(build_request(send_debug_header)).await.unwrap()
}
};
let response = run(
CdnInvalidationConfig {
enabled: true,
..Default::default()
},
true,
)
.await;
let debug = get_cdn_invalidation_debug(response)
.await
.expect("expected CDN invalidation debug info");
assert_eq!(debug.outcome, "complete_without_truncation");
assert!(debug.emitted, "{debug:?}");
assert!(debug.header_value.is_some(), "{debug:?}");
assert!(debug.untruncated_size_bytes.is_some(), "{debug:?}");
assert_eq!(debug.header_name, "Cache-Tag");
let response = run(
CdnInvalidationConfig {
enabled: true,
max_bytes: 10,
..Default::default()
},
true,
)
.await;
let debug = get_cdn_invalidation_debug(response)
.await
.expect("expected CDN invalidation debug info");
assert_eq!(debug.outcome, "complete_with_truncation");
assert!(
!debug.emitted,
"an empty-value header shouldn't be reported as emitted: {debug:?}"
);
let header_value = debug.header_value.clone().expect("expected a header value");
assert_eq!(
header_value, "",
"expected nothing to have survived truncation at max_bytes=10: {debug:?}"
);
let untruncated = debug
.untruncated_size_bytes
.expect("expected an untruncated size");
assert!(
untruncated > 0,
"expected a nonzero untruncated size: {debug:?}"
);
assert_eq!(debug.max_bytes, 10);
let response = run(
CdnInvalidationConfig {
enabled: false,
..Default::default()
},
true,
)
.await;
assert!(
get_cdn_invalidation_debug(response).await.is_none(),
"expected no CDN invalidation debug info when the feature is disabled"
);
let response = run(
CdnInvalidationConfig {
enabled: true,
..Default::default()
},
false,
)
.await;
assert!(
get_cdn_invalidation_debug(response).await.is_none(),
"expected no CDN invalidation debug info when the request didn't ask for it"
);
}
.with_metrics()
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn cdn_invalidation_header_excludes_deferred_payload_tags() {
async move {
let valid_schema =
Arc::new(Schema::parse_and_validate(SCHEMA_CACHE_TAG, "test.graphql").unwrap());
let query = "query { currentUser { id ... @defer { activeOrganization { ... on Organization { id creatorUser { __typename id } } } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"id": "1",
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [serde_json::json!({
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
})],
"headers": {"cache-control": "public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let subgraph_config = || Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
invalidation: None,
};
let map = [
("user".to_string(), subgraph_config()),
("orga".to_string(), subgraph_config()),
]
.into_iter()
.collect();
let response_cache = ResponseCache::for_test_with_cdn_invalidation(
storage,
create_subgraph_conf(map),
valid_schema,
true,
drop_tx,
false,
CdnInvalidationConfig {
enabled: true,
..Default::default()
},
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache)
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(defer_context())
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let header = get_cache_tag_header(&response).expect("expected a Cache-Tag header");
assert!(
!header.contains(&"organization".to_string()),
"deferred-only tag leaked into the primary response's header: {header:?}"
);
let primary = response.next_response().await.unwrap();
assert_eq!(
primary.has_next,
Some(true),
"expected the primary chunk to signal more chunks are coming"
);
let deferred = response
.next_response()
.await
.expect("expected a second, deferred chunk");
assert!(
!deferred.incremental.is_empty(),
"expected the deferred chunk to carry incremental data"
);
assert!(
header.contains(&"currentUser".to_string()),
"missing primary-payload tag in {header:?}"
);
}
.with_metrics()
.await;
}
fn defer_context() -> Context {
let context = Context::new();
context.extensions().with_lock(|lock| {
lock.insert(crate::services::router::ClientRequestAccepts {
multipart_defer: true,
..Default::default()
})
});
context
}
async fn setup_cdn_invalidation_hit_test(
cache_tag_index_enabled: bool,
extension_tag: Option<&str>,
) -> (supergraph::Response, supergraph::Response) {
let valid_schema =
Arc::new(Schema::parse_and_validate(SCHEMA_CACHE_TAG, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { ... on Organization { id creatorUser { __typename id } } } } }";
let mut orga_entity = serde_json::json!({
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
});
if let Some(tag) = extension_tag {
orga_entity
.as_object_mut()
.unwrap()
.insert("__cacheTags".to_string(), serde_json::json!([tag]));
}
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [orga_entity],
"headers": {"cache-control": "public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let subgraph_config = || Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
invalidation: Some(SubgraphInvalidationConfig {
indexes: InvalidationIndexes {
cache_tag: cache_tag_index_enabled,
..Default::default()
},
..Default::default()
}),
};
let map = [
("user".to_string(), subgraph_config()),
("orga".to_string(), subgraph_config()),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache = ResponseCache::for_test_with_cdn_invalidation(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
CdnInvalidationConfig {
enabled: true,
..Default::default()
},
)
.await
.unwrap();
let build_request = || {
supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap()
};
let miss_service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let miss_response = miss_service.oneshot(build_request()).await.unwrap();
let cache_keys = get_cache_keys_context(&miss_response).expect("missing cache keys");
wait_for_cache(&storage, expected_cached_keys(&cache_keys)).await;
let hit_service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache)
.build_supergraph()
.await
.unwrap();
let hit_response = hit_service.oneshot(build_request()).await.unwrap();
(miss_response, hit_response)
}
#[rstest]
#[case::redis_index_off(false)]
#[case::redis_index_on(true)]
#[tokio::test(flavor = "multi_thread")]
async fn cdn_invalidation_header_persists_schema_tags_across_cache_hit(
#[case] cache_tag_index_enabled: bool,
) {
async move {
let (miss_response, hit_response) =
setup_cdn_invalidation_hit_test(cache_tag_index_enabled, None).await;
for (label, response) in [("miss", &miss_response), ("hit", &hit_response)] {
let header = get_cache_tag_header(response).unwrap_or_else(|| {
panic!(
"cache_tag_index_enabled={cache_tag_index_enabled}: expected a Cache-Tag header on the {label} response"
)
});
assert!(
header.contains(&"currentUser".to_string()),
"cache_tag_index_enabled={cache_tag_index_enabled}: {label} response missing root tag in {header:?}"
);
assert!(
header.contains(&"organization".to_string()),
"cache_tag_index_enabled={cache_tag_index_enabled}: {label} response missing entity tag in {header:?}"
);
assert!(
header.contains(&"organizationid-id--1".to_string()),
"cache_tag_index_enabled={cache_tag_index_enabled}: {label} response missing interpolated entity tag in {header:?}"
);
}
}
.with_metrics()
.await;
}
#[rstest]
#[case::redis_index_off(false)]
#[case::redis_index_on(true)]
#[tokio::test(flavor = "multi_thread")]
async fn cdn_invalidation_header_persists_extension_tag_across_cache_hit(
#[case] cache_tag_index_enabled: bool,
) {
async move {
let (miss_response, hit_response) =
setup_cdn_invalidation_hit_test(cache_tag_index_enabled, Some("extension-tag")).await;
for (label, response) in [("miss", &miss_response), ("hit", &hit_response)] {
let header = get_cache_tag_header(response).unwrap_or_else(|| {
panic!(
"cache_tag_index_enabled={cache_tag_index_enabled}: expected a Cache-Tag header on the {label} response"
)
});
assert!(
header.contains(&"extension-tag".to_string()),
"cache_tag_index_enabled={cache_tag_index_enabled}: {label} response missing apolloEntityCacheTags-derived tag in {header:?}"
);
}
}
.with_metrics()
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn cdn_invalidation_header_unions_hit_and_miss_entities_in_the_same_request() {
async move {
let valid_schema =
Arc::new(Schema::parse_and_validate(SCHEMA_CACHE_TAG, "test.graphql").unwrap());
let query = "query { currentUser { allOrganizations { id name } } }";
let namespace = Uuid::new_v4().to_string();
let map = || {
[
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect()
};
{
let subgraphs = MockedSubgraphs(
[
(
"user",
MockSubgraph::builder()
.with_json(
serde_json::json! {{"query":"{currentUser{allOrganizations{__typename id}}}"}},
serde_json::json! {{"data": {"currentUser": { "allOrganizations": [
{"__typename": "Organization", "id": "1"},
{"__typename": "Organization", "id": "2"}
] }}}},
)
.with_header(CACHE_CONTROL, HeaderValue::from_static("no-store"))
.build(),
),
(
"orga",
MockSubgraph::builder()
.with_json(
serde_json::json! {{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on Organization{name}}}",
"variables": {
"representations": [
{"id": "1", "__typename": "Organization"},
{"id": "2", "__typename": "Organization"}
]
}
}},
serde_json::json! {{
"data": {
"_entities": [
{"name": "Organization 1"},
{"name": "Organization 2"}
]
}
}},
)
.with_header(CACHE_CONTROL, HeaderValue::from_static("public, max-age=3600"))
.build(),
),
]
.into_iter()
.collect(),
);
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &namespace), drop_rx)
.await
.unwrap();
let response_cache = ResponseCache::for_test_with_cdn_invalidation(
storage.clone(),
create_subgraph_conf(map()),
valid_schema.clone(),
true,
drop_tx,
true,
CdnInvalidationConfig {
enabled: true,
..Default::default()
},
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache)
.extra_plugin(subgraphs)
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
wait_for_cache(&storage, expected_cached_keys(&cache_keys)).await;
}
let subgraphs = MockedSubgraphs(
[
(
"user",
MockSubgraph::builder()
.with_json(
serde_json::json! {{"query":"{currentUser{allOrganizations{__typename id}}}"}},
serde_json::json! {{"data": {"currentUser": { "allOrganizations": [
{"__typename": "Organization", "id": "1"},
{"__typename": "Organization", "id": "2"},
{"__typename": "Organization", "id": "3"}
] }}}},
)
.with_header(CACHE_CONTROL, HeaderValue::from_static("no-store"))
.build(),
),
(
"orga",
MockSubgraph::builder()
.with_json(
serde_json::json! {{
"query": "query($representations:[_Any!]!){_entities(representations:$representations){...on Organization{name}}}",
"variables": {
"representations": [
{"id": "3", "__typename": "Organization"}
]
}
}},
serde_json::json! {{
"data": {
"_entities": [
{"name": "Organization 3"}
]
}
}},
)
.with_header(CACHE_CONTROL, HeaderValue::from_static("public, max-age=3600"))
.build(),
),
]
.into_iter()
.collect(),
);
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &namespace), drop_rx)
.await
.unwrap();
let response_cache = ResponseCache::for_test_with_cdn_invalidation(
storage.clone(),
create_subgraph_conf(map()),
valid_schema.clone(),
false,
drop_tx,
true,
CdnInvalidationConfig {
enabled: true,
..Default::default()
},
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache)
.extra_plugin(subgraphs)
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.build()
.unwrap();
let response = service.oneshot(request).await.unwrap();
let header = get_cache_tag_header(&response).expect("expected a Cache-Tag header");
assert!(
header.contains(&"currentUser".to_string()),
"missing root tag in {header:?}"
);
assert!(
header.contains(&"organizationid-id--1".to_string()),
"missing hit-path entity tag (org 1) in {header:?}"
);
assert!(
header.contains(&"organizationid-id--2".to_string()),
"missing hit-path entity tag (org 2) in {header:?}"
);
assert!(
header.contains(&"organizationid-id--3".to_string()),
"missing miss-path entity tag (org 3) in {header:?}"
);
}
.with_metrics()
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn invalidate_by_type() {
async move {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false,"test_invalidate_by_subgraph"), drop_rx)
.await
.unwrap();
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let subgraphs_conf = create_subgraph_conf(map);
let response_cache =
ResponseCache::for_test(storage.clone(), subgraphs_conf, valid_schema.clone(), true, drop_tx, true)
.await
.unwrap();
let invalidation = response_cache.invalidation.clone();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
wait_for_cache(&storage, expected_cached_keys(&cache_keys)).await;
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.clone().oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
let res = invalidation
.invalidate(vec![InvalidationRequest::Type { subgraph: "orga".to_string(), r#type: "Organization".to_string() }])
.await
.unwrap();
assert_eq!(res, 1);
assert_counter!("apollo.router.operations.response_cache.invalidation.entry", 1u64, "subgraph.name" = "orga", "graphql.type" = "Organization", "kind" = "type");
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true }, "experimental_mock_subgraphs": subgraphs.clone() }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache)
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.clone().oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::assert_json_snapshot!(cache_keys);
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
}.with_metrics().await;
}
#[tokio::test]
async fn failure_mode() {
async {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query =
"query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "public"},
},
});
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let response_cache =
ResponseCache::without_storage_for_failure_mode(map, valid_schema.clone())
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs.clone(),
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let response = response.next_response().await.unwrap();
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
assert_counter!(
"apollo.router.operations.response_cache.fetch.error",
1,
"subgraph.name" = "orga",
"code" = "NO_STORAGE"
);
assert_counter!(
"apollo.router.operations.response_cache.fetch.error",
1,
"subgraph.name" = "user",
"code" = "NO_STORAGE"
);
let service = TestHarness::builder()
.configuration_json(
serde_json::json!({"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs.clone(),
}),
)
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let response = response.next_response().await.unwrap();
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
assert_counter!(
"apollo.router.operations.response_cache.fetch.error",
2,
"subgraph.name" = "orga",
"code" = "NO_STORAGE"
);
assert_counter!(
"apollo.router.operations.response_cache.fetch.error",
2,
"subgraph.name" = "user",
"code" = "NO_STORAGE"
);
}
.with_metrics()
.await;
}
#[tokio::test]
async fn failure_mode_reconnect() {
async {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query =
"query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "public"},
},
});
let map = [
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect();
let (_drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false,"failure_mode_reconnect"), drop_rx)
.await
.unwrap();
storage.truncate_namespace().await.unwrap();
let response_cache =
ResponseCache::without_storage_for_failure_mode(map, valid_schema.clone())
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs.clone(),
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let response = response.next_response().await.unwrap();
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
assert_counter!(
"apollo.router.operations.response_cache.fetch.error",
1,
"subgraph.name" = "orga",
"code" = "NO_STORAGE"
);
assert_counter!(
"apollo.router.operations.response_cache.fetch.error",
1,
"subgraph.name" = "user",
"code" = "NO_STORAGE"
);
let service = TestHarness::builder()
.configuration_json(
serde_json::json!({"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs.clone(),
}),
)
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
response_cache
.storage.replace_storage(storage).expect("must be able to replace");
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is in status 'new' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
assert_counter!(
"apollo.router.operations.response_cache.fetch.error",
1,
"subgraph.name" = "orga",
"code" = "NO_STORAGE"
);
assert_counter!(
"apollo.router.operations.response_cache.fetch.error",
1,
"subgraph.name" = "user",
"code" = "NO_STORAGE"
);
let service = TestHarness::builder()
.configuration_json(
serde_json::json!({"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs.clone(),
}),
)
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
insta::with_settings!({
description => "Make sure everything is in status 'cached' and we have all the entities and root fields"
}, {
insta::assert_json_snapshot!(cache_keys);
});
let mut response = response.next_response().await.unwrap();
assert!(remove_debug_extensions_key(&mut response));
insta::assert_json_snapshot!(response, @r#"
{
"data": {
"currentUser": {
"activeOrganization": {
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
}
}
}
"#);
assert_counter!(
"apollo.router.operations.response_cache.fetch.error",
1,
"subgraph.name" = "orga",
"code" = "NO_STORAGE"
);
assert_counter!(
"apollo.router.operations.response_cache.fetch.error",
1,
"subgraph.name" = "user",
"code" = "NO_STORAGE"
);
}
.with_metrics()
.await;
}
#[tokio::test(flavor = "multi_thread")]
async fn no_store_on_subgraph_timeout() {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "max-age=1800, public"},
},
"orga": {
"entities": [],
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let subgraphs_conf = create_subgraph_conf(HashMap::from([
("user".to_string(), Subgraph::default()),
("orga".to_string(), Subgraph::default()),
]));
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
"traffic_shaping": {
"subgraphs": {
"orga": {
"timeout": "1ms"
}
}
}
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.subgraph_hook(|name, service| {
if name == "orga" {
tower::service_fn(|_req: subgraph::Request| async move {
tokio::time::sleep(Duration::from_millis(500)).await;
Err::<subgraph::Response, tower::BoxError>("orga sleep exceeded".into())
})
.boxed_clone()
} else {
service
}
})
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_control_header =
get_cache_control_header(&response).expect("missing cache-control header");
assert!(
cache_control_contains_no_store(&cache_control_header),
"expected Cache-Control: no-store when a subgraph times out, got: {:?}",
cache_control_header
);
assert!(
!cache_control_contains_public(&cache_control_header),
"Cache-Control must not contain 'public' when a subgraph timed out, got: {:?}",
cache_control_header
);
assert!(
!cache_control_contains_max_age(&cache_control_header),
"Cache-Control must not contain max-age when a subgraph timed out, got: {:?}",
cache_control_header
);
let body = response.next_response().await.unwrap();
assert!(
!body.errors.is_empty(),
"expected errors in response body due to subgraph timeout"
);
}
#[tokio::test]
async fn no_store_on_partial_subgraph_failure() {
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "max-age=1800, public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let subgraphs_conf = create_subgraph_conf(HashMap::from([
("user".to_string(), Subgraph::default()),
("orga".to_string(), Subgraph::default()),
]));
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema.clone(),
true,
drop_tx,
true,
)
.await
.unwrap();
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.build()
.unwrap();
let mut response = service.oneshot(request).await.unwrap();
let cache_control_header =
get_cache_control_header(&response).expect("missing cache-control header");
assert!(
cache_control_contains_no_store(&cache_control_header),
"expected Cache-Control: no-store on partial failure, got: {:?}",
cache_control_header
);
assert!(
!cache_control_contains_public(&cache_control_header),
"Cache-Control must not contain 'public' when a subgraph failed, got: {:?}",
cache_control_header
);
assert!(
!cache_control_contains_max_age(&cache_control_header),
"Cache-Control must not contain max-age when a subgraph failed, got: {:?}",
cache_control_header
);
let body = response.next_response().await.unwrap();
assert!(
!body.errors.is_empty(),
"expected errors in response body due to failing subgraph"
);
}
async fn setup_send_cache_control_test(
include_cache_control_header_on_router_response: bool,
) -> (Storage, ResponseCache, serde_json::Value) {
let subgraphs = serde_json::json!({
"user": {
"query": {
"currentUser": {
"activeOrganization": {
"__typename": "Organization",
"id": "1",
}
}
},
"headers": {"cache-control": "public"},
},
"orga": {
"entities": [
{
"__typename": "Organization",
"id": "1",
"creatorUser": {
"__typename": "User",
"id": 2
}
}
],
"headers": {"cache-control": "public"},
},
});
let (drop_tx, drop_rx) = tokio::sync::broadcast::channel(2);
let storage = Storage::new(&Config::test(false, &Uuid::new_v4().to_string()), drop_rx)
.await
.unwrap();
let subgraphs_conf = create_subgraph_conf(
[
(
"user".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
(
"orga".to_string(),
Subgraph {
redis: None,
private_id: Some("sub".to_string()),
enabled: true.into(),
ttl: None,
..Default::default()
},
),
]
.into_iter()
.collect(),
);
let valid_schema = Arc::new(Schema::parse_and_validate(SCHEMA, "test.graphql").unwrap());
let response_cache = ResponseCache::for_test(
storage.clone(),
subgraphs_conf,
valid_schema,
true,
drop_tx,
include_cache_control_header_on_router_response,
)
.await
.unwrap();
(storage, response_cache, subgraphs)
}
#[tokio::test]
async fn include_cache_control_header_on_router_response_false_suppresses_headers() {
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let (storage, response_cache, subgraphs) = setup_send_cache_control_test(false).await;
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let response = service.oneshot(request).await.unwrap();
assert!(
get_cache_control_header(&response).is_none(),
"Cache-Control header should be suppressed when include_cache_control_header_on_router_response is false"
);
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
wait_for_cache(&storage, expected_cached_keys(&cache_keys)).await;
let service = TestHarness::builder()
.configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } }))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.header(
HeaderName::from_static(CACHE_DEBUG_HEADER_NAME),
HeaderValue::from_static("true"),
)
.build()
.unwrap();
let response = service.oneshot(request).await.unwrap();
assert!(
get_cache_control_header(&response).is_none(),
"Cache-Control header should remain suppressed on cache hit"
);
let cache_keys = get_cache_keys_context(&response).expect("missing cache keys");
assert!(
cache_keys
.iter()
.any(|ck| matches!(ck.source, super::debugger::CacheKeySource::Cache)),
"second request should produce a cache hit"
);
}
#[tokio::test]
async fn include_cache_control_header_on_router_response_true_sends_headers() {
let query = "query { currentUser { activeOrganization { id creatorUser { __typename id } } } }";
let (_storage, response_cache, subgraphs) = setup_send_cache_control_test(true).await;
let service = TestHarness::builder()
.configuration_json(serde_json::json!({
"include_subgraph_errors": { "all": true },
"experimental_mock_subgraphs": subgraphs,
}))
.unwrap()
.schema(SCHEMA)
.extra_private_plugin(response_cache.clone())
.build_supergraph()
.await
.unwrap();
let request = supergraph::Request::fake_builder()
.query(query)
.context(Context::new())
.build()
.unwrap();
let response = service.oneshot(request).await.unwrap();
let cache_control_header = get_cache_control_header(&response).expect("missing header");
assert!(cache_control_contains_max_age(&cache_control_header));
assert!(cache_control_contains_public(&cache_control_header));
}