purescript-httpurple/docs/Routing.md
2022-06-13 15:12:05 +01:00

10 KiB

Routing in HTTPurple 🪁

Table of contents

Routing introduction

HTTPurple 🪁 uses routing-duplex for routing.

You'll need two things:

  1. A data type representing your routes
  2. A mapping between the data constructors of your data type and the paths

Here an example:

import HTTPurple

-- We define a data type representing our route
-- In this case, a single route `SayHello` that accepts one parameter
data Route = SayHello String
derive instance Generic Route _ -- a generic instance is needed

-- The mapping between our data constructor `SayHello` and 
-- the route /hello/<argument>
route :: RouteDuplex' Route
route = mkRoute
  { "SayHello": "hello" / string segment
  }

-- We then start the http server passing the route and a handler function (router)
main :: ServerM
main = serve {} { route, router }
  where
  router { route: SayHello name } = ok $ "hello " <> name <> "!"

The router function is called for each inbound request to the HTTPure server. Its signature is:

forall route. HTTPurple.Request route -> HTTPurple.ResponseM

So in HTTPurple, routing is handled simply by the router being a pure function which is passed a value that contains all information about the current request, and which returns a response monad. There's no fancy path parsing and matching algorithm to learn, and everything is pure--you don't get anything or set anything, you simply define the return value given the input parameters, like any other pure function.

This is quite powerful, as all routing can be defined using the same PureScript pattern matching and guard syntax you use everywhere else. It allows you to break up your router to sub-routers easily, using whatever router grouping makes sense for your app. It also leads to some powerful patterns for defining and using middleware. For more details about defining and using middleware, see the Middleware guide.

For more details about the response monad, see the Responses guide.

The Request Record

The HTTPurple.Request route type is the input parameter for the router function. It is a Record type that contains the following fields:

  • method - A member of HTTPurple.Method.
  • route - A data type representing your route with paths and query parameters.
  • headers - A HTTPurple.Headers object. The HTTPurple.Headers newtype wraps the Object String type and provides some typeclass instances that make more sense when working with HTTP headers.
  • body - A String containing the contents of the request body, or an empty String if none was provided.

Following are some more details on working with specific fields, but remember, you can combine guards and pattern matching for any or all of these fields however it makes sense for your use case.

Matching paths and query parameters

Let's have a look at a bit more complex routing scenario. Imagine we are developing the backend service for a simple web shop.We want two define three routes:

  • / which returns the data of the start page
  • /categories/<category>/products/<product> which takes two path parameters category name and product name and returns a certain product
  • /search?q=<query>&sorting=<asc|desc> which takes two query parameters, a search string and an optional sorting argument
-- We define three data types representing the three routes
data Route
  = Home
  | Products String String -- the product route with two path parameters
  | Search { q :: String, sorting :: Maybe String } -- the search route with two query parameters, whereby sorting is optional
derive instance Generic Route _


-- Next we define the route (mapping)
route :: RouteDuplex' Route
route = mkRoute
  { "Home": noArgs -- the root route /
  , "Products": "categories" / string segment / "products" / string segment
  , "Search": "search" ? { q: string, sorting: optional <<< string }
  }

-- Finally, we pass the route (mapping) to the server and also define a route handler
main :: ServerM
main = serve { port: 8080 } { route: route, router }
  where
  router { route: Home } = ok "home"
  router { route: Products category product } = do
    ok $ "category=" <> category <> ", product=" <> product
  router { route: Search { q, sorting } } = ok $ "searching=" <> q <> "," <> case sorting of
    Just "asc" -> "ascending"
    _ -> "descending"

As you can see, in the route handler you can directly pattern match your data type. Pattern matching of the route is exhaustive so that you will get an error if you miss a route.

Adding further types

We can add further type information to our route data type. Instead of treating the path arguments of our product route as String, we can define newtypes for the arguments:

newtype Category = Category String
instance Newtype Category String

newtype Product = Product String
instance Newtype Product String

We'll change our data constructor to accept the two newtypes:

  | Products Category Product

We can then define new segment types to match the different arguments:

category :: RouteDuplex' Category
category = _Newtype R.segment

product :: RouteDuplex' Product
product = _Newtype segment

Now we can update our route mapping:

"Products": "categories" / category / "products" / product

We can furthe add a more expressive type for our sorting query parameter

