Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a more ergonomic API around path segment matching #114

Closed
cprussin opened this issue Sep 4, 2018 · 7 comments
Closed

Add a more ergonomic API around path segment matching #114

cprussin opened this issue Sep 4, 2018 · 7 comments
Projects

Comments

@cprussin
Copy link
Collaborator

cprussin commented Sep 4, 2018

The Problem

The current API for matching path segments is entirely built around the Lookup typeclass. This works, but it can get clunky for some common things. For example, you can do this:

router :: HTTPure.Request -> HTTPure.ResponseM
router request@{ path }
  | path !@ 0 == "something" = somethingRoute request
  | otherwise                = HTTPure.notFound

But that will send any request whose first segment is "something" to somethingRoute. Likely the intent is to only send the request to somethingRoute if it has exactly one segment, which is "something", and you'd have to do something like this:

router :: HTTPure.Request -> HTTPure.ResponseM
router request@{ path }
  | path !@ 0 == "something" && length path == 1 = somethingRoute request
  | otherwise                                    = HTTPure.notFound

The API can become very painful as you have longer paths, e.g.:

path !@ 0 == "something" && path !@ 1 == "anotherThing" && path !@ 2 == "somethingElse" && length path == 3

And that doesn't even include doing things like enforcing specific requirements for certain pattern-based path segments, which requires significantly more complexity.

Proposed Solution

router :: HTTPure.Request -> HTTPure.ResponseM
router request@{ path }
  | path =~ "/something"                            = somethingRoute request
  | path =~ "/foo/\d+"                              = fooRoute request
  | path =~ "/something/anotherThing/somethingElse" = ...
  | otherwise                                       = HTTPure.notFound

Introduce two new binary operators, =~ and ==~. These operators take a Path on the left side, and a String on the right side. The Path will be matched against the String using some set of rules heavily inspired by Express's routing engine. =~ will be used for case-insensitive routing, where ==~ can be used for strict (case-sensitive) routing.

The only real change I'm aware of over what express does is that we'll have to come up with a different API around route parameters. Of course, that could be as simple as replacing :namedParameter: with \w+ and using Lookup to reference the parameters. If there were some way to even use the named parameters approach that Express uses, it would be awesome, but I don't see how that would be possible in a purely functional world (at least, without introducing a significantly more complex API, which isn't really worth it--it's a fairly small thing, and if it can't be done without an overly complex API, it's not worth changing to it).

@cprussin
Copy link
Collaborator Author

cprussin commented Sep 4, 2018

@akheron I've put a lot of thought into this issue, I'm not quite convinced on the proposed solution and would love to get feedback/ideas, so if you have any to give, it would be much appreciated!

@cprussin cprussin added this to To Do in 0.8 Sep 4, 2018
@akheron
Copy link
Collaborator

akheron commented Sep 4, 2018

I've used array pattern matching to accomplish good ergonomics, and I think it's the way to go. It solves matching the path and capturing parameters at the same time.

Matching path only:

router { path } =
  case path of
    ["todos", id] -> readTodo id
    ["todos"] -> listTodos
    [] -> frontPage
    _ -> HTTPure.notFound

Matching path and method:

router { method, path } =
  handle method path
  where
    handle HTTPure.Get ["todos", id] -> readTodo id
    handle HTTPure.Put ["todos", id] -> updateTodo id
    handle HTTPure.Get [] -> frontPage
    handle _ [] -> HTTPure.methodNotAllowed
    handle _ _ -> HTTPure.notFound

@cprussin
Copy link
Collaborator Author

cprussin commented Sep 4, 2018

That's what I was originally leaning towards, but I started thinking more along the lines of the express API, because working on array pattern matching is somewhat limited in a few ways. Specifically:

  1. We can't match on specific ID shapes (/todos/\d{4}-\d{6}--contrived but a lot of use cases do build IDs that have specific shapes).
  2. We can't deal in variable-length segments. For instance, in Express, you could match something like (/:locale:)?/todos/:id.
  3. Doing anything involving requiring regexes immediately becomes a non-ergonomic API.

