Skip to content

Commit 90aa229

Browse files
committed
Add NotRouteConstraint implementation and associated tests
Implements a new route constraint that provides logical negation capabilities for existing route constraints. This enables developers to create routes that match when specific constraints do NOT match. Key Features: - Single constraint negation: not(int) matches non-integers - Multiple constraint negation: not(int;bool) matches neither integers nor booleans - Nested negation support: not(not(int)) for double negation (equivalent to int) - Parameterized constraints: not(min(18)) for values under 18 or non-numeric - File extension negation: not(file) for values without file extensions - String pattern negation: not(alpha) for non-alphabetic values and other patterns Examples: - /users/{id:not(int)} - matches /users/john but not /users/123 - /files/{name:not(file)} - matches /files/readme but not /files/doc.txt - /ages/{age:not(min(18))} - matches /ages/16 but not /ages/25 - /values/{val:not(int;bool)} - matches /values/text but not /values/123 or /values/true Implementation: - Added NotRouteConstraint class with XML documentation - Supports both IRouteConstraint and IParameterLiteralNodeMatchingPolicy - Added to RouteOptions default constraint map with 'not' token Testing: - Tests for single/multiple constraints, nested negation, edge cases - Integration tests with HttpContext and service provider scenarios - Validation of argument null exceptions and error handling - Performance tests for literal matching optimization This enables more flexible route patterns by allowing inverse constraint logic. It can also be used to escape internal implementation details when building route templates.
1 parent be33d3b commit 90aa229

File tree

4 files changed

+826
-0
lines changed

4 files changed

