Преглед изворни кода

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.
Ryan Nowak пре 7 година
родитељ
комит
f150e89125

+ 15 - 0
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<Microsoft.AspNetCore.Routing.IEndpointRouteBuilder> 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) { }

+ 96 - 0
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
+{
+    /// <summary>
+    /// Contains extension methods for <see cref="IEndpointRouteBuilder"/>.
+    /// </summary>
+    public static class FallbackEndpointRouteBuilderExtensions
+    {
+        /// <summary>
+        /// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
+        /// requests for non-file-names with the lowest possible priority.
+        /// </summary>
+        /// <param name="builder">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
+        /// <param name="requestDelegate">The delegate executed when the endpoint is matched.</param>
+        /// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to further customize the endpoint.</returns>
+        /// <remarks>
+        /// <para>
+        /// <see cref="MapFallback(IEndpointRouteBuilder, RequestDelegate)"/> 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.
+        /// </para>
+        /// <para>
+        /// <see cref="MapFallback(IEndpointRouteBuilder, RequestDelegate)"/> registers an endpoint using the pattern
+        /// <c>{*path:nonfile}</c>. The order of the registered endpoint will be <c>int.MaxValue</c>.
+        /// </para>
+        /// </remarks>
+        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);
+        }
+
+        /// <summary>
+        /// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
+        /// the provided pattern with the lowest possible priority.
+        /// </summary>
+        /// <param name="builder">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
+        /// <param name="pattern">The route pattern.</param>
+        /// <param name="requestDelegate">The delegate executed when the endpoint is matched.</param>
+        /// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to further customize the endpoint.</returns>
+        /// <remarks>
+        /// <para>
+        /// <see cref="MapFallback(IEndpointRouteBuilder, string, RequestDelegate)"/> is intended to handle cases where no
+        /// other endpoint has matched. This is convenient for routing requests to a SPA framework.
+        /// </para>
+        /// <para>
+        /// The order of the registered endpoint will be <c>int.MaxValue</c>.
+        /// </para>
+        /// <para>
+        /// This overload will use the provided <paramref name="pattern"/> verbatim. Use the <c>:nonfile</c> route contraint
+        /// to exclude requests for static files.
+        /// </para>
+        /// </remarks>
+        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;
+        }
+    }
+}

+ 148 - 0
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
+{
+    /// <summary>
+    /// 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.
+    /// </summary>
+    /// <remarks>
+    /// <para>
+    /// This constraint can be used to disambiguate requests for static files versus dynamic
+    /// content served from the application.
+    /// </para>
+    /// <para>
+    /// This constraint determines whether a route value represents a file name by examining
+    /// the last URL Path segment of the value (delimited by <c>/</c>). The last segment
+    /// must contain the dot (<c>.</c>) character followed by one or more non-(<c>.</c>) characters.
+    /// </para>
+    /// <para>
+    /// If the route value does not contain a <c>/</c> then the entire value will be interpreted
+    /// as the last segment.
+    /// </para>
+    /// <para>
+    /// The <see cref="FileNameRouteConstraint"/> does not attempt to validate that the value contains
+    /// a legal file name for the current operating system.
+    /// </para>
+    /// <para>
+    /// The <see cref="FileNameRouteConstraint"/> does not attempt to validate that the value represents
+    /// an actual file on disk.
+    /// </para>
+    /// <para>
+    /// <list type="bullet">  
+    ///     <listheader>  
+    ///         <term>Examples of route values that will be matched as file names</term>  
+    ///         <description>description</description>  
+    ///     </listheader>  
+    ///     <item>  
+    ///         <term><c>/a/b/c.txt</c></term>  
+    ///         <description>Final segment contains a <c>.</c> followed by other characters.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c>/hello.world.txt</c></term>  
+    ///         <description>Final segment contains a <c>.</c> followed by other characters.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c>hello.world.txt</c></term>  
+    ///         <description>Final segment contains a <c>.</c> followed by other characters.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c>.gitignore</c></term>  
+    ///         <description>Final segment contains a <c>.</c> followed by other characters.</description>  
+    ///     </item> 
+    /// </list>
+    /// <list type="bullet">  
+    ///     <listheader>  
+    ///         <term>Examples of route values that will be rejected as non-file-names</term>  
+    ///         <description>description</description>  
+    ///     </listheader>  
+    ///     <item>  
+    ///         <term><c>/a/b/c</c></term>  
+    ///         <description>Final segment does not contain a <c>.</c>.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c>/a/b.d/c</c></term>  
+    ///         <description>Final segment does not contain a <c>.</c>.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c>/a/b.d/c/</c></term>  
+    ///         <description>Final segment is empty.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c></c></term>  
+    ///         <description>Value is empty</description>  
+    ///     </item>
+    /// </list>  
+    /// </para>
+    /// </remarks>
+    public class FileNameRouteConstraint : IRouteConstraint
+    {
+        /// <inheritdoc />
+        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<char> 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;
+        }
+    }
+}

