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 +4 -4
- data/README.md +77 -12
- data/docs/README.md +1 -1
- data/docs/client.md +103 -0
- data/docs/composer.md +2 -2
- data/docs/supergraph.md +1 -1
- data/example/gateway.rb +4 -4
- data/lib/graphql/stitching/{gateway.rb → client.rb} +7 -7
- data/lib/graphql/stitching/composer.rb +39 -19
- data/lib/graphql/stitching/executor/boundary_source.rb +199 -0
- data/lib/graphql/stitching/executor/root_source.rb +48 -0
- data/lib/graphql/stitching/executor.rb +3 -226
- data/lib/graphql/stitching/{remote_client.rb → http_executable.rb} +1 -1
- data/lib/graphql/stitching/version.rb +1 -1
- data/lib/graphql/stitching.rb +3 -2
- metadata +7 -5
- data/docs/gateway.md +0 -103
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9ff6bbdb8e949da02a0ed58f6db5ab56a6e622d9befc436042dcaaed6556a0f0
|
4
|
+
data.tar.gz: ecb682677bb576a3742e1a0ebf8eabe4d17e2dfbc214120aed4cbef09db881df
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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 [`
|
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
|
-
|
49
|
+
client = GraphQL::Stitching::Client.new(locations: {
|
49
50
|
movies: {
|
50
51
|
schema: GraphQL::Schema.from_definition(movies_schema),
|
51
|
-
executable: GraphQL::Stitching::
|
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::
|
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 =
|
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 `
|
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
|
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
|
-
|
129
|
+
client = GraphQL::Stitching::Client.new(locations: {
|
125
130
|
products: {
|
126
131
|
schema: GraphQL::Schema.from_definition(products_schema),
|
127
|
-
executable: GraphQL::Stitching::
|
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::
|
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::
|
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::
|
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
|
-
- [
|
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::
|
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::
|
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::
|
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
|
-
@
|
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::
|
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::
|
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 = @
|
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
|
8
|
-
class
|
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
|
14
|
+
raise ClientError, "Cannot provide both locations and a supergraph."
|
15
15
|
elsif supergraph && !supergraph.is_a?(GraphQL::Stitching::Supergraph)
|
16
|
-
raise
|
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
|
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
|
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
|
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
|
-
|
149
|
-
|
150
|
-
|
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
|
-
|
157
|
-
|
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
|
-
|
161
|
-
|
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
|
-
|
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
|
-
|
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[
|
493
|
-
@boundary_map[
|
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
|
-
"
|
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"
|
data/lib/graphql/stitching.rb
CHANGED
@@ -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.
|
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-
|
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/
|
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
|
-
```
|