Skip to content

Commit f050a94

Browse files
committed
Support resolving IFormFile in complex form mapping
1 parent 2772a78 commit f050a94

File tree

8 files changed

+180
-3
lines changed

8 files changed

+180
-3
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics.CodeAnalysis;
5+
using Microsoft.AspNetCore.Http;
6+
7+
namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping;
8+
9+
internal sealed class FileConverter<T>(HttpContext? httpContext) : FormDataConverter<T>, ISingleValueConverter
10+
{
11+
[RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)]
12+
[RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)]
13+
internal override bool TryRead(ref FormDataReader reader, Type type, FormDataMapperOptions options, out T? result, out bool found)
14+
{
15+
if (httpContext == null)
16+
{
17+
result = default;
18+
found = false;
19+
return true;
20+
}
21+
22+
if (typeof(T) == typeof(IFormFileCollection))
23+
{
24+
result = (T)httpContext.Request.Form.Files;
25+
found = true;
26+
return true;
27+
}
28+
29+
var formFileCollection = httpContext.Request.Form.Files;
30+
if (formFileCollection.Count == 0)
31+
{
32+
result = default;
33+
found = false;
34+
return true;
35+
}
36+
37+
var file = formFileCollection.GetFile(reader.CurrentPrefix.ToString());
38+
if (file != null)
39+
{
40+
result = (T)file;
41+
found = true;
42+
return true;
43+
}
44+
45+
result = default;
46+
found = false;
47+
return true;
48+
}
49+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics.CodeAnalysis;
5+
using Microsoft.AspNetCore.Http;
6+
7+
namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping;
8+
9+
internal sealed class FileConverterFactory(IHttpContextAccessor? httpContextAccessor = null) : IFormDataConverterFactory
10+
{
11+
[RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)]
12+
[RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)]
13+
public bool CanConvert(Type type, FormDataMapperOptions options) => type == typeof(IFormFile) || type == typeof(IFormFileCollection);
14+
15+
[RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)]
16+
[RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)]
17+
public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options)
18+
{
19+
return Activator.CreateInstance(typeof(FileConverter<>).MakeGenericType(type), httpContextAccessor?.HttpContext) as FormDataConverter ??
20+
throw new InvalidOperationException($"Unable to create converter for '{type.FullName}'.");
21+
}
22+
}

src/Components/Endpoints/src/FormMapping/FormDataMapperOptions.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Collections.Concurrent;
55
using System.Diagnostics.CodeAnalysis;
6+
using Microsoft.AspNetCore.Http;
67
using Microsoft.AspNetCore.WebUtilities;
78

89
namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping;
@@ -25,6 +26,21 @@ public FormDataMapperOptions()
2526
_factories.Add(new ComplexTypeConverterFactory(this));
2627
}
2728

29+
[RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)]
30+
[RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)]
31+
internal FormDataMapperOptions(IHttpContextAccessor httpContextAccessor)
32+
{
33+
// We don't use the base constructor here since the ordering of the factories is important.
34+
_converters = new(WellKnownConverters.Converters);
35+
_factories.Add(new ParsableConverterFactory());
36+
_factories.Add(new EnumConverterFactory());
37+
_factories.Add(new FileConverterFactory(httpContextAccessor));
38+
_factories.Add(new NullableConverterFactory());
39+
_factories.Add(new DictionaryConverterFactory());
40+
_factories.Add(new CollectionConverterFactory());
41+
_factories.Add(new ComplexTypeConverterFactory(this));
42+
}
43+
2844
// Not configurable for now, this is the max number of elements we will bind. This is important for
2945
// security reasons, as we don't want to bind a huge collection and cause perf issues.
3046
// Some examples of this are:
@@ -35,7 +51,7 @@ public FormDataMapperOptions()
3551
// MVC uses 32, JSON uses 64. Let's stick to STJ default.
3652
internal int MaxRecursionDepth = 64;
3753

38-
// This is normally 200 (similar to ModelStateDictionary.DefaultMaxAllowedErrors in MVC)
54+
// This is normally 200 (similar to ModelStateDictionary.DefaultMaxAllowedErrors in MVC)
3955
internal int MaxErrorCount = 200;
4056

4157
internal int MaxKeyBufferSize = FormReader.DefaultKeyLengthLimit;

