Giraffe: Nesting route combinators

Created on 12 Feb 2017  路  15Comments  路  Source: giraffe-fsharp/Giraffe

As a newcomer to AspNetCore.Lambda, I've been working on exploring the library. During this process, I attempted to do something like this:

let app =
  choose [
    route "/ping" >>= text "pong"
    route "/" >>= text "index"
    route "/api"
      >>= choose [
        route "" >>= setStatusCode 204 >>= text "No Content"
        route "/users" >>= text "users"
      ]
    route "/docs" >>= text "docs"
  ]

expecting a request to /api would return a 204 No Content response and a request to /api/users would return "users". To my surprise, I received a 404 Not Found for both. After doing some digging, I discovered that route tests if the given path matches the request path, so with route "/users" above, it compares a request to /api/users with the given /users, fails, and returns None.

I wasn't able to find anything in the existing code base to handle this, so I added the following as a helper module in my application to achieve the expected behavior:

let optionally pred value =
  if pred then Some value else None

let getCurrentRoot (ctx : HttpContext) : string =
  if ctx.Items.ContainsKey "rootPath"
  then ctx.Items.Item "rootPath" |> string
  else ""

let rootPath (part : string) =
  fun (ctx : HttpHandlerContext) ->
    let root = getCurrentRoot ctx.HttpContext
    ctx.HttpContext.Items.Item "rootPath" <- root + part
    ctx
    |> Some
    |> async.Return

let subPath (part : string) =
  fun (ctx : HttpHandlerContext) ->
    let fullPath = (getCurrentRoot ctx.HttpContext) + part
    ctx
    |> optionally (fullPath = ctx.HttpContext.Request.Path.ToString())
    |> async.Return

allowing the following to work as expected:

let app =
  choose [
    route "/ping" >>= text "pong"
    route "/" >>= text "index"
    rootPath "/api"
      >>= choose [
        subPath "" >>= setStatusCode 204 >>= text "No Content"
        subPath "/users" >>= text "users"
      ]
    route "/docs" >>= text "docs"
  ]

Does something like this exist today? If not, would it be useful to include this functionality as a part of AspNetCore.Lambda?

Most helpful comment

I will attempt this evening to implement the following api, as suggested by a friend:

let app =
  choose [
    route "/ping" >>= text "pong"
    route "/" >>= text "index"
    subPath "/api" (choose [
        route "" >>= setStatusCode 204 >>= text "No Content"
        route "/users" >>= text "users"
      ])
    route "/docs" >>= text "docs"
  ]

So the change would be to add subPath that takes a path and a handler (no >>= required), that way it maintains the scope so that the HttpContext Item value doesn't bleed out into other handlers (and you could nest them), and to make route aware of this.

All 15 comments

I would love to see something like this. I would prefer it to be just one method, but because you are flowing the path through HttpContext you have to have this rigid ordering of these functions. Not sure if there's another way to achieve this?

Hi,

What about using the routeStartsWith handler?

let app =
    choose [
        route "/ping"   >>= text "pong"
        route "/"       >>= text "index"
        routeStartsWith "/api" >>=
            choose [
                route "/api"        >>= setStatusCode 204 >>= text "No Content"
                route "/api/users"  >>= text "users"
            ]
        route "/docs"   >>= text "docs"
    ]

This still requires the full route in the sub paths, but I think it probably solves most use cases. The routeStartsWith is useful when you want to add an additional handler above all /api routes only once (e.g. for authentication). It also doesn't require to store intermediate state in the items collection, which could be easily tampered with by other handlers.

I should probably document this somewhere, but I would like to keep the standard library as small as possible to avoid bloat over time. New handlers should ideally be useful to a broad range of users. When in doubt I rather risk not adding a useful handler in the beginning than adding too many too early, especially because most handlers are so small and quick to write that some missing functionality can always be added in a project where needed.

For example, currently I use Lambda myself for a couple projects and I added new handlers there as well, but refused to add them to the standard list yet, because I want to see if they really prove to be useful over time to deserve being added for everyone.

Having said that, I am open to the idea of your proposed handler. Could you perhaps explain the use case you have for it?

I was just thinking would adding a routeEndsWith handler satisfy your use case as well?

Example:

