Skip to content

bruderj15/servant-hateoas

Repository files navigation

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.

About

HATEOAS support for servant

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published