Skip to content

[Blazor] Support for primitive types in [SupplyParameterFromForm] #48432

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

Merged
merged 9 commits into from
Jun 7, 2023
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
5 changes: 5 additions & 0 deletions src/Components/Authorization/test/AuthorizeRouteViewTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,11 @@ public bool CanBind(string formName, Type valueType)
return false;
}

public bool CanConvertSingleValue(Type type)
{
return false;
}

public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ internal void UpdateBindingInformation(string url)
var bindingContext = _bindingContext != null &&
string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) &&
string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal) ?
_bindingContext : new ModelBindingContext(name, bindingId);
_bindingContext : new ModelBindingContext(name, bindingId, FormValueSupplier.CanConvertSingleValue);

// It doesn't matter that we don't check IsFixed, since the CascadingValue we are setting up will throw if the app changes.
if (IsFixed && _bindingContext != null && _bindingContext != bindingContext)
Expand Down
8 changes: 8 additions & 0 deletions src/Components/Components/src/Binding/IFormValueSupplier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ public interface IFormValueSupplier
/// <returns><c>true</c> if the value type can be bound; otherwise, <c>false</c>.</returns>
bool CanBind(string formName, Type valueType);

/// <summary>
/// Determines whether a given <see cref="Type"/> can be converted from a single string value.
/// For example, strings, numbers, boolean values, enums, guids, etc. fall in this category.
/// </summary>
/// <param name="type">The <see cref="Type"/> to check.</param>
/// <returns><c>true</c> if the type can be converted from a single string value; otherwise, <c>false</c>.</returns>
bool CanConvertSingleValue(Type type);

/// <summary>
/// Tries to bind the form with the specified name to a value of the specified type.
/// </summary>
Expand Down
11 changes: 10 additions & 1 deletion src/Components/Components/src/Binding/ModelBindingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ namespace Microsoft.AspNetCore.Components;
/// </summary>
public sealed class ModelBindingContext
{
internal ModelBindingContext(string name, string bindingContextId)
private readonly Predicate<Type> _canBind;

internal ModelBindingContext(string name, string bindingContextId, Predicate<Type> canBind)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(bindingContextId);
ArgumentNullException.ThrowIfNull(canBind);
// We are initializing the root context, that can be a "named" root context, or the default context.
// A named root context only provides a name, and that acts as the BindingId
// A "default" root context does not provide a name, and instead it provides an explicit Binding ID.
Expand All @@ -23,6 +26,7 @@ internal ModelBindingContext(string name, string bindingContextId)

Name = name;
BindingContextId = bindingContextId ?? name;
_canBind = canBind;
}

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

internal static string Combine(ModelBindingContext? parentContext, string name) =>
string.IsNullOrEmpty(parentContext?.Name) ? name : $"{parentContext.Name}.{name}";

internal bool CanConvert(Type type)
{
return _canBind(type);
}
}
1 change: 1 addition & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
abstract Microsoft.AspNetCore.Components.RenderModeAttribute.Mode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode!
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.CanBind(string! formName, System.Type! valueType) -> bool
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.CanConvertSingleValue(System.Type! type) -> bool
Microsoft.AspNetCore.Components.Binding.IFormValueSupplier.TryBind(string! formName, System.Type! valueType, out object? boundValue) -> bool
Microsoft.AspNetCore.Components.CascadingModelBinder
Microsoft.AspNetCore.Components.CascadingModelBinder.CascadingModelBinder() -> void
Expand Down
5 changes: 5 additions & 0 deletions src/Components/Components/test/CascadingModelBinderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@ public bool CanBind(string formName, Type valueType)
return false;
}

public bool CanConvertSingleValue(Type type)
{
return false;
}

public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,11 @@ public bool CanBind(string formName, Type valueType)
valueType == ValueType;
}

public bool CanConvertSingleValue(Type type)
{
return type == ValueType;
}

