diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 2a8862d60c..26efc76983 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -59,6 +59,12 @@ By [@geal](https://github.com/geal) in https://github.com/apollographql/router/p ## 🚀 Features +### Expose query plan in extensions for GraphQL response (experimental) ([PR #1470](https://github.com/apollographql/router/pull/1470)) + +Expose query plan in extensions for GraphQL response. Only experimental for now, no documentation available. + +By [@bnjjj](https://github.com/bnjjj) in https://github.com/apollographql/router/pull/1470 + ### Add support of global rate limit and timeout. [PR #1347](https://github.com/apollographql/router/pull/1347) Additions to the traffic shaping plugin: 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 dd58ee074d..03c3e6300f 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 @@ -1,5 +1,6 @@ --- source: apollo-router/src/configuration/mod.rs +assertion_line: 909 expression: "&schema" --- { @@ -282,6 +283,9 @@ expression: "&schema" "description": "Plugin configuration", "default": null, "properties": { + "experimental.expose_query_plan": { + "type": "boolean" + }, "experimental.include_subgraph_errors": { "type": "object", "properties": { diff --git a/apollo-router/src/context.rs b/apollo-router/src/context.rs index 83fe00c52a..6984881326 100644 --- a/apollo-router/src/context.rs +++ b/apollo-router/src/context.rs @@ -90,6 +90,14 @@ impl Context { self.entries.insert(key.into(), value) } + /// Get a json value from the context using the provided key. + pub fn get_json_value(&self, key: K) -> Option + where + K: Into, + { + self.entries.get(&key.into()).map(|v| v.value().clone()) + } + /// Upsert a value in the context using the provided key and resolving /// function. /// diff --git a/apollo-router/src/plugins/expose_query_plan.rs b/apollo-router/src/plugins/expose_query_plan.rs new file mode 100644 index 0000000000..ab3eb9c2f0 --- /dev/null +++ b/apollo-router/src/plugins/expose_query_plan.rs @@ -0,0 +1,248 @@ +use futures::future::ready; +use futures::stream::once; +use futures::StreamExt; +use http::HeaderValue; +use serde_json_bytes::json; +use tower::util::BoxService; +use tower::BoxError; +use tower::ServiceExt as TowerServiceExt; + +use crate::layers::ServiceExt; +use crate::plugin::Plugin; +use crate::plugin::PluginInit; +use crate::register_plugin; +use crate::services::QueryPlannerContent; +use crate::services::QueryPlannerRequest; +use crate::services::QueryPlannerResponse; +use crate::services::RouterRequest; +use crate::services::RouterResponse; + +const EXPOSE_QUERY_PLAN_HEADER_NAME: &str = "Apollo-Expose-Query-Plan"; +const ENABLE_EXPOSE_QUERY_PLAN_ENV: &str = "APOLLO_EXPOSE_QUERY_PLAN"; +const QUERY_PLAN_CONTEXT_KEY: &str = "experimental::expose_query_plan.plan"; +const ENABLED_CONTEXT_KEY: &str = "experimental::expose_query_plan.enabled"; + +#[derive(Debug, Clone)] +struct ExposeQueryPlan { + enabled: bool, +} + +#[async_trait::async_trait] +impl Plugin for ExposeQueryPlan { + type Config = bool; + + async fn new(init: PluginInit) -> Result { + Ok(ExposeQueryPlan { + enabled: init.config + || std::env::var(ENABLE_EXPOSE_QUERY_PLAN_ENV).as_deref() == Ok("true"), + }) + } + + fn query_planning_service( + &self, + service: BoxService, + ) -> BoxService { + service + .map_response(move |res| { + if res + .context + .get::<_, bool>(ENABLED_CONTEXT_KEY) + .ok() + .flatten() + .is_some() + { + if let QueryPlannerContent::Plan { plan, .. } = &res.content { + res.context + .insert(QUERY_PLAN_CONTEXT_KEY, plan.root.clone()) + .unwrap(); + } + } + + res + }) + .boxed() + } + + fn router_service( + &self, + service: BoxService, + ) -> BoxService { + let conf_enabled = self.enabled; + service + .map_future_with_context(move |req: &RouterRequest| { + let is_enabled = conf_enabled && req.originating_request.headers().get(EXPOSE_QUERY_PLAN_HEADER_NAME) == Some(&HeaderValue::from_static("true")); + if is_enabled { + req.context.insert(ENABLED_CONTEXT_KEY, true).unwrap(); + } + (req.originating_request.body().query.clone(), is_enabled) + }, move |(query, is_enabled): (Option, bool), f| async move { + let mut res: Result = f.await; + res = match res { + Ok(mut res) => { + if is_enabled { + let (parts, stream) = http::Response::from(res.response).into_parts(); + let (mut first, rest) = stream.into_future().await; + + if let Some(first) = &mut first { + if let Some(plan) = + res.context.get_json_value(QUERY_PLAN_CONTEXT_KEY) + { + first + .extensions + .insert("apolloQueryPlan", json!({ "object": { "kind": "QueryPlan", "node": plan, "text": query } })); + } + } + res.response = http::Response::from_parts( + parts, + once(ready(first.unwrap_or_default())).chain(rest).boxed(), + ) + .into(); + } + + Ok(res) + } + Err(err) => Err(err), + }; + + res + }) + .boxed() + } +} + +register_plugin!("experimental", "expose_query_plan", ExposeQueryPlan); + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use once_cell::sync::Lazy; + use serde_json::Value as jValue; + use serde_json_bytes::ByteString; + use serde_json_bytes::Value; + use tower::util::BoxCloneService; + use tower::Service; + + use super::*; + use crate::graphql::Response; + use crate::json_ext::Object; + use crate::plugin::test::MockSubgraph; + use crate::plugin::DynPlugin; + use crate::services::PluggableRouterServiceBuilder; + use crate::Schema; + + static EXPECTED_RESPONSE_WITH_QUERY_PLAN: Lazy = Lazy::new(|| { + serde_json::from_str(include_str!( + "../../tests/fixtures/expected_response_with_queryplan.json" + )) + .unwrap() + }); + static EXPECTED_RESPONSE_WITHOUT_QUERY_PLAN: Lazy = Lazy::new(|| { + serde_json::from_str(include_str!( + "../../tests/fixtures/expected_response_without_queryplan.json" + )) + .unwrap() + }); + + static VALID_QUERY: &str = r#"query TopProducts($first: Int) { topProducts(first: $first) { upc name reviews { id product { name } author { id name } } } }"#; + + async fn build_mock_router( + plugin: Box, + ) -> BoxCloneService { + let mut extensions = Object::new(); + extensions.insert("test", Value::String(ByteString::from("value"))); + + let account_mocks = vec![ + ( + r#"{"query":"query TopProducts__accounts__3($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}","operationName":"TopProducts__accounts__3","variables":{"representations":[{"__typename":"User","id":"1"},{"__typename":"User","id":"2"},{"__typename":"User","id":"1"}]}}"#, + r#"{"data":{"_entities":[{"name":"Ada Lovelace"},{"name":"Alan Turing"},{"name":"Ada Lovelace"}]}}"# + ) + ].into_iter().map(|(query, response)| (serde_json::from_str(query).unwrap(), serde_json::from_str(response).unwrap())).collect(); + let account_service = MockSubgraph::new(account_mocks); + + let review_mocks = vec![ + ( + r#"{"query":"query TopProducts__reviews__1($representations:[_Any!]!){_entities(representations:$representations){...on Product{reviews{id product{__typename upc}author{__typename id}}}}}","operationName":"TopProducts__reviews__1","variables":{"representations":[{"__typename":"Product","upc":"1"},{"__typename":"Product","upc":"2"}]}}"#, + r#"{"data":{"_entities":[{"reviews":[{"id":"1","product":{"__typename":"Product","upc":"1"},"author":{"__typename":"User","id":"1"}},{"id":"4","product":{"__typename":"Product","upc":"1"},"author":{"__typename":"User","id":"2"}}]},{"reviews":[{"id":"2","product":{"__typename":"Product","upc":"2"},"author":{"__typename":"User","id":"1"}}]}]}}"# + ) + ].into_iter().map(|(query, response)| (serde_json::from_str(query).unwrap(), serde_json::from_str(response).unwrap())).collect(); + let review_service = MockSubgraph::new(review_mocks); + + let product_mocks = vec![ + ( + r#"{"query":"query TopProducts__products__0($first:Int){topProducts(first:$first){__typename upc name}}","operationName":"TopProducts__products__0","variables":{"first":2}}"#, + r#"{"data":{"topProducts":[{"__typename":"Product","upc":"1","name":"Table"},{"__typename":"Product","upc":"2","name":"Couch"}]}}"# + ), + ( + r#"{"query":"query TopProducts__products__2($representations:[_Any!]!){_entities(representations:$representations){...on Product{name}}}","operationName":"TopProducts__products__2","variables":{"representations":[{"__typename":"Product","upc":"1"},{"__typename":"Product","upc":"1"},{"__typename":"Product","upc":"2"}]}}"#, + r#"{"data":{"_entities":[{"name":"Table"},{"name":"Table"},{"name":"Couch"}]}}"# + ) + ].into_iter().map(|(query, response)| (serde_json::from_str(query).unwrap(), serde_json::from_str(response).unwrap())).collect(); + + let product_service = MockSubgraph::new(product_mocks).with_extensions(extensions); + + let schema = + include_str!("../../../apollo-router-benchmarks/benches/fixtures/supergraph.graphql"); + let schema = Arc::new(Schema::parse(schema, &Default::default()).unwrap()); + + let builder = PluggableRouterServiceBuilder::new(schema.clone()); + let builder = builder + .with_dyn_plugin("experimental.expose_query_plan".to_string(), plugin) + .with_subgraph_service("accounts", account_service.clone()) + .with_subgraph_service("reviews", review_service.clone()) + .with_subgraph_service("products", product_service.clone()); + + let router = builder.build().await.expect("should build").test_service(); + + router + } + + async fn get_plugin(config: &jValue) -> Box { + crate::plugin::plugins() + .get("experimental.expose_query_plan") + .expect("Plugin not found") + .create_instance_without_schema(config) + .await + .expect("Plugin not created") + } + + async fn execute_router_test( + query: &str, + body: &Response, + mut router_service: BoxCloneService, + ) { + let request = RouterRequest::fake_builder() + .query(query.to_string()) + .variable("first", 2usize) + .header(EXPOSE_QUERY_PLAN_HEADER_NAME, "true") + .build() + .expect("expecting valid request"); + + let response = router_service + .ready() + .await + .unwrap() + .call(request) + .await + .unwrap() + .next_response() + .await + .unwrap(); + + assert_eq!(response, *body); + } + + #[tokio::test] + async fn it_expose_query_plan() { + let plugin = get_plugin(&serde_json::json!(true)).await; + let router = build_mock_router(plugin).await; + execute_router_test(VALID_QUERY, &*EXPECTED_RESPONSE_WITH_QUERY_PLAN, router).await; + } + + #[tokio::test] + async fn it_doesnt_expose_query_plan() { + let plugin = get_plugin(&serde_json::json!(false)).await; + let router = build_mock_router(plugin).await; + execute_router_test(VALID_QUERY, &*EXPECTED_RESPONSE_WITHOUT_QUERY_PLAN, router).await; + } +} diff --git a/apollo-router/src/plugins/mod.rs b/apollo-router/src/plugins/mod.rs index c5f4771ef8..aee7a9c5fb 100644 --- a/apollo-router/src/plugins/mod.rs +++ b/apollo-router/src/plugins/mod.rs @@ -3,6 +3,7 @@ //! These plugins are compiled into the router and configured via YAML configuration. pub mod csrf; +mod expose_query_plan; mod forbid_mutations; mod headers; mod include_subgraph_errors; diff --git a/apollo-router/src/query_planner/mod.rs b/apollo-router/src/query_planner/mod.rs index 1c84378436..a7fc333e47 100644 --- a/apollo-router/src/query_planner/mod.rs +++ b/apollo-router/src/query_planner/mod.rs @@ -11,6 +11,7 @@ use futures::prelude::*; use opentelemetry::trace::SpanKind; use router_bridge::planner::UsageReporting; use serde::Deserialize; +use serde::Serialize; use tokio::sync::broadcast::Sender; use tokio_stream::wrappers::BroadcastStream; use tracing::Instrument; @@ -64,7 +65,7 @@ impl QueryPlan { } /// Query plans are composed of a set of nodes. -#[derive(Debug, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "PascalCase", tag = "kind")] pub(crate) enum PlanNode { /// These nodes must be executed in order. @@ -725,6 +726,7 @@ pub(crate) mod fetch { use indexmap::IndexSet; use serde::Deserialize; + use serde::Serialize; use tower::ServiceExt; use tracing::instrument; use tracing::Instrument; @@ -743,7 +745,7 @@ pub(crate) mod fetch { use crate::*; /// GraphQL operation type. - #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Deserialize)] + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub enum OperationKind { Query, @@ -768,7 +770,7 @@ pub(crate) mod fetch { } /// A fetch node. - #[derive(Debug, PartialEq, Deserialize)] + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct FetchNode { /// The name of the service or subgraph that the fetch is querying. @@ -1084,7 +1086,7 @@ pub(crate) mod fetch { } /// A flatten node. -#[derive(Debug, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct FlattenNode { /// The path when result should be merged. @@ -1095,7 +1097,7 @@ pub(crate) struct FlattenNode { } /// A primary query for a Defer node, the non deferred part -#[derive(Debug, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct Primary { /// Optional path, set if and only if the defer node is a @@ -1113,7 +1115,7 @@ pub(crate) struct Primary { /// The "deferred" parts of the defer (note that it's an array). Each /// of those deferred elements will correspond to a different chunk of /// the response to the client (after the initial non-deferred one that is). -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DeferredNode { /// References one or more fetch node(s) (by `id`) within @@ -1144,7 +1146,7 @@ impl DeferredNode { } } /// A deferred node. -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct Depends { id: String, diff --git a/apollo-router/src/query_planner/selection.rs b/apollo-router/src/query_planner/selection.rs index fb6b9bd6aa..552a13393c 100644 --- a/apollo-router/src/query_planner/selection.rs +++ b/apollo-router/src/query_planner/selection.rs @@ -1,4 +1,5 @@ use serde::Deserialize; +use serde::Serialize; use serde_json_bytes::Entry; use crate::error::FetchError; @@ -9,7 +10,7 @@ use crate::*; /// A selection that is part of a fetch. /// Selections are used to propagate data to subgraph fetches. -#[derive(Debug, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "PascalCase", tag = "kind")] pub(crate) enum Selection { /// A field selection. @@ -20,7 +21,7 @@ pub(crate) enum Selection { } /// The field that is used -#[derive(Debug, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct Field { /// An optional alias for the field. @@ -36,7 +37,7 @@ pub(crate) struct Field { } /// An inline fragment. -#[derive(Debug, PartialEq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct InlineFragment { /// The required fragment type. diff --git a/apollo-router/tests/fixtures/expected_response_with_queryplan.json b/apollo-router/tests/fixtures/expected_response_with_queryplan.json new file mode 100644 index 0000000000..4538b09898 --- /dev/null +++ b/apollo-router/tests/fixtures/expected_response_with_queryplan.json @@ -0,0 +1 @@ +{"data":{"topProducts":[{"upc":"1","name":"Table","reviews":[{"id":"1","product":{"name":"Table"},"author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","product":{"name":"Table"},"author":{"id":"2","name":"Alan Turing"}}]},{"upc":"2","name":"Couch","reviews":[{"id":"2","product":{"name":"Couch"},"author":{"id":"1","name":"Ada Lovelace"}}]}]},"extensions":{"apolloQueryPlan":{"object":{"kind":"QueryPlan","node":{"kind":"Sequence","nodes":[{"kind":"Fetch","serviceName":"products","variableUsages":["first"],"operation":"query TopProducts__products__0($first:Int){topProducts(first:$first){__typename upc name}}","operationName":"TopProducts__products__0","operationKind":"query","id":null},{"kind":"Flatten","path":["topProducts","@"],"node":{"kind":"Fetch","serviceName":"reviews","requires":[{"kind":"InlineFragment","typeCondition":"Product","selections":[{"kind":"Field","name":"__typename"},{"kind":"Field","name":"upc"}]}],"variableUsages":[],"operation":"query TopProducts__reviews__1($representations:[_Any!]!){_entities(representations:$representations){...on Product{reviews{id product{__typename upc}author{__typename id}}}}}","operationName":"TopProducts__reviews__1","operationKind":"query","id":null}},{"kind":"Parallel","nodes":[{"kind":"Flatten","path":["topProducts","@","reviews","@","product"],"node":{"kind":"Fetch","serviceName":"products","requires":[{"kind":"InlineFragment","typeCondition":"Product","selections":[{"kind":"Field","name":"__typename"},{"kind":"Field","name":"upc"}]}],"variableUsages":[],"operation":"query TopProducts__products__2($representations:[_Any!]!){_entities(representations:$representations){...on Product{name}}}","operationName":"TopProducts__products__2","operationKind":"query","id":null}},{"kind":"Flatten","path":["topProducts","@","reviews","@","author"],"node":{"kind":"Fetch","serviceName":"accounts","requires":[{"kind":"InlineFragment","typeCondition":"User","selections":[{"kind":"Field","name":"__typename"},{"kind":"Field","name":"id"}]}],"variableUsages":[],"operation":"query TopProducts__accounts__3($representations:[_Any!]!){_entities(representations:$representations){...on User{name}}}","operationName":"TopProducts__accounts__3","operationKind":"query","id":null}}]}]},"text":"query TopProducts($first: Int) { topProducts(first: $first) { upc name reviews { id product { name } author { id name } } } }"}}}} \ No newline at end of file diff --git a/apollo-router/tests/fixtures/expected_response_without_queryplan.json b/apollo-router/tests/fixtures/expected_response_without_queryplan.json new file mode 100644 index 0000000000..60e18f263d --- /dev/null +++ b/apollo-router/tests/fixtures/expected_response_without_queryplan.json @@ -0,0 +1 @@ +{"data":{"topProducts":[{"upc":"1","name":"Table","reviews":[{"id":"1","product":{"name":"Table"},"author":{"id":"1","name":"Ada Lovelace"}},{"id":"4","product":{"name":"Table"},"author":{"id":"2","name":"Alan Turing"}}]},{"upc":"2","name":"Couch","reviews":[{"id":"2","product":{"name":"Couch"},"author":{"id":"1","name":"Ada Lovelace"}}]}]}} \ No newline at end of file