graphql-stitching 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10934c3d783de823cba55bfe33b361f7ffb6ead4a113a415199f660db97790be
4
- data.tar.gz: b310d93e9493319363924fd4eaca3a828e110479a91906394b35df5cb2b7d2d7
3
+ metadata.gz: 66715354401a5907a302fcecc6e5a285344e31357060f093f5804e6c27dab1d2
4
+ data.tar.gz: c51d4046ad4c6101edec196d97b7e44f083640ad739c81f9f27612067646c6fb
5
5
  SHA512:
6
- metadata.gz: d998d78a25d271bd41b70df55f6d9e5442e2a5ce38c05c30ed281cc34a28e88aa41a0f923f07e306e1c472143e4a164ad109f1297a8b0af0c621769e38b92f30
7
- data.tar.gz: 9aee659c12c236428062e2621d189b6a79518e31ed8fed60c995a27fb18fe746479c554edcb0bc9e091ed39e412185c63da0223d7acee21b304bd6707c630298
6
+ metadata.gz: 641b5c2903c1d07b00d794ac445aadc96bb7be640ef0e5b2607842e8c5d296eebfd39ddde3e75a97978e2533994a0c9d0b587c68cfd76c8d2c5cde4fb0dc302a
7
+ data.tar.gz: 916a94f76a1d453cf24ea7e382fe3b1b41184390720dd55c5b0ef1ffe32306f40ee33de73574e7672a0a895461e049b9204ef3fbd1e907a2570f29e463b1c1c1
data/README.md CHANGED
@@ -21,7 +21,7 @@ This Ruby implementation is designed as a generic library to join basic spec-com
21
21
 
22
22
  ## Documentation
23
23
 
24
- 1. [Introduction](./docs/introduction.md)
24
+ 1. [Introduction](./docs/README.md)
25
25
  1. [Composing a supergraph](./docs/composing_a_supergraph.md)
26
26
  1. [Merged types](./docs/merged_types.md)
27
27
  1. [Executables & file uploads](./docs/executables.md)
data/Rakefile CHANGED
@@ -9,4 +9,14 @@ Rake::TestTask.new(:test) do |t, args|
9
9
  t.test_files = FileList['test/**/*_test.rb']
10
10
  end
11
11
 
