graphql-stitching 1.0.6 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/docs/client.md +10 -12
- data/docs/request.md +14 -10
- data/docs/supergraph.md +7 -11
- data/lib/graphql/stitching/client.rb +3 -3
- data/lib/graphql/stitching/composer.rb +1 -1
- data/lib/graphql/stitching/planner.rb +1 -1
- data/lib/graphql/stitching/supergraph.rb +132 -32
- 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: 66c85693da3a07eb7b3ab81fe76b69ce095aaffa8b9cab68a1f34b86e20963ba
|
4
|
+
data.tar.gz: f1e8bbbe3ffdaf23189f219cad6fe604203527e12911a0609fb1711375effe58
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a86ac6f20dda8dc5ae68d548d85d9c4482856c56e86a07cee9ff5689ba95cf73f19dadd80d6f711a55a0b9e25796522b1b1feb4c7d70f7fa499a0f38beb0d24a
|
7
|
+
data.tar.gz: 61b2d6f21dc8001fab5e6d8c27d3cc04582fc506daa17f45354475cb9ef6812a355ec5819257d9d582d7f4a312b85b10b539322e3fa14b73a255376a75687658
|
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/request.md
CHANGED
@@ -3,18 +3,22 @@
|
|
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
|
-
request.document # parsed AST via GraphQL.parse
|
10
|
-
request.variables # user-submitted variables
|
11
|
-
request.string # normalized printed document string
|
12
|
-
request.digest # SHA digest of the normalized document string
|
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(source, variables: { "id" => "1" }, operation_name: "FetchMovie")
|
16
8
|
```
|
17
9
|
|
10
|
+
A `Request` provides the following information:
|
11
|
+
|
12
|
+
- `req.document`: parsed AST of the GraphQL source
|
13
|
+
- `req.variables`: a hash of user-submitted variables
|
14
|
+
- `req.string`: the original GraphQL source string, or printed document
|
15
|
+
- `req.digest`: a SHA2 of the request string
|
16
|
+
- `req.normalized_string`: printed document string with consistent whitespace
|
17
|
+
- `req.normalized_digest`: a SHA2 of the normalized string
|
18
|
+
- `req.operation`: the operation definition selected for the request
|
19
|
+
- `req.variable_definitions`: a mapping of variable names to their type definitions
|
20
|
+
- `req.fragment_definitions`: a mapping of fragment names to their fragment definitions
|
21
|
+
|
18
22
|
### Preparing requests
|
19
23
|
|
20
24
|
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,
|
@@ -52,7 +52,7 @@ module GraphQL
|
|
52
52
|
rescue GraphQL::ParseError, GraphQL::ExecutionError => e
|
53
53
|
error_result([e])
|
54
54
|
rescue StandardError => e
|
55
|
-
custom_message = @on_error.call(
|
55
|
+
custom_message = @on_error.call(request, e) if @on_error
|
56
56
|
error_result([{ "message" => custom_message || "An unexpected error occured." }])
|
57
57
|
end
|
58
58
|
|
@@ -75,14 +75,14 @@ module GraphQL
|
|
75
75
|
|
76
76
|
def fetch_plan(request)
|
77
77
|
if @on_cache_read
|
78
|
-
cached_plan = @on_cache_read.call(request
|
78
|
+
cached_plan = @on_cache_read.call(request)
|
79
79
|
return GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan)) if cached_plan
|
80
80
|
end
|
81
81
|
|
82
82
|
plan = yield
|
83
83
|
|
84
84
|
if @on_cache_write
|
85
|
-
@on_cache_write.call(request
|
85
|
+
@on_cache_write.call(request, JSON.generate(plan.as_json))
|
86
86
|
end
|
87
87
|
|
88
88
|
plan
|
@@ -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
|
@@ -48,32 +103,77 @@ module GraphQL
|
|
48
103
|
next unless type.kind.fields?
|
49
104
|
|
50
105
|
memo[type_name] = type.fields.keys.each_with_object({}) do |field_name, m|
|
51
|
-
m[field_name] = [
|
106
|
+
m[field_name] = [SUPERGRAPH_LOCATION]
|
52
107
|
end
|
53
108
|
end.freeze
|
54
109
|
|
55
110
|
# validate and normalize executable references
|
56
|
-
@executables = executables.each_with_object({
|
111
|
+
@executables = executables.each_with_object({ SUPERGRAPH_LOCATION => @schema }) do |(location, executable), memo|
|
57
112
|
if self.class.validate_executable!(location, executable)
|
58
113
|
memo[location.to_s] = executable
|
59
114
|
end
|
60
115
|
end.freeze
|
61
116
|
end
|
62
117
|
|
118
|
+
def to_definition
|
119
|
+
if @schema.directives[ResolverDirective.graphql_name].nil?
|
120
|
+
@schema.directive(ResolverDirective)
|
121
|
+
end
|
122
|
+
if @schema.directives[SourceDirective.graphql_name].nil?
|
123
|
+
@schema.directive(SourceDirective)
|
124
|
+
end
|
125
|
+
|
126
|
+
@schema.types.each do |type_name, type|
|
127
|
+
if boundaries_for_type = @boundaries.dig(type_name)
|
128
|
+
boundaries_for_type.each do |boundary|
|
129
|
+
existing = type.directives.find do |d|
|
130
|
+
kwargs = d.arguments.keyword_arguments
|
131
|
+
d.graphql_name == ResolverDirective.graphql_name &&
|
132
|
+
kwargs[:location] == boundary.location &&
|
133
|
+
kwargs[:key] == boundary.key &&
|
134
|
+
kwargs[:field] == boundary.field &&
|
135
|
+
kwargs[:arg] == boundary.arg &&
|
136
|
+
kwargs.fetch(:list, false) == boundary.list &&
|
137
|
+
kwargs.fetch(:federation, false) == boundary.federation
|
138
|
+
end
|
139
|
+
|
140
|
+
type.directive(ResolverDirective, **{
|
141
|
+
location: boundary.location,
|
142
|
+
key: boundary.key,
|
143
|
+
field: boundary.field,
|
144
|
+
arg: boundary.arg,
|
145
|
+
list: boundary.list || nil,
|
146
|
+
federation: boundary.federation || nil,
|
147
|
+
}.tap(&:compact!)) if existing.nil?
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
next unless type.kind.fields?
|
152
|
+
|
153
|
+
type.fields.each do |field_name, field|
|
154
|
+
locations_for_field = @locations_by_type_and_field.dig(type_name, field_name)
|
155
|
+
next if locations_for_field.nil?
|
156
|
+
|
157
|
+
locations_for_field.each do |location|
|
158
|
+
existing = field.directives.find do |d|
|
159
|
+
d.graphql_name == SourceDirective.graphql_name &&
|
160
|
+
d.arguments.keyword_arguments[:location] == location
|
161
|
+
end
|
162
|
+
|
163
|
+
field.directive(SourceDirective, location: location) if existing.nil?
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
@schema.to_definition
|
169
|
+
end
|
170
|
+
|
63
171
|
def fields
|
64
172
|
@locations_by_type_and_field.reject { |k, _v| memoized_introspection_types[k] }
|
65
173
|
end
|
66
174
|
|
67
175
|
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
|
-
}
|
176
|
+
@executables.keys.reject { _1 == SUPERGRAPH_LOCATION }
|
77
177
|
end
|
78
178
|
|
79
179
|
def memoized_introspection_types
|
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.0
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg MacWilliam
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-12-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|