src/Components/Endpoints/src/FormMapping/Metadata/FormDataMetadataFactory.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ internal class FormDataMetadataFactory(List<IFormDataConverterFactory> factories
1515
private readonly FormMetadataContext _context = new();
1616
private readonly ParsableConverterFactory _parsableFactory = factories.OfType<ParsableConverterFactory>().Single();
1717
private readonly DictionaryConverterFactory _dictionaryFactory = factories.OfType<DictionaryConverterFactory>().Single();
18+
private readonly FileConverterFactory? _fileConverterFactory = factories.OfType<FileConverterFactory>().SingleOrDefault();
1819
private readonly CollectionConverterFactory _collectionFactory = factories.OfType<CollectionConverterFactory>().Single();
1920

2021
[RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)]
@@ -67,6 +68,12 @@ public FormDataTypeMetadata GetOrCreateMetadataFor(Type type, FormDataMapperOpti
6768
return result;
6869
}
6970

71+
if (_fileConverterFactory?.CanConvert(type, options) == true)
72+
{
73+
result.Kind = FormDataTypeKind.File;
74+
return result;
75+
}
76+
7077
if (_dictionaryFactory.CanConvert(type, options))
7178
{
7279
result.Kind = FormDataTypeKind.Dictionary;

src/Components/Endpoints/src/FormMapping/Metadata/FormDataTypeKind.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping.Metadata;
66
internal enum FormDataTypeKind
77
{
88
Primitive,
9+
File,
910
Collection,
1011
Dictionary,
1112
Object,

src/Http/Http.Extensions/src/RequestDelegateFactory.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@ private static RequestDelegateFactoryContext CreateFactoryContext(RequestDelegat
276276
var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices ?? EmptyServiceProvider.Instance;
277277
var endpointBuilder = options?.EndpointBuilder ?? new RdfEndpointBuilder(serviceProvider);
278278
var jsonSerializerOptions = serviceProvider.GetService<IOptions<JsonOptions>>()?.Value.SerializerOptions ?? JsonOptions.DefaultSerializerOptions;
279+
var formDataMapperOptions = serviceProvider.GetService<IHttpContextAccessor>() is {} httpContextAccessor
280+
? new FormDataMapperOptions(httpContextAccessor)
281+
: new FormDataMapperOptions();
279282

280283
var factoryContext = new RequestDelegateFactoryContext
281284
{
@@ -288,6 +291,7 @@ private static RequestDelegateFactoryContext CreateFactoryContext(RequestDelegat
288291
EndpointBuilder = endpointBuilder,
289292
MetadataAlreadyInferred = metadataResult is not null,
290293
JsonSerializerOptions = jsonSerializerOptions,
294+
FormDataMapperOptions = formDataMapperOptions
291295
};
292296

293297
return factoryContext;
@@ -2054,7 +2058,7 @@ private static Expression BindComplexParameterFromFormItem(
20542058
return formArgument;
20552059
}
20562060

2057-
var formDataMapperOptions = new FormDataMapperOptions();
2061+
var formDataMapperOptions = factoryContext.FormDataMapperOptions;
20582062
var formMappingOptionsMetadatas = factoryContext.EndpointBuilder.Metadata.OfType<FormMappingOptionsMetadata>();
20592063
foreach (var formMappingOptionsMetadata in formMappingOptionsMetadatas)
20602064
{
@@ -2073,7 +2077,7 @@ private static Expression BindComplexParameterFromFormItem(
20732077

20742078
// ProcessForm(context.Request.Form, form_dict, form_buffer);
20752079
var processFormExpr = Expression.Call(ProcessFormMethod, FormExpr, Expression.Constant(formDataMapperOptions.MaxKeyBufferSize), formDict, formBuffer);
2076-
// name_reader = new FormDataReader(form_dict, CultureInfo.InvariantCulture, form_buffer.AsMemory(0, FormDataMapperOptions.MaxKeyBufferSize));
2080+
// name_reader = new FormDataReader(form_dict, CultureInfo.InvariantCulture, form_buffer.AsMemory(0, formDataMapperOptions.MaxKeyBufferSize));
20772081
var initializeReaderExpr = Expression.Assign(
20782082
formReader,
20792083
Expression.New(FormDataReaderConstructor,

src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Reflection;
66
using System.Text.Json;
77
using Microsoft.AspNetCore.Builder;
8+
using Microsoft.AspNetCore.Components.Endpoints.FormMapping;
89
using Microsoft.Extensions.DependencyInjection;
910

1011
namespace Microsoft.AspNetCore.Http;
@@ -59,4 +60,6 @@ internal sealed class RequestDelegateFactoryContext
5960

6061
// Grab these options upfront to avoid the per request DI scope that would be made otherwise to get the options when writing Json
6162
public required JsonSerializerOptions JsonSerializerOptions { get; set; }
63+
64+
public required FormDataMapperOptions FormDataMapperOptions { get; set; }
6265
}

src/Http/Http.Extensions/test/RequestDelegateFactoryTests.FormMapping.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using Microsoft.AspNetCore.Http;
66
using Microsoft.AspNetCore.Http.Metadata;
77
using Microsoft.AspNetCore.Testing;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.DependencyInjection.Extensions;
810
using Microsoft.Extensions.Logging;
911
using Microsoft.Extensions.Primitives;
1012

@@ -278,7 +280,58 @@ public async Task SupportsRecursivePropertiesWithRecursionLimit()
278280
var exception = await Assert.ThrowsAsync<BadHttpRequestException>(async () => await requestDelegate(httpContext));
279281

280282
Assert.Equal("The maximum recursion depth of '3' was exceeded for 'Manager.Manager.Manager.Name'.", exception.Message);
283+
}
284+
285+
[Fact]
286+
public async Task SupportsFormFileSourcesInDto()
287+
{
288+
FormFileDto capturedArgument = default;
289+
void TestAction([FromForm] FormFileDto args) { capturedArgument = args; };
290+
var httpContext = CreateHttpContext();
291+
var formFiles = new FormFileCollection
292+
{
293+
new FormFile(Stream.Null, 0, 10, "file", "file.txt"),
294+
};
295+
httpContext.Request.Form = new FormCollection(new() { { "Description", "A test file" } }, formFiles);
296+
var serviceCollection = new ServiceCollection();
297+
serviceCollection.TryAddSingleton<IHttpContextAccessor>(new HttpContextAccessor(httpContext));
298+
var options = new RequestDelegateFactoryOptions
299+
{
300+
ServiceProvider = serviceCollection.BuildServiceProvider()
301+
};
302+
303+
var factoryResult = RequestDelegateFactory.Create(TestAction, options);
304+
var requestDelegate = factoryResult.RequestDelegate;
305+
306+
await requestDelegate(httpContext);
307+
Assert.Equal("A test file", capturedArgument.Description);
308+
Assert.Equal(formFiles["file"], capturedArgument.File);
309+
}
310+
311+
[Fact]
312+
public async Task SupportsFormFileCollectionSourcesInDto()
313+
{
314+
FormFileCollectionDto capturedArgument = default;
315+
void TestAction([FromForm] FormFileCollectionDto args) { capturedArgument = args; };
316+
var httpContext = CreateHttpContext();
317+
var formFiles = new FormFileCollection
318+
{
319+
new FormFile(Stream.Null, 0, 10, "file", "file.txt"),
320+
};
321+
httpContext.Request.Form = new FormCollection(new() { { "Description", "A test file" } }, formFiles);
322+
var serviceCollection = new ServiceCollection();
323+
serviceCollection.TryAddSingleton<IHttpContextAccessor>(new HttpContextAccessor(httpContext));
324+
var options = new RequestDelegateFactoryOptions
325+
{
326+
ServiceProvider = serviceCollection.BuildServiceProvider()
327+
};
281328

329+
var factoryResult = RequestDelegateFactory.Create(TestAction, options);
330+
var requestDelegate = factoryResult.RequestDelegate;
331+
332+
await requestDelegate(httpContext);
333+
Assert.Equal("A test file", capturedArgument.Description);
334+
Assert.Equal(formFiles, capturedArgument.FileCollection);
282335
}
283336

284337
private record TodoRecord(int Id, string Name, bool IsCompleted);
@@ -288,4 +341,26 @@ private class Employee
288341
public string Name { get; set; }
289342
public Employee Manager { get; set; }
290343
}
344+
345+
private class FormFileDto
346+
{
347+
public string Description { get; set; }
348+
public IFormFile File { get; set; }
349+
}
350+
351+
private class FormFileCollectionDto
352+
{
353+
public string Description { get; set; }
354+
public IFormFileCollection FileCollection { get; set; }
355+
}
356+
357+
private class HttpContextAccessor(HttpContext httpContext) : IHttpContextAccessor
358+
{
359+
360+
public HttpContext HttpContext
361+
{
362+
get;
363+
set;
364+
} = httpContext;
365+
}
291366
}

0 commit comments

Comments
 (0)