mirror of
https://github.com/Start9Labs/registry.git
synced 2026-03-26 10:21:51 +00:00
457 lines
22 KiB
Haskell
457 lines
22 KiB
Haskell
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE RecordWildCards #-}
|
|
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
{-# LANGUAGE TypeApplications #-}
|
|
{-# LANGUAGE TemplateHaskell #-}
|
|
{-# LANGUAGE DeriveGeneric #-}
|
|
{-# LANGUAGE QuasiQuotes #-}
|
|
{-# LANGUAGE TupleSections #-}
|
|
|
|
|
|
module Handler.Marketplace where
|
|
import Startlude hiding (from, Handler, on, sortOn)
|
|
import Foundation
|
|
import Yesod.Core
|
|
import qualified Database.Persist as P
|
|
import Model
|
|
import Yesod.Persist.Core
|
|
import Database.Marketplace
|
|
import Data.List
|
|
import Lib.Types.Category
|
|
import Lib.Types.AppIndex
|
|
import qualified Data.HashMap.Strict as HM
|
|
import Lib.Types.Emver
|
|
import qualified Data.List.NonEmpty as NE
|
|
import Database.Esqueleto.Experimental
|
|
import Lib.Error
|
|
import Network.HTTP.Types
|
|
import Lib.Registry
|
|
import Settings
|
|
import System.FilePath.Posix
|
|
import Lib.External.AppMgr
|
|
import Data.Aeson
|
|
import qualified Data.ByteString.Lazy as BS
|
|
import qualified Data.Text as T
|
|
import Data.String.Interpolate.IsString
|
|
import Util.Shared
|
|
|
|
newtype CategoryRes = CategoryRes {
|
|
categories :: [CategoryTitle]
|
|
} deriving (Show, Generic)
|
|
instance ToJSON CategoryRes
|
|
instance FromJSON CategoryRes
|
|
instance ToContent CategoryRes where
|
|
toContent = toContent . toJSON
|
|
instance ToTypedContent CategoryRes where
|
|
toTypedContent = toTypedContent . toJSON
|
|
data ServiceRes = ServiceRes
|
|
{ serviceResIcon :: URL
|
|
, serviceResManifest :: Maybe Data.Aeson.Value -- ServiceManifest
|
|
, serviceResCategories :: [CategoryTitle]
|
|
, serviceResInstructions :: URL
|
|
, serviceResLicense :: URL
|
|
, serviceResVersions :: [Version]
|
|
, serviceResDependencyInfo :: HM.HashMap AppIdentifier DependencyInfo
|
|
} deriving (Generic)
|
|
|
|
newtype ReleaseNotes = ReleaseNotes { unReleaseNotes :: HM.HashMap Version Text }
|
|
deriving (Eq, Show)
|
|
instance ToJSON ReleaseNotes where
|
|
toJSON ReleaseNotes { .. } = object [ t .= v | (k,v) <- HM.toList unReleaseNotes, let (String t) = toJSON k ]
|
|
instance ToContent ReleaseNotes where
|
|
toContent = toContent . toJSON
|
|
instance ToTypedContent ReleaseNotes where
|
|
toTypedContent = toTypedContent . toJSON
|
|
instance ToJSON ServiceRes where
|
|
toJSON ServiceRes {..} = object
|
|
[ "icon" .= serviceResIcon
|
|
, "license" .= serviceResLicense
|
|
, "instructions" .= serviceResInstructions
|
|
, "manifest" .= serviceResManifest
|
|
, "categories" .= serviceResCategories
|
|
, "versions" .= serviceResVersions
|
|
, "dependency-metadata" .= serviceResDependencyInfo
|
|
]
|
|
instance ToContent ServiceRes where
|
|
toContent = toContent . toJSON
|
|
instance ToTypedContent ServiceRes where
|
|
toTypedContent = toTypedContent . toJSON
|
|
data DependencyInfo = DependencyInfo
|
|
{ dependencyInfoTitle :: Text -- title
|
|
, dependencyInfoIcon :: Text -- url
|
|
} deriving (Eq, Show)
|
|
instance ToJSON DependencyInfo where
|
|
toJSON DependencyInfo {..} = object
|
|
[ "icon" .= dependencyInfoIcon
|
|
, "title" .= dependencyInfoTitle
|
|
]
|
|
|
|
data ServiceListRes = ServiceListRes {
|
|
serviceListResCategories :: [CategoryTitle]
|
|
, serviceListResServices :: [ServiceAvailable]
|
|
} deriving (Show)
|
|
instance ToJSON ServiceListRes where
|
|
toJSON ServiceListRes {..} = object
|
|
[ "categories" .= serviceListResCategories
|
|
, "services" .= serviceListResServices
|
|
]
|
|
instance ToContent ServiceListRes where
|
|
toContent = toContent . toJSON
|
|
instance ToTypedContent ServiceListRes where
|
|
toTypedContent = toTypedContent . toJSON
|
|
|
|
data ServiceAvailable = ServiceAvailable
|
|
{ serviceAvailableId :: Text
|
|
, serviceAvailableTitle :: Text
|
|
, serviceAvailableVersion :: Version
|
|
, serviceAvailableIcon :: URL
|
|
, serviceAvailableDescShort :: Text
|
|
} deriving (Show)
|
|
instance ToJSON ServiceAvailable where
|
|
toJSON ServiceAvailable { .. } = object
|
|
[ "id" .= serviceAvailableId
|
|
, "title" .= serviceAvailableTitle
|
|
, "version" .= serviceAvailableVersion
|
|
, "icon" .= serviceAvailableIcon
|
|
, "descriptionShort" .= serviceAvailableDescShort
|
|
]
|
|
instance ToContent ServiceAvailable where
|
|
toContent = toContent . toJSON
|
|
instance ToTypedContent ServiceAvailable where
|
|
toTypedContent = toTypedContent . toJSON
|
|
|
|
newtype ServiceAvailableRes = ServiceAvailableRes [ServiceRes]
|
|
deriving (Generic)
|
|
instance ToJSON ServiceAvailableRes
|
|
instance ToContent ServiceAvailableRes where
|
|
toContent = toContent . toJSON
|
|
instance ToTypedContent ServiceAvailableRes where
|
|
toTypedContent = toTypedContent . toJSON
|
|
|
|
newtype VersionLatestRes = VersionLatestRes (HM.HashMap AppIdentifier (Maybe Version))
|
|
deriving (Show, Generic)
|
|
instance ToJSON VersionLatestRes
|
|
instance ToContent VersionLatestRes where
|
|
toContent = toContent . toJSON
|
|
instance ToTypedContent VersionLatestRes where
|
|
toTypedContent = toTypedContent . toJSON
|
|
data OrderArrangement = ASC | DESC
|
|
deriving (Eq, Show, Read)
|
|
data ServiceListDefaults = ServiceListDefaults
|
|
{ serviceListOrder :: OrderArrangement
|
|
, serviceListPageLimit :: Int64 -- the number of items per page
|
|
, serviceListPageNumber :: Int64 -- the page you are on
|
|
, serviceListCategory :: CategoryTitle
|
|
, serviceListQuery :: Text
|
|
}
|
|
deriving (Eq, Show, Read)
|
|
data EosRes = EosRes
|
|
{ eosResVersion :: Version
|
|
, eosResHeadline :: Text
|
|
, eosResReleaseNotes :: ReleaseNotes
|
|
} deriving (Eq, Show, Generic)
|
|
instance ToJSON EosRes where
|
|
toJSON EosRes { .. } = object
|
|
[ "version" .= eosResVersion
|
|
, "headline" .= eosResHeadline
|
|
, "release-notes" .= eosResReleaseNotes
|
|
]
|
|
instance ToContent EosRes where
|
|
toContent = toContent . toJSON
|
|
instance ToTypedContent EosRes where
|
|
toTypedContent = toTypedContent . toJSON
|
|
|
|
data PackageVersion = PackageVersion
|
|
{ packageVersionId :: AppIdentifier
|
|
, packageVersionVersion :: VersionRange
|
|
} deriving (Show)
|
|
instance FromJSON PackageVersion where
|
|
parseJSON = withObject "package version" $ \o -> do
|
|
packageVersionId <- o .: "id"
|
|
packageVersionVersion <- o .: "version"
|
|
pure PackageVersion { .. }
|
|
|
|
getCategoriesR :: Handler CategoryRes
|
|
getCategoriesR = do
|
|
allCategories <- runDB $ select $ do
|
|
cats <- from $ table @Category
|
|
orderBy [desc (cats ^. CategoryPriority)]
|
|
pure cats
|
|
pure $ CategoryRes $ categoryName . entityVal <$>allCategories
|
|
|
|
getEosR :: Handler EosRes
|
|
getEosR = do
|
|
allEosVersions <- runDB $ select $ do
|
|
vers <- from $ table @OsVersion
|
|
orderBy [desc (vers ^. OsVersionCreatedAt)]
|
|
pure vers
|
|
let osV = entityVal <$> allEosVersions
|
|
let latest = Data.List.head osV
|
|
let mappedVersions = ReleaseNotes $ HM.fromList $ sortOn (Down . fst) $ (\v -> (osVersionNumber v, osVersionReleaseNotes v)) <$> osV
|
|
pure $ EosRes
|
|
{ eosResVersion = osVersionNumber latest
|
|
, eosResHeadline = osVersionHeadline latest
|
|
, eosResReleaseNotes = mappedVersions
|
|
}
|
|
|
|
getReleaseNotesR :: Handler ReleaseNotes
|
|
getReleaseNotesR = do
|
|
getParameters <- reqGetParams <$> getRequest
|
|
case lookup "id" getParameters of
|
|
Nothing -> sendResponseStatus status400 ("expected query param \"id\" to exist" :: Text)
|
|
Just package -> do
|
|
(service, _) <- runDB $ fetchLatestApp package >>= errOnNothing status404 "package not found"
|
|
(_, mappedVersions) <- fetchAllAppVersions (entityKey service)
|
|
pure mappedVersions
|
|
|
|
getVersionLatestR :: Handler VersionLatestRes
|
|
getVersionLatestR = do
|
|
getParameters <- reqGetParams <$> getRequest
|
|
case lookup "ids" getParameters of
|
|
Nothing -> sendResponseStatus status400 ("expected query param \"ids\" to exist" :: Text)
|
|
Just packages -> case eitherDecode $ BS.fromStrict $ encodeUtf8 packages of
|
|
Left e -> sendResponseStatus status400 ("could not parse query param \"ids\"" <> show e :: Text)
|
|
Right (p :: [AppIdentifier])-> do
|
|
let packageList :: [(AppIdentifier, Maybe Version)] = (, Nothing) <$> p
|
|
found <- runDB $ traverse fetchLatestApp $ fst <$> packageList
|
|
pure $ VersionLatestRes $ HM.union (HM.fromList $ (\v -> (sAppAppId $ entityVal $ fst v :: AppIdentifier, Just $ sVersionNumber $ entityVal $ snd v)) <$> catMaybes found) $ HM.fromList packageList
|
|
|
|
getPackageListR :: Handler ServiceAvailableRes
|
|
getPackageListR = do
|
|
getParameters <- reqGetParams <$> getRequest
|
|
let defaults = ServiceListDefaults
|
|
{ serviceListOrder = DESC
|
|
, serviceListPageLimit = 20
|
|
, serviceListPageNumber = 1
|
|
, serviceListCategory = ANY
|
|
, serviceListQuery = ""
|
|
}
|
|
case lookup "ids" getParameters of
|
|
Nothing -> do
|
|
-- query for all
|
|
category <- case lookup "category" getParameters of
|
|
Nothing -> pure $ serviceListCategory defaults
|
|
Just c -> case readMaybe $ T.toUpper c of
|
|
Nothing -> do
|
|
$logInfo c
|
|
sendResponseStatus status400 ("could not read category" :: Text)
|
|
Just t -> pure t
|
|
page <- case lookup "page" getParameters of
|
|
Nothing -> pure $ serviceListPageNumber defaults
|
|
Just p -> case readMaybe p of
|
|
Nothing -> do
|
|
$logInfo p
|
|
sendResponseStatus status400 ("could not read page" :: Text)
|
|
Just t -> pure $ case t of
|
|
0 -> 1 -- disallow page 0 so offset is not negative
|
|
_ -> t
|
|
limit' <- case lookup "per-page" getParameters of
|
|
Nothing -> pure $ serviceListPageLimit defaults
|
|
Just c -> case readMaybe $ toS c of
|
|
Nothing -> sendResponseStatus status400 ("could not read per-page" :: Text)
|
|
Just l -> pure l
|
|
query <- T.filter (not . isSpace) . fromMaybe (serviceListQuery defaults) <$> lookupGetParam "query"
|
|
filteredServices <- runDB $ searchServices category limit' ((page - 1) * limit') query
|
|
-- domain <- getsYesod $ registryHostname . appSettings
|
|
-- (appsDir, appMgrDir) <- getsYesod $ ((</> "apps") . resourcesDir &&& staticBinDir) . appSettings
|
|
-- res <- runDB $ traverse (mapEntityToServiceAvailable appMgrDir appsDir domain) filteredServices
|
|
res <- traverse (getServiceDetails Nothing) filteredServices
|
|
pure $ ServiceAvailableRes res
|
|
|
|
Just packageVersionList -> case eitherDecode $ BS.fromStrict $ encodeUtf8 packageVersionList of
|
|
Left e -> sendResponseStatus status400 ("could not parse query param \"ids\"" <> show e :: Text)
|
|
Right (packages :: [PackageVersion])-> do
|
|
-- for each item in list get best available from version range
|
|
availableServices <- traverse getPackageDetails packages
|
|
services <- traverse (uncurry getServiceDetails) availableServices
|
|
pure $ ServiceAvailableRes services
|
|
where
|
|
getPackageDetails :: PackageVersion -> HandlerFor RegistryCtx (Maybe (Entity SVersion), Entity SApp)
|
|
getPackageDetails pv = do
|
|
appsDir <- getsYesod $ ((</> "apps") . resourcesDir) . appSettings
|
|
let appId = packageVersionId pv
|
|
let spec = packageVersionVersion pv
|
|
let appExt = Extension (toS appId) :: Extension "s9pk"
|
|
getBestVersion appsDir appExt spec >>= \case
|
|
Nothing -> sendResponseStatus status404 ("best version could not be found for " <> appId <> " with spec " <> show spec :: Text)
|
|
Just v -> do
|
|
(service, version) <- runDB $ fetchLatestAppAtVersion appId v >>= errOnNothing status404 ("service at version " <> show v <> " not found")
|
|
pure (Just version, service)
|
|
|
|
getServiceR :: Handler ServiceRes
|
|
getServiceR = do
|
|
getParameters <- reqGetParams <$> getRequest
|
|
(service, version) <- case lookup "id" getParameters of
|
|
Nothing -> sendResponseStatus status404 ("id param should exist" :: Text)
|
|
Just appId' -> do
|
|
case lookup "version" getParameters of
|
|
-- default to latest - @TODO need to determine best available based on OS version?
|
|
Nothing -> runDB $ fetchLatestApp appId' >>= errOnNothing status404 "service not found"
|
|
Just v -> do
|
|
case readMaybe v of
|
|
Nothing -> sendResponseStatus status400 ("Invalid App Version Specification" :: Text)
|
|
Just vv -> runDB $ fetchLatestAppAtVersion appId' vv >>= errOnNothing status404 ("service at version " <> show v <> " not found")
|
|
getServiceDetails (Just version) service
|
|
|
|
getServiceDetails :: Maybe (Entity SVersion) -> Entity SApp -> HandlerFor RegistryCtx ServiceRes
|
|
getServiceDetails maybeVersion service = do
|
|
(versions, _) <- fetchAllAppVersions (entityKey service)
|
|
categories <- runDB $ fetchAppCategories (entityKey service)
|
|
(appsDir, appMgrDir) <- getsYesod $ ((</> "apps") . resourcesDir &&& staticBinDir) . appSettings
|
|
domain <- getsYesod $ registryHostname . appSettings
|
|
let appId = sAppAppId $ entityVal service
|
|
version <- case maybeVersion of
|
|
Nothing -> do
|
|
(_, version) <- runDB $ fetchLatestApp appId >>= errOnNothing status404 "service not found"
|
|
pure $ sVersionNumber $ entityVal version
|
|
Just v -> pure $ sVersionNumber $ entityVal v
|
|
let appDir = (<> "/") . (</> show version) . (</> toS appId) $ appsDir
|
|
let appExt = Extension (toS appId) :: Extension "s9pk"
|
|
manifest' <- handleS9ErrT $ getManifest appMgrDir appDir appExt
|
|
manifest <- case eitherDecode $ BS.fromStrict manifest' of
|
|
Left e -> do
|
|
$logError "could not parse service manifest!"
|
|
$logError (show e)
|
|
sendResponseStatus status500 ("Internal Server Error" :: Text)
|
|
Right a -> pure a
|
|
d <- traverse (mapDependencyMetadata appsDir domain) (HM.toList $ serviceManifestDependencies manifest)
|
|
pure $ ServiceRes
|
|
{ serviceResIcon = [i|https://#{domain}/package/icon/#{appId}|]
|
|
, serviceResManifest = decode $ BS.fromStrict manifest' -- pass through raw JSON Value
|
|
, serviceResCategories = serviceCategoryCategoryName . entityVal <$> categories
|
|
, serviceResInstructions = [i|https://#{domain}/package/instructions/#{appId}|]
|
|
, serviceResLicense = [i|https://#{domain}/package/license/#{appId}|]
|
|
, serviceResVersions = versionInfoVersion <$> versions
|
|
, serviceResDependencyInfo = HM.fromList d
|
|
}
|
|
|
|
type URL = Text
|
|
mapDependencyMetadata :: (MonadIO m, MonadHandler m) => FilePath -> Text -> (AppIdentifier, ServiceDependencyInfo) -> m (AppIdentifier, DependencyInfo)
|
|
mapDependencyMetadata appsDir domain (appId, depInfo) = do
|
|
let ext = (Extension (toS appId) :: Extension "s9pk")
|
|
-- get best version from VersionRange of dependency
|
|
version <- getBestVersion appsDir ext (serviceDependencyInfoVersion depInfo) >>= \case
|
|
Nothing -> sendResponseStatus status404 ("best version not found for dependent package " <> appId :: Text)
|
|
Just v -> pure v
|
|
pure (appId, DependencyInfo
|
|
{ dependencyInfoTitle = appId
|
|
, dependencyInfoIcon = [i|https://#{domain}/package/icon/#{appId}?spec==#{version}|]
|
|
})
|
|
|
|
decodeIcon :: (MonadHandler m, KnownSymbol a) => FilePath -> FilePath -> Extension a -> m URL
|
|
decodeIcon appmgrPath depPath e@(Extension icon) = do
|
|
icon' <- handleS9ErrT $ getIcon appmgrPath depPath e
|
|
case eitherDecode $ BS.fromStrict icon' of
|
|
Left e' -> do
|
|
$logInfo $ T.pack e'
|
|
sendResponseStatus status400 e'
|
|
Right (i' :: URL) -> pure $ i' <> T.pack icon
|
|
|
|
decodeInstructions :: (MonadHandler m, KnownSymbol a) => FilePath -> FilePath -> Extension a -> m Text
|
|
decodeInstructions appmgrPath depPath package = do
|
|
instructions <- handleS9ErrT $ getInstructions appmgrPath depPath package
|
|
pure $ decodeUtf8 instructions
|
|
|
|
decodeLicense :: (MonadHandler m, KnownSymbol a) => FilePath -> FilePath -> Extension a -> m Text
|
|
decodeLicense appmgrPath depPath package = do
|
|
license <- handleS9ErrT $ getLicense appmgrPath depPath package
|
|
pure $ decodeUtf8 license
|
|
|
|
fetchAllAppVersions :: Key SApp -> HandlerFor RegistryCtx ([VersionInfo], ReleaseNotes)
|
|
fetchAllAppVersions appId = do
|
|
entityAppVersions <- runDB $ P.selectList [SVersionAppId P.==. appId] [] -- orderby version
|
|
let vers = entityVal <$> entityAppVersions
|
|
let vv = mapSVersionToVersionInfo vers
|
|
let mappedVersions = ReleaseNotes $ HM.fromList $ (\v -> (versionInfoVersion v, versionInfoReleaseNotes v)) <$> vv
|
|
pure (vv, mappedVersions)
|
|
|
|
fetchMostRecentAppVersions :: MonadIO m => Key SApp -> ReaderT SqlBackend m [Entity SVersion]
|
|
fetchMostRecentAppVersions appId = select $ do
|
|
version <- from $ table @SVersion
|
|
where_ (version ^. SVersionAppId ==. val appId)
|
|
orderBy [ desc (version ^. SVersionNumber) ]
|
|
limit 1
|
|
pure version
|
|
|
|
fetchLatestApp :: MonadIO m => Text -> ReaderT SqlBackend m (Maybe (P.Entity SApp, P.Entity SVersion))
|
|
fetchLatestApp appId = selectOne $ do
|
|
(service :& version) <-
|
|
from $ table @SApp
|
|
`innerJoin` table @SVersion
|
|
`on` (\(service :& version) ->
|
|
service ^. SAppId ==. version ^. SVersionAppId)
|
|
where_ (service ^. SAppAppId ==. val appId)
|
|
orderBy [ desc (version ^. SVersionNumber)]
|
|
pure (service, version)
|
|
|
|
fetchLatestAppAtVersion :: MonadIO m => Text -> Version -> ReaderT SqlBackend m (Maybe (P.Entity SApp, P.Entity SVersion))
|
|
fetchLatestAppAtVersion appId version' = selectOne $ do
|
|
(service :& version) <-
|
|
from $ table @SApp
|
|
`innerJoin` table @SVersion
|
|
`on` (\(service :& version) ->
|
|
service ^. SAppId ==. version ^. SVersionAppId)
|
|
where_ $ (service ^. SAppAppId ==. val appId)
|
|
&&. (version ^. SVersionNumber ==. val version')
|
|
pure (service, version)
|
|
|
|
fetchAppCategories :: MonadIO m => Key SApp -> ReaderT SqlBackend m [P.Entity ServiceCategory]
|
|
fetchAppCategories appId = select $ do
|
|
(categories :& service) <-
|
|
from $ table @ServiceCategory
|
|
`innerJoin` table @SApp
|
|
`on` (\(sc :& s) ->
|
|
sc ^. ServiceCategoryServiceId ==. s ^. SAppId)
|
|
where_ (service ^. SAppId ==. val appId)
|
|
pure categories
|
|
|
|
mapEntityToStoreApp :: MonadIO m => Entity SApp -> ReaderT SqlBackend m StoreApp
|
|
mapEntityToStoreApp serviceEntity = do
|
|
let service = entityVal serviceEntity
|
|
entityVersion <- fetchMostRecentAppVersions $ entityKey serviceEntity
|
|
let vers = entityVal <$> entityVersion
|
|
let vv = mapSVersionToVersionInfo vers
|
|
pure $ StoreApp {
|
|
storeAppTitle = sAppTitle service
|
|
, storeAppDescShort = sAppDescShort service
|
|
, storeAppDescLong = sAppDescLong service
|
|
, storeAppVersionInfo = NE.fromList vv
|
|
, storeAppIconType = sAppIconType service
|
|
, storeAppTimestamp = Just (sAppCreatedAt service) -- case on if updatedAt? or always use updated time? was file timestamp
|
|
}
|
|
|
|
mapEntityToServiceAvailable :: (MonadIO m, MonadHandler m) => Text -> Entity SApp -> ReaderT SqlBackend m ServiceAvailable
|
|
mapEntityToServiceAvailable domain service = do
|
|
let appId = sAppAppId $ entityVal service
|
|
(_, v) <- fetchLatestApp appId >>= errOnNothing status404 "service not found"
|
|
let appVersion = sVersionNumber (entityVal v)
|
|
pure $ ServiceAvailable
|
|
{ serviceAvailableId = appId
|
|
, serviceAvailableTitle = sAppTitle $ entityVal service
|
|
, serviceAvailableDescShort = sAppDescShort $ entityVal service
|
|
, serviceAvailableVersion = appVersion
|
|
, serviceAvailableIcon = [i|https://#{domain}/package/icon/#{appId}?spec==#{appVersion}|]
|
|
}
|
|
|
|
-- >>> encode hm
|
|
-- "{\"0.2.0\":\"some notes\"}"
|
|
hm :: Data.Aeson.Value
|
|
hm = object [ t .= v | (k,v) <- [("0.2.0", "some notes") :: (Version, Text)], let (String t) = toJSON k ]
|
|
|
|
-- >>> encode rn
|
|
-- "{\"0.2.0\":\"notes one\",\"0.3.0\":\"notes two\"}"
|
|
rn :: ReleaseNotes
|
|
rn = ReleaseNotes $ HM.fromList [("0.2.0", "notes one"), ("0.3.0", "notes two")]
|
|
|
|
-- >>> readMaybe $ cc :: Maybe CategoryTitle
|
|
-- Just FEATURED
|
|
cc :: Text
|
|
cc = T.toUpper "featured"
|
|
|
|
-- >>> encode ccc
|
|
-- "\"featured\""
|
|
ccc :: CategoryTitle
|
|
ccc = FEATURED
|
|
|