Add fallback routing to file

This is a new routing feature that integrates static files to serve a
static file when routing doesn't match anything else.

This is a scenario that's covered by SPA services today, but given the
improvements to routing it makes much more sense to move lower in the
stack.
This commit is contained in:
Ryan Nowak 2019-02-28 11:47:52 -08:00
parent 7e63e2da43
commit 089f64c528
5 changed files with 364 additions and 1 deletions

View File

@ -7,6 +7,7 @@
<Compile Include="Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs" />
<Reference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
<Reference Include="Microsoft.AspNetCore.Http.Extensions" />
<Reference Include="Microsoft.AspNetCore.Routing" />
<Reference Include="Microsoft.Extensions.FileProviders.Abstractions" />
<Reference Include="Microsoft.Extensions.Logging.Abstractions" />
<Reference Include="Microsoft.Extensions.WebEncoders" />

View File

@ -58,6 +58,13 @@ namespace Microsoft.AspNetCore.Builder
public System.Action<Microsoft.AspNetCore.StaticFiles.StaticFileResponseContext> OnPrepareResponse { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public bool ServeUnknownFileTypes { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
}
public static partial class StaticFilesEndpointRouteBuilderExtensions
{
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToFile(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, string filePath) { throw null; }
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToFile(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, string filePath, Microsoft.AspNetCore.Builder.StaticFileOptions options) { throw null; }
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToFile(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, string pattern, string filePath) { throw null; }
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapFallbackToFile(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder builder, string pattern, string filePath, Microsoft.AspNetCore.Builder.StaticFileOptions options) { throw null; }
}
}
namespace Microsoft.AspNetCore.StaticFiles
{

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>ASP.NET Core static files middleware. Includes middleware for serving static files, directory browsing, and default files.</Description>
@ -16,6 +16,7 @@
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
<Reference Include="Microsoft.AspNetCore.Http.Extensions" />
<Reference Include="Microsoft.AspNetCore.Routing" />
<Reference Include="Microsoft.Extensions.FileProviders.Abstractions" />
<Reference Include="Microsoft.Extensions.Logging.Abstractions" />
<Reference Include="Microsoft.Extensions.WebEncoders" />

View File

@ -0,0 +1,223 @@
// 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.Http.Endpoints;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.StaticFiles;
namespace Microsoft.AspNetCore.Builder
{
/// <summary>
/// Contains extension methods for using static files with endpoint routing.
/// </summary>
public static class StaticFilesEndpointRouteBuilderExtensions
{
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-filenames with the lowest possible priority. The request will be routed to a
/// <see cref="StaticFileMiddleware"/> that attempts to serve the file specified by <paramref name="filePath"/>.
/// </summary>
/// <param name="builder">The <see cref="IEndpointRouteBuilder"/>.</param>
/// <param name="filePath">The file path of the file to serve.</param>
/// <returns>The <see cref="IEndpointRouteBuilder"/></returns>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToFile(IEndpointRouteBuilder, string)"/> is intended to handle cases where URL path of
/// the request does not contain a filename, 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>
/// The default <see cref="StaticFileOptions"/> for the <see cref="StaticFileMiddleware"/> will be used.
/// </para>
/// <para>
/// <see cref="MapFallbackToFile(IEndpointRouteBuilder, string)"/> 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 MapFallbackToFile(
this IEndpointRouteBuilder builder,
string filePath)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
if (filePath == null)
{
throw new ArgumentNullException(nameof(filePath));
}
return builder.MapFallback(CreateRequestDelegate(builder, filePath));
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-filenames with the lowest possible priority. The request will be routed to a
/// <see cref="StaticFileMiddleware"/> that attempts to serve the file specified by <paramref name="filePath"/>.
/// </summary>
/// <param name="builder">The <see cref="IEndpointRouteBuilder"/>.</param>
/// <param name="filePath">The file path of the file to serve.</param>
/// <param name="options"><see cref="StaticFileOptions"/> for the <see cref="StaticFileMiddleware"/>.</param>
/// <returns>The <see cref="IEndpointRouteBuilder"/></returns>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToFile(IEndpointRouteBuilder, string, StaticFileOptions)"/> 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="MapFallbackToFile(IEndpointRouteBuilder, string, StaticFileOptions)"/> 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 MapFallbackToFile(
this IEndpointRouteBuilder builder,
string filePath,
StaticFileOptions options)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
if (filePath == null)
{
throw new ArgumentNullException(nameof(filePath));
}
return builder.MapFallback(CreateRequestDelegate(builder, filePath, options));
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-filenames with the lowest possible priority. The request will be routed to a
/// <see cref="StaticFileMiddleware"/> that attempts to serve the file specified by <paramref name="filePath"/>.
/// </summary>
/// <param name="builder">The <see cref="IEndpointRouteBuilder"/>.</param>
/// <param name="pattern">The route pattern.</param>
/// <param name="filePath">The file path of the file to serve.</param>
/// <returns>The <see cref="IEndpointRouteBuilder"/></returns>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToFile(IEndpointRouteBuilder, string, string)"/> is intended to handle
/// cases where URL path of the request does not contain a filename, 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>
/// The default <see cref="StaticFileOptions"/> for the <see cref="StaticFileMiddleware"/> will be used.
/// </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 MapFallbackToFile(
this IEndpointRouteBuilder builder,
string pattern,
string filePath)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
if (pattern == null)
{
throw new ArgumentNullException(nameof(filePath));
}
if (filePath == null)
{
throw new ArgumentNullException(nameof(filePath));
}
return builder.MapFallback(pattern, CreateRequestDelegate(builder, filePath));
}
/// <summary>
/// Adds a specialized <see cref="RouteEndpoint"/> to the <see cref="IEndpointRouteBuilder"/> that will match
/// requests for non-filenames with the lowest possible priority. The request will be routed to a
/// <see cref="StaticFileMiddleware"/> that attempts to serve the file specified by <paramref name="filePath"/>.
/// </summary>
/// <param name="builder">The <see cref="IEndpointRouteBuilder"/>.</param>\
/// <param name="pattern">The route pattern.</param>
/// <param name="filePath">The file path of the file to serve.</param>
/// <param name="options"><see cref="StaticFileOptions"/> for the <see cref="StaticFileMiddleware"/>.</param>
/// <returns>The <see cref="IEndpointRouteBuilder"/></returns>
/// <remarks>
/// <para>
/// <see cref="MapFallbackToFile(IEndpointRouteBuilder, string, string, StaticFileOptions)"/> is intended to handle
/// cases where URL path of the request does not contain a filename, 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>
/// 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 MapFallbackToFile(
this IEndpointRouteBuilder builder,
string pattern,
string filePath,
StaticFileOptions options)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
if (pattern == null)
{
throw new ArgumentNullException(nameof(filePath));
}
if (filePath == null)
{
throw new ArgumentNullException(nameof(filePath));
}
return builder.MapFallback(pattern, CreateRequestDelegate(builder, filePath, options));
}
private static RequestDelegate CreateRequestDelegate(
IEndpointRouteBuilder builder,
string filePath,
StaticFileOptions options = null)
{
var app = builder.CreateApplicationBuilder();
app.Use(next => context =>
{
context.Request.Path = "/" + filePath;
// Set endpoint to null so the static files middleware will handle the request.
context.SetEndpoint(null);
return next(context);
});
if (options == null)
{
app.UseStaticFiles();
}
else
{
app.UseStaticFiles(options);
}
return app.Build();
}
}
}

