graphql-stitching 0.2.3 → 0.3.1

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: a05f9ff15f40a886a20136cdaf5323cf37a59e7d98de27cc19cf7e600feb48e2
4
- data.tar.gz: 7727f7394bb99abcb3c3a448f1ebf8b3bd5f9b7a2fd74e9bc741285f401a23cc
3
+ metadata.gz: 10489fd6a8670d5a23a7afa132d7941a242848783f3697a4d3ddd519b208a4d8
4
+ data.tar.gz: 7a2e8dda124bdc6e96da43e1a4ed64bbb4baeaf0065f6d8411edaeba2c52e064
5
5
  SHA512:
6
- metadata.gz: f8993da51f8bd18ec1e590727a24bcba8690831e006a1a3fc861cfaa83b9794b94f90fb0e458919d7997d67606ae8ece4acfdd8a3a0d6fe669e7971b01232cf3
7
- data.tar.gz: 1032ed3279a98d1bc52db321a49a0a47d78dfb00cf9a8c922d1e77217ac837f8695c146908767ec398620f42a816f92eefc339d1fde970fe543904a6a30976f5
6
+ metadata.gz: 4c9243880e3b41fcede7fddb5947a962f1d4c43882ba07cc0ab63d1ba154527ef4cc8e5cc130bb2524b40fcbe093ecfd2f3b8fb0bafc9a2a7324050c30d2af00
7
+ data.tar.gz: a2b37c3ab8b98065a0910a458e177b71576c5d8f52c6f6eba0e31d25ae7797f1517a168aeef2f39c7295d27e4f981373b7a90066dc7ab8651670ecbd41fc70d5
@@ -16,6 +16,8 @@ jobs:
16
16
  ruby: 3.2
17
17
  - gemfile: Gemfile
18
18
  ruby: 3.1
19
+ - gemfile: gemfiles/graphql_1.13.gemfile
20
+ ruby: 3.1
19
21
  - gemfile: Gemfile
20
22
  ruby: 2.7
21
23
 
data/README.md CHANGED
@@ -70,12 +70,12 @@ 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) for more information on how schemas get merged.
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.
74
74
 
75
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:
76
76
 
