Skip to content

Commit

Permalink
Update files documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
tobias-tengler committed Oct 24, 2021
1 parent 184d955 commit dc07abf
Showing 1 changed file with 217 additions and 23 deletions.
240 changes: 217 additions & 23 deletions website/src/docs/hotchocolate/server/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,59 +2,186 @@
title: Files
---

import { ExampleTabs } from "../../../components/mdx/example-tabs";

Handling files is traditionally not a concern of a GraphQL server, which is also why the [GraphQL over HTTP](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md) specification doesn't mention it.

That being said, we recognize that at some point in the development of a new application you'll likely have to deal with files in some way or another. Which is why we want to give you some guidance on this topic.

# Uploading files

Hot Chocolate implements the GraphQL multipart request specification which allows it to handle file upload streams.
When it comes to uploading files there are a couple of options we have.

## Completely decoupled

We could handle file uploads completely decoupled from our GraphQL server, for example using a dedicated web application offering a HTTP endpoint for us to upload our files to.

This however has a couple of downsides:

- Authentication and authorization need to be handled by this dedicated endpoint as well.
- The process of uploading a file would need to be documented outside of our GraphQL schema.

[Learn more about the specification](https://github.com/jaydenseric/graphql-multipart-request-spec)
## Upload scalar

## Usage
Hot Chocolate implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) which adds a new `Upload` scalar and allows our GraphQL server to handle file upload streams.

In order to use file upload streams in our input types or as an argument register the `Upload` scalar like the following.
> ⚠️ Note: Files can not yet be uploaded through a gateway to stitched services using the `Upload` scalar.
### Usage

In order to use file upload streams in our input types or as an argument register the `Upload` scalar like the following:

```csharp
services
.AddGraphQLServer()
.AddType<UploadType>();
```

> ⚠️ Note: Files can not yet be uploaded from a gateway to stitched services using the `Upload` scalar.
> Note: The `Upload` scalar can only be used as an input type and does not work on output types.
We can use the `Upload` scalar as an argument like the following:

In our resolver or input type we can then use the `IFile` interface to use the upload scalar.
<ExampleTabs>
<ExampleTabs.Annotation>

```csharp
public class Mutation
{
public async Task<bool> UploadFileAsync(IFile file)
{
using Stream stream = file.OpenReadStream();
// we can now work with standard stream functionality of .NET
var fileName = file.Name;
var fileSize = file.Length;

await using Stream stream = file.OpenReadStream();

// We can now work with standard stream functionality of .NET
// to handle the file.
}
}
```

public async Task<bool> UploadFiles(List<IFile> files)
{
// Omitted code for brevity
}
</ExampleTabs.Annotation>
<ExampleTabs.Code>

public async Task<bool> UploadFileInInput(ExampleInput input)
```csharp
public class MutationType : ObjectType
{
protected override void Configure(IObjectTypeDescriptor descriptor)
{
// Omitted code for brevity
descriptor
.Field("uploadFile")
.Argument("file", a => a.Type<UploadType>())
.Resolve(async context =>
{
var file = context.ArgumentValue<IFile>("file");

var fileName = file.Name;
var fileSize = file.Length;

await using Stream stream = file.OpenReadStream();

// We can now work with standard stream functionality of .NET
// to handle the file.
});
}
}
```

</ExampleTabs.Code>
<ExampleTabs.Schema>

Take a look at the Annotation-based or Code-first example.

</ExampleTabs.Schema>
</ExampleTabs>

[Learn more about arguments](/docs/hotchocolate/defining-a-schema/arguments)

In input object types it can be used like the following.

<ExampleTabs>
<ExampleTabs.Annotation>

```csharp
public class ExampleInput
{
[GraphQLType(typeof(NonNullType<UploadType>))]
public IFile File { get; set; }
}
```

> Note: The `Upload` scalar can only be used as an input type and does not work on output types.
</ExampleTabs.Annotation>
<ExampleTabs.Code>

```csharp
public class ExampleInput
{
public IFile File { get; set; }
}

public class ExampleInputType : InputObjectType<ExampleInput>
{
protected override void Configure(IInputObjectTypeDescriptor<ExampleInput> descriptor)
{
descriptor.Field(f => f.File).Type<UploadType>();
}
}
```

</ExampleTabs.Code>
<ExampleTabs.Schema>

Take a look at the Annotation-based or Code-first example.

</ExampleTabs.Schema>
</ExampleTabs>

[Learn more about input object types](/docs/hotchocolate/defining-a-schema/input-object-types)

If you need to upload a list of files, it works exactly as you would expect. You just use a `List<IFile>` or `ListType<UploadType>`.

[Learn more about lists](/docs/hotchocolate/defining-a-schema/lists)

### Client usage

When performing a mutation with the `Upload` scalar, we need to use variables.

An example mutation could look like the following:

```graphql
mutation ($file: Upload!) {
uploadFile(file: $file) {
success
}
}
```

If we now want to send this request to our GraphQL server, we need to do so using HTTP multipart:

```bash
curl localhost:5000/graphql \
-F operations='{ "query": "mutation ($file: Upload!) { uploadFile(file: $file) { success } }", "variables": { "file": null } }' \
-F map='{ "0": ["variables.file"] }' \
-F 0=@file.txt

```

