packages feed

postgrest 7.0.1 → 8.0.0

raw patch · 75 files changed

+8586/−5708 lines, 75 filesdep +fast-loggerdep +hasql-dynamic-statementsdep +hasql-notificationsdep ~hasql-transactiondep ~networksetup-changed

Dependencies added: fast-logger, hasql-dynamic-statements, hasql-notifications, mtl, wai-logger

Dependency ranges changed: hasql-transaction, network

Files

CHANGELOG.md view
@@ -9,6 +9,66 @@  ### Fixed +## [8.0.0] - 2021-07-25++### Added++ - #1525, Allow http status override through response.status guc - @steve-chavez+ - #1512, Allow schema cache reloading with NOTIFY - @steve-chavez+ - #1119, Allow config file reloading with SIGUSR2 - @steve-chavez+ - #1558, Allow 'Bearer' with and without capitalization as authentication schema - @wolfgangwalther+ - #1470, Allow calling RPC with variadic argument by passing repeated params - @wolfgangwalther+ - #1559, No downtime when reloading the schema cache with SIGUSR1 - @steve-chavez+ - #504, Add `log-level` config option. The admitted levels are: crit, error, warn and info - @steve-chavez+ - #1607, Enable embedding through multiple views recursively - @wolfgangwalther+ - #1598, Allow rollback of the transaction with Prefer tx=rollback - @wolfgangwalther+ - #1633, Enable prepared statements for GET filters. When behind a connection pooler, you can disable preparing with `db-prepared-statements=false`+   + This increases throughput by around 30% for simple GET queries(no embedding, with filters applied).+ - #1729, #1760, Get configuration parameters from the db and allow reloading config with NOTIFY  - @steve-chavez+ - #1824, Allow OPTIONS to generate certain HTTP methods for a DB view - @laurenceisla+ - #1872, Show timestamps in startup/worker logs - @steve-chavez+ - #1881, Add `openapi-mode` config option that allows ignoring roles privileges when showing the OpenAPI output - @steve-chavez+ - CLI options(for debugging):+   + #1678, Add --dump-config CLI option that prints loaded config and exits - @wolfgangwalther+   + #1691, Add --example CLI option to show example config file - @wolfgangwalther+   + #1697, #1723, Add --dump-schema CLI option for debugging purposes - @monacoremo, @wolfgangwalther+ - #1794, (Experimental) Add `request.spec` GUC for db-root-spec - @steve-chavez++### Fixed++ - #1592, Removed single column restriction to allow composite foreign keys in join tables - @goteguru+ - #1530, Fix how the PostgREST version is shown in the help text when the `.git` directory is not available - @monacoremo+ - #1094, Fix expired JWTs starting an empty transaction on the db - @steve-chavez+ - #1162, Fix location header for POST request with select= without PK - @wolfgangwalther+ - #1585, Fix error messages on connection failure for localized postgres on Windows - @wolfgangwalther+ - #1636, Fix `application/octet-stream` appending `charset=utf-8` - @steve-chavez+ - #1469, #1638 Fix overloading of functions with unnamed arguments - @wolfgangwalther+ - #1560, Return 405 Method not Allowed for GET of volatile RPC instead of 500 - @wolfgangwalther+ - #1584, Fix RPC return type handling and embedding for domains with composite base type (#1615) - @wolfgangwalther+ - #1608, #1635, Fix embedding through views that have COALESCE with subselect - @wolfgangwalther+ - #1572, Fix parsing of boolean config values for Docker environment variables, now it accepts double quoted truth values ("true", "false") and numbers("1", "0") - @wolfgangwalther+ - #1624, Fix using `app.settings.xxx` config options in Docker, now they can be used as `PGRST_APP_SETTINGS_xxx` - @wolfgangwalther+ - #1814, Fix panic when attempting to run with unix socket on non-unix host and properly close unix domain socket on exit - @monacoremo+ - #1825, Disregard internal junction(in non-exposed schema) when embedding - @steve-chavez+ - #1846, Fix requests for overloaded functions from html forms to no longer hang (#1848) - @laurenceisla+ - #1858, Add a hint and clarification to the no relationship found error - @laurenceisla+ - #1841, Show comprehensive error when an RPC is not found in a stale schema cache - @laurenceisla+ - #1875, Fix Location headers in headers only representation for null PK inserts on views - @laurenceisla++### Changed++ - #1522, #1528, #1535, Docker images are now built from scratch based on a the static PostgREST executable (#1494) and with Nix instead of a `Dockerfile`. This reduces the compressed image size from over 30mb to about 4mb - @monacoremo+ - #1461, Location header for POST request is only included when PK is available on the table - @wolfgangwalther+ - #1560, Volatile RPC called with GET now returns 405 Method not Allowed instead of 500 - @wolfgangwalther+ - #1584, #1849 Functions that declare `returns composite_type` no longer return a single object array by default, only functions with `returns setof composite_type` return an array of objects - @wolfgangwalther+ - #1604, Change the default logging level to `log-level=error`. Only requests with a status greater or equal than 500 will be logged. If you wish to go back to the previous behaviour and log all the requests, use `log-level=info` - @steve-chavez+   + Because currently there's no buffering for logging, defaulting to the `error` level(minimum logging) increases throughput by around 15% for simple GET queries(no embedding, with filters applied).+ - #1617, Dropped support for PostgreSQL 9.4 - @wolfgangwalther+ - #1679, Renamed config settings with fallback aliases. e.g. `max-rows` is now `db-max-rows`, but `max-rows` is still accepted - @wolfgangwalther+ - #1656, Allow `Prefer=headers-only` on POST requests and change default to `minimal` (#1813) - @laurenceisla+ - #1854, Dropped undocumented support for gzip compression (which was surprisingly slow and easily enabled by mistake). In some use-cases this makes Postgres up to 3x faster - @aljungberg+ - #1872, Send startup/worker logs to stderr to differentiate from access logs on stdout - @steve-chavez+ ## [7.0.1] - 2020-05-18  ### Fixed@@ -18,6 +78,10 @@ - #1500, Fix missing `openapi-server-proxy-uri` config option - @steve-chavez - #1508, Fix `Content-Profile` not working for POST RPC - @steve-chavez - #1452, Fix PUT restriction for all columns - @steve-chavez++### Changed++- From this version onwards, the release page will only include a single Linux static executable that can be run on any Linux distribution.  ## [7.0.0] - 2020-04-03 
Setup.hs view
@@ -1,2 +1,3 @@+-- This file is required by Hackage. import Distribution.Simple main = defaultMain
main/Main.hs view
@@ -1,330 +1,47 @@ {-# LANGUAGE CPP #-} -module Main where+module Main (main) where -import qualified Data.ByteString            as BS-import qualified Data.ByteString.Base64     as B64-import qualified Hasql.Pool                 as P-import qualified Hasql.Transaction.Sessions as HT+import qualified Data.Map.Strict as M -import Control.AutoUpdate       (defaultUpdateSettings, mkAutoUpdate,-                                 updateAction)-import Control.Retry            (RetryStatus, capDelay,-                                 exponentialBackoff, retrying,-                                 rsPreviousDelay)-import Data.Either.Combinators  (whenLeft)-import Data.IORef               (IORef, atomicWriteIORef, newIORef,-                                 readIORef)-import Data.String              (IsString (..))-import Data.Text                (pack, replace, strip, stripPrefix)-import Data.Text.IO             (hPutStrLn)-import Data.Time.Clock          (getCurrentTime)-import Network.Wai.Handler.Warp (defaultSettings, runSettings,-                                 setHost, setPort, setServerName)-import System.IO                (BufferMode (..), hSetBuffering)+import System.IO (BufferMode (..), hSetBuffering) -import PostgREST.App         (postgrest)-import PostgREST.Config      (AppConfig (..), configPoolTimeout',-                              prettyVersion, readOptions)-import PostgREST.DbStructure (getDbStructure, getPgVersion)-import PostgREST.Error       (PgError (PgError), checkIsFatal,-                              errorPayload)-import PostgREST.OpenAPI     (isMalformedProxyUri)-import PostgREST.Types       (ConnectionStatus (..), DbStructure,-                              PgVersion (..), Schema,-                              minimumPgVersion)-import Protolude             hiding (hPutStrLn, head, replace, toS)-import Protolude.Conv        (toS)+import qualified PostgREST.App as App+import qualified PostgREST.CLI as CLI +import PostgREST.Config (readPGRSTEnvironment) +import Protolude+ #ifndef mingw32_HOST_OS-import System.Posix.Signals-import UnixSocket+import qualified PostgREST.Unix as Unix #endif --{-|-  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-  the previous invocation has not terminated. In all cases this method does not-  halt the calling thread, the work is preformed in a separate thread.--  Note: 'atomicWriteIORef' is essentially a lazy semaphore that prevents two-  threads from running 'connectionWorker' at the same time.--  Background thread that does the following :-  1. Tries to connect to pg server and will keep trying until success.-  2. Checks if the pg version is supported and if it's not it kills the main-     program.-  3. Obtains the dbStructure.-  4. If 2 or 3 fail to give their result it means the connection is down so it-     goes back to 1, otherwise it finishes his work successfully.--}-connectionWorker-  :: ThreadId -- ^ This thread is killed if pg version is unsupported-  -> P.Pool   -- ^ The PostgreSQL connection pool-  -> [Schema] -- ^ Schemas PostgREST is serving up-  -> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'-  -> IORef Bool                -- ^ Used as a binary Semaphore-  -> IO ()-connectionWorker mainTid pool schemas refDbStructure refIsWorkerOn = do-  isWorkerOn <- readIORef refIsWorkerOn-  unless isWorkerOn $ do-    atomicWriteIORef refIsWorkerOn True-    void $ forkIO work-  where-    work = do-      atomicWriteIORef refDbStructure Nothing-      putStrLn ("Attempting to connect to the database..." :: Text)-      connected <- connectionStatus pool-      case connected of-        FatalConnectionError reason -> hPutStrLn stderr reason-                                      >> killThread mainTid    -- Fatal error when connecting-        NotConnected                -> return ()               -- Unreachable-        Connected actualPgVersion   -> do                      -- Procede with initialization-          result <- P.use pool $ do-            dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schemas actualPgVersion-            liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure-          case result of-            Left e -> do-              putStrLn ("Failed to query the database. Retrying." :: Text)-              hPutStrLn stderr . toS . errorPayload $ PgError False e-              work--            Right _ -> do-              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-  the first time the connection is tested, but only to test before-  calling 'getDbStructure' inside the 'connectionWorker' method.--  The connection tries are capped, but if the connection times out no error is-  thrown, just 'False' is returned.--}-connectionStatus :: P.Pool -> IO ConnectionStatus-connectionStatus pool =-  retrying (capDelay 32000000 $ exponentialBackoff 1000000)-           shouldRetry-           (const $ P.release pool >> getConnectionStatus)-  where-    getConnectionStatus :: IO ConnectionStatus-    getConnectionStatus = do-      pgVersion <- P.use pool getPgVersion-      case pgVersion of-        Left e -> do-          let err = PgError False e-          hPutStrLn stderr . toS $ errorPayload err-          case checkIsFatal err of-            Just reason -> return $ FatalConnectionError reason-            Nothing     -> return NotConnected--        Right version ->-          if version < minimumPgVersion-             then return . FatalConnectionError $ "Cannot run in this PostgreSQL version, PostgREST needs at least " <> pgvName minimumPgVersion-             else return . Connected  $ version--    shouldRetry :: RetryStatus -> ConnectionStatus -> IO Bool-    shouldRetry rs isConnSucc = do-      let delay    = fromMaybe 0 (rsPreviousDelay rs) `div` 1000000-          itShould = NotConnected == isConnSucc-      when itShould $-        putStrLn $ "Attempting to reconnect to the database in " <> (show delay::Text) <> " seconds..."-      return itShould---{-|-  This is where everything starts.--} main :: IO () main = do-  ---  -- LineBuffering: the entire output buffer is flushed whenever a newline is-  -- output, the buffer overflows, a hFlush is issued or the handle is closed-  ---  -- NoBuffering: output is written immediately and never stored in the buffer-  hSetBuffering stdout LineBuffering-  hSetBuffering stdin LineBuffering-  hSetBuffering stderr NoBuffering-  ---  -- readOptions builds the 'AppConfig' from the config file specified on the-  -- command line-  conf <- loadDbUriFile =<< loadSecretFile =<< readOptions-  let schemas = toList $ configSchemas conf-      host = configHost conf-      port = configPort conf-      proxy = configOpenAPIProxyUri conf-      maybeSocketAddr = configSocket conf-      socketFileMode = configSocketMode conf-      pgSettings = toS (configDatabase conf) -- is the db-uri-      roleClaimKey = configRoleClaimKey conf-      appSettings =-        setHost ((fromString . toS) host) -- Warp settings-        . setPort port-        . setServerName (toS $ "postgrest/" <> prettyVersion) $-        defaultSettings---  whenLeft socketFileMode panic--  -- Checks that the provided proxy uri is formated correctly-  when (isMalformedProxyUri $ toS <$> proxy) $-    panic-      "Malformed proxy uri, a correct example: https://example.com:8443/basePath"--  -- Checks that the provided jspath is valid-  whenLeft roleClaimKey $-    panic $ show roleClaimKey+  setBuffering+  hasPGRSTEnv <- not . M.null <$> readPGRSTEnvironment+  opts <- CLI.readCLIShowHelp hasPGRSTEnv+  CLI.main installSignalHandlers runAppInSocket opts -  -- create connection pool with the provided settings, returns either-  -- a 'Connection' or a 'ConnectionError'. Does not throw.-  pool <- P.acquire (configPool conf, configPoolTimeout' conf, pgSettings)-  ---  -- To be filled in by connectionWorker-  refDbStructure <- newIORef Nothing-  ---  -- Helper ref to make sure just one connectionWorker can run at a time-  refIsWorkerOn <- newIORef False-  ---  -- This is passed to the connectionWorker method so it can kill the main-  -- thread if the PostgreSQL's version is not supported.-  mainTid <- myThreadId-  ---  -- Sets the refDbStructure-  connectionWorker-    mainTid-    pool-    schemas-    refDbStructure-    refIsWorkerOn-  ---  -- Only for systems with signals:-  ---  -- releases the connection pool whenever the program is terminated,-  -- see issue #268-  ---  -- Plus the SIGHUP signal updates the internal 'DbStructure' by running-  -- 'connectionWorker' exactly as before.+installSignalHandlers :: App.SignalHandlerInstaller #ifndef mingw32_HOST_OS-  forM_ [sigINT, sigTERM] $ \sig ->-    void $ installHandler sig (Catch $ do-        P.release pool-        throwTo mainTid UserInterrupt-      ) Nothing--  void $ installHandler sigUSR1 (-    Catch $ connectionWorker-              mainTid-              pool-              schemas-              refDbStructure-              refIsWorkerOn-    ) Nothing+installSignalHandlers = Unix.installSignalHandlers+#else+installSignalHandlers _ = pass #endif --  -- ask for the OS time at most once per second-  getTime <- mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}--  let postgrestApplication =-        postgrest-          conf-          refDbStructure-          pool-          getTime-          (connectionWorker-             mainTid-             pool-             schemas-             refDbStructure-             refIsWorkerOn)--  -- run the postgrest application with user defined socket. Only for UNIX systems.+runAppInSocket :: Maybe App.SocketRunner #ifndef mingw32_HOST_OS-  whenJust maybeSocketAddr $-    runAppInSocket appSettings postgrestApplication socketFileMode+runAppInSocket = Just Unix.runAppWithSocket+#else+runAppInSocket = Nothing #endif -  -- run the postgrest application-  whenNothing maybeSocketAddr $ do-    putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)-    runSettings appSettings postgrestApplication--{-|-  The purpose of this function is to load the JWT secret from a file if-  configJwtSecret is actually a filepath and replaces some characters if the JWT-  is base64 encoded.--  The reason some characters need to be replaced is because JWT is actually-  base64url encoded which must be turned into just base64 before decoding.--  To check if the JWT secret is provided is in fact a file path, it must be-  decoded as 'Text' to be processed.--  decodeUtf8: Decode a ByteString containing UTF-8 encoded text that is known to-  be valid.--}-loadSecretFile :: AppConfig -> IO AppConfig-loadSecretFile conf = extractAndTransform mSecret-  where-    mSecret = decodeUtf8 <$> configJwtSecret conf-    isB64 = configJwtSecretIsBase64 conf-    ---    -- The Text (variable name secret) here is mSecret from above which is the JWT-    -- decoded as Utf8-    ---    -- stripPrefix: Return the suffix of the second string if its prefix matches-    -- the entire first string.-    ---    -- The configJwtSecret is a filepath instead of the JWT secret itself if the-    -- secret has @ as its prefix.-    extractAndTransform :: Maybe Text -> IO AppConfig-    extractAndTransform Nothing = return conf-    extractAndTransform (Just secret) =-      fmap setSecret $-      transformString isB64 =<<-      case stripPrefix "@" secret of-        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 -> ByteString -> IO ByteString-    transformString False t = return t-    transformString True t =-      case B64.decode $ encodeUtf8 $ strip $ replaceUrlChars $ decodeUtf8 t of-        Left errMsg -> panic $ pack errMsg-        Right bs    -> return bs-    setSecret bs = conf {configJwtSecret = Just bs}-    ---    -- replace: Replace every occurrence of one substring with another-    replaceUrlChars =-      replace "_" "/" . replace "-" "+" . replace "." "="--{--  Load database uri from a separate file if `db-uri` is a filepath.--}-loadDbUriFile :: AppConfig -> IO AppConfig-loadDbUriFile conf = extractDbUri mDbUri-  where-    mDbUri = configDatabase conf-    extractDbUri :: Text -> IO AppConfig-    extractDbUri dbUri =-      fmap setDbUri $-      case stripPrefix "@" dbUri of-        Nothing       -> return dbUri-        Just filename -> strip <$> readFile (toS filename)-    setDbUri dbUri = conf {configDatabase = dbUri}---- Utilitarian functions.-whenJust :: Applicative f => Maybe a -> (a -> f ()) -> f ()-whenJust (Just x) f = f x-whenJust Nothing _  = pass--whenNothing :: Applicative f => Maybe a -> f () -> f ()-whenNothing Nothing f = f-whenNothing _       _ = pass+setBuffering :: IO ()+setBuffering = do+  -- LineBuffering: the entire output buffer is flushed whenever a newline is+  -- output, the buffer overflows, a hFlush is issued or the handle is closed+  hSetBuffering stdout LineBuffering+  hSetBuffering stdin LineBuffering+  hSetBuffering stderr LineBuffering
− main/UnixSocket.hs
@@ -1,40 +0,0 @@-module UnixSocket (-  runAppInSocket-)where--import Network.Socket           (Family (AF_UNIX),-                                 SockAddr (SockAddrUnix), Socket,-                                 SocketType (Stream), bind, close,-                                 defaultProtocol, listen,-                                 maxListenQueue, socket)-import Network.Wai              (Application)-import Network.Wai.Handler.Warp-import System.Directory         (removeFile)-import System.IO.Error          (isDoesNotExistError)-import System.Posix.Files       (setFileMode)-import System.Posix.Types       (FileMode)--import Protolude--createAndBindSocket :: FilePath -> Maybe FileMode -> IO Socket-createAndBindSocket socketFilePath maybeSocketFileMode = do-  deleteSocketFileIfExist socketFilePath-  sock <- socket AF_UNIX Stream defaultProtocol-  bind sock $ SockAddrUnix socketFilePath-  mapM_ (setFileMode socketFilePath) maybeSocketFileMode-  return sock-  where-    deleteSocketFileIfExist path = removeFile path `catch` handleDoesNotExist-    handleDoesNotExist e-      | isDoesNotExistError e = return ()-      | otherwise = throwIO e---- run the postgrest application with user defined socket.-runAppInSocket :: Settings -> Application -> Either Text FileMode -> FilePath -> IO ()-runAppInSocket settings app socketFileMode sockPath = do-  sock <- createAndBindSocket sockPath (rightToMaybe socketFileMode)-  putStrLn $ ("Listening on unix socket " :: Text) <> show sockPath-  listen sock maxListenQueue-  runSettingsSocket settings sock app-  -- clean socket up when done-  close sock
postgrest.cabal view
@@ -1,5 +1,5 @@ name:               postgrest-version:            7.0.1+version:            8.0.0 synopsis:           REST API for any Postgres database description:        Reads the schema of a PostgreSQL database and creates RESTful routes                     for the tables and views, supporting all HTTP verbs that security@@ -19,36 +19,59 @@   type:     git   location: git://github.com/PostgREST/postgrest.git -flag FailOnWarn+flag dev   default:     False   manual:      True-  description: No warnings allowed+  description: Development flags +flag hpc+  default:     True+  manual:      True+  description: Enable HPC (dev only)+ library-  exposed-modules:    PostgREST.ApiRequest-                      PostgREST.App+  default-language:   Haskell2010+  default-extensions: OverloadedStrings+                      NoImplicitPrelude+  hs-source-dirs:     src+  exposed-modules:    PostgREST.App+                      PostgREST.AppState                       PostgREST.Auth+                      PostgREST.CLI                       PostgREST.Config-                      PostgREST.DbRequestBuilder+                      PostgREST.Config.Database+                      PostgREST.Config.JSPath+                      PostgREST.Config.PgVersion+                      PostgREST.Config.Proxy+                      PostgREST.ContentType                       PostgREST.DbStructure+                      PostgREST.DbStructure.Identifiers+                      PostgREST.DbStructure.Proc+                      PostgREST.DbStructure.Relationship+                      PostgREST.DbStructure.Table                       PostgREST.Error+                      PostgREST.GucHeader                       PostgREST.Middleware                       PostgREST.OpenAPI-                      PostgREST.Parsers-                      PostgREST.QueryBuilder-                      PostgREST.Statements+                      PostgREST.Query.QueryBuilder+                      PostgREST.Query.SqlFragment+                      PostgREST.Query.Statements                       PostgREST.RangeQuery-                      PostgREST.Types+                      PostgREST.Request.ApiRequest+                      PostgREST.Request.DbRequestBuilder+                      PostgREST.Request.Parsers+                      PostgREST.Request.Preferences+                      PostgREST.Request.Types+                      PostgREST.Version+                      PostgREST.Workers   other-modules:      Paths_postgrest-                      PostgREST.Private.Common-                      PostgREST.Private.QueryFragment-  hs-source-dirs:     src   build-depends:      base                      >= 4.9 && < 4.15                     , HTTP                      >= 4000.3.7 && < 4000.4                     , Ranged-sets               >= 0.3 && < 0.5-                    , aeson                     >= 1.4.7 && < 1.5+                    , aeson                     >= 1.4.7 && < 1.6                     , ansi-wl-pprint            >= 0.6.7 && < 0.7-                    , base64-bytestring         >= 1 && < 1.2+                    , auto-update               >= 0.1.4 && < 0.2+                    , base64-bytestring         >= 1 && < 1.3                     , bytestring                >= 0.10.8 && < 0.11                     , case-insensitive          >= 1.2 && < 1.3                     , cassava                   >= 0.4.5 && < 0.6@@ -58,22 +81,27 @@                     , contravariant-extras      >= 0.3.3 && < 0.4                     , cookie                    >= 0.4.2 && < 0.5                     , either                    >= 4.4.1 && < 5.1+                    , fast-logger               >= 2.4.5                     , gitrev                    >= 1.2 && < 1.4                     , hasql                     >= 1.4 && < 1.5+                    , hasql-dynamic-statements  == 0.3.1+                    , hasql-notifications       >= 0.1 && < 0.3                     , hasql-pool                >= 0.5 && < 0.6-                    , hasql-transaction         >= 0.7.2 && < 1.1+                    , hasql-transaction         >= 1.0.1 && < 1.1                     , heredoc                   >= 0.2 && < 0.3                     , http-types                >= 0.12.2 && < 0.13                     , insert-ordered-containers >= 0.2.2 && < 0.3                     , interpolatedstring-perl6  >= 1 && < 1.1                     , jose                      >= 0.8.1 && < 0.9-                    , lens                      >= 4.14 && < 4.20+                    , lens                      >= 4.14 && < 5.1                     , lens-aeson                >= 1.0.1 && < 1.2+                    , mtl                       >= 2.2.2 && < 2.3                     , network-uri               >= 2.6.1 && < 2.8-                    , optparse-applicative      >= 0.13 && < 0.16+                    , optparse-applicative      >= 0.13 && < 0.17                     , parsec                    >= 3.1.11 && < 3.2                     , protolude                 >= 0.3 && < 0.4                     , regex-tdfa                >= 1.2.2 && < 1.4+                    , retry                     >= 0.7.4 && < 0.9                     , scientific                >= 0.3.4 && < 0.4                     , swagger2                  >= 2.4 && < 2.7                     , text                      >= 1.2.2 && < 1.3@@ -82,63 +110,61 @@                     , vector                    >= 0.11 && < 0.13                     , wai                       >= 3.2.1 && < 3.3                     , wai-cors                  >= 0.2.5 && < 0.3-                    , wai-extra                 >= 3.0.19 && < 3.1-                    , wai-middleware-static     >= 0.8.1 && < 0.9-  default-language:   Haskell2010-  default-extensions: OverloadedStrings-                      QuasiQuotes-                      NoImplicitPrelude-  if flag(FailOnWarn)-    ghc-options: -O2 -Werror -Wall -fwarn-identities-                 -fno-spec-constr -optP-Wno-nonportable-include-path+                    , wai-extra                 >= 3.0.19 && < 3.2+                    , wai-logger                >= 2.3.2+                    , wai-middleware-static     >= 0.8.1 && < 0.10+                    , warp                      >= 3.2.12 && < 3.4+                      -- -fno-spec-constr may help keep compile time memory use in check,+                      --   see https://gitlab.haskell.org/ghc/ghc/issues/16017#note_219304+                      -- -optP-Wno-nonportable-include-path+                      --   prevents build failures on case-insensitive filesystems (macos),+                      --   see https://github.com/commercialhaskell/stack/issues/3918+  ghc-options:        -Wall -fwarn-identities+                      -fno-spec-constr -optP-Wno-nonportable-include-path++  if flag(dev)+    ghc-options: --disable-optimizations+    if flag(hpc)+      ghc-options: --enable-coverage -hpcdir .hpc   else-    ghc-options: -O2 -Wall -fwarn-identities-                 -fno-spec-constr -optP-Wno-nonportable-include-path-          -- -fno-spec-constr may help keep compile time memory use in check,-          --   see https://gitlab.haskell.org/ghc/ghc/issues/16017#note_219304-          -- -optP-Wno-nonportable-include-path-          --   prevents build failures on case-insensitive filesystems (macos),-          --   see https://github.com/commercialhaskell/stack/issues/3918+    ghc-options: -O2 +  if !os(windows)+    build-depends:+      unix+      , directory >= 1.2.6 && < 1.4+      , network   >= 2.6 && < 3.2+    exposed-modules:+      PostgREST.Unix+ executable postgrest-  main-is:            Main.hs-  hs-source-dirs:     main-  build-depends:      base              >= 4.9 && < 4.15-                    , auto-update       >= 0.1.4 && < 0.2-                    , base64-bytestring >= 1 && < 1.2-                    , bytestring        >= 0.10.8 && < 0.11-                    , directory         >= 1.2.6 && < 1.4-                    , either            >= 4.4.1 && < 5.1-                    , hasql             >= 1.4 && < 1.5-                    , hasql-pool        >= 0.5 && < 0.6-                    , hasql-transaction >= 0.7.2 && < 1.1-                    , network           < 3.2-                    , postgrest-                    , protolude         >= 0.3 && < 0.4-                    , retry             >= 0.7.4 && < 0.9-                    , text              >= 1.2.2 && < 1.3-                    , time              >= 1.6 && < 1.11-                    , wai               >= 3.2.1 && < 3.3-                    , warp              >= 3.2.12 && < 3.4   default-language:   Haskell2010   default-extensions: OverloadedStrings-                      QuasiQuotes                       NoImplicitPrelude-  if flag(FailOnWarn)-    ghc-options:      -threaded -rtsopts "-with-rtsopts=-N -I2"-                      -O2 -Werror -Wall -fwarn-identities-                      -fno-spec-constr -optP-Wno-nonportable-include-path-  else-    ghc-options:      -threaded -rtsopts "-with-rtsopts=-N -I2"+  hs-source-dirs:     main+  main-is:            Main.hs+  build-depends:      base                >= 4.9 && < 4.15+                    , containers          >= 0.5.7 && < 0.7+                    , postgrest+                    , protolude           >= 0.3 && < 0.4+  ghc-options:        -threaded -rtsopts "-with-rtsopts=-N -I2"                       -O2 -Wall -fwarn-identities                       -fno-spec-constr -optP-Wno-nonportable-include-path -  if !os(windows)-    build-depends: unix-    other-modules: UnixSocket+  if flag(dev)+    ghc-options: --disable-optimizations+    if flag(hpc)+      ghc-options: --enable-coverage -hpcdir .hpc+  else+    ghc-options: -O2  test-suite spec   type:               exitcode-stdio-1.0+  default-language:   Haskell2010+  default-extensions: OverloadedStrings+                      QuasiQuotes+                      NoImplicitPrelude+  hs-source-dirs:     test   main-is:            Main.hs   other-modules:      Feature.AndOrParamsSpec                       Feature.AsymmetricJwtSpec@@ -148,36 +174,39 @@                       Feature.ConcurrentSpec                       Feature.CorsSpec                       Feature.DeleteSpec+                      Feature.DisabledOpenApiSpec                       Feature.EmbedDisambiguationSpec                       Feature.ExtraSearchPathSpec+                      Feature.HtmlRawOutputSpec                       Feature.InsertSpec+                      Feature.IgnorePrivOpenApiSpec                       Feature.JsonOperatorSpec+                      Feature.MultipleSchemaSpec                       Feature.NoJwtSpec                       Feature.NonexistentSchemaSpec-                      Feature.PgVersion95Spec-                      Feature.PgVersion96Spec+                      Feature.OpenApiSpec+                      Feature.OptionsSpec                       Feature.ProxySpec                       Feature.QueryLimitedSpec                       Feature.QuerySpec                       Feature.RangeSpec+                      Feature.RawOutputTypesSpec+                      Feature.RollbackSpec                       Feature.RootSpec+                      Feature.RpcPreRequestGucsSpec                       Feature.RpcSpec                       Feature.SingularSpec-                      Feature.StructureSpec                       Feature.UnicodeSpec+                      Feature.UpdateSpec                       Feature.UpsertSpec-                      Feature.RawOutputTypesSpec-                      Feature.HtmlRawOutputSpec-                      Feature.MultipleSchemaSpec                       SpecHelper                       TestTypes-  hs-source-dirs:     test   build-depends:      base              >= 4.9 && < 4.15-                    , aeson             >= 1.4.7 && < 1.5+                    , aeson             >= 1.4.7 && < 1.6                     , aeson-qq          >= 0.8.1 && < 0.9                     , async             >= 2.1.1 && < 2.3                     , auto-update       >= 0.1.4 && < 0.2-                    , base64-bytestring >= 1 && < 1.2+                    , base64-bytestring >= 1 && < 1.3                     , bytestring        >= 0.10.8 && < 0.11                     , case-insensitive  >= 1.2 && < 1.3                     , cassava           >= 0.4.5 && < 0.6@@ -185,13 +214,13 @@                     , contravariant     >= 1.4 && < 1.6                     , hasql             >= 1.4 && < 1.5                     , hasql-pool        >= 0.5 && < 0.6-                    , hasql-transaction >= 0.7.2 && < 1.1+                    , hasql-transaction >= 1.0.1 && < 1.1                     , heredoc           >= 0.2 && < 0.3                     , hspec             >= 2.3 && < 2.8-                    , hspec-wai         >= 0.10 && < 0.11-                    , hspec-wai-json    >= 0.10 && < 0.11+                    , hspec-wai         >= 0.10 && < 0.12+                    , hspec-wai-json    >= 0.10 && < 0.12                     , http-types        >= 0.12.3 && < 0.13-                    , lens              >= 4.14 && < 4.20+                    , lens              >= 4.14 && < 5.1                     , lens-aeson        >= 1.0.1 && < 1.2                     , monad-control     >= 1.0.1 && < 1.1                     , postgrest@@ -202,40 +231,41 @@                     , time              >= 1.6 && < 1.11                     , transformers-base >= 0.4.4 && < 0.5                     , wai               >= 3.2.1 && < 3.3-                    , wai-extra         >= 3.0.19 && < 3.1-  default-language:   Haskell2010-  default-extensions: OverloadedStrings-                      QuasiQuotes-                      NoImplicitPrelude-  ghc-options:        -threaded -rtsopts -with-rtsopts=-N+                    , wai-extra         >= 3.0.19 && < 3.2+  ghc-options:        --disable-optimizations -Wall -fwarn-identities+                      -fno-spec-constr -optP-Wno-nonportable-include-path+                      -fno-warn-missing-signatures -Test-Suite spec-querycost-  Type:                exitcode-stdio-1.0-  Default-Language:    Haskell2010-  default-extensions:  OverloadedStrings, QuasiQuotes, NoImplicitPrelude-  Hs-Source-Dirs:      test-  Main-Is:             QueryCost.hs-  Other-Modules:       SpecHelper-  Build-Depends:       base              >= 4.9 && < 4.15-                     , aeson             >= 1.4.7 && < 1.5+test-suite spec-querycost+  type:                exitcode-stdio-1.0+  default-language:    Haskell2010+  default-extensions:  OverloadedStrings+                       QuasiQuotes+                       NoImplicitPrelude+  hs-source-dirs:      test+  main-is:             QueryCost.hs+  other-modules:       SpecHelper+  build-depends:       base              >= 4.9 && < 4.15+                     , aeson             >= 1.4.7 && < 1.6                      , aeson-qq          >= 0.8.1 && < 0.9                      , async             >= 2.1.1 && < 2.3                      , auto-update       >= 0.1.4 && < 0.2-                     , base64-bytestring >= 1 && < 1.2+                     , base64-bytestring >= 1 && < 1.3                      , bytestring        >= 0.10.8 && < 0.11                      , case-insensitive  >= 1.2 && < 1.3                      , cassava           >= 0.4.5 && < 0.6                      , containers        >= 0.5.7 && < 0.7                      , contravariant     >= 1.4 && < 1.6                      , hasql             >= 1.4 && < 1.5+                     , hasql-dynamic-statements  == 0.3.1                      , hasql-pool        >= 0.5 && < 0.6-                     , hasql-transaction >= 0.7.2 && < 1.1+                     , hasql-transaction >= 1.0.1 && < 1.1                      , heredoc           >= 0.2 && < 0.3                      , hspec             >= 2.3 && < 2.8-                     , hspec-wai         >= 0.10 && < 0.11-                     , hspec-wai-json    >= 0.10 && < 0.11+                     , hspec-wai         >= 0.10 && < 0.12+                     , hspec-wai-json    >= 0.10 && < 0.12                      , http-types        >= 0.12.3 && < 0.13-                     , lens              >= 4.14 && < 4.20+                     , lens              >= 4.14 && < 5.1                      , lens-aeson        >= 1.0.1 && < 1.2                      , monad-control     >= 1.0.1 && < 1.1                      , postgrest@@ -246,4 +276,6 @@                      , time              >= 1.6 && < 1.11                      , transformers-base >= 0.4.4 && < 0.5                      , wai               >= 3.2.1 && < 3.3-                     , wai-extra         >= 3.0.19 && < 3.1+                     , wai-extra         >= 3.0.19 && < 3.2+  ghc-options:         --disable-optimizations -Wall -fwarn-identities+                       -fno-spec-constr -optP-Wno-nonportable-include-path
− src/PostgREST/ApiRequest.hs
@@ -1,346 +0,0 @@-{-|-Module      : PostgREST.ApiRequest-Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.--}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE MultiWayIf #-}--module PostgREST.ApiRequest (-  ApiRequest(..)-, InvokeMethod(..)-, ContentType(..)-, Action(..)-, Target(..)-, mutuallyAgreeable-, userApiRequest-) where--import qualified Data.Aeson           as JSON-import qualified Data.ByteString      as BS-import qualified Data.ByteString.Lazy as BL-import qualified Data.CaseInsensitive as CI-import qualified Data.Csv             as CSV-import qualified Data.HashMap.Strict  as M-import qualified Data.List            as L-import qualified Data.Set             as S-import qualified Data.Text            as T-import qualified Data.Vector          as V--import Control.Arrow             ((***))-import Data.Aeson.Types          (emptyArray, emptyObject)-import Data.List                 (last, lookup, partition)-import Data.List.NonEmpty        (head)-import Data.Maybe                (fromJust)-import Data.Ranged.Ranges        (Range (..), emptyRange,-                                  rangeIntersection)-import Network.HTTP.Base         (urlEncodeVars)-import Network.HTTP.Types.Header (hAuthorization, hCookie)-import Network.HTTP.Types.URI    (parseQueryReplacePlus,-                                  parseSimpleQuery)-import Network.Wai               (Request (..))-import Network.Wai.Parse         (parseHttpAccept)-import Web.Cookie                (parseCookiesText)---import Data.Ranged.Boundaries--import PostgREST.Error      (ApiRequestError (..))-import PostgREST.RangeQuery (NonnegRange, allRange, rangeGeq,-                             rangeLimit, rangeOffset, rangeRequested,-                             restrictRange)-import PostgREST.Types-import Protolude            hiding (head, toS)-import Protolude.Conv       (toS)--type RequestBody = BL.ByteString--data InvokeMethod = InvHead | InvGet | InvPost deriving Eq--- | Types of things a user wants to do to tables/views/procs-data Action = ActionCreate       | ActionRead{isHead :: Bool}-            | ActionUpdate       | ActionDelete-            | ActionSingleUpsert | ActionInvoke InvokeMethod-            | ActionInfo         | ActionInspect{isHead :: Bool}-            deriving Eq--- | The target db object of a user action-data Target = TargetIdent QualifiedIdentifier-            | TargetProc{tpQi :: QualifiedIdentifier, tpIsRootSpec :: Bool}-            | TargetDefaultSpec{tdsSchema :: Schema} -- The default spec offered at root "/"-            | TargetUnknown [Text]-            deriving Eq--{-|-  Describes what the user wants to do. This data type is a-  translation of the raw elements of an HTTP request into domain-  specific language.  There is no guarantee that the intent is-  sensible, it is up to a later stage of processing to determine-  if it is an action we are able to perform.--}-data ApiRequest = ApiRequest {-    iAction               :: Action                           -- ^ Similar but not identical to HTTP verb, e.g. Create/Invoke both POST-  , iRange                :: M.HashMap ByteString NonnegRange -- ^ Requested range of rows within response-  , iTopLevelRange        :: NonnegRange                      -- ^ Requested range of rows from the top level-  , iTarget               :: Target                           -- ^ The target, be it calling a proc or accessing a table-  , iAccepts              :: [ContentType]                    -- ^ Content types the client will accept, [CTAny] if no Accept header-  , iPayload              :: Maybe PayloadJSON                -- ^ Data sent by client and used for mutation actions-  , iPreferRepresentation :: PreferRepresentation             -- ^ If client wants created items echoed back-  , iPreferParameters     :: Maybe PreferParameters           -- ^ How to pass parameters to a stored procedure-  , iPreferCount          :: Maybe PreferCount                -- ^ Whether the client wants a result count-  , iPreferResolution     :: Maybe PreferResolution           -- ^ Whether the client wants to UPSERT or ignore records on PK conflict-  , iFilters              :: [(Text, Text)]                   -- ^ Filters on the result ("id", "eq.10")-  , iLogic                :: [(Text, Text)]                   -- ^ &and and &or parameters used for complex boolean logic-  , iSelect               :: Maybe Text                       -- ^ &select parameter used to shape the response-  , iOnConflict           :: Maybe Text                       -- ^ &on_conflict parameter used to upsert on specific unique keys-  , iColumns              :: Maybe Text                       -- ^ &columns parameter used to shape the payload-  , iOrder                :: [(Text, Text)]                   -- ^ &order parameters for each level-  , iCanonicalQS          :: ByteString                       -- ^ Alphabetized (canonical) request query string for response URLs-  , iJWT                  :: Text                             -- ^ JSON Web Token-  , iHeaders              :: [(Text, Text)]                   -- ^ HTTP request headers-  , iCookies              :: [(Text, Text)]                   -- ^ Request Cookies-  , iPath                 :: ByteString                       -- ^ Raw request path-  , iMethod               :: ByteString                       -- ^ Raw request method-  , iProfile              :: Maybe Schema                     -- ^ The request profile for enabling use of multiple schemas. Follows the spec in hhttps://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/.-  , iSchema               :: Schema                           -- ^ The request schema. Can vary depending on iProfile.-  }---- | Examines HTTP request and translates it into user intent.-userApiRequest :: NonEmpty Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest-userApiRequest confSchemas rootSpec req reqBody-  | isJust profile && fromJust profile `notElem` confSchemas = Left $ UnacceptableSchema $ toList confSchemas-  | isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate-  | topLevelRange == emptyRange = Left InvalidRange-  | shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload-  | otherwise = Right ApiRequest {-      iAction = action-      , iTarget = target-      , iRange = ranges-      , iTopLevelRange = topLevelRange-      , iAccepts = maybe [CTAny] (map decodeContentType . parseHttpAccept) $ lookupHeader "accept"-      , iPayload = relevantPayload-      , iPreferRepresentation = representation-      , iPreferParameters = if | hasPrefer (show SingleObject)     -> Just SingleObject-                               | hasPrefer (show MultipleObjects)  -> Just MultipleObjects-                               | otherwise                         -> Nothing-      , iPreferCount      = if | hasPrefer (show ExactCount)     -> Just ExactCount-                               | hasPrefer (show PlannedCount)   -> Just PlannedCount-                               | hasPrefer (show EstimatedCount) -> Just EstimatedCount-                               | otherwise                         -> Nothing-      , iPreferResolution = if | hasPrefer (show MergeDuplicates)  -> Just MergeDuplicates-                               | hasPrefer (show IgnoreDuplicates) -> Just IgnoreDuplicates-                               | otherwise                         -> Nothing-      , iFilters = filters-      , iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]-      , iSelect = toS <$> join (lookup "select" qParams)-      , iOnConflict = toS <$> join (lookup "on_conflict" qParams)-      , iColumns = columns-      , iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]-      , iCanonicalQS = toS $ urlEncodeVars-        . L.sortOn fst-        . map (join (***) toS . second (fromMaybe BS.empty))-        $ qString-      , iJWT = tokenStr-      , iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hCookie]-      , iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"-      , iPath = rawPathInfo req-      , iMethod = method-      , iProfile = profile-      , iSchema = schema-      }- where-  -- queryString with '+' converted to ' '(space)-  qString = parseQueryReplacePlus True $ rawQueryString req-  -- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)-  (filters, rpcQParams) =-    case action of-      ActionInvoke InvGet  -> partitionFlts-      ActionInvoke InvHead -> partitionFlts-      _                    -> (flts, [])-  partitionFlts = partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts-  flts =-    [ (toS k, toS $ fromJust v) |-      (k,v) <- qParams, isJust v,-      k `notElem` ["select", "columns"],-      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 = case target of-    TargetProc _ _ -> True-    _              -> False-  isTargetingDefaultSpec = case target of-    TargetDefaultSpec _ -> True-    _                   -> False-  contentType = decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type"-  columns-    | action `elem` [ActionCreate, ActionUpdate, ActionInvoke InvPost] = toS <$> join (lookup "columns" qParams)-    | otherwise = Nothing-  payload =-    case (contentType, action) of-      (_, ActionInvoke InvGet)  -> Right rpcPrmsToJson-      (_, ActionInvoke InvHead) -> Right rpcPrmsToJson-      (CTApplicationJSON, _) ->-        if isJust columns-          then Right $ RawJSON reqBody-          else note "All object keys must match" . payloadAttributes reqBody-                 =<< if BL.null reqBody && isTargetingProc-                       then Right emptyObject-                       else JSON.eitherDecode reqBody-      (CTTextCSV, _) -> do-        json <- csvToJson <$> CSV.decodeByName reqBody-        note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json-      (CTOther "application/x-www-form-urlencoded", _) ->-        let json = M.fromList . map (toS *** JSON.String . toS) . parseSimpleQuery $ toS reqBody-            keys = S.fromList $ M.keys json in-        Right $ ProcessedJSON (JSON.encode json) keys-      (ct, _) ->-        Left $ toS $ "Content-Type not acceptable: " <> toMime ct-  rpcPrmsToJson = ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) (S.fromList $ fst <$> rpcQParams)-  topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges -- if no limit is specified, get all the request rows-  action =-    case method of-      -- The HEAD method is identical to GET except that the server MUST NOT return a message-body in the response-      -- From https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4-      "HEAD"     | isTargetingDefaultSpec -> ActionInspect{isHead=True}-                 | isTargetingProc        -> ActionInvoke InvHead-                 | otherwise              -> ActionRead{isHead=True}-      "GET"      | isTargetingDefaultSpec -> ActionInspect{isHead=False}-                 | isTargetingProc        -> ActionInvoke InvGet-                 | otherwise              -> ActionRead{isHead=False}-      "POST"    -> if isTargetingProc-                    then ActionInvoke InvPost-                    else ActionCreate-      "PATCH"   -> ActionUpdate-      "PUT"     -> ActionSingleUpsert-      "DELETE"  -> ActionDelete-      "OPTIONS" -> ActionInfo-      _         -> ActionInspect{isHead=False}--  defaultSchema = head confSchemas-  profile-    | length confSchemas <= 1 -- only enable content negotiation by profile when there are multiple schemas specified in the config-      = Nothing-    | action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionDelete, ActionInvoke InvPost] -- POST/PATCH/PUT/DELETE don't use the same header as per the spec-      = Just $ maybe defaultSchema toS $ lookupHeader "Content-Profile"-    | action `elem` [ActionRead True, ActionRead False, ActionInvoke InvGet, ActionInvoke InvHead,-                     ActionInspect False, ActionInspect True, ActionInfo]-      = Just $ maybe defaultSchema toS $ lookupHeader "Accept-Profile"-    | otherwise = Nothing-  schema = fromMaybe defaultSchema profile-  target = case path of-    []            -> case rootSpec of-                       Just pName -> TargetProc (QualifiedIdentifier schema pName) True-                       Nothing    -> TargetDefaultSpec schema-    [table]       -> TargetIdent $ QualifiedIdentifier schema table-    ["rpc", proc] -> TargetProc (QualifiedIdentifier schema proc) False-    other         -> TargetUnknown other--  shouldParsePayload =-    action `elem`-    [ActionCreate, ActionUpdate, ActionSingleUpsert,-    ActionInvoke InvPost,-    -- Though ActionInvoke{isGet=True}(a GET /rpc/..) doesn't really have a payload, we use the payload variable as a way-    -- to store the query string arguments to the function.-    ActionInvoke InvGet,-    ActionInvoke InvHead]-  relevantPayload | shouldParsePayload = rightToMaybe payload-                  | otherwise = Nothing-  path            = pathInfo req-  method          = requestMethod req-  hdrs            = requestHeaders req-  qParams         = [(toS k, v)|(k,v) <- qString]-  lookupHeader    = flip lookup hdrs-  hasPrefer :: Text -> Bool-  hasPrefer val   = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs-    where-        split :: BS.ByteString -> [Text]-        split = map T.strip . T.split (==',') . toS-  representation-    | hasPrefer (show Full) = Full-    | hasPrefer (show None) = None-    | otherwise             = if action == ActionCreate-                                then HeadersOnly -- Assume the user wants the Location header(for POST) by default-                                else None-  auth = fromMaybe "" $ lookupHeader hAuthorization-  tokenStr = case T.split (== ' ') (toS auth) of-    ("Bearer" : t : _) -> t-    _                  -> ""-  endingIn:: [Text] -> Text -> Bool-  endingIn xx key = lastWord `elem` xx-    where lastWord = last $ T.split (=='.') key--  headerRange = rangeRequested hdrs-  replaceLast x s = T.intercalate "." $ L.init (T.split (=='.') s) ++ [x]-  limitParams :: M.HashMap ByteString NonnegRange-  limitParams  = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< (toS <$> v)) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]-  offsetParams :: M.HashMap ByteString NonnegRange-  offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe =<< (toS <$> v))) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]--  urlRange = M.unionWith f limitParams offsetParams-    where-      f rl ro = Range (BoundaryBelow o) (BoundaryAbove $ o + l - 1)-        where-          l = fromMaybe 0 $ rangeLimit rl-          o = rangeOffset ro-  ranges = M.insert "limit" (rangeIntersection headerRange (fromMaybe allRange (M.lookup "limit" urlRange))) urlRange--{-|-  Find the best match from a list of content types accepted by the-  client in order of decreasing preference and a list of types-  producible by the server.  If there is no match but the client-  accepts */* then return the top server pick.--}-mutuallyAgreeable :: [ContentType] -> [ContentType] -> Maybe ContentType-mutuallyAgreeable sProduces cAccepts =-  let exact = listToMaybe $ L.intersect cAccepts sProduces in-  if isNothing exact && CTAny `elem` cAccepts-     then listToMaybe sProduces-     else exact--type CsvData = V.Vector (M.HashMap Text BL.ByteString)--{-|-  Converts CSV like-  a,b-  1,hi-  2,bye--  into a JSON array like-  [ {"a": "1", "b": "hi"}, {"a": 2, "b": "bye"} ]--  The reason for its odd signature is so that it can compose-  directly with CSV.decodeByName--}-csvToJson :: (CSV.Header, CsvData) -> JSON.Value-csvToJson (_, vals) =-  JSON.Array $ V.map rowToJsonObj vals- where-  rowToJsonObj = JSON.Object .-    M.map (\str ->-        if str == "NULL"-          then JSON.Null-          else JSON.String $ toS str-      )--payloadAttributes :: RequestBody -> JSON.Value -> Maybe PayloadJSON-payloadAttributes raw json =-  -- Test that Array contains only Objects having the same keys-  case json of-    JSON.Array arr ->-      case arr V.!? 0 of-        Just (JSON.Object o) ->-          let canonicalKeys = S.fromList $ M.keys o-              areKeysUniform = all (\case-                JSON.Object x -> S.fromList (M.keys x) == canonicalKeys-                _ -> False) arr in-          if areKeysUniform-            then Just $ ProcessedJSON raw canonicalKeys-            else Nothing-        Just _ -> Nothing-        Nothing -> Just emptyPJArray--    JSON.Object o -> Just $ ProcessedJSON raw (S.fromList $ M.keys o)--    -- truncate everything else to an empty array.-    _ -> Just emptyPJArray-  where-    emptyPJArray = ProcessedJSON (JSON.encode emptyArray) S.empty
src/PostgREST/App.hs view
@@ -9,411 +9,608 @@ - Producing HTTP Headers according to RFCs. - Content Negotiation -}-{-# LANGUAGE FlexibleContexts    #-}-{-# LANGUAGE MultiWayIf          #-}-{-# LANGUAGE NamedFieldPuns      #-}-{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE RecordWildCards #-}+module PostgREST.App+  ( SignalHandlerInstaller+  , SocketRunner+  , postgrest+  , run+  ) where -module PostgREST.App (-  postgrest-) where+import Control.Monad.Except     (liftEither)+import Data.Either.Combinators  (mapLeft)+import Data.List                (union)+import Data.String              (IsString (..))+import Data.Time.Clock          (UTCTime)+import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort,+                                 setServerName)+import System.Posix.Types       (FileMode) -import qualified Data.ByteString.Char8      as BS-import qualified Data.HashMap.Strict        as M-import qualified Data.List                  as L (union)-import qualified Data.Set                   as S-import qualified Hasql.Pool                 as P-import qualified Hasql.Transaction          as H-import qualified Hasql.Transaction          as HT-import qualified Hasql.Transaction.Sessions as HT+import qualified Data.ByteString.Char8           as BS8+import qualified Data.ByteString.Lazy            as LBS+import qualified Data.HashMap.Strict             as Map+import qualified Data.Set                        as Set+import qualified Hasql.DynamicStatements.Snippet as SQL+import qualified Hasql.Pool                      as SQL+import qualified Hasql.Transaction               as SQL+import qualified Hasql.Transaction.Sessions      as SQL+import qualified Network.HTTP.Types.Header       as HTTP+import qualified Network.HTTP.Types.Status       as HTTP+import qualified Network.HTTP.Types.URI          as HTTP+import qualified Network.Wai                     as Wai+import qualified Network.Wai.Handler.Warp        as Warp -import Data.Function                        (id)-import Data.IORef                           (IORef, readIORef)-import Data.Time.Clock                      (UTCTime)-import Network.HTTP.Types.URI               (renderSimpleQuery)-import Network.Wai.Middleware.RequestLogger (logStdout)+import qualified PostgREST.AppState                 as AppState+import qualified PostgREST.Auth                     as Auth+import qualified PostgREST.DbStructure              as DbStructure+import qualified PostgREST.Error                    as Error+import qualified PostgREST.Middleware               as Middleware+import qualified PostgREST.OpenAPI                  as OpenAPI+import qualified PostgREST.Query.QueryBuilder       as QueryBuilder+import qualified PostgREST.Query.Statements         as Statements+import qualified PostgREST.RangeQuery               as RangeQuery+import qualified PostgREST.Request.ApiRequest       as ApiRequest+import qualified PostgREST.Request.DbRequestBuilder as ReqBuilder -import Control.Applicative-import Data.Maybe-import Network.HTTP.Types.Header-import Network.HTTP.Types.Status-import Network.Wai+import PostgREST.AppState                (AppState)+import PostgREST.Config                  (AppConfig (..),+                                          LogLevel (..),+                                          OpenAPIMode (..))+import PostgREST.Config.PgVersion        (PgVersion (..))+import PostgREST.ContentType             (ContentType (..))+import PostgREST.DbStructure             (DbStructure (..),+                                          tablePKCols)+import PostgREST.DbStructure.Identifiers (FieldName,+                                          QualifiedIdentifier (..),+                                          Schema)+import PostgREST.DbStructure.Proc        (ProcDescription (..),+                                          ProcVolatility (..))+import PostgREST.DbStructure.Table       (Table (..))+import PostgREST.Error                   (Error)+import PostgREST.GucHeader               (GucHeader,+                                          addHeadersIfNotIncluded,+                                          unwrapGucHeader)+import PostgREST.Request.ApiRequest      (Action (..),+                                          ApiRequest (..),+                                          InvokeMethod (..),+                                          Target (..))+import PostgREST.Request.Preferences     (PreferCount (..),+                                          PreferParameters (..),+                                          PreferRepresentation (..))+import PostgREST.Request.Types           (ReadRequest, fstFieldNames)+import PostgREST.Version                 (prettyVersion)+import PostgREST.Workers                 (connectionWorker, listener) -import PostgREST.ApiRequest       (Action (..), ApiRequest (..),-                                   InvokeMethod (..), Target (..),-                                   mutuallyAgreeable, userApiRequest)-import PostgREST.Auth             (containsRole, jwtClaims,-                                   parseSecret)-import PostgREST.Config           (AppConfig (..))-import PostgREST.DbRequestBuilder (mutateRequest, readRequest,-                                   returningCols)-import PostgREST.DbStructure-import PostgREST.Error            (PgError (..), SimpleError (..),-                                   errorResponseFor, singularityError)-import PostgREST.Middleware-import PostgREST.OpenAPI-import PostgREST.Parsers          (pRequestColumns)-import PostgREST.QueryBuilder     (limitedQuery, mutateRequestToQuery,-                                   readRequestToCountQuery,-                                   readRequestToQuery,-                                   requestToCallProcQuery)-import PostgREST.RangeQuery       (allRange, contentRangeH,-                                   rangeStatusHeader)-import PostgREST.Statements       (callProcStatement,-                                   createExplainStatement,-                                   createReadStatement,-                                   createWriteStatement)-import PostgREST.Types-import Protolude                  hiding (Proxy, intercalate, toS)-import Protolude.Conv             (toS)+import qualified PostgREST.ContentType      as ContentType+import qualified PostgREST.DbStructure.Proc as Proc -postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> IO () -> Application-postgrest conf refDbStructure pool getTime worker =-  let middle = (if configQuiet conf then id else logStdout) . defaultMiddle-      jwtSecret = parseSecret <$> configJwtSecret conf in-  middle $ \ req respond -> do-    time <- getTime-    body <- strictRequestBody req-    maybeDbStructure <- readIORef refDbStructure-    case maybeDbStructure of-      Nothing -> respond . errorResponseFor $ ConnectionLostError-      Just dbStructure -> do-        response <- do-          -- Need to parse ?columns early because findProc needs it to solve overloaded functions.-          -- TODO: move this logic to the app function-          let apiReq = userApiRequest (configSchemas conf) (configRootSpec conf) req body-              apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)-          case apiReqCols of-            Left err -> return . errorResponseFor $ err-            Right (apiRequest, maybeCols) -> do-              eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) time (rightToMaybe $ configRoleClaimKey conf)-              let authed = containsRole eClaims-                  cols = case (iPayload apiRequest, maybeCols) of-                    (Just ProcessedJSON{pjKeys}, _) -> pjKeys-                    (Just RawJSON{}, Just cls)      -> cls-                    _                               -> S.empty-                  proc = case iTarget apiRequest of-                    TargetProc qi _ -> findProc qi cols (iPreferParameters apiRequest == Just SingleObject) $ dbProcs dbStructure-                    _ -> Nothing-                  handleReq = runWithClaims conf eClaims (app dbStructure proc cols conf) apiRequest-                  txMode = transactionMode proc (iAction apiRequest)-              response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq-              return $ either (errorResponseFor . PgError authed) identity response-        when (responseStatus response == status503) worker-        respond response+import Protolude      hiding (Handler, toS)+import Protolude.Conv (toS) -transactionMode :: Maybe ProcDescription -> Action -> HT.Mode-transactionMode proc action =-  case action of-    ActionRead _         -> HT.Read-    ActionInfo           -> HT.Read-    ActionInspect _      -> HT.Read-    ActionInvoke InvGet  -> HT.Read-    ActionInvoke InvHead -> HT.Read-    ActionInvoke InvPost ->-      let v = maybe Volatile pdVolatility proc in-      if v == Stable || v == Immutable-         then HT.Read-         else HT.Write-    _ -> HT.Write -app :: DbStructure -> Maybe ProcDescription -> S.Set FieldName -> AppConfig -> ApiRequest -> H.Transaction Response-app dbStructure proc cols conf apiRequest =-  let rawContentTypes = (decodeContentType <$> configRawMediaTypes conf) `L.union` [ CTOctetStream, CTTextPlain ] in-  case responseContentTypeOrError (iAccepts apiRequest) rawContentTypes (iAction apiRequest) (iTarget apiRequest) of-    Left errorResponse -> return errorResponse-    Right contentType ->-      case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of+data RequestContext = RequestContext+  { ctxConfig      :: AppConfig+  , ctxDbStructure :: DbStructure+  , ctxApiRequest  :: ApiRequest+  , ctxPgVersion   :: PgVersion+  } -        (ActionRead headersOnly, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->-          case readSqlParts tSchema tName of-            Left errorResponse -> return errorResponse-            Right (q, cq, bField, _) -> do-              let cQuery = if estimatedCount-                             then limitedQuery cq ((+ 1) <$> maxRows) -- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed-                             else cq-                  stm = createReadStatement q cQuery (contentType == CTSingularJSON) shouldCount-                        (contentType == CTTextCSV) bField pgVer-                  explStm = createExplainStatement cq-              row <- H.statement () stm-              let (tableTotal, queryTotal, _ , body, gucHeaders) = row-              case gucHeaders of-                Left _ -> return . errorResponseFor $ GucHeadersError-                Right ghdrs -> do-                  total <- if | plannedCount   -> H.statement () explStm-                              | estimatedCount -> if tableTotal > (fromIntegral <$> maxRows)-                                                    then do estTotal <- H.statement () explStm-                                                            pure $ if estTotal > tableTotal then estTotal else tableTotal-                                                    else pure tableTotal-                              | otherwise      -> pure tableTotal-                  let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal total-                      headers = addHeadersIfNotIncluded (catMaybes [-                                  Just $ toHeader contentType, Just contentRange,-                                  Just $ contentLocationH tName (iCanonicalQS apiRequest), profileH])-                                (unwrapGucHeader <$> ghdrs)-                      rBody = if headersOnly then mempty else toS body-                  return $-                    if contentType == CTSingularJSON && queryTotal /= 1-                      then errorResponseFor . singularityError $ queryTotal-                      else responseLBS status headers rBody+type Handler = ExceptT Error -        (ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->-          case mutateSqlParts tSchema tName of-            Left errorResponse -> return errorResponse-            Right (sq, mq) -> do-              let pkCols = tablePKCols dbStructure tSchema tName-                  stm = createWriteStatement sq mq-                    (contentType == CTSingularJSON) True-                    (contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols pgVer-              row <- H.statement (toS $ pjRaw pJson) stm-              let (_, queryTotal, fields, body, gucHeaders) = row-              case gucHeaders of-                Left _ -> return . errorResponseFor $ GucHeadersError-                Right ghdrs -> do-                  let-                    (ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full-                                          then ([Just $ toHeader contentType, profileH], toS body)-                                          else ([], mempty)-                    headers = addHeadersIfNotIncluded (catMaybes ([-                          if null fields-                            then Nothing-                            else Just $ locationH tName fields-                        , Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing-                        , if null pkCols && isNothing (iOnConflict apiRequest)-                            then Nothing-                            else (\x -> ("Preference-Applied", encodeUtf8 (show x))) <$> iPreferResolution apiRequest-                        ] ++ ctHeaders)) (unwrapGucHeader <$> ghdrs)-                  if contentType == CTSingularJSON && queryTotal /= 1-                    then do-                      HT.condemn-                      return . errorResponseFor . singularityError $ queryTotal-                    else-                      return $ responseLBS status201 headers rBody+type DbHandler = Handler SQL.Transaction -        (ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->-          case mutateSqlParts tSchema tName of-            Left errorResponse -> return errorResponse-            Right (sq, mq) -> do-              let stm = createWriteStatement sq mq-                    (contentType == CTSingularJSON) False (contentType == CTTextCSV)-                    (iPreferRepresentation apiRequest) [] pgVer-              row <- H.statement (toS $ pjRaw pJson) stm-              let (_, queryTotal, _, body, gucHeaders) = row-              case gucHeaders of-                Left _ -> return . errorResponseFor $ GucHeadersError-                Right ghdrs -> do-                  let-                    updateIsNoOp = S.null cols-                    status | queryTotal == 0 && not updateIsNoOp      = status404-                           | iPreferRepresentation apiRequest == Full = status200-                           | otherwise                                = status204-                    contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ if shouldCount then Just queryTotal else Nothing-                    (ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full-                                          then ([Just $ toHeader contentType, profileH], toS body)-                                          else ([], mempty)-                    headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)-                  if contentType == CTSingularJSON && queryTotal /= 1-                    then do-                      HT.condemn-                      return . errorResponseFor . singularityError $ queryTotal-                    else-                      return $ responseLBS status headers rBody+type SignalHandlerInstaller = AppState -> IO() -        (ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->-          case mutateSqlParts tSchema tName of-            Left errorResponse -> return errorResponse-            Right (sq, mq) ->-              if topLevelRange /= allRange-                then return . errorResponseFor $ PutRangeNotAllowedError-              else do-                row <- H.statement (toS $ pjRaw pJson) $-                       createWriteStatement sq mq (contentType == CTSingularJSON) False-                                            (contentType == CTTextCSV) (iPreferRepresentation apiRequest) [] pgVer-                let (_, queryTotal, _, body, gucHeaders) = row-                case gucHeaders of-                  Left _ -> return . errorResponseFor $ GucHeadersError-                  Right ghdrs -> do-                    let headers = addHeadersIfNotIncluded (catMaybes [Just $ toHeader contentType, profileH]) (unwrapGucHeader <$> ghdrs)-                        (status, rBody) = if iPreferRepresentation apiRequest == Full then (status200, toS body) else (status204, mempty)-                    -- Makes sure the querystring pk matches the payload pk-                    -- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected-                    -- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done-                    if queryTotal /= 1-                      then do-                        HT.condemn-                        return . errorResponseFor $ PutMatchingPkError-                      else-                        return $ responseLBS status headers rBody+type SocketRunner = Warp.Settings -> Wai.Application -> FileMode -> FilePath -> IO() -        (ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->-          case mutateSqlParts tSchema tName of-            Left errorResponse -> return errorResponse-            Right (sq, mq) -> do-              let stm = createWriteStatement sq mq-                    (contentType == CTSingularJSON) False-                    (contentType == CTTextCSV)-                    (iPreferRepresentation apiRequest) [] pgVer-              row <- H.statement mempty stm-              let (_, queryTotal, _, body, gucHeaders) = row-              case gucHeaders of-                Left _ -> return . errorResponseFor $ GucHeadersError-                Right ghdrs -> do-                  let-                    status = if iPreferRepresentation apiRequest == Full then status200 else status204-                    contentRangeHeader = contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing-                    (ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full-                                          then ([Just $ toHeader contentType, profileH], toS body)-                                          else ([], mempty)-                    headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)-                  if contentType == CTSingularJSON-                     && queryTotal /= 1-                    then do-                      HT.condemn-                      return . errorResponseFor . singularityError $ queryTotal-                    else-                      return $ responseLBS status headers rBody -        (ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->-          let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in-          case mTable of-            Nothing -> return notFound-            Just table ->-              let allowH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET")-                  allOrigins = ("Access-Control-Allow-Origin", "*") :: Header in-              return $ responseLBS status200 [allOrigins, allowH] mempty+run :: SignalHandlerInstaller -> Maybe SocketRunner -> AppState -> IO ()+run installHandlers maybeRunWithSocket appState = do+  conf@AppConfig{..} <- AppState.getConfig appState+  connectionWorker appState -- Loads the initial DbStructure+  installHandlers appState+  -- reload schema cache + config on NOTIFY+  when configDbChannelEnabled $ listener appState -        (ActionInvoke invMethod, TargetProc qi@(QualifiedIdentifier tSchema pName) _, Just pJson) ->-          let tName = fromMaybe pName $ procTableName =<< proc in-          case readSqlParts tSchema tName of-            Left errorResponse -> return errorResponse-            Right (q, cq, bField, returning) -> do-              let-                preferParams = iPreferParameters apiRequest-                pq = requestToCallProcQuery qi (specifiedProcArgs cols proc) returnsScalar preferParams returning-                stm = callProcStatement returnsScalar pq q cq shouldCount (contentType == CTSingularJSON)-                        (contentType == CTTextCSV) (contentType `elem` rawContentTypes) (preferParams == Just MultipleObjects)-                        bField pgVer-              row <- H.statement (toS $ pjRaw pJson) stm-              let (tableTotal, queryTotal, body, gucHeaders) = row-              case gucHeaders of-                Left _ -> return . errorResponseFor $ GucHeadersError-                Right ghdrs -> do-                  let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal tableTotal-                      headers = addHeadersIfNotIncluded-                        (catMaybes [Just $ toHeader contentType, Just contentRange, profileH])-                        (unwrapGucHeader <$> ghdrs)-                      rBody = if invMethod == InvHead then mempty else toS body-                  if contentType == CTSingularJSON && queryTotal /= 1-                    then do-                      HT.condemn-                      return . errorResponseFor . singularityError $ queryTotal-                    else-                      return $ responseLBS status headers rBody+  let app = postgrest configLogLevel appState (connectionWorker appState) -        (ActionInspect headersOnly, TargetDefaultSpec tSchema, Nothing) -> do-          let host = configHost conf-              port = toInteger $ configPort conf-              proxy = pickProxy $ toS <$> configOpenAPIProxyUri conf-              uri Nothing = ("http", host, port, "/")-              uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)-              uri' = uri proxy-              toTableInfo :: [Table] -> [(Table, [Column], [Text])]-              toTableInfo = map (\t -> let (s, tn) = (tableSchema t, tableName t) in (t, tableCols dbStructure s tn, tablePKCols dbStructure s tn))-              encodeApi ti sd procs = encodeOpenAPI (concat $ M.elems procs) (toTableInfo ti) uri' sd $ dbPrimaryKeys dbStructure+  case configServerUnixSocket of+    Just socket ->+      -- run the postgrest application with user defined socket. Only for UNIX systems+      case maybeRunWithSocket of+        Just runWithSocket -> do+          AppState.logWithZTime appState $ "Listening on unix socket " <> show socket+          runWithSocket (serverSettings conf) app configServerUnixSocketMode socket+        Nothing ->+          panic "Cannot run with socket on non-unix plattforms."+    Nothing ->+      do+        AppState.logWithZTime appState $ "Listening on port " <> show configServerPort+        Warp.runSettings (serverSettings conf) app -          body <- encodeApi <$>-            H.statement tSchema accessibleTables <*>-            H.statement tSchema schemaDescription <*>-            H.statement tSchema accessibleProcs-          return $ responseLBS status200 (catMaybes [Just $ toHeader CTOpenAPI, profileH]) (if headersOnly then mempty else toS body)+serverSettings :: AppConfig -> Warp.Settings+serverSettings AppConfig{..} =+  defaultSettings+    & setHost (fromString $ toS configServerHost)+    & setPort configServerPort+    & setServerName (toS $ "postgrest/" <> prettyVersion) -        _ -> return notFound+-- | PostgREST application+postgrest :: LogLevel -> AppState.AppState -> IO () -> Wai.Application+postgrest logLev appState connWorker =+  Middleware.pgrstMiddleware logLev $+    \req respond -> do+      time <- AppState.getTime appState+      conf <- AppState.getConfig appState+      maybeDbStructure <- AppState.getDbStructure appState+      pgVer <- AppState.getPgVersion appState+      jsonDbS <- AppState.getJsonDbS appState -      where-        notFound = responseLBS status404 [] ""-        maxRows = configMaxRows conf-        exactCount = iPreferCount apiRequest == Just ExactCount-        estimatedCount = iPreferCount apiRequest == Just EstimatedCount-        plannedCount = iPreferCount apiRequest == Just PlannedCount-        shouldCount = exactCount || estimatedCount-        topLevelRange = iTopLevelRange apiRequest-        returnsScalar = maybe False procReturnsScalar proc-        pgVer = pgVersion dbStructure-        profileH = contentProfileH <$> iProfile apiRequest+      let+        eitherResponse :: IO (Either Error Wai.Response)+        eitherResponse =+          runExceptT $ postgrestResponse conf maybeDbStructure jsonDbS pgVer (AppState.getPool appState) time req -        readSqlParts s t =-          let-            readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest-            returnings :: ReadRequest -> Either Response [FieldName]-            returnings rr = Right (returningCols rr)-          in-          (,,,) <$>-          (readRequestToQuery <$> readReq) <*>-          (readRequestToCountQuery <$> readReq) <*>-          (binaryField contentType rawContentTypes returnsScalar =<< readReq) <*>-          (returnings =<< readReq)+      response <- either Error.errorResponseFor identity <$> eitherResponse -        mutateSqlParts s t =-          let-            readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest-            mutReq = mutateRequest s t apiRequest cols (tablePKCols dbStructure s t) =<< readReq-          in-          (,) <$>-          (readRequestToQuery <$> readReq) <*>-          (mutateRequestToQuery <$> mutReq)+      -- Launch the connWorker when the connection is down.  The postgrest+      -- function can respond successfully (with a stale schema cache) before+      -- the connWorker is done.+      when (Wai.responseStatus response == HTTP.status503) connWorker -responseContentTypeOrError :: [ContentType] -> [ContentType] -> Action -> Target -> Either Response ContentType-responseContentTypeOrError accepts rawContentTypes action target = serves contentTypesForRequest accepts-  where-    contentTypesForRequest = case action of-      ActionRead _       ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]-                             ++ rawContentTypes-      ActionCreate       ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]-      ActionUpdate       ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]-      ActionDelete       ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]-      ActionInvoke _     ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]-                             ++ rawContentTypes-                             ++ [CTOpenAPI | tpIsRootSpec target]-      ActionInspect _    ->  [CTOpenAPI, CTApplicationJSON]-      ActionInfo         ->  [CTTextCSV]-      ActionSingleUpsert ->  [CTApplicationJSON, CTSingularJSON, CTTextCSV]-    serves sProduces cAccepts =-      case mutuallyAgreeable sProduces cAccepts of-        Nothing -> Left . errorResponseFor . ContentTypeError . map toMime $ cAccepts-        Just ct -> Right ct+      respond response -{--  | If raw(binary) output is requested, check that ContentType is one of the admitted rawContentTypes and that-  | `?select=...` contains only one field other than `*`--}-binaryField :: ContentType -> [ContentType] -> Bool -> ReadRequest -> Either Response (Maybe FieldName)-binaryField ct rawContentTypes isScalarProc readReq-  | isScalarProc = Right Nothing-  | ct `elem` rawContentTypes =-      let fieldName = headMay fldNames in-      if length fldNames == 1 && fieldName /= Just "*"-        then Right fieldName-        else Left . errorResponseFor $ BinaryFieldError ct-  | otherwise = Right Nothing+postgrestResponse+  :: AppConfig+  -> Maybe DbStructure+  -> ByteString+  -> PgVersion+  -> SQL.Pool+  -> UTCTime+  -> Wai.Request+  -> Handler IO Wai.Response+postgrestResponse conf maybeDbStructure jsonDbS pgVer pool time req = do+  body <- lift $ Wai.strictRequestBody req++  dbStructure <-+    case maybeDbStructure of+      Just dbStructure ->+        return dbStructure+      Nothing ->+        throwError Error.ConnectionLostError++  apiRequest@ApiRequest{..} <-+    liftEither . mapLeft Error.ApiRequestError $+      ApiRequest.userApiRequest conf dbStructure req body++  -- The JWT must be checked before touching the db+  jwtClaims <- Auth.jwtClaims conf (toS iJWT) time++  let+    handleReq apiReq =+      handleRequest $ RequestContext conf dbStructure apiReq pgVer++  runDbHandler pool (txMode apiRequest) jwtClaims (configDbPreparedStatements conf) .+    Middleware.optionalRollback conf apiRequest $+      Middleware.runPgLocals conf jwtClaims handleReq apiRequest jsonDbS++runDbHandler :: SQL.Pool -> SQL.Mode -> Auth.JWTClaims -> Bool -> DbHandler a -> Handler IO a+runDbHandler pool mode jwtClaims prepared handler = do+  dbResp <-+    let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in+    lift . SQL.use pool . transaction SQL.ReadCommitted mode $ runExceptT handler++  resp <-+    liftEither . mapLeft Error.PgErr $+      mapLeft (Error.PgError $ Auth.containsRole jwtClaims) dbResp++  liftEither resp++handleRequest :: RequestContext -> DbHandler Wai.Response+handleRequest context@(RequestContext _ _ ApiRequest{..} _) =+  case (iAction, iTarget) of+    (ActionRead headersOnly, TargetIdent identifier) ->+      handleRead headersOnly identifier context+    (ActionCreate, TargetIdent identifier) ->+      handleCreate identifier context+    (ActionUpdate, TargetIdent identifier) ->+      handleUpdate identifier context+    (ActionSingleUpsert, TargetIdent identifier) ->+      handleSingleUpsert identifier context+    (ActionDelete, TargetIdent identifier) ->+      handleDelete identifier context+    (ActionInfo, TargetIdent identifier) ->+      handleInfo identifier context+    (ActionInvoke invMethod, TargetProc proc _) ->+      handleInvoke invMethod proc context+    (ActionInspect headersOnly, TargetDefaultSpec tSchema) ->+      handleOpenApi headersOnly tSchema context+    _ ->+      throwError Error.NotFound++handleRead :: Bool -> QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response+handleRead headersOnly identifier context@RequestContext{..} = do+  req <- readRequest identifier context+  bField <- binaryField context req++  let+    ApiRequest{..} = ctxApiRequest+    AppConfig{..} = ctxConfig+    countQuery = QueryBuilder.readRequestToCountQuery req++  (tableTotal, queryTotal, _ , body, gucHeaders, gucStatus) <-+    lift . SQL.statement mempty $+      Statements.createReadStatement+        (QueryBuilder.readRequestToQuery req)+        (if iPreferCount == Just EstimatedCount then+           -- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed+           QueryBuilder.limitedQuery countQuery ((+ 1) <$> configDbMaxRows)+         else+           countQuery+        )+        (iAcceptContentType == CTSingularJSON)+        (shouldCount iPreferCount)+        (iAcceptContentType == CTTextCSV)+        bField+        ctxPgVersion+        configDbPreparedStatements++  total <- readTotal ctxConfig ctxApiRequest tableTotal countQuery+  response <- liftEither $ gucResponse <$> gucStatus <*> gucHeaders++  let+    (status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange queryTotal total+    headers =+      [ contentRange+      , ( "Content-Location"+        , "/"+            <> toS (qiName identifier)+            <> if BS8.null iCanonicalQS then mempty else "?" <> toS iCanonicalQS+        )+      ]+      ++ contentTypeHeaders context++  failNotSingular iAcceptContentType queryTotal . response status headers $+    if headersOnly then mempty else toS body++readTotal :: AppConfig -> ApiRequest -> Maybe Int64 -> SQL.Snippet -> DbHandler (Maybe Int64)+readTotal AppConfig{..} ApiRequest{..} tableTotal countQuery =+  case iPreferCount of+    Just PlannedCount ->+      explain+    Just EstimatedCount ->+      if tableTotal > (fromIntegral <$> configDbMaxRows) then+        max tableTotal <$> explain+      else+        return tableTotal+    _ ->+      return tableTotal   where-    fldNames = fstFieldNames readReq+    explain =+      lift . SQL.statement mempty . Statements.createExplainStatement countQuery $+        configDbPreparedStatements -locationH :: TableName -> [BS.ByteString] -> Header-locationH tName fields =+handleCreate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response+handleCreate identifier@QualifiedIdentifier{..} context@RequestContext{..} = do   let-    locationFields = renderSimpleQuery True $ splitKeyValue <$> fields-  in-    (hLocation, "/" <> toS tName <> locationFields)+    ApiRequest{..} = ctxApiRequest+    pkCols = tablePKCols ctxDbStructure qiSchema qiName++  WriteQueryResult{..} <- writeQuery identifier True pkCols context++  let+    response = gucResponse resGucStatus resGucHeaders+    headers =+      catMaybes+        [ if null resFields then+            Nothing+          else+            Just+              ( HTTP.hLocation+              , "/"+                  <> toS qiName+                  <> HTTP.renderSimpleQuery True (splitKeyValue <$> resFields)+              )+        , Just . RangeQuery.contentRangeH 1 0 $+            if shouldCount iPreferCount then Just resQueryTotal else Nothing+        , if null pkCols && isNothing iOnConflict then+            Nothing+          else+            (\x -> ("Preference-Applied", BS8.pack $ show x)) <$> iPreferResolution+        ]++  failNotSingular iAcceptContentType resQueryTotal $+    if iPreferRepresentation == Full then+      response HTTP.status201 (headers ++ contentTypeHeaders context) (toS resBody)+    else+      response HTTP.status201 headers mempty++handleUpdate :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response+handleUpdate identifier context@(RequestContext _ _ ApiRequest{..} _) = do+  WriteQueryResult{..} <- writeQuery identifier False mempty context++  let+    response = gucResponse resGucStatus resGucHeaders+    fullRepr = iPreferRepresentation == Full+    updateIsNoOp = Set.null iColumns+    status+      | resQueryTotal == 0 && not updateIsNoOp = HTTP.status404+      | fullRepr = HTTP.status200+      | otherwise = HTTP.status204+    contentRangeHeader =+      RangeQuery.contentRangeH 0 (resQueryTotal - 1) $+        if shouldCount iPreferCount then Just resQueryTotal else Nothing++  failNotSingular iAcceptContentType resQueryTotal $+    if fullRepr then+      response status (contentTypeHeaders context ++ [contentRangeHeader]) (toS resBody)+    else+      response status [contentRangeHeader] mempty++handleSingleUpsert :: QualifiedIdentifier -> RequestContext-> DbHandler Wai.Response+handleSingleUpsert identifier context@(RequestContext _ _ ApiRequest{..} _) = do+  when (iTopLevelRange /= RangeQuery.allRange) $+    throwError Error.PutRangeNotAllowedError++  WriteQueryResult{..} <- writeQuery identifier False mempty context++  let response = gucResponse resGucStatus resGucHeaders++  -- Makes sure the querystring pk matches the payload pk+  -- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted,+  -- PUT /items?id=eq.14 { "id" : 2, .. } is rejected.+  -- If this condition is not satisfied then nothing is inserted,+  -- check the WHERE for INSERT in QueryBuilder.hs to see how it's done+  when (resQueryTotal /= 1) $ do+    lift SQL.condemn+    throwError Error.PutMatchingPkError++  return $+    if iPreferRepresentation == Full then+      response HTTP.status200 (contentTypeHeaders context) (toS resBody)+    else+      response HTTP.status204 (contentTypeHeaders context) mempty++handleDelete :: QualifiedIdentifier -> RequestContext -> DbHandler Wai.Response+handleDelete identifier context@(RequestContext _ _ ApiRequest{..} _) = do+  WriteQueryResult{..} <- writeQuery identifier False mempty context++  let+    response = gucResponse resGucStatus resGucHeaders+    contentRangeHeader =+      RangeQuery.contentRangeH 1 0 $+        if shouldCount iPreferCount then Just resQueryTotal else Nothing++  failNotSingular iAcceptContentType resQueryTotal $+    if iPreferRepresentation == Full then+      response HTTP.status200+        (contentTypeHeaders context ++ [contentRangeHeader])+        (toS resBody)+    else+      response HTTP.status204 [contentRangeHeader] mempty++handleInfo :: Monad m => QualifiedIdentifier -> RequestContext -> Handler m Wai.Response+handleInfo identifier RequestContext{..} =+  case find tableMatches $ dbTables ctxDbStructure of+    Just table ->+      return $ Wai.responseLBS HTTP.status200 [allOrigins, allowH table] mempty+    Nothing ->+      throwError Error.NotFound   where-    splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)-    splitKeyValue kv =-      let (k, v) = BS.break (== '=') kv-      in (k, BS.tail v)+    allOrigins = ("Access-Control-Allow-Origin", "*")+    allowH table =+      ( HTTP.hAllow+      , BS8.intercalate "," $+          ["OPTIONS,GET,HEAD"]+          ++ ["POST" | tableInsertable table]+          ++ ["PUT" | tableInsertable table && tableUpdatable table && hasPK]+          ++ ["PATCH" | tableUpdatable table]+          ++ ["DELETE" | tableDeletable table]+      )+    tableMatches table =+      tableName table == qiName identifier+      && tableSchema table == qiSchema identifier+    hasPK =+      not $ null $ tablePKCols ctxDbStructure (qiSchema identifier) (qiName identifier) -contentLocationH :: TableName -> ByteString -> Header-contentLocationH tName qString =-  ("Content-Location", "/" <> toS tName <> if BS.null qString then mempty else "?" <> toS qString)+handleInvoke :: InvokeMethod -> ProcDescription -> RequestContext -> DbHandler Wai.Response+handleInvoke invMethod proc context@RequestContext{..} = do+  let+    ApiRequest{..} = ctxApiRequest -contentProfileH :: Schema -> Header-contentProfileH schema =-   ("Content-Profile", toS schema)+    identifier =+      QualifiedIdentifier+        (pdSchema proc)+        (fromMaybe (pdName proc) $ Proc.procTableName proc)++    returnsSingle (ApiRequest.TargetProc target _) = Proc.procReturnsSingle target+    returnsSingle _                                = False++  req <- readRequest identifier context+  bField <- binaryField context req++  (tableTotal, queryTotal, body, gucHeaders, gucStatus) <-+    lift . SQL.statement mempty $+      Statements.callProcStatement+        (returnsScalar iTarget)+        (returnsSingle iTarget)+        (QueryBuilder.requestToCallProcQuery+          (QualifiedIdentifier (pdSchema proc) (pdName proc))+          (Proc.specifiedProcArgs iColumns proc)+          iPayload+          (returnsScalar iTarget)+          iPreferParameters+          (ReqBuilder.returningCols req [])+        )+        (QueryBuilder.readRequestToQuery req)+        (QueryBuilder.readRequestToCountQuery req)+        (shouldCount iPreferCount)+        (iAcceptContentType == CTSingularJSON)+        (iAcceptContentType == CTTextCSV)+        (iPreferParameters == Just MultipleObjects)+        bField+        ctxPgVersion+        (configDbPreparedStatements ctxConfig)++  response <- liftEither $ gucResponse <$> gucStatus <*> gucHeaders++  let+    (status, contentRange) =+      RangeQuery.rangeStatusHeader iTopLevelRange queryTotal tableTotal++  failNotSingular iAcceptContentType queryTotal $+    response status+      (contentTypeHeaders context ++ [contentRange])+      (if invMethod == InvHead then mempty else toS body)++handleOpenApi :: Bool -> Schema -> RequestContext -> DbHandler Wai.Response+handleOpenApi headersOnly tSchema (RequestContext conf@AppConfig{..} dbStructure apiRequest _) = do+  body <-+    lift $ case configOpenApiMode of+      OAFollowPriv ->+        OpenAPI.encode conf dbStructure+           <$> SQL.statement tSchema (DbStructure.accessibleTables configDbPreparedStatements)+           <*> SQL.statement tSchema (DbStructure.accessibleProcs configDbPreparedStatements)+           <*> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements)+      OAIgnorePriv ->+        OpenAPI.encode conf dbStructure+              (filter (\x -> tableSchema x == tSchema) $ DbStructure.dbTables dbStructure)+              (Map.filterWithKey (\(QualifiedIdentifier sch _) _ ->  sch == tSchema) $ DbStructure.dbProcs dbStructure)+          <$> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements)+      OADisabled ->+        pure mempty++  return $+    Wai.responseLBS HTTP.status200+      (ContentType.toHeader CTOpenAPI : maybeToList (profileHeader apiRequest))+      (if headersOnly then mempty else toS body)++txMode :: ApiRequest -> SQL.Mode+txMode ApiRequest{..} =+  case (iAction, iTarget) of+    (ActionRead _, _) ->+      SQL.Read+    (ActionInfo, _) ->+      SQL.Read+    (ActionInspect _, _) ->+      SQL.Read+    (ActionInvoke InvGet, _) ->+      SQL.Read+    (ActionInvoke InvHead, _) ->+      SQL.Read+    (ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Stable} _) ->+      SQL.Read+    (ActionInvoke InvPost, TargetProc ProcDescription{pdVolatility=Immutable} _) ->+      SQL.Read+    _ ->+      SQL.Write++-- | Result from executing a write query on the database+data WriteQueryResult = WriteQueryResult+  { resQueryTotal :: Int64+  , resFields     :: [ByteString]+  , resBody       :: ByteString+  , resGucStatus  :: Maybe HTTP.Status+  , resGucHeaders :: [GucHeader]+  }++writeQuery :: QualifiedIdentifier -> Bool -> [Text] -> RequestContext -> DbHandler WriteQueryResult+writeQuery identifier@QualifiedIdentifier{..} isInsert pkCols context@RequestContext{..} = do+  readReq <- readRequest identifier context++  mutateReq <-+    liftEither $+      ReqBuilder.mutateRequest qiSchema qiName ctxApiRequest+        (tablePKCols ctxDbStructure qiSchema qiName)+        readReq++  (_, queryTotal, fields, body, gucHeaders, gucStatus) <-+    lift . SQL.statement mempty $+      Statements.createWriteStatement+        (QueryBuilder.readRequestToQuery readReq)+        (QueryBuilder.mutateRequestToQuery mutateReq)+        (iAcceptContentType ctxApiRequest == CTSingularJSON)+        isInsert+        (iAcceptContentType ctxApiRequest == CTTextCSV)+        (iPreferRepresentation ctxApiRequest)+        pkCols+        ctxPgVersion+        (configDbPreparedStatements ctxConfig)++  liftEither $ WriteQueryResult queryTotal fields body <$> gucStatus <*> gucHeaders++-- | Response with headers and status overridden from GUCs.+gucResponse+  :: Maybe HTTP.Status+  -> [GucHeader]+  -> HTTP.Status+  -> [HTTP.Header]+  -> LBS.ByteString+  -> Wai.Response+gucResponse gucStatus gucHeaders status headers =+  Wai.responseLBS (fromMaybe status gucStatus) $+    addHeadersIfNotIncluded headers (map unwrapGucHeader gucHeaders)++-- |+-- Fail a response if a single JSON object was requested and not exactly one+-- was found.+failNotSingular :: ContentType -> Int64 -> Wai.Response -> DbHandler Wai.Response+failNotSingular contentType queryTotal response =+  if contentType == CTSingularJSON && queryTotal /= 1 then+    do+      lift SQL.condemn+      throwError $ Error.singularityError queryTotal+  else+    return response++shouldCount :: Maybe PreferCount -> Bool+shouldCount preferCount =+  preferCount == Just ExactCount || preferCount == Just EstimatedCount++returnsScalar :: ApiRequest.Target -> Bool+returnsScalar (TargetProc proc _) = Proc.procReturnsScalar proc+returnsScalar _                   = False++readRequest :: Monad m => QualifiedIdentifier -> RequestContext -> Handler m ReadRequest+readRequest QualifiedIdentifier{..} (RequestContext AppConfig{..} dbStructure apiRequest _) =+  liftEither $+    ReqBuilder.readRequest qiSchema qiName configDbMaxRows+      (dbRelationships dbStructure)+      apiRequest++contentTypeHeaders :: RequestContext -> [HTTP.Header]+contentTypeHeaders RequestContext{..} =+  ContentType.toHeader (iAcceptContentType ctxApiRequest) : maybeToList (profileHeader ctxApiRequest)++-- | If raw(binary) output is requested, check that ContentType is one of the+-- admitted rawContentTypes and that`?select=...` contains only one field other+-- than `*`+binaryField :: Monad m => RequestContext -> ReadRequest -> Handler m (Maybe FieldName)+binaryField RequestContext{..} readReq+  | returnsScalar (iTarget ctxApiRequest) && iAcceptContentType ctxApiRequest `elem` rawContentTypes ctxConfig =+      return $ Just "pgrst_scalar"+  | iAcceptContentType ctxApiRequest `elem` rawContentTypes ctxConfig =+      let+        fldNames = fstFieldNames readReq+        fieldName = headMay fldNames+      in+      if length fldNames == 1 && fieldName /= Just "*" then+        return fieldName+      else+        throwError $ Error.BinaryFieldError (iAcceptContentType ctxApiRequest)+  | otherwise =+      return Nothing++rawContentTypes :: AppConfig -> [ContentType]+rawContentTypes AppConfig{..} =+  (ContentType.decodeContentType <$> configRawMediaTypes) `union` [CTOctetStream, CTTextPlain]++profileHeader :: ApiRequest -> Maybe HTTP.Header+profileHeader ApiRequest{..} =+  (,) "Content-Profile" <$> (toS <$> iProfile)++splitKeyValue :: ByteString -> (ByteString, ByteString)+splitKeyValue kv =+  (k, BS8.tail v)+  where+    (k, v) = BS8.break (== '=') kv
+ src/PostgREST/AppState.hs view
@@ -0,0 +1,145 @@+{-# LANGUAGE RecordWildCards #-}++module PostgREST.AppState+  ( AppState+  , getConfig+  , getDbStructure+  , getIsWorkerOn+  , getJsonDbS+  , getMainThreadId+  , getPgVersion+  , getPool+  , getTime+  , init+  , initWithPool+  , logWithZTime+  , putConfig+  , putDbStructure+  , putIsWorkerOn+  , putJsonDbS+  , putPgVersion+  , releasePool+  , signalListener+  , waitListener+  ) where++import qualified Hasql.Pool as P++import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,+                           updateAction)+import Data.IORef         (IORef, atomicWriteIORef, newIORef,+                           readIORef)+import Data.Time          (ZonedTime, defaultTimeLocale, formatTime,+                           getZonedTime)+import Data.Time.Clock    (UTCTime, getCurrentTime)++import PostgREST.Config           (AppConfig (..))+import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion)+import PostgREST.DbStructure      (DbStructure)++import Protolude      hiding (toS)+import Protolude.Conv (toS)+++data AppState = AppState+  { statePool         :: P.Pool -- | Connection pool, either a 'Connection' or a 'ConnectionError'+  , statePgVersion    :: IORef PgVersion+  -- | No schema cache at the start. Will be filled in by the connectionWorker+  , stateDbStructure  :: IORef (Maybe DbStructure)+  -- | Cached DbStructure in json+  , stateJsonDbS      :: IORef ByteString+  -- | Helper ref to make sure just one connectionWorker can run at a time+  , stateIsWorkerOn   :: IORef Bool+  -- | Binary semaphore used to sync the listener(NOTIFY reload) with the connectionWorker.+  , stateListener     :: MVar ()+  -- | Config that can change at runtime+  , stateConf         :: IORef AppConfig+  -- | Time used for verifying JWT expiration+  , stateGetTime      :: IO UTCTime+  -- | Time with time zone used for worker logs+  , stateGetZTime     :: IO ZonedTime+  -- | Used for killing the main thread in case a subthread fails+  , stateMainThreadId :: ThreadId+  }++init :: AppConfig -> IO AppState+init conf = do+  newPool <- initPool conf+  initWithPool newPool conf++initWithPool :: P.Pool -> AppConfig -> IO AppState+initWithPool newPool conf =+  AppState newPool+    <$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step+    <*> newIORef Nothing+    <*> newIORef mempty+    <*> newIORef False+    <*> newEmptyMVar+    <*> newIORef conf+    <*> mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }+    <*> mkAutoUpdate defaultUpdateSettings { updateAction = getZonedTime }+    <*> myThreadId++initPool :: AppConfig -> IO P.Pool+initPool AppConfig{..} =+  P.acquire (configDbPoolSize, configDbPoolTimeout, toS configDbUri)++getPool :: AppState -> P.Pool+getPool = statePool++releasePool :: AppState -> IO ()+releasePool AppState{..} = P.release statePool >> throwTo stateMainThreadId UserInterrupt++getPgVersion :: AppState -> IO PgVersion+getPgVersion = readIORef . statePgVersion++putPgVersion :: AppState -> PgVersion -> IO ()+putPgVersion = atomicWriteIORef . statePgVersion++getDbStructure :: AppState -> IO (Maybe DbStructure)+getDbStructure = readIORef . stateDbStructure++putDbStructure :: AppState -> DbStructure -> IO ()+putDbStructure appState structure =+  atomicWriteIORef (stateDbStructure appState) $ Just structure++getJsonDbS :: AppState -> IO ByteString+getJsonDbS = readIORef . stateJsonDbS++putJsonDbS :: AppState -> ByteString -> IO ()+putJsonDbS appState = atomicWriteIORef (stateJsonDbS appState)++getIsWorkerOn :: AppState -> IO Bool+getIsWorkerOn = readIORef . stateIsWorkerOn++putIsWorkerOn :: AppState -> Bool -> IO ()+putIsWorkerOn = atomicWriteIORef . stateIsWorkerOn++getConfig :: AppState -> IO AppConfig+getConfig = readIORef . stateConf++putConfig :: AppState -> AppConfig -> IO ()+putConfig = atomicWriteIORef . stateConf++getTime :: AppState -> IO UTCTime+getTime = stateGetTime++-- | Log to stderr with local time+logWithZTime :: AppState -> Text -> IO ()+logWithZTime appState txt = do+  zTime <- stateGetZTime appState+  hPutStrLn stderr $ toS (formatTime defaultTimeLocale "%d/%b/%Y:%T %z: " zTime) <> txt++getMainThreadId :: AppState -> ThreadId+getMainThreadId = stateMainThreadId++-- | As this IO action uses `takeMVar` internally, it will only return once+-- `stateListener` has been set using `signalListener`. This is currently used+-- to syncronize workers.+waitListener :: AppState -> IO ()+waitListener = takeMVar . stateListener++-- tryPutMVar doesn't lock the thread. It should always succeed since+-- the connectionWorker is the only mvar producer.+signalListener :: AppState -> IO ()+signalListener appState = void $ tryPutMVar (stateListener appState) ()
src/PostgREST/Auth.hs view
@@ -1,5 +1,3 @@-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE LambdaCase       #-} {-| Module      : PostgREST.Auth Description : PostgREST authorization functions.@@ -12,103 +10,73 @@ In the test suite there is an example of simple login function that can be used for a very simple authentication system inside the PostgreSQL database. -}-module PostgREST.Auth (-    containsRole+{-# LANGUAGE RecordWildCards #-}+module PostgREST.Auth+  ( containsRole   , jwtClaims-  , JWTAttempt(..)-  , parseSecret+  , JWTClaims   ) where -import qualified Crypto.JOSE.Types   as JOSE.Types+import qualified Crypto.JWT          as JWT import qualified Data.Aeson          as JSON import qualified Data.HashMap.Strict as M-import           Data.Vector         as V+import qualified Data.Vector         as V -import Control.Lens    (set)-import Data.Time.Clock (UTCTime)+import Control.Lens            (set)+import Control.Monad.Except    (liftEither)+import Data.Either.Combinators (mapLeft)+import Data.Time.Clock         (UTCTime) -import Control.Lens.Operators-import Crypto.JWT+import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..))+import PostgREST.Error  (Error (..)) -import PostgREST.Types-import Protolude       hiding (toS)-import Protolude.Conv  (toS)+import Protolude -{-|-  Possible situations encountered with client JWTs--}-data JWTAttempt = JWTInvalid JWTError-                | JWTMissingSecret-                | JWTClaims (M.HashMap Text JSON.Value)-                deriving (Eq, Show) -{-|-  Receives the JWT secret and audience (from config) and a JWT and returns a map-  of JWT claims.--}-jwtClaims :: Maybe JWKSet -> Maybe StringOrURI -> LByteString -> UTCTime -> Maybe JSPath -> IO JWTAttempt-jwtClaims _ _ "" _ _ = return $ JWTClaims M.empty-jwtClaims secret audience payload time jspath =-  case secret of-    Nothing -> return JWTMissingSecret-    Just s -> do-      let validation = set allowedSkew 1 $ defaultJWTValidationSettings (maybe (const True) (==) audience)-      eJwt <- runExceptT $ do-        jwt <- decodeCompact payload-        verifyClaimsAt validation s time jwt-      return $ case eJwt of-        Left e    -> JWTInvalid e-        Right jwt -> JWTClaims $ claims2map jwt jspath+type JWTClaims = M.HashMap Text JSON.Value -{-|-  Turn JWT ClaimSet into something easier to work with,-  also here the jspath is applied to put the "role" in the map--}-claims2map :: ClaimsSet -> Maybe JSPath -> M.HashMap Text JSON.Value-claims2map claims jspath = (\case-    val@(JSON.Object o) ->-      let role = maybe M.empty (M.singleton "role") $-                 walkJSPath (Just val) =<< jspath in-      M.delete "role" o `M.union` role -- mutating the map-    _ -> M.empty-  ) $ JSON.toJSON claims+-- | Receives the JWT secret and audience (from config) and a JWT and returns a+-- map of JWT claims.+jwtClaims :: Monad m =>+  AppConfig -> LByteString -> UTCTime -> ExceptT Error m JWTClaims+jwtClaims _ "" _ = return M.empty+jwtClaims AppConfig{..} payload time = do+  secret <- liftEither . maybeToRight JwtTokenMissing $ configJWKS+  eitherClaims <-+    lift . runExceptT $+      JWT.verifyClaimsAt validation secret time =<< JWT.decodeCompact payload+  liftEither . mapLeft jwtClaimsError $ claimsMap configJwtRoleClaimKey <$> eitherClaims+  where+    validation =+      JWT.defaultJWTValidationSettings audienceCheck & set JWT.allowedSkew 1 -walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value-walkJSPath x                      []                = x-walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (M.lookup key o) rest-walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest-walkJSPath _                      _                 = Nothing+    audienceCheck :: JWT.StringOrURI -> Bool+    audienceCheck = maybe (const True) (==) configJwtAudience -{-|-  Whether a response from jwtClaims contains a role claim--}-containsRole :: JWTAttempt -> Bool-containsRole (JWTClaims claims) = M.member "role" claims-containsRole _                  = False+    jwtClaimsError :: JWT.JWTError -> Error+    jwtClaimsError JWT.JWTExpired = JwtTokenInvalid "JWT expired"+    jwtClaimsError e              = JwtTokenInvalid $ show e -{-|-  Parse `jwt-secret` configuration option and turn into a JWKSet.+-- | Turn JWT ClaimSet into something easier to work with.+--+-- Also, here the jspath is applied to put the "role" in the map.+claimsMap :: JSPath -> JWT.ClaimsSet -> JWTClaims+claimsMap jspath claims =+  case JSON.toJSON claims of+    val@(JSON.Object o) ->+      M.delete "role" o `M.union` role val+    _ ->+      M.empty+  where+    role value =+      maybe M.empty (M.singleton "role") $ walkJSPath (Just value) jspath -  There are three ways to specify `jwt-secret`: text secret, JSON Web Key-  (JWK), or JSON Web Key Set (JWKS). The first two are converted into a JWKSet-  with one key and the last is converted as is.--}-parseSecret :: ByteString -> JWKSet-parseSecret str =-  fromMaybe (maybe secret (\jwk' -> JWKSet [jwk']) maybeJWK)-    maybeJWKSet- where-  maybeJWKSet = JSON.decode (toS str) :: Maybe JWKSet-  maybeJWK = JSON.decode (toS str) :: Maybe JWK-  secret = JWKSet [jwkFromSecret str]+    walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value+    walkJSPath x                      []                = x+    walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (M.lookup key o) rest+    walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest+    walkJSPath _                      _                 = Nothing -{-|-  Internal helper to generate a symmetric HMAC-SHA256 JWK from a text secret.--}-jwkFromSecret :: ByteString -> JWK-jwkFromSecret key =-  fromKeyMaterial km-    & jwkUse ?~ Sig-    & jwkAlg ?~ JWSAlg HS256- where-  km = OctKeyMaterial (OctKeyParameters (JOSE.Types.Base64Octets key))+-- | Whether a response from jwtClaims contains a role claim+containsRole :: JWTClaims -> Bool+containsRole = M.member "role"
+ src/PostgREST/CLI.hs view
@@ -0,0 +1,217 @@+{-# LANGUAGE NamedFieldPuns  #-}+{-# LANGUAGE QuasiQuotes     #-}+{-# LANGUAGE RecordWildCards #-}+module PostgREST.CLI+  ( main+  , CLI (..)+  , Command (..)+  , readCLIShowHelp+  ) where++import qualified Data.Aeson                 as Aeson+import qualified Data.ByteString.Lazy       as LBS+import qualified Hasql.Pool                 as P+import qualified Hasql.Transaction.Sessions as HT+import qualified Options.Applicative        as O+import qualified Protolude.Conv             as Conv++import Data.Text.IO (hPutStrLn)+import Text.Heredoc (str)++import PostgREST.AppState    (AppState)+import PostgREST.Config      (AppConfig (..))+import PostgREST.DbStructure (queryDbStructure)+import PostgREST.Version     (prettyVersion)+import PostgREST.Workers     (reReadConfig)++import qualified PostgREST.App      as App+import qualified PostgREST.AppState as AppState+import qualified PostgREST.Config   as Config++import Protolude hiding (hPutStrLn)+++main :: App.SignalHandlerInstaller -> Maybe App.SocketRunner -> CLI -> IO ()+main installSignalHandlers runAppWithSocket CLI{cliCommand, cliPath} = do+  conf@AppConfig{..} <-+    either panic identity <$> Config.readAppConfig mempty cliPath Nothing+  appState <- AppState.init conf++  -- Override the config with config options from the db+  -- TODO: the same operation is repeated on connectionWorker, ideally this+  -- would be done only once, but dump CmdDumpConfig needs it for tests.+  when configDbConfig $ reReadConfig True appState++  exec cliCommand appState+  where+    exec :: Command -> AppState -> IO ()+    exec CmdDumpConfig appState = putStr . Config.toText =<< AppState.getConfig appState+    exec CmdDumpSchema appState = putStrLn =<< dumpSchema appState+    exec CmdRun appState = App.run installSignalHandlers runAppWithSocket appState++-- | Dump DbStructure schema to JSON+dumpSchema :: AppState -> IO LBS.ByteString+dumpSchema appState = do+  AppConfig{..} <- AppState.getConfig appState+  result <-+    let transaction = if configDbPreparedStatements then HT.transaction else HT.unpreparedTransaction in+    P.use (AppState.getPool appState) $+      transaction HT.ReadCommitted HT.Read $+        queryDbStructure+          (toList configDbSchemas)+          configDbExtraSearchPath+          configDbPreparedStatements+  P.release $ AppState.getPool appState+  case result of+    Left e -> do+      hPutStrLn stderr $ "An error ocurred when loading the schema cache:\n" <> show e+      exitFailure+    Right dbStructure -> return $ Aeson.encode dbStructure++-- | Command line interface options+data CLI = CLI+  { cliCommand :: Command+  , cliPath    :: Maybe FilePath+  }++data Command+  = CmdRun+  | CmdDumpConfig+  | CmdDumpSchema++-- | Read command line interface options. Also prints help.+readCLIShowHelp :: Bool -> IO CLI+readCLIShowHelp hasEnvironment =+  O.customExecParser prefs opts+  where+    prefs = O.prefs $ O.showHelpOnError <> O.showHelpOnEmpty+    opts = O.info parser $ O.fullDesc <> progDesc <> footer+    parser = O.helper <*> exampleParser <*> cliParser++    progDesc =+      O.progDesc $+        "PostgREST "+        <> Conv.toS prettyVersion+        <> " / create a REST API to an existing Postgres database"++    footer =+      O.footer $+        "To run PostgREST, please pass the FILENAME argument"+        <> " or set PGRST_ environment variables."++    exampleParser =+      O.infoOption exampleConfigFile $+        O.long "example"+        <> O.short 'e'+        <> O.help "Show an example configuration file"++    cliParser :: O.Parser CLI+    cliParser =+      CLI+        <$> (dumpConfigFlag <|> dumpSchemaFlag)+        <*> optionalIf hasEnvironment configFileOption++    configFileOption =+      O.strArgument $+        O.metavar "FILENAME"+        <> O.help "Path to configuration file (optional with PGRST_ environment variables)"++    dumpConfigFlag =+      O.flag CmdRun CmdDumpConfig $+        O.long "dump-config"+        <> O.help "Dump loaded configuration and exit"++    dumpSchemaFlag =+      O.flag CmdRun CmdDumpSchema $+        O.long "dump-schema"+        <> O.help "Dump loaded schema as JSON and exit (for debugging, output structure is unstable)"++    optionalIf :: Alternative f => Bool -> f a -> f (Maybe a)+    optionalIf True  = O.optional+    optionalIf False = fmap Just++exampleConfigFile :: [Char]+exampleConfigFile =+  [str|### REQUIRED:+      |db-uri = "postgres://user:pass@localhost:5432/dbname"+      |db-schema = "public"+      |db-anon-role = "postgres"+      |+      |### OPTIONAL:+      |## number of open connections in the pool+      |db-pool = 10+      |+      |## Time to live, in seconds, for an idle database pool connection.+      |db-pool-timeout = 10+      |+      |## extra schemas to add to the search_path of every request+      |db-extra-search-path = "public"+      |+      |## limit rows in response+      |# db-max-rows = 1000+      |+      |## stored proc to exec immediately after auth+      |# db-pre-request = "stored_proc_name"+      |+      |## stored proc that overrides the root "/" spec+      |## it must be inside the db-schema+      |# db-root-spec = "stored_proc_name"+      |+      |## Notification channel for reloading the schema cache+      |db-channel = "pgrst"+      |+      |## Enable or disable the notification channel+      |db-channel-enabled = true+      |+      |## Enable in-database configuration+      |db-config = true+      |+      |## how to terminate database transactions+      |## possible values are:+      |## commit (default)+      |##   transaction is always committed, this can not be overriden+      |## commit-allow-override+      |##   transaction is committed, but can be overriden with Prefer tx=rollback header+      |## rollback+      |##   transaction is always rolled back, this can not be overriden+      |## rollback-allow-override+      |##   transaction is rolled back, but can be overriden with Prefer tx=commit header+      |db-tx-end = "commit"+      |+      |## enable or disable prepared statements. disabling is only necessary when behind a connection pooler.+      |## when disabled, statements will be parametrized but won't be prepared.+      |db-prepared-statements = true+      |+      |server-host = "!4"+      |server-port = 3000+      |+      |## unix socket location+      |## if specified it takes precedence over server-port+      |# server-unix-socket = "/tmp/pgrst.sock"+      |+      |## unix socket file mode+      |## when none is provided, 660 is applied by default+      |# server-unix-socket-mode = "660"+      |+      |## determine if the OpenAPI output should follow or ignore role privileges or be disabled entirely+      |## admitted values: follow-privileges, ignore-privileges, disabled+      |openapi-mode = "follow-privileges"+      |+      |## base url for the OpenAPI output+      |openapi-server-proxy-uri = ""+      |+      |## choose a secret, JSON Web Key (or set) to enable JWT auth+      |## (use "@filename" to load from separate file)+      |# jwt-secret = "secret_with_at_least_32_characters"+      |# jwt-aud = "your_audience_claim"+      |jwt-secret-is-base64 = false+      |+      |## jspath to the role claim key+      |jwt-role-claim-key = ".role"+      |+      |## content types to produce raw output+      |# raw-media-types="image/png, image/jpg"+      |+      |## logging level, the admitted values are: crit, error, warn and info.+      |log-level = "error"+      |]
src/PostgREST/Config.hs view
@@ -1,218 +1,364 @@ {-| Module      : PostgREST.Config-Description : Manages PostgREST configuration options.--This module provides a helper function to read the command line-arguments using the optparse-applicative and the AppConfig type to store-them.  It also can be used to define other middleware configuration that-may be delegated to some sort of external configuration.--It currently includes a hardcoded CORS policy but this could easly be-turned in configurable behaviour if needed.+Description : Manages PostgREST configuration type and parser. -Other hardcoded options such as the minimum version number also belong here. -}-{-# LANGUAGE LambdaCase          #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TemplateHaskell     #-}+{-# LANGUAGE FlexibleContexts      #-}+{-# LANGUAGE FlexibleInstances     #-}+{-# LANGUAGE LambdaCase            #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE RecordWildCards       #-} {-# OPTIONS_GHC -fno-warn-type-defaults #-} -module PostgREST.Config ( prettyVersion-                        , docsVersion-                        , readOptions-                        , corsPolicy-                        , AppConfig (..)-                        , configPoolTimeout'-                        )-       where+module PostgREST.Config+  ( AppConfig (..)+  , Environment+  , JSPath+  , JSPathExp(..)+  , LogLevel(..)+  , OpenAPIMode(..)+  , Proxy(..)+  , toText+  , isMalformedProxyUri+  , readAppConfig+  , readPGRSTEnvironment+  , toURI+  , parseSecret+  ) where -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 Text.PrettyPrint.ANSI.Leijen as L+import qualified Crypto.JOSE.Types      as JOSE+import qualified Crypto.JWT             as JWT+import qualified Data.Aeson             as JSON+import qualified Data.ByteString        as B+import qualified Data.ByteString.Base64 as B64+import qualified Data.ByteString.Char8  as BS+import qualified Data.Configurator      as C+import qualified Data.Map.Strict        as M+import qualified Data.Text              as T -import Control.Lens                (preview)-import Control.Monad               (fail)-import Crypto.JWT                  (StringOrURI, stringOrUri)-import Data.List                   (lookup)-import Data.List.NonEmpty          (fromList)-import Data.Scientific             (floatingOrInteger)-import Data.Text                   (dropEnd, dropWhileEnd,-                                    intercalate, splitOn, strip, take,-                                    unpack)-import Data.Text.IO                (hPutStrLn)-import Data.Version                (versionBranch)-import Development.GitRev          (gitHash)-import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))-import Numeric                     (readOct)-import Paths_postgrest             (version)-import System.IO.Error             (IOError)-import System.Posix.Types          (FileMode)+import qualified GHC.Show (show) -import Control.Applicative-import Data.Monoid-import Network.Wai-import Options.Applicative          hiding (str)-import Text.Heredoc-import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>))+import Control.Lens            (preview)+import Control.Monad           (fail)+import Crypto.JWT              (JWK, JWKSet, StringOrURI, stringOrUri)+import Data.Aeson              (encode, toJSON)+import Data.Either.Combinators (mapLeft)+import Data.List               (lookup)+import Data.List.NonEmpty      (fromList, toList)+import Data.Maybe              (fromJust)+import Data.Scientific         (floatingOrInteger)+import Data.Time.Clock         (NominalDiffTime)+import Numeric                 (readOct, showOct)+import System.Environment      (getEnvironment)+import System.Posix.Types      (FileMode) -import PostgREST.Error   (ApiRequestError (..))-import PostgREST.Parsers (pRoleClaimKey)-import PostgREST.Types   (JSPath, JSPathExp (..))-import Protolude         hiding (concat, hPutStrLn, intercalate, null,-                          take, toS, (<>))-import Protolude.Conv    (toS)+import PostgREST.Config.JSPath           (JSPath, JSPathExp (..),+                                          pRoleClaimKey)+import PostgREST.Config.Proxy            (Proxy (..),+                                          isMalformedProxyUri, toURI)+import PostgREST.DbStructure.Identifiers (QualifiedIdentifier, toQi) +import Protolude      hiding (Proxy, toList, toS)+import Protolude.Conv (toS) --- | Config file settings for the server-data AppConfig = AppConfig {-    configDatabase          :: Text-  , configAnonRole          :: Text-  , configOpenAPIProxyUri   :: Maybe Text-  , configSchemas           :: NonEmpty Text-  , configHost              :: Text-  , configPort              :: Int-  , configSocket            :: Maybe FilePath-  , configSocketMode        :: Either Text FileMode -  , configJwtSecret         :: Maybe B.ByteString-  , configJwtSecretIsBase64 :: Bool-  , configJwtAudience       :: Maybe StringOrURI--  , configPool              :: Int-  , configPoolTimeout       :: Int-  , configMaxRows           :: Maybe Integer-  , configReqCheck          :: Maybe Text-  , configQuiet             :: Bool-  , configSettings          :: [(Text, Text)]-  , configRoleClaimKey      :: Either ApiRequestError JSPath-  , configExtraSearchPath   :: [Text]--  , configRootSpec          :: Maybe Text-  , configRawMediaTypes     :: [B.ByteString]+data AppConfig = AppConfig+  { configAppSettings           :: [(Text, Text)]+  , configDbAnonRole            :: Text+  , configDbChannel             :: Text+  , configDbChannelEnabled      :: Bool+  , configDbExtraSearchPath     :: [Text]+  , configDbMaxRows             :: Maybe Integer+  , configDbPoolSize            :: Int+  , configDbPoolTimeout         :: NominalDiffTime+  , configDbPreRequest          :: Maybe QualifiedIdentifier+  , configDbPreparedStatements  :: Bool+  , configDbRootSpec            :: Maybe QualifiedIdentifier+  , configDbSchemas             :: NonEmpty Text+  , configDbConfig              :: Bool+  , configDbTxAllowOverride     :: Bool+  , configDbTxRollbackAll       :: Bool+  , configDbUri                 :: Text+  , configFilePath              :: Maybe FilePath+  , configJWKS                  :: Maybe JWKSet+  , configJwtAudience           :: Maybe StringOrURI+  , configJwtRoleClaimKey       :: JSPath+  , configJwtSecret             :: Maybe B.ByteString+  , configJwtSecretIsBase64     :: Bool+  , configLogLevel              :: LogLevel+  , configOpenApiMode           :: OpenAPIMode+  , configOpenApiServerProxyUri :: Maybe Text+  , configRawMediaTypes         :: [B.ByteString]+  , configServerHost            :: Text+  , configServerPort            :: Int+  , configServerUnixSocket      :: Maybe FilePath+  , configServerUnixSocketMode  :: FileMode   } -configPoolTimeout' :: (Fractional a) => AppConfig -> a-configPoolTimeout' =-  fromRational . toRational . configPoolTimeout+data LogLevel = LogCrit | LogError | LogWarn | LogInfo +instance Show LogLevel where+  show LogCrit  = "crit"+  show LogError = "error"+  show LogWarn  = "warn"+  show LogInfo  = "info" -defaultCorsPolicy :: CorsResourcePolicy-defaultCorsPolicy =  CorsResourcePolicy Nothing-  ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] ["Authorization"] Nothing-  (Just $ 60*60*24) False False True+data OpenAPIMode = OAFollowPriv | OAIgnorePriv | OADisabled+  deriving Eq --- | CORS policy to be used in by Wai Cors middleware-corsPolicy :: Request -> Maybe CorsResourcePolicy-corsPolicy req = case lookup "origin" headers of-  Just origin -> Just defaultCorsPolicy {-      corsOrigins = Just ([origin], True)-    , corsRequestHeaders = "Authentication":accHeaders-    , corsExposedHeaders = Just [-        "Content-Encoding", "Content-Location", "Content-Range", "Content-Type"-      , "Date", "Location", "Server", "Transfer-Encoding", "Range-Unit"-      ]-    }-  Nothing -> Nothing+instance Show OpenAPIMode where+  show OAFollowPriv = "follow-privileges"+  show OAIgnorePriv = "ignore-privileges"+  show OADisabled   = "disabled"++-- | Dump the config+toText :: AppConfig -> Text+toText conf =+  unlines $ (\(k, v) -> k <> " = " <> v) <$> pgrstSettings ++ appSettings   where-    headers = requestHeaders req-    accHeaders = case lookup "access-control-request-headers" headers of-      Just hdrs -> map (CI.mk . toS . strip . toS) $ BS.split ',' hdrs-      Nothing -> []+    -- apply conf to all pgrst settings+    pgrstSettings = (\(k, v) -> (k, v conf)) <$>+      [("db-anon-role",              q . configDbAnonRole)+      ,("db-channel",                q . configDbChannel)+      ,("db-channel-enabled",            T.toLower . show . configDbChannelEnabled)+      ,("db-extra-search-path",      q . T.intercalate "," . configDbExtraSearchPath)+      ,("db-max-rows",                   maybe "\"\"" show . configDbMaxRows)+      ,("db-pool",                       show . configDbPoolSize)+      ,("db-pool-timeout",               show . floor . configDbPoolTimeout)+      ,("db-pre-request",            q . maybe mempty show . configDbPreRequest)+      ,("db-prepared-statements",        T.toLower . show . configDbPreparedStatements)+      ,("db-root-spec",              q . maybe mempty show . configDbRootSpec)+      ,("db-schemas",                q . T.intercalate "," . toList . configDbSchemas)+      ,("db-config",                 q . T.toLower . show . configDbConfig)+      ,("db-tx-end",                 q . showTxEnd)+      ,("db-uri",                    q . configDbUri)+      ,("jwt-aud",                       toS . encode . maybe "" toJSON . configJwtAudience)+      ,("jwt-role-claim-key",        q . T.intercalate mempty . fmap show . configJwtRoleClaimKey)+      ,("jwt-secret",                q . toS . showJwtSecret)+      ,("jwt-secret-is-base64",          T.toLower . show . configJwtSecretIsBase64)+      ,("log-level",                 q . show . configLogLevel)+      ,("openapi-mode",              q . show . configOpenApiMode)+      ,("openapi-server-proxy-uri",  q . fromMaybe mempty . configOpenApiServerProxyUri)+      ,("raw-media-types",           q . toS . B.intercalate "," . configRawMediaTypes)+      ,("server-host",               q . configServerHost)+      ,("server-port",                   show . configServerPort)+      ,("server-unix-socket",        q . maybe mempty T.pack . configServerUnixSocket)+      ,("server-unix-socket-mode",   q . T.pack . showSocketMode)+      ] --- | User friendly version number-prettyVersion :: Text-prettyVersion =-  intercalate "." (map show $ versionBranch version)-  <> " (" <> take 7 $(gitHash) <> ")"+    -- quote all app.settings+    appSettings = second q <$> configAppSettings conf --- | Version number used in docs-docsVersion :: Text-docsVersion = "v" <> dropEnd 1 (dropWhileEnd (/= '.') prettyVersion)+    -- quote strings and replace " with \"+    q s = "\"" <> T.replace "\"" "\\\"" s <> "\"" --- | Function to read and parse options from the command line-readOptions :: IO AppConfig-readOptions = do-  -- First read the config file path from command line-  cfgPath <- customExecParser parserPrefs opts-  -- Now read the actual config file-  conf <- catches (C.load cfgPath)-    [ Handler (\(ex :: IOError)    -> exitErr $ "Cannot open config file:\n\t" <> show ex)-    , Handler (\(C.ParseError err) -> exitErr $ "Error parsing config file:\n" <> err)-    ]+    showTxEnd c = case (configDbTxRollbackAll c, configDbTxAllowOverride c) of+      ( False, False ) -> "commit"+      ( False, True  ) -> "commit-allow-override"+      ( True , False ) -> "rollback"+      ( True , True  ) -> "rollback-allow-override"+    showJwtSecret c+      | configJwtSecretIsBase64 c = B64.encode secret+      | otherwise                 = toS secret+      where+        secret = fromMaybe mempty $ configJwtSecret c+    showSocketMode c = showOct (configServerUnixSocketMode c) mempty -  case C.runParser parseConfig conf of+-- This class is needed for the polymorphism of overrideFromDbOrEnvironment+-- because C.required and C.optional have different signatures+class JustIfMaybe a b where+  justIfMaybe :: a -> b++instance JustIfMaybe a a where+  justIfMaybe a = a++instance JustIfMaybe a (Maybe a) where+  justIfMaybe a = Just a++-- | Reads and parses the config and overrides its parameters from env vars,+-- files or db settings.+readAppConfig :: [(Text, Text)] -> Maybe FilePath -> Maybe Text -> IO (Either Text AppConfig)+readAppConfig dbSettings optPath prevDbUri = do+  env <- readPGRSTEnvironment+  -- if no filename provided, start with an empty map to read config from environment+  conf <- maybe (return $ Right M.empty) loadConfig optPath++  case C.runParser (parser optPath env dbSettings) =<< mapLeft show conf of     Left err ->-      exitErr $ "Error parsing config file:\n\t" <> err-    Right appConf ->-      return appConf+      return . Left $ "Error in config " <> err+    Right parsedConfig ->+      Right <$> decodeLoadFiles parsedConfig+  where+    -- Both C.ParseError and IOError are shown here+    loadConfig :: FilePath -> IO (Either SomeException C.Config)+    loadConfig = try . C.load +    decodeLoadFiles :: AppConfig -> IO AppConfig+    decodeLoadFiles parsedConfig =+      decodeJWKS <$>+        (decodeSecret =<< readSecretFile =<< readDbUriFile prevDbUri parsedConfig)++parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> C.Parser C.Config AppConfig+parser optPath env dbSettings =+  AppConfig+    <$> parseAppSettings "app.settings"+    <*> reqString "db-anon-role"+    <*> (fromMaybe "pgrst" <$> optString "db-channel")+    <*> (fromMaybe True <$> optBool "db-channel-enabled")+    <*> (maybe ["public"] splitOnCommas <$> optValue "db-extra-search-path")+    <*> optWithAlias (optInt "db-max-rows")+                     (optInt "max-rows")+    <*> (fromMaybe 10 <$> optInt "db-pool")+    <*> (fromIntegral . fromMaybe 10 <$> optInt "db-pool-timeout")+    <*> (fmap toQi <$> optWithAlias (optString "db-pre-request")+                                    (optString "pre-request"))+    <*> (fromMaybe True <$> optBool "db-prepared-statements")+    <*> (fmap toQi <$> optWithAlias (optString "db-root-spec")+                                    (optString "root-spec"))+    <*> (fromList . splitOnCommas <$> reqWithAlias (optValue "db-schemas")+                                                   (optValue "db-schema")+                                                   "missing key: either db-schemas or db-schema must be set")+    <*> (fromMaybe True <$> optBool "db-config")+    <*> parseTxEnd "db-tx-end" snd+    <*> parseTxEnd "db-tx-end" fst+    <*> reqString "db-uri"+    <*> pure optPath+    <*> pure Nothing+    <*> parseJwtAudience "jwt-aud"+    <*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key"+    <*> (fmap encodeUtf8 <$> optString "jwt-secret")+    <*> (fromMaybe False <$> optWithAlias+          (optBool "jwt-secret-is-base64")+          (optBool "secret-is-base64"))+    <*> parseLogLevel "log-level"+    <*> parseOpenAPIMode "openapi-mode"+    <*> parseOpenAPIServerProxyURI "openapi-server-proxy-uri"+    <*> (maybe [] (fmap encodeUtf8 . splitOnCommas) <$> optValue "raw-media-types")+    <*> (fromMaybe "!4" <$> optString "server-host")+    <*> (fromMaybe 3000 <$> optInt "server-port")+    <*> (fmap T.unpack <$> optString "server-unix-socket")+    <*> parseSocketFileMode "server-unix-socket-mode"   where-    parseConfig =-      AppConfig-        <$> reqString "db-uri"-        <*> reqString "db-anon-role"-        <*> optString "openapi-server-proxy-uri"-        <*> (fromList . splitOnCommas <$> reqValue "db-schema")-        <*> (fromMaybe "!4" <$> optString "server-host")-        <*> (fromMaybe 3000 <$> optInt "server-port")-        <*> (fmap unpack <$> optString "server-unix-socket")-        <*> parseSocketFileMode "server-unix-socket-mode"-        <*> (fmap encodeUtf8 <$> optString "jwt-secret")-        <*> (fromMaybe False <$> optBool "secret-is-base64")-        <*> parseJwtAudience "jwt-aud"-        <*> (fromMaybe 10 <$> optInt "db-pool")-        <*> (fromMaybe 10 <$> optInt "db-pool-timeout")-        <*> optInt "max-rows"-        <*> optString "pre-request"-        <*> pure False-        <*> (fmap (fmap coerceText) <$> C.subassocs "app.settings" C.value)-        <*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> optValue "role-claim-key")-        <*> (maybe ["public"] splitOnCommas <$> optValue "db-extra-search-path")-        <*> optString "root-spec"-        <*> (maybe [] (fmap encodeUtf8 . splitOnCommas) <$> optValue "raw-media-types")+    parseAppSettings :: C.Key -> C.Parser C.Config [(Text, Text)]+    parseAppSettings key = addFromEnv . fmap (fmap coerceText) <$> C.subassocs key C.value+      where+        addFromEnv f = M.toList $ M.union fromEnv $ M.fromList f+        fromEnv = M.mapKeys fromJust $ M.filterWithKey (\k _ -> isJust k) $ M.mapKeys normalize env+        normalize k = ("app.settings." <>) <$> T.stripPrefix "PGRST_APP_SETTINGS_" (toS k) -    parseSocketFileMode :: C.Key -> C.Parser C.Config (Either Text FileMode)+    parseSocketFileMode :: C.Key -> C.Parser C.Config FileMode     parseSocketFileMode k =-      C.optional k C.string >>= \case-        Nothing -> pure $ Right 432 -- return default 660 mode if no value was provided+      optString k >>= \case+        Nothing -> pure 432 -- return default 660 mode if no value was provided         Just fileModeText ->-          case (readOct . unpack) fileModeText of+          case readOct $ T.unpack fileModeText of             []              ->-              pure $ Left "Invalid server-unix-socket-mode: not an octal"+              fail "Invalid server-unix-socket-mode: not an octal"             (fileMode, _):_ ->               if fileMode < 384 || fileMode > 511-                then pure $ Left "Invalid server-unix-socket-mode: needs to be between 600 and 777"-                else pure $ Right fileMode+                then fail "Invalid server-unix-socket-mode: needs to be between 600 and 777"+                else pure fileMode +    parseOpenAPIMode :: C.Key -> C.Parser C.Config OpenAPIMode+    parseOpenAPIMode k =+      optString k >>= \case+        Nothing                  -> pure OAFollowPriv+        Just "follow-privileges" -> pure OAFollowPriv+        Just "ignore-privileges" -> pure OAIgnorePriv+        Just "disabled"          -> pure OADisabled+        Just _                   -> fail "Invalid openapi-mode. Check your configuration."++    parseOpenAPIServerProxyURI :: C.Key -> C.Parser C.Config (Maybe Text)+    parseOpenAPIServerProxyURI k =+      optString k >>= \case+        Nothing                            -> pure Nothing+        Just val | isMalformedProxyUri val -> fail "Malformed proxy uri, a correct example: https://example.com:8443/basePath"+                 | otherwise               -> pure $ Just val+     parseJwtAudience :: C.Key -> C.Parser C.Config (Maybe StringOrURI)     parseJwtAudience k =-      C.optional k C.string >>= \case+      optString k >>= \case         Nothing -> pure Nothing -- no audience in config file-        Just aud -> case preview stringOrUri (unpack aud) of+        Just aud -> case preview stringOrUri (T.unpack aud) of           Nothing -> fail "Invalid Jwt audience. Check your configuration."-          (Just "") -> pure Nothing           aud' -> pure aud' -    reqString :: C.Key -> C.Parser C.Config Text-    reqString k = C.required k C.string+    parseLogLevel :: C.Key -> C.Parser C.Config LogLevel+    parseLogLevel k =+      optString k >>= \case+        Nothing      -> pure LogError+        Just "crit"  -> pure LogCrit+        Just "error" -> pure LogError+        Just "warn"  -> pure LogWarn+        Just "info"  -> pure LogInfo+        Just _       -> fail "Invalid logging level. Check your configuration." -    reqValue :: C.Key -> C.Parser C.Config C.Value-    reqValue k = C.required k C.value+    parseTxEnd :: C.Key -> ((Bool, Bool) -> Bool) -> C.Parser C.Config Bool+    parseTxEnd k f =+      optString k >>= \case+        --                                          RollbackAll AllowOverride+        Nothing                        -> pure $ f (False,      False)+        Just "commit"                  -> pure $ f (False,      False)+        Just "commit-allow-override"   -> pure $ f (False,      True)+        Just "rollback"                -> pure $ f (True,       False)+        Just "rollback-allow-override" -> pure $ f (True,       True)+        Just _                         -> fail "Invalid transaction termination. Check your configuration." +    parseRoleClaimKey :: C.Key -> C.Key -> C.Parser C.Config JSPath+    parseRoleClaimKey k al =+      optWithAlias (optString k) (optString al) >>= \case+        Nothing  -> pure [JSPKey "role"]+        Just rck -> either (fail . show) pure $ pRoleClaimKey rck++    reqWithAlias :: C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a) -> [Char] -> C.Parser C.Config a+    reqWithAlias orig alias err =+      orig >>= \case+        Just v  -> pure v+        Nothing ->+          alias >>= \case+            Just v  -> pure v+            Nothing -> fail err++    optWithAlias :: C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a)+    optWithAlias orig alias =+      orig >>= \case+        Just v  -> pure $ Just v+        Nothing -> alias++    reqString :: C.Key -> C.Parser C.Config Text+    reqString k = overrideFromDbOrEnvironment C.required k coerceText+     optString :: C.Key -> C.Parser C.Config (Maybe Text)-    optString k = mfilter (/= "") <$> C.optional k C.string+    optString k = mfilter (/= "") <$> overrideFromDbOrEnvironment C.optional k coerceText      optValue :: C.Key -> C.Parser C.Config (Maybe C.Value)-    optValue k = C.optional k C.value+    optValue k = overrideFromDbOrEnvironment C.optional k identity      optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)-    optInt k = join <$> C.optional k (coerceInt <$> C.value)+    optInt k = join <$> overrideFromDbOrEnvironment C.optional k coerceInt      optBool :: C.Key -> C.Parser C.Config (Maybe Bool)-    optBool k = join <$> C.optional k (coerceBool <$> C.value)+    optBool k = join <$> overrideFromDbOrEnvironment C.optional k coerceBool +    overrideFromDbOrEnvironment :: JustIfMaybe a b =>+                               (C.Key -> C.Parser C.Value a -> C.Parser C.Config b) ->+                               C.Key -> (C.Value -> a) -> C.Parser C.Config b+    overrideFromDbOrEnvironment necessity key coercion =+      case reloadableDbSetting <|> M.lookup envVarName env of+        Just dbOrEnvVal -> pure $ justIfMaybe $ coercion $ C.String dbOrEnvVal+        Nothing  -> necessity key (coercion <$> C.value)+      where+        dashToUnderscore '-' = '_'+        dashToUnderscore c   = c+        envVarName = "PGRST_" <> (toUpper . dashToUnderscore <$> toS key)+        reloadableDbSetting =+          let dbSettingName = T.pack $ dashToUnderscore <$> toS key in+          if dbSettingName `notElem` [+            "server_host", "server_port", "server_unix_socket", "server_unix_socket_mode", "log_level",+            "db_anon_role", "db_uri", "db_channel_enabled", "db_channel", "db_pool", "db_pool_timeout", "db_config"]+          then lookup dbSettingName dbSettings+          else Nothing+     coerceText :: C.Value -> Text     coerceText (C.String s) = s     coerceText v            = show v@@ -224,85 +370,77 @@      coerceBool :: C.Value -> Maybe Bool     coerceBool (C.Bool b)   = Just b-    coerceBool (C.String b) = readMaybe $ toS b+    coerceBool (C.String s) =+      -- parse all kinds of text: True, true, TRUE, "true", ...+      case readMaybe . toS $ T.toTitle $ T.filter isAlpha $ toS s of+        Just b  -> Just b+        -- numeric instead?+        Nothing -> (> 0) <$> (readMaybe $ toS s :: Maybe Integer)     coerceBool _            = Nothing -    parseRoleClaimKey :: C.Value -> Either ApiRequestError JSPath-    parseRoleClaimKey (C.String s) = pRoleClaimKey s-    parseRoleClaimKey v            = pRoleClaimKey $ show v-     splitOnCommas :: C.Value -> [Text]-    splitOnCommas (C.String s) = strip <$> splitOn "," s+    splitOnCommas (C.String s) = T.strip <$> T.splitOn "," s     splitOnCommas _            = [] -    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)-               )+-- | Read the JWT secret from a file if configJwtSecret is actually a+-- filepath(has @ as its prefix). To check if the JWT secret is provided is+-- in fact a file path, it must be decoded as 'Text' to be processed.+readSecretFile :: AppConfig -> IO AppConfig+readSecretFile conf =+  maybe (return conf) readSecret maybeFilename+  where+    maybeFilename = T.stripPrefix "@" . decodeUtf8 =<< configJwtSecret conf+    readSecret filename = do+      jwtSecret <- chomp <$> BS.readFile (toS filename)+      return $ conf { configJwtSecret = Just jwtSecret }+    chomp bs = fromMaybe bs (BS.stripSuffix "\n" bs) -    parserPrefs = prefs showHelpOnError+decodeSecret :: AppConfig -> IO AppConfig+decodeSecret conf@AppConfig{..} =+  case (configJwtSecretIsBase64, configJwtSecret) of+    (True, Just secret) ->+      either fail (return . updateSecret) $ decodeB64 secret+    _ -> return conf+  where+    updateSecret bs = conf { configJwtSecret = Just bs }+    decodeB64 = B64.decode . encodeUtf8 . T.strip . replaceUrlChars . decodeUtf8+    replaceUrlChars = T.replace "_" "/" . T.replace "-" "+" . T.replace "." "=" -    exitErr :: Text -> IO a-    exitErr err = do-      hPutStrLn stderr err-      exitFailure+-- | Parse `jwt-secret` configuration option and turn into a JWKSet.+--+-- There are three ways to specify `jwt-secret`: text secret, JSON Web Key+-- (JWK), or JSON Web Key Set (JWKS). The first two are converted into a JWKSet+-- with one key and the last is converted as is.+decodeJWKS :: AppConfig -> AppConfig+decodeJWKS conf =+  conf { configJWKS = parseSecret <$> configJwtSecret conf } -    exampleCfg :: Doc-    exampleCfg = vsep . map (text . toS) . lines $-      [str|db-uri = "postgres://user:pass@localhost:5432/dbname"-          |db-schema = "public" # this schema gets added to the search_path of every request-          |db-anon-role = "postgres"-          |db-pool = 10-          |db-pool-timeout = 10-          |-          |server-host = "!4"-          |server-port = 3000-          |-          |## unix socket location-          |## if specified it takes precedence over server-port-          |# server-unix-socket = "/tmp/pgrst.sock"-          |## unix socket file mode-          |## when none is provided, 660 is applied by default-          |# server-unix-socket-mode = "660"-          |-          |## base url for swagger output-          |# openapi-server-proxy-uri = ""-          |-          |## choose a secret, JSON Web Key (or set) to enable JWT auth-          |## (use "@filename" to load from separate file)-          |# jwt-secret = "secret_with_at_least_32_characters"-          |# 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"-          |-          |## jspath to the role claim key-          |# role-claim-key = ".role"-          |-          |## extra schemas to add to the search_path of every request-          |# db-extra-search-path = "extensions, util"-          |-          |## stored proc that overrides the root "/" spec-          |## it must be inside the db-schema-          |# root-spec = "stored_proc_name"-          |-          |## content types to produce raw output-          |# raw-media-types="image/png, image/jpg"-          |]+parseSecret :: ByteString -> JWKSet+parseSecret bytes =+  fromMaybe (maybe secret (\jwk' -> JWT.JWKSet [jwk']) maybeJWK)+    maybeJWKSet+  where+    maybeJWKSet = JSON.decode (toS bytes) :: Maybe JWKSet+    maybeJWK = JSON.decode (toS bytes) :: Maybe JWK+    secret = JWT.JWKSet [JWT.fromKeyMaterial keyMaterial]+    keyMaterial = JWT.OctKeyMaterial . JWT.OctKeyParameters $ JOSE.Base64Octets bytes -pathParser :: Parser FilePath-pathParser =-  strArgument $-    metavar "FILENAME" <>-    help "Path to configuration file"+-- | Read database uri from a separate file if `db-uri` is a filepath.+readDbUriFile :: Maybe Text -> AppConfig -> IO AppConfig+readDbUriFile maybeDbUri conf =+  case maybeDbUri of+    Just prevDbUri ->+      pure $ conf { configDbUri = prevDbUri }+    Nothing ->+      case T.stripPrefix "@" $ configDbUri conf of+        Nothing -> return conf+        Just filename -> do+          dbUri <- T.strip <$> readFile (toS filename)+          return $ conf { configDbUri = dbUri }++type Environment = M.Map [Char] Text++-- | Read environment variables that start with PGRST_+readPGRSTEnvironment :: IO Environment+readPGRSTEnvironment =+  M.map T.pack . M.fromList . filter (isPrefixOf "PGRST_" . fst) <$> getEnvironment
+ src/PostgREST/Config/Database.hs view
@@ -0,0 +1,56 @@+{-# LANGUAGE QuasiQuotes #-}++module PostgREST.Config.Database+  ( queryDbSettings+  , queryPgVersion+  ) where++import PostgREST.Config.PgVersion (PgVersion (..))++import qualified Hasql.Decoders             as HD+import qualified Hasql.Encoders             as HE+import qualified Hasql.Pool                 as P+import qualified Hasql.Session              as H+import qualified Hasql.Statement            as H+import qualified Hasql.Transaction          as HT+import qualified Hasql.Transaction.Sessions as HT++import Text.InterpolatedString.Perl6 (q)++import Protolude++queryPgVersion :: H.Session PgVersion+queryPgVersion = H.statement mempty $ H.Statement sql HE.noParams versionRow False+  where+    sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')"+    versionRow = HD.singleRow $ PgVersion <$> column HD.int4 <*> column HD.text++queryDbSettings :: P.Pool -> Bool -> IO (Either P.UsageError [(Text, Text)])+queryDbSettings pool prepared =+  let transaction = if prepared then HT.transaction else HT.unpreparedTransaction in+  P.use pool . transaction HT.ReadCommitted HT.Read $+    HT.statement mempty dbSettingsStatement++-- | Get db settings from the connection role. Global settings will be overridden by database specific settings.+dbSettingsStatement :: H.Statement () [(Text, Text)]+dbSettingsStatement = H.Statement sql HE.noParams decodeSettings False+  where+    sql = [q|+      with+      role_setting as (+        select setdatabase, unnest(setconfig) as setting from pg_catalog.pg_db_role_setting+        where setrole = current_user::regrole::oid+          and setdatabase in (0, (select oid from pg_catalog.pg_database where datname = current_catalog))+      ),+      kv_settings as (+        select setdatabase, split_part(setting, '=', 1) as k, split_part(setting, '=', 2) as value from role_setting+        where setting like 'pgrst.%'+      )+      select distinct on (key) replace(k, 'pgrst.', '') as key, value+      from kv_settings+      order by key, setdatabase desc;+    |]+    decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text++column :: HD.Value a -> HD.Row a+column = HD.column . HD.nonNullable
+ src/PostgREST/Config/JSPath.hs view
@@ -0,0 +1,59 @@+{-|+Module      : PostgREST.Types+Description : PostgREST common types and functions used by the rest of the modules+-}+{-# LANGUAGE DuplicateRecordFields #-}++module PostgREST.Config.JSPath+  ( JSPath+  , JSPathExp(..)+  , pRoleClaimKey+  ) where++import qualified Text.ParserCombinators.Parsec as P++import Data.Either.Combinators       (mapLeft)+import Text.ParserCombinators.Parsec ((<?>))+import Text.Read                     (read)++import qualified GHC.Show (show)++import Protolude      hiding (toS)+import Protolude.Conv (toS)+++-- | full jspath, e.g. .property[0].attr.detail+type JSPath = [JSPathExp]++-- | jspath expression, e.g. .property, .property[0] or ."property-dash"+data JSPathExp+  = JSPKey Text+  | JSPIdx Int++instance Show JSPathExp where+  -- TODO: this needs to be quoted properly for special chars+  show (JSPKey k) = "." <> show k+  show (JSPIdx i) = "[" <> show i <> "]"++-- Used for the config value "role-claim-key"+pRoleClaimKey :: Text -> Either Text JSPath+pRoleClaimKey selStr =+  mapLeft show $ P.parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr)++pJSPath :: P.Parser JSPath+pJSPath = toJSPath <$> (period *> pPath `P.sepBy` period <* P.eof)+  where+    toJSPath :: [(Text, Maybe Int)] -> JSPath+    toJSPath = concatMap (\(key, idx) -> JSPKey key : maybeToList (JSPIdx <$> idx))+    period = P.char '.' <?> "period (.)"+    pPath :: P.Parser (Text, Maybe Int)+    pPath = (,) <$> pJSPKey <*> P.optionMaybe pJSPIdx++pJSPKey :: P.Parser Text+pJSPKey = toS <$> P.many1 (P.alphaNum <|> P.oneOf "_$@") <|> pQuotedValue <?> "attribute name [a..z0..9_$@])"++pJSPIdx :: P.Parser Int+pJSPIdx = P.char '[' *> (read <$> P.many1 P.digit) <* P.char ']' <?> "array index [0..n]"++pQuotedValue :: P.Parser Text+pQuotedValue = toS <$> (P.char '"' *> P.many (P.noneOf "\"") <* P.char '"')
+ src/PostgREST/Config/PgVersion.hs view
@@ -0,0 +1,60 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric  #-}+module PostgREST.Config.PgVersion+  ( PgVersion(..)+  , minimumPgVersion+  , pgVersion95+  , pgVersion96+  , pgVersion100+  , pgVersion109+  , pgVersion110+  , pgVersion112+  , pgVersion114+  , pgVersion121+  , pgVersion130+  ) where++import qualified Data.Aeson as JSON++import Protolude+++data PgVersion = PgVersion+  { pgvNum  :: Int32+  , pgvName :: Text+  }+  deriving (Eq, Generic, JSON.ToJSON)++instance Ord PgVersion where+  (PgVersion v1 _) `compare` (PgVersion v2 _) = v1 `compare` v2++-- | Tells the minimum PostgreSQL version required by this version of PostgREST+minimumPgVersion :: PgVersion+minimumPgVersion = pgVersion95++pgVersion95 :: PgVersion+pgVersion95 = PgVersion 90500 "9.5"++pgVersion96 :: PgVersion+pgVersion96 = PgVersion 90600 "9.6"++pgVersion100 :: PgVersion+pgVersion100 = PgVersion 100000 "10"++pgVersion109 :: PgVersion+pgVersion109 = PgVersion 100009 "10.9"++pgVersion110 :: PgVersion+pgVersion110 = PgVersion 110000 "11.0"++pgVersion112 :: PgVersion+pgVersion112 = PgVersion 110002 "11.2"++pgVersion114 :: PgVersion+pgVersion114 = PgVersion 110004 "11.4"++pgVersion121 :: PgVersion+pgVersion121 = PgVersion 120001 "12.1"++pgVersion130 :: PgVersion+pgVersion130 = PgVersion 130000 "13.0"
+ src/PostgREST/Config/Proxy.hs view
@@ -0,0 +1,78 @@+{-|+Module      : PostgREST.Private.ProxyUri+Description : Proxy Uri validator+-}+module PostgREST.Config.Proxy+  ( Proxy(..)+  , isMalformedProxyUri+  , toURI+  ) where++import Data.Maybe  (fromJust)+import Data.Text   (pack, toLower)+import Network.URI (URI (..), URIAuth (..), isAbsoluteURI, parseURI)++import Protolude      hiding (Proxy, dropWhile, get, intercalate,+                       toLower, toS, (&))+import Protolude.Conv (toS)++data Proxy = Proxy+  { proxyScheme :: Text+  , proxyHost   :: Text+  , proxyPort   :: Integer+  , proxyPath   :: Text+  }++{-|+  Test whether a proxy uri is malformed or not.+  A valid proxy uri should be an absolute uri without query and user info,+  only http(s) schemes are valid, port number range is 1-65535.++  For example+  http://postgrest.com/openapi.json+  https://postgrest.com:8080/openapi.json+-}+isMalformedProxyUri :: Text -> Bool+isMalformedProxyUri uri+  | isAbsoluteURI (toS uri) = not $ isUriValid $ toURI uri+  | otherwise = True++toURI :: Text -> URI+toURI uri = fromJust $ parseURI (toS uri)++isUriValid:: URI -> Bool+isUriValid = fAnd [isSchemeValid, isQueryValid, isAuthorityValid]++fAnd :: [a -> Bool] -> a -> Bool+fAnd fs x = all ($ x) fs++isSchemeValid :: URI -> Bool+isSchemeValid URI {uriScheme = s}+  | toLower (pack s) == "https:" = True+  | toLower (pack s) == "http:" = True+  | otherwise = False++isQueryValid :: URI -> Bool+isQueryValid URI {uriQuery = ""} = True+isQueryValid _                   = False++isAuthorityValid :: URI -> Bool+isAuthorityValid URI {uriAuthority = a}+  | isJust a = fAnd [isUserInfoValid, isHostValid, isPortValid] $ fromJust a+  | otherwise = False++isUserInfoValid :: URIAuth -> Bool+isUserInfoValid URIAuth {uriUserInfo = ""} = True+isUserInfoValid _                          = False++isHostValid :: URIAuth -> Bool+isHostValid URIAuth {uriRegName = ""} = False+isHostValid _                         = True++isPortValid :: URIAuth -> Bool+isPortValid URIAuth {uriPort = ""} = True+isPortValid URIAuth {uriPort = (':':p)} =+  case readMaybe p of+    Just i  -> i > (0 :: Integer) && i < 65536+    Nothing -> False+isPortValid _ = False
+ src/PostgREST/ContentType.hs view
@@ -0,0 +1,64 @@+{-# LANGUAGE DuplicateRecordFields #-}++module PostgREST.ContentType+  ( ContentType(..)+  , toHeader+  , toMime+  , decodeContentType+  ) where++import qualified Data.ByteString          as BS+import qualified Data.ByteString.Internal as BS (c2w)++import Network.HTTP.Types.Header (Header, hContentType)++import Protolude++-- | Enumeration of currently supported response content types+data ContentType+  = CTApplicationJSON+  | CTSingularJSON+  | CTTextCSV+  | CTTextPlain+  | CTOpenAPI+  | CTUrlEncoded+  | CTOctetStream+  | CTAny+  | CTOther ByteString+  deriving (Eq)++-- | Convert from ContentType to a full HTTP Header+toHeader :: ContentType -> Header+toHeader ct = (hContentType, toMime ct <> charset)+  where+    charset = case ct of+      CTOctetStream -> mempty+      CTOther _     -> mempty+      _             -> "; charset=utf-8"++-- | Convert from ContentType to a ByteString representing the mime type+toMime :: ContentType -> ByteString+toMime CTApplicationJSON = "application/json"+toMime CTTextCSV         = "text/csv"+toMime CTTextPlain       = "text/plain"+toMime CTOpenAPI         = "application/openapi+json"+toMime CTSingularJSON    = "application/vnd.pgrst.object+json"+toMime CTUrlEncoded      = "application/x-www-form-urlencoded"+toMime CTOctetStream     = "application/octet-stream"+toMime CTAny             = "*/*"+toMime (CTOther ct)      = ct++-- | Convert from ByteString to ContentType. Warning: discards MIME parameters+decodeContentType :: BS.ByteString -> ContentType+decodeContentType ct =+  case BS.takeWhile (/= BS.c2w ';') ct of+    "application/json"                  -> CTApplicationJSON+    "text/csv"                          -> CTTextCSV+    "text/plain"                        -> CTTextPlain+    "application/openapi+json"          -> CTOpenAPI+    "application/vnd.pgrst.object+json" -> CTSingularJSON+    "application/vnd.pgrst.object"      -> CTSingularJSON+    "application/x-www-form-urlencoded" -> CTUrlEncoded+    "application/octet-stream"          -> CTOctetStream+    "*/*"                               -> CTAny+    ct'                                 -> CTOther ct'
− src/PostgREST/DbRequestBuilder.hs
@@ -1,351 +0,0 @@-{-|-Module      : PostgREST.DbRequestBuilder-Description : PostgREST database request builder--This module is in charge of building an intermediate representation(ReadRequest, MutateRequest) between the HTTP request and the final resulting SQL query.--A query tree is built in case of resource embedding. By inferring the relationship between tables, join conditions are added for every embedded resource.--}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts      #-}-{-# LANGUAGE LambdaCase            #-}-{-# LANGUAGE NamedFieldPuns        #-}--module PostgREST.DbRequestBuilder (-  readRequest-, mutateRequest-, returningCols-) where--import qualified Data.HashMap.Strict as M-import qualified Data.Set            as S--import Control.Arrow           ((***))-import Data.Either.Combinators (mapLeft)-import Data.Foldable           (foldr1)-import Data.List               (delete)-import Data.Text               (isInfixOf)--import Control.Applicative-import Data.Tree-import Network.Wai--import PostgREST.ApiRequest (Action (..), ApiRequest (..))-import PostgREST.Error      (ApiRequestError (..), errorResponseFor)-import PostgREST.Parsers-import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange)-import PostgREST.Types-import Protolude            hiding (from)--readRequest :: Schema -> TableName -> Maybe Integer -> [Relation] -> ApiRequest -> Either Response ReadRequest-readRequest schema rootTableName maxRows allRels apiRequest  =-  mapLeft errorResponseFor $-  treeRestrictRange maxRows =<<-  augmentRequestWithJoin schema rootRels =<<-  addFiltersOrdersRanges apiRequest <*>-  (initReadRequest rootName <$> pRequestSelect sel)-  where-    sel = fromMaybe "*" $ iSelect apiRequest -- default to all columns requested (SELECT *) for a non existent ?select querystring param-    (rootName, rootRels) = rootWithRels schema rootTableName allRels (iAction apiRequest)---- Get the root table name with its relationships according to the Action type.--- This is done because of the shape of the final SQL Query. The mutation cases are wrapped in a WITH {sourceCTEName}(see Statements.hs).--- So we need a FROM {sourceCTEName} instead of FROM {tableName}.-rootWithRels :: Schema -> TableName -> [Relation] -> Action -> (QualifiedIdentifier, [Relation])-rootWithRels schema rootTableName allRels action = case action of-  ActionRead _ -> (QualifiedIdentifier schema rootTableName, allRels) -- normal read case-  _            -> (QualifiedIdentifier mempty sourceCTEName, mapMaybe toSourceRel allRels ++ allRels) -- mutation cases and calling proc-  where-    -- To enable embedding in the sourceCTEName cases we need to replace the foreign key tableName in the Relation-    -- with {sourceCTEName}. This way findRel can find relationships with sourceCTEName.-    toSourceRel :: Relation -> Maybe Relation-    toSourceRel r@Relation{relTable=t}-      | rootTableName == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}-      | otherwise                    = Nothing---- Build the initial tree with a Depth attribute so when a self join occurs we can differentiate the parent and child tables by having--- an alias like "table_depth", this is related to http://github.com/PostgREST/postgrest/issues/987.-initReadRequest :: QualifiedIdentifier -> [Tree SelectItem] -> ReadRequest-initReadRequest rootQi =-  foldr (treeEntry rootDepth) initial-  where-    rootDepth = 0-    rootSchema = qiSchema rootQi-    rootName = qiName rootQi-    initial = Node (Select [] rootQi Nothing [] [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, rootDepth)) []-    treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest-    treeEntry depth (Node fld@((fn, _),_,alias, embedHint) fldForest) (Node (q, i) rForest) =-      let nxtDepth = succ depth in-      case fldForest of-        [] -> Node (q {select=fld:select q}, i) rForest-        _  -> Node (q, i) $-              foldr (treeEntry nxtDepth)-              (Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] [] allRange,-                (fn, Nothing, alias, embedHint, nxtDepth)) [])-              fldForest:rForest--treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest-treeRestrictRange maxRows request = pure $ nodeRestrictRange maxRows <$> request-  where-    nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode-    nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)--augmentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest-augmentRequestWithJoin schema allRels request =-  addRels schema allRels Nothing request-  >>= addJoinConditions Nothing--addRels :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest-addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, depth)) forest) =-  case parentNode of-    Just (Node (Select{from=parentNodeQi}, _) _) ->-      let newFrom r = if qiName tbl == nodeName then tableQi (relFTable r) else tbl-          newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel-          rel = findRel schema allRels (qiName parentNodeQi) nodeName hint-      in-      Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)-    _ ->-      let rn = (query, (nodeName, Nothing, alias, Nothing, depth)) in-      Node rn <$> updateForest (Just $ Node rn forest)-  where-    updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]-    updateForest rq = mapM (addRels schema allRels rq) forest---- Finds a relationship between an origin and a target in the request: /origin?select=target(*)--- If more than one relationship is found then the request is ambiguous and we return an error.--- In that case the request can be disambiguated by adding precision to the target or by using a hint: /origin?select=target!hint(*)--- The elements will be matched according to these rules:--- origin = table / view--- target = table / view / constraint / column-from-origin--- hint   = table / view / constraint / column-from-origin / column-from-target--- (hint can take table / view values to aid in finding the junction in an m2m relationship)-findRel :: Schema -> [Relation] -> NodeName -> NodeName -> Maybe EmbedHint -> Either ApiRequestError Relation-findRel schema allRels origin target hint =-  case rel of-    []  -> Left $ NoRelBetween origin target-    [r] -> Right r-    rs  ->-      -- Return error if more than one relationship is found, unless we're in a self reference case.-      ---      -- Here we handle a self reference relationship to not cause a breaking change:-      -- In a self reference we get two relationships with the same foreign key and relTable/relFtable but with different cardinalities(m2o/o2m)-      -- We output the O2M rel, the M2O rel can be obtained by using the origin column as an embed hint.-      let [rel0, rel1] = take 2 rs in-      if length rs == 2 && relConstraint rel0 == relConstraint rel1 && relTable rel0 == relTable rel1 && relFTable rel0 == relFTable rel1-        then note (NoRelBetween origin target) (find (\r -> relType r == O2M) rs)-        else Left $ AmbiguousRelBetween origin target rs-  where-    matchFKSingleCol hint_ cols = length cols == 1 && hint_ == (colName <$> head cols)-    rel = filter (-      \Relation{relTable, relColumns, relConstraint, relFTable, relFColumns, relType, relJunction} ->-        -- Both relationship ends need to be on the exposed schema-        schema == tableSchema relTable && schema == tableSchema relFTable &&-        (-          -- /projects?select=clients(*)-          origin == tableName relTable  &&  -- projects-          target == tableName relFTable ||  -- clients--          -- /projects?select=projects_client_id_fkey(*)-          (-            origin == tableName relTable && -- projects-            Just target == relConstraint    -- projects_client_id_fkey-          ) ||-          -- /projects?select=client_id(*)-          (-            origin == tableName relTable &&           -- projects-            matchFKSingleCol (Just target) relColumns -- client_id-          )-        ) && (-          isNothing hint || -- hint is optional--          -- /projects?select=clients!projects_client_id_fkey(*)-          hint == relConstraint             || -- projects_client_id_fkey--          -- /projects?select=clients!client_id(*) or /projects?select=clients!id(*)-          matchFKSingleCol hint relColumns  || -- client_id-          matchFKSingleCol hint relFColumns || -- id--          -- /users?select=tasks!users_tasks(*)-          (-            relType == M2M &&                              -- many-to-many between users and tasks-            hint == (tableName . junTable <$> relJunction) -- users_tasks-          )-        )-      ) allRels---- previousAlias is only used for the case of self joins-addJoinConditions :: Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest-addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, rel, _, _, depth)) forest) =-  case rel of-    Just r@Relation{relType=O2M} -> Node (augmentQuery r, nodeProps) <$> updatedForest-    Just r@Relation{relType=M2O} -> Node (augmentQuery r, nodeProps) <$> updatedForest-    Just r@Relation{relType=M2M, relJunction=junction} ->-      case junction of-        Just Junction{junTable} ->-          let rq = augmentQuery r in-          Node (rq{implicitJoins=tableQi junTable:implicitJoins rq}, nodeProps) <$> updatedForest-        Nothing ->-          Left UnknownRelation-    Nothing -> Node node <$> updatedForest-  where-    newAlias = case isSelfReference <$> rel of-      Just True-        | depth /= 0 -> Just (qiName tbl <> "_" <> show depth) -- root node doesn't get aliased-        | otherwise  -> Nothing-      _              -> Nothing-    augmentQuery r =-      foldr-        (\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})-        query{fromAlias=newAlias}-        (getJoinConditions previousAlias newAlias r)-    updatedForest = mapM (addJoinConditions newAlias) forest---- previousAlias and newAlias are used in the case of self joins-getJoinConditions :: Maybe Alias -> Maybe Alias -> Relation -> [JoinCondition]-getJoinConditions previousAlias newAlias (Relation Table{tableSchema=tSchema, tableName=tN} cols _ Table{tableName=ftN} fCols typ jun) =-  case typ of-    O2M ->-        zipWith (toJoinCondition tN ftN) cols fCols-    M2O ->-        zipWith (toJoinCondition tN ftN) cols fCols-    M2M -> case jun of-        Just (Junction jt _ jc1 _ jc2) ->-          let jtn = tableName jt in-          zipWith (toJoinCondition tN jtn) cols jc1 ++ zipWith (toJoinCondition ftN jtn) fCols jc2-        Nothing -> []-  where-    toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition-    toJoinCondition tb ftb c fc =-      let qi1 = removeSourceCTESchema tSchema tb-          qi2 = removeSourceCTESchema tSchema ftb in-        JoinCondition (maybe qi1 (QualifiedIdentifier mempty) previousAlias, colName c)-                      (maybe qi2 (QualifiedIdentifier mempty) newAlias, colName fc)--    -- On mutation and calling proc cases we wrap the target table in a WITH {sourceCTEName}-    -- if this happens remove the schema `FROM "schema"."{sourceCTEName}"` and use only the-    -- `FROM "{sourceCTEName}"`. If the schema remains the FROM would be invalid.-    removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier-    removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then mempty else schema) tbl--addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)-addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [-    flip (foldr addFilter) <$> filters,-    flip (foldr addOrder) <$> orders,-    flip (foldr addRange) <$> ranges,-    flip (foldr addLogicTree) <$> logicForest-  ]-  {--  The esence of what is going on above is that we are composing tree functions-  of type (ReadRequest->ReadRequest) that are in (Either ApiRequestError a) context-  -}-  where-    filters :: Either ApiRequestError [(EmbedPath, Filter)]-    filters = mapM pRequestFilter flts-    logicForest :: Either ApiRequestError [(EmbedPath, LogicTree)]-    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) =-      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)]-    ranges = mapM pRequestRange $ M.toList $ iRange apiRequest--addFilterToNode :: Filter -> ReadRequest -> ReadRequest-addFilterToNode flt (Node (q@Select {where_=lf}, i) f) = Node (q{where_=addFilterToLogicForest flt lf}::ReadQuery, i) f--addFilter :: (EmbedPath, Filter) -> ReadRequest -> ReadRequest-addFilter = addProperty addFilterToNode--addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest-addOrderToNode o (Node (q,i) f) = Node (q{order=o}, i) f--addOrder :: (EmbedPath, [OrderTerm]) -> ReadRequest -> ReadRequest-addOrder = addProperty addOrderToNode--addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest-addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f--addRange :: (EmbedPath, NonnegRange) -> ReadRequest -> ReadRequest-addRange = addProperty addRangeToNode--addLogicTreeToNode :: LogicTree -> ReadRequest -> ReadRequest-addLogicTreeToNode t (Node (q@Select{where_=lf},i) f) = Node (q{where_=t:lf}::ReadQuery, i) f--addLogicTree :: (EmbedPath, LogicTree) -> ReadRequest -> ReadRequest-addLogicTree = addProperty addLogicTreeToNode--addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest-addProperty f ([], a) rr = f a rr-addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =-  case pathNode of-    Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path-    Just tn -> Node rn (addProperty f (remainingPath, a) tn:delete tn forest)-  where-    pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest--mutateRequest :: Schema -> TableName -> ApiRequest -> S.Set FieldName -> [FieldName] -> ReadRequest -> Either Response MutateRequest-mutateRequest schema tName apiRequest cols pkCols readReq = mapLeft errorResponseFor $-  case action of-    ActionCreate -> do-        confCols <- case iOnConflict apiRequest of-            Nothing    -> pure pkCols-            Just param -> pRequestOnConflict param-        pure $ Insert qi cols ((,) <$> iPreferResolution apiRequest <*> Just confCols) [] returnings-    ActionUpdate -> Update qi cols <$> combinedLogic <*> pure returnings-    ActionSingleUpsert ->-      (\flts ->-        if null (iLogic apiRequest) &&-           S.fromList (fst <$> iFilters apiRequest) == S.fromList pkCols &&-           not (null (S.fromList pkCols)) &&-           all (\case-              Filter _ (OpExpr False (Op "eq" _)) -> True-              _ -> False) flts-          then Insert qi cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings-        else-          Left InvalidFilters) =<< filters-    ActionDelete -> Delete qi <$> combinedLogic <*> pure returnings-    _            -> Left UnsupportedVerb-  where-    qi = QualifiedIdentifier schema tName-    action = iAction apiRequest-    returnings =-      if iPreferRepresentation apiRequest == None-        then []-        else returningCols readReq-    filters = map snd <$> mapM pRequestFilter mutateFilters-    logic = map snd <$> mapM pRequestLogicTree logicFilters-    combinedLogic = foldr addFilterToLogicForest <$> logic <*> filters-    -- update/delete filters can be only on the root table-    (mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)-    onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)--returningCols :: ReadRequest -> [FieldName]-returningCols rr@(Node _ forest) = returnings-  where-    fldNames = fstFieldNames rr-    -- Without fkCols, when a mutateRequest to /projects?select=name,clients(name) occurs, the RETURNING SQL part would be-    -- `RETURNING name`(see QueryBuilder).-    -- This would make the embedding fail because the following JOIN would need the "client_id" column from projects.-    -- So this adds the foreign key columns to ensure the embedding succeeds, result would be `RETURNING name, client_id`.-    -- This also works for the other relType's.-    fkCols = concat $ mapMaybe (\case-        Node (_, (_, Just Relation{relColumns=cols, relType=relTyp}, _, _, _)) _ -> case relTyp of-          O2M -> Just cols-          M2O -> Just cols-          M2M -> Just cols-        _ -> Nothing-      ) forest-    -- However if the "client_id" is present, e.g. mutateRequest to /projects?select=client_id,name,clients(name)-    -- we would get `RETURNING client_id, name, client_id` and then we would produce the "column reference \"client_id\" is ambiguous"-    -- error from PostgreSQL. So we deduplicate with Set:-    returnings = S.toList . S.fromList $ fldNames ++ (colName <$> fkCols)---- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree--- they are later concatenated with AND in the QueryBuilder-addFilterToLogicForest :: Filter -> [LogicTree] -> [LogicTree]-addFilterToLogicForest flt lf = Stmnt flt : lf
src/PostgREST/DbStructure.hs view
@@ -8,64 +8,118 @@  These queries are executed once at startup or when PostgREST is reloaded. -}+{-# LANGUAGE DeriveAnyClass        #-}+{-# LANGUAGE DeriveGeneric         #-} {-# LANGUAGE FlexibleContexts      #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE NamedFieldPuns        #-} {-# LANGUAGE QuasiQuotes           #-}+{-# LANGUAGE RecordWildCards       #-} {-# LANGUAGE ScopedTypeVariables   #-} {-# LANGUAGE TypeSynonymInstances  #-}-module PostgREST.DbStructure (-  getDbStructure-, accessibleTables-, accessibleProcs-, schemaDescription-, getPgVersion-) where +module PostgREST.DbStructure+  ( DbStructure(..)+  , queryDbStructure+  , accessibleTables+  , accessibleProcs+  , schemaDescription+  , tableCols+  , tablePKCols+  ) where++import qualified Data.Aeson          as JSON import qualified Data.HashMap.Strict as M import qualified Data.List           as L-import qualified Data.Text           as T import qualified Hasql.Decoders      as HD import qualified Hasql.Encoders      as HE-import qualified Hasql.Session       as H import qualified Hasql.Statement     as H import qualified Hasql.Transaction   as HT +import Contravariant.Extras          (contrazip2) import Data.Set                      as S (fromList)-import Data.Text                     (breakOn, dropAround, split,-                                      splitOn, strip)-import GHC.Exts                      (groupWith)-import Protolude                     hiding (toS)-import Protolude.Conv                (toS)-import Protolude.Unsafe              (unsafeHead)-import Text.InterpolatedString.Perl6 (q, qc)+import Data.Text                     (split)+import Text.InterpolatedString.Perl6 (q) -import PostgREST.Private.Common-import PostgREST.Types+import PostgREST.DbStructure.Identifiers  (QualifiedIdentifier (..),+                                           Schema, TableName)+import PostgREST.DbStructure.Proc         (PgArg (..), PgType (..),+                                           ProcDescription (..),+                                           ProcVolatility (..),+                                           ProcsMap, RetType (..))+import PostgREST.DbStructure.Relationship (Cardinality (..),+                                           Junction (..),+                                           PrimaryKey (..),+                                           Relationship (..))+import PostgREST.DbStructure.Table        (Column (..), Table (..)) -getDbStructure :: [Schema] -> PgVersion -> HT.Transaction DbStructure-getDbStructure schemas pgVer = do+import Protolude        hiding (toS)+import Protolude.Conv   (toS)+import Protolude.Unsafe (unsafeHead)+++data DbStructure = DbStructure+  { dbTables        :: [Table]+  , dbColumns       :: [Column]+  , dbRelationships :: [Relationship]+  , dbPrimaryKeys   :: [PrimaryKey]+  , dbProcs         :: ProcsMap+  }+  deriving (Generic, JSON.ToJSON)++-- TODO Table could hold references to all its Columns+tableCols :: DbStructure -> Schema -> TableName -> [Column]+tableCols dbs tSchema tName = filter (\Column{colTable=Table{tableSchema=s, tableName=t}} -> s==tSchema && t==tName) $ dbColumns dbs++-- TODO Table could hold references to all its PrimaryKeys+tablePKCols :: DbStructure -> Schema -> TableName -> [Text]+tablePKCols dbs tSchema tName =  pkName <$> filter (\pk -> tSchema == (tableSchema . pkTable) pk && tName == (tableName . pkTable) pk) (dbPrimaryKeys dbs)++-- | The source table column a view column refers to+type SourceColumn = (Column, ViewColumn)+type ViewColumn = Column++-- | A SQL query that can be executed independently+type SqlQuery = ByteString++queryDbStructure :: [Schema] -> [Schema] -> Bool -> HT.Transaction DbStructure+queryDbStructure schemas extraSearchPath prepared = do   HT.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object-  tabs    <- HT.statement () allTables-  cols    <- HT.statement schemas $ allColumns tabs-  srcCols <- HT.statement schemas $ allSourceColumns cols pgVer-  m2oRels <- HT.statement () $ allM2ORels tabs cols-  keys    <- HT.statement () $ allPrimaryKeys tabs-  procs   <- HT.statement schemas allProcs+  tabs    <- HT.statement mempty $ allTables prepared+  cols    <- HT.statement schemas $ allColumns tabs prepared+  srcCols <- HT.statement (schemas, extraSearchPath) $ pfkSourceColumns cols prepared+  m2oRels <- HT.statement mempty $ allM2ORels tabs cols prepared+  keys    <- HT.statement mempty $ allPrimaryKeys tabs prepared+  procs   <- HT.statement schemas $ allProcs prepared -  let rels = addM2MRels . addO2MRels $ addViewM2ORels srcCols m2oRels-      cols' = addForeignKeys rels cols+  let rels = addO2MRels . addM2MRels $ addViewM2ORels srcCols m2oRels       keys' = addViewPrimaryKeys srcCols keys -  return DbStructure {+  return $ removeInternal schemas $ DbStructure {       dbTables = tabs-    , dbColumns = cols'-    , dbRelations = rels+    , dbColumns = cols+    , dbRelationships = rels     , dbPrimaryKeys = keys'     , dbProcs = procs-    , pgVersion = pgVer     } +-- | Remove db objects that belong to an internal schema(not exposed through the API) from the DbStructure.+removeInternal :: [Schema] -> DbStructure -> DbStructure+removeInternal schemas dbStruct =+  DbStructure {+      dbTables        = filter (\x -> tableSchema x `elem` schemas) $ dbTables dbStruct+    , dbColumns       = filter (\x -> tableSchema (colTable x) `elem` schemas) (dbColumns dbStruct)+    , dbRelationships = filter (\x -> tableSchema (relTable x) `elem` schemas &&+                                      tableSchema (relForeignTable x) `elem` schemas &&+                                      not (hasInternalJunction x)) $ dbRelationships dbStruct+    , dbPrimaryKeys   = filter (\x -> tableSchema (pkTable x) `elem` schemas) $ dbPrimaryKeys dbStruct+    , dbProcs         = dbProcs dbStruct -- procs are only obtained from the exposed schemas, no need to filter them.+    }+  where+    hasInternalJunction rel = case relCardinality rel of+      M2M Junction{junTable} -> tableSchema junTable `notElem` schemas+      _                      -> False+ decodeTables :: HD.Result [Table] decodeTables =   HD.rowList tblRow@@ -74,23 +128,26 @@                  <*> column HD.text                  <*> nullableColumn HD.text                  <*> column HD.bool+                 <*> column HD.bool+                 <*> column HD.bool  decodeColumns :: [Table] -> HD.Result [Column] decodeColumns tables =   mapMaybe (columnFromRow tables) <$> HD.rowList colRow  where   colRow =-    (,,,,,,,,,,,)-      <$> column HD.text <*> column HD.text-      <*> column HD.text <*> nullableColumn HD.text-      <*> column HD.int4 <*> column HD.bool-      <*> column HD.text <*> column HD.bool-      <*> nullableColumn HD.int4+    (,,,,,,,,)+      <$> column HD.text+      <*> column HD.text+      <*> column HD.text+      <*> nullableColumn HD.text+      <*> column HD.bool+      <*> column HD.text       <*> nullableColumn HD.int4       <*> nullableColumn HD.text       <*> nullableColumn HD.text -decodeRels :: [Table] -> [Column] -> HD.Result [Relation]+decodeRels :: [Table] -> [Column] -> HD.Result [Relationship] decodeRels tables cols =   mapMaybe (relFromRow tables cols) <$> HD.rowList relRow  where@@ -98,10 +155,10 @@     <$> column HD.text     <*> column HD.text     <*> column HD.text-    <*> column (HD.array (HD.dimension replicateM (element HD.text)))+    <*> arrayColumn HD.text     <*> column HD.text     <*> column HD.text-    <*> column (HD.array (HD.dimension replicateM (element HD.text)))+    <*> arrayColumn HD.text  decodePks :: [Table] -> HD.Result [PrimaryKey] decodePks tables =@@ -134,81 +191,118 @@               <$> column HD.text               <*> column HD.text               <*> nullableColumn HD.text-              <*> (parseArgs <$> column HD.text)+              <*> compositeArrayColumn+                  (PgArg+                  <$> compositeField HD.text+                  <*> compositeField HD.text+                  <*> compositeField HD.bool+                  <*> compositeField HD.bool)               <*> (parseRetType                   <$> column HD.text                   <*> column HD.text                   <*> column HD.bool-                  <*> column HD.char)+                  <*> column HD.bool)               <*> (parseVolatility <$> column HD.char)+              <*> column HD.bool      addKey :: ProcDescription -> (QualifiedIdentifier, ProcDescription)     addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd) -    parseArgs :: Text -> [PgArg]-    parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',')--    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)--    parseRetType :: Text -> Text -> Bool -> Char -> RetType-    parseRetType schema name isSetOf typ+    parseRetType :: Text -> Text -> Bool -> Bool -> RetType+    parseRetType schema name isSetOf isComposite       | 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+        pgType+          | isComposite = Composite qi+          | otherwise   = Scalar      parseVolatility :: Char -> ProcVolatility     parseVolatility v | v == 'i' = Immutable                       | v == 's' = Stable                       | otherwise = Volatile -- only 'v' can happen here -allProcs :: H.Statement [Schema] ProcsMap-allProcs = H.Statement (toS sql) (arrayParam HE.text) decodeProcs True+allProcs :: Bool -> H.Statement [Schema] ProcsMap+allProcs = H.Statement (toS sql) (arrayParam HE.text) decodeProcs   where     sql = procsSqlQuery <> " WHERE pn.nspname = ANY($1)" -accessibleProcs :: H.Statement Schema ProcsMap-accessibleProcs = H.Statement (toS sql) (param HE.text) decodeProcs True+accessibleProcs :: Bool -> H.Statement Schema ProcsMap+accessibleProcs = H.Statement (toS sql) (param HE.text) decodeProcs   where     sql = procsSqlQuery <> " WHERE pn.nspname = $1 AND has_function_privilege(p.oid, 'execute')"  procsSqlQuery :: SqlQuery procsSqlQuery = [q|+ -- Recursively get the base types of domains+  WITH+  base_types AS (+    WITH RECURSIVE+    recurse AS (+      SELECT+        oid,+        typbasetype,+        COALESCE(NULLIF(typbasetype, 0), oid) AS base+      FROM pg_type+      UNION+      SELECT+        t.oid,+        b.typbasetype,+        COALESCE(NULLIF(b.typbasetype, 0), b.oid) AS base+      FROM recurse t+      JOIN pg_type b ON t.typbasetype = b.oid+    )+    SELECT+      oid,+      base+    FROM recurse+    WHERE typbasetype = 0+  ),+  arguments AS (+    SELECT+      oid,+      array_agg((+        COALESCE(name, ''), -- name+        type::regtype::text, -- type+        idx <= (pronargs - pronargdefaults), -- is_required+        COALESCE(mode = 'v', FALSE) -- is_variadic+      ) ORDER BY idx) AS args+    FROM pg_proc,+         unnest(proargnames, proargtypes, proargmodes)+           WITH ORDINALITY AS _ (name, type, mode, idx)+    WHERE type IS NOT NULL -- only input arguments+    GROUP BY oid+  )   SELECT-    pn.nspname as proc_schema,-    p.proname as proc_name,-    d.description as proc_description,-    pg_get_function_arguments(p.oid) as args,-    tn.nspname as rettype_schema,-    coalesce(comp.relname, t.typname) as rettype_name,-    p.proretset as rettype_is_setof,-    t.typtype as rettype_typ,-    p.provolatile+    pn.nspname AS proc_schema,+    p.proname AS proc_name,+    d.description AS proc_description,+    COALESCE(a.args, '{}') AS args,+    tn.nspname AS schema,+    COALESCE(comp.relname, t.typname) AS name,+    p.proretset AS rettype_is_setof,+    (t.typtype = 'c'+     -- Only pg pseudo type that is a row type is 'record'+     or t.typtype = 'p' and t.typname = 'record'+     -- if any INOUT or OUT arguments present, treat as composite+     or COALESCE(proargmodes::text[] && '{b,o}', false)+    ) AS rettype_is_composite,+    p.provolatile,+    p.provariadic > 0 as hasvariadic   FROM pg_proc p-    JOIN pg_namespace pn ON pn.oid = p.pronamespace-    JOIN pg_type t ON t.oid = p.prorettype-    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+  LEFT JOIN arguments a ON a.oid = p.oid+  JOIN pg_namespace pn ON pn.oid = p.pronamespace+  JOIN base_types bt ON bt.oid = p.prorettype+  JOIN pg_type t ON t.oid = bt.base+  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 |] -schemaDescription :: H.Statement Schema (Maybe Text)+schemaDescription :: Bool -> H.Statement Schema (Maybe Text) schemaDescription =-    H.Statement sql (param HE.text) (join <$> HD.rowMaybe (nullableColumn HD.text)) True+    H.Statement sql (param HE.text) (join <$> HD.rowMaybe (nullableColumn HD.text))   where     sql = [q|       select@@ -219,9 +313,9 @@       where         n.nspname = $1 |] -accessibleTables :: H.Statement Schema [Table]+accessibleTables :: Bool -> H.Statement Schema [Table] accessibleTables =-  H.Statement sql (param HE.text) decodeTables True+  H.Statement sql (param HE.text) decodeTables  where   sql = [q|     select@@ -229,22 +323,40 @@       relname as table_name,       d.description as table_description,       (-        c.relkind in ('r', 'v', 'f')-        and (pg_relation_is_updatable(c.oid::regclass, false) & 8) = 8-        -- The function `pg_relation_is_updateable` returns a bitmask where 8-        -- corresponds to `1 << CMD_INSERT` in the PostgreSQL source code, i.e.-        -- it's possible to insert into the relation.-        or (exists (-          select 1-          from pg_trigger-          where+        c.relkind IN ('r', 'v','f')+        AND (pg_relation_is_updatable(c.oid::regclass, FALSE) & 8) = 8+        OR EXISTS (+          SELECT 1+          FROM pg_trigger+          WHERE             pg_trigger.tgrelid = c.oid-            and (pg_trigger.tgtype::integer & 69) = 69)-            -- The trigger type `tgtype` is a bitmask where 69 corresponds to-            -- TRIGGER_TYPE_ROW + TRIGGER_TYPE_INSTEAD + TRIGGER_TYPE_INSERT-            -- in the PostgreSQL source code.+            AND (pg_trigger.tgtype::integer & 69) = 69         )-      ) as insertable+      ) AS insertable,+      (+        c.relkind IN ('r', 'v','f')+        AND (pg_relation_is_updatable(c.oid::regclass, FALSE) & 4) = 4+        -- CMD_UPDATE+        OR EXISTS (+          SELECT 1+          FROM pg_trigger+          WHERE+            pg_trigger.tgrelid = c.oid+            and (pg_trigger.tgtype::integer & 81) = 81+        )+      ) as updatable,+      (+        c.relkind IN ('r', 'v','f')+        AND (pg_relation_is_updatable(c.oid::regclass, FALSE) & 16) = 16+        -- CMD_DELETE+        OR EXISTS (+          SELECT 1+          FROM pg_trigger+          WHERE+            pg_trigger.tgrelid = c.oid+            and (pg_trigger.tgtype::integer & 73) = 73+        )+      ) as deletable     from       pg_class c       join pg_namespace n on n.oid = c.relnamespace@@ -259,26 +371,14 @@       )     order by relname |] -addForeignKeys :: [Relation] -> [Column] -> [Column]-addForeignKeys rels = map addFk-  where-    addFk col = col { colFK = fk col }-    fk col = find (lookupFn col) rels >>= relToFk col-    lookupFn :: Column -> Relation -> Bool-    lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==M2O-    relToFk col Relation{relColumns=cols, relFColumns=colsF} = do-      pos <- L.elemIndex col cols-      colF <- atMay colsF pos-      return $ ForeignKey colF- {--Adds Views M2O Relations based on SourceColumns found, the logic is as follows:+Adds Views M2O Relationships based on SourceColumns found, the logic is as follows: -Having a Relation{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relType=M2O} represented by:+Having a Relationship{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relCardinality=M2O} represented by:  t1.c1------t2.c2 -When only having a t1_view.c1 source column, we need to add a View-Table M2O Relation+When only having a t1_view.c1 source column, we need to add a View-Table M2O Relationship           t1.c1----t2.c2         t1.c1----------t2.c2                          ->            ________/@@ -286,88 +386,71 @@       t1_view.c1             t1_view.c1  -When only having a t2_view.c2 source column, we need to add a Table-View M2O Relation+When only having a t2_view.c2 source column, we need to add a Table-View M2O Relationship           t1.c1----t2.c2               t1.c1----------t2.c2                                ->          \________                                                     \                     t2_view.c2                      t2_view.c1 -When having t1_view.c1 and a t2_view.c2 source columns, we need to add a View-View M2O Relation in addition to the prior+When having t1_view.c1 and a t2_view.c2 source columns, we need to add a View-View M2O Relationship in addition to the prior           t1.c1----t2.c2               t1.c1----------t2.c2                                ->          \________/                                            /        \     t1_view.c1     t2_view.c2     t1_view.c1-------t2_view.c1 -The logic for composite pks is similar just need to make sure all the Relation columns have source columns.+The logic for composite pks is similar just need to make sure all the Relationship columns have source columns. -}-addViewM2ORels :: [SourceColumn] -> [Relation] -> [Relation]-addViewM2ORels allSrcCols = concatMap (\rel ->-  rel : case rel of-    Relation{relType=M2O, relTable, relColumns, relConstraint, relFTable, relFColumns} ->--      let srcColsGroupedByView :: [Column] -> [[SourceColumn]]-          srcColsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $-                                         filter (\(c, _) -> c `elem` relCols) allSrcCols-          relSrcCols = srcColsGroupedByView relColumns-          relFSrcCols = srcColsGroupedByView relFColumns-          getView :: [SourceColumn] -> Table-          getView = colTable . snd . unsafeHead-          srcCols `allSrcColsOf` cols = S.fromList (fst <$> srcCols) == S.fromList cols-          -- Relation is dependent on the order of relColumns and relFColumns to get the join conditions right in the generated query.-          -- So we need to change the order of the SourceColumns to match the relColumns-          -- TODO: This could be avoided if the Relation type is improved with a structure that maintains the association of relColumns and relFColumns-          srcCols `sortAccordingTo` cols = sortOn (\(k, _) -> L.lookup k $ zip cols [0::Int ..]) srcCols--          viewTableM2O =-            [ Relation (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)-                       relConstraint relFTable relFColumns-                       M2O Nothing-            | srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns ]+addViewM2ORels :: [SourceColumn] -> [Relationship] -> [Relationship]+addViewM2ORels allSrcCols = concatMap (\rel@Relationship{..} -> rel :+  let+    srcColsGroupedByView :: [Column] -> [[SourceColumn]]+    srcColsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $+                   filter (\(c, _) -> c `elem` relCols) allSrcCols+    relSrcCols = srcColsGroupedByView relColumns+    relFSrcCols = srcColsGroupedByView relForeignColumns+    getView :: [SourceColumn] -> Table+    getView = colTable . snd . unsafeHead+    srcCols `allSrcColsOf` cols = S.fromList (fst <$> srcCols) == S.fromList cols+    -- Relationship is dependent on the order of relColumns and relFColumns to get the join conditions right in the generated query.+    -- So we need to change the order of the SourceColumns to match the relColumns+    -- TODO: This could be avoided if the Relationship type is improved with a structure that maintains the association of relColumns and relFColumns+    srcCols `sortAccordingTo` cols = sortOn (\(k, _) -> L.lookup k $ zip cols [0::Int ..]) srcCols -          tableViewM2O =-            [ Relation relTable relColumns-                       relConstraint-                       (getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relFColumns)-                       M2O Nothing-            | fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relFColumns ]+    viewTableM2O =+      [ Relationship+          (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)+          relForeignTable relForeignColumns relCardinality+      | srcCols <- relSrcCols, srcCols `allSrcColsOf` relColumns ] -          viewViewM2O =-            [ Relation (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)-                       relConstraint-                       (getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relFColumns)-                       M2O Nothing-            | srcCols  <- relSrcCols, srcCols `allSrcColsOf` relColumns-            , fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relFColumns ]+    tableViewM2O =+      [ Relationship+          relTable relColumns+          (getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relForeignColumns)+          relCardinality+      | fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relForeignColumns ] -      in viewTableM2O ++ tableViewM2O ++ viewViewM2O+    viewViewM2O =+      [ Relationship+          (getView srcCols) (snd <$> srcCols `sortAccordingTo` relColumns)+          (getView fSrcCols) (snd <$> fSrcCols `sortAccordingTo` relForeignColumns)+          relCardinality+      | srcCols  <- relSrcCols, srcCols `allSrcColsOf` relColumns+      , fSrcCols <- relFSrcCols, fSrcCols `allSrcColsOf` relForeignColumns ] -    _ -> [])+  in viewTableM2O ++ tableViewM2O ++ viewViewM2O) -addO2MRels :: [Relation] -> [Relation]-addO2MRels = concatMap (\rel@(Relation t c cn ft fc _ _) -> [rel, Relation ft fc cn t c O2M Nothing])+addO2MRels :: [Relationship] -> [Relationship]+addO2MRels rels = rels ++ [ Relationship ft fc t c (O2M cons)+                          | Relationship t c ft fc (M2O cons) <- rels ] -addM2MRels :: [Relation] -> [Relation]-addM2MRels rels = rels ++ addMirrorRel (mapMaybe junction2Rel junctions)-  where-    junctions = join $ map (combinations 2) $ filter (not . null) $ groupWith groupFn $ filter ( (==M2O). relType) rels-    groupFn :: Relation -> Text-    groupFn Relation{relTable=Table{tableSchema=s, tableName=t}} = s <> "_" <> t-    -- Reference : https://wiki.haskell.org/99_questions/Solutions/26-    combinations :: Int -> [a] -> [[a]]-    combinations 0 _  = [ [] ]-    combinations n xs = [ y:ys | y:xs' <- tails xs-                               , ys <- combinations (n-1) xs']-    junction2Rel [-      Relation{relTable=jt, relColumns=jc1, relConstraint=const1, relFTable=t,  relFColumns=c},-      Relation{             relColumns=jc2, relConstraint=const2, relFTable=ft, relFColumns=fc}-      ]-      | jc1 /= jc2 && length jc1 == 1 && length jc2 == 1 = Just $ Relation t c Nothing ft fc M2M (Just $ Junction jt const1 jc1 const2 jc2)-      | otherwise = Nothing-    junction2Rel _ = Nothing-    addMirrorRel = concatMap (\rel@(Relation t c _ ft fc _ (Just (Junction jt const1 jc1 const2 jc2))) ->-      [rel, Relation ft fc Nothing t c M2M (Just (Junction jt const2 jc2 const1 jc1))])+addM2MRels :: [Relationship] -> [Relationship]+addM2MRels rels = rels ++ [ Relationship t c ft fc (M2M $ Junction jt1 cons1 jc1 cons2 jc2)+                          | Relationship jt1 jc1 t c (M2O cons1) <- rels+                          , Relationship jt2 jc2 ft fc (M2O cons2) <- rels+                          , jt1 == jt2+                          , cons1 /= cons2]  addViewPrimaryKeys :: [SourceColumn] -> [PrimaryKey] -> [PrimaryKey] addViewPrimaryKeys srcCols = concatMap (\pk ->@@ -375,36 +458,77 @@                 filter (\(col, _) -> colTable col == pkTable pk && colName col == pkName pk) srcCols in   pk : viewPks) -allTables :: H.Statement () [Table]+allTables :: Bool -> H.Statement () [Table] allTables =-  H.Statement sql HE.noParams decodeTables True+  H.Statement sql HE.noParams decodeTables  where   sql = [q|     SELECT       n.nspname AS table_schema,       c.relname AS table_name,-      NULL AS table_description,+      d.description AS table_description,       (-        c.relkind IN ('r', 'v','f')-        AND (pg_relation_is_updatable(c.oid::regclass, FALSE) & 8) = 8-        OR EXISTS (-          SELECT 1-          FROM pg_trigger-          WHERE-            pg_trigger.tgrelid = c.oid+        c.relkind = 'r'+        OR (+          c.relkind in ('v','f')+          AND (pg_relation_is_updatable(c.oid::regclass, FALSE) & 8) = 8+          -- The function `pg_relation_is_updateable` returns a bitmask where 8+          -- corresponds to `1 << CMD_INSERT` in the PostgreSQL source code, i.e.+          -- it's possible to insert into the relation.+          OR EXISTS (+            SELECT 1+            FROM pg_trigger+            WHERE+              pg_trigger.tgrelid = c.oid             AND (pg_trigger.tgtype::integer & 69) = 69+            -- The trigger type `tgtype` is a bitmask where 69 corresponds to+            -- TRIGGER_TYPE_ROW + TRIGGER_TYPE_INSTEAD + TRIGGER_TYPE_INSERT+            -- in the PostgreSQL source code.+          )         )-      ) AS insertable+      ) AS insertable,+      (+        c.relkind = 'r'+        OR (+          c.relkind in ('v','f')+          AND (pg_relation_is_updatable(c.oid::regclass, FALSE) & 4) = 4+          -- CMD_UPDATE+          OR EXISTS (+            SELECT 1+            FROM pg_trigger+            WHERE+              pg_trigger.tgrelid = c.oid+              and (pg_trigger.tgtype::integer & 81) = 81+              -- TRIGGER_TYPE_ROW + TRIGGER_TYPE_INSTEAD + TRIGGER_TYPE_UPDATE+          )+        )+      ) AS updatable,+      (+        c.relkind = 'r'+        OR (+          c.relkind in ('v','f')+          AND (pg_relation_is_updatable(c.oid::regclass, FALSE) & 16) = 16+          -- CMD_DELETE+          OR EXISTS (+            SELECT 1+            FROM pg_trigger+            WHERE+              pg_trigger.tgrelid = c.oid+              and (pg_trigger.tgtype::integer & 73) = 73+              -- TRIGGER_TYPE_ROW + TRIGGER_TYPE_INSTEAD + TRIGGER_TYPE_DELETE+          )+        )+      ) AS deletable     FROM pg_class c     JOIN pg_namespace n ON n.oid = c.relnamespace+    LEFT JOIN pg_catalog.pg_description as d on d.objoid = c.oid and d.objsubid = 0     WHERE c.relkind IN ('v','r','m','f')       AND n.nspname NOT IN ('pg_catalog', 'information_schema')-    GROUP BY table_schema, table_name, insertable     ORDER BY table_schema, table_name |] -allColumns :: [Table] -> H.Statement [Schema] [Column]+allColumns :: [Table] -> Bool -> H.Statement [Schema] [Column] allColumns tabs =- H.Statement sql (arrayParam HE.text) (decodeColumns tabs) True+ H.Statement sql (arrayParam HE.text) (decodeColumns tabs)  where   sql = [q|     SELECT DISTINCT@@ -412,14 +536,12 @@         info.table_name AS table_name,         info.column_name AS name,         info.description AS description,-        info.ordinal_position AS position,         info.is_nullable::boolean AS nullable,         info.data_type AS col_type,-        info.is_updatable::boolean AS updatable,         info.character_maximum_length AS max_len,-        info.numeric_precision AS precision,         info.column_default AS default_value,-        array_to_string(enum_info.vals, ',') AS enum+        array_to_string(enum_info.vals, ',') AS enum,+        info.position     FROM (         -- CTE based on pg_catalog to get PRIMARY/FOREIGN key and UNIQUE columns outside api schema         WITH key_columns AS (@@ -454,7 +576,6 @@                 c.relname::name AS table_name,                 a.attname::name AS column_name,                 d.description AS description,-                a.attnum::integer AS ordinal_position,                 pg_get_expr(ad.adbin, ad.adrelid)::text AS column_default,                 not (a.attnotnull OR t.typtype = 'd' AND t.typnotnull) AS is_nullable,                     CASE@@ -475,15 +596,8 @@                     information_schema._pg_truetypid(a.*, t.*),                     information_schema._pg_truetypmod(a.*, t.*)                 )::integer AS character_maximum_length,-                information_schema._pg_numeric_precision(-                    information_schema._pg_truetypid(a.*, t.*),-                    information_schema._pg_truetypmod(a.*, t.*)-                )::integer AS numeric_precision,                 COALESCE(bt.typname, t.typname)::name AS udt_name,-                (-                    c.relkind in ('r', 'v', 'f')-                    AND pg_column_is_updatable(c.oid::regclass, a.attnum, false)-                )::bool is_updatable+                a.attnum::integer AS position             FROM pg_attribute a                 LEFT JOIN key_columns kc                     ON kc.conkey = a.attnum AND kc.c_oid = a.attrelid@@ -512,14 +626,12 @@             table_name,             column_name,             description,-            ordinal_position,             is_nullable,             data_type,-            is_updatable,             character_maximum_length,-            numeric_precision,             column_default,-            udt_name+            udt_name,+            position         FROM columns         WHERE table_schema NOT IN ('pg_catalog', 'information_schema')     ) AS info@@ -537,20 +649,19 @@  columnFromRow :: [Table] ->                  (Text,        Text,        Text,-                  Maybe Text,  Int32,       Bool,-                  Text,        Bool,        Maybe Int32,+                  Maybe Text,  Bool,        Text,                   Maybe Int32, Maybe Text,  Maybe Text)                  -> Maybe Column-columnFromRow tabs (s, t, n, desc, pos, nul, typ, u, l, p, d, e) = buildColumn <$> table+columnFromRow tabs (s, t, n, desc, nul, typ, l, d, e) = buildColumn <$> table   where-    buildColumn tbl = Column tbl n desc pos nul typ u l p d (parseEnum e) Nothing+    buildColumn tbl = Column tbl n desc nul typ l d (parseEnum e)     table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs     parseEnum :: Maybe Text -> [Text]     parseEnum = maybe [] (split (==',')) -allM2ORels :: [Table] -> [Column] -> H.Statement () [Relation]+allM2ORels :: [Table] -> [Column] -> Bool -> H.Statement () [Relationship] allM2ORels tabs cols =-  H.Statement sql HE.noParams (decodeRels tabs cols) True+  H.Statement sql HE.noParams (decodeRels tabs cols)  where   sql = [q|     SELECT ns1.nspname AS table_schema,@@ -575,9 +686,9 @@     WHERE confrelid != 0     ORDER BY (conrelid, column_info.nums) |] -relFromRow :: [Table] -> [Column] -> (Text, Text, Text, [Text], Text, Text, [Text]) -> Maybe Relation+relFromRow :: [Table] -> [Column] -> (Text, Text, Text, [Text], Text, Text, [Text]) -> Maybe Relationship relFromRow allTabs allCols (rs, rt, cn, rcs, frs, frt, frcs) =-  Relation <$> table <*> cols <*> pure (Just cn) <*> tableF <*> colsF <*> pure M2O <*> pure Nothing+  Relationship <$> table <*> cols <*> tableF <*> colsF <*> pure (M2O cn)   where     findTable s t = find (\tbl -> tableSchema tbl == s && tableName tbl == t) allTabs     findCol s t c = find (\col -> tableSchema (colTable col) == s && tableName (colTable col) == t && colName col == c) allCols@@ -586,9 +697,9 @@     cols  = mapM (findCol rs rt) rcs     colsF = mapM (findCol frs frt) frcs -allPrimaryKeys :: [Table] -> H.Statement () [PrimaryKey]+allPrimaryKeys :: [Table] -> Bool -> H.Statement () [PrimaryKey] allPrimaryKeys tabs =-  H.Statement sql HE.noParams (decodePks tabs) True+  H.Statement sql HE.noParams (decodePks tabs)  where   sql = [q|     -- CTE to replace information_schema.table_constraints to remove owner limit@@ -666,77 +777,181 @@ pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n   where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs -allSourceColumns :: [Column] -> PgVersion -> H.Statement [Schema] [SourceColumn]-allSourceColumns cols pgVer =-  H.Statement sql (arrayParam HE.text) (decodeSourceColumns cols) True-  -- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569+-- returns all the primary and foreign key columns which are referenced in views+pfkSourceColumns :: [Column] -> Bool -> H.Statement ([Schema], [Schema]) [SourceColumn]+pfkSourceColumns cols =+  H.Statement sql (contrazip2 (arrayParam HE.text) (arrayParam HE.text)) (decodeSourceColumns cols)+  -- query explanation at:+  --  * rationale: https://gist.github.com/wolfgangwalther/5425d64e7b0d20aad71f6f68474d9f19+  --  * json transformation: https://gist.github.com/wolfgangwalther/3a8939da680c24ad767e93ad2c183089   where-    subselectRegex :: Text-    -- "result" appears when the subselect is used inside "case when", see `authors_have_book_in_decade` fixture-    -- "resno"  appears in every other case-    -- when copying the query into pg make sure you omit one backslash from \\d+, it should be like `\d+` for the regex-    subselectRegex | pgVer < pgVersion100 = ":subselect {.*?:constraintDeps <>} :location \\d+} :res(no|ult)"-                   | otherwise = ":subselect {.*?:stmt_len 0} :location \\d+} :res(no|ult)"-    sql = [qc|-      with+    sql = [q|+      with recursive+      pks_fks as (+        -- pk + fk referencing col+        select+          conrelid as resorigtbl,+          unnest(conkey) as resorigcol+        from pg_constraint+        where contype IN ('p', 'f')+        union+        -- fk referenced col+        select+          confrelid,+          unnest(confkey)+        from pg_constraint+        where contype='f'+      ),       views as (         select+          c.oid       as view_id,           n.nspname   as view_schema,           c.relname   as view_name,           r.ev_action as view_definition         from pg_class c         join pg_namespace n on n.oid = c.relnamespace         join pg_rewrite r on r.ev_class = c.oid-        where c.relkind in ('v', 'm') and n.nspname = ANY ($1)+        where c.relkind in ('v', 'm') and n.nspname = ANY($1 || $2)       ),-      removed_subselects as(+      transform_json as (         select-          view_schema, view_name,-          regexp_replace(view_definition, '{subselectRegex}', '', 'g') as x+          view_id, view_schema, view_name,+          -- the following formatting is without indentation on purpose+          -- to allow simple diffs, with less whitespace noise+          replace(+            replace(+            replace(+            replace(+            replace(+            replace(+            replace(+            replace(+            regexp_replace(+            replace(+            replace(+            replace(+            replace(+            replace(+            replace(+            replace(+            replace(+            replace(+            replace(+              view_definition::text,+            -- This conversion to json is heavily optimized for performance.+            -- The general idea is to use as few regexp_replace() calls as possible.+            -- Simple replace() is a lot faster, so we jump through some hoops+            -- to be able to use regexp_replace() only once.+            -- This has been tested against a huge schema with 250+ different views.+            -- The unit tests do NOT reflect all possible inputs. Be careful when changing this!+            -- -----------------------------------------------+            -- pattern           | replacement         | flags+            -- -----------------------------------------------+            -- `,` is not part of the pg_node_tree format, but used in the regex.+            -- This removes all `,` that might be part of column names.+               ','               , ''+            -- The same applies for `{` and `}`, although those are used a lot in pg_node_tree.+            -- We remove the escaped ones, which might be part of column names again.+            ), '\{'              , ''+            ), '\}'              , ''+            -- The fields we need are formatted as json manually to protect them from the regex.+            ), ' :targetList '   , ',"targetList":'+            ), ' :resno '        , ',"resno":'+            ), ' :resorigtbl '   , ',"resorigtbl":'+            ), ' :resorigcol '   , ',"resorigcol":'+            -- Make the regex also match the node type, e.g. `{QUERY ...`, to remove it in one pass.+            ), '{'               , '{ :'+            -- Protect node lists, which start with `({` or `((` from the greedy regex.+            -- The extra `{` is removed again later.+            ), '(('              , '{(('+            ), '({'              , '{({'+            -- This regex removes all unused fields to avoid the need to format all of them correctly.+            -- This leads to a smaller json result as well.+            -- Removal stops at `,` for used fields (see above) and `}` for the end of the current node.+            -- Nesting can't be parsed correctly with a regex, so we stop at `{` as well and+            -- add an empty key for the followig node.+            ), ' :[^}{,]+'       , ',"":'              , 'g'+            -- For performance, the regex also added those empty keys when hitting a `,` or `}`.+            -- Those are removed next.+            ), ',"":}'           , '}'+            ), ',"":,'           , ','+            -- This reverses the "node list protection" from above.+            ), '{('              , '('+            -- Every key above has been added with a `,` so far. The first key in an object doesn't need it.+            ), '{,'              , '{'+            -- pg_node_tree has `()` around lists, but JSON uses `[]`+            ), '('               , '['+            ), ')'               , ']'+            -- pg_node_tree has ` ` between list items, but JSON uses `,`+            ), ' '             , ','+            -- `<>` in pg_node_tree is the same as `null` in JSON, but due to very poor performance of json_typeof+            -- we need to make this an empty array here to prevent json_array_elements from throwing an error+            -- when the targetList is null.+            ), '<>'              , '[]'+          )::json as view_definition         from views       ),-      target_lists as(-        select-          view_schema, view_name,-          regexp_split_to_array(x, 'targetList') as x-        from removed_subselects-      ),-      last_target_list_wo_tail as(-        select-          view_schema, view_name,-          (regexp_split_to_array(x[array_upper(x, 1)], ':onConflict'))[1] as x-        from target_lists-      ),       target_entries as(         select-          view_schema, view_name,-          unnest(regexp_split_to_array(x, 'TARGETENTRY')) as entry-        from last_target_list_wo_tail+          view_id, view_schema, view_name,+          json_array_elements(view_definition->0->'targetList') as entry+        from transform_json       ),       results as(         select-          view_schema, view_name,-          substring(entry from ':resname (.*?) :') as view_colum_name,-          substring(entry from ':resorigtbl (.*?) :') as resorigtbl,-          substring(entry from ':resorigcol (.*?) :') as resorigcol+          view_id, view_schema, view_name,+          (entry->>'resno')::int as view_column,+          (entry->>'resorigtbl')::oid as resorigtbl,+          (entry->>'resorigcol')::int as resorigcol         from target_entries+      ),+      recursion as(+        select r.*+        from results r+        where view_schema = ANY ($1)+        union all+        select+          view.view_id,+          view.view_schema,+          view.view_name,+          view.view_column,+          tab.resorigtbl,+          tab.resorigcol+        from recursion view+        join results tab on view.resorigtbl=tab.view_id and view.resorigcol=tab.view_column       )       select         sch.nspname as table_schema,         tbl.relname as table_name,         col.attname as table_column_name,-        res.view_schema,-        res.view_name,-        res.view_colum_name-      from results res-      join pg_class tbl on tbl.oid::text = res.resorigtbl-      join pg_attribute col on col.attrelid = tbl.oid and col.attnum::text = res.resorigcol+        rec.view_schema,+        rec.view_name,+        vcol.attname as view_column_name+      from recursion rec+      join pg_class tbl on tbl.oid = rec.resorigtbl+      join pg_attribute col on col.attrelid = tbl.oid and col.attnum = rec.resorigcol+      join pg_attribute vcol on vcol.attrelid = rec.view_id and vcol.attnum = rec.view_column       join pg_namespace sch on sch.oid = tbl.relnamespace-      where resorigtbl <> '0'-      order by view_schema, view_name, view_colum_name; |]+      join pks_fks using (resorigtbl, resorigcol)+      order by view_schema, view_name, view_column_name; |] -getPgVersion :: H.Session PgVersion-getPgVersion = H.statement () $ H.Statement sql HE.noParams versionRow False-  where-    sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')"-    versionRow = HD.singleRow $ PgVersion <$> column HD.int4 <*> column HD.text+param :: HE.Value a -> HE.Params a+param = HE.param . HE.nonNullable++arrayParam :: HE.Value a -> HE.Params [a]+arrayParam = param . HE.foldableArray . HE.nonNullable++compositeArrayColumn :: HD.Composite a -> HD.Row [a]+compositeArrayColumn = arrayColumn . HD.composite++compositeField :: HD.Value a -> HD.Composite a+compositeField = HD.field . HD.nonNullable++column :: HD.Value a -> HD.Row a+column = HD.column . HD.nonNullable++nullableColumn :: HD.Value a -> HD.Row (Maybe a)+nullableColumn = HD.column . HD.nullable++arrayColumn :: HD.Value a -> HD.Row [a]+arrayColumn = column . HD.listArray . HD.nonNullable
+ src/PostgREST/DbStructure/Identifiers.hs view
@@ -0,0 +1,42 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric  #-}++module PostgREST.DbStructure.Identifiers+  ( QualifiedIdentifier(..)+  , Schema+  , TableName+  , FieldName+  , toQi+  ) where++import qualified Data.Aeson as JSON+import qualified Data.Text  as T+import qualified GHC.Show++import Protolude+++-- | Represents a pg identifier with a prepended schema name "schema.table".+-- When qiSchema is "", the schema is defined by the pg search_path.+data QualifiedIdentifier = QualifiedIdentifier+  { qiSchema :: Schema+  , qiName   :: TableName+  }+  deriving (Eq, Ord, Generic, JSON.ToJSON, JSON.ToJSONKey)++instance Hashable QualifiedIdentifier++instance Show QualifiedIdentifier where+  show (QualifiedIdentifier s i) =+    (if T.null s then mempty else toS s <> ".") <> toS i++-- TODO: Handle a case where the QI comes like this: "my.fav.schema"."my.identifier"+-- Right now it only handles the schema.identifier case+toQi :: Text -> QualifiedIdentifier+toQi txt = case T.drop 1 <$> T.breakOn "." txt of+  (i, "") -> QualifiedIdentifier mempty i+  (s, i)  -> QualifiedIdentifier s i++type Schema = Text+type TableName = Text+type FieldName = Text
+ src/PostgREST/DbStructure/Proc.hs view
@@ -0,0 +1,97 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric  #-}++module PostgREST.DbStructure.Proc+  ( PgArg(..)+  , PgType(..)+  , ProcDescription(..)+  , ProcVolatility(..)+  , ProcsMap+  , RetType(..)+  , procReturnsScalar+  , procReturnsSingle+  , procTableName+  , specifiedProcArgs+  ) where++import qualified Data.Aeson          as JSON+import qualified Data.HashMap.Strict as M+import qualified Data.Set            as S++import PostgREST.DbStructure.Identifiers (FieldName,+                                          QualifiedIdentifier (..),+                                          Schema, TableName)++import Protolude+++data PgArg = PgArg+  { pgaName :: Text+  , pgaType :: Text+  , pgaReq  :: Bool+  , pgaVar  :: Bool+  }+  deriving (Eq, Ord, Generic, JSON.ToJSON)++data PgType+  = Scalar+  | Composite QualifiedIdentifier+  deriving (Eq, Ord, Generic, JSON.ToJSON)++data RetType+  = Single PgType+  | SetOf PgType+  deriving (Eq, Ord, Generic, JSON.ToJSON)++data ProcVolatility+  = Volatile+  | Stable+  | Immutable+  deriving (Eq, Ord, Generic, JSON.ToJSON)++data ProcDescription = ProcDescription+  { pdSchema      :: Schema+  , pdName        :: Text+  , pdDescription :: Maybe Text+  , pdArgs        :: [PgArg]+  , pdReturnType  :: RetType+  , pdVolatility  :: ProcVolatility+  , pdHasVariadic :: Bool+  }+  deriving (Eq, Generic, JSON.ToJSON)++-- Order by least number of args in the case of overloaded functions+instance Ord ProcDescription where+  ProcDescription schema1 name1 des1 args1 rt1 vol1 hasVar1 `compare` ProcDescription schema2 name2 des2 args2 rt2 vol2 hasVar2+    | schema1 == schema2 && name1 == name2 && length args1 < length args2  = LT+    | schema2 == schema2 && name1 == name2 && length args1 > length args2  = GT+    | otherwise = (schema1, name1, des1, args1, rt1, vol1, hasVar1) `compare` (schema2, name2, des2, args2, rt2, vol2, hasVar2)++-- | A map of all procs, all of which can be overloaded(one entry will have more than one ProcDescription).+-- | It uses a HashMap for a faster lookup.+type ProcsMap = M.HashMap QualifiedIdentifier [ProcDescription]++{-|+  Search the procedure parameters by matching them with the specified keys.+  If the key doesn't match a parameter, a parameter with a default type "text" is assumed.+-}+specifiedProcArgs :: S.Set FieldName -> ProcDescription -> [PgArg]+specifiedProcArgs keys proc =+  (\k -> fromMaybe (PgArg k "text" True False) (find ((==) k . pgaName) (pdArgs proc))) <$> S.toList keys++procReturnsScalar :: ProcDescription -> Bool+procReturnsScalar proc = case proc of+  ProcDescription{pdReturnType = (Single Scalar)} -> True+  ProcDescription{pdReturnType = (SetOf Scalar)}  -> True+  _                                               -> False++procReturnsSingle :: ProcDescription -> Bool+procReturnsSingle proc = case proc of+  ProcDescription{pdReturnType = (Single _)} -> True+  _                                          -> False++procTableName :: ProcDescription -> Maybe TableName+procTableName proc = case pdReturnType proc of+  SetOf  (Composite qi) -> Just $ qiName qi+  Single (Composite qi) -> Just $ qiName qi+  _                     -> Nothing
+ src/PostgREST/DbStructure/Relationship.hs view
@@ -0,0 +1,62 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric  #-}++module PostgREST.DbStructure.Relationship+  ( Cardinality(..)+  , PrimaryKey(..)+  , Relationship(..)+  , Junction(..)+  , isSelfReference+  ) where++import qualified Data.Aeson as JSON++import PostgREST.DbStructure.Table (Column (..), Table (..))++import Protolude+++-- | Relationship between two tables.+--+-- The order of the relColumns and relForeignColumns should be maintained to get the+-- join conditions right.+--+-- TODO merge relColumns and relForeignColumns to a tuple or Data.Bimap+data Relationship = Relationship+  { relTable          :: Table+  , relColumns        :: [Column]+  , relForeignTable   :: Table+  , relForeignColumns :: [Column]+  , relCardinality    :: Cardinality+  }+  deriving (Eq, Generic, JSON.ToJSON)++-- | The relationship cardinality+-- | https://en.wikipedia.org/wiki/Cardinality_(data_modeling)+-- TODO: missing one-to-one+data Cardinality+  = O2M FKConstraint -- ^ one-to-many cardinality+  | M2O FKConstraint -- ^ many-to-one cardinality+  | M2M Junction     -- ^ many-to-many cardinality+  deriving (Eq, Generic, JSON.ToJSON)++type FKConstraint = Text++-- | Junction table on an M2M relationship+data Junction = Junction+  { junTable       :: Table+  , junConstraint1 :: FKConstraint+  , junColumns1    :: [Column]+  , junConstraint2 :: FKConstraint+  , junColumns2    :: [Column]+  }+  deriving (Eq, Generic, JSON.ToJSON)++isSelfReference :: Relationship -> Bool+isSelfReference r = relTable r == relForeignTable r++data PrimaryKey = PrimaryKey+  { pkTable :: Table+  , pkName  :: Text+  }+  deriving (Generic, JSON.ToJSON)
+ src/PostgREST/DbStructure/Table.hs view
@@ -0,0 +1,55 @@+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric  #-}++module PostgREST.DbStructure.Table+  ( Column(..)+  , Table(..)+  , tableQi+  ) where++import qualified Data.Aeson as JSON++import PostgREST.DbStructure.Identifiers (FieldName,+                                          QualifiedIdentifier (..),+                                          Schema, TableName)++import Protolude+++data Table = Table+  { tableSchema      :: Schema+  , tableName        :: TableName+  , tableDescription :: Maybe Text+    -- The following fields identify what can be done on the table/view, they're not related to the privileges granted to it+  , tableInsertable  :: Bool+  , tableUpdatable   :: Bool+  , tableDeletable   :: Bool+  }+  deriving (Show, Ord, Generic, JSON.ToJSON)++instance Eq Table where+  Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2++tableQi :: Table -> QualifiedIdentifier+tableQi Table{tableSchema=s, tableName=n} = QualifiedIdentifier s n++data Column = Column+  { colTable       :: Table+  , colName        :: FieldName+  , colDescription :: Maybe Text+  , colNullable    :: Bool+  , colType        :: Text+  , colMaxLen      :: Maybe Int32+  , colDefault     :: Maybe Text+  , colEnum        :: [Text]+  }+  deriving (Ord, Generic, JSON.ToJSON)++instance Eq Column where+  Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2++data PrimaryKey = PrimaryKey+  { pkTable :: Table+  , pkName  :: Text+  }+  deriving (Generic, JSON.ToJSON)
src/PostgREST/Error.hs view
@@ -3,17 +3,17 @@ Description : PostgREST error HTTP responses -} {-# OPTIONS_GHC -fno-warn-orphans #-}-{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE RecordWildCards #-} -module PostgREST.Error (-  errorResponseFor-, ApiRequestError(..)-, PgError(..)-, SimpleError(..)-, errorPayload-, checkIsFatal-, singularityError-) where+module PostgREST.Error+  ( errorResponseFor+  , ApiRequestError(..)+  , PgError(..)+  , Error(..)+  , errorPayload+  , checkIsFatal+  , singularityError+  ) where  import qualified Data.Aeson                as JSON import qualified Data.Text                 as T@@ -24,13 +24,22 @@ import Data.Aeson  ((.=)) import Network.Wai (Response, responseLBS) -import Network.HTTP.Types.Header+import Network.HTTP.Types.Header (Header) -import PostgREST.Types-import Protolude       hiding (toS)-import Protolude.Conv  (toS)+import           PostgREST.ContentType (ContentType (..))+import qualified PostgREST.ContentType as ContentType +import PostgREST.DbStructure.Proc         (PgArg (..),+                                           ProcDescription (..))+import PostgREST.DbStructure.Relationship (Cardinality (..),+                                           Junction (..),+                                           Relationship (..))+import PostgREST.DbStructure.Table        (Column (..), Table (..)) +import Protolude      hiding (toS)+import Protolude.Conv (toS, toSL)++ class (JSON.ToJSON a) => PgrstError a where   status   :: a -> HT.Status   headers  :: a -> [Header]@@ -49,26 +58,29 @@   | InvalidBody ByteString   | ParseRequestError Text Text   | NoRelBetween Text Text-  | AmbiguousRelBetween Text Text [Relation]+  | AmbiguousRelBetween Text Text [Relationship]+  | AmbiguousRpc [ProcDescription]+  | NoRpc Text Text [Text] Bool   | InvalidFilters   | UnacceptableSchema [Text]-  | UnknownRelation                -- Unreachable?+  | ContentTypeError [ByteString]   | UnsupportedVerb                -- Unreachable?-  deriving (Show, Eq)  instance PgrstError ApiRequestError where   status InvalidRange            = HT.status416   status InvalidFilters          = HT.status405   status (InvalidBody _)         = HT.status400   status UnsupportedVerb         = HT.status405-  status UnknownRelation         = HT.status404   status ActionInappropriate     = HT.status405   status (ParseRequestError _ _) = HT.status400   status (NoRelBetween _ _)      = HT.status400   status AmbiguousRelBetween{}   = HT.status300+  status (AmbiguousRpc _)        = HT.status300+  status NoRpc{}                 = HT.status404   status (UnacceptableSchema _)  = HT.status406+  status (ContentTypeError _)    = HT.status415 -  headers _ = [toHeader CTApplicationJSON]+  headers _ = [ContentType.toHeader CTApplicationJSON]  instance JSON.ToJSON ApiRequestError where   toJSON (ParseRequestError message details) = JSON.object [@@ -79,41 +91,51 @@     "message" .= (toS errorMessage :: Text)]   toJSON InvalidRange = JSON.object [     "message" .= ("HTTP Range error" :: Text)]-  toJSON UnknownRelation = JSON.object [-    "message" .= ("Unknown relation" :: Text)]   toJSON (NoRelBetween parent child) = JSON.object [-    "message" .= ("Could not find foreign keys between these entities. No relationship found between " <> parent <> " and " <> child :: Text)]+    "hint"    .= ("If a new foreign key between these entities was created in the database, try reloading the schema cache." :: Text),+    "message" .= ("Could not find a relationship between " <> parent <> " and " <> child <> " in the schema cache" :: Text)]   toJSON (AmbiguousRelBetween parent child rels) = JSON.object [     "hint"    .= ("By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)" :: Text),     "message" .= ("More than one relationship was found for " <> parent <> " and " <> child :: Text),     "details" .= (compressedRel <$> rels) ]+  toJSON (AmbiguousRpc procs)  = JSON.object [+    "hint"    .= ("Overloaded functions with the same argument name but different types are not supported" :: Text),+    "message" .= ("Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [pgaName a <> " => " <> pgaType a | a <- pdArgs p] <> ")" | p <- procs])]+  toJSON (NoRpc schema procName payloadKeys hasPreferSingleObject)  = JSON.object [+    "hint"    .= ("If a new function was created in the database with this name and arguments, try reloading the schema cache." :: Text),+    "message" .= ("Could not find the " <> schema <> "." <> procName <> (if hasPreferSingleObject then " function with a single json or jsonb argument" else "(" <> T.intercalate ", " payloadKeys <> ")" <> " function") <> " in the schema cache")]   toJSON UnsupportedVerb = JSON.object [     "message" .= ("Unsupported HTTP verb" :: Text)]   toJSON InvalidFilters = JSON.object [     "message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]   toJSON (UnacceptableSchema schemas) = JSON.object [     "message" .= ("The schema must be one of the following: " <> T.intercalate ", " schemas)]+  toJSON (ContentTypeError cts)    = JSON.object [+    "message" .= ("None of these Content-Types are available: " <> (toS . intercalate ", " . map toS) cts :: Text)] -compressedRel :: Relation -> JSON.Value-compressedRel rel =+compressedRel :: Relationship -> JSON.Value+compressedRel Relationship{..} =   let-    fmtTbl tbl = tableSchema tbl <> "." <> tableName tbl+    fmtTbl Table{..} = tableSchema <> "." <> tableName     fmtEls els = "[" <> T.intercalate ", " els <> "]"   in   JSON.object $ [-    "origin"      .= fmtTbl (relTable rel)-  , "target"      .= fmtTbl (relFTable rel)-  , "cardinality" .= (show $ relType rel :: Text)+    "origin"      .= fmtTbl relTable+  , "target"      .= fmtTbl relForeignTable   ] ++-  case (relType rel, relJunction rel, relConstraint rel) of-    (M2M, Just (Junction jt (Just const1) _ (Just const2) _), _) -> [-      "relationship" .= (fmtTbl jt <> fmtEls [const1] <> fmtEls [const2])+  case relCardinality of+    M2M Junction{..} -> [+        "cardinality" .= ("m2m" :: Text)+      , "relationship" .= (fmtTbl junTable <> fmtEls [junConstraint1] <> fmtEls [junConstraint2])       ]-    (_, _, Just relCon) -> [-      "relationship" .= (relCon <> fmtEls (colName <$> relColumns rel) <> fmtEls (colName <$> relFColumns rel))+    M2O cons -> [+        "cardinality" .= ("m2o" :: Text)+      , "relationship" .= (cons <> fmtEls (colName <$> relColumns) <> fmtEls (colName <$> relForeignColumns))       ]-    (_, _, _) ->-      mempty+    O2M cons -> [+        "cardinality" .= ("o2m" :: Text)+      , "relationship" .= (cons <> fmtEls (colName <$> relColumns) <> fmtEls (colName <$> relForeignColumns))+      ]  data PgError = PgError Authenticated P.UsageError type Authenticated = Bool@@ -123,8 +145,8 @@    headers err =     if status err == HT.status401-       then [toHeader CTApplicationJSON, ("WWW-Authenticate", "Bearer") :: Header]-       else [toHeader CTApplicationJSON]+       then [ContentType.toHeader CTApplicationJSON, ("WWW-Authenticate", "Bearer") :: Header]+       else [ContentType.toHeader CTApplicationJSON]  instance JSON.ToJSON PgError where   toJSON (PgError _ usageError) = JSON.toJSON usageError@@ -132,8 +154,8 @@ instance JSON.ToJSON P.UsageError where   toJSON (P.ConnectionError e) = JSON.object [     "code"    .= ("" :: Text),-    "message" .= ("Database connection error" :: Text),-    "details" .= (toS $ fromMaybe "" e :: Text)]+    "message" .= ("Database connection error. Retrying the connection." :: Text),+    "details" .= (toSL $ fromMaybe "" e :: Text)]   toJSON (P.SessionError e) = JSON.toJSON e -- H.Error  instance JSON.ToJSON H.QueryError where@@ -169,7 +191,7 @@     "message" .= ("Unexpected amount of rows" :: Text),     "details" .= i]   toJSON (H.ClientError d) = JSON.object [-    "message" .= ("Database client error" :: Text),+    "message" .= ("Database client error. Retrying the connection." :: Text),     "details" .= (fmap toS d :: Maybe Text)]  pgErrorStatus :: Bool -> P.UsageError -> HT.Status@@ -185,6 +207,7 @@         '0':'P':_ -> HT.status403 -- invalid role specification         "23503"   -> HT.status409 -- foreign_key_violation         "23505"   -> HT.status409 -- unique_violation+        "25006"   -> HT.status405 -- read_only_sql_transaction         '2':'5':_ -> HT.status500 -- invalid tx state         '2':'8':_ -> HT.status403 -- invalid auth specification         '2':'D':_ -> HT.status500 -- invalid tx termination@@ -215,12 +238,29 @@   | isAuthFailureMessage = Just $ toS failureMessage   | otherwise = Nothing   where isAuthFailureMessage = "FATAL:  password authentication failed" `isPrefixOf` toS failureMessage-        failureMessage = fromMaybe "" e+        failureMessage = fromMaybe mempty e+checkIsFatal (PgError _ (P.SessionError (H.QueryError _ _ (H.ResultError serverError))))+  = case serverError of+      -- Check for a syntax error (42601 is the pg code). This would mean the error is on our part somehow, so we treat it as fatal.+      H.ServerError "42601" _ _ _+        -> Just "Hint: This is probably a bug in PostgREST, please report it at https://github.com/PostgREST/postgrest/issues"+      -- Check for a "prepared statement <name> already exists" error (Code 42P05: duplicate_prepared_statement).+      -- This would mean that a connection pooler in transaction mode is being used+      -- while prepared statements are enabled in the PostgREST configuration,+      -- both of which are incompatible with each other.+      H.ServerError "42P05" _ _ _+        -> Just "Hint: If you are using connection poolers in transaction mode, try setting db-prepared-statements to false."+      -- Check for a "transaction blocks not allowed in statement pooling mode" error (Code 08P01: protocol_violation).+      -- This would mean that a connection pooler in statement mode is being used which is not supported in PostgREST.+      H.ServerError "08P01" "transaction blocks not allowed in statement pooling mode" _ _+        -> Just "Hint: Connection poolers in statement mode are not supported."+      _ -> Nothing checkIsFatal _ = Nothing  -data SimpleError+data Error   = GucHeadersError+  | GucStatusError   | BinaryFieldError ContentType   | ConnectionLostError   | PutMatchingPkError@@ -228,11 +268,13 @@   | JwtTokenMissing   | JwtTokenInvalid Text   | SingularityError Integer-  | ContentTypeError [ByteString]-  deriving (Show, Eq)+  | NotFound+  | ApiRequestError ApiRequestError+  | PgErr PgError -instance PgrstError SimpleError where+instance PgrstError Error where   status GucHeadersError         = HT.status500+  status GucStatusError          = HT.status500   status (BinaryFieldError _)    = HT.status406   status ConnectionLostError     = HT.status503   status PutMatchingPkError      = HT.status400@@ -240,39 +282,46 @@   status JwtTokenMissing         = HT.status500   status (JwtTokenInvalid _)     = HT.unauthorized401   status (SingularityError _)    = HT.status406-  status (ContentTypeError _)    = HT.status415+  status NotFound                = HT.status404+  status (PgErr err)             = status err+  status (ApiRequestError err)   = status err -  headers (SingularityError _)     = [toHeader CTSingularJSON]-  headers (JwtTokenInvalid m)      = [toHeader CTApplicationJSON, invalidTokenHeader m]-  headers _                        = [toHeader CTApplicationJSON]+  headers (SingularityError _)     = [ContentType.toHeader CTSingularJSON]+  headers (JwtTokenInvalid m)      = [ContentType.toHeader CTApplicationJSON, invalidTokenHeader m]+  headers (PgErr err)              = headers err+  headers (ApiRequestError err)    = headers err+  headers _                        = [ContentType.toHeader CTApplicationJSON] -instance JSON.ToJSON SimpleError where+instance JSON.ToJSON Error where   toJSON GucHeadersError           = JSON.object [     "message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text)]+  toJSON GucStatusError           = JSON.object [+    "message" .= ("response.status guc must be a valid status code" :: Text)]   toJSON (BinaryFieldError ct)          = JSON.object [-    "message" .= ((toS (toMime ct) <> " requested but more than one column was selected") :: Text)]+    "message" .= ((toS (ContentType.toMime ct) <> " requested but more than one column was selected") :: Text)]   toJSON ConnectionLostError       = JSON.object [-    "message" .= ("Database connection lost, retrying the connection." :: Text)]+    "message" .= ("Database connection lost. Retrying the connection." :: Text)]    toJSON PutRangeNotAllowedError   = JSON.object [     "message" .= ("Range header and limit/offset querystring parameters are not allowed for PUT" :: Text)]   toJSON PutMatchingPkError        = JSON.object [     "message" .= ("Payload values do not match URL in primary key column(s)" :: Text)] -  toJSON (ContentTypeError cts)    = JSON.object [-    "message" .= ("None of these Content-Types are available: " <> (toS . intercalate ", " . map toS) cts :: Text)]   toJSON (SingularityError n)      = JSON.object [     "message" .= ("JSON object requested, multiple (or no) rows returned" :: Text),-    "details" .= T.unwords ["Results contain", show n, "rows,", toS (toMime CTSingularJSON), "requires 1 row"]]+    "details" .= T.unwords ["Results contain", show n, "rows,", toS (ContentType.toMime CTSingularJSON), "requires 1 row"]]    toJSON JwtTokenMissing           = JSON.object [     "message" .= ("Server lacks JWT secret" :: Text)]   toJSON (JwtTokenInvalid message) = JSON.object [     "message" .= (message :: Text)]+  toJSON NotFound = JSON.object []+  toJSON (PgErr err) = JSON.toJSON err+  toJSON (ApiRequestError err) = JSON.toJSON err  invalidTokenHeader :: Text -> Header invalidTokenHeader m =   ("WWW-Authenticate", "Bearer error=\"invalid_token\", " <> "error_description=" <> encodeUtf8 (show m)) -singularityError :: (Integral a) => a -> SimpleError+singularityError :: (Integral a) => a -> Error singularityError = SingularityError . toInteger
+ src/PostgREST/GucHeader.hs view
@@ -0,0 +1,37 @@+module PostgREST.GucHeader+  ( GucHeader+  , unwrapGucHeader+  , addHeadersIfNotIncluded+  ) where++import qualified Data.Aeson           as JSON+import qualified Data.CaseInsensitive as CI+import qualified Data.HashMap.Strict  as M++import Network.HTTP.Types.Header (Header)++import Protolude      hiding (toS)+import Protolude.Conv (toS)+++{-|+  Custom guc header, it's obtained by parsing the json in a:+  `SET LOCAL "response.headers" = '[{"Set-Cookie": ".."}]'+-}+newtype GucHeader = GucHeader (CI.CI ByteString, ByteString)++instance JSON.FromJSON GucHeader where+  parseJSON (JSON.Object o) = case headMay (M.toList o) of+    Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (CI.mk $ toS k, toS s)+                            | otherwise     -> mzero+    _ -> mzero+  parseJSON _          = mzero++unwrapGucHeader :: GucHeader -> Header+unwrapGucHeader (GucHeader (k, v)) = (k, v)++-- | Add headers not already included to allow the user to override them instead of duplicating them+addHeadersIfNotIncluded :: [Header] -> [Header] -> [Header]+addHeadersIfNotIncluded newHeaders initialHeaders =+  filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders +++  initialHeaders
src/PostgREST/Middleware.hs view
@@ -1,71 +1,183 @@ {-| Module      : PostgREST.Middleware-Description : Sets the PostgreSQL GUCs, role, search_path and pre-request function. Validates JWT.+Description : Sets CORS policy. Also the PostgreSQL GUCs, role, search_path and pre-request function. -}-{-# OPTIONS_GHC -fno-warn-orphans #-}-{-# LANGUAGE FlexibleContexts    #-}-{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE RecordWildCards #-}+module PostgREST.Middleware+  ( runPgLocals+  , pgrstFormat+  , pgrstMiddleware+  , defaultCorsPolicy+  , corsPolicy+  , optionalRollback+  ) where -module PostgREST.Middleware where+import qualified Data.Aeson                           as JSON+import qualified Data.ByteString.Char8                as BS+import qualified Data.CaseInsensitive                 as CI+import qualified Data.HashMap.Strict                  as M+import qualified Data.Text                            as T+import qualified Hasql.Decoders                       as HD+import qualified Hasql.DynamicStatements.Snippet      as H hiding+                                                           (sql)+import qualified Hasql.DynamicStatements.Statement    as H+import qualified Hasql.Transaction                    as H+import qualified Network.HTTP.Types.Header            as HTTP+import qualified Network.Wai                          as Wai+import qualified Network.Wai.Logger                   as Wai+import qualified Network.Wai.Middleware.Cors          as Wai+import qualified Network.Wai.Middleware.Gzip          as Wai+import qualified Network.Wai.Middleware.RequestLogger as Wai+import qualified Network.Wai.Middleware.Static        as Wai -import qualified Data.Aeson          as JSON-import qualified Data.HashMap.Strict as M-import           Data.Scientific     (FPFormat (..), formatScientific,-                                      isInteger)-import qualified Hasql.Transaction   as H+import Data.Function             (id)+import Data.List                 (lookup)+import Data.Scientific           (FPFormat (..), formatScientific,+                                  isInteger)+import Network.HTTP.Types.Status (Status, status400, status500,+                                  statusCode)+import System.IO.Unsafe          (unsafePerformIO)+import System.Log.FastLogger     (toLogStr) -import Network.Wai                   (Application, Response)-import Network.Wai.Middleware.Cors   (cors)-import Network.Wai.Middleware.Gzip   (def, gzip)-import Network.Wai.Middleware.Static (only, staticPolicy)+import PostgREST.Config             (AppConfig (..), LogLevel (..))+import PostgREST.Error              (Error, errorResponseFor)+import PostgREST.GucHeader          (addHeadersIfNotIncluded)+import PostgREST.Query.SqlFragment  (fromQi, intercalateSnippet,+                                     unknownEncoder)+import PostgREST.Request.ApiRequest (ApiRequest (..), Target (..)) -import Crypto.JWT+import PostgREST.Request.Preferences -import PostgREST.ApiRequest   (ApiRequest (..))-import PostgREST.Auth         (JWTAttempt (..))-import PostgREST.Config       (AppConfig (..), corsPolicy)-import PostgREST.Error        (SimpleError (JwtTokenInvalid, JwtTokenMissing),-                               errorResponseFor)-import PostgREST.QueryBuilder (setLocalQuery, setLocalSearchPathQuery)-import Protolude              hiding (head, toS)-import Protolude.Conv         (toS)+import Protolude      hiding (head, toS)+import Protolude.Conv (toS) -runWithClaims :: AppConfig -> JWTAttempt ->-                 (ApiRequest -> H.Transaction Response) ->-                 ApiRequest -> H.Transaction Response-runWithClaims conf eClaims app req =-  case eClaims of-    JWTMissingSecret      -> return . errorResponseFor $ JwtTokenMissing-    JWTInvalid JWTExpired -> return . errorResponseFor . JwtTokenInvalid $ "JWT expired"-    JWTInvalid e          -> return . errorResponseFor . JwtTokenInvalid . show $ e-    JWTClaims claims      -> do-      H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql-      mapM_ H.sql customReqCheck-      app req-      where-        methodSql = setLocalQuery mempty ("request.method", toS $ iMethod req)-        pathSql = setLocalQuery mempty ("request.path", toS $ iPath req)-        headersSql = setLocalQuery "request.header." <$> iHeaders req-        cookiesSql = setLocalQuery "request.cookie." <$> iCookies req-        claimsSql = setLocalQuery "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]-        appSettingsSql = setLocalQuery mempty <$> configSettings conf-        setRoleSql = maybeToList $ (\x ->-          setLocalQuery mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole-        setSearchPathSql = setLocalSearchPathQuery (iSchema req : configExtraSearchPath conf)-        -- role claim defaults to anon if not specified in jwt-        claimsWithRole = M.union claims (M.singleton "role" anon)-        anon = JSON.String . toS $ configAnonRole conf-        customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf+-- | Runs local(transaction scoped) GUCs for every request, plus the pre-request function+runPgLocals :: AppConfig   -> M.HashMap Text JSON.Value ->+               (ApiRequest -> ExceptT Error H.Transaction Wai.Response) ->+               ApiRequest  -> ByteString -> ExceptT Error H.Transaction Wai.Response+runPgLocals conf claims app req jsonDbS = do+  lift $ H.statement mempty $ H.dynamicallyParameterized+    ("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql ++ specSql))+    HD.noResult (configDbPreparedStatements conf)+  lift $ traverse_ H.sql preReqSql+  app req+  where+    methodSql = setConfigLocal mempty ("request.method", iMethod req)+    pathSql = setConfigLocal mempty ("request.path", iPath req)+    headersSql = setConfigLocal "request.header." <$> iHeaders req+    cookiesSql = setConfigLocal "request.cookie." <$> iCookies req+    claimsWithRole =+      let anon = JSON.String . toS $ configDbAnonRole conf in -- role claim defaults to anon if not specified in jwt+      M.union claims (M.singleton "role" anon)+    claimsSql = setConfigLocal "request.jwt.claim." <$> [(toS c, toS $ unquoted v) | (c,v) <- M.toList claimsWithRole]+    roleSql = maybeToList $ (\x -> setConfigLocal mempty ("role", toS $ unquoted x)) <$> M.lookup "role" claimsWithRole+    appSettingsSql = setConfigLocal mempty <$> (join bimap toS <$> configAppSettings conf)+    searchPathSql =+      let schemas = T.intercalate ", " (iSchema req : configDbExtraSearchPath conf) in+      setConfigLocal mempty ("search_path", toS schemas)+    preReqSql = (\f -> "select " <> fromQi f <> "();") <$> configDbPreRequest conf+    specSql = case iTarget req of+      TargetProc{tpIsRootSpec=True} -> [setConfigLocal mempty ("request.spec", jsonDbS)]+      _                             -> mempty+    -- | Do a pg set_config(setting, value, true) call. This is equivalent to a SET LOCAL.+    setConfigLocal :: ByteString -> (ByteString, ByteString) -> H.Snippet+    setConfigLocal prefix (k, v) =+      "set_config(" <> unknownEncoder (prefix <> k) <> ", " <> unknownEncoder v <> ", true)" -defaultMiddle :: Application -> Application-defaultMiddle =-    gzip def-  . cors corsPolicy-  . staticPolicy (only [("favicon.ico", "static/favicon.ico")])+-- | Log in apache format. Only requests that have a status greater than minStatus are logged.+-- | There's no way to filter logs in the apache format on wai-extra: https://hackage.haskell.org/package/wai-extra-3.0.29.2/docs/Network-Wai-Middleware-RequestLogger.html#t:OutputFormat.+-- | So here we copy wai-logger apacheLogStr function: https://github.com/kazu-yamamoto/logger/blob/a4f51b909a099c51af7a3f75cf16e19a06f9e257/wai-logger/Network/Wai/Logger/Apache.hs#L45+-- | TODO: Add the ability to filter apache logs on wai-extra and remove this function.+pgrstFormat :: Status -> Wai.OutputFormatter+pgrstFormat minStatus date req status responseSize =+  if status < minStatus+    then mempty+  else toLogStr (getSourceFromSocket req)+    <> " - - ["+    <> toLogStr date+    <> "] \""+    <> toLogStr (Wai.requestMethod req)+    <> " "+    <> toLogStr (Wai.rawPathInfo req <> Wai.rawQueryString req)+    <> " "+    <> toLogStr (show (Wai.httpVersion req)::Text)+    <> "\" "+    <> toLogStr (show (statusCode status)::Text)+    <> " "+    <> toLogStr (maybe "-" show responseSize::Text)+    <> " \""+    <> toLogStr (fromMaybe mempty $ Wai.requestHeaderReferer req)+    <> "\" \""+    <> toLogStr (fromMaybe mempty $ Wai.requestHeaderUserAgent req)+    <> "\"\n"+  where+    getSourceFromSocket = BS.pack . Wai.showSockAddr . Wai.remoteHost +pgrstMiddleware :: LogLevel -> Wai.Application -> Wai.Application+pgrstMiddleware logLevel =+    logger+  . Wai.cors corsPolicy+  . Wai.staticPolicy (Wai.only [("favicon.ico", "static/favicon.ico")])+  where+    logger = case logLevel of+      LogCrit  -> id+      LogError -> unsafePerformIO $ Wai.mkRequestLogger Wai.def { Wai.outputFormat = Wai.CustomOutputFormat $ pgrstFormat status500}+      LogWarn  -> unsafePerformIO $ Wai.mkRequestLogger Wai.def { Wai.outputFormat = Wai.CustomOutputFormat $ pgrstFormat status400}+      LogInfo  -> Wai.logStdout++defaultCorsPolicy :: Wai.CorsResourcePolicy+defaultCorsPolicy =  Wai.CorsResourcePolicy Nothing+  ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] ["Authorization"] Nothing+  (Just $ 60*60*24) False False True++-- | CORS policy to be used in by Wai Cors middleware+corsPolicy :: Wai.Request -> Maybe Wai.CorsResourcePolicy+corsPolicy req = case lookup "origin" headers of+  Just origin -> Just defaultCorsPolicy {+      Wai.corsOrigins = Just ([origin], True)+    , Wai.corsRequestHeaders = "Authentication" : accHeaders+    , Wai.corsExposedHeaders = Just [+        "Content-Encoding", "Content-Location", "Content-Range", "Content-Type"+      , "Date", "Location", "Server", "Transfer-Encoding", "Range-Unit"+      ]+    }+  Nothing -> Nothing+  where+    headers = Wai.requestHeaders req+    accHeaders = case lookup "access-control-request-headers" headers of+      Just hdrs -> map (CI.mk . toS . T.strip . toS) $ BS.split ',' hdrs+      Nothing -> []+ unquoted :: JSON.Value -> Text unquoted (JSON.String t) = t unquoted (JSON.Number n) =   toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n unquoted (JSON.Bool b) = show b unquoted v = toS $ JSON.encode v++-- | Set a transaction to eventually roll back if requested and set respective+-- headers on the response.+optionalRollback+  :: AppConfig+  -> ApiRequest+  -> ExceptT Error H.Transaction Wai.Response+  -> ExceptT Error H.Transaction Wai.Response+optionalRollback AppConfig{..} ApiRequest{..} transaction = do+  resp <- catchError transaction $ return . errorResponseFor+  when (shouldRollback || (configDbTxRollbackAll && not shouldCommit))+    (lift H.condemn)+  return $ Wai.mapResponseHeaders preferenceApplied resp+  where+    shouldCommit =+      configDbTxAllowOverride && iPreferTransaction == Just Commit+    shouldRollback =+      configDbTxAllowOverride && iPreferTransaction == Just Rollback+    preferenceApplied+      | shouldCommit =+          addHeadersIfNotIncluded+            [(HTTP.hPreferenceApplied, BS.pack (show Commit))]+      | shouldRollback =+          addHeadersIfNotIncluded+            [(HTTP.hPreferenceApplied, BS.pack (show Rollback))]+      | otherwise =+          identity
src/PostgREST/OpenAPI.hs view
@@ -2,41 +2,57 @@ Module      : PostgREST.OpenAPI Description : Generates the OpenAPI output -}-{-# LANGUAGE OverloadedStrings #-}--module PostgREST.OpenAPI (-  encodeOpenAPI-, isMalformedProxyUri-, pickProxy-) where+{-# LANGUAGE LambdaCase      #-}+{-# LANGUAGE NamedFieldPuns  #-}+{-# LANGUAGE RecordWildCards #-}+module PostgREST.OpenAPI (encode) where -import qualified Data.HashSet.InsOrd as Set+import qualified Data.Aeson           as JSON+import qualified Data.ByteString.Lazy as LBS+import qualified Data.HashMap.Strict  as HashMap+import qualified Data.HashSet.InsOrd  as Set+import qualified Data.Text            as T  import Control.Arrow              ((&&&))-import Data.Aeson                 (decode, encode) import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList) import Data.Maybe                 (fromJust) import Data.String                (IsString (..))-import Data.Text                  (append, breakOn, dropWhile, init,-                                   intercalate, pack, tail, toLower,-                                   unpack)-import Network.URI                (URI (..), URIAuth (..),-                                   isAbsoluteURI, parseURI)+import Network.URI                (URI (..), URIAuth (..)) -import Control.Lens+import Control.Lens (at, (.~), (?~))+ import Data.Swagger -import PostgREST.ApiRequest (ContentType (..))-import PostgREST.Config     (docsVersion, prettyVersion)-import PostgREST.Types      (Column (..), ForeignKey (..), PgArg (..),-                             PrimaryKey (..), ProcDescription (..),-                             Proxy (..), Table (..), toMime)-import Protolude            hiding (Proxy, dropWhile, get,-                             intercalate, toLower, toS, (&))-import Protolude.Conv       (toS)+import PostgREST.Config                   (AppConfig (..), Proxy (..),+                                           isMalformedProxyUri, toURI)+import PostgREST.DbStructure              (DbStructure (..),+                                           tableCols, tablePKCols)+import PostgREST.DbStructure.Proc         (PgArg (..),+                                           ProcDescription (..))+import PostgREST.DbStructure.Relationship (Cardinality (..),+                                           PrimaryKey (..),+                                           Relationship (..))+import PostgREST.DbStructure.Table        (Column (..), Table (..))+import PostgREST.Version                  (docsVersion, prettyVersion) +import PostgREST.ContentType++import Protolude      hiding (Proxy, get, toS)+import Protolude.Conv (toS)++encode :: AppConfig -> DbStructure -> [Table] -> HashMap.HashMap k [ProcDescription] -> Maybe Text -> LBS.ByteString+encode conf dbStructure tables procs schemaDescription =+  JSON.encode $+    postgrestSpec+      (dbRelationships dbStructure)+      (concat $ HashMap.elems procs)+      (openApiTableInfo dbStructure <$> tables)+      (proxyUri conf)+      schemaDescription+      (dbPrimaryKeys dbStructure)+ makeMimeList :: [ContentType] -> MimeList-makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs+makeMimeList cs = MimeList $ fmap (fromString . toS . toMime) cs  toSwaggerType :: Text -> SwaggerType t toSwaggerType "character varying" = SwaggerString@@ -51,36 +67,47 @@ toSwaggerType "double precision"  = SwaggerNumber toSwaggerType _                   = SwaggerString -makeTableDef :: [PrimaryKey] -> (Table, [Column], [Text]) -> (Text, Schema)-makeTableDef pks (t, cs, _) =+makeTableDef :: [Relationship] -> [PrimaryKey] -> (Table, [Column], [Text]) -> (Text, Schema)+makeTableDef rels pks (t, cs, _) =   let tn = tableName t in       (tn, (mempty :: Schema)         & description .~ tableDescription t         & type_ ?~ SwaggerObject-        & properties .~ fromList (map (makeProperty pks) cs)-        & required .~ map colName (filter (not . colNullable) cs))+        & properties .~ fromList (fmap (makeProperty rels pks) cs)+        & required .~ fmap colName (filter (not . colNullable) cs)) -makeProperty :: [PrimaryKey] -> Column -> (Text, Referenced Schema)-makeProperty pks c = (colName c, Inline s)+makeProperty :: [Relationship] -> [PrimaryKey] -> Column -> (Text, Referenced Schema)+makeProperty rels pks c = (colName c, Inline s)   where-    e = if null $ colEnum c then Nothing else decode $ encode $ colEnum c-    fk ForeignKey{fkCol=Column{colTable=Table{tableName=a}, colName=b}} =-      intercalate "" ["This is a Foreign Key to `", a, ".", b, "`.<fk table='", a, "' column='", b, "'/>"]+    e = if null $ colEnum c then Nothing else JSON.decode $ JSON.encode $ colEnum c+    fk :: Maybe Text+    fk =+      let+        -- Finds the relationship that has a single column foreign key+        rel = find (\case+          Relationship{relColumns, relCardinality=M2O _} -> [c] == relColumns+          _                                                 -> False+          ) rels+        fCol = colName <$> (headMay . relForeignColumns =<< rel)+        fTbl = tableName . relForeignTable <$> rel+        fTblCol = (,) <$> fTbl <*> fCol+      in+        (\(a, b) -> T.intercalate "" ["This is a Foreign Key to `", a, ".", b, "`.<fk table='", a, "' column='", b, "'/>"]) <$> fTblCol     pk :: Bool     pk = any (\p -> pkTable p == colTable c && pkName p == colName c) pks     n = catMaybes       [ Just "Note:"       , if pk then Just "This is a Primary Key.<pk/>" else Nothing-      , fk <$> colFK c+      , fk       ]     d =       if length n > 1 then-        Just $ append (maybe "" (`append` "\n\n") $ colDescription c) (intercalate "\n" n)+        Just $ T.append (maybe "" (`T.append` "\n\n") $ colDescription c) (T.intercalate "\n" n)       else         colDescription c     s =       (mempty :: Schema)-        & default_ .~ (decode . toS =<< colDefault c)+        & default_ .~ (JSON.decode . toS =<< colDefault c)         & description .~ d         & enum_ .~ e         & format ?~ colType c@@ -92,11 +119,11 @@   (mempty :: Schema)   & description .~ pdDescription pd   & type_ ?~ SwaggerObject-  & properties .~ fromList (map makeProcProperty (pdArgs pd))-  & required .~ map pgaName (filter pgaReq (pdArgs pd))+  & properties .~ fromList (fmap makeProcProperty (pdArgs pd))+  & required .~ fmap pgaName (filter pgaReq (pdArgs pd))  makeProcProperty :: PgArg -> (Text, Referenced Schema)-makeProcProperty (PgArg n t _) = (n, Inline s)+makeProcProperty (PgArg n t _ _) = (n, Inline s)   where     s = (mempty :: Schema)           & type_ ?~ toSwaggerType t@@ -111,7 +138,7 @@   & schema .~ ParamOther ((mempty :: ParamOtherSchema)     & in_ .~ ParamHeader     & type_ ?~ SwaggerString-    & enum_ .~ decode (encode ts))+    & enum_ .~ JSON.decode (JSON.encode ts))  makeProcParam :: ProcDescription -> [Referenced Param] makeProcParam pd =@@ -162,7 +189,7 @@       & schema .~ ParamOther ((mempty :: ParamOtherSchema)         & in_ .~ ParamHeader         & type_ ?~ SwaggerString-        & default_ .~ decode "\"items\""))+        & default_ .~ JSON.decode "\"items\""))   , ("offset", (mempty :: Param)       & name        .~ "offset"       & description ?~ "Limiting and Pagination"@@ -192,7 +219,7 @@  makeRowFilter :: Text -> Column -> (Text, Param) makeRowFilter tn c =-  (intercalate "." ["rowFilter", tn, colName c], (mempty :: Param)+  (T.intercalate "." ["rowFilter", tn, colName c], (mempty :: Param)     & name .~ colName c     & description .~ colDescription c     & required ?~ False@@ -202,21 +229,21 @@       & format ?~ colType c))  makeRowFilters :: Text -> [Column] -> [(Text, Param)]-makeRowFilters tn = map (makeRowFilter tn)+makeRowFilters tn = fmap (makeRowFilter tn)  makePathItem :: (Table, [Column], [Text]) -> (FilePath, PathItem)-makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)+makePathItem (t, cs, _) = ("/" ++ T.unpack tn, p $ tableInsertable t || tableUpdatable t || tableDeletable t)   where     -- Use first line of table description as summary; rest as description (if present)     -- We strip leading newlines from description so that users can include a blank line between summary and description-    (tSum, tDesc) = fmap fst &&& fmap (dropWhile (=='\n') . snd) $-                    breakOn "\n" <$> tableDescription t+    (tSum, tDesc) = fmap fst &&& fmap (T.dropWhile (=='\n') . snd) $+                    T.breakOn "\n" <$> tableDescription t     tOp = (mempty :: Operation)       & tags .~ Set.fromList [tn]       & summary .~ tSum       & description .~ mfilter (/="") tDesc     getOp = tOp-      & parameters .~ map ref (rs <> ["select", "order", "range", "rangeUnit", "offset", "limit", "preferCount"])+      & parameters .~ fmap ref (rs <> ["select", "order", "range", "rangeUnit", "offset", "limit", "preferCount"])       & at 206 ?~ "Partial Content"       & at 200 ?~ Inline ((mempty :: Response)         & description .~ "OK"@@ -226,20 +253,20 @@         )       )     postOp = tOp-      & parameters .~ map ref ["body." <> tn, "select", "preferReturn"]+      & parameters .~ fmap ref ["body." <> tn, "select", "preferReturn"]       & at 201 ?~ "Created"     patchOp = tOp-      & parameters .~ map ref (rs <> ["body." <> tn, "preferReturn"])+      & parameters .~ fmap ref (rs <> ["body." <> tn, "preferReturn"])       & at 204 ?~ "No Content"     deletOp = tOp-      & parameters .~ map ref (rs <> ["preferReturn"])+      & parameters .~ fmap ref (rs <> ["preferReturn"])       & at 204 ?~ "No Content"     pr = (mempty :: PathItem) & get ?~ getOp     pw = pr & post ?~ postOp & patch ?~ patchOp & delete ?~ deletOp     p False = pr     p True  = pw     tn = tableName t-    rs = [ intercalate "." ["rowFilter", tn, colName c ] | c <- cs ]+    rs = [ T.intercalate "." ["rowFilter", tn, colName c ] | c <- cs ]     ref = Ref . Reference  makeProcPathItem :: ProcDescription -> (FilePath, PathItem)@@ -247,8 +274,8 @@   where     -- Use first line of proc description as summary; rest as description (if present)     -- We strip leading newlines from description so that users can include a blank line between summary and description-    (pSum, pDesc) = fmap fst &&& fmap (dropWhile (=='\n') . snd) $-                    breakOn "\n" <$> pdDescription pd+    (pSum, pDesc) = fmap fst &&& fmap (T.dropWhile (=='\n') . snd) $+                    T.breakOn "\n" <$> pdDescription pd     postOp = (mempty :: Operation)       & summary .~ pSum       & description .~ mfilter (/="") pDesc@@ -271,7 +298,7 @@  makePathItems :: [ProcDescription] -> [(Table, [Column], [Text])] -> InsOrdHashMap FilePath PathItem makePathItems pds ti = fromList $ makeRootPathItem :-  map makePathItem ti ++ map makeProcPathItem pds+  fmap makePathItem ti ++ fmap makeProcPathItem pds  escapeHostName :: Text -> Text escapeHostName "*"  = "0.0.0.0"@@ -281,9 +308,9 @@ escapeHostName "!6" = "0.0.0.0" escapeHostName h    = h -postgrestSpec :: [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> Maybe Text -> [PrimaryKey] -> Swagger-postgrestSpec pds ti (s, h, p, b) sd pks = (mempty :: Swagger)-  & basePath ?~ unpack b+postgrestSpec :: [Relationship] -> [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> Maybe Text -> [PrimaryKey] -> Swagger+postgrestSpec rels pds ti (s, h, p, b) sd pks = (mempty :: Swagger)+  & basePath ?~ T.unpack b   & schemes ?~ [s']   & info .~ ((mempty :: Info)       & version .~ prettyVersion@@ -293,44 +320,23 @@     & description ?~ "PostgREST Documentation"     & url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))   & host .~ h'-  & definitions .~ fromList (map (makeTableDef pks) ti)+  & definitions .~ fromList (makeTableDef rels pks <$> ti)   & parameters .~ fromList (makeParamDefs ti)   & paths .~ makePathItems pds ti   & produces .~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV]   & consumes .~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV]     where       s' = if s == "http" then Http else Https-      h' = Just $ Host (unpack $ escapeHostName h) (Just (fromInteger p))+      h' = Just $ Host (T.unpack $ escapeHostName h) (Just (fromInteger p))       d = fromMaybe "This is a dynamic API generated by PostgREST" sd -encodeOpenAPI :: [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> Maybe Text -> [PrimaryKey] ->  LByteString-encodeOpenAPI pds ti uri sd pks = encode $ postgrestSpec pds ti uri sd pks--{-|-  Test whether a proxy uri is malformed or not.-  A valid proxy uri should be an absolute uri without query and user info,-  only http(s) schemes are valid, port number range is 1-65535.--  For example-  http://postgrest.com/openapi.json-  https://postgrest.com:8080/openapi.json--}-isMalformedProxyUri :: Maybe Text -> Bool-isMalformedProxyUri Nothing =  False-isMalformedProxyUri (Just uri)-  | isAbsoluteURI (toS uri) = not $ isUriValid $ toURI uri-  | otherwise = True--toURI :: Text -> URI-toURI uri = fromJust $ parseURI (toS uri)- pickProxy :: Maybe Text -> Maybe Proxy pickProxy proxy   | isNothing proxy = Nothing   -- should never happen   -- since the request would have been rejected by the middleware if proxy uri   -- is malformed-  | isMalformedProxyUri proxy = Nothing+  | isMalformedProxyUri $ fromMaybe mempty proxy = Nothing   | otherwise = Just Proxy {     proxyScheme = scheme   , proxyHost = host'@@ -339,53 +345,31 @@   }  where    uri = toURI $ fromJust proxy-   scheme = init $ toLower $ pack $ uriScheme uri+   scheme = T.init $ T.toLower $ T.pack $ uriScheme uri    path URI {uriPath = ""} =  "/"    path URI {uriPath = p}  = p-   path' = pack $ path uri+   path' = T.pack $ path uri    authority = fromJust $ uriAuthority uri-   host' = pack $ uriRegName authority+   host' = T.pack $ uriRegName authority    port' = uriPort authority    readPort = fromMaybe 80 . readMaybe    port'' :: Integer    port'' = case (port', scheme) of              ("", "http")  -> 80              ("", "https") -> 443-             _             -> readPort $ unpack $ tail $ pack port'--isUriValid:: URI -> Bool-isUriValid = fAnd [isSchemeValid, isQueryValid, isAuthorityValid]--fAnd :: [a -> Bool] -> a -> Bool-fAnd fs x = all ($ x) fs--isSchemeValid :: URI -> Bool-isSchemeValid URI {uriScheme = s}-  | toLower (pack s) == "https:" = True-  | toLower (pack s) == "http:" = True-  | otherwise = False--isQueryValid :: URI -> Bool-isQueryValid URI {uriQuery = ""} = True-isQueryValid _                   = False--isAuthorityValid :: URI -> Bool-isAuthorityValid URI {uriAuthority = a}-  | isJust a = fAnd [isUserInfoValid, isHostValid, isPortValid] $ fromJust a-  | otherwise = False--isUserInfoValid :: URIAuth -> Bool-isUserInfoValid URIAuth {uriUserInfo = ""} = True-isUserInfoValid _                          = False+             _             -> readPort $ T.unpack $ T.tail $ T.pack port' -isHostValid :: URIAuth -> Bool-isHostValid URIAuth {uriRegName = ""} = False-isHostValid _                         = True+proxyUri :: AppConfig -> (Text, Text, Integer, Text)+proxyUri AppConfig{..} =+  case pickProxy $ toS <$> configOpenApiServerProxyUri of+    Just Proxy{..} ->+      (proxyScheme, proxyHost, proxyPort, proxyPath)+    Nothing ->+      ("http", configServerHost, toInteger configServerPort, "/") -isPortValid :: URIAuth -> Bool-isPortValid URIAuth {uriPort = ""} = True-isPortValid URIAuth {uriPort = (':':p)} =-  case readMaybe p of-    Just i  -> i > (0 :: Integer) && i < 65536-    Nothing -> False-isPortValid _ = False+openApiTableInfo :: DbStructure -> Table -> (Table, [Column], [Text])+openApiTableInfo dbStructure table =+  ( table+  , tableCols dbStructure (tableSchema table) (tableName table)+  , tablePKCols dbStructure (tableSchema table) (tableName table)+  )
− src/PostgREST/Parsers.hs
@@ -1,272 +0,0 @@-{-|-Module      : PostgREST.Parsers-Description : PostgREST parser combinators--This module is in charge of parsing all the querystring values in an url, e.g. the select, id, order in `/projects?select=id,name&id=eq.1&order=id,name.desc`.--}-module PostgREST.Parsers where--import qualified Data.HashMap.Strict as M-import qualified Data.Set            as S--import Data.Either.Combinators (mapLeft)-import Data.Foldable           (foldl1)-import Data.List               (init, last)-import Data.Text               (intercalate, replace, strip)-import Text.Read               (read)--import Data.Tree-import Text.Parsec.Error-import Text.ParserCombinators.Parsec hiding (many, (<|>))--import PostgREST.Error      (ApiRequestError (ParseRequestError))-import PostgREST.RangeQuery (NonnegRange)-import PostgREST.Types-import Protolude            hiding (intercalate, option, replace, toS,-                             try)-import Protolude.Conv       (toS)--pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]-pRequestSelect selStr =-  mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)--pRequestOnConflict :: Text -> Either ApiRequestError  [FieldName]-pRequestOnConflict oncStr =-  mapError $ parse pColumns ("failed to parse on_conflict parameter (" <> toS oncStr <> ")") (toS oncStr)--pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)-pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)-  where-    treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k-    oper = parse (pOpExpr pSingleVal) ("failed to parse filter (" ++ toS v ++ ")") $ toS v-    path = fst <$> treePath-    fld = snd <$> treePath--pRequestOrder :: (Text, Text) -> Either ApiRequestError (EmbedPath, [OrderTerm])-pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord'-  where-    treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k-    path = fst <$> treePath-    ord' = parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v--pRequestRange :: (ByteString, NonnegRange) -> Either ApiRequestError (EmbedPath, NonnegRange)-pRequestRange (k, v) = mapError $ (,) <$> path <*> pure v-  where-    treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k-    path = fst <$> treePath--pRequestLogicTree :: (Text, Text) -> Either ApiRequestError (EmbedPath, LogicTree)-pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree-  where-    path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k-    embedPath = fst <$> path-    logicTree = do-      op <- snd <$> path-      -- Concat op and v to make pLogicTree argument regular,-      -- in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)"-      parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") $ toS (op <> v)--pRequestColumns :: Maybe Text -> Either ApiRequestError (Maybe (S.Set FieldName))-pRequestColumns colStr =-  case colStr of-    Just str ->-      mapError $ Just . S.fromList <$> parse pColumns ("failed to parse columns parameter (" <> toS str <> ")") (toS str)-    _ -> Right Nothing--ws :: Parser Text-ws = toS <$> many (oneOf " \t")--lexeme :: Parser a -> Parser a-lexeme p = ws *> p <* ws--pTreePath :: Parser (EmbedPath, Field)-pTreePath = do-  p <- pFieldName `sepBy1` pDelimiter-  jp <- option [] pJsonPath-  return (init p, (last p, jp))--pFieldForest :: Parser [Tree SelectItem]-pFieldForest = pFieldTree `sepBy1` lexeme (char ',')-  where-    pFieldTree :: Parser (Tree SelectItem)-    pFieldTree =  try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) <|>-                  Node <$> pFieldSelect <*> pure []--pStar :: Parser Text-pStar = toS <$> (string "*" $> ("*"::ByteString))--pFieldName :: Parser Text-pFieldName =-  pQuotedValue <|>-  intercalate "-" . map toS <$> (many1 (letter <|> digit <|> oneOf "_ ") `sepBy1` dash) <?>-  "field name (* or [a..z0..9_])"-  where-    isDash :: GenParser Char st ()-    isDash = try ( char '-' >> notFollowedBy (char '>') )-    dash :: Parser Char-    dash = isDash $> '-'--pJsonPath :: Parser JsonPath-pJsonPath = many pJsonOperation-  where-    pJsonOperation :: Parser JsonOperation-    pJsonOperation = pJsonArrow <*> pJsonOperand--    pJsonArrow   =-      try (string "->>" $> J2Arrow) <|>-      try (string "->" $> JArrow)--    pJsonOperand =-      let pJKey = JKey . toS <$> pFieldName-          pJIdx = JIdx . toS <$> ((:) <$> option '+' (char '-') <*> many1 digit) <* pEnd-          pEnd = try (void $ lookAhead (string "->")) <|>-                 try (void $ lookAhead (string "::")) <|>-                 try eof in-      try pJIdx <|> try pJKey--pField :: Parser Field-pField = lexeme $ (,) <$> pFieldName <*> option [] pJsonPath--aliasSeparator :: Parser ()-aliasSeparator = char ':' >> notFollowedBy (char ':')--pRelationSelect :: Parser SelectItem-pRelationSelect = lexeme $ try ( do-    alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )-    fld <- pField-    hint <- optionMaybe (-        try ( char '!' *> pFieldName) <|>-        -- deprecated, remove in next major version-        try ( char '.' *> pFieldName)-      )-    return (fld, Nothing, alias, hint)-  )--pFieldSelect :: Parser SelectItem-pFieldSelect = lexeme $-  try (-    do-      alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )-      fld <- pField-      cast' <- optionMaybe (string "::" *> many letter)-      return (fld, toS <$> cast', alias, Nothing)-  )-  <|> do-    s <- pStar-    return ((s, []), Nothing, Nothing, Nothing)--pOpExpr :: Parser SingleVal -> Parser OpExpr-pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation-  where-    pOperation :: Parser Operation-    pOperation =-          Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal-      <|> In <$> (try (string "in" *> pDelimiter) *> pListVal)-      <|> pFts-      <?> "operator (eq, gt, ...)"--    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--    ops = M.filterWithKey (const . flip notElem ("in":ftsOps)) operators-    ftsOps = M.keys ftsOperators--pSingleVal :: Parser SingleVal-pSingleVal = toS <$> many anyChar--pListVal :: Parser ListVal-pListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')--pListElement :: Parser Text-pListElement = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> (toS <$> many (noneOf ",)"))--pQuotedValue :: Parser Text-pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"')--pDelimiter :: Parser Char-pDelimiter = char '.' <?> "delimiter (.)"--pOrder :: Parser [OrderTerm]-pOrder = lexeme pOrderTerm `sepBy1` char ','--pOrderTerm :: Parser OrderTerm-pOrderTerm = do-  fld <- pField-  dir <- optionMaybe $-         try (pDelimiter *> string "asc" $> OrderAsc) <|>-         try (pDelimiter *> string "desc" $> OrderDesc)-  nls <- optionMaybe pNulls <* pEnd <|>-         pEnd $> Nothing-  return $ OrderTerm fld dir nls-  where-    pNulls = try (pDelimiter *> string "nullsfirst" $> OrderNullsFirst) <|>-             try (pDelimiter *> string "nullslast"  $> OrderNullsLast)-    pEnd = try (void $ lookAhead (char ',')) <|>-           try eof--pLogicTree :: Parser LogicTree-pLogicTree = Stmnt <$> try pLogicFilter-             <|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')'))-  where-    pLogicFilter :: Parser Filter-    pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal-    pNot :: Parser Bool-    pNot = try (string "not" *> pDelimiter $> True)-           <|> pure False-           <?> "negation operator (not)"-    pLogicOp :: Parser LogicOperator-    pLogicOp = try (string "and" $> And)-               <|> string "or" $> Or-               <?> "logic operator (and, or)"--pLogicSingleVal :: Parser SingleVal-pLogicSingleVal = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> try pPgArray <|> (toS <$> many (noneOf ",)"))-  where-    pPgArray :: Parser Text-    pPgArray =  do-      a <- string "{"-      b <- many (noneOf "{}")-      c <- string "}"-      toS <$> pure (a ++ b ++ c)--pLogicPath :: Parser (EmbedPath, Text)-pLogicPath = do-  path <- pFieldName `sepBy1` pDelimiter-  let op = last path-      notOp = "not." <> op-  return (filter (/= "not") (init path), if "not" `elem` path then notOp else op)--pColumns :: Parser [FieldName]-pColumns = pFieldName `sepBy1` lexeme (char ',')--mapError :: Either ParseError a -> Either ApiRequestError a-mapError = mapLeft translateError-  where-    translateError e =-      ParseRequestError message details-      where-        message = show $ errorPos e-        details = strip $ replace "\n" " " $ toS-           $ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)---- Used for the config value "role-claim-key"-pRoleClaimKey :: Text -> Either ApiRequestError JSPath-pRoleClaimKey selStr =-  mapError $ parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr)--pJSPath :: Parser JSPath-pJSPath = toJSPath <$> (period *> pPath `sepBy` period <* eof)-  where-    toJSPath :: [(Text, Maybe Int)] -> JSPath-    toJSPath = concatMap (\(key, idx) -> JSPKey key : maybeToList (JSPIdx <$> idx))-    period = char '.' <?> "period (.)"-    pPath :: Parser (Text, Maybe Int)-    pPath = (,) <$> pJSPKey <*> optionMaybe pJSPIdx--pJSPKey :: Parser Text-pJSPKey = toS <$> many1 (alphaNum <|> oneOf "_$@") <|> pQuotedValue <?> "attribute name [a..z0..9_$@])"--pJSPIdx :: Parser Int-pJSPIdx = char '[' *> (read <$> many1 digit) <* char ']' <?> "array index [0..n]"
− src/PostgREST/Private/Common.hs
@@ -1,25 +0,0 @@-{-|-Module      : PostgREST.Common-Description : Common helper functions.--}-module PostgREST.Private.Common where--import           Data.Maybe-import qualified Hasql.Decoders as HD-import qualified Hasql.Encoders as HE-import           Protolude--column :: HD.Value a -> HD.Row a-column = HD.column . HD.nonNullable--nullableColumn :: HD.Value a -> HD.Row (Maybe a)-nullableColumn = HD.column . HD.nullable--element :: HD.Value a -> HD.Array a-element = HD.element . HD.nonNullable--param :: HE.Value a -> HE.Params a-param = HE.param . HE.nonNullable--arrayParam :: HE.Value a -> HE.Params [a]-arrayParam = param . HE.array . HE.dimension foldl' . HE.element . HE.nonNullable
− src/PostgREST/Private/QueryFragment.hs
@@ -1,206 +0,0 @@-{-# LANGUAGE LambdaCase #-}-{-|-Module      : PostgREST.Private.QueryFragment-Description : Helper functions for PostgREST.QueryBuilder.--Any function that outputs a SqlFragment should be in this module.--}-module PostgREST.Private.QueryFragment where--import qualified Data.HashMap.Strict           as HM-import           Data.Maybe-import           Data.Text                     (intercalate,-                                                isInfixOf, replace,-                                                toLower)-import qualified Data.Text                     as T (map, null,-                                                     takeWhile)-import           PostgREST.Types-import           Protolude                     hiding (cast,-                                                intercalate, replace,-                                                toLower)-import           Text.InterpolatedString.Perl6 (qc)--noLocationF :: SqlFragment-noLocationF = "array[]::text[]"---- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query--- otherwise the query will err with a `could not determine data type of parameter $1`.--- This happens because `unknown` relies on the context to determine the value type.--- The error also happens on raw libpq used with C.-ignoredBody :: SqlFragment-ignoredBody = "pgrst_ignored_body AS (SELECT $1::text) "---- |--- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads--- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays--- We do this in SQL to avoid processing the JSON in application code-normalizedBody :: SqlFragment-normalizedBody =-  unwords [-    "pgrst_payload AS (SELECT $1::json AS json_data),",-    "pgrst_body AS (",-      "SELECT",-        "CASE WHEN json_typeof(json_data) = 'array'",-          "THEN json_data",-          "ELSE json_build_array(json_data)",-        "END AS val",-      "FROM pgrst_payload)"]--selectBody :: SqlFragment-selectBody = "(SELECT val FROM pgrst_body)"--pgFmtLit :: SqlFragment -> SqlFragment-pgFmtLit x =- let trimmed = trimNullChars x-     escaped = "'" <> replace "'" "''" trimmed <> "'"-     slashed = replace "\\" "\\\\" escaped in- if "\\" `isInfixOf` escaped-   then "E" <> slashed-   else slashed--pgFmtIdent :: SqlFragment -> SqlFragment-pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""--asCsvF :: SqlFragment-asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF-  where-    asCsvHeaderF =-      "(SELECT coalesce(string_agg(a.k, ','), '')" <>-      "  FROM (" <>-      "    SELECT json_object_keys(r)::TEXT as k" <>-      "    FROM ( " <>-      "      SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>-      "    ) s" <>-      "  ) a" <>-      ")"-    asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"--asJsonF :: SqlFragment-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 "--asBinaryF :: FieldName -> SqlFragment-asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"--locationF :: [Text] -> SqlFragment-locationF pKeys = [qc|(-  WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)-  SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))-  FROM data CROSS JOIN json_each_text(data.row) AS json_data-  {("WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')") `emptyOnFalse` null pKeys}-)|]--fromQi :: QualifiedIdentifier -> SqlFragment-fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n-  where-    n = qiName t-    s = qiSchema t--emptyOnFalse :: Text -> Bool -> Text-emptyOnFalse val cond = if cond then "" else val--pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment-pgFmtColumn table "*" = fromQi table <> ".*"-pgFmtColumn table c   = fromQi table <> "." <> pgFmtIdent c--pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment-pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp--pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment-pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias-pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs fName jp alias--pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment-pgFmtOrderTerm qi ot = unwords [-  toS . pgFmtField qi $ otTerm ot,-  maybe "" show $ otDirection ot,-  maybe "" show $ otNullOrder ot]--pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment-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-       <> ") "- where-   pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op-   sqlOperator o = HM.lookupDefault "=" o operators-   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"])--pgFmtJoinCondition :: JoinCondition -> SqlFragment-pgFmtJoinCondition (JoinCondition (qi1, col1) (qi2, col2)) =-  pgFmtColumn qi1 col1 <> " = " <> pgFmtColumn qi2 col2--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 (Stmnt flt) = pgFmtFilter qi flt--pgFmtJsonPath :: JsonPath -> SqlFragment-pgFmtJsonPath = \case-  []             -> ""-  (JArrow x:xs)  -> "->" <> pgFmtJsonOperand x <> pgFmtJsonPath xs-  (J2Arrow x:xs) -> "->>" <> pgFmtJsonOperand x <> pgFmtJsonPath xs-  where-    pgFmtJsonOperand (JKey k) = pgFmtLit k-    pgFmtJsonOperand (JIdx i) = pgFmtLit i <> "::int"--pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment-pgFmtAs _ [] Nothing = ""-pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of-  Just (JKey key) -> " AS " <> pgFmtIdent key-  Just (JIdx _)   -> " AS " <> pgFmtIdent (fromMaybe fName lastKey)-    -- We get the lastKey because on:-    -- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ]-    -- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ]-    where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp)-  Nothing -> ""-pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias--trimNullChars :: Text -> Text-trimNullChars = T.takeWhile (/= '\x0')--countF :: SqlQuery -> Bool -> (SqlFragment, SqlFragment)-countF countQuery shouldCount =-  if shouldCount-    then (-        ", pgrst_source_count AS (" <> countQuery <> ")"-      , "(SELECT pg_catalog.count(*) FROM pgrst_source_count)" )-    else (-        mempty-      , "null::bigint")--returningF :: QualifiedIdentifier -> [FieldName] -> SqlFragment-returningF qi returnings =-  if null returnings-    then "RETURNING 1" -- For mutation cases where there's no ?select, we return 1 to know how many rows were modified-    else "RETURNING " <> intercalate ", " (pgFmtColumn qi <$> returnings)--responseHeadersF :: PgVersion -> SqlFragment-responseHeadersF pgVer =-  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
+ src/PostgREST/Query/QueryBuilder.hs view
@@ -0,0 +1,181 @@+{-# LANGUAGE DuplicateRecordFields #-}+{-|+Module      : PostgREST.Query.QueryBuilder+Description : PostgREST SQL queries generating functions.++This module provides functions to consume data types that+represent database queries (e.g. ReadRequest, MutateRequest) and SqlFragment+to produce SqlQuery type outputs.+-}+module PostgREST.Query.QueryBuilder+  ( readRequestToQuery+  , mutateRequestToQuery+  , readRequestToCountQuery+  , requestToCallProcQuery+  , limitedQuery+  ) where++import qualified Data.ByteString.Char8           as BS+import qualified Data.Set                        as S+import qualified Hasql.DynamicStatements.Snippet as H++import Data.Tree (Tree (..))++import PostgREST.DbStructure.Identifiers  (FieldName,+                                           QualifiedIdentifier (..))+import PostgREST.DbStructure.Proc         (PgArg (..))+import PostgREST.DbStructure.Relationship (Cardinality (..),+                                           Relationship (..))+import PostgREST.DbStructure.Table        (Table (..))+import PostgREST.Request.ApiRequest       (PayloadJSON (..))+import PostgREST.Request.Preferences      (PreferParameters (..),+                                           PreferResolution (..))++import PostgREST.Query.SqlFragment+import PostgREST.Request.Types++import Protolude++readRequestToQuery :: ReadRequest -> H.Snippet+readRequestToQuery (Node (Select colSelects mainQi tblAlias implJoins logicForest joinConditions_ ordts range, _) forest) =+  "SELECT " <>+  intercalateSnippet ", " ((pgFmtSelectItem qi <$> colSelects) ++ selects) <>+  "FROM " <> H.sql (BS.intercalate ", " (tabl : implJs)) <> " " <>+  intercalateSnippet " " joins <> " " <>+  (if null logicForest && null joinConditions_ then mempty else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_))+  <> " " <>+  (if null ordts then mempty else "ORDER BY " <> intercalateSnippet ", " (map (pgFmtOrderTerm qi) ordts)) <> " " <>+  limitOffsetF range+  where+    implJs = fromQi <$> implJoins+    tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias+    qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias+    (joins, selects) = foldr getJoinsSelects ([],[]) forest++getJoinsSelects :: ReadRequest -> ([H.Snippet], [H.Snippet]) -> ([H.Snippet], [H.Snippet])+getJoinsSelects rr@(Node (_, (name, Just Relationship{relCardinality=card,relTable=Table{tableName=table}}, alias, _, _)) _) (j,s) =+  let subquery = readRequestToQuery rr in+  case card of+    M2O _ ->+      let aliasOrName = fromMaybe name alias+          localTableName = pgFmtIdent $ table <> "_" <> aliasOrName+          sel = H.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName)+          joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> H.sql localTableName <> " ON TRUE " in+      (joi:j,sel:s)+    _ ->+      let sel = "COALESCE (("+             <> "SELECT json_agg(" <> H.sql (pgFmtIdent table) <> ".*) "+             <> "FROM (" <> subquery <> ") " <> H.sql (pgFmtIdent table) <> " "+             <> "), '[]') AS " <> H.sql (pgFmtIdent (fromMaybe name alias)) in+      (j,sel:s)+getJoinsSelects (Node (_, (_, Nothing, _, _, _)) _) _ = ([], [])++mutateRequestToQuery :: MutateRequest -> H.Snippet+mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returnings) =+  "WITH " <> normalizedBody body <> " " <>+  "INSERT INTO " <> H.sql (fromQi mainQi) <> H.sql (if S.null iCols then " " else "(" <> cols <> ") ") <>+  "SELECT " <> H.sql cols <> " " <>+  H.sql ("FROM json_populate_recordset (null::" <> fromQi mainQi <> ", " <> selectBody <> ") _ ") <>+  -- Only used for PUT+  (if null putConditions then mempty else "WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "_") <$> putConditions)) <>+  H.sql (BS.unwords [+    maybe "" (\(oncDo, oncCols) ->+      if null oncCols then+        mempty+      else+        "ON CONFLICT(" <> BS.intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of+        IgnoreDuplicates ->+          "DO NOTHING"+        MergeDuplicates  ->+          if S.null iCols+             then "DO NOTHING"+             else "DO UPDATE SET " <> BS.intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols)+      ) onConflct,+    returningF mainQi returnings+    ])+  where+    cols = BS.intercalate ", " $ pgFmtIdent <$> S.toList iCols+mutateRequestToQuery (Update mainQi uCols body logicForest returnings) =+  if S.null uCols+    -- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax+    -- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select=+    -- the select has to be based on "returnings" to make computed overloaded functions not throw+    then H.sql ("SELECT " <> emptyBodyReturnedColumns <> " FROM " <> fromQi mainQi <> " WHERE false")+    else+      "WITH " <> normalizedBody body <> " " <>+      "UPDATE " <> H.sql (fromQi mainQi) <> " SET " <> H.sql cols <> " " <>+      "FROM (SELECT * FROM json_populate_recordset (null::" <> H.sql (fromQi mainQi) <> " , " <> H.sql selectBody <> " )) _ " <>+      (if null logicForest then mempty else "WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)) <> " " <>+      H.sql (returningF mainQi returnings)+  where+    cols = BS.intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols)+    emptyBodyReturnedColumns :: SqlFragment+    emptyBodyReturnedColumns+      | null returnings = "NULL"+      | otherwise       = BS.intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings)+mutateRequestToQuery (Delete mainQi logicForest returnings) =+  "DELETE FROM " <> H.sql (fromQi mainQi) <> " " <>+  (if null logicForest then mempty else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree mainQi) logicForest)) <> " " <>+  H.sql (returningF mainQi returnings)++requestToCallProcQuery :: QualifiedIdentifier -> [PgArg] -> Maybe PayloadJSON -> Bool -> Maybe PreferParameters -> [FieldName] -> H.Snippet+requestToCallProcQuery qi pgArgs pj returnsScalar preferParams returnings =+  argsCTE <> sourceBody+  where+    body = pjRaw <$> pj+    paramsAsSingleObject    = preferParams == Just SingleObject+    paramsAsMultipleObjects = preferParams == Just MultipleObjects++    (argsCTE, args)+      | null pgArgs = (mempty, mempty)+      | paramsAsSingleObject = ("WITH pgrst_args AS (SELECT NULL)", jsonPlaceHolder body)+      | otherwise = (+          "WITH " <> normalizedBody body <> ", " <>+          H.sql (+            BS.unwords [+            "pgrst_args AS (",+              "SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <> fmtArgs (const mempty) (\a -> " " <> encodeUtf8 (pgaType a)) <> ")",+            ")"])+         , H.sql $ if paramsAsMultipleObjects+             then fmtArgs varadicPrefix (\a -> " := pgrst_args." <> pgFmtIdent (pgaName a))+             else fmtArgs varadicPrefix (\a -> " := (SELECT " <> pgFmtIdent (pgaName a) <> " FROM pgrst_args LIMIT 1)")+        )++    fmtArgs :: (PgArg -> SqlFragment) -> (PgArg -> SqlFragment) -> SqlFragment+    fmtArgs argFragPre argFragSuf = BS.intercalate ", " ((\a -> argFragPre a <> pgFmtIdent (pgaName a) <> argFragSuf a) <$> pgArgs)++    varadicPrefix :: PgArg -> SqlFragment+    varadicPrefix a = if pgaVar a then "VARIADIC " else mempty++    sourceBody :: H.Snippet+    sourceBody+      | paramsAsMultipleObjects =+          if returnsScalar+            then "SELECT " <> callIt <> " AS pgrst_scalar FROM pgrst_args"+            else "SELECT pgrst_lat_args.* FROM pgrst_args, " <>+                 "LATERAL ( SELECT " <> returnedColumns <> " FROM " <> callIt <> " ) pgrst_lat_args"+      | otherwise =+          if returnsScalar+            then "SELECT " <> callIt <> " AS pgrst_scalar"+            else "SELECT " <> returnedColumns <> " FROM " <> callIt++    callIt :: H.Snippet+    callIt = H.sql (fromQi qi) <> "(" <> args <> ")"++    returnedColumns :: H.Snippet+    returnedColumns+      | null returnings = "*"+      | otherwise       = H.sql $ BS.intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName qi) <$> returnings)+++-- | SQL query meant for COUNTing the root node of the Tree.+-- It only takes WHERE into account and doesn't include LIMIT/OFFSET because it would reduce the COUNT.+-- SELECT 1 is done instead of SELECT * to prevent doing expensive operations(like functions based on the columns)+-- inside the FROM target.+readRequestToCountQuery :: ReadRequest -> H.Snippet+readRequestToCountQuery (Node (Select{from=qi, where_=logicForest}, _) _) =+ "SELECT 1 " <> "FROM " <> H.sql (fromQi qi) <> " " <>+ if null logicForest then mempty else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest)++limitedQuery :: H.Snippet -> Maybe Integer -> H.Snippet+limitedQuery query maxRows = query <> H.sql (maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows)
+ src/PostgREST/Query/SqlFragment.hs view
@@ -0,0 +1,322 @@+{-# LANGUAGE LambdaCase  #-}+{-# LANGUAGE QuasiQuotes #-}+{-|+Module      : PostgREST.Query.SqlFragment+Description : Helper functions for PostgREST.QueryBuilder.++Any function that outputs a SqlFragment should be in this module.+-}+module PostgREST.Query.SqlFragment+  ( noLocationF+  , SqlFragment+  , asBinaryF+  , asCsvF+  , asJsonF+  , asJsonSingleF+  , countF+  , fromQi+  , ftsOperators+  , jsonPlaceHolder+  , limitOffsetF+  , locationF+  , normalizedBody+  , operators+  , pgFmtColumn+  , pgFmtIdent+  , pgFmtJoinCondition+  , pgFmtLogicTree+  , pgFmtOrderTerm+  , pgFmtSelectItem+  , responseHeadersF+  , responseStatusF+  , returningF+  , selectBody+  , sourceCTEName+  , unknownEncoder+  , intercalateSnippet+  ) where++import qualified Data.ByteString.Char8           as BS+import qualified Data.ByteString.Lazy            as BL+import qualified Data.HashMap.Strict             as HM+import qualified Data.Text                       as T+import qualified Hasql.DynamicStatements.Snippet as H+import qualified Hasql.Encoders                  as HE++import Data.Foldable                 (foldr1)+import Text.InterpolatedString.Perl6 (qc)++import PostgREST.Config.PgVersion        (PgVersion, pgVersion96)+import PostgREST.DbStructure.Identifiers (FieldName,+                                          QualifiedIdentifier (..))+import PostgREST.RangeQuery              (NonnegRange, allRange,+                                          rangeLimit, rangeOffset)+import PostgREST.Request.Types           (Alias, Field, Filter (..),+                                          JoinCondition (..),+                                          JsonOperand (..),+                                          JsonOperation (..),+                                          JsonPath, LogicTree (..),+                                          OpExpr (..), Operation (..),+                                          OrderTerm (..), SelectItem)++import Protolude      hiding (cast, toS)+import Protolude.Conv (toS)+++-- | A part of a SQL query that cannot be executed independently+type SqlFragment = ByteString++noLocationF :: SqlFragment+noLocationF = "array[]::text[]"++sourceCTEName :: SqlFragment+sourceCTEName = "pgrst_source"++operators :: HM.HashMap Text SqlFragment+operators = HM.union (HM.fromList [+  ("eq", "="),+  ("gte", ">="),+  ("gt", ">"),+  ("lte", "<="),+  ("lt", "<"),+  ("neq", "<>"),+  ("like", "LIKE"),+  ("ilike", "ILIKE"),+  ("in", "IN"),+  ("is", "IS"),+  ("cs", "@>"),+  ("cd", "<@"),+  ("ov", "&&"),+  ("sl", "<<"),+  ("sr", ">>"),+  ("nxr", "&<"),+  ("nxl", "&>"),+  ("adj", "-|-")]) ftsOperators++ftsOperators :: HM.HashMap Text SqlFragment+ftsOperators = HM.fromList [+  ("fts", "@@ to_tsquery"),+  ("plfts", "@@ plainto_tsquery"),+  ("phfts", "@@ phraseto_tsquery"),+  ("wfts", "@@ websearch_to_tsquery")+  ]++-- |+-- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads+-- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays+-- We do this in SQL to avoid processing the JSON in application code+normalizedBody :: Maybe BL.ByteString -> H.Snippet+normalizedBody body =+  "pgrst_payload AS (SELECT " <> jsonPlaceHolder body <> " AS json_data), " <>+  H.sql (BS.unwords [+    "pgrst_body AS (",+      "SELECT",+        "CASE WHEN json_typeof(json_data) = 'array'",+          "THEN json_data",+          "ELSE json_build_array(json_data)",+        "END AS val",+      "FROM pgrst_payload)"])++-- | Equivalent to "$1::json"+-- | TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body+jsonPlaceHolder :: Maybe BL.ByteString -> H.Snippet+jsonPlaceHolder body =+  H.encoderAndParam (HE.nullable HE.unknown) (toS <$> body) <> "::json"++selectBody :: SqlFragment+selectBody = "(SELECT val FROM pgrst_body)"++pgFmtLit :: Text -> SqlFragment+pgFmtLit x =+ let trimmed = trimNullChars x+     escaped = "'" <> T.replace "'" "''" trimmed <> "'"+     slashed = T.replace "\\" "\\\\" escaped in+ encodeUtf8 $ if "\\" `T.isInfixOf` escaped+   then "E" <> slashed+   else slashed++-- TODO: refactor by following https://github.com/PostgREST/postgrest/pull/1631#issuecomment-711070833+pgFmtIdent :: Text -> SqlFragment+pgFmtIdent x = encodeUtf8 $ "\"" <> T.replace "\"" "\"\"" (trimNullChars x) <> "\""++trimNullChars :: Text -> Text+trimNullChars = T.takeWhile (/= '\x0')++asCsvF :: SqlFragment+asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF+  where+    asCsvHeaderF =+      "(SELECT coalesce(string_agg(a.k, ','), '')" <>+      "  FROM (" <>+      "    SELECT json_object_keys(r)::text as k" <>+      "    FROM ( " <>+      "      SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>+      "    ) s" <>+      "  ) a" <>+      ")"+    asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"++asJsonF :: Bool -> SqlFragment+asJsonF returnsScalar+  | returnsScalar = "coalesce(json_agg(_postgrest_t.pgrst_scalar), '[]')::character varying"+  | otherwise     = "coalesce(json_agg(_postgrest_t), '[]')::character varying"++asJsonSingleF :: Bool -> SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element+asJsonSingleF returnsScalar+  | returnsScalar = "coalesce(string_agg(to_json(_postgrest_t.pgrst_scalar)::text, ','), 'null')::character varying"+  | otherwise     = "coalesce(string_agg(to_json(_postgrest_t)::text, ','), '')::character varying"++asBinaryF :: FieldName -> SqlFragment+asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"++locationF :: [Text] -> SqlFragment+locationF pKeys = [qc|(+  WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)+  SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))+  FROM data CROSS JOIN json_each_text(data.row) AS json_data+  WHERE json_data.key IN ('{fmtPKeys}')+)|]+  where+    fmtPKeys = T.intercalate "','" pKeys++fromQi :: QualifiedIdentifier -> SqlFragment+fromQi t = (if T.null s then mempty else pgFmtIdent s <> ".") <> pgFmtIdent n+  where+    n = qiName t+    s = qiSchema t++pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment+pgFmtColumn table "*" = fromQi table <> ".*"+pgFmtColumn table c   = fromQi table <> "." <> pgFmtIdent c++pgFmtField :: QualifiedIdentifier -> Field -> H.Snippet+pgFmtField table (c, jp) = H.sql (pgFmtColumn table c) <> pgFmtJsonPath jp++pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> H.Snippet+pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> H.sql (pgFmtAs fName jp alias)+-- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc.+-- Try doing: `select 1::"bigint"` - it'll err, using "int8" will work though. There's some parser magic that pg does that's invalidated when quoting.+-- Not quoting should be fine, we validate the input on Parsers.+pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> H.sql (encodeUtf8 cast) <> " )" <> H.sql (pgFmtAs fName jp alias)++pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> H.Snippet+pgFmtOrderTerm qi ot =+  pgFmtField qi (otTerm ot) <> " " <>+  H.sql (BS.unwords [+    BS.pack $ maybe mempty show $ otDirection ot,+    BS.pack $ maybe mempty show $ otNullOrder ot])++pgFmtFilter :: QualifiedIdentifier -> Filter -> H.Snippet+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"    -> isAllowed val+     _       -> unknownLiteral val++   -- We don't use "IN", we use "= ANY". IN has the following disadvantages:+   -- + No way to use an empty value on IN: "col IN ()" is invalid syntax. With ANY we can do "= ANY('{}')"+   -- + Can invalidate prepared statements: multiple parameters on an IN($1, $2, $3) will lead to using different prepared statements and not take advantage of caching.+   In vals -> pgFmtField table fld <> " " <>+    case vals of+      [""] -> "= ANY('{}') "+      -- Here we build the pg array, e.g '{"Hebdon, John","Other","Another"}', manually. We quote the values to prevent the "," being treated as an element separator.+      -- TODO: Ideally this would be done on Hasql with an encoder, but the "array unknown" is not working(Hasql doesn't pass any value).+      _    -> "= ANY (" <> unknownLiteral ("{" <> T.intercalate "," ((\x -> "\"" <> x <> "\"") <$> vals) <> "}") <> ")"++   Fts op lang val ->+     pgFmtFieldOp op <> "(" <> ftsLang lang <> unknownLiteral val <> ") "+ where+   ftsLang = maybe mempty (\l -> unknownLiteral l <> ", ")+   pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op+   sqlOperator o = H.sql $ HM.lookupDefault "=" o operators+   notOp = if hasNot then "NOT" else mempty+   star c = if c == '*' then '%' else c+   -- IS cannot be prepared. `PREPARE boolplan AS SELECT * FROM projects where id IS $1` will give a syntax error.+   -- The above can be fixed by using `PREPARE boolplan AS SELECT * FROM projects where id IS NOT DISTINCT FROM $1;`+   -- However that would not accept the TRUE/FALSE/NULL keywords. See: https://stackoverflow.com/questions/6133525/proper-way-to-set-preparedstatement-parameter-to-null-under-postgres.+   isAllowed :: Text -> H.Snippet+   isAllowed v = H.sql $ maybe+     (pgFmtLit v <> "::unknown") encodeUtf8+     (find ((==) . T.toLower $ v) ["null","true","false"])++pgFmtJoinCondition :: JoinCondition -> H.Snippet+pgFmtJoinCondition (JoinCondition (qi1, col1) (qi2, col2)) =+  H.sql $ pgFmtColumn qi1 col1 <> " = " <> pgFmtColumn qi2 col2++pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> H.Snippet+pgFmtLogicTree qi (Expr hasNot op forest) = H.sql notOp <> " (" <> intercalateSnippet (" " <> BS.pack (show op) <> " ") (pgFmtLogicTree qi <$> forest) <> ")"+  where notOp =  if hasNot then "NOT" else mempty+pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt++pgFmtJsonPath :: JsonPath -> H.Snippet+pgFmtJsonPath = \case+  []             -> mempty+  (JArrow x:xs)  -> "->" <> pgFmtJsonOperand x <> pgFmtJsonPath xs+  (J2Arrow x:xs) -> "->>" <> pgFmtJsonOperand x <> pgFmtJsonPath xs+  where+    pgFmtJsonOperand (JKey k) = unknownLiteral k+    pgFmtJsonOperand (JIdx i) = unknownLiteral i <> "::int"++pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment+pgFmtAs _ [] Nothing = mempty+pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of+  Just (JKey key) -> " AS " <> pgFmtIdent key+  Just (JIdx _)   -> " AS " <> pgFmtIdent (fromMaybe fName lastKey)+    -- We get the lastKey because on:+    -- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ]+    -- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ]+    where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp)+  Nothing -> mempty+pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias++countF :: H.Snippet -> Bool -> (H.Snippet, SqlFragment)+countF countQuery shouldCount =+  if shouldCount+    then (+        ", pgrst_source_count AS (" <> countQuery <> ")"+      , "(SELECT pg_catalog.count(*) FROM pgrst_source_count)" )+    else (+        mempty+      , "null::bigint")++returningF :: QualifiedIdentifier -> [FieldName] -> SqlFragment+returningF qi returnings =+  if null returnings+    then "RETURNING 1" -- For mutation cases where there's no ?select, we return 1 to know how many rows were modified+    else "RETURNING " <> BS.intercalate ", " (pgFmtColumn qi <$> returnings)++limitOffsetF :: NonnegRange -> H.Snippet+limitOffsetF range =+  if range == allRange then mempty else "LIMIT " <> limit <> " OFFSET " <> offset+  where+    limit = maybe "ALL" (\l -> unknownEncoder (BS.pack $ show l)) $ rangeLimit range+    offset = unknownEncoder (BS.pack . show $ rangeOffset range)++responseHeadersF :: PgVersion -> SqlFragment+responseHeadersF pgVer =+  if pgVer >= pgVersion96+    then currentSettingF "response.headers"+    else "null"++responseStatusF :: PgVersion -> SqlFragment+responseStatusF pgVer =+  if pgVer >= pgVersion96+    then currentSettingF "response.status"+    else "null"++currentSettingF :: Text -> SqlFragment+currentSettingF setting =+  -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15+  "nullif(current_setting(" <> pgFmtLit setting <> ", true), '')"++-- Hasql Snippet utilities+unknownEncoder :: ByteString -> H.Snippet+unknownEncoder = H.encoderAndParam (HE.nonNullable HE.unknown)++unknownLiteral :: Text -> H.Snippet+unknownLiteral = unknownEncoder . encodeUtf8++intercalateSnippet :: ByteString -> [H.Snippet] -> H.Snippet+intercalateSnippet _ [] = mempty+intercalateSnippet frag snippets = foldr1 (\a b -> a <> H.sql frag <> b) snippets
+ src/PostgREST/Query/Statements.hs view
@@ -0,0 +1,204 @@+{-|+Module      : PostgREST.Query.Statements+Description : PostgREST single SQL statements.++This module constructs single SQL statements that can be parametrized and prepared.++- It consumes the SqlQuery types generated by the QueryBuilder module.+- It generates the body format and some headers of the final HTTP response.++TODO: Currently, createReadStatement is not using prepared statements. See https://github.com/PostgREST/postgrest/issues/718.+-}+module PostgREST.Query.Statements+  ( createWriteStatement+  , createReadStatement+  , callProcStatement+  , createExplainStatement+  ) where++import qualified Data.Aeson                        as JSON+import qualified Data.Aeson.Lens                   as L+import qualified Data.ByteString.Char8             as BS+import qualified Hasql.Decoders                    as HD+import qualified Hasql.DynamicStatements.Snippet   as H+import qualified Hasql.DynamicStatements.Statement as H+import qualified Hasql.Statement                   as H++import Control.Lens              ((^?))+import Data.Maybe                (fromJust)+import Data.Text.Read            (decimal)+import Network.HTTP.Types.Status (Status)++import PostgREST.Config.PgVersion (PgVersion)+import PostgREST.Error            (Error (..))+import PostgREST.GucHeader        (GucHeader)++import PostgREST.DbStructure.Identifiers (FieldName)+import PostgREST.Query.SqlFragment+import PostgREST.Request.Preferences++import Protolude      hiding (toS)+import Protolude.Conv (toS)++{-| The generic query result format used by API responses. The location header+    is represented as a list of strings containing variable bindings like+    @"k1=eq.42"@, or the empty list if there is no location header.+-}+type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString, Either Error [GucHeader], Either Error (Maybe Status))++createWriteStatement :: H.Snippet -> H.Snippet -> Bool -> Bool -> Bool ->+                        PreferRepresentation -> [Text] -> PgVersion -> Bool ->+                        H.Statement () ResultsWithCount+createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys pgVer =+  H.dynamicallyParameterized snippet decodeStandard+ where+  snippet =+    "WITH " <> H.sql sourceCTEName <> " AS (" <> mutateQuery <> ") " <>+    H.sql (+    "SELECT " <>+      "'' AS total_result_set, " <>+      "pg_catalog.count(_postgrest_t) AS page_total, " <>+      locF <> " AS header, " <>+      bodyF <> " AS body, " <>+      responseHeadersF pgVer <> " AS response_headers, " <>+      responseStatusF pgVer  <> " AS response_status "+    ) <>+    "FROM (" <> selectF <> ") _postgrest_t"++  locF =+    if isInsert && rep `elem` [Full, HeadersOnly]+      then BS.unwords [+        "CASE WHEN pg_catalog.count(_postgrest_t) = 1",+          "THEN coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")",+          "ELSE " <> noLocationF,+        "END"]+      else noLocationF++  bodyF+    | rep `elem` [None, HeadersOnly] = "''"+    | asCsv = asCsvF+    | wantSingle = asJsonSingleF False+    | otherwise = asJsonF False++  selectF+    -- prevent using any of the column names in ?select= when no response is returned from the CTE+    | rep `elem` [None, HeadersOnly] = H.sql ("SELECT * FROM " <> sourceCTEName)+    | otherwise                      = selectQuery++  decodeStandard :: HD.Result ResultsWithCount+  decodeStandard =+   fromMaybe (Nothing, 0, [], mempty, Right [], Right Nothing) <$> HD.rowMaybe standardRow++createReadStatement :: H.Snippet -> H.Snippet -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion -> Bool ->+                       H.Statement () ResultsWithCount+createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField pgVer =+  H.dynamicallyParameterized snippet decodeStandard+ where+  snippet =+    "WITH " <>+    H.sql sourceCTEName <> " AS ( " <> selectQuery <> " ) " <>+    countCTEF <> " " <>+    H.sql ("SELECT " <>+      countResultF <> " AS total_result_set, " <>+      "pg_catalog.count(_postgrest_t) AS page_total, " <>+      noLocationF <> " AS header, " <>+      bodyF <> " AS body, " <>+      responseHeadersF pgVer <> " AS response_headers, " <>+      responseStatusF pgVer <> " AS response_status " <>+    "FROM ( SELECT * FROM " <> sourceCTEName <> " ) _postgrest_t")++  (countCTEF, countResultF) = countF countQuery countTotal++  bodyF+    | asCsv = asCsvF+    | isSingle = asJsonSingleF False+    | isJust binaryField = asBinaryF $ fromJust binaryField+    | otherwise = asJsonF False++  decodeStandard :: HD.Result ResultsWithCount+  decodeStandard =+    HD.singleRow standardRow++{-| Read and Write api requests use a similar response format which includes+    various record counts and possible location header. This is the decoder+    for that common type of query.+-}+standardRow :: HD.Row ResultsWithCount+standardRow = (,,,,,) <$> nullableColumn HD.int8 <*> column HD.int8+                      <*> arrayColumn HD.bytea <*> column HD.bytea+                      <*> (fromMaybe (Right []) <$> nullableColumn decodeGucHeaders)+                      <*> (fromMaybe (Right Nothing) <$> nullableColumn decodeGucStatus)++type ProcResults = (Maybe Int64, Int64, ByteString, Either Error [GucHeader], Either Error (Maybe Status))++callProcStatement :: Bool -> Bool -> H.Snippet -> H.Snippet -> H.Snippet -> Bool ->+                     Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion -> Bool ->+                     H.Statement () ProcResults+callProcStatement returnsScalar returnsSingle callProcQuery selectQuery countQuery countTotal asSingle asCsv multObjects binaryField pgVer =+  H.dynamicallyParameterized snippet decodeProc+  where+    snippet =+      "WITH " <> H.sql sourceCTEName <> " AS (" <> callProcQuery <> ") " <>+      countCTEF <>+      H.sql (+      "SELECT " <>+        countResultF <> " AS total_result_set, " <>+        "pg_catalog.count(_postgrest_t) AS page_total, " <>+        bodyF <> " AS body, " <>+        responseHeadersF pgVer <> " AS response_headers, " <>+        responseStatusF pgVer <> " AS response_status ") <>+      "FROM (" <> selectQuery <> ") _postgrest_t"++    (countCTEF, countResultF) = countF countQuery countTotal++    bodyF+     | asSingle           = asJsonSingleF returnsScalar+     | asCsv              = asCsvF+     | isJust binaryField = asBinaryF $ fromJust binaryField+     | returnsSingle+       && not multObjects = asJsonSingleF returnsScalar+     | otherwise          = asJsonF returnsScalar++    decodeProc :: HD.Result ProcResults+    decodeProc =+      fromMaybe (Just 0, 0, mempty, defGucHeaders, defGucStatus) <$> HD.rowMaybe procRow+      where+        defGucHeaders = Right []+        defGucStatus  = Right Nothing+        procRow = (,,,,) <$> nullableColumn HD.int8 <*> column HD.int8+                         <*> column HD.bytea+                         <*> (fromMaybe defGucHeaders <$> nullableColumn decodeGucHeaders)+                         <*> (fromMaybe defGucStatus <$> nullableColumn decodeGucStatus)++createExplainStatement :: H.Snippet -> Bool -> H.Statement () (Maybe Int64)+createExplainStatement countQuery =+  H.dynamicallyParameterized snippet decodeExplain+  where+    snippet = "EXPLAIN (FORMAT JSON) " <> countQuery+    -- |+    -- An `EXPLAIN (FORMAT JSON) select * from items;` output looks like this:+    -- [{+    --   "Plan": {+    --     "Node Type": "Seq Scan", "Parallel Aware": false, "Relation Name": "items",+    --     "Alias": "items", "Startup Cost": 0.00, "Total Cost": 32.60,+    --     "Plan Rows": 2260,"Plan Width": 8} }]+    -- We only obtain the Plan Rows here.+    decodeExplain :: HD.Result (Maybe Int64)+    decodeExplain =+      let row = HD.singleRow $ column HD.bytea in+      (^? L.nth 0 . L.key "Plan" .  L.key "Plan Rows" . L._Integral) <$> row++decodeGucHeaders :: HD.Value (Either Error [GucHeader])+decodeGucHeaders = first (const GucHeadersError) . JSON.eitherDecode . toS <$> HD.bytea++decodeGucStatus :: HD.Value (Either Error (Maybe Status))+decodeGucStatus = first (const GucStatusError) . fmap (Just . toEnum . fst) . decimal <$> HD.text++column :: HD.Value a -> HD.Row a+column = HD.column . HD.nonNullable++nullableColumn :: HD.Value a -> HD.Row (Maybe a)+nullableColumn = HD.column . HD.nullable++arrayColumn :: HD.Value a -> HD.Row [a]+arrayColumn = column . HD.listArray . HD.nonNullable
− src/PostgREST/QueryBuilder.hs
@@ -1,193 +0,0 @@-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts      #-}-{-# LANGUAGE FlexibleInstances     #-}-{-# OPTIONS_GHC -fno-warn-orphans  #-}-{-|-Module      : PostgREST.QueryBuilder-Description : PostgREST SQL queries generating functions.--This module provides functions to consume data types that-represent database queries (e.g. ReadRequest, MutateRequest) and SqlFragment-to produce SqlQuery type outputs.--}-module PostgREST.QueryBuilder (-    readRequestToQuery-  , mutateRequestToQuery-  , readRequestToCountQuery-  , requestToCallProcQuery-  , limitedQuery-  , setLocalQuery-  , setLocalSearchPathQuery-  ) where--import qualified Data.Set as S--import Data.Text (intercalate)-import Data.Tree (Tree (..))--import Data.Maybe--import PostgREST.Private.QueryFragment-import PostgREST.RangeQuery            (allRange, rangeLimit,-                                        rangeOffset)-import PostgREST.Types-import Protolude                       hiding (cast, intercalate,-                                        replace)--readRequestToQuery :: ReadRequest -> SqlQuery-readRequestToQuery (Node (Select colSelects mainQi tblAlias implJoins logicForest joinConditions_ ordts range, _) forest) =-  unwords [-    "SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),-    "FROM " <> intercalate ", " (tabl : implJs),-    unwords joins,-    ("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_))-      `emptyOnFalse` (null logicForest && null joinConditions_),-    ("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts,-    ("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (range == allRange)-    ]-  where-    implJs = fromQi <$> implJoins-    tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias-    qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias-    (joins, selects) = foldr getJoinsSelects ([],[]) forest--getJoinsSelects :: ReadRequest -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])-getJoinsSelects rr@(Node (_, (name, Just Relation{relType=relTyp,relTable=Table{tableName=table}}, alias, _, _)) _) (j,s) =-  let subquery = readRequestToQuery rr in-  case relTyp of-    M2O ->-      let 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 " in-      (joi:j,sel:s)-    _ ->-      let sel = "COALESCE (("-             <> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "-             <> "FROM (" <> subquery <> ") " <> pgFmtIdent table-             <> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias) in-      (j,sel:s)-getJoinsSelects (Node (_, (_, Nothing, _, _, _)) _) _ = ([], [])--mutateRequestToQuery :: MutateRequest -> SqlQuery-mutateRequestToQuery (Insert mainQi iCols onConflct putConditions returnings) =-  unwords [-    "WITH " <> normalizedBody,-    "INSERT INTO ", fromQi mainQi, if S.null iCols then " " else "(" <> cols <> ")",-    unwords [-      "SELECT " <> cols <> " FROM",-      "json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ") _",-      -- Only used for PUT-      ("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "_") <$> putConditions)) `emptyOnFalse` null putConditions],-    maybe "" (\(oncDo, oncCols) -> (-      "ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of-      IgnoreDuplicates ->-        "DO NOTHING"-      MergeDuplicates  ->-        if S.null iCols-           then "DO NOTHING"-           else "DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols)-                                   ) `emptyOnFalse` null oncCols) onConflct,-    returningF mainQi returnings-    ]-  where-    cols = intercalate ", " $ pgFmtIdent <$> S.toList iCols-mutateRequestToQuery (Update mainQi uCols logicForest returnings) =-  if S.null uCols-    -- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax-    -- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select=-    -- the select has to be based on "returnings" to make computed overloaded functions not throw-    then "WITH " <> ignoredBody <> "SELECT " <> empty_body_returned_columns <> " FROM " <> fromQi mainQi <> " WHERE false"-    else-      unwords [-        "WITH " <> normalizedBody,-        "UPDATE " <> fromQi mainQi <> " SET " <> cols,-        "FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ")) _ ",-        ("WHERE " <> intercalate " AND " (pgFmtLogicTree mainQi <$> logicForest)) `emptyOnFalse` null logicForest,-        returningF mainQi returnings-        ]-  where-    cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols)-    empty_body_returned_columns :: SqlFragment-    empty_body_returned_columns-      | null returnings = "NULL"-      | otherwise       = intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings)-mutateRequestToQuery (Delete mainQi logicForest returnings) =-  unwords [-    "WITH " <> ignoredBody,-    "DELETE FROM ", fromQi mainQi,-    ("WHERE " <> intercalate " AND " (map (pgFmtLogicTree mainQi) logicForest)) `emptyOnFalse` null logicForest,-    returningF mainQi returnings-    ]--requestToCallProcQuery :: QualifiedIdentifier -> [PgArg] -> Bool -> Maybe PreferParameters -> [FieldName] -> SqlQuery-requestToCallProcQuery qi pgArgs returnsScalar preferParams returnings =-  unwords [-    "WITH",-    argsCTE,-    sourceBody ]-  where-    paramsAsSingleObject    = preferParams == Just SingleObject-    paramsAsMultipleObjects = preferParams == Just MultipleObjects--    (argsCTE, args)-      | null pgArgs = (ignoredBody, "")-      | paramsAsSingleObject = ("pgrst_args AS (SELECT NULL)", "$1::json")-      | otherwise = (-          unwords [-            normalizedBody <> ",",-            "pgrst_args AS (",-              "SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <> fmtArgs (\a -> " " <> pgaType a) <> ")",-            ")"]-         , if paramsAsMultipleObjects-             then fmtArgs (\a -> " := pgrst_args." <> pgFmtIdent (pgaName a))-             else fmtArgs (\a -> " := (SELECT " <> pgFmtIdent (pgaName a) <> " FROM pgrst_args LIMIT 1)")-        )--    fmtArgs :: (PgArg -> SqlFragment) -> SqlFragment-    fmtArgs argFrag = intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> argFrag a) <$> pgArgs)--    sourceBody :: SqlFragment-    sourceBody-      | paramsAsMultipleObjects =-          if returnsScalar-            then "SELECT " <> callIt <> " AS pgrst_scalar FROM pgrst_args"-            else unwords [ "SELECT pgrst_lat_args.*"-                         , "FROM pgrst_args,"-                         , "LATERAL ( SELECT " <> returned_columns <> " FROM " <> callIt <> " ) pgrst_lat_args" ]-      | otherwise =-          if returnsScalar-            then "SELECT " <> callIt <> " AS pgrst_scalar"-            else "SELECT " <> returned_columns <> " FROM " <> callIt--    callIt :: SqlFragment-    callIt = fromQi qi <> "(" <> args <> ")"--    returned_columns :: SqlFragment-    returned_columns-      | null returnings = "*"-      | otherwise       = intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName qi) <$> returnings)----- | SQL query meant for COUNTing the root node of the Tree.--- It only takes WHERE into account and doesn't include LIMIT/OFFSET because it would reduce the COUNT.--- SELECT 1 is done instead of SELECT * to prevent doing expensive operations(like functions based on the columns)--- inside the FROM target.-readRequestToCountQuery :: ReadRequest -> SqlQuery-readRequestToCountQuery (Node (Select{from=qi, where_=logicForest}, _) _) =- unwords [-   "SELECT 1",-   "FROM " <> fromQi qi,-   ("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest-   ]--limitedQuery :: SqlQuery -> Maybe Integer -> SqlQuery-limitedQuery query maxRows = query <> maybe mempty (\x -> " LIMIT " <> show x) maxRows--setLocalQuery :: Text -> (Text, Text) -> SqlQuery-setLocalQuery prefix (k, v) =-  "SET LOCAL " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"--setLocalSearchPathQuery :: [Text] -> SqlQuery-setLocalSearchPathQuery vals =-  "SET LOCAL search_path = " <> intercalate ", " (pgFmtLit <$> vals) <> ";"
src/PostgREST/RangeQuery.hs view
@@ -99,5 +99,5 @@         | totalNotZero && fromInRange = show lower <> "-" <> show upper         | otherwise = "*"       totalString   = maybe "*" show total-      totalNotZero  = maybe True (0 /=) total+      totalNotZero  = Just 0 /= total       fromInRange   = lower <= upper
+ src/PostgREST/Request/ApiRequest.hs view
@@ -0,0 +1,516 @@+{-|+Module      : PostgREST.Request.ApiRequest+Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.+-}+{-# LANGUAGE LambdaCase      #-}+{-# LANGUAGE MultiWayIf      #-}+{-# LANGUAGE NamedFieldPuns  #-}+{-# LANGUAGE RecordWildCards #-}++module PostgREST.Request.ApiRequest+  ( ApiRequest(..)+  , InvokeMethod(..)+  , ContentType(..)+  , Action(..)+  , Target(..)+  , PayloadJSON(..)+  , userApiRequest+  ) where++import qualified Data.Aeson           as JSON+import qualified Data.ByteString      as BS+import qualified Data.ByteString.Lazy as BL+import qualified Data.CaseInsensitive as CI+import qualified Data.Csv             as CSV+import qualified Data.HashMap.Strict  as M+import qualified Data.List            as L+import qualified Data.Set             as S+import qualified Data.Text            as T+import qualified Data.Vector          as V++import Control.Arrow             ((***))+import Data.Aeson.Types          (emptyArray, emptyObject)+import Data.List                 (last, lookup, partition, union)+import Data.List.NonEmpty        (head)+import Data.Maybe                (fromJust)+import Data.Ranged.Boundaries    (Boundary (..))+import Data.Ranged.Ranges        (Range (..), emptyRange,+                                  rangeIntersection)+import Network.HTTP.Base         (urlEncodeVars)+import Network.HTTP.Types.Header (hAuthorization, hCookie)+import Network.HTTP.Types.URI    (parseQueryReplacePlus,+                                  parseSimpleQuery)+import Network.Wai               (Request (..))+import Network.Wai.Parse         (parseHttpAccept)+import Web.Cookie                (parseCookies)++import PostgREST.Config                  (AppConfig (..),+                                          OpenAPIMode (..))+import PostgREST.ContentType             (ContentType (..))+import PostgREST.DbStructure             (DbStructure (..))+import PostgREST.DbStructure.Identifiers (FieldName,+                                          QualifiedIdentifier (..),+                                          Schema)+import PostgREST.DbStructure.Proc        (PgArg (..),+                                          ProcDescription (..),+                                          ProcsMap)+import PostgREST.Error                   (ApiRequestError (..))+import PostgREST.Query.SqlFragment       (ftsOperators, operators)+import PostgREST.RangeQuery              (NonnegRange, allRange,+                                          rangeGeq, rangeLimit,+                                          rangeOffset, rangeRequested,+                                          restrictRange)+import PostgREST.Request.Parsers         (pRequestColumns)+import PostgREST.Request.Preferences     (PreferCount (..),+                                          PreferParameters (..),+                                          PreferRepresentation (..),+                                          PreferResolution (..),+                                          PreferTransaction (..))++import qualified PostgREST.ContentType as ContentType++import Protolude      hiding (head, toS)+import Protolude.Conv (toS)+++type RequestBody = BL.ByteString++data PayloadJSON+  = ProcessedJSON -- ^ Cached attributes of a JSON payload+      { pjRaw  :: BL.ByteString+      -- ^ This is the raw ByteString that comes from the request body.  We+      -- cache this instead of an Aeson Value because it was detected that for+      -- large payloads the encoding had high memory usage, see+      -- https://github.com/PostgREST/postgrest/pull/1005 for more details+      , pjKeys :: S.Set Text+      -- ^ Keys of the object or if it's an array these keys are guaranteed to+      -- be the same across all its objects+      }+  | RawJSON { pjRaw  :: BL.ByteString }++data InvokeMethod = InvHead | InvGet | InvPost deriving Eq+-- | Types of things a user wants to do to tables/views/procs+data Action = ActionCreate       | ActionRead{isHead :: Bool}+            | ActionUpdate       | ActionDelete+            | ActionSingleUpsert | ActionInvoke InvokeMethod+            | ActionInfo         | ActionInspect{isHead :: Bool}+            deriving Eq+-- | The path info that will be mapped to a target (used to handle validations and errors before defining the Target)+data Path+  = PathInfo+      { pSchema        :: Schema,+        pName          :: Text,+        pHasRpc        :: Bool,+        pIsDefaultSpec :: Bool,+        pIsRootSpec    :: Bool+      }+  | PathUnknown+-- | The target db object of a user action+data Target = TargetIdent QualifiedIdentifier+            | TargetProc{tProc :: ProcDescription, tpIsRootSpec :: Bool}+            | TargetDefaultSpec{tdsSchema :: Schema} -- The default spec offered at root "/"+            | TargetUnknown++-- | RPC query param value `/rpc/func?v=<value>`, used for VARIADIC functions on form-urlencoded POST and GETs+-- | It can be fixed `?v=1` or repeated `?v=1&v=2&v=3.+data RpcParamValue = Fixed Text | Variadic [Text]+instance JSON.ToJSON RpcParamValue where+  toJSON (Fixed    v) = JSON.toJSON v+  toJSON (Variadic v) = JSON.toJSON v++toRpcParamValue :: ProcDescription -> (Text, Text) -> (Text, RpcParamValue)+toRpcParamValue proc (k, v) | argIsVariadic k = (k, Variadic [v])+                            | otherwise       = (k, Fixed v)+  where+    argIsVariadic arg = isJust $ find (\PgArg{pgaName, pgaVar} -> pgaName == arg && pgaVar) $ pdArgs proc++-- | Convert rpc params `/rpc/func?a=val1&b=val2` to json `{"a": "val1", "b": "val2"}+jsonRpcParams :: ProcDescription -> [(Text, Text)] -> PayloadJSON+jsonRpcParams proc prms =+  if not $ pdHasVariadic proc then -- if proc has no variadic arg, save steps and directly convert to json+    ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> prms) (S.fromList $ fst <$> prms)+  else+    let paramsMap = M.fromListWith mergeParams $ toRpcParamValue proc <$> prms in+    ProcessedJSON (JSON.encode paramsMap) (S.fromList $ M.keys paramsMap)+  where+    mergeParams :: RpcParamValue -> RpcParamValue -> RpcParamValue+    mergeParams (Variadic a) (Variadic b) = Variadic $ b ++ a+    mergeParams v _                       = v -- repeated params for non-variadic arguments are not merged++targetToJsonRpcParams :: Maybe Target -> [(Text, Text)] -> Maybe PayloadJSON+targetToJsonRpcParams target params =+  case target of+    Just TargetProc{tProc} -> Just $ jsonRpcParams tProc params+    _                      -> Nothing++{-|+  Describes what the user wants to do. This data type is a+  translation of the raw elements of an HTTP request into domain+  specific language.  There is no guarantee that the intent is+  sensible, it is up to a later stage of processing to determine+  if it is an action we are able to perform.+-}+data ApiRequest = ApiRequest {+    iAction               :: Action                           -- ^ Similar but not identical to HTTP verb, e.g. Create/Invoke both POST+  , iRange                :: M.HashMap ByteString NonnegRange -- ^ Requested range of rows within response+  , iTopLevelRange        :: NonnegRange                      -- ^ Requested range of rows from the top level+  , iTarget               :: Target                           -- ^ The target, be it calling a proc or accessing a table+  , iPayload              :: Maybe PayloadJSON                -- ^ Data sent by client and used for mutation actions+  , iPreferRepresentation :: PreferRepresentation             -- ^ If client wants created items echoed back+  , iPreferParameters     :: Maybe PreferParameters           -- ^ How to pass parameters to a stored procedure+  , iPreferCount          :: Maybe PreferCount                -- ^ Whether the client wants a result count+  , iPreferResolution     :: Maybe PreferResolution           -- ^ Whether the client wants to UPSERT or ignore records on PK conflict+  , iPreferTransaction    :: Maybe PreferTransaction          -- ^ Whether the clients wants to commit or rollback the transaction+  , iFilters              :: [(Text, Text)]                   -- ^ Filters on the result ("id", "eq.10")+  , iLogic                :: [(Text, Text)]                   -- ^ &and and &or parameters used for complex boolean logic+  , iSelect               :: Maybe Text                       -- ^ &select parameter used to shape the response+  , iOnConflict           :: Maybe Text                       -- ^ &on_conflict parameter used to upsert on specific unique keys+  , iColumns              :: S.Set FieldName                  -- ^ parsed colums from &columns parameter and payload+  , iOrder                :: [(Text, Text)]                   -- ^ &order parameters for each level+  , iCanonicalQS          :: ByteString                       -- ^ Alphabetized (canonical) request query string for response URLs+  , iJWT                  :: Text                             -- ^ JSON Web Token+  , iHeaders              :: [(ByteString, ByteString)]       -- ^ HTTP request headers+  , iCookies              :: [(ByteString, ByteString)]       -- ^ Request Cookies+  , iPath                 :: ByteString                       -- ^ Raw request path+  , iMethod               :: ByteString                       -- ^ Raw request method+  , iProfile              :: Maybe Schema                     -- ^ The request profile for enabling use of multiple schemas. Follows the spec in hhttps://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/.+  , iSchema               :: Schema                           -- ^ The request schema. Can vary depending on iProfile.+  , iAcceptContentType    :: ContentType+  }++-- | Examines HTTP request and translates it into user intent.+userApiRequest :: AppConfig -> DbStructure -> Request -> RequestBody -> Either ApiRequestError ApiRequest+userApiRequest conf@AppConfig{..} dbStructure req reqBody+  | isJust profile && fromJust profile `notElem` configDbSchemas = Left $ UnacceptableSchema $ toList configDbSchemas+  | isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate+  | topLevelRange == emptyRange = Left InvalidRange+  | shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload+  | isLeft parsedColumns = either Left witness parsedColumns+  | otherwise = do+     acceptContentType <- findAcceptContentType conf action path accepts+     checkedTarget <- target+     return ApiRequest {+      iAction = action+      , iTarget = checkedTarget+      , iRange = ranges+      , iTopLevelRange = topLevelRange+      , iPayload = relevantPayload+      , iPreferRepresentation = representation+      , iPreferParameters  = if | hasPrefer (show SingleObject)     -> Just SingleObject+                                | hasPrefer (show MultipleObjects)  -> Just MultipleObjects+                                | otherwise                         -> Nothing+      , iPreferCount       = if | hasPrefer (show ExactCount)       -> Just ExactCount+                                | hasPrefer (show PlannedCount)     -> Just PlannedCount+                                | hasPrefer (show EstimatedCount)   -> Just EstimatedCount+                                | otherwise                         -> Nothing+      , iPreferResolution  = if | hasPrefer (show MergeDuplicates)  -> Just MergeDuplicates+                                | hasPrefer (show IgnoreDuplicates) -> Just IgnoreDuplicates+                                | otherwise                         -> Nothing+      , iPreferTransaction = if | hasPrefer (show Commit)           -> Just Commit+                                | hasPrefer (show Rollback)         -> Just Rollback+                                | otherwise                         -> Nothing+      , iFilters = filters+      , iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]+      , iSelect = toS <$> join (lookup "select" qParams)+      , iOnConflict = toS <$> join (lookup "on_conflict" qParams)+      , iColumns = payloadColumns+      , iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]+      , iCanonicalQS = toS $ urlEncodeVars+        . L.sortOn fst+        . map (join (***) toS . second (fromMaybe BS.empty))+        $ qString+      , iJWT = tokenStr+      , iHeaders = [ (CI.foldedCase k, v) | (k,v) <- hdrs, k /= hCookie]+      , iCookies = maybe [] parseCookies $ lookupHeader "Cookie"+      , iPath = rawPathInfo req+      , iMethod = method+      , iProfile = profile+      , iSchema = schema+      , iAcceptContentType = acceptContentType+      }+ where+  accepts = maybe [CTAny] (map ContentType.decodeContentType . parseHttpAccept) $ lookupHeader "accept"+  -- queryString with '+' converted to ' '(space)+  qString = parseQueryReplacePlus True $ rawQueryString req+  -- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)+  (filters, rpcQParams) =+    case action of+      ActionInvoke InvGet  -> partitionFlts+      ActionInvoke InvHead -> partitionFlts+      _                    -> (flts, [])+  partitionFlts = partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts+  flts =+    [ (toS k, toS $ fromJust v) |+      (k,v) <- qParams, isJust v,+      k `notElem` ["select", "columns"],+      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 = case path of+    PathInfo{pHasRpc, pIsRootSpec} -> pHasRpc || pIsRootSpec+    _                              -> False+  isTargetingDefaultSpec = case path of+    PathInfo{pIsDefaultSpec=True} -> True+    _                             -> False+  contentType = ContentType.decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type"+  columns+    | action `elem` [ActionCreate, ActionUpdate, ActionInvoke InvPost] = toS <$> join (lookup "columns" qParams)+    | otherwise = Nothing+  parsedColumns = pRequestColumns columns+  payloadColumns =+    case (contentType, action) of+      (_, ActionInvoke InvGet)  -> S.fromList $ fst <$> rpcQParams+      (_, ActionInvoke InvHead) -> S.fromList $ fst <$> rpcQParams+      (CTUrlEncoded, _)         -> S.fromList $ map (toS . fst) $ parseSimpleQuery $ toS reqBody+      _ -> case (relevantPayload, fromRight Nothing parsedColumns) of+        (Just ProcessedJSON{pjKeys}, _) -> pjKeys+        (Just RawJSON{}, Just cls)      -> cls+        _                               -> S.empty+  payload = case contentType of+    CTApplicationJSON ->+      if isJust columns+        then Right $ RawJSON reqBody+        else note "All object keys must match" . payloadAttributes reqBody+               =<< if BL.null reqBody && isTargetingProc+                     then Right emptyObject+                     else JSON.eitherDecode reqBody+    CTTextCSV -> do+      json <- csvToJson <$> CSV.decodeByName reqBody+      note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json+    CTUrlEncoded ->+      let paramsMap = M.fromList $ (toS *** JSON.String . toS) <$> parseSimpleQuery (toS reqBody) in+      Right $ ProcessedJSON (JSON.encode paramsMap) $ S.fromList (M.keys paramsMap)+    ct ->+      Left $ toS $ "Content-Type not acceptable: " <> ContentType.toMime ct+  topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges -- if no limit is specified, get all the request rows+  action =+    case method of+      -- The HEAD method is identical to GET except that the server MUST NOT return a message-body in the response+      -- From https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4+      "HEAD"     | isTargetingDefaultSpec -> ActionInspect{isHead=True}+                 | isTargetingProc        -> ActionInvoke InvHead+                 | otherwise              -> ActionRead{isHead=True}+      "GET"      | isTargetingDefaultSpec -> ActionInspect{isHead=False}+                 | isTargetingProc        -> ActionInvoke InvGet+                 | otherwise              -> ActionRead{isHead=False}+      "POST"    -> if isTargetingProc+                    then ActionInvoke InvPost+                    else ActionCreate+      "PATCH"   -> ActionUpdate+      "PUT"     -> ActionSingleUpsert+      "DELETE"  -> ActionDelete+      "OPTIONS" -> ActionInfo+      _         -> ActionInspect{isHead=False}++  defaultSchema = head configDbSchemas+  profile+    | length configDbSchemas <= 1 -- only enable content negotiation by profile when there are multiple schemas specified in the config+      = Nothing+    | otherwise = case action of+        -- POST/PATCH/PUT/DELETE don't use the same header as per the spec+        ActionCreate         -> contentProfile+        ActionUpdate         -> contentProfile+        ActionSingleUpsert   -> contentProfile+        ActionDelete         -> contentProfile+        ActionInvoke InvPost -> contentProfile+        _                    -> acceptProfile+    where+      contentProfile = Just $ maybe defaultSchema toS $ lookupHeader "Content-Profile"+      acceptProfile = Just $ maybe defaultSchema toS $ lookupHeader "Accept-Profile"+  schema = fromMaybe defaultSchema profile+  target =+    let+      callFindProc procSch procNam = findProc (QualifiedIdentifier procSch procNam) payloadColumns (hasPrefer (show SingleObject)) $ dbProcs dbStructure+    in+    case path of+      PathInfo{pSchema, pName, pHasRpc, pIsRootSpec, pIsDefaultSpec}+        | pHasRpc || pIsRootSpec -> (`TargetProc` pIsRootSpec) <$> callFindProc pSchema pName+        | pIsDefaultSpec         -> Right $ TargetDefaultSpec pSchema+        | otherwise              -> Right $ TargetIdent $ QualifiedIdentifier pSchema pName+      PathUnknown -> Right TargetUnknown++  shouldParsePayload = case (contentType, action) of+    (CTUrlEncoded, ActionInvoke InvPost) -> False+    (_, act)                             -> act `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke InvPost]+  relevantPayload = case (contentType, action) of+    -- Though ActionInvoke GET/HEAD doesn't really have a payload, we use the payload variable as a way+    -- to store the query string arguments to the function.+    (_, ActionInvoke InvGet)             -> targetToJsonRpcParams (rightToMaybe target) rpcQParams+    (_, ActionInvoke InvHead)            -> targetToJsonRpcParams (rightToMaybe target) rpcQParams+    (CTUrlEncoded, ActionInvoke InvPost) -> targetToJsonRpcParams (rightToMaybe target) $ (toS *** toS) <$> parseSimpleQuery (toS reqBody)+    _ | shouldParsePayload               -> rightToMaybe payload+      | otherwise                        -> Nothing+  path =+    case pathInfo req of+      []             -> case configDbRootSpec of+                          Just (QualifiedIdentifier pSch pName)     -> PathInfo (if pSch == mempty then schema else pSch) pName False False True+                          Nothing | configOpenApiMode == OADisabled -> PathUnknown+                                  | otherwise                       -> PathInfo schema "" False True False+      [table]        -> PathInfo schema table False False False+      ["rpc", pName] -> PathInfo schema pName True False False+      _              -> PathUnknown+  method          = requestMethod req+  hdrs            = requestHeaders req+  qParams         = [(toS k, v)|(k,v) <- qString]+  lookupHeader    = flip lookup hdrs+  hasPrefer :: Text -> Bool+  hasPrefer val   = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs+    where+        split :: BS.ByteString -> [Text]+        split = map T.strip . T.split (==',') . toS+  representation+    | hasPrefer (show Full)        = Full+    | hasPrefer (show None)        = None+    | hasPrefer (show HeadersOnly) = HeadersOnly+    | otherwise                    = None+  auth = fromMaybe "" $ lookupHeader hAuthorization+  tokenStr = case T.split (== ' ') (toS auth) of+    ("Bearer" : t : _) -> t+    ("bearer" : t : _) -> t+    _                  -> ""+  endingIn:: [Text] -> Text -> Bool+  endingIn xx key = lastWord `elem` xx+    where lastWord = last $ T.split (=='.') key++  headerRange = rangeRequested hdrs+  replaceLast x s = T.intercalate "." $ L.init (T.split (=='.') s) ++ [x]+  limitParams :: M.HashMap ByteString NonnegRange+  limitParams  = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe . toS =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]+  offsetParams :: M.HashMap ByteString NonnegRange+  offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe . toS =<< v)) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]++  urlRange = M.unionWith f limitParams offsetParams+    where+      f rl ro = Range (BoundaryBelow o) (BoundaryAbove $ o + l - 1)+        where+          l = fromMaybe 0 $ rangeLimit rl+          o = rangeOffset ro+  ranges = M.insert "limit" (rangeIntersection headerRange (fromMaybe allRange (M.lookup "limit" urlRange))) urlRange++{-|+  Find the best match from a list of content types accepted by the+  client in order of decreasing preference and a list of types+  producible by the server.  If there is no match but the client+  accepts */* then return the top server pick.+-}+mutuallyAgreeable :: [ContentType] -> [ContentType] -> Maybe ContentType+mutuallyAgreeable sProduces cAccepts =+  let exact = listToMaybe $ L.intersect cAccepts sProduces in+  if isNothing exact && CTAny `elem` cAccepts+     then listToMaybe sProduces+     else exact++type CsvData = V.Vector (M.HashMap Text BL.ByteString)++{-|+  Converts CSV like+  a,b+  1,hi+  2,bye++  into a JSON array like+  [ {"a": "1", "b": "hi"}, {"a": 2, "b": "bye"} ]++  The reason for its odd signature is so that it can compose+  directly with CSV.decodeByName+-}+csvToJson :: (CSV.Header, CsvData) -> JSON.Value+csvToJson (_, vals) =+  JSON.Array $ V.map rowToJsonObj vals+ where+  rowToJsonObj = JSON.Object .+    M.map (\str ->+        if str == "NULL"+          then JSON.Null+          else JSON.String $ toS str+      )++payloadAttributes :: RequestBody -> JSON.Value -> Maybe PayloadJSON+payloadAttributes raw json =+  -- Test that Array contains only Objects having the same keys+  case json of+    JSON.Array arr ->+      case arr V.!? 0 of+        Just (JSON.Object o) ->+          let canonicalKeys = S.fromList $ M.keys o+              areKeysUniform = all (\case+                JSON.Object x -> S.fromList (M.keys x) == canonicalKeys+                _ -> False) arr in+          if areKeysUniform+            then Just $ ProcessedJSON raw canonicalKeys+            else Nothing+        Just _ -> Nothing+        Nothing -> Just emptyPJArray++    JSON.Object o -> Just $ ProcessedJSON raw (S.fromList $ M.keys o)++    -- truncate everything else to an empty array.+    _ -> Just emptyPJArray+  where+    emptyPJArray = ProcessedJSON (JSON.encode emptyArray) S.empty++findAcceptContentType :: AppConfig -> Action -> Path -> [ContentType] -> Either ApiRequestError ContentType+findAcceptContentType conf action path accepts =+  case mutuallyAgreeable (requestContentTypes conf action path) accepts of+    Just ct ->+      Right ct+    Nothing ->+      Left . ContentTypeError $ map ContentType.toMime accepts++requestContentTypes :: AppConfig -> Action -> Path -> [ContentType]+requestContentTypes conf action path =+  case action of+    ActionRead _    -> defaultContentTypes ++ rawContentTypes conf+    ActionInvoke _  -> invokeContentTypes+    ActionInspect _ -> [CTOpenAPI, CTApplicationJSON]+    ActionInfo      -> [CTTextCSV]+    _               -> defaultContentTypes+  where+    invokeContentTypes =+      defaultContentTypes+        ++ rawContentTypes conf+        ++ [CTOpenAPI | pIsRootSpec path]+    defaultContentTypes =+      [CTApplicationJSON, CTSingularJSON, CTTextCSV]++rawContentTypes :: AppConfig -> [ContentType]+rawContentTypes AppConfig{..} =+  (ContentType.decodeContentType <$> configRawMediaTypes) `union` [CTOctetStream, CTTextPlain]++{-|+  Search a pg procedure by its parameters. Since a function can be overloaded, the name is not enough to find it.+  An overloaded function can have a different volatility or even a different return type.+-}+findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> Either ApiRequestError ProcDescription+findProc qi payloadKeys paramsAsSingleObject allProcs =+  case bestMatch of+    []     -> Left $ NoRpc (qiSchema qi) (qiName qi) (S.toList payloadKeys) paramsAsSingleObject+    [proc] -> Right proc+    procs  -> Left $ AmbiguousRpc (toList procs)+  where+    bestMatch =+      case M.lookup qi allProcs of+        Nothing     -> []+        Just [proc] -> [proc | matches proc]+        Just procs  -> filter matches procs+    -- Find the exact arguments match+    matches proc+      | paramsAsSingleObject = case pdArgs proc of+                               [arg] -> pgaType arg `elem` ["json", "jsonb"]+                               _     -> False+      | otherwise            = case pdArgs proc of+                               []   -> null payloadKeys+                               args -> matchesArg args+    matchesArg args =+      -- The function's required arguments are separated from the ones with a default value assigned.+      -- The set of names of those arguments is compared to the set of keys supplied by the client+      -- 1. If only required arguments are found, the keys must be exactly the same as those arguments+      -- 2. If only optional arguments are found, the keys must be a subset of those arguments+      -- 3. If both required and optional arguments are found, the result of taking away the optional arguments+      --    from the keys must be exactly the same as the required arguments+      case L.partition pgaReq args of+        (reqArgs, [])      -> payloadKeys == S.fromList (pgaName <$> reqArgs)+        ([], defArgs)      -> payloadKeys `S.isSubsetOf` S.fromList (pgaName <$> defArgs)+        (reqArgs, defArgs) -> payloadKeys `S.difference` S.fromList (pgaName <$> defArgs) == S.fromList (pgaName <$> reqArgs)
+ src/PostgREST/Request/DbRequestBuilder.hs view
@@ -0,0 +1,376 @@+{-|+Module      : PostgREST.Request.DbRequestBuilder+Description : PostgREST database request builder++This module is in charge of building an intermediate+representation(ReadRequest, MutateRequest) between the HTTP request and the+final resulting SQL query.++A query tree is built in case of resource embedding. By inferring the+relationship between tables, join conditions are added for every embedded+resource.+-}+{-# LANGUAGE DuplicateRecordFields #-}+{-# LANGUAGE LambdaCase            #-}+{-# LANGUAGE NamedFieldPuns        #-}+{-# LANGUAGE RecordWildCards       #-}++module PostgREST.Request.DbRequestBuilder+  ( readRequest+  , mutateRequest+  , returningCols+  ) where++import qualified Data.HashMap.Strict as M+import qualified Data.Set            as S++import Control.Arrow           ((***))+import Data.Either.Combinators (mapLeft)+import Data.List               (delete)+import Data.Text               (isInfixOf)+import Data.Tree               (Tree (..))++import PostgREST.DbStructure.Identifiers  (FieldName,+                                           QualifiedIdentifier (..),+                                           Schema, TableName)+import PostgREST.DbStructure.Relationship (Cardinality (..),+                                           Junction (..),+                                           Relationship (..))+import PostgREST.DbStructure.Table        (Column (..), Table (..),+                                           tableQi)+import PostgREST.Error                    (ApiRequestError (..),+                                           Error (..))+import PostgREST.Query.SqlFragment        (sourceCTEName)+import PostgREST.RangeQuery               (NonnegRange, allRange,+                                           restrictRange)+import PostgREST.Request.ApiRequest       (Action (..),+                                           ApiRequest (..),+                                           PayloadJSON (..))++import PostgREST.Request.Parsers+import PostgREST.Request.Preferences+import PostgREST.Request.Types++import qualified PostgREST.DbStructure.Relationship as Relationship++import Protolude hiding (from)++-- | Builds the ReadRequest tree on a number of stages.+-- | Adds filters, order, limits on its respective nodes.+-- | Adds joins conditions obtained from resource embedding.+readRequest :: Schema -> TableName -> Maybe Integer -> [Relationship] -> ApiRequest -> Either Error ReadRequest+readRequest schema rootTableName maxRows allRels apiRequest  =+  mapLeft ApiRequestError $+  treeRestrictRange maxRows =<<+  augmentRequestWithJoin schema rootRels =<<+  (addFiltersOrdersRanges apiRequest . initReadRequest rootName =<< pRequestSelect sel)+  where+    sel = fromMaybe "*" $ iSelect apiRequest -- default to all columns requested (SELECT *) for a non existent ?select querystring param+    (rootName, rootRels) = rootWithRels schema rootTableName allRels (iAction apiRequest)++-- Get the root table name with its relationships according to the Action type.+-- This is done because of the shape of the final SQL Query. The mutation cases+-- are wrapped in a WITH {sourceCTEName}(see Statements.hs).  So we need a FROM+-- {sourceCTEName} instead of FROM {tableName}.+rootWithRels :: Schema -> TableName -> [Relationship] -> Action -> (QualifiedIdentifier, [Relationship])+rootWithRels schema rootTableName allRels action = case action of+  ActionRead _ -> (QualifiedIdentifier schema rootTableName, allRels) -- normal read case+  _            -> (QualifiedIdentifier mempty _sourceCTEName, mapMaybe toSourceRel allRels ++ allRels) -- mutation cases and calling proc+  where+    _sourceCTEName = decodeUtf8 sourceCTEName+    -- To enable embedding in the sourceCTEName cases we need to replace the+    -- foreign key tableName in the Relationship with {sourceCTEName}. This way+    -- findRel can find relationships with sourceCTEName.+    toSourceRel :: Relationship -> Maybe Relationship+    toSourceRel r@Relationship{relTable=t}+      | rootTableName == tableName t = Just $ r {relTable=t {tableName=_sourceCTEName}}+      | otherwise                    = Nothing++-- Build the initial tree with a Depth attribute so when a self join occurs we+-- can differentiate the parent and child tables by having an alias like+-- "table_depth", this is related to+-- http://github.com/PostgREST/postgrest/issues/987.+initReadRequest :: QualifiedIdentifier -> [Tree SelectItem] -> ReadRequest+initReadRequest rootQi =+  foldr (treeEntry rootDepth) initial+  where+    rootDepth = 0+    rootSchema = qiSchema rootQi+    rootName = qiName rootQi+    initial = Node (Select [] rootQi Nothing [] [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, rootDepth)) []+    treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest+    treeEntry depth (Node fld@((fn, _),_,alias, embedHint) fldForest) (Node (q, i) rForest) =+      let nxtDepth = succ depth in+      case fldForest of+        [] -> Node (q {select=fld:select q}, i) rForest+        _  -> Node (q, i) $+              foldr (treeEntry nxtDepth)+              (Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] [] allRange,+                (fn, Nothing, alias, embedHint, nxtDepth)) [])+              fldForest:rForest++-- | Enforces the `max-rows` config on the result+treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest+treeRestrictRange maxRows request = pure $ nodeRestrictRange maxRows <$> request+  where+    nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode+    nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)++augmentRequestWithJoin :: Schema -> [Relationship] -> ReadRequest -> Either ApiRequestError ReadRequest+augmentRequestWithJoin schema allRels request =+  addRels schema allRels Nothing request+  >>= addJoinConditions Nothing++addRels :: Schema -> [Relationship] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest+addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, depth)) forest) =+  case parentNode of+    Just (Node (Select{from=parentNodeQi}, _) _) ->+      let newFrom r = if qiName tbl == nodeName then tableQi (relForeignTable r) else tbl+          newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel+          rel = findRel schema allRels (qiName parentNodeQi) nodeName hint+      in+      Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)+    _ ->+      let rn = (query, (nodeName, Nothing, alias, Nothing, depth)) in+      Node rn <$> updateForest (Just $ Node rn forest)+  where+    updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]+    updateForest rq = addRels schema allRels rq `traverse` forest++-- Finds a relationship between an origin and a target in the request:+-- /origin?select=target(*) If more than one relationship is found then the+-- request is ambiguous and we return an error.  In that case the request can+-- be disambiguated by adding precision to the target or by using a hint:+-- /origin?select=target!hint(*) The elements will be matched according to+-- these rules:+-- origin = table / view+-- target = table / view / constraint / column-from-origin+-- hint   = table / view / constraint / column-from-origin / column-from-target+-- (hint can take table / view values to aid in finding the junction in an m2m relationship)+findRel :: Schema -> [Relationship] -> NodeName -> NodeName -> Maybe EmbedHint -> Either ApiRequestError Relationship+findRel schema allRels origin target hint =+  case rel of+    []  -> Left $ NoRelBetween origin target+    [r] -> Right r+    -- Here we handle a self reference relationship to not cause a breaking+    -- change: In a self reference we get two relationships with the same+    -- foreign key and relTable/relFtable but with different+    -- cardinalities(m2o/o2m) We output the O2M rel, the M2O rel can be+    -- obtained by using the origin column as an embed hint.+    rs@[rel0, rel1]  -> case (relCardinality rel0, relCardinality rel1, relTable rel0 == relTable rel1 && relForeignTable rel0 == relForeignTable rel1) of+      (O2M cons1, M2O cons2, True) -> if cons1 == cons2 then Right rel0 else Left $ AmbiguousRelBetween origin target rs+      (M2O cons1, O2M cons2, True) -> if cons1 == cons2 then Right rel1 else Left $ AmbiguousRelBetween origin target rs+      _                            -> Left $ AmbiguousRelBetween origin target rs+    rs -> Left $ AmbiguousRelBetween origin target rs+  where+    matchFKSingleCol hint_ cols = length cols == 1 && hint_ == (colName <$> head cols)+    matchConstraint tar card = case card of+      O2M cons -> tar == Just cons+      M2O cons -> tar == Just cons+      _        -> False+    matchJunction hint_ card = case card of+      M2M Junction{junTable} -> hint_ == Just (tableName junTable)+      _                      -> False+    rel = filter (+      \Relationship{..} ->+        -- Both relationship ends need to be on the exposed schema+        schema == tableSchema relTable && schema == tableSchema relForeignTable &&+        (+          -- /projects?select=clients(*)+          origin == tableName relTable  &&  -- projects+          target == tableName relForeignTable ||  -- clients++          -- /projects?select=projects_client_id_fkey(*)+          (+            origin == tableName relTable &&              -- projects+            matchConstraint (Just target) relCardinality -- projects_client_id_fkey+          ) ||+          -- /projects?select=client_id(*)+          (+            origin == tableName relTable &&           -- projects+            matchFKSingleCol (Just target) relColumns -- client_id+          )+        ) && (+          isNothing hint || -- hint is optional++          -- /projects?select=clients!projects_client_id_fkey(*)+          matchConstraint hint relCardinality || -- projects_client_id_fkey++          -- /projects?select=clients!client_id(*) or /projects?select=clients!id(*)+          matchFKSingleCol hint relColumns  || -- client_id+          matchFKSingleCol hint relForeignColumns || -- id++          -- /users?select=tasks!users_tasks(*) many-to-many between users and tasks+          matchJunction hint relCardinality -- users_tasks+        )+      ) allRels++-- previousAlias is only used for the case of self joins+addJoinConditions :: Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest+addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, rel, _, _, depth)) forest) =+  case rel of+    Just r@Relationship{relCardinality=M2M Junction{junTable}} ->+      let rq = augmentQuery r in+      Node (rq{implicitJoins=tableQi junTable:implicitJoins rq}, nodeProps) <$> updatedForest+    Just r -> Node (augmentQuery r, nodeProps) <$> updatedForest+    Nothing -> Node node <$> updatedForest+  where+    newAlias = case Relationship.isSelfReference <$> rel of+      Just True+        | depth /= 0 -> Just (qiName tbl <> "_" <> show depth) -- root node doesn't get aliased+        | otherwise  -> Nothing+      _              -> Nothing+    augmentQuery r =+      foldr+        (\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})+        query{fromAlias=newAlias}+        (getJoinConditions previousAlias newAlias r)+    updatedForest = addJoinConditions newAlias `traverse` forest++-- previousAlias and newAlias are used in the case of self joins+getJoinConditions :: Maybe Alias -> Maybe Alias -> Relationship -> [JoinCondition]+getJoinConditions previousAlias newAlias (Relationship Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols card) =+  case card of+    M2M (Junction Table{tableName=jtn} _ jc1 _ jc2) ->+      zipWith (toJoinCondition tN jtn) cols jc1 ++ zipWith (toJoinCondition ftN jtn) fCols jc2+    _ ->+      zipWith (toJoinCondition tN ftN) cols fCols+  where+    toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition+    toJoinCondition tb ftb c fc =+      let qi1 = removeSourceCTESchema tSchema tb+          qi2 = removeSourceCTESchema tSchema ftb in+        JoinCondition (maybe qi1 (QualifiedIdentifier mempty) previousAlias, colName c)+                      (maybe qi2 (QualifiedIdentifier mempty) newAlias, colName fc)++    -- On mutation and calling proc cases we wrap the target table in a WITH+    -- {sourceCTEName} if this happens remove the schema `FROM+    -- "schema"."{sourceCTEName}"` and use only the `FROM "{sourceCTEName}"`.+    -- If the schema remains the FROM would be invalid.+    removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier+    removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == decodeUtf8 sourceCTEName then mempty else schema) tbl++addFiltersOrdersRanges :: ApiRequest -> ReadRequest -> Either ApiRequestError ReadRequest+addFiltersOrdersRanges apiRequest rReq = do+  rFlts <- foldr addFilter rReq <$> filters+  rOrds <- foldr addOrder rFlts <$> orders+  rRngs <- foldr addRange rOrds <$> ranges+  foldr addLogicTree rRngs      <$> logicForest+  where+    filters :: Either ApiRequestError [(EmbedPath, Filter)]+    filters = pRequestFilter `traverse` flts+    orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]+    orders = pRequestOrder `traverse` iOrder apiRequest+    ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]+    ranges = pRequestRange `traverse` M.toList (iRange apiRequest)+    logicForest :: Either ApiRequestError [(EmbedPath, LogicTree)]+    logicForest = pRequestLogicTree `traverse` logFrst+    action = iAction apiRequest+    -- there can be no filters on the root table when we are doing insert/update/delete+    (flts, logFrst) =+      case action of+        ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)+        ActionRead _   -> (iFilters apiRequest, iLogic apiRequest)+        _              -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)++addFilterToNode :: Filter -> ReadRequest -> ReadRequest+addFilterToNode flt (Node (q@Select {where_=lf}, i) f) = Node (q{where_=addFilterToLogicForest flt lf}::ReadQuery, i) f++addFilter :: (EmbedPath, Filter) -> ReadRequest -> ReadRequest+addFilter = addProperty addFilterToNode++addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest+addOrderToNode o (Node (q,i) f) = Node (q{order=o}, i) f++addOrder :: (EmbedPath, [OrderTerm]) -> ReadRequest -> ReadRequest+addOrder = addProperty addOrderToNode++addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest+addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f++addRange :: (EmbedPath, NonnegRange) -> ReadRequest -> ReadRequest+addRange = addProperty addRangeToNode++addLogicTreeToNode :: LogicTree -> ReadRequest -> ReadRequest+addLogicTreeToNode t (Node (q@Select{where_=lf},i) f) = Node (q{where_=t:lf}::ReadQuery, i) f++addLogicTree :: (EmbedPath, LogicTree) -> ReadRequest -> ReadRequest+addLogicTree = addProperty addLogicTreeToNode++addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest+addProperty f ([], a) rr = f a rr+addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =+  case pathNode of+    Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path+    Just tn -> Node rn (addProperty f (remainingPath, a) tn:delete tn forest)+  where+    pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest++mutateRequest :: Schema -> TableName -> ApiRequest -> [FieldName] -> ReadRequest -> Either Error MutateRequest+mutateRequest schema tName apiRequest pkCols readReq = mapLeft ApiRequestError $+  case action of+    ActionCreate -> do+        confCols <- case iOnConflict apiRequest of+            Nothing    -> pure pkCols+            Just param -> pRequestOnConflict param+        pure $ Insert qi (iColumns apiRequest) body ((,) <$> iPreferResolution apiRequest <*> Just confCols) [] returnings+    ActionUpdate -> Update qi (iColumns apiRequest) body  <$> combinedLogic <*> pure returnings+    ActionSingleUpsert ->+      (\flts ->+        if null (iLogic apiRequest) &&+           S.fromList (fst <$> iFilters apiRequest) == S.fromList pkCols &&+           not (null (S.fromList pkCols)) &&+           all (\case+              Filter _ (OpExpr False (Op "eq" _)) -> True+              _                                   -> False) flts+          then Insert qi (iColumns apiRequest) body (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings+        else+          Left InvalidFilters) =<< filters+    ActionDelete -> Delete qi <$> combinedLogic <*> pure returnings+    _            -> Left UnsupportedVerb+  where+    qi = QualifiedIdentifier schema tName+    action = iAction apiRequest+    returnings =+      if iPreferRepresentation apiRequest == None+        then []+        else returningCols readReq pkCols+    filters = map snd <$> pRequestFilter `traverse` mutateFilters+    logic = map snd <$> pRequestLogicTree `traverse` logicFilters+    combinedLogic = foldr addFilterToLogicForest <$> logic <*> filters+    -- update/delete filters can be only on the root table+    (mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)+    onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)+    body = pjRaw <$> iPayload apiRequest++returningCols :: ReadRequest -> [FieldName] -> [FieldName]+returningCols rr@(Node _ forest) pkCols+  -- if * is part of the select, we must not add pk or fk columns manually -+  -- otherwise those would be selected and output twice+  | "*" `elem` fldNames = ["*"]+  | otherwise           = returnings+  where+    fldNames = fstFieldNames rr+    -- Without fkCols, when a mutateRequest to+    -- /projects?select=name,clients(name) occurs, the RETURNING SQL part would+    -- be `RETURNING name`(see QueryBuilder).  This would make the embedding+    -- fail because the following JOIN would need the "client_id" column from+    -- projects.  So this adds the foreign key columns to ensure the embedding+    -- succeeds, result would be `RETURNING name, client_id`.+    fkCols = concat $ mapMaybe (\case+        Node (_, (_, Just Relationship{relColumns=cols}, _, _, _)) _ -> Just cols+        _                                                        -> Nothing+      ) forest+    -- However if the "client_id" is present, e.g. mutateRequest to+    -- /projects?select=client_id,name,clients(name) we would get `RETURNING+    -- client_id, name, client_id` and then we would produce the "column+    -- reference \"client_id\" is ambiguous" error from PostgreSQL. So we+    -- deduplicate with Set: We are adding the primary key columns as well to+    -- make sure, that a proper location header can always be built for+    -- INSERT/POST+    returnings = S.toList . S.fromList $ fldNames ++ (colName <$> fkCols) ++ pkCols++-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree+-- they are later concatenated with AND in the QueryBuilder+addFilterToLogicForest :: Filter -> [LogicTree] -> [LogicTree]+addFilterToLogicForest flt lf = Stmnt flt : lf
+ src/PostgREST/Request/Parsers.hs view
@@ -0,0 +1,276 @@+{-|+Module      : PostgREST.Request.Parsers+Description : PostgREST parser combinators++This module is in charge of parsing all the querystring values in an url, e.g. the select, id, order in `/projects?select=id,name&id=eq.1&order=id,name.desc`.+-}+module PostgREST.Request.Parsers+  ( pColumns+  , pLogicPath+  , pLogicSingleVal+  , pLogicTree+  , pOrder+  , pOrderTerm+  , pRequestColumns+  , pRequestFilter+  , pRequestLogicTree+  , pRequestOnConflict+  , pRequestOrder+  , pRequestRange+  , pRequestSelect+  , pSingleVal+  , pTreePath+  ) where++import qualified Data.HashMap.Strict as M+import qualified Data.Set            as S++import Data.Either.Combinators       (mapLeft)+import Data.Foldable                 (foldl1)+import Data.List                     (init, last)+import Data.Text                     (intercalate, replace, strip)+import Data.Tree                     (Tree (..))+import Text.Parsec.Error             (errorMessages,+                                      showErrorMessages)+import Text.ParserCombinators.Parsec (GenParser, ParseError, Parser,+                                      anyChar, between, char, digit,+                                      eof, errorPos, letter,+                                      lookAhead, many1, noneOf,+                                      notFollowedBy, oneOf, option,+                                      optionMaybe, parse, sepBy1,+                                      string, try, (<?>))++import PostgREST.DbStructure.Identifiers (FieldName)+import PostgREST.Error                   (ApiRequestError (ParseRequestError))+import PostgREST.Query.SqlFragment       (ftsOperators, operators)+import PostgREST.RangeQuery              (NonnegRange)++import PostgREST.Request.Types++import Protolude      hiding (intercalate, option, replace, toS, try)+import Protolude.Conv (toS)++pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]+pRequestSelect selStr =+  mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)++pRequestOnConflict :: Text -> Either ApiRequestError  [FieldName]+pRequestOnConflict oncStr =+  mapError $ parse pColumns ("failed to parse on_conflict parameter (" <> toS oncStr <> ")") (toS oncStr)++pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)+pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)+  where+    treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k+    oper = parse (pOpExpr pSingleVal) ("failed to parse filter (" ++ toS v ++ ")") $ toS v+    path = fst <$> treePath+    fld = snd <$> treePath++pRequestOrder :: (Text, Text) -> Either ApiRequestError (EmbedPath, [OrderTerm])+pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord'+  where+    treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k+    path = fst <$> treePath+    ord' = parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v++pRequestRange :: (ByteString, NonnegRange) -> Either ApiRequestError (EmbedPath, NonnegRange)+pRequestRange (k, v) = mapError $ (,) <$> path <*> pure v+  where+    treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k+    path = fst <$> treePath++pRequestLogicTree :: (Text, Text) -> Either ApiRequestError (EmbedPath, LogicTree)+pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree+  where+    path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k+    embedPath = fst <$> path+    logicTree = do+      op <- snd <$> path+      -- Concat op and v to make pLogicTree argument regular,+      -- in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)"+      parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") $ toS (op <> v)++pRequestColumns :: Maybe Text -> Either ApiRequestError (Maybe (S.Set FieldName))+pRequestColumns colStr =+  case colStr of+    Just str ->+      mapError $ Just . S.fromList <$> parse pColumns ("failed to parse columns parameter (" <> toS str <> ")") (toS str)+    _ -> Right Nothing++ws :: Parser Text+ws = toS <$> many (oneOf " \t")++lexeme :: Parser a -> Parser a+lexeme p = ws *> p <* ws++pTreePath :: Parser (EmbedPath, Field)+pTreePath = do+  p <- pFieldName `sepBy1` pDelimiter+  jp <- option [] pJsonPath+  return (init p, (last p, jp))++pFieldForest :: Parser [Tree SelectItem]+pFieldForest = pFieldTree `sepBy1` lexeme (char ',')+  where+    pFieldTree :: Parser (Tree SelectItem)+    pFieldTree =  try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) <|>+                  Node <$> pFieldSelect <*> pure []++pStar :: Parser Text+pStar = toS <$> (string "*" $> ("*"::ByteString))++pFieldName :: Parser Text+pFieldName =+  pQuotedValue <|>+  intercalate "-" . map toS <$> (many1 (letter <|> digit <|> oneOf "_ ") `sepBy1` dash) <?>+  "field name (* or [a..z0..9_])"+  where+    isDash :: GenParser Char st ()+    isDash = try ( char '-' >> notFollowedBy (char '>') )+    dash :: Parser Char+    dash = isDash $> '-'++pJsonPath :: Parser JsonPath+pJsonPath = many pJsonOperation+  where+    pJsonOperation :: Parser JsonOperation+    pJsonOperation = pJsonArrow <*> pJsonOperand++    pJsonArrow   =+      try (string "->>" $> J2Arrow) <|>+      try (string "->" $> JArrow)++    pJsonOperand =+      let pJKey = JKey . toS <$> pFieldName+          pJIdx = JIdx . toS <$> ((:) <$> option '+' (char '-') <*> many1 digit) <* pEnd+          pEnd = try (void $ lookAhead (string "->")) <|>+                 try (void $ lookAhead (string "::")) <|>+                 try eof in+      try pJIdx <|> try pJKey++pField :: Parser Field+pField = lexeme $ (,) <$> pFieldName <*> option [] pJsonPath++aliasSeparator :: Parser ()+aliasSeparator = char ':' >> notFollowedBy (char ':')++pRelationSelect :: Parser SelectItem+pRelationSelect = lexeme $ try ( do+    alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )+    fld <- pField+    hint <- optionMaybe (+        try ( char '!' *> pFieldName) <|>+        -- deprecated, remove in next major version+        try ( char '.' *> pFieldName)+      )+    return (fld, Nothing, alias, hint)+  )++pFieldSelect :: Parser SelectItem+pFieldSelect = lexeme $+  try (+    do+      alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )+      fld <- pField+      cast' <- optionMaybe (string "::" *> many letter)+      return (fld, toS <$> cast', alias, Nothing)+  )+  <|> do+    s <- pStar+    return ((s, []), Nothing, Nothing, Nothing)++pOpExpr :: Parser SingleVal -> Parser OpExpr+pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation+  where+    pOperation :: Parser Operation+    pOperation =+          Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal+      <|> In <$> (try (string "in" *> pDelimiter) *> pListVal)+      <|> pFts+      <?> "operator (eq, gt, ...)"++    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++    ops = M.filterWithKey (const . flip notElem ("in":ftsOps)) operators+    ftsOps = M.keys ftsOperators++pSingleVal :: Parser SingleVal+pSingleVal = toS <$> many anyChar++pListVal :: Parser ListVal+pListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')++pListElement :: Parser Text+pListElement = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> (toS <$> many (noneOf ",)"))++pQuotedValue :: Parser Text+pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"')++pDelimiter :: Parser Char+pDelimiter = char '.' <?> "delimiter (.)"++pOrder :: Parser [OrderTerm]+pOrder = lexeme pOrderTerm `sepBy1` char ','++pOrderTerm :: Parser OrderTerm+pOrderTerm = do+  fld <- pField+  dir <- optionMaybe $+         try (pDelimiter *> string "asc" $> OrderAsc) <|>+         try (pDelimiter *> string "desc" $> OrderDesc)+  nls <- optionMaybe pNulls <* pEnd <|>+         pEnd $> Nothing+  return $ OrderTerm fld dir nls+  where+    pNulls = try (pDelimiter *> string "nullsfirst" $> OrderNullsFirst) <|>+             try (pDelimiter *> string "nullslast"  $> OrderNullsLast)+    pEnd = try (void $ lookAhead (char ',')) <|>+           try eof++pLogicTree :: Parser LogicTree+pLogicTree = Stmnt <$> try pLogicFilter+             <|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')'))+  where+    pLogicFilter :: Parser Filter+    pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal+    pNot :: Parser Bool+    pNot = try (string "not" *> pDelimiter $> True)+           <|> pure False+           <?> "negation operator (not)"+    pLogicOp :: Parser LogicOperator+    pLogicOp = try (string "and" $> And)+               <|> string "or" $> Or+               <?> "logic operator (and, or)"++pLogicSingleVal :: Parser SingleVal+pLogicSingleVal = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> try pPgArray <|> (toS <$> many (noneOf ",)"))+  where+    pPgArray :: Parser Text+    pPgArray =  do+      a <- string "{"+      b <- many (noneOf "{}")+      c <- string "}"+      pure (toS $ a ++ b ++ c)++pLogicPath :: Parser (EmbedPath, Text)+pLogicPath = do+  path <- pFieldName `sepBy1` pDelimiter+  let op = last path+      notOp = "not." <> op+  return (filter (/= "not") (init path), if "not" `elem` path then notOp else op)++pColumns :: Parser [FieldName]+pColumns = pFieldName `sepBy1` lexeme (char ',')++mapError :: Either ParseError a -> Either ApiRequestError a+mapError = mapLeft translateError+  where+    translateError e =+      ParseRequestError message details+      where+        message = show $ errorPos e+        details = strip $ replace "\n" " " $ toS+           $ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
+ src/PostgREST/Request/Preferences.hs view
@@ -0,0 +1,54 @@+module PostgREST.Request.Preferences where++import GHC.Show+import Protolude+++data PreferResolution+  = MergeDuplicates+  | IgnoreDuplicates++instance Show PreferResolution where+  show MergeDuplicates  = "resolution=merge-duplicates"+  show IgnoreDuplicates = "resolution=ignore-duplicates"++-- | How to return the mutated data. From https://tools.ietf.org/html/rfc7240#section-4.2+data PreferRepresentation+  = Full        -- ^ Return the body plus the Location header(in case of POST).+  | HeadersOnly -- ^ Return the Location header(in case of POST). This needs a SELECT privilege on the pk.+  | None        -- ^ Return nothing from the mutated data.+  deriving Eq++instance Show PreferRepresentation where+  show Full        = "return=representation"+  show None        = "return=minimal"+  show HeadersOnly = "return=headers-only"++data PreferParameters+  = SingleObject    -- ^ Pass all parameters as a single json object to a stored procedure+  | MultipleObjects -- ^ Pass an array of json objects as params to a stored procedure+  deriving Eq++instance Show PreferParameters where+  show SingleObject    = "params=single-object"+  show MultipleObjects = "params=multiple-objects"++data PreferCount+  = ExactCount     -- ^ exact count(slower)+  | PlannedCount   -- ^ PostgreSQL query planner rows count guess. Done by using EXPLAIN {query}.+  | EstimatedCount -- ^ use the query planner rows if the count is superior to max-rows, otherwise get the exact count.+  deriving Eq++instance Show PreferCount where+  show ExactCount     = "count=exact"+  show PlannedCount   = "count=planned"+  show EstimatedCount = "count=estimated"++data PreferTransaction+  = Commit   -- Commit transaction - the default.+  | Rollback -- Rollback transaction after sending the response - does not persist changes, e.g. for running tests.+  deriving Eq++instance Show PreferTransaction where+  show Commit   = "tx=commit"+  show Rollback = "tx=rollback"
+ src/PostgREST/Request/Types.hs view
@@ -0,0 +1,208 @@+{-# LANGUAGE DuplicateRecordFields #-}+module PostgREST.Request.Types+  ( Alias+  , Depth+  , EmbedHint+  , EmbedPath+  , Field+  , Filter(..)+  , JoinCondition(..)+  , JsonOperand(..)+  , JsonOperation(..)+  , JsonPath+  , ListVal+  , LogicOperator(..)+  , LogicTree(..)+  , MutateQuery(..)+  , MutateRequest+  , NodeName+  , OpExpr(..)+  , Operation (..)+  , OrderDirection(..)+  , OrderNulls(..)+  , OrderTerm(..)+  , ReadNode+  , ReadQuery(..)+  , ReadRequest+  , SelectItem+  , SingleVal+  , fstFieldNames+  ) where++import qualified Data.ByteString.Lazy as BL+import qualified Data.Set             as S++import Data.Tree (Tree (..))++import qualified GHC.Show (show)++import PostgREST.DbStructure.Identifiers  (FieldName,+                                           QualifiedIdentifier)+import PostgREST.DbStructure.Relationship (Relationship)+import PostgREST.RangeQuery               (NonnegRange)+import PostgREST.Request.Preferences      (PreferResolution)++import Protolude+++type ReadRequest = Tree ReadNode+type MutateRequest = MutateQuery++type ReadNode =+  (ReadQuery, (NodeName, Maybe Relationship, Maybe Alias, Maybe EmbedHint, Depth))++type NodeName = Text+type Depth = Integer++data ReadQuery = Select+  { select         :: [SelectItem]+  , from           :: QualifiedIdentifier+  -- ^ A table alias is used in case of self joins+  , fromAlias      :: Maybe Alias+  -- ^ Only used for Many to Many joins. Parent and Child joins use explicit joins.+  , implicitJoins  :: [QualifiedIdentifier]+  , where_         :: [LogicTree]+  , joinConditions :: [JoinCondition]+  , order          :: [OrderTerm]+  , range_         :: NonnegRange+  }+  deriving (Eq)++data JoinCondition =+  JoinCondition+    (QualifiedIdentifier, FieldName)+    (QualifiedIdentifier, FieldName)+  deriving (Eq)++data OrderTerm = OrderTerm+  { otTerm      :: Field+  , otDirection :: Maybe OrderDirection+  , otNullOrder :: Maybe OrderNulls+  }+  deriving (Eq)++data OrderDirection+  = OrderAsc+  | OrderDesc+  deriving (Eq)++instance Show OrderDirection where+  show OrderAsc  = "ASC"+  show OrderDesc = "DESC"++data OrderNulls+  = OrderNullsFirst+  | OrderNullsLast+  deriving (Eq)++instance Show OrderNulls where+  show OrderNullsFirst = "NULLS FIRST"+  show OrderNullsLast  = "NULLS LAST"++data MutateQuery+  = Insert+      { in_        :: QualifiedIdentifier+      , insCols    :: S.Set FieldName+      , insBody    :: Maybe BL.ByteString+      , onConflict :: Maybe (PreferResolution, [FieldName])+      , where_     :: [LogicTree]+      , returning  :: [FieldName]+      }+  | Update+      { in_       :: QualifiedIdentifier+      , updCols   :: S.Set FieldName+      , updBody   :: Maybe BL.ByteString+      , where_    :: [LogicTree]+      , returning :: [FieldName]+      }+  | Delete+      { in_       :: QualifiedIdentifier+      , where_    :: [LogicTree]+      , returning :: [FieldName]+      }++-- | The select value in `/tbl?select=alias:field::cast`+type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe EmbedHint)++type Field = (FieldName, JsonPath)+type Cast = Text+type Alias = Text++-- | Disambiguates an embedding operation when there's multiple relationships+-- between two tables. Can be the name of a foreign key constraint, column+-- name or the junction in an m2m relationship.+type EmbedHint = Text++-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path+-- ["clients", "projects"]+type EmbedPath = [Text]++-- | Json path operations as specified in+-- https://www.postgresql.org/docs/current/static/functions-json.html+type JsonPath = [JsonOperation]++-- | Represents the single arrow `->` or double arrow `->>` operators+data JsonOperation+  = JArrow { jOp :: JsonOperand }+  | J2Arrow { jOp :: JsonOperand }+  deriving (Eq)++-- | Represents the key(`->'key'`) or index(`->'1`::int`), the index is Text+-- because we reuse our escaping functons and let pg do the casting with+-- '1'::int+data JsonOperand+  = JKey { jVal :: Text }+  | JIdx { jVal :: Text }+  deriving (Eq)++-- First level FieldNames(e.g get a,b from /table?select=a,b,other(c,d))+fstFieldNames :: ReadRequest -> [FieldName]+fstFieldNames (Node (sel, _) _) =+  fst . (\(f, _, _, _) -> f) <$> select sel+++-- | Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:+--+--            And+--           /   \+--  name.eq.N     Or+--               /  \+--         id.eq.1   id.eq.2+data LogicTree+  = Expr Bool LogicOperator [LogicTree]+  | Stmnt Filter+  deriving (Eq)++data LogicOperator+  = And+  | Or+  deriving Eq++instance Show LogicOperator where+  show And = "AND"+  show Or  = "OR"++data Filter = Filter+  { field  :: Field+  , opExpr :: OpExpr+  }+  deriving (Eq)++data OpExpr =+  OpExpr Bool Operation+  deriving (Eq)++data Operation+  = Op Operator SingleVal+  | In ListVal+  | Fts Operator (Maybe Language) SingleVal+  deriving (Eq)++type Operator = Text+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]
− src/PostgREST/Statements.hs
@@ -1,183 +0,0 @@-{-|-Module      : PostgREST.Statements-Description : PostgREST single SQL statements.--This module constructs single SQL statements that can be parametrized and prepared.--- It consumes the SqlQuery types generated by the QueryBuilder module.-- It generates the body format and some headers of the final HTTP response.--TODO: Currently, createReadStatement is not using prepared statements. See https://github.com/PostgREST/postgrest/issues/718.--}-module PostgREST.Statements (-    createWriteStatement-  , createReadStatement-  , callProcStatement-  , createExplainStatement-) where---import           Control.Lens                    ((^?))-import           Data.Aeson                      as JSON-import qualified Data.Aeson.Lens                 as L-import qualified Data.ByteString.Char8           as BS-import           Data.Maybe-import qualified Hasql.Decoders                  as HD-import qualified Hasql.Encoders                  as HE-import qualified Hasql.Statement                 as H-import           PostgREST.Private.Common-import           PostgREST.Private.QueryFragment-import           PostgREST.Types-import           Protolude                       hiding (cast,-                                                  replace, toS)-import           Protolude.Conv                  (toS)-import           Text.InterpolatedString.Perl6   (qc)--{-| The generic query result format used by API responses. The location header-    is represented as a list of strings containing variable bindings like-    @"k1=eq.42"@, or the empty list if there is no location header.--}-type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString, Either Text [GucHeader])--createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->-                        PreferRepresentation -> [Text] -> PgVersion ->-                        H.Statement ByteString ResultsWithCount-createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys pgVer =-  unicodeStatement sql (param HE.unknown) decodeStandard True- where-  sql = [qc|-      WITH-      {sourceCTEName} AS ({mutateQuery})-      SELECT-        '' AS total_result_set,-        pg_catalog.count(_postgrest_t) AS page_total,-        {locF} AS header,-        {bodyF} AS body,-        {responseHeadersF pgVer} AS response_headers-      FROM ({selectF}) _postgrest_t |]--  locF =-    if isInsert && rep `elem` [Full, HeadersOnly]-      then unwords [-        "CASE WHEN pg_catalog.count(_postgrest_t) = 1",-          "THEN coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")",-          "ELSE " <> noLocationF,-        "END"]-      else noLocationF--  bodyF-    | rep `elem` [None, HeadersOnly] = "''"-    | asCsv = asCsvF-    | wantSingle = asJsonSingleF-    | otherwise = asJsonF--  selectF-    -- prevent using any of the column names in ?select= when no response is returned from the CTE-    | rep `elem` [None, HeadersOnly] = "SELECT * FROM " <> sourceCTEName-    | otherwise                      = selectQuery--  decodeStandard :: HD.Result ResultsWithCount-  decodeStandard =-   fromMaybe (Nothing, 0, [], mempty, Right []) <$> HD.rowMaybe standardRow--createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion ->-                       H.Statement () ResultsWithCount-createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField pgVer =-  unicodeStatement sql HE.noParams decodeStandard False- where-  sql = [qc|-      WITH-      {sourceCTEName} AS ({selectQuery})-      {countCTEF}-      SELECT-        {countResultF} AS total_result_set,-        pg_catalog.count(_postgrest_t) AS page_total,-        {noLocationF} AS header,-        {bodyF} AS body,-        {responseHeadersF pgVer} AS response_headers-      FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]--  (countCTEF, countResultF) = countF countQuery countTotal--  bodyF-    | asCsv = asCsvF-    | isSingle = asJsonSingleF-    | isJust binaryField = asBinaryF $ fromJust binaryField-    | otherwise = asJsonF--  decodeStandard :: HD.Result ResultsWithCount-  decodeStandard =-    HD.singleRow standardRow--{-| Read and Write api requests use a similar response format which includes-    various record counts and possible location header. This is the decoder-    for that common type of query.--}-standardRow :: HD.Row ResultsWithCount-standardRow = (,,,,) <$> nullableColumn HD.int8 <*> column HD.int8-                    <*> column header <*> column HD.bytea <*> column decodeGucHeaders-  where-    header = HD.array $ HD.dimension replicateM $ element HD.bytea--type ProcResults = (Maybe Int64, Int64, ByteString, Either Text [GucHeader])--callProcStatement :: Bool -> SqlQuery -> SqlQuery -> SqlQuery -> Bool ->-                     Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion ->-                     H.Statement ByteString ProcResults-callProcStatement returnsScalar callProcQuery selectQuery countQuery countTotal isSingle asCsv asBinary multObjects binaryField pgVer =-  unicodeStatement sql (param HE.unknown) decodeProc True-  where-    sql = [qc|-      WITH {sourceCTEName} AS ({callProcQuery})-      {countCTEF}-      SELECT-        {countResultF} AS total_result_set,-        pg_catalog.count(_postgrest_t) AS page_total,-        {bodyF} AS body,-        {responseHeadersF pgVer} AS response_headers-      FROM ({selectQuery}) _postgrest_t;|]--    (countCTEF, countResultF) = countF countQuery countTotal--    bodyF-     | returnsScalar = scalarBodyF-     | isSingle     = asJsonSingleF-     | asCsv = asCsvF-     | isJust binaryField = asBinaryF $ fromJust binaryField-     | otherwise = asJsonF--    scalarBodyF-     | asBinary    = asBinaryF "pgrst_scalar"-     | multObjects = "json_agg(_postgrest_t.pgrst_scalar)::character varying"-     | otherwise   = "(json_agg(_postgrest_t.pgrst_scalar)->0)::character varying"--    decodeProc :: HD.Result ProcResults-    decodeProc =-      fromMaybe (Just 0, 0, mempty, Right []) <$> HD.rowMaybe procRow-      where-        procRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8-                        <*> column HD.bytea <*> column decodeGucHeaders--createExplainStatement :: SqlQuery -> H.Statement () (Maybe Int64)-createExplainStatement countQuery =-  unicodeStatement sql HE.noParams decodeExplain False-  where-    sql = [qc| EXPLAIN (FORMAT JSON) {countQuery} |]-    -- |-    -- An `EXPLAIN (FORMAT JSON) select * from items;` output looks like this:-    -- [{-    --   "Plan": {-    --     "Node Type": "Seq Scan", "Parallel Aware": false, "Relation Name": "items",-    --     "Alias": "items", "Startup Cost": 0.00, "Total Cost": 32.60,-    --     "Plan Rows": 2260,"Plan Width": 8} }]-    -- We only obtain the Plan Rows here.-    decodeExplain :: HD.Result (Maybe Int64)-    decodeExplain =-      let row = HD.singleRow $ column HD.bytea in-      (^? L.nth 0 . L.key "Plan" .  L.key "Plan Rows" . L._Integral) <$> row--unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b-unicodeStatement = H.Statement . encodeUtf8--decodeGucHeaders :: HD.Value (Either Text [GucHeader])-decodeGucHeaders = first toS . JSON.eitherDecode . toS <$> HD.bytea
− src/PostgREST/Types.hs
@@ -1,537 +0,0 @@-{-|-Module      : PostgREST.Types-Description : PostgREST common types and functions used by the rest of the modules--}-{-# LANGUAGE DeriveGeneric         #-}-{-# LANGUAGE DuplicateRecordFields #-}--module PostgREST.Types where--import Control.Lens.Getter (view)-import Control.Lens.Tuple  (_1)--import qualified Data.Aeson               as JSON-import qualified Data.ByteString          as BS-import qualified Data.ByteString.Internal as BS (c2w)-import qualified Data.ByteString.Lazy     as BL-import qualified Data.CaseInsensitive     as CI-import qualified Data.HashMap.Strict      as M-import qualified Data.Set                 as S-import qualified GHC.Show--import Network.HTTP.Types.Header (Header, hContentType)--import Data.Tree--import PostgREST.RangeQuery (NonnegRange)-import Protolude            hiding (toS)-import Protolude.Conv       (toS)---- | Enumeration of currently supported response content types-data ContentType = CTApplicationJSON | CTSingularJSON-                 | CTTextCSV | CTTextPlain-                 | CTOpenAPI | CTOctetStream-                 | CTAny | CTOther ByteString deriving (Show, Eq)---- | Convert from ContentType to a full HTTP Header-toHeader :: ContentType -> Header-toHeader ct = (hContentType, toMime ct <> "; charset=utf-8")---- | Convert from ContentType to a ByteString representing the mime type-toMime :: ContentType -> ByteString-toMime CTApplicationJSON = "application/json"-toMime CTTextCSV         = "text/csv"-toMime CTTextPlain       = "text/plain"-toMime CTOpenAPI         = "application/openapi+json"-toMime CTSingularJSON    = "application/vnd.pgrst.object+json"-toMime CTOctetStream     = "application/octet-stream"-toMime CTAny             = "*/*"-toMime (CTOther ct)      = ct---- | Convert from ByteString to ContentType. Warning: discards MIME parameters-decodeContentType :: BS.ByteString -> ContentType-decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of-  "application/json"                  -> CTApplicationJSON-  "text/csv"                          -> CTTextCSV-  "text/plain"                        -> CTTextPlain-  "application/openapi+json"          -> CTOpenAPI-  "application/vnd.pgrst.object+json" -> CTSingularJSON-  "application/vnd.pgrst.object"      -> CTSingularJSON-  "application/octet-stream"          -> CTOctetStream-  "*/*"                               -> CTAny-  ct'                                 -> CTOther ct'---- | A SQL query that can be executed independently-type SqlQuery = Text---- | A part of a SQL query that cannot be executed independently-type SqlFragment = Text--data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq-instance Show PreferResolution where-  show MergeDuplicates  = "resolution=merge-duplicates"-  show IgnoreDuplicates = "resolution=ignore-duplicates"---- | How to return the mutated data. From https://tools.ietf.org/html/rfc7240#section-4.2-data PreferRepresentation = Full        -- ^ Return the body plus the Location header(in case of POST).-                          | HeadersOnly -- ^ Return the Location header(in case of POST). This needs a SELECT privilege on the pk.-                          | None        -- ^ Return nothing from the mutated data.-                          deriving Eq-instance Show PreferRepresentation where-  show Full        = "return=representation"-  show None        = "return=minimal"-  show HeadersOnly = mempty--data PreferParameters-  = SingleObject    -- ^ Pass all parameters as a single json object to a stored procedure-  | MultipleObjects -- ^ Pass an array of json objects as params to a stored procedure-  deriving Eq--instance Show PreferParameters where-  show SingleObject    = "params=single-object"-  show MultipleObjects = "params=multiple-objects"--data PreferCount-  = ExactCount     -- ^ exact count(slower)-  | PlannedCount   -- ^ PostgreSQL query planner rows count guess. Done by using EXPLAIN {query}.-  | EstimatedCount -- ^ use the query planner rows if the count is superior to max-rows, otherwise get the exact count.-  deriving Eq--instance Show PreferCount where-  show ExactCount     = "count=exact"-  show PlannedCount   = "count=planned"-  show EstimatedCount = "count=estimated"--data DbStructure = DbStructure {-  dbTables      :: [Table]-, dbColumns     :: [Column]-, dbRelations   :: [Relation]-, dbPrimaryKeys :: [PrimaryKey]-, dbProcs       :: ProcsMap-, pgVersion     :: PgVersion-} deriving (Show, Eq)---- TODO Table could hold references to all its Columns-tableCols :: DbStructure -> Schema -> TableName -> [Column]-tableCols dbs tSchema tName = filter (\Column{colTable=Table{tableSchema=s, tableName=t}} -> s==tSchema && t==tName) $ dbColumns dbs---- TODO Table could hold references to all its PrimaryKeys-tablePKCols :: DbStructure -> Schema -> TableName -> [Text]-tablePKCols dbs tSchema tName =  pkName <$> filter (\pk -> tSchema == (tableSchema . pkTable) pk && tName == (tableName . pkTable) pk) (dbPrimaryKeys dbs)--data PgArg = PgArg {-  pgaName :: Text-, pgaType :: Text-, pgaReq  :: Bool-} deriving (Show, Eq, Ord)--data PgType = Scalar QualifiedIdentifier | Composite QualifiedIdentifier deriving (Eq, Show, Ord)--data RetType = Single PgType | SetOf PgType deriving (Eq, Show, Ord)--data ProcVolatility = Volatile | Stable | Immutable-  deriving (Eq, Show, Ord)--data ProcDescription = ProcDescription {-  pdSchema      :: Schema-, pdName        :: Text-, pdDescription :: Maybe Text-, pdArgs        :: [PgArg]-, pdReturnType  :: RetType-, pdVolatility  :: ProcVolatility-} deriving (Show, Eq)---- Order by least number of args in the case of overloaded functions-instance Ord ProcDescription where-  ProcDescription schema1 name1 des1 args1 rt1 vol1 `compare` ProcDescription schema2 name2 des2 args2 rt2 vol2-    | schema1 == schema2 && name1 == name2 && length args1 < length args2  = LT-    | schema2 == schema2 && name1 == name2 && length args1 > length args2  = GT-    | otherwise = (schema1, name1, des1, args1, rt1, vol1) `compare` (schema2, name2, des2, args2, rt2, vol2)---- | A map of all procs, all of which can be overloaded(one entry will have more than one ProcDescription).--- | It uses a HashMap for a faster lookup.-type ProcsMap = M.HashMap QualifiedIdentifier [ProcDescription]--{-|-  Search a pg procedure by its parameters. Since a function can be overloaded, the name is not enough to find it.-  An overloaded function can have a different volatility or even a different return type.-  Ideally, handling overloaded functions should be left to pg itself. But we need to know certain proc attributes in advance.--}-findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> Maybe ProcDescription-findProc qi payloadKeys paramsAsSingleObject allProcs =-  case M.lookup qi allProcs of-    Nothing     -> Nothing-    Just [proc] -> Just proc           -- if it's not an overloaded function then immediately get the ProcDescription-    Just procs  -> find matches procs  -- Handle overloaded functions case-  where-    matches proc =-      if paramsAsSingleObject-        -- if the arg is not of json type let the db give the err-        then length (pdArgs proc) == 1-        else payloadKeys `S.isSubsetOf` S.fromList (pgaName <$> pdArgs proc)--{-|-  Search the procedure parameters by matching them with the specified keys.-  If the key doesn't match a parameter, a parameter with a default type "text" is assumed.--}-specifiedProcArgs :: S.Set FieldName -> Maybe ProcDescription -> [PgArg]-specifiedProcArgs keys proc =-  let-    args = maybe [] pdArgs proc-  in-  (\k -> fromMaybe (PgArg k "text" True) (find ((==) k . pgaName) args)) <$> S.toList keys--procReturnsScalar :: ProcDescription -> Bool-procReturnsScalar proc = case proc of-  ProcDescription{pdReturnType = (Single (Scalar _))} -> True-  _                                                   -> False--procTableName :: ProcDescription -> Maybe TableName-procTableName proc = case pdReturnType proc of-  SetOf  (Composite qi) -> Just $ qiName qi-  Single (Composite qi) -> Just $ qiName qi-  _                     -> Nothing--type Schema = Text-type TableName = Text--data Table = Table {-  tableSchema      :: Schema-, tableName        :: TableName-, tableDescription :: Maybe Text-, tableInsertable  :: Bool-} deriving (Show, Ord)--instance Eq Table where-  Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2--tableQi :: Table -> QualifiedIdentifier-tableQi Table{tableSchema=s, tableName=n} = QualifiedIdentifier s n--newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)--data Column =-    Column {-      colTable       :: Table-    , colName        :: FieldName-    , colDescription :: Maybe Text-    , colPosition    :: Int32-    , colNullable    :: Bool-    , colType        :: Text-    , colUpdatable   :: Bool-    , colMaxLen      :: Maybe Int32-    , colPrecision   :: Maybe Int32-    , colDefault     :: Maybe Text-    , colEnum        :: [Text]-    , colFK          :: Maybe ForeignKey-    } deriving (Show, Ord)--instance Eq Column where-  Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2---- | The source table column a view column refers to-type SourceColumn = (Column, ViewColumn)-type ViewColumn = Column--data PrimaryKey = PrimaryKey {-    pkTable :: Table-  , pkName  :: Text-} deriving (Show, Eq)--data OrderDirection = OrderAsc | OrderDesc deriving (Eq)-instance Show OrderDirection where-  show OrderAsc  = "ASC"-  show OrderDesc = "DESC"--data OrderNulls = OrderNullsFirst | OrderNullsLast deriving (Eq)-instance Show OrderNulls where-  show OrderNullsFirst = "NULLS FIRST"-  show OrderNullsLast  = "NULLS LAST"--data OrderTerm = OrderTerm {-  otTerm      :: Field-, otDirection :: Maybe OrderDirection-, otNullOrder :: Maybe OrderNulls-} deriving (Show, Eq)--{-|-  Represents a pg identifier with a prepended schema name "schema.table"-  When qiSchema is "", the schema is defined by the pg search_path--}-data QualifiedIdentifier = QualifiedIdentifier {-  qiSchema :: Schema-, qiName   :: TableName-} deriving (Show, Eq, Ord, Generic)-instance Hashable QualifiedIdentifier---- | The relationship [cardinality](https://en.wikipedia.org/wiki/Cardinality_(data_modeling)).--- | TODO: missing one-to-one-data Cardinality = O2M -- ^ one-to-many,  previously known as Parent-                 | M2O -- ^ many-to-one,  previously known as Child-                 | M2M -- ^ many-to-many, previously known as Many-                 deriving Eq-instance Show Cardinality where-  show O2M = "o2m"-  show M2O = "m2o"-  show M2M = "m2m"--type ConstraintName = Text--{-|-  "Relation"ship between two tables.-  The order of the relColumns and relFColumns should be maintained to get the join conditions right.-  TODO merge relColumns and relFColumns to a tuple or Data.Bimap--}-data Relation = Relation {-  relTable      :: Table-, relColumns    :: [Column]-, relConstraint :: Maybe ConstraintName -- ^ Just on O2M/M2O, Nothing on M2M-, relFTable     :: Table-, relFColumns   :: [Column]-, relType       :: Cardinality-, relJunction   :: Maybe Junction -- ^ Junction for M2M Cardinality-} deriving (Show, Eq)---- | Junction table on an M2M relationship-data Junction = Junction {-  junTable       :: Table-, junConstraint1 :: Maybe ConstraintName-, junCols1       :: [Column]-, junConstraint2 :: Maybe ConstraintName-, junCols2       :: [Column]-} deriving (Show, Eq)--isSelfReference :: Relation -> Bool-isSelfReference r = relTable r == relFTable r--data PayloadJSON =-  -- | Cached attributes of a JSON payload-  ProcessedJSON {-    -- | This is the raw ByteString that comes from the request body.-    -- We cache this instead of an Aeson Value because it was detected that for large payloads the encoding-    -- had high memory usage, see https://github.com/PostgREST/postgrest/pull/1005 for more details-    pjRaw  :: BL.ByteString-    -- | Keys of the object or if it's an array these keys are guaranteed to be the same across all its objects-  , pjKeys :: S.Set Text-  }|-  RawJSON {-    pjRaw  :: BL.ByteString-  } deriving (Show, Eq)--data PJType = PJArray { pjaLength :: Int } | PJObject deriving (Show, Eq)--data Proxy = Proxy {-  proxyScheme :: Text-, proxyHost   :: Text-, proxyPort   :: Integer-, proxyPath   :: Text-} deriving (Show, Eq)--type Operator = Text-operators :: M.HashMap Operator SqlFragment-operators = M.union (M.fromList [-  ("eq", "="),-  ("gte", ">="),-  ("gt", ">"),-  ("lte", "<="),-  ("lt", "<"),-  ("neq", "<>"),-  ("like", "LIKE"),-  ("ilike", "ILIKE"),-  ("in", "IN"),-  ("is", "IS"),-  ("cs", "@>"),-  ("cd", "<@"),-  ("ov", "&&"),-  ("sl", "<<"),-  ("sr", ">>"),-  ("nxr", "&<"),-  ("nxl", "&>"),-  ("adj", "-|-")]) ftsOperators--ftsOperators :: M.HashMap Operator SqlFragment-ftsOperators = M.fromList [-  ("fts", "@@ to_tsquery"),-  ("plfts", "@@ plainto_tsquery"),-  ("phfts", "@@ phraseto_tsquery"),-  ("wfts", "@@ websearch_to_tsquery")-  ]--data OpExpr = OpExpr Bool Operation deriving (Eq, Show)-data Operation = Op Operator SingleVal |-                 In ListVal |-                 Fts Operator (Maybe Language) SingleVal 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"-  show Or  = "OR"-{-|-  Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:--            And-           /   \-  name.eq.N     Or-               /  \-         id.eq.1   id.eq.2--}-data LogicTree = Expr Bool LogicOperator [LogicTree] | Stmnt Filter deriving (Show, Eq)--type FieldName = Text-{-|-  Json path operations as specified in https://www.postgresql.org/docs/9.4/static/functions-json.html--}-type JsonPath = [JsonOperation]--- | Represents the single arrow `->` or double arrow `->>` operators-data JsonOperation = JArrow{jOp :: JsonOperand} | J2Arrow{jOp :: JsonOperand} deriving (Show, Eq)--- | Represents the key(`->'key'`) or index(`->'1`::int`), the index is Text because we reuse our escaping functons and let pg do the casting with '1'::int-data JsonOperand = JKey{jVal :: Text} | JIdx{jVal :: Text} deriving (Show, Eq)--type Field = (FieldName, JsonPath)-type Alias = Text-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 (CI.CI ByteString, ByteString)-  deriving (Show, Eq)--instance JSON.FromJSON GucHeader where-  parseJSON (JSON.Object o) = case headMay (M.toList o) of-    Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (CI.mk $ toS k, toS s)-                            | otherwise     -> mzero-    _ -> mzero-  parseJSON _          = mzero--unwrapGucHeader :: GucHeader -> Header-unwrapGucHeader (GucHeader (k, v)) = (k, v)---- | Add headers not already included to allow the user to override them instead of duplicating them-addHeadersIfNotIncluded :: [Header] -> [Header] -> [Header]-addHeadersIfNotIncluded newHeaders initialHeaders =-  filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++-  initialHeaders--{-|-  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.--}-type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe EmbedHint)--- | Disambiguates an embedding operation when there's multiple relationships between two tables.--- | Can be the name of a foreign key constraint, column name or the junction in an m2m relationship.-type EmbedHint = Text--- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"]-type EmbedPath = [Text]-data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq)-data JoinCondition = JoinCondition (QualifiedIdentifier, FieldName)-                                   (QualifiedIdentifier, FieldName) deriving (Show, Eq)--data ReadQuery = Select {-    select         :: [SelectItem]-  , from           :: QualifiedIdentifier--- | A table alias is used in case of self joins-  , fromAlias      :: Maybe Alias--- | Only used for Many to Many joins. Parent and Child joins use explicit joins.-  , implicitJoins  :: [QualifiedIdentifier]-  , where_         :: [LogicTree]-  , joinConditions :: [JoinCondition]-  , order          :: [OrderTerm]-  , range_         :: NonnegRange-} deriving (Show, Eq)--data MutateQuery =-  Insert {-    in_        :: QualifiedIdentifier-  , insCols    :: S.Set FieldName-  , onConflict :: Maybe (PreferResolution, [FieldName])-  , where_     :: [LogicTree]-  , returning  :: [FieldName]-  }|-  Update {-    in_       :: QualifiedIdentifier-  , updCols   :: S.Set FieldName-  , where_    :: [LogicTree]-  , returning :: [FieldName]-  }|-  Delete {-    in_       :: QualifiedIdentifier-  , where_    :: [LogicTree]-  , returning :: [FieldName]-  } deriving (Show, Eq)--type ReadRequest = Tree ReadNode-type MutateRequest = MutateQuery--type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe EmbedHint, Depth))-type Depth = Integer---- First level FieldNames(e.g get a,b from /table?select=a,b,other(c,d))-fstFieldNames :: ReadRequest -> [FieldName]-fstFieldNames (Node (sel, _) _) =-  fst . view _1 <$> select sel--data PgVersion = PgVersion {-  pgvNum  :: Int32-, pgvName :: Text-} deriving (Eq, Show)--instance Ord PgVersion where-  (PgVersion v1 _) `compare` (PgVersion v2 _) = v1 `compare` v2---- | Tells the minimum PostgreSQL version required by this version of PostgREST-minimumPgVersion :: PgVersion-minimumPgVersion = pgVersion94--pgVersion94 :: PgVersion-pgVersion94 = PgVersion 90400 "9.4"--pgVersion95 :: PgVersion-pgVersion95 = PgVersion 90500 "9.5"--pgVersion96 :: PgVersion-pgVersion96 = PgVersion 90600 "9.6"--pgVersion100 :: PgVersion-pgVersion100 = PgVersion 100000 "10"--pgVersion109 :: PgVersion-pgVersion109 = PgVersion 100009 "10.9"--pgVersion110 :: PgVersion-pgVersion110 = PgVersion 110000 "11.0"--pgVersion112 :: PgVersion-pgVersion112 = PgVersion 110002 "11.2"--pgVersion114 :: PgVersion-pgVersion114 = PgVersion 110004 "11.4"--pgVersion121 :: PgVersion-pgVersion121 = PgVersion 120001 "12.1"--sourceCTEName :: SqlFragment-sourceCTEName = "pgrst_source"---- | full jspath, e.g. .property[0].attr.detail-type JSPath = [JSPathExp]--- | jspath expression, e.g. .property, .property[0] or ."property-dash"-data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)---- | Current database connection status data ConnectionStatus-data ConnectionStatus-  = NotConnected-  | Connected PgVersion-  | FatalConnectionError Text-  deriving (Eq, Show)
+ src/PostgREST/Unix.hs view
@@ -0,0 +1,59 @@+module PostgREST.Unix+  ( runAppWithSocket+  , installSignalHandlers+  ) where++import qualified Network.Socket           as Socket+import qualified Network.Wai.Handler.Warp as Warp+import qualified System.Posix.Signals     as Signals++import Network.Wai        (Application)+import System.Directory   (removeFile)+import System.IO.Error    (isDoesNotExistError)+import System.Posix.Files (setFileMode)+import System.Posix.Types (FileMode)++import qualified PostgREST.AppState as AppState+import qualified PostgREST.Workers  as Workers++import Protolude+++-- | Run the PostgREST application with user defined socket.+runAppWithSocket :: Warp.Settings -> Application -> FileMode -> FilePath -> IO ()+runAppWithSocket settings app socketFileMode socketFilePath =+  bracket createAndBindSocket Socket.close $ \socket -> do+    Socket.listen socket Socket.maxListenQueue+    Warp.runSettingsSocket settings socket app+  where+    createAndBindSocket = do+      deleteSocketFileIfExist socketFilePath+      sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol+      Socket.bind sock $ Socket.SockAddrUnix socketFilePath+      setFileMode socketFilePath socketFileMode+      return sock++    deleteSocketFileIfExist path =+      removeFile path `catch` handleDoesNotExist++    handleDoesNotExist e+      | isDoesNotExistError e = return ()+      | otherwise = throwIO e++-- | Set signal handlers, only for systems with signals+installSignalHandlers :: AppState.AppState -> IO ()+installSignalHandlers appState = do+  -- Releases the connection pool whenever the program is terminated,+  -- see https://github.com/PostgREST/postgrest/issues/268+  install Signals.sigINT $ AppState.releasePool appState+  install Signals.sigTERM $ AppState.releasePool appState++  -- The SIGUSR1 signal updates the internal 'DbStructure' by running+  -- 'connectionWorker' exactly as before.+  install Signals.sigUSR1 $ Workers.connectionWorker appState++  -- Re-read the config on SIGUSR2+  install Signals.sigUSR2 $ Workers.reReadConfig False appState+  where+    install signal handler =+      void $ Signals.installHandler signal (Signals.Catch handler) Nothing
+ src/PostgREST/Version.hs view
@@ -0,0 +1,29 @@+{-# LANGUAGE TemplateHaskell #-}+{-# OPTIONS_GHC -fno-warn-type-defaults #-}+module PostgREST.Version+  ( docsVersion+  , prettyVersion+  ) where++import qualified Data.Text as T++import Data.Version       (versionBranch)+import Development.GitRev (gitHash)+import Paths_postgrest    (version)++import Protolude+++-- | User friendly version number+prettyVersion :: Text+prettyVersion =+  T.intercalate "." (map show $ versionBranch version) <> gitRev+  where+    gitRev =+      if $(gitHash) == "UNKNOWN"+        then mempty+        else " (" <> T.take 7 $(gitHash) <> ")"++-- | Version number used in docs+docsVersion :: Text+docsVersion = "v" <> T.dropEnd 1 (T.dropWhileEnd (/= '.') prettyVersion)
+ src/PostgREST/Workers.hs view
@@ -0,0 +1,261 @@+{-# LANGUAGE LambdaCase      #-}+{-# LANGUAGE RecordWildCards #-}++module PostgREST.Workers+  ( connectionWorker+  , reReadConfig+  , listener+  ) where++import qualified Data.Aeson                 as JSON+import qualified Data.ByteString            as BS+import qualified Hasql.Connection           as C+import qualified Hasql.Notifications        as N+import qualified Hasql.Pool                 as P+import qualified Hasql.Transaction.Sessions as HT++import Control.Retry (RetryStatus, capDelay, exponentialBackoff,+                      retrying, rsPreviousDelay)++import PostgREST.AppState         (AppState)+import PostgREST.Config           (AppConfig (..), readAppConfig)+import PostgREST.Config.Database  (queryDbSettings, queryPgVersion)+import PostgREST.Config.PgVersion (PgVersion (..), minimumPgVersion)+import PostgREST.DbStructure      (queryDbStructure)+import PostgREST.Error            (PgError (PgError), checkIsFatal,+                                   errorPayload)++import qualified PostgREST.AppState as AppState++import Protolude      hiding (head, toS)+import Protolude.Conv (toS)+++-- | Current database connection status data ConnectionStatus+data ConnectionStatus+  = NotConnected+  | Connected PgVersion+  | FatalConnectionError Text+  deriving (Eq)++-- | Schema cache status+data SCacheStatus+  = SCLoaded+  | SCOnRetry+  | SCFatalFail++-- | The purpose of this worker is to obtain a healthy connection to pg and an+-- up-to-date schema cache(DbStructure).  This method is meant to be called+-- multiple times by the same thread, but does nothing if the previous+-- invocation has not terminated. In all cases this method does not halt the+-- calling thread, the work is preformed in a separate thread.+--+-- Background thread that does the following :+--  1. Tries to connect to pg server and will keep trying until success.+--  2. Checks if the pg version is supported and if it's not it kills the main+--     program.+--  3. Obtains the dbStructure. If this fails, it goes back to 1.+connectionWorker :: AppState -> IO ()+connectionWorker appState = do+  isWorkerOn <- AppState.getIsWorkerOn appState+  -- Prevents multiple workers to be running at the same time. Could happen on+  -- too many SIGUSR1s.+  unless isWorkerOn $ do+    AppState.putIsWorkerOn appState True+    void $ forkIO work+  where+    work = do+      AppConfig{..} <- AppState.getConfig appState+      AppState.logWithZTime appState "Attempting to connect to the database..."+      connected <- connectionStatus appState+      case connected of+        FatalConnectionError reason ->+          -- Fatal error when connecting+          AppState.logWithZTime appState reason >> killThread (AppState.getMainThreadId appState)+        NotConnected ->+          -- Unreachable because connectionStatus will keep trying to connect+          return ()+        Connected actualPgVersion -> do+          -- Procede with initialization+          AppState.putPgVersion appState actualPgVersion+          when configDbChannelEnabled $+            AppState.signalListener appState+          AppState.logWithZTime appState "Connection successful"+          -- this could be fail because the connection drops, but the+          -- loadSchemaCache will pick the error and retry again+          when configDbConfig $ reReadConfig False appState+          scStatus <- loadSchemaCache appState+          case scStatus of+            SCLoaded ->+              -- do nothing and proceed if the load was successful+              return ()+            SCOnRetry ->+              work+            SCFatalFail ->+              -- die if our schema cache query has an error+              killThread $ AppState.getMainThreadId appState+          AppState.putIsWorkerOn appState False++-- | Check if a connection from the pool allows access to the PostgreSQL+-- database.  If not, the pool connections are released and a new connection is+-- tried.  Releasing the pool is key for rapid recovery. Otherwise, the pool+-- timeout would have to be reached for new healthy connections to be acquired.+-- Which might not happen if the server is busy with requests. No idle+-- connection, no pool timeout.+--+-- The connection tries are capped, but if the connection times out no error is+-- thrown, just 'False' is returned.+connectionStatus :: AppState -> IO ConnectionStatus+connectionStatus appState =+  retrying retrySettings shouldRetry $+    const $ P.release pool >> getConnectionStatus+  where+    pool = AppState.getPool appState+    retrySettings = capDelay delayMicroseconds $ exponentialBackoff backoffMicroseconds+    delayMicroseconds = 32000000 -- 32 seconds+    backoffMicroseconds = 1000000 -- 1 second++    getConnectionStatus :: IO ConnectionStatus+    getConnectionStatus = do+      pgVersion <- P.use pool queryPgVersion+      case pgVersion of+        Left e -> do+          let err = PgError False e+          AppState.logWithZTime appState . toS $ errorPayload err+          case checkIsFatal err of+            Just reason ->+              return $ FatalConnectionError reason+            Nothing ->+              return NotConnected+        Right version ->+          if version < minimumPgVersion then+            return . FatalConnectionError $+              "Cannot run in this PostgreSQL version, PostgREST needs at least "+              <> pgvName minimumPgVersion+          else+            return . Connected  $ version++    shouldRetry :: RetryStatus -> ConnectionStatus -> IO Bool+    shouldRetry rs isConnSucc = do+      let+        delay = fromMaybe 0 (rsPreviousDelay rs) `div` backoffMicroseconds+        itShould = NotConnected == isConnSucc+      when itShould . AppState.logWithZTime appState $+        "Attempting to reconnect to the database in "+        <> (show delay::Text)+        <> " seconds..."+      return itShould++-- | Load the DbStructure by using a connection from the pool.+loadSchemaCache :: AppState -> IO SCacheStatus+loadSchemaCache appState = do+  AppConfig{..} <- AppState.getConfig appState+  result <-+    let transaction = if configDbPreparedStatements then HT.transaction else HT.unpreparedTransaction in+    P.use (AppState.getPool appState) . transaction HT.ReadCommitted HT.Read $+      queryDbStructure (toList configDbSchemas) configDbExtraSearchPath configDbPreparedStatements+  case result of+    Left e -> do+      let+        err = PgError False e+        putErr = AppState.logWithZTime appState . toS $ errorPayload err+      case checkIsFatal err of+        Just hint -> do+          AppState.logWithZTime appState "A fatal error ocurred when loading the schema cache"+          putErr+          AppState.logWithZTime appState hint+          return SCFatalFail+        Nothing -> do+          AppState.logWithZTime appState "An error ocurred when loading the schema cache"+          putErr+          return SCOnRetry++    Right dbStructure -> do+      AppState.putDbStructure appState dbStructure+      when (isJust configDbRootSpec) $+        AppState.putJsonDbS appState $ toS $ JSON.encode dbStructure+      AppState.logWithZTime appState "Schema cache loaded"+      return SCLoaded++-- | Starts a dedicated pg connection to LISTEN for notifications.  When a+-- NOTIFY <db-channel> - with an empty payload - is done, it refills the schema+-- cache.  It uses the connectionWorker in case the LISTEN connection dies.+listener :: AppState -> IO ()+listener appState = do+  AppConfig{..} <- AppState.getConfig appState+  let dbChannel = toS configDbChannel++  -- The listener has to wait for a signal from the connectionWorker.+  -- This is because when the connection to the db is lost, the listener also+  -- tries to recover the connection, but not with the same pace as the connectionWorker.+  -- Not waiting makes stderr quickly fill with connection retries messages from the listener.+  AppState.waitListener appState++  -- forkFinally allows to detect if the thread dies+  void . flip forkFinally (handleFinally dbChannel) $ do+    dbOrError <- C.acquire $ toS configDbUri+    case dbOrError of+      Right db -> do+        AppState.logWithZTime appState $ "Listening for notifications on the " <> dbChannel <> " channel"+        N.listen db $ N.toPgIdentifier dbChannel+        N.waitForNotifications handleNotification db+      _ ->+        die $ "Could not listen for notifications on the " <> dbChannel <> " channel"+  where+    handleFinally dbChannel _ = do+      -- if the thread dies, we try to recover+      AppState.logWithZTime appState $ "Retrying listening for notifications on the " <> dbChannel <> " channel.."+      -- assume the pool connection was also lost, call the connection worker+      connectionWorker appState+      -- retry the listener+      listener appState++    handleNotification _ msg+      | BS.null msg            = scLoader -- reload the schema cache+      | msg == "reload schema" = scLoader -- reload the schema cache+      | msg == "reload config" = reReadConfig False appState -- reload the config+      | otherwise              = pure () -- Do nothing if anything else than an empty message is sent++    scLoader =+      -- It's not necessary to check the loadSchemaCache success+      -- here. If the connection drops, the thread will die and+      -- proceed to recover.+      void $ loadSchemaCache appState++-- | Re-reads the config plus config options from the db+reReadConfig :: Bool -> AppState -> IO ()+reReadConfig startingUp appState = do+  AppConfig{..} <- AppState.getConfig appState+  dbSettings <-+    if configDbConfig then do+      qDbSettings <- queryDbSettings (AppState.getPool appState) configDbPreparedStatements+      case qDbSettings of+        Left e -> do+          let+            err = PgError False e+            putErr = AppState.logWithZTime appState . toS $ errorPayload err+          AppState.logWithZTime appState+            "An error ocurred when trying to query database settings for the config parameters"+          case checkIsFatal err of+            Just hint -> do+              putErr+              AppState.logWithZTime appState hint+              killThread (AppState.getMainThreadId appState)+            Nothing -> do+              AppState.logWithZTime appState $ show e+          pure []+        Right x -> pure x+    else+      pure mempty+  readAppConfig dbSettings configFilePath (Just configDbUri) >>= \case+    Left err   ->+      if startingUp then+        panic err -- die on invalid config if the program is starting up+      else+        AppState.logWithZTime appState $ "Failed re-loading config: " <> err+    Right newConf -> do+      AppState.putConfig appState newConf+      if startingUp then+        pass+      else+        AppState.logWithZTime appState "Config re-loaded"
test/Feature/AndOrParamsSpec.hs view
@@ -7,8 +7,9 @@ import Test.Hspec.Wai import Test.Hspec.Wai.JSON -import PostgREST.Types (PgVersion, pgVersion112)-import Protolude       hiding (get)+import PostgREST.Config.PgVersion (PgVersion, pgVersion112)++import Protolude  hiding (get) import SpecHelper  spec :: PgVersion -> SpecWith ((), Application)@@ -33,13 +34,24 @@               {"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []},               {"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}             ]|] { matchHeaders = [matchContentTypeJson] }+         it "can do logic on the third level" $-          get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities(id,grandchild_entities(id))" `shouldRespondWith`-            [json|[-              {"id": 1, "child_entities": [ { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]}, { "id": 2, "grandchild_entities": []}]},-              {"id": 2, "child_entities": [ { "id": 3, "grandchild_entities": []} ]},-              {"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}-            ]|] { matchHeaders = [matchContentTypeJson] }+          get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities(id,grandchild_entities(id))"+            `shouldRespondWith`+              [json|[+                {"id": 1, "child_entities": [+                  { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]},+                  { "id": 2, "grandchild_entities": []},+                  { "id": 4, "grandchild_entities": []},+                  { "id": 5, "grandchild_entities": []}+                ]},+                {"id": 2, "child_entities": [+                  { "id": 3, "grandchild_entities": []},+                  { "id": 6, "grandchild_entities": []}+                ]},+                {"id": 3, "child_entities": []},+                {"id": 4, "child_entities": []}+              ]|]        context "and/or params combined" $ do         it "can be nested inside the same expression" $@@ -210,12 +222,15 @@     context "used with POST" $       it "includes related data with filters" $         request methodPost "/child_entities?select=id,entities(id)&entities.or=(id.eq.2,id.eq.3)&entities.order=id"-          [("Prefer", "return=representation")]-          [json|[{"id":4,"name":"entity 4","parent_id":1},-                 {"id":5,"name":"entity 5","parent_id":2},-                 {"id":6,"name":"entity 6","parent_id":3}]|] `shouldRespondWith`-          [json|[{"id": 4, "entities":null}, {"id": 5, "entities": {"id": 2}}, {"id": 6, "entities": {"id": 3}}]|]-          { matchStatus = 201, matchHeaders = [matchContentTypeJson] }+            [("Prefer", "return=representation")]+            [json|[+              {"id":7,"name":"entity 4","parent_id":1},+              {"id":8,"name":"entity 5","parent_id":2},+              {"id":9,"name":"entity 6","parent_id":3}+            ]|]+          `shouldRespondWith`+            [json|[{"id": 7, "entities":null}, {"id": 8, "entities": {"id": 2}}, {"id": 9, "entities": {"id": 3}}]|]+            { matchStatus = 201 }      context "used with PATCH" $       it "succeeds when using and/or params" $@@ -228,9 +243,10 @@     context "used with DELETE" $       it "succeeds when using and/or params" $         request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"-          [("Prefer", "return=representation")] "" `shouldRespondWith`-          [json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]-          { matchHeaders = [matchContentTypeJson] }+            [("Prefer", "return=representation")]+            ""+          `shouldRespondWith`+            [json|[{ "id": 1, "name" : "grandchild entity 1" },{ "id": 2, "name" : "grandchild entity 2" }]|]      it "can query columns that begin with and/or reserved words" $       get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
test/Feature/AuthSpec.hs view
@@ -6,10 +6,10 @@ import Test.Hspec import Test.Hspec.Wai import Test.Hspec.Wai.JSON-import Text.Heredoc -import PostgREST.Types (PgVersion, pgVersion112)-import Protolude       hiding (get)+import PostgREST.Config.PgVersion (PgVersion, pgVersion112)++import Protolude  hiding (get) import SpecHelper  spec :: PgVersion -> SpecWith ((), Application)@@ -85,7 +85,7 @@   it "sql functions can read custom and standard claims variables" $ do     let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmdW4iLCJqdGkiOiJmb28iLCJuYmYiOjEzMDA4MTkzODAsImV4cCI6OTk5OTk5OTk5OSwiaHR0cDovL3Bvc3RncmVzdC5jb20vZm9vIjp0cnVlLCJpc3MiOiJqb2UiLCJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWF0IjoxMzAwODE5MzgwfQ.V5fEpXfpb7feqwVqlcDleFdKu86bdwU2cBRT4fcMhXg"     request methodPost "/rpc/reveal_big_jwt" [auth] "{}"-      `shouldRespondWith` [str|[{"iss":"joe","sub":"fun","exp":9999999999,"nbf":1300819380,"iat":1300819380,"jti":"foo","http://postgrest.com/foo":true}]|]+      `shouldRespondWith` [json|[{"iss":"joe","sub":"fun","exp":9999999999,"nbf":1300819380,"iat":1300819380,"jti":"foo","http://postgrest.com/foo":true}]|]    it "allows users with permissions to see their tables" $ do     let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"@@ -148,7 +148,7 @@       let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.gKw7qI50i9hMrSJW8BlTpdMEVmMXJYxlAqueGqpa_mE" in       request methodPost "/rpc/get_current_user" [auth]         [json| {} |]-         `shouldRespondWith` [str|"postgrest_test_author"|]+         `shouldRespondWith` [json|"postgrest_test_author"|]           { matchStatus = 200           , matchHeaders = []           }@@ -157,7 +157,7 @@       let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.nwzjMI0YLvVGJQTeoCPEBsK983b__gxdpLXisBNaO2A" in       request methodPost "/rpc/get_current_user" [auth]         [json| {} |]-         `shouldRespondWith` [str|"postgrest_test_default_role"|]+         `shouldRespondWith` [json|"postgrest_test_default_role"|]           { matchStatus = 200           , matchHeaders = []           }@@ -166,7 +166,14 @@       let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.OGxEJAf60NKZiTn-tIb2jy4rqKs_ZruLGWZ40TjrJsM" in       request methodPost "/rpc/get_current_user" [auth]         [json| {} |]-         `shouldRespondWith` [str|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]+         `shouldRespondWith` [json|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]           { matchStatus = 400           , matchHeaders = []           }++  it "allows 'Bearer' and 'bearer' as authentication schemes" $ do+    let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"+    request methodGet "/authors_only" [authHeader "Bearer" token] ""+      `shouldRespondWith` 200+    request methodGet "/authors_only" [authHeader "bearer" token] ""+      `shouldRespondWith` 200
test/Feature/DeleteSpec.hs view
@@ -6,7 +6,6 @@ import Test.Hspec import Test.Hspec.Wai import Test.Hspec.Wai.JSON-import Text.Heredoc  import Protolude hiding (get) @@ -23,7 +22,7 @@        it "returns the deleted item and count if requested" $         request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""-          `shouldRespondWith` [str|[{"id":2}]|]+          `shouldRespondWith` [json|[{"id":2}]|]           { matchStatus  = 200           , matchHeaders = ["Content-Range" <:> "*/1"]           }@@ -42,28 +41,20 @@        it "returns the deleted item and shapes the response" $         request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""-          `shouldRespondWith` [str|[{"id":2,"name":"Two"}]|]+          `shouldRespondWith` [json|[{"id":2,"name":"Two"}]|]           { matchStatus  = 200           , matchHeaders = ["Content-Range" <:> "*/*"]           }        it "can rename and cast the selected columns" $         request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""-          `shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]+          `shouldRespondWith` [json|[{"ciId":"3","ciName":"Three"}]|]        it "can embed (parent) entities" $         request methodDelete "/tasks?id=eq.8&select=id,name,project:projects(id)" [("Prefer", "return=representation")] ""-          `shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]+          `shouldRespondWith` [json|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]           { matchStatus  = 200           , matchHeaders = ["Content-Range" <:> "*/*"]-          }--      it "actually clears items ouf the db" $ do-        _ <- request methodDelete "/items?id=lt.15" [] ""-        get "/items"-          `shouldRespondWith` [str|[{"id":15}]|]-          { matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-0/*"]           }      context "known route, no records matched" $
+ test/Feature/DisabledOpenApiSpec.hs view
@@ -0,0 +1,20 @@+module Feature.DisabledOpenApiSpec where++import Network.HTTP.Types+import Network.Wai        (Application)++import Test.Hspec     hiding (pendingWith)+import Test.Hspec.Wai++import Protolude++spec :: SpecWith ((), Application)+spec =+  describe "Disabled OpenApi" $ do+    it "does not accept application/openapi+json and responds with 415" $+      request methodGet "/"+        [("Accept","application/openapi+json")] "" `shouldRespondWith` 415++    it "accepts application/json and responds with 404" $+      request methodGet "/"+        [("Accept","application/json")] "" `shouldRespondWith` 404
test/Feature/EmbedDisambiguationSpec.hs view
@@ -5,7 +5,6 @@ import Test.Hspec          hiding (pendingWith) import Test.Hspec.Wai import Test.Hspec.Wai.JSON-import Text.Heredoc  import Protolude  hiding (get) import SpecHelper@@ -154,7 +153,7 @@          it "can embed parent with view!fk and grandparent by using fk" $           get "/tasks?id=eq.1&select=id,name,projects_view!project(id,name,client(id,name))" `shouldRespondWith`-            [str|[{"id":1,"name":"Design w7","projects_view":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]+            [json|[{"id":1,"name":"Design w7","projects_view":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]          it "can embed by using a composite FK name" $           get "/unit_workdays?select=unit_id,day,fst_shift(car_id,schedule(name)),snd_shift(camera_id,schedule(name))" `shouldRespondWith`@@ -185,7 +184,9 @@          it "fails if the fk is not known" $           get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith`-            [json|{"message":"Could not find foreign keys between these entities. No relationship found between message and person"}|]+            [json|{+              "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",+              "message":"Could not find a relationship between message and person in the schema cache"}|]             { matchStatus = 400             , matchHeaders = [matchContentTypeJson] } @@ -195,9 +196,9 @@             { matchHeaders = [matchContentTypeJson] }          it "can request two parents with fks" $-          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] }+          get "/articleStars?select=createdAt,article(id),user(name)&limit=1"+            `shouldRespondWith`+              [json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"id": 1},"user":{"name": "Angela Martin"}}]|]          it "can specify a view!fk" $           get "/message?select=id,body,sender:person_detail!message_sender_fkey(name,sent),recipient:person_detail!message_recipient_fkey(name,received)&id=lt.4" `shouldRespondWith`@@ -230,7 +231,7 @@          it "can embed parent by using view!column and grandparent by using the column" $           get "/tasks?id=eq.1&select=id,name,project:projects_view!project_id(id,name,client:client_id(id,name))" `shouldRespondWith`-            [str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]+            [json|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]          it "can specify table!column" $           get "/message?select=id,body,sender:person!sender(name),recipient:person!recipient(name)&id=lt.4" `shouldRespondWith`@@ -432,3 +433,29 @@           [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] } +    context "m2m embed when there's a junction in an internal schema" $ do+      -- https://github.com/PostgREST/postgrest/issues/1736+      it "works with no ambiguity when there's an exposed view of the junction" $ do+        get "/screens?select=labels(name)" `shouldRespondWith`+          [json|[{"labels":[{"name":"fruit"}]}, {"labels":[{"name":"vehicles"}]}, {"labels":[{"name":"vehicles"}, {"name":"fruit"}]}]|]+          { matchHeaders = [matchContentTypeJson] }+        get "/actors?select=*,films(*)" `shouldRespondWith`+          [json|[ {"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]},+                  {"id":2,"name":"mary","films":[{"id":2001,"title":"odyssée de l'espace"}]}]|]+          { matchHeaders = [matchContentTypeJson] }+      it "doesn't work if the junction is only internal" $+        get "/end_1?select=end_2(*)" `shouldRespondWith`+          [json|{+            "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",+            "message":"Could not find a relationship between end_1 and end_2 in the schema cache"}|]+          { matchStatus  = 400+          , matchHeaders = [matchContentTypeJson] }+      it "shouldn't try to embed if the private junction has an exposed homonym" $+        -- ensures the "invalid reference to FROM-clause entry for table "rollen" error doesn't happen.+        -- Ref: https://github.com/PostgREST/postgrest/issues/1587#issuecomment-734995669+        get "/schauspieler?select=filme(*)" `shouldRespondWith`+          [json|{+            "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",+            "message":"Could not find a relationship between schauspieler and filme in the schema cache"}|]+          { matchStatus  = 400+          , matchHeaders = [matchContentTypeJson] }
test/Feature/ExtraSearchPathSpec.hs view
@@ -6,7 +6,7 @@ import Test.Hspec.Wai import Test.Hspec.Wai.JSON -import Protolude+import Protolude  hiding (get) import SpecHelper  spec :: SpecWith ((), Application)@@ -34,3 +34,6 @@     request methodGet "/rpc/is_valid_isbn?input=978-0-393-04002-9" [] ""       `shouldRespondWith` [json|true|]       { matchHeaders = [matchContentTypeJson] }++  it "can detect fk relations through multiple views recursively when middle views are in extra search path" $+    get "/consumers_extra_view?select=*,orders_view(*)" `shouldRespondWith` 200
test/Feature/HtmlRawOutputSpec.hs view
@@ -26,5 +26,5 @@           |</html>           |]       { matchStatus = 200-      , matchHeaders = ["Content-Type" <:> "text/html; charset=utf-8"]+      , matchHeaders = ["Content-Type" <:> "text/html"]       }
+ test/Feature/IgnorePrivOpenApiSpec.hs view
@@ -0,0 +1,76 @@+module Feature.IgnorePrivOpenApiSpec where++import Control.Lens ((^?))++import Data.Aeson.Lens+import Data.Aeson.QQ++import Network.HTTP.Types+import Network.Wai        (Application)+import Network.Wai.Test   (SResponse (..))++import Test.Hspec     hiding (pendingWith)+import Test.Hspec.Wai++import Protolude  hiding (get)+import SpecHelper++spec :: SpecWith ((), Application)+spec = describe "OpenAPI Ignore Privileges" $ do+  it "root path returns a valid openapi spec" $ do+    validateOpenApiResponse [("Accept", "application/openapi+json")]+    request methodHead "/" (acceptHdrs "application/openapi+json") ""+      `shouldRespondWith` "" { matchStatus  = 200 }++  describe "table" $ do++    it "includes privileged table even if user does not have permission" $ do+      r <- simpleBody <$> get "/"+      let tableTag = r ^? key "paths" . key "/authors_only"+                     . key "post" . key "tags"+                     . nth 0++      liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]++    it "only includes tables that belong to another schema if the Accept-Profile header is used" $ do+      r1 <- simpleBody <$> get "/"+      let tableKey1 = r1 ^? key "paths" . key "/children"++      liftIO $ tableKey1 `shouldBe` Nothing++      r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] ""+      let tableKey2 = r2 ^? key "paths" . key "/children"++      liftIO $ tableKey2 `shouldNotBe` Nothing++    it "includes comments on tables" $ do+      r <- simpleBody <$> get "/"++      let grandChildGet s = key "paths" . key "/grandchild_entities" . key "get" . key s+          grandChildGetSummary = r ^? grandChildGet "summary"+          grandChildGetDescription = r ^? grandChildGet "description"++      liftIO $ do+        grandChildGetSummary `shouldBe` Just "grandchild_entities summary"+        grandChildGetDescription `shouldBe` Just "grandchild_entities description\nthat spans\nmultiple lines"++  describe "RPC" $ do++    it "includes privileged function even if user does not have permission" $ do+      r <- simpleBody <$> get "/"+      let funcTag = r ^? key "paths" . key "/rpc/privileged_hello"+                    . key "post" . key "tags"+                    . nth 0++      liftIO $ funcTag `shouldBe` Just [aesonQQ|"(rpc) privileged_hello"|]++    it "only includes functions that belong to another schema if the Accept-Profile header is used" $ do+      r1 <- simpleBody <$> get "/"+      let funcKey1 = r1 ^? key "paths" . key "/rpc/get_parents_below"++      liftIO $ funcKey1 `shouldBe` Nothing++      r2 <- simpleBody <$> request methodGet "/" [("Accept-Profile", "v1")] ""+      let funcKey2 = r2 ^? key "paths" . key "/rpc/get_parents_below"++      liftIO $ funcKey2 `shouldNotBe` Nothing
test/Feature/InsertSpec.hs view
@@ -1,22 +1,20 @@ module Feature.InsertSpec where -import qualified Data.Aeson as JSON- import Data.List              (lookup)-import Data.Maybe             (fromJust) import Network.Wai            (Application)-import Network.Wai.Test       (SResponse (simpleBody, simpleHeaders, simpleStatus))+import Network.Wai.Test       (SResponse (simpleHeaders)) import Test.Hspec             hiding (pendingWith) import Test.Hspec.Wai.Matcher (bodyEquals)-import TestTypes              (CompoundPK (..), IncPK (..))  import Network.HTTP.Types import Test.Hspec.Wai import Test.Hspec.Wai.JSON import Text.Heredoc -import PostgREST.Types (PgVersion, pgVersion112)-import Protolude       hiding (get)+import PostgREST.Config.PgVersion (PgVersion, pgVersion112,+                                   pgVersion130)++import Protolude  hiding (get) import SpecHelper  spec :: PgVersion -> SpecWith ((), Application)@@ -24,17 +22,16 @@   describe "Posting new record" $ do     context "disparate json types" $ do       it "accepts disparate json types" $ do-        p <- post "/menagerie"+        post "/menagerie"           [json| {             "integer": 13, "double": 3.14159, "varchar": "testing!"           , "boolean": false, "date": "1900-01-01", "money": "$3.99"           , "enum": "foo"-          } |]-        liftIO $ do-          simpleBody p `shouldBe` ""-          simpleStatus p `shouldBe` created201-          -- should not have content type set when body is empty-          lookup hContentType (simpleHeaders p) `shouldBe` Nothing+          } |] `shouldRespondWith` ""+          { matchStatus  = 201+            -- should not have content type set when body is empty+          , matchHeaders = [matchHeaderAbsent hContentType]+          }        it "filters columns in result using &select" $         request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=representation")]@@ -42,7 +39,7 @@             "integer": 14, "double": 3.14159, "varchar": "testing!"           , "boolean": false, "date": "1900-01-01", "money": "$3.99"           , "enum": "foo"-          }] |] `shouldRespondWith` [str|[{"integer":14,"varchar":"testing!"}]|]+          }] |] `shouldRespondWith` [json|[{"integer":14,"varchar":"testing!"}]|]           { matchStatus  = 201           , matchHeaders = [matchContentTypeJson]           }@@ -86,7 +83,7 @@       it "includes related data after insert" $         request methodPost "/projects?select=id,name,clients(id,name)"                 [("Prefer", "return=representation"), ("Prefer", "count=exact")]-          [str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [str|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]+          [json|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [json|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]           { matchStatus  = 201           , matchHeaders = [ matchContentTypeJson                            , "Location" <:> "/projects?id=eq.6"@@ -96,109 +93,141 @@       it "can rename and cast the selected columns" $         request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"                 [("Prefer", "return=representation")]-          [str|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith`-          [str|[{"pId":"7","pName":"New Project","cId":"2"}]|]+          [json|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith`+          [json|[{"pId":"7","pName":"New Project","cId":"2"}]|]           { matchStatus  = 201           , matchHeaders = [ matchContentTypeJson                            , "Location" <:> "/projects?id=eq.7"                            , "Content-Range" <:> "*/*" ]           } +      it "should not throw and return location header when selecting without PK" $+        request methodPost "/projects?select=name,client_id" [("Prefer", "return=representation")]+          [json|{"id":10,"name":"New Project","client_id":2}|] `shouldRespondWith`+          [json|[{"name":"New Project","client_id":2}]|]+          { matchStatus  = 201+          , matchHeaders = [ matchContentTypeJson+                           , "Location" <:> "/projects?id=eq.10"+                           , "Content-Range" <:> "*/*" ]+          }++    context "requesting headers only representation" $+      it "should not throw and return location header when selecting without PK" $+        request methodPost "/projects?select=name,client_id" [("Prefer", "return=headers-only")]+          [json|{"id":11,"name":"New Project","client_id":2}|] `shouldRespondWith` ""+          { matchStatus  = 201+          , matchHeaders = [ "Location" <:> "/projects?id=eq.11"+                           , "Content-Range" <:> "*/*" ]+          }++    context "requesting no representation" $+      it "should not throw and return no location header when selecting without PK" $+        request methodPost "/projects?select=name,client_id" []+          [json|{"id":12,"name":"New Project","client_id":2}|] `shouldRespondWith` ""+          { matchStatus  = 201+          , matchHeaders = [ matchHeaderAbsent hLocation ]+          }+     context "from an html form" $       it "accepts disparate json types" $ do-        p <- request methodPost "/menagerie"-               [("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")-        liftIO $ do-          simpleBody p `shouldBe` ""-          simpleStatus p `shouldBe` created201+        request methodPost "/menagerie"+            [("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`+            ""+            { matchStatus = 201 }      context "with no pk supplied" $ do       context "into a table with auto-incrementing pk" $-        it "succeeds with 201 and link" $ do-          p <- post "/auto_incrementing_pk" [json| { "non_nullable_string":"not null"} |]-          liftIO $ do-            simpleBody p `shouldBe` ""-            simpleHeaders p `shouldSatisfy` matchHeader hLocation "/auto_incrementing_pk\\?id=eq\\.[0-9]+"-            simpleStatus p `shouldBe` created201-          let Just location = lookup hLocation $ simpleHeaders p-          r <- get location-          let [record] = fromJust (JSON.decode $ simpleBody r :: Maybe [IncPK])-          liftIO $ do-            incStr record `shouldBe` "not null"-            incNullableStr record `shouldBe` Nothing+        it "succeeds with 201 and location header" $ do+          -- reset pk sequence first to make test repeatable+          request methodPost "/rpc/reset_sequence"+              [("Prefer", "tx=commit")]+              [json|{"name": "auto_incrementing_pk_id_seq", "value": 2}|]+            `shouldRespondWith`+              [json|""|] +          request methodPost "/auto_incrementing_pk" [("Prefer", "return=headers-only")]+              [json| { "non_nullable_string":"not null"} |]+            `shouldRespondWith`+              ""+              { matchStatus  = 201+              , matchHeaders = [ "Location" <:> "/auto_incrementing_pk?id=eq.2" ]+              }+       context "into a table with simple pk" $         it "fails with 400 and error" $           post "/simple_pk" [json| { "extra":"foo"} |]           `shouldRespondWith`-          [json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" violates not-null constraint"}|]+          (if actualPgVersion >= pgVersion130 then+            [json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" of relation \"simple_pk\" violates not-null constraint"}|]+           else+            [json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" violates not-null constraint"}|]+          )           { matchStatus  = 400           , matchHeaders = [matchContentTypeJson]           }        context "into a table with no pk" $ do-        it "succeeds with 201 and a link including all fields" $ do-          p <- post "/no_pk" [json| { "a":"foo", "b":"bar" } |]-          liftIO $ do-            simpleBody p `shouldBe` ""-            simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.foo&b=eq.bar"-            simpleStatus p `shouldBe` created201+        it "succeeds with 201 but no location header" $ do+          post "/no_pk" [json| { "a":"foo", "b":"bar" } |]+            `shouldRespondWith`+              ""+              { matchStatus  = 201+              , matchHeaders = [matchHeaderAbsent hLocation]+              }          it "returns full details of inserted record if asked" $ do-          p <- request methodPost "/no_pk"-                       [("Prefer", "return=representation")]-                       [json| { "a":"bar", "b":"baz" } |]-          liftIO $ do-            simpleBody p `shouldBe` [json| [{ "a":"bar", "b":"baz" }] |]-            simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.bar&b=eq.baz"-            simpleStatus p `shouldBe` created201+          request methodPost "/no_pk"+              [("Prefer", "return=representation")]+              [json| { "a":"bar", "b":"baz" } |]+            `shouldRespondWith`+              [json| [{ "a":"bar", "b":"baz" }] |]+              { matchStatus  = 201+              , matchHeaders = [matchHeaderAbsent hLocation]+              }          it "returns empty array when no items inserted, and return=rep" $ do-          p <- request methodPost "/no_pk"-                       [("Prefer", "return=representation")]-                       [json| [] |]-          liftIO $ do-            simpleBody p `shouldBe` [json| [] |]-            simpleStatus p `shouldBe` created201+          request methodPost "/no_pk"+              [("Prefer", "return=representation")]+              [json| [] |]+            `shouldRespondWith`+              [json| [] |]+              { matchStatus = 201 }          it "can post nulls" $ do-          p <- request methodPost "/no_pk"-                       [("Prefer", "return=representation")]-                       [json| { "a":null, "b":"foo" } |]-          liftIO $ do-            simpleBody p `shouldBe` [json| [{ "a":null, "b":"foo" }] |]-            simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=is.null&b=eq.foo"-            simpleStatus p `shouldBe` created201+          request methodPost "/no_pk"+              [("Prefer", "return=representation")]+              [json| { "a":null, "b":"foo" } |]+            `shouldRespondWith`+              [json| [{ "a":null, "b":"foo" }] |]+              { matchStatus  = 201+              , matchHeaders = [matchHeaderAbsent hLocation]+              }      context "with compound pk supplied" $       it "builds response location header appropriately" $ do-        let inserted    = [json| { "k1":12, "k2":"Rock & R+ll" } |]-            expectedObj = CompoundPK 12 "Rock & R+ll" Nothing-            expectedLoc = "/compound_pk?k1=eq.12&k2=eq.Rock%20%26%20R%2Bll"-        p <- request methodPost "/compound_pk"-                     [("Prefer", "return=representation")]-                     inserted-        liftIO $ do-          JSON.decode (simpleBody p) `shouldBe` Just [expectedObj]-          simpleStatus p `shouldBe` created201-          lookup hLocation (simpleHeaders p) `shouldBe` Just expectedLoc--        r <- get expectedLoc-        liftIO $ do-          JSON.decode (simpleBody r) `shouldBe` Just [expectedObj]-          simpleStatus r `shouldBe` ok200+        request methodPost "/compound_pk"+            [("Prefer", "return=representation")]+            [json| { "k1":12, "k2":"Rock & R+ll" } |]+          `shouldRespondWith`+            [json|[ { "k1":12, "k2":"Rock & R+ll", "extra": null } ]|]+            { matchStatus  = 201+            , matchHeaders = [ "Location" <:> "/compound_pk?k1=eq.12&k2=eq.Rock%20%26%20R%2Bll" ]+            }      context "with bulk insert" $       it "returns 201 but no location header" $ do         let bulkData = [json| [ {"k1":21, "k2":"hello world"}                               , {"k1":22, "k2":"bye for now"}]                             |]-        p <- request methodPost "/compound_pk" [] bulkData-        liftIO $ do-          simpleStatus p `shouldBe` created201-          lookup hLocation (simpleHeaders p) `shouldBe` Nothing+        request methodPost "/compound_pk" [] bulkData+          `shouldRespondWith`+            ""+            { matchStatus  = 201+            , matchHeaders = [matchHeaderAbsent hLocation]+            }      context "with invalid json payload" $       it "fails with 400 and error" $@@ -224,12 +253,11 @@      context "attempting to insert a row with the same primary key" $       it "fails returning a 409 Conflict" $-        post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |]+        post "/simple_pk"+            [json| { "k":"xyyx", "extra":"e1" } |]           `shouldRespondWith`-          [json|{"hint":null,"details":"Key (k)=(k1) already exists.","code":"23505","message":"duplicate key value violates unique constraint \"contacts_pkey\""}|]-          { matchStatus  = 409-          , matchHeaders = [matchContentTypeJson]-          }+            [json|{"hint":null,"details":"Key (k)=(xyyx) already exists.","code":"23505","message":"duplicate key value violates unique constraint \"simple_pk_pkey\""}|]+            { matchStatus  = 409 }      context "attempting to insert a row with conflicting unique constraint" $       it "fails returning a 409 Conflict" $@@ -238,24 +266,20 @@     context "jsonb" $ do       it "serializes nested object" $ do         let inserted = [json| { "data": { "foo":"bar" } } |]-            location = "/json?data=eq.%7B%22foo%22%3A%22bar%22%7D"-        request methodPost "/json"+        request methodPost "/json_table"                      [("Prefer", "return=representation")]                      inserted-          `shouldRespondWith` [str|[{"data":{"foo":"bar"}}]|]+          `shouldRespondWith` [json|[{"data":{"foo":"bar"}}]|]           { matchStatus  = 201-          , matchHeaders = ["Location" <:> location]           }        it "serializes nested array" $ do         let inserted = [json| { "data": [1,2,3] } |]-            location = "/json?data=eq.%5B1%2C2%2C3%5D"-        request methodPost "/json"+        request methodPost "/json_table"                      [("Prefer", "return=representation")]                      inserted-          `shouldRespondWith` [str|[{"data":[1,2,3]}]|]+          `shouldRespondWith` [json|[{"data":[1,2,3]}]|]           { matchStatus  = 201-          , matchHeaders = ["Location" <:> location]           }      context "empty objects" $ do@@ -275,20 +299,35 @@           , matchHeaders = []           } -      it "successfully inserts a row with all-default columns with prefer=rep" $-        request methodPost "/items" [("Prefer", "return=representation")] "{}"-          `shouldRespondWith` [json|[{ id: 20 }]|]-          { matchStatus  = 201,-            matchHeaders = []-          }+      it "successfully inserts a row with all-default columns with prefer=rep" $ do+        -- reset pk sequence first to make test repeatable+        request methodPost "/rpc/reset_sequence"+            [("Prefer", "tx=commit")]+            [json|{"name": "items2_id_seq", "value": 20}|]+          `shouldRespondWith`+            [json|""|] -      it "successfully inserts a row with all-default columns with prefer=rep and &select=" $-        request methodPost "/items?select=id" [("Prefer", "return=representation")] "{}"-          `shouldRespondWith` [json|[{ id: 21 }]|]-          { matchStatus  = 201,-            matchHeaders = []-          }+        request methodPost "/items2"+            [("Prefer", "return=representation")]+            [json|{}|]+          `shouldRespondWith`+            [json|[{ id: 20 }]|]+            { matchStatus  = 201 } +      it "successfully inserts a row with all-default columns with prefer=rep and &select=" $ do+        -- reset pk sequence first to make test repeatable+        request methodPost "/rpc/reset_sequence"+            [("Prefer", "tx=commit")]+            [json|{"name": "items3_id_seq", "value": 20}|]+          `shouldRespondWith`+            [json|""|]++        request methodPost "/items3?select=id"+            [("Prefer", "return=representation")]+            [json|{}|]+          `shouldRespondWith` [json|[{ id: 20 }]|]+            { matchStatus  = 201 }+     context "POST with ?columns parameter" $ do       it "ignores json keys not included in ?columns" $ do         request methodPost "/articles?columns=id,body" [("Prefer", "return=representation")]@@ -358,8 +397,7 @@                      "a,b\nbar,baz"           `shouldRespondWith` "a,b\nbar,baz"           { matchStatus  = 201-          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",-                            "Location" <:> "/no_pk?a=eq.bar&b=eq.baz"]+          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]           }        it "can post nulls" $@@ -368,8 +406,7 @@                      "a,b\nNULL,foo"           `shouldRespondWith` "a,b\n,foo"           { matchStatus  = 201-          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",-                            "Location" <:> "/no_pk?a=is.null&b=eq.foo"]+          , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]           }        it "only returns the requested column header with its associated data" $@@ -393,294 +430,40 @@      context "with unicode values" $       it "succeeds and returns usable location header" $ do-        let payload = [json| { "a":"圍棋", "b":"¥" } |]-        p <- request methodPost "/no_pk"-                     [("Prefer", "return=representation")]-                     payload-        liftIO $ do-          simpleBody p `shouldBe` "["<>payload<>"]"-          simpleStatus p `shouldBe` created201+        p <- request methodPost "/simple_pk2?select=extra,k"+            [("Prefer", "tx=commit"), ("Prefer", "return=representation")]+            [json| { "k":"圍棋", "extra":"¥" } |]+        pure p `shouldRespondWith`+          [json|[ { "k":"圍棋", "extra":"¥" } ]|]+          { matchStatus = 201 }          let Just location = lookup hLocation $ simpleHeaders p-        r <- get location-        liftIO $ simpleBody r `shouldBe` "["<>payload<>"]"--  describe "Patching record" $ do-    context "to unknown uri" $-      it "indicates no table found by returning 404" $-        request methodPatch "/fake" []-          [json| { "real": false } |]-            `shouldRespondWith` 404--    context "on an empty table" $-      it "indicates no records found to update by returning 404" $-        request methodPatch "/empty_table" []-          [json| { "extra":20 } |]-          `shouldRespondWith` ""-          { matchStatus  = 404,-            matchHeaders = []-          }--    context "with invalid json payload" $-      it "fails with 400 and error" $-        request methodPatch "/simple_pk" [] "}{ x = 2"+        get location           `shouldRespondWith`-          [json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]-          { matchStatus  = 400,-            matchHeaders = [matchContentTypeJson]-          }+            [json|[ { "k":"圍棋", "extra":"¥" } ]|] -    context "with no payload" $-      it "fails with 400 and error" $-        request methodPatch "/items" [] ""+        request methodDelete location+            [("Prefer", "tx=commit")]+            ""           `shouldRespondWith`-          [json|{"message":"Error in $: not enough input"}|]-          { matchStatus  = 400,-            matchHeaders = [matchContentTypeJson]-          }--    context "in a nonempty table" $ do-      it "can update a single item" $ do-        g <- get "/items?id=eq.42"-        liftIO $ simpleHeaders g-          `shouldSatisfy` matchHeader "Content-Range" "\\*/\\*"-        p <- request methodPatch "/items?id=eq.2" [] [json| { "id":42 } |]-        pure p `shouldRespondWith` ""-          { matchStatus  = 204,-            matchHeaders = ["Content-Range" <:> "0-0/*"]-          }-        liftIO $ lookup hContentType (simpleHeaders p) `shouldBe` Nothing--        -- check it really got updated-        g' <- get "/items?id=eq.42"-        liftIO $ simpleHeaders g'-          `shouldSatisfy` matchHeader "Content-Range" "0-0/\\*"-        -- put value back for other tests-        void $ request methodPatch "/items?id=eq.42" [] [json| { "id":2 } |]--      it "returns empty array when no rows updated and return=rep" $-        request methodPatch "/items?id=eq.999999"-          [("Prefer", "return=representation")] [json| { "id":999999 } |]-          `shouldRespondWith` "[]"-          {-            matchStatus  = 404,-            matchHeaders = []-          }--      it "gives a 404 when no rows updated" $-        request methodPatch "/items?id=eq.99999999" []-          [json| { "id": 42 } |]-            `shouldRespondWith` 404--      it "returns updated object as array when return=rep" $-        request methodPatch "/items?id=eq.2"-          [("Prefer", "return=representation")] [json| { "id":2 } |]-          `shouldRespondWith` [str|[{"id":2}]|]-          { matchStatus  = 200,-            matchHeaders = ["Content-Range" <:> "0-0/*"]-          }--      it "can update multiple items" $ do-        replicateM_ 10 $ post "/auto_incrementing_pk"-          [json| { non_nullable_string: "a" } |]-        replicateM_ 10 $ post "/auto_incrementing_pk"-          [json| { non_nullable_string: "b" } |]-        _ <- request methodPatch-          "/auto_incrementing_pk?non_nullable_string=eq.a" []-          [json| { non_nullable_string: "c" } |]-        g <- get "/auto_incrementing_pk?non_nullable_string=eq.c"-        liftIO $ simpleHeaders g-          `shouldSatisfy` matchHeader "Content-Range" "0-9/\\*"--      it "can set a column to NULL" $ do-        _ <- post "/no_pk" [json| { a: "keepme", b: "nullme" } |]-        _ <- request methodPatch "/no_pk?b=eq.nullme" [] [json| { b: null } |]-        get "/no_pk?a=eq.keepme" `shouldRespondWith`-          [json| [{ a: "keepme", b: null }] |]-          { matchHeaders = [matchContentTypeJson] }--      context "filtering by a computed column" $ do-        it "is successful" $-          request methodPatch-            "/items?is_first=eq.true"-            [("Prefer", "return=representation")]-            [json| { id: 100 } |]-            `shouldRespondWith` [json| [{ id: 100 }] |]-            { matchStatus  = 200,-              matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]-            }--        it "indicates no records updated by returning 404" $-          request methodPatch-            "/items?always_true=eq.false"-            [("Prefer", "return=representation")]-            [json| { id: 100 } |]-            `shouldRespondWith` "[]"-            { matchStatus  = 404,-              matchHeaders = []-            }--      context "with representation requested" $ do-        it "can provide a representation" $ do-          _ <- post "/items"-            [json| { id: 1 } |]-          request methodPatch-            "/items?id=eq.1"-            [("Prefer", "return=representation")]-            [json| { id: 99 } |]-            `shouldRespondWith` [json| [{id:99}] |]-            { matchHeaders = [matchContentTypeJson] }-          -- put value back for other tests-          void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]--        it "can return computed columns" $-          request methodPatch-            "/items?id=eq.1&select=id,always_true"-            [("Prefer", "return=representation")]-            [json| { id: 1 } |]-            `shouldRespondWith` [json| [{ id: 1, always_true: true }] |]-            { matchHeaders = [matchContentTypeJson] }--        it "can select overloaded computed columns" $ do-          request methodPatch-            "/items?id=eq.1&select=id,computed_overload"-            [("Prefer", "return=representation")]-            [json| { id: 1 } |]-            `shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]-            { matchHeaders = [matchContentTypeJson] }-          request methodPatch-            "/items2?id=eq.1&select=id,computed_overload"-            [("Prefer", "return=representation")]-            [json| { id: 1 } |]-            `shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]-            { matchHeaders = [matchContentTypeJson] }--      it "ignores ?select= when return not set or return=minimal" $ do-        request methodPatch "/items?id=eq.1&select=id" [] [json| { id:1 } |]-          `shouldRespondWith` ""-          {-            matchStatus  = 204,-            matchHeaders = ["Content-Range" <:> "0-0/*"]-          }-        request methodPatch "/items?id=eq.1&select=id" [("Prefer", "return=minimal")] [json| { id:1 } |]-          `shouldRespondWith` ""-          {-            matchStatus  = 204,-            matchHeaders = ["Content-Range" <:> "0-0/*"]-          }--      context "when patching with an empty body" $ do-        it "makes no updates and returns 204 without return= and without ?select=" $ do-          request methodPatch "/items" [] [json| {} |]-            `shouldRespondWith` ""-            {-              matchStatus  = 204,-              matchHeaders = ["Content-Range" <:> "*/*"]-            }--          request methodPatch "/items" [] [json| [] |]-            `shouldRespondWith` ""-            {-              matchStatus  = 204,-              matchHeaders = ["Content-Range" <:> "*/*"]-            }--          request methodPatch "/items" [] [json| [{}] |]-            `shouldRespondWith` ""-            {-              matchStatus  = 204,-              matchHeaders = ["Content-Range" <:> "*/*"]-            }--        it "makes no updates and returns 204 without return= and with ?select=" $ do-          request methodPatch "/items?select=id" [] [json| {} |]-            `shouldRespondWith` ""-            {-              matchStatus  = 204,-              matchHeaders = ["Content-Range" <:> "*/*"]-            }--          request methodPatch "/items?select=id" [] [json| [] |]-            `shouldRespondWith` ""-            {-              matchStatus  = 204,-              matchHeaders = ["Content-Range" <:> "*/*"]-            }--          request methodPatch "/items?select=id" [] [json| [{}] |]-            `shouldRespondWith` ""-            {-              matchStatus  = 204,-              matchHeaders = ["Content-Range" <:> "*/*"]-            }--        it "makes no updates and returns 200 with return=rep and without ?select=" $-          request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]-            `shouldRespondWith` "[]"-            {-              matchStatus  = 200,-              matchHeaders = ["Content-Range" <:> "*/*"]-            }--        it "makes no updates and returns 200 with return=rep and with ?select=" $-          request methodPatch "/items?select=id" [("Prefer", "return=representation")] [json| {} |]-            `shouldRespondWith` "[]"-            {-              matchStatus  = 200,-              matchHeaders = ["Content-Range" <:> "*/*"]-            }--        it "makes no updates and returns 200 with return=rep and with ?select= for overloaded computed columns" $-          request methodPatch "/items?select=id,computed_overload" [("Prefer", "return=representation")] [json| {} |]-            `shouldRespondWith` "[]"-            {-              matchStatus  = 200,-              matchHeaders = ["Content-Range" <:> "*/*"]-            }--    context "with unicode values" $-      it "succeeds and returns values intact" $ do-        void $ request methodPost "/no_pk" []-          [json| { "a":"patchme", "b":"patchme" } |]-        let payload = [json| { "a":"圍棋", "b":"¥" } |]-        p <- request methodPatch "/no_pk?a=eq.patchme&b=eq.patchme"-          [("Prefer", "return=representation")] payload-        liftIO $ do-          simpleBody p `shouldBe` "["<>payload<>"]"-          simpleStatus p `shouldBe` ok200--    context "PATCH with ?columns parameter" $ do-      it "ignores json keys not included in ?columns" $-        request methodPatch "/articles?id=eq.200&columns=body" [("Prefer", "return=representation")]-          [json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`-          [json|[{"id": 200, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]-          { matchStatus  = 200-          , matchHeaders = [] }--      it "ignores json keys and gives 404 if no record updated" $-        request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]-          [json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404+            204    describe "Row level permission" $     it "set user_id when inserting rows" $ do-      let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"-      _ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]-      _ <- post "/postgrest/users" [json| { "id":"jroe", "pass": "1234", "role": "postgrest_test_author" } |]--      p1 <- request methodPost "/authors_only"-        [ auth, ("Prefer", "return=representation") ]-        [json| { "secret": "nyancat" } |]-      liftIO $ do-        simpleBody p1 `shouldBe` [str|[{"owner":"jdoe","secret":"nyancat"}]|]-        simpleStatus p1 `shouldBe` created201+      request methodPost "/authors_only"+          [ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0", ("Prefer", "return=representation") ]+          [json| { "secret": "nyancat" } |]+        `shouldRespondWith`+          [json|[{"owner":"jdoe","secret":"nyancat"}]|]+          { matchStatus  = 201 } -      p2 <- request methodPost "/authors_only"-        -- jwt token for jroe-        [ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.2e7mx0U4uDcInlbJVOBGlrRufwqWLINDIEDC1vS0nw8", ("Prefer", "return=representation") ]-        [json| { "secret": "lolcat", "owner": "hacker" } |]-      liftIO $ do-        simpleBody p2 `shouldBe` [str|[{"owner":"jroe","secret":"lolcat"}]|]-        simpleStatus p2 `shouldBe` created201+      request methodPost "/authors_only"+          -- jwt token for jroe+          [ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.2e7mx0U4uDcInlbJVOBGlrRufwqWLINDIEDC1vS0nw8", ("Prefer", "return=representation") ]+          [json| { "secret": "lolcat", "owner": "hacker" } |]+        `shouldRespondWith`+          [json|[{"owner":"jroe","secret":"lolcat"}]|]+          { matchStatus  = 201 }    context "tables with self reference foreign keys" $ do     it "embeds parent after insert" $@@ -693,75 +476,10 @@         , matchHeaders = [ matchContentTypeJson , "Location" <:> "/web_content?id=eq.6" ]         } -    it "embeds children after update" $-      request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"-              [("Prefer", "return=representation")]-        [json|{"name": "tardis-patched"}|]-        `shouldRespondWith`-        [json|-          [ { "id": 0, "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]-        |]-        { matchStatus  = 200,-          matchHeaders = [matchContentTypeJson]-        }--    it "embeds parent, children and grandchildren after update" $-      request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name,web_content(name)),parent_content:p_web_id(name)"-              [("Prefer", "return=representation")]-        [json|{"name": "tardis-patched-2"}|]-        `shouldRespondWith`-        [json| [-          {-            "id": 0,-            "name": "tardis-patched-2",-            "parent_content": { "name": "wat" },-            "web_content": [-                { "name": "fezz", "web_content": [ { "name": "wut" } ] },-                { "name": "foo",  "web_content": [] },-                { "name": "bar",  "web_content": [] }-            ]-          }-        ] |]-        { matchStatus  = 200,-          matchHeaders = [matchContentTypeJson]-        }--    it "embeds children after update without explicitly including the id in the ?select" $-      request methodPatch "/web_content?id=eq.0&select=name,web_content(name)"-              [("Prefer", "return=representation")]-        [json|{"name": "tardis-patched"}|]-        `shouldRespondWith`-        [json|-          [ { "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]-        |]-        { matchStatus  = 200,-          matchHeaders = [matchContentTypeJson]-        }--    it "embeds an M2M relationship plus parent after update" $-      request methodPatch "/users?id=eq.1&select=name,tasks(name,project:projects(name))"-              [("Prefer", "return=representation")]-        [json|{"name": "Kevin Malone"}|]-        `shouldRespondWith`-        [json|[-          {-            "name": "Kevin Malone",-            "tasks": [-                { "name": "Design w7", "project": { "name": "Windows 7" } },-                { "name": "Code w7", "project": { "name": "Windows 7" } },-                { "name": "Design w10", "project": { "name": "Windows 10" } },-                { "name": "Code w10", "project": { "name": "Windows 10" } }-            ]-          }-        ]|]-        { matchStatus  = 200,-          matchHeaders = [matchContentTypeJson]-        }-   context "table with limited privileges" $ do     it "succeeds inserting if correct select is applied" $       request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]-        [json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [str|[{"article_id":2,"user_id":1}]|]+        [json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [json|[{"article_id":2,"user_id":1}]|]         { matchStatus  = 201         , matchHeaders = []         }@@ -770,9 +488,9 @@       request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]         [json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (       if actualPgVersion >= pgVersion112 then-      [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]+      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]          else-      [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]+      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]                                                                       )         { matchStatus  = 401         , matchHeaders = []@@ -782,28 +500,50 @@       request methodPost "/limited_article_stars" [("Prefer", "return=representation")]         [json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (       if actualPgVersion >= pgVersion112 then-      [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]+      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]          else-      [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]+      [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]                                                                       )         { matchStatus  = 401         , matchHeaders = []         }      it "can insert in a table with no select and return=minimal" $ do-      p <- request methodPost "/insertonly"-                   [("Prefer", "return=minimal")]-                   [json| { "v":"some value" } |]-      liftIO $ do-        simpleBody p `shouldBe` ""-        simpleStatus p `shouldBe` created201+      request methodPost "/insertonly"+          [("Prefer", "return=minimal")]+          [json| { "v":"some value" } |]+        `shouldRespondWith`+          ""+          { matchStatus = 201 } -    it "succeeds updating row and gives a 204 when using return=minimal" $-      request methodPatch "/app_users?id=eq.1" [("Prefer", "return=minimal")]-        [json| { "password": "passxyz" } |]-          `shouldRespondWith` 204+  describe "Inserting into VIEWs" $ do+    context "requesting no representation" $+      it "succeeds with 201" $+        post "/compound_pk_view"+            [json|{"k1":1,"k2":"test","extra":2}|]+          `shouldRespondWith`+            ""+            { matchStatus  = 201+            , matchHeaders = [ matchHeaderAbsent hLocation ]+            } -    it "can update without return=minimal and no explicit select" $-      request methodPatch "/app_users?id=eq.1" []-        [json| { "password": "passabc" } |]-          `shouldRespondWith` 204+    context "requesting header only representation" $ do+      it "returns a location header" $+        request methodPost "/compound_pk_view" [("Prefer", "return=headers-only")]+            [json|{"k1":1,"k2":"test","extra":2}|]+          `shouldRespondWith`+            ""+            { matchStatus  = 201+            , matchHeaders = [ "Location" <:> "/compound_pk_view?k1=eq.1&k2=eq.test"+                             , "Content-Range" <:> "*/*" ]+            }++      it "should not throw and return location header when a PK is null" $+        request methodPost "/test_null_pk_competitors_sponsors" [("Prefer", "return=headers-only")]+            [json|{"id":1}|]+          `shouldRespondWith`+            ""+            { matchStatus  = 201+            , matchHeaders = [ "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null"+                             , "Content-Range" <:> "*/*" ]+            }
test/Feature/JsonOperatorSpec.hs view
@@ -7,8 +7,10 @@ import Test.Hspec.Wai import Test.Hspec.Wai.JSON -import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)-import Protolude       hiding (get)+import PostgREST.Config.PgVersion (PgVersion, pgVersion112,+                                   pgVersion121, pgVersion95)++import Protolude  hiding (get) import SpecHelper  spec :: PgVersion -> SpecWith ((), Application)@@ -139,20 +141,20 @@    context "filtering response" $ do     it "can filter by properties inside json column" $ do-      get "/json?data->foo->>bar=eq.baz" `shouldRespondWith`+      get "/json_table?data->foo->>bar=eq.baz" `shouldRespondWith`         [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]         { matchHeaders = [matchContentTypeJson] }-      get "/json?data->foo->>bar=eq.fake" `shouldRespondWith`+      get "/json_table?data->foo->>bar=eq.fake" `shouldRespondWith`         [json| [] |]         { matchHeaders = [matchContentTypeJson] }      it "can filter by properties inside json column using not" $-      get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith`+      get "/json_table?data->foo->>bar=not.eq.baz" `shouldRespondWith`         [json| [] |]         { matchHeaders = [matchContentTypeJson] }      it "can filter by properties inside json column using ->>" $-      get "/json?data->>id=eq.1" `shouldRespondWith`+      get "/json_table?data->>id=eq.1" `shouldRespondWith`         [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]         { matchHeaders = [matchContentTypeJson] } @@ -190,20 +192,63 @@    context "ordering response" $ do     it "orders by a json column property asc" $-      get "/json?order=data->>id.asc" `shouldRespondWith`+      get "/json_table?order=data->>id.asc" `shouldRespondWith`         [json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]         { matchHeaders = [matchContentTypeJson] }      it "orders by a json column with two level property nulls first" $-      get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith`+      get "/json_table?order=data->foo->>bar.nullsfirst" `shouldRespondWith`         [json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]         { matchHeaders = [matchContentTypeJson] }    context "Patching record, in a nonempty table" $     it "can set a json column to escaped value" $ do-      _ <- post "/json" [json| { data: {"escaped":"bar"} } |]-      request methodPatch "/json?data->>escaped=eq.bar"-                   [("Prefer", "return=representation")]-                   [json| { "data": { "escaped":" \"bar" } } |]-        `shouldRespondWith` [json| [{ "data": { "escaped":" \"bar" } }] |]-        { matchStatus  = 200 , matchHeaders = [] }+      request methodPatch "/json_table?data->>id=eq.3"+          [("Prefer", "return=representation")]+          [json| { "data": { "id":" \"escaped" } } |]+        `shouldRespondWith`+          [json| [{ "data": { "id":" \"escaped" } }] |]++  when (actualPgVersion >= pgVersion95) $+    context "json array negative index" $ do+      it "can select with negative indexes" $ do+        get "/json_arr?select=data->>-1::int&id=in.(1,2)" `shouldRespondWith`+          [json| [{"data":3}, {"data":6}] |]+          { matchHeaders = [matchContentTypeJson] }+        get "/json_arr?select=data->0->>-2::int&id=in.(3,4)" `shouldRespondWith`+          [json| [{"data":8}, {"data":7}] |]+          { matchHeaders = [matchContentTypeJson] }+        get "/json_arr?select=data->-2->>a&id=in.(5,6)" `shouldRespondWith`+          [json| [{"a":"A"}, {"a":"[1,2,3]"}] |]+          { matchHeaders = [matchContentTypeJson] }++      it "can filter with negative indexes" $ do+        get "/json_arr?select=data&data->>-3=eq.1" `shouldRespondWith`+          [json| [{"data":[1, 2, 3]}] |]+          { matchHeaders = [matchContentTypeJson] }+        get "/json_arr?select=data&data->-1->>-3=eq.11" `shouldRespondWith`+          [json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]+          { matchHeaders = [matchContentTypeJson] }+        get "/json_arr?select=data&data->-1->>b=eq.B" `shouldRespondWith`+          [json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]+          { matchHeaders = [matchContentTypeJson] }+        get "/json_arr?select=data&data->-1->b->>-1=eq.5" `shouldRespondWith`+          [json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]+          { matchHeaders = [matchContentTypeJson] }++      it "should fail on badly formed negatives" $ do+        get "/json_arr?select=data->>-78xy" `shouldRespondWith`+          [json|+            {"details": "unexpected 'x' expecting digit, \"->\", \"::\" or end of input",+             "message": "\"failed to parse select parameter (data->>-78xy)\" (line 1, column 11)"} |]+          { matchStatus = 400, matchHeaders = [matchContentTypeJson] }+        get "/json_arr?select=data->>--34" `shouldRespondWith`+          [json|+            {"details": "unexpected \"-\" expecting digit",+             "message": "\"failed to parse select parameter (data->>--34)\" (line 1, column 9)"} |]+          { matchStatus = 400, matchHeaders = [matchContentTypeJson] }+        get "/json_arr?select=data->>-xy-4" `shouldRespondWith`+          [json|+            {"details":"unexpected \"x\" expecting digit",+             "message":"\"failed to parse select parameter (data->>-xy-4)\" (line 1, column 9)"} |]+          { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
test/Feature/MultipleSchemaSpec.hs view
@@ -15,7 +15,7 @@ import Protolude import SpecHelper -import PostgREST.Types (PgVersion, pgVersion96)+import PostgREST.Config.PgVersion (PgVersion, pgVersion96)  spec :: PgVersion -> SpecWith ((), Application) spec actualPgVersion =@@ -77,33 +77,37 @@      context "Inserting tables on different schemas" $ do       it "succeeds inserting on default schema and returning it" $-        request methodPost "/children" [("Prefer", "return=representation")] [json|{"name": "child v1-1", "parent_id": 1}|]-         `shouldRespondWith`-         [json|[{"id":1, "name": "child v1-1", "parent_id": 1}]|]-         {-           matchStatus = 201-         , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]-         }+        request methodPost "/children"+            [("Prefer", "return=representation")]+            [json|{"id": 0, "name": "child v1-1", "parent_id": 1}|]+          `shouldRespondWith`+            [json|[{"id": 0, "name": "child v1-1", "parent_id": 1}]|]+            {+              matchStatus = 201+            , matchHeaders = ["Content-Profile" <:> "v1"]+            }        it "succeeds inserting on the v1 schema and returning its parent" $-        request methodPost "/children?select=id,parent(*)" [("Prefer", "return=representation"), ("Content-Profile", "v1")]-          [json|{"name": "child v1-2", "parent_id": 2}|]+        request methodPost "/children?select=id,parent(*)"+            [("Prefer", "return=representation"), ("Content-Profile", "v1")]+            [json|{"id": 0, "name": "child v1-2", "parent_id": 2}|]           `shouldRespondWith`-          [json|[{"id":2, "parent": {"id": 2, "name": "parent v1-2"}}]|]-          {-            matchStatus = 201-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]-          }+            [json|[{"id": 0, "parent": {"id": 2, "name": "parent v1-2"}}]|]+            {+              matchStatus = 201+            , matchHeaders = ["Content-Profile" <:> "v1"]+            }        it "succeeds inserting on the v2 schema and returning its parent" $-        request methodPost "/children?select=id,parent(*)" [("Prefer", "return=representation"), ("Content-Profile", "v2")]-          [json|{"name": "child v2-3", "parent_id": 3}|]+        request methodPost "/children?select=id,parent(*)"+            [("Prefer", "return=representation"), ("Content-Profile", "v2")]+            [json|{"id": 0, "name": "child v2-3", "parent_id": 3}|]           `shouldRespondWith`-          [json|[{"id":1, "parent": {"id": 3, "name": "parent v2-3"}}]|]-          {-            matchStatus = 201-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]-          }+            [json|[{"id": 0, "parent": {"id": 3, "name": "parent v2-3"}}]|]+            {+              matchStatus = 201+            , matchHeaders = ["Content-Profile" <:> "v2"]+            }        it "fails when inserting on an unknown schema" $         request methodPost "/children" [("Content-Profile", "unknown")]@@ -180,18 +184,12 @@           }        it "succeeds on deleting on the v2 schema" $ do-        request methodDelete "/children?id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")] ""-          `shouldRespondWith` [json|[{"id": 1, "name": "child v2-1 updated", "parent_id": 3}]|]-          {-            matchStatus = 200-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]-          }-        request methodGet "/children?id=eq.1" [("Accept-Profile", "v2")] ""-          `shouldRespondWith` "[]"-          {-            matchStatus = 200-          , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]-          }+        request methodDelete "/children?id=eq.1"+            [("Content-Profile", "v2"), ("Prefer", "return=representation")]+            ""+          `shouldRespondWith`+            [json|[{"id": 1, "name": "child v2-3", "parent_id": 3}]|]+            { matchHeaders = ["Content-Profile" <:> "v2"] }        when (actualPgVersion >= pgVersion96) $         it "succeeds on PUT on the v2 schema" $
+ test/Feature/OpenApiSpec.hs view
@@ -0,0 +1,504 @@+module Feature.OpenApiSpec where++import Control.Lens     ((^?))+import Data.Aeson.Types (Value (..))+import Network.Wai      (Application)+import Network.Wai.Test (SResponse (..))++import Data.Aeson.Lens+import Data.Aeson.QQ+import Network.HTTP.Types+import Test.Hspec         hiding (pendingWith)+import Test.Hspec.Wai++import PostgREST.Version (docsVersion)+import Protolude         hiding (get)+import SpecHelper++spec :: SpecWith ((), Application)+spec = describe "OpenAPI" $ do+  it "root path returns a valid openapi spec" $ do+    validateOpenApiResponse [("Accept", "application/openapi+json")]+    request methodHead "/" (acceptHdrs "application/openapi+json") ""+      `shouldRespondWith` "" { matchStatus  = 200 }++  it "should respond to openapi request on none root path with 415" $+    request methodGet "/items"+            (acceptHdrs "application/openapi+json") ""+      `shouldRespondWith` 415++  it "includes postgrest.org current version api docs" $ do+    r <- simpleBody <$> get "/"++    let docsUrl = r ^? key "externalDocs" . key "url"++    liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))++  describe "table" $ do++    it "includes paths to tables" $ do+      r <- simpleBody <$> get "/"++      let method s = key "paths" . key "/child_entities" . key s+          childGetSummary = r ^? method "get" . key "summary"+          childGetDescription = r ^? method "get" . key "description"+          getParameters = r ^? method "get" . key "parameters"+          postParameters = r ^? method "post" . key "parameters"+          postResponse = r ^? method "post" . key "responses" . key "201" . key "description"+          patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description"+          deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description"++      let grandChildGet s = key "paths" . key "/grandchild_entities" . key "get" . key s+          grandChildGetSummary = r ^? grandChildGet "summary"+          grandChildGetDescription = r ^? grandChildGet "description"++      liftIO $ do++        childGetSummary `shouldBe` Just "child_entities comment"++        childGetDescription `shouldBe` Nothing++        grandChildGetSummary `shouldBe` Just "grandchild_entities summary"++        grandChildGetDescription `shouldBe` Just "grandchild_entities description\nthat spans\nmultiple lines"++        getParameters `shouldBe` Just+          [aesonQQ|+            [+              { "$ref": "#/parameters/rowFilter.child_entities.id" },+              { "$ref": "#/parameters/rowFilter.child_entities.name" },+              { "$ref": "#/parameters/rowFilter.child_entities.parent_id" },+              { "$ref": "#/parameters/select" },+              { "$ref": "#/parameters/order" },+              { "$ref": "#/parameters/range" },+              { "$ref": "#/parameters/rangeUnit" },+              { "$ref": "#/parameters/offset" },+              { "$ref": "#/parameters/limit" },+              { "$ref": "#/parameters/preferCount" }+            ]+          |]++        postParameters `shouldBe` Just+          [aesonQQ|+            [+              { "$ref": "#/parameters/body.child_entities" },+              { "$ref": "#/parameters/select" },+              { "$ref": "#/parameters/preferReturn" }+            ]+          |]++        postResponse `shouldBe` Just "Created"++        patchResponse `shouldBe` Just "No Content"++        deleteResponse `shouldBe` Just "No Content"++    it "includes an array type for GET responses" $ do+      r <- simpleBody <$> get "/"++      let childGetSchema = r ^? key "paths"+            . key "/child_entities"+            . key "get"+            . key "responses"+            . key "200"+            . key "schema"++      liftIO $+        childGetSchema `shouldBe` Just+          [aesonQQ|+            {+              "items": {+                "$ref": "#/definitions/child_entities"+              },+              "type": "array"+            }+          |]++    it "includes definitions to tables" $ do+      r <- simpleBody <$> get "/"++      let def = r ^? key "definitions" . key "child_entities"++      liftIO $++        def `shouldBe` Just+          [aesonQQ|+            {+              "type": "object",+              "description": "child_entities comment",+              "properties": {+                "id": {+                  "description": "child_entities id comment\n\nNote:\nThis is a Primary Key.<pk/>",+                  "format": "integer",+                  "type": "integer"+                },+                "name": {+                  "description": "child_entities name comment. Can be longer than sixty-three characters long",+                  "format": "text",+                  "type": "string"+                },+                "parent_id": {+                  "description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' column='id'/>",+                  "format": "integer",+                  "type": "integer"+                }+              },+              "required": [+                "id"+              ]+            }+          |]++    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 "Foreign table" $++    it "includes foreign table properties" $ do+      r <- simpleBody <$> get "/"++      let method s = key "paths" . key "/projects_dump" . key s+          getSummary = r ^? method "get" . key "summary"+          getDescription = r ^? method "get" . key "description"+          getParameters = r ^? method "get" . key "parameters"++      liftIO $ do++        getSummary `shouldBe` Just "A temporary projects dump"++        getDescription `shouldBe` Just "Just a test for foreign tables"++        getParameters `shouldBe` Just+          [aesonQQ|+            [+              { "$ref": "#/parameters/rowFilter.projects_dump.id" },+              { "$ref": "#/parameters/rowFilter.projects_dump.name" },+              { "$ref": "#/parameters/rowFilter.projects_dump.client_id" },+              { "$ref": "#/parameters/select" },+              { "$ref": "#/parameters/order" },+              { "$ref": "#/parameters/range" },+              { "$ref": "#/parameters/rangeUnit" },+              { "$ref": "#/parameters/offset" },+              { "$ref": "#/parameters/limit" },+              { "$ref": "#/parameters/preferCount" }+            ]+          |]++  describe "Materialized view" $++    it "includes materialized view properties" $ do+      r <- simpleBody <$> get "/"++      let method s = key "paths" . key "/materialized_projects" . key s+          summary = r ^? method "get" . key "summary"+          description = r ^? method "get" . key "description"+          parameters = r ^? method "get" . key "parameters"++      liftIO $ do++        summary `shouldBe` Just "A materialized view for projects"++        description `shouldBe` Just "Just a test for materialized views"++        parameters `shouldBe` Just+          [aesonQQ|+            [+              { "$ref": "#/parameters/rowFilter.materialized_projects.id" },+              { "$ref": "#/parameters/rowFilter.materialized_projects.name" },+              { "$ref": "#/parameters/rowFilter.materialized_projects.client_id" },+              { "$ref": "#/parameters/select" },+              { "$ref": "#/parameters/order" },+              { "$ref": "#/parameters/range" },+              { "$ref": "#/parameters/rangeUnit" },+              { "$ref": "#/parameters/offset" },+              { "$ref": "#/parameters/limit" },+              { "$ref": "#/parameters/preferCount" }+            ]+          |]++  describe "VIEW that has a source FK based on a UNIQUE key" $++    it "includes fk description" $ do+      r <- simpleBody <$> get "/"++      let referralLink = r ^? key "definitions" . key "referrals" . key "properties" . key "link"++      liftIO $+        referralLink `shouldBe` Just+          [aesonQQ|+            {+              "format": "integer",+              "type": "integer",+              "description": "Note:\nThis is a Foreign Key to `pages.link`.<fk table='pages' column='link'/>"+            }+          |]++  describe "PostgreSQL to Swagger Type Mapping" $ do++    it "character varying to string" $ do+      r <- simpleBody <$> get "/"++      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character_varying"++      liftIO $++        types `shouldBe` Just+          [aesonQQ|+            {+              "format": "character varying",+              "type": "string"+            }+          |]+    it "character(1) to string" $ do+      r <- simpleBody <$> get "/"++      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character"++      liftIO $++        types `shouldBe` Just+          [aesonQQ|+            {+              "maxLength": 1,+              "format": "character",+              "type": "string"+            }+          |]++    it "text to string" $ do+      r <- simpleBody <$> get "/"++      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_text"++      liftIO $++        types `shouldBe` Just+          [aesonQQ|+            {+              "format": "text",+              "type": "string"+            }+          |]++    it "boolean to boolean" $ do+      r <- simpleBody <$> get "/"++      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_boolean"++      liftIO $++        types `shouldBe` Just+          [aesonQQ|+            {+              "format": "boolean",+              "type": "boolean"+            }+          |]++    it "smallint to integer" $ do+      r <- simpleBody <$> get "/"++      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_smallint"++      liftIO $++        types `shouldBe` Just+          [aesonQQ|+            {+              "format": "smallint",+              "type": "integer"+            }+          |]++    it "integer to integer" $ do+      r <- simpleBody <$> get "/"++      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_integer"++      liftIO $++        types `shouldBe` Just+          [aesonQQ|+            {+              "format": "integer",+              "type": "integer"+            }+          |]++    it "bigint to integer" $ do+      r <- simpleBody <$> get "/"++      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bigint"++      liftIO $++        types `shouldBe` Just+          [aesonQQ|+            {+              "format": "bigint",+              "type": "integer"+            }+          |]++    it "numeric to number" $ do+      r <- simpleBody <$> get "/"++      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_numeric"++      liftIO $++        types `shouldBe` Just+          [aesonQQ|+            {+              "format": "numeric",+              "type": "number"+            }+          |]++    it "real to number" $ do+      r <- simpleBody <$> get "/"++      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_real"++      liftIO $++        types `shouldBe` Just+          [aesonQQ|+            {+              "format": "real",+              "type": "number"+            }+          |]++    it "double_precision to number" $ do+      r <- simpleBody <$> get "/"++      let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_double_precision"++      liftIO $++        types `shouldBe` Just+          [aesonQQ|+            {+              "format": "double precision",+              "type": "number"+            }+          |]++  describe "RPC" $ do++    it "includes function summary/description and body schema for arguments" $ do+      r <- simpleBody <$> get "/"++      let method s = key "paths" . key "/rpc/varied_arguments" . key s+          args = r ^? method "post" . key "parameters" . nth 0 . key "schema"+          summary = r ^? method "post" . key "summary"+          description = r ^? method "post" . key "description"++      liftIO $ do++        summary `shouldBe` Just "An RPC function"++        description `shouldBe` Just "Just a test for RPC function arguments"++        args `shouldBe` Just+          [aesonQQ|+            {+              "required": [+                "double",+                "varchar",+                "boolean",+                "date",+                "money",+                "enum",+                "arr"+              ],+              "properties": {+                "double": {+                  "format": "double precision",+                  "type": "number"+                },+                "varchar": {+                  "format": "character varying",+                  "type": "string"+                },+                "boolean": {+                  "format": "boolean",+                  "type": "boolean"+                },+                "date": {+                  "format": "date",+                  "type": "string"+                },+                "money": {+                  "format": "money",+                  "type": "string"+                },+                "enum": {+                  "format": "enum_menagerie_type",+                  "type": "string"+                },+                "arr": {+                  "format": "text[]",+                  "type": "string"+                },+                "integer": {+                  "format": "integer",+                  "type": "integer"+                },+                "json": {+                  "format": "json",+                  "type": "string"+                },+                "jsonb": {+                  "format": "jsonb",+                  "type": "string"+                }+              },+              "type": "object",+              "description": "An RPC function\n\nJust a test for RPC function arguments"+            }+          |]++    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"]|]+
+ test/Feature/OptionsSpec.hs view
@@ -0,0 +1,71 @@+module Feature.OptionsSpec where++import Network.Wai      (Application)+import Network.Wai.Test (SResponse (..))++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai++import Protolude+import SpecHelper++spec :: SpecWith ((), Application)+spec = describe "Allow header" $ do+  context "a table" $ do+    it "includes read/write verbs for writeable table" $ do+      r <- request methodOptions "/items" [] ""+      liftIO $+        simpleHeaders r `shouldSatisfy`+          matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"++  context "a view" $ do+    context "auto updatable" $ do+      it "includes read/write verbs for auto updatable views with pk" $ do+        r <- request methodOptions "/projects_auto_updatable_view_with_pk" [] ""+        liftIO $+          simpleHeaders r `shouldSatisfy`+            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"++      it "includes read/write verbs for auto updatable views without pk" $ do+        r <- request methodOptions "/projects_auto_updatable_view_without_pk" [] ""+        liftIO $+          simpleHeaders r `shouldSatisfy`+            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PATCH,DELETE"++    context "non auto updatable" $ do+      it "includes read verbs for non auto updatable views" $ do+        r <- request methodOptions "/projects_view_without_triggers" [] ""+        liftIO $+          simpleHeaders r `shouldSatisfy`+            matchHeader "Allow" "OPTIONS,GET,HEAD"++      it "includes read/write verbs for insertable, updatable and deletable views with pk" $ do+        r <- request methodOptions "/projects_view_with_all_triggers_with_pk" [] ""+        liftIO $+          simpleHeaders r `shouldSatisfy`+            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"++      it "includes read/write verbs for insertable, updatable and deletable views without pk" $ do+        r <- request methodOptions "/projects_view_with_all_triggers_without_pk" [] ""+        liftIO $+          simpleHeaders r `shouldSatisfy`+            matchHeader "Allow" "OPTIONS,GET,HEAD,POST,PATCH,DELETE"++      it "includes read and insert verbs for insertable views" $ do+        r <- request methodOptions "/projects_view_with_insert_trigger" [] ""+        liftIO $+          simpleHeaders r `shouldSatisfy`+            matchHeader "Allow" "OPTIONS,GET,HEAD,POST"++      it "includes read and update verbs for updatable views" $ do+        r <- request methodOptions "/projects_view_with_update_trigger" [] ""+        liftIO $+          simpleHeaders r `shouldSatisfy`+            matchHeader "Allow" "OPTIONS,GET,HEAD,PATCH"++      it "includes read and delete verbs for deletable views" $ do+        r <- request methodOptions "/projects_view_with_delete_trigger" [] ""+        liftIO $+          simpleHeaders r `shouldSatisfy`+            matchHeader "Allow" "OPTIONS,GET,HEAD,DELETE"
− test/Feature/PgVersion95Spec.hs
@@ -1,55 +0,0 @@-module Feature.PgVersion95Spec where--import Network.Wai (Application)--import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import Protolude  hiding (get)-import SpecHelper--spec :: SpecWith ((), Application)-spec = describe "features supported on PostgreSQL 9.5" $-  context "json array negative index" $ do-    it "can select with negative indexes" $ do-      get "/json_arr?select=data->>-1::int&id=in.(1,2)" `shouldRespondWith`-        [json| [{"data":3}, {"data":6}] |]-        { matchHeaders = [matchContentTypeJson] }-      get "/json_arr?select=data->0->>-2::int&id=in.(3,4)" `shouldRespondWith`-        [json| [{"data":8}, {"data":7}] |]-        { matchHeaders = [matchContentTypeJson] }-      get "/json_arr?select=data->-2->>a&id=in.(5,6)" `shouldRespondWith`-        [json| [{"a":"A"}, {"a":"[1,2,3]"}] |]-        { matchHeaders = [matchContentTypeJson] }--    it "can filter with negative indexes" $ do-      get "/json_arr?select=data&data->>-3=eq.1" `shouldRespondWith`-        [json| [{"data":[1, 2, 3]}] |]-        { matchHeaders = [matchContentTypeJson] }-      get "/json_arr?select=data&data->-1->>-3=eq.11" `shouldRespondWith`-        [json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]-        { matchHeaders = [matchContentTypeJson] }-      get "/json_arr?select=data&data->-1->>b=eq.B" `shouldRespondWith`-        [json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]-        { matchHeaders = [matchContentTypeJson] }-      get "/json_arr?select=data&data->-1->b->>-1=eq.5" `shouldRespondWith`-        [json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]-        { matchHeaders = [matchContentTypeJson] }--    it "should fail on badly formed negatives" $ do-      get "/json_arr?select=data->>-78xy" `shouldRespondWith`-        [json|-          {"details": "unexpected 'x' expecting digit, \"->\", \"::\" or end of input",-           "message": "\"failed to parse select parameter (data->>-78xy)\" (line 1, column 11)"} |]-        { matchStatus = 400, matchHeaders = [matchContentTypeJson] }-      get "/json_arr?select=data->>--34" `shouldRespondWith`-        [json|-          {"details": "unexpected \"-\" expecting digit",-           "message": "\"failed to parse select parameter (data->>--34)\" (line 1, column 9)"} |]-        { matchStatus = 400, matchHeaders = [matchContentTypeJson] }-      get "/json_arr?select=data->>-xy-4" `shouldRespondWith`-        [json|-          {"details":"unexpected \"x\" expecting digit",-           "message":"\"failed to parse select parameter (data->>-xy-4)\" (line 1, column 9)"} |]-        { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
− test/Feature/PgVersion96Spec.hs
@@ -1,195 +0,0 @@-module Feature.PgVersion96Spec where--import Network.HTTP.Types-import Network.Wai        (Application)-import Network.Wai.Test   (SResponse (simpleHeaders))--import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import Protolude  hiding (get)-import SpecHelper--spec :: SpecWith ((), Application)-spec =-  describe "features supported on PostgreSQL 9.6" $ do-    context "GUC headers on function calls" $ 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`-          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]-          { matchStatus  = 500-          , matchHeaders = [ matchContentTypeJson ]-          }-        get "/rpc/bad_guc_headers_2"-          `shouldRespondWith`-          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]-          { matchStatus  = 500-          , matchHeaders = [ matchContentTypeJson ]-          }-        get "/rpc/bad_guc_headers_3"-          `shouldRespondWith`-          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]-          { matchStatus  = 500-          , matchHeaders = [ matchContentTypeJson ]-          }-        post "/rpc/bad_guc_headers_1" [json|{}|]-          `shouldRespondWith`-          [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]-          { matchStatus  = 500-          , matchHeaders = [ matchContentTypeJson ]-          }--      it "can set the same http header twice" $-        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 "GUC headers on all other methods via pre-request" $ do-      it "succeeds setting the headers on GET and HEAD" $ do-        request methodGet "/items?id=eq.1" [("User-Agent", "MSIE 6.0")] mempty-          `shouldRespondWith` [json|[{"id": 1}]|]-          {matchHeaders = [-              matchContentTypeJson,-              "Cache-Control" <:> "no-cache, no-store, must-revalidate"]}--        request methodHead "/items?id=eq.1" [("User-Agent", "MSIE 7.0")] mempty-          `shouldRespondWith` ""-          {matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"]}--        request methodHead "/projects" [("Accept", "text/csv")] mempty-          `shouldRespondWith` ""-          {matchHeaders = ["Content-Disposition" <:> "attachment; filename=projects.csv"]}--      it "succeeds setting the headers on POST" $-        request methodPost "/items" [] [json|[{"id": 11111}]|]-          `shouldRespondWith` ""-          { matchStatus = 201-          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]-          }--      it "succeeds setting the headers on PATCH" $-        request methodPatch "/items?id=eq.11111" [] [json|[{"id": 11111}]|]-          `shouldRespondWith` ""-          { matchStatus = 204-          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]-          }--      it "succeeds setting the headers on PUT" $-        request methodPut "/items?id=eq.11111" [] [json|[{"id": 11111}]|]-          `shouldRespondWith` ""-          { matchStatus = 204-          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]-          }--      it "succeeds setting the headers on DELETE" $-        request methodDelete "/items?id=eq.11111" [] mempty-          `shouldRespondWith` ""-          { matchStatus = 204-          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]-          }--    context "Override provided headers by using GUC headers" $ do-      it "can override the Content-Type header" $ do-        request methodHead "/clients?id=eq.1" [] mempty-          `shouldRespondWith` ""-          { matchStatus = 200-          , matchHeaders = ["Content-Type" <:> "application/geo+json"]-          }-        request methodHead "/rpc/getallprojects" [] mempty-          `shouldRespondWith` ""-          { matchStatus = 200-          , matchHeaders = ["Content-Type" <:> "application/geo+json"]-          }--      it "can override the Location header" $-        request methodPost "/stuff" [] [json|[{"id": 1, "name": "stuff 1"}]|]-          `shouldRespondWith` ""-          { matchStatus = 201-          , matchHeaders = ["Location" <:> "/stuff?id=eq.1&overriden=true"]-          }--    -- On https://github.com/PostgREST/postgrest/issues/1427#issuecomment-595907535-    -- it was reported that blank headers ` : ` where added and that cause proxies to fail the requests.-    -- These tests are to ensure no blank headers are added.-    context "Blank headers bug" $ do-      it "shouldn't add blank headers on POST" $ do-        r <- request methodPost "/loc_test" [] [json|{"id": "1", "c": "c1"}|]-        liftIO $ do-          let respHeaders = simpleHeaders r-          respHeaders `shouldSatisfy` noBlankHeader--      it "shouldn't add blank headers on PATCH" $ do-        r <- request methodPatch "/loc_test?id=eq.1" [] [json|{"c": "c2"}|]-        liftIO $ do-          let respHeaders = simpleHeaders r-          respHeaders `shouldSatisfy` noBlankHeader--      it "shouldn't add blank headers on GET" $ do-        r <- request methodGet "/loc_test" [] ""-        liftIO $ do-          let respHeaders = simpleHeaders r-          respHeaders `shouldSatisfy` noBlankHeader--      it "shouldn't add blank headers on DELETE" $ do-        r <- request methodDelete "/loc_test?id=eq.1" [] ""-        liftIO $ do-          let respHeaders = simpleHeaders r-          respHeaders `shouldSatisfy` noBlankHeader--    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
@@ -1,7 +1,6 @@ module Feature.QueryLimitedSpec where -import Network.Wai      (Application)-import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))+import Network.Wai (Application)  import Network.HTTP.Types import Test.Hspec@@ -15,59 +14,56 @@ spec =   describe "Requesting many items with server limits(max-rows) enabled" $ do     it "restricts results" $-      get "/items"-        `shouldRespondWith` [json| [{"id":1},{"id":2}] |]-        { matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-1/*"]-        }+      get "/items?order=id"+        `shouldRespondWith`+          [json| [{"id":1},{"id":2}] |]+          { matchHeaders = ["Content-Range" <:> "0-1/*"] }      it "respects additional client limiting" $ do-      r <- request methodGet  "/items"-                   (rangeHdrs $ ByteRangeFromTo 0 0) ""-      liftIO $ do-        simpleHeaders r `shouldSatisfy`-          matchHeader "Content-Range" "0-0/*"-        simpleStatus r `shouldBe` ok200+      request methodGet  "/items"+          (rangeHdrs $ ByteRangeFromTo 0 0)+          ""+        `shouldRespondWith`+          [json| [{"id":1}] |]+          { matchHeaders = ["Content-Range" <:> "0-0/*"] }      it "works on all levels" $       get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"-        `shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]-        { matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-1/*"]-        }+        `shouldRespondWith`+          [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]+          { matchHeaders = ["Content-Range" <:> "0-1/*"] }      it "succeeds in getting parent embeds despite the limit, see #647" $       get "/tasks?select=id,project:projects(id)&id=gt.5"-        `shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]-        { matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-1/*"]-        }+        `shouldRespondWith`+          [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]+          { matchHeaders = ["Content-Range" <:> "0-1/*"] }      it "can offset the parent embed, being consistent with the other embed types" $       get "/tasks?select=id,project:projects(id)&id=gt.5&project.offset=1"-        `shouldRespondWith` [json|[{"id":6,"project":null}, {"id":7,"project":null}]|]-        { matchStatus  = 200-        , matchHeaders = ["Content-Range" <:> "0-1/*"]-        }+        `shouldRespondWith`+          [json|[{"id":6,"project":null}, {"id":7,"project":null}]|]+          { matchHeaders = ["Content-Range" <:> "0-1/*"] }      context "count=estimated" $ do       it "uses the query planner guess when query rows > maxRows" $         request methodHead "/getallprojects_view" [("Prefer", "count=estimated")] ""-          `shouldRespondWith` ""-          { matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-1/2019"]-          }+          `shouldRespondWith`+            ""+            { matchStatus  = 206+            , matchHeaders = ["Content-Range" <:> "0-1/2019"]+            }        it "gives exact count when query rows <= maxRows" $         request methodHead "/getallprojects_view?id=lt.3" [("Prefer", "count=estimated")] ""-          `shouldRespondWith` ""-          { matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-1/2"]-          }+          `shouldRespondWith`+            ""+            { matchHeaders = ["Content-Range" <:> "0-1/2"] }        it "only uses the query planner guess if it's indeed greater than the exact count" $         request methodHead "/get_projects_above_view" [("Prefer", "count=estimated")] ""-          `shouldRespondWith` ""-          { matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-1/3"]-          }+          `shouldRespondWith`+            ""+            { matchStatus  = 206+            , matchHeaders = ["Content-Range" <:> "0-1/3"]+            }
test/Feature/QuerySpec.hs view
@@ -8,10 +8,9 @@ import Test.Hspec.Wai import Test.Hspec.Wai.JSON -import Text.Heredoc--import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)-import Protolude       hiding (get)+import PostgREST.Config.PgVersion (PgVersion, pgVersion112,+                                   pgVersion121, pgVersion96)+import Protolude                  hiding (get) import SpecHelper  spec :: PgVersion -> SpecWith ((), Application)@@ -36,7 +35,7 @@         { matchHeaders = ["Content-Range" <:> "0-0/*"] }      it "matches with equality using not operator" $-      get "/items?id=not.eq.5"+      get "/items?id=not.eq.5&order=id"         `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]         { matchHeaders = ["Content-Range" <:> "0-13/*"] } @@ -71,19 +70,19 @@         [json| [{"a": null, "b": null}] |]         { matchHeaders = [matchContentTypeJson] } -      get "/nullable_integer?a=is.null" `shouldRespondWith` [str|[{"a":null}]|]+      get "/nullable_integer?a=is.null" `shouldRespondWith` [json|[{"a":null}]|]      it "matches with like" $ do       get "/simple_pk?k=like.*yx" `shouldRespondWith`-        [str|[{"k":"xyyx","extra":"u"}]|]+        [json|[{"k":"xyyx","extra":"u"}]|]       get "/simple_pk?k=like.xy*" `shouldRespondWith`-        [str|[{"k":"xyyx","extra":"u"}]|]+        [json|[{"k":"xyyx","extra":"u"}]|]       get "/simple_pk?k=like.*YY*" `shouldRespondWith`-        [str|[{"k":"xYYx","extra":"v"}]|]+        [json|[{"k":"xYYx","extra":"v"}]|]      it "matches with like using not operator" $       get "/simple_pk?k=not.like.*yx" `shouldRespondWith`-        [str|[{"k":"xYYx","extra":"v"}]|]+        [json|[{"k":"xYYx","extra":"v"}]|]      it "matches with ilike" $ do       get "/simple_pk?k=ilike.xy*&order=extra.asc" `shouldRespondWith`@@ -183,6 +182,35 @@                     {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]                   { matchHeaders = [matchContentTypeJson] } +      when (actualPgVersion >= pgVersion96) $+        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 "matches with computed column" $       get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`         [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]@@ -197,8 +225,10 @@       get "/items?always_false=is.false" `shouldRespondWith` 400      it "matches filtering nested items 2" $-      get "/clients?select=id,projects(id,tasks2(id,name))&projects.tasks.name=like.Design*"-        `shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities. No relationship found between projects and tasks2"}|]+      get "/clients?select=id,projects(id,tasks2(id,name))&projects.tasks.name=like.Design*" `shouldRespondWith`+        [json| {+          "hint":"If a new foreign key between these entities was created in the database, try reloading the schema cache.",+          "message":"Could not find a relationship between projects and tasks2 in the schema cache"}|]         { matchStatus  = 400         , matchHeaders = [matchContentTypeJson]         }@@ -221,11 +251,11 @@   describe "Shaping response with select parameter" $ do     it "selectStar works in absense of parameter" $       get "/complex_items?id=eq.3" `shouldRespondWith`-        [str|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|]+        [json|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|]      it "dash `-` in column names is accepted" $       get "/complex_items?id=eq.3&select=id,field-with_sep" `shouldRespondWith`-        [str|[{"id":3,"field-with_sep":1}]|]+        [json|[{"id":3,"field-with_sep":1}]|]      it "one simple column" $       get "/complex_items?select=id" `shouldRespondWith`@@ -290,7 +320,7 @@      it "requesting parents and filtering parent columns" $       get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`-        [str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]+        [json|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]      it "rows with missing parents are included" $       get "/projects?id=in.(1,5)&select=id,clients(id)" `shouldRespondWith`@@ -299,7 +329,7 @@      it "rows with no children return [] instead of null" $       get "/projects?id=in.(5)&select=id,tasks(id)" `shouldRespondWith`-        [str|[{"id":5,"tasks":[]}]|]+        [json|[{"id":5,"tasks":[]}]|]      it "requesting children 2 levels" $       get "/clients?id=eq.1&select=id,projects(id,tasks(id))" `shouldRespondWith`@@ -321,6 +351,21 @@         [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 many<->many relation using composite key" $+      get "/files?filename=eq.autoexec.bat&project_id=eq.1&select=filename,users_tasks(user_id,task_id)" `shouldRespondWith`+        [json|[{"filename":"autoexec.bat","users_tasks":[{"user_id":1,"task_id":1},{"user_id":3,"task_id":1}]}]|]+        { matchHeaders = [matchContentTypeJson] }++    it "requesting data using many<->many relation defined by composite keys" $+      get "/users_tasks?user_id=eq.1&task_id=eq.1&select=user_id,files(filename,content)" `shouldRespondWith`+        [json|[{"user_id":1,"files":[{"filename":"command.com","content":"#include <unix.h>"},{"filename":"autoexec.bat","content":"@ECHO OFF"},{"filename":"README.md","content":"# make $$$!"}]}]|]+        { matchHeaders = [matchContentTypeJson] }++    it "requesting data using many<->many (composite keys) relation using hint" $+      get "/users_tasks?user_id=eq.1&task_id=eq.1&select=user_id,files!touched_files(filename,content)" `shouldRespondWith`+        [json|[{"user_id":1,"files":[{"filename":"command.com","content":"#include <unix.h>"},{"filename":"autoexec.bat","content":"@ECHO OFF"},{"filename":"README.md","content":"# make $$$!"}]}]|]+        { matchHeaders = [matchContentTypeJson] }+     it "requesting children with composite key" $       get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments(content)" `shouldRespondWith`         [json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]@@ -358,9 +403,9 @@         get "/materialized_projects?select=*,users(*)" `shouldRespondWith` 200        it "can request two parents" $-        get "/articleStars?select=createdAt,article:articles(owner),user:users(name)&limit=1" `shouldRespondWith`-          [json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|]-          { matchHeaders = [matchContentTypeJson] }+        get "/articleStars?select=createdAt,article:articles(id),user:users(name)&limit=1"+          `shouldRespondWith`+            [json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"id": 1},"user":{"name": "Angela Martin"}}]|]        it "can 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`@@ -391,6 +436,9 @@           [json|[ { "title": "To Kill a Mockingbird", "author": { "name": "Harper Lee" } } ]|]           { matchHeaders = [matchContentTypeJson] } +      it "can detect fk relations through multiple views recursively when all views are in api schema" $ do+        get "/consumers_view_view?select=*,orders_view(*)" `shouldRespondWith` 200+       it "works with views that have subselects" $         get "/authors_books_number?select=*,books(title)&id=eq.1" `shouldRespondWith`           [json|[ {"id":1, "name":"George Orwell","num_in_forties":1,"num_in_fifties":0,"num_in_sixties":0,"num_in_all_decades":1,@@ -408,6 +456,12 @@           [json|[{"title":"1984","first_publisher":"Secker & Warburg","author":{"name":"George Orwell"}}]|]           { matchHeaders = [matchContentTypeJson] } +      it "works with views that have subselects in a function call" $+        get "/authors_have_book_in_decade2?select=*,books(title)&id=eq.3"+          `shouldRespondWith`+            [json|[ {"id":3,"name":"Antoine de Saint-Exupéry","has_book_in_forties":true,"has_book_in_fifties":false,+                     "has_book_in_sixties":false,"books":[{"title":"The Little Prince"}]} ]|]+       it "works with views that have CTE" $         get "/odd_years_publications?select=title,publication_year,first_publisher,author:authors(name)&id=in.(1,2,3)" `shouldRespondWith`           [json|[@@ -612,7 +666,7 @@      it "ordering embeded parents does not break things" $       get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith`-        [str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|]+        [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|]      context "order syntax errors" $ do       it "gives meaningful error messages when asc/desc/nulls{first,last} are misspelled" $ do@@ -762,7 +816,7 @@       request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") ""         `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"         { matchStatus = 200-        , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]+        , matchHeaders = ["Content-Type" <:> "application/octet-stream"]         }      it "can get raw output with Accept: text/plain" $@@ -775,20 +829,28 @@     it "fails if a single column is not selected" $ do       request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""         `shouldRespondWith`-        [json| {"message":"application/octet-stream requested but more than one column was selected"} |]-        { matchStatus = 406-        , matchHeaders = [matchContentTypeJson]-        }-      request methodGet "/images?select=*&name=eq.A.png" (acceptHdrs "application/octet-stream") ""-        `shouldRespondWith` 406-      request methodGet "/images?name=eq.A.png" (acceptHdrs "application/octet-stream") ""-        `shouldRespondWith` 406+          [json| {"message":"application/octet-stream requested but more than one column was selected"} |]+          { matchStatus = 406 } +      request methodGet "/images?select=*&name=eq.A.png"+          (acceptHdrs "application/octet-stream")+          ""+        `shouldRespondWith`+          [json| {"message":"application/octet-stream requested but more than one column was selected"} |]+          { matchStatus = 406 }++      request methodGet "/images?name=eq.A.png"+          (acceptHdrs "application/octet-stream")+          ""+        `shouldRespondWith`+          [json| {"message":"application/octet-stream requested but more than one column was selected"} |]+          { matchStatus = 406 }+     it "concatenates results if more than one row is returned" $       request methodGet "/images_base64?select=img&name=in.(A.png,B.png)" (acceptHdrs "application/octet-stream") ""         `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="         { matchStatus = 200-        , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]+        , matchHeaders = ["Content-Type" <:> "application/octet-stream"]         }    describe "values with quotes in IN and NOT IN" $ do@@ -800,7 +862,7 @@         [json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |]         { 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"}] |]+        [json| [{"name":"David White"},{"name":"Larry Thompson"},{"name":"Double O Seven(007)"}] |]         { matchHeaders = [matchContentTypeJson] }      it "succeeds w/ and w/o quoted values" $ do@@ -808,16 +870,16 @@         [json| [{"name":"Hebdon, John"},{"name":"David White"}] |]         { matchHeaders = [matchContentTypeJson] }       get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",Larry Thompson,\"Smith, Joseph\")" `shouldRespondWith`-        [json| [{"name":"Williams, Mary"},{"name":"David White"}] |]+        [json| [{"name":"Williams, Mary"},{"name":"David White"},{"name":"Double O Seven(007)"}] |]         { matchHeaders = [matchContentTypeJson] }+      get "/w_or_wo_comma_names?name=in.(\"Double O Seven(007)\")" `shouldRespondWith`+        [json| [{"name":"Double O Seven(007)"}] |]+        { matchHeaders = [matchContentTypeJson] } -    it "checks well formed quoted values" $ do-      get "/w_or_wo_comma_names?name=in.(\"\"Hebdon, John\")" `shouldRespondWith`-        [json| [] |] { matchHeaders = [matchContentTypeJson] }-      get "/w_or_wo_comma_names?name=in.(\"\"Hebdon, John\"\"Mary)" `shouldRespondWith`-        [json| [] |] { matchHeaders = [matchContentTypeJson] }-      get "/w_or_wo_comma_names?name=in.(Williams\"Hebdon, John\")" `shouldRespondWith`-        [json| [] |] { matchHeaders = [matchContentTypeJson] }+    it "fails on malformed quoted values" $ do+      get "/w_or_wo_comma_names?name=in.(\"\"Hebdon, John\")" `shouldRespondWith` 400+      get "/w_or_wo_comma_names?name=in.(\"\"Hebdon, John\"\"Mary)" `shouldRespondWith` 400+      get "/w_or_wo_comma_names?name=in.(Williams\"Hebdon, John\")" `shouldRespondWith` 400    describe "IN and NOT IN empty set" $ do     context "returns an empty result for IN when no value is present" $ do
test/Feature/RangeSpec.hs view
@@ -33,9 +33,11 @@             `shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]}          it "returns range Content-Range with range/*" $-          request methodPost "/rpc/getitemrange" [] defaultRange-            `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]-            { matchHeaders = ["Content-Range" <:> "0-14/*"] }+          post "/rpc/getitemrange?order=id"+              defaultRange+            `shouldRespondWith`+              [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]+              { matchHeaders = ["Content-Range" <:> "0-14/*"] }      context "with range headers" $ do       context "of acceptable range" $ do@@ -168,20 +170,16 @@           }        it "succeeds if offset equals 0 as a no-op" $-        get "/items?select=id&offset=0"+        get "/items?select=id&offset=0&order=id"           `shouldRespondWith`-          [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]-          { matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-14/*"]-          }+            [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]+            { matchHeaders = ["Content-Range" <:> "0-14/*"] }        it "succeeds if offset is negative as a no-op" $-        get "/items?select=id&offset=-4"+        get "/items?select=id&offset=-4&order=id"           `shouldRespondWith`-          [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]-          { matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-14/*"]-          }+            [json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]+            { matchHeaders = ["Content-Range" <:> "0-14/*"] }        it "fails if limit equals 0" $         get "/items?select=id&limit=0"@@ -199,62 +197,98 @@      context "when count=planned" $ do       it "obtains a filtered range" $ do-        request methodGet "/items?select=id&id=gt.8" [("Prefer", "count=planned")] ""-          `shouldRespondWith` [json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|]-          { matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-6/8"]-          }-        request methodGet "/child_entities?select=id&id=gt.3" [("Prefer", "count=planned")] ""-          `shouldRespondWith` [json|[{"id":4}, {"id":5}, {"id":6}]|]-          { matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-2/4"]-          }-        request methodGet "/getallprojects_view?select=id&id=lt.3" [("Prefer", "count=planned")] ""-          `shouldRespondWith` [json|[{"id":1}, {"id":2}]|]-          { matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-1/673"]-          }+        request methodGet "/items?select=id&id=gt.8"+            [("Prefer", "count=planned")]+            ""+          `shouldRespondWith`+            [json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|]+            { matchStatus  = 206+            , matchHeaders = ["Content-Range" <:> "0-6/8"]+            } +        request methodGet "/child_entities?select=id&id=gt.3"+            [("Prefer", "count=planned")]+            ""+          `shouldRespondWith`+            [json|[{"id":4}, {"id":5}, {"id":6}]|]+            { matchStatus  = 206+            , matchHeaders = ["Content-Range" <:> "0-2/4"]+            }++        request methodGet "/getallprojects_view?select=id&id=lt.3"+            [("Prefer", "count=planned")]+            ""+          `shouldRespondWith`+            [json|[{"id":1}, {"id":2}]|]+            { matchStatus  = 206+            , matchHeaders = ["Content-Range" <:> "0-1/673"]+            }+       it "obtains the full range" $ do-        request methodHead "/items" [("Prefer", "count=planned")] ""-          `shouldRespondWith` ""-          { matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-14/15"]-          }-        request methodHead "/child_entities" [("Prefer", "count=planned")] ""-          `shouldRespondWith` ""-          { matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-5/6"]-          }-        request methodHead "/getallprojects_view" [("Prefer", "count=planned")] ""-          `shouldRespondWith` ""-          { matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-4/2019"]-          }+        request methodHead "/items"+            [("Prefer", "count=planned")]+            ""+          `shouldRespondWith`+            ""+            { matchStatus  = 200+            , matchHeaders = ["Content-Range" <:> "0-14/15"]+            } +        request methodHead "/child_entities"+            [("Prefer", "count=planned")]+            ""+          `shouldRespondWith`+            ""+            { matchStatus  = 200+            , matchHeaders = ["Content-Range" <:> "0-5/6"]+            }++        request methodHead "/getallprojects_view"+            [("Prefer", "count=planned")]+            ""+          `shouldRespondWith`+            ""+            { matchStatus  = 206+            , matchHeaders = ["Content-Range" <:> "0-4/2019"]+            }+       it "ignores limit/offset on the planned count" $ do-        request methodHead "/items?limit=2&offset=3" [("Prefer", "count=planned")] ""-          `shouldRespondWith` ""-          { matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "3-4/15"]-          }-        request methodHead "/child_entities?limit=2" [("Prefer", "count=planned")] ""-          `shouldRespondWith` ""-          { matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-1/6"]-          }-        request methodHead "/getallprojects_view?limit=2" [("Prefer", "count=planned")] ""-          `shouldRespondWith` ""-          { matchStatus  = 206-          , matchHeaders = ["Content-Range" <:> "0-1/2019"]-          }+        request methodHead "/items?limit=2&offset=3"+            [("Prefer", "count=planned")]+            ""+          `shouldRespondWith`+            ""+            { matchStatus  = 206+            , matchHeaders = ["Content-Range" <:> "3-4/15"]+            } +        request methodHead "/child_entities?limit=2"+           [("Prefer", "count=planned")]+            ""+          `shouldRespondWith`+            ""+            { matchStatus  = 206+            , matchHeaders = ["Content-Range" <:> "0-1/6"]+            }++        request methodHead "/getallprojects_view?limit=2"+            [("Prefer", "count=planned")]+            ""+          `shouldRespondWith`+            ""+            { matchStatus  = 206+            , matchHeaders = ["Content-Range" <:> "0-1/2019"]+            }+       it "works with two levels" $-        request methodHead "/child_entities?select=*,entities(*)" [("Prefer", "count=planned")] ""-          `shouldRespondWith` ""-          { matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-5/6"]-          }+        request methodHead "/child_entities?select=*,entities(*)"+            [("Prefer", "count=planned")]+            ""+          `shouldRespondWith`+            ""+            { matchStatus  = 200+            , matchHeaders = ["Content-Range" <:> "0-5/6"]+            }      context "with range headers" $ do       context "of acceptable range" $ do
test/Feature/RawOutputTypesSpec.hs view
@@ -24,7 +24,7 @@         { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }    it "responds json to a GET request to RPC with Firefox Accept headers" $-    request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""+    request methodGet "/rpc/get_projects_below?id=3" firefoxAcceptHdrs ""       `shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]         { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }   it "responds json to a GET request to RPC with Chrome Accept headers" $
+ test/Feature/RollbackSpec.hs view
@@ -0,0 +1,218 @@+module Feature.RollbackSpec where++import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import Protolude hiding (get)++-- two helpers functions to make sure that each test can setup and cleanup properly++-- creates Item to work with for PATCH and DELETE+postItem =+  request methodPost "/items"+      [("Prefer", "tx=commit"), ("Prefer", "resolution=ignore-duplicates")]+      [json|{"id":0}|]+    `shouldRespondWith`+      ""+      { matchStatus  = 201 }++-- removes Items left over from POST, PUT, and PATCH+deleteItems =+  request methodDelete "/items?id=lte.0"+      [("Prefer", "tx=commit")]+      ""+    `shouldRespondWith`+      ""+      { matchStatus  = 204 }++preferDefault  = [("Prefer", "return=representation")]+preferCommit   = [("Prefer", "return=representation"), ("Prefer", "tx=commit")]+preferRollback = [("Prefer", "return=representation"), ("Prefer", "tx=rollback")]++withoutPreferenceApplied      = []+withPreferenceCommitApplied   = [ "Preference-Applied" <:> "tx=commit" ]+withPreferenceRollbackApplied = [ "Preference-Applied" <:> "tx=rollback" ]++shouldRespondToReads reqHeaders respHeaders = do+  it "responds to GET" $ do+    request methodGet "/items?id=eq.1"+        reqHeaders+        ""+      `shouldRespondWith`+        [json|[{"id":1}]|]+        { matchHeaders = respHeaders }++  it "responds to HEAD" $ do+    request methodHead "/items?id=eq.1"+        reqHeaders+        ""+      `shouldRespondWith`+        ""+        { matchHeaders = respHeaders }++  it "responds to GET on RPC" $ do+    request methodGet "/rpc/search?id=1"+        reqHeaders+        ""+      `shouldRespondWith`+        [json|[{"id":1}]|]+        { matchHeaders = respHeaders }++  it "responds to POST on RPC" $ do+    request methodPost "/rpc/search"+        reqHeaders+        [json|{"id":1}|]+      `shouldRespondWith`+        [json|[{"id":1}]|]+        { matchHeaders = respHeaders }++shouldPersistMutations reqHeaders respHeaders = do+  it "does persist post" $ do+    request methodPost "/items"+        reqHeaders+        [json|{"id":0}|]+      `shouldRespondWith`+        [json|[{"id":0}]|]+        { matchStatus  = 201+        , matchHeaders = respHeaders }+    get "/items?id=eq.0"+      `shouldRespondWith`+        [json|[{"id":0}]|]+    deleteItems++  it "does persist put" $ do+    request methodPut "/items?id=eq.0"+        reqHeaders+        [json|{"id":0}|]+      `shouldRespondWith`+        [json|[{"id":0}]|]+        { matchHeaders = respHeaders }+    get "/items?id=eq.0"+      `shouldRespondWith`+        [json|[{"id":0}]|]+    deleteItems++  it "does persist patch" $ do+    postItem+    request methodPatch "/items?id=eq.0"+        reqHeaders+        [json|{"id":-1}|]+      `shouldRespondWith`+        [json|[{"id":-1}]|]+        { matchHeaders = respHeaders }+    get "/items?id=eq.0"+      `shouldRespondWith`+        [json|[]|]+    get "/items?id=eq.-1"+      `shouldRespondWith`+        [json|[{"id":-1}]|]+    deleteItems++  it "does persist delete" $ do+    postItem+    request methodDelete "/items?id=eq.0"+        reqHeaders+        ""+      `shouldRespondWith`+        [json|[{"id":0}]|]+        { matchHeaders = respHeaders }+    get "/items?id=eq.0"+      `shouldRespondWith`+        [json|[]|]++shouldNotPersistMutations reqHeaders respHeaders = do+  it "does not persist post" $ do+    request methodPost "/items"+        reqHeaders+        [json|{"id":0}|]+      `shouldRespondWith`+        [json|[{"id":0}]|]+        { matchStatus  = 201+        , matchHeaders = respHeaders }+    get "/items?id=eq.0"+      `shouldRespondWith`+        [json|[]|]++  it "does not persist put" $ do+    request methodPut "/items?id=eq.0"+        reqHeaders+        [json|{"id":0}|]+      `shouldRespondWith`+        [json|[{"id":0}]|]+        { matchHeaders = respHeaders }+    get "/items?id=eq.0"+      `shouldRespondWith`+        [json|[]|]++  it "does not persist patch" $ do+    request methodPatch "/items?id=eq.1"+        reqHeaders+        [json|{"id":0}|]+      `shouldRespondWith`+        [json|[{"id":0}]|]+        { matchHeaders = respHeaders }+    get "/items?id=eq.0"+      `shouldRespondWith`+        [json|[]|]+    get "items?id=eq.1"+      `shouldRespondWith`+        [json|[{"id":1}]|]++  it "does not persist delete" $ do+    request methodDelete "/items?id=eq.1"+        reqHeaders+        ""+      `shouldRespondWith`+        [json|[{"id":1}]|]+        { matchHeaders = respHeaders }+    get "/items?id=eq.1"+      `shouldRespondWith`+        [json|[{"id":1}]|]++allowed :: SpecWith ((), Application)+allowed = describe "tx-allow-override = true" $ do+  describe "without Prefer tx" $ do+    preferDefault `shouldRespondToReads` withoutPreferenceApplied+    preferDefault `shouldNotPersistMutations` withoutPreferenceApplied++  describe "Prefer tx=commit" $ do+    preferCommit `shouldRespondToReads` withPreferenceCommitApplied+    preferCommit `shouldPersistMutations` withPreferenceCommitApplied++  describe "Prefer tx=rollback" $ do+    preferRollback `shouldRespondToReads` withPreferenceRollbackApplied+    preferRollback `shouldNotPersistMutations` withPreferenceRollbackApplied++disallowed :: SpecWith ((), Application)+disallowed = describe "tx-rollback-all = false, tx-allow-override = false" $ do+  describe "without Prefer tx" $ do+    preferDefault `shouldRespondToReads` withoutPreferenceApplied+    preferDefault `shouldPersistMutations` withoutPreferenceApplied++  describe "Prefer tx=commit" $ do+    preferCommit `shouldRespondToReads` withoutPreferenceApplied+    preferCommit `shouldPersistMutations` withoutPreferenceApplied++  describe "Prefer tx=rollback" $ do+    preferRollback `shouldRespondToReads` withoutPreferenceApplied+    preferRollback `shouldPersistMutations` withoutPreferenceApplied+++forced :: SpecWith ((), Application)+forced = describe "tx-rollback-all = true, tx-allow-override = false" $ do+  describe "without Prefer tx" $ do+    preferDefault `shouldRespondToReads` withoutPreferenceApplied+    preferDefault `shouldNotPersistMutations` withoutPreferenceApplied++  describe "Prefer tx=commit" $ do+    preferCommit `shouldRespondToReads` withoutPreferenceApplied+    preferCommit `shouldNotPersistMutations` withoutPreferenceApplied++  describe "Prefer tx=rollback" $ do+    preferRollback `shouldRespondToReads` withoutPreferenceApplied+    preferRollback `shouldNotPersistMutations` withoutPreferenceApplied+
test/Feature/RootSpec.hs view
@@ -26,5 +26,9 @@     it "accepts application/json" $       request methodGet "/"         [("Accept", "application/json")] "" `shouldRespondWith`-        [json| [{"table": "items"}, {"table": "subitems"}] |]+        [json| {+            "tableName": "orders_view", "tableSchema": "test",+            "tableDeletable": true, "tableUpdatable": true,+            "tableInsertable": true, "tableDescription": null+          } |]         { matchHeaders = [matchContentTypeJson] }
+ test/Feature/RpcPreRequestGucsSpec.hs view
@@ -0,0 +1,84 @@+module Feature.RpcPreRequestGucsSpec where++import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec          hiding (pendingWith)+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import Protolude hiding (get, put)++spec :: SpecWith ((), Application)+spec =+  describe "GUC headers on all methods via pre-request" $ do+    it "succeeds setting the headers on POST" $+      post "/items"+          [json|[{"id": 11111}]|]+        `shouldRespondWith` ""+          { matchStatus = 201+          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]+          }++    it "succeeds setting the headers on GET and HEAD" $ do+      request methodGet "/items?id=eq.1"+          [("User-Agent", "MSIE 6.0")]+          ""+        `shouldRespondWith`+          [json|[{"id": 1}]|]+          { matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"] }++      request methodHead "/items?id=eq.1"+          [("User-Agent", "MSIE 7.0")]+          ""+        `shouldRespondWith`+          ""+          { matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"] }++      request methodHead "/projects"+          [("Accept", "text/csv")]+          ""+        `shouldRespondWith`+          ""+          { matchHeaders = ["Content-Disposition" <:> "attachment; filename=projects.csv"] }++    it "succeeds setting the headers on PATCH" $+        patch "/items?id=eq.1"+            [json|[{"id": 11111}]|]+          `shouldRespondWith` ""+            { matchStatus = 204+            , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]+            }++    it "succeeds setting the headers on PUT" $+      put "/items?id=eq.1"+          [json|[{"id": 1}]|]+        `shouldRespondWith` ""+          { matchStatus = 204+          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]+          }++    it "succeeds setting the headers on DELETE" $+      delete "/items?id=eq.1"+        `shouldRespondWith`+          ""+          { matchStatus = 204+          , matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]+          }+    it "can override the Content-Type header" $ do+      request methodHead "/clients?id=eq.1"+          []+          ""+        `shouldRespondWith`+          ""+          { matchStatus = 200+          , matchHeaders = ["Content-Type" <:> "application/custom+json"]+          }+      request methodHead "/rpc/getallprojects"+          []+          ""+        `shouldRespondWith`+          ""+          { matchStatus = 200+          , matchHeaders = ["Content-Type" <:> "application/custom+json"]+          }
test/Feature/RpcSpec.hs view
@@ -3,7 +3,7 @@ import qualified Data.ByteString.Lazy as BL (empty)  import Network.Wai      (Application)-import Network.Wai.Test (SResponse (simpleBody, simpleStatus))+import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))  import Network.HTTP.Types import Test.Hspec          hiding (pendingWith)@@ -11,10 +11,12 @@ import Test.Hspec.Wai.JSON import Text.Heredoc -import PostgREST.Types (PgVersion, pgVersion100, pgVersion109,-                        pgVersion110, pgVersion112, pgVersion114,-                        pgVersion95)-import Protolude       hiding (get)+import PostgREST.Config.PgVersion (PgVersion, pgVersion100,+                                   pgVersion109, pgVersion110,+                                   pgVersion112, pgVersion114,+                                   pgVersion96)++import Protolude  hiding (get) import SpecHelper  spec :: PgVersion -> SpecWith ((), Application)@@ -94,27 +96,61 @@     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+       it "should not ignore unknown args and fail with 404" $         get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`-        let-          message :: Text-          message-            | actualPgVersion < pgVersion95 = "function test.add_them(a := integer, b := integer, smthelse := text) does not exist"-            | otherwise = "function test.add_them(a => integer, b => integer, smthelse => text) does not exist"-        in [json| {-          "code": "42883",-          "details": null,-          "hint": "No function matches the given name and argument types. You might need to add explicit type casts.",-          "message": #{message} } |]+        [json| {+          "hint":"If a new function was created in the database with this name and arguments, try reloading the schema cache.",+          "message":"Could not find the test.add_them(a, b, smthelse) function in the schema cache" } |]         { matchStatus  = 404         , matchHeaders = [matchContentTypeJson]         } +      it "should fail with 404 when no json arg is found with prefer single object" $+        request methodPost "/rpc/sayhello"+          [("Prefer","params=single-object")]+          [json|{}|]+        `shouldRespondWith`+          [json| {+            "hint":"If a new function was created in the database with this name and arguments, try reloading the schema cache.",+            "message":"Could not find the test.sayhello function with a single json or jsonb argument in the schema cache" } |]+        { matchStatus  = 404+        , matchHeaders = [matchContentTypeJson]+        }++      it "should fail with 404 for overloaded functions with unknown args" $ do+        get "/rpc/overloaded?wrong_arg=value" `shouldRespondWith`+          [json| {+            "hint":"If a new function was created in the database with this name and arguments, try reloading the schema cache.",+            "message":"Could not find the test.overloaded(wrong_arg) function in the schema cache" } |]+          { matchStatus  = 404+          , matchHeaders = [matchContentTypeJson]+          }+        get "/rpc/overloaded?a=1&b=2&wrong_arg=value" `shouldRespondWith`+          [json| {+            "hint":"If a new function was created in the database with this name and arguments, try reloading the schema cache.",+            "message":"Could not find the test.overloaded(a, b, wrong_arg) function in the schema cache" } |]+          { matchStatus  = 404+          , matchHeaders = [matchContentTypeJson]+          }++    context "ambiguous overloaded functions with same arguments but different types" $ do+      it "should fail with 300 Multiple Choices without explicit argument type casts" $+        get "/rpc/overloaded_same_args?arg=value" `shouldRespondWith`+          [json| {+            "hint":"Overloaded functions with the same argument name but different types are not supported",+            "message":"Could not choose the best candidate function between: test.overloaded_same_args(arg => integer), test.overloaded_same_args(arg => xml), test.overloaded_same_args(arg => text, num => integer)" } |]+          { matchStatus  = 300+          , matchHeaders = [matchContentTypeJson]+          }+     it "works when having uppercase identifiers" $ do       get "/rpc/quotedFunction?user=mscott&fullName=Michael Scott&SSN=401-32-XXXX" `shouldRespondWith`         [json|{"user": "mscott", "fullName": "Michael Scott", "SSN": "401-32-XXXX"}|]@@ -128,9 +164,9 @@     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}]|]+          [json|[{"id":1,"name":"Windows 7","client_id":1}]|]         get "/rpc/getproject?id=1" `shouldRespondWith`-          [str|[{"id":1,"name":"Windows 7","client_id":1}]|]+          [json|[{"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`@@ -152,9 +188,9 @@        it "select works on the first level" $ do         post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`-          [str|[{"id":1,"name":"Windows 7"}]|]+          [json|[{"id":1,"name":"Windows 7"}]|]         get "/rpc/getproject?id=1&select=id,name" `shouldRespondWith`-          [str|[{"id":1,"name":"Windows 7"}]|]+          [json|[{"id":1,"name":"Windows 7"}]|]      context "foreign entities embedding" $ do       it "can embed if related tables are in the exposed schema" $ do@@ -172,12 +208,13 @@           `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] }+        post "/rpc/single_article?select=id,articleStars(userId)"+            [json|{ "id": 2}|]+          `shouldRespondWith`+            [json|{"id": 2, "articleStars": [{"userId": 3}]}|]         get "/rpc/single_article?id=2&select=id,articleStars(userId)"-          `shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]-          { matchHeaders = [matchContentTypeJson] }+          `shouldRespondWith`+            [json|{"id": 2, "articleStars": [{"userId": 3}]}|]        it "can embed an M2M relationship table" $         get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"@@ -202,6 +239,16 @@           ]|]           { matchHeaders = [matchContentTypeJson] } +      when (actualPgVersion >= pgVersion110) $+        it "can embed if rpc returns domain of table type" $ do+          post "/rpc/getproject_domain?select=id,name,client:clients(id),tasks(id)"+              [json| { "id": 1} |]+            `shouldRespondWith`+              [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]+          get "/rpc/getproject_domain?id=1&select=id,name,client:clients(id),tasks(id)"+            `shouldRespondWith`+              [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]+     context "a proc that returns an empty rowset" $       it "returns empty json array" $ do         post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`@@ -229,11 +276,10 @@           { 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] }+        post "/rpc/ret_setof_integers"+            [json|{}|]+          `shouldRespondWith`+            [json|[1,2,3]|]        it "returns enum value" $         post "/rpc/ret_enum" [json|{ "val": "foo" }|] `shouldRespondWith`@@ -256,41 +302,154 @@           { matchHeaders = [matchContentTypeJson] }        it "returns composite type in exposed schema" $-        post "/rpc/ret_point_2d" [json|{}|] `shouldRespondWith`-          [json|[{"x": 10, "y": 5}]|]-          { matchHeaders = [matchContentTypeJson] }+        post "/rpc/ret_point_2d"+            [json|{}|]+          `shouldRespondWith`+            [json|{"x": 10, "y": 5}|]        it "cannot return composite type in hidden schema" $         post "/rpc/ret_point_3d" [json|{}|] `shouldRespondWith` 401 +      when (actualPgVersion >= pgVersion110) $+        it "returns domain of composite type" $+          post "/rpc/ret_composite_domain"+              [json|{}|]+            `shouldRespondWith`+              [json|{"x": 10, "y": 5}|]+       it "returns single row from table" $-        post "/rpc/single_article?select=id" [json|{"id": 2}|] `shouldRespondWith`-          [json|[{"id": 2}]|]-          { matchHeaders = [matchContentTypeJson] }+        post "/rpc/single_article?select=id"+            [json|{"id": 2}|]+          `shouldRespondWith`+            [json|{"id": 2}|]        it "returns null for void" $-        post "/rpc/ret_void" [json|{}|] `shouldRespondWith`-          [json|null|]-          { matchHeaders = [matchContentTypeJson] }+        post "/rpc/ret_void"+            [json|{}|]+          `shouldRespondWith`+            "null"+            { matchHeaders = [matchContentTypeJson] } +      it "returns null for an integer with null value" $+        post "/rpc/ret_null"+            [json|{}|]+          `shouldRespondWith`+            "null"+            { matchHeaders = [matchContentTypeJson] }++      context "different types when overloaded" $ do+        it "returns composite type" $+          post "/rpc/ret_point_overloaded"+              [json|{"x": 1, "y": 2}|]+            `shouldRespondWith`+              [json|{"x": 1, "y": 2}|]++        it "returns json scalar with prefer single object" $+          request methodPost "/rpc/ret_point_overloaded" [("Prefer","params=single-object")]+            [json|{"x": 1, "y": 2}|]+            `shouldRespondWith`+            [json|{"x": 1, "y": 2}|]+            { matchHeaders = [matchContentTypeJson] }+     context "proc argument types" $ do-      it "accepts a variety of arguments" $-        post "/rpc/varied_arguments"-            [json| {-              "double": 3.1,-              "varchar": "hello",-              "boolean": true,-              "date": "20190101",-              "money": 0,-              "enum": "foo",-              "integer": 43,-              "json": {"some key": "some value"},-              "jsonb": {"another key": [1, 2, "3"]}-            } |]+      -- different syntax for array needed for pg<10+      when (actualPgVersion < pgVersion100) $+        it "accepts a variety of arguments (Postgres < 10)" $+          post "/rpc/varied_arguments"+              [json| {+                "double": 3.1,+                "varchar": "hello",+                "boolean": true,+                "date": "20190101",+                "money": 0,+                "enum": "foo",+                "arr": "{a,b,c}",+                "integer": 43,+                "json": {"some key": "some value"},+                "jsonb": {"another key": [1, 2, "3"]}+              } |]+            `shouldRespondWith`+              [json| {+                "double": 3.1,+                "varchar": "hello",+                "boolean": true,+                "date": "2019-01-01",+                "money": "$0.00",+                "enum": "foo",+                "arr": ["a", "b", "c"],+                "integer": 43,+                "json": {"some key": "some value"},+                "jsonb": {"another key": [1, 2, "3"]}+              } |]+              { matchHeaders = [matchContentTypeJson] }++      when (actualPgVersion >= pgVersion100) $+        it "accepts a variety of arguments (Postgres >= 10)" $+          post "/rpc/varied_arguments"+              [json| {+                "double": 3.1,+                "varchar": "hello",+                "boolean": true,+                "date": "20190101",+                "money": 0,+                "enum": "foo",+                "arr": ["a", "b", "c"],+                "integer": 43,+                "json": {"some key": "some value"},+                "jsonb": {"another key": [1, 2, "3"]}+              } |]+            `shouldRespondWith`+              [json| {+                "double": 3.1,+                "varchar": "hello",+                "boolean": true,+                "date": "2019-01-01",+                "money": "$0.00",+                "enum": "foo",+                "arr": ["a", "b", "c"],+                "integer": 43,+                "json": {"some key": "some value"},+                "jsonb": {"another key": [1, 2, "3"]}+              } |]+              { matchHeaders = [matchContentTypeJson] }++      it "accepts a variety of arguments with GET" $+        -- without JSON / JSONB here, because passing those via query string is useless - they just become a "json string" all the time+        get "/rpc/varied_arguments?double=3.1&varchar=hello&boolean=true&date=20190101&money=0&enum=foo&arr=%7Ba,b,c%7D&integer=43"           `shouldRespondWith`-            [json|"Hi"|]+              [json| {+                "double": 3.1,+                "varchar": "hello",+                "boolean": true,+                "date": "2019-01-01",+                "money": "$0.00",+                "enum": "foo",+                "arr": ["a", "b", "c"],+                "integer": 43,+                "json": {},+                "jsonb": {}+              } |]             { matchHeaders = [matchContentTypeJson] } +      it "accepts a variety of arguments from an html form" $+        request methodPost "/rpc/varied_arguments"+            [("Content-Type", "application/x-www-form-urlencoded")]+            "double=3.1&varchar=hello&boolean=true&date=20190101&money=0&enum=foo&arr=%7Ba,b,c%7D&integer=43"+          `shouldRespondWith`+              [json| {+                "double": 3.1,+                "varchar": "hello",+                "boolean": true,+                "date": "2019-01-01",+                "money": "$0.00",+                "enum": "foo",+                "arr": ["a", "b", "c"],+                "integer": 43,+                "json": {},+                "jsonb": {}+              } |]+            { matchHeaders = [matchContentTypeJson] }+       it "parses embedded JSON arguments as JSON" $         post "/rpc/json_argument"             [json| { "arg": { "key": 3 } } |]@@ -352,13 +511,25 @@           `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] }+      -- callcounter is persistent even with rollback, because it uses a sequence+      -- reset counter first to make test repeatable+      request methodPost "/rpc/reset_sequence"+          [("Prefer", "tx=commit")]+          [json|{"name": "callcounter_count", "value": 1}|]+        `shouldRespondWith`+          [json|""|] +      -- now the test+      post "/rpc/callcounter"+          [json|{}|]+        `shouldRespondWith`+          [json|1|]++      post "/rpc/callcounter"+          [json|{}|]+        `shouldRespondWith`+          [json|2|]+     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`@@ -376,29 +547,101 @@         [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 an object result when there is a single OUT param" $ do+        get "/rpc/single_out_param?num=5"+          `shouldRespondWith`+            [json|{"num_plus_one":6}|] -      it "returns a scalar result when there is a single INOUT param" $-        get "/rpc/single_inout_param?num=2" `shouldRespondWith`-          [json|3|] { matchHeaders = [matchContentTypeJson] }+        get "/rpc/single_json_out_param?a=1&b=two"+          `shouldRespondWith`+            [json|{"my_json": {"a": 1, "b": "two"}}|] -      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 an object result when there is a single INOUT param" $+        get "/rpc/single_inout_param?num=2"+          `shouldRespondWith`+            [json|{"num":3}|] -      it "returns a row result when there are many INOUT params" $-        get "/rpc/many_inout_params?num=1&str=two&b=false" `shouldRespondWith`-          [json| [{"num":1,"str":"two","b":false}]|] { matchHeaders = [matchContentTypeJson] }+      it "returns an object result when there are many OUT params" $+        get "/rpc/many_out_params"+          `shouldRespondWith`+            [json|{"my_json":{"a": 1, "b": "two"},"num":3,"str":"four"}|] +      it "returns an object result when there are many INOUT params" $+        get "/rpc/many_inout_params?num=1&str=two&b=false"+          `shouldRespondWith`+            [json|{"num":1,"str":"two","b":false}|]++    context "procs with VARIADIC params" $ do+      when (actualPgVersion < pgVersion100) $+        it "works with POST (Postgres < 10)" $+          post "/rpc/variadic_param"+              [json| { "v": "{hi,hello,there}" } |]+            `shouldRespondWith`+              [json|["hi", "hello", "there"]|]++      when (actualPgVersion >= pgVersion100) $ do+        it "works with POST (Postgres >= 10)" $+          post "/rpc/variadic_param"+              [json| { "v": ["hi", "hello", "there"] } |]+            `shouldRespondWith`+              [json|["hi", "hello", "there"]|]++        context "works with GET and repeated params" $ do+          it "n=0 (through DEFAULT)" $+            get "/rpc/variadic_param"+              `shouldRespondWith`+                [json|[]|]++          it "n=1" $+            get "/rpc/variadic_param?v=hi"+              `shouldRespondWith`+                [json|["hi"]|]++          it "n>1" $+            get "/rpc/variadic_param?v=hi&v=there"+              `shouldRespondWith`+                [json|["hi", "there"]|]++        context "works with POST and repeated params from html form" $ do+          it "n=0 (through DEFAULT)" $+            request methodPost "/rpc/variadic_param"+                [("Content-Type", "application/x-www-form-urlencoded")]+                ""+              `shouldRespondWith`+                [json|[]|]++          it "n=1" $+            request methodPost "/rpc/variadic_param"+                [("Content-Type", "application/x-www-form-urlencoded")]+                "v=hi"+              `shouldRespondWith`+                [json|["hi"]|]++          it "n>1" $+            request methodPost "/rpc/variadic_param"+                [("Content-Type", "application/x-www-form-urlencoded")]+                "v=hi&v=there"+              `shouldRespondWith`+                [json|["hi", "there"]|]++    it "returns last value for repeated params without VARIADIC" $+      get "/rpc/sayhello?name=ignored&name=world"+        `shouldRespondWith`+          [json|"Hello, world"|]++    when (actualPgVersion >= pgVersion100) $+      it "returns last value for repeated non-variadic params in function with other VARIADIC arguments" $+        get "/rpc/sayhello_variadic?name=ignored&name=world&v=unused"+          `shouldRespondWith`+            [json|"Hello, world"|]+     it "can handle procs with args that have a DEFAULT value" $ do-      get "/rpc/many_inout_params?num=1&str=two" `shouldRespondWith`-        [json| [{"num":1,"str":"two","b":true}]|] { matchHeaders = [matchContentTypeJson] }-      get "/rpc/three_defaults?b=4" `shouldRespondWith`-        [json|8|] { matchHeaders = [matchContentTypeJson] }+      get "/rpc/many_inout_params?num=1&str=two"+        `shouldRespondWith`+          [json| {"num":1,"str":"two","b":true}|]+      get "/rpc/three_defaults?b=4"+        `shouldRespondWith`+          [json|8|]      it "can map a RAISE error code and message to a http status" $       get "/rpc/raise_pt402"@@ -436,23 +679,56 @@           `shouldRespondWith` [json|{ "p1": "1", "p2": "text"}|]           { matchHeaders = [matchContentTypeJson] } -    it "should work with an overloaded function" $ do-      get "/rpc/overloaded" `shouldRespondWith`-        [json|[{ "overloaded": 1 },-               { "overloaded": 2 },-               { "overloaded": 3 }]|]-        { matchHeaders = [matchContentTypeJson] }-      request methodPost "/rpc/overloaded" [("Prefer","params=single-object")]-        [json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]-       `shouldRespondWith`-        [json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]-        { matchHeaders = [matchContentTypeJson] }-      get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|]-      get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [str|"123"|]+    context "should work with an overloaded function" $ do+      it "overloaded()" $+        get "/rpc/overloaded"+          `shouldRespondWith`+            [json|[1,2,3]|] +      it "overloaded(json) single-object" $+        request methodPost "/rpc/overloaded"+            [("Prefer","params=single-object")]+            [json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]+          `shouldRespondWith`+            [json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]++      it "overloaded(int, int)" $+        get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|]++      it "overloaded(text, text, text)" $+        get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [json|"123"|]++      it "overloaded_html_form()" $+        request methodPost "/rpc/overloaded_html_form"+            [("Content-Type", "application/x-www-form-urlencoded")]+            ""+          `shouldRespondWith`+            [json|[1,2,3]|]++      it "overloaded_html_form(json) single-object" $+        request methodPost "/rpc/overloaded_html_form"+            [("Content-Type", "application/x-www-form-urlencoded"), ("Prefer","params=single-object")]+            "a=1&b=2&c=3"+          `shouldRespondWith`+            [json|{"a": "1", "b": "2", "c": "3"}|]++      it "overloaded_html_form(int, int)" $+        request methodPost "/rpc/overloaded_html_form"+            [("Content-Type", "application/x-www-form-urlencoded")]+            "a=1&b=2"+          `shouldRespondWith`+            [str|3|]++      it "overloaded_html_form(text, text, text)" $+        request methodPost "/rpc/overloaded_html_form"+            [("Content-Type", "application/x-www-form-urlencoded")]+            "a=1&b=2&c=3"+          `shouldRespondWith`+            [json|"123"|]+     context "only for POST rpc" $ do       it "gives a parse filter error if GET style proc args are specified" $-        post "/rpc/sayhello?name=John" [json|{}|] `shouldRespondWith` 400+        post "/rpc/sayhello?name=John" [json|{name: "John"}|] `shouldRespondWith` 400        it "ignores json keys not included in ?columns" $         post "/rpc/sayhello?columns=name"@@ -512,7 +788,7 @@                   [("Custom-Header", "test")]             [json| { "name": "request.header.custom-header" } |]             `shouldRespondWith`-            [str|"test"|]+            [json|"test"|]             { matchStatus  = 200             , matchHeaders = [ matchContentTypeJson ]             }@@ -521,7 +797,7 @@                   [("Origin", "http://example.com")]             [json| { "name": "request.header.origin" } |]             `shouldRespondWith`-            [str|"http://example.com"|]+            [json|"http://example.com"|]             { matchStatus  = 200             , matchHeaders = [ matchContentTypeJson ]             }@@ -529,7 +805,7 @@         request methodPost "/rpc/get_guc_value" []             [json| { "name": "request.jwt.claim.role" } |]             `shouldRespondWith`-            [str|"postgrest_test_anonymous"|]+            [json|"postgrest_test_anonymous"|]             { matchStatus  = 200             , matchHeaders = [ matchContentTypeJson ]             }@@ -537,7 +813,7 @@         request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]           [json| {"name":"request.cookie.acookie"} |]             `shouldRespondWith`-            [str|"cookievalue"|]+            [json|"cookievalue"|]             { matchStatus = 200             , matchHeaders = []             }@@ -545,7 +821,7 @@         request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]           [json| {"name":"request.cookie.secondcookie"} |]             `shouldRespondWith`-            [str|"anothervalue"|]+            [json|"anothervalue"|]             { matchStatus = 200             , matchHeaders = []             }@@ -553,7 +829,7 @@         request methodPost "/rpc/get_guc_value" []           [json| { "name": "app.settings.app_host" } |]             `shouldRespondWith`-            [str|"localhost"|]+            [json|"localhost"|]             { matchStatus  = 200             , matchHeaders = [ matchContentTypeJson ]             }@@ -561,7 +837,7 @@         request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]           [json| {"name":"request.header.authorization"} |]             `shouldRespondWith`-            [str|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]+            [json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]             { matchStatus = 200             , matchHeaders = []             }@@ -569,7 +845,7 @@         request methodPost "/rpc/get_guc_value" []           [json| {"name":"request.method"} |]             `shouldRespondWith`-            [str|"POST"|]+            [json|"POST"|]             { matchStatus = 200             , matchHeaders = []             }@@ -577,7 +853,7 @@         request methodPost "/rpc/get_guc_value" []           [json| {"name":"request.path"} |]             `shouldRespondWith`-            [str|"/rpc/get_guc_value"|]+            [json|"/rpc/get_guc_value"|]             { matchStatus = 200             , matchHeaders = []             }@@ -588,7 +864,7 @@           request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") ""             `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"             { matchStatus = 200-            , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]+            , matchHeaders = ["Content-Type" <:> "application/octet-stream"]             }          it "can get raw output with Accept: text/plain" $@@ -598,26 +874,36 @@             , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]             } +      context "Proc that returns set of scalars" $+        it "can query without selecting column" $+          request methodGet "/rpc/welcome_twice"+              (acceptHdrs "text/plain")+              ""+            `shouldRespondWith`+              "Welcome to PostgRESTWelcome to PostgREST"+              { matchStatus = 200+              , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]+              }+       context "Proc that returns rows" $ do         it "can query if a single column is selected" $           request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (acceptHdrs "application/octet-stream") ""             `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="             { matchStatus = 200-            , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]+            , matchHeaders = ["Content-Type" <:> "application/octet-stream"]             }          it "fails if a single column is not selected" $-          request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") ""+          request methodPost "/rpc/ret_rows_with_base64_bin"+              (acceptHdrs "application/octet-stream") ""             `shouldRespondWith`-            [json| {"message":"application/octet-stream requested but more than one column was selected"} |]-            { matchStatus = 406-            , matchHeaders = [matchContentTypeJson]-            }+              [json| {"message":"application/octet-stream requested but more than one column was selected"} |]+              { matchStatus = 406 }      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+        get "/rpc/callcounter" `shouldRespondWith` 405+        get "/rpc/setprojects?id_l=1&id_h=5&name=FreeBSD" `shouldRespondWith` 405        it "should filter a proc that has arg name = filter name" $         get "/rpc/get_projects_below?id=5&id=gt.2&select=id" `shouldRespondWith`@@ -647,7 +933,129 @@                 [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]                 { matchHeaders = [matchContentTypeJson] } +      when (actualPgVersion >= pgVersion96) $+        it "should work with the phraseto_tsquery function" $+          get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith`+            [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]+            { matchHeaders = [matchContentTypeJson] }+     it "should work with an argument of custom type in public schema" $         get "/rpc/test_arg?my_arg=something" `shouldRespondWith`           [json|"foobar"|]           { matchHeaders = [matchContentTypeJson] }++    when (actualPgVersion >= pgVersion96) $ do+      context "GUC headers on function calls" $ 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`+            [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]+            { matchStatus  = 500+            , matchHeaders = [ matchContentTypeJson ]+            }+          get "/rpc/bad_guc_headers_2"+            `shouldRespondWith`+            [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]+            { matchStatus  = 500+            , matchHeaders = [ matchContentTypeJson ]+            }+          get "/rpc/bad_guc_headers_3"+            `shouldRespondWith`+            [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]+            { matchStatus  = 500+            , matchHeaders = [ matchContentTypeJson ]+            }+          post "/rpc/bad_guc_headers_1" [json|{}|]+            `shouldRespondWith`+            [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]+            { matchStatus  = 500+            , matchHeaders = [ matchContentTypeJson ]+            }++        it "can set the same http header twice" $+          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" ]}++      it "can override the Location header on a trigger" $+        post "/stuff"+            [json|[{"id": 2, "name": "stuff 2"}]|]+          `shouldRespondWith`+            ""+            { matchStatus = 201+            , matchHeaders = ["Location" <:> "/stuff?id=eq.2&overriden=true"]+            }++      -- On https://github.com/PostgREST/postgrest/issues/1427#issuecomment-595907535+      -- it was reported that blank headers ` : ` where added and that cause proxies to fail the requests.+      -- These tests are to ensure no blank headers are added.+      context "Blank headers bug" $ do+        it "shouldn't add blank headers on POST" $ do+          r <- request methodPost "/loc_test" [] [json|{"id": "1", "c": "c1"}|]+          liftIO $ do+            let respHeaders = simpleHeaders r+            respHeaders `shouldSatisfy` noBlankHeader++        it "shouldn't add blank headers on PATCH" $ do+          r <- request methodPatch "/loc_test?id=eq.1" [] [json|{"c": "c2"}|]+          liftIO $ do+            let respHeaders = simpleHeaders r+            respHeaders `shouldSatisfy` noBlankHeader++        it "shouldn't add blank headers on GET" $ do+          r <- request methodGet "/loc_test" [] ""+          liftIO $ do+            let respHeaders = simpleHeaders r+            respHeaders `shouldSatisfy` noBlankHeader++        it "shouldn't add blank headers on DELETE" $ do+          r <- request methodDelete "/loc_test?id=eq.1" [] ""+          liftIO $ do+            let respHeaders = simpleHeaders r+            respHeaders `shouldSatisfy` noBlankHeader++      context "GUC status override" $ do+        it "can override the status on RPC" $+          get "/rpc/send_body_status_403"+            `shouldRespondWith`+            [json|{"message" : "invalid user or password"}|]+            { matchStatus  = 403+            , matchHeaders = [ matchContentTypeJson ]+            }++        it "can override the status through trigger" $+          patch "/stuff?id=eq.1"+              [json|[{"name": "updated stuff 1"}]|]+            `shouldRespondWith`+              205++        it "fails when setting invalid status guc" $+          get "/rpc/send_bad_status"+            `shouldRespondWith`+            [json|{"message":"response.status guc must be a valid status code"}|]+            { matchStatus  = 500+            , matchHeaders = [ matchContentTypeJson ]+            }
test/Feature/SingularSpec.hs view
@@ -7,7 +7,6 @@ import Test.Hspec import Test.Hspec.Wai import Test.Hspec.Wai.JSON-import Text.Heredoc  import Protolude  hiding (get) import SpecHelper@@ -16,8 +15,7 @@ spec :: SpecWith ((), Application) spec =   describe "Requesting singular json object" $ do-    let pgrstObj = "application/vnd.pgrst.object+json"-        singular = ("Accept", pgrstObj)+    let singular = ("Accept", "application/vnd.pgrst.object+json")      context "with GET request" $ do       it "fails for zero rows" $@@ -26,140 +24,171 @@        it "will select an existing object" $ do         request methodGet "/items?id=eq.5" [singular] ""-          `shouldRespondWith` [str|{"id":5}|]+          `shouldRespondWith`+            [json|{"id":5}|]+            { matchHeaders = [matchContentTypeSingular] }         -- also test without the +json suffix         request methodGet "/items?id=eq.5"-          [("Accept", "application/vnd.pgrst.object")] ""-          `shouldRespondWith` [str|{"id":5}|]+            [("Accept", "application/vnd.pgrst.object")] ""+          `shouldRespondWith`+            [json|{"id":5}|]+            { matchHeaders = [matchContentTypeSingular] }        it "can combine multiple prefer values" $         request methodGet "/items?id=eq.5" [singular, ("Prefer","count=none")] ""-          `shouldRespondWith` [str|{"id":5}|]+          `shouldRespondWith`+            [json|{"id":5}|]+            { matchHeaders = [matchContentTypeSingular] }        it "can shape plurality singular object routes" $         request methodGet "/projects_view?id=eq.1&select=id,name,clients(*),tasks(id,name)" [singular] ""           `shouldRespondWith`             [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"] }+            { matchHeaders = [matchContentTypeSingular] }      context "when updating rows" $ do-      it "works for one row" $ do-        _ <- post "/addresses" [json| { id: 97, address: "A Street" } |]-        request methodPatch-          "/addresses?id=eq.97"-          [("Prefer", "return=representation"), singular]-          [json| { address: "B Street" } |]+      it "works for one row with return=rep" $ do+        request methodPatch "/addresses?id=eq.1"+            [("Prefer", "return=representation"), singular]+            [json| { address: "B Street" } |]           `shouldRespondWith`-            [str|{"id":97,"address":"B Street"}|]+            [json|{"id":1,"address":"B Street"}|]+            { matchHeaders = [matchContentTypeSingular] } +      it "works for one row with return=minimal" $+        request methodPatch "/addresses?id=eq.1"+            [("Prefer", "return=minimal"), singular]+            [json| { address: "C Street" } |]+          `shouldRespondWith`+            ""+            { matchStatus  = 204 }+       it "raises an error for multiple rows" $ do-        _ <- post "/addresses" [json| { id: 98, address: "xxx" } |]-        _ <- post "/addresses" [json| { id: 99, address: "yyy" } |]-        p <- request methodPatch "/addresses?id=gt.0"-                [singular]-                [json| { address: "zzz" } |]-        liftIO $ do-          simpleStatus p `shouldBe` notAcceptable406-          isErrorFormat (simpleBody p) `shouldBe` True+        request methodPatch "/addresses"+            [("Prefer", "tx=commit"), singular]+            [json| { address: "zzz" } |]+          `shouldRespondWith`+            [json|{"details":"Results contain 4 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+            { matchStatus  = 406+            , matchHeaders = [ matchContentTypeSingular+                             , "Preference-Applied" <:> "tx=commit" ]+            }          -- the rows should not be updated, either-        get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|]+        get "/addresses?id=eq.1"+          `shouldRespondWith`+            [json|[{"id":1,"address":"address 1"}]|]        it "raises an error for multiple rows with return=rep" $ do-        _ <- post "/addresses" [json| { id: 100, address: "xxx" } |]-        _ <- post "/addresses" [json| { id: 101, address: "yyy" } |]-        p <- request methodPatch "/addresses?id=gt.0"-                [("Prefer", "return=representation"), singular]-                [json| { address: "zzz" } |]-        liftIO $ do-          simpleStatus p `shouldBe` notAcceptable406-          isErrorFormat (simpleBody p) `shouldBe` True+        request methodPatch "/addresses"+            [("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular]+            [json| { address: "zzz" } |]+          `shouldRespondWith`+            [json|{"details":"Results contain 4 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+            { matchStatus  = 406+            , matchHeaders = [ matchContentTypeSingular+                             , "Preference-Applied" <:> "tx=commit" ]+            }          -- the rows should not be updated, either-        get "/addresses?id=eq.100" `shouldRespondWith` [str|[{"id":100,"address":"xxx"}]|]+        get "/addresses?id=eq.1"+          `shouldRespondWith`+            [json|[{"id":1,"address":"address 1"}]|]        it "raises an error for zero rows" $         request methodPatch "/items?id=gt.0&id=lt.0"                 [singular] [json|{"id":1}|]           `shouldRespondWith`-                  [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]                   { matchStatus  = 406-                  , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]+                  , matchHeaders = [matchContentTypeSingular]                   }        it "raises an error for zero rows with return=rep" $         request methodPatch "/items?id=gt.0&id=lt.0"                 [("Prefer", "return=representation"), singular] [json|{"id":1}|]           `shouldRespondWith`-                  [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]                   { matchStatus  = 406-                  , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]+                  , matchHeaders = [matchContentTypeSingular]                   }      context "when creating rows" $ do-      it "works for one row" $ do-        p <- request methodPost-          "/addresses"-          [("Prefer", "return=representation"), singular]-          [json| [ { id: 102, address: "xxx" } ] |]-        liftIO $ simpleBody p `shouldBe` [str|{"id":102,"address":"xxx"}|]+      it "works for one row with return=rep" $ do+        request methodPost "/addresses"+            [("Prefer", "return=representation"), singular]+            [json| [ { id: 102, address: "xxx" } ] |]+          `shouldRespondWith`+            [json|{"id":102,"address":"xxx"}|]+            { matchStatus  = 201+            , matchHeaders = [matchContentTypeSingular]+            } -      it "works for one row even with return=minimal" $ do+      it "works for one row with return=minimal" $ do         request methodPost "/addresses"-          [("Prefer", "return=minimal"), singular]-          [json| [ { id: 103, address: "xxx" } ] |]+            [("Prefer", "return=minimal"), singular]+            [json| [ { id: 103, address: "xxx" } ] |]           `shouldRespondWith` ""-          { matchStatus  = 201-          , matchHeaders = ["Content-Range" <:> "*/*"]-          }-        -- and the element should exist-        get "/addresses?id=eq.103"-          `shouldRespondWith` [str|[{"id":103,"address":"xxx"}]|]-          { matchStatus  = 200-          , matchHeaders = []-          }+            { matchStatus  = 201+            , matchHeaders = ["Content-Range" <:> "*/*"]+            }        it "raises an error when attempting to create multiple entities" $ do-        p <- request methodPost-          "/addresses"-          [singular]-          [json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]-        liftIO $ simpleStatus p `shouldBe` notAcceptable406+        request methodPost "/addresses"+            [("Prefer", "tx=commit"), singular]+            [json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]+          `shouldRespondWith`+            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+            { matchStatus  = 406+            , matchHeaders = [ matchContentTypeSingular+                             , "Preference-Applied" <:> "tx=commit" ]+            }          -- the rows should not exist, either-        get "/addresses?id=eq.200" `shouldRespondWith` "[]"+        get "/addresses?id=eq.200"+          `shouldRespondWith`+            "[]"        it "raises an error when attempting to create multiple entities with return=rep" $ do-        p <- request methodPost-          "/addresses"-          [("Prefer", "return=representation"), singular]-          [json| [ { id: 202, address: "xxx" }, { id: 203, address: "yyy" } ] |]-        liftIO $ simpleStatus p `shouldBe` notAcceptable406+        request methodPost "/addresses"+            [("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular]+            [json| [ { id: 202, address: "xxx" }, { id: 203, address: "yyy" } ] |]+          `shouldRespondWith`+            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+            { matchStatus  = 406+            , matchHeaders = [ matchContentTypeSingular+                             , "Preference-Applied" <:> "tx=commit" ]+            }          -- the rows should not exist, either-        get "/addresses?id=eq.202" `shouldRespondWith` "[]"+        get "/addresses?id=eq.202"+          `shouldRespondWith`+            "[]"        it "raises an error regardless of return=minimal" $ do         request methodPost "/addresses"-                [("Prefer", "return=minimal"), singular]-                [json| [ { id: 204, address: "xxx" }, { id: 205, address: "yyy" } ] |]+            [("Prefer", "tx=commit"), ("Prefer", "return=minimal"), singular]+            [json| [ { id: 204, address: "xxx" }, { id: 205, address: "yyy" } ] |]           `shouldRespondWith`-                  [str|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]-                  { matchStatus  = 406-                  , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]-                  }+            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+            { matchStatus  = 406+            , matchHeaders = [ matchContentTypeSingular+                             , "Preference-Applied" <:> "tx=commit" ]+            }          -- the rows should not exist, either-        get "/addresses?id=eq.204" `shouldRespondWith` "[]"+        get "/addresses?id=eq.204"+          `shouldRespondWith`+            "[]"        it "raises an error when creating zero entities" $         request methodPost "/addresses"                 [singular]                 [json| [ ] |]           `shouldRespondWith`-                  [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]                   { matchStatus  = 406-                  , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]+                  , matchHeaders = [matchContentTypeSingular]                   }        it "raises an error when creating zero entities with return=rep" $@@ -167,58 +196,76 @@                 [("Prefer", "return=representation"), singular]                 [json| [ ] |]           `shouldRespondWith`-                  [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+                  [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]                   { matchStatus  = 406-                  , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]+                  , matchHeaders = [matchContentTypeSingular]                   }      context "when deleting rows" $ do-      it "works for one row" $ do+      it "works for one row with return=rep" $ do         p <- request methodDelete           "/items?id=eq.11"           [("Prefer", "return=representation"), singular] ""-        liftIO $ simpleBody p `shouldBe` [str|{"id":11}|]+        liftIO $ simpleBody p `shouldBe` [json|{"id":11}|] +      it "works for one row with return=minimal" $ do+        p <- request methodDelete+          "/items?id=eq.12"+          [("Prefer", "return=minimal"), singular] ""+        liftIO $ simpleBody p `shouldBe` ""+       it "raises an error when attempting to delete multiple entities" $ do-        let firstItems = "/items?id=gt.0&id=lt.6"-        request methodDelete firstItems-          [singular] ""-          `shouldRespondWith` 406+        request methodDelete "/items?id=gt.0&id=lt.6"+            [("Prefer", "tx=commit"), singular]+            ""+          `shouldRespondWith`+            [json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+            { matchStatus  = 406+            , matchHeaders = [ matchContentTypeSingular+                             , "Preference-Applied" <:> "tx=commit" ]+            } -        get firstItems-          `shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5}] |]-          { matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-4/*"]-          }+        -- the rows should still exist+        get "/items?id=gt.0&id=lt.6&order=id"+          `shouldRespondWith`+            [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5}] |]+            { matchStatus  = 200+            , matchHeaders = ["Content-Range" <:> "0-4/*"]+            }        it "raises an error when attempting to delete multiple entities with return=rep" $ do-        let firstItems = "/items?id=gt.5&id=lt.11"-        request methodDelete firstItems-          [("Prefer", "return=representation"), singular] ""-          `shouldRespondWith` 406+        request methodDelete "/items?id=gt.5&id=lt.11"+            [("Prefer", "tx=commit"), ("Prefer", "return=representation"), singular] ""+          `shouldRespondWith`+            [json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+            { matchStatus  = 406+            , matchHeaders = [ matchContentTypeSingular+                             , "Preference-Applied" <:> "tx=commit" ]+            } -        get firstItems+        -- the rows should still exist+        get "/items?id=gt.5&id=lt.11"           `shouldRespondWith` [json| [{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]-          { matchStatus  = 200-          , matchHeaders = ["Content-Range" <:> "0-4/*"]-          }+            { matchStatus  = 200+            , matchHeaders = ["Content-Range" <:> "0-4/*"]+            }        it "raises an error when deleting zero entities" $         request methodDelete "/items?id=lt.0"                 [singular] ""           `shouldRespondWith`-                [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+                [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]                 { matchStatus  = 406-                , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]+                , matchHeaders = [matchContentTypeSingular]                 }        it "raises an error when deleting zero entities with return=rep" $         request methodDelete "/items?id=lt.0"                 [("Prefer", "return=representation"), singular] ""           `shouldRespondWith`-                [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+                [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]                 { matchStatus  = 406-                , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]+                , matchHeaders = [matchContentTypeSingular]                 }      context "when calling a stored proc" $ do@@ -226,9 +273,9 @@         request methodPost "/rpc/getproject"                 [singular] [json|{ "id": 9999999}|]           `shouldRespondWith`-                [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+                [json|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]                 { matchStatus  = 406-                , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]+                , matchHeaders = [matchContentTypeSingular]                 }        -- this one may be controversial, should vnd.pgrst.object include@@ -240,30 +287,38 @@        it "returns a single object for json proc" $         request methodPost "/rpc/getproject"-          [singular] [json|{ "id": 1}|] `shouldRespondWith`-          [str|{"id":1,"name":"Windows 7","client_id":1}|]+            [singular] [json|{ "id": 1}|]+          `shouldRespondWith`+            [json|{"id":1,"name":"Windows 7","client_id":1}|]+            { matchHeaders = [matchContentTypeSingular] }        it "fails for multiple rows" $         request methodPost "/rpc/getallprojects"                 [singular] "{}"           `shouldRespondWith`-                [str|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+                [json|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]                 { matchStatus  = 406-                , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]+                , matchHeaders = [matchContentTypeSingular]                 } -      it "executes the proc exactly once per request" $ do-        request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]-          `shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]+      it "fails for multiple rows with rolled back changes" $ do+        post "/rpc/getproject?select=id,name"+            [json| {"id": 1} |]+          `shouldRespondWith`+            [json|[{"id":1,"name":"Windows 7"}]|] -        request methodPost "/rpc/setprojects" [singular]-                [json| {"id_l": 1, "id_h": 2, "name": "changed"} |]+        request methodPost "/rpc/setprojects"+            [("Prefer", "tx=commit"), singular]+            [json| {"id_l": 1, "id_h": 2, "name": "changed"} |]           `shouldRespondWith`-                [str|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]-                { matchStatus  = 406-                , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]-                }+            [json|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]+            { matchStatus  = 406+            , matchHeaders = [ matchContentTypeSingular+                             , "Preference-Applied" <:> "tx=commit" ]+            } -        -- should not actually have executed the function-        request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]-          `shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]+        -- should rollback function+        post "/rpc/getproject?select=id,name"+            [json| {"id": 1} |]+          `shouldRespondWith`+            [json|[{"id":1,"name":"Windows 7"}]|]
− test/Feature/StructureSpec.hs
@@ -1,516 +0,0 @@-module Feature.StructureSpec where--import Control.Lens     ((^?))-import Data.Aeson.Types (Value (..))-import Network.Wai      (Application)-import Network.Wai.Test (SResponse (..))--import Data.Aeson.Lens-import Data.Aeson.QQ-import Network.HTTP.Types-import Test.Hspec         hiding (pendingWith)-import Test.Hspec.Wai----import PostgREST.Config (docsVersion)-import Protolude        hiding (get)-import SpecHelper--spec :: SpecWith ((), Application)-spec = do--  describe "OpenAPI" $ do-    it "root path returns a valid openapi spec" $ do-      validateOpenApiResponse [("Accept", "application/openapi+json")]-      request methodHead "/" (acceptHdrs "application/openapi+json") ""-        `shouldRespondWith` "" { matchStatus  = 200 }--    it "should respond to openapi request on none root path with 415" $-      request methodGet "/items"-              (acceptHdrs "application/openapi+json") ""-        `shouldRespondWith` 415--    it "includes postgrest.org current version api docs" $ do-      r <- simpleBody <$> get "/"--      let docsUrl = r ^? key "externalDocs" . key "url"--      liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))--    describe "table" $ do--      it "includes paths to tables" $ do-        r <- simpleBody <$> get "/"--        let method s = key "paths" . key "/child_entities" . key s-            childGetSummary = r ^? method "get" . key "summary"-            childGetDescription = r ^? method "get" . key "description"-            getParameters = r ^? method "get" . key "parameters"-            postParameters = r ^? method "post" . key "parameters"-            postResponse = r ^? method "post" . key "responses" . key "201" . key "description"-            patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description"-            deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description"--        let grandChildGet s = key "paths" . key "/grandchild_entities" . key "get" . key s-            grandChildGetSummary = r ^? grandChildGet "summary"-            grandChildGetDescription = r ^? grandChildGet "description"--        liftIO $ do--          childGetSummary `shouldBe` Just "child_entities comment"--          childGetDescription `shouldBe` Nothing--          grandChildGetSummary `shouldBe` Just "grandchild_entities summary"--          grandChildGetDescription `shouldBe` Just "grandchild_entities description\nthat spans\nmultiple lines"--          getParameters `shouldBe` Just-            [aesonQQ|-              [-                { "$ref": "#/parameters/rowFilter.child_entities.id" },-                { "$ref": "#/parameters/rowFilter.child_entities.name" },-                { "$ref": "#/parameters/rowFilter.child_entities.parent_id" },-                { "$ref": "#/parameters/select" },-                { "$ref": "#/parameters/order" },-                { "$ref": "#/parameters/range" },-                { "$ref": "#/parameters/rangeUnit" },-                { "$ref": "#/parameters/offset" },-                { "$ref": "#/parameters/limit" },-                { "$ref": "#/parameters/preferCount" }-              ]-            |]--          postParameters `shouldBe` Just-            [aesonQQ|-              [-                { "$ref": "#/parameters/body.child_entities" },-                { "$ref": "#/parameters/select" },-                { "$ref": "#/parameters/preferReturn" }-              ]-            |]--          postResponse `shouldBe` Just "Created"--          patchResponse `shouldBe` Just "No Content"--          deleteResponse `shouldBe` Just "No Content"--      it "includes an array type for GET responses" $ do-        r <- simpleBody <$> get "/"--        let childGetSchema = r ^? key "paths"-              . key "/child_entities"-              . key "get"-              . key "responses"-              . key "200"-              . key "schema"--        liftIO $-          childGetSchema `shouldBe` Just-            [aesonQQ|-              {-                "items": {-                  "$ref": "#/definitions/child_entities"-                },-                "type": "array"-              }-            |]--      it "includes definitions to tables" $ do-        r <- simpleBody <$> get "/"--        let def = r ^? key "definitions" . key "child_entities"--        liftIO $--          def `shouldBe` Just-            [aesonQQ|-              {-                "type": "object",-                "description": "child_entities comment",-                "properties": {-                  "id": {-                    "description": "child_entities id comment\n\nNote:\nThis is a Primary Key.<pk/>",-                    "format": "integer",-                    "type": "integer"-                  },-                  "name": {-                    "description": "child_entities name comment. Can be longer than sixty-three characters long",-                    "format": "text",-                    "type": "string"-                  },-                  "parent_id": {-                    "description": "Note:\nThis is a Foreign Key to `entities.id`.<fk table='entities' column='id'/>",-                    "format": "integer",-                    "type": "integer"-                  }-                },-                "required": [-                  "id"-                ]-              }-            |]--      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 "Foreign table" $--      it "includes foreign table properties" $ do-        r <- simpleBody <$> get "/"--        let method s = key "paths" . key "/projects_dump" . key s-            getSummary = r ^? method "get" . key "summary"-            getDescription = r ^? method "get" . key "description"-            getParameters = r ^? method "get" . key "parameters"--        liftIO $ do--          getSummary `shouldBe` Just "A temporary projects dump"--          getDescription `shouldBe` Just "Just a test for foreign tables"--          getParameters `shouldBe` Just-            [aesonQQ|-              [-                { "$ref": "#/parameters/rowFilter.projects_dump.id" },-                { "$ref": "#/parameters/rowFilter.projects_dump.name" },-                { "$ref": "#/parameters/rowFilter.projects_dump.client_id" },-                { "$ref": "#/parameters/select" },-                { "$ref": "#/parameters/order" },-                { "$ref": "#/parameters/range" },-                { "$ref": "#/parameters/rangeUnit" },-                { "$ref": "#/parameters/offset" },-                { "$ref": "#/parameters/limit" },-                { "$ref": "#/parameters/preferCount" }-              ]-            |]--    describe "Materialized view" $--      it "includes materialized view properties" $ do-        r <- simpleBody <$> get "/"--        let method s = key "paths" . key "/materialized_projects" . key s-            summary = r ^? method "get" . key "summary"-            description = r ^? method "get" . key "description"-            parameters = r ^? method "get" . key "parameters"--        liftIO $ do--          summary `shouldBe` Just "A materialized view for projects"--          description `shouldBe` Just "Just a test for materialized views"--          parameters `shouldBe` Just-            [aesonQQ|-              [-                { "$ref": "#/parameters/rowFilter.materialized_projects.id" },-                { "$ref": "#/parameters/rowFilter.materialized_projects.name" },-                { "$ref": "#/parameters/rowFilter.materialized_projects.client_id" },-                { "$ref": "#/parameters/select" },-                { "$ref": "#/parameters/order" },-                { "$ref": "#/parameters/range" },-                { "$ref": "#/parameters/rangeUnit" },-                { "$ref": "#/parameters/offset" },-                { "$ref": "#/parameters/limit" },-                { "$ref": "#/parameters/preferCount" }-              ]-            |]--    describe "VIEW that has a source FK based on a UNIQUE key" $--      it "includes fk description" $ do-        r <- simpleBody <$> get "/"--        let referralLink = r ^? key "definitions" . key "referrals" . key "properties" . key "link"--        liftIO $-          referralLink `shouldBe` Just-            [aesonQQ|-              {-                "format": "integer",-                "type": "integer",-                "description": "Note:\nThis is a Foreign Key to `pages.link`.<fk table='pages' column='link'/>"-              }-            |]--    describe "PostgreSQL to Swagger Type Mapping" $ do--      it "character varying to string" $ do-        r <- simpleBody <$> get "/"--        let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character_varying"--        liftIO $--          types `shouldBe` Just-            [aesonQQ|-              {-                "format": "character varying",-                "type": "string"-              }-            |]-      it "character(1) to string" $ do-        r <- simpleBody <$> get "/"--        let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character"--        liftIO $--          types `shouldBe` Just-            [aesonQQ|-              {-                "maxLength": 1,-                "format": "character",-                "type": "string"-              }-            |]--      it "text to string" $ do-        r <- simpleBody <$> get "/"--        let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_text"--        liftIO $--          types `shouldBe` Just-            [aesonQQ|-              {-                "format": "text",-                "type": "string"-              }-            |]--      it "boolean to boolean" $ do-        r <- simpleBody <$> get "/"--        let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_boolean"--        liftIO $--          types `shouldBe` Just-            [aesonQQ|-              {-                "format": "boolean",-                "type": "boolean"-              }-            |]--      it "smallint to integer" $ do-        r <- simpleBody <$> get "/"--        let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_smallint"--        liftIO $--          types `shouldBe` Just-            [aesonQQ|-              {-                "format": "smallint",-                "type": "integer"-              }-            |]--      it "integer to integer" $ do-        r <- simpleBody <$> get "/"--        let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_integer"--        liftIO $--          types `shouldBe` Just-            [aesonQQ|-              {-                "format": "integer",-                "type": "integer"-              }-            |]--      it "bigint to integer" $ do-        r <- simpleBody <$> get "/"--        let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bigint"--        liftIO $--          types `shouldBe` Just-            [aesonQQ|-              {-                "format": "bigint",-                "type": "integer"-              }-            |]--      it "numeric to number" $ do-        r <- simpleBody <$> get "/"--        let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_numeric"--        liftIO $--          types `shouldBe` Just-            [aesonQQ|-              {-                "format": "numeric",-                "type": "number"-              }-            |]--      it "real to number" $ do-        r <- simpleBody <$> get "/"--        let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_real"--        liftIO $--          types `shouldBe` Just-            [aesonQQ|-              {-                "format": "real",-                "type": "number"-              }-            |]--      it "double_precision to number" $ do-        r <- simpleBody <$> get "/"--        let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_double_precision"--        liftIO $--          types `shouldBe` Just-            [aesonQQ|-              {-                "format": "double precision",-                "type": "number"-              }-            |]--    describe "RPC" $ do--      it "includes function summary/description and body schema for arguments" $ do-        r <- simpleBody <$> get "/"--        let method s = key "paths" . key "/rpc/varied_arguments" . key s-            args = r ^? method "post" . key "parameters" . nth 0 . key "schema"-            summary = r ^? method "post" . key "summary"-            description = r ^? method "post" . key "description"--        liftIO $ do--          summary `shouldBe` Just "An RPC function"--          description `shouldBe` Just "Just a test for RPC function arguments"--          args `shouldBe` Just-            [aesonQQ|-              {-                "required": [-                  "double",-                  "varchar",-                  "boolean",-                  "date",-                  "money",-                  "enum"-                ],-                "properties": {-                  "double": {-                    "format": "double precision",-                    "type": "number"-                  },-                  "varchar": {-                    "format": "character varying",-                    "type": "string"-                  },-                  "boolean": {-                    "format": "boolean",-                    "type": "boolean"-                  },-                  "date": {-                    "format": "date",-                    "type": "string"-                  },-                  "money": {-                    "format": "money",-                    "type": "string"-                  },-                  "enum": {-                    "format": "enum_menagerie_type",-                    "type": "string"-                  },-                  "integer": {-                    "format": "integer",-                    "type": "integer"-                  },-                  "json": {-                    "format": "json",-                    "type": "string"-                  },-                  "jsonb": {-                    "format": "jsonb",-                    "type": "string"-                  }-                },-                "type": "object",-                "description": "An RPC function\n\nJust a test for RPC function arguments"-              }-            |]--      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--    it "includes read/write verbs for writeable table" $ do-      r <- request methodOptions "/items" [] ""-      liftIO $-        simpleHeaders r `shouldSatisfy`-          matchHeader "Allow" "GET,POST,PATCH,DELETE"--    it "includes read verbs for read-only table" $ do-      r <- request methodOptions "/has_count_column" [] ""-      liftIO $-        simpleHeaders r `shouldSatisfy`-          matchHeader "Allow" "GET"
test/Feature/UnicodeSpec.hs view
@@ -2,12 +2,12 @@  import Network.Wai (Application) +import Network.HTTP.Types import Test.Hspec import Test.Hspec.Wai import Test.Hspec.Wai.JSON -import Protolude  hiding (get)-import SpecHelper+import Protolude hiding (get)  spec :: SpecWith ((), Application) spec =@@ -16,9 +16,19 @@       get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"         `shouldRespondWith` "[]" -      void $ post "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"-        [json| { "هویت": 1 } |]+      request methodPost "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"+          [("Prefer", "tx=commit"), ("Prefer", "return=representation")]+          [json| { "هویت": 1 } |]+        `shouldRespondWith`+          [json| [{ "هویت": 1 }] |]+          { matchStatus = 201 }        get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"-        `shouldRespondWith` [json| [{ "هویت": 1 }] |]-        { matchHeaders = [matchContentTypeJson] }+        `shouldRespondWith`+          [json| [{ "هویت": 1 }] |]++      request methodDelete "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"+          [("Prefer", "tx=commit")]+          ""+        `shouldRespondWith`+          204
+ test/Feature/UpdateSpec.hs view
@@ -0,0 +1,349 @@+module Feature.UpdateSpec where++import Network.Wai (Application)+import Test.Hspec  hiding (pendingWith)++import Network.HTTP.Types+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import Protolude  hiding (get)+import SpecHelper++spec :: SpecWith ((), Application)+spec = do+  describe "Patching record" $ do+    context "to unknown uri" $+      it "indicates no table found by returning 404" $+        request methodPatch "/fake" []+          [json| { "real": false } |]+            `shouldRespondWith` 404++    context "on an empty table" $+      it "indicates no records found to update by returning 404" $+        request methodPatch "/empty_table" []+          [json| { "extra":20 } |]+          `shouldRespondWith` ""+          { matchStatus  = 404,+            matchHeaders = []+          }++    context "with invalid json payload" $+      it "fails with 400 and error" $+        request methodPatch "/simple_pk" [] "}{ x = 2"+          `shouldRespondWith`+          [json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]+          { matchStatus  = 400,+            matchHeaders = [matchContentTypeJson]+          }++    context "with no payload" $+      it "fails with 400 and error" $+        request methodPatch "/items" [] ""+          `shouldRespondWith`+          [json|{"message":"Error in $: not enough input"}|]+          { matchStatus  = 400,+            matchHeaders = [matchContentTypeJson]+          }++    context "in a nonempty table" $ do+      it "can update a single item" $ do+        patch "/items?id=eq.2"+            [json| { "id":42 } |]+          `shouldRespondWith`+            ""+            { matchStatus  = 204+            , matchHeaders = ["Content-Range" <:> "0-0/*"]+            }++      it "returns empty array when no rows updated and return=rep" $+        request methodPatch "/items?id=eq.999999"+          [("Prefer", "return=representation")] [json| { "id":999999 } |]+          `shouldRespondWith` "[]"+          {+            matchStatus  = 404,+            matchHeaders = []+          }++      it "gives a 404 when no rows updated" $+        request methodPatch "/items?id=eq.99999999" []+          [json| { "id": 42 } |]+            `shouldRespondWith` 404++      it "returns updated object as array when return=rep" $+        request methodPatch "/items?id=eq.2"+          [("Prefer", "return=representation")] [json| { "id":2 } |]+          `shouldRespondWith` [json|[{"id":2}]|]+          { matchStatus  = 200,+            matchHeaders = ["Content-Range" <:> "0-0/*"]+          }++      it "can update multiple items" $ do+        get "/no_pk?select=a&b=eq.1"+          `shouldRespondWith`+            [json|[]|]++        request methodPatch "/no_pk?b=eq.0"+            [("Prefer", "tx=commit")]+            [json| { b: "1" } |]+          `shouldRespondWith`+            ""+            { matchStatus  = 204+            , matchHeaders = ["Content-Range" <:> "0-1/*"+                             , "Preference-Applied" <:> "tx=commit" ]+            }++        -- check it really got updated+        get "/no_pk?select=a&b=eq.1"+          `shouldRespondWith`+            [json|[ { a: "1" }, { a: "2" } ]|]++        -- put value back for other tests+        request methodPatch "/no_pk?b=eq.1"+            [("Prefer", "tx=commit")]+            [json| { b: "0" } |]+          `shouldRespondWith`+            204++      it "can set a column to NULL" $ do+        request methodPatch "/no_pk?a=eq.1"+            [("Prefer", "return=representation")]+            [json| { b: null } |]+          `shouldRespondWith`+            [json| [{ a: "1", b: null }] |]++      context "filtering by a computed column" $ do+        it "is successful" $+          request methodPatch+            "/items?is_first=eq.true"+            [("Prefer", "return=representation")]+            [json| { id: 100 } |]+            `shouldRespondWith` [json| [{ id: 100 }] |]+            { matchStatus  = 200,+              matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]+            }++        it "indicates no records updated by returning 404" $+          request methodPatch+            "/items?always_true=eq.false"+            [("Prefer", "return=representation")]+            [json| { id: 100 } |]+            `shouldRespondWith` "[]"+            { matchStatus  = 404,+              matchHeaders = []+            }++      context "with representation requested" $ do+        it "can provide a representation" $ do+          _ <- post "/items"+            [json| { id: 1 } |]+          request methodPatch+            "/items?id=eq.1"+            [("Prefer", "return=representation")]+            [json| { id: 99 } |]+            `shouldRespondWith` [json| [{id:99}] |]+            { matchHeaders = [matchContentTypeJson] }+          -- put value back for other tests+          void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]++        it "can return computed columns" $+          request methodPatch+            "/items?id=eq.1&select=id,always_true"+            [("Prefer", "return=representation")]+            [json| { id: 1 } |]+            `shouldRespondWith` [json| [{ id: 1, always_true: true }] |]+            { matchHeaders = [matchContentTypeJson] }++        it "can select overloaded computed columns" $ do+          request methodPatch+            "/items?id=eq.1&select=id,computed_overload"+            [("Prefer", "return=representation")]+            [json| { id: 1 } |]+            `shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]+            { matchHeaders = [matchContentTypeJson] }+          request methodPatch+            "/items2?id=eq.1&select=id,computed_overload"+            [("Prefer", "return=representation")]+            [json| { id: 1 } |]+            `shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]+            { matchHeaders = [matchContentTypeJson] }++      it "ignores ?select= when return not set or return=minimal" $ do+        request methodPatch "/items?id=eq.1&select=id" [] [json| { id:1 } |]+          `shouldRespondWith` ""+          {+            matchStatus  = 204,+            matchHeaders = ["Content-Range" <:> "0-0/*"]+          }+        request methodPatch "/items?id=eq.1&select=id" [("Prefer", "return=minimal")] [json| { id:1 } |]+          `shouldRespondWith` ""+          {+            matchStatus  = 204,+            matchHeaders = ["Content-Range" <:> "0-0/*"]+          }++      context "when patching with an empty body" $ do+        it "makes no updates and returns 204 without return= and without ?select=" $ do+          request methodPatch "/items" [] [json| {} |]+            `shouldRespondWith` ""+            {+              matchStatus  = 204,+              matchHeaders = ["Content-Range" <:> "*/*"]+            }++          request methodPatch "/items" [] [json| [] |]+            `shouldRespondWith` ""+            {+              matchStatus  = 204,+              matchHeaders = ["Content-Range" <:> "*/*"]+            }++          request methodPatch "/items" [] [json| [{}] |]+            `shouldRespondWith` ""+            {+              matchStatus  = 204,+              matchHeaders = ["Content-Range" <:> "*/*"]+            }++        it "makes no updates and returns 204 without return= and with ?select=" $ do+          request methodPatch "/items?select=id" [] [json| {} |]+            `shouldRespondWith` ""+            {+              matchStatus  = 204,+              matchHeaders = ["Content-Range" <:> "*/*"]+            }++          request methodPatch "/items?select=id" [] [json| [] |]+            `shouldRespondWith` ""+            {+              matchStatus  = 204,+              matchHeaders = ["Content-Range" <:> "*/*"]+            }++          request methodPatch "/items?select=id" [] [json| [{}] |]+            `shouldRespondWith` ""+            {+              matchStatus  = 204,+              matchHeaders = ["Content-Range" <:> "*/*"]+            }++        it "makes no updates and returns 200 with return=rep and without ?select=" $+          request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]+            `shouldRespondWith` "[]"+            {+              matchStatus  = 200,+              matchHeaders = ["Content-Range" <:> "*/*"]+            }++        it "makes no updates and returns 200 with return=rep and with ?select=" $+          request methodPatch "/items?select=id" [("Prefer", "return=representation")] [json| {} |]+            `shouldRespondWith` "[]"+            {+              matchStatus  = 200,+              matchHeaders = ["Content-Range" <:> "*/*"]+            }++        it "makes no updates and returns 200 with return=rep and with ?select= for overloaded computed columns" $+          request methodPatch "/items?select=id,computed_overload" [("Prefer", "return=representation")] [json| {} |]+            `shouldRespondWith` "[]"+            {+              matchStatus  = 200,+              matchHeaders = ["Content-Range" <:> "*/*"]+            }++    context "with unicode values" $+      it "succeeds and returns values intact" $ do+        request methodPatch "/no_pk?a=eq.1"+            [("Prefer", "return=representation")]+            [json| { "a":"圍棋", "b":"¥" } |]+          `shouldRespondWith`+            [json|[ { "a":"圍棋", "b":"¥" } ]|]++    context "PATCH with ?columns parameter" $ do+      it "ignores json keys not included in ?columns" $ do+        request methodPatch "/articles?id=eq.1&columns=body"+            [("Prefer", "return=representation")]+            [json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |]+          `shouldRespondWith`+            [json|[{"id": 1, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]++      it "ignores json keys and gives 404 if no record updated" $+        request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]+          [json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404++  context "tables with self reference foreign keys" $ do+    it "embeds children after update" $+      request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"+              [("Prefer", "return=representation")]+        [json|{"name": "tardis-patched"}|]+        `shouldRespondWith`+        [json|+          [ { "id": 0, "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]+        |]+        { matchStatus  = 200,+          matchHeaders = [matchContentTypeJson]+        }++    it "embeds parent, children and grandchildren after update" $+      request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name,web_content(name)),parent_content:p_web_id(name)"+              [("Prefer", "return=representation")]+        [json|{"name": "tardis-patched-2"}|]+        `shouldRespondWith`+        [json| [+          {+            "id": 0,+            "name": "tardis-patched-2",+            "parent_content": { "name": "wat" },+            "web_content": [+                { "name": "fezz", "web_content": [ { "name": "wut" } ] },+                { "name": "foo",  "web_content": [] },+                { "name": "bar",  "web_content": [] }+            ]+          }+        ] |]+        { matchStatus  = 200,+          matchHeaders = [matchContentTypeJson]+        }++    it "embeds children after update without explicitly including the id in the ?select" $+      request methodPatch "/web_content?id=eq.0&select=name,web_content(name)"+              [("Prefer", "return=representation")]+        [json|{"name": "tardis-patched"}|]+        `shouldRespondWith`+        [json|+          [ { "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]+        |]+        { matchStatus  = 200,+          matchHeaders = [matchContentTypeJson]+        }++    it "embeds an M2M relationship plus parent after update" $+      request methodPatch "/users?id=eq.1&select=name,tasks(name,project:projects(name))"+              [("Prefer", "return=representation")]+        [json|{"name": "Kevin Malone"}|]+        `shouldRespondWith`+        [json|[+          {+            "name": "Kevin Malone",+            "tasks": [+                { "name": "Design w7", "project": { "name": "Windows 7" } },+                { "name": "Code w7", "project": { "name": "Windows 7" } },+                { "name": "Design w10", "project": { "name": "Windows 10" } },+                { "name": "Code w10", "project": { "name": "Windows 10" } }+            ]+          }+        ]|]+        { matchStatus  = 200,+          matchHeaders = [matchContentTypeJson]+        }++  context "table with limited privileges" $ do+    it "succeeds updating row and gives a 204 when using return=minimal" $+      request methodPatch "/app_users?id=eq.1" [("Prefer", "return=minimal")]+        [json| { "password": "passxyz" } |]+          `shouldRespondWith` 204++    it "can update without return=minimal and no explicit select" $+      request methodPatch "/app_users?id=eq.1" []+        [json| { "password": "passabc" } |]+          `shouldRespondWith` 204
test/Feature/UpsertSpec.hs view
@@ -6,7 +6,6 @@ import Test.Hspec import Test.Hspec.Wai import Test.Hspec.Wai.JSON-import Text.Heredoc  import Protolude  hiding (get, put) import SpecHelper@@ -101,30 +100,38 @@             }          it "INSERTs and ignores rows on single unique key conflict" $-          request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]-            [json| [-              { "unique_key": 1, "value": "B" },-              { "unique_key": 2, "value": "C" },-              { "unique_key": 3, "value": "D" }-            ]|] `shouldRespondWith` [json| [-              { "unique_key": 3, "value": "D" }-            ]|]-            { matchStatus = 201-            , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]-            }+          request methodPost "/single_unique?on_conflict=unique_key"+              [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]+              [json| [+                { "unique_key": 1, "value": "B" },+                { "unique_key": 2, "value": "C" },+                { "unique_key": 3, "value": "D" }+              ]|]+            `shouldRespondWith`+              [json| [+                { "unique_key": 2, "value": "C" },+                { "unique_key": 3, "value": "D" }+              ]|]+              { matchStatus = 201+              , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]+              }          it "INSERTs and UPDATEs rows on compound unique keys conflict" $-          request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]-            [json| [-              { "key1": 1, "key2": 1, "value": "B" },-              { "key1": 1, "key2": 2, "value": "C" },-              { "key1": 1, "key2": 3, "value": "D" }-            ]|] `shouldRespondWith` [json| [-              { "key1": 1, "key2": 3, "value": "D" }-            ]|]-            { matchStatus = 201-            , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]-            }+          request methodPost "/compound_unique?on_conflict=key1,key2"+              [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]+              [json| [+                { "key1": 1, "key2": 1, "value": "B" },+                { "key1": 1, "key2": 2, "value": "C" },+                { "key1": 1, "key2": 3, "value": "D" }+              ]|]+            `shouldRespondWith`+              [json| [+                { "key1": 1, "key2": 2, "value": "C" },+                { "key1": 1, "key2": 3, "value": "D" }+              ]|]+              { matchStatus = 201+              , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]+              }        it "succeeds if the table has only PK cols and no other cols" $ do         request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]@@ -161,160 +168,205 @@       context "Restrictions" $ do         it "fails if Range is specified" $           request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]-            [str| [ { "name": "Javascript", "rank": 1 } ]|]+            [json| [ { "name": "Javascript", "rank": 1 } ]|]             `shouldRespondWith`             [json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]             { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }          it "fails if limit is specified" $           put "/tiobe_pls?name=eq.Javascript&limit=1"-            [str| [ { "name": "Javascript", "rank": 1 } ]|]+            [json| [ { "name": "Javascript", "rank": 1 } ]|]             `shouldRespondWith`             [json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]             { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }          it "fails if offset is specified" $           put "/tiobe_pls?name=eq.Javascript&offset=1"-            [str| [ { "name": "Javascript", "rank": 1 } ]|]+            [json| [ { "name": "Javascript", "rank": 1 } ]|]             `shouldRespondWith`             [json|{"message":"Range header and limit/offset querystring parameters are not allowed for PUT"}|]             { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }          it "rejects every other filter than pk cols eq's" $ do           put "/tiobe_pls?rank=eq.19"-            [str| [ { "name": "Go", "rank": 19 } ]|]+            [json| [ { "name": "Go", "rank": 19 } ]|]             `shouldRespondWith`             [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]             { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }            put "/tiobe_pls?id=not.eq.Java"-            [str| [ { "name": "Go", "rank": 19 } ]|]+            [json| [ { "name": "Go", "rank": 19 } ]|]             `shouldRespondWith`             [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]             { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }           put "/tiobe_pls?id=in.(Go)"-            [str| [ { "name": "Go", "rank": 19 } ]|]+            [json| [ { "name": "Go", "rank": 19 } ]|]             `shouldRespondWith`             [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]             { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }           put "/tiobe_pls?and=(id.eq.Go)"-            [str| [ { "name": "Go", "rank": 19 } ]|]+            [json| [ { "name": "Go", "rank": 19 } ]|]             `shouldRespondWith`             [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]             { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }          it "fails if not all composite key cols are specified as eq filters" $ do           put "/employees?first_name=eq.Susan"-            [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]+            [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]             `shouldRespondWith`             [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]             { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }           put "/employees?last_name=eq.Heidt"-            [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]+            [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]             `shouldRespondWith`             [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]             { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }        it "fails if the uri primary key doesn't match the payload primary key" $ do-        put "/tiobe_pls?name=eq.MATLAB" [str| [ { "name": "Perl", "rank": 17 } ]|]+        put "/tiobe_pls?name=eq.MATLAB" [json| [ { "name": "Perl", "rank": 17 } ]|]           `shouldRespondWith`           [json|{"message":"Payload values do not match URL in primary key column(s)"}|]           { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }         put "/employees?first_name=eq.Wendy&last_name=eq.Anderson"-          [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]+          [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]           `shouldRespondWith`           [json|{"message":"Payload values do not match URL in primary key column(s)"}|]           { matchStatus = 400 , matchHeaders = [matchContentTypeJson] }        it "fails if the table has no PK" $-        put "/no_pk?a=eq.one&b=eq.two" [str| [ { "a": "one", "b": "two" } ]|]+        put "/no_pk?a=eq.one&b=eq.two" [json| [ { "a": "one", "b": "two" } ]|]           `shouldRespondWith`           [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]           { matchStatus = 405 , matchHeaders = [matchContentTypeJson] }        context "Inserting row" $ do         it "succeeds on table with single pk col" $ do-          get "/tiobe_pls?name=eq.Go" `shouldRespondWith` "[]"-          put "/tiobe_pls?name=eq.Go" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 204-          get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json| [ { "name": "Go", "rank": 19 } ]|] { matchHeaders = [matchContentTypeJson] }+          -- assert that the next request will indeed be an insert+          get "/tiobe_pls?name=eq.Go"+            `shouldRespondWith`+              [json|[]|] +          request methodPut "/tiobe_pls?name=eq.Go"+              [("Prefer", "return=representation")]+              [json| [ { "name": "Go", "rank": 19 } ]|]+            `shouldRespondWith`+              [json| [ { "name": "Go", "rank": 19 } ]|]+         it "succeeds on table with composite pk" $ do-          get "/employees?first_name=eq.Susan&last_name=eq.Heidt"-            `shouldRespondWith` "[]"-          put "/employees?first_name=eq.Susan&last_name=eq.Heidt"-            [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]-            `shouldRespondWith` 204+          -- assert that the next request will indeed be an insert           get "/employees?first_name=eq.Susan&last_name=eq.Heidt"             `shouldRespondWith`-            [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]-            { matchHeaders = [matchContentTypeJson] }+              [json|[]|] +          request methodPut "/employees?first_name=eq.Susan&last_name=eq.Heidt"+              [("Prefer", "return=representation")]+              [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]+            `shouldRespondWith`+              [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]+         it "succeeds if the table has only PK cols and no other cols" $ do-          get "/only_pk?id=eq.10" `shouldRespondWith` "[]"-          put "/only_pk?id=eq.10" [str|[ { "id": 10 } ]|] `shouldRespondWith` 204-          get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }+          -- assert that the next request will indeed be an insert+          get "/only_pk?id=eq.10"+            `shouldRespondWith`+              [json|[]|] +          request methodPut "/only_pk?id=eq.10"+              [("Prefer", "return=representation")]+              [json|[ { "id": 10 } ]|]+            `shouldRespondWith`+              [json|[ { "id": 10 } ]|]+       context "Updating row" $ do         it "succeeds on table with single pk col" $ do-          get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json|[ { "name": "Go", "rank": 19 } ]|] { matchHeaders = [matchContentTypeJson] }-          put "/tiobe_pls?name=eq.Go" [str| [ { "name": "Go", "rank": 13 } ]|] `shouldRespondWith` 204-          get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json| [ { "name": "Go", "rank": 13 } ]|] { matchHeaders = [matchContentTypeJson] }+          -- assert that the next request will indeed be an update+          get "/tiobe_pls?name=eq.Java"+            `shouldRespondWith`+              [json|[ { "name": "Java", "rank": 1 } ]|] -        it "succeeds if the payload has more than one row, but it only puts the first element" $-          request methodPut "/tiobe_pls?name=eq.Go"-            [("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]-            [str| [ { "name": "Go", "rank": 19 }, { "name": "Swift", "rank": 12 } ] |]+          request methodPut "/tiobe_pls?name=eq.Java"+              [("Prefer", "return=representation")]+              [json| [ { "name": "Java", "rank": 13 } ]|]             `shouldRespondWith`-            [json|{ "name": "Go", "rank": 19 }|]-            { matchStatus = 200 , matchHeaders = [matchContentTypeSingular] }+              [json| [ { "name": "Java", "rank": 13 } ]|] +        -- TODO: move this to SingularSpec?+        it "succeeds if the payload has more than one row, but it only puts the first element" $ do+          -- assert that the next request will indeed be an update+          get "/tiobe_pls?name=eq.Java"+            `shouldRespondWith`+              [json|[ { "name": "Java", "rank": 1 } ]|]++          request methodPut "/tiobe_pls?name=eq.Java"+              [("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]+              [json| [ { "name": "Java", "rank": 19 }, { "name": "Swift", "rank": 12 } ] |]+            `shouldRespondWith`+              [json|{ "name": "Java", "rank": 19 }|]+              { matchHeaders = [matchContentTypeSingular] }+         it "succeeds on table with composite pk" $ do-          get "/employees?first_name=eq.Susan&last_name=eq.Heidt"+          -- assert that the next request will indeed be an update+          get "/employees?first_name=eq.Frances M.&last_name=eq.Roe"             `shouldRespondWith`-            [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]-            { matchHeaders = [matchContentTypeJson] }-          put "/employees?first_name=eq.Susan&last_name=eq.Heidt"-            [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "60000", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]-            `shouldRespondWith` 204-          get "/employees?first_name=eq.Susan&last_name=eq.Heidt"+              [json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$24,000.00", "company": "One-Up Realty", "occupation": "Author" } ]|]++          request methodPut "/employees?first_name=eq.Frances M.&last_name=eq.Roe"+              [("Prefer", "return=representation")]+              [json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "60000", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]             `shouldRespondWith`-            [json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]-            { matchHeaders = [matchContentTypeJson] }+              [json| [ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]          it "succeeds if the table has only PK cols and no other cols" $ do-          get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }-          put "/only_pk?id=eq.10" [str|[ { "id": 10 } ]|] `shouldRespondWith` 204-          get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }+          -- assert that the next request will indeed be an update+          get "/only_pk?id=eq.1"+            `shouldRespondWith`+              [json|[ { "id": 1 } ]|] +          request methodPut "/only_pk?id=eq.1"+              [("Prefer", "return=representation")]+              [json|[ { "id": 1 } ]|]+            `shouldRespondWith`+              [json|[ { "id": 1 } ]|]++      -- TODO: move this to SingularSpec?       it "works with return=representation and vnd.pgrst.object+json" $         request methodPut "/tiobe_pls?name=eq.Ruby"           [("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]-          [str| [ { "name": "Ruby", "rank": 11 } ]|]+          [json| [ { "name": "Ruby", "rank": 11 } ]|]           `shouldRespondWith` [json|{ "name": "Ruby", "rank": 11 }|] { matchHeaders = [matchContentTypeSingular] }      context "with a camel case pk column" $ do-      it "works with POST and merge-duplicates/ignore-duplicates headers" $ do-        request methodPost "/UnitTest" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]-          [json| [-            { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },-            { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }-          ]|] `shouldRespondWith` [json|[-            { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },-            { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }-          ]|]-          { matchStatus = 201-          , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]-          }-        request methodPost "/UnitTest" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]-          [json| [-            { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },-            { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }-          ]|] `shouldRespondWith` [json|[]|]-          { matchStatus = 201-          , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]-          }+      it "works with POST and merge-duplicates" $ do+        request methodPost "/UnitTest"+            [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]+            [json|[+              { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },+              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }+            ]|]+          `shouldRespondWith`+            [json|[+              { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },+              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }+            ]|]+            { matchStatus = 201+            , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates"]+            } +      it "works with POST and ignore-duplicates headers" $ do+        request methodPost "/UnitTest"+            [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]+            [json|[+              { "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },+              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }+            ]|]+          `shouldRespondWith`+            [json|[+              { "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }+            ]|]+            { matchStatus = 201+            , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates"]+            }+       it "works with PUT" $ do-        put "/UnitTest?idUnitTest=eq.1" [str| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] `shouldRespondWith` 204+        put "/UnitTest?idUnitTest=eq.1" [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] `shouldRespondWith` 204         get "/UnitTest?idUnitTest=eq.1" `shouldRespondWith`-          [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] { matchHeaders = [matchContentTypeJson] }+          [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|]
test/Main.hs view
@@ -1,25 +1,25 @@ module Main where +import qualified Data.Aeson                 as JSON import qualified Hasql.Pool                 as P import qualified Hasql.Transaction.Sessions as HT -import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,-                           updateAction) import Data.Function      (id) import Data.List.NonEmpty (toList)-import Data.Time.Clock    (getCurrentTime) -import Data.IORef import Test.Hspec -import PostgREST.App         (postgrest)-import PostgREST.Config      (AppConfig (..))-import PostgREST.DbStructure (getDbStructure, getPgVersion)-import PostgREST.Types       (pgVersion95, pgVersion96)-import Protolude             hiding (toList, toS)-import Protolude.Conv        (toS)+import PostgREST.App              (postgrest)+import PostgREST.Config           (AppConfig (..), LogLevel (..))+import PostgREST.Config.Database  (queryPgVersion)+import PostgREST.Config.PgVersion (pgVersion96)+import PostgREST.DbStructure      (queryDbStructure)+import Protolude                  hiding (toList, toS)+import Protolude.Conv             (toS) import SpecHelper +import qualified PostgREST.AppState as AppState+ import qualified Feature.AndOrParamsSpec import qualified Feature.AsymmetricJwtSpec import qualified Feature.AudienceJwtSecretSpec@@ -28,161 +28,200 @@ import qualified Feature.ConcurrentSpec import qualified Feature.CorsSpec import qualified Feature.DeleteSpec+import qualified Feature.DisabledOpenApiSpec import qualified Feature.EmbedDisambiguationSpec import qualified Feature.ExtraSearchPathSpec import qualified Feature.HtmlRawOutputSpec+import qualified Feature.IgnorePrivOpenApiSpec import qualified Feature.InsertSpec import qualified Feature.JsonOperatorSpec import qualified Feature.MultipleSchemaSpec import qualified Feature.NoJwtSpec import qualified Feature.NonexistentSchemaSpec-import qualified Feature.PgVersion95Spec-import qualified Feature.PgVersion96Spec+import qualified Feature.OpenApiSpec+import qualified Feature.OptionsSpec import qualified Feature.ProxySpec import qualified Feature.QueryLimitedSpec import qualified Feature.QuerySpec import qualified Feature.RangeSpec import qualified Feature.RawOutputTypesSpec+import qualified Feature.RollbackSpec import qualified Feature.RootSpec+import qualified Feature.RpcPreRequestGucsSpec import qualified Feature.RpcSpec import qualified Feature.SingularSpec-import qualified Feature.StructureSpec import qualified Feature.UnicodeSpec+import qualified Feature.UpdateSpec import qualified Feature.UpsertSpec   main :: IO () main = do-  getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }--  testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"+  testDbConn <- getEnvVarWithDefault "PGRST_DB_URI" "postgres://postgrest_test@localhost/postgrest_test"    pool <- P.acquire (3, 10, toS testDbConn) -  actualPgVersion <- either (panic.show) id <$> P.use pool getPgVersion+  actualPgVersion <- either (panic.show) id <$> P.use pool queryPgVersion -  refDbStructure <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ testCfg testDbConn) actualPgVersion+  baseDbStructure <-+    loadDbStructure pool+      (configDbSchemas $ testCfg testDbConn)+      (configDbExtraSearchPath $ testCfg testDbConn)    let     -- For tests that run with the same refDbStructure-    app cfg = return ((), postgrest (cfg testDbConn) refDbStructure pool getTime $ pure ())+    app cfg = do+      let config = cfg testDbConn+      appState <- AppState.initWithPool pool config+      AppState.putPgVersion appState actualPgVersion+      AppState.putDbStructure appState baseDbStructure+      when (isJust $ configDbRootSpec config) $+        AppState.putJsonDbS appState $ toS $ JSON.encode baseDbStructure+      return ((), postgrest LogCrit appState $ pure ())      -- For tests that run with a different DbStructure(depends on configSchemas)     appDbs cfg = do-      dbs <- (newIORef . Just) =<< setupDbStructure pool (configSchemas $ cfg testDbConn) actualPgVersion-      return ((), postgrest (cfg testDbConn) dbs pool getTime $ pure ())+      let config = cfg testDbConn+      customDbStructure <-+        loadDbStructure pool+          (configDbSchemas config)+          (configDbExtraSearchPath config)+      appState <- AppState.initWithPool pool config+      AppState.putDbStructure appState customDbStructure+      when (isJust $ configDbRootSpec config) $+        AppState.putJsonDbS appState $ toS $ JSON.encode baseDbStructure+      return ((), postgrest LogCrit appState $ pure ())    let withApp              = app testCfg       maxRowsApp           = app testMaxRowsCfg+      disabledOpenApi      = app testDisabledOpenApiCfg       proxyApp             = app testProxyCfg       noJwtApp             = app testCfgNoJWT       binaryJwtApp         = app testCfgBinaryJWT       audJwtApp            = app testCfgAudienceJWT       asymJwkApp           = app testCfgAsymJWK       asymJwkSetApp        = app testCfgAsymJWKSet-      extraSearchPathApp   = app testCfgExtraSearchPath       rootSpecApp          = app testCfgRootSpec       htmlRawOutputApp     = app testCfgHtmlRawOutput       responseHeadersApp   = app testCfgResponseHeaders+      disallowRollbackApp  = app testCfgDisallowRollback+      forceRollbackApp     = app testCfgForceRollback +      extraSearchPathApp   = appDbs testCfgExtraSearchPath       unicodeApp           = appDbs testUnicodeCfg       nonexistentSchemaApp = appDbs testNonexistentSchemaCfg       multipleSchemaApp    = appDbs testMultipleSchemaCfg+      ignorePrivOpenApi    = appDbs testIgnorePrivOpenApiCfg -  let reset, analyze :: IO ()-      reset = resetDb testDbConn+  let analyze :: IO ()       analyze = do         analyzeTable testDbConn "items"         analyzeTable testDbConn "child_entities" -      extraSpecs =-        [("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++-        [("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95]-       specs = uncurry describe <$> [-          ("Feature.AuthSpec"                , Feature.AuthSpec.spec actualPgVersion)-        , ("Feature.RawOutputTypesSpec"      , Feature.RawOutputTypesSpec.spec)+          ("Feature.AndOrParamsSpec"         , Feature.AndOrParamsSpec.spec actualPgVersion)+        , ("Feature.AuthSpec"                , Feature.AuthSpec.spec actualPgVersion)         , ("Feature.ConcurrentSpec"          , Feature.ConcurrentSpec.spec)         , ("Feature.CorsSpec"                , Feature.CorsSpec.spec)+        , ("Feature.DeleteSpec"              , Feature.DeleteSpec.spec)+        , ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec)+        , ("Feature.InsertSpec"              , Feature.InsertSpec.spec actualPgVersion)         , ("Feature.JsonOperatorSpec"        , Feature.JsonOperatorSpec.spec actualPgVersion)+        , ("Feature.OpenApiSpec"             , Feature.OpenApiSpec.spec)+        , ("Feature.OptionsSpec"             , Feature.OptionsSpec.spec)         , ("Feature.QuerySpec"               , Feature.QuerySpec.spec actualPgVersion)-        , ("Feature.EmbedDisambiguationSpec" , Feature.EmbedDisambiguationSpec.spec)+        , ("Feature.RawOutputTypesSpec"      , Feature.RawOutputTypesSpec.spec)         , ("Feature.RpcSpec"                 , Feature.RpcSpec.spec actualPgVersion)-        , ("Feature.StructureSpec"           , Feature.StructureSpec.spec)-        , ("Feature.AndOrParamsSpec"         , Feature.AndOrParamsSpec.spec actualPgVersion)-        ] ++ extraSpecs--      mutSpecs = uncurry describe <$> [-          ("Feature.DeleteSpec"             , Feature.DeleteSpec.spec)-        , ("Feature.InsertSpec"             , Feature.InsertSpec.spec actualPgVersion)-        , ("Feature.SingularSpec"           , Feature.SingularSpec.spec)+        , ("Feature.SingularSpec"            , Feature.SingularSpec.spec)+        , ("Feature.UpdateSpec"              , Feature.UpdateSpec.spec)+        , ("Feature.UpsertSpec"              , Feature.UpsertSpec.spec)         ]    hspec $ do-    -- Only certain Specs need a database reset, this should be used with care as it slows down the whole test suite.-    mapM_ (afterAll_ reset . before withApp) mutSpecs--    mapM_ (before withApp) specs+    mapM_ (parallel . before withApp) specs      -- we analyze to get accurate results from EXPLAIN-    beforeAll_ analyze . before withApp $+    parallel $ beforeAll_ analyze . before withApp $       describe "Feature.RangeSpec" Feature.RangeSpec.spec      -- this test runs with a raw-output-media-types set to text/html-    before htmlRawOutputApp $+    parallel $ before htmlRawOutputApp $       describe "Feature.HtmlRawOutputSpec" Feature.HtmlRawOutputSpec.spec      -- this test runs with a different server flag-    before maxRowsApp $+    parallel $ before maxRowsApp $       describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec      -- this test runs with a different schema-    before unicodeApp $+    parallel $ before unicodeApp $       describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec +    -- this test runs with openapi-mode set to disabled+    parallel $ before disabledOpenApi $+      describe "Feature.DisabledOpenApiSpec" Feature.DisabledOpenApiSpec.spec++    -- this test runs with openapi-mode set to ignore-acl+    parallel $ before ignorePrivOpenApi $+      describe "Feature.IgnorePrivOpenApiSpec" Feature.IgnorePrivOpenApiSpec.spec+     -- this test runs with a proxy-    before proxyApp $+    parallel $ before proxyApp $       describe "Feature.ProxySpec" Feature.ProxySpec.spec      -- this test runs without a JWT secret-    before noJwtApp $+    parallel $ before noJwtApp $       describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec      -- this test runs with a binary JWT secret-    before binaryJwtApp $+    parallel $ before binaryJwtApp $       describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec      -- this test runs with a binary JWT secret and an audience claim-    before audJwtApp $+    parallel $ before audJwtApp $       describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec      -- this test runs with asymmetric JWK-    before asymJwkApp $+    parallel $ before asymJwkApp $       describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec      -- this test runs with asymmetric JWKSet-    before asymJwkSetApp $+    parallel $ before asymJwkSetApp $       describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec      -- this test runs with a nonexistent db-schema-    before nonexistentSchemaApp $+    parallel $ before nonexistentSchemaApp $       describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec      -- this test runs with an extra search path-    before extraSearchPathApp $+    parallel $ before extraSearchPathApp $       describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec -    -- this test runs with a root spec function override     when (actualPgVersion >= pgVersion96) $ do-      before rootSpecApp $+      -- this test runs with a root spec function override+      parallel $ before rootSpecApp $         describe "Feature.RootSpec" Feature.RootSpec.spec-      before responseHeadersApp $-        describe "Feature.PgVersion96Spec" Feature.PgVersion96Spec.spec+      parallel $ before responseHeadersApp $+        describe "Feature.RpcPreRequestGucsSpec" Feature.RpcPreRequestGucsSpec.spec      -- this test runs with multiple schemas-    before multipleSchemaApp $+    parallel $ before multipleSchemaApp $       describe "Feature.MultipleSchemaSpec" $ Feature.MultipleSchemaSpec.spec actualPgVersion +    -- Note: the rollback tests can not run in parallel, because they test persistance and+    -- this results in race conditions++    -- this test runs with tx-rollback-all = true and tx-allow-override = true+    before withApp $+      describe"Feature.RollbackAllowedSpec" Feature.RollbackSpec.allowed++    -- this test runs with tx-rollback-all = false and tx-allow-override = false+    before disallowRollbackApp $+      describe "Feature.RollbackDisallowedSpec" Feature.RollbackSpec.disallowed++    -- this test runs with tx-rollback-all = true and tx-allow-override = false+    before forceRollbackApp $+      describe "Feature.RollbackForcedSpec" Feature.RollbackSpec.forced+   where-    setupDbStructure pool schemas ver =-      either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ getDbStructure (toList schemas) ver)+    loadDbStructure pool schemas extraSearchPath =+      either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ queryDbStructure (toList schemas) extraSearchPath True)
test/QueryCost.hs view
@@ -1,75 +1,85 @@ module Main where -import           Control.Lens               ((^?))-import qualified Data.Aeson.Lens            as L-import qualified Hasql.Decoders             as HD-import qualified Hasql.Encoders             as HE-import qualified Hasql.Pool                 as P-import qualified Hasql.Statement            as H-import qualified Hasql.Transaction          as HT-import qualified Hasql.Transaction.Sessions as HT+import           Control.Lens                      ((^?))+import qualified Data.Aeson.Lens                   as L+import qualified Hasql.Decoders                    as HD+import qualified Hasql.DynamicStatements.Snippet   as H+import qualified Hasql.DynamicStatements.Statement as H+import qualified Hasql.Pool                        as P+import qualified Hasql.Statement                   as H+import qualified Hasql.Transaction                 as HT+import qualified Hasql.Transaction.Sessions        as HT import           Text.Heredoc  import Protolude      hiding (get, toS) import Protolude.Conv (toS) -import PostgREST.QueryBuilder (requestToCallProcQuery)-import PostgREST.Types+import PostgREST.Query.QueryBuilder (requestToCallProcQuery)+import PostgREST.Request.ApiRequest (PayloadJSON (..)) +import PostgREST.DbStructure.Identifiers+import PostgREST.DbStructure.Proc+import PostgREST.Request.Preferences+ import SpecHelper (getEnvVarWithDefault)  import Test.Hspec  main :: IO () main = do-  testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"+  testDbConn <- getEnvVarWithDefault "PGRST_DB_URI" "postgres://postgrest_test@localhost/postgrest_test"   pool <- P.acquire (3, 10, toS testDbConn)    hspec $ describe "QueryCost" $     context "call proc query" $ do       it "should not exceed cost when calling setof composite proc" $ do-        cost <- exec pool [str| {"id": 3} |] $-          requestToCallProcQuery (QualifiedIdentifier "test" "get_projects_below") [PgArg "id" "int" True] False Nothing []+        cost <- exec pool $+          requestToCallProcQuery (QualifiedIdentifier "test" "get_projects_below") [PgArg "id" "int" True False]+          (Just $ RawJSON [str| {"id": 3} |]) False Nothing []         liftIO $           cost `shouldSatisfy` (< Just 40)        it "should not exceed cost when calling setof composite proc with empty params" $ do-        cost <- exec pool mempty $-          requestToCallProcQuery (QualifiedIdentifier "test" "getallprojects") [] False Nothing []+        cost <- exec pool $+          requestToCallProcQuery (QualifiedIdentifier "test" "getallprojects") [] Nothing False Nothing []         liftIO $           cost `shouldSatisfy` (< Just 30)        it "should not exceed cost when calling scalar proc" $ do-        cost <- exec pool [str| {"a": 3, "b": 4} |] $-          requestToCallProcQuery (QualifiedIdentifier "test" "add_them") [PgArg "a" "int" True, PgArg "b" "int" True] True Nothing []+        cost <- exec pool $+          requestToCallProcQuery (QualifiedIdentifier "test" "add_them") [PgArg "a" "int" True False, PgArg "b" "int" True False]+          (Just $ RawJSON [str| {"a": 3, "b": 4} |]) True Nothing []         liftIO $           cost `shouldSatisfy` (< Just 10)        context "params=multiple-objects" $ do         it "should not exceed cost when calling setof composite proc" $ do-          cost <- exec pool [str| [{"id": 1}, {"id": 4}] |] $-            requestToCallProcQuery (QualifiedIdentifier "test" "get_projects_below") [PgArg "id" "int" True] False (Just MultipleObjects) []+          cost <- exec pool $+            requestToCallProcQuery (QualifiedIdentifier "test" "get_projects_below") [PgArg "id" "int" True False]+            (Just $ RawJSON [str| [{"id": 1}, {"id": 4}] |]) False (Just MultipleObjects) []           liftIO $ do+            -- lower bound needed for now to make sure that cost is not Nothing             cost `shouldSatisfy` (> Just 2000)             cost `shouldSatisfy` (< Just 2100)          it "should not exceed cost when calling scalar proc" $ do-          cost <- exec pool [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |] $-            requestToCallProcQuery (QualifiedIdentifier "test" "add_them") [PgArg "a" "int" True, PgArg "b" "int" True] True Nothing []+          cost <- exec pool $+            requestToCallProcQuery (QualifiedIdentifier "test" "add_them") [PgArg "a" "int" True False, PgArg "b" "int" True False]+            (Just $ RawJSON [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True Nothing []           liftIO $             cost `shouldSatisfy` (< Just 10)  -exec :: P.Pool -> ByteString -> SqlQuery -> IO (Maybe Int64)-exec pool input query =+exec :: P.Pool -> H.Snippet -> IO (Maybe Int64)+exec pool query =   join . rightToMaybe <$>-  P.use pool (HT.transaction HT.ReadCommitted HT.Read $ HT.statement input $ explainCost query)+  P.use pool (HT.transaction HT.ReadCommitted HT.Read $ HT.statement mempty $ explainCost query) -explainCost :: SqlQuery -> H.Statement ByteString (Maybe Int64)+explainCost :: H.Snippet -> H.Statement () (Maybe Int64) explainCost query =-  H.Statement (encodeUtf8 sql) (HE.param $ HE.nonNullable HE.unknown) decodeExplain False+  H.dynamicallyParameterized snippet decodeExplain False  where-   sql = "EXPLAIN (FORMAT JSON) " <> query+   snippet = "EXPLAIN (FORMAT JSON) " <> query    decodeExplain :: HD.Result (Maybe Int64)    decodeExplain =      let row = HD.singleRow $ HD.column $ HD.nonNullable HD.bytea in
test/SpecHelper.hs view
@@ -8,7 +8,7 @@ import qualified System.IO.Error        as E  import Data.Aeson           (Value (..), decode, encode)-import Data.CaseInsensitive (CI (..))+import Data.CaseInsensitive (CI (..), original) import Data.List            (lookup) import Data.List.NonEmpty   (fromList) import Network.Wai.Test     (SResponse (simpleBody, simpleHeaders, simpleStatus))@@ -22,10 +22,14 @@ import Test.Hspec.Wai import Text.Heredoc -import PostgREST.Config (AppConfig (..))-import PostgREST.Types  (JSPathExp (..))-import Protolude        hiding (toS)-import Protolude.Conv   (toS)+import PostgREST.Config                  (AppConfig (..),+                                          JSPathExp (..),+                                          LogLevel (..),+                                          OpenAPIMode (..),+                                          parseSecret)+import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..))+import Protolude                         hiding (toS)+import Protolude.Conv                    (toS)  matchContentTypeJson :: MatchHeader matchContentTypeJson = "Content-Type" <:> "application/json; charset=utf-8"@@ -33,6 +37,12 @@ matchContentTypeSingular :: MatchHeader matchContentTypeSingular = "Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8" +matchHeaderAbsent :: HeaderName -> MatchHeader+matchHeaderAbsent name = MatchHeader $ \headers _body ->+  case lookup name headers of+    Just _  -> Just $ "unexpected header: " <> toS (original name) <> "\n"+    Nothing -> Nothing+ validateOpenApiResponse :: [Header] -> WaiSession () () validateOpenApiResponse headers = do   r <- request methodGet "/" headers ""@@ -63,87 +73,117 @@   getEnv (toS var) `E.catchIOError` const (return $ toS def)  _baseCfg :: AppConfig-_baseCfg =  -- Connection Settings-  AppConfig mempty "postgrest_test_anonymous" Nothing (fromList ["test"]) "localhost" 3000-            -- No user configured Unix Socket-            Nothing-            -- No user configured Unix Socket file mode (defaults to 660)-            (Right 432)-            -- Jwt settings-            (Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False Nothing-            -- Connection Modifiers-            10 10 Nothing (Just "test.switch_role")-            -- Debug Settings-            True-            [ ("app.settings.app_host", "localhost")-            , ("app.settings.external_api_secret", "0123456789abcdef")-            ]-            -- Default role claim key-            (Right [JSPKey "role"])-            -- Empty db-extra-search-path-            []-            -- No root spec override-            Nothing-            -- Raw output media types-            []+_baseCfg = let secret = Just $ encodeUtf8 "reallyreallyreallyreallyverysafe" in+  AppConfig {+    configAppSettings           = [ ("app.settings.app_host", "localhost") , ("app.settings.external_api_secret", "0123456789abcdef") ]+  , configDbAnonRole            = "postgrest_test_anonymous"+  , configDbChannel             = mempty+  , configDbChannelEnabled      = True+  , configDbExtraSearchPath     = []+  , configDbMaxRows             = Nothing+  , configDbPoolSize            = 10+  , configDbPoolTimeout         = 10+  , configDbPreRequest          = Just $ QualifiedIdentifier "test" "switch_role"+  , configDbPreparedStatements  = True+  , configDbRootSpec            = Nothing+  , configDbSchemas             = fromList ["test"]+  , configDbConfig              = False+  , configDbUri                 = mempty+  , configFilePath              = Nothing+  , configJWKS                  = parseSecret <$> secret+  , configJwtAudience           = Nothing+  , configJwtRoleClaimKey       = [JSPKey "role"]+  , configJwtSecret             = secret+  , configJwtSecretIsBase64     = False+  , configLogLevel              = LogCrit+  , configOpenApiMode           = OAFollowPriv+  , configOpenApiServerProxyUri = Nothing+  , configRawMediaTypes         = []+  , configServerHost            = "localhost"+  , configServerPort            = 3000+  , configServerUnixSocket      = Nothing+  , configServerUnixSocketMode  = 432+  , configDbTxAllowOverride     = True+  , configDbTxRollbackAll       = True+  }  testCfg :: Text -> AppConfig-testCfg testDbConn = _baseCfg { configDatabase = testDbConn }+testCfg testDbConn = _baseCfg { configDbUri = testDbConn } +testCfgDisallowRollback :: Text -> AppConfig+testCfgDisallowRollback testDbConn = (testCfg testDbConn) { configDbTxAllowOverride = False, configDbTxRollbackAll = False }++testCfgForceRollback :: Text -> AppConfig+testCfgForceRollback testDbConn = (testCfg testDbConn) { configDbTxAllowOverride = False, configDbTxRollbackAll = True }+ testCfgNoJWT :: Text -> AppConfig-testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing }+testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing, configJWKS = Nothing }  testUnicodeCfg :: Text -> AppConfig-testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["تست"] }+testUnicodeCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["تست"] }  testMaxRowsCfg :: Text -> AppConfig-testMaxRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }+testMaxRowsCfg testDbConn = (testCfg testDbConn) { configDbMaxRows = Just 2 } +testDisabledOpenApiCfg :: Text -> AppConfig+testDisabledOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OADisabled }++testIgnorePrivOpenApiCfg :: Text -> AppConfig+testIgnorePrivOpenApiCfg testDbConn = (testCfg testDbConn) { configOpenApiMode = OAIgnorePriv, configDbSchemas = fromList ["test", "v1"] }+ testProxyCfg :: Text -> AppConfig-testProxyCfg testDbConn = (testCfg testDbConn) { configOpenAPIProxyUri = Just "https://postgrest.com/openapi.json" }+testProxyCfg testDbConn = (testCfg testDbConn) { configOpenApiServerProxyUri = Just "https://postgrest.com/openapi.json" }  testCfgBinaryJWT :: Text -> AppConfig-testCfgBinaryJWT testDbConn = (testCfg testDbConn) {-    configJwtSecret = Just . B64.decodeLenient $-      "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU="+testCfgBinaryJWT testDbConn =+  let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in+  (testCfg testDbConn) {+    configJwtSecret = secret+  , configJWKS = parseSecret <$> secret   }  testCfgAudienceJWT :: Text -> AppConfig-testCfgAudienceJWT testDbConn = (testCfg testDbConn) {-    configJwtSecret = Just . B64.decodeLenient $-      "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=",-    configJwtAudience = Just "youraudience"+testCfgAudienceJWT testDbConn =+  let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in+  (testCfg testDbConn) {+    configJwtSecret = secret+  , configJwtAudience = Just "youraudience"+  , configJWKS = parseSecret <$> secret   }  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"}|]+testCfgAsymJWK testDbConn =+  let secret = Just $ encodeUtf8 [str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|]+  in (testCfg testDbConn) {+    configJwtSecret = secret+  , configJWKS = parseSecret <$> secret   }  testCfgAsymJWKSet :: Text -> AppConfig-testCfgAsymJWKSet testDbConn = (testCfg testDbConn) {-    configJwtSecret = Just $ encodeUtf8-      [str|{"keys": [{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}]}|]+testCfgAsymJWKSet testDbConn =+  let secret = Just $ encodeUtf8 [str|{"keys": [{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}]}|]+  in (testCfg testDbConn) {+    configJwtSecret = secret+  , configJWKS = parseSecret <$> secret   }  testNonexistentSchemaCfg :: Text -> AppConfig-testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["nonexistent"] }+testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["nonexistent"] }  testCfgExtraSearchPath :: Text -> AppConfig-testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] }+testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configDbExtraSearchPath = ["public", "extensions"] }  testCfgRootSpec :: Text -> AppConfig-testCfgRootSpec testDbConn = (testCfg testDbConn) { configRootSpec = Just "root"}+testCfgRootSpec testDbConn = (testCfg testDbConn) { configDbRootSpec = Just $ QualifiedIdentifier mempty "root"}  testCfgHtmlRawOutput :: Text -> AppConfig testCfgHtmlRawOutput testDbConn = (testCfg testDbConn) { configRawMediaTypes = ["text/html"] }  testCfgResponseHeaders :: Text -> AppConfig-testCfgResponseHeaders testDbConn = (testCfg testDbConn) { configReqCheck = Just "custom_headers" }+testCfgResponseHeaders testDbConn = (testCfg testDbConn) { configDbPreRequest = Just $ QualifiedIdentifier mempty "custom_headers" }  testMultipleSchemaCfg :: Text -> AppConfig-testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configSchemas = fromList ["v1", "v2"] }+testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["v1", "v2"] }  resetDb :: Text -> IO () resetDb dbConn = loadFixture dbConn "data"@@ -178,15 +218,18 @@ noProfileHeader :: [Header] -> Bool noProfileHeader headers = isNothing $ find ((== "Content-Profile") . fst) headers +authHeader :: BS.ByteString -> BS.ByteString -> Header+authHeader typ creds =+  (hAuthorization, typ <> " " <> creds)+ authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header authHeaderBasic u p =-  (hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p))+  authHeader "Basic" $ toS . B64.encode . toS $ u <> ":" <> p  authHeaderJWT :: BS.ByteString -> Header-authHeaderJWT token =-  (hAuthorization, "Bearer " <> token)+authHeaderJWT = authHeader "Bearer" --- | Tests whether the text can be parsed as a json object comtaining+-- | Tests whether the text can be parsed as a json object containing -- the key "message", and optional keys "details", "hint", "code", -- and no extraneous keys isErrorFormat :: BL.ByteString -> Bool