Skip to content

Commit 2ac6452

Browse files
authored
Support resolving form files in complex form mapping (#50537)
* Support resolving IFormFile in complex form mapping * Feedback * Fix up FormFile integration in Blazor * Fix up FileConverter interfaces * Fix build * Fix FormFileCollection initialization * Revert "Revert "[Blazor] Update selenium versions (#50511)" (#50556)" This reverts commit 564a94d. * Update test for non-enhanced form * Revert "Revert "Revert "[Blazor] Update selenium versions (#50511)" (#50556)"" This reverts commit 78bf7d8. * Add support for IReadOnlyList<IBrowserFile>
1 parent 2595235 commit 2ac6452

20 files changed

+559
-16
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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.Globalization;
5+
using Microsoft.AspNetCore.Components.Forms;
6+
using Microsoft.AspNetCore.Http;
7+
8+
namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping;
9+
10+
internal sealed class BrowserFileFromFormFile(IFormFile formFile) : IBrowserFile
11+
{
12+
public string Name => formFile.Name;
13+
14+
public DateTimeOffset LastModified => DateTimeOffset.Parse(formFile.Headers.LastModified.ToString(), CultureInfo.InvariantCulture);
15+
16+
public long Size => formFile.Length;
17+
18+
public string ContentType => formFile.ContentType;
19+
20+
public Stream OpenReadStream(long maxAllowedSize = 512000, CancellationToken cancellationToken = default)
21+
{
22+
if (Size > maxAllowedSize)
23+
{
24+
throw new IOException($"Supplied file with size {Size} bytes exceeds the maximum of {maxAllowedSize} bytes.");
25+
}
26+
27+
return formFile.OpenReadStream();
28+
}
29+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
#if COMPONENTS
6+
using Microsoft.AspNetCore.Components.Forms;
7+
#endif
8+
using Microsoft.AspNetCore.Http;
9+
10+
namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping;
11+
12+
internal sealed class FileConverter<T> : FormDataConverter<T>
13+
{
14+
[RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)]
15+
[RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)]
16+
internal override bool TryRead(ref FormDataReader reader, Type type, FormDataMapperOptions options, out T? result, out bool found)
17+
{
18+
if (reader.FormFileCollection == null)
19+
{
20+
result = default;
21+
found = false;
22+
return true;
23+
}
24+
25+
#if COMPONENTS
26+
if (typeof(T) == typeof(IBrowserFile))
27+
{
28+
var targetFile = reader.FormFileCollection.GetFile(reader.CurrentPrefix.ToString());
29+
if (targetFile != null)
30+
{
31+
var browserFile = new BrowserFileFromFormFile(targetFile);
32+
result = (T)(IBrowserFile)browserFile;
33+
found = true;
34+
return true;
35+
}
36+
}
37+
38+
if (typeof(T) == typeof(IReadOnlyList<IBrowserFile>))
39+
{
40+
var targetFiles = reader.FormFileCollection.GetFiles(reader.CurrentPrefix.ToString());
41+
var buffer = ReadOnlyCollectionBufferAdapter<IBrowserFile>.CreateBuffer();
42+
for (var i = 0; i < targetFiles.Count; i++)
43+
{
44+
buffer = ReadOnlyCollectionBufferAdapter<IBrowserFile>.Add(ref buffer, new BrowserFileFromFormFile(targetFiles[i]));
45+
}
46+
result = (T)(IReadOnlyList<IBrowserFile>)ReadOnlyCollectionBufferAdapter<IBrowserFile>.ToResult(buffer);
47+
found = true;
48+
return true;
49+
}
50+
#endif
51+
52+
if (typeof(T) == typeof(IReadOnlyList<IFormFile>))
53+
{
54+
result = (T)reader.FormFileCollection.GetFiles(reader.CurrentPrefix.ToString());
55+
found = true;
56+
return true;
57+
}
58+
59+
if (typeof(T) == typeof(IFormFileCollection))
60+
{
61+
result = (T)reader.FormFileCollection;
62+
found = true;
63+
return true;
64+
}
65+
66+
var formFileCollection = reader.FormFileCollection;
67+
if (formFileCollection.Count == 0)
68+
{
69+
result = default;
70+
found = false;
71+
return true;
72+
}
73+
74+
var file = formFileCollection.GetFile(reader.CurrentPrefix.ToString());
75+
if (file != null)
76+
{
77+
result = (T)file;
78+
found = true;
79+
return true;
80+
}
81+
82+
result = default;
83+
found = false;
84+
return true;
85+
}
86+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
#if COMPONENTS
6+
using Microsoft.AspNetCore.Components.Forms;
7+
#endif
8+
using Microsoft.AspNetCore.Http;
9+
10+
namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping;
11+
12+
internal sealed class FileConverterFactory : IFormDataConverterFactory
13+
{
14+
[RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)]
15+
[RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)]
16+
#if COMPONENTS
17+
public bool CanConvert(Type type, FormDataMapperOptions options) => CanConvertCommon(type) || type == typeof(IBrowserFile) || type == typeof(IReadOnlyList<IBrowserFile>);
18+
#else
19+
public bool CanConvert(Type type, FormDataMapperOptions options) => CanConvertCommon(type);
20+
#endif
21+
22+
private static bool CanConvertCommon(Type type) => type == typeof(IFormFile) || type == typeof(IFormFileCollection) || type == typeof(IReadOnlyList<IFormFile>);
23+
24+
[RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)]
25+
[RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)]
26+
public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options)
27+
{
28+
return Activator.CreateInstance(typeof(FileConverter<>).MakeGenericType(type)) as FormDataConverter ??
29+
throw new InvalidOperationException($"Unable to create converter for '{type.FullName}'.");
30+
}
31+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public FormDataMapperOptions(ILoggerFactory loggerFactory)
2626
{
2727
_converters = new(WellKnownConverters.Converters);
2828
_factories.Add(new ParsableConverterFactory());
29+
_factories.Add(new FileConverterFactory());
2930
_factories.Add(new EnumConverterFactory());
3031
_factories.Add(new NullableConverterFactory());
3132
_factories.Add(new DictionaryConverterFactory());

src/Components/Endpoints/src/FormMapping/FormDataReader.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Diagnostics.CodeAnalysis;
77
using System.Globalization;
88
using System.Runtime.CompilerServices;
9+
using Microsoft.AspNetCore.Http;
910
using Microsoft.Extensions.Primitives;
1011

1112
namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping;
@@ -33,9 +34,17 @@ public FormDataReader(IReadOnlyDictionary<FormKey, StringValues> formCollection,
3334
_prefixBuffer = buffer;
3435
}
3536

37+
public FormDataReader(IReadOnlyDictionary<FormKey, StringValues> formCollection, CultureInfo culture, Memory<char> buffer, IFormFileCollection formFileCollection)
38+
: this(formCollection, culture, buffer)
39+
{
40+
FormFileCollection = formFileCollection;
41+
}
42+
3643
internal ReadOnlyMemory<char> CurrentPrefix => _currentPrefixBuffer;
3744

38-
public IFormatProvider Culture { get; internal set; }
45+
public IFormatProvider Culture { get; }
46+
47+
public IFormFileCollection? FormFileCollection { get; internal set; }
3948

4049
public int MaxRecursionDepth { get; set; } = 64;
4150

src/Components/Endpoints/src/FormMapping/HttpContextFormDataProvider.cs

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

44
using System.Collections.ObjectModel;
55
using System.Diagnostics.CodeAnalysis;
6+
using Microsoft.AspNetCore.Http;
67
using Microsoft.Extensions.Primitives;
78

89
namespace Microsoft.AspNetCore.Components.Endpoints;
@@ -11,15 +12,19 @@ internal sealed class HttpContextFormDataProvider
1112
{
1213
private string? _incomingHandlerName;
1314
private IReadOnlyDictionary<string, StringValues>? _entries;
15+
private IFormFileCollection? _formFiles;
1416

1517
public string? IncomingHandlerName => _incomingHandlerName;
1618

1719
public IReadOnlyDictionary<string, StringValues> Entries => _entries ?? ReadOnlyDictionary<string, StringValues>.Empty;
1820

19-
public void SetFormData(string incomingHandlerName, IReadOnlyDictionary<string, StringValues> form)
21+
public IFormFileCollection FormFiles => _formFiles ?? (IFormFileCollection)FormCollection.Empty;
22+
23+
public void SetFormData(string incomingHandlerName, IReadOnlyDictionary<string, StringValues> form, IFormFileCollection formFiles)
2024
{
2125
_incomingHandlerName = incomingHandlerName;
2226
_entries = form;
27+
_formFiles = formFiles;
2328
}
2429

2530
public bool TryGetIncomingHandlerName([NotNullWhen(true)] out string? incomingHandlerName)

src/Components/Endpoints/src/FormMapping/HttpContextFormValueMapper.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Globalization;
99
using Microsoft.AspNetCore.Components.Endpoints.FormMapping;
1010
using Microsoft.AspNetCore.Components.Forms.Mapping;
11+
using Microsoft.AspNetCore.Http;
1112
using Microsoft.Extensions.Options;
1213
using Microsoft.Extensions.Primitives;
1314

@@ -85,7 +86,7 @@ public void Map(FormValueMappingContext context)
8586

8687
var deserializer = _cache.GetOrAdd(context.ValueType, CreateDeserializer);
8788
Debug.Assert(deserializer != null);
88-
deserializer.Deserialize(context, _options, _formData.Entries);
89+
deserializer.Deserialize(context, _options, _formData.Entries, _formData.FormFiles);
8990
}
9091

9192
private FormValueSupplier CreateDeserializer(Type type) =>
@@ -99,7 +100,8 @@ internal abstract class FormValueSupplier
99100
public abstract void Deserialize(
100101
FormValueMappingContext context,
101102
FormDataMapperOptions options,
102-
IReadOnlyDictionary<string, StringValues> form);
103+
IReadOnlyDictionary<string, StringValues> form,
104+
IFormFileCollection formFiles);
103105
}
104106

