From c5aa0d61cb50d542a00add50f72eb7d92614b5f8 Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Sun, 27 Oct 2024 16:40:01 +0100 Subject: [PATCH 01/12] collection: base impl --- servant-hateoas.cabal | 1 + src/Servant/Hateoas.hs | 6 ++- src/Servant/Hateoas/ContentType/Collection.hs | 49 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/Servant/Hateoas/ContentType/Collection.hs diff --git a/servant-hateoas.cabal b/servant-hateoas.cabal index 9588728..6cad013 100644 --- a/servant-hateoas.cabal +++ b/servant-hateoas.cabal @@ -29,6 +29,7 @@ library , Servant.Hateoas.Some , Servant.Hateoas.Resource , Servant.Hateoas.ContentType.HAL + , Servant.Hateoas.ContentType.Collection other-modules: Servant.Hateoas.Example diff --git a/src/Servant/Hateoas.hs b/src/Servant/Hateoas.hs index 563707c..72ddf7d 100644 --- a/src/Servant/Hateoas.hs +++ b/src/Servant/Hateoas.hs @@ -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 diff --git a/src/Servant/Hateoas/ContentType/Collection.hs b/src/Servant/Hateoas/ContentType/Collection.hs new file mode 100644 index 0000000..7c9a776 --- /dev/null +++ b/src/Servant/Hateoas/ContentType/Collection.hs @@ -0,0 +1,49 @@ +{-# 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 GHC.Exts +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 String + , items :: [a] + , links :: [(String, Link)] + } deriving (Generic) + +instance HasResource (Collection t) where + type Resource (Collection t) = CollectionResource + +instance Accept (Collection JSON) where + contentType _ = "application" M.// "vnd.collection+json" + +instance {-# OVERLAPPABLE #-} ToJSON a => ToJSON (CollectionResource a) where + toJSON (CollectionResource mHref is ls) = object ["collection".= collection] + where + collection = object $ ["version" .= ("1.0" :: String), "links" .= ls', "items" .= is'] <> maybe [] (pure . ("href" .=)) mHref + ls' = Array $ Foldable.foldl' (\xs (rel, l) -> pure (object ["name" .= rel, "value" .= linkURI l]) <> xs) mempty ls + is' = Array $ Foldable.foldl' (\xs i -> pure (object ["data" .= toCollectionData i, "links" .= (mempty :: Array)]) <> xs) mempty is + +toCollectionData :: ToJSON a => a -> Value +toCollectionData (toJSON -> Object m) = Array $ Foldable.foldl' (\xs (k, v) -> pure (object ["name" .= k, "value" .= v]) <> xs) mempty $ toList m +toCollectionData (toJSON -> x) = x From e282cb521f38285cdd0b5628eca19eb82d1a84e4 Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Sun, 27 Oct 2024 17:05:22 +0100 Subject: [PATCH 02/12] main:CollectionResource consists of CollectionItem s --- src/Servant/Hateoas/ContentType/Collection.hs | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/Servant/Hateoas/ContentType/Collection.hs b/src/Servant/Hateoas/ContentType/Collection.hs index 7c9a776..1e5d5af 100644 --- a/src/Servant/Hateoas/ContentType/Collection.hs +++ b/src/Servant/Hateoas/ContentType/Collection.hs @@ -27,9 +27,15 @@ data Collection (t :: Type) -- | Resource wrapper for Collection. data CollectionResource a = CollectionResource { href :: Maybe String - , items :: [a] + , items :: [CollectionItem a] , links :: [(String, Link)] - } deriving (Generic) + } deriving (Show, Generic) + +-- | A single item inside a 'CollectionResource'. +data CollectionItem a = CollectionItem + { item :: a + , itemLinks :: [(String, Link)] + } deriving (Show, Generic) instance HasResource (Collection t) where type Resource (Collection t) = CollectionResource @@ -37,13 +43,19 @@ instance HasResource (Collection t) where instance Accept (Collection JSON) where contentType _ = "application" M.// "vnd.collection+json" +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" .= ls', "items" .= is'] <> maybe [] (pure . ("href" .=)) mHref - ls' = Array $ Foldable.foldl' (\xs (rel, l) -> pure (object ["name" .= rel, "value" .= linkURI l]) <> xs) mempty ls - is' = Array $ Foldable.foldl' (\xs i -> pure (object ["data" .= toCollectionData i, "links" .= (mempty :: Array)]) <> xs) mempty is + collection = object $ ["version" .= ("1.0" :: String), "links" .= collectionLinks ls, "items" .= is'] <> maybe [] (pure . ("href" .=)) mHref + is' = Array $ Foldable.foldl' (\xs i -> pure (toJSON i) <> xs) mempty is + +collectionLinks :: [(String, Link)] -> Value +collectionLinks = Array . Foldable.foldl' (\xs (rel, l) -> pure (object ["name" .= rel, "value" .= linkURI l]) <> xs) mempty -toCollectionData :: ToJSON a => a -> Value -toCollectionData (toJSON -> Object m) = Array $ Foldable.foldl' (\xs (k, v) -> pure (object ["name" .= k, "value" .= v]) <> xs) mempty $ toList m -toCollectionData (toJSON -> x) = x +-- instance ToResource ... - both for CollectionItem and CollectionResource...? From 73988a05942cfc01e1816b8b34c797d8322e0258 Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Tue, 29 Oct 2024 13:07:21 +0100 Subject: [PATCH 03/12] collection: extra class ToCollection for creating Collection resources --- src/Servant/Hateoas/ContentType/Collection.hs | 24 +++++++++++++------ src/Servant/Hateoas/Resource.hs | 5 ++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/Servant/Hateoas/ContentType/Collection.hs b/src/Servant/Hateoas/ContentType/Collection.hs index 1e5d5af..5c857c3 100644 --- a/src/Servant/Hateoas/ContentType/Collection.hs +++ b/src/Servant/Hateoas/ContentType/Collection.hs @@ -17,18 +17,19 @@ import qualified Data.Foldable as Foldable import Data.Kind import Data.Aeson 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@. +-- Type parameter @t@ is the Mime-Type suffix in @application/vnd.collection+t@. data Collection (t :: Type) --- | Resource wrapper for Collection. +-- | Resource wrapper for 'Collection'. data CollectionResource a = CollectionResource - { href :: Maybe String - , items :: [CollectionItem a] - , links :: [(String, Link)] + { href :: Maybe Link + , resource :: [CollectionItem a] + , links :: [(String, Link)] } deriving (Show, Generic) -- | A single item inside a 'CollectionResource'. @@ -52,10 +53,19 @@ instance ToJSON a => ToJSON (CollectionItem a) where 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" .=)) mHref + 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 collectionLinks :: [(String, Link)] -> Value collectionLinks = Array . Foldable.foldl' (\xs (rel, l) -> pure (object ["name" .= rel, "value" .= linkURI l]) <> xs) mempty --- instance ToResource ... - both for CollectionItem and CollectionResource...? +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 (Collection t) + ) + => ToCollection (Collection t) api a where + toCollection _ api is = CollectionResource Nothing is' mempty + where + is' = foldl' (\xs x -> CollectionItem x (defaultLinks api x) : xs) mempty is diff --git a/src/Servant/Hateoas/Resource.hs b/src/Servant/Hateoas/Resource.hs index 811a0b8..3846078 100644 --- a/src/Servant/Hateoas/Resource.hs +++ b/src/Servant/Hateoas/Resource.hs @@ -5,6 +5,7 @@ module Servant.Hateoas.Resource -- * Resource HasResource(..) , ToResource(..) +, ToCollection(..) -- * Hypermedia-Relations -- ** Type @@ -34,6 +35,10 @@ class HasResource ct => ToResource ct api a where -- | Converts a value into it's Resource-Representation. toResource :: Proxy ct -> Proxy api -> a -> Resource ct a +-- TODO: Können wir das auch gleich für die HAL-Instanz auf Listen anwenden? +class HasResource ct => ToCollection ct api a where + toCollection :: Foldable f => Proxy ct -> Proxy api -> f a -> Resource ct a + -- | Data-Kind for Hypermedia-Relations. data HRel = HRel { relName :: Symbol -- ^ Name of the relation From b4733f6bdf5c17ffb31ce44c5f4b1ec51965a0e8 Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Tue, 29 Oct 2024 13:16:24 +0100 Subject: [PATCH 04/12] collection: hygiene --- src/Servant/Hateoas/ContentType/Collection.hs | 2 +- src/Servant/Hateoas/ContentType/HAL.hs | 2 +- src/Servant/Hateoas/Resource.hs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Servant/Hateoas/ContentType/Collection.hs b/src/Servant/Hateoas/ContentType/Collection.hs index 5c857c3..78f8c0c 100644 --- a/src/Servant/Hateoas/ContentType/Collection.hs +++ b/src/Servant/Hateoas/ContentType/Collection.hs @@ -51,7 +51,7 @@ instance ToJSON a => ToJSON (CollectionItem a) where toJSON (CollectionItem (toJSON -> v) _) = v instance {-# OVERLAPPABLE #-} ToJSON a => ToJSON (CollectionResource a) where - toJSON (CollectionResource mHref is ls) = object ["collection".= collection] + 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 diff --git a/src/Servant/Hateoas/ContentType/HAL.hs b/src/Servant/Hateoas/ContentType/HAL.hs index 89aebe7..e12c1a9 100644 --- a/src/Servant/Hateoas/ContentType/HAL.hs +++ b/src/Servant/Hateoas/ContentType/HAL.hs @@ -49,7 +49,7 @@ instance {-# OVERLAPPABLE #-} ToJSON a => ToJSON (HALResource a) where 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 $ diff --git a/src/Servant/Hateoas/Resource.hs b/src/Servant/Hateoas/Resource.hs index 3846078..73abde3 100644 --- a/src/Servant/Hateoas/Resource.hs +++ b/src/Servant/Hateoas/Resource.hs @@ -35,8 +35,9 @@ class HasResource ct => ToResource ct api a where -- | Converts a value into it's Resource-Representation. toResource :: Proxy ct -> Proxy api -> a -> Resource ct a --- TODO: Können wir das auch gleich für die HAL-Instanz auf Listen anwenden? +-- | Class for converting multiple values of @a@ to their respective collection-like representation. class HasResource ct => ToCollection ct api a where + -- | Converts a many values into their Collecrion-Representation. toCollection :: Foldable f => Proxy ct -> Proxy api -> f a -> Resource ct a -- | Data-Kind for Hypermedia-Relations. From 8355acdf5f7a3bb125a741fefc7c6a7a4c72ddad Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Tue, 29 Oct 2024 16:50:52 +0100 Subject: [PATCH 05/12] collection: remove ContentType from Resource class --- src/Servant/Hateoas/ContentType/Collection.hs | 16 ++++++---- src/Servant/Hateoas/ContentType/HAL.hs | 10 +++---- src/Servant/Hateoas/Resource.hs | 30 +++++++++++++------ 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/Servant/Hateoas/ContentType/Collection.hs b/src/Servant/Hateoas/ContentType/Collection.hs index 78f8c0c..51c067d 100644 --- a/src/Servant/Hateoas/ContentType/Collection.hs +++ b/src/Servant/Hateoas/ContentType/Collection.hs @@ -16,6 +16,7 @@ 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 @@ -38,8 +39,11 @@ data CollectionItem a = CollectionItem , itemLinks :: [(String, Link)] } deriving (Show, Generic) -instance HasResource (Collection t) where - type Resource (Collection t) = CollectionResource +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" @@ -63,9 +67,9 @@ 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 (Collection t) + , Resource CollectionResource ) - => ToCollection (Collection t) api a where - toCollection _ api is = CollectionResource Nothing is' mempty + => ToCollection api CollectionResource a where + toCollection is = CollectionResource Nothing is' mempty where - is' = foldl' (\xs x -> CollectionItem x (defaultLinks api x) : xs) mempty is + is' = foldl' (\xs x -> CollectionItem x (defaultLinks (Proxy @api) x) : xs) mempty is diff --git a/src/Servant/Hateoas/ContentType/HAL.hs b/src/Servant/Hateoas/ContentType/HAL.hs index e12c1a9..8621f28 100644 --- a/src/Servant/Hateoas/ContentType/HAL.hs +++ b/src/Servant/Hateoas/ContentType/HAL.hs @@ -35,8 +35,8 @@ data HALResource a = HALResource , 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" @@ -61,6 +61,6 @@ 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 diff --git a/src/Servant/Hateoas/Resource.hs b/src/Servant/Hateoas/Resource.hs index 73abde3..c66d3c7 100644 --- a/src/Servant/Hateoas/Resource.hs +++ b/src/Servant/Hateoas/Resource.hs @@ -1,9 +1,10 @@ {-# LANGUAGE UndecidableInstances #-} +{-# LANGUAGE AllowAmbiguousTypes #-} module Servant.Hateoas.Resource ( -- * Resource - HasResource(..) + Resource(..) , ToResource(..) , ToCollection(..) @@ -26,19 +27,30 @@ 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 Resource res where + addLink :: (String, Link) -> res a -> res a -- | Class for converting values of @a@ to their respective Resource-Representation. -class HasResource ct => ToResource ct api a where +class Resource res => 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 HasResource ct => ToCollection ct api a where - -- | Converts a many values into their Collecrion-Representation. - toCollection :: Foldable f => Proxy ct -> Proxy api -> f a -> Resource ct a +class Resource res => ToCollection api res a where + -- | Converts a 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 From 7c19d13759a4b8838160531f40da7b0bd3d802cc Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Tue, 29 Oct 2024 17:26:22 +0100 Subject: [PATCH 06/12] collection: classes for resource-modification --- src/Servant/Hateoas/ContentType/Collection.hs | 13 ++++++---- src/Servant/Hateoas/ContentType/HAL.hs | 3 +++ src/Servant/Hateoas/Resource.hs | 25 +++++++++++++++---- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/Servant/Hateoas/ContentType/Collection.hs b/src/Servant/Hateoas/ContentType/Collection.hs index 51c067d..f27efb7 100644 --- a/src/Servant/Hateoas/ContentType/Collection.hs +++ b/src/Servant/Hateoas/ContentType/Collection.hs @@ -28,9 +28,9 @@ data Collection (t :: Type) -- | Resource wrapper for 'Collection'. data CollectionResource a = CollectionResource - { href :: Maybe Link - , resource :: [CollectionItem a] - , links :: [(String, Link)] + { href :: Maybe Link + , items :: [CollectionItem a] + , links :: [(String, Link)] } deriving (Show, Generic) -- | A single item inside a 'CollectionResource'. @@ -48,6 +48,9 @@ instance Resource CollectionItem where instance Accept (Collection JSON) where contentType _ = "application" M.// "vnd.collection+json" +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 @@ -60,8 +63,8 @@ instance {-# OVERLAPPABLE #-} ToJSON a => ToJSON (CollectionResource a) 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 -collectionLinks :: [(String, Link)] -> Value -collectionLinks = Array . Foldable.foldl' (\xs (rel, l) -> pure (object ["name" .= rel, "value" .= linkURI l]) <> xs) mempty +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 diff --git a/src/Servant/Hateoas/ContentType/HAL.hs b/src/Servant/Hateoas/ContentType/HAL.hs index 8621f28..2079a33 100644 --- a/src/Servant/Hateoas/ContentType/HAL.hs +++ b/src/Servant/Hateoas/ContentType/HAL.hs @@ -57,6 +57,9 @@ instance {-# OVERLAPPING #-} (ToJSON a, Related a, KnownSymbol (CollectionName a .= (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) diff --git a/src/Servant/Hateoas/Resource.hs b/src/Servant/Hateoas/Resource.hs index c66d3c7..60b4036 100644 --- a/src/Servant/Hateoas/Resource.hs +++ b/src/Servant/Hateoas/Resource.hs @@ -4,7 +4,10 @@ module Servant.Hateoas.Resource ( -- * Resource - Resource(..) + -- ** Modification + Resource(..), EmbeddingResource(..), CollectingResource(..) + + -- ** Construction , ToResource(..) , ToCollection(..) @@ -23,15 +26,27 @@ where 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 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 Resource res => ToResource api res a where +class ToResource api res a where -- | Converts a value into it's Resource-Representation. toResource :: a -> res a toResource = toResource' (Proxy @api) (Proxy @res) @@ -42,7 +57,7 @@ class Resource res => ToResource api res a where {-# MINIMAL toResource | toResource' #-} -- | Class for converting multiple values of @a@ to their respective collection-like representation. -class Resource res => ToCollection api res a where +class ToCollection api res a where -- | Converts a many values into their Collection-Representation. toCollection :: Foldable f => f a -> res a toCollection = toCollection' (Proxy @api) (Proxy @res) @@ -96,7 +111,7 @@ instance 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) From 9b76a9100fbbd88ee5962c769fb1898090fe124a Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Tue, 29 Oct 2024 17:28:10 +0100 Subject: [PATCH 07/12] collection: added cabal refs homepage & bug-reports --- servant-hateoas.cabal | 2 ++ 1 file changed, 2 insertions(+) diff --git a/servant-hateoas.cabal b/servant-hateoas.cabal index 6cad013..d02e9b7 100644 --- a/servant-hateoas.cabal +++ b/servant-hateoas.cabal @@ -6,6 +6,8 @@ description: Create Resource-Representations for your types and make 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 From 3f5d7b46abe3f171397a8bbde4b9609931262d8b Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Tue, 29 Oct 2024 17:48:11 +0100 Subject: [PATCH 08/12] collection: added instances for MimeRender --- src/Servant/Hateoas/ContentType/Collection.hs | 3 +++ src/Servant/Hateoas/ContentType/HAL.hs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/Servant/Hateoas/ContentType/Collection.hs b/src/Servant/Hateoas/ContentType/Collection.hs index f27efb7..25df962 100644 --- a/src/Servant/Hateoas/ContentType/Collection.hs +++ b/src/Servant/Hateoas/ContentType/Collection.hs @@ -48,6 +48,9 @@ instance Resource CollectionItem where 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 diff --git a/src/Servant/Hateoas/ContentType/HAL.hs b/src/Servant/Hateoas/ContentType/HAL.hs index 2079a33..38ed474 100644 --- a/src/Servant/Hateoas/ContentType/HAL.hs +++ b/src/Servant/Hateoas/ContentType/HAL.hs @@ -41,6 +41,9 @@ instance Resource HALResource where 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 From 2b6374dbc3b58028f2dd2549298384b75d1dfd6d Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Tue, 29 Oct 2024 17:48:25 +0100 Subject: [PATCH 09/12] collection: doc --- src/Servant/Hateoas/Example.hs | 2 +- src/Servant/Hateoas/Resource.hs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Servant/Hateoas/Example.hs b/src/Servant/Hateoas/Example.hs index 9ecdae2..2e1e65a 100644 --- a/src/Servant/Hateoas/Example.hs +++ b/src/Servant/Hateoas/Example.hs @@ -23,7 +23,7 @@ type AddressGetOne = "address" :> Capture "id" Int :> Get '[HAL JSON] (HALResour 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" diff --git a/src/Servant/Hateoas/Resource.hs b/src/Servant/Hateoas/Resource.hs index 60b4036..7994057 100644 --- a/src/Servant/Hateoas/Resource.hs +++ b/src/Servant/Hateoas/Resource.hs @@ -4,13 +4,13 @@ module Servant.Hateoas.Resource ( -- * Resource - -- ** Modification - Resource(..), EmbeddingResource(..), CollectingResource(..) - -- ** Construction -, ToResource(..) + ToResource(..) , ToCollection(..) + -- ** Modification +, Resource(..), EmbeddingResource(..), CollectingResource(..) + -- * Hypermedia-Relations -- ** Type , HRel(..) @@ -58,7 +58,7 @@ class ToResource api res a where -- | Class for converting multiple values of @a@ to their respective collection-like representation. class ToCollection api res a where - -- | Converts a many values into their Collection-Representation. + -- | Converts many values into their Collection-Representation. toCollection :: Foldable f => f a -> res a toCollection = toCollection' (Proxy @api) (Proxy @res) From 04ea490eb03b258763cb325cefecb7d6b8f348f5 Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Tue, 29 Oct 2024 17:58:13 +0100 Subject: [PATCH 10/12] collection: update readme --- README.md | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 7ff67ee..f2a4285 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ Currently in infant state. 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 AddressGetOne = "address" :> Capture "id" Int :> Get '[HAL JSON] (HALResour 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. + +## Contact information +Contributions, critics and bug reports are welcome! -Client usage with `MimeUnrender` is not yet supported but easily extensible. +Please feel free to contact me through GitHub. From 2ad44a6bbc38d24936b411c1cf76b4f8a342bb5c Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Tue, 29 Oct 2024 17:58:46 +0100 Subject: [PATCH 11/12] collection: bump version --- CHANGELOG.md | 10 ++++++++++ servant-hateoas.cabal | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6699a5d..040fb98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ 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.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 diff --git a/servant-hateoas.cabal b/servant-hateoas.cabal index d02e9b7..c6de4b8 100644 --- a/servant-hateoas.cabal +++ b/servant-hateoas.cabal @@ -1,6 +1,6 @@ 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. From 78354489f7f2398f4b8fac35fa58d19c4ec725c6 Mon Sep 17 00:00:00 2001 From: bruderj15 Date: Tue, 29 Oct 2024 18:05:23 +0100 Subject: [PATCH 12/12] collection: fix missing qualified `Foldable.foldl'` --- src/Servant/Hateoas/ContentType/Collection.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Servant/Hateoas/ContentType/Collection.hs b/src/Servant/Hateoas/ContentType/Collection.hs index 25df962..0ecab30 100644 --- a/src/Servant/Hateoas/ContentType/Collection.hs +++ b/src/Servant/Hateoas/ContentType/Collection.hs @@ -78,4 +78,4 @@ instance {-# OVERLAPPABLE #-} => ToCollection api CollectionResource a where toCollection is = CollectionResource Nothing is' mempty where - is' = foldl' (\xs x -> CollectionItem x (defaultLinks (Proxy @api) x) : xs) mempty is + is' = Foldable.foldl' (\xs x -> CollectionItem x (defaultLinks (Proxy @api) x) : xs) mempty is