Skip to content

Commit

Permalink
Support multipart file uploads (#1115)
Browse files Browse the repository at this point in the history
  • Loading branch information
Shane32 committed Jan 23, 2024
1 parent 2462fdd commit f903c2e
Show file tree
Hide file tree
Showing 23 changed files with 1,084 additions and 19 deletions.
16 changes: 15 additions & 1 deletion GraphQL.Server.sln
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
ProjectSection(SolutionItems) = preProject
.github\workflows\build.yml = .github\workflows\build.yml
.github\workflows\codeql-analysis.yml = .github\workflows\codeql-analysis.yml
.github\workflows\label.yml = .github\workflows\label.yml
.github\workflows\format.yml = .github\workflows\format.yml
.github\workflows\label.yml = .github\workflows\label.yml
.github\workflows\publish.yml = .github\workflows\publish.yml
.github\workflows\test.yml = .github\workflows\test.yml
.github\workflows\wipcheck.yml = .github\workflows\wipcheck.yml
Expand Down Expand Up @@ -120,6 +120,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Samples.AzureFunctions", "s
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Samples.AzureFunctions.Tests", "tests\Samples.AzureFunctions.Tests\Samples.AzureFunctions.Tests.csproj", "{A204E359-05E8-4CEE-891C-4CCA6570FA52}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples.Upload", "samples\Samples.Upload\Samples.Upload.csproj", "{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Samples.Upload.Tests", "tests\Samples.Upload.Tests\Samples.Upload.Tests.csproj", "{DE3059F4-B548-4091-BFC0-5879246A2DF9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -262,6 +266,14 @@ Global
{A204E359-05E8-4CEE-891C-4CCA6570FA52}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A204E359-05E8-4CEE-891C-4CCA6570FA52}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A204E359-05E8-4CEE-891C-4CCA6570FA52}.Release|Any CPU.Build.0 = Release|Any CPU
{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8}.Release|Any CPU.Build.0 = Release|Any CPU
{DE3059F4-B548-4091-BFC0-5879246A2DF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DE3059F4-B548-4091-BFC0-5879246A2DF9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DE3059F4-B548-4091-BFC0-5879246A2DF9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DE3059F4-B548-4091-BFC0-5879246A2DF9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -301,6 +313,8 @@ Global
{7F5D8EE4-CD03-482E-A478-E3334F1D0439} = {382C5C04-A34D-4C81-83D7-584C85FB9356}
{FD93A9D8-4663-4FF0-8082-DE9E006956FD} = {5C07AFA3-12F2-40EA-807D-7A1EEF29012B}
{A204E359-05E8-4CEE-891C-4CCA6570FA52} = {BBD07745-C962-4D2D-B302-6DA1BCC2FF43}
{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8} = {5C07AFA3-12F2-40EA-807D-7A1EEF29012B}
{DE3059F4-B548-4091-BFC0-5879246A2DF9} = {BBD07745-C962-4D2D-B302-6DA1BCC2FF43}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3FC7FA59-E938-453C-8C4A-9D5635A9489A}
Expand Down
66 changes: 53 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ This package is designed for ASP.NET Core (2.1 through 6.0) to facilitate easy s
over HTTP. The code is designed to be used as middleware within the ASP.NET Core pipeline,
serving GET, POST or WebSocket requests. GET requests process requests from the query string.
POST requests can be in the form of JSON requests, form submissions, or raw GraphQL strings.
Form submissions either accepts `query`, `operationName`, `variables` and `extensions` parameters,
or `operations` and `map` parameters along with file uploads as defined in the
[GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec).
WebSocket requests can use the `graphql-ws` or `graphql-transport-ws` WebSocket sub-protocol,
as defined in the [apollographql/subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws)
and [enisdenjo/graphql-ws](https://github.com/enisdenjo/graphql-ws) repositories, respectively.
Expand Down Expand Up @@ -660,6 +663,8 @@ methods allowing for different options for each configured endpoint.
| `HandleGet` | Enables handling of GET requests. | True |
| `HandlePost` | Enables handling of POST requests. | True |
| `HandleWebSockets` | Enables handling of WebSockets requests. | True |
| `MaximumFileSize` | Sets the maximum file size allowed for GraphQL multipart requests. | unlimited |
| `MaximumFileCount` | Sets the maximum number of files allowed for GraphQL multipart requests. | unlimited |
| `ReadExtensionsFromQueryString` | Enables reading extensions from the query string. | True |
| `ReadFormOnPost` | Enables parsing of form data for POST requests (may have security implications). | True |
| `ReadQueryStringOnPost` | Enables parsing the query string on POST requests. | True |
Expand Down Expand Up @@ -918,6 +923,24 @@ security risk. However, GraphQL query operations usually do not alter data, and
Additionally, the response is not expected to be readable in the browser (unless CORS checks are successful),
which helps alleviate this concern.

GraphQL.NET Server supports two formats of `application/x-www-form-urlencoded` or `multipart/form-data` requests:

1. The following keys are read from the form data and used to populate the GraphQL request:
- `query`: The GraphQL query string.
- `operationName`: The name of the operation to execute.
- `variables`: A JSON-encoded object containing the variables for the operation.
- `extensions`: A JSON-encoded object containing the extensions for the operation.

2. The following keys are read from the form data and used to populate the GraphQL request:
- `operations`: A JSON-encoded object containing the GraphQL request, in the same format as typical
requests sent via `application/json`. This can be a single object or an array of objects if batching
is enabled.
- `map`: An optional JSON-encoded map of file keys to file objects. This is used to map attached files
into the GraphQL request's variables property. See the section below titled 'File uploading/downloading' and the
[GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec)
for additional details. Since `application/x-www-form-urlencoded` cannot transmit files, this feature
is only available for `multipart/form-data` requests.

### Excessive `OperationCanceledException`s

When hosting a WebSockets endpoint, it may be common for clients to simply disconnect rather
Expand Down Expand Up @@ -956,10 +979,26 @@ security complications, especially when used with JWT bearer authentication.
This answer often works well for GraphQL queries, but may not be desired during
uploads (mutations).

An option for uploading is to upload file data alongside a mutation with the `multipart/form-data`
content type. Please see [Issue 307](https://github.com/graphql-dotnet/server/issues/307) and
[FileUploadTests.cs](https://github.com/graphql-dotnet/server/blob/master/tests/Transports.AspNetCore.Tests/Middleware/FileUploadTests.cs)
for discussion and demonstration of this capability.
An option for uploading is to upload file data alongside a mutation with the
`multipart/form-data` content type as described by the
[GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec).
Uploaded files are mapped into the GraphQL request's variables as `IFormFile` objects.
You can use the provided `FormFileGraphType` scalar graph type in your GraphQL schema
to access these files. The `AddFormFileGraphType()` builder extension method adds this scalar
to the DI container and configures a CLR type mapping for it to be used for `IFormFile` objects.

```csharp
services.AddGraphQL(b => b
.AddAutoSchema<Query>()
.AddFormFileGraphType()
.AddSystemTextJson());
```

Please see the 'Upload' sample for a demonstration of this technique. Note that
using the `FormFileGraphType` scalar requires that the uploaded files be sent only
via the `multipart/form-data` content type as attached files. If you wish to also
allow clients to send files as base-64 encoded strings, you can write a custom scalar
better suited to your needs.

## Samples

Expand All @@ -968,16 +1007,17 @@ typical ASP.NET Core scenarios.

| Name | Framework | Description |
|-----------------|--------------------------|-------------|
| Authorization | .NET 6 Minimal | Based on the VS template, demonstrates authorization functionality with cookie-based authentication |
| Basic | .NET 6 Minimal | Demonstrates simplest possible implementation |
| Complex | .NET 3.1 / 5 / 6 | Demonstrates older Program/Startup files and various configuration options, and multiple UI endpoints |
| Controller | .NET 6 Minimal | MVC implementation; does not include WebSocket support |
| Cors | .NET 6 Minimal | Demonstrates configuring a GraphQL endpoint to use a specified CORS policy |
| EndpointRouting | .NET 6 Minimal | Demonstrates configuring GraphQL through endpoint routing |
| Jwt | .NET 6 Minimal | Demonstrates authenticating GraphQL requests with a JWT bearer token over HTTP POST and WebSocket connections |
| MultipleSchemas | .NET 6 Minimal | Demonstrates configuring multiple schemas within a single server |
| Authorization | .NET 8 Minimal | Based on the VS template, demonstrates authorization functionality with cookie-based authentication |
| Basic | .NET 8 Minimal | Demonstrates simplest possible implementation |
| Complex | .NET 3.1 / 6 / 8 | Demonstrates older Program/Startup files and various configuration options, and multiple UI endpoints |
| Controller | .NET 8 Minimal | MVC implementation; does not include WebSocket support |
| Cors | .NET 8 Minimal | Demonstrates configuring a GraphQL endpoint to use a specified CORS policy |
| EndpointRouting | .NET 8 Minimal | Demonstrates configuring GraphQL through endpoint routing |
| Jwt | .NET 8 Minimal | Demonstrates authenticating GraphQL requests with a JWT bearer token over HTTP POST and WebSocket connections |
| MultipleSchemas | .NET 8 Minimal | Demonstrates configuring multiple schemas within a single server |
| Net48 | .NET Core 2.1 / .NET 4.8 | Demonstrates configuring GraphQL on .NET 4.8 / Core 2.1 |
| Pages | .NET 6 Minimal | Demonstrates configuring GraphQL on top of a Razor Pages template |
| Pages | .NET 8 Minimal | Demonstrates configuring GraphQL on top of a Razor Pages template |
| Upload | .NET 8 Minimal | Demonstrates uploading files via the `multipart/form-data` content type |

Most of the above samples rely on a sample "Chat" schema.
Below are some basic requests you can use to test the schema:
Expand Down
41 changes: 41 additions & 0 deletions samples/Samples.Upload/Mutation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using GraphQL;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;

namespace Samples.Upload;

public class Mutation
{
public static async Task<string> Rotate(IFormFile file, CancellationToken cancellationToken)
{
if (file == null || file.Length == 0)
{
throw new ExecutionError("File is null or empty.");
}

try
{
// Read the file into an Image
using var sourceStream = file.OpenReadStream();
using var image = await Image.LoadAsync(sourceStream, cancellationToken);

// Rotate the image 90 degrees
image.Mutate(x => x.Rotate(90));

// Convert the image to a byte array
await using var memoryStream = new MemoryStream();
await image.SaveAsJpegAsync(memoryStream, cancellationToken);
byte[] imageBytes = memoryStream.ToArray();

// Convert byte array to a base-64 string
string base64String = Convert.ToBase64String(imageBytes);

return base64String;
}
catch (Exception ex)
{
// Handle exceptions (e.g., file is not an image, or unsupported image format)
throw new ExecutionError("Error processing image: " + ex.Message, ex);
}
}
}
64 changes: 64 additions & 0 deletions samples/Samples.Upload/Pages/Index.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
@page
@model GraphQL.Server.Samples.Upload.Pages.IndexModel
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Image Upload</title>
</head>
<body>
<h1>Rotate JPEG images</h1>
<ol>
<li>Select a JPEG image</li>
<li>Click the "Upload Image" button</li>
<li>Wait for the image to be rotated</li>
</ol>
<p><input type="file" id="imageInput" accept="image/jpeg,image/jpg"></p>
<p><button id="uploadButton">Upload Image</button></p>
<p>
<img id="resultImage" alt="Uploaded Image" style="display:none;" />
</p>

<script>
document.getElementById('uploadButton').addEventListener('click', function () {
const input = document.getElementById('imageInput');
if (!input.files[0]) {
alert("Please select a file first!");
return;
}
const file = input.files[0];
const formData = new FormData();
const operations = {
query: "mutation ($img: FormFile!) { rotate(file: $img) }",
variables: { img: null }
};
const map = {
"file1": ["variables.img"]
}
formData.append('operations', JSON.stringify(operations));
formData.append('map', JSON.stringify(map));
formData.append('file1', file);
fetch('/graphql', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data && data.data && data.data.rotate) {
const img = document.getElementById('resultImage');
img.src = 'data:image/jpeg;base64,' + data.data.rotate;
img.style.display = 'block';
} else {
throw new Error('Invalid response format');
}
})
.catch(error => {
console.error('Error:', error);
alert("An error occurred while uploading the image.");
});
});
</script>
</body>
</html>
11 changes: 11 additions & 0 deletions samples/Samples.Upload/Pages/Index.cshtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace GraphQL.Server.Samples.Upload.Pages
{
public class IndexModel : PageModel
{
public void OnGet()
{
}
}
}
18 changes: 18 additions & 0 deletions samples/Samples.Upload/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using GraphQL;
using Samples.Upload;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddGraphQL(b => b
.AddAutoSchema<Query>(c => c.WithMutation<Mutation>())
.AddFormFileGraphType()
.AddSystemTextJson());

var app = builder.Build();
app.UseDeveloperExceptionPage();
app.UseGraphQL();
app.UseRouting();
app.MapRazorPages();

await app.RunAsync();
27 changes: 27 additions & 0 deletions samples/Samples.Upload/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:51526/",
"sslPort": 44334
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Typical": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:5001;http://localhost:5000"
}
}
}
6 changes: 6 additions & 0 deletions samples/Samples.Upload/Query.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Samples.Upload;

public class Query
{
public static string Hello() => "Hello World!";
}
18 changes: 18 additions & 0 deletions samples/Samples.Upload/Samples.Upload.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.6" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Transports.AspNetCore\Transports.AspNetCore.csproj" />
</ItemGroup>

</Project>
18 changes: 18 additions & 0 deletions src/Transports.AspNetCore/Errors/FileCountExceededError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace GraphQL.Server.Transports.AspNetCore.Errors;

/// <summary>
/// Represents an error when too many files are uploaded in a GraphQL request.
/// </summary>
public class FileCountExceededError : RequestError, IHasPreferredStatusCode
{
/// <summary>
/// Initializes a new instance of the <see cref="FileCountExceededError"/> class.
/// </summary>
public FileCountExceededError()
: base("File uploads exceeded.")
{
}

/// <inheritdoc/>
public HttpStatusCode PreferredStatusCode => HttpStatusCode.RequestEntityTooLarge;
}
18 changes: 18 additions & 0 deletions src/Transports.AspNetCore/Errors/FileSizeExceededError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace GraphQL.Server.Transports.AspNetCore.Errors;

/// <summary>
/// Represents an error when a file exceeds the allowed size limit in a GraphQL upload.
/// </summary>
public class FileSizeExceededError : RequestError, IHasPreferredStatusCode
{
/// <summary>
/// Initializes a new instance of the <see cref="FileSizeExceededError"/> class.
/// </summary>
public FileSizeExceededError()
: base("File size limit exceeded.")
{
}

/// <inheritdoc/>
public HttpStatusCode PreferredStatusCode => HttpStatusCode.RequestEntityTooLarge;
}
12 changes: 12 additions & 0 deletions src/Transports.AspNetCore/Errors/IHasPreferredStatusCode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace GraphQL.Server.Transports.AspNetCore.Errors;

/// <summary>
/// Defines an interface for errors that have a preferred HTTP status code.
/// </summary>
public interface IHasPreferredStatusCode
{
/// <summary>
/// Returns the preferred HTTP status code for this error.
/// </summary>
HttpStatusCode PreferredStatusCode { get; }
}
Loading

0 comments on commit f903c2e

Please sign in to comment.