diff --git a/apollo-router/src/json_ext.rs b/apollo-router/src/json_ext.rs index 3b9cbc0e19..1af88a1e48 100644 --- a/apollo-router/src/json_ext.rs +++ b/apollo-router/src/json_ext.rs @@ -87,12 +87,21 @@ pub(crate) trait ValueExt { where F: FnMut(&Path, &'a Value); + /// Select all values matching a `Path`, and allows to mutate those values. + /// + /// The behavior of the method is otherwise the same as it's non-mutable counterpart + #[track_caller] + fn select_values_and_paths_mut<'a, F>(&'a mut self, schema: &Schema, path: &'a Path, f: F) + where + F: FnMut(&Path, &'a mut Value); + #[track_caller] fn is_valid_float_input(&self) -> bool; #[track_caller] fn is_valid_int_input(&self) -> bool; + /// Returns whether this value is an object that matches the provided type. /// /// More precisely, this checks that this value is an object, looks at @@ -376,6 +385,14 @@ impl ValueExt for Value { iterate_path(schema, &mut Path::default(), &path.0, self, &mut f) } + #[track_caller] + fn select_values_and_paths_mut<'a, F>(&'a mut self, schema: &Schema, path: &'a Path, mut f: F) + where + F: FnMut(&Path, &'a mut Value), + { + iterate_path_mut(schema, &mut Path::default(), &path.0, self, &mut f) + } + #[track_caller] fn is_valid_float_input(&self) -> bool { // https://spec.graphql.org/draft/#sec-Float.Input-Coercion @@ -475,6 +492,64 @@ fn iterate_path<'a, F>( } } +fn iterate_path_mut<'a, F>( + schema: &Schema, + parent: &mut Path, + path: &'a [PathElement], + data: &'a mut Value, + f: &mut F, +) where + F: FnMut(&Path, &'a mut Value), +{ + match path.get(0) { + None => f(parent, data), + Some(PathElement::Flatten) => { + if let Some(array) = data.as_array_mut() { + for (i, value) in array.iter_mut().enumerate() { + parent.push(PathElement::Index(i)); + iterate_path_mut(schema, parent, &path[1..], value, f); + parent.pop(); + } + } + } + Some(PathElement::Index(i)) => { + if let Value::Array(a) = data { + if let Some(value) = a.get_mut(*i) { + parent.push(PathElement::Index(*i)); + iterate_path_mut(schema, parent, &path[1..], value, f); + parent.pop(); + } + } + } + Some(PathElement::Key(k)) => { + if let Value::Object(o) = data { + if let Some(value) = o.get_mut(k.as_str()) { + parent.push(PathElement::Key(k.to_string())); + iterate_path_mut(schema, parent, &path[1..], value, f); + parent.pop(); + } + } else if let Value::Array(array) = data { + for (i, value) in array.iter_mut().enumerate() { + parent.push(PathElement::Index(i)); + iterate_path_mut(schema, parent, path, value, f); + parent.pop(); + } + } + } + Some(PathElement::Fragment(name)) => { + if data.is_object_of_type(schema, name) { + iterate_path_mut(schema, parent, &path[1..], data, f); + } else if let Value::Array(array) = data { + for (i, value) in array.iter_mut().enumerate() { + parent.push(PathElement::Index(i)); + iterate_path_mut(schema, parent, path, value, f); + parent.pop(); + } + } + } + } +} + /// A GraphQL path element that is composes of strings or numbers. /// e.g `/book/3/name` #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)] diff --git a/apollo-router/src/query_planner/fetch.rs b/apollo-router/src/query_planner/fetch.rs index 833d456bbe..64c5f5fb49 100644 --- a/apollo-router/src/query_planner/fetch.rs +++ b/apollo-router/src/query_planner/fetch.rs @@ -10,6 +10,7 @@ use tracing::instrument; use tracing::Instrument; use super::execution::ExecutionParameters; +use super::rewrites; use super::selection::select_object; use super::selection::Selection; use crate::error::Error; @@ -83,6 +84,12 @@ pub(crate) struct FetchNode { /// Optional id used by Deferred nodes pub(crate) id: Option, + + // Optionally describes a number of "rewrites" that query plan executors should apply to the data that is sent as input of this fetch. + pub(crate) input_rewrites: Option>, + + // Optionally describes a number of "rewrites" to apply to the data that received from a fetch (and before it is applied to the current in-memory results). + pub(crate) output_rewrites: Option>, } struct Variables { @@ -92,6 +99,7 @@ struct Variables { impl Variables { #[instrument(skip_all, level = "debug", name = "make_variables")] + #[allow(clippy::too_many_arguments)] async fn new( requires: &[Selection], variable_usages: &[String], @@ -100,6 +108,7 @@ impl Variables { request: &Arc>, schema: &Schema, enable_deduplicate_variables: bool, + input_rewrites: &Option>, ) -> Option { let body = request.body(); if !requires.is_empty() { @@ -116,7 +125,8 @@ impl Variables { let mut values: IndexSet = IndexSet::new(); data.select_values_and_paths(schema, current_dir, |path, value| { if let Value::Object(content) = value { - if let Ok(Some(value)) = select_object(content, requires, schema) { + if let Ok(Some(mut value)) = select_object(content, requires, schema) { + rewrites::apply_rewrites(schema, &mut value, input_rewrites); match values.get_index_of(&value) { Some(index) => { paths.insert(path.clone(), index); @@ -139,7 +149,8 @@ impl Variables { let mut values: Vec = Vec::new(); data.select_values_and_paths(schema, current_dir, |path, value| { if let Value::Object(content) = value { - if let Ok(Some(value)) = select_object(content, requires, schema) { + if let Ok(Some(mut value)) = select_object(content, requires, schema) { + rewrites::apply_rewrites(schema, &mut value, input_rewrites); paths.insert(path.clone(), values.len()); values.push(value); } @@ -210,6 +221,7 @@ impl FetchNode { parameters.supergraph_request, parameters.schema, parameters.options.enable_deduplicate_variables, + &self.input_rewrites, ) .await { @@ -280,7 +292,8 @@ impl FetchNode { }); } - let (value, errors) = self.response_at_path(current_dir, paths, response); + let (value, errors) = + self.response_at_path(parameters.schema, current_dir, paths, response); if let Some(id) = &self.id { if let Some(sender) = parameters.deferred_fetches.get(id.as_str()) { if let Err(e) = sender.clone().send((value.clone(), errors.clone())) { @@ -294,6 +307,7 @@ impl FetchNode { #[instrument(skip_all, level = "debug", name = "response_insert")] fn response_at_path<'a>( &'a self, + schema: &Schema, current_dir: &'a Path, paths: HashMap, response: graphql::Response, @@ -356,7 +370,9 @@ impl FetchNode { for (path, entity_idx) in paths { if let Some(entity) = array.get(entity_idx) { - let _ = value.insert(&path, entity.clone()); + let mut data = entity.clone(); + rewrites::apply_rewrites(schema, &mut data, &self.output_rewrites); + let _ = value.insert(&path, data); } } return (value, errors); @@ -399,10 +415,9 @@ impl FetchNode { } }) .collect(); - ( - Value::from_path(current_dir, response.data.unwrap_or_default()), - errors, - ) + let mut data = response.data.unwrap_or_default(); + rewrites::apply_rewrites(schema, &mut data, &self.output_rewrites); + (Value::from_path(current_dir, data), errors) } } diff --git a/apollo-router/src/query_planner/mod.rs b/apollo-router/src/query_planner/mod.rs index 0c7df47369..8043a1181d 100644 --- a/apollo-router/src/query_planner/mod.rs +++ b/apollo-router/src/query_planner/mod.rs @@ -12,6 +12,7 @@ mod caching_query_planner; mod execution; pub(crate) mod fetch; mod plan; +pub(crate) mod rewrites; mod selection; pub use plan::*; diff --git a/apollo-router/src/query_planner/rewrites.rs b/apollo-router/src/query_planner/rewrites.rs new file mode 100644 index 0000000000..d9e5f2eb2e --- /dev/null +++ b/apollo-router/src/query_planner/rewrites.rs @@ -0,0 +1,94 @@ +//! Declares data structure for the "data rewrites" that the query planner can include in some `FetchNode`, +//! and implements those rewrites. +//! +//! Note that on the typescript side, the query planner currently declare the rewrites that applies +//! to "inputs" and those applying to "outputs" separatly. This is due to simplify the current +//! implementation on the typescript side (for ... reasons), but it does not simplify anything to make that +//! distinction here. All this means is that, as of this writing, some kind of rewrites will only +//! every appear on the input side, while other will only appear on outputs, but it does not hurt +//! to be future-proof by supporting all types of rewrites on both "sides". + +use serde::Deserialize; +use serde::Serialize; + +use crate::json_ext::Path; +use crate::json_ext::PathElement; +use crate::json_ext::Value; +use crate::json_ext::ValueExt; +use crate::spec::Schema; + +/// Given a path, separates the last element of path and the rest of it and return them as a pair. +/// This will return `None` if the path is empty. +fn split_path_last_element(path: &Path) -> Option<(Path, &PathElement)> { + // If we have a `last()`, then we have a `parent()` too, so unwrapping shoud be safe. + path.last().map(|last| (path.parent().unwrap(), last)) +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "PascalCase", tag = "kind")] +pub(crate) enum DataRewrite { + ValueSetter(DataValueSetter), + KeyRenamer(DataKeyRenamer), +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DataValueSetter { + pub(crate) path: Path, + pub(crate) set_value_to: Value, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DataKeyRenamer { + pub(crate) path: Path, + pub(crate) rename_key_to: String, +} + +impl DataRewrite { + fn maybe_apply(&self, schema: &Schema, data: &mut Value) { + match self { + DataRewrite::ValueSetter(setter) => { + // The `path` of rewrites can only be either `Key` or `Fragment`, and so far + // we only ever rewrite the value of fields, so the last element will be a + // `Key` and we ignore other cases (in theory, it could be `Fragment` needs + // to be supported someday if we ever need to rewrite full object values, + // but that can be added then). + if let Some((parent, PathElement::Key(k))) = split_path_last_element(&setter.path) { + data.select_values_and_paths_mut(schema, &parent, |_path, obj| { + if let Some(value) = obj.get_mut(k) { + *value = setter.set_value_to.clone() + } + }); + } + } + DataRewrite::KeyRenamer(renamer) => { + // As the name implies, this only applies to renaming "keys", so we're + // guaranteed the last element is one and can ignore other cases. + if let Some((parent, PathElement::Key(k))) = split_path_last_element(&renamer.path) + { + data.select_values_and_paths_mut(schema, &parent, |_path, selected| { + if let Some(obj) = selected.as_object_mut() { + if let Some(value) = obj.remove(k.as_str()) { + obj.insert(renamer.rename_key_to.clone(), value); + } + } + }); + } + } + } + } +} + +/// Modifies he provided `value` by applying any of the rewrites provided that match. +pub(crate) fn apply_rewrites( + schema: &Schema, + value: &mut Value, + maybe_rewrites: &Option>, +) { + if let Some(rewrites) = maybe_rewrites { + for rewrite in rewrites { + rewrite.maybe_apply(schema, value); + } + } +} diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_root.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_root.snap index aca8880cee..45ad51f28d 100644 --- a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_root.snap +++ b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__bridge_query_planner__tests__plan_root.snap @@ -11,5 +11,7 @@ Fetch( operation_name: None, operation_kind: Query, id: None, + input_rewrites: None, + output_rewrites: None, }, ) diff --git a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__tests__query_plan_from_json.snap b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__tests__query_plan_from_json.snap index 03aea311ff..5a1793200e 100644 --- a/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__tests__query_plan_from_json.snap +++ b/apollo-router/src/query_planner/snapshots/apollo_router__query_planner__tests__query_plan_from_json.snap @@ -1,5 +1,5 @@ --- -source: apollo-router/src/query_planner/mod.rs +source: apollo-router/src/query_planner/tests.rs expression: query_plan --- Sequence { @@ -15,6 +15,8 @@ Sequence { ), operation_kind: Query, id: None, + input_rewrites: None, + output_rewrites: None, }, ), Parallel { @@ -66,6 +68,8 @@ Sequence { operation_name: None, operation_kind: Query, id: None, + input_rewrites: None, + output_rewrites: None, }, ), }, @@ -127,6 +131,8 @@ Sequence { operation_name: None, operation_kind: Query, id: None, + input_rewrites: None, + output_rewrites: None, }, ), }, @@ -177,6 +183,8 @@ Sequence { operation_name: None, operation_kind: Query, id: None, + input_rewrites: None, + output_rewrites: None, }, ), }, @@ -237,6 +245,8 @@ Sequence { operation_name: None, operation_kind: Query, id: None, + input_rewrites: None, + output_rewrites: None, }, ), }, diff --git a/apollo-router/src/query_planner/tests.rs b/apollo-router/src/query_planner/tests.rs index 8c690243eb..201194efce 100644 --- a/apollo-router/src/query_planner/tests.rs +++ b/apollo-router/src/query_planner/tests.rs @@ -249,6 +249,8 @@ async fn defer() { operation_name: Some("t".to_string()), operation_kind: OperationKind::Query, id: Some("fetch1".to_string()), + input_rewrites: None, + output_rewrites: None, }))), }, deferred: vec![DeferredNode { @@ -289,6 +291,8 @@ async fn defer() { operation_name: None, operation_kind: OperationKind::Query, id: Some("fetch2".to_string()), + input_rewrites: None, + output_rewrites: None, })), }))), }], diff --git a/apollo-router/src/services/supergraph_service.rs b/apollo-router/src/services/supergraph_service.rs index 0e2951d92a..d11c95bb38 100644 --- a/apollo-router/src/services/supergraph_service.rs +++ b/apollo-router/src/services/supergraph_service.rs @@ -1542,4 +1542,543 @@ mod tests { context.insert(ACCEPTS_MULTIPART_CONTEXT_KEY, true).unwrap(); context } + + #[tokio::test] + async fn interface_object_typename_rewrites() { + let schema = r#" + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + { + query: Query + } + + 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 + + type A implements I + @join__implements(graph: S1, interface: "I") + @join__type(graph: S1, key: "id") + { + id: ID! + x: Int + z: Int + y: Int @join__field + } + + type B implements I + @join__implements(graph: S1, interface: "I") + @join__type(graph: S1, key: "id") + { + id: ID! + x: Int + w: Int + y: Int @join__field + } + + interface I + @join__type(graph: S1, key: "id") + @join__type(graph: S2, key: "id", isInterfaceObject: true) + { + id: ID! + x: Int @join__field(graph: S1) + y: Int @join__field(graph: S2) + } + + scalar join__FieldSet + + enum join__Graph { + S1 @join__graph(name: "S1", url: "s1") + S2 @join__graph(name: "S2", url: "s2") + } + + scalar link__Import + + enum link__Purpose { + SECURITY + EXECUTION + } + + type Query + @join__type(graph: S1) + @join__type(graph: S2) + { + iFromS1: I @join__field(graph: S1) + iFromS2: I @join__field(graph: S2) + } + "#; + + let query = r#" + { + iFromS1 { + ... on A { + y + } + } + } + "#; + + let subgraphs = MockedSubgraphs([ + ("S1", MockSubgraph::builder() + .with_json( + serde_json::json! {{ + "query": "{iFromS1{__typename ...on A{__typename id}}}", + }}, + serde_json::json! {{ + "data": {"iFromS1":{"__typename":"A","id":"idA"}} + }}, + ) + .build()), + ("S2", MockSubgraph::builder() + // Note that this query below will only match if the input rewrite in the query plan is handled + // correctly. Otherwise, the `representations` in the variables will have `__typename = A` + // instead of `__typename = I`. + .with_json( + serde_json::json! {{ + "query": "query($representations:[_Any!]!){_entities(representations:$representations){...on I{y}}}", + "variables":{"representations":[{"__typename":"I","id":"idA"}]} + }}, + serde_json::json! {{ + "data": {"_entities":[{"y":42}]} + }}, + ) + .build()), + ].into_iter().collect()); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(schema) + .extra_plugin(subgraphs) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .build() + .unwrap(); + + let mut stream = service.oneshot(request).await.unwrap(); + let response = stream.next_response().await.unwrap(); + + assert_eq!( + serde_json::to_value(&response.data).unwrap(), + serde_json::json!({ "iFromS1": { "y": 42 } }), + ); + } + + #[tokio::test] + async fn only_query_interface_object_subgraph() { + // This test has 2 subgraphs, one with an interface and another with that interface + // declared as an @interfaceObject. It then sends a query that can be entirely + // fulfilled by the @interfaceObject subgraph (in particular, it doesn't request + // __typename; if it did, it would force a query on the other subgraph to obtain + // the actual implementation type). + // The specificity here is that the final in-memory result will not have a __typename + // _despite_ being the parent type of that result being an interface. Which is fine + // since __typename is not requested, and so there is no need to known the actual + // __typename, but this is something that never happen outside of @interfaceObject + // (usually, results whose parent type is an abstract type (say an interface) are always + // queried internally with their __typename). And so this test make sure that the + // post-processing done by the router on the result handle this correctly. + + let schema = r#" + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + { + query: Query + } + + 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 + + type A implements I + @join__implements(graph: S1, interface: "I") + @join__type(graph: S1, key: "id") + { + id: ID! + x: Int + z: Int + y: Int @join__field + } + + type B implements I + @join__implements(graph: S1, interface: "I") + @join__type(graph: S1, key: "id") + { + id: ID! + x: Int + w: Int + y: Int @join__field + } + + interface I + @join__type(graph: S1, key: "id") + @join__type(graph: S2, key: "id", isInterfaceObject: true) + { + id: ID! + x: Int @join__field(graph: S1) + y: Int @join__field(graph: S2) + } + + scalar join__FieldSet + + enum join__Graph { + S1 @join__graph(name: "S1", url: "S1") + S2 @join__graph(name: "S2", url: "S2") + } + + scalar link__Import + + enum link__Purpose { + SECURITY + EXECUTION + } + + type Query + @join__type(graph: S1) + @join__type(graph: S2) + { + iFromS1: I @join__field(graph: S1) + iFromS2: I @join__field(graph: S2) + } + "#; + + let query = r#" + { + iFromS2 { + y + } + } + "#; + + let subgraphs = MockedSubgraphs( + [ + ( + "S1", + MockSubgraph::builder() + // This test makes no queries to S1, only to S2 + .build(), + ), + ( + "S2", + MockSubgraph::builder() + .with_json( + serde_json::json! {{ + "query": "{iFromS2{y}}", + }}, + serde_json::json! {{ + "data": {"iFromS2":{"y":20}} + }}, + ) + .build(), + ), + ] + .into_iter() + .collect(), + ); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(schema) + .extra_plugin(subgraphs) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .build() + .unwrap(); + + let mut stream = service.oneshot(request).await.unwrap(); + let response = stream.next_response().await.unwrap(); + + assert_eq!( + serde_json::to_value(&response.data).unwrap(), + serde_json::json!({ "iFromS2": { "y": 20 } }), + ); + } + + #[tokio::test] + async fn aliased_subgraph_data_rewrites_on_root_fetch() { + let schema = r#" + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + { + query: Query + } + + 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 + + type A implements U + @join__implements(graph: S1, interface: "U") + @join__type(graph: S1, key: "g") + @join__type(graph: S2, key: "g") + { + f: String @join__field(graph: S1, external: true) @join__field(graph: S2) + g: String + } + + type B implements U + @join__implements(graph: S1, interface: "U") + @join__type(graph: S1, key: "g") + @join__type(graph: S2, key: "g") + { + f: String @join__field(graph: S1, external: true) @join__field(graph: S2) + g: Int + } + + scalar join__FieldSet + + enum join__Graph { + S1 @join__graph(name: "S1", url: "s1") + S2 @join__graph(name: "S2", url: "s2") + } + + scalar link__Import + + enum link__Purpose { + SECURITY + EXECUTION + } + + type Query + @join__type(graph: S1) + @join__type(graph: S2) + { + us: [U] @join__field(graph: S1) + } + + interface U + @join__type(graph: S1) + { + f: String + } + "#; + + let query = r#" + { + us { + f + } + } + "#; + + let subgraphs = MockedSubgraphs([ + ("S1", MockSubgraph::builder() + .with_json( + serde_json::json! {{ + "query": "{us{__typename ...on A{__typename g}...on B{__typename g__alias_0:g}}}", + }}, + serde_json::json! {{ + "data": {"us":[{"__typename":"A","g":"foo"},{"__typename":"B","g__alias_0":1}]}, + }}, + ) + .build()), + ("S2", MockSubgraph::builder() + .with_json( + // Note that the query below will only match if the output rewrite in the query plan is handled + // correctly. Otherwise, the `representations` in the variables will not be able to find the + // field `g` for the `B` object, since it was returned as `g__alias_0` on the initial subgraph + // query above. + serde_json::json! {{ + "query": "query($representations:[_Any!]!){_entities(representations:$representations){...on A{f}...on B{f}}}", + "variables":{"representations":[{"__typename":"A","g":"foo"},{"__typename":"B","g":1}]} + }}, + serde_json::json! {{ + "data": {"_entities":[{"f":"fA"},{"f":"fB"}]} + }}, + ) + .build()), + ].into_iter().collect()); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(schema) + .extra_plugin(subgraphs) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .build() + .unwrap(); + + let mut stream = service.oneshot(request).await.unwrap(); + let response = stream.next_response().await.unwrap(); + + assert_eq!( + serde_json::to_value(&response.data).unwrap(), + serde_json::json!({"us": [{"f": "fA"}, {"f": "fB"}]}), + ); + } + + #[tokio::test] + async fn aliased_subgraph_data_rewrites_on_non_root_fetch() { + let schema = r#" + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + { + query: Query + } + + 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 + type A implements U + @join__implements(graph: S1, interface: "U") + @join__type(graph: S1, key: "g") + @join__type(graph: S2, key: "g") + { + f: String @join__field(graph: S1, external: true) @join__field(graph: S2) + g: String + } + + type B implements U + @join__implements(graph: S1, interface: "U") + @join__type(graph: S1, key: "g") + @join__type(graph: S2, key: "g") + { + f: String @join__field(graph: S1, external: true) @join__field(graph: S2) + g: Int + } + + scalar join__FieldSet + + enum join__Graph { + S1 @join__graph(name: "S1", url: "s1") + S2 @join__graph(name: "S2", url: "s2") + } + + scalar link__Import + + enum link__Purpose { + SECURITY + EXECUTION + } + + type Query + @join__type(graph: S1) + @join__type(graph: S2) + { + t: T @join__field(graph: S2) + } + + type T + @join__type(graph: S1, key: "id") + @join__type(graph: S2, key: "id") + { + id: ID! + us: [U] @join__field(graph: S1) + } + + interface U + @join__type(graph: S1) + { + f: String + } + "#; + + let query = r#" + { + t { + us { + f + } + } + } + "#; + + let subgraphs = MockedSubgraphs([ + ("S1", MockSubgraph::builder() + .with_json( + serde_json::json! {{ + "query": "query($representations:[_Any!]!){_entities(representations:$representations){...on T{us{__typename ...on A{__typename g}...on B{__typename g__alias_0:g}}}}}", + "variables":{"representations":[{"__typename":"T","id":"0"}]} + }}, + serde_json::json! {{ + "data": {"_entities":[{"us":[{"__typename":"A","g":"foo"},{"__typename":"B","g__alias_0":1}]}]}, + }}, + ) + .build()), + ("S2", MockSubgraph::builder() + .with_json( + serde_json::json! {{ + "query": "{t{__typename id}}", + }}, + serde_json::json! {{ + "data": {"t":{"__typename":"T","id":"0"}}, + }}, + ) + // Note that this query will only match if the output rewrite in the query plan is handled correctly. Otherwise, + // the `representations` in the variables will not be able to find the field `g` for the `B` object, since it was + // returned as `g__alias_0` on the (non-root) S1 query above. + .with_json( + serde_json::json! {{ + "query": "query($representations:[_Any!]!){_entities(representations:$representations){...on A{f}...on B{f}}}", + "variables":{"representations":[{"__typename":"A","g":"foo"},{"__typename":"B","g":1}]} + }}, + serde_json::json! {{ + "data": {"_entities":[{"f":"fA"},{"f":"fB"}]} + }}, + ) + .build()), + ].into_iter().collect()); + + let service = TestHarness::builder() + .configuration_json(serde_json::json!({"include_subgraph_errors": { "all": true } })) + .unwrap() + .schema(schema) + .extra_plugin(subgraphs) + .build_supergraph() + .await + .unwrap(); + + let request = supergraph::Request::fake_builder() + .query(query) + .build() + .unwrap(); + + let mut stream = service.oneshot(request).await.unwrap(); + let response = stream.next_response().await.unwrap(); + + assert_eq!( + serde_json::to_value(&response.data).unwrap(), + serde_json::json!({"t": {"us": [{"f": "fA"}, {"f": "fB"}]}}), + ); + } } diff --git a/apollo-router/tests/fixtures/expected_response_with_queryplan.json b/apollo-router/tests/fixtures/expected_response_with_queryplan.json index 8f6cb19084..66955d6309 100644 --- a/apollo-router/tests/fixtures/expected_response_with_queryplan.json +++ b/apollo-router/tests/fixtures/expected_response_with_queryplan.json @@ -1 +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": "QueryPlan {\n Sequence {\n Fetch(service: \"products\") {\n {\n topProducts(first: $first) {\n __typename\n upc\n name\n }\n }\n },\n Flatten(path: \"topProducts.@\") {\n Fetch(service: \"reviews\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n reviews {\n id\n product {\n __typename\n upc\n }\n author {\n __typename\n id\n }\n }\n }\n }\n },\n },\n Parallel {\n Flatten(path: \"topProducts.@.reviews.@.product\") {\n Fetch(service: \"products\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n name\n }\n }\n },\n },\n Flatten(path: \"topProducts.@.reviews.@.author\") {\n Fetch(service: \"accounts\") {\n {\n ... on User {\n __typename\n id\n }\n } =>\n {\n ... on User {\n name\n }\n }\n },\n },\n },\n },\n}"}}} \ No newline at end of file +{"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,"inputRewrites":null,"outputRewrites":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,"inputRewrites":null,"outputRewrites":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,"inputRewrites":null,"outputRewrites":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,"inputRewrites":null,"outputRewrites":null}}]}]}}, "text": "QueryPlan {\n Sequence {\n Fetch(service: \"products\") {\n {\n topProducts(first: $first) {\n __typename\n upc\n name\n }\n }\n },\n Flatten(path: \"topProducts.@\") {\n Fetch(service: \"reviews\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n reviews {\n id\n product {\n __typename\n upc\n }\n author {\n __typename\n id\n }\n }\n }\n }\n },\n },\n Parallel {\n Flatten(path: \"topProducts.@.reviews.@.product\") {\n Fetch(service: \"products\") {\n {\n ... on Product {\n __typename\n upc\n }\n } =>\n {\n ... on Product {\n name\n }\n }\n },\n },\n Flatten(path: \"topProducts.@.reviews.@.author\") {\n Fetch(service: \"accounts\") {\n {\n ... on User {\n __typename\n id\n }\n } =>\n {\n ... on User {\n name\n }\n }\n },\n },\n },\n },\n}"}}}