| | | 1 | | using System.Globalization; |
| | | 2 | | |
| | | 3 | | namespace Kestrun.OpenApi; |
| | | 4 | | |
| | | 5 | | /// <summary> |
| | | 6 | | /// Maps ASP.NET Core route values to RFC 6570 URI Template variable assignments. |
| | | 7 | | /// </summary> |
| | | 8 | | public static partial class Rfc6570VariableMapper |
| | | 9 | | { |
| | | 10 | | /// <summary> |
| | | 11 | | /// Attempts to build RFC 6570 variable assignments from an ASP.NET Core HTTP context. |
| | | 12 | | /// </summary> |
| | | 13 | | /// <param name="context">The HTTP context containing route values from ASP.NET Core routing.</param> |
| | | 14 | | /// <param name="openApiPathTemplate">The OpenAPI 3.2 RFC 6570 path template (e.g., "/files/{+path}" or "/users/{id} |
| | | 15 | | /// <param name="variables"> |
| | | 16 | | /// When this method returns true, contains a dictionary of variable names to their values. |
| | | 17 | | /// Variable names are extracted from the OpenAPI path template, and values are taken from ASP.NET route values. |
| | | 18 | | /// </param> |
| | | 19 | | /// <param name="error">When this method returns false, contains a human-readable error message.</param> |
| | | 20 | | /// <returns> |
| | | 21 | | /// True if variable assignments were successfully built; false if the context or template is invalid. |
| | | 22 | | /// </returns> |
| | | 23 | | /// <remarks> |
| | | 24 | | /// <para> |
| | | 25 | | /// This helper only prepares variable values. Actual URI template expansion (percent-encoding rules) |
| | | 26 | | /// is performed by an RFC 6570 expander. |
| | | 27 | | /// </para> |
| | | 28 | | /// <list type="bullet"> |
| | | 29 | | /// <item><description>Simple variables: {id}</description></item> |
| | | 30 | | /// <item><description>Reserved expansion: {+path} (multi-segment)</description></item> |
| | | 31 | | /// <item><description>Explode modifier: {path*} (multi-segment)</description></item> |
| | | 32 | | /// </list> |
| | | 33 | | /// <para> |
| | | 34 | | /// The OpenAPI template determines whether a variable is multi-segment ({+var} / {var*}). |
| | | 35 | | /// This method does not parse the ASP.NET route template to infer expansion semantics. |
| | | 36 | | /// </para> |
| | | 37 | | /// <example> |
| | | 38 | | /// <code> |
| | | 39 | | /// var template = "/users/{id}"; |
| | | 40 | | /// if (Rfc6570VariableMapper.TryBuildRfc6570Variables(context, template, out var vars, out var error)) |
| | | 41 | | /// { |
| | | 42 | | /// // vars contains: { "id" = "123" } |
| | | 43 | | /// } |
| | | 44 | | /// </code> |
| | | 45 | | /// </example> |
| | | 46 | | /// </remarks> |
| | | 47 | | public static bool TryBuildRfc6570Variables( |
| | | 48 | | HttpContext context, |
| | | 49 | | string openApiPathTemplate, |
| | | 50 | | out Dictionary<string, object> variables, |
| | | 51 | | out string? error) |
| | | 52 | | { |
| | 20 | 53 | | variables = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase); |
| | 20 | 54 | | if (context is null) |
| | | 55 | | { |
| | 1 | 56 | | error = "HttpContext is null."; |
| | 1 | 57 | | return false; |
| | | 58 | | } |
| | | 59 | | |
| | 19 | 60 | | if (string.IsNullOrWhiteSpace(openApiPathTemplate)) |
| | | 61 | | { |
| | 3 | 62 | | error = "OpenAPI path template is null or empty."; |
| | 3 | 63 | | return false; |
| | | 64 | | } |
| | | 65 | | |
| | 16 | 66 | | var routeValues = context.Request.RouteValues; |
| | | 67 | | |
| | 16 | 68 | | if (!TryParseTemplate(openApiPathTemplate, out var segments, out error)) |
| | | 69 | | { |
| | 3 | 70 | | return false; |
| | | 71 | | } |
| | | 72 | | |
| | 51 | 73 | | foreach (var seg in segments) |
| | | 74 | | { |
| | 14 | 75 | | if (!TryBuildSegmentValue(routeValues, seg, openApiPathTemplate, out var stringValue, out error)) |
| | | 76 | | { |
| | 3 | 77 | | variables.Clear(); |
| | 3 | 78 | | return false; |
| | | 79 | | } |
| | | 80 | | |
| | 11 | 81 | | variables[seg.Name] = stringValue; |
| | | 82 | | } |
| | | 83 | | |
| | 10 | 84 | | return true; |
| | 3 | 85 | | } |
| | | 86 | | |
| | | 87 | | /// <summary> |
| | | 88 | | /// Attempts to read, convert, and normalize a route value for a template variable. |
| | | 89 | | /// </summary> |
| | | 90 | | /// <param name="routeValues">The ASP.NET Core route values.</param> |
| | | 91 | | /// <param name="segment">The parsed RFC 6570 variable segment.</param> |
| | | 92 | | /// <param name="openApiPathTemplate">The OpenAPI 3.2 RFC 6570 path template.</param> |
| | | 93 | | /// <param name="stringValue">The resulting invariant string value.</param> |
| | | 94 | | /// <param name="error">Error details on failure.</param> |
| | | 95 | | /// <returns>True if the variable value was produced; otherwise false.</returns> |
| | | 96 | | private static bool TryBuildSegmentValue( |
| | | 97 | | RouteValueDictionary routeValues, |
| | | 98 | | VarSegment segment, |
| | | 99 | | string openApiPathTemplate, |
| | | 100 | | out string stringValue, |
| | | 101 | | out string? error) |
| | | 102 | | { |
| | 14 | 103 | | if (!routeValues.TryGetValue(segment.Name, out var rawValue) || rawValue is null) |
| | | 104 | | { |
| | 2 | 105 | | error = $"Missing required route value '{segment.Name}' for OpenAPI template '{openApiPathTemplate}'."; |
| | 2 | 106 | | stringValue = string.Empty; |
| | 2 | 107 | | return false; |
| | | 108 | | } |
| | | 109 | | |
| | 12 | 110 | | if (!TryConvertRouteValueToInvariantString(rawValue, out stringValue)) |
| | | 111 | | { |
| | 0 | 112 | | error = $"Route value '{segment.Name}' could not be converted to a string."; |
| | 0 | 113 | | stringValue = string.Empty; |
| | 0 | 114 | | return false; |
| | | 115 | | } |
| | | 116 | | |
| | 12 | 117 | | if (segment.IsMultiSegment) |
| | | 118 | | { |
| | 4 | 119 | | stringValue = NormalizeMultiSegmentValue(stringValue); |
| | | 120 | | } |
| | 8 | 121 | | else if (stringValue.Contains('/', StringComparison.Ordinal)) |
| | | 122 | | { |
| | 1 | 123 | | error = $"Route value '{segment.Name}' contains '/', but template '{segment.Name}' is not multi-segment."; |
| | 1 | 124 | | stringValue = string.Empty; |
| | 1 | 125 | | return false; |
| | | 126 | | } |
| | | 127 | | |
| | 11 | 128 | | error = null; |
| | 11 | 129 | | return true; |
| | | 130 | | } |
| | | 131 | | |
| | | 132 | | /// <summary> |
| | | 133 | | /// Normalizes a multi-segment variable by trimming a leading '/' while preserving embedded '/'. |
| | | 134 | | /// </summary> |
| | | 135 | | /// <param name="value">The raw route value.</param> |
| | | 136 | | /// <returns>The normalized value.</returns> |
| | | 137 | | private static string NormalizeMultiSegmentValue(string value) => |
| | 4 | 138 | | value.Length > 0 && value[0] == '/' ? value.TrimStart('/') : value; |
| | | 139 | | |
| | | 140 | | /// <summary> |
| | | 141 | | /// Backwards-compatible overload that discards detailed error information. |
| | | 142 | | /// </summary> |
| | | 143 | | /// <param name="context">The HTTP context containing route values from ASP.NET Core routing.</param> |
| | | 144 | | /// <param name="openApiPathTemplate">The OpenAPI 3.2 RFC 6570 path template.</param> |
| | | 145 | | /// <param name="variables">When true, contains variable assignments.</param> |
| | | 146 | | /// <returns>True on success; otherwise false.</returns> |
| | | 147 | | public static bool TryBuildRfc6570Variables( |
| | | 148 | | HttpContext context, |
| | | 149 | | string openApiPathTemplate, |
| | | 150 | | out Dictionary<string, object?> variables) |
| | | 151 | | { |
| | 0 | 152 | | var ok = TryBuildRfc6570Variables(context, openApiPathTemplate, out var vars, out _); |
| | 0 | 153 | | variables = vars.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value, StringComparer.OrdinalIgnoreCase); |
| | 0 | 154 | | return ok; |
| | | 155 | | } |
| | | 156 | | |
| | | 157 | | /// <summary> |
| | | 158 | | /// Represents a parsed RFC 6570 variable segment from an OpenAPI 3.2 path template. |
| | | 159 | | /// </summary> |
| | 65 | 160 | | private sealed record VarSegment(string Name, bool IsReservedExpansion, bool IsExplode) |
| | | 161 | | { |
| | | 162 | | /// <summary> |
| | | 163 | | /// True when the variable can represent multiple path segments. |
| | | 164 | | /// </summary> |
| | 12 | 165 | | public bool IsMultiSegment => IsReservedExpansion || IsExplode; |
| | | 166 | | } |
| | | 167 | | |
| | | 168 | | /// <summary> |
| | | 169 | | /// Parses an OpenAPI 3.2 RFC 6570 path template using a restricted subset: |
| | | 170 | | /// {var}, {+var}, {var*}, {+var*}. |
| | | 171 | | /// </summary> |
| | | 172 | | /// <param name="template">The OpenAPI path template.</param> |
| | | 173 | | /// <param name="segments">Parsed variable segments.</param> |
| | | 174 | | /// <param name="error">Error details on failure.</param> |
| | | 175 | | /// <returns>True if parsing succeeded; otherwise false.</returns> |
| | | 176 | | private static bool TryParseTemplate(string template, out List<VarSegment> segments, out string? error) |
| | | 177 | | { |
| | 16 | 178 | | segments = []; |
| | 16 | 179 | | error = null; |
| | | 180 | | |
| | 316 | 181 | | for (var i = 0; i < template.Length; i++) |
| | | 182 | | { |
| | 145 | 183 | | if (template[i] != '{') |
| | | 184 | | { |
| | | 185 | | continue; |
| | | 186 | | } |
| | | 187 | | |
| | 17 | 188 | | var close = template.IndexOf('}', i + 1); |
| | 17 | 189 | | if (close < 0) |
| | | 190 | | { |
| | 0 | 191 | | error = "Unterminated RFC6570 expression: missing '}'."; |
| | 0 | 192 | | return false; |
| | | 193 | | } |
| | | 194 | | |
| | 17 | 195 | | var inner = template.Substring(i + 1, close - i - 1).Trim(); |
| | 17 | 196 | | if (inner.Length == 0) |
| | | 197 | | { |
| | 0 | 198 | | error = "Empty RFC6570 expression '{}' is not supported."; |
| | 0 | 199 | | return false; |
| | | 200 | | } |
| | | 201 | | |
| | 17 | 202 | | if (inner.Contains(':', StringComparison.Ordinal)) |
| | | 203 | | { |
| | 2 | 204 | | error = "Regex/constraint syntax (':') is not supported in OpenAPI 3.2 RFC6570 path templates."; |
| | 2 | 205 | | return false; |
| | | 206 | | } |
| | | 207 | | |
| | 15 | 208 | | if (inner.Contains(',', StringComparison.Ordinal)) |
| | | 209 | | { |
| | 1 | 210 | | error = "Multiple variables in a single RFC6570 expression (e.g. '{a,b}') are not supported."; |
| | 1 | 211 | | return false; |
| | | 212 | | } |
| | | 213 | | |
| | 14 | 214 | | var isReserved = inner[0] == '+'; |
| | 14 | 215 | | if (isReserved) |
| | | 216 | | { |
| | 2 | 217 | | inner = inner[1..]; |
| | 2 | 218 | | if (inner.Length == 0) |
| | | 219 | | { |
| | 0 | 220 | | error = "Reserved RFC6570 expression '{+}' is not valid."; |
| | 0 | 221 | | return false; |
| | | 222 | | } |
| | | 223 | | } |
| | | 224 | | |
| | 14 | 225 | | var isExplode = inner.EndsWith('*'); |
| | 14 | 226 | | if (isExplode) |
| | | 227 | | { |
| | 2 | 228 | | inner = inner[..^1]; |
| | 2 | 229 | | if (inner.Length == 0) |
| | | 230 | | { |
| | 0 | 231 | | error = "Explode RFC6570 expression '{*}' is not valid."; |
| | 0 | 232 | | return false; |
| | | 233 | | } |
| | | 234 | | } |
| | | 235 | | |
| | 14 | 236 | | if (!IsValidVarName(inner)) |
| | | 237 | | { |
| | 0 | 238 | | error = $"Invalid RFC6570 variable name '{inner}'."; |
| | 0 | 239 | | return false; |
| | | 240 | | } |
| | | 241 | | |
| | 14 | 242 | | segments.Add(new VarSegment(inner, isReserved, isExplode)); |
| | 14 | 243 | | i = close; |
| | | 244 | | } |
| | | 245 | | |
| | 13 | 246 | | return true; |
| | | 247 | | } |
| | | 248 | | |
| | | 249 | | /// <summary> |
| | | 250 | | /// Converts a route value to an invariant string representation. |
| | | 251 | | /// </summary> |
| | | 252 | | /// <param name="value">The route value.</param> |
| | | 253 | | /// <param name="stringValue">Invariant string representation.</param> |
| | | 254 | | /// <returns>True if conversion succeeded; otherwise false.</returns> |
| | | 255 | | private static bool TryConvertRouteValueToInvariantString(object value, out string stringValue) |
| | | 256 | | { |
| | | 257 | | // Route values commonly come in as string, int, long, Guid, etc. |
| | 12 | 258 | | if (value is string s) |
| | | 259 | | { |
| | 11 | 260 | | stringValue = s; |
| | 11 | 261 | | return true; |
| | | 262 | | } |
| | | 263 | | |
| | 1 | 264 | | if (value is IFormattable f) |
| | | 265 | | { |
| | 1 | 266 | | stringValue = f.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty; |
| | 1 | 267 | | return true; |
| | | 268 | | } |
| | | 269 | | |
| | 0 | 270 | | stringValue = value.ToString() ?? string.Empty; |
| | 0 | 271 | | return true; |
| | | 272 | | } |
| | | 273 | | |
| | | 274 | | /// <summary> |
| | | 275 | | /// Validates RFC6570 variable names for the supported subset. |
| | | 276 | | /// </summary> |
| | | 277 | | /// <param name="name">Variable name.</param> |
| | | 278 | | /// <returns>True if name is acceptable; otherwise false.</returns> |
| | | 279 | | private static bool IsValidVarName(string name) |
| | | 280 | | { |
| | | 281 | | // Conservative: allow letters/digits/_ and .- |
| | | 282 | | // (PowerShell and ASP.NET route values tend to be simple identifiers.) |
| | 14 | 283 | | if (string.IsNullOrWhiteSpace(name)) |
| | | 284 | | { |
| | 0 | 285 | | return false; |
| | | 286 | | } |
| | | 287 | | |
| | 130 | 288 | | for (var i = 0; i < name.Length; i++) |
| | | 289 | | { |
| | 51 | 290 | | var c = name[i]; |
| | 51 | 291 | | var ok = char.IsLetterOrDigit(c) || c == '_' || c == '.' || c == '-'; |
| | 51 | 292 | | if (!ok) |
| | | 293 | | { |
| | 0 | 294 | | return false; |
| | | 295 | | } |
| | | 296 | | } |
| | | 297 | | |
| | 14 | 298 | | return true; |
| | | 299 | | } |
| | | 300 | | } |