Declarative, Informative, Well-Typed API Definitions
TL;DR
A well-typed API interface declaration makes possible an extensible toolset with a ton of practical downstream benefits. Some of the benefits include:
- Documentation generation
- Client generation
- Mock server support
- Reduction in developer workload / increase in integration confidence
- Faster time to market
What's the big idea?
Within the Haskell ecosystem exists a library called Servant which touts itself as "A type-level web DSL". We'll take a look at this library's approach to API interface declaration and consider many of the positive effects. Perhaps in a follow on post there's an opportunity to compare and contrast Servant against other libraries with a similar conceptual design. Suggestions are cetainly welcome.
In their own words, Servant aims to adhere to the following guiding principles
- concision
- flexibility
- separation of concerns
- type safety
Let's look at some simple examples and see where and how this shapes up.
Level setting
Before we dive in, fix your mind on your tool of choice's routing schema. Meaning, if you're a Railsist, think config/routes.rb, if you like Express, picture your files containing your routing definitions like this one:
app.get('/', function (req, res) {
// stuff here
})
As we uncover capabilities provided by Servant, check back with your toolset and think on the differences.
Declaring our API
Defining GET api/users
Let's define a simple route at GET api/users to respond with a JSON list of users.
type UsersIndex = "api" :> "users" :> Get '[JSON] [User]
Hey, that's pretty readable, right? Even if we don't know exactly what the operators are doing, we can extract a bunch of meaningful information from this.
... "api" :> "users" ...
That looks like the path we want api/users. Then we have
... Get '[JSON] [User]
First, this tell us that our path responds to GET requests.
Next the '[JSON] refers to the Accept headers that our route supports. We can see that this route always responds with JSON.
Lastly, [User] says that a request will get back a list of User values. Let's say User is defined as
data User = User
{ userId :: Int64
, userFirstName :: FirstName
, userLastName :: LastName
} deriving (Eq, Show, Generic)
$(deriveJSON defaultOptions ''User)
I'm not going to go into detail about how this works, but I bet if you took a guess at what the JSON representation of User is, you'd be right.
me: Neat, right!?
probably you: I guess. ...I mean, not really.
Well, let's look at one more example, then get into why this is so powerful.
Defining POST api/users
This time, we'll create an endpoint which requires form data in order to create a new User.
type UsersCreate = "api" :> "users"
:> ReqBody '[JSON] UserForm
:> Post '[JSON] User
The interesting bit here is ReqBody '[JSON] UserForm.
This endpoint wants the request body (ReqBody) to contain a JSON formatted UserForm. This time, used with ReqBody, the '[JSON] bit refers to the Content-Type request header. And let's say the UserForm looks something like this
data UserForm = UserForm
{ userFormFirstName :: FirstName
, userFormLastName :: LastName
} deriving (Eq, Show, Generic)
$(deriveJSON defaultOptions ''UserForm)
Again, you're already right about what the JSON representation of this looks like.
Given this endpoint definition, we start to see that Servant is able to take a JSON representation of our UserForm, reify that into a useful structure, and pass it to our handler function. In the case where the request is malformed and won't reify into a proper UserForm, Servant will do the right thing and respond with a 400 Bad Request response. This turns out to be a very nice separation of HTTP semantics from our application logic.
Kinda cool, right? To see this pay off a little more, let's imagine writing a client to work with this API.
Building a client to work with this API.
Let's look at that route definition one more time
type UsersCreate = "api" :> "users"
:> ReqBody '[JSON] UserForm
:> Post '[JSON] User
From this, we know that we need our client to work by
- including a Content-Type header of application/json
- including a request body with a JSON representation of a UserForm (which we unabmiguously know the structure of)
- handling a JSON response payload of a single User (which we also already know the structure of)
So, using curl as our client, we put together something like this
curl \
-X POST \
-H "Content-Type: application/json" \
-d '{ "userFormFirstName": "Cole", "userFormLastName": "Slaw" }' \
http://localhost:3000/api/users
Wow. Ok, yeah, that type definiton told us everything we needed to know to make a valid request.
Let's compare that to an untyped API declaration
Let's compare that to a Rails config/routes.rb entry.
post '/api/users' => 'api/users#create'
We see that we can post to the route api/users, but
- what headers does it accept?
- what is the structure of the payload I need to send?
- what the heck is it going to return?
Now, I'm plenty aware that comparing Haskell to Ruby is unfair in many ways. My point, however, is to show just how much information is packed into that Servant declaration. In the Ruby example we need to go to the actual implementation to find out what headers/parameters this endpoint accepts and what shape the response will take. It's worth noting that, without types to help us, it's very difficult to be sure about what is accepted and returned.
Wait, that brings up an interesting detail...
Coming back to our Servant example, we haven't implemented our route handlers yet!
We're over here happily defining our types (our API interface) and because Servant is amazing, we know full well how to build our client.
you: So, hang on... Since we have a well-defined unambiguous interface, can I go off and implement the server side of things and let you go build a client?
me: YES!
hopefully you: Whoa. π
hopefully our boss: Did you all just find a way to parallelize that work? Here's a bunch of raises.
Types are great, but what about some real documentation?
This has been all well and good so far, but if you're going to go off and write an API client, you'll at least need to be able to read Haskell types. It would be nicer if you had something implementation agnostic to read, don't you think?
It turns out that there's so much information packed into our type-level API declaration that we can generate documentation from our types themselves! That's great because we don't have to maintain the docs separate from our code. Additionally, this means our documentation can never be out of date. And, because of the constraints of the type system, our code won't even complile until the documentation specifics exist and are correct with respect to our API declaration. In other words, the compiler won't let us produce a build if annotations are missing or incorrect. π₯ π
you: Whoa, dang! So, if we get docs for free, can we get other stuff for free, too?
me: Heck yes!
And now the free stuff!
Live Documentation Generation
Servant works well with Swagger API tooling. Swagger has a nice playground set up if you'd like to get a feel for it.
As mentioned earlier, with the API documentation bound to the type definitions, consumers of the docs can rest easy that they're looking at accurate and up-to-date information. Plus, the Swagger presentation is completely decoupled from the implementation, so no need for the doc consumers to understand Haskell (or even care that Haskell is under the hood).
All we have to do on the Haskell side is provide some human-readable information about our API types and π.
Mock Server
We've seen that we can build our client and server implementations in parallel because of the clarity the Servant interface gives us. When building our client, however, there's a gap in feedback and testing until the server side is complete. Wouldn't it be nice if we could cheaply mock out the server in some way?
The servant-mock library does just that. We can perform tests against the mock server and get responses in the same structural format as our real server eventually will. This is great for tests that don't require full integration with our API and gives us that much more confidence that our client is implemented correctly. We can feel really good that our parallel efforts will line up nicely come integration time.
To build our mock server we add some instructions Haskell-side to produce arbirary instances of our API types and π
Client Library Generation
Oh, by the way, that API client we've been talking about building ...we don't have to. We can generate most of that. I mean, if we can generate documentation and mock servers, it stands to reason that we can do the same for our API wrapper library.
Here's a bunch of client generation libraries for various languages. Don't see your language in here? Well, the Ruby generator for example is ~200 lines of Haskell, so I'm sure you can whip one up for your stuff in no time. Plus, your community will love you.
Here's an example of servant-js jQuery output. Bindings for Angular and other frameworks are also available within the servant-js package.
var getApiUsers = function(onSuccess, onError)
{
$.ajax(
{ url: '/api/users'
, success: onSuccess
, error: onError
, type: 'GET'
});
}
var postApiUsers = function(body, onSuccess, onError)
{
$.ajax(
{ url: '/api/users'
, success: onSuccess
, data: JSON.stringify(body)
, contentType: 'application/json'
, error: onError
, type: 'POST'
});
}
Wrap up
Do I think all of this is cool because of fancy type systems or functional programming wizardry? No. (well, yeah, but that wasn't the motivation for writing this post π)
The reason I love this is because it helps teams move very fast with a great deal of confidence that decoupled systems can work and change together in a maintainable way.
We've seen a bit about how Servant helps us:
- separate concerns
- HTTP semantics from application semantics (evident by the content of this post)
- informs well-structured dependency graphs (for another post, but this is π³)
- enable parallel client/server workflows over a well-defined interface
- generate docs and test helpers which encourage development best practices
- reduce development cost and time-to-market π₯πΈπ₯
I'd love to see other tools that do something similar to the Servant family of libraries. If you know of anything, let me know!
Resources
Software Engineering at Gust
At Gust, we are working to support founders worldwide pursue their passions by removing roadblocks and providing easy solutions to common startup challenges. Our engineering culture is one of constant learning and knowledge sharing; a self-motivating environment of exploration, experimentation, and improvement.