packages feed

mig-0.2.1.0: src/Mig/Core/Class/Response.hs

-- | Generic response
module Mig.Core.Class.Response (
  Resp (..),
  RespOr (..),
  IsResp (..),
  badReq,
  internalServerError,
  notImplemented,
  redirect,
  setHeader,
  SetCookie (..),
  defCookie,
  setCookie,
) where

import Data.Bifunctor
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as BL
import Data.Kind
import Data.List qualified as List
import Data.Maybe
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.Time
import Network.HTTP.Media.RenderHeader (RenderHeader (..))
import Network.HTTP.Types.Header (HeaderName, ResponseHeaders, hSetCookie)
import Network.HTTP.Types.Status (Status, internalServerError500, notImplemented501, ok200, status302, status400)
import Web.HttpApiData
import Web.Internal.FormUrlEncoded

import Mig.Core.Class.MediaType (AnyMedia, MediaType, ToMediaType (..), ToRespBody (..))
import Mig.Core.Types.Http (Response, ResponseBody (..), noContentResponse)
import Mig.Core.Types.Http qualified as Response (Response (..))
import Mig.Core.Types.Http qualified as Types

-- | Response with info on the media-type encoded as type.
data Resp (media :: Type) a = Resp
  { status :: Status
  -- ^ response status
  , headers :: ResponseHeaders
  -- ^ response headers
  , body :: Maybe a
  -- ^ response body. Nothing means "no content" in the body
  }
  deriving (Show, Eq, Functor)

-- | Response that can contain an error. The error is represented with left case of an @Either@-type.
newtype RespOr ty err a = RespOr {unRespOr :: Either (Resp ty err) (Resp ty a)}
  deriving (Show, Eq, Functor)

-------------------------------------------------------------------------------------
-- response class

{-| Values that can be converted to low-level response.

The repsonse value is usually one of two cases:

* @Resp a@ -- for routes which always produce a value

* @RespOr err a@ - for routes that can also produce an error or value.

* @Response@ - low-level HTTP-response.
-}
class IsResp a where
  -- | the type of response body value
  type RespBody a :: Type

  -- | the type of an error
  type RespError a :: Type

  -- | the media tpye of resp
  type RespMedia a :: Type

  -- | Returns valid repsonse with 200 status
  ok :: RespBody a -> a

  -- | Returns an error with given status
  bad :: Status -> RespError a -> a

  -- | response with no content
  noContent :: Status -> a

  -- | Add some header to the response
  addHeaders :: ResponseHeaders -> a -> a

  -- | Get response headers
  getHeaders :: a -> ResponseHeaders

  -- | Sets repsonse status
  setStatus :: Status -> a -> a

  -- | Get response body
  getRespBody :: a -> Maybe (RespBody a)

  -- | Get response error
  getRespError :: a -> Maybe (RespError a)

  -- | Get response status
  getStatus :: a -> Status

  -- | Set the media type of the response
  setMedia :: MediaType -> a -> a
  setMedia media = addHeaders [("Content-Type", renderHeader media)]

  -- | Reads the media type by response type
  getMedia :: MediaType

  -- | Converts value to low-level response
  toResponse :: a -> Response

-- | Set header for response
setHeader :: (IsResp a, ToHttpApiData h) => HeaderName -> h -> a -> a
setHeader name val = addHeaders [(name, toHeader val)]

instance (ToRespBody ty a) => IsResp (Resp ty a) where
  type RespBody (Resp ty a) = a
  type RespError (Resp ty a) = a
  type RespMedia (Resp ty a) = ty

  ok = Resp ok200 [] . Just
  bad status = Resp status [] . Just
  addHeaders hs x = x{headers = x.headers <> hs}
  getHeaders x = x.headers
  noContent st = Resp st [] Nothing
  setStatus st x = x{status = st}
  getStatus x = x.status
  getMedia = toMediaType @ty
  getRespBody x = x.body
  getRespError x
    | x.status == ok200 = Nothing
    | otherwise = x.body

  toResponse a = Response.Response a.status headers body
    where
      media = toMediaType @ty
      headers = a.headers <> [("Content-Type", renderHeader media)]
      body = RawResp media (maybe "" (toRespBody @ty) a.body)

