Build servers in Hyper using Trout
By Oskar WickströmThe purpose of this package, Hypertrout, is writing web servers using the type-level routing API in Trout. It provides a router middleware which, together with records of handler functions for resources, and rendering instances, gives us a full-fledged server.
Let’s say we want to render a home page as HTML. We start out by declaring the data type Home
, and the structure of our site:
data Home = Home
type Site1 = Resource (Get Home HTML)
Resource (Get Home HTML)
is a routing type with only one resource, responding to HTTP GET requests, rendering a Home
value as HTML. So where does the Home
value come from? We provide it using a handler inside a resource record. A resource record for Site1
would be some value of the following type:
forall m. Monad m => {"GET" :: ExceptT RoutingError m Home}
The resource record has fields for each supported HTTP method, with values being the corresponding handlers. A resource record type, supporting both GET and POST, could have the following type:
forall m. Monad m => { "GET" :: ExceptT RoutingError m SomeType
, "POST" :: ExceptT RoutingError m SomeType
}
We can construct a resource record for the Site1
routing type using pure
and a Home
value:
home :: forall m. Applicative m => {"GET" :: m Home}
home = {"GET": pure Home}
Nice! But what comes out on the other end? We need something that renders the Home
value as HTML. By providing an instance of EncodeHTML
for Home
, we instruct the resource how to render.
instance encodeHTMLHome :: EncodeHTML Home where
encodeHTML Home =
p (text "Welcome to my site!")
The HTML
type is a phantom type, only used as a marker type, and the actual markup is written in the MarkupM
DSL from purescript-smolder.
We are getting ready to create the server. First, we need a value-level representation of the Site1
type, to be able to pass it to the router
function. For that we use Proxy. Its documentation describes it as follows:
The Proxy type and values are for situations where type information is required for an input to determine the type of an output, but where it is not possible or convenient to provide a value for the input.
We create a top-level definition of the type Proxy Site1
with the value constructor Proxy
.
site1 :: Proxy Site1
site1 = Proxy
We pass the proxy, our handler, and the onRoutingError
function for cases where no route matched the request, to the router
function.
onRoutingError status msg =
writeStatus status
:*> contentType textHTML
:*> closeHeaders
:*> respond (maybe "" id msg)
siteRouter = router site1 home onRoutingError
The value returned by router
is regular middleware, ready to be passed to a server.
main :: forall e. Eff (http :: HTTP, console :: CONSOLE, buffer :: BUFFER | e) Unit
main =
runServer defaultOptions {} siteRouter
Real-world servers often need more than one resource. To combine multiple resources, resource routing types are separated using the :<|>
operator, the type-level operator for separating alternatives.
RoutingType1 :<|> RoutingType2 :<|> ... :<|> RoutingTypeN
When combining multiple resources in a routing type, each resource has to be named. The :=
type-level operator names a resource, or another nested structure of resources, using a Symbol on the left-hand side, and a routing type on the right-hand side.
"<resource-name>" := RoutingType
The following is a routing type for two resources, named "foo"
and "bar"
:
"foo" := Resource (Get Foo HTML)
:<|> "bar" := Resource (Get Bar HTML)
Named routes can be nested to create a structure of arbitrary depth, a practice useful for grouping related resources:
type UserResources =
"profile" := Resource (Get UserProfile HTML)
:<|> "settings" := Resource (Get UserSettings HTML)
type AdminResources =
"users" := Resource (Get Users HTML)
:<|> "logs" := Resource (Get Logs HTML)
type MyNestedResources =
"user" := UserResources
:<|> "admin" := AdminResources
Let’s define a router for an application that shows a home page with links, a page listing users, and a page rendering a specific user.
data Home = Home
data AllUsers = AllUsers (Array User)
newtype User = User { id :: Int, name :: String }
type Site2 =
"home" := Resource (Get Home HTML)
:<|> "users" := "users" :/ Resource (Get AllUsers HTML)
:<|> "user" := "users" :/ Capture "user-id" Int
:> Resource (Get User HTML)
site2 :: Proxy Site2
site2 = Proxy
There are some new things in this code that we haven’t talked about, and some we touched upon a bit. Here’s a walk-through of what’s going on:
:<|>
is the type-level operator that, in general, separates alternatives. In case of resources, a router will try each route in order until one matches.:=
names a route, where the left-hand argument is a Symbol, the name, and the right-hand argument is a routing type. Named routes are combined with :<|>
, as explained previously.:/
separates a literal path segment and the rest of the routing type. Note that a named routing type, created with :=
, has no relation to literal path segments. In other words, if want a resource named "foo"
to be served under the path /foo
, we write:
"foo" := "foo" :/ ...
Capture
takes a descriptive string and a type. It takes the next available path segment and tries to convert it to the given type. Each capture in a routing type corresponds to an argument in the handler function.:>
separates a routing type modifier, like Capture
, and the rest of the routing type.
We define a resource record using regular functions on the specified data types, returning ExceptT RoutingError m a
values, where m
is the monad of our middleware, and a
is the type to render for the resource and method.
homeResource :: forall m. Monad m => {"GET" :: ExceptT RoutingError m Home}
homeResource = {"GET": pure Home}
usersResource :: forall m. Monad m => {"GET" :: ExceptT RoutingError m AllUsers}
usersResource = {"GET": AllUsers <$> getUsers}
userResource :: forall m. Monad m => Int -> {"GET" :: ExceptT RoutingError m User}
userResource id' =
{"GET":
find (\(User u) -> u.id == id') <$> getUsers >>=
case _ of
Just user -> pure user
Nothing ->
throwError (HTTPError { status: statusNotFound
, message: Just "User not found."
})
}
As in the single-resource example, we want to render as HTML. Let’s create instances for our data types. Notice how we can create links between routes in a type-safe manner.
instance encodeHTMLHome :: EncodeHTML Home where
encodeHTML Home =
let {users} = linksTo site2
in p do
text "Welcome to my site! Go check out my "
linkTo users (text "Users")
text "."
instance encodeHTMLAllUsers :: EncodeHTML AllUsers where
encodeHTML (AllUsers users) =
div do
h1 (text "Users")
ul (traverse_ linkToUser users)
where
linkToUser (User u) =
let {user} = linksTo site2
in li (linkTo (user u.id) (text u.name))
instance encodeHTMLUser :: EncodeHTML User where
encodeHTML (User { name }) =
h1 (text name)
The record destructuring on the value returned by linksTo
extracts the correct link, based on the names from the routing type. Each link will have a type based on the corresponding resource. user
in the previous code has type Int -> URI
, while users
has no captures and thus has type URI
.
We are still missing getUsers
, our source of User
values. In a real application it would probably be a database query, but for this example we simply hard-code some famous users of proper instruments.
getUsers :: forall m. Applicative m => m (Array User)
getUsers =
pure
[ User { id: 1, name: "John Paul Jones" }
, User { id: 2, name: "Tal Wilkenfeld" }
, User { id: 3, name: "John Patitucci" }
, User { id: 4, name: "Jaco Pastorious" }
]
Almost done! We just need to create the router, and start a server.
main :: forall e. Eff (http :: HTTP, console :: CONSOLE, buffer :: BUFFER | e) Unit
main =
let resources = { home: homeResource
, users: usersResource
, user: userResource
}
otherSiteRouter =
router site2 resources onRoutingError
onRoutingError status msg =
writeStatus status
:*> contentType textHTML
:*> closeHeaders
:*> respond (maybe "" id msg)
in runServer defaultOptions {} otherSiteRouter
Notice how the resources
record matches the names and structure of our routing type. If we fail to match the type we get a compile error.
So far we have just used a single method per resource, the Get
method. By replacing the single method type with a sequence of alternatives, constructed with the type-level operator :<|>
, we get a resource with multiple methods.
type MultiMethodExample =
"user" := Resource (Get User HTML :<|> Delete User HTML)
MultiMethodExample
is a routing type with a single resource, named "user"
, which has multiple resource methods. Handlers for the resource methods are provided as a record value, with field names matching the HTTP methods:
resources =
{ user: { "GET": getUser
, "DELETE": deleteUser
}
}
By specifying alternative content types for a method, Hyper can choose a response and content type based on the request Accept
header. This is called content negotiation. Instead of specifying a single type, like HTML
or JSON
, we provide alternatives using :<|>
. All content types must have MimeRender
instances for the response body type.
type Site3 =
"home" := Resource (Get Home HTML)
:<|> "users" := "users" :/ Resource (Get AllUsers (HTML :<|> JSON))
:<|> "user" := "users" :/ Capture "user-id" Int
:> Resource (Get User (HTML :<|> JSON))
By making requests to this site, using Accept
headers, we can see how the router chooses the matching content type (output formatted and shortened for readability).
$ <strong>curl -H 'Accept: application/json' http://localhost:3000/users</strong>
[
{
"name": "John Paul Jones",
"id": "1"
},
{
"name": "Tal Wilkenfeld",
"id": "2"
},
...
]
There is support for wildcards and qualities as well.
$ curl -H 'Accept: text/*;q=1.0' http://localhost:3000/users
<div>
<h1>Users</h1>
<ul>
<li><a href="/users/1">John Paul Jones</a></li>
<li><a href="/users/2">Tal Wilkenfeld</a></li>
...
</ul>
</div>