graphql-stitching 1.1.1 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +0 -3
  3. data/README.md +10 -32
  4. data/docs/README.md +1 -0
  5. data/docs/client.md +2 -2
  6. data/docs/composer.md +1 -1
  7. data/docs/executor.md +8 -15
  8. data/docs/http_executable.md +51 -0
  9. data/docs/planner.md +12 -14
  10. data/docs/request.md +2 -0
  11. data/docs/supergraph.md +2 -2
  12. data/examples/file_uploads/Gemfile +9 -0
  13. data/examples/file_uploads/Procfile +2 -0
  14. data/examples/file_uploads/README.md +37 -0
  15. data/examples/file_uploads/file.txt +1 -0
  16. data/examples/file_uploads/gateway.rb +37 -0
  17. data/examples/file_uploads/helpers.rb +62 -0
  18. data/examples/file_uploads/remote.rb +21 -0
  19. data/examples/merged_types/Gemfile +8 -0
  20. data/examples/merged_types/Procfile +3 -0
  21. data/examples/merged_types/README.md +33 -0
  22. data/{example → examples/merged_types}/gateway.rb +4 -5
  23. data/examples/merged_types/remote1.rb +22 -0
  24. data/examples/merged_types/remote2.rb +22 -0
  25. data/lib/graphql/stitching/client.rb +9 -19
  26. data/lib/graphql/stitching/executor/boundary_source.rb +1 -1
  27. data/lib/graphql/stitching/executor/root_source.rb +1 -1
  28. data/lib/graphql/stitching/executor.rb +4 -7
  29. data/lib/graphql/stitching/export_selection.rb +6 -1
  30. data/lib/graphql/stitching/http_executable.rb +134 -4
  31. data/lib/graphql/stitching/planner.rb +2 -2
  32. data/lib/graphql/stitching/request.rb +22 -3
  33. data/lib/graphql/stitching/shaper.rb +2 -2
  34. data/lib/graphql/stitching/supergraph.rb +4 -4
  35. data/lib/graphql/stitching/util.rb +0 -9
  36. data/lib/graphql/stitching/version.rb +1 -1
  37. metadata +17 -7
  38. data/Procfile +0 -3
  39. data/example/remote1.rb +0 -26
  40. data/example/remote2.rb +0 -26
  41. /data/{example → examples/merged_types}/graphiql.html +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9cdcb3361e391ba9b6dd78aa38c308c08ddb6fde2720d15208e8dfb96c3006ea
4
- data.tar.gz: 9f64b024bda3987d1c523cf52b49f0ca2dfd688ac4c89bbf18de30c88c098156
3
+ metadata.gz: 32918bd6b17708d712d3ac945f6007b0405299379372aabda8b0c3a5f0c246da
4
+ data.tar.gz: 608562f6b35cfe912e2388c6cbfce8c774bedc6f6518d257eb0de8b45fdc8b52
5
5
  SHA512:
6
- metadata.gz: b51bcfeaa20e9cdea6c2d4b4e472e8c23e13ec8af36e1b23852603d0329045af064e3ae738467a0823be0c9f545105acba001b708c207eb7f08d51ebf0791e1f
7
- data.tar.gz: c529e74c88fc7193d7823d8c92a488b3e459b5ba9f88e8d62355ae2bd5e9eb0ed2b0d6f7fd14b6b8ed13ab1bb53a93bc5da49dcecd5deb92ee180fa70604c80b
6
+ metadata.gz: c0f40d79e3b06c9477f5967f12f6e33c0534b916c3454669d2802357137ff78d7bb9bb7d99d7c82d43ea47f58fac1c4d0e5aa38243d4cbfcdec6ba192ef56c0e
7
+ data.tar.gz: eed8c018a8efde4500bb0eb93de78803f555c77fad284718e945648160442eea249439d2a2cb5b4c0a5121fc148197f48b9ea0d50a21e7cfa291499fe4533912
data/Gemfile CHANGED
@@ -3,9 +3,6 @@
3
3
  source 'https://rubygems.org'
4
4
  gemspec
5
5
 
6
- gem 'rack'
7
- gem 'rackup'
8
- gem 'foreman'
9
6
  gem 'pry'
10
7
  gem 'pry-byebug'
11
8
  gem 'warning'
data/README.md CHANGED
@@ -10,6 +10,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
10
10
  - Shared objects, fields, enums, and inputs across locations.
11
11
  - Combining local and remote schemas.
12
12
  - Type merging via arbitrary queries or federation `_entities` protocol.
