Skip to content

Commit 3863fdb

Browse files
authored
[Blazor] Support for primitive types in [SupplyParameterFromForm] (#48432)
* Adds support for binding primitive types via [SupplyParameterFromForm] * Introduces the internal infrastructure to deal with the binding process.
1 parent b97a36e commit 3863fdb

37 files changed

+847
-47
lines changed

src/Components/Authorization/test/AuthorizeRouteViewTest.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,11 @@ public bool CanBind(string formName, Type valueType)
478478
return false;
479479
}
480480

481+
public bool CanConvertSingleValue(Type type)
482+
{
483+
return false;
484+
}
485+
481486
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
482487
{
483488
boundValue = null;

src/Components/Components/src/Binding/CascadingModelBinder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ internal void UpdateBindingInformation(string url)
114114
var bindingContext = _bindingContext != null &&
115115
string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) &&
116116
string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal) ?
117-
_bindingContext : new ModelBindingContext(name, bindingId);
117+
_bindingContext : new ModelBindingContext(name, bindingId, FormValueSupplier.CanConvertSingleValue);
118118

119119
// It doesn't matter that we don't check IsFixed, since the CascadingValue we are setting up will throw if the app changes.
120120
if (IsFixed && _bindingContext != null && _bindingContext != bindingContext)

src/Components/Components/src/Binding/IFormValueSupplier.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ public interface IFormValueSupplier
1818
/// <returns><c>true</c> if the value type can be bound; otherwise, <c>false</c>.</returns>
1919
bool CanBind(string formName, Type valueType);
2020

21+
/// <summary>
22+
/// Determines whether a given <see cref="Type"/> can be converted from a single string value.
23+
/// For example, strings, numbers, boolean values, enums, guids, etc. fall in this category.
24+
/// </summary>
25+
/// <param name="type">The <see cref="Type"/> to check.</param>
26+
/// <returns><c>true</c> if the type can be converted from a single string value; otherwise, <c>false</c>.</returns>
27+
bool CanConvertSingleValue(Type type);
28+
2129
/// <summary>
2230
/// Tries to bind the form with the specified name to a value of the specified type.
2331
/// </summary>

src/Components/Components/src/Binding/ModelBindingContext.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ namespace Microsoft.AspNetCore.Components;
88
/// </summary>
99
public sealed class ModelBindingContext
1010
{
11-
internal ModelBindingContext(string name, string bindingContextId)
11+
private readonly Predicate<Type> _canBind;
12+
13+
internal ModelBindingContext(string name, string bindingContextId, Predicate<Type> canBind)
1214
{
1315
ArgumentNullException.ThrowIfNull(name);
1416
ArgumentNullException.ThrowIfNull(bindingContextId);
17+
ArgumentNullException.ThrowIfNull(canBind);
1518
// We are initializing the root context, that can be a "named" root context, or the default context.
1619
// A named root context only provides a name, and that acts as the BindingId
1720
// A "default" root context does not provide a name, and instead it provides an explicit Binding ID.
@@ -23,6 +26,7 @@ internal ModelBindingContext(string name, string bindingContextId)
2326

2427
Name = name;
2528
BindingContextId = bindingContextId ?? name;
29+
_canBind = canBind;
2630
}
2731

2832
/// <summary>
@@ -37,4 +41,9 @@ internal ModelBindingContext(string name, string bindingContextId)
3741

3842
internal static string Combine(ModelBindingContext? parentContext, string name) =>
3943
string.IsNullOrEmpty(parentContext?.Name) ? name : $"{parentContext.Name}.{name}";
44+
45+
internal bool CanConvert(Type type)
46+
{
47+
return _canBind(type);
48+
}
4049
}

src/Components/Components/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
abstract Microsoft.AspNetCore.Components.RenderModeAttribute.Mode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode!
33
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier
44
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.CanBind(string! formName, System.Type! valueType) -> bool
5+
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.CanConvertSingleValue(System.Type! type) -> bool
56
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.TryBind(string! formName, System.Type! valueType, out object? boundValue) -> bool
67
Microsoft.AspNetCore.Components.CascadingModelBinder
78
Microsoft.AspNetCore.Components.CascadingModelBinder.CascadingModelBinder() -> void

src/Components/Components/test/CascadingModelBinderTest.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,11 @@ public bool CanBind(string formName, Type valueType)
338338
return false;
339339
}
340340

341+
public bool CanConvertSingleValue(Type type)
342+
{
343+
return false;
344+
}
345+
341346
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
342347
{
343348
boundValue = null;

src/Components/Components/test/CascadingParameterStateTest.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,11 @@ public bool CanBind(string formName, Type valueType)
535535
valueType == ValueType;
536536
}
537537

