How to intercept 404 using Owin middleware

2019-01-26 04:58发布

问题:

Background

First let me explain the background. I am working on a project that attempts to marry a backend server that uses Web API configured via OWIN- hosted on IIS now, but potentially other OWIN-supported hosts in the future- to a frontend using AngularJS.

The AngularJS frontend is entirely static content. I completely avoid server-side technologies such as MVC/Razor, WebForms, Bundles, anything that has to do with the frontend and the assets it uses, and defer instead to the latest and greatest techniques using Node.js, Grunt/Gulp, etc. to handle CSS compilation, bundling, minification, etc. For reasons I won't go into here, I keep the frontend and server projects in separate locations within the same project (rather than stick them all in the Host project directly (see crude diagram below).

MyProject.sln
server
  MyProject.Host
     MyProject.Host.csproj
     Startup.cs
     (etc.)
frontend
  MyProjectApp
     app.js
     index.html
     MyProjectApp.njproj
     (etc.)

So as far as the frontend is concerned, all I need to do is get my Host to serve my static content. In Express.js, this is trivial. With OWIN, I was able to do this easily using Microsoft.Owin.StaticFiles middleware, and it works great (it's very slick).

Here is my OwinStartup configuration:

string dir = AppDomain.CurrentDomain.RelativeSearchPath; // get executing path
string contentPath = Path.GetFullPath(Path.Combine(dir, @"../../../frontend/MyProjectApp")); // resolve nearby frontend project directory

app.UseFileServer(new FileServerOptions
{
    EnableDefaultFiles = true,
    FileSystem = new PhysicalFileSystem(contentPath),
    RequestPath = new PathString(string.Empty) // starts at the root of the host
});

// ensure the above occur before map handler to prevent native static content handler
app.UseStageMarker(PipelineStage.MapHandler);

The Catch

Basically, it just hosts everything in frontend/MyProjectApp as if it were right inside the root of MyProject.Host. So naturally, if you request a file that doesn't exist, IIS generates a 404 error.

Now, because this is an AngularJS app, and it supports html5mode, I will have some routes that aren't physical files on the server, but are handled as routes in the AngularJS app. If a user were to drop onto an AngularJS (anything other than index.html or a file that physically exists, in this example), I would get a 404 even though that route might be valid in the AngularJS app. Therefore, I need my OWIN middleware to return the index.html file in the event a requested file does not exist, and let my AngularJS app figure out if it really is a 404.

If you're familiar with SPAs and AngularJS, this is a normal and straight-forward approach. If I were using MVC or ASP.NET routing, I could just set the default route to an MVC controller that returns my index.html, or something along those lines. However, I've already stated I'm not using MVC and I'm trying to keep this as simple and lightweight as possible.

This user had a similar dilemma and solved it with IIS rewriting. In my case, it doesn't work because a) my content doesn't physically exist where the rewrite URL module can find it, so it always returns index.html and b) I want something that doesn't rely on IIS, but is handled within OWIN middleware so it can be used flexibly.

TL;DNR me, for crying out loud.

Simple, how can I intercept a 404 Not Found and return the content of (note: not redirect) my FileServer-served index.html using OWIN middleware?

回答1:

If you're using OWIN, you should be able to use this:

using AppFunc = Func<
       IDictionary<string, object>, // Environment
       Task>; // Done

public static class AngularServerExtension
{
    public static IAppBuilder UseAngularServer(this IAppBuilder builder, string rootPath, string entryPath)
    {
        var options = new AngularServerOptions()
        {
            FileServerOptions = new FileServerOptions()
            {
                EnableDirectoryBrowsing = false,
                FileSystem = new PhysicalFileSystem(System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, rootPath))
            },
            EntryPath = new PathString(entryPath)
        };

        builder.UseDefaultFiles(options.FileServerOptions.DefaultFilesOptions);

        return builder.Use(new Func<AppFunc, AppFunc>(next => new AngularServerMiddleware(next, options).Invoke));    
    }
}

public class AngularServerOptions
{
    public FileServerOptions FileServerOptions { get; set; }

    public PathString EntryPath { get; set; }

    public bool Html5Mode
    {
        get
        {
            return EntryPath.HasValue;
        }
    }

    public AngularServerOptions()
    {
        FileServerOptions = new FileServerOptions();
        EntryPath = PathString.Empty;
    }
}

public class AngularServerMiddleware
{
    private readonly AngularServerOptions _options;
    private readonly AppFunc _next;
    private readonly StaticFileMiddleware _innerMiddleware;

    public AngularServerMiddleware(AppFunc next, AngularServerOptions options)
    {
        _next = next;
        _options = options;

        _innerMiddleware = new StaticFileMiddleware(next, options.FileServerOptions.StaticFileOptions);
    }

    public async Task Invoke(IDictionary<string, object> arg)
    {
        await _innerMiddleware.Invoke(arg);
        // route to root path if the status code is 404
        // and need support angular html5mode
        if ((int)arg["owin.ResponseStatusCode"] == 404 && _options.Html5Mode)
        {
            arg["owin.RequestPath"] = _options.EntryPath.Value;
            await _innerMiddleware.Invoke(arg);
        }
    }
}


回答2:

The solution that Javier Figueroa provided really works for my project. The back end of my program is an OWIN self-hosted webserver, and I use AngularJS with html5Mode enabled as the front end. I tried many different ways writing a IOwinContext middleware and none of them works till I found this one, it finally works! Thanks for sharing this solution.

solution provided by Javier Figueroa

