Small refactor around error responses and more tests (#1282)

* Fix #880, Clean and consolidate error responses
* Fix #1285, Abort on fatal errors
* Add / Detail tests
This commit is contained in:
Xavier Francisco
2019-05-22 09:30:33 -05:00
committed by Steve Chávez
parent e2d917f7b9
commit 1cf54e6575
17 changed files with 500 additions and 284 deletions
+1
View File
@@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #1239, Add support for resource embedding on materialized views - @vitorbaptista - #1239, Add support for resource embedding on materialized views - @vitorbaptista
- #1264, Add support for bulk RPC call - @steve-chavez - #1264, Add support for bulk RPC call - @steve-chavez
- #1278, Add db-pool-timeout config option - @qu4tro - #1278, Add db-pool-timeout config option - @qu4tro
- #1285, Abort on wrong database password - @qu4tro
### Fixed ### Fixed
+42 -33
View File
@@ -7,9 +7,9 @@ import PostgREST.App (postgrest)
import PostgREST.Config (AppConfig (..), configPoolTimeout', import PostgREST.Config (AppConfig (..), configPoolTimeout',
prettyVersion, readOptions) prettyVersion, readOptions)
import PostgREST.DbStructure (getDbStructure, getPgVersion) import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.Error (encodeError) import PostgREST.Error (errorPayload, checkIsFatal, PgError(PgError))
import PostgREST.OpenAPI (isMalformedProxyUri) import PostgREST.OpenAPI (isMalformedProxyUri)
import PostgREST.Types (DbStructure, Schema, PgVersion(..), minimumPgVersion) import PostgREST.Types (DbStructure, Schema, PgVersion(..), minimumPgVersion, ConnectionStatus(..))
import Protolude hiding (hPutStrLn, replace) import Protolude hiding (hPutStrLn, replace)
@@ -28,7 +28,6 @@ import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Text.IO (hPutStrLn, readFile) import Data.Text.IO (hPutStrLn, readFile)
import Data.Time.Clock (getCurrentTime) import Data.Time.Clock (getCurrentTime)
import qualified Hasql.Pool as P import qualified Hasql.Pool as P
import qualified Hasql.Session as H
import qualified Hasql.Transaction.Sessions as HT import qualified Hasql.Transaction.Sessions as HT
import Network.Wai.Handler.Warp (defaultSettings, import Network.Wai.Handler.Warp (defaultSettings,
runSettings, setHost, runSettings, setHost,
@@ -74,25 +73,24 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
work = do work = do
atomicWriteIORef refDbStructure Nothing atomicWriteIORef refDbStructure Nothing
putStrLn ("Attempting to connect to the database..." :: Text) putStrLn ("Attempting to connect to the database..." :: Text)
connected <- connectingSucceeded pool connected <- connectionStatus pool
when connected $ do case connected of
result <- P.use pool $ do FatalConnectionError reason -> hPutStrLn stderr reason
actualPgVersion <- getPgVersion >> killThread mainTid -- Fatal error when connecting
unless (actualPgVersion >= minimumPgVersion) $ liftIO $ do NotConnected -> return () -- Unreachable
hPutStrLn stderr Connected actualPgVersion -> do -- Procede with initialization
("Cannot run in this PostgreSQL version, PostgREST needs at least " result <- P.use pool $ do
<> pgvName minimumPgVersion) dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schema actualPgVersion
killThread mainTid liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schema actualPgVersion case result of
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure Left e -> do
case result of putStrLn ("Failed to query the database. Retrying." :: Text)
Left e -> do hPutStrLn stderr . toS . errorPayload $ PgError False e
putStrLn ("Failed to query the database. Retrying." :: Text) work
hPutStrLn stderr (toS $ encodeError e)
work Right _ -> do
Right _ -> do atomicWriteIORef refIsWorkerOn False
atomicWriteIORef refIsWorkerOn False putStrLn ("Connection successful" :: Text)
putStrLn ("Connection successful" :: Text)
{-| {-|
Used by 'connectionWorker' to check if the provided db-uri lets Used by 'connectionWorker' to check if the provided db-uri lets
@@ -103,26 +101,37 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
The connection tries are capped, but if the connection times out no error is The connection tries are capped, but if the connection times out no error is
thrown, just 'False' is returned. thrown, just 'False' is returned.
-} -}
connectingSucceeded :: P.Pool -> IO Bool connectionStatus :: P.Pool -> IO ConnectionStatus
connectingSucceeded pool = connectionStatus pool =
retrying (capDelay 32000000 $ exponentialBackoff 1000000) retrying (capDelay 32000000 $ exponentialBackoff 1000000)
shouldRetry shouldRetry
(const $ P.release pool >> isConnectionSuccessful) (const $ P.release pool >> getConnectionStatus)
where where
isConnectionSuccessful :: IO Bool getConnectionStatus :: IO ConnectionStatus
isConnectionSuccessful = do getConnectionStatus = do
testConn <- P.use pool $ H.sql "SELECT 1" pgVersion <- P.use pool getPgVersion
case testConn of case pgVersion of
Left e -> hPutStrLn stderr (toS $ encodeError e) >> pure False Left e -> do
_ -> pure True let err = PgError False e
shouldRetry :: RetryStatus -> Bool -> IO Bool hPutStrLn stderr . toS $ errorPayload err
case checkIsFatal err of
Just reason -> return $ FatalConnectionError reason
Nothing -> return NotConnected
Right version ->
if version < minimumPgVersion
then return . FatalConnectionError $ "Cannot run in this PostgreSQL version, PostgREST needs at least " <> pgvName minimumPgVersion
else return . Connected $ version
shouldRetry :: RetryStatus -> ConnectionStatus -> IO Bool
shouldRetry rs isConnSucc = do shouldRetry rs isConnSucc = do
delay <- pure $ fromMaybe 0 (rsPreviousDelay rs) `div` 1000000 delay <- pure $ fromMaybe 0 (rsPreviousDelay rs) `div` 1000000
itShould <- pure $ not isConnSucc itShould <- pure $ NotConnected == isConnSucc
when itShould $ when itShould $
putStrLn $ "Attempting to reconnect to the database in " <> (show delay::Text) <> " seconds..." putStrLn $ "Attempting to reconnect to the database in " <> (show delay::Text) <> " seconds..."
return itShould return itShould
{-| {-|
This is where everything starts. This is where everything starts.
-} -}
+12 -9
View File
@@ -1,16 +1,18 @@
{-# LANGUAGE LambdaCase #-}
{-| {-|
Module : PostgREST.ApiRequest Module : PostgREST.ApiRequest
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest. Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
-} -}
module PostgREST.ApiRequest ( ApiRequest(..) {-# LANGUAGE LambdaCase #-}
, ContentType(..)
, Action(..) module PostgREST.ApiRequest (
, Target(..) ApiRequest(..)
, PreferRepresentation (..) , ContentType(..)
, mutuallyAgreeable , Action(..)
, userApiRequest , Target(..)
) where , PreferRepresentation (..)
, mutuallyAgreeable
, userApiRequest
) where
import Protolude import Protolude
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
@@ -35,6 +37,7 @@ import Network.Wai.Parse (parseHttpAccept)
import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset) import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset)
import Data.Ranged.Boundaries import Data.Ranged.Boundaries
import PostgREST.Types import PostgREST.Types
import PostgREST.Error (ApiRequestError(..))
import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange) import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange)
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
import Web.Cookie (parseCookiesText) import Web.Cookie (parseCookiesText)
+20 -23
View File
@@ -11,7 +11,6 @@ import Data.Aeson as JSON
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import Data.Maybe import Data.Maybe
import Data.IORef (IORef, readIORef) import Data.IORef (IORef, readIORef)
import Data.Text (intercalate)
import Data.Time.Clock (UTCTime) import Data.Time.Clock (UTCTime)
import qualified Data.Set as S import qualified Data.Set as S
@@ -42,10 +41,8 @@ import PostgREST.DbRequestBuilder( readRequest
, mutateRequest , mutateRequest
, fieldNames , fieldNames
) )
import PostgREST.Error ( simpleError, pgError import PostgREST.Error ( SimpleError(..), PgError(..)
, apiRequestError , errorResponseFor
, singularityError, binaryFieldError
, connectionLostError, gucHeadersError
) )
import PostgREST.RangeQuery (allRange, rangeOffset) import PostgREST.RangeQuery (allRange, rangeOffset)
import PostgREST.Middleware import PostgREST.Middleware
@@ -73,14 +70,14 @@ postgrest conf refDbStructure pool getTime worker =
body <- strictRequestBody req body <- strictRequestBody req
maybeDbStructure <- readIORef refDbStructure maybeDbStructure <- readIORef refDbStructure
case maybeDbStructure of case maybeDbStructure of
Nothing -> respond connectionLostError Nothing -> respond . errorResponseFor $ ConnectionLostError
Just dbStructure -> do Just dbStructure -> do
response <- do response <- do
-- Need to parse ?columns early because findProc needs it to solve overloaded functions -- Need to parse ?columns early because findProc needs it to solve overloaded functions
let apiReq = userApiRequest (configSchema conf) req body let apiReq = userApiRequest (configSchema conf) req body
apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq) apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
case apiReqCols of case apiReqCols of
Left err -> return $ apiRequestError err Left err -> return . errorResponseFor $ err
Right (apiRequest, maybeCols) -> do Right (apiRequest, maybeCols) -> do
eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) time (rightToMaybe $ configRoleClaimKey conf) eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) time (rightToMaybe $ configRoleClaimKey conf)
let authed = containsRole eClaims let authed = containsRole eClaims
@@ -94,7 +91,7 @@ postgrest conf refDbStructure pool getTime worker =
handleReq = runWithClaims conf eClaims (app dbStructure proc cols conf) apiRequest handleReq = runWithClaims conf eClaims (app dbStructure proc cols conf) apiRequest
txMode = transactionMode proc (iAction apiRequest) txMode = transactionMode proc (iAction apiRequest)
response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq
return $ either (pgError authed) identity response return $ either (errorResponseFor . PgError authed) identity response
when (responseStatus response == status503) worker when (responseStatus response == status503) worker
respond response respond response
@@ -133,7 +130,7 @@ app dbStructure proc cols conf apiRequest =
canonical = iCanonicalQS apiRequest canonical = iCanonicalQS apiRequest
return $ return $
if contentType == CTSingularJSON && queryTotal /= 1 if contentType == CTSingularJSON && queryTotal /= 1
then singularityError (toInteger queryTotal) then errorResponseFor . singularityError $ queryTotal
else responseLBS status else responseLBS status
[toHeader contentType, contentRange, [toHeader contentType, contentRange,
("Content-Location", ("Content-Location",
@@ -170,7 +167,7 @@ app dbStructure proc cols conf apiRequest =
&& iPreferRepresentation apiRequest == Full && iPreferRepresentation apiRequest == Full
then do then do
HT.condemn HT.condemn
return $ singularityError (toInteger queryTotal) return . errorResponseFor . singularityError $ queryTotal
else else
return . responseLBS status201 headers $ return . responseLBS status201 headers $
if iPreferRepresentation apiRequest == Full if iPreferRepresentation apiRequest == Full
@@ -199,7 +196,7 @@ app dbStructure proc cols conf apiRequest =
case (contentType, iPreferRepresentation apiRequest) of case (contentType, iPreferRepresentation apiRequest) of
(CTSingularJSON, Full) (CTSingularJSON, Full)
| queryTotal == 1 -> return $ responseLBS status fullHeaders (toS body) | queryTotal == 1 -> return $ responseLBS status fullHeaders (toS body)
| otherwise -> HT.condemn >> return (singularityError queryTotal) | otherwise -> HT.condemn >> (return . errorResponseFor . singularityError) queryTotal
(_, Full) -> (_, Full) ->
return $ responseLBS status fullHeaders (toS body) return $ responseLBS status fullHeaders (toS body)
@@ -217,11 +214,11 @@ app dbStructure proc cols conf apiRequest =
PJObject -> True PJObject -> True
colNames = colName <$> tableCols dbStructure tSchema tName colNames = colName <$> tableCols dbStructure tSchema tName
if topLevelRange /= allRange if topLevelRange /= allRange
then return $ simpleError status400 [] "Range header and limit/offset querystring parameters are not allowed for PUT" then return . errorResponseFor $ PutRangeNotAllowedError
else if not isSingle else if not isSingle
then return $ simpleError status400 [] "PUT payload must contain a single row" then return . errorResponseFor $ PutSingletonError
else if S.fromList colNames /= pjKeys else if S.fromList colNames /= pjKeys
then return $ simpleError status400 [] "You must specify all columns in the payload when using PUT" then return . errorResponseFor $ PutPayloadIncompleteError
else do else do
row <- H.statement (toS pjRaw) $ row <- H.statement (toS pjRaw) $
createWriteStatement sq mq (contentType == CTSingularJSON) False createWriteStatement sq mq (contentType == CTSingularJSON) False
@@ -233,7 +230,7 @@ app dbStructure proc cols conf apiRequest =
if queryTotal /= 1 if queryTotal /= 1
then do then do
HT.condemn HT.condemn
return $ simpleError status400 [] "Payload values do not match URL in primary key column(s)" return . errorResponseFor $ PutMatchingPkError
else else
return $ if iPreferRepresentation apiRequest == Full return $ if iPreferRepresentation apiRequest == Full
then responseLBS status200 [toHeader contentType] (toS body) then responseLBS status200 [toHeader contentType] (toS body)
@@ -256,7 +253,7 @@ app dbStructure proc cols conf apiRequest =
&& iPreferRepresentation apiRequest == Full && iPreferRepresentation apiRequest == Full
then do then do
HT.condemn HT.condemn
return $ singularityError (toInteger queryTotal) return . errorResponseFor . singularityError $ queryTotal
else else
return $ if iPreferRepresentation apiRequest == Full return $ if iPreferRepresentation apiRequest == Full
then responseLBS status200 [toHeader contentType, r] (toS body) then responseLBS status200 [toHeader contentType, r] (toS body)
@@ -293,12 +290,12 @@ app dbStructure proc cols conf apiRequest =
(status, contentRange) = rangeHeader queryTotal tableTotal (status, contentRange) = rangeHeader queryTotal tableTotal
decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader] decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader]
case decodedHeaders of case decodedHeaders of
Left _ -> return gucHeadersError Left _ -> return . errorResponseFor $ GucHeadersError
Right hs -> Right hs ->
if singular && queryTotal /= 1 if singular && queryTotal /= 1
then do then do
HT.condemn HT.condemn
return $ singularityError (toInteger queryTotal) return . errorResponseFor . singularityError $ queryTotal
else return $ responseLBS status ([toHeader contentType, contentRange] ++ toHeaders hs) (toS body) else return $ responseLBS status ([toHeader contentType, contentRange] ++ toHeaders hs) (toS body)
(ActionInspect, TargetRoot, Nothing) -> do (ActionInspect, TargetRoot, Nothing) -> do
@@ -355,17 +352,14 @@ responseContentTypeOrError accepts action = serves contentTypesForRequest accept
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
serves sProduces cAccepts = serves sProduces cAccepts =
case mutuallyAgreeable sProduces cAccepts of case mutuallyAgreeable sProduces cAccepts of
Nothing -> do Nothing -> Left . errorResponseFor . ContentTypeError . map toMime $ cAccepts
let failed = intercalate ", " $ map (toS . toMime) cAccepts
Left $ simpleError status415 [] $
"None of these Content-Types are available: " <> failed
Just ct -> Right ct Just ct -> Right ct
binaryField :: ContentType -> [FieldName] -> Either Response (Maybe FieldName) binaryField :: ContentType -> [FieldName] -> Either Response (Maybe FieldName)
binaryField CTOctetStream fldNames = binaryField CTOctetStream fldNames =
if length fldNames == 1 && fieldName /= Just "*" if length fldNames == 1 && fieldName /= Just "*"
then Right fieldName then Right fieldName
else Left binaryFieldError else Left . errorResponseFor $ BinaryFieldError
where where
fieldName = headMay fldNames fieldName = headMay fldNames
binaryField _ _ = Right Nothing binaryField _ _ = Right Nothing
@@ -399,3 +393,6 @@ contentRangeH lower upper total =
extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount
extractQueryResult = fromMaybe (Nothing, 0, [], "") extractQueryResult = fromMaybe (Nothing, 0, [], "")
singularityError :: (Integral a) => a -> SimpleError
singularityError = SingularityError . toInteger
+5 -4
View File
@@ -1,5 +1,3 @@
{-# LANGUAGE LambdaCase, TemplateHaskell #-}
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
{-| {-|
Module : PostgREST.Config Module : PostgREST.Config
Description : Manages PostgREST configuration options. Description : Manages PostgREST configuration options.
@@ -14,6 +12,9 @@ turned in configurable behaviour if needed.
Other hardcoded options such as the minimum version number also belong here. Other hardcoded options such as the minimum version number also belong here.
-} -}
{-# LANGUAGE LambdaCase, TemplateHaskell #-}
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
module PostgREST.Config ( prettyVersion module PostgREST.Config ( prettyVersion
, docsVersion , docsVersion
, readOptions , readOptions
@@ -50,8 +51,8 @@ import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
import Options.Applicative hiding (str) import Options.Applicative hiding (str)
import Paths_postgrest (version) import Paths_postgrest (version)
import PostgREST.Parsers (pRoleClaimKey) import PostgREST.Parsers (pRoleClaimKey)
import PostgREST.Types (ApiRequestError(..), import PostgREST.Types (JSPath, JSPathExp(..))
JSPath, JSPathExp(..)) import PostgREST.Error (ApiRequestError(..))
import Protolude hiding (hPutStrLn, take, import Protolude hiding (hPutStrLn, take,
intercalate, (<>)) intercalate, (<>))
import System.IO (hPrint) import System.IO (hPrint)
+13 -12
View File
@@ -11,6 +11,7 @@ A query tree is built in case of resource embedding. By inferring the relationsh
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
module PostgREST.DbRequestBuilder ( module PostgREST.DbRequestBuilder (
readRequest readRequest
, mutateRequest , mutateRequest
@@ -31,26 +32,26 @@ import Data.Either.Combinators (mapLeft)
import Network.Wai import Network.Wai
import Data.Foldable (foldr1) import Data.Foldable (foldr1)
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import PostgREST.ApiRequest ( ApiRequest(..) import PostgREST.ApiRequest ( ApiRequest(..)
, PreferRepresentation(..) , PreferRepresentation(..)
, Action(..), Target(..) , Action(..), Target(..)
, PreferRepresentation (..) , PreferRepresentation (..)
) )
import PostgREST.Error (apiRequestError) import PostgREST.Error (ApiRequestError(..), errorResponseFor)
import PostgREST.Parsers import PostgREST.Parsers
import PostgREST.RangeQuery (NonnegRange, restrictRange, allRange) import PostgREST.RangeQuery (NonnegRange, restrictRange, allRange)
import PostgREST.Types import PostgREST.Types
import Protolude hiding (from) import Protolude hiding (from)
import Text.Regex.TDFA ((=~)) import Text.Regex.TDFA ((=~))
import Unsafe (unsafeHead) import Unsafe (unsafeHead)
readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest
readRequest maxRows allRels proc apiRequest = readRequest maxRows allRels proc apiRequest =
mapLeft apiRequestError $ mapLeft errorResponseFor $
treeRestrictRange maxRows =<< treeRestrictRange maxRows =<<
augumentRequestWithJoin schema relations =<< augumentRequestWithJoin schema relations =<<
addFiltersOrdersRanges apiRequest <*> addFiltersOrdersRanges apiRequest <*>
@@ -319,7 +320,7 @@ addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
mutateRequest :: ApiRequest -> TableName -> S.Set FieldName -> [FieldName] -> [FieldName] -> Either Response MutateRequest mutateRequest :: ApiRequest -> TableName -> S.Set FieldName -> [FieldName] -> [FieldName] -> Either Response MutateRequest
mutateRequest apiRequest tName cols pkCols fldNames = mapLeft apiRequestError $ mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
case action of case action of
ActionCreate -> Right $ Insert tName cols ((,) <$> iPreferResolution apiRequest <*> Just pkCols) [] returnings ActionCreate -> Right $ Insert tName cols ((,) <$> iPreferResolution apiRequest <*> Just pkCols) [] returnings
ActionUpdate -> Update tName cols <$> combinedLogic <*> pure returnings ActionUpdate -> Update tName cols <$> combinedLogic <*> pure returnings
+180 -125
View File
@@ -7,14 +7,12 @@ Description : PostgREST error HTTP responses
{-# LANGUAGE TypeSynonymInstances #-} {-# LANGUAGE TypeSynonymInstances #-}
module PostgREST.Error ( module PostgREST.Error (
apiRequestError errorResponseFor
, pgError , ApiRequestError(..)
, simpleError , PgError(..)
, singularityError , SimpleError(..)
, binaryFieldError , errorPayload
, connectionLostError , checkIsFatal
, encodeError
, gucHeadersError
) where ) where
import Protolude import Protolude
@@ -29,73 +27,41 @@ import Network.Wai (Response, responseLBS)
import PostgREST.Types import PostgREST.Types
import Text.Read (readMaybe) import Text.Read (readMaybe)
apiRequestError :: ApiRequestError -> Response
apiRequestError err =
errorResponse status
[toHeader CTApplicationJSON] err
where
status =
case err of
ActionInappropriate -> HT.status405
UnsupportedVerb -> HT.status405
InvalidBody _ -> HT.status400
ParseRequestError _ _ -> HT.status400
NoRelationBetween _ _ -> HT.status400
InvalidRange -> HT.status416
UnknownRelation -> HT.status404
InvalidFilters -> HT.status405
simpleError :: HT.Status -> [Header] -> Text -> Response class (JSON.ToJSON a) => PgrstError a where
simpleError status hdrs message = status :: a -> HT.Status
errorResponse status (toHeader CTApplicationJSON : hdrs) $ headers :: a -> [Header]
JSON.object ["message" .= message]
errorResponse :: JSON.ToJSON a => HT.Status -> [Header] -> a -> Response errorPayload :: a -> LByteString
errorResponse status hdrs e = errorPayload = JSON.encode
responseLBS status hdrs $ encodeError e
pgError :: Bool -> P.UsageError -> Response errorResponseFor :: a -> Response
pgError authed e = errorResponseFor err = responseLBS (status err) (headers err) $ errorPayload err
let status = httpStatus authed e
jsonType = toHeader CTApplicationJSON
wwwAuth = ("WWW-Authenticate", "Bearer")
hdrs = if status == HT.status401
then [jsonType, wwwAuth]
else [jsonType] in
responseLBS status hdrs (encodeError e)
singularityError :: (Integral a, Show a) => a -> Response
singularityError numRows =
responseLBS HT.status406
[toHeader CTSingularJSON]
$ toS . formatGeneralError
"JSON object requested, multiple (or no) rows returned"
$ unwords
[ "Results contain", show numRows, "rows,"
, toS (toMime CTSingularJSON), "requires 1 row"
]
where
formatGeneralError :: Text -> Text -> Text
formatGeneralError message details = toS . JSON.encode $
JSON.object ["message" .= message, "details" .= details]
binaryFieldError :: Response
binaryFieldError =
simpleError HT.status406 [] (toS (toMime CTOctetStream) <>
" requested but a single column was not selected")
gucHeadersError :: Response data ApiRequestError
gucHeadersError = = ActionInappropriate
simpleError HT.status500 [] | InvalidRange
"response.headers guc must be a JSON array composed of objects with a single key and a string value" | InvalidBody ByteString
| ParseRequestError Text Text
| NoRelationBetween Text Text
| InvalidFilters
| UnknownRelation -- Unreachable?
| UnsupportedVerb -- Unreachable?
deriving (Show, Eq)
connectionLostError :: Response instance PgrstError ApiRequestError where
connectionLostError = status InvalidRange = HT.status416
simpleError HT.status503 [] "Database connection lost, retrying the connection." status InvalidFilters = HT.status405
status (InvalidBody _) = HT.status400
status UnsupportedVerb = HT.status405
status UnknownRelation = HT.status404
status ActionInappropriate = HT.status405
status (ParseRequestError _ _) = HT.status400
status (NoRelationBetween _ _) = HT.status400
encodeError :: JSON.ToJSON a => a -> LByteString headers _ = [toHeader CTApplicationJSON]
encodeError = JSON.encode
instance JSON.ToJSON ApiRequestError where instance JSON.ToJSON ApiRequestError where
toJSON (ParseRequestError message details) = JSON.object [ toJSON (ParseRequestError message details) = JSON.object [
@@ -115,9 +81,24 @@ instance JSON.ToJSON ApiRequestError where
toJSON InvalidFilters = JSON.object [ toJSON InvalidFilters = JSON.object [
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)] "message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
data PgError = PgError Authenticated P.UsageError
type Authenticated = Bool
instance PgrstError PgError where
status (PgError authed usageError) = pgErrorStatus authed usageError
headers err =
if status err == HT.status401
then [toHeader CTApplicationJSON, ("WWW-Authenticate", "Bearer") :: Header]
else [toHeader CTApplicationJSON]
instance JSON.ToJSON PgError where
toJSON (PgError _ usageError) = JSON.toJSON usageError
instance JSON.ToJSON P.UsageError where instance JSON.ToJSON P.UsageError where
toJSON (P.ConnectionError e) = JSON.object [ toJSON (P.ConnectionError e) = JSON.object [
"code" .= ("" :: Text), "code" .= ("" :: Text),
"message" .= ("Database connection error" :: Text), "message" .= ("Database connection error" :: Text),
"details" .= (toS $ fromMaybe "" e :: Text)] "details" .= (toS $ fromMaybe "" e :: Text)]
toJSON (P.SessionError e) = JSON.toJSON e -- H.Error toJSON (P.SessionError e) = JSON.toJSON e -- H.Error
@@ -127,69 +108,143 @@ instance JSON.ToJSON H.QueryError where
instance JSON.ToJSON H.CommandError where instance JSON.ToJSON H.CommandError where
toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of
'P':'T':_ -> 'P':'T':_ -> JSON.object [
JSON.object [ "details" .= (fmap toS d :: Maybe Text),
"details" .= (fmap toS d::Maybe Text), "hint" .= (fmap toS h :: Maybe Text)]
"hint" .= (fmap toS h::Maybe Text)]
_ -> _ -> JSON.object [
JSON.object [ "code" .= (toS c :: Text),
"code" .= (toS c::Text), "message" .= (toS m :: Text),
"message" .= (toS m::Text), "details" .= (fmap toS d :: Maybe Text),
"details" .= (fmap toS d::Maybe Text), "hint" .= (fmap toS h :: Maybe Text)]
"hint" .= (fmap toS h::Maybe Text)]
toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [ toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [
"message" .= (m::Text)] "message" .= (m :: Text)]
toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [ toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [
"message" .= ("Row error: end of input"::Text), "message" .= ("Row error: end of input" :: Text),
"details" .= "details" .= ("Attempt to parse more columns than there are in the result" :: Text),
("Attempt to parse more columns than there are in the result"::Text), "hint" .= (("Row number " <> show i) :: Text)]
"details" .= (("Row number " <> show i)::Text)]
toJSON (H.ResultError (H.RowError i H.UnexpectedNull)) = JSON.object [ toJSON (H.ResultError (H.RowError i H.UnexpectedNull)) = JSON.object [
"message" .= ("Row error: unexpected null"::Text), "message" .= ("Row error: unexpected null" :: Text),
"details" .= ("Attempt to parse a NULL as some value."::Text), "details" .= ("Attempt to parse a NULL as some value." :: Text),
"details" .= (("Row number " <> show i)::Text)] "hint" .= (("Row number " <> show i) :: Text)]
toJSON (H.ResultError (H.RowError i (H.ValueError d))) = JSON.object [ toJSON (H.ResultError (H.RowError i (H.ValueError d))) = JSON.object [
"message" .= ("Row error: Wrong value parser used"::Text), "message" .= ("Row error: Wrong value parser used" :: Text),
"details" .= d, "details" .= d,
"details" .= (("Row number " <> show i)::Text)] "hint" .= (("Row number " <> show i) :: Text)]
toJSON (H.ResultError (H.UnexpectedAmountOfRows i)) = JSON.object [ toJSON (H.ResultError (H.UnexpectedAmountOfRows i)) = JSON.object [
"message" .= ("Unexpected amount of rows"::Text), "message" .= ("Unexpected amount of rows" :: Text),
"details" .= i] "details" .= i]
toJSON (H.ClientError d) = JSON.object [ toJSON (H.ClientError d) = JSON.object [
"message" .= ("Database client error"::Text), "message" .= ("Database client error" :: Text),
"details" .= (fmap toS d::Maybe Text)] "details" .= (fmap toS d :: Maybe Text)]
httpStatus :: Bool -> P.UsageError -> HT.Status pgErrorStatus :: Bool -> P.UsageError -> HT.Status
httpStatus _ (P.ConnectionError _) = HT.status503 pgErrorStatus _ (P.ConnectionError _) = HT.status503
httpStatus authed (P.SessionError (H.QueryError _ _ (H.ResultError (H.ServerError c m _ _)))) = pgErrorStatus _ (P.SessionError (H.QueryError _ _ (H.ClientError _))) = HT.status503
case toS c of pgErrorStatus authed (P.SessionError (H.QueryError _ _ (H.ResultError rError))) =
'0':'8':_ -> HT.status503 -- pg connection err case rError of
'0':'9':_ -> HT.status500 -- triggered action exception (H.ServerError c m _ _) ->
'0':'L':_ -> HT.status403 -- invalid grantor case toS c of
'0':'P':_ -> HT.status403 -- invalid role specification '0':'8':_ -> HT.status503 -- pg connection err
"23503" -> HT.status409 -- foreign_key_violation '0':'9':_ -> HT.status500 -- triggered action exception
"23505" -> HT.status409 -- unique_violation '0':'L':_ -> HT.status403 -- invalid grantor
'2':'5':_ -> HT.status500 -- invalid tx state '0':'P':_ -> HT.status403 -- invalid role specification
'2':'8':_ -> HT.status403 -- invalid auth specification "23503" -> HT.status409 -- foreign_key_violation
'2':'D':_ -> HT.status500 -- invalid tx termination "23505" -> HT.status409 -- unique_violation
'3':'8':_ -> HT.status500 -- external routine exception '2':'5':_ -> HT.status500 -- invalid tx state
'3':'9':_ -> HT.status500 -- external routine invocation '2':'8':_ -> HT.status403 -- invalid auth specification
'3':'B':_ -> HT.status500 -- savepoint exception '2':'D':_ -> HT.status500 -- invalid tx termination
'4':'0':_ -> HT.status500 -- tx rollback '3':'8':_ -> HT.status500 -- external routine exception
'5':'3':_ -> HT.status503 -- insufficient resources '3':'9':_ -> HT.status500 -- external routine invocation
'5':'4':_ -> HT.status413 -- too complex '3':'B':_ -> HT.status500 -- savepoint exception
'5':'5':_ -> HT.status500 -- obj not on prereq state '4':'0':_ -> HT.status500 -- tx rollback
'5':'7':_ -> HT.status500 -- operator intervention '5':'3':_ -> HT.status503 -- insufficient resources
'5':'8':_ -> HT.status500 -- system error '5':'4':_ -> HT.status413 -- too complex
'F':'0':_ -> HT.status500 -- conf file error '5':'5':_ -> HT.status500 -- obj not on prereq state
'H':'V':_ -> HT.status500 -- foreign data wrapper error '5':'7':_ -> HT.status500 -- operator intervention
"P0001" -> HT.status400 -- default code for "raise" '5':'8':_ -> HT.status500 -- system error
'P':'0':_ -> HT.status500 -- PL/pgSQL Error 'F':'0':_ -> HT.status500 -- conf file error
'X':'X':_ -> HT.status500 -- internal Error 'H':'V':_ -> HT.status500 -- foreign data wrapper error
"42883" -> HT.status404 -- undefined function "P0001" -> HT.status400 -- default code for "raise"
"42P01" -> HT.status404 -- undefined table 'P':'0':_ -> HT.status500 -- PL/pgSQL Error
"42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege 'X':'X':_ -> HT.status500 -- internal Error
'P':'T':n -> fromMaybe HT.status500 (HT.mkStatus <$> readMaybe n <*> pure m) "42883" -> HT.status404 -- undefined function
_ -> HT.status400 "42P01" -> HT.status404 -- undefined table
httpStatus _ (P.SessionError (H.QueryError _ _ (H.ResultError _))) = HT.status500 "42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege
httpStatus _ (P.SessionError (H.QueryError _ _ (H.ClientError _))) = HT.status503 'P':'T':n -> fromMaybe HT.status500 (HT.mkStatus <$> readMaybe n <*> pure m)
_ -> HT.status400
_ -> HT.status500
checkIsFatal :: PgError -> Maybe Text
checkIsFatal (PgError _ (P.ConnectionError e))
| isAuthFailureMessage = Just $ toS failureMessage
| otherwise = Nothing
where isAuthFailureMessage = "FATAL: password authentication failed" `isPrefixOf` toS failureMessage
failureMessage = fromMaybe "" e
checkIsFatal _ = Nothing
data SimpleError
= GucHeadersError
| BinaryFieldError
| ConnectionLostError
| PutSingletonError
| PutMatchingPkError
| PutRangeNotAllowedError
| PutPayloadIncompleteError
| JwtTokenMissing
| JwtTokenInvalid Text
| SingularityError Integer
| ContentTypeError [ByteString]
deriving (Show, Eq)
instance PgrstError SimpleError where
status GucHeadersError = HT.status500
status BinaryFieldError = HT.status406
status ConnectionLostError = HT.status503
status PutSingletonError = HT.status400
status PutMatchingPkError = HT.status400
status PutRangeNotAllowedError = HT.status400
status PutPayloadIncompleteError = HT.status400
status JwtTokenMissing = HT.status500
status (JwtTokenInvalid _) = HT.unauthorized401
status (SingularityError _) = HT.status406
status (ContentTypeError _) = HT.status415
headers (SingularityError _) = [toHeader CTSingularJSON]
headers (JwtTokenInvalid m) = [toHeader CTApplicationJSON, invalidTokenHeader m]
headers _ = [toHeader CTApplicationJSON]
instance JSON.ToJSON SimpleError where
toJSON GucHeadersError = JSON.object [
"message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text)]
toJSON BinaryFieldError = JSON.object [
"message" .= ((toS (toMime CTOctetStream) <> " requested but a single column was not selected") :: Text)]
toJSON ConnectionLostError = JSON.object [
"message" .= ("Database connection lost, retrying the connection." :: Text)]
toJSON PutSingletonError = JSON.object [
"message" .= ("PUT payload must contain a single row" :: Text)]
toJSON PutRangeNotAllowedError = JSON.object [
"message" .= ("Range header and limit/offset querystring parameters are not allowed for PUT" :: Text)]
toJSON PutPayloadIncompleteError = JSON.object [
"message" .= ("You must specify all columns in the payload when using PUT" :: Text)]
toJSON PutMatchingPkError = JSON.object [
"message" .= ("Payload values do not match URL in primary key column(s)" :: Text)]
toJSON (ContentTypeError cts) = JSON.object [
"message" .= ("None of these Content-Types are available: " <> (toS . intercalate ", " . map toS) cts :: Text)]
toJSON (SingularityError n) = JSON.object [
"message" .= ("JSON object requested, multiple (or no) rows returned" :: Text),
"details" .= unwords ["Results contain", show n, "rows,", toS (toMime CTSingularJSON), "requires 1 row"]]
toJSON JwtTokenMissing = JSON.object [
"message" .= ("Server lacks JWT secret" :: Text)]
toJSON (JwtTokenInvalid message) = JSON.object [
"message" .= (message :: Text)]
invalidTokenHeader :: Text -> Header
invalidTokenHeader m =
("WWW-Authenticate", "Bearer error=\"invalid_token\", " <> "error_description=" <> show m)
+5 -14
View File
@@ -13,7 +13,6 @@ import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import qualified Hasql.Transaction as H import qualified Hasql.Transaction as H
import Network.HTTP.Types.Status (unauthorized401, status500)
import Network.Wai (Application, Response) import Network.Wai (Application, Response)
import Network.Wai.Middleware.Cors (cors) import Network.Wai.Middleware.Cors (cors)
import Network.Wai.Middleware.Gzip (def, gzip) import Network.Wai.Middleware.Gzip (def, gzip)
@@ -22,7 +21,7 @@ import Network.Wai.Middleware.Static (only, staticPolicy)
import PostgREST.ApiRequest (ApiRequest(..)) import PostgREST.ApiRequest (ApiRequest(..))
import PostgREST.Auth (JWTAttempt(..)) import PostgREST.Auth (JWTAttempt(..))
import PostgREST.Config (AppConfig (..), corsPolicy) import PostgREST.Config (AppConfig (..), corsPolicy)
import PostgREST.Error (simpleError) import PostgREST.Error (errorResponseFor, SimpleError(JwtTokenMissing, JwtTokenInvalid))
import PostgREST.QueryBuilder (unquoted, pgFmtSetLocal, pgFmtSetLocalSearchPath) import PostgREST.QueryBuilder (unquoted, pgFmtSetLocal, pgFmtSetLocalSearchPath)
import Protolude import Protolude
@@ -32,10 +31,10 @@ runWithClaims :: AppConfig -> JWTAttempt ->
ApiRequest -> H.Transaction Response ApiRequest -> H.Transaction Response
runWithClaims conf eClaims app req = runWithClaims conf eClaims app req =
case eClaims of case eClaims of
JWTInvalid JWTExpired -> return $ unauthed "JWT expired" JWTMissingSecret -> return . errorResponseFor $ JwtTokenMissing
JWTInvalid e -> return $ unauthed $ show e JWTInvalid JWTExpired -> return . errorResponseFor . JwtTokenInvalid $ "JWT expired"
JWTMissingSecret -> return $ simpleError status500 [] "Server lacks JWT secret" JWTInvalid e -> return . errorResponseFor . JwtTokenInvalid . show $ e
JWTClaims claims -> do JWTClaims claims -> do
H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql ++ appSettingsSql H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql ++ appSettingsSql
mapM_ H.sql customReqCheck mapM_ H.sql customReqCheck
app req app req
@@ -51,14 +50,6 @@ runWithClaims conf eClaims app req =
claimsWithRole = M.union claims (M.singleton "role" anon) claimsWithRole = M.union claims (M.singleton "role" anon)
anon = JSON.String . toS $ configAnonRole conf anon = JSON.String . toS $ configAnonRole conf
customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf
where
unauthed message = simpleError
unauthorized401
[( "WWW-Authenticate"
, "Bearer error=\"invalid_token\", " <>
"error_description=" <> show message
)]
message
defaultMiddle :: Application -> Application defaultMiddle :: Application -> Application
defaultMiddle = defaultMiddle =
+1
View File
@@ -17,6 +17,7 @@ import qualified Data.Set as S
import Data.Tree import Data.Tree
import Data.Either.Combinators (mapLeft) import Data.Either.Combinators (mapLeft)
import PostgREST.RangeQuery (NonnegRange) import PostgREST.RangeQuery (NonnegRange)
import PostgREST.Error (ApiRequestError(ParseRequestError))
import PostgREST.Types import PostgREST.Types
import Text.ParserCombinators.Parsec hiding (many, (<|>)) import Text.ParserCombinators.Parsec hiding (many, (<|>))
import Text.Parsec.Error import Text.Parsec.Error
+11 -10
View File
@@ -3,7 +3,9 @@ Module : PostgREST.Types
Description : PostgREST common types and functions used by the rest of the modules Description : PostgREST common types and functions used by the rest of the modules
-} -}
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.Types where module PostgREST.Types where
import Protolude import Protolude
import qualified GHC.Show import qualified GHC.Show
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
@@ -34,16 +36,6 @@ toMime CTOctetStream = "application/octet-stream"
toMime CTAny = "*/*" toMime CTAny = "*/*"
toMime (CTOther ct) = ct toMime (CTOther ct) = ct
data ApiRequestError = ActionInappropriate
| InvalidBody ByteString
| InvalidRange
| ParseRequestError Text Text
| UnknownRelation
| NoRelationBetween Text Text
| UnsupportedVerb
| InvalidFilters
deriving (Show, Eq)
data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq
instance Show PreferResolution where instance Show PreferResolution where
show MergeDuplicates = "resolution=merge-duplicates" show MergeDuplicates = "resolution=merge-duplicates"
@@ -413,3 +405,12 @@ sourceCTEName = "pg_source"
type JSPath = [JSPathExp] type JSPath = [JSPathExp]
-- | jspath expression, e.g. .property, .property[0] or ."property-dash" -- | jspath expression, e.g. .property, .property[0] or ."property-dash"
data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show) data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)
-- | Current database connection status data ConnectionStatus
data ConnectionStatus
= NotConnected
| Connected PgVersion
| FatalConnectionError Text
deriving (Eq, Show)
+48 -22
View File
@@ -53,9 +53,22 @@ spec actualPgVersion = do
context "non uniform json array" $ do context "non uniform json array" $ do
it "rejects json array that isn't exclusivily composed of objects" $ it "rejects json array that isn't exclusivily composed of objects" $
post "/articles" [json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |] `shouldRespondWith` 400 post "/articles"
[json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |]
`shouldRespondWith`
[json| {"message":"All object keys must match"} |]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
it "rejects json array that has objects with different keys" $ it "rejects json array that has objects with different keys" $
post "/articles" [json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |] `shouldRespondWith` 400 post "/articles"
[json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |]
`shouldRespondWith`
[json| {"message":"All object keys must match"} |]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "requesting full representation" $ do context "requesting full representation" $ do
it "includes related data after insert" $ it "includes related data after insert" $
@@ -105,11 +118,13 @@ spec actualPgVersion = do
incNullableStr record `shouldBe` Nothing incNullableStr record `shouldBe` Nothing
context "into a table with simple pk" $ context "into a table with simple pk" $
it "fails with 400 and error" $ do it "fails with 400 and error" $
p <- post "/simple_pk" [json| { "extra":"foo"} |] post "/simple_pk" [json| { "extra":"foo"} |]
liftIO $ do `shouldRespondWith`
simpleStatus p `shouldBe` badRequest400 [json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" violates not-null constraint"}|]
isErrorFormat (simpleBody p) `shouldBe` True { matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "into a table with no pk" $ do context "into a table with no pk" $ do
it "succeeds with 201 and a link including all fields" $ do it "succeeds with 201 and a link including all fields" $ do
@@ -183,11 +198,13 @@ spec actualPgVersion = do
lookup hLocation (simpleHeaders p) `shouldBe` Nothing lookup hLocation (simpleHeaders p) `shouldBe` Nothing
context "with invalid json payload" $ context "with invalid json payload" $
it "fails with 400 and error" $ do it "fails with 400 and error" $
p <- post "/simple_pk" "}{ x = 2" post "/simple_pk" "}{ x = 2"
liftIO $ do `shouldRespondWith`
simpleStatus p `shouldBe` badRequest400 [json|{"message":"Error in $: Failed reading: not a valid json value"}|]
isErrorFormat (simpleBody p) `shouldBe` True { matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "with valid json payload" $ context "with valid json payload" $
it "succeeds and returns 201 created" $ it "succeeds and returns 201 created" $
@@ -195,7 +212,12 @@ spec actualPgVersion = do
context "attempting to insert a row with the same primary key" $ context "attempting to insert a row with the same primary key" $
it "fails returning a 409 Conflict" $ it "fails returning a 409 Conflict" $
post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |] `shouldRespondWith` 409 post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |]
`shouldRespondWith`
[json|{"hint":null,"details":"Key (k)=(k1) already exists.","code":"23505","message":"duplicate key value violates unique constraint \"contacts_pkey\""}|]
{ matchStatus = 409
, matchHeaders = [matchContentTypeJson]
}
context "attempting to insert a row with conflicting unique constraint" $ context "attempting to insert a row with conflicting unique constraint" $
it "fails returning a 409 Conflict" $ it "fails returning a 409 Conflict" $
@@ -295,7 +317,12 @@ spec actualPgVersion = do
post "/articles?columns=" post "/articles?columns="
[json|[ [json|[
{"id": 204, "body": "yyy"}, {"id": 204, "body": "yyy"},
{"id": 205, "body": "zzz"}]|] `shouldRespondWith` 400 {"id": 205, "body": "zzz"}]|]
`shouldRespondWith`
[json| {"details":"unexpected end of input expecting field name (* or [a..z0..9_])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)"} |]
{ matchStatus = 400
, matchHeaders = []
}
it "disallows array elements that are not json objects" $ it "disallows array elements that are not json objects" $
post "/articles?columns=id,body" post "/articles?columns=id,body"
@@ -314,7 +341,6 @@ spec actualPgVersion = do
} }
describe "CSV insert" $ do describe "CSV insert" $ do
context "disparate csv types" $ context "disparate csv types" $
it "succeeds with multipart response" $ do it "succeeds with multipart response" $ do
pendingWith "Decide on what to do with CSV insert" pendingWith "Decide on what to do with CSV insert"
@@ -361,11 +387,13 @@ spec actualPgVersion = do
} }
context "with wrong number of columns" $ context "with wrong number of columns" $
it "fails for too few" $ do it "fails for too few" $
p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz" request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
liftIO $ do `shouldRespondWith`
simpleStatus p `shouldBe` badRequest400 [json|{"message":"All lines must have same number of fields"}|]
isErrorFormat (simpleBody p) `shouldBe` True { matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "with unicode values" $ context "with unicode values" $
it "succeeds and returns usable location header" $ do it "succeeds and returns usable location header" $ do
@@ -381,9 +409,7 @@ spec actualPgVersion = do
r <- get location r <- get location
liftIO $ simpleBody r `shouldBe` "["<>payload<>"]" liftIO $ simpleBody r `shouldBe` "["<>payload<>"]"
describe "Patching record" $ do describe "Patching record" $ do
context "to unknown uri" $ context "to unknown uri" $
it "indicates no table found by returning 404" $ it "indicates no table found by returning 404" $
request methodPatch "/fake" [] request methodPatch "/fake" []
+6 -1
View File
@@ -3,6 +3,7 @@ module Feature.NoJwtSpec where
-- {{{ Imports -- {{{ Imports
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Network.HTTP.Types
import SpecHelper import SpecHelper
@@ -18,7 +19,11 @@ spec = describe "server started without JWT secret" $ do
it "responds with error on attempted auth" $ do it "responds with error on attempted auth" $ do
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA" let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 500 `shouldRespondWith`
[json|{"message":"Server lacks JWT secret"}|]
{ matchStatus = 500
, matchHeaders = [ matchContentTypeJson ]
}
it "behaves normally when user does not attempt auth" $ it "behaves normally when user does not attempt auth" $
request methodGet "/items" [] "" request methodGet "/items" [] ""
+24 -4
View File
@@ -34,10 +34,30 @@ spec =
"X-Test-2" <:> "key1=val1"]} "X-Test-2" <:> "key1=val1"]}
it "fails when setting headers with wrong json structure" $ do it "fails when setting headers with wrong json structure" $ do
get "/rpc/bad_guc_headers_1" `shouldRespondWith` 500 get "/rpc/bad_guc_headers_1"
get "/rpc/bad_guc_headers_2" `shouldRespondWith` 500 `shouldRespondWith`
get "/rpc/bad_guc_headers_3" `shouldRespondWith` 500 [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
post "/rpc/bad_guc_headers_1" [json|{}|] `shouldRespondWith` 500 { matchStatus = 500
, matchHeaders = [ matchContentTypeJson ]
}
get "/rpc/bad_guc_headers_2"
`shouldRespondWith`
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
{ matchStatus = 500
, matchHeaders = [ matchContentTypeJson ]
}
get "/rpc/bad_guc_headers_3"
`shouldRespondWith`
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
{ matchStatus = 500
, matchHeaders = [ matchContentTypeJson ]
}
post "/rpc/bad_guc_headers_1" [json|{}|]
`shouldRespondWith`
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
{ matchStatus = 500
, matchHeaders = [ matchContentTypeJson ]
}
it "can set the same http header twice" $ it "can set the same http header twice" $
get "/rpc/set_cookie_twice" get "/rpc/set_cookie_twice"
+27 -6
View File
@@ -181,7 +181,6 @@ spec = do
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
describe "Shaping response with select parameter" $ do describe "Shaping response with select parameter" $ do
it "selectStar works in absense of parameter" $ it "selectStar works in absense of parameter" $
get "/complex_items?id=eq.3" `shouldRespondWith` get "/complex_items?id=eq.3" `shouldRespondWith`
[str|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|] [str|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|]
@@ -455,6 +454,12 @@ spec = do
{"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |] {"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "fails with an unknown relation" $
get "/message?select=id,sender:person.space(name)&id=lt.4" `shouldRespondWith`
[json|{"message":"Could not find foreign keys between these entities, No relation found between message and person"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson] }
it "works with a parent view relation" $ it "works with a parent view relation" $
get "/message?select=id,body,sender:person_detail%2Bsender(name,sent),recipient:person_detail%2Brecipient(name,received)&id=lt.4" `shouldRespondWith` get "/message?select=id,body,sender:person_detail%2Bsender(name,sent),recipient:person_detail%2Brecipient(name,received)&id=lt.4" `shouldRespondWith`
[json| [json|
@@ -790,7 +795,11 @@ spec = do
it "should respond an unknown accept type with 415" $ it "should respond an unknown accept type with 415" $
request methodGet "/simple_pk" request methodGet "/simple_pk"
(acceptHdrs "text/unknowntype") "" (acceptHdrs "text/unknowntype") ""
`shouldRespondWith` 415 `shouldRespondWith`
[json|{"message":"None of these Content-Types are available: text/unknowntype"}|]
{ matchStatus = 415
, matchHeaders = [matchContentTypeJson]
}
it "should respond correctly to */* in accept header" $ it "should respond correctly to */* in accept header" $
request methodGet "/simple_pk" request methodGet "/simple_pk"
@@ -846,7 +855,6 @@ spec = do
respHeaders `shouldSatisfy` matchHeader respHeaders `shouldSatisfy` matchHeader
"Content-Location" "/simple_pk" "Content-Location" "/simple_pk"
describe "weird requests" $ do describe "weird requests" $ do
it "can query as normal" $ do it "can query as normal" $ do
get "/Escap3e;" `shouldRespondWith` get "/Escap3e;" `shouldRespondWith`
@@ -897,7 +905,11 @@ spec = do
it "fails if a single column is not selected" $ do it "fails if a single column is not selected" $ do
request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") "" request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` 406 `shouldRespondWith`
[json| {"message":"application/octet-stream requested but a single column was not selected"} |]
{ matchStatus = 406
, matchHeaders = [matchContentTypeJson]
}
request methodGet "/images?select=*&name=eq.A.png" (acceptHdrs "application/octet-stream") "" request methodGet "/images?select=*&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` 406 `shouldRespondWith` 406
request methodGet "/images?name=eq.A.png" (acceptHdrs "application/octet-stream") "" request methodGet "/images?name=eq.A.png" (acceptHdrs "application/octet-stream") ""
@@ -929,7 +941,11 @@ spec = do
it "fails if a single column is not selected" $ it "fails if a single column is not selected" $
request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") "" request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` 406 `shouldRespondWith`
[json| {"message":"application/octet-stream requested but a single column was not selected"} |]
{ matchStatus = 406
, matchHeaders = [matchContentTypeJson]
}
describe "HTTP request env vars" $ do describe "HTTP request env vars" $ do
it "custom header is set" $ it "custom header is set" $
@@ -1047,7 +1063,12 @@ spec = do
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
it "only returns an empty result set if the in value is empty" $ it "only returns an empty result set if the in value is empty" $
get "/items_with_different_col_types?int_data=in.( ,3,4)" `shouldRespondWith` 400 get "/items_with_different_col_types?int_data=in.( ,3,4)"
`shouldRespondWith`
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
describe "Embedding when column name = table name" $ do describe "Embedding when column name = table name" $ do
it "works with child embeds" $ it "works with child embeds" $
+30 -3
View File
@@ -38,7 +38,6 @@ spec = do
{ matchHeaders = ["Content-Range" <:> "0-14/*"] } { matchHeaders = ["Content-Range" <:> "0-14/*"] }
context "with range headers" $ do context "with range headers" $ do
context "of acceptable range" $ do context "of acceptable range" $ do
it "succeeds with partial content" $ do it "succeeds with partial content" $ do
r <- request methodPost "/rpc/getitemrange" r <- request methodPost "/rpc/getitemrange"
@@ -156,7 +155,6 @@ spec = do
, matchHeaders = ["Content-Range" <:> "0-0/*"] , matchHeaders = ["Content-Range" <:> "0-0/*"]
} }
it "limit and offset works on first level" $ it "limit and offset works on first level" $
get "/items?select=id&order=id.asc&limit=3&offset=2" get "/items?select=id&order=id.asc&limit=3&offset=2"
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|] `shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
@@ -164,8 +162,37 @@ spec = do
, matchHeaders = ["Content-Range" <:> "2-4/*"] , matchHeaders = ["Content-Range" <:> "2-4/*"]
} }
context "with range headers" $ do it "succeeds if offset equals 0 as a no-op" $
get "/items?select=id&offset=0"
`shouldRespondWith`
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-14/*"]
}
it "succeeds if offset is negative as a no-op" $
get "/items?select=id&offset=-4"
`shouldRespondWith`
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
{ matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-14/*"]
}
it "fails if limit equals 0" $
get "/items?select=id&limit=0"
`shouldRespondWith` [json|{"message":"HTTP Range error"}|]
{ matchStatus = 416
, matchHeaders = [matchContentTypeJson]
}
it "fails if limit is negative" $
get "/items?select=id&limit=-1"
`shouldRespondWith` [json|{"message":"HTTP Range error"}|]
{ matchStatus = 416
, matchHeaders = [matchContentTypeJson]
}
context "with range headers" $ do
context "of acceptable range" $ do context "of acceptable range" $ do
it "succeeds with partial content" $ do it "succeeds with partial content" $ do
r <- request methodGet "/items" r <- request methodGet "/items"
+11 -2
View File
@@ -253,7 +253,11 @@ spec actualPgVersion =
context "unsupported verbs" $ do context "unsupported verbs" $ do
it "DELETE fails" $ it "DELETE fails" $
request methodDelete "/rpc/sayhello" [] "" request methodDelete "/rpc/sayhello" [] ""
`shouldRespondWith` 405 `shouldRespondWith`
[json|{"message":"Bad Request"}|]
{ matchStatus = 405
, matchHeaders = [matchContentTypeJson]
}
it "PATCH fails" $ it "PATCH fails" $
request methodPatch "/rpc/sayhello" [] "" request methodPatch "/rpc/sayhello" [] ""
`shouldRespondWith` 405 `shouldRespondWith` 405
@@ -319,7 +323,12 @@ spec actualPgVersion =
} }
it "defaults to status 500 if RAISE code is PT not followed by a number" $ it "defaults to status 500 if RAISE code is PT not followed by a number" $
get "/rpc/raise_bad_pt" `shouldRespondWith` 500 get "/rpc/raise_bad_pt"
`shouldRespondWith`
[json|{"hint": null, "details": null}|]
{ matchStatus = 500
, matchHeaders = [ matchContentTypeJson ]
}
context "expects a single json object" $ do context "expects a single json object" $ do
it "does not expand posted json into parameters" $ it "does not expand posted json into parameters" $
+64 -16
View File
@@ -110,48 +110,96 @@ spec =
context "Restrictions" $ do context "Restrictions" $ do
it "fails if Range is specified" $ it "fails if Range is specified" $
request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")] request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]
[str| [ { "name": "Javascript", "rank": 1 } ]|] `shouldRespondWith` 400 [str| [ { "name": "Javascript", "rank": 1 } ]|]
`shouldRespondWith`
[json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
it "fails if limit is specified" $ it "fails if limit is specified" $
put "/tiobe_pls?name=eq.Javascript&limit=1" put "/tiobe_pls?name=eq.Javascript&limit=1"
[str| [ { "name": "Javascript", "rank": 1 } ]|] `shouldRespondWith` 400 [str| [ { "name": "Javascript", "rank": 1 } ]|]
`shouldRespondWith`
[json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
it "fails if offset is specified" $ it "fails if offset is specified" $
put "/tiobe_pls?name=eq.Javascript&offset=1" put "/tiobe_pls?name=eq.Javascript&offset=1"
[str| [ { "name": "Javascript", "rank": 1 } ]|] `shouldRespondWith` 400 [str| [ { "name": "Javascript", "rank": 1 } ]|]
`shouldRespondWith`
[json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
it "fails if the payload has more than one row" $ it "fails if the payload has more than one row" $
put "/tiobe_pls?name=eq.Go" put "/tiobe_pls?name=eq.Go"
[str| [ { "name": "Go", "rank": 19 }, { "name": "Swift", "rank": 12 } ]|] `shouldRespondWith` 400 [str| [ { "name": "Go", "rank": 19 }, { "name": "Swift", "rank": 12 } ]|]
`shouldRespondWith`
[json|{"message":"PUT payload must contain a single row"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
it "fails if not all columns are specified" $ do it "fails if not all columns are specified" $ do
put "/tiobe_pls?name=eq.Go" put "/tiobe_pls?name=eq.Go"
[str| [ { "name": "Go" } ]|] `shouldRespondWith` 400 [str| [ { "name": "Go" } ]|]
`shouldRespondWith`
[json|{"message":"You must specify all columns in the payload when using PUT"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
put "/employees?first_name=eq.Susan&last_name=eq.Heidt" put "/employees?first_name=eq.Susan&last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000" } ]|] `shouldRespondWith` 400 [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000" } ]|]
`shouldRespondWith`
[json|{"message":"You must specify all columns in the payload when using PUT"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
it "rejects every other filter than pk cols eq's" $ do it "rejects every other filter than pk cols eq's" $ do
put "/tiobe_pls?rank=eq.19" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405 put "/tiobe_pls?rank=eq.19"
put "/tiobe_pls?id=not.eq.Java" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405 [str| [ { "name": "Go", "rank": 19 } ]|]
put "/tiobe_pls?id=in.(Go)" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405 `shouldRespondWith`
put "/tiobe_pls?and=(id.eq.Go)" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405 [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
put "/tiobe_pls?id=not.eq.Java"
[str| [ { "name": "Go", "rank": 19 } ]|]
`shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
put "/tiobe_pls?id=in.(Go)"
[str| [ { "name": "Go", "rank": 19 } ]|]
`shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
put "/tiobe_pls?and=(id.eq.Go)"
[str| [ { "name": "Go", "rank": 19 } ]|]
`shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
it "fails if not all composite key cols are specified as eq filters" $ do it "fails if not all composite key cols are specified as eq filters" $ do
put "/employees?first_name=eq.Susan" put "/employees?first_name=eq.Susan"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|] [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` 405 `shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
put "/employees?last_name=eq.Heidt" put "/employees?last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|] [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` 405 `shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
it "fails if the uri primary key doesn't match the payload primary key" $ do it "fails if the uri primary key doesn't match the payload primary key" $ do
put "/tiobe_pls?name=eq.MATLAB" put "/tiobe_pls?name=eq.MATLAB" [str| [ { "name": "Perl", "rank": 17 } ]|]
[str| [ { "name": "Perl", "rank": 17 } ]|] `shouldRespondWith` 400 `shouldRespondWith`
[json|{"message":"Payload values do not match URL in primary key column(s)"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
put "/employees?first_name=eq.Wendy&last_name=eq.Anderson" put "/employees?first_name=eq.Wendy&last_name=eq.Anderson"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|] `shouldRespondWith` 400 [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith`
[json|{"message":"Payload values do not match URL in primary key column(s)"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
it "fails if the table has no PK" $ it "fails if the table has no PK" $
put "/no_pk?a=eq.one&b=eq.two" [str| [ { "a": "one", "b": "two" } ]|] `shouldRespondWith` 405 put "/no_pk?a=eq.one&b=eq.two" [str| [ { "a": "one", "b": "two" } ]|]
`shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
context "Inserting row" $ do context "Inserting row" $ do
it "succeeds on table with single pk col" $ do it "succeeds on table with single pk col" $ do