538+
public bool CanConvertSingleValue(Type type)
539+
{
540+
return type == ValueType;
541+
}
542+
538543
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
539544
{
540545
boundValue = BoundValue;

src/Components/Components/test/ModelBindingContextTest.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,30 @@ public class ModelBindingContextTest
88
[Fact]
99
public void CanCreate_BindingContext_WithDefaultName()
1010
{
11-
var context = new ModelBindingContext("", "");
11+
var context = new ModelBindingContext("", "", t => true);
1212
Assert.Equal("", context.Name);
1313
Assert.Equal("", context.BindingContextId);
1414
}
1515

1616
[Fact]
1717
public void CanCreate_BindingContext_WithName()
1818
{
19-
var context = new ModelBindingContext("name", "path?handler=name");
19+
var context = new ModelBindingContext("name", "path?handler=name", t => true);
2020
Assert.Equal("name", context.Name);
2121
Assert.Equal("path?handler=name", context.BindingContextId);
2222
}
2323

2424
[Fact]
2525
public void Throws_WhenNameIsProvided_AndNoBindingContextId()
2626
{
27-
var exception = Assert.Throws<InvalidOperationException>(() => new ModelBindingContext("name", ""));
27+
var exception = Assert.Throws<InvalidOperationException>(() => new ModelBindingContext("name", "", t => true));
2828
Assert.Equal("A root binding context needs to provide a name and explicit binding context id or none.", exception.Message);
2929
}
3030

3131
[Fact]
3232
public void Throws_WhenBindingContextId_IsProvidedForDefaultName()
3333
{
34-
var exception = Assert.Throws<InvalidOperationException>(() => new ModelBindingContext("", "context"));
34+
var exception = Assert.Throws<InvalidOperationException>(() => new ModelBindingContext("", "context", t => true));
3535
Assert.Equal("A root binding context needs to provide a name and explicit binding context id or none.", exception.Message);
3636
}
3737
}

src/Components/Components/test/RouteViewTest.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,11 @@ public bool CanBind(string formName, Type valueType)
248248
return false;
249249
}
250250