instance IsResp Response where
  type RespBody Response = BL.ByteString
  type RespError Response = BL.ByteString
  type RespMedia Response = AnyMedia

  ok = Response.Response ok200 [] . RawResp "*/*"
  bad st = Response.Response st [] . RawResp "*/*"
  addHeaders hs x = x{Response.headers = x.headers <> hs}
  noContent = noContentResponse
  setStatus st x = x{Response.status = st}
  getMedia = "*/*"
  getStatus x = x.status
  getHeaders x = x.headers
  getRespBody x = case x.body of
    RawResp _ res -> Just res
    _ -> Nothing
  getRespError x
    | x.status == ok200 = Nothing
    | otherwise = getRespBody x

  toResponse = id

  setMedia media = addHeaders [("Content-Type", renderHeader media)] . updateBody
    where
      updateBody response = response{Response.body = setBodyMedia response.body}

      setBodyMedia = \case
        RawResp _ content -> RawResp media content
        other -> other

instance (ToRespBody ty err, ToRespBody ty a) => IsResp (RespOr ty err a) where
  type RespBody (RespOr ty err a) = a
  type RespError (RespOr ty err a) = err
  type RespMedia (RespOr ty err a) = ty

  ok = RespOr . Right . Resp ok200 [] . Just
  bad status = RespOr . Left . bad status
  addHeaders hs = RespOr . bimap (addHeaders hs) (addHeaders hs) . unRespOr
  noContent st = RespOr $ Right (noContent st)
  setStatus st = RespOr . bimap (setStatus st) (setStatus st) . unRespOr
  getMedia = toMediaType @ty
  getStatus (RespOr x) = either (.status) (.status) x
  getHeaders (RespOr x) = either (.headers) (headers) x
  getRespBody (RespOr x) = either (const Nothing) (.body) x
  getRespError (RespOr x) = either (.body) (const Nothing) x

  toResponse = either toResponse toResponse . unRespOr

-- | Bad request. The @bad@ response with 400 status.
badReq :: (IsResp a) => RespError a -> a
badReq = bad status400

-- | Internal server error. The @bad@ response with 500 status.
internalServerError :: (IsResp a) => RespError a -> a
internalServerError = bad internalServerError500

-- | Not implemented route. The @bad@ response with 501 status.
notImplemented :: (IsResp a) => RespError a -> a
notImplemented = bad notImplemented501

-- | Redirect to url. It is @bad@ response with 302 status and set header of "Location" to a given URL.
redirect :: (IsResp a) => Text -> a
redirect url = addHeaders [("Location", Text.encodeUtf8 url)] $ noContent status302

-- | Set cookie as http header from form url encoded value
setCookie :: (ToForm cookie, IsResp resp) => SetCookie cookie -> resp -> resp
setCookie cookie = addHeaders [(hSetCookie, renderSetCookie cookie)]

{-| Set cookie params. For explanation see an article
<https://web.archive.org/web/20170122122852/https://www.nczonline.net/blog/2009/05/05/http-cookies-explained/>
-}
data SetCookie a = SetCookie
  { cookie :: a
  , expires :: Maybe UTCTime
  , domain :: Maybe Text
  , path :: Maybe Text
  , secure :: Bool
  , httpOnly :: Bool
  }
  deriving (Show, Eq)

renderSetCookie :: (ToForm a) => SetCookie a -> ByteString
renderSetCookie value =
  mconcat $
    (BL.toStrict $ urlEncodeForm $ toForm value.cookie)
      : addColons
        ( catMaybes
            [ param "expires" . fmtTime <$> value.expires
            , param "domain" <$> value.domain
            , param "path" <$> value.path
            , flag "secure" value.secure
            , flag "httpOnly" value.httpOnly
            ]
        )
  where
    addColons xs
      | null xs = []
      | otherwise = ";" : List.intersperse ";" xs

    param name v = Text.encodeUtf8 $ name <> v

    flag name = \case
      True -> Just name
      False -> Nothing

    fmtTime :: UTCTime -> Text
    fmtTime = Text.pack . formatTime defaultTimeLocale expiresFormat

    expiresFormat :: String
    expiresFormat = "%a, %d-%b-%Y %X GMT"

-- | Default cookie which sets only the cookie itself.
defCookie :: a -> SetCookie a
defCookie val =
  SetCookie
    { cookie = val
    , expires = Nothing
    , domain = Nothing
    , path = Nothing
    , secure = False
    , httpOnly = False
    }