graphql-stitching 1.0.6 → 1.1.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: fbeabcd8c2327504cc5ccc613942d163bddddb2087e7eb90c966861777d67b9d
4
- data.tar.gz: cf03589e91ef96fb8dc9b74ca2703c20095e586bab45f91a89b3579376edbdac
3
+ metadata.gz: 66c85693da3a07eb7b3ab81fe76b69ce095aaffa8b9cab68a1f34b86e20963ba
4
+ data.tar.gz: f1e8bbbe3ffdaf23189f219cad6fe604203527e12911a0609fb1711375effe58
5
5
  SHA512:
6
- metadata.gz: 78b5d6bbf4abccb99795c6225e1a61807ed74f94ad9ce5bdde0be534133e101c7d51f677d3c89aebaad7ebe2b125ff11a192a3d1a29cdacb3d3b51049801212f
7
- data.tar.gz: ade024c4ace1474d9c7a7cd80fb8820fc71e456e566d89fe656d07bf4b64e60db83561546a908b5d2fcbec9d88ac740a7080946e981a74f7bd904b3da0aeebfb
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
- exported_schema = "type Query { ..."
29
- exported_mapping = JSON.parse("{ ... }")
30
- supergraph = GraphQL::Stitching::Supergraph.from_export(
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 |key, _context, _request|
65
- $redis.get(key) # << 3P code
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 |key, payload, _context, _request|
69
- $redis.set(key, payload) # << 3P code
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_ this when possible:
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 |err, context|
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
- document = "query FetchMovie($id: ID!) { movie(id:$id) { id genre } }"
7
- request = GraphQL::Stitching::Request.new(document, variables: { "id" => "1" }, operation_name: "FetchMovie")
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 the `export` method will return an SDL (Schema Definition Language) print of the combined graph schema and a delegation mapping hash. These can be persisted in any raw format that suits your stack:
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, delegation_map = supergraph.export
10
+ supergraph_sdl = supergraph.to_definition
11
11
 
12
- # stash these resources in Redis...
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 resources as files and commit them to your repo...
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 `from_export` proving the cached SDL string, the parsed JSON delegation mapping, and a hash of executables keyed by their location names:
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.from_export(
28
- schema: supergraph_sdl,
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(e, request.context) if @on_error
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.digest, request.context, 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.digest, JSON.generate(plan.as_json), request.context, request)
85
+ @on_cache_write.call(request, JSON.generate(plan.as_json))
86
86
  end
87
87
 
88
88
  plan
@@ -541,7 +541,7 @@ module GraphQL
541
541
  field: field_candidate.name,
542
542
  arg: argument_name,
543
543
  list: boundary_structure.first.list?,
544
- federation: kwargs[:federation],
544
+ federation: kwargs[:federation] || false,
545
545
  )
546
546
  end
547
547
  end
@@ -3,7 +3,7 @@
3
3
  module GraphQL
4
4
  module Stitching
5
5
  class Planner
6
- SUPERGRAPH_LOCATIONS = [Supergraph::LOCATION].freeze
6
+ SUPERGRAPH_LOCATIONS = [Supergraph::SUPERGRAPH_LOCATION].freeze
7
7
  TYPENAME = "__typename"
8
8
  QUERY_OP = "query"
9
9
  MUTATION_OP = "mutation"
@@ -3,34 +3,89 @@
3
3
  module GraphQL
4
4
  module Stitching
5
5
  class Supergraph
6
- LOCATION = "__super"
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
- def self.validate_executable!(location, executable)
9
- return true if executable.is_a?(Class) && executable <= GraphQL::Schema
10
- return true if executable && executable.respond_to?(:call)
11
- raise StitchingError, "Invalid executable provided for location `#{location}`."
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
- def self.from_export(schema:, delegation_map:, executables:)
15
- schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
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
- executables = delegation_map["locations"].each_with_object({}) do |location, memo|
18
- executable = executables[location] || executables[location.to_sym]
19
- if validate_executable!(location, executable)
20
- memo[location] = executable
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
- boundaries = delegation_map["boundaries"].map do |k, b|
25
- [k, b.map { Boundary.new(**_1) }]
26
- end
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
- new(
29
- schema: schema,
30
- fields: delegation_map["fields"],
31
- boundaries: boundaries.to_h,
32
- executables: executables,
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] = [LOCATION]
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({ LOCATION => @schema }) do |(location, executable), memo|
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 == LOCATION }
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "1.0.6"
5
+ VERSION = "1.1.0"
6
6
  end
7
7
  end
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.6
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-29 00:00:00.000000000 Z
11
+ date: 2023-12-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql