10 KiB
Routing in HTTPurple 🪁
Table of contents
- Routing introduction
- The request record
- Matching HTTP methods
- Matching paths and query parameters
- Working with request headers
Routing introduction
HTTPurple 🪁 uses routing-duplex
for routing.
You'll need two things:
- A data type representing your routes
- 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 ofHTTPurple.Method
.route
- A data type representing your route with paths and query parameters.headers
- AHTTPurple.Headers
object. TheHTTPurple.Headers
newtype wraps theObject String
type and provides some typeclass instances that make more sense when working with HTTP headers.body
- AString
containing the contents of the request body, or an emptyString
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.