graphql-stitching 0.3.2 → 0.3.3

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: b07a54288795f5b321029340a61e97dd9209d395712dc9ac5921ebd5c52523a1
4
+ data.tar.gz: 83e4dea46c7b4ff7a3716c2936773893df58e732a50c36ee1a5af8e4e5cab4bd
5
5
  SHA512:
6
- metadata.gz: ad1afa71d6525ea79857106eca6c347f77fab8141dd9a504f3da1a6631f598fe7fb154bc446e228e420fa46dff993140ff568fc3dc94af7a76f0cf29da550896
7
- data.tar.gz: 2632af6a1347f1fb6513c79781c3959aacdd879583519cb31e69834a48f2ae70cafdb778d6915713d0d19bf535a67cac55763377cc27da0795b80ed6abc298d4
6
+ metadata.gz: 831a54675315529b6bc1d489296a15325f88bcccbbb8f480c786641410de853989425591f4e60a6249ef123a3aa9e204780388925f2a027d758ac5594ca6c306
7
+ data.tar.gz: f66ab6dfc996aa9982f6c5d09e2bf98b5a5de8b85220ea839f854e53edd3f55144404b5ddff91f2681f7d8fee21071bb54f20265951424840c527b7203dd9ace
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"]
@@ -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 }
@@ -39,10 +39,8 @@ module GraphQL
39
39
 
40
40
  selections_by_location = @request.operation.selections.each_with_object({}) do |node, memo|
41
41
  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
42
+ memo[locations.first] ||= []
43
+ memo[locations.first] << node
46
44
  end
47
45
 
48
46
  selections_by_location.each do |location, selections|
@@ -53,8 +51,7 @@ module GraphQL
53
51
  parent_type = @supergraph.schema.mutation
54
52
 
55
53
  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
+ next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
58
55
 
59
56
  if memo.none? || memo.last[:location] != next_location
60
57
  memo << { location: next_location, selections: [] }
@@ -219,7 +216,7 @@ module GraphQL
219
216
  possible_locations_by_field = @supergraph.locations_by_type_and_field[parent_type.graphql_name]
220
217
  selections_by_location = {}
221
218
 
222
- # distribute unique fields among required locations
219
+ # 1. distribute unique fields among required locations
223
220
  remote_selections.reject! do |node|
224
221
  possible_locations = possible_locations_by_field[node.name]
225
222
  if possible_locations.length == 1
@@ -229,13 +226,22 @@ module GraphQL
229
226
  end
230
227
  end
231
228
 
232
- # distribute non-unique fields among available locations, preferring locations already used
229
+ # 2. distribute non-unique fields among locations that are already used
230
+ if selections_by_location.any? && remote_selections.any?
231
+ remote_selections.reject! do |node|
232
+ used_location = possible_locations_by_field[node.name].find { selections_by_location[_1] }
233
+ if used_location
234
+ selections_by_location[used_location] << node
235
+ true
236
+ end
237
+ end
238
+ end
239
+
240
+ # 3. distribute remaining fields among locations weighted by greatest availability
233
241
  if remote_selections.any?
234
- # weight locations by number of required fields available, preferring greater availability
235
- location_weights = if remote_selections.length > 1
242
+ field_count_by_location = if remote_selections.length > 1
236
243
  remote_selections.each_with_object({}) do |node, memo|
237
- possible_locations = possible_locations_by_field[node.name]
238
- possible_locations.each do |location|
244
+ possible_locations_by_field[node.name].each do |location|
239
245
  memo[location] ||= 0
240
246
  memo[location] += 1
241
247
  end
@@ -246,18 +252,16 @@ module GraphQL
246
252
 
247
253
  remote_selections.each do |node|
248
254
  possible_locations = possible_locations_by_field[node.name]
249
- preferred_location_score = 0
255
+ preferred_location = possible_locations.first
250
256
 
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)
257
+ possible_locations.reduce(0) do |max_availability, possible_location|
258
+ available_fields = field_count_by_location.fetch(possible_location, 0)
255
259
 
256
- if score > preferred_location_score
257
- preferred_location_score = score
258
- possible_location
260
+ if available_fields > max_availability
261
+ preferred_location = possible_location
262
+ available_fields
259
263
  else
260
- best_location
264
+ max_availability
261
265
  end
262
266
  end
263
267
 
@@ -272,10 +276,8 @@ module GraphQL
272
276
  routes.values.each_with_object({}) do |route, ops_by_location|
273
277
  route.reduce(nil) do |parent_op, boundary|
274
278
  location = boundary["location"]
275
- new_operation = false
276
279
 
277
280
  unless op = ops_by_location[location]
278
- new_operation = true
279
281
  op = ops_by_location[location] = add_operation(
280
282
  location: location,
281
283
  # routing locations added as intermediaries have no initial selections,
@@ -291,7 +293,7 @@ module GraphQL
291
293
  foreign_key = "_STITCH_#{boundary["selection"]}"
292
294
  parent_selections = parent_op ? parent_op.selections : locale_selections
293
295
 
294
- if new_operation || parent_selections.none? { _1.is_a?(GraphQL::Language::Nodes::Field) && _1.alias == foreign_key }
296
+ if parent_selections.none? { _1.is_a?(GraphQL::Language::Nodes::Field) && _1.alias == foreign_key }
295
297
  foreign_key_node = GraphQL::Language::Nodes::Field.new(alias: foreign_key, name: boundary["selection"])
296
298
  parent_selections << foreign_key_node << TYPENAME_NODE
297
299
  end
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "0.3.2"
5
+ VERSION = "0.3.3"
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.3
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-24 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