By the way, the following is how I apply that AngularServerExtension in my OWIN startup class:

        // declare the use of UseAngularServer extention
        // "/" <= the rootPath
        // "/index.html" <= the entryPath
        app.UseAngularServer("/", "/index.html");

        // Setting OWIN based web root directory
        app.UseFileServer(new FileServerOptions()
        {
            RequestPath = PathString.Empty,
            FileSystem = new PhysicalFileSystem(@staticFilesDir), // point to the root directory of my web server
        });


回答3:

I wrote this little middleware component, but I don't know if it's overkill, inefficient, or if there are other pitfalls. Basically it just takes in the same FileServerOptions the FileServerMiddleware uses, the most important part being the FileSystem we're using. It is placed before the aforementioned middleware and does a quick check to see if the requested path exists. If not, the request path is rewritten as "index.html", and the normal StaticFileMiddleware will take over from there.

Obviously it could stand to be cleaned up for reuse, including a way to define different default files for different root paths (e.g. anything requested from "/feature1" that is missing should use "/feature1/index.html", likewise with "/feature2" and "/feature2/default.html", etc.).

But for now, it this works for me. This has a dependency on Microsoft.Owin.StaticFiles, obviously.

public class DefaultFileRewriterMiddleware : OwinMiddleware
{
    private readonly FileServerOptions _options;

    /// <summary>
    /// Instantiates the middleware with an optional pointer to the next component.
    /// </summary>
    /// <param name="next"/>
    /// <param name="options"></param>
    public DefaultFileRewriterMiddleware(OwinMiddleware next, FileServerOptions options) : base(next)
    {
        _options = options;
    }

    #region Overrides of OwinMiddleware

    /// <summary>
    /// Process an individual request.
    /// </summary>
    /// <param name="context"/>
    /// <returns/>
    public override async Task Invoke(IOwinContext context)
    {
        IFileInfo fileInfo;
        PathString subpath;

        if (!TryMatchPath(context, _options.RequestPath, false, out subpath) ||
            !_options.FileSystem.TryGetFileInfo(subpath.Value, out fileInfo))
        {
            context.Request.Path = new PathString(_options.RequestPath + "/index.html");
        }

        await Next.Invoke(context);
    }

    #endregion

    internal static bool PathEndsInSlash(PathString path)
    {
        return path.Value.EndsWith("/", StringComparison.Ordinal);
    }

    internal static bool TryMatchPath(IOwinContext context, PathString matchUrl, bool forDirectory, out PathString subpath)
    {
        var path = context.Request.Path;

        if (forDirectory && !PathEndsInSlash(path))
        {
            path += new PathString("/");
        }

        if (path.StartsWithSegments(matchUrl, out subpath))
        {
            return true;
        }
        return false;
    }
}


回答4:

Answer given by Javier Figueroa here works and really helpful! Thanks for that! However, it has one odd behavior: whenever nothing exists (including the entry file), it runs next pipeline twice. For example, below test fails when I apply that implementation through UseHtml5Mode:

[Test]
public async Task ShouldRunNextMiddlewareOnceWhenNothingExists()
{
    // ARRANGE
    int hitCount = 0;
    var server = TestServer.Create(app =>
    {
        app.UseHtml5Mode("test-resources", "/does-not-exist.html");
        app.UseCountingMiddleware(() => { hitCount++; });
    });

    using (server)
    {
        // ACT
        await server.HttpClient.GetAsync("/does-not-exist.html");

        // ASSERT
        Assert.AreEqual(1, hitCount);
    }
}

A few notes about my above test if anyone is intrested:

  • It uses Microsoft.Owin.Testing.
  • Test framework is NUnit.
  • UseCountingMiddleware implementation is available here.

The implementation I went with which makes the above test pass is as below:

namespace Foo 
{
    using AppFunc = Func<IDictionary<string, object>, Task>;

    public class Html5ModeMiddleware
    {
        private readonly Html5ModeOptions m_Options;
        private readonly StaticFileMiddleware m_InnerMiddleware;
        private readonly StaticFileMiddleware m_EntryPointAwareInnerMiddleware;

        public Html5ModeMiddleware(AppFunc next, Html5ModeOptions options)
        {
            if (next == null) throw new ArgumentNullException(nameof(next));
            if (options == null) throw new ArgumentNullException(nameof(options));

            m_Options = options;
            m_InnerMiddleware = new StaticFileMiddleware(next, options.FileServerOptions.StaticFileOptions);
            m_EntryPointAwareInnerMiddleware = new StaticFileMiddleware((environment) =>
            {
                var context = new OwinContext(environment);
                context.Request.Path = m_Options.EntryPath;
                return m_InnerMiddleware.Invoke(environment);

            }, options.FileServerOptions.StaticFileOptions);
        }

        public Task Invoke(IDictionary<string, object> environment) => 
            m_EntryPointAwareInnerMiddleware.Invoke(environment);
    }
}

The extension is pretty similar:

namespace Owin
{
    using AppFunc = Func<IDictionary<string, object>, Task>;

    public static class AppBuilderExtensions
    {
        public static IAppBuilder UseHtml5Mode(this IAppBuilder app, string rootPath, string entryPath)
        {
            if (app == null) throw new ArgumentNullException(nameof(app));
            if (rootPath == null) throw new ArgumentNullException(nameof(rootPath));
            if (entryPath == null) throw new ArgumentNullException(nameof(entryPath));

            var options = new Html5ModeOptions
            {
                EntryPath = new PathString(entryPath),
                FileServerOptions = new FileServerOptions()
                {
                    EnableDirectoryBrowsing = false,
                    FileSystem = new PhysicalFileSystem(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, rootPath))
                }
            };

            app.UseDefaultFiles(options.FileServerOptions.DefaultFilesOptions);

            return app.Use(new Func<AppFunc, AppFunc>(next => new Html5ModeMiddleware(next, options).Invoke));
        }
    }
}