+826
-0
lines changed
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
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+
#if !COMPONENTS
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.AspNetCore.Routing.Matching;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Options;
9+
#else
10+
using Microsoft.AspNetCore.Components.Routing;
11+
#endif
12+
13+
namespace Microsoft.AspNetCore.Routing.Constraints;
14+
15+
/// <summary>
16+
/// A route constraint that negates one or more inner constraints. The constraint matches
17+
/// when none of the inner constraints match the route value.
18+
/// </summary>
19+
/// <remarks>
20+
/// <para>
21+
/// The <see cref="NotRouteConstraint"/> implements logical negation for route constraints.
22+
/// It takes a semicolon-separated list of constraint names and returns <c>true</c> only
23+
/// when none of the specified constraints match the route value.
24+
/// </para>
25+
/// <para>
26+
/// <strong>Supported Features:</strong>
27+
/// </para>
28+
/// <list type="bullet">
29+
/// <item>
30+
/// <description>Basic type constraints: <c>int</c>, <c>bool</c>, <c>guid</c>, <c>datetime</c>, <c>decimal</c>, <c>double</c>, <c>float</c>, <c>long</c></description>
31+
/// </item>
32+
/// <item>
33+
/// <description>String constraints: <c>alpha</c>, <c>length(n)</c>, <c>minlength(n)</c>, <c>maxlength(n)</c></description>
34+
/// </item>
35+
/// <item>
36+
/// <description>Numeric constraints: <c>min(n)</c>, <c>max(n)</c>, <c>range(min,max)</c></description>
37+
/// </item>
38+
/// <item>
39+
/// <description>File constraints: <c>file</c>, <c>nonfile</c></description>
40+
/// </item>
41+
/// <item>
42+
/// <description>Special constraints: <c>required</c></description>
43+
/// </item>
44+
/// <item>
45+
/// <description>Multiple constraints with semicolon separation (logical AND of negations)</description>
46+
/// </item>
47+
/// <item>
48+
/// <description>Nested negation patterns (e.g., <c>not(not(int))</c>) - fully supported as recursive constraint evaluation</description>
49+
/// </item>
50+
/// </list>
51+
/// <para>
52+
/// <strong>Examples:</strong>
53+
/// </para>
54+
/// <list type="bullet">
55+
/// <item>
56+
/// <term><c>not(int)</c></term>
57+
/// <description>Matches any value that is NOT an integer (e.g., "abc", "12.5", "true")</description>
58+
/// </item>
59+
/// <item>
60+
/// <term><c>not(int;bool)</c></term>
61+
/// <description>Matches values that are neither integers nor booleans (e.g., "abc", "12.5")</description>
62+
/// </item>
63+
/// <item>
64+
/// <term><c>not(not(int))</c></term>
65+
/// <description>Double negation - matches integers (equivalent to just using <c>int</c> constraint)</description>
66+
/// </item>
67+
/// <item>
68+
/// <term><c>not(min(18))</c></term>
69+
/// <description>Matches integer values less than 18 or non-integer values</description>
70+
/// </item>
71+
/// <item>
72+
/// <term><c>not(alpha)</c></term>
73+
/// <description>Matches non-alphabetic values (e.g., "123", "test123")</description>
74+
/// </item>
75+
/// <item>
76+
/// <term><c>not(file)</c></term>
77+
/// <description>Matches values that don't contain file extensions</description>
78+
/// </item>
79+
/// </list>
80+
/// <para>
81+
/// <strong>Important Notes:</strong>
82+
/// </para>
83+
/// <list type="bullet">
84+
/// <item>
85+
/// <description>Unknown constraint names are ignored and always treated as non-matching, resulting in negation returning <c>true</c></description>
86+
/// </item>
87+
/// <item>
88+
/// <description>Nested negation patterns are fully supported and work recursively (e.g., <c>not(not(int))</c> = double negation)</description>
89+
/// </item>
90+
/// <item>
91+
/// <description>Multiple constraints are combined with logical AND - ALL inner constraints must fail for the negation to succeed</description>
92+
/// </item>
93+
/// <item>
94+
/// <description>Works with both route matching and literal parameter matching scenarios</description>
95+
/// </item>
96+
/// </list>
97+
/// </remarks>
98+
#if !COMPONENTS
99+
public class NotRouteConstraint : IRouteConstraint, IParameterLiteralNodeMatchingPolicy
100+
#else
101+
internal class NotRouteConstraint : IRouteConstraint
102+
#endif
103+
{
104+
/// <summary>
105+
/// Gets the array of inner constraint names to be negated.
106+
/// </summary>
107+
private string[] _inner { get; }
108+
109+
/// <summary>
110+
/// Cached constraint map to avoid repeated reflection-based lookups.
111+
/// </summary>
112+
private static IDictionary<string, Type>? _cachedConstraintMap;
113+
114+
/// <summary>
115+
/// Initializes a new instance of the <see cref="NotRouteConstraint"/> class
116+
/// with the specified inner constraints.
117+
/// </summary>
118+
/// <param name="constraints">
119+
/// A semicolon-separated string containing the names of constraints to negate.
120+
/// Can be a single constraint name (e.g., "int") or multiple constraints (e.g., "int;bool;guid").
121+
/// Parameterized constraints are supported (e.g., "min(18);length(5)").
122+
/// Unknown constraint names are treated as non-matching constraints.
123+
/// </param>
124+
/// <remarks>
125+
/// <para>The constraints string is split by semicolons to create individual constraint checks.</para>
126+
/// <para>Examples of valid constraint strings:</para>
127+
/// <list type="bullet">
128+
/// <item><description><c>"int"</c> - Single type constraint</description></item>
129+
/// <item><description><c>"int;bool"</c> - Multiple type constraints</description></item>
130+
/// <item><description><c>"min(18)"</c> - Parameterized constraint</description></item>
131+
/// <item><description><c>"length(5);alpha"</c> - Mixed constraint types</description></item>
132+
/// <item><description><c>""</c> - Empty string (always returns true)</description></item>
133+
/// </list>
134+
/// </remarks>
135+
public NotRouteConstraint(string constraints)
136+
{
137+
_inner = constraints.Split(";");
138+
}
139+
140+
private static IDictionary<string, Type> GetConstraintMap()
141+
{
142+
// Use cached map or fall back to default constraint map
143+
return _cachedConstraintMap ??= GetDefaultConstraintMap();
144+
}
145+
146+
private static Dictionary<string, Type> GetDefaultConstraintMap()
147+
{
148+
// FIXME: I'm not sure if this is a good thing to do because
149+
// it requires weak spreading between the ConstraintMap and
150+
// RouteOptions. It doesn't seem appropriate to create two
151+
// identical variables for this...
152+
153+
var defaults = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
154+
{
155+
// Type-specific constraints
156+
["int"] = typeof(IntRouteConstraint),
157+
["bool"] = typeof(BoolRouteConstraint),
158+
["datetime"] = typeof(DateTimeRouteConstraint),
159+
["decimal"] = typeof(DecimalRouteConstraint),
160+
["double"] = typeof(DoubleRouteConstraint),
161+
["float"] = typeof(FloatRouteConstraint),
162+
["guid"] = typeof(GuidRouteConstraint),
163+
["long"] = typeof(LongRouteConstraint),
164+
165+
// Length constraints
166+
["minlength"] = typeof(MinLengthRouteConstraint),
167+
["maxlength"] = typeof(MaxLengthRouteConstraint),
168+
["length"] = typeof(LengthRouteConstraint),
169+
170+
// Min/Max value constraints
171+
["min"] = typeof(MinRouteConstraint),
172+
["max"] = typeof(MaxRouteConstraint),
173+
["range"] = typeof(RangeRouteConstraint),
174+
175+
// Alpha constraint
176+
["alpha"] = typeof(AlphaRouteConstraint),
177+
178+
#if !COMPONENTS
179+
["required"] = typeof(RequiredRouteConstraint),
180+
#endif
181+
182+
// File constraints
183+
["file"] = typeof(FileNameRouteConstraint),
184+
["nonfile"] = typeof(NonFileNameRouteConstraint),
185+
186+
// Not constraint
187+
["not"] = typeof(NotRouteConstraint)
188+
};
189+
190+
return defaults;
191+
}
192+
193+
/// <inheritdoc />
194+
/// <remarks>
195+
/// <para>
196+
/// This method implements the core negation logic by:
197+
/// </para>
198+
/// <list type="number">
199+
/// <item>Resolving each inner constraint name to its corresponding <see cref="IRouteConstraint"/> implementation</item>
200+
/// <item>Testing each resolved constraint against the route value</item>
201+
/// <item>Returning <c>false</c> immediately if any constraint matches (short-circuit evaluation)</item>
202+
/// <item>Returning <c>true</c> only if no constraints match</item>
203+
/// </list>
204+
/// <para>
205+
/// The method attempts to use the constraint map from <see cref="RouteOptions"/> if available via
206+
/// the HTTP context's service provider, falling back to the default constraint map if needed.
207+
/// </para>
208+
/// <para>
209+
/// Unknown constraint names are ignored (treated as non-matching), which means they don't affect
210+
/// the negation result.
211+
/// </para>
212+
/// </remarks>
213+
public bool Match(
214+
#if !COMPONENTS
215+
HttpContext? httpContext,
216+
IRouter? route,
217+
string routeKey,
218+
RouteValueDictionary values,
219+
RouteDirection routeDirection)
220+
#else
221+
string routeKey,
222+
RouteValueDictionary values)
223+
#endif
224+
{
225+
ArgumentNullException.ThrowIfNull(routeKey);
226+
ArgumentNullException.ThrowIfNull(values);
227+
228+
// Try to get constraint map from HttpContext first, fallback to default map
229+
IDictionary<string, Type> constraintMap;
230+
IServiceProvider? serviceProvider = null;
231+
232+
#if !COMPONENTS
233+
if (httpContext?.RequestServices != null)
234+
{
235+
try
236+
{
237+
var routeOptions = httpContext.RequestServices.GetService<IOptions<RouteOptions>>();
238+
if (routeOptions != null)
239+
{
240+
constraintMap = routeOptions.Value.TrimmerSafeConstraintMap;
241+
serviceProvider = httpContext.RequestServices;
242+
}
243+
else
244+
{
245+
constraintMap = GetConstraintMap();
246+
}
247+
}
248+
catch
249+
{
250+
constraintMap = GetConstraintMap();
251+
}
252+
}
253+
else
254+
{
255+
constraintMap = GetConstraintMap();
256+
}
257+
#else
258+
constraintMap = GetConstraintMap();
259+
#endif
260+
261+
foreach (var constraintText in _inner)
262+
{
263+
var resolvedConstraint = ParameterPolicyActivator.ResolveParameterPolicy<IRouteConstraint>(
264+
constraintMap,
265+
serviceProvider,
266+
constraintText,
267+
out _);
268+
269+
if (resolvedConstraint != null)
270+
{
271+
// If any inner constraint matches, return false (negation logic)
272+
#if !COMPONENTS
273+
if (resolvedConstraint.Match(httpContext, route, routeKey, values, routeDirection))
274+
#else
275+
if (resolvedConstraint.Match(routeKey, values))
276+
#endif
277+
{
278+
return false;
279+
}
280+
}
281+
}
282+
283+
// If no inner constraints matched, return true (all constraints were negated)
284+
return true;
285+
}
286+
287+
#if !COMPONENTS
288+
bool IParameterLiteralNodeMatchingPolicy.MatchesLiteral(string parameterName, string literal)
289+
{
290+
var constraintMap = GetConstraintMap();
291+
292+
foreach (var constraintText in _inner)
293+
{
294+
var resolvedConstraint = ParameterPolicyActivator.ResolveParameterPolicy<IRouteConstraint>(
295+
constraintMap,
296+
null,
297+
constraintText,
298+
out _);
299+
300+
if (resolvedConstraint is IParameterLiteralNodeMatchingPolicy literalPolicy)
301+
{
302+
// If any inner constraint matches the literal, return false (negation logic)
303+
if (literalPolicy.MatchesLiteral(parameterName, literal))
304+
{
305+
return false;
306+
}
307+
}
308+
}
309+
310+
// If no inner constraints matched the literal, return true
311+
return true;
312+
}
313+
#endif
314+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
#nullable enable
22
Microsoft.AspNetCore.Builder.ValidationEndpointConventionBuilderExtensions
3+
Microsoft.AspNetCore.Routing.Constraints.NotRouteConstraint
4+
Microsoft.AspNetCore.Routing.Constraints.NotRouteConstraint.Match(Microsoft.AspNetCore.Http.HttpContext? httpContext, Microsoft.AspNetCore.Routing.IRouter? route, string! routeKey, Microsoft.AspNetCore.Routing.RouteValueDictionary! values, Microsoft.AspNetCore.Routing.RouteDirection routeDirection) -> bool
5+
Microsoft.AspNetCore.Routing.Constraints.NotRouteConstraint.NotRouteConstraint(string! constraints) -> void
36
static Microsoft.AspNetCore.Builder.ValidationEndpointConventionBuilderExtensions.DisableValidation<TBuilder>(this TBuilder builder) -> TBuilder

src/Http/Routing/src/RouteOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ private static IDictionary<string, Type> GetDefaultConstraintMap()
141141
AddConstraint<FileNameRouteConstraint>(defaults, "file");
142142
AddConstraint<NonFileNameRouteConstraint>(defaults, "nonfile");
143143

144+
// Not constraint
145+
AddConstraint<NotRouteConstraint>(defaults, "not");
144146
return defaults;
145147
}
146148

0 commit comments

Comments
 (0)