packages feed

ffunctor-1.1.0: test/Data/FFunctor/ServantTest.hs

{-# LANGUAGE DataKinds        #-}
{-# LANGUAGE DeriveAnyClass   #-}
{-# LANGUAGE DeriveGeneric    #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators    #-}

module Data.FFunctor.ServantTest where

import           Control.Monad.Error.Class (liftEither)
import           Control.Monad.Except
import           Data.Aeson                hiding ((.:))
import           Data.Composition
import           Data.FFunctor
import           Data.Functor.Identity
import           Data.Proxy                (Proxy (..))
import           Data.Time                 (UTCTime)
import           GHC.Generics
import           Network.HTTP.Client       (defaultManagerSettings, newManager)
import           Servant.API
import           Servant.Client

-- From the Servant Tutorial
-- https://haskell-servant.readthedocs.io/en/stable/tutorial/index.html

-- Define a domain object
data User = User
  { name              :: String
  , age               :: Int
  , email             :: String
  , registration_date :: UTCTime
  } deriving (Generic, FromJSON, ToJSON)

-- Define the HTTP endpoints as types
type API = "users" :> Get '[JSON] [User]
      :<|> "users" :> ReqBody '[JSON] User :> Post '[JSON] User
      :<|> "users" :> Capture "userid" Integer :> ReqBody '[JSON] User :> Put '[JSON] User

-- Servant.Client derives functions that return a ClientM that can be run to
-- call those endpoints.
getUsers  :: ClientM [User]
postUsers :: User -> ClientM User
putUsers  :: Integer -> User -> ClientM User
getUsers :<|> postUsers :<|> putUsers = client (Proxy @API)

-- A record of function is just a data type that has functions as fields.
-- We can abstract over a higher kinded type parameter that will eventually
-- be our application's monad stack.
--
-- For example, we can create a record of functions for the API we just defined.
data UserApi m = UserApi
  { apiGetUsers  :: m [User]
  , apiPostUsers :: User -> m User
  , apiPutUsers  :: Integer -> User -> m User
  }

-- An advantage of records of functions is that we can write custom
-- implementations for use in unit tests, without ever talking to a real HTTP
-- server, or performing any IO.
--
-- For example, we may wish to have a trivial mock for the Identity monad, like
-- so, or to have state and error handling via Either and State. Every unit test
-- can have its own custom behaviour.
mockApi :: UserApi Identity
mockApi = UserApi (pure []) (\u -> pure u) (\_ u -> pure u)

-- For production, we want to use the Servant.Client functions that we derived
-- above. But we don't want our application's monad stack to be a ClientM, so we
-- will need to transform this...
servantApi :: UserApi ClientM
servantApi = UserApi getUsers postUsers putUsers

-- To transform a `UserApi ClientM` into a `UserApi OurMonadStack` we need to be
-- able to map over the type parameter. That is exactly what the FFunctor
-- typeclass allows us to do.
--
-- UserAPI may have an instance of an FFunctor because all occurences of the
-- higher kinded type m only appear in return (i.e. covariant) position.
--
-- Creating instances of FFunctor is procedural. Each field (which is a
-- function) has the natural transformation applied according to its number of
-- parameters:
--
--   0. If there are no parameters, the nt is applied as a regular
--      function
--   1. If there is one parameter, the nt is composed with (.)
--   2. If there are more than one parameter, the Data.Composition package may
--      be used, which provides compositions of arbitrary arity.
--      Data.Composition functions are conveniently named such that the number
--      of dots after the initial one are the number of parameters, so .:
--      handles two parameters, .:. handles three, .:: handles four, etc.
--
-- Note that FFunctor is not the same shape as HFunctor, MFunctor or MonadTrans,
-- although they are all related from a category theory point of view.
instance FFunctor UserApi where
  ffmap nt (UserApi f1 f2 f3) = UserApi (nt f1) (nt . f2) (nt .: f3)

-- We need a natural transformation from ClientM into an arbitrary monad stack.
-- The bare minimum requirements to do this are:
--
--   1. we need a value of Servant.Client.ClientEnv, providing the host, port
--      and connection settings.
--   2. we need to be able to perform IO, implying MonadIO
--   3. we need to be able to report ServantError, implying MonadError
--
-- Such a natural transformation may be defined like this and may be shared by
-- all APIs.
liftClientM :: (MonadIO m, MonadError ServantError m)
  => ClientEnv
  -> ClientM a
  -> m a
liftClientM env ca = liftEither =<< (liftIO $ runClientM ca env)

-- We can now generate a UserApi for our application's monad stack, which we can
-- create during initalisation from the ClientEnv configuration.
userApi :: (MonadIO m, MonadError ServantError m) => ClientEnv -> UserApi m
userApi env = ffmap (liftClientM env) servantApi

-- But this demands that we have a `MonadError ServantError` in our stack. That
-- sucks!
--
-- The trick to overcome this limitation is to define a type alias that allows
-- us to have only local errors, provided by the ExceptT monad transformer. This
-- is a well known MTL trick (which doesn't seem to have a name) that can be
-- used to add a variety of locally scoped capabilities to a component, such as
-- a MonadState/MonadReader/MonadWriter via their associated monad transformers.
type UserApiT m = UserApi (ExceptT ServantError m)

-- For example, a downstream user may depend on UserApiT and must handle
-- ServantErrors at the point of use. They may chose to retry, recover, ignore
-- errors, or translate errors into an application specific error ADT.
--
-- Note that we only need the minimal set of constraints, so we only require a
-- Applicative to write:
doStuff :: Applicative m => UserApiT m -> String -> m Bool
doStuff http check = hasEmail <$> (runExceptT $ apiGetUsers http)
  where
    hasEmail (Left _)      = False
    hasEmail (Right users) = any (\u -> (email u) == check) users

-- Compare to the version where errors are ignored and must be handled at a
-- higher layer.
doStuff' :: Applicative m => UserApi m -> String -> m Bool
doStuff' http check = hasEmail <$> apiGetUsers http
  where
    hasEmail users = any (\u -> (email u) == check) users

-- Creating an instance of UserApiT is easy
myApp :: IO Bool
myApp = do
  mgr <- newManager defaultManagerSettings
  let base = BaseUrl Http "localhost" 8080 ""
      env = mkClientEnv mgr base
      api = userApi env
  doStuff api "wibble@wobble.com"