From dd234f8fe7a9c76bf69b46332b04e4e8eed70c66 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 3 May 2024 12:23:15 -0400 Subject: [PATCH 01/12] Refactor selection_parser into a directory called json_selection. This will allow me to reorganize the Selection-related code into separate modules, with a README and (eventually) a path to publishing this code as a standalone crate. --- .../sources/connect/federated_query_graph/builder.rs | 6 +++--- .../src/sources/connect/federated_query_graph/mod.rs | 4 ++-- .../src/sources/connect/fetch_dependency_graph/mod.rs | 6 +++--- .../src/sources/connect/json_selection/mod.rs | 2 ++ .../{selection_parser.rs => json_selection/parser.rs} | 0 apollo-federation/src/sources/connect/mod.rs | 10 +++++----- .../src/sources/connect/spec/directives.rs | 2 +- apollo-federation/src/sources/connect/spec/schema.rs | 2 +- 8 files changed, 17 insertions(+), 15 deletions(-) create mode 100644 apollo-federation/src/sources/connect/json_selection/mod.rs rename apollo-federation/src/sources/connect/{selection_parser.rs => json_selection/parser.rs} (100%) diff --git a/apollo-federation/src/sources/connect/federated_query_graph/builder.rs b/apollo-federation/src/sources/connect/federated_query_graph/builder.rs index 70de69c2cb..95dc4da790 100644 --- a/apollo-federation/src/sources/connect/federated_query_graph/builder.rs +++ b/apollo-federation/src/sources/connect/federated_query_graph/builder.rs @@ -15,8 +15,8 @@ use crate::sources::connect::federated_query_graph::EnumNode; use crate::sources::connect::federated_query_graph::ScalarNode; use crate::sources::connect::federated_query_graph::SourceEnteringEdge; use crate::sources::connect::models::Connector; -use crate::sources::connect::selection_parser::Property; -use crate::sources::connect::selection_parser::SubSelection; +use crate::sources::connect::json_selection::Property; +use crate::sources::connect::json_selection::SubSelection; use crate::sources::connect::Selection; use crate::sources::source::federated_query_graph::builder::FederatedQueryGraphBuilderApi; use crate::sources::source::SourceId; @@ -669,7 +669,7 @@ mod tests { use crate::source_aware::federated_query_graph::SelfConditionIndex; use crate::sources::connect::federated_query_graph::ConcreteFieldEdge; use crate::sources::connect::federated_query_graph::SourceEnteringEdge; - use crate::sources::connect::selection_parser::Property; + use crate::sources::connect::json_selection::Property; use crate::sources::connect::ConnectId; use crate::sources::source; use crate::sources::source::SourceId; diff --git a/apollo-federation/src/sources/connect/federated_query_graph/mod.rs b/apollo-federation/src/sources/connect/federated_query_graph/mod.rs index 50cd203017..604386c84c 100644 --- a/apollo-federation/src/sources/connect/federated_query_graph/mod.rs +++ b/apollo-federation/src/sources/connect/federated_query_graph/mod.rs @@ -6,8 +6,8 @@ use crate::schema::position::EnumTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::ScalarTypeDefinitionPosition; use crate::schema::ObjectFieldDefinitionPosition; -use crate::sources::connect::selection_parser::PathSelection; -use crate::sources::connect::selection_parser::Property; +use crate::sources::connect::json_selection::PathSelection; +use crate::sources::connect::json_selection::Property; use crate::sources::connect::Selection; use crate::sources::connect::SubSelection; use crate::sources::source; diff --git a/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs b/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs index e3b8ec8f96..c57bb14b58 100644 --- a/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs +++ b/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs @@ -16,8 +16,8 @@ use crate::source_aware::federated_query_graph::FederatedQueryGraph; use crate::source_aware::federated_query_graph::SelfConditionIndex; use crate::source_aware::query_plan::FetchDataPathElement; use crate::source_aware::query_plan::QueryPlanCost; -use crate::sources::connect::selection_parser::PathSelection; -use crate::sources::connect::selection_parser::Property; +use crate::sources::connect::json_selection::PathSelection; +use crate::sources::connect::json_selection::Property; use crate::sources::connect::Selection; use crate::sources::connect::SubSelection; use crate::sources::source; @@ -205,7 +205,7 @@ mod tests { use crate::sources::connect::federated_query_graph::ConcreteFieldEdge; use crate::sources::connect::federated_query_graph::ConcreteNode; use crate::sources::connect::federated_query_graph::SourceEnteringEdge; - use crate::sources::connect::selection_parser::Property; + use crate::sources::connect::json_selection::Property; use crate::sources::connect::ConnectId; use crate::sources::source::fetch_dependency_graph::FetchDependencyGraphApi; use crate::sources::source::SourceId; diff --git a/apollo-federation/src/sources/connect/json_selection/mod.rs b/apollo-federation/src/sources/connect/json_selection/mod.rs new file mode 100644 index 0000000000..763a232d04 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/mod.rs @@ -0,0 +1,2 @@ +mod parser; +pub use parser::*; diff --git a/apollo-federation/src/sources/connect/selection_parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs similarity index 100% rename from apollo-federation/src/sources/connect/selection_parser.rs rename to apollo-federation/src/sources/connect/json_selection/parser.rs diff --git a/apollo-federation/src/sources/connect/mod.rs b/apollo-federation/src/sources/connect/mod.rs index 397f0a060f..6f0ba9f45e 100644 --- a/apollo-federation/src/sources/connect/mod.rs +++ b/apollo-federation/src/sources/connect/mod.rs @@ -6,16 +6,16 @@ use crate::schema::position::ObjectOrInterfaceFieldDirectivePosition; pub(crate) mod federated_query_graph; pub(crate) mod fetch_dependency_graph; +mod json_selection; mod models; pub mod query_plan; -mod selection_parser; pub(crate) mod spec; mod url_path_template; -pub use selection_parser::ApplyTo; -pub use selection_parser::ApplyToError; -pub use selection_parser::Selection; -pub use selection_parser::SubSelection; +pub use json_selection::ApplyTo; +pub use json_selection::ApplyToError; +pub use json_selection::Selection; +pub use json_selection::SubSelection; pub(crate) use spec::ConnectSpecDefinition; pub use url_path_template::URLPathTemplate; diff --git a/apollo-federation/src/sources/connect/spec/directives.rs b/apollo-federation/src/sources/connect/spec/directives.rs index 688ae11ce8..46e895cf1a 100644 --- a/apollo-federation/src/sources/connect/spec/directives.rs +++ b/apollo-federation/src/sources/connect/spec/directives.rs @@ -28,7 +28,7 @@ use crate::error::FederationError; use crate::schema::position::ObjectOrInterfaceFieldDefinitionPosition; use crate::schema::position::ObjectOrInterfaceFieldDirectivePosition; use crate::schema::FederationSchema; -use crate::sources::connect::selection_parser::Selection; +use crate::sources::connect::json_selection::Selection; use crate::sources::connect::spec::schema::CONNECT_HTTP_ARGUMENT_NAME; use crate::sources::connect::spec::schema::CONNECT_SOURCE_ARGUMENT_NAME; diff --git a/apollo-federation/src/sources/connect/spec/schema.rs b/apollo-federation/src/sources/connect/spec/schema.rs index 19be9ebbdd..28c1eaeaa6 100644 --- a/apollo-federation/src/sources/connect/spec/schema.rs +++ b/apollo-federation/src/sources/connect/spec/schema.rs @@ -4,7 +4,7 @@ use apollo_compiler::NodeStr; use indexmap::IndexMap; use crate::schema::position::ObjectOrInterfaceFieldDirectivePosition; -use crate::sources::connect::selection_parser::Selection; +use crate::sources::connect::json_selection::Selection; pub(crate) const CONNECT_DIRECTIVE_NAME_IN_SPEC: Name = name!("connect"); pub(crate) const CONNECT_SOURCE_ARGUMENT_NAME: Name = name!("source"); From 8d72950b85ed24666c2555adc45f688a13b48f2b Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 3 May 2024 12:29:03 -0400 Subject: [PATCH 02/12] Extract json_selection/helpers.rs from parser.rs. --- .../sources/connect/json_selection/helpers.rs | 151 ++++++++++++++++++ .../src/sources/connect/json_selection/mod.rs | 2 + .../sources/connect/json_selection/parser.rs | 141 +--------------- 3 files changed, 155 insertions(+), 139 deletions(-) create mode 100644 apollo-federation/src/sources/connect/json_selection/helpers.rs diff --git a/apollo-federation/src/sources/connect/json_selection/helpers.rs b/apollo-federation/src/sources/connect/json_selection/helpers.rs new file mode 100644 index 0000000000..c9f78aeb65 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/helpers.rs @@ -0,0 +1,151 @@ +use nom::character::complete::multispace0; +use nom::IResult; +use serde_json_bytes::Value as JSON; + +// This macro is handy for tests, but it absolutely should never be used with +// dynamic input at runtime, since it panics if the selection string fails to +// parse for any reason. +#[cfg(test)] +#[macro_export] +macro_rules! selection { + ($input:expr) => { + if let Ok((remainder, parsed)) = + $crate::sources::connect::json_selection::Selection::parse($input) + { + assert_eq!(remainder, ""); + parsed + } else { + panic!("invalid selection: {:?}", $input); + } + }; +} + +// Consumes any amount of whitespace and/or comments starting with # until the +// end of the line. +pub fn spaces_or_comments(input: &str) -> IResult<&str, &str> { + let mut suffix = input; + loop { + (suffix, _) = multispace0(suffix)?; + let mut chars = suffix.chars(); + if let Some('#') = chars.next() { + for c in chars.by_ref() { + if c == '\n' { + break; + } + } + suffix = chars.as_str(); + } else { + return Ok((suffix, &input[0..input.len() - suffix.len()])); + } + } +} + +pub fn json_type_name(v: &JSON) -> &str { + match v { + JSON::Array(_) => "array", + JSON::Object(_) => "object", + JSON::String(_) => "string", + JSON::Number(_) => "number", + JSON::Bool(_) => "boolean", + JSON::Null => "null", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_spaces_or_comments() { + assert_eq!(spaces_or_comments(""), Ok(("", ""))); + assert_eq!(spaces_or_comments(" "), Ok(("", " "))); + assert_eq!(spaces_or_comments(" "), Ok(("", " "))); + + assert_eq!(spaces_or_comments("#"), Ok(("", "#"))); + assert_eq!(spaces_or_comments("# "), Ok(("", "# "))); + assert_eq!(spaces_or_comments(" # "), Ok(("", " # "))); + assert_eq!(spaces_or_comments(" #"), Ok(("", " #"))); + + assert_eq!(spaces_or_comments("#\n"), Ok(("", "#\n"))); + assert_eq!(spaces_or_comments("# \n"), Ok(("", "# \n"))); + assert_eq!(spaces_or_comments(" # \n"), Ok(("", " # \n"))); + assert_eq!(spaces_or_comments(" #\n"), Ok(("", " #\n"))); + assert_eq!(spaces_or_comments(" # \n "), Ok(("", " # \n "))); + + assert_eq!(spaces_or_comments("hello"), Ok(("hello", ""))); + assert_eq!(spaces_or_comments(" hello"), Ok(("hello", " "))); + assert_eq!(spaces_or_comments("hello "), Ok(("hello ", ""))); + assert_eq!(spaces_or_comments("hello#"), Ok(("hello#", ""))); + assert_eq!(spaces_or_comments("hello #"), Ok(("hello #", ""))); + assert_eq!(spaces_or_comments("hello # "), Ok(("hello # ", ""))); + assert_eq!(spaces_or_comments(" hello # "), Ok(("hello # ", " "))); + assert_eq!( + spaces_or_comments(" hello # world "), + Ok(("hello # world ", " ")) + ); + + assert_eq!(spaces_or_comments("#comment"), Ok(("", "#comment"))); + assert_eq!(spaces_or_comments(" #comment"), Ok(("", " #comment"))); + assert_eq!(spaces_or_comments("#comment "), Ok(("", "#comment "))); + assert_eq!(spaces_or_comments("#comment#"), Ok(("", "#comment#"))); + assert_eq!(spaces_or_comments("#comment #"), Ok(("", "#comment #"))); + assert_eq!(spaces_or_comments("#comment # "), Ok(("", "#comment # "))); + assert_eq!( + spaces_or_comments(" #comment # world "), + Ok(("", " #comment # world ")) + ); + assert_eq!( + spaces_or_comments(" # comment # world "), + Ok(("", " # comment # world ")) + ); + + assert_eq!( + spaces_or_comments(" # comment\nnot a comment"), + Ok(("not a comment", " # comment\n")) + ); + assert_eq!( + spaces_or_comments(" # comment\nnot a comment\n"), + Ok(("not a comment\n", " # comment\n")) + ); + assert_eq!( + spaces_or_comments("not a comment\n # comment\nasdf"), + Ok(("not a comment\n # comment\nasdf", "")) + ); + + #[rustfmt::skip] + assert_eq!(spaces_or_comments(" + # This is a comment + # And so is this + not a comment + "), + Ok(("not a comment + ", " + # This is a comment + # And so is this + "))); + + #[rustfmt::skip] + assert_eq!(spaces_or_comments(" + # This is a comment + not a comment + # Another comment + "), + Ok(("not a comment + # Another comment + ", " + # This is a comment + "))); + + #[rustfmt::skip] + assert_eq!(spaces_or_comments(" + not a comment + # This is a comment + # Another comment + "), + Ok(("not a comment + # This is a comment + # Another comment + ", " + "))); + } +} diff --git a/apollo-federation/src/sources/connect/json_selection/mod.rs b/apollo-federation/src/sources/connect/json_selection/mod.rs index 763a232d04..79d70795f5 100644 --- a/apollo-federation/src/sources/connect/json_selection/mod.rs +++ b/apollo-federation/src/sources/connect/json_selection/mod.rs @@ -1,2 +1,4 @@ +mod helpers; mod parser; + pub use parser::*; diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs index 3a8e02f88d..207e511bf1 100644 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -6,7 +6,6 @@ use indexmap::IndexSet; use itertools::Itertools; use nom::branch::alt; use nom::character::complete::char; -use nom::character::complete::multispace0; use nom::character::complete::one_of; use nom::combinator::all_consuming; use nom::combinator::map; @@ -23,25 +22,7 @@ use serde_json_bytes::json; use serde_json_bytes::Map; use serde_json_bytes::Value as JSON; -// Consumes any amount of whitespace and/or comments starting with # until the -// end of the line. -fn spaces_or_comments(input: &str) -> IResult<&str, &str> { - let mut suffix = input; - loop { - (suffix, _) = multispace0(suffix)?; - let mut chars = suffix.chars(); - if let Some('#') = chars.next() { - for c in chars.by_ref() { - if c == '\n' { - break; - } - } - suffix = chars.as_str(); - } else { - return Ok((suffix, &input[0..input.len() - suffix.len()])); - } - } -} +use super::helpers::{json_type_name, spaces_or_comments}; // Selection ::= NamedSelection* StarSelection? | PathSelection @@ -787,17 +768,6 @@ impl ApplyTo for SubSelection { } } -fn json_type_name(v: &JSON) -> &str { - match v { - JSON::Array(_) => "array", - JSON::Object(_) => "object", - JSON::String(_) => "string", - JSON::Number(_) => "number", - JSON::Bool(_) => "boolean", - JSON::Null => "null", - } -} - // GraphQL Selection Set ------------------------------------------------------- use apollo_compiler::ast; @@ -918,114 +888,7 @@ impl From for GraphQLSelections { #[cfg(test)] mod tests { use super::*; - - // This macro is handy for tests, but it absolutely should never be used with - // dynamic input at runtime, since it panics if the selection string fails to - // parse for any reason. - macro_rules! selection { - ($input:expr) => { - if let Ok((remainder, parsed)) = Selection::parse($input) { - assert_eq!(remainder, ""); - parsed - } else { - panic!("invalid selection: {:?}", $input); - } - }; - } - - #[test] - fn test_spaces_or_comments() { - assert_eq!(spaces_or_comments(""), Ok(("", ""))); - assert_eq!(spaces_or_comments(" "), Ok(("", " "))); - assert_eq!(spaces_or_comments(" "), Ok(("", " "))); - - assert_eq!(spaces_or_comments("#"), Ok(("", "#"))); - assert_eq!(spaces_or_comments("# "), Ok(("", "# "))); - assert_eq!(spaces_or_comments(" # "), Ok(("", " # "))); - assert_eq!(spaces_or_comments(" #"), Ok(("", " #"))); - - assert_eq!(spaces_or_comments("#\n"), Ok(("", "#\n"))); - assert_eq!(spaces_or_comments("# \n"), Ok(("", "# \n"))); - assert_eq!(spaces_or_comments(" # \n"), Ok(("", " # \n"))); - assert_eq!(spaces_or_comments(" #\n"), Ok(("", " #\n"))); - assert_eq!(spaces_or_comments(" # \n "), Ok(("", " # \n "))); - - assert_eq!(spaces_or_comments("hello"), Ok(("hello", ""))); - assert_eq!(spaces_or_comments(" hello"), Ok(("hello", " "))); - assert_eq!(spaces_or_comments("hello "), Ok(("hello ", ""))); - assert_eq!(spaces_or_comments("hello#"), Ok(("hello#", ""))); - assert_eq!(spaces_or_comments("hello #"), Ok(("hello #", ""))); - assert_eq!(spaces_or_comments("hello # "), Ok(("hello # ", ""))); - assert_eq!(spaces_or_comments(" hello # "), Ok(("hello # ", " "))); - assert_eq!( - spaces_or_comments(" hello # world "), - Ok(("hello # world ", " ")) - ); - - assert_eq!(spaces_or_comments("#comment"), Ok(("", "#comment"))); - assert_eq!(spaces_or_comments(" #comment"), Ok(("", " #comment"))); - assert_eq!(spaces_or_comments("#comment "), Ok(("", "#comment "))); - assert_eq!(spaces_or_comments("#comment#"), Ok(("", "#comment#"))); - assert_eq!(spaces_or_comments("#comment #"), Ok(("", "#comment #"))); - assert_eq!(spaces_or_comments("#comment # "), Ok(("", "#comment # "))); - assert_eq!( - spaces_or_comments(" #comment # world "), - Ok(("", " #comment # world ")) - ); - assert_eq!( - spaces_or_comments(" # comment # world "), - Ok(("", " # comment # world ")) - ); - - assert_eq!( - spaces_or_comments(" # comment\nnot a comment"), - Ok(("not a comment", " # comment\n")) - ); - assert_eq!( - spaces_or_comments(" # comment\nnot a comment\n"), - Ok(("not a comment\n", " # comment\n")) - ); - assert_eq!( - spaces_or_comments("not a comment\n # comment\nasdf"), - Ok(("not a comment\n # comment\nasdf", "")) - ); - - #[rustfmt::skip] - assert_eq!(spaces_or_comments(" - # This is a comment - # And so is this - not a comment - "), - Ok(("not a comment - ", " - # This is a comment - # And so is this - "))); - - #[rustfmt::skip] - assert_eq!(spaces_or_comments(" - # This is a comment - not a comment - # Another comment - "), - Ok(("not a comment - # Another comment - ", " - # This is a comment - "))); - - #[rustfmt::skip] - assert_eq!(spaces_or_comments(" - not a comment - # This is a comment - # Another comment - "), - Ok(("not a comment - # This is a comment - # Another comment - ", " - "))); - } + use crate::selection; #[test] fn test_identifier() { From cacdcaf858336ee422da3854cb963f6738f18ef2 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 3 May 2024 12:31:35 -0400 Subject: [PATCH 03/12] Extract json_selection/apply_to.rs from parser.rs. --- .../connect/json_selection/apply_to.rs | 1128 +++++++++++++++++ .../src/sources/connect/json_selection/mod.rs | 2 + .../sources/connect/json_selection/parser.rs | 1121 +--------------- 3 files changed, 1131 insertions(+), 1120 deletions(-) create mode 100644 apollo-federation/src/sources/connect/json_selection/apply_to.rs diff --git a/apollo-federation/src/sources/connect/json_selection/apply_to.rs b/apollo-federation/src/sources/connect/json_selection/apply_to.rs new file mode 100644 index 0000000000..1b5137b7b9 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/apply_to.rs @@ -0,0 +1,1128 @@ +/// ApplyTo is a trait for applying a Selection to a JSON value, collecting +/// any/all errors encountered in the process. +use std::hash::Hash; +use std::hash::Hasher; + +use indexmap::IndexSet; +use itertools::Itertools; +use serde_json_bytes::json; +use serde_json_bytes::Map; +use serde_json_bytes::Value as JSON; + +use super::helpers::json_type_name; +use super::parser::*; + +pub trait ApplyTo { + // Applying a selection to a JSON value produces a new JSON value, along + // with any/all errors encountered in the process. The value is represented + // as an Option to allow for undefined/missing values (which JSON does not + // explicitly support), which are distinct from null values (which it does + // support). + fn apply_to(&self, data: &JSON) -> (Option, Vec) { + let mut input_path = vec![]; + // Using IndexSet over HashSet to preserve the order of the errors. + let mut errors = IndexSet::new(); + let value = self.apply_to_path(data, &mut input_path, &mut errors); + (value, errors.into_iter().collect()) + } + + // This is the trait method that should be implemented and called + // recursively by the various Selection types. + fn apply_to_path( + &self, + data: &JSON, + input_path: &mut Vec, + errors: &mut IndexSet, + ) -> Option; + + // When array is encountered, the Self selection will be applied to each + // element of the array, producing a new array. + fn apply_to_array( + &self, + data_array: &[JSON], + input_path: &mut Vec, + errors: &mut IndexSet, + ) -> Option { + let mut output = Vec::with_capacity(data_array.len()); + + for (i, element) in data_array.iter().enumerate() { + input_path.push(Property::Index(i)); + let value = self.apply_to_path(element, input_path, errors); + input_path.pop(); + // When building an Object, we can simply omit missing properties + // and report an error, but when building an Array, we need to + // insert null values to preserve the original array indices/length. + output.push(value.unwrap_or(JSON::Null)); + } + + Some(JSON::Array(output)) + } +} + +#[derive(Debug, Eq, PartialEq, Clone)] +pub struct ApplyToError(JSON); + +impl Hash for ApplyToError { + fn hash(&self, hasher: &mut H) { + // Although serde_json::Value (aka JSON) does not implement the Hash + // trait, we can convert self.0 to a JSON string and hash that. To do + // this properly, we should ensure all object keys are serialized in + // lexicographic order before hashing, but the only object keys we use + // are "message" and "path", and they always appear in that order. + self.0.to_string().hash(hasher) + } +} + +impl ApplyToError { + fn new(message: &str, path: &[Property]) -> Self { + Self(json!({ + "message": message, + "path": path.iter().map(|property| match property { + Property::Field(name) => json!(name), + Property::Quoted(name) => json!(name), + Property::Index(index) => json!(index), + }).collect::>(), + })) + } + + #[cfg(test)] + fn from_json(json: &JSON) -> Self { + if let JSON::Object(error) = json { + if let Some(JSON::String(message)) = error.get("message") { + if let Some(JSON::Array(path)) = error.get("path") { + if path + .iter() + .all(|element| matches!(element, JSON::String(_) | JSON::Number(_))) + { + // Instead of simply returning Self(json.clone()), we + // enforce that the "message" and "path" properties are + // always in that order, as promised in the comment in + // the hash method above. + return Self(json!({ + "message": message, + "path": path, + })); + } + } + } + } + panic!("invalid ApplyToError JSON: {:?}", json); + } + + pub fn message(&self) -> Option<&str> { + self.0 + .as_object() + .and_then(|v| v.get("message")) + .and_then(|s| s.as_str()) + } + + pub fn path(&self) -> Option { + self.0 + .as_object() + .and_then(|v| v.get("path")) + .and_then(|p| p.as_array()) + .map(|l| l.iter().filter_map(|v| v.as_str()).join(".")) + } +} + +impl ApplyTo for Selection { + fn apply_to_path( + &self, + data: &JSON, + input_path: &mut Vec, + errors: &mut IndexSet, + ) -> Option { + let data = match data { + JSON::Array(array) => return self.apply_to_array(array, input_path, errors), + JSON::Object(_) => data, + _ => { + errors.insert(ApplyToError::new("not an object", input_path)); + return None; + } + }; + + match self { + // Because we represent a Selection::Named as a SubSelection, we can + // fully delegate apply_to_path to SubSelection::apply_to_path. Even + // if we represented Self::Named as a Vec, we could + // still delegate to SubSelection::apply_to_path, but we would need + // to create a temporary SubSelection to wrap the selections Vec. + Self::Named(named_selections) => { + named_selections.apply_to_path(data, input_path, errors) + } + Self::Path(path_selection) => path_selection.apply_to_path(data, input_path, errors), + } + } +} + +impl ApplyTo for NamedSelection { + fn apply_to_path( + &self, + data: &JSON, + input_path: &mut Vec, + errors: &mut IndexSet, + ) -> Option { + let data = match data { + JSON::Array(array) => return self.apply_to_array(array, input_path, errors), + JSON::Object(_) => data, + _ => { + errors.insert(ApplyToError::new("not an object", input_path)); + return None; + } + }; + + let mut output = Map::new(); + + #[rustfmt::skip] // cargo fmt butchers this closure's formatting + let mut field_quoted_helper = | + alias: Option<&Alias>, + name: &String, + selection: &Option, + input_path: &mut Vec, + | { + if let Some(child) = data.get(name) { + let output_name = alias.map_or(name, |alias| &alias.name); + if let Some(selection) = selection { + let value = selection.apply_to_path(child, input_path, errors); + if let Some(value) = value { + output.insert(output_name.clone(), value); + } + } else { + output.insert(output_name.clone(), child.clone()); + } + } else { + errors.insert(ApplyToError::new( + format!("Response field {} not found", name).as_str(), + input_path, + )); + } + }; + + match self { + Self::Field(alias, name, selection) => { + input_path.push(Property::Field(name.clone())); + field_quoted_helper(alias.as_ref(), name, selection, input_path); + input_path.pop(); + } + Self::Quoted(alias, name, selection) => { + input_path.push(Property::Quoted(name.clone())); + field_quoted_helper(Some(alias), name, selection, input_path); + input_path.pop(); + } + Self::Path(alias, path_selection) => { + let value = path_selection.apply_to_path(data, input_path, errors); + if let Some(value) = value { + output.insert(alias.name.clone(), value); + } + } + Self::Group(alias, sub_selection) => { + let value = sub_selection.apply_to_path(data, input_path, errors); + if let Some(value) = value { + output.insert(alias.name.clone(), value); + } + } + }; + + Some(JSON::Object(output)) + } +} + +impl ApplyTo for PathSelection { + fn apply_to_path( + &self, + data: &JSON, + input_path: &mut Vec, + errors: &mut IndexSet, + ) -> Option { + if let JSON::Array(array) = data { + return self.apply_to_array(array, input_path, errors); + } + + match self { + Self::Path(head, tail) => { + match data { + JSON::Object(_) => {} + _ => { + errors.insert(ApplyToError::new( + format!( + "Expected an object in response, received {}", + json_type_name(data) + ) + .as_str(), + input_path, + )); + return None; + } + }; + + input_path.push(head.clone()); + if let Some(child) = match head { + Property::Field(name) => data.get(name), + Property::Quoted(name) => data.get(name), + Property::Index(index) => data.get(index), + } { + let result = tail.apply_to_path(child, input_path, errors); + input_path.pop(); + result + } else { + let message = match head { + Property::Field(name) => format!("Response field {} not found", name), + Property::Quoted(name) => format!("Response field {} not found", name), + Property::Index(index) => format!("Response field {} not found", index), + }; + errors.insert(ApplyToError::new(message.as_str(), input_path)); + input_path.pop(); + None + } + } + Self::Selection(selection) => { + // If data is not an object here, this recursive apply_to_path + // call will handle the error. + selection.apply_to_path(data, input_path, errors) + } + Self::Empty => { + // If data is not an object here, we want to preserve its value + // without an error. + Some(data.clone()) + } + } + } +} + +impl ApplyTo for SubSelection { + fn apply_to_path( + &self, + data: &JSON, + input_path: &mut Vec, + errors: &mut IndexSet, + ) -> Option { + let data_map = match data { + JSON::Array(array) => return self.apply_to_array(array, input_path, errors), + JSON::Object(data_map) => data_map, + _ => { + errors.insert(ApplyToError::new( + format!( + "Expected an object in response, received {}", + json_type_name(data) + ) + .as_str(), + input_path, + )); + return None; + } + }; + + let mut output = Map::new(); + let mut input_names = IndexSet::new(); + + for named_selection in &self.selections { + let value = named_selection.apply_to_path(data, input_path, errors); + + // If value is an object, extend output with its keys and their values. + if let Some(JSON::Object(key_and_value)) = value { + output.extend(key_and_value); + } + + // If there is a star selection, we need to keep track of the + // *original* names of the fields that were explicitly selected, + // because we will need to omit them from what the * matches. + if self.star.is_some() { + match named_selection { + NamedSelection::Field(_, name, _) => { + input_names.insert(name.as_str()); + } + NamedSelection::Quoted(_, name, _) => { + input_names.insert(name.as_str()); + } + NamedSelection::Path(_, path_selection) => { + if let PathSelection::Path(head, _) = path_selection { + match head { + Property::Field(name) | Property::Quoted(name) => { + input_names.insert(name.as_str()); + } + // While Property::Index may be used to + // represent the input_path during apply_to_path + // when arrays are encountered, it will never be + // used to represent the parsed structure of any + // actual selection string, becase arrays are + // processed automatically/implicitly and their + // indices are never explicitly selected. This + // means the numeric Property::Index case cannot + // affect the keys selected by * selections, so + // input_names does not need updating here. + Property::Index(_) => {} + }; + } + } + // The contents of groups do not affect the keys matched by + // * selections in the parent object (outside the group). + NamedSelection::Group(_, _) => {} + }; + } + } + + match &self.star { + // Aliased but not subselected, e.g. "a b c rest: *" + Some(StarSelection(Some(alias), None)) => { + let mut star_output = Map::new(); + for (key, value) in data_map { + if !input_names.contains(key.as_str()) { + star_output.insert(key.clone(), value.clone()); + } + } + output.insert(alias.name.clone(), JSON::Object(star_output)); + } + // Aliased and subselected, e.g. "alias: * { hello }" + Some(StarSelection(Some(alias), Some(selection))) => { + let mut star_output = Map::new(); + for (key, value) in data_map { + if !input_names.contains(key.as_str()) { + if let Some(selected) = selection.apply_to_path(value, input_path, errors) { + star_output.insert(key.clone(), selected); + } + } + } + output.insert(alias.name.clone(), JSON::Object(star_output)); + } + // Not aliased but subselected, e.g. "parent { * { hello } }" + Some(StarSelection(None, Some(selection))) => { + for (key, value) in data_map { + if !input_names.contains(key.as_str()) { + if let Some(selected) = selection.apply_to_path(value, input_path, errors) { + output.insert(key.clone(), selected); + } + } + } + } + // Neither aliased nor subselected, e.g. "parent { * }" or just "*" + Some(StarSelection(None, None)) => { + for (key, value) in data_map { + if !input_names.contains(key.as_str()) { + output.insert(key.clone(), value.clone()); + } + } + } + // No * selection present, e.g. "parent { just some properties }" + None => {} + }; + + Some(JSON::Object(output)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::selection; + + #[test] + fn test_apply_to_selection() { + let data = json!({ + "hello": "world", + "nested": { + "hello": "world", + "world": "hello", + }, + "array": [ + { "hello": "world 0" }, + { "hello": "world 1" }, + { "hello": "world 2" }, + ], + }); + + let check_ok = |selection: Selection, expected_json: JSON| { + let (actual_json, errors) = selection.apply_to(&data); + assert_eq!(actual_json, Some(expected_json)); + assert_eq!(errors, vec![]); + }; + + check_ok(selection!("hello"), json!({"hello": "world"})); + + check_ok( + selection!("nested"), + json!({ + "nested": { + "hello": "world", + "world": "hello", + }, + }), + ); + + check_ok(selection!(".nested.hello"), json!("world")); + + check_ok(selection!(".nested.world"), json!("hello")); + + check_ok( + selection!("nested hello"), + json!({ + "hello": "world", + "nested": { + "hello": "world", + "world": "hello", + }, + }), + ); + + check_ok( + selection!("array { hello }"), + json!({ + "array": [ + { "hello": "world 0" }, + { "hello": "world 1" }, + { "hello": "world 2" }, + ], + }), + ); + + check_ok( + selection!("greetings: array { hello }"), + json!({ + "greetings": [ + { "hello": "world 0" }, + { "hello": "world 1" }, + { "hello": "world 2" }, + ], + }), + ); + + check_ok( + selection!(".array { hello }"), + json!([ + { "hello": "world 0" }, + { "hello": "world 1" }, + { "hello": "world 2" }, + ]), + ); + + check_ok( + selection!("worlds: .array.hello"), + json!({ + "worlds": [ + "world 0", + "world 1", + "world 2", + ], + }), + ); + + check_ok( + selection!(".array.hello"), + json!(["world 0", "world 1", "world 2",]), + ); + + check_ok( + selection!("nested grouped: { hello worlds: .array.hello }"), + json!({ + "nested": { + "hello": "world", + "world": "hello", + }, + "grouped": { + "hello": "world", + "worlds": [ + "world 0", + "world 1", + "world 2", + ], + }, + }), + ); + } + + #[test] + fn test_apply_to_star_selections() { + let data = json!({ + "englishAndGreekLetters": { + "a": { "en": "ay", "gr": "alpha" }, + "b": { "en": "bee", "gr": "beta" }, + "c": { "en": "see", "gr": "gamma" }, + "d": { "en": "dee", "gr": "delta" }, + "e": { "en": "ee", "gr": "epsilon" }, + "f": { "en": "eff", "gr": "phi" }, + }, + "englishAndSpanishNumbers": [ + { "en": "one", "es": "uno" }, + { "en": "two", "es": "dos" }, + { "en": "three", "es": "tres" }, + { "en": "four", "es": "cuatro" }, + { "en": "five", "es": "cinco" }, + { "en": "six", "es": "seis" }, + ], + "asciiCharCodes": { + "A": 65, + "B": 66, + "C": 67, + "D": 68, + "E": 69, + "F": 70, + "G": 71, + }, + "books": { + "9780262533751": { + "title": "The Geometry of Meaning", + "author": "Peter Gärdenfors", + }, + "978-1492674313": { + "title": "P is for Pterodactyl: The Worst Alphabet Book Ever", + "author": "Raj Haldar", + }, + "9780262542456": { + "title": "A Biography of the Pixel", + "author": "Alvy Ray Smith", + }, + } + }); + + let check_ok = |selection: Selection, expected_json: JSON| { + let (actual_json, errors) = selection.apply_to(&data); + assert_eq!(actual_json, Some(expected_json)); + assert_eq!(errors, vec![]); + }; + + check_ok( + selection!("englishAndGreekLetters { * { en }}"), + json!({ + "englishAndGreekLetters": { + "a": { "en": "ay" }, + "b": { "en": "bee" }, + "c": { "en": "see" }, + "d": { "en": "dee" }, + "e": { "en": "ee" }, + "f": { "en": "eff" }, + }, + }), + ); + + check_ok( + selection!("englishAndGreekLetters { C: .c.en * { gr }}"), + json!({ + "englishAndGreekLetters": { + "a": { "gr": "alpha" }, + "b": { "gr": "beta" }, + "C": "see", + "d": { "gr": "delta" }, + "e": { "gr": "epsilon" }, + "f": { "gr": "phi" }, + }, + }), + ); + + check_ok( + selection!("englishAndGreekLetters { A: a B: b rest: * }"), + json!({ + "englishAndGreekLetters": { + "A": { "en": "ay", "gr": "alpha" }, + "B": { "en": "bee", "gr": "beta" }, + "rest": { + "c": { "en": "see", "gr": "gamma" }, + "d": { "en": "dee", "gr": "delta" }, + "e": { "en": "ee", "gr": "epsilon" }, + "f": { "en": "eff", "gr": "phi" }, + }, + }, + }), + ); + + check_ok( + selection!(".'englishAndSpanishNumbers' { en rest: * }"), + json!([ + { "en": "one", "rest": { "es": "uno" } }, + { "en": "two", "rest": { "es": "dos" } }, + { "en": "three", "rest": { "es": "tres" } }, + { "en": "four", "rest": { "es": "cuatro" } }, + { "en": "five", "rest": { "es": "cinco" } }, + { "en": "six", "rest": { "es": "seis" } }, + ]), + ); + + // To include/preserve all remaining properties from an object in the output + // object, we support a naked * selection (no alias or subselection). This + // is useful when the values of the properties are scalar, so a subselection + // isn't possible, and we want to preserve all properties of the original + // object. These unnamed properties may not be useful for GraphQL unless the + // whole object is considered as opaque JSON scalar data, but we still need + // to support preserving JSON when it has scalar properties. + check_ok( + selection!("asciiCharCodes { ay: A bee: B * }"), + json!({ + "asciiCharCodes": { + "ay": 65, + "bee": 66, + "C": 67, + "D": 68, + "E": 69, + "F": 70, + "G": 71, + }, + }), + ); + + check_ok( + selection!("asciiCharCodes { * } gee: .asciiCharCodes.G"), + json!({ + "asciiCharCodes": data.get("asciiCharCodes").unwrap(), + "gee": 71, + }), + ); + + check_ok( + selection!("books { * { title } }"), + json!({ + "books": { + "9780262533751": { + "title": "The Geometry of Meaning", + }, + "978-1492674313": { + "title": "P is for Pterodactyl: The Worst Alphabet Book Ever", + }, + "9780262542456": { + "title": "A Biography of the Pixel", + }, + }, + }), + ); + + check_ok( + selection!("books { authorsByISBN: * { author } }"), + json!({ + "books": { + "authorsByISBN": { + "9780262533751": { + "author": "Peter Gärdenfors", + }, + "978-1492674313": { + "author": "Raj Haldar", + }, + "9780262542456": { + "author": "Alvy Ray Smith", + }, + }, + }, + }), + ); + } + + #[test] + fn test_apply_to_errors() { + let data = json!({ + "hello": "world", + "nested": { + "hello": 123, + "world": true, + }, + "array": [ + { "hello": 1, "goodbye": "farewell" }, + { "hello": "two" }, + { "hello": 3.0, "smello": "yellow" }, + ], + }); + + assert_eq!( + selection!("hello").apply_to(&data), + (Some(json!({"hello": "world"})), vec![],) + ); + + assert_eq!( + selection!("yellow").apply_to(&data), + ( + Some(json!({})), + vec![ApplyToError::from_json(&json!({ + "message": "Response field yellow not found", + "path": ["yellow"], + })),], + ) + ); + + assert_eq!( + selection!(".nested.hello").apply_to(&data), + (Some(json!(123)), vec![],) + ); + + assert_eq!( + selection!(".nested.'yellow'").apply_to(&data), + ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Response field yellow not found", + "path": ["nested", "yellow"], + })),], + ) + ); + + assert_eq!( + selection!(".nested { hola yellow world }").apply_to(&data), + ( + Some(json!({ + "world": true, + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Response field hola not found", + "path": ["nested", "hola"], + })), + ApplyToError::from_json(&json!({ + "message": "Response field yellow not found", + "path": ["nested", "yellow"], + })), + ], + ) + ); + + assert_eq!( + selection!("partial: .array { hello goodbye }").apply_to(&data), + ( + Some(json!({ + "partial": [ + { "hello": 1, "goodbye": "farewell" }, + { "hello": "two" }, + { "hello": 3.0 }, + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Response field goodbye not found", + "path": ["array", 1, "goodbye"], + })), + ApplyToError::from_json(&json!({ + "message": "Response field goodbye not found", + "path": ["array", 2, "goodbye"], + })), + ], + ) + ); + + assert_eq!( + selection!("good: .array.hello bad: .array.smello").apply_to(&data), + ( + Some(json!({ + "good": [ + 1, + "two", + 3.0, + ], + "bad": [ + null, + null, + "yellow", + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Response field smello not found", + "path": ["array", 0, "smello"], + })), + ApplyToError::from_json(&json!({ + "message": "Response field smello not found", + "path": ["array", 1, "smello"], + })), + ], + ) + ); + + assert_eq!( + selection!("array { hello smello }").apply_to(&data), + ( + Some(json!({ + "array": [ + { "hello": 1 }, + { "hello": "two" }, + { "hello": 3.0, "smello": "yellow" }, + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Response field smello not found", + "path": ["array", 0, "smello"], + })), + ApplyToError::from_json(&json!({ + "message": "Response field smello not found", + "path": ["array", 1, "smello"], + })), + ], + ) + ); + + assert_eq!( + selection!(".nested { grouped: { hello smelly world } }").apply_to(&data), + ( + Some(json!({ + "grouped": { + "hello": 123, + "world": true, + }, + })), + vec![ApplyToError::from_json(&json!({ + "message": "Response field smelly not found", + "path": ["nested", "smelly"], + })),], + ) + ); + + assert_eq!( + selection!("alias: .nested { grouped: { hello smelly world } }").apply_to(&data), + ( + Some(json!({ + "alias": { + "grouped": { + "hello": 123, + "world": true, + }, + }, + })), + vec![ApplyToError::from_json(&json!({ + "message": "Response field smelly not found", + "path": ["nested", "smelly"], + })),], + ) + ); + } + + #[test] + fn test_apply_to_nested_arrays() { + let data = json!({ + "arrayOfArrays": [ + [ + { "x": 0, "y": 0 }, + ], + [ + { "x": 1, "y": 0 }, + { "x": 1, "y": 1 }, + { "x": 1, "y": 2 }, + ], + [ + { "x": 2, "y": 0 }, + { "x": 2, "y": 1 }, + ], + [], + [ + null, + { "x": 4, "y": 1 }, + { "x": 4, "why": 2 }, + null, + { "x": 4, "y": 4 }, + ] + ], + }); + + assert_eq!( + selection!(".arrayOfArrays.x").apply_to(&data), + ( + Some(json!([[0], [1, 1, 1], [2, 2], [], [null, 4, 4, null, 4],])), + vec![ + ApplyToError::from_json(&json!({ + "message": "Expected an object in response, received null", + "path": ["arrayOfArrays", 4, 0], + })), + ApplyToError::from_json(&json!({ + "message": "Expected an object in response, received null", + "path": ["arrayOfArrays", 4, 3], + })), + ], + ), + ); + + assert_eq!( + selection!(".arrayOfArrays.y").apply_to(&data), + ( + Some(json!([ + [0], + [0, 1, 2], + [0, 1], + [], + [null, 1, null, null, 4], + ])), + vec![ + ApplyToError::from_json(&json!({ + "message": "Expected an object in response, received null", + "path": ["arrayOfArrays", 4, 0], + })), + ApplyToError::from_json(&json!({ + "message": "Response field y not found", + "path": ["arrayOfArrays", 4, 2, "y"], + })), + ApplyToError::from_json(&json!({ + "message": "Expected an object in response, received null", + "path": ["arrayOfArrays", 4, 3], + })), + ], + ), + ); + + assert_eq!( + selection!("alias: arrayOfArrays { x y }").apply_to(&data), + ( + Some(json!({ + "alias": [ + [ + { "x": 0, "y": 0 }, + ], + [ + { "x": 1, "y": 0 }, + { "x": 1, "y": 1 }, + { "x": 1, "y": 2 }, + ], + [ + { "x": 2, "y": 0 }, + { "x": 2, "y": 1 }, + ], + [], + [ + null, + { "x": 4, "y": 1 }, + { "x": 4 }, + null, + { "x": 4, "y": 4 }, + ] + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Expected an object in response, received null", + "path": ["arrayOfArrays", 4, 0], + })), + ApplyToError::from_json(&json!({ + "message": "Response field y not found", + "path": ["arrayOfArrays", 4, 2, "y"], + })), + ApplyToError::from_json(&json!({ + "message": "Expected an object in response, received null", + "path": ["arrayOfArrays", 4, 3], + })), + ], + ), + ); + + assert_eq!( + selection!("ys: .arrayOfArrays.y xs: .arrayOfArrays.x").apply_to(&data), + ( + Some(json!({ + "ys": [ + [0], + [0, 1, 2], + [0, 1], + [], + [null, 1, null, null, 4], + ], + "xs": [ + [0], + [1, 1, 1], + [2, 2], + [], + [null, 4, 4, null, 4], + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Expected an object in response, received null", + "path": ["arrayOfArrays", 4, 0], + })), + ApplyToError::from_json(&json!({ + "message": "Response field y not found", + "path": ["arrayOfArrays", 4, 2, "y"], + })), + ApplyToError::from_json(&json!({ + // Reversing the order of "path" and "message" here to make + // sure that doesn't affect the deduplication logic. + "path": ["arrayOfArrays", 4, 3], + "message": "Expected an object in response, received null", + })), + // These errors have already been reported along different paths, above. + // ApplyToError::from_json(&json!({ + // "message": "not an object", + // "path": ["arrayOfArrays", 4, 0], + // })), + // ApplyToError::from_json(&json!({ + // "message": "not an object", + // "path": ["arrayOfArrays", 4, 3], + // })), + ], + ), + ); + } + + #[test] + fn test_apply_to_non_identifier_properties() { + let data = json!({ + "not an identifier": [ + { "also.not.an.identifier": 0 }, + { "also.not.an.identifier": 1 }, + { "also.not.an.identifier": 2 }, + ], + "another": { + "pesky string literal!": { + "identifier": 123, + "{ evil braces }": true, + }, + }, + }); + + assert_eq!( + // The grammar enforces that we must always provide identifier aliases + // for non-identifier properties, so the data we get back will always be + // GraphQL-safe. + selection!("alias: 'not an identifier' { safe: 'also.not.an.identifier' }") + .apply_to(&data), + ( + Some(json!({ + "alias": [ + { "safe": 0 }, + { "safe": 1 }, + { "safe": 2 }, + ], + })), + vec![], + ), + ); + + assert_eq!( + selection!(".'not an identifier'.'also.not.an.identifier'").apply_to(&data), + (Some(json!([0, 1, 2])), vec![],), + ); + + assert_eq!( + selection!(".\"not an identifier\" { safe: \"also.not.an.identifier\" }") + .apply_to(&data), + ( + Some(json!([ + { "safe": 0 }, + { "safe": 1 }, + { "safe": 2 }, + ])), + vec![], + ), + ); + + assert_eq!( + selection!( + "another { + pesky: 'pesky string literal!' { + identifier + evil: '{ evil braces }' + } + }" + ) + .apply_to(&data), + ( + Some(json!({ + "another": { + "pesky": { + "identifier": 123, + "evil": true, + }, + }, + })), + vec![], + ), + ); + + assert_eq!( + selection!(".another.'pesky string literal!'.'{ evil braces }'").apply_to(&data), + (Some(json!(true)), vec![],), + ); + + assert_eq!( + selection!(".another.'pesky string literal!'.\"identifier\"").apply_to(&data), + (Some(json!(123)), vec![],), + ); + } +} diff --git a/apollo-federation/src/sources/connect/json_selection/mod.rs b/apollo-federation/src/sources/connect/json_selection/mod.rs index 79d70795f5..98837934b2 100644 --- a/apollo-federation/src/sources/connect/json_selection/mod.rs +++ b/apollo-federation/src/sources/connect/json_selection/mod.rs @@ -1,4 +1,6 @@ +mod apply_to; mod helpers; mod parser; +pub use apply_to::*; pub use parser::*; diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs index 207e511bf1..52873936d4 100644 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -1,9 +1,5 @@ use std::fmt::Display; -use std::hash::Hash; -use std::hash::Hasher; -use indexmap::IndexSet; -use itertools::Itertools; use nom::branch::alt; use nom::character::complete::char; use nom::character::complete::one_of; @@ -18,11 +14,8 @@ use nom::sequence::preceded; use nom::sequence::tuple; use nom::IResult; use serde::Serialize; -use serde_json_bytes::json; -use serde_json_bytes::Map; -use serde_json_bytes::Value as JSON; -use super::helpers::{json_type_name, spaces_or_comments}; +use super::helpers::spaces_or_comments; // Selection ::= NamedSelection* StarSelection? | PathSelection @@ -367,407 +360,6 @@ fn parse_string_literal(input: &str) -> IResult<&str, String> { } } -/// ApplyTo is a trait for applying a Selection to a JSON value, collecting -/// any/all errors encountered in the process. - -pub trait ApplyTo { - // Applying a selection to a JSON value produces a new JSON value, along - // with any/all errors encountered in the process. The value is represented - // as an Option to allow for undefined/missing values (which JSON does not - // explicitly support), which are distinct from null values (which it does - // support). - fn apply_to(&self, data: &JSON) -> (Option, Vec) { - let mut input_path = vec![]; - // Using IndexSet over HashSet to preserve the order of the errors. - let mut errors = IndexSet::new(); - let value = self.apply_to_path(data, &mut input_path, &mut errors); - (value, errors.into_iter().collect()) - } - - // This is the trait method that should be implemented and called - // recursively by the various Selection types. - fn apply_to_path( - &self, - data: &JSON, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option; - - // When array is encountered, the Self selection will be applied to each - // element of the array, producing a new array. - fn apply_to_array( - &self, - data_array: &[JSON], - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option { - let mut output = Vec::with_capacity(data_array.len()); - - for (i, element) in data_array.iter().enumerate() { - input_path.push(Property::Index(i)); - let value = self.apply_to_path(element, input_path, errors); - input_path.pop(); - // When building an Object, we can simply omit missing properties - // and report an error, but when building an Array, we need to - // insert null values to preserve the original array indices/length. - output.push(value.unwrap_or(JSON::Null)); - } - - Some(JSON::Array(output)) - } -} - -#[derive(Debug, Eq, PartialEq, Clone)] -pub struct ApplyToError(JSON); - -impl Hash for ApplyToError { - fn hash(&self, hasher: &mut H) { - // Although serde_json::Value (aka JSON) does not implement the Hash - // trait, we can convert self.0 to a JSON string and hash that. To do - // this properly, we should ensure all object keys are serialized in - // lexicographic order before hashing, but the only object keys we use - // are "message" and "path", and they always appear in that order. - self.0.to_string().hash(hasher) - } -} - -impl ApplyToError { - fn new(message: &str, path: &[Property]) -> Self { - Self(json!({ - "message": message, - "path": path.iter().map(|property| match property { - Property::Field(name) => json!(name), - Property::Quoted(name) => json!(name), - Property::Index(index) => json!(index), - }).collect::>(), - })) - } - - #[cfg(test)] - fn from_json(json: &JSON) -> Self { - if let JSON::Object(error) = json { - if let Some(JSON::String(message)) = error.get("message") { - if let Some(JSON::Array(path)) = error.get("path") { - if path - .iter() - .all(|element| matches!(element, JSON::String(_) | JSON::Number(_))) - { - // Instead of simply returning Self(json.clone()), we - // enforce that the "message" and "path" properties are - // always in that order, as promised in the comment in - // the hash method above. - return Self(json!({ - "message": message, - "path": path, - })); - } - } - } - } - panic!("invalid ApplyToError JSON: {:?}", json); - } - - pub fn message(&self) -> Option<&str> { - self.0 - .as_object() - .and_then(|v| v.get("message")) - .and_then(|s| s.as_str()) - } - - pub fn path(&self) -> Option { - self.0 - .as_object() - .and_then(|v| v.get("path")) - .and_then(|p| p.as_array()) - .map(|l| l.iter().filter_map(|v| v.as_str()).join(".")) - } -} - -impl ApplyTo for Selection { - fn apply_to_path( - &self, - data: &JSON, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option { - let data = match data { - JSON::Array(array) => return self.apply_to_array(array, input_path, errors), - JSON::Object(_) => data, - _ => { - errors.insert(ApplyToError::new("not an object", input_path)); - return None; - } - }; - - match self { - // Because we represent a Selection::Named as a SubSelection, we can - // fully delegate apply_to_path to SubSelection::apply_to_path. Even - // if we represented Self::Named as a Vec, we could - // still delegate to SubSelection::apply_to_path, but we would need - // to create a temporary SubSelection to wrap the selections Vec. - Self::Named(named_selections) => { - named_selections.apply_to_path(data, input_path, errors) - } - Self::Path(path_selection) => path_selection.apply_to_path(data, input_path, errors), - } - } -} - -impl ApplyTo for NamedSelection { - fn apply_to_path( - &self, - data: &JSON, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option { - let data = match data { - JSON::Array(array) => return self.apply_to_array(array, input_path, errors), - JSON::Object(_) => data, - _ => { - errors.insert(ApplyToError::new("not an object", input_path)); - return None; - } - }; - - let mut output = Map::new(); - - #[rustfmt::skip] // cargo fmt butchers this closure's formatting - let mut field_quoted_helper = | - alias: Option<&Alias>, - name: &String, - selection: &Option, - input_path: &mut Vec, - | { - if let Some(child) = data.get(name) { - let output_name = alias.map_or(name, |alias| &alias.name); - if let Some(selection) = selection { - let value = selection.apply_to_path(child, input_path, errors); - if let Some(value) = value { - output.insert(output_name.clone(), value); - } - } else { - output.insert(output_name.clone(), child.clone()); - } - } else { - errors.insert(ApplyToError::new( - format!("Response field {} not found", name).as_str(), - input_path, - )); - } - }; - - match self { - Self::Field(alias, name, selection) => { - input_path.push(Property::Field(name.clone())); - field_quoted_helper(alias.as_ref(), name, selection, input_path); - input_path.pop(); - } - Self::Quoted(alias, name, selection) => { - input_path.push(Property::Quoted(name.clone())); - field_quoted_helper(Some(alias), name, selection, input_path); - input_path.pop(); - } - Self::Path(alias, path_selection) => { - let value = path_selection.apply_to_path(data, input_path, errors); - if let Some(value) = value { - output.insert(alias.name.clone(), value); - } - } - Self::Group(alias, sub_selection) => { - let value = sub_selection.apply_to_path(data, input_path, errors); - if let Some(value) = value { - output.insert(alias.name.clone(), value); - } - } - }; - - Some(JSON::Object(output)) - } -} - -impl ApplyTo for PathSelection { - fn apply_to_path( - &self, - data: &JSON, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option { - if let JSON::Array(array) = data { - return self.apply_to_array(array, input_path, errors); - } - - match self { - Self::Path(head, tail) => { - match data { - JSON::Object(_) => {} - _ => { - errors.insert(ApplyToError::new( - format!( - "Expected an object in response, received {}", - json_type_name(data) - ) - .as_str(), - input_path, - )); - return None; - } - }; - - input_path.push(head.clone()); - if let Some(child) = match head { - Property::Field(name) => data.get(name), - Property::Quoted(name) => data.get(name), - Property::Index(index) => data.get(index), - } { - let result = tail.apply_to_path(child, input_path, errors); - input_path.pop(); - result - } else { - let message = match head { - Property::Field(name) => format!("Response field {} not found", name), - Property::Quoted(name) => format!("Response field {} not found", name), - Property::Index(index) => format!("Response field {} not found", index), - }; - errors.insert(ApplyToError::new(message.as_str(), input_path)); - input_path.pop(); - None - } - } - Self::Selection(selection) => { - // If data is not an object here, this recursive apply_to_path - // call will handle the error. - selection.apply_to_path(data, input_path, errors) - } - Self::Empty => { - // If data is not an object here, we want to preserve its value - // without an error. - Some(data.clone()) - } - } - } -} - -impl ApplyTo for SubSelection { - fn apply_to_path( - &self, - data: &JSON, - input_path: &mut Vec, - errors: &mut IndexSet, - ) -> Option { - let data_map = match data { - JSON::Array(array) => return self.apply_to_array(array, input_path, errors), - JSON::Object(data_map) => data_map, - _ => { - errors.insert(ApplyToError::new( - format!( - "Expected an object in response, received {}", - json_type_name(data) - ) - .as_str(), - input_path, - )); - return None; - } - }; - - let mut output = Map::new(); - let mut input_names = IndexSet::new(); - - for named_selection in &self.selections { - let value = named_selection.apply_to_path(data, input_path, errors); - - // If value is an object, extend output with its keys and their values. - if let Some(JSON::Object(key_and_value)) = value { - output.extend(key_and_value); - } - - // If there is a star selection, we need to keep track of the - // *original* names of the fields that were explicitly selected, - // because we will need to omit them from what the * matches. - if self.star.is_some() { - match named_selection { - NamedSelection::Field(_, name, _) => { - input_names.insert(name.as_str()); - } - NamedSelection::Quoted(_, name, _) => { - input_names.insert(name.as_str()); - } - NamedSelection::Path(_, path_selection) => { - if let PathSelection::Path(head, _) = path_selection { - match head { - Property::Field(name) | Property::Quoted(name) => { - input_names.insert(name.as_str()); - } - // While Property::Index may be used to - // represent the input_path during apply_to_path - // when arrays are encountered, it will never be - // used to represent the parsed structure of any - // actual selection string, becase arrays are - // processed automatically/implicitly and their - // indices are never explicitly selected. This - // means the numeric Property::Index case cannot - // affect the keys selected by * selections, so - // input_names does not need updating here. - Property::Index(_) => {} - }; - } - } - // The contents of groups do not affect the keys matched by - // * selections in the parent object (outside the group). - NamedSelection::Group(_, _) => {} - }; - } - } - - match &self.star { - // Aliased but not subselected, e.g. "a b c rest: *" - Some(StarSelection(Some(alias), None)) => { - let mut star_output = Map::new(); - for (key, value) in data_map { - if !input_names.contains(key.as_str()) { - star_output.insert(key.clone(), value.clone()); - } - } - output.insert(alias.name.clone(), JSON::Object(star_output)); - } - // Aliased and subselected, e.g. "alias: * { hello }" - Some(StarSelection(Some(alias), Some(selection))) => { - let mut star_output = Map::new(); - for (key, value) in data_map { - if !input_names.contains(key.as_str()) { - if let Some(selected) = selection.apply_to_path(value, input_path, errors) { - star_output.insert(key.clone(), selected); - } - } - } - output.insert(alias.name.clone(), JSON::Object(star_output)); - } - // Not aliased but subselected, e.g. "parent { * { hello } }" - Some(StarSelection(None, Some(selection))) => { - for (key, value) in data_map { - if !input_names.contains(key.as_str()) { - if let Some(selected) = selection.apply_to_path(value, input_path, errors) { - output.insert(key.clone(), selected); - } - } - } - } - // Neither aliased nor subselected, e.g. "parent { * }" or just "*" - Some(StarSelection(None, None)) => { - for (key, value) in data_map { - if !input_names.contains(key.as_str()) { - output.insert(key.clone(), value.clone()); - } - } - } - // No * selection present, e.g. "parent { just some properties }" - None => {} - }; - - Some(JSON::Object(output)) - } -} - // GraphQL Selection Set ------------------------------------------------------- use apollo_compiler::ast; @@ -1605,717 +1197,6 @@ mod tests { ); } - #[test] - fn test_apply_to_selection() { - let data = json!({ - "hello": "world", - "nested": { - "hello": "world", - "world": "hello", - }, - "array": [ - { "hello": "world 0" }, - { "hello": "world 1" }, - { "hello": "world 2" }, - ], - }); - - let check_ok = |selection: Selection, expected_json: JSON| { - let (actual_json, errors) = selection.apply_to(&data); - assert_eq!(actual_json, Some(expected_json)); - assert_eq!(errors, vec![]); - }; - - check_ok(selection!("hello"), json!({"hello": "world"})); - - check_ok( - selection!("nested"), - json!({ - "nested": { - "hello": "world", - "world": "hello", - }, - }), - ); - - check_ok(selection!(".nested.hello"), json!("world")); - - check_ok(selection!(".nested.world"), json!("hello")); - - check_ok( - selection!("nested hello"), - json!({ - "hello": "world", - "nested": { - "hello": "world", - "world": "hello", - }, - }), - ); - - check_ok( - selection!("array { hello }"), - json!({ - "array": [ - { "hello": "world 0" }, - { "hello": "world 1" }, - { "hello": "world 2" }, - ], - }), - ); - - check_ok( - selection!("greetings: array { hello }"), - json!({ - "greetings": [ - { "hello": "world 0" }, - { "hello": "world 1" }, - { "hello": "world 2" }, - ], - }), - ); - - check_ok( - selection!(".array { hello }"), - json!([ - { "hello": "world 0" }, - { "hello": "world 1" }, - { "hello": "world 2" }, - ]), - ); - - check_ok( - selection!("worlds: .array.hello"), - json!({ - "worlds": [ - "world 0", - "world 1", - "world 2", - ], - }), - ); - - check_ok( - selection!(".array.hello"), - json!(["world 0", "world 1", "world 2",]), - ); - - check_ok( - selection!("nested grouped: { hello worlds: .array.hello }"), - json!({ - "nested": { - "hello": "world", - "world": "hello", - }, - "grouped": { - "hello": "world", - "worlds": [ - "world 0", - "world 1", - "world 2", - ], - }, - }), - ); - } - - #[test] - fn test_apply_to_star_selections() { - let data = json!({ - "englishAndGreekLetters": { - "a": { "en": "ay", "gr": "alpha" }, - "b": { "en": "bee", "gr": "beta" }, - "c": { "en": "see", "gr": "gamma" }, - "d": { "en": "dee", "gr": "delta" }, - "e": { "en": "ee", "gr": "epsilon" }, - "f": { "en": "eff", "gr": "phi" }, - }, - "englishAndSpanishNumbers": [ - { "en": "one", "es": "uno" }, - { "en": "two", "es": "dos" }, - { "en": "three", "es": "tres" }, - { "en": "four", "es": "cuatro" }, - { "en": "five", "es": "cinco" }, - { "en": "six", "es": "seis" }, - ], - "asciiCharCodes": { - "A": 65, - "B": 66, - "C": 67, - "D": 68, - "E": 69, - "F": 70, - "G": 71, - }, - "books": { - "9780262533751": { - "title": "The Geometry of Meaning", - "author": "Peter Gärdenfors", - }, - "978-1492674313": { - "title": "P is for Pterodactyl: The Worst Alphabet Book Ever", - "author": "Raj Haldar", - }, - "9780262542456": { - "title": "A Biography of the Pixel", - "author": "Alvy Ray Smith", - }, - } - }); - - let check_ok = |selection: Selection, expected_json: JSON| { - let (actual_json, errors) = selection.apply_to(&data); - assert_eq!(actual_json, Some(expected_json)); - assert_eq!(errors, vec![]); - }; - - check_ok( - selection!("englishAndGreekLetters { * { en }}"), - json!({ - "englishAndGreekLetters": { - "a": { "en": "ay" }, - "b": { "en": "bee" }, - "c": { "en": "see" }, - "d": { "en": "dee" }, - "e": { "en": "ee" }, - "f": { "en": "eff" }, - }, - }), - ); - - check_ok( - selection!("englishAndGreekLetters { C: .c.en * { gr }}"), - json!({ - "englishAndGreekLetters": { - "a": { "gr": "alpha" }, - "b": { "gr": "beta" }, - "C": "see", - "d": { "gr": "delta" }, - "e": { "gr": "epsilon" }, - "f": { "gr": "phi" }, - }, - }), - ); - - check_ok( - selection!("englishAndGreekLetters { A: a B: b rest: * }"), - json!({ - "englishAndGreekLetters": { - "A": { "en": "ay", "gr": "alpha" }, - "B": { "en": "bee", "gr": "beta" }, - "rest": { - "c": { "en": "see", "gr": "gamma" }, - "d": { "en": "dee", "gr": "delta" }, - "e": { "en": "ee", "gr": "epsilon" }, - "f": { "en": "eff", "gr": "phi" }, - }, - }, - }), - ); - - check_ok( - selection!(".'englishAndSpanishNumbers' { en rest: * }"), - json!([ - { "en": "one", "rest": { "es": "uno" } }, - { "en": "two", "rest": { "es": "dos" } }, - { "en": "three", "rest": { "es": "tres" } }, - { "en": "four", "rest": { "es": "cuatro" } }, - { "en": "five", "rest": { "es": "cinco" } }, - { "en": "six", "rest": { "es": "seis" } }, - ]), - ); - - // To include/preserve all remaining properties from an object in the output - // object, we support a naked * selection (no alias or subselection). This - // is useful when the values of the properties are scalar, so a subselection - // isn't possible, and we want to preserve all properties of the original - // object. These unnamed properties may not be useful for GraphQL unless the - // whole object is considered as opaque JSON scalar data, but we still need - // to support preserving JSON when it has scalar properties. - check_ok( - selection!("asciiCharCodes { ay: A bee: B * }"), - json!({ - "asciiCharCodes": { - "ay": 65, - "bee": 66, - "C": 67, - "D": 68, - "E": 69, - "F": 70, - "G": 71, - }, - }), - ); - - check_ok( - selection!("asciiCharCodes { * } gee: .asciiCharCodes.G"), - json!({ - "asciiCharCodes": data.get("asciiCharCodes").unwrap(), - "gee": 71, - }), - ); - - check_ok( - selection!("books { * { title } }"), - json!({ - "books": { - "9780262533751": { - "title": "The Geometry of Meaning", - }, - "978-1492674313": { - "title": "P is for Pterodactyl: The Worst Alphabet Book Ever", - }, - "9780262542456": { - "title": "A Biography of the Pixel", - }, - }, - }), - ); - - check_ok( - selection!("books { authorsByISBN: * { author } }"), - json!({ - "books": { - "authorsByISBN": { - "9780262533751": { - "author": "Peter Gärdenfors", - }, - "978-1492674313": { - "author": "Raj Haldar", - }, - "9780262542456": { - "author": "Alvy Ray Smith", - }, - }, - }, - }), - ); - } - - #[test] - fn test_apply_to_errors() { - let data = json!({ - "hello": "world", - "nested": { - "hello": 123, - "world": true, - }, - "array": [ - { "hello": 1, "goodbye": "farewell" }, - { "hello": "two" }, - { "hello": 3.0, "smello": "yellow" }, - ], - }); - - assert_eq!( - selection!("hello").apply_to(&data), - (Some(json!({"hello": "world"})), vec![],) - ); - - assert_eq!( - selection!("yellow").apply_to(&data), - ( - Some(json!({})), - vec![ApplyToError::from_json(&json!({ - "message": "Response field yellow not found", - "path": ["yellow"], - })),], - ) - ); - - assert_eq!( - selection!(".nested.hello").apply_to(&data), - (Some(json!(123)), vec![],) - ); - - assert_eq!( - selection!(".nested.'yellow'").apply_to(&data), - ( - None, - vec![ApplyToError::from_json(&json!({ - "message": "Response field yellow not found", - "path": ["nested", "yellow"], - })),], - ) - ); - - assert_eq!( - selection!(".nested { hola yellow world }").apply_to(&data), - ( - Some(json!({ - "world": true, - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Response field hola not found", - "path": ["nested", "hola"], - })), - ApplyToError::from_json(&json!({ - "message": "Response field yellow not found", - "path": ["nested", "yellow"], - })), - ], - ) - ); - - assert_eq!( - selection!("partial: .array { hello goodbye }").apply_to(&data), - ( - Some(json!({ - "partial": [ - { "hello": 1, "goodbye": "farewell" }, - { "hello": "two" }, - { "hello": 3.0 }, - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Response field goodbye not found", - "path": ["array", 1, "goodbye"], - })), - ApplyToError::from_json(&json!({ - "message": "Response field goodbye not found", - "path": ["array", 2, "goodbye"], - })), - ], - ) - ); - - assert_eq!( - selection!("good: .array.hello bad: .array.smello").apply_to(&data), - ( - Some(json!({ - "good": [ - 1, - "two", - 3.0, - ], - "bad": [ - null, - null, - "yellow", - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Response field smello not found", - "path": ["array", 0, "smello"], - })), - ApplyToError::from_json(&json!({ - "message": "Response field smello not found", - "path": ["array", 1, "smello"], - })), - ], - ) - ); - - assert_eq!( - selection!("array { hello smello }").apply_to(&data), - ( - Some(json!({ - "array": [ - { "hello": 1 }, - { "hello": "two" }, - { "hello": 3.0, "smello": "yellow" }, - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Response field smello not found", - "path": ["array", 0, "smello"], - })), - ApplyToError::from_json(&json!({ - "message": "Response field smello not found", - "path": ["array", 1, "smello"], - })), - ], - ) - ); - - assert_eq!( - selection!(".nested { grouped: { hello smelly world } }").apply_to(&data), - ( - Some(json!({ - "grouped": { - "hello": 123, - "world": true, - }, - })), - vec![ApplyToError::from_json(&json!({ - "message": "Response field smelly not found", - "path": ["nested", "smelly"], - })),], - ) - ); - - assert_eq!( - selection!("alias: .nested { grouped: { hello smelly world } }").apply_to(&data), - ( - Some(json!({ - "alias": { - "grouped": { - "hello": 123, - "world": true, - }, - }, - })), - vec![ApplyToError::from_json(&json!({ - "message": "Response field smelly not found", - "path": ["nested", "smelly"], - })),], - ) - ); - } - - #[test] - fn test_apply_to_nested_arrays() { - let data = json!({ - "arrayOfArrays": [ - [ - { "x": 0, "y": 0 }, - ], - [ - { "x": 1, "y": 0 }, - { "x": 1, "y": 1 }, - { "x": 1, "y": 2 }, - ], - [ - { "x": 2, "y": 0 }, - { "x": 2, "y": 1 }, - ], - [], - [ - null, - { "x": 4, "y": 1 }, - { "x": 4, "why": 2 }, - null, - { "x": 4, "y": 4 }, - ] - ], - }); - - assert_eq!( - selection!(".arrayOfArrays.x").apply_to(&data), - ( - Some(json!([[0], [1, 1, 1], [2, 2], [], [null, 4, 4, null, 4],])), - vec![ - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 0], - })), - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 3], - })), - ], - ), - ); - - assert_eq!( - selection!(".arrayOfArrays.y").apply_to(&data), - ( - Some(json!([ - [0], - [0, 1, 2], - [0, 1], - [], - [null, 1, null, null, 4], - ])), - vec![ - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 0], - })), - ApplyToError::from_json(&json!({ - "message": "Response field y not found", - "path": ["arrayOfArrays", 4, 2, "y"], - })), - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 3], - })), - ], - ), - ); - - assert_eq!( - selection!("alias: arrayOfArrays { x y }").apply_to(&data), - ( - Some(json!({ - "alias": [ - [ - { "x": 0, "y": 0 }, - ], - [ - { "x": 1, "y": 0 }, - { "x": 1, "y": 1 }, - { "x": 1, "y": 2 }, - ], - [ - { "x": 2, "y": 0 }, - { "x": 2, "y": 1 }, - ], - [], - [ - null, - { "x": 4, "y": 1 }, - { "x": 4 }, - null, - { "x": 4, "y": 4 }, - ] - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 0], - })), - ApplyToError::from_json(&json!({ - "message": "Response field y not found", - "path": ["arrayOfArrays", 4, 2, "y"], - })), - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 3], - })), - ], - ), - ); - - assert_eq!( - selection!("ys: .arrayOfArrays.y xs: .arrayOfArrays.x").apply_to(&data), - ( - Some(json!({ - "ys": [ - [0], - [0, 1, 2], - [0, 1], - [], - [null, 1, null, null, 4], - ], - "xs": [ - [0], - [1, 1, 1], - [2, 2], - [], - [null, 4, 4, null, 4], - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 0], - })), - ApplyToError::from_json(&json!({ - "message": "Response field y not found", - "path": ["arrayOfArrays", 4, 2, "y"], - })), - ApplyToError::from_json(&json!({ - // Reversing the order of "path" and "message" here to make - // sure that doesn't affect the deduplication logic. - "path": ["arrayOfArrays", 4, 3], - "message": "Expected an object in response, received null", - })), - // These errors have already been reported along different paths, above. - // ApplyToError::from_json(&json!({ - // "message": "not an object", - // "path": ["arrayOfArrays", 4, 0], - // })), - // ApplyToError::from_json(&json!({ - // "message": "not an object", - // "path": ["arrayOfArrays", 4, 3], - // })), - ], - ), - ); - } - - #[test] - fn test_apply_to_non_identifier_properties() { - let data = json!({ - "not an identifier": [ - { "also.not.an.identifier": 0 }, - { "also.not.an.identifier": 1 }, - { "also.not.an.identifier": 2 }, - ], - "another": { - "pesky string literal!": { - "identifier": 123, - "{ evil braces }": true, - }, - }, - }); - - assert_eq!( - // The grammar enforces that we must always provide identifier aliases - // for non-identifier properties, so the data we get back will always be - // GraphQL-safe. - selection!("alias: 'not an identifier' { safe: 'also.not.an.identifier' }") - .apply_to(&data), - ( - Some(json!({ - "alias": [ - { "safe": 0 }, - { "safe": 1 }, - { "safe": 2 }, - ], - })), - vec![], - ), - ); - - assert_eq!( - selection!(".'not an identifier'.'also.not.an.identifier'").apply_to(&data), - (Some(json!([0, 1, 2])), vec![],), - ); - - assert_eq!( - selection!(".\"not an identifier\" { safe: \"also.not.an.identifier\" }") - .apply_to(&data), - ( - Some(json!([ - { "safe": 0 }, - { "safe": 1 }, - { "safe": 2 }, - ])), - vec![], - ), - ); - - assert_eq!( - selection!( - "another { - pesky: 'pesky string literal!' { - identifier - evil: '{ evil braces }' - } - }" - ) - .apply_to(&data), - ( - Some(json!({ - "another": { - "pesky": { - "identifier": 123, - "evil": true, - }, - }, - })), - vec![], - ), - ); - - assert_eq!( - selection!(".another.'pesky string literal!'.'{ evil braces }'").apply_to(&data), - (Some(json!(true)), vec![],), - ); - - assert_eq!( - selection!(".another.'pesky string literal!'.\"identifier\"").apply_to(&data), - (Some(json!(123)), vec![],), - ); - } - use apollo_compiler::ast::Selection as GraphQLSelection; fn print_set(set: &[apollo_compiler::ast::Selection]) -> String { From 8d795929a57960cc3621491831c7843ee6c163e8 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 3 May 2024 12:34:47 -0400 Subject: [PATCH 04/12] Extract json_selection/graphql.rs from parser.rs. --- .../sources/connect/json_selection/graphql.rs | 193 ++++++++++++++++++ .../src/sources/connect/json_selection/mod.rs | 1 + .../sources/connect/json_selection/parser.rs | 173 +--------------- 3 files changed, 196 insertions(+), 171 deletions(-) create mode 100644 apollo-federation/src/sources/connect/json_selection/graphql.rs diff --git a/apollo-federation/src/sources/connect/json_selection/graphql.rs b/apollo-federation/src/sources/connect/json_selection/graphql.rs new file mode 100644 index 0000000000..fc1d6cdedf --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/graphql.rs @@ -0,0 +1,193 @@ +// The Selection syntax is intended to be more generic than GraphQL, +// capable of transforming any aribitrary JSON in arbitrary ways, +// without assuming the universal availability of __typename or other +// convenient GraphQL-isms. However, since we are using the Selection +// syntax to generate GraphQL-shaped output JSON, it's helpful to have +// some GraphQL-specific utilities. +// +// This file contains several trait implementations that allow +// converting from the Selection type to a corresponding GraphQL +// selection set, where (for example) PathSelection syntax is expanded +// to ordinary nested selection sets. The resulting JSON will retain the +// nested structure of the GraphQL selection sets, and thus be more +// verbose than the output of the Selection syntax, but may be easier to +// use for validating the selection against a GraphQL schema, using +// existing code for validating GraphQL operations. + +use apollo_compiler::ast; +use apollo_compiler::ast::Selection as GraphQLSelection; + +use super::parser::NamedSelection; +use super::parser::PathSelection; +use super::parser::Selection; +use super::parser::StarSelection; +use super::parser::SubSelection; + +#[derive(Default)] +struct GraphQLSelections(Vec>); + +impl GraphQLSelections { + fn valid_selections(self) -> Vec { + self.0.into_iter().filter_map(|i| i.ok()).collect() + } +} + +impl From> for GraphQLSelections { + fn from(val: Vec) -> Self { + Self(val.into_iter().map(Ok).collect()) + } +} + +impl From for Vec { + fn from(val: Selection) -> Vec { + match val { + Selection::Named(named_selections) => { + GraphQLSelections::from(named_selections).valid_selections() + } + Selection::Path(path_selection) => path_selection.into(), + } + } +} + +fn new_field(name: String, selection: Option) -> GraphQLSelection { + GraphQLSelection::Field( + apollo_compiler::ast::Field { + alias: None, + name: ast::Name::new_unchecked(name.into()), + arguments: Default::default(), + directives: Default::default(), + selection_set: selection + .map(GraphQLSelections::valid_selections) + .unwrap_or_default(), + } + .into(), + ) +} + +impl From for Vec { + fn from(val: NamedSelection) -> Vec { + match val { + NamedSelection::Field(alias, name, selection) => vec![new_field( + alias.map(|a| a.name).unwrap_or(name), + selection.map(|s| s.into()), + )], + NamedSelection::Quoted(alias, _name, selection) => { + vec![new_field( + alias.name, + selection.map(GraphQLSelections::from), + )] + } + NamedSelection::Path(alias, path_selection) => { + let graphql_selection: Vec = path_selection.into(); + vec![new_field( + alias.name, + Some(GraphQLSelections::from(graphql_selection)), + )] + } + NamedSelection::Group(alias, sub_selection) => { + vec![new_field(alias.name, Some(sub_selection.into()))] + } + } + } +} + +impl From for Vec { + fn from(val: PathSelection) -> Vec { + match val { + PathSelection::Path(_head, tail) => { + let tail = *tail; + tail.into() + } + PathSelection::Selection(selection) => { + GraphQLSelections::from(selection).valid_selections() + } + PathSelection::Empty => vec![], + } + } +} + +impl From for GraphQLSelections { + // give as much as we can, yield errors for star selection without alias. + fn from(val: SubSelection) -> GraphQLSelections { + let mut selections = val + .selections + .into_iter() + .flat_map(|named_selection| { + let selections: Vec = named_selection.into(); + GraphQLSelections::from(selections).0 + }) + .collect::>>(); + + if let Some(StarSelection(alias, sub_selection)) = val.star { + if let Some(alias) = alias { + let star = new_field( + alias.name, + sub_selection.map(|s| GraphQLSelections::from(*s)), + ); + selections.push(Ok(star)); + } else { + selections.push(Err( + "star selection without alias cannot be converted to GraphQL".to_string(), + )); + } + } + GraphQLSelections(selections) + } +} + +#[cfg(test)] +mod tests { + use apollo_compiler::ast::Selection as GraphQLSelection; + + use crate::selection; + + fn print_set(set: &[apollo_compiler::ast::Selection]) -> String { + set.iter() + .map(|s| s.serialize().to_string()) + .collect::>() + .join(" ") + } + + #[test] + fn into_selection_set() { + let selection = selection!("f"); + let set: Vec = selection.into(); + assert_eq!(print_set(&set), "f"); + + let selection = selection!("f f2 f3"); + let set: Vec = selection.into(); + assert_eq!(print_set(&set), "f f2 f3"); + + let selection = selection!("f { f2 f3 }"); + let set: Vec = selection.into(); + assert_eq!(print_set(&set), "f {\n f2\n f3\n}"); + + let selection = selection!("a: f { b: f2 }"); + let set: Vec = selection.into(); + assert_eq!(print_set(&set), "a {\n b\n}"); + + let selection = selection!(".a { b c }"); + let set: Vec = selection.into(); + assert_eq!(print_set(&set), "b c"); + + let selection = selection!(".a.b { c: .d e }"); + let set: Vec = selection.into(); + assert_eq!(print_set(&set), "c e"); + + let selection = selection!("a: { b c }"); + let set: Vec = selection.into(); + assert_eq!(print_set(&set), "a {\n b\n c\n}"); + + let selection = selection!("a: 'quoted'"); + let set: Vec = selection.into(); + assert_eq!(print_set(&set), "a"); + + let selection = selection!("a b: *"); + let set: Vec = selection.into(); + assert_eq!(print_set(&set), "a b"); + + let selection = selection!("a *"); + let set: Vec = selection.into(); + assert_eq!(print_set(&set), "a"); + } +} diff --git a/apollo-federation/src/sources/connect/json_selection/mod.rs b/apollo-federation/src/sources/connect/json_selection/mod.rs index 98837934b2..dce9a3a44b 100644 --- a/apollo-federation/src/sources/connect/json_selection/mod.rs +++ b/apollo-federation/src/sources/connect/json_selection/mod.rs @@ -1,4 +1,5 @@ mod apply_to; +mod graphql; mod helpers; mod parser; diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs index 52873936d4..219a29aa21 100644 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -231,7 +231,7 @@ impl SubSelection { // StarSelection ::= Alias? "*" SubSelection? #[derive(Debug, PartialEq, Clone, Serialize)] -pub struct StarSelection(Option, Option>); +pub struct StarSelection(pub Option, pub Option>); impl StarSelection { fn parse(input: &str) -> IResult<&str, Self> { @@ -255,7 +255,7 @@ impl StarSelection { #[derive(Debug, PartialEq, Clone, Serialize)] pub struct Alias { - name: String, + pub(crate) name: String, } impl Alias { @@ -360,123 +360,6 @@ fn parse_string_literal(input: &str) -> IResult<&str, String> { } } -// GraphQL Selection Set ------------------------------------------------------- - -use apollo_compiler::ast; -use apollo_compiler::ast::Selection as GraphQLSelection; - -#[derive(Default)] -struct GraphQLSelections(Vec>); - -impl GraphQLSelections { - fn valid_selections(self) -> Vec { - self.0.into_iter().filter_map(|i| i.ok()).collect() - } -} - -impl From> for GraphQLSelections { - fn from(val: Vec) -> Self { - Self(val.into_iter().map(Ok).collect()) - } -} - -impl From for Vec { - fn from(val: Selection) -> Vec { - match val { - Selection::Named(named_selections) => { - GraphQLSelections::from(named_selections).valid_selections() - } - Selection::Path(path_selection) => path_selection.into(), - } - } -} - -fn new_field(name: String, selection: Option) -> GraphQLSelection { - GraphQLSelection::Field( - apollo_compiler::ast::Field { - alias: None, - name: ast::Name::new_unchecked(name.into()), - arguments: Default::default(), - directives: Default::default(), - selection_set: selection - .map(GraphQLSelections::valid_selections) - .unwrap_or_default(), - } - .into(), - ) -} - -impl From for Vec { - fn from(val: NamedSelection) -> Vec { - match val { - NamedSelection::Field(alias, name, selection) => vec![new_field( - alias.map(|a| a.name).unwrap_or(name), - selection.map(|s| s.into()), - )], - NamedSelection::Quoted(alias, _name, selection) => { - vec![new_field( - alias.name, - selection.map(GraphQLSelections::from), - )] - } - NamedSelection::Path(alias, path_selection) => { - let graphql_selection: Vec = path_selection.into(); - vec![new_field( - alias.name, - Some(GraphQLSelections::from(graphql_selection)), - )] - } - NamedSelection::Group(alias, sub_selection) => { - vec![new_field(alias.name, Some(sub_selection.into()))] - } - } - } -} - -impl From for Vec { - fn from(val: PathSelection) -> Vec { - match val { - PathSelection::Path(_head, tail) => { - let tail = *tail; - tail.into() - } - PathSelection::Selection(selection) => { - GraphQLSelections::from(selection).valid_selections() - } - PathSelection::Empty => vec![], - } - } -} - -impl From for GraphQLSelections { - // give as much as we can, yield errors for star selection without alias. - fn from(val: SubSelection) -> GraphQLSelections { - let mut selections = val - .selections - .into_iter() - .flat_map(|named_selection| { - let selections: Vec = named_selection.into(); - GraphQLSelections::from(selections).0 - }) - .collect::>>(); - - if let Some(StarSelection(alias, sub_selection)) = val.star { - if let Some(alias) = alias { - let star = new_field( - alias.name, - sub_selection.map(|s| GraphQLSelections::from(*s)), - ); - selections.push(Ok(star)); - } else { - selections.push(Err( - "star selection without alias cannot be converted to GraphQL".to_string(), - )); - } - } - GraphQLSelections(selections) - } -} - #[cfg(test)] mod tests { use super::*; @@ -1196,56 +1079,4 @@ mod tests { }), ); } - - use apollo_compiler::ast::Selection as GraphQLSelection; - - fn print_set(set: &[apollo_compiler::ast::Selection]) -> String { - set.iter() - .map(|s| s.serialize().to_string()) - .collect::>() - .join(" ") - } - - #[test] - fn into_selection_set() { - let selection = selection!("f"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "f"); - - let selection = selection!("f f2 f3"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "f f2 f3"); - - let selection = selection!("f { f2 f3 }"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "f {\n f2\n f3\n}"); - - let selection = selection!("a: f { b: f2 }"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "a {\n b\n}"); - - let selection = selection!(".a { b c }"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "b c"); - - let selection = selection!(".a.b { c: .d e }"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "c e"); - - let selection = selection!("a: { b c }"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "a {\n b\n c\n}"); - - let selection = selection!("a: 'quoted'"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "a"); - - let selection = selection!("a b: *"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "a b"); - - let selection = selection!("a *"); - let set: Vec = selection.into(); - assert_eq!(print_set(&set), "a"); - } } From 2bb25fdea7bd0f851aa3a103d7a014266c7d83da Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 3 May 2024 12:35:41 -0400 Subject: [PATCH 05/12] Technical README for json_selection directory. --- .../sources/connect/json_selection/README.md | 843 ++++++++++++++++++ .../connect/json_selection/grammar/Alias.svg | 53 ++ .../json_selection/grammar/Comment.svg | 49 + .../json_selection/grammar/Identifier.svg | 76 ++ .../json_selection/grammar/JSArray.svg | 69 ++ .../json_selection/grammar/JSLiteral.svg | 66 ++ .../json_selection/grammar/JSNumber.svg | 75 ++ .../json_selection/grammar/JSONSelection.svg | 52 ++ .../json_selection/grammar/JSObject.svg | 69 ++ .../json_selection/grammar/JSPrimitive.svg | 76 ++ .../json_selection/grammar/JSProperty.svg | 60 ++ .../connect/json_selection/grammar/Key.svg | 52 ++ .../json_selection/grammar/KeyPath.svg | 52 ++ .../json_selection/grammar/MethodArgs.svg | 69 ++ .../grammar/NakedSubselection.svg | 52 ++ .../grammar/NamedFieldSelection.svg | 59 ++ .../grammar/NamedGroupSelection.svg | 52 ++ .../grammar/NamedPathSelection.svg | 52 ++ .../grammar/NamedQuotedSelection.svg | 59 ++ .../json_selection/grammar/NamedSelection.svg | 66 ++ .../json_selection/grammar/PathSelection.svg | 59 ++ .../json_selection/grammar/PathStep.svg | 75 ++ .../connect/json_selection/grammar/Spaces.svg | 70 ++ .../grammar/SpacesOrComments.svg | 52 ++ .../json_selection/grammar/StarSelection.svg | 60 ++ .../json_selection/grammar/StringLiteral.svg | 92 ++ .../json_selection/grammar/Subselection.svg | 61 ++ .../json_selection/grammar/UnsignedInt.svg | 59 ++ .../json_selection/grammar/VarPath.svg | 67 ++ 29 files changed, 2596 insertions(+) create mode 100644 apollo-federation/src/sources/connect/json_selection/README.md create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/JSProperty.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/Key.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NakedSubselection.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/Subselection.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg create mode 100644 apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg diff --git a/apollo-federation/src/sources/connect/json_selection/README.md b/apollo-federation/src/sources/connect/json_selection/README.md new file mode 100644 index 0000000000..d8ac1f142e --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/README.md @@ -0,0 +1,843 @@ +# What is `JSONSelection` syntax? + +One of the most fundamental goals of the connectors project is that a GraphQL +subgraph schema, all by itself, should be able to encapsulate and selectively +re-expose any JSON-speaking data source as strongly-typed GraphQL, using a +declarative annotation syntax based on the `@source` and `@connect` directives, +with no need for additional resolver code, and without having to run a subgraph +server. + +Delivering on this goal entails somehow transforming arbitrary JSON into +GraphQL-shaped JSON without writing any procedural transformation code. Instead, +these transformations are expressed using a static, declarative string literal +syntax, which resembles GraphQL operation syntax but also supports a number of +other features necessary/convenient for processing arbitrary JSON. + +The _static_ part is important, since we need to be able to tell, by examining a +given `JSONSelection` string at composition time, exactly what shape its output +will have, even though we cannot anticipate every detail of every possible JSON +input that will be encountered at runtime. As a benefit of this static analysis, +we can then validate that the connector schema reliably generates the expected +GraphQL data types. + +In GraphQL terms, this syntax is represented by the `JSONSelection` scalar type, +whose grammar and semantics are detailed in this document. Typically, string +literals obeying this grammar will be passed as the `selection` argument to the +`@connect` directive, which is used to annotate fields of object types within a +subgraph schema. + +In terms of this Rust implementation, the string syntax is parsed into a +`JSONSelection` enum, which implements the `ApplyTo` trait for processing +incoming JSON and producing GraphQL-friendly JSON output. + +## Guiding principles + +As the `JSONSelection` syntax was being designed, and as we consider future +improvements, we should adhere to the following principles: + +1. Since `JSONSelection` syntax resembles GraphQL operation syntax and will + often be used in close proximity to GraphQL operations, whenever an element + of `JSONSelection` syntax looks the same as GraphQL, its behavior and + semantics should be the same as (or at least analogous to) GraphQL. It is + preferable, therefore, to invent new (non-GraphQL) `JSONSelection` syntax + when we want to introduce behaviors that are not part of GraphQL, or when + GraphQL syntax is insufficiently expressive to accomplish a particular + JSON-processing task. For example, `->` method syntax is better for inline + transformations that reusing/abusing GraphQL field argument syntax. + +2. It must be possible to statically determine the output shape (object + properties, array types, and nested value shapes) produced by a + `JSONSelection` string. JSON data encountered at runtime may be inherently + dynamic and unpredicatable, but we must be able to validate the output shape + matches the GraphQL schema. Because we can assume all input data is some kind + of JSON, for types whose shape cannot be statically determined, the GraphQL + `JSON` scalar type can be used as an "any" type, though this should be + avoided because it limits the developer's ability to subselect fields of the + opaque `JSON` value in GraphQL operations. + +3. Backwards compatibility should be maintained as we release new versions of + the `JSONSelection` syntax along with new versions of the (forthcoming) + `@link(url: "https://specs.apollo.dev/connect/vX.Y")` specification. Wherever + possible, we should only add new functionality, not remove or change existing + functionality, unless we are releasing a new major version (and even then we + should be careful not to create unnecessary upgrade work for developers). + +## Formal grammar + +[Extended Backus-Naur Form](https://en.wikipedia.org/wiki/Extended_Backus%E2%80%93Naur_form) +(EBNF) provides a compact way to describe the complete `JSONSelection` grammar. + +This grammar is more for future reference than initial explanation, so don't +worry if it doesn't seem helpful yet, as every rule will be explained in detail +below. + +```ebnf +JSONSelection ::= NakedSubSelection | PathSelection +NakedSubSelection ::= NamedSelection* StarSelection? +SubSelection ::= "{" NakedSubSelection "}" +NamedSelection ::= NamedFieldSelection | NamedQuotedSelection | NamedPathSelection | NamedGroupSelection +NamedFieldSelection ::= Alias? Identifier SubSelection? +NamedQuotedSelection ::= Alias StringLiteral SubSelection? +NamedPathSelection ::= Alias PathSelection +NamedGroupSelection ::= Alias SubSelection +Alias ::= Identifier ":" +PathSelection ::= (VarPath | KeyPath) SubSelection? +VarPath ::= "$" (NO_SPACE Identifier)? PathStep* +KeyPath ::= Key PathStep+ +PathStep ::= "." Key | "->" Identifier MethodArgs? +Key ::= Identifier | StringLiteral +Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* +StringLiteral ::= "'" ("\\'" | [^'])* "'" | '"' ('\\"' | [^"])* '"' +MethodArgs ::= "(" (JSLiteral ("," JSLiteral)*)? ")" +JSLiteral ::= JSPrimitive | JSObject | JSArray | PathSelection +JSPrimitive ::= StringLiteral | JSNumber | "true" | "false" | "null" +JSNumber ::= "-"? (UnsignedInt ("." [0-9]*)? | "." [0-9]+) +UnsignedInt ::= "0" | [1-9] NO_SPACE [0-9]* +JSObject ::= "{" (JSProperty ("," JSProperty)*)? "}" +JSProperty ::= Key ":" JSLiteral +JSArray ::= "[" (JSLiteral ("," JSLiteral)*)? "]" +StarSelection ::= Alias? "*" SubSelection? +NO_SPACE ::= !SpacesOrComments +SpacesOrComments ::= (Spaces | Comment)+ +Spaces ::= (" " | "\t" | "\r" | "\n")+ +Comment ::= "#" [^\n]* +``` + +### How to read this grammar + +Every valid `JSONSelection` string can be parsed by starting with the +`JSONSelection` non-terminal and repeatedly applying one of the expansions on +the right side of the `::=` operator, with alternatives separated by the `|` +operator. Every `CamelCase` identifier on the left side of the `::=` operator +can be recursively expanded into one of its right-side alternatives. + +Methodically trying out all these alternatives is the fundamental job of the +parser. While this grammar is not believed to have any ambiguities, ambiguities +can be resolved by applying the alternatives left to right, accepting the first +set of expansions that fully matches the input tokens. Parsing succeeds when +only terminal tokens remain (quoted text or regular expression character +classes). + +Much like regular expression syntax, the `*` and `+` operators denote repetition +(_zero or more_ and _one or more_, respectively), `?` denotes optionality (_zero +or one_), parentheses allow grouping, `"quoted"` or `'quoted'` text represents +raw characters that cannot be expanded further, and `[...]` specifies character +ranges. + +### Whitespace, comments, and `NO_SPACE` + +In many parsers, whitespace and comments are handled by the lexer, which +performs tokenization before the parser sees the input. This approach can +simplify the grammar, because the parser doesn't need to worry about whitespace +or comments, and can focus instead on parsing the structure of the input tokens. + +The grammar shown above adopts this convention. In other words, instead of +explicitly specifying everywhere whitespace and comments are allowed, we +verbally declare that **whitespace and comments are _allowed_ between any +tokens, except where explicitly forbidden by the `NO_SPACE` notation**. The +`NO_SPACE ::= !SpacesOrComments` rule is called _negative lookahead_ in many +parsing systems. Spaces are also implicitly _required_ if omitting them would +undesirably result in parsing adjacent tokens as one token, though the grammar +cannot enforce this requirement. + +While the current Rust parser implementation does not have a formal lexical +analysis phase, the `spaces_or_comments` function is used extensively to consume +whitespace and `#`-style comments wherever they might appear between tokens. The +negative lookahead of `NO_SPACE` is enforced by _avoiding_ `spaces_or_comments` +in a few key places: + +```ebnf +VarPath ::= "$" (NO_SPACE Identifier)? PathStep* +Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* +UnsignedInt ::= "0" | [1-9] NO_SPACE [0-9]* +``` + +These rules mean the `$` of a `$variable` cannot be separated from the +identifier part (so `$ var` is invalid), and the first character of a +multi-character `Identifier` or `UnsignedInt` must not be separated from the +remaining characters. + +Make sure you use `spaces_or_comments` generously when modifying or adding to +the grammar implementation, or parsing may fail in cryptic ways when the input +contains seemingly harmless whitespace or comment characters. + +### GraphQL string literals vs. `JSONSelection` string literals + +Since the `JSONSelection` syntax is meant to be embedded within GraphQL string +literals, and GraphQL shares the same `'...'` and `"..."` string literal syntax +as `JSONSelection`, it can be visually confusing to embed a `JSONSelection` +string literal (denoted by the `StringLiteral` non-terminal) within a GraphQL +string. + +Fortunately, GraphQL also supports multi-line string literals, delimited by +triple quotes (`"""` or `'''`), which allow using single- or double-quoted +`JSONSelection` string literals freely within the GraphQL string, along with +newlines and `#`-style comments. + +While it can be convenient to write short `JSONSelection` strings inline using +`"` or `'` quotes at the GraphQL level, multi-line string literals are strongly +recommended (with comments!) for any `JSONSelection` string that would overflow +the margin of a typical text editor. + +## Rule-by-rule grammar explanation + +This section discusses each non-terminal production in the `JSONSelection` +grammar, using a visual representation of the EBNF syntax called "railroad +diagrams" to illustrate the possible expansions of each rule. In case you need +to generate new diagrams or regenerate existing ones, you can use [this online +generator](https://rr.red-dove.com/ui), whose source code is available +[here](https://github.com/GuntherRademacher/rr). + +The railroad metaphor comes from the way you read the diagram: start at the ▶▶ +arrows on the far left side, and proceed along any path a train could take +(without reversing) until you reach the ▶◀ arrows on the far right side. +Whenever your "train" stops at a non-terminal node, recursively repeat the +process using the diagram for that non-terminal. When you reach a terminal +token, the input must match that token at the current position to proceed. If +you get stuck, restart from the last untaken branch. The input is considered +valid if you can reach the end of the original diagram, and invalid if you +exhaust all possible alternatives without reaching the end. + +I like to think every stop along the railroad has a gift shop and restrooms, so +feel free to take your time and enjoy the journey. + +### `JSONSelection ::=` + +![JSONSelection](./grammar/JSONSelection.svg) + +The `JSONSelection` non-terminal is the top-level entry point for the grammar, +and appears nowhere else within the rest of the grammar. It can be either a +`NakedSubSelection` (for selecting multiple named items) or a `PathSelection` +(for selecting a single anonymous value from a given path). When the +`PathSelection` option is chosen at this level, the entire `JSONSelection` must +be that single path, without any other named selections. + +### `NakedSubSelection ::=` + +![NakedSubSelection](./grammar/NakedSubSelection.svg) + +A `NakedSubSelection` is a `SubSelection` without the surrounding `{` and `}` +braces. It can appear at the top level of a `JSONSelection`, but otherwise +appears only as part of the `SubSelection` rule, meaning it must have braces +everywhere except at the top level. + +Because a `NakedSubSelection` can contain any number of `NamedSelection` items +(including zero), and may have no `StarSelection`, it's possible for the +`NakedSelection` to be fully empty. In these unusual cases, whitespace and +comments are still allowed, and the result of the selection will always be an +empty object. + +In the Rust implementation, there is no dedicated `NakedSubSelection` struct, as +we use the `SubSelection` struct to represent the meaningful contents of the +selection, regardless of whether it has braces. The `NakedSubSelection` +non-terminal is just a grammatical convenience, to avoid repetition between +`JSONSelection` and `SubSelection`. + +### `SubSelection ::=` + +![SubSelection](./grammar/SubSelection.svg) + +A `SubSelection` is a `NakedSubSelection` surrounded by `{` and `}`, and is used +to select specific properties from the preceding object, much like a nested +selection set in a GraphQL operation. + +Note that `SubSelection` may appear recursively within itself, as part of one of +the various `NamedSelection` rules. This recursion allows for arbitrarily deep +nesting of selections, which is necessary to handle complex JSON structures. + +### `NamedSelection ::=` + +![NamedSelection](./grammar/NamedSelection.svg) + +Every possible production of the `NamedSelection` non-terminal corresponds to a +named property in the output object, though each one obtains its value from the +input object in a slightly different way. + +### `NamedFieldSelection ::=` + +![NamedFieldSelection](./grammar/NamedFieldSelection.svg) + +The `NamedFieldSelection` non-terminal is the option most closely resembling +GraphQL field selections, where the field name must be an `Identifier`, may have +an `Alias`, and may have a `SubSelection` to select nested properties (which +requires the field's value to be an object rather than a scalar). + +In practice, whitespace is often required to keep multiple consecutive +`NamedFieldSelection` identifiers separate, but is not strictly necessary when +there is no ambiguity, as when an identifier follows a preceding subselection: +`a{b}c`. + +### `NamedQuotedSelection ::=` + +![NamedQuotedSelection](./grammar/NamedQuotedSelection.svg) + +Since arbitrary JSON objects can have properties that are not identifiers, we +need a version of `NamedFieldSelection` that allows for quoted property names as +opposed to identifiers. + +However, since our goal is always to produce an output that is safe for GraphQL +consumption, an `Alias` is strictly required in this case, and it must be a +valid GraphQL `Identifier`: + +```graphql +first +second: "second property" { x y z } +third { a b } +``` + +Besides extracting the `first` and `third` fields in typical GraphQL fashion, +this selection extracts the `second property` field as `second`, subselecting +`x`, `y`, and `z` from the extracted object. The final object will have the +properties `first`, `second`, and `third`. + +### `NamedPathSelection ::=` + +![NamedPathSelection](./grammar/NamedPathSelection.svg) + +Since `PathSelection` returns an anonymous value extracted from the given path, +if you want to use a `PathSelection` alongside other `NamedSelection` items, you +have to prefix it with an `Alias`, turning it into a `NamedPathSelection`. + +For example, you cannot omit the `pathName:` alias in the following +`NakedSubSelection`, because `some.nested.path` has no output name by itself: + +```graphql +position { x y } +pathName: some.nested.path { a b c } +scalarField +``` + +### `NamedGroupSelection ::=` + +![NamedGroupSelection](./grammar/NamedGroupSelection.svg) + +Sometimes you will need to take a group of named properties and nest them under +a new name in the output object. The `NamedGroupSelection` syntax allows you to +provide an `Alias` followed by a `SubSelection` that contains the named +properties to be grouped. The `Alias` is mandatory because the grouped object +would otherwise be anonymous. + +For example, if the input JSON has `firstName` and `lastName` fields, but you +want to represent them under a single `names` field in the output object, you +could use the following `NamedGroupSelection`: + +```graphql +names: { + first: firstName + last: lastName +} +# Also allowed: +firstName +lastName +``` + +A common use case for `NamedGroupSelection` is to create nested objects from +scalar ID fields: + +```graphql +postID +title +author: { + id: authorID + name: authorName +} +``` + +This convention is useful when the `Author` type is an entity with `@key(fields: +"id")`, and you want to select fields from `post` and `post.author` in the same +query, without directly handling the `post.authorID` field in GraphQL. + +### `Alias ::=` + +![Alias](./grammar/Alias.svg) + +Analogous to a GraphQL alias, the `Alias` syntax allows for renaming properties +from the input JSON to match the desired output shape. + +In addition to renaming, `Alias` can provide names to otherwise anonymous +structures, such as those selected by `PathSelection`, `NamedGroupSelection`, or +`StarSelection` syntax. + +Because we always want to generate GraphQL-safe output properties, an `Alias` +must be a valid GraphQL identifier, rather than a quoted string. + +### `PathSelection ::=` + +![PathSelection](./grammar/PathSelection.svg) + +A `PathSelection` is a `VarPath` or `KeyPath` followed by an optional +`SubSelection`. The purpose of a `PathSelection` is to extract a single +anonymous value from the input JSON, without preserving the nested structure of +the keys along the path. + +Since properties along the path may be either `Identifier` or `StringLiteral` +values, you are not limited to selecting only properties that are valid GraphQL +field names, e.g. `myID: people."Ben Newman".id`. This is a slight departure +from JavaScript syntax, which would use `people["Ben Newman"].id` to achieve the +same result. Using `.` for all steps along the path is more consistent, and +aligns with the goal of keeping all property names statically analyzable, since +it does not suggest dynamic properties like `people[$name].id` are allowed. + +Often, the whole `JSONSelection` string serves as a `PathSelection`, in cases +where you want to extract a single nested value from the input JSON, without +selecting any other named properties: + +```graphql +type Query { + authorName(isbn: ID!): String @connect( + source: "BOOKS" + http: { GET: "/books/{$args.isbn}"} + selection: "author.name" + ) +} +``` + +If you need to select other named properties, you can still use a +`PathSelection` as part of a `NakedSubSelection`, as long as you give it an +`Alias`: + +```graphql +type Query { + book(isbn: ID!): Book @connect( + source: "BOOKS" + http: { GET: "/books/{$args.isbn}"} + selection: """ + title + year: publication.year + authorName: author.name + """ + ) +} +``` + +### `VarPath ::=` + +![VarPath](./grammar/VarPath.svg) + +A `VarPath` is a `PathSelection` that begins with a `$variable` reference, which +allows embedding arbitrary variables and their sub-properties within the output +object, rather than always selecting a property from the input object. The +`variable` part must be an `Identifier`, and must not be separated from the `$` +by whitespace. + +In the Rust implementation, input variables are passed as JSON to the +`apply_with_vars` method of the `ApplyTo` trait, providing additional context +besides the input JSON. Unlike GraphQL, the provided variables do not all have +to be consumed, since variables like `$this` may have many more possible keys +than you actually want to use. + +Variable references are especially useful when you want to refer to field +arguments (like `$args.some.arg` or `$args { x y }`) or sibling fields of the +current GraphQL object (like `$this.sibling` or `sibs: $this { brother sister +}`). + +Injecting a known argument value comes in handy when your REST endpoint does not +return the property you need: + +```graphql +type Query { + user(id: ID!): User @connect( + source: "USERS" + http: { GET: "/users/{$args.id}"} + selection: """ + # For some reason /users/{$args.id} returns an object with name + # and email but no id, so we inject the id manually: + id: $args.id + name + email + """ + ) +} + +type User @key(fields: "id") { + id: ID! + name: String + email: String +} +``` + +In addition to variables like `$this` and `$args`, a special `$` variable is +always bound to the current value being processed, which allows you to transform +input data that looks like this + +```json +{ + "id": 123, + "name": "Ben", + "friend_ids": [234, 345, 456] +} +``` + +into output data that looks like this + +```json +{ + "id": 123, + "name": "Ben", + "friends": [ + { "id": 234 }, + { "id": 345 }, + { "id": 456 } + ] +} +``` + +using the following `JSONSelection` string: + +```graphql +id name friends: friend_ids { id: $ } +``` + +Because `friend_ids` is an array, the `{ id: $ }` selection maps over each +element of the array, with `$` taking on the value of each scalar ID in turn. + +The `$` variable is also essential for disambiguating a `KeyPath` consisting of +only one key from a `NamedFieldSelection` with no `Alias`. For example, +`$.result` extracts the `result` property as an anonymous value from the current +object, where as `result` would select an object that still has the `result` +property. + +### `KeyPath ::=` + +![KeyPath](./grammar/KeyPath.svg) + +A `KeyPath` is a `PathSelection` that begins with a `Key` (referring to a +property of the current object) and is followed by a sequence of at least one +`PathStep`, where each `PathStep` either selects a nested key or invokes a `->` +method against the preceding value. + +For example: + +```graphql +items: data.nested.items { id name } +firstItem: data.nested.items->first { id name } +firstItemName: data.nested.items->first.name +``` + +An important ambiguity arises when you want to extract a `PathSelection` +consisting of only a single key, such as `data` by itself. Since there is no `.` +to disambiguate the path from an ordinary `NamedFieldSelection`, the `KeyPath` +rule is inadequate. Instead, you should use a `VarPath` (which also counts as a +`PathSelection`), where the variable is the special `$` character, which +represents the current value being processed: + +```graphql +$.data { id name } +``` + +This will produce a single object with `id` and `name` fields, without the +enclosing `data` property. Equivalently, you could manually unroll this example +to the following `NakedSubSelection`: + +```graphql +id: data.id +name: data.name +``` + +In this case, the `$.` is no longer necessary because `data.id` and `data.name` +are unambiguously `KeyPath` selections. + +> For backwards compatibility with earlier versions of the `JSONSelection` +syntax that did not support the `$` variable, you can also use a leading `.` +character (so `.data { id name }`, or even `.data.id` or `.data.name`) to mean +the same thing as `$.`, but this is no longer recommended, since `.data` is easy +to mistype and misread, compared to `$.data`. + +### `PathStep ::=` + +![PathStep](./grammar/PathStep.svg) + +A `PathStep` is a single step along a `VarPath` or `KeyPath`, which can either +select a nested key using `.` or invoke a method using `->`. + +Keys selected using `.` can be either `Identifier` or `StringLiteral` names, but +method names invoked using `->` must be `Identifier` names, and must be +registered in the `JSONSelection` parser in order to be recognized. + +For the time being, only a fixed set of known methods are supported, though this +list may grow and/or become user-configurable in the future: + +> Full disclosure: even this list is still aspirational, but suggestive of the +> kinds of methods that are likely to be supported in the next version of the +> `JSONSelection` parser. + +```graphql +list->first { id name } +list->last.name +list->slice($args.start, $args.end) +list->reverse +some.value->times(2) +some.value->plus($addend) +some.value->minus(100) +some.value->div($divisor) +isDog: kind->eq("dog") +isNotCat: kind->neq("cat") +__typename: kind->match({ "dog": "Dog", "cat": "Cat" }) +decoded: utf8Bytes->decode("utf-8") +utf8Bytes: string->encode("utf-8") +encoded: bytes->encode("base64") +``` + +### `MethodArgs ::=` + +![MethodArgs](./grammar/MethodArgs.svg) + +When a `PathStep` invokes an `->operator` method, the method invocation may +optionally take a sequence of comma-separated `JSLiteral` arguments in +parentheses, as in `list->slice(0, 5)` or `kilometers: miles->times(1.60934)`. + +Methods do not have to take arguments, as in `list->first` or `list->last`, +which is why `MethodArgs` is optional in `PathStep`. + +### `Key ::=` + +![Key](./grammar/Key.svg) + +A property name occurring along a dotted `PathSelection`, either an `Identifier` +or a `StringLiteral`. + +### `Identifier ::=` + +![Identifier](./grammar/Identifier.svg) + +Any valid GraphQL field name. If you need to select a property that is not +allowed by this rule, use a `NamedQuotedSelection` instead. + +In some languages, identifiers can include `$` characters, but `JSONSelection` +syntax aims to match GraphQL grammar, which does not allow `$` in field names. +Instead, the `$` is reserved for denoting variables in `VarPath` selections. + +### `StringLiteral ::=` + +![StringLiteral](./grammar/StringLiteral.svg) + +A string literal that can be single-quoted or double-quoted, and may contain any +characters except the quote character that delimits the string. The backslash +character `\` can be used to escape the quote character within the string. + +Note that the `\\'` and `\\"` tokens correspond to character sequences +consisting of two characters: a literal backslash `\` followed by a single quote +`'` or double quote `"` character, respectively. The double backslash is +important so the backslash can stand alone, without escaping the quote +character. + +You can avoid most of the headaches of escaping by choosing your outer quote +characters wisely. If your string contains many double quotes, use single quotes +to delimit the string, and vice versa, as in JavaScript. + +### `JSLiteral ::=` + +![JSLiteral](./grammar/JSLiteral.svg) + +A `JSLiteral` represents a JSON-like value that can be passed inline as part of +`MethodArgs`. + +The `JSLiteral` mini-language diverges from JSON by allowing symbolic +`PathSelection` values (which may refer to variables or fields) in addition to +the usual JSON primitives. This allows `->` methods to be parameterized in +powerful ways, e.g. `page: list->slice(0, $limit)`. + +Also, as a minor syntactic convenience, `JSObject` literals can have +`Identifier` or `StringLiteral` keys, whereas JSON objects can have only +double-quoted string literal keys. + +### `JSPrimitive ::=` + +![JSPrimitive](./grammar/JSPrimitive.svg) + +Analogous to a JSON primitive value, with the only differences being that +`JSNumber` does not currently support the exponential syntax, and +`StringLiteral` values can be single-quoted as well as double-quoted. + +### `JSNumber ::=` + +![JSNumber](./grammar/JSNumber.svg) + +A numeric literal that is possibly negative and may contain a fractional +component. The integer component is required unless a fractional component is +present, and the fractional component can have zero digits when there the +integer component is present (as in `-123.`), but the fractional component must +have at least one digit when there is no integer component, since `.` is not a +valid numeric literal by itself. Leading and trailing zeroes are essential for +the fractional component, but leading zeroes are disallowed for the integer +component, except when the integer component is exactly zero. + +### `UnsignedInt ::=` + +![UnsignedInt](./grammar/UnsignedInt.svg) + +The integer component of a `JSNumber`, which must be either `0` or an integer +without any leading zeroes. + +### `JSObject ::=` + +![JSObject](./grammar/JSObject.svg) + +A sequence of `JSProperty` items within curly braces, as in JavaScript. + +Trailing commas are not currently allowed, but could be supported in the future. + +### `JSProperty ::=` + +![JSProperty](./grammar/JSProperty.svg) + +A key-value pair within a `JSObject`. Note that the `Key` may be either an +`Identifier` or a `StringLiteral`, as in JavaScript. This is a little different +from JSON, which allows double-quoted strings only. + +### `JSArray ::=` + +![JSArray](./grammar/JSArray.svg) + +A list of `JSLiteral` items within square brackets, as in JavaScript. + +Trailing commas are not currently allowed, but could be supported in the future. + +### `StarSelection ::=` + +![StarSelection](./grammar/StarSelection.svg) + +The `StarSelection` non-terminal is uncommon when working with GraphQL, since it +selects all remaining properties of an object, which can be difficult to +represent using static GraphQL types, without resorting to the catch-all `JSON` +scalar type. Still, a `StarSelection` can be useful for consuming JSON +dictionaries with dynamic keys, or for capturing unexpected properties for +debugging purposes. + +When used, a `StarSelection` must come after any `NamedSelection` items within a +given `NakedSubSelection`. + +A common use case for `StarSelection` is capturing all properties not otherwise +selected using a field called `allOtherFields`, which must have a generic `JSON` +type in the GraphQL schema: + +```graphql +knownField +anotherKnownField +allOtherFields: * +``` + +Note that `knownField` and `anotherKnownField` will not be included in the +`allOtherFields` output, since they are selected explicitly. In this sense, the +`*` functions a bit like object `...rest` syntax in JavaScript. + +If you happen to know these other fields all have certain properties, you can +restrict the `*` selection to just those properties: + +```graphql +knownField { id name } +allOtherFields: * { id } +``` + +Sometimes a REST API will return a dictionary result with an unknown set of +dynamic keys but values of some known type, such as a map of ISBN numbers to +`Book` objects: + +```graphql +booksByISBN: result.books { * { title author { name } } +``` + +Because the set of ISBN numbers is statically unknowable, the type of +`booksByISBN` would have to be `JSON` in the GraphQL schema, but it can still be +useful to select known properties from the `Book` objects within the +`result.books` dictionary, so you don't return more GraphQL data than necessary. + +The grammar technically allows a `StarSelection` with neither an `Alias` nor a +`SubSelection`, but this is not a useful construct from a GraphQL perspective, +since it provides no output fields that can be reliably typed by a GraphQL +schema. This form has some use cases when working with `JSONSelection` outside +of GraphQL, but they are not relevant here. + +### `NO_SPACE ::= !SpacesOrComments` + +The `NO_SPACE` non-terminal is used to enforce the absence of whitespace or +comments between certain tokens. See [Whitespace, comments, and +`NO_SPACE`](#whitespace-comments-and-no_space) for more information. There is no +diagram for this rule because the `!` negative lookahead operator is not +supported by the railroad diagram generator. + +### `SpacesOrComments ::=` + +![SpacesOrComments](./grammar/SpacesOrComments.svg) + +A run of either whitespace or comments involving at least one character, which +are handled equivalently (ignored) by the parser. + +### `Spaces ::=` + +![Spaces](./grammar/Spaces.svg) + +A run of at least one whitespace character, including spaces, tabs, carriage +returns, and newlines. + +Note that we generally allow any amount of whitespace between tokens, so the +`Spaces` non-terminal is not explicitly used in most places where whitespace is +allowed, though it could be used to enforce the presence of some whitespace, if +desired. + +### `Comment ::=` + +![Comment](./grammar/Comment.svg) + +A `#` character followed by any number of characters up to the next newline +character. Comments are allowed anywhere whitespace is allowed, and are handled +like whitespace (i.e. ignored) by the parser. + +## FAQ + +### What about arrays? + +As with standard GraphQL operation syntax, there is no explicit representation +of array-valued fields in this grammar, but (as with GraphQL) a `SubSelection` +following an array-valued field or `PathSelection` will be automatically applied +to every element of the array, thereby preserving/mapping/sub-selecting the +array structure. + +Conveniently, this handling of arrays also makes sense within dotted +`PathSelection` elements, which do not exist in GraphQL. Consider the following +selections, assuming the `author` property of the JSON object has an object +value with a child property called `articles` whose value is an array of +`Article` objects, which have `title`, `date`, `byline`, and `author` +properties: + +```graphql +@connect( + selection: "author.articles.title" #1 + selection: "author.articles { title }" #2 + selection: "author.articles { title date }" #3 + selection: "author.articles.byline.place" #4 + selection: "author.articles.byline { place date }" #5 + selection: "author.articles { name: author.name place: byline.place }" #6 + selection: "author.articles { titleDateAlias: { title date } }" #7 +) +``` + +These selections should produce the following result shapes: + +1. an array of `title` strings +2. an array of `{ title }` objects +3. an array of `{ title date }` objects +4. an array of `place` strings +5. an array of `{ place date }` objects +6. an array of `{ name place }` objects +7. an array of `{ titleDateAlias }` objects + +If the `author.articles` value happened not to be an array, this syntax would +resolve a single result in each case, instead of an array, but the +`JSONSelection` syntax would not have to change to accommodate this possibility. + +If the top-level JSON input itself is an array, then the whole `JSONSelection` +will be applied to each element of that array, and the result will be an array +of those results. + +Compared to dealing explicitly with hard-coded array indices, this automatic +array mapping behavior is much easier to reason about, once you get the hang of +it. If you're familiar with how arrays are handled during GraphQL execution, +it's essentially the same principle, extended to the additional syntaxes +introduced by `JSONSelection`. + +### Why a string-based syntax, rather than first-class syntax? + +### What about field argument syntax? + +### What future `JSONSelection` syntax is under consideration? diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg new file mode 100644 index 0000000000..5c2a8db39b --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Alias.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + Identifier + + + + : + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg new file mode 100644 index 0000000000..a28134cc17 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Comment.svg @@ -0,0 +1,49 @@ + + + + + + + + + + # + + + [^\n] + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg new file mode 100644 index 0000000000..03a7bb0abf --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Identifier.svg @@ -0,0 +1,76 @@ + + + + + + + + + + [a-z] + + + [A-Z] + + + _ + + + + NO_SPACE + + + + [0-9] + + + [a-z] + + + [A-Z] + + + _ + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg new file mode 100644 index 0000000000..cd7d51ed75 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/JSArray.svg @@ -0,0 +1,69 @@ + + + + + + + + + + [ + + + + JSLiteral + + + + , + + + ] + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg new file mode 100644 index 0000000000..74a592e7e7 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/JSLiteral.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + JSPrimitive + + + + + JSObject + + + + + JSArray + + + + + PathSelection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg new file mode 100644 index 0000000000..413b1fe835 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/JSNumber.svg @@ -0,0 +1,75 @@ + + + + + + + + + + - + + + + UnsignedInt + + + + . + + + [0-9] + + + . + + + [0-9] + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg new file mode 100644 index 0000000000..30007672d3 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + NakedSubselection + + + + + PathSelection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg new file mode 100644 index 0000000000..d305abdacd --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/JSObject.svg @@ -0,0 +1,69 @@ + + + + + + + + + + { + + + + JSProperty + + + + , + + + } + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg new file mode 100644 index 0000000000..1b3d452739 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/JSPrimitive.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + StringLiteral + + + + + JSNumber + + + + true + + + false + + + null + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSProperty.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSProperty.svg new file mode 100644 index 0000000000..320035e1e4 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/JSProperty.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + Key + + + + : + + + + JSLiteral + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Key.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Key.svg new file mode 100644 index 0000000000..a41054011a --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Key.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + Identifier + + + + + StringLiteral + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg b/apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg new file mode 100644 index 0000000000..191de27c46 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/KeyPath.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + Key + + + + + PathStep + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg b/apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg new file mode 100644 index 0000000000..c000e67e4a --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/MethodArgs.svg @@ -0,0 +1,69 @@ + + + + + + + + + + ( + + + + JSLiteral + + + + , + + + ) + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NakedSubselection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NakedSubselection.svg new file mode 100644 index 0000000000..c7ec2b04a5 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NakedSubselection.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + NamedSelection + + + + + StarSelection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg new file mode 100644 index 0000000000..d38a92944a --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedFieldSelection.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + Alias + + + + + Identifier + + + + + Subselection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg new file mode 100644 index 0000000000..28aabf9daf --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + Alias + + + + + Subselection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg new file mode 100644 index 0000000000..c00795281d --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedPathSelection.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + Alias + + + + + PathSelection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg new file mode 100644 index 0000000000..212ec3b111 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + Alias + + + + + StringLiteral + + + + + Subselection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg new file mode 100644 index 0000000000..5ee79391fb --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedSelection.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + NamedFieldSelection + + + + + NamedQuotedSelection + + + + + NamedPathSelection + + + + + NamedGroupSelection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg new file mode 100644 index 0000000000..a8bbcfa34d --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + VarPath + + + + + KeyPath + + + + + Subselection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg b/apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg new file mode 100644 index 0000000000..299a06d8cc --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/PathStep.svg @@ -0,0 +1,75 @@ + + + + + + + + + + . + + + + Key + + + + -> + + + + Identifier + + + + + MethodArgs + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg new file mode 100644 index 0000000000..a08f826870 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Spaces.svg @@ -0,0 +1,70 @@ + + + + + + + + + + ⎵ + + + \t + + + \r + + + \n + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg b/apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg new file mode 100644 index 0000000000..2e9c815c1d --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/SpacesOrComments.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + Spaces + + + + + Comment + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg new file mode 100644 index 0000000000..c57b6bb968 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + Alias + + + + * + + + + Subselection + + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg b/apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg new file mode 100644 index 0000000000..229fe4e594 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/StringLiteral.svg @@ -0,0 +1,92 @@ + + + + + + + + + + ' + + + \\' + + + [^'] + + + ' + + + " + + + \\" + + + [^"] + + + " + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Subselection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/Subselection.svg new file mode 100644 index 0000000000..c5d2e4db29 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/Subselection.svg @@ -0,0 +1,61 @@ + + + + + + + + + + { + + + + NakedSubselection + + + + } + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg b/apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg new file mode 100644 index 0000000000..3d8fdde47e --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/UnsignedInt.svg @@ -0,0 +1,59 @@ + + + + + + + + + + [1-9] + + + + NO_SPACE + + + + [0-9] + + + 0 + + + + diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg b/apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg new file mode 100644 index 0000000000..0017afb15f --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/grammar/VarPath.svg @@ -0,0 +1,67 @@ + + + + + + + + + + $ + + + + NO_SPACE + + + + + Identifier + + + + + PathStep + + + + + From 1f2b9b15fe698d5c0f89117164f3dc36212a5655 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 7 May 2024 10:54:14 -0400 Subject: [PATCH 06/12] Rename Selection to JSONSelection for clarity/consistency. --- .../connect/federated_query_graph/builder.rs | 10 ++--- .../connect/federated_query_graph/mod.rs | 6 +-- .../connect/fetch_dependency_graph/mod.rs | 8 ++-- .../connect/json_selection/apply_to.rs | 21 ++++++----- .../sources/connect/json_selection/graphql.rs | 37 +++++++++---------- .../sources/connect/json_selection/helpers.rs | 2 +- .../sources/connect/json_selection/parser.rs | 36 +++++++++--------- apollo-federation/src/sources/connect/mod.rs | 4 +- .../src/sources/connect/models/mod.rs | 6 +-- .../src/sources/connect/query_plan/mod.rs | 4 +- .../src/sources/connect/spec/directives.rs | 6 +-- .../src/sources/connect/spec/schema.rs | 6 +-- .../src/plugins/connectors/connector.rs | 2 +- .../src/plugins/connectors/directives.rs | 2 +- apollo-router/src/plugins/connectors/fetch.rs | 4 +- .../plugins/connectors/http_json_transport.rs | 2 +- .../plugins/connectors/request_response.rs | 2 +- 17 files changed, 80 insertions(+), 78 deletions(-) diff --git a/apollo-federation/src/sources/connect/federated_query_graph/builder.rs b/apollo-federation/src/sources/connect/federated_query_graph/builder.rs index 95dc4da790..c4774050e8 100644 --- a/apollo-federation/src/sources/connect/federated_query_graph/builder.rs +++ b/apollo-federation/src/sources/connect/federated_query_graph/builder.rs @@ -14,10 +14,10 @@ use crate::sources::connect::federated_query_graph::ConcreteNode; use crate::sources::connect::federated_query_graph::EnumNode; use crate::sources::connect::federated_query_graph::ScalarNode; use crate::sources::connect::federated_query_graph::SourceEnteringEdge; -use crate::sources::connect::models::Connector; +use crate::sources::connect::json_selection::JSONSelection; use crate::sources::connect::json_selection::Property; use crate::sources::connect::json_selection::SubSelection; -use crate::sources::connect::Selection; +use crate::sources::connect::models::Connector; use crate::sources::source::federated_query_graph::builder::FederatedQueryGraphBuilderApi; use crate::sources::source::SourceId; use crate::ValidFederationSubgraph; @@ -106,7 +106,7 @@ impl FederatedQueryGraphBuilderApi for FederatedQueryGraphBuilder { /// This method creates nodes from selection parameters of a field decorated by /// a connect directive, making sure to reuse nodes if possible. fn process_selection( - selection: Selection, + selection: JSONSelection, field_output_type_pos: TypeDefinitionPosition, subgraph_schema: &ValidFederationSchema, builder: &mut impl IntraSourceQueryGraphBuilderApi, @@ -137,7 +137,7 @@ fn process_selection( // If we aren't a custom scalar, then look at the selection to see what to attempt match selection { - Selection::Path(path) => match field_output_type_pos { + JSONSelection::Path(path) => match field_output_type_pos { TypeDefinitionPosition::Enum(enum_type) => { // Create the node for this enum builder.add_enum_node( @@ -179,7 +179,7 @@ fn process_selection( ) } }, - Selection::Named(sub) => { + JSONSelection::Named(sub) => { // Make sure that we aren't selecting sub fields from simple types if field_ty.is_scalar() || field_ty.is_enum() { return Err(FederationError::internal( diff --git a/apollo-federation/src/sources/connect/federated_query_graph/mod.rs b/apollo-federation/src/sources/connect/federated_query_graph/mod.rs index 604386c84c..fca3629097 100644 --- a/apollo-federation/src/sources/connect/federated_query_graph/mod.rs +++ b/apollo-federation/src/sources/connect/federated_query_graph/mod.rs @@ -6,10 +6,10 @@ use crate::schema::position::EnumTypeDefinitionPosition; use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::ScalarTypeDefinitionPosition; use crate::schema::ObjectFieldDefinitionPosition; +use crate::sources::connect::json_selection::JSONSelection; use crate::sources::connect::json_selection::PathSelection; use crate::sources::connect::json_selection::Property; -use crate::sources::connect::Selection; -use crate::sources::connect::SubSelection; +use crate::sources::connect::json_selection::SubSelection; use crate::sources::source; use crate::sources::source::federated_query_graph::FederatedQueryGraphApi; use crate::sources::source::SourceId; @@ -68,7 +68,7 @@ pub(crate) enum ScalarNode { }, CustomScalarSelectionRoot { subgraph_type: ScalarTypeDefinitionPosition, - selection: Selection, + selection: JSONSelection, }, SelectionChild { subgraph_type: ScalarTypeDefinitionPosition, diff --git a/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs b/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs index c57bb14b58..67d036f49b 100644 --- a/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs +++ b/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs @@ -16,10 +16,10 @@ use crate::source_aware::federated_query_graph::FederatedQueryGraph; use crate::source_aware::federated_query_graph::SelfConditionIndex; use crate::source_aware::query_plan::FetchDataPathElement; use crate::source_aware::query_plan::QueryPlanCost; +use crate::sources::connect::json_selection::JSONSelection; use crate::sources::connect::json_selection::PathSelection; use crate::sources::connect::json_selection::Property; -use crate::sources::connect::Selection; -use crate::sources::connect::SubSelection; +use crate::sources::connect::json_selection::SubSelection; use crate::sources::source; use crate::sources::source::fetch_dependency_graph::FetchDependencyGraphApi; use crate::sources::source::fetch_dependency_graph::PathApi; @@ -124,7 +124,7 @@ pub(crate) struct Node { source_entering_edge: EdgeIndex, field_response_name: Name, field_arguments: IndexMap, - selection: Selection, + selection: JSONSelection, } #[derive(Debug)] @@ -150,7 +150,7 @@ pub(crate) enum PathSelections { tail_selection: Option<(Name, PathTailSelection)>, }, CustomScalarRoot { - selection: Selection, + selection: JSONSelection, }, } diff --git a/apollo-federation/src/sources/connect/json_selection/apply_to.rs b/apollo-federation/src/sources/connect/json_selection/apply_to.rs index 1b5137b7b9..f9979e8cbd 100644 --- a/apollo-federation/src/sources/connect/json_selection/apply_to.rs +++ b/apollo-federation/src/sources/connect/json_selection/apply_to.rs @@ -1,4 +1,4 @@ -/// ApplyTo is a trait for applying a Selection to a JSON value, collecting +/// ApplyTo is a trait for applying a JSONSelection to a JSON value, collecting /// any/all errors encountered in the process. use std::hash::Hash; use std::hash::Hasher; @@ -27,7 +27,7 @@ pub trait ApplyTo { } // This is the trait method that should be implemented and called - // recursively by the various Selection types. + // recursively by the various JSONSelection types. fn apply_to_path( &self, data: &JSON, @@ -125,7 +125,7 @@ impl ApplyToError { } } -impl ApplyTo for Selection { +impl ApplyTo for JSONSelection { fn apply_to_path( &self, data: &JSON, @@ -142,11 +142,12 @@ impl ApplyTo for Selection { }; match self { - // Because we represent a Selection::Named as a SubSelection, we can - // fully delegate apply_to_path to SubSelection::apply_to_path. Even - // if we represented Self::Named as a Vec, we could - // still delegate to SubSelection::apply_to_path, but we would need - // to create a temporary SubSelection to wrap the selections Vec. + // Because we represent a JSONSelection::Named as a SubSelection, we + // can fully delegate apply_to_path to SubSelection::apply_to_path. + // Even if we represented Self::Named as a Vec, we + // could still delegate to SubSelection::apply_to_path, but we would + // need to create a temporary SubSelection to wrap the selections + // Vec. Self::Named(named_selections) => { named_selections.apply_to_path(data, input_path, errors) } @@ -430,7 +431,7 @@ mod tests { ], }); - let check_ok = |selection: Selection, expected_json: JSON| { + let check_ok = |selection: JSONSelection, expected_json: JSON| { let (actual_json, errors) = selection.apply_to(&data); assert_eq!(actual_json, Some(expected_json)); assert_eq!(errors, vec![]); @@ -573,7 +574,7 @@ mod tests { } }); - let check_ok = |selection: Selection, expected_json: JSON| { + let check_ok = |selection: JSONSelection, expected_json: JSON| { let (actual_json, errors) = selection.apply_to(&data); assert_eq!(actual_json, Some(expected_json)); assert_eq!(errors, vec![]); diff --git a/apollo-federation/src/sources/connect/json_selection/graphql.rs b/apollo-federation/src/sources/connect/json_selection/graphql.rs index fc1d6cdedf..331767b1e1 100644 --- a/apollo-federation/src/sources/connect/json_selection/graphql.rs +++ b/apollo-federation/src/sources/connect/json_selection/graphql.rs @@ -1,25 +1,24 @@ -// The Selection syntax is intended to be more generic than GraphQL, -// capable of transforming any aribitrary JSON in arbitrary ways, -// without assuming the universal availability of __typename or other -// convenient GraphQL-isms. However, since we are using the Selection -// syntax to generate GraphQL-shaped output JSON, it's helpful to have -// some GraphQL-specific utilities. +// The JSONSelection syntax is intended to be more generic than GraphQL, capable +// of transforming any aribitrary JSON in arbitrary ways, without assuming the +// universal availability of __typename or other convenient GraphQL-isms. +// However, since we are using the JSONSelection syntax to generate +// GraphQL-shaped output JSON, it's helpful to have some GraphQL-specific +// utilities. // -// This file contains several trait implementations that allow -// converting from the Selection type to a corresponding GraphQL -// selection set, where (for example) PathSelection syntax is expanded -// to ordinary nested selection sets. The resulting JSON will retain the -// nested structure of the GraphQL selection sets, and thus be more -// verbose than the output of the Selection syntax, but may be easier to -// use for validating the selection against a GraphQL schema, using -// existing code for validating GraphQL operations. +// This file contains several trait implementations that allow converting from +// the JSONSelection type to a corresponding GraphQL selection set, where (for +// example) PathSelection syntax is expanded to ordinary nested selection sets. +// The resulting JSON will retain the nested structure of the GraphQL selection +// sets, and thus be more verbose than the output of the JSONSelection syntax, +// but may be easier to use for validating the selection against a GraphQL +// schema, using existing code for validating GraphQL operations. use apollo_compiler::ast; use apollo_compiler::ast::Selection as GraphQLSelection; +use super::parser::JSONSelection; use super::parser::NamedSelection; use super::parser::PathSelection; -use super::parser::Selection; use super::parser::StarSelection; use super::parser::SubSelection; @@ -38,13 +37,13 @@ impl From> for GraphQLSelections { } } -impl From for Vec { - fn from(val: Selection) -> Vec { +impl From for Vec { + fn from(val: JSONSelection) -> Vec { match val { - Selection::Named(named_selections) => { + JSONSelection::Named(named_selections) => { GraphQLSelections::from(named_selections).valid_selections() } - Selection::Path(path_selection) => path_selection.into(), + JSONSelection::Path(path_selection) => path_selection.into(), } } } diff --git a/apollo-federation/src/sources/connect/json_selection/helpers.rs b/apollo-federation/src/sources/connect/json_selection/helpers.rs index c9f78aeb65..e811188a82 100644 --- a/apollo-federation/src/sources/connect/json_selection/helpers.rs +++ b/apollo-federation/src/sources/connect/json_selection/helpers.rs @@ -10,7 +10,7 @@ use serde_json_bytes::Value as JSON; macro_rules! selection { ($input:expr) => { if let Ok((remainder, parsed)) = - $crate::sources::connect::json_selection::Selection::parse($input) + $crate::sources::connect::json_selection::JSONSelection::parse($input) { assert_eq!(remainder, ""); parsed diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs index 219a29aa21..adb5c130ca 100644 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -17,18 +17,18 @@ use serde::Serialize; use super::helpers::spaces_or_comments; -// Selection ::= NamedSelection* StarSelection? | PathSelection +// JSONSelection ::= NamedSelection* StarSelection? | PathSelection #[derive(Debug, PartialEq, Clone, Serialize)] -pub enum Selection { - // Although we reuse the SubSelection type for the Selection::Named case, we - // parse it as a sequence of NamedSelection items without the {...} curly - // braces that SubSelection::parse expects. +pub enum JSONSelection { + // Although we reuse the SubSelection type for the JSONSelection::Named + // case, we parse it as a sequence of NamedSelection items without the + // {...} curly braces that SubSelection::parse expects. Named(SubSelection), Path(PathSelection), } -impl Selection { +impl JSONSelection { pub fn parse(input: &str) -> IResult<&str, Self> { alt(( all_consuming(map( @@ -479,7 +479,7 @@ mod tests { assert_eq!(actual.unwrap().1.name(), name); assert_eq!( selection!(input), - Selection::Named(SubSelection { + JSONSelection::Named(SubSelection { selections: vec![expected], star: None, }), @@ -594,7 +594,7 @@ mod tests { fn test_selection() { assert_eq!( selection!(""), - Selection::Named(SubSelection { + JSONSelection::Named(SubSelection { selections: vec![], star: None, }), @@ -602,7 +602,7 @@ mod tests { assert_eq!( selection!(" "), - Selection::Named(SubSelection { + JSONSelection::Named(SubSelection { selections: vec![], star: None, }), @@ -610,7 +610,7 @@ mod tests { assert_eq!( selection!("hello"), - Selection::Named(SubSelection { + JSONSelection::Named(SubSelection { selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], star: None, }), @@ -618,7 +618,7 @@ mod tests { assert_eq!( selection!(".hello"), - Selection::Path(PathSelection::from_slice( + JSONSelection::Path(PathSelection::from_slice( &[Property::Field("hello".to_string()),], None )), @@ -626,7 +626,7 @@ mod tests { assert_eq!( selection!("hi: .hello.world"), - Selection::Named(SubSelection { + JSONSelection::Named(SubSelection { selections: vec![NamedSelection::Path( Alias { name: "hi".to_string(), @@ -645,7 +645,7 @@ mod tests { assert_eq!( selection!("before hi: .hello.world after"), - Selection::Named(SubSelection { + JSONSelection::Named(SubSelection { selections: vec![ NamedSelection::Field(None, "before".to_string(), None), NamedSelection::Path( @@ -666,7 +666,7 @@ mod tests { }), ); - let before_path_nested_after_result = Selection::Named(SubSelection { + let before_path_nested_after_result = JSONSelection::Named(SubSelection { selections: vec![ NamedSelection::Field(None, "before".to_string(), None), NamedSelection::Path( @@ -723,7 +723,7 @@ mod tests { siblingGroup: { brother sister } }" ), - Selection::Named(SubSelection { + JSONSelection::Named(SubSelection { selections: vec![NamedSelection::Field( Some(Alias { name: "topLevelAlias".to_string(), @@ -793,7 +793,7 @@ mod tests { fn test_path_selection() { fn check_path_selection(input: &str, expected: PathSelection) { assert_eq!(PathSelection::parse(input), Ok(("", expected.clone()))); - assert_eq!(selection!(input), Selection::Path(expected.clone())); + assert_eq!(selection!(input), JSONSelection::Path(expected.clone())); } check_path_selection( @@ -1025,7 +1025,7 @@ mod tests { assert_eq!( selection!(" before alias: * { * { a b c } } "), - Selection::Named(SubSelection { + JSONSelection::Named(SubSelection { selections: vec![NamedSelection::Field(None, "before".to_string(), None),], star: Some(StarSelection( Some(Alias { @@ -1051,7 +1051,7 @@ mod tests { assert_eq!( selection!(" before group: { * { a b c } } after "), - Selection::Named(SubSelection { + JSONSelection::Named(SubSelection { selections: vec![ NamedSelection::Field(None, "before".to_string(), None), NamedSelection::Group( diff --git a/apollo-federation/src/sources/connect/mod.rs b/apollo-federation/src/sources/connect/mod.rs index 6f0ba9f45e..3df07e64b7 100644 --- a/apollo-federation/src/sources/connect/mod.rs +++ b/apollo-federation/src/sources/connect/mod.rs @@ -14,7 +14,9 @@ mod url_path_template; pub use json_selection::ApplyTo; pub use json_selection::ApplyToError; -pub use json_selection::Selection; +pub use json_selection::JSONSelection; +pub use json_selection::PathSelection; +pub use json_selection::Property; pub use json_selection::SubSelection; pub(crate) use spec::ConnectSpecDefinition; pub use url_path_template::URLPathTemplate; diff --git a/apollo-federation/src/sources/connect/models/mod.rs b/apollo-federation/src/sources/connect/models/mod.rs index 12d765391b..5da68b00a7 100644 --- a/apollo-federation/src/sources/connect/models/mod.rs +++ b/apollo-federation/src/sources/connect/models/mod.rs @@ -7,7 +7,7 @@ use super::spec::ConnectHTTPArguments; use super::spec::HTTPHeaderOption; use super::spec::SourceHTTPArguments; use super::ConnectId; -use super::Selection; +use super::JSONSelection; use super::URLPathTemplate; use crate::error::FederationError; use crate::schema::ValidFederationSchema; @@ -21,7 +21,7 @@ use crate::sources::connect::ConnectSpecDefinition; pub(crate) struct Connector { pub(crate) id: ConnectId, transport: Transport, - pub(crate) selection: Selection, + pub(crate) selection: JSONSelection, } #[cfg_attr(test, derive(Debug))] @@ -107,7 +107,7 @@ struct HttpJsonTransport { path_template: URLPathTemplate, method: HTTPMethod, headers: IndexMap>, - body: Option, + body: Option, } impl HttpJsonTransport { diff --git a/apollo-federation/src/sources/connect/query_plan/mod.rs b/apollo-federation/src/sources/connect/query_plan/mod.rs index 37646c9d2d..10dabe4ca0 100644 --- a/apollo-federation/src/sources/connect/query_plan/mod.rs +++ b/apollo-federation/src/sources/connect/query_plan/mod.rs @@ -3,7 +3,7 @@ use apollo_compiler::ast::Value; use indexmap::IndexMap; use crate::sources::connect::ConnectId; -use crate::sources::connect::Selection; +use crate::sources::connect::JSONSelection; pub mod query_planner; @@ -12,5 +12,5 @@ pub struct FetchNode { pub source_id: ConnectId, pub field_response_name: Name, pub field_arguments: IndexMap, - pub selection: Selection, + pub selection: JSONSelection, } diff --git a/apollo-federation/src/sources/connect/spec/directives.rs b/apollo-federation/src/sources/connect/spec/directives.rs index 46e895cf1a..7794b172d9 100644 --- a/apollo-federation/src/sources/connect/spec/directives.rs +++ b/apollo-federation/src/sources/connect/spec/directives.rs @@ -28,7 +28,7 @@ use crate::error::FederationError; use crate::schema::position::ObjectOrInterfaceFieldDefinitionPosition; use crate::schema::position::ObjectOrInterfaceFieldDirectivePosition; use crate::schema::FederationSchema; -use crate::sources::connect::json_selection::Selection; +use crate::sources::connect::json_selection::JSONSelection; use crate::sources::connect::spec::schema::CONNECT_HTTP_ARGUMENT_NAME; use crate::sources::connect::spec::schema::CONNECT_SOURCE_ARGUMENT_NAME; @@ -268,7 +268,7 @@ impl ConnectDirectiveArguments { .as_node_str() .expect("`selection` field in `@connect` directive is not a string"); let (remainder, selection_value) = - Selection::parse(selection_value.as_str()).expect("invalid JSON selection"); + JSONSelection::parse(selection_value.as_str()).expect("invalid JSON selection"); if !remainder.is_empty() { panic!("`selection` field in `@connect` directive could not be fully parsed: the following was left over: {remainder}"); } @@ -315,7 +315,7 @@ impl TryFrom<&ObjectNode> for ConnectHTTPArguments { .as_node_str() .expect("`body` field in `@connect` directive's `http` field is not a string"); let (remainder, body_value) = - Selection::parse(body_value.as_str()).expect("invalid JSON selection"); + JSONSelection::parse(body_value.as_str()).expect("invalid JSON selection"); if !remainder.is_empty() { panic!("`body` field in `@connect` directive could not be fully parsed: the following was left over: {remainder}"); } diff --git a/apollo-federation/src/sources/connect/spec/schema.rs b/apollo-federation/src/sources/connect/spec/schema.rs index 28c1eaeaa6..64e06d250a 100644 --- a/apollo-federation/src/sources/connect/spec/schema.rs +++ b/apollo-federation/src/sources/connect/spec/schema.rs @@ -4,7 +4,7 @@ use apollo_compiler::NodeStr; use indexmap::IndexMap; use crate::schema::position::ObjectOrInterfaceFieldDirectivePosition; -use crate::sources::connect::json_selection::Selection; +use crate::sources::connect::json_selection::JSONSelection; pub(crate) const CONNECT_DIRECTIVE_NAME_IN_SPEC: Name = name!("connect"); pub(crate) const CONNECT_SOURCE_ARGUMENT_NAME: Name = name!("source"); @@ -96,7 +96,7 @@ pub(crate) struct ConnectDirectiveArguments { /// /// Uses the JSONSelection syntax to define a mapping of connector response to /// GraphQL schema. - pub(crate) selection: Selection, + pub(crate) selection: JSONSelection, /// Entity resolver marker /// @@ -120,7 +120,7 @@ pub(crate) struct ConnectHTTPArguments { /// Define a request body using JSONSelection. Selections can include values from /// field arguments using `$args.argName` and from fields on the parent type using /// `$this.fieldName`. - pub(crate) body: Option, + pub(crate) body: Option, /// Configuration for headers to attach to the request. /// diff --git a/apollo-router/src/plugins/connectors/connector.rs b/apollo-router/src/plugins/connectors/connector.rs index 9efb9ca516..6bcfbee931 100644 --- a/apollo-router/src/plugins/connectors/connector.rs +++ b/apollo-router/src/plugins/connectors/connector.rs @@ -241,7 +241,7 @@ mod tests { use apollo_compiler::name; use apollo_compiler::Schema; - use apollo_federation::sources::connect::Selection as JSONSelection; + use apollo_federation::sources::connect::JSONSelection; use apollo_federation::sources::connect::URLPathTemplate; use super::*; diff --git a/apollo-router/src/plugins/connectors/directives.rs b/apollo-router/src/plugins/connectors/directives.rs index b0d6896e95..085368ee32 100644 --- a/apollo-router/src/plugins/connectors/directives.rs +++ b/apollo-router/src/plugins/connectors/directives.rs @@ -18,7 +18,7 @@ use apollo_compiler::schema::Value; use apollo_compiler::validation::Valid; use apollo_compiler::Node; use apollo_compiler::Schema; -use apollo_federation::sources::connect::Selection as JSONSelection; +use apollo_federation::sources::connect::JSONSelection; use apollo_federation::sources::connect::URLPathTemplate; use http::Method; use indexmap::IndexMap; diff --git a/apollo-router/src/plugins/connectors/fetch.rs b/apollo-router/src/plugins/connectors/fetch.rs index 13689115eb..5d6a521d69 100644 --- a/apollo-router/src/plugins/connectors/fetch.rs +++ b/apollo-router/src/plugins/connectors/fetch.rs @@ -7,7 +7,7 @@ use apollo_federation::schema::ObjectOrInterfaceFieldDefinitionPosition; use apollo_federation::schema::ObjectOrInterfaceFieldDirectivePosition; use apollo_federation::sources::connect; use apollo_federation::sources::connect::ConnectId; -use apollo_federation::sources::connect::Selection; +use apollo_federation::sources::connect::JSONSelection; use apollo_federation::sources::connect::SubSelection; use apollo_federation::sources::source; use tower::ServiceExt; @@ -48,7 +48,7 @@ impl From for source::query_plan::FetchNode { }, field_response_name: name!("Field"), field_arguments: Default::default(), - selection: Selection::Named(SubSelection { + selection: JSONSelection::Named(SubSelection { selections: vec![], star: None, }), diff --git a/apollo-router/src/plugins/connectors/http_json_transport.rs b/apollo-router/src/plugins/connectors/http_json_transport.rs index 3e7704e749..ccd07c620c 100644 --- a/apollo-router/src/plugins/connectors/http_json_transport.rs +++ b/apollo-router/src/plugins/connectors/http_json_transport.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use apollo_compiler::ast::Selection as GraphQLSelection; use apollo_federation::sources::connect::ApplyTo; use apollo_federation::sources::connect::ApplyToError; -use apollo_federation::sources::connect::Selection as JSONSelection; +use apollo_federation::sources::connect::JSONSelection; use apollo_federation::sources::connect::URLPathTemplate; use displaydoc::Display; use http::header::ACCEPT; diff --git a/apollo-router/src/plugins/connectors/request_response.rs b/apollo-router/src/plugins/connectors/request_response.rs index b0a5e519a5..ccdaf57460 100644 --- a/apollo-router/src/plugins/connectors/request_response.rs +++ b/apollo-router/src/plugins/connectors/request_response.rs @@ -756,7 +756,7 @@ mod tests { use apollo_compiler::name; use apollo_compiler::validation::Valid; use apollo_compiler::Schema; - use apollo_federation::sources::connect::Selection as JSONSelection; + use apollo_federation::sources::connect::JSONSelection; use apollo_federation::sources::connect::URLPathTemplate; use insta::assert_debug_snapshot; From b078b29c13c86937c51635ec5473ea1c7d5d8627 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 7 May 2024 11:09:35 -0400 Subject: [PATCH 07/12] Capitalize SubSelection consistently. --- .../json_selection/grammar/JSONSelection.svg | 6 +++--- ...Subselection.svg => NakedSubSelection.svg} | 0 .../grammar/NamedFieldSelection.svg | 6 +++--- .../grammar/NamedGroupSelection.svg | 6 +++--- .../grammar/NamedQuotedSelection.svg | 6 +++--- .../json_selection/grammar/PathSelection.svg | 6 +++--- .../json_selection/grammar/StarSelection.svg | 6 +++--- .../{Subselection.svg => SubSelection.svg} | 6 +++--- .../sources/connect/json_selection/parser.rs | 20 +++++++++++-------- 9 files changed, 33 insertions(+), 29 deletions(-) rename apollo-federation/src/sources/connect/json_selection/grammar/{NakedSubselection.svg => NakedSubSelection.svg} (100%) rename apollo-federation/src/sources/connect/json_selection/grammar/{Subselection.svg => SubSelection.svg} (94%) diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg index 30007672d3..c828cdaf35 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/JSONSelection.svg @@ -32,11 +32,11 @@ + xlink:href="#NakedSubSelection" + xlink:title="NakedSubSelection"> - NakedSubselection + NakedSubSelection Identifier + xlink:href="#SubSelection" + xlink:title="SubSelection"> - Subselection + SubSelection diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg index 28aabf9daf..743cbaf26d 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedGroupSelection.svg @@ -39,11 +39,11 @@ Alias + xlink:href="#SubSelection" + xlink:title="SubSelection"> - Subselection + SubSelection diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg index 212ec3b111..0a28dac9b3 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/NamedQuotedSelection.svg @@ -46,11 +46,11 @@ StringLiteral + xlink:href="#SubSelection" + xlink:title="SubSelection"> - Subselection + SubSelection diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg index a8bbcfa34d..575d9840a5 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/PathSelection.svg @@ -46,11 +46,11 @@ KeyPath + xlink:href="#SubSelection" + xlink:title="SubSelection"> - Subselection + SubSelection diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg index c57b6bb968..2b4615d2e9 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/StarSelection.svg @@ -47,11 +47,11 @@ rx="10"/> * + xlink:href="#SubSelection" + xlink:title="SubSelection"> - Subselection + SubSelection diff --git a/apollo-federation/src/sources/connect/json_selection/grammar/Subselection.svg b/apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg similarity index 94% rename from apollo-federation/src/sources/connect/json_selection/grammar/Subselection.svg rename to apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg index c5d2e4db29..12284c811c 100644 --- a/apollo-federation/src/sources/connect/json_selection/grammar/Subselection.svg +++ b/apollo-federation/src/sources/connect/json_selection/grammar/SubSelection.svg @@ -40,11 +40,11 @@ rx="10"/> { + xlink:href="#NakedSubSelection" + xlink:title="NakedSubSelection"> - NakedSubselection + NakedSubSelection " Identifier MethodArgs? #[derive(Debug, PartialEq, Clone, Serialize)] pub enum PathSelection { @@ -201,7 +205,7 @@ impl PathSelection { } } -// SubSelection ::= "{" NamedSelection* StarSelection? "}" +// SubSelection ::= "{" NakedSubSelection "}" #[derive(Debug, PartialEq, Clone, Serialize)] pub struct SubSelection { From 0bb7728b3c9a764b63b091cd0ce97181c87c0b45 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 7 May 2024 11:13:35 -0400 Subject: [PATCH 08/12] Rename Property to Key to match README grammar. --- .../connect/federated_query_graph/builder.rs | 16 ++-- .../connect/federated_query_graph/mod.rs | 12 +-- .../connect/fetch_dependency_graph/mod.rs | 14 +-- .../connect/json_selection/apply_to.rs | 56 +++++------ .../sources/connect/json_selection/graphql.rs | 2 +- .../sources/connect/json_selection/parser.rs | 96 +++++++++---------- apollo-federation/src/sources/connect/mod.rs | 2 +- 7 files changed, 99 insertions(+), 99 deletions(-) diff --git a/apollo-federation/src/sources/connect/federated_query_graph/builder.rs b/apollo-federation/src/sources/connect/federated_query_graph/builder.rs index c4774050e8..335b77c52b 100644 --- a/apollo-federation/src/sources/connect/federated_query_graph/builder.rs +++ b/apollo-federation/src/sources/connect/federated_query_graph/builder.rs @@ -15,7 +15,7 @@ use crate::sources::connect::federated_query_graph::EnumNode; use crate::sources::connect::federated_query_graph::ScalarNode; use crate::sources::connect::federated_query_graph::SourceEnteringEdge; use crate::sources::connect::json_selection::JSONSelection; -use crate::sources::connect::json_selection::Property; +use crate::sources::connect::json_selection::Key; use crate::sources::connect::json_selection::SubSelection; use crate::sources::connect::models::Connector; use crate::sources::source::federated_query_graph::builder::FederatedQueryGraphBuilderApi; @@ -206,7 +206,7 @@ fn process_subselection( subgraph_schema: &ValidFederationSchema, builder: &mut impl IntraSourceQueryGraphBuilderApi, node_cache: &mut IndexMap>, - properties_path: Option>, + properties_path: Option>, ) -> Result, FederationError> { // Get the type of the field let field_ty = field_output_type_pos.get(subgraph_schema.schema())?; @@ -669,7 +669,7 @@ mod tests { use crate::source_aware::federated_query_graph::SelfConditionIndex; use crate::sources::connect::federated_query_graph::ConcreteFieldEdge; use crate::sources::connect::federated_query_graph::SourceEnteringEdge; - use crate::sources::connect::json_selection::Property; + use crate::sources::connect::json_selection::Key; use crate::sources::connect::ConnectId; use crate::sources::source; use crate::sources::source::SourceId; @@ -690,7 +690,7 @@ mod tests { /// A mock query edge struct MockEdge { field_name: Name, - path: Vec, + path: Vec, } impl Display for MockEdge { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -702,13 +702,13 @@ mod tests { } // Helper for checking name equality of a property - fn is_name_eq(prop: &Property, other: &str) -> bool { + fn is_name_eq(prop: &Key, other: &str) -> bool { match prop { - Property::Field(f) => f == other, - Property::Quoted(q) => q == other, + Key::Field(f) => f == other, + Key::Quoted(q) => q == other, // No string name will be equal to a number - Property::Index(_) => false, + Key::Index(_) => false, } } diff --git a/apollo-federation/src/sources/connect/federated_query_graph/mod.rs b/apollo-federation/src/sources/connect/federated_query_graph/mod.rs index fca3629097..f16320dae1 100644 --- a/apollo-federation/src/sources/connect/federated_query_graph/mod.rs +++ b/apollo-federation/src/sources/connect/federated_query_graph/mod.rs @@ -7,8 +7,8 @@ use crate::schema::position::ObjectTypeDefinitionPosition; use crate::schema::position::ScalarTypeDefinitionPosition; use crate::schema::ObjectFieldDefinitionPosition; use crate::sources::connect::json_selection::JSONSelection; +use crate::sources::connect::json_selection::Key; use crate::sources::connect::json_selection::PathSelection; -use crate::sources::connect::json_selection::Property; use crate::sources::connect::json_selection::SubSelection; use crate::sources::source; use crate::sources::source::federated_query_graph::FederatedQueryGraphApi; @@ -42,7 +42,7 @@ pub(crate) enum ConcreteNode { }, SelectionRoot { subgraph_type: ObjectTypeDefinitionPosition, - property_path: Vec, + property_path: Vec, }, SelectionChild { subgraph_type: ObjectTypeDefinitionPosition, @@ -53,7 +53,7 @@ pub(crate) enum ConcreteNode { pub(crate) enum EnumNode { SelectionRoot { subgraph_type: EnumTypeDefinitionPosition, - property_path: Vec, + property_path: Vec, }, SelectionChild { subgraph_type: EnumTypeDefinitionPosition, @@ -64,7 +64,7 @@ pub(crate) enum EnumNode { pub(crate) enum ScalarNode { SelectionRoot { subgraph_type: ScalarTypeDefinitionPosition, - property_path: Vec, + property_path: Vec, }, CustomScalarSelectionRoot { subgraph_type: ScalarTypeDefinitionPosition, @@ -86,7 +86,7 @@ pub(crate) enum ConcreteFieldEdge { }, Selection { subgraph_field: ObjectFieldDefinitionPosition, - property_path: Vec, + property_path: Vec, }, CustomScalarPathSelection { subgraph_field: ObjectFieldDefinitionPosition, @@ -95,7 +95,7 @@ pub(crate) enum ConcreteFieldEdge { CustomScalarStarSelection { subgraph_field: ObjectFieldDefinitionPosition, star_subselection: Option, - excluded_properties: IndexSet, + excluded_properties: IndexSet, }, } diff --git a/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs b/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs index 67d036f49b..7f13437b3f 100644 --- a/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs +++ b/apollo-federation/src/sources/connect/fetch_dependency_graph/mod.rs @@ -17,8 +17,8 @@ use crate::source_aware::federated_query_graph::SelfConditionIndex; use crate::source_aware::query_plan::FetchDataPathElement; use crate::source_aware::query_plan::QueryPlanCost; use crate::sources::connect::json_selection::JSONSelection; +use crate::sources::connect::json_selection::Key; use crate::sources::connect::json_selection::PathSelection; -use crate::sources::connect::json_selection::Property; use crate::sources::connect::json_selection::SubSelection; use crate::sources::source; use crate::sources::source::fetch_dependency_graph::FetchDependencyGraphApi; @@ -145,8 +145,8 @@ pub(crate) struct PathField { #[derive(Debug)] pub(crate) enum PathSelections { Selections { - head_property_path: Vec, - named_selections: Vec<(Name, Vec)>, + head_property_path: Vec, + named_selections: Vec<(Name, Vec)>, tail_selection: Option<(Name, PathTailSelection)>, }, CustomScalarRoot { @@ -157,14 +157,14 @@ pub(crate) enum PathSelections { #[derive(Debug)] pub(crate) enum PathTailSelection { Selection { - property_path: Vec, + property_path: Vec, }, CustomScalarPathSelection { path_selection: PathSelection, }, CustomScalarStarSelection { star_subselection: Option, - excluded_properties: IndexSet, + excluded_properties: IndexSet, }, } @@ -205,7 +205,7 @@ mod tests { use crate::sources::connect::federated_query_graph::ConcreteFieldEdge; use crate::sources::connect::federated_query_graph::ConcreteNode; use crate::sources::connect::federated_query_graph::SourceEnteringEdge; - use crate::sources::connect::json_selection::Property; + use crate::sources::connect::json_selection::Key; use crate::sources::connect::ConnectId; use crate::sources::source::fetch_dependency_graph::FetchDependencyGraphApi; use crate::sources::source::SourceId; @@ -272,7 +272,7 @@ mod tests { source_id: source_id.clone(), source_data: ConcreteNode::SelectionRoot { subgraph_type: node_type.clone(), - property_path: vec![Property::Field(type_name.to_lowercase().to_string())], + property_path: vec![Key::Field(type_name.to_lowercase().to_string())], } .into(), }); diff --git a/apollo-federation/src/sources/connect/json_selection/apply_to.rs b/apollo-federation/src/sources/connect/json_selection/apply_to.rs index f9979e8cbd..ae9acd28d7 100644 --- a/apollo-federation/src/sources/connect/json_selection/apply_to.rs +++ b/apollo-federation/src/sources/connect/json_selection/apply_to.rs @@ -31,7 +31,7 @@ pub trait ApplyTo { fn apply_to_path( &self, data: &JSON, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option; @@ -40,13 +40,13 @@ pub trait ApplyTo { fn apply_to_array( &self, data_array: &[JSON], - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { let mut output = Vec::with_capacity(data_array.len()); for (i, element) in data_array.iter().enumerate() { - input_path.push(Property::Index(i)); + input_path.push(Key::Index(i)); let value = self.apply_to_path(element, input_path, errors); input_path.pop(); // When building an Object, we can simply omit missing properties @@ -74,13 +74,13 @@ impl Hash for ApplyToError { } impl ApplyToError { - fn new(message: &str, path: &[Property]) -> Self { + fn new(message: &str, path: &[Key]) -> Self { Self(json!({ "message": message, "path": path.iter().map(|property| match property { - Property::Field(name) => json!(name), - Property::Quoted(name) => json!(name), - Property::Index(index) => json!(index), + Key::Field(name) => json!(name), + Key::Quoted(name) => json!(name), + Key::Index(index) => json!(index), }).collect::>(), })) } @@ -129,7 +129,7 @@ impl ApplyTo for JSONSelection { fn apply_to_path( &self, data: &JSON, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { let data = match data { @@ -160,7 +160,7 @@ impl ApplyTo for NamedSelection { fn apply_to_path( &self, data: &JSON, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { let data = match data { @@ -179,7 +179,7 @@ impl ApplyTo for NamedSelection { alias: Option<&Alias>, name: &String, selection: &Option, - input_path: &mut Vec, + input_path: &mut Vec, | { if let Some(child) = data.get(name) { let output_name = alias.map_or(name, |alias| &alias.name); @@ -201,12 +201,12 @@ impl ApplyTo for NamedSelection { match self { Self::Field(alias, name, selection) => { - input_path.push(Property::Field(name.clone())); + input_path.push(Key::Field(name.clone())); field_quoted_helper(alias.as_ref(), name, selection, input_path); input_path.pop(); } Self::Quoted(alias, name, selection) => { - input_path.push(Property::Quoted(name.clone())); + input_path.push(Key::Quoted(name.clone())); field_quoted_helper(Some(alias), name, selection, input_path); input_path.pop(); } @@ -232,7 +232,7 @@ impl ApplyTo for PathSelection { fn apply_to_path( &self, data: &JSON, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { if let JSON::Array(array) = data { @@ -240,7 +240,7 @@ impl ApplyTo for PathSelection { } match self { - Self::Path(head, tail) => { + Self::Key(key, tail) => { match data { JSON::Object(_) => {} _ => { @@ -256,20 +256,20 @@ impl ApplyTo for PathSelection { } }; - input_path.push(head.clone()); - if let Some(child) = match head { - Property::Field(name) => data.get(name), - Property::Quoted(name) => data.get(name), - Property::Index(index) => data.get(index), + input_path.push(key.clone()); + if let Some(child) = match key { + Key::Field(name) => data.get(name), + Key::Quoted(name) => data.get(name), + Key::Index(index) => data.get(index), } { let result = tail.apply_to_path(child, input_path, errors); input_path.pop(); result } else { - let message = match head { - Property::Field(name) => format!("Response field {} not found", name), - Property::Quoted(name) => format!("Response field {} not found", name), - Property::Index(index) => format!("Response field {} not found", index), + let message = match key { + Key::Field(name) => format!("Response field {} not found", name), + Key::Quoted(name) => format!("Response field {} not found", name), + Key::Index(index) => format!("Response field {} not found", index), }; errors.insert(ApplyToError::new(message.as_str(), input_path)); input_path.pop(); @@ -294,7 +294,7 @@ impl ApplyTo for SubSelection { fn apply_to_path( &self, data: &JSON, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { let data_map = match data { @@ -336,9 +336,9 @@ impl ApplyTo for SubSelection { input_names.insert(name.as_str()); } NamedSelection::Path(_, path_selection) => { - if let PathSelection::Path(head, _) = path_selection { - match head { - Property::Field(name) | Property::Quoted(name) => { + if let PathSelection::Key(key, _) = path_selection { + match key { + Key::Field(name) | Key::Quoted(name) => { input_names.insert(name.as_str()); } // While Property::Index may be used to @@ -351,7 +351,7 @@ impl ApplyTo for SubSelection { // means the numeric Property::Index case cannot // affect the keys selected by * selections, so // input_names does not need updating here. - Property::Index(_) => {} + Key::Index(_) => {} }; } } diff --git a/apollo-federation/src/sources/connect/json_selection/graphql.rs b/apollo-federation/src/sources/connect/json_selection/graphql.rs index 331767b1e1..44e0fcf602 100644 --- a/apollo-federation/src/sources/connect/json_selection/graphql.rs +++ b/apollo-federation/src/sources/connect/json_selection/graphql.rs @@ -93,7 +93,7 @@ impl From for Vec { impl From for Vec { fn from(val: PathSelection) -> Vec { match val { - PathSelection::Path(_head, tail) => { + PathSelection::Key(_, tail) => { let tail = *tail; tail.into() } diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs index a76d46ac8f..0f1f3b7b91 100644 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -116,13 +116,13 @@ impl NamedSelection { /// Extracts the property path for a given named selection /// // TODO: Expand on what this means once I have a better understanding - pub(crate) fn property_path(&self) -> Vec { + pub(crate) fn property_path(&self) -> Vec { match self { - NamedSelection::Field(_, name, _) => vec![Property::Field(name.to_string())], + NamedSelection::Field(_, name, _) => vec![Key::Field(name.to_string())], NamedSelection::Quoted(_, _, Some(_)) => todo!(), - NamedSelection::Quoted(_, name, None) => vec![Property::Quoted(name.to_string())], + NamedSelection::Quoted(_, name, None) => vec![Key::Quoted(name.to_string())], NamedSelection::Path(_, path) => path.collect_paths(), - NamedSelection::Group(alias, _) => vec![Property::Field(alias.name.to_string())], + NamedSelection::Group(alias, _) => vec![Key::Field(alias.name.to_string())], } } @@ -150,9 +150,9 @@ impl NamedSelection { #[derive(Debug, PartialEq, Clone, Serialize)] pub enum PathSelection { - // We use a recursive structure here instead of a Vec to make - // applying the selection to a JSON value easier. - Path(Property, Box), + // We use a recursive structure here instead of a Vec to make applying + // the selection to a JSON value easier. + Key(Key, Box), Selection(SubSelection), Empty, } @@ -161,18 +161,18 @@ impl PathSelection { fn parse(input: &str) -> IResult<&str, Self> { tuple(( spaces_or_comments, - many1(preceded(char('.'), Property::parse)), + many1(preceded(char('.'), Key::parse)), opt(SubSelection::parse), spaces_or_comments, ))(input) .map(|(input, (_, path, selection, _))| (input, Self::from_slice(&path, selection))) } - fn from_slice(properties: &[Property], selection: Option) -> Self { + fn from_slice(properties: &[Key], selection: Option) -> Self { match properties { [] => selection.map_or(Self::Empty, Self::Selection), [head, tail @ ..] => { - Self::Path(head.clone(), Box::new(Self::from_slice(tail, selection))) + Self::Key(head.clone(), Box::new(Self::from_slice(tail, selection))) } } } @@ -181,13 +181,13 @@ impl PathSelection { /// /// This method attempts to collect as many paths as possible, shorting out once /// a non path selection is encountered. - pub(crate) fn collect_paths(&self) -> Vec { + pub(crate) fn collect_paths(&self) -> Vec { let mut results = Vec::new(); // Collect as many as possible let mut current = self; - while let Self::Path(prop, rest) = current { - results.push(prop.clone()); + while let Self::Key(key, rest) = current { + results.push(key.clone()); current = rest; } @@ -198,7 +198,7 @@ impl PathSelection { /// Find the next subselection, traversing nested chains if needed pub(crate) fn next_subselection(&self) -> Option<&SubSelection> { match self { - PathSelection::Path(_, path) => path.next_subselection(), + PathSelection::Key(_, path) => path.next_subselection(), PathSelection::Selection(sub) => Some(sub), PathSelection::Empty => None, } @@ -269,16 +269,16 @@ impl Alias { } } -// Property ::= Identifier | StringLiteral +// Key ::= Identifier | StringLiteral #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize)] -pub enum Property { +pub enum Key { Field(String), Quoted(String), Index(usize), } -impl Property { +impl Key { fn parse(input: &str) -> IResult<&str, Self> { alt(( map(parse_identifier, Self::Field), @@ -287,17 +287,17 @@ impl Property { } } -impl Display for Property { +impl Display for Key { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Property::Field(field) => write!(f, ".{field}"), - Property::Quoted(quote) => write!(f, r#"."{quote}""#), - Property::Index(index) => write!(f, "[{index}]"), + Key::Field(field) => write!(f, ".{field}"), + Key::Quoted(quote) => write!(f, r#"."{quote}""#), + Key::Index(index) => write!(f, "[{index}]"), } } } -// Identifier ::= [a-zA-Z_][0-9a-zA-Z_]* +// Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* fn parse_identifier(input: &str) -> IResult<&str, String> { tuple(( @@ -410,15 +410,15 @@ mod tests { ); } #[test] - fn test_property() { + fn test_key() { assert_eq!( - Property::parse("hello"), - Ok(("", Property::Field("hello".to_string()))), + Key::parse("hello"), + Ok(("", Key::Field("hello".to_string()))), ); assert_eq!( - Property::parse("'hello'"), - Ok(("", Property::Quoted("hello".to_string()))), + Key::parse("'hello'"), + Ok(("", Key::Quoted("hello".to_string()))), ); } @@ -623,7 +623,7 @@ mod tests { assert_eq!( selection!(".hello"), JSONSelection::Path(PathSelection::from_slice( - &[Property::Field("hello".to_string()),], + &[Key::Field("hello".to_string()),], None )), ); @@ -637,8 +637,8 @@ mod tests { }, PathSelection::from_slice( &[ - Property::Field("hello".to_string()), - Property::Field("world".to_string()), + Key::Field("hello".to_string()), + Key::Field("world".to_string()), ], None ), @@ -658,8 +658,8 @@ mod tests { }, PathSelection::from_slice( &[ - Property::Field("hello".to_string()), - Property::Field("world".to_string()), + Key::Field("hello".to_string()), + Key::Field("world".to_string()), ], None ), @@ -679,8 +679,8 @@ mod tests { }, PathSelection::from_slice( &[ - Property::Field("hello".to_string()), - Property::Field("world".to_string()), + Key::Field("hello".to_string()), + Key::Field("world".to_string()), ], Some(SubSelection { selections: vec![ @@ -748,9 +748,9 @@ mod tests { }, PathSelection::from_slice( &[ - Property::Field("some".to_string()), - Property::Field("nested".to_string()), - Property::Field("path".to_string()), + Key::Field("some".to_string()), + Key::Field("nested".to_string()), + Key::Field("path".to_string()), ], Some(SubSelection { selections: vec![ @@ -802,15 +802,15 @@ mod tests { check_path_selection( ".hello", - PathSelection::from_slice(&[Property::Field("hello".to_string())], None), + PathSelection::from_slice(&[Key::Field("hello".to_string())], None), ); check_path_selection( ".hello.world", PathSelection::from_slice( &[ - Property::Field("hello".to_string()), - Property::Field("world".to_string()), + Key::Field("hello".to_string()), + Key::Field("world".to_string()), ], None, ), @@ -820,8 +820,8 @@ mod tests { ".hello.world { hello }", PathSelection::from_slice( &[ - Property::Field("hello".to_string()), - Property::Field("world".to_string()), + Key::Field("hello".to_string()), + Key::Field("world".to_string()), ], Some(SubSelection { selections: vec![NamedSelection::Field(None, "hello".to_string(), None)], @@ -834,10 +834,10 @@ mod tests { ".nested.'string literal'.\"property\".name", PathSelection::from_slice( &[ - Property::Field("nested".to_string()), - Property::Quoted("string literal".to_string()), - Property::Quoted("property".to_string()), - Property::Field("name".to_string()), + Key::Field("nested".to_string()), + Key::Quoted("string literal".to_string()), + Key::Quoted("property".to_string()), + Key::Field("name".to_string()), ], None, ), @@ -847,8 +847,8 @@ mod tests { ".nested.'string literal' { leggo: 'my ego' }", PathSelection::from_slice( &[ - Property::Field("nested".to_string()), - Property::Quoted("string literal".to_string()), + Key::Field("nested".to_string()), + Key::Quoted("string literal".to_string()), ], Some(SubSelection { selections: vec![NamedSelection::Quoted( diff --git a/apollo-federation/src/sources/connect/mod.rs b/apollo-federation/src/sources/connect/mod.rs index 3df07e64b7..8a4092f4af 100644 --- a/apollo-federation/src/sources/connect/mod.rs +++ b/apollo-federation/src/sources/connect/mod.rs @@ -15,8 +15,8 @@ mod url_path_template; pub use json_selection::ApplyTo; pub use json_selection::ApplyToError; pub use json_selection::JSONSelection; +pub use json_selection::Key; pub use json_selection::PathSelection; -pub use json_selection::Property; pub use json_selection::SubSelection; pub(crate) use spec::ConnectSpecDefinition; pub use url_path_template::URLPathTemplate; From 41e1fe625a91faaf0b73e8054231147503c524b3 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 7 May 2024 11:14:27 -0400 Subject: [PATCH 09/12] More generous use of spaces_or_comments. --- .../sources/connect/json_selection/parser.rs | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs index 0f1f3b7b91..c3fa9d5c6b 100644 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -9,6 +9,7 @@ use nom::combinator::opt; use nom::combinator::recognize; use nom::multi::many0; use nom::multi::many1; +use nom::sequence::delimited; use nom::sequence::pair; use nom::sequence::preceded; use nom::sequence::tuple; @@ -76,15 +77,19 @@ impl NamedSelection { fn parse_field(input: &str) -> IResult<&str, Self> { tuple(( opt(Alias::parse), - parse_identifier, + delimited(spaces_or_comments, parse_identifier, spaces_or_comments), opt(SubSelection::parse), ))(input) .map(|(input, (alias, name, selection))| (input, Self::Field(alias, name, selection))) } fn parse_quoted(input: &str) -> IResult<&str, Self> { - tuple((Alias::parse, parse_string_literal, opt(SubSelection::parse)))(input) - .map(|(input, (alias, name, selection))| (input, Self::Quoted(alias, name, selection))) + tuple(( + Alias::parse, + delimited(spaces_or_comments, parse_string_literal, spaces_or_comments), + opt(SubSelection::parse), + ))(input) + .map(|(input, (alias, name, selection))| (input, Self::Quoted(alias, name, selection))) } fn parse_path(input: &str) -> IResult<&str, Self> { @@ -264,8 +269,14 @@ pub struct Alias { impl Alias { fn parse(input: &str) -> IResult<&str, Self> { - tuple((parse_identifier, char(':'), spaces_or_comments))(input) - .map(|(input, (name, _, _))| (input, Self { name })) + tuple(( + spaces_or_comments, + parse_identifier, + spaces_or_comments, + char(':'), + spaces_or_comments, + ))(input) + .map(|(input, (_, name, _, _, _))| (input, Self { name })) } } @@ -300,7 +311,7 @@ impl Display for Key { // Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* fn parse_identifier(input: &str) -> IResult<&str, String> { - tuple(( + delimited( spaces_or_comments, recognize(pair( one_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"), @@ -309,13 +320,13 @@ fn parse_identifier(input: &str) -> IResult<&str, String> { )), )), spaces_or_comments, - ))(input) - .map(|(input, (_, name, _))| (input, name.to_string())) + )(input) + .map(|(input, name)| (input, name.to_string())) } // StringLiteral ::= -// | "'" ("\'" | [^'])* "'" -// | '"' ('\"' | [^"])* '"' +// | "'" ("\\'" | [^'])* "'" +// | '"' ('\\"' | [^"])* '"' fn parse_string_literal(input: &str) -> IResult<&str, String> { let input = spaces_or_comments(input).map(|(input, _)| input)?; From 285c5ddd921924dbe5fe83f9ae31da9360fc8a29 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Tue, 7 May 2024 16:38:49 -0400 Subject: [PATCH 10/12] Support `$this`, `$args`, `$` and other `$variable` expressions. --- .../connect/json_selection/apply_to.rs | 136 ++++++++- .../sources/connect/json_selection/graphql.rs | 4 + .../sources/connect/json_selection/parser.rs | 287 +++++++++++++++++- 3 files changed, 398 insertions(+), 29 deletions(-) diff --git a/apollo-federation/src/sources/connect/json_selection/apply_to.rs b/apollo-federation/src/sources/connect/json_selection/apply_to.rs index ae9acd28d7..74d9ef16a0 100644 --- a/apollo-federation/src/sources/connect/json_selection/apply_to.rs +++ b/apollo-federation/src/sources/connect/json_selection/apply_to.rs @@ -3,6 +3,7 @@ use std::hash::Hash; use std::hash::Hasher; +use indexmap::IndexMap; use indexmap::IndexSet; use itertools::Itertools; use serde_json_bytes::json; @@ -19,10 +20,18 @@ pub trait ApplyTo { // explicitly support), which are distinct from null values (which it does // support). fn apply_to(&self, data: &JSON) -> (Option, Vec) { + self.apply_with_vars(data, &IndexMap::new()) + } + + fn apply_with_vars( + &self, + data: &JSON, + vars: &IndexMap, + ) -> (Option, Vec) { let mut input_path = vec![]; // Using IndexSet over HashSet to preserve the order of the errors. let mut errors = IndexSet::new(); - let value = self.apply_to_path(data, &mut input_path, &mut errors); + let value = self.apply_to_path(data, vars, &mut input_path, &mut errors); (value, errors.into_iter().collect()) } @@ -31,6 +40,7 @@ pub trait ApplyTo { fn apply_to_path( &self, data: &JSON, + vars: &IndexMap, input_path: &mut Vec, errors: &mut IndexSet, ) -> Option; @@ -40,6 +50,7 @@ pub trait ApplyTo { fn apply_to_array( &self, data_array: &[JSON], + vars: &IndexMap, input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { @@ -47,7 +58,7 @@ pub trait ApplyTo { for (i, element) in data_array.iter().enumerate() { input_path.push(Key::Index(i)); - let value = self.apply_to_path(element, input_path, errors); + let value = self.apply_to_path(element, vars, input_path, errors); input_path.pop(); // When building an Object, we can simply omit missing properties // and report an error, but when building an Array, we need to @@ -129,11 +140,12 @@ impl ApplyTo for JSONSelection { fn apply_to_path( &self, data: &JSON, + vars: &IndexMap, input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { let data = match data { - JSON::Array(array) => return self.apply_to_array(array, input_path, errors), + JSON::Array(array) => return self.apply_to_array(array, vars, input_path, errors), JSON::Object(_) => data, _ => { errors.insert(ApplyToError::new("not an object", input_path)); @@ -149,9 +161,11 @@ impl ApplyTo for JSONSelection { // need to create a temporary SubSelection to wrap the selections // Vec. Self::Named(named_selections) => { - named_selections.apply_to_path(data, input_path, errors) + named_selections.apply_to_path(data, vars, input_path, errors) + } + Self::Path(path_selection) => { + path_selection.apply_to_path(data, vars, input_path, errors) } - Self::Path(path_selection) => path_selection.apply_to_path(data, input_path, errors), } } } @@ -160,11 +174,12 @@ impl ApplyTo for NamedSelection { fn apply_to_path( &self, data: &JSON, + vars: &IndexMap, input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { let data = match data { - JSON::Array(array) => return self.apply_to_array(array, input_path, errors), + JSON::Array(array) => return self.apply_to_array(array, vars, input_path, errors), JSON::Object(_) => data, _ => { errors.insert(ApplyToError::new("not an object", input_path)); @@ -184,7 +199,7 @@ impl ApplyTo for NamedSelection { if let Some(child) = data.get(name) { let output_name = alias.map_or(name, |alias| &alias.name); if let Some(selection) = selection { - let value = selection.apply_to_path(child, input_path, errors); + let value = selection.apply_to_path(child, vars, input_path, errors); if let Some(value) = value { output.insert(output_name.clone(), value); } @@ -211,13 +226,13 @@ impl ApplyTo for NamedSelection { input_path.pop(); } Self::Path(alias, path_selection) => { - let value = path_selection.apply_to_path(data, input_path, errors); + let value = path_selection.apply_to_path(data, vars, input_path, errors); if let Some(value) = value { output.insert(alias.name.clone(), value); } } Self::Group(alias, sub_selection) => { - let value = sub_selection.apply_to_path(data, input_path, errors); + let value = sub_selection.apply_to_path(data, vars, input_path, errors); if let Some(value) = value { output.insert(alias.name.clone(), value); } @@ -232,14 +247,31 @@ impl ApplyTo for PathSelection { fn apply_to_path( &self, data: &JSON, + vars: &IndexMap, input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { if let JSON::Array(array) = data { - return self.apply_to_array(array, input_path, errors); + return self.apply_to_array(array, vars, input_path, errors); } match self { + Self::Var(var_name, tail) => { + if var_name == "$" { + // Because $ refers to the current value, we keep using + // input_path instead of creating a new var_path here. + tail.apply_to_path(data, vars, input_path, errors) + } else if let Some(var_data) = vars.get(var_name) { + let mut var_path = vec![Key::Field(var_name.clone())]; + tail.apply_to_path(var_data, vars, &mut var_path, errors) + } else { + errors.insert(ApplyToError::new( + format!("Variable {} not found", var_name).as_str(), + &[Key::Field(var_name.clone())], + )); + None + } + } Self::Key(key, tail) => { match data { JSON::Object(_) => {} @@ -262,7 +294,7 @@ impl ApplyTo for PathSelection { Key::Quoted(name) => data.get(name), Key::Index(index) => data.get(index), } { - let result = tail.apply_to_path(child, input_path, errors); + let result = tail.apply_to_path(child, vars, input_path, errors); input_path.pop(); result } else { @@ -279,7 +311,7 @@ impl ApplyTo for PathSelection { Self::Selection(selection) => { // If data is not an object here, this recursive apply_to_path // call will handle the error. - selection.apply_to_path(data, input_path, errors) + selection.apply_to_path(data, vars, input_path, errors) } Self::Empty => { // If data is not an object here, we want to preserve its value @@ -294,11 +326,12 @@ impl ApplyTo for SubSelection { fn apply_to_path( &self, data: &JSON, + vars: &IndexMap, input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { let data_map = match data { - JSON::Array(array) => return self.apply_to_array(array, input_path, errors), + JSON::Array(array) => return self.apply_to_array(array, vars, input_path, errors), JSON::Object(data_map) => data_map, _ => { errors.insert(ApplyToError::new( @@ -317,7 +350,7 @@ impl ApplyTo for SubSelection { let mut input_names = IndexSet::new(); for named_selection in &self.selections { - let value = named_selection.apply_to_path(data, input_path, errors); + let value = named_selection.apply_to_path(data, vars, input_path, errors); // If value is an object, extend output with its keys and their values. if let Some(JSON::Object(key_and_value)) = value { @@ -378,7 +411,9 @@ impl ApplyTo for SubSelection { let mut star_output = Map::new(); for (key, value) in data_map { if !input_names.contains(key.as_str()) { - if let Some(selected) = selection.apply_to_path(value, input_path, errors) { + if let Some(selected) = + selection.apply_to_path(value, vars, input_path, errors) + { star_output.insert(key.clone(), selected); } } @@ -389,7 +424,9 @@ impl ApplyTo for SubSelection { Some(StarSelection(None, Some(selection))) => { for (key, value) in data_map { if !input_names.contains(key.as_str()) { - if let Some(selected) = selection.apply_to_path(value, input_path, errors) { + if let Some(selected) = + selection.apply_to_path(value, vars, input_path, errors) + { output.insert(key.clone(), selected); } } @@ -1041,6 +1078,73 @@ mod tests { ); } + #[test] + fn test_apply_to_variable_expressions() { + let id_object = selection!("id: $").apply_to(&json!(123)); + assert_eq!(id_object, (Some(json!({"id": 123})), vec![])); + + let data = json!({ + "id": 123, + "name": "Ben", + "friend_ids": [234, 345, 456] + }); + + assert_eq!( + selection!("id name friends: friend_ids { id: $ }").apply_to(&data), + ( + Some(json!({ + "id": 123, + "name": "Ben", + "friends": [ + { "id": 234 }, + { "id": 345 }, + { "id": 456 }, + ], + })), + vec![], + ), + ); + + let mut vars = IndexMap::new(); + vars.insert("$args".to_string(), json!({ "id": "id from args" })); + assert_eq!( + selection!("id: $args.id name").apply_with_vars(&data, &vars), + ( + Some(json!({ + "id": "id from args", + "name": "Ben" + })), + vec![], + ), + ); + assert_eq!( + selection!("id: $args.id name").apply_to(&data), + ( + Some(json!({ + "name": "Ben" + })), + vec![ApplyToError::from_json(&json!({ + "message": "Variable $args not found", + "path": ["$args"], + }))], + ), + ); + let mut vars_without_args_id = IndexMap::new(); + vars_without_args_id.insert("$args".to_string(), json!({ "unused": "ignored" })); + assert_eq!( + selection!("id: $args.id name").apply_with_vars(&data, &vars_without_args_id), + ( + Some(json!({ + "name": "Ben" + })), + vec![ApplyToError::from_json(&json!({ + "message": "Property .id not found in object", + "path": ["$args", "id"], + }))], + ), + ); + } + #[test] fn test_apply_to_non_identifier_properties() { let data = json!({ diff --git a/apollo-federation/src/sources/connect/json_selection/graphql.rs b/apollo-federation/src/sources/connect/json_selection/graphql.rs index 44e0fcf602..e61cb3a7ce 100644 --- a/apollo-federation/src/sources/connect/json_selection/graphql.rs +++ b/apollo-federation/src/sources/connect/json_selection/graphql.rs @@ -93,6 +93,10 @@ impl From for Vec { impl From for Vec { fn from(val: PathSelection) -> Vec { match val { + PathSelection::Var(_, _) => { + // Variable references do not correspond to GraphQL fields. + vec![] + } PathSelection::Key(_, tail) => { let tail = *tail; tail.into() diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs index c3fa9d5c6b..04a8bb6168 100644 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -8,7 +8,6 @@ use nom::combinator::map; use nom::combinator::opt; use nom::combinator::recognize; use nom::multi::many0; -use nom::multi::many1; use nom::sequence::delimited; use nom::sequence::pair; use nom::sequence::preceded; @@ -157,6 +156,7 @@ impl NamedSelection { pub enum PathSelection { // We use a recursive structure here instead of a Vec to make applying // the selection to a JSON value easier. + Var(String, Box), Key(Key, Box), Selection(SubSelection), Empty, @@ -164,13 +164,75 @@ pub enum PathSelection { impl PathSelection { fn parse(input: &str) -> IResult<&str, Self> { - tuple(( - spaces_or_comments, - many1(preceded(char('.'), Key::parse)), - opt(SubSelection::parse), - spaces_or_comments, - ))(input) - .map(|(input, (_, path, selection, _))| (input, Self::from_slice(&path, selection))) + match Self::parse_with_depth(input, 0) { + Ok((remainder, Self::Empty)) => Err(nom::Err::Error(nom::error::Error::new( + remainder, + nom::error::ErrorKind::IsNot, + ))), + otherwise => otherwise, + } + } + + fn parse_with_depth(input: &str, depth: usize) -> IResult<&str, Self> { + let (input, _spaces) = spaces_or_comments(input)?; + + // Variable references and key references without a leading . are + // accepted only at depth 0, or at the beginning of the PathSelection. + if depth == 0 { + if let Ok((suffix, opt_var)) = delimited( + tuple((spaces_or_comments, char('$'))), + opt(parse_identifier), + spaces_or_comments, + )(input) + { + let (input, rest) = Self::parse_with_depth(suffix, depth + 1)?; + // Note the $ prefix is included in the variable name. + let dollar_var = format!("${}", opt_var.unwrap_or("".to_string())); + return Ok((input, Self::Var(dollar_var, Box::new(rest)))); + } + + if let Ok((suffix, key)) = Key::parse(input) { + let (input, rest) = Self::parse_with_depth(suffix, depth + 1)?; + return match rest { + Self::Empty | Self::Selection(_) => Err(nom::Err::Error( + nom::error::Error::new(input, nom::error::ErrorKind::IsNot), + )), + rest => Ok((input, Self::Key(key, Box::new(rest)))), + }; + } + } + + // The .key case is applicable at any depth. If it comes first in the + // path selection, $.key is implied, but the distinction is preserved + // (using Self::Path rather than Self::Var) for accurate reprintability. + if let Ok((suffix, key)) = preceded( + tuple((spaces_or_comments, char('.'), spaces_or_comments)), + Key::parse, + )(input) + { + // tuple((char('.'), Key::parse))(input) { + let (input, rest) = Self::parse_with_depth(suffix, depth + 1)?; + return Ok((input, Self::Key(key, Box::new(rest)))); + } + + if depth == 0 { + // If the PathSelection does not start with a $var, a key., or a + // .key, it is not a valid PathSelection. + return Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::IsNot, + ))); + } + + // If the PathSelection has a SubSelection, it must appear at the end of + // a non-empty path. + if let Ok((suffix, selection)) = SubSelection::parse(input) { + return Ok((suffix, Self::Selection(selection))); + } + + // The Self::Empty enum case is used to indicate the end of a + // PathSelection that has no SubSelection. + Ok((input, Self::Empty)) } fn from_slice(properties: &[Key], selection: Option) -> Self { @@ -203,6 +265,7 @@ impl PathSelection { /// Find the next subselection, traversing nested chains if needed pub(crate) fn next_subselection(&self) -> Option<&SubSelection> { match self { + PathSelection::Var(_, path) => path.next_subselection(), PathSelection::Key(_, path) => path.next_subselection(), PathSelection::Selection(sub) => Some(sub), PathSelection::Empty => None, @@ -804,13 +867,13 @@ mod tests { ); } + fn check_path_selection(input: &str, expected: PathSelection) { + assert_eq!(PathSelection::parse(input), Ok(("", expected.clone()))); + assert_eq!(selection!(input), JSONSelection::Path(expected.clone())); + } + #[test] fn test_path_selection() { - fn check_path_selection(input: &str, expected: PathSelection) { - assert_eq!(PathSelection::parse(input), Ok(("", expected.clone()))); - assert_eq!(selection!(input), JSONSelection::Path(expected.clone())); - } - check_path_selection( ".hello", PathSelection::from_slice(&[Key::Field("hello".to_string())], None), @@ -875,6 +938,204 @@ mod tests { ); } + #[test] + fn test_path_selection_vars() { + check_path_selection( + "$var", + PathSelection::Var("$var".to_string(), Box::new(PathSelection::Empty)), + ); + + check_path_selection( + "$", + PathSelection::Var("$".to_string(), Box::new(PathSelection::Empty)), + ); + + check_path_selection( + "$var { hello }", + PathSelection::Var( + "$var".to_string(), + Box::new(PathSelection::Selection(SubSelection { + selections: vec![NamedSelection::Field(None, "hello".to_string(), None)], + star: None, + })), + ), + ); + + check_path_selection( + "$ { hello }", + PathSelection::Var( + "$".to_string(), + Box::new(PathSelection::Selection(SubSelection { + selections: vec![NamedSelection::Field(None, "hello".to_string(), None)], + star: None, + })), + ), + ); + + check_path_selection( + "$var { before alias: $args.arg after }", + PathSelection::Var( + "$var".to_string(), + Box::new(PathSelection::Selection(SubSelection { + selections: vec![ + NamedSelection::Field(None, "before".to_string(), None), + NamedSelection::Path( + Alias { + name: "alias".to_string(), + }, + PathSelection::Var( + "$args".to_string(), + Box::new(PathSelection::Key( + Key::Field("arg".to_string()), + Box::new(PathSelection::Empty), + )), + ), + ), + NamedSelection::Field(None, "after".to_string(), None), + ], + star: None, + })), + ), + ); + + check_path_selection( + "$.nested { key injected: $args.arg }", + PathSelection::Var( + "$".to_string(), + Box::new(PathSelection::Key( + Key::Field("nested".to_string()), + Box::new(PathSelection::Selection(SubSelection { + selections: vec![ + NamedSelection::Field(None, "key".to_string(), None), + NamedSelection::Path( + Alias { + name: "injected".to_string(), + }, + PathSelection::Var( + "$args".to_string(), + Box::new(PathSelection::Key( + Key::Field("arg".to_string()), + Box::new(PathSelection::Empty), + )), + ), + ), + ], + star: None, + })), + )), + ), + ); + + check_path_selection( + "$root.a.b.c", + PathSelection::Var( + "$root".to_string(), + Box::new(PathSelection::from_slice( + &[ + Key::Field("a".to_string()), + Key::Field("b".to_string()), + Key::Field("c".to_string()), + ], + None, + )), + ), + ); + + check_path_selection( + "undotted.x.y.z", + PathSelection::from_slice( + &[ + Key::Field("undotted".to_string()), + Key::Field("x".to_string()), + Key::Field("y".to_string()), + Key::Field("z".to_string()), + ], + None, + ), + ); + + check_path_selection( + ".dotted.x.y.z", + PathSelection::from_slice( + &[ + Key::Field("dotted".to_string()), + Key::Field("x".to_string()), + Key::Field("y".to_string()), + Key::Field("z".to_string()), + ], + None, + ), + ); + + check_path_selection( + "$.data", + PathSelection::Var( + "$".to_string(), + Box::new(PathSelection::Key( + Key::Field("data".to_string()), + Box::new(PathSelection::Empty), + )), + ), + ); + + check_path_selection( + "$.data.'quoted property'.nested", + PathSelection::Var( + "$".to_string(), + Box::new(PathSelection::Key( + Key::Field("data".to_string()), + Box::new(PathSelection::Key( + Key::Quoted("quoted property".to_string()), + Box::new(PathSelection::Key( + Key::Field("nested".to_string()), + Box::new(PathSelection::Empty), + )), + )), + )), + ), + ); + + assert_eq!( + PathSelection::parse("naked"), + Err(nom::Err::Error(nom::error::Error::new( + "", + nom::error::ErrorKind::IsNot, + ))), + ); + + assert_eq!( + PathSelection::parse("naked { hi }"), + Err(nom::Err::Error(nom::error::Error::new( + "", + nom::error::ErrorKind::IsNot, + ))), + ); + + assert_eq!( + PathSelection::parse("valid.$invalid"), + Err(nom::Err::Error(nom::error::Error::new( + ".$invalid", + nom::error::ErrorKind::IsNot, + ))), + ); + + assert_eq!( + selection!("$"), + JSONSelection::Path(PathSelection::Var( + "$".to_string(), + Box::new(PathSelection::Empty), + )), + ); + + assert_eq!( + selection!("$this"), + JSONSelection::Path(PathSelection::Var( + "$this".to_string(), + Box::new(PathSelection::Empty), + )), + ); + } + #[test] fn test_subselection() { assert_eq!( From 6062640ed8d3e2451bcaff87b1d1ea7b55c4b243 Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Thu, 9 May 2024 12:43:21 -0400 Subject: [PATCH 11/12] Revamp ApplyTo errors when accessing properties of non-objects. --- .../connect/json_selection/apply_to.rs | 501 ++++++++++-------- .../sources/connect/json_selection/parser.rs | 36 +- .../plugins/connectors/request_response.rs | 18 +- 3 files changed, 332 insertions(+), 223 deletions(-) diff --git a/apollo-federation/src/sources/connect/json_selection/apply_to.rs b/apollo-federation/src/sources/connect/json_selection/apply_to.rs index 74d9ef16a0..0c7ad15d7e 100644 --- a/apollo-federation/src/sources/connect/json_selection/apply_to.rs +++ b/apollo-federation/src/sources/connect/json_selection/apply_to.rs @@ -144,14 +144,9 @@ impl ApplyTo for JSONSelection { input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { - let data = match data { - JSON::Array(array) => return self.apply_to_array(array, vars, input_path, errors), - JSON::Object(_) => data, - _ => { - errors.insert(ApplyToError::new("not an object", input_path)); - return None; - } - }; + if let JSON::Array(array) = data { + return self.apply_to_array(array, vars, input_path, errors); + } match self { // Because we represent a JSONSelection::Named as a SubSelection, we @@ -178,26 +173,23 @@ impl ApplyTo for NamedSelection { input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { - let data = match data { - JSON::Array(array) => return self.apply_to_array(array, vars, input_path, errors), - JSON::Object(_) => data, - _ => { - errors.insert(ApplyToError::new("not an object", input_path)); - return None; - } - }; + if let JSON::Array(array) = data { + return self.apply_to_array(array, vars, input_path, errors); + } let mut output = Map::new(); #[rustfmt::skip] // cargo fmt butchers this closure's formatting let mut field_quoted_helper = | alias: Option<&Alias>, - name: &String, + key: Key, selection: &Option, input_path: &mut Vec, | { - if let Some(child) = data.get(name) { - let output_name = alias.map_or(name, |alias| &alias.name); + input_path.push(key.clone()); + let name = key.as_string(); + if let Some(child) = data.get(name.clone()) { + let output_name = alias.map_or(&name, |alias| &alias.name); if let Some(selection) = selection { let value = selection.apply_to_path(child, vars, input_path, errors); if let Some(value) = value { @@ -208,22 +200,33 @@ impl ApplyTo for NamedSelection { } } else { errors.insert(ApplyToError::new( - format!("Response field {} not found", name).as_str(), + format!( + "Property {} not found in {}", + key.dotted(), + json_type_name(data), + ).as_str(), input_path, )); } + input_path.pop(); }; match self { Self::Field(alias, name, selection) => { - input_path.push(Key::Field(name.clone())); - field_quoted_helper(alias.as_ref(), name, selection, input_path); - input_path.pop(); + field_quoted_helper( + alias.as_ref(), + Key::Field(name.clone()), + selection, + input_path, + ); } Self::Quoted(alias, name, selection) => { - input_path.push(Key::Quoted(name.clone())); - field_quoted_helper(Some(alias), name, selection, input_path); - input_path.pop(); + field_quoted_helper( + Some(alias), + Key::Quoted(name.clone()), + selection, + input_path, + ); } Self::Path(alias, path_selection) => { let value = path_selection.apply_to_path(data, vars, input_path, errors); @@ -273,40 +276,44 @@ impl ApplyTo for PathSelection { } } Self::Key(key, tail) => { - match data { - JSON::Object(_) => {} - _ => { - errors.insert(ApplyToError::new( - format!( - "Expected an object in response, received {}", - json_type_name(data) - ) - .as_str(), - input_path, - )); - return None; - } - }; - input_path.push(key.clone()); - if let Some(child) = match key { + + if !matches!(data, JSON::Object(_)) { + errors.insert(ApplyToError::new( + format!( + "Property {} not found in {}", + key.dotted(), + json_type_name(data), + ) + .as_str(), + input_path, + )); + input_path.pop(); + return None; + } + + let result = if let Some(child) = match key { Key::Field(name) => data.get(name), Key::Quoted(name) => data.get(name), Key::Index(index) => data.get(index), } { - let result = tail.apply_to_path(child, vars, input_path, errors); - input_path.pop(); - result + tail.apply_to_path(child, vars, input_path, errors) } else { - let message = match key { - Key::Field(name) => format!("Response field {} not found", name), - Key::Quoted(name) => format!("Response field {} not found", name), - Key::Index(index) => format!("Response field {} not found", index), - }; - errors.insert(ApplyToError::new(message.as_str(), input_path)); - input_path.pop(); + errors.insert(ApplyToError::new( + format!( + "Property {} not found in {}", + key.dotted(), + json_type_name(data), + ) + .as_str(), + input_path, + )); None - } + }; + + input_path.pop(); + + result } Self::Selection(selection) => { // If data is not an object here, this recursive apply_to_path @@ -330,20 +337,13 @@ impl ApplyTo for SubSelection { input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { - let data_map = match data { - JSON::Array(array) => return self.apply_to_array(array, vars, input_path, errors), - JSON::Object(data_map) => data_map, - _ => { - errors.insert(ApplyToError::new( - format!( - "Expected an object in response, received {}", - json_type_name(data) - ) - .as_str(), - input_path, - )); - return None; - } + if let JSON::Array(array) = data { + return self.apply_to_array(array, vars, input_path, errors); + } + + let (data_map, data_really_primitive) = match data { + JSON::Object(data_map) => (data_map.clone(), false), + _primitive => (Map::new(), true), }; let mut output = Map::new(); @@ -399,7 +399,7 @@ impl ApplyTo for SubSelection { // Aliased but not subselected, e.g. "a b c rest: *" Some(StarSelection(Some(alias), None)) => { let mut star_output = Map::new(); - for (key, value) in data_map { + for (key, value) in &data_map { if !input_names.contains(key.as_str()) { star_output.insert(key.clone(), value.clone()); } @@ -409,7 +409,7 @@ impl ApplyTo for SubSelection { // Aliased and subselected, e.g. "alias: * { hello }" Some(StarSelection(Some(alias), Some(selection))) => { let mut star_output = Map::new(); - for (key, value) in data_map { + for (key, value) in &data_map { if !input_names.contains(key.as_str()) { if let Some(selected) = selection.apply_to_path(value, vars, input_path, errors) @@ -422,7 +422,7 @@ impl ApplyTo for SubSelection { } // Not aliased but subselected, e.g. "parent { * { hello } }" Some(StarSelection(None, Some(selection))) => { - for (key, value) in data_map { + for (key, value) in &data_map { if !input_names.contains(key.as_str()) { if let Some(selected) = selection.apply_to_path(value, vars, input_path, errors) @@ -434,7 +434,7 @@ impl ApplyTo for SubSelection { } // Neither aliased nor subselected, e.g. "parent { * }" or just "*" Some(StarSelection(None, None)) => { - for (key, value) in data_map { + for (key, value) in &data_map { if !input_names.contains(key.as_str()) { output.insert(key.clone(), value.clone()); } @@ -444,6 +444,10 @@ impl ApplyTo for SubSelection { None => {} }; + if data_really_primitive && output.is_empty() { + return Some(data.clone()); + } + Some(JSON::Object(output)) } } @@ -487,8 +491,10 @@ mod tests { ); check_ok(selection!(".nested.hello"), json!("world")); + check_ok(selection!("$.nested.hello"), json!("world")); check_ok(selection!(".nested.world"), json!("hello")); + check_ok(selection!("$.nested.world"), json!("hello")); check_ok( selection!("nested hello"), @@ -543,11 +549,27 @@ mod tests { }), ); + check_ok( + selection!("worlds: $.array.hello"), + json!({ + "worlds": [ + "world 0", + "world 1", + "world 2", + ], + }), + ); + check_ok( selection!(".array.hello"), json!(["world 0", "world 1", "world 2",]), ); + check_ok( + selection!("$.array.hello"), + json!(["world 0", "world 1", "world 2",]), + ); + check_ok( selection!("nested grouped: { hello worlds: .array.hello }"), json!({ @@ -565,6 +587,24 @@ mod tests { }, }), ); + + check_ok( + selection!("nested grouped: { hello worlds: $.array.hello }"), + json!({ + "nested": { + "hello": "world", + "world": "hello", + }, + "grouped": { + "hello": "world", + "worlds": [ + "world 0", + "world 1", + "world 2", + ], + }, + }), + ); } #[test] @@ -760,15 +800,21 @@ mod tests { (Some(json!({"hello": "world"})), vec![],) ); + let yellow_errors_expected = vec![ApplyToError::from_json(&json!({ + "message": "Property .yellow not found in object", + "path": ["yellow"], + }))]; assert_eq!( selection!("yellow").apply_to(&data), - ( - Some(json!({})), - vec![ApplyToError::from_json(&json!({ - "message": "Response field yellow not found", - "path": ["yellow"], - })),], - ) + (Some(json!({})), yellow_errors_expected.clone()) + ); + assert_eq!( + selection!(".yellow").apply_to(&data), + (None, yellow_errors_expected.clone()) + ); + assert_eq!( + selection!("$.yellow").apply_to(&data), + (None, yellow_errors_expected.clone()) ); assert_eq!( @@ -776,57 +822,72 @@ mod tests { (Some(json!(123)), vec![],) ); + let quoted_yellow_expected = ( + None, + vec![ApplyToError::from_json(&json!({ + "message": "Property .\"yellow\" not found in object", + "path": ["nested", "yellow"], + }))], + ); assert_eq!( selection!(".nested.'yellow'").apply_to(&data), - ( - None, - vec![ApplyToError::from_json(&json!({ - "message": "Response field yellow not found", - "path": ["nested", "yellow"], - })),], - ) + quoted_yellow_expected, + ); + assert_eq!( + selection!("$.nested.'yellow'").apply_to(&data), + quoted_yellow_expected, ); + let nested_path_expected = ( + Some(json!({ + "world": true, + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .hola not found in object", + "path": ["nested", "hola"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .yellow not found in object", + "path": ["nested", "yellow"], + })), + ], + ); assert_eq!( selection!(".nested { hola yellow world }").apply_to(&data), - ( - Some(json!({ - "world": true, - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Response field hola not found", - "path": ["nested", "hola"], - })), - ApplyToError::from_json(&json!({ - "message": "Response field yellow not found", - "path": ["nested", "yellow"], - })), - ], - ) + nested_path_expected, + ); + assert_eq!( + selection!("$.nested { hola yellow world }").apply_to(&data), + nested_path_expected, ); + let partial_array_expected = ( + Some(json!({ + "partial": [ + { "hello": 1, "goodbye": "farewell" }, + { "hello": "two" }, + { "hello": 3.0 }, + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .goodbye not found in object", + "path": ["array", 1, "goodbye"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .goodbye not found in object", + "path": ["array", 2, "goodbye"], + })), + ], + ); assert_eq!( selection!("partial: .array { hello goodbye }").apply_to(&data), - ( - Some(json!({ - "partial": [ - { "hello": 1, "goodbye": "farewell" }, - { "hello": "two" }, - { "hello": 3.0 }, - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Response field goodbye not found", - "path": ["array", 1, "goodbye"], - })), - ApplyToError::from_json(&json!({ - "message": "Response field goodbye not found", - "path": ["array", 2, "goodbye"], - })), - ], - ) + partial_array_expected, + ); + assert_eq!( + selection!("partial: $.array { hello goodbye }").apply_to(&data), + partial_array_expected, ); assert_eq!( @@ -846,11 +907,11 @@ mod tests { })), vec![ ApplyToError::from_json(&json!({ - "message": "Response field smello not found", + "message": "Property .smello not found in object", "path": ["array", 0, "smello"], })), ApplyToError::from_json(&json!({ - "message": "Response field smello not found", + "message": "Property .smello not found in object", "path": ["array", 1, "smello"], })), ], @@ -869,11 +930,11 @@ mod tests { })), vec![ ApplyToError::from_json(&json!({ - "message": "Response field smello not found", + "message": "Property .smello not found in object", "path": ["array", 0, "smello"], })), ApplyToError::from_json(&json!({ - "message": "Response field smello not found", + "message": "Property .smello not found in object", "path": ["array", 1, "smello"], })), ], @@ -890,7 +951,7 @@ mod tests { }, })), vec![ApplyToError::from_json(&json!({ - "message": "Response field smelly not found", + "message": "Property .smelly not found in object", "path": ["nested", "smelly"], })),], ) @@ -908,7 +969,7 @@ mod tests { }, })), vec![ApplyToError::from_json(&json!({ - "message": "Response field smelly not found", + "message": "Property .smelly not found in object", "path": ["nested", "smelly"], })),], ) @@ -942,48 +1003,58 @@ mod tests { ], }); + let array_of_arrays_x_expected = ( + Some(json!([[0], [1, 1, 1], [2, 2], [], [null, 4, 4, null, 4],])), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 0, "x"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 3, "x"], + })), + ], + ); assert_eq!( selection!(".arrayOfArrays.x").apply_to(&data), - ( - Some(json!([[0], [1, 1, 1], [2, 2], [], [null, 4, 4, null, 4],])), - vec![ - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 0], - })), - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 3], - })), - ], - ), + array_of_arrays_x_expected, + ); + assert_eq!( + selection!("$.arrayOfArrays.x").apply_to(&data), + array_of_arrays_x_expected, ); + let array_of_arrays_y_expected = ( + Some(json!([ + [0], + [0, 1, 2], + [0, 1], + [], + [null, 1, null, null, 4], + ])), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .y not found in null", + "path": ["arrayOfArrays", 4, 0, "y"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in object", + "path": ["arrayOfArrays", 4, 2, "y"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in null", + "path": ["arrayOfArrays", 4, 3, "y"], + })), + ], + ); assert_eq!( selection!(".arrayOfArrays.y").apply_to(&data), - ( - Some(json!([ - [0], - [0, 1, 2], - [0, 1], - [], - [null, 1, null, null, 4], - ])), - vec![ - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 0], - })), - ApplyToError::from_json(&json!({ - "message": "Response field y not found", - "path": ["arrayOfArrays", 4, 2, "y"], - })), - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 3], - })), - ], - ), + array_of_arrays_y_expected + ); + assert_eq!( + selection!("$.arrayOfArrays.y").apply_to(&data), + array_of_arrays_y_expected ); assert_eq!( @@ -1015,66 +1086,78 @@ mod tests { })), vec![ ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 0], + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 0, "x"], })), ApplyToError::from_json(&json!({ - "message": "Response field y not found", + "message": "Property .y not found in null", + "path": ["arrayOfArrays", 4, 0, "y"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in object", "path": ["arrayOfArrays", 4, 2, "y"], })), ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 3], + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 3, "x"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in null", + "path": ["arrayOfArrays", 4, 3, "y"], })), ], ), ); + let array_of_arrays_x_y_expected = ( + Some(json!({ + "ys": [ + [0], + [0, 1, 2], + [0, 1], + [], + [null, 1, null, null, 4], + ], + "xs": [ + [0], + [1, 1, 1], + [2, 2], + [], + [null, 4, 4, null, 4], + ], + })), + vec![ + ApplyToError::from_json(&json!({ + "message": "Property .y not found in null", + "path": ["arrayOfArrays", 4, 0, "y"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .y not found in object", + "path": ["arrayOfArrays", 4, 2, "y"], + })), + ApplyToError::from_json(&json!({ + // Reversing the order of "path" and "message" here to make + // sure that doesn't affect the deduplication logic. + "path": ["arrayOfArrays", 4, 3, "y"], + "message": "Property .y not found in null", + })), + ApplyToError::from_json(&json!({ + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 0, "x"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 3, "x"], + })), + ], + ); assert_eq!( selection!("ys: .arrayOfArrays.y xs: .arrayOfArrays.x").apply_to(&data), - ( - Some(json!({ - "ys": [ - [0], - [0, 1, 2], - [0, 1], - [], - [null, 1, null, null, 4], - ], - "xs": [ - [0], - [1, 1, 1], - [2, 2], - [], - [null, 4, 4, null, 4], - ], - })), - vec![ - ApplyToError::from_json(&json!({ - "message": "Expected an object in response, received null", - "path": ["arrayOfArrays", 4, 0], - })), - ApplyToError::from_json(&json!({ - "message": "Response field y not found", - "path": ["arrayOfArrays", 4, 2, "y"], - })), - ApplyToError::from_json(&json!({ - // Reversing the order of "path" and "message" here to make - // sure that doesn't affect the deduplication logic. - "path": ["arrayOfArrays", 4, 3], - "message": "Expected an object in response, received null", - })), - // These errors have already been reported along different paths, above. - // ApplyToError::from_json(&json!({ - // "message": "not an object", - // "path": ["arrayOfArrays", 4, 0], - // })), - // ApplyToError::from_json(&json!({ - // "message": "not an object", - // "path": ["arrayOfArrays", 4, 3], - // })), - ], - ), + array_of_arrays_x_y_expected, + ); + assert_eq!( + selection!("ys: $.arrayOfArrays.y xs: $.arrayOfArrays.x").apply_to(&data), + array_of_arrays_x_y_expected, ); } diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs index 04a8bb6168..2a68000e65 100644 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -359,15 +359,41 @@ impl Key { map(parse_string_literal, Self::Quoted), ))(input) } + + // This method returns the field/property name as a String, and is + // appropriate for accessing JSON properties, in contrast to the dotted + // method below. + pub fn as_string(&self) -> String { + match self { + Key::Field(name) => name.clone(), + Key::Quoted(name) => name.clone(), + Key::Index(n) => n.to_string(), + } + } + + // This method is used to implement the Display trait for Key, and includes + // a leading '.' character for string keys, as well as proper quoting for + // Key::Quoted values. However, these additions make key.dotted() unsafe to + // use for accessing JSON properties. + pub fn dotted(&self) -> String { + match self { + Key::Field(field) => format!(".{field}"), + Key::Quoted(field) => { + // JSON encoding is a reliable way to ensure a string that may + // contain special characters (such as '"' characters) is + // properly escaped and double-quoted. + let quoted = serde_json_bytes::Value::String(field.clone().into()).to_string(); + format!(".{quoted}") + } + Key::Index(index) => format!(".{index}"), + } + } } impl Display for Key { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Key::Field(field) => write!(f, ".{field}"), - Key::Quoted(quote) => write!(f, r#"."{quote}""#), - Key::Index(index) => write!(f, "[{index}]"), - } + let dotted = self.dotted(); + write!(f, "{dotted}") } } diff --git a/apollo-router/src/plugins/connectors/request_response.rs b/apollo-router/src/plugins/connectors/request_response.rs index ccdaf57460..bd8b195fae 100644 --- a/apollo-router/src/plugins/connectors/request_response.rs +++ b/apollo-router/src/plugins/connectors/request_response.rs @@ -1445,33 +1445,33 @@ mod tests { [ Response { connector: "[B] Query.field @sourceField(api: API, http: { GET: /path })", - message: "Response field c not found", + message: "Property .c not found in object", path: "c", }, Response { connector: "[B] Query.field @sourceField(api: API, http: { GET: /path })", - message: "Expected an object in response, received number", - path: "d", + message: "Property .e not found in number", + path: "d.e", }, Response { connector: "[B] Query.field @sourceField(api: API, http: { GET: /path })", - message: "Response field c not found", + message: "Property .c not found in object", path: "c", }, Response { connector: "[B] Query.field @sourceField(api: API, http: { GET: /path })", - message: "Expected an object in response, received number", - path: "d", + message: "Property .e not found in number", + path: "d.e", }, Response { connector: "[B] Query.field @sourceField(api: API, http: { GET: /path })", - message: "Response field iii not found", + message: "Property .iii not found in object", path: "i.ii.iii", }, Response { connector: "[B] Query.field @sourceField(api: API, http: { GET: /path })", - message: "Expected an object in response, received number", - path: "j", + message: "Property .jj not found in number", + path: "j.jj", }, Response { connector: "[B] Query.field @sourceField(api: API, http: { GET: /path })", From 42a076c8b1a8b2009557a922b8504cb65d3b44ba Mon Sep 17 00:00:00 2001 From: Ben Newman Date: Fri, 10 May 2024 11:46:37 -0400 Subject: [PATCH 12/12] Use JSON instead of Key for ApplyToError path array elements. --- .../connect/json_selection/apply_to.rs | 32 ++++++++----------- .../sources/connect/json_selection/parser.rs | 9 ++++++ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apollo-federation/src/sources/connect/json_selection/apply_to.rs b/apollo-federation/src/sources/connect/json_selection/apply_to.rs index 0c7ad15d7e..80bf5e1a77 100644 --- a/apollo-federation/src/sources/connect/json_selection/apply_to.rs +++ b/apollo-federation/src/sources/connect/json_selection/apply_to.rs @@ -41,7 +41,7 @@ pub trait ApplyTo { &self, data: &JSON, vars: &IndexMap, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option; @@ -51,13 +51,13 @@ pub trait ApplyTo { &self, data_array: &[JSON], vars: &IndexMap, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { let mut output = Vec::with_capacity(data_array.len()); for (i, element) in data_array.iter().enumerate() { - input_path.push(Key::Index(i)); + input_path.push(JSON::Number(i.into())); let value = self.apply_to_path(element, vars, input_path, errors); input_path.pop(); // When building an Object, we can simply omit missing properties @@ -85,14 +85,10 @@ impl Hash for ApplyToError { } impl ApplyToError { - fn new(message: &str, path: &[Key]) -> Self { + fn new(message: &str, path: &[JSON]) -> Self { Self(json!({ "message": message, - "path": path.iter().map(|property| match property { - Key::Field(name) => json!(name), - Key::Quoted(name) => json!(name), - Key::Index(index) => json!(index), - }).collect::>(), + "path": JSON::Array(path.to_vec()), })) } @@ -141,7 +137,7 @@ impl ApplyTo for JSONSelection { &self, data: &JSON, vars: &IndexMap, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { if let JSON::Array(array) = data { @@ -170,7 +166,7 @@ impl ApplyTo for NamedSelection { &self, data: &JSON, vars: &IndexMap, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { if let JSON::Array(array) = data { @@ -184,9 +180,9 @@ impl ApplyTo for NamedSelection { alias: Option<&Alias>, key: Key, selection: &Option, - input_path: &mut Vec, + input_path: &mut Vec, | { - input_path.push(key.clone()); + input_path.push(key.to_json()); let name = key.as_string(); if let Some(child) = data.get(name.clone()) { let output_name = alias.map_or(&name, |alias| &alias.name); @@ -251,7 +247,7 @@ impl ApplyTo for PathSelection { &self, data: &JSON, vars: &IndexMap, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { if let JSON::Array(array) = data { @@ -265,18 +261,18 @@ impl ApplyTo for PathSelection { // input_path instead of creating a new var_path here. tail.apply_to_path(data, vars, input_path, errors) } else if let Some(var_data) = vars.get(var_name) { - let mut var_path = vec![Key::Field(var_name.clone())]; + let mut var_path = vec![json!(var_name)]; tail.apply_to_path(var_data, vars, &mut var_path, errors) } else { errors.insert(ApplyToError::new( format!("Variable {} not found", var_name).as_str(), - &[Key::Field(var_name.clone())], + &[json!(var_name)], )); None } } Self::Key(key, tail) => { - input_path.push(key.clone()); + input_path.push(key.to_json()); if !matches!(data, JSON::Object(_)) { errors.insert(ApplyToError::new( @@ -334,7 +330,7 @@ impl ApplyTo for SubSelection { &self, data: &JSON, vars: &IndexMap, - input_path: &mut Vec, + input_path: &mut Vec, errors: &mut IndexSet, ) -> Option { if let JSON::Array(array) = data { diff --git a/apollo-federation/src/sources/connect/json_selection/parser.rs b/apollo-federation/src/sources/connect/json_selection/parser.rs index 2a68000e65..454d71e470 100644 --- a/apollo-federation/src/sources/connect/json_selection/parser.rs +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -14,6 +14,7 @@ use nom::sequence::preceded; use nom::sequence::tuple; use nom::IResult; use serde::Serialize; +use serde_json_bytes::Value as JSON; use super::helpers::spaces_or_comments; @@ -360,6 +361,14 @@ impl Key { ))(input) } + pub fn to_json(&self) -> JSON { + match self { + Key::Field(name) => JSON::String(name.clone().into()), + Key::Quoted(name) => JSON::String(name.clone().into()), + Key::Index(index) => JSON::Number((*index).into()), + } + } + // This method returns the field/property name as a String, and is // appropriate for accessing JSON properties, in contrast to the dotted // method below.