@hackage servant-hateoas0.3.4

HATEOAS extension for servant

Hackage Static Badge Haskell-CI

servant-hateoas

HATEOAS support for servant.

Infant state, highly experimental.

Find a motivating example further down this README.

What can we do already?

  • Derive a layered HATEOAS-API and a server-implementation from an API, basically what has been touched on here.
  • Derive a HATEOAS-API from an API by rewriting the API and its server-implementation
    • Wrapping the response types of your API with Resource-Representations
    • Automatically adding the self-link to every resource
    • Adding custom links to resources via instances for type-class ToResource
  • Directly write a HATEOAS-API yourself

What can we do better?

Deriving the layered HATEOAS-API from your API does not require your API to be structured in a certain way.

However, for rewriting your API we need you to specify your server-implementation as an instance of class HasHandler (bad name, should be HasServer - exists already).

This currently makes it tricky for APIs which have shared path segments, e.g. "api" :> (UserApi :<|> AddressApi)

Therefore we currently need an instance on each flattened endpoint of the API, e.g. for "api :> UserApi" and "api :> AddressApi".

What's on the horizon?

A lot. There are plenty of opportunities.

  • Merging the derived HATEOAS Layer-API with the rewritten HATEOAS API.
  • Automatically adding links for servant-pagination
  • Adding rich descriptions for Hypermedia-relations for content-types such as application/prs.hal-forms+json
  • ...

Media-Types

  • application/hal+json
  • application/vnd.collection+json: Work in progrress
  • application/prs.hal-forms+json: Soon
  • Others: Maybe

Client usage with MimeUnrender is not yet supported.

Example

Suppose we have users and addresses, where each user has an address:

data User = User { usrId :: Int, addressId :: Int, income :: Double }
  deriving stock (Generic, Show, Eq, Ord)
  deriving anyclass (ToJSON)

data Address = Address { addrId :: Int, street :: String, city :: String }
  deriving stock (Generic, Show, Eq, Ord)
  deriving anyclass (ToJSON)

We need to define how their resource-representation looks like:

-- default just wrapps an address to a resource
instance ToResource res Address

-- add a link to the address-resource with the relation "address" for the user-resource
instance Resource res => ToResource res User where
  toResource _ ct usr = addRel ("address", mkAddrLink $ addressId usr) $ wrap usr
    where
      mkAddrLink = toRelationLink $ resourcifyProxy (Proxy @AddressGetOne) ct

Further we define our API as usual:

type Api = UserApi :<|> AddressApi

type UserApi = UserGetOne :<|> UserGetAll :<|> UserGetQuery
type UserGetOne    = "api" :> "user" :> Title "The user with the given id" :> Capture "id" Int :> Get '[JSON] User
type UserGetAll    = "api" :> "user" :> Get '[JSON] [User]
type UserGetQuery  = "api" :> "user" :> "query" :> QueryParam "addrId" Int :> QueryParam "income" Double :> Get '[JSON] User

type AddressApi = AddressGetOne
type AddressGetOne = "api" :> "address" :> Capture "id" Int :> Get '[JSON] Address

Getting all the layers of the API in a HATEOAS way now is as simple as:

layerServer :: Server (Resourcify (MkLayers Api) (HAL JSON))
layerServer = getResourceServer (Proxy @Handler) (Proxy @(HAL JSON)) (Proxy @(MkLayers Api))

If we further want to rewrite our API to a HATEOAS-API, we need to define the server-implementation as an instance of HasHandler.

This is nothing but the usual servant-server implementation, just that the implementation is not floating around in the source code and instead is bound to a class instance.

instance HasHandler UserGetOne where
  getHandler _ _ = \uId -> return $ User uId 0 0
instance HasHandler UserGetAll where
  getHandler _ _ = return [User 1 1 1000, User 2 2 2000, User 42 3 3000]
instance HasHandler UserGetQuery where
  getHandler _ _ = \mAddrId mIncome -> return $ User 42 (maybe 0 id mAddrId) (maybe 0 id mIncome)
instance HasHandler AddressGetOne where
  getHandler _ _ = \aId -> return $ Address aId "Foo St" "BarBaz"

Getting the rewritten HATEOAS-API and it's server-implementation is as simple as:

apiServer :: Server (Resourcify Api (HAL JSON))
apiServer = getResourceServer (Proxy @Handler) (Proxy @(HAL JSON)) (Proxy @Api)

For now apiServer and layerServer exist in isolation, but the goal is to merge them into one.

When we now run the layerServer and request GET http://host:port/api/user/query, we get:

{
    "_embedded": {},
    "_links": {
        "addrId": {
            "href": "/api/user/query{?addrId}",
            "templated": true,
            "type": "application/hal+json"
        },
        "income": {
            "href": "/api/user/query{?income}",
            "templated": true,
            "type": "application/hal+json"
        },
        "self": {
            "href": "/api/user/query",
            "type": "application/hal+json"
        }
    }
}

Similar for userServer and GET http://host:port/api/user/42:

{
    "_embedded": {},
    "_links": {
        "address": {
            "href": "/api/address/0",
            "type": "application/hal+json"
        },
        "self": {
            "href": "/api/user/42",
            "title": "The user with the given id",
            "type": "application/hal+json"
        }
    },
    "addressId": 0,
    "income": 0,
    "usrId": 42
}

The complete example can be found here.

Contact information

Contributions, critics and bug reports are welcome!

Please feel free to contact me through GitHub.