12
- task :default => :test
12
+ namespace :benchmark do
13
+ desc "Benchmark planner throughput"
14
+ task :planner do
15
+ ruby "benchmark/run.rb"
16
+ end
17
+ end
18
+
19
+ desc "Run benchmarks"
20
+ task benchmark: "benchmark:planner"
21
+
22
+ task :default => :test
data/benchmark/run.rb ADDED
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "bundler/setup"
6
+ require "graphql/stitching"
7
+
8
+ STITCH_DEFINITION = "directive @stitch(key: String!, arguments: String, typeName: String) repeatable on FIELD_DEFINITION\n"
9
+
10
+ def compose_definitions(locations)
11
+ locations = locations.each_with_object({}) do |(location, schema_config), memo|
12
+ schema_config = STITCH_DEFINITION + schema_config if schema_config.include?("@stitch")
13
+ memo[location.to_s] = { schema: GraphQL::Schema.from_definition(schema_config) }
14
+ end
15
+
16
+ GraphQL::Stitching::Composer.new.perform(locations)
17
+ end
18
+
19
+ class PlannerBenchmark
20
+ Case = Struct.new(:name, :supergraph, :document, :variables, keyword_init: true)
21
+
22
+ MINIMUM_SECONDS = Float(ENV.fetch("BENCHMARK_SECONDS", 2.0))
23
+ WARMUP_ITERATIONS = Integer(ENV.fetch("BENCHMARK_WARMUP", 200))
24
+
25
+ def initialize(cases)
26
+ @cases = cases
27
+ end
28
+
29
+ def run
30
+ width = @cases.map { |c| c.name.length }.max
31
+
32
+ puts "GraphQL::Stitching::Planner"
33
+ puts "ruby #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} (#{RUBY_PLATFORM})"
34
+ puts "graphql #{GraphQL::VERSION}"
35
+ puts "minimum #{MINIMUM_SECONDS}s per case"
36
+ puts
37
+
38
+ @cases.each do |bench_case|
39
+ WARMUP_ITERATIONS.times { plan(bench_case) }
40
+
41
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ iterations = 0
43
+ elapsed = 0.0
44
+
45
+ loop do
46
+ plan(bench_case)
47
+ iterations += 1
48
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
49
+ break if elapsed >= MINIMUM_SECONDS
50
+ end
51
+
52
+ ips = iterations / elapsed
53
+ microseconds = 1_000_000.0 / ips
54
+ puts "%-#{width}s %10.1f i/s %10.2f us/i" % [bench_case.name, ips, microseconds]
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def plan(bench_case)
61
+ GraphQL::Stitching::Request.new(
62
+ bench_case.supergraph,
63
+ bench_case.document,
64
+ variables: bench_case.variables,
65
+ ).plan
66
+ end
67
+ end
68
+
69
+ root_supergraph = compose_definitions({
70
+ "widgets" => %|
71
+ input MakeWidgetInput { name: String child: MakeWidgetInput }
72
+ type Widget { id: ID! name(lang: String): String size: Int color: String }
73
+ type Query { widget(id: ID!): Widget widgets(ids: [ID!]): [Widget!]! }
74
+ type Mutation { makeWidget(input: MakeWidgetInput!): Widget }
75
+ |,
76
+ "sprockets" => %|
77
+ input MakeSprocketInput { name: String child: MakeSprocketInput }
78
+ type Sprocket { id: ID! name(lang: String): String size: Int color: String }
79
+ type Query { sprocket(id: ID!): Sprocket sprockets(ids: [ID!]): [Sprocket!]! }
80
+ type Mutation { makeSprocket(input: MakeSprocketInput!): Sprocket }
81
+ |,
82
+ })
83
+
84
+ delegation_supergraph = compose_definitions({
85
+ "alpha" => %|
86
+ type Widget { id: ID! a: String! b: String! }
87
+ type Query { alpha(id: ID!): Widget @stitch(key: "id") }
88
+ |,
89
+ "bravo" => %|
90
+ type Widget { id: ID! a: String! b: String! }
91
+ type Query { bravo(id: ID!): Widget @stitch(key: "id") }
92
+ |,
93
+ "charlie" => %|
94
+ type Widget { id: ID! c: String! d: String! }
95
+ type Query { charlie(id: ID!): Widget @stitch(key: "id") }
96
+ |,
97
+ "delta" => %|
98
+ type Widget { id: ID! d: String! e: String! }
99
+ type Query { delta(id: ID!): Widget @stitch(key: "id") }
100
+ |,
101
+ "echo" => %|
102
+ type Widget { id: ID! d: String! e: String! f: String! }
103
+ type Query { echo(id: ID!): Widget @stitch(key: "id") }
104
+ |,
105
+ "foxtrot" => %|
106
+ type Widget { id: ID! d: String! f: String! }
107
+ type Query { foxtrot(id: ID!): Widget @stitch(key: "id") }
108
+ |,
109
+ })
110
+
111
+ resolver_supergraph = compose_definitions({
112
+ "storefronts" => %|
113
+ type Storefront {
114
+ id: ID!
115
+ name: String!
116
+ products: [Product]!
117
+ }
118
+ type Product {
119
+ upc: ID!
120
+ }
121
+ type Query {
122
+ storefront(id: ID!): Storefront
123
+ }
124
+ |,
125
+ "products" => %|
126
+ type Product {
127
+ upc: ID!
128
+ name: String!
129
+ price: Float!
130
+ manufacturer: Manufacturer!
131
+ }
132
+ type Manufacturer {
133
+ id: ID!
134
+ name: String!
135
+ products: [Product]!
136
+ }
137
+ type Query {
138
+ product(upc: ID!): Product @stitch(key: "upc")
139
+ productsManufacturer(id: ID!): Manufacturer @stitch(key: "id")
140
+ }
141
+ |,
142
+ "manufacturers" => %|
143
+ type Manufacturer {
144
+ id: ID!
145
+ name: String!
146
+ address: String!
147
+ }
148
+ type Query {
149
+ manufacturer(id: ID!): Manufacturer @stitch(key: "id")
150
+ }
151
+ |,
152
+ })
153
+
154
+ abstract_supergraph = compose_definitions({
155
+ "a" => %|
156
+ interface Buyable {
157
+ id: ID!
158
+ name: String!
159
+ price: Float!
160
+ }
161
+ type Product implements Buyable {
162
+ id: ID!
163
+ name: String!
164
+ price: Float!
165
+ }
166
+ type Query {
167
+ products(ids: [ID!]!): [Product]! @stitch(key: "id")
168
+ }
169
+ |,
170
+ "b" => %|
171
+ interface Buyable { id: ID! }
172
+ type Product implements Buyable { id: ID! }
173
+ type Bundle implements Buyable {
174
+ id: ID!
175
+ name: String!
176
+ price: Float!
177
+ products: [Product]!
178
+ }
179
+ type Query {
180
+ buyable(id: ID!): Buyable @stitch(key: "id")
181
+ }
182
+ |,
183
+ })
184
+
185
+ cases = [
186
+ PlannerBenchmark::Case.new(
187
+ name: "root groups",
188
+ supergraph: root_supergraph,
189
+ document: %|
190
+ query($wid: ID!, $sid: ID!, $ids: [ID!], $lang: String) {
191
+ a: widget(id: $wid) { id name(lang: $lang) size color }
192
+ b: sprocket(id: $sid) { id name(lang: $lang) size color }
193
+ c: widgets(ids: $ids) { id name size color }
194
+ d: sprockets(ids: $ids) { id name size color }
195
+ }
196
+ |,
197
+ variables: { "wid" => "1", "sid" => "2", "ids" => ["1", "2"], "lang" => "en" },
198
+ ),
199
+ PlannerBenchmark::Case.new(
200
+ name: "variables",
201
+ supergraph: root_supergraph,
202
+ document: %|
203
+ mutation($wname1: String!, $wname2: String!, $sname1: String!, $sname2: String!, $lang: String) {
204
+ makeWidget(input: { name: $wname1, child: { name: $wname2 } }) { id name(lang: $lang) }
205
+ makeSprocket(input: { name: $sname1, child: { name: $sname2 } }) { id name(lang: $lang) }
206
+ }
207
+ |,
208
+ variables: {
209
+ "wname1" => "a",
210
+ "wname2" => "b",
211
+ "sname1" => "c",
212
+ "sname2" => "d",
213
+ "lang" => "en",
214
+ },
215
+ ),
216
+ PlannerBenchmark::Case.new(
217
+ name: "delegation",
218
+ supergraph: delegation_supergraph,
219
+ document: %|query { alpha(id: "1") { a b c d e f } }|,
220
+ ),
221
+ PlannerBenchmark::Case.new(
222
+ name: "nested resolvers",
223
+ supergraph: resolver_supergraph,
224
+ document: %|
225
+ query {
226
+ storefront(id: "1") {
227
+ name
228
+ products {
229
+ name
230
+ manufacturer {
231
+ address
232
+ products {
233
+ name
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
239
+ |,
240
+ ),
241
+ PlannerBenchmark::Case.new(
242
+ name: "fragments",
243
+ supergraph: resolver_supergraph,
244
+ document: %|
245
+ query {
246
+ storefront(id: "1") {
247
+ products {
248
+ ...ProductAttrs
249
+ manufacturer {
250
+ ...ManufacturerAttrs
251
+ }
252
+ }
253
+ }
254
+ }
255
+ fragment ProductAttrs on Product { name price }
256
+ fragment ManufacturerAttrs on Manufacturer {
257
+ name
258
+ address
259
+ products { ...ProductAttrs }
260
+ }
261
+ |,
262
+ ),
263
+ PlannerBenchmark::Case.new(
264
+ name: "abstracts",
265
+ supergraph: abstract_supergraph,
266
+ document: %|
267
+ query {
268
+ buyable(id: "1") {
269
+ ... {
270
+ ...BuyableAttrs
271
+ }
272
+ }
273
+ }
274
+ fragment BuyableAttrs on Buyable { id name price }
275
+ |,
276
+ ),
277
+ ]
278
+
279
+ PlannerBenchmark.new(cases).run
data/docs/performance.md CHANGED
@@ -57,6 +57,34 @@ query($id: ID!) {
57
57
  # variables: { "id" => "1" }
58
58
  ```
59
59
 
60
+ ### Subgraph validations
61
+
62
+ Requests are validated by the supergraph, and should always divide into valid subgraph documents. Therefore, you can skip redundant subgraph validations for requests sent by the supergraph, ex:
63
+
64
+ ```ruby
65
+ exe = GraphQL::Stitching::HttpExecutable.new(
66
+ url: "http://localhost:3001",
67
+ headers: {
68
+ "Authorization" => "...",
69
+ "X-Supergraph-Secret" => "<shared-secret>",
70
+ },
71
+ )
72
+ ```
73
+
74
+ A shared secret allows a subgraph location to trust the supergraph origin, at which time it can disable validations:
75
+
76
+ ```ruby
77
+ def query
78
+ sg_header = request.headers["X-Supergraph-Secret"]
79
+ MySchema.execute(
80
+ query: params[:query],
81
+ variables: params[:variables],
82
+ operation_name: params[:operationName],
83
+ validate: sg_header.nil? || sg_header != Rails.env.credentials.supergraph,
84
+ )
85
+ end
86
+ ```
87
+
60
88
  ### Digests
61
89
 
62
90
  All computed digests use SHA2 hashing by default. You can swap in [a faster algorithm](https://github.com/Shopify/blake3-rb) and/or add base state by reconfiguring `Stitching.digest`:
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
28
28
 
29
29
  spec.add_runtime_dependency 'graphql', '>= 2.0'
30
30
 
31
- spec.add_development_dependency 'bundler', '~> 2.0'
32
- spec.add_development_dependency 'rake', '~> 12.0'
33
- spec.add_development_dependency 'minitest', '~> 5.12'
31
+ spec.add_development_dependency 'bundler', '>= 2.0'
32
+ spec.add_development_dependency 'rake', '>= 12.0'
33
+ spec.add_development_dependency 'minitest', '>= 5.12'
34
34
  end
@@ -25,7 +25,7 @@ module GraphQL
25
25
  raise ArgumentError, "Cannot provide both locations and a supergraph."
26
26
  elsif supergraph && !supergraph.is_a?(Supergraph)
27
27
  raise ArgumentError, "Provided supergraph must be a GraphQL::Stitching::Supergraph instance."
28
- elsif supergraph && composer_options.any?
28
+ elsif supergraph && !composer_options.empty?
29
29
  raise ArgumentError, "Cannot provide composer options with a pre-built supergraph."
30
30
  elsif supergraph
31
31
  supergraph
@@ -50,7 +50,7 @@ module GraphQL
50
50
 
51
51
  if validate
52
52
  validation_errors = request.validate
53
- return error_result(request, validation_errors) if validation_errors.any?
53
+ return error_result(request, validation_errors) unless validation_errors.empty?
54
54
  end
55
55
 
56
56
  load_plan(request)
@@ -8,7 +8,7 @@ module GraphQL::Stitching
8
8
 
9
9
  class << self
10
10
  def extract_directive_assignments(schema, location, assignments)
11
- return EMPTY_OBJECT unless assignments && assignments.any?
11
+ return EMPTY_OBJECT unless assignments && !assignments.empty?
12
12
 
13
13
  assignments.each_with_object({}) do |kwargs, memo|
14
14
  type = kwargs[:parent_type_name] ? schema.get_type(kwargs[:parent_type_name]) : schema.query
@@ -423,7 +423,7 @@ module GraphQL
423
423
  memo[location] = argument.default_value
424
424
  end
425
425
 
426
- if default_values_by_location.any?
426
+ unless default_values_by_location.empty?
427
427
  kwargs[:default_value] = @default_value_merger.call(default_values_by_location, {
428
428
  type_name: type_name,
429
429
  field_name: field_name,
@@ -468,8 +468,6 @@ module GraphQL
468
468
  kwarg_values_by_name_location = directives_by_location.each_with_object({}) do |(location, directive), memo|
469
469
  directive.arguments.keyword_arguments.each do |key, value|
470
470
  key = key.to_s
471
- next unless directive_class.arguments[key]
472
-
473
471
  memo[key] ||= {}
474
472
  memo[key][location] = value
475
473
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL::Stitching
4
+ class Executor
5
+ # Utilities for traversing aggregate executor data along planned paths.
6
+ module PathAccess
7
+ private
8
+
9
+ def path_objects(root, path)
10
+ objects = []
11
+ each_path_object(root, path) { |object| objects << object }
12
+ objects
13
+ end
14
+
15
+ def each_path_object(scope, path, &block)
16
+ return enum_for(:each_path_object, scope, path) unless block
17
+ return if scope.nil?
18
+
19
+ if path.empty?
20
+ each_leaf_object(scope, &block)
21
+ elsif scope.is_a?(Array)
22
+ scope.each { |element| each_path_object(element, path, &block) }
23
+ elsif scope.respond_to?(:[])
24
+ path_segment = path.first
25
+ each_path_object(scope[path_segment], path[1..-1], &block)
26
+ end
27
+ end
28
+
29
+ def each_leaf_object(scope, &block)
30
+ return if scope.nil?
31
+
32
+ if scope.is_a?(Array)
33
+ scope.each { |element| each_leaf_object(element, &block) }
34
+ else
35
+ yield(scope)
36
+ end
37
+ end
38
+
39
+ def path_entries(root, path)
40
+ entries = []
41
+ each_path_entry(root, path) { |object, response_path| entries << [object, response_path] }
42
+ entries
43
+ end
44
+
45
+ def each_path_entry(scope, path, response_path = [], &block)
46
+ return enum_for(:each_path_entry, scope, path, response_path) unless block
47
+ return if scope.nil?
48
+
49
+ if path.empty?
50
+ each_leaf_entry(scope, response_path, &block)
51
+ elsif scope.is_a?(Array)
52
+ scope.each_with_index do |element, index|
53
+ each_path_entry(element, path, [*response_path, index], &block)
54
+ end
55
+ elsif scope.respond_to?(:[])
56
+ path_segment = path.first
57
+ each_path_entry(scope[path_segment], path[1..-1], [*response_path, path_segment], &block)
58
+ end
59
+ end
60
+
61
+ def each_leaf_entry(scope, response_path, &block)
62
+ return if scope.nil?
63
+
64
+ if scope.is_a?(Array)
65
+ scope.each_with_index do |element, index|
66
+ each_leaf_entry(element, [*response_path, index], &block)
67
+ end
68
+ else
69
+ yield(scope, response_path)
70
+ end
71
+ end
72
+
73
+ def sanitized_error(error, path: nil)
74
+ error.dup.tap do |formatted|
75
+ formatted.delete("locations")
76
+ formatted["path"] = path if path
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -3,75 +3,94 @@
3
3
  module GraphQL::Stitching
4
4
  class Executor
5
5
  class RootSource < GraphQL::Dataloader::Source
6
+ include PathAccess
7
+
6
8
  def initialize(executor, location)
7
9
  @executor = executor
8
10
  @location = location
9
11
  end
10
12
 
11
13
  def fetch(ops)
12
- op = ops.first # There should only ever be one per location at a time
13
-
14
- query_document = build_document(
15
- op,
16
- @executor.request.operation_name,
17
- @executor.request.operation_directives,
18
- )
19
- query_variables = @executor.request.variables.slice(*op.variables.each_key)
20
- result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
21
- @executor.query_count += 1
22
-
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!)
14
+ ops.map do |op|
15
+ origin_set = op.path.empty? ? [@executor.data] : path_objects(@executor.data, op.path)
16
+
17
+ query_document = build_document(
18
+ op,
19
+ @executor.request.operation_name,
20
+ @executor.request.operation_directives,
21
+ )
22
+ query_variables = @executor.request.variables.slice(*op.variables.each_key)
23
+ result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
24
+ @executor.query_count += 1
25
+
26
+ errors = result["errors"]
27
+ origin_entries = nil
28
+
29
+ if errors && !errors.empty?
30
+ origin_entries = op.path.empty? ? [[@executor.data, []]] : path_entries(@executor.data, op.path)
31
+ end
32
+
33
+ if result["data"]
34
+ if op.path.empty?
35
+ # Actual root scopes merge directly into results data
36
+ @executor.data.merge!(result["data"])
37
+ elsif !origin_set.empty?
38
+ # Nested root scopes merge the same root payload into each pathed origin
39
+ origin_set.each { |origin_obj| origin_obj.merge!(result["data"]) }
28
40
  end
41
+ end
29
42
 
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"])
43
+ if errors && !errors.empty?
44
+ @executor.errors.concat(format_errors(errors, origin_entries, op.path))
34
45
  end
35
- end
36
46
 
37
- if result["errors"]&.any?
38
- @executor.errors.concat(format_errors!(result["errors"], op.path))
47
+ op.step
39
48
  end
40
-
41
- ops.map(&:step)
42
49
  end
43
50
 
44
51
  # Builds root source documents
45
52
  # "query MyOperation_1($var:VarType) { rootSelections ... }"
46
53
  def build_document(op, operation_name = nil, operation_directives = nil)
47
- doc = String.new
48
- doc << op.operation_type
54
+ doc_buffer = String.new
55
+ doc_buffer << op.operation_type
49
56
 
50
57
  if operation_name
51
- doc << " #{operation_name}_#{op.step}"
58
+ doc_buffer << " " << operation_name << "_" << op.step.to_s
52
59
  end
53
60
 
54
- if op.variables.any?
55
- variable_defs = op.variables.map { |k, v| "$#{k}:#{v}" }.join(",")
56
- doc << "(#{variable_defs})"
61
+ unless op.variables.empty?
62
+ doc_buffer << "("
63
+ op.variables.each_with_index do |(k, v), i|
64
+ doc_buffer << "," unless i.zero?
65
+ doc_buffer << "$" << k << ":" << v
66
+ end
67
+ doc_buffer << ")"
57
68
  end
58
69
 
59
70
  if operation_directives
60
- doc << " #{operation_directives} "
71
+ doc_buffer << " " << operation_directives << " "
61
72
  end
62
73
 
63
- doc << op.selections
64
- doc
74
+ doc_buffer << op.selections
75
+ doc_buffer
65
76
  end
66
77
 
67
78
  # 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?
79
+ # and prepend all concrete insertion paths for nested scopes into error paths.
80
+ def format_errors(errors, origin_entries, fallback_path = [])
81
+ errors.flat_map do |err|
82
+ path = err["path"]
83
+
84
+ if path && !origin_entries.empty?
85
+ origin_entries.map do |_origin_obj, origin_path|
86
+ sanitized_error(err, path: origin_path + path)
87
+ end
88
+ elsif path && !fallback_path.empty?
89
+ sanitized_error(err, path: fallback_path + path)
90
+ else
91
+ sanitized_error(err)
92
+ end
73
93
  end
74
- errors
75
94
  end
76
95
  end
77
96
  end