Generating documentation from API types

Noel Kwan @kwannoel
May 18, 2020

Context

As an Intern at Holmusk, I have been involved in projects which improve the quality of life for our programmers. The latest project I have worked on, servant-docs-simple allowed me to work with exciting things relating to type-level programming.

Our backend is currently built in Haskell, but those who need to use the APIs are Elm and Flutter developers. Hence, we should provide documentation of APIs which is readable by everyone.

Since our API uses Servant, we attempted to use servant-docs to document our API. However, there were differing use cases so we created servant-docs-simple to handle these.

Requirements

Supporting various output formats

We want to output a variety of formats. Currently we support PlainText, JSON and PrettyPrint output formats.

These can be used in a variety of ways:

  • JSON can be served from an endpoint, allowing developers to query API endpoints and look up their documentation.

  • PlainText documentation allows people to read through the whole documentation in PlainText.

  • PrettyPrint allows people to further format text documentation to their liking.

Including format types in our documentation

A format type is a Haskell data type. This type can be serialized to other forms, depending on instances which have been defined for it. For instance, if it has a JSON instance, it can be serialized to and from JSON.

Each endpoint accepts certain format type(s). These format types are specified in the endpoint’s type definition, as part of its parameters. For instance, in its RequestBody parameter, we could say it accepts the CreateUser type and in the Response parameter we could say it returns the UserData type.


Format type in documentation

/api/user/
RequestType:
  Format: CreateUser
  ContentType: ...

Response:
  Format: UserData
  ContentType: ...

We can use these format types to search haskell source files to look up their type definitions. We can also look up our own generated files from these types. For example if a users.proto file is generated for the UserData type, we can search through that file to find the protobuf definition.


Searching for the UserData type in users.proto

message UserData {
    required string name = 1;
    ...
}

message UserCreate {
    ...
}

.
.
.

In the documentation generated by servant-docs, the format types mentioned above (User, Message) are not included. They provide examples instead, which is a different usecase, since we want to use these format types to search our internal source files.

Automatically generating API endpoints documentation

servant-docs generates documentation only if format types have implemented the necessary instances. This is good for extensive documentation of the endpoints as users have to write examples for each format type they introduce.


Instances for format types

instance ToSample User where
    toSamples _ = <some example>

instance ToSample Message where
    toSamples _ = <some example>

However, in our case, we want to document our endpoints automatically, without having to write extra instances for each format type we introduce.

Implementation

Note: Some code snippets below are simplified for understanding. They may be approximations of the actual implementations.

Overview

To allow us to parse to multiple output formats, we should:

  1. Parse the api to an intermediate structure.

  2. Render this structure to multiple output formats.

Parsing the API type

Flattening the API type

We observe that the Servant API type is built by chaining together types. You can observe that :> and :<|> serve as operators which introduce a tree-like structure to the API type.

:> extends the branch and :<|> forks multiple branches.


Sample API type

type API = Route :> ( Request :> Response
                 :<|> Route2 :> Request2 :> Response2
                    )

Since a list is easier to parse than a tree, we can flatten this structure into a type-level list of endpoints. To do so, we use the Endpoints type family, applying it to the API type. For those unfamiliar with type families, you can think of them as functions which act on types.


Flattened API type after applying the Endpoints type family

Endpoints @API = '[ Route :> Request :> Response -- Endpt 1
                  , Route :> Route2 :> Request2 :> Response2 -- Endpt 2
                  ]

The result is a type-level list of endpoints, which we can parse by pattern matching on ': (for separating endpoints) and :> (for separating parts of each endpoint). These operators ': and :> are similar to the cons operator : for lists, but on the type-level.

The intermediate structure

In the documentation, certain fields should come before others.

For example, the route of an endpoint should come before its Request or Response parameters. This allows users to easily find the route they need and access the relevant information for that route.

Hence structure we parse to should preserve the ordering of fields for each endpoint.


When parsing each endpoint, we want this ordering

Route: ...
Request: ...
Response: ...

Rather than this ordering

Response: ...
Route: ...
Request: ...

Intuitively, the final structure should also permit us to use the route and its field names as keys, and their details as values. This is analogous to a JSON-like / map structure as shown below.


“users/login”, “Response”, “Request”, “ContentType” are the keys below, bound by the (:) to their values.

users/login/ : {
    Response: { ContentType: <value> }
    Request: { ... }
}

Hence, this is the Haskell type we want our API documentation to have. Notice the OMap structure used. This gives us the 2 properties we need, ordered key-value pairs and a map structure.


ApiDocs type

import Data.Map.Ordered (OMap)

data ApiDocs = ApiDocs { unApiDocs :: OMap Route Details }
type Route = Text
data Details = Details (OMap Parameter Details) | Detail Text
type Parameter = Text


Converting the API type to a value

How do we convert the API type to a value of type ApiDocs? Can we have a function which takes in types, returning values? Don’t functions primarily take in values?

To answer those questions, first, we recognize that our API type is built out of Servant types and type combinators.


Examples of Servant types

-- Type representing request body
data ReqBody (contentTypes :: [*]) (a :: *)

-- Type representing headers
data Header (sym :: Symbol) a

Example of Servant type combinator

