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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32918bd6b17708d712d3ac945f6007b0405299379372aabda8b0c3a5f0c246da
4
- data.tar.gz: 608562f6b35cfe912e2388c6cbfce8c774bedc6f6518d257eb0de8b45fdc8b52
3
+ metadata.gz: 6753e205aac88d3dd3be1c906b743cc49ae0de84ff8ab56c7e9be6e889429aa3
4
+ data.tar.gz: 59440f9d70cb365ef124604c42012bf655934555dfb76905328368353f5e310b
5
5
  SHA512:
6
- metadata.gz: c0f40d79e3b06c9477f5967f12f6e33c0534b916c3454669d2802357137ff78d7bb9bb7d99d7c82d43ea47f58fac1c4d0e5aa38243d4cbfcdec6ba192ef56c0e
7
- data.tar.gz: eed8c018a8efde4500bb0eb93de78803f555c77fad284718e945648160442eea249439d2a2cb5b4c0a5121fc148197f48b9ea0d50a21e7cfa291499fe4533912
6
+ metadata.gz: ac218d2218c14b8debff552ffb11e83e00293dfebb404d25296ed9e408232e488c44575c701ee3fd933331d84755062517f2c2da8df2db98ac7c5e8594595846
7
+ data.tar.gz: ec4f5051ebd511e72536c8e3246302079b2eb65ab5edab37c7c7d7de5488cdc7581318c69a0ea54047bd79ff42bf9e9c5af3434a27fc5667cf029340527c8117
data/.yardopts ADDED
@@ -0,0 +1,5 @@
1
+ --no-private
2
+ --markup=markdown
3
+ --readme=readme.md
4
+ --title='GraphQL Stitching Ruby API Documentation'
5
+ 'lib/**/*.rb' - '*.md'
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) - prepares a requested GraphQL document and variables for stitching.
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(shipping_schema),
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. This pattern of providing key-only types is very common in stitching: it allows a foreign key to be represented as an object stub that may be enriched by data collected from other locations.
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
- ### Preparing requests
28
+ ### Request lifecycle
29
29
 
30
- A request should be prepared for stitching using the `prepare!` method _after_ validations have been run:
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
- ```ruby
33
- document = <<~GRAPHQL
34
- query FetchMovie($id: ID!, $lang: String = "en", $withShowtimes: Boolean = true) {
35
- movie(id:$id) {
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
- module Stitching
5
- class Composer::BaseValidator
3
+ module GraphQL::Stitching
4
+ class Composer
5
+ class BaseValidator
6
6
  def perform(ctx, composer)
7
7
  raise "not implemented"
8
8
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module GraphQL
4
- module Stitching
5
- class Composer::ValidateBoundaries < Composer::BaseValidator
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
- module Stitching
5
- class Composer::ValidateInterfaces < Composer::BaseValidator
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
- class ReferenceType < GraphQL::Schema::Object
9
- field(:f, String) do
10
- argument(:a, String)
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
- NO_DEFAULT_VALUE = ReferenceType.get_field("f").get_argument("a").default_value
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
- attr_reader :query_name, :mutation_name, :candidate_types_by_name_and_location, :schema_directives
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.values.each do |type|
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.values.each do |field|
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.values.each do |argument|
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.values.each do |argument|
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
- module Stitching
5
- class Executor::BoundarySource < GraphQL::Dataloader::Source
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
- module Stitching
5
- class Executor::RootSource < GraphQL::Dataloader::Source
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
- @executor.data.merge!(result["data"]) if result["data"]
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"].each { _1.delete("locations") }
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
- attr_reader :supergraph, :request, :plan, :data, :errors
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
- def initialize(url:, headers:{}, upload_types: nil)
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
- multipart_form = if request.variable_definitions.any? && variables&.any?
18
- extract_multipart_form(request, document, variables)
19
- end
21
+ form_data = extract_multipart_form(request, document, variables)
20
22
 
21
- response = if multipart_form
22
- post_multipart(multipart_form)
23
+ response = if form_data
24
+ send_multipart_form(request, form_data)
23
25
  else
24
- post(document, variables)
26
+ send(request, document, variables)
25
27
  end
26
28
 
27
29
  JSON.parse(response.body)
28
30
  end
29
31
 
30
- def post(document, variables)
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
- def post_multipart(form_data)
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
- # extract multipart upload forms
52
- # spec: https://github.com/jaydenseric/graphql-multipart-request-spec
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.each do |key, value|
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.keys.each do |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
- map_key = files.index(copy[key]).to_s
79
- map[map_key] ||= []
80
- map[map_key] << "variables.#{path.join(".")}"
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
- foreign_key = ExportSelection.key(boundary.key)
280
- has_key = false
281
- has_typename = false
282
-
283
- parent_selections.each do |node|
284
- next unless node.is_a?(GraphQL::Language::Nodes::Field)
285
- has_key ||= node.alias == foreign_key
286
- has_typename ||= node.alias == ExportSelection.typename_node.alias
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
- @supergraph.memoized_schema_possible_types(parent_type.graphql_name).each do |possible_type|
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
- attr_reader :supergraph, :document, :variables, :operation_name, :context
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
- @context = context || GraphQL::Stitching::EMPTY_OBJECT
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.schema.validate(@document, context: @context)
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? && @supergraph.memoized_schema_possible_types(type.graphql_name).any? do |t|
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
- attr_reader :schema, :boundaries, :locations_by_type_and_field, :executables
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
- @boundaries[type_name].map(&:key).tap(&:uniq!)
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
- if possible_keys_for_type(type_name).length > 1
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
- return route_type_to_locations_via_search(type_name, start_location, goal_locations)
275
- end
279
+ route_type_to_locations_via_search(type_name, start_location, goal_locations)
276
280
 
277
- # types with a single key attribute must all be within a single hop of each other,
278
- # so can use a simple match to collect boundaries for the goal locations.
279
- @boundaries[type_name].each_with_object({}) do |boundary, memo|
280
- if goal_locations.include?(boundary.location)
281
- memo[boundary.location] = [boundary]
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.values.each do |type|
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "1.2.0"
5
+ VERSION = "1.2.2"
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: 1.2.0
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: 2023-12-29 00:00:00.000000000 Z
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
- ```