Compare commits

...
12 Commits
Author SHA1 Message Date
steve-chavez e30bc63f49 bump version to 13.0.7 2025-09-14 14:08:53 -05:00
Taimoor ZaeemandSteve Chavez a8f40c4908 feat: improve error details of PGRST301 error 2025-09-14 13:37:17 -05:00
Taimoor ZaeemandSteve Chavez 75972e9ffe refactor: move jwt error messages to Error.hs module 2025-09-14 13:37:17 -05:00
Laurence IslaandSteve Chavez 4ba6b1b30c feat: improve error response when the requested schema is invalid
- It now shows the invalid schema in the "message"
- The exposed schemas are now listed in the "hint" instead of the "message"
2025-09-14 13:37:17 -05:00
renovate[bot]andWolfgang Walther a4927141ee chore(deps): update codecov/codecov-action action to v5.5.1 2025-09-04 19:25:55 +00:00
renovate[bot]andWolfgang Walther 1b353590ff chore(deps): update ubuntu:noble docker digest to 9cbed75 2025-09-03 12:50:39 +00:00
renovate[bot]andWolfgang Walther 394aa026d3 chore(deps): update ubuntu:noble docker digest to f3b7f1b 2025-09-02 08:18:24 +00:00
steve-chavez 272e2e7535 bump version to 13.0.6 2025-08-30 13:55:34 -05:00
Laurence IslaandWolfgang Walther ea153523d1 fix: empty enum in 'preferParams' openAPI parameter 2025-08-30 08:31:21 +02:00
Taimoor ZaeemandWolfgang Walther bf0a1173b5 fix: format of IPv6 address logged at PostgREST startup
The IPv6 address logged at the startup like `::1:80` was
wrong because the port isn't clearly separated. This commit
corrects it, now logging as `[::1]:80`.