-- Type combinator (:>) which combines Servant types
data (path :: k) :> (a :: *)

-- Type combinator (:>) is able to take in types as parameters
ReqBody '[()] () :> Header "" ()

Then, using these combinators and types, we can build our API type.


Our api with a single endpoint

-- Here, using the (:>) combinator,
-- we combine a type-level string "users",
-- the ReqBody type and the Post type
type API = "users" :> ReqBody '[()] () :> Post '[()] ()

Inductively, if we can convert these types and type combinators to values, we can convert our API type to a value.

Now, we need to think of a way to pattern match on types to get values. The way would be typeclasses!

We can define different instances of a typeclass which match on different types. These instances can give us different values, so long as they conform to the definitions of that typeclass.


Example of transforming types to values using typeclasses

class HasAlphabet a where
    toAlpha :: String


data A

instance HasAlphabet A where
    toAlpha = "A"


data B

instance HasAlphabet B where
    toAlpha = "B"
    

-- Instantiating "toAlpha" for A and B

(toAlpha :: A) == "A"

(toAlpha :: B) == "B"

From this, we now know how to parse different servant types and type combinators. We can create a typeclass, define instances for all Servant types and type combinators, then use that to parse them.

With this knowledge, we can implement a typeclass that parses a single endpoint, since an endpoint is made up of servant types and combinators.

We match on the :> combinator and other types, converting them to Parameter-Detail pairs and inserting them into the Details OMap. We then return a Route-Detail pair.


Parsing each endpoint by pattern matching on :>

class HasParsable api where
    document :: Route -> Details -> (Route, Details)

-- Parses a route
instance HasSymbol p => HasParsable (p :> a) where
    document r d = document @a $ r <> symbolVal (Proxy @p)
                               $ d


-- 'a' contains the rest of the API chain
instance HasParsable a => HasParsable (Auth :> a) where
    document r d = document @a $ r
                               $ insert ("Authentication", Detail "true") d

instance ( HasParsable a
         , Typeable ct
         , Typeable typ
         ) => HasParsable (ReqBody ct typ :> a) where

    document r d = document @a $ r
                               $ insert ( "Request"
                                        , Details ( insert ( "Content-type"
                                                           , typeRep @ct
                                                           )
                                                  . insert ( "Format"
                                                           , typeRep @typ
                                                           )
                                                  $ empty
                                                  )
                                        ) d

We can convert types into their text form by using typeRep from Data.Typeable and type-level strings into their string equivalent by using symbolVal from GHC.TypeLits.

Next, we have a typeclass that destructures the flattened API type. As mentioned earlier, the flattened API type is a type-level list of endpoints. The typeclass recurses through the list, converting each Endpoint type into a Route-Details pair.

Since each endpoint type is an instance of the HasParsable typeclass, we can use the document function for the conversion. We then insert the pair into the rest of the ApiDocs OMap.


Collating the ApiDocs by pattern matching on ':

class HasCollatable api where
    collate :: ApiDocs

instance HasCollatable (endpoint ': b) where
    collate = document endpoint `insert` unApiDocs (collate @b)

instance HasCollatable '[] where
    collate = ApiDocs empty -- An empty OMap


Rendering the intermediate structure

The ApiDocs structure can then be rendered into other formats using another typeclass.


Renderable typeclass

class Renderable a where
    render :: ApiDocs -> a -- Render to specified format types

instance Renderable JSON
...

instance Renderable PlainText
...

Usage

Now, to get the documentation we can parse the API and then render it to the desired format.


Writing the docs as PlainText to a file

type MyAPI = ...

main :: IO ()
main = writeToFile apiDocs

writeToFile :: PlainText -> IO ()
writeToFile = ...

apiDocs :: PlainText
apiDocs = render @PlainText (parse @MyAPI)

Reflections

What can be improved?

Currently there are some issues opened in the repository.

The main ones which come to mind would be:

Trade offs

Documentation may not be sufficiently extensive, as the generated documentation lacks examples by default. These are up to the user to include.

Servant-docs has a much more developed ecosystem. These can be seen through packages such as servant-pandoc which supports many more output formats than servant-docs-simple.

Pros

You get lightweight documentation for free! As long as you have an Api type, you can generate simple documentation in a variety of outputs.

It is also easy to extend. We have included a bunch of tutorial scripts you can refer to. These include writing your own Renderable instances for rendering custom output and HasParsable instances to parse custom types.

Other thoughts

Writing this library was very interesting, as it was the first time I tried type-level programming. It was a similar feeling as when I first learnt about Monoids, Functors and Monads.

When I started working on this project I was worried but excited. After having heard interesting things about Servant, but never really understanding it, I could finally get some experience. Along the way I referenced Servant's documentation and Thinking with Types which have helped me immensely to understand type-level programming in Haskell.

After working on this project, I understand compile-time safety a lot better . By encoding the API as a type, we can more safely reuse it in a variety of ways, such as documentation (as in this case), on the client-side and so on.

I also learned about the nitty-gritty bits of packaging a module, like how to upload to Hackage, Stackage, configure the CI, setting up the project with summoner, among many other things.

For more details, you can reference the README as well as the Hackage docs. We also welcome PRs and Issues to improve the package! Thanks for reading :)