251+
public bool CanConvertSingleValue(Type type)
252+
{
253+
return false;
254+
}
255+
251256
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
252257
{
253258
boundValue = null;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
5+
6+
internal sealed class NullableConverter<T> : FormDataConverter<T?> where T : struct
7+
{
8+
private readonly FormDataConverter<T> _nonNullableConverter;
9+
10+
public NullableConverter(FormDataConverter<T> nonNullableConverter)
11+
{
12+
_nonNullableConverter = nonNullableConverter;
13+
}
14+
15+
internal override bool TryRead(ref FormDataReader context, Type type, FormDataMapperOptions options, out T? result, out bool found)
16+
{
17+
if (!(_nonNullableConverter.TryRead(ref context, type, options, out var innerResult, out found) && found))
18+
{
19+
result = null;
20+
return false;
21+
}
22+
else
23+
{
24+
result = innerResult;
25+
return true;
26+
}
27+
}
28+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
5+
6+
internal sealed class ParsableConverter<T> : FormDataConverter<T>, ISingleValueConverter where T : IParsable<T>
7+
{
8+
internal override bool TryRead(ref FormDataReader reader, Type type, FormDataMapperOptions options, out T? result, out bool found)
9+
{
10+
found = reader.TryGetValue(out var value);
11+
if (found && T.TryParse(value, reader.Culture, out result))
12+
{
13+
return true;
14+
}
15+
else
16+
{
17+
result = default;
18+
return false;
19+
}
20+
}
21+
}
Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,82 @@
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+
using System.Collections.Concurrent;
45
using System.Diagnostics.CodeAnalysis;
6+
using System.Globalization;
7+
using System.Reflection;
58
using Microsoft.AspNetCore.Components.Binding;
9+
using Microsoft.AspNetCore.Components.Endpoints.Binding;
610
using Microsoft.AspNetCore.Components.Forms;
11+
using Microsoft.Extensions.Primitives;
712

813
namespace Microsoft.AspNetCore.Components.Endpoints;
914

10-
internal class DefaultFormValuesSupplier : IFormValueSupplier
15+
internal sealed class DefaultFormValuesSupplier : IFormValueSupplier
1116
{
12-
private readonly FormDataProvider _formData;
17+
private static readonly MethodInfo _method = typeof(DefaultFormValuesSupplier)
18+
.GetMethod(
19+
nameof(DeserializeCore),
20+
BindingFlags.NonPublic | BindingFlags.Static) ??
21+
throw new InvalidOperationException($"Unable to find method '{nameof(DeserializeCore)}'.");
22+
23+
private readonly HttpContextFormDataProvider _formData;
24+
private readonly FormDataMapperOptions _options = new();
25+
private static readonly ConcurrentDictionary<Type, Func<IReadOnlyDictionary<string, StringValues>, FormDataMapperOptions, string, object>> _cache =
26+
new();
1327

1428
public DefaultFormValuesSupplier(FormDataProvider formData)
1529
{
16-
_formData = formData;
30+
_formData = (HttpContextFormDataProvider)formData;
1731
}
1832

1933
public bool CanBind(string formName, Type valueType)
2034
{
2135
return _formData.IsFormDataAvailable &&
2236
string.Equals(formName, _formData.Name, StringComparison.Ordinal) &&
23-
valueType == typeof(string);
37+
_options.ResolveConverter(valueType) != null;
2438
}
2539

2640
public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue)
2741
{
28-
// This will delegate to a proper binder
42+
// This will func to a proper binder
2943
if (!CanBind(formName, valueType))
3044
{
3145
boundValue = null;
3246
return false;
3347
}
3448

35-
if (!_formData.Entries.TryGetValue("value", out var rawValue) || rawValue.Count != 1)
36-
{
37-
boundValue = null;
38-
return false;
39-
}
40-
41-
var valueAsString = rawValue.ToString();
49+
var deserializer = _cache.GetOrAdd(valueType, CreateDeserializer);
4250

43-
if (valueType == typeof(string))
51+
var result = deserializer(_formData.Entries, _options, "value");
52+
if (result != default)
4453
{
45-
boundValue = valueAsString;
54+
// This is not correct, but works for primtive values.
55+
// Will change the interface when we add support for complex types.
56+
boundValue = result;
4657
return true;
4758
}
4859

49-
boundValue = null;
60+
boundValue = valueType.IsValueType ? Activator.CreateInstance(valueType) : null;
5061
return false;
5162
}
63+
64+
private Func<IReadOnlyDictionary<string, StringValues>, FormDataMapperOptions, string, object> CreateDeserializer(Type type) =>
65+
_method.MakeGenericMethod(type)
66+
.CreateDelegate<Func<IReadOnlyDictionary<string, StringValues>, FormDataMapperOptions, string, object>>();
67+
68+
private static object? DeserializeCore<T>(IReadOnlyDictionary<string, StringValues> form, FormDataMapperOptions options, string value)
69+
{
70+
// Form values are parsed according to the culture of the request, which is set to the current culture by the localization middleware.
71+
// Some form input types use the invariant culture when sending the data to the server. For those cases, we'll
72+
// provide a way to override the culture to use to parse that value.
73+
var reader = new FormDataReader(form, CultureInfo.CurrentCulture);
74+
reader.PushPrefix(value);
75+
return FormDataMapper.Map<T>(reader, options);
76+
}
77+
78+
public bool CanConvertSingleValue(Type type)
79+
{
80+
return _options.IsSingleValueConverter(type);
81+
}
5282
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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;
5+
6+
namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
7+
8+
internal sealed class NullableConverterFactory : IFormDataConverterFactory
9+
{
10+
public static readonly NullableConverterFactory Instance = new();
11+
12+
public bool CanConvert(Type type, FormDataMapperOptions options)
13+
{
14+
var underlyingType = Nullable.GetUnderlyingType(type);
15+
return underlyingType != null && options.ResolveConverter(underlyingType) != null;
16+
}
17+
18+
public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options)
19+
{
20+
var underlyingType = Nullable.GetUnderlyingType(type);
21+
Debug.Assert(underlyingType != null);
22+
23+
var underlyingConverter = options.ResolveConverter(underlyingType);
24+
Debug.Assert(underlyingConverter != null);
25+
26+
var expectedConverterType = typeof(NullableConverter<>).MakeGenericType(underlyingType);
27+
Debug.Assert(expectedConverterType != null);
28+
29+
return Activator.CreateInstance(expectedConverterType, underlyingConverter) as FormDataConverter ??
30+
throw new InvalidOperationException($"Unable to create converter for type '{type}'.");
31+
}
32+
}
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 Microsoft.Extensions.Internal;
5+
6+
namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
7+
8+
internal sealed class ParsableConverterFactory : IFormDataConverterFactory
9+
{
10+
public static readonly ParsableConverterFactory Instance = new();
11+
12+
public bool CanConvert(Type type, FormDataMapperOptions options)
13+
{
14+
return ClosedGenericMatcher.ExtractGenericInterface(type, typeof(IParsable<>)) is not null;
15+
}
16+
17+
public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options)
18+
{
19+
return Activator.CreateInstance(typeof(ParsableConverter<>).MakeGenericType(type)) as FormDataConverter ??
20+
throw new InvalidOperationException($"Unable to create converter for '{type.FullName}'.");
21+
}
22+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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+
namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
5+
6+
// Base type for all types that can map from form data to a .NET type.
7+
internal class FormDataConverter
8+
{
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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+
namespace Microsoft.AspNetCore.Components.Endpoints.Binding;
5+
6+
internal abstract class FormDataConverter<T> : FormDataConverter
7+
{
8+
internal abstract bool TryRead(ref FormDataReader context, Type type, FormDataMapperOptions options, out T? result, out bool found);
9+
}

0 commit comments

Comments
 (0)