+ 114 - 0
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
+{
+    /// <summary>
+    /// 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.
+    /// </summary>
+    /// <remarks>
+    /// <para>
+    /// This constraint can be used to disambiguate requests for dynamic content versus
+    /// static files served from the application.
+    /// </para>
+    /// <para>
+    /// This constraint determines whether a route value represents a file name by examining
+    /// the last URL Path segment of the value (delimited by <c>/</c>). The last segment
+    /// must contain the dot (<c>.</c>) character followed by one or more non-(<c>.</c>) characters.
+    /// </para>
+    /// <para>
+    /// If the route value does not contain a <c>/</c> then the entire value will be interpreted
+    /// as a the last segment.
+    /// </para>
+    /// <para>
+    /// The <see cref="NonFileNameRouteConstraint"/> does not attempt to validate that the value contains
+    /// a legal file name for the current operating system.
+    /// </para>
+    /// <para>
+    /// <list type="bullet">  
+    ///     <listheader>  
+    ///         <term>Examples of route values that will be matched as non-file-names</term>  
+    ///         <description>description</description>  
+    ///     </listheader>  
+    ///     <item>  
+    ///         <term><c>/a/b/c</c></term>  
+    ///         <description>Final segment does not contain a <c>.</c>.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c>/a/b.d/c</c></term>  
+    ///         <description>Final segment does not contain a <c>.</c>.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c>/a/b.d/c/</c></term>  
+    ///         <description>Final segment is empty.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c></c></term>  
+    ///         <description>Value is empty</description>  
+    ///     </item>
+    /// </list>
+    /// <list type="bullet">  
+    ///     <listheader>  
+    ///         <term>Examples of route values that will be rejected as file names</term>  
+    ///         <description>description</description>  
+    ///     </listheader>  
+    ///     <item>  
+    ///         <term><c>/a/b/c.txt</c></term>  
+    ///         <description>Final segment contains a <c>.</c> followed by other characters.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c>/hello.world.txt</c></term>  
+    ///         <description>Final segment contains a <c>.</c> followed by other characters.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c>hello.world.txt</c></term>  
+    ///         <description>Final segment contains a <c>.</c> followed by other characters.</description>  
+    ///     </item>
+    ///     <item>  
+    ///         <term><c>.gitignore</c></term>  
+    ///         <description>Final segment contains a <c>.</c> followed by other characters.</description>  
+    ///     </item> 
+    /// </list>
+    /// </para>
+    /// </remarks>
+    public class NonFileNameRouteConstraint : IRouteConstraint
+    {
+        /// <inheritdoc />
+        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;
+        }
+    }
+}

+ 4 - 0
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) },
             };
         }
     }

+ 106 - 0
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<RoutingTestFixture<MapFallbackStartup>>
+    {
+        private readonly RoutingTestFixture<MapFallbackStartup> _fixture;
+        private readonly HttpClient _client;
+
+        public MapFallbackTest(RoutingTestFixture<MapFallbackStartup> 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);
+        }
+    }
+}

+ 100 - 0
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<object> FileNameData
+        {
+            get
+            {
+                return new TheoryData<object>()
+                {
+                    "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<object> NonFileNameData
+        {
+            get
+            {
+                return new TheoryData<object>()
+                {
+                    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);
+        }
+    }
+}

+ 59 - 0
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);
+        }
+    }
+}

+ 37 - 0
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();
+        }
+    }
+}