Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

General link data-type RelationLink #32

Merged
merged 16 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [PVP versioning](https://pvp.haskell.org/).

## v0.3.4 _(2024-12-30)_

### Added
- Added class `HasTemplateLink` for fully templated links to endpoints
- Added combinator `data Title (sym :: Symbol)` for human-readable titles of resources

### Changed
- Class `HasRelationLink` now returns complete links instead of partially templated ones
- Replaced all usages of `Link` with `RelationLink`, allowing more flexibility when gathering information about the resource the link refers to
- Extended the rendering of `HALResource` by props `type` (Content-Type) and `title`

## v0.3.3 _(2024-12-28)_

### Added
Expand Down
59 changes: 53 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,19 @@ 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 _ usr = addRel ("address", CompleteLink $ mkAddrLink $ addressId usr) $ wrap usr
toResource _ ct usr = addRel ("address", mkAddrLink $ addressId usr) $ wrap usr
where
mkAddrLink = safeLink (Proxy @AddressGetOne) (Proxy @AddressGetOne)
mkAddrLink = toRelationLink $ resourcifyProxy (Proxy @AddressGetOne) ct
```

Further we define our API as usual:
```haskell
type Api = UserApi :<|> AddressApi

type UserApi = UserGetOne :<|> UserGetAll :<|> UserGetAllCool
type UserGetOne = "api" :> "user" :> Capture "id" Int :> Get '[JSON] User
type UserGetAll = "api" :> "user" :> Get '[JSON] [User]
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
Expand All @@ -89,9 +90,11 @@ If we further want to rewrite our API to a HATEOAS-API, we need to define the se
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.
```haskell
instance HasHandler UserGetOne where
getHandler _ _ = \uId -> return $ User uId 1 1000
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"
```
Expand All @@ -104,6 +107,50 @@ 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:
```json
{
"_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`:
```json
{
"_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](https://github.com/bruderj15/servant-hateoas/blob/main/src/Servant/Hateoas/Example.hs).

## Contact information
Expand Down
3 changes: 2 additions & 1 deletion servant-hateoas.cabal
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
cabal-version: 3.0
name: servant-hateoas
version: 0.3.3
version: 0.3.4
synopsis: HATEOAS extension for servant
description: Create Resource-Representations for your types and make your API HATEOAS-compliant.
Automatically derive a HATEOAS-API and server-implementation from your API or straight up define a HATEOAS-API yourself.
Expand Down Expand Up @@ -41,6 +41,7 @@ library
, Servant.Hateoas.Layer.Build
, Servant.Hateoas.Layer.Merge
, Servant.Hateoas.ContentType.HAL
, Servant.Hateoas.Combinator.Title
, Servant.Hateoas.Internal.Sym
, Servant.Hateoas.Internal.Polyvariadic

Expand Down
4 changes: 3 additions & 1 deletion src/Servant/Hateoas.hs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
module Servant.Hateoas
( module Servant.Hateoas.ContentType.HAL
( module Servant.Hateoas.Combinator.Title
, module Servant.Hateoas.ContentType.HAL
, module Servant.Hateoas.ResourceServer
, module Servant.Hateoas.RelationLink
, module Servant.Hateoas.HasHandler
, module Servant.Hateoas.Resource
, module Servant.Hateoas.Layer
) where

import Servant.Hateoas.Combinator.Title
import Servant.Hateoas.ContentType.HAL (HAL, HALResource)
import Servant.Hateoas.ResourceServer
import Servant.Hateoas.RelationLink
Expand Down
36 changes: 36 additions & 0 deletions src/Servant/Hateoas/Combinator/Title.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{-# LANGUAGE UndecidableInstances #-}

module Servant.Hateoas.Combinator.Title where

import Servant
import Servant.Hateoas.HasHandler
import Servant.Hateoas.RelationLink
import Servant.Hateoas.Internal.Polyvariadic
import Data.String (fromString)
import Control.Applicative ((<|>))
import GHC.TypeLits

-- | Combinator similar to 'Summary' and 'Description' but for the human readable title of the resource a 'RelationLink' refers to.
data Title (sym :: Symbol)

instance HasLink b => HasLink (Title sym :> b) where
type MkLink (Title sym :> b) link = MkLink b link
toLink f _ = toLink f (Proxy @b)

instance HasServer api ctx => HasServer (Title desc :> api) ctx where
type ServerT (Title desc :> api) m = ServerT api m
route _ = route (Proxy @api)
hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy @api) pc nt s

instance HasHandler api => HasHandler (Title desc :> api) where
getHandler m _ = getHandler m (Proxy @api)

instance (KnownSymbol title, HasTemplatedLink api) => HasTemplatedLink (Title title :> api) where
toTemplatedLink _ = (\l -> l { _title = _title l <|> Just title }) $ toTemplatedLink (Proxy @api)
where
title = fromString $ symbolVal (Proxy @title)

instance (KnownSymbol title, RightLink api) => HasRelationLink (Title title :> api) where
toRelationLink _ = (\l -> l { _title = _title l <|> Just title }) ... toRelationLink (Proxy @api)
where
title = fromString $ symbolVal (Proxy @title)
7 changes: 4 additions & 3 deletions src/Servant/Hateoas/ContentType/Collection.hs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module Servant.Hateoas.ContentType.Collection
where

import Servant.Hateoas.Resource
import Servant.Hateoas.RelationLink
import Servant.API.ContentTypes
import qualified Network.HTTP.Media as M
import Servant.Links
Expand All @@ -28,7 +29,7 @@ type instance MkResource (Collection t) = CollectionResource
data CollectionResource a = CollectionResource
{ href :: Maybe URI -- ^ Link to the collection
, items :: [CollectionItem a] -- ^ All items in the collection
, rels :: [(String, ResourceLink)] -- ^ Pairs @(rel, link)@ for relations
, rels :: [(String, RelationLink)] -- ^ Pairs @(rel, link)@ for relations
} deriving (Show, Generic, Functor)

instance Semigroup (CollectionResource a) where
Expand All @@ -40,7 +41,7 @@ instance Monoid (CollectionResource a) where
-- | A single item inside a 'CollectionResource'.
data CollectionItem a = CollectionItem
{ item :: a -- ^ Wrapped item
, itemLinks :: [(String, ResourceLink)] -- ^ Links for the wrapped item
, itemLinks :: [(String, RelationLink)] -- ^ Links for the wrapped item
} deriving (Show, Generic, Functor)

instance Resource CollectionResource where
Expand All @@ -57,7 +58,7 @@ instance Accept (Collection JSON) where
instance ToJSON (CollectionResource a) => MimeRender (Collection JSON) (CollectionResource a) where
mimeRender _ = encode

collectionLinks :: [(String, ResourceLink)] -> Value
collectionLinks :: [(String, RelationLink)] -> Value
collectionLinks = Array . Foldable.foldl' (\xs (rel, l) -> pure (object ["name" .= rel, "value" .= l]) <> xs) mempty

-- TODO: I dont like this at all
Expand Down
12 changes: 8 additions & 4 deletions src/Servant/Hateoas/ContentType/HAL.hs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import Data.Some.Constraint
import Data.Kind
import Data.Aeson
import Data.Aeson.KeyMap (singleton)
import qualified Data.Text as Text
import GHC.Exts
import GHC.Generics

Expand All @@ -32,7 +33,7 @@ type instance MkResource (HAL t) = HALResource
-- | HAL-resource representation.
data HALResource a = HALResource
{ resource :: a -- ^ Wrapped resource
, rels :: [(String, ResourceLink)] -- ^ Pairs @(rel, link)@ for hypermedia relations
, rels :: [(String, RelationLink)] -- ^ Pairs @(rel, link)@ for hypermedia relations
, embedded :: [(String, SomeF HALResource ToJSON)] -- ^ Pairs @(rel, resource)@ for embedded resources
} deriving (Generic, Functor)

Expand All @@ -46,9 +47,12 @@ instance Accept (HAL JSON) where
instance ToJSON (HALResource a) => MimeRender (HAL JSON) (HALResource a) where
mimeRender _ = encode

renderHalLink :: ResourceLink -> Value
renderHalLink (CompleteLink l) = let uri = linkURI l in object ["href" .= uri { uriPath = "/" <> uriPath uri }]
renderHalLink (TemplateLink l) = object $ ["href" .= l {_path = "/" <> _path l } ] <> if _templated l then ["templated" .= True] else []
renderHalLink :: RelationLink -> Value
renderHalLink l = object $
[ "href" .= getHref l
, "type" .= Text.intercalate "|" (fromString . show <$> _contentTypes l)
] <> if _templated l then ["templated" .= True] else []
<> maybe mempty (\t -> ["title" .= t]) (_title l)

instance {-# OVERLAPPABLE #-} ToJSON a => ToJSON (HALResource a) where
toJSON (HALResource res ls es) = Object $ (singleton "_links" ls') <> (singleton "_embedded" es') <> (case toJSON res of Object kvm -> kvm ; _ -> mempty)
Expand Down
10 changes: 5 additions & 5 deletions src/Servant/Hateoas/Example.hs
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@ data Address = Address { addrId :: Int, street :: String, city :: String }
deriving anyclass (ToJSON, ToResource res)

instance Resource res => ToResource res User where
toResource _ usr = addRel ("address", CompleteLink $ mkAddrLink $ addressId usr) $ wrap usr
toResource _ ct usr = addRel ("address", mkAddrLink $ addressId usr) $ wrap usr
where
mkAddrLink = safeLink (Proxy @AddressGetOne) (Proxy @AddressGetOne)
mkAddrLink = toRelationLink $ resourcifyProxy (Proxy @AddressGetOne) ct

type Api = UserApi :<|> AddressApi

type UserApi = UserGetOne :<|> UserGetAll :<|> UserGetQuery
type UserGetOne = "api" :> "user" :> Capture "id" Int :> Get '[JSON] User
type UserGetAll = "api" :> "user" :> Get '[JSON] [User]
type UserGetQuery = "api" :> "user" :> "querying" :> QueryParam "addrId" Int :> QueryParam "income" Double :> Get '[JSON] User
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
Expand Down
5 changes: 4 additions & 1 deletion src/Servant/Hateoas/Internal/Sym.hs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ instance (HasLink api, KnownSymbol sym) => HasLink (Sym sym :> api) where
type MkLink (Sym sym :> api) link = MkLink (sym :> api) link
toLink f _ = toLink f (Proxy @(sym :> api))

instance (HasRelationLink api, KnownSymbol sym) => HasRelationLink (Sym sym :> api) where
instance (HasTemplatedLink api, KnownSymbol sym) => HasTemplatedLink (Sym sym :> api) where
toTemplatedLink _ = toTemplatedLink (Proxy @(sym :> api))

instance (KnownSymbol sym, HasRelationLink (sym :> api), HasLink api) => HasRelationLink (Sym sym :> api) where
toRelationLink _ = toRelationLink (Proxy @(sym :> api))

instance (HasHandler api, KnownSymbol sym) => HasHandler (Sym sym :> api) where
Expand Down
Loading