Skip to content

Commit 5936bd4

Browse files
Component parameters from querystring (#34038)
1 parent 1b819d0 commit 5936bd4

17 files changed

+1160
-131
lines changed

src/Components/Authorization/test/AuthorizeRouteViewTest.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.Components.Authorization
1616
{
1717
public class AuthorizeRouteViewTest
1818
{
19-
private readonly static IReadOnlyDictionary<string, object> EmptyParametersDictionary = new Dictionary<string, object>();
19+
private static readonly IReadOnlyDictionary<string, object> EmptyParametersDictionary = new Dictionary<string, object>();
2020
private readonly TestAuthenticationStateProvider _authenticationStateProvider;
2121
private readonly TestRenderer _renderer;
2222
private readonly RouteView _authorizeRouteViewComponent;
@@ -35,6 +35,7 @@ public AuthorizeRouteViewTest()
3535
serviceCollection.AddSingleton<AuthenticationStateProvider>(_authenticationStateProvider);
3636
serviceCollection.AddSingleton<IAuthorizationPolicyProvider, TestAuthorizationPolicyProvider>();
3737
serviceCollection.AddSingleton<IAuthorizationService>(_testAuthorizationService);
38+
serviceCollection.AddSingleton<NavigationManager, TestNavigationManager>();
3839

3940
_renderer = new TestRenderer(serviceCollection.BuildServiceProvider());
4041
_authorizeRouteViewComponent = new AuthorizeRouteView();
@@ -424,5 +425,9 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
424425
builder.CloseComponent();
425426
}
426427
}
428+
429+
class TestNavigationManager : NavigationManager
430+
{
431+
}
427432
}
428433
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
<IsAspNetCoreApp>true</IsAspNetCoreApp>
88
<Nullable>enable</Nullable>
99
<Trimmable>true</Trimmable>
10+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
1011
</PropertyGroup>
1112

1213
<ItemGroup>
1314
<Compile Include="$(ComponentsSharedSourceRoot)src\ArrayBuilder.cs" LinkBase="RenderTree" />
1415
<Compile Include="$(ComponentsSharedSourceRoot)src\JsonSerializerOptionsProvider.cs" />
1516
<Compile Include="$(ComponentsSharedSourceRoot)src\HotReloadFeature.cs" LinkBase="HotReload" />
1617
<Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
18+
<Compile Include="$(SharedSourceRoot)QueryStringEnumerable.cs" LinkBase="Shared" />
1719
</ItemGroup>
1820

1921
<ItemGroup>

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute
5757
Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute.CascadingTypeParameterAttribute(string! name) -> void
5858
Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute.Name.get -> string!
5959
Microsoft.AspNetCore.Components.RenderTree.Renderer.GetEventArgsType(ulong eventHandlerId) -> System.Type!
60+
Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute
61+
Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.get -> string?
62+
Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.Name.set -> void
63+
Microsoft.AspNetCore.Components.SupplyParameterFromQueryAttribute.SupplyParameterFromQueryAttribute() -> void
6064
abstract Microsoft.AspNetCore.Components.ErrorBoundaryBase.OnErrorAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
6165
override Microsoft.AspNetCore.Components.LayoutComponentBase.SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task!
6266
static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary<string!, object?>! parameters) -> Microsoft.AspNetCore.Components.ParameterView

