diff --git a/GraphQL.Server.sln b/GraphQL.Server.sln index 5f9137df..02e5081d 100644 --- a/GraphQL.Server.sln +++ b/GraphQL.Server.sln @@ -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 @@ -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 @@ -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 @@ -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} diff --git a/README.md b/README.md index a325246a..e42698fc 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 | @@ -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 @@ -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() + .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 @@ -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: diff --git a/samples/Samples.Upload/Mutation.cs b/samples/Samples.Upload/Mutation.cs new file mode 100644 index 00000000..42cb2263 --- /dev/null +++ b/samples/Samples.Upload/Mutation.cs @@ -0,0 +1,41 @@ +using GraphQL; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; + +namespace Samples.Upload; + +public class Mutation +{ + public static async Task 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); + } + } +} diff --git a/samples/Samples.Upload/Pages/Index.cshtml b/samples/Samples.Upload/Pages/Index.cshtml new file mode 100644 index 00000000..318800cd --- /dev/null +++ b/samples/Samples.Upload/Pages/Index.cshtml @@ -0,0 +1,64 @@ +@page +@model GraphQL.Server.Samples.Upload.Pages.IndexModel + + + + + Image Upload + + +

Rotate JPEG images

+
    +
  1. Select a JPEG image
  2. +
  3. Click the "Upload Image" button
  4. +
  5. Wait for the image to be rotated
  6. +
+

+

+

+ +

