HackedByChinese HackedByChinese - 3 months ago 96
ASP.NET (C#) Question

How to intercept 404 using Owin middleware

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?

Answer

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