diff --git a/.changesets/feat_glasser_pq_client_name.md b/.changesets/feat_glasser_pq_client_name.md new file mode 100644 index 0000000000..42157835d3 --- /dev/null +++ b/.changesets/feat_glasser_pq_client_name.md @@ -0,0 +1,13 @@ +### Client name support for Persisted Query Lists ([PR #6198](https://github.com/apollographql/router/pull/6198)) + +The persisted query manifest fetched from Uplink can now contain a `clientName` field in each operation. Two operations with the same `id` but different `clientName` are considered to be distinct operations (and may have distinct bodies). + +Router resolves the client name by taking the first of these which exists: +- Reading the `apollo_persisted_queries::client_name` context key (which may be set by a `router_service` plugin) +- Reading the HTTP header named by `telemetry.apollo.client_name_header` (which defaults to `apollographql-client-name`) + +If a client name can be resolved for a request, Router first tries to find a persisted query with the specified ID and the resolved client name. + +If there is no operation with that ID and client name, or if a client name cannot be resolved, Router tries to find a persisted query with the specified ID and no client name specified. (This means that existing PQ lists that do not contain client names will continue to work.) + +By [@glasser](https://github.com/glasser) in https://github.com/apollographql/router/pull/6198 \ No newline at end of file diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap index db8f3f8851..36d76b53ec 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap @@ -1747,7 +1747,7 @@ expression: "&schema" }, "client_name_header": { "default": "apollographql-client-name", - "description": "The name of the header to extract from requests when populating 'client nane' for traces and metrics in Apollo Studio.", + "description": "The name of the header to extract from requests when populating 'client name' for traces and metrics in Apollo Studio.", "nullable": true, "type": "string" }, diff --git a/apollo-router/src/plugins/telemetry/apollo.rs b/apollo-router/src/plugins/telemetry/apollo.rs index 69780cbc9e..89c1ecd8e8 100644 --- a/apollo-router/src/plugins/telemetry/apollo.rs +++ b/apollo-router/src/plugins/telemetry/apollo.rs @@ -67,7 +67,7 @@ pub(crate) struct Config { #[schemars(skip)] pub(crate) apollo_graph_ref: Option, - /// The name of the header to extract from requests when populating 'client nane' for traces and metrics in Apollo Studio. + /// The name of the header to extract from requests when populating 'client name' for traces and metrics in Apollo Studio. #[schemars(with = "Option", default = "client_name_header_default_str")] #[serde(deserialize_with = "deserialize_header_name")] pub(crate) client_name_header: HeaderName, diff --git a/apollo-router/src/plugins/telemetry/mod.rs b/apollo-router/src/plugins/telemetry/mod.rs index da5361e1c1..49fcbdf935 100644 --- a/apollo-router/src/plugins/telemetry/mod.rs +++ b/apollo-router/src/plugins/telemetry/mod.rs @@ -171,7 +171,7 @@ pub(crate) mod tracing; pub(crate) mod utils; // Tracing consts -const CLIENT_NAME: &str = "apollo_telemetry::client_name"; +pub(crate) const CLIENT_NAME: &str = "apollo_telemetry::client_name"; const CLIENT_VERSION: &str = "apollo_telemetry::client_version"; const SUBGRAPH_FTV1: &str = "apollo_telemetry::subgraph_ftv1"; pub(crate) const STUDIO_EXCLUDE: &str = "apollo_telemetry::studio::exclude"; diff --git a/apollo-router/src/services/layers/persisted_queries/manifest_poller.rs b/apollo-router/src/services/layers/persisted_queries/manifest_poller.rs index 7051ec97be..496c98d0a7 100644 --- a/apollo-router/src/services/layers/persisted_queries/manifest_poller.rs +++ b/apollo-router/src/services/layers/persisted_queries/manifest_poller.rs @@ -21,8 +21,18 @@ use crate::uplink::stream_from_uplink_transforming_new_response; use crate::uplink::UplinkConfig; use crate::Configuration; +/// The full identifier for an operation in a PQ list consists of an operation +/// ID and an optional client name. +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +pub struct FullPersistedQueryOperationId { + /// The operation ID (usually a hash). + pub operation_id: String, + /// The client name associated with the operation; if None, can be any client. + pub client_name: Option, +} + /// An in memory cache of persisted queries. -pub(crate) type PersistedQueryManifest = HashMap; +pub type PersistedQueryManifest = HashMap; /// How the router should respond to requests that are not resolved as the IDs /// of an operation in the manifest. (For the most part this means "requests @@ -212,7 +222,7 @@ impl PersistedQueryManifestPoller { if manifest_files.is_empty() { return Err("no local persisted query list files specified".into()); } - let mut manifest: HashMap = PersistedQueryManifest::new(); + let mut manifest = PersistedQueryManifest::new(); for local_pq_list in manifest_files { tracing::info!( @@ -250,7 +260,13 @@ impl PersistedQueryManifestPoller { } for operation in manifest_file.operations { - manifest.insert(operation.id, operation.body); + manifest.insert( + FullPersistedQueryOperationId { + operation_id: operation.id, + client_name: operation.client_name, + }, + operation.body, + ); } } @@ -343,15 +359,35 @@ impl PersistedQueryManifestPoller { } } - pub(crate) fn get_operation_body(&self, persisted_query_id: &str) -> Option { + pub(crate) fn get_operation_body( + &self, + persisted_query_id: &str, + client_name: Option, + ) -> Option { let state = self .state .read() .expect("could not acquire read lock on persisted query manifest state"); - state + if let Some(body) = state .persisted_query_manifest - .get(persisted_query_id) + .get(&FullPersistedQueryOperationId { + operation_id: persisted_query_id.to_string(), + client_name: client_name.clone(), + }) .cloned() + { + Some(body) + } else if client_name.is_some() { + state + .persisted_query_manifest + .get(&FullPersistedQueryOperationId { + operation_id: persisted_query_id.to_string(), + client_name: None, + }) + .cloned() + } else { + None + } } pub(crate) fn get_all_operations(&self) -> Vec { @@ -588,7 +624,13 @@ async fn add_chunk_to_operations( match fetch_chunk(http_client.clone(), chunk_url).await { Ok(chunk) => { for operation in chunk.operations { - operations.insert(operation.id, operation.body); + operations.insert( + FullPersistedQueryOperationId { + operation_id: operation.id, + client_name: operation.client_name, + }, + operation.body, + ); } return Ok(()); } @@ -674,9 +716,11 @@ pub(crate) struct SignedUrlChunk { /// A single operation containing an ID and a body, #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] pub(crate) struct Operation { pub(crate) id: String, pub(crate) body: String, + pub(crate) client_name: Option, } #[cfg(test)] @@ -701,7 +745,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(manifest_manager.get_operation_body(&id), Some(body)) + assert_eq!(manifest_manager.get_operation_body(&id, None), Some(body)) } #[tokio::test(flavor = "multi_thread")] @@ -734,18 +778,26 @@ mod tests { ) .await .unwrap(); - assert_eq!(manifest_manager.get_operation_body(&id), Some(body)) + assert_eq!(manifest_manager.get_operation_body(&id, None), Some(body)) } #[test] fn safelist_body_normalization() { - let safelist = FreeformGraphQLSafelist::new(&PersistedQueryManifest::from([( - "valid-syntax".to_string(), - "fragment A on T { a } query SomeOp { ...A ...B } fragment,,, B on U{b c } # yeah" - .to_string(), - ), ( - "invalid-syntax".to_string(), - "}}}".to_string()), + let safelist = FreeformGraphQLSafelist::new(&PersistedQueryManifest::from([ + ( + FullPersistedQueryOperationId { + operation_id: "valid-syntax".to_string(), + client_name: None, + }, + "fragment A on T { a } query SomeOp { ...A ...B } fragment,,, B on U{b c } # yeah".to_string(), + ), + ( + FullPersistedQueryOperationId { + operation_id: "invalid-syntax".to_string(), + client_name: None, + }, + "}}}".to_string(), + ), ])); let is_allowed = |body: &str| -> bool { @@ -795,6 +847,6 @@ mod tests { ) .await .unwrap(); - assert_eq!(manifest_manager.get_operation_body(&id), Some(body)) + assert_eq!(manifest_manager.get_operation_body(&id, None), Some(body)) } } diff --git a/apollo-router/src/services/layers/persisted_queries/mod.rs b/apollo-router/src/services/layers/persisted_queries/mod.rs index 48c4dcba1d..0208b2f878 100644 --- a/apollo-router/src/services/layers/persisted_queries/mod.rs +++ b/apollo-router/src/services/layers/persisted_queries/mod.rs @@ -8,17 +8,21 @@ use http::header::CACHE_CONTROL; use http::HeaderValue; use http::StatusCode; use id_extractor::PersistedQueryIdExtractor; +pub use manifest_poller::FullPersistedQueryOperationId; +pub use manifest_poller::PersistedQueryManifest; pub(crate) use manifest_poller::PersistedQueryManifestPoller; use tower::BoxError; use self::manifest_poller::FreeformGraphQLAction; use super::query_analysis::ParsedDocument; use crate::graphql::Error as GraphQLError; +use crate::plugins::telemetry::CLIENT_NAME; use crate::services::SupergraphRequest; use crate::services::SupergraphResponse; use crate::Configuration; const DONT_CACHE_RESPONSE_VALUE: &str = "private, no-cache, must-revalidate"; +const PERSISTED_QUERIES_CLIENT_NAME_CONTEXT_KEY: &str = "apollo_persisted_queries::client_name"; struct UsedQueryIdFromManifest; @@ -110,9 +114,21 @@ impl PersistedQueryLayer { } else { // if there is no query, look up the persisted query in the manifest // and put the body on the `supergraph_request` - if let Some(persisted_query_body) = - manifest_poller.get_operation_body(persisted_query_id) - { + if let Some(persisted_query_body) = manifest_poller.get_operation_body( + persisted_query_id, + // Use the first one of these that exists: + // - The PQL-specific context name entry + // `apollo_persisted_queries::client_name` (which can be set + // by router_service plugins) + // - The same name used by telemetry (ie, the value of the + // header named by `telemetry.apollo.client_name_header`, + // which defaults to `apollographql-client-name` by default) + request + .context + .get(PERSISTED_QUERIES_CLIENT_NAME_CONTEXT_KEY) + .unwrap_or_default() + .or_else(|| request.context.get(CLIENT_NAME).unwrap_or_default()), + ) { let body = request.supergraph_request.body_mut(); body.query = Some(persisted_query_body); body.extensions.remove("persistedQuery"); @@ -389,6 +405,7 @@ mod tests { use std::collections::HashMap; use std::time::Duration; + use maplit::hashmap; use serde_json::json; use super::*; @@ -400,6 +417,7 @@ mod tests { use crate::services::layers::query_analysis::QueryAnalysisLayer; use crate::spec::Schema; use crate::test_harness::mocks::persisted_queries::*; + use crate::Context; #[tokio::test(flavor = "multi_thread")] async fn disabled_pq_layer_has_no_poller() { @@ -479,6 +497,84 @@ mod tests { assert_eq!(request.supergraph_request.body().query, Some(body)); } + #[tokio::test(flavor = "multi_thread")] + async fn enabled_pq_layer_with_client_names() { + let (_mock_guard, uplink_config) = mock_pq_uplink(&hashmap! { + FullPersistedQueryOperationId { + operation_id: "both-plain-and-cliented".to_string(), + client_name: None, + } => "query { bpac_no_client: __typename }".to_string(), + FullPersistedQueryOperationId { + operation_id: "both-plain-and-cliented".to_string(), + client_name: Some("web".to_string()), + } => "query { bpac_web_client: __typename }".to_string(), + FullPersistedQueryOperationId { + operation_id: "only-cliented".to_string(), + client_name: Some("web".to_string()), + } => "query { oc_web_client: __typename }".to_string(), + }) + .await; + + let pq_layer = PersistedQueryLayer::new( + &Configuration::fake_builder() + .persisted_query(PersistedQueries::builder().enabled(true).build()) + .uplink(uplink_config) + .build() + .unwrap(), + ) + .await + .unwrap(); + + let map_to_query = |operation_id: &str, client_name: Option<&str>| -> Option { + let context = Context::new(); + if let Some(client_name) = client_name { + context + .insert( + PERSISTED_QUERIES_CLIENT_NAME_CONTEXT_KEY, + client_name.to_string(), + ) + .unwrap(); + } + + let incoming_request = SupergraphRequest::fake_builder() + .extension( + "persistedQuery", + json!({"version": 1, "sha256Hash": operation_id.to_string()}), + ) + .context(context) + .build() + .unwrap(); + + pq_layer + .supergraph_request(incoming_request) + .ok() + .expect("pq layer returned response instead of putting the query on the request") + .supergraph_request + .body() + .query + .clone() + }; + + assert_eq!( + map_to_query("both-plain-and-cliented", None), + Some("query { bpac_no_client: __typename }".to_string()) + ); + assert_eq!( + map_to_query("both-plain-and-cliented", Some("not-web")), + Some("query { bpac_no_client: __typename }".to_string()) + ); + assert_eq!( + map_to_query("both-plain-and-cliented", Some("web")), + Some("query { bpac_web_client: __typename }".to_string()) + ); + assert_eq!( + map_to_query("only-cliented", Some("web")), + Some("query { oc_web_client: __typename }".to_string()) + ); + assert_eq!(map_to_query("only-cliented", None), None); + assert_eq!(map_to_query("only-cliented", Some("not-web")), None); + } + #[tokio::test(flavor = "multi_thread")] async fn pq_layer_passes_on_to_apq_layer_when_id_not_found() { let (_id, _body, manifest) = fake_manifest(); @@ -690,11 +786,17 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn pq_layer_freeform_graphql_with_safelist() { let manifest = HashMap::from([( - "valid-syntax".to_string(), + FullPersistedQueryOperationId { + operation_id: "valid-syntax".to_string(), + client_name: None, + }, "fragment A on Query { me { id } } query SomeOp { ...A ...B } fragment,,, B on Query{me{name,username} } # yeah" .to_string(), ), ( - "invalid-syntax".to_string(), + FullPersistedQueryOperationId { + operation_id: "invalid-syntax".to_string(), + client_name: None, + }, "}}}".to_string()), ]); diff --git a/apollo-router/src/test_harness/mocks/persisted_queries.rs b/apollo-router/src/test_harness/mocks/persisted_queries.rs index cdf8b0e5f9..7d513305bf 100644 --- a/apollo-router/src/test_harness/mocks/persisted_queries.rs +++ b/apollo-router/src/test_harness/mocks/persisted_queries.rs @@ -14,14 +14,21 @@ use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +pub use crate::services::layers::persisted_queries::FullPersistedQueryOperationId; +pub use crate::services::layers::persisted_queries::PersistedQueryManifest; use crate::uplink::Endpoints; use crate::uplink::UplinkConfig; /// Get a query ID, body, and a PQ manifest with that ID and body. -pub fn fake_manifest() -> (String, String, HashMap) { +pub fn fake_manifest() -> (String, String, PersistedQueryManifest) { let id = "1234".to_string(); let body = r#"query { typename }"#.to_string(); - let manifest = hashmap! { id.to_string() => body.to_string() }; + let manifest = hashmap! { + FullPersistedQueryOperationId { + operation_id: id.to_string(), + client_name: None, + } => body.to_string() + }; (id, body, manifest) } @@ -32,7 +39,7 @@ pub async fn mock_empty_pq_uplink() -> (UplinkMockGuard, UplinkConfig) { /// Mocks an uplink server with a persisted query list with a delay. pub async fn mock_pq_uplink_with_delay( - manifest: &HashMap, + manifest: &PersistedQueryManifest, delay: Duration, ) -> (UplinkMockGuard, UplinkConfig) { let (guard, url) = mock_pq_uplink_one_endpoint(manifest, Some(delay)).await; @@ -43,7 +50,7 @@ pub async fn mock_pq_uplink_with_delay( } /// Mocks an uplink server with a persisted query list containing operations passed to this function. -pub async fn mock_pq_uplink(manifest: &HashMap) -> (UplinkMockGuard, UplinkConfig) { +pub async fn mock_pq_uplink(manifest: &PersistedQueryManifest) -> (UplinkMockGuard, UplinkConfig) { let (guard, url) = mock_pq_uplink_one_endpoint(manifest, None).await; ( guard, @@ -58,22 +65,29 @@ pub struct UplinkMockGuard { } #[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] struct Operation { id: String, body: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + client_name: Option, } /// Mocks an uplink server; returns a single Url rather than a full UplinkConfig, so you /// can combine it with another one to test failover. pub async fn mock_pq_uplink_one_endpoint( - manifest: &HashMap, + manifest: &PersistedQueryManifest, delay: Option, ) -> (UplinkMockGuard, Url) { let operations: Vec = manifest // clone the manifest so the caller can still make assertions about it .clone() .drain() - .map(|(id, body)| Operation { id, body }) + .map(|(full_id, body)| Operation { + id: full_id.operation_id, + body, + client_name: full_id.client_name, + }) .collect(); let mock_gcs_server = MockServer::start().await; diff --git a/apollo-router/tests/integration_tests.rs b/apollo-router/tests/integration_tests.rs index 378a20ddd5..4346ba2f71 100644 --- a/apollo-router/tests/integration_tests.rs +++ b/apollo-router/tests/integration_tests.rs @@ -8,6 +8,7 @@ use std::ffi::OsStr; use std::sync::Arc; use std::sync::Mutex; +use apollo_router::_private::create_test_service_factory_from_yaml; use apollo_router::graphql; use apollo_router::plugin::Plugin; use apollo_router::plugin::PluginInit; @@ -17,7 +18,6 @@ use apollo_router::services::supergraph; use apollo_router::test_harness::mocks::persisted_queries::*; use apollo_router::Configuration; use apollo_router::Context; -use apollo_router::_private::create_test_service_factory_from_yaml; use futures::StreamExt; use http::header::ACCEPT; use http::header::CONTENT_TYPE; @@ -401,137 +401,6 @@ async fn automated_persisted_queries() { assert_eq!(registry.totals(), expected_service_hits); } -#[tokio::test(flavor = "multi_thread")] -async fn persisted_queries() { - use hyper::header::HeaderValue; - use serde_json::json; - - /// Construct a persisted query request from an ID. - fn pq_request(persisted_query_id: &str) -> router::Request { - supergraph::Request::fake_builder() - .extension( - "persistedQuery", - json!({ - "version": 1, - "sha256Hash": persisted_query_id - }), - ) - .build() - .expect("expecting valid request") - .try_into() - .expect("could not convert supergraph::Request to router::Request") - } - - // set up a PQM with one query - const PERSISTED_QUERY_ID: &str = "GetMyNameID"; - const PERSISTED_QUERY_BODY: &str = "query GetMyName { me { name } }"; - let expected_data = serde_json_bytes::json!({ - "me": { - "name": "Ada Lovelace" - } - }); - - let (_mock_guard, uplink_config) = mock_pq_uplink( - &hashmap! { PERSISTED_QUERY_ID.to_string() => PERSISTED_QUERY_BODY.to_string() }, - ) - .await; - - let config = serde_json::json!({ - "persisted_queries": { - "enabled": true - }, - "apq": { - "enabled": false - } - }); - - let mut config: Configuration = serde_json::from_value(config).unwrap(); - config.uplink = Some(uplink_config); - let (router, registry) = setup_router_and_registry_with_config(config).await.unwrap(); - - // Successfully run a persisted query. - let actual = query_with_router(router.clone(), pq_request(PERSISTED_QUERY_ID)).await; - assert!(actual.errors.is_empty()); - assert_eq!(actual.data.as_ref(), Some(&expected_data)); - assert_eq!(registry.totals(), hashmap! {"accounts".to_string() => 1}); - - // Error on unpersisted query. - const UNKNOWN_QUERY_ID: &str = "unknown_query"; - const UNPERSISTED_QUERY_BODY: &str = "query GetYourName { you: me { name } }"; - let expected_data = serde_json_bytes::json!({ - "you": { - "name": "Ada Lovelace" - } - }); - let actual = query_with_router(router.clone(), pq_request(UNKNOWN_QUERY_ID)).await; - assert_eq!( - actual.errors, - vec![apollo_router::graphql::Error::builder() - .message(format!( - "Persisted query '{UNKNOWN_QUERY_ID}' not found in the persisted query list" - )) - .extension_code("PERSISTED_QUERY_NOT_IN_LIST") - .build()] - ); - assert_eq!(actual.data, None); - assert_eq!(registry.totals(), hashmap! {"accounts".to_string() => 1}); - - // We didn't break normal GETs. - let actual = query_with_router( - router.clone(), - supergraph::Request::fake_builder() - .query(UNPERSISTED_QUERY_BODY) - .method(Method::GET) - .build() - .unwrap() - .try_into() - .unwrap(), - ) - .await; - assert!(actual.errors.is_empty()); - assert_eq!(actual.data.as_ref(), Some(&expected_data)); - assert_eq!(registry.totals(), hashmap! {"accounts".to_string() => 2}); - - // We didn't break normal POSTs. - let actual = query_with_router( - router.clone(), - supergraph::Request::fake_builder() - .query(UNPERSISTED_QUERY_BODY) - .method(Method::POST) - .build() - .unwrap() - .try_into() - .unwrap(), - ) - .await; - assert!(actual.errors.is_empty()); - assert_eq!(actual.data, Some(expected_data)); - assert_eq!(registry.totals(), hashmap! {"accounts".to_string() => 3}); - - // Proper error when sending malformed request body - let actual = query_with_router( - router.clone(), - http::Request::builder() - .uri("http://default") - .method(Method::POST) - .header( - CONTENT_TYPE, - HeaderValue::from_static(APPLICATION_JSON.essence_str()), - ) - .body(router::Body::empty()) - .unwrap() - .into(), - ) - .await; - assert_eq!(actual.errors.len(), 1); - - assert_eq!(actual.errors[0].message, "Invalid GraphQL request"); - assert_eq!( - actual.errors[0].extensions["code"], - "INVALID_GRAPHQL_REQUEST" - ); -} - #[tokio::test(flavor = "multi_thread")] async fn missing_variables() { let request = supergraph::Request::fake_builder() diff --git a/apollo-router/tests/samples/README.md b/apollo-router/tests/samples/README.md index c37c65e06b..b5c2f68114 100644 --- a/apollo-router/tests/samples/README.md +++ b/apollo-router/tests/samples/README.md @@ -4,7 +4,7 @@ This folder contains a series of Router integration tests that can be defined en ## How to write a test -One test is recognized as a folder containing a `plan.json` file. Any number of subfolders is accepted, and the test name will be the path to the test folder. If the folder contains a `README.md` file, it will be added to the captured output of the test, and displayed if the test failed. +One test is recognized as a folder containing a `plan.json` (or `plan.yaml`) file. Any number of subfolders is accepted, and the test name will be the path to the test folder. If the folder contains a `README.md` file, it will be added to the captured output of the test, and displayed if the test failed. The `plan.json` file contains a top level JSON object with an `actions` field, containing an array of possible actions, that will be executed one by one: diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/README.md b/apollo-router/tests/samples/enterprise/persisted-queries/basic/README.md new file mode 100644 index 0000000000..09cba4f996 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/README.md @@ -0,0 +1,3 @@ +# Persisted Queries + +This tests Persisted Query Lists: https://www.apollographql.com/docs/graphos/routing/security/persisted-queries diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/configuration.yaml b/apollo-router/tests/samples/enterprise/persisted-queries/basic/configuration.yaml new file mode 100644 index 0000000000..dc60579e53 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/configuration.yaml @@ -0,0 +1,12 @@ +persisted_queries: + enabled: true + experimental_local_manifests: + - tests/samples/enterprise/persisted-queries/basic/persisted-query-manifest.json +apq: + enabled: false +telemetry: + apollo: + client_name_header: custom-client-name +rhai: + scripts: "tests/samples/enterprise/persisted-queries/basic/rhai" + main: "main.rhai" diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/persisted-query-manifest.json b/apollo-router/tests/samples/enterprise/persisted-queries/basic/persisted-query-manifest.json new file mode 100644 index 0000000000..1659290d24 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/persisted-query-manifest.json @@ -0,0 +1,15 @@ +{ + "format": "apollo-persisted-query-manifest", + "version": 1, + "operations": [ + { + "id": "GetMyNameID", + "body": "query GetMyName { me { name } }" + }, + { + "id": "GetMyNameID", + "clientName": "mobile", + "body": "query GetMyName { me { mobileAlias: name } }" + } + ] +} diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/plan.yaml b/apollo-router/tests/samples/enterprise/persisted-queries/basic/plan.yaml new file mode 100644 index 0000000000..c08b75aeb9 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/plan.yaml @@ -0,0 +1,129 @@ +enterprise: true + +actions: +- type: Start + schema_path: ./supergraph.graphql + configuration_path: ./configuration.yaml + subgraphs: + accounts: + requests: + - request: + body: + query: "query GetMyName__accounts__0{me{name}}" + response: + body: + data: + me: + name: "Ada Lovelace" + - request: + body: + query: "query GetMyName__accounts__0{me{mobileAlias:name}}" + response: + body: + data: + me: + mobileAlias: "Ada Lovelace" + - request: + body: + query: "query GetYourName__accounts__0{you:me{name}}" + response: + body: + data: + you: + name: "Ada Lovelace" + +# Successfully run a persisted query. +- type: Request + description: "Run a persisted query" + request: + extensions: + persistedQuery: + version: 1 + sha256Hash: "GetMyNameID" + expected_response: + data: + me: + name: "Ada Lovelace" + +# Successfully run a persisted query with client name that has its own +# operation, using the client name header configured in +# `telemetry.apollo.client_name_header`. +- type: Request + description: "Run a persisted query with client_name_header" + request: + extensions: + persistedQuery: + version: 1 + sha256Hash: "GetMyNameID" + headers: + custom-client-name: mobile + expected_response: + data: + me: + mobileAlias: "Ada Lovelace" + +# Successfully run a persisted query with client name that has its own +# operation, setting the client name via context in a Rhai plugin. +- type: Request + description: "Run a persisted query with plugin-set client name" + request: + extensions: + persistedQuery: + version: 1 + sha256Hash: "GetMyNameID" + headers: + plugin-client-name: mobile + expected_response: + data: + me: + mobileAlias: "Ada Lovelace" + +# Successfully run a persisted query with random client name falling back to the +# version without client name. +- type: Request + description: "Run a persisted query with fallback client name" + request: + extensions: + persistedQuery: + version: 1 + sha256Hash: "GetMyNameID" + headers: + custom-client-name: something-not-mobile + expected_response: + data: + me: + name: "Ada Lovelace" + +- type: Request + description: "Unknown persisted query ID" + request: + extensions: + persistedQuery: + version: 1 + sha256Hash: "unknown_query" + expected_response: + errors: + - message: "Persisted query 'unknown_query' not found in the persisted query list" + extensions: + code: PERSISTED_QUERY_NOT_IN_LIST + +- type: Request + description: "Normal non-PQ POSTs work" + request: + query: "query GetYourName { you: me { name } }" + expected_response: + data: + you: + name: "Ada Lovelace" + +- type: Request + description: "Proper error when sending malformed request body" + request: "" + expected_response: + errors: + - message: "Invalid GraphQL request" + extensions: + code: INVALID_GRAPHQL_REQUEST + details: 'failed to deserialize the request body into JSON: invalid type: string "", expected a GraphQL request at line 1 column 2' + +- type: Stop diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/rhai/main.rhai b/apollo-router/tests/samples/enterprise/persisted-queries/basic/rhai/main.rhai new file mode 100644 index 0000000000..b31e749479 --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/rhai/main.rhai @@ -0,0 +1,7 @@ +fn router_service(service) { + service.map_request(|request| { + if (request.headers.contains("plugin-client-name")) { + request.context["apollo_persisted_queries::client_name"] = request.headers["plugin-client-name"]; + } + }); +} diff --git a/apollo-router/tests/samples/enterprise/persisted-queries/basic/supergraph.graphql b/apollo-router/tests/samples/enterprise/persisted-queries/basic/supergraph.graphql new file mode 100644 index 0000000000..c5a920730a --- /dev/null +++ b/apollo-router/tests/samples/enterprise/persisted-queries/basic/supergraph.graphql @@ -0,0 +1,124 @@ +schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/inaccessible/v0.2", for: SECURITY) + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) { + query: Query + mutation: Mutation +} + +directive @inaccessible on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + +directive @tag( + name: String! +) repeatable on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | ARGUMENT_DEFINITION | SCALAR | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION | SCHEMA + +directive @join__enumValue(graph: join__Graph!) repeatable on ENUM_VALUE + +directive @join__field( + graph: join__Graph + requires: join__FieldSet + provides: join__FieldSet + type: String + external: Boolean + override: String + usedOverridden: Boolean +) repeatable on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + +directive @join__graph(name: String!, url: String!) on ENUM_VALUE + +directive @join__implements( + graph: join__Graph! + interface: String! +) repeatable on OBJECT | INTERFACE + +directive @join__type( + graph: join__Graph! + key: join__FieldSet + extension: Boolean! = false + resolvable: Boolean! = true + isInterfaceObject: Boolean! = false +) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + +directive @join__unionMember( + graph: join__Graph! + member: String! +) repeatable on UNION + +directive @link( + url: String + as: String + for: link__Purpose + import: [link__Import] +) repeatable on SCHEMA + +scalar join__FieldSet +scalar link__Import + +enum join__Graph { + ACCOUNTS + @join__graph(name: "accounts", url: "https://accounts.demo.starstuff.dev/") + INVENTORY + @join__graph( + name: "inventory" + url: "https://inventory.demo.starstuff.dev/" + ) + PRODUCTS + @join__graph(name: "products", url: "https://products.demo.starstuff.dev/") + REVIEWS + @join__graph(name: "reviews", url: "https://reviews.demo.starstuff.dev/") +} + +enum link__Purpose { + SECURITY + EXECUTION +} + +type Mutation @join__type(graph: PRODUCTS) @join__type(graph: REVIEWS) { + createProduct(upc: ID!, name: String): Product @join__field(graph: PRODUCTS) + createReview(upc: ID!, id: ID!, body: String): Review + @join__field(graph: REVIEWS) +} + +type Product + @join__type(graph: INVENTORY, key: "upc") + @join__type(graph: PRODUCTS, key: "upc") + @join__type(graph: REVIEWS, key: "upc") { + inStock: Boolean + @join__field(graph: INVENTORY) + @tag(name: "private") + @inaccessible + name: String @join__field(graph: PRODUCTS) + weight: Int @join__field(graph: INVENTORY, external: true) @join__field(graph: PRODUCTS) + price: Int @join__field(graph: INVENTORY, external: true) @join__field(graph: PRODUCTS) + reviews: [Review] @join__field(graph: REVIEWS) + reviewsForAuthor(authorID: ID!): [Review] @join__field(graph: REVIEWS) + shippingEstimate: Int @join__field(graph: INVENTORY, requires: "price weight") + upc: String! +} + +type Query + @join__type(graph: ACCOUNTS) + @join__type(graph: INVENTORY) + @join__type(graph: PRODUCTS) + @join__type(graph: REVIEWS) { + me: User @join__field(graph: ACCOUNTS) + topProducts(first: Int = 5): [Product] @join__field(graph: PRODUCTS) +} + +type Review @join__type(graph: REVIEWS, key: "id") { + id: ID! + author: User @join__field(graph: REVIEWS, provides: "username") + body: String @join__field(graph: REVIEWS) + product: Product @join__field(graph: REVIEWS) +} + +type User + @join__type(graph: ACCOUNTS, key: "id") + @join__type(graph: REVIEWS, key: "id") { + id: ID! + name: String @join__field(graph: ACCOUNTS) + username: String + @join__field(graph: ACCOUNTS) + @join__field(graph: REVIEWS, external: true) + reviews: [Review] @join__field(graph: REVIEWS) +} diff --git a/apollo-router/tests/samples_tests.rs b/apollo-router/tests/samples_tests.rs index 8159a40179..98782558bb 100644 --- a/apollo-router/tests/samples_tests.rs +++ b/apollo-router/tests/samples_tests.rs @@ -56,7 +56,7 @@ fn lookup_dir( path.file_name().unwrap().to_str().unwrap() ); - if path.join("plan.json").exists() { + let plan: Option = if path.join("plan.json").exists() { let mut file = File::open(path.join("plan.json")).map_err(|e| { format!( "could not open file at path '{:?}': {e}", @@ -71,8 +71,8 @@ fn lookup_dir( ) })?; - let plan: Plan = match serde_json::from_str(&s) { - Ok(data) => data, + match serde_json::from_str(&s) { + Ok(data) => Some(data), Err(e) => { return Err(format!( "could not deserialize test plan at {}: {e}", @@ -80,8 +80,37 @@ fn lookup_dir( ) .into()); } - }; + } + } else if path.join("plan.yaml").exists() { + let mut file = File::open(path.join("plan.yaml")).map_err(|e| { + format!( + "could not open file at path '{:?}': {e}", + &path.join("plan.yaml") + ) + })?; + let mut s = String::new(); + file.read_to_string(&mut s).map_err(|e| { + format!( + "could not read file at path: '{:?}': {e}", + &path.join("plan.yaml") + ) + })?; + match serde_yaml::from_str(&s) { + Ok(data) => Some(data), + Err(e) => { + return Err(format!( + "could not deserialize test plan at {}: {e}", + path.display() + ) + .into()); + } + } + } else { + None + }; + + if let Some(plan) = plan { if plan.enterprise && !(std::env::var("TEST_APOLLO_KEY").is_ok() && std::env::var("TEST_APOLLO_GRAPH_REF").is_ok()) @@ -172,6 +201,7 @@ impl TestExecution { .await } Action::Request { + description, request, query_path, headers, @@ -179,6 +209,7 @@ impl TestExecution { expected_headers, } => { self.request( + description.clone(), request.clone(), query_path.as_deref(), headers, @@ -429,6 +460,7 @@ impl TestExecution { #[allow(clippy::too_many_arguments)] async fn request( &mut self, + description: Option, mut request: Value, query_path: Option<&str>, headers: &HashMap, @@ -456,6 +488,11 @@ impl TestExecution { } } + writeln!(out).unwrap(); + if let Some(description) = description { + writeln!(out, "description: {description}").unwrap(); + } + writeln!(out, "query: {}\n", serde_json::to_string(&request).unwrap()).unwrap(); writeln!(out, "header: {:?}\n", headers).unwrap(); @@ -660,6 +697,7 @@ fn check_path(path: &Path, out: &mut String) -> Result<(), Failed> { #[derive(Deserialize)] #[allow(dead_code)] +#[serde(deny_unknown_fields)] struct Plan { #[serde(default)] enterprise: bool, @@ -669,7 +707,7 @@ struct Plan { } #[derive(Deserialize)] -#[serde(tag = "type")] +#[serde(tag = "type", deny_unknown_fields)] enum Action { Start { schema_path: String, @@ -689,6 +727,7 @@ enum Action { update_url_overrides: bool, }, Request { + description: Option, request: Value, query_path: Option, #[serde(default)] @@ -705,17 +744,20 @@ enum Action { } #[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct Subgraph { requests: Vec, } #[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct SubgraphRequestMock { request: HttpRequest, response: HttpResponse, } #[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct HttpRequest { method: Option, path: Option, @@ -725,6 +767,7 @@ struct HttpRequest { } #[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct HttpResponse { status: Option, #[serde(default)] diff --git a/docs/source/routing/observability/client-awareness.mdx b/docs/source/routing/observability/client-awareness.mdx index f7660f85a8..8b5f566aef 100644 --- a/docs/source/routing/observability/client-awareness.mdx +++ b/docs/source/routing/observability/client-awareness.mdx @@ -8,6 +8,8 @@ import { Link } from "gatsby"; The GraphOS Router and Apollo Router Core support [client awareness](/graphos/metrics/client-awareness/) by default. If the client sets the headers `apollographql-client-name` and `apollographql-client-version` in its HTTP requests, GraphOS Studio can separate the metrics and operations per client. +This client name is also used by the [Persisted Queries](/graphos/routing/security/persisted-queries) feature. + ## Overriding client awareness headers Different header names can be used by updating the configuration file. If those headers will be sent by a browser, they must be allowed in the [CORS (Cross Origin Resource Sharing) configuration](/router/configuration/cors), as follows: diff --git a/docs/source/routing/security/persisted-queries.mdx b/docs/source/routing/security/persisted-queries.mdx index 5befd7071c..845e0aa75d 100644 --- a/docs/source/routing/security/persisted-queries.mdx +++ b/docs/source/routing/security/persisted-queries.mdx @@ -138,6 +138,20 @@ To enable safelisting, you _must_ turn off [automatic persisted queries](/router +### Customization via request context + +GraphOS Router can be [customized](/graphos/routing/customization/overview) via several mechanisms such as [Rhai scripts](/graphos/routing/customization/rhai) and [coprocessors](/graphos/routing/customization/coprocessor). These plugins can affect your router's persistent query processing by writing to the request context. + +#### `apollo_persisted_queries::client_name` + +When publishing operations to a PQL, you can specify a client name associated with the operation (by including a `clientName` field in the individual operation in your [manifest](/graphos/platform/security/persisted-queries#per-operation-properties), or by including the `--for-client-name` option to `rover persisted-queries publish`). If an operation has a client name, it will only be executed by requests that specify that client name. (Your PQL can contain multiple operations with the same ID and different client names.) + +Your customization (Rhai script, coprocessor, etc) can examine a request during the [Router Service stage](/graphos/routing/customization/overview#request-path) of the request path and set the `apollo_persisted_queries::client_name` value in the request context to the request's client name. + +If this context value is not set by a customization, your router will use the same client name used for [client awareness](/graphos/routing/observability/client-awareness) in observability. This client name is read from an HTTP header specified by `telemetry.apollo.client_name_header`, or `apollographql-client-name` by default. + +If your request specifies an ID and a client name but there is no operation in the PQL with that ID and client name, your router will look to see if there is an operation with that ID and no client name specified, and use that if it finds it. + ## Limitations * **Unsupported with offline license**. An GraphOS Router using an [offline Enterprise license](/router/enterprise-features/#offline-enterprise-license) cannot use safelisting with persisted queries. The feature relies on Apollo Uplink to fetch persisted query manifests, so it doesn't work as designed when the router is disconnected from Uplink.