graphql-stitching 0.2.3 → 0.3.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -0
- data/README.md +47 -31
- data/docs/composer.md +81 -73
- data/docs/executor.md +16 -0
- data/docs/gateway.md +11 -14
- data/docs/supergraph.md +9 -39
- data/gemfiles/graphql_1.13.9.gemfile +6 -0
- data/lib/graphql/stitching/composer.rb +57 -11
- data/lib/graphql/stitching/gateway.rb +4 -27
- data/lib/graphql/stitching/planner.rb +10 -10
- data/lib/graphql/stitching/shaper.rb +8 -2
- data/lib/graphql/stitching/supergraph.rb +44 -27
- data/lib/graphql/stitching/version.rb +1 -1
- data/lib/graphql/stitching.rb +0 -50
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 10489fd6a8670d5a23a7afa132d7941a242848783f3697a4d3ddd519b208a4d8
|
4
|
+
data.tar.gz: 7a2e8dda124bdc6e96da43e1a4ed64bbb4baeaf0065f6d8411edaeba2c52e064
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4c9243880e3b41fcede7fddb5947a962f1d4c43882ba07cc0ab63d1ba154527ef4cc8e5cc130bb2524b40fcbe093ecfd2f3b8fb0bafc9a2a7324050c30d2af00
|
7
|
+
data.tar.gz: a2b37c3ab8b98065a0910a458e177b71576c5d8f52c6f6eba0e31d25ae7797f1517a168aeef2f39c7295d27e4f981373b7a90066dc7ab8651670ecbd41fc70d5
|
data/.github/workflows/ci.yml
CHANGED
data/README.md
CHANGED
@@ -70,12 +70,12 @@ result = gateway.execute(
|
|
70
70
|
)
|
71
71
|
```
|
72
72
|
|
73
|
-
Schemas provided to the `Gateway` constructor 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) for more information on how schemas get merged.
|
73
|
+
Schemas provided to the `Gateway` constructor 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
74
|
|
75
75
|
While the [`Gateway`](./docs/gateway.md) constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
|
76
76
|
|
77
|
-
- [Composer](./docs/composer.md) - merges and validates many schemas into one
|
78
|
-
- [Supergraph](./docs/supergraph.md) - manages the combined schema
|
77
|
+
- [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
|
78
|
+
- [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
|
79
79
|
- [Request](./docs/request.md) - prepares a requested GraphQL document and variables for stitching.
|
80
80
|
- [Planner](./docs/planner.md) - builds a cacheable query plan for a request document.
|
81
81
|
- [Executor](./docs/executor.md) - executes a query plan with given request variables.
|
@@ -121,17 +121,16 @@ shipping_schema = <<~GRAPHQL
|
|
121
121
|
}
|
122
122
|
GRAPHQL
|
123
123
|
|
124
|
-
supergraph = GraphQL::Stitching::Composer.new(
|
125
|
-
|
126
|
-
|
124
|
+
supergraph = GraphQL::Stitching::Composer.new.perform({
|
125
|
+
products: {
|
126
|
+
schema: GraphQL::Schema.from_definition(products_schema),
|
127
|
+
executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001"),
|
128
|
+
},
|
129
|
+
shipping: {
|
130
|
+
schema: GraphQL::Schema.from_definition(shipping_schema),
|
131
|
+
executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3002"),
|
132
|
+
},
|
127
133
|
})
|
128
|
-
|
129
|
-
supergraph.assign_executable("products",
|
130
|
-
GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001")
|
131
|
-
)
|
132
|
-
supergraph.assign_executable("shipping",
|
133
|
-
GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3002")
|
134
|
-
)
|
135
134
|
```
|
136
135
|
|
137
136
|
Focusing on the `@stitch` directive usage:
|
@@ -255,7 +254,7 @@ The `@stitch` directive can be exported from a class-based schema to an SDL stri
|
|
255
254
|
|
256
255
|
#### SDL-based schemas
|
257
256
|
|
258
|
-
A clean SDL string may also have stitching directives applied via static configuration
|
257
|
+
A clean SDL string may also have stitching directives applied via static configuration by passing a `stitch` array in [location settings](./docs/composer.md#performing-composition):
|
259
258
|
|
260
259
|
```ruby
|
261
260
|
sdl_string = <<~GRAPHQL
|
@@ -269,13 +268,15 @@ sdl_string = <<~GRAPHQL
|
|
269
268
|
}
|
270
269
|
GRAPHQL
|
271
270
|
|
272
|
-
|
273
|
-
{
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
271
|
+
supergraph = GraphQL::Stitching::Composer.new.perform({
|
272
|
+
products: {
|
273
|
+
schema: GraphQL::Schema.from_definition(sdl_string),
|
274
|
+
executable: ->() { ... },
|
275
|
+
stitch: [
|
276
|
+
{ field_name: "productById", key: "id" },
|
277
|
+
{ field_name: "productByUpc", key: "upc" },
|
278
|
+
]
|
279
|
+
},
|
279
280
|
# ...
|
280
281
|
})
|
281
282
|
```
|
@@ -290,26 +291,41 @@ GraphQL::Stitching.stitch_directive = "merge"
|
|
290
291
|
|
291
292
|
## Executables
|
292
293
|
|
293
|
-
An executable resource performs location-specific GraphQL requests. Executables may be `GraphQL::Schema` classes, or
|
294
|
+
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:
|
294
295
|
|
295
296
|
```ruby
|
296
297
|
class MyExecutable
|
297
|
-
def call(location,
|
298
|
+
def call(location, source, variables, context)
|
298
299
|
# process a GraphQL request...
|
300
|
+
return {
|
301
|
+
"data" => { ... },
|
302
|
+
"errors" => [ ... ],
|
303
|
+
}
|
299
304
|
end
|
300
305
|
end
|
301
306
|
```
|
302
307
|
|
303
|
-
|
308
|
+
A [Supergraph](./docs/supergraph.md) is composed with executable resources provided for each location. Any location that omits the `executable` option will use the provided `schema` as its default executable:
|
304
309
|
|
305
310
|
```ruby
|
306
|
-
supergraph = GraphQL::Stitching::Composer.new(
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
311
|
+
supergraph = GraphQL::Stitching::Composer.new.perform({
|
312
|
+
first: {
|
313
|
+
schema: FirstSchema,
|
314
|
+
# executable:^^^^^^ delegates to FirstSchema,
|
315
|
+
},
|
316
|
+
second: {
|
317
|
+
schema: SecondSchema,
|
318
|
+
executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001", headers: { ... }),
|
319
|
+
},
|
320
|
+
third: {
|
321
|
+
schema: ThirdSchema,
|
322
|
+
executable: MyExecutable.new,
|
323
|
+
},
|
324
|
+
fourth: {
|
325
|
+
schema: FourthSchema,
|
326
|
+
executable: ->(loc, query, vars, ctx) { ... },
|
327
|
+
},
|
328
|
+
})
|
313
329
|
```
|
314
330
|
|
315
331
|
The `GraphQL::Stitching::RemoteClient` class is provided as a simple executable wrapper around `Net::HTTP.post`. You should build your own executables to leverage your existing libraries and to add instrumentation. Note that you must manually assign all executables to a `Supergraph` when rehydrating it from cache ([see docs](./docs/supergraph.md)).
|
data/docs/composer.md
CHANGED
@@ -1,47 +1,92 @@
|
|
1
1
|
## GraphQL::Stitching::Composer
|
2
2
|
|
3
|
-
|
3
|
+
A `Composer` receives many individual `GraphQL:Schema` instances for various graph locations and _composes_ them into a combined [`Supergraph`](./supergraph.md) that is validated for integrity.
|
4
|
+
|
5
|
+
### Configuring composition
|
6
|
+
|
7
|
+
A `Composer` may be constructed with optional settings that tune how it builds a schema:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
composer = GraphQL::Stitching::Composer.new(
|
11
|
+
query_name: "Query",
|
12
|
+
mutation_name: "Mutation",
|
13
|
+
description_merger: ->(values_by_location, info) { values_by_location.values.join("\n") },
|
14
|
+
deprecation_merger: ->(values_by_location, info) { values_by_location.values.first },
|
15
|
+
directive_kwarg_merger: ->(values_by_location, info) { values_by_location.values.last },
|
16
|
+
)
|
17
|
+
```
|
18
|
+
|
19
|
+
Constructor arguments:
|
20
|
+
|
21
|
+
- **`query_name:`** _optional_, the name of the root query type in the composed schema; `Query` by default. The root query types from all location schemas will be merged into this type, regardless of their local names.
|
22
|
+
|
23
|
+
- **`mutation_name:`** _optional_, the name of the root mutation type in the composed schema; `Mutation` by default. The root mutation types from all location schemas will be merged into this type, regardless of their local names.
|
24
|
+
|
25
|
+
- **`description_merger:`** _optional_, a [value merger function](#value-merger-functions) for merging element description strings from across locations.
|
26
|
+
|
27
|
+
- **`deprecation_merger:`** _optional_, a [value merger function](#value-merger-functions) for merging element deprecation strings from across locations.
|
28
|
+
|
29
|
+
- **`directive_kwarg_merger:`** _optional_, a [value merger function](#value-merger-functions) for merging directive keyword arguments from across locations.
|
30
|
+
|
31
|
+
#### Value merger functions
|
32
|
+
|
33
|
+
Static data values such as element descriptions and directive arguments must also merge across locations. By default, the first non-null value encountered for a given element attribute is used. A value merger function may customize this process by selecting a different value or computing a new one:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
supergraph = GraphQL::Stitching::Composer.new(
|
37
|
+
description_merger: ->(values_by_location, info) { values_by_location.values.compact.join("\n") },
|
38
|
+
)
|
39
|
+
```
|
40
|
+
|
41
|
+
A merger function receives `values_by_location` and `info` arguments; these provide possible values keyed by location and info about where in the schema these values were encountered:
|
42
|
+
|
43
|
+
```ruby
|
44
|
+
values_by_location = {
|
45
|
+
"storefronts" => "A fabulous data type.",
|
46
|
+
"products" => "An excellent data type.",
|
47
|
+
}
|
48
|
+
|
49
|
+
info = {
|
50
|
+
type_name: "Product",
|
51
|
+
# field_name: ...,
|
52
|
+
# argument_name: ...,
|
53
|
+
# directive_name: ...,
|
54
|
+
}
|
55
|
+
```
|
56
|
+
|
57
|
+
### Performing composition
|
58
|
+
|
59
|
+
Construct a `Composer` and call its `perform` method with location settings to compose a supergraph:
|
4
60
|
|
5
61
|
```ruby
|
6
|
-
storefronts_sdl =
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
directive @stitch(key: String!) repeatable on FIELD_DEFINITION
|
24
|
-
|
25
|
-
type Product {
|
26
|
-
id:ID!
|
27
|
-
name: String
|
28
|
-
price: Int
|
29
|
-
}
|
30
|
-
|
31
|
-
type Query {
|
32
|
-
product(id: ID!): Product @stitch(key: "id")
|
33
|
-
}
|
34
|
-
GRAPHQL
|
35
|
-
|
36
|
-
supergraph = GraphQL::Stitching::Composer.new(schemas: {
|
37
|
-
"storefronts" => GraphQL::Schema.from_definition(storefronts_sdl),
|
38
|
-
"products" => GraphQL::Schema.from_definition(products_sdl),
|
39
|
-
}).perform
|
62
|
+
storefronts_sdl = "type Query { ..."
|
63
|
+
products_sdl = "type Query { ..."
|
64
|
+
|
65
|
+
supergraph = GraphQL::Stitching::Composer.new.perform({
|
66
|
+
storefronts: {
|
67
|
+
schema: GraphQL::Schema.from_definition(storefronts_sdl),
|
68
|
+
executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001"),
|
69
|
+
stitch: [{ field_name: "storefront", key: "id" }],
|
70
|
+
},
|
71
|
+
products: {
|
72
|
+
schema: GraphQL::Schema.from_definition(products_sdl),
|
73
|
+
executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3002"),
|
74
|
+
},
|
75
|
+
my_local: {
|
76
|
+
schema: MyLocalSchema,
|
77
|
+
},
|
78
|
+
})
|
40
79
|
|
41
80
|
combined_schema = supergraph.schema
|
42
81
|
```
|
43
82
|
|
44
|
-
|
83
|
+
Location settings have top-level keys that specify arbitrary location names, each of which provide:
|
84
|
+
|
85
|
+
- **`schema:`** _required_, provides a `GraphQL::Schema` class for the location. This may be a class-based schema that inherits from `GraphQL::Schema`, or built from SDL (Schema Definition Language) string using `GraphQL::Schema.from_definition` and mapped to a remote location. The provided schema is only used for type reference and does not require any real data resolvers (unless it is also used as the location's executable, see below).
|
86
|
+
|
87
|
+
- **`executable:`** _optional_, provides an executable resource to be called when delegating a request to this location. Executables are `GraphQL::Schema` classes or any object with a `.call(location, source, variables, context)` method that returns a GraphQL response. Omitting the executable option will use the location's provided `schema` as the executable resource.
|
88
|
+
|
89
|
+
- **`stitch:`** _optional_, an array of configs used to dynamically apply `@stitch` directives to select root fields prior to composing. This is useful when you can't easily render stitching directives into a location's source schema.
|
45
90
|
|
46
91
|
### Merge patterns
|
47
92
|
|
@@ -71,40 +116,3 @@ The strategy used to merge source schemas into the combined schema is based on e
|
|
71
116
|
- Stitching directives (both definitions and assignments) are omitted.
|
72
117
|
|
73
118
|
Note that the structure of a composed schema may change based on new schema additions and/or element usage (ie: changing input object arguments in one service may cause the intersection of arguments to change). Therefore, it's highly recommended that you use a [schema comparator](https://github.com/xuorig/graphql-schema_comparator) to flag regressions across composed schema versions.
|
74
|
-
|
75
|
-
### Value merger functions
|
76
|
-
|
77
|
-
The composer has no way of intelligently merging static data values that are embedded into a schema. These include:
|
78
|
-
|
79
|
-
- Element descriptions
|
80
|
-
- Element deprecations
|
81
|
-
- Directive keyword argument values
|
82
|
-
|
83
|
-
By default, the first non-null value encountered across locations is used to fill these data slots. You may customize this aggregation process by providing value merger functions:
|
84
|
-
|
85
|
-
|
86
|
-
```ruby
|
87
|
-
supergraph = GraphQL::Stitching::Composer.new(
|
88
|
-
schemas: { ... },
|
89
|
-
description_merger: ->(values_by_location, info) { values_by_location.values.last },
|
90
|
-
deprecation_merger: ->(values_by_location, info) { values_by_location.values.last },
|
91
|
-
directive_kwarg_merger: ->(values_by_location, info) { values_by_location.values.last },
|
92
|
-
).perform
|
93
|
-
```
|
94
|
-
|
95
|
-
Each merger accepts a `values_by_location` and an `info` argument; these provide the values found across locations and info about where in schema they were encountered:
|
96
|
-
|
97
|
-
```ruby
|
98
|
-
values_by_location = {
|
99
|
-
"storefronts" => "A fabulous data type.",
|
100
|
-
"products" => "An excellent data type.",
|
101
|
-
}
|
102
|
-
|
103
|
-
info = {
|
104
|
-
type_name: "Product",
|
105
|
-
# field_name: ...,
|
106
|
-
# argument_name: ...,
|
107
|
-
}
|
108
|
-
```
|
109
|
-
|
110
|
-
The function should then select a value (or compute a new one) and return that for use in the combined schema.
|
data/docs/executor.md
CHANGED
@@ -43,6 +43,22 @@ raw_result = GraphQL::Stitching::Executor.new(
|
|
43
43
|
).perform(raw: true)
|
44
44
|
```
|
45
45
|
|
46
|
+
The raw result will contain many irregularities from the stitching process, however may be insightful when debugging inconsistencies in results:
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
{
|
50
|
+
"data" => {
|
51
|
+
"product" => {
|
52
|
+
"upc" => "1",
|
53
|
+
"_STITCH_upc" => "1",
|
54
|
+
"_STITCH_typename" => "Product",
|
55
|
+
"name" => "iPhone",
|
56
|
+
"price" => nil,
|
57
|
+
}
|
58
|
+
}
|
59
|
+
}
|
60
|
+
```
|
61
|
+
|
46
62
|
### Batching
|
47
63
|
|
48
64
|
The Executor batches together as many requests as possible to a given location at a given time. Batched queries are written with the operation name suffixed by all operation keys in the batch, and root stitching fields are each prefixed by their batch index and collection index (for non-list fields):
|
data/docs/gateway.md
CHANGED
@@ -1,10 +1,6 @@
|
|
1
1
|
## GraphQL::Stitching::Gateway
|
2
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.
|
4
|
-
|
5
|
-
### Building
|
6
|
-
|
7
|
-
The Gateway constructor accepts configuration to build a [`Supergraph`](./supergraph.md) for you. Location names are root keys, and each location config provides a `schema` and an optional [executable](../README.md#executables).
|
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:
|
8
4
|
|
9
5
|
```ruby
|
10
6
|
movies_schema = "type Query { ..."
|
@@ -14,6 +10,7 @@ gateway = GraphQL::Stitching::Gateway.new(locations: {
|
|
14
10
|
products: {
|
15
11
|
schema: GraphQL::Schema.from_definition(movies_schema),
|
16
12
|
executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3000"),
|
13
|
+
stitch: [{ field_name: "products", key: "id" }],
|
17
14
|
},
|
18
15
|
showtimes: {
|
19
16
|
schema: GraphQL::Schema.from_definition(showtimes_schema),
|
@@ -25,23 +22,23 @@ gateway = GraphQL::Stitching::Gateway.new(locations: {
|
|
25
22
|
})
|
26
23
|
```
|
27
24
|
|
28
|
-
|
29
|
-
|
30
|
-
#### From exported supergraph
|
31
|
-
|
32
|
-
It's possible to [export and rehydrate](./supergraph.md#export-and-caching) `Supergraph` instances, allowing a supergraph to be cached as static artifacts and then rehydrated quickly at runtime without going through composition. To setup a gateway with a prebuilt supergraph, you may pass it as a `supergraph` argument:
|
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:
|
33
26
|
|
34
27
|
```ruby
|
35
|
-
exported_schema = "..."
|
28
|
+
exported_schema = "type Query { ..."
|
36
29
|
exported_mapping = JSON.parse("{ ... }")
|
37
|
-
supergraph = GraphQL::Stitching::Supergraph.from_export(
|
30
|
+
supergraph = GraphQL::Stitching::Supergraph.from_export(
|
31
|
+
schema: exported_schema,
|
32
|
+
delegation_map: exported_mapping,
|
33
|
+
executables: { ... },
|
34
|
+
)
|
38
35
|
|
39
36
|
gateway = GraphQL::Stitching::Gateway.new(supergraph: supergraph)
|
40
37
|
```
|
41
38
|
|
42
39
|
### Execution
|
43
40
|
|
44
|
-
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
|
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:
|
45
42
|
|
46
43
|
```ruby
|
47
44
|
result = gateway.execute(
|
@@ -57,7 +54,7 @@ Arguments for the `execute` method include:
|
|
57
54
|
* `variables`: a hash of variables for the request.
|
58
55
|
* `operation_name`: the name of the operation to execute (when multiple are provided).
|
59
56
|
* `validate`: true if static validation should run on the supergraph schema before execution.
|
60
|
-
* `context`: an object
|
57
|
+
* `context`: an object passed through to executable calls and gateway hooks.
|
61
58
|
|
62
59
|
### Cache hooks
|
63
60
|
|
data/docs/supergraph.md
CHANGED
@@ -1,32 +1,6 @@
|
|
1
1
|
## GraphQL::Stitching::Supergraph
|
2
2
|
|
3
|
-
A `Supergraph` is the singuar representation of a stitched graph. `Supergraph` is
|
4
|
-
|
5
|
-
```ruby
|
6
|
-
storefronts_sdl = "type Query { storefront(id: ID!): Storefront } ..."
|
7
|
-
products_sdl = "type Query { product(id: ID!): Product } ..."
|
8
|
-
|
9
|
-
supergraph = GraphQL::Stitching::Composer.new(schemas: {
|
10
|
-
"storefronts" => GraphQL::Schema.from_definition(storefronts_sdl),
|
11
|
-
"products" => GraphQL::Schema.from_definition(products_sdl),
|
12
|
-
}).perform
|
13
|
-
|
14
|
-
combined_schema = supergraph.schema
|
15
|
-
```
|
16
|
-
|
17
|
-
### Assigning executables
|
18
|
-
|
19
|
-
A Supergraph also manages executable resources assigned for each location (ie: the objects that perform GraphQL requests for each location). An executable is a `GraphQL::Schema` class or any object that implements a `.call(location, query_string, variables)` method and returns a raw GraphQL response. Executables are assigned to a supergraph using `assign_executable`:
|
20
|
-
|
21
|
-
```ruby
|
22
|
-
supergraph = GraphQL::Stitching::Composer.new(...)
|
23
|
-
|
24
|
-
supergraph.assign_executable("location1", MyExecutable.new)
|
25
|
-
supergraph.assign_executable("location2", ->(loc, query, vars) { ... })
|
26
|
-
supergraph.assign_executable("location3") do |loc, query vars|
|
27
|
-
# ...
|
28
|
-
end
|
29
|
-
```
|
3
|
+
A `Supergraph` is the singuar representation of a stitched graph. `Supergraph` is composed from many locations, and provides a combined GraphQL schema and delegation maps used to route incoming requests.
|
30
4
|
|
31
5
|
### Export and caching
|
32
6
|
|
@@ -44,22 +18,18 @@ File.write("supergraph/schema.graphql", supergraph_sdl)
|
|
44
18
|
File.write("supergraph/delegation_map.json", JSON.generate(delegation_map))
|
45
19
|
```
|
46
20
|
|
47
|
-
To restore a
|
21
|
+
To restore a Supergraph, call `from_export` proving the cached SDL string, the parsed JSON delegation mapping, and a hash of executables keyed by their location names:
|
48
22
|
|
49
23
|
```ruby
|
50
24
|
supergraph_sdl = $redis.get("cached_supergraph_sdl")
|
51
25
|
delegation_map = JSON.parse($redis.get("cached_delegation_map"))
|
52
26
|
|
53
|
-
supergraph = GraphQL::Stitching::Supergraph.from_export(
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
url: "http:localhost:3000",
|
61
|
-
headers: { "Authorization" => "Bearer 12345" }
|
27
|
+
supergraph = GraphQL::Stitching::Supergraph.from_export(
|
28
|
+
schema: supergraph_sdl,
|
29
|
+
delegation_map: delegation_map,
|
30
|
+
executables: {
|
31
|
+
my_remote: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3000"),
|
32
|
+
my_local: MyLocalSchema,
|
33
|
+
}
|
62
34
|
)
|
63
|
-
supergraph.assign_executable("my_remote", remote_client)
|
64
|
-
supergraph.assign_executable("my_local", MyLocal::Schema)
|
65
35
|
```
|
@@ -16,28 +16,25 @@ module GraphQL
|
|
16
16
|
].freeze
|
17
17
|
|
18
18
|
def initialize(
|
19
|
-
schemas:,
|
20
19
|
query_name: "Query",
|
21
20
|
mutation_name: "Mutation",
|
22
21
|
description_merger: nil,
|
23
22
|
deprecation_merger: nil,
|
24
23
|
directive_kwarg_merger: nil
|
25
24
|
)
|
26
|
-
@schemas = schemas
|
27
25
|
@query_name = query_name
|
28
26
|
@mutation_name = mutation_name
|
29
|
-
@field_map = {}
|
30
|
-
@boundary_map = {}
|
31
|
-
@mapped_type_names = {}
|
32
|
-
|
33
27
|
@description_merger = description_merger || DEFAULT_VALUE_MERGER
|
34
28
|
@deprecation_merger = deprecation_merger || DEFAULT_VALUE_MERGER
|
35
29
|
@directive_kwarg_merger = directive_kwarg_merger || DEFAULT_VALUE_MERGER
|
36
30
|
end
|
37
31
|
|
38
|
-
def perform
|
32
|
+
def perform(locations_input)
|
33
|
+
reset!
|
34
|
+
schemas, executables = prepare_locations_input(locations_input)
|
35
|
+
|
39
36
|
# "directive_name" => "location" => candidate_directive
|
40
|
-
@subschema_directives_by_name_and_location =
|
37
|
+
@subschema_directives_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
|
41
38
|
(schema.directives.keys - schema.default_directives.keys - GraphQL::Stitching.stitching_directive_names).each do |directive_name|
|
42
39
|
memo[directive_name] ||= {}
|
43
40
|
memo[directive_name][location] = schema.directives[directive_name]
|
@@ -52,7 +49,7 @@ module GraphQL
|
|
52
49
|
@schema_directives.merge!(GraphQL::Schema.default_directives)
|
53
50
|
|
54
51
|
# "Typename" => "location" => candidate_type
|
55
|
-
@subschema_types_by_name_and_location =
|
52
|
+
@subschema_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
|
56
53
|
raise ComposerError, "Location keys must be strings" unless location.is_a?(String)
|
57
54
|
raise ComposerError, "The subscription operation is not supported." if schema.subscription
|
58
55
|
|
@@ -74,7 +71,7 @@ module GraphQL
|
|
74
71
|
end
|
75
72
|
end
|
76
73
|
|
77
|
-
enum_usage = build_enum_usage_map(
|
74
|
+
enum_usage = build_enum_usage_map(schemas.values)
|
78
75
|
|
79
76
|
# "Typename" => merged_type
|
80
77
|
schema_types = @subschema_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo|
|
@@ -119,7 +116,7 @@ module GraphQL
|
|
119
116
|
schema: schema,
|
120
117
|
fields: @field_map,
|
121
118
|
boundaries: @boundary_map,
|
122
|
-
executables:
|
119
|
+
executables: executables,
|
123
120
|
)
|
124
121
|
|
125
122
|
VALIDATORS.each do |validator|
|
@@ -130,6 +127,45 @@ module GraphQL
|
|
130
127
|
supergraph
|
131
128
|
end
|
132
129
|
|
130
|
+
def prepare_locations_input(locations_input)
|
131
|
+
schemas = {}
|
132
|
+
executables = {}
|
133
|
+
|
134
|
+
locations_input.each do |location, input|
|
135
|
+
schema = input[:schema]
|
136
|
+
|
137
|
+
if schema.nil?
|
138
|
+
raise ComposerError, "A schema is required for `#{location}` location."
|
139
|
+
elsif !(schema.is_a?(Class) && schema <= GraphQL::Schema)
|
140
|
+
raise ComposerError, "The schema for `#{location}` location must be a GraphQL::Schema class."
|
141
|
+
end
|
142
|
+
|
143
|
+
if input[:stitch]
|
144
|
+
stitch_directive = Class.new(GraphQL::Schema::Directive) do
|
145
|
+
graphql_name(GraphQL::Stitching.stitch_directive)
|
146
|
+
locations :FIELD_DEFINITION
|
147
|
+
argument :key, String
|
148
|
+
repeatable true
|
149
|
+
end
|
150
|
+
|
151
|
+
input[:stitch].each do |dir|
|
152
|
+
type = dir[:type_name] ? schema.types[dir[:type_name]] : schema.query
|
153
|
+
raise ComposerError, "Invalid stitch directive type `#{dir[:type_name]}`" unless type
|
154
|
+
|
155
|
+
field = type.fields[dir[:field_name]]
|
156
|
+
raise ComposerError, "Invalid stitch directive field `#{dir[:field_name]}`" unless field
|
157
|
+
|
158
|
+
field.directive(stitch_directive, **dir.slice(:key))
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
schemas[location.to_s] = schema
|
163
|
+
executables[location.to_s] = input[:executable] || schema
|
164
|
+
end
|
165
|
+
|
166
|
+
return schemas, executables
|
167
|
+
end
|
168
|
+
|
133
169
|
def build_directive(directive_name, directives_by_location)
|
134
170
|
builder = self
|
135
171
|
|
@@ -515,6 +551,16 @@ module GraphQL
|
|
515
551
|
memo[enum_name] << :write
|
516
552
|
end
|
517
553
|
end
|
554
|
+
|
555
|
+
private
|
556
|
+
|
557
|
+
def reset!
|
558
|
+
@field_map = {}
|
559
|
+
@boundary_map = {}
|
560
|
+
@mapped_type_names = {}
|
561
|
+
@subschema_directives_by_name_and_location = nil
|
562
|
+
@schema_directives = nil
|
563
|
+
end
|
518
564
|
end
|
519
565
|
end
|
520
566
|
end
|
@@ -9,17 +9,16 @@ module GraphQL
|
|
9
9
|
|
10
10
|
attr_reader :supergraph
|
11
11
|
|
12
|
-
def initialize(locations: nil, supergraph: nil)
|
12
|
+
def initialize(locations: nil, supergraph: nil, composer: nil)
|
13
13
|
@supergraph = if locations && supergraph
|
14
14
|
raise GatewayError, "Cannot provide both locations and a supergraph."
|
15
|
-
elsif supergraph && !supergraph.is_a?(Supergraph)
|
15
|
+
elsif supergraph && !supergraph.is_a?(GraphQL::Stitching::Supergraph)
|
16
16
|
raise GatewayError, "Provided supergraph must be a GraphQL::Stitching::Supergraph instance."
|
17
17
|
elsif supergraph
|
18
18
|
supergraph
|
19
|
-
elsif locations
|
20
|
-
build_supergraph_from_locations_config(locations)
|
21
19
|
else
|
22
|
-
|
20
|
+
composer ||= GraphQL::Stitching::Composer.new
|
21
|
+
composer.perform(locations)
|
23
22
|
end
|
24
23
|
end
|
25
24
|
|
@@ -74,28 +73,6 @@ module GraphQL
|
|
74
73
|
|
75
74
|
private
|
76
75
|
|
77
|
-
def build_supergraph_from_locations_config(locations)
|
78
|
-
schemas = locations.each_with_object({}) do |(location, config), memo|
|
79
|
-
schema = config[:schema]
|
80
|
-
if schema.nil?
|
81
|
-
raise GatewayError, "A schema is required for `#{location}` location."
|
82
|
-
elsif !(schema.is_a?(Class) && schema <= GraphQL::Schema)
|
83
|
-
raise GatewayError, "The schema for `#{location}` location must be a GraphQL::Schema class."
|
84
|
-
else
|
85
|
-
memo[location.to_s] = schema
|
86
|
-
end
|
87
|
-
end
|
88
|
-
|
89
|
-
supergraph = GraphQL::Stitching::Composer.new(schemas: schemas).perform
|
90
|
-
|
91
|
-
locations.each do |location, config|
|
92
|
-
executable = config[:executable]
|
93
|
-
supergraph.assign_executable(location.to_s, executable) if executable
|
94
|
-
end
|
95
|
-
|
96
|
-
supergraph
|
97
|
-
end
|
98
|
-
|
99
76
|
def fetch_plan(request)
|
100
77
|
if @on_cache_read
|
101
78
|
cached_plan = @on_cache_read.call(request.digest, request.context)
|
@@ -53,18 +53,16 @@ module GraphQL
|
|
53
53
|
|
54
54
|
when "mutation"
|
55
55
|
parent_type = @supergraph.schema.mutation
|
56
|
-
location_groups = []
|
57
56
|
|
58
|
-
@request.operation.selections.
|
57
|
+
location_groups = @request.operation.selections.each_with_object([]) do |node, memo|
|
59
58
|
# root fields currently just delegate to the last location that defined them; this should probably be smarter
|
60
59
|
next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].last
|
61
60
|
|
62
|
-
if
|
63
|
-
|
61
|
+
if memo.none? || memo.last[:location] != next_location
|
62
|
+
memo << { location: next_location, selections: [] }
|
64
63
|
end
|
65
64
|
|
66
|
-
|
67
|
-
next_location
|
65
|
+
memo.last[:selections] << node
|
68
66
|
end
|
69
67
|
|
70
68
|
location_groups.reduce(0) do |after_key, group|
|
@@ -144,7 +142,7 @@ module GraphQL
|
|
144
142
|
implements_fragments = false
|
145
143
|
|
146
144
|
if parent_type.kind.interface?
|
147
|
-
expand_interface_selections(current_location, parent_type, input_selections)
|
145
|
+
input_selections = expand_interface_selections(current_location, parent_type, input_selections)
|
148
146
|
end
|
149
147
|
|
150
148
|
input_selections.each do |node|
|
@@ -254,7 +252,7 @@ module GraphQL
|
|
254
252
|
|
255
253
|
# hill climbing selects highest scoring locations to use
|
256
254
|
preferred_location = possible_locations.reduce(possible_locations.first) do |best_location, possible_location|
|
257
|
-
score = selections_by_location[
|
255
|
+
score = selections_by_location[possible_location] ? remote_selections.length : 0
|
258
256
|
score += location_weights.fetch(possible_location, 0)
|
259
257
|
|
260
258
|
if score > preferred_location_score
|
@@ -328,8 +326,8 @@ module GraphQL
|
|
328
326
|
local_interface_fields = @supergraph.fields_by_type_and_location[parent_type.graphql_name][current_location]
|
329
327
|
|
330
328
|
expanded_selections = nil
|
331
|
-
input_selections.reject
|
332
|
-
if node.is_a?(GraphQL::Language::Nodes::Field) && !local_interface_fields.include?(node.name)
|
329
|
+
input_selections = input_selections.reject do |node|
|
330
|
+
if node.is_a?(GraphQL::Language::Nodes::Field) && node.name != "__typename" && !local_interface_fields.include?(node.name)
|
333
331
|
expanded_selections ||= []
|
334
332
|
expanded_selections << node
|
335
333
|
true
|
@@ -344,6 +342,8 @@ module GraphQL
|
|
344
342
|
input_selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: expanded_selections)
|
345
343
|
end
|
346
344
|
end
|
345
|
+
|
346
|
+
input_selections
|
347
347
|
end
|
348
348
|
|
349
349
|
# expand concrete type selections into typed fragments when sending to abstract boundaries
|
@@ -42,15 +42,16 @@ module GraphQL
|
|
42
42
|
return nil if raw_object[field_name].nil? && node_type.non_null?
|
43
43
|
|
44
44
|
when GraphQL::Language::Nodes::InlineFragment
|
45
|
-
next unless typename == node.type.name
|
46
45
|
fragment_type = @schema.types[node.type.name]
|
46
|
+
next unless fragment_matches_typename?(fragment_type, typename)
|
47
|
+
|
47
48
|
result = resolve_object_scope(raw_object, fragment_type, node.selections, typename)
|
48
49
|
return nil if result.nil?
|
49
50
|
|
50
51
|
when GraphQL::Language::Nodes::FragmentSpread
|
51
52
|
fragment = @request.fragment_definitions[node.name]
|
52
53
|
fragment_type = @schema.types[fragment.type.name]
|
53
|
-
next unless typename
|
54
|
+
next unless fragment_matches_typename?(fragment_type, typename)
|
54
55
|
|
55
56
|
result = resolve_object_scope(raw_object, fragment_type, fragment.selections, typename)
|
56
57
|
return nil if result.nil?
|
@@ -91,6 +92,11 @@ module GraphQL
|
|
91
92
|
|
92
93
|
resolved_list
|
93
94
|
end
|
95
|
+
|
96
|
+
def fragment_matches_typename?(fragment_type, typename)
|
97
|
+
return true if fragment_type.graphql_name == typename
|
98
|
+
fragment_type.kind.interface? && @schema.possible_types(fragment_type).any? { _1.graphql_name == typename }
|
99
|
+
end
|
94
100
|
end
|
95
101
|
end
|
96
102
|
end
|
@@ -15,11 +15,39 @@ module GraphQL
|
|
15
15
|
"__DirectiveLocation",
|
16
16
|
].freeze
|
17
17
|
|
18
|
+
def self.validate_executable!(location, executable)
|
19
|
+
return true if executable.is_a?(Class) && executable <= GraphQL::Schema
|
20
|
+
return true if executable && executable.respond_to?(:call)
|
21
|
+
raise StitchingError, "Invalid executable provided for location `#{location}`."
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.from_export(schema:, delegation_map:, executables:)
|
25
|
+
schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
|
26
|
+
|
27
|
+
executables = delegation_map["locations"].each_with_object({}) do |location, memo|
|
28
|
+
executable = executables[location] || executables[location.to_sym]
|
29
|
+
if validate_executable!(location, executable)
|
30
|
+
memo[location] = executable
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
new(
|
35
|
+
schema: schema,
|
36
|
+
fields: delegation_map["fields"],
|
37
|
+
boundaries: delegation_map["boundaries"],
|
38
|
+
executables: executables,
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
18
42
|
attr_reader :schema, :boundaries, :locations_by_type_and_field, :executables
|
19
43
|
|
20
|
-
def initialize(schema:, fields:, boundaries:, executables:
|
44
|
+
def initialize(schema:, fields:, boundaries:, executables:)
|
21
45
|
@schema = schema
|
22
46
|
@boundaries = boundaries
|
47
|
+
@possible_keys_by_type = {}
|
48
|
+
@possible_keys_by_type_and_location = {}
|
49
|
+
|
50
|
+
# add introspection types into the fields mapping
|
23
51
|
@locations_by_type_and_field = INTROSPECTION_TYPES.each_with_object(fields) do |type_name, memo|
|
24
52
|
introspection_type = schema.get_type(type_name)
|
25
53
|
next unless introspection_type.kind.fields?
|
@@ -27,57 +55,46 @@ module GraphQL
|
|
27
55
|
memo[type_name] = introspection_type.fields.keys.each_with_object({}) do |field_name, m|
|
28
56
|
m[field_name] = [LOCATION]
|
29
57
|
end
|
30
|
-
end
|
58
|
+
end.freeze
|
31
59
|
|
32
|
-
|
33
|
-
@
|
34
|
-
|
60
|
+
# validate and normalize executable references
|
61
|
+
@executables = executables.each_with_object({ LOCATION => @schema }) do |(location, executable), memo|
|
62
|
+
if self.class.validate_executable!(location, executable)
|
63
|
+
memo[location.to_s] = executable
|
64
|
+
end
|
65
|
+
end.freeze
|
35
66
|
end
|
36
67
|
|
37
68
|
def fields
|
38
69
|
@locations_by_type_and_field.reject { |k, _v| INTROSPECTION_TYPES.include?(k) }
|
39
70
|
end
|
40
71
|
|
72
|
+
def locations
|
73
|
+
@executables.keys.reject { _1 == LOCATION }
|
74
|
+
end
|
75
|
+
|
41
76
|
def export
|
42
77
|
return GraphQL::Schema::Printer.print_schema(@schema), {
|
78
|
+
"locations" => locations,
|
43
79
|
"fields" => fields,
|
44
80
|
"boundaries" => @boundaries,
|
45
81
|
}
|
46
82
|
end
|
47
83
|
|
48
|
-
def
|
49
|
-
schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
|
50
|
-
new(
|
51
|
-
schema: schema,
|
52
|
-
fields: delegation_map["fields"],
|
53
|
-
boundaries: delegation_map["boundaries"],
|
54
|
-
executables: executables,
|
55
|
-
)
|
56
|
-
end
|
57
|
-
|
58
|
-
def assign_executable(location, executable = nil, &block)
|
59
|
-
executable ||= block
|
60
|
-
unless executable.is_a?(Class) && executable <= GraphQL::Schema
|
61
|
-
raise StitchingError, "A client or block handler must be provided." unless executable
|
62
|
-
raise StitchingError, "A client must be callable" unless executable.respond_to?(:call)
|
63
|
-
end
|
64
|
-
@executables[location] = executable
|
65
|
-
end
|
66
|
-
|
67
|
-
def execute_at_location(location, query, variables, context)
|
84
|
+
def execute_at_location(location, source, variables, context)
|
68
85
|
executable = executables[location]
|
69
86
|
|
70
87
|
if executable.nil?
|
71
88
|
raise StitchingError, "No executable assigned for #{location} location."
|
72
89
|
elsif executable.is_a?(Class) && executable <= GraphQL::Schema
|
73
90
|
executable.execute(
|
74
|
-
query:
|
91
|
+
query: source,
|
75
92
|
variables: variables,
|
76
93
|
context: context.frozen? ? context.dup : context,
|
77
94
|
validate: false,
|
78
95
|
)
|
79
96
|
elsif executable.respond_to?(:call)
|
80
|
-
executable.call(location,
|
97
|
+
executable.call(location, source, variables, context)
|
81
98
|
else
|
82
99
|
raise StitchingError, "Missing valid executable for #{location} location."
|
83
100
|
end
|
data/lib/graphql/stitching.rb
CHANGED
@@ -19,56 +19,6 @@ module GraphQL
|
|
19
19
|
def stitching_directive_names
|
20
20
|
[stitch_directive]
|
21
21
|
end
|
22
|
-
|
23
|
-
def schema_from_definition(sdl, stitch_directives:)
|
24
|
-
ast = GraphQL.parse(sdl)
|
25
|
-
|
26
|
-
if stitch_directives&.any?
|
27
|
-
directive_definition = ast.definitions.find do |d|
|
28
|
-
d.is_a?(GraphQL::Language::Nodes::DirectiveDefinition) && d.name == stitch_directive
|
29
|
-
end
|
30
|
-
|
31
|
-
if !directive_definition
|
32
|
-
directive_sdl = "directive @#{stitch_directive}(key: String!) repeatable on FIELD_DEFINITION"
|
33
|
-
directive_definition = GraphQL.parse(directive_sdl).definitions.first
|
34
|
-
ast.send(:merge!, { definitions: [directive_definition, *ast.definitions] })
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
stitch_directives.each do |config|
|
39
|
-
config[:type_name] ||= "Query"
|
40
|
-
|
41
|
-
type_node = ast.definitions.find do |d|
|
42
|
-
d.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) && d.name == config[:type_name]
|
43
|
-
end
|
44
|
-
|
45
|
-
raise StitchingError, "invalid type name `#{config[:type_name]}`." unless type_node
|
46
|
-
|
47
|
-
field_node = type_node.fields.find do |f|
|
48
|
-
f.name == config[:field_name]
|
49
|
-
end
|
50
|
-
|
51
|
-
raise StitchingError, "invalid field name `#{config[:field_name]}`." unless field_node
|
52
|
-
|
53
|
-
field_node.send(:merge!, {
|
54
|
-
directives: [
|
55
|
-
*field_node.directives,
|
56
|
-
GraphQL::Language::Nodes::Directive.new(
|
57
|
-
arguments: [GraphQL::Language::Nodes::Argument.new(name: "key", value: config[:key])],
|
58
|
-
name: stitch_directive,
|
59
|
-
)
|
60
|
-
]
|
61
|
-
})
|
62
|
-
end
|
63
|
-
|
64
|
-
if GraphQL::Schema::BuildFromDefinition.method(:from_document).parameters.first.last == :document
|
65
|
-
# GraphQL v1.13.x
|
66
|
-
GraphQL::Schema::BuildFromDefinition.from_document(ast, default_resolve: nil)
|
67
|
-
else
|
68
|
-
# GraphQL v2
|
69
|
-
GraphQL::Schema::BuildFromDefinition.from_document(GraphQL::Schema, ast, default_resolve: nil)
|
70
|
-
end
|
71
|
-
end
|
72
22
|
end
|
73
23
|
end
|
74
24
|
end
|
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: 0.3.1
|
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-03-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|
@@ -93,6 +93,7 @@ files:
|
|
93
93
|
- example/graphiql.html
|
94
94
|
- example/remote1.rb
|
95
95
|
- example/remote2.rb
|
96
|
+
- gemfiles/graphql_1.13.9.gemfile
|
96
97
|
- graphql-stitching.gemspec
|
97
98
|
- lib/graphql/stitching.rb
|
98
99
|
- lib/graphql/stitching/composer.rb
|