graphql-stitching 1.0.6 → 1.1.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 -2
- data/README.md +19 -19
- data/docs/client.md +10 -12
- data/docs/executor.md +3 -2
- data/docs/mechanics.md +54 -0
- data/docs/planner.md +5 -5
- data/docs/request.md +19 -10
- data/docs/supergraph.md +7 -11
- data/lib/graphql/stitching/client.rb +7 -3
- data/lib/graphql/stitching/composer.rb +7 -1
- data/lib/graphql/stitching/export_selection.rb +7 -1
- data/lib/graphql/stitching/planner.rb +1 -1
- data/lib/graphql/stitching/request.rb +9 -0
- data/lib/graphql/stitching/shaper.rb +1 -0
- data/lib/graphql/stitching/supergraph.rb +136 -32
- data/lib/graphql/stitching/util.rb +9 -0
- data/lib/graphql/stitching/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9cdcb3361e391ba9b6dd78aa38c308c08ddb6fde2720d15208e8dfb96c3006ea
|
4
|
+
data.tar.gz: 9f64b024bda3987d1c523cf52b49f0ca2dfd688ac4c89bbf18de30c88c098156
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b51bcfeaa20e9cdea6c2d4b4e472e8c23e13ec8af36e1b23852603d0329045af064e3ae738467a0823be0c9f545105acba001b708c207eb7f08d51ebf0791e1f
|
7
|
+
data.tar.gz: c529e74c88fc7193d7823d8c92a488b3e459b5ba9f88e8d62355ae2bd5e9eb0ed2b0d6f7fd14b6b8ed13ab1bb53a93bc5da49dcecd5deb92ee180fa70604c80b
|
data/.github/workflows/ci.yml
CHANGED
@@ -16,7 +16,7 @@ jobs:
|
|
16
16
|
ruby: 3.2
|
17
17
|
- gemfile: Gemfile
|
18
18
|
ruby: 3.1
|
19
|
-
- gemfile: gemfiles/graphql_1.13.gemfile
|
19
|
+
- gemfile: gemfiles/graphql_1.13.9.gemfile
|
20
20
|
ruby: 3.1
|
21
21
|
- gemfile: Gemfile
|
22
22
|
ruby: 2.7
|
@@ -30,6 +30,6 @@ jobs:
|
|
30
30
|
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
31
31
|
- name: Run tests
|
32
32
|
run: |
|
33
|
-
gem install bundler
|
33
|
+
gem install bundler -v 2.4.22
|
34
34
|
bundle install --jobs 4 --retry 3
|
35
35
|
bundle exec rake test
|
data/README.md
CHANGED
@@ -113,12 +113,12 @@ products_schema = <<~GRAPHQL
|
|
113
113
|
}
|
114
114
|
GRAPHQL
|
115
115
|
|
116
|
-
|
116
|
+
catalog_schema = <<~GRAPHQL
|
117
117
|
directive @stitch(key: String!) repeatable on FIELD_DEFINITION
|
118
118
|
|
119
119
|
type Product {
|
120
120
|
id: ID!
|
121
|
-
|
121
|
+
price: Float!
|
122
122
|
}
|
123
123
|
|
124
124
|
type Query {
|
@@ -131,7 +131,7 @@ client = GraphQL::Stitching::Client.new(locations: {
|
|
131
131
|
schema: GraphQL::Schema.from_definition(products_schema),
|
132
132
|
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
|
133
133
|
},
|
134
|
-
|
134
|
+
catalog: {
|
135
135
|
schema: GraphQL::Schema.from_definition(shipping_schema),
|
136
136
|
executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
|
137
137
|
},
|
@@ -151,9 +151,9 @@ type Query {
|
|
151
151
|
```
|
152
152
|
|
153
153
|
* 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.
|
154
|
-
* 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 later).
|
154
|
+
* 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).
|
155
155
|
|
156
|
-
Each location that provides a unique variant of a type must provide one stitching query
|
156
|
+
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:
|
157
157
|
|
158
158
|
```graphql
|
159
159
|
type Product {
|
@@ -165,7 +165,7 @@ The above representation of a `Product` type provides no unique data beyond a ke
|
|
165
165
|
|
166
166
|
#### List queries
|
167
167
|
|
168
|
-
It's okay ([even preferable](
|
168
|
+
It's okay ([even preferable](#batching) in most circumstances) to provide a list accessor as a stitching query. The only requirement is that both the field argument and return type must be lists, and the query results are expected to be a mapped set with `null` holding the position of missing results.
|
169
169
|
|
170
170
|
```graphql
|
171
171
|
type Query {
|
@@ -228,7 +228,7 @@ type Query {
|
|
228
228
|
}
|
229
229
|
```
|
230
230
|
|
231
|
-
The `@stitch` directive is also repeatable
|
231
|
+
The `@stitch` directive is also repeatable, allowing a single query to associate with multiple keys:
|
232
232
|
|
233
233
|
```graphql
|
234
234
|
type Product {
|
@@ -311,16 +311,15 @@ The [Apollo Federation specification](https://www.apollographql.com/docs/federat
|
|
311
311
|
The composer will automatcially detect and stitch schemas with an `_entities` query, for example:
|
312
312
|
|
313
313
|
```ruby
|
314
|
-
|
314
|
+
products_schema = <<~GRAPHQL
|
315
315
|
directive @key(fields: String!) repeatable on OBJECT
|
316
316
|
|
317
|
-
type
|
317
|
+
type Product @key(fields: "id") {
|
318
318
|
id: ID!
|
319
319
|
name: String!
|
320
|
-
address: String!
|
321
320
|
}
|
322
321
|
|
323
|
-
union _Entity =
|
322
|
+
union _Entity = Product
|
324
323
|
scalar _Any
|
325
324
|
|
326
325
|
type Query {
|
@@ -329,15 +328,15 @@ accounts_schema = <<~GRAPHQL
|
|
329
328
|
}
|
330
329
|
GRAPHQL
|
331
330
|
|
332
|
-
|
331
|
+
catalog_schema = <<~GRAPHQL
|
333
332
|
directive @key(fields: String!) repeatable on OBJECT
|
334
333
|
|
335
|
-
type
|
334
|
+
type Product @key(fields: "id") {
|
336
335
|
id: ID!
|
337
|
-
|
336
|
+
price: Float!
|
338
337
|
}
|
339
338
|
|
340
|
-
union _Entity =
|
339
|
+
union _Entity = Product
|
341
340
|
scalar _Any
|
342
341
|
|
343
342
|
type Query {
|
@@ -346,12 +345,12 @@ comments_schema = <<~GRAPHQL
|
|
346
345
|
GRAPHQL
|
347
346
|
|
348
347
|
client = GraphQL::Stitching::Client.new(locations: {
|
349
|
-
|
350
|
-
schema: GraphQL::Schema.from_definition(
|
348
|
+
products: {
|
349
|
+
schema: GraphQL::Schema.from_definition(products_schema),
|
351
350
|
executable: ...,
|
352
351
|
},
|
353
|
-
|
354
|
-
schema: GraphQL::Schema.from_definition(
|
352
|
+
catalog: {
|
353
|
+
schema: GraphQL::Schema.from_definition(catalog_schema),
|
355
354
|
executable: ...,
|
356
355
|
},
|
357
356
|
})
|
@@ -426,6 +425,7 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im
|
|
426
425
|
|
427
426
|
## Additional topics
|
428
427
|
|
428
|
+
- [Deploying a stitched schema](./docs/mechanics.md#deploying-a-stitched-schema)
|
429
429
|
- [Field selection routing](./docs/mechanics.md#field-selection-routing)
|
430
430
|
- [Root selection routing](./docs/mechanics.md#root-selection-routing)
|
431
431
|
- [Stitched errors](./docs/mechanics.md#stitched-errors)
|
data/docs/client.md
CHANGED
@@ -25,11 +25,9 @@ client = GraphQL::Stitching::Client.new(locations: {
|
|
25
25
|
Alternatively, you may pass a prebuilt `Supergraph` instance to the `Client` constructor. This is useful when [exporting and rehydrating](./supergraph.md#export-and-caching) supergraph instances, which bypasses the need for runtime composition:
|
26
26
|
|
27
27
|
```ruby
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
schema: exported_schema,
|
32
|
-
delegation_map: exported_mapping,
|
28
|
+
supergraph_sdl = File.read("precomposed_schema.graphql")
|
29
|
+
supergraph = GraphQL::Stitching::Supergraph.from_definition(
|
30
|
+
supergraph_sdl,
|
33
31
|
executables: { ... },
|
34
32
|
)
|
35
33
|
|
@@ -61,16 +59,16 @@ Arguments for the `execute` method include:
|
|
61
59
|
The client provides cache hooks to enable caching query plans across requests. Without caching, every request made to the client will be planned individually. With caching, a query may be planned once, cached, and then executed from cache for subsequent requests. Cache keys are a normalized digest of each query string.
|
62
60
|
|
63
61
|
```ruby
|
64
|
-
client.on_cache_read do |
|
65
|
-
$redis.get(
|
62
|
+
client.on_cache_read do |request|
|
63
|
+
$redis.get(request.digest) # << 3P code
|
66
64
|
end
|
67
65
|
|
68
|
-
client.on_cache_write do |
|
69
|
-
$redis.set(
|
66
|
+
client.on_cache_write do |request, payload|
|
67
|
+
$redis.set(request.digest, payload) # << 3P code
|
70
68
|
end
|
71
69
|
```
|
72
70
|
|
73
|
-
Note that inlined input data works against caching, so you should _avoid_
|
71
|
+
Note that inlined input data works against caching, so you should _avoid_ these input literals when possible:
|
74
72
|
|
75
73
|
```graphql
|
76
74
|
query {
|
@@ -93,11 +91,11 @@ query($id: ID!) {
|
|
93
91
|
The client also provides an error hook. Any program errors rescued during execution will be passed to the `on_error` handler, which can report on the error as needed and return a formatted error message for the client to add to the [GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) result.
|
94
92
|
|
95
93
|
```ruby
|
96
|
-
client.on_error do |
|
94
|
+
client.on_error do |request, err|
|
97
95
|
# log the error
|
98
96
|
Bugsnag.notify(err)
|
99
97
|
|
100
98
|
# return a formatted message for the public response
|
101
|
-
"Whoops, please contact support abount request '#{context[:request_id]}'"
|
99
|
+
"Whoops, please contact support abount request '#{request.context[:request_id]}'"
|
102
100
|
end
|
103
101
|
```
|
data/docs/executor.md
CHANGED
@@ -16,6 +16,7 @@ request = GraphQL::Stitching::Request.new(
|
|
16
16
|
query,
|
17
17
|
variables: { "id" => "123" },
|
18
18
|
operation_name: "MyQuery",
|
19
|
+
context: { ... },
|
19
20
|
)
|
20
21
|
|
21
22
|
plan = GraphQL::Stitching::Planner.new(
|
@@ -26,7 +27,7 @@ plan = GraphQL::Stitching::Planner.new(
|
|
26
27
|
result = GraphQL::Stitching::Executor.new(
|
27
28
|
supergraph: supergraph,
|
28
29
|
request: request,
|
29
|
-
plan: plan
|
30
|
+
plan: plan,
|
30
31
|
).perform
|
31
32
|
```
|
32
33
|
|
@@ -39,7 +40,7 @@ By default, execution results are always returned with document shaping (stitchi
|
|
39
40
|
raw_result = GraphQL::Stitching::Executor.new(
|
40
41
|
supergraph: supergraph,
|
41
42
|
request: request,
|
42
|
-
plan: plan
|
43
|
+
plan: plan,
|
43
44
|
).perform(raw: true)
|
44
45
|
```
|
45
46
|
|
data/docs/mechanics.md
CHANGED
@@ -1,5 +1,59 @@
|
|
1
1
|
## Schema Stitching, mechanics
|
2
2
|
|
3
|
+
### Deploying a stitched schema
|
4
|
+
|
5
|
+
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:
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
task :compose_graphql do
|
9
|
+
schema1_sdl = ... # load schema 1
|
10
|
+
schema2_sdl = ... # load schema 2
|
11
|
+
|
12
|
+
supergraph = GraphQL::Stitching::Composer.new.perform({
|
13
|
+
schema1: {
|
14
|
+
schema: GraphQL::Schema.from_definition(schema1_sdl)
|
15
|
+
},
|
16
|
+
schema2: {
|
17
|
+
schema: GraphQL::Schema.from_definition(schema2_sdl)
|
18
|
+
}
|
19
|
+
})
|
20
|
+
|
21
|
+
File.write("schema/supergraph.graphql", supergraph.to_definition)
|
22
|
+
puts "Schema composition was successful."
|
23
|
+
end
|
24
|
+
|
25
|
+
# bundle exec rake compose-graphql
|
26
|
+
```
|
27
|
+
|
28
|
+
Then at runtime, load the composed schema into a stitching client:
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
class GraphQlController
|
32
|
+
class < self
|
33
|
+
def client
|
34
|
+
@client ||= begin
|
35
|
+
supergraph_sdl = File.read("schema/supergraph.graphql")
|
36
|
+
supergraph = GraphQL::Stitching::Supergraph.from_definition(supergraph_sdl, executables: {
|
37
|
+
schema1: GraphQL::Stitching::HttpExecutable.new("http://localhost:3001/graphql"),
|
38
|
+
schema2: GraphQL::Stitching::HttpExecutable.new("http://localhost:3002/graphql"),
|
39
|
+
})
|
40
|
+
GraphQL::Stitching::Client.new(supergraph: supergraph)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def exec
|
46
|
+
self.class.client.execute(
|
47
|
+
params[:query],
|
48
|
+
variables: params[:variables],
|
49
|
+
operation_name: params[:operation_name]
|
50
|
+
)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
This process assures that composition always happens before deployment where failures can be detected. Hot reloading of the supergraph can also be accommodated by uploading the composed schema to a sync location (cloud storage, etc) that is polled by the application runtime. When the schema changes, load it into a new stitching client and swap that into the application.
|
56
|
+
|
3
57
|
### Field selection routing
|
4
58
|
|
5
59
|
Fields of a merged type may exist in multiple locations. For example, the `title` field below is provided by both locations:
|
data/docs/planner.md
CHANGED
@@ -32,15 +32,15 @@ Plans are designed to be cacheable. This is very useful for redundant GraphQL do
|
|
32
32
|
cached_plan = $redis.get(request.digest)
|
33
33
|
|
34
34
|
plan = if cached_plan
|
35
|
-
JSON.parse(cached_plan)
|
35
|
+
GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
|
36
36
|
else
|
37
|
-
|
37
|
+
plan = GraphQL::Stitching::Planner.new(
|
38
38
|
supergraph: supergraph,
|
39
39
|
request: request,
|
40
|
-
).perform
|
40
|
+
).perform
|
41
41
|
|
42
|
-
$redis.set(request.digest, JSON.generate(
|
43
|
-
|
42
|
+
$redis.set(request.digest, JSON.generate(plan.as_json))
|
43
|
+
plan
|
44
44
|
end
|
45
45
|
|
46
46
|
# execute the plan...
|
data/docs/request.md
CHANGED
@@ -3,18 +3,27 @@
|
|
3
3
|
A `Request` contains a parsed GraphQL document and variables, and handles the logistics of extracting the appropriate operation, variable definitions, and fragments. A `Request` should be built once per server request and passed through to other stitching components that utilize request information.
|
4
4
|
|
5
5
|
```ruby
|
6
|
-
|
7
|
-
request = GraphQL::Stitching::Request.new(
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
request.variable_definitions # a mapping of variable names to their type definitions
|
15
|
-
request.fragment_definitions # a mapping of fragment names to their fragment definitions
|
6
|
+
source = "query FetchMovie($id: ID!) { movie(id:$id) { id genre } }"
|
7
|
+
request = GraphQL::Stitching::Request.new(
|
8
|
+
source,
|
9
|
+
variables: { "id" => "1" },
|
10
|
+
operation_name: "FetchMovie",
|
11
|
+
context: { ... },
|
12
|
+
)
|
16
13
|
```
|
17
14
|
|
15
|
+
A `Request` provides the following information:
|
16
|
+
|
17
|
+
- `req.document`: parsed AST of the GraphQL source
|
18
|
+
- `req.variables`: a hash of user-submitted variables
|
19
|
+
- `req.string`: the original GraphQL source string, or printed document
|
20
|
+
- `req.digest`: a SHA2 of the request string
|
21
|
+
- `req.normalized_string`: printed document string with consistent whitespace
|
22
|
+
- `req.normalized_digest`: a SHA2 of the normalized string
|
23
|
+
- `req.operation`: the operation definition selected for the request
|
24
|
+
- `req.variable_definitions`: a mapping of variable names to their type definitions
|
25
|
+
- `req.fragment_definitions`: a mapping of fragment names to their fragment definitions
|
26
|
+
|
18
27
|
### Preparing requests
|
19
28
|
|
20
29
|
A request should be prepared for stitching using the `prepare!` method _after_ validations have been run:
|
data/docs/supergraph.md
CHANGED
@@ -4,29 +4,25 @@ A `Supergraph` is the singuar representation of a stitched graph. `Supergraph` i
|
|
4
4
|
|
5
5
|
### Export and caching
|
6
6
|
|
7
|
-
A Supergraph is designed to be composed, cached, and restored. Calling
|
7
|
+
A Supergraph is designed to be composed, cached, and restored. Calling `to_definition` will return an SDL (Schema Definition Language) print of the combined graph schema with delegation mapping directives. This pre-composed schema can be persisted in any raw format that suits your stack:
|
8
8
|
|
9
9
|
```ruby
|
10
|
-
supergraph_sdl
|
10
|
+
supergraph_sdl = supergraph.to_definition
|
11
11
|
|
12
|
-
# stash
|
12
|
+
# stash this composed schema in a cache...
|
13
13
|
$redis.set("cached_supergraph_sdl", supergraph_sdl)
|
14
|
-
$redis.set("cached_delegation_map", JSON.generate(delegation_map))
|
15
14
|
|
16
|
-
# or, write the
|
15
|
+
# or, write the composed schema as a file into your repo...
|
17
16
|
File.write("supergraph/schema.graphql", supergraph_sdl)
|
18
|
-
File.write("supergraph/delegation_map.json", JSON.generate(delegation_map))
|
19
17
|
```
|
20
18
|
|
21
|
-
To restore a Supergraph, call `
|
19
|
+
To restore a Supergraph, call `from_definition` providing the cached SDL string and a hash of executables keyed by their location names:
|
22
20
|
|
23
21
|
```ruby
|
24
22
|
supergraph_sdl = $redis.get("cached_supergraph_sdl")
|
25
|
-
delegation_map = JSON.parse($redis.get("cached_delegation_map"))
|
26
23
|
|
27
|
-
supergraph = GraphQL::Stitching::Supergraph.
|
28
|
-
|
29
|
-
delegation_map: delegation_map,
|
24
|
+
supergraph = GraphQL::Stitching::Supergraph.from_definition(
|
25
|
+
supergraph_sdl,
|
30
26
|
executables: {
|
31
27
|
my_remote: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3000"),
|
32
28
|
my_local: MyLocalSchema,
|
@@ -20,6 +20,10 @@ module GraphQL
|
|
20
20
|
composer ||= GraphQL::Stitching::Composer.new
|
21
21
|
composer.perform(locations)
|
22
22
|
end
|
23
|
+
|
24
|
+
@on_cache_read = nil
|
25
|
+
@on_cache_write = nil
|
26
|
+
@on_error = nil
|
23
27
|
end
|
24
28
|
|
25
29
|
def execute(query:, variables: nil, operation_name: nil, context: nil, validate: true)
|
@@ -52,7 +56,7 @@ module GraphQL
|
|
52
56
|
rescue GraphQL::ParseError, GraphQL::ExecutionError => e
|
53
57
|
error_result([e])
|
54
58
|
rescue StandardError => e
|
55
|
-
custom_message = @on_error.call(
|
59
|
+
custom_message = @on_error.call(request, e) if @on_error
|
56
60
|
error_result([{ "message" => custom_message || "An unexpected error occured." }])
|
57
61
|
end
|
58
62
|
|
@@ -75,14 +79,14 @@ module GraphQL
|
|
75
79
|
|
76
80
|
def fetch_plan(request)
|
77
81
|
if @on_cache_read
|
78
|
-
cached_plan = @on_cache_read.call(request
|
82
|
+
cached_plan = @on_cache_read.call(request)
|
79
83
|
return GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan)) if cached_plan
|
80
84
|
end
|
81
85
|
|
82
86
|
plan = yield
|
83
87
|
|
84
88
|
if @on_cache_write
|
85
|
-
@on_cache_write.call(request
|
89
|
+
@on_cache_write.call(request, JSON.generate(plan.as_json))
|
86
90
|
end
|
87
91
|
|
88
92
|
plan
|
@@ -39,6 +39,12 @@ module GraphQL
|
|
39
39
|
@directive_kwarg_merger = directive_kwarg_merger || BASIC_VALUE_MERGER
|
40
40
|
@root_field_location_selector = root_field_location_selector || BASIC_ROOT_FIELD_LOCATION_SELECTOR
|
41
41
|
@stitch_directives = {}
|
42
|
+
|
43
|
+
@field_map = nil
|
44
|
+
@boundary_map = nil
|
45
|
+
@mapped_type_names = nil
|
46
|
+
@candidate_directives_by_name_and_location = nil
|
47
|
+
@schema_directives = nil
|
42
48
|
end
|
43
49
|
|
44
50
|
def perform(locations_input)
|
@@ -541,7 +547,7 @@ module GraphQL
|
|
541
547
|
field: field_candidate.name,
|
542
548
|
arg: argument_name,
|
543
549
|
list: boundary_structure.first.list?,
|
544
|
-
federation: kwargs[:federation],
|
550
|
+
federation: kwargs[:federation] || false,
|
545
551
|
)
|
546
552
|
end
|
547
553
|
end
|
@@ -8,6 +8,8 @@ module GraphQL
|
|
8
8
|
EXPORT_PREFIX = "_export_"
|
9
9
|
|
10
10
|
class << self
|
11
|
+
@typename_node = nil
|
12
|
+
|
11
13
|
def key?(name)
|
12
14
|
return false unless name
|
13
15
|
|
@@ -19,7 +21,11 @@ module GraphQL
|
|
19
21
|
end
|
20
22
|
|
21
23
|
def key_node(field_name)
|
22
|
-
|
24
|
+
if Util.graphql_version?(2, 2)
|
25
|
+
GraphQL::Language::Nodes::Field.new(field_alias: key(field_name), name: field_name)
|
26
|
+
else
|
27
|
+
GraphQL::Language::Nodes::Field.new(alias: key(field_name), name: field_name)
|
28
|
+
end
|
23
29
|
end
|
24
30
|
|
25
31
|
def typename_node
|
@@ -9,6 +9,15 @@ module GraphQL
|
|
9
9
|
attr_reader :document, :variables, :operation_name, :context
|
10
10
|
|
11
11
|
def initialize(document, operation_name: nil, variables: nil, context: nil)
|
12
|
+
@string = nil
|
13
|
+
@digest = nil
|
14
|
+
@normalized_string = nil
|
15
|
+
@normalized_digest = nil
|
16
|
+
@operation = nil
|
17
|
+
@operation_directives = nil
|
18
|
+
@variable_definitions = nil
|
19
|
+
@fragment_definitions = nil
|
20
|
+
|
12
21
|
@document = if document.is_a?(String)
|
13
22
|
@string = document
|
14
23
|
GraphQL.parse(document)
|
@@ -3,34 +3,89 @@
|
|
3
3
|
module GraphQL
|
4
4
|
module Stitching
|
5
5
|
class Supergraph
|
6
|
-
|
6
|
+
SUPERGRAPH_LOCATION = "__super"
|
7
|
+
|
8
|
+
class ResolverDirective < GraphQL::Schema::Directive
|
9
|
+
graphql_name "resolver"
|
10
|
+
locations OBJECT, INTERFACE, UNION
|
11
|
+
argument :location, String, required: true
|
12
|
+
argument :key, String, required: true
|
13
|
+
argument :field, String, required: true
|
14
|
+
argument :arg, String, required: true
|
15
|
+
argument :list, Boolean, required: false
|
16
|
+
argument :federation, Boolean, required: false
|
17
|
+
repeatable true
|
18
|
+
end
|
7
19
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
20
|
+
class SourceDirective < GraphQL::Schema::Directive
|
21
|
+
graphql_name "source"
|
22
|
+
locations FIELD_DEFINITION
|
23
|
+
argument :location, String, required: true
|
24
|
+
repeatable true
|
12
25
|
end
|
13
26
|
|
14
|
-
|
15
|
-
|
27
|
+
class << self
|
28
|
+
def validate_executable!(location, executable)
|
29
|
+
return true if executable.is_a?(Class) && executable <= GraphQL::Schema
|
30
|
+
return true if executable && executable.respond_to?(:call)
|
31
|
+
raise StitchingError, "Invalid executable provided for location `#{location}`."
|
32
|
+
end
|
33
|
+
|
34
|
+
def from_definition(schema, executables:)
|
35
|
+
schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
|
36
|
+
field_map = {}
|
37
|
+
boundary_map = {}
|
38
|
+
possible_locations = {}
|
39
|
+
introspection_types = schema.introspection_system.types.keys
|
40
|
+
|
41
|
+
schema.types.each do |type_name, type|
|
42
|
+
next if introspection_types.include?(type_name)
|
43
|
+
|
44
|
+
type.directives.each do |directive|
|
45
|
+
next unless directive.graphql_name == ResolverDirective.graphql_name
|
46
|
+
|
47
|
+
kwargs = directive.arguments.keyword_arguments
|
48
|
+
boundary_map[type_name] ||= []
|
49
|
+
boundary_map[type_name] << Boundary.new(
|
50
|
+
type_name: type_name,
|
51
|
+
location: kwargs[:location],
|
52
|
+
key: kwargs[:key],
|
53
|
+
field: kwargs[:field],
|
54
|
+
arg: kwargs[:arg],
|
55
|
+
list: kwargs[:list] || false,
|
56
|
+
federation: kwargs[:federation] || false,
|
57
|
+
)
|
58
|
+
end
|
59
|
+
|
60
|
+
next unless type.kind.fields?
|
16
61
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
62
|
+
type.fields.each do |field_name, field|
|
63
|
+
field.directives.each do |d|
|
64
|
+
next unless d.graphql_name == SourceDirective.graphql_name
|
65
|
+
|
66
|
+
location = d.arguments.keyword_arguments[:location]
|
67
|
+
field_map[type_name] ||= {}
|
68
|
+
field_map[type_name][field_name] ||= []
|
69
|
+
field_map[type_name][field_name] << location
|
70
|
+
possible_locations[location] = true
|
71
|
+
end
|
72
|
+
end
|
21
73
|
end
|
22
|
-
end
|
23
74
|
|
24
|
-
|
25
|
-
|
26
|
-
|
75
|
+
executables = possible_locations.keys.each_with_object({}) do |location, memo|
|
76
|
+
executable = executables[location] || executables[location.to_sym]
|
77
|
+
if validate_executable!(location, executable)
|
78
|
+
memo[location] = executable
|
79
|
+
end
|
80
|
+
end
|
27
81
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
82
|
+
new(
|
83
|
+
schema: schema,
|
84
|
+
fields: field_map,
|
85
|
+
boundaries: boundary_map,
|
86
|
+
executables: executables,
|
87
|
+
)
|
88
|
+
end
|
34
89
|
end
|
35
90
|
|
36
91
|
attr_reader :schema, :boundaries, :locations_by_type_and_field, :executables
|
@@ -42,38 +97,87 @@ module GraphQL
|
|
42
97
|
@possible_keys_by_type_and_location = {}
|
43
98
|
@memoized_schema_possible_types = {}
|
44
99
|
@memoized_schema_fields = {}
|
100
|
+
@memoized_introspection_types = nil
|
101
|
+
@memoized_schema_types = nil
|
102
|
+
@fields_by_type_and_location = nil
|
103
|
+
@locations_by_type = nil
|
45
104
|
|
46
105
|
# add introspection types into the fields mapping
|
47
106
|
@locations_by_type_and_field = memoized_introspection_types.each_with_object(fields) do |(type_name, type), memo|
|
48
107
|
next unless type.kind.fields?
|
49
108
|
|
50
109
|
memo[type_name] = type.fields.keys.each_with_object({}) do |field_name, m|
|
51
|
-
m[field_name] = [
|
110
|
+
m[field_name] = [SUPERGRAPH_LOCATION]
|
52
111
|
end
|
53
112
|
end.freeze
|
54
113
|
|
55
114
|
# validate and normalize executable references
|
56
|
-
@executables = executables.each_with_object({
|
115
|
+
@executables = executables.each_with_object({ SUPERGRAPH_LOCATION => @schema }) do |(location, executable), memo|
|
57
116
|
if self.class.validate_executable!(location, executable)
|
58
117
|
memo[location.to_s] = executable
|
59
118
|
end
|
60
119
|
end.freeze
|
61
120
|
end
|
62
121
|
|
122
|
+
def to_definition
|
123
|
+
if @schema.directives[ResolverDirective.graphql_name].nil?
|
124
|
+
@schema.directive(ResolverDirective)
|
125
|
+
end
|
126
|
+
if @schema.directives[SourceDirective.graphql_name].nil?
|
127
|
+
@schema.directive(SourceDirective)
|
128
|
+
end
|
129
|
+
|
130
|
+
@schema.types.each do |type_name, type|
|
131
|
+
if boundaries_for_type = @boundaries.dig(type_name)
|
132
|
+
boundaries_for_type.each do |boundary|
|
133
|
+
existing = type.directives.find do |d|
|
134
|
+
kwargs = d.arguments.keyword_arguments
|
135
|
+
d.graphql_name == ResolverDirective.graphql_name &&
|
136
|
+
kwargs[:location] == boundary.location &&
|
137
|
+
kwargs[:key] == boundary.key &&
|
138
|
+
kwargs[:field] == boundary.field &&
|
139
|
+
kwargs[:arg] == boundary.arg &&
|
140
|
+
kwargs.fetch(:list, false) == boundary.list &&
|
141
|
+
kwargs.fetch(:federation, false) == boundary.federation
|
142
|
+
end
|
143
|
+
|
144
|
+
type.directive(ResolverDirective, **{
|
145
|
+
location: boundary.location,
|
146
|
+
key: boundary.key,
|
147
|
+
field: boundary.field,
|
148
|
+
arg: boundary.arg,
|
149
|
+
list: boundary.list || nil,
|
150
|
+
federation: boundary.federation || nil,
|
151
|
+
}.tap(&:compact!)) if existing.nil?
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
next unless type.kind.fields?
|
156
|
+
|
157
|
+
type.fields.each do |field_name, field|
|
158
|
+
locations_for_field = @locations_by_type_and_field.dig(type_name, field_name)
|
159
|
+
next if locations_for_field.nil?
|
160
|
+
|
161
|
+
locations_for_field.each do |location|
|
162
|
+
existing = field.directives.find do |d|
|
163
|
+
d.graphql_name == SourceDirective.graphql_name &&
|
164
|
+
d.arguments.keyword_arguments[:location] == location
|
165
|
+
end
|
166
|
+
|
167
|
+
field.directive(SourceDirective, location: location) if existing.nil?
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
@schema.to_definition
|
173
|
+
end
|
174
|
+
|
63
175
|
def fields
|
64
176
|
@locations_by_type_and_field.reject { |k, _v| memoized_introspection_types[k] }
|
65
177
|
end
|
66
178
|
|
67
179
|
def locations
|
68
|
-
@executables.keys.reject { _1 ==
|
69
|
-
end
|
70
|
-
|
71
|
-
def export
|
72
|
-
return GraphQL::Schema::Printer.print_schema(@schema), {
|
73
|
-
"locations" => locations,
|
74
|
-
"fields" => fields,
|
75
|
-
"boundaries" => @boundaries.map { |k, b| [k, b.map(&:as_json)] }.to_h,
|
76
|
-
}
|
180
|
+
@executables.keys.reject { _1 == SUPERGRAPH_LOCATION }
|
77
181
|
end
|
78
182
|
|
79
183
|
def memoized_introspection_types
|
@@ -12,7 +12,16 @@ module GraphQL
|
|
12
12
|
end
|
13
13
|
end
|
14
14
|
|
15
|
+
GRAPHQL_VERSION = GraphQL::VERSION.split(".").map(&:to_i).freeze
|
16
|
+
|
15
17
|
class << self
|
18
|
+
def graphql_version?(major, minor = nil, patch = nil)
|
19
|
+
result = GRAPHQL_VERSION[0] >= major
|
20
|
+
result &&= GRAPHQL_VERSION[1] >= minor if minor
|
21
|
+
result &&= GRAPHQL_VERSION[2] >= patch if patch
|
22
|
+
result
|
23
|
+
end
|
24
|
+
|
16
25
|
# specifies if a type is a primitive leaf value
|
17
26
|
def is_leaf_type?(type)
|
18
27
|
type.kind.scalar? || type.kind.enum?
|
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.
|
4
|
+
version: 1.1.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-12-26 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|