graphql-stitching 1.2.1 → 1.2.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: 4cb8c0d3b16cda5db8b82d0d0f9eba93a4537c3faa584b1b301dc84b34b5fd0e
4
- data.tar.gz: 83f4b436479307ac8575f718ace5a82288024a6e5741f786151e55920d7ef61f
3
+ metadata.gz: 99acc247a0e5e227236b0261b3f975e7277f642a9d76daaf4f6a985bfc4825ea
4
+ data.tar.gz: ef5f01132498ca4ba1be6f8593f189645ddf8fb6b6d8ab71f9b7d0e55d6ec682
5
5
  SHA512:
6
- metadata.gz: e247e539e223a1fbefcd398c1aeb3940fa91320b81c45caeaa21b3ddca50631ffb6ddbd3963cde07f11609cbaacc1f814a167fefebab8f888979afe929bcb93f
7
- data.tar.gz: 66de3bacb73749bd90b4d8e7ade4bf35cc144e0ae60b3ff1c81a5f548b20d74e204454b6727875984da293b4b3853aa9bf6215283666f2b037a7dc148e8aa64b
6
+ metadata.gz: d1bd0db4c7f6d363231330222006f2bbc552a669227a38aeb7861dde843b59bc3e61b0f301f97d4a5c6b55193d01424cd9f240c0ff7a7ee483e49150686ac3da
7
+ data.tar.gz: 348aa663608fd8b0e5544ccf7ec500d4295a63bb9bd19a91031f927165da2a206024ce6cb17111c3ebda2c172e08072be8d01716c11f0a05c3ea8a9a4b99466e
@@ -16,10 +16,16 @@ jobs:
16
16
  ruby: 3.2
17
17
  - gemfile: Gemfile
18
18
  ruby: 3.1
19
- - gemfile: gemfiles/graphql_1.13.9.gemfile
20
- ruby: 3.1
21
19
  - gemfile: Gemfile
22
20
  ruby: 2.7
21
+ - gemfile: gemfiles/graphql_2.2.0.gemfile
22
+ ruby: 3.1
23
+ - gemfile: gemfiles/graphql_2.1.0.gemfile
24
+ ruby: 3.1
25
+ - gemfile: gemfiles/graphql_2.0.0.gemfile
26
+ ruby: 3.1
27
+ - gemfile: gemfiles/graphql_1.13.9.gemfile
28
+ ruby: 3.1
23
29
 
24
30
  steps:
25
31
  - uses: actions/checkout@v2
data/README.md CHANGED
@@ -9,14 +9,13 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
9
9
  - Multiple keys per merged type.
10
10
  - Shared objects, fields, enums, and inputs across locations.
11
11
  - Combining local and remote schemas.