public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = BoundValue;
Expand Down
8 changes: 4 additions & 4 deletions src/Components/Components/test/ModelBindingContextTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,30 @@ public class ModelBindingContextTest
[Fact]
public void CanCreate_BindingContext_WithDefaultName()
{
var context = new ModelBindingContext("", "");
var context = new ModelBindingContext("", "", t => true);
Assert.Equal("", context.Name);
Assert.Equal("", context.BindingContextId);
}

[Fact]
public void CanCreate_BindingContext_WithName()
{
var context = new ModelBindingContext("name", "path?handler=name");
var context = new ModelBindingContext("name", "path?handler=name", t => true);
Assert.Equal("name", context.Name);
Assert.Equal("path?handler=name", context.BindingContextId);
}

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

[Fact]
public void Throws_WhenBindingContextId_IsProvidedForDefaultName()
{
var exception = Assert.Throws<InvalidOperationException>(() => new ModelBindingContext("", "context"));
var exception = Assert.Throws<InvalidOperationException>(() => new ModelBindingContext("", "context", t => true));
Assert.Equal("A root binding context needs to provide a name and explicit binding context id or none.", exception.Message);
}
}
5 changes: 5 additions & 0 deletions src/Components/Components/test/RouteViewTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ public bool CanBind(string formName, Type valueType)
return false;
}

public bool CanConvertSingleValue(Type type)
{
return false;
}

public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Endpoints.Binding;