77
- - [Composer](./docs/composer.md) - merges and validates many schemas into one graph.
78
- - [Supergraph](./docs/supergraph.md) - manages the combined schema and location routing maps. Can be exported, cached, and rehydrated.
77
+ - [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
78
+ - [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
79
79
  - [Request](./docs/request.md) - prepares a requested GraphQL document and variables for stitching.
80
80
  - [Planner](./docs/planner.md) - builds a cacheable query plan for a request document.
81
81
  - [Executor](./docs/executor.md) - executes a query plan with given request variables.
@@ -121,17 +121,16 @@ shipping_schema = <<~GRAPHQL
121
121
  }
122
122
  GRAPHQL
123
123
 
124
- supergraph = GraphQL::Stitching::Composer.new(schemas: {
125
- "products" => GraphQL::Schema.from_definition(products_schema),
126
- "shipping" => GraphQL::Schema.from_definition(shipping_schema),
124
+ supergraph = GraphQL::Stitching::Composer.new.perform({
125
+ products: {
126
+ schema: GraphQL::Schema.from_definition(products_schema),
127
+ executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001"),
128
+ },
129
+ shipping: {
130
+ schema: GraphQL::Schema.from_definition(shipping_schema),
131
+ executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3002"),
132
+ },
127
133
  })
128
-
129
- supergraph.assign_executable("products",
130
- GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001")
131
- )
132
- supergraph.assign_executable("shipping",
133
- GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3002")
134
- )
135
134
  ```
136
135
 
137
136
  Focusing on the `@stitch` directive usage:
@@ -255,7 +254,7 @@ The `@stitch` directive can be exported from a class-based schema to an SDL stri
255
254
 
256
255
  #### SDL-based schemas
257
256
 
258
- A clean SDL string may also have stitching directives applied via static configuration using the `GraphQL::Stitching` module to build the SDL into a schema:
257
+ A clean SDL string may also have stitching directives applied via static configuration by passing a `stitch` array in [location settings](./docs/composer.md#performing-composition):
259
258
 
260
259
  ```ruby
261
260
  sdl_string = <<~GRAPHQL
@@ -269,13 +268,15 @@ sdl_string = <<~GRAPHQL
269
268
  }
270
269
  GRAPHQL
271
270
 
272
- decorated_schema = GraphQL::Stitching.schema_from_definition(sdl_string, stitch_directives: [
273
- { type_name: "Query", field_name: "productById", key: "id" },
274
- { type_name: "Query", field_name: "productByUpc", key: "upc" },
275
- ])
276
-
277
- supergraph = GraphQL::Stitching::Composer.new(schemas: {
278
- "products" => decorated_schema,
271
+ supergraph = GraphQL::Stitching::Composer.new.perform({
272
+ products: {
273
+ schema: GraphQL::Schema.from_definition(sdl_string),
274
+ executable: ->() { ... },
275
+ stitch: [
276
+ { field_name: "productById", key: "id" },
277
+ { field_name: "productByUpc", key: "upc" },
278
+ ]
279
+ },
279
280
  # ...
280
281
  })
281
282
  ```
@@ -290,26 +291,41 @@ GraphQL::Stitching.stitch_directive = "merge"
290
291
 
291
292
  ## Executables
292
293
 
293
- An executable resource performs location-specific GraphQL requests. Executables may be `GraphQL::Schema` classes, or objects that respond to `.call` with the following arguments...
294
+ An executable resource performs location-specific GraphQL requests. Executables may be `GraphQL::Schema` classes, or any object that responds to `.call(location, source, variables, context)` and returns a raw GraphQL response:
294
295
 
295
296
  ```ruby
296
297
  class MyExecutable
297
- def call(location, query_string, variables, context)
298
+ def call(location, source, variables, context)
298
299
  # process a GraphQL request...
300
+ return {
301
+ "data" => { ... },
302
+ "errors" => [ ... ],
303
+ }
299
304
  end
300
305
  end
301
306
  ```
302
307
 
303
- By default, a [Supergraph](./docs/supergraph.md) will use the individual `GraphQL::Schema` classes that composed it as executable resources for each location. You may assign new executables using `assign_executable`:
308
+ A [Supergraph](./docs/supergraph.md) is composed with executable resources provided for each location. Any location that omits the `executable` option will use the provided `schema` as its default executable:
304
309
 
305
310
  ```ruby
306
- supergraph = GraphQL::Stitching::Composer.new(...)
307
-
308
- supergraph.assign_executable("location1", MyExecutable.new)
309
- supergraph.assign_executable("location2", ->(loc, query, vars, ctx) { ... })
310
- supergraph.assign_executable("location3") do |loc, query vars, ctx|
311
- # ...
312
- end
311
+ supergraph = GraphQL::Stitching::Composer.new.perform({
312
+ first: {
313
+ schema: FirstSchema,
314
+ # executable:^^^^^^ delegates to FirstSchema,
315
+ },
316
+ second: {
317
+ schema: SecondSchema,
318
+ executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001", headers: { ... }),
319
+ },
320
+ third: {
321
+ schema: ThirdSchema,
322
+ executable: MyExecutable.new,
323
+ },
324
+ fourth: {
325
+ schema: FourthSchema,
326
+ executable: ->(loc, query, vars, ctx) { ... },
327
+ },
328
+ })
313
329
  ```
314
330
 
315
331
  The `GraphQL::Stitching::RemoteClient` class is provided as a simple executable wrapper around `Net::HTTP.post`. You should build your own executables to leverage your existing libraries and to add instrumentation. Note that you must manually assign all executables to a `Supergraph` when rehydrating it from cache ([see docs](./docs/supergraph.md)).
data/docs/composer.md CHANGED
@@ -1,47 +1,92 @@
1
1
  ## GraphQL::Stitching::Composer
2
2
 
3
- The `Composer` receives many individual `GraphQL:Schema` instances for various graph locations and _composes_ them into a combined [`Supergraph`](./supergraph.md) that is validated for integrity. The resulting supergraph provides a combined GraphQL schema and delegation maps used to route incoming requests:
3
+ A `Composer` receives many individual `GraphQL:Schema` instances for various graph locations and _composes_ them into a combined [`Supergraph`](./supergraph.md) that is validated for integrity.
4
+
5
+ ### Configuring composition
6
+
7
+ A `Composer` may be constructed with optional settings that tune how it builds a schema:
8
+
9
+ ```ruby
10
+ composer = GraphQL::Stitching::Composer.new(
11
+ query_name: "Query",
12
+ mutation_name: "Mutation",
13
+ description_merger: ->(values_by_location, info) { values_by_location.values.join("\n") },
14
+ deprecation_merger: ->(values_by_location, info) { values_by_location.values.first },
15
+ directive_kwarg_merger: ->(values_by_location, info) { values_by_location.values.last },
16
+ )
17
+ ```
18
+
19
+ Constructor arguments:
20
+
21
+ - **`query_name:`** _optional_, the name of the root query type in the composed schema; `Query` by default. The root query types from all location schemas will be merged into this type, regardless of their local names.
22
+
23
+ - **`mutation_name:`** _optional_, the name of the root mutation type in the composed schema; `Mutation` by default. The root mutation types from all location schemas will be merged into this type, regardless of their local names.
24
+
25
+ - **`description_merger:`** _optional_, a [value merger function](#value-merger-functions) for merging element description strings from across locations.
26
+
27
+ - **`deprecation_merger:`** _optional_, a [value merger function](#value-merger-functions) for merging element deprecation strings from across locations.
28
+
29
+ - **`directive_kwarg_merger:`** _optional_, a [value merger function](#value-merger-functions) for merging directive keyword arguments from across locations.
30
+
31
+ #### Value merger functions
32
+
33
+ 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
+
35
+ ```ruby
36
+ supergraph = GraphQL::Stitching::Composer.new(
37
+ description_merger: ->(values_by_location, info) { values_by_location.values.compact.join("\n") },
38
+ )
39
+ ```
40
+
41
+ A merger function receives `values_by_location` and `info` arguments; these provide possible values keyed by location and info about where in the schema these values were encountered:
42
+
43
+ ```ruby
44
+ values_by_location = {
45
+ "storefronts" => "A fabulous data type.",
46
+ "products" => "An excellent data type.",
47
+ }
48
+
49
+ info = {
50
+ type_name: "Product",
51
+ # field_name: ...,
52
+ # argument_name: ...,
53
+ # directive_name: ...,
54
+ }
55
+ ```
56
+
57
+ ### Performing composition
58
+
59
+ Construct a `Composer` and call its `perform` method with location settings to compose a supergraph:
4
60
 
5
61
  ```ruby
6
- storefronts_sdl = <<~GRAPHQL
7
- type Storefront {
8
- id:ID!
9
- name: String!
10
- products: [Product]
11
- }
12
-
13
- type Product {
14
- id:ID!
15
- }
16
-
17
- type Query {
18
- storefront(id: ID!): Storefront
19
- }
20
- GRAPHQL
21
-
22
- products_sdl = <<~GRAPHQL
23
- directive @stitch(key: String!) repeatable on FIELD_DEFINITION
24
-
25
- type Product {
26
- id:ID!
27
- name: String
28
- price: Int
29
- }
30
-
31
- type Query {
32
- product(id: ID!): Product @stitch(key: "id")
33
- }
34
- GRAPHQL
35
-
36
- supergraph = GraphQL::Stitching::Composer.new(schemas: {
37
- "storefronts" => GraphQL::Schema.from_definition(storefronts_sdl),
38
- "products" => GraphQL::Schema.from_definition(products_sdl),
39
- }).perform
62
+ storefronts_sdl = "type Query { ..."
63
+ products_sdl = "type Query { ..."
64
+
65
+ supergraph = GraphQL::Stitching::Composer.new.perform({
66
+ storefronts: {
67
+ schema: GraphQL::Schema.from_definition(storefronts_sdl),
68
+ executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3001"),
69
+ stitch: [{ field_name: "storefront", key: "id" }],
70
+ },
71
+ products: {
72
+ schema: GraphQL::Schema.from_definition(products_sdl),
73
+ executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3002"),
74
+ },
75
+ my_local: {
76
+ schema: MyLocalSchema,
77
+ },
78
+ })
40
79
 
41
80
  combined_schema = supergraph.schema
42
81
  ```
43
82
 
44
- The individual schemas provided to the composer are assigned a location name based on their input key. These source schemas may be built from SDL (Schema Definition Language) strings using `GraphQL::Schema.from_definition`, or may be structured Ruby classes that inherit from `GraphQL::Schema`. The source schemas are used exclusively for type reference and do NOT need any real data resolvers. Likewise, the resulting combined schema is only used for type reference and resolving introspections.
83
+ Location settings have top-level keys that specify arbitrary location names, each of which provide:
84
+
85
+ - **`schema:`** _required_, provides a `GraphQL::Schema` class for the location. This may be a class-based schema that inherits from `GraphQL::Schema`, or built from SDL (Schema Definition Language) string using `GraphQL::Schema.from_definition` and mapped to a remote location. The provided schema is only used for type reference and does not require any real data resolvers (unless it is also used as the location's executable, see below).
86
+
87
+ - **`executable:`** _optional_, provides an executable resource to be called when delegating a request to this location. Executables are `GraphQL::Schema` classes or any object with a `.call(location, source, variables, context)` method that returns a GraphQL response. Omitting the executable option will use the location's provided `schema` as the executable resource.
88
+
89
+ - **`stitch:`** _optional_, an array of configs used to dynamically apply `@stitch` directives to select root fields prior to composing. This is useful when you can't easily render stitching directives into a location's source schema.
45
90
 
46
91
  ### Merge patterns
47
92
 
@@ -71,40 +116,3 @@ The strategy used to merge source schemas into the combined schema is based on e
71
116
  - Stitching directives (both definitions and assignments) are omitted.
72
117
 
73
118
  Note that the structure of a composed schema may change based on new schema additions and/or element usage (ie: changing input object arguments in one service may cause the intersection of arguments to change). Therefore, it's highly recommended that you use a [schema comparator](https://github.com/xuorig/graphql-schema_comparator) to flag regressions across composed schema versions.
74
-
75
- ### Value merger functions
76
-
77
- The composer has no way of intelligently merging static data values that are embedded into a schema. These include:
78
-
79
- - Element descriptions
80
- - Element deprecations
81
- - Directive keyword argument values
82
-
83
- By default, the first non-null value encountered across locations is used to fill these data slots. You may customize this aggregation process by providing value merger functions:
84
-
85
-
86
- ```ruby
87
- supergraph = GraphQL::Stitching::Composer.new(
88
- schemas: { ... },
89
- description_merger: ->(values_by_location, info) { values_by_location.values.last },
90
- deprecation_merger: ->(values_by_location, info) { values_by_location.values.last },
91
- directive_kwarg_merger: ->(values_by_location, info) { values_by_location.values.last },
92
- ).perform
93
- ```
94
-
95
- Each merger accepts a `values_by_location` and an `info` argument; these provide the values found across locations and info about where in schema they were encountered:
96
-
97
- ```ruby
98
- values_by_location = {
99
- "storefronts" => "A fabulous data type.",
100
- "products" => "An excellent data type.",
101
- }
102
-
103
- info = {
104
- type_name: "Product",
105
- # field_name: ...,
106
- # argument_name: ...,
107
- }
108
- ```
109
-
110
- The function should then select a value (or compute a new one) and return that for use in the combined schema.
data/docs/executor.md CHANGED
@@ -43,6 +43,22 @@ raw_result = GraphQL::Stitching::Executor.new(
43
43
  ).perform(raw: true)
44
44
  ```
45
45
 
46
+ The raw result will contain many irregularities from the stitching process, however may be insightful when debugging inconsistencies in results:
47
+
48
+ ```ruby
49
+ {
50
+ "data" => {
51
+ "product" => {
52
+ "upc" => "1",
53
+ "_STITCH_upc" => "1",
54
+ "_STITCH_typename" => "Product",
55
+ "name" => "iPhone",
56
+ "price" => nil,
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
46
62
  ### Batching
47
63
 
48
64
  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):
data/docs/gateway.md CHANGED
@@ -1,10 +1,6 @@
1
1
  ## GraphQL::Stitching::Gateway
2
2
 
3
- The `Gateway` is an out-of-the-box convenience with all stitching components assembled into a default workflow. A gateway is designed to work for most common needs, though you're welcome to assemble the component parts into your own configuration.
4
-
5
- ### Building
6
-
7
- The Gateway constructor accepts configuration to build a [`Supergraph`](./supergraph.md) for you. Location names are root keys, and each location config provides a `schema` and an optional [executable](../README.md#executables).
3
+ The `Gateway` is an out-of-the-box convenience with all stitching components assembled into a default workflow. A gateway is designed to work for most common needs, though you're welcome to assemble the component parts into your own configuration. A Gateway is constructed with the same [location settings](./composer.md#performing-composition) used to perform supergraph composition:
8
4
 
9
5
  ```ruby
10
6
  movies_schema = "type Query { ..."
@@ -14,6 +10,7 @@ gateway = GraphQL::Stitching::Gateway.new(locations: {
14
10
  products: {
15
11
  schema: GraphQL::Schema.from_definition(movies_schema),
16
12
  executable: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3000"),
13
+ stitch: [{ field_name: "products", key: "id" }],
17
14
  },
18
15
  showtimes: {
19
16
  schema: GraphQL::Schema.from_definition(showtimes_schema),
@@ -25,23 +22,23 @@ gateway = GraphQL::Stitching::Gateway.new(locations: {
25
22
  })
26
23
  ```
27
24
 
28
- Locations provided with only a `schema` will assign the schema as the location executable (these are locally-executable schemas, and must have locally-implemented resolvers). Locations that provide an `executable` will perform requests using the executable.
29
-
30
- #### From exported supergraph
31
-
32
- It's possible to [export and rehydrate](./supergraph.md#export-and-caching) `Supergraph` instances, allowing a supergraph to be cached as static artifacts and then rehydrated quickly at runtime without going through composition. To setup a gateway with a prebuilt supergraph, you may pass it as a `supergraph` argument:
25
+ Alternatively, you may pass a prebuilt `Supergraph` instance to the Gateway constructor. This is useful when [exporting and rehydrating](./supergraph.md#export-and-caching) supergraph instances, which bypasses the need for runtime composition:
33
26
 
34
27
  ```ruby
35
- exported_schema = "..."
28
+ exported_schema = "type Query { ..."
36
29
  exported_mapping = JSON.parse("{ ... }")
37
- supergraph = GraphQL::Stitching::Supergraph.from_export(exported_schema, exported_mapping)
30
+ supergraph = GraphQL::Stitching::Supergraph.from_export(
31
+ schema: exported_schema,
32
+ delegation_map: exported_mapping,
33
+ executables: { ... },
34
+ )
38
35
 
39
36
  gateway = GraphQL::Stitching::Gateway.new(supergraph: supergraph)
40
37
  ```
41
38
 
42
39
  ### Execution
43
40
 
44
- A gateway provides an `execute` method with a subset of arguments provided by [`GraphQL::Schema.execute`](https://graphql-ruby.org/queries/executing_queries). Executing requests to a stitched gateway becomes mostly a drop-in replacement to executing a `GraphQL::Schema` instance:
41
+ A gateway provides an `execute` method with a subset of arguments provided by [`GraphQL::Schema.execute`](https://graphql-ruby.org/queries/executing_queries). Executing requests on a stitched gateway becomes mostly a drop-in replacement to executing on a `GraphQL::Schema` instance:
45
42
 
46
43
  ```ruby
47
44
  result = gateway.execute(
@@ -57,7 +54,7 @@ Arguments for the `execute` method include:
57
54
  * `variables`: a hash of variables for the request.
58
55
  * `operation_name`: the name of the operation to execute (when multiple are provided).
59
56
  * `validate`: true if static validation should run on the supergraph schema before execution.
60
- * `context`: an object that gets passed through to gateway caching and error hooks.
57
+ * `context`: an object passed through to executable calls and gateway hooks.
61
58
 
62
59
  ### Cache hooks
63
60
 
data/docs/supergraph.md CHANGED
@@ -1,32 +1,6 @@
1
1
  ## GraphQL::Stitching::Supergraph
2
2
 
3
- A `Supergraph` is the singuar representation of a stitched graph. `Supergraph` is generated by a [`Composer`](./composer.md), and contains the combined schema and delegation map for location routing.
4
-
5
- ```ruby
6
- storefronts_sdl = "type Query { storefront(id: ID!): Storefront } ..."
7
- products_sdl = "type Query { product(id: ID!): Product } ..."
8
-
9
- supergraph = GraphQL::Stitching::Composer.new(schemas: {
10
- "storefronts" => GraphQL::Schema.from_definition(storefronts_sdl),
11
- "products" => GraphQL::Schema.from_definition(products_sdl),
12
- }).perform
13
-
14
- combined_schema = supergraph.schema
15
- ```
16
-
17
- ### Assigning executables
18
-
19
- A Supergraph also manages executable resources assigned for each location (ie: the objects that perform GraphQL requests for each location). An executable is a `GraphQL::Schema` class or any object that implements a `.call(location, query_string, variables)` method and returns a raw GraphQL response. Executables are assigned to a supergraph using `assign_executable`:
20
-
21
- ```ruby
22
- supergraph = GraphQL::Stitching::Composer.new(...)
23
-
24
- supergraph.assign_executable("location1", MyExecutable.new)
25
- supergraph.assign_executable("location2", ->(loc, query, vars) { ... })
26
- supergraph.assign_executable("location3") do |loc, query vars|
27
- # ...
28
- end
29
- ```
3
+ A `Supergraph` is the singuar representation of a stitched graph. `Supergraph` is composed from many locations, and provides a combined GraphQL schema and delegation maps used to route incoming requests.
30
4
 
31
5
  ### Export and caching
32
6
 
@@ -44,22 +18,18 @@ File.write("supergraph/schema.graphql", supergraph_sdl)
44
18
  File.write("supergraph/delegation_map.json", JSON.generate(delegation_map))
45
19
  ```
46
20
 
47
- To restore a cached Supergraph, collect the cached SDL and delegation mapping then recreate the Supergraph using `from_export`:
21
+ To restore a Supergraph, call `from_export` proving the cached SDL string, the parsed JSON delegation mapping, and a hash of executables keyed by their location names:
48
22
 
49
23
  ```ruby
50
24
  supergraph_sdl = $redis.get("cached_supergraph_sdl")
51
25
  delegation_map = JSON.parse($redis.get("cached_delegation_map"))
52
26
 
53
- supergraph = GraphQL::Stitching::Supergraph.from_export(supergraph_sdl, delegation_map)
54
- ```
55
-
56
- Note that a supergraph built from cache will not have _any_ executables assigned to it, so you'll need to manually reassign executables for each location:
57
-
58
- ```ruby
59
- remote_client = GraphQL::Stitching::RemoteClient.new(
60
- url: "http:localhost:3000",
61
- headers: { "Authorization" => "Bearer 12345" }
27
+ supergraph = GraphQL::Stitching::Supergraph.from_export(
28
+ schema: supergraph_sdl,
29
+ delegation_map: delegation_map,
30
+ executables: {
31
+ my_remote: GraphQL::Stitching::RemoteClient.new(url: "http://localhost:3000"),
32
+ my_local: MyLocalSchema,
33
+ }
62
34
  )
63
- supergraph.assign_executable("my_remote", remote_client)
64
- supergraph.assign_executable("my_local", MyLocal::Schema)
65
35
  ```
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ gem 'graphql', '1.13.9'
@@ -16,28 +16,25 @@ module GraphQL
16
16
  ].freeze
17
17
 
18
18
  def initialize(
19
- schemas:,
20
19
  query_name: "Query",
21
20
  mutation_name: "Mutation",
22
21
  description_merger: nil,
23
22
  deprecation_merger: nil,
24
23
  directive_kwarg_merger: nil
25
24
  )
26
- @schemas = schemas
27
25
  @query_name = query_name
28
26
  @mutation_name = mutation_name
29
- @field_map = {}
30
- @boundary_map = {}
31
- @mapped_type_names = {}
32
-
33
27
  @description_merger = description_merger || DEFAULT_VALUE_MERGER
34
28
  @deprecation_merger = deprecation_merger || DEFAULT_VALUE_MERGER
35
29
  @directive_kwarg_merger = directive_kwarg_merger || DEFAULT_VALUE_MERGER
36
30
  end
37
31
 
38
- def perform
32
+ def perform(locations_input)
33
+ reset!
34
+ schemas, executables = prepare_locations_input(locations_input)
35
+
39
36
  # "directive_name" => "location" => candidate_directive
40
- @subschema_directives_by_name_and_location = @schemas.each_with_object({}) do |(location, schema), memo|
37
+ @subschema_directives_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
41
38
  (schema.directives.keys - schema.default_directives.keys - GraphQL::Stitching.stitching_directive_names).each do |directive_name|
42
39
  memo[directive_name] ||= {}
43
40
  memo[directive_name][location] = schema.directives[directive_name]
@@ -52,7 +49,7 @@ module GraphQL
52
49
  @schema_directives.merge!(GraphQL::Schema.default_directives)
53
50
 
54
51
  # "Typename" => "location" => candidate_type
55
- @subschema_types_by_name_and_location = @schemas.each_with_object({}) do |(location, schema), memo|
52
+ @subschema_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo|
56
53
  raise ComposerError, "Location keys must be strings" unless location.is_a?(String)
57
54
  raise ComposerError, "The subscription operation is not supported." if schema.subscription
58
55
 
@@ -74,7 +71,7 @@ module GraphQL
74
71
  end
75
72
  end
76
73
 
77
- enum_usage = build_enum_usage_map(@schemas.values)
74
+ enum_usage = build_enum_usage_map(schemas.values)
78
75
 
79
76
  # "Typename" => merged_type
80
77
  schema_types = @subschema_types_by_name_and_location.each_with_object({}) do |(type_name, types_by_location), memo|
@@ -119,7 +116,7 @@ module GraphQL
119
116
  schema: schema,
120
117
  fields: @field_map,
121
118
  boundaries: @boundary_map,
122
- executables: @schemas,
119
+ executables: executables,
123
120
  )
124
121
 
125
122
  VALIDATORS.each do |validator|
@@ -130,6 +127,45 @@ module GraphQL
130
127
  supergraph
131
128
  end
132
129
 
130
+ def prepare_locations_input(locations_input)
131
+ schemas = {}
132
+ executables = {}
133
+
134
+ locations_input.each do |location, input|
135
+ schema = input[:schema]
136
+
137
+ if schema.nil?
138
+ raise ComposerError, "A schema is required for `#{location}` location."
139
+ elsif !(schema.is_a?(Class) && schema <= GraphQL::Schema)
140
+ raise ComposerError, "The schema for `#{location}` location must be a GraphQL::Schema class."
141
+ end
142
+
143
+ if input[:stitch]
144
+ stitch_directive = Class.new(GraphQL::Schema::Directive) do
145
+ graphql_name(GraphQL::Stitching.stitch_directive)
146
+ locations :FIELD_DEFINITION
147
+ argument :key, String
148
+ repeatable true
149
+ end
150
+
151
+ input[:stitch].each do |dir|
152
+ type = dir[:type_name] ? schema.types[dir[:type_name]] : schema.query
153
+ raise ComposerError, "Invalid stitch directive type `#{dir[:type_name]}`" unless type
154
+
155
+ field = type.fields[dir[:field_name]]
156
+ raise ComposerError, "Invalid stitch directive field `#{dir[:field_name]}`" unless field
157
+
158
+ field.directive(stitch_directive, **dir.slice(:key))
159
+ end
160
+ end
161
+
162
+ schemas[location.to_s] = schema
163
+ executables[location.to_s] = input[:executable] || schema
164
+ end
165
+
166
+ return schemas, executables
167
+ end
168
+
133
169
  def build_directive(directive_name, directives_by_location)
134
170
  builder = self
135
171
 
@@ -515,6 +551,16 @@ module GraphQL
515
551
  memo[enum_name] << :write
516
552
  end
517
553
  end
554
+
555
+ private
556
+
557
+ def reset!
558
+ @field_map = {}
559
+ @boundary_map = {}
560
+ @mapped_type_names = {}
561
+ @subschema_directives_by_name_and_location = nil
562
+ @schema_directives = nil
563
+ end
518
564
  end
519
565
  end
520
566
  end
@@ -9,17 +9,16 @@ module GraphQL
9
9
 
10
10
  attr_reader :supergraph
11
11
 
12
- def initialize(locations: nil, supergraph: nil)
12
+ def initialize(locations: nil, supergraph: nil, composer: nil)
13
13
  @supergraph = if locations && supergraph
14
14
  raise GatewayError, "Cannot provide both locations and a supergraph."
15
- elsif supergraph && !supergraph.is_a?(Supergraph)
15
+ elsif supergraph && !supergraph.is_a?(GraphQL::Stitching::Supergraph)
16
16
  raise GatewayError, "Provided supergraph must be a GraphQL::Stitching::Supergraph instance."
17
17
  elsif supergraph
18
18
  supergraph
19
- elsif locations
20
- build_supergraph_from_locations_config(locations)
21
19
  else
22
- raise GatewayError, "No locations or supergraph provided."
20
+ composer ||= GraphQL::Stitching::Composer.new
21
+ composer.perform(locations)
23
22
  end
24
23
  end
25
24
 
@@ -74,28 +73,6 @@ module GraphQL
74
73
 
75
74
  private
76
75
 
77
- def build_supergraph_from_locations_config(locations)
78
- schemas = locations.each_with_object({}) do |(location, config), memo|
79
- schema = config[:schema]
80
- if schema.nil?
81
- raise GatewayError, "A schema is required for `#{location}` location."
82
- elsif !(schema.is_a?(Class) && schema <= GraphQL::Schema)
83
- raise GatewayError, "The schema for `#{location}` location must be a GraphQL::Schema class."
84
- else
85
- memo[location.to_s] = schema
86
- end
87
- end
88
-
89
- supergraph = GraphQL::Stitching::Composer.new(schemas: schemas).perform
90
-
91
- locations.each do |location, config|
92
- executable = config[:executable]
93
- supergraph.assign_executable(location.to_s, executable) if executable
94
- end
95
-
96
- supergraph
97
- end
98
-
99
76
  def fetch_plan(request)
100
77
  if @on_cache_read
101
78
  cached_plan = @on_cache_read.call(request.digest, request.context)
@@ -53,18 +53,16 @@ module GraphQL
53
53
 
54
54
  when "mutation"
55
55
  parent_type = @supergraph.schema.mutation
56
- location_groups = []
57
56
 
58
- @request.operation.selections.reduce(nil) do |last_location, node|
57
+ location_groups = @request.operation.selections.each_with_object([]) do |node, memo|
59
58
  # root fields currently just delegate to the last location that defined them; this should probably be smarter
60
59
  next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].last
61
60
 
62
- if next_location != last_location
63
- location_groups << { location: next_location, selections: [] }
61
+ if memo.none? || memo.last[:location] != next_location
62
+ memo << { location: next_location, selections: [] }
64
63
  end
65
64
 
66
- location_groups.last[:selections] << node
67
- next_location
65
+ memo.last[:selections] << node
68
66
  end
69
67
 
70
68
  location_groups.reduce(0) do |after_key, group|
@@ -144,7 +142,7 @@ module GraphQL
144
142
  implements_fragments = false
145
143
 
146
144
  if parent_type.kind.interface?
147
- expand_interface_selections(current_location, parent_type, input_selections)
145
+ input_selections = expand_interface_selections(current_location, parent_type, input_selections)
148
146
  end
149
147
 
150
148
  input_selections.each do |node|
@@ -254,7 +252,7 @@ module GraphQL
254
252
 
255
253
  # hill climbing selects highest scoring locations to use
256
254
  preferred_location = possible_locations.reduce(possible_locations.first) do |best_location, possible_location|
257
- score = selections_by_location[location] ? remote_selections.length : 0
255
+ score = selections_by_location[possible_location] ? remote_selections.length : 0
258
256
  score += location_weights.fetch(possible_location, 0)
259
257
 
260
258
  if score > preferred_location_score
@@ -328,8 +326,8 @@ module GraphQL
328
326
  local_interface_fields = @supergraph.fields_by_type_and_location[parent_type.graphql_name][current_location]
329
327
 
330
328
  expanded_selections = nil
331
- input_selections.reject! do |node|
332
- if node.is_a?(GraphQL::Language::Nodes::Field) && !local_interface_fields.include?(node.name)
329
+ input_selections = input_selections.reject do |node|
330
+ if node.is_a?(GraphQL::Language::Nodes::Field) && node.name != "__typename" && !local_interface_fields.include?(node.name)
333
331
  expanded_selections ||= []
334
332
  expanded_selections << node
335
333
  true
@@ -344,6 +342,8 @@ module GraphQL
344
342
  input_selections << GraphQL::Language::Nodes::InlineFragment.new(type: type_name, selections: expanded_selections)
345
343
  end
346
344
  end
345
+
346
+ input_selections
347
347
  end
348
348
 
349
349
  # expand concrete type selections into typed fragments when sending to abstract boundaries
@@ -42,15 +42,16 @@ module GraphQL
42
42
  return nil if raw_object[field_name].nil? && node_type.non_null?
43
43
 
44
44
  when GraphQL::Language::Nodes::InlineFragment
45
- next unless typename == node.type.name
46
45
  fragment_type = @schema.types[node.type.name]
46
+ next unless fragment_matches_typename?(fragment_type, typename)
47
+
47
48
  result = resolve_object_scope(raw_object, fragment_type, node.selections, typename)
48
49
  return nil if result.nil?
49
50
 
50
51
  when GraphQL::Language::Nodes::FragmentSpread
51
52
  fragment = @request.fragment_definitions[node.name]
52
53
  fragment_type = @schema.types[fragment.type.name]
53
- next unless typename == fragment_type.graphql_name
54
+ next unless fragment_matches_typename?(fragment_type, typename)
54
55
 
55
56
  result = resolve_object_scope(raw_object, fragment_type, fragment.selections, typename)
56
57
  return nil if result.nil?
@@ -91,6 +92,11 @@ module GraphQL
91
92
 
92
93
  resolved_list
93
94
  end
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 }
99
+ end
94
100
  end
95
101
  end
96
102
  end
@@ -15,11 +15,39 @@ module GraphQL
15
15
  "__DirectiveLocation",
16
16
  ].freeze
17
17
 
18
+ def self.validate_executable!(location, executable)
19
+ return true if executable.is_a?(Class) && executable <= GraphQL::Schema
20
+ return true if executable && executable.respond_to?(:call)
21
+ raise StitchingError, "Invalid executable provided for location `#{location}`."
22
+ end
23
+
24
+ def self.from_export(schema:, delegation_map:, executables:)
25
+ schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
26
+
27
+ executables = delegation_map["locations"].each_with_object({}) do |location, memo|
28
+ executable = executables[location] || executables[location.to_sym]
29
+ if validate_executable!(location, executable)
30
+ memo[location] = executable
31
+ end
32
+ end
33
+
34
+ new(
35
+ schema: schema,
36
+ fields: delegation_map["fields"],
37
+ boundaries: delegation_map["boundaries"],
38
+ executables: executables,
39
+ )
40
+ end
41
+
18
42
  attr_reader :schema, :boundaries, :locations_by_type_and_field, :executables
19
43
 
20
- def initialize(schema:, fields:, boundaries:, executables: {})
44
+ def initialize(schema:, fields:, boundaries:, executables:)
21
45
  @schema = schema
22
46
  @boundaries = boundaries
47
+ @possible_keys_by_type = {}
48
+ @possible_keys_by_type_and_location = {}
49
+
50
+ # add introspection types into the fields mapping
23
51
  @locations_by_type_and_field = INTROSPECTION_TYPES.each_with_object(fields) do |type_name, memo|
24
52
  introspection_type = schema.get_type(type_name)
25
53
  next unless introspection_type.kind.fields?
@@ -27,57 +55,46 @@ module GraphQL
27
55
  memo[type_name] = introspection_type.fields.keys.each_with_object({}) do |field_name, m|
28
56
  m[field_name] = [LOCATION]
29
57
  end
30
- end
58
+ end.freeze
31
59
 
32
- @possible_keys_by_type = {}
33
- @possible_keys_by_type_and_location = {}
34
- @executables = { LOCATION => @schema }.merge!(executables)
60
+ # validate and normalize executable references
61
+ @executables = executables.each_with_object({ LOCATION => @schema }) do |(location, executable), memo|
62
+ if self.class.validate_executable!(location, executable)
63
+ memo[location.to_s] = executable
64
+ end
65
+ end.freeze
35
66
  end
36
67
 
37
68
  def fields
38
69
  @locations_by_type_and_field.reject { |k, _v| INTROSPECTION_TYPES.include?(k) }
39
70
  end
40
71
 
72
+ def locations
73
+ @executables.keys.reject { _1 == LOCATION }
74
+ end
75
+
41
76
  def export
42
77
  return GraphQL::Schema::Printer.print_schema(@schema), {
78
+ "locations" => locations,
43
79
  "fields" => fields,
44
80
  "boundaries" => @boundaries,
45
81
  }
46
82
  end
47
83
 
48
- def self.from_export(schema, delegation_map, executables: {})
49
- schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
50
- new(
51
- schema: schema,
52
- fields: delegation_map["fields"],
53
- boundaries: delegation_map["boundaries"],
54
- executables: executables,
55
- )
56
- end
57
-
58
- def assign_executable(location, executable = nil, &block)
59
- executable ||= block
60
- unless executable.is_a?(Class) && executable <= GraphQL::Schema
61
- raise StitchingError, "A client or block handler must be provided." unless executable
62
- raise StitchingError, "A client must be callable" unless executable.respond_to?(:call)
63
- end
64
- @executables[location] = executable
65
- end
66
-
67
- def execute_at_location(location, query, variables, context)
84
+ def execute_at_location(location, source, variables, context)
68
85
  executable = executables[location]
69
86
 
70
87
  if executable.nil?
71
88
  raise StitchingError, "No executable assigned for #{location} location."
72
89
  elsif executable.is_a?(Class) && executable <= GraphQL::Schema
73
90
  executable.execute(
74
- query: query,
91
+ query: source,
75
92
  variables: variables,
76
93
  context: context.frozen? ? context.dup : context,
77
94
  validate: false,
78
95
  )
79
96
  elsif executable.respond_to?(:call)
80
- executable.call(location, query, variables, context)
97
+ executable.call(location, source, variables, context)
81
98
  else
82
99
  raise StitchingError, "Missing valid executable for #{location} location."
83
100
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "0.2.3"
5
+ VERSION = "0.3.1"
6
6
  end
7
7
  end
@@ -19,56 +19,6 @@ module GraphQL
19
19
  def stitching_directive_names
20
20
  [stitch_directive]
21
21
  end
22
-
23
- def schema_from_definition(sdl, stitch_directives:)
24
- ast = GraphQL.parse(sdl)
25
-
26
- if stitch_directives&.any?
27
- directive_definition = ast.definitions.find do |d|
28
- d.is_a?(GraphQL::Language::Nodes::DirectiveDefinition) && d.name == stitch_directive
29
- end
30
-
31
- if !directive_definition
32
- directive_sdl = "directive @#{stitch_directive}(key: String!) repeatable on FIELD_DEFINITION"
33
- directive_definition = GraphQL.parse(directive_sdl).definitions.first
34
- ast.send(:merge!, { definitions: [directive_definition, *ast.definitions] })
35
- end
36
- end
37
-
38
- stitch_directives.each do |config|
39
- config[:type_name] ||= "Query"
40
-
41
- type_node = ast.definitions.find do |d|
42
- d.is_a?(GraphQL::Language::Nodes::ObjectTypeDefinition) && d.name == config[:type_name]
43
- end
44
-
45
- raise StitchingError, "invalid type name `#{config[:type_name]}`." unless type_node
46
-
47
- field_node = type_node.fields.find do |f|
48
- f.name == config[:field_name]
49
- end
50
-
51
- raise StitchingError, "invalid field name `#{config[:field_name]}`." unless field_node
52
-
53
- field_node.send(:merge!, {
54
- directives: [
55
- *field_node.directives,
56
- GraphQL::Language::Nodes::Directive.new(
57
- arguments: [GraphQL::Language::Nodes::Argument.new(name: "key", value: config[:key])],
58
- name: stitch_directive,
59
- )
60
- ]
61
- })
62
- end
63
-
64
- if GraphQL::Schema::BuildFromDefinition.method(:from_document).parameters.first.last == :document
65
- # GraphQL v1.13.x
66
- GraphQL::Schema::BuildFromDefinition.from_document(ast, default_resolve: nil)
67
- else
68
- # GraphQL v2
69
- GraphQL::Schema::BuildFromDefinition.from_document(GraphQL::Schema, ast, default_resolve: nil)
70
- end
71
- end
72
22
  end
73
23
  end
74
24
  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.2.3
4
+ version: 0.3.1
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-02-22 00:00:00.000000000 Z
11
+ date: 2023-03-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -93,6 +93,7 @@ files:
93
93
  - example/graphiql.html
94
94
  - example/remote1.rb
95
95
  - example/remote2.rb
96
+ - gemfiles/graphql_1.13.9.gemfile
96
97
  - graphql-stitching.gemspec
97
98
  - lib/graphql/stitching.rb
98
99
  - lib/graphql/stitching/composer.rb