graphql-stitching 0.3.1 → 0.3.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10489fd6a8670d5a23a7afa132d7941a242848783f3697a4d3ddd519b208a4d8
4
- data.tar.gz: 7a2e8dda124bdc6e96da43e1a4ed64bbb4baeaf0065f6d8411edaeba2c52e064
3
+ metadata.gz: b07a54288795f5b321029340a61e97dd9209d395712dc9ac5921ebd5c52523a1
4
+ data.tar.gz: 83e4dea46c7b4ff7a3716c2936773893df58e732a50c36ee1a5af8e4e5cab4bd
5
5
  SHA512:
6
- metadata.gz: 4c9243880e3b41fcede7fddb5947a962f1d4c43882ba07cc0ab63d1ba154527ef4cc8e5cc130bb2524b40fcbe093ecfd2f3b8fb0bafc9a2a7324050c30d2af00
7
- data.tar.gz: a2b37c3ab8b98065a0910a458e177b71576c5d8f52c6f6eba0e31d25ae7797f1517a168aeef2f39c7295d27e4f981373b7a90066dc7ab8651670ecbd41fc70d5
6
+ metadata.gz: 831a54675315529b6bc1d489296a15325f88bcccbbb8f480c786641410de853989425591f4e60a6249ef123a3aa9e204780388925f2a027d758ac5594ca6c306
7
+ data.tar.gz: f66ab6dfc996aa9982f6c5d09e2bf98b5a5de8b85220ea839f854e53edd3f55144404b5ddff91f2681f7d8fee21071bb54f20265951424840c527b7203dd9ace
data/.gitignore CHANGED
@@ -25,6 +25,8 @@ Gemfile.lock
25
25
  .dat*
26
26
  .repl_history
27
27
  build/
28
+ node_modules/
29
+ package-lock.json
28
30
  *.bridgesupport
29
31
  build-iPhoneOS/
30
32
  build-iPhoneSimulator/
data/README.md CHANGED
@@ -5,7 +5,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
5
5
  ![Stitched graph](./docs/images/stitching.png)
6
6
 
7
7
  **Supports:**
8
- - Merged object and interface types.
8
+ - Merged object and abstract types.
9
9
  - Multiple keys per merged type.
10
10
  - Shared objects, fields, enums, and inputs across locations.
11
11
  - Combining local and remote schemas.
@@ -32,7 +32,7 @@ require "graphql/stitching"
32
32
 
33
33
  ## Usage
34
34
 