let app =
    choose [
        route "/ping"   >>= text "pong"
        route "/"       >>= text "index"
        routeStartsWith "/api" >>=
            choose [
                routeEndsWith "" >>= setStatusCode 204 >>= text "No Content"
                routeEndsWith "/users" >>= text "users"
            ]
        route "/docs"   >>= text "docs"
    ]

This looks similar to the original example by @slogsdon and doesn't require to store the route in the items collection.

I think this API would also be clearer and less likely to be misused. For example this wouldn't be working, but it wouldn't be immediately clear why:

let app =
  choose [
    route "/ping" >>= text "pong"
    route "/" >>= text "index"
    rootPath "/api" >>=
      rootPath "/v1" >>=
        choose [
          subPath "" >>= setStatusCode 204 >>= text "No Content"
          subPath "/users" >>= text "users"
        ]
    route "/docs" >>= text "docs"
  ]

What are your thoughts?

@dustinmoris The use case behind something like this is to help reduce duplication in and improve composition of parts of an application, specifically around route specifications. One example could be a theoretical resource handler for REST APIs that defines routes for a particular resource type without needing to know the entire route path for the resource. There are some other considerations that would need to be made here, so I don't have a

Frankly, the last example you posted with nested rootPaths would actually work as rootPath accumulates parts, so it would work as expected. 馃憤

TBH, one of my first thoughts was to use routeStartsWith and a potential routeEndsWith, but the possibility of routes matching the beginning and end portions with a variable middle portion. For example, with:

let app =
    choose [
        route "/ping"   >>= text "pong"
        route "/"       >>= text "index"
        routeStartsWith "/api" >>=
            choose [
                routeEndsWith "" >>= setStatusCode 204 >>= text "No Content"
                routeEndsWith "/users" >>= text "users"
            ]
        route "/docs"   >>= text "docs"
    ]

it could match the following request paths:

  • /api
  • /api/users
  • /api/other/users
  • /api/something/that/does-not-exist

Using routeStartsWith alone would prompt me to still create helper functions within an application to reduce duplication in specifying routes.

Along with @slang25's thoughts, an ideal solution would be to have a single function to handle the functionality of rootPath/subPath, but I would also add that it would be ideal to have the functionality exposed today through route, routef, routeCi, routeCif, etc. supported as well.

Are there any negatives with storing request state within HttpContext?

Ok I see what you say and it makes sense.

Actually thanks for pointing out, I forgot to paste something in my example from before. The current rootPath function would never return None, which is an issue I think. Correct me if I a wrong, but with this example we would never get a matching request or a v2 route, because the rootPath item would have /api/v1/v2 at that point, right?

let app =
  choose [
    route "/ping" >>= text "pong"
    route "/" >>= text "index"
    rootPath "/api" >>=
      rootPath "/v1" >>=
        choose [
          subPath "" >>= setStatusCode 204 >>= text "No Content"
          subPath "/users" >>= text "users"
        ]
      rootPath "/v2" >>=
        choose [
          subPath "" >>= setStatusCode 204 >>= text "No Content"
          subPath "/users" >>= text "users"
        ]
    route "/docs" >>= text "docs"
  ]

I think the rootPath function should already validate the request if it matches the part in the root path and if not then skip a whole block of sub paths and proceed to the next. If you'd modify the rootPath function to do that, then I think you could even have it all in one function like this (treat this as untested pseudo code):

let subPath (part : string) =
    fun (ctx : HttpHandlerContext) ->
        let key     = "subPath"
        let current = ctx.HttpContext.Items.Item key |> string
        let path    = current + part
        if ctx.HttpContext.Request.Path.ToString().StartsWith path 
        then
            ctx.HttpContext.Items.Item key <- path
            Some ctx
        else None
        |> async.Return

And you are right, if the above snippet works then this should/could probably be integrated into the existing route handlers, so you could simply do something like:

let app =
    choose [
        route "/ping"   >>= text "pong"
        route "/"       >>= text "index"
        route "/api" >>=
            choose [
                route "" >>= setStatusCode 204 >>= text "No Content"
                routeCi "/users" >>= text "users"
            ]
        route "/docs"   >>= text "docs"
    ]

Is that something you think you'd want to do?