I'm not entirely convinced these 3 are significant problems--they all have workarounds, and it may be cleaner to defer to the workarounds than to move away from PS language features.

For 1., the workaround is just to match on any shape and check the shape in the route handler. I just don't love that, because it means that some aspects of routing are moved out of the router, but it works. For 2, you just have to have two different matches, one for each case--not a huge deal. 3 is a gross API, but totally doable--and perhaps not something we should cater towards, since HTTPure I think should make everything possible, but should make good things easy, and I can't think of a case where complex regex path matching would ever be a good thing.

So I guess the question is, at this time, do you feel that any of these 3 are significant enough limitations (or that the workarounds are gross enough) to be worth catering solutions?

@cprussin
Copy link
Collaborator Author

cprussin commented Sep 4, 2018

Oh, and one other problem with array pattern matching: you can't do case-insensitive matches. That isn't a big issue for something like a REST API, but it's a non-starter for something that serves, e.g., a UI, where addresses are entered by users into address bars in browsers. HTTPure should be able to cater to both use cases. I don't know of any good workaround here, other than making all routes use regexes, which gets back into the gross ergonomics situation.

@akheron
Copy link
Collaborator

akheron commented Sep 5, 2018

The thing I like most in HTTPure is that the serve takes a function that processes all requests. The user is free to implement routing, request handlers, middleware etc. in any way he sees fit. However, providing helpers for common use cases (e.g. routing) would be a big plus, so that each user doesn't have to reinvent the wheel.

I suggest figuring out the best way to create composable routers, which could then be used for different purposes. If the user wants regex route matching, it could be used while also using plain array pattern matching or simple prefix matching for other purposes.

In purescript-httpure-rest-router I approached the problem like this: https://github.com/cprussin/purescript-httpure-rest-router/blob/f8883ba2cc95646ead3933ec7bce8a12b9fc6b90/examples/Todo.purs#L105-L119

The only problem with those arrays of routes and making a router function using Router.router is that routers made like this are not composable, i.e. the router always returns a response even though none of the routes matches (falling back to 404).

PS. Case-insitive matching could be accomplished by e.g. converting the path of some or all requests to lowercase in a middleware function.

@paluh
Copy link
Contributor

paluh commented Sep 5, 2018

Hi,

I'm not sure if I should add any comments as I'm not a contributor but barely a user (we have just started to build a service around httpure)... but anyway here are my two cents:

I really like clean API which is provided by this framework and I think that advanced/opinionated routing options should be delegated to external libraries like: purescript-httpure-rest-router, purescript-routing, purescript-routing-duplex, purescript-boomboom, purescript-boomerang or some other (maybe "contrib") proposition.
Routing can be done in different ways and with different focus in mind. If you are building API you can lean toward performance and simplicity and don't need bidirectional routes, if you are implementing a web service you probably don't want to hard code any urls in your HTMLs and want some automatic serialization for your typed routes etc.

I think that your path representation as just an array of segments is nearly ideal as it is lightweight, covers really simple scenarios and can be base for integration with any other more advanced routing option...

Thanks for your work on httpure!

@cprussin
Copy link
Collaborator Author

cprussin commented Sep 5, 2018

Folks, the feedback here is really helpful, thank you both for leaving those opinions @akheron and @paluh . You guys have convinced me, there are enough tools out there that can be used and the simplicity of HTTPure is valuable. I'll close this ticket as something that doesn't need to happen.

I do think before we go to 1.0 it would be nice to do a better job documenting some common patterns around routing, such as the array pattern matching approach, and using tools like the ones you listed @paluh . I'll add a note in #106 to remember to do that.

Thanks again all!

@cprussin cprussin closed this as completed Sep 5, 2018
@cprussin cprussin moved this from To Do to Done in 0.8 Sep 5, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
0.8
Done
Development

No branches or pull requests

3 participants