Skip to content
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
17 changes: 17 additions & 0 deletions src/Components/Authorization/test/AuthorizeRouteViewTest.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// 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.CodeAnalysis;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Binding;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Test.Helpers;
Expand Down Expand Up @@ -32,6 +34,7 @@ public AuthorizeRouteViewTest()
serviceCollection.AddSingleton<IAuthorizationPolicyProvider, TestAuthorizationPolicyProvider>();
serviceCollection.AddSingleton<IAuthorizationService>(_testAuthorizationService);
serviceCollection.AddSingleton<NavigationManager, TestNavigationManager>();
serviceCollection.AddSingleton<IFormValueSupplier, TestFormValueSupplier>();

var services = serviceCollection.BuildServiceProvider();
_renderer = new TestRenderer(services);
Expand Down Expand Up @@ -467,4 +470,18 @@ public TestNavigationManager()
Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash");
}
}

private class TestFormValueSupplier : IFormValueSupplier
{
public bool CanBind(string formName, Type valueType)
{
return false;
}

public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = null;
return false;
}
}
}
65 changes: 60 additions & 5 deletions src/Components/Components/src/Binding/CascadingModelBinder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Reflection.Metadata;
using Microsoft.AspNetCore.Components.Binding;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Routing;

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// Defines the binding context for data bound from external sources.
/// </summary>
public sealed class CascadingModelBinder : IComponent, IDisposable
public sealed class CascadingModelBinder : IComponent, ICascadingValueComponent, IDisposable
{
private RenderHandle _handle;
private ModelBindingContext? _bindingContext;
private bool _hasPendingQueuedRender;
private BindingInfo? _bindingInfo;

/// <summary>
/// The binding context name.
Expand All @@ -35,7 +38,9 @@ public sealed class CascadingModelBinder : IComponent, IDisposable

[CascadingParameter] ModelBindingContext? ParentContext { get; set; }

[Inject] private NavigationManager Navigation { get; set; } = null!;
[Inject] internal NavigationManager Navigation { get; set; } = null!;

[Inject] internal IFormValueSupplier FormValueSupplier { get; set; } = null!;

void IComponent.Attach(RenderHandle renderHandle)
{
Expand Down Expand Up @@ -87,7 +92,7 @@ private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
Render();
}

private void UpdateBindingInformation(string url)
internal void UpdateBindingInformation(string url)
{
// BindingContextId: action parameter used to define the handler
// Name: form name and context used to bind
Expand All @@ -103,11 +108,11 @@ private void UpdateBindingInformation(string url)
// 3) Parent has a name "parent-name"
// Name = "parent-name.my-handler";
// BindingContextId = <<base-relative-uri>>((<<existing-query>>&)|?)handler=my-handler
var name = string.IsNullOrEmpty(ParentContext?.Name) ? Name : $"{ParentContext.Name}.{Name}";
var name = ModelBindingContext.Combine(ParentContext, Name);
var bindingId = string.IsNullOrEmpty(name) ? "" : GenerateBindingContextId(name);

var bindingContext = _bindingContext != null &&
string.Equals(_bindingContext.Name, Name, StringComparison.Ordinal) &&
string.Equals(_bindingContext.Name, name, StringComparison.Ordinal) &&
string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal) ?
_bindingContext : new ModelBindingContext(name, bindingId);

Expand Down Expand Up @@ -136,4 +141,54 @@ void IDisposable.Dispose()
{
Navigation.LocationChanged -= HandleLocationChanged;
}

bool ICascadingValueComponent.CanSupplyValue(Type valueType, string? valueName)
Copy link
Member

@SteveSandersonMS SteveSandersonMS May 26, 2023

Choose a reason for hiding this comment

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

I'm trying to make sense of whether this can interfere with unrelated [CascadingParameter] scenarios. It looks like it might do.

  • Right now I know you've limited this so that FormValueSupplier.CanBind only returns true for string. So it looks like it would interfere with any case where someone has a parameter like [CascadingValue] public string Something { get; set; }. That's a pretty niche scenario, but still would be very weird for a form value to show up.
  • If we want to extend in the future also to binding arbitrary types to model-bind a form post, then maybe it would start interfering with everything as it would automatically want to supply values for all types. I'm not 100% sure about this but it seems possible.

The two main mitigations I could think of are:

  1. We could require these parameters to have a special type, e.g., [SupplyParameterFromForm] public FormData Form { get; set; } (possibly also FormData<T> for model binding other types, which also gives a way to trigger binding procedurally and possibly collect validation errors)
  2. Or, extend the ICascadingValueComponent interface to have another overload of CanSupplyValue which also accepts the attribute on the parameter (i.e., the CascadingParameterAttribute or derived attribute type), and then the logic here can be prefixed with if (thatAttribute is not SupplyParameterFromFormAttribute) return false and we then know for sure it's not interfering with anything else, and we're not incurring any perf cost of instantiating strings for every unrelated named [CascadingParameter] like on line 141.

Copy link
Member Author

Choose a reason for hiding this comment

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

@SteveSandersonMS Mackinnon is working on that part. We'll very likely do 2. Which allows us to filter.

Copy link
Member Author

Choose a reason for hiding this comment

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

I am scoping that part out of my PR.

Copy link
Member

Choose a reason for hiding this comment

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

I find it difficult to review this PR in isolation because it's adding things we know we don't want, so would have to combine it with a follow-up PR to make sense of whether the net result actually adds the right things. If you feel that's definitely the best way to proceed then OK, but my preference would be to split things up into small items which are each individually correct, for example first making the tweak to generalize cascading parameters, then as a follow-up building [SupplyParameterFromForm] on top of that.

{
var formName = string.IsNullOrEmpty(valueName) ?
(_bindingContext?.Name) :
ModelBindingContext.Combine(_bindingContext, valueName);

if (_bindingInfo != null &&
string.Equals(_bindingInfo.FormName, formName, StringComparison.Ordinal) &&
_bindingInfo.ValueType.Equals(valueType))
{
// We already bound the value, but some component might have been destroyed and
// re-created. If the type and name of the value that we bound are the same,
// we can provide the value that we bound.
return true;
}

// Can't supply the value if this context is for a form with a different name.
if (FormValueSupplier.CanBind(formName!, valueType))
{
var bindingSucceeded = FormValueSupplier.TryBind(formName!, valueType, out var boundValue);
_bindingInfo = new BindingInfo(formName, valueType, bindingSucceeded, boundValue);
if (!bindingSucceeded)
{
// Report errors
}

return true;
}

return false;
}

void ICascadingValueComponent.Subscribe(ComponentState subscriber)
{
throw new InvalidOperationException("Form values are always fixed.");
}

void ICascadingValueComponent.Unsubscribe(ComponentState subscriber)
{
throw new InvalidOperationException("Form values are always fixed.");
}

object? ICascadingValueComponent.CurrentValue => _bindingInfo == null ?
throw new InvalidOperationException("Tried to access form value before it was bound.") :
_bindingInfo.BoundValue;

bool ICascadingValueComponent.CurrentValueIsFixed => true;

private record BindingInfo(string? FormName, Type ValueType, bool BindingResult, object? BoundValue);
}
29 changes: 29 additions & 0 deletions src/Components/Components/src/Binding/IFormValueSupplier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 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.CodeAnalysis;

namespace Microsoft.AspNetCore.Components.Binding;

/// <summary>
/// Binds form data valuesto a model.
/// </summary>
public interface IFormValueSupplier
{
/// <summary>
/// Determines whether the specified value type can be bound.
/// </summary>
/// <param name="formName">The form name to bind data from.</param>
/// <param name="valueType">The <see cref="Type"/> for the value to bind.</param>
/// <returns><c>true</c> if the value type can be bound; otherwise, <c>false</c>.</returns>
bool CanBind(string formName, Type valueType);

/// <summary>
/// Tries to bind the form with the specified name to a value of the specified type.
/// </summary>
/// <param name="formName">The form name to bind data from.</param>
/// <param name="valueType">The <see cref="Type"/> for the value to bind.</param>
/// <param name="boundValue">The bound value if succeeded.</param>
/// <returns><c>true</c> if the form was bound successfully; otherwise, <c>false</c>.</returns>
bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object? boundValue);
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,7 @@ internal ModelBindingContext(string name, string bindingContextId)
/// The computed identifier used to determine what parts of the app can bind data.
/// </summary>
public string BindingContextId { get; }

internal static string Combine(ModelBindingContext? parentContext, string name) =>
string.IsNullOrEmpty(parentContext?.Name) ? name : $"{parentContext.Name}.{name}";
}
25 changes: 16 additions & 9 deletions src/Components/Components/src/CascadingParameterState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Components.Reflection;
using Microsoft.AspNetCore.Components.Rendering;
Expand Down Expand Up @@ -45,11 +46,8 @@ public static IReadOnlyList<CascadingParameterState> FindCascadingParameters(Com
var supplier = GetMatchingCascadingValueSupplier(info, componentState);
if (supplier != null)
{
if (resultStates == null)
{
// Although not all parameters might be matched, we know the maximum number
resultStates = new List<CascadingParameterState>(infos.Length - infoIndex);
}
// Although not all parameters might be matched, we know the maximum number
resultStates ??= new List<CascadingParameterState>(infos.Length - infoIndex);

resultStates.Add(new CascadingParameterState(info.ConsumerValueName, supplier));
}
Expand Down Expand Up @@ -98,16 +96,25 @@ private static ReflectedCascadingParameterInfo[] CreateReflectedCascadingParamet
var attribute = prop.GetCustomAttribute<CascadingParameterAttribute>();
if (attribute != null)
{
if (result == null)
{
result = new List<ReflectedCascadingParameterInfo>();
}
result ??= new List<ReflectedCascadingParameterInfo>();

result.Add(new ReflectedCascadingParameterInfo(
prop.Name,
prop.PropertyType,
attribute.Name));
}

var hostParameterAttribute = prop.GetCustomAttributes()
.OfType<IHostEnvironmentCascadingParameter>().SingleOrDefault();
if (hostParameterAttribute != null)
{
result ??= new List<ReflectedCascadingParameterInfo>();

result.Add(new ReflectedCascadingParameterInfo(
prop.Name,
prop.PropertyType,
hostParameterAttribute.Name));
}
}