12
- - Type merging via arbitrary queries or federation `_entities` protocol.
13
12
  - File uploads via [multipart form spec](https://github.com/jaydenseric/graphql-multipart-request-spec).
14
13
 
15
14
  **NOT Supported:**
16
15
  - Computed fields (ie: federation-style `@requires`).
17
16
  - Subscriptions, defer/stream.
18
17
 
19
- This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic. While Ruby is not the fastest language for a purely high-throughput API gateway, the opportunity here is for a Ruby application to stitch its local schemas together or onto remote sources without requiring an additional proxy service running in another language.
18
+ This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic. The opportunity here is for a Ruby application to stitch its local schemas together or onto remote sources without requiring an additional proxy service running in another language. If your goal is to build a purely high-throughput API gateway, consider not using Ruby.
20
19
 
21
20
  ## Getting started
22
21
 
@@ -34,7 +33,7 @@ require "graphql/stitching"
34
33
 
35
34
  ## Usage
36
35
 
37
- The quickest way to start is to use the provided [`Client`](./docs/client.md) component that wraps a stitched graph in an executable workflow with [caching hooks](./docs/client.md#cache-hooks):
36
+ The quickest way to start is to use the provided [`Client`](./docs/client.md) component that wraps a stitched graph in an executable workflow (with optional query plan caching hooks):
38
37
 
39
38
  ```ruby
40
39
  movies_schema = <<~GRAPHQL
@@ -72,15 +71,13 @@ result = client.execute(
72
71
  )
73
72
  ```
74
73
 
75
- 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
+ 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 via [executables](#executables).
76
75
 
77
76
  While the `Client` constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
78
77
 
79
78
  - [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
80
79
  - [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
81
- - [Request](./docs/request.md) - prepares a requested GraphQL document and variables for stitching.
82
- - [Planner](./docs/planner.md) - builds a cacheable query plan for a request document.
83
- - [Executor](./docs/executor.md) - executes a query plan with given request variables.
80
+ - [Request](./docs/request.md) - manages the lifecycle of a stitched GraphQL request.
84
81
  - [HttpExecutable](./docs/http_executable.md) - proxies requests to remotes with multipart file upload support.
85
82
 
86
83
  ## Merged types
@@ -89,11 +86,11 @@ While the `Client` constructor is an easy quick start, the library also has seve
89
86
 
90
87
  ![Merging types](./docs/images/merging.png)
91
88
 
92
- To facilitate this merging of types, stitching must know how to cross-reference and fetch each variant of a type from its source location. This can be done using [arbitrary queries](#merged-types-via-arbitrary-queries) or [federation entities](#merged-types-via-federation-entities).
89
+ To facilitate this merging of types, stitching must know how to cross-reference and fetch each variant of a type from its source location using [resolver queries](#merged-type-resolver-queries). For those in an Apollo ecosystem, there's also _limited_ support for merging types though a [federation `_entities` protocol](./docs/federation_entities.md).
93
90
 
94
- ### Merged types via arbitrary queries
91
+ ### Merged type resolver queries
95
92
 
96
- Types can merge through arbitrary queries using the `@stitch` directive:
93
+ Types merge through resolver queries identified by a `@stitch` directive:
97
94
 
98
95
  ```graphql
99
96
  directive @stitch(key: String!) repeatable on FIELD_DEFINITION
@@ -134,7 +131,7 @@ client = GraphQL::Stitching::Client.new(locations: {
134
131
  executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
135
132
  },
136
133
  catalog: {
137
- schema: GraphQL::Schema.from_definition(shipping_schema),
134
+ schema: GraphQL::Schema.from_definition(catalog_schema),
138
135
  executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
139
136
  },
140
137
  })
@@ -155,7 +152,7 @@ type Query {
155
152
  * 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.
156
153
  * The `key: "id"` parameter indicates that an `{ id }` must be selected from prior locations so it may be submitted as an argument to this query. The query argument used to send the key is inferred when possible ([more on arguments](#multiple-query-arguments) later).
157
154
 
158
- Each location that provides a unique variant of a type must provide at least one stitching query. The exception to this requirement are types that contain only a single key field:
155
+ Each location that provides a unique variant of a type must provide at least one stitching query for the type. The exception to this requirement are [foreign key types](./docs/mechanics.md##modeling-foreign-keys-for-stitching) that contain only a single key field:
159
156
 
160
157
  ```graphql
161
158
  type Product {
@@ -163,7 +160,7 @@ type Product {
163
160
  }
164
161
  ```
165
162
 
166
- The above representation of a `Product` type provides no unique data beyond a key that is available in other locations. Thus, this representation will never require an inbound request to fetch it, and its stitching query may be omitted. This pattern of providing key-only types is very common in stitching: it allows a foreign key to be represented as an object stub that may be enriched by data collected from other locations.
163
+ The above representation of a `Product` type provides no unique data beyond a key that is available in other locations. Thus, this representation will never require an inbound request to fetch it, and its stitching query may be omitted.
167
164
 
168
165
  #### List queries
169
166
 
@@ -301,65 +298,6 @@ The library is configured to use a `@stitch` directive by default. You may custo
301
298
  GraphQL::Stitching.stitch_directive = "merge"
302
299
  ```
303
300
 
304
- ### Merged types via Federation entities
305
-
306
- The [Apollo Federation specification](https://www.apollographql.com/docs/federation/subgraph-spec/) defines a standard interface for accessing merged type variants across locations. Stitching can utilize a _subset_ of this interface to facilitate basic type merging. The following spec is supported:
307
-
308
- - `@key(fields: "id")` (repeatable) specifies a key field for an object type. The key `fields` argument may only contain one field selection.
309
- - `_Entity` is a union type that must contain all types that implement a `@key`.
310
- - `_Any` is a scalar that recieves raw JSON objects; each object representation contains a `__typename` and the type's key field.
311
- - `_entities(representations: [_Any!]!): [_Entity]!` is a root query for local entity types.
312
-
313
- The composer will automatcially detect and stitch schemas with an `_entities` query, for example:
314
-
315
- ```ruby
316
- products_schema = <<~GRAPHQL
317
- directive @key(fields: String!) repeatable on OBJECT
318
-
319
- type Product @key(fields: "id") {
320
- id: ID!
321
- name: String!
322
- }
323
-
324
- union _Entity = Product
325
- scalar _Any
326
-
327
- type Query {
328
- user(id: ID!): User
329
- _entities(representations: [_Any!]!): [_Entity]!
330
- }
331
- GRAPHQL
332
-
333
- catalog_schema = <<~GRAPHQL
334
- directive @key(fields: String!) repeatable on OBJECT
335
-
336
- type Product @key(fields: "id") {
337
- id: ID!
338
- price: Float!
339
- }
340
-
341
- union _Entity = Product
342
- scalar _Any
343
-
344
- type Query {
345
- _entities(representations: [_Any!]!): [_Entity]!
346
- }
347
- GRAPHQL
348
-
349
- client = GraphQL::Stitching::Client.new(locations: {
350
- products: {
351
- schema: GraphQL::Schema.from_definition(products_schema),
352
- executable: ...,
353
- },
354
- catalog: {
355
- schema: GraphQL::Schema.from_definition(catalog_schema),
356
- executable: ...,
357
- },
358
- })
359
- ```
360
-
361
- It's perfectly fine to mix and match schemas that implement an `_entities` query with schemas that implement `@stitch` directives; the protocols achieve the same result. Note that stitching is much simpler than Apollo Federation by design, and that Federation's advanced routing features (such as the `@requires` and `@external` directives) will not work with stitching.
362
-
363
301
  ## Executables
364
302
 
365
303
  An executable resource performs location-specific GraphQL requests. Executables may be `GraphQL::Schema` classes, or any object that responds to `.call(request, source, variables)` and returns a raw GraphQL response:
@@ -427,7 +365,9 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im
427
365
 
428
366
  ## Additional topics
429
367
 
368
+ - [Modeling foreign keys for stitching](./docs/mechanics.md##modeling-foreign-keys-for-stitching)
430
369
  - [Deploying a stitched schema](./docs/mechanics.md#deploying-a-stitched-schema)
370
+ - [Schema composition merge patterns](./docs/composer.md#merge-patterns)
431
371
  - [Field selection routing](./docs/mechanics.md#field-selection-routing)
432
372
  - [Root selection routing](./docs/mechanics.md#root-selection-routing)
433
373
  - [Stitched errors](./docs/mechanics.md#stitched-errors)
data/docs/README.md CHANGED
@@ -10,8 +10,6 @@ Major components include:
10
10
  - [Composer](./composer.md) - merges and validates many schemas into one graph.
11
11
  - [Supergraph](./supergraph.md) - manages the combined schema and location routing maps. Can be exported, cached, and rehydrated.
12
12
  - [Request](./request.md) - prepares a requested GraphQL document and variables for stitching.
13
- - [Planner](./planner.md) - builds a cacheable query plan for a request document.
14
- - [Executor](./executor.md) - executes a query plan with given request variables.
15
13
  - [HttpExecutable](./http_executable.md) - proxies requests to remotes with multipart file upload support.
16
14
 
17
15
  Additional topics:
@@ -0,0 +1,70 @@
1
+ ## Merged types via Apollo Federation `_entities`
2
+
3
+ The [Apollo Federation specification](https://www.apollographql.com/docs/federation/subgraph-spec/) defines a standard interface for accessing merged type variants across locations. Stitching can utilize a _subset_ of this interface to facilitate basic type merging; the full spec is NOT supported and therefore is not fully interchangable with an Apollo Gateway.
4
+
5
+ To avoid confusion, using [basic resolver queries](../README.md#merged-type-resolver-queries) is recommended unless you specifically need to interact with a service built for an Apollo ecosystem. Even then, be wary that it does not exceed the supported spec by [using features that will not work](#federation-features-that-will-most-definitly-break).
6
+
7
+ ### Supported spec
8
+
9
+ The following subset of the federation spec is supported:
10
+
11
+ - `@key(fields: "id")` (repeatable) specifies a key field for an object type. The key `fields` argument may only contain one field selection.
12
+ - `_Entity` is a union type that must contain all types that implement a `@key`.
13
+ - `_Any` is a scalar that recieves raw JSON objects; each object representation contains a `__typename` and the type's key field.
14
+ - `_entities(representations: [_Any!]!): [_Entity]!` is a root query for local entity types.
15
+
16
+ The composer will automatcially detect and stitch schemas with an `_entities` query, for example:
17
+
18
+ ```ruby
19
+ products_schema = <<~GRAPHQL
20
+ directive @key(fields: String!) repeatable on OBJECT
21
+
22
+ type Product @key(fields: "id") {
23
+ id: ID!
24
+ name: String!
25
+ }
26
+
27
+ union _Entity = Product
28
+ scalar _Any
29
+
30
+ type Query {
31
+ user(id: ID!): User
32
+ _entities(representations: [_Any!]!): [_Entity]!
33
+ }
34
+ GRAPHQL
35
+
36
+ catalog_schema = <<~GRAPHQL
37
+ directive @key(fields: String!) repeatable on OBJECT
38
+
39
+ type Product @key(fields: "id") {
40
+ id: ID!
41
+ price: Float!
42
+ }
43
+
44
+ union _Entity = Product
45
+ scalar _Any
46
+
47
+ type Query {
48
+ _entities(representations: [_Any!]!): [_Entity]!
49
+ }
50
+ GRAPHQL
51
+
52
+ client = GraphQL::Stitching::Client.new(locations: {
53
+ products: {
54
+ schema: GraphQL::Schema.from_definition(products_schema),
55
+ executable: ...,
56
+ },
57
+ catalog: {
58
+ schema: GraphQL::Schema.from_definition(catalog_schema),
59
+ executable: ...,
60
+ },
61
+ })
62
+ ```
63
+
64
+ It's perfectly fine to mix and match schemas that implement an `_entities` query with schemas that implement `@stitch` directives; the protocols achieve the same result.
65
+
66
+ ### Federation features that will most definitly break
67
+
68
+ - `@external` fields will confuse the stitching query planner.
69
+ - `@requires` fields will not be sent any dependencies.
70
+ - No support for Apollo composition directives.
data/docs/mechanics.md CHANGED
@@ -1,5 +1,47 @@
1
1
  ## Schema Stitching, mechanics
2
2
 
3
+ ### Modeling foreign keys for stitching
4
+
5
+ Foreign keys in a GraphQL schema typically look like the `Product.imageId` field here:
6
+
7
+ ```graphql
8
+ # -- Products schema:
9
+
10
+ type Product {
11
+ id: ID!
12
+ imageId: ID!
13
+ }
14
+
15
+ # -- Images schema:
16
+
17
+ type Image {
18
+ id: ID!
19
+ url: String!
20
+ }
21
+ ```
22
+
23
+ However, this design does not lend itself to stitching where types need to _merge_ across locations. A simple schema refactor makes this foreign key more expressive as an entity type, and turns the key into an _object_ that will merge with analogous object types in other locations:
24
+
25
+ ```graphql
26
+ # -- Products schema:
27
+
28
+ type Product {
29
+ id: ID!
30
+ image: Image!
31
+ }
32
+
33
+ type Image {
34
+ id: ID!
35
+ }
36
+
37
+ # -- Images schema:
38
+
39
+ type Image {
40
+ id: ID!
41
+ url: String!
42
+ }
43
+ ```
44
+
3
45
  ### Deploying a stitched schema
4
46
 
5
47
  Among the simplest and most effective ways to manage a stitched schema is to compose it locally, write the composed SDL as a `.graphql` file in your repo, and then load the composed schema into a stitching client at runtime. For example, setup a `rake` task that loads/fetches subgraph schemas, composes them, and then writes the composed schema definition as a file committed to the repo:
data/docs/request.md CHANGED
@@ -25,37 +25,12 @@ A `Request` provides the following information:
25
25
  - `req.variable_definitions`: a mapping of variable names to their type definitions
26
26
  - `req.fragment_definitions`: a mapping of fragment names to their fragment definitions
27
27
 
28
- ### Preparing requests
28
+ ### Request lifecycle
29
29
 
30
- A request should be prepared for stitching using the `prepare!` method _after_ validations have been run:
30
+ A request manages the flow of stitching behaviors. These are sequenced by the `Client`
31
+ component, or you may invoke them manually:
31
32
 
32
- ```ruby
33
- document = <<~GRAPHQL
34
- query FetchMovie($id: ID!, $lang: String = "en", $withShowtimes: Boolean = true) {
35
- movie(id:$id) {
36
- id
37
- title(lang: $lang)
38
- showtimes @include(if: $withShowtimes) {
39
- time
40
- }
41
- }
42
- }
43
- GRAPHQL
44
-
45
- request = GraphQL::Stitching::Request.new(
46
- supergraph,
47
- document,
48
- variables: { "id" => "1" },
49
- operation_name: "FetchMovie",
50
- )
51
-
52
- errors = MySchema.validate(request.document)
53
- # return early with any static validation errors...
54
-
55
- request.prepare!
56
- ```
57
-
58
- Preparing a request will apply several destructive transformations:
59
-
60
- - Default values from variable definitions will be added to request variables.
61
- - The document will be pre-shaped based on `@skip` and `@include` directives.
33
+ 1. `request.validate`: runs static validations on the request using the combined schema.
34
+ 2. `request.prepare!`: inserts variable defaults and pre-renders skip/include conditional shaping.
35
+ 3. `request.plan`: builds a plan for the request. May act as a setting for plans pulled from cache.
36
+ 4. `request.execute`: executes the request, and returns the resulting data.
@@ -3,4 +3,4 @@
3
3
  source 'https://rubygems.org'
4
4
  gemspec
5
5
 
6
- gem 'graphql', '1.13.9'
6
+ gem 'graphql', '~> 1.13.9'
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ gem 'graphql', '~> 2.0.0'
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ gem 'graphql', '~> 2.1.0'
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ gem 'graphql', '~> 2.2.0'
@@ -145,7 +145,8 @@ module GraphQL
145
145
 
146
146
  builder = self
147
147
  schema = Class.new(GraphQL::Schema) do
148
- orphan_types schema_types.values
148
+ add_type_and_traverse(schema_types.values, root: false)
149
+ orphan_types(schema_types.values.select { |t| t.respond_to?(:kind) && t.kind.object? })
149
150
  query schema_types[builder.query_name]
150
151
  mutation schema_types[builder.mutation_name]
151
152
  directives builder.schema_directives.values
@@ -263,7 +264,6 @@ module GraphQL
263
264
  enum_values_by_name_location = types_by_location.each_with_object({}) do |(location, type_candidate), memo|
264
265
  type_candidate.enum_values.each do |enum_value_candidate|
265
266
  memo[enum_value_candidate.graphql_name] ||= {}
266
- memo[enum_value_candidate.graphql_name][location] ||= {}
267
267
  memo[enum_value_candidate.graphql_name][location] = enum_value_candidate
268
268
  end
269
269
  end
@@ -376,7 +376,6 @@ module GraphQL
376
376
  @field_map[type_name][field_candidate.name] << location
377
377
 
378
378
  memo[field_name] ||= {}
379
- memo[field_name][location] ||= {}
380
379
  memo[field_name][location] = field_candidate
381
380
  end
382
381
  end
@@ -406,7 +405,6 @@ module GraphQL
406
405
  args_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo|
407
406
  member_candidate.arguments.each do |argument_name, argument|
408
407
  memo[argument_name] ||= {}
409
- memo[argument_name][location] ||= {}
410
408
  memo[argument_name][location] = argument
411
409
  end
412
410
  end
@@ -461,7 +459,6 @@ module GraphQL
461
459
  directives_by_name_location = members_by_location.each_with_object({}) do |(location, member_candidate), memo|
462
460
  member_candidate.directives.each do |directive|
463
461
  memo[directive.graphql_name] ||= {}
464
- memo[directive.graphql_name][location] ||= {}
465
462
  memo[directive.graphql_name][location] = directive
466
463
  end
467
464
  end
@@ -652,22 +649,22 @@ module GraphQL
652
649
 
653
650
  schemas.each do |schema|
654
651
  introspection_types = schema.introspection_system.types.keys
655
- schema.types.values.each do |type|
652
+ schema.types.each_value do |type|
656
653
  next if introspection_types.include?(type.graphql_name)
657
654
 
658
655
  if type.kind.object? || type.kind.interface?
659
- type.fields.values.each do |field|
656
+ type.fields.each_value do |field|
660
657
  field_type = field.type.unwrap
661
658
  reads << field_type.graphql_name if field_type.kind.enum?
662
659
 
663
- field.arguments.values.each do |argument|
660
+ field.arguments.each_value do |argument|
664
661
  argument_type = argument.type.unwrap
665
662
  writes << argument_type.graphql_name if argument_type.kind.enum?
666
663
  end
667
664
  end
668
665
 
669
666
  elsif type.kind.input_object?
670
- type.arguments.values.each do |argument|
667
+ type.arguments.each_value do |argument|
671
668
  argument_type = argument.type.unwrap
672
669
  writes << argument_type.graphql_name if argument_type.kind.enum?
673
670
  end
@@ -20,10 +20,22 @@ module GraphQL::Stitching
20
20
  result = @executor.request.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
21
21
  @executor.query_count += 1
22
22
 
23
- @executor.data.merge!(result["data"]) if result["data"]
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!)
28
+ end
29
+
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"])
34
+ end
35
+ end
36
+
24
37
  if result["errors"]&.any?
25
- result["errors"].each { _1.delete("locations") }
26
- @executor.errors.concat(result["errors"])
38
+ @executor.errors.concat(format_errors!(result["errors"], op.path))
27
39
  end
28
40
 
29
41
  ops.map(&:step)
@@ -51,6 +63,16 @@ module GraphQL::Stitching
51
63
  doc << op.selections
52
64
  doc
53
65
  end
66
+
67
+ # 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?
73
+ end
74
+ errors
75
+ end
54
76
  end
55
77
  end
56
78
  end
@@ -276,19 +276,21 @@ module GraphQL
276
276
  routes.each_value do |route|
277
277
  route.reduce(locale_selections) do |parent_selections, boundary|
278
278
  # E.1) Add the key of each boundary query into the prior location's selection set.
279
- foreign_key = ExportSelection.key(boundary.key)
280
- has_key = false
281
- has_typename = false
282
-
283
- parent_selections.each do |node|
284
- next unless node.is_a?(GraphQL::Language::Nodes::Field)
285
- has_key ||= node.alias == foreign_key
286
- has_typename ||= node.alias == ExportSelection.typename_node.alias
279
+ if boundary.key
280
+ foreign_key = ExportSelection.key(boundary.key)
281
+ has_key = false
282
+ has_typename = false
283
+
284
+ parent_selections.each do |node|
285
+ next unless node.is_a?(GraphQL::Language::Nodes::Field)
286
+ has_key ||= node.alias == foreign_key
287
+ has_typename ||= node.alias == ExportSelection.typename_node.alias
288
+ end
289
+
290
+ parent_selections << ExportSelection.key_node(boundary.key) unless has_key
291
+ parent_selections << ExportSelection.typename_node unless has_typename
287
292
  end
288
293
 
289
- parent_selections << ExportSelection.key_node(boundary.key) unless has_key
290
- parent_selections << ExportSelection.typename_node unless has_typename
291
-
292
294
  # E.2) Add a planner step for each new entrypoint location.
293
295
  add_step(
294
296
  location: boundary.location,
@@ -296,7 +298,7 @@ module GraphQL
296
298
  parent_type: parent_type,
297
299
  selections: remote_selections_by_location[boundary.location] || [],
298
300
  path: path.dup,
299
- boundary: boundary,
301
+ boundary: boundary.key ? boundary : nil,
300
302
  ).selections
301
303
  end
302
304
  end
@@ -120,12 +120,13 @@ module GraphQL
120
120
  end
121
121
 
122
122
  # Validates the request using the combined supergraph schema.
123
+ # @return [Array<GraphQL::ExecutionError>] an array of static validation errors
123
124
  def validate
124
125
  result = @supergraph.static_validator.validate(@query)
125
126
  result[:errors]
126
127
  end
127
128
 
128
- # Prepares the request for stitching by rendering variable defaults and applying @skip/@include conditionals.
129
+ # Prepares the request for stitching by inserting variable defaults and applying @skip/@include conditionals.
129
130
  def prepare!
130
131
  operation.variables.each do |v|
131
132
  @variables[v.name] = v.default_value if @variables[v.name].nil? && !v.default_value.nil?
@@ -143,7 +144,17 @@ module GraphQL
143
144
  self
144
145
  end
145
146
 
146
- # Gets and sets the query plan for the request. Assigned query plans may pull from cache.
147
+ # Gets and sets the query plan for the request. Assigned query plans may pull from a cache,
148
+ # which is useful for redundant GraphQL documents (commonly sent by frontend clients).
149
+ # ```ruby
150
+ # if cached_plan = $cache.get(request.digest)
151
+ # plan = GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
152
+ # request.plan(plan)
153
+ # else
154
+ # plan = request.plan
155
+ # $cache.set(request.digest, JSON.generate(plan.as_json))
156
+ # end
157
+ # ```
147
158
  # @param new_plan [Plan, nil] a cached query plan for the request.
148
159
  # @return [Plan] query plan for the request.
149
160
  def plan(new_plan = nil)
@@ -245,7 +245,11 @@ module GraphQL
245
245
  # ("Type") => ["id", ...]
246
246
  def possible_keys_for_type(type_name)
247
247
  @possible_keys_by_type[type_name] ||= begin
248
- @boundaries[type_name].map(&:key).tap(&:uniq!)
248
+ if type_name == @schema.query.graphql_name
249
+ GraphQL::Stitching::EMPTY_ARRAY
250
+ else
251
+ @boundaries[type_name].map(&:key).tap(&:uniq!)
252
+ end
249
253
  end
250
254
  end
251
255
 
@@ -262,16 +266,25 @@ module GraphQL
262
266
  # For a given type, route from one origin location to one or more remote locations
263
267
  # used to connect a partial type across locations via boundary queries
264
268
  def route_type_to_locations(type_name, start_location, goal_locations)
265
- if possible_keys_for_type(type_name).length > 1
269
+ key_count = possible_keys_for_type(type_name).length
270
+
271
+ if key_count.zero?
272
+ # nested root scopes have no boundary keys and just return a location
273
+ goal_locations.each_with_object({}) do |goal_location, memo|
274
+ memo[goal_location] = [Boundary.new(location: goal_location)]
275
+ end
276
+
277
+ elsif key_count > 1
266
278
  # multiple keys use an A* search to traverse intermediary locations
267
- return route_type_to_locations_via_search(type_name, start_location, goal_locations)
268
- end
279
+ route_type_to_locations_via_search(type_name, start_location, goal_locations)
269
280
 
270
- # types with a single key attribute must all be within a single hop of each other,
271
- # so can use a simple match to collect boundaries for the goal locations.
272
- @boundaries[type_name].each_with_object({}) do |boundary, memo|
273
- if goal_locations.include?(boundary.location)
274
- memo[boundary.location] = [boundary]
281
+ else
282
+ # types with a single key attribute must all be within a single hop of each other,
283
+ # so can use a simple match to collect boundaries for the goal locations.
284
+ @boundaries[type_name].each_with_object({}) do |boundary, memo|
285
+ if goal_locations.include?(boundary.location)
286
+ memo[boundary.location] = [boundary]
287
+ end
275
288
  end
276
289
  end
277
290
  end
@@ -54,7 +54,7 @@ module GraphQL
54
54
  return parent_type.possible_types if parent_type.kind.union?
55
55
 
56
56
  result = []
57
- schema.types.values.each do |type|
57
+ schema.types.each_value do |type|
58
58
  next unless type <= GraphQL::Schema::Interface && type != parent_type
59
59
  next unless type.interfaces.include?(parent_type)
60
60
  result << type
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "1.2.1"
5
+ VERSION = "1.2.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: 1.2.1
4
+ version: 1.2.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: 2024-01-13 00:00:00.000000000 Z
11
+ date: 2024-04-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -82,13 +82,12 @@ files:
82
82
  - docs/README.md
83
83
  - docs/client.md
84
84
  - docs/composer.md
85
- - docs/executor.md
85
+ - docs/federation_entities.md
86
86
  - docs/http_executable.md
87
87
  - docs/images/library.png
88
88
  - docs/images/merging.png
89
89
  - docs/images/stitching.png
90
90
  - docs/mechanics.md
91
- - docs/planner.md
92
91
  - docs/request.md
93
92
  - docs/supergraph.md
94
93
  - examples/file_uploads/Gemfile
@@ -106,6 +105,9 @@ files:
106
105
  - examples/merged_types/remote1.rb
107
106
  - examples/merged_types/remote2.rb
108
107
  - gemfiles/graphql_1.13.9.gemfile
108
+ - gemfiles/graphql_2.0.0.gemfile
109
+ - gemfiles/graphql_2.1.0.gemfile
110
+ - gemfiles/graphql_2.2.0.gemfile
109
111
  - graphql-stitching.gemspec
110
112
  - lib/graphql/stitching.rb
111
113
  - lib/graphql/stitching/boundary.rb
data/docs/executor.md DELETED
@@ -1,68 +0,0 @@
1
- ## GraphQL::Stitching::Executor
2
-
3
- An `Executor` accepts a [`Supergraph`](./supergraph.md), a [query plan hash](./planner.md), and optional request variables. It handles executing requests and merging results collected from across graph locations.
4
-
5
- ```ruby
6
- query = <<~GRAPHQL
7
- query MyQuery($id: ID!) {
8
- product(id:$id) {
9
- title
10
- brands { name }
11
- }
12
- }
13
- GRAPHQL
14
-
15
- request = GraphQL::Stitching::Request.new(
16
- supergraph,
17
- query,
18
- variables: { "id" => "123" },
19
- operation_name: "MyQuery",
20
- context: { ... },
21
- )
22
-
23
- # Via Request:
24
- result = request.execute
25
-
26
- # Via Executor:
27
- result = GraphQL::Stitching::Executor.new(request).perform
28
- ```
29
-
30
- ### Raw results
31
-
32
- By default, execution results are always returned with document shaping (stitching additions removed, missing fields added, null bubbling applied). You may access the raw execution result by calling the `perform` method with a `raw: true` argument:
33
-
34
- ```ruby
35
- # get the raw result without shaping using either form:
36
- raw_result = request.execute(raw: true)
37
- raw_result = GraphQL::Stitching::Executor.new(request).perform(raw: true)
38
- ```
39
-
40
- The raw result will contain many irregularities from the stitching process, however may be insightful when debugging inconsistencies in results:
41
-
42
- ```ruby
43
- {
44
- "data" => {
45
- "product" => {
46
- "upc" => "1",
47
- "_export_upc" => "1",
48
- "_export_typename" => "Product",
49
- "name" => "iPhone",
50
- "price" => nil,
51
- }
52
- }
53
- }
54
- ```
55
-
56
- ### Batching
57
-
58
- The Executor batches together as many requests as possible to a given location at a given time. Batched queries are written with the operation name suffixed by all operation keys in the batch, and root stitching fields are each prefixed by their batch index and collection index (for non-list fields):
59
-
60
- ```graphql
61
- query MyOperation_2_3($lang:String!,$currency:Currency!){
62
- _0_result: storefronts(ids:["7","8"]) { name(lang:$lang) }
63
- _1_0_result: product(upc:"abc") { price(currency:$currency) }
64
- _1_1_result: product(upc:"xyz") { price(currency:$currency) }
65
- }
66
- ```
67
-
68
- All told, the executor will make one request per location per generation of data. Generations started on separate forks of the resolution tree will be resolved independently.
data/docs/planner.md DELETED
@@ -1,45 +0,0 @@
1
- ## GraphQL::Stitching::Planner
2
-
3
- A `Planner` generates a query plan for a given [`Supergraph`](./supergraph.md) and [`Request`](./request.md). The generated plan breaks down all the discrete GraphQL operations that must be delegated across locations and their sequencing.
4
-
5
- ```ruby
6
- document = <<~GRAPHQL
7
- query MyQuery($id: ID!) {
8
- product(id:$id) {
9
- title
10
- brands { name }
11
- }
12
- }
13
- GRAPHQL
14
-
15
- request = GraphQL::Stitching::Request.new(
16
- supergraph,
17
- document,
18
- variables: { "id" => "1" },
19
- operation_name: "MyQuery",
20
- ).prepare!
21
-
22
- # Via Request:
23
- plan = request.plan
24
-
25
- # Via Planner:
26
- plan = GraphQL::Stitching::Planner.new(request).perform
27
- ```
28
-
29
- ### Caching
30
-
31
- Plans are designed to be cacheable. This is very useful for redundant GraphQL documents (commonly sent by frontend clients) where there's no sense in planning every request individually. It's far more efficient to generate a plan once and cache it, then simply retreive the plan and execute it for future requests.
32
-
33
- ```ruby
34
- cached_plan = $cache.get(request.digest)
35
-
36
- if cached_plan
37
- plan = GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
38
- request.plan(plan)
39
- else
40
- plan = request.plan
41
- $cache.set(request.digest, JSON.generate(plan.as_json))
42
- end
43
-
44
- # execute the plan...
45
- ```