dotnet / dotnet/aspnetcore

Expose some internal Blazor APIs to allow customization for form and querystring deserialization

Open
#66,731 0 comments 0 reactions 0 assignees View on GitHub
api-suggestion area-blazor area-minimal
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 6h
Merged PRs (30d)
290

Description

## Background and Motivation

Over the last couple of months I've logged a couple of bugs with the form / query binding in Blazor:
* #66607
* #65010
* #65002

And now I'm having another issue I'm struggling with, and that's the fact that `ParsableConverter` calls `TryParse` with `reader.Culture`, and when `FormDataReader` is instantiated it's hard coded to use `CultureInfo.InvariantCulture` as the culture. This is a problem for me because my website is for New Zealand and Australia and so all the date inputs format, so `InvariantCulture` will incorrectly deserialize the `DateTime` values.

In MVC there's similar issues, but I can create a custom implementation of `IModelBinderProvider` so I can fix it, but the relevant types here are all internal, like `FormDataConverter`, `FormDataConverter` , `ISingleValueConverter` and `FormDataMapperOptions`. If they where public I could create my own implementation and configure `FormDataMapperOptions` to add them.

Another approach I tried was to create my own implementation of `SupplyParameterFromFormAttribute`. From there there's lots of approaches I could take, possibly something crazy like wrapping some calls to `IModelBinderFactory` so my MVC and Blazor code could share serialization behavior, but that's not possible either because `ICascadingValueSupplier` is also internal.

## Proposed API

So I'd like to propose making `ICascadingValueSupplier` public, and either making `FormDataConverter` and `ISingleValueConverter` public, or creating a new interface to implement so `FormDataReader` doesn't need to be exposed publicly also.

## Usage Examples

### Custom FormDataConverter

```csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure(options =>
{
options.Converters[typeof(DateTime)] = new CustomDateTimeConverter();
});

internal class CustomDateTimeConverter : FormDataConverter, ISingleValueConverter
{
private static CultureInfo NZCulture = new CultureInfo("en-NZ");

public bool CanConvertSingleValue() => true;

public bool TryConvertValue(ref FormDataReader reader, string value, out DateTime result)
{
if (DateTime.TryParse(value, NZCulture.DateTimeFormat, out result!))
return true;
else
return false;
}

internal override bool TryRead(ref FormDataReader reader, Type type, FormDataMapperOptions options, out DateTime result, out bool found)
{
found = reader.TryGetValue(out var value);
if (!found)
{
result = default;
return true;
}
else
{
return TryConvertValue(ref reader, value!, out result!);
}
}
}
```

### Custom SupplyParameterFromFormAttribute

``` csharp
public static class CustomSupplyParameterFromFormServiceCollectionExtensions
{
public static IServiceCollection AddCustomSupplyValueFromFormProvider(this IServiceCollection serviceCollection)
{
serviceCollection.AddScoped();

return serviceCollection;
}
}

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class CustomSupplyParameterFromFormAttribute : CascadingParameterAttributeBase
{
}

internal class CustomSupplyParameterFromFormValueProvider : ICascadingValueSupplier
{
private readonly IModelMetadataProvider _metadataProvider;
private readonly FormMappingContext _mappingContext;
private readonly IModelBinderFactory _modelBinderFactory;
private readonly IFormValueMapper? _formValueMapper;

public CustomSupplyParameterFromFormValueProvider(IFormValueMapper? formValueMapper, IModelMetadataProvider metadataProvider, IModelBinderFactory modelBinderFactory)
{
_metadataProvider = metadataProvider;
_mappingContext = new FormMappingContext("");
_modelBinderFactory = modelBinderFactory;
_formValueMapper = formValueMapper;
}

bool ICascadingValueSupplier.IsFixed => true;

public bool CanSupplyValue(in CascadingParameterInfo parameterInfo)
{
if (_formValueMapper is not null && parameterInfo.Attribute is CustomSupplyParameterFromFormAttribute)
return _formValueMapper.CanMap(parameterInfo.PropertyType, "", "");

return false;
}

public object? GetCurrentValue(object? key, in CascadingParameterInfo parameterInfo)
{
var modelMetadata = _metadataProvider.GetMetadataForType(parameterInfo.PropertyType);

var valueProvider = new FormMappingContextValueProvider(_mappingContext);

var actionContext = new ActionContext()
{
HttpContext = new DefaultHttpContext(),
RouteData = new Microsoft.AspNetCore.Routing.RouteData(),
ActionDescriptor = new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor()
};

var modelBindingContext = DefaultModelBindingContext.CreateBindingContext(
actionContext,
valueProvider,
modelMetadata,
bindingInfo: null,
modelName: string.Empty);

var result = modelMetadata.ModelType.IsValueType ? Activator.CreateInstance(modelMetadata.ModelType) : null;

modelBindingContext.Model = result;

var factoryContext = new ModelBinderFactoryContext()
{
Metadata = modelMetadata,
BindingInfo = new BindingInfo()
{
BinderModelName = modelMetadata.BinderModelName,
BinderType = modelMetadata.BinderType,
BindingSource = modelMetadata.BindingSource,
PropertyFilterProvider = modelMetadata.PropertyFilterProvider,
},

CacheToken = modelMetadata,
};

var binder = _modelBinderFactory.CreateBinder(factoryContext);

binder.BindModelAsync(modelBindingContext).Wait();

if (modelBindingContext.ModelState.Count > 0)
return result;

return default;
}

void ICascadingValueSupplier.Subscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo)
=> throw new NotSupportedException(); // IsFixed = true, so the framework won't call this

void ICascadingValueSupplier.Unsubscribe(ComponentState subscriber, in CascadingParameterInfo parameterInfo)
=> throw new NotSupportedException(); // IsFixed = true, so the framework won't call this
}
```

## Alternative Designs

I think there's a lot of ways that these could be done, new interfaces so not so many internals are exposed.

## Risks

I'm unsure about how the internals of the form deserialization full works, especially when enhanced navigation is involved, so exposing these things might be much more complicated than I think, and it could be too complex to allow people to create their own implantations that don't break things entirely

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.