graphql-stitching 1.7.0 → 1.7.1

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.
@@ -0,0 +1,457 @@
1
+ ## Merged Types
2
+
3
+ `Object` and `Interface` types may exist with different fields in different graph locations, and will get merged together in the combined supergraph schema.
4
+
5
+ ![Merging types](./images/merging.png)
6
+
7
+ To facilitate this, schemas should be designed around **merged type keys** that stitching can cross-reference and fetch across locations using **type resolver queries** (discussed below). For those in an Apollo ecosystem, there's also _limited_ support for merging types though [federation `_entities`](./merged_types_apollo.md).
8
+
9
+ ### Merged type keys
10
+
11
+ Foreign keys in a GraphQL schema frequently look like the `Product.imageId` field here:
12
+
13
+ ```graphql
14
+ # -- Products schema:
15
+
16
+ type Product {
17
+ id: ID!
18
+ imageId: ID!
19
+ }
20
+
21
+ # -- Images schema:
22
+
23
+ type Image {
24
+ id: ID!
25
+ url: String!
26
+ }
27
+ ```
28
+
29
+ However, this design does not lend itself to merging types 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 objects in other locations:
30
+
31
+ ```graphql
32
+ # -- Products schema:
33
+
34
+ type Product {
35
+ id: ID!
36
+ image: Image!
37
+ }
38
+
39
+ type Image {
40
+ id: ID!
41
+ }
42
+
43
+ # -- Images schema:
44
+
45
+ type Image {
46
+ id: ID!
47
+ url: String!
48
+ }
49
+ ```
50
+
51
+ ### Merged type resolver queries
52
+
53
+ Each location that provides a unique variant of a type must provide at least one _resolver query_ for accessing it. Type resolvers are root queries identified by a `@stitch` directive:
54
+
55
+ ```graphql
56
+ directive @stitch(key: String!, arguments: String, typeName: String) repeatable on FIELD_DEFINITION
57
+ ```
58
+
59
+ This directive tells stitching how to cross-reference and fetch types from across locations, for example:
60
+
61
+ ```ruby
62
+ products_schema = <<~GRAPHQL
63
+ directive @stitch(key: String!, arguments: String) repeatable on FIELD_DEFINITION
64
+
65
+ type Product {
66
+ id: ID!
67
+ name: String!
68
+ }
69
+
70
+ type Query {
71
+ product(id: ID!): Product @stitch(key: "id")
72
+ }
73
+ GRAPHQL
74
+
75
+ catalog_schema = <<~GRAPHQL
76
+ directive @stitch(key: String!, arguments: String) repeatable on FIELD_DEFINITION
77
+
78
+ type Product {
79
+ id: ID!
80
+ price: Float!
81
+ }
82
+
83
+ type Query {
84
+ products(ids: [ID!]!): [Product]! @stitch(key: "id")
85
+ }
86
+ GRAPHQL
87
+
88
+ client = GraphQL::Stitching::Client.new(locations: {
89
+ products: {
90
+ schema: GraphQL::Schema.from_definition(products_schema),
91
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
92
+ },
93
+ catalog: {
94
+ schema: GraphQL::Schema.from_definition(catalog_schema),
95
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
96
+ },
97
+ })
98
+ ```
99
+
100
+ Focusing on the `@stitch` directive usage:
101
+
102
+ ```graphql
103
+ type Product {
104
+ id: ID!
105
+ name: String!
106
+ }
107
+ type Query {
108
+ product(id: ID!): Product @stitch(key: "id")
109
+ }
110
+ ```
111
+
112
+ * The `@stitch` directive marks a root query where the merged type may be accessed. The merged type identity is inferred from the field return.
113
+ * The `key: "id"` parameter indicates that an `{ id }` must be selected from prior locations so it can be submitted as an argument to this query. The query argument used to send the key is inferred when possible ([more on arguments](#argument-shapes) later).
114
+
115
+ Merged types must have a resolver query in each of their possible locations. The one exception to this requirement are [outbound-only types](#outbound-only-merged-types) that contain no exclusive data; these may omit their resolver because they never require an inbound request to fetch them.
116
+
117
+ #### List queries
118
+
119
+ It's generally preferable to provide a list accessor as a resolver query for optimal batching. The only requirement is that both the field argument and the return type must be lists, and the query results are expected to be a mapped set with `null` holding the position of missing results.
120
+
121
+ ```graphql
122
+ type Query {
123
+ products(ids: [ID!]!): [Product]! @stitch(key: "id")
124
+ }
125
+
126
+ # input: ["1", "2", "3"]
127
+ # result: [{ id: "1" }, null, { id: "3" }]
128
+ ```
129
+
130
+ See [error handling](./error_handling.md#list-queries) tips for list queries.
131
+
132
+ #### Abstract queries
133
+
134
+ It's okay for resolver queries to be implemented through abstract types. An abstract query will provide access to all of its possible types by default, each of which must implement the key.
135
+
136
+ ```graphql
137
+ interface Node {
138
+ id: ID!
139
+ }
140
+ type Product implements Node {
141
+ id: ID!
142
+ name: String!
143
+ }
144
+ type Query {
145
+ nodes(ids: [ID!]!): [Node]! @stitch(key: "id")
146
+ }
147
+ ```
148
+
149
+ To customize which types an abstract query provides and their respective keys, add a `typeName` constraint. This can be repeated to select multiple types from an abstract.
150
+
151
+ ```graphql
152
+ type Product { sku: ID! }
153
+ type Order { id: ID! }
154
+ type Customer { id: ID! } # << not stitched
155
+ union Entity = Product | Order | Customer
156
+
157
+ type Query {
158
+ entity(key: ID!): Entity
159
+ @stitch(key: "sku", typeName: "Product")
160
+ @stitch(key: "id", typeName: "Order")
161
+ }
162
+ ```
163
+
164
+ #### Argument shapes
165
+
166
+ Stitching infers which argument to use for queries with a single argument, or when the key name matches its intended argument. For custom mappings, use the `arguments` option:
167
+
168
+ ```graphql
169
+ type Product {
170
+ id: ID!
171
+ }
172
+ union Entity = Product
173
+
174
+ type Query {
175
+ entity(key: ID!, type: String!): Entity @stitch(
176
+ key: "id",
177
+ arguments: "key: $.id, type: $.__typename",
178
+ typeName: "Product",
179
+ )
180
+ }
181
+ ```
182
+
183
+ The `arguments` option specifies a template of [GraphQL arguments](https://spec.graphql.org/October2021/#sec-Language.Arguments) (or, GraphQL syntax that would normally be written into an arguments closure). This template may include key insertions prefixed by `$` with dot-notation paths to any selections made by the resolver `key`. A `__typename` key selection is also always available. This arguments syntax allows sending multiple arguments that intermix stitching keys with complex input shapes and/or static values.
184
+
185
+ <details>
186
+ <summary>All argument patterns</summary>
187
+
188
+ ---
189
+
190
+ **List arguments**
191
+
192
+ List arguments may specify input just like non-list arguments, and [GraphQL list input coercion](https://spec.graphql.org/October2021/#sec-List.Input-Coercion) will assume the shape represents a list item:
193
+
194
+ ```graphql
195
+ type Query {
196
+ product(ids: [ID!]!, organization: ID!): [Product]!
197
+ @stitch(key: "id", arguments: "ids: $.id, organization: '1'")
198
+ }
199
+ ```
200
+
201
+ List resolvers (that return list types) may _only_ insert keys into repeatable list arguments, while non-list arguments may only contain static values. Nested list inputs are neither common nor practical, so are not supported.
202
+
203
+ **Scalar & Enum arguments**
204
+
205
+ Built-in scalars are written as normal literal values. For convenience, string literals may be enclosed in single quotes rather than escaped double-quotes:
206
+
207
+ ```graphql
208
+ enum DataSource { CACHE }
209
+ type Query {
210
+ product(id: ID!, source: String!): Product
211
+ @stitch(key: "id", arguments: "id: $.id, source: 'cache'")
212
+
213
+ variant(id: ID!, source: DataSource!): Variant
214
+ @stitch(key: "id", arguments: "id: $.id, source: CACHE")
215
+ }
216
+ ```
217
+
218
+ **InputObject arguments**
219
+
220
+ Input objects may be provided anywhere in the input, even as nested structures. The stitching resolver will build the specified object shape:
221
+
222
+ ```graphql
223
+ input ComplexKey {
224
+ id: ID
225
+ nested: ComplexKey
226
+ }
227
+ type Query {
228
+ product(key: ComplexKey!): [Product]!
229
+ @stitch(key: "id", arguments: "key: { nested: { id: $.id } }")
230
+ }
231
+ ```
232
+
233
+ **Custom scalar arguments**
234
+
235
+ Custom scalar keys allow any input shape to be submitted, from primitive scalars to complex object structures. These values will be sent and recieved as untyped JSON input, which makes them flexible but quite lax with validation:
236
+
237
+ ```graphql
238
+ type Product {
239
+ id: ID!
240
+ }
241
+ union Entity = Product
242
+ scalar Key
243
+
244
+ type Query {
245
+ entities(representations: [Key!]!): [Entity]!
246
+ @stitch(key: "id", arguments: "representations: { id: $.id, __typename: $.__typename }")
247
+ }
248
+ ```
249
+
250
+ ---
251
+ </details>
252
+
253
+ #### Composite type keys
254
+
255
+ Resolver keys may make composite selections for multiple key fields and/or nested scopes, for example:
256
+
257
+ ```graphql
258
+ interface FieldOwner {
259
+ id: ID!
260
+ }
261
+ type CustomField {
262
+ owner: FieldOwner!
263
+ key: String!
264
+ value: String
265
+ }
266
+ input CustomFieldLookup {
267
+ ownerId: ID!
268
+ ownerType: String!
269
+ key: String!
270
+ }
271
+
272
+ type Query {
273
+ customFields(lookups: [CustomFieldLookup!]!): [CustomField]! @stitch(
274
+ key: "owner { id __typename } key",
275
+ arguments: "lookups: { ownerId: $.owner.id, ownerType: $.owner.__typename, key: $.key }"
276
+ )
277
+ }
278
+ ```
279
+
280
+ Note that composite key selections may _not_ be distributed across locations. The complete selection criteria must be available in each location that provides the key.
281
+
282
+ #### Multiple type keys
283
+
284
+ A type may exist in multiple locations across the graph using different keys, for example:
285
+
286
+ ```graphql
287
+ type Product { id:ID! } # storefronts location
288
+ type Product { id:ID! sku:ID! } # products location
289
+ type Product { sku:ID! } # catelog location
290
+ ```
291
+
292
+ In the above graph, the `storefronts` and `catelog` locations have different keys that join through an intermediary. This pattern is perfectly valid and resolvable as long as the intermediary provides resolver queries for each possible key:
293
+
294
+ ```graphql
295
+ type Product {
296
+ id: ID!
297
+ sku: ID!
298
+ }
299
+ type Query {
300
+ productById(id: ID!): Product @stitch(key: "id")
301
+ productBySku(sku: ID!): Product @stitch(key: "sku")
302
+ }
303
+ ```
304
+
305
+ The `@stitch` directive is also repeatable, allowing a single query to associate with multiple keys:
306
+
307
+ ```graphql
308
+ type Product {
309
+ id: ID!
310
+ sku: ID!
311
+ }
312
+ type Query {
313
+ product(id: ID, sku: ID): Product @stitch(key: "id") @stitch(key: "sku")
314
+ }
315
+ ```
316
+
317
+ #### Null merges
318
+
319
+ It's okay for a merged type resolver to return `null` for an object as long as all unique fields of the type allow null. For example, the following merge works:
320
+
321
+ ```graphql
322
+ # -- Request
323
+
324
+ query {
325
+ movieA(id: "23") {
326
+ id
327
+ title
328
+ rating
329
+ }
330
+ }
331
+
332
+ # -- Location A
333
+
334
+ type Movie {
335
+ id: String!
336
+ title: String!
337
+ }
338
+
339
+ type Query {
340
+ movieA(id: ID!): Movie @stitch(key: "id")
341
+ # (id: "23") -> { id: "23", title: "Jurassic Park" }
342
+ }
343
+
344
+ # -- Location B
345
+
346
+ type Movie {
347
+ id: String!
348
+ rating: Int
349
+ }
350
+
351
+ type Query {
352
+ movieB(id: ID!): Movie @stitch(key: "id")
353
+ # (id: "23") -> null
354
+ }
355
+ ```
356
+
357
+ And produces this result:
358
+
359
+ ```json
360
+ {
361
+ "data": {
362
+ "id": "23",
363
+ "title": "Jurassic Park",
364
+ "rating": null
365
+ }
366
+ }
367
+ ```
368
+
369
+ Location B is allowed to return `null` here because its one unique field (`rating`) is nullable. If `rating` were non-null, then null bubbling would invalidate the response object.
370
+
371
+ ### Adding @stitch directives
372
+
373
+ The `@stitch` directive can be added to class-based schemas using the provided definition:
374
+
375
+ ```ruby
376
+ class Query < GraphQL::Schema::Object
377
+ field :product, Product, null: false do
378
+ directive(GraphQL::Stitching::Directives::Stitch, key: "id")
379
+ argument(:id, ID, required: true)
380
+ end
381
+ end
382
+
383
+ class Schema < GraphQL::Schema
384
+ directive(GraphQL::Stitching::Directives::Stitch)
385
+ query(Query)
386
+ end
387
+ ```
388
+
389
+ Alternatively, a clean schema can have stitching directives applied from static configuration passed as a location's `stitch` option:
390
+
391
+ ```ruby
392
+ sdl_string = <<~GRAPHQL
393
+ type Product {
394
+ id: ID!
395
+ sku: ID!
396
+ }
397
+ type Query {
398
+ productById(id: ID!): Product
399
+ productBySku(sku: ID!): Product
400
+ }
401
+ GRAPHQL
402
+
403
+ client = GraphQL::Stitching::Client.new(locations: {
404
+ products: {
405
+ schema: GraphQL::Schema.from_definition(sdl_string),
406
+ executable: ->() { ... },
407
+ stitch: [
408
+ { field_name: "productById", key: "id" },
409
+ { field_name: "productBySku", key: "sku", arguments: "mySku: $.sku" },
410
+ ]
411
+ },
412
+ # ...
413
+ })
414
+ ```
415
+
416
+ ### Outbound-only merged types
417
+
418
+ Merged types do not always require a resolver query. For example:
419
+
420
+ ```graphql
421
+ # -- Location A
422
+
423
+ type Widget {
424
+ id: ID!
425
+ name: String
426
+ price: Float
427
+ }
428
+
429
+ type Query {
430
+ widgetA(id: ID!): Widget @stitch(key: "id")
431
+ }
432
+
433
+ # -- Location B
434
+
435
+ type Widget {
436
+ id: ID!
437
+ size: Float
438
+ }
439
+
440
+ type Query {
441
+ widgetB(id: ID!): Widget @stitch(key: "id")
442
+ }
443
+
444
+ # -- Location C
445
+
446
+ type Widget {
447
+ id: ID!
448
+ name: String
449
+ size: Float
450
+ }
451
+
452
+ type Query {
453
+ featuredWidget: Widget
454
+ }
455
+ ```
456
+
457
+ In this graph, `Widget` is a merged type without a resolver query in location C. This works because all of its fields are resolvable in other locations; that means location C can provide outbound representations of this type without ever needing to resolve inbound requests for it. Outbound types do still require a shared key field (such as `id` above) that allow them to join with data in other resolver locations (such as `price` above).
@@ -8,7 +8,7 @@ To avoid confusion, using [basic resolver queries](../README.md#merged-type-reso
8
8
 
9
9
  The following subset of the federation spec is supported:
10
10
 
11
- - `@key(fields: "id")` (repeatable) specifies a key field for an object type. The key `fields` argument may only contain one field selection.
11
+ - `@key(fields: "id")` (repeatable) specifies a key field for an object type.
12
12
  - `_Entity` is a union type that must contain all types that implement a `@key`.
13
13
  - `_Any` is a scalar that recieves raw JSON objects; each object representation contains a `__typename` and the type's key field.
14
14
  - `_entities(representations: [_Any!]!): [_Entity]!` is a root query for local entity types.
@@ -0,0 +1,71 @@
1
+ ## Performance
2
+
3
+ There are many considerations that can aid in the performance of a stitched schema.
4
+
5
+ ### Batching
6
+
7
+ The stitching executor automatically batches subgraph requests so that only one request is made per location per generation of data (multiple generations can still force the executor to return to a location for more data). This is done using batched queries that combine all data access for a given a location. For example:
8
+
9
+ ```graphql
10
+ query MyOperation_2($_0_key:[ID!]!, $_1_0_key:ID!, $_1_1_key:ID!, $_1_2_key:ID!) {
11
+ _0_result: widgets(ids: $_0_key) { ... } # << 3 Widget
12
+ _1_0_result: sprocket(id: $_1_0_key) { ... } # << 1 Sprocket
13
+ _1_1_result: sprocket(id: $_1_1_key) { ... } # << 1 Sprocket
14
+ _1_2_result: sprocket(id: $_1_2_key) { ... } # << 1 Sprocket
15
+ }
16
+ ```
17
+
18
+ You can make optimal use of this batching behavior by following some best-practices:
19
+
20
+ 1. List queries (like the `widgets` selection above) are preferable as resolver queries because they keep the batched document consistent regardless of set size, and make for smaller documents that parse and validate faster.
21
+
22
+ 2. Root subgraph fields used as merged type resolvers (like the three `sprocket` selections above) should implement [batch loading](https://github.com/Shopify/graphql-batch) to anticipate repeated selections. Never assume that a root field will only be selected once per request.
23
+
24
+ ### Query plan caching
25
+
26
+ A stitching client provides caching hooks for saving query plans. 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 on subsequent requests. The provided `request` object includes digests for use in cache keys:
27
+
28
+ ```ruby
29
+ client = GraphQL::Stitching::Client.new(locations: { ... })
30
+
31
+ client.on_cache_read do |request|
32
+ # get a cached query plan...
33
+ $cache.get(request.digest)
34
+ end
35
+
36
+ client.on_cache_write do |request, payload|
37
+ # write a computed query plan...
38
+ $cache.set(request.digest, payload)
39
+ end
40
+ ```
41
+
42
+ Note that inlined input data (such as the `id: "1"` argument below) works against caching, so you should _avoid_ these input literals when possible:
43
+
44
+ ```graphql
45
+ query {
46
+ product(id: "1") { name }
47
+ }
48
+ ```
49
+
50
+ Instead, leverage query variables so that the document body remains consistent across requests:
51
+
52
+ ```graphql
53
+ query($id: ID!) {
54
+ product(id: $id) { name }
55
+ }
56
+
57
+ # variables: { "id" => "1" }
58
+ ```
59
+
60
+ ### Digests
61
+
62
+ All computed digests use SHA2 hashing by default. You can swap in [a faster algorithm](https://github.com/Shopify/blake3-rb) and/or add base state by reconfiguring `Stitching.digest`:
63
+
64
+ _config/initializers/graphql_stitching.rb_
65
+ ```ruby
66
+ GraphQL::Stitching.digest { |str| Digest::Blake3.hexdigest("v2/#{str}") }
67
+ ```
68
+
69
+ ### Concurrency
70
+
71
+ The stitching executor 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.
@@ -0,0 +1,102 @@
1
+ ## Query Planning
2
+
3
+
4
+ ### Root selection routing
5
+
6
+ It's okay if root field names are repeated across locations. The entrypoint location will be used when routing root selections:
7
+
8
+ ```graphql
9
+ # -- Location A
10
+
11
+ type Movie {
12
+ id: String!
13
+ rating: Int!
14
+ }
15
+
16
+ type Query {
17
+ movie(id: ID!): Movie @stitch(key: "id") # << set as root entrypoint
18
+ }
19
+
20
+ # -- Location B
21
+
22
+ type Movie {
23
+ id: String!
24
+ reviews: [String!]!
25
+ }
26
+
27
+ type Query {
28
+ movie(id: ID!): Movie @stitch(key: "id")
29
+ }
30
+
31
+ # -- Request
32
+
33
+ query {
34
+ movie(id: "23") { id } # routes to Location A
35
+ }
36
+ ```
37
+
38
+ 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:
39
+
40
+ ```graphql
41
+ schema {
42
+ query: Query # << root query, uses primary locations
43
+ }
44
+
45
+ type Query {
46
+ subquery: Query # << subquery, acts as a normal object type
47
+ }
48
+ ```
49
+
50
+ 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.
51
+
52
+ ### Field selection routing
53
+
54
+ Fields of a merged type may exist in multiple locations. For example, the `title` field below is provided by both locations:
55
+
56
+ ```graphql
57
+ # -- Location A
58
+
59
+ type Movie {
60
+ id: String!
61
+ title: String! # shared
62
+ rating: Int!
63
+ }
64
+
65
+ type Query {
66
+ movieA(id: ID!): Movie @stitch(key: "id")
67
+ }
68
+
69
+ # -- Location B
70
+
71
+ type Movie {
72
+ id: String!
73
+ title: String! # shared
74
+ reviews: [String!]!
75
+ }
76
+
77
+ type Query {
78
+ movieB(id: ID!): Movie @stitch(key: "id")
79
+ }
80
+ ```
81
+
82
+ When planning a request, field selections always attempt to use the current routing location that originates from the selection root, for example:
83
+
84
+ ```graphql
85
+ query GetTitleFromA {
86
+ movieA(id: "23") { # <- enter via Location A
87
+ title # <- source from Location A
88
+ }
89
+ }
90
+
91
+ query GetTitleFromB {
92
+ movieB(id: "23") { # <- enter via Location B
93
+ title # <- source from Location B
94
+ }
95
+ }
96
+ ```
97
+
98
+ Field selections that are NOT available in the current routing location delegate to new locations as follows:
99
+
100
+ 1. Fields with only one location automatically use that location.
101
+ 2. Fields with multiple locations attempt to use a location added during step-1.
102
+ 3. Any remaining fields pick a location based on their highest availability among locations.