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..335b77c52b 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::json_selection::JSONSelection; +use crate::sources::connect::json_selection::Key; +use crate::sources::connect::json_selection::SubSelection; use crate::sources::connect::models::Connector; -use crate::sources::connect::selection_parser::Property; -use crate::sources::connect::selection_parser::SubSelection; -use crate::sources::connect::Selection; 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( @@ -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::selection_parser::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 50cd203017..f16320dae1 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::selection_parser::PathSelection; -use crate::sources::connect::selection_parser::Property; -use crate::sources::connect::Selection; -use crate::sources::connect::SubSelection; +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::SubSelection; use crate::sources::source; use crate::sources::source::federated_query_graph::FederatedQueryGraphApi; use crate::sources::source::SourceId; @@ -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,11 +64,11 @@ pub(crate) enum EnumNode { pub(crate) enum ScalarNode { SelectionRoot { subgraph_type: ScalarTypeDefinitionPosition, - property_path: Vec, + property_path: Vec, }, CustomScalarSelectionRoot { subgraph_type: ScalarTypeDefinitionPosition, - selection: Selection, + selection: JSONSelection, }, SelectionChild { 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 e3b8ec8f96..7f13437b3f 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::selection_parser::PathSelection; -use crate::sources::connect::selection_parser::Property; -use crate::sources::connect::Selection; -use crate::sources::connect::SubSelection; +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::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)] @@ -145,26 +145,26 @@ 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 { - selection: Selection, + selection: JSONSelection, }, } #[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::selection_parser::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/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/apply_to.rs b/apollo-federation/src/sources/connect/json_selection/apply_to.rs new file mode 100644 index 0000000000..80bf5e1a77 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/apply_to.rs @@ -0,0 +1,1312 @@ +/// 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; + +use indexmap::IndexMap; +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) { + 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, vars, &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 JSONSelection types. + fn apply_to_path( + &self, + data: &JSON, + vars: &IndexMap, + 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], + vars: &IndexMap, + 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(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 + // 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: &[JSON]) -> Self { + Self(json!({ + "message": message, + "path": JSON::Array(path.to_vec()), + })) + } + + #[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 JSONSelection { + 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, vars, input_path, errors); + } + + match self { + // 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, vars, input_path, errors) + } + Self::Path(path_selection) => { + path_selection.apply_to_path(data, vars, input_path, errors) + } + } + } +} + +impl ApplyTo for NamedSelection { + 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, 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>, + key: Key, + selection: &Option, + input_path: &mut Vec, + | { + 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); + if let Some(selection) = selection { + let value = selection.apply_to_path(child, vars, 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!( + "Property {} not found in {}", + key.dotted(), + json_type_name(data), + ).as_str(), + input_path, + )); + } + input_path.pop(); + }; + + match self { + Self::Field(alias, name, selection) => { + field_quoted_helper( + alias.as_ref(), + Key::Field(name.clone()), + selection, + input_path, + ); + } + Self::Quoted(alias, name, selection) => { + 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); + if let Some(value) = value { + output.insert(alias.name.clone(), value); + } + } + Self::Group(alias, sub_selection) => { + let value = sub_selection.apply_to_path(data, vars, 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, + vars: &IndexMap, + input_path: &mut Vec, + errors: &mut IndexSet, + ) -> Option { + if let JSON::Array(array) = data { + 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![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(), + &[json!(var_name)], + )); + None + } + } + Self::Key(key, tail) => { + input_path.push(key.to_json()); + + 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), + } { + tail.apply_to_path(child, vars, input_path, errors) + } else { + 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 + // call will handle the error. + selection.apply_to_path(data, vars, 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, + vars: &IndexMap, + input_path: &mut Vec, + errors: &mut IndexSet, + ) -> Option { + 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(); + let mut input_names = IndexSet::new(); + + for named_selection in &self.selections { + 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 { + 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::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 + // 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. + Key::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, vars, 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, vars, 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 => {} + }; + + if data_really_primitive && output.is_empty() { + return Some(data.clone()); + } + + 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: JSONSelection, 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.hello"), json!("world")); + + check_ok(selection!(".nested.world"), json!("hello")); + 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!("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!({ + "nested": { + "hello": "world", + "world": "hello", + }, + "grouped": { + "hello": "world", + "worlds": [ + "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: JSONSelection, 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![],) + ); + + 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!({})), 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!( + selection!(".nested.hello").apply_to(&data), + (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), + 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), + 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), + partial_array_expected, + ); + assert_eq!( + selection!("partial: $.array { hello goodbye }").apply_to(&data), + partial_array_expected, + ); + + 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": "Property .smello not found in object", + "path": ["array", 0, "smello"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .smello not found in object", + "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": "Property .smello not found in object", + "path": ["array", 0, "smello"], + })), + ApplyToError::from_json(&json!({ + "message": "Property .smello not found in object", + "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": "Property .smelly not found in object", + "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": "Property .smelly not found in object", + "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 }, + ] + ], + }); + + 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), + 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), + array_of_arrays_y_expected + ); + assert_eq!( + selection!("$.arrayOfArrays.y").apply_to(&data), + array_of_arrays_y_expected + ); + + 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": "Property .x not found in null", + "path": ["arrayOfArrays", 4, 0, "x"], + })), + 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 .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), + array_of_arrays_x_y_expected, + ); + assert_eq!( + selection!("ys: $.arrayOfArrays.y xs: $.arrayOfArrays.x").apply_to(&data), + array_of_arrays_x_y_expected, + ); + } + + #[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!({ + "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/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..c828cdaf35 --- /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..d2934e6c78 --- /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..743cbaf26d --- /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..0a28dac9b3 --- /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..575d9840a5 --- /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..2b4615d2e9 --- /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..12284c811c --- /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 + + + + + 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..e61cb3a7ce --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/graphql.rs @@ -0,0 +1,196 @@ +// 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 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::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: JSONSelection) -> Vec { + match val { + JSONSelection::Named(named_selections) => { + GraphQLSelections::from(named_selections).valid_selections() + } + JSONSelection::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::Var(_, _) => { + // Variable references do not correspond to GraphQL fields. + vec![] + } + PathSelection::Key(_, 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/helpers.rs b/apollo-federation/src/sources/connect/json_selection/helpers.rs new file mode 100644 index 0000000000..e811188a82 --- /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::JSONSelection::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 new file mode 100644 index 0000000000..dce9a3a44b --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/mod.rs @@ -0,0 +1,7 @@ +mod apply_to; +mod graphql; +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 new file mode 100644 index 0000000000..454d71e470 --- /dev/null +++ b/apollo-federation/src/sources/connect/json_selection/parser.rs @@ -0,0 +1,1393 @@ +use std::fmt::Display; + +use nom::branch::alt; +use nom::character::complete::char; +use nom::character::complete::one_of; +use nom::combinator::all_consuming; +use nom::combinator::map; +use nom::combinator::opt; +use nom::combinator::recognize; +use nom::multi::many0; +use nom::sequence::delimited; +use nom::sequence::pair; +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; + +// JSONSelection ::= NakedSubSelection | PathSelection +// NakedSubSelection ::= NamedSelection* StarSelection? + +#[derive(Debug, PartialEq, Clone, Serialize)] +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 JSONSelection { + pub fn parse(input: &str) -> IResult<&str, Self> { + alt(( + all_consuming(map( + tuple(( + many0(NamedSelection::parse), + // When a * selection is used, it must be the last selection + // in the sequence, since it is not a NamedSelection. + opt(StarSelection::parse), + // In case there were no named selections and no * selection, we + // still want to consume any space before the end of the input. + spaces_or_comments, + )), + |(selections, star, _)| Self::Named(SubSelection { selections, star }), + )), + all_consuming(map(PathSelection::parse, Self::Path)), + ))(input) + } +} + +// NamedSelection ::= NamedFieldSelection | NamedQuotedSelection | NamedPathSelection | NamedGroupSelection +// NamedFieldSelection ::= Alias? Identifier SubSelection? +// NamedQuotedSelection ::= Alias StringLiteral SubSelection? +// NamedPathSelection ::= Alias PathSelection +// NamedGroupSelection ::= Alias SubSelection + +#[derive(Debug, PartialEq, Clone, Serialize)] +pub enum NamedSelection { + Field(Option, String, Option), + Quoted(Alias, String, Option), + Path(Alias, PathSelection), + Group(Alias, SubSelection), +} + +impl NamedSelection { + fn parse(input: &str) -> IResult<&str, Self> { + alt(( + Self::parse_field, + Self::parse_quoted, + Self::parse_path, + Self::parse_group, + ))(input) + } + + fn parse_field(input: &str) -> IResult<&str, Self> { + tuple(( + opt(Alias::parse), + 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, + 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> { + tuple((Alias::parse, PathSelection::parse))(input) + .map(|(input, (alias, path))| (input, Self::Path(alias, path))) + } + + fn parse_group(input: &str) -> IResult<&str, Self> { + tuple((Alias::parse, SubSelection::parse))(input) + .map(|(input, (alias, group))| (input, Self::Group(alias, group))) + } + + #[allow(dead_code)] + pub(crate) fn name(&self) -> &str { + match self { + Self::Field(alias, name, _) => { + if let Some(alias) = alias { + alias.name.as_str() + } else { + name.as_str() + } + } + Self::Quoted(alias, _, _) => alias.name.as_str(), + Self::Path(alias, _) => alias.name.as_str(), + Self::Group(alias, _) => alias.name.as_str(), + } + } + + /// 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 { + match self { + NamedSelection::Field(_, name, _) => vec![Key::Field(name.to_string())], + NamedSelection::Quoted(_, _, Some(_)) => todo!(), + NamedSelection::Quoted(_, name, None) => vec![Key::Quoted(name.to_string())], + NamedSelection::Path(_, path) => path.collect_paths(), + NamedSelection::Group(alias, _) => vec![Key::Field(alias.name.to_string())], + } + } + + /// Find the next subselection, if present + pub(crate) fn next_subselection(&self) -> Option<&SubSelection> { + match self { + // Paths are complicated because they can have a subselection deeply nested + NamedSelection::Path(_, path) => path.next_subselection(), + + // The other options have it at the root + NamedSelection::Field(_, _, Some(sub)) + | NamedSelection::Quoted(_, _, Some(sub)) + | NamedSelection::Group(_, sub) => Some(sub), + + // Every other option does not have a subselection + _ => None, + } + } +} + +// PathSelection ::= (VarPath | KeyPath) SubSelection? +// VarPath ::= "$" (NO_SPACE Identifier)? PathStep* +// KeyPath ::= Key PathStep+ +// PathStep ::= "." Key | "->" Identifier MethodArgs? + +#[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. + Var(String, Box), + Key(Key, Box), + Selection(SubSelection), + Empty, +} + +impl PathSelection { + fn parse(input: &str) -> IResult<&str, Self> { + 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 { + match properties { + [] => selection.map_or(Self::Empty, Self::Selection), + [head, tail @ ..] => { + Self::Key(head.clone(), Box::new(Self::from_slice(tail, selection))) + } + } + } + + /// Collect all nested paths + /// + /// 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 { + let mut results = Vec::new(); + + // Collect as many as possible + let mut current = self; + while let Self::Key(key, rest) = current { + results.push(key.clone()); + + current = rest; + } + + results + } + + /// 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, + } + } +} + +// SubSelection ::= "{" NakedSubSelection "}" + +#[derive(Debug, PartialEq, Clone, Serialize)] +pub struct SubSelection { + pub selections: Vec, + pub star: Option, +} + +impl SubSelection { + fn parse(input: &str) -> IResult<&str, Self> { + tuple(( + spaces_or_comments, + char('{'), + many0(NamedSelection::parse), + // Note that when a * selection is used, it must be the last + // selection in the SubSelection, since it does not count as a + // NamedSelection, and is stored as a separate field from the + // selections vector. + opt(StarSelection::parse), + spaces_or_comments, + char('}'), + spaces_or_comments, + ))(input) + .map(|(input, (_, _, selections, star, _, _, _))| (input, Self { selections, star })) + } +} + +// StarSelection ::= Alias? "*" SubSelection? + +#[derive(Debug, PartialEq, Clone, Serialize)] +pub struct StarSelection(pub Option, pub Option>); + +impl StarSelection { + fn parse(input: &str) -> IResult<&str, Self> { + tuple(( + // The spaces_or_comments separators are necessary here because + // Alias::parse and SubSelection::parse only consume surrounding + // spaces when they match, and they are both optional here. + opt(Alias::parse), + spaces_or_comments, + char('*'), + spaces_or_comments, + opt(SubSelection::parse), + ))(input) + .map(|(remainder, (alias, _, _, _, selection))| { + (remainder, Self(alias, selection.map(Box::new))) + }) + } +} + +// Alias ::= Identifier ":" + +#[derive(Debug, PartialEq, Clone, Serialize)] +pub struct Alias { + pub(crate) name: String, +} + +impl Alias { + fn parse(input: &str) -> IResult<&str, Self> { + tuple(( + spaces_or_comments, + parse_identifier, + spaces_or_comments, + char(':'), + spaces_or_comments, + ))(input) + .map(|(input, (_, name, _, _, _))| (input, Self { name })) + } +} + +// Key ::= Identifier | StringLiteral + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize)] +pub enum Key { + Field(String), + Quoted(String), + Index(usize), +} + +impl Key { + fn parse(input: &str) -> IResult<&str, Self> { + alt(( + map(parse_identifier, Self::Field), + map(parse_string_literal, Self::Quoted), + ))(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. + 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 { + let dotted = self.dotted(); + write!(f, "{dotted}") + } +} + +// Identifier ::= [a-zA-Z_] NO_SPACE [0-9a-zA-Z_]* + +fn parse_identifier(input: &str) -> IResult<&str, String> { + delimited( + spaces_or_comments, + recognize(pair( + one_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"), + many0(one_of( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789", + )), + )), + spaces_or_comments, + )(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)?; + let mut input_char_indices = input.char_indices(); + + match input_char_indices.next() { + Some((0, quote @ '\'')) | Some((0, quote @ '"')) => { + let mut escape_next = false; + let mut chars: Vec = vec![]; + let mut remainder: Option<&str> = None; + + for (i, c) in input_char_indices { + if escape_next { + match c { + 'n' => chars.push('\n'), + _ => chars.push(c), + } + escape_next = false; + continue; + } + if c == '\\' { + escape_next = true; + continue; + } + if c == quote { + remainder = Some(spaces_or_comments(&input[i + 1..])?.0); + break; + } + chars.push(c); + } + + if let Some(remainder) = remainder { + Ok((remainder, chars.iter().collect::())) + } else { + Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::Eof, + ))) + } + } + + _ => Err(nom::Err::Error(nom::error::Error::new( + input, + nom::error::ErrorKind::IsNot, + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::selection; + + #[test] + fn test_identifier() { + assert_eq!(parse_identifier("hello"), Ok(("", "hello".to_string())),); + + assert_eq!( + parse_identifier("hello_world"), + Ok(("", "hello_world".to_string())), + ); + + assert_eq!( + parse_identifier("hello_world_123"), + Ok(("", "hello_world_123".to_string())), + ); + + assert_eq!(parse_identifier(" hello "), Ok(("", "hello".to_string())),); + } + + #[test] + fn test_string_literal() { + assert_eq!( + parse_string_literal("'hello world'"), + Ok(("", "hello world".to_string())), + ); + assert_eq!( + parse_string_literal("\"hello world\""), + Ok(("", "hello world".to_string())), + ); + assert_eq!( + parse_string_literal("'hello \"world\"'"), + Ok(("", "hello \"world\"".to_string())), + ); + assert_eq!( + parse_string_literal("\"hello \\\"world\\\"\""), + Ok(("", "hello \"world\"".to_string())), + ); + assert_eq!( + parse_string_literal("'hello \\'world\\''"), + Ok(("", "hello 'world'".to_string())), + ); + } + #[test] + fn test_key() { + assert_eq!( + Key::parse("hello"), + Ok(("", Key::Field("hello".to_string()))), + ); + + assert_eq!( + Key::parse("'hello'"), + Ok(("", Key::Quoted("hello".to_string()))), + ); + } + + #[test] + fn test_alias() { + assert_eq!( + Alias::parse("hello:"), + Ok(( + "", + Alias { + name: "hello".to_string(), + }, + )), + ); + + assert_eq!( + Alias::parse("hello :"), + Ok(( + "", + Alias { + name: "hello".to_string(), + }, + )), + ); + + assert_eq!( + Alias::parse("hello : "), + Ok(( + "", + Alias { + name: "hello".to_string(), + }, + )), + ); + + assert_eq!( + Alias::parse(" hello :"), + Ok(( + "", + Alias { + name: "hello".to_string(), + }, + )), + ); + + assert_eq!( + Alias::parse("hello: "), + Ok(( + "", + Alias { + name: "hello".to_string(), + }, + )), + ); + } + + #[test] + fn test_named_selection() { + fn assert_result_and_name(input: &str, expected: NamedSelection, name: &str) { + let actual = NamedSelection::parse(input); + assert_eq!(actual, Ok(("", expected.clone()))); + assert_eq!(actual.unwrap().1.name(), name); + assert_eq!( + selection!(input), + JSONSelection::Named(SubSelection { + selections: vec![expected], + star: None, + }), + ); + } + + assert_result_and_name( + "hello", + NamedSelection::Field(None, "hello".to_string(), None), + "hello", + ); + + assert_result_and_name( + "hello { world }", + NamedSelection::Field( + None, + "hello".to_string(), + Some(SubSelection { + selections: vec![NamedSelection::Field(None, "world".to_string(), None)], + star: None, + }), + ), + "hello", + ); + + assert_result_and_name( + "hi: hello", + NamedSelection::Field( + Some(Alias { + name: "hi".to_string(), + }), + "hello".to_string(), + None, + ), + "hi", + ); + + assert_result_and_name( + "hi: 'hello world'", + NamedSelection::Quoted( + Alias { + name: "hi".to_string(), + }, + "hello world".to_string(), + None, + ), + "hi", + ); + + assert_result_and_name( + "hi: hello { world }", + NamedSelection::Field( + Some(Alias { + name: "hi".to_string(), + }), + "hello".to_string(), + Some(SubSelection { + selections: vec![NamedSelection::Field(None, "world".to_string(), None)], + star: None, + }), + ), + "hi", + ); + + assert_result_and_name( + "hey: hello { world again }", + NamedSelection::Field( + Some(Alias { + name: "hey".to_string(), + }), + "hello".to_string(), + Some(SubSelection { + selections: vec![ + NamedSelection::Field(None, "world".to_string(), None), + NamedSelection::Field(None, "again".to_string(), None), + ], + star: None, + }), + ), + "hey", + ); + + assert_result_and_name( + "hey: 'hello world' { again }", + NamedSelection::Quoted( + Alias { + name: "hey".to_string(), + }, + "hello world".to_string(), + Some(SubSelection { + selections: vec![NamedSelection::Field(None, "again".to_string(), None)], + star: None, + }), + ), + "hey", + ); + + assert_result_and_name( + "leggo: 'my ego'", + NamedSelection::Quoted( + Alias { + name: "leggo".to_string(), + }, + "my ego".to_string(), + None, + ), + "leggo", + ); + } + + #[test] + fn test_selection() { + assert_eq!( + selection!(""), + JSONSelection::Named(SubSelection { + selections: vec![], + star: None, + }), + ); + + assert_eq!( + selection!(" "), + JSONSelection::Named(SubSelection { + selections: vec![], + star: None, + }), + ); + + assert_eq!( + selection!("hello"), + JSONSelection::Named(SubSelection { + selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], + star: None, + }), + ); + + assert_eq!( + selection!(".hello"), + JSONSelection::Path(PathSelection::from_slice( + &[Key::Field("hello".to_string()),], + None + )), + ); + + assert_eq!( + selection!("hi: .hello.world"), + JSONSelection::Named(SubSelection { + selections: vec![NamedSelection::Path( + Alias { + name: "hi".to_string(), + }, + PathSelection::from_slice( + &[ + Key::Field("hello".to_string()), + Key::Field("world".to_string()), + ], + None + ), + )], + star: None, + }), + ); + + assert_eq!( + selection!("before hi: .hello.world after"), + JSONSelection::Named(SubSelection { + selections: vec![ + NamedSelection::Field(None, "before".to_string(), None), + NamedSelection::Path( + Alias { + name: "hi".to_string(), + }, + PathSelection::from_slice( + &[ + Key::Field("hello".to_string()), + Key::Field("world".to_string()), + ], + None + ), + ), + NamedSelection::Field(None, "after".to_string(), None), + ], + star: None, + }), + ); + + let before_path_nested_after_result = JSONSelection::Named(SubSelection { + selections: vec![ + NamedSelection::Field(None, "before".to_string(), None), + NamedSelection::Path( + Alias { + name: "hi".to_string(), + }, + PathSelection::from_slice( + &[ + Key::Field("hello".to_string()), + Key::Field("world".to_string()), + ], + Some(SubSelection { + selections: vec![ + NamedSelection::Field(None, "nested".to_string(), None), + NamedSelection::Field(None, "names".to_string(), None), + ], + star: None, + }), + ), + ), + NamedSelection::Field(None, "after".to_string(), None), + ], + star: None, + }); + + assert_eq!( + selection!("before hi: .hello.world { nested names } after"), + before_path_nested_after_result, + ); + + assert_eq!( + selection!("before hi:.hello.world{nested names}after"), + before_path_nested_after_result, + ); + + assert_eq!( + selection!( + " + # Comments are supported because we parse them as whitespace + topLevelAlias: topLevelField { + # Non-identifier properties must be aliased as an identifier + nonIdentifier: 'property name with spaces' + + # This extracts the value located at the given path and applies a + # selection set to it before renaming the result to pathSelection + pathSelection: .some.nested.path { + still: yet + more + properties + } + + # An aliased SubSelection of fields nests the fields together + # under the given alias + siblingGroup: { brother sister } + }" + ), + JSONSelection::Named(SubSelection { + selections: vec![NamedSelection::Field( + Some(Alias { + name: "topLevelAlias".to_string(), + }), + "topLevelField".to_string(), + Some(SubSelection { + selections: vec![ + NamedSelection::Quoted( + Alias { + name: "nonIdentifier".to_string(), + }, + "property name with spaces".to_string(), + None, + ), + NamedSelection::Path( + Alias { + name: "pathSelection".to_string(), + }, + PathSelection::from_slice( + &[ + Key::Field("some".to_string()), + Key::Field("nested".to_string()), + Key::Field("path".to_string()), + ], + Some(SubSelection { + selections: vec![ + NamedSelection::Field( + Some(Alias { + name: "still".to_string(), + }), + "yet".to_string(), + None, + ), + NamedSelection::Field(None, "more".to_string(), None,), + NamedSelection::Field( + None, + "properties".to_string(), + None, + ), + ], + star: None, + }) + ), + ), + NamedSelection::Group( + Alias { + name: "siblingGroup".to_string(), + }, + SubSelection { + selections: vec![ + NamedSelection::Field(None, "brother".to_string(), None,), + NamedSelection::Field(None, "sister".to_string(), None,), + ], + star: None, + }, + ), + ], + star: None, + }), + )], + star: None, + }), + ); + } + + 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() { + check_path_selection( + ".hello", + PathSelection::from_slice(&[Key::Field("hello".to_string())], None), + ); + + check_path_selection( + ".hello.world", + PathSelection::from_slice( + &[ + Key::Field("hello".to_string()), + Key::Field("world".to_string()), + ], + None, + ), + ); + + check_path_selection( + ".hello.world { hello }", + PathSelection::from_slice( + &[ + Key::Field("hello".to_string()), + Key::Field("world".to_string()), + ], + Some(SubSelection { + selections: vec![NamedSelection::Field(None, "hello".to_string(), None)], + star: None, + }), + ), + ); + + check_path_selection( + ".nested.'string literal'.\"property\".name", + PathSelection::from_slice( + &[ + Key::Field("nested".to_string()), + Key::Quoted("string literal".to_string()), + Key::Quoted("property".to_string()), + Key::Field("name".to_string()), + ], + None, + ), + ); + + check_path_selection( + ".nested.'string literal' { leggo: 'my ego' }", + PathSelection::from_slice( + &[ + Key::Field("nested".to_string()), + Key::Quoted("string literal".to_string()), + ], + Some(SubSelection { + selections: vec![NamedSelection::Quoted( + Alias { + name: "leggo".to_string(), + }, + "my ego".to_string(), + None, + )], + star: None, + }), + ), + ); + } + + #[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!( + SubSelection::parse(" { \n } "), + Ok(( + "", + SubSelection { + selections: vec![], + star: None, + }, + )), + ); + + assert_eq!( + SubSelection::parse("{hello}"), + Ok(( + "", + SubSelection { + selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], + star: None, + }, + )), + ); + + assert_eq!( + SubSelection::parse("{ hello }"), + Ok(( + "", + SubSelection { + selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], + star: None, + }, + )), + ); + + assert_eq!( + SubSelection::parse(" { padded } "), + Ok(( + "", + SubSelection { + selections: vec![NamedSelection::Field(None, "padded".to_string(), None),], + star: None, + }, + )), + ); + + assert_eq!( + SubSelection::parse("{ hello world }"), + Ok(( + "", + SubSelection { + selections: vec![ + NamedSelection::Field(None, "hello".to_string(), None), + NamedSelection::Field(None, "world".to_string(), None), + ], + star: None, + }, + )), + ); + + assert_eq!( + SubSelection::parse("{ hello { world } }"), + Ok(( + "", + SubSelection { + selections: vec![NamedSelection::Field( + None, + "hello".to_string(), + Some(SubSelection { + selections: vec![NamedSelection::Field( + None, + "world".to_string(), + None + ),], + star: None, + }) + ),], + star: None, + }, + )), + ); + } + + #[test] + fn test_star_selection() { + assert_eq!( + StarSelection::parse("rest: *"), + Ok(( + "", + StarSelection( + Some(Alias { + name: "rest".to_string(), + }), + None + ), + )), + ); + + assert_eq!( + StarSelection::parse("*"), + Ok(("", StarSelection(None, None),)), + ); + + assert_eq!( + StarSelection::parse(" * "), + Ok(("", StarSelection(None, None),)), + ); + + assert_eq!( + StarSelection::parse(" * { hello } "), + Ok(( + "", + StarSelection( + None, + Some(Box::new(SubSelection { + selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], + star: None, + })) + ), + )), + ); + + assert_eq!( + StarSelection::parse("hi: * { hello }"), + Ok(( + "", + StarSelection( + Some(Alias { + name: "hi".to_string(), + }), + Some(Box::new(SubSelection { + selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], + star: None, + })) + ), + )), + ); + + assert_eq!( + StarSelection::parse("alias: * { x y z rest: * }"), + Ok(( + "", + StarSelection( + Some(Alias { + name: "alias".to_string() + }), + Some(Box::new(SubSelection { + selections: vec![ + NamedSelection::Field(None, "x".to_string(), None), + NamedSelection::Field(None, "y".to_string(), None), + NamedSelection::Field(None, "z".to_string(), None), + ], + star: Some(StarSelection( + Some(Alias { + name: "rest".to_string(), + }), + None + )), + })), + ), + )), + ); + + assert_eq!( + selection!(" before alias: * { * { a b c } } "), + JSONSelection::Named(SubSelection { + selections: vec![NamedSelection::Field(None, "before".to_string(), None),], + star: Some(StarSelection( + Some(Alias { + name: "alias".to_string() + }), + Some(Box::new(SubSelection { + selections: vec![], + star: Some(StarSelection( + None, + Some(Box::new(SubSelection { + selections: vec![ + NamedSelection::Field(None, "a".to_string(), None), + NamedSelection::Field(None, "b".to_string(), None), + NamedSelection::Field(None, "c".to_string(), None), + ], + star: None, + })) + )), + })), + )), + }), + ); + + assert_eq!( + selection!(" before group: { * { a b c } } after "), + JSONSelection::Named(SubSelection { + selections: vec![ + NamedSelection::Field(None, "before".to_string(), None), + NamedSelection::Group( + Alias { + name: "group".to_string(), + }, + SubSelection { + selections: vec![], + star: Some(StarSelection( + None, + Some(Box::new(SubSelection { + selections: vec![ + NamedSelection::Field(None, "a".to_string(), None), + NamedSelection::Field(None, "b".to_string(), None), + NamedSelection::Field(None, "c".to_string(), None), + ], + star: None, + })) + )), + }, + ), + NamedSelection::Field(None, "after".to_string(), None), + ], + star: None, + }), + ); + } +} diff --git a/apollo-federation/src/sources/connect/mod.rs b/apollo-federation/src/sources/connect/mod.rs index 397f0a060f..8a4092f4af 100644 --- a/apollo-federation/src/sources/connect/mod.rs +++ b/apollo-federation/src/sources/connect/mod.rs @@ -6,16 +6,18 @@ 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::JSONSelection; +pub use json_selection::Key; +pub use json_selection::PathSelection; +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/selection_parser.rs b/apollo-federation/src/sources/connect/selection_parser.rs deleted file mode 100644 index 3a8e02f88d..0000000000 --- a/apollo-federation/src/sources/connect/selection_parser.rs +++ /dev/null @@ -1,2507 +0,0 @@ -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::multispace0; -use nom::character::complete::one_of; -use nom::combinator::all_consuming; -use nom::combinator::map; -use nom::combinator::opt; -use nom::combinator::recognize; -use nom::multi::many0; -use nom::multi::many1; -use nom::sequence::pair; -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; - -// 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()])); - } - } -} - -// Selection ::= 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. - Named(SubSelection), - Path(PathSelection), -} - -impl Selection { - pub fn parse(input: &str) -> IResult<&str, Self> { - alt(( - all_consuming(map( - tuple(( - many0(NamedSelection::parse), - // When a * selection is used, it must be the last selection - // in the sequence, since it is not a NamedSelection. - opt(StarSelection::parse), - // In case there were no named selections and no * selection, we - // still want to consume any space before the end of the input. - spaces_or_comments, - )), - |(selections, star, _)| Self::Named(SubSelection { selections, star }), - )), - all_consuming(map(PathSelection::parse, Self::Path)), - ))(input) - } -} - -// NamedSelection ::= -// | Alias? Identifier SubSelection? -// | Alias StringLiteral SubSelection? -// | Alias PathSelection -// | Alias SubSelection - -#[derive(Debug, PartialEq, Clone, Serialize)] -pub enum NamedSelection { - Field(Option, String, Option), - Quoted(Alias, String, Option), - Path(Alias, PathSelection), - Group(Alias, SubSelection), -} - -impl NamedSelection { - fn parse(input: &str) -> IResult<&str, Self> { - alt(( - Self::parse_field, - Self::parse_quoted, - Self::parse_path, - Self::parse_group, - ))(input) - } - - fn parse_field(input: &str) -> IResult<&str, Self> { - tuple(( - opt(Alias::parse), - parse_identifier, - 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))) - } - - fn parse_path(input: &str) -> IResult<&str, Self> { - tuple((Alias::parse, PathSelection::parse))(input) - .map(|(input, (alias, path))| (input, Self::Path(alias, path))) - } - - fn parse_group(input: &str) -> IResult<&str, Self> { - tuple((Alias::parse, SubSelection::parse))(input) - .map(|(input, (alias, group))| (input, Self::Group(alias, group))) - } - - #[allow(dead_code)] - pub(crate) fn name(&self) -> &str { - match self { - Self::Field(alias, name, _) => { - if let Some(alias) = alias { - alias.name.as_str() - } else { - name.as_str() - } - } - Self::Quoted(alias, _, _) => alias.name.as_str(), - Self::Path(alias, _) => alias.name.as_str(), - Self::Group(alias, _) => alias.name.as_str(), - } - } - - /// 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 { - match self { - NamedSelection::Field(_, name, _) => vec![Property::Field(name.to_string())], - NamedSelection::Quoted(_, _, Some(_)) => todo!(), - NamedSelection::Quoted(_, name, None) => vec![Property::Quoted(name.to_string())], - NamedSelection::Path(_, path) => path.collect_paths(), - NamedSelection::Group(alias, _) => vec![Property::Field(alias.name.to_string())], - } - } - - /// Find the next subselection, if present - pub(crate) fn next_subselection(&self) -> Option<&SubSelection> { - match self { - // Paths are complicated because they can have a subselection deeply nested - NamedSelection::Path(_, path) => path.next_subselection(), - - // The other options have it at the root - NamedSelection::Field(_, _, Some(sub)) - | NamedSelection::Quoted(_, _, Some(sub)) - | NamedSelection::Group(_, sub) => Some(sub), - - // Every other option does not have a subselection - _ => None, - } - } -} - -// PathSelection ::= ("." Property)+ SubSelection? - -#[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), - Selection(SubSelection), - Empty, -} - -impl PathSelection { - fn parse(input: &str) -> IResult<&str, Self> { - tuple(( - spaces_or_comments, - many1(preceded(char('.'), Property::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 { - match properties { - [] => selection.map_or(Self::Empty, Self::Selection), - [head, tail @ ..] => { - Self::Path(head.clone(), Box::new(Self::from_slice(tail, selection))) - } - } - } - - /// Collect all nested paths - /// - /// 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 { - 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()); - - current = rest; - } - - results - } - - /// 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::Selection(sub) => Some(sub), - PathSelection::Empty => None, - } - } -} - -// SubSelection ::= "{" NamedSelection* StarSelection? "}" - -#[derive(Debug, PartialEq, Clone, Serialize)] -pub struct SubSelection { - pub selections: Vec, - pub star: Option, -} - -impl SubSelection { - fn parse(input: &str) -> IResult<&str, Self> { - tuple(( - spaces_or_comments, - char('{'), - many0(NamedSelection::parse), - // Note that when a * selection is used, it must be the last - // selection in the SubSelection, since it does not count as a - // NamedSelection, and is stored as a separate field from the - // selections vector. - opt(StarSelection::parse), - spaces_or_comments, - char('}'), - spaces_or_comments, - ))(input) - .map(|(input, (_, _, selections, star, _, _, _))| (input, Self { selections, star })) - } -} - -// StarSelection ::= Alias? "*" SubSelection? - -#[derive(Debug, PartialEq, Clone, Serialize)] -pub struct StarSelection(Option, Option>); - -impl StarSelection { - fn parse(input: &str) -> IResult<&str, Self> { - tuple(( - // The spaces_or_comments separators are necessary here because - // Alias::parse and SubSelection::parse only consume surrounding - // spaces when they match, and they are both optional here. - opt(Alias::parse), - spaces_or_comments, - char('*'), - spaces_or_comments, - opt(SubSelection::parse), - ))(input) - .map(|(remainder, (alias, _, _, _, selection))| { - (remainder, Self(alias, selection.map(Box::new))) - }) - } -} - -// Alias ::= Identifier ":" - -#[derive(Debug, PartialEq, Clone, Serialize)] -pub struct Alias { - name: String, -} - -impl Alias { - fn parse(input: &str) -> IResult<&str, Self> { - tuple((parse_identifier, char(':'), spaces_or_comments))(input) - .map(|(input, (name, _, _))| (input, Self { name })) - } -} - -// Property ::= Identifier | StringLiteral - -#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize)] -pub enum Property { - Field(String), - Quoted(String), - Index(usize), -} - -impl Property { - fn parse(input: &str) -> IResult<&str, Self> { - alt(( - map(parse_identifier, Self::Field), - map(parse_string_literal, Self::Quoted), - ))(input) - } -} - -impl Display for Property { - 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}]"), - } - } -} - -// Identifier ::= [a-zA-Z_][0-9a-zA-Z_]* - -fn parse_identifier(input: &str) -> IResult<&str, String> { - tuple(( - spaces_or_comments, - recognize(pair( - one_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"), - many0(one_of( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789", - )), - )), - spaces_or_comments, - ))(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)?; - let mut input_char_indices = input.char_indices(); - - match input_char_indices.next() { - Some((0, quote @ '\'')) | Some((0, quote @ '"')) => { - let mut escape_next = false; - let mut chars: Vec = vec![]; - let mut remainder: Option<&str> = None; - - for (i, c) in input_char_indices { - if escape_next { - match c { - 'n' => chars.push('\n'), - _ => chars.push(c), - } - escape_next = false; - continue; - } - if c == '\\' { - escape_next = true; - continue; - } - if c == quote { - remainder = Some(spaces_or_comments(&input[i + 1..])?.0); - break; - } - chars.push(c); - } - - if let Some(remainder) = remainder { - Ok((remainder, chars.iter().collect::())) - } else { - Err(nom::Err::Error(nom::error::Error::new( - input, - nom::error::ErrorKind::Eof, - ))) - } - } - - _ => Err(nom::Err::Error(nom::error::Error::new( - input, - nom::error::ErrorKind::IsNot, - ))), - } -} - -/// 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)) - } -} - -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; -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::*; - - // 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 - ", " - "))); - } - - #[test] - fn test_identifier() { - assert_eq!(parse_identifier("hello"), Ok(("", "hello".to_string())),); - - assert_eq!( - parse_identifier("hello_world"), - Ok(("", "hello_world".to_string())), - ); - - assert_eq!( - parse_identifier("hello_world_123"), - Ok(("", "hello_world_123".to_string())), - ); - - assert_eq!(parse_identifier(" hello "), Ok(("", "hello".to_string())),); - } - - #[test] - fn test_string_literal() { - assert_eq!( - parse_string_literal("'hello world'"), - Ok(("", "hello world".to_string())), - ); - assert_eq!( - parse_string_literal("\"hello world\""), - Ok(("", "hello world".to_string())), - ); - assert_eq!( - parse_string_literal("'hello \"world\"'"), - Ok(("", "hello \"world\"".to_string())), - ); - assert_eq!( - parse_string_literal("\"hello \\\"world\\\"\""), - Ok(("", "hello \"world\"".to_string())), - ); - assert_eq!( - parse_string_literal("'hello \\'world\\''"), - Ok(("", "hello 'world'".to_string())), - ); - } - #[test] - fn test_property() { - assert_eq!( - Property::parse("hello"), - Ok(("", Property::Field("hello".to_string()))), - ); - - assert_eq!( - Property::parse("'hello'"), - Ok(("", Property::Quoted("hello".to_string()))), - ); - } - - #[test] - fn test_alias() { - assert_eq!( - Alias::parse("hello:"), - Ok(( - "", - Alias { - name: "hello".to_string(), - }, - )), - ); - - assert_eq!( - Alias::parse("hello :"), - Ok(( - "", - Alias { - name: "hello".to_string(), - }, - )), - ); - - assert_eq!( - Alias::parse("hello : "), - Ok(( - "", - Alias { - name: "hello".to_string(), - }, - )), - ); - - assert_eq!( - Alias::parse(" hello :"), - Ok(( - "", - Alias { - name: "hello".to_string(), - }, - )), - ); - - assert_eq!( - Alias::parse("hello: "), - Ok(( - "", - Alias { - name: "hello".to_string(), - }, - )), - ); - } - - #[test] - fn test_named_selection() { - fn assert_result_and_name(input: &str, expected: NamedSelection, name: &str) { - let actual = NamedSelection::parse(input); - assert_eq!(actual, Ok(("", expected.clone()))); - assert_eq!(actual.unwrap().1.name(), name); - assert_eq!( - selection!(input), - Selection::Named(SubSelection { - selections: vec![expected], - star: None, - }), - ); - } - - assert_result_and_name( - "hello", - NamedSelection::Field(None, "hello".to_string(), None), - "hello", - ); - - assert_result_and_name( - "hello { world }", - NamedSelection::Field( - None, - "hello".to_string(), - Some(SubSelection { - selections: vec![NamedSelection::Field(None, "world".to_string(), None)], - star: None, - }), - ), - "hello", - ); - - assert_result_and_name( - "hi: hello", - NamedSelection::Field( - Some(Alias { - name: "hi".to_string(), - }), - "hello".to_string(), - None, - ), - "hi", - ); - - assert_result_and_name( - "hi: 'hello world'", - NamedSelection::Quoted( - Alias { - name: "hi".to_string(), - }, - "hello world".to_string(), - None, - ), - "hi", - ); - - assert_result_and_name( - "hi: hello { world }", - NamedSelection::Field( - Some(Alias { - name: "hi".to_string(), - }), - "hello".to_string(), - Some(SubSelection { - selections: vec![NamedSelection::Field(None, "world".to_string(), None)], - star: None, - }), - ), - "hi", - ); - - assert_result_and_name( - "hey: hello { world again }", - NamedSelection::Field( - Some(Alias { - name: "hey".to_string(), - }), - "hello".to_string(), - Some(SubSelection { - selections: vec![ - NamedSelection::Field(None, "world".to_string(), None), - NamedSelection::Field(None, "again".to_string(), None), - ], - star: None, - }), - ), - "hey", - ); - - assert_result_and_name( - "hey: 'hello world' { again }", - NamedSelection::Quoted( - Alias { - name: "hey".to_string(), - }, - "hello world".to_string(), - Some(SubSelection { - selections: vec![NamedSelection::Field(None, "again".to_string(), None)], - star: None, - }), - ), - "hey", - ); - - assert_result_and_name( - "leggo: 'my ego'", - NamedSelection::Quoted( - Alias { - name: "leggo".to_string(), - }, - "my ego".to_string(), - None, - ), - "leggo", - ); - } - - #[test] - fn test_selection() { - assert_eq!( - selection!(""), - Selection::Named(SubSelection { - selections: vec![], - star: None, - }), - ); - - assert_eq!( - selection!(" "), - Selection::Named(SubSelection { - selections: vec![], - star: None, - }), - ); - - assert_eq!( - selection!("hello"), - Selection::Named(SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], - star: None, - }), - ); - - assert_eq!( - selection!(".hello"), - Selection::Path(PathSelection::from_slice( - &[Property::Field("hello".to_string()),], - None - )), - ); - - assert_eq!( - selection!("hi: .hello.world"), - Selection::Named(SubSelection { - selections: vec![NamedSelection::Path( - Alias { - name: "hi".to_string(), - }, - PathSelection::from_slice( - &[ - Property::Field("hello".to_string()), - Property::Field("world".to_string()), - ], - None - ), - )], - star: None, - }), - ); - - assert_eq!( - selection!("before hi: .hello.world after"), - Selection::Named(SubSelection { - selections: vec![ - NamedSelection::Field(None, "before".to_string(), None), - NamedSelection::Path( - Alias { - name: "hi".to_string(), - }, - PathSelection::from_slice( - &[ - Property::Field("hello".to_string()), - Property::Field("world".to_string()), - ], - None - ), - ), - NamedSelection::Field(None, "after".to_string(), None), - ], - star: None, - }), - ); - - let before_path_nested_after_result = Selection::Named(SubSelection { - selections: vec![ - NamedSelection::Field(None, "before".to_string(), None), - NamedSelection::Path( - Alias { - name: "hi".to_string(), - }, - PathSelection::from_slice( - &[ - Property::Field("hello".to_string()), - Property::Field("world".to_string()), - ], - Some(SubSelection { - selections: vec![ - NamedSelection::Field(None, "nested".to_string(), None), - NamedSelection::Field(None, "names".to_string(), None), - ], - star: None, - }), - ), - ), - NamedSelection::Field(None, "after".to_string(), None), - ], - star: None, - }); - - assert_eq!( - selection!("before hi: .hello.world { nested names } after"), - before_path_nested_after_result, - ); - - assert_eq!( - selection!("before hi:.hello.world{nested names}after"), - before_path_nested_after_result, - ); - - assert_eq!( - selection!( - " - # Comments are supported because we parse them as whitespace - topLevelAlias: topLevelField { - # Non-identifier properties must be aliased as an identifier - nonIdentifier: 'property name with spaces' - - # This extracts the value located at the given path and applies a - # selection set to it before renaming the result to pathSelection - pathSelection: .some.nested.path { - still: yet - more - properties - } - - # An aliased SubSelection of fields nests the fields together - # under the given alias - siblingGroup: { brother sister } - }" - ), - Selection::Named(SubSelection { - selections: vec![NamedSelection::Field( - Some(Alias { - name: "topLevelAlias".to_string(), - }), - "topLevelField".to_string(), - Some(SubSelection { - selections: vec![ - NamedSelection::Quoted( - Alias { - name: "nonIdentifier".to_string(), - }, - "property name with spaces".to_string(), - None, - ), - NamedSelection::Path( - Alias { - name: "pathSelection".to_string(), - }, - PathSelection::from_slice( - &[ - Property::Field("some".to_string()), - Property::Field("nested".to_string()), - Property::Field("path".to_string()), - ], - Some(SubSelection { - selections: vec![ - NamedSelection::Field( - Some(Alias { - name: "still".to_string(), - }), - "yet".to_string(), - None, - ), - NamedSelection::Field(None, "more".to_string(), None,), - NamedSelection::Field( - None, - "properties".to_string(), - None, - ), - ], - star: None, - }) - ), - ), - NamedSelection::Group( - Alias { - name: "siblingGroup".to_string(), - }, - SubSelection { - selections: vec![ - NamedSelection::Field(None, "brother".to_string(), None,), - NamedSelection::Field(None, "sister".to_string(), None,), - ], - star: None, - }, - ), - ], - star: None, - }), - )], - star: None, - }), - ); - } - - #[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), Selection::Path(expected.clone())); - } - - check_path_selection( - ".hello", - PathSelection::from_slice(&[Property::Field("hello".to_string())], None), - ); - - check_path_selection( - ".hello.world", - PathSelection::from_slice( - &[ - Property::Field("hello".to_string()), - Property::Field("world".to_string()), - ], - None, - ), - ); - - check_path_selection( - ".hello.world { hello }", - PathSelection::from_slice( - &[ - Property::Field("hello".to_string()), - Property::Field("world".to_string()), - ], - Some(SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None)], - star: None, - }), - ), - ); - - check_path_selection( - ".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()), - ], - None, - ), - ); - - check_path_selection( - ".nested.'string literal' { leggo: 'my ego' }", - PathSelection::from_slice( - &[ - Property::Field("nested".to_string()), - Property::Quoted("string literal".to_string()), - ], - Some(SubSelection { - selections: vec![NamedSelection::Quoted( - Alias { - name: "leggo".to_string(), - }, - "my ego".to_string(), - None, - )], - star: None, - }), - ), - ); - } - - #[test] - fn test_subselection() { - assert_eq!( - SubSelection::parse(" { \n } "), - Ok(( - "", - SubSelection { - selections: vec![], - star: None, - }, - )), - ); - - assert_eq!( - SubSelection::parse("{hello}"), - Ok(( - "", - SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], - star: None, - }, - )), - ); - - assert_eq!( - SubSelection::parse("{ hello }"), - Ok(( - "", - SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], - star: None, - }, - )), - ); - - assert_eq!( - SubSelection::parse(" { padded } "), - Ok(( - "", - SubSelection { - selections: vec![NamedSelection::Field(None, "padded".to_string(), None),], - star: None, - }, - )), - ); - - assert_eq!( - SubSelection::parse("{ hello world }"), - Ok(( - "", - SubSelection { - selections: vec![ - NamedSelection::Field(None, "hello".to_string(), None), - NamedSelection::Field(None, "world".to_string(), None), - ], - star: None, - }, - )), - ); - - assert_eq!( - SubSelection::parse("{ hello { world } }"), - Ok(( - "", - SubSelection { - selections: vec![NamedSelection::Field( - None, - "hello".to_string(), - Some(SubSelection { - selections: vec![NamedSelection::Field( - None, - "world".to_string(), - None - ),], - star: None, - }) - ),], - star: None, - }, - )), - ); - } - - #[test] - fn test_star_selection() { - assert_eq!( - StarSelection::parse("rest: *"), - Ok(( - "", - StarSelection( - Some(Alias { - name: "rest".to_string(), - }), - None - ), - )), - ); - - assert_eq!( - StarSelection::parse("*"), - Ok(("", StarSelection(None, None),)), - ); - - assert_eq!( - StarSelection::parse(" * "), - Ok(("", StarSelection(None, None),)), - ); - - assert_eq!( - StarSelection::parse(" * { hello } "), - Ok(( - "", - StarSelection( - None, - Some(Box::new(SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], - star: None, - })) - ), - )), - ); - - assert_eq!( - StarSelection::parse("hi: * { hello }"), - Ok(( - "", - StarSelection( - Some(Alias { - name: "hi".to_string(), - }), - Some(Box::new(SubSelection { - selections: vec![NamedSelection::Field(None, "hello".to_string(), None),], - star: None, - })) - ), - )), - ); - - assert_eq!( - StarSelection::parse("alias: * { x y z rest: * }"), - Ok(( - "", - StarSelection( - Some(Alias { - name: "alias".to_string() - }), - Some(Box::new(SubSelection { - selections: vec![ - NamedSelection::Field(None, "x".to_string(), None), - NamedSelection::Field(None, "y".to_string(), None), - NamedSelection::Field(None, "z".to_string(), None), - ], - star: Some(StarSelection( - Some(Alias { - name: "rest".to_string(), - }), - None - )), - })), - ), - )), - ); - - assert_eq!( - selection!(" before alias: * { * { a b c } } "), - Selection::Named(SubSelection { - selections: vec![NamedSelection::Field(None, "before".to_string(), None),], - star: Some(StarSelection( - Some(Alias { - name: "alias".to_string() - }), - Some(Box::new(SubSelection { - selections: vec![], - star: Some(StarSelection( - None, - Some(Box::new(SubSelection { - selections: vec![ - NamedSelection::Field(None, "a".to_string(), None), - NamedSelection::Field(None, "b".to_string(), None), - NamedSelection::Field(None, "c".to_string(), None), - ], - star: None, - })) - )), - })), - )), - }), - ); - - assert_eq!( - selection!(" before group: { * { a b c } } after "), - Selection::Named(SubSelection { - selections: vec![ - NamedSelection::Field(None, "before".to_string(), None), - NamedSelection::Group( - Alias { - name: "group".to_string(), - }, - SubSelection { - selections: vec![], - star: Some(StarSelection( - None, - Some(Box::new(SubSelection { - selections: vec![ - NamedSelection::Field(None, "a".to_string(), None), - NamedSelection::Field(None, "b".to_string(), None), - NamedSelection::Field(None, "c".to_string(), None), - ], - star: None, - })) - )), - }, - ), - NamedSelection::Field(None, "after".to_string(), None), - ], - star: None, - }), - ); - } - - #[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 { - 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/spec/directives.rs b/apollo-federation/src/sources/connect/spec/directives.rs index 688ae11ce8..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::selection_parser::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 19be9ebbdd..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::selection_parser::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..bd8b195fae 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; @@ -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 })",