+ + + + diff --git a/samples/Samples.Upload/Pages/Index.cshtml.cs b/samples/Samples.Upload/Pages/Index.cshtml.cs new file mode 100644 index 00000000..f61101eb --- /dev/null +++ b/samples/Samples.Upload/Pages/Index.cshtml.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace GraphQL.Server.Samples.Upload.Pages +{ + public class IndexModel : PageModel + { + public void OnGet() + { + } + } +} diff --git a/samples/Samples.Upload/Program.cs b/samples/Samples.Upload/Program.cs new file mode 100644 index 00000000..76ee93ca --- /dev/null +++ b/samples/Samples.Upload/Program.cs @@ -0,0 +1,18 @@ +using GraphQL; +using Samples.Upload; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorPages(); +builder.Services.AddGraphQL(b => b + .AddAutoSchema(c => c.WithMutation()) + .AddFormFileGraphType() + .AddSystemTextJson()); + +var app = builder.Build(); +app.UseDeveloperExceptionPage(); +app.UseGraphQL(); +app.UseRouting(); +app.MapRazorPages(); + +await app.RunAsync(); diff --git a/samples/Samples.Upload/Properties/launchSettings.json b/samples/Samples.Upload/Properties/launchSettings.json new file mode 100644 index 00000000..0dd7c39d --- /dev/null +++ b/samples/Samples.Upload/Properties/launchSettings.json @@ -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" + } + } +} diff --git a/samples/Samples.Upload/Query.cs b/samples/Samples.Upload/Query.cs new file mode 100644 index 00000000..55fc62ac --- /dev/null +++ b/samples/Samples.Upload/Query.cs @@ -0,0 +1,6 @@ +namespace Samples.Upload; + +public class Query +{ + public static string Hello() => "Hello World!"; +} diff --git a/samples/Samples.Upload/Samples.Upload.csproj b/samples/Samples.Upload/Samples.Upload.csproj new file mode 100644 index 00000000..5c71aa15 --- /dev/null +++ b/samples/Samples.Upload/Samples.Upload.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + diff --git a/src/Transports.AspNetCore/Errors/FileCountExceededError.cs b/src/Transports.AspNetCore/Errors/FileCountExceededError.cs new file mode 100644 index 00000000..0d5733cc --- /dev/null +++ b/src/Transports.AspNetCore/Errors/FileCountExceededError.cs @@ -0,0 +1,18 @@ +namespace GraphQL.Server.Transports.AspNetCore.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/Transports.AspNetCore/Errors/FileSizeExceededError.cs b/src/Transports.AspNetCore/Errors/FileSizeExceededError.cs new file mode 100644 index 00000000..67b998c5 --- /dev/null +++ b/src/Transports.AspNetCore/Errors/FileSizeExceededError.cs @@ -0,0 +1,18 @@ +namespace GraphQL.Server.Transports.AspNetCore.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/Transports.AspNetCore/Errors/IHasPreferredStatusCode.cs b/src/Transports.AspNetCore/Errors/IHasPreferredStatusCode.cs new file mode 100644 index 00000000..475d1570 --- /dev/null +++ b/src/Transports.AspNetCore/Errors/IHasPreferredStatusCode.cs @@ -0,0 +1,12 @@ +namespace GraphQL.Server.Transports.AspNetCore.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/Transports.AspNetCore/Errors/InvalidMapError.cs b/src/Transports.AspNetCore/Errors/InvalidMapError.cs new file mode 100644 index 00000000..7e09ae98 --- /dev/null +++ b/src/Transports.AspNetCore/Errors/InvalidMapError.cs @@ -0,0 +1,15 @@ +namespace GraphQL.Server.Transports.AspNetCore.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/Transports.AspNetCore/Extensions/GraphQLBuilderExtensions.cs b/src/Transports.AspNetCore/Extensions/GraphQLBuilderExtensions.cs index 10a67109..ec140b1e 100644 --- a/src/Transports.AspNetCore/Extensions/GraphQLBuilderExtensions.cs +++ b/src/Transports.AspNetCore/Extensions/GraphQLBuilderExtensions.cs @@ -143,4 +143,15 @@ public static IGraphQLBuilder AddAuthorizationRule(this IGraphQLBuilder builder) builder.AddValidationRule(true); 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/Transports.AspNetCore/FormFileGraphType.cs b/src/Transports.AspNetCore/FormFileGraphType.cs new file mode 100644 index 00000000..748cced6 --- /dev/null +++ b/src/Transports.AspNetCore/FormFileGraphType.cs @@ -0,0 +1,37 @@ +namespace GraphQL.Server.Transports.AspNetCore; + +/// +/// 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/Transports.AspNetCore/GraphQLHttpMiddleware.cs b/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs index c314630b..3aceec51 100644 --- a/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs +++ b/src/Transports.AspNetCore/GraphQLHttpMiddleware.cs @@ -1,5 +1,9 @@ #pragma warning disable CA1716 // Identifiers should not match keywords +using System.Collections; +using System.Diagnostics; +using System.Globalization; +using System.Text; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Primitives; using MediaTypeHeaderValueMs = Microsoft.Net.Http.Headers.MediaTypeHeaderValue; @@ -55,6 +59,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"; // deprecated private const string MEDIATYPE_JSON = "application/json"; private const string MEDIATYPE_GRAPHQL = "application/graphql"; @@ -203,7 +209,7 @@ protected virtual async Task InvokeAsync(HttpContext context, RequestDelegate ne /// and return . /// protected virtual async Task<(GraphQLRequest? SingleRequest, IList? BatchRequest)?> ReadPostContentAsync( - HttpContext context, RequestDelegate next, string? mediaType, System.Text.Encoding? sourceEncoding) + HttpContext context, RequestDelegate next, string? mediaType, Encoding? sourceEncoding) { var httpRequest = context.Request; @@ -250,9 +256,14 @@ protected virtual async Task InvokeAsync(HttpContext context, RequestDelegate ne try { var formCollection = await httpRequest.ReadFormAsync(context.RequestAborted); - return (DeserializeFromFormBody(formCollection), null); + return ReadFormContent(formCollection); } - catch (Exception ex) + 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; @@ -264,6 +275,205 @@ protected virtual async Task InvokeAsync(HttpContext context, RequestDelegate ne } } + /// + /// 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 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 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/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs b/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs index 30196365..1bb01339 100644 --- a/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs +++ b/src/Transports.AspNetCore/GraphQLHttpMiddlewareOptions.cs @@ -110,6 +110,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/tests/ApiApprovalTests/net50+net60+netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/net50+net60+netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt index 6be79fab..2686b29a 100644 --- a/tests/ApiApprovalTests/net50+net60+netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/net50+net60+netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -62,6 +62,17 @@ namespace GraphQL.Server.Transports.AspNetCore public string ContentType { get; set; } 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 class GraphQLExecutionActionResult : GraphQL.Server.Transports.AspNetCore.GraphQLExecutionActionResult { public GraphQLExecutionActionResult(GraphQL.Server.Transports.AspNetCore.GraphQLHttpMiddlewareOptions options) { } @@ -121,6 +132,8 @@ namespace GraphQL.Server.Transports.AspNetCore 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 ReadFormOnPost { get; set; } public bool ReadQueryStringOnPost { get; set; } @@ -177,15 +190,33 @@ namespace GraphQL.Server.Transports.AspNetCore.Errors { public BatchedRequestsNotSupportedError() { } } + public class FileCountExceededError : GraphQL.Execution.RequestError, GraphQL.Server.Transports.AspNetCore.Errors.IHasPreferredStatusCode + { + public FileCountExceededError() { } + public System.Net.HttpStatusCode PreferredStatusCode { get; } + } + public class FileSizeExceededError : GraphQL.Execution.RequestError, GraphQL.Server.Transports.AspNetCore.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() { } @@ -377,6 +408,7 @@ namespace GraphQL public static class ServerGraphQLBuilderExtensions { 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) where TUserContextBuilder : class, GraphQL.Server.Transports.AspNetCore.IUserContextBuilder { } public static GraphQL.DI.IGraphQLBuilder AddUserContextBuilder(this GraphQL.DI.IGraphQLBuilder builder, System.Func> creator) diff --git a/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt index a410de5d..6fd19749 100644 --- a/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -69,6 +69,17 @@ namespace GraphQL.Server.Transports.AspNetCore public string ContentType { get; set; } 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 class GraphQLExecutionActionResult : GraphQL.Server.Transports.AspNetCore.GraphQLExecutionActionResult { public GraphQLExecutionActionResult(GraphQL.Server.Transports.AspNetCore.GraphQLHttpMiddlewareOptions options) { } @@ -128,6 +139,8 @@ namespace GraphQL.Server.Transports.AspNetCore 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 ReadFormOnPost { get; set; } public bool ReadQueryStringOnPost { get; set; } @@ -195,15 +208,33 @@ namespace GraphQL.Server.Transports.AspNetCore.Errors { public BatchedRequestsNotSupportedError() { } } + public class FileCountExceededError : GraphQL.Execution.RequestError, GraphQL.Server.Transports.AspNetCore.Errors.IHasPreferredStatusCode + { + public FileCountExceededError() { } + public System.Net.HttpStatusCode PreferredStatusCode { get; } + } + public class FileSizeExceededError : GraphQL.Execution.RequestError, GraphQL.Server.Transports.AspNetCore.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() { } @@ -395,6 +426,7 @@ namespace GraphQL public static class ServerGraphQLBuilderExtensions { 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) where TUserContextBuilder : class, GraphQL.Server.Transports.AspNetCore.IUserContextBuilder { } public static GraphQL.DI.IGraphQLBuilder AddUserContextBuilder(this GraphQL.DI.IGraphQLBuilder builder, System.Func> creator) diff --git a/tests/Samples.Upload.Tests/EndToEndTests.cs b/tests/Samples.Upload.Tests/EndToEndTests.cs new file mode 100644 index 00000000..9a867d5f --- /dev/null +++ b/tests/Samples.Upload.Tests/EndToEndTests.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Samples.Upload.Tests; + +public class EndToEndTests +{ + [Fact] + public async Task RotateImage() + { + using var webApp = new WebApplicationFactory(); + var server = webApp.Server; + + using var client = server.CreateClient(); + var form = new MultipartFormDataContent(); + var operations = new + { + query = "mutation ($img: FormFile!) { rotate(file: $img) }", + variables = new { img = (string?)null }, + }; + form.Add(JsonContent.Create(operations), "operations"); + var map = new + { + file0 = new string[] { "variables.img" }, + }; + form.Add(JsonContent.Create(map), "map"); + // jpeg image of a red triangle + var base64triangle = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAgACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+obq6hsrOe7uH2QQRtJI2CdqqMk4HPQVNXmPxg8Q/ZtOt9Bhb95dYmn46RqflHI7sM8HI2ehrOtUVODkzty7BSxuJhQj1evkur+79DvdC1eHXtDs9UgG1LiPcV5Oxhwy5IGcMCM45xWjXjXwf8Q/ZtRuNBmb93dZmg46SKPmHA7qM8nA2epr2Wpw9X2tNSNs3wDwOLlR6br0e33bfIhurqGys57u4fZBBG0kjYJ2qoyTgc9BXzHrurza9rl5qk42vcSbgvB2KOFXIAzhQBnHOK978daJq/iLQxpely2sSSyBrhp3Iyq8hQAp/iwc5H3e+TXm3/Cm/EP/AD+aX/39k/8AiK5MbGrUajGOiPf4ZrYHCQlWr1Upy0s+iXy6v8EcFa3U1leQXdu+yeCRZI2wDtZTkHB46ivpzQtXh17Q7PVIBtS4j3FeTsYcMuSBnDAjOOcV5B/wpvxD/wA/ml/9/ZP/AIiu7+H3hjW/CkN3Z6hPZzWczCWPyJGLJJ0PBQZBAHfjb05NTg4Vac7Si7M24kxGAxtBTo1U5x/FPdbfP7z/2Q=="; + var triangle = Convert.FromBase64String(base64triangle); + var triangleContent = new ByteArrayContent(triangle); + triangleContent.Headers.ContentType = new("image/jpeg"); + form.Add(triangleContent, "file0", "triangle.jpg"); + using var request = new HttpRequestMessage(HttpMethod.Post, "/graphql"); + request.Content = form; + using var response = await client.SendAsync(request); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var ret = await response.Content.ReadAsStringAsync(); + ret.ShouldBe("{\"data\":{\"rotate\":\"/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDIBCQkJDAsMGA0NGDIhHCEyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMv/AABEIACAAIAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5\\u002BgEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4\\u002BTl5ufo6ery8/T19vf4\\u002Bfr/2gAMAwEAAhEDEQA/APUNa8c6boGvppeorLFG8Kyi5UblXlgQwHzdh0B69sZrorS8tr\\u002B2We0uY54X\\u002B7LGwZWwcHBHHWvGfi\\u002Bf\\u002BKwh5z/oSf8Aob1x\\u002Bla1qOiXP2jTbuS3kPXbyrcEDKng4ycZBxXnzxjp1ZRkro\\u002BsocNxxeCp16MuWbV3fVPf5r8UfUGSME8etDMBk\\u002BleX\\u002BH/AIt2twyQ65AbZ\\u002Bc3EILRnqeV\\u002B8Ow43ZPoK9HtLy2v7ZLi0njmhfO2SNtynBxwRx1rtp1YVFeLPncZgMTg5cteDXn0fo9v62PGPi//wAjhB/15J/6G9cLb2093OsFtBLNM2cRxoXZsDJwBz0r3DxL8P18UeJYr\\u002B7vGgtEt1i8uJcyMwLk8ngDlexzz0610mlaFpmgW7R6bZRwI33ivLNgkjcx5OMnGelcE8HKpVlJ6K59RhuIqODwFOjBc00vRLfd/wCX3nmGg/CS\\u002BugJdcn\\u002ByJ/zxhIeQ9Ry3QdjxuyPQ16fpXh/TNBhaLTLOO3VsZK8s2CSNzHk4ycZ6VsU0Z9K7aVCFL4UfO47NcVjn\\u002B\\u002Blp2Wi\\u002B7r87n//2Q==\"}}"); + } +} diff --git a/tests/Samples.Upload.Tests/Samples.Upload.Tests.csproj b/tests/Samples.Upload.Tests/Samples.Upload.Tests.csproj new file mode 100644 index 00000000..3f5c1f02 --- /dev/null +++ b/tests/Samples.Upload.Tests/Samples.Upload.Tests.csproj @@ -0,0 +1,13 @@ + + + + + net8.0 + End to end tests for the Samples.Upload project + + + + + + + diff --git a/tests/Transports.AspNetCore.Tests/FormFileGraphTypeTests.cs b/tests/Transports.AspNetCore.Tests/FormFileGraphTypeTests.cs new file mode 100644 index 00000000..1cb3dfa8 --- /dev/null +++ b/tests/Transports.AspNetCore.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/tests/Transports.AspNetCore.Tests/Middleware/PostTests.cs b/tests/Transports.AspNetCore.Tests/Middleware/PostTests.cs index e13619f7..fe777f75 100644 --- a/tests/Transports.AspNetCore.Tests/Middleware/PostTests.cs +++ b/tests/Transports.AspNetCore.Tests/Middleware/PostTests.cs @@ -20,6 +20,8 @@ public PostTests() .WithMutation() .WithSubscription()) .AddSchema() + .AddAutoClrMappings() + .AddFormFileGraphType() .AddSystemTextJson() .ConfigureExecutionOptions(o => _configureExecution(o))); #if NETCOREAPP2_1 || NET48 @@ -43,7 +45,8 @@ public PostTests() private class Schema2 : Schema { - public Schema2() + public Schema2(IServiceProvider serviceProvider) + : base(serviceProvider) { Query = new AutoRegisteringObjectGraphType(); } @@ -55,6 +58,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(); @@ -117,7 +148,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(); @@ -137,6 +168,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() {