13
+ - File uploads via [multipart form spec](https://github.com/jaydenseric/graphql-multipart-request-spec).
13
14
 
14
15
  **NOT Supported:**
15
16
  - Computed fields (ie: federation-style `@requires`).
@@ -80,6 +81,7 @@ While the `Client` constructor is an easy quick start, the library also has seve
80
81
  - [Request](./docs/request.md) - prepares a requested GraphQL document and variables for stitching.
81
82
  - [Planner](./docs/planner.md) - builds a cacheable query plan for a request document.
82
83
  - [Executor](./docs/executor.md) - executes a query plan with given request variables.
84
+ - [HttpExecutable](./docs/http_executable.md) - proxies requests to remotes with multipart file upload support.
83
85
 
84
86
  ## Merged types
85
87
 
@@ -360,11 +362,11 @@ It's perfectly fine to mix and match schemas that implement an `_entities` query
360
362
 
361
363
  ## Executables
362
364
 
363
- 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:
365
+ 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:
364
366
 
365
367
  ```ruby
366
368
  class MyExecutable
367
- def call(location, source, variables, context)
369
+ def call(request, source, variables)
368
370
  # process a GraphQL request...
369
371
  return {
370
372
  "data" => { ... },
@@ -392,12 +394,12 @@ supergraph = GraphQL::Stitching::Composer.new.perform({
392
394
  },
393
395
  fourth: {
394
396
  schema: FourthSchema,
395
- executable: ->(loc, query, vars, ctx) { ... },
397
+ executable: ->(req, query, vars) { ... },
396
398
  },
397
399
  })
398
400
  ```
399
401
 
400
- The `GraphQL::Stitching::HttpExecutable` 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)).
402
+ The `GraphQL::Stitching::HttpExecutable` class is provided as a simple executable wrapper around `Net::HTTP.post` with [file upload](./docs/http_executable.md#graphql-file-uploads) support. 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)).
401
403
 
402
404
  ## Batching
403
405
 
@@ -431,36 +433,12 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im
431
433
  - [Stitched errors](./docs/mechanics.md#stitched-errors)
432
434
  - [Null results](./docs/mechanics.md#null-results)
433
435
 
434
- ## Example
436
+ ## Examples
435
437
 
436
- This repo includes a working example of several stitched schemas running across small Rack servers. Try running it:
438
+ This repo includes working examples of stitched schemas running across small Rack servers. Clone the repo, `cd` into each example and try running it following its README instructions.
437
439
 
438
- ```shell
439
- bundle install
440
- foreman start
441
- ```
442
-
443
- Then visit the gateway service at `http://localhost:3000` and try this query:
444
-
445
- ```graphql
446
- query {
447
- storefront(id: "1") {
448
- id
449
- products {
450
- upc
451
- name
452
- price
453
- manufacturer {
454
- name
455
- address
456
- products { upc name }
457
- }
458
- }
459
- }
460
- }
461
- ```
462
-
463
- The above query collects data from all locations, two of which are remote schemas and the third a local schema. The combined graph schema is also stitched in to provide introspection capabilities.
440
+ - [Merged types](./examples/merged_types)
441
+ - [File uploads](./examples/file_uploads)
464
442
 
465
443
  ## Tests
466
444
 
data/docs/README.md CHANGED
@@ -12,6 +12,7 @@ Major components include:
12
12
  - [Request](./request.md) - prepares a requested GraphQL document and variables for stitching.
13
13
  - [Planner](./planner.md) - builds a cacheable query plan for a request document.
14
14
  - [Executor](./executor.md) - executes a query plan with given request variables.
15
+ - [HttpExecutable](./http_executable.md) - proxies requests to remotes with multipart file upload support.
15
16
 
16
17
  Additional topics:
17
18
 
data/docs/client.md CHANGED
@@ -60,11 +60,11 @@ The client provides cache hooks to enable caching query plans across requests. W
60
60
 
61
61
  ```ruby
62
62
  client.on_cache_read do |request|
63
- $redis.get(request.digest) # << 3P code
63
+ $cache.get(request.digest) # << 3P code
64
64
  end
65
65
 
66
66
  client.on_cache_write do |request, payload|
67
- $redis.set(request.digest, payload) # << 3P code
67
+ $cache.set(request.digest, payload) # << 3P code
68
68
  end
69
69
  ```
70
70
 
data/docs/composer.md CHANGED
@@ -90,7 +90,7 @@ Location settings have top-level keys that specify arbitrary location names, eac
90
90
 
91
91
  - **`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).
92
92
 
93
- - **`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.
93
+ - **`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(request, source, variables)` method that returns a GraphQL response. Omitting the executable option will use the location's provided `schema` as the executable resource.
94
94
 
95
95
  - **`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.
96
96
 
data/docs/executor.md CHANGED
@@ -13,22 +13,18 @@ query = <<~GRAPHQL
13
13
  GRAPHQL
14
14
 
15
15
  request = GraphQL::Stitching::Request.new(
16
+ supergraph,
16
17
  query,
17
18
  variables: { "id" => "123" },
18
19
  operation_name: "MyQuery",
19
20
  context: { ... },
20
21
  )
21
22
 
22
- plan = GraphQL::Stitching::Planner.new(
23
- supergraph: supergraph,
24
- request: request,
25
- ).perform
23
+ # Via Request:
24
+ result = request.execute
26
25
 
27
- result = GraphQL::Stitching::Executor.new(
28
- supergraph: supergraph,
29
- request: request,
30
- plan: plan,
31
- ).perform
26
+ # Via Executor:
27
+ result = GraphQL::Stitching::Executor.new(request).perform
32
28
  ```
33
29
 
34
30
  ### Raw results
@@ -36,12 +32,9 @@ result = GraphQL::Stitching::Executor.new(
36
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:
37
33
 
38
34
  ```ruby
39
- # get the raw result without shaping
40
- raw_result = GraphQL::Stitching::Executor.new(
41
- supergraph: supergraph,
42
- request: request,
43
- plan: plan,
44
- ).perform(raw: true)
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)
45
38
  ```
46
39
 
47
40
  The raw result will contain many irregularities from the stitching process, however may be insightful when debugging inconsistencies in results:
@@ -0,0 +1,51 @@
1
+ ## GraphQL::Stitching::HttpExecutable
2
+
3
+ A `HttpExecutable` provides an out-of-the-box convenience for sending HTTP post requests to a remote location, or a base class for your own implementation with [GraphQL multipart uploads](https://github.com/jaydenseric/graphql-multipart-request-spec).
4
+
5
+ ```ruby
6
+ exe = GraphQL::Stitching::HttpExecutable.new(
7
+ url: "http://localhost:3001",
8
+ headers: {
9
+ "Authorization" => "..."
10
+ }
11
+ )
12
+ ```
13
+
14
+ ### GraphQL file uploads
15
+
16
+ The [GraphQL Upload Spec](https://github.com/jaydenseric/graphql-multipart-request-spec) defines a multipart form structure for submitting GraphQL requests with file upload attachments. It's possible to pass these requests through stitched schemas using the following:
17
+
18
+ #### 1. Input file uploads as Tempfile variables
19
+
20
+ ```ruby
21
+ client.execute(
22
+ "mutation($file: Upload) { upload(file: $file) }",
23
+ variables: { "file" => Tempfile.new(...) }
24
+ )
25
+ ```
26
+
27
+ File uploads must enter the stitched schema as standard GraphQL variables with `Tempfile` values. The simplest way to recieve this input is to install [apollo_upload_server](https://github.com/jetruby/apollo_upload_server-ruby) into your stitching app's middleware so that multipart form submissions automatically unpack into standard variables.
28
+
29
+ #### 2. Enable `HttpExecutable.upload_types`
30
+
31
+ ```ruby
32
+ client = GraphQL::Stitching::Client.new(locations: {
33
+ alpha: {
34
+ schema: GraphQL::Schema.from_definition(...),
35
+ executable: GraphQL::Stitching::HttpExecutable.new(
36
+ url: "http://localhost:3000",
37
+ upload_types: ["Upload"], # << extract `Upload` scalars into multipart forms
38
+ ),
39
+ },
40
+ bravo: {
41
+ schema: GraphQL::Schema.from_definition(...),
42
+ executable: GraphQL::Stitching::HttpExecutable.new(
43
+ url: "http://localhost:3001"
44
+ ),
45
+ },
46
+ })
47
+ ```
48
+
49
+ A location's `HttpExecutable` can then re-package `Tempfile` variables into multipart forms before sending them upstream. This is enabled with an `upload_types` parameter that specifies which scalar names require form extraction. Enabling `upload_types` does add some additional subgraph request processing, so it should only be enabled for locations that will actually recieve file uploads.
50
+
51
+ The upstream location will recieve a multipart form submission from stitching that can again be unpacked using [apollo_upload_server](https://github.com/jetruby/apollo_upload_server-ruby) or similar.
data/docs/planner.md CHANGED
@@ -13,15 +13,17 @@ document = <<~GRAPHQL
13
13
  GRAPHQL
14
14
 
15
15
  request = GraphQL::Stitching::Request.new(
16
+ supergraph,
16
17
  document,
17
18
  variables: { "id" => "1" },
18
19
  operation_name: "MyQuery",
19
20
  ).prepare!
20
21
 
21
- plan = GraphQL::Stitching::Planner.new(
22
- supergraph: supergraph,
23
- request: request,
24
- ).perform
22
+ # Via Request:
23
+ plan = request.plan
24
+
25
+ # Via Planner:
26
+ plan = GraphQL::Stitching::Planner.new(request).perform
25
27
  ```
26
28
 
27
29
  ### Caching
@@ -29,18 +31,14 @@ plan = GraphQL::Stitching::Planner.new(
29
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.
30
32
 
31
33
  ```ruby
32
- cached_plan = $redis.get(request.digest)
34
+ cached_plan = $cache.get(request.digest)
33
35
 
34
- plan = if cached_plan
35
- GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
36
+ if cached_plan
37
+ plan = GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan))
38
+ request.plan(plan)
36
39
  else
37
- plan = GraphQL::Stitching::Planner.new(
38
- supergraph: supergraph,
39
- request: request,
40
- ).perform
41
-
42
- $redis.set(request.digest, JSON.generate(plan.as_json))
43
- plan
40
+ plan = request.plan
41
+ $cache.set(request.digest, JSON.generate(plan.as_json))
44
42
  end
45
43
 
46
44
  # execute the plan...
data/docs/request.md CHANGED
@@ -5,6 +5,7 @@ A `Request` contains a parsed GraphQL document and variables, and handles the lo
5
5
  ```ruby
6
6
  source = "query FetchMovie($id: ID!) { movie(id:$id) { id genre } }"
7
7
  request = GraphQL::Stitching::Request.new(
8
+ supergraph,
8
9
  source,
9
10
  variables: { "id" => "1" },
10
11
  operation_name: "FetchMovie",
@@ -42,6 +43,7 @@ document = <<~GRAPHQL
42
43
  GRAPHQL
43
44
 
44
45
  request = GraphQL::Stitching::Request.new(
46
+ supergraph,
45
47
  document,
46
48
  variables: { "id" => "1" },
47
49
  operation_name: "FetchMovie",
data/docs/supergraph.md CHANGED
@@ -10,7 +10,7 @@ A Supergraph is designed to be composed, cached, and restored. Calling `to_defin
10
10
  supergraph_sdl = supergraph.to_definition
11
11
 
12
12
  # stash this composed schema in a cache...
13
- $redis.set("cached_supergraph_sdl", supergraph_sdl)
13
+ $cache.set("cached_supergraph_sdl", supergraph_sdl)
14
14
 
15
15
  # or, write the composed schema as a file into your repo...
16
16
  File.write("supergraph/schema.graphql", supergraph_sdl)
@@ -19,7 +19,7 @@ File.write("supergraph/schema.graphql", supergraph_sdl)
19
19
  To restore a Supergraph, call `from_definition` providing the cached SDL string and a hash of executables keyed by their location names:
20
20
 
21
21
  ```ruby
22
- supergraph_sdl = $redis.get("cached_supergraph_sdl")
22
+ supergraph_sdl = $cache.get("cached_supergraph_sdl")
23
23
 
24
24
  supergraph = GraphQL::Stitching::Supergraph.from_definition(
25
25
  supergraph_sdl,
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rack'
6
+ gem 'rackup'
7
+ gem 'foreman'
8
+ gem 'graphql'
9
+ gem 'apollo_upload_server', '2.1'
@@ -0,0 +1,2 @@
1
+ gateway: bundle exec ruby gateway.rb
2
+ remote: bundle exec ruby remote.rb
@@ -0,0 +1,37 @@
1
+ # File uploads example
2
+
3
+ This example demonstrates uploading files via the [GraphQL Upload spec](https://github.com/jaydenseric/graphql-multipart-request-spec).
4
+
5
+ Try running it:
6
+
7
+ ```shell
8
+ cd examples/file_uploads
9
+ bundle install
10
+ foreman start
11
+ ```
12
+
13
+ This example is headless, but you can verify the stitched schema is running by querying a field from each graph location:
14
+
15
+ ```shell
16
+ curl -X POST http://localhost:3000 \
17
+ -H 'Content-Type: application/json' \
18
+ -d '{"query":"{ gateway remote }"}'
19
+ ```
20
+
21
+ Now try submitting a multipart form upload with a file attachment, per the [spec](https://github.com/jaydenseric/graphql-multipart-request-spec?tab=readme-ov-file#curl-request). The response will echo the uploaded file contents:
22
+
23
+ ```shell
24
+ curl http://localhost:3000 \
25
+ -H 'Content-Type: multipart/form-data' \
26
+ -F operations='{ "query": "mutation ($file: Upload!) { gateway upload(file: $file) }", "variables": { "file": null } }' \
27
+ -F map='{ "0": ["variables.file"] }' \
28
+ -F 0=@file.txt
29
+ ```
30
+
31
+ This workflow has:
32
+
33
+ 1. Submitted a multipart form to the stitched gateway.
34
+ 2. The gateway server unpacked the request using [apollo_upload_server](https://github.com/jetruby/apollo_upload_server-ruby).
35
+ 3. Stitching delegated the `upload` field to its appropraite subgraph location.
36
+ 4. `HttpExecutable` has re-encoded the subgraph request into a multipart form.
37
+ 5. The subgraph location has recieved, unpacked, and resolved the uploaded file.
@@ -0,0 +1 @@
1
+ Hello World!
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rackup'
4
+ require 'json'
5
+ require 'graphql'
6
+ require_relative '../../lib/graphql/stitching'
7
+ require_relative './helpers'
8
+
9
+ class StitchedApp
10
+ def initialize
11
+ @client = GraphQL::Stitching::Client.new(locations: {
12
+ gateway: {
13
+ schema: GatewaySchema,
14
+ },
15
+ remote: {
16
+ schema: RemoteSchema,
17
+ executable: GraphQL::Stitching::HttpExecutable.new(
18
+ url: "http://localhost:3001",
19
+ upload_types: ["Upload"]
20
+ ),
21
+ },
22
+ })
23
+ end
24
+
25
+ def call(env)
26
+ params = apollo_upload_server_middleware_params(env)
27
+ result = @client.execute(
28
+ query: params["query"],
29
+ variables: params["variables"],
30
+ operation_name: params["operationName"],
31
+ )
32
+
33
+ [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
34
+ end
35
+ end
36
+
37
+ Rackup::Handler.default.run(StitchedApp.new, :Port => 3000)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'action_dispatch'
4
+ require 'apollo_upload_server/graphql_data_builder'
5
+ require 'apollo_upload_server/upload'
6
+
7
+ # ApolloUploadServer middleware only modifies Rails request params;
8
+ # for simple Rack apps we need to extract the behavior.
9
+ def apollo_upload_server_middleware_params(env)
10
+ req = ActionDispatch::Request.new(env)
11
+ if env['CONTENT_TYPE'].to_s.include?('multipart/form-data')
12
+ ApolloUploadServer::GraphQLDataBuilder.new(strict_mode: true).call(req.params)
13
+ else
14
+ req.params
15
+ end
16
+ end
17
+
18
+ # Gateway local schema
19
+ class GatewaySchema < GraphQL::Schema
20
+ class Query < GraphQL::Schema::Object
21
+ field :gateway, Boolean, null: false
22
+
23
+ def gateway
24
+ true
25
+ end
26
+ end
27
+
28
+ class Mutation < GraphQL::Schema::Object
29
+ field :gateway, Boolean, null: false
30
+
31
+ def gateway
32
+ true
33
+ end
34
+ end
35
+
36
+ query Query
37
+ mutation Mutation
38
+ end
39
+
40
+ # Remote local schema, with file upload
41
+ class RemoteSchema < GraphQL::Schema
42
+ class Query < GraphQL::Schema::Object
43
+ field :remote, Boolean, null: false
44
+
45
+ def remote
46
+ true
47
+ end
48
+ end
49
+
50
+ class Mutation < GraphQL::Schema::Object
51
+ field :upload, String, null: true do
52
+ argument :file, ApolloUploadServer::Upload, required: true
53
+ end
54
+
55
+ def upload(file:)
56
+ file.read
57
+ end
58
+ end
59
+
60
+ query Query
61
+ mutation Mutation
62
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rackup'
4
+ require 'json'
5
+ require 'graphql'
6
+ require_relative './helpers'
7
+
8
+ class RemoteApp
9
+ def call(env)
10
+ params = apollo_upload_server_middleware_params(env)
11
+ result = RemoteSchema.execute(
12
+ query: params["query"],
13
+ variables: params["variables"],
14
+ operation_name: params["operationName"],
15
+ )
16
+
17
+ [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
18
+ end
19
+ end
20
+
21
+ Rackup::Handler.default.run(RemoteApp.new, :Port => 3001)
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'rack'
6
+ gem 'rackup'
7
+ gem 'foreman'
8
+ gem 'graphql'
@@ -0,0 +1,3 @@
1
+ gateway: bundle exec ruby gateway.rb
2
+ remote1: bundle exec ruby remote1.rb
3
+ remote2: bundle exec ruby remote2.rb
@@ -0,0 +1,33 @@
1
+ # Merged types example
2
+
3
+ This example demonstrates several stitched schemas running across small Rack servers with types merged across locations. The main "gateway" location stitches its local schema onto two remote endpoints.
4
+
5
+ Try running it:
6
+
7
+ ```shell
8
+ cd examples/merged_types
9
+ bundle install
10
+ foreman start
11
+ ```
12
+
13
+ Then visit the gateway service at [`http://localhost:3000`](http://localhost:3000) and try this query:
14
+
15
+ ```graphql
16
+ query {
17
+ storefront(id: "1") {
18
+ id
19
+ products {
20
+ upc
21
+ name
22
+ price
23
+ manufacturer {
24
+ name
25
+ address
26
+ products { upc name }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ The above query collects data from all locations. You can also request introspections that resolve using the combined supergraph schema.
@@ -2,10 +2,9 @@
2
2
 
3
3
  require 'rackup'
4
4
  require 'json'
5
- require 'byebug'
6
5
  require 'graphql'
7
- require 'graphql/stitching'
8
- require_relative '../test/schemas/example'
6
+ require_relative '../../lib/graphql/stitching'
7
+ require_relative '../../test/schemas/example'
9
8
 
10
9
  class StitchedApp
11
10
  def initialize
@@ -19,11 +18,11 @@ class StitchedApp
19
18
  },
20
19
  storefronts: {
21
20
  schema: Schemas::Example::Storefronts,
22
- executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001/graphql"),
21
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3001"),
23
22
  },
24
23
  manufacturers: {
25
24
  schema: Schemas::Example::Manufacturers,
26
- executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002/graphql"),
25
+ executable: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3002"),
27
26
  }
28
27
  })
29
28
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rackup'
4
+ require 'json'
5
+ require 'graphql'
6
+ require_relative '../../test/schemas/example'
7
+
8
+ class FirstRemoteApp
9
+ def call(env)
10
+ req = Rack::Request.new(env)
11
+ params = JSON.parse(req.body.read)
12
+ result = Schemas::Example::Storefronts.execute(
13
+ query: params["query"],
14
+ variables: params["variables"],
15
+ operation_name: params["operationName"],
16
+ )
17
+
18
+ [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
19
+ end
20
+ end
21
+
22
+ Rackup::Handler.default.run(FirstRemoteApp.new, :Port => 3001)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rackup'
4
+ require 'json'
5
+ require 'graphql'
6
+ require_relative '../../test/schemas/example'
7
+
8
+ class SecondRemoteApp
9
+ def call(env)
10
+ req = Rack::Request.new(env)
11
+ params = JSON.parse(req.body.read)
12
+ result = Schemas::Example::Manufacturers.execute(
13
+ query: params["query"],
14
+ variables: params["variables"],
15
+ operation_name: params["operationName"],
16
+ )
17
+
18
+ [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
19
+ end
20
+ end
21
+
22
+ Rackup::Handler.default.run(SecondRemoteApp.new, :Port => 3002)
@@ -28,6 +28,7 @@ module GraphQL
28
28
 
29
29
  def execute(query:, variables: nil, operation_name: nil, context: nil, validate: true)
30
30
  request = GraphQL::Stitching::Request.new(
31
+ @supergraph,
31
32
  query,
32
33
  operation_name: operation_name,
33
34
  variables: variables,
@@ -35,24 +36,13 @@ module GraphQL
35
36
  )
36
37
 
37
38
  if validate
38
- validation_errors = @supergraph.schema.validate(request.document)
39
+ validation_errors = request.validate
39
40
  return error_result(validation_errors) if validation_errors.any?
40
41
  end
41
42
 
42
43
  request.prepare!
43
-
44
- plan = fetch_plan(request) do
45
- GraphQL::Stitching::Planner.new(
46
- supergraph: @supergraph,
47
- request: request,
48
- ).perform
49
- end
50
-
51
- GraphQL::Stitching::Executor.new(
52
- supergraph: @supergraph,
53
- request: request,
54
- plan: plan,
55
- ).perform
44
+ load_plan(request)
45
+ request.execute
56
46
  rescue GraphQL::ParseError, GraphQL::ExecutionError => e
57
47
  error_result([e])
58
48
  rescue StandardError => e
@@ -77,13 +67,13 @@ module GraphQL
77
67
 
78
68
  private
79
69
 
80
- def fetch_plan(request)
81
- if @on_cache_read
82
- cached_plan = @on_cache_read.call(request)
83
- return GraphQL::Stitching::Plan.from_json(JSON.parse(cached_plan)) if cached_plan
70
+ def load_plan(request)
71
+ if @on_cache_read && plan_json = @on_cache_read.call(request)
72
+ plan = GraphQL::Stitching::Plan.from_json(JSON.parse(plan_json))
73
+ return request.plan(plan)
84
74
  end
85
75
 
86
- plan = yield
76
+ plan = request.plan
87
77
 
88
78
  if @on_cache_write
89
79
  @on_cache_write.call(request, JSON.generate(plan.as_json))
@@ -29,7 +29,7 @@ module GraphQL
29
29
  @executor.request.operation_directives,
30
30
  )
31
31
  variables = @executor.request.variables.slice(*variable_names)
32
- raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables, @executor.request.context)
32
+ raw_result = @executor.supergraph.execute_at_location(@location, query_document, variables, @executor.request)
33
33
  @executor.query_count += 1
34
34
 
35
35
  merge_results!(origin_sets_by_operation, raw_result.dig("data"))
@@ -17,7 +17,7 @@ module GraphQL
17
17
  @executor.request.operation_directives,
18
18
  )
19
19
  query_variables = @executor.request.variables.slice(*op.variables.keys)
20
- result = @executor.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request.context)
20
+ result = @executor.supergraph.execute_at_location(op.location, query_document, query_variables, @executor.request)
21
21
  @executor.query_count += 1
22
22
 
23
23
  @executor.data.merge!(result["data"]) if result["data"]
@@ -8,10 +8,10 @@ module GraphQL
8
8
  attr_reader :supergraph, :request, :plan, :data, :errors
9
9
  attr_accessor :query_count
10
10
 
11
- def initialize(supergraph:, request:, plan:, nonblocking: false)
12
- @supergraph = supergraph
11
+ def initialize(request, nonblocking: false)
13
12
  @request = request
14
- @plan = plan
13
+ @supergraph = request.supergraph
14
+ @plan = request.plan
15
15
  @data = {}
16
16
  @errors = []
17
17
  @query_count = 0
@@ -24,10 +24,7 @@ module GraphQL
24
24
  result = {}
25
25
 
26
26
  if @data && @data.length > 0
27
- result["data"] = raw ? @data : GraphQL::Stitching::Shaper.new(
28
- supergraph: @supergraph,
29
- request: @request,
30
- ).perform!(@data)
27
+ result["data"] = raw ? @data : GraphQL::Stitching::Shaper.new(@request).perform!(@data)
31
28
  end
32
29
 
33
30
  if @errors.length > 0
@@ -20,8 +20,13 @@ module GraphQL
20
20
  "#{EXPORT_PREFIX}#{name}"
21
21
  end
22
22
 
23
+ # The argument assigning Field.alias changed from
24
+ # a generic `alias` hash key to a structured `field_alias` kwarg.
25
+ # See https://github.com/rmosolgo/graphql-ruby/pull/4718
26
+ FIELD_ALIAS_KWARG = !GraphQL::Language::Nodes::Field.new(field_alias: "a").alias.nil?
27
+
23
28
  def key_node(field_name)
24
- if Util.graphql_version?(2, 2)
29
+ if FIELD_ALIAS_KWARG
25
30
  GraphQL::Language::Nodes::Field.new(field_alias: key(field_name), name: field_name)
26
31
  else
27
32
  GraphQL::Language::Nodes::Field.new(alias: key(field_name), name: field_name)
@@ -7,18 +7,148 @@ require "json"
7
7
  module GraphQL
8
8
  module Stitching
9
9
  class HttpExecutable
10
- def initialize(url:, headers:{})
10
+ def initialize(url:, headers:{}, upload_types: nil)
11
11
  @url = url
12
12
  @headers = { "Content-Type" => "application/json" }.merge!(headers)
13
+ @upload_types = upload_types
13
14
  end
14
15
 
15
- def call(_location, document, variables, _context)
16
- response = Net::HTTP.post(
16
+ def call(request, document, variables)
17
+ multipart_form = if request.variable_definitions.any? && variables&.any?
18
+ extract_multipart_form(request, document, variables)
19
+ end
20
+
21
+ response = if multipart_form
22
+ post_multipart(multipart_form)
23
+ else
24
+ post(document, variables)
25
+ end
26
+
27
+ JSON.parse(response.body)
28
+ end
29
+
30
+ def post(document, variables)
31
+ Net::HTTP.post(
17
32
  URI(@url),
18
33
  JSON.generate({ "query" => document, "variables" => variables }),
19
34
  @headers,
20
35
  )
21
- JSON.parse(response.body)
36
+ end
37
+
38
+ def post_multipart(form_data)
39
+ uri = URI(@url)
40
+ req = Net::HTTP::Post.new(uri)
41
+ @headers.each_pair do |key, value|
42
+ req[key] = value
43
+ end
44
+
45
+ req.set_form(form_data.to_a, "multipart/form-data")
46
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
47
+ http.request(req)
48
+ end
49
+ end
50
+
51
+ # extract multipart upload forms
52
+ # spec: https://github.com/jaydenseric/graphql-multipart-request-spec
53
+ def extract_multipart_form(request, document, variables)
54
+ return unless @upload_types
55
+
56
+ path = []
57
+ files_by_path = {}
58
+
59
+ # extract all upload scalar values mapped by their input path
60
+ variables.each do |key, value|
61
+ ast_node = request.variable_definitions[key]
62
+ path << key
63
+ extract_ast_node(ast_node, value, files_by_path, path, request) if ast_node
64
+ path.pop
65
+ end
66
+
67
+ return if files_by_path.none?
68
+
69
+ map = {}
70
+ files = files_by_path.values.tap(&:uniq!)
71
+ variables_copy = variables.dup
72
+
73
+ files_by_path.keys.each do |path|
74
+ orig = variables
75
+ copy = variables_copy
76
+ path.each_with_index do |key, i|
77
+ if i == path.length - 1
78
+ map_key = files.index(copy[key]).to_s
79
+ map[map_key] ||= []
80
+ map[map_key] << "variables.#{path.join(".")}"
81
+ copy[key] = nil
82
+ elsif orig[key].object_id == copy[key].object_id
83
+ copy[key] = copy[key].dup
84
+ end
85
+ orig = orig[key]
86
+ copy = copy[key]
87
+ end
88
+ end
89
+
90
+ form = {
91
+ "operations" => JSON.generate({
92
+ "query" => document,
93
+ "variables" => variables_copy,
94
+ }),
95
+ "map" => JSON.generate(map),
96
+ }
97
+
98
+ files.each_with_object(form).with_index do |(file, memo), index|
99
+ memo[index.to_s] = file.respond_to?(:tempfile) ? file.tempfile : file
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def extract_ast_node(ast_node, value, files_by_path, path, request)
106
+ return unless value
107
+
108
+ ast_node = ast_node.of_type while ast_node.is_a?(GraphQL::Language::Nodes::NonNullType)
109
+
110
+ if ast_node.is_a?(GraphQL::Language::Nodes::ListType)
111
+ if value.is_a?(Array)
112
+ value.each_with_index do |val, index|
113
+ path << index
114
+ extract_ast_node(ast_node.of_type, val, files_by_path, path, request)
115
+ path.pop
116
+ end
117
+ end
118
+ elsif @upload_types.include?(ast_node.name)
119
+ files_by_path[path.dup] = value
120
+ else
121
+ type_def = request.supergraph.schema.get_type(ast_node.name)
122
+ extract_type_node(type_def, value, files_by_path, path) if type_def&.kind&.input_object?
123
+ end
124
+ end
125
+
126
+ def extract_type_node(parent_type, value, files_by_path, path)
127
+ return unless value
128
+
129
+ parent_type = Util.unwrap_non_null(parent_type)
130
+
131
+ if parent_type.list?
132
+ if value.is_a?(Array)
133
+ value.each_with_index do |val, index|
134
+ path << index
135
+ extract_type_node(parent_type.of_type, val, files_by_path, path)
136
+ path.pop
137
+ end
138
+ end
139
+ elsif parent_type.kind.input_object?
140
+ if value.is_a?(Enumerable)
141
+ arguments = parent_type.arguments
142
+ value.each do |key, val|
143
+ arg_type = arguments[key]&.type
144
+ path << key
145
+ extract_type_node(arg_type, val, files_by_path, path) if arg_type
146
+ path.pop
147
+ end
148
+ end
149
+ elsif @upload_types.include?(parent_type.graphql_name)
150
+ files_by_path[path.dup] = value
151
+ end
22
152
  end
23
153
  end
24
154
  end
@@ -9,9 +9,9 @@ module GraphQL
9
9
  MUTATION_OP = "mutation"
10
10
  ROOT_INDEX = 0
11
11
 
12
- def initialize(supergraph:, request:)
13
- @supergraph = supergraph
12
+ def initialize(request)
14
13
  @request = request
14
+ @supergraph = request.supergraph
15
15
  @planning_index = ROOT_INDEX
16
16
  @steps_by_entrypoint = {}
17
17
  end
@@ -6,9 +6,10 @@ module GraphQL
6
6
  SUPPORTED_OPERATIONS = ["query", "mutation"].freeze
7
7
  SKIP_INCLUDE_DIRECTIVE = /@(?:skip|include)/
8
8
 
9
- attr_reader :document, :variables, :operation_name, :context
9
+ attr_reader :supergraph, :document, :variables, :operation_name, :context
10
10
 
11
- def initialize(document, operation_name: nil, variables: nil, context: nil)
11
+ def initialize(supergraph, document, operation_name: nil, variables: nil, context: nil)
12
+ @supergraph = supergraph
12
13
  @string = nil
13
14
  @digest = nil
14
15
  @normalized_string = nil
@@ -17,6 +18,7 @@ module GraphQL
17
18
  @operation_directives = nil
18
19
  @variable_definitions = nil
19
20
  @fragment_definitions = nil
21
+ @plan = nil
20
22
 
21
23
  @document = if document.is_a?(String)
22
24
  @string = document
@@ -83,6 +85,10 @@ module GraphQL
83
85
  end
84
86
  end
85
87
 
88
+ def validate
89
+ @supergraph.schema.validate(@document, context: @context)
90
+ end
91
+
86
92
  def prepare!
87
93
  operation.variables.each do |v|
88
94
  @variables[v.name] = v.default_value if @variables[v.name].nil? && !v.default_value.nil?
@@ -93,12 +99,25 @@ module GraphQL
93
99
  @document = modified_ast
94
100
  @string = @normalized_string = nil
95
101
  @digest = @normalized_digest = nil
96
- @operation = @operation_directives = @variable_definitions = nil
102
+ @operation = @operation_directives = @variable_definitions = @plan = nil
97
103
  end
98
104
  end
99
105
 
100
106
  self
101
107
  end
108
+
109
+ def plan(new_plan = nil)
110
+ if new_plan
111
+ raise StitchingError, "Plan must be a `GraphQL::Stitching::Plan`." unless new_plan.is_a?(Plan)
112
+ @plan = new_plan
113
+ else
114
+ @plan ||= GraphQL::Stitching::Planner.new(self).perform
115
+ end
116
+ end
117
+
118
+ def execute(raw: false)
119
+ GraphQL::Stitching::Executor.new(self).perform(raw: raw)
120
+ end
102
121
  end
103
122
  end
104
123
  end
@@ -6,9 +6,9 @@ module GraphQL
6
6
  # Shapes the final results payload to the request selection and schema definition.
7
7
  # This eliminates unrequested export selections and applies null bubbling.
8
8
  class Shaper
9
- def initialize(supergraph:, request:)
10
- @supergraph = supergraph
9
+ def initialize(request)
11
10
  @request = request
11
+ @supergraph = request.supergraph
12
12
  @root_type = nil
13
13
  end
14
14
 
@@ -90,7 +90,7 @@ module GraphQL
90
90
 
91
91
  attr_reader :schema, :boundaries, :locations_by_type_and_field, :executables
92
92
 
93
- def initialize(schema:, fields:, boundaries:, executables:)
93
+ def initialize(schema:, fields: {}, boundaries: {}, executables: {})
94
94
  @schema = schema
95
95
  @boundaries = boundaries
96
96
  @possible_keys_by_type = {}
@@ -209,7 +209,7 @@ module GraphQL
209
209
  end
210
210
  end
211
211
 
212
- def execute_at_location(location, source, variables, context)
212
+ def execute_at_location(location, source, variables, request)
213
213
  executable = executables[location]
214
214
 
215
215
  if executable.nil?
@@ -218,11 +218,11 @@ module GraphQL
218
218
  executable.execute(
219
219
  query: source,
220
220
  variables: variables,
221
- context: context.frozen? ? context.dup : context,
221
+ context: request.context.frozen? ? request.context.dup : request.context,
222
222
  validate: false,
223
223
  )
224
224
  elsif executable.respond_to?(:call)
225
- executable.call(location, source, variables, context)
225
+ executable.call(request, source, variables)
226
226
  else
227
227
  raise StitchingError, "Missing valid executable for #{location} location."
228
228
  end
@@ -12,16 +12,7 @@ module GraphQL
12
12
  end
13
13
  end
14
14
 
15
- GRAPHQL_VERSION = GraphQL::VERSION.split(".").map(&:to_i).freeze
16
-
17
15
  class << self
18
- def graphql_version?(major, minor = nil, patch = nil)
19
- result = GRAPHQL_VERSION[0] >= major
20
- result &&= GRAPHQL_VERSION[1] >= minor if minor
21
- result &&= GRAPHQL_VERSION[2] >= patch if patch
22
- result
23
- end
24
-
25
16
  # specifies if a type is a primitive leaf value
26
17
  def is_leaf_type?(type)
27
18
  type.kind.scalar? || type.kind.enum?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module GraphQL
4
4
  module Stitching
5
- VERSION = "1.1.1"
5
+ VERSION = "1.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-stitching
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg MacWilliam
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-12-26 00:00:00.000000000 Z
11
+ date: 2023-12-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -76,13 +76,13 @@ files:
76
76
  - ".gitignore"
77
77
  - Gemfile
78
78
  - LICENSE
79
- - Procfile
80
79
  - README.md
81
80
  - Rakefile
82
81
  - docs/README.md
83
82
  - docs/client.md
84
83
  - docs/composer.md
85
84
  - docs/executor.md
85
+ - docs/http_executable.md
86
86
  - docs/images/library.png
87
87
  - docs/images/merging.png
88
88
  - docs/images/stitching.png
@@ -90,10 +90,20 @@ files:
90
90
  - docs/planner.md
91
91
  - docs/request.md
92
92
  - docs/supergraph.md
93
- - example/gateway.rb
94
- - example/graphiql.html
95
- - example/remote1.rb
96
- - example/remote2.rb
93
+ - examples/file_uploads/Gemfile
94
+ - examples/file_uploads/Procfile
95
+ - examples/file_uploads/README.md
96
+ - examples/file_uploads/file.txt
97
+ - examples/file_uploads/gateway.rb
98
+ - examples/file_uploads/helpers.rb
99
+ - examples/file_uploads/remote.rb
100
+ - examples/merged_types/Gemfile
101
+ - examples/merged_types/Procfile
102
+ - examples/merged_types/README.md
103
+ - examples/merged_types/gateway.rb
104
+ - examples/merged_types/graphiql.html
105
+ - examples/merged_types/remote1.rb
106
+ - examples/merged_types/remote2.rb
97
107
  - gemfiles/graphql_1.13.9.gemfile
98
108
  - graphql-stitching.gemspec
99
109
  - lib/graphql/stitching.rb
data/Procfile DELETED
@@ -1,3 +0,0 @@
1
- gateway: bundle exec ruby example/gateway.rb
2
- remote1: bundle exec ruby example/remote1.rb
3
- remote2: bundle exec ruby example/remote2.rb
data/example/remote1.rb DELETED
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rackup'
4
- require 'json'
5
- require 'graphql'
6
- require_relative '../test/schemas/example'
7
-
8
- class FirstRemoteApp
9
- def call(env)
10
- req = Rack::Request.new(env)
11
- case req.path_info
12
- when /graphql/
13
- params = JSON.parse(req.body.read)
14
- result = Schemas::Example::Storefronts.execute(
15
- query: params["query"],
16
- variables: params["variables"],
17
- operation_name: params["operationName"],
18
- )
19
- [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
20
- else
21
- [404, {"content-type" => "text/html"}, ["not found"]]
22
- end
23
- end
24
- end
25
-
26
- Rackup::Handler.default.run(FirstRemoteApp.new, :Port => 3001)
data/example/remote2.rb DELETED
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rackup'
4
- require 'json'
5
- require 'graphql'
6
- require_relative '../test/schemas/example'
7
-
8
- class SecondRemoteApp
9
- def call(env)
10
- req = Rack::Request.new(env)
11
- case req.path_info
12
- when /graphql/
13
- params = JSON.parse(req.body.read)
14
- result = Schemas::Example::Manufacturers.execute(
15
- query: params["query"],
16
- variables: params["variables"],
17
- operation_name: params["operationName"],
18
- )
19
- [200, {"content-type" => "application/json"}, [JSON.generate(result)]]
20
- else
21
- [404, {"content-type" => "text/html"}, ["not found"]]
22
- end
23
- end
24
- end
25
-
26
- Rackup::Handler.default.run(SecondRemoteApp.new, :Port => 3002)
File without changes