This is done in accordance to RFC 3986. In short, we did this
have a clear separation between the port and host because
the components of an IPv6 are separated with the ':' character.

Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
2025-08-29 22:31:01 +02:00
renovate[bot]andWolfgang Walther 3a28968f3c chore(deps): update nixbuild/nix-quick-install-action action to v33 2025-08-25 15:08:25 +00:00
Laurence IslaandWolfgang Walther 86aac1ead5 fix: logging the Haskell type instead of the listener error message directly
Previously: Just "connection error..."
Now: connection error...
2025-08-25 10:25:48 +00:00
15 changed files with 177 additions and 63 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ inputs:
runs: runs:
using: composite using: composite
steps: steps:
- uses: nixbuild/nix-quick-install-action@63ca48f939ee3b8d835f4126562537df0fee5b91 # v32 - uses: nixbuild/nix-quick-install-action@1f095fee853b33114486cfdeae62fa099cda35a9 # v33
with: with:
nix_conf: |- nix_conf: |-
always-allow-substitutes = true always-allow-substitutes = true
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
- name: Run coverage (IO tests and Spec tests against PostgreSQL 15) - name: Run coverage (IO tests and Spec tests against PostgreSQL 15)
run: postgrest-coverage run: postgrest-coverage
- name: Upload coverage to codecov - name: Upload coverage to codecov
uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
with: with:
files: ./coverage/codecov.json files: ./coverage/codecov.json
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
+17
View File
@@ -5,6 +5,23 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased ## Unreleased
## [13.0.7] - 2025-09-14
### Added
- Improve the `PGRST106` error when the requested schema is invalid by @laurenceisla in #4089
+ It now shows the invalid schema in the `message` field.
+ The exposed schemas are now listed in the `hint` instead of the `message` field.
- Improve error details of `PGRST301` error by @taimoorzaeem in #4051
## [13.0.6] - 2025-08-30
### Fixed
- Fix logging the Haskell type instead of the listener error message directly by @laurenceisla in #3588
- Fix format of `IPv6` address logged at PostgREST startup by @taimoorzaeem in #4291
- Fix empty enum in `preferParams` OpenAPI parameter by @laurenceisla in #4292
## [13.0.5] - 2025-08-24 ## [13.0.5] - 2025-08-24
### Fixed ### Fixed
+1 -1
View File
@@ -2,7 +2,7 @@
# The x86-64 is a single-static-binary image built via Nix, see: # The x86-64 is a single-static-binary image built via Nix, see:
# nix/tools/docker/README.md # nix/tools/docker/README.md
FROM ubuntu:noble@sha256:7c06e91f61fa88c08cc74f7e1b7c69ae24910d745357e0dfe1d2c0322aaf20f9 AS postgrest FROM ubuntu:noble@sha256:9cbed754112939e914291337b5e554b07ad7c392491dba6daf25eef1332a22e8 AS postgrest
RUN apt-get update -y \ RUN apt-get update -y \
&& apt install -y --no-install-recommends libpq-dev zlib1g-dev jq gcc libnuma-dev \ && apt install -y --no-install-recommends libpq-dev zlib1g-dev jq gcc libnuma-dev \
+1 -1
View File
@@ -1,5 +1,5 @@
name: postgrest name: postgrest
version: 13.0.5 version: 13.0.7
synopsis: REST API for any Postgres database synopsis: REST API for any Postgres database
description: Reads the schema of a PostgreSQL database and creates RESTful routes description: Reads the schema of a PostgreSQL database and creates RESTful routes
for tables, views, and functions, supporting all HTTP methods that security for tables, views, and functions, supporting all HTTP methods that security
+1 -1
View File
@@ -207,7 +207,7 @@ getAction resource schema method =
getSchema :: AppConfig -> RequestHeaders -> ByteString -> Either ApiRequestError (Schema, Bool) getSchema :: AppConfig -> RequestHeaders -> ByteString -> Either ApiRequestError (Schema, Bool)
getSchema AppConfig{configDbSchemas} hdrs method = do getSchema AppConfig{configDbSchemas} hdrs method = do
case profile of case profile of
Just p | p `notElem` configDbSchemas -> Left $ UnacceptableSchema $ toList configDbSchemas Just p | p `notElem` configDbSchemas -> Left $ UnacceptableSchema p $ toList configDbSchemas
| otherwise -> Right (p, True) | otherwise -> Right (p, True)
Nothing -> Right (defaultSchema, length configDbSchemas /= 1) -- if we have many schemas, assume the default schema was negotiated Nothing -> Right (defaultSchema, length configDbSchemas /= 1) -- if we have many schemas, assume the default schema was negotiated
where where
+19 -18
View File
@@ -50,7 +50,8 @@ import PostgREST.Auth.JwtCache (lookupJwtCache)
import PostgREST.Auth.Types (AuthResult (..)) import PostgREST.Auth.Types (AuthResult (..))
import PostgREST.Config (AppConfig (..), FilterExp (..), import PostgREST.Config (AppConfig (..), FilterExp (..),
JSPath, JSPathExp (..)) JSPath, JSPathExp (..))
import PostgREST.Error (Error (..), JwtError (..)) import PostgREST.Error (Error (..), JwtClaimsError (..),
JwtDecodeError (..), JwtError (..))
import Protolude import Protolude
@@ -58,7 +59,7 @@ import Protolude
-- JSON object of JWT claims. -- JSON object of JWT claims.
parseToken :: AppConfig -> Maybe ByteString -> UTCTime -> ExceptT Error IO JSON.Value parseToken :: AppConfig -> Maybe ByteString -> UTCTime -> ExceptT Error IO JSON.Value
parseToken _ Nothing _ = return JSON.emptyObject parseToken _ Nothing _ = return JSON.emptyObject
parseToken _ (Just "") _ = throwE . JwtErr $ JwtDecodeError "Empty JWT is sent in Authorization header" parseToken _ (Just "") _ = throwE . JwtErr $ JwtDecodeErr EmptyAuthHeader
parseToken AppConfig{..} (Just tkn) time = do parseToken AppConfig{..} (Just tkn) time = do
secret <- liftEither . maybeToRight (JwtErr JwtSecretMissing) $ configJWKS secret <- liftEither . maybeToRight (JwtErr JwtSecretMissing) $ configJWKS
tknWith3Parts <- liftEither $ hasThreeParts tkn tknWith3Parts <- liftEither $ hasThreeParts tkn
@@ -69,33 +70,33 @@ parseToken AppConfig{..} (Just tkn) time = do
hasThreeParts :: ByteString -> Either Error ByteString hasThreeParts :: ByteString -> Either Error ByteString
hasThreeParts token = case length $ BS.split (BS.c2w '.') token of hasThreeParts token = case length $ BS.split (BS.c2w '.') token of
3 -> Right token 3 -> Right token
n -> Left $ JwtErr $ JwtDecodeError ("Expected 3 parts in JWT; got " <> show n) n -> Left $ JwtErr $ JwtDecodeErr $ UnexpectedParts n
jwtDecodeError :: JWT.JwtError -> JwtError jwtDecodeError :: JWT.JwtError -> JwtError
-- The only errors we can get from JWT.decode function are: -- The only errors we can get from JWT.decode function are:
-- BadAlgorithm -- BadAlgorithm
-- KeyError -- KeyError
-- BadCrypto -- BadCrypto
jwtDecodeError (JWT.KeyError _) = JwtDecodeError "No suitable key or wrong key type" jwtDecodeError (JWT.KeyError m) = JwtDecodeErr $ KeyError m
jwtDecodeError (JWT.BadAlgorithm _) = JwtDecodeError "Wrong or unsupported encoding algorithm" jwtDecodeError (JWT.BadAlgorithm m) = JwtDecodeErr $ BadAlgorithm m
jwtDecodeError JWT.BadCrypto = JwtDecodeError "JWT cryptographic operation failed" jwtDecodeError JWT.BadCrypto = JwtDecodeErr BadCrypto
-- Control never reaches here, the decode function only returns the above three -- Control never reaches here, the decode function only returns the above three
jwtDecodeError _ = JwtDecodeError "JWT couldn't be decoded" jwtDecodeError _ = JwtDecodeErr UnreachableDecodeError
verifyClaims :: JWT.JwtContent -> Either JwtError JSON.Value verifyClaims :: JWT.JwtContent -> Either JwtError JSON.Value
verifyClaims (JWT.Jws (_, claims)) = case JSON.decodeStrict claims of verifyClaims (JWT.Jws (_, claims)) = case JSON.decodeStrict claims of
Just jclaims@(JSON.Object mclaims) -> Just jclaims@(JSON.Object mclaims) ->
verifyClaim mclaims "exp" isValidExpClaim "JWT expired" >> verifyClaim mclaims "exp" isValidExpClaim JWTExpired >>
verifyClaim mclaims "nbf" isValidNbfClaim "JWT not yet valid" >> verifyClaim mclaims "nbf" isValidNbfClaim JWTNotYetValid >>
verifyClaim mclaims "iat" isValidIatClaim "JWT issued at future" >> verifyClaim mclaims "iat" isValidIatClaim JWTIssuedAtFuture >>
verifyClaim mclaims "aud" isValidAudClaim "JWT not in audience" >> verifyClaim mclaims "aud" isValidAudClaim JWTNotInAudience >>
return jclaims return jclaims
_ -> Left $ JwtClaimsError "Parsing claims failed" _ -> Left $ JwtClaimsErr ParsingClaimsFailed
-- TODO: We could enable JWE support here (encrypted tokens) -- TODO: We could enable JWE support here (encrypted tokens)
verifyClaims _ = Left $ JwtDecodeError "Unsupported token type" verifyClaims _ = Left $ JwtDecodeErr UnsupportedTokenType
verifyClaim mclaims claim func err = do verifyClaim mclaims claim func err = do
isValid <- maybe (Right True) func (KM.lookup claim mclaims) isValid <- maybe (Right True) func (KM.lookup claim mclaims)
unless isValid $ Left $ JwtClaimsError err unless isValid $ Left $ JwtClaimsErr err
allowedSkewSeconds = 30 :: Int64 allowedSkewSeconds = 30 :: Int64
now = floor . nominalDiffTimeToSeconds $ utcTimeToPOSIXSeconds time now = floor . nominalDiffTimeToSeconds $ utcTimeToPOSIXSeconds time
@@ -104,15 +105,15 @@ parseToken AppConfig{..} (Just tkn) time = do
isValidExpClaim :: JSON.Value -> Either JwtError Bool isValidExpClaim :: JSON.Value -> Either JwtError Bool
isValidExpClaim (JSON.Number secs) = Right $ now <= (sciToInt secs + allowedSkewSeconds) isValidExpClaim (JSON.Number secs) = Right $ now <= (sciToInt secs + allowedSkewSeconds)
isValidExpClaim _ = Left $ JwtClaimsError "The JWT 'exp' claim must be a number" isValidExpClaim _ = Left $ JwtClaimsErr ExpClaimNotNumber
isValidNbfClaim :: JSON.Value -> Either JwtError Bool isValidNbfClaim :: JSON.Value -> Either JwtError Bool
isValidNbfClaim (JSON.Number secs) = Right $ now >= (sciToInt secs - allowedSkewSeconds) isValidNbfClaim (JSON.Number secs) = Right $ now >= (sciToInt secs - allowedSkewSeconds)
isValidNbfClaim _ = Left $ JwtClaimsError "The JWT 'nbf' claim must be a number" isValidNbfClaim _ = Left $ JwtClaimsErr NbfClaimNotNumber
isValidIatClaim :: JSON.Value -> Either JwtError Bool isValidIatClaim :: JSON.Value -> Either JwtError Bool
isValidIatClaim (JSON.Number secs) = Right $ now >= (sciToInt secs - allowedSkewSeconds) isValidIatClaim (JSON.Number secs) = Right $ now >= (sciToInt secs - allowedSkewSeconds)
isValidIatClaim _ = Left $ JwtClaimsError "The JWT 'iat' claim must be a number" isValidIatClaim _ = Left $ JwtClaimsErr IatClaimNotNumber
isValidAudClaim :: JSON.Value -> Either JwtError Bool isValidAudClaim :: JSON.Value -> Either JwtError Bool
isValidAudClaim JSON.Null = Right True -- {"aud": null} is valid for all audiences isValidAudClaim JSON.Null = Right True -- {"aud": null} is valid for all audiences
@@ -120,7 +121,7 @@ parseToken AppConfig{..} (Just tkn) time = do
isValidAudClaim (JSON.Array arr) isValidAudClaim (JSON.Array arr)
| null arr = Right True -- {"aud": []} is valid for all audiences | null arr = Right True -- {"aud": []} is valid for all audiences
| allStrings arr = Right $ maybe True (\a -> JSON.String a `elem` arr) configJwtAudience | allStrings arr = Right $ maybe True (\a -> JSON.String a `elem` arr) configJwtAudience
isValidAudClaim _ = Left $ JwtClaimsError "The JWT 'aud' claim must be a string or an array of strings" isValidAudClaim _ = Left $ JwtClaimsErr AudClaimNotStringOrArray
parseClaims :: Monad m => parseClaims :: Monad m =>
AppConfig -> JSON.Value -> ExceptT Error m AuthResult AppConfig -> JSON.Value -> ExceptT Error m AuthResult
+61 -16
View File
@@ -14,6 +14,8 @@ module PostgREST.Error
, PgError(..) , PgError(..)
, Error(..) , Error(..)
, JwtError (..) , JwtError (..)
, JwtDecodeError(..)
, JwtClaimsError(..)
, errorPayload , errorPayload
, status , status
) where ) where
@@ -86,7 +88,7 @@ data ApiRequestError
| QueryParamError QPError | QueryParamError QPError
| RelatedOrderNotToOne Text Text | RelatedOrderNotToOne Text Text
| UnacceptableFilter Text | UnacceptableFilter Text
| UnacceptableSchema [Text] | UnacceptableSchema Text [Text]
| UnsupportedMethod ByteString | UnsupportedMethod ByteString
| GucHeadersError | GucHeadersError
| GucStatusError | GucStatusError
@@ -194,7 +196,7 @@ instance ErrorBody ApiRequestError where
message (InvalidBody errorMessage) = T.decodeUtf8 errorMessage message (InvalidBody errorMessage) = T.decodeUtf8 errorMessage
message (InvalidRange _) = "Requested range not satisfiable" message (InvalidRange _) = "Requested range not satisfiable"
message InvalidFilters = "Filters must include all and only primary key columns with 'eq' operators" message InvalidFilters = "Filters must include all and only primary key columns with 'eq' operators"
message (UnacceptableSchema schemas) = "The schema must be one of the following: " <> T.intercalate ", " schemas message (UnacceptableSchema sch _) = "Invalid schema: " <> sch
message (MediaTypeError cts) = "None of these media types are available: " <> T.intercalate ", " (map T.decodeUtf8 cts) message (MediaTypeError cts) = "None of these media types are available: " <> T.intercalate ", " (map T.decodeUtf8 cts)
message (NotEmbedded resource) = "'" <> resource <> "' is not an embedded resource in this request" message (NotEmbedded resource) = "'" <> resource <> "' is not an embedded resource in this request"
message GucHeadersError = "response.headers guc must be a JSON array composed of objects with a single key and a string value" message GucHeadersError = "response.headers guc must be a JSON array composed of objects with a single key and a string value"
@@ -234,6 +236,7 @@ instance ErrorBody ApiRequestError where
-- HINT: Maybe JSON.Value -- HINT: Maybe JSON.Value
hint (NotEmbedded resource) = Just $ JSON.String $ "Verify that '" <> resource <> "' is included in the 'select' query parameter." hint (NotEmbedded resource) = Just $ JSON.String $ "Verify that '" <> resource <> "' is included in the 'select' query parameter."
hint (PGRSTParseError raiseErr) = Just $ JSON.String $ pgrstParseErrorHint raiseErr hint (PGRSTParseError raiseErr) = Just $ JSON.String $ pgrstParseErrorHint raiseErr
hint (UnacceptableSchema _ schemas) = Just $ JSON.String $ "Only the following schemas are exposed: " <> T.intercalate ", " schemas
hint _ = Nothing hint _ = Nothing
@@ -647,10 +650,32 @@ data Error
deriving Show deriving Show
data JwtError data JwtError
= JwtDecodeError Text = JwtDecodeErr JwtDecodeError
| JwtSecretMissing | JwtSecretMissing
| JwtTokenRequired | JwtTokenRequired
| JwtClaimsError Text | JwtClaimsErr JwtClaimsError
deriving Show
data JwtDecodeError
= EmptyAuthHeader
| UnexpectedParts Int
| KeyError Text
| BadAlgorithm Text
| BadCrypto
| UnsupportedTokenType
| UnreachableDecodeError
deriving Show
data JwtClaimsError
= JWTExpired
| JWTNotYetValid
| JWTIssuedAtFuture
| JWTNotInAudience
| ParsingClaimsFailed
| ExpClaimNotNumber
| NbfClaimNotNumber
| IatClaimNotNumber
| AudClaimNotStringOrArray
deriving Show deriving Show
instance PgrstError Error where instance PgrstError Error where
@@ -696,14 +721,14 @@ instance ErrorBody Error where
hint (PgErr err) = hint err hint (PgErr err) = hint err
instance PgrstError JwtError where instance PgrstError JwtError where
status JwtDecodeError{} = HTTP.unauthorized401 status JwtDecodeErr{} = HTTP.unauthorized401
status JwtSecretMissing = HTTP.status500 status JwtSecretMissing = HTTP.status500
status JwtTokenRequired = HTTP.unauthorized401 status JwtTokenRequired = HTTP.unauthorized401
status JwtClaimsError{} = HTTP.unauthorized401 status JwtClaimsErr{} = HTTP.unauthorized401
headers (JwtDecodeError m) = [invalidTokenHeader m] headers e@(JwtDecodeErr _) = [invalidTokenHeader $ message e]
headers JwtTokenRequired = [requiredTokenHeader] headers JwtTokenRequired = [requiredTokenHeader]
headers (JwtClaimsError m) = [invalidTokenHeader m] headers e@(JwtClaimsErr _) = [invalidTokenHeader $ message e]
headers _ = mempty headers _ = mempty
instance JSON.ToJSON JwtError where instance JSON.ToJSON JwtError where
@@ -711,16 +736,36 @@ instance JSON.ToJSON JwtError where
(code err) (message err) (details err) (hint err) (code err) (message err) (details err) (hint err)
instance ErrorBody JwtError where instance ErrorBody JwtError where
code JwtSecretMissing = "PGRST300" code JwtSecretMissing = "PGRST300"
code (JwtDecodeError _) = "PGRST301" code (JwtDecodeErr _) = "PGRST301"
code JwtTokenRequired = "PGRST302" code JwtTokenRequired = "PGRST302"
code (JwtClaimsError _) = "PGRST303" code (JwtClaimsErr _) = "PGRST303"
message JwtSecretMissing = "Server lacks JWT secret" message JwtSecretMissing = "Server lacks JWT secret"
message (JwtDecodeError msg) = msg message (JwtDecodeErr e) = case e of
message JwtTokenRequired = "Anonymous access is disabled" EmptyAuthHeader -> "Empty JWT is sent in Authorization header"
message (JwtClaimsError msg) = msg UnexpectedParts n -> "Expected 3 parts in JWT; got " <> show n
KeyError _ -> "No suitable key or wrong key type"
BadAlgorithm _ -> "Wrong or unsupported encoding algorithm"
BadCrypto -> "JWT cryptographic operation failed"
UnsupportedTokenType -> "Unsupported token type"
UnreachableDecodeError -> "JWT couldn't be decoded"
message JwtTokenRequired = "Anonymous access is disabled"
message (JwtClaimsErr e) = case e of
JWTExpired -> "JWT expired"
JWTNotYetValid -> "JWT not yet valid"
JWTIssuedAtFuture -> "JWT issued at future"
JWTNotInAudience -> "JWT not in audience"
ParsingClaimsFailed -> "Parsing claims failed"
ExpClaimNotNumber -> "The JWT 'exp' claim must be a number"
NbfClaimNotNumber -> "The JWT 'nbf' claim must be a number"
IatClaimNotNumber -> "The JWT 'iat' claim must be a number"
AudClaimNotStringOrArray -> "The JWT 'aud' claim must be a string or an array of strings"
details (JwtDecodeErr jde) = case jde of
KeyError dets -> Just $ JSON.String dets
BadAlgorithm dets -> Just $ JSON.String dets
_ -> Nothing
details _ = Nothing details _ = Nothing
hint _ = Nothing hint _ = Nothing
+5 -1
View File
@@ -13,5 +13,9 @@ resolveHost sock = do
sn <- NS.getSocketName sock sn <- NS.getSocketName sock
case sn of case sn of
NS.SockAddrInet _ hostAddr -> pure $ Just $ fromString $ show $ fromHostAddress hostAddr NS.SockAddrInet _ hostAddr -> pure $ Just $ fromString $ show $ fromHostAddress hostAddr
NS.SockAddrInet6 _ _ hostAddr6 _ -> pure $ Just $ fromString $ show $ fromHostAddress6 hostAddr6 -- The IPv6 addresses are wrapped in [] brackets. This is done in accordance
-- to RFC 3986 (https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2).
-- In short, we did this to have a clear separation between the port and host
-- because the components of an IPv6 are separated with the ':' character.
NS.SockAddrInet6 _ _ hostAddr6 _ -> pure $ Just $ fromString $ "[" ++ show (fromHostAddress6 hostAddr6) ++ "]"
_ -> pure Nothing _ -> pure Nothing
+10 -10
View File
@@ -108,11 +108,8 @@ observationMessage = \case
DBListenStart channel -> do DBListenStart channel -> do
"Listening for database notifications on the " <> show channel <> " channel" "Listening for database notifications on the " <> show channel <> " channel"
DBListenFail channel listenErr -> DBListenFail channel listenErr ->
"Failed listening for database notifications on the " <> show channel <> " channel. " <> ( "Failed listening for database notifications on the " <> show channel <> " channel. " <>
case listenErr of either showListenerConnError showListenerException listenErr
Left err -> show err
Right err -> showListenerError err
)
DBListenRetry delay -> DBListenRetry delay ->
"Retrying listening for database notifications in " <> (show delay::Text) <> " seconds..." "Retrying listening for database notifications in " <> (show delay::Text) <> " seconds..."
DBListenerGotSCacheMsg channel -> DBListenerGotSCacheMsg channel ->
@@ -157,8 +154,11 @@ observationMessage = \case
jsonMessage err = T.decodeUtf8 . LBS.toStrict . Error.errorPayload $ Error.PgError False err jsonMessage err = T.decodeUtf8 . LBS.toStrict . Error.errorPayload $ Error.PgError False err
showListenerError :: Either SomeException () -> Text showOnSingleLine txt = T.intercalate " " $ T.filter (/= '\t') <$> T.lines txt -- the errors from hasql-notifications come intercalated with "\t\n"
showListenerError (Right _) = "Failed getting notifications" -- should not happen as the listener will never finish (hasql-notifications uses `forever` internally) with a Right result
showListenerError (Left e) = showListenerConnError :: SQL.ConnectionError -> Text
let showOnSingleLine txt = T.intercalate " " $ T.filter (/= '\t') <$> T.lines txt in -- the errors from hasql-notifications come intercalated with "\t\n" showListenerConnError = maybe "Connection error" (showOnSingleLine . T.decodeUtf8)
showOnSingleLine $ show e
showListenerException :: Either SomeException () -> Text
showListenerException (Right _) = "Failed getting notifications" -- should not happen as the listener will never finish (hasql-notifications uses `forever` internally) with a Right result
showListenerException (Left e) = showOnSingleLine $ show e
+2 -1
View File
@@ -171,8 +171,9 @@ makePreferParam ts =
& schema .~ ParamOther ((mempty :: ParamOtherSchema) & schema .~ ParamOther ((mempty :: ParamOtherSchema)
& in_ .~ ParamHeader & in_ .~ ParamHeader
& type_ ?~ SwaggerString & type_ ?~ SwaggerString
& enum_ .~ JSON.decode (JSON.encode $ foldl (<>) [] (val <$> ts))) & enum_ .~ if null enu then Nothing else JSON.decode (JSON.encode enu))
where where
enu = foldl (<>) [] (val <$> ts)
val :: Text -> [Text] val :: Text -> [Text]
val = \case val = \case
"count" -> ["count=none"] "count" -> ["count=none"]
+13 -2
View File
@@ -96,7 +96,9 @@ def run(
if port: if port:
env["PGRST_SERVER_PORT"] = str(port) env["PGRST_SERVER_PORT"] = str(port)
env["PGRST_SERVER_HOST"] = host or "localhost" env["PGRST_SERVER_HOST"] = host or "localhost"
baseurl = f"http://localhost:{port}" # When constructing IPv6 address, host address should be bracketed like [host]
apihost = f"[{host}]" if host and is_ipv6(host) else "localhost"
baseurl = f"http://{apihost}:{port}"
else: else:
socketfile = pathlib.Path(tmpdir) / "postgrest.sock" socketfile = pathlib.Path(tmpdir) / "postgrest.sock"
env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile) env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile)
@@ -104,7 +106,8 @@ def run(
adminport = freeport(port) adminport = freeport(port)
env["PGRST_ADMIN_SERVER_PORT"] = str(adminport) env["PGRST_ADMIN_SERVER_PORT"] = str(adminport)
adminurl = f"http://localhost:{adminport}" adminhost = f"[{host}]" if host and is_ipv6(host) else "localhost"
adminurl = f"http://{adminhost}:{adminport}"
command = [POSTGREST_BIN] command = [POSTGREST_BIN]
env["HPCTIXFILE"] = hpctixfile() env["HPCTIXFILE"] = hpctixfile()
@@ -218,3 +221,11 @@ def sleep_pool_connection(url, seconds):
session.get(url + f"/rpc/sleep?seconds={seconds}", timeout=0.1) session.get(url + f"/rpc/sleep?seconds={seconds}", timeout=0.1)
except requests.exceptions.ReadTimeout: except requests.exceptions.ReadTimeout:
pass pass
def is_ipv6(addr):
try:
socket.inet_pton(socket.AF_INET6, addr)
return True
except OSError:
return False
+31 -3
View File
@@ -93,6 +93,9 @@ def test_jwt_errors(defaultenv):
response = postgrest.session.get("/", headers=headers) response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401 assert response.status_code == 401
assert response.json()["message"] == "No suitable key or wrong key type" assert response.json()["message"] == "No suitable key or wrong key type"
assert (
response.json()["details"] == "None of the keys was able to decode the JWT"
)
headers = jwtauthheader({"role": "not_existing"}, SECRET) headers = jwtauthheader({"role": "not_existing"}, SECRET)
response = postgrest.session.get("/", headers=headers) response = postgrest.session.get("/", headers=headers)
@@ -141,6 +144,10 @@ def test_jwt_errors(defaultenv):
response = postgrest.session.get("/", headers=headers) response = postgrest.session.get("/", headers=headers)
assert response.status_code == 401 assert response.status_code == 401
assert response.json()["message"] == "Wrong or unsupported encoding algorithm" assert response.json()["message"] == "Wrong or unsupported encoding algorithm"
assert (
response.json()["details"]
== "JWT is unsecured but expected 'alg' was not 'none'"
)
env = { env = {
**defaultenv, **defaultenv,
@@ -1355,9 +1362,9 @@ def test_log_postgrest_version(defaultenv):
assert "Starting PostgREST %s..." % version in output[0] assert "Starting PostgREST %s..." % version in output[0]
def test_log_postgrest_host_and_port(defaultenv): @pytest.mark.parametrize("host", ["127.0.0.1", "::1"])
def test_log_postgrest_host_and_port(host, defaultenv):
"PostgREST should output the host and port it is bound to." "PostgREST should output the host and port it is bound to."
host = "127.0.0.1"
port = freeport() port = freeport()
with run( with run(
@@ -1365,7 +1372,10 @@ def test_log_postgrest_host_and_port(defaultenv):
) as postgrest: ) as postgrest:
output = postgrest.read_stdout(nlines=10) output = postgrest.read_stdout(nlines=10)
assert f"API server listening on {host}:{port}" in output[2] # output-sensitive if is_ipv6(host): # IPv6
assert f"API server listening on [{host}]:{port}" in output[2]
else: # IPv4
assert f"API server listening on {host}:{port}" in output[2]
def test_succeed_w_role_having_superuser_settings(defaultenv): def test_succeed_w_role_having_superuser_settings(defaultenv):
@@ -1930,3 +1940,21 @@ def test_schema_cache_error_observation(defaultenv):
"Failed to load the schema cache using db-schemas=public and db-extra-search-path=x" "Failed to load the schema cache using db-schemas=public and db-extra-search-path=x"
in output[7] in output[7]
) )
def test_log_listener_connection_errors(defaultenv):
"The logs should show the listener connection error message in a single line"
env = {
**defaultenv,
"PGHOST": "no_host",
"PGRST_DB_CHANNEL_ENABLED": "true",
}
with run(env=env, no_startup_stdout=False, wait_for_readiness=False) as postgrest:
output = postgrest.read_stdout(nlines=5)
assert any(
'Failed listening for database notifications on the "pgrst" channel. could not translate host name "no_host" to address:'
in line
for line in output
)
+7
View File
@@ -1085,6 +1085,13 @@ spec = describe "OpenAPI" $ do
immutableGet `shouldNotBe` Nothing immutableGet `shouldNotBe` Nothing
immutablePost `shouldNotBe` Nothing immutablePost `shouldNotBe` Nothing
it "does not include empty enum in the preferParams parameter" $ do
r <- simpleBody <$> get "/"
let preferParams = r ^? key "parameters" . key "preferParams" . key "enum"
liftIO $ do
preferParams `shouldBe` Nothing
describe "Security" $ describe "Security" $
it "does not include security or security definitions by default" $ do it "does not include security or security definitions by default" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
@@ -72,9 +72,9 @@ spec =
, matchHeaders = [] , matchHeaders = []
} }
it "fails trying to read table from unkown schema" $ it "fails trying to read table from unknown schema" $
request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith` request methodGet "/parents" [("Accept-Profile", "unknown")] "" `shouldRespondWith`
[json|{"message":"The schema must be one of the following: v1, v2, SPECIAL \"@/\\#~_-","code":"PGRST106","details":null,"hint":null}|] [json|{"message":"Invalid schema: unknown","code":"PGRST106","details":null,"hint":"Only the following schemas are exposed: v1, v2, SPECIAL \"@/\\#~_-"}|]
{ {
matchStatus = 406 matchStatus = 406
} }
@@ -151,7 +151,7 @@ spec =
request methodPost "/children" [("Content-Profile", "unknown")] request methodPost "/children" [("Content-Profile", "unknown")]
[json|{"name": "child 4", "parent_id": 4}|] [json|{"name": "child 4", "parent_id": 4}|]
`shouldRespondWith` `shouldRespondWith`
[json|{"message":"The schema must be one of the following: v1, v2, SPECIAL \"@/\\#~_-","code":"PGRST106","details":null,"hint":null}|] [json|{"message":"Invalid schema: unknown","code":"PGRST106","details":null,"hint":"Only the following schemas are exposed: v1, v2, SPECIAL \"@/\\#~_-"}|]
{ {
matchStatus = 406 matchStatus = 406
} }
@@ -389,9 +389,9 @@ spec =
let def = simpleBody r ^? key "definitions" . key "another_table" let def = simpleBody r ^? key "definitions" . key "another_table"
def `shouldBe` Nothing def `shouldBe` Nothing
it "fails trying to read definitions from unkown schema" $ it "fails trying to read definitions from unknown schema" $
request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith` request methodGet "/" [("Accept-Profile", "unknown")] "" `shouldRespondWith`
[json|{"message":"The schema must be one of the following: v1, v2, SPECIAL \"@/\\#~_-","code":"PGRST106","details":null,"hint":null}|] [json|{"message":"Invalid schema: unknown","code":"PGRST106","details":null,"hint":"Only the following schemas are exposed: v1, v2, SPECIAL \"@/\\#~_-"}|]
{ {
matchStatus = 406 matchStatus = 406
} }