仆人服务器的自定义 JSON 错误

Custom JSON errors for Servant-server

当使用 servant 时,我想 return 所有错误都作为 JSON。目前,如果请求解析失败,我会看到这样的错误消息,returned as plain text

Failed reading: not a valid json value

相反,我想 return 这作为 application/json

{"error":"Failed reading: not a valid json value"}

我该怎么做?文档说 ServantErr 是默认错误类型,我当然可以在我的处理程序中使用自定义错误来响应,但如果解析失败,我不知道如何 return 自定义错误。

首先,一些语言扩展

{-# LANGUAGE FlexibleContexts      #-}
{-# LANGUAGE FlexibleInstances     #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings     #-}
{-# LANGUAGE ScopedTypeVariables   #-}
{-# LANGUAGE TypeFamilies          #-}
{-# LANGUAGE TypeOperators         #-}
{-# LANGUAGE UndecidableInstances  #-}
{-# LANGUAGE ViewPatterns          #-}

现在

不幸的是,这比应有的困难。 Servant,虽然 well-designed 和小逻辑部分的组成,但对 HTTP 服务应该如何运行非常固执己见。您可能正在使用的 ReqBody 的默认实现是 hard-coded 吐出一个文本字符串。

但是,我们可以为自己的数据类型换出ReqBody

module Body where

import Control.Monad.Trans (liftIO)
import Data.Proxy (Proxy(..))
import Network.Wai (lazyRequestBody)

import Data.Aeson
import Servant.API
import Servant.Server
import Servant.Server.Internal

data Body a
instance (FromJSON a, HasServer api context) => HasServer (Body a :> api) context where
  type ServerT (Body a :> api) m = a -> ServerT api m

  route Proxy context subserver =
    route (Proxy :: Proxy api) context (addBodyCheck subserver (withRequest bodyCheck))
    where
      bodyCheck request = do
        body <- liftIO (lazyRequestBody request)
        case eitherDecode body of
          Left (BodyError -> e) ->
            delayedFailFatal err400 { errBody = encode e }
          Right v ->
            return v

在这段非常简短的代码中,发生了很多事情:

  • 我们正在教 servant-server 包如何处理出现在 serve (Proxy :: Proxy (Body foo :> bar)) server 类型解析中的新数据类型。

  • 我们已经从 the v0.8.1 release of ReqBody 中提取了大部分代码。

  • 我们正在向处理请求主体的管道添加一个函数。

  • 其中,我们尝试解码到Bodya参数。失败时,我们吐出一个 JSON blob 和一个 HTTP 400。

  • 为了简洁起见,我们在这里完全忽略 content-type headers。

这是 JSON blob 的类型:

newtype BodyError = BodyError String
instance ToJSON BodyError where
  toJSON (BodyError b) = object ["error" .= b]

大多数这种机制都是 servant-server 的内部机制,而且文档不足且相当脆弱。例如,我已经看到代码在 master 分支上出现分歧,并且我的 addBodyCheck 的元数已更改。

虽然 Servant 项目还很年轻,而且雄心勃勃,但我不得不说,这个解决方案的美观和稳健性绝对是平庸的。

测试这个

我们需要一个主模块:

{-# LANGUAGE DataKinds             #-}
{-# LANGUAGE TypeOperators         #-}
module Main where
import Data.Proxy (Proxy(..))
import Network.Wai.Handler.Warp (run)
import Servant.API
import Servant.Server

import Body

type API = Body [Int] :> Post '[JSON] [Int]

server :: Server API
server = pure

main :: IO ()
main = do
  putStrLn "running on port 8000"
  run 8000 (serve (Proxy :: Proxy API) server)

还有一个shell:

~ ❯❯❯ curl -i -XPOST 'http://localhost:8000/'
HTTP/1.1 400 Bad Request
Transfer-Encoding: chunked
Date: Fri, 20 Jan 2017 01:18:57 GMT
Server: Warp/3.2.9

{"error":"Error in $: not enough input"}%

~ ❯❯❯ curl -id 'hey' -XPOST 'http://localhost:8000/'
HTTP/1.1 400 Bad Request
Transfer-Encoding: chunked
Date: Fri, 20 Jan 2017 01:19:02 GMT
Server: Warp/3.2.9

{"error":"Error in $: Failed reading: not a valid json value"}%

~ ❯❯❯ curl -id '[1,2,3]' -XPOST 'http://localhost:8000/'
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Date: Fri, 20 Jan 2017 01:19:07 GMT
Server: Warp/3.2.9
Content-Type: application/json

[1,2,3]%

Ta-da!

您应该能够 运行 在 LTS-7.16 上运行所有这些代码。

我们学到了什么

(1) Servant 和 Haskell 很有趣。

(2) 当涉及到您在 API 中指定的类型时,Servant 的类型类机制允许一种 plug-and-play。我们可以把ReqBody拿出来换成我们自己的;在我工作时做的一个项目中,我们甚至用我们自己的动词替换了仆人动词(GETPOST、...)。我们写了新的内容类型,我们甚至用 ReqBody 做了类似的事情,就像你在这里看到的那样。

(3) GHC 编译器的非凡能力是,我们可以在 compile-time 期间解构类型,以安全且逻辑合理的方式影响 运行 时间行为。我们可以在 type-level 处表达一棵 API 路由树,然后使用类型类实例遍历它们,使用类型族累积服务器类型,这是构建 well-typed 的一种非常优雅的方式网络服务。

目前我只是在中间件中处理这个。我做了类似以下的事情:

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE FlexibleContexts #-}

module Lib.ErrorResponse where

import Data.Text.Lazy.Encoding (decodeUtf8)
import Data.ByteString.Lazy (toStrict)
import Blaze.ByteString.Builder (toLazyByteString)
import Blaze.ByteString.Builder.ByteString (fromByteString)
import Network.Wai
import Network.Wai.Internal
import Network.HTTP.Types
import Data.Text
import Data.Aeson
import qualified Data.Text.Lazy as TL

customError :: Application -> Application
customError = modifyResponse responseModifier

responseModifier :: Response -> Response
responseModifier r
  | responseStatus r == status400 && not (isCustomMessage r "Bad Request") =
    buildResponse status400 "Bad Request" (customErrorBody r "BadRequest") 400
  | responseStatus r == status403 =
    buildResponse status403 "Forbidden" "Forbidden" 400
  | responseStatus r == status404 =
    buildResponse status404 "Not Found" "Not Found" 404
  | responseStatus r == status405 =
    buildResponse status405 "Method Not Allowed" "Method Not Allowed" 405
  | otherwise = r

customErrorBody :: Response -> Text -> Text
customErrorBody (ResponseBuilder _ _ b) _ = TL.toStrict $ decodeUtf8 $ toLazyByteString b
customErrorBody (ResponseRaw _ res) e = customErrorBody res e
customErrorBody _ e = e

isCustomMessage :: Response -> Text -> Bool
isCustomMessage r m = "{\"error\":" `isInfixOf` customErrorBody r m

buildResponse :: Status -> Text -> Text -> Int -> Response
buildResponse st err msg cde = responseBuilder st
  [("Content-Type", "application/json")]
  (fromByteString . toStrict . encode $ object
    [ "error" .= err
    , "message" .= msg
    , "statusCode" .= cde
    ]
  )

然后我就可以像使用任何其他中间件一样使用了:

run 8000 . customError $ serve api server

受@codedmart的启发我也使用了一个中间件,但是它没有构造json,它只是在出错的时候改变响应的内容类型,并保留原来的错误信息。

startApp :: IO ()
startApp = run 8081 . (modifyResponse errorHeadersToJson) $ serve api server

errorHeadersToJson :: Response -> Response
errorHeadersToJson r
  | responseStatus r == status200 = r
  | otherwise = mapResponseHeaders text2json r

text2json :: ResponseHeaders -> ResponseHeaders
text2json h = Map.assocs (Map.fromList [("Content-Type", "application/json")] `Map.union` Map.fromList h)

json 是预先用一个覆盖 Servant throwError 函数的函数构建的。

data ServerError = ServerError
  { statusCode        :: Int
  , error :: String
  , message  :: String
  } deriving (Eq, Show)

$(deriveJSON defaultOptions ''ServerError)

throwJsonError :: ServantErr -> String -> Servant.Handler b
throwJsonError err "" = throwError $ err { errBody = encode $ ServerError (errHTTPCode err) ("Server error"::String) (show $ errBody err) }
throwJsonError err message = throwError $ err { errBody = encode $ ServerError (errHTTPCode err) ("Server error"::String) message }

然后我可以使用自定义消息抛出任何错误,它将作为具有正确内容类型的 json 提供:

throwJsonError err500 "Oh no !"