Provide proper JSON details for errors

Refines HTTP codes for server vs client problems

Fixes #92
Fixes #40
This commit is contained in:
Joe Nelson
2014-12-21 19:34:21 -08:00
parent 07b039370d
commit f689ce684a
9 changed files with 142 additions and 95 deletions
+34 -15
View File
@@ -1,5 +1,5 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
module App (app, sqlErrHandler, isSqlError) where module App (app, sqlError, isSqlError) where
import Control.Monad (join) import Control.Monad (join)
import Control.Arrow ((***)) import Control.Arrow ((***))
@@ -28,10 +28,12 @@ import Data.Monoid
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Postgres as H import qualified Hasql.Postgres as H
import Auth
import PgQuery import PgQuery
import RangeQuery import RangeQuery
import PgStructure import PgStructure
import Auth import PgError
import Text.Parsec hiding (Column)
app :: BL.ByteString -> Request -> H.Tx H.Postgres s Response app :: BL.ByteString -> Request -> H.Tx H.Postgres s Response
app reqBody req = app reqBody req =
@@ -88,7 +90,7 @@ app reqBody req =
case user of case user of
Nothing -> return $ responseLBS status400 [jsonH] $ Nothing -> return $ responseLBS status400 [jsonH] $
encode . object $ [("error", String "Failed to parse user.")] encode . object $ [("message", String "Failed to parse user.")]
Just u -> do Just u -> do
_ <- addUser (cs $ userId u) _ <- addUser (cs $ userId u)
(cs $ userPass u) (cs $ userRole u) (cs $ userPass u) (cs $ userRole u)
@@ -166,15 +168,32 @@ app reqBody req =
isSqlError :: H.Error -> Maybe H.Error isSqlError :: H.Error -> Maybe H.Error
isSqlError (H.ErroneousResult x) = Just $ H.ErroneousResult x isSqlError = Just
isSqlError _ = Nothing
sqlError :: H.Error -> Response
sqlError err =
let inside = case err of
H.CantConnect t -> t
H.ConnectionLost t -> t
H.ErroneousResult t -> t
H.UnexpectedResult t -> t
H.UnparsableTemplate t -> t
H.UnparsableRow t -> t
H.NotInTransaction -> "An operation which requires a"
<> "database transaction was executed without one"
p = parse message
"{\"message\": \"failed to parse exception\" }" inside in
either
(\nope ->
responseLBS status500
[(hContentType, "application/json")]
(cs . show $ nope))
(\msg ->
responseLBS (httpStatus msg)
[(hContentType, "application/json")]
(encode msg))
p
sqlErrHandler :: H.Error -> IO Response
sqlErrHandler (H.ErroneousResult err) =
return $ if "42P01" `isInfixOf` err
then responseLBS status404 [] ""
else responseLBS status400 [] (cs err)
sqlErrHandler _ = error "just for debugging"
rangeStatus :: Int -> Int -> Int -> Status rangeStatus :: Int -> Int -> Int -> Status
rangeStatus from to total rangeStatus from to total
@@ -208,19 +227,19 @@ jsonH = (hContentType, "application/json")
handleJsonObj :: BL.ByteString -> (Object -> H.Tx H.Postgres s Response) handleJsonObj :: BL.ByteString -> (Object -> H.Tx H.Postgres s Response)
-> H.Tx H.Postgres s Response -> H.Tx H.Postgres s Response
handleJsonObj reqBody handler = do handleJsonObj reqBody handler = do
let parse = eitherDecode reqBody let p = eitherDecode reqBody
case parse of case p of
Left err -> Left err ->
return $ responseLBS status400 [jsonH] jErr return $ responseLBS status400 [jsonH] jErr
where where
jErr = encode . object $ jErr = encode . object $
[("error", String $ "Failed to parse JSON payload. " <> cs err)] [("message", String $ "Failed to parse JSON payload. " <> cs err)]
Right (Object o) -> handler o Right (Object o) -> handler o
Right _ -> Right _ ->
return $ responseLBS status400 [jsonH] jErr return $ responseLBS status400 [jsonH] jErr
where where
jErr = encode . object $ jErr = encode . object $
[("error", String "Expecting a JSON object")] [("message", String "Expecting a JSON object")]
data TableOptions = TableOptions { data TableOptions = TableOptions {
tblOptcolumns :: [Column] tblOptcolumns :: [Column]
+2 -2
View File
@@ -46,7 +46,7 @@ main = do
$ defaultSettings $ defaultSettings
middle = middle =
(if configSecure conf then redirectInsecure else id) (if configSecure conf then redirectInsecure else id)
. gzip def . cors corsPolicy . clientErrors . gzip def . cors corsPolicy
. staticPolicy (only [("favicon.ico", "static/favicon.ico")]) . staticPolicy (only [("favicon.ico", "static/favicon.ico")])
anonRole = cs $ configAnonRole conf anonRole = cs $ configAnonRole conf
@@ -56,7 +56,7 @@ main = do
respond =<< catchJust isSqlError respond =<< catchJust isSqlError
(unlift $ H.tx Nothing (unlift $ H.tx Nothing
$ authenticated anonRole (app body) req) $ authenticated anonRole (app body) req)
sqlErrHandler (return . sqlError)
where where
describe = progDesc "create a REST API to an existing Postgres database" describe = progDesc "create a REST API to an existing Postgres database"
+1 -28
View File
@@ -3,7 +3,6 @@
module Middleware where module Middleware where
--import Data.Aeson ((.=), toJSON, ToJSON, object, encode)
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import Data.Monoid (mconcat) import Data.Monoid (mconcat)
import Data.Text import Data.Text
@@ -12,9 +11,8 @@ import Data.Text
import qualified Hasql as H import qualified Hasql as H
import qualified Hasql.Postgres as H import qualified Hasql.Postgres as H
import Data.String.Conversions(cs) import Data.String.Conversions(cs)
import Control.Exception (catchJust)
import Network.HTTP.Types.Header (hLocation, hContentType, hAuthorization) import Network.HTTP.Types.Header (hLocation, hAuthorization)
import Network.HTTP.Types (RequestHeaders) import Network.HTTP.Types (RequestHeaders)
import Network.HTTP.Types.Status (status400, status401, status301) import Network.HTTP.Types.Status (status400, status401, status301)
import Network.Wai (Application, requestHeaders, responseLBS, rawPathInfo, import Network.Wai (Application, requestHeaders, responseLBS, rawPathInfo,
@@ -24,8 +22,6 @@ import Network.URI (URI(..), parseURI)
import Auth (LoginAttempt(..), signInRole, setRole, resetRole) import Auth (LoginAttempt(..), signInRole, setRole, resetRole)
import Codec.Binary.Base64.String (decode) import Codec.Binary.Base64.String (decode)
import Debug.Trace
-- data Environment = Test | Production deriving (Eq) -- data Environment = Test | Production deriving (Eq)
-- safeAction :: Request -> Bool -- safeAction :: Request -> Bool
@@ -69,29 +65,6 @@ authenticated anon app req = do
resetRole resetRole
return res return res
-- instance ToJSON SqlError where
-- toJSON t = object [
-- "error" .= object [
-- "message" .= (cs $ sqlErrorMsg t :: String)
-- , "detail" .= (cs $ sqlErrorDetail t :: String)
-- , "state" .= (cs $ sqlState t :: String)
-- , "hint" .= (cs $ sqlErrorHint t :: String)
-- ]
-- ]
clientErrors :: Application -> Application
clientErrors app req respond =
catchJust isPgException (app req respond) $ \err ->
respond $
responseLBS status400 [(hContentType, "application/json")] (cs $ show err)
-- if sqlState err == "42P01"
-- then responseLBS status404 [] ""
-- else responseLBS status400 [(hContentType, "application/json")] (encode err)
where
isPgException :: H.Error -> Maybe H.Error
isPgException x = Just (traceShow x x)
redirectInsecure :: Application -> Application redirectInsecure :: Application -> Application
redirectInsecure app req respond = do redirectInsecure app req respond = do
+60 -12
View File
@@ -1,38 +1,86 @@
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
module PgError (Message(..), parseMessage) where module PgError (Message(..), message, httpStatus) where
import Text.Parsec import Text.Parsec
import Text.Parsec.Text import Text.Parsec.Text
import qualified Data.Map as M import qualified Data.Map as M
import Data.Text import Text.Regex.TDFA.Text ()
import Data.Text hiding (drop, concat, head)
import Data.Aeson
import Data.Maybe
import Control.Monad (void)
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.CaseInsensitive (CI, mk) import Data.CaseInsensitive (CI, mk)
import Network.HTTP.Types.Status
data Message = Message { data Message = Message {
msgStatus :: Maybe Text msgStatus :: Maybe Text
, msgCode :: Maybe Text , msgCode :: Text
, msgText :: Maybe Text , msgText :: Maybe Text
, msgHint :: Maybe Text , msgHint :: Maybe Text
} deriving (Show) } deriving (Show, Eq)
parseMessage :: Parser Message message :: Parser Message
parseMessage = do message = do
ps <- sepBy valPair (char ';' >> optional (char ' ')) ps <- sepBy valPair (char ';')
let m = M.fromList ps let m = M.fromList ps
return $ Message return $ Message
(M.lookup "status" m) (M.lookup "status" m)
(M.lookup "code" m) (fromMaybe "" $ M.lookup "code" m)
(M.lookup "message" m) (M.lookup "message" m)
(M.lookup "hint" m) (M.lookup "hint" m)
valPair :: Parser (CI Text, Text) valPair :: Parser (CI Text, Text)
valPair = do valPair = do
_ <- spaces
name <- many1 letter name <- many1 letter
_ <- char ':' _ <- char ':'
optional $ char ' ' spaces
_ <- char '"' _ <- many $ char '"'
val <- many1 (noneOf "\"") val <- manyTill anyChar $
_ <- char '"' try
(void $ many (char '"') >> (
(void . lookAhead $ (char ';'))
<|> ((optional $ char '.') >> eof)
))
return (mk (cs name), cs val) return (mk (cs name), cs val)
instance ToJSON Message where
toJSON t = object [
"message" .= msgText t
, "code" .= msgCode t
, "status" .= msgStatus t
, "hint" .= msgHint t
]
httpStatus :: Message -> Status
httpStatus m =
let code = cs $ msgCode m :: String in
case code of
'0' : '8' : _ -> status503 -- pg connection err
'0' : '9' : _ -> status500 -- triggered action exception
'0' : 'L' : _ -> status403 -- invalid grantor
'0' : 'P' : _ -> status403 -- invalid role specification
'2' : '5' : _ -> status500 -- invalid tx state
'2' : '8' : _ -> status403 -- invalid auth specification
'2' : 'D' : _ -> status500 -- invalid tx termination
'3' : '8' : _ -> status500 -- external routine exception
'3' : '9' : _ -> status500 -- external routine invocation
'3' : 'B' : _ -> status500 -- savepoint exception
'4' : '0' : _ -> status500 -- tx rollback
'5' : '3' : _ -> status503 -- insufficient resources
'5' : '4' : _ -> status413 -- too complex
'5' : '5' : _ -> status500 -- obj not on prereq state
'5' : '7' : _ -> status500 -- operator intervention
'5' : '8' : _ -> status500 -- system error
'F' : '0' : _ -> status500 -- conf file error
'H' : 'V' : _ -> status500 -- foreign data wrapper error
'P' : '0' : _ -> status500 -- PL/pgSQL Error
'X' : 'X' : _ -> status500 -- internal Error
"42P01" -> status404 -- undefined table
"42501" -> status404 -- insufficient privilege
_ -> status400
+1 -1
View File
@@ -14,7 +14,7 @@ spec :: Spec
spec = before resetDb $ around withApp $ spec = before resetDb $ around withApp $
describe "authorization" $ do describe "authorization" $ do
it "hides tables that anonymous does not own" $ it "hides tables that anonymous does not own" $
get "/authors_only" `shouldRespondWith` 400 -- TODO: should be 404 get "/authors_only" `shouldRespondWith` 404
it "indicates login failure" $ do it "indicates login failure" $ do
let auth = authHeader "postgrest_test_author" "fakefake" let auth = authHeader "postgrest_test_author" "fakefake"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
+1 -1
View File
@@ -73,7 +73,7 @@ spec = before resetDb $ around withApp $ do
it "fails with 400 and error" $ it "fails with 400 and error" $
post "/simple_pk" "}{ x = 2" post "/simple_pk" "}{ x = 2"
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just [json| {"error":"Failed to parse JSON payload. Failed reading: satisfy"} |] matchBody = Just [json| {"message":"Failed to parse JSON payload. Failed reading: satisfy"} |]
, matchStatus = 400 , matchStatus = 400
, matchHeaders = [] , matchHeaders = []
} }
+2 -2
View File
@@ -24,7 +24,7 @@ import qualified Data.ByteString.Char8 as BS
import Network.Wai.Middleware.Cors (cors) import Network.Wai.Middleware.Cors (cors)
import System.Process (readProcess) import System.Process (readProcess)
import App (app, sqlErrHandler, isSqlError) import App (app, sqlError, isSqlError)
import Config (AppConfig(..), corsPolicy) import Config (AppConfig(..), corsPolicy)
import Middleware import Middleware
-- import Auth (addUser) -- import Auth (addUser)
@@ -52,7 +52,7 @@ withApp perform =
resp =<< catchJust isSqlError resp =<< catchJust isSqlError
(unlift $ H.tx Nothing (unlift $ H.tx Nothing
$ authenticated anonRole (app body) req) $ authenticated anonRole (app body) req)
sqlErrHandler (return . sqlError)
where middle = cors corsPolicy where middle = cors corsPolicy
+41
View File
@@ -0,0 +1,41 @@
module Unit.ErrorsSpec where
import Test.Hspec
import Text.Parsec
import PgError
import Data.Either (rights)
spec :: Spec
spec =
describe "Parsing Hasql errors" $ do
it "can handle status and code" $
let p = parse message "" "Status: \"foo\"; Code: \"abc\"." in
rights [p] `shouldBe` [
Message (Just "foo") "abc" Nothing Nothing
]
it "can handle weird redundant quotes in status" $
let p = parse message "" "Status: \"\"foo\"\"; Code: \"abc\"." in
rights [p] `shouldBe` [
Message (Just "foo") "abc" Nothing Nothing
]
it "can handle text and code" $
let p = parse message "" "Message: \"foo\"; Code: \"abc\"." in
rights [p] `shouldBe` [
Message Nothing "abc" (Just "foo") Nothing
]
it "can handle status, text and code" $
let p = parse message "" "Status: \"hi\"; Message: \"foo\"; Code: \"abc\"." in
rights [p] `shouldBe` [
Message (Just "hi") "abc" (Just "foo") Nothing
]
it "can handle unescaped quotes in message" $
let p = parse message "" "Status: \"hi\"; Message: \"unknown \"foo\"!\"; Code: \"abc\"." in
rights [p] `shouldBe` [
Message (Just "hi") "abc" (Just "unknown \"foo\"!") Nothing
]
it "can handle periods in message" $
let p = parse message "" "Message: \"unknown \"foo\".bar\"; Code: \"42P01\"." in
rights [p] `shouldBe` [
Message Nothing "42P01" (Just "unknown \"foo\".bar") Nothing
]
-34
View File
@@ -1,34 +0,0 @@
module Unit.ErrorsSpec where
import Test.Hspec
import Database.HDBC (runRaw, quickQuery, fromSql, SqlError)
import SpecHelper (dbWithSchema)
import Middleware (withSavepoint, Environment(..))
import PgQuery (insert)
import Types(SqlRow(..))
import Control.Exception(catch)
import Control.Monad(void)
import Network.Wai (defaultRequest, responseLBS)
import Network.HTTP.Types.Status (ok200)
spec :: Spec
spec = let
dbErrApp conn _ res = do
_ <- insert "1" "items" (SqlRow []) conn
runRaw conn "select 1/0"
_ <- insert "1" "items" (SqlRow []) conn
res $ responseLBS ok200 [("Content-Type", "application/json")] "{}"
in around dbWithSchema $
describe "withSavepoint" $
it "allows partial rollback of request" $ \c -> do
let app = withSavepoint Test dbErrApp c
[[beforeCount]] <- quickQuery c "select count(*) from \"1\".items" []
runRaw c "set role postgrest_anonymous"
_ <- insert "1" "items" (SqlRow []) c
catch (void $ app defaultRequest (const undefined) ) $
\e -> let _ = (e::SqlError) in do
_ <- insert "1" "items" (SqlRow []) c
[[afterCount]] <- quickQuery c "select count(*) from \"1\".items" []
fromSql afterCount `shouldBe` (fromSql beforeCount::Int) + 2