Copyright | (c) Frederick Pringle 2024 |
---|---|
License | BSD-3-Clause |
Maintainer | freddyjepringle@gmail.com |
Safe Haskell | Safe-Inferred |
Language | Haskell2010 |
This package provides two things:
- A simple, and probably incomplete, way to represent APIs at the term level.
This is achieved by the
Route
,Routes
,Path
,Param
,HeaderRep
types. - More interestingly, a way to automatically generate the routes from any Servant API. This is
accomplished using the
HasRoutes
typeclass. You can think of this as being a less sophisticated version ofHasOpenApi
from servant-openapi3, or a more sophisticated version oflayout
from servant-server.
Motivation
Refactoring Servant API types is quite error-prone, especially when you have to move
around lots of :<|>
and :>
. So it's very possible that the route structure could
change in that refactoring, without being caught by the type-checker.
The HasRoutes
class could help as a golden test - run getRoutes
before and after
the refactor, and if they give the same result you can be much more confident that the
refactor didn't introduce difficult bugs.
/Note that printRoutes
only includes the path, method and query parameters.
For more detailed comparison, use the JSON instance of Routes
, encode the routes to
a file (before and after the refactoring), and use jdiff./
Another use-case is in testing: some Haskellers use type families to modify Servant APIs, for example
to add endpoints or authorisation headers. Types are hard to test, but terms are easy. Use HasRoutes
and run your tests on Routes
.
API routes
To render all of an API's Route
s as JSON, we need to identify each route by its
path AND its method (since 2 routes can have the same path but different method).
This newtype lets us represent this nested structure.
A simple representation of a single endpoint of an API.
The Route
type is not sophisticated, and its internals are hidden.
Create Route
s using defRoute
, and update its fields using the provided lenses.
Automatic generation of routes for Servant API types
Now we can automatically generate a Routes
for any Servant combinator. In most cases
the user should never need to implement HasRoutes
unless they're hacking on Servant or
defining their own combinators.
class HasRoutes api where Source #
Get a simple list of all the routes in an API.
One use-case, which was the original motivation for the class, is refactoring Servant APIs
to use NamedRoutes
. It's a fiddly, repetitive, and error-prone process, and it's very
easy to make small mistakes. A lot of these errors will be caught by the type-checker, e.g.
if the type signature of a handler function doesn't match the ServerT
of its API type.
However there are some errrors that wouldn't be caught by the type-checker, such as missing
out path parts.
For example, if our original API looked like
type API = "api" :> "v2" :> ( "users" :> Get '[JSON] [User] :<|> "user" :> ReqBody '[JSON] UserData :> Post '[JSON] UserId ) server :: Server API server = listUsers :<|> createUser where ...
and we refactored to
data RefactoredAPI mode = RefactoredAPI { listUsers :: mode :- "api" :> "v2" :> "users" :> Get '[JSON] [User] , createUser :: mode :- "user" :> ReqBody '[JSON] UserData :> Post '[JSON] UserId } deriving Generic server :: Server (NamedRoutes RefactoredAPI) server = RefactoredAPI {listUsers, createUser} where ...
Oops! We forgot the "api" :> "v2" :>
in the 2nd sub-endpoint. However, since the ServerT
type
is unaffected by adding or remove path parts, this will still compile.
However, if we user HasRoutes
as a sanity check:
ghci> printRoutes @API GET /api/v2/users POST /api/v2/user ghci> printRoutes @(NamedRoutes RefactoredAPI) GET /api/v2/users POST /user
Much clearer to see the mistake. For more detailed output, use the ToJSON
instance:
ghci> BL.putStrLn . encodePretty $ getRoutes @API [ { "auths": [], "method": "GET", "params": [], "path": "/api/v2/users", "request_body": null, "request_headers": [], "response": "[User]", "response_headers": [] }, { "auths": [], "method": "POST", "params": [], "path": "/api/v2/user", "request_body": "UserData", "request_headers": [], "response": "UserId", "response_headers": [] } ] ghci> BL.putStrLn . encodePretty $ getRoutes @(NamedRoutes RefactoredAPI) [ { "auths": [], "method": "GET", "params": [], "path": "/api/v2/users", "request_body": null, "request_headers": [], "response": "[User]", "response_headers": [] }, { "auths": [], "method": "POST", "params": [], "path": "/user", -- oops! "request_body": "UserData", "request_headers": [], "response": "UserId", "response_headers": [] } ]
Type-level list of API routes for the given API.
Since TypeApplications
is becoming pervasive, we forego Proxy
here in favour
of getRoutes @API
.