src/Components/Components/src/Reflection/ComponentProperties.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ namespace Microsoft.AspNetCore.Components.Reflection
1212
{
1313
internal static class ComponentProperties
1414
{
15-
private const BindingFlags _bindablePropertyFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase;
15+
internal const BindingFlags BindablePropertyFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase;
1616

1717
// Right now it's not possible for a component to define a Parameter and a Cascading Parameter with
1818
// the same name. We don't give you a way to express this in code (would create duplicate properties),
1919
// and we don't have the ability to represent it in our data structures.
20-
private readonly static ConcurrentDictionary<Type, WritersForType> _cachedWritersByType
20+
private static readonly ConcurrentDictionary<Type, WritersForType> _cachedWritersByType
2121
= new ConcurrentDictionary<Type, WritersForType>();
2222

2323
public static void ClearCache() => _cachedWritersByType.Clear();
@@ -162,15 +162,15 @@ static void SetProperty(object target, PropertySetter writer, string parameterNa
162162
}
163163

164164
internal static IEnumerable<PropertyInfo> GetCandidateBindableProperties([DynamicallyAccessedMembers(Component)] Type targetType)
165-
=> MemberAssignment.GetPropertiesIncludingInherited(targetType, _bindablePropertyFlags);
165+
=> MemberAssignment.GetPropertiesIncludingInherited(targetType, BindablePropertyFlags);
166166

167167
[DoesNotReturn]
168168
private static void ThrowForUnknownIncomingParameterName([DynamicallyAccessedMembers(Component)] Type targetType,
169169
string parameterName)
170170
{
171171
// We know we're going to throw by this stage, so it doesn't matter that the following
172172
// reflection code will be slow. We're just trying to help developers see what they did wrong.
173-
var propertyInfo = targetType.GetProperty(parameterName, _bindablePropertyFlags);
173+
var propertyInfo = targetType.GetProperty(parameterName, BindablePropertyFlags);
174174
if (propertyInfo != null)
175175
{
176176
if (!propertyInfo.IsDefined(typeof(ParameterAttribute)) && !propertyInfo.IsDefined(typeof(CascadingParameterAttribute)))
@@ -223,7 +223,7 @@ private static void ThrowForCaptureUnmatchedValuesConflict(Type targetType, stri
223223
private static void ThrowForMultipleCaptureUnmatchedValuesParameters([DynamicallyAccessedMembers(Component)] Type targetType)
224224
{
225225
var propertyNames = new List<string>();
226-
foreach (var property in targetType.GetProperties(_bindablePropertyFlags))
226+
foreach (var property in targetType.GetProperties(BindablePropertyFlags))
227227
{
228228
if (property.GetCustomAttribute<ParameterAttribute>()?.CaptureUnmatchedValues == true)
229229
{

src/Components/Components/src/RenderTree/Renderer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ private async void RenderRootComponentsOnHotReload()
130130
// Before re-rendering the root component, also clear any well-known caches in the framework
131131
_componentFactory.ClearCache();
132132
ComponentProperties.ClearCache();
133+
Routing.QueryParameterValueSupplier.ClearCache();
133134

134135
await Dispatcher.InvokeAsync(() =>
135136
{

src/Components/Components/src/RouteView.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
#nullable disable warnings
55

66
using System;
7+
using System.Collections.Generic;
78
using System.Reflection;
89
using System.Threading.Tasks;
910
using Microsoft.AspNetCore.Components.Rendering;
11+
using Microsoft.AspNetCore.Components.Routing;
1012

1113
namespace Microsoft.AspNetCore.Components
1214
{
@@ -20,6 +22,9 @@ public class RouteView : IComponent
2022
private readonly RenderFragment _renderPageWithParametersDelegate;
2123
private RenderHandle _renderHandle;
2224

25+
[Inject]
26+
private NavigationManager NavigationManager { get; set; }
27+
2328
/// <summary>
2429
/// Gets or sets the route data. This determines the page that will be
2530
/// displayed and the parameter values that will be supplied to the page.
@@ -90,6 +95,17 @@ private void RenderPageWithParameters(RenderTreeBuilder builder)
9095
builder.AddAttribute(1, kvp.Key, kvp.Value);
9196
}
9297

98+
var queryParameterSupplier = QueryParameterValueSupplier.ForType(RouteData.PageType);
99+
if (queryParameterSupplier is not null)
100+
{
101+
// Since this component does accept some parameters from query, we must supply values for all of them,
102+
// even if the querystring in the URI is empty. So don't skip the following logic.
103+
var url = NavigationManager.Uri;
104+
var queryStartPos = url.IndexOf('?');
105+
var query = queryStartPos < 0 ? default : url.AsMemory(queryStartPos);
106+
queryParameterSupplier.RenderParametersFromQueryString(builder, query);
107+
}
108+
93109
builder.CloseComponent();
94110
}
95111
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Buffers;
6+
using System.Collections.Generic;
7+
using System.Diagnostics.CodeAnalysis;
8+
using System.Reflection;
9+
using Microsoft.AspNetCore.Components.Reflection;
10+
using Microsoft.AspNetCore.Components.Rendering;
11+
using Microsoft.AspNetCore.Internal;
12+
using static Microsoft.AspNetCore.Internal.LinkerFlags;
13+
14+
namespace Microsoft.AspNetCore.Components.Routing
15+
{
16+
internal sealed class QueryParameterValueSupplier
17+
{
18+
public static void ClearCache() => _cacheByType.Clear();
19+
20+
private static readonly Dictionary<Type, QueryParameterValueSupplier?> _cacheByType = new();
21+
22+
// These two arrays contain the same number of entries, and their corresponding positions refer to each other.
23+
// Holding the info like this means we can use Array.BinarySearch with less custom implementation.
24+
private readonly ReadOnlyMemory<char>[] _queryParameterNames;
25+
private readonly QueryParameterDestination[] _destinations;
26+
27+
public static QueryParameterValueSupplier? ForType([DynamicallyAccessedMembers(Component)] Type componentType)
28+
{
29+
if (!_cacheByType.TryGetValue(componentType, out var instanceOrNull))
30+
{
31+
// If the component doesn't have any query parameters, store a null value for it
32+
// so we know the upstream code can't try to render query parameter frames for it.
33+
var sortedMappings = GetSortedMappings(componentType);
34+
instanceOrNull = sortedMappings == null ? null : new QueryParameterValueSupplier(sortedMappings);
35+
_cacheByType.TryAdd(componentType, instanceOrNull);
36+
}
37+
38+
return instanceOrNull;
39+
}
40+
41+
private QueryParameterValueSupplier(QueryParameterMapping[] sortedMappings)
42+
{
43+
_queryParameterNames = new ReadOnlyMemory<char>[sortedMappings.Length];
44+
_destinations = new QueryParameterDestination[sortedMappings.Length];
45+
for (var i = 0; i < sortedMappings.Length; i++)
46+
{
47+
ref var mapping = ref sortedMappings[i];
48+
_queryParameterNames[i] = mapping.QueryParameterName;
49+
_destinations[i] = mapping.Destination;
50+
}
51+
}
52+
53+
public void RenderParametersFromQueryString(RenderTreeBuilder builder, ReadOnlyMemory<char> queryString)
54+
{
55+
// If there's no querystring contents, we can skip renting from the pool
56+
if (queryString.IsEmpty)
57+
{
58+
for (var destinationIndex = 0; destinationIndex < _destinations.Length; destinationIndex++)
59+
{
60+
ref var destination = ref _destinations[destinationIndex];
61+
var blankValue = destination.IsArray ? destination.Parser.ParseMultiple(default, string.Empty) : null;
62+
builder.AddAttribute(0, destination.ComponentParameterName, blankValue);
63+
}
64+
return;
65+
}
66+
67+
// Temporary workspace in which we accumulate the data while walking the querystring.
68+
var valuesByMapping = ArrayPool<StringSegmentAccumulator>.Shared.Rent(_destinations.Length);
69+
70+
try
71+
{
72+
// Capture values by destination in a single pass through the querystring
73+
var queryStringEnumerable = new QueryStringEnumerable(queryString);
74+
foreach (var suppliedPair in queryStringEnumerable)
75+
{
76+
var decodedName = suppliedPair.DecodeName();
77+
var mappingIndex = Array.BinarySearch(_queryParameterNames, decodedName, QueryParameterNameComparer.Instance);
78+
if (mappingIndex >= 0)
79+
{
80+
var decodedValue = suppliedPair.DecodeValue();
81+
82+
if (_destinations[mappingIndex].IsArray)
83+
{
84+
valuesByMapping[mappingIndex].Add(decodedValue);
85+
}
86+
else
87+
{
88+
valuesByMapping[mappingIndex].SetSingle(decodedValue);
89+
}
90+
}
91+
}
92+
93+
// Finally, emit the parameter attributes by parsing all the string segments and building arrays
94+
for (var mappingIndex = 0; mappingIndex < _destinations.Length; mappingIndex++)
95+
{
96+
ref var destination = ref _destinations[mappingIndex];
97+
ref var values = ref valuesByMapping[mappingIndex];
98+
99+
var parsedValue = destination.IsArray
100+
? destination.Parser.ParseMultiple(values, destination.ComponentParameterName)
101+
: values.Count == 0
102+
? default
103+
: destination.Parser.Parse(values[0].Span, destination.ComponentParameterName);
104+
105+
builder.AddAttribute(0, destination.ComponentParameterName, parsedValue);
106+
}
107+
}
108+
finally
109+
{
110+
ArrayPool<StringSegmentAccumulator>.Shared.Return(valuesByMapping, true);
111+
}
112+
}
113+
114+
private static QueryParameterMapping[]? GetSortedMappings([DynamicallyAccessedMembers(Component)] Type componentType)
115+
{
116+
var candidateProperties = MemberAssignment.GetPropertiesIncludingInherited(componentType, ComponentProperties.BindablePropertyFlags);
117+
HashSet<ReadOnlyMemory<char>>? usedQueryParameterNames = null;
118+
List<QueryParameterMapping>? mappings = null;
119+
120+
foreach (var propertyInfo in candidateProperties)
121+
{
122+
if (!propertyInfo.IsDefined(typeof(ParameterAttribute)))
123+
{
124+
continue;
125+
}
126+
127+
var fromQueryAttribute = propertyInfo.GetCustomAttribute<SupplyParameterFromQueryAttribute>();
128+
if (fromQueryAttribute is not null)
129+
{
130+
// Found a parameter that's assignable from querystring
131+
var componentParameterName = propertyInfo.Name;
132+
var queryParameterName = (string.IsNullOrEmpty(fromQueryAttribute.Name)
133+
? componentParameterName
134+
: fromQueryAttribute.Name).AsMemory();
135+
136+
// If it's an array type, capture that info and prepare to parse the element type
137+
Type effectiveType = propertyInfo.PropertyType;
138+
var isArray = false;
139+
if (effectiveType.IsArray)
140+
{
141+
isArray = true;
142+
effectiveType = effectiveType.GetElementType()!;
143+
}
144+
145+
if (!UrlValueConstraint.TryGetByTargetType(effectiveType, out var parser))
146+
{
147+
throw new NotSupportedException($"Querystring values cannot be parsed as type '{propertyInfo.PropertyType}'.");
148+
}
149+
150+
// Add the destination for this component parameter name
151+
usedQueryParameterNames ??= new(QueryParameterNameComparer.Instance);
152+
if (usedQueryParameterNames.Contains(queryParameterName))
153+
{
154+
throw new InvalidOperationException($"The component '{componentType}' declares more than one mapping for the query parameter '{queryParameterName}'.");
155+
}
156+
usedQueryParameterNames.Add(queryParameterName);
157+
158+
mappings ??= new();
159+
mappings.Add(new QueryParameterMapping
160+
{
161+
QueryParameterName = queryParameterName,
162+
Destination = new QueryParameterDestination(componentParameterName, parser, isArray)
163+
});
164+
}
165+
}
166+
167+
mappings?.Sort((a, b) => QueryParameterNameComparer.Instance.Compare(a.QueryParameterName, b.QueryParameterName));
168+
return mappings?.ToArray();
169+
}
170+
171+
private readonly struct QueryParameterMapping
172+
{
173+
public ReadOnlyMemory<char> QueryParameterName { get; init; }
174+
public QueryParameterDestination Destination { get; init; }
175+
}
176+
177+
private readonly struct QueryParameterDestination
178+
{
179+
public readonly string ComponentParameterName;
180+
public readonly UrlValueConstraint Parser;
181+
public readonly bool IsArray;
182+
183+
public QueryParameterDestination(string componentParameterName, UrlValueConstraint parser, bool isArray)
184+
{
185+
ComponentParameterName = componentParameterName;
186+
Parser = parser;
187+
IsArray = isArray;
188+
}
189+
}
190+
191+
private class QueryParameterNameComparer : IComparer<ReadOnlyMemory<char>>, IEqualityComparer<ReadOnlyMemory<char>>
192+
{
193+
public static readonly QueryParameterNameComparer Instance = new();
194+
195+
public int Compare(ReadOnlyMemory<char> x, ReadOnlyMemory<char> y)
196+
=> x.Span.CompareTo(y.Span, StringComparison.OrdinalIgnoreCase);
197+
198+
public bool Equals(ReadOnlyMemory<char> x, ReadOnlyMemory<char> y)
199+
=> x.Span.Equals(y.Span, StringComparison.OrdinalIgnoreCase);
200+
201+
public int GetHashCode([DisallowNull] ReadOnlyMemory<char> obj)
202+
=> string.GetHashCode(obj.Span, StringComparison.OrdinalIgnoreCase);
203+
}
204+
}
205+
}

0 commit comments

Comments
 (0)