> Note: The `$file` variable is intentionally `null`. It is filled in by Hot Chocolate on the server.
[More examples can be found here](https://github.com/jaydenseric/graphql-multipart-request-spec#examples)

You can check if your GraphQL client supports the specification [here](https://github.com/jaydenseric/graphql-multipart-request-spec#client).

Both Relay and Apollo support this specification through community packages:

- [react-relay-network-modern](https://github.com/relay-tools/react-relay-network-modern) using the `uploadMiddleware`
- [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client)

> ⚠️ Note: [Strawberry Shake](/docs/strawberryshake) does not yet support the `Upload` scalar.
## Configuration
### Options

If we need to upload large files or set custom upload size limits, we can configure those by registering custom [`FormOptions`](https://docs.microsoft.com/dotnet/api/microsoft.aspnetcore.http.features.formoptions).
If you need to upload larger files or set custom upload size limits, you can configure those by registering custom [`FormOptions`](https://docs.microsoft.com/dotnet/api/microsoft.aspnetcore.http.features.formoptions).

```csharp
services.Configure<FormOptions>(options =>
Expand All @@ -66,21 +193,88 @@ services.Configure<FormOptions>(options =>

Based on our WebServer we might need to configure these limits elsewhere as well. [Kestrel](https://docs.microsoft.com/aspnet/core/mvc/models/file-uploads#kestrel-maximum-request-body-size) and [IIS](https://docs.microsoft.com/aspnet/core/mvc/models/file-uploads#iis) are covered in the ASP.NET Core Documentation.

## Client usage
## Presigned upload URLs

TODO
The arguably best solution for uploading files is a hybrid of the above. Our GraphQL server still provides a mutation for uploading files, **but** the mutation is only used to setup a file upload. The actual file upload is done through a dedicated endpoint.

> ⚠️ Note: [Strawberry Shake](/docs/strawberryshake) does not yet support the `Upload` scalar.
We can accomplish this by returning _presigned upload URLs_ from our mutations. _Presigned upload URLs_ are URLs that point to an endpoint, through which we can upload our files. Files can only be uploaded to this endpoint, if the URL to this endpoint contains a valid token. Our mutation generates said token, appends the token to the upload URL and returns the _presigned_ URL to the client.

Let's take a look at a quick example. We have built the following mutation resolver:

```csharp
public record ProfilePictureUploadPayload(string UploadUrl);

public class Mutation
{
[Authorize]
public ProfilePictureUploadPayload UploadProfilePicture()
{
var baseUrl = "https://blob.chillicream.com/upload";

// Here we can handle our authorization logic
// If the user is allowed to upload the profile picture
// we generate the token
var token = "myuploadtoken";

var uploadUrl = QueryHelpers.AddQueryString(baseUrl, "token", token);

return new(uploadUrl);
}
}
```

If you are using any of the big cloud providers for storing your BLOBs, chances are they already come with support for _presigned upload URLs_:

- [Azure Storage shared access signatures](https://docs.microsoft.com/en-us/azure/storage/common/storage-sas-overview)
- [AWS presigned URLS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html)
- [GCP signed URLs](https://cloud.google.com/storage/docs/access-control/signed-urls)

If you need to implement the file upload endpoint yourself, you can research best practices for creating _presigned upload URLs_.

Let's take a look at how a client would upload a new profile picture.

**Request**

```graphql
mutation {
uploadProfilePicture {
uploadUrl
}
}
```

**Response**

```json
{
"data": {
"uploadProfilePicture": {
"uploadUrl": "https://blob.chillicream.com/upload?token=myuploadtoken"
}
}
}
```

Given the `uploadUrl` our client can now HTTP POST the file to this endpoint to upload his profile picture.

This solution offers the following benefits:

- Uploading files is treated as a separate concern and our GraphQL server is kept _pure_ in a sense.
- The GraphQL server maintains control over authorization and all of the business logic regarding granting a file upload stays in one place.
- The action of uploading a profile picture is described by the schema and therefore more discoverable for developers.

There is still some uncertainty about how the actual file upload happens, e.g. which HTTP verb to use or which headers to send using the `uploadUrl`. These additional parameters can either be documented somewhere or be made queryable using our mutation.

# Serving files

Lets imagine we have an application where we want to display user profiles with information such as a name and a profile picture. How would we query for the image?
Let's imagine we want to expose the file we just uploaded as the user's profile picture. How would we query for this file?

We _could_ make the profile picture a queryable field in our graph that returns the Base64 encoded image. While this _can_ work it has a number of downsides:

- Since the image is part of the JSON serialized GraphQL response, caching is incredibly hard.
- A query for the user's name might take a couple of milliseconds to transfer from the server to the client. Additionally querying for the image data might increase the response time by seconds.
- Let's not even think about how video playback, i.e. streaming, would be done...
- Let's not even think about how video playback, i.e. streaming, would work...

The recommended solution is to serve files through a different HTTP endpoint and only referencing this endpoint in our GraphQL response. So instead of querying for the profile picture we would query for an URL that points to the profile picture.

Expand All @@ -102,7 +296,7 @@ The recommended solution is to serve files through a different HTTP endpoint and
"data": {
"user": {
"name": "John Doe",
"imageUrl": "http://img.chillicream.com/john-doe.png"
"imageUrl": "https://blob.chillicream.com/john-doe.png"
}
}
}
Expand Down

0 comments on commit dc07abf

Please sign in to comment.