return result?.ToArray() ?? Array.Empty<ReflectedCascadingParameterInfo>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// 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;

// Marks a cascading parameter that can be offered via an attribute that is not
// directly defined in the Components assembly. For example [SupplyParameterFromForm].
internal interface IHostEnvironmentCascadingParameter
{
public string? Name { get; set; }
}
3 changes: 3 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#nullable enable
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.TryBind(string! formName, System.Type! valueType, out object? boundValue) -> bool
Microsoft.AspNetCore.Components.CascadingModelBinder
Microsoft.AspNetCore.Components.CascadingModelBinder.CascadingModelBinder() -> void
Microsoft.AspNetCore.Components.CascadingModelBinder.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.ModelBindingContext!>!
Expand Down
37 changes: 31 additions & 6 deletions src/Components/Components/src/Reflection/ComponentProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using static Microsoft.AspNetCore.Internal.LinkerFlags;

Expand Down Expand Up @@ -168,11 +169,14 @@ private static void ThrowForUnknownIncomingParameterName([DynamicallyAccessedMem
var propertyInfo = targetType.GetProperty(parameterName, BindablePropertyFlags);
if (propertyInfo != null)
{
if (!propertyInfo.IsDefined(typeof(ParameterAttribute)) && !propertyInfo.IsDefined(typeof(CascadingParameterAttribute)))
if (!propertyInfo.IsDefined(typeof(ParameterAttribute)) &&
!propertyInfo.IsDefined(typeof(CascadingParameterAttribute)) &&
!propertyInfo.GetCustomAttributes().OfType<IHostEnvironmentCascadingParameter>().Any())
{
throw new InvalidOperationException(
$"Object of type '{targetType.FullName}' has a property matching the name '{parameterName}', " +
$"but it does not have [{nameof(ParameterAttribute)}] or [{nameof(CascadingParameterAttribute)}] applied.");
$"but it does not have [{nameof(ParameterAttribute)}], [{nameof(CascadingParameterAttribute)}] or " +
$"[SupplyParameterFromFormAttribute] applied.");
}
else
{
Expand Down Expand Up @@ -257,9 +261,30 @@ public WritersForType([DynamicallyAccessedMembers(Component)] Type targetType)

foreach (var propertyInfo in GetCandidateBindableProperties(targetType))
{
var parameterAttribute = propertyInfo.GetCustomAttribute<ParameterAttribute>();
var cascadingParameterAttribute = propertyInfo.GetCustomAttribute<CascadingParameterAttribute>();
var isParameter = parameterAttribute != null || cascadingParameterAttribute != null;
ParameterAttribute? parameterAttribute = null;
CascadingParameterAttribute? cascadingParameterAttribute = null;
IHostEnvironmentCascadingParameter? hostEnvironmentCascadingParameter = null;

var attributes = propertyInfo.GetCustomAttributes();
foreach (var attribute in attributes)
{
switch (attribute)
{
case ParameterAttribute parameter:
parameterAttribute = parameter;
break;
case CascadingParameterAttribute cascadingParameter:
cascadingParameterAttribute = cascadingParameter;
break;
case IHostEnvironmentCascadingParameter hostEnvironmentAttribute:
hostEnvironmentCascadingParameter = hostEnvironmentAttribute;
break;
default:
break;
}
}

var isParameter = parameterAttribute != null || cascadingParameterAttribute != null || hostEnvironmentCascadingParameter != null;
if (!isParameter)
{
continue;
Expand All @@ -274,7 +299,7 @@ public WritersForType([DynamicallyAccessedMembers(Component)] Type targetType)

var propertySetter = new PropertySetter(targetType, propertyInfo)
{
Cascading = cascadingParameterAttribute != null,
Cascading = cascadingParameterAttribute != null || hostEnvironmentCascadingParameter != null,
};

if (_underlyingWriters.ContainsKey(propertyName))
Expand Down
16 changes: 16 additions & 0 deletions src/Components/Components/test/CascadingModelBinderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components.Binding;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Test.Helpers;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -18,6 +19,7 @@ public CascadingModelBinderTest()
var serviceCollection = new ServiceCollection();
_navigationManager = new TestNavigationManager();
serviceCollection.AddSingleton<NavigationManager>(_navigationManager);
serviceCollection.AddSingleton<IFormValueSupplier, TestFormValueSupplier>();
var services = serviceCollection.BuildServiceProvider();
_renderer = new TestRenderer(services);
}
Expand Down Expand Up @@ -328,4 +330,18 @@ public TestComponent(RenderFragment renderFragment)
protected override void BuildRenderTree(RenderTreeBuilder builder)
=> _renderFragment(builder);
}

private class TestFormValueSupplier : IFormValueSupplier
{
public bool CanBind(string formName, Type valueType)
{
return false;
}

public bool TryBind(string formName, Type valueType, [NotNullWhen(true)] out object boundValue)
{
boundValue = null;
return false;
}
}
}
Loading