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 multipart file uploads #62

Merged
merged 3 commits into from
Aug 19, 2024
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
8 changes: 4 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use .NET Core 6.0 SDK
uses: actions/setup-dotnet@v2
- uses: actions/checkout@v4
- name: Use .NET Core 8.0 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '6.0.x'
dotnet-version: '8.0.x'
source-url: https://nuget.pkg.github.com/Shane32/index.json
env:
NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Use .NET Core SDK
uses: actions/setup-dotnet@v2
uses: actions/setup-dotnet@v4
with:
dotnet-version: 6.0.100
dotnet-version: 8.0
source-url: https://nuget.pkg.github.com/Shane32/index.json
env:
NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Check github.ref starts with 'refs/tags/'
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
run: |
Expand All @@ -23,10 +23,10 @@ jobs:
version="${github_ref:10}"
echo version=$version
echo "version=$version" >> $GITHUB_ENV
- name: Use .NET Core 6.0 SDK
uses: actions/setup-dotnet@v1
- name: Use .NET Core 8.0 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '6.0.x'
dotnet-version: '8.0.x'
source-url: https://api.nuget.org/v3/index.json
env:
NUGET_AUTH_TOKEN: ${{secrets.NUGET_AUTH_TOKEN}}
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ jobs:
- windows-latest
steps:
- name: Checkout source
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Use .NET Core SDK
uses: actions/setup-dotnet@v1
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
2.1.x
3.1.x
5.0.x
6.0.x
8.0.x
source-url: https://nuget.pkg.github.com/Shane32/index.json
env:
NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<VersionPrefix>5.0.0-preview</VersionPrefix>
<LangVersion>10.0</LangVersion>
<LangVersion>12.0</LangVersion>
<Copyright>Shane Krueger</Copyright>
<Authors>Shane Krueger</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 querystring.
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) respoitories, respectively.
Expand Down Expand Up @@ -543,6 +546,8 @@ 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 |
| `ReadQueryStringOnPost` | Enables parsing the query string on POST requests. | True |
| `ReadVariablesFromQueryString` | Enables reading variables from the query string. | True |
Expand Down Expand Up @@ -753,6 +758,86 @@ are rejected over HTTP GET connections. Derive from `GraphQLHttpMiddleware` and

As would be expected, subscription requests are only allowed over WebSocket channels.

### Handling form data for POST requests

The GraphQL over HTTP specification does not outline a procedure for transmitting GraphQL requests via
HTTP POST connections using a `Content-Type` of `application/x-www-form-urlencoded` or `multipart/form-data`.
Allowing the processing of such requests could be advantageous in avoiding CORS preflight requests when
sending GraphQL queries from a web browser. Nevertheless, enabling this feature may give rise to security
risks when utilizing cookie authentication, since transmitting cookies with these requests does not trigger
a pre-flight CORS check. As a consequence, GraphQL.NET might execute a request and potentially modify data
even when the CORS policy prohibits it, regardless of whether the sender has access to the response.
This situation exposes the system to security vulnerabilities, which should be carefully evaluated and
mitigated to ensure the safe handling of GraphQL requests and maintain the integrity of the data.

This functionality is activated by default to maintain backward compatibility, but it can be turned off by
setting the `ReadFormOnPost` value to `false`. The next major version of GraphQL.NET Server will have this
feature disabled by default, enhancing security measures.

Keep in mind that CORS pre-flight requests are also not executed for GET requests, potentially presenting a
security risk. However, GraphQL query operations usually do not alter data, and mutations are refused.
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.

### File uploading/downloading

A common question is how to upload or download files attached to GraphQL data.
For instance, storing and retrieving photographs attached to product data.
One common technique is to encode the data as Base64 and transmitting as a custom
GraphQL scalar (encoded as a string value within the JSON transport).
This may not be ideal, but works well for smaller files. It can also couple with
response compression (details listed above) to reduce the impact of the Base64
encoding.
Another technique is to get or store the data out-of-band. For responses, this can
be as simple as a Uri pointing to a location to retrieve the data, especially if
the data are photographs used in a SPA client application. This may have additional
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

The following samples are provided to show how to integrate this project with various
Expand Down
18 changes: 18 additions & 0 deletions src/GraphQL.AspNetCore3/Errors/FileCountExceededError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace GraphQL.AspNetCore3.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/GraphQL.AspNetCore3/Errors/FileSizeExceededError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace GraphQL.AspNetCore3.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/GraphQL.AspNetCore3/Errors/IHasPreferredStatusCode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace GraphQL.AspNetCore3.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; }
}
15 changes: 15 additions & 0 deletions src/GraphQL.AspNetCore3/Errors/InvalidMapError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace GraphQL.AspNetCore3.Errors;

/// <summary>
/// Represents an error when an invalid map path is provided in a GraphQL file upload request.
/// </summary>
public class InvalidMapError : RequestError
{
/// <summary>
/// Initializes a new instance of the <see cref="InvalidMapError"/> class.
/// </summary>
public InvalidMapError(string message, Exception? innerException = null)
: base("Invalid map path. " + message, innerException)
{
}
}
36 changes: 36 additions & 0 deletions src/GraphQL.AspNetCore3/FormFileGraphType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace GraphQL.AspNetCore3;

/// <summary>
/// Represents a GraphQL scalar type named 'FormFile' for handling file uploads
/// sent via multipart/form-data GraphQL requests.
/// </summary>
public class FormFileGraphType : ScalarGraphType
{
/// <inheritdoc/>
public override bool CanParseLiteral(GraphQLValue value) => value is GraphQLNullValue;

/// <inheritdoc/>
public override object? ParseLiteral(GraphQLValue value)
=> value is GraphQLNullValue ? null : ThrowLiteralConversionError(value, "Uploaded files must be passed through variables.");

/// <inheritdoc/>
public override bool CanParseValue(object? value) => value is IFormFile || value == null;

/// <inheritdoc/>
public override object? ParseValue(object? value) => value switch {
IFormFile _ => value,
null => null,
_ => ThrowValueConversionError(value)
};

/// <inheritdoc/>
public override object? Serialize(object? value) => value is null ? null :
throw new InvalidOperationException("The FormFile scalar graph type cannot be used to return information from a GraphQL endpoint.");

/// <inheritdoc/>
public override bool IsValidDefault(object value) => false;

/// <inheritdoc/>
public override GraphQLValue ToAST(object? value) => value is null ? new GraphQLNullValue() :
throw new InvalidOperationException("FormFile values cannot be converted to an AST node.");
}
11 changes: 11 additions & 0 deletions src/GraphQL.AspNetCore3/GraphQLBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,15 @@ public static IGraphQLBuilder AddUserContextBuilder<TUserContext>(this IGraphQLB

return builder;
}

/// <summary>
/// Registers <see cref="FormFileGraphType"/> within the dependency injection framework
/// and configures the schema to use it for mapping <see cref="IFormFile"/> instances.
/// </summary>
public static IGraphQLBuilder AddFormFileGraphType(this IGraphQLBuilder builder)
{
builder.Services.Register<FormFileGraphType>(DI.ServiceLifetime.Singleton);
builder.ConfigureSchema(s => s.RegisterTypeMapping<IFormFile, FormFileGraphType>());
return builder;
}
}
Loading
Loading