diff --git a/README.md b/README.md index 98e76aab..e1d2935e 100644 --- a/README.md +++ b/README.md @@ -1001,11 +1001,12 @@ services.AddGraphQL(b => b .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. +Please see the 'Upload' sample for a demonstration of this technique, which also +demonstrates the use of the `MediaTypeAttribute` to restrict the allowable media +types that will be accepted. 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 also wish to allow clients to send files as base-64 encoded +strings, you can write a custom scalar better suited to your needs. ### Native AOT support diff --git a/docs/migration/migration8.md b/docs/migration/migration8.md index 010944ab..8e2b3667 100644 --- a/docs/migration/migration8.md +++ b/docs/migration/migration8.md @@ -1,16 +1,17 @@ # Migrating from v7 to v8 -## Major changes and new features +## New features -None +- When using `FormFileGraphType` with type-first schemas, you may specify the allowed media + types for the file by using the new `[MediaType]` attribute on the argument or input object field. ## Breaking changes - The validation rules' signatures have changed slightly due to the underlying changes to the - GraphQL.NET library. Please see the GraphQL.NET v8 migration document for more information. -- The obsolete (v6 and prior) authorization validation rule has been removed. See the v7 migration + GraphQL.NET library. Please see the GraphQL.NET v8 migration document for more information. +- The obsolete (v6 and prior) authorization validation rule has been removed. See the v7 migration document for more information on how to migrate to the v7/v8 authorization validation rule. ## Other changes -- GraphiQL has been bumped from 1.5.1 to 3.2.0 +- GraphiQL has been bumped from 1.5.1 to 3.2.0. diff --git a/samples/Samples.Upload/Mutation.cs b/samples/Samples.Upload/Mutation.cs index 42cb2263..76190fcb 100644 --- a/samples/Samples.Upload/Mutation.cs +++ b/samples/Samples.Upload/Mutation.cs @@ -1,4 +1,5 @@ using GraphQL; +using GraphQL.Server.Transports.AspNetCore; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; @@ -6,7 +7,7 @@ namespace Samples.Upload; public class Mutation { - public static async Task Rotate(IFormFile file, CancellationToken cancellationToken) + public static async Task Rotate([MediaType("image/*")] IFormFile file, CancellationToken cancellationToken) { if (file == null || file.Length == 0) { diff --git a/src/Transports.AspNetCore/MediaTypeAttribute.cs b/src/Transports.AspNetCore/MediaTypeAttribute.cs new file mode 100644 index 00000000..a2291cd6 --- /dev/null +++ b/src/Transports.AspNetCore/MediaTypeAttribute.cs @@ -0,0 +1,137 @@ +using System.Collections; +using MediaTypeHeaderValue = Microsoft.Net.Http.Headers.MediaTypeHeaderValue; + +namespace GraphQL.Server.Transports.AspNetCore; + +/// +/// Ensures that the marked field argument or input object field is a valid media type +/// via the method +/// and supports wildcards such as "text/*". +/// +/// +/// Only checks values of type , or lists of . +/// Any other types of values will throw a run-time exception. +/// +public class MediaTypeAttribute : GraphQLAttribute +{ + private readonly MediaTypeHeaderValue[] _mimeTypes; + + /// + public MediaTypeAttribute(params string[] mimeTypes) + { + var types = MediaTypeHeaderValue.ParseList(mimeTypes); + _mimeTypes = types as MediaTypeHeaderValue[] ?? types.ToArray(); + } + + /// + public override void Modify(FieldType fieldType, bool isInputType) + { + var lists = fieldType.Type != null + ? CountNestedLists(fieldType.Type) + : CountNestedLists(fieldType.ResolvedType + ?? throw new InvalidOperationException($"No graph type set on field '{fieldType.Name}'.")); + fieldType.Validator += new Validator(lists, _mimeTypes).Validate; + } + + /// + public override void Modify(QueryArgument queryArgument) + { + var lists = queryArgument.Type != null + ? CountNestedLists(queryArgument.Type) + : CountNestedLists(queryArgument.ResolvedType + ?? throw new InvalidOperationException($"No graph type set on field '{queryArgument.Name}'.")); + queryArgument.Validator += new Validator(lists, _mimeTypes).Validate; + } + + private class Validator + { + private readonly int _lists; + private readonly MediaTypeHeaderValue[] _mediaTypes; + + public Validator(int lists, MediaTypeHeaderValue[] mediaTypes) + { + _lists = lists; + _mediaTypes = mediaTypes; + } + + public void Validate(object? obj) + { + Validate(obj, _lists); + } + + private void Validate(object? obj, int lists) + { + if (obj == null) + return; + if (lists == 0) + { + if (obj is IFormFile file) + ValidateMediaType(file); + else + throw new InvalidOperationException("Expected an IFormFile object."); + } + else if (obj is IEnumerable enumerable && obj is not string) + { + foreach (var item in enumerable) + { + Validate(item, lists - 1); + } + } + else + { + throw new InvalidOperationException("Expected a list."); + } + } + + private void ValidateMediaType(IFormFile? file) + { + if (file == null) + return; + var contentType = file.ContentType; + if (contentType == null) + return; + var mediaType = MediaTypeHeaderValue.Parse(contentType); + foreach (var validMediaType in _mediaTypes) + { + if (mediaType.IsSubsetOf(validMediaType)) + return; + } + throw new InvalidOperationException($"Invalid media type '{mediaType}'."); + } + } + + private static int CountNestedLists(Type type) + { + if (!type.IsGenericType) + return 0; + + var typeDef = type.GetGenericTypeDefinition(); + + if (typeDef == typeof(ListGraphType<>)) + { + return 1 + CountNestedLists(type.GetGenericArguments()[0]); + } + + if (typeDef == typeof(NonNullGraphType<>)) + { + return CountNestedLists(type.GetGenericArguments()[0]); + } + + return 0; + } + + private static int CountNestedLists(IGraphType type) + { + if (type is ListGraphType listGraphType) + { + return 1 + CountNestedLists(listGraphType.ResolvedType ?? throw new InvalidOperationException($"ResolvedType not set for {listGraphType}.")); + } + + if (type is NonNullGraphType nonNullGraphType) + { + return CountNestedLists(nonNullGraphType.ResolvedType ?? throw new InvalidOperationException($"ResolvedType not set for {nonNullGraphType}.")); + } + + return 0; + } +} diff --git a/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt index 693815b1..d4eda1ba 100644 --- a/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/net50+net60+net80/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -169,6 +169,12 @@ namespace GraphQL.Server.Transports.AspNetCore } public interface IUserContextBuilder : GraphQL.Server.Transports.AspNetCore.IUserContextBuilder where TSchema : GraphQL.Types.ISchema { } + public class MediaTypeAttribute : GraphQL.GraphQLAttribute + { + public MediaTypeAttribute(params string[] mimeTypes) { } + public override void Modify(GraphQL.Types.QueryArgument queryArgument) { } + public override void Modify(GraphQL.Types.FieldType fieldType, bool isInputType) { } + } public class UserContextBuilder : GraphQL.Server.Transports.AspNetCore.IUserContextBuilder where TUserContext : System.Collections.Generic.IDictionary { 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 9167836f..ffa1da76 100644 --- a/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/netcoreapp21+netstandard20/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -187,6 +187,12 @@ namespace GraphQL.Server.Transports.AspNetCore } public interface IUserContextBuilder : GraphQL.Server.Transports.AspNetCore.IUserContextBuilder where TSchema : GraphQL.Types.ISchema { } + public class MediaTypeAttribute : GraphQL.GraphQLAttribute + { + public MediaTypeAttribute(params string[] mimeTypes) { } + public override void Modify(GraphQL.Types.QueryArgument queryArgument) { } + public override void Modify(GraphQL.Types.FieldType fieldType, bool isInputType) { } + } public class UserContextBuilder : GraphQL.Server.Transports.AspNetCore.IUserContextBuilder where TUserContext : System.Collections.Generic.IDictionary { diff --git a/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt b/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt index 246ddb6c..70369b81 100644 --- a/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt +++ b/tests/ApiApprovalTests/netcoreapp31/GraphQL.Server.Transports.AspNetCore.approved.txt @@ -169,6 +169,12 @@ namespace GraphQL.Server.Transports.AspNetCore } public interface IUserContextBuilder : GraphQL.Server.Transports.AspNetCore.IUserContextBuilder where TSchema : GraphQL.Types.ISchema { } + public class MediaTypeAttribute : GraphQL.GraphQLAttribute + { + public MediaTypeAttribute(params string[] mimeTypes) { } + public override void Modify(GraphQL.Types.QueryArgument queryArgument) { } + public override void Modify(GraphQL.Types.FieldType fieldType, bool isInputType) { } + } public class UserContextBuilder : GraphQL.Server.Transports.AspNetCore.IUserContextBuilder where TUserContext : System.Collections.Generic.IDictionary { diff --git a/tests/Samples.Upload.Tests/EndToEndTests.cs b/tests/Samples.Upload.Tests/EndToEndTests.cs index 9a867d5f..b37a41b6 100644 --- a/tests/Samples.Upload.Tests/EndToEndTests.cs +++ b/tests/Samples.Upload.Tests/EndToEndTests.cs @@ -38,4 +38,37 @@ public async Task RotateImage() 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==\"}}"); } + + [Fact] + public async Task RotateImage_WrongType() + { + 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"); + // base 64 of hello world + var base64hello = "aGVsbG8gd29ybGQ="; + var triangle = Convert.FromBase64String(base64hello); + var triangleContent = new ByteArrayContent(triangle); + triangleContent.Headers.ContentType = new("text/text"); + form.Add(triangleContent, "file0", "hello-world.txt"); + using var request = new HttpRequestMessage(HttpMethod.Post, "/graphql"); + request.Content = form; + using var response = await client.SendAsync(request); + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + var ret = await response.Content.ReadAsStringAsync(); + ret.ShouldBe("{\"errors\":[{\"message\":\"Invalid value for argument \\u0027file\\u0027 of field \\u0027rotate\\u0027. Invalid media type \\u0027text/text\\u0027.\",\"locations\":[{\"line\":1,\"column\":43}],\"extensions\":{\"code\":\"INVALID_VALUE\",\"codes\":[\"INVALID_VALUE\",\"INVALID_OPERATION\"],\"number\":\"5.6\"}}]}"); + } }