105107
internal class FormValueSupplier<T> : FormValueSupplier
@@ -109,7 +111,8 @@ internal class FormValueSupplier<T> : FormValueSupplier
109111
public override void Deserialize(
110112
FormValueMappingContext context,
111113
FormDataMapperOptions options,
112-
IReadOnlyDictionary<string, StringValues> form)
114+
IReadOnlyDictionary<string, StringValues> form,
115+
IFormFileCollection formFiles)
113116
{
114117
if (form.Count == 0)
115118
{
@@ -129,7 +132,8 @@ public override void Deserialize(
129132
using var reader = new FormDataReader(
130133
dictionary,
131134
CultureInfo.InvariantCulture,
132-
buffer.AsMemory(0, options.MaxKeyBufferSize))
135+
buffer.AsMemory(0, options.MaxKeyBufferSize),
136+
formFiles)
133137
{
134138
ErrorHandler = context.OnError,
135139
AttachInstanceToErrorsHandler = context.MapErrorToContainer,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ internal partial class FormDataMetadataFactory(List<IFormDataConverterFactory> f
1717
private readonly FormMetadataContext _context = new();
1818
private readonly ParsableConverterFactory _parsableFactory = factories.OfType<ParsableConverterFactory>().Single();
1919
private readonly DictionaryConverterFactory _dictionaryFactory = factories.OfType<DictionaryConverterFactory>().Single();
20+
private readonly FileConverterFactory _fileConverterFactory = factories.OfType<FileConverterFactory>().Single();
2021
private readonly CollectionConverterFactory _collectionFactory = factories.OfType<CollectionConverterFactory>().Single();
2122
private readonly ILogger<FormDataMetadataFactory> _logger = loggerFactory.CreateLogger<FormDataMetadataFactory>();
2223

@@ -86,6 +87,12 @@ internal partial class FormDataMetadataFactory(List<IFormDataConverterFactory> f
8687
return result;
8788
}
8889

90+
if (_fileConverterFactory.CanConvert(type, options))
91+
{
92+
result.Kind = FormDataTypeKind.File;
93+
return result;
94+
}
95+
8996
if (_dictionaryFactory.CanConvert(type, options))
9097
{
9198
Log.DictionaryType(_logger, type);

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/Components/Endpoints/src/FormMapping/WellKnownConverters.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
#if COMPONENTS
5+
using Microsoft.AspNetCore.Components.Forms;
6+
#endif
7+
using Microsoft.AspNetCore.Http;
8+
49
namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping;
510

611
internal static class WellKnownConverters
@@ -37,7 +42,14 @@ static WellKnownConverters()
3742
{ typeof(DateTimeOffset), new ParsableConverter<DateTimeOffset>() },
3843
{ typeof(TimeSpan), new ParsableConverter<TimeSpan>() },
3944
{ typeof(TimeOnly), new ParsableConverter<TimeOnly>() },
40-
{ typeof(Guid), new ParsableConverter<Guid>() }
45+
{ typeof(Guid), new ParsableConverter<Guid>() },
46+
{ typeof(IFormFileCollection), new FileConverter<IFormFileCollection>() },
47+
{ typeof(IFormFile), new FileConverter<IFormFile>() },
48+
{ typeof(IReadOnlyList<IFormFile>), new FileConverter<IReadOnlyList<IFormFile>>() },
49+
#if COMPONENTS
50+
{ typeof(IBrowserFile), new FileConverter<IBrowserFile>() },
51+
{ typeof(IReadOnlyList<IBrowserFile>), new FileConverter<IReadOnlyList<IBrowserFile>>() }
52+
#endif
4153
};
4254

4355
converters.Add(typeof(char?), new NullableConverter<char>((FormDataConverter<char>)converters[typeof(char)]));

src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<IsPackable>false</IsPackable>
1111
<EmbeddedFilesManifestFileName>Microsoft.Extensions.FileProviders.Embedded.Manifest.xml</EmbeddedFilesManifestFileName>
1212
<Nullable>enable</Nullable>
13+
<DefineConstants>$(DefineConstants);COMPONENTS</DefineConstants>
1314
</PropertyGroup>
1415

1516
<!-- This workaround is required when referencing FileProviders.Embedded as a project -->

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ internal static async Task InitializeStandardComponentServicesAsync(
8484
if (handler != null && form != null)
8585
{
8686
httpContext.RequestServices.GetRequiredService<HttpContextFormDataProvider>()
87-
.SetFormData(handler, new FormCollectionReadOnlyDictionary(form));
87+
.SetFormData(handler, new FormCollectionReadOnlyDictionary(form), form.Files);
8888
}
8989

9090
if (httpContext.RequestServices.GetService<AntiforgeryStateProvider>() is EndpointAntiforgeryStateProvider antiforgery)

0 commit comments

Comments
 (0)