packages feed

servant-hateoas 0.1.1 → 0.2.0

raw patch · 8 files changed

+177/−35 lines, 8 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

- Servant.Hateoas.ContentType.HAL: instance (Servant.Hateoas.Resource.Related a, GHC.Records.HasField (Servant.Hateoas.Resource.IdSelName a) a id, Servant.API.TypeLevel.IsElem (Servant.Hateoas.Resource.GetOneApi a) api, Servant.Links.HasLink (Servant.Hateoas.Resource.GetOneApi a), Servant.Links.MkLink (Servant.Hateoas.Resource.GetOneApi a) Servant.Links.Link GHC.Types.~ (id -> Servant.Links.Link), Servant.Hateoas.Resource.BuildRels api (Servant.Hateoas.Resource.Relations a) a, Servant.Hateoas.Resource.HasResource (Servant.Hateoas.ContentType.HAL.HAL t)) => Servant.Hateoas.Resource.ToResource (Servant.Hateoas.ContentType.HAL.HAL t) api a
- Servant.Hateoas.ContentType.HAL: instance Servant.Hateoas.Resource.HasResource (Servant.Hateoas.ContentType.HAL.HAL t)
- Servant.Hateoas.Resource: class HasResource ct where {
- Servant.Hateoas.Resource: type Resource ct :: Type -> Type;
+ Servant.Hateoas: data Collection (t :: Type)
+ Servant.Hateoas: data CollectionResource a
+ Servant.Hateoas: data HAL (t :: Type)
+ Servant.Hateoas: data HALResource a
+ Servant.Hateoas.ContentType.Collection: CollectionResource :: Maybe Link -> [CollectionItem a] -> [(String, Link)] -> CollectionResource a
+ Servant.Hateoas.ContentType.Collection: [href] :: CollectionResource a -> Maybe Link
+ Servant.Hateoas.ContentType.Collection: [items] :: CollectionResource a -> [CollectionItem a]
+ Servant.Hateoas.ContentType.Collection: [links] :: CollectionResource a -> [(String, Link)]
+ Servant.Hateoas.ContentType.Collection: data Collection (t :: Type)
+ Servant.Hateoas.ContentType.Collection: data CollectionResource a
+ Servant.Hateoas.ContentType.Collection: instance (Servant.Hateoas.Resource.Related a, GHC.Records.HasField (Servant.Hateoas.Resource.IdSelName a) a id, Servant.API.TypeLevel.IsElem (Servant.Hateoas.Resource.GetOneApi a) api, Servant.Links.HasLink (Servant.Hateoas.Resource.GetOneApi a), Servant.Links.MkLink (Servant.Hateoas.Resource.GetOneApi a) Servant.Links.Link GHC.Types.~ (id -> Servant.Links.Link), Servant.Hateoas.Resource.BuildRels api (Servant.Hateoas.Resource.Relations a) a, Servant.Hateoas.Resource.Resource Servant.Hateoas.ContentType.Collection.CollectionResource) => Servant.Hateoas.Resource.ToCollection api Servant.Hateoas.ContentType.Collection.CollectionResource a
+ Servant.Hateoas.ContentType.Collection: instance Data.Aeson.Types.ToJSON.ToJSON a => Data.Aeson.Types.ToJSON.ToJSON (Servant.Hateoas.ContentType.Collection.CollectionItem a)
+ Servant.Hateoas.ContentType.Collection: instance Data.Aeson.Types.ToJSON.ToJSON a => Data.Aeson.Types.ToJSON.ToJSON (Servant.Hateoas.ContentType.Collection.CollectionResource a)
+ Servant.Hateoas.ContentType.Collection: instance Data.Aeson.Types.ToJSON.ToJSON a => Servant.API.ContentTypes.MimeRender (Servant.Hateoas.ContentType.Collection.Collection Servant.API.ContentTypes.JSON) (Servant.Hateoas.ContentType.Collection.CollectionResource a)
+ Servant.Hateoas.ContentType.Collection: instance GHC.Generics.Generic (Servant.Hateoas.ContentType.Collection.CollectionItem a)
+ Servant.Hateoas.ContentType.Collection: instance GHC.Generics.Generic (Servant.Hateoas.ContentType.Collection.CollectionResource a)
+ Servant.Hateoas.ContentType.Collection: instance GHC.Show.Show a => GHC.Show.Show (Servant.Hateoas.ContentType.Collection.CollectionItem a)
+ Servant.Hateoas.ContentType.Collection: instance GHC.Show.Show a => GHC.Show.Show (Servant.Hateoas.ContentType.Collection.CollectionResource a)
+ Servant.Hateoas.ContentType.Collection: instance Servant.API.ContentTypes.Accept (Servant.Hateoas.ContentType.Collection.Collection Servant.API.ContentTypes.JSON)
+ Servant.Hateoas.ContentType.Collection: instance Servant.Hateoas.Resource.CollectingResource Servant.Hateoas.ContentType.Collection.CollectionResource
+ Servant.Hateoas.ContentType.Collection: instance Servant.Hateoas.Resource.Resource Servant.Hateoas.ContentType.Collection.CollectionItem
+ Servant.Hateoas.ContentType.Collection: instance Servant.Hateoas.Resource.Resource Servant.Hateoas.ContentType.Collection.CollectionResource
+ Servant.Hateoas.ContentType.HAL: instance (Servant.Hateoas.Resource.Related a, GHC.Records.HasField (Servant.Hateoas.Resource.IdSelName a) a id, Servant.API.TypeLevel.IsElem (Servant.Hateoas.Resource.GetOneApi a) api, Servant.Links.HasLink (Servant.Hateoas.Resource.GetOneApi a), Servant.Links.MkLink (Servant.Hateoas.Resource.GetOneApi a) Servant.Links.Link GHC.Types.~ (id -> Servant.Links.Link), Servant.Hateoas.Resource.BuildRels api (Servant.Hateoas.Resource.Relations a) a, Servant.Hateoas.Resource.Resource Servant.Hateoas.ContentType.HAL.HALResource) => Servant.Hateoas.Resource.ToResource api Servant.Hateoas.ContentType.HAL.HALResource a
+ Servant.Hateoas.ContentType.HAL: instance Data.Aeson.Types.ToJSON.ToJSON a => Servant.API.ContentTypes.MimeRender (Servant.Hateoas.ContentType.HAL.HAL Servant.API.ContentTypes.JSON) (Servant.Hateoas.ContentType.HAL.HALResource a)
+ Servant.Hateoas.ContentType.HAL: instance Servant.Hateoas.Resource.EmbeddingResource Servant.Hateoas.ContentType.HAL.HALResource
+ Servant.Hateoas.ContentType.HAL: instance Servant.Hateoas.Resource.Resource Servant.Hateoas.ContentType.HAL.HALResource
+ Servant.Hateoas.Resource: addLink :: Resource res => (String, Link) -> res a -> res a
+ Servant.Hateoas.Resource: class Resource res => CollectingResource res
+ Servant.Hateoas.Resource: class Resource res => EmbeddingResource res
+ Servant.Hateoas.Resource: class Resource res
+ Servant.Hateoas.Resource: class ToCollection api res a
+ Servant.Hateoas.Resource: collect :: CollectingResource res => a -> res a -> res a
+ Servant.Hateoas.Resource: embed :: (EmbeddingResource res, ToJSON b) => (String, b) -> res a -> res a
+ Servant.Hateoas.Resource: toCollection :: (ToCollection api res a, Foldable f) => f a -> res a
+ Servant.Hateoas.Resource: toCollection' :: (ToCollection api res a, Foldable f) => Proxy api -> Proxy res -> f a -> res a
+ Servant.Hateoas.Resource: toResource' :: ToResource api res a => Proxy api -> Proxy res -> a -> res a
- Servant.Hateoas.Resource: class HasResource ct => ToResource ct api a
+ Servant.Hateoas.Resource: class ToResource api res a
- Servant.Hateoas.Resource: toResource :: ToResource ct api a => Proxy ct -> Proxy api -> a -> Resource ct a
+ Servant.Hateoas.Resource: toResource :: ToResource api res a => a -> res a

Files

CHANGELOG.md view
@@ -6,6 +6,16 @@ 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.2.0 _(2024-10-29)_++### Added+- Support for Content-Type `application/collection+json`+- Classes `EmbeddingResource res` & `CollectingResource res` for resource-modification++### Changed++- *(breaking change)* Renamed class `HasResource` to `Resource`, removed associated type and Content-Type param+ ## v0.1.1 _(2024-10-25)_  ### Changed
README.md view
@@ -12,10 +12,11 @@ Final goal is something similar to what has been proposed [here](https://www.servant.dev/extending.html#other-directions).  ## What can we do already?-Define an instance for class `ToResource ct api a` where `ct` is the Content-Type, `a` is the datatype you want to have a resty Api for and-`api` is the type of your Servant-Api within which the resty representation of your datatype `a` lives.+Define an instance for class `ToResource api res a` where `api` is the type of your Servant-Api within which the resty representation+of your datatype `a` lives and `res` is the resource-representation to create. -When providing some extra information with an instance for `Related a` we can derive related links.+When providing some extra information with an instance for `Related a` there are stock instances which derive the links+based on the relations in your instance. ## Example ```haskell data User = User { usrId :: Int, addressId :: Int, income :: Double }@@ -33,43 +34,49 @@  type UserApi = UserGetOne :<|> UserGetAll type UserGetOne = "user" :> Capture "id" Int :> Get '[HAL JSON] (HALResource User)-type UserGetAll = "user" :> Get '[HAL JSON] (HALResource [User])+type UserGetAll = "user" :> Get '[Collection JSON] (CollectionResource User)  instance Related User where-  type IdSelName User      = "usrId"              -- This is type-safe because of using class HasField-  type GetOneApi User      = UserGetOne+  type IdSelName User = "usrId"+  type GetOneApi User = UserGetOne   type CollectionName User = "users"-  type Relations User      =-    '[ 'HRel "address" "addressId" AddressGetOne  -- Also type-safe+  type Relations User =+    '[ 'HRel "address" "addressId" AddressGetOne      ]+ ``` ```haskell->>> mimeRender (Proxy @JSON) $ toResource (Proxy @(HAL JSON)) (Proxy @CompleteApi) $ User 1 100 100000+>>> mimeRender (Proxy @(HAL JSON)) $ toResource @CompleteApi @HALResource $ User 1 42 100000 ``` ```json {   "_links": {     "address": {-      "href": "address/100"+      "href": "address/42"     },     "self": {       "href": "user/1"     }   },-  "addressId": 100,+  "addressId": 42,   "income": 100000,   "usrId": 1 } ```  ## Goals-- [x] Deriving links where possible+- [x] Deriving simple links for self and relations - [ ] Deriving links for paging, ... - [ ] Type-level rewriting of APIs like `CompleteAPI` to make API HATEOAS-compliant  ## Media-Types-Currently we only serve Content-Type `application/hal+json`.-Support for others such as `application/vnd.collection+json` or `application/vnd.amundsen-uber+json` can easily be added-with instances for `Accept` and `MimeRender`.+- [x] `application/hal+json`+- [x] `application/collection+json`+- [ ] Others: Easily extensible -Client usage with `MimeUnrender` is not yet supported but easily extensible.+Client usage with `MimeUnrender` is not yet supported.++## Contact information+Contributions, critics and bug reports are welcome!++Please feel free to contact me through GitHub.
servant-hateoas.cabal view
@@ -1,11 +1,13 @@ cabal-version:           3.0 name:                    servant-hateoas-version:                 0.1.1+version:                 0.2.0 synopsis:                HATEOAS extension for servant description:             Create Resource-Representations for your types and make your API HATEOAS-compliant.   Resource construction is generic where possible and manually adjustable where required.   Currently HAL+JSON is the only supported Content-Type, work for more is in progress.   The ultimate goal is to generate an entirely HATEOAS-compliant API generically.+homepage:                https://github.com/bruderj15/servant-hateoas+bug-reports:             https://github.com/bruderj15/servant-hateoas/issues license:                 BSD-3-Clause license-file:            LICENSE author:                  Julian Bruder@@ -29,6 +31,7 @@                        , Servant.Hateoas.Some                        , Servant.Hateoas.Resource                        , Servant.Hateoas.ContentType.HAL+                       , Servant.Hateoas.ContentType.Collection      other-modules:       Servant.Hateoas.Example 
src/Servant/Hateoas.hs view
@@ -1,9 +1,11 @@ module Servant.Hateoas-( module Servant.Hateoas.ContentType.HAL+( module Servant.Hateoas.ContentType.Collection+, module Servant.Hateoas.ContentType.HAL , module Servant.Hateoas.Resource , module Servant.Hateoas.Some ) where -import Servant.Hateoas.ContentType.HAL+import Servant.Hateoas.ContentType.Collection (Collection, CollectionResource)+import Servant.Hateoas.ContentType.HAL (HAL, HALResource) import Servant.Hateoas.Resource import Servant.Hateoas.Some
+ src/Servant/Hateoas/ContentType/Collection.hs view
@@ -0,0 +1,81 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE UndecidableInstances #-}+{-# LANGUAGE DefaultSignatures #-}+{-# LANGUAGE ViewPatterns #-}++module Servant.Hateoas.ContentType.Collection+( Collection+, CollectionResource(..)+)+where++import Servant.Hateoas.Resource+import Servant.API.ContentTypes+import qualified Network.HTTP.Media as M+import Servant.Links+import qualified Data.Foldable as Foldable+import Data.Kind+import Data.Aeson+import Data.Proxy+import GHC.Exts+import GHC.Records+import GHC.Generics++-- | Data-Kind representing Content-Types of HATEOAS collections.+--+-- Type parameter @t@ is the Mime-Type suffix in @application/vnd.collection+t@.+data Collection (t :: Type)++-- | Resource wrapper for 'Collection'.+data CollectionResource a = CollectionResource+  { href  :: Maybe Link+  , items :: [CollectionItem a]+  , links :: [(String, Link)]+  } deriving (Show, Generic)++-- | A single item inside a 'CollectionResource'.+data CollectionItem a = CollectionItem+  { item :: a+  , itemLinks :: [(String, Link)]+  } deriving (Show, Generic)++instance Resource CollectionResource where+  addLink l (CollectionResource h r ls) = CollectionResource h r (l:ls)++instance Resource CollectionItem where+  addLink l (CollectionItem i ls) = CollectionItem i (l:ls)++instance Accept (Collection JSON) where+  contentType _ = "application" M.// "vnd.collection+json"++instance ToJSON a => MimeRender (Collection JSON) (CollectionResource a) where+  mimeRender _ = encode++collectionLinks :: [(String, Link)] -> Value+collectionLinks = Array . Foldable.foldl' (\xs (rel, l) -> pure (object ["name" .= rel, "value" .= linkURI l]) <> xs) mempty++instance ToJSON a => ToJSON (CollectionItem a) where+  toJSON (CollectionItem (toJSON -> Object m) ls) = object ["data" .= itemData, "links" .= collectionLinks ls]+    where+      itemData = Array $ Foldable.foldl' (\xs (k, v) -> pure (object ["name" .= k, "value" .= v]) <> xs) mempty $ toList m+  toJSON (CollectionItem (toJSON -> v) _) = v++instance {-# OVERLAPPABLE #-} ToJSON a => ToJSON (CollectionResource a) where+  toJSON (CollectionResource mHref is ls) = object ["collection" .= collection]+    where+      collection = object $ ["version" .= ("1.0" :: String), "links" .= collectionLinks ls, "items" .= is'] <> maybe [] (pure . ("href" .=) . linkURI) mHref+      is' = Array $ Foldable.foldl' (\xs i -> pure (toJSON i) <> xs) mempty is++instance CollectingResource CollectionResource where+  collect i (CollectionResource mHref is ls) = CollectionResource mHref (CollectionItem i mempty : is) ls++instance {-# OVERLAPPABLE #-}+  ( Related a, HasField (IdSelName a) a id, IsElem (GetOneApi a) api+  , HasLink (GetOneApi a), MkLink (GetOneApi a) Link ~ (id -> Link)+  , BuildRels api (Relations a) a+  , Resource CollectionResource+  )+  => ToCollection api CollectionResource a where+  toCollection is = CollectionResource Nothing is' mempty+    where+      is' = Foldable.foldl' (\xs x -> CollectionItem x (defaultLinks (Proxy @api) x) : xs) mempty is
src/Servant/Hateoas/ContentType/HAL.hs view
@@ -35,12 +35,15 @@   , embedded :: [(String, SomeToJSON HALResource)]   } deriving (Generic) -instance HasResource (HAL t) where-  type Resource (HAL t) = HALResource+instance Resource HALResource where+  addLink l (HALResource r ls es) = HALResource r (l:ls) es  instance Accept (HAL JSON) where   contentType _ = "application" M.// "hal+json" +instance ToJSON a => MimeRender (HAL JSON) (HALResource a) where+  mimeRender _ = encode+ instance {-# OVERLAPPABLE #-} ToJSON a => ToJSON (HALResource a) where   toJSON (HALResource res ls es) = case toJSON res of     Object kvm -> Object $ (singleton "_links" ls') <> (singleton "_embedded" es') <> kvm@@ -49,7 +52,7 @@       ls' = object [fromString rel .= object ["href" .= linkURI href] | (rel, href) <- ls]       es' = object [fromString name .= toJSON e | (name, e) <- es] -instance {-# OVERLAPPING #-} (ToJSON a, Related a, KnownSymbol (CollectionName a)) => ToJSON ([HALResource a]) where+instance {-# OVERLAPPING #-} (ToJSON a, Related a, KnownSymbol (CollectionName a)) => ToJSON [HALResource a] where   toJSON xs = object ["_links" .= (mempty :: Object), "_embedded" .= es]     where       es = object $@@ -57,10 +60,13 @@         .= (Array $ Foldable.foldl' (\xs' x -> xs' <> pure (toJSON x)) mempty xs)         ] +instance EmbeddingResource HALResource where+  embed e (HALResource r ls es) = HALResource r ls $ fmap SomeToJSON e : es+ instance {-# OVERLAPPABLE #-}   ( Related a, HasField (IdSelName a) a id, IsElem (GetOneApi a) api   , HasLink (GetOneApi a), MkLink (GetOneApi a) Link ~ (id -> Link)   , BuildRels api (Relations a) a-  , HasResource (HAL t)-  ) => ToResource (HAL t) api a where-  toResource _ api x = HALResource x (defaultLinks api x) mempty+  , Resource HALResource+  ) => ToResource api HALResource a where+  toResource x = HALResource x (defaultLinks (Proxy @api) x) mempty
src/Servant/Hateoas/Example.hs view
@@ -23,7 +23,7 @@  type UserApi = UserGetOne :<|> UserGetAll type UserGetOne = "user" :> Capture "id" Int :> Get '[HAL JSON] (HALResource User)-type UserGetAll = "user" :> Get '[HAL JSON] (HALResource [User])+type UserGetAll = "user" :> Get '[Collection JSON] (CollectionResource User)  instance Related User where   type IdSelName User = "usrId"
src/Servant/Hateoas/Resource.hs view
@@ -1,11 +1,16 @@ {-# LANGUAGE UndecidableInstances #-}+{-# LANGUAGE AllowAmbiguousTypes #-}  module Servant.Hateoas.Resource (   -- * Resource-  HasResource(..)-, ToResource(..)+  -- ** Construction+  ToResource(..)+, ToCollection(..) +  -- ** Modification+, Resource(..), EmbeddingResource(..), CollectingResource(..)+ -- * Hypermedia-Relations -- ** Type , HRel(..)@@ -21,19 +26,47 @@  import Servant import Data.Kind+import Data.Aeson import GHC.TypeLits import GHC.Records --- | Class that indicates that a Content-Type has a specific Resource-Representation.-class HasResource ct where-  -- | Associated type for this Content-Type-  type Resource ct :: Type -> Type+-- | Class for resources that carry Hypermedia-Relations.+class Resource res where+  -- | Add a relation @(rel, link)@ to a resource.+  addLink :: (String, Link) -> res a -> res a +-- | Class for 'Resource's that can embed other resources.+class Resource res => EmbeddingResource res where+  -- | Embed a resource @b@ with its relation @rel@ as tuple @(rel, b)@.+  embed :: ToJSON b => (String, b) -> res a -> res a++-- | Class for 'Resource's that can collect multiple resources.+class Resource res => CollectingResource res where+  -- | Collect a resource into the collection.+  collect :: a -> res a -> res a+ -- | Class for converting values of @a@ to their respective Resource-Representation.-class HasResource ct => ToResource ct api a where+class ToResource api res a where   -- | Converts a value into it's Resource-Representation.-  toResource :: Proxy ct -> Proxy api -> a -> Resource ct a+  toResource :: a -> res a+  toResource = toResource' (Proxy @api) (Proxy @res) +  -- | Like 'toResource' but takes proxies for ambiguity.+  toResource' :: Proxy api -> Proxy res -> a -> res a+  toResource' _ _ = toResource @api @res+  {-# MINIMAL toResource | toResource' #-}++-- | Class for converting multiple values of @a@ to their respective collection-like representation.+class ToCollection api res a where+  -- | Converts many values into their Collection-Representation.+  toCollection :: Foldable f => f a -> res a+  toCollection = toCollection' (Proxy @api) (Proxy @res)++  -- | Like 'toCollection' but takes proxies for ambiguity.+  toCollection' :: Foldable f => Proxy api -> Proxy res -> f a -> res a+  toCollection' _ _ = toCollection @api @res+  {-# MINIMAL toCollection | toCollection' #-}+ -- | Data-Kind for Hypermedia-Relations. data HRel = HRel   { relName  :: Symbol       -- ^ Name of the relation@@ -78,7 +111,7 @@ relatedLinks :: forall api a. (Related a, BuildRels api (Relations a) a) => Proxy api -> a -> [(String, Link)] relatedLinks = buildRels (Proxy @(Relations a)) --- | Generates the pair (\"self\", link) where @link@ is the 'Link' to @a@ itself.+-- | Generates the pair @(\"self\", link)@ where @link@ is the 'Link' to @a@ itself. selfLink :: forall api a id.   ( Related a, HasField (IdSelName a) a id   , IsElem (GetOneApi a) api, HasLink (GetOneApi a)