33namespace Shiny . Net . HttpServer . Routing ;
44
55/// <summary>
6- /// An inline route constraint such as <c>{id:int}</c>.
6+ /// An inline route constraint such as <c>{id:int}</c> or <c>{page:range(1,100)}</c> .
77/// <para>
88/// Deliberately a closed set evaluated by a switch rather than a pluggable
99/// <c>IRouteConstraint</c> resolved from a container. A closed set is trim-safe, allocation-free,
1010/// and covers what route matching is actually for — anything richer belongs in the handler, where
1111/// it can return a meaningful error instead of a bare 404.
1212/// </para>
13+ /// <para>
14+ /// There is no <c>regex</c> constraint, and that is the same decision rather than an omission: it
15+ /// would put an attacker-influenced pattern on the routing hot path for every request, which is a
16+ /// denial-of-service surface, and a route that needs a regular expression is a route whose handler
17+ /// should be explaining what was wrong with the input.
18+ /// </para>
19+ /// <para>
20+ /// A constraint decides whether a route <em>matches</em>. It does not convert anything — binding a
21+ /// segment to a parameter's type is the binder's job, and it already handles every
22+ /// <c>IParsable<T></c>. <c>{id:int}</c> on an endpoint taking a <c>long</c> is legal and does
23+ /// what it says: match integers, hand the handler a long.
24+ /// </para>
1325/// </summary>
1426public sealed class RouteConstraint
1527{
1628 enum Kind
1729 {
1830 None ,
31+
32+ // Integers, by width. A narrower constraint is a real filter: {id:byte} does not match 300.
33+ Byte ,
34+ Short ,
1935 Int ,
2036 Long ,
21- Guid ,
22- Bool ,
37+
38+ // Reals.
39+ Float ,
2340 Double ,
2441 Decimal ,
42+
43+ Bool ,
44+ Guid ,
2545 Alpha ,
46+
47+ // Temporal. Parsed with the invariant culture, so a route means the same thing wherever the
48+ // server happens to be running.
49+ DateTime ,
50+ DateOnly ,
51+ TimeOnly ,
52+ TimeSpan ,
53+
54+ // Length of the text.
2655 MinLength ,
2756 MaxLength ,
28- Length
57+ Length ,
58+
59+ // Value of the number.
60+ Min ,
61+ Max ,
62+ Range
2963 }
3064
3165 readonly Kind kind ;
32- readonly int argument ;
66+ readonly long argument ;
67+ readonly long argument2 ;
3368
34- RouteConstraint ( Kind kind , int argument = 0 )
69+ RouteConstraint ( Kind kind , long argument = 0 , long argument2 = 0 )
3570 {
3671 this . kind = kind ;
3772 this . argument = argument ;
73+ this . argument2 = argument2 ;
3874 }
3975
4076 /// <summary>No constraint — any single segment matches.</summary>
@@ -50,13 +86,20 @@ enum Kind
5086 {
5187 return text . ToLowerInvariant ( ) switch
5288 {
89+ "byte" => new RouteConstraint ( Kind . Byte ) ,
90+ "short" => new RouteConstraint ( Kind . Short ) ,
5391 "int" => new RouteConstraint ( Kind . Int ) ,
5492 "long" => new RouteConstraint ( Kind . Long ) ,
55- "guid" => new RouteConstraint ( Kind . Guid ) ,
56- "bool" => new RouteConstraint ( Kind . Bool ) ,
93+ "float" => new RouteConstraint ( Kind . Float ) ,
5794 "double" => new RouteConstraint ( Kind . Double ) ,
5895 "decimal" => new RouteConstraint ( Kind . Decimal ) ,
96+ "bool" => new RouteConstraint ( Kind . Bool ) ,
97+ "guid" => new RouteConstraint ( Kind . Guid ) ,
5998 "alpha" => new RouteConstraint ( Kind . Alpha ) ,
99+ "datetime" => new RouteConstraint ( Kind . DateTime ) ,
100+ "dateonly" => new RouteConstraint ( Kind . DateOnly ) ,
101+ "timeonly" => new RouteConstraint ( Kind . TimeOnly ) ,
102+ "timespan" => new RouteConstraint ( Kind . TimeSpan ) ,
60103 _ => null
61104 } ;
62105 }
@@ -65,17 +108,35 @@ enum Kind
65108 return null ;
66109
67110 var name = text [ ..paren ] . ToLowerInvariant ( ) ;
68- var arg = text [ ( paren + 1 ) ..^ 1 ] ;
69- if ( ! int . TryParse ( arg , NumberStyles . Integer , CultureInfo . InvariantCulture , out var value ) || value < 0 )
70- return null ;
111+ var arguments = text [ ( paren + 1 ) ..^ 1 ] ;
71112
72- return name switch
113+ var comma = arguments . IndexOf ( ',' ) ;
114+ if ( comma < 0 )
73115 {
74- "minlength" => new RouteConstraint ( Kind . MinLength , value ) ,
75- "maxlength" => new RouteConstraint ( Kind . MaxLength , value ) ,
76- "length" => new RouteConstraint ( Kind . Length , value ) ,
77- _ => null
78- } ;
116+ if ( ! TryParseArgument ( arguments , out var value ) )
117+ return null ;
118+
119+ return name switch
120+ {
121+ // A length cannot be negative; a bound on a value very much can.
122+ "minlength" => value < 0 ? null : new RouteConstraint ( Kind . MinLength , value ) ,
123+ "maxlength" => value < 0 ? null : new RouteConstraint ( Kind . MaxLength , value ) ,
124+ "length" => value < 0 ? null : new RouteConstraint ( Kind . Length , value ) ,
125+ "min" => new RouteConstraint ( Kind . Min , value ) ,
126+ "max" => new RouteConstraint ( Kind . Max , value ) ,
127+ _ => null
128+ } ;
129+ }
130+
131+ if ( name != "range" )
132+ return null ;
133+
134+ if ( ! TryParseArgument ( arguments [ ..comma ] , out var low ) ||
135+ ! TryParseArgument ( arguments [ ( comma + 1 ) ..] , out var high ) )
136+ return null ;
137+
138+ // An inverted range matches nothing, which is never what someone meant to type.
139+ return low > high ? null : new RouteConstraint ( Kind . Range , low , high ) ;
79140 }
80141
81142 public bool Matches ( ReadOnlySpan < char > value )
@@ -85,24 +146,50 @@ public bool Matches(ReadOnlySpan<char> value)
85146 case Kind . None :
86147 return true ;
87148
149+ case Kind . Byte :
150+ return byte . TryParse ( value , NumberStyles . Integer , CultureInfo . InvariantCulture , out _ ) ;
151+
152+ case Kind . Short :
153+ return short . TryParse ( value , NumberStyles . Integer , CultureInfo . InvariantCulture , out _ ) ;
154+
88155 case Kind . Int :
89156 return int . TryParse ( value , NumberStyles . Integer , CultureInfo . InvariantCulture , out _ ) ;
90157
91158 case Kind . Long :
92159 return long . TryParse ( value , NumberStyles . Integer , CultureInfo . InvariantCulture , out _ ) ;
93160
94- case Kind . Guid :
95- return Guid . TryParse ( value , out _ ) ;
96-
97- case Kind . Bool :
98- return bool . TryParse ( value , out _ ) ;
161+ case Kind . Float :
162+ return float . TryParse ( value , NumberStyles . Float , CultureInfo . InvariantCulture , out _ ) ;
99163
100164 case Kind . Double :
101165 return double . TryParse ( value , NumberStyles . Float , CultureInfo . InvariantCulture , out _ ) ;
102166
103167 case Kind . Decimal :
104168 return decimal . TryParse ( value , NumberStyles . Number , CultureInfo . InvariantCulture , out _ ) ;
105169
170+ case Kind . Bool :
171+ return bool . TryParse ( value , out _ ) ;
172+
173+ case Kind . Guid :
174+ return Guid . TryParse ( value , out _ ) ;
175+
176+ case Kind . DateTime :
177+ return System . DateTime . TryParse (
178+ value ,
179+ CultureInfo . InvariantCulture ,
180+ DateTimeStyles . None ,
181+ out _
182+ ) ;
183+
184+ case Kind . DateOnly :
185+ return System . DateOnly . TryParse ( value , CultureInfo . InvariantCulture , DateTimeStyles . None , out _ ) ;
186+
187+ case Kind . TimeOnly :
188+ return System . TimeOnly . TryParse ( value , CultureInfo . InvariantCulture , DateTimeStyles . None , out _ ) ;
189+
190+ case Kind . TimeSpan :
191+ return System . TimeSpan . TryParse ( value , CultureInfo . InvariantCulture , out _ ) ;
192+
106193 case Kind . Alpha :
107194 if ( value . IsEmpty )
108195 return false ;
@@ -122,6 +209,17 @@ public bool Matches(ReadOnlySpan<char> value)
122209 case Kind . Length :
123210 return value . Length == this . argument ;
124211
212+ case Kind . Min :
213+ return TryParseValue ( value , out var atLeast ) && atLeast >= this . argument ;
214+
215+ case Kind . Max :
216+ return TryParseValue ( value , out var atMost ) && atMost <= this . argument ;
217+
218+ case Kind . Range :
219+ return TryParseValue ( value , out var within )
220+ && within >= this . argument
221+ && within <= this . argument2 ;
222+
125223 default :
126224 return false ;
127225 }
@@ -133,6 +231,15 @@ public bool Matches(ReadOnlySpan<char> value)
133231 Kind . MinLength => $ "minlength({ this . argument } )",
134232 Kind . MaxLength => $ "maxlength({ this . argument } )",
135233 Kind . Length => $ "length({ this . argument } )",
234+ Kind . Min => $ "min({ this . argument } )",
235+ Kind . Max => $ "max({ this . argument } )",
236+ Kind . Range => $ "range({ this . argument } ,{ this . argument2 } )",
136237 _ => this . kind . ToString ( ) . ToLowerInvariant ( )
137238 } ;
239+
240+ static bool TryParseArgument ( ReadOnlySpan < char > text , out long value )
241+ => long . TryParse ( text . Trim ( ) , NumberStyles . Integer , CultureInfo . InvariantCulture , out value ) ;
242+
243+ static bool TryParseValue ( ReadOnlySpan < char > text , out long value )
244+ => long . TryParse ( text , NumberStyles . Integer , CultureInfo . InvariantCulture , out value ) ;
138245}
0 commit comments