35
- The quickest way to start is to use the provided [`Gateway`](./docs/gateway.md) component that assembles a stitched graph ready to execute requests:
35
+ The quickest way to start is to use the provided [`Gateway`](./docs/gateway.md) component that wraps a stitched graph in an executable workflow with [caching hooks](./docs/gateway.md#cache-hooks):
36
36
 
37
37
  ```ruby
38
38
  movies_schema = <<~GRAPHQL
@@ -70,9 +70,9 @@ result = gateway.execute(
70
70
  )
71
71
  ```
72
72
 
73
- Schemas provided to the `Gateway` constructor 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. See [composer docs](./docs/composer.md#merge-patterns) for more information on how schemas get merged.
73
+ 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. See [composer docs](./docs/composer.md#merge-patterns) for more information on how schemas get merged.
74
74
 
75
- While the [`Gateway`](./docs/gateway.md) constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
75
+ While the `Gateway` constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
76
76
 
77
77
  - [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
78
78
  - [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
@@ -148,7 +148,7 @@ type Query {
148
148
  * 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.
149
149
  * 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).
150
150
 
151
- Each location that provides a unique variant of a type must provide _exactly one_ stitching query per possible key (more on multiple keys later). The exception to this requirement are types that contain only a single key field:
151
+ Each location that provides a unique variant of a type must provide one stitching query per key. The exception to this requirement are types that contain only a single key field:
152
152
 
153
153
  ```graphql
154
154
  type Product {
@@ -166,8 +166,13 @@ It's okay ([even preferable](https://www.youtube.com/watch?v=VmK0KBHTcWs) in man
166
166
  type Query {
167
167
  products(ids: [ID!]!): [Product]! @stitch(key: "id")
168
168
  }
169
+
170
+ # input: ["1", "2", "3"]
171
+ # result: [{ id: "1" }, null, { id: "3" }]
169
172
  ```
170
173
 
174
+ See [error handling](./docs/mechanics.md#stitched-errors) tips for list queries.
175
+
171
176
  #### Abstract queries
172
177
 
173
178
  It's okay for stitching queries to be implemented through abstract types. An abstract query will provide access to all of its possible types. For interfaces, the key selection should match a field within the interface. For unions, all possible types must implement the key selection individually.
@@ -334,6 +339,13 @@ The `GraphQL::Stitching::RemoteClient` class is provided as a simple executable
334
339
 
335
340
  The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based implementation of `GraphQL::Dataloader`. Non-blocking concurrency requires setting a fiber scheduler via `Fiber.set_scheduler`, see [graphql-ruby docs](https://graphql-ruby.org/dataloader/nonblocking.html). You may also need to build your own remote clients using corresponding HTTP libraries.
336
341
 
342
+ ## Additional topics
343
+
344
+ - [Field selection routing](./docs/mechanics.md#field-selection-routing)
345
+ - [Root selection routing](./docs/mechanics.md#root-selection-routing)
346
+ - [Stitched errors](./docs/mechanics.md#stitched-errors)
347
+ - [Null results](./docs/mechanics.md#null-results)
348
+
337
349
  ## Example
338
350
 
339
351
  This repo includes a working example of several stitched schemas running across small Rack servers. Try running it:
data/docs/README.md CHANGED
@@ -12,3 +12,7 @@ Major components include:
12
12
  - [Request](./request.md) - prepares a requested GraphQL document and variables for stitching.
13
13
  - [Planner](./planner.md) - builds a cacheable query plan for a request document.
14
14
  - [Executor](./executor.md) - executes a query plan with given request variables.
15
+
16
+ Additional topics:
17
+
18
+ - [Stitching mechanics](./mechanics.md) - learn more about building for stitching.
data/docs/composer.md CHANGED
@@ -13,6 +13,7 @@ composer = GraphQL::Stitching::Composer.new(
13
13
  description_merger: ->(values_by_location, info) { values_by_location.values.join("\n") },
14
14
  deprecation_merger: ->(values_by_location, info) { values_by_location.values.first },
15
15
  directive_kwarg_merger: ->(values_by_location, info) { values_by_location.values.last },
16
+ root_field_location_selector: ->(locations, info) { locations.last },
16
17
  )
17
18
  ```
18
19
 
@@ -28,12 +29,14 @@ Constructor arguments:
28
29
 
29
30
  - **`directive_kwarg_merger:`** _optional_, a [value merger function](#value-merger-functions) for merging directive keyword arguments from across locations.
30
31
 
32
+ - **`root_field_location_selector:`** _optional_, selects a default routing location for root fields with multiple locations. Use this to prioritize sending root fields to their primary data sources (only applies while routing the root operation scope). This handler receives an array of possible locations and an info object with field information, and should return the prioritized location. The last location is used by default.
33
+
31
34
  #### Value merger functions
32
35
 
33
36
  Static data values such as element descriptions and directive arguments must also merge across locations. By default, the first non-null value encountered for a given element attribute is used. A value merger function may customize this process by selecting a different value or computing a new one:
34
37
 
35
38
  ```ruby
36
- supergraph = GraphQL::Stitching::Composer.new(
39
+ composer = GraphQL::Stitching::Composer.new(
37
40
  description_merger: ->(values_by_location, info) { values_by_location.values.compact.join("\n") },
38
41
  )
39
42
  ```
data/docs/mechanics.md ADDED
@@ -0,0 +1,209 @@
1
+ ## Schema Stitching, mechanics
2
+
3
+ ### Field selection routing
4
+
5
+ Fields of a merged type may exist in multiple locations. For example, the `title` field below is provided by both locations:
6
+
7
+ ```graphql
8
+ # -- Location A
9
+
10
+ type Movie {
11
+ id: String!
12
+ title: String! # shared
13
+ rating: Int!
14
+ }
15
+
16
+ type Query {
17
+ movieA(id: ID!): Movie @stitch(key: "id")
18
+ }
19
+
20
+ # -- Location B
21
+
22
+ type Movie {
23
+ id: String!
24
+ title: String! # shared
25
+ reviews: [String!]!
26
+ }
27
+
28
+ type Query {
29
+ movieB(id: ID!): Movie @stitch(key: "id")
30
+ }
31
+ ```
32
+
33
+ When planning a request, field selections always attempt to use the current routing location that originates from the selection root, for example:
34
+
35
+ ```graphql
36
+ query GetTitleFromA {
37
+ movieA(id: "23") { # <- enter via Location A
38
+ title # <- source from Location A
39
+ }
40
+ }
41
+
42
+ query GetTitleFromB {
43
+ movieB(id: "23") { # <- enter via Location B
44
+ title # <- source from Location B
45
+ }
46
+ }
47
+ ```
48
+
49
+ Field selections that are NOT available in the current routing location delegate to new locations as follows:
50
+
51
+ 1. Fields with only one location automatically use that location.
52
+ 2. Fields with multiple locations attempt to use a location added during step-1.
53
+ 3. Any remaining fields pick a location based on their highest availability among locations.
54
+
55
+ ### Root selection routing
56
+
57
+ Root fields should route to the primary locations of their provided types. This assures that the most common data for a type can be resolved via root access and thus avoid unnecessary stitching. Root fields can select their primary locations using the `root_field_location_selector` option in [composer configuration](./composer.md#configuring-composition):
58
+
59
+ ```ruby
60
+ supergraph = GraphQL::Stitching::Composer.new(
61
+ root_field_location_selector: ->(locations) { locations.find { _1 == "a" } || locations.last },
62
+ ).perform({ ... })
63
+ ```
64
+
65
+ It's okay if root field names are repeated across locations. The primary location will be used when routing root selections:
66
+
67
+ ```graphql
68
+ # -- Location A
69
+
70
+ type Movie {
71
+ id: String!
72
+ rating: Int!
73
+ }
74
+
75
+ type Query {
76
+ movie(id: ID!): Movie @stitch(key: "id") # shared, primary
77
+ }
78
+
79
+ # -- Location B
80
+
81
+ type Movie {
82
+ id: String!
83
+ reviews: [String!]!
84
+ }
85
+
86
+ type Query {
87
+ movie(id: ID!): Movie @stitch(key: "id") # shared
88
+ }
89
+
90
+ # -- Request
91
+
92
+ query {
93
+ movie(id: "23") { id } # routes to Location A
94
+ }
95
+ ```
96
+
97
+ Note that primary location routing _only_ applies to selections in the root scope. If the `Query` type appears again lower in the graph, then its fields are resolved as normal object fields outside of root context, for example:
98
+
99
+ ```graphql
100
+ schema {
101
+ query: Query # << root query, uses primary locations
102
+ }
103
+
104
+ type Query {
105
+ subquery: Query # << subquery, acts as a normal object type
106
+ }
107
+ ```
108
+
109
+ Also note that stitching queries (denoted by the `@stitch` directive) are completely separate from field routing concerns. A `@stitch` directive establishes a contract for resolving a given type in a given location. This contract is always used to collect stitching data, regardless of how request routing selected the location for use.
110
+
111
+ ### Stitched errors
112
+
113
+ Any [spec GraphQL errors](https://spec.graphql.org/June2018/#sec-Errors) returned by a stitching query will flow through the request. Stitching has two strategies for passing errors through to the final result:
114
+
115
+ 1. **Direct passthrough**, where subgraph errors are returned directly without modification. This strategy is used for errors without a `path` (ie: "base" errors), and errors pathed to root fields.
116
+
117
+ 2. **Mapped passthrough**, where the `path` attribute of a subgraph error is remapped to an insertion point in the stitched request. This strategy is used when merging stitching queries into the composed result.
118
+
119
+ In either strategy, it's important that subgraphs provide properly pathed errors (GraphQL Ruby [can do this automatically](https://graphql-ruby.org/errors/overview.html)). For example:
120
+
121
+ ```json
122
+ {
123
+ "data": { "shop": { "product": null } },
124
+ "errors": [{
125
+ "message": "Record not found.",
126
+ "path": ["shop", "product"]
127
+ }]
128
+ }
129
+ ```
130
+
131
+ When resolving [stitching list queries](../README.md#list-queries), it's important to only error out specific array positions rather than the entire array result, for example:
132
+
133
+ ```ruby
134
+ def products
135
+ [
136
+ { id: "1" },
137
+ GraphQL::ExecutionError.new("Not found"),
138
+ { id: "3" },
139
+ ]
140
+ end
141
+ ```
142
+
143
+ Stitching expects list queries to pad their missing elements with null, and to report corresponding errors pathed down to list position:
144
+
145
+ ```json
146
+ {
147
+ "data": {
148
+ "products": [{ "id": "1" }, null, { "id": "3" }]
149
+ },
150
+ "errors": [{
151
+ "message": "Record not found.",
152
+ "path": ["products", 1]
153
+ }]
154
+ }
155
+ ```
156
+
157
+ ### Null results
158
+
159
+ It's okay for a stitching query to return `null` for a merged type as long as all unique fields of the type allow null. For example, the following merge works:
160
+
161
+ ```graphql
162
+ # -- Request
163
+
164
+ query {
165
+ movieA(id: "23") {
166
+ id
167
+ title
168
+ rating
169
+ }
170
+ }
171
+
172
+ # -- Location A
173
+
174
+ type Movie {
175
+ id: String!
176
+ title: String!
177
+ }
178
+
179
+ type Query {
180
+ movieA(id: ID!): Movie @stitch(key: "id")
181
+ # (id: "23") -> { id: "23", title: "Jurassic Park" }
182
+ }
183
+
184
+ # -- Location B
185
+
186
+ type Movie {
187
+ id: String!
188
+ rating: Int
189
+ }
190
+
191
+ type Query {
192
+ movieB(id: ID!): Movie @stitch(key: "id")
193
+ # (id: "23") -> null
194
+ }
195
+ ```
196
+
197
+ And produces this result:
198
+
199
+ ```json
200
+ {
201
+ "data": {
202
+ "id": "23",
203
+ "title": "Jurassic Park",
204
+ "rating": null
205
+ }
206
+ }
207
+ ```
208
+
209
+ Location B is allowed to return `null` here because its one unique field, `rating`, is nullable (the `id` field can be provided by Location A). If `rating` were non-null, then null bubbling would invalidate the response data.
@@ -12,61 +12,62 @@ module GraphQL
12
12
  next if type.graphql_name.start_with?("__")
13
13
 
14
14
  # multiple subschemas implement the type
15
- subschema_types_by_location = composer.subschema_types_by_name_and_location[type_name]
16
- next unless subschema_types_by_location.length > 1
15
+ candidate_types_by_location = composer.candidate_types_by_name_and_location[type_name]
16
+ next unless candidate_types_by_location.length > 1
17
17
 
18
18
  boundaries = ctx.boundaries[type_name]
19
19
  if boundaries&.any?
20
- validate_as_boundary(ctx, type, subschema_types_by_location, boundaries)
20
+ validate_as_boundary(ctx, type, candidate_types_by_location, boundaries)
21
21
  elsif type.kind.object?
22
- validate_as_shared(ctx, type, subschema_types_by_location)
22
+ validate_as_shared(ctx, type, candidate_types_by_location)
23
23
  end
24
24
  end
25
25
  end
26
26
 
27
27
  private
28
28
 
29
- def validate_as_boundary(ctx, type, subschema_types_by_location, boundaries)
29
+ def validate_as_boundary(ctx, type, candidate_types_by_location, boundaries)
30
30
  # abstract boundaries are expanded with their concrete implementations, which each get validated. Ignore the abstract itself.
31
31
  return if type.kind.abstract?
32
32
 
33
33
  # only one boundary allowed per type/location/key
34
34
  boundaries_by_location_and_key = boundaries.each_with_object({}) do |boundary, memo|
35
35
  if memo.dig(boundary["location"], boundary["selection"])
36
- raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["selection"]}` found in #{boundary["location"]}.
37
- Limit one boundary query per type and key in each location. Abstract boundaries provide all possible types."
36
+ raise Composer::ValidationError, "Multiple boundary queries for `#{type.graphql_name}.#{boundary["selection"]}` "\
37
+ "found in #{boundary["location"]}. Limit one boundary query per type and key in each location. "\
38
+ "Abstract boundaries provide all possible types."
38
39
  end
39
40
  memo[boundary["location"]] ||= {}
40
41
  memo[boundary["location"]][boundary["selection"]] = boundary
41
42
  end
42
43
 
43
44
  boundary_keys = boundaries.map { _1["selection"] }.uniq
44
- key_only_types_by_location = subschema_types_by_location.select do |location, subschema_type|
45
+ key_only_types_by_location = candidate_types_by_location.select do |location, subschema_type|
45
46
  subschema_type.fields.keys.length == 1 && boundary_keys.include?(subschema_type.fields.keys.first)
46
47
  end
47
48
 
48
49
  # all locations have a boundary, or else are key-only
49
- subschema_types_by_location.each do |location, subschema_type|
50
+ candidate_types_by_location.each do |location, subschema_type|
50
51
  unless boundaries_by_location_and_key[location] || key_only_types_by_location[location]
51
52
  raise Composer::ValidationError, "A boundary query is required for `#{type.graphql_name}` in #{location} because it provides unique fields."
52
53
  end
53
54
  end
54
55
 
55
56
  outbound_access_locations = key_only_types_by_location.keys
56
- bidirectional_access_locations = subschema_types_by_location.keys - outbound_access_locations
57
+ bidirectional_access_locations = candidate_types_by_location.keys - outbound_access_locations
57
58
 
58
59
  # verify that all outbound locations can access all inbound locations
59
60
  (outbound_access_locations + bidirectional_access_locations).each do |location|
60
61
  remote_locations = bidirectional_access_locations.reject { _1 == location }
61
62
  paths = ctx.route_type_to_locations(type.graphql_name, location, remote_locations)
62
63
  if paths.length != remote_locations.length || paths.any? { |_loc, path| path.nil? }
63
- raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` boundaries in #{location} to all other locations.
64
- All locations must provide a boundary accessor that uses a conjoining key."
64
+ raise Composer::ValidationError, "Cannot route `#{type.graphql_name}` boundaries in #{location} to all other locations. "\
65
+ "All locations must provide a boundary accessor that uses a conjoining key."
65
66
  end
66
67
  end
67
68
  end
68
69
 
69
- def validate_as_shared(ctx, type, subschema_types_by_location)
70
+ def validate_as_shared(ctx, type, candidate_types_by_location)
70
71
  expected_fields = begin
71
72
  type.fields.keys.sort
72
73
  rescue StandardError => e
@@ -78,10 +79,10 @@ module GraphQL
78
79
  end
79
80
  end
80
81
 
81
- subschema_types_by_location.each do |location, subschema_type|
82
+ candidate_types_by_location.each do |location, subschema_type|
82
83
  if subschema_type.fields.keys.sort != expected_fields
83
- raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations,
84
- or else define boundary queries so that its unique fields may be accessed remotely."
84
+ raise Composer::ValidationError, "Shared type `#{type.graphql_name}` must have consistent fields across locations, "\
85
+ "or else define boundary queries so that its unique fields may be accessed remotely."
85
86
  end
86
87
  end
87
88
  end
@@ -4,19 +4,47 @@ module GraphQL
4
4
  module Stitching
5
5
  class Composer::ValidateInterfaces < Composer::BaseValidator
6
6
 
7
+ # For each composed interface, check the interface against each possible type
8
+ # to assure that intersecting fields have compatible types, structures, and nullability.
9
+ # Verifies compatibility of types that inherit interface contracts through merging.
7
10
  def perform(supergraph, composer)
8
- # @todo
9
- # Validate all supergraph interface fields
10
- # match possible types in all locations...
11
- # - Traverse supergraph types (supergraph.types)
12
- # - For each interface (.kind.interface?), get possible types (Util.get_possible_types)
13
- # - For each possible type, traverse type candidates (composer.subschema_types_by_name_and_location)
14
- # - For each type candidate, compare interface fields to type candidate fields
15
- # - For each type candidate field that matches an interface field...
16
- # - Named types must match
17
- # - List structures must match
18
- # - Nullabilities must be >= interface field
19
- # - It's OKAY if a type candidate does not implement the full interface
11
+ supergraph.schema.types.each do |type_name, interface_type|
12
+ next unless interface_type.kind.interface?
13
+
14
+ supergraph.schema.possible_types(interface_type).each do |possible_type|
15
+ interface_type.fields.each do |field_name, interface_field|
16
+ # graphql-ruby will dynamically apply interface fields on a type implementation,
17
+ # so check the delegation map to assure that all materialized fields have resolver locations.
18
+ unless supergraph.locations_by_type_and_field[possible_type.graphql_name][field_name]&.any?
19
+ raise Composer::ValidationError, "Type #{possible_type.graphql_name} does not implement a `#{field_name}` field in any location, "\
20
+ "which is required by interface #{interface_type.graphql_name}."
21
+ end
22
+
23
+ intersecting_field = possible_type.fields[field_name]
24
+ interface_type_structure = Util.flatten_type_structure(interface_field.type)
25
+ possible_type_structure = Util.flatten_type_structure(intersecting_field.type)
26
+
27
+ if possible_type_structure.length != interface_type_structure.length
28
+ raise Composer::ValidationError, "Incompatible list structures between field #{possible_type.graphql_name}.#{field_name} of type "\
29
+ "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
30
+ end
31
+
32
+ interface_type_structure.each_with_index do |interface_struct, index|
33
+ possible_struct = possible_type_structure[index]
34
+
35
+ if possible_struct[:name] != interface_struct[:name]
36
+ raise Composer::ValidationError, "Incompatible named types between field #{possible_type.graphql_name}.#{field_name} of type "\
37
+ "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
38
+ end
39
+
40
+ if possible_struct[:null] && !interface_struct[:null]
41
+ raise Composer::ValidationError, "Incompatible nullability between field #{possible_type.graphql_name}.#{field_name} of type "\
42
+ "#{intersecting_field.type.to_type_signature} and interface #{interface_type.graphql_name}.#{field_name} of type #{interface_field.type.to_type_signature}."
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
20
48
  end
21
49
 
22
50
  end
@@ -6,9 +6,10 @@ module GraphQL
6
6
  class ComposerError < StitchingError; end
7
7
  class ValidationError < ComposerError; end
8
8
 
9
- attr_reader :query_name, :mutation_name, :subschema_types_by_name_and_location, :schema_directives
9
+ attr_reader :query_name, :mutation_name, :candidate_types_by_name_and_location, :schema_directives
10
10
 
11
11
  DEFAULT_VALUE_MERGER = ->(values_by_location, _info) { values_by_location.values.find { !_1.nil? } }
12
+ DEFAULT_ROOT_FIELD_LOCATION_SELECTOR = ->(locations, _info) { locations.last }
12
13
 
13
14
  VALIDATORS = [
14
15
  "ValidateInterfaces",
@@ -20,13 +21,15 @@ module GraphQL
20
21
  mutation_name: "Mutation",
21
22
  description_merger: nil,
22
23
  deprecation_merger: nil,
23
- directive_kwarg_merger: nil
24
+ directive_kwarg_merger: nil,
25
+ root_field_location_selector: nil
24
26
  )
25
27
  @query_name = query_name
26
28
  @mutation_name = mutation_name
27
29
  @description_merger = description_merger || DEFAULT_VALUE_MERGER
28
30
  @deprecation_merger = deprecation_merger || DEFAULT_VALUE_MERGER
29
31
  @directive_kwarg_merger = directive_kwarg_merger || DEFAULT_VALUE_MERGER
32
+ @root_field_location_selector = root_field_location_selector || DEFAULT_ROOT_FIELD_LOCATION_SELECTOR
30
33
  end
31
34
 
32
35
  def perform(locations_input)
@@ -34,7 +37,7 @@ module GraphQL
34
37
  schemas, executables = prepare_locations_input(locations_input)
35
38
 
36
39
  # "directive_name" => "location" => candidate_directive
37
- @subschema_directives_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
40
+ @candidate_directives_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
38
41
  (schema.directives.keys - schema.default_directives.keys - GraphQL::Stitching.stitching_directive_names).each do |directive_name|
39
42
  memo[directive_name] ||= {}
40
43
  memo[directive_name][location] = schema.directives[directive_name]
@@ -42,14 +45,14 @@ module GraphQL
42
45
  end
43
46
 
44
47
  # "Typename" => merged_directive
45
- @schema_directives = @subschema_directives_by_name_and_location.each_with_object({}) do |(directive_name, directives_by_location), memo|
48
+ @schema_directives = @candidate_directives_by_name_and_location.each_with_object({}) do |(directive_name, directives_by_location), memo|
46
49
  memo[directive_name] = build_directive(directive_name, directives_by_location)
47
50
  end
48
51
 
49
52
  @schema_directives.merge!(GraphQL::Schema.default_directives)
50
53
 
51
54
  # "Typename" => "location" => candidate_type
52
- @subschema_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
55
+ @candidate_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
53
56
  raise ComposerError, "Location keys must be strings" unless location.is_a?(String)
54
57
  raise ComposerError, "The subscription operation is not supported." if schema.subscription
55
58
 
@@ -74,10 +77,10 @@ module GraphQL
74
77
  enum_usage = build_enum_usage_map(schemas.values)
75
78
 
76
79
  # "Typename" => merged_type
77
- schema_types = @subschema_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo|
80
+ schema_types = @candidate_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo|
78
81
  kinds = types_by_location.values.map { _1.kind.name }.uniq
79
82
 
80
- unless kinds.all? { _1 == kinds.first }
83
+ if kinds.length > 1
81
84
  raise ComposerError, "Cannot merge different kinds for `#{type_name}`. Found: #{kinds.join(", ")}."
82
85
  end
83
86
 
@@ -96,7 +99,7 @@ module GraphQL
96
99
  when "INPUT_OBJECT"
97
100
  build_input_object_type(type_name, types_by_location)
98
101
  else
99
- raise ComposerError, "Unexpected kind encountered for `#{type_name}`. Found: #{kind}."
102
+ raise ComposerError, "Unexpected kind encountered for `#{type_name}`. Found: #{kinds.first}."
100
103
  end
101
104
  end
102
105
 
@@ -110,6 +113,7 @@ module GraphQL
110
113
  own_orphan_types.clear
111
114
  end
112
115
 
116
+ select_root_field_locations(schema)
113
117
  expand_abstract_boundaries(schema)
114
118
 
115
119
  supergraph = Supergraph.new(
@@ -307,12 +311,13 @@ module GraphQL
307
311
  fields_by_name_location.each do |field_name, fields_by_location|
308
312
  value_types = fields_by_location.values.map(&:type)
309
313
 
314
+ type = merge_value_types(type_name, value_types, field_name: field_name)
310
315
  schema_field = owner.field(
311
316
  field_name,
312
317
  description: merge_descriptions(type_name, fields_by_location, field_name: field_name),
313
318
  deprecation_reason: merge_deprecations(type_name, fields_by_location, field_name: field_name),
314
- type: merge_value_types(type_name, value_types, field_name: field_name),
315
- null: !value_types.all?(&:non_null?),
319
+ type: Util.unwrap_non_null(type),
320
+ null: !type.non_null?,
316
321
  camelize: false,
317
322
  )
318
323
 
@@ -345,12 +350,13 @@ module GraphQL
345
350
  # Getting double args sometimes... why?
346
351
  return if owner.arguments.any? { _1.first == argument_name }
347
352
 
353
+ type = merge_value_types(type_name, value_types, argument_name: argument_name, field_name: field_name)
348
354
  schema_argument = owner.argument(
349
355
  argument_name,
350
356
  description: merge_descriptions(type_name, arguments_by_location, argument_name: argument_name, field_name: field_name),
351
357
  deprecation_reason: merge_deprecations(type_name, arguments_by_location, argument_name: argument_name, field_name: field_name),
352
- type: merge_value_types(type_name, value_types, argument_name: argument_name, field_name: field_name),
353
- required: value_types.any?(&:non_null?),
358
+ type: Util.unwrap_non_null(type),
359
+ required: type.non_null?,
354
360
  camelize: false,
355
361
  )
356
362
 
@@ -401,37 +407,32 @@ module GraphQL
401
407
 
402
408
  def merge_value_types(type_name, type_candidates, field_name: nil, argument_name: nil)
403
409
  path = [type_name, field_name, argument_name].compact.join(".")
404
- named_types = type_candidates.map { _1.unwrap.graphql_name }.uniq
410
+ alt_structures = type_candidates.map { Util.flatten_type_structure(_1) }
411
+ basis_structure = alt_structures.shift
405
412
 
406
- unless named_types.all? { _1 == named_types.first }
407
- raise ComposerError, "Cannot compose mixed types at `#{path}`. Found: #{named_types.join(", ")}."
408
- end
409
-
410
- type = GraphQL::Schema::BUILT_IN_TYPES.fetch(named_types.first, build_type_binding(named_types.first))
411
- list_structures = type_candidates.map { Util.get_list_structure(_1) }
412
-
413
- if list_structures.any?(&:any?)
414
- if list_structures.any? { _1.length != list_structures.first.length }
413
+ alt_structures.each do |alt_structure|
414
+ if alt_structure.length != basis_structure.length
415
415
  raise ComposerError, "Cannot compose mixed list structures at `#{path}`."
416
416
  end
417
417
 
418
- list_structures.each(&:reverse!)
419
- list_structures.first.each_with_index do |current, index|
420
- # input arguments use strongest nullability, readonly fields use weakest
421
- non_null = list_structures.public_send(argument_name ? :any? : :all?) do |list_structure|
422
- list_structure[index].start_with?("non_null")
423
- end
424
-
425
- case current
426
- when "list", "non_null_list"
427
- type = type.to_list_type
428
- type = type.to_non_null_type if non_null
429
- when "element", "non_null_element"
430
- type = type.to_non_null_type if non_null
431
- end
418
+ if alt_structure.last[:name] != basis_structure.last[:name]
419
+ raise ComposerError, "Cannot compose mixed types at `#{path}`."
432
420
  end
433
421
  end
434
422
 
423
+ type = GraphQL::Schema::BUILT_IN_TYPES.fetch(
424
+ basis_structure.last[:name],
425
+ build_type_binding(basis_structure.last[:name])
426
+ )
427
+
428
+ basis_structure.reverse!.each_with_index do |basis, index|
429
+ rev_index = basis_structure.length - index - 1
430
+ non_null = alt_structures.each_with_object([!basis[:null]]) { |s, m| m << !s[rev_index][:null] }
431
+
432
+ type = type.to_list_type if basis[:list]
433
+ type = type.to_non_null_type if argument_name ? non_null.any? : non_null.all?
434
+ end
435
+
435
436
  type
436
437
  end
437
438
 
@@ -459,7 +460,7 @@ module GraphQL
459
460
  types_by_location.each do |location, type_candidate|
460
461
  type_candidate.fields.each do |field_name, field_candidate|
461
462
  boundary_type_name = field_candidate.type.unwrap.graphql_name
462
- boundary_list = Util.get_list_structure(field_candidate.type)
463
+ boundary_structure = Util.flatten_type_structure(field_candidate.type)
463
464
 
464
465
  field_candidate.directives.each do |directive|
465
466
  next unless directive.graphql_name == GraphQL::Stitching.stitch_directive
@@ -482,8 +483,8 @@ module GraphQL
482
483
  raise ComposerError, "Invalid boundary argument `#{argument_name}` for #{type_name}.#{field_name}."
483
484
  end
484
485
 
485
- argument_list = Util.get_list_structure(argument.type)
486
- if argument_list.length != boundary_list.length
486
+ argument_structure = Util.flatten_type_structure(argument.type)
487
+ if argument_structure.length != boundary_structure.length
487
488
  raise ComposerError, "Mismatched input/output for #{type_name}.#{field_name}.#{argument_name} boundary. Arguments must map directly to results."
488
489
  end
489
490
 
@@ -493,7 +494,7 @@ module GraphQL
493
494
  "selection" => key_selections[0].name,
494
495
  "field" => field_candidate.name,
495
496
  "arg" => argument_name,
496
- "list" => boundary_list.any?,
497
+ "list" => boundary_structure.first[:list],
497
498
  "type_name" => boundary_type_name,
498
499
  }
499
500
  end
@@ -501,13 +502,31 @@ module GraphQL
501
502
  end
502
503
  end
503
504
 
505
+ def select_root_field_locations(schema)
506
+ [schema.query, schema.mutation].tap(&:compact!).each do |root_type|
507
+ root_type.fields.each do |root_field_name, root_field|
508
+ root_field_locations = @field_map[root_type.graphql_name][root_field_name]
509
+ next unless root_field_locations.length > 1
510
+
511
+ target_location = @root_field_location_selector.call(root_field_locations, {
512
+ type_name: root_type.graphql_name,
513
+ field_name: root_field_name,
514
+ })
515
+ next unless root_field_locations.include?(target_location)
516
+
517
+ root_field_locations.reject! { _1 == target_location }
518
+ root_field_locations.unshift(target_location)
519
+ end
520
+ end
521
+ end
522
+
504
523
  def expand_abstract_boundaries(schema)
505
524
  @boundary_map.keys.each do |type_name|
506
525
  boundary_type = schema.types[type_name]
507
526
  next unless boundary_type.kind.abstract?
508
527
 
509
528
  expanded_types = Util.expand_abstract_type(schema, boundary_type)
510
- expanded_types.select { @subschema_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type|
529
+ expanded_types.select { @candidate_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type|
511
530
  @boundary_map[expanded_type.graphql_name] ||= []
512
531
  @boundary_map[expanded_type.graphql_name].push(*@boundary_map[type_name])
513
532
  end
@@ -558,7 +577,7 @@ module GraphQL
558
577
  @field_map = {}
559
578
  @boundary_map = {}
560
579
  @mapped_type_names = {}
561
- @subschema_directives_by_name_and_location = nil
580
+ @candidate_directives_by_name_and_location = nil
562
581
  @schema_directives = nil
563
582
  end
564
583
  end
@@ -58,9 +58,7 @@ module GraphQL
58
58
  def fetch(ops)
59
59
  origin_sets_by_operation = ops.each_with_object({}) do |op, memo|
60
60
  origin_set = op["insertion_path"].reduce([@executor.data]) do |set, path_segment|
61
- mapped = set.flat_map { |obj| obj && obj[path_segment] }
62
- mapped.compact!
63
- mapped
61
+ set.flat_map { |obj| obj && obj[path_segment] }.tap(&:compact!)
64
62
  end
65
63
 
66
64
  if op["type_condition"]
@@ -37,23 +37,23 @@ module GraphQL
37
37
 
38
38
  request.prepare!
39
39
 
40
- begin
41
- plan = fetch_plan(request) do
42
- GraphQL::Stitching::Planner.new(
43
- supergraph: @supergraph,
44
- request: request,
45
- ).perform.to_h
46
- end
47
-
48
- GraphQL::Stitching::Executor.new(
40
+ plan = fetch_plan(request) do
41
+ GraphQL::Stitching::Planner.new(
49
42
  supergraph: @supergraph,
50
43
  request: request,
51
- plan: plan,
52
- ).perform
53
- rescue StandardError => e
54
- custom_message = @on_error.call(e, request.context) if @on_error
55
- error_result([{ "message" => custom_message || "An unexpected error occured." }])
44
+ ).perform.to_h
56
45
  end
46
+
47
+ GraphQL::Stitching::Executor.new(
48
+ supergraph: @supergraph,
49
+ request: request,
50
+ plan: plan,
51
+ ).perform
52
+ rescue GraphQL::ParseError, GraphQL::ExecutionError => e
53
+ error_result([e])
54
+ rescue StandardError => e
55
+ custom_message = @on_error.call(e, request.context) if @on_error
56
+ error_result([{ "message" => custom_message || "An unexpected error occured." }])
57
57
  end
58
58
 
59
59
  def on_cache_read(&block)
@@ -89,10 +89,8 @@ module GraphQL
89
89
  end
90
90
 
91
91
  def error_result(errors)
92
- public_errors = errors.map do |e|
93
- public_error = e.is_a?(Hash) ? e : e.to_h
94
- public_error["path"] ||= []
95
- public_error
92
+ public_errors = errors.map! do |e|
93
+ e.is_a?(Hash) ? e : e.to_h
96
94
  end
97
95
 
98
96
  { "errors" => public_errors }
@@ -20,13 +20,11 @@ module GraphQL
20
20
  end
21
21
 
22
22
  def operations
23
- ops = @operations_by_grouping.values
24
- ops.sort_by!(&:key)
25
- ops
23
+ @operations_by_grouping.values.sort_by!(&:key)
26
24
  end
27
25
 
28
26
  def to_h
29
- { "ops" => operations.map(&:to_h) }
27
+ { "ops" => operations.map!(&:to_h) }
30
28
  end
31
29
 
32
30
  private
@@ -41,10 +39,8 @@ module GraphQL
41
39
 
42
40
  selections_by_location = @request.operation.selections.each_with_object({}) do |node, memo|
43
41
  locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
44
-
45
- # root fields currently just delegate to the last location that defined them; this should probably be smarter
46
- memo[locations.last] ||= []
47
- memo[locations.last] << node
42
+ memo[locations.first] ||= []
43
+ memo[locations.first] << node
48
44
  end
49
45
 
50
46
  selections_by_location.each do |location, selections|
@@ -55,8 +51,7 @@ module GraphQL
55
51
  parent_type = @supergraph.schema.mutation
56
52
 
57
53
  location_groups = @request.operation.selections.each_with_object([]) do |node, memo|
58
- # root fields currently just delegate to the last location that defined them; this should probably be smarter
59
- next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].last
54
+ next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
60
55
 
61
56
  if memo.none? || memo.last[:location] != next_location
62
57
  memo << { location: next_location, selections: [] }
@@ -221,7 +216,7 @@ module GraphQL
221
216
  possible_locations_by_field = @supergraph.locations_by_type_and_field[parent_type.graphql_name]
222
217
  selections_by_location = {}
223
218
 
224
- # distribute unique fields among required locations
219
+ # 1. distribute unique fields among required locations
225
220
  remote_selections.reject! do |node|
226
221
  possible_locations = possible_locations_by_field[node.name]
227
222
  if possible_locations.length == 1
@@ -231,13 +226,22 @@ module GraphQL
231
226
  end
232
227
  end
233
228
 
234
- # distribute non-unique fields among available locations, preferring locations already used
229
+ # 2. distribute non-unique fields among locations that are already used
230
+ if selections_by_location.any? && remote_selections.any?
231
+ remote_selections.reject! do |node|
232
+ used_location = possible_locations_by_field[node.name].find { selections_by_location[_1] }
233
+ if used_location
234
+ selections_by_location[used_location] << node
235
+ true
236
+ end
237
+ end
238
+ end
239
+
240
+ # 3. distribute remaining fields among locations weighted by greatest availability
235
241
  if remote_selections.any?
236
- # weight locations by number of required fields available, preferring greater availability
237
- location_weights = if remote_selections.length > 1
242
+ field_count_by_location = if remote_selections.length > 1
238
243
  remote_selections.each_with_object({}) do |node, memo|
239
- possible_locations = possible_locations_by_field[node.name]
240
- possible_locations.each do |location|
244
+ possible_locations_by_field[node.name].each do |location|
241
245
  memo[location] ||= 0
242
246
  memo[location] += 1
243
247
  end
@@ -248,18 +252,16 @@ module GraphQL
248
252
 
249
253
  remote_selections.each do |node|
250
254
  possible_locations = possible_locations_by_field[node.name]
251
- preferred_location_score = 0
255
+ preferred_location = possible_locations.first
252
256
 
253
- # hill climbing selects highest scoring locations to use
254
- preferred_location = possible_locations.reduce(possible_locations.first) do |best_location, possible_location|
255
- score = selections_by_location[possible_location] ? remote_selections.length : 0
256
- score += location_weights.fetch(possible_location, 0)
257
+ possible_locations.reduce(0) do |max_availability, possible_location|
258
+ available_fields = field_count_by_location.fetch(possible_location, 0)
257
259
 
258
- if score > preferred_location_score
259
- preferred_location_score = score
260
- possible_location
260
+ if available_fields > max_availability
261
+ preferred_location = possible_location
262
+ available_fields
261
263
  else
262
- best_location
264
+ max_availability
263
265
  end
264
266
  end
265
267
 
@@ -268,14 +270,14 @@ module GraphQL
268
270
  end
269
271
  end
270
272
 
273
+ # route from current location to target locations via boundary queries,
274
+ # then translate those routes into planner operations
271
275
  routes = @supergraph.route_type_to_locations(parent_type.graphql_name, current_location, selections_by_location.keys)
272
276
  routes.values.each_with_object({}) do |route, ops_by_location|
273
277
  route.reduce(nil) do |parent_op, boundary|
274
278
  location = boundary["location"]
275
- new_operation = false
276
279
 
277
280
  unless op = ops_by_location[location]
278
- new_operation = true
279
281
  op = ops_by_location[location] = add_operation(
280
282
  location: location,
281
283
  # routing locations added as intermediaries have no initial selections,
@@ -291,7 +293,7 @@ module GraphQL
291
293
  foreign_key = "_STITCH_#{boundary["selection"]}"
292
294
  parent_selections = parent_op ? parent_op.selections : locale_selections
293
295
 
294
- if new_operation || parent_selections.none? { _1.is_a?(GraphQL::Language::Nodes::Field) && _1.alias == foreign_key }
296
+ if parent_selections.none? { _1.is_a?(GraphQL::Language::Nodes::Field) && _1.alias == foreign_key }
295
297
  foreign_key_node = GraphQL::Language::Nodes::Field.new(alias: foreign_key, name: boundary["selection"])
296
298
  parent_selections << foreign_key_node << TYPENAME_NODE
297
299
  end
@@ -3,6 +3,8 @@
3
3
  module GraphQL
4
4
  module Stitching
5
5
  class PlannerOperation
6
+ LANGUAGE_PRINTER = GraphQL::Language::Printer.new
7
+
6
8
  attr_reader :key, :location, :parent_type, :type_condition, :operation_type, :insertion_path
7
9
  attr_accessor :after_key, :selections, :variables, :boundary
8
10
 
@@ -32,12 +34,12 @@ module GraphQL
32
34
 
33
35
  def selection_set
34
36
  op = GraphQL::Language::Nodes::OperationDefinition.new(selections: @selections)
35
- GraphQL::Language::Printer.new.print(op).gsub!(/\s+/, " ").strip!
37
+ LANGUAGE_PRINTER.print(op).gsub!(/\s+/, " ").strip!
36
38
  end
37
39
 
38
40
  def variable_set
39
41
  @variables.each_with_object({}) do |(variable_name, value_type), memo|
40
- memo[variable_name] = GraphQL::Language::Printer.new.print(value_type)
42
+ memo[variable_name] = LANGUAGE_PRINTER.print(value_type)
41
43
  end
42
44
  end
43
45
 
@@ -87,7 +87,7 @@ module GraphQL
87
87
  end
88
88
 
89
89
  if operation_defs.length < 1
90
- raise GraphQL::ExecutionError, "Invalid root operation."
90
+ raise GraphQL::ExecutionError, "Invalid root operation for given name and operation type."
91
91
  elsif operation_defs.length > 1
92
92
  raise GraphQL::ExecutionError, "An operation name is required when sending multiple operations."
93
93
  end
@@ -43,7 +43,7 @@ module GraphQL
43
43
 
44
44
  when GraphQL::Language::Nodes::InlineFragment
45
45
  fragment_type = @schema.types[node.type.name]
46
- next unless fragment_matches_typename?(fragment_type, typename)
46
+ next unless typename_in_type?(typename, fragment_type)
47
47
 
48
48
  result = resolve_object_scope(raw_object, fragment_type, node.selections, typename)
49
49
  return nil if result.nil?
@@ -51,7 +51,7 @@ module GraphQL
51
51
  when GraphQL::Language::Nodes::FragmentSpread
52
52
  fragment = @request.fragment_definitions[node.name]
53
53
  fragment_type = @schema.types[fragment.type.name]
54
- next unless fragment_matches_typename?(fragment_type, typename)
54
+ next unless typename_in_type?(typename, fragment_type)
55
55
 
56
56
  result = resolve_object_scope(raw_object, fragment_type, fragment.selections, typename)
57
57
  return nil if result.nil?
@@ -93,9 +93,9 @@ module GraphQL
93
93
  resolved_list
94
94
  end
95
95
 
96
- def fragment_matches_typename?(fragment_type, typename)
97
- return true if fragment_type.graphql_name == typename
98
- fragment_type.kind.interface? && @schema.possible_types(fragment_type).any? { _1.graphql_name == typename }
96
+ def typename_in_type?(typename, type)
97
+ return true if type.graphql_name == typename
98
+ type.kind.abstract? && @schema.possible_types(type).any? { _1.graphql_name == typename }
99
99
  end
100
100
  end
101
101
  end
@@ -10,12 +10,33 @@ module GraphQL
10
10
 
11
11
  # strips non-null wrappers from a type
12
12
  def self.unwrap_non_null(type)
13
- while type.is_a?(GraphQL::Schema::NonNull)
14
- type = type.of_type
15
- end
13
+ type = type.of_type while type.non_null?
16
14
  type
17
15
  end
18
16
 
17
+ # builds a single-dimensional representation of a wrapped type structure
18
+ def self.flatten_type_structure(type)
19
+ structure = []
20
+
21
+ while type.list?
22
+ structure << {
23
+ list: true,
24
+ null: !type.non_null?,
25
+ name: nil,
26
+ }
27
+
28
+ type = unwrap_non_null(type).of_type
29
+ end
30
+
31
+ structure << {
32
+ list: false,
33
+ null: !type.non_null?,
34
+ name: type.unwrap.graphql_name,
35
+ }
36
+
37
+ structure
38
+ end
39
+
19
40
  # gets a named type for a field node, including hidden root introspections
20
41
  def self.named_type_for_field_node(schema, parent_type, node)
21
42
  if node.name == "__schema" && parent_type == schema.query
@@ -40,25 +61,6 @@ module GraphQL
40
61
  end
41
62
  result.uniq
42
63
  end
43
-
44
- # gets a deep structural description of a list value type
45
- def self.get_list_structure(type)
46
- structure = []
47
- previous = nil
48
- while type.respond_to?(:of_type)
49
- if type.is_a?(GraphQL::Schema::List)
50
- structure.push(previous.is_a?(GraphQL::Schema::NonNull) ? "non_null_list" : "list")
51
- end
52
- if structure.any?
53
- previous = type
54
- if !type.of_type.respond_to?(:of_type)
55
- structure.push(previous.is_a?(GraphQL::Schema::NonNull) ? "non_null_element" : "element")
56
- end
57
- end
58
- type = type.of_type
59
- end
60
- structure
61
- end
62
64
  end
63
65
  end
64
66
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "0.3.1"
5
+ VERSION = "0.3.3"
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: 0.3.1
4
+ version: 0.3.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: 2023-03-03 00:00:00.000000000 Z
11
+ date: 2023-03-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -86,6 +86,7 @@ files:
86
86
  - docs/images/library.png
87
87
  - docs/images/merging.png
88
88
  - docs/images/stitching.png
89
+ - docs/mechanics.md
89
90
  - docs/planner.md
90
91
  - docs/request.md
91
92
  - docs/supergraph.md