mellon-web-0.8.0.2: src/Mellon/Web/Server/API.hs
{-|
Module : Mellon.Web.Server.API
Description : A REST web service for @mellon-core@ controllers
Copyright : (c) 2017, Quixoftic, LLC
License : BSD3
Maintainer : Drew Hess <dhess-src@quixoftic.com>
Stability : experimental
Portability : non-portable
This module provides a "Servant" REST web service for @mellon-core@
controllers. The service translates REST methods to controller
actions.
See the included <API.md API.md> file for detailed documentation on
the REST service methods and document types.
-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators #-}
module Mellon.Web.Server.API
( -- * Types
MellonAPI
, State(..)
, Time(..)
-- * Servant / WAI functions
, app
, mellonAPI
, server
) where
import Control.Lens ((&), (.~), (?~), mapped)
import Control.Monad.Trans.Reader (ReaderT, runReaderT, ask)
import Control.Monad.IO.Class (liftIO)
import qualified Data.Aeson.Types as Aeson (Value(String))
import Data.Aeson.Types
((.=), (.:), (.:?), FromJSON(..), Pair, Series, ToJSON(..),
Value(Object), defaultOptions, genericToJSON, genericParseJSON,
object, pairs, typeMismatch)
import Data.Data
import Data.Maybe (maybe)
import Data.Monoid ((<>))
import Data.Swagger
(NamedSchema(..), Referenced(Inline), SwaggerType(..),
ToSchema(..), declareSchemaRef, defaultSchemaOptions, description,
enum_, example, genericDeclareNamedSchema, properties, required,
schema, type_)
import Data.Text (Text)
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (UTCTime(..), getCurrentTime)
import GHC.Generics
import Lucid
(ToHtml(..), HtmlT, doctypehtml_, head_, title_, body_)
import Mellon.Controller
(Controller, lockController, unlockController, queryController)
import qualified Mellon.Controller as Controller (State(..))
import Network.Wai (Application)
import Servant
((:<|>)(..), (:>), (:~>)(..), Get, Handler, JSON, Proxy(..), Put,
ReqBody, Server, ServerT, enter, serve)
import Servant.Docs (ToSample(..))
import Servant.HTML.Lucid (HTML)
-- $setup
-- >>> :set -XOverloadedStrings
-- >>> import Data.Aeson (eitherDecode, encode)
-- >>> import Data.Swagger.Schema.Validation
wrapBody :: Monad m => HtmlT m () -> HtmlT m a -> HtmlT m a
wrapBody title body =
doctypehtml_ $
do head_ $
title_ title
body_ body
-- | Mimics 'Controller.State', but provides JSON conversions.
-- (Avoids orphan instances.)
data State
= Locked
| Unlocked !UTCTime
deriving (Eq, Data, Read, Show, Generic, Typeable)
stateToState :: Controller.State -> State
stateToState Controller.StateLocked = Locked
stateToState (Controller.StateUnlocked date) = Unlocked date
lockedName :: Text
lockedName = "Locked"
unlockedName :: Text
unlockedName = "Unlocked"
untilName :: Text
untilName = "until"
stateName :: Text
stateName = "state"
lockedPair :: Pair
lockedPair = stateName .= lockedName
unlockedPair :: Pair
unlockedPair = stateName .= unlockedName
untilPair :: UTCTime -> Pair
untilPair t = untilName .= t
lockedSeries :: Series
lockedSeries = stateName .= lockedName
unlockedSeries :: Series
unlockedSeries = stateName .= unlockedName
untilSeries :: UTCTime -> Series
untilSeries t = untilName .= t
-- $
-- >>> toJSON Locked
-- Object (fromList [("state",String "Locked")])
-- >>> toJSON $ Unlocked sampleDate
-- Object (fromList [("state",String "Unlocked"),("until",String "2015-10-06T00:00:00Z")])
-- >>> encode $ toJSON Locked
-- "{\"state\":\"Locked\"}"
-- >>> encode $ toJSON $ Unlocked sampleDate
-- "{\"state\":\"Unlocked\",\"until\":\"2015-10-06T00:00:00Z\"}"
instance ToJSON State where
toJSON Locked = object [lockedPair]
toJSON (Unlocked time) = object [unlockedPair, untilPair time]
toEncoding Locked = pairs lockedSeries
toEncoding (Unlocked time) = pairs $ unlockedSeries <> untilSeries time
-- $
-- >>> (eitherDecode $ encode $ toJSON $ Unlocked sampleDate) :: Either String State
-- Right (Unlocked 2015-10-06 00:00:00 UTC)
-- >>> eitherDecode $ "{\"state\":\"Unlocked\",\"until\":\"2017-05-05T22:30-08:00\"}" :: Either String State
-- Right (Unlocked 2017-05-06 06:30:00 UTC)
-- >>> (eitherDecode $ encode $ toJSON Locked) :: Either String State
-- Right Locked
-- >>> eitherDecode $ "{\"state\":\"Unlocked\"}" :: Either String State
-- Left "Error in $: 'Unlocked' state requires an expiration date"
-- >>> eitherDecode $ "{\"state\":\"Locked\",\"until\":\"2015-10-06T00:00:00Z\"}" :: Either String State
-- Left "Error in $: 'Locked' state takes no argument"
-- >>> eitherDecode $ "{\"state\":\"Unlocked\",\"until\":\"2015\"}" :: Either String State
-- Left "Error in $.until: could not parse date: '-': not enough input"
-- >>> eitherDecode $ "{\"state\":\"Lokced\"}" :: Either String State -- note: typo
-- Left "Error in $: Invalid 'state' value"
instance FromJSON State where
parseJSON (Object v) = do
state :: Text <- v .: stateName
until_ :: Maybe Time <- v .:? untilName
case state of
"Locked" ->
maybe
(pure Locked)
(const $ fail "'Locked' state takes no argument")
until_
"Unlocked" ->
maybe
(fail "'Unlocked' state requires an expiration date")
(\(Time t) -> pure $ Unlocked t)
until_
_ -> fail "Invalid 'state' value"
parseJSON invalid = typeMismatch "State" invalid
-- $
-- >>> validateToJSON Locked
-- []
-- >>> validateToJSON $ Unlocked sampleDate
-- []
instance ToSchema State where
declareNamedSchema _ = do
utcTimeSchema <- declareSchemaRef (Proxy :: Proxy UTCTime)
let stateSchema =
mempty & enum_ ?~ [Aeson.String lockedName, Aeson.String unlockedName]
& type_ .~ SwaggerString
return $
NamedSchema (Just "State") $
mempty & type_ .~ SwaggerObject
& properties .~ [(stateName, Inline stateSchema), (untilName, utcTimeSchema)]
& required .~ [stateName]
& description ?~ "The controller state; a variant type."
& example ?~ toJSON (Unlocked sampleDate)
sampleDate :: UTCTime
sampleDate = UTCTime { utctDay = fromGregorian 2015 10 06, utctDayTime = 0 }
instance ToSample State where
toSamples _ =
[ ("Locked", Locked)
, ("Unlocked until a given date", Unlocked sampleDate)
]
stateDocument :: Monad m => HtmlT m a -> HtmlT m a
stateDocument = wrapBody "Mellon state"
instance ToHtml State where
toHtml Locked = stateDocument "Locked"
toHtml (Unlocked time) = stateDocument $ "Unlocked until " >> toHtml (show time)
toHtmlRaw = toHtml
-- | A newtype wrapper around 'UTCTime', for serving HTML without
-- orphan instances.
newtype Time =
Time UTCTime
deriving (Eq, Data, Ord, Read, Show, Generic, Typeable)
instance ToJSON Time where
toJSON = genericToJSON defaultOptions
instance FromJSON Time where
parseJSON = genericParseJSON defaultOptions
-- $
-- >>> validateToJSON sampleDate
-- []
instance ToSchema Time where
declareNamedSchema proxy =
genericDeclareNamedSchema defaultSchemaOptions proxy
& mapped.schema.description ?~ "A UTC date"
& mapped.schema.example ?~ toJSON sampleDate
instance ToSample Time where
toSamples _ = [("2015-10-06",Time sampleDate)]
timeDocument :: Monad m => HtmlT m a -> HtmlT m a
timeDocument = wrapBody "Server time"
instance ToHtml Time where
toHtml (Time time) = timeDocument $ toHtml $ "Server time is " ++ show time
toHtmlRaw = toHtml
-- | A "Servant" API for interacting with a 'Controller'.
--
-- In addition to the controller methods, the API also provides a way
-- to obtain the system time on the server, to ensure that the
-- server's clock is accurate.
type MellonAPI =
"time" :> Get '[JSON, HTML] Time :<|>
"state" :> Get '[JSON, HTML] State :<|>
"state" :> ReqBody '[JSON] State :> Put '[JSON, HTML] State
type AppM d = ReaderT (Controller d) Handler
serverT :: ServerT MellonAPI (AppM d)
serverT =
getTime :<|>
getState :<|>
putState
where
getTime :: AppM d Time
getTime =
do now <- liftIO getCurrentTime
return $ Time now
getState :: AppM d State
getState =
do cc <- ask
fmap stateToState (queryController cc)
putState :: State -> AppM d State
putState Locked =
do cc <- ask
fmap stateToState (lockController cc)
putState (Unlocked date) =
do cc <- ask
fmap stateToState (unlockController date cc)
-- | A 'Proxy' for 'MellonAPI', exported in order to make it possible
-- to extend the API.
mellonAPI :: Proxy MellonAPI
mellonAPI = Proxy
appToHandler :: Controller d -> AppM d :~> Handler
appToHandler cc = NT $ \m -> runReaderT m cc
-- | A Servant 'Server' which serves the 'MellonAPI' on the given
-- 'Controller'.
--
-- Normally you will just use 'app', but this function is exported so
-- that you can extend/wrap 'MellonAPI'.
server :: Controller d -> Server MellonAPI
server cc = enter (appToHandler cc) serverT
-- | A WAI 'Network.Wai.Application' which runs the service, using the
-- given 'Controller'.
app :: Controller d -> Application
app = serve mellonAPI . server