Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add attribute for media type validation #1117

Merged
merged 11 commits into from
Aug 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
gao-artur marked this conversation as resolved.
Show resolved Hide resolved
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.
gao-artur marked this conversation as resolved.
Show resolved Hide resolved
- The obsolete (v6 and prior) authorization validation rule has been removed. See the v7 migration
gao-artur marked this conversation as resolved.
Show resolved Hide resolved
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;
}
Dismissed Show dismissed Hide dismissed
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 },
Dismissed Show dismissed Hide dismissed
};
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\"}}]}");
}
}
Loading