postgrest 0.4.3.0 → 0.4.4.0
raw patch · 27 files changed
+1327/−686 lines, 27 filesdep +gitrevdep −hjsonpointerdep ~jose
Dependencies added: gitrev
Dependencies removed: hjsonpointer
Dependency ranges changed: jose
Files
- main/Main.hs +18/−32
- postgrest.cabal +8/−4
- src/PostgREST/ApiRequest.hs +36/−20
- src/PostgREST/App.hs +32/−24
- src/PostgREST/Auth.hs +17/−22
- src/PostgREST/Config.hs +108/−80
- src/PostgREST/DbRequestBuilder.hs +31/−9
- src/PostgREST/DbStructure.hs +108/−60
- src/PostgREST/Error.hs +20/−6
- src/PostgREST/OpenAPI.hs +2/−2
- src/PostgREST/Parsers.hs +38/−25
- src/PostgREST/QueryBuilder.hs +53/−70
- src/PostgREST/Types.hs +52/−9
- test/Feature/AndOrParamsSpec.hs +6/−0
- test/Feature/AudienceJwtSecretSpec.hs +47/−0
- test/Feature/AuthSpec.hs +11/−2
- test/Feature/InsertSpec.hs +7/−7
- test/Feature/NonexistentSchemaSpec.hs +15/−0
- test/Feature/PgVersion96Spec.hs +81/−0
- test/Feature/QueryLimitedSpec.hs +2/−3
- test/Feature/QuerySpec.hs +167/−277
- test/Feature/RangeSpec.hs +5/−6
- test/Feature/RpcSpec.hs +345/−0
- test/Feature/SingularSpec.hs +2/−1
- test/Feature/StructureSpec.hs +57/−3
- test/Main.hs +48/−23
- test/SpecHelper.hs +11/−1
main/Main.hs view
@@ -2,31 +2,29 @@ module Main where + import PostgREST.App (postgrest) import PostgREST.Config (AppConfig (..),- PgVersion (..), minimumPgVersion, prettyVersion, readOptions)-import PostgREST.DbStructure (getDbStructure)+import PostgREST.DbStructure (getDbStructure, getPgVersion) import PostgREST.Error (encodeError) import PostgREST.OpenAPI (isMalformedProxyUri)-import PostgREST.Types (DbStructure, Schema)-import Protolude hiding (replace, hPutStrLn)+import PostgREST.Types (DbStructure, Schema, PgVersion(..))+import Protolude hiding (hPutStrLn, replace) import Control.Retry (RetryStatus, capDelay, exponentialBackoff, retrying, rsPreviousDelay)-import Data.ByteString.Base64 (decode)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64 as B64 import Data.IORef (IORef, atomicWriteIORef, newIORef, readIORef) import Data.String (IsString (..)) import Data.Text (pack, replace, stripPrefix, strip) import Data.Text.Encoding (decodeUtf8, encodeUtf8)-import Data.Text.IO (hPutStrLn, readFile)-import qualified Hasql.Decoders as HD-import qualified Hasql.Encoders as HE+import Data.Text.IO (hPutStrLn) import qualified Hasql.Pool as P-import qualified Hasql.Query as H import qualified Hasql.Session as H import Network.Wai.Handler.Warp (defaultSettings, runSettings, setHost,@@ -39,19 +37,6 @@ #endif {-|- Used by connectionWorker to know if it should throw an error and kill the- main thread.--}-isServerVersionSupported :: H.Session Bool-isServerVersionSupported = do- ver <- H.query () pgVersion- return $ ver >= pgvNum minimumPgVersion- where- pgVersion =- H.statement "SELECT current_setting('server_version_num')::integer"- HE.unit (HD.singleRow $ HD.value HD.int4) False--{-| The purpose of this worker is to fill the refDbStructure created in 'main' with the 'DbStructure' returned from calling 'getDbStructure'. This method is meant to be called by multiple times by the same thread, but does nothing if@@ -70,7 +55,7 @@ goes back to 1, otherwise it finishes his work successfully. -} connectionWorker- :: ThreadId -- ^ This thread is killed if 'isServerVersionSupported' returns false+ :: ThreadId -- ^ This thread is killed if pg version is unsupported -> P.Pool -- ^ The PostgreSQL connection pool -> Schema -- ^ Schema PostgREST is serving up -> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'@@ -88,13 +73,13 @@ connected <- connectingSucceeded pool when connected $ do result <- P.use pool $ do- supported <- isServerVersionSupported- unless supported $ liftIO $ do+ actualPgVersion <- getPgVersion+ unless (actualPgVersion >= minimumPgVersion) $ liftIO $ do hPutStrLn stderr ("Cannot run in this PostgreSQL version, PostgREST needs at least " <> pgvName minimumPgVersion) killThread mainTid- dbStructure <- getDbStructure schema+ dbStructure <- getDbStructure schema actualPgVersion liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure case result of Left e -> do@@ -105,7 +90,6 @@ atomicWriteIORef refIsWorkerOn False putStrLn ("Connection successful" :: Text) - {-| Used by 'connectionWorker' to check if the provided db-uri lets the application access the PostgreSQL database. This method is used@@ -264,14 +248,16 @@ fmap setSecret $ transformString isB64 =<< case stripPrefix "@" secret of- Nothing -> return secret- Just filename -> readFile (toS filename)+ Nothing -> return . encodeUtf8 $ secret+ Just filename -> chomp <$> BS.readFile (toS filename)+ where+ chomp bs = fromMaybe bs (BS.stripSuffix "\n" bs) -- -- Turns the Base64url encoded JWT into Base64- transformString :: Bool -> Text -> IO ByteString- transformString False t = return . encodeUtf8 $ t+ transformString :: Bool -> ByteString -> IO ByteString+ transformString False t = return t transformString True t =- case decode (encodeUtf8 $ strip $ replaceUrlChars t) of+ case B64.decode $ encodeUtf8 $ strip $ replaceUrlChars $ decodeUtf8 t of Left errMsg -> panic $ pack errMsg Right bs -> return bs setSecret bs = conf {configJwtSecret = Just bs}
postgrest.cabal view
@@ -2,7 +2,7 @@ description: Reads the schema of a PostgreSQL database and creates RESTful routes for the tables and views, supporting all HTTP verbs that security permits.-version: 0.4.3.0+version: 0.4.4.0 synopsis: REST API for any Postgres database license: MIT license-file: LICENSE@@ -58,6 +58,7 @@ , containers , contravariant , either+ , gitrev , hasql , hasql-pool == 0.4.1 , hasql-transaction == 0.5@@ -66,7 +67,7 @@ , http-types , insert-ordered-containers , interpolatedstring-perl6- , jose+ , jose >= 0.6 , lens , lens-aeson , network-uri@@ -110,7 +111,8 @@ ghc-options: -threaded -rtsopts -with-rtsopts=-N Hs-Source-Dirs: test Main-Is: Main.hs- Other-Modules: Feature.AuthSpec+ Other-Modules: Feature.AudienceJwtSecretSpec+ , Feature.AuthSpec , Feature.AsymmetricJwtSpec , Feature.BinaryJwtSecretSpec , Feature.ConcurrentSpec@@ -118,6 +120,7 @@ , Feature.DeleteSpec , Feature.InsertSpec , Feature.NoJwtSpec+ , Feature.PgVersion96Spec , Feature.ProxySpec , Feature.QueryLimitedSpec , Feature.QuerySpec@@ -126,6 +129,8 @@ , Feature.StructureSpec , Feature.UnicodeSpec , Feature.AndOrParamsSpec+ , Feature.RpcSpec+ , Feature.NonexistentSchemaSpec , SpecHelper , TestTypes Build-Depends: aeson@@ -141,7 +146,6 @@ , hasql , hasql-pool , heredoc- , hjsonpointer , hjsonschema == 1.5.0.1 , hspec , hspec-wai >= 0.7.0
src/PostgREST/ApiRequest.hs view
@@ -19,7 +19,7 @@ import qualified Data.ByteString.Lazy as BL import qualified Data.Csv as CSV import qualified Data.List as L-import Data.List (lookup, last)+import Data.List (lookup, last, partition) import qualified Data.HashMap.Strict as M import qualified Data.Set as S import Data.Maybe (fromJust)@@ -38,7 +38,9 @@ , PayloadJSON(..) , ContentType(..) , ApiRequestError(..)- , toMime)+ , toMime+ , operators+ , ftsOperators) import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange) import qualified Data.CaseInsensitive as CI import Web.Cookie (parseCookiesText)@@ -48,7 +50,7 @@ -- | Types of things a user wants to do to tables/views/procs data Action = ActionCreate | ActionRead | ActionUpdate | ActionDelete- | ActionInfo | ActionInvoke+ | ActionInfo | ActionInvoke{isReadOnly :: Bool} | ActionInspect deriving Eq -- | The target db object of a user action@@ -100,12 +102,14 @@ , iHeaders :: [(Text, Text)] -- | Request Cookies , iCookies :: [(Text, Text)]+ -- | Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)+ , iRpcQParams :: [(Text, Text)] } -- | Examines HTTP request and translates it into user intent. userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError ApiRequest userApiRequest schema req reqBody- | isTargetingProc && method /= "POST" = Left ActionInappropriate+ | isTargetingProc && method `notElem` ["GET", "POST"] = Left ActionInappropriate | topLevelRange == emptyRange = Left InvalidRange | shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) undefined payload | otherwise = Right ApiRequest {@@ -118,7 +122,8 @@ , iPreferRepresentation = representation , iPreferSingleObjectParameter = singleObject , iPreferCount = hasPrefer "count=exact"- , iFilters = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset", "and", "or"] k) ]+ , iFilters = filters+ , iRpcQParams = rpcQParams , iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ] , iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams , iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]@@ -132,6 +137,15 @@ , iCookies = fromMaybe [] $ parseCookiesText <$> lookupHeader "Cookie" } where+ (filters, rpcQParams) =+ case action of+ ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts+ _ -> (flts, [])+ flts = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset", "and", "or"] k) ]+ hasOperator val = any (`T.isPrefixOf` val) $+ ((<> ".") <$> "not":M.keys operators) +++ ((<> "(") <$> M.keys ftsOperators)+ isEmbedPath = T.isInfixOf "." isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path payload = case decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type" of@@ -150,17 +164,19 @@ ct -> Left $ toS $ "Content-Type not acceptable: " <> toMime ct topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges- action = case method of- "GET" -> if target == TargetRoot- then ActionInspect- else ActionRead- "POST" -> if isTargetingProc- then ActionInvoke- else ActionCreate- "PATCH" -> ActionUpdate- "DELETE" -> ActionDelete- "OPTIONS" -> ActionInfo- _ -> ActionInspect+ action =+ case method of+ "GET" | target == TargetRoot -> ActionInspect+ | isTargetingProc -> ActionInvoke{isReadOnly=True}+ | otherwise -> ActionRead++ "POST" -> if isTargetingProc+ then ActionInvoke{isReadOnly=False}+ else ActionCreate+ "PATCH" -> ActionUpdate+ "DELETE" -> ActionDelete+ "OPTIONS" -> ActionInfo+ _ -> ActionInspect target = case path of [] -> TargetRoot [table] -> TargetIdent@@ -168,10 +184,10 @@ ["rpc", proc] -> TargetProc $ QualifiedIdentifier schema proc other -> TargetUnknown other- shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionInvoke]- relevantPayload = if shouldParsePayload- then rightToMaybe payload- else Nothing+ shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionInvoke{isReadOnly=False}]+ relevantPayload | action == ActionInvoke{isReadOnly=True} = Nothing+ | shouldParsePayload = rightToMaybe payload+ | otherwise = Nothing path = pathInfo req method = requestMethod req hdrs = requestHeaders req
src/PostgREST/App.hs view
@@ -6,6 +6,7 @@ ) where import Control.Applicative+import Data.Aeson (toJSON, eitherDecode) import qualified Data.ByteString.Char8 as BS import Data.Maybe import Data.IORef (IORef, readIORef)@@ -37,12 +38,13 @@ import PostgREST.DbStructure import PostgREST.DbRequestBuilder( readRequest , mutateRequest+ , readRpcRequest , fieldNames ) import PostgREST.Error ( simpleError, pgError , apiRequestError , singularityError, binaryFieldError- , connectionLostError+ , connectionLostError, gucHeadersError ) import PostgREST.RangeQuery (allRange, rangeOffset) import PostgREST.Middleware@@ -74,7 +76,7 @@ response <- case userApiRequest (configSchema conf) req body of Left err -> return $ apiRequestError err Right apiRequest -> do- eClaims <- jwtClaims jwtSecret (toS $ iJWT apiRequest)+ eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) let authed = containsRole eClaims handleReq = runWithClaims conf eClaims (app dbStructure conf) apiRequest@@ -82,19 +84,16 @@ (iTarget apiRequest) (iAction apiRequest) response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq return $ either (pgError authed) identity response- when (isResponse503 response) worker+ when (responseStatus response == status503) worker respond response -isResponse503 :: Response -> Bool-isResponse503 resp = statusCode (responseStatus resp) == 503- transactionMode :: DbStructure -> Target -> Action -> H.Mode transactionMode structure target action = case action of ActionRead -> HT.Read ActionInfo -> HT.Read ActionInspect -> HT.Read- ActionInvoke ->+ ActionInvoke{isReadOnly=False} -> let proc = case target of (TargetProc qi) -> M.lookup (qiName qi) $@@ -104,6 +103,7 @@ if v == Stable || v == Immutable then HT.Read else HT.Write+ ActionInvoke{isReadOnly=True} -> HT.Read _ -> HT.Write app :: DbStructure -> AppConfig -> ApiRequest -> H.Transaction Response@@ -227,7 +227,7 @@ let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in return $ responseLBS status200 [allOrigins, acceptH] "" - (ActionInvoke, TargetProc qi, Just (PayloadJSON payload)) ->+ (ActionInvoke _isReadOnly, TargetProc qi, payload) -> let proc = M.lookup (qiName qi) allProcs returnsScalar = case proc of Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True@@ -235,26 +235,33 @@ rpcBinaryField = if returnsScalar then Right Nothing else binaryField contentType =<< fldNames- partsField = (,) <$> readSqlParts <*> rpcBinaryField in- case partsField of+ parts = (,,) <$> readSqlParts <*> rpcBinaryField <*> rpcQParams in+ case parts of Left errorResponse -> return errorResponse- Right ((q, cq), bField) -> do- let p = V.head payload+ Right ((q, cq), bField, params) -> do+ let prms = case payload of+ Just (PayloadJSON pld) -> V.head pld+ Nothing -> M.fromList $ second toJSON <$> params -- toJSON is just for reusing the callProc function singular = contentType == CTSingularJSON paramsAsSingleObject = iPreferSingleObjectParameter apiRequest row <- H.query () $- callProc qi p returnsScalar q cq topLevelRange shouldCount+ callProc qi prms returnsScalar q cq shouldCount singular paramsAsSingleObject (contentType == CTTextCSV)- (contentType == CTOctetStream) bField- let (tableTotal, queryTotal, body) =- fromMaybe (Just 0, 0, "[]") row+ (contentType == CTOctetStream) _isReadOnly bField+ (pgVersion dbStructure)+ let (tableTotal, queryTotal, body, jsonHeaders) =+ fromMaybe (Just 0, 0, "[]", "[]") row (status, contentRange) = rangeHeader queryTotal tableTotal- if singular && queryTotal /= 1- then do- HT.condemn- return $ singularityError (toInteger queryTotal)- else return $ responseLBS status [toHeader contentType, contentRange] (toS body)+ decodedHeaders = first toS $ eitherDecode $ toS jsonHeaders :: Either Text [GucHeader]+ case decodedHeaders of+ Left _ -> return gucHeadersError+ Right hs ->+ if singular && queryTotal /= 1+ then do+ HT.condemn+ return $ singularityError (toInteger queryTotal)+ else return $ responseLBS status ([toHeader contentType, contentRange] ++ toHeaders hs) (toS body) (ActionInspect, TargetRoot, Nothing) -> do let host = configHost conf@@ -263,8 +270,8 @@ uri Nothing = ("http", host, port, "/") uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b) uri' = uri proxy- encodeApi ti sd = encodeOpenAPI (M.elems allProcs) (toTableInfo ti) uri' sd (dbPrimaryKeys dbStructure)- body <- encodeApi <$> H.query schema accessibleTables <*> H.query schema schemaDescription+ encodeApi ti sd procs = encodeOpenAPI (M.elems procs) (toTableInfo ti) uri' sd (dbPrimaryKeys dbStructure)+ body <- encodeApi <$> H.query schema accessibleTables <*> H.query schema schemaDescription <*> H.query schema accessibleProcs return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body _ -> return notFound@@ -298,6 +305,7 @@ fldNames = fieldNames <$> readReq readDbRequest = DbRead <$> readReq mutateDbRequest = DbMutate <$> (mutateRequest apiRequest =<< fldNames)+ rpcQParams = readRpcRequest apiRequest selectQuery = requestToQuery schema False <$> readDbRequest mutateQuery = requestToQuery schema False <$> mutateDbRequest countQuery = requestToCountQuery schema <$> readDbRequest@@ -313,7 +321,7 @@ ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]- ActionInvoke -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream]+ ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream] ActionInspect -> [CTOpenAPI, CTApplicationJSON] ActionInfo -> [CTTextCSV] serves sProduces cAccepts =
src/PostgREST/Auth.hs view
@@ -18,16 +18,12 @@ , parseJWK ) where -import Protolude hiding ((&))-import Control.Lens-import Data.Aeson (Value (..), decode, toJSON)-import qualified Data.ByteString.Lazy as BL-import qualified Data.HashMap.Strict as M+import Control.Lens.Operators+import Data.Aeson (Value (..), decode, toJSON)+import qualified Data.HashMap.Strict as M+import Protolude -import Crypto.JOSE.Compact-import Crypto.JOSE.JWK-import Crypto.JOSE.JWS-import Crypto.JOSE.Types+import qualified Crypto.JOSE.Types as JOSE.Types import Crypto.JWT {-|@@ -39,30 +35,29 @@ deriving (Eq, Show) {-|- Receives the JWT secret (from config) and a JWT and returns a map+ Receives the JWT secret and audience (from config) and a JWT and returns a map of JWT claims. -}-jwtClaims :: Maybe JWK -> BL.ByteString -> IO JWTAttempt-jwtClaims _ "" = return $ JWTClaims M.empty-jwtClaims secret payload =+jwtClaims :: Maybe JWK -> Maybe StringOrURI -> LByteString -> IO JWTAttempt+jwtClaims _ _ "" = return $ JWTClaims M.empty+jwtClaims secret audience payload = case secret of Nothing -> return JWTMissingSecret- Just jwk -> do- let validation = defaultJWTValidationSettings+ Just s -> do+ let validation = defaultJWTValidationSettings (maybe (const True) (==) audience) eJwt <- runExceptT $ do jwt <- decodeCompact payload- validateJWSJWT validation jwk jwt- return jwt+ verifyClaims validation s jwt return $ case eJwt of- Left e -> JWTInvalid e- Right jwt -> JWTClaims . claims2map . jwtClaimsSet $ jwt+ Left e -> JWTInvalid e+ Right jwt -> JWTClaims . claims2map $ jwt {-| Whether a response from jwtClaims contains a role claim -} containsRole :: JWTAttempt -> Bool containsRole (JWTClaims claims) = M.member "role" claims-containsRole _ = False+containsRole _ = False {-| Internal helper used to turn JWT ClaimSet into something@@ -72,7 +67,7 @@ claims2map = val2map . toJSON where val2map (Object o) = o- val2map _ = M.empty+ val2map _ = M.empty parseJWK :: ByteString -> JWK parseJWK str =@@ -89,4 +84,4 @@ & jwkUse .~ Just Sig & jwkAlg .~ (Just $ JWSAlg HS256) where- km = OctKeyMaterial (OctKeyParameters Oct (Base64Octets key))+ km = OctKeyMaterial (OctKeyParameters (JOSE.Types.Base64Octets key))
src/PostgREST/Config.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE LambdaCase, TemplateHaskell #-} {-# OPTIONS_GHC -fno-warn-type-defaults #-} {-| Module : PostgREST.Config@@ -14,38 +15,49 @@ Other hardcoded options such as the minimum version number also belong here. -} module PostgREST.Config ( prettyVersion+ , docsVersion , readOptions , corsPolicy , minimumPgVersion- , PgVersion (..)+ , pgVersion96 , AppConfig (..) ) where -import System.IO.Error (IOError)+import PostgREST.Types (PgVersion(..)) import Control.Applicative-import qualified Data.ByteString as B-import qualified Data.ByteString.Char8 as BS-import qualified Data.CaseInsensitive as CI-import qualified Data.Configurator as C-import qualified Data.Configurator.Parser as C-import Data.Configurator.Types (Value(..))-import Data.List (lookup)+import Control.Monad (fail)+import Control.Lens (preview)+import Crypto.JWT (StringOrURI,+ stringOrUri)+import qualified Data.ByteString as B+import qualified Data.ByteString.Char8 as BS+import qualified Data.CaseInsensitive as CI+import qualified Data.Configurator as C+import qualified Data.Configurator.Parser as C+import Data.Configurator.Types as C+import Data.List (lookup) import Data.Monoid-import Data.Scientific (floatingOrInteger)-import Data.Text (strip, intercalate, lines)-import Data.Text.Encoding (encodeUtf8)-import Data.Text.IO (hPutStrLn)-import Data.Version (versionBranch)+import Data.Scientific (floatingOrInteger)+import Data.String (String)+import Data.Text (dropAround,+ intercalate, lines,+ strip, take)+import Data.Text.Encoding (encodeUtf8)+import Data.Text.IO (hPutStrLn)+import Data.Version (versionBranch)+import Development.GitRev (gitHash) import Network.Wai-import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))-import Options.Applicative hiding (str)-import Paths_postgrest (version)-import System.IO (hPrint)+import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))+import Options.Applicative hiding (str)+import Paths_postgrest (version)+import Protolude hiding (hPutStrLn, take,+ intercalate, (<>))+import System.IO (hPrint)+import System.IO.Error (IOError) import Text.Heredoc-import Text.PrettyPrint.ANSI.Leijen hiding ((<>), (<$>))+import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>)) import qualified Text.PrettyPrint.ANSI.Leijen as L-import Protolude hiding (intercalate, (<>), hPutStrLn) -- | Config file settings for the server data AppConfig = AppConfig {@@ -58,6 +70,7 @@ , configJwtSecret :: Maybe B.ByteString , configJwtSecretIsBase64 :: Bool+ , configJwtAudience :: Maybe StringOrURI , configPool :: Int , configMaxRows :: Maybe Integer@@ -90,8 +103,14 @@ -- | User friendly version number prettyVersion :: Text-prettyVersion = intercalate "." $ map show $ versionBranch version+prettyVersion =+ intercalate "." (map show $ versionBranch version)+ <> " (" <> take 7 $(gitHash) <> ")" +-- | Version number used in docs+docsVersion :: Text+docsVersion = "v" <> dropAround (== '.') (dropAround (/= '.') prettyVersion)+ -- | Function to read and parse options from the command line readOptions :: IO AppConfig readOptions = do@@ -102,16 +121,17 @@ (C.readConfig =<< C.load [C.Required cfgPath]) configNotfoundHint - let (mAppConf, errs) = flip C.runParserA conf $- AppConfig <$>- C.key "db-uri"+ let (mAppConf, errs) = flip C.runParserM conf $+ AppConfig+ <$> C.key "db-uri" <*> C.key "db-anon-role"- <*> C.key "server-proxy-uri"+ <*> (mfilter (/= "") <$> C.key "server-proxy-uri") <*> C.key "db-schema" <*> (fromMaybe "*4" . mfilter (/= "") <$> C.key "server-host") <*> (fromMaybe 3000 . join . fmap coerceInt <$> C.key "server-port") <*> (fmap encodeUtf8 . mfilter (/= "") <$> C.key "jwt-secret") <*> (fromMaybe False . join . fmap coerceBool <$> C.key "secret-is-base64")+ <*> parseJwtAudience "jwt-aud" <*> (fromMaybe 10 . join . fmap coerceInt <$> C.key "db-pool") <*> (join . fmap coerceInt <$> C.key "max-rows") <*> (mfilter (/= "") <$> C.key "pre-request")@@ -124,73 +144,81 @@ Just appConf -> return appConf - where- coerceInt :: (Read i, Integral i) => Value -> Maybe i- coerceInt (Number x) = rightToMaybe $ floatingOrInteger x- coerceInt (String x) = readMaybe $ toS x- coerceInt _ = Nothing+ where+ parseJwtAudience :: Name -> C.ConfigParserM (Maybe StringOrURI)+ parseJwtAudience k =+ C.key k >>= \case+ Nothing -> pure Nothing -- no audience in config file+ Just aud -> case preview stringOrUri (aud :: String) of+ Nothing -> fail "Invalid Jwt audience. Check your configuration."+ (Just "") -> pure Nothing+ aud' -> pure aud' - coerceBool :: Value -> Maybe Bool- coerceBool (Bool b) = Just b- coerceBool (String x) = readMaybe $ toS x- coerceBool _ = Nothing+ coerceInt :: (Read i, Integral i) => Value -> Maybe i+ coerceInt (Number x) = rightToMaybe $ floatingOrInteger x+ coerceInt (String x) = readMaybe $ toS x+ coerceInt _ = Nothing - opts = info (helper <*> pathParser) $- fullDesc- <> progDesc (- "PostgREST "- <> toS prettyVersion- <> " / create a REST API to an existing Postgres database"- )- <> footerDoc (Just $- text "Example Config File:"- L.<> nest 2 (hardline L.<> exampleCfg)- )+ coerceBool :: Value -> Maybe Bool+ coerceBool (Bool b) = Just b+ coerceBool (String x) = readMaybe $ toS x+ coerceBool _ = Nothing - parserPrefs = prefs showHelpOnError+ opts = info (helper <*> pathParser) $+ fullDesc+ <> progDesc (+ "PostgREST "+ <> toS prettyVersion+ <> " / create a REST API to an existing Postgres database"+ )+ <> footerDoc (Just $+ text "Example Config File:"+ L.<> nest 2 (hardline L.<> exampleCfg)+ ) - configNotfoundHint :: IOError -> IO a- configNotfoundHint e = do- hPutStrLn stderr $- "Cannot open config file:\n\t" <> show e- exitFailure+ parserPrefs = prefs showHelpOnError - exampleCfg :: Doc- exampleCfg = vsep . map (text . toS) . lines $- [str|db-uri = "postgres://user:pass@localhost:5432/dbname"- |db-schema = "public"- |db-anon-role = "postgres"- |db-pool = 10- |- |server-host = "*4"- |server-port = 3000- |- |## base url for swagger output- |# server-proxy-uri = ""- |- |## choose a secret to enable JWT auth- |## (use "@filename" to load from separate file)- |# jwt-secret = "foo"- |# secret-is-base64 = false- |- |## limit rows in response- |# max-rows = 1000- |- |## stored proc to exec immediately after auth- |# pre-request = "stored_proc_name"- |]+ configNotfoundHint :: IOError -> IO a+ configNotfoundHint e = do+ hPutStrLn stderr $+ "Cannot open config file:\n\t" <> show e+ exitFailure + exampleCfg :: Doc+ exampleCfg = vsep . map (text . toS) . lines $+ [str|db-uri = "postgres://user:pass@localhost:5432/dbname"+ |db-schema = "public"+ |db-anon-role = "postgres"+ |db-pool = 10+ |+ |server-host = "*4"+ |server-port = 3000+ |+ |## base url for swagger output+ |# server-proxy-uri = ""+ |+ |## choose a secret to enable JWT auth+ |## (use "@filename" to load from separate file)+ |# jwt-secret = "foo"+ |# secret-is-base64 = false+ |# jwt-aud = "your_audience_claim"+ |+ |## limit rows in response+ |# max-rows = 1000+ |+ |## stored proc to exec immediately after auth+ |# pre-request = "stored_proc_name"+ |]+ pathParser :: Parser FilePath pathParser = strArgument $ metavar "FILENAME" <> help "Path to configuration file" -data PgVersion = PgVersion {- pgvNum :: Int32-, pgvName :: Text-}- -- | Tells the minimum PostgreSQL version required by this version of PostgREST minimumPgVersion :: PgVersion minimumPgVersion = PgVersion 90300 "9.3"++pgVersion96 :: PgVersion+pgVersion96 = PgVersion 90600 "9.6"
src/PostgREST/DbRequestBuilder.hs view
@@ -3,6 +3,7 @@ module PostgREST.DbRequestBuilder ( readRequest , mutateRequest+, readRpcRequest , fieldNames ) where @@ -30,7 +31,6 @@ import PostgREST.Error (apiRequestError) import PostgREST.Parsers import PostgREST.RangeQuery (NonnegRange, restrictRange)-import PostgREST.QueryBuilder (getJoinFilters, sourceCTEName) import PostgREST.Types import Protolude hiding (from, dropWhile, drop)@@ -72,10 +72,10 @@ relations :: [Relation] relations = case action of- ActionCreate -> fakeSourceRelations ++ allRels- ActionUpdate -> fakeSourceRelations ++ allRels- ActionDelete -> fakeSourceRelations ++ allRels- ActionInvoke -> fakeSourceRelations ++ allRels+ ActionCreate -> fakeSourceRelations ++ allRels+ ActionUpdate -> fakeSourceRelations ++ allRels+ ActionDelete -> fakeSourceRelations ++ allRels+ ActionInvoke _ -> fakeSourceRelations ++ allRels _ -> allRels where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation @@ -193,7 +193,7 @@ addJoinFilters schema (Node node@(query, nodeProps@(_, relation, _, _)) forest) = case relation of Just Relation{relType=Root} -> Node node <$> updatedForest -- this is the root node- Just Relation{relType=Parent} -> Node node <$> updatedForest+ Just rel@Relation{relType=Parent} -> Node (augmentQuery rel, nodeProps) <$> updatedForest Just rel@Relation{relType=Child} -> Node (augmentQuery rel, nodeProps) <$> updatedForest Just rel@Relation{relType=Many, relLTable=(Just linkTable)} -> let rq = augmentQuery rel in@@ -204,6 +204,21 @@ augmentQuery rel = foldr addFilterToReadQuery query (getJoinFilters rel) addFilterToReadQuery flt rq@Select{where_=lf} = rq{where_=addFilterToLogicForest flt lf}::ReadQuery +getJoinFilters :: Relation -> [Filter]+getJoinFilters (Relation t cols ft fcs typ lt lc1 lc2) =+ case typ of+ Child -> zipWith (toFilter tN ftN) cols fcs+ Parent -> zipWith (toFilter tN ftN) cols fcs+ Many -> zipWith (toFilter tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toFilter ftN ltN) fcs (fromMaybe [] lc2)+ Root -> undefined --error "undefined getJoinFilters"+ where+ s = if typ == Parent then "" else tableSchema t+ tN = tableName t+ ftN = tableName ft+ ltN = fromMaybe "" (tableName <$> lt)+ toFilter :: Text -> Text -> Column -> Column -> Filter+ toFilter tb ftb c fc = Filter (colName c, Nothing) (OpExpr False (Join (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}})))+ addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest) addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [ flip (foldr addFilter) <$> filters,@@ -222,9 +237,11 @@ logicForest = mapM pRequestLogicTree logFrst action = iAction apiRequest -- there can be no filters on the root table when we are doing insert/update/delete- (flts, logFrst)- | action == ActionRead || action == ActionInvoke = (iFilters apiRequest, iLogic apiRequest)- | otherwise = join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)+ (flts, logFrst) =+ case action of+ ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)+ ActionRead -> (iFilters apiRequest, iLogic apiRequest)+ _ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest) orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])] orders = mapM pRequestOrder $ iOrder apiRequest ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]@@ -309,6 +326,11 @@ -- update/delete filters can be only on the root table (mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest) onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)++readRpcRequest :: ApiRequest -> Either Response [RpcQParam]+readRpcRequest apiRequest = mapLeft apiRequestError rpcQParams+ where+ rpcQParams = mapM pRequestRpcQParam $ iRpcQParams apiRequest fieldNames :: ReadRequest -> [FieldName] fieldNames (Node (sel, _) forest) =
src/PostgREST/DbStructure.hs view
@@ -6,7 +6,9 @@ module PostgREST.DbStructure ( getDbStructure , accessibleTables+, accessibleProcs , schemaDescription+, getPgVersion ) where import qualified Hasql.Decoders as HD@@ -18,7 +20,7 @@ import Data.List (elemIndex) import Data.Maybe (fromJust) import Data.Text (split, strip,- breakOn, dropAround)+ breakOn, dropAround, splitOn) import qualified Data.Text as T import qualified Hasql.Session as H import PostgREST.Types@@ -28,14 +30,14 @@ import Protolude import Unsafe (unsafeHead) -getDbStructure :: Schema -> H.Session DbStructure-getDbStructure schema = do+getDbStructure :: Schema -> PgVersion -> H.Session DbStructure+getDbStructure schema pgVer = do tabs <- H.query () allTables- cols <- H.query () $ allColumns tabs+ cols <- H.query schema $ allColumns tabs syns <- H.query () $ allSynonyms cols rels <- H.query () $ allRelations tabs cols keys <- H.query () $ allPrimaryKeys tabs- procs <- H.query schema accessibleProcs+ procs <- H.query schema allProcs let rels' = (addManyToManyRelations . raiseRelations schema syns . addParentRelations . addSynonymousRelations syns) rels cols' = addForeignKeys rels' cols@@ -47,6 +49,7 @@ , dbRelations = rels' , dbPrimaryKeys = keys' , dbProcs = procs+ , pgVersion = pgVer } decodeTables :: HD.Result [Table]@@ -100,59 +103,65 @@ <*> HD.value HD.text <*> HD.value HD.text <*> HD.value HD.text <*> HD.value HD.text -accessibleProcs :: H.Query Schema (M.HashMap Text ProcDescription)-accessibleProcs =- H.statement sql (HE.value HE.text)- (M.fromList . map addName <$>- HD.rowsList (- ProcDescription <$> HD.value HD.text- <*> HD.nullableValue HD.text- <*> (parseArgs <$> HD.value HD.text)- <*> (parseRetType <$>- HD.value HD.text <*>- HD.value HD.text <*>- HD.value HD.bool <*>- HD.value HD.char)- <*> (parseVolatility <$>- HD.value HD.char)- )- ) True- where- addName :: ProcDescription -> (Text, ProcDescription)- addName pd = (pdName pd, pd)+decodeProcs :: HD.Result (M.HashMap Text ProcDescription)+decodeProcs =+ M.fromList . map addName <$> HD.rowsList tblRow+ where+ tblRow = ProcDescription+ <$> HD.value HD.text+ <*> HD.nullableValue HD.text+ <*> (parseArgs <$> HD.value HD.text)+ <*> (parseRetType+ <$> HD.value HD.text+ <*> HD.value HD.text+ <*> HD.value HD.bool+ <*> HD.value HD.char)+ <*> (parseVolatility <$> HD.value HD.char) - parseArgs :: Text -> [PgArg]- parseArgs = mapMaybe (parseArg . strip) . split (==',')+ addName :: ProcDescription -> (Text, ProcDescription)+ addName pd = (pdName pd, pd) - parseArg :: Text -> Maybe PgArg- parseArg a =- let (body, def) = breakOn " DEFAULT " a- (name, typ) = breakOn " " body in- if T.null typ- then Nothing- else Just $- PgArg (dropAround (== '"') name) (strip typ) (T.null def)+ parseArgs :: Text -> [PgArg]+ parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',') - parseRetType :: Text -> Text -> Bool -> Char -> RetType- parseRetType schema name isSetOf typ- | isSetOf = SetOf pgType- | otherwise = Single pgType- where- qi = QualifiedIdentifier schema name- pgType = case typ of- 'c' -> Composite qi- 'p' -> if name == "record" -- Only pg pseudo type that is a row type is 'record'- then Composite qi- else Scalar qi- _ -> Scalar qi -- 'b'ase, 'd'omain, 'e'num, 'r'ange+ parseArg :: Text -> Maybe PgArg+ parseArg a =+ let arg = lastDef "" $ splitOn "INOUT " a+ (body, def) = breakOn " DEFAULT " arg+ (name, typ) = breakOn " " body in+ if T.null typ+ then Nothing+ else Just $+ PgArg (dropAround (== '"') name) (strip typ) (T.null def) - parseVolatility :: Char -> ProcVolatility- parseVolatility 'i' = Immutable- parseVolatility 's' = Stable- parseVolatility 'v' = Volatile- parseVolatility _ = Volatile -- should not happen, but be pessimistic+ parseRetType :: Text -> Text -> Bool -> Char -> RetType+ parseRetType schema name isSetOf typ+ | isSetOf = SetOf pgType+ | otherwise = Single pgType+ where+ qi = QualifiedIdentifier schema name+ pgType = case typ of+ 'c' -> Composite qi+ 'p' -> if name == "record" -- Only pg pseudo type that is a row type is 'record'+ then Composite qi+ else Scalar qi+ _ -> Scalar qi -- 'b'ase, 'd'omain, 'e'num, 'r'ange - sql = [q|+ parseVolatility :: Char -> ProcVolatility+ parseVolatility v | v == 'i' = Immutable+ | v == 's' = Stable+ | otherwise = Volatile -- only 'v' can happen here++allProcs :: H.Query Schema (M.HashMap Text ProcDescription)+allProcs = H.statement (toS procsSqlQuery) (HE.value HE.text) decodeProcs True++accessibleProcs :: H.Query Schema (M.HashMap Text ProcDescription)+accessibleProcs = H.statement (toS sql) (HE.value HE.text) decodeProcs True+ where+ sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')"++procsSqlQuery :: SqlQuery+procsSqlQuery = [q| SELECT p.proname as "proc_name", d.description as "proc_description", pg_get_function_arguments(p.oid) as "args",@@ -167,11 +176,12 @@ JOIN pg_namespace tn ON tn.oid = t.typnamespace LEFT JOIN pg_class comp ON comp.oid = t.typrelid LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid- WHERE pn.nspname = $1|]+ WHERE pn.nspname = $1+|] schemaDescription :: H.Query Schema (Maybe Text) schemaDescription =- H.statement sql (HE.value HE.text) (HD.singleRow $ HD.nullableValue HD.text) True+ H.statement sql (HE.value HE.text) (join <$> HD.maybeRow (HD.nullableValue HD.text)) True where sql = [q| select@@ -311,9 +321,9 @@ GROUP BY table_schema, table_name, insertable ORDER BY table_schema, table_name |] -allColumns :: [Table] -> H.Query () [Column]+allColumns :: [Table] -> H.Query Schema [Column] allColumns tabs =- H.statement sql HE.unit (decodeColumns tabs) True+ H.statement sql (HE.value HE.text) (decodeColumns tabs) True where sql = [q| SELECT DISTINCT@@ -331,9 +341,35 @@ array_to_string(enum_info.vals, ',') AS enum FROM ( /*- -- CTE based on information_schema.columns to remove the owner filter+ -- CTE based on pg_catalog to get only Primary and Foreign key columns outside api schema */- WITH columns AS (+ WITH key_columns AS (+ SELECT+ r.oid AS r_oid,+ c.oid AS c_oid,+ n.nspname,+ c.relname,+ r.conname,+ r.contype,+ unnest(r.conkey) AS conkey+ FROM+ pg_catalog.pg_constraint r,+ pg_catalog.pg_class c,+ pg_catalog.pg_namespace n+ WHERE+ r.contype IN ('f', 'p')+ AND c.relkind IN ('r', 'v', 'f', 'mv')+ AND r.conrelid = c.oid+ AND c.relnamespace = n.oid+ AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1)+ ),+ /*+ -- CTE based on information_schema.columns+ -- changed:+ -- remove the owner filter+ -- limit columns to the ones in the api schema or PK/FK columns+ */+ columns AS ( SELECT current_database()::information_schema.sql_identifier AS table_catalog, nc.nspname::information_schema.sql_identifier AS table_schema, c.relname::information_schema.sql_identifier AS table_name,@@ -411,6 +447,7 @@ ELSE 'NO'::text END::information_schema.yes_or_no AS is_updatable FROM pg_attribute a+ LEFT JOIN key_columns kc ON kc.conkey = a.attnum AND kc.c_oid = a.attrelid LEFT JOIN pg_catalog.pg_description AS d ON d.objoid = a.attrelid and d.objsubid = a.attnum LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum JOIN (pg_class c@@ -421,7 +458,12 @@ JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid) ON t.typtype = 'd'::"char" AND t.typbasetype = bt.oid LEFT JOIN (pg_collation co JOIN pg_namespace nco ON co.collnamespace = nco.oid) ON a.attcollation = co.oid AND (nco.nspname <> 'pg_catalog'::name OR co.collname <> 'default'::name)- WHERE NOT pg_is_other_temp_schema(nc.oid) AND a.attnum > 0 AND NOT a.attisdropped AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char"]))+ WHERE+ NOT pg_is_other_temp_schema(nc.oid)+ AND a.attnum > 0+ AND NOT a.attisdropped+ AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char"]))+ AND (nc.nspname = $1 OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */ /*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/ ) SELECT@@ -698,3 +740,9 @@ col1 = findCol s1 t1 c1 col2 = findCol s2 t2 c2 findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols++getPgVersion :: H.Session PgVersion+getPgVersion = H.query () $ H.statement sql HE.unit versionRow False+ where+ sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')"+ versionRow = HD.singleRow $ PgVersion <$> HD.value HD.int4 <*> HD.value HD.text
src/PostgREST/Error.hs view
@@ -10,6 +10,7 @@ , binaryFieldError , connectionLostError , encodeError+, gucHeadersError ) where import Protolude@@ -22,6 +23,7 @@ import qualified Network.HTTP.Types.Status as HT import Network.Wai (Response, responseLBS) import PostgREST.Types+import Text.Read (readMaybe) apiRequestError :: ApiRequestError -> Response apiRequestError err =@@ -78,6 +80,11 @@ simpleError HT.status406 [] (toS (toMime CTOctetStream) <> " requested but a single column was not selected") +gucHeadersError :: Response+gucHeadersError =+ simpleError HT.status500 []+ "response.headers guc must be a JSON array composed of objects with a single key and a string value"+ connectionLostError :: Response connectionLostError = simpleError HT.status503 [] "Database connection lost, retrying the connection."@@ -109,11 +116,17 @@ toJSON (P.SessionError e) = JSON.toJSON e -- H.Error instance JSON.ToJSON H.Error where- toJSON (H.ResultError (H.ServerError c m d h)) = JSON.object [- "code" .= (toS c::Text),- "message" .= (toS m::Text),- "details" .= (fmap toS d::Maybe Text),- "hint" .= (fmap toS h::Maybe Text)]+ toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of+ 'P':'T':_ ->+ JSON.object [+ "details" .= (fmap toS d::Maybe Text),+ "hint" .= (fmap toS h::Maybe Text)]+ _ ->+ JSON.object [+ "code" .= (toS c::Text),+ "message" .= (toS m::Text),+ "details" .= (fmap toS d::Maybe Text),+ "hint" .= (fmap toS h::Maybe Text)] toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [ "message" .= (m::Text)] toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [@@ -138,7 +151,7 @@ httpStatus :: Bool -> P.UsageError -> HT.Status httpStatus _ (P.ConnectionError _) = HT.status503-httpStatus authed (P.SessionError (H.ResultError (H.ServerError c _ _ _))) =+httpStatus authed (P.SessionError (H.ResultError (H.ServerError c m _ _))) = case toS c of '0':'8':_ -> HT.status503 -- pg connection err '0':'9':_ -> HT.status500 -- triggered action exception@@ -166,6 +179,7 @@ "42883" -> HT.status404 -- undefined function "42P01" -> HT.status404 -- undefined table "42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege+ 'P':'T':n -> fromMaybe HT.status500 (HT.mkStatus <$> readMaybe n <*> pure m) _ -> HT.status400 httpStatus _ (P.SessionError (H.ResultError _)) = HT.status500 httpStatus _ (P.SessionError (H.ClientError _)) = HT.status503
src/PostgREST/OpenAPI.hs view
@@ -22,7 +22,7 @@ import Data.Swagger import PostgREST.ApiRequest (ContentType(..))-import PostgREST.Config (prettyVersion)+import PostgREST.Config (prettyVersion, docsVersion) import PostgREST.Types (Table(..), Column(..), PgArg(..), ForeignKey(..), PrimaryKey(..), Proxy(..), ProcDescription(..), toMime) @@ -260,7 +260,7 @@ & description ?~ d) & externalDocs ?~ ((mempty :: ExternalDocs) & description ?~ "PostgREST Documentation"- & url .~ URL "https://postgrest.com/en/latest/api.html")+ & url .~ URL ("https://postgrest.com/en/" <> docsVersion <> "/api.html")) & host .~ h' & definitions .~ fromList (map (makeTableDef pks) ti) & parameters .~ fromList (makeParamDefs ti)
src/PostgREST/Parsers.hs view
@@ -1,6 +1,6 @@ module PostgREST.Parsers where -import Protolude hiding (try, intercalate, replace)+import Protolude hiding (try, intercalate, replace, option) import Control.Monad ((>>)) import Data.Foldable (foldl1) import qualified Data.HashMap.Strict as M@@ -21,7 +21,7 @@ pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper) where treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k- oper = parse (pOperation pVText pVTextL) ("failed to parse filter (" ++ toS v ++ ")") $ toS v+ oper = parse (pOpExpr pSingleVal pListVal) ("failed to parse filter (" ++ toS v ++ ")") $ toS v path = fst <$> treePath fld = snd <$> treePath @@ -44,9 +44,15 @@ path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k embedPath = fst <$> path op = snd <$> path- -- Concat op and v to make pLogicTree argument regular, in the form of "op(.,.)"+ -- Concat op and v to make pLogicTree argument regular, in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)" logicTree = join $ parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") . toS <$> ((<>) <$> op <*> pure v) +pRequestRpcQParam :: (Text, Text) -> Either ApiRequestError RpcQParam+pRequestRpcQParam (k, v) = mapError $ (,) <$> name <*> val+ where+ name = parse pFieldName ("failed to parse rpc arg name (" ++ toS k ++ ")") $ toS k+ val = toS <$> parse (many anyChar) ("failed to parse rpc arg value (" ++ toS v ++ ")") v+ ws :: Parser Text ws = toS <$> many (oneOf " \t") @@ -78,7 +84,7 @@ pFieldForest = pFieldTree `sepBy1` lexeme (char ',') pFieldTree :: Parser (Tree SelectItem)-pFieldTree = try (Node <$> pRelationSelect <*> between (char '{') (char '}') pFieldForest)+pFieldTree = try (Node <$> pRelationSelect <*> between (char '{') (char '}') pFieldForest) -- TODO: "{}" deprecated <|> try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) <|> Node <$> pFieldSelect <*> pure [] @@ -96,7 +102,6 @@ dash :: Parser Char dash = isDash *> pure '-' - pJsonPathStep :: Parser Text pJsonPathStep = toS <$> try (string "->" *> pFieldName) @@ -131,27 +136,34 @@ s <- pStar return ((s, Nothing), Nothing, Nothing, Nothing) -pOperation :: Parser Operand -> Parser Operand -> Parser Operation-pOperation parserVText parserVTextL = try ( string "not" *> pDelimiter *> (Operation True <$> pExpr)) <|> Operation False <$> pExpr+pOpExpr :: Parser SingleVal -> Parser ListVal -> Parser OpExpr+pOpExpr pSVal pLVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation where- pExpr :: Parser (Operator, Operand)- pExpr =- ((,) <$> (toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys notInOps)) <*> parserVText)- <|> ((,) <$> (toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys inOps)) <*> parserVTextL)+ pOperation :: Parser Operation+ pOperation =+ Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal+ <|> In <$> (string "in" *> pDelimiter *> pLVal)+ <|> pFts <?> "operator (eq, gt, ...)"- inOps = M.filterWithKey (const . flip elem ["in", "notin"]) operators- notInOps = M.difference operators inOps -pVText :: Parser Operand-pVText = VText . toS <$> many anyChar+ pFts = do+ op <- foldl1 (<|>) (try . string . toS <$> ftsOps)+ lang <- optionMaybe $ try (between (char '(') (char ')') (many (letter <|> digit <|> oneOf "_")))+ pDelimiter >> Fts (toS op) (toS <$> lang) <$> pSVal -pVTextL :: Parser Operand-pVTextL = VTextL <$> try (lexeme (char '(') *> pVTextLElement `sepBy1` char ',' <* lexeme (char ')'))- <|> VTextL <$> lexeme pVTextLElement `sepBy1` char ','+ ops = M.filterWithKey (const . flip notElem ("in":ftsOps)) operators+ ftsOps = M.keys ftsOperators -pVTextLElement :: Parser Text-pVTextLElement = try pQuotedValue <|> (toS <$> many (noneOf ",)"))+pSingleVal :: Parser SingleVal+pSingleVal = toS <$> many anyChar +pListVal :: Parser ListVal+pListVal = try (lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')'))+ <|> lexeme pListElement `sepBy1` char ',' -- TODO: "in.3,4,5" deprecated, parens e.g. "in.(3,4,5)" should be used++pListElement :: Parser Text+pListElement = try pQuotedValue <|> (toS <$> many (noneOf ",)"))+ pQuotedValue :: Parser Text pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"' <* notFollowedBy (noneOf ",)")) @@ -182,7 +194,7 @@ <|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')')) where pLogicFilter :: Parser Filter- pLogicFilter = Filter <$> pField <* pDelimiter <*> pOperation pLogicVText pLogicVTextL+ pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal pLogicListVal pNot :: Parser Bool pNot = try (string "not" *> pDelimiter *> pure True) <|> pure False@@ -192,9 +204,10 @@ <|> string "or" *> pure Or <?> "logic operator (and, or)" -pLogicVText :: Parser Operand-pLogicVText = VText <$> (try pQuotedValue <|> try pPgArray <|> (toS <$> many (noneOf ",)")))+pLogicSingleVal :: Parser SingleVal+pLogicSingleVal = try pQuotedValue <|> try pPgArray <|> (toS <$> many (noneOf ",)")) where+ -- TODO: "{}" deprecated, after removal pPgArray can be removed pPgArray :: Parser Text pPgArray = do a <- string "{"@@ -202,8 +215,8 @@ c <- string "}" toS <$> pure (a ++ b ++ c) -pLogicVTextL :: Parser Operand-pLogicVTextL = VTextL <$> (lexeme (char '(') *> pVTextLElement `sepBy1` char ',' <* lexeme (char ')'))+pLogicListVal :: Parser ListVal+pLogicListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')') pLogicPath :: Parser (EmbedPath, Text) pLogicPath = do
src/PostgREST/QueryBuilder.hs view
@@ -14,12 +14,10 @@ callProc , createReadStatement , createWriteStatement- , getJoinFilters , pgFmtIdent , pgFmtLit , requestToQuery , requestToCountQuery- , sourceCTEName , unquoted , ResultsWithCount , pgFmtEnvVar@@ -31,6 +29,7 @@ import qualified Data.Aeson as JSON +import PostgREST.Config (pgVersion96) import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset, allRange) import Data.Functor.Contravariant (contramap) import qualified Data.HashMap.Strict as HM@@ -142,38 +141,43 @@ | wantSingle = asJsonSingleF | otherwise = asJsonF -type ProcResults = (Maybe Int64, Int64, ByteString)-callProc :: QualifiedIdentifier -> JSON.Object -> Bool -> SqlQuery -> SqlQuery -> NonnegRange ->- Bool -> Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> H.Query () (Maybe ProcResults)-callProc qi params returnsScalar selectQuery countQuery _ countTotal isSingle paramsAsJson asCsv asBinary binaryField =+type ProcResults = (Maybe Int64, Int64, ByteString, ByteString)+callProc :: QualifiedIdentifier -> JSON.Object -> Bool -> SqlQuery -> SqlQuery -> Bool ->+ Bool -> Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion -> H.Query () (Maybe ProcResults)+callProc qi params returnsScalar selectQuery countQuery countTotal isSingle paramsAsJson asCsv asBinary isReadOnly binaryField pgVer = unicodeStatement sql HE.unit decodeProc True where sql = if returnsScalar then [qc|- WITH {sourceCTEName} AS ({_callSql})+ WITH {sourceCTEName} AS (select {fromQi qi}({_args})) SELECT {countResultF} AS total_result_set, 1 AS page_total,- {scalarBodyF} as body+ {scalarBodyF} AS body,+ {responseHeaders} AS response_headers FROM ({selectQuery}) _postgrest_t;|] else [qc|- WITH {sourceCTEName} AS ({_callSql})+ WITH {sourceCTEName} AS (select * from {fromQi qi}({_args})) SELECT {countResultF} AS total_result_set, pg_catalog.count(_postgrest_t) AS page_total,- {bodyF} as body+ {bodyF} AS body,+ {responseHeaders} AS response_headers FROM ({selectQuery}) _postgrest_t;|] countResultF = if countTotal then "( "<> countQuery <> ")" else "null::bigint" :: Text- _args = if paramsAsJson+ _args = if paramsAsJson && not isReadOnly then insertableValueWithType "json" $ JSON.Object params else intercalate "," $ map _assignment (HM.toList params) _procName = qiName qi _assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v- _callSql = [qc|select * from {fromQi qi}({_args}) |] :: Text+ responseHeaders =+ if pgVer >= pgVersion96+ then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15+ else "'[]'" :: Text decodeProc = HD.maybeRow procRow- procRow = (,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8- <*> HD.value HD.bytea+ procRow = (,,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8+ <*> HD.value HD.bytea <*> HD.value HD.bytea scalarBodyF | asBinary = asBinaryF _procName | otherwise = "(row_to_json(_postgrest_t)->" <> pgFmtLit _procName <> ")::character varying"@@ -208,7 +212,7 @@ qi = removeSourceCTESchema schema mainTbl -- all foreing key filters are root nodes(see addFilterToLogicForest), only those are filtered nonFKRoot :: LogicTree -> Bool- nonFKRoot (Stmnt (Filter _ Operation{expr=(_, VForeignKey _ _)})) = False+ nonFKRoot (Stmnt (Filter _ (OpExpr _ (Join _ _)))) = False nonFKRoot (Stmnt _) = True nonFKRoot Expr{} = True filteredLogic = filter nonFKRoot logicForest@@ -245,24 +249,21 @@ getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias, _)) forst) (j,s) = (j,sel:s) where sel = "COALESCE(("- <> "SELECT array_to_json(array_agg(row_to_json("<>pgFmtIdent table<>"))) "+ <> "SELECT json_agg(" <> pgFmtIdent table <> ".*) " <> "FROM (" <> subquery <> ") " <> pgFmtIdent table <> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias) where subquery = requestToQuery schema False (DbRead (Node n forst))- getQueryParts (Node n@(_, (name, Just r@Relation{relType=Parent,relTable=Table{tableName=table}}, alias, _)) forst) (j,s) = (joi:j,sel:s)+ getQueryParts (Node n@(_, (name, Just Relation{relType=Parent,relTable=Table{tableName=table}}, alias, _)) forst) (j,s) = (joi:j,sel:s) where- node_name = fromMaybe name alias- local_table_name = table <> "_" <> node_name- replaceTableName localTableName (Filter a (Operation b (c, VForeignKey (QualifiedIdentifier "" _) d))) = Filter a (Operation b (c, VForeignKey (QualifiedIdentifier "" localTableName) d))- replaceTableName _ x = x- sel = "row_to_json(" <> pgFmtIdent local_table_name <> ".*) AS " <> pgFmtIdent node_name- joi = " LEFT OUTER JOIN ( " <> subquery <> " ) AS " <> pgFmtIdent local_table_name <>- " ON " <> intercalate " AND " ( map (pgFmtFilter qi . replaceTableName local_table_name) (getJoinFilters r) )+ aliasOrName = fromMaybe name alias+ localTableName = pgFmtIdent $ table <> "_" <> aliasOrName+ sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName+ joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE " where subquery = requestToQuery schema True (DbRead (Node n forst)) getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias, _)) forst) (j,s) = (j,sel:s) where sel = "COALESCE (("- <> "SELECT array_to_json(array_agg(row_to_json("<>pgFmtIdent table<>"))) "+ <> "SELECT json_agg(" <> pgFmtIdent table <> ".*) " <> "FROM (" <> subquery <> ") " <> pgFmtIdent table <> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias) where subquery = requestToQuery schema False (DbRead (Node n forst))@@ -309,9 +310,6 @@ ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings ] -sourceCTEName :: SqlFragment-sourceCTEName = "pg_source"- removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl @@ -338,7 +336,7 @@ asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')" asJsonF :: SqlFragment-asJsonF = "coalesce(array_to_json(array_agg(row_to_json(_postgrest_t))), '[]')::character varying"+asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying" asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "@@ -372,21 +370,6 @@ n = qiName t s = qiSchema t -getJoinFilters :: Relation -> [Filter]-getJoinFilters (Relation t cols ft fcs typ lt lc1 lc2) =- case typ of- Child -> zipWith (toFilter tN ftN) cols fcs- Parent -> zipWith (toFilter tN ftN) cols fcs- Many -> zipWith (toFilter tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toFilter ftN ltN) fcs (fromMaybe [] lc2)- Root -> undefined --error "undefined getJoinFilters"- where- s = if typ == Parent then "" else tableSchema t- tN = tableName t- ftN = tableName ft- ltN = fromMaybe "" (tableName <$> lt)- toFilter :: Text -> Text -> Column -> Column -> Filter- toFilter tb ftb c fc = Filter (colName c, Nothing) (Operation False ("=", VForeignKey (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}})))- unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Query a b unicodeStatement = H.statement . T.encodeUtf8 @@ -413,43 +396,43 @@ pgFmtSelectItem table (f@(_, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs jp alias pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment-pgFmtFilter table (Filter fld (Operation hasNot_ ex)) = notOp <> " " <> case ex of- (op, VText val) -> pgFmtFieldOp op <> " " <> case op of- "like" -> unknownLiteral (T.map star val)- "ilike" -> unknownLiteral (T.map star val)- -- TODO: The '@@' was deprecated, remove in v0.5.0.0- "@@" -> "to_tsquery(" <> unknownLiteral val <> ") "- "fts" -> "to_tsquery(" <> unknownLiteral val <> ") "- "is" -> whiteList val- "isnot" -> whiteList val- _ -> unknownLiteral val- (op, VTextL vals) -> pgFmtIn op vals -- in and notin- (op, VForeignKey fQi (ForeignKey Column{colTable=Table{tableName=fTableName}, colName=fColName})) ->- pgFmtField fQi fld <> " " <> sqlOperator op <> " " <> pgFmtColumn (removeSourceCTESchema (qiSchema fQi) fTableName) fColName+pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of+ Op op val -> pgFmtFieldOp op <> " " <> case op of+ "like" -> unknownLiteral (T.map star val)+ "ilike" -> unknownLiteral (T.map star val)+ "is" -> whiteList val+ _ -> unknownLiteral val++ In vals -> pgFmtField table fld <> " " <>+ let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"+ case ((&&) (length vals == 1) . T.null) <$> headMay vals of+ Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "+ Just True -> emptyValForIn+ Nothing -> emptyValForIn++ Fts op lang val ->+ pgFmtFieldOp op+ <> "("+ <> maybe "" ((<> ", ") . pgFmtLit) lang+ <> unknownLiteral val+ <> ") "++ Join fQi (ForeignKey Column{colTable=Table{tableName=fTableName}, colName=fColName}) ->+ pgFmtField fQi fld <> " = " <> pgFmtColumn (removeSourceCTESchema (qiSchema fQi) fTableName) fColName where pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op sqlOperator o = HM.lookupDefault "=" o operators- notOp = if hasNot_ then "NOT" else ""+ notOp = if hasNot then "NOT" else "" star c = if c == '*' then '%' else c unknownLiteral = (<> "::unknown ") . pgFmtLit whiteList :: Text -> SqlFragment whiteList v = fromMaybe (toS (pgFmtLit v) <> "::unknown ") (find ((==) . toLower $ v) ["null","true","false"])- pgFmtIn :: Operator -> [Text] -> SqlFragment- pgFmtIn op vals =- -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"- let emptyValForIn o = (if "not" `isInfixOf` o then "NOT " else "") -- handle case of "notin" operator- <> pgFmtField table fld <> " = any('{}') " in- case T.null <$> headMay vals of- Just isNull -> if isNull && length vals == 1- then emptyValForIn op- else pgFmtFieldOp op <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "- Nothing -> emptyValForIn op pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment-pgFmtLogicTree qi (Expr hasNot_ op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"- where notOp = if hasNot_ then "NOT" else ""+pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"+ where notOp = if hasNot then "NOT" else "" pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt pgFmtJsonPath :: Maybe JsonPath -> SqlFragment
src/PostgREST/Types.hs view
@@ -4,6 +4,7 @@ import qualified GHC.Show import Data.Aeson import qualified Data.ByteString.Lazy as BL+import qualified Data.CaseInsensitive as CI import qualified Data.HashMap.Strict as M import Data.Tree import qualified Data.Vector as V@@ -30,6 +31,7 @@ , dbRelations :: [Relation] , dbPrimaryKeys :: [PrimaryKey] , dbProcs :: M.HashMap Text ProcDescription+, pgVersion :: PgVersion } deriving (Show, Eq) data PgArg = PgArg {@@ -148,7 +150,7 @@ type Operator = Text operators :: M.HashMap Operator SqlFragment-operators = M.fromList [+operators = M.union (M.fromList [ ("eq", "="), ("gte", ">="), ("gt", ">"),@@ -158,10 +160,7 @@ ("like", "LIKE"), ("ilike", "ILIKE"), ("in", "IN"),- ("notin", "NOT IN"),- ("isnot", "IS NOT"), ("is", "IS"),- ("fts", "@@"), ("cs", "@>"), ("cd", "<@"), ("ov", "&&"),@@ -171,12 +170,29 @@ ("nxl", "&>"), ("adj", "-|-"), -- TODO: these are deprecated and should be removed in v0.5.0.0- ("@@", "@@"), ("@>", "@>"),- ("<@", "<@")]-data Operation = Operation{ hasNot::Bool, expr::(Operator, Operand) } deriving (Eq, Show)-data Operand = VText Text | VTextL [Text] | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)+ ("<@", "<@")]) ftsOperators +ftsOperators :: M.HashMap Operator SqlFragment+ftsOperators = M.fromList [+ ("@@", "@@ to_tsquery"), -- TODO: '@@' deprecated+ ("fts", "@@ to_tsquery"),+ ("plfts", "@@ plainto_tsquery"),+ ("phfts", "@@ phraseto_tsquery")+ ]++data OpExpr = OpExpr Bool Operation deriving (Eq, Show)+data Operation = Op Operator SingleVal |+ In ListVal |+ Fts Operator (Maybe Language) SingleVal |+ Join QualifiedIdentifier ForeignKey deriving (Eq, Show)+type Language = Text++-- | Represents a single value in a filter, e.g. id=eq.singleval+type SingleVal = Text+-- | Represents a list value in a filter, e.g. id=in.(val1,val2,val3)+type ListVal = [Text]+ data LogicOperator = And | Or deriving Eq instance Show LogicOperator where show And = "AND"@@ -199,7 +215,26 @@ type Cast = Text type NodeName = Text +-- Rpc query param, only used for GET rpcs+type RpcQParam = (Text, Text)+ {-|+ Custom guc header, it's obtained by parsing the json in a:+ `SET LOCAL "response.headers" = '[{"Set-Cookie": ".."}]'+-}+newtype GucHeader = GucHeader (Text, Text)++instance FromJSON GucHeader where+ parseJSON (Object o) = case headMay (M.toList o) of+ Just (k, String s) | M.size o == 1 -> pure $ GucHeader (k, s)+ | otherwise -> mzero+ _ -> mzero+ parseJSON _ = mzero++toHeaders :: [GucHeader] -> [Header]+toHeaders = map $ \(GucHeader (k, v)) -> (CI.mk $ toS k, toS v)++{-| This type will hold information about which particular 'Relation' between two tables to choose when there are multiple ones. Specifically, it will contain the name of the foreign key or the join table in many to many relations. -}@@ -207,7 +242,7 @@ type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe RelationDetail) -- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"] type EmbedPath = [Text]-data Filter = Filter { field::Field, operation::Operation } deriving (Show, Eq)+data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq) data ReadQuery = Select { select::[SelectItem], from::[TableName], where_::[LogicTree], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq) data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] }@@ -237,3 +272,11 @@ toMime CTOctetStream = "application/octet-stream" toMime CTAny = "*/*" toMime (CTOther ct) = ct++data PgVersion = PgVersion {+ pgvNum :: Int32+, pgvName :: Text+} deriving (Eq, Ord, Show)++sourceCTEName :: SqlFragment+sourceCTEName = "pg_source"
test/Feature/AndOrParamsSpec.hs view
@@ -73,6 +73,12 @@ it "can handle fts" $ do get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith` [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?or=(text_search_vector.plfts(german).Art%20Spass, text_search_vector.plfts(french).amusant%20impossible, text_search_vector.fts(english).impossible)" `shouldRespondWith`+ [json|[+ {"text_search_vector": "'fun':5 'imposs':9 'kind':3" },+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}+ ]|] { matchHeaders = [matchContentTypeJson] } -- TODO: remove in 0.5.0 as deprecated get "/entities?or=(text_search_vector.@@.bar,text_search_vector.@@.baz)&select=id" `shouldRespondWith` [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
+ test/Feature/AudienceJwtSecretSpec.hs view
@@ -0,0 +1,47 @@+module Feature.AudienceJwtSecretSpec where++-- {{{ Imports+import Test.Hspec+import Test.Hspec.Wai+import Network.HTTP.Types++import SpecHelper+import Network.Wai (Application)++import Protolude hiding (get)+-- }}}++spec :: SpecWith Application+spec = describe "test handling of aud claims in JWT" $ do++ -- this test will stop working 9999999999s after the UNIX EPOCH+ it "succeeds with jwt token containing with an audience claim" $ do+ {- This is the decoded contents of authHeaderJWT++ {+ "exp": 9999999999,+ "role": "postgrest_test_author",+ "id": "jdoe",+ "aud": "youraudience"+ }++ -}+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 200++ it "succeeds with jwt token that does not contain an audience claim" $ do+ {- This is the decoded contents of authHeaderJWT++ {+ "exp": 9999999999,+ "role": "postgrest_test_author",+ "id": "jdoe"+ }+ -}+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 200++ it "requests without JWT token should work" $+ get "/has_count_column" `shouldRespondWith` 200
test/Feature/AuthSpec.hs view
@@ -1,6 +1,5 @@ module Feature.AuthSpec where --- {{{ Imports import Text.Heredoc import Test.Hspec import Test.Hspec.Wai@@ -11,7 +10,6 @@ import Network.Wai (Application) import Protolude hiding (get)--- }}} spec :: SpecWith Application spec = describe "authorization" $ do@@ -37,6 +35,17 @@ "message":"permission denied for relation private_table"} |] { matchStatus = 403 , matchHeaders = []+ }++ it "denies execution on functions that anonymous does not own" $+ post "/rpc/privileged_hello" [json|{"name": "anonymous"}|] `shouldRespondWith` 401++ it "allows execution on a function that postgrest_test_author owns" $+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in+ request methodPost "/rpc/privileged_hello" [auth] [json|{"name": "jdoe"}|]+ `shouldRespondWith` [json|"Privileged hello to jdoe"|]+ { matchStatus = 200+ , matchHeaders = [matchContentTypeJson] } it "returns jwt functions as jwt tokens" $
test/Feature/InsertSpec.hs view
@@ -423,9 +423,9 @@ matchStatus = 204, matchHeaders = ["Content-Range" <:> "*/*"] }-- g <- get "/items"- liftIO $ simpleBody g `shouldBe` [json| [{"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},{id:16},{"id":2},{"id":1}] |]+ get "/items" `shouldRespondWith`+ [json|[{"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},{id:16},{"id":2},{"id":1}]|]+ { matchHeaders = [matchContentTypeJson] } it "makes no updates and and returns 200, when patching with an empty json object and return=rep" $ do request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]@@ -434,10 +434,10 @@ matchStatus = 200, matchHeaders = ["Content-Range" <:> "*/*"] }-- g <- get "/items"- liftIO $ simpleBody g `shouldBe` [json| [{"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},{id:16},{"id":2},{"id":1}] |]-+ get "/items" `shouldRespondWith`+ [json| [{"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},{id:16},{"id":2},{"id":1}] |]+ { matchHeaders = [matchContentTypeJson] }+ context "with unicode values" $ it "succeeds and returns values intact" $ do void $ request methodPost "/no_pk" []
+ test/Feature/NonexistentSchemaSpec.hs view
@@ -0,0 +1,15 @@+module Feature.NonexistentSchemaSpec where++import Network.Wai (Application)+import Protolude hiding (get)+import Test.Hspec+import Test.Hspec.Wai++spec :: SpecWith Application+spec =+ describe "Non existent api schema" $ do+ it "succeeds when requesting root path" $+ get "/" `shouldRespondWith` 200++ it "gives 404 when requesting a nonexistent table in this nonexistent schema" $+ get "/nonexistent_table" `shouldRespondWith` 404
+ test/Feature/PgVersion96Spec.hs view
@@ -0,0 +1,81 @@+module Feature.PgVersion96Spec where++import Test.Hspec hiding (pendingWith)+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import SpecHelper+import Network.Wai (Application)++import Protolude hiding (get)++spec :: SpecWith Application+spec =+ describe "features supported on PostgreSQL 9.6" $ do+ context "GUC headers" $ do+ it "succeeds setting the headers" $ do+ get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id"+ `shouldRespondWith` [json|[{"id": 2}]|]+ {matchHeaders = [+ matchContentTypeJson,+ "X-Test" <:> "key1=val1; someValue; key2=val2",+ "X-Test-2" <:> "key1=val1"]}+ get "/rpc/get_int_and_guc_headers?num=1"+ `shouldRespondWith` [json|1|]+ {matchHeaders = [+ matchContentTypeJson,+ "X-Test" <:> "key1=val1; someValue; key2=val2",+ "X-Test-2" <:> "key1=val1"]}+ post "/rpc/get_int_and_guc_headers" [json|{"num": 1}|]+ `shouldRespondWith` [json|1|]+ {matchHeaders = [+ matchContentTypeJson,+ "X-Test" <:> "key1=val1; someValue; key2=val2",+ "X-Test-2" <:> "key1=val1"]}++ it "fails when setting headers with wrong json structure" $ do+ get "/rpc/bad_guc_headers_1" `shouldRespondWith` 500+ get "/rpc/bad_guc_headers_2" `shouldRespondWith` 500+ get "/rpc/bad_guc_headers_3" `shouldRespondWith` 500+ post "/rpc/bad_guc_headers_1" [json|{}|] `shouldRespondWith` 500++ it "can set the same http header twice" $+ get "/rpc/set_cookie_twice"+ `shouldRespondWith` "null"+ {matchHeaders = [+ matchContentTypeJson,+ "Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/",+ "Set-Cookie" <:> "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"]}++ context "Use of the phraseto_tsquery function" $ do+ it "finds matches" $+ get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith`+ [json| [{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]+ { matchHeaders = [matchContentTypeJson] }++ it "finds matches with different dictionaries" $+ get "/tsearch?text_search_vector=phfts(german).Art%20Spass" `shouldRespondWith`+ [json| [{"text_search_vector": "'art':4 'spass':5 'unmog':7" }] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can be negated with not operator" $+ get "/tsearch?text_search_vector=not.phfts(english).The%20Fat%20Cats" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},+ {"text_search_vector": "'also':2 'fun':3 'possibl':8"},+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can be used with or query param" $+ get "/tsearch?or=(text_search_vector.phfts(german).Art%20Spass, text_search_vector.phfts(french).amusant, text_search_vector.fts(english).impossible)" `shouldRespondWith`+ [json|[+ {"text_search_vector": "'fun':5 'imposs':9 'kind':3" },+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}+ ]|] { matchHeaders = [matchContentTypeJson] }++ it "should work when used with GET RPC" $+ get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith`+ [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]+ { matchHeaders = [matchContentTypeJson] }
test/Feature/QueryLimitedSpec.hs view
@@ -5,7 +5,6 @@ import Test.Hspec.Wai.JSON import Network.HTTP.Types import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus))-import Text.Heredoc import SpecHelper import Network.Wai (Application) @@ -31,14 +30,14 @@ it "limit works on all levels" $ get "/users?select=id,tasks{id}&order=id.asc&tasks.order=id.asc"- `shouldRespondWith` [str|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]+ `shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|] { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "0-1/*"] } it "limit is not applied to parent embeds" $ get "/tasks?select=id,project{id}&id=gt.5"- `shouldRespondWith` [str|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]+ `shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|] { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "0-1/*"] }
test/Feature/QuerySpec.hs view
@@ -4,8 +4,7 @@ import Test.Hspec.Wai import Test.Hspec.Wai.JSON import Network.HTTP.Types-import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus,simpleBody))-import qualified Data.ByteString.Lazy as BL (empty)+import Network.Wai.Test (SResponse(simpleHeaders)) import SpecHelper import Text.Heredoc@@ -55,11 +54,6 @@ `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |] { matchHeaders = ["Content-Range" <:> "0-2/*"] } - it "matches items NOT IN" $- get "/items?id=notin.2,4,6,7,8,9,10,11,12,13,14,15"- `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]- { matchHeaders = ["Content-Range" <:> "0-2/*"] }- it "matches items NOT IN using not operator" $ get "/items?id=not.in.2,4,6,7,8,9,10,11,12,13,14,15" `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]@@ -91,36 +85,81 @@ it "matches with ilike" $ do get "/simple_pk?k=ilike.xy*&order=extra.asc" `shouldRespondWith`- [str|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]+ [json|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]+ { matchHeaders = [matchContentTypeJson] } get "/simple_pk?k=ilike.*YY*&order=extra.asc" `shouldRespondWith`- [str|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]+ [json|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]+ { matchHeaders = [matchContentTypeJson] } it "matches with ilike using not operator" $ get "/simple_pk?k=not.ilike.xy*&order=extra.asc" `shouldRespondWith` "[]" - it "matches with tsearch fts" $ do- get "/tsearch?text_search_vector=fts.impossible" `shouldRespondWith`- [json| [{"text_search_vector": "'fun':5 'imposs':9 'kind':3" }] |]- { matchHeaders = [matchContentTypeJson] }- get "/tsearch?text_search_vector=fts.possible" `shouldRespondWith`- [json| [{"text_search_vector": "'also':2 'fun':3 'possibl':8" }] |]- { matchHeaders = [matchContentTypeJson] }- -- TODO: remove in 0.5.0 as deprecated- get "/tsearch?text_search_vector=@@.impossible" `shouldRespondWith`- [json| [{"text_search_vector": "'fun':5 'imposs':9 'kind':3" }] |]- { matchHeaders = [matchContentTypeJson] }- get "/tsearch?text_search_vector=@@.possible" `shouldRespondWith`- [json| [{"text_search_vector": "'also':2 'fun':3 'possibl':8" }] |]- { matchHeaders = [matchContentTypeJson] }+ describe "Full text search operator" $ do+ it "finds matches with to_tsquery" $+ get "/tsearch?text_search_vector=fts.impossible" `shouldRespondWith`+ [json| [{"text_search_vector": "'fun':5 'imposs':9 'kind':3" }] |]+ { matchHeaders = [matchContentTypeJson] } - it "matches with tsearch fts using not operator" $ do- get "/tsearch?text_search_vector=not.fts.impossible" `shouldRespondWith`- [json| [{"text_search_vector": "'also':2 'fun':3 'possibl':8" }] |]- { matchHeaders = [matchContentTypeJson] }+ it "can use lexeme boolean operators(&=%26, |=%7C, !) in to_tsquery" $ do+ get "/tsearch?text_search_vector=fts.fun%26possible" `shouldRespondWith`+ [json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=fts.impossible%7Cpossible" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},+ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=fts.fun%26!possible" `shouldRespondWith`+ [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "finds matches with plainto_tsquery" $+ get "/tsearch?text_search_vector=plfts.The%20Fat%20Rats" `shouldRespondWith`+ [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]+ { matchHeaders = [matchContentTypeJson] }++ it "finds matches with different dictionaries" $ do+ get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith`+ [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=plfts(french).amusant%20impossible" `shouldRespondWith`+ [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can be negated with not operator" $ do+ get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=not.fts(english).impossible%7Cfat%7Cfun" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=not.plfts.The%20Fat%20Rats" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},+ {"text_search_vector": "'also':2 'fun':3 'possibl':8"},+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }+ -- TODO: remove in 0.5.0 as deprecated- get "/tsearch?text_search_vector=not.@@.impossible" `shouldRespondWith`- [json| [{"text_search_vector": "'also':2 'fun':3 'possibl':8" }] |]- { matchHeaders = [matchContentTypeJson] }+ it "Deprecated @@ operator, pending to remove" $ do+ get "/tsearch?text_search_vector=@@.impossible" `shouldRespondWith`+ [json| [{"text_search_vector": "'fun':5 'imposs':9 'kind':3" }] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=not.@@.impossible%7Cfat%7Cfun" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=not.@@(english).impossible%7Cfat%7Cfun" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] } it "matches with computed column" $ get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`@@ -141,21 +180,26 @@ it "matches filtering nested items" $ get "/clients?select=id,projects{id,tasks{id,name}}&projects.tasks.name=like.Design*" `shouldRespondWith`- [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1,"name":"Design w7"}]},{"id":2,"tasks":[{"id":3,"name":"Design w10"}]}]},{"id":2,"projects":[{"id":3,"tasks":[{"id":5,"name":"Design IOS"}]},{"id":4,"tasks":[{"id":7,"name":"Design OSX"}]}]}]|]+ [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1,"name":"Design w7"}]},{"id":2,"tasks":[{"id":3,"name":"Design w10"}]}]},{"id":2,"projects":[{"id":3,"tasks":[{"id":5,"name":"Design IOS"}]},{"id":4,"tasks":[{"id":7,"name":"Design OSX"}]}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "matches with cs operator" $ do get "/complex_items?select=id&arr_data=cs.{2}" `shouldRespondWith`- [str|[{"id":2},{"id":3}]|]+ [json|[{"id":2},{"id":3}]|]+ { matchHeaders = [matchContentTypeJson] } -- TODO: remove in 0.5.0 as deprecated get "/complex_items?select=id&arr_data=@>.{2}" `shouldRespondWith`- [str|[{"id":2},{"id":3}]|]+ [json|[{"id":2},{"id":3}]|]+ { matchHeaders = [matchContentTypeJson] } it "matches with cd operator" $ do get "/complex_items?select=id&arr_data=cd.{1,2,4}" `shouldRespondWith`- [str|[{"id":1},{"id":2}]|]+ [json|[{"id":1},{"id":2}]|]+ { matchHeaders = [matchContentTypeJson] } -- TODO: remove in 0.5.0 as deprecated get "/complex_items?select=id&arr_data=<@.{1,2,4}" `shouldRespondWith`- [str|[{"id":1},{"id":2}]|]+ [json|[{"id":1},{"id":2}]|]+ { matchHeaders = [matchContentTypeJson] } describe "Shaping response with select parameter" $ do@@ -242,8 +286,42 @@ it "requesting parents and children" $ get "/projects?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`- [str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] } + it "requesting parent without specifying primary key" $ do+ get "/projects?select=name,client{name}" `shouldRespondWith`+ [json|[+ {"name":"Windows 7","client":{"name": "Microsoft"}},+ {"name":"Windows 10","client":{"name": "Microsoft"}},+ {"name":"IOS","client":{"name": "Apple"}},+ {"name":"OSX","client":{"name": "Apple"}},+ {"name":"Orphan","client":null}+ ]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/articleStars?select=createdAt,article{owner},user{name}&limit=1" `shouldRespondWith`+ [json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "requesting parent and renaming primary key" $+ get "/projects?select=name,client{clientId:id,name}" `shouldRespondWith`+ [json|[+ {"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},+ {"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},+ {"name":"IOS","client":{"name": "Apple", "clientId": 2}},+ {"name":"OSX","client":{"name": "Apple", "clientId": 2}},+ {"name":"Orphan","client":null}+ ]|]+ { matchHeaders = [matchContentTypeJson] }++ it "requesting parent and specifying/renaming one key of the composite primary key" $ do+ get "/comments?select=*,users_tasks{userId:user_id}" `shouldRespondWith`+ [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"userId": 2}}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/comments?select=*,users_tasks{taskId:task_id}" `shouldRespondWith`+ [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]+ { matchHeaders = [matchContentTypeJson] }+ it "embed data with two fk pointing to the same table" $ get "/orders?id=eq.1&select=id, name, billing_address_id{id}, shipping_address_id{id}" `shouldRespondWith` [str|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]@@ -251,7 +329,8 @@ it "requesting parents and children while renaming them" $ get "/projects?id=eq.1&select=myId:id, name, project_client:client_id{*}, project_tasks:tasks{id, name}" `shouldRespondWith`- [str|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ [json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "requesting parents two levels up while using FK to specify the link" $ get "/tasks?id=eq.1&select=id,name,project:project_id{id,name,client:client_id{id,name}}" `shouldRespondWith`@@ -268,7 +347,8 @@ it "rows with missing parents are included" $ get "/projects?id=in.1,5&select=id,clients{id}" `shouldRespondWith`- [str|[{"id":1,"clients":{"id":1}},{"id":5,"clients":null}]|]+ [json|[{"id":1,"clients":{"id":1}},{"id":5,"clients":null}]|]+ { matchHeaders = [matchContentTypeJson] } it "rows with no children return [] instead of null" $ get "/projects?id=in.5&select=id,tasks{id}" `shouldRespondWith`@@ -276,53 +356,70 @@ it "requesting children 2 levels" $ get "/clients?id=eq.1&select=id,projects{id,tasks{id}}" `shouldRespondWith`- [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]+ [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "requesting children 2 levels (with relation path fixed)" $ get "/clients?id=eq.1&select=id,projects:projects.client_id{id,tasks{id}}" `shouldRespondWith`- [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]+ [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "requesting many<->many relation" $ get "/tasks?select=id,users{id}" `shouldRespondWith`- [str|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]+ [json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]+ { matchHeaders = [matchContentTypeJson] } it "requesting many<->many relation (with relation path fixed)" $ get "/tasks?select=id,users:users.users_tasks{id}" `shouldRespondWith`- [str|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]+ [json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]+ { matchHeaders = [matchContentTypeJson] } it "requesting many<->many relation with rename" $ get "/tasks?id=eq.1&select=id,theUsers:users{id}" `shouldRespondWith`- [str|[{"id":1,"theUsers":[{"id":1},{"id":3}]}]|]+ [json|[{"id":1,"theUsers":[{"id":1},{"id":3}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "requesting many<->many relation reverse" $ get "/users?select=id,tasks{id}" `shouldRespondWith`- [str|[{"id":1,"tasks":[{"id":1},{"id":2},{"id":3},{"id":4}]},{"id":2,"tasks":[{"id":5},{"id":6},{"id":7}]},{"id":3,"tasks":[{"id":1},{"id":5}]}]|]+ [json|[{"id":1,"tasks":[{"id":1},{"id":2},{"id":3},{"id":4}]},{"id":2,"tasks":[{"id":5},{"id":6},{"id":7}]},{"id":3,"tasks":[{"id":1},{"id":5}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "requesting parents and children on views" $ get "/projects_view?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`- [str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "requesting parents and children on views with renamed keys" $ get "/projects_view_alt?t_id=eq.1&select=t_id, name, clients{*}, tasks{id, name}" `shouldRespondWith`- [str|[{"t_id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ [json|[{"t_id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "requesting children with composite key" $ get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments{content}" `shouldRespondWith`- [str|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]+ [json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "detect relations in views from exposed schema that are based on tables in private schema and have columns renames" $ get "/articles?id=eq.1&select=id,articleStars{users{*}}" `shouldRespondWith`- [str|[{"id":1,"articleStars":[{"users":{"id":1,"name":"Angela Martin"}},{"users":{"id":2,"name":"Michael Scott"}},{"users":{"id":3,"name":"Dwight Schrute"}}]}]|]+ [json|[{"id":1,"articleStars":[{"users":{"id":1,"name":"Angela Martin"}},{"users":{"id":2,"name":"Michael Scott"}},{"users":{"id":3,"name":"Dwight Schrute"}}]}]|]+ { matchHeaders = [matchContentTypeJson] } - it "can select by column name" $- get "/projects?id=in.1,3&select=id,name,client_id,client_id{id,name}" `shouldRespondWith`- [str|[{"id":1,"name":"Windows 7","client_id":1,"client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client_id":{"id":2,"name":"Apple"}}]|]+ it "can embed by FK column name" $+ get "/projects?id=in.1,3&select=id,name,client_id{id,name}" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]+ { matchHeaders = [matchContentTypeJson] }+ + it "can embed by FK column name and select the FK value at the same time, if aliased" $+ get "/projects?id=in.1,3&select=id,name,client_id,client:client_id{id,name}" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]+ { matchHeaders = [matchContentTypeJson] } it "can select by column name sans id" $ get "/projects?id=in.1,3&select=id,name,client_id,client{id,name}" `shouldRespondWith`- [str|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]+ [json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]+ { matchHeaders = [matchContentTypeJson] } it "can detect fk relations through views to tables in the public schema" $ get "/consumers_view?select=*,orders_view{*}" `shouldRespondWith` 200@@ -394,15 +491,18 @@ it "ordering embeded entities" $ get "/projects?id=eq.1&select=id, name, tasks{id, name}&tasks.order=name.asc" `shouldRespondWith`- [str|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]+ [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "ordering embeded entities with alias" $ get "/projects?id=eq.1&select=id, name, the_tasks:tasks{id, name}&tasks.order=name.asc" `shouldRespondWith`- [str|[{"id":1,"name":"Windows 7","the_tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]+ [json|[{"id":1,"name":"Windows 7","the_tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "ordering embeded entities, two levels" $ get "/projects?id=eq.1&select=id, name, tasks{id, name, users{id, name}}&tasks.order=name.asc&tasks.users.order=name.desc" `shouldRespondWith`- [str|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7","users":[{"id":1,"name":"Angela Martin"}]},{"id":1,"name":"Design w7","users":[{"id":3,"name":"Dwight Schrute"},{"id":1,"name":"Angela Martin"}]}]}]|]+ [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7","users":[{"id":1,"name":"Angela Martin"}]},{"id":1,"name":"Design w7","users":[{"id":3,"name":"Dwight Schrute"},{"id":1,"name":"Angela Martin"}]}]}]|]+ { matchHeaders = [matchContentTypeJson] } it "ordering embeded parents does not break things" $ get "/projects?id=eq.1&select=id, name, clients{id, name}&clients.order=name.asc" `shouldRespondWith`@@ -491,214 +591,6 @@ [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |] { matchHeaders = [matchContentTypeJson] } - describe "remote procedure call" $ do- context "a proc that returns a set" $ do- it "returns paginated results" $- request methodPost "/rpc/getitemrange"- (rangeHdrs (ByteRangeFromTo 0 0)) [json| { "min": 2, "max": 4 } |]- `shouldRespondWith` [json| [{"id":3}] |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-0/*"]- }-- it "includes total count if requested" $- request methodPost "/rpc/getitemrange"- (rangeHdrsWithCount (ByteRangeFromTo 0 0))- [json| { "min": 2, "max": 4 } |]- `shouldRespondWith` [json| [{"id":3}] |]- { matchStatus = 206 -- it now knows the response is partial- , matchHeaders = ["Content-Range" <:> "0-0/2"]- }-- it "returns proper json" $- post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`- [json| [ {"id": 3}, {"id":4} ] |]- { matchHeaders = [matchContentTypeJson] }-- it "returns CSV" $- request methodPost "/rpc/getitemrange"- (acceptHdrs "text/csv")- [json| { "min": 2, "max": 4 } |]- `shouldRespondWith` "id\n3\n4"- { matchStatus = 200- , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]- }-- context "unknown function" $- it "returns 404" $- post "/rpc/fakefunc" [json| {} |] `shouldRespondWith` 404-- context "shaping the response returned by a proc" $ do- it "returns a project" $- post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`- [str|[{"id":1,"name":"Windows 7","client_id":1}]|]-- it "can filter proc results" $- post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`- [json|[{"id":2},{"id":3},{"id":4}]|]- { matchHeaders = [matchContentTypeJson] }-- it "can limit proc results" $- post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1" [json| {} |]- `shouldRespondWith` [json|[{"id":3},{"id":4}]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "1-2/*"]- }-- it "select works on the first level" $- post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`- [str|[{"id":1,"name":"Windows 7"}]|]-- context "foreign entities embedding" $ do- it "can embed if related tables are in the exposed schema" $- post "/rpc/getproject?select=id,name,client{id},tasks{id}" [json| { "id": 1} |] `shouldRespondWith`- [str|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]-- it "cannot embed if the related table is not in the exposed schema" $- post "/rpc/single_article?select=*,article_stars{*}" [json|{ "id": 1}|]- `shouldRespondWith` 400-- it "can embed if the related tables are in a hidden schema but exposed as views" $- post "/rpc/single_article?select=id,articleStars{userId}" [json|{ "id": 2}|]- `shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]- { matchHeaders = [matchContentTypeJson] }-- context "a proc that returns an empty rowset" $- it "returns empty json array" $- post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`- [json| [] |]- { matchHeaders = [matchContentTypeJson] }-- context "proc return types" $ do- context "returns text" $ do- it "returns proper json" $- post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`- [json|"Hello, world"|]- { matchHeaders = [matchContentTypeJson] }-- it "can handle unicode" $- post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith`- [json|"Hello, ¥"|]- { matchHeaders = [matchContentTypeJson] }-- it "returns array" $- post "/rpc/ret_array" [json|{}|] `shouldRespondWith`- [json|[1, 2, 3]|]- { matchHeaders = [matchContentTypeJson] }-- it "returns setof integers" $- post "/rpc/ret_setof_integers" [json|{}|] `shouldRespondWith`- [json|[{ "ret_setof_integers": 1 },- { "ret_setof_integers": 2 },- { "ret_setof_integers": 3 }]|]- { matchHeaders = [matchContentTypeJson] }-- it "returns enum value" $- post "/rpc/ret_enum" [json|{ "val": "foo" }|] `shouldRespondWith`- [json|"foo"|]- { matchHeaders = [matchContentTypeJson] }-- it "returns domain value" $- post "/rpc/ret_domain" [json|{ "val": "8" }|] `shouldRespondWith`- [json|8|]- { matchHeaders = [matchContentTypeJson] }-- it "returns range" $- post "/rpc/ret_range" [json|{ "low": 10, "up": 20 }|] `shouldRespondWith`- [json|"[10,20)"|]- { matchHeaders = [matchContentTypeJson] }-- it "returns row of scalars" $- post "/rpc/ret_scalars" [json|{}|] `shouldRespondWith`- [json|[{"a":"scalars", "b":"foo", "c":1, "d":"[10,20)"}]|]- { matchHeaders = [matchContentTypeJson] }-- it "returns composite type in exposed schema" $- post "/rpc/ret_point_2d" [json|{}|] `shouldRespondWith`- [json|[{"x": 10, "y": 5}]|]- { matchHeaders = [matchContentTypeJson] }-- it "cannot return composite type in hidden schema" $- post "/rpc/ret_point_3d" [json|{}|] `shouldRespondWith` 401-- it "returns single row from table" $- post "/rpc/single_article?select=id" [json|{"id": 2}|] `shouldRespondWith`- [json|[{"id": 2}]|]- { matchHeaders = [matchContentTypeJson] }-- it "returns null for void" $- post "/rpc/ret_void" [json|{}|] `shouldRespondWith`- [json|null|]- { matchHeaders = [matchContentTypeJson] }-- context "improper input" $ do- it "rejects unknown content type even if payload is good" $- request methodPost "/rpc/sayhello"- (acceptHdrs "audio/mpeg3") [json| { "name": "world" } |]- `shouldRespondWith` 415- it "rejects malformed json payload" $ do- p <- request methodPost "/rpc/sayhello"- (acceptHdrs "application/json") "sdfsdf"- liftIO $ do- simpleStatus p `shouldBe` badRequest400- isErrorFormat (simpleBody p) `shouldBe` True- it "treats simple plpgsql raise as invalid input" $ do- p <- post "/rpc/problem" "{}"- liftIO $ do- simpleStatus p `shouldBe` badRequest400- isErrorFormat (simpleBody p) `shouldBe` True-- context "unsupported verbs" $ do- it "DELETE fails" $- request methodDelete "/rpc/sayhello" [] ""- `shouldRespondWith` 405- it "PATCH fails" $- request methodPatch "/rpc/sayhello" [] ""- `shouldRespondWith` 405- it "OPTIONS fails" $- -- TODO: should return info about the function- request methodOptions "/rpc/sayhello" [] ""- `shouldRespondWith` 405- it "GET fails with 405 on unknown procs" $- -- TODO: should this be 404?- get "/rpc/fake" `shouldRespondWith` 405- it "GET with 405 on known procs" $- get "/rpc/sayhello" `shouldRespondWith` 405-- it "executes the proc exactly once per request" $ do- post "/rpc/callcounter" [json| {} |] `shouldRespondWith`- [json|1|]- { matchHeaders = [matchContentTypeJson] }- post "/rpc/callcounter" [json| {} |] `shouldRespondWith`- [json|2|]- { matchHeaders = [matchContentTypeJson] }-- context "expects a single json object" $ do- it "does not expand posted json into parameters" $- request methodPost "/rpc/singlejsonparam"- [("Prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`- [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]- { matchHeaders = [matchContentTypeJson] }-- it "accepts parameters from an html form" $- request methodPost "/rpc/singlejsonparam"- [("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]- ("integer=7&double=2.71828&varchar=forms+are+fun&" <>- "boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`- [json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"- , "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]- { matchHeaders = [matchContentTypeJson] }-- context "a proc that receives no parameters" $- it "interprets empty string as empty json object on a post request" $- post "/rpc/noparamsproc" BL.empty `shouldRespondWith`- [json| "Return value of no parameters procedure." |]- { matchHeaders = [matchContentTypeJson] }-- it "returns proper output when having the same return col name as the proc name" $- post "/rpc/test" [json|{}|] `shouldRespondWith`- [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }- describe "weird requests" $ do it "can query as normal" $ do get "/Escap3e;" `shouldRespondWith`@@ -814,7 +706,7 @@ , matchHeaders = [] } - describe "values with quotes in IN and NOTIN operators" $ do+ describe "values with quotes in IN and NOT IN" $ do it "succeeds when only quoted values are present" $ do get "/w_or_wo_comma_names?name=in.\"Hebdon, John\"" `shouldRespondWith` [json| [{"name":"Hebdon, John"}] |]@@ -822,9 +714,6 @@ get "/w_or_wo_comma_names?name=in.\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\"" `shouldRespondWith` [json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |] { matchHeaders = [matchContentTypeJson] }- get "/w_or_wo_comma_names?name=notin.\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\"" `shouldRespondWith`- [json| [{"name":"David White"},{"name":"Larry Thompson"}] |]- { matchHeaders = [matchContentTypeJson] } get "/w_or_wo_comma_names?name=not.in.\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\"" `shouldRespondWith` [json| [{"name":"David White"},{"name":"Larry Thompson"}] |] { matchHeaders = [matchContentTypeJson] }@@ -836,9 +725,6 @@ get "/w_or_wo_comma_names?name=not.in.\"Hebdon, John\",Larry Thompson,\"Smith, Joseph\"" `shouldRespondWith` [json| [{"name":"Williams, Mary"},{"name":"David White"}] |] { matchHeaders = [matchContentTypeJson] }- get "/w_or_wo_comma_names?name=notin.\"Hebdon, John\",David White,\"Williams, Mary\",Larry Thompson" `shouldRespondWith`- [json| [{"name":"Smith, Joseph"}] |]- { matchHeaders = [matchContentTypeJson] } it "checks well formed quoted values" $ do get "/w_or_wo_comma_names?name=in.\"\"Hebdon, John\"" `shouldRespondWith`@@ -875,10 +761,6 @@ get "/items_with_different_col_types?time_data=in." `shouldRespondWith` [json| [] |] { matchHeaders = [matchContentTypeJson] } - it "returns all results for notin when no value is present" $- get "/items_with_different_col_types?int_data=notin.&select=int_data" `shouldRespondWith`- [json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }- it "returns all results for not.in when no value is present" $ get "/items_with_different_col_types?int_data=not.in.&select=int_data" `shouldRespondWith` [json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }@@ -894,9 +776,7 @@ get "/items_with_different_col_types?int_data=in.()" `shouldRespondWith` [json| [] |] { matchHeaders = [matchContentTypeJson] } - it "returns all results when the notin value is empty between parentheses" $ do- get "/items_with_different_col_types?int_data=notin.()&select=int_data" `shouldRespondWith`- [json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }+ it "returns all results when the not.in value is empty between parentheses" $ get "/items_with_different_col_types?int_data=not.in.()&select=int_data" `shouldRespondWith` [json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] } @@ -916,3 +796,13 @@ it "works with brackets" $ get "/entities?id=eq.2&select=id,child_entities{id}" `shouldRespondWith` [json| [{"id": 2, "child_entities": [{"id": 3}]}] |] { matchHeaders = [matchContentTypeJson] }++ context "Embedding when column name = table name" $ do+ it "works with child embeds" $+ get "/being?select=*,descendant(*)&limit=1" `shouldRespondWith`+ [json|[{"being":1,"descendant":[{"descendant":1,"being":1},{"descendant":2,"being":1},{"descendant":3,"being":1}]}]|]+ { matchHeaders = [matchContentTypeJson] }+ it "works with many to many embeds" $+ get "/being?select=*,part(*)&limit=1" `shouldRespondWith`+ [json|[{"being":1,"part":[{"part":1}]}]|]+ { matchHeaders = [matchContentTypeJson] }
test/Feature/RangeSpec.hs view
@@ -9,7 +9,6 @@ import qualified Data.ByteString.Lazy as BL import SpecHelper-import Text.Heredoc import Network.Wai (Application) import Protolude hiding (get)@@ -131,20 +130,20 @@ it "no parameters return everything" $ get "/items?select=id&order=id.asc" `shouldRespondWith`- [str|[{"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}]|] { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "0-14/*"] } it "top level limit with parameter" $ get "/items?select=id&order=id.asc&limit=3"- `shouldRespondWith` [str|[{"id":1},{"id":2},{"id":3}]|]+ `shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|] { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "0-2/*"] } it "headers override get parameters" $ request methodGet "/items?select=id&order=id.asc&limit=3" (rangeHdrs $ ByteRangeFromTo 0 1) ""- `shouldRespondWith` [str|[{"id":1},{"id":2}]|]+ `shouldRespondWith` [json|[{"id":1},{"id":2}]|] { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "0-1/*"] }@@ -152,7 +151,7 @@ it "limit works on all levels" $ get "/clients?select=id,projects{id,tasks{id}}&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1" `shouldRespondWith`- [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]+ [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|] { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "0-0/*"] }@@ -160,7 +159,7 @@ it "limit and offset works on first level" $ get "/items?select=id&order=id.asc&limit=3&offset=2"- `shouldRespondWith` [str|[{"id":3},{"id":4},{"id":5}]|]+ `shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|] { matchStatus = 200 , matchHeaders = ["Content-Range" <:> "2-4/*"] }
+ test/Feature/RpcSpec.hs view
@@ -0,0 +1,345 @@+module Feature.RpcSpec where++import Test.Hspec hiding (pendingWith)+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON+import Network.HTTP.Types+import Network.Wai.Test (SResponse(simpleStatus, simpleBody))+import qualified Data.ByteString.Lazy as BL (empty)++import SpecHelper+import Text.Heredoc+import Network.Wai (Application)++import Protolude hiding (get)++spec :: SpecWith Application+spec =+ describe "remote procedure call" $ do+ context "a proc that returns a set" $ do+ it "returns paginated results" $ do+ request methodPost "/rpc/getitemrange"+ (rangeHdrs (ByteRangeFromTo 0 0)) [json| { "min": 2, "max": 4 } |]+ `shouldRespondWith` [json| [{"id":3}] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-0/*"]+ }+ request methodGet "/rpc/getitemrange?min=2&max=4"+ (rangeHdrs (ByteRangeFromTo 0 0)) ""+ `shouldRespondWith` [json| [{"id":3}] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-0/*"]+ }++ it "includes total count if requested" $ do+ request methodPost "/rpc/getitemrange"+ (rangeHdrsWithCount (ByteRangeFromTo 0 0))+ [json| { "min": 2, "max": 4 } |]+ `shouldRespondWith` [json| [{"id":3}] |]+ { matchStatus = 206 -- it now knows the response is partial+ , matchHeaders = ["Content-Range" <:> "0-0/2"]+ }+ request methodGet "/rpc/getitemrange?min=2&max=4"+ (rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""+ `shouldRespondWith` [json| [{"id":3}] |]+ { matchStatus = 206+ , matchHeaders = ["Content-Range" <:> "0-0/2"]+ }++ it "returns proper json" $ do+ post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`+ [json| [ {"id": 3}, {"id":4} ] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/getitemrange?min=2&max=4" `shouldRespondWith`+ [json| [ {"id": 3}, {"id":4} ] |]+ { matchHeaders = [matchContentTypeJson] }++ it "returns CSV" $ do+ request methodPost "/rpc/getitemrange"+ (acceptHdrs "text/csv")+ [json| { "min": 2, "max": 4 } |]+ `shouldRespondWith` "id\n3\n4"+ { matchStatus = 200+ , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]+ }+ request methodGet "/rpc/getitemrange?min=2&max=4"+ (acceptHdrs "text/csv") ""+ `shouldRespondWith` "id\n3\n4"+ { matchStatus = 200+ , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]+ }++ context "unknown function" $ do+ it "returns 404" $+ post "/rpc/fakefunc" [json| {} |] `shouldRespondWith` 404+ it "should fail with 404 on unknown proc name" $+ get "/rpc/fake" `shouldRespondWith` 404+ it "should fail with 404 on unknown proc args" $ do+ get "/rpc/sayhello" `shouldRespondWith` 404+ get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404++ context "shaping the response returned by a proc" $ do+ it "returns a project" $ do+ post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`+ [str|[{"id":1,"name":"Windows 7","client_id":1}]|]+ get "/rpc/getproject?id=1" `shouldRespondWith`+ [str|[{"id":1,"name":"Windows 7","client_id":1}]|]++ it "can filter proc results" $ do+ post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`+ [json|[{"id":2},{"id":3},{"id":4}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" `shouldRespondWith`+ [json|[{"id":2},{"id":3},{"id":4}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can limit proc results" $ do+ post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1" [json| {} |]+ `shouldRespondWith` [json|[{"id":3},{"id":4}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "1-2/*"] }+ get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1"+ `shouldRespondWith` [json|[{"id":3},{"id":4}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "1-2/*"] }++ it "select works on the first level" $ do+ post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`+ [str|[{"id":1,"name":"Windows 7"}]|]+ get "/rpc/getproject?id=1&select=id,name" `shouldRespondWith`+ [str|[{"id":1,"name":"Windows 7"}]|]++ context "foreign entities embedding" $ do+ it "can embed if related tables are in the exposed schema" $ do+ post "/rpc/getproject?select=id,name,client{id},tasks{id}" [json| { "id": 1} |] `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/getproject?id=1&select=id,name,client{id},tasks{id}" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "cannot embed if the related table is not in the exposed schema" $ do+ post "/rpc/single_article?select=*,article_stars{*}" [json|{ "id": 1}|]+ `shouldRespondWith` 400+ get "/rpc/single_article?id=1&select=*,article_stars{*}"+ `shouldRespondWith` 400++ it "can embed if the related tables are in a hidden schema but exposed as views" $ do+ post "/rpc/single_article?select=id,articleStars{userId}" [json|{ "id": 2}|]+ `shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/single_article?id=2&select=id,articleStars{userId}"+ `shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ context "a proc that returns an empty rowset" $+ it "returns empty json array" $ do+ post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`+ [json| [] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/test_empty_rowset" `shouldRespondWith`+ [json| [] |]+ { matchHeaders = [matchContentTypeJson] }++ context "proc return types" $ do+ context "returns text" $ do+ it "returns proper json" $+ post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`+ [json|"Hello, world"|]+ { matchHeaders = [matchContentTypeJson] }++ it "can handle unicode" $+ post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith`+ [json|"Hello, ¥"|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns array" $+ post "/rpc/ret_array" [json|{}|] `shouldRespondWith`+ [json|[1, 2, 3]|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns setof integers" $+ post "/rpc/ret_setof_integers" [json|{}|] `shouldRespondWith`+ [json|[{ "ret_setof_integers": 1 },+ { "ret_setof_integers": 2 },+ { "ret_setof_integers": 3 }]|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns enum value" $+ post "/rpc/ret_enum" [json|{ "val": "foo" }|] `shouldRespondWith`+ [json|"foo"|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns domain value" $+ post "/rpc/ret_domain" [json|{ "val": "8" }|] `shouldRespondWith`+ [json|8|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns range" $+ post "/rpc/ret_range" [json|{ "low": 10, "up": 20 }|] `shouldRespondWith`+ [json|"[10,20)"|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns row of scalars" $+ post "/rpc/ret_scalars" [json|{}|] `shouldRespondWith`+ [json|[{"a":"scalars", "b":"foo", "c":1, "d":"[10,20)"}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns composite type in exposed schema" $+ post "/rpc/ret_point_2d" [json|{}|] `shouldRespondWith`+ [json|[{"x": 10, "y": 5}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "cannot return composite type in hidden schema" $+ post "/rpc/ret_point_3d" [json|{}|] `shouldRespondWith` 401++ it "returns single row from table" $+ post "/rpc/single_article?select=id" [json|{"id": 2}|] `shouldRespondWith`+ [json|[{"id": 2}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns null for void" $+ post "/rpc/ret_void" [json|{}|] `shouldRespondWith`+ [json|null|]+ { matchHeaders = [matchContentTypeJson] }++ context "improper input" $ do+ it "rejects unknown content type even if payload is good" $ do+ request methodPost "/rpc/sayhello"+ (acceptHdrs "audio/mpeg3") [json| { "name": "world" } |]+ `shouldRespondWith` 415+ request methodGet "/rpc/sayhello?name=world"+ (acceptHdrs "audio/mpeg3") ""+ `shouldRespondWith` 415+ it "rejects malformed json payload" $ do+ p <- request methodPost "/rpc/sayhello"+ (acceptHdrs "application/json") "sdfsdf"+ liftIO $ do+ simpleStatus p `shouldBe` badRequest400+ isErrorFormat (simpleBody p) `shouldBe` True+ it "treats simple plpgsql raise as invalid input" $ do+ p <- post "/rpc/problem" "{}"+ liftIO $ do+ simpleStatus p `shouldBe` badRequest400+ isErrorFormat (simpleBody p) `shouldBe` True++ context "unsupported verbs" $ do+ it "DELETE fails" $+ request methodDelete "/rpc/sayhello" [] ""+ `shouldRespondWith` 405+ it "PATCH fails" $+ request methodPatch "/rpc/sayhello" [] ""+ `shouldRespondWith` 405+ it "OPTIONS fails" $+ -- TODO: should return info about the function+ request methodOptions "/rpc/sayhello" [] ""+ `shouldRespondWith` 405++ it "executes the proc exactly once per request" $ do+ post "/rpc/callcounter" [json| {} |] `shouldRespondWith`+ [json|1|]+ { matchHeaders = [matchContentTypeJson] }+ post "/rpc/callcounter" [json| {} |] `shouldRespondWith`+ [json|2|]+ { matchHeaders = [matchContentTypeJson] }++ context "a proc that receives no parameters" $ do+ it "interprets empty string as empty json object on a post request" $+ post "/rpc/noparamsproc" BL.empty `shouldRespondWith`+ [json| "Return value of no parameters procedure." |]+ { matchHeaders = [matchContentTypeJson] }+ it "interprets empty string as a function with no args on a get request" $+ get "/rpc/noparamsproc" `shouldRespondWith`+ [json| "Return value of no parameters procedure." |]+ { matchHeaders = [matchContentTypeJson] }++ it "returns proper output when having the same return col name as the proc name" $ do+ post "/rpc/test" [json|{}|] `shouldRespondWith`+ [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }+ get "/rpc/test" `shouldRespondWith`+ [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }++ context "procs with OUT/INOUT params" $ do+ it "returns a scalar result when there is a single OUT param" $ do+ get "/rpc/single_out_param?num=5" `shouldRespondWith`+ [json|6|] { matchHeaders = [matchContentTypeJson] }+ get "/rpc/single_json_out_param?a=1&b=two" `shouldRespondWith`+ [json|{"a": 1, "b": "two"}|] { matchHeaders = [matchContentTypeJson] }++ it "returns a scalar result when there is a single INOUT param" $+ get "/rpc/single_inout_param?num=2" `shouldRespondWith`+ [json|3|] { matchHeaders = [matchContentTypeJson] }++ it "returns a row result when there are many OUT params" $+ get "/rpc/many_out_params" `shouldRespondWith`+ [json|[{"my_json":{"a": 1, "b": "two"},"num":3,"str":"four"}]|] { matchHeaders = [matchContentTypeJson] }++ it "returns a row result when there are many INOUT params" $+ get "/rpc/many_inout_params?num=1&str=two" `shouldRespondWith`+ [json| [{"num":1,"str":"two","b":true}]|] { matchHeaders = [matchContentTypeJson] }++ it "can map a RAISE error code and message to a http status" $+ get "/rpc/raise_pt402"+ `shouldRespondWith` [json|{ "hint": "Upgrade your plan", "details": "Quota exceeded" }|]+ { matchStatus = 402+ , matchHeaders = [matchContentTypeJson]+ }++ it "defaults to status 500 if RAISE code is PT not followed by a number" $+ get "/rpc/raise_bad_pt" `shouldRespondWith` 500++ context "only for POST rpc" $ do+ context "expects a single json object" $ do+ it "does not expand posted json into parameters" $+ request methodPost "/rpc/singlejsonparam"+ [("Prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`+ [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]+ { matchHeaders = [matchContentTypeJson] }++ it "accepts parameters from an html form" $+ request methodPost "/rpc/singlejsonparam"+ [("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]+ ("integer=7&double=2.71828&varchar=forms+are+fun&" <>+ "boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`+ [json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"+ , "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]+ { matchHeaders = [matchContentTypeJson] }++ it "gives a parse filter error if GET style proc args are specified" $+ post "/rpc/sayhello?name=John" [json|{}|] `shouldRespondWith` 400++ context "only for GET rpc" $ do+ it "should fail on mutating procs" $ do+ get "/rpc/callcounter" `shouldRespondWith` 500+ get "/rpc/setprojects?id_l=1&id_h=5&name=FreeBSD" `shouldRespondWith` 500++ it "should filter a proc that has arg name = filter name" $+ get "/rpc/get_projects_below?id=5&id=gt.2&select=id" `shouldRespondWith`+ [json|[{ "id": 3 }, { "id": 4 }]|]+ { matchHeaders = [matchContentTypeJson] }++ it "should work with filters that have the not operator" $ do+ get "/rpc/get_projects_below?id=5&id=not.gt.2&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/get_projects_below?id=5&id=not.in.(1,3)&select=id" `shouldRespondWith`+ [json|[{ "id": 2 }, { "id": 4 }]|]+ { matchHeaders = [matchContentTypeJson] }++ it "should work with filters that use the plain with language fts operator" $ do+ get "/rpc/get_tsearch?text_search_vector=fts(english).impossible" `shouldRespondWith`+ [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/get_tsearch?text_search_vector=plfts.impossible" `shouldRespondWith`+ [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`+ [json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }+ -- TODO: '@@' deprecated+ get "/rpc/get_tsearch?text_search_vector=@@(english).impossible" `shouldRespondWith`+ [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/get_tsearch?text_search_vector=not.@@(english).fun%7Crat" `shouldRespondWith`+ [json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }
test/Feature/SingularSpec.hs view
@@ -39,7 +39,8 @@ it "can shape plurality singular object routes" $ request methodGet "/projects_view?id=eq.1&select=id,name,clients{*},tasks{id,name}" [singular] "" `shouldRespondWith`- [str|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]+ [json|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]+ { matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"] } context "when updating rows" $ do
test/Feature/StructureSpec.hs view
@@ -4,7 +4,9 @@ import Test.Hspec.Wai import Network.HTTP.Types +import PostgREST.Config (docsVersion) import Control.Lens ((^?))+import Data.Aeson.Types (Value (..)) import Data.Aeson.Lens import Data.Aeson.QQ @@ -27,8 +29,15 @@ (acceptHdrs "application/openapi+json") "" `shouldRespondWith` 415 - describe "table" $+ it "includes postgrest.com current version api docs" $ do+ r <- simpleBody <$> get "/" + let docsUrl = r ^? key "externalDocs" . key "url"++ liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.com/en/" <> docsVersion <> "/api.html"))++ describe "table" $ do+ it "includes paths to tables" $ do r <- simpleBody <$> get "/" @@ -76,7 +85,7 @@ deleteResponse `shouldBe` Just "No Content" - it "includes definitions to tables" $ do+ it "includes definitions to tables" $ do r <- simpleBody <$> get "/" let def = r ^? key "definitions" . key "child_entities"@@ -108,8 +117,22 @@ } |] - describe "RPC" $+ it "doesn't include privileged table for anonymous" $ do+ r <- simpleBody <$> get "/"+ let tablePath = r ^? key "paths" . key "/authors_only" + liftIO $ tablePath `shouldBe` Nothing++ it "includes table if user has permission" $ do+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"+ r <- simpleBody <$> request methodGet "/" [auth] ""+ let tableTag = r ^? key "paths" . key "/authors_only"+ . key "post" . key "tags"+ . nth 0+ liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]++ describe "RPC" $ do+ it "includes body schema for arguments" $ do r <- simpleBody <$> get "/" let args = r ^? key "paths" . key "/rpc/varied_arguments"@@ -161,6 +184,37 @@ "type": "object" } |]++ it "doesn't include privileged function for anonymous" $ do+ r <- simpleBody <$> get "/"+ let funcPath = r ^? key "paths" . key "/rpc/privileged_hello"++ liftIO $ funcPath `shouldBe` Nothing++ it "includes function if user has permission" $ do+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"+ r <- simpleBody <$> request methodGet "/" [auth] ""+ let funcTag = r ^? key "paths" . key "/rpc/privileged_hello"+ . key "post" . key "tags"+ . nth 0++ liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|]++ it "doesn't include OUT params of function as required parameters" $ do+ r <- simpleBody <$> get "/"+ let params = r ^? key "paths" . key "/rpc/many_out_params"+ . key "post" . key "parameters" . nth 0+ . key "schema". key "required"++ liftIO $ params `shouldBe` Nothing++ it "includes INOUT params(with no DEFAULT) of function as required parameters" $ do+ r <- simpleBody <$> get "/"+ let params = r ^? key "paths" . key "/rpc/many_inout_params"+ . key "post" . key "parameters" . nth 0+ . key "schema". key "required"++ liftIO $ params `shouldBe` Just [aesonQQ|["num", "str"]|] describe "Allow header" $ do
test/Main.hs view
@@ -5,14 +5,17 @@ import qualified Hasql.Pool as P -import PostgREST.DbStructure (getDbStructure) import PostgREST.App (postgrest)+import PostgREST.Config (pgVersion96)+import PostgREST.DbStructure (getDbStructure, getPgVersion)+import PostgREST.Types (DbStructure(..)) import Data.Function (id) import Data.IORef import qualified Feature.AuthSpec import qualified Feature.AsymmetricJwtSpec import qualified Feature.BinaryJwtSecretSpec+import qualified Feature.AudienceJwtSecretSpec import qualified Feature.ConcurrentSpec import qualified Feature.CorsSpec import qualified Feature.DeleteSpec@@ -26,6 +29,9 @@ import qualified Feature.UnicodeSpec import qualified Feature.ProxySpec import qualified Feature.AndOrParamsSpec+import qualified Feature.RpcSpec+import qualified Feature.NonexistentSchemaSpec+import qualified Feature.PgVersion96Spec import Protolude @@ -36,17 +42,42 @@ pool <- P.acquire (3, 10, toS testDbConn) - result <- P.use pool $ getDbStructure "test"- refDbStructure <- newIORef $ Just $ either (panic.show) id result- let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool $ pure ()- ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool $ pure ()- unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool $ pure ()- proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool $ pure ()- noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool $ pure ()- binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool $ pure ()- asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool $ pure ()+ result <- P.use pool $ getDbStructure "test" =<< getPgVersion + dbStructure <- pure $ either (panic.show) id result++ refDbStructure <- newIORef $ Just dbStructure++ let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool $ pure ()+ ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool $ pure ()+ unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool $ pure ()+ proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool $ pure ()+ noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool $ pure ()+ binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool $ pure ()+ audJwtApp = return $ postgrest (testCfgAudienceJWT testDbConn) refDbStructure pool $ pure ()+ asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool $ pure ()+ nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool $ pure ()+ let reset = resetDb testDbConn+ actualPgVersion = pgVersion dbStructure+ pg96spec | actualPgVersion >= pgVersion96 = [("Feature.PgVersion96Spec" , Feature.PgVersion96Spec.spec)]+ | otherwise = []++ specs = uncurry describe <$> [+ ("Feature.AuthSpec" , Feature.AuthSpec.spec)+ , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)+ , ("Feature.CorsSpec" , Feature.CorsSpec.spec)+ , ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)+ , ("Feature.InsertSpec" , Feature.InsertSpec.spec)+ , ("Feature.QuerySpec" , Feature.QuerySpec.spec)+ , ("Feature.RpcSpec" , Feature.RpcSpec.spec)+ , ("Feature.RangeSpec" , Feature.RangeSpec.spec)+ , ("Feature.SingularSpec" , Feature.SingularSpec.spec)+ , ("Feature.StructureSpec" , Feature.StructureSpec.spec)+ , ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec)+ , ("Feature.NonexistentSchemaSpec" , Feature.NonexistentSchemaSpec.spec)+ ] ++ pg96spec+ hspec $ do mapM_ (beforeAll_ reset . before withApp) specs @@ -70,20 +101,14 @@ beforeAll_ reset . before binaryJwtApp $ describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec + -- this test runs with a binary JWT secret and an audience claim+ beforeAll_ reset . before audJwtApp $+ describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec+ -- this test runs with asymmetric JWK beforeAll_ reset . before asymJwkApp $ describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec - where- specs = map (uncurry describe) [- ("Feature.AuthSpec" , Feature.AuthSpec.spec)- , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)- , ("Feature.CorsSpec" , Feature.CorsSpec.spec)- , ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)- , ("Feature.InsertSpec" , Feature.InsertSpec.spec)- , ("Feature.QuerySpec" , Feature.QuerySpec.spec)- , ("Feature.RangeSpec" , Feature.RangeSpec.spec)- , ("Feature.SingularSpec" , Feature.SingularSpec.spec)- , ("Feature.StructureSpec" , Feature.StructureSpec.spec)- , ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec)- ]+ -- this test runs with a nonexistent db-schema+ beforeAll_ reset . before nonexistentSchemaApp $+ describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec
test/SpecHelper.hs view
@@ -68,7 +68,7 @@ _baseCfg = -- Connection Settings AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000 -- Jwt settings- (Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False+ (Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False Nothing -- Connection Modifiers 10 Nothing (Just "test.switch_role") -- Debug Settings@@ -95,11 +95,21 @@ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" } +testCfgAudienceJWT :: Text -> AppConfig+testCfgAudienceJWT testDbConn = (testCfg testDbConn) {+ configJwtSecret = Just . B64.decodeLenient $+ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=",+ configJwtAudience = Just "youraudience"+ }+ testCfgAsymJWK :: Text -> AppConfig testCfgAsymJWK testDbConn = (testCfg testDbConn) { configJwtSecret = Just $ encodeUtf8 [str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|] }++testNonexistentSchemaCfg :: Text -> AppConfig+testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" } setupDb :: Text -> IO () setupDb dbConn = do