diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 031c69e..7d1ec78 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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}} diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 8ed4835..6a41db0 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -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}} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b0beb71..7b2d646 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: | @@ -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}} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3cedd48..29510c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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}} diff --git a/Directory.Build.props b/Directory.Build.props index 71f0f91..0e4100e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ 5.0.0-preview - 10.0 + 12.0 Shane Krueger Shane Krueger MIT diff --git a/README.md b/README.md index 3e2ce84..d698aa7 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 | @@ -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() + .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 diff --git a/src/GraphQL.AspNetCore3/Errors/FileCountExceededError.cs b/src/GraphQL.AspNetCore3/Errors/FileCountExceededError.cs new file mode 100644 index 0000000..d096b98 --- /dev/null +++ b/src/GraphQL.AspNetCore3/Errors/FileCountExceededError.cs @@ -0,0 +1,18 @@ +namespace GraphQL.AspNetCore3.Errors; + +/// +/// Represents an error when too many files are uploaded in a GraphQL request. +/// +public class FileCountExceededError : RequestError, IHasPreferredStatusCode +{ + /// + /// Initializes a new instance of the class. + /// + public FileCountExceededError() + : base("File uploads exceeded.") + { + } + + /// + public HttpStatusCode PreferredStatusCode => HttpStatusCode.RequestEntityTooLarge; +} diff --git a/src/GraphQL.AspNetCore3/Errors/FileSizeExceededError.cs b/src/GraphQL.AspNetCore3/Errors/FileSizeExceededError.cs new file mode 100644 index 0000000..4f85375 --- /dev/null +++ b/src/GraphQL.AspNetCore3/Errors/FileSizeExceededError.cs @@ -0,0 +1,18 @@ +namespace GraphQL.AspNetCore3.Errors; + +/// +/// Represents an error when a file exceeds the allowed size limit in a GraphQL upload. +/// +public class FileSizeExceededError : RequestError, IHasPreferredStatusCode +{ + /// + /// Initializes a new instance of the class. + /// + public FileSizeExceededError() + : base("File size limit exceeded.") + { + } + + /// + public HttpStatusCode PreferredStatusCode => HttpStatusCode.RequestEntityTooLarge; +} diff --git a/src/GraphQL.AspNetCore3/Errors/IHasPreferredStatusCode.cs b/src/GraphQL.AspNetCore3/Errors/IHasPreferredStatusCode.cs new file mode 100644 index 0000000..1d8f678 --- /dev/null +++ b/src/GraphQL.AspNetCore3/Errors/IHasPreferredStatusCode.cs @@ -0,0 +1,12 @@ +namespace GraphQL.AspNetCore3.Errors; + +/// +/// Defines an interface for errors that have a preferred HTTP status code. +/// +public interface IHasPreferredStatusCode +{ + /// + /// Returns the preferred HTTP status code for this error. + /// + HttpStatusCode PreferredStatusCode { get; } +} diff --git a/src/GraphQL.AspNetCore3/Errors/InvalidMapError.cs b/src/GraphQL.AspNetCore3/Errors/InvalidMapError.cs new file mode 100644 index 0000000..38376db --- /dev/null +++ b/src/GraphQL.AspNetCore3/Errors/InvalidMapError.cs @@ -0,0 +1,15 @@ +namespace GraphQL.AspNetCore3.Errors; + +/// +/// Represents an error when an invalid map path is provided in a GraphQL file upload request. +/// +public class InvalidMapError : RequestError +{ + /// + /// Initializes a new instance of the class. + /// + public InvalidMapError(string message, Exception? innerException = null) + : base("Invalid map path. " + message, innerException) + { + } +} diff --git a/src/GraphQL.AspNetCore3/FormFileGraphType.cs b/src/GraphQL.AspNetCore3/FormFileGraphType.cs new file mode 100644 index 0000000..d6dffa1 --- /dev/null +++ b/src/GraphQL.AspNetCore3/FormFileGraphType.cs @@ -0,0 +1,36 @@ +namespace GraphQL.AspNetCore3; + +/// +/// Represents a GraphQL scalar type named 'FormFile' for handling file uploads +/// sent via multipart/form-data GraphQL requests. +/// +public class FormFileGraphType : ScalarGraphType +{ + /// + public override bool CanParseLiteral(GraphQLValue value) => value is GraphQLNullValue; + + /// + public override object? ParseLiteral(GraphQLValue value) + => value is GraphQLNullValue ? null : ThrowLiteralConversionError(value, "Uploaded files must be passed through variables."); + + /// + public override bool CanParseValue(object? value) => value is IFormFile || value == null; + + /// + public override object? ParseValue(object? value) => value switch { + IFormFile _ => value, + null => null, + _ => ThrowValueConversionError(value) + }; + + /// + 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."); + + /// + public override bool IsValidDefault(object value) => false; + + /// + public override GraphQLValue ToAST(object? value) => value is null ? new GraphQLNullValue() : + throw new InvalidOperationException("FormFile values cannot be converted to an AST node."); +} diff --git a/src/GraphQL.AspNetCore3/GraphQLBuilderExtensions.cs b/src/GraphQL.AspNetCore3/GraphQLBuilderExtensions.cs index 05c37e0..ede6934 100644 --- a/src/GraphQL.AspNetCore3/GraphQLBuilderExtensions.cs +++ b/src/GraphQL.AspNetCore3/GraphQLBuilderExtensions.cs @@ -156,4 +156,15 @@ public static IGraphQLBuilder AddUserContextBuilder(this IGraphQLB return builder; } + + /// + /// Registers within the dependency injection framework + /// and configures the schema to use it for mapping instances. + /// + public static IGraphQLBuilder AddFormFileGraphType(this IGraphQLBuilder builder) + { + builder.Services.Register(DI.ServiceLifetime.Singleton); + builder.ConfigureSchema(s => s.RegisterTypeMapping()); + return builder; + } } diff --git a/src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs b/src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs index 792b6b9..90d1642 100644 --- a/src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs +++ b/src/GraphQL.AspNetCore3/GraphQLHttpMiddleware.cs @@ -1,5 +1,7 @@ #pragma warning disable CA1716 // Identifiers should not match keywords +using System.Diagnostics; +using System.Globalization; using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; @@ -64,6 +66,8 @@ public class GraphQLHttpMiddleware : IUserContextBuilder private const string VARIABLES_KEY = "variables"; private const string EXTENSIONS_KEY = "extensions"; private const string OPERATION_NAME_KEY = "operationName"; + private const string OPERATIONS_KEY = "operations"; // used for multipart/form-data requests per https://github.com/jaydenseric/graphql-multipart-request-spec + private const string MAP_KEY = "map"; // used for multipart/form-data requests per https://github.com/jaydenseric/graphql-multipart-request-spec private const string MEDIATYPE_GRAPHQLJSON = "application/graphql+json"; private const string MEDIATYPE_JSON = "application/json"; private const string MEDIATYPE_GRAPHQL = "application/graphql"; @@ -222,8 +226,11 @@ public virtual async Task InvokeAsync(HttpContext context) if (httpRequest.HasFormContentType) { try { var formCollection = await httpRequest.ReadFormAsync(context.RequestAborted); - return (DeserializeFromFormBody(formCollection), null); - } catch (Exception ex) { + return ReadFormContent(formCollection); + } catch (ExecutionError ex) { // catches FileCountExceededError, FileSizeExceededError, InvalidMapError + await WriteErrorResponseAsync(context, ex is IHasPreferredStatusCode sc ? sc.PreferredStatusCode : HttpStatusCode.BadRequest, ex); + return null; + } catch (Exception ex) { // catches JSON deserialization exceptions if (!await HandleDeserializationErrorAsync(context, _next, ex)) throw; return null; @@ -234,6 +241,188 @@ public virtual async Task InvokeAsync(HttpContext context) } } + /// + /// This method looks for an 'operations' key with a JSON value representing the GraphQL request(s) + /// and a 'map' key with a JSON object value mapping file keys to variables in the request(s). + /// See: . + /// + /// If no 'operations' key exists, then falls back to looking for 'query', 'operationName', 'variables' and 'extensions' keys. + /// + /// + /// + /// Note that 'operations' and 'map' keys are searched for even with application/x-www-form-urlencoded requests, but + /// this should not be a problem. Also, JSON deserialization may throw an exception by the JSON serialization engine in use. + /// + /// + /// + /// + private (GraphQLRequest? SingleRequest, IList? BatchRequest)? ReadFormContent(IFormCollection formCollection) + { + var operationsString = formCollection.TryGetValue(OPERATIONS_KEY, out var operationsValue) ? operationsValue[0] : null; + var deserializationResult = _serializer.Deserialize>(operationsString) + ?? new GraphQLRequest[] { DeserializeFromFormBody(formCollection) }; + + var mapString = formCollection.TryGetValue(MAP_KEY, out var mapValue) ? mapValue[0] : null; + var map = _serializer.Deserialize>(mapString); + if (map != null) + ApplyMapToRequests(map, formCollection, deserializationResult); + + // GraphQL serializers will deserialize a single request object as an array of a single request, + // and an array of requests as a List of requests, so we can identify which way it was encoded, + // which is important for the response format. + if (deserializationResult is GraphQLRequest[] array && array.Length == 1) + return (deserializationResult[0], null); + else + return (null, deserializationResult); + + // Applies uploaded files onto request variables based on a provided map. + // Validates file count and size. + // Expected map format: { "abc": ["variables.file"] } where abc is the form field name of the uploaded file. + // Also supports batch requests: { "abc": ["0.variables.file"] } + // Also supports mapping one file to multiple variables: { "abc": ["variables.file1", "variables.file2"] } + void ApplyMapToRequests(Dictionary map, IFormCollection form, IList requests) + { + // validate file count + if (_options.MaximumFileCount.HasValue && form.Files.Count > _options.MaximumFileCount.Value) + throw new FileCountExceededError(); + + // validate each file's size + foreach (var file in form.Files) { + if (_options.MaximumFileSize.HasValue && _options.MaximumFileSize.Value < file.Length) + throw new FileSizeExceededError(); + } + + foreach (var entry in map) { + // validate entry key + if (entry.Key == "" || entry.Key == "query" || entry.Key == "operationName" || entry.Key == "variables" || entry.Key == "extensions" || entry.Key == "operations" || entry.Key == "map") + throw new InvalidMapError("Map key cannot be query, operationName, variables, extensions, operations or map."); + // locate file + var file = form.Files[entry.Key] + ?? throw new InvalidMapError("Map key does not refer to an uploaded file."); + // apply file to each target + foreach (var target in entry.Value) { + if (target == null) + throw new InvalidMapError("Map target cannot be null."); + ApplyFileToRequests(file, target, requests); + } + } + } + + // Applies an uploaded file to a specific target property within a list of requests. + // Expects a target string in the format of "variables.foo.bar" or "0.variables.foo.bar". + static void ApplyFileToRequests(IFormFile file, string target, IList requests) + { + if (target.StartsWith("variables.", StringComparison.Ordinal)) { + if (requests.Count < 1) + throw new InvalidMapError("No request specified."); + ApplyFileToRequest(file, target.Substring(10), requests[0]); + return; + } + var i = target.IndexOf('.'); + +#if NETCOREAPP3_1_OR_GREATER + if (i == -1 || target.Length < 10 + i + 1 || !target.AsSpan(i + 1, 10).Equals("variables.", StringComparison.Ordinal)) +#else + if (i == -1 || target.Length < 10 + i + 1 || !string.Equals(target.Substring(i + 1, 10), "variables.", StringComparison.Ordinal)) +#endif + throw new InvalidMapError("Map path must start with 'variables.' or the index of the request followed by '.variables.'."); + +#if NETCOREAPP3_1_OR_GREATER + if (!int.TryParse(target.AsSpan(0, i), NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) +#else + if (!int.TryParse(target.Substring(0, i), NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) +#endif + throw new InvalidMapError("Could not parse the request index."); + + if (requests.Count < (index + 1)) + throw new InvalidMapError("Invalid request index."); + + ApplyFileToRequest(file, target.Substring(10 + i + 1), requests[index]); + } + + // Applies an uploaded file to a specific target property within a GraphQLRequest. + // Expects a target string in the format of "foo.bar". + static void ApplyFileToRequest(IFormFile file, string target, GraphQLRequest? request) + { + // Ensure request's Variables are not null, else throw an error. + var variables = request?.Variables ?? throw new InvalidMapError("No variables defined for this request."); + + // Define the parent object and pointer to index or child key within + object parent = variables; + string? prop = null; + // Iterate over each segment of the target path + foreach (var location in target.Split('.')) { + if (location == "") + throw new InvalidMapError("Empty property name."); + // If this is the first segment, it is the property name. + if (prop == null) { + prop = location; + continue; + } + + // First, resolve the prior segment to an object + + // Handle lists + if (parent is System.Collections.IList list) { + // Parse the index, ensure it is within bounds, and get the child object. + if (!int.TryParse(prop, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) + throw new InvalidMapError($"Child index '{prop}' is not an integer."); + if (list.Count < (index + 1) || index < 0) + throw new InvalidMapError($"Index '{index}' is out of bounds."); + parent = list[index] ?? throw new InvalidMapError($"Child index '{index}' refers to a null object."); + } + // Handle objects + else if (parent is IReadOnlyDictionary dic) { + // Ensure the child property exists and get the child object. + if (!dic.TryGetValue(prop, out var value)) + throw new InvalidMapError($"Child property '{prop}' does not exist."); + parent = value ?? + throw new InvalidMapError($"Child property '{prop}' refers to a null object."); + } else { + throw new InvalidMapError($"Cannot refer to child property '{prop}' of a string or number."); + } + + // Then, set the child property key or index + prop = location; + } + + // Verify that the target is valid (should not be possible) + Debug.Assert(prop != null); + Debug.Assert(prop!.Length > 0); + + // Resolve the segment, and set it to the form file + + // Handle lists + if (parent is System.Collections.IList list2) { + // Parse the index, ensure it is within bounds, and set the child object. + if (!int.TryParse(prop, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index)) + throw new InvalidMapError($"Child index '{prop}' is not an integer."); + if (list2.Count < (index + 1) || index < 0) + throw new InvalidMapError($"Index '{index}' is out of bounds."); + if (list2[index] != null) + throw new InvalidMapError($"Index '{index}' must refer to a null variable."); + list2[index] = file; + } + // Handle objects + else if (parent is IDictionary dic) { + // Ensure the child property exists and set the child object. + if (!dic.TryGetValue(prop, out var value)) + throw new InvalidMapError($"Child property '{prop}' does not exist."); + else if (value != null) + throw new InvalidMapError($"Child property '{prop}' must refer to a null object."); + if (parent == variables) { + // unfortunate design due to Inputs being readonly + request.Variables = new Inputs(new Dictionary(variables) { + [prop] = file + }); + } else + dic[prop] = file; + } else { + throw new InvalidMapError($"Cannot refer to child property '{prop}' of a string or number."); + } + } + } + /// /// Perform authentication, if required, and return if the /// request was handled (typically by returning an error message). If diff --git a/src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs b/src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs index 4b91ef8..dc461c0 100644 --- a/src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs +++ b/src/GraphQL.AspNetCore3/GraphQLHttpMiddlewareOptions.cs @@ -88,6 +88,20 @@ public class GraphQLHttpMiddlewareOptions : IAuthorizationOptions /// public string? AuthorizedPolicy { get; set; } + /// + /// The maximum allowed file size in bytes for each file uploaded pursuant to the + /// specification at . + /// Null indicates no limit. + /// + public long? MaximumFileSize { get; set; } + + /// + /// The maximum allowed number of files uploaded pursuant to the specification at + /// . + /// Null indicates no limit. + /// + public int? MaximumFileCount { get; set; } + /// /// Returns an options class for WebSocket connections. /// diff --git a/src/GraphQL.AspNetCore3/WebSockets/GraphQLWs/SubscriptionServer.cs b/src/GraphQL.AspNetCore3/WebSockets/GraphQLWs/SubscriptionServer.cs index 8e75172..d858588 100644 --- a/src/GraphQL.AspNetCore3/WebSockets/GraphQLWs/SubscriptionServer.cs +++ b/src/GraphQL.AspNetCore3/WebSockets/GraphQLWs/SubscriptionServer.cs @@ -182,10 +182,9 @@ await Connection.SendMessageAsync(new OperationMessage { /// protected override async Task ExecuteRequestAsync(OperationMessage message) { - var request = Serializer.ReadNode(message.Payload); #pragma warning disable CA2208 // Instantiate argument exceptions correctly - if (request == null) - throw new ArgumentNullException(nameof(message) + "." + nameof(OperationMessage.Payload)); + var request = Serializer.ReadNode(message.Payload) + ?? throw new ArgumentNullException(nameof(message) + "." + nameof(OperationMessage.Payload)); #pragma warning restore CA2208 // Instantiate argument exceptions correctly using var scope = ServiceScopeFactory.CreateScope(); try { diff --git a/src/GraphQL.AspNetCore3/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs b/src/GraphQL.AspNetCore3/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs index 4d41a11..2e72846 100644 --- a/src/GraphQL.AspNetCore3/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs +++ b/src/GraphQL.AspNetCore3/WebSockets/SubscriptionsTransportWs/SubscriptionServer.cs @@ -162,10 +162,9 @@ await Connection.SendMessageAsync(new OperationMessage { /// protected override async Task ExecuteRequestAsync(OperationMessage message) { - var request = Serializer.ReadNode(message.Payload); #pragma warning disable CA2208 // Instantiate argument exceptions correctly - if (request == null) - throw new ArgumentNullException(nameof(message) + "." + nameof(OperationMessage.Payload)); + var request = Serializer.ReadNode(message.Payload) + ?? throw new ArgumentNullException(nameof(message) + "." + nameof(OperationMessage.Payload)); #pragma warning restore CA2208 // Instantiate argument exceptions correctly using var scope = ServiceScopeFactory.CreateScope(); try { diff --git a/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt b/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt index 4b53f26..102f5f0 100644 --- a/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt +++ b/src/Tests.ApiApprovals/GraphQL.AspNetCore3.approved.txt @@ -60,11 +60,23 @@ namespace GraphQL.AspNetCore3 public ExecutionResultActionResult(GraphQL.ExecutionResult executionResult, System.Net.HttpStatusCode statusCode = 200) { } public System.Threading.Tasks.Task ExecuteResultAsync(Microsoft.AspNetCore.Mvc.ActionContext context) { } } + public class FormFileGraphType : GraphQL.Types.ScalarGraphType + { + public FormFileGraphType() { } + public override bool CanParseLiteral(GraphQLParser.AST.GraphQLValue value) { } + public override bool CanParseValue(object? value) { } + public override bool IsValidDefault(object value) { } + public override object? ParseLiteral(GraphQLParser.AST.GraphQLValue value) { } + public override object? ParseValue(object? value) { } + public override object? Serialize(object? value) { } + public override GraphQLParser.AST.GraphQLValue ToAST(object? value) { } + } public static class GraphQLBuilderExtensions { [System.Obsolete("Please use AddAuthorizationRule")] public static GraphQL.DI.IGraphQLBuilder AddAuthorization(this GraphQL.DI.IGraphQLBuilder builder) { } public static GraphQL.DI.IGraphQLBuilder AddAuthorizationRule(this GraphQL.DI.IGraphQLBuilder builder) { } + public static GraphQL.DI.IGraphQLBuilder AddFormFileGraphType(this GraphQL.DI.IGraphQLBuilder builder) { } public static GraphQL.DI.IGraphQLBuilder AddUserContextBuilder(this GraphQL.DI.IGraphQLBuilder builder, GraphQL.DI.ServiceLifetime serviceLifetime = 0) where TUserContextBuilder : class, GraphQL.AspNetCore3.IUserContextBuilder { } public static GraphQL.DI.IGraphQLBuilder AddUserContextBuilder(this GraphQL.DI.IGraphQLBuilder builder, System.Func> creator) @@ -150,6 +162,8 @@ namespace GraphQL.AspNetCore3 public bool HandleGet { get; set; } public bool HandlePost { get; set; } public bool HandleWebSockets { get; set; } + public int? MaximumFileCount { get; set; } + public long? MaximumFileSize { get; set; } public bool ReadExtensionsFromQueryString { get; set; } public bool ReadQueryStringOnPost { get; set; } public bool ReadVariablesFromQueryString { get; set; } @@ -208,15 +222,33 @@ namespace GraphQL.AspNetCore3.Errors { public BatchedRequestsNotSupportedError() { } } + public class FileCountExceededError : GraphQL.Execution.RequestError, GraphQL.AspNetCore3.Errors.IHasPreferredStatusCode + { + public FileCountExceededError() { } + public System.Net.HttpStatusCode PreferredStatusCode { get; } + } + public class FileSizeExceededError : GraphQL.Execution.RequestError, GraphQL.AspNetCore3.Errors.IHasPreferredStatusCode + { + public FileSizeExceededError() { } + public System.Net.HttpStatusCode PreferredStatusCode { get; } + } public class HttpMethodValidationError : GraphQL.Validation.ValidationError { public HttpMethodValidationError(GraphQLParser.ROM originalQuery, GraphQLParser.AST.ASTNode node, string message) { } } + public interface IHasPreferredStatusCode + { + System.Net.HttpStatusCode PreferredStatusCode { get; } + } public class InvalidContentTypeError : GraphQL.Execution.RequestError { public InvalidContentTypeError() { } public InvalidContentTypeError(string message) { } } + public class InvalidMapError : GraphQL.Execution.RequestError + { + public InvalidMapError(string message, System.Exception? innerException = null) { } + } public class JsonInvalidError : GraphQL.Execution.RequestError { public JsonInvalidError() { } diff --git a/src/Tests/FormFileGraphTypeTests.cs b/src/Tests/FormFileGraphTypeTests.cs new file mode 100644 index 0000000..1cb3dfa --- /dev/null +++ b/src/Tests/FormFileGraphTypeTests.cs @@ -0,0 +1,140 @@ +using GraphQLParser.AST; + +namespace Tests; + +public class FormFileGraphTypeTests +{ + private static readonly FormFileGraphType _scalar = new(); + private static readonly IFormFile _formFile = Mock.Of(); + private static readonly byte[] _byteArray = [1, 2, 3]; + private static readonly string _base64 = Convert.ToBase64String(_byteArray); + + [Fact] + public void Name() + { + _scalar.Name.ShouldBe("FormFile"); + } + + [Fact] + public void Serialize_Null() + { + _scalar.Serialize(null).ShouldBeNull(); + } + + [Fact] + public void Serialize_IFormFile() + { + Should.Throw(() => _scalar.Serialize(_formFile)); + } + + [Fact] + public void Serialize_ByteArray() + { + Should.Throw(() => _scalar.Serialize(_byteArray)); + } + + [Fact] + public void Serialize_Base64() + { + Should.Throw(() => _scalar.Serialize(_base64)); + } + + [Fact] + public void ParseLiteral_Null() + { + _scalar.CanParseLiteral(new GraphQLNullValue()).ShouldBeTrue(); + _scalar.ParseLiteral(new GraphQLNullValue()).ShouldBeNull(); + } + + [Fact] + public void ParseLiteral_Base64() + { + var literal = new GraphQLStringValue(_base64); + _scalar.CanParseLiteral(literal).ShouldBeFalse(); + Should.Throw(() => _scalar.ParseLiteral(literal)); + } + + [Fact] + public void ParseLiteral_ByteArray() + { + var literal = new GraphQLListValue() { Values = new() { new GraphQLIntValue(1) } }; + _scalar.CanParseLiteral(literal).ShouldBeFalse(); + Should.Throw(() => _scalar.ParseLiteral(literal)); + } + + [Fact] + public void ParseValue_Null() + { + _scalar.CanParseValue(null).ShouldBeTrue(); + _scalar.ParseValue(null).ShouldBeNull(); + } + + [Fact] + public void ParseValue_IFormFile() + { + _scalar.CanParseValue(_formFile).ShouldBeTrue(); + _scalar.ParseValue(_formFile).ShouldBe(_formFile); + } + + [Fact] + public void ParseValue_ByteArray() + { + _scalar.CanParseValue(_byteArray).ShouldBeFalse(); + Should.Throw(() => _scalar.ParseValue(_byteArray)); + } + + [Fact] + public void ParseValue_Base64() + { + _scalar.CanParseValue(_base64).ShouldBeFalse(); + Should.Throw(() => _scalar.ParseValue(_base64)); + } + + [Fact] + public void IsValidDefault_Null() + { + _scalar.IsValidDefault(null!).ShouldBeFalse(); + } + + [Fact] + public void IsValidDefault_FormFile() + { + _scalar.IsValidDefault(_formFile).ShouldBeFalse(); + } + + [Fact] + public void IsValidDefault_ByteArray() + { + _scalar.IsValidDefault(_byteArray).ShouldBeFalse(); + } + + [Fact] + public void IsValidDefault_Base64() + { + _scalar.IsValidDefault(_base64).ShouldBeFalse(); + } + + [Fact] + public void ToAST_Null() + { + _scalar.ToAST(null).ShouldBeOfType(); + } + + [Fact] + public void ToAST_FormFile() + { + Should.Throw(() => _scalar.ToAST(_formFile)); + } + + [Fact] + public void ToAST_ByteArray() + { + Should.Throw(() => _scalar.ToAST(_byteArray)); + } + + [Fact] + public void ToAST_Base64() + { + Should.Throw(() => _scalar.ToAST(_base64)); + } +} diff --git a/src/Tests/Middleware/PostTests.cs b/src/Tests/Middleware/PostTests.cs index 88803bd..8c22307 100644 --- a/src/Tests/Middleware/PostTests.cs +++ b/src/Tests/Middleware/PostTests.cs @@ -19,6 +19,8 @@ public PostTests() .WithMutation() .WithSubscription()) .AddSchema() + .AddAutoClrMappings() + .AddFormFileGraphType() .AddSystemTextJson() .ConfigureExecutionOptions(o => _configureExecution(o))); #if NETCOREAPP2_1 || NET48 @@ -39,7 +41,8 @@ public PostTests() private class Schema2 : Schema { - public Schema2() + public Schema2(IServiceProvider serviceProvider) + : base(serviceProvider) { Query = new AutoRegisteringObjectGraphType(); } @@ -51,6 +54,34 @@ private class Query2 public static string? Ext(IResolveFieldContext context) => context.InputExtensions.TryGetValue("test", out var value) ? value?.ToString() : null; + + public static MyFile? File(IFormFile? file) => file == null ? null : new(file); + + public static IEnumerable File2(IEnumerable files) => files.Select(x => new MyFile(x)); + public static MyFile File3(MyFileInput arg) => new(arg.File); + public static IEnumerable File4(IEnumerable args) => args.Select(x => new MyFile(x.File)); + public static IEnumerable File5(MyFileInput2 args) => args.Files.Select(x => new MyFile(x)); + } + + private record MyFileInput(IFormFile File); + private record MyFileInput2(IEnumerable Files); + + private class MyFile + { + private readonly IFormFile _file; + public MyFile(IFormFile file) + { + _file = file; + } + + public string Name => _file.Name; + public string ContentType => _file.ContentType; + public string Content() + { + using var stream = _file.OpenReadStream(); + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } } public void Dispose() => _server.Dispose(); @@ -113,7 +144,7 @@ public async Task AltCharset_Invalid() #endif [Fact] - public async Task FormMultipart() + public async Task FormMultipart_Legacy() { var client = _server.CreateClient(); var content = new MultipartFormDataContent(); @@ -133,6 +164,208 @@ public async Task FormMultipart() await response.ShouldBeAsync(@"{""data"":{""ext"":""2"",""var"":""1""}}"); } + [Fact] + public async Task FormMultipart_Upload() + { + var client = _server.CreateClient(); + using var content = new MultipartFormDataContent(); + var jsonContent = new StringContent(""" + { + "query": "query op1{ext} query op2($test:String!){ext var(test:$test)}", + "operationName": "op2", + "variables": { "test": "1" }, + "extensions": { "test": "2"} + } + """, Encoding.UTF8, "application/json"); + content.Add(jsonContent, "operations"); + using var response = await client.PostAsync("/graphql2", content); + await response.ShouldBeAsync("""{"data":{"ext":"2","var":"1"}}"""); + } + + // successful queries + // typical, single file + [InlineData(1, "{\"query\":\"query($arg:FormFile){file(file:$arg){name contentType content}}\",\"variables\":{\"arg\":null}}", "{\"file0\":[\"variables.arg\"]}", true, false, + 200, "{\"data\":{\"file\":{\"name\":\"file0\",\"contentType\":\"text/text; charset=utf-8\",\"content\":\"test1\"}}}")] + // single file with map specified as 0.variables + [InlineData(2, "{\"query\":\"query($arg:FormFile){file(file:$arg){content}}\",\"variables\":{\"arg\":null}}", "{\"file0\":[\"0.variables.arg\"]}", true, false, + 200, "{\"data\":{\"file\":{\"content\":\"test1\"}}}")] + // two files + [InlineData(3, "{\"query\":\"query($arg1:FormFile,$arg2:FormFile){file0:file(file:$arg1){content},file1:file(file:$arg2){content}}\",\"variables\":{\"arg1\":null,\"arg2\":null}}", "{\"file0\":[\"0.variables.arg1\"],\"file1\":[\"0.variables.arg2\"]}", true, true, + 200, "{\"data\":{\"file0\":{\"content\":\"test1\"},\"file1\":{\"content\":\"test2\"}}}")] + // batch query of two requests + [InlineData(4, "[{\"query\":\"query($arg:FormFile){file(file:$arg){content}}\",\"variables\":{\"arg\":null}},{\"query\":\"query($arg:FormFile){file(file:$arg){content}}\",\"variables\":{\"arg\":null}}]", "{\"file0\":[\"0.variables.arg\"],\"file1\":[\"1.variables.arg\"]}", true, true, + 200, "[{\"data\":{\"file\":{\"content\":\"test1\"}}},{\"data\":{\"file\":{\"content\":\"test2\"}}}]")] + // batch query of one request + [InlineData(5, "[{\"query\":\"query($arg:FormFile){file(file:$arg){content}}\",\"variables\":{\"arg\":null}}]", "{\"file0\":[\"variables.arg\"]}", true, false, + 200, "[{\"data\":{\"file\":{\"content\":\"test1\"}}}]")] + // referencing a variable's child by index + [InlineData(6, "{\"query\":\"query($arg:[FormFile!]!){file2(files:$arg){content}}\",\"variables\":{\"arg\":[null]}}", "{\"file0\":[\"variables.arg.0\"]}", true, false, + 200, "{\"data\":{\"file2\":[{\"content\":\"test1\"}]}}")] + // referencing a variable's child by property name + [InlineData(7, "{\"query\":\"query($arg:MyFileInput!){file3(arg:$arg){content}}\",\"variables\":{\"arg\":{\"file\":null}}}", "{\"file0\":[\"variables.arg.file\"]}", true, false, + 200, "{\"data\":{\"file3\":{\"content\":\"test1\"}}}")] + // referencing a variable's child by index by property name + [InlineData(8, "{\"query\":\"query($arg:[MyFileInput!]!){file4(args:$arg){content}}\",\"variables\":{\"arg\":[{\"file\":null}]}}", "{\"file0\":[\"variables.arg.0.file\"]}", true, false, + 200, "{\"data\":{\"file4\":[{\"content\":\"test1\"}]}}")] + + // failing queries + // invalid index for request (no requests) + [InlineData(20, "[]", "{\"file0\":[\"variables.arg\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. No request specified.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // invalid index for request (string not integer) + [InlineData(21, null, "{\"file0\":[\"abc.variables.arg\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Could not parse the request index.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // invalid index for request + [InlineData(22, null, "{\"file0\":[\"1.variables.arg\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Invalid request index.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // already set variable + [InlineData(23, "{\"query\":\"query($arg:FormFile){file(file:$arg){content}}\",\"variables\":{\"arg\":\"hello\"}}", "{\"file0\":[\"variables.arg\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child property \\u0027arg\\u0027 must refer to a null object.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // invalid 'operations' json + [InlineData(24, "{", null, false, false, + 400, "{\"errors\":[{\"message\":\"JSON body text could not be parsed. Expected depth to be zero at the end of the JSON payload. There is an open JSON object or array that should be closed. Path: $ | LineNumber: 0 | BytePositionInLine: 1.\",\"extensions\":{\"code\":\"JSON_INVALID\",\"codes\":[\"JSON_INVALID\"]}}]}")] + // invalid 'map' json + [InlineData(25, null, "{", false, false, + 400, "{\"errors\":[{\"message\":\"JSON body text could not be parsed. Expected depth to be zero at the end of the JSON payload. There is an open JSON object or array that should be closed. Path: $ | LineNumber: 0 | BytePositionInLine: 1.\",\"extensions\":{\"code\":\"JSON_INVALID\",\"codes\":[\"JSON_INVALID\"]}}]}")] + // invalid map path: invalid prefix + [InlineData(30, null, "{\"file0\":[\"abc\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map path must start with \\u0027variables.\\u0027 or the index of the request followed by \\u0027.variables.\\u0027.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [InlineData(31, null, "{\"file0\":[\"0.abc\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map path must start with \\u0027variables.\\u0027 or the index of the request followed by \\u0027.variables.\\u0027.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [InlineData(32, null, "{\"file0\":[\"variables\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map path must start with \\u0027variables.\\u0027 or the index of the request followed by \\u0027.variables.\\u0027.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [InlineData(33, null, "{\"file0\":[\"0.variables\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map path must start with \\u0027variables.\\u0027 or the index of the request followed by \\u0027.variables.\\u0027.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // invalid map path: missing property name + [InlineData(34, null, "{\"file0\":[\"variables.\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Empty property name.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [InlineData(35, null, "{\"file0\":[\"0.variables.\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Empty property name.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // invalid map path: child of null specified + [InlineData(36, null, "{\"file0\":[\"variables.arg.file\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child property \\u0027arg\\u0027 refers to a null object.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // invalid map path: child of string specified + [InlineData(37, "{\"query\":\"query($arg:FormFile){file(file:$arg){name contentType content}}\",\"variables\":{\"arg\":\"hello\"}}", "{\"file0\":[\"variables.arg.file\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Cannot refer to child property \\u0027file\\u0027 of a string or number.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [InlineData(38, "{\"query\":\"query($arg:FormFile){file(file:$arg){name contentType content}}\",\"variables\":{\"arg\":\"hello\"}}", "{\"file0\":[\"variables.arg.file.dummy\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Cannot refer to child property \\u0027file\\u0027 of a string or number.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // map target is null + [InlineData(39, null, "{\"file0\":[null]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map target cannot be null.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // invalid map keys + [InlineData(40, null, "{\"\":[\"0.variables.arg\"]}", false, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map key cannot be query, operationName, variables, extensions, operations or map.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [InlineData(41, null, "{\"query\":[\"0.variables.arg\"]}", false, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map key cannot be query, operationName, variables, extensions, operations or map.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [InlineData(42, null, "{\"variables\":[\"0.variables.arg\"]}", false, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map key cannot be query, operationName, variables, extensions, operations or map.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [InlineData(43, null, "{\"extensions\":[\"0.variables.arg\"]}", false, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map key cannot be query, operationName, variables, extensions, operations or map.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [InlineData(44, null, "{\"operationName\":[\"0.variables.arg\"]}", false, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map key cannot be query, operationName, variables, extensions, operations or map.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [InlineData(45, null, "{\"map\":[\"0.variables.arg\"]}", false, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map key cannot be query, operationName, variables, extensions, operations or map.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // missing referenced file + [InlineData(50, null, "{\"file0\":[\"0.variables.arg\"]}", false, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Map key does not refer to an uploaded file.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // no variables in request + [InlineData(51, "{}", "{\"file0\":[\"0.variables.arg\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. No variables defined for this request.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // no variables in request + [InlineData(52, "[null]", "{\"file0\":[\"0.variables.arg\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. No variables defined for this request.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // variables present but not the one referenced + [InlineData(53, null, "{\"file0\":[\"0.variables.arg2\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child property \\u0027arg2\\u0027 does not exist.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // invalid variable path + [InlineData(54, null, "{\"file0\":[\"0.variables.arg.child\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child property \\u0027arg\\u0027 refers to a null object.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // file2 tests + // missing index in variable path + [InlineData(60, "{\"query\":\"query($arg:[FormFile!]!){file2(files:$arg){content}}\",\"variables\":{\"arg\":[null]}}", "{\"file0\":[\"variables.arg\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child property \\u0027arg\\u0027 must refer to a null object.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // invalid index in variable path + [InlineData(61, "{\"query\":\"query($arg:[FormFile!]!){file2(files:$arg){content}}\",\"variables\":{\"arg\":[null]}}", "{\"file0\":[\"variables.arg.1\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Index \\u00271\\u0027 is out of bounds.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // name instead of index in variable path + [InlineData(62, "{\"query\":\"query($arg:[FormFile!]!){file2(files:$arg){content}}\",\"variables\":{\"arg\":[null]}}", "{\"file0\":[\"variables.arg.abc\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child index \\u0027abc\\u0027 is not an integer.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // suffix in variable path + [InlineData(63, "{\"query\":\"query($arg:[FormFile!]!){file2(files:$arg){content}}\",\"variables\":{\"arg\":[null]}}", "{\"file0\":[\"variables.arg.0.abc\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child index \\u00270\\u0027 refers to a null object.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // suffix in variable path for string + [InlineData(64, "{\"query\":\"query($arg:[FormFile!]!){file2(files:$arg){content}}\",\"variables\":{\"arg\":[\"test\"]}}", "{\"file0\":[\"variables.arg.0.abc\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Cannot refer to child property \\u0027abc\\u0027 of a string or number.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // already set variable + [InlineData(65, "{\"query\":\"query($arg:[FormFile!]!){file2(files:$arg){content}}\",\"variables\":{\"arg\":[\"test\"]}}", "{\"file0\":[\"variables.arg.0\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Index \\u00270\\u0027 must refer to a null variable.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // file3 tests + // missing prop in variable path + [InlineData(70, "{\"query\":\"query($arg:[FormFile!]!){file3(arg:$arg){content}}\",\"variables\":{\"arg\":{\"file\":null}}}", "{\"file0\":[\"variables.arg\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child property \\u0027arg\\u0027 must refer to a null object.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // invalid prop in variable path + [InlineData(71, "{\"query\":\"query($arg:MyFileInput!){file3(arg:$arg){content}}\",\"variables\":{\"arg\":{\"file\":null}}}", "{\"file0\":[\"variables.arg.1\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child property \\u00271\\u0027 does not exist.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // suffix in variable path + [InlineData(72, "{\"query\":\"query($arg:MyFileInput!){file3(arg:$arg){content}}\",\"variables\":{\"arg\":{\"file\":null}}}", "{\"file0\":[\"variables.arg.file.abc\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child property \\u0027file\\u0027 refers to a null object.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // suffix in variable path for string + [InlineData(73, "{\"query\":\"query($arg:MyFileInput!){file3(arg:$arg){content}}\",\"variables\":{\"arg\":{\"file\":\"test\"}}}", "{\"file0\":[\"variables.arg.file.abc\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Cannot refer to child property \\u0027abc\\u0027 of a string or number.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // already set variable + [InlineData(74, "{\"query\":\"query($arg:MyFileInput!){file3(arg:$arg){content}}\",\"variables\":{\"arg\":{\"file\":\"test\"}}}", "{\"file0\":[\"variables.arg.file\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child property \\u0027file\\u0027 must refer to a null object.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // file4 tests + // parent not an integer + [InlineData(80, "{\"query\":\"query($arg:[MyFileInput!]!){file4(args:$arg){content}}\",\"variables\":{\"arg\":[{\"file\":null}]}}", "{\"file0\":[\"variables.arg.test.file\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child index \\u0027test\\u0027 is not an integer.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // parent not valid + [InlineData(81, "{\"query\":\"query($arg:[MyFileInput!]!){file4(args:$arg){content}}\",\"variables\":{\"arg\":[{\"file\":null}]}}", "{\"file0\":[\"variables.arg.1.file\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Index \\u00271\\u0027 is out of bounds.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + // file5 tests + // parent not valid + [InlineData(90, "{\"query\":\"query($arg:MyFileInput2!){file5(arg:$arg){content}}\",\"variables\":{\"arg\":{\"files\":[null]}}}", "{\"file0\":[\"variables.arg.dummy.0\"]}", true, false, + 400, "{\"errors\":[{\"message\":\"Invalid map path. Child property \\u0027dummy\\u0027 does not exist.\",\"extensions\":{\"code\":\"INVALID_MAP\",\"codes\":[\"INVALID_MAP\"]}}]}")] + [Theory] + public async Task FormMultipart_Upload_Matrix(int testIndex, string? operations, string? map, bool file0, bool file1, int expectedStatusCode, string expectedResponse) + { + _ = testIndex; + operations ??= "{\"query\":\"query($arg:FormFile){file(file:$arg){content}}\",\"variables\":{\"arg\":null}}"; + var client = _server.CreateClient(); + using var content = new MultipartFormDataContent(); + if (operations != null) + content.Add(new StringContent(operations, Encoding.UTF8, "application/json"), "operations"); + if (map != null) + content.Add(new StringContent(map, Encoding.UTF8, "application/json"), "map"); + if (file0) + content.Add(new StringContent("test1", Encoding.UTF8, "text/text"), "file0", "example1.txt"); + if (file1) + content.Add(new StringContent("test2", Encoding.UTF8, "text/html"), "file1", "example2.html"); + using var response = await client.PostAsync("/graphql2", content); + await response.ShouldBeAsync((HttpStatusCode)expectedStatusCode, expectedResponse); + } + + [InlineData(1, null, HttpStatusCode.RequestEntityTooLarge, "{\"errors\":[{\"message\":\"File uploads exceeded.\",\"extensions\":{\"code\":\"FILE_COUNT_EXCEEDED\",\"codes\":[\"FILE_COUNT_EXCEEDED\"]}}]}")] + [InlineData(null, 1, HttpStatusCode.RequestEntityTooLarge, "{\"errors\":[{\"message\":\"File size limit exceeded.\",\"extensions\":{\"code\":\"FILE_SIZE_EXCEEDED\",\"codes\":[\"FILE_SIZE_EXCEEDED\"]}}]}")] + [Theory] + public async Task FormMultipart_Upload_Validation(int? maxFileCount, int? maxFileLength, HttpStatusCode expectedStatusCode, string expectedResponse) + { + var operations = "{\"query\":\"query($arg1:FormFile,$arg2:FormFile){file0:file(file:$arg1){content},file1:file(file:$arg2){content}}\",\"variables\":{\"arg1\":null,\"arg2\":null}}"; + var map = "{\"file0\":[\"0.variables.arg1\"],\"file1\":[\"0.variables.arg2\"]}"; + var client = _server.CreateClient(); + _options2.MaximumFileCount = maxFileCount; + _options2.MaximumFileSize = maxFileLength; + using var content = new MultipartFormDataContent + { + { new StringContent(operations, Encoding.UTF8, "application/json"), "operations" }, + { new StringContent(map, Encoding.UTF8, "application/json"), "map" }, + { new StringContent("test1", Encoding.UTF8, "text/text"), "file0", "example1.txt" }, + { new StringContent("test2", Encoding.UTF8, "text/html"), "file1", "example2.html" } + }; + using var response = await client.PostAsync("/graphql2", content); + await response.ShouldBeAsync(expectedStatusCode, expectedResponse); + } + [Fact] public async Task FormUrlEncoded() { diff --git a/src/Tests/Middleware/WebSocketTests.cs b/src/Tests/Middleware/WebSocketTests.cs index 72c764c..90123e0 100644 --- a/src/Tests/Middleware/WebSocketTests.cs +++ b/src/Tests/Middleware/WebSocketTests.cs @@ -9,10 +9,8 @@ public class WebSocketTests : IDisposable private void Configure(Action? configureOptions = null, Action? configureServices = null) { - if (configureOptions == null) - configureOptions = _ => { }; - if (configureServices == null) - configureServices = _ => { }; + configureOptions ??= _ => { }; + configureServices ??= _ => { }; var hostBuilder = new WebHostBuilder(); hostBuilder.ConfigureServices(services => {