internal sealed class NullableConverter<T> : FormDataConverter<T?> where T : struct
{
private readonly FormDataConverter<T> _nonNullableConverter;

public NullableConverter(FormDataConverter<T> nonNullableConverter)
{
_nonNullableConverter = nonNullableConverter;
}

internal override bool TryRead(ref FormDataReader context, Type type, FormDataMapperOptions options, out T? result, out bool found)
{
if (!(_nonNullableConverter.TryRead(ref context, type, options, out var innerResult, out found) && found))
{
result = null;
return false;
}
else
{
result = innerResult;
return true;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Endpoints.Binding;

internal sealed class ParsableConverter<T> : FormDataConverter<T>, ISingleValueConverter where T : IParsable<T>
{
internal override bool TryRead(ref FormDataReader reader, Type type, FormDataMapperOptions options, out T? result, out bool found)
{
found = reader.TryGetValue(out var value);
if (found && T.TryParse(value, reader.Culture, out result))
{
return true;
}
else
{
result = default;
return false;
}
}
}
60 changes: 45 additions & 15 deletions src/Components/Endpoints/src/Binding/DefaultFormValuesSupplier.cs
Original file line number Diff line number Diff line change
@@ -1,52 +1,82 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Reflection;
using Microsoft.AspNetCore.Components.Binding;
using Microsoft.AspNetCore.Components.Endpoints.Binding;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Primitives;

namespace Microsoft.AspNetCore.Components.Endpoints;

internal class DefaultFormValuesSupplier : IFormValueSupplier
internal sealed class DefaultFormValuesSupplier : IFormValueSupplier
{
private readonly FormDataProvider _formData;
private static readonly MethodInfo _method = typeof(DefaultFormValuesSupplier)
.GetMethod(
nameof(DeserializeCore),
BindingFlags.NonPublic | BindingFlags.Static) ??
throw new InvalidOperationException($"Unable to find method '{nameof(DeserializeCore)}'.");

private readonly HttpContextFormDataProvider _formData;
private readonly FormDataMapperOptions _options = new();
private static readonly ConcurrentDictionary<Type, Func<IReadOnlyDictionary<string, StringValues>, FormDataMapperOptions, string, object>> _cache =
new();

public DefaultFormValuesSupplier(FormDataProvider formData)
{
_formData = formData;
_formData = (HttpContextFormDataProvider)formData;
}

public bool CanBind(string formName, Type valueType)
{
return _formData.IsFormDataAvailable &&
string.Equals(formName, _formData.Name, StringComparison.Ordinal) &&
valueType == typeof(string);
_options.ResolveConverter(valueType) != null;
}

public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue)
{
// This will delegate to a proper binder
// This will func to a proper binder
if (!CanBind(formName, valueType))
{
boundValue = null;
return false;
}

if (!_formData.Entries.TryGetValue("value", out var rawValue) || rawValue.Count != 1)
{
boundValue = null;
return false;
}

var valueAsString = rawValue.ToString();
var deserializer = _cache.GetOrAdd(valueType, CreateDeserializer);

if (valueType == typeof(string))
var result = deserializer(_formData.Entries, _options, "value");
if (result != default)
{
boundValue = valueAsString;
// This is not correct, but works for primtive values.
// Will change the interface when we add support for complex types.
boundValue = result;
return true;
}

boundValue = null;
boundValue = valueType.IsValueType ? Activator.CreateInstance(valueType) : null;
return false;
}

private Func<IReadOnlyDictionary<string, StringValues>, FormDataMapperOptions, string, object> CreateDeserializer(Type type) =>
_method.MakeGenericMethod(type)
.CreateDelegate<Func<IReadOnlyDictionary<string, StringValues>, FormDataMapperOptions, string, object>>();

private static object? DeserializeCore<T>(IReadOnlyDictionary<string, StringValues> form, FormDataMapperOptions options, string value)
{
// Form values are parsed according to the culture of the request, which is set to the current culture by the localization middleware.
// Some form input types use the invariant culture when sending the data to the server. For those cases, we'll
// provide a way to override the culture to use to parse that value.
var reader = new FormDataReader(form, CultureInfo.CurrentCulture);
reader.PushPrefix(value);
return FormDataMapper.Map<T>(reader, options);
}

public bool CanConvertSingleValue(Type type)
{
return _options.IsSingleValueConverter(type);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;

namespace Microsoft.AspNetCore.Components.Endpoints.Binding;

internal sealed class NullableConverterFactory : IFormDataConverterFactory
{
public static readonly NullableConverterFactory Instance = new();

public bool CanConvert(Type type, FormDataMapperOptions options)
{
var underlyingType = Nullable.GetUnderlyingType(type);
return underlyingType != null && options.ResolveConverter(underlyingType) != null;
}

public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options)
{
var underlyingType = Nullable.GetUnderlyingType(type);
Debug.Assert(underlyingType != null);

var underlyingConverter = options.ResolveConverter(underlyingType);
Debug.Assert(underlyingConverter != null);

var expectedConverterType = typeof(NullableConverter<>).MakeGenericType(underlyingType);
Debug.Assert(expectedConverterType != null);

return Activator.CreateInstance(expectedConverterType, underlyingConverter) as FormDataConverter ??
throw new InvalidOperationException($"Unable to create converter for type '{type}'.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Extensions.Internal;

namespace Microsoft.AspNetCore.Components.Endpoints.Binding;

internal sealed class ParsableConverterFactory : IFormDataConverterFactory
{
public static readonly ParsableConverterFactory Instance = new();

public bool CanConvert(Type type, FormDataMapperOptions options)
{
return ClosedGenericMatcher.ExtractGenericInterface(type, typeof(IParsable<>)) is not null;
}

public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options)
{
return Activator.CreateInstance(typeof(ParsableConverter<>).MakeGenericType(type)) as FormDataConverter ??
throw new InvalidOperationException($"Unable to create converter for '{type.FullName}'.");
}
}
9 changes: 9 additions & 0 deletions src/Components/Endpoints/src/Binding/FormDataConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Endpoints.Binding;

// Base type for all types that can map from form data to a .NET type.
internal class FormDataConverter
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Endpoints.Binding;

internal abstract class FormDataConverter<T> : FormDataConverter
{
internal abstract bool TryRead(ref FormDataReader context, Type type, FormDataMapperOptions options, out T? result, out bool found);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is type ever going to be different from typeof(T)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not obvious yet in this PR, but it's passed in for a different reason. When you do typeof(T) the generated code needs to ask the type system at runtime (this is in IL) for the type instance, which involves a lookup. For that reason, many serializers cache and pass the type instance along.

If you look at System.Text.Json, they do something similar. See here

The other part is that we might still need this if in the future we want to support polymorphic binding or enable people to do so, so we can't just assume typeof(T) == type in general.

}
Loading