graphql-stitching 0.2.2 → 0.3.0
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 +70 -25
- data/docs/composer.md +81 -73
- data/docs/executor.md +16 -0
- data/docs/gateway.md +11 -14
- data/docs/supergraph.md +10 -40
- data/gemfiles/graphql_1.13.9.gemfile +6 -0
- data/graphql-stitching.gemspec +1 -1
- data/lib/graphql/stitching/composer/validate_boundaries.rb +11 -1
- data/lib/graphql/stitching/composer.rb +57 -11
- data/lib/graphql/stitching/gateway.rb +4 -27
- data/lib/graphql/stitching/planner.rb +1 -1
- data/lib/graphql/stitching/supergraph.rb +46 -29
- data/lib/graphql/stitching/version.rb +1 -1
- metadata +7 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8f7587a903fe54feeffe79deb25fdcf341f13af67b46f896003dfdba861cb614
|
4
|
+
data.tar.gz: 191ff2274244d23b16325579792fd07310bf5b3fc3cc1a2b20e7b30305456851
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7fd408963db10a7bfdfc272987a793353a190a191eb5de264a1a6a0ccd7e80293605bb57e85c70c52f66a3cd646b966930741b938ca231146f527704b524f0a1
|
7
|
+
data.tar.gz: 1857a8492545cc4d7da64c541c897bb14e898c391edf271baf54a5e39369d8f387d80500c40fc591726c2dcb06fcce3f1164a9680f266e9a22494da577076648
|
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.
|
@@ -92,7 +92,7 @@ To facilitate this merging of types, stitching must know how to cross-reference
|
|
92
92
|
directive @stitch(key: String!) repeatable on FIELD_DEFINITION
|
93
93
|
```
|
94
94
|
|
95
|
-
This directive is applied to root queries where a merged type may be accessed in each location, and a `key` argument specifies a field needed from other locations to be used as a query argument.
|
95
|
+
This directive (or [static configuration](#sdl-based-schemas)) is applied to root queries where a merged type may be accessed in each location, and a `key` argument specifies a field needed from other locations to be used as a query argument.
|
96
96
|
|
97
97
|
```ruby
|
98
98
|
products_schema = <<~GRAPHQL
|
@@ -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:
|
@@ -219,7 +218,7 @@ type Query {
|
|
219
218
|
}
|
220
219
|
```
|
221
220
|
|
222
|
-
The `@stitch` directive is also repeatable (_requires graphql-ruby v2.0.15_), allowing a single query to associate with multiple keys:
|
221
|
+
The `@stitch` directive is also repeatable (_requires graphql-ruby >= v2.0.15_), allowing a single query to associate with multiple keys:
|
223
222
|
|
224
223
|
```graphql
|
225
224
|
type Product {
|
@@ -251,6 +250,37 @@ class Query < GraphQL::Schema::Object
|
|
251
250
|
end
|
252
251
|
```
|
253
252
|
|
253
|
+
The `@stitch` directive can be exported from a class-based schema to an SDL string by calling `schema.to_definition`.
|
254
|
+
|
255
|
+
#### SDL-based schemas
|
256
|
+
|
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):
|
258
|
+
|
259
|
+
```ruby
|
260
|
+
sdl_string = <<~GRAPHQL
|
261
|
+
type Product {
|
262
|
+
id: ID!
|
263
|
+
upc: ID!
|
264
|
+
}
|
265
|
+
type Query {
|
266
|
+
productById(id: ID!): Product
|
267
|
+
productByUpc(upc: ID!): Product
|
268
|
+
}
|
269
|
+
GRAPHQL
|
270
|
+
|
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
|
+
},
|
280
|
+
# ...
|
281
|
+
})
|
282
|
+
```
|
283
|
+
|
254
284
|
#### Custom directive names
|
255
285
|
|
256
286
|
The library is configured to use a `@stitch` directive by default. You may customize this by setting a new name during initialization:
|
@@ -261,26 +291,41 @@ GraphQL::Stitching.stitch_directive = "merge"
|
|
261
291
|
|
262
292
|
## Executables
|
263
293
|
|
264
|
-
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:
|
265
295
|
|
266
296
|
```ruby
|
267
297
|
class MyExecutable
|
268
|
-
def call(location,
|
298
|
+
def call(location, source, variables, context)
|
269
299
|
# process a GraphQL request...
|
300
|
+
return {
|
301
|
+
"data" => { ... },
|
302
|
+
"errors" => [ ... ],
|
303
|
+
}
|
270
304
|
end
|
271
305
|
end
|
272
306
|
```
|
273
307
|
|
274
|
-
|
308
|
+
A [Supergraph](./docs/supergraph.md) is composed with executable resource provided for each location. Any location that omits the `executable` option will use the provided `schema` as the default executable:
|
275
309
|
|
276
310
|
```ruby
|
277
|
-
supergraph = GraphQL::Stitching::Composer.new(
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
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
|
+
})
|
284
329
|
```
|
285
330
|
|
286
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,36 +1,10 @@
|
|
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
|
|
33
|
-
A Supergraph is designed to be composed, cached, and restored. Calling the `export` method will return an SDL (Schema Definition Language) print of the combined graph schema and a
|
7
|
+
A Supergraph is designed to be composed, cached, and restored. Calling the `export` method will return an SDL (Schema Definition Language) print of the combined graph schema and a delegation mapping hash. These can be persisted in any raw format that suits your stack:
|
34
8
|
|
35
9
|
```ruby
|
36
10
|
supergraph_sdl, delegation_map = supergraph.export
|
@@ -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
|
```
|
data/graphql-stitching.gemspec
CHANGED
@@ -26,7 +26,7 @@ Gem::Specification.new do |spec|
|
|
26
26
|
end
|
27
27
|
spec.require_paths = ['lib']
|
28
28
|
|
29
|
-
spec.add_runtime_dependency 'graphql', '
|
29
|
+
spec.add_runtime_dependency 'graphql', '>= 1.13.9'
|
30
30
|
|
31
31
|
spec.add_development_dependency 'bundler', '~> 2.0'
|
32
32
|
spec.add_development_dependency 'rake', '~> 12.0'
|
@@ -67,7 +67,17 @@ module GraphQL
|
|
67
67
|
end
|
68
68
|
|
69
69
|
def validate_as_shared(ctx, type, subschema_types_by_location)
|
70
|
-
expected_fields =
|
70
|
+
expected_fields = begin
|
71
|
+
type.fields.keys.sort
|
72
|
+
rescue StandardError => e
|
73
|
+
# bug with inherited interfaces in older versions of GraphQL
|
74
|
+
if type.interfaces.any? { _1.is_a?(GraphQL::Schema::LateBoundType) }
|
75
|
+
raise Composer::ComposerError, "Merged interface inheritance requires GraphQL >= v2.0.3"
|
76
|
+
else
|
77
|
+
raise e
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
71
81
|
subschema_types_by_location.each do |location, subschema_type|
|
72
82
|
if subschema_type.fields.keys.sort != expected_fields
|
73
83
|
raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations,
|
@@ -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)
|
@@ -254,7 +254,7 @@ module GraphQL
|
|
254
254
|
|
255
255
|
# hill climbing selects highest scoring locations to use
|
256
256
|
preferred_location = possible_locations.reduce(possible_locations.first) do |best_location, possible_location|
|
257
|
-
score = selections_by_location[
|
257
|
+
score = selections_by_location[possible_location] ? remote_selections.length : 0
|
258
258
|
score += location_weights.fetch(possible_location, 0)
|
259
259
|
|
260
260
|
if score > preferred_location_score
|
@@ -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
|
@@ -96,7 +113,7 @@ module GraphQL
|
|
96
113
|
end
|
97
114
|
end
|
98
115
|
|
99
|
-
#
|
116
|
+
# "Type" => ["location1", "location2", ...]
|
100
117
|
def locations_by_type
|
101
118
|
@locations_by_type ||= @locations_by_type_and_field.each_with_object({}) do |(type_name, fields), memo|
|
102
119
|
memo[type_name] = fields.values.flatten.uniq
|
@@ -104,7 +121,7 @@ module GraphQL
|
|
104
121
|
end
|
105
122
|
|
106
123
|
# collects all possible boundary keys for a given type
|
107
|
-
#
|
124
|
+
# ("Type") => ["id", ...]
|
108
125
|
def possible_keys_for_type(type_name)
|
109
126
|
@possible_keys_by_type[type_name] ||= begin
|
110
127
|
keys = @boundaries[type_name].map { _1["selection"] }
|
metadata
CHANGED
@@ -1,29 +1,29 @@
|
|
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.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-02-
|
11
|
+
date: 2023-02-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- - "
|
17
|
+
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
19
|
+
version: 1.13.9
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
|
-
- - "
|
24
|
+
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
26
|
+
version: 1.13.9
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: bundler
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -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
|