aantron/dream

Support a request "falling through" to later routes

Opened this issue · 0 comments

When using other web frameworks, I'm used to being able to just return null/nil from handlers in order to say "this handler cannot handle this request", and so the request "falls through" to a later handler. The most common rationale IMO for doing this is to centralize the serving of static assets (and when necessary, 404's) referred to by N different "modules" in monolithic webapps.

Say you have an app with some scopes, corresponding to logically distinct components:

let main () =
  run
  @@ router
  [ scope "/blog" [] ...
  ; scope "/feeds" [] ...
  ; get "/" ...
  ; get "/**" (Dream.static ~loader:static_loader "")]]
  @@ fun _r -> Dream.respond ~status:`Not_Found "sorry" 

It's not uncommon for pages to refer to static assets within the same prefix, e.g. a blog post at /blog/2021/10/08/foo would typically have any images on that page served from the same directory, so e.g. /blog/2021/10/08/picture.png. However, the static handler in the example above would never see those requests, as they are exclusively handled within the scope provided to the router. Therefore, each scope currently needs its own static asset handler and not-found handler, to accommodate serving those static assets, and issuing 404s as needed for requests within that scope.

(An immediate workaround is to just put all static assets under a dedicated prefix like "/static/**", but (a) that's a silly reason to potentially break extant URLs, and (b) is of little help when integrating content generated by other tools.)

I had expected to find an exception type that Dream.router would catch as a signal to move on to the next handler in the route list…but no such thing exists. My proposal is then that such a thing be added: a custom exception type that any handler can raise to indicate that they can't satisfy the provided request (a "response" distinct from a 404), forcing the router to continue attempting to delegate request handling to the next handler in the chain.

If and until such a thing is available, implementing a somewhat more limited approach is possible using middleware:

exception NoResponse

let handleNoResponse (failsafe : Dream.handler) (routes : Dream.middleware) =
  let h = routes failsafe in
  fun request ->
    try
      h request
    with NoResponse ->
      failsafe request

let static_handler = Dream.static ~loader:static_loader ""

let main () =
  run
  @@ handleNoResponse static_handler
  @@ router
  [ scope "/blog" [] ...
  ; scope "/feeds" [] ...
  ; get "/" ...
  ]]

With this arrangement, any handler in the router can raise NoResponse, and the handleNoResponse middleware will ensure that the "failsafe" handler is invoked (which is also used as the last-resort handler for the router). This is slightly less capable than if the router module itself declared and handled such an exception (the handleNoResponse middleware is sort of a top-level catch-all that can't be composed with anything else, in contrast with regular routes and handlers), but gets the job done for my main use case for this pattern.