Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support GraphQL uploads via multipart form spec #103

Merged
merged 2 commits into from
Dec 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
- Shared objects, fields, enums, and inputs across locations.
- Combining local and remote schemas.
- Type merging via arbitrary queries or federation `_entities` protocol.
- File uploads via [multipart form spec](https://github.com/jaydenseric/graphql-multipart-request-spec).

**NOT Supported:**
- Computed fields (ie: federation-style `@requires`).
Expand Down Expand Up @@ -80,6 +81,7 @@ While the `Client` constructor is an easy quick start, the library also has seve
- [Request](./docs/request.md) - prepares a requested GraphQL document and variables for stitching.
- [Planner](./docs/planner.md) - builds a cacheable query plan for a request document.
- [Executor](./docs/executor.md) - executes a query plan with given request variables.
- [HttpExecutable](./docs/http_executable.md) - proxies requests to remotes with multipart file upload support.

## Merged types

Expand Down Expand Up @@ -397,7 +399,7 @@ supergraph = GraphQL::Stitching::Composer.new.perform({
})
```

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)).
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)).

## Batching

Expand Down Expand Up @@ -436,6 +438,7 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im
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.

- [Merged types](./examples/merged_types)
- [File uploads](./examples/file_uploads)

## Tests

Expand Down
51 changes: 51 additions & 0 deletions docs/http_executable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
## GraphQL::Stitching::HttpExecutable

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).

```ruby
exe = GraphQL::Stitching::HttpExecutable.new(
url: "http://localhost:3001",
headers: {
"Authorization" => "..."
}
)
```

### GraphQL file uploads

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:

#### 1. Input file uploads as Tempfile variables

```ruby
client.execute(
"mutation($file: Upload) { upload(file: $file) }",
variables: { "file" => Tempfile.new(...) }
)
```

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.

#### 2. Enable `HttpExecutable.upload_types`

```ruby
client = GraphQL::Stitching::Client.new(locations: {
alpha: {
schema: GraphQL::Schema.from_definition(...),
executable: GraphQL::Stitching::HttpExecutable.new(
url: "http://localhost:3000",
upload_types: ["Upload"], # << extract `Upload` scalars into multipart forms
),
},
bravo: {
schema: GraphQL::Schema.from_definition(...),
executable: GraphQL::Stitching::HttpExecutable.new(
url: "http://localhost:3001"
),
},
})
```

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.

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.
9 changes: 9 additions & 0 deletions examples/file_uploads/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

source 'https://rubygems.org'

gem 'rack'
gem 'rackup'
gem 'foreman'
gem 'graphql'
gem 'apollo_upload_server', '2.1'
2 changes: 2 additions & 0 deletions examples/file_uploads/Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
gateway: bundle exec ruby gateway.rb
remote: bundle exec ruby remote.rb
37 changes: 37 additions & 0 deletions examples/file_uploads/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# File uploads example

This example demonstrates uploading files via the [GraphQL Upload spec](https://github.com/jaydenseric/graphql-multipart-request-spec).

Try running it:

```shell
cd examples/file_uploads
bundle install
foreman start
```

This example is headless, but you can verify the stitched schema is running by querying a field from each graph location:

```shell
curl -X POST http://localhost:3000 \
-H 'Content-Type: application/json' \
-d '{"query":"{ gateway remote }"}'
```

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:

```shell
curl http://localhost:3000 \
-H 'Content-Type: multipart/form-data' \
-F operations='{ "query": "mutation ($file: Upload!) { gateway upload(file: $file) }", "variables": { "file": null } }' \
-F map='{ "0": ["variables.file"] }' \
-F [email protected]
```

This workflow has:

1. Submitted a multipart form to the stitched gateway.
2. The gateway server unpacked the request using [apollo_upload_server](https://github.com/jetruby/apollo_upload_server-ruby).
3. Stitching delegated the `upload` field to its appropraite subgraph location.
4. `HttpExecutable` has re-encoded the subgraph request into a multipart form.
5. The subgraph location has recieved, unpacked, and resolved the uploaded file.
1 change: 1 addition & 0 deletions examples/file_uploads/file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello World!
37 changes: 37 additions & 0 deletions examples/file_uploads/gateway.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

require 'rackup'
require 'json'
require 'graphql'
require_relative '../../lib/graphql/stitching'
require_relative './helpers'

class StitchedApp
def initialize
@client = GraphQL::Stitching::Client.new(locations: {
gateway: {
schema: GatewaySchema,
},
remote: {
schema: RemoteSchema,
executable: GraphQL::Stitching::HttpExecutable.new(
url: "http://localhost:3001",
upload_types: ["Upload"]
),
},
})
end

def call(env)
params = apollo_upload_server_middleware_params(env)
result = @client.execute(
query: params["query"],
variables: params["variables"],
operation_name: params["operationName"],
)

[200, {"content-type" => "application/json"}, [JSON.generate(result)]]
end
end

Rackup::Handler.default.run(StitchedApp.new, :Port => 3000)
62 changes: 62 additions & 0 deletions examples/file_uploads/helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

require 'action_dispatch'
require 'apollo_upload_server/graphql_data_builder'
require 'apollo_upload_server/upload'

# ApolloUploadServer middleware only modifies Rails request params;
# for simple Rack apps we need to extract the behavior.
def apollo_upload_server_middleware_params(env)
req = ActionDispatch::Request.new(env)
if env['CONTENT_TYPE'].to_s.include?('multipart/form-data')
ApolloUploadServer::GraphQLDataBuilder.new(strict_mode: true).call(req.params)
else
req.params
end
end

# Gateway local schema
class GatewaySchema < GraphQL::Schema
class Query < GraphQL::Schema::Object
field :gateway, Boolean, null: false

def gateway
true
end
end

class Mutation < GraphQL::Schema::Object
field :gateway, Boolean, null: false

def gateway
true
end
end

query Query
mutation Mutation
end

# Remote local schema, with file upload
class RemoteSchema < GraphQL::Schema
class Query < GraphQL::Schema::Object
field :remote, Boolean, null: false

def remote
true
end
end

class Mutation < GraphQL::Schema::Object
field :upload, String, null: true do
argument :file, ApolloUploadServer::Upload, required: true
end

def upload(file:)
file.read
end
end

query Query
mutation Mutation
end
21 changes: 21 additions & 0 deletions examples/file_uploads/remote.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

require 'rackup'
require 'json'
require 'graphql'
require_relative './helpers'

class RemoteApp
def call(env)
params = apollo_upload_server_middleware_params(env)
result = RemoteSchema.execute(
query: params["query"],
variables: params["variables"],
operation_name: params["operationName"],
)

[200, {"content-type" => "application/json"}, [JSON.generate(result)]]
end
end

Rackup::Handler.default.run(RemoteApp.new, :Port => 3001)
Loading