One route type, two sides: a PureScript client and server that share their HTTP API
TeamTavern is a site where people find teammates for online games. Its browser app and its API server are both written in PureScript, in one codebase, compiled by one spago build. The part of that worth writing about is
TeamTavern is a site where people find teammates for online games. Its browser app and its API server are both written in PureScript, in one codebase, compiled by one spago build. The part of that worth writing about is how the two sides talk: every HTTP endpoint is a single type, and both the server's handler and the client's call are derived from it.
This post walks through how that works, with code from the repository. The routing library is Jarilo, part of purescript-bklaric. If you know Haskell's Servant, the idea will be familiar.
A route is a type
Here is the route that signs a player in:
type StartSession =
PostJson_ (Literal "sessions") RequestContent
==> (NoContent ! BadRequestJson BadContent ! Internal_)
type RequestContent = Variant
( password :: { emailOrNickname :: String, password :: String }
, discord :: { accessToken :: String }
)
type BadContent = Variant
( unknownPlayer :: {}
, wrongPassword :: {}
, unknownDiscord :: { nickname :: String }
)
Read it left to right: a POST to /sessions with a JSON body, answered with 204 No Content, or a 400 carrying one of three reasons, or a 500. Nothing else.
There is no value of type StartSession. The pieces are declared as bare type-level data:
foreign import data Literal :: Symbol -> Path
foreign import data Capture :: Symbol -> Type -> Path
foreign import data PathChain :: Path -> Path -> Path
infixr 9 type PathChain as /
PostJson_, NoContent and the rest are type synonyms over a handful of these. A route with path parameters looks like this:
type ViewPost =
Get_ (Literal "games" / Capture "handle" String / Literal "posts" / Capture "id" Int)
==> OkJson OkContent ! NotFound_ ! Internal_
Each route lives in its own module under Routes/, next to the record and variant types of its bodies. Server/ and Client/ both import them from there.
The server: a record of handlers
All the routes are joined into one type, each under a name:
type SessionRoutes
= "startSession" : StartSession
<|> "endSession" : EndSession
type AllRoutes
= SessionRoutes
<|> PasswordRoutes
<|> PlayerRoutes
-- ...
The server is one call that takes that type and a record with a handler per name:
serve (Proxy :: _ AllRoutes) serveOptions
{ startSession: \{ cookies, body } ->
Session.start environment mailer discordApiUrl pool cookies body
, viewPost: \{ path, cookies } ->
viewPost pool path.handle path.id cookies
-- ...
}
serve has a constraint, JunctionRouter junction handlers, with a functional dependency from the routes to the record. So the compiler works out the record's type from AllRoutes: which fields it has, what each handler receives and what it may return.
For viewPost, path is { handle :: String, id :: Int }, because the route captures a String named handle and an Int named id. The router has already parsed the segment into an Int by the time the handler runs; a request for /games/valorant/posts/abc is answered before any of my code sees it. For startSession, body is the RequestContent variant, already decoded.
The return type is derived the same way:
RequestResult pathParams queryParams realBody -> Async Void (Variant responseRow)
Two things follow from it.
The handler's error type is Void. It cannot fail; whatever goes wrong inside has to become one of the route's responses. In practice every handler is wrapped in a small function that turns its typed error into the matching response and logs it only when it is the internal one.
And responseRow holds exactly the responses the route declares. StartSession has no 404, so a handler for it that tries to answer notFound does not compile. Neither does a server with a route in AllRoutes and no handler for it.
The client: the same type, read the other way
On the client, a request is a call to fetch with the route as a proxy:
class Fetch (route :: Route) pathParams queryParams realBody responses
| route -> pathParams queryParams realBody responses where
fetch
:: Proxy route
-> Record pathParams
-> Record queryParams
-> realBody
-> ...
-> Promise FetchError (Variant responses)
The functional dependency again does the work. The route decides the method, builds the URL from the path and query records, encodes the body and sets its Content-Type. A call for a post is:
fetchPath (Proxy :: _ ViewPost) { handle, id }
Leave out id, or pass it as a String, and it does not compile.
What comes back is a Variant with a case per declared response, its JSON body already decoded into the route's type. Signing in with Discord, from the sign-in page, trimmed a little:
result <- fetchBody (Proxy :: _ StartSession)
(inj (Proxy :: _ "discord") { accessToken })
case result of
Right response -> response # onMatch
{ noContent: const $ navigateReplace_ back
, badRequest: onMatch
{ unknownDiscord: \{ nickname } ->
set _ { screen = Nickname { accessToken }, nickname = nickname }
}
(const $ failWith somethingWrong)
}
(const $ failWith somethingWrong)
Left _ -> failWith somethingWrong
The nickname the page prefills for a new player is the field the server put in unknownDiscord. There is no step where the client's idea of that payload could drift from the server's, because there is only one definition of it.
What a change looks like
The site has 47 routes. When one changes, the compiler finds what the change touches:
- Rename a field in a response body, and the handler that builds it and every page that reads it fail to compile.
- Add a path capture, and every
fetchPathcall for that route is missing a field. - Remove a response, and the handler that returned it and the pages that matched on it by name fail.
- Add a route to
AllRoutes, and the server does not build until it has a handler.
One case the compiler does not force. Adding a response to a route leaves existing client code compiling wherever it matches with onMatch and a default branch, as above. That is a choice: match instead would make every call site exhaustive, at the cost of spelling out internal everywhere.
Where the types stop
The route type describes what my server sends. It does not describe everything a browser can receive.
The reverse proxy in front of the API refuses a body over 64 KB with a 413, a status no route declares. A deploy can leave a stale client talking to a newer server for a few minutes. fetch handles both the same way: a status the route does not list is a FetchError, not a Variant case, so the page's Left branch runs.
The site also records when that happens. The client's wrapper around fetch reports to the server any response the route does not declare, and any 400, 401, 403 or 404 that the call site did not say it expects:
fetchBody (expecting [ "badRequest" ] (Proxy :: _ UpdateEmail)) body
Those labels are plain strings, and the compiler doesn't check them. Misspell one as "badRequets" and the code still compiles; the only sign is a report in the log the next time that call gets a 400.
The other boundary is the database. Rows are decoded from SQL results at runtime, so a query that returns the wrong shape fails when it runs, not when it compiles. The route types guarantee that what leaves the handler matches what the client expects; they say nothing about whether the handler could produce it.
The same types, read a third way
The server and the client don't share code. They share a type, and each reads it through its own type classes: JunctionRouter on one side, Fetch on the other. Nothing stops a third class from reading the same routes for another purpose.
TeamTavern already has a small one. When the client reports a failed request, it logs the method and path but never the query string, which can carry the one-time codes from links in emails. The line comes from the route:
class RequestLine (route :: Route) pathParams | route -> pathParams where
requestLine :: Proxy route -> Record pathParams -> String
The bigger use is documentation. An OpenAPI document, the format Swagger UI and most API tooling read, is mostly what a route type already says: the method, the path with its parameters, the query parameters, the request body and a response for each status. Haskell's Servant has libraries that generate one from its route types, and the same is possible here.
The path is the easy part. This class turns a Jarilo path into OpenAPI's template syntax:
class OpenApiPath (path :: Path) where
openApiPath :: Proxy path -> String
instance IsSymbol literal => OpenApiPath (Literal literal) where
openApiPath _ = "/" <> reflectSymbol (Proxy :: _ literal)
instance IsSymbol name => OpenApiPath (Capture name value) where
openApiPath _ = "/{" <> reflectSymbol (Proxy :: _ name) <> "}"
instance (OpenApiPath left, OpenApiPath right) => OpenApiPath (PathChain left right) where
openApiPath _ = openApiPath (Proxy :: _ left) <> openApiPath (Proxy :: _ right)
For ViewPost it gives /games/{handle}/posts/{id}. The same pattern extends to the method, a parameter list from the Capture and query types, and a status list from the responses, and a class over AllRoutes then walks every route.
The bodies take more work. Each record, array, Maybe and Variant needs a JSON Schema describing the JSON its codec actually produces, so that class has to follow the codec's choices, such as how a variant is tagged. That's more code than the rest together, but it's written once in the library, not once per route.
TeamTavern doesn't generate one: its only client is its own pages, which get the types directly. An API with outside consumers would, and those consumers would get documentation that is never out of date, because it's produced from the same type the server has to satisfy. The same goes for anything else derived from the routes: a list of endpoints for a test to visit, TypeScript declarations for a client in another language, a mock server that answers with the declared shapes.
Is it worth it?
For me, yes. It moves a whole category of bugs from run time to compile time: a client sending a body the server can't read, a page reading a field the server stopped sending, a URL built with a parameter missing, a handler answering with a status the route never declared. Without shared types, each of those surfaces when someone clicks through the page, or when a user does. With them, it surfaces as a compile error pointing at the line.
That speeds development up more than it slows it down. A change to an endpoint starts with the route type, and the compiler hands back the list of places to update, on both sides at once. When it builds, the two sides agree, and what's left to test is whether the page does the right thing, not whether it talks to the server correctly.
The code is at github.com/bklaric/team-tavern, under AGPL-3.0: routes in src/TeamTavern/Routes, handlers in Server, pages in Client. The site it runs is teamtavern.net.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.