graphql-stitching 1.2.1 → 1.2.3
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 +8 -2
- data/README.md +12 -72
- data/docs/README.md +0 -2
- data/docs/federation_entities.md +70 -0
- data/docs/mechanics.md +42 -0
- data/docs/request.md +7 -32
- data/gemfiles/graphql_1.13.9.gemfile +1 -1
- data/gemfiles/graphql_2.0.0.gemfile +6 -0
- data/gemfiles/graphql_2.1.0.gemfile +6 -0
- data/gemfiles/graphql_2.2.0.gemfile +6 -0
- data/lib/graphql/stitching/composer.rb +6 -9
- data/lib/graphql/stitching/executor/root_source.rb +25 -3
- data/lib/graphql/stitching/planner.rb +14 -12
- data/lib/graphql/stitching/request.rb +13 -2
- data/lib/graphql/stitching/supergraph.rb +22 -9
- data/lib/graphql/stitching/util.rb +1 -1
- data/lib/graphql/stitching/version.rb +1 -1
- metadata +6 -4
- data/docs/executor.md +0 -68
- data/docs/planner.md +0 -45
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 99acc247a0e5e227236b0261b3f975e7277f642a9d76daaf4f6a985bfc4825ea
|
4
|
+
data.tar.gz: ef5f01132498ca4ba1be6f8593f189645ddf8fb6b6d8ab71f9b7d0e55d6ec682
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d1bd0db4c7f6d363231330222006f2bbc552a669227a38aeb7861dde843b59bc3e61b0f301f97d4a5c6b55193d01424cd9f240c0ff7a7ee483e49150686ac3da
|
7
|
+
data.tar.gz: 348aa663608fd8b0e5544ccf7ec500d4295a63bb9bd19a91031f927165da2a206024ce6cb17111c3ebda2c172e08072be8d01716c11f0a05c3ea8a9a4b99466e
|
data/.github/workflows/ci.yml
CHANGED
@@ -16,10 +16,16 @@ jobs:
|
|
16
16
|
ruby: 3.2
|
17
17
|
- gemfile: Gemfile
|
18
18
|
ruby: 3.1
|
19
|
-
- gemfile: gemfiles/graphql_1.13.9.gemfile
|
20
|
-
ruby: 3.1
|
21
19
|
- gemfile: Gemfile
|
22
20
|
ruby: 2.7
|
21
|
+
- gemfile: gemfiles/graphql_2.2.0.gemfile
|
22
|
+
ruby: 3.1
|
23
|
+
- gemfile: gemfiles/graphql_2.1.0.gemfile
|
24
|
+
ruby: 3.1
|
25
|
+
- gemfile: gemfiles/graphql_2.0.0.gemfile
|
26
|
+
ruby: 3.1
|
27
|
+
- gemfile: gemfiles/graphql_1.13.9.gemfile
|
28
|
+
ruby: 3.1
|
23
29
|
|
24
30
|
steps:
|
25
31
|
- uses: actions/checkout@v2
|
data/README.md
CHANGED
@@ -9,14 +9,13 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
|
|
9
9
|
- Multiple keys per merged type.
|
10
10
|
- Shared objects, fields, enums, and inputs across locations.
|
11
11
|
- Combining local and remote schemas.
|
12
|
-
- Type merging via arbitrary queries or federation `_entities` protocol.
|
13
12
|
- File uploads via [multipart form spec](https://github.com/jaydenseric/graphql-multipart-request-spec).
|
14
13
|
|
15
14
|
**NOT Supported:**
|
16
15
|
- Computed fields (ie: federation-style `@requires`).
|
17
16
|
- Subscriptions, defer/stream.
|
18
17
|
|
19
|
-
This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic.
|
18
|
+
This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic. The opportunity here is for a Ruby application to stitch its local schemas together or onto remote sources without requiring an additional proxy service running in another language. If your goal is to build a purely high-throughput API gateway, consider not using Ruby.
|
20
19
|
|
21
20
|
## Getting started
|
22
21
|
|
@@ -34,7 +33,7 @@ require "graphql/stitching"
|
|
34
33
|
|
35
34
|
## Usage
|
36
35
|
|
37
|
-
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
|
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 optional query plan caching hooks):
|
38
37
|
|
39
38
|
```ruby
|
40
39
|
movies_schema = <<~GRAPHQL
|
@@ -72,15 +71,13 @@ result = client.execute(
|
|
72
71
|
)
|
73
72
|
```
|
74
73
|
|
75
|
-
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
|
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 via [executables](#executables).
|
76
75
|
|
77
76
|
While the `Client` constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
|
78
77
|
|
79
78
|
- [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
|
80
79
|
- [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
|
81
|
-
- [Request](./docs/request.md) -
|
82
|
-
- [Planner](./docs/planner.md) - builds a cacheable query plan for a request document.
|
83
|
-
- [Executor](./docs/executor.md) - executes a query plan with given request variables.
|
80
|
+
- [Request](./docs/request.md) - manages the lifecycle of a stitched GraphQL request.
|
84
81
|
- [HttpExecutable](./docs/http_executable.md) - proxies requests to remotes with multipart file upload support.
|
85
82
|
|
86
83
|
## Merged types
|
@@ -89,11 +86,11 @@ While the `Client` constructor is an easy quick start, the library also has seve
|
|
89
86
|
|
90
87
|
![Merging types](./docs/images/merging.png)
|
91
88
|
|
92
|
-
To facilitate this merging of types, stitching must know how to cross-reference and fetch each variant of a type from its source location
|
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 using [resolver queries](#merged-type-resolver-queries). For those in an Apollo ecosystem, there's also _limited_ support for merging types though a [federation `_entities` protocol](./docs/federation_entities.md).
|
93
90
|
|
94
|
-
### Merged
|
91
|
+
### Merged type resolver queries
|
95
92
|
|
96
|
-
Types
|
93
|
+
Types merge through resolver queries identified by a `@stitch` directive:
|
97
94
|
|
98
95
|
```graphql
|
99
96
|
directive @stitch(key: String!) repeatable on FIELD_DEFINITION
|
@@ -134,7 +131,7 @@ client = GraphQL::Stitching::Client.new(locations: {
|
|
134
131
|
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
|
135
132
|
},
|
136
133
|
catalog: {
|
137
|
-
schema: GraphQL::Schema.from_definition(
|
134
|
+
schema: GraphQL::Schema.from_definition(catalog_schema),
|
138
135
|
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
|
139
136
|
},
|
140
137
|
})
|
@@ -155,7 +152,7 @@ type Query {
|
|
155
152
|
* The `@stitch` directive is applied to a root query where the merged type may be accessed. The merged type identity is inferred from the field return.
|
156
153
|
* The `key: "id"` parameter indicates that an `{ id }` must be selected from prior locations so it may be submitted as an argument to this query. The query argument used to send the key is inferred when possible ([more on arguments](#multiple-query-arguments) later).
|
157
154
|
|
158
|
-
Each location that provides a unique variant of a type must provide at least one stitching query. The exception to this requirement are types that contain only a single key field:
|
155
|
+
Each location that provides a unique variant of a type must provide at least one stitching query for the type. The exception to this requirement are [foreign key types](./docs/mechanics.md##modeling-foreign-keys-for-stitching) that contain only a single key field:
|
159
156
|
|
160
157
|
```graphql
|
161
158
|
type Product {
|
@@ -163,7 +160,7 @@ type Product {
|
|
163
160
|
}
|
164
161
|
```
|
165
162
|
|
166
|
-
The above representation of a `Product` type provides no unique data beyond a key that is available in other locations. Thus, this representation will never require an inbound request to fetch it, and its stitching query may be omitted.
|
163
|
+
The above representation of a `Product` type provides no unique data beyond a key that is available in other locations. Thus, this representation will never require an inbound request to fetch it, and its stitching query may be omitted.
|
167
164
|
|
168
165
|
#### List queries
|
169
166
|
|
@@ -301,65 +298,6 @@ The library is configured to use a `@stitch` directive by default. You may custo
|
|
301
298
|
GraphQL::Stitching.stitch_directive = "merge"
|
302
299
|
```
|
303
300
|
|
304
|
-
### Merged types via Federation entities
|
305
|
-
|
306
|
-
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:
|
307
|
-
|
308
|
-
- `@key(fields: "id")` (repeatable) specifies a key field for an object type. The key `fields` argument may only contain one field selection.
|
309
|
-
- `_Entity` is a union type that must contain all types that implement a `@key`.
|
310
|
-
- `_Any` is a scalar that recieves raw JSON objects; each object representation contains a `__typename` and the type's key field.
|
311
|
-
- `_entities(representations: [_Any!]!): [_Entity]!` is a root query for local entity types.
|
312
|
-
|
313
|
-
The composer will automatcially detect and stitch schemas with an `_entities` query, for example:
|
314
|
-
|
315
|
-
```ruby
|
316
|
-
products_schema = <<~GRAPHQL
|
317
|
-
directive @key(fields: String!) repeatable on OBJECT
|
318
|
-
|
319
|
-
type Product @key(fields: "id") {
|
320
|
-
id: ID!
|
321
|
-
name: String!
|
322
|
-
}
|
323
|
-
|
324
|
-
union _Entity = Product
|
325
|
-
scalar _Any
|
326
|
-
|
327
|
-
type Query {
|
328
|
-
user(id: ID!): User
|
329
|
-
_entities(representations: [_Any!]!): [_Entity]!
|
330
|
-
}
|
331
|
-
GRAPHQL
|
332
|
-
|
333
|
-
catalog_schema = <<~GRAPHQL
|
334
|
-
directive @key(fields: String!) repeatable on OBJECT
|
335
|
-
|
336
|
-
type Product @key(fields: "id") {
|
337
|
-
id: ID!
|
338
|
-
price: Float!
|
339
|
-
}
|
340
|
-
|
341
|
-
union _Entity = Product
|
342
|
-
scalar _Any
|
343
|
-
|
344
|
-
type Query {
|
345
|
-
_entities(representations: [_Any!]!): [_Entity]!
|
346
|
-
}
|
347
|
-
GRAPHQL
|
348
|
-
|
349
|
-
client = GraphQL::Stitching::Client.new(locations: {
|
350
|
-
products: {
|
351
|
-
schema: GraphQL::Schema.from_definition(products_schema),
|
352
|
-
executable: ...,
|
353
|
-
},
|
354
|
-
catalog: {
|
355
|
-
schema: GraphQL::Schema.from_definition(catalog_schema),
|
356
|
-
executable: ...,
|
357
|
-
},
|
358
|
-
})
|
359
|
-
```
|
360
|
-
|
361
|
-
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.
|
362
|
-
|
363
301
|
## Executables
|
364
302
|
|
365
303
|
An executable resource performs location-specific GraphQL requests. Executables may be `GraphQL::Schema` classes, or any object that responds to `.call(request, source, variables)` and returns a raw GraphQL response:
|
@@ -427,7 +365,9 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im
|
|
427
365
|
|
428
366
|
## Additional topics
|
429
367
|
|
368
|
+
- [Modeling foreign keys for stitching](./docs/mechanics.md##modeling-foreign-keys-for-stitching)
|
430
369
|
- [Deploying a stitched schema](./docs/mechanics.md#deploying-a-stitched-schema)
|
370
|
+
- [Schema composition merge patterns](./docs/composer.md#merge-patterns)
|
431
371
|
- [Field selection routing](./docs/mechanics.md#field-selection-routing)
|
432
372
|
- [Root selection routing](./docs/mechanics.md#root-selection-routing)
|
433
373
|
- [Stitched errors](./docs/mechanics.md#stitched-errors)
|
data/docs/README.md
CHANGED
@@ -10,8 +10,6 @@ Major components include:
|
|
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.
|
13
|
-
- [Planner](./planner.md) - builds a cacheable query plan for a request document.
|
14
|
-
- [Executor](./executor.md) - executes a query plan with given request variables.
|
15
13
|
- [HttpExecutable](./http_executable.md) - proxies requests to remotes with multipart file upload support.
|
16
14
|
|
17
15
|
Additional topics:
|
@@ -0,0 +1,70 @@
|
|
1
|
+
## Merged types via Apollo Federation `_entities`
|
2
|
+
|
3
|
+
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 full spec is NOT supported and therefore is not fully interchangable with an Apollo Gateway.
|
4
|
+
|
5
|
+
To avoid confusion, using [basic resolver queries](../README.md#merged-type-resolver-queries) is recommended unless you specifically need to interact with a service built for an Apollo ecosystem. Even then, be wary that it does not exceed the supported spec by [using features that will not work](#federation-features-that-will-most-definitly-break).
|
6
|
+
|
7
|
+
### Supported spec
|
8
|
+
|
9
|
+
The following subset of the federation spec is supported:
|
10
|
+
|
11
|
+
- `@key(fields: "id")` (repeatable) specifies a key field for an object type. The key `fields` argument may only contain one field selection.
|
12
|
+
- `_Entity` is a union type that must contain all types that implement a `@key`.
|
13
|
+
- `_Any` is a scalar that recieves raw JSON objects; each object representation contains a `__typename` and the type's key field.
|
14
|
+
- `_entities(representations: [_Any!]!): [_Entity]!` is a root query for local entity types.
|
15
|
+
|
16
|
+
The composer will automatcially detect and stitch schemas with an `_entities` query, for example:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
products_schema = <<~GRAPHQL
|
20
|
+
directive @key(fields: String!) repeatable on OBJECT
|
21
|
+
|
22
|
+
type Product @key(fields: "id") {
|
23
|
+
id: ID!
|
24
|
+
name: String!
|
25
|
+
}
|
26
|
+
|
27
|
+
union _Entity = Product
|
28
|
+
scalar _Any
|
29
|
+
|
30
|
+
type Query {
|
31
|
+
user(id: ID!): User
|
32
|
+
_entities(representations: [_Any!]!): [_Entity]!
|
33
|
+
}
|
34
|
+
GRAPHQL
|
35
|
+
|
36
|
+
catalog_schema = <<~GRAPHQL
|
37
|
+
directive @key(fields: String!) repeatable on OBJECT
|
38
|
+
|
39
|
+
type Product @key(fields: "id") {
|
40
|
+
id: ID!
|
41
|
+
price: Float!
|
42
|
+
}
|
43
|
+
|
44
|
+
union _Entity = Product
|
45
|
+
scalar _Any
|
46
|
+
|
47
|
+
type Query {
|
48
|
+
_entities(representations: [_Any!]!): [_Entity]!
|
49
|
+
}
|
50
|
+
GRAPHQL
|
51
|
+
|
52
|
+
client = GraphQL::Stitching::Client.new(locations: {
|
53
|
+
products: {
|
54
|
+
schema: GraphQL::Schema.from_definition(products_schema),
|
55
|
+
executable: ...,
|
56
|
+
},
|
57
|
+
catalog: {
|
58
|
+
schema: GraphQL::Schema.from_definition(catalog_schema),
|
59
|
+
executable: ...,
|
60
|
+
},
|
61
|
+
})
|
62
|
+
```
|
63
|
+
|
64
|
+
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.
|
65
|
+
|
66
|
+
### Federation features that will most definitly break
|
67
|
+
|
68
|
+
- `@external` fields will confuse the stitching query planner.
|
69
|
+
- `@requires` fields will not be sent any dependencies.
|
70
|
+
- No support for Apollo composition directives.
|
data/docs/mechanics.md
CHANGED
@@ -1,5 +1,47 @@
|
|
1
1
|
## Schema Stitching, mechanics
|
2
2
|
|
3
|
+
### Modeling foreign keys for stitching
|
4
|
+
|
5
|
+
Foreign keys in a GraphQL schema typically look like the `Product.imageId` field here:
|
6
|
+
|
7
|
+
```graphql
|
8
|
+
# -- Products schema:
|
9
|
+
|
10
|
+
type Product {
|
11
|
+
id: ID!
|
12
|
+
imageId: ID!
|
13
|
+
}
|
14
|
+
|
15
|
+
# -- Images schema:
|
16
|
+
|
17
|
+
type Image {
|
18
|
+
id: ID!
|
19
|
+
url: String!
|
20
|
+
}
|
21
|
+
```
|
22
|
+
|
23
|
+
However, this design does not lend itself to stitching where types need to _merge_ across locations. A simple schema refactor makes this foreign key more expressive as an entity type, and turns the key into an _object_ that will merge with analogous object types in other locations:
|
24
|
+
|
25
|
+
```graphql
|
26
|
+
# -- Products schema:
|
27
|
+
|
28
|
+
type Product {
|
29
|
+
id: ID!
|
30
|
+
image: Image!
|
31
|
+
}
|
32
|
+
|
33
|
+
type Image {
|
34
|
+
id: ID!
|
35
|
+
}
|
36
|
+
|
37
|
+
# -- Images schema:
|
38
|
+
|
39
|
+
type Image {
|
40
|
+
id: ID!
|
41
|
+
url: String!
|
42
|
+
}
|
43
|
+
```
|
44
|
+
|
3
45
|
### Deploying a stitched schema
|
4
46
|
|
5
47
|
Among the simplest and most effective ways to manage a stitched schema is to compose it locally, write the composed SDL as a `.graphql` file in your repo, and then load the composed schema into a stitching client at runtime. For example, setup a `rake` task that loads/fetches subgraph schemas, composes them, and then writes the composed schema definition as a file committed to the repo:
|
data/docs/request.md
CHANGED
@@ -25,37 +25,12 @@ A `Request` provides the following information:
|
|
25
25
|
- `req.variable_definitions`: a mapping of variable names to their type definitions
|
26
26
|
- `req.fragment_definitions`: a mapping of fragment names to their fragment definitions
|
27
27
|
|
28
|
-
###
|
28
|
+
### Request lifecycle
|
29
29
|
|
30
|
-
A request
|
30
|
+
A request manages the flow of stitching behaviors. These are sequenced by the `Client`
|
31
|
+
component, or you may invoke them manually:
|
31
32
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
id
|
37
|
-
title(lang: $lang)
|
38
|
-
showtimes @include(if: $withShowtimes) {
|
39
|
-
time
|
40
|
-
}
|
41
|
-
}
|
42
|
-
}
|
43
|
-
GRAPHQL
|
44
|
-
|
45
|
-
request = GraphQL::Stitching::Request.new(
|
46
|
-
supergraph,
|
47
|
-
document,
|
48
|
-
variables: { "id" => "1" },
|
49
|
-
operation_name: "FetchMovie",
|
50
|
-
)
|
51
|
-
|
52
|
-
errors = MySchema.validate(request.document)
|
53
|
-
# return early with any static validation errors...
|
54
|
-
|
55
|
-
request.prepare!
|
56
|
-
```
|
57
|
-
|
58
|
-
Preparing a request will apply several destructive transformations:
|
59
|
-
|
60
|
-
- Default values from variable definitions will be added to request variables.
|
61
|
-
- The document will be pre-shaped based on `@skip` and `@include` directives.
|
33
|
+
1. `request.validate`: runs static validations on the request using the combined schema.
|
34
|
+
2. `request.prepare!`: inserts variable defaults and pre-renders skip/include conditional shaping.
|
35
|
+
3. `request.plan`: builds a plan for the request. May act as a setting for plans pulled from cache.
|
36
|
+
4. `request.execute`: executes the request, and returns the resulting data.
|
@@ -145,7 +145,8 @@ module GraphQL
|
|
145
145
|
|
146
146
|
builder = self
|
147
147
|
schema = Class.new(GraphQL::Schema) do
|
148
|
-
|
148
|
+
add_type_and_traverse(schema_types.values, root: false)
|
149
|
+
orphan_types(schema_types.values.select { |t| t.respond_to?(:kind) && t.kind.object? })
|
149
150
|
query schema_types[builder.query_name]
|
150
151
|
mutation schema_types[builder.mutation_name]
|
151
152
|
directives builder.schema_directives.values
|
@@ -263,7 +264,6 @@ module GraphQL
|
|
263
264
|
enum_values_by_name_location = types_by_location.each_with_object({}) do |(location, type_candidate), memo|
|
264
265
|
type_candidate.enum_values.each do |enum_value_candidate|
|
265
266
|
memo[enum_value_candidate.graphql_name] ||= {}
|
266
|
-
memo[enum_value_candidate.graphql_name][location] ||= {}
|
267
267
|
memo[enum_value_candidate.graphql_name][location] = enum_value_candidate
|
268
268
|
end
|
269
269
|
end
|
@@ -376,7 +376,6 @@ module GraphQL
|
|
376
376
|
@field_map[type_name][field_candidate.name] << location
|
377
377
|
|
378
378
|
memo[field_name] ||= {}
|
379
|
-
memo[field_name][location] ||= {}
|
380
379
|
memo[field_name][location] = field_candidate
|
381
380
|
end
|
382
381
|
end
|
@@ -406,7 +405,6 @@ module GraphQL
|
|
406
405
|
args_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo|
|
407
406
|
member_candidate.arguments.each do |argument_name, argument|
|
408
407
|
memo[argument_name] ||= {}
|
409
|
-
memo[argument_name][location] ||= {}
|
410
408
|
memo[argument_name][location] = argument
|
411
409
|
end
|
412
410
|
end
|
@@ -461,7 +459,6 @@ module GraphQL
|
|
461
459
|
directives_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo|
|
462
460
|
member_candidate.directives.each do |directive|
|
463
461
|
memo[directive.graphql_name] ||= {}
|
464
|
-
memo[directive.graphql_name][location] ||= {}
|
465
462
|
memo[directive.graphql_name][location] = directive
|
466
463
|
end
|
467
464
|
end
|
@@ -652,22 +649,22 @@ module GraphQL
|
|
652
649
|
|
653
650
|
schemas.each do |schema|
|
654
651
|
introspection_types = schema.introspection_system.types.keys
|
655
|
-
schema.types.
|
652
|
+
schema.types.each_value do |type|
|
656
653
|
next if introspection_types.include?(type.graphql_name)
|
657
654
|
|
658
655
|
if type.kind.object? || type.kind.interface?
|
659
|
-
type.fields.
|
656
|
+
type.fields.each_value do |field|
|
660
657
|
field_type = field.type.unwrap
|
661
658
|
reads << field_type.graphql_name if field_type.kind.enum?
|
662
659
|
|
663
|
-
field.arguments.
|
660
|
+
field.arguments.each_value do |argument|
|
664
661
|
argument_type = argument.type.unwrap
|
665
662
|
writes << argument_type.graphql_name if argument_type.kind.enum?
|
666
663
|
end
|
667
664
|
end
|
668
665
|
|
669
666
|
elsif type.kind.input_object?
|
670
|
-
type.arguments.
|
667
|
+
type.arguments.each_value do |argument|
|
671
668
|
argument_type = argument.type.unwrap
|
672
669
|
writes << argument_type.graphql_name if argument_type.kind.enum?
|
673
670
|
end
|
@@ -20,10 +20,22 @@ module GraphQL::Stitching
|
|
20
20
|
result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
|
21
21
|
@executor.query_count += 1
|
22
22
|
|
23
|
-
|
23
|
+
if result["data"]
|
24
|
+
if op.path.any?
|
25
|
+
# Nested root scopes must expand their pathed origin set
|
26
|
+
origin_set = op.path.reduce([@executor.data]) do |set, ns|
|
27
|
+
set.flat_map { |obj| obj && obj[ns] }.tap(&:compact!)
|
28
|
+
end
|
29
|
+
|
30
|
+
origin_set.each { _1.merge!(result["data"]) }
|
31
|
+
else
|
32
|
+
# Actual root scopes merge directly into results data
|
33
|
+
@executor.data.merge!(result["data"])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
24
37
|
if result["errors"]&.any?
|
25
|
-
result["errors"]
|
26
|
-
@executor.errors.concat(result["errors"])
|
38
|
+
@executor.errors.concat(format_errors!(result["errors"], op.path))
|
27
39
|
end
|
28
40
|
|
29
41
|
ops.map(&:step)
|
@@ -51,6 +63,16 @@ module GraphQL::Stitching
|
|
51
63
|
doc << op.selections
|
52
64
|
doc
|
53
65
|
end
|
66
|
+
|
67
|
+
# Format response errors without a document location (because it won't match the request doc),
|
68
|
+
# and prepend any insertion path for the scope into error paths.
|
69
|
+
def format_errors!(errors, path)
|
70
|
+
errors.each do |err|
|
71
|
+
err.delete("locations")
|
72
|
+
err["path"].unshift(*path) if err["path"] && path.any?
|
73
|
+
end
|
74
|
+
errors
|
75
|
+
end
|
54
76
|
end
|
55
77
|
end
|
56
78
|
end
|
@@ -276,19 +276,21 @@ module GraphQL
|
|
276
276
|
routes.each_value do |route|
|
277
277
|
route.reduce(locale_selections) do |parent_selections, boundary|
|
278
278
|
# E.1) Add the key of each boundary query into the prior location's selection set.
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
279
|
+
if boundary.key
|
280
|
+
foreign_key = ExportSelection.key(boundary.key)
|
281
|
+
has_key = false
|
282
|
+
has_typename = false
|
283
|
+
|
284
|
+
parent_selections.each do |node|
|
285
|
+
next unless node.is_a?(GraphQL::Language::Nodes::Field)
|
286
|
+
has_key ||= node.alias == foreign_key
|
287
|
+
has_typename ||= node.alias == ExportSelection.typename_node.alias
|
288
|
+
end
|
289
|
+
|
290
|
+
parent_selections << ExportSelection.key_node(boundary.key) unless has_key
|
291
|
+
parent_selections << ExportSelection.typename_node unless has_typename
|
287
292
|
end
|
288
293
|
|
289
|
-
parent_selections << ExportSelection.key_node(boundary.key) unless has_key
|
290
|
-
parent_selections << ExportSelection.typename_node unless has_typename
|
291
|
-
|
292
294
|
# E.2) Add a planner step for each new entrypoint location.
|
293
295
|
add_step(
|
294
296
|
location: boundary.location,
|
@@ -296,7 +298,7 @@ module GraphQL
|
|
296
298
|
parent_type: parent_type,
|
297
299
|
selections: remote_selections_by_location[boundary.location] || [],
|
298
300
|
path: path.dup,
|
299
|
-
boundary: boundary,
|
301
|
+
boundary: boundary.key ? boundary : nil,
|
300
302
|
).selections
|
301
303
|
end
|
302
304
|
end
|
@@ -120,12 +120,13 @@ module GraphQL
|
|
120
120
|
end
|
121
121
|
|
122
122
|
# Validates the request using the combined supergraph schema.
|
123
|
+
# @return [Array<GraphQL::ExecutionError>] an array of static validation errors
|
123
124
|
def validate
|
124
125
|
result = @supergraph.static_validator.validate(@query)
|
125
126
|
result[:errors]
|
126
127
|
end
|
127
128
|
|
128
|
-
# Prepares the request for stitching by
|
129
|
+
# Prepares the request for stitching by inserting variable defaults and applying @skip/@include conditionals.
|
129
130
|
def prepare!
|
130
131
|
operation.variables.each do |v|
|
131
132
|
@variables[v.name] = v.default_value if @variables[v.name].nil? && !v.default_value.nil?
|
@@ -143,7 +144,17 @@ module GraphQL
|
|
143
144
|
self
|
144
145
|
end
|
145
146
|
|
146
|
-
# Gets and sets the query plan for the request. Assigned query plans may pull from cache
|
147
|
+
# Gets and sets the query plan for the request. Assigned query plans may pull from a cache,
|
148
|
+
# which is useful for redundant GraphQL documents (commonly sent by frontend clients).
|
149
|
+
# ```ruby
|
150
|
+
# if cached_plan = $cache.get(request.digest)
|
151
|
+
# plan = GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
|
152
|
+
# request.plan(plan)
|
153
|
+
# else
|
154
|
+
# plan = request.plan
|
155
|
+
# $cache.set(request.digest, JSON.generate(plan.as_json))
|
156
|
+
# end
|
157
|
+
# ```
|
147
158
|
# @param new_plan [Plan, nil] a cached query plan for the request.
|
148
159
|
# @return [Plan] query plan for the request.
|
149
160
|
def plan(new_plan = nil)
|
@@ -245,7 +245,11 @@ module GraphQL
|
|
245
245
|
# ("Type") => ["id", ...]
|
246
246
|
def possible_keys_for_type(type_name)
|
247
247
|
@possible_keys_by_type[type_name] ||= begin
|
248
|
-
@
|
248
|
+
if type_name == @schema.query.graphql_name
|
249
|
+
GraphQL::Stitching::EMPTY_ARRAY
|
250
|
+
else
|
251
|
+
@boundaries[type_name].map(&:key).tap(&:uniq!)
|
252
|
+
end
|
249
253
|
end
|
250
254
|
end
|
251
255
|
|
@@ -262,16 +266,25 @@ module GraphQL
|
|
262
266
|
# For a given type, route from one origin location to one or more remote locations
|
263
267
|
# used to connect a partial type across locations via boundary queries
|
264
268
|
def route_type_to_locations(type_name, start_location, goal_locations)
|
265
|
-
|
269
|
+
key_count = possible_keys_for_type(type_name).length
|
270
|
+
|
271
|
+
if key_count.zero?
|
272
|
+
# nested root scopes have no boundary keys and just return a location
|
273
|
+
goal_locations.each_with_object({}) do |goal_location, memo|
|
274
|
+
memo[goal_location] = [Boundary.new(location: goal_location)]
|
275
|
+
end
|
276
|
+
|
277
|
+
elsif key_count > 1
|
266
278
|
# multiple keys use an A* search to traverse intermediary locations
|
267
|
-
|
268
|
-
end
|
279
|
+
route_type_to_locations_via_search(type_name, start_location, goal_locations)
|
269
280
|
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
281
|
+
else
|
282
|
+
# types with a single key attribute must all be within a single hop of each other,
|
283
|
+
# so can use a simple match to collect boundaries for the goal locations.
|
284
|
+
@boundaries[type_name].each_with_object({}) do |boundary, memo|
|
285
|
+
if goal_locations.include?(boundary.location)
|
286
|
+
memo[boundary.location] = [boundary]
|
287
|
+
end
|
275
288
|
end
|
276
289
|
end
|
277
290
|
end
|
@@ -54,7 +54,7 @@ module GraphQL
|
|
54
54
|
return parent_type.possible_types if parent_type.kind.union?
|
55
55
|
|
56
56
|
result = []
|
57
|
-
schema.types.
|
57
|
+
schema.types.each_value do |type|
|
58
58
|
next unless type <= GraphQL::Schema::Interface && type != parent_type
|
59
59
|
next unless type.interfaces.include?(parent_type)
|
60
60
|
result << type
|
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: 1.2.
|
4
|
+
version: 1.2.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg MacWilliam
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-04-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|
@@ -82,13 +82,12 @@ files:
|
|
82
82
|
- docs/README.md
|
83
83
|
- docs/client.md
|
84
84
|
- docs/composer.md
|
85
|
-
- docs/
|
85
|
+
- docs/federation_entities.md
|
86
86
|
- docs/http_executable.md
|
87
87
|
- docs/images/library.png
|
88
88
|
- docs/images/merging.png
|
89
89
|
- docs/images/stitching.png
|
90
90
|
- docs/mechanics.md
|
91
|
-
- docs/planner.md
|
92
91
|
- docs/request.md
|
93
92
|
- docs/supergraph.md
|
94
93
|
- examples/file_uploads/Gemfile
|
@@ -106,6 +105,9 @@ files:
|
|
106
105
|
- examples/merged_types/remote1.rb
|
107
106
|
- examples/merged_types/remote2.rb
|
108
107
|
- gemfiles/graphql_1.13.9.gemfile
|
108
|
+
- gemfiles/graphql_2.0.0.gemfile
|
109
|
+
- gemfiles/graphql_2.1.0.gemfile
|
110
|
+
- gemfiles/graphql_2.2.0.gemfile
|
109
111
|
- graphql-stitching.gemspec
|
110
112
|
- lib/graphql/stitching.rb
|
111
113
|
- lib/graphql/stitching/boundary.rb
|
data/docs/executor.md
DELETED
@@ -1,68 +0,0 @@
|
|
1
|
-
## GraphQL::Stitching::Executor
|
2
|
-
|
3
|
-
An `Executor` accepts a [`Supergraph`](./supergraph.md), a [query plan hash](./planner.md), and optional request variables. It handles executing requests and merging results collected from across graph locations.
|
4
|
-
|
5
|
-
```ruby
|
6
|
-
query = <<~GRAPHQL
|
7
|
-
query MyQuery($id: ID!) {
|
8
|
-
product(id:$id) {
|
9
|
-
title
|
10
|
-
brands { name }
|
11
|
-
}
|
12
|
-
}
|
13
|
-
GRAPHQL
|
14
|
-
|
15
|
-
request = GraphQL::Stitching::Request.new(
|
16
|
-
supergraph,
|
17
|
-
query,
|
18
|
-
variables: { "id" => "123" },
|
19
|
-
operation_name: "MyQuery",
|
20
|
-
context: { ... },
|
21
|
-
)
|
22
|
-
|
23
|
-
# Via Request:
|
24
|
-
result = request.execute
|
25
|
-
|
26
|
-
# Via Executor:
|
27
|
-
result = GraphQL::Stitching::Executor.new(request).perform
|
28
|
-
```
|
29
|
-
|
30
|
-
### Raw results
|
31
|
-
|
32
|
-
By default, execution results are always returned with document shaping (stitching additions removed, missing fields added, null bubbling applied). You may access the raw execution result by calling the `perform` method with a `raw: true` argument:
|
33
|
-
|
34
|
-
```ruby
|
35
|
-
# get the raw result without shaping using either form:
|
36
|
-
raw_result = request.execute(raw: true)
|
37
|
-
raw_result = GraphQL::Stitching::Executor.new(request).perform(raw: true)
|
38
|
-
```
|
39
|
-
|
40
|
-
The raw result will contain many irregularities from the stitching process, however may be insightful when debugging inconsistencies in results:
|
41
|
-
|
42
|
-
```ruby
|
43
|
-
{
|
44
|
-
"data" => {
|
45
|
-
"product" => {
|
46
|
-
"upc" => "1",
|
47
|
-
"_export_upc" => "1",
|
48
|
-
"_export_typename" => "Product",
|
49
|
-
"name" => "iPhone",
|
50
|
-
"price" => nil,
|
51
|
-
}
|
52
|
-
}
|
53
|
-
}
|
54
|
-
```
|
55
|
-
|
56
|
-
### Batching
|
57
|
-
|
58
|
-
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):
|
59
|
-
|
60
|
-
```graphql
|
61
|
-
query MyOperation_2_3($lang:String!,$currency:Currency!){
|
62
|
-
_0_result: storefronts(ids:["7","8"]) { name(lang:$lang) }
|
63
|
-
_1_0_result: product(upc:"abc") { price(currency:$currency) }
|
64
|
-
_1_1_result: product(upc:"xyz") { price(currency:$currency) }
|
65
|
-
}
|
66
|
-
```
|
67
|
-
|
68
|
-
All told, the executor will make one request per location per generation of data. Generations started on separate forks of the resolution tree will be resolved independently.
|
data/docs/planner.md
DELETED
@@ -1,45 +0,0 @@
|
|
1
|
-
## GraphQL::Stitching::Planner
|
2
|
-
|
3
|
-
A `Planner` generates a query plan for a given [`Supergraph`](./supergraph.md) and [`Request`](./request.md). The generated plan breaks down all the discrete GraphQL operations that must be delegated across locations and their sequencing.
|
4
|
-
|
5
|
-
```ruby
|
6
|
-
document = <<~GRAPHQL
|
7
|
-
query MyQuery($id: ID!) {
|
8
|
-
product(id:$id) {
|
9
|
-
title
|
10
|
-
brands { name }
|
11
|
-
}
|
12
|
-
}
|
13
|
-
GRAPHQL
|
14
|
-
|
15
|
-
request = GraphQL::Stitching::Request.new(
|
16
|
-
supergraph,
|
17
|
-
document,
|
18
|
-
variables: { "id" => "1" },
|
19
|
-
operation_name: "MyQuery",
|
20
|
-
).prepare!
|
21
|
-
|
22
|
-
# Via Request:
|
23
|
-
plan = request.plan
|
24
|
-
|
25
|
-
# Via Planner:
|
26
|
-
plan = GraphQL::Stitching::Planner.new(request).perform
|
27
|
-
```
|
28
|
-
|
29
|
-
### Caching
|
30
|
-
|
31
|
-
Plans are designed to be cacheable. This is very useful for redundant GraphQL documents (commonly sent by frontend clients) where there's no sense in planning every request individually. It's far more efficient to generate a plan once and cache it, then simply retreive the plan and execute it for future requests.
|
32
|
-
|
33
|
-
```ruby
|
34
|
-
cached_plan = $cache.get(request.digest)
|
35
|
-
|
36
|
-
if cached_plan
|
37
|
-
plan = GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
|
38
|
-
request.plan(plan)
|
39
|
-
else
|
40
|
-
plan = request.plan
|
41
|
-
$cache.set(request.digest, JSON.generate(plan.as_json))
|
42
|
-
end
|
43
|
-
|
44
|
-
# execute the plan...
|
45
|
-
```
|