I just tested locally, and while a request to /api/v1/* will succeed, any request to /api/v2/* will fail. I hadn't yet gone through any formal testing of this, so edge cases would need to be covered and tested for.

I definitely agree that it would beneficial to add validation inside of the handler to reduce any unnecessary computation.

Ok, try this:

let route (path : string) =
    fun (ctx : HttpHandlerContext) ->
        let key        = "route"
        let storedPath = ctx.HttpContext.Items.Item key |> string
        let path'      = storedPath + path
        if ctx.HttpContext.Request.Path.ToString().StartsWith path'
        then
            ctx.HttpContext.Items.Item key <- path'
            Some ctx
        else None
        |> async.Return

I think this should work and then we could apply the same logic to the other route handlers. It would be a non breaking change to route and would allow you to do what you need as well.

I just ran that locally, and it seems to match routes prematurely. It responded with the handler for / when requesting /api/v1/users.

I can work with this locally to get something that works for route, both keeping existing functionality and the new feature.

Oh yeah true I didn't think about that. Well ok, thanks for looking into this. I am not opposed to have one or two additional handlers for this functionality (e.g. subRoute and subRouteCi). Have a look and see what you think makes most sense. When you add/update tests and update the README file then you can also submit a pull request and I can merge it after a review.

Bit late here in London now so will get back to any more questions tomorrow again! Thanks!

After giving it some thought, I think the original suggestion doesn't really fit, and is better solved with composition.

You could in your own library add the following:

let bindWithPath (path : string, handler : HttpHandler) (handler2 : string -> HttpHandler) =
    fun (ctx : HttpHandlerContext) ->
        async {
            let! result = handler ctx
            match result with
            | None      -> return None
            | Some ctx2 ->
                match ctx2.HttpContext.Response.HasStarted with
                | true  -> return  Some ctx2
                | false -> return! handler2 path ctx2
        }

let (>>==) = bindWithPath // naming stuff is hard, even operators :-D

let routePrefix (path : string) =
    path,
    fun (ctx : HttpHandlerContext) ->
        if ctx.HttpContext.Request.Path.ToString().StartsWith path 
        then Some ctx
        else None
        |> async.Return

Now we have an additional bind operator that can flow through the path prefix like this:

let app =
    choose [
        route "/ping"   >>= text "pong"
        route "/"       >>= text "index"
        routePrefix "/api" >>== fun p ->
            choose [
                route p >>= setStatusCode 204 >>= text "No Content"
                route <| p + "/users" >>= text "users"
            ]
        route "/docs"   >>= text "docs"
    ]

This is a little ugly, and you could probably drop the new bind operator all together (roll it into the routePrefix function). I am against building up a path in HttpContext FWIW.

EDIT: I have thought some more (dangerous), I think using HttpContext is a necessary evil here, this pattern is used by plenty of web middleware architectures. The only thing I am uncomfortable with is the Items value not getting reset at the end of it's scope (like middleware would do), but rather being overwritten each time a "new scope" is needed. Also, if you have scope and keep adding to it, rather than having 2 and only 2 functions you could have 1 function you could nest as many times as you want.

I will attempt this evening to implement the following api, as suggested by a friend:

let app =
  choose [
    route "/ping" >>= text "pong"
    route "/" >>= text "index"
    subPath "/api" (choose [
        route "" >>= setStatusCode 204 >>= text "No Content"
        route "/users" >>= text "users"
      ])
    route "/docs" >>= text "docs"
  ]

So the change would be to add subPath that takes a path and a handler (no >>= required), that way it maintains the scope so that the HttpContext Item value doesn't bleed out into other handlers (and you could nest them), and to make route aware of this.

Ok I see what you're doing. So the subPath handler would add the path to the Items collection of a copy of the HttpHandlerContext and pass it on to the sub handlers, but if none of the sub handlers returns Some HttpHandlerContext then it will not propagate the modified HttpContext to other http handlers?

I like this idea, it's a cleaner design and I like the feature as well. TBH this feature will probably make routeStartsWith sort of redundant, but let's see.

That's a very nice suggestion @slang25.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

nicolocodev picture nicolocodev  路  4Comments

nicolocodev picture nicolocodev  路  8Comments

dustinmoris picture dustinmoris  路  7Comments

hravnx picture hravnx  路  5Comments

padzikm picture padzikm  路  6Comments