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 +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
|

|
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
|
-
```
|