View File

@ -0,0 +1,131 @@
// 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.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Server.IntegrationTesting;
using Microsoft.AspNetCore.Server.IntegrationTesting.Common;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging.Testing;
using Xunit;
namespace Microsoft.AspNetCore.StaticFiles
{
public class FallbackStaticFileTest : LoggedTest
{
[Fact]
public async Task ReturnsFileForDefaultPattern()
{
var builder = new WebHostBuilder()
.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(LoggerFactory);
})
.UseKestrel()
.UseWebRoot(AppContext.BaseDirectory)
.Configure(app =>
{
var environment = app.ApplicationServices.GetRequiredService<IWebHostEnvironment>();
app.UseRouting(routes =>
{
routes.Map("/hello", context =>
{
return context.Response.WriteAsync("Hello, world!");
});
routes.MapFallbackToFile("default.html", new StaticFileOptions()
{
FileProvider = new PhysicalFileProvider(Path.Combine(environment.WebRootPath, "SubFolder")),
});
});
});
using (var server = builder.Start(TestUrlHelper.GetTestUrl(ServerType.Kestrel)))
{
var environment = server.Services.GetRequiredService<IWebHostEnvironment>();
using (var client = new HttpClient { BaseAddress = new Uri(server.GetAddress()) })
{
var response = await client.GetAsync("hello");
var responseText = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Hello, world!", responseText);
response = await client.GetAsync("/");
var responseContent = await response.Content.ReadAsByteArrayAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
AssertFileEquals(environment, "SubFolder/default.html", responseContent);
}
}
}
[Fact]
public async Task ReturnsFileForCustomPattern()
{
var builder = new WebHostBuilder()
.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(LoggerFactory);
})
.UseKestrel()
.UseWebRoot(AppContext.BaseDirectory)
.Configure(app =>
{
app.UseRouting(routes =>
{
routes.Map("/hello", context =>
{
return context.Response.WriteAsync("Hello, world!");
});
routes.MapFallbackToFile("/prefix/{*path:nonfile}", "TestDocument.txt");
});
});
using (var server = builder.Start(TestUrlHelper.GetTestUrl(ServerType.Kestrel)))
{
var environment = server.Services.GetRequiredService<IWebHostEnvironment>();
using (var client = new HttpClient { BaseAddress = new Uri(server.GetAddress()) })
{
var response = await client.GetAsync("hello");
var responseText = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Hello, world!", responseText);
response = await client.GetAsync("prefix/Some-Path");
var responseContent = await response.Content.ReadAsByteArrayAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
AssertFileEquals(environment, "TestDocument.txt", responseContent);
}
}
}
private static void AssertFileEquals(IWebHostEnvironment environment, string filePath, byte[] responseContent)
{
var fileInfo = environment.WebRootFileProvider.GetFileInfo(filePath);
Assert.NotNull(fileInfo);
Assert.True(fileInfo.Exists);
using (var stream = fileInfo.CreateReadStream())
{
var fileContents = new byte[stream.Length];
stream.Read(fileContents, 0, (int)stream.Length);
Assert.True(responseContent.SequenceEqual(fileContents));
}
}
}
}