servant-util-0.3: src/Servant/Util/Combinators/ErrorResponses.hs
module Servant.Util.Combinators.ErrorResponses
( ErrorDesc (..)
, ErrorPartialDesc (..)
, ErrorResponses
, type (#:)
, type ($)
, ExceptionalResponses
) where
import Universum
import Control.Lens (at, (<>~), (?~))
import qualified Data.OpenApi as O
import qualified Data.Swagger as S
import qualified Data.Swagger.Declare as S (runDeclare)
import GHC.TypeLits (KnownSymbol, Symbol)
import Servant (HasServer (..), (:>))
import Servant.Client (HasClient (..))
import Servant.OpenApi (HasOpenApi (..))
import Servant.Swagger (HasSwagger (..))
import Servant.Util.Combinators.Logging
import Servant.Util.Common
-- | Type-level information about an error response.
data ErrorDesc = ErrorDesc
{ erCode :: Nat
, erException :: Type
, erDesc :: Symbol
}
-- | Like 'ErrorDesc', but without exception type yet.
data ErrorPartialDesc = ErrorPartialDesc
{ epdCode :: Nat
, epdDesc :: Symbol
}
{- | This combinator adds description of error response to swagger specification.
You have two ways to define this combinator:
* General:
@
ErrorResponses
'[ 404 #! MyBackendException $
"Not found"
, 403 #! Int $
"Operation is not permitted"
]
@
* When only one exception type is used:
@
ExceptionalResponses MyBackendException
'[ 404 #: "Not found"
, 403 #: "Operation is not permitted"
]
@
Note that if an error code was already defined further in endpoint definition,
it will be overwriten. For instance, 'Capture's define 400 error code (invalid
format); but in endpoint like @ErrorResponses (400 ...) :> Capture ... :> ...@
description for 400-error case provided by 'Capture' will be overwritten.
This combinator is transparent for server implementation.
-}
data ErrorResponses (errs :: [ErrorDesc])
type family ExceptionDesc err (codes :: [ErrorPartialDesc]) :: [ErrorDesc] where
ExceptionDesc e '[] = '[]
ExceptionDesc e ('ErrorPartialDesc code desc ': cs) =
'ErrorDesc code e desc ': ExceptionDesc e cs
instance HasServer subApi ctx => HasServer (ErrorResponses errors :> subApi) ctx where
type ServerT (ErrorResponses errors :> subApi) m = ServerT subApi m
route _ = route (Proxy @subApi)
hoistServerWithContext _ = hoistServerWithContext (Proxy @subApi)
instance HasClient m subApi => HasClient m (ErrorResponses errors :> subApi) where
type Client m (ErrorResponses errors :> subApi) = Client m subApi
clientWithRoute pm _ = clientWithRoute pm (Proxy @subApi)
hoistClientMonad pm _ hst = hoistClientMonad pm (Proxy @subApi) hst
instance HasLoggingServer config lcontext subApi ctx =>
HasLoggingServer config lcontext (ErrorResponses errors :> subApi) ctx where
routeWithLog = inRouteServer @(ErrorResponses errors :> LoggingApiRec config lcontext subApi) route id
-- Swagger instances
class KnownErrorCodes (errors :: [ErrorDesc]) where
errorCodesToSwagger :: S.Swagger -> S.Swagger
instance KnownErrorCodes '[] where
errorCodesToSwagger = id
instance (KnownNat code, KnownSymbol desc, S.ToSchema exc, KnownErrorCodes es) =>
KnownErrorCodes ('ErrorDesc code exc desc ': es) where
errorCodesToSwagger swagger = swagger
& S.allOperations . S.responses . S.responses . at code ?~ S.Inline response
& S.definitions <>~ defs
& errorCodesToSwagger @es
where
code = fromIntegral (natVal @code Proxy)
desc = symbolValT @desc
(defs, excSchema) = S.runDeclare (S.declareSchemaRef (Proxy @exc)) mempty
response = mempty
& S.description .~ desc
& S.schema ?~ excSchema
instance ( HasSwagger subApi
, KnownErrorCodes errors
) => HasSwagger (ErrorResponses errors :> subApi) where
toSwagger _ = toSwagger (Proxy @subApi) & errorCodesToSwagger @errors
-- OpenApi instances
class KnownErrorCodesOpenApi (errors :: [ErrorDesc]) where
errorCodesToOpenApi :: O.OpenApi -> O.OpenApi
instance KnownErrorCodesOpenApi '[] where
errorCodesToOpenApi = id
instance (KnownNat code, KnownSymbol desc, O.ToSchema exc, KnownErrorCodesOpenApi es) =>
KnownErrorCodesOpenApi ('ErrorDesc code exc desc ': es) where
-- In the absence of a media type we cannot insert a response schema.
errorCodesToOpenApi openapi = openapi
& O.allOperations . O.responses . O.responses . at code ?~ O.Inline response
& errorCodesToOpenApi @es
where
code = fromIntegral (natVal @code Proxy)
desc = symbolValT @desc
response = mempty
& O.description .~ desc
instance ( HasOpenApi subApi
, KnownErrorCodesOpenApi errors
) => HasOpenApi (ErrorResponses errors :> subApi) where
toOpenApi _ = toOpenApi (Proxy @subApi) & errorCodesToOpenApi @errors
---------------------------------------------------------------------------
-- Helpers
---------------------------------------------------------------------------
-- | A convenient alias for use with 'ErrorResponse'.
type (#!) = 'ErrorDesc
-- | An alias for 'ErrorResponse' which allows to mention an exception type
-- just once across all errors specification.
type ExceptionalResponses err codes = ErrorResponses $ ExceptionDesc err codes
-- | A convenient alias for use with 'ExceptionalResponse'.
type (#:) = 'ErrorPartialDesc
---------------------------------------------------------------------------
-- Test samples
---------------------------------------------------------------------------
data MyBackendException
type Sample1 = ExceptionalResponses MyBackendException
'[ 404 #: "Not found"
, 403 #: "Operation is not permitted"
]
_sample1 :: Sample1
_sample1 = error "Just checked that kind of Sample1 is *"
type Sample2 =
ErrorResponses
'[ 404 #! MyBackendException $
"Not found"
, 403 #! Int $
"Operation is not permitted"
]
_sample2 :: Sample2
_sample2 = error "Just checked that kind of Sample2 is *"