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.
This commit is contained in:
Ryan Nowak 2019-02-28 10:41:02 -08:00
parent 4c79e7fdc0
commit f150e89125
9 changed files with 679 additions and 0 deletions

View File

@ -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) { }

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -76,6 +76,10 @@ namespace Microsoft.AspNetCore.Routing
{ "regex", typeof(RegexInlineRouteConstraint) },
{"required", typeof(RequiredRouteConstraint) },
// Files
{ "file", typeof(FileNameRouteConstraint) },
{ "nonfile", typeof(NonFileNameRouteConstraint) },
};
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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();
}
}
}