fix: add error body when Prefer: count=exact is used and offset is out of bounds

* Adds error body when Prefer: count=exact is used and offset is out of bounds

* Adds details to differentiate between negative limits, lower boundaries greater than upper boundaries and out of bound ranges
This commit is contained in:
Laurence Isla
2022-09-15 18:39:37 -05:00
committed by GitHub
parent 334f500f7c
commit d5e662567b
5 changed files with 139 additions and 18 deletions
+10 -2
View File
@@ -291,9 +291,13 @@ handleRead headersOnly identifier context@RequestContext{..} = do
) )
] ]
++ contentTypeHeaders context ++ contentTypeHeaders context
rsOrErrBody = if status == HTTP.status416
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show total)
else LBS.fromStrict rsBody
failNotSingular iAcceptMediaType rsQueryTotal . response status headers $ failNotSingular iAcceptMediaType rsQueryTotal . response status headers $
if headersOnly then mempty else LBS.fromStrict rsBody if headersOnly then mempty else rsOrErrBody
RSPlan plan -> RSPlan plan ->
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
@@ -501,6 +505,10 @@ handleInvoke invMethod proc context@RequestContext{..} = do
let let
(status, contentRange) = (status, contentRange) =
RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
rsOrErrBody = if status == HTTP.status416
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
else LBS.fromStrict rsBody
failNotSingular iAcceptMediaType rsQueryTotal $ failNotSingular iAcceptMediaType rsQueryTotal $
if Proc.procReturnsVoid proc then if Proc.procReturnsVoid proc then
@@ -508,7 +516,7 @@ handleInvoke invMethod proc context@RequestContext{..} = do
else else
response status response status
(contentTypeHeaders context ++ [contentRange]) (contentTypeHeaders context ++ [contentRange])
(if invMethod == InvHead then mempty else LBS.fromStrict rsBody) (if invMethod == InvHead then mempty else rsOrErrBody)
RSPlan plan -> RSPlan plan ->
pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan pure $ Wai.responseLBS HTTP.status200 (contentTypeHeaders context) $ LBS.fromStrict plan
+9 -5
View File
@@ -32,7 +32,8 @@ import Network.HTTP.Types.Header (Header)
import PostgREST.MediaType (MediaType (..)) import PostgREST.MediaType (MediaType (..))
import qualified PostgREST.MediaType as MediaType import qualified PostgREST.MediaType as MediaType
import PostgREST.Request.Types (ApiRequestError (..), import PostgREST.Request.Types (ApiRequestError (..),
QPError (..)) QPError (..),
RangeError (..))
import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..)) import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))
import PostgREST.DbStructure.Proc (ProcDescription (..), import PostgREST.DbStructure.Proc (ProcDescription (..),
@@ -60,7 +61,7 @@ instance PgrstError ApiRequestError where
status InvalidBody{} = HTTP.status400 status InvalidBody{} = HTTP.status400
status InvalidFilters = HTTP.status405 status InvalidFilters = HTTP.status405
status InvalidRpcMethod{} = HTTP.status405 status InvalidRpcMethod{} = HTTP.status405
status InvalidRange = HTTP.status416 status InvalidRange{} = HTTP.status416
status NotFound = HTTP.status404 status NotFound = HTTP.status404
status NoRelBetween{} = HTTP.status400 status NoRelBetween{} = HTTP.status400
status NoRpc{} = HTTP.status404 status NoRpc{} = HTTP.status404
@@ -90,10 +91,13 @@ instance JSON.ToJSON ApiRequestError where
"message" .= T.decodeUtf8 errorMessage, "message" .= T.decodeUtf8 errorMessage,
"details" .= JSON.Null, "details" .= JSON.Null,
"hint" .= JSON.Null] "hint" .= JSON.Null]
toJSON InvalidRange = JSON.object [ toJSON (InvalidRange rangeError) = JSON.object [
"code" .= ApiRequestErrorCode03, "code" .= ApiRequestErrorCode03,
"message" .= ("HTTP Range error" :: Text), "message" .= ("Requested range not satisfiable" :: Text),
"details" .= JSON.Null, "details" .= (case rangeError of
NegativeLimit -> "Limit should be greater than or equal to zero."
LowerGTUpper -> "The lower boundary must be lower than or equal to the upper boundary in the Range header."
OutOfBounds lower total -> "An offset of " <> lower <> " was requested, but there are only " <> total <> " rows."),
"hint" .= JSON.Null] "hint" .= JSON.Null]
toJSON (ParseRequestError message details) = JSON.object [ toJSON (ParseRequestError message details) = JSON.object [
"code" .= ApiRequestErrorCode04, "code" .= ApiRequestErrorCode04,
+5 -3
View File
@@ -36,7 +36,8 @@ import Control.Arrow ((***))
import Data.Aeson.Types (emptyArray, emptyObject) import Data.Aeson.Types (emptyArray, emptyObject)
import Data.List (lookup, union) import Data.List (lookup, union)
import Data.Maybe (fromJust) import Data.Maybe (fromJust)
import Data.Ranged.Ranges (emptyRange, rangeIntersection) import Data.Ranged.Ranges (emptyRange, rangeIntersection,
rangeIsEmpty)
import Network.HTTP.Types.Header (hCookie) import Network.HTTP.Types.Header (hCookie)
import Network.HTTP.Types.URI (parseSimpleQuery) import Network.HTTP.Types.URI (parseSimpleQuery)
import Network.Wai (Request (..)) import Network.Wai (Request (..))
@@ -64,7 +65,8 @@ import PostgREST.Request.Preferences (PreferCount (..),
PreferResolution (..), PreferResolution (..),
PreferTransaction (..)) PreferTransaction (..))
import PostgREST.Request.QueryParams (QueryParams (..)) import PostgREST.Request.QueryParams (QueryParams (..))
import PostgREST.Request.Types (ApiRequestError (..)) import PostgREST.Request.Types (ApiRequestError (..),
RangeError (..))
import qualified PostgREST.MediaType as MediaType import qualified PostgREST.MediaType as MediaType
import qualified PostgREST.Request.Preferences as Preferences import qualified PostgREST.Request.Preferences as Preferences
@@ -217,7 +219,7 @@ getAction PathInfo{pathIsProc, pathIsDefSpec} method =
apiRequest :: AppConfig -> DbStructure -> Request -> RequestBody -> QueryParams.QueryParams -> PathInfo -> Action -> Either ApiRequestError ApiRequest apiRequest :: AppConfig -> DbStructure -> Request -> RequestBody -> QueryParams.QueryParams -> PathInfo -> Action -> Either ApiRequestError ApiRequest
apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..} path@PathInfo{pathName, pathIsProc, pathIsRootSpec, pathIsDefSpec} action apiRequest conf@AppConfig{..} dbStructure req reqBody queryparams@QueryParams{..} path@PathInfo{pathName, pathIsProc, pathIsRootSpec, pathIsDefSpec} action
| isJust profile && fromJust profile `notElem` configDbSchemas = Left $ UnacceptableSchema $ toList configDbSchemas | isJust profile && fromJust profile `notElem` configDbSchemas = Left $ UnacceptableSchema $ toList configDbSchemas
| isInvalidRange = Left InvalidRange | isInvalidRange = Left $ InvalidRange (if rangeIsEmpty headerRange then LowerGTUpper else NegativeLimit)
| shouldParsePayload && isLeft payload = either (Left . InvalidBody) witness payload | shouldParsePayload && isLeft payload = either (Left . InvalidBody) witness payload
| not expectParams && not (L.null qsParams) = Left $ ParseRequestError "Unexpected param or filter missing operator" ("Failed to parse " <> show qsParams) | not expectParams && not (L.null qsParams) = Left $ ParseRequestError "Unexpected param or filter missing operator" ("Failed to parse " <> show qsParams)
| method `elem` ["PATCH", "DELETE"] && not (null qsRanges) && null qsOrder = Left LimitNoOrderError | method `elem` ["PATCH", "DELETE"] && not (null qsRanges) && null qsOrder = Left LimitNoOrderError
+6 -1
View File
@@ -27,6 +27,7 @@ module PostgREST.Request.Types
, OrderNulls(..) , OrderNulls(..)
, OrderTerm(..) , OrderTerm(..)
, QPError(..) , QPError(..)
, RangeError(..)
, SingleVal , SingleVal
, TrileanVal(..) , TrileanVal(..)
, SimpleOperator(..) , SimpleOperator(..)
@@ -52,7 +53,7 @@ data ApiRequestError
| MediaTypeError [ByteString] | MediaTypeError [ByteString]
| InvalidBody ByteString | InvalidBody ByteString
| InvalidFilters | InvalidFilters
| InvalidRange | InvalidRange RangeError
| InvalidRpcMethod ByteString | InvalidRpcMethod ByteString
| LimitNoOrderError | LimitNoOrderError
| NotFound | NotFound
@@ -66,6 +67,10 @@ data ApiRequestError
| UnsupportedMethod ByteString | UnsupportedMethod ByteString
data QPError = QPError Text Text data QPError = QPError Text Text
data RangeError
= NegativeLimit
| LowerGTUpper
| OutOfBounds Text Text
type CallRequest = CallQuery type CallRequest = CallQuery
+109 -7
View File
@@ -39,6 +39,35 @@ spec = do
[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}] |] [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}] |]
{ matchHeaders = ["Content-Range" <:> "0-14/*"] } { matchHeaders = ["Content-Range" <:> "0-14/*"] }
context "of invalid range" $ do
it "refuses a range with nonzero start when there are no items" $
request methodPost "/rpc/getitemrange?offset=1"
[("Prefer", "count=exact")] emptyRange
`shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 1 was requested, but there are only 0 rows.",
"hint":null
}|]
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"]
}
it "refuses a range requesting start past last item" $
request methodPost "/rpc/getitemrange?offset=100"
[("Prefer", "count=exact")] defaultRange
`shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 100 was requested, but there are only 15 rows.",
"hint":null
}|]
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"]
}
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
@@ -82,12 +111,25 @@ spec = do
it "fails with 416 for offside range" $ it "fails with 416 for offside range" $
request methodPost "/rpc/getitemrange" request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 1 0) emptyRange (rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
`shouldRespondWith` 416 `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"The lower boundary must be lower than or equal to the upper boundary in the Range header.",
"hint":null
}|]
{ matchStatus = 416 }
it "refuses a range with nonzero start when there are no items" $ it "refuses a range with nonzero start when there are no items" $
request methodPost "/rpc/getitemrange" request methodPost "/rpc/getitemrange"
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange (rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
`shouldRespondWith` "[]" `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 1 was requested, but there are only 0 rows.",
"hint":null
}|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"] , matchHeaders = ["Content-Range" <:> "*/0"]
} }
@@ -95,7 +137,13 @@ spec = do
it "refuses a range requesting start past last item" $ it "refuses a range requesting start past last item" $
request methodPost "/rpc/getitemrange" request methodPost "/rpc/getitemrange"
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange (rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
`shouldRespondWith` "[]" `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 100 was requested, but there are only 15 rows.",
"hint":null
}|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"] , matchHeaders = ["Content-Range" <:> "*/15"]
} }
@@ -193,11 +241,46 @@ spec = do
it "fails if limit is negative" $ it "fails if limit is negative" $
get "/items?select=id&limit=-1" get "/items?select=id&limit=-1"
`shouldRespondWith` [json|{"message":"HTTP Range error","code":"PGRST103","details":null,"hint":null}|] `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"Limit should be greater than or equal to zero.",
"hint":null
}|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
context "of invalid range" $ do
it "refuses a range with nonzero start when there are no items" $
request methodGet "/menagerie?offset=1"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 1 was requested, but there are only 0 rows.",
"hint":null
}|]
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"]
}
it "refuses a range requesting start past last item" $
request methodGet "/items?offset=100"
[("Prefer", "count=exact")] ""
`shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 100 was requested, but there are only 15 rows.",
"hint":null
}|]
{ matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"]
}
context "when count=planned" $ do context "when count=planned" $ do
it "obtains a filtered range" $ do it "obtains a filtered range" $ do
request methodGet "/items?select=id&id=gt.8" request methodGet "/items?select=id&id=gt.8"
@@ -343,12 +426,25 @@ spec = do
it "fails with 416 for offside range" $ it "fails with 416 for offside range" $
request methodGet "/items" request methodGet "/items"
(rangeHdrs $ ByteRangeFromTo 1 0) "" (rangeHdrs $ ByteRangeFromTo 1 0) ""
`shouldRespondWith` 416 `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"The lower boundary must be lower than or equal to the upper boundary in the Range header.",
"hint":null
}|]
{ matchStatus = 416 }
it "refuses a range with nonzero start when there are no items" $ it "refuses a range with nonzero start when there are no items" $
request methodGet "/menagerie" request methodGet "/menagerie"
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) "" (rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
`shouldRespondWith` "[]" `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 1 was requested, but there are only 0 rows.",
"hint":null
}|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"] , matchHeaders = ["Content-Range" <:> "*/0"]
} }
@@ -356,7 +452,13 @@ spec = do
it "refuses a range requesting start past last item" $ it "refuses a range requesting start past last item" $
request methodGet "/items" request methodGet "/items"
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) "" (rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
`shouldRespondWith` "[]" `shouldRespondWith`
[json| {
"message":"Requested range not satisfiable",
"code":"PGRST103",
"details":"An offset of 100 was requested, but there are only 15 rows.",
"hint":null
}|]
{ matchStatus = 416 { matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"] , matchHeaders = ["Content-Range" <:> "*/15"]
} }