packages feed

acolyte-server-0.1.0.0: src/Acolyte/Server/Response.hs

-- | Response encoding: converting handler return values to HTTP responses.
module Acolyte.Server.Response
  ( -- * Response class
    IntoResponse (..)
    -- * JSON response wrapper
  , Json (..)
    -- * Structured error
  , JsonError (..)
  , jsonError
  ) where

import Data.ByteString (ByteString)
import qualified Data.ByteString.Builder.Extra as Builder
import qualified Data.ByteString.Lazy as LBS
import Data.Text (Text)
import qualified Data.Text.Encoding as TE
import Network.HTTP.Types
  ( Status, status200, status400, status401, status403, status404
  , status422, status500, statusCode
  )

import qualified Data.Aeson as Aeson

import Http.Core (Response (..))
import Acolyte.Core.Endpoint (Json (..))
import Acolyte.Server.Extract (ServerError (..))


-- | Convert a value into an HTTP 'Response'.
--
-- Handlers can return any type with an 'IntoResponse' instance.
-- The endpoint's declared response type (for OpenAPI/clients) is
-- separate — see 'CompatibleWith' (future).
class IntoResponse a where
  intoResponse :: a -> Response ByteString


-- | Passthrough: Response is already a Response.
instance IntoResponse (Response ByteString) where
  intoResponse = id

-- | Plain text from ByteString.
instance IntoResponse ByteString where
  intoResponse bs = Response status200 [("Content-Type", "text/plain")] bs

-- | Plain text from Text.
instance IntoResponse Text where
  intoResponse t = Response status200 [("Content-Type", "text/plain; charset=utf-8")] (TE.encodeUtf8 t)

-- | Status code only (empty body).
instance IntoResponse Status where
  intoResponse s = Response s [] ""

-- | Status + body tuple.
instance IntoResponse a => IntoResponse (Status, a) where
  intoResponse (s, a) = (intoResponse a) { responseStatus = s }

-- | Either: Left is error response, Right is success response.
instance (IntoResponse e, IntoResponse a) => IntoResponse (Either e a) where
  intoResponse (Left e)  = intoResponse e
  intoResponse (Right a) = intoResponse a

-- | ServerError as a JSON error response.
instance IntoResponse ServerError where
  intoResponse (ServerError s msg) =
    let body = Aeson.encode $ Aeson.object
          [ "error" Aeson..= Aeson.object
            [ "status"  Aeson..= statusCode s
            , "message" Aeson..= msg
            ]
          ]
    in Response s [("Content-Type", "application/json")] (LBS.toStrict body)


-- ===================================================================
-- Json: JSON response wrapper
-- ===================================================================

-- | A JSON-serialized response with @application/json@ content type.
--
-- @
-- handler :: IO (Json User)
-- handler = pure (Json myUser)
-- @

instance Aeson.ToJSON a => IntoResponse (Json a) where
  intoResponse (Json val) =
    let body = LBS.toStrict
             $ Builder.toLazyByteStringWith
                 (Builder.safeStrategy 256 256)
                 LBS.empty
             $ Aeson.fromEncoding (Aeson.toEncoding val)
    in Response status200 [("Content-Type", "application/json")] body


-- ===================================================================
-- JsonError: structured JSON error responses
-- ===================================================================

-- | A structured JSON error response.
data JsonError = JsonError
  { jeStatus  :: !Status
  , jeMessage :: !Text
  } deriving (Show)

-- | Construct a 'JsonError' from a status code and message.
jsonError :: Status -> Text -> JsonError
jsonError = JsonError

instance IntoResponse JsonError where
  intoResponse (JsonError s msg) =
    let body = Aeson.encode $ Aeson.object
          [ "error" Aeson..= Aeson.object
            [ "status"  Aeson..= statusCode s
            , "message" Aeson..= msg
            ]
          ]
    in Response s [("Content-Type", "application/json")] (LBS.toStrict body)