From f150e891257d404877a4ad5a75f9e4d269e15f0e Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Thu, 28 Feb 2019 10:41:02 -0800 Subject: [PATCH] Add file/non-file and generic fallback Adds new constraints for checking if a route value is a file or not. Added a new set of builder methods that specify what it means to be a 'fallback'. This is really similar to what the older SPA fallback routes do, but this is lower in the stack and directly integrated with endpoints. --- ...rosoft.AspNetCore.Routing.netcoreapp3.0.cs | 15 ++ .../FallbackEndpointRouteBuilderExtensions.cs | 96 ++++++++++++ .../Constraints/FileNameRouteConstraint.cs | 148 ++++++++++++++++++ .../Constraints/NonFileNameRouteConstraint.cs | 114 ++++++++++++++ src/Http/Routing/src/RouteOptions.cs | 4 + .../test/FunctionalTests/MapFallbackTest.cs | 106 +++++++++++++ .../FIleNameRouteConstraintTest.cs | 100 ++++++++++++ .../NonFIleNameRouteConstraintTest.cs | 59 +++++++ .../RoutingWebSite/MapFallbackStartup.cs | 37 +++++ 9 files changed, 679 insertions(+) create mode 100644 src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs create mode 100644 src/Http/Routing/src/Constraints/FileNameRouteConstraint.cs create mode 100644 src/Http/Routing/src/Constraints/NonFileNameRouteConstraint.cs create mode 100644 src/Http/Routing/test/FunctionalTests/MapFallbackTest.cs create mode 100644 src/Http/Routing/test/UnitTests/Constraints/FIleNameRouteConstraintTest.cs create mode 100644 src/Http/Routing/test/UnitTests/Constraints/NonFIleNameRouteConstraintTest.cs create mode 100644 src/Http/Routing/test/testassets/RoutingWebSite/MapFallbackStartup.cs diff --git a/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs b/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs index a016059849..1e96d95495 100644 --- a/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs +++ b/src/Http/Routing/ref/Microsoft.AspNetCore.Routing.netcoreapp3.0.cs @@ -25,6 +25,11 @@ namespace Microsoft.AspNetCore.Builder public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseEndpoint(this Microsoft.AspNetCore.Builder.IApplicationBuilder builder) { throw null; } public static Microsoft.AspNetCore.Builder.IApplicationBuilder UseRouting(this Microsoft.AspNetCore.Builder.IApplicationBuilder builder, System.Action configure) { throw null; } } + public static partial class FallbackEndpointRouteBuilderExtensions + { + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallback(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, Microsoft.AspNetCore.Http.RequestDelegate requestDelegate) { throw null; } + public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallback(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, string pattern, Microsoft.AspNetCore.Http.RequestDelegate requestDelegate) { throw null; } + } public static partial class MapRouteRouteBuilderExtensions { public static Microsoft.AspNetCore.Routing.IRouteBuilder MapRoute(this Microsoft.AspNetCore.Routing.IRouteBuilder routeBuilder, string name, string template) { throw null; } @@ -383,6 +388,11 @@ namespace Microsoft.AspNetCore.Routing.Constraints public DoubleRouteConstraint() { } public bool Match(Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.Routing.IRouter route, string routeKey, Microsoft.AspNetCore.Routing.RouteValueDictionary values, Microsoft.AspNetCore.Routing.RouteDirection routeDirection) { throw null; } } + public partial class FileNameRouteConstraint : Microsoft.AspNetCore.Routing.IParameterPolicy, Microsoft.AspNetCore.Routing.IRouteConstraint + { + public FileNameRouteConstraint() { } + public bool Match(Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.Routing.IRouter route, string routeKey, Microsoft.AspNetCore.Routing.RouteValueDictionary values, Microsoft.AspNetCore.Routing.RouteDirection routeDirection) { throw null; } + } public partial class FloatRouteConstraint : Microsoft.AspNetCore.Routing.IParameterPolicy, Microsoft.AspNetCore.Routing.IRouteConstraint { public FloatRouteConstraint() { } @@ -441,6 +451,11 @@ namespace Microsoft.AspNetCore.Routing.Constraints public long Min { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } public bool Match(Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.Routing.IRouter route, string routeKey, Microsoft.AspNetCore.Routing.RouteValueDictionary values, Microsoft.AspNetCore.Routing.RouteDirection routeDirection) { throw null; } } + public partial class NonFileNameRouteConstraint : Microsoft.AspNetCore.Routing.IParameterPolicy, Microsoft.AspNetCore.Routing.IRouteConstraint + { + public NonFileNameRouteConstraint() { } + public bool Match(Microsoft.AspNetCore.Http.HttpContext httpContext, Microsoft.AspNetCore.Routing.IRouter route, string routeKey, Microsoft.AspNetCore.Routing.RouteValueDictionary values, Microsoft.AspNetCore.Routing.RouteDirection routeDirection) { throw null; } + } public partial class OptionalRouteConstraint : Microsoft.AspNetCore.Routing.IParameterPolicy, Microsoft.AspNetCore.Routing.IRouteConstraint { public OptionalRouteConstraint(Microsoft.AspNetCore.Routing.IRouteConstraint innerConstraint) { } diff --git a/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs b/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..43aa9cd66d --- /dev/null +++ b/src/Http/Routing/src/Builder/FallbackEndpointRouteBuilderExtensions.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Contains extension methods for . + /// + public static class FallbackEndpointRouteBuilderExtensions + { + /// + /// Adds a specialized to the that will match + /// requests for non-file-names with the lowest possible priority. + /// + /// The to add the route to. + /// The delegate executed when the endpoint is matched. + /// A that can be used to further customize the endpoint. + /// + /// + /// is intended to handle cases where URL path of + /// the request does not contain a file name, and no other endpoint has matched. This is convenient for routing + /// requests for dynamic content to a SPA framework, while also allowing requests for non-existent files to + /// result in an HTTP 404. + /// + /// + /// registers an endpoint using the pattern + /// {*path:nonfile}. The order of the registered endpoint will be int.MaxValue. + /// + /// + public static IEndpointConventionBuilder MapFallback(this IEndpointRouteBuilder builder, RequestDelegate requestDelegate) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (requestDelegate == null) + { + throw new ArgumentNullException(nameof(requestDelegate)); + } + + return builder.MapFallback("{*path:nonfile}", requestDelegate); + } + + /// + /// Adds a specialized to the that will match + /// the provided pattern with the lowest possible priority. + /// + /// The to add the route to. + /// The route pattern. + /// The delegate executed when the endpoint is matched. + /// A that can be used to further customize the endpoint. + /// + /// + /// is intended to handle cases where no + /// other endpoint has matched. This is convenient for routing requests to a SPA framework. + /// + /// + /// The order of the registered endpoint will be int.MaxValue. + /// + /// + /// This overload will use the provided verbatim. Use the :nonfile route contraint + /// to exclude requests for static files. + /// + /// + public static IEndpointConventionBuilder MapFallback( + this IEndpointRouteBuilder builder, + string pattern, + RequestDelegate requestDelegate) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (pattern == null) + { + throw new ArgumentNullException(nameof(pattern)); + } + + if (requestDelegate == null) + { + throw new ArgumentNullException(nameof(requestDelegate)); + } + + var conventionBuilder = builder.Map(pattern, "Fallback " + pattern, requestDelegate); + conventionBuilder.Add(b => ((RouteEndpointBuilder)b).Order = int.MaxValue); + return conventionBuilder; + } + } +} diff --git a/src/Http/Routing/src/Constraints/FileNameRouteConstraint.cs b/src/Http/Routing/src/Constraints/FileNameRouteConstraint.cs new file mode 100644 index 0000000000..50d85eb86f --- /dev/null +++ b/src/Http/Routing/src/Constraints/FileNameRouteConstraint.cs @@ -0,0 +1,148 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing.Constraints +{ + /// + /// Constrains a route parameter to represent only file name values. Does not validate that + /// the route value contains valid file system characters, or that the value represents + /// an actual file on disk. + /// + /// + /// + /// This constraint can be used to disambiguate requests for static files versus dynamic + /// content served from the application. + /// + /// + /// This constraint determines whether a route value represents a file name by examining + /// the last URL Path segment of the value (delimited by /). The last segment + /// must contain the dot (.) character followed by one or more non-(.) characters. + /// + /// + /// If the route value does not contain a / then the entire value will be interpreted + /// as the last segment. + /// + /// + /// The does not attempt to validate that the value contains + /// a legal file name for the current operating system. + /// + /// + /// The does not attempt to validate that the value represents + /// an actual file on disk. + /// + /// + /// + /// + /// Examples of route values that will be matched as file names + /// description + /// + /// + /// /a/b/c.txt + /// Final segment contains a . followed by other characters. + /// + /// + /// /hello.world.txt + /// Final segment contains a . followed by other characters. + /// + /// + /// hello.world.txt + /// Final segment contains a . followed by other characters. + /// + /// + /// .gitignore + /// Final segment contains a . followed by other characters. + /// + /// + /// + /// + /// Examples of route values that will be rejected as non-file-names + /// description + /// + /// + /// /a/b/c + /// Final segment does not contain a .. + /// + /// + /// /a/b.d/c + /// Final segment does not contain a .. + /// + /// + /// /a/b.d/c/ + /// Final segment is empty. + /// + /// + /// + /// Value is empty + /// + /// + /// + /// + public class FileNameRouteConstraint : IRouteConstraint + { + /// + public bool Match( + HttpContext httpContext, + IRouter route, + string routeKey, + RouteValueDictionary values, + RouteDirection routeDirection) + { + if (routeKey == null) + { + throw new ArgumentNullException(nameof(routeKey)); + } + + if (values == null) + { + throw new ArgumentNullException(nameof(values)); + } + + if (values.TryGetValue(routeKey, out var obj) && obj != null) + { + var value = Convert.ToString(obj, CultureInfo.InvariantCulture); + return IsFileName(value); + } + + // No value or null value. + return false; + } + + // This is used both here and in NonFileNameRouteConstraint + // Any changes to this logic need to update the docs in those places. + internal static bool IsFileName(ReadOnlySpan value) + { + if (value.Length == 0) + { + // Not a file name because empty. + return false; + } + + var lastSlashIndex = value.LastIndexOf('/'); + if (lastSlashIndex >= 0) + { + value = value.Slice(lastSlashIndex + 1); + } + + var dotIndex = value.IndexOf('.'); + if (dotIndex == -1) + { + // No dot. + return false; + } + + for (var i = dotIndex + 1; i < value.Length; i++) + { + if (value[i] != '.') + { + return true; + } + } + + return false; + } + } +} diff --git a/src/Http/Routing/src/Constraints/NonFileNameRouteConstraint.cs b/src/Http/Routing/src/Constraints/NonFileNameRouteConstraint.cs new file mode 100644 index 0000000000..c6867b6e05 --- /dev/null +++ b/src/Http/Routing/src/Constraints/NonFileNameRouteConstraint.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Routing.Constraints +{ + /// + /// Constrains a route parameter to represent only non-file-name values. Does not validate that + /// the route value contains valid file system characters, or that the value represents + /// an actual file on disk. + /// + /// + /// + /// This constraint can be used to disambiguate requests for dynamic content versus + /// static files served from the application. + /// + /// + /// This constraint determines whether a route value represents a file name by examining + /// the last URL Path segment of the value (delimited by /). The last segment + /// must contain the dot (.) character followed by one or more non-(.) characters. + /// + /// + /// If the route value does not contain a / then the entire value will be interpreted + /// as a the last segment. + /// + /// + /// The does not attempt to validate that the value contains + /// a legal file name for the current operating system. + /// + /// + /// + /// + /// Examples of route values that will be matched as non-file-names + /// description + /// + /// + /// /a/b/c + /// Final segment does not contain a .. + /// + /// + /// /a/b.d/c + /// Final segment does not contain a .. + /// + /// + /// /a/b.d/c/ + /// Final segment is empty. + /// + /// + /// + /// Value is empty + /// + /// + /// + /// + /// Examples of route values that will be rejected as file names + /// description + /// + /// + /// /a/b/c.txt + /// Final segment contains a . followed by other characters. + /// + /// + /// /hello.world.txt + /// Final segment contains a . followed by other characters. + /// + /// + /// hello.world.txt + /// Final segment contains a . followed by other characters. + /// + /// + /// .gitignore + /// Final segment contains a . followed by other characters. + /// + /// + /// + /// + public class NonFileNameRouteConstraint : IRouteConstraint + { + /// + public bool Match( + HttpContext httpContext, + IRouter route, + string routeKey, + RouteValueDictionary values, + RouteDirection routeDirection) + { + if (routeKey == null) + { + throw new ArgumentNullException(nameof(routeKey)); + } + + if (values == null) + { + throw new ArgumentNullException(nameof(values)); + } + + if (values.TryGetValue(routeKey, out var obj) && obj != null) + { + var value = Convert.ToString(obj, CultureInfo.InvariantCulture); + return !FileNameRouteConstraint.IsFileName(value); + } + + // No value or null value. + // + // We want to return true here because the core use-case of the constraint is to *exclude* + // things that look like file names. There's nothing here that looks like a file name, so + // let it through. + return true; + } + } +} diff --git a/src/Http/Routing/src/RouteOptions.cs b/src/Http/Routing/src/RouteOptions.cs index 910ec514b7..5c4de4742c 100644 --- a/src/Http/Routing/src/RouteOptions.cs +++ b/src/Http/Routing/src/RouteOptions.cs @@ -76,6 +76,10 @@ namespace Microsoft.AspNetCore.Routing { "regex", typeof(RegexInlineRouteConstraint) }, {"required", typeof(RequiredRouteConstraint) }, + + // Files + { "file", typeof(FileNameRouteConstraint) }, + { "nonfile", typeof(NonFileNameRouteConstraint) }, }; } } diff --git a/src/Http/Routing/test/FunctionalTests/MapFallbackTest.cs b/src/Http/Routing/test/FunctionalTests/MapFallbackTest.cs new file mode 100644 index 0000000000..70e40f6bf7 --- /dev/null +++ b/src/Http/Routing/test/FunctionalTests/MapFallbackTest.cs @@ -0,0 +1,106 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using RoutingWebSite; +using Xunit; + +namespace Microsoft.AspNetCore.Routing.FunctionalTests +{ + public class MapFallbackTest : IClassFixture> + { + private readonly RoutingTestFixture _fixture; + private readonly HttpClient _client; + + public MapFallbackTest(RoutingTestFixture fixture) + { + _fixture = fixture; + _client = _fixture.CreateClient("http://localhost"); + } + + [Fact] + public async Task Get_HelloWorld() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "helloworld"); + + // Act + var response = await _client.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Hello World", responseContent); + } + + [Theory] + [InlineData("prefix/favicon.ico")] + [InlineData("prefix/content/js/jquery.min.js")] + public async Task Get_FallbackWithPattern_FileName(string path) + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, path); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("prefix")] + [InlineData("prefix/")] + [InlineData("prefix/store")] + [InlineData("prefix/blog/read/18")] + public async Task Get_FallbackWithPattern_NonFileName(string path) + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, path); + + // Act + var response = await _client.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("FallbackCustomPattern", responseContent); + } + + [Theory] + [InlineData("favicon.ico")] + [InlineData("content/js/jquery.min.js")] + public async Task Get_Fallback_FileName(string path) + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, path); + + // Act + var response = await _client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Theory] + [InlineData("")] + [InlineData("/")] + [InlineData("store")] + [InlineData("blog/read/18")] + public async Task Get_Fallback_NonFileName(string path) + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, path); + + // Act + var response = await _client.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("FallbackDefaultPattern", responseContent); + } + } +} diff --git a/src/Http/Routing/test/UnitTests/Constraints/FIleNameRouteConstraintTest.cs b/src/Http/Routing/test/UnitTests/Constraints/FIleNameRouteConstraintTest.cs new file mode 100644 index 0000000000..4f6b15ac94 --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Constraints/FIleNameRouteConstraintTest.cs @@ -0,0 +1,100 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Constraints +{ + public class FileNameRouteConstraintTest + { + public static TheoryData FileNameData + { + get + { + return new TheoryData() + { + "hello.txt", + "hello.txt.jpg", + "/hello.t", + "/////hello.x", + "a/b/c/d.e", + "a/b./.c/d.e", + ".gitnore", + ".a", + "/.......a" + }; + } + } + + + [Theory] + [MemberData(nameof(FileNameData))] + public void Match_RouteValue_IsFileName(object value) + { + // Arrange + var constraint = new FileNameRouteConstraint(); + + var values = new RouteValueDictionary(); + values.Add("path", value); + + // Act + var result = constraint.Match(httpContext: null, route: null, "path", values, RouteDirection.IncomingRequest); + + // Assert + Assert.True(result); + } + + public static TheoryData NonFileNameData + { + get + { + return new TheoryData() + { + null, + string.Empty, + "/", + ".", + "..........", + "hello.", + "/hello", + "//", + "//b.c/", + "/////hello.", + "a/b./.c/d.", + }; + } + } + + [Theory] + [MemberData(nameof(NonFileNameData))] + public void Match_RouteValue_IsNotFileName(object value) + { + // Arrange + var constraint = new FileNameRouteConstraint(); + + var values = new RouteValueDictionary(); + values.Add("path", value); + + // Act + var result = constraint.Match(httpContext: null, route: null, "path", values, RouteDirection.IncomingRequest); + + // Assert + Assert.False(result); + } + + [Fact] + public void Match_MissingValue_IsNotFileName() + { + // Arrange + var constraint = new FileNameRouteConstraint(); + + var values = new RouteValueDictionary(); + + // Act + var result = constraint.Match(httpContext: null, route: null, "path", values, RouteDirection.IncomingRequest); + + // Assert + Assert.False(result); + } + } +} diff --git a/src/Http/Routing/test/UnitTests/Constraints/NonFIleNameRouteConstraintTest.cs b/src/Http/Routing/test/UnitTests/Constraints/NonFIleNameRouteConstraintTest.cs new file mode 100644 index 0000000000..78e03ec7d1 --- /dev/null +++ b/src/Http/Routing/test/UnitTests/Constraints/NonFIleNameRouteConstraintTest.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Routing.Constraints +{ + public class NonFileNameRouteConstraintTest + { + [Theory] + [MemberData(nameof(FileNameRouteConstraintTest.FileNameData), MemberType = typeof(FileNameRouteConstraintTest))] + public void Match_RouteValue_IsNotNonFileName(object value) + { + // Arrange + var constraint = new NonFileNameRouteConstraint(); + + var values = new RouteValueDictionary(); + values.Add("path", value); + + // Act + var result = constraint.Match(httpContext: null, route: null, "path", values, RouteDirection.IncomingRequest); + + // Assert + Assert.False(result); + } + + [Theory] + [MemberData(nameof(FileNameRouteConstraintTest.NonFileNameData), MemberType = typeof(FileNameRouteConstraintTest))] + public void Match_RouteValue_IsNonFileName(object value) + { + // Arrange + var constraint = new NonFileNameRouteConstraint(); + + var values = new RouteValueDictionary(); + values.Add("path", value); + + // Act + var result = constraint.Match(httpContext: null, route: null, "path", values, RouteDirection.IncomingRequest); + + // Assert + Assert.True(result); + } + + [Fact] + public void Match_MissingValue_IsNotFileName() + { + // Arrange + var constraint = new NonFileNameRouteConstraint(); + + var values = new RouteValueDictionary(); + + // Act + var result = constraint.Match(httpContext: null, route: null, "path", values, RouteDirection.IncomingRequest); + + // Assert + Assert.True(result); + } + } +} diff --git a/src/Http/Routing/test/testassets/RoutingWebSite/MapFallbackStartup.cs b/src/Http/Routing/test/testassets/RoutingWebSite/MapFallbackStartup.cs new file mode 100644 index 0000000000..4e9662b55b --- /dev/null +++ b/src/Http/Routing/test/testassets/RoutingWebSite/MapFallbackStartup.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace RoutingWebSite +{ + public class MapFallbackStartup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddRouting(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseRouting(routes => + { + routes.MapFallback("/prefix/{*path:nonfile}", (context) => + { + return context.Response.WriteAsync("FallbackCustomPattern"); + }); + + routes.MapFallback((context) => + { + return context.Response.WriteAsync("FallbackDefaultPattern"); + }); + + routes.MapHello("/helloworld", "World"); + }); + + app.UseEndpoint(); + } + } +}