graphql-stitching 0.3.6 → 1.0.0

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: d3cb133db35cdb705f296a2fe67909bfe2f07baefc873cf502ba48dbd53e8c6a
4
- data.tar.gz: c81040a57f364a1a8a045bf079428cfae3c8a5886d2f7d0aaae45844c055b92e
3
+ metadata.gz: 9ff6bbdb8e949da02a0ed58f6db5ab56a6e622d9befc436042dcaaed6556a0f0
4
+ data.tar.gz: ecb682677bb576a3742e1a0ebf8eabe4d17e2dfbc214120aed4cbef09db881df
5
5
  SHA512:
6
- metadata.gz: dc2a8d99942c2a5e1558ded0fbb467dd96d4bdb30c5a2ff9aa2348161ae28073cf36b2b56e51b0d92b757f68a87366ab1750e716bf0939d1645bf1ddee2049f5
7
- data.tar.gz: 599760134f3f59f6ec5285e0d0976e1e9c07ba4103a74341bb35d1dacb89d0026383da2f4c2ca47d33ce3971510d9dddcc61d90a5248342406a4787f650d6126
6
+ metadata.gz: 506a02bced23940043c43bfb59b4a3cba9cf98c7177f0f0abe58b16b0543498cb7acdeecac4d44d22d867d3dd12f3be756236b36ac534e22eb3ed2f347f16984
7
+ data.tar.gz: b409cbe17f3d4792bb8aa4ae0553d8d15dcb5a5316999248a66644fabae67133036804f9a34d805b3bed58e11e40158ca36444365f80773e945c2b586588fd39
data/README.md CHANGED
@@ -9,6 +9,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
9
9
  - Multiple keys per merged type.
10
10
  - Shared objects, fields, enums, and inputs across locations.
11
11
  - Combining local and remote schemas.
12
+ - Type merging via federation `_entities` protocol.
12
13
 
13
14
  **NOT Supported:**
14
15
  - Computed fields (ie: federation-style `@requires`).
@@ -32,7 +33,7 @@ require "graphql/stitching"
32
33
 
33
34
  ## Usage
34
35
 
35
- The quickest way to start is to use the provided [`Gateway`](./docs/gateway.md) component that wraps a stitched graph in an executable workflow with [caching hooks](./docs/gateway.md#cache-hooks):
36
+ The quickest way to start is to use the provided [`Client`](./docs/client.md) component that wraps a stitched graph in an executable workflow with [caching hooks](./docs/client.md#cache-hooks):
36
37
 
37
38
  ```ruby
38
39
  movies_schema = <<~GRAPHQL
@@ -45,21 +46,21 @@ showtimes_schema = <<~GRAPHQL
45
46
  type Query { showtime(id: ID!): Showtime }
46
47
  GRAPHQL
47
48
 
48
- gateway = GraphQL::Stitching::Gateway.new(locations: {
49
+ client = GraphQL::Stitching::Client.new(locations: {
49
50
  movies: {
50
51
  schema: GraphQL::Schema.from_definition(movies_schema),
51
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3000"),
52
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3000"),
52
53
  },
53
54
  showtimes: {
54
55
  schema: GraphQL::Schema.from_definition(showtimes_schema),
55
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001"),
56
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
56
57
  },
57
58
  my_local: {
58
59
  schema: MyLocal::GraphQL::Schema,
59
60
  },
60
61
  })
61
62
 
62
- result = gateway.execute(
63
+ result = client.execute(
63
64
  query: "query FetchFromAll($movieId:ID!, $showtimeId:ID!){
64
65
  movie(id:$movieId) { name }
65
66
  showtime(id:$showtimeId): { time }
@@ -72,7 +73,7 @@ result = gateway.execute(
72
73
 
73
74
  Schemas provided in [location settings](./docs/composer.md#performing-composition) may be class-based schemas with local resolvers (locally-executable schemas), or schemas built from SDL strings (schema definition language parsed using `GraphQL::Schema.from_definition`) and mapped to remote locations. See [composer docs](./docs/composer.md#merge-patterns) for more information on how schemas get merged.
74
75
 
75
- While the `Gateway` constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
76
+ While the `Client` constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
76
77
 
77
78
  - [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
78
79
  - [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
@@ -86,7 +87,11 @@ While the `Gateway` constructor is an easy quick start, the library also has sev
86
87
 
87
88
  ![Merging types](./docs/images/merging.png)
88
89
 
89
- To facilitate this merging of types, stitching must know how to cross-reference and fetch each variant of a type from its source location. This is done using the `@stitch` directive:
90
+ To facilitate this merging of types, stitching must know how to cross-reference and fetch each variant of a type from its source location. This can be done using [arbitrary queries](#merged-types-via-arbitrary-queries) or [federation entities](#merged-types-via-federation-entities).
91
+
92
+ ### Merged types via arbitrary queries
93
+
94
+ Types can merge through arbitrary queries using the `@stitch` directive:
90
95
 
91
96
  ```graphql
92
97
  directive @stitch(key: String!) repeatable on FIELD_DEFINITION
@@ -121,14 +126,14 @@ shipping_schema = <<~GRAPHQL
121
126
  }
122
127
  GRAPHQL
123
128
 
124
- supergraph = GraphQL::Stitching::Composer.new.perform({
129
+ client = GraphQL::Stitching::Client.new(locations: {
125
130
  products: {
126
131
  schema: GraphQL::Schema.from_definition(products_schema),
127
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001"),
132
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
128
133
  },
129
134
  shipping: {
130
135
  schema: GraphQL::Schema.from_definition(shipping_schema),
131
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3002"),
136
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
132
137
  },
133
138
  })
134
139
  ```
@@ -294,6 +299,66 @@ The library is configured to use a `@stitch` directive by default. You may custo
294
299
  GraphQL::Stitching.stitch_directive = "merge"
295
300
  ```
296
301
 
302
+ ### Merged types via Federation entities
303
+
304
+ The [Apollo Federation specification](https://www.apollographql.com/docs/federation/subgraph-spec/) defines a standard interface for accessing merged type variants across locations. Stitching can utilize a _subset_ of this interface to facilitate basic type merging. The following spec is supported:
305
+
306
+ - `@key(fields: "id")` (repeatable) specifies a key field for an object type. The key `fields` argument may only contain one field selection.
307
+ - `_Entity` is a union type that must contain all types that implement a `@key`.
308
+ - `_Any` is a scalar that recieves raw JSON objects; each object representation contains a `__typename` and the type's key field.
309
+ - `_entities(representations: [_Any!]!): [_Entity]!` is a root query for local entity types.
310
+
311
+ The composer will automatcially detect and stitch schemas with an `_entities` query, for example:
312
+
313
+ ```ruby
314
+ accounts_schema = <<~GRAPHQL
315
+ directive @key(fields: String!) repeatable on OBJECT
316
+
317
+ type User @key(fields: "id") {
318
+ id: ID!
319
+ name: String!
320
+ address: String!
321
+ }
322
+
323
+ union _Entity = User
324
+ scalar _Any
325
+
326
+ type Query {
327
+ user(id: ID!): User
328
+ _entities(representations: [_Any!]!): [_Entity]!
329
+ }
330
+ GRAPHQL
331
+
332
+ comments_schema = <<~GRAPHQL
333
+ directive @key(fields: String!) repeatable on OBJECT
334
+
335
+ type User @key(fields: "id") {
336
+ id: ID!
337
+ comments: [String!]!
338
+ }
339
+
340
+ union _Entity = User
341
+ scalar _Any
342
+
343
+ type Query {
344
+ _entities(representations: [_Any!]!): [_Entity]!
345
+ }
346
+ GRAPHQL
347
+
348
+ client = GraphQL::Stitching::Client.new(locations: {
349
+ accounts: {
350
+ schema: GraphQL::Schema.from_definition(accounts_schema),
351
+ executable: ...,
352
+ },
353
+ comments: {
354
+ schema: GraphQL::Schema.from_definition(comments_schema),
355
+ executable: ...,
356
+ },
357
+ })
358
+ ```
359
+
360
+ It's perfectly fine to mix and match schemas that implement an `_entities` query with schemas that implement `@stitch` directives; the protocols achieve the same result. Note that stitching is much simpler than Apollo Federation by design, and that Federation's advanced routing features (such as the `@requires` and `@external` directives) will not work with stitching.
361
+
297
362
  ## Executables
298
363
 
299
364
  An executable resource performs location-specific GraphQL requests. Executables may be `GraphQL::Schema` classes, or any object that responds to `.call(location, source, variables, context)` and returns a raw GraphQL response:
@@ -320,7 +385,7 @@ supergraph = GraphQL::Stitching::Composer.new.perform({
320
385
  },
321
386
  second: {
322
387
  schema: SecondSchema,
323
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001", headers: { ... }),
388
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001", headers: { ... }),
324
389
  },
325
390
  third: {
326
391
  schema: ThirdSchema,
@@ -333,7 +398,7 @@ supergraph = GraphQL::Stitching::Composer.new.perform({
333
398
  })
334
399
  ```
335
400
 
336
- The `GraphQL::Stitching::RemoteClient` class is provided as a simple executable wrapper around `Net::HTTP.post`. You should build your own executables to leverage your existing libraries and to add instrumentation. Note that you must manually assign all executables to a `Supergraph` when rehydrating it from cache ([see docs](./docs/supergraph.md)).
401
+ The `GraphQL::Stitching::HttpExecutable` class is provided as a simple executable wrapper around `Net::HTTP.post`. You should build your own executables to leverage your existing libraries and to add instrumentation. Note that you must manually assign all executables to a `Supergraph` when rehydrating it from cache ([see docs](./docs/supergraph.md)).
337
402
 
338
403
  ## Concurrency
339
404
 
data/docs/README.md CHANGED
@@ -6,7 +6,7 @@ This module provides a collection of components that may be composed into a stit
6
6
 
7
7
  Major components include:
8
8
 
9
- - [Gateway](./gateway.md) - an out-of-the-box stitching configuration.
9
+ - [Client](./client.md) - an out-of-the-box setup for performing stitched requests.
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.
data/docs/client.md ADDED
@@ -0,0 +1,103 @@
1
+ ## GraphQL::Stitching::Client
2
+
3
+ The `Client` is an out-of-the-box convenience with all stitching components assembled into a default workflow. A client is designed to work for most common needs, though you're welcome to assemble the component parts into your own configuration (see the [client source](../lib/graphql/stitching/client.rb) for an example). A client is constructed with the same [location settings](./composer.md#performing-composition) used to perform supergraph composition:
4
+
5
+ ```ruby
6
+ movies_schema = "type Query { ..."
7
+ showtimes_schema = "type Query { ..."
8
+
9
+ client = GraphQL::Stitching::Client.new(locations: {
10
+ products: {
11
+ schema: GraphQL::Schema.from_definition(movies_schema),
12
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3000"),
13
+ stitch: [{ field_name: "products", key: "id" }],
14
+ },
15
+ showtimes: {
16
+ schema: GraphQL::Schema.from_definition(showtimes_schema),
17
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
18
+ },
19
+ my_local: {
20
+ schema: MyLocal::GraphQL::Schema,
21
+ },
22
+ })
23
+ ```
24
+
25
+ Alternatively, you may pass a prebuilt `Supergraph` instance to the `Client` constructor. This is useful when [exporting and rehydrating](./supergraph.md#export-and-caching) supergraph instances, which bypasses the need for runtime composition:
26
+
27
+ ```ruby
28
+ exported_schema = "type Query { ..."
29
+ exported_mapping = JSON.parse("{ ... }")
30
+ supergraph = GraphQL::Stitching::Supergraph.from_export(
31
+ schema: exported_schema,
32
+ delegation_map: exported_mapping,
33
+ executables: { ... },
34
+ )
35
+
36
+ client = GraphQL::Stitching::Client.new(supergraph: supergraph)
37
+ ```
38
+
39
+ ### Execution
40
+
41
+ A client provides an `execute` method with a subset of arguments provided by [`GraphQL::Schema.execute`](https://graphql-ruby.org/queries/executing_queries). Executing requests on a stitching client becomes mostly a drop-in replacement to executing on a `GraphQL::Schema` instance:
42
+
43
+ ```ruby
44
+ result = client.execute(
45
+ query: "query MyProduct($id: ID!) { product(id: $id) { name } }",
46
+ variables: { "id" => "1" },
47
+ operation_name: "MyProduct",
48
+ )
49
+ ```
50
+
51
+ Arguments for the `execute` method include:
52
+
53
+ * `query`: a query (or mutation) as a string or parsed AST.
54
+ * `variables`: a hash of variables for the request.
55
+ * `operation_name`: the name of the operation to execute (when multiple are provided).
56
+ * `validate`: true if static validation should run on the supergraph schema before execution.
57
+ * `context`: an object passed through to executable calls and client hooks.
58
+
59
+ ### Cache hooks
60
+
61
+ The client provides cache hooks to enable caching query plans across requests. Without caching, every request made to the client will be planned individually. With caching, a query may be planned once, cached, and then executed from cache for subsequent requests. Cache keys are a normalized digest of each query string.
62
+
63
+ ```ruby
64
+ client.on_cache_read do |key, _context|
65
+ $redis.get(key) # << 3P code
66
+ end
67
+
68
+ client.on_cache_write do |key, payload, _context|
69
+ $redis.set(key, payload) # << 3P code
70
+ end
71
+ ```
72
+
73
+ Note that inlined input data works against caching, so you should _avoid_ this:
74
+
75
+ ```graphql
76
+ query {
77
+ product(id: "1") { name }
78
+ }
79
+ ```
80
+
81
+ Instead, always leverage variables in queries so that the document body remains consistent across requests:
82
+
83
+ ```graphql
84
+ query($id: ID!) {
85
+ product(id: $id) { name }
86
+ }
87
+
88
+ # variables: { "id" => "1" }
89
+ ```
90
+
91
+ ### Error hooks
92
+
93
+ The client also provides an error hook. Any program errors rescued during execution will be passed to the `on_error` handler, which can report on the error as needed and return a formatted error message for the client to add to the [GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) result.
94
+
95
+ ```ruby
96
+ client.on_error do |err, context|
97
+ # log the error
98
+ Bugsnag.notify(err)
99
+
100
+ # return a formatted message for the public response
101
+ "Whoops, please contact support abount request '#{context[:request_id]}'"
102
+ end
103
+ ```
data/docs/composer.md CHANGED
@@ -68,12 +68,12 @@ products_sdl = "type Query { ..."
68
68
  supergraph = GraphQL::Stitching::Composer.new.perform({
69
69
  storefronts: {
70
70
  schema: GraphQL::Schema.from_definition(storefronts_sdl),
71
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001"),
71
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
72
72
  stitch: [{ field_name: "storefront", key: "id" }],
73
73
  },
74
74
  products: {
75
75
  schema: GraphQL::Schema.from_definition(products_sdl),
76
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3002"),
76
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
77
77
  },
78
78
  my_local: {
79
79
  schema: MyLocalSchema,
data/docs/supergraph.md CHANGED
@@ -28,7 +28,7 @@ supergraph = GraphQL::Stitching::Supergraph.from_export(
28
28
  schema: supergraph_sdl,
29
29
  delegation_map: delegation_map,
30
30
  executables: {
31
- my_remote: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3000"),
31
+ my_remote: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3000"),
32
32
  my_local: MyLocalSchema,
33
33
  }
34
34
  )
data/example/gateway.rb CHANGED
@@ -13,17 +13,17 @@ class StitchedApp
13
13
  @graphiql = file.read
14
14
  file.close
15
15
 
16
- @gateway = GraphQL::Stitching::Gateway.new(locations: {
16
+ @client = GraphQL::Stitching::Client.new(locations: {
17
17
  products: {
18
18
  schema: Schemas::Example::Products,
19
19
  },
20
20
  storefronts: {
21
21
  schema: Schemas::Example::Storefronts,
22
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001/graphql"),
22
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001/graphql"),
23
23
  },
24
24
  manufacturers: {
25
25
  schema: Schemas::Example::Manufacturers,
26
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3002/graphql"),
26
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002/graphql"),
27
27
  }
28
28
  })
29
29
  end
@@ -34,7 +34,7 @@ class StitchedApp
34
34
  when /graphql/
35
35
  params = JSON.parse(req.body.read)
36
36
 
37
- result = @gateway.execute(
37
+ result = @client.execute(
38
38
  query: params["query"],
39
39
  variables: params["variables"],
40
40
  operation_name: params["operationName"],
@@ -4,16 +4,16 @@ require "json"
4
4
 
5
5
  module GraphQL
6
6
  module Stitching
7
- class Gateway
8
- class GatewayError < StitchingError; end
7
+ class Client
8
+ class ClientError < StitchingError; end
9
9
 
10
10
  attr_reader :supergraph
11
11
 
12
12
  def initialize(locations: nil, supergraph: nil, composer: nil)
13
13
  @supergraph = if locations && supergraph
14
- raise GatewayError, "Cannot provide both locations and a supergraph."
14
+ raise ClientError, "Cannot provide both locations and a supergraph."
15
15
  elsif supergraph && !supergraph.is_a?(GraphQL::Stitching::Supergraph)
16
- raise GatewayError, "Provided supergraph must be a GraphQL::Stitching::Supergraph instance."
16
+ raise ClientError, "Provided supergraph must be a GraphQL::Stitching::Supergraph instance."
17
17
  elsif supergraph
18
18
  supergraph
19
19
  else
@@ -57,17 +57,17 @@ module GraphQL
57
57
  end
58
58
 
59
59
  def on_cache_read(&block)
60
- raise GatewayError, "A cache read block is required." unless block_given?
60
+ raise ClientError, "A cache read block is required." unless block_given?
61
61
  @on_cache_read = block
62
62
  end
63
63
 
64
64
  def on_cache_write(&block)
65
- raise GatewayError, "A cache write block is required." unless block_given?
65
+ raise ClientError, "A cache write block is required." unless block_given?
66
66
  @on_cache_write = block
67
67
  end
68
68
 
69
69
  def on_error(&block)
70
- raise GatewayError, "An error handler block is required." unless block_given?
70
+ raise ClientError, "An error handler block is required." unless block_given?
71
71
  @on_error = block
72
72
  end
73
73
 
@@ -30,6 +30,7 @@ module GraphQL
30
30
  @deprecation_merger = deprecation_merger || DEFAULT_VALUE_MERGER
31
31
  @directive_kwarg_merger = directive_kwarg_merger || DEFAULT_VALUE_MERGER
32
32
  @root_field_location_selector = root_field_location_selector || DEFAULT_ROOT_FIELD_LOCATION_SELECTOR
33
+ @stitch_directives = {}
33
34
  end
34
35
 
35
36
  def perform(locations_input)
@@ -91,7 +92,7 @@ module GraphQL
91
92
  when "ENUM"
92
93
  build_enum_type(type_name, types_by_location, enum_usage)
93
94
  when "OBJECT"
94
- extract_boundaries(type_name, types_by_location)
95
+ extract_boundaries(type_name, types_by_location) if type_name == @query_name
95
96
  build_object_type(type_name, types_by_location)
96
97
  when "INTERFACE"
97
98
  build_interface_type(type_name, types_by_location)
@@ -145,22 +146,35 @@ module GraphQL
145
146
  raise ComposerError, "The schema for `#{location}` location must be a GraphQL::Schema class."
146
147
  end
147
148
 
148
- if input[:stitch]
149
- stitch_directive = Class.new(GraphQL::Schema::Directive) do
150
- graphql_name(GraphQL::Stitching.stitch_directive)
151
- locations :FIELD_DEFINITION
152
- argument :key, String
153
- repeatable true
154
- end
149
+ input.fetch(:stitch, GraphQL::Stitching::EMPTY_ARRAY).each do |dir|
150
+ type = dir[:parent_type_name] ? schema.types[dir[:parent_type_name]] : schema.query
151
+ raise ComposerError, "Invalid stitch directive type `#{dir[:parent_type_name]}`" unless type
155
152
 
156
- input[:stitch].each do |dir|
157
- type = dir[:type_name] ? schema.types[dir[:type_name]] : schema.query
158
- raise ComposerError, "Invalid stitch directive type `#{dir[:type_name]}`" unless type
153
+ field = type.fields[dir[:field_name]]
154
+ raise ComposerError, "Invalid stitch directive field `#{dir[:field_name]}`" unless field
159
155
 
160
- field = type.fields[dir[:field_name]]
161
- raise ComposerError, "Invalid stitch directive field `#{dir[:field_name]}`" unless field
156
+ field_path = "#{location}.#{field.name}"
157
+ @stitch_directives[field_path] ||= []
158
+ @stitch_directives[field_path] << dir.slice(:key, :type_name)
159
+ end
162
160
 
163
- field.directive(stitch_directive, **dir.slice(:key))
161
+ federation_entity_type = schema.types["_Entity"]
162
+ if federation_entity_type && federation_entity_type.kind.union? && schema.query.fields["_entities"]&.type&.unwrap == federation_entity_type
163
+ schema.possible_types(federation_entity_type).each do |entity_type|
164
+ entity_type.directives.each do |directive|
165
+ next unless directive.graphql_name == "key"
166
+
167
+ key = directive.arguments.keyword_arguments.fetch(:fields).strip
168
+ raise ComposerError, "Composite federation keys are not supported." unless /^\w+$/.match?(key)
169
+
170
+ field_path = "#{location}._entities"
171
+ @stitch_directives[field_path] ||= []
172
+ @stitch_directives[field_path] << {
173
+ key: key,
174
+ type_name: entity_type.graphql_name,
175
+ federation: true,
176
+ }
177
+ end
164
178
  end
165
179
  end
166
180
 
@@ -462,11 +476,16 @@ module GraphQL
462
476
  type_candidate.fields.each do |field_name, field_candidate|
463
477
  boundary_type_name = field_candidate.type.unwrap.graphql_name
464
478
  boundary_structure = Util.flatten_type_structure(field_candidate.type)
479
+ boundary_kwargs = @stitch_directives["#{location}.#{field_name}"] || []
465
480
 
466
481
  field_candidate.directives.each do |directive|
467
482
  next unless directive.graphql_name == GraphQL::Stitching.stitch_directive
483
+ boundary_kwargs << directive.arguments.keyword_arguments
484
+ end
468
485
 
469
- key = directive.arguments.keyword_arguments.fetch(:key)
486
+ boundary_kwargs.each do |kwargs|
487
+ key = kwargs.fetch(:key)
488
+ impl_type_name = kwargs.fetch(:type_name, boundary_type_name)
470
489
  key_selections = GraphQL.parse("{ #{key} }").definitions[0].selections
471
490
 
472
491
  if key_selections.length != 1
@@ -489,15 +508,16 @@ module GraphQL
489
508
  raise ComposerError, "Mismatched input/output for #{type_name}.#{field_name}.#{argument_name} boundary. Arguments must map directly to results."
490
509
  end
491
510
 
492
- @boundary_map[boundary_type_name] ||= []
493
- @boundary_map[boundary_type_name] << {
511
+ @boundary_map[impl_type_name] ||= []
512
+ @boundary_map[impl_type_name] << {
494
513
  "location" => location,
514
+ "type_name" => impl_type_name,
495
515
  "key" => key_selections[0].name,
496
516
  "field" => field_candidate.name,
497
517
  "arg" => argument_name,
498
518
  "list" => boundary_structure.first[:list],
499
- "type_name" => boundary_type_name,
500
- }
519
+ "federation" => kwargs[:federation],
520
+ }.compact
501
521
  end
502
522
  end
503
523
  end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Executor::BoundarySource < GraphQL::Dataloader::Source
6
+ def initialize(executor, location)
7
+ @executor = executor
8
+ @location = location
9
+ end
10
+
11
+ def fetch(ops)
12
+ origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
13
+ origin_set = op["path"].reduce([@executor.data]) do |set, path_segment|
14
+ set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
15
+ end
16
+
17
+ if op["if_type"]
18
+ # operations planned around unused fragment conditions should not trigger requests
19
+ origin_set.select! { _1["_STITCH_typename"] == op["if_type"] }
20
+ end
21
+
22
+ memo[op] = origin_set if origin_set.any?
23
+ end
24
+
25
+ if origin_sets_by_operation.any?
26
+ query_document, variable_names = build_document(origin_sets_by_operation, @executor.request.operation_name)
27
+ variables = @executor.request.variables.slice(*variable_names)
28
+ raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables, @executor.request.context)
29
+ @executor.query_count += 1
30
+
31
+ merge_results!(origin_sets_by_operation, raw_result.dig("data"))
32
+
33
+ errors = raw_result.dig("errors")
34
+ @executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors&.any?
35
+ end
36
+
37
+ ops.map { origin_sets_by_operation[_1] ? _1["order"] : nil }
38
+ end
39
+
40
+ # Builds batched boundary queries
41
+ # "query MyOperation_2_3($var:VarType) {
42
+ # _0_result: list(keys:["a","b","c"]) { boundarySelections... }
43
+ # _1_0_result: item(key:"x") { boundarySelections... }
44
+ # _1_1_result: item(key:"y") { boundarySelections... }
45
+ # _1_2_result: item(key:"z") { boundarySelections... }
46
+ # }"
47
+ def build_document(origin_sets_by_operation, operation_name = nil)
48
+ variable_defs = {}
49
+ query_fields = origin_sets_by_operation.map.with_index do |(op, origin_set), batch_index|
50
+ variable_defs.merge!(op["variables"])
51
+ boundary = op["boundary"]
52
+
53
+ if boundary["list"]
54
+ input = origin_set.each_with_index.reduce(String.new) do |memo, (origin_obj, index)|
55
+ memo << "," if index > 0
56
+ memo << build_key(boundary["key"], origin_obj, federation: boundary["federation"])
57
+ memo
58
+ end
59
+
60
+ "_#{batch_index}_result: #{boundary["field"]}(#{boundary["arg"]}:[#{input}]) #{op["selections"]}"
61
+ else
62
+ origin_set.map.with_index do |origin_obj, index|
63
+ input = build_key(boundary["key"], origin_obj, federation: boundary["federation"])
64
+ "_#{batch_index}_#{index}_result: #{boundary["field"]}(#{boundary["arg"]}:#{input}) #{op["selections"]}"
65
+ end
66
+ end
67
+ end
68
+
69
+ doc = String.new("query") # << boundary fulfillment always uses query
70
+
71
+ if operation_name
72
+ doc << " #{operation_name}"
73
+ origin_sets_by_operation.each_key do |op|
74
+ doc << "_#{op["order"]}"
75
+ end
76
+ end
77
+
78
+ if variable_defs.any?
79
+ variable_str = variable_defs.map { |k, v| "$#{k}:#{v}" }.join(",")
80
+ doc << "(#{variable_str})"
81
+ end
82
+
83
+ doc << "{ #{query_fields.join(" ")} }"
84
+
85
+ return doc, variable_defs.keys
86
+ end
87
+
88
+ def build_key(key, origin_obj, federation: false)
89
+ key_value = JSON.generate(origin_obj["_STITCH_#{key}"])
90
+ if federation
91
+ "{ __typename: \"#{origin_obj["_STITCH_typename"]}\", #{key}: #{key_value} }"
92
+ else
93
+ key_value
94
+ end
95
+ end
96
+
97
+ def merge_results!(origin_sets_by_operation, raw_result)
98
+ return unless raw_result
99
+
100
+ origin_sets_by_operation.each_with_index do |(op, origin_set), batch_index|
101
+ results = if op.dig("boundary", "list")
102
+ raw_result["_#{batch_index}_result"]
103
+ else
104
+ origin_set.map.with_index { |_, index| raw_result["_#{batch_index}_#{index}_result"] }
105
+ end
106
+
107
+ next unless results&.any?
108
+
109
+ origin_set.each_with_index do |origin_obj, index|
110
+ origin_obj.merge!(results[index]) if results[index]
111
+ end
112
+ end
113
+ end
114
+
115
+ # https://spec.graphql.org/June2018/#sec-Errors
116
+ def extract_errors!(origin_sets_by_operation, errors)
117
+ ops = origin_sets_by_operation.keys
118
+ origin_sets = origin_sets_by_operation.values
119
+ pathed_errors_by_op_index_and_object_id = {}
120
+
121
+ errors_result = errors.each_with_object([]) do |err, memo|
122
+ err.delete("locations")
123
+ path = err["path"]
124
+
125
+ if path && path.length > 0
126
+ result_alias = /^_(\d+)(?:_(\d+))?_result$/.match(path.first.to_s)
127
+
128
+ if result_alias
129
+ path = err["path"] = path[1..-1]
130
+
131
+ origin_obj = if result_alias[2]
132
+ origin_sets.dig(result_alias[1].to_i, result_alias[2].to_i)
133
+ elsif path[0].is_a?(Integer) || /\d+/.match?(path[0].to_s)
134
+ origin_sets.dig(result_alias[1].to_i, path.shift.to_i)
135
+ end
136
+
137
+ if origin_obj
138
+ by_op_index = pathed_errors_by_op_index_and_object_id[result_alias[1].to_i] ||= {}
139
+ by_object_id = by_op_index[origin_obj.object_id] ||= []
140
+ by_object_id << err
141
+ next
142
+ end
143
+ end
144
+ end
145
+
146
+ memo << err
147
+ end
148
+
149
+ if pathed_errors_by_op_index_and_object_id.any?
150
+ pathed_errors_by_op_index_and_object_id.each do |op_index, pathed_errors_by_object_id|
151
+ repath_errors!(pathed_errors_by_object_id, ops.dig(op_index, "path"))
152
+ errors_result.concat(pathed_errors_by_object_id.values)
153
+ end
154
+ end
155
+ errors_result.flatten!
156
+ end
157
+
158
+ private
159
+
160
+ # traverse forward through origin data, expanding arrays to follow all paths
161
+ # any errors found for an origin object_id have their path prefixed by the object path
162
+ def repath_errors!(pathed_errors_by_object_id, forward_path, current_path=[], root=@executor.data)
163
+ current_path.push(forward_path.shift)
164
+ scope = root[current_path.last]
165
+
166
+ if forward_path.any? && scope.is_a?(Array)
167
+ scope.each_with_index do |element, index|
168
+ inner_elements = element.is_a?(Array) ? element.flatten : [element]
169
+ inner_elements.each do |inner_element|
170
+ current_path << index
171
+ repath_errors!(pathed_errors_by_object_id, forward_path, current_path, inner_element)
172
+ current_path.pop
173
+ end
174
+ end
175
+
176
+ elsif forward_path.any?
177
+ current_path << index
178
+ repath_errors!(pathed_errors_by_object_id, forward_path, current_path, scope)
179
+ current_path.pop
180
+
181
+ elsif scope.is_a?(Array)
182
+ scope.each_with_index do |element, index|
183
+ inner_elements = element.is_a?(Array) ? element.flatten : [element]
184
+ inner_elements.each do |inner_element|
185
+ errors = pathed_errors_by_object_id[inner_element.object_id]
186
+ errors.each { _1["path"] = [*current_path, index, *_1["path"]] } if errors
187
+ end
188
+ end
189
+
190
+ else
191
+ errors = pathed_errors_by_object_id[scope.object_id]
192
+ errors.each { _1["path"] = [*current_path, *_1["path"]] } if errors
193
+ end
194
+
195
+ forward_path.unshift(current_path.pop)
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ class Executor::RootSource < GraphQL::Dataloader::Source
6
+ def initialize(executor, location)
7
+ @executor = executor
8
+ @location = location
9
+ end
10
+
11
+ def fetch(ops)
12
+ op = ops.first # There should only ever be one per location at a time
13
+
14
+ query_document = build_document(op, @executor.request.operation_name)
15
+ query_variables = @executor.request.variables.slice(*op["variables"].keys)
16
+ result = @executor.supergraph.execute_at_location(op["location"], query_document, query_variables, @executor.request.context)
17
+ @executor.query_count += 1
18
+
19
+ @executor.data.merge!(result["data"]) if result["data"]
20
+ if result["errors"]&.any?
21
+ result["errors"].each { _1.delete("locations") }
22
+ @executor.errors.concat(result["errors"])
23
+ end
24
+
25
+ ops.map { op["order"] }
26
+ end
27
+
28
+ # Builds root source documents
29
+ # "query MyOperation_1($var:VarType) { rootSelections ... }"
30
+ def build_document(op, operation_name = nil)
31
+ doc = String.new
32
+ doc << op["operation_type"]
33
+
34
+ if operation_name
35
+ doc << " #{operation_name}_#{op["order"]}"
36
+ end
37
+
38
+ if op["variables"].any?
39
+ variable_defs = op["variables"].map { |k, v| "$#{k}:#{v}" }.join(",")
40
+ doc << "(#{variable_defs})"
41
+ end
42
+
43
+ doc << op["selections"]
44
+ doc
45
+ end
46
+ end
47
+ end
48
+ end
@@ -5,232 +5,6 @@ require "json"
5
5
  module GraphQL
6
6
  module Stitching
7
7
  class Executor
8
-
9
- class RootSource < GraphQL::Dataloader::Source
10
- def initialize(executor, location)
11
- @executor = executor
12
- @location = location
13
- end
14
-
15
- def fetch(ops)
16
- op = ops.first # There should only ever be one per location at a time
17
-
18
- query_document = build_document(op, @executor.request.operation_name)
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.context)
21
- @executor.query_count += 1
22
-
23
- @executor.data.merge!(result["data"]) if result["data"]
24
- if result["errors"]&.any?
25
- result["errors"].each { _1.delete("locations") }
26
- @executor.errors.concat(result["errors"])
27
- end
28
-
29
- ops.map { op["order"] }
30
- end
31
-
32
- # Builds root source documents
33
- # "query MyOperation_1($var:VarType) { rootSelections ... }"
34
- def build_document(op, operation_name = nil)
35
- doc = String.new
36
- doc << op["operation_type"]
37
-
38
- if operation_name
39
- doc << " #{operation_name}_#{op["order"]}"
40
- end
41
-
42
- if op["variables"].any?
43
- variable_defs = op["variables"].map { |k, v| "$#{k}:#{v}" }.join(",")
44
- doc << "(#{variable_defs})"
45
- end
46
-
47
- doc << op["selections"]
48
- doc
49
- end
50
- end
51
-
52
- class BoundarySource < GraphQL::Dataloader::Source
53
- def initialize(executor, location)
54
- @executor = executor
55
- @location = location
56
- end
57
-
58
- def fetch(ops)
59
- origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
60
- origin_set = op["path"].reduce([@executor.data]) do |set, path_segment|
61
- set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
62
- end
63
-
64
- if op["if_type"]
65
- # operations planned around unused fragment conditions should not trigger requests
66
- origin_set.select! { _1["_STITCH_typename"] == op["if_type"] }
67
- end
68
-
69
- memo[op] = origin_set if origin_set.any?
70
- end
71
-
72
- if origin_sets_by_operation.any?
73
- query_document, variable_names = build_document(origin_sets_by_operation, @executor.request.operation_name)
74
- variables = @executor.request.variables.slice(*variable_names)
75
- raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables, @executor.request.context)
76
- @executor.query_count += 1
77
-
78
- merge_results!(origin_sets_by_operation, raw_result.dig("data"))
79
-
80
- errors = raw_result.dig("errors")
81
- @executor.errors.concat(extract_errors!(origin_sets_by_operation, errors)) if errors&.any?
82
- end
83
-
84
- ops.map { origin_sets_by_operation[_1] ? _1["order"] : nil }
85
- end
86
-
87
- # Builds batched boundary queries
88
- # "query MyOperation_2_3($var:VarType) {
89
- # _0_result: list(keys:["a","b","c"]) { boundarySelections... }
90
- # _1_0_result: item(key:"x") { boundarySelections... }
91
- # _1_1_result: item(key:"y") { boundarySelections... }
92
- # _1_2_result: item(key:"z") { boundarySelections... }
93
- # }"
94
- def build_document(origin_sets_by_operation, operation_name = nil)
95
- variable_defs = {}
96
- query_fields = origin_sets_by_operation.map.with_index do |(op, origin_set), batch_index|
97
- variable_defs.merge!(op["variables"])
98
- boundary = op["boundary"]
99
- key_selection = "_STITCH_#{boundary["key"]}"
100
-
101
- if boundary["list"]
102
- input = JSON.generate(origin_set.map { _1[key_selection] })
103
- "_#{batch_index}_result: #{boundary["field"]}(#{boundary["arg"]}:#{input}) #{op["selections"]}"
104
- else
105
- origin_set.map.with_index do |origin_obj, index|
106
- input = JSON.generate(origin_obj[key_selection])
107
- "_#{batch_index}_#{index}_result: #{boundary["field"]}(#{boundary["arg"]}:#{input}) #{op["selections"]}"
108
- end
109
- end
110
- end
111
-
112
- doc = String.new
113
- doc << "query" # << boundary fulfillment always uses query
114
-
115
- if operation_name
116
- doc << " #{operation_name}"
117
- origin_sets_by_operation.each_key do |op|
118
- doc << "_#{op["order"]}"
119
- end
120
- end
121
-
122
- if variable_defs.any?
123
- variable_str = variable_defs.map { |k, v| "$#{k}:#{v}" }.join(",")
124
- doc << "(#{variable_str})"
125
- end
126
-
127
- doc << "{ #{query_fields.join(" ")} }"
128
-
129
- return doc, variable_defs.keys
130
- end
131
-
132
- def merge_results!(origin_sets_by_operation, raw_result)
133
- return unless raw_result
134
-
135
- origin_sets_by_operation.each_with_index do |(op, origin_set), batch_index|
136
- results = if op.dig("boundary", "list")
137
- raw_result["_#{batch_index}_result"]
138
- else
139
- origin_set.map.with_index { |_, index| raw_result["_#{batch_index}_#{index}_result"] }
140
- end
141
-
142
- next unless results&.any?
143
-
144
- origin_set.each_with_index do |origin_obj, index|
145
- origin_obj.merge!(results[index]) if results[index]
146
- end
147
- end
148
- end
149
-
150
- # https://spec.graphql.org/June2018/#sec-Errors
151
- def extract_errors!(origin_sets_by_operation, errors)
152
- ops = origin_sets_by_operation.keys
153
- origin_sets = origin_sets_by_operation.values
154
- pathed_errors_by_op_index_and_object_id = {}
155
-
156
- errors_result = errors.each_with_object([]) do |err, memo|
157
- err.delete("locations")
158
- path = err["path"]
159
-
160
- if path && path.length > 0
161
- result_alias = /^_(\d+)(?:_(\d+))?_result$/.match(path.first.to_s)
162
-
163
- if result_alias
164
- path = err["path"] = path[1..-1]
165
-
166
- origin_obj = if result_alias[2]
167
- origin_sets.dig(result_alias[1].to_i, result_alias[2].to_i)
168
- elsif path[0].is_a?(Integer) || /\d+/.match?(path[0].to_s)
169
- origin_sets.dig(result_alias[1].to_i, path.shift.to_i)
170
- end
171
-
172
- if origin_obj
173
- by_op_index = pathed_errors_by_op_index_and_object_id[result_alias[1].to_i] ||= {}
174
- by_object_id = by_op_index[origin_obj.object_id] ||= []
175
- by_object_id << err
176
- next
177
- end
178
- end
179
- end
180
-
181
- memo << err
182
- end
183
-
184
- if pathed_errors_by_op_index_and_object_id.any?
185
- pathed_errors_by_op_index_and_object_id.each do |op_index, pathed_errors_by_object_id|
186
- repath_errors!(pathed_errors_by_object_id, ops.dig(op_index, "path"))
187
- errors_result.concat(pathed_errors_by_object_id.values)
188
- end
189
- end
190
- errors_result.flatten!
191
- end
192
-
193
- private
194
-
195
- # traverse forward through origin data, expanding arrays to follow all paths
196
- # any errors found for an origin object_id have their path prefixed by the object path
197
- def repath_errors!(pathed_errors_by_object_id, forward_path, current_path=[], root=@executor.data)
198
- current_path.push(forward_path.shift)
199
- scope = root[current_path.last]
200
-
201
- if forward_path.any? && scope.is_a?(Array)
202
- scope.each_with_index do |element, index|
203
- inner_elements = element.is_a?(Array) ? element.flatten : [element]
204
- inner_elements.each do |inner_element|
205
- current_path << index
206
- repath_errors!(pathed_errors_by_object_id, forward_path, current_path, inner_element)
207
- current_path.pop
208
- end
209
- end
210
-
211
- elsif forward_path.any?
212
- current_path << index
213
- repath_errors!(pathed_errors_by_object_id, forward_path, current_path, scope)
214
- current_path.pop
215
-
216
- elsif scope.is_a?(Array)
217
- scope.each_with_index do |element, index|
218
- inner_elements = element.is_a?(Array) ? element.flatten : [element]
219
- inner_elements.each do |inner_element|
220
- errors = pathed_errors_by_object_id[inner_element.object_id]
221
- errors.each { _1["path"] = [*current_path, index, *_1["path"]] } if errors
222
- end
223
- end
224
-
225
- else
226
- errors = pathed_errors_by_object_id[scope.object_id]
227
- errors.each { _1["path"] = [*current_path, *_1["path"]] } if errors
228
- end
229
-
230
- forward_path.unshift(current_path.pop)
231
- end
232
- end
233
-
234
8
  attr_reader :supergraph, :request, :data, :errors
235
9
  attr_accessor :query_count
236
10
 
@@ -297,3 +71,6 @@ module GraphQL
297
71
  end
298
72
  end
299
73
  end
74
+
75
+ require_relative "./executor/boundary_source"
76
+ require_relative "./executor/root_source"
@@ -6,7 +6,7 @@ require "json"
6
6
 
7
7
  module GraphQL
8
8
  module Stitching
9
- class RemoteClient
9
+ class HttpExecutable
10
10
  def initialize(url:, headers:{})
11
11
  @url = url
12
12
  @headers = { "Content-Type" => "application/json" }.merge!(headers)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "0.3.6"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end
@@ -5,6 +5,7 @@ require "graphql"
5
5
  module GraphQL
6
6
  module Stitching
7
7
  EMPTY_OBJECT = {}.freeze
8
+ EMPTY_ARRAY = [].freeze
8
9
 
9
10
  class StitchingError < StandardError; end
10
11
 
@@ -23,13 +24,13 @@ module GraphQL
23
24
  end
24
25
  end
25
26
 
26
- require_relative "stitching/gateway"
27
27
  require_relative "stitching/supergraph"
28
+ require_relative "stitching/client"
28
29
  require_relative "stitching/composer"
29
30
  require_relative "stitching/executor"
31
+ require_relative "stitching/http_executable"
30
32
  require_relative "stitching/planner_operation"
31
33
  require_relative "stitching/planner"
32
- require_relative "stitching/remote_client"
33
34
  require_relative "stitching/request"
34
35
  require_relative "stitching/shaper"
35
36
  require_relative "stitching/util"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-stitching
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.6
4
+ version: 1.0.0
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-04-11 00:00:00.000000000 Z
11
+ date: 2023-08-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -80,9 +80,9 @@ files:
80
80
  - README.md
81
81
  - Rakefile
82
82
  - docs/README.md
83
+ - docs/client.md
83
84
  - docs/composer.md
84
85
  - docs/executor.md
85
- - docs/gateway.md
86
86
  - docs/images/library.png
87
87
  - docs/images/merging.png
88
88
  - docs/images/stitching.png
@@ -97,15 +97,17 @@ files:
97
97
  - gemfiles/graphql_1.13.9.gemfile
98
98
  - graphql-stitching.gemspec
99
99
  - lib/graphql/stitching.rb
100
+ - lib/graphql/stitching/client.rb
100
101
  - lib/graphql/stitching/composer.rb
101
102
  - lib/graphql/stitching/composer/base_validator.rb
102
103
  - lib/graphql/stitching/composer/validate_boundaries.rb
103
104
  - lib/graphql/stitching/composer/validate_interfaces.rb
104
105
  - lib/graphql/stitching/executor.rb
105
- - lib/graphql/stitching/gateway.rb
106
+ - lib/graphql/stitching/executor/boundary_source.rb
107
+ - lib/graphql/stitching/executor/root_source.rb
108
+ - lib/graphql/stitching/http_executable.rb
106
109
  - lib/graphql/stitching/planner.rb
107
110
  - lib/graphql/stitching/planner_operation.rb
108
- - lib/graphql/stitching/remote_client.rb
109
111
  - lib/graphql/stitching/request.rb
110
112
  - lib/graphql/stitching/shaper.rb
111
113
  - lib/graphql/stitching/supergraph.rb
data/docs/gateway.md DELETED
@@ -1,103 +0,0 @@
1
- ## GraphQL::Stitching::Gateway
2
-
3
- The `Gateway` is an out-of-the-box convenience with all stitching components assembled into a default workflow. A gateway is designed to work for most common needs, though you're welcome to assemble the component parts into your own configuration. A Gateway is constructed with the same [location settings](./composer.md#performing-composition) used to perform supergraph composition:
4
-
5
- ```ruby
6
- movies_schema = "type Query { ..."
7
- showtimes_schema = "type Query { ..."
8
-
9
- gateway = GraphQL::Stitching::Gateway.new(locations: {
10
- products: {
11
- schema: GraphQL::Schema.from_definition(movies_schema),
12
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3000"),
13
- stitch: [{ field_name: "products", key: "id" }],
14
- },
15
- showtimes: {
16
- schema: GraphQL::Schema.from_definition(showtimes_schema),
17
- executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001"),
18
- },
19
- my_local: {
20
- schema: MyLocal::GraphQL::Schema,
21
- },
22
- })
23
- ```
24
-
25
- Alternatively, you may pass a prebuilt `Supergraph` instance to the Gateway constructor. This is useful when [exporting and rehydrating](./supergraph.md#export-and-caching) supergraph instances, which bypasses the need for runtime composition:
26
-
27
- ```ruby
28
- exported_schema = "type Query { ..."
29
- exported_mapping = JSON.parse("{ ... }")
30
- supergraph = GraphQL::Stitching::Supergraph.from_export(
31
- schema: exported_schema,
32
- delegation_map: exported_mapping,
33
- executables: { ... },
34
- )
35
-
36
- gateway = GraphQL::Stitching::Gateway.new(supergraph: supergraph)
37
- ```
38
-
39
- ### Execution
40
-
41
- A gateway provides an `execute` method with a subset of arguments provided by [`GraphQL::Schema.execute`](https://graphql-ruby.org/queries/executing_queries). Executing requests on a stitched gateway becomes mostly a drop-in replacement to executing on a `GraphQL::Schema` instance:
42
-
43
- ```ruby
44
- result = gateway.execute(
45
- query: "query MyProduct($id: ID!) { product(id: $id) { name } }",
46
- variables: { "id" => "1" },
47
- operation_name: "MyProduct",
48
- )
49
- ```
50
-
51
- Arguments for the `execute` method include:
52
-
53
- * `query`: a query (or mutation) as a string or parsed AST.
54
- * `variables`: a hash of variables for the request.
55
- * `operation_name`: the name of the operation to execute (when multiple are provided).
56
- * `validate`: true if static validation should run on the supergraph schema before execution.
57
- * `context`: an object passed through to executable calls and gateway hooks.
58
-
59
- ### Cache hooks
60
-
61
- The gateway provides cache hooks to enable caching query plans across requests. Without caching, every request made the the gateway will be planned individually. With caching, a query may be planned once, cached, and then executed from cache for subsequent requests. Cache keys are a normalized digest of each query string.
62
-
63
- ```ruby
64
- gateway.on_cache_read do |key, _context|
65
- $redis.get(key) # << 3P code
66
- end
67
-
68
- gateway.on_cache_write do |key, payload, _context|
69
- $redis.set(key, payload) # << 3P code
70
- end
71
- ```
72
-
73
- Note that inlined input data works against caching, so you should _avoid_ this:
74
-
75
- ```graphql
76
- query {
77
- product(id: "1") { name }
78
- }
79
- ```
80
-
81
- Instead, always leverage variables in queries so that the document body remains consistent across requests:
82
-
83
- ```graphql
84
- query($id: ID!) {
85
- product(id: $id) { name }
86
- }
87
-
88
- # variables: { "id" => "1" }
89
- ```
90
-
91
- ### Error hooks
92
-
93
- The gateway also provides an error hook. Any program errors rescued during execution will be passed to the `on_error` handler, which can report on the error as needed and return a formatted error message for the gateway to add to the [GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) result.
94
-
95
- ```ruby
96
- gateway.on_error do |err, context|
97
- # log the error
98
- Bugsnag.notify(err)
99
-
100
- # return a formatted message for the public response
101
- "Whoops, please contact support abount request '#{context[:request_id]}'"
102
- end
103
- ```