< Summary

Information
Class: NanoRoute.NanoRouteValueParserExtensions
Assembly: NanoRoute.dll
File(s): /home/runner/work/nanoroute/nanoroute/Src/NanoRoute/Public/Extensions/NanoRouteValueParserExtensions.cs
Line coverage
100%
Covered lines: 244
Uncovered lines: 0
Coverable lines: 244
Total lines: 547
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/nanoroute/nanoroute/Src/NanoRoute/Public/Extensions/NanoRouteValueParserExtensions.cs

#LineLine coverage
 1/********************************************************************************
 2* NanoRouteValueParserExtensions.cs                                             *
 3*                                                                               *
 4* Author: Denes Solti                                                           *
 5********************************************************************************/
 6using System;
 7using System.Collections.Generic;
 8using System.Globalization;
 9using System.Runtime.CompilerServices;
 10using System.Text.RegularExpressions;
 11using System.Threading.Tasks;
 12
 13namespace NanoRoute
 14{
 15    using Internals;
 16    using Properties;
 17
 18    /// <summary>
 19    /// Represents a synchronous value parser.
 20    /// </summary>
 21    /// <param name="segment">The decoded segment extracted from the request URI.</param>
 22    /// <param name="arguments">
 23    /// The parser-specific argument payload produced by <see cref="BindArgumentsDelegate"/> during route registration,
 24    /// or <see langword="null"/> when the parser was registered without arguments.
 25    /// </param>
 26    /// <param name="parsed">The parsed value when the delegate returns <see langword="true"/>; otherwise <see langword=
 27    /// <returns><see langword="true"/> when the segment is accepted by the parser; otherwise <see langword="false"/>.</
 28    /// <remarks>
 29    /// Exceptions thrown by the delegate propagate during request processing for matching routes that use the parser.
 30    /// </remarks>
 31    /// <example>
 32    /// <code>
 33    /// routerBuilder.AddValueParser("int", (ReadOnlyMemory&lt;char&gt; segment, object? arguments, out object? parsed) 
 34    /// {
 35    ///     var limits = ((int? Min, int? Max)) arguments!;
 36    ///
 37    ///     if (int.TryParse(segment, out int value))
 38    ///     {
 39    ///         if (limits.Min.HasValue &amp;&amp; value &lt; limits.Min.Value)
 40    ///         {
 41    ///             parsed = null;
 42    ///             return false;
 43    ///         }
 44    ///
 45    ///         parsed = value;
 46    ///         return true;
 47    ///     }
 48    ///
 49    ///     parsed = null;
 50    ///     return false;
 51    /// });
 52    /// </code>
 53    /// </example>
 54    public delegate bool SyncValueParserDelegate(ReadOnlyMemory<char> segment, object? arguments, out object? parsed);
 55
 56    /// <summary>
 57    /// Provides convenience methods for registering value parsers.
 58    /// </summary>
 59    /// <example>
 60    /// <code>
 61    /// builder
 62    ///     .AddDefaultValueParsers()
 63    ///     .AddHandler("GET", "/users/{id:int}/", (context, _) =&gt; Results.Ok(context.Parameters["id"]));
 64    /// </code>
 65    /// </example>
 66    public static class NanoRouteValueParserExtensions
 67    {
 68        #region Private
 69        private readonly record struct IntParserArguments(int? Min, int? Max);
 70
 71        private readonly record struct StringParserArguments(int? Min, int? Max);
 72
 73        private readonly record struct RegexParserArguments(Regex Pattern);
 74
 175        private static readonly ValueTask<ValueParseResult> s_false = new(ValueParseResult.False);
 76
 77        private static object? NoArgs(IReadOnlyDictionary<string, string> rawArgs)
 178        {
 179            if (rawArgs.Count > 0)
 180                throw new ArgumentException(Resources.ERR_INVALID_PARSERS_ARGS, nameof(rawArgs));
 81
 182            return null;
 183        }
 84
 85        private static int ParseIntArgument(string value, string paramName)
 186        {
 187            if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int result))
 188                throw new ArgumentException(Resources.ERR_INVALID_PARSERS_ARGS, paramName);
 89
 190            return result;
 191        }
 92
 93        private static bool ParseBoolArgument(string value, string paramName)
 194        {
 195            if (!bool.TryParse(value, out bool result))
 196                throw new ArgumentException(Resources.ERR_INVALID_PARSERS_ARGS, paramName);
 97
 198            return result;
 199        }
 100
 101        private static Regex ParseRegexArgument(string value, bool caseSensitive, int timeoutMs, string paramName)
 1102        {
 1103            if (timeoutMs <= 0)
 1104                throw new ArgumentException(Resources.ERR_INVALID_PARSERS_ARGS, paramName);
 105
 1106            RegexOptions options = RuntimeFeature.IsDynamicCodeSupported
 1107                ? RegexOptions.Compiled
 1108                : RegexOptions.None;
 109
 1110            if (!caseSensitive)
 1111                options |= RegexOptions.IgnoreCase | RegexOptions.CultureInvariant;
 112
 113            try
 1114            {
 1115                return new Regex(value, options, TimeSpan.FromMilliseconds(timeoutMs));
 116            }
 1117            catch (ArgumentException ex)  // catches ArgumentOutOfRangeException too
 1118            {
 1119                throw new ArgumentException(Resources.ERR_INVALID_PARSERS_ARGS, paramName, ex);
 120            }
 1121        }
 122        #endregion
 123
 124        extension<TBuilder>(TBuilder routeScopeBuilder) where TBuilder : RouteScopeBuilder
 125        {
 126            /// <summary>
 127            /// Registers a synchronous parser by adapting it to <see cref="ValueParserDelegate"/>.
 128            /// </summary>
 129            /// <param name="parserName">The name used in route patterns such as <c>{id:int}</c>.</param>
 130            /// <param name="tryParseDelegate">The synchronous parser to adapt.</param>
 131            /// <returns>The current <paramref name="routeScopeBuilder"/> instance.</returns>
 132            /// <exception cref="ArgumentNullException">
 133            /// Thrown when <paramref name="routeScopeBuilder"/>, <paramref name="parserName"/>, or
 134            /// <paramref name="tryParseDelegate"/> is <see langword="null"/>.
 135            /// </exception>
 136            /// <example>
 137            /// <code>
 138            /// builder.AddValueParser("slug", static (ReadOnlyMemory&lt;char&gt; segment, object? _, out object? parsed
 139            /// {
 140            ///     parsed = segment.ToString();
 141            ///     return segment.Length &gt; 0;
 142            /// });
 143            /// </code>
 144            /// </example>
 145            public TBuilder AddValueParser(string parserName, SyncValueParserDelegate tryParseDelegate) =>
 1146                routeScopeBuilder.AddValueParser(parserName, NoArgs, tryParseDelegate);
 147
 148            /// <summary>
 149            /// Registers a synchronous parser by adapting it to <see cref="ValueParserDelegate"/> and binding parser ar
 150            /// </summary>
 151            /// <param name="parserName">The name used in route patterns such as <c>{id:int(min=1)}</c>.</param>
 152            /// <param name="bindArguments">Converts raw parser arguments into typed values once per route-template bran
 153            /// <param name="tryParseDelegate">The synchronous parser to adapt.</param>
 154            /// <returns>The current <paramref name="routeScopeBuilder"/> instance.</returns>
 155            /// <exception cref="ArgumentNullException">
 156            /// Thrown when <paramref name="routeScopeBuilder"/>, <paramref name="parserName"/>,
 157            /// <paramref name="bindArguments"/>, or <paramref name="tryParseDelegate"/> is <see langword="null"/>.
 158            /// </exception>
 159            /// <example>
 160            /// <code>
 161            /// builder.AddValueParser("str", BindStringParserArguments, TryParseStringSegment);
 162            /// </code>
 163            /// </example>
 164            public TBuilder AddValueParser(string parserName, BindArgumentsDelegate bindArguments, SyncValueParserDelega
 1165            {
 1166                Ensure.NotNull(routeScopeBuilder);
 1167                Ensure.NotNull(parserName);
 1168                Ensure.NotNull(bindArguments);
 1169                Ensure.NotNull(tryParseDelegate);
 170
 1171                routeScopeBuilder.AddValueParser(parserName, bindArguments, context =>
 1172                {
 1173                    bool success = tryParseDelegate(context.Segment, context.Arguments, out object? parsed);
 1174                    return new ValueTask<ValueParseResult>(new ValueParseResult(success, parsed));
 1175                });
 176
 1177                return routeScopeBuilder;
 178            }
 179
 180            /// <summary>
 181            /// Registers an asynchronous parser without route-template arguments.
 182            /// </summary>
 183            /// <param name="parserName">The name used in route patterns such as <c>{id:user}</c>.</param>
 184            /// <param name="tryParseDelegate">The asynchronous parser to register.</param>
 185            /// <returns>The current <paramref name="routeScopeBuilder"/> instance.</returns>
 186            /// <exception cref="ArgumentNullException">
 187            /// Thrown when <paramref name="routeScopeBuilder"/>, <paramref name="parserName"/>, or
 188            /// <paramref name="tryParseDelegate"/> is <see langword="null"/>.
 189            /// </exception>
 190            /// <example>
 191            /// <code>
 192            /// builder.AddValueParser("user", static async context =&gt;
 193            /// {
 194            ///     object? user = await FindUserAsync(context.Segment.ToString(), context.Cancellation);
 195            ///     return new ValueParseResult(user is not null, user);
 196            /// });
 197            /// </code>
 198            /// </example>
 199            public TBuilder AddValueParser(string parserName, ValueParserDelegate tryParseDelegate)
 1200            {
 1201                Ensure.NotNull(routeScopeBuilder);
 1202                Ensure.NotNull(parserName);
 1203                Ensure.NotNull(tryParseDelegate);
 204
 1205                routeScopeBuilder.AddValueParser(parserName, NoArgs, tryParseDelegate);
 206
 1207                return routeScopeBuilder;
 208            }
 209
 210            /// <summary>
 211            /// Registers an asynchronous parser and binds parser arguments once during route registration.
 212            /// </summary>
 213            /// <param name="parserName">The name used in route patterns such as <c>{id:user(scope='admins')}</c>.</para
 214            /// <param name="bindArguments">Converts raw parser arguments into a parser-specific payload.</param>
 215            /// <param name="tryParseDelegate">The asynchronous parser to register.</param>
 216            /// <returns>The current <paramref name="routeScopeBuilder"/> instance.</returns>
 217            /// <exception cref="ArgumentNullException">
 218            /// Thrown when <paramref name="routeScopeBuilder"/>, <paramref name="parserName"/>,
 219            /// <paramref name="bindArguments"/>, or <paramref name="tryParseDelegate"/> is <see langword="null"/>.
 220            /// </exception>
 221            /// <example>
 222            /// <code>
 223            /// builder.AddValueParser("user", BindUserParserArguments, ParseUserAsync);
 224            /// </code>
 225            /// </example>
 226            public TBuilder AddValueParser(string parserName, BindArgumentsDelegate bindArguments, ValueParserDelegate t
 1227            {
 1228                Ensure.NotNull(routeScopeBuilder);
 1229                Ensure.NotNull(parserName);
 1230                Ensure.NotNull(bindArguments);
 1231                Ensure.NotNull(tryParseDelegate);
 232
 1233                routeScopeBuilder.AddValueParser(parserName, bindArguments, tryParseDelegate);
 234
 1235                return routeScopeBuilder;
 236            }
 237
 238            /// <summary>
 239            /// Registers the built-in <c>int</c> value parser.
 240            /// </summary>
 241            /// <returns>The current <paramref name="routeScopeBuilder"/> instance.</returns>
 242            /// <remarks>
 243            /// Supported arguments:
 244            /// <c>min</c>, <c>max</c>.
 245            /// </remarks>
 246            /// <exception cref="ArgumentNullException">Thrown when <paramref name="routeScopeBuilder"/> is <see langwor
 247            /// <example>
 248            /// <code>
 249            /// builder
 250            ///     .AddIntParser()
 251            ///     .AddHandler("GET", "/items/{id:int(min=1)}/", (context, _) =&gt; Results.Ok(context.Parameters["id"]
 252            /// </code>
 253            /// </example>
 254            public TBuilder AddIntParser()
 1255            {
 1256                Ensure.NotNull(routeScopeBuilder);
 257
 1258                routeScopeBuilder.AddValueParser
 1259                (
 1260                    "int",
 1261                    bindArguments: static (IReadOnlyDictionary<string, string> args) =>
 1262                    {
 1263                        int?
 1264                            min = null,
 1265                            max = null;
 1266
 1267                        foreach (KeyValuePair<string, string> arg in args)
 1268                        {
 1269                            switch (arg.Key.ToLower())
 1270                            {
 1271                                case "min":
 1272                                    min = ParseIntArgument(arg.Value, nameof(args));
 1273                                    break;
 1274                                case "max":
 1275                                    max = ParseIntArgument(arg.Value, nameof(args));
 1276                                    break;
 1277                                default:
 1278                                    throw new ArgumentException(Resources.ERR_INVALID_PARSERS_ARGS, nameof(args));
 1279                            }
 1280                        }
 1281
 1282                        if (min > max)
 1283                            throw new ArgumentException(Resources.ERR_INVALID_PARSERS_ARGS, nameof(args));
 1284
 1285                        return new IntParserArguments(min, max);
 1286                    },
 1287                    tryParseDelegate: static (ReadOnlyMemory<char> segment, object? arguments, out object? parsed) =>
 1288                    {
 1289                        IntParserArguments args = (IntParserArguments) arguments!;
 1290                        parsed = null;
 1291#if NETSTANDARD2_1_OR_GREATER
 1292                        if (!int.TryParse(segment.Span, NumberStyles.Integer, CultureInfo.InvariantCulture, out int valu
 1293#else
 1294                        if (!int.TryParse(segment.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out in
 1295#endif
 1296                            return false;
 1297
 1298                        if (value < args.Min)
 1299                            return false;
 1300
 1301                        if (value > args.Max)
 1302                            return false;
 1303
 1304                        parsed = value;
 1305                        return true;
 1306                    }
 1307                );
 308
 1309                return routeScopeBuilder;
 310            }
 311
 312            /// <summary>
 313            /// Registers the built-in <c>guid</c> value parser.
 314            /// </summary>
 315            /// <returns>The current <paramref name="routeScopeBuilder"/> instance.</returns>
 316            /// <remarks>
 317            /// This parser does not support any arguments.
 318            /// </remarks>
 319            /// <exception cref="ArgumentNullException">Thrown when <paramref name="routeScopeBuilder"/> is <see langwor
 320            /// <example>
 321            /// <code>
 322            /// builder
 323            ///     .AddGuidParser()
 324            ///     .AddHandler("GET", "/users/{id:guid}/", (context, _) =&gt; Results.Ok(context.Parameters["id"]));
 325            /// </code>
 326            /// </example>
 1327            public TBuilder AddGuidParser() => routeScopeBuilder.AddValueParser
 1328            (
 1329                "guid",
 1330                static (ReadOnlyMemory<char> segment, object? _, out object? parsed) =>
 1331                {
 1332                    bool success =
 1333#if NETSTANDARD2_1_OR_GREATER
 1334                        Guid.TryParse(segment.Span, out Guid value);
 1335#else
 1336                        Guid.TryParse(segment.ToString(), out Guid value);
 1337#endif
 1338                    parsed = success ? value : null;
 1339                    return success;
 1340                }
 1341            );
 342
 343            /// <summary>
 344            /// Registers the built-in <c>bool</c> value parser.
 345            /// </summary>
 346            /// <returns>The current <paramref name="routeScopeBuilder"/> instance.</returns>
 347            /// <remarks>
 348            /// This parser does not support any arguments.
 349            /// </remarks>
 350            /// <exception cref="ArgumentNullException">Thrown when <paramref name="routeScopeBuilder"/> is <see langwor
 351            /// <example>
 352            /// <code>
 353            /// builder
 354            ///     .AddBoolParser()
 355            ///     .AddHandler("GET", "/features/{enabled:bool}/", (context, _) =&gt; Results.Ok(context.Parameters["en
 356            /// </code>
 357            /// </example>
 1358            public TBuilder AddBoolParser() => routeScopeBuilder.AddValueParser
 1359            (
 1360                "bool",
 1361                static (ReadOnlyMemory<char> segment, object? _, out object? parsed) =>
 1362                {
 1363                    bool success =
 1364#if NETSTANDARD2_1_OR_GREATER
 1365                        bool.TryParse(segment.Span, out bool value);
 1366#else
 1367                        bool.TryParse(segment.ToString(), out bool value);
 1368#endif
 1369                    parsed = success ? value : null;
 1370                    return success;
 1371                }
 1372            );
 373
 374            /// <summary>
 375            /// Registers the built-in <c>str</c> value parser.
 376            /// </summary>
 377            /// <returns>The current <paramref name="routeScopeBuilder"/> instance.</returns>
 378            /// <remarks>
 379            /// Supported arguments:
 380            /// <c>min</c>, <c>max</c>.
 381            /// </remarks>
 382            /// <exception cref="ArgumentNullException">Thrown when <paramref name="routeScopeBuilder"/> is <see langwor
 383            /// <example>
 384            /// <code>
 385            /// builder
 386            ///     .AddStringParser()
 387            ///     .AddHandler("GET", "/users/{name:str(min=2)}/", (context, _) =&gt; Results.Ok(context.Parameters["na
 388            /// </code>
 389            /// </example>
 390            public TBuilder AddStringParser()
 1391            {
 1392                Ensure.NotNull(routeScopeBuilder);
 393
 1394                routeScopeBuilder.AddValueParser
 1395                (
 1396                    "str",
 1397                    bindArguments: static (IReadOnlyDictionary<string, string> args) =>
 1398                    {
 1399                        int?
 1400                            min = null,
 1401                            max = null;
 1402
 1403                        foreach (KeyValuePair<string, string> arg in args)
 1404                        {
 1405                            switch (arg.Key.ToLower())
 1406                            {
 1407                                case "min":
 1408                                    min = ParseIntArgument(arg.Value, nameof(args));
 1409                                    break;
 1410                                case "max":
 1411                                    max = ParseIntArgument(arg.Value, nameof(args));
 1412                                    break;
 1413                                default:
 1414                                    throw new ArgumentException(Resources.ERR_INVALID_PARSERS_ARGS, nameof(args));
 1415                            }
 1416                        }
 1417
 1418                        if (min > max)
 1419                            throw new ArgumentException(Resources.ERR_INVALID_PARSERS_ARGS, nameof(args));
 1420
 1421                        return new StringParserArguments(min, max);
 1422                    },
 1423                    tryParseDelegate: static (ValueParserContext context) =>
 1424                    {
 1425                        StringParserArguments args = (StringParserArguments) context.Arguments!;
 1426
 1427                        if (context.Segment.Length < args.Min)
 1428                            return s_false;
 1429
 1430                        if (context.Segment.Length > args.Max)
 1431                            return s_false;
 1432
 1433                        string segmentStr = context.Segment.ToString();
 1434
 1435                        return new ValueTask<ValueParseResult>(new ValueParseResult(true, segmentStr));
 1436                    }
 1437                );
 438
 1439                return routeScopeBuilder;
 440            }
 441
 442            /// <summary>
 443            /// Registers the built-in <c>regex</c> value parser.
 444            /// </summary>
 445            /// <returns>The current <paramref name="routeScopeBuilder"/> instance.</returns>
 446            /// <remarks>
 447            /// Supported arguments:
 448            /// <c>pattern</c>, <c>timeoutMs</c>, <c>caseSensitive</c>. The <c>pattern</c> argument is required,
 449            /// <c>timeoutMs</c> defaults to <c>50</c>, and <c>caseSensitive</c> defaults to <see langword="false"/>.
 450            /// </remarks>
 451            /// <exception cref="ArgumentNullException">Thrown when <paramref name="routeScopeBuilder"/> is <see langwor
 452            /// <example>
 453            /// <code>
 454            /// builder
 455            ///     .AddRegexParser()
 456            ///     .AddHandler("GET", "/tags/{slug:regex(pattern='^[a-z]+$',timeoutMs=50)}/", (context, _) =&gt; Result
 457            /// </code>
 458            /// </example>
 459            public TBuilder AddRegexParser()
 1460            {
 1461                Ensure.NotNull(routeScopeBuilder);
 462
 1463                routeScopeBuilder.AddValueParser
 1464                (
 1465                    "regex",
 1466                    bindArguments: static (IReadOnlyDictionary<string, string> args) =>
 1467                    {
 1468                        string? pattern = null;
 1469                        int timeoutMs = 50;
 1470                        bool caseSensitive = false;
 1471
 1472                        foreach (KeyValuePair<string, string> arg in args)
 1473                        {
 1474                            switch (arg.Key.ToLower())
 1475                            {
 1476                                case "pattern":
 1477                                    pattern = arg.Value;
 1478                                    break;
 1479                                case "timeoutms":
 1480                                    timeoutMs = ParseIntArgument(arg.Value, nameof(args));
 1481                                    break;
 1482                                case "casesensitive":
 1483                                    caseSensitive = ParseBoolArgument(arg.Value, nameof(args));
 1484                                    break;
 1485                                default:
 1486                                    throw new ArgumentException(Resources.ERR_INVALID_PARSERS_ARGS, nameof(args));
 1487                            }
 1488                        }
 1489
 1490                        return new RegexParserArguments(ParseRegexArgument(pattern!, caseSensitive, timeoutMs, nameof(ar
 1491                    },
 1492                    tryParseDelegate: static (ValueParserContext context) =>
 1493                    {
 1494                        RegexParserArguments args = (RegexParserArguments) context.Arguments!;
 1495                        string segmentStr = context.Segment.ToString();
 1496
 1497                        try
 1498                        {
 1499                            if (!args.Pattern.IsMatch(segmentStr))
 1500                                return s_false;
 1501                        }
 1502                        catch (RegexMatchTimeoutException)
 1503                        {
 1504                            return s_false;
 1505                        }
 1506
 1507                        return new ValueTask<ValueParseResult>(new ValueParseResult(true, segmentStr));
 1508                    }
 1509                );
 510
 1511                return routeScopeBuilder;
 512            }
 513
 514            /// <summary>
 515            /// Registers the built-in value parsers for common scalar route segments.
 516            /// </summary>
 517            /// <returns>The current <paramref name="routeScopeBuilder"/> instance.</returns>
 518            /// <remarks>
 519            /// This convenience method registers parsers named <c>int</c>, <c>guid</c>, <c>bool</c>, <c>str</c>, and <c
 520            /// Existing registrations with the same names are overwritten.
 521            /// </remarks>
 522            /// <exception cref="ArgumentNullException">Thrown when <paramref name="routeScopeBuilder"/> is <see langwor
 523            /// <example>
 524            /// <code>
 525            /// builder
 526            ///     .AddDefaultValueParsers()
 527            ///     .AddHandler("GET", "/users/{id:int}/", (context, next) =&gt; Results.Ok(context.Parameters["id"]));
 528            /// </code>
 529            /// </example>
 530            public TBuilder AddDefaultValueParsers()
 1531            {
 1532                Ensure.NotNull(routeScopeBuilder);
 533
 1534                routeScopeBuilder
 1535                    .AddIntParser()
 1536                    .AddGuidParser()
 1537                    .AddBoolParser()
 1538                    .AddStringParser()
 1539                    .AddRegexParser();
 540
 1541                return routeScopeBuilder;
 542            }
 543        }
 544    }
 545}
 546
 547