graphql-stitching 0.3.6 → 1.0.1

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: 4ce8bb1075d536f01aa63487df3e1686bc0c2899b58ca14e95383caef38812e2
4
+ data.tar.gz: ae39e685bdb31cbf3962216cfce263b2970aa2aeac6be1901a7397cee6eb1656
5
5
  SHA512:
6
- metadata.gz: dc2a8d99942c2a5e1558ded0fbb467dd96d4bdb30c5a2ff9aa2348161ae28073cf36b2b56e51b0d92b757f68a87366ab1750e716bf0939d1645bf1ddee2049f5
7
- data.tar.gz: 599760134f3f59f6ec5285e0d0976e1e9c07ba4103a74341bb35d1dacb89d0026383da2f4c2ca47d33ce3971510d9dddcc61d90a5248342406a4787f650d6126
6
+ metadata.gz: e718527102969773accf28b2323cbde1d2c467339c44bb876144ae5387a7aaec64143b35b6dfff21401fb087c64ed8742f79cda78e743518ba045871c320f51f
7
+ data.tar.gz: 74bc97f475b61ea51dd8ce9e42a1ff2783d0c1717a38ff812e256962d64b97ef0ccc092de11e529a22e274d8ad5cf77af9fd7ed2fd680e20e0c058534942a5d6
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 arbitrary queries or 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"],
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Stitching
5
+ Boundary = Struct.new(
6
+ :location,
7
+ :type_name,
8
+ :key,
9
+ :field,
10
+ :arg,
11
+ :list,
12
+ :federation,
13
+ keyword_init: true
14
+ ) do
15
+ def as_json
16
+ {
17
+ location: location,
18
+ type_name: type_name,
19
+ key: key,
20
+ field: field,
21
+ arg: arg,
22
+ list: list,
23
+ federation: federation,
24
+ }.tap(&:compact!)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -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
@@ -41,7 +41,7 @@ module GraphQL
41
41
  GraphQL::Stitching::Planner.new(
42
42
  supergraph: @supergraph,
43
43
  request: request,
44
- ).perform.to_h
44
+ ).perform
45
45
  end
46
46
 
47
47
  GraphQL::Stitching::Executor.new(
@@ -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
 
@@ -76,16 +76,16 @@ module GraphQL
76
76
  def fetch_plan(request)
77
77
  if @on_cache_read
78
78
  cached_plan = @on_cache_read.call(request.digest, request.context)
79
- return JSON.parse(cached_plan) if cached_plan
79
+ return GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan)) if cached_plan
80
80
  end
81
81
 
82
- plan_json = yield
82
+ plan = yield
83
83
 
84
84
  if @on_cache_write
85
- @on_cache_write.call(request.digest, JSON.generate(plan_json), request.context)
85
+ @on_cache_write.call(request.digest, JSON.generate(plan.as_json), request.context)
86
86
  end
87
87
 
88
- plan_json
88
+ plan
89
89
  end
90
90
 
91
91
  def error_result(errors)
@@ -32,16 +32,16 @@ module GraphQL
32
32
 
33
33
  # only one boundary allowed per type/location/key
34
34
  boundaries_by_location_and_key = boundaries.each_with_object({}) do |boundary, memo|
35
- if memo.dig(boundary["location"], boundary["key"])
36
- raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["key"]}` "\
37
- "found in #{boundary["location"]}. Limit one boundary query per type and key in each location. "\
35
+ if memo.dig(boundary.location, boundary.key)
36
+ raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary.key}` "\
37
+ "found in #{boundary.location}. Limit one boundary query per type and key in each location. "\
38
38
  "Abstract boundaries provide all possible types."
39
39
  end
40
- memo[boundary["location"]] ||= {}
41
- memo[boundary["location"]][boundary["key"]] = boundary
40
+ memo[boundary.location] ||= {}
41
+ memo[boundary.location][boundary.key] = boundary
42
42
  end
43
43
 
44
- boundary_keys = boundaries.map { _1["key"] }.uniq
44
+ boundary_keys = boundaries.map { _1.key }.uniq
45
45
  key_only_types_by_location = candidate_types_by_location.select do |location, subschema_type|
46
46
  subschema_type.fields.keys.length == 1 && boundary_keys.include?(subschema_type.fields.keys.first)
47
47
  end
@@ -32,12 +32,12 @@ module GraphQL
32
32
  interface_type_structure.each_with_index do |interface_struct, index|
33
33
  possible_struct = possible_type_structure[index]
34
34
 
35
- if possible_struct[:name] != interface_struct[:name]
35
+ if possible_struct.name != interface_struct.name
36
36
  raise Composer::ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\
37
37
  "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
38
38
  end
39
39
 
40
- if possible_struct[:null] && !interface_struct[:null]
40
+ if possible_struct.null? && interface_struct.non_null?
41
41
  raise Composer::ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\
42
42
  "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
43
43
  end
@@ -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
 
@@ -416,21 +430,21 @@ module GraphQL
416
430
  raise ComposerError, "Cannot compose mixed list structures at `#{path}`."
417
431
  end
418
432
 
419
- if alt_structure.last[:name] != basis_structure.last[:name]
433
+ if alt_structure.last.name != basis_structure.last.name
420
434
  raise ComposerError, "Cannot compose mixed types at `#{path}`."
421
435
  end
422
436
  end
423
437
 
424
438
  type = GraphQL::Schema::BUILT_IN_TYPES.fetch(
425
- basis_structure.last[:name],
426
- build_type_binding(basis_structure.last[:name])
439
+ basis_structure.last.name,
440
+ build_type_binding(basis_structure.last.name)
427
441
  )
428
442
 
429
443
  basis_structure.reverse!.each_with_index do |basis, index|
430
444
  rev_index = basis_structure.length - index - 1
431
- non_null = alt_structures.each_with_object([!basis[:null]]) { |s, m| m << !s[rev_index][:null] }
445
+ non_null = alt_structures.each_with_object([basis.non_null?]) { |s, m| m << s[rev_index].non_null? }
432
446
 
433
- type = type.to_list_type if basis[:list]
447
+ type = type.to_list_type if basis.list?
434
448
  type = type.to_non_null_type if argument_name ? non_null.any? : non_null.all?
435
449
  end
436
450
 
@@ -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] << {
494
- "location" => location,
495
- "key" => key_selections[0].name,
496
- "field" => field_candidate.name,
497
- "arg" => argument_name,
498
- "list" => boundary_structure.first[:list],
499
- "type_name" => boundary_type_name,
500
- }
511
+ @boundary_map[impl_type_name] ||= []
512
+ @boundary_map[impl_type_name] << Boundary.new(
513
+ location: location,
514
+ type_name: impl_type_name,
515
+ key: key_selections[0].name,
516
+ field: field_candidate.name,
517
+ arg: argument_name,
518
+ list: boundary_structure.first.list?,
519
+ federation: kwargs[:federation],
520
+ )
501
521
  end
502
522
  end
503
523
  end