data Sort = Asc | Desc
derive instance Generic Sort _

We can then define the conversion to and from string and define a new RouteDuplex:

sortToString :: Sort -> String
sortToString = case _ of
  Asc -> "asc"
  Desc -> "desc"

sortFromString :: String -> Either String Sort
sortFromString = case _ of
  "asc" -> Right Asc
  "desc" -> Right Desc
  val -> Left $ "Not a sort: " <> val

sort :: RouteDuplex' String -> RouteDuplex' Sort
sort = as sortToString sortFromString

We can then update our Route data type to use the new Sort data type

  | Search { q :: String, sorting :: Maybe Sort }

and use the new sort function in our route mapping:

"Search": "search" ? { q: string, sorting: optional <<< sort }

As you can see, the RoutingDuplex approach is quite powerful and once you got a grip on it, it is also rather straight-forward to use. I recommend you to also check out the routing-duplex documentation which contains many more examples.

Composing routes

Sometimes you might want to define two route data types to structure your routing logically. Composing routes is straight-forward with HTTPurple. E.g. you could have an ApiRoute representing your business api and a MetaRoute for technical routes, such as a health check:

data ApiRoute
  = Home
derive instance Generic Route _
api :: RouteDuplex' ApiRoute
api = mkRoute
  { "Home": G.noArgs }

data MetaRoute = Health

derive instance Generic MetaRoute _

meta :: RouteDuplex' MetaRoute
meta = mkRoute { "Health": "health" / G.noArgs }

You can compose these two routes using the <+> operator, and compose the routing handlers with orElse:

main :: ServerM
main = serve { port: 8080, notFoundHandler } { route: api <+> meta, router: apiRouter `orElse` metaRouter }
  where

  apiRouter { route: Home } = ok "hello world!"
  metaRouter { route: Health } = ok """{"status":"ok"}"""

Reverse routing

Reverse routing, e.g. for redirects or HATEOAS, is straight-forward using the print function from routing-duplex:

data Route
  = Old
  | New

route :: RouteDuplex' Route
route = mkRoute
  { "Old": "old" / RG.noArgs
  , "New": "new" / RG.noArgs
  }

main :: ServerM
main = serve { port: 8080 } { route, router }
  where
  router { route: Old } = found' redirect ""
    where
    redirect = headers [ Tuple "Location" $ print route $ New account ]

Catch-all route

You can easily create a catch all route by using the predefined catchAll.

import HTTPurple

data Route = CatchAll (Array String) -- list of paths
derive instance Generic Route _ 

route :: RouteDuplex' Route
route = mkRoute
  { "CatchAll": catchAll
  }

main :: ServerM
main = serve {} { route, router }
  where
  router { route: CatchAll paths } = ok $ "hello 🗺!"

Routing prefixes

You can create a route prefix such as /v1/ using the prefix method. Here is an example:

import HTTPurple

data Route = Home -- list of paths
derive instance Generic Route _ 

route :: RouteDuplex' Route
route = root $ prefix "v1" $ sum
  { "Home": noArgs
  }

main :: ServerM
main = serve {} { route, router }
  where
  router { route: Home } = ok $ "hello 🗺!"

Now all requests need to be prefixed with /v1/.

Note: prefix takes exactly one path segment. If you need two prefixes, e.g. for /api/v1/ you need to use two prefixes: root $ prefix "api" $ prefix "v1" $ sum.

Matching HTTP Methods

You can use normal pattern matching to route based on the HTTP method:

router { method: HTTPurple.Post } = HTTPurple.ok "received a post"
router { method: HTTPurple.Get } = HTTPurple.ok "received a get"
router { method } = HTTPurple.ok $ "received a " <> show method

To see the list of methods that HTTPure understands, see the Method module. To see an example server that routes based on the HTTP method, see the Post example.

Working With Request Headers

Headers are again very similar to working with path segments or query parameters:

router { headers }
  | headers !? "X-Foo" = HTTPurple.ok "There is an 'X-Foo' header"
  | headers !@ "X-Foo" == "bar" = HTTPurple.ok "The header 'X-Foo' is 'bar'"
  | otherwise = HTTPurple.ok $ "The value of 'X-Foo' is " <> headers !@ "x-foo"

Note that using the HTTPurple.Lookup typeclass on headers is case-insensitive.

To see an example server that works with headers, see the Headers example.