graphql-stitching 1.2.0 → 1.2.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.yardopts +5 -0
- data/README.md +5 -6
- data/docs/README.md +0 -2
- data/docs/mechanics.md +42 -0
- data/docs/request.md +7 -32
- data/lib/graphql/stitching/composer/base_validator.rb +3 -3
- data/lib/graphql/stitching/composer/validate_boundaries.rb +3 -3
- data/lib/graphql/stitching/composer/validate_interfaces.rb +3 -4
- data/lib/graphql/stitching/composer.rb +70 -17
- data/lib/graphql/stitching/executor/boundary_source.rb +4 -6
- data/lib/graphql/stitching/executor/root_source.rb +29 -7
- data/lib/graphql/stitching/executor.rb +14 -8
- data/lib/graphql/stitching/http_executable.rb +29 -18
- data/lib/graphql/stitching/planner.rb +15 -13
- data/lib/graphql/stitching/request.rb +57 -3
- data/lib/graphql/stitching/shaper.rb +2 -1
- data/lib/graphql/stitching/skip_include.rb +4 -3
- data/lib/graphql/stitching/supergraph/resolver_directive.rb +17 -0
- data/lib/graphql/stitching/supergraph/source_directive.rb +12 -0
- data/lib/graphql/stitching/supergraph.rb +45 -39
- data/lib/graphql/stitching/util.rb +1 -1
- data/lib/graphql/stitching/version.rb +1 -1
- metadata +5 -4
- data/docs/executor.md +0 -68
- data/docs/planner.md +0 -45
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6753e205aac88d3dd3be1c906b743cc49ae0de84ff8ab56c7e9be6e889429aa3
|
4
|
+
data.tar.gz: 59440f9d70cb365ef124604c42012bf655934555dfb76905328368353f5e310b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ac218d2218c14b8debff552ffb11e83e00293dfebb404d25296ed9e408232e488c44575c701ee3fd933331d84755062517f2c2da8df2db98ac7c5e8594595846
|
7
|
+
data.tar.gz: ec4f5051ebd511e72536c8e3246302079b2eb65ab5edab37c7c7d7de5488cdc7581318c69a0ea54047bd79ff42bf9e9c5af3434a27fc5667cf029340527c8117
|
data/.yardopts
ADDED
data/README.md
CHANGED
@@ -78,9 +78,7 @@ While the `Client` constructor is an easy quick start, the library also has seve
|
|
78
78
|
|
79
79
|
- [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
|
80
80
|
- [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
|
81
|
-
- [Request](./docs/request.md) -
|
82
|
-
- [Planner](./docs/planner.md) - builds a cacheable query plan for a request document.
|
83
|
-
- [Executor](./docs/executor.md) - executes a query plan with given request variables.
|
81
|
+
- [Request](./docs/request.md) - manages the lifecycle of a stitched GraphQL request.
|
84
82
|
- [HttpExecutable](./docs/http_executable.md) - proxies requests to remotes with multipart file upload support.
|
85
83
|
|
86
84
|
## Merged types
|
@@ -134,7 +132,7 @@ client = GraphQL::Stitching::Client.new(locations: {
|
|
134
132
|
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
|
135
133
|
},
|
136
134
|
catalog: {
|
137
|
-
schema: GraphQL::Schema.from_definition(
|
135
|
+
schema: GraphQL::Schema.from_definition(catalog_schema),
|
138
136
|
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
|
139
137
|
},
|
140
138
|
})
|
@@ -155,7 +153,7 @@ type Query {
|
|
155
153
|
* 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.
|
156
154
|
* 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](#multiple-query-arguments) later).
|
157
155
|
|
158
|
-
Each location that provides a unique variant of a type must provide at least one stitching query. The exception to this requirement are types that contain only a single key field:
|
156
|
+
Each location that provides a unique variant of a type must provide at least one stitching query for the type. The exception to this requirement are [foreign key types](./docs/mechanics.md##modeling-foreign-keys-for-stitching) that contain only a single key field:
|
159
157
|
|
160
158
|
```graphql
|
161
159
|
type Product {
|
@@ -163,7 +161,7 @@ type Product {
|
|
163
161
|
}
|
164
162
|
```
|
165
163
|
|
166
|
-
The above representation of a `Product` type provides no unique data beyond a key that is available in other locations. Thus, this representation will never require an inbound request to fetch it, and its stitching query may be omitted.
|
164
|
+
The above representation of a `Product` type provides no unique data beyond a key that is available in other locations. Thus, this representation will never require an inbound request to fetch it, and its stitching query may be omitted.
|
167
165
|
|
168
166
|
#### List queries
|
169
167
|
|
@@ -427,6 +425,7 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im
|
|
427
425
|
|
428
426
|
## Additional topics
|
429
427
|
|
428
|
+
- [Modeling foreign keys for stitching](./docs/mechanics.md##modeling-foreign-keys-for-stitching)
|
430
429
|
- [Deploying a stitched schema](./docs/mechanics.md#deploying-a-stitched-schema)
|
431
430
|
- [Field selection routing](./docs/mechanics.md#field-selection-routing)
|
432
431
|
- [Root selection routing](./docs/mechanics.md#root-selection-routing)
|
data/docs/README.md
CHANGED
@@ -10,8 +10,6 @@ Major components include:
|
|
10
10
|
- [Composer](./composer.md) - merges and validates many schemas into one graph.
|
11
11
|
- [Supergraph](./supergraph.md) - manages the combined schema and location routing maps. Can be exported, cached, and rehydrated.
|
12
12
|
- [Request](./request.md) - prepares a requested GraphQL document and variables for stitching.
|
13
|
-
- [Planner](./planner.md) - builds a cacheable query plan for a request document.
|
14
|
-
- [Executor](./executor.md) - executes a query plan with given request variables.
|
15
13
|
- [HttpExecutable](./http_executable.md) - proxies requests to remotes with multipart file upload support.
|
16
14
|
|
17
15
|
Additional topics:
|
data/docs/mechanics.md
CHANGED
@@ -1,5 +1,47 @@
|
|
1
1
|
## Schema Stitching, mechanics
|
2
2
|
|
3
|
+
### Modeling foreign keys for stitching
|
4
|
+
|
5
|
+
Foreign keys in a GraphQL schema typically look like the `Product.imageId` field here:
|
6
|
+
|
7
|
+
```graphql
|
8
|
+
# -- Products schema:
|
9
|
+
|
10
|
+
type Product {
|
11
|
+
id: ID!
|
12
|
+
imageId: ID!
|
13
|
+
}
|
14
|
+
|
15
|
+
# -- Images schema:
|
16
|
+
|
17
|
+
type Image {
|
18
|
+
id: ID!
|
19
|
+
url: String!
|
20
|
+
}
|
21
|
+
```
|
22
|
+
|
23
|
+
However, this design does not lend itself to stitching where types need to _merge_ across locations. A simple schema refactor makes this foreign key more expressive as an entity type, and turns the key into an _object_ that will merge with analogous object types in other locations:
|
24
|
+
|
25
|
+
```graphql
|
26
|
+
# -- Products schema:
|
27
|
+
|
28
|
+
type Product {
|
29
|
+
id: ID!
|
30
|
+
image: Image!
|
31
|
+
}
|
32
|
+
|
33
|
+
type Image {
|
34
|
+
id: ID!
|
35
|
+
}
|
36
|
+
|
37
|
+
# -- Images schema:
|
38
|
+
|
39
|
+
type Image {
|
40
|
+
id: ID!
|
41
|
+
url: String!
|
42
|
+
}
|
43
|
+
```
|
44
|
+
|
3
45
|
### Deploying a stitched schema
|
4
46
|
|
5
47
|
Among the simplest and most effective ways to manage a stitched schema is to compose it locally, write the composed SDL as a `.graphql` file in your repo, and then load the composed schema into a stitching client at runtime. For example, setup a `rake` task that loads/fetches subgraph schemas, composes them, and then writes the composed schema definition as a file committed to the repo:
|
data/docs/request.md
CHANGED
@@ -25,37 +25,12 @@ A `Request` provides the following information:
|
|
25
25
|
- `req.variable_definitions`: a mapping of variable names to their type definitions
|
26
26
|
- `req.fragment_definitions`: a mapping of fragment names to their fragment definitions
|
27
27
|
|
28
|
-
###
|
28
|
+
### Request lifecycle
|
29
29
|
|
30
|
-
A request
|
30
|
+
A request manages the flow of stitching behaviors. These are sequenced by the `Client`
|
31
|
+
component, or you may invoke them manually:
|
31
32
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
id
|
37
|
-
title(lang: $lang)
|
38
|
-
showtimes @include(if: $withShowtimes) {
|
39
|
-
time
|
40
|
-
}
|
41
|
-
}
|
42
|
-
}
|
43
|
-
GRAPHQL
|
44
|
-
|
45
|
-
request = GraphQL::Stitching::Request.new(
|
46
|
-
supergraph,
|
47
|
-
document,
|
48
|
-
variables: { "id" => "1" },
|
49
|
-
operation_name: "FetchMovie",
|
50
|
-
)
|
51
|
-
|
52
|
-
errors = MySchema.validate(request.document)
|
53
|
-
# return early with any static validation errors...
|
54
|
-
|
55
|
-
request.prepare!
|
56
|
-
```
|
57
|
-
|
58
|
-
Preparing a request will apply several destructive transformations:
|
59
|
-
|
60
|
-
- Default values from variable definitions will be added to request variables.
|
61
|
-
- The document will be pre-shaped based on `@skip` and `@include` directives.
|
33
|
+
1. `request.validate`: runs static validations on the request using the combined schema.
|
34
|
+
2. `request.prepare!`: inserts variable defaults and pre-renders skip/include conditional shaping.
|
35
|
+
3. `request.plan`: builds a plan for the request. May act as a setting for plans pulled from cache.
|
36
|
+
4. `request.execute`: executes the request, and returns the resulting data.
|
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module GraphQL
|
4
|
-
|
5
|
-
class
|
3
|
+
module GraphQL::Stitching
|
4
|
+
class Composer
|
5
|
+
class ValidateBoundaries < BaseValidator
|
6
6
|
|
7
7
|
def perform(ctx, composer)
|
8
8
|
ctx.schema.types.each do |type_name, type|
|
@@ -1,9 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module GraphQL
|
4
|
-
|
5
|
-
class
|
6
|
-
|
3
|
+
module GraphQL::Stitching
|
4
|
+
class Composer
|
5
|
+
class ValidateInterfaces < BaseValidator
|
7
6
|
# For each composed interface, check the interface against each possible type
|
8
7
|
# to assure that intersecting fields have compatible types, structures, and nullability.
|
9
8
|
# Verifies compatibility of types that inherit interface contracts through merging.
|
@@ -1,26 +1,49 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "./composer/base_validator"
|
4
|
+
require_relative "./composer/validate_interfaces"
|
5
|
+
require_relative "./composer/validate_boundaries"
|
6
|
+
|
3
7
|
module GraphQL
|
4
8
|
module Stitching
|
5
9
|
class Composer
|
6
10
|
class ComposerError < StitchingError; end
|
7
11
|
class ValidationError < ComposerError; end
|
8
|
-
|
9
|
-
|
10
|
-
|
12
|
+
|
13
|
+
# @api private
|
14
|
+
NO_DEFAULT_VALUE = begin
|
15
|
+
class T < GraphQL::Schema::Object
|
16
|
+
field(:f, String) do
|
17
|
+
argument(:a, String)
|
18
|
+
end
|
11
19
|
end
|
20
|
+
|
21
|
+
T.get_field("f").get_argument("a").default_value
|
12
22
|
end
|
13
23
|
|
14
|
-
|
24
|
+
# @api private
|
15
25
|
BASIC_VALUE_MERGER = ->(values_by_location, _info) { values_by_location.values.find { !_1.nil? } }
|
26
|
+
|
27
|
+
# @api private
|
16
28
|
BASIC_ROOT_FIELD_LOCATION_SELECTOR = ->(locations, _info) { locations.last }
|
17
29
|
|
30
|
+
# @api private
|
18
31
|
VALIDATORS = [
|
19
32
|
"ValidateInterfaces",
|
20
33
|
"ValidateBoundaries",
|
21
34
|
].freeze
|
22
35
|
|
23
|
-
|
36
|
+
# @return [String] name of the Query type in the composed schema.
|
37
|
+
attr_reader :query_name
|
38
|
+
|
39
|
+
# @return [String] name of the Mutation type in the composed schema.
|
40
|
+
attr_reader :mutation_name
|
41
|
+
|
42
|
+
# @api private
|
43
|
+
attr_reader :candidate_types_by_name_and_location
|
44
|
+
|
45
|
+
# @api private
|
46
|
+
attr_reader :schema_directives
|
24
47
|
|
25
48
|
def initialize(
|
26
49
|
query_name: "Query",
|
@@ -148,6 +171,8 @@ module GraphQL
|
|
148
171
|
supergraph
|
149
172
|
end
|
150
173
|
|
174
|
+
# @!scope class
|
175
|
+
# @!visibility private
|
151
176
|
def prepare_locations_input(locations_input)
|
152
177
|
schemas = {}
|
153
178
|
executables = {}
|
@@ -200,6 +225,8 @@ module GraphQL
|
|
200
225
|
return schemas, executables
|
201
226
|
end
|
202
227
|
|
228
|
+
# @!scope class
|
229
|
+
# @!visibility private
|
203
230
|
def build_directive(directive_name, directives_by_location)
|
204
231
|
builder = self
|
205
232
|
|
@@ -212,6 +239,8 @@ module GraphQL
|
|
212
239
|
end
|
213
240
|
end
|
214
241
|
|
242
|
+
# @!scope class
|
243
|
+
# @!visibility private
|
215
244
|
def build_scalar_type(type_name, types_by_location)
|
216
245
|
built_in_type = GraphQL::Schema::BUILT_IN_TYPES[type_name]
|
217
246
|
return built_in_type if built_in_type
|
@@ -225,6 +254,8 @@ module GraphQL
|
|
225
254
|
end
|
226
255
|
end
|
227
256
|
|
257
|
+
# @!scope class
|
258
|
+
# @!visibility private
|
228
259
|
def build_enum_type(type_name, types_by_location, enum_usage)
|
229
260
|
builder = self
|
230
261
|
|
@@ -232,7 +263,6 @@ module GraphQL
|
|
232
263
|
enum_values_by_name_location = types_by_location.each_with_object({}) do |(location, type_candidate), memo|
|
233
264
|
type_candidate.enum_values.each do |enum_value_candidate|
|
234
265
|
memo[enum_value_candidate.graphql_name] ||= {}
|
235
|
-
memo[enum_value_candidate.graphql_name][location] ||= {}
|
236
266
|
memo[enum_value_candidate.graphql_name][location] = enum_value_candidate
|
237
267
|
end
|
238
268
|
end
|
@@ -261,6 +291,8 @@ module GraphQL
|
|
261
291
|
end
|
262
292
|
end
|
263
293
|
|
294
|
+
# @!scope class
|
295
|
+
# @!visibility private
|
264
296
|
def build_object_type(type_name, types_by_location)
|
265
297
|
builder = self
|
266
298
|
|
@@ -278,6 +310,8 @@ module GraphQL
|
|
278
310
|
end
|
279
311
|
end
|
280
312
|
|
313
|
+
# @!scope class
|
314
|
+
# @!visibility private
|
281
315
|
def build_interface_type(type_name, types_by_location)
|
282
316
|
builder = self
|
283
317
|
|
@@ -296,6 +330,8 @@ module GraphQL
|
|
296
330
|
end
|
297
331
|
end
|
298
332
|
|
333
|
+
# @!scope class
|
334
|
+
# @!visibility private
|
299
335
|
def build_union_type(type_name, types_by_location)
|
300
336
|
builder = self
|
301
337
|
|
@@ -309,6 +345,8 @@ module GraphQL
|
|
309
345
|
end
|
310
346
|
end
|
311
347
|
|
348
|
+
# @!scope class
|
349
|
+
# @!visibility private
|
312
350
|
def build_input_object_type(type_name, types_by_location)
|
313
351
|
builder = self
|
314
352
|
|
@@ -320,10 +358,14 @@ module GraphQL
|
|
320
358
|
end
|
321
359
|
end
|
322
360
|
|
361
|
+
# @!scope class
|
362
|
+
# @!visibility private
|
323
363
|
def build_type_binding(type_name)
|
324
364
|
GraphQL::Schema::LateBoundType.new(@mapped_type_names.fetch(type_name, type_name))
|
325
365
|
end
|
326
366
|
|
367
|
+
# @!scope class
|
368
|
+
# @!visibility private
|
327
369
|
def build_merged_fields(type_name, types_by_location, owner)
|
328
370
|
# "field_name" => "location" => field
|
329
371
|
fields_by_name_location = types_by_location.each_with_object({}) do |(location, type_candidate), memo|
|
@@ -333,7 +375,6 @@ module GraphQL
|
|
333
375
|
@field_map[type_name][field_candidate.name] << location
|
334
376
|
|
335
377
|
memo[field_name] ||= {}
|
336
|
-
memo[field_name][location] ||= {}
|
337
378
|
memo[field_name][location] = field_candidate
|
338
379
|
end
|
339
380
|
end
|
@@ -356,12 +397,13 @@ module GraphQL
|
|
356
397
|
end
|
357
398
|
end
|
358
399
|
|
400
|
+
# @!scope class
|
401
|
+
# @!visibility private
|
359
402
|
def build_merged_arguments(type_name, members_by_location, owner, field_name: nil, directive_name: nil)
|
360
403
|
# "argument_name" => "location" => argument
|
361
404
|
args_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo|
|
362
405
|
member_candidate.arguments.each do |argument_name, argument|
|
363
406
|
memo[argument_name] ||= {}
|
364
|
-
memo[argument_name][location] ||= {}
|
365
407
|
memo[argument_name][location] = argument
|
366
408
|
end
|
367
409
|
end
|
@@ -410,11 +452,12 @@ module GraphQL
|
|
410
452
|
end
|
411
453
|
end
|
412
454
|
|
455
|
+
# @!scope class
|
456
|
+
# @!visibility private
|
413
457
|
def build_merged_directives(type_name, members_by_location, owner, field_name: nil, argument_name: nil, enum_value: nil)
|
414
458
|
directives_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo|
|
415
459
|
member_candidate.directives.each do |directive|
|
416
460
|
memo[directive.graphql_name] ||= {}
|
417
|
-
memo[directive.graphql_name][location] ||= {}
|
418
461
|
memo[directive.graphql_name][location] = directive
|
419
462
|
end
|
420
463
|
end
|
@@ -451,6 +494,8 @@ module GraphQL
|
|
451
494
|
end
|
452
495
|
end
|
453
496
|
|
497
|
+
# @!scope class
|
498
|
+
# @!visibility private
|
454
499
|
def merge_value_types(type_name, type_candidates, field_name: nil, argument_name: nil)
|
455
500
|
path = [type_name, field_name, argument_name].tap(&:compact!).join(".")
|
456
501
|
alt_structures = type_candidates.map { Util.flatten_type_structure(_1) }
|
@@ -482,6 +527,8 @@ module GraphQL
|
|
482
527
|
type
|
483
528
|
end
|
484
529
|
|
530
|
+
# @!scope class
|
531
|
+
# @!visibility private
|
485
532
|
def merge_descriptions(type_name, members_by_location, field_name: nil, argument_name: nil, enum_value: nil)
|
486
533
|
strings_by_location = members_by_location.each_with_object({}) { |(l, m), memo| memo[l] = m.description }
|
487
534
|
@description_merger.call(strings_by_location, {
|
@@ -492,6 +539,8 @@ module GraphQL
|
|
492
539
|
}.tap(&:compact!))
|
493
540
|
end
|
494
541
|
|
542
|
+
# @!scope class
|
543
|
+
# @!visibility private
|
495
544
|
def merge_deprecations(type_name, members_by_location, field_name: nil, argument_name: nil, enum_value: nil)
|
496
545
|
strings_by_location = members_by_location.each_with_object({}) { |(l, m), memo| memo[l] = m.deprecation_reason }
|
497
546
|
@deprecation_merger.call(strings_by_location, {
|
@@ -502,6 +551,8 @@ module GraphQL
|
|
502
551
|
}.tap(&:compact!))
|
503
552
|
end
|
504
553
|
|
554
|
+
# @!scope class
|
555
|
+
# @!visibility private
|
505
556
|
def extract_boundaries(type_name, types_by_location)
|
506
557
|
types_by_location.each do |location, type_candidate|
|
507
558
|
type_candidate.fields.each do |field_name, field_candidate|
|
@@ -554,6 +605,8 @@ module GraphQL
|
|
554
605
|
end
|
555
606
|
end
|
556
607
|
|
608
|
+
# @!scope class
|
609
|
+
# @!visibility private
|
557
610
|
def select_root_field_locations(schema)
|
558
611
|
[schema.query, schema.mutation].tap(&:compact!).each do |root_type|
|
559
612
|
root_type.fields.each do |root_field_name, root_field|
|
@@ -572,6 +625,8 @@ module GraphQL
|
|
572
625
|
end
|
573
626
|
end
|
574
627
|
|
628
|
+
# @!scope class
|
629
|
+
# @!visibility private
|
575
630
|
def expand_abstract_boundaries(schema)
|
576
631
|
@boundary_map.keys.each do |type_name|
|
577
632
|
boundary_type = schema.types[type_name]
|
@@ -585,28 +640,30 @@ module GraphQL
|
|
585
640
|
end
|
586
641
|
end
|
587
642
|
|
643
|
+
# @!scope class
|
644
|
+
# @!visibility private
|
588
645
|
def build_enum_usage_map(schemas)
|
589
646
|
reads = []
|
590
647
|
writes = []
|
591
648
|
|
592
649
|
schemas.each do |schema|
|
593
650
|
introspection_types = schema.introspection_system.types.keys
|
594
|
-
schema.types.
|
651
|
+
schema.types.each_value do |type|
|
595
652
|
next if introspection_types.include?(type.graphql_name)
|
596
653
|
|
597
654
|
if type.kind.object? || type.kind.interface?
|
598
|
-
type.fields.
|
655
|
+
type.fields.each_value do |field|
|
599
656
|
field_type = field.type.unwrap
|
600
657
|
reads << field_type.graphql_name if field_type.kind.enum?
|
601
658
|
|
602
|
-
field.arguments.
|
659
|
+
field.arguments.each_value do |argument|
|
603
660
|
argument_type = argument.type.unwrap
|
604
661
|
writes << argument_type.graphql_name if argument_type.kind.enum?
|
605
662
|
end
|
606
663
|
end
|
607
664
|
|
608
665
|
elsif type.kind.input_object?
|
609
|
-
type.arguments.
|
666
|
+
type.arguments.each_value do |argument|
|
610
667
|
argument_type = argument.type.unwrap
|
611
668
|
writes << argument_type.graphql_name if argument_type.kind.enum?
|
612
669
|
end
|
@@ -636,7 +693,3 @@ module GraphQL
|
|
636
693
|
end
|
637
694
|
end
|
638
695
|
end
|
639
|
-
|
640
|
-
require_relative "./composer/base_validator"
|
641
|
-
require_relative "./composer/validate_interfaces"
|
642
|
-
require_relative "./composer/validate_boundaries"
|
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module GraphQL
|
4
|
-
|
5
|
-
class
|
3
|
+
module GraphQL::Stitching
|
4
|
+
class Executor
|
5
|
+
class BoundarySource < GraphQL::Dataloader::Source
|
6
6
|
def initialize(executor, location)
|
7
7
|
@executor = executor
|
8
8
|
@location = location
|
@@ -29,7 +29,7 @@ module GraphQL
|
|
29
29
|
@executor.request.operation_directives,
|
30
30
|
)
|
31
31
|
variables = @executor.request.variables.slice(*variable_names)
|
32
|
-
raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables, @executor.request)
|
32
|
+
raw_result = @executor.request.supergraph.execute_at_location(@location, query_document, variables, @executor.request)
|
33
33
|
@executor.query_count += 1
|
34
34
|
|
35
35
|
merge_results!(origin_sets_by_operation, raw_result.dig("data"))
|
@@ -183,9 +183,7 @@ module GraphQL
|
|
183
183
|
end
|
184
184
|
|
185
185
|
elsif forward_path.any?
|
186
|
-
current_path << index
|
187
186
|
repath_errors!(pathed_errors_by_object_id, forward_path, current_path, scope)
|
188
|
-
current_path.pop
|
189
187
|
|
190
188
|
elsif scope.is_a?(Array)
|
191
189
|
scope.each_with_index do |element, index|
|
@@ -1,8 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
module GraphQL
|
4
|
-
|
5
|
-
class
|
3
|
+
module GraphQL::Stitching
|
4
|
+
class Executor
|
5
|
+
class RootSource < GraphQL::Dataloader::Source
|
6
6
|
def initialize(executor, location)
|
7
7
|
@executor = executor
|
8
8
|
@location = location
|
@@ -17,13 +17,25 @@ module GraphQL
|
|
17
17
|
@executor.request.operation_directives,
|
18
18
|
)
|
19
19
|
query_variables = @executor.request.variables.slice(*op.variables.keys)
|
20
|
-
result = @executor.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
|
20
|
+
result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
|
21
21
|
@executor.query_count += 1
|
22
22
|
|
23
|
-
|
23
|
+
if result["data"]
|
24
|
+
if op.path.any?
|
25
|
+
# Nested root scopes must expand their pathed origin set
|
26
|
+
origin_set = op.path.reduce([@executor.data]) do |set, ns|
|
27
|
+
set.flat_map { |obj| obj && obj[ns] }.tap(&:compact!)
|
28
|
+
end
|
29
|
+
|
30
|
+
origin_set.each { _1.merge!(result["data"]) }
|
31
|
+
else
|
32
|
+
# Actual root scopes merge directly into results data
|
33
|
+
@executor.data.merge!(result["data"])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
24
37
|
if result["errors"]&.any?
|
25
|
-
result["errors"]
|
26
|
-
@executor.errors.concat(result["errors"])
|
38
|
+
@executor.errors.concat(format_errors!(result["errors"], op.path))
|
27
39
|
end
|
28
40
|
|
29
41
|
ops.map(&:step)
|
@@ -51,6 +63,16 @@ module GraphQL
|
|
51
63
|
doc << op.selections
|
52
64
|
doc
|
53
65
|
end
|
66
|
+
|
67
|
+
# Format response errors without a document location (because it won't match the request doc),
|
68
|
+
# and prepend any insertion path for the scope into error paths.
|
69
|
+
def format_errors!(errors, path)
|
70
|
+
errors.each do |err|
|
71
|
+
err.delete("locations")
|
72
|
+
err["path"].unshift(*path) if err["path"] && path.any?
|
73
|
+
end
|
74
|
+
errors
|
75
|
+
end
|
54
76
|
end
|
55
77
|
end
|
56
78
|
end
|
@@ -1,17 +1,26 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "json"
|
4
|
+
require_relative "./executor/boundary_source"
|
5
|
+
require_relative "./executor/root_source"
|
4
6
|
|
5
7
|
module GraphQL
|
6
8
|
module Stitching
|
7
9
|
class Executor
|
8
|
-
|
10
|
+
# @return [Request] the stitching request to execute.
|
11
|
+
attr_reader :request
|
12
|
+
|
13
|
+
# @return [Hash] an aggregate data payload to return.
|
14
|
+
attr_reader :data
|
15
|
+
|
16
|
+
# @return [Array<Hash>] aggregate GraphQL errors to return.
|
17
|
+
attr_reader :errors
|
18
|
+
|
19
|
+
# @return [Integer] tally of queries performed while executing.
|
9
20
|
attr_accessor :query_count
|
10
21
|
|
11
22
|
def initialize(request, nonblocking: false)
|
12
23
|
@request = request
|
13
|
-
@supergraph = request.supergraph
|
14
|
-
@plan = request.plan
|
15
24
|
@data = {}
|
16
25
|
@errors = []
|
17
26
|
@query_count = 0
|
@@ -37,13 +46,13 @@ module GraphQL
|
|
37
46
|
private
|
38
47
|
|
39
48
|
def exec!(next_steps = [0])
|
40
|
-
if @exec_cycles > @plan.ops.length
|
49
|
+
if @exec_cycles > @request.plan.ops.length
|
41
50
|
# sanity check... if we've exceeded queue size, then something went wrong.
|
42
51
|
raise StitchingError, "Too many execution requests attempted."
|
43
52
|
end
|
44
53
|
|
45
54
|
@dataloader.append_job do
|
46
|
-
tasks = @plan
|
55
|
+
tasks = @request.plan
|
47
56
|
.ops
|
48
57
|
.select { next_steps.include?(_1.after) }
|
49
58
|
.group_by { [_1.location, _1.boundary.nil?] }
|
@@ -66,6 +75,3 @@ module GraphQL
|
|
66
75
|
end
|
67
76
|
end
|
68
77
|
end
|
69
|
-
|
70
|
-
require_relative "./executor/boundary_source"
|
71
|
-
require_relative "./executor/root_source"
|
@@ -7,27 +7,33 @@ require "json"
|
|
7
7
|
module GraphQL
|
8
8
|
module Stitching
|
9
9
|
class HttpExecutable
|
10
|
-
|
10
|
+
# Builds a new executable for proxying subgraph requests via HTTP.
|
11
|
+
# @param url [String] the url of the remote location to proxy.
|
12
|
+
# @param headers [Hash] headers to include in upstream requests.
|
13
|
+
# @param upload_types [Array<String>, nil] a list of scalar names that represent file uploads. These types extract into multipart forms.
|
14
|
+
def initialize(url:, headers: {}, upload_types: nil)
|
11
15
|
@url = url
|
12
16
|
@headers = { "Content-Type" => "application/json" }.merge!(headers)
|
13
17
|
@upload_types = upload_types
|
14
18
|
end
|
15
19
|
|
16
20
|
def call(request, document, variables)
|
17
|
-
|
18
|
-
extract_multipart_form(request, document, variables)
|
19
|
-
end
|
21
|
+
form_data = extract_multipart_form(request, document, variables)
|
20
22
|
|
21
|
-
response = if
|
22
|
-
|
23
|
+
response = if form_data
|
24
|
+
send_multipart_form(request, form_data)
|
23
25
|
else
|
24
|
-
|
26
|
+
send(request, document, variables)
|
25
27
|
end
|
26
28
|
|
27
29
|
JSON.parse(response.body)
|
28
30
|
end
|
29
31
|
|
30
|
-
|
32
|
+
# Sends a POST request to the remote location.
|
33
|
+
# @param request [Request] the original supergraph request.
|
34
|
+
# @param document [String] the location-specific subgraph document to send.
|
35
|
+
# @param variables [Hash] a hash of variables specific to the subgraph document.
|
36
|
+
def send(_request, document, variables)
|
31
37
|
Net::HTTP.post(
|
32
38
|
URI(@url),
|
33
39
|
JSON.generate({ "query" => document, "variables" => variables }),
|
@@ -35,7 +41,10 @@ module GraphQL
|
|
35
41
|
)
|
36
42
|
end
|
37
43
|
|
38
|
-
|
44
|
+
# Sends a POST request to the remote location with multipart form data.
|
45
|
+
# @param request [Request] the original supergraph request.
|
46
|
+
# @param form_data [Hash] a rendered multipart form with an "operations", "map", and file sections.
|
47
|
+
def send_multipart_form(_request, form_data)
|
39
48
|
uri = URI(@url)
|
40
49
|
req = Net::HTTP::Post.new(uri)
|
41
50
|
@headers.each_pair do |key, value|
|
@@ -48,16 +57,18 @@ module GraphQL
|
|
48
57
|
end
|
49
58
|
end
|
50
59
|
|
51
|
-
#
|
52
|
-
#
|
60
|
+
# Extracts multipart upload forms per the spec:
|
61
|
+
# https://github.com/jaydenseric/graphql-multipart-request-spec
|
62
|
+
# @param request [Request] the original supergraph request.
|
63
|
+
# @param document [String] the location-specific subgraph document to send.
|
64
|
+
# @param variables [Hash] a hash of variables specific to the subgraph document.
|
53
65
|
def extract_multipart_form(request, document, variables)
|
54
|
-
return unless @upload_types
|
66
|
+
return unless @upload_types && request.variable_definitions.any? && variables&.any?
|
55
67
|
|
56
|
-
path = []
|
57
68
|
files_by_path = {}
|
58
69
|
|
59
70
|
# extract all upload scalar values mapped by their input path
|
60
|
-
variables.
|
71
|
+
variables.each_with_object([]) do |(key, value), path|
|
61
72
|
ast_node = request.variable_definitions[key]
|
62
73
|
path << key
|
63
74
|
extract_ast_node(ast_node, value, files_by_path, path, request) if ast_node
|
@@ -70,14 +81,14 @@ module GraphQL
|
|
70
81
|
files = files_by_path.values.tap(&:uniq!)
|
71
82
|
variables_copy = variables.dup
|
72
83
|
|
73
|
-
files_by_path.
|
84
|
+
files_by_path.each_key do |path|
|
74
85
|
orig = variables
|
75
86
|
copy = variables_copy
|
76
87
|
path.each_with_index do |key, i|
|
77
88
|
if i == path.length - 1
|
78
|
-
|
79
|
-
map[
|
80
|
-
map[
|
89
|
+
file_index = files.index(copy[key]).to_s
|
90
|
+
map[file_index] ||= []
|
91
|
+
map[file_index] << "variables.#{path.join(".")}"
|
81
92
|
copy[key] = nil
|
82
93
|
elsif orig[key].object_id == copy[key].object_id
|
83
94
|
copy[key] = copy[key].dup
|
@@ -276,19 +276,21 @@ module GraphQL
|
|
276
276
|
routes.each_value do |route|
|
277
277
|
route.reduce(locale_selections) do |parent_selections, boundary|
|
278
278
|
# E.1) Add the key of each boundary query into the prior location's selection set.
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
279
|
+
if boundary.key
|
280
|
+
foreign_key = ExportSelection.key(boundary.key)
|
281
|
+
has_key = false
|
282
|
+
has_typename = false
|
283
|
+
|
284
|
+
parent_selections.each do |node|
|
285
|
+
next unless node.is_a?(GraphQL::Language::Nodes::Field)
|
286
|
+
has_key ||= node.alias == foreign_key
|
287
|
+
has_typename ||= node.alias == ExportSelection.typename_node.alias
|
288
|
+
end
|
289
|
+
|
290
|
+
parent_selections << ExportSelection.key_node(boundary.key) unless has_key
|
291
|
+
parent_selections << ExportSelection.typename_node unless has_typename
|
287
292
|
end
|
288
293
|
|
289
|
-
parent_selections << ExportSelection.key_node(boundary.key) unless has_key
|
290
|
-
parent_selections << ExportSelection.typename_node unless has_typename
|
291
|
-
|
292
294
|
# E.2) Add a planner step for each new entrypoint location.
|
293
295
|
add_step(
|
294
296
|
location: boundary.location,
|
@@ -296,7 +298,7 @@ module GraphQL
|
|
296
298
|
parent_type: parent_type,
|
297
299
|
selections: remote_selections_by_location[boundary.location] || [],
|
298
300
|
path: path.dup,
|
299
|
-
boundary: boundary,
|
301
|
+
boundary: boundary.key ? boundary : nil,
|
300
302
|
).selections
|
301
303
|
end
|
302
304
|
end
|
@@ -324,7 +326,7 @@ module GraphQL
|
|
324
326
|
end
|
325
327
|
|
326
328
|
if expanded_selections
|
327
|
-
@
|
329
|
+
@request.warden.possible_types(parent_type).each do |possible_type|
|
328
330
|
next unless @supergraph.locations_by_type[possible_type.graphql_name].include?(current_location)
|
329
331
|
|
330
332
|
type_name = GraphQL::Language::Nodes::TypeName.new(name: possible_type.graphql_name)
|
@@ -6,8 +6,30 @@ module GraphQL
|
|
6
6
|
SUPPORTED_OPERATIONS = ["query", "mutation"].freeze
|
7
7
|
SKIP_INCLUDE_DIRECTIVE = /@(?:skip|include)/
|
8
8
|
|
9
|
-
|
9
|
+
# @return [Supergraph] supergraph instance that resolves the request.
|
10
|
+
attr_reader :supergraph
|
10
11
|
|
12
|
+
# @return [GraphQL::Language::Nodes::Document] the parsed GraphQL AST document.
|
13
|
+
attr_reader :document
|
14
|
+
|
15
|
+
# @return [Hash] input variables for the request.
|
16
|
+
attr_reader :variables
|
17
|
+
|
18
|
+
# @return [String] operation name selected for the request.
|
19
|
+
attr_reader :operation_name
|
20
|
+
|
21
|
+
# @return [Hash] contextual object passed through resolver flows.
|
22
|
+
attr_reader :context
|
23
|
+
|
24
|
+
# @return [GraphQL::Schema::Warden] a visibility warden for this request.
|
25
|
+
attr_reader :warden
|
26
|
+
|
27
|
+
# Creates a new supergraph request.
|
28
|
+
# @param supergraph [Supergraph] supergraph instance that resolves the request.
|
29
|
+
# @param document [String, GraphQL::Language::Nodes::Document] the request string or parsed AST.
|
30
|
+
# @param operation_name [String, nil] operation name selected for the request.
|
31
|
+
# @param variables [Hash, nil] input variables for the request.
|
32
|
+
# @param context [Hash, nil] a contextual object passed through resolver flows.
|
11
33
|
def initialize(supergraph, document, operation_name: nil, variables: nil, context: nil)
|
12
34
|
@supergraph = supergraph
|
13
35
|
@string = nil
|
@@ -29,25 +51,34 @@ module GraphQL
|
|
29
51
|
|
30
52
|
@operation_name = operation_name
|
31
53
|
@variables = variables || {}
|
32
|
-
|
54
|
+
|
55
|
+
@query = GraphQL::Query.new(@supergraph.schema, document: @document, context: context)
|
56
|
+
@warden = @query.warden
|
57
|
+
@context = @query.context
|
58
|
+
@context[:request] = self
|
33
59
|
end
|
34
60
|
|
61
|
+
# @return [String] the original document string, or a print of the parsed AST document.
|
35
62
|
def string
|
36
63
|
@string || normalized_string
|
37
64
|
end
|
38
65
|
|
66
|
+
# @return [String] a print of the parsed AST document with consistent whitespace.
|
39
67
|
def normalized_string
|
40
68
|
@normalized_string ||= @document.to_query_string
|
41
69
|
end
|
42
70
|
|
71
|
+
# @return [String] a digest of the original document string. Generally faster but less consistent.
|
43
72
|
def digest
|
44
73
|
@digest ||= Digest::SHA2.hexdigest(string)
|
45
74
|
end
|
46
75
|
|
76
|
+
# @return [String] a digest of the normalized document string. Slower but more consistent.
|
47
77
|
def normalized_digest
|
48
78
|
@normalized_digest ||= Digest::SHA2.hexdigest(normalized_string)
|
49
79
|
end
|
50
80
|
|
81
|
+
# @return [GraphQL::Language::Nodes::OperationDefinition] The selected root operation for the request.
|
51
82
|
def operation
|
52
83
|
@operation ||= begin
|
53
84
|
operation_defs = @document.definitions.select do |d|
|
@@ -66,6 +97,7 @@ module GraphQL
|
|
66
97
|
end
|
67
98
|
end
|
68
99
|
|
100
|
+
# @return [String] A string of directives applied to the root operation. These are passed through in all subgraph requests.
|
69
101
|
def operation_directives
|
70
102
|
@operation_directives ||= if operation.directives.any?
|
71
103
|
printer = GraphQL::Language::Printer.new
|
@@ -73,22 +105,28 @@ module GraphQL
|
|
73
105
|
end
|
74
106
|
end
|
75
107
|
|
108
|
+
# @return [Hash<String, GraphQL::Language::Nodes::AbstractNode>] map of variable names to AST type definitions.
|
76
109
|
def variable_definitions
|
77
110
|
@variable_definitions ||= operation.variables.each_with_object({}) do |v, memo|
|
78
111
|
memo[v.name] = v.type
|
79
112
|
end
|
80
113
|
end
|
81
114
|
|
115
|
+
# @return [Hash<String, GraphQL::Language::Nodes::FragmentDefinition>] map of fragment names to their AST definitions.
|
82
116
|
def fragment_definitions
|
83
117
|
@fragment_definitions ||= @document.definitions.each_with_object({}) do |d, memo|
|
84
118
|
memo[d.name] = d if d.is_a?(GraphQL::Language::Nodes::FragmentDefinition)
|
85
119
|
end
|
86
120
|
end
|
87
121
|
|
122
|
+
# Validates the request using the combined supergraph schema.
|
123
|
+
# @return [Array<GraphQL::ExecutionError>] an array of static validation errors
|
88
124
|
def validate
|
89
|
-
@supergraph.
|
125
|
+
result = @supergraph.static_validator.validate(@query)
|
126
|
+
result[:errors]
|
90
127
|
end
|
91
128
|
|
129
|
+
# Prepares the request for stitching by inserting variable defaults and applying @skip/@include conditionals.
|
92
130
|
def prepare!
|
93
131
|
operation.variables.each do |v|
|
94
132
|
@variables[v.name] = v.default_value if @variables[v.name].nil? && !v.default_value.nil?
|
@@ -106,6 +144,19 @@ module GraphQL
|
|
106
144
|
self
|
107
145
|
end
|
108
146
|
|
147
|
+
# Gets and sets the query plan for the request. Assigned query plans may pull from a cache,
|
148
|
+
# which is useful for redundant GraphQL documents (commonly sent by frontend clients).
|
149
|
+
# ```ruby
|
150
|
+
# if cached_plan = $cache.get(request.digest)
|
151
|
+
# plan = GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
|
152
|
+
# request.plan(plan)
|
153
|
+
# else
|
154
|
+
# plan = request.plan
|
155
|
+
# $cache.set(request.digest, JSON.generate(plan.as_json))
|
156
|
+
# end
|
157
|
+
# ```
|
158
|
+
# @param new_plan [Plan, nil] a cached query plan for the request.
|
159
|
+
# @return [Plan] query plan for the request.
|
109
160
|
def plan(new_plan = nil)
|
110
161
|
if new_plan
|
111
162
|
raise StitchingError, "Plan must be a `GraphQL::Stitching::Plan`." unless new_plan.is_a?(Plan)
|
@@ -115,6 +166,9 @@ module GraphQL
|
|
115
166
|
end
|
116
167
|
end
|
117
168
|
|
169
|
+
# Executes the request and returns the rendered response.
|
170
|
+
# @param raw [Boolean] specifies the result should be unshaped without pruning or null bubbling. Useful for debugging.
|
171
|
+
# @return [Hash] the rendered GraphQL response with "data" and "errors" sections.
|
118
172
|
def execute(raw: false)
|
119
173
|
GraphQL::Stitching::Executor.new(self).perform(raw: raw)
|
120
174
|
end
|
@@ -5,6 +5,7 @@ module GraphQL
|
|
5
5
|
module Stitching
|
6
6
|
# Shapes the final results payload to the request selection and schema definition.
|
7
7
|
# This eliminates unrequested export selections and applies null bubbling.
|
8
|
+
# @api private
|
8
9
|
class Shaper
|
9
10
|
def initialize(request)
|
10
11
|
@request = request
|
@@ -117,7 +118,7 @@ module GraphQL
|
|
117
118
|
def typename_in_type?(typename, type)
|
118
119
|
return true if type.graphql_name == typename
|
119
120
|
|
120
|
-
type.kind.abstract? && @
|
121
|
+
type.kind.abstract? && @request.warden.possible_types(type).any? do |t|
|
121
122
|
t.graphql_name == typename
|
122
123
|
end
|
123
124
|
end
|
@@ -2,11 +2,12 @@
|
|
2
2
|
|
3
3
|
module GraphQL
|
4
4
|
module Stitching
|
5
|
+
# Faster implementation of an AST visitor for prerendering
|
6
|
+
# @skip and @include conditional directives into a document.
|
7
|
+
# This avoids unnecessary planning steps, and prepares result shaping.
|
8
|
+
# @api private
|
5
9
|
class SkipInclude
|
6
10
|
class << self
|
7
|
-
# Faster implementation of an AST visitor for prerendering
|
8
|
-
# @skip and @include conditional directives into a document.
|
9
|
-
# This avoids unnecessary planning steps, and prepares result shaping.
|
10
11
|
def render(document, variables)
|
11
12
|
changed = false
|
12
13
|
definitions = document.definitions.map do |original_definition|
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GraphQL::Stitching
|
4
|
+
class Supergraph
|
5
|
+
class ResolverDirective < GraphQL::Schema::Directive
|
6
|
+
graphql_name "resolver"
|
7
|
+
locations OBJECT, INTERFACE, UNION
|
8
|
+
argument :location, String, required: true
|
9
|
+
argument :key, String, required: true
|
10
|
+
argument :field, String, required: true
|
11
|
+
argument :arg, String, required: true
|
12
|
+
argument :list, Boolean, required: false
|
13
|
+
argument :federation, Boolean, required: false
|
14
|
+
repeatable true
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GraphQL::Stitching
|
4
|
+
class Supergraph
|
5
|
+
class SourceDirective < GraphQL::Schema::Directive
|
6
|
+
graphql_name "source"
|
7
|
+
locations FIELD_DEFINITION
|
8
|
+
argument :location, String, required: true
|
9
|
+
repeatable true
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -1,29 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "./supergraph/resolver_directive"
|
4
|
+
require_relative "./supergraph/source_directive"
|
5
|
+
|
3
6
|
module GraphQL
|
4
7
|
module Stitching
|
5
8
|
class Supergraph
|
6
9
|
SUPERGRAPH_LOCATION = "__super"
|
7
10
|
|
8
|
-
class ResolverDirective < GraphQL::Schema::Directive
|
9
|
-
graphql_name "resolver"
|
10
|
-
locations OBJECT, INTERFACE, UNION
|
11
|
-
argument :location, String, required: true
|
12
|
-
argument :key, String, required: true
|
13
|
-
argument :field, String, required: true
|
14
|
-
argument :arg, String, required: true
|
15
|
-
argument :list, Boolean, required: false
|
16
|
-
argument :federation, Boolean, required: false
|
17
|
-
repeatable true
|
18
|
-
end
|
19
|
-
|
20
|
-
class SourceDirective < GraphQL::Schema::Directive
|
21
|
-
graphql_name "source"
|
22
|
-
locations FIELD_DEFINITION
|
23
|
-
argument :location, String, required: true
|
24
|
-
repeatable true
|
25
|
-
end
|
26
|
-
|
27
11
|
class << self
|
28
12
|
def validate_executable!(location, executable)
|
29
13
|
return true if executable.is_a?(Class) && executable <= GraphQL::Schema
|
@@ -88,19 +72,27 @@ module GraphQL
|
|
88
72
|
end
|
89
73
|
end
|
90
74
|
|
91
|
-
|
75
|
+
# @return [GraphQL::Schema] the composed schema for the supergraph.
|
76
|
+
attr_reader :schema
|
77
|
+
|
78
|
+
# @return [Hash<String, Executable>] a map of executable resources by location.
|
79
|
+
attr_reader :executables
|
80
|
+
|
81
|
+
attr_reader :boundaries, :locations_by_type_and_field
|
92
82
|
|
93
83
|
def initialize(schema:, fields: {}, boundaries: {}, executables: {})
|
94
84
|
@schema = schema
|
85
|
+
@schema.use(GraphQL::Schema::AlwaysVisible)
|
86
|
+
|
95
87
|
@boundaries = boundaries
|
96
|
-
@possible_keys_by_type = {}
|
97
|
-
@possible_keys_by_type_and_location = {}
|
98
|
-
@memoized_schema_possible_types = {}
|
99
|
-
@memoized_schema_fields = {}
|
100
|
-
@memoized_introspection_types = nil
|
101
|
-
@memoized_schema_types = nil
|
102
88
|
@fields_by_type_and_location = nil
|
103
89
|
@locations_by_type = nil
|
90
|
+
@memoized_introspection_types = nil
|
91
|
+
@memoized_schema_fields = {}
|
92
|
+
@memoized_schema_types = nil
|
93
|
+
@possible_keys_by_type = {}
|
94
|
+
@possible_keys_by_type_and_location = {}
|
95
|
+
@static_validator = nil
|
104
96
|
|
105
97
|
# add introspection types into the fields mapping
|
106
98
|
@locations_by_type_and_field = memoized_introspection_types.each_with_object(fields) do |(type_name, type), memo|
|
@@ -172,6 +164,11 @@ module GraphQL
|
|
172
164
|
@schema.to_definition
|
173
165
|
end
|
174
166
|
|
167
|
+
# @return [GraphQL::StaticValidation::Validator] static validator for the supergraph schema.
|
168
|
+
def static_validator
|
169
|
+
@static_validator ||= @schema.static_validator
|
170
|
+
end
|
171
|
+
|
175
172
|
def fields
|
176
173
|
@locations_by_type_and_field.reject { |k, _v| memoized_introspection_types[k] }
|
177
174
|
end
|
@@ -188,10 +185,6 @@ module GraphQL
|
|
188
185
|
@memoized_schema_types ||= @schema.types
|
189
186
|
end
|
190
187
|
|
191
|
-
def memoized_schema_possible_types(type_name)
|
192
|
-
@memoized_schema_possible_types[type_name] ||= @schema.possible_types(memoized_schema_types[type_name])
|
193
|
-
end
|
194
|
-
|
195
188
|
def memoized_schema_fields(type_name)
|
196
189
|
@memoized_schema_fields[type_name] ||= begin
|
197
190
|
fields = memoized_schema_types[type_name].fields
|
@@ -252,7 +245,11 @@ module GraphQL
|
|
252
245
|
# ("Type") => ["id", ...]
|
253
246
|
def possible_keys_for_type(type_name)
|
254
247
|
@possible_keys_by_type[type_name] ||= begin
|
255
|
-
@
|
248
|
+
if type_name == @schema.query.graphql_name
|
249
|
+
GraphQL::Stitching::EMPTY_ARRAY
|
250
|
+
else
|
251
|
+
@boundaries[type_name].map(&:key).tap(&:uniq!)
|
252
|
+
end
|
256
253
|
end
|
257
254
|
end
|
258
255
|
|
@@ -269,16 +266,25 @@ module GraphQL
|
|
269
266
|
# For a given type, route from one origin location to one or more remote locations
|
270
267
|
# used to connect a partial type across locations via boundary queries
|
271
268
|
def route_type_to_locations(type_name, start_location, goal_locations)
|
272
|
-
|
269
|
+
key_count = possible_keys_for_type(type_name).length
|
270
|
+
|
271
|
+
if key_count.zero?
|
272
|
+
# nested root scopes have no boundary keys and just return a location
|
273
|
+
goal_locations.each_with_object({}) do |goal_location, memo|
|
274
|
+
memo[goal_location] = [Boundary.new(location: goal_location)]
|
275
|
+
end
|
276
|
+
|
277
|
+
elsif key_count > 1
|
273
278
|
# multiple keys use an A* search to traverse intermediary locations
|
274
|
-
|
275
|
-
end
|
279
|
+
route_type_to_locations_via_search(type_name, start_location, goal_locations)
|
276
280
|
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
281
|
+
else
|
282
|
+
# types with a single key attribute must all be within a single hop of each other,
|
283
|
+
# so can use a simple match to collect boundaries for the goal locations.
|
284
|
+
@boundaries[type_name].each_with_object({}) do |boundary, memo|
|
285
|
+
if goal_locations.include?(boundary.location)
|
286
|
+
memo[boundary.location] = [boundary]
|
287
|
+
end
|
282
288
|
end
|
283
289
|
end
|
284
290
|
end
|
@@ -54,7 +54,7 @@ module GraphQL
|
|
54
54
|
return parent_type.possible_types if parent_type.kind.union?
|
55
55
|
|
56
56
|
result = []
|
57
|
-
schema.types.
|
57
|
+
schema.types.each_value do |type|
|
58
58
|
next unless type <= GraphQL::Schema::Interface && type != parent_type
|
59
59
|
next unless type.interfaces.include?(parent_type)
|
60
60
|
result << type
|
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: 1.2.
|
4
|
+
version: 1.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg MacWilliam
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2024-02-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|
@@ -74,6 +74,7 @@ extra_rdoc_files: []
|
|
74
74
|
files:
|
75
75
|
- ".github/workflows/ci.yml"
|
76
76
|
- ".gitignore"
|
77
|
+
- ".yardopts"
|
77
78
|
- Gemfile
|
78
79
|
- LICENSE
|
79
80
|
- README.md
|
@@ -81,13 +82,11 @@ files:
|
|
81
82
|
- docs/README.md
|
82
83
|
- docs/client.md
|
83
84
|
- docs/composer.md
|
84
|
-
- docs/executor.md
|
85
85
|
- docs/http_executable.md
|
86
86
|
- docs/images/library.png
|
87
87
|
- docs/images/merging.png
|
88
88
|
- docs/images/stitching.png
|
89
89
|
- docs/mechanics.md
|
90
|
-
- docs/planner.md
|
91
90
|
- docs/request.md
|
92
91
|
- docs/supergraph.md
|
93
92
|
- examples/file_uploads/Gemfile
|
@@ -125,6 +124,8 @@ files:
|
|
125
124
|
- lib/graphql/stitching/shaper.rb
|
126
125
|
- lib/graphql/stitching/skip_include.rb
|
127
126
|
- lib/graphql/stitching/supergraph.rb
|
127
|
+
- lib/graphql/stitching/supergraph/resolver_directive.rb
|
128
|
+
- lib/graphql/stitching/supergraph/source_directive.rb
|
128
129
|
- lib/graphql/stitching/util.rb
|
129
130
|
- lib/graphql/stitching/version.rb
|
130
131
|
homepage: https://github.com/gmac/graphql-stitching-ruby
|
data/docs/executor.md
DELETED
@@ -1,68 +0,0 @@
|
|
1
|
-
## GraphQL::Stitching::Executor
|
2
|
-
|
3
|
-
An `Executor` accepts a [`Supergraph`](./supergraph.md), a [query plan hash](./planner.md), and optional request variables. It handles executing requests and merging results collected from across graph locations.
|
4
|
-
|
5
|
-
```ruby
|
6
|
-
query = <<~GRAPHQL
|
7
|
-
query MyQuery($id: ID!) {
|
8
|
-
product(id:$id) {
|
9
|
-
title
|
10
|
-
brands { name }
|
11
|
-
}
|
12
|
-
}
|
13
|
-
GRAPHQL
|
14
|
-
|
15
|
-
request = GraphQL::Stitching::Request.new(
|
16
|
-
supergraph,
|
17
|
-
query,
|
18
|
-
variables: { "id" => "123" },
|
19
|
-
operation_name: "MyQuery",
|
20
|
-
context: { ... },
|
21
|
-
)
|
22
|
-
|
23
|
-
# Via Request:
|
24
|
-
result = request.execute
|
25
|
-
|
26
|
-
# Via Executor:
|
27
|
-
result = GraphQL::Stitching::Executor.new(request).perform
|
28
|
-
```
|
29
|
-
|
30
|
-
### Raw results
|
31
|
-
|
32
|
-
By default, execution results are always returned with document shaping (stitching additions removed, missing fields added, null bubbling applied). You may access the raw execution result by calling the `perform` method with a `raw: true` argument:
|
33
|
-
|
34
|
-
```ruby
|
35
|
-
# get the raw result without shaping using either form:
|
36
|
-
raw_result = request.execute(raw: true)
|
37
|
-
raw_result = GraphQL::Stitching::Executor.new(request).perform(raw: true)
|
38
|
-
```
|
39
|
-
|
40
|
-
The raw result will contain many irregularities from the stitching process, however may be insightful when debugging inconsistencies in results:
|
41
|
-
|
42
|
-
```ruby
|
43
|
-
{
|
44
|
-
"data" => {
|
45
|
-
"product" => {
|
46
|
-
"upc" => "1",
|
47
|
-
"_export_upc" => "1",
|
48
|
-
"_export_typename" => "Product",
|
49
|
-
"name" => "iPhone",
|
50
|
-
"price" => nil,
|
51
|
-
}
|
52
|
-
}
|
53
|
-
}
|
54
|
-
```
|
55
|
-
|
56
|
-
### Batching
|
57
|
-
|
58
|
-
The Executor batches together as many requests as possible to a given location at a given time. Batched queries are written with the operation name suffixed by all operation keys in the batch, and root stitching fields are each prefixed by their batch index and collection index (for non-list fields):
|
59
|
-
|
60
|
-
```graphql
|
61
|
-
query MyOperation_2_3($lang:String!,$currency:Currency!){
|
62
|
-
_0_result: storefronts(ids:["7","8"]) { name(lang:$lang) }
|
63
|
-
_1_0_result: product(upc:"abc") { price(currency:$currency) }
|
64
|
-
_1_1_result: product(upc:"xyz") { price(currency:$currency) }
|
65
|
-
}
|
66
|
-
```
|
67
|
-
|
68
|
-
All told, the executor will make one request per location per generation of data. Generations started on separate forks of the resolution tree will be resolved independently.
|
data/docs/planner.md
DELETED
@@ -1,45 +0,0 @@
|
|
1
|
-
## GraphQL::Stitching::Planner
|
2
|
-
|
3
|
-
A `Planner` generates a query plan for a given [`Supergraph`](./supergraph.md) and [`Request`](./request.md). The generated plan breaks down all the discrete GraphQL operations that must be delegated across locations and their sequencing.
|
4
|
-
|
5
|
-
```ruby
|
6
|
-
document = <<~GRAPHQL
|
7
|
-
query MyQuery($id: ID!) {
|
8
|
-
product(id:$id) {
|
9
|
-
title
|
10
|
-
brands { name }
|
11
|
-
}
|
12
|
-
}
|
13
|
-
GRAPHQL
|
14
|
-
|
15
|
-
request = GraphQL::Stitching::Request.new(
|
16
|
-
supergraph,
|
17
|
-
document,
|
18
|
-
variables: { "id" => "1" },
|
19
|
-
operation_name: "MyQuery",
|
20
|
-
).prepare!
|
21
|
-
|
22
|
-
# Via Request:
|
23
|
-
plan = request.plan
|
24
|
-
|
25
|
-
# Via Planner:
|
26
|
-
plan = GraphQL::Stitching::Planner.new(request).perform
|
27
|
-
```
|
28
|
-
|
29
|
-
### Caching
|
30
|
-
|
31
|
-
Plans are designed to be cacheable. This is very useful for redundant GraphQL documents (commonly sent by frontend clients) where there's no sense in planning every request individually. It's far more efficient to generate a plan once and cache it, then simply retreive the plan and execute it for future requests.
|
32
|
-
|
33
|
-
```ruby
|
34
|
-
cached_plan = $cache.get(request.digest)
|
35
|
-
|
36
|
-
if cached_plan
|
37
|
-
plan = GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
|
38
|
-
request.plan(plan)
|
39
|
-
else
|
40
|
-
plan = request.plan
|
41
|
-
$cache.set(request.digest, JSON.generate(plan.as_json))
|
42
|
-
end
|
43
|
-
|
44
|
-
# execute the plan...
|
45
|
-
```
|