diff --git a/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.csproj b/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.csproj
index be1f1f3424..0bd2351f38 100644
--- a/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.csproj
+++ b/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs b/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs
index e7dba441d3..06fc6c792c 100644
--- a/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs
+++ b/src/Middleware/StaticFiles/ref/Microsoft.AspNetCore.StaticFiles.netcoreapp3.0.cs
@@ -58,6 +58,13 @@ namespace Microsoft.AspNetCore.Builder
public System.Action 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
{
diff --git a/src/Middleware/StaticFiles/src/Microsoft.AspNetCore.StaticFiles.csproj b/src/Middleware/StaticFiles/src/Microsoft.AspNetCore.StaticFiles.csproj
index 1b8d3d29a0..c4a164b987 100644
--- a/src/Middleware/StaticFiles/src/Microsoft.AspNetCore.StaticFiles.csproj
+++ b/src/Middleware/StaticFiles/src/Microsoft.AspNetCore.StaticFiles.csproj
@@ -1,4 +1,4 @@
-
+
ASP.NET Core static files middleware. Includes middleware for serving static files, directory browsing, and default files.
@@ -16,6 +16,7 @@
+
diff --git a/src/Middleware/StaticFiles/src/StaticFilesEndpointRouteBuilderExtensions.cs b/src/Middleware/StaticFiles/src/StaticFilesEndpointRouteBuilderExtensions.cs
new file mode 100644
index 0000000000..73c32b5467
--- /dev/null
+++ b/src/Middleware/StaticFiles/src/StaticFilesEndpointRouteBuilderExtensions.cs
@@ -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
+{
+ ///
+ /// Contains extension methods for using static files with endpoint routing.
+ ///
+ public static class StaticFilesEndpointRouteBuilderExtensions
+ {
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-filenames with the lowest possible priority. The request will be routed to a
+ /// that attempts to serve the file specified by .
+ ///
+ /// The .
+ /// The file path of the file to serve.
+ /// The
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// The default for the will be used.
+ ///
+ ///
+ /// registers an endpoint using the pattern
+ /// {*path:nonfile}. The order of the registered endpoint will be int.MaxValue.
+ ///
+ ///
+ 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));
+ }
+
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-filenames with the lowest possible priority. The request will be routed to a
+ /// that attempts to serve the file specified by .
+ ///
+ /// The .
+ /// The file path of the file to serve.
+ /// for the .
+ /// The
+ ///
+ ///
+ /// 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 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));
+ }
+
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-filenames with the lowest possible priority. The request will be routed to a
+ /// that attempts to serve the file specified by .
+ ///
+ /// The .
+ /// The route pattern.
+ /// The file path of the file to serve.
+ /// The
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// The default for the will be used.
+ ///
+ ///
+ /// 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 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));
+ }
+
+ ///
+ /// Adds a specialized to the that will match
+ /// requests for non-filenames with the lowest possible priority. The request will be routed to a
+ /// that attempts to serve the file specified by .
+ ///
+ /// The .\
+ /// The route pattern.
+ /// The file path of the file to serve.
+ /// for the .
+ /// The
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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 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();
+ }
+ }
+}
diff --git a/src/Middleware/StaticFiles/test/FunctionalTests/FallbackStaticFileTest.cs b/src/Middleware/StaticFiles/test/FunctionalTests/FallbackStaticFileTest.cs
new file mode 100644
index 0000000000..00dc48ae08
--- /dev/null
+++ b/src/Middleware/StaticFiles/test/FunctionalTests/FallbackStaticFileTest.cs
@@ -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();
+
+ 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();
+ 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();
+ 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));
+ }
+ }
+ }
+}