Skip to content

Commit 86a72b2

Browse files
committed
Updates
1 parent 65391c0 commit 86a72b2

6 files changed

Lines changed: 369 additions & 30 deletions

File tree

skills/shiny-httpserver/SKILL.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -540,9 +540,15 @@ app.MapGeneratedMediatorEndpoints(); // or Map{Handler}MediatorEndpoints()
540540

541541
1. **Prefer tier 3** for anything with typed parameters; tier 1 for small servers.
542542
2. **Always declare a `JsonSerializerContext`** and never use the reflection JSON overloads.
543-
3. **Route constraints are a closed set**`int`, `long`, `guid`, `bool`, `double`, `decimal`,
544-
`alpha`, `minlength(n)`, `maxlength(n)`, `length(n)`. Validate anything else in the handler so it
545-
can return a meaningful error instead of a 404.
543+
3. **Route constraints are a closed set**`byte`, `short`, `int`, `long`, `float`, `double`,
544+
`decimal`, `bool`, `guid`, `alpha`, `datetime`, `dateonly`, `timeonly`, `timespan`,
545+
`minlength(n)`, `maxlength(n)`, `length(n)`, `min(n)`, `max(n)`, `range(a,b)`. There is **no
546+
`regex`** — validate anything richer in the handler so it can return a meaningful error instead of
547+
a 404. Length constraints count characters; `min`/`max`/`range` compare the value and may take
548+
negative arguments.
549+
A constraint only decides whether the route **matches** — it converts nothing, and the binder
550+
handles every `IParsable<T>` regardless. So `{id:int}` on a handler taking a `long` is fine, a
551+
refused segment is a **404**, and a matched-but-unparseable one is a **400**.
546552
4. **Segments are literal or a parameter, never mixed** (`v{version}` is rejected at registration).
547553
5. **Register middleware before starting**; register routes whenever you like.
548554
6. **Put auth in front of static files** when they are not public.

src/Shiny.Net.HttpServer.SourceGenerators/RouteTemplateInfo.cs

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Globalization;
34
using System.Linq;
45

56
namespace Shiny.Net.HttpServer.SourceGenerators;
@@ -156,20 +157,33 @@ public static string Combine(string prefix, string template)
156157
return new RouteTemplateInfo("/" + normalized, names);
157158
}
158159

160+
/// <summary>
161+
/// The constraint vocabulary, which must stay identical to
162+
/// <c>Shiny.Net.HttpServer.Routing.RouteConstraint.Parse</c>. Anything accepted here and
163+
/// rejected there is a route that compiles and never matches; the reverse is a route the
164+
/// generator refuses for no reason. Parity is covered by tests.
165+
/// </summary>
159166
static bool IsKnownConstraint(string constraint)
160167
{
161168
var paren = constraint.IndexOf('(');
162169
if (paren < 0)
163170
{
164171
switch (constraint.ToLowerInvariant())
165172
{
173+
case "byte":
174+
case "short":
166175
case "int":
167176
case "long":
168-
case "guid":
169-
case "bool":
177+
case "float":
170178
case "double":
171179
case "decimal":
180+
case "bool":
181+
case "guid":
172182
case "alpha":
183+
case "datetime":
184+
case "dateonly":
185+
case "timeonly":
186+
case "timespan":
173187
return true;
174188
default:
175189
return false;
@@ -180,11 +194,43 @@ static bool IsKnownConstraint(string constraint)
180194
return false;
181195

182196
var name = constraint.Substring(0, paren).ToLowerInvariant();
183-
var argument = constraint.Substring(paren + 1, constraint.Length - paren - 2);
197+
var arguments = constraint.Substring(paren + 1, constraint.Length - paren - 2);
198+
199+
var comma = arguments.IndexOf(',');
200+
if (comma < 0)
201+
{
202+
if (!TryParseArgument(arguments, out var value))
203+
return false;
184204

185-
if (!int.TryParse(argument, out var value) || value < 0)
205+
switch (name)
206+
{
207+
// A length cannot be negative; a bound on a value very much can.
208+
case "minlength":
209+
case "maxlength":
210+
case "length":
211+
return value >= 0;
212+
213+
case "min":
214+
case "max":
215+
return true;
216+
217+
default:
218+
return false;
219+
}
220+
}
221+
222+
if (name != "range")
186223
return false;
187224

188-
return name is "minlength" or "maxlength" or "length";
225+
return TryParseArgument(arguments.Substring(0, comma), out var low)
226+
&& TryParseArgument(arguments.Substring(comma + 1), out var high)
227+
&& low <= high;
189228
}
229+
230+
static bool TryParseArgument(string text, out long value) => long.TryParse(
231+
text.Trim(),
232+
NumberStyles.Integer,
233+
CultureInfo.InvariantCulture,
234+
out value
235+
);
190236
}

src/Shiny.Net.HttpServer/Routing/RouteConstraint.cs

Lines changed: 129 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,74 @@
33
namespace 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&lt;T&gt;</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>
1426
public 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

Comments
 (0)