graphql-stitching 0.3.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
- ```