Skip to content

Commit

Permalink
Add attribute for media type validation (#1117)
Browse files Browse the repository at this point in the history
  • Loading branch information
Shane32 authored Aug 3, 2024
1 parent b1ee1af commit c421048
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 11 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 6 additions & 5 deletions docs/migration/migration8.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion samples/Samples.Upload/Mutation.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
using GraphQL;
using GraphQL.Server.Transports.AspNetCore;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;

namespace Samples.Upload;

public class Mutation
{
public static async Task<string> Rotate(IFormFile file, CancellationToken cancellationToken)
public static async Task<string> Rotate([MediaType("image/*")] IFormFile file, CancellationToken cancellationToken)
{
if (file == null || file.Length == 0)
{
Expand Down
137 changes: 137 additions & 0 deletions src/Transports.AspNetCore/MediaTypeAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using System.Collections;
using MediaTypeHeaderValue = Microsoft.Net.Http.Headers.MediaTypeHeaderValue;

namespace GraphQL.Server.Transports.AspNetCore;

/// <summary>
/// Ensures that the marked field argument or input object field is a valid media type
/// via the <see cref="MediaTypeHeaderValue.IsSubsetOf(MediaTypeHeaderValue)"/> method
/// and supports wildcards such as "<c>text/*</c>".
/// </summary>
/// <remarks>
/// Only checks values of type <see cref="IFormFile"/>, or lists of <see cref="IFormFile"/>.
/// Any other types of values will throw a run-time exception.
/// </remarks>
public class MediaTypeAttribute : GraphQLAttribute
{
private readonly MediaTypeHeaderValue[] _mimeTypes;

/// <inheritdoc cref="MediaTypeAttribute"/>
public MediaTypeAttribute(params string[] mimeTypes)
{
var types = MediaTypeHeaderValue.ParseList(mimeTypes);
_mimeTypes = types as MediaTypeHeaderValue[] ?? types.ToArray();
}

/// <inheritdoc/>
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;
}

/// <inheritdoc/>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ namespace GraphQL.Server.Transports.AspNetCore
}
public interface IUserContextBuilder<TSchema> : 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<TUserContext> : GraphQL.Server.Transports.AspNetCore.IUserContextBuilder
where TUserContext : System.Collections.Generic.IDictionary<string, object?>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ namespace GraphQL.Server.Transports.AspNetCore
}
public interface IUserContextBuilder<TSchema> : 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<TUserContext> : GraphQL.Server.Transports.AspNetCore.IUserContextBuilder
where TUserContext : System.Collections.Generic.IDictionary<string, object?>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ namespace GraphQL.Server.Transports.AspNetCore
}
public interface IUserContextBuilder<TSchema> : 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<TUserContext> : GraphQL.Server.Transports.AspNetCore.IUserContextBuilder
where TUserContext : System.Collections.Generic.IDictionary<string, object?>
{
Expand Down
33 changes: 33 additions & 0 deletions tests/Samples.Upload.Tests/EndToEndTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Program>();
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\"}}]}");
}
}

0 comments on commit c421048

Please sign in to comment.