Skip to content

Commit f903c2e

Browse files
authored
Support multipart file uploads (#1115)
1 parent 2462fdd commit f903c2e

23 files changed

+1084
-19
lines changed

GraphQL.Server.sln

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
4545
ProjectSection(SolutionItems) = preProject
4646
.github\workflows\build.yml = .github\workflows\build.yml
4747
.github\workflows\codeql-analysis.yml = .github\workflows\codeql-analysis.yml
48-
.github\workflows\label.yml = .github\workflows\label.yml
4948
.github\workflows\format.yml = .github\workflows\format.yml
49+
.github\workflows\label.yml = .github\workflows\label.yml
5050
.github\workflows\publish.yml = .github\workflows\publish.yml
5151
.github\workflows\test.yml = .github\workflows\test.yml
5252
.github\workflows\wipcheck.yml = .github\workflows\wipcheck.yml
@@ -120,6 +120,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Samples.AzureFunctions", "s
120120
EndProject
121121
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Samples.AzureFunctions.Tests", "tests\Samples.AzureFunctions.Tests\Samples.AzureFunctions.Tests.csproj", "{A204E359-05E8-4CEE-891C-4CCA6570FA52}"
122122
EndProject
123+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples.Upload", "samples\Samples.Upload\Samples.Upload.csproj", "{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8}"
124+
EndProject
125+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Samples.Upload.Tests", "tests\Samples.Upload.Tests\Samples.Upload.Tests.csproj", "{DE3059F4-B548-4091-BFC0-5879246A2DF9}"
126+
EndProject
123127
Global
124128
GlobalSection(SolutionConfigurationPlatforms) = preSolution
125129
Debug|Any CPU = Debug|Any CPU
@@ -262,6 +266,14 @@ Global
262266
{A204E359-05E8-4CEE-891C-4CCA6570FA52}.Debug|Any CPU.Build.0 = Debug|Any CPU
263267
{A204E359-05E8-4CEE-891C-4CCA6570FA52}.Release|Any CPU.ActiveCfg = Release|Any CPU
264268
{A204E359-05E8-4CEE-891C-4CCA6570FA52}.Release|Any CPU.Build.0 = Release|Any CPU
269+
{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
270+
{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
271+
{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
272+
{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8}.Release|Any CPU.Build.0 = Release|Any CPU
273+
{DE3059F4-B548-4091-BFC0-5879246A2DF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
274+
{DE3059F4-B548-4091-BFC0-5879246A2DF9}.Debug|Any CPU.Build.0 = Debug|Any CPU
275+
{DE3059F4-B548-4091-BFC0-5879246A2DF9}.Release|Any CPU.ActiveCfg = Release|Any CPU
276+
{DE3059F4-B548-4091-BFC0-5879246A2DF9}.Release|Any CPU.Build.0 = Release|Any CPU
265277
EndGlobalSection
266278
GlobalSection(SolutionProperties) = preSolution
267279
HideSolutionNode = FALSE
@@ -301,6 +313,8 @@ Global
301313
{7F5D8EE4-CD03-482E-A478-E3334F1D0439} = {382C5C04-A34D-4C81-83D7-584C85FB9356}
302314
{FD93A9D8-4663-4FF0-8082-DE9E006956FD} = {5C07AFA3-12F2-40EA-807D-7A1EEF29012B}
303315
{A204E359-05E8-4CEE-891C-4CCA6570FA52} = {BBD07745-C962-4D2D-B302-6DA1BCC2FF43}
316+
{33E2CDF5-F854-4F1A-80D5-DBF0BDF8EEA8} = {5C07AFA3-12F2-40EA-807D-7A1EEF29012B}
317+
{DE3059F4-B548-4091-BFC0-5879246A2DF9} = {BBD07745-C962-4D2D-B302-6DA1BCC2FF43}
304318
EndGlobalSection
305319
GlobalSection(ExtensibilityGlobals) = postSolution
306320
SolutionGuid = {3FC7FA59-E938-453C-8C4A-9D5635A9489A}

README.md

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ This package is designed for ASP.NET Core (2.1 through 6.0) to facilitate easy s
4545
over HTTP. The code is designed to be used as middleware within the ASP.NET Core pipeline,
4646
serving GET, POST or WebSocket requests. GET requests process requests from the query string.
4747
POST requests can be in the form of JSON requests, form submissions, or raw GraphQL strings.
48+
Form submissions either accepts `query`, `operationName`, `variables` and `extensions` parameters,
49+
or `operations` and `map` parameters along with file uploads as defined in the
50+
[GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec).
4851
WebSocket requests can use the `graphql-ws` or `graphql-transport-ws` WebSocket sub-protocol,
4952
as defined in the [apollographql/subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws)
5053
and [enisdenjo/graphql-ws](https://github.com/enisdenjo/graphql-ws) repositories, respectively.
@@ -660,6 +663,8 @@ methods allowing for different options for each configured endpoint.
660663
| `HandleGet` | Enables handling of GET requests. | True |
661664
| `HandlePost` | Enables handling of POST requests. | True |
662665
| `HandleWebSockets` | Enables handling of WebSockets requests. | True |
666+
| `MaximumFileSize` | Sets the maximum file size allowed for GraphQL multipart requests. | unlimited |
667+
| `MaximumFileCount` | Sets the maximum number of files allowed for GraphQL multipart requests. | unlimited |
663668
| `ReadExtensionsFromQueryString` | Enables reading extensions from the query string. | True |
664669
| `ReadFormOnPost` | Enables parsing of form data for POST requests (may have security implications). | True |
665670
| `ReadQueryStringOnPost` | Enables parsing the query string on POST requests. | True |
@@ -918,6 +923,24 @@ security risk. However, GraphQL query operations usually do not alter data, and
918923
Additionally, the response is not expected to be readable in the browser (unless CORS checks are successful),
919924
which helps alleviate this concern.
920925

926+
GraphQL.NET Server supports two formats of `application/x-www-form-urlencoded` or `multipart/form-data` requests:
927+
928+
1. The following keys are read from the form data and used to populate the GraphQL request:
929+
- `query`: The GraphQL query string.
930+
- `operationName`: The name of the operation to execute.
931+
- `variables`: A JSON-encoded object containing the variables for the operation.
932+
- `extensions`: A JSON-encoded object containing the extensions for the operation.
933+
934+
2. The following keys are read from the form data and used to populate the GraphQL request:
935+
- `operations`: A JSON-encoded object containing the GraphQL request, in the same format as typical
936+
requests sent via `application/json`. This can be a single object or an array of objects if batching
937+
is enabled.
938+
- `map`: An optional JSON-encoded map of file keys to file objects. This is used to map attached files
939+
into the GraphQL request's variables property. See the section below titled 'File uploading/downloading' and the
940+
[GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec)
941+
for additional details. Since `application/x-www-form-urlencoded` cannot transmit files, this feature
942+
is only available for `multipart/form-data` requests.
943+
921944
### Excessive `OperationCanceledException`s
922945

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

959-
An option for uploading is to upload file data alongside a mutation with the `multipart/form-data`
960-
content type. Please see [Issue 307](https://github.com/graphql-dotnet/server/issues/307) and
961-
[FileUploadTests.cs](https://github.com/graphql-dotnet/server/blob/master/tests/Transports.AspNetCore.Tests/Middleware/FileUploadTests.cs)
962-
for discussion and demonstration of this capability.
982+
An option for uploading is to upload file data alongside a mutation with the
983+
`multipart/form-data` content type as described by the
984+
[GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec).
985+
Uploaded files are mapped into the GraphQL request's variables as `IFormFile` objects.
986+
You can use the provided `FormFileGraphType` scalar graph type in your GraphQL schema
987+
to access these files. The `AddFormFileGraphType()` builder extension method adds this scalar
988+
to the DI container and configures a CLR type mapping for it to be used for `IFormFile` objects.
989+
990+
```csharp
991+
services.AddGraphQL(b => b
992+
.AddAutoSchema<Query>()
993+
.AddFormFileGraphType()
994+
.AddSystemTextJson());
995+
```
996+
997+
Please see the 'Upload' sample for a demonstration of this technique. Note that
998+
using the `FormFileGraphType` scalar requires that the uploaded files be sent only
999+
via the `multipart/form-data` content type as attached files. If you wish to also
1000+
allow clients to send files as base-64 encoded strings, you can write a custom scalar
1001+
better suited to your needs.
9631002

9641003
## Samples
9651004

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

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

9821022
Most of the above samples rely on a sample "Chat" schema.
9831023
Below are some basic requests you can use to test the schema:

samples/Samples.Upload/Mutation.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using GraphQL;
2+
using SixLabors.ImageSharp;
3+
using SixLabors.ImageSharp.Processing;
4+
5+
namespace Samples.Upload;
6+
7+
public class Mutation
8+
{
9+
public static async Task<string> Rotate(IFormFile file, CancellationToken cancellationToken)
10+
{
11+
if (file == null || file.Length == 0)
12+
{
13+
throw new ExecutionError("File is null or empty.");
14+
}
15+
16+
try
17+
{
18+
// Read the file into an Image
19+
using var sourceStream = file.OpenReadStream();
20+
using var image = await Image.LoadAsync(sourceStream, cancellationToken);
21+
22+
// Rotate the image 90 degrees
23+
image.Mutate(x => x.Rotate(90));
24+
25+
// Convert the image to a byte array
26+
await using var memoryStream = new MemoryStream();
27+
await image.SaveAsJpegAsync(memoryStream, cancellationToken);
28+
byte[] imageBytes = memoryStream.ToArray();
29+
30+
// Convert byte array to a base-64 string
31+
string base64String = Convert.ToBase64String(imageBytes);
32+
33+
return base64String;
34+
}
35+
catch (Exception ex)
36+
{
37+
// Handle exceptions (e.g., file is not an image, or unsupported image format)
38+
throw new ExecutionError("Error processing image: " + ex.Message, ex);
39+
}
40+
}
41+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
@page
2+
@model GraphQL.Server.Samples.Upload.Pages.IndexModel
3+
<!DOCTYPE html>
4+
<html lang="en">
5+
<head>
6+
<meta charset="UTF-8">
7+
<title>Image Upload</title>
8+
</head>
9+
<body>
10+
<h1>Rotate JPEG images</h1>
11+
<ol>
12+
<li>Select a JPEG image</li>
13+
<li>Click the "Upload Image" button</li>
14+
<li>Wait for the image to be rotated</li>
15+
</ol>
16+
<p><input type="file" id="imageInput" accept="image/jpeg,image/jpg"></p>
17+
<p><button id="uploadButton">Upload Image</button></p>
18+
<p>
19+
<img id="resultImage" alt="Uploaded Image" style="display:none;" />
20+
</p>
21+
22+
<script>
23+
document.getElementById('uploadButton').addEventListener('click', function () {
24+
const input = document.getElementById('imageInput');
25+
if (!input.files[0]) {
26+
alert("Please select a file first!");
27+
return;
28+
}
29+
30+
const file = input.files[0];
31+
const formData = new FormData();
32+
const operations = {
33+
query: "mutation ($img: FormFile!) { rotate(file: $img) }",
34+
variables: { img: null }
35+
};
36+
const map = {
37+
"file1": ["variables.img"]
38+
}
39+
formData.append('operations', JSON.stringify(operations));
40+
formData.append('map', JSON.stringify(map));
41+
formData.append('file1', file);
42+
43+
fetch('/graphql', {
44+
method: 'POST',
45+
body: formData
46+
})
47+
.then(response => response.json())
48+
.then(data => {
49+
if (data && data.data && data.data.rotate) {
50+
const img = document.getElementById('resultImage');
51+
img.src = 'data:image/jpeg;base64,' + data.data.rotate;
52+
img.style.display = 'block';
53+
} else {
54+
throw new Error('Invalid response format');
55+
}
56+
})
57+
.catch(error => {
58+
console.error('Error:', error);
59+
alert("An error occurred while uploading the image.");
60+
});
61+
});
62+
</script>
63+
</body>
64+
</html>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Microsoft.AspNetCore.Mvc.RazorPages;
2+
3+
namespace GraphQL.Server.Samples.Upload.Pages
4+
{
5+
public class IndexModel : PageModel
6+
{
7+
public void OnGet()
8+
{
9+
}
10+
}
11+
}

samples/Samples.Upload/Program.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using GraphQL;
2+
using Samples.Upload;
3+
4+
var builder = WebApplication.CreateBuilder(args);
5+
6+
builder.Services.AddRazorPages();
7+
builder.Services.AddGraphQL(b => b
8+
.AddAutoSchema<Query>(c => c.WithMutation<Mutation>())
9+
.AddFormFileGraphType()
10+
.AddSystemTextJson());
11+
12+
var app = builder.Build();
13+
app.UseDeveloperExceptionPage();
14+
app.UseGraphQL();
15+
app.UseRouting();
16+
app.MapRazorPages();
17+
18+
await app.RunAsync();
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"iisSettings": {
3+
"windowsAuthentication": false,
4+
"anonymousAuthentication": true,
5+
"iisExpress": {
6+
"applicationUrl": "http://localhost:51526/",
7+
"sslPort": 44334
8+
}
9+
},
10+
"profiles": {
11+
"IIS Express": {
12+
"commandName": "IISExpress",
13+
"launchBrowser": true,
14+
"environmentVariables": {
15+
"ASPNETCORE_ENVIRONMENT": "Development"
16+
}
17+
},
18+
"Typical": {
19+
"commandName": "Project",
20+
"launchBrowser": true,
21+
"environmentVariables": {
22+
"ASPNETCORE_ENVIRONMENT": "Development"
23+
},
24+
"applicationUrl": "https://localhost:5001;http://localhost:5000"
25+
}
26+
}
27+
}

samples/Samples.Upload/Query.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Samples.Upload;
2+
3+
public class Query
4+
{
5+
public static string Hello() => "Hello World!";
6+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.6" />
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<ProjectReference Include="..\..\src\Transports.AspNetCore\Transports.AspNetCore.csproj" />
16+
</ItemGroup>
17+
18+
</Project>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace GraphQL.Server.Transports.AspNetCore.Errors;
2+
3+
/// <summary>
4+
/// Represents an error when too many files are uploaded in a GraphQL request.
5+
/// </summary>
6+
public class FileCountExceededError : RequestError, IHasPreferredStatusCode
7+
{
8+
/// <summary>
9+
/// Initializes a new instance of the <see cref="FileCountExceededError"/> class.
10+
/// </summary>
11+
public FileCountExceededError()
12+
: base("File uploads exceeded.")
13+
{
14+
}
15+
16+
/// <inheritdoc/>
17+
public HttpStatusCode PreferredStatusCode => HttpStatusCode.RequestEntityTooLarge;
18+
}

0 commit comments

Comments
 (0)