graphql-stitching 0.3.2 → 0.3.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33a2615122207bdf93a7df873fe1efe7a7f3c13050f59dabc6e85becd75cf96e
4
- data.tar.gz: 9f203624ab5c26a3cb6ab4710759ef7cb76c1890fff8f4849ab31404875fe79a
3
+ metadata.gz: 0b8fbbdd8c300982092ee9297953f87e014420801e1a6058a49e36d5de8fb85b
4
+ data.tar.gz: 1fbd9b21161e8452a2fa55c4617ffabe8834c49e1b0635a58e17ed9b0cb43315
5
5
  SHA512:
6
- metadata.gz: ad1afa71d6525ea79857106eca6c347f77fab8141dd9a504f3da1a6631f598fe7fb154bc446e228e420fa46dff993140ff568fc3dc94af7a76f0cf29da550896
7
- data.tar.gz: 2632af6a1347f1fb6513c79781c3959aacdd879583519cb31e69834a48f2ae70cafdb778d6915713d0d19bf535a67cac55763377cc27da0795b80ed6abc298d4
6
+ metadata.gz: 9eb747176cb0b39ced4ce35a10bab8f4a8e0b7448a5d6a9a74e8e74f484aba7f1bea46c1eff3ca3b118ee3be5fd2f8e7f5ebffb23dd5e6480a92abe620af9523
7
+ data.tar.gz: 64d935449b052d52b1068f22cc7df4b33608bb2b05bb5f8670da0e045f1c0faa06e44aa137acedab469312db5cda5144755a8d99b116a1c7ac09f809759ef198
data/README.md CHANGED
@@ -5,7 +5,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
5
5
  ![Stitched graph](./docs/images/stitching.png)
6
6
 
7
7
  **Supports:**
8
- - Merged object and interface types.
8
+ - Merged object and abstract types.
9
9
  - Multiple keys per merged type.
10
10
  - Shared objects, fields, enums, and inputs across locations.
11
11
  - Combining local and remote schemas.
@@ -70,9 +70,9 @@ result = gateway.execute(
70
70
  )
71
71
  ```
72
72
 
73
- Schemas provided to the `Gateway` constructor may be class-based schemas with local resolvers (locally-executable schemas), or schemas built from SDL strings (schema definition language parsed using `GraphQL::Schema.from_definition`) and mapped to remote locations. See [composer docs](./docs/composer.md#merge-patterns) for more information on how schemas get merged.
73
+ Schemas provided in [location settings](./docs/composer.md#performing-composition) may be class-based schemas with local resolvers (locally-executable schemas), or schemas built from SDL strings (schema definition language parsed using `GraphQL::Schema.from_definition`) and mapped to remote locations. See [composer docs](./docs/composer.md#merge-patterns) for more information on how schemas get merged.
74
74
 
75
- While the [`Gateway`](./docs/gateway.md) constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
75
+ While the `Gateway` constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
76
76
 
77
77
  - [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
78
78
  - [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
@@ -148,7 +148,7 @@ type Query {
148
148
  * The `@stitch` directive is applied to a root query where the merged type may be accessed. The merged type identity is inferred from the field return.
149
149
  * The `key: "id"` parameter indicates that an `{ id }` must be selected from prior locations so it may be submitted as an argument to this query. The query argument used to send the key is inferred when possible (more on arguments later).
150
150
 
151
- Each location that provides a unique variant of a type must provide _exactly one_ stitching query per possible key (more on multiple keys later). The exception to this requirement are types that contain only a single key field:
151
+ Each location that provides a unique variant of a type must provide one stitching query per key. The exception to this requirement are types that contain only a single key field:
152
152
 
153
153
  ```graphql
154
154
  type Product {
@@ -166,8 +166,13 @@ It's okay ([even preferable](https://www.youtube.com/watch?v=VmK0KBHTcWs) in man
166
166
  type Query {
167
167
  products(ids: [ID!]!): [Product]! @stitch(key: "id")
168
168
  }
169
+
170
+ # input: ["1", "2", "3"]
171
+ # result: [{ id: "1" }, null, { id: "3" }]
169
172
  ```
170
173
 
174
+ See [error handling](./docs/mechanics.md#stitched-errors) tips for list queries.
175
+
171
176
  #### Abstract queries
172
177
 
173
178
  It's okay for stitching queries to be implemented through abstract types. An abstract query will provide access to all of its possible types. For interfaces, the key selection should match a field within the interface. For unions, all possible types must implement the key selection individually.
@@ -334,6 +339,13 @@ The `GraphQL::Stitching::RemoteClient` class is provided as a simple executable
334
339
 
335
340
  The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based implementation of `GraphQL::Dataloader`. Non-blocking concurrency requires setting a fiber scheduler via `Fiber.set_scheduler`, see [graphql-ruby docs](https://graphql-ruby.org/dataloader/nonblocking.html). You may also need to build your own remote clients using corresponding HTTP libraries.
336
341
 
342
+ ## Additional topics
343
+
344
+ - [Field selection routing](./docs/mechanics.md#field-selection-routing)
345
+ - [Root selection routing](./docs/mechanics.md#root-selection-routing)
346
+ - [Stitched errors](./docs/mechanics.md#stitched-errors)
347
+ - [Null results](./docs/mechanics.md#null-results)
348
+
337
349
  ## Example
338
350
 
339
351
  This repo includes a working example of several stitched schemas running across small Rack servers. Try running it:
data/docs/README.md CHANGED
@@ -12,3 +12,7 @@ Major components include:
12
12
  - [Request](./request.md) - prepares a requested GraphQL document and variables for stitching.
13
13
  - [Planner](./planner.md) - builds a cacheable query plan for a request document.
14
14
  - [Executor](./executor.md) - executes a query plan with given request variables.
15
+
16
+ Additional topics:
17
+
18
+ - [Stitching mechanics](./mechanics.md) - learn more about building for stitching.
data/docs/composer.md CHANGED
@@ -13,6 +13,7 @@ composer = GraphQL::Stitching::Composer.new(
13
13
  description_merger: ->(values_by_location, info) { values_by_location.values.join("\n") },
14
14
  deprecation_merger: ->(values_by_location, info) { values_by_location.values.first },
15
15
  directive_kwarg_merger: ->(values_by_location, info) { values_by_location.values.last },
16
+ root_field_location_selector: ->(locations, info) { locations.last },
16
17
  )
17
18
  ```
18
19
 
@@ -28,12 +29,14 @@ Constructor arguments:
28
29
 
29
30
  - **`directive_kwarg_merger:`** _optional_, a [value merger function](#value-merger-functions) for merging directive keyword arguments from across locations.
30
31
 
32
+ - **`root_field_location_selector:`** _optional_, selects a default routing location for root fields with multiple locations. Use this to prioritize sending root fields to their primary data sources (only applies while routing the root operation scope). This handler receives an array of possible locations and an info object with field information, and should return the prioritized location. The last location is used by default.
33
+
31
34
  #### Value merger functions
32
35
 
33
36
  Static data values such as element descriptions and directive arguments must also merge across locations. By default, the first non-null value encountered for a given element attribute is used. A value merger function may customize this process by selecting a different value or computing a new one:
34
37
 
35
38
  ```ruby
36
- supergraph = GraphQL::Stitching::Composer.new(
39
+ composer = GraphQL::Stitching::Composer.new(
37
40
  description_merger: ->(values_by_location, info) { values_by_location.values.compact.join("\n") },
38
41
  )
39
42
  ```
data/docs/mechanics.md ADDED
@@ -0,0 +1,209 @@
1
+ ## Schema Stitching, mechanics
2
+
3
+ ### Field selection routing
4
+
5
+ Fields of a merged type may exist in multiple locations. For example, the `title` field below is provided by both locations:
6
+
7
+ ```graphql
8
+ # -- Location A
9
+
10
+ type Movie {
11
+ id: String!
12
+ title: String! # shared
13
+ rating: Int!
14
+ }
15
+
16
+ type Query {
17
+ movieA(id: ID!): Movie @stitch(key: "id")
18
+ }
19
+
20
+ # -- Location B
21
+
22
+ type Movie {
23
+ id: String!
24
+ title: String! # shared
25
+ reviews: [String!]!
26
+ }
27
+
28
+ type Query {
29
+ movieB(id: ID!): Movie @stitch(key: "id")
30
+ }
31
+ ```
32
+
33
+ When planning a request, field selections always attempt to use the current routing location that originates from the selection root, for example:
34
+
35
+ ```graphql
36
+ query GetTitleFromA {
37
+ movieA(id: "23") { # <- enter via Location A
38
+ title # <- source from Location A
39
+ }
40
+ }
41
+
42
+ query GetTitleFromB {
43
+ movieB(id: "23") { # <- enter via Location B
44
+ title # <- source from Location B
45
+ }
46
+ }
47
+ ```
48
+
49
+ Field selections that are NOT available in the current routing location delegate to new locations as follows:
50
+
51
+ 1. Fields with only one location automatically use that location.
52
+ 2. Fields with multiple locations attempt to use a location added during step-1.
53
+ 3. Any remaining fields pick a location based on their highest availability among locations.
54
+
55
+ ### Root selection routing
56
+
57
+ Root fields should route to the primary locations of their provided types. This assures that the most common data for a type can be resolved via root access and thus avoid unnecessary stitching. Root fields can select their primary locations using the `root_field_location_selector` option in [composer configuration](./composer.md#configuring-composition):
58
+
59
+ ```ruby
60
+ supergraph = GraphQL::Stitching::Composer.new(
61
+ root_field_location_selector: ->(locations) { locations.find { _1 == "a" } || locations.last },
62
+ ).perform({ ... })
63
+ ```
64
+
65
+ It's okay if root field names are repeated across locations. The primary location will be used when routing root selections:
66
+
67
+ ```graphql
68
+ # -- Location A
69
+
70
+ type Movie {
71
+ id: String!
72
+ rating: Int!
73
+ }
74
+
75
+ type Query {
76
+ movie(id: ID!): Movie @stitch(key: "id") # shared, primary
77
+ }
78
+
79
+ # -- Location B
80
+
81
+ type Movie {
82
+ id: String!
83
+ reviews: [String!]!
84
+ }
85
+
86
+ type Query {
87
+ movie(id: ID!): Movie @stitch(key: "id") # shared
88
+ }
89
+
90
+ # -- Request
91
+
92
+ query {
93
+ movie(id: "23") { id } # routes to Location A
94
+ }
95
+ ```
96
+
97
+ Note that primary location routing _only_ applies to selections in the root scope. If the `Query` type appears again lower in the graph, then its fields are resolved as normal object fields outside of root context, for example:
98
+
99
+ ```graphql
100
+ schema {
101
+ query: Query # << root query, uses primary locations
102
+ }
103
+
104
+ type Query {
105
+ subquery: Query # << subquery, acts as a normal object type
106
+ }
107
+ ```
108
+
109
+ Also note that stitching queries (denoted by the `@stitch` directive) are completely separate from field routing concerns. A `@stitch` directive establishes a contract for resolving a given type in a given location. This contract is always used to collect stitching data, regardless of how request routing selected the location for use.
110
+
111
+ ### Stitched errors
112
+
113
+ Any [spec GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) returned by a stitching query will flow through the request. Stitching has two strategies for passing errors through to the final result:
114
+
115
+ 1. **Direct passthrough**, where subgraph errors are returned directly without modification. This strategy is used for errors without a `path` (ie: "base" errors), and errors pathed to root fields.
116
+
117
+ 2. **Mapped passthrough**, where the `path` attribute of a subgraph error is remapped to an insertion point in the stitched request. This strategy is used when merging stitching queries into the composed result.
118
+
119
+ In either strategy, it's important that subgraphs provide properly pathed errors (GraphQL Ruby [can do this automatically](https://graphql-ruby.org/errors/overview.html)). For example:
120
+
121
+ ```json
122
+ {
123
+ "data": { "shop": { "product": null } },
124
+ "errors": [{
125
+ "message": "Record not found.",
126
+ "path": ["shop", "product"]
127
+ }]
128
+ }
129
+ ```
130
+
131
+ When resolving [stitching list queries](../README.md#list-queries), it's important to only error out specific array positions rather than the entire array result, for example:
132
+
133
+ ```ruby
134
+ def products
135
+ [
136
+ { id: "1" },
137
+ GraphQL::ExecutionError.new("Not found"),
138
+ { id: "3" },
139
+ ]
140
+ end
141
+ ```
142
+
143
+ Stitching expects list queries to pad their missing elements with null, and to report corresponding errors pathed down to list position:
144
+
145
+ ```json
146
+ {
147
+ "data": {
148
+ "products": [{ "id": "1" }, null, { "id": "3" }]
149
+ },
150
+ "errors": [{
151
+ "message": "Record not found.",
152
+ "path": ["products", 1]
153
+ }]
154
+ }
155
+ ```
156
+
157
+ ### Null results
158
+
159
+ It's okay for a stitching query to return `null` for a merged type as long as all unique fields of the type allow null. For example, the following merge works:
160
+
161
+ ```graphql
162
+ # -- Request
163
+
164
+ query {
165
+ movieA(id: "23") {
166
+ id
167
+ title
168
+ rating
169
+ }
170
+ }
171
+
172
+ # -- Location A
173
+
174
+ type Movie {
175
+ id: String!
176
+ title: String!
177
+ }
178
+
179
+ type Query {
180
+ movieA(id: ID!): Movie @stitch(key: "id")
181
+ # (id: "23") -> { id: "23", title: "Jurassic Park" }
182
+ }
183
+
184
+ # -- Location B
185
+
186
+ type Movie {
187
+ id: String!
188
+ rating: Int
189
+ }
190
+
191
+ type Query {
192
+ movieB(id: ID!): Movie @stitch(key: "id")
193
+ # (id: "23") -> null
194
+ }
195
+ ```
196
+
197
+ And produces this result:
198
+
199
+ ```json
200
+ {
201
+ "data": {
202
+ "id": "23",
203
+ "title": "Jurassic Park",
204
+ "rating": null
205
+ }
206
+ }
207
+ ```
208
+
209
+ Location B is allowed to return `null` here because its one unique field, `rating`, is nullable (the `id` field can be provided by Location A). If `rating` were non-null, then null bubbling would invalidate the response data.
@@ -12,21 +12,21 @@ module GraphQL
12
12
  next if type.graphql_name.start_with?("__")
13
13
 
14
14
  # multiple subschemas implement the type
15
- subschema_types_by_location = composer.subschema_types_by_name_and_location[type_name]
16
- next unless subschema_types_by_location.length > 1
15
+ candidate_types_by_location = composer.candidate_types_by_name_and_location[type_name]
16
+ next unless candidate_types_by_location.length > 1
17
17
 
18
18
  boundaries = ctx.boundaries[type_name]
19
19
  if boundaries&.any?
20
- validate_as_boundary(ctx, type, subschema_types_by_location, boundaries)
20
+ validate_as_boundary(ctx, type, candidate_types_by_location, boundaries)
21
21
  elsif type.kind.object?
22
- validate_as_shared(ctx, type, subschema_types_by_location)
22
+ validate_as_shared(ctx, type, candidate_types_by_location)
23
23
  end
24
24
  end
25
25
  end
26
26
 
27
27
  private
28
28
 
29
- def validate_as_boundary(ctx, type, subschema_types_by_location, boundaries)
29
+ def validate_as_boundary(ctx, type, candidate_types_by_location, boundaries)
30
30
  # abstract boundaries are expanded with their concrete implementations, which each get validated. Ignore the abstract itself.
31
31
  return if type.kind.abstract?
32
32
 
@@ -42,19 +42,19 @@ module GraphQL
42
42
  end
43
43
 
44
44
  boundary_keys = boundaries.map { _1["selection"] }.uniq
45
- key_only_types_by_location = subschema_types_by_location.select do |location, subschema_type|
45
+ key_only_types_by_location = candidate_types_by_location.select do |location, subschema_type|
46
46
  subschema_type.fields.keys.length == 1 && boundary_keys.include?(subschema_type.fields.keys.first)
47
47
  end
48
48
 
49
49
  # all locations have a boundary, or else are key-only
50
- subschema_types_by_location.each do |location, subschema_type|
50
+ candidate_types_by_location.each do |location, subschema_type|
51
51
  unless boundaries_by_location_and_key[location] || key_only_types_by_location[location]
52
52
  raise Composer::ValidationError, "A boundary query is required for `#{type.graphql_name}` in #{location} because it provides unique fields."
53
53
  end
54
54
  end
55
55
 
56
56
  outbound_access_locations = key_only_types_by_location.keys
57
- bidirectional_access_locations = subschema_types_by_location.keys - outbound_access_locations
57
+ bidirectional_access_locations = candidate_types_by_location.keys - outbound_access_locations
58
58
 
59
59
  # verify that all outbound locations can access all inbound locations
60
60
  (outbound_access_locations + bidirectional_access_locations).each do |location|
@@ -67,7 +67,7 @@ module GraphQL
67
67
  end
68
68
  end
69
69
 
70
- def validate_as_shared(ctx, type, subschema_types_by_location)
70
+ def validate_as_shared(ctx, type, candidate_types_by_location)
71
71
  expected_fields = begin
72
72
  type.fields.keys.sort
73
73
  rescue StandardError => e
@@ -79,7 +79,7 @@ module GraphQL
79
79
  end
80
80
  end
81
81
 
82
- subschema_types_by_location.each do |location, subschema_type|
82
+ candidate_types_by_location.each do |location, subschema_type|
83
83
  if subschema_type.fields.keys.sort != expected_fields
84
84
  raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\
85
85
  "or else define boundary queries so that its unique fields may be accessed remotely."
@@ -6,9 +6,10 @@ module GraphQL
6
6
  class ComposerError < StitchingError; end
7
7
  class ValidationError < ComposerError; end
8
8
 
9
- attr_reader :query_name, :mutation_name, :subschema_types_by_name_and_location, :schema_directives
9
+ attr_reader :query_name, :mutation_name, :candidate_types_by_name_and_location, :schema_directives
10
10
 
11
11
  DEFAULT_VALUE_MERGER = ->(values_by_location, _info) { values_by_location.values.find { !_1.nil? } }
12
+ DEFAULT_ROOT_FIELD_LOCATION_SELECTOR = ->(locations, _info) { locations.last }
12
13
 
13
14
  VALIDATORS = [
14
15
  "ValidateInterfaces",
@@ -20,13 +21,15 @@ module GraphQL
20
21
  mutation_name: "Mutation",
21
22
  description_merger: nil,
22
23
  deprecation_merger: nil,
23
- directive_kwarg_merger: nil
24
+ directive_kwarg_merger: nil,
25
+ root_field_location_selector: nil
24
26
  )
25
27
  @query_name = query_name
26
28
  @mutation_name = mutation_name
27
29
  @description_merger = description_merger || DEFAULT_VALUE_MERGER
28
30
  @deprecation_merger = deprecation_merger || DEFAULT_VALUE_MERGER
29
31
  @directive_kwarg_merger = directive_kwarg_merger || DEFAULT_VALUE_MERGER
32
+ @root_field_location_selector = root_field_location_selector || DEFAULT_ROOT_FIELD_LOCATION_SELECTOR
30
33
  end
31
34
 
32
35
  def perform(locations_input)
@@ -34,7 +37,7 @@ module GraphQL
34
37
  schemas, executables = prepare_locations_input(locations_input)
35
38
 
36
39
  # "directive_name" => "location" => candidate_directive
37
- @subschema_directives_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
40
+ @candidate_directives_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
38
41
  (schema.directives.keys - schema.default_directives.keys - GraphQL::Stitching.stitching_directive_names).each do |directive_name|
39
42
  memo[directive_name] ||= {}
40
43
  memo[directive_name][location] = schema.directives[directive_name]
@@ -42,14 +45,14 @@ module GraphQL
42
45
  end
43
46
 
44
47
  # "Typename" => merged_directive
45
- @schema_directives = @subschema_directives_by_name_and_location.each_with_object({}) do |(directive_name, directives_by_location), memo|
48
+ @schema_directives = @candidate_directives_by_name_and_location.each_with_object({}) do |(directive_name, directives_by_location), memo|
46
49
  memo[directive_name] = build_directive(directive_name, directives_by_location)
47
50
  end
48
51
 
49
52
  @schema_directives.merge!(GraphQL::Schema.default_directives)
50
53
 
51
54
  # "Typename" => "location" => candidate_type
52
- @subschema_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
55
+ @candidate_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
53
56
  raise ComposerError, "Location keys must be strings" unless location.is_a?(String)
54
57
  raise ComposerError, "The subscription operation is not supported." if schema.subscription
55
58
 
@@ -74,7 +77,7 @@ module GraphQL
74
77
  enum_usage = build_enum_usage_map(schemas.values)
75
78
 
76
79
  # "Typename" => merged_type
77
- schema_types = @subschema_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo|
80
+ schema_types = @candidate_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo|
78
81
  kinds = types_by_location.values.map { _1.kind.name }.uniq
79
82
 
80
83
  if kinds.length > 1
@@ -110,6 +113,7 @@ module GraphQL
110
113
  own_orphan_types.clear
111
114
  end
112
115
 
116
+ select_root_field_locations(schema)
113
117
  expand_abstract_boundaries(schema)
114
118
 
115
119
  supergraph = Supergraph.new(
@@ -498,13 +502,31 @@ module GraphQL
498
502
  end
499
503
  end
500
504
 
505
+ def select_root_field_locations(schema)
506
+ [schema.query, schema.mutation].tap(&:compact!).each do |root_type|
507
+ root_type.fields.each do |root_field_name, root_field|
508
+ root_field_locations = @field_map[root_type.graphql_name][root_field_name]
509
+ next unless root_field_locations.length > 1
510
+
511
+ target_location = @root_field_location_selector.call(root_field_locations, {
512
+ type_name: root_type.graphql_name,
513
+ field_name: root_field_name,
514
+ })
515
+ next unless root_field_locations.include?(target_location)
516
+
517
+ root_field_locations.reject! { _1 == target_location }
518
+ root_field_locations.unshift(target_location)
519
+ end
520
+ end
521
+ end
522
+
501
523
  def expand_abstract_boundaries(schema)
502
524
  @boundary_map.keys.each do |type_name|
503
525
  boundary_type = schema.types[type_name]
504
526
  next unless boundary_type.kind.abstract?
505
527
 
506
528
  expanded_types = Util.expand_abstract_type(schema, boundary_type)
507
- expanded_types.select { @subschema_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type|
529
+ expanded_types.select { @candidate_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type|
508
530
  @boundary_map[expanded_type.graphql_name] ||= []
509
531
  @boundary_map[expanded_type.graphql_name].push(*@boundary_map[type_name])
510
532
  end
@@ -555,7 +577,7 @@ module GraphQL
555
577
  @field_map = {}
556
578
  @boundary_map = {}
557
579
  @mapped_type_names = {}
558
- @subschema_directives_by_name_and_location = nil
580
+ @candidate_directives_by_name_and_location = nil
559
581
  @schema_directives = nil
560
582
  end
561
583
  end
@@ -58,9 +58,7 @@ module GraphQL
58
58
  def fetch(ops)
59
59
  origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
60
60
  origin_set = op["insertion_path"].reduce([@executor.data]) do |set, path_segment|
61
- mapped = set.flat_map { |obj| obj && obj[path_segment] }
62
- mapped.compact!
63
- mapped
61
+ set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
64
62
  end
65
63
 
66
64
  if op["type_condition"]
@@ -253,7 +251,7 @@ module GraphQL
253
251
 
254
252
  if @data && @data.length > 0
255
253
  result["data"] = raw ? @data : GraphQL::Stitching::Shaper.new(
256
- schema: @supergraph.schema,
254
+ supergraph: @supergraph,
257
255
  request: @request,
258
256
  ).perform!(@data)
259
257
  end
@@ -35,25 +35,25 @@ module GraphQL
35
35
  return error_result(validation_errors) if validation_errors.any?
36
36
  end
37
37
 
38
- begin
39
- request.prepare!
38
+ request.prepare!
40
39
 
41
- plan = fetch_plan(request) do
42
- GraphQL::Stitching::Planner.new(
43
- supergraph: @supergraph,
44
- request: request,
45
- ).perform.to_h
46
- end
47
-
48
- GraphQL::Stitching::Executor.new(
40
+ plan = fetch_plan(request) do
41
+ GraphQL::Stitching::Planner.new(
49
42
  supergraph: @supergraph,
50
43
  request: request,
51
- plan: plan,
52
- ).perform
53
- rescue StandardError => e
54
- custom_message = @on_error.call(e, request.context) if @on_error
55
- error_result([{ "message" => custom_message || "An unexpected error occured." }])
44
+ ).perform.to_h
56
45
  end
46
+
47
+ GraphQL::Stitching::Executor.new(
48
+ supergraph: @supergraph,
49
+ request: request,
50
+ plan: plan,
51
+ ).perform
52
+ rescue GraphQL::ParseError, GraphQL::ExecutionError => e
53
+ error_result([e])
54
+ rescue StandardError => e
55
+ custom_message = @on_error.call(e, request.context) if @on_error
56
+ error_result([{ "message" => custom_message || "An unexpected error occured." }])
57
57
  end
58
58
 
59
59
  def on_cache_read(&block)
@@ -89,10 +89,8 @@ module GraphQL
89
89
  end
90
90
 
91
91
  def error_result(errors)
92
- public_errors = errors.map do |e|
93
- public_error = e.is_a?(Hash) ? e : e.to_h
94
- public_error["path"] ||= []
95
- public_error
92
+ public_errors = errors.map! do |e|
93
+ e.is_a?(Hash) ? e : e.to_h
96
94
  end
97
95
 
98
96
  { "errors" => public_errors }
@@ -37,12 +37,11 @@ module GraphQL
37
37
  when "query"
38
38
  parent_type = @supergraph.schema.query
39
39
 
40
- selections_by_location = @request.operation.selections.each_with_object({}) do |node, memo|
40
+ selections_by_location = {}
41
+ each_selection_in_type(parent_type, @request.operation.selections) do |node|
41
42
  locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
42
-
43
- # root fields currently just delegate to the last location that defined them; this should probably be smarter
44
- memo[locations.last] ||= []
45
- memo[locations.last] << node
43
+ selections_by_location[locations.first] ||= []
44
+ selections_by_location[locations.first] << node
46
45
  end
47
46
 
48
47
  selections_by_location.each do |location, selections|
@@ -52,15 +51,15 @@ module GraphQL
52
51
  when "mutation"
53
52
  parent_type = @supergraph.schema.mutation
54
53
 
55
- location_groups = @request.operation.selections.each_with_object([]) do |node, memo|
56
- # root fields currently just delegate to the last location that defined them; this should probably be smarter
57
- next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].last
54
+ location_groups = []
55
+ each_selection_in_type(parent_type, @request.operation.selections) do |node|
56
+ next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
58
57
 
59
- if memo.none? || memo.last[:location] != next_location
60
- memo << { location: next_location, selections: [] }
58
+ if location_groups.none? || location_groups.last[:location] != next_location
59
+ location_groups << { location: next_location, selections: [] }
61
60
  end
62
61
 
63
- memo.last[:selections] << node
62
+ location_groups.last[:selections] << node
64
63
  end
65
64
 
66
65
  location_groups.reduce(0) do |after_key, group|
@@ -78,6 +77,27 @@ module GraphQL
78
77
  end
79
78
  end
80
79
 
80
+ def each_selection_in_type(parent_type, input_selections, &block)
81
+ input_selections.each do |node|
82
+ case node
83
+ when GraphQL::Language::Nodes::Field
84
+ yield(node)
85
+
86
+ when GraphQL::Language::Nodes::InlineFragment
87
+ next unless parent_type.graphql_name == node.type.name
88
+ each_selection_in_type(parent_type, node.selections, &block)
89
+
90
+ when GraphQL::Language::Nodes::FragmentSpread
91
+ fragment = @request.fragment_definitions[node.name]
92
+ next unless parent_type.graphql_name == fragment.type.name
93
+ each_selection_in_type(parent_type, fragment.selections, &block)
94
+
95
+ else
96
+ raise "Unexpected node of type #{node.class.name} in selection set."
97
+ end
98
+ end
99
+ end
100
+
81
101
  # adds an operation (data access) to the plan which maps a data selection to an insertion point.
82
102
  # note that planned operations are NOT always 1:1 with executed requests, as the executor can
83
103
  # frequently batch different insertion points with the same location into a single request.
@@ -101,11 +121,8 @@ module GraphQL
101
121
  # groupings coalesce similar operation parameters into a single operation
102
122
  # multiple operations per service may still occur with different insertion points,
103
123
  # but those will get query-batched together during execution.
104
- grouping = String.new
105
- grouping << after_key.to_s << "/" << location << "/" << parent_type.graphql_name
106
- grouping = insertion_path.reduce(grouping) do |memo, segment|
107
- memo << "/" << segment
108
- end
124
+ grouping = String.new("#{after_key}/#{location}/#{parent_type.graphql_name}")
125
+ insertion_path.each { grouping << "/#{_1}" }
109
126
 
110
127
  if op = @operations_by_grouping[grouping]
111
128
  op.selections.concat(locale_selections)
@@ -158,7 +175,7 @@ module GraphQL
158
175
  next
159
176
  end
160
177
 
161
- field_type = Util.named_type_for_field_node(@supergraph.schema, parent_type, node)
178
+ field_type = @supergraph.memoized_schema_fields(parent_type.graphql_name)[node.name].type.unwrap
162
179
  extract_node_variables(node, locale_variables)
163
180
 
164
181
  if Util.is_leaf_type?(field_type)
@@ -174,7 +191,7 @@ module GraphQL
174
191
  when GraphQL::Language::Nodes::InlineFragment
175
192
  next unless @supergraph.locations_by_type[node.type.name].include?(current_location)
176
193
 
177
- fragment_type = @supergraph.schema.types[node.type.name]
194
+ fragment_type = @supergraph.memoized_schema_types[node.type.name]
178
195
  selection_set = extract_locale_selections(current_location, fragment_type, node.selections, insertion_path, after_key, locale_variables)
179
196
  locale_selections << node.merge(selections: selection_set)
180
197
  implements_fragments = true
@@ -183,7 +200,7 @@ module GraphQL
183
200
  fragment = @request.fragment_definitions[node.name]
184
201
  next unless @supergraph.locations_by_type[fragment.type.name].include?(current_location)
185
202
 
186
- fragment_type = @supergraph.schema.types[fragment.type.name]
203
+ fragment_type = @supergraph.memoized_schema_types[fragment.type.name]
187
204
  selection_set = extract_locale_selections(current_location, fragment_type, fragment.selections, insertion_path, after_key, locale_variables)
188
205
  locale_selections << GraphQL::Language::Nodes::InlineFragment.new(type: fragment.type, selections: selection_set)
189
206
  implements_fragments = true
@@ -219,7 +236,7 @@ module GraphQL
219
236
  possible_locations_by_field = @supergraph.locations_by_type_and_field[parent_type.graphql_name]
220
237
  selections_by_location = {}
221
238
 
222
- # distribute unique fields among required locations
239
+ # 1. distribute unique fields among required locations
223
240
  remote_selections.reject! do |node|
224
241
  possible_locations = possible_locations_by_field[node.name]
225
242
  if possible_locations.length == 1
@@ -229,13 +246,22 @@ module GraphQL
229
246
  end
230
247
  end
231
248
 
232
- # distribute non-unique fields among available locations, preferring locations already used
249
+ # 2. distribute non-unique fields among locations that are already used
250
+ if selections_by_location.any? && remote_selections.any?
251
+ remote_selections.reject! do |node|
252
+ used_location = possible_locations_by_field[node.name].find { selections_by_location[_1] }
253
+ if used_location
254
+ selections_by_location[used_location] << node
255
+ true
256
+ end
257
+ end
258
+ end
259
+
260
+ # 3. distribute remaining fields among locations weighted by greatest availability
233
261
  if remote_selections.any?
234
- # weight locations by number of required fields available, preferring greater availability
235
- location_weights = if remote_selections.length > 1
262
+ field_count_by_location = if remote_selections.length > 1
236
263
  remote_selections.each_with_object({}) do |node, memo|
237
- possible_locations = possible_locations_by_field[node.name]
238
- possible_locations.each do |location|
264
+ possible_locations_by_field[node.name].each do |location|
239
265
  memo[location] ||= 0
240
266
  memo[location] += 1
241
267
  end
@@ -246,18 +272,16 @@ module GraphQL
246
272
 
247
273
  remote_selections.each do |node|
248
274
  possible_locations = possible_locations_by_field[node.name]
249
- preferred_location_score = 0
275
+ preferred_location = possible_locations.first
250
276
 
251
- # hill-climb to select highest scoring location for each field
252
- preferred_location = possible_locations.reduce(possible_locations.first) do |best_location, possible_location|
253
- score = selections_by_location[possible_location] ? remote_selections.length : 0
254
- score += location_weights.fetch(possible_location, 0)
277
+ possible_locations.reduce(0) do |max_availability, possible_location|
278
+ available_fields = field_count_by_location.fetch(possible_location, 0)
255
279
 
256
- if score > preferred_location_score
257
- preferred_location_score = score
258
- possible_location
280
+ if available_fields > max_availability
281
+ preferred_location = possible_location
282
+ available_fields
259
283
  else
260
- best_location
284
+ max_availability
261
285
  end
262
286
  end
263
287
 
@@ -272,10 +296,8 @@ module GraphQL
272
296
  routes.values.each_with_object({}) do |route, ops_by_location|
273
297
  route.reduce(nil) do |parent_op, boundary|
274
298
  location = boundary["location"]
275
- new_operation = false
276
299
 
277
300
  unless op = ops_by_location[location]
278
- new_operation = true
279
301
  op = ops_by_location[location] = add_operation(
280
302
  location: location,
281
303
  # routing locations added as intermediaries have no initial selections,
@@ -291,7 +313,7 @@ module GraphQL
291
313
  foreign_key = "_STITCH_#{boundary["selection"]}"
292
314
  parent_selections = parent_op ? parent_op.selections : locale_selections
293
315
 
294
- if new_operation || parent_selections.none? { _1.is_a?(GraphQL::Language::Nodes::Field) && _1.alias == foreign_key }
316
+ if parent_selections.none? { _1.is_a?(GraphQL::Language::Nodes::Field) && _1.alias == foreign_key }
295
317
  foreign_key_node = GraphQL::Language::Nodes::Field.new(alias: foreign_key, name: boundary["selection"])
296
318
  parent_selections << foreign_key_node << TYPENAME_NODE
297
319
  end
@@ -335,7 +357,7 @@ module GraphQL
335
357
  end
336
358
 
337
359
  if expanded_selections
338
- @supergraph.schema.possible_types(parent_type).each do |possible_type|
360
+ @supergraph.memoized_schema_possible_types(parent_type.graphql_name).each do |possible_type|
339
361
  next unless @supergraph.locations_by_type[possible_type.graphql_name].include?(current_location)
340
362
 
341
363
  type_name = GraphQL::Language::Nodes::TypeName.new(name: possible_type.graphql_name)
@@ -352,7 +374,7 @@ module GraphQL
352
374
  @operations_by_grouping.each do |_grouping, op|
353
375
  next unless op.boundary
354
376
 
355
- boundary_type = @supergraph.schema.types[op.boundary["type_name"]]
377
+ boundary_type = @supergraph.memoized_schema_types[op.boundary["type_name"]]
356
378
  next unless boundary_type.kind.abstract?
357
379
  next if boundary_type == op.parent_type
358
380
 
@@ -87,7 +87,7 @@ module GraphQL
87
87
  end
88
88
 
89
89
  if operation_defs.length < 1
90
- raise GraphQL::ExecutionError, "Invalid root operation."
90
+ raise GraphQL::ExecutionError, "Invalid root operation for given name and operation type."
91
91
  elsif operation_defs.length > 1
92
92
  raise GraphQL::ExecutionError, "An operation name is required when sending multiple operations."
93
93
  end
@@ -4,14 +4,14 @@
4
4
  module GraphQL
5
5
  module Stitching
6
6
  class Shaper
7
- def initialize(schema:, request:)
8
- @schema = schema
7
+ def initialize(supergraph:, request:)
8
+ @supergraph = supergraph
9
9
  @request = request
10
10
  end
11
11
 
12
12
  def perform!(raw)
13
- root_type = @schema.public_send(@request.operation.operation_type)
14
- resolve_object_scope(raw, root_type, @request.operation.selections)
13
+ @root_type = @supergraph.schema.root_type_for_operation(@request.operation.operation_type)
14
+ resolve_object_scope(raw, @root_type, @request.operation.selections, @root_type.graphql_name)
15
15
  end
16
16
 
17
17
  private
@@ -25,11 +25,14 @@ module GraphQL
25
25
  selections.each do |node|
26
26
  case node
27
27
  when GraphQL::Language::Nodes::Field
28
- next if node.name.start_with?("__")
29
-
30
28
  field_name = node.alias || node.name
31
- node_type = parent_type.fields[node.name].type
32
- named_type = Util.named_type_for_field_node(@schema, parent_type, node)
29
+
30
+ next if introspection_field?(parent_type, node) do |is_root_typename|
31
+ raw_object[field_name] = @root_type.graphql_name if is_root_typename
32
+ end
33
+
34
+ node_type = @supergraph.memoized_schema_fields(parent_type.graphql_name)[node.name].type
35
+ named_type = node_type.unwrap
33
36
 
34
37
  raw_object[field_name] = if node_type.list?
35
38
  resolve_list_scope(raw_object[field_name], Util.unwrap_non_null(node_type), node.selections)
@@ -42,7 +45,7 @@ module GraphQL
42
45
  return nil if raw_object[field_name].nil? && node_type.non_null?
43
46
 
44
47
  when GraphQL::Language::Nodes::InlineFragment
45
- fragment_type = @schema.types[node.type.name]
48
+ fragment_type = @supergraph.memoized_schema_types[node.type.name]
46
49
  next unless typename_in_type?(typename, fragment_type)
47
50
 
48
51
  result = resolve_object_scope(raw_object, fragment_type, node.selections, typename)
@@ -50,7 +53,7 @@ module GraphQL
50
53
 
51
54
  when GraphQL::Language::Nodes::FragmentSpread
52
55
  fragment = @request.fragment_definitions[node.name]
53
- fragment_type = @schema.types[fragment.type.name]
56
+ fragment_type = @supergraph.memoized_schema_types[fragment.type.name]
54
57
  next unless typename_in_type?(typename, fragment_type)
55
58
 
56
59
  result = resolve_object_scope(raw_object, fragment_type, fragment.selections, typename)
@@ -93,9 +96,27 @@ module GraphQL
93
96
  resolved_list
94
97
  end
95
98
 
99
+ def introspection_field?(parent_type, node)
100
+ return false unless node.name.start_with?("__")
101
+ is_root = parent_type == @root_type
102
+
103
+ case node.name
104
+ when "__typename"
105
+ yield(is_root)
106
+ true
107
+ when "__schema", "__type"
108
+ is_root && @request.operation.operation_type == "query"
109
+ else
110
+ false
111
+ end
112
+ end
113
+
96
114
  def typename_in_type?(typename, type)
97
115
  return true if type.graphql_name == typename
98
- type.kind.abstract? && @schema.possible_types(type).any? { _1.graphql_name == typename }
116
+
117
+ type.kind.abstract? && @supergraph.memoized_schema_possible_types(type.graphql_name).any? do |t|
118
+ t.graphql_name == typename
119
+ end
99
120
  end
100
121
  end
101
122
  end
@@ -46,6 +46,8 @@ module GraphQL
46
46
  @boundaries = boundaries
47
47
  @possible_keys_by_type = {}
48
48
  @possible_keys_by_type_and_location = {}
49
+ @memoized_schema_possible_types = {}
50
+ @memoized_schema_fields = {}
49
51
 
50
52
  # add introspection types into the fields mapping
51
53
  @locations_by_type_and_field = INTROSPECTION_TYPES.each_with_object(fields) do |type_name, memo|
@@ -81,6 +83,28 @@ module GraphQL
81
83
  }
82
84
  end
83
85
 
86
+ def memoized_schema_types
87
+ @memoized_schema_types ||= @schema.types
88
+ end
89
+
90
+ def memoized_schema_possible_types(type_name)
91
+ @memoized_schema_possible_types[type_name] ||= @schema.possible_types(memoized_schema_types[type_name])
92
+ end
93
+
94
+ def memoized_schema_fields(type_name)
95
+ @memoized_schema_fields[type_name] ||= begin
96
+ fields = memoized_schema_types[type_name].fields
97
+ fields["__typename"] = @schema.introspection_system.dynamic_field(name: "__typename")
98
+
99
+ if type_name == @schema.query.graphql_name
100
+ fields["__schema"] = @schema.introspection_system.entry_point(name: "__schema")
101
+ fields["__type"] = @schema.introspection_system.entry_point(name: "__type")
102
+ end
103
+
104
+ fields
105
+ end
106
+ end
107
+
84
108
  def execute_at_location(location, source, variables, context)
85
109
  executable = executables[location]
86
110
 
@@ -37,15 +37,6 @@ module GraphQL
37
37
  structure
38
38
  end
39
39
 
40
- # gets a named type for a field node, including hidden root introspections
41
- def self.named_type_for_field_node(schema, parent_type, node)
42
- if node.name == "__schema" && parent_type == schema.query
43
- schema.types["__Schema"]
44
- else
45
- parent_type.fields[node.name].type.unwrap
46
- end
47
- end
48
-
49
40
  # expands interfaces and unions to an array of their memberships
50
41
  # like `schema.possible_types`, but includes child interfaces
51
42
  def self.expand_abstract_type(schema, parent_type)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "0.3.2"
5
+ VERSION = "0.3.4"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-stitching
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg MacWilliam
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-03-09 00:00:00.000000000 Z
11
+ date: 2023-03-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -86,6 +86,7 @@ files:
86
86
  - docs/images/library.png
87
87
  - docs/images/merging.png
88
88
  - docs/images/stitching.png
89
+ - docs/mechanics.md
89
90
  - docs/planner.md
90
91
  - docs/request.md
91
92
  - docs/supergraph.md