postgrest 9.0.0 → 9.0.1
raw patch · 98 files changed
+8951/−8843 lines, 98 filesdep −ansi-wl-pprintdep −fast-loggerdep −wai-loggerdep ~protoludedep ~wai-extradep ~warp
Dependencies removed: ansi-wl-pprint, fast-logger, wai-logger, wai-middleware-static
Dependency ranges changed: protolude, wai-extra, warp
Files
- CHANGELOG.md +17/−3
- postgrest.cabal +68/−77
- src/PostgREST/App.hs +6/−3
- src/PostgREST/CLI.hs +0/−2
- src/PostgREST/Config.hs +4/−4
- src/PostgREST/Config/Database.hs +24/−13
- src/PostgREST/Cors.hs +42/−0
- src/PostgREST/DbStructure.hs +25/−19
- src/PostgREST/Error.hs +4/−5
- src/PostgREST/Logger.hs +28/−0
- src/PostgREST/Middleware.hs +22/−103
- src/PostgREST/OpenAPI.hs +18/−16
- src/PostgREST/Query/SqlFragment.hs +3/−3
- src/PostgREST/Request/ApiRequest.hs +3/−3
- src/PostgREST/Request/DbRequestBuilder.hs +1/−1
- src/PostgREST/Request/Parsers.hs +10/−4
- src/PostgREST/Version.hs +4/−2
- src/PostgREST/Workers.hs +1/−2
- test/Feature/AndOrParamsSpec.hs +0/−276
- test/Feature/AsymmetricJwtSpec.hs +0/−21
- test/Feature/AudienceJwtSecretSpec.hs +0/−47
- test/Feature/AuthSpec.hs +0/−179
- test/Feature/BinaryJwtSecretSpec.hs +0/−21
- test/Feature/ConcurrentSpec.hs +0/−52
- test/Feature/CorsSpec.hs +0/−74
- test/Feature/DeleteSpec.hs +0/−92
- test/Feature/DisabledOpenApiSpec.hs +0/−20
- test/Feature/EmbedDisambiguationSpec.hs +0/−440
- test/Feature/EmbedInnerJoinSpec.hs +0/−356
- test/Feature/ExtraSearchPathSpec.hs +0/−39
- test/Feature/HtmlRawOutputSpec.hs +0/−30
- test/Feature/IgnorePrivOpenApiSpec.hs +0/−76
- test/Feature/InsertSpec.hs +0/−558
- test/Feature/JsonOperatorSpec.hs +0/−253
- test/Feature/LegacyGucsSpec.hs +0/−68
- test/Feature/MultipleSchemaSpec.hs +0/−331
- test/Feature/NoJwtSpec.hs +0/−31
- test/Feature/NonexistentSchemaSpec.hs +0/−17
- test/Feature/OpenApiSpec.hs +0/−589
- test/Feature/OptionsSpec.hs +0/−87
- test/Feature/ProxySpec.hs +0/−13
- test/Feature/QueryLimitedSpec.hs +0/−69
- test/Feature/QuerySpec.hs +0/−1108
- test/Feature/RangeSpec.hs +0/−352
- test/Feature/RawOutputTypesSpec.hs +0/−33
- test/Feature/RollbackSpec.hs +0/−218
- test/Feature/RootSpec.hs +0/−34
- test/Feature/RpcPreRequestGucsSpec.hs +0/−84
- test/Feature/RpcSpec.hs +0/−1256
- test/Feature/SingularSpec.hs +0/−324
- test/Feature/UnicodeSpec.hs +0/−34
- test/Feature/UpdateSpec.hs +0/−349
- test/Feature/UpsertSpec.hs +0/−427
- test/Main.hs +0/−236
- test/QueryCost.hs +0/−89
- test/SpecHelper.hs +0/−246
- test/TestTypes.hs +0/−37
- test/doc/Main.hs +17/−0
- test/doctests/Main.hs +0/−17
- test/spec/Feature/AndOrParamsSpec.hs +276/−0
- test/spec/Feature/AsymmetricJwtSpec.hs +21/−0
- test/spec/Feature/AudienceJwtSecretSpec.hs +47/−0
- test/spec/Feature/AuthSpec.hs +179/−0
- test/spec/Feature/BinaryJwtSecretSpec.hs +21/−0
- test/spec/Feature/ConcurrentSpec.hs +52/−0
- test/spec/Feature/CorsSpec.hs +56/−0
- test/spec/Feature/DeleteSpec.hs +92/−0
- test/spec/Feature/DisabledOpenApiSpec.hs +20/−0
- test/spec/Feature/EmbedDisambiguationSpec.hs +464/−0
- test/spec/Feature/EmbedInnerJoinSpec.hs +356/−0
- test/spec/Feature/ExtraSearchPathSpec.hs +39/−0
- test/spec/Feature/HtmlRawOutputSpec.hs +30/−0
- test/spec/Feature/IgnorePrivOpenApiSpec.hs +76/−0
- test/spec/Feature/InsertSpec.hs +558/−0
- test/spec/Feature/JsonOperatorSpec.hs +253/−0
- test/spec/Feature/LegacyGucsSpec.hs +68/−0
- test/spec/Feature/MultipleSchemaSpec.hs +331/−0
- test/spec/Feature/NoJwtSpec.hs +31/−0
- test/spec/Feature/NonexistentSchemaSpec.hs +17/−0
- test/spec/Feature/OpenApiSpec.hs +615/−0
- test/spec/Feature/OptionsSpec.hs +87/−0
- test/spec/Feature/ProxySpec.hs +13/−0
- test/spec/Feature/QueryLimitedSpec.hs +69/−0
- test/spec/Feature/QuerySpec.hs +1125/−0
- test/spec/Feature/RangeSpec.hs +352/−0
- test/spec/Feature/RawOutputTypesSpec.hs +33/−0
- test/spec/Feature/RollbackSpec.hs +260/−0
- test/spec/Feature/RootSpec.hs +34/−0
- test/spec/Feature/RpcPreRequestGucsSpec.hs +84/−0
- test/spec/Feature/RpcSpec.hs +1266/−0
- test/spec/Feature/SingularSpec.hs +324/−0
- test/spec/Feature/UnicodeSpec.hs +34/−0
- test/spec/Feature/UpdateSpec.hs +349/−0
- test/spec/Feature/UpsertSpec.hs +427/−0
- test/spec/Main.hs +234/−0
- test/spec/QueryCost.hs +89/−0
- test/spec/SpecHelper.hs +235/−0
- test/spec/TestTypes.hs +37/−0
CHANGELOG.md view
@@ -3,11 +3,25 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). -## Unreleased--### Added+## [9.0.1] - 2022-06-03 ### Fixed++ - #2165, Fix json/jsonb columns should not have type in OpenAPI spec - @clrnd+ - #2020, Execute deferred constraint triggers when using `Prefer: tx=rollback` - @wolfgangwalther+ - #2077, Fix `is` not working with upper or mixed case values like `NULL, TrUe, FaLsE` - @steve-chavez+ - #2024, Fix schema cache loading when views with XMLTABLE and DEFAULT are present - @wolfgangwalther+ - #1724, Fix wrong CORS header Authentication -> Authorization - @wolfgangwalther+ - #2120, Fix reading database configuration properly when `=` is present in value - @wolfgangwalther+ - #2135, Remove trigger functions from schema cache and OpenAPI output, because they can't be called directly anyway. - @wolfgangwalther+ - #2101, Remove aggregates, procedures and window functions from the schema cache and OpenAPI output. - @wolfgangwalther+ - #2153, Fix --dump-schema running with a wrong PG version. - @wolfgangwalther+ - #2042, Keep working when EMFILE(Too many open files) is reached. - @steve-chavez+ - #2147, Ignore `Content-Type` headers for `GET` requests when calling RPCs. - @laurenceisla+ + Previously, `GET` without parameters, but with `Content-Type: text/plain` or `Content-Type: application/octet-stream` would fail with `404 Not Found`, even if a function without arguments was available.+ - #2239, Fix misleading disambiguation error where the content of the `relationship` key looks like valid syntax - @laurenceisla+ - #2294, Disable parallel GC for better performance on higher core CPUs - @steve-chavez+ - #1076, Fix using CPU while idle - @steve-chavez ## [9.0.0] - 2021-11-25
postgrest.cabal view
@@ -1,8 +1,8 @@ name: postgrest-version: 9.0.0+version: 9.0.1 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+ for tables, views, and functions, supporting all HTTP verbs that security permits. license: MIT license-file: LICENSE@@ -44,6 +44,7 @@ PostgREST.Config.PgVersion PostgREST.Config.Proxy PostgREST.ContentType+ PostgREST.Cors PostgREST.DbStructure PostgREST.DbStructure.Identifiers PostgREST.DbStructure.Proc@@ -51,6 +52,7 @@ PostgREST.DbStructure.Table PostgREST.Error PostgREST.GucHeader+ PostgREST.Logger PostgREST.Middleware PostgREST.OpenAPI PostgREST.Query.QueryBuilder@@ -69,7 +71,6 @@ , HTTP >= 4000.3.7 && < 4000.4 , Ranged-sets >= 0.3 && < 0.5 , aeson >= 1.4.7 && < 1.6- , ansi-wl-pprint >= 0.6.7 && < 0.7 , auto-update >= 0.1.4 && < 0.2 , base64-bytestring >= 1 && < 1.3 , bytestring >= 0.10.8 && < 0.11@@ -77,11 +78,9 @@ , cassava >= 0.4.5 && < 0.6 , configurator-pg >= 0.2 && < 0.3 , containers >= 0.5.7 && < 0.7- , contravariant >= 1.4 && < 1.6 , 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@@ -99,7 +98,7 @@ , network-uri >= 2.6.1 && < 2.8 , optparse-applicative >= 0.13 && < 0.17 , parsec >= 3.1.11 && < 3.2- , protolude >= 0.3 && < 0.4+ , protolude >= 0.3.1 && < 0.4 , regex-tdfa >= 1.2.2 && < 1.4 , retry >= 0.7.4 && < 0.10 , scientific >= 0.3.4 && < 0.4@@ -110,10 +109,8 @@ , vector >= 0.11 && < 0.13 , wai >= 3.2.1 && < 3.3 , wai-cors >= 0.2.5 && < 0.3- , 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+ , wai-extra >= 3.1.8 && < 3.2+ , warp >= 3.3.19 && < 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@@ -123,9 +120,9 @@ -fno-spec-constr -optP-Wno-nonportable-include-path if flag(dev)- ghc-options: -O0+ ghc-options: -O0 -fwrite-ide-info if flag(hpc)- ghc-options: -hpcdir .hpc+ ghc-options: --enable-coverage -hpcdir .hpc else ghc-options: -O2 @@ -146,15 +143,17 @@ build-depends: base >= 4.9 && < 4.16 , containers >= 0.5.7 && < 0.7 , postgrest- , protolude >= 0.3 && < 0.4- ghc-options: -threaded -rtsopts "-with-rtsopts=-N -I2"+ , protolude >= 0.3.1 && < 0.4+ ghc-options: -threaded -rtsopts "-with-rtsopts=-N -I0 -qg" -O2 -Wall -fwarn-identities -fno-spec-constr -optP-Wno-nonportable-include-path if flag(dev)- ghc-options: -O0+ ghc-options: -O0 -fwrite-ide-info+ -- https://github.com/PostgREST/postgrest/issues/387+ -with-rtsopts=-K1K if flag(hpc)- ghc-options: -hpcdir .hpc+ ghc-options: --enable-coverage -hpcdir .hpc else ghc-options: -O2 @@ -164,7 +163,7 @@ default-extensions: OverloadedStrings QuasiQuotes NoImplicitPrelude- hs-source-dirs: test+ hs-source-dirs: test/spec main-is: Main.hs other-modules: Feature.AndOrParamsSpec Feature.AsymmetricJwtSpec@@ -211,10 +210,7 @@ , 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-pool >= 0.5 && < 0.6 , hasql-transaction >= 1.0.1 && < 1.1 , heredoc >= 0.2 && < 0.3@@ -227,72 +223,67 @@ , monad-control >= 1.0.1 && < 1.1 , postgrest , process >= 1.4.2 && < 1.7- , protolude >= 0.3 && < 0.4+ , protolude >= 0.3.1 && < 0.4 , regex-tdfa >= 1.2.2 && < 1.4 , text >= 1.2.2 && < 1.3- , time >= 1.6 && < 1.11 , transformers-base >= 0.4.4 && < 0.5 , wai >= 3.2.1 && < 3.3 , wai-extra >= 3.0.19 && < 3.2 ghc-options: -O0 -Wall -fwarn-identities -fno-spec-constr -optP-Wno-nonportable-include-path -fno-warn-missing-signatures+ -fwrite-ide-info+ -- https://github.com/PostgREST/postgrest/issues/387+ -with-rtsopts=-K33K test-suite 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.16- , 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.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 >= 1.0.1 && < 1.1- , heredoc >= 0.2 && < 0.3- , hspec >= 2.3 && < 2.9- , hspec-wai >= 0.10 && < 0.12- , hspec-wai-json >= 0.10 && < 0.12- , http-types >= 0.12.3 && < 0.13- , lens >= 4.14 && < 5.1- , lens-aeson >= 1.0.1 && < 1.2- , monad-control >= 1.0.1 && < 1.1- , postgrest- , process >= 1.4.2 && < 1.7- , protolude >= 0.3 && < 0.4- , regex-tdfa >= 1.2.2 && < 1.4- , text >= 1.2.2 && < 1.3- , time >= 1.6 && < 1.11- , transformers-base >= 0.4.4 && < 0.5- , wai >= 3.2.1 && < 3.3- , wai-extra >= 3.0.19 && < 3.2- ghc-options: -O0 -Wall -fwarn-identities- -fno-spec-constr -optP-Wno-nonportable-include-path+ type: exitcode-stdio-1.0+ default-language: Haskell2010+ default-extensions: OverloadedStrings+ QuasiQuotes+ NoImplicitPrelude+ hs-source-dirs: test/spec+ main-is: QueryCost.hs+ other-modules: SpecHelper+ build-depends: base >= 4.9 && < 4.16+ , aeson >= 1.4.7 && < 1.6+ , base64-bytestring >= 1 && < 1.3+ , bytestring >= 0.10.8 && < 0.11+ , case-insensitive >= 1.2 && < 1.3+ , 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 >= 1.0.1 && < 1.1+ , heredoc >= 0.2 && < 0.3+ , hspec >= 2.3 && < 2.9+ , hspec-wai >= 0.10 && < 0.12+ , http-types >= 0.12.3 && < 0.13+ , lens >= 4.14 && < 5.1+ , lens-aeson >= 1.0.1 && < 1.2+ , postgrest+ , process >= 1.4.2 && < 1.7+ , protolude >= 0.3.1 && < 0.4+ , regex-tdfa >= 1.2.2 && < 1.4+ , wai-extra >= 3.0.19 && < 3.2+ ghc-options: -O0 -Wall -fwarn-identities+ -fno-spec-constr -optP-Wno-nonportable-include-path+ -fwrite-ide-info+ -- https://github.com/PostgREST/postgrest/issues/387+ -with-rtsopts=-K1K test-suite doctests- type: exitcode-stdio-1.0- default-language: Haskell2010- default-extensions: OverloadedStrings- NoImplicitPrelude- hs-source-dirs: test/doctests- main-is: Main.hs- build-depends: base >= 4.9 && < 4.16- , doctest >= 0.8- , postgrest- , pretty-simple- , protolude >= 0.3 && < 0.4- ghc-options: -threaded -O0 -Wall -fwarn-identities- -fno-spec-constr -optP-Wno-nonportable-include-path+ type: exitcode-stdio-1.0+ default-language: Haskell2010+ default-extensions: OverloadedStrings+ NoImplicitPrelude+ hs-source-dirs: test/doc+ main-is: Main.hs+ build-depends: base >= 4.9 && < 4.16+ , doctest >= 0.8+ , postgrest+ , pretty-simple+ , protolude >= 0.3.1 && < 0.4+ ghc-options: -threaded -O0 -Wall -fwarn-identities+ -fno-spec-constr -optP-Wno-nonportable-include-path
src/PostgREST/App.hs view
@@ -42,8 +42,10 @@ import qualified PostgREST.AppState as AppState import qualified PostgREST.Auth as Auth+import qualified PostgREST.Cors as Cors import qualified PostgREST.DbStructure as DbStructure import qualified PostgREST.Error as Error+import qualified PostgREST.Logger as Logger import qualified PostgREST.Middleware as Middleware import qualified PostgREST.OpenAPI as OpenAPI import qualified PostgREST.Query.QueryBuilder as QueryBuilder@@ -137,8 +139,9 @@ -- | PostgREST application postgrest :: LogLevel -> AppState.AppState -> IO () -> Wai.Application-postgrest logLev appState connWorker =- Middleware.pgrstMiddleware logLev $+postgrest logLevel appState connWorker =+ Logger.middleware logLevel .+ Cors.middleware $ \req respond -> do time <- AppState.getTime appState conf <- AppState.getConfig appState@@ -466,7 +469,7 @@ OAFollowPriv -> OpenAPI.encode conf dbStructure <$> SQL.statement tSchema (DbStructure.accessibleTables ctxPgVersion configDbPreparedStatements)- <*> SQL.statement tSchema (DbStructure.accessibleProcs configDbPreparedStatements)+ <*> SQL.statement tSchema (DbStructure.accessibleProcs ctxPgVersion configDbPreparedStatements) <*> SQL.statement tSchema (DbStructure.schemaDescription configDbPreparedStatements) OAIgnorePriv -> OpenAPI.encode conf dbStructure
src/PostgREST/CLI.hs view
@@ -53,7 +53,6 @@ dumpSchema :: AppState -> IO LBS.ByteString dumpSchema appState = do AppConfig{..} <- AppState.getConfig appState- actualPgVersion <- AppState.getPgVersion appState result <- let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in SQL.use (AppState.getPool appState) $@@ -61,7 +60,6 @@ queryDbStructure (toList configDbSchemas) configDbExtraSearchPath- actualPgVersion configDbPreparedStatements SQL.release $ AppState.getPool appState case result of
src/PostgREST/Config.hs view
@@ -131,7 +131,7 @@ ,("db-prepared-statements", T.toLower . show . configDbPreparedStatements) ,("db-root-spec", q . maybe mempty dumpQi . configDbRootSpec) ,("db-schemas", q . T.intercalate "," . toList . configDbSchemas)- ,("db-config", q . T.toLower . show . configDbConfig)+ ,("db-config", T.toLower . show . configDbConfig) ,("db-tx-end", q . showTxEnd) ,("db-uri", q . configDbUri) ,("db-use-legacy-gucs", T.toLower . show . configDbUseLegacyGucs)@@ -369,17 +369,17 @@ coerceInt :: (Read i, Integral i) => C.Value -> Maybe i coerceInt (C.Number x) = rightToMaybe $ floatingOrInteger x- coerceInt (C.String x) = readMaybe $ toS x+ coerceInt (C.String x) = readMaybe x coerceInt _ = Nothing coerceBool :: C.Value -> Maybe Bool coerceBool (C.Bool b) = Just 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+ case readMaybe $ T.toTitle $ T.filter isAlpha $ toS s of Just b -> Just b -- numeric instead?- Nothing -> (> 0) <$> (readMaybe $ toS s :: Maybe Integer)+ Nothing -> (> 0) <$> (readMaybe s :: Maybe Integer) coerceBool _ = Nothing splitOnCommas :: C.Value -> [Text]
src/PostgREST/Config/Database.hs view
@@ -1,7 +1,8 @@ {-# LANGUAGE QuasiQuotes #-} module PostgREST.Config.Database- ( queryDbSettings+ ( pgVersionStatement+ , queryDbSettings , queryPgVersion ) where @@ -20,7 +21,10 @@ import Protolude queryPgVersion :: Session PgVersion-queryPgVersion = statement mempty $ SQL.Statement sql HE.noParams versionRow False+queryPgVersion = statement mempty pgVersionStatement++pgVersionStatement :: SQL.Statement () PgVersion+pgVersionStatement = SQL.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@@ -36,19 +40,26 @@ dbSettingsStatement = SQL.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))+ WITH+ role_setting (database, setting) AS (+ SELECT setdatabase,+ unnest(setconfig)+ 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.%'+ kv_settings (database, k, v) AS (+ SELECT database,+ substr(setting, 1, strpos(setting, '=') - 1),+ substr(setting, strpos(setting, '=') + 1)+ 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;+ SELECT DISTINCT ON (key)+ replace(k, 'pgrst.', '') AS key,+ v AS value+ FROM kv_settings+ ORDER BY key, database DESC; |] decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text
+ src/PostgREST/Cors.hs view
@@ -0,0 +1,42 @@+{-|+Module : PostgREST.Cors+Description : Wai Middleware to set cors policy.+-}+module PostgREST.Cors (middleware) where++import qualified Data.ByteString.Char8 as BS+import qualified Data.CaseInsensitive as CI+import qualified Network.Wai as Wai+import qualified Network.Wai.Middleware.Cors as Wai++import Data.List (lookup)++import Protolude++middleware :: Wai.Middleware+middleware = Wai.cors corsPolicy++-- | 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 Wai.CorsResourcePolicy+ { Wai.corsOrigins = Just ([origin], True)+ , Wai.corsMethods = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]+ , Wai.corsRequestHeaders = "Authorization" : accHeaders+ , Wai.corsExposedHeaders = Just+ [ "Content-Encoding", "Content-Location", "Content-Range", "Content-Type"+ , "Date", "Location", "Server", "Transfer-Encoding", "Range-Unit"]+ , Wai.corsMaxAge = Just $ 60*60*24+ , Wai.corsVaryOrigin = False+ , Wai.corsRequireOrigin = False+ , Wai.corsIgnoreFailures = True+ }+ Nothing -> Nothing+ where+ headers = Wai.requestHeaders req+ accHeaders = case lookup "access-control-request-headers" headers of+ Just hdrs -> map (CI.mk . BS.strip) $ BS.split ',' hdrs+ -- Impossible case, Middleware.Cors will not evaluate this when+ -- the Access-Control-Request-Headers header is not set.+ Nothing -> []
src/PostgREST/DbStructure.hs view
@@ -41,7 +41,9 @@ import Data.Text (split) import Text.InterpolatedString.Perl6 (q) -import PostgREST.Config.PgVersion (PgVersion, pgVersion100)+import PostgREST.Config.Database (pgVersionStatement)+import PostgREST.Config.PgVersion (PgVersion, pgVersion100,+ pgVersion110) import PostgREST.DbStructure.Identifiers (QualifiedIdentifier (..), Schema, TableName) import PostgREST.DbStructure.Proc (PgType (..),@@ -83,15 +85,16 @@ -- | A SQL query that can be executed independently type SqlQuery = ByteString -queryDbStructure :: [Schema] -> [Schema] -> PgVersion -> Bool -> SQL.Transaction DbStructure-queryDbStructure schemas extraSearchPath pgVer prepared = do+queryDbStructure :: [Schema] -> [Schema] -> Bool -> SQL.Transaction DbStructure+queryDbStructure schemas extraSearchPath prepared = do SQL.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+ pgVer <- SQL.statement mempty pgVersionStatement tabs <- SQL.statement mempty $ allTables pgVer prepared cols <- SQL.statement schemas $ allColumns tabs prepared srcCols <- SQL.statement (schemas, extraSearchPath) $ pfkSourceColumns cols prepared m2oRels <- SQL.statement mempty $ allM2ORels tabs cols prepared keys <- SQL.statement mempty $ allPrimaryKeys tabs prepared- procs <- SQL.statement schemas $ allProcs prepared+ procs <- SQL.statement schemas $ allProcs pgVer prepared let rels = addO2MRels . addM2MRels $ addViewM2ORels srcCols m2oRels keys' = addViewPrimaryKeys srcCols keys@@ -224,18 +227,18 @@ | v == 's' = Stable | otherwise = Volatile -- only 'v' can happen here -allProcs :: Bool -> SQL.Statement [Schema] ProcsMap-allProcs = SQL.Statement sql (arrayParam HE.text) decodeProcs+allProcs :: PgVersion -> Bool -> SQL.Statement [Schema] ProcsMap+allProcs pgVer = SQL.Statement sql (arrayParam HE.text) decodeProcs where- sql = procsSqlQuery <> " WHERE pn.nspname = ANY($1)"+ sql = procsSqlQuery pgVer <> " AND pn.nspname = ANY($1)" -accessibleProcs :: Bool -> SQL.Statement Schema ProcsMap-accessibleProcs = SQL.Statement sql (param HE.text) decodeProcs+accessibleProcs :: PgVersion -> Bool -> SQL.Statement Schema ProcsMap+accessibleProcs pgVer = SQL.Statement sql (param HE.text) decodeProcs where- sql = procsSqlQuery <> " WHERE pn.nspname = $1 AND has_function_privilege(p.oid, 'execute')"+ sql = procsSqlQuery pgVer <> " AND pn.nspname = $1 AND has_function_privilege(p.oid, 'execute')" -procsSqlQuery :: SqlQuery-procsSqlQuery = [q|+procsSqlQuery :: PgVersion -> SqlQuery+procsSqlQuery pgVer = [q| -- Recursively get the base types of domains WITH base_types AS (@@ -297,7 +300,8 @@ JOIN pg_namespace tn ON tn.oid = t.typnamespace LEFT JOIN pg_class comp ON comp.oid = t.typrelid LEFT JOIN pg_catalog.pg_description as d ON d.objoid = p.oid-|]+ WHERE t.oid <> 'pg_catalog.trigger'::regtype+|] <> (if pgVer >= pgVersion110 then "AND prokind = 'f'" else "AND NOT (proisagg OR proiswindow)") schemaDescription :: Bool -> SQL.Statement Schema (Maybe Text) schemaDescription =@@ -792,7 +796,6 @@ replace( replace( replace(- replace( regexp_replace( replace( replace(@@ -804,6 +807,7 @@ 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.@@ -814,9 +818,15 @@ -- ----------------------------------------------- -- pattern | replacement | flags -- -----------------------------------------------+ -- `<>` 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.+ -- We'll need to put it first, to make the node protection below work for node lists that start with+ -- null: `(<> ...`, too. This is the case for coldefexprs, when the first column does not have a default value.+ '<>' , '()' -- `,` 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. ), E'\\{' , ''@@ -851,10 +861,6 @@ ), ')' , ']' -- 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 ),
src/PostgREST/Error.hs view
@@ -126,23 +126,22 @@ compressedRel :: Relationship -> JSON.Value compressedRel Relationship{..} = let- fmtTbl Table{..} = tableSchema <> "." <> tableName- fmtEls els = "[" <> T.intercalate ", " els <> "]"+ fmtEls els = "(" <> T.intercalate ", " els <> ")" in JSON.object $ ("embedding" .= (tableName relTable <> " with " <> tableName relForeignTable :: Text)) : case relCardinality of M2M Junction{..} -> [ "cardinality" .= ("many-to-many" :: Text)- , "relationship" .= (fmtTbl junTable <> fmtEls [junConstraint1] <> fmtEls [junConstraint2])+ , "relationship" .= (tableName junTable <> " using " <> junConstraint1 <> fmtEls (colName <$> junColumns1) <> " and " <> junConstraint2 <> fmtEls (colName <$> junColumns2)) ] M2O cons -> [ "cardinality" .= ("many-to-one" :: Text)- , "relationship" .= (cons <> fmtEls (colName <$> relColumns) <> fmtEls (colName <$> relForeignColumns))+ , "relationship" .= (cons <> " using " <> tableName relTable <> fmtEls (colName <$> relColumns) <> " and " <> tableName relForeignTable <> fmtEls (colName <$> relForeignColumns)) ] O2M cons -> [ "cardinality" .= ("one-to-many" :: Text)- , "relationship" .= (cons <> fmtEls (colName <$> relColumns) <> fmtEls (colName <$> relForeignColumns))+ , "relationship" .= (cons <> " using " <> tableName relTable <> fmtEls (colName <$> relColumns) <> " and " <> tableName relForeignTable <> fmtEls (colName <$> relForeignColumns)) ] relHint :: [Relationship] -> Text
+ src/PostgREST/Logger.hs view
@@ -0,0 +1,28 @@+{-|+Module : PostgREST.Logger+Description : Wai Middleware to log requests to stdout.+-}+module PostgREST.Logger (middleware) where++import qualified Network.Wai as Wai+import qualified Network.Wai.Middleware.RequestLogger as Wai++import Network.HTTP.Types.Status (status400, status500)+import System.IO.Unsafe (unsafePerformIO)++import PostgREST.Config (LogLevel (..))++import Protolude++middleware :: LogLevel -> Wai.Middleware+middleware logLevel = case logLevel of+ LogInfo -> requestLogger (const True)+ LogWarn -> requestLogger (>= status400)+ LogError -> requestLogger (>= status500)+ LogCrit -> requestLogger (const False)+ where+ requestLogger filterStatus = unsafePerformIO $ Wai.mkRequestLogger Wai.defaultRequestLoggerSettings+ { Wai.outputFormat = Wai.ApacheWithSettings $+ Wai.defaultApacheSettings+ & Wai.setApacheRequestFilter (\_ res -> filterStatus $ Wai.responseStatus res)+ }
src/PostgREST/Middleware.hs view
@@ -2,47 +2,29 @@ Module : PostgREST.Middleware Description : Sets CORS policy. Also the PostgreSQL GUCs, role, search_path and pre-request function. -}+{-# LANGUAGE BlockArguments #-} {-# LANGUAGE RecordWildCards #-} module PostgREST.Middleware ( runPgLocals- , pgrstFormat- , pgrstMiddleware- , defaultCorsPolicy- , corsPolicy , optionalRollback ) where -import qualified Data.Aeson as JSON-import qualified Data.ByteString.Char8 as BS-import qualified Data.ByteString.Lazy.Char8 as LBS-import qualified Data.CaseInsensitive as CI-import qualified Data.HashMap.Strict as M-import qualified Data.Text as T-import qualified Data.Text.Encoding as T-import qualified Hasql.Decoders as HD-import qualified Hasql.DynamicStatements.Snippet as SQL hiding- (sql)-import qualified Hasql.DynamicStatements.Statement as SQL-import qualified Hasql.Transaction as SQL-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.ByteString.Lazy.Char8 as LBS+import qualified Data.HashMap.Strict as M+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import qualified Hasql.Decoders as HD+import qualified Hasql.DynamicStatements.Snippet as SQL hiding (sql)+import qualified Hasql.DynamicStatements.Statement as SQL+import qualified Hasql.Transaction as SQL+import qualified Network.Wai as Wai import Control.Arrow ((***)) -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 Data.Scientific (FPFormat (..), formatScientific, isInteger) -import PostgREST.Config (AppConfig (..), LogLevel (..))+import PostgREST.Config (AppConfig (..)) import PostgREST.Config.PgVersion (PgVersion (..), pgVersion140) import PostgREST.Error (Error, errorResponseFor) import PostgREST.GucHeader (addHeadersIfNotIncluded)@@ -90,76 +72,12 @@ _ -> mempty usesLegacyGucs = configDbUseLegacyGucs conf && actualPgVersion < pgVersion140 --- | 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 . BS.strip) $ 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 = T.decodeUtf8 . LBS.toStrict $ JSON.encode v+ 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 = T.decodeUtf8 . LBS.toStrict $ JSON.encode v -- | Set a transaction to eventually roll back if requested and set respective -- headers on the response.@@ -170,8 +88,9 @@ -> ExceptT Error SQL.Transaction Wai.Response optionalRollback AppConfig{..} ApiRequest{..} transaction = do resp <- catchError transaction $ return . errorResponseFor- when (shouldRollback || (configDbTxRollbackAll && not shouldCommit))- (lift SQL.condemn)+ when (shouldRollback || (configDbTxRollbackAll && not shouldCommit)) $ lift do+ SQL.sql "SET CONSTRAINTS ALL IMMEDIATE"+ SQL.condemn return $ Wai.mapResponseHeaders preferenceApplied resp where shouldCommit =
src/PostgREST/OpenAPI.hs view
@@ -55,24 +55,26 @@ makeMimeList :: [ContentType] -> MimeList makeMimeList cs = MimeList $ fmap (fromString . BS.unpack . toMime) cs -toSwaggerType :: Text -> SwaggerType t-toSwaggerType "character varying" = SwaggerString-toSwaggerType "character" = SwaggerString-toSwaggerType "text" = SwaggerString-toSwaggerType "boolean" = SwaggerBoolean-toSwaggerType "smallint" = SwaggerInteger-toSwaggerType "integer" = SwaggerInteger-toSwaggerType "bigint" = SwaggerInteger-toSwaggerType "numeric" = SwaggerNumber-toSwaggerType "real" = SwaggerNumber-toSwaggerType "double precision" = SwaggerNumber-toSwaggerType "ARRAY" = SwaggerArray-toSwaggerType _ = SwaggerString+toSwaggerType :: Text -> Maybe (SwaggerType t)+toSwaggerType "character varying" = Just SwaggerString+toSwaggerType "character" = Just SwaggerString+toSwaggerType "text" = Just SwaggerString+toSwaggerType "boolean" = Just SwaggerBoolean+toSwaggerType "smallint" = Just SwaggerInteger+toSwaggerType "integer" = Just SwaggerInteger+toSwaggerType "bigint" = Just SwaggerInteger+toSwaggerType "numeric" = Just SwaggerNumber+toSwaggerType "real" = Just SwaggerNumber+toSwaggerType "double precision" = Just SwaggerNumber+toSwaggerType "ARRAY" = Just SwaggerArray+toSwaggerType "json" = Nothing+toSwaggerType "jsonb" = Nothing+toSwaggerType _ = Just SwaggerString parseDefault :: Text -> Text -> Text parseDefault colType colDefault = case toSwaggerType colType of- SwaggerString -> wrapInQuotations $ case T.stripSuffix ("::" <> colType) colDefault of+ Just SwaggerString -> wrapInQuotations $ case T.stripSuffix ("::" <> colType) colDefault of Just def -> T.dropAround (=='\'') def Nothing -> colDefault _ -> colDefault@@ -124,7 +126,7 @@ & enum_ .~ e & format ?~ colType c & maxLength .~ (fromIntegral <$> colMaxLen c)- & type_ ?~ toSwaggerType (colType c)+ & type_ .~ toSwaggerType (colType c) makeProcSchema :: ProcDescription -> Schema makeProcSchema pd =@@ -138,7 +140,7 @@ makeProcProperty (ProcParam n t _ _) = (n, Inline s) where s = (mempty :: Schema)- & type_ ?~ toSwaggerType t+ & type_ .~ toSwaggerType t & format ?~ t makePreferParam :: [Text] -> Param
src/PostgREST/Query/SqlFragment.hs view
@@ -172,10 +172,10 @@ | 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 :: Bool -> SqlFragment 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"+ | returnsScalar = "coalesce((json_agg(_postgrest_t.pgrst_scalar)->0)::text, 'null')"+ | otherwise = "coalesce((json_agg(_postgrest_t)->0)::text, 'null')" asBinaryF :: FieldName -> SqlFragment asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
src/PostgREST/Request/ApiRequest.hs view
@@ -364,9 +364,9 @@ headerRange = rangeRequested hdrs replaceLast x s = T.intercalate "." $ L.init (T.split (=='.') s) ++ [x] limitParams :: M.HashMap Text NonnegRange- limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe . toS =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]+ limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k] offsetParams :: M.HashMap Text NonnegRange- offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe . toS =<< v)) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]+ offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe =<< v)) | (k,v) <- qParams, isJust v, endingIn ["offset"] k] urlRange = M.unionWith f limitParams offsetParams where@@ -505,7 +505,7 @@ then length params == 1 && (ppType <$> headMay params) `elem` [Just "json", Just "jsonb"] -- If the function has no parameters, the arguments keys must be empty as well else if null params- then null argumentsKeys && contentType `notElem` [CTTextPlain, CTOctetStream]+ then null argumentsKeys && not (isInvPost && contentType `elem` [CTTextPlain, CTOctetStream]) -- A function has optional and required parameters. Optional parameters have a default value and -- don't require arguments for the function to be executed, required parameters must have an argument present. else case L.partition ppReq params of
src/PostgREST/Request/DbRequestBuilder.hs view
@@ -56,7 +56,7 @@ import qualified PostgREST.DbStructure.Relationship as Relationship -import Protolude hiding (from)+import Protolude hiding (from, isInfixOf) -- | Builds the ReadRequest tree on a number of stages. -- | Adds filters, order, limits on its respective nodes.
src/PostgREST/Request/Parsers.hs view
@@ -199,10 +199,10 @@ <|> pFts <?> "operator (eq, gt, ...)" - pTriVal = try (string "null" $> TriNull)- <|> try (string "unknown" $> TriUnknown)- <|> try (string "true" $> TriTrue)- <|> try (string "false" $> TriFalse)+ pTriVal = try (ciString "null" $> TriNull)+ <|> try (ciString "unknown" $> TriUnknown)+ <|> try (ciString "true" $> TriTrue)+ <|> try (ciString "false" $> TriFalse) <?> "null or trilean value (unknown, true, false)" pFts = do@@ -212,6 +212,12 @@ ops = M.filterWithKey (const . flip notElem ("in":"is":ftsOps)) operators ftsOps = M.keys ftsOperators++ -- case insensitive char and string+ ciChar :: Char -> GenParser Char state Char+ ciChar c = char c <|> char (toUpper c)+ ciString :: [Char] -> GenParser Char state [Char]+ ciString = traverse ciChar pSingleVal :: Parser SingleVal pSingleVal = toS <$> many anyChar
src/PostgREST/Version.hs view
@@ -30,10 +30,12 @@ -- | Version number used in docs.+-- Pre-release versions link to the latest docs -- Uses only the two first components of the version. Example: 'v1.1' docsVersion :: Text-docsVersion =- "v" <> (T.intercalate "." . map show . take 2 $ versionBranch version)+docsVersion+ | isPreRelease = "latest"+ | otherwise = "v" <> (T.intercalate "." . map show . take 2 $ versionBranch version) -- | Versions with four components (e.g., '1.1.1.1') are treated as pre-releases.
src/PostgREST/Workers.hs view
@@ -152,11 +152,10 @@ loadSchemaCache :: AppState -> IO SCacheStatus loadSchemaCache appState = do AppConfig{..} <- AppState.getConfig appState- actualPgVersion <- AppState.getPgVersion appState result <- let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in SQL.use (AppState.getPool appState) . transaction SQL.ReadCommitted SQL.Read $- queryDbStructure (toList configDbSchemas) configDbExtraSearchPath actualPgVersion configDbPreparedStatements+ queryDbStructure (toList configDbSchemas) configDbExtraSearchPath configDbPreparedStatements case result of Left e -> do let
− test/Feature/AndOrParamsSpec.hs
@@ -1,276 +0,0 @@-module Feature.AndOrParamsSpec where--import Network.Wai (Application)--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import PostgREST.Config.PgVersion (PgVersion, pgVersion112)--import Protolude hiding (get)-import SpecHelper--spec :: PgVersion -> SpecWith ((), Application)-spec actualPgVersion =- describe "and/or params used for complex boolean logic" $ do- context "used with GET" $ do- context "or param" $ do- it "can do simple logic" $- get "/entities?or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }- it "can negate simple logic" $- get "/entities?not.or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`- [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- it "can be combined with traditional filters" $- get "/entities?or=(id.eq.1,id.eq.2)&name=eq.entity 1&select=id" `shouldRespondWith`- [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }-- context "embedded levels" $ do- it "can do logic on the second level" $- get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities(id)" `shouldRespondWith`- [json|[- {"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": 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" $- get "/entities?or=(and(name.eq.entity 2,id.eq.2),and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }- it "can be negated while nested" $- get "/entities?or=(not.and(name.eq.entity 2,id.eq.2),not.and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- it "can be combined unnested" $- get "/entities?and=(id.eq.1,name.eq.entity 1)&or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`- [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }-- context "operators inside and/or" $ do- it "can handle eq and neq" $- get "/entities?and=(id.eq.1,id.neq.2))&select=id" `shouldRespondWith`- [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }- it "can handle lt and gt" $- get "/entities?or=(id.lt.2,id.gt.3)&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- it "can handle lte and gte" $- get "/entities?or=(id.lte.2,id.gte.3)&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- it "can handle like and ilike" $- get "/entities?or=(name.like.*1,name.ilike.*ENTITY 2)&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }- it "can handle in" $- get "/entities?or=(id.in.(1,2),id.in.(3,4))&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- it "can handle is" $- get "/entities?and=(name.is.null,arr.is.null)&select=id" `shouldRespondWith`- [json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- it "can handle fts" $ do- get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }- get "/tsearch?or=(text_search_vector.plfts(german).Art%20Spass, text_search_vector.plfts(french).amusant%20impossible, text_search_vector.fts(english).impossible)" `shouldRespondWith`- [json|[- {"text_search_vector": "'fun':5 'imposs':9 'kind':3" },- {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },- {"text_search_vector": "'art':4 'spass':5 'unmog':7"}- ]|] { matchHeaders = [matchContentTypeJson] }-- when (actualPgVersion >= pgVersion112) $- it "can handle wfts (websearch_to_tsquery)" $- get "/tsearch?or=(text_search_vector.plfts(german).Art,text_search_vector.plfts(french).amusant,text_search_vector.not.wfts(english).impossible)"- `shouldRespondWith`- [json|[- {"text_search_vector": "'also':2 'fun':3 'possibl':8" },- {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" },- {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },- {"text_search_vector": "'art':4 'spass':5 'unmog':7" }- ]|]- { matchHeaders = [matchContentTypeJson] }-- it "can handle cs and cd" $- get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`- [json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }-- it "can handle range operators" $ do- get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith`- [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=neq.[1,3]&select=id" `shouldRespondWith`- [json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=lt.[1,10]&select=id" `shouldRespondWith`- [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=gt.[8,11]&select=id" `shouldRespondWith`- [json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=lte.[1,3]&select=id" `shouldRespondWith`- [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=gte.[2,3]&select=id" `shouldRespondWith`- [json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=cs.[1,2]&select=id" `shouldRespondWith`- [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=cd.[1,6]&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=ov.[0,4]&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=sl.[9,10]&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=sr.[3,4]&select=id" `shouldRespondWith`- [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=nxr.[4,7]&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=nxl.[4,7]&select=id" `shouldRespondWith`- [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }- get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`- [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }-- it "can handle array operators" $ do- get "/entities?arr=eq.{1,2,3}&select=id" `shouldRespondWith`- [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=neq.{1,2}&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=lt.{2,3}&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=lt.{2,0}&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=gt.{1,1}&select=id" `shouldRespondWith`- [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=gt.{3}&select=id" `shouldRespondWith`- [json|[]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=lte.{2,1}&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=lte.{1,2,3}&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=lte.{1,2}&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=cs.{1,2}&select=id" `shouldRespondWith`- [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=cd.{1,2,6}&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=ov.{3}&select=id" `shouldRespondWith`- [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?arr=ov.{2,3}&select=id" `shouldRespondWith`- [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }-- context "operators with not" $ do- it "eq, cs, like can be negated" $- get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`- [json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }- it "in, is, fts can be negated" $- get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith`- [json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }- it "lt, gte, cd can be negated" $- get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`- [json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }- it "gt, lte, ilike can be negated" $- get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith`- [json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }-- context "and/or params with quotes" $ do- it "eq can have quotes" $- get "/grandchild_entities?or=(name.eq.\"(grandchild,entity,4)\",name.eq.\"(grandchild,entity,5)\")&select=id" `shouldRespondWith`- [json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }- it "like and ilike can have quotes" $- get "/grandchild_entities?or=(name.like.\"*ity,4*\",name.ilike.\"*ITY,5)\")&select=id" `shouldRespondWith`- [json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }- it "in can have quotes" $- get "/grandchild_entities?or=(id.in.(\"1\",\"2\"),id.in.(\"3\",\"4\"))&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }-- it "allows whitespace" $- get "/entities?and=( and ( id.in.( 1, 2, 3 ) , id.eq.3 ) , or ( id.eq.2 , id.eq.3 ) )&select=id" `shouldRespondWith`- [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }-- context "multiple and/or conditions" $ do- it "cannot have zero conditions" $- get "/entities?or=()" `shouldRespondWith`- [json|{- "details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)",- "message": "\"failed to parse logic tree (())\" (line 1, column 4)"- }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }- it "can have a single condition" $ do- get "/entities?or=(id.eq.1)&select=id" `shouldRespondWith`- [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }- get "/entities?and=(id.eq.1)&select=id" `shouldRespondWith`- [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }- it "can have three conditions" $ do- get "/grandchild_entities?or=(id.eq.1, id.eq.2, id.eq.3)&select=id" `shouldRespondWith`- [json|[{"id":1}, {"id":2}, {"id":3}]|] { matchHeaders = [matchContentTypeJson] }- get "/grandchild_entities?and=(id.in.(1,2), id.in.(3,1), id.in.(1,4))&select=id" `shouldRespondWith`- [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }- it "can have four conditions combining and/or" $ do- get "/grandchild_entities?or=( id.eq.1, id.eq.2, and(id.in.(1,3), id.in.(2,3)), id.eq.4 )&select=id" `shouldRespondWith`- [json|[{"id":1}, {"id":2}, {"id":3}, {"id":4}]|] { matchHeaders = [matchContentTypeJson] }- get "/grandchild_entities?and=( id.eq.1, not.or(id.eq.2, id.eq.3), id.in.(1,4), or(id.eq.1, id.eq.4) )&select=id" `shouldRespondWith`- [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }-- 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":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" $- request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"- [("Prefer", "return=representation")]- [json|{ name : "updated grandchild entity"}|] `shouldRespondWith`- [json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]- { matchHeaders = [matchContentTypeJson] }-- 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" : "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-- it "fails when using IN without () and provides meaningful error message" $- get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith`- [json|{- "details": "unexpected \"1\" expecting \"(\"",- "message": "\"failed to parse logic tree ((id.in.1,2,id.eq.3))\" (line 1, column 10)"- }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }-- it "fails on malformed query params and provides meaningful error message" $ do- get "/entities?or=)(" `shouldRespondWith`- [json|{- "details": "unexpected \")\" expecting \"(\"",- "message": "\"failed to parse logic tree ()()\" (line 1, column 3)"- }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }- get "/entities?and=(ord(id.eq.1,id.eq.1),id.eq.2)" `shouldRespondWith`- [json|{- "details": "unexpected \"d\" expecting \"(\"",- "message": "\"failed to parse logic tree ((ord(id.eq.1,id.eq.1),id.eq.2))\" (line 1, column 7)"- }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }- get "/entities?or=(id.eq.1,not.xor(id.eq.2,id.eq.3))" `shouldRespondWith`- [json|{- "details": "unexpected \"x\" expecting logic operator (and, or)",- "message": "\"failed to parse logic tree ((id.eq.1,not.xor(id.eq.2,id.eq.3)))\" (line 1, column 16)"- }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
− test/Feature/AsymmetricJwtSpec.hs
@@ -1,21 +0,0 @@-module Feature.AsymmetricJwtSpec where---- {{{ Imports-import Network.Wai (Application)--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai--import Protolude-import SpecHelper--- }}}--spec :: SpecWith ((), Application)-spec = describe "server started with asymmetric JWK" $-- -- this test will stop working 9999999999s after the UNIX EPOCH- it "succeeds with jwt token signed with an asymmetric key" $ do- let auth = authHeaderJWT "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlIjogInBvc3RncmVzdF90ZXN0X2F1dGhvciJ9Cg.CBOYWDvqgAR0YYnZnyDGTQi6AJLc2Pds6_eV3YuBG6I36mj_h05eLhkEKNEDA5ZteMzCiY83P60rC_xtxVd7B6vo3BeF5uoanPS3rrbuHzKPwzsrgrD_CqvEuJ4n7Q9epkQiLsNkcexneENZDRqFjbwZx3DrXiCWwlK3Ytr5NAIGxmy0od-0xNpb2U1nXQyO_Q3mumWFViRt4tmFn_3goDHNKG3Ha_AzImfUNvHnWL78kAc4rbn15vLtWXD8PwtSnZaB4lY4V6RfsaW937srQsmRetvytM1i_bHBnjkjQLAqGbXPyItjtlXPs0uGNBadE8-wgkLtfmSCC4v2DjUthw"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` 200
− test/Feature/AudienceJwtSecretSpec.hs
@@ -1,47 +0,0 @@-module Feature.AudienceJwtSecretSpec where---- {{{ Imports-import Network.Wai (Application)--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai--import Protolude hiding (get)-import SpecHelper--- }}}--spec :: SpecWith ((), Application)-spec = describe "test handling of aud claims in JWT" $ do-- -- this test will stop working 9999999999s after the UNIX EPOCH- it "succeeds with jwt token containing with an audience claim" $ do- {- This is the decoded contents of authHeaderJWT-- {- "exp": 9999999999,- "role": "postgrest_test_author",- "id": "jdoe",- "aud": "youraudience"- }-- -}- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` 200-- it "succeeds with jwt token that does not contain an audience claim" $ do- {- This is the decoded contents of authHeaderJWT-- {- "exp": 9999999999,- "role": "postgrest_test_author",- "id": "jdoe"- }- -}- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` 200-- it "requests without JWT token should work" $- get "/has_count_column" `shouldRespondWith` 200
− test/Feature/AuthSpec.hs
@@ -1,179 +0,0 @@-module Feature.AuthSpec where--import Network.Wai (Application)--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import PostgREST.Config.PgVersion (PgVersion, pgVersion112)--import Protolude hiding (get)-import SpecHelper--spec :: PgVersion -> SpecWith ((), Application)-spec actualPgVersion = describe "authorization" $ do- let single = ("Accept","application/vnd.pgrst.object+json")-- it "denies access to tables that anonymous does not own" $- get "/authors_only" `shouldRespondWith` (- if actualPgVersion >= pgVersion112 then- [json| {- "hint":null,- "details":null,- "code":"42501",- "message":"permission denied for table authors_only"} |]- else- [json| {- "hint":null,- "details":null,- "code":"42501",- "message":"permission denied for relation authors_only"} |]- )- { matchStatus = 401- , matchHeaders = ["WWW-Authenticate" <:> "Bearer"]- }-- it "denies access to tables that postgrest_test_author does not own" $- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in- request methodGet "/private_table" [auth] ""- `shouldRespondWith` (- if actualPgVersion >= pgVersion112 then- [json| {- "hint":null,- "details":null,- "code":"42501",- "message":"permission denied for table private_table"} |]- else- [json| {- "hint":null,- "details":null,- "code":"42501",- "message":"permission denied for relation private_table"} |]- )- { matchStatus = 403- , matchHeaders = []- }-- it "denies execution on functions that anonymous does not own" $- post "/rpc/privileged_hello" [json|{"name": "anonymous"}|] `shouldRespondWith` 401-- it "allows execution on a function that postgrest_test_author owns" $- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in- request methodPost "/rpc/privileged_hello" [auth] [json|{"name": "jdoe"}|]- `shouldRespondWith` [json|"Privileged hello to jdoe"|]- { matchStatus = 200- , matchHeaders = [matchContentTypeJson]- }-- it "returns jwt functions as jwt tokens" $- request methodPost "/rpc/login" [single]- [json| { "id": "jdoe", "pass": "1234" } |]- `shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.KO-0PGp_rU-utcDBP6qwdd-Th2Fk-ICVt01I7QtTDWs"} |]- { matchStatus = 200- , matchHeaders = [matchContentTypeSingular]- }-- it "sql functions can encode custom and standard claims" $- request methodPost "/rpc/jwt_test" [single] "{}"- `shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.G2REtPnOQMUrVRDA9OnkPJTd8R0tf4wdYOlauh1E2Ek"} |]- { matchStatus = 200- , matchHeaders = [matchContentTypeSingular]- }-- 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` [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"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` 200-- it "works with tokens which have extra fields" $ do- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIiwia2V5MSI6InZhbHVlMSIsImtleTIiOiJ2YWx1ZTIiLCJrZXkzIjoidmFsdWUzIiwiYSI6MSwiYiI6MiwiYyI6M30.b0eglDKYEmGi-hCvD-ddSqFl7vnDO5qkUaviaHXm3es"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` 200-- -- this test will stop working 9999999999s after the UNIX EPOCH- it "succeeds with an unexpired token" $ do- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` 200-- it "fails with an expired token" $ do- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.f8__E6VQwYcDqwHmr9PG03uaZn8Zh1b0vbJ9DYS0AdM"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` [json| {"message":"JWT expired"} |]- { matchStatus = 401- , matchHeaders = [- "WWW-Authenticate" <:>- "Bearer error=\"invalid_token\", error_description=\"JWT expired\""- ]- }-- it "hides tables from users with invalid JWT" $ do- let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"} |]- { matchStatus = 401- , matchHeaders = [- "WWW-Authenticate" <:>- "Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)\""- ]- }-- it "should fail when jwt contains no claims" $ do- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.CUIP5V9thWsGGFsFyGijSZf1fJMfarLHI9CEJL-TGNk"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` 401-- it "hides tables from users with JWT that contain no claims about role" $ do- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.RVlZDaSyKbFPvxUf3V_NQXybfRB4dlBIkAUQXVXLUAI"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` 401-- it "recovers after 401 error with logged in user" $ do- _ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"- _ <- request methodPost "/rpc/problem" [auth] ""- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` 200-- describe "custom pre-request proc acting on id claim" $ do-- it "able to switch to postgrest_test_author role (id=1)" $- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.gKw7qI50i9hMrSJW8BlTpdMEVmMXJYxlAqueGqpa_mE" in- request methodPost "/rpc/get_current_user" [auth]- [json| {} |]- `shouldRespondWith` [json|"postgrest_test_author"|]- { matchStatus = 200- , matchHeaders = []- }-- it "able to switch to postgrest_test_default_role (id=2)" $- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.nwzjMI0YLvVGJQTeoCPEBsK983b__gxdpLXisBNaO2A" in- request methodPost "/rpc/get_current_user" [auth]- [json| {} |]- `shouldRespondWith` [json|"postgrest_test_default_role"|]- { matchStatus = 200- , matchHeaders = []- }-- it "raises error (id=3)" $- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.OGxEJAf60NKZiTn-tIb2jy4rqKs_ZruLGWZ40TjrJsM" in- request methodPost "/rpc/get_current_user" [auth]- [json| {} |]- `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/BinaryJwtSecretSpec.hs
@@ -1,21 +0,0 @@-module Feature.BinaryJwtSecretSpec where---- {{{ Imports-import Network.Wai (Application)--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai--import Protolude-import SpecHelper--- }}}--spec :: SpecWith ((), Application)-spec = describe "server started with binary JWT secret" $-- -- this test will stop working 9999999999s after the UNIX EPOCH- it "succeeds with jwt token encoded with a binary secret" $ do- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith` 200
− test/Feature/ConcurrentSpec.hs
@@ -1,52 +0,0 @@-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE TypeFamilies #-}-{-# LANGUAGE UndecidableInstances #-}-{-# OPTIONS_GHC -fno-warn-orphans #-}-module Feature.ConcurrentSpec where--import Control.Concurrent.Async (mapConcurrently)-import Network.Wai (Application)--import Control.Monad.Base-import Control.Monad.Trans.Control--import Network.Wai.Test (Session)-import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.Internal-import Test.Hspec.Wai.JSON--import Protolude hiding (get)--spec :: SpecWith ((), Application)-spec =- describe "Querying in parallel" $- it "should not raise 'transaction in progress' error" $- raceTest 10 $- get "/fakefake"- `shouldRespondWith` [json|- { "hint": null,- "details":null,- "code":"42P01",- "message":"relation \"test.fakefake\" does not exist"- } |]- { matchStatus = 404- , matchHeaders = []- }--raceTest :: Int -> WaiExpectation st -> WaiExpectation st-raceTest times = liftBaseDiscard go- where- go test = void $ mapConcurrently (const test) [1..times]--instance MonadBaseControl IO (WaiSession st) where- type StM (WaiSession st) a = StM Session a- liftBaseWith f = WaiSession $- liftBaseWith $ \runInBase ->- f $ \k -> runInBase (unWaiSession k)- restoreM = WaiSession . restoreM- {-# INLINE liftBaseWith #-}- {-# INLINE restoreM #-}--instance MonadBase IO (WaiSession st) where- liftBase = liftIO
− test/Feature/CorsSpec.hs
@@ -1,74 +0,0 @@-module Feature.CorsSpec where---- {{{ Imports-import qualified Data.ByteString.Lazy as BL--import Network.Wai (Application)-import Network.Wai.Test (SResponse (simpleBody, simpleHeaders))--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai--import Protolude-import SpecHelper--- }}}--spec :: SpecWith ((), Application)-spec =- describe "CORS" $ do- let preflightHeaders = [- ("Accept", "*/*"),- ("Origin", "http://example.com"),- ("Access-Control-Request-Method", "POST"),- ("Access-Control-Request-Headers", "Foo,Bar") ]- let normalCors = [- ("Host", "localhost:3000"),- ("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:32.0) Gecko/20100101 Firefox/32.0"),- ("Origin", "http://localhost:8000"),- ("Accept", "text/csv, */*; q=0.01"),- ("Accept-Language", "en-US,en;q=0.5"),- ("Accept-Encoding", "gzip, deflate"),- ("Referer", "http://localhost:8000/"),- ("Connection", "keep-alive") ]-- describe "preflight request" $ do- it "replies naively and permissively to preflight request" $ do- r <- request methodOptions "/items" preflightHeaders ""- liftIO $ do- let respHeaders = simpleHeaders r- respHeaders `shouldSatisfy` matchHeader- "Access-Control-Allow-Origin"- "http://example.com"- respHeaders `shouldSatisfy` matchHeader- "Access-Control-Allow-Credentials"- "true"- respHeaders `shouldSatisfy` matchHeader- "Access-Control-Allow-Methods"- "GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD"- respHeaders `shouldSatisfy` matchHeader- "Access-Control-Allow-Headers"- "Authentication, Foo, Bar, Accept, Accept-Language, Content-Language"- respHeaders `shouldSatisfy` matchHeader- "Access-Control-Max-Age"- "86400"-- it "suppresses body in response" $ do- r <- request methodOptions "/" preflightHeaders ""- liftIO $ simpleBody r `shouldBe` ""-- describe "regular request" $- it "exposes necesssary response headers" $ do- r <- request methodGet "/items" [("Origin", "http://example.com")] ""- liftIO $ simpleHeaders r `shouldSatisfy` matchHeader- "Access-Control-Expose-Headers"- "Content-Encoding, Content-Location, Content-Range, Content-Type, \- \Date, Location, Server, Transfer-Encoding, Range-Unit"-- describe "postflight request" $- it "allows INFO body through even with CORS request headers present" $ do- r <- request methodOptions "/items" normalCors ""- liftIO $ do- simpleHeaders r `shouldSatisfy` matchHeader- "Access-Control-Allow-Origin" "\\*"- simpleBody r `shouldSatisfy` BL.null
− test/Feature/DeleteSpec.hs
@@ -1,92 +0,0 @@-module Feature.DeleteSpec 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)--spec :: SpecWith ((), Application)-spec =- describe "Deleting" $ do- context "existing record" $ do- it "succeeds with 204 and deletion count" $- request methodDelete "/items?id=eq.1" [] ""- `shouldRespondWith` ""- { matchStatus = 204- , matchHeaders = ["Content-Range" <:> "*/*"]- }-- it "returns the deleted item and count if requested" $- request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""- `shouldRespondWith` [json|[{"id":2}]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "*/1"]- }-- it "ignores ?select= when return not set or return=minimal" $ do- request methodDelete "/items?id=eq.3&select=id" [] ""- `shouldRespondWith` ""- { matchStatus = 204- , matchHeaders = ["Content-Range" <:> "*/*"]- }- request methodDelete "/items?id=eq.3&select=id" [("Prefer", "return=minimal")] ""- `shouldRespondWith` ""- { matchStatus = 204- , matchHeaders = ["Content-Range" <:> "*/*"]- }-- it "returns the deleted item and shapes the response" $- request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""- `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` [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` [json|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "*/*"]- }-- context "known route, no records matched" $- it "includes [] body if return=rep" $- request methodDelete "/items?id=eq.101"- [("Prefer", "return=representation")] ""- `shouldRespondWith` "[]"- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "*/*"]- }-- context "totally unknown route" $- it "fails with 404" $- request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404-- context "table with limited privileges" $ do- it "fails deleting the row when return=representation and selecting all the columns" $- request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty- `shouldRespondWith` 401-- it "succeeds deleting the row when return=representation and selecting only the privileged columns" $- request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]- [json| { "password": "passxyz" } |]- `shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "*/*"]- }-- it "suceeds deleting the row with no explicit select when using return=minimal" $- request methodDelete "/app_users?id=eq.2" [("Prefer", "return=minimal")] mempty- `shouldRespondWith` 204-- it "suceeds deleting the row with no explicit select by default" $- request methodDelete "/app_users?id=eq.3" [] mempty- `shouldRespondWith` 204
− test/Feature/DisabledOpenApiSpec.hs
@@ -1,20 +0,0 @@-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
@@ -1,440 +0,0 @@-module Feature.EmbedDisambiguationSpec where--import Network.Wai (Application)--import Test.Hspec hiding (pendingWith)-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import Protolude hiding (get)-import SpecHelper--spec :: SpecWith ((), Application)-spec =- describe "resource embedding disambiguation" $ do- context "ambiguous requests that give 300 Multiple Choices" $ do- it "errs when there's a table and view that point to the same fk" $- get "/message?select=id,body,sender(name,sent)" `shouldRespondWith`- [json|- {- "details": [- {- "cardinality": "many-to-one",- "relationship": "message_sender_fkey[sender][id]",- "embedding": "message with person"- },- {- "cardinality": "many-to-one",- "relationship": "message_sender_fkey[sender][id]",- "embedding": "message with person_detail"- }- ],- "hint": "Try changing 'sender' to one of the following: 'person!message_sender_fkey', 'person_detail!message_sender_fkey'. Find the desired relationship in the 'details' key.",- "message": "Could not embed because more than one relationship was found for 'message' and 'sender'"- }- |]- { matchStatus = 300- , matchHeaders = [matchContentTypeJson]- }-- it "errs when there are o2m and m2m cardinalities to the target table" $- get "/sites?select=*,big_projects(*)" `shouldRespondWith`- [json|- {- "details": [- {- "cardinality": "many-to-one",- "relationship": "main_project[main_project_id][big_project_id]",- "embedding": "sites with big_projects"- },- {- "cardinality": "many-to-many",- "relationship": "test.jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",- "embedding": "sites with big_projects"- },- {- "cardinality": "many-to-many",- "relationship": "test.main_jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",- "embedding": "sites with big_projects"- }- ],- "hint": "Try changing 'big_projects' to one of the following: 'big_projects!main_project', 'big_projects!jobs', 'big_projects!main_jobs'. Find the desired relationship in the 'details' key.",- "message": "Could not embed because more than one relationship was found for 'sites' and 'big_projects'"- }- |]- { matchStatus = 300- , matchHeaders = [matchContentTypeJson]- }-- it "errs on an ambiguous embed that has a circular reference" $- get "/agents?select=*,departments(*)" `shouldRespondWith`- [json|- {- "details": [- {- "cardinality": "many-to-one",- "relationship": "agents_department_id_fkey[department_id][id]",- "embedding": "agents with departments"- },- {- "cardinality": "one-to-many",- "relationship": "departments_head_id_fkey[id][head_id]",- "embedding": "agents with departments"- }- ],- "hint": "Try changing 'departments' to one of the following: 'departments!agents_department_id_fkey', 'departments!departments_head_id_fkey'. Find the desired relationship in the 'details' key.",- "message": "Could not embed because more than one relationship was found for 'agents' and 'departments'"- }- |]- { matchStatus = 300- , matchHeaders = [matchContentTypeJson]- }-- it "errs when there are more than two fks on a junction table(currently impossible to disambiguate, only choice is to split the table)" $- -- We have 4 possibilities for doing the junction JOIN here.- -- This could be solved by specifying two additional fks, like whatev_projects!fk1!fk2(*)- -- If the need arises this capability can be added later without causing a breaking change- get "/whatev_sites?select=*,whatev_projects(*)" `shouldRespondWith`- [json|- {- "details": [- {- "cardinality": "many-to-many",- "relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_1_fkey]",- "embedding": "whatev_sites with whatev_projects"- },- {- "cardinality": "many-to-many",- "relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_2_fkey]",- "embedding": "whatev_sites with whatev_projects"- },- {- "cardinality": "many-to-many",- "relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_1_fkey]",- "embedding": "whatev_sites with whatev_projects"- },- {- "cardinality": "many-to-many",- "relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_2_fkey]",- "embedding": "whatev_sites with whatev_projects"- }- ],- "hint": "Try changing 'whatev_projects' to one of the following: 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs'. Find the desired relationship in the 'details' key.",- "message": "Could not embed because more than one relationship was found for 'whatev_sites' and 'whatev_projects'"- }- |]- { matchStatus = 300- , matchHeaders = [matchContentTypeJson]- }-- context "disambiguating requests with embed hints" $ do-- context "using FK to specify the relationship" $ do- it "can embed by FK name" $- get "/projects?id=in.(1,3)&select=id,name,client(id,name)" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client":{"id":2,"name":"Apple"}}]|]- { matchHeaders = [matchContentTypeJson] }-- it "can embed by FK name and select the FK column at the same time" $- get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]- { matchHeaders = [matchContentTypeJson] }-- it "can 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`- [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`- [json| [- {- "day": "2019-12-02",- "fst_shift": {- "car_id": "CAR-349",- "schedule": {- "name": "morning"- }- },- "snd_shift": {- "camera_id": "CAM-123",- "schedule": {- "name": "night"- }- },- "unit_id": 1- }- ] |]- { matchHeaders = [matchContentTypeJson] }-- it "embeds by using two fks pointing to the same table" $- get "/orders?id=eq.1&select=id, name, billing(address), shipping(address)" `shouldRespondWith`- [json|[{"id":1,"name":"order 1","billing":{"address": "address 1"},"shipping":{"address": "address 2"}}]|]- { matchHeaders = [matchContentTypeJson] }-- it "fails if the fk is not known" $- get "/message?select=id,sender:person!space(name)&id=lt.4" `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 message and person in the schema cache"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson] }-- it "can request a parent with fk" $- get "/comments?select=content,user(name)" `shouldRespondWith`- [json|[ { "content": "Needs to be delivered ASAP", "user": { "name": "Angela Martin" } } ]|]- { matchHeaders = [matchContentTypeJson] }-- it "can request two parents with fks" $- 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`- [json|- [{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},- {"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},- {"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can specify a table!fk hint and request children 2 levels" $- get "/clients?id=eq.1&select=id,projects:projects!client(id,tasks(id))" `shouldRespondWith`- [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "can disambiguate with the fk in case of an o2m and m2m relationship to the same table" $- get "/sites?select=name,main_project(name)&site_id=eq.1" `shouldRespondWith`- [json| [ { "name": "site 1", "main_project": { "name": "big project 1" } } ] |]- { matchHeaders = [matchContentTypeJson] }-- context "using the column name of the FK to specify the relationship" $ do- it "can embed by column" $- get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]- { matchHeaders = [matchContentTypeJson] }-- it "can embed by column and select the column at the same time, if aliased" $- get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]- { matchHeaders = [matchContentTypeJson] }-- it "can 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`- [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`- [json|- [{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},- {"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},- {"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "will embed using a column that has uppercase chars" $- get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`- [json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "embeds by using two columns pointing to the same table" $- get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`- [json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]- { matchHeaders = [matchContentTypeJson] }-- it "can disambiguate with the column in case of an o2m and m2m relationship to the same table" $- get "/sites?select=name,main_project_id(name)&site_id=eq.1" `shouldRespondWith`- [json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]- { matchHeaders = [matchContentTypeJson] }-- context "using the junction to disambiguate the request" $- it "can specify the junction of an m2m relationship" $ do- get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`- [json|- [- {- "big_projects": [- {- "name": "big project 1"- }- ],- "main_project_id": 1,- "name": "site 1",- "site_id": 1- },- {- "big_projects": [- {- "name": "big project 1"- },- {- "name": "big project 2"- }- ],- "main_project_id": null,- "name": "site 2",- "site_id": 2- }- ]- |]- get "/sites?select=*,big_projects!main_jobs(name)&site_id=in.(1,2)" `shouldRespondWith`- [json|- [- {- "big_projects": [- {- "name": "big project 1"- }- ],- "main_project_id": 1,- "name": "site 1",- "site_id": 1- },- {- "big_projects": [],- "main_project_id": null,- "name": "site 2",- "site_id": 2- }- ]- |]- { matchHeaders = [matchContentTypeJson] }-- context "using a FK column and a FK to specify the relationship" $- it "embeds by using a column and a fk pointing to the same table" $- get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping(id)" `shouldRespondWith`- [json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping":{"id":2}}]|]- { matchHeaders = [matchContentTypeJson] }-- context "tables with self reference foreign keys" $ do- context "one self reference foreign key" $ do- it "embeds parents recursively" $- get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`- [json|[- { "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },- { "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }- ]|]- { matchHeaders = [matchContentTypeJson] }-- it "embeds children recursively" $- get "/family_tree?id=eq.1&select=id,name, children:family_tree!parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`- [json|[{- "id": "1", "name": "Parental Unit", "children": [- { "id": "2", "name": "Kid One", "children": [ { "id": "4", "name": "Grandkid One" } ] },- { "id": "3", "name": "Kid Two", "children": [ { "id": "5", "name": "Grandkid Two" } ] }- ]- }]|] { matchHeaders = [matchContentTypeJson] }-- it "embeds parent and then embeds children" $- get "/family_tree?id=eq.2&select=id,name,parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`- [json|[{- "id": "2", "name": "Kid One", "parent": {- "id": "1", "name": "Parental Unit", "children": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]- }- }]|] { matchHeaders = [matchContentTypeJson] }-- context "two self reference foreign keys" $ do- it "embeds parents" $- get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`- [json|[{- "id": 3, "name": "Acme",- "referee": {- "id": 1,- "name": "Referee Org"- },- "auditor": {- "id": 2,- "name": "Auditor Org"- }- }]|] { matchHeaders = [matchContentTypeJson] }-- it "embeds children" $ do- get "/organizations?select=id,name,refereeds:organizations!referee(id,name)&id=eq.1" `shouldRespondWith`- [json|[{- "id": 1, "name": "Referee Org",- "refereeds": [- {- "id": 3,- "name": "Acme"- },- {- "id": 4,- "name": "Umbrella"- }- ]- }]|] { matchHeaders = [matchContentTypeJson] }- get "/organizations?select=id,name,auditees:organizations!auditor(id,name)&id=eq.2" `shouldRespondWith`- [json|[{- "id": 2, "name": "Auditor Org",- "auditees": [- {- "id": 3,- "name": "Acme"- },- {- "id": 4,- "name": "Umbrella"- }- ]- }]|] { matchHeaders = [matchContentTypeJson] }-- it "embeds other relations(manager) besides the self reference" $ do- get "/organizations?select=name,manager(name),referee(name,manager(name),auditor(name,manager(name))),auditor(name,manager(name),referee(name,manager(name)))&id=eq.5" `shouldRespondWith`- [json|[{- "name":"Cyberdyne",- "manager":{"name":"Cyberdyne Manager"},- "referee":{- "name":"Acme",- "manager":{"name":"Acme Manager"},- "auditor":{- "name":"Auditor Org",- "manager":{"name":"Auditor Manager"}}},- "auditor":{- "name":"Umbrella",- "manager":{"name":"Umbrella Manager"},- "referee":{- "name":"Referee Org",- "manager":{"name":"Referee Manager"}}}- }]|] { matchHeaders = [matchContentTypeJson] }-- get "/organizations?select=name,manager(name),auditees:organizations!auditor(name,manager(name),refereeds:organizations!referee(name,manager(name)))&id=eq.2" `shouldRespondWith`- [json|[{- "name":"Auditor Org",- "manager":{"name":"Auditor Manager"},- "auditees":[- {"name":"Acme",- "manager":{"name":"Acme Manager"},- "refereeds":[- {"name":"Cyberdyne",- "manager":{"name":"Cyberdyne Manager"}},- {"name":"Oscorp",- "manager":{"name":"Oscorp Manager"}}]},- {"name":"Umbrella",- "manager":{"name":"Umbrella Manager"},- "refereeds":[]}]- }]|] { 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/EmbedInnerJoinSpec.hs
@@ -1,356 +0,0 @@-module Feature.EmbedInnerJoinSpec where--import Network.HTTP.Types-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 "Embedding with an inner join" $ do- context "many-to-one relationships" $ do- it "ignores null embeddings while the default left join doesn't" $ do- get "/projects?select=id,clients!inner(id)" `shouldRespondWith`- [json|[- {"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},- {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}]|]- { matchHeaders = [matchContentTypeJson] }- get "/projects?select=id,clients!left(id)" `shouldRespondWith`- [json|[- {"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},- {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},- {"id":5,"clients":null}]|]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/projects?select=id,clients!inner(id)" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-3/4" ]- }-- it "filters source tables when the embedded table is filtered" $ do- get "/projects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`- [json|[- {"id":1,"clients":{"id":1}},- {"id":2,"clients":{"id":1}}]|]- { matchHeaders = [matchContentTypeJson] }- get "/projects?select=id,clients!inner(id)&clients.id=eq.2" `shouldRespondWith`- [json|[- {"id":3,"clients":{"id":2}},- {"id":4,"clients":{"id":2}}] |]- { matchHeaders = [matchContentTypeJson] }- get "/projects?select=id,clients!inner(id)&clients.id=eq.0" `shouldRespondWith`- [json|[]|]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/projects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-1/2" ]- }-- it "filters source tables when a two levels below embedded table is filtered" $ do- get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" `shouldRespondWith`- [json|[- {"id":1,"projects":{"id":1,"clients":{"id":1}}},- {"id":2,"projects":{"id":1,"clients":{"id":1}}},- {"id":3,"projects":{"id":2,"clients":{"id":1}}},- {"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]- { matchHeaders = [matchContentTypeJson] }- get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`- [json|[- {"id":5,"projects":{"id":3,"clients":{"id":2}}},- {"id":6,"projects":{"id":3,"clients":{"id":2}}},- {"id":7,"projects":{"id":4,"clients":{"id":2}}},- {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-3/4" ]- }-- it "only affects the source table rows if his direct embedding is an inner join" $ do- get "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`- [json|[- {"id":1,"projects":null},- {"id":2,"projects":null},- {"id":3,"projects":null},- {"id":4,"projects":null},- {"id":5,"projects":{"id":3,"clients":{"id":2}}},- {"id":6,"projects":{"id":3,"clients":{"id":2}}},- {"id":7,"projects":{"id":4,"clients":{"id":2}}},- {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-7/8" ]- }-- it "works with views" $ do- get "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" `shouldRespondWith`- [json| [{"title":"1984","authors":{"name":"George Orwell"}}] |]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-0/1" ]- }-- context "one-to-many relationships" $ do- it "ignores empty array embeddings while the default left join doesn't" $ do- get "/entities?select=id,child_entities!inner(id)" `shouldRespondWith`- [json|[- {"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},- {"id":2,"child_entities":[{"id":3}, {"id":6}]}]|]- { matchHeaders = [matchContentTypeJson] }- get "/entities?select=id,child_entities!left(id)" `shouldRespondWith`- [json| [- {"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},- {"id":2,"child_entities":[{"id":3}, {"id":6}]},- {"id":3,"child_entities":[]},- {"id":4,"child_entities":[]}] |]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/entities?select=id,child_entities!inner(id)" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-1/2" ]- }-- it "filters source tables when the embedded table is filtered" $ do- get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" `shouldRespondWith`- [json|[{"id":1,"child_entities":[{"id":1}]}]|]- { matchHeaders = [matchContentTypeJson] }- get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.3" `shouldRespondWith`- [json|[{"id":2,"child_entities":[{"id":3}]}]|]- { matchHeaders = [matchContentTypeJson] }- get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.0" `shouldRespondWith`- [json|[]|]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-0/1" ]- }-- it "filters source tables when a two levels below embedded table is filtered" $ do- get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)"- `shouldRespondWith`- [json|[- {- "id": 1,- "child_entities": [- { "id": 1, "grandchild_entities": [ { "id": 1 } ] },- { "id": 2, "grandchild_entities": [ { "id": 5 } ] }]- }- ]|]- { matchHeaders = [matchContentTypeJson] }- get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`- [json|[- {- "id": 1,- "child_entities": [- { "id": 1, "grandchild_entities": [ { "id": 2 } ] } ]- }- ]|]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-0/1" ]- }-- it "only affects the source table rows if his direct embedding is an inner join" $ do- get "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`- [json|[- {- "id": 1,- "child_entities": [- { "id": 1, "grandchild_entities": [ { "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": [] } ]- }- ]|]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-1/2" ]- }-- it "works with views" $ do- get "/authors?select=*,books!inner(*)&books.title=eq.1984" `shouldRespondWith`- [json| [{"id":1,"name":"George Orwell","books":[{"id":1,"title":"1984","publication_year":1949,"author_id":1}]}] |]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/authors?select=*,books!inner(*)&books.title=eq.1984" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-0/1" ]- }-- context "many-to-many relationships" $ do- it "ignores empty array embeddings while the default left join doesn't" $ do- get "/products?select=id,suppliers!inner(id)" `shouldRespondWith`- [json| [- {"id":1,"suppliers":[{"id":1}, {"id":2}]},- {"id":2,"suppliers":[{"id":1}, {"id":3}]}] |]- { matchHeaders = [matchContentTypeJson] }- get "/products?select=id,suppliers!left(id)" `shouldRespondWith`- [json| [- {"id":1,"suppliers":[{"id":1}, {"id":2}]},- {"id":2,"suppliers":[{"id":1}, {"id":3}]},- {"id":3,"suppliers":[]}] |]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/products?select=id,suppliers!inner(id)" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-1/2" ]- }-- it "filters source tables when the embedded table is filtered" $ do- get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" `shouldRespondWith`- [json| [{"id":1,"suppliers":[{"id":2}]}] |]- { matchHeaders = [matchContentTypeJson] }- get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.3" `shouldRespondWith`- [json| [{"id":2,"suppliers":[{"id":3}]}] |]- { matchHeaders = [matchContentTypeJson] }- get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.0" `shouldRespondWith`- [json| [] |]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-0/1" ]- }-- it "filters source tables when a two levels below embedded table is filtered" $ do- get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3"- `shouldRespondWith`- [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]- { matchHeaders = [matchContentTypeJson] }- get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.4"- `shouldRespondWith`- [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":4}]}]}] |]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-0/1" ]- }-- it "only affects the source table rows if his direct embedding is an inner join" $ do- get "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" `shouldRespondWith`- [json|[- {"id":1,"suppliers":[{"id":1,"trade_unions":[]}, {"id":2,"trade_unions":[{"id":3}]}]},- {"id":2,"suppliers":[{"id":1,"trade_unions":[]}, {"id":3,"trade_unions":[]}]}]|]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-1/2" ]- }-- it "works with views" $ do- get "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" `shouldRespondWith`- [json| [{"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]}] |]- { matchHeaders = [matchContentTypeJson] }- get "/films?select=*,actors!inner(*)&actors.name=eq.john" `shouldRespondWith`- [json| [{"id":12,"title":"douze commandements","actors":[{"id":1,"name":"john"}]}] |]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-0/1" ]- }-- it "works with m2o and m2m relationships combined" $ do- get "/projects?select=name,clients!inner(name),users!inner(name)" `shouldRespondWith`- [json| [- {"name":"Windows 7","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}, {"name":"Dwight Schrute"}]},- {"name":"Windows 10","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}]},- {"name":"IOS","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}, {"name":"Dwight Schrute"}]},- {"name":"OSX","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}]}]|]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/projects?select=name,clients!inner(name),users!inner(name)" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-3/4" ]- }-- it "works with rpc" $ do- get "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`- [json| [{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}}] |]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-1/2" ]- }-- it "works when using hints" $ do- get "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" `shouldRespondWith`- [json| [{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}] |]- { matchHeaders = [matchContentTypeJson] }- get "/projects?select=id,client!inner(id)&client.id=eq.2" `shouldRespondWith`- [json| [{"id":3,"client":{"id":2}}, {"id":4,"client":{"id":2}}] |]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-1/2" ]- }-- it "works with many one-to-many relationships" $ do- -- https://github.com/PostgREST/postgrest/issues/1977- get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" `shouldRespondWith`- [json|[- {"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}, {"name":"Wilma Wellers"}],"clientinfo":[{"other":"123 Main St"}]},- {"id":2,"name":"Target", "contact":[{"name":"Tabby Targo"}],"clientinfo":[{"other":"456 South 3rd St"}]},- {"id":3,"name":"Big Lots","contact":[{"name":"Bobby Bots"}, {"name":"Bonnie Bits"}, {"name":"Billy Boats"}],"clientinfo":[{"other":"789 Palm Tree Ln"}]}- ]|]- { matchHeaders = [matchContentTypeJson] }- get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&contact.name=eq.Wally%20Walton" `shouldRespondWith`- [json|[- {"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}],"clientinfo":[{"other":"123 Main St"}]}- ]|]- { matchHeaders = [matchContentTypeJson] }- get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&clientinfo.other=eq.456%20South%203rd%20St" `shouldRespondWith`- [json|[- {"id":2,"name":"Target","clientinfo":[{"other":"456 South 3rd St"}],"contact":[{"name":"Tabby Targo"}]}- ]|]- { matchHeaders = [matchContentTypeJson] }- request methodHead "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" [("Prefer", "count=exact")] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson- , "Content-Range" <:> "0-2/3" ]- }
− test/Feature/ExtraSearchPathSpec.hs
@@ -1,39 +0,0 @@-module Feature.ExtraSearchPathSpec where--import Network.HTTP.Types-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 "extra search path" $ do-- it "finds the ltree <@ operator on the public schema" $- request methodGet "/ltree_sample?path=cd.Top.Science.Astronomy" [] ""- `shouldRespondWith` [json|[- {"path":"Top.Science.Astronomy"},- {"path":"Top.Science.Astronomy.Astrophysics"},- {"path":"Top.Science.Astronomy.Cosmology"}]|]- { matchHeaders = [matchContentTypeJson] }-- it "finds the ltree nlevel function on the public schema, used through a computed column" $- request methodGet "/ltree_sample?select=number_of_labels&path=eq.Top.Science" [] ""- `shouldRespondWith` [json|[{"number_of_labels":2}]|]- { matchHeaders = [matchContentTypeJson] }-- it "finds the isn = operator on the extensions schema" $- request methodGet "/isn_sample?id=eq.978-0-393-04002-9&select=name" [] ""- `shouldRespondWith` [json|[{"name":"Mathematics: From the Birth of Numbers"}]|]- { matchHeaders = [matchContentTypeJson] }-- it "finds the isn is_valid function on the extensions schema" $- 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
@@ -1,30 +0,0 @@-module Feature.HtmlRawOutputSpec where--import Network.Wai (Application)--import Network.HTTP.Types-import Test.Hspec hiding (pendingWith)-import Test.Hspec.Wai-import Text.Heredoc--import Protolude hiding (get)-import SpecHelper (acceptHdrs)--spec :: SpecWith ((), Application)-spec = describe "When raw-media-types is set to \"text/html\"" $- it "can get raw output with Accept: text/html" $- request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""- `shouldRespondWith`- [str|- |<html>- | <head>- | <title>PostgREST</title>- | </head>- | <body>- | <h1>Welcome to PostgREST</h1>- | </body>- |</html>- |]- { matchStatus = 200- , matchHeaders = ["Content-Type" <:> "text/html"]- }
− test/Feature/IgnorePrivOpenApiSpec.hs
@@ -1,76 +0,0 @@-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
@@ -1,558 +0,0 @@-module Feature.InsertSpec where--import Data.List (lookup)-import Network.Wai (Application)-import Network.Wai.Test (SResponse (simpleHeaders))-import Test.Hspec hiding (pendingWith)-import Test.Hspec.Wai.Matcher (bodyEquals)--import Network.HTTP.Types-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON-import Text.Heredoc--import PostgREST.Config.PgVersion (PgVersion, pgVersion110,- pgVersion112, pgVersion130)--import Protolude hiding (get)-import SpecHelper--spec :: PgVersion -> SpecWith ((), Application)-spec actualPgVersion = do- describe "Posting new record" $ do- context "disparate json types" $ do- it "accepts disparate json types" $ do- post "/menagerie"- [json| {- "integer": 13, "double": 3.14159, "varchar": "testing!"- , "boolean": false, "date": "1900-01-01", "money": "$3.99"- , "enum": "foo"- } |] `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")]- [json| [{- "integer": 14, "double": 3.14159, "varchar": "testing!"- , "boolean": false, "date": "1900-01-01", "money": "$3.99"- , "enum": "foo"- }] |] `shouldRespondWith` [json|[{"integer":14,"varchar":"testing!"}]|]- { matchStatus = 201- , matchHeaders = [matchContentTypeJson]- }-- it "ignores &select when return not set or using return=minimal" $ do- request methodPost "/menagerie?select=integer,varchar" []- [json| [{- "integer": 15, "double": 3.14159, "varchar": "testing!"- , "boolean": false, "date": "1900-01-01", "money": "$3.99"- , "enum": "foo"- }] |] `shouldRespondWith` ""- { matchStatus = 201 }- request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=minimal")]- [json| [{- "integer": 16, "double": 3.14159, "varchar": "testing!"- , "boolean": false, "date": "1900-01-01", "money": "$3.99"- , "enum": "foo"- }] |] `shouldRespondWith` ""- { matchStatus = 201 }-- context "non uniform json array" $ do- it "rejects json array that isn't exclusivily composed of objects" $- post "/articles"- [json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |]- `shouldRespondWith`- [json| {"message":"All object keys must match"} |]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- it "rejects json array that has objects with different keys" $- post "/articles"- [json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |]- `shouldRespondWith`- [json| {"message":"All object keys must match"} |]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- context "requesting full representation" $ do- it "includes related data after insert" $- request methodPost "/projects?select=id,name,clients(id,name)"- [("Prefer", "return=representation"), ("Prefer", "count=exact")]- [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"- , "Content-Range" <:> "*/1" ]- }-- it "can rename and cast the selected columns" $- request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"- [("Prefer", "return=representation")]- [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" $ do- 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" <:> "*/*" ]- }-- when (actualPgVersion >= pgVersion110) $- it "should not throw and return location header for partitioned tables when selecting without PK" $- request methodPost "/car_models" [("Prefer", "return=headers-only")]- [json|{"name":"Enzo","year":2021}|] `shouldRespondWith` ""- { matchStatus = 201- , matchHeaders = [ "Location" <:> "/car_models?name=eq.Enzo&year=eq.2021"- , "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- 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 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`- (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 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- 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- request methodPost "/no_pk"- [("Prefer", "return=representation")]- [json| [] |]- `shouldRespondWith`- [json| [] |]- { matchStatus = 201 }-- it "can post nulls" $ do- 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- 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"}]- |]- request methodPost "/compound_pk" [] bulkData- `shouldRespondWith`- ""- { matchStatus = 201- , matchHeaders = [matchHeaderAbsent hLocation]- }-- context "with invalid json payload" $- it "fails with 400 and error" $- post "/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" $- post "/simple_pk" ""- `shouldRespondWith`- [json|{"message":"Error in $: not enough input"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- context "with valid json payload" $- it "succeeds and returns 201 created" $- post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |] `shouldRespondWith` 201-- context "attempting to insert a row with the same primary key" $- it "fails returning a 409 Conflict" $- post "/simple_pk"- [json| { "k":"xyyx", "extra":"e1" } |]- `shouldRespondWith`- [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" $- post "/withUnique" [json| { "uni":"nodup", "extra":"e2" } |] `shouldRespondWith` 409-- context "jsonb" $ do- it "serializes nested object" $ do- let inserted = [json| { "data": { "foo":"bar" } } |]- request methodPost "/json_table"- [("Prefer", "return=representation")]- inserted- `shouldRespondWith` [json|[{"data":{"foo":"bar"}}]|]- { matchStatus = 201- }-- it "serializes nested array" $ do- let inserted = [json| { "data": [1,2,3] } |]- request methodPost "/json_table"- [("Prefer", "return=representation")]- inserted- `shouldRespondWith` [json|[{"data":[1,2,3]}]|]- { matchStatus = 201- }-- context "empty objects" $ do- it "successfully inserts a row with all-default columns" $ do- post "/items" "{}" `shouldRespondWith` ""- { matchStatus = 201- , matchHeaders = []- }- post "/items" "[{}]" `shouldRespondWith` ""- { matchStatus = 201- , matchHeaders = []- }-- it "successfully inserts two rows with all-default columns" $- post "/items" "[{}, {}]" `shouldRespondWith` ""- { 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|""|]-- 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")]- [json| {"id": 200, "body": "xxx", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`- [json|[{"id": 200, "body": "xxx", "owner": "postgrest_test_anonymous"}]|]- { matchStatus = 201- , matchHeaders = [] }- request methodPost "/articles?columns=id,body&select=id,body" [("Prefer", "return=representation")]- [json| [- {"id": 201, "body": "yyy", "smth": "here", "other": "stuff", "fake_id": 13},- {"id": 202, "body": "zzz", "garbage": "%%$&", "kkk": "jjj"},- {"id": 203, "body": "aaa", "hey": "ho"} ]|] `shouldRespondWith`- [json|[- {"id": 201, "body": "yyy"},- {"id": 202, "body": "zzz"},- {"id": 203, "body": "aaa"} ]|]- { matchStatus = 201- , matchHeaders = [] }-- -- TODO parse columns error message needs to be improved- it "disallows blank ?columns" $- post "/articles?columns="- [json|[- {"id": 204, "body": "yyy"},- {"id": 205, "body": "zzz"}]|]- `shouldRespondWith`- [json| {"details":"unexpected end of input expecting field name (* or [a..z0..9_])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)"} |]- { matchStatus = 400- , matchHeaders = []- }-- it "disallows array elements that are not json objects" $- post "/articles?columns=id,body"- [json|[- {"id": 204, "body": "yyy"},- 333,- "asdf",- {"id": 205, "body": "zzz"}]|] `shouldRespondWith`- [json|{- "code": "22023",- "details": null,- "hint": null,- "message": "argument of json_populate_recordset must be an array of objects"}|]- { matchStatus = 400- , matchHeaders = []- }-- describe "CSV insert" $ do- context "disparate csv types" $- it "succeeds with multipart response" $ do- pendingWith "Decide on what to do with CSV insert"- let inserted = [str|integer,double,varchar,boolean,date,money,enum- |13,3.14159,testing!,false,1900-01-01,$3.99,foo- |12,0.1,a string,true,1929-10-01,12,bar- |]- request methodPost "/menagerie" [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] inserted- `shouldRespondWith` ResponseMatcher- { matchStatus = 201- , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]- , matchBody = bodyEquals inserted- }-- context "requesting full representation" $ do- it "returns full details of inserted record" $- request methodPost "/no_pk"- [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]- "a,b\nbar,baz"- `shouldRespondWith` "a,b\nbar,baz"- { matchStatus = 201- , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]- }-- it "can post nulls" $- request methodPost "/no_pk"- [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]- "a,b\nNULL,foo"- `shouldRespondWith` "a,b\n,foo"- { matchStatus = 201- , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]- }-- it "only returns the requested column header with its associated data" $- request methodPost "/projects?select=id"- [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]- "id,name,client_id\n8,Xenix,1\n9,Windows NT,1"- `shouldRespondWith` "id\n8\n9"- { matchStatus = 201- , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",- "Content-Range" <:> "*/*"]- }-- context "with wrong number of columns" $- it "fails for too few" $- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"- `shouldRespondWith`- [json|{"message":"All lines must have same number of fields"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- context "with unicode values" $- it "succeeds and returns usable location header" $ do- 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- get location- `shouldRespondWith`- [json|[ { "k":"圍棋", "extra":"¥" } ]|]-- request methodDelete location- [("Prefer", "tx=commit")]- ""- `shouldRespondWith`- 204-- describe "Row level permission" $- it "set user_id when inserting rows" $ do- request methodPost "/authors_only"- [ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0", ("Prefer", "return=representation") ]- [json| { "secret": "nyancat" } |]- `shouldRespondWith`- [json|[{"owner":"jdoe","secret":"nyancat"}]|]- { matchStatus = 201 }-- 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" $- request methodPost "/web_content?select=id,name,parent_content:p_web_id(name)"- [("Prefer", "return=representation")]- [json|{"id":6, "name":"wot", "p_web_id":4}|]- `shouldRespondWith`- [json|[{"id":6,"name":"wot","parent_content":{"name":"wut"}}]|]- { matchStatus = 201- , matchHeaders = [ matchContentTypeJson , "Location" <:> "/web_content?id=eq.6" ]- }-- 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` [json|[{"article_id":2,"user_id":1}]|]- { matchStatus = 201- , matchHeaders = []- }-- it "fails inserting if more columns are selected" $- 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- [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]- else- [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]- )- { matchStatus = 401- , matchHeaders = []- }-- it "fails inserting if select is not specified" $- request methodPost "/limited_article_stars" [("Prefer", "return=representation")]- [json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (- if actualPgVersion >= pgVersion112 then- [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]- else- [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- request methodPost "/insertonly"- [("Prefer", "return=minimal")]- [json| { "v":"some value" } |]- `shouldRespondWith`- ""- { matchStatus = 201 }-- 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 ]- }-- 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
@@ -1,253 +0,0 @@-module Feature.JsonOperatorSpec where--import Network.Wai (Application)--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import PostgREST.Config.PgVersion (PgVersion, pgVersion112,- pgVersion121)--import Protolude hiding (get)-import SpecHelper--spec :: PgVersion -> SpecWith ((), Application)-spec actualPgVersion = describe "json and jsonb operators" $ do- context "Shaping response with select parameter" $ do- it "obtains a json subfield one level with casting" $- get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`- [json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"- { matchHeaders = [matchContentTypeJson] }-- it "renames json subfield one level with casting" $- get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`- [json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"- { matchHeaders = [matchContentTypeJson] }-- it "fails on bad casting (data of the wrong format)" $- get "/complex_items?select=settings->foo->>bar::integer"- `shouldRespondWith` (- if actualPgVersion >= pgVersion121 then- [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]- else- [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]- )- { matchStatus = 400 , matchHeaders = [] }-- it "obtains a json subfield two levels (string)" $- get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`- [json| [{"bar":"baz"}] |]- { matchHeaders = [matchContentTypeJson] }-- it "renames json subfield two levels (string)" $- get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`- [json| [{"myBar":"baz"}] |]- { matchHeaders = [matchContentTypeJson] }-- it "obtains a json subfield two levels with casting (int)" $- get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`- [json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now- { matchHeaders = [matchContentTypeJson] }-- it "renames json subfield two levels with casting (int)" $- get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`- [json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now- { matchHeaders = [matchContentTypeJson] }-- -- TODO the status code for the error is 404, this is because 42883 represents undefined function- -- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate- it "fails when a double arrow ->> is followed with a single arrow ->" $ do- get "/json_arr?select=data->>c->1"- `shouldRespondWith` (- if actualPgVersion >= pgVersion112 then- [json|- {"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",- "details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]- else- [json|- {"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",- "details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]- )- { matchStatus = 404 , matchHeaders = [] }- get "/json_arr?select=data->>c->b"- `shouldRespondWith` (- if actualPgVersion >= pgVersion112 then- [json|- {"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",- "details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]- else- [json|- {"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",- "details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]- )- { matchStatus = 404 , matchHeaders = [] }-- context "with array index" $ do- it "can get array of ints and alias/cast it" $ do- get "/json_arr?select=data->>0::int&id=in.(1,2)" `shouldRespondWith`- [json| [{"data":1}, {"data":4}] |]- { matchHeaders = [matchContentTypeJson] }- get "/json_arr?select=idx0:data->>0::int,idx1:data->>1::int&id=in.(1,2)" `shouldRespondWith`- [json| [{"idx0":1,"idx1":2}, {"idx0":4,"idx1":5}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can get nested array of ints" $ do- get "/json_arr?select=data->0->>1::int&id=in.(3,4)" `shouldRespondWith`- [json| [{"data":8}, {"data":7}] |]- { matchHeaders = [matchContentTypeJson] }- get "/json_arr?select=data->0->0->>1::int&id=in.(3,4)" `shouldRespondWith`- [json| [{"data":null}, {"data":6}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can get array of objects" $ do- get "/json_arr?select=data->0->>a&id=in.(5,6)" `shouldRespondWith`- [json| [{"a":"A"}, {"a":"[1,2,3]"}] |]- { matchHeaders = [matchContentTypeJson] }- get "/json_arr?select=data->0->a->>2&id=in.(5,6)" `shouldRespondWith`- [json| [{"a":null}, {"a":"3"}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can get array in object keys" $ do- get "/json_arr?select=data->c->>0::json&id=in.(7,8)" `shouldRespondWith`- [json| [{"c":1}, {"c":{"d": [4,5,6,7,8]}}] |]- { matchHeaders = [matchContentTypeJson] }- get "/json_arr?select=data->c->0->d->>4::int&id=in.(7,8)" `shouldRespondWith`- [json| [{"d":null}, {"d":8}] |]- { matchHeaders = [matchContentTypeJson] }-- it "only treats well formed numbers as indexes" $- get "/json_arr?select=data->0->0xy1->1->23-xy-45->1->xy-6->>0::int&id=eq.9" `shouldRespondWith`- [json| [{"xy-6":3}] |]- { matchHeaders = [matchContentTypeJson] }-- context "finishing json path with single arrow ->" $ do- it "works when finishing with a key" $ do- get "/json_arr?select=data->c&id=in.(7,8)" `shouldRespondWith`- [json| [{"c":[1,2,3]}, {"c":[{"d": [4,5,6,7,8]}]}] |]- { matchHeaders = [matchContentTypeJson] }- get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`- [json| [{"a":"A"}, {"a":[1,2,3]}] |]- { matchHeaders = [matchContentTypeJson] }-- it "works when finishing with an index" $ do- get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`- [json| [{"a":"A"}, {"a":[1,2,3]}] |]- { matchHeaders = [matchContentTypeJson] }- get "/json_arr?select=data->c->0->d&id=eq.8" `shouldRespondWith`- [json| [{"d":[4,5,6,7,8]}] |]- { matchHeaders = [matchContentTypeJson] }-- context "filtering response" $ do- it "can filter by properties inside json column" $ do- get "/json_table?data->foo->>bar=eq.baz" `shouldRespondWith`- [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]- { matchHeaders = [matchContentTypeJson] }- get "/json_table?data->foo->>bar=eq.fake" `shouldRespondWith`- [json| [] |]- { matchHeaders = [matchContentTypeJson] }-- it "can filter by properties inside json column using not" $- get "/json_table?data->foo->>bar=not.eq.baz" `shouldRespondWith`- [json| [] |]- { matchHeaders = [matchContentTypeJson] }-- it "can filter by properties inside json column using ->>" $- get "/json_table?data->>id=eq.1" `shouldRespondWith`- [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can be filtered with and/or" $- get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`- [json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }-- it "can filter by array indexes" $ do- get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`- [json| [{"data":[1, 2, 3]}] |]- { matchHeaders = [matchContentTypeJson] }- get "/json_arr?select=data&data->1->>2=eq.13" `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 "can filter jsonb" $ do- get "/jsonb_test?data=eq.{\"e\":1}" `shouldRespondWith`- [json| [{"id":4,"data":{"e": 1}}] |]- { matchHeaders = [matchContentTypeJson] }- get "/jsonb_test?data->a=eq.{\"b\":2}" `shouldRespondWith`- [json| [{"id":1,"data":{"a": {"b": 2}}}] |]- { matchHeaders = [matchContentTypeJson] }- get "/jsonb_test?data->c=eq.[1,2,3]" `shouldRespondWith`- [json| [{"id":2,"data":{"c": [1, 2, 3]}}] |]- { matchHeaders = [matchContentTypeJson] }- get "/jsonb_test?data->0=eq.{\"d\":\"test\"}" `shouldRespondWith`- [json| [{"id":3,"data":[{"d": "test"}]}] |]- { matchHeaders = [matchContentTypeJson] }-- context "ordering response" $ do- it "orders by a json column property asc" $- 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_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- request methodPatch "/json_table?data->>id=eq.3"- [("Prefer", "return=representation")]- [json| { "data": { "id":" \"escaped" } } |]- `shouldRespondWith`- [json| [{ "data": { "id":" \"escaped" } }] |]-- 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/LegacyGucsSpec.hs
@@ -1,68 +0,0 @@-module Feature.LegacyGucsSpec 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)-import SpecHelper--spec :: SpecWith ((), Application)-spec =- describe "remote procedure call with legacy gucs disabled" $ do- it "custom header is set" $- request methodPost "/rpc/get_guc_value" [("Custom-Header", "test")]- [json| { "prefix": "request.headers", "name": "custom-header" } |]- `shouldRespondWith`- [json|"test"|]- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson ]- }-- it "standard header is set" $- request methodPost "/rpc/get_guc_value" [("Origin", "http://example.com")]- [json| { "prefix": "request.headers", "name": "origin" } |]- `shouldRespondWith`- [json|"http://example.com"|]- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson ]- }-- it "current role is available as GUC claim" $- request methodPost "/rpc/get_guc_value" []- [json| { "prefix": "request.jwt.claims", "name": "role" } |]- `shouldRespondWith`- [json|"postgrest_test_anonymous"|]- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson ]- }-- it "single cookie ends up as claims" $- request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]- [json| {"prefix": "request.cookies", "name":"acookie"} |]- `shouldRespondWith`- [json|"cookievalue"|]- { matchStatus = 200- , matchHeaders = []- }-- it "multiple cookies ends up as claims" $- request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]- [json| {"prefix": "request.cookies", "name":"secondcookie"} |]- `shouldRespondWith`- [json|"anothervalue"|]- { matchStatus = 200- , matchHeaders = []- }-- it "gets the Authorization value" $- request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]- [json| {"prefix": "request.headers", "name":"authorization"} |]- `shouldRespondWith`- [json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]- { matchStatus = 200- , matchHeaders = []- }
− test/Feature/MultipleSchemaSpec.hs
@@ -1,331 +0,0 @@-module Feature.MultipleSchemaSpec 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 (simpleHeaders), simpleBody)--import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import Protolude-import SpecHelper--spec :: SpecWith ((), Application)-spec =- describe "multiple schemas in single instance" $ do- context "Reading tables on different schemas" $ do- it "succeeds in reading table from default schema v1 if no schema is selected via header" $- request methodGet "/parents" [] "" `shouldRespondWith`- [json|[- {"id":1,"name":"parent v1-1"},- {"id":2,"name":"parent v1-2"}- ]|]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]- }-- it "succeeds in reading table from default schema v1 after explicitly passing it in the header" $- request methodGet "/parents" [("Accept-Profile", "v1")] "" `shouldRespondWith`- [json|[- {"id":1,"name":"parent v1-1"},- {"id":2,"name":"parent v1-2"}- ]|]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]- }-- it "succeeds in reading table from schema v2" $- request methodGet "/parents" [("Accept-Profile", "v2")] "" `shouldRespondWith`- [json|[- {"id":3,"name":"parent v2-3"},- {"id":4,"name":"parent v2-4"}- ]|]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]- }-- it "succeeds in reading another_table from schema v2" $- request methodGet "/another_table" [("Accept-Profile", "v2")] "" `shouldRespondWith`- [json|[- {"id":5,"another_value":"value 5"},- {"id":6,"another_value":"value 6"}- ]|]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]- }-- it "doesn't find another_table in schema v1" $- request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404-- it "fails trying to read table from unkown schema" $- request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`- [json|{"message":"The schema must be one of the following: v1, v2"}|]- {- matchStatus = 406- }-- context "Inserting tables on different schemas" $ do- it "succeeds inserting on default schema and returning it" $- 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|{"id": 0, "name": "child v1-2", "parent_id": 2}|]- `shouldRespondWith`- [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|{"id": 0, "name": "child v2-3", "parent_id": 3}|]- `shouldRespondWith`- [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")]- [json|{"name": "child 4", "parent_id": 4}|]- `shouldRespondWith`- [json|{"message":"The schema must be one of the following: v1, v2"}|]- {- matchStatus = 406- }-- context "calling procs on different schemas" $ do- it "succeeds in calling the default schema proc" $- request methodGet "/rpc/get_parents_below?id=6" [] ""- `shouldRespondWith`- [json|[{"id":1,"name":"parent v1-1"}, {"id":2,"name":"parent v1-2"}]|]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]- }-- it "succeeds in calling the v1 schema proc and embedding" $- request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v1")] ""- `shouldRespondWith`- [json| [- {"id":1,"name":"parent v1-1","children":[{"id":1,"name":"child v1-1"}]},- {"id":2,"name":"parent v1-2","children":[{"id":2,"name":"child v1-2"}]}] |]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]- }-- it "succeeds in calling the v2 schema proc and embedding" $- request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v2")] ""- `shouldRespondWith`- [json| [- {"id":3,"name":"parent v2-3","children":[{"id":1,"name":"child v2-3"}]},- {"id":4,"name":"parent v2-4","children":[]}] |]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]- }-- it "succeeds in calling the v2 schema proc with POST by using Content-Profile" $- request methodPost "/rpc/get_parents_below?select=id,name" [("Content-Profile", "v2")]- [json|{"id": "6"}|]- `shouldRespondWith`- [json| [- {"id":3,"name":"parent v2-3"},- {"id":4,"name":"parent v2-4"}]|]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]- }-- context "Modifying tables on different schemas" $ do- it "succeeds in patching on the v1 schema and returning its parent" $- request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]- [json|{"name": "child v1-1 updated"}|]- `shouldRespondWith`- [json|[{"name":"child v1-1 updated", "parent": {"name": "parent v1-1"}}]|]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]- }-- it "succeeds in patching on the v2 schema and returning its parent" $- request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")]- [json|{"name": "child v2-1 updated"}|]- `shouldRespondWith`- [json|[{"name":"child v2-1 updated", "parent": {"name": "parent v2-3"}}]|]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]- }-- 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-3", "parent_id": 3}]|]- { matchHeaders = ["Content-Profile" <:> "v2"] }-- it "succeeds on PUT on the v2 schema" $- request methodPut "/children?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]- [json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|]- `shouldRespondWith`- [json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|]- {- matchStatus = 200- , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]- }-- context "OpenAPI output" $ do- it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do- req <- request methodGet "/" [] ""-- liftIO $ do- simpleHeaders req `shouldSatisfy` matchHeader "Content-Profile" "v1"-- let def = simpleBody req ^? key "definitions" . key "parents"-- def `shouldBe` Just- [aesonQQ|- {- "type" : "object",- "properties" : {- "id" : {- "description" : "Note:\nThis is a Primary Key.<pk/>",- "format" : "integer",- "type" : "integer"- },- "name" : {- "format" : "text",- "type" : "string"- }- },- "required" : [- "id"- ]- }- |]-- it "succeeds in reading table definition from default schema v1 after explicitly passing it in the header" $ do- r <- request methodGet "/" [("Accept-Profile", "v1")] ""-- liftIO $ do- simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v1"-- let def = simpleBody r ^? key "definitions" . key "parents"-- def `shouldBe` Just- [aesonQQ|- {- "type" : "object",- "properties" : {- "id" : {- "description" : "Note:\nThis is a Primary Key.<pk/>",- "format" : "integer",- "type" : "integer"- },- "name" : {- "format" : "text",- "type" : "string"- }- },- "required" : [- "id"- ]- }- |]-- it "succeeds in reading table definition from schema v2" $ do- r <- request methodGet "/" [("Accept-Profile", "v2")] ""-- liftIO $ do- simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"-- let def = simpleBody r ^? key "definitions" . key "parents"-- def `shouldBe` Just- [aesonQQ|- {- "type" : "object",- "properties" : {- "id" : {- "description" : "Note:\nThis is a Primary Key.<pk/>",- "format" : "integer",- "type" : "integer"- },- "name" : {- "format" : "text",- "type" : "string"- }- },- "required" : [- "id"- ]- }- |]-- it "succeeds in reading another_table definition from schema v2" $ do- r <- request methodGet "/" [("Accept-Profile", "v2")] ""-- liftIO $ do- simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"-- let def = simpleBody r ^? key "definitions" . key "another_table"-- def `shouldBe` Just- [aesonQQ|- {- "type" : "object",- "properties" : {- "id" : {- "description" : "Note:\nThis is a Primary Key.<pk/>",- "format" : "integer",- "type" : "integer"- },- "another_value" : {- "format" : "text",- "type" : "string"- }- },- "required" : [- "id"- ]- }- |]-- it "doesn't find another_table definition in schema v1" $ do- r <- request methodGet "/" [("Accept-Profile", "v1")] ""-- liftIO $ do- let def = simpleBody r ^? key "definitions" . key "another_table"- def `shouldBe` Nothing-- it "fails trying to read definitions from unkown schema" $- request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith`- [json|{"message":"The schema must be one of the following: v1, v2"}|]- {- matchStatus = 406- }
− test/Feature/NoJwtSpec.hs
@@ -1,31 +0,0 @@-module Feature.NoJwtSpec where---- {{{ Imports--import Network.Wai (Application)--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import Protolude-import SpecHelper--- }}}--spec :: SpecWith ((), Application)-spec = describe "server started without JWT secret" $ do-- -- this test will stop working 9999999999s after the UNIX EPOCH- it "responds with error on attempted auth" $ do- let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"- request methodGet "/authors_only" [auth] ""- `shouldRespondWith`- [json|{"message":"Server lacks JWT secret"}|]- { matchStatus = 500- , matchHeaders = [ matchContentTypeJson ]- }-- it "behaves normally when user does not attempt auth" $- request methodGet "/items" [] ""- `shouldRespondWith` 200
− test/Feature/NonexistentSchemaSpec.hs
@@ -1,17 +0,0 @@-module Feature.NonexistentSchemaSpec where--import Network.Wai (Application)--import Test.Hspec-import Test.Hspec.Wai--import Protolude hiding (get)--spec :: SpecWith ((), Application)-spec =- describe "Non existent api schema" $ do- it "succeeds when requesting root path" $- get "/" `shouldRespondWith` 200-- it "gives 404 when requesting a nonexistent table in this nonexistent schema" $- get "/nonexistent_table" `shouldRespondWith` 404
− test/Feature/OpenApiSpec.hs
@@ -1,589 +0,0 @@-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.Config.PgVersion (PgVersion, pgVersion100,- pgVersion110)--import PostgREST.Version (docsVersion)-import Protolude hiding (get)-import SpecHelper--spec :: PgVersion -> SpecWith ((), Application)-spec actualPgVersion = 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" }- ]- |]-- when (actualPgVersion >= pgVersion100) $ do- describe "Partitioned table" $-- it "includes partitioned table properties" $ do- r <- simpleBody <$> get "/"-- let method s = key "paths" . key "/car_models" . key s- getSummary = r ^? method "get" . key "summary"- getDescription = r ^? method "get" . key "description"- getParameterName = r ^? method "get" . key "parameters" . nth 0 . key "$ref"- getParameterYear = r ^? method "get" . key "parameters" . nth 1 . key "$ref"- getParameterRef = r ^? method "get" . key "parameters" . nth 2 . key "$ref"-- liftIO $ do-- getSummary `shouldBe` Just "A partitioned table"-- getDescription `shouldBe` Just "A test for partitioned tables"-- getParameterName `shouldBe` Just "#/parameters/rowFilter.car_models.name"-- getParameterYear `shouldBe` Just "#/parameters/rowFilter.car_models.year"-- when (actualPgVersion >= pgVersion110) $- getParameterRef `shouldBe` Just "#/parameters/rowFilter.car_models.car_brand_name"-- 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 "Detects default values" $ do-- it "text" $ do- r <- simpleBody <$> get "/"-- let defaultValue = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "text" . key "default"-- liftIO $-- defaultValue `shouldBe` Just "default"-- it "boolean" $ do- r <- simpleBody <$> get "/"-- let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "boolean" . key "default"-- liftIO $-- types `shouldBe` Just (Bool False)-- it "integer" $ do- r <- simpleBody <$> get "/"-- let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "integer" . key "default"-- liftIO $-- types `shouldBe` Just (Number 42)-- it "numeric" $ do- r <- simpleBody <$> get "/"-- let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "numeric" . key "default"-- liftIO $-- types `shouldBe` Just (Number 42.2)-- it "date" $ do- r <- simpleBody <$> get "/"-- let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "date" . key "default"-- liftIO $-- types `shouldBe` Just "1900-01-01"-- it "time" $ do- r <- simpleBody <$> get "/"-- let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "time" . key "default"-- liftIO $-- types `shouldBe` Just "13:00:00"-- 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
@@ -1,87 +0,0 @@-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 PostgREST.Config.PgVersion (PgVersion, pgVersion100,- pgVersion110)--import Protolude-import SpecHelper--spec :: PgVersion -> SpecWith ((), Application)-spec actualPgVersion = 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"-- when (actualPgVersion >= pgVersion100) $- context "a partitioned table" $ do- it "includes read/write verbs for writeable partitioned tables" $ do- r <- request methodOptions "/car_models" [] ""- liftIO $- simpleHeaders r `shouldSatisfy`- matchHeader "Allow" (- if actualPgVersion >= pgVersion110 then- "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"- else- "OPTIONS,GET,HEAD,POST,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/ProxySpec.hs
@@ -1,13 +0,0 @@-module Feature.ProxySpec where--import Network.Wai (Application)-import Test.Hspec hiding (pendingWith)--import Protolude-import SpecHelper--spec :: SpecWith ((), Application)-spec =- describe "GET / with proxy" $- it "returns a valid openapi spec with proxy" $- validateOpenApiResponse [("Accept", "application/openapi+json")]
− test/Feature/QueryLimitedSpec.hs
@@ -1,69 +0,0 @@-module Feature.QueryLimitedSpec 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)-import SpecHelper--spec :: SpecWith ((), Application)-spec =- describe "Requesting many items with server limits(max-rows) enabled" $ do- it "restricts results" $- get "/items?order=id"- `shouldRespondWith`- [json| [{"id":1},{"id":2}] |]- { matchHeaders = ["Content-Range" <:> "0-1/*"] }-- it "respects additional client limiting" $ do- 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}]}]|]- { 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}}]|]- { 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}]|]- { 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"]- }-- it "gives exact count when query rows <= maxRows" $- request methodHead "/getallprojects_view?id=lt.3" [("Prefer", "count=estimated")] ""- `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"]- }
− test/Feature/QuerySpec.hs
@@ -1,1108 +0,0 @@-module Feature.QuerySpec where--import Network.Wai (Application)-import Network.Wai.Test (SResponse (simpleHeaders))--import Network.HTTP.Types-import Test.Hspec hiding (pendingWith)-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import PostgREST.Config.PgVersion (PgVersion, pgVersion110,- pgVersion112, pgVersion121)-import Protolude hiding (get)-import SpecHelper--spec :: PgVersion -> SpecWith ((), Application)-spec actualPgVersion = do-- describe "Querying a table with a column called count" $- it "should not confuse count column with pg_catalog.count aggregate" $- get "/has_count_column" `shouldRespondWith` 200-- describe "Querying a table with a column called t" $- it "should not conflict with internal postgrest table alias" $- get "/clashing_column?select=t" `shouldRespondWith` 200-- describe "Querying a nonexistent table" $- it "causes a 404" $- get "/faketable" `shouldRespondWith` 404-- describe "Filtering response" $ do- it "matches with equality" $- get "/items?id=eq.5"- `shouldRespondWith` [json| [{"id":5}] |]- { matchHeaders = ["Content-Range" <:> "0-0/*"] }-- it "matches with equality using not operator" $- 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/*"] }-- it "matches with more than one condition using not operator" $- get "/simple_pk?k=like.*yx&extra=not.eq.u" `shouldRespondWith` "[]"-- it "matches with inequality using not operator" $ do- get "/items?id=not.lt.14&order=id.asc"- `shouldRespondWith` [json| [{"id":14},{"id":15}] |]- { matchHeaders = ["Content-Range" <:> "0-1/*"] }- get "/items?id=not.gt.2&order=id.asc"- `shouldRespondWith` [json| [{"id":1},{"id":2}] |]- { matchHeaders = ["Content-Range" <:> "0-1/*"] }-- it "matches items IN" $- get "/items?id=in.(1,3,5)"- `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]- { matchHeaders = ["Content-Range" <:> "0-2/*"] }-- it "matches items NOT IN using not operator" $- get "/items?id=not.in.(2,4,6,7,8,9,10,11,12,13,14,15)"- `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]- { matchHeaders = ["Content-Range" <:> "0-2/*"] }-- it "matches nulls using not operator" $- get "/no_pk?a=not.is.null" `shouldRespondWith`- [json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]- { matchHeaders = [matchContentTypeJson] }-- it "matches nulls in varchar and numeric fields alike" $ do- get "/no_pk?a=is.null" `shouldRespondWith`- [json| [{"a": null, "b": null}] |]- { matchHeaders = [matchContentTypeJson] }-- get "/nullable_integer?a=is.null" `shouldRespondWith` [json|[{"a":null}]|]-- it "matches with trilean values" $ do- get "/chores?done=is.true" `shouldRespondWith`- [json| [{"id": 1, "name": "take out the garbage", "done": true }] |]- { matchHeaders = [matchContentTypeJson] }-- get "/chores?done=is.false" `shouldRespondWith`- [json| [{"id": 2, "name": "do the laundry", "done": false }] |]- { matchHeaders = [matchContentTypeJson] }-- get "/chores?done=is.unknown" `shouldRespondWith`- [json| [{"id": 3, "name": "wash the dishes", "done": null }] |]- { matchHeaders = [matchContentTypeJson] }-- it "fails if 'is' used and there's no null or trilean value" $ do- get "/chores?done=is.nil" `shouldRespondWith` 400- get "/chores?done=is.ok" `shouldRespondWith` 400-- it "matches with like" $ do- get "/simple_pk?k=like.*yx" `shouldRespondWith`- [json|[{"k":"xyyx","extra":"u"}]|]- get "/simple_pk?k=like.xy*" `shouldRespondWith`- [json|[{"k":"xyyx","extra":"u"}]|]- get "/simple_pk?k=like.*YY*" `shouldRespondWith`- [json|[{"k":"xYYx","extra":"v"}]|]-- it "matches with like using not operator" $- get "/simple_pk?k=not.like.*yx" `shouldRespondWith`- [json|[{"k":"xYYx","extra":"v"}]|]-- it "matches with ilike" $ do- get "/simple_pk?k=ilike.xy*&order=extra.asc" `shouldRespondWith`- [json|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]- { matchHeaders = [matchContentTypeJson] }- get "/simple_pk?k=ilike.*YY*&order=extra.asc" `shouldRespondWith`- [json|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]- { matchHeaders = [matchContentTypeJson] }-- it "matches with ilike using not operator" $- get "/simple_pk?k=not.ilike.xy*&order=extra.asc" `shouldRespondWith` "[]"-- describe "Full text search operator" $ do- it "finds matches with to_tsquery" $- get "/tsearch?text_search_vector=fts.impossible" `shouldRespondWith`- [json| [{"text_search_vector": "'fun':5 'imposs':9 'kind':3" }] |]- { matchHeaders = [matchContentTypeJson] }-- it "can use lexeme boolean operators(&=%26, |=%7C, !) in to_tsquery" $ do- get "/tsearch?text_search_vector=fts.fun%26possible" `shouldRespondWith`- [json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]- { matchHeaders = [matchContentTypeJson] }- get "/tsearch?text_search_vector=fts.impossible%7Cpossible" `shouldRespondWith`- [json| [- {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},- {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]- { matchHeaders = [matchContentTypeJson] }- get "/tsearch?text_search_vector=fts.fun%26!possible" `shouldRespondWith`- [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]- { matchHeaders = [matchContentTypeJson] }-- it "finds matches with plainto_tsquery" $- get "/tsearch?text_search_vector=plfts.The%20Fat%20Rats" `shouldRespondWith`- [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]- { matchHeaders = [matchContentTypeJson] }-- when (actualPgVersion >= pgVersion112) $ do- it "finds matches with websearch_to_tsquery" $- get "/tsearch?text_search_vector=wfts.The%20Fat%20Rats" `shouldRespondWith`- [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]- { matchHeaders = [matchContentTypeJson] }-- it "can use boolean operators(and, or, -) in websearch_to_tsquery" $ do- get "/tsearch?text_search_vector=wfts.fun%20and%20possible"- `shouldRespondWith`- [json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]- { matchHeaders = [matchContentTypeJson] }- get "/tsearch?text_search_vector=wfts.impossible%20or%20possible"- `shouldRespondWith`- [json| [- {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},- {"text_search_vector": "'also':2 'fun':3 'possibl':8"}]- |]- { matchHeaders = [matchContentTypeJson] }- get "/tsearch?text_search_vector=wfts.fun%20and%20-possible"- `shouldRespondWith`- [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]- { matchHeaders = [matchContentTypeJson] }-- it "finds matches with different dictionaries" $ do- get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith`- [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]- { matchHeaders = [matchContentTypeJson] }- get "/tsearch?text_search_vector=plfts(french).amusant%20impossible" `shouldRespondWith`- [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]- { matchHeaders = [matchContentTypeJson] }-- when (actualPgVersion >= pgVersion112) $- get "/tsearch?text_search_vector=wfts(french).amusant%20impossible"- `shouldRespondWith`- [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]- { matchHeaders = [matchContentTypeJson] }-- it "can be negated with not operator" $ do- get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith`- [json| [- {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},- {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]- { matchHeaders = [matchContentTypeJson] }- get "/tsearch?text_search_vector=not.fts(english).impossible%7Cfat%7Cfun" `shouldRespondWith`- [json| [- {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},- {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]- { matchHeaders = [matchContentTypeJson] }- get "/tsearch?text_search_vector=not.plfts.The%20Fat%20Rats" `shouldRespondWith`- [json| [- {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},- {"text_search_vector": "'also':2 'fun':3 'possibl':8"},- {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},- {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]- { matchHeaders = [matchContentTypeJson] }- when (actualPgVersion >= pgVersion112) $- get "/tsearch?text_search_vector=not.wfts(english).impossible%20or%20fat%20or%20fun"- `shouldRespondWith`- [json| [- {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},- {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]- { matchHeaders = [matchContentTypeJson] }-- 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}] |]- { matchHeaders = [matchContentTypeJson] }-- it "order by computed column" $- get "/items?order=anti_id.desc" `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 = [matchContentTypeJson] }-- it "cannot access a computed column that is outside of the config schema" $- 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| {- "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]- }-- it "matches filtering nested items" $- get "/clients?select=id,projects(id,tasks(id,name))&projects.tasks.name=like.Design*" `shouldRespondWith`- [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1,"name":"Design w7"}]},{"id":2,"tasks":[{"id":3,"name":"Design w10"}]}]},{"id":2,"projects":[{"id":3,"tasks":[{"id":5,"name":"Design IOS"}]},{"id":4,"tasks":[{"id":7,"name":"Design OSX"}]}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "matches with cs operator" $- get "/complex_items?select=id&arr_data=cs.{2}" `shouldRespondWith`- [json|[{"id":2},{"id":3}]|]- { matchHeaders = [matchContentTypeJson] }-- it "matches with cd operator" $- get "/complex_items?select=id&arr_data=cd.{1,2,4}" `shouldRespondWith`- [json|[{"id":1},{"id":2}]|]- { matchHeaders = [matchContentTypeJson] }-- describe "Shaping response with select parameter" $ do- it "selectStar works in absense of parameter" $- get "/complex_items?id=eq.3" `shouldRespondWith`- [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`- [json|[{"id":3,"field-with_sep":1}]|]-- it "one simple column" $- get "/complex_items?select=id" `shouldRespondWith`- [json| [{"id":1},{"id":2},{"id":3}] |]- { matchHeaders = [matchContentTypeJson] }-- it "rename simple column" $- get "/complex_items?id=eq.1&select=myId:id" `shouldRespondWith`- [json| [{"myId":1}] |]- { matchHeaders = [matchContentTypeJson] }-- it "one simple column with casting (text)" $- get "/complex_items?select=id::text" `shouldRespondWith`- [json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]- { matchHeaders = [matchContentTypeJson] }-- it "rename simple column with casting" $- get "/complex_items?id=eq.1&select=myId:id::text" `shouldRespondWith`- [json| [{"myId":"1"}] |]- { matchHeaders = [matchContentTypeJson] }-- it "json column" $- get "/complex_items?id=eq.1&select=settings" `shouldRespondWith`- [json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "fails on bad casting (wrong cast type)" $- get "/complex_items?select=id::fakecolumntype"- `shouldRespondWith` [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |]- { matchStatus = 400- , matchHeaders = []- }-- it "requesting parents and children" $- get "/projects?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "requesting parent and renaming primary key" $- get "/projects?select=name,client:clients(clientId:id,name)" `shouldRespondWith`- [json|[- {"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},- {"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},- {"name":"IOS","client":{"name": "Apple", "clientId": 2}},- {"name":"OSX","client":{"name": "Apple", "clientId": 2}},- {"name":"Orphan","client":null}- ]|]- { matchHeaders = [matchContentTypeJson] }-- it "requesting parent and specifying/renaming one key of the composite primary key" $ do- get "/comments?select=*,users_tasks(userId:user_id)" `shouldRespondWith`- [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"userId": 2}}]|]- { matchHeaders = [matchContentTypeJson] }- get "/comments?select=*,users_tasks(taskId:task_id)" `shouldRespondWith`- [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]- { matchHeaders = [matchContentTypeJson] }-- it "requesting parents and children while renaming them" $- get "/projects?id=eq.1&select=myId:id, name, project_client:clients(*), project_tasks:tasks(id, name)" `shouldRespondWith`- [json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "requesting parents and filtering parent columns" $- get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`- [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`- [json|[{"id":1,"clients":{"id":1}},{"id":5,"clients":null}]|]- { matchHeaders = [matchContentTypeJson] }-- it "rows with no children return [] instead of null" $- get "/projects?id=in.(5)&select=id,tasks(id)" `shouldRespondWith`- [json|[{"id":5,"tasks":[]}]|]-- it "requesting children 2 levels" $- get "/clients?id=eq.1&select=id,projects(id,tasks(id))" `shouldRespondWith`- [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "requesting many<->many relation" $- get "/tasks?select=id,users(id)" `shouldRespondWith`- [json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "requesting many<->many relation with rename" $- get "/tasks?id=eq.1&select=id,theUsers:users(id)" `shouldRespondWith`- [json|[{"id":1,"theUsers":[{"id":1},{"id":3}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "requesting many<->many relation reverse" $- get "/users?select=id,tasks(id)" `shouldRespondWith`- [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"}]}]|]- { matchHeaders = [matchContentTypeJson] }-- describe "computed columns" $ do- it "computed column on table" $- get "/items?id=eq.1&select=id,always_true" `shouldRespondWith`- [json|[{"id":1,"always_true":true}]|]- { matchHeaders = [matchContentTypeJson] }-- it "computed column on rpc" $- get "/rpc/search?id=1&select=id,always_true" `shouldRespondWith`- [json|[{"id":1,"always_true":true}]|]- { matchHeaders = [matchContentTypeJson] }-- it "overloaded computed columns on both tables" $ do- get "/items?id=eq.1&select=id,computed_overload" `shouldRespondWith`- [json|[{"id":1,"computed_overload":true}]|]- { matchHeaders = [matchContentTypeJson] }- get "/items2?id=eq.1&select=id,computed_overload" `shouldRespondWith`- [json|[{"id":1,"computed_overload":true}]|]- { matchHeaders = [matchContentTypeJson] }-- it "overloaded computed column on rpc" $- get "/rpc/search?id=1&select=id,computed_overload" `shouldRespondWith`- [json|[{"id":1,"computed_overload":true}]|]- { matchHeaders = [matchContentTypeJson] }-- when (actualPgVersion >= pgVersion110) $ do- describe "partitioned tables embedding" $ do- it "can request a table as parent from a partitioned table" $- get "/car_models?name=in.(DeLorean,Murcielago)&select=name,year,car_brands(name)&order=name.asc" `shouldRespondWith`- [json|- [{"name":"DeLorean","year":1981,"car_brands":{"name":"DMC"}},- {"name":"Murcielago","year":2001,"car_brands":{"name":"Lamborghini"}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can request partitioned tables as children from a table" $- get "/car_brands?select=name,car_models(name,year)&order=name.asc&car_models.order=name.asc" `shouldRespondWith`- [json|- [{"name":"DMC","car_models":[{"name":"DeLorean","year":1981}]},- {"name":"Ferrari","car_models":[{"name":"F310-B","year":1997}]},- {"name":"Lamborghini","car_models":[{"name":"Murcielago","year":2001},{"name":"Veneno","year":2013}]}] |]- { matchHeaders = [matchContentTypeJson] }-- when (actualPgVersion >= pgVersion121) $ do- it "can request tables as children from a partitioned table" $- get "/car_models?name=in.(DeLorean,F310-B)&select=name,year,car_racers(name)&order=name.asc" `shouldRespondWith`- [json|- [{"name":"DeLorean","year":1981,"car_racers":[]},- {"name":"F310-B","year":1997,"car_racers":[{"name":"Michael Schumacher"}]}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can request a partitioned table as parent from a table" $- get "/car_racers?select=name,car_models(name,year)&order=name.asc" `shouldRespondWith`- [json|- [{"name":"Alain Prost","car_models":null},- {"name":"Michael Schumacher","car_models":{"name":"F310-B","year":1997}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can request partitioned tables as children from a partitioned table" $- get "/car_models?name=in.(DeLorean,Murcielago,Veneno)&select=name,year,car_model_sales(date,quantity)&order=name.asc" `shouldRespondWith`- [json|- [{"name":"DeLorean","year":1981,"car_model_sales":[{"date":"2021-01-14","quantity":7},{"date":"2021-01-15","quantity":9}]},- {"name":"Murcielago","year":2001,"car_model_sales":[{"date":"2021-02-11","quantity":1},{"date":"2021-02-12","quantity":3}]},- {"name":"Veneno","year":2013,"car_model_sales":[]}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can request a partitioned table as parent from a partitioned table" $ do- get "/car_model_sales?date=in.(2021-01-15,2021-02-11)&select=date,quantity,car_models(name,year)&order=date.asc" `shouldRespondWith`- [json|- [{"date":"2021-01-15","quantity":9,"car_models":{"name":"DeLorean","year":1981}},- {"date":"2021-02-11","quantity":1,"car_models":{"name":"Murcielago","year":2001}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can request many to many relationships between partitioned tables ignoring the intermediate table partitions" $- get "/car_models?select=name,year,car_dealers(name,city)&order=name.asc&limit=4" `shouldRespondWith`- [json|- [{"name":"DeLorean","year":1981,"car_dealers":[{"name":"Springfield Cars S.A.","city":"Springfield"}]},- {"name":"F310-B","year":1997,"car_dealers":[]},- {"name":"Murcielago","year":2001,"car_dealers":[{"name":"The Best Deals S.A.","city":"Franklin"}]},- {"name":"Veneno","year":2013,"car_dealers":[]}] |]- { matchStatus = 200- , matchHeaders = [matchContentTypeJson]- }-- it "cannot request partitions as children from a partitioned table" $- get "/car_models?id=in.(1,2,4)&select=id,name,car_model_sales_202101(id)&order=id.asc" `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 car_models and car_model_sales_202101 in the schema cache"} |]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- it "cannot request a partitioned table as parent from a partition" $- get "/car_model_sales_202101?select=id,name,car_models(id,name)&order=id.asc" `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 car_model_sales_202101 and car_models in the schema cache"} |]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- it "cannot request a partition as parent from a partitioned table" $- get "/car_model_sales?id=in.(1,3,4)&select=id,name,car_models_default(id,name)&order=id.asc" `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 car_model_sales and car_models_default in the schema cache"} |]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- it "cannot request partitioned tables as children from a partition" $- get "/car_models_default?select=id,name,car_model_sales(id,name)&order=id.asc" `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 car_models_default and car_model_sales in the schema cache"} |]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- describe "view embedding" $ do- it "can detect fk relations through views to tables in the public schema" $- get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200-- it "can detect fk relations through materialized views to tables in the public schema" $- get "/materialized_projects?select=*,users(*)" `shouldRespondWith` 200-- it "can request two parents" $- 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`- [json|[{"id":1,"articleStars":[{"users":{"id":1,"name":"Angela Martin"}},{"users":{"id":2,"name":"Michael Scott"}},{"users":{"id":3,"name":"Dwight Schrute"}}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "works when requesting parents and children on views" $- get "/projects_view?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "works when requesting parents and children on views with renamed keys" $- get "/projects_view_alt?t_id=eq.1&select=t_id, name, clients(*), tasks(id, name)" `shouldRespondWith`- [json|[{"t_id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "detects parent relations when having many views of a private table" $ do- get "/books?select=title,author:authors(name)&id=eq.5" `shouldRespondWith`- [json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|]- { matchHeaders = [matchContentTypeJson] }- get "/forties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`- [json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|]- { matchHeaders = [matchContentTypeJson] }- get "/fifties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`- [json|[ { "title": "The Catcher in the Rye", "author": { "name": "J.D. Salinger" } } ]|]- { matchHeaders = [matchContentTypeJson] }- get "/sixties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`- [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,- "books":[{"title":"1984"}]} ]|]- { matchHeaders = [matchContentTypeJson] }-- it "works with views that have case subselects" $- get "/authors_have_book_in_decade?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"}]} ]|]- { matchHeaders = [matchContentTypeJson] }-- it "works with views that have subselect in the FROM clause" $- get "/forties_and_fifties_books?select=title,first_publisher,author:authors(name)&id=eq.1" `shouldRespondWith`- [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|[- {"title":"1984","publication_year":1949,"first_publisher":"Secker & Warburg","author":{"name":"George Orwell"}},- {"title":"The Diary of a Young Girl","publication_year":1947,"first_publisher":"Contact Publishing","author":{"name":"Anne Frank"}},- {"title":"The Little Prince","publication_year":1947,"first_publisher":"Reynal & Hitchcock","author":{"name":"Antoine de Saint-Exupéry"}} ]|]- { matchHeaders = [matchContentTypeJson] }-- it "works when having a capitalized table name and camelCase fk column" $- get "/foos?select=*,bars(*)" `shouldRespondWith` 200-- it "works when embedding a view with a table that has a long compound pk" $ do- get "/player_view?select=id,contract(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`- [json|- [{"id":1,"contract":[{"purchase_price":10}]},- {"id":3,"contract":[{"purchase_price":30}]},- {"id":5,"contract":[{"purchase_price":50}]},- {"id":7,"contract":[]}] |]- { matchHeaders = [matchContentTypeJson] }- get "/contract?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`- [json|- [{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},- {"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},- {"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "works when embedding a view with a view that referes to a table that has a long compound pk" $ do- get "/player_view?select=id,contract_view(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`- [json|- [{"id":1,"contract_view":[{"purchase_price":10}]},- {"id":3,"contract_view":[{"purchase_price":30}]},- {"id":5,"contract_view":[{"purchase_price":50}]},- {"id":7,"contract_view":[]}] |]- { matchHeaders = [matchContentTypeJson] }- get "/contract_view?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`- [json|- [{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},- {"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},- {"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can embed a view that has group by" $- get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith`- [json|- [{"number_of_projects":1,"client":null},- {"number_of_projects":2,"client":{"name":"Microsoft"}},- {"number_of_projects":2,"client":{"name":"Apple"}}] |]- { matchHeaders = [matchContentTypeJson] }-- it "can embed a view that has a subselect containing a select in a where" $- get "/authors_w_entities?select=name,entities,books(title)&id=eq.1" `shouldRespondWith`- [json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |]- { matchHeaders = [matchContentTypeJson] }-- describe "aliased embeds" $ do- it "works with child relation" $- get "/space?select=id,zones:zone(id,name),stores:zone(id,name)&zones.zone_type_id=eq.2&stores.zone_type_id=eq.3" `shouldRespondWith`- [json|[- { "id":1,- "zones": [ {"id":1,"name":"zone 1"}, {"id":2,"name":"zone 2"}],- "stores": [ {"id":3,"name":"store 3"}, {"id":4,"name":"store 4"}]}- ]|] { matchHeaders = [matchContentTypeJson] }-- it "works with many to many relation" $- get "/users?select=id,designTasks:tasks(id,name),codeTasks:tasks(id,name)&designTasks.name=like.*Design*&codeTasks.name=like.*Code*" `shouldRespondWith`- [json|[- { "id":1,- "designTasks":[ { "id":1, "name":"Design w7" }, { "id":3, "name":"Design w10" } ],- "codeTasks":[ { "id":2, "name":"Code w7" }, { "id":4, "name":"Code w10" } ] },- { "id":2,- "designTasks":[ { "id":5, "name":"Design IOS" }, { "id":7, "name":"Design OSX" } ],- "codeTasks":[ { "id":6, "name":"Code IOS" } ] },- { "id":3,- "designTasks":[ { "id":1, "name":"Design w7" }, { "id":5, "name":"Design IOS" } ],- "codeTasks":[ ] }- ]|] { matchHeaders = [matchContentTypeJson] }-- it "works with an aliased child plus non aliased child" $- get "/projects?select=id,name,designTasks:tasks(name,users(id,name))&designTasks.name=like.*Design*&designTasks.users.id=in.(1,2)" `shouldRespondWith`- [json|[- {- "id":1, "name":"Windows 7",- "designTasks":[ { "name":"Design w7", "users":[ { "id":1, "name":"Angela Martin" } ] } ] },- {- "id":2, "name":"Windows 10",- "designTasks":[ { "name":"Design w10", "users":[ { "id":1, "name":"Angela Martin" } ] } ] },- {- "id":3, "name":"IOS",- "designTasks":[ { "name":"Design IOS", "users":[ { "id":2, "name":"Michael Scott" } ] } ] },- {- "id":4, "name":"OSX",- "designTasks":[ { "name":"Design OSX", "users":[ { "id":2, "name":"Michael Scott" } ] } ] },- {- "id":5, "name":"Orphan",- "designTasks":[ ] }- ]|] { matchHeaders = [matchContentTypeJson] }-- it "works with two aliased children embeds plus and/or" $- get "/entities?select=id,children:child_entities(id,gChildren:grandchild_entities(id))&children.and=(id.in.(1,2,3))&children.gChildren.or=(id.eq.1,id.eq.2)" `shouldRespondWith`- [json|[- { "id":1,- "children":[- {"id":1,"gChildren":[{"id":1}, {"id":2}]},- {"id":2,"gChildren":[]}]},- { "id":2,- "children":[- {"id":3,"gChildren":[]}]},- { "id":3,"children":[]},- { "id":4,"children":[]}- ]|] { matchHeaders = [matchContentTypeJson] }-- describe "ordering response" $ do- it "by a column asc" $- get "/items?id=lte.2&order=id.asc"- `shouldRespondWith` [json| [{"id":1},{"id":2}] |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-1/*"]- }--- it "by a column desc" $- get "/items?id=lte.2&order=id.desc"- `shouldRespondWith` [json| [{"id":2},{"id":1}] |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-1/*"]- }-- it "by a column with nulls first" $- get "/no_pk?order=a.nullsfirst"- `shouldRespondWith` [json| [{"a":null,"b":null},- {"a":"1","b":"0"},- {"a":"2","b":"0"}- ] |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-2/*"]- }-- it "by a column asc with nulls last" $- get "/no_pk?order=a.asc.nullslast"- `shouldRespondWith` [json| [{"a":"1","b":"0"},- {"a":"2","b":"0"},- {"a":null,"b":null}] |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-2/*"]- }-- it "by a column desc with nulls first" $- get "/no_pk?order=a.desc.nullsfirst"- `shouldRespondWith` [json| [{"a":null,"b":null},- {"a":"2","b":"0"},- {"a":"1","b":"0"}] |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-2/*"]- }-- it "by a column desc with nulls last" $- get "/no_pk?order=a.desc.nullslast"- `shouldRespondWith` [json| [{"a":"2","b":"0"},- {"a":"1","b":"0"},- {"a":null,"b":null}] |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-2/*"]- }-- it "by two columns with nulls and direction specified" $- get "/projects?select=client_id,id,name&order=client_id.desc.nullslast,id.desc"- `shouldRespondWith` [json|- [{"client_id":2,"id":4,"name":"OSX"},- {"client_id":2,"id":3,"name":"IOS"},- {"client_id":1,"id":2,"name":"Windows 10"},- {"client_id":1,"id":1,"name":"Windows 7"},- {"client_id":null,"id":5,"name":"Orphan"}]- |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-4/*"]- }-- it "by a column with no direction or nulls specified" $- get "/items?id=lte.2&order=id"- `shouldRespondWith` [json| [{"id":1},{"id":2}] |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-1/*"]- }-- it "without other constraints" $- get "/items?order=id.asc" `shouldRespondWith` 200-- it "ordering embeded entities" $- get "/projects?id=eq.1&select=id, name, tasks(id, name)&tasks.order=name.asc" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "ordering embeded entities with alias" $- get "/projects?id=eq.1&select=id, name, the_tasks:tasks(id, name)&tasks.order=name.asc" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","the_tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "ordering embeded entities, two levels" $- get "/projects?id=eq.1&select=id, name, tasks(id, name, users(id, name))&tasks.order=name.asc&tasks.users.order=name.desc" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7","users":[{"id":1,"name":"Angela Martin"}]},{"id":1,"name":"Design w7","users":[{"id":3,"name":"Dwight Schrute"},{"id":1,"name":"Angela Martin"}]}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "ordering embeded parents does not break things" $- get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith`- [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- get "/items?order=id.ac" `shouldRespondWith`- [json|{"details":"unexpected \"c\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.ac)\" (line 1, column 4)"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }- get "/items?order=id.descc" `shouldRespondWith`- [json|{"details":"unexpected 'c' expecting delimiter (.), \",\" or end of input","message":"\"failed to parse order (id.descc)\" (line 1, column 8)"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }- get "/items?order=id.nulsfist" `shouldRespondWith`- [json|{"details":"unexpected \"n\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.nulsfist)\" (line 1, column 4)"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }- get "/items?order=id.nullslasttt" `shouldRespondWith`- [json|{"details":"unexpected 't' expecting \",\" or end of input","message":"\"failed to parse order (id.nullslasttt)\" (line 1, column 13)"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }- get "/items?order=id.smth34" `shouldRespondWith`- [json|{"details":"unexpected \"s\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.smth34)\" (line 1, column 4)"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- it "gives meaningful error messages when nulls{first,last} are misspelled after asc/desc" $ do- get "/items?order=id.asc.nlsfst" `shouldRespondWith`- [json|{"details":"unexpected \"l\" expecting \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.asc.nlsfst)\" (line 1, column 8)"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }- get "/items?order=id.asc.nullslasttt" `shouldRespondWith`- [json|{"details":"unexpected 't' expecting \",\" or end of input","message":"\"failed to parse order (id.asc.nullslasttt)\" (line 1, column 17)"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }- get "/items?order=id.asc.smth34" `shouldRespondWith`- [json|{"details":"unexpected \"s\" expecting \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.asc.smth34)\" (line 1, column 8)"}|]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- describe "Accept headers" $ do- it "should respond an unknown accept type with 415" $- request methodGet "/simple_pk"- (acceptHdrs "text/unknowntype") ""- `shouldRespondWith`- [json|{"message":"None of these Content-Types are available: text/unknowntype"}|]- { matchStatus = 415- , matchHeaders = [matchContentTypeJson]- }-- it "should respond correctly to */* in accept header" $- request methodGet "/simple_pk"- (acceptHdrs "*/*") ""- `shouldRespondWith` 200-- it "*/* should rescue an unknown type" $- request methodGet "/simple_pk"- (acceptHdrs "text/unknowntype, */*") ""- `shouldRespondWith` 200-- it "specific available preference should override */*" $ do- r <- request methodGet "/simple_pk"- (acceptHdrs "text/csv, */*") ""- liftIO $ do- let respHeaders = simpleHeaders r- respHeaders `shouldSatisfy` matchHeader- "Content-Type" "text/csv; charset=utf-8"-- it "honors client preference even when opposite of server preference" $ do- r <- request methodGet "/simple_pk"- (acceptHdrs "text/csv, application/json") ""- liftIO $ do- let respHeaders = simpleHeaders r- respHeaders `shouldSatisfy` matchHeader- "Content-Type" "text/csv; charset=utf-8"-- it "should respond correctly to multiple types in accept header" $- request methodGet "/simple_pk"- (acceptHdrs "text/unknowntype, text/csv") ""- `shouldRespondWith` 200-- it "should respond with CSV to 'text/csv' request" $- request methodGet "/simple_pk"- (acceptHdrs "text/csv; version=1") ""- `shouldRespondWith` "k,extra\nxyyx,u\nxYYx,v"- { matchStatus = 200- , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]- }-- describe "Canonical location" $ do- it "Sets Content-Location with alphabetized params" $- get "/no_pk?b=eq.1&a=eq.1"- `shouldRespondWith` "[]"- { matchStatus = 200- , matchHeaders = ["Content-Location" <:> "/no_pk?a=eq.1&b=eq.1"]- }-- it "Omits question mark when there are no params" $ do- r <- get "/simple_pk"- liftIO $ do- let respHeaders = simpleHeaders r- respHeaders `shouldSatisfy` matchHeader- "Content-Location" "/simple_pk"-- describe "weird requests" $ do- it "can query as normal" $ do- get "/Escap3e;" `shouldRespondWith`- [json| [{"so6meIdColumn":1},{"so6meIdColumn":2},{"so6meIdColumn":3},{"so6meIdColumn":4},{"so6meIdColumn":5}] |]- { matchHeaders = [matchContentTypeJson] }- get "/ghostBusters" `shouldRespondWith`- [json| [{"escapeId":1},{"escapeId":3},{"escapeId":5}] |]- { matchHeaders = [matchContentTypeJson] }-- it "fails if an operator is not given" $- get "/ghostBusters?id=0" `shouldRespondWith` [json| {"details":"unexpected \"0\" expecting \"not\" or operator (eq, gt, ...)","message":"\"failed to parse filter (0)\" (line 1, column 1)"} |]- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- it "will embed a collection" $- get "/Escap3e;?select=ghostBusters(*)" `shouldRespondWith`- [json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]- { matchHeaders = [matchContentTypeJson] }-- it "will select and filter a column that has spaces" $- get "/Server%20Today?select=Just%20A%20Server%20Model&Just%20A%20Server%20Model=like.*91*" `shouldRespondWith`- [json|[- {"Just A Server Model":" IBM,9113-550 (P5-550)"},- {"Just A Server Model":" IBM,9113-550 (P5-550)"},- {"Just A Server Model":" IBM,9131-52A (P5-52A)"},- {"Just A Server Model":" IBM,9133-55A (P5-55A)"}]|]- { matchHeaders = [matchContentTypeJson] }-- it "will select and filter a quoted column that has PostgREST reserved characters" $- get "/pgrst_reserved_chars?select=%22:arr-%3Eow::cast%22,%22(inside,parens)%22,%22a.dotted.column%22,%22%20%20col%20%20w%20%20space%20%20%22&%22*id*%22=eq.1" `shouldRespondWith`- [json|[{":arr->ow::cast":" arrow-1 ","(inside,parens)":" parens-1 ","a.dotted.column":" dotted-1 "," col w space ":" space-1"}]|]- { matchHeaders = [matchContentTypeJson] }-- context "binary output" $ do- it "can query if a single column is selected" $- 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"]- }-- it "can get raw output with Accept: text/plain" $- request methodGet "/projects?select=name&id=eq.1" (acceptHdrs "text/plain") ""- `shouldRespondWith` "Windows 7"- { matchStatus = 200- , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]- }-- 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 }-- 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"]- }-- describe "values with quotes in IN and NOT IN" $ do- it "succeeds when only quoted values are present" $ do- get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`- [json| [{"name":"Hebdon, John"}] |]- { matchHeaders = [matchContentTypeJson] }- get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")" `shouldRespondWith`- [json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |]- { matchHeaders = [matchContentTypeJson] }- get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")&limit=3" `shouldRespondWith`- [json| [ { "name": "David White" }, { "name": "Larry Thompson" }, { "name": "Double O Seven(007)" }] |]- { matchHeaders = [matchContentTypeJson] }-- it "succeeds w/ and w/o quoted values" $ do- get "/w_or_wo_comma_names?name=in.(David White,\"Hebdon, John\")" `shouldRespondWith`- [json| [{"name":"Hebdon, John"},{"name":"David White"}] |]- { matchHeaders = [matchContentTypeJson] }- get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",Larry Thompson,\"Smith, Joseph\")&limit=3" `shouldRespondWith`- [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] }-- context "escaped chars" $ do- it "accepts escaped double quotes" $- get "/w_or_wo_comma_names?name=in.(\"Double\\\"Quote\\\"McGraw\\\"\")" `shouldRespondWith`- [json| [ { "name": "Double\"Quote\"McGraw\"" } ] |]- { matchHeaders = [matchContentTypeJson] }-- it "accepts escaped backslashes" $ do- get "/w_or_wo_comma_names?name=in.(\"\\\\\")" `shouldRespondWith`- [json| [{ "name": "\\" }] |]- { matchHeaders = [matchContentTypeJson] }- get "/w_or_wo_comma_names?name=in.(\"/\\\\Slash/\\\\Beast/\\\\\")" `shouldRespondWith`- [json| [ { "name": "/\\Slash/\\Beast/\\" } ] |]- { matchHeaders = [matchContentTypeJson] }-- it "passes any escaped char as the same char" $- get "/w_or_wo_comma_names?name=in.(\"D\\a\\vid W\\h\\ite\")" `shouldRespondWith`- [json| [{ "name": "David White" }] |]- { matchHeaders = [matchContentTypeJson] }-- describe "IN values without quotes" $ do- it "accepts single double quotes as values" $ do- get "/w_or_wo_comma_names?name=in.(\")" `shouldRespondWith`- [json| [{ "name": "\"" }] |]- { matchHeaders = [matchContentTypeJson] }- get "/w_or_wo_comma_names?name=in.(Double\"Quote\"McGraw\")" `shouldRespondWith`- [json| [ { "name": "Double\"Quote\"McGraw\"" } ] |]- { matchHeaders = [matchContentTypeJson] }-- it "accepts backslashes as values" $ do- get "/w_or_wo_comma_names?name=in.(\\)" `shouldRespondWith`- [json| [{ "name": "\\" }] |]- { matchHeaders = [matchContentTypeJson] }- get "/w_or_wo_comma_names?name=in.(/\\Slash/\\Beast/\\)" `shouldRespondWith`- [json| [ { "name": "/\\Slash/\\Beast/\\" } ] |]- { matchHeaders = [matchContentTypeJson] }-- describe "IN and NOT IN empty set" $ do- context "returns an empty result for IN when no value is present" $ do- it "works for integer" $- get "/items_with_different_col_types?int_data=in.()" `shouldRespondWith`- [json| [] |] { matchHeaders = [matchContentTypeJson] }- it "works for text" $- get "/items_with_different_col_types?text_data=in.()" `shouldRespondWith`- [json| [] |] { matchHeaders = [matchContentTypeJson] }- it "works for bool" $- get "/items_with_different_col_types?bool_data=in.()" `shouldRespondWith`- [json| [] |] { matchHeaders = [matchContentTypeJson] }- it "works for bytea" $- get "/items_with_different_col_types?bin_data=in.()" `shouldRespondWith`- [json| [] |] { matchHeaders = [matchContentTypeJson] }- it "works for char" $- get "/items_with_different_col_types?char_data=in.()" `shouldRespondWith`- [json| [] |] { matchHeaders = [matchContentTypeJson] }- it "works for date" $- get "/items_with_different_col_types?date_data=in.()" `shouldRespondWith`- [json| [] |] { matchHeaders = [matchContentTypeJson] }- it "works for real" $- get "/items_with_different_col_types?real_data=in.()" `shouldRespondWith`- [json| [] |] { matchHeaders = [matchContentTypeJson] }- it "works for time" $- get "/items_with_different_col_types?time_data=in.()" `shouldRespondWith`- [json| [] |] { matchHeaders = [matchContentTypeJson] }-- it "returns all results for not.in when no value is present" $- get "/items_with_different_col_types?int_data=not.in.()&select=int_data" `shouldRespondWith`- [json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }-- it "returns an empty result ignoring spaces" $- get "/items_with_different_col_types?int_data=in.( )" `shouldRespondWith`- [json| [] |] { matchHeaders = [matchContentTypeJson] }-- it "only returns an empty result set if the in value is empty" $- get "/items_with_different_col_types?int_data=in.( ,3,4)"- `shouldRespondWith` (- if actualPgVersion >= pgVersion121 then- [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"\""} |]- else- [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]- )- { matchStatus = 400- , matchHeaders = [matchContentTypeJson]- }-- describe "Embedding when column name = table name" $ do- it "works with child embeds" $- get "/being?select=*,descendant(*)&limit=1" `shouldRespondWith`- [json|[{"being":1,"descendant":[{"descendant":1,"being":1},{"descendant":2,"being":1},{"descendant":3,"being":1}]}]|]- { matchHeaders = [matchContentTypeJson] }- it "works with many to many embeds" $- get "/being?select=*,part(*)&limit=1" `shouldRespondWith`- [json|[{"being":1,"part":[{"part":1}]}]|]- { matchHeaders = [matchContentTypeJson] }-- describe "Foreign table" $ do- it "can be queried by using regular filters" $- get "/projects_dump?id=in.(1,2,3)" `shouldRespondWith`- [json| [{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}, {"id":3,"name":"IOS","client_id":2}]|]- { matchHeaders = [matchContentTypeJson] }- it "can be queried with select, order and limit" $- get "/projects_dump?select=id,name&order=id.desc&limit=3" `shouldRespondWith`- [json| [{"id":5,"name":"Orphan"}, {"id":4,"name":"OSX"}, {"id":3,"name":"IOS"}] |]- { matchHeaders = [matchContentTypeJson] }-- it "cannot use ltree(in public schema) extension operators if no extra search path added" $- get "/ltree_sample?path=cd.Top.Science.Astronomy" `shouldRespondWith` 400-- context "VIEW that has a source FK based on a UNIQUE key" $- it "can be embedded" $- get "/referrals?select=site,link:pages(url)" `shouldRespondWith`- [json| [- {"site":"github.com", "link":{"url":"http://postgrest.org/en/v6.0/api.html"}},- {"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}- ]|]- { matchHeaders = [matchContentTypeJson] }-- it "shouldn't produce a Content-Profile header since only a single schema is exposed" $ do- r <- get "/items"- liftIO $ do- let respHeaders = simpleHeaders r- respHeaders `shouldSatisfy` noProfileHeader
− test/Feature/RangeSpec.hs
@@ -1,352 +0,0 @@-module Feature.RangeSpec where--import qualified Data.ByteString.Lazy as BL--import Network.Wai (Application)-import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import Protolude hiding (get)-import SpecHelper--defaultRange :: BL.ByteString-defaultRange = [json| { "min": 0, "max": 15 } |]--emptyRange :: BL.ByteString-emptyRange = [json| { "min": 2, "max": 2 } |]--spec :: SpecWith ((), Application)-spec = do- describe "POST /rpc/getitemrange" $ do- context "without range headers" $ do- context "with response under server size limit" $- it "returns whole range with status 200" $- post "/rpc/getitemrange" defaultRange `shouldRespondWith` 200-- context "when I don't want the count" $ do- it "returns range Content-Range with */* for empty range" $- request methodPost "/rpc/getitemrange" [] emptyRange- `shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]}-- it "returns range Content-Range with range/*" $- 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- it "succeeds with partial content" $ do- r <- request methodPost "/rpc/getitemrange"- (rangeHdrs $ ByteRangeFromTo 0 1) defaultRange- liftIO $ do- simpleHeaders r `shouldSatisfy`- matchHeader "Content-Range" "0-1/*"- simpleStatus r `shouldBe` ok200-- it "understands open-ended ranges" $- request methodPost "/rpc/getitemrange"- (rangeHdrs $ ByteRangeFrom 0) defaultRange- `shouldRespondWith` 200-- it "returns an empty body when there are no results" $- request methodPost "/rpc/getitemrange"- (rangeHdrs $ ByteRangeFromTo 0 1) emptyRange- `shouldRespondWith` "[]"- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "*/*"]- }-- it "allows one-item requests" $ do- r <- request methodPost "/rpc/getitemrange"- (rangeHdrs $ ByteRangeFromTo 0 0) defaultRange- liftIO $ do- simpleHeaders r `shouldSatisfy`- matchHeader "Content-Range" "0-0/*"- simpleStatus r `shouldBe` ok200-- it "handles ranges beyond collection length via truncation" $ do- r <- request methodPost "/rpc/getitemrange"- (rangeHdrs $ ByteRangeFromTo 10 100) defaultRange- liftIO $ do- simpleHeaders r `shouldSatisfy`- matchHeader "Content-Range" "10-14/*"- simpleStatus r `shouldBe` ok200-- context "of invalid range" $ do- it "fails with 416 for offside range" $- request methodPost "/rpc/getitemrange"- (rangeHdrs $ ByteRangeFromTo 1 0) emptyRange- `shouldRespondWith` 416-- it "refuses a range with nonzero start when there are no items" $- request methodPost "/rpc/getitemrange"- (rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange- `shouldRespondWith` "[]"- { matchStatus = 416- , matchHeaders = ["Content-Range" <:> "*/0"]- }-- it "refuses a range requesting start past last item" $- request methodPost "/rpc/getitemrange"- (rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange- `shouldRespondWith` "[]"- { matchStatus = 416- , matchHeaders = ["Content-Range" <:> "*/15"]- }-- describe "GET /items" $ do- context "without range headers" $ do- context "with response under server size limit" $- it "returns whole range with status 200" $- get "/items" `shouldRespondWith` 200-- context "when I don't want the count" $ do- it "returns range Content-Range with /*" $- request methodGet "/menagerie"- [("Prefer", "count=none")] ""- `shouldRespondWith` "[]"- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "*/*"]- }-- it "returns range Content-Range with range/*" $- request methodGet "/items?order=id"- [("Prefer", "count=none")] ""- `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/*"] }-- it "returns range Content-Range with range/* even using other filters" $- request methodGet "/items?id=eq.1&order=id"- [("Prefer", "count=none")] ""- `shouldRespondWith` [json| [{"id":1}] |]- { matchHeaders = ["Content-Range" <:> "0-0/*"] }-- context "with limit/offset parameters" $ do- it "no parameters return everything" $- get "/items?select=id&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}]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-14/*"]- }- it "top level limit with parameter" $- get "/items?select=id&order=id.asc&limit=3"- `shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-2/*"]- }- it "headers override get parameters" $- request methodGet "/items?select=id&order=id.asc&limit=3"- (rangeHdrs $ ByteRangeFromTo 0 1) ""- `shouldRespondWith` [json|[{"id":1},{"id":2}]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-1/*"]- }-- it "limit works on all levels" $- get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"- `shouldRespondWith`- [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-0/*"]- }-- it "limit and offset works on first level" $ do- get "/items?select=id&order=id.asc&limit=3&offset=2"- `shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "2-4/*"]- }- request methodHead "/items?select=id&order=id.asc&limit=3&offset=2" [] mempty- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "2-4/*"]- }-- it "succeeds if offset equals 0 as a no-op" $- 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}]|]- { matchHeaders = ["Content-Range" <:> "0-14/*"] }-- it "succeeds if offset is negative as a no-op" $- 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}]|]- { matchHeaders = ["Content-Range" <:> "0-14/*"] }-- it "fails if limit equals 0" $- get "/items?select=id&limit=0"- `shouldRespondWith` [json|{"message":"HTTP Range error"}|]- { matchStatus = 416- , matchHeaders = [matchContentTypeJson]- }-- it "fails if limit is negative" $- get "/items?select=id&limit=-1"- `shouldRespondWith` [json|{"message":"HTTP Range error"}|]- { matchStatus = 416- , matchHeaders = [matchContentTypeJson]- }-- context "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"]- }-- 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"]- }-- 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"]- }-- it "works with two levels" $- 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- it "succeeds with partial content" $ do- r <- request methodGet "/items"- (rangeHdrs $ ByteRangeFromTo 0 1) ""- liftIO $ do- simpleHeaders r `shouldSatisfy`- matchHeader "Content-Range" "0-1/*"- simpleStatus r `shouldBe` ok200-- it "understands open-ended ranges" $- request methodGet "/items"- (rangeHdrs $ ByteRangeFrom 0) ""- `shouldRespondWith` 200-- it "returns an empty body when there are no results" $- request methodGet "/menagerie"- (rangeHdrs $ ByteRangeFromTo 0 1) ""- `shouldRespondWith` "[]"- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "*/*"]- }-- it "allows one-item requests" $ do- r <- request methodGet "/items"- (rangeHdrs $ ByteRangeFromTo 0 0) ""- liftIO $ do- simpleHeaders r `shouldSatisfy`- matchHeader "Content-Range" "0-0/*"- simpleStatus r `shouldBe` ok200-- it "handles ranges beyond collection length via truncation" $ do- r <- request methodGet "/items"- (rangeHdrs $ ByteRangeFromTo 10 100) ""- liftIO $ do- simpleHeaders r `shouldSatisfy`- matchHeader "Content-Range" "10-14/*"- simpleStatus r `shouldBe` ok200-- context "of invalid range" $ do- it "fails with 416 for offside range" $- request methodGet "/items"- (rangeHdrs $ ByteRangeFromTo 1 0) ""- `shouldRespondWith` 416-- it "refuses a range with nonzero start when there are no items" $- request methodGet "/menagerie"- (rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""- `shouldRespondWith` "[]"- { matchStatus = 416- , matchHeaders = ["Content-Range" <:> "*/0"]- }-- it "refuses a range requesting start past last item" $- request methodGet "/items"- (rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""- `shouldRespondWith` "[]"- { matchStatus = 416- , matchHeaders = ["Content-Range" <:> "*/15"]- }
− test/Feature/RawOutputTypesSpec.hs
@@ -1,33 +0,0 @@-module Feature.RawOutputTypesSpec where--import Network.Wai (Application)--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import Protolude-import SpecHelper (acceptHdrs)--spec :: SpecWith ((), Application)-spec = describe "When raw-media-types config variable is missing or left empty" $ do- let firefoxAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"- chromeAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"- it "responds json to a GET request with Firefox Accept headers" $- request methodGet "/items?id=eq.1" firefoxAcceptHdrs ""- `shouldRespondWith` [json| [{"id":1}] |]- { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }- it "responds json to a GET request with Chrome Accept headers" $- request methodGet "/items?id=eq.1" chromeAcceptHdrs ""- `shouldRespondWith` [json| [{"id":1}] |]- { 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" 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" $- request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""- `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"] }
− test/Feature/RollbackSpec.hs
@@ -1,218 +0,0 @@-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
@@ -1,34 +0,0 @@-module Feature.RootSpec where--import Network.HTTP.Types-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 "root spec function" $ do- it "accepts application/openapi+json" $- request methodGet "/"- [("Accept","application/openapi+json")] "" `shouldRespondWith`- [json|{- "swagger": "2.0",- "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}- }|]- { matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] }-- it "accepts application/json" $- request methodGet "/"- [("Accept", "application/json")] "" `shouldRespondWith`- [json| {- "tableName": "orders_view", "tableSchema": "test",- "tableDeletable": true, "tableUpdatable": true,- "tableInsertable": true, "tableDescription": null- } |]- { matchHeaders = [matchContentTypeJson] }
− test/Feature/RpcPreRequestGucsSpec.hs
@@ -1,84 +0,0 @@-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
@@ -1,1256 +0,0 @@-module Feature.RpcSpec where--import qualified Data.ByteString.Lazy as BL (empty, readFile)--import Network.Wai (Application)-import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))--import Network.HTTP.Types-import System.IO.Unsafe (unsafePerformIO)-import Test.Hspec hiding (pendingWith)-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON-import Text.Heredoc--import PostgREST.Config.PgVersion (PgVersion, pgVersion100,- pgVersion109, pgVersion110,- pgVersion112, pgVersion114,- pgVersion140)--import Protolude hiding (get)-import SpecHelper--spec :: PgVersion -> SpecWith ((), Application)-spec actualPgVersion =- describe "remote procedure call" $ do- context "a proc that returns a set" $ do- it "returns paginated results" $ do- request methodPost "/rpc/getitemrange"- (rangeHdrs (ByteRangeFromTo 0 0)) [json| { "min": 2, "max": 4 } |]- `shouldRespondWith` [json| [{"id":3}] |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-0/*"]- }- request methodGet "/rpc/getitemrange?min=2&max=4"- (rangeHdrs (ByteRangeFromTo 0 0)) ""- `shouldRespondWith` [json| [{"id":3}] |]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-0/*"]- }- request methodHead "/rpc/getitemrange?min=2&max=4"- (rangeHdrs (ByteRangeFromTo 0 0)) ""- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "0-0/*"]- }-- it "includes total count if requested" $ do- request methodPost "/rpc/getitemrange"- (rangeHdrsWithCount (ByteRangeFromTo 0 0))- [json| { "min": 2, "max": 4 } |]- `shouldRespondWith` [json| [{"id":3}] |]- { matchStatus = 206 -- it now knows the response is partial- , matchHeaders = ["Content-Range" <:> "0-0/2"]- }- request methodGet "/rpc/getitemrange?min=2&max=4"- (rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""- `shouldRespondWith` [json| [{"id":3}] |]- { matchStatus = 206- , matchHeaders = ["Content-Range" <:> "0-0/2"]- }- request methodHead "/rpc/getitemrange?min=2&max=4"- (rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""- `shouldRespondWith` ""- { matchStatus = 206- , matchHeaders = ["Content-Range" <:> "0-0/2"]- }-- it "returns proper json" $ do- post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`- [json| [ {"id": 3}, {"id":4} ] |]- { matchHeaders = [matchContentTypeJson] }- get "/rpc/getitemrange?min=2&max=4" `shouldRespondWith`- [json| [ {"id": 3}, {"id":4} ] |]- { matchHeaders = [matchContentTypeJson] }-- it "returns CSV" $ do- request methodPost "/rpc/getitemrange"- (acceptHdrs "text/csv")- [json| { "min": 2, "max": 4 } |]- `shouldRespondWith` "id\n3\n4"- { matchStatus = 200- , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]- }- request methodGet "/rpc/getitemrange?min=2&max=4"- (acceptHdrs "text/csv") ""- `shouldRespondWith` "id\n3\n4"- { matchStatus = 200- , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]- }- request methodHead "/rpc/getitemrange?min=2&max=4"- (acceptHdrs "text/csv") ""- `shouldRespondWith` ""- { matchStatus = 200- , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]- }-- context "unknown function" $ do- it "returns 404" $- post "/rpc/fakefunc" [json| {} |] `shouldRespondWith` 404-- it "should fail with 404 on unknown proc name" $- get "/rpc/fake" `shouldRespondWith` 404-- it "should fail with 404 on unknown proc args" $ do- get "/rpc/sayhello" `shouldRespondWith` 404- get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404-- it "should not ignore unknown args and fail with 404" $- get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`- [json| {- "hint":"If a new function was created in the database with this name and parameters, 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 parameters, try reloading the schema cache.",- "message":"Could not find the test.sayhello function with a single json or jsonb parameter 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 parameters, 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 parameters, 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 parameters' names but different types" $ do- it "should fail with 300 Multiple Choices without explicit type casts" $- get "/rpc/overloaded_same_args?arg=value" `shouldRespondWith`- [json| {- "hint":"Try renaming the parameters or the function itself in the database so function overloading can be resolved",- "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"}|]- { matchHeaders = [matchContentTypeJson] }- post "/rpc/quotedFunction"- [json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]- `shouldRespondWith`- [json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]- { matchHeaders = [matchContentTypeJson] }-- context "shaping the response returned by a proc" $ do- it "returns a project" $ do- post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","client_id":1}]|]- get "/rpc/getproject?id=1" `shouldRespondWith`- [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`- [json|[{"id":2},{"id":3},{"id":4}]|]- { matchHeaders = [matchContentTypeJson] }- get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" `shouldRespondWith`- [json|[{"id":2},{"id":3},{"id":4}]|]- { matchHeaders = [matchContentTypeJson] }-- it "can limit proc results" $ do- post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id&limit=2&offset=1" [json| {} |]- `shouldRespondWith` [json|[{"id":3},{"id":4}]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "1-2/*"] }- get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id&limit=2&offset=1"- `shouldRespondWith` [json|[{"id":3},{"id":4}]|]- { matchStatus = 200- , matchHeaders = ["Content-Range" <:> "1-2/*"] }-- it "select works on the first level" $ do- post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7"}]|]- get "/rpc/getproject?id=1&select=id,name" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7"}]|]-- context "foreign entities embedding" $ do- it "can embed if related tables are in the exposed schema" $ do- post "/rpc/getproject?select=id,name,client:clients(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]- { matchHeaders = [matchContentTypeJson] }- get "/rpc/getproject?id=1&select=id,name,client:clients(id),tasks(id)" `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]- { matchHeaders = [matchContentTypeJson] }-- it "cannot embed if the related table is not in the exposed schema" $ do- post "/rpc/single_article?select=*,article_stars(*)" [json|{ "id": 1}|]- `shouldRespondWith` 400- get "/rpc/single_article?id=1&select=*,article_stars(*)"- `shouldRespondWith` 400-- it "can embed if the related tables are in a hidden schema but exposed as views" $ do- post "/rpc/single_article?select=id,articleStars(userId)"- [json|{ "id": 2}|]- `shouldRespondWith`- [json|{"id": 2, "articleStars": [{"userId": 3}]}|]- get "/rpc/single_article?id=2&select=id,articleStars(userId)"- `shouldRespondWith`- [json|{"id": 2, "articleStars": [{"userId": 3}]}|]-- it "can embed an M2M relationship table" $- get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"- `shouldRespondWith` [json|[- {"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]},- {"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]}- ]|]- { matchHeaders = [matchContentTypeJson] }-- it "can embed an M2M relationship table that has a parent relationship table" $- get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1"- `shouldRespondWith` [json|[- {"name":"Michael Scott","tasks":[- {"name":"Design IOS","project":{"name":"IOS"}},- {"name":"Code IOS","project":{"name":"IOS"}},- {"name":"Design OSX","project":{"name":"OSX"}}- ]},- {"name":"Dwight Schrute","tasks":[- {"name":"Design w7","project":{"name":"Windows 7"}},- {"name":"Design IOS","project":{"name":"IOS"}}- ]}- ]|]- { 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`- [json| [] |]- { matchHeaders = [matchContentTypeJson] }- get "/rpc/test_empty_rowset" `shouldRespondWith`- [json| [] |]- { matchHeaders = [matchContentTypeJson] }-- context "proc return types" $ do- context "returns text" $ do- it "returns proper json" $- post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`- [json|"Hello, world"|]- { matchHeaders = [matchContentTypeJson] }-- it "can handle unicode" $- post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith`- [json|"Hello, ¥"|]- { matchHeaders = [matchContentTypeJson] }-- it "returns array" $- post "/rpc/ret_array" [json|{}|] `shouldRespondWith`- [json|[1, 2, 3]|]- { matchHeaders = [matchContentTypeJson] }-- it "returns setof integers" $- post "/rpc/ret_setof_integers"- [json|{}|]- `shouldRespondWith`- [json|[1,2,3]|]-- it "returns enum value" $- post "/rpc/ret_enum" [json|{ "val": "foo" }|] `shouldRespondWith`- [json|"foo"|]- { matchHeaders = [matchContentTypeJson] }-- it "returns domain value" $- post "/rpc/ret_domain" [json|{ "val": "8" }|] `shouldRespondWith`- [json|8|]- { matchHeaders = [matchContentTypeJson] }-- it "returns range" $- post "/rpc/ret_range" [json|{ "low": 10, "up": 20 }|] `shouldRespondWith`- [json|"[10,20)"|]- { matchHeaders = [matchContentTypeJson] }-- it "returns row of scalars" $- post "/rpc/ret_scalars" [json|{}|] `shouldRespondWith`- [json|[{"a":"scalars", "b":"foo", "c":1, "d":"[10,20)"}]|]- { matchHeaders = [matchContentTypeJson] }-- it "returns composite type in exposed schema" $- post "/rpc/ret_point_2d"- [json|{}|]- `shouldRespondWith`- [json|{"x": 10, "y": 5}|]-- 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}|]-- it "returns null for void" $- 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- -- 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| {- "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 } } |]- `shouldRespondWith`- [json|"object"|]- { matchHeaders = [matchContentTypeJson] }-- when (actualPgVersion < pgVersion100) $- it "parses quoted JSON arguments as JSON (Postgres < 10)" $- post "/rpc/json_argument"- [json| { "arg": "{ \"key\": 3 }" } |]- `shouldRespondWith`- [json|"object"|]- { matchHeaders = [matchContentTypeJson] }-- when ((actualPgVersion >= pgVersion109 && actualPgVersion < pgVersion110)- || actualPgVersion >= pgVersion114) $- it "parses quoted JSON arguments as JSON string (from Postgres 10.9, 11.4)" $- post "/rpc/json_argument"- [json| { "arg": "{ \"key\": 3 }" } |]- `shouldRespondWith`- [json|"string"|]- { matchHeaders = [matchContentTypeJson] }-- context "improper input" $ do- it "rejects unknown content type even if payload is good" $ do- request methodPost "/rpc/sayhello"- (acceptHdrs "audio/mpeg3") [json| { "name": "world" } |]- `shouldRespondWith` 415- request methodGet "/rpc/sayhello?name=world"- (acceptHdrs "audio/mpeg3") ""- `shouldRespondWith` 415- it "rejects malformed json payload" $ do- p <- request methodPost "/rpc/sayhello"- (acceptHdrs "application/json") "sdfsdf"- liftIO $ do- simpleStatus p `shouldBe` badRequest400- isErrorFormat (simpleBody p) `shouldBe` True- it "treats simple plpgsql raise as invalid input" $ do- p <- post "/rpc/problem" "{}"- liftIO $ do- simpleStatus p `shouldBe` badRequest400- isErrorFormat (simpleBody p) `shouldBe` True- it "treats plpgsql assert as internal server error" $ do- p <- post "/rpc/assert" "{}"- liftIO $ do- simpleStatus p `shouldBe` internalServerError500- isErrorFormat (simpleBody p) `shouldBe` True-- context "unsupported verbs" $ do- it "DELETE fails" $- request methodDelete "/rpc/sayhello" [] ""- `shouldRespondWith`- [json|{"message":"Bad Request"}|]- { matchStatus = 405- , matchHeaders = [matchContentTypeJson]- }- it "PATCH fails" $- request methodPatch "/rpc/sayhello" [] ""- `shouldRespondWith` 405- it "OPTIONS fails" $- -- TODO: should return info about the function- request methodOptions "/rpc/sayhello" [] ""- `shouldRespondWith` 405-- it "executes the proc exactly once per request" $ do- -- 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`- [json| "Return value of no parameters procedure." |]- { matchHeaders = [matchContentTypeJson] }- it "interprets empty string as a function with no args on a get request" $- get "/rpc/noparamsproc" `shouldRespondWith`- [json| "Return value of no parameters procedure." |]- { matchHeaders = [matchContentTypeJson] }-- it "returns proper output when having the same return col name as the proc name" $ do- post "/rpc/test" [json|{}|] `shouldRespondWith`- [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }- get "/rpc/test" `shouldRespondWith`- [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }-- context "procs with OUT/INOUT params" $ do- it "returns an object result when there is a single OUT param" $ do- get "/rpc/single_out_param?num=5"- `shouldRespondWith`- [json|{"num_plus_one":6}|]-- get "/rpc/single_json_out_param?a=1&b=two"- `shouldRespondWith`- [json|{"my_json": {"a": 1, "b": "two"}}|]-- 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 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 TABLE return" $ do- it "returns an object result when there is a single-column TABLE return type" $- get "/rpc/single_column_table_return"- `shouldRespondWith`- [json|[{"a": "A"}]|]-- it "returns an object result when there is a multi-column TABLE return type" $- get "/rpc/multi_column_table_return"- `shouldRespondWith`- [json|[{"a": "A", "b": "B"}]|]-- 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}|]- 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"- `shouldRespondWith` [json|{ "hint": "Upgrade your plan", "details": "Quota exceeded" }|]- { matchStatus = 402- , matchHeaders = [matchContentTypeJson]- }-- it "defaults to status 500 if RAISE code is PT not followed by a number" $- get "/rpc/raise_bad_pt"- `shouldRespondWith`- [json|{"hint": null, "details": null}|]- { matchStatus = 500- , matchHeaders = [ matchContentTypeJson ]- }-- context "expects a single json object" $ do- it "does not expand posted json into parameters" $- request methodPost "/rpc/singlejsonparam"- [("prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`- [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]- { matchHeaders = [matchContentTypeJson] }-- it "accepts parameters from an html form" $- request methodPost "/rpc/singlejsonparam"- [("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]- ("integer=7&double=2.71828&varchar=forms+are+fun&" <>- "boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`- [json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"- , "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]- { matchHeaders = [matchContentTypeJson] }-- it "works with GET" $- request methodGet "/rpc/singlejsonparam?p1=1&p2=text" [("Prefer","params=single-object")] ""- `shouldRespondWith` [json|{ "p1": "1", "p2": "text"}|]- { matchHeaders = [matchContentTypeJson] }-- 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"|]-- -- https://github.com/PostgREST/postgrest/issues/1672- context "embedding overloaded functions with the same signature except for the last param with a default value" $ do- it "overloaded_default(text default)" $ do- request methodPost "/rpc/overloaded_default?select=id,name,users(name)"- [("Content-Type", "application/json")]- [json|{}|]- `shouldRespondWith`- [json|[{"id": 2, "name": "Code w7", "users": [{"name": "Angela Martin"}]}] |]-- it "overloaded_default(int)" $- request methodPost "/rpc/overloaded_default"- [("Content-Type", "application/json")]- [json|{"must_param":1}|]- `shouldRespondWith`- [json|{"val":1}|]-- it "overloaded_default(int, text default)" $ do- request methodPost "/rpc/overloaded_default?select=id,name,users(name)"- [("Content-Type", "application/json")]- [json|{"a":4}|]- `shouldRespondWith`- [json|[{"id": 5, "name": "Design IOS", "users": [{"name": "Michael Scott"}, {"name": "Dwight Schrute"}]}] |]-- it "overloaded_default(int, int)" $- request methodPost "/rpc/overloaded_default"- [("Content-Type", "application/json")]- [json|{"a":2,"must_param":4}|]- `shouldRespondWith`- [json|{"a":2,"val":4}|]-- 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|{name: "John"}|] `shouldRespondWith` 400-- it "ignores json keys not included in ?columns" $- post "/rpc/sayhello?columns=name"- [json|{"name": "John", "smth": "here", "other": "stuff", "fake_id": 13}|]- `shouldRespondWith`- [json|"Hello, John"|]- { matchHeaders = [matchContentTypeJson] }-- it "only takes the first object in case of array of objects payload" $- post "/rpc/add_them"- [json|[- {"a": 1, "b": 2},- {"a": 4, "b": 6},- {"a": 100, "b": 200} ]|]- `shouldRespondWith` "3"- { matchHeaders = [matchContentTypeJson] }-- context "bulk RPC with params=multiple-objects" $ do- it "works with a scalar function an returns a json array" $- request methodPost "/rpc/add_them" [("Prefer", "params=multiple-objects")]- [json|[- {"a": 1, "b": 2},- {"a": 4, "b": 6},- {"a": 100, "b": 200} ]|]- `shouldRespondWith`- [json|- [3, 10, 300]- |] { matchHeaders = [matchContentTypeJson] }-- it "works with a scalar function an returns a json array when posting CSV" $- request methodPost "/rpc/add_them" [("Content-Type", "text/csv"), ("Prefer", "params=multiple-objects")]- "a,b\n1,2\n4,6\n100,200"- `shouldRespondWith`- [json|- [3, 10, 300]- |]- { matchStatus = 200- , matchHeaders = [matchContentTypeJson]- }-- it "works with a non-scalar result" $- request methodPost "/rpc/get_projects_below?select=id,name" [("Prefer", "params=multiple-objects")]- [json|[- {"id": 1},- {"id": 5} ]|]- `shouldRespondWith`- [json|- [{"id":1,"name":"Windows 7"},- {"id":2,"name":"Windows 10"},- {"id":3,"name":"IOS"},- {"id":4,"name":"OSX"}]- |] { matchHeaders = [matchContentTypeJson] }-- context "HTTP request env vars" $ do- it "custom header is set" $- request methodPost "/rpc/get_guc_value"- [("Custom-Header", "test")]- (- if actualPgVersion >= pgVersion140 then- [json| { "prefix": "request.headers", "name": "custom-header" } |]- else- [json| { "name": "request.header.custom-header" } |]- )- `shouldRespondWith`- [json|"test"|]- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson ]- }- it "standard header is set" $- request methodPost "/rpc/get_guc_value"- [("Origin", "http://example.com")]- (- if actualPgVersion >= pgVersion140 then- [json| { "prefix": "request.headers", "name": "origin" } |]- else- [json| { "name": "request.header.origin" } |]- )- `shouldRespondWith`- [json|"http://example.com"|]- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson ]- }- it "current role is available as GUC claim" $- request methodPost "/rpc/get_guc_value" []- (- if actualPgVersion >= pgVersion140 then- [json| { "prefix": "request.jwt.claims", "name": "role" } |]- else- [json| { "name": "request.jwt.claim.role" } |]- )- `shouldRespondWith`- [json|"postgrest_test_anonymous"|]- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson ]- }- it "single cookie ends up as claims" $- request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]- (- if actualPgVersion >= pgVersion140 then- [json| {"prefix": "request.cookies", "name":"acookie"} |]- else- [json| {"name":"request.cookie.acookie"} |]- )- `shouldRespondWith`- [json|"cookievalue"|]- { matchStatus = 200- , matchHeaders = []- }- it "multiple cookies ends up as claims" $- request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]- (- if actualPgVersion >= pgVersion140 then- [json| {"prefix": "request.cookies", "name":"secondcookie"} |]- else- [json| {"name":"request.cookie.secondcookie"} |]- )- `shouldRespondWith`- [json|"anothervalue"|]- { matchStatus = 200- , matchHeaders = []- }- it "app settings available" $- request methodPost "/rpc/get_guc_value" []- [json| { "name": "app.settings.app_host" } |]- `shouldRespondWith`- [json|"localhost"|]- { matchStatus = 200- , matchHeaders = [ matchContentTypeJson ]- }- it "gets the Authorization value" $- request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]- (- if actualPgVersion >= pgVersion140 then- [json| {"prefix": "request.headers", "name":"authorization"} |]- else- [json| {"name":"request.header.authorization"} |]- )- `shouldRespondWith`- [json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]- { matchStatus = 200- , matchHeaders = []- }- it "gets the http method" $- request methodPost "/rpc/get_guc_value" []- [json| {"name":"request.method"} |]- `shouldRespondWith`- [json|"POST"|]- { matchStatus = 200- , matchHeaders = []- }- it "gets the http path" $- request methodPost "/rpc/get_guc_value" []- [json| {"name":"request.path"} |]- `shouldRespondWith`- [json|"/rpc/get_guc_value"|]- { matchStatus = 200- , matchHeaders = []- }-- context "binary output" $ do- context "Proc that returns scalar" $ do- it "can query without selecting column" $- 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"]- }-- it "can get raw output with Accept: text/plain" $- request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""- `shouldRespondWith` "Welcome to PostgREST"- { matchStatus = 200- , 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"]- }-- it "fails if a single column is not selected" $- 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 }-- context "only for GET rpc" $ do- it "should fail on mutating procs" $ do- 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`- [json|[{ "id": 3 }, { "id": 4 }]|]- { matchHeaders = [matchContentTypeJson] }-- it "should work with filters that have the not operator" $ do- get "/rpc/get_projects_below?id=5&id=not.gt.2&select=id" `shouldRespondWith`- [json|[{ "id": 1 }, { "id": 2 }]|]- { matchHeaders = [matchContentTypeJson] }- get "/rpc/get_projects_below?id=5&id=not.in.(1,3)&select=id" `shouldRespondWith`- [json|[{ "id": 2 }, { "id": 4 }]|]- { matchHeaders = [matchContentTypeJson] }-- it "should work with filters that use the plain with language fts operator" $ do- get "/rpc/get_tsearch?text_search_vector=fts(english).impossible" `shouldRespondWith`- [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]- { matchHeaders = [matchContentTypeJson] }- get "/rpc/get_tsearch?text_search_vector=plfts.impossible" `shouldRespondWith`- [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]- { matchHeaders = [matchContentTypeJson] }- get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`- [json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]- { matchHeaders = [matchContentTypeJson] }- when (actualPgVersion >= pgVersion112) $- get "/rpc/get_tsearch?text_search_vector=wfts.impossible" `shouldRespondWith`- [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]- { matchHeaders = [matchContentTypeJson] }-- 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] }-- 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 ]- }-- context "single unnamed param" $ do- it "can insert json directly" $- post "/rpc/unnamed_json_param"- [json|{"A": 1, "B": 2, "C": 3}|]- `shouldRespondWith`- [json|{"A": 1, "B": 2, "C": 3}|]-- it "can insert text directly" $- request methodPost "/rpc/unnamed_text_param"- [("Content-Type", "text/plain"), ("Accept", "text/plain")]- [str|unnamed text arg|]- `shouldRespondWith`- [str|unnamed text arg|]-- it "can insert bytea directly" $ do- let file = unsafePerformIO $ BL.readFile "test/C.png"- r <- request methodPost "/rpc/unnamed_bytea_param"- [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]- file- liftIO $ do- let respBody = simpleBody r- respBody `shouldBe` file-- it "will err when no function with single unnamed json parameter exists and application/json is specified" $- request methodPost "/rpc/unnamed_int_param" [("Content-Type", "application/json")]- [json|{"x": 1, "y": 2}|]- `shouldRespondWith`- [json|{- "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",- "message": "Could not find the test.unnamed_int_param(x, y) function or the test.unnamed_int_param function with a single unnamed json or jsonb parameter in the schema cache"- }|]- { matchStatus = 404- , matchHeaders = [ matchContentTypeJson ]- }-- it "will err when no function with single unnamed text parameter exists and text/plain is specified" $- request methodPost "/rpc/unnamed_int_param"- [("Content-Type", "text/plain")]- [str|a simple text|]- `shouldRespondWith`- [json|{- "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",- "message": "Could not find the test.unnamed_int_param function with a single unnamed text parameter in the schema cache"- }|]- { matchStatus = 404- , matchHeaders = [ matchContentTypeJson ]- }-- it "will err when no function with single unnamed bytea parameter exists and application/octet-stream is specified" $- let file = unsafePerformIO $ BL.readFile "test/C.png" in- request methodPost "/rpc/unnamed_int_param"- [("Content-Type", "application/octet-stream")]- file- `shouldRespondWith`- [json|{- "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",- "message": "Could not find the test.unnamed_int_param function with a single unnamed bytea parameter in the schema cache"- }|]- { matchStatus = 404- , matchHeaders = [ matchContentTypeJson ]- }-- it "should be able to resolve when a single unnamed json parameter exists and other overloaded functions are found" $ do- request methodPost "/rpc/overloaded_unnamed_param" [("Content-Type", "application/json")]- [json|{}|]- `shouldRespondWith`- [json| 1 |]- { matchStatus = 200- , matchHeaders = [matchContentTypeJson]- }- request methodPost "/rpc/overloaded_unnamed_param" [("Content-Type", "application/json")]- [json|{"x": 1, "y": 2}|]- `shouldRespondWith`- [json| 3 |]- { matchStatus = 200- , matchHeaders = [matchContentTypeJson]- }-- it "should be able to fallback to the single unnamed parameter function when other overloaded functions are not found" $ do- request methodPost "/rpc/overloaded_unnamed_param"- [("Content-Type", "application/json")]- [json|{"A": 1, "B": 2, "C": 3}|]- `shouldRespondWith`- [json|{"A": 1, "B": 2, "C": 3}|]- request methodPost "/rpc/overloaded_unnamed_param"- [("Content-Type", "text/plain"), ("Accept", "text/plain")]- [str|unnamed text arg|]- `shouldRespondWith`- [str|unnamed text arg|]- let file = unsafePerformIO $ BL.readFile "test/C.png"- r <- request methodPost "/rpc/overloaded_unnamed_param"- [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]- file- liftIO $ do- let respBody = simpleBody r- respBody `shouldBe` file-- it "should fail to fallback to any single unnamed parameter function when using an unsupported Content-Type header" $ do- request methodPost "/rpc/overloaded_unnamed_param"- [("Content-Type", "text/csv")]- "a,b\n1,2\n4,6\n100,200"- `shouldRespondWith`- [json| {- "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",- "message":"Could not find the test.overloaded_unnamed_param(a, b) function in the schema cache"}|]- { matchStatus = 404- , matchHeaders = [matchContentTypeJson]- }-- it "should fail with multiple choices when two fallback functions with single unnamed json and jsonb parameters exist" $ do- request methodPost "/rpc/overloaded_unnamed_json_jsonb_param" [("Content-Type", "application/json")]- [json|{"A": 1, "B": 2, "C": 3}|]- `shouldRespondWith`- [json| {- "hint":"Try renaming the parameters or the function itself in the database so function overloading can be resolved",- "message":"Could not choose the best candidate function between: test.overloaded_unnamed_json_jsonb_param( => json), test.overloaded_unnamed_json_jsonb_param( => jsonb)"}|]- { matchStatus = 300- , matchHeaders = [matchContentTypeJson]- }
− test/Feature/SingularSpec.hs
@@ -1,324 +0,0 @@-module Feature.SingularSpec where--import Network.Wai (Application)-import Network.Wai.Test (SResponse (..))--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import Protolude hiding (get)-import SpecHelper---spec :: SpecWith ((), Application)-spec =- describe "Requesting singular json object" $ do- let singular = ("Accept", "application/vnd.pgrst.object+json")-- context "with GET request" $ do- it "fails for zero rows" $- request methodGet "/items?id=gt.0&id=lt.0" [singular] ""- `shouldRespondWith` 406-- it "will select an existing object" $ do- request methodGet "/items?id=eq.5" [singular] ""- `shouldRespondWith`- [json|{"id":5}|]- { matchHeaders = [matchContentTypeSingular] }- -- also test without the +json suffix- request methodGet "/items?id=eq.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`- [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 = [matchContentTypeSingular] }-- context "when updating rows" $ do- it "works for one row with return=rep" $ do- request methodPatch "/addresses?id=eq.1"- [("Prefer", "return=representation"), singular]- [json| { address: "B Street" } |]- `shouldRespondWith`- [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- 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.1"- `shouldRespondWith`- [json|[{"id":1,"address":"address 1"}]|]-- it "raises an error for multiple rows with return=rep" $ do- 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.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`- [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 = [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`- [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 = [matchContentTypeSingular]- }-- context "when creating rows" $ do- 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 with return=minimal" $ do- request methodPost "/addresses"- [("Prefer", "return=minimal"), singular]- [json| [ { id: 103, address: "xxx" } ] |]- `shouldRespondWith` ""- { matchStatus = 201- , matchHeaders = ["Content-Range" <:> "*/*"]- }-- it "raises an error when attempting to create multiple entities" $ do- 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`- "[]"-- it "raises an error when attempting to create multiple entities with return=rep" $ do- 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`- "[]"-- it "raises an error regardless of return=minimal" $ do- request methodPost "/addresses"- [("Prefer", "tx=commit"), ("Prefer", "return=minimal"), singular]- [json| [ { id: 204, address: "xxx" }, { id: 205, 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.204"- `shouldRespondWith`- "[]"-- it "raises an error when creating zero entities" $- request methodPost "/addresses"- [singular]- [json| [ ] |]- `shouldRespondWith`- [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 = [matchContentTypeSingular]- }-- it "raises an error when creating zero entities with return=rep" $- request methodPost "/addresses"- [("Prefer", "return=representation"), singular]- [json| [ ] |]- `shouldRespondWith`- [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 = [matchContentTypeSingular]- }-- context "when deleting rows" $ 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` [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- 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" ]- }-- -- 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- 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" ]- }-- -- 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/*"]- }-- it "raises an error when deleting zero entities" $- request methodDelete "/items?id=lt.0"- [singular] ""- `shouldRespondWith`- [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 = [matchContentTypeSingular]- }-- it "raises an error when deleting zero entities with return=rep" $- request methodDelete "/items?id=lt.0"- [("Prefer", "return=representation"), singular] ""- `shouldRespondWith`- [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 = [matchContentTypeSingular]- }-- context "when calling a stored proc" $ do- it "fails for zero rows" $- request methodPost "/rpc/getproject"- [singular] [json|{ "id": 9999999}|]- `shouldRespondWith`- [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 = [matchContentTypeSingular]- }-- -- this one may be controversial, should vnd.pgrst.object include- -- the likes of 2 and "hello?"- it "succeeds for scalar result" $- request methodPost "/rpc/sayhello"- [singular] [json|{ "name": "world"}|]- `shouldRespondWith` 200-- it "returns a single object for json proc" $- request methodPost "/rpc/getproject"- [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`- [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]- }-- 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"- [("Prefer", "tx=commit"), singular]- [json| {"id_l": 1, "id_h": 2, "name": "changed"} |]- `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" ]- }-- -- should rollback function- post "/rpc/getproject?select=id,name"- [json| {"id": 1} |]- `shouldRespondWith`- [json|[{"id":1,"name":"Windows 7"}]|]
− test/Feature/UnicodeSpec.hs
@@ -1,34 +0,0 @@-module Feature.UnicodeSpec 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)--spec :: SpecWith ((), Application)-spec =- describe "Reading and writing to unicode schema and table names" $- it "Can read and write values" $ do- get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"- `shouldRespondWith` "[]"-- 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 }] |]-- request methodDelete "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"- [("Prefer", "tx=commit")]- ""- `shouldRespondWith`- 204
− test/Feature/UpdateSpec.hs
@@ -1,349 +0,0 @@-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
@@ -1,427 +0,0 @@-module Feature.UpsertSpec where--import Network.Wai (Application)--import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai-import Test.Hspec.Wai.JSON--import PostgREST.Config.PgVersion (PgVersion, pgVersion110)--import Protolude hiding (get, put)-import SpecHelper--spec :: PgVersion -> SpecWith ((), Application)-spec actualPgVersion =- describe "UPSERT" $ do- context "with POST" $ do- context "when Prefer: resolution=merge-duplicates is specified" $ do- it "INSERTs and UPDATEs rows on pk conflict" $- request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]- [json| [- { "name": "Javascript", "rank": 6 },- { "name": "Java", "rank": 2 },- { "name": "C", "rank": 1 }- ]|] `shouldRespondWith` [json| [- { "name": "Javascript", "rank": 6 },- { "name": "Java", "rank": 2 },- { "name": "C", "rank": 1 }- ]|]- { matchStatus = 201- , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]- }-- it "INSERTs and UPDATEs row on composite pk conflict" $- request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]- [json| [- { "first_name": "Frances M.", "last_name": "Roe", "salary": "30000" },- { "first_name": "Peter S.", "last_name": "Yang", "salary": 42000 }- ]|] `shouldRespondWith` [json| [- { "first_name": "Frances M.", "last_name": "Roe", "salary": "$30,000.00", "company": "One-Up Realty", "occupation": "Author" },- { "first_name": "Peter S.", "last_name": "Yang", "salary": "$42,000.00", "company": null, "occupation": null }- ]|]- { matchStatus = 201- , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]- }-- when (actualPgVersion >= pgVersion110) $- it "INSERTs and UPDATEs rows on composite pk conflict for partitioned tables" $- request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]- [json| [- { "name": "Murcielago", "year": 2001, "car_brand_name": null},- { "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }- ]|] `shouldRespondWith` [json| [- { "name": "Murcielago", "year": 2001, "car_brand_name": null},- { "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }- ]|]- { matchStatus = 201- , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]- }-- it "succeeds when the payload has no elements" $- request methodPost "/articles" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]- [json|[]|] `shouldRespondWith`- [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }-- it "INSERTs and UPDATEs rows on single unique key conflict" $- request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]- [json| [- { "unique_key": 1, "value": "B" },- { "unique_key": 2, "value": "C" }- ]|] `shouldRespondWith` [json| [- { "unique_key": 1, "value": "B" },- { "unique_key": 2, "value": "C" }- ]|]- { matchStatus = 201- , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]- }-- it "INSERTs and UPDATEs rows on compound unique keys conflict" $- request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]- [json| [- { "key1": 1, "key2": 1, "value": "B" },- { "key1": 1, "key2": 2, "value": "C" }- ]|] `shouldRespondWith` [json| [- { "key1": 1, "key2": 1, "value": "B" },- { "key1": 1, "key2": 2, "value": "C" }- ]|]- { matchStatus = 201- , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]- }-- context "when Prefer: resolution=ignore-duplicates is specified" $ do- it "INSERTs and ignores rows on pk conflict" $- request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]- [json|[- { "name": "PHP", "rank": 9 },- { "name": "Python", "rank": 10 }- ]|] `shouldRespondWith` [json|[- { "name": "PHP", "rank": 9 }- ]|]- { matchStatus = 201- , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]- }-- it "INSERTs and ignores rows on composite pk conflict" $- request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]- [json|[- { "first_name": "Daniel B.", "last_name": "Lyon", "salary": "72000", "company": null, "occupation": null },- { "first_name": "Sara M.", "last_name": "Torpey", "salary": 60000, "company": "Burstein-Applebee", "occupation": "Soil scientist" }- ]|] `shouldRespondWith` [json|[- { "first_name": "Sara M.", "last_name": "Torpey", "salary": "$60,000.00", "company": "Burstein-Applebee", "occupation": "Soil scientist" }- ]|]- { matchStatus = 201- , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]- }-- when (actualPgVersion >= pgVersion110) $- it "INSERTs and ignores rows on composite pk conflict for partitioned tables" $- request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]- [json| [- { "name": "Murcielago", "year": 2001, "car_brand_name": "Ferrari" },- { "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }- ]|] `shouldRespondWith` [json| [- { "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }- ]|]- { matchStatus = 201- , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]- }-- 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": 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": 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")]- [json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|]- `shouldRespondWith`- [json|[ { "id": 3} ]|]- { matchStatus = 201 ,- matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates",- matchContentTypeJson] }-- request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]- [json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]- `shouldRespondWith`- [json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]- { matchStatus = 201 ,- matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates",- matchContentTypeJson] }-- it "succeeds and ignores the Prefer: resolution header(no Preference-Applied present) if the table has no PK" $- request methodPost "/no_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]- [json|[ { "a": "1", "b": "0" } ]|]- `shouldRespondWith`- [json|[ { "a": "1", "b": "0" } ]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }-- it "succeeds if not a single resource is created" $ do- request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]- [json|[ { "name": "Java", "rank": 1 } ]|] `shouldRespondWith`- [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }- request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]- [json|[ { "name": "Java", "rank": 1 }, { "name": "C", "rank": 2 } ]|] `shouldRespondWith`- [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }-- context "with PUT" $ do- context "Restrictions" $ do- it "fails if Range is specified" $- request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]- [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"- [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"- [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"- [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"- [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)"- [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)"- [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"- [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"- [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" [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"- [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" [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- -- 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- -- assert that the next request will indeed be an insert- get "/employees?first_name=eq.Susan&last_name=eq.Heidt"- `shouldRespondWith`- [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" } ]|]-- when (actualPgVersion >= pgVersion110) $- it "succeeds on a partitioned table with composite pk" $ do- -- assert that the next request will indeed be an insert- get "/car_models?name=eq.Supra&year=eq.2021"- `shouldRespondWith`- [json|[]|]-- request methodPut "/car_models?name=eq.Supra&year=eq.2021"- [("Prefer", "return=representation")]- [json| [ { "name": "Supra", "year": 2021 } ]|]- `shouldRespondWith`- [json| [ { "name": "Supra", "year": 2021, "car_brand_name": null } ]|]-- it "succeeds if the table has only PK cols and no other cols" $ do- -- 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- -- 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")]- [json| [ { "name": "Java", "rank": 13 } ]|]- `shouldRespondWith`- [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- -- 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": "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": "Frances M.", "last_name": "Roe", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]-- when (actualPgVersion >= pgVersion110) $- it "succeeds on a partitioned table with composite pk" $ do- -- assert that the next request will indeed be an update- get "/car_models?name=eq.DeLorean&year=eq.1981"- `shouldRespondWith`- [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": "DMC" } ]|]-- request methodPut "/car_models?name=eq.DeLorean&year=eq.1981"- [("Prefer", "return=representation")]- [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]- `shouldRespondWith`- [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]-- it "succeeds if the table has only PK cols and no other cols" $ do- -- 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")]- [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" $ 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" [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] `shouldRespondWith` 204- get "/UnitTest?idUnitTest=eq.1" `shouldRespondWith`- [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|]
− test/Main.hs
@@ -1,236 +0,0 @@-module Main where--import qualified Data.Aeson as JSON-import qualified Hasql.Pool as P-import qualified Hasql.Transaction.Sessions as HT--import Data.Function (id)-import Data.List.NonEmpty (toList)--import Test.Hspec--import PostgREST.App (postgrest)-import PostgREST.Config (AppConfig (..), LogLevel (..))-import PostgREST.Config.Database (queryPgVersion)-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-import qualified Feature.AuthSpec-import qualified Feature.BinaryJwtSecretSpec-import qualified Feature.ConcurrentSpec-import qualified Feature.CorsSpec-import qualified Feature.DeleteSpec-import qualified Feature.DisabledOpenApiSpec-import qualified Feature.EmbedDisambiguationSpec-import qualified Feature.EmbedInnerJoinSpec-import qualified Feature.ExtraSearchPathSpec-import qualified Feature.HtmlRawOutputSpec-import qualified Feature.IgnorePrivOpenApiSpec-import qualified Feature.InsertSpec-import qualified Feature.JsonOperatorSpec-import qualified Feature.LegacyGucsSpec-import qualified Feature.MultipleSchemaSpec-import qualified Feature.NoJwtSpec-import qualified Feature.NonexistentSchemaSpec-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.UnicodeSpec-import qualified Feature.UpdateSpec-import qualified Feature.UpsertSpec---main :: IO ()-main = do- 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 queryPgVersion-- baseDbStructure <-- loadDbStructure pool- (configDbSchemas $ testCfg testDbConn)- (configDbExtraSearchPath $ testCfg testDbConn)- actualPgVersion-- let- -- For tests that run with the same refDbStructure- 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- let config = cfg testDbConn- customDbStructure <-- loadDbStructure pool- (configDbSchemas config)- (configDbExtraSearchPath config)- actualPgVersion- appState <- AppState.initWithPool pool config- AppState.putPgVersion appState actualPgVersion- 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- rootSpecApp = app testCfgRootSpec- htmlRawOutputApp = app testCfgHtmlRawOutput- responseHeadersApp = app testCfgResponseHeaders- disallowRollbackApp = app testCfgDisallowRollback- forceRollbackApp = app testCfgForceRollback- testCfgLegacyGucsApp = app testCfgLegacyGucs-- extraSearchPathApp = appDbs testCfgExtraSearchPath- unicodeApp = appDbs testUnicodeCfg- nonexistentSchemaApp = appDbs testNonexistentSchemaCfg- multipleSchemaApp = appDbs testMultipleSchemaCfg- ignorePrivOpenApi = appDbs testIgnorePrivOpenApiCfg-- let analyze :: IO ()- analyze = do- analyzeTable testDbConn "items"- analyzeTable testDbConn "child_entities"-- specs = uncurry describe <$> [- ("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.EmbedInnerJoinSpec" , Feature.EmbedInnerJoinSpec.spec)- , ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)- , ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)- , ("Feature.OpenApiSpec" , Feature.OpenApiSpec.spec actualPgVersion)- , ("Feature.OptionsSpec" , Feature.OptionsSpec.spec actualPgVersion)- , ("Feature.QuerySpec" , Feature.QuerySpec.spec actualPgVersion)- , ("Feature.RawOutputTypesSpec" , Feature.RawOutputTypesSpec.spec)- , ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion)- , ("Feature.SingularSpec" , Feature.SingularSpec.spec)- , ("Feature.UpdateSpec" , Feature.UpdateSpec.spec)- , ("Feature.UpsertSpec" , Feature.UpsertSpec.spec actualPgVersion)- ]-- hspec $ do- mapM_ (parallel . before withApp) specs-- -- we analyze to get accurate results from EXPLAIN- parallel $ beforeAll_ analyze . before withApp $- describe "Feature.RangeSpec" Feature.RangeSpec.spec-- -- this test runs with a raw-output-media-types set to text/html- parallel $ before htmlRawOutputApp $- describe "Feature.HtmlRawOutputSpec" Feature.HtmlRawOutputSpec.spec-- -- this test runs with a different server flag- parallel $ before maxRowsApp $- describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec-- -- this test runs with a different schema- 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- parallel $ before proxyApp $- describe "Feature.ProxySpec" Feature.ProxySpec.spec-- -- this test runs without a JWT secret- parallel $ before noJwtApp $- describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec-- -- this test runs with a binary JWT secret- parallel $ before binaryJwtApp $- describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec-- -- this test runs with a binary JWT secret and an audience claim- parallel $ before audJwtApp $- describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec-- -- this test runs with asymmetric JWK- parallel $ before asymJwkApp $- describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec-- -- this test runs with asymmetric JWKSet- parallel $ before asymJwkSetApp $- describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec-- -- this test runs with a nonexistent db-schema- parallel $ before nonexistentSchemaApp $- describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec-- -- this test runs with an extra search path- parallel $ before extraSearchPathApp $- describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec-- -- this test runs with a root spec function override- parallel $ before rootSpecApp $- describe "Feature.RootSpec" Feature.RootSpec.spec- parallel $ before responseHeadersApp $- describe "Feature.RpcPreRequestGucsSpec" Feature.RpcPreRequestGucsSpec.spec-- -- this test runs with multiple schemas- parallel $ before multipleSchemaApp $- describe "Feature.MultipleSchemaSpec" Feature.MultipleSchemaSpec.spec-- -- this test runs with db-uses-legacy-gucs = false- parallel $ before testCfgLegacyGucsApp $- describe "Feature.LegacyGucsSpec" Feature.LegacyGucsSpec.spec-- -- 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- loadDbStructure pool schemas extraSearchPath actualPgVersion =- either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ queryDbStructure (toList schemas) extraSearchPath actualPgVersion True)
− test/QueryCost.hs
@@ -1,89 +0,0 @@-module Main where--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.Query.QueryBuilder (requestToCallProcQuery)-import PostgREST.Request.Types--import PostgREST.DbStructure.Identifiers-import PostgREST.DbStructure.Proc--import SpecHelper (getEnvVarWithDefault)--import Test.Hspec--main :: IO ()-main = do- 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 $- requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")- (KeyParams [ProcParam "id" "int" True False])- (Just [str| {"id": 3} |]) False False [])- liftIO $- cost `shouldSatisfy` (< Just 40)-- it "should not exceed cost when calling setof composite proc with empty params" $ do- cost <- exec pool $- requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "getallprojects") (KeyParams []) Nothing False False [])- liftIO $- cost `shouldSatisfy` (< Just 30)-- it "should not exceed cost when calling scalar proc" $ do- cost <- exec pool $- requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them")- (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])- (Just [str| {"a": 3, "b": 4} |]) True False [])- liftIO $- cost `shouldSatisfy` (< Just 10)-- context "params=multiple-objects" $ do- it "should not exceed cost when calling setof composite proc" $ do- cost <- exec pool $- requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")- (KeyParams [ProcParam "id" "int" True False])- (Just [str| [{"id": 1}, {"id": 4}] |]) False True [])- 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 $- requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them")- (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])- (Just [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True False [])- liftIO $- cost `shouldSatisfy` (< Just 10)---exec :: P.Pool -> H.Snippet -> IO (Maybe Int64)-exec pool query =- join . rightToMaybe <$>- P.use pool (HT.transaction HT.ReadCommitted HT.Read $ HT.statement mempty $ explainCost query)--explainCost :: H.Snippet -> H.Statement () (Maybe Int64)-explainCost query =- H.dynamicallyParameterized snippet decodeExplain False- where- snippet = "EXPLAIN (FORMAT JSON) " <> query- decodeExplain :: HD.Result (Maybe Int64)- decodeExplain =- let row = HD.singleRow $ HD.column $ HD.nonNullable HD.bytea in- (^? L.nth 0 . L.key "Plan" . L.key "Total Cost" . L._Integral) <$> row
− test/SpecHelper.hs
@@ -1,246 +0,0 @@-module SpecHelper where--import qualified Data.ByteString.Base64 as B64 (decodeLenient, encode)-import qualified Data.ByteString.Char8 as BS-import qualified Data.ByteString.Lazy as BL-import qualified Data.Map.Strict as M-import qualified Data.Set as S-import qualified System.IO.Error as E--import Data.Aeson (Value (..), decode, encode)-import Data.CaseInsensitive (CI (..), original)-import Data.List (lookup)-import Data.List.NonEmpty (fromList)-import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))-import System.Environment (getEnv)-import System.Process (readProcess)-import Text.Regex.TDFA ((=~))---import Network.HTTP.Types-import Test.Hspec-import Test.Hspec.Wai-import Text.Heredoc--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"--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 ""- liftIO $- let respStatus = simpleStatus r in- respStatus `shouldSatisfy`- \s -> s == Status { statusCode = 200, statusMessage="OK" }- liftIO $- let respHeaders = simpleHeaders r in- respHeaders `shouldSatisfy`- \hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs- let Just body = decode (simpleBody r)- Just schema <- liftIO $ decode <$> BL.readFile "test/fixtures/openapi.json"- let args :: M.Map Text Value- args = M.fromList- [ ( "schema", schema )- , ( "data", body ) ]- hdrs = acceptHdrs "application/json"- request methodPost "/rpc/validate_json_schema" hdrs (encode args)- `shouldRespondWith` "true"- { matchStatus = 200- , matchHeaders = []- }---getEnvVarWithDefault :: Text -> Text -> IO Text-getEnvVarWithDefault var def = toS <$>- getEnv (toS var) `E.catchIOError` const (return $ toS def)--_baseCfg :: AppConfig-_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- , configDbUseLegacyGucs = True- , 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 { 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, configJWKS = Nothing }--testUnicodeCfg :: Text -> AppConfig-testUnicodeCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["تست"] }--testMaxRowsCfg :: Text -> AppConfig-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) { configOpenApiServerProxyUri = Just "https://postgrest.com/openapi.json" }--testCfgBinaryJWT :: Text -> AppConfig-testCfgBinaryJWT testDbConn =- let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in- (testCfg testDbConn) {- configJwtSecret = secret- , configJWKS = parseSecret <$> secret- }--testCfgAudienceJWT :: Text -> AppConfig-testCfgAudienceJWT testDbConn =- let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in- (testCfg testDbConn) {- configJwtSecret = secret- , configJwtAudience = Just "youraudience"- , configJWKS = parseSecret <$> secret- }--testCfgAsymJWK :: Text -> AppConfig-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 =- 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) { configDbSchemas = fromList ["nonexistent"] }--testCfgExtraSearchPath :: Text -> AppConfig-testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configDbExtraSearchPath = ["public", "extensions"] }--testCfgRootSpec :: Text -> AppConfig-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) { configDbPreRequest = Just $ QualifiedIdentifier mempty "custom_headers" }--testMultipleSchemaCfg :: Text -> AppConfig-testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["v1", "v2"] }--testCfgLegacyGucs :: Text -> AppConfig-testCfgLegacyGucs testDbConn = (testCfg testDbConn) { configDbUseLegacyGucs = False }--resetDb :: Text -> IO ()-resetDb dbConn = loadFixture dbConn "data"--analyzeTable :: Text -> Text -> IO ()-analyzeTable dbConn tableName =- void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-a", "-c", toS $ "ANALYZE test.\"" <> tableName <> "\""] []--loadFixture :: Text -> FilePath -> IO()-loadFixture dbConn name =- void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-q", "-f", "test/fixtures/" ++ name ++ ".sql"] []--rangeHdrs :: ByteRange -> [Header]-rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]--rangeHdrsWithCount :: ByteRange -> [Header]-rangeHdrsWithCount r = ("Prefer", "count=exact") : rangeHdrs r--acceptHdrs :: BS.ByteString -> [Header]-acceptHdrs mime = [(hAccept, mime)]--rangeUnit :: Header-rangeUnit = ("Range-Unit" :: CI BS.ByteString, "items")--matchHeader :: CI BS.ByteString -> BS.ByteString -> [Header] -> Bool-matchHeader name valRegex headers =- maybe False (=~ valRegex) $ lookup name headers--noBlankHeader :: [Header] -> Bool-noBlankHeader = notElem mempty--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 =- authHeader "Basic" $ toS . B64.encode . toS $ u <> ":" <> p--authHeaderJWT :: BS.ByteString -> Header-authHeaderJWT = authHeader "Bearer"---- | 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-isErrorFormat s =- "message" `S.member` keys &&- S.null (S.difference keys validKeys)- where- obj = decode s :: Maybe (M.Map Text Value)- keys = maybe S.empty M.keysSet obj- validKeys = S.fromList ["message", "details", "hint", "code"]
− test/TestTypes.hs
@@ -1,37 +0,0 @@-module TestTypes (- IncPK(..)-, CompoundPK(..)-) where--import Data.Aeson ((.:))-import qualified Data.Aeson as JSON--import Protolude--data IncPK = IncPK {- incId :: Int-, incNullableStr :: Maybe Text-, incStr :: Text-, incInsert :: Text-} deriving (Eq, Show)--instance JSON.FromJSON IncPK where- parseJSON (JSON.Object r) = IncPK <$>- r .: "id" <*>- r .: "nullable_string" <*>- r .: "non_nullable_string" <*>- r .: "inserted_at"- parseJSON _ = mzero--data CompoundPK = CompoundPK {- compoundK1 :: Int-, compoundK2 :: Text-, compoundExtra :: Maybe Int-} deriving (Eq, Show)--instance JSON.FromJSON CompoundPK where- parseJSON (JSON.Object r) = CompoundPK <$>- r .: "k1" <*>- r .: "k2" <*>- r .: "extra"- parseJSON _ = mzero
+ test/doc/Main.hs view
@@ -0,0 +1,17 @@+module Main (main) where++import Test.DocTest (doctest)++import Protolude+++main :: IO ()+main =+ doctest+ [ "--verbose"+ , "-XOverloadedStrings"+ , "-XNoImplicitPrelude"+ , "-XStandaloneDeriving"+ , "-isrc"+ , "src/PostgREST/Request/Preferences.hs"+ ]
− test/doctests/Main.hs
@@ -1,17 +0,0 @@-module Main (main) where--import Test.DocTest (doctest)--import Protolude---main :: IO ()-main =- doctest- [ "--verbose"- , "-XOverloadedStrings"- , "-XNoImplicitPrelude"- , "-XStandaloneDeriving"- , "-isrc"- , "src/PostgREST/Request/Preferences.hs"- ]
+ test/spec/Feature/AndOrParamsSpec.hs view
@@ -0,0 +1,276 @@+module Feature.AndOrParamsSpec where++import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import PostgREST.Config.PgVersion (PgVersion, pgVersion112)++import Protolude hiding (get)+import SpecHelper++spec :: PgVersion -> SpecWith ((), Application)+spec actualPgVersion =+ describe "and/or params used for complex boolean logic" $ do+ context "used with GET" $ do+ context "or param" $ do+ it "can do simple logic" $+ get "/entities?or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ it "can negate simple logic" $+ get "/entities?not.or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`+ [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ it "can be combined with traditional filters" $+ get "/entities?or=(id.eq.1,id.eq.2)&name=eq.entity 1&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }++ context "embedded levels" $ do+ it "can do logic on the second level" $+ get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities(id)" `shouldRespondWith`+ [json|[+ {"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": 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" $+ get "/entities?or=(and(name.eq.entity 2,id.eq.2),and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ it "can be negated while nested" $+ get "/entities?or=(not.and(name.eq.entity 2,id.eq.2),not.and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ it "can be combined unnested" $+ get "/entities?and=(id.eq.1,name.eq.entity 1)&or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }++ context "operators inside and/or" $ do+ it "can handle eq and neq" $+ get "/entities?and=(id.eq.1,id.neq.2))&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }+ it "can handle lt and gt" $+ get "/entities?or=(id.lt.2,id.gt.3)&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ it "can handle lte and gte" $+ get "/entities?or=(id.lte.2,id.gte.3)&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ it "can handle like and ilike" $+ get "/entities?or=(name.like.*1,name.ilike.*ENTITY 2)&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ it "can handle in" $+ get "/entities?or=(id.in.(1,2),id.in.(3,4))&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ it "can handle is" $+ get "/entities?and=(name.is.null,arr.is.null)&select=id" `shouldRespondWith`+ [json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ it "can handle fts" $ do+ get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?or=(text_search_vector.plfts(german).Art%20Spass, text_search_vector.plfts(french).amusant%20impossible, text_search_vector.fts(english).impossible)" `shouldRespondWith`+ [json|[+ {"text_search_vector": "'fun':5 'imposs':9 'kind':3" },+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}+ ]|] { matchHeaders = [matchContentTypeJson] }++ when (actualPgVersion >= pgVersion112) $+ it "can handle wfts (websearch_to_tsquery)" $+ get "/tsearch?or=(text_search_vector.plfts(german).Art,text_search_vector.plfts(french).amusant,text_search_vector.not.wfts(english).impossible)"+ `shouldRespondWith`+ [json|[+ {"text_search_vector": "'also':2 'fun':3 'possibl':8" },+ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" },+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },+ {"text_search_vector": "'art':4 'spass':5 'unmog':7" }+ ]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can handle cs and cd" $+ get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`+ [json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }++ it "can handle range operators" $ do+ get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=neq.[1,3]&select=id" `shouldRespondWith`+ [json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=lt.[1,10]&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=gt.[8,11]&select=id" `shouldRespondWith`+ [json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=lte.[1,3]&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=gte.[2,3]&select=id" `shouldRespondWith`+ [json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=cs.[1,2]&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=cd.[1,6]&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=ov.[0,4]&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=sl.[9,10]&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=sr.[3,4]&select=id" `shouldRespondWith`+ [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=nxr.[4,7]&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=nxl.[4,7]&select=id" `shouldRespondWith`+ [json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }++ it "can handle array operators" $ do+ get "/entities?arr=eq.{1,2,3}&select=id" `shouldRespondWith`+ [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=neq.{1,2}&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=lt.{2,3}&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=lt.{2,0}&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=gt.{1,1}&select=id" `shouldRespondWith`+ [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=gt.{3}&select=id" `shouldRespondWith`+ [json|[]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=lte.{2,1}&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=lte.{1,2,3}&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=lte.{1,2}&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=cs.{1,2}&select=id" `shouldRespondWith`+ [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=cd.{1,2,6}&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=ov.{3}&select=id" `shouldRespondWith`+ [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?arr=ov.{2,3}&select=id" `shouldRespondWith`+ [json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }++ context "operators with not" $ do+ it "eq, cs, like can be negated" $+ get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`+ [json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }+ it "in, is, fts can be negated" $+ get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith`+ [json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }+ it "lt, gte, cd can be negated" $+ get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`+ [json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }+ it "gt, lte, ilike can be negated" $+ get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith`+ [json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }++ context "and/or params with quotes" $ do+ it "eq can have quotes" $+ get "/grandchild_entities?or=(name.eq.\"(grandchild,entity,4)\",name.eq.\"(grandchild,entity,5)\")&select=id" `shouldRespondWith`+ [json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }+ it "like and ilike can have quotes" $+ get "/grandchild_entities?or=(name.like.\"*ity,4*\",name.ilike.\"*ITY,5)\")&select=id" `shouldRespondWith`+ [json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }+ it "in can have quotes" $+ get "/grandchild_entities?or=(id.in.(\"1\",\"2\"),id.in.(\"3\",\"4\"))&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }++ it "allows whitespace" $+ get "/entities?and=( and ( id.in.( 1, 2, 3 ) , id.eq.3 ) , or ( id.eq.2 , id.eq.3 ) )&select=id" `shouldRespondWith`+ [json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }++ context "multiple and/or conditions" $ do+ it "cannot have zero conditions" $+ get "/entities?or=()" `shouldRespondWith`+ [json|{+ "details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)",+ "message": "\"failed to parse logic tree (())\" (line 1, column 4)"+ }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }+ it "can have a single condition" $ do+ get "/entities?or=(id.eq.1)&select=id" `shouldRespondWith`+ [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }+ get "/entities?and=(id.eq.1)&select=id" `shouldRespondWith`+ [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }+ it "can have three conditions" $ do+ get "/grandchild_entities?or=(id.eq.1, id.eq.2, id.eq.3)&select=id" `shouldRespondWith`+ [json|[{"id":1}, {"id":2}, {"id":3}]|] { matchHeaders = [matchContentTypeJson] }+ get "/grandchild_entities?and=(id.in.(1,2), id.in.(3,1), id.in.(1,4))&select=id" `shouldRespondWith`+ [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }+ it "can have four conditions combining and/or" $ do+ get "/grandchild_entities?or=( id.eq.1, id.eq.2, and(id.in.(1,3), id.in.(2,3)), id.eq.4 )&select=id" `shouldRespondWith`+ [json|[{"id":1}, {"id":2}, {"id":3}, {"id":4}]|] { matchHeaders = [matchContentTypeJson] }+ get "/grandchild_entities?and=( id.eq.1, not.or(id.eq.2, id.eq.3), id.in.(1,4), or(id.eq.1, id.eq.4) )&select=id" `shouldRespondWith`+ [json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }++ 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":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" $+ request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"+ [("Prefer", "return=representation")]+ [json|{ name : "updated grandchild entity"}|] `shouldRespondWith`+ [json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]+ { matchHeaders = [matchContentTypeJson] }++ 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" : "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++ it "fails when using IN without () and provides meaningful error message" $+ get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith`+ [json|{+ "details": "unexpected \"1\" expecting \"(\"",+ "message": "\"failed to parse logic tree ((id.in.1,2,id.eq.3))\" (line 1, column 10)"+ }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }++ it "fails on malformed query params and provides meaningful error message" $ do+ get "/entities?or=)(" `shouldRespondWith`+ [json|{+ "details": "unexpected \")\" expecting \"(\"",+ "message": "\"failed to parse logic tree ()()\" (line 1, column 3)"+ }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }+ get "/entities?and=(ord(id.eq.1,id.eq.1),id.eq.2)" `shouldRespondWith`+ [json|{+ "details": "unexpected \"d\" expecting \"(\"",+ "message": "\"failed to parse logic tree ((ord(id.eq.1,id.eq.1),id.eq.2))\" (line 1, column 7)"+ }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }+ get "/entities?or=(id.eq.1,not.xor(id.eq.2,id.eq.3))" `shouldRespondWith`+ [json|{+ "details": "unexpected \"x\" expecting logic operator (and, or)",+ "message": "\"failed to parse logic tree ((id.eq.1,not.xor(id.eq.2,id.eq.3)))\" (line 1, column 16)"+ }|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
+ test/spec/Feature/AsymmetricJwtSpec.hs view
@@ -0,0 +1,21 @@+module Feature.AsymmetricJwtSpec where++-- {{{ Imports+import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai++import Protolude+import SpecHelper+-- }}}++spec :: SpecWith ((), Application)+spec = describe "server started with asymmetric JWK" $++ -- this test will stop working 9999999999s after the UNIX EPOCH+ it "succeeds with jwt token signed with an asymmetric key" $ do+ let auth = authHeaderJWT "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlIjogInBvc3RncmVzdF90ZXN0X2F1dGhvciJ9Cg.CBOYWDvqgAR0YYnZnyDGTQi6AJLc2Pds6_eV3YuBG6I36mj_h05eLhkEKNEDA5ZteMzCiY83P60rC_xtxVd7B6vo3BeF5uoanPS3rrbuHzKPwzsrgrD_CqvEuJ4n7Q9epkQiLsNkcexneENZDRqFjbwZx3DrXiCWwlK3Ytr5NAIGxmy0od-0xNpb2U1nXQyO_Q3mumWFViRt4tmFn_3goDHNKG3Ha_AzImfUNvHnWL78kAc4rbn15vLtWXD8PwtSnZaB4lY4V6RfsaW937srQsmRetvytM1i_bHBnjkjQLAqGbXPyItjtlXPs0uGNBadE8-wgkLtfmSCC4v2DjUthw"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 200
+ test/spec/Feature/AudienceJwtSecretSpec.hs view
@@ -0,0 +1,47 @@+module Feature.AudienceJwtSecretSpec where++-- {{{ Imports+import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai++import Protolude hiding (get)+import SpecHelper+-- }}}++spec :: SpecWith ((), Application)+spec = describe "test handling of aud claims in JWT" $ do++ -- this test will stop working 9999999999s after the UNIX EPOCH+ it "succeeds with jwt token containing with an audience claim" $ do+ {- This is the decoded contents of authHeaderJWT++ {+ "exp": 9999999999,+ "role": "postgrest_test_author",+ "id": "jdoe",+ "aud": "youraudience"+ }++ -}+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 200++ it "succeeds with jwt token that does not contain an audience claim" $ do+ {- This is the decoded contents of authHeaderJWT++ {+ "exp": 9999999999,+ "role": "postgrest_test_author",+ "id": "jdoe"+ }+ -}+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 200++ it "requests without JWT token should work" $+ get "/has_count_column" `shouldRespondWith` 200
+ test/spec/Feature/AuthSpec.hs view
@@ -0,0 +1,179 @@+module Feature.AuthSpec where++import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import PostgREST.Config.PgVersion (PgVersion, pgVersion112)++import Protolude hiding (get)+import SpecHelper++spec :: PgVersion -> SpecWith ((), Application)+spec actualPgVersion = describe "authorization" $ do+ let single = ("Accept","application/vnd.pgrst.object+json")++ it "denies access to tables that anonymous does not own" $+ get "/authors_only" `shouldRespondWith` (+ if actualPgVersion >= pgVersion112 then+ [json| {+ "hint":null,+ "details":null,+ "code":"42501",+ "message":"permission denied for table authors_only"} |]+ else+ [json| {+ "hint":null,+ "details":null,+ "code":"42501",+ "message":"permission denied for relation authors_only"} |]+ )+ { matchStatus = 401+ , matchHeaders = ["WWW-Authenticate" <:> "Bearer"]+ }++ it "denies access to tables that postgrest_test_author does not own" $+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in+ request methodGet "/private_table" [auth] ""+ `shouldRespondWith` (+ if actualPgVersion >= pgVersion112 then+ [json| {+ "hint":null,+ "details":null,+ "code":"42501",+ "message":"permission denied for table private_table"} |]+ else+ [json| {+ "hint":null,+ "details":null,+ "code":"42501",+ "message":"permission denied for relation private_table"} |]+ )+ { matchStatus = 403+ , matchHeaders = []+ }++ it "denies execution on functions that anonymous does not own" $+ post "/rpc/privileged_hello" [json|{"name": "anonymous"}|] `shouldRespondWith` 401++ it "allows execution on a function that postgrest_test_author owns" $+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in+ request methodPost "/rpc/privileged_hello" [auth] [json|{"name": "jdoe"}|]+ `shouldRespondWith` [json|"Privileged hello to jdoe"|]+ { matchStatus = 200+ , matchHeaders = [matchContentTypeJson]+ }++ it "returns jwt functions as jwt tokens" $+ request methodPost "/rpc/login" [single]+ [json| { "id": "jdoe", "pass": "1234" } |]+ `shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.KO-0PGp_rU-utcDBP6qwdd-Th2Fk-ICVt01I7QtTDWs"} |]+ { matchStatus = 200+ , matchHeaders = [matchContentTypeSingular]+ }++ it "sql functions can encode custom and standard claims" $+ request methodPost "/rpc/jwt_test" [single] "{}"+ `shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.G2REtPnOQMUrVRDA9OnkPJTd8R0tf4wdYOlauh1E2Ek"} |]+ { matchStatus = 200+ , matchHeaders = [matchContentTypeSingular]+ }++ 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` [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"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 200++ it "works with tokens which have extra fields" $ do+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIiwia2V5MSI6InZhbHVlMSIsImtleTIiOiJ2YWx1ZTIiLCJrZXkzIjoidmFsdWUzIiwiYSI6MSwiYiI6MiwiYyI6M30.b0eglDKYEmGi-hCvD-ddSqFl7vnDO5qkUaviaHXm3es"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 200++ -- this test will stop working 9999999999s after the UNIX EPOCH+ it "succeeds with an unexpired token" $ do+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 200++ it "fails with an expired token" $ do+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.f8__E6VQwYcDqwHmr9PG03uaZn8Zh1b0vbJ9DYS0AdM"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` [json| {"message":"JWT expired"} |]+ { matchStatus = 401+ , matchHeaders = [+ "WWW-Authenticate" <:>+ "Bearer error=\"invalid_token\", error_description=\"JWT expired\""+ ]+ }++ it "hides tables from users with invalid JWT" $ do+ let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"} |]+ { matchStatus = 401+ , matchHeaders = [+ "WWW-Authenticate" <:>+ "Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)\""+ ]+ }++ it "should fail when jwt contains no claims" $ do+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.CUIP5V9thWsGGFsFyGijSZf1fJMfarLHI9CEJL-TGNk"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 401++ it "hides tables from users with JWT that contain no claims about role" $ do+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.RVlZDaSyKbFPvxUf3V_NQXybfRB4dlBIkAUQXVXLUAI"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 401++ it "recovers after 401 error with logged in user" $ do+ _ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"+ _ <- request methodPost "/rpc/problem" [auth] ""+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 200++ describe "custom pre-request proc acting on id claim" $ do++ it "able to switch to postgrest_test_author role (id=1)" $+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.gKw7qI50i9hMrSJW8BlTpdMEVmMXJYxlAqueGqpa_mE" in+ request methodPost "/rpc/get_current_user" [auth]+ [json| {} |]+ `shouldRespondWith` [json|"postgrest_test_author"|]+ { matchStatus = 200+ , matchHeaders = []+ }++ it "able to switch to postgrest_test_default_role (id=2)" $+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.nwzjMI0YLvVGJQTeoCPEBsK983b__gxdpLXisBNaO2A" in+ request methodPost "/rpc/get_current_user" [auth]+ [json| {} |]+ `shouldRespondWith` [json|"postgrest_test_default_role"|]+ { matchStatus = 200+ , matchHeaders = []+ }++ it "raises error (id=3)" $+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.OGxEJAf60NKZiTn-tIb2jy4rqKs_ZruLGWZ40TjrJsM" in+ request methodPost "/rpc/get_current_user" [auth]+ [json| {} |]+ `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/spec/Feature/BinaryJwtSecretSpec.hs view
@@ -0,0 +1,21 @@+module Feature.BinaryJwtSecretSpec where++-- {{{ Imports+import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai++import Protolude+import SpecHelper+-- }}}++spec :: SpecWith ((), Application)+spec = describe "server started with binary JWT secret" $++ -- this test will stop working 9999999999s after the UNIX EPOCH+ it "succeeds with jwt token encoded with a binary secret" $ do+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith` 200
+ test/spec/Feature/ConcurrentSpec.hs view
@@ -0,0 +1,52 @@+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE UndecidableInstances #-}+{-# OPTIONS_GHC -fno-warn-orphans #-}+module Feature.ConcurrentSpec where++import Control.Concurrent.Async (mapConcurrently)+import Network.Wai (Application)++import Control.Monad.Base+import Control.Monad.Trans.Control++import Network.Wai.Test (Session)+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.Internal+import Test.Hspec.Wai.JSON++import Protolude hiding (get)++spec :: SpecWith ((), Application)+spec =+ describe "Querying in parallel" $+ it "should not raise 'transaction in progress' error" $+ raceTest 10 $+ get "/fakefake"+ `shouldRespondWith` [json|+ { "hint": null,+ "details":null,+ "code":"42P01",+ "message":"relation \"test.fakefake\" does not exist"+ } |]+ { matchStatus = 404+ , matchHeaders = []+ }++raceTest :: Int -> WaiExpectation st -> WaiExpectation st+raceTest times = liftBaseDiscard go+ where+ go test = void $ mapConcurrently (const test) [1..times]++instance MonadBaseControl IO (WaiSession st) where+ type StM (WaiSession st) a = StM Session a+ liftBaseWith f = WaiSession $+ liftBaseWith $ \runInBase ->+ f $ \k -> runInBase (unWaiSession k)+ restoreM = WaiSession . restoreM+ {-# INLINE liftBaseWith #-}+ {-# INLINE restoreM #-}++instance MonadBase IO (WaiSession st) where+ liftBase = liftIO
+ test/spec/Feature/CorsSpec.hs view
@@ -0,0 +1,56 @@+module Feature.CorsSpec where++import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai++import Protolude++spec :: SpecWith ((), Application)+spec =+ describe "CORS" $ do+ it "replies naively and permissively to preflight request" $+ request methodOptions "/"+ [ ("Accept", "*/*")+ , ("Origin", "http://example.com")+ , ("Access-Control-Request-Method", "POST")+ , ("Access-Control-Request-Headers", "Foo,Bar") ]+ ""+ `shouldRespondWith`+ ""+ { matchHeaders = [ "Access-Control-Allow-Origin" <:> "http://example.com"+ , "Access-Control-Allow-Credentials" <:> "true"+ , "Access-Control-Allow-Methods" <:> "GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD"+ , "Access-Control-Allow-Headers" <:> "Authorization, Foo, Bar, Accept, Accept-Language, Content-Language"+ , "Access-Control-Max-Age" <:> "86400" ]+ }++ it "exposes necesssary response headers to regular request" $+ request methodGet "/items"+ [("Origin", "http://example.com")]+ ""+ `shouldRespondWith`+ ResponseMatcher+ { matchStatus = 200+ , matchBody = MatchBody (\_ _ -> Nothing) -- match any body+ , matchHeaders = [ "Access-Control-Expose-Headers" <:>+ "Content-Encoding, Content-Location, Content-Range, Content-Type, \+ \Date, Location, Server, Transfer-Encoding, Range-Unit"]+ }++ it "allows INFO body through even with CORS request headers present to postflight request" $+ request methodOptions "/items"+ [ ("Host", "localhost:3000")+ , ("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:32.0) Gecko/20100101 Firefox/32.0")+ , ("Origin", "http://localhost:8000")+ , ("Accept", "text/csv, */*; q=0.01")+ , ("Accept-Language", "en-US,en;q=0.5")+ , ("Accept-Encoding", "gzip, deflate")+ , ("Referer", "http://localhost:8000/")+ , ("Connection", "keep-alive") ]+ ""+ `shouldRespondWith`+ ""+ { matchHeaders = [ "Access-Control-Allow-Origin" <:> "*" ] }
+ test/spec/Feature/DeleteSpec.hs view
@@ -0,0 +1,92 @@+module Feature.DeleteSpec 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)++spec :: SpecWith ((), Application)+spec =+ describe "Deleting" $ do+ context "existing record" $ do+ it "succeeds with 204 and deletion count" $+ request methodDelete "/items?id=eq.1" [] ""+ `shouldRespondWith` ""+ { matchStatus = 204+ , matchHeaders = ["Content-Range" <:> "*/*"]+ }++ it "returns the deleted item and count if requested" $+ request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""+ `shouldRespondWith` [json|[{"id":2}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "*/1"]+ }++ it "ignores ?select= when return not set or return=minimal" $ do+ request methodDelete "/items?id=eq.3&select=id" [] ""+ `shouldRespondWith` ""+ { matchStatus = 204+ , matchHeaders = ["Content-Range" <:> "*/*"]+ }+ request methodDelete "/items?id=eq.3&select=id" [("Prefer", "return=minimal")] ""+ `shouldRespondWith` ""+ { matchStatus = 204+ , matchHeaders = ["Content-Range" <:> "*/*"]+ }++ it "returns the deleted item and shapes the response" $+ request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""+ `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` [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` [json|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "*/*"]+ }++ context "known route, no records matched" $+ it "includes [] body if return=rep" $+ request methodDelete "/items?id=eq.101"+ [("Prefer", "return=representation")] ""+ `shouldRespondWith` "[]"+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "*/*"]+ }++ context "totally unknown route" $+ it "fails with 404" $+ request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404++ context "table with limited privileges" $ do+ it "fails deleting the row when return=representation and selecting all the columns" $+ request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty+ `shouldRespondWith` 401++ it "succeeds deleting the row when return=representation and selecting only the privileged columns" $+ request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]+ [json| { "password": "passxyz" } |]+ `shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "*/*"]+ }++ it "suceeds deleting the row with no explicit select when using return=minimal" $+ request methodDelete "/app_users?id=eq.2" [("Prefer", "return=minimal")] mempty+ `shouldRespondWith` 204++ it "suceeds deleting the row with no explicit select by default" $+ request methodDelete "/app_users?id=eq.3" [] mempty+ `shouldRespondWith` 204
+ test/spec/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/spec/Feature/EmbedDisambiguationSpec.hs view
@@ -0,0 +1,464 @@+module Feature.EmbedDisambiguationSpec where++import Network.Wai (Application)++import Test.Hspec hiding (pendingWith)+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import Protolude hiding (get)+import SpecHelper++spec :: SpecWith ((), Application)+spec =+ describe "resource embedding disambiguation" $ do+ context "ambiguous requests that give 300 Multiple Choices" $ do+ it "errs when there's a table and view that point to the same fk" $+ get "/message?select=id,body,sender(name,sent)" `shouldRespondWith`+ [json|+ {+ "details": [+ {+ "cardinality": "many-to-one",+ "relationship": "message_sender_fkey using message(sender) and person(id)",+ "embedding": "message with person"+ },+ {+ "cardinality": "many-to-one",+ "relationship": "message_sender_fkey using message(sender) and person_detail(id)",+ "embedding": "message with person_detail"+ }+ ],+ "hint": "Try changing 'sender' to one of the following: 'person!message_sender_fkey', 'person_detail!message_sender_fkey'. Find the desired relationship in the 'details' key.",+ "message": "Could not embed because more than one relationship was found for 'message' and 'sender'"+ }+ |]+ { matchStatus = 300+ , matchHeaders = [matchContentTypeJson]+ }++ it "errs when there's a table and view that point to the same fk (composite pk)" $+ get "/activities?select=fst_shift(*)" `shouldRespondWith`+ [json|+ {+ "details": [+ {+ "cardinality": "one-to-many",+ "relationship": "fst_shift using activities(id, schedule_id) and unit_workdays(fst_shift_activity_id, fst_shift_schedule_id)",+ "embedding": "activities with unit_workdays"+ },+ {+ "cardinality": "one-to-many",+ "relationship": "fst_shift using activities(id, schedule_id) and unit_workdays_fst_shift(fst_shift_activity_id, fst_shift_schedule_id)",+ "embedding": "activities with unit_workdays_fst_shift"+ }+ ],+ "hint": "Try changing 'fst_shift' to one of the following: 'unit_workdays!fst_shift', 'unit_workdays_fst_shift!fst_shift'. Find the desired relationship in the 'details' key.",+ "message": "Could not embed because more than one relationship was found for 'activities' and 'fst_shift'"+ }+ |]+ { matchStatus = 300+ , matchHeaders = [matchContentTypeJson]+ }++ it "errs when there are o2m and m2m cardinalities to the target table" $+ get "/sites?select=*,big_projects(*)" `shouldRespondWith`+ [json|+ {+ "details": [+ {+ "cardinality": "many-to-one",+ "relationship": "main_project using sites(main_project_id) and big_projects(big_project_id)",+ "embedding": "sites with big_projects"+ },+ {+ "cardinality": "many-to-many",+ "relationship": "jobs using jobs_site_id_fkey(site_id) and jobs_big_project_id_fkey(big_project_id)",+ "embedding": "sites with big_projects"+ },+ {+ "cardinality": "many-to-many",+ "relationship": "main_jobs using jobs_site_id_fkey(site_id) and jobs_big_project_id_fkey(big_project_id)",+ "embedding": "sites with big_projects"+ }+ ],+ "hint": "Try changing 'big_projects' to one of the following: 'big_projects!main_project', 'big_projects!jobs', 'big_projects!main_jobs'. Find the desired relationship in the 'details' key.",+ "message": "Could not embed because more than one relationship was found for 'sites' and 'big_projects'"+ }+ |]+ { matchStatus = 300+ , matchHeaders = [matchContentTypeJson]+ }++ it "errs on an ambiguous embed that has a circular reference" $+ get "/agents?select=*,departments(*)" `shouldRespondWith`+ [json|+ {+ "details": [+ {+ "cardinality": "many-to-one",+ "relationship": "agents_department_id_fkey using agents(department_id) and departments(id)",+ "embedding": "agents with departments"+ },+ {+ "cardinality": "one-to-many",+ "relationship": "departments_head_id_fkey using agents(id) and departments(head_id)",+ "embedding": "agents with departments"+ }+ ],+ "hint": "Try changing 'departments' to one of the following: 'departments!agents_department_id_fkey', 'departments!departments_head_id_fkey'. Find the desired relationship in the 'details' key.",+ "message": "Could not embed because more than one relationship was found for 'agents' and 'departments'"+ }+ |]+ { matchStatus = 300+ , matchHeaders = [matchContentTypeJson]+ }++ it "errs when there are more than two fks on a junction table(currently impossible to disambiguate, only choice is to split the table)" $+ -- We have 4 possibilities for doing the junction JOIN here.+ -- This could be solved by specifying two additional fks, like whatev_projects!fk1!fk2(*)+ -- If the need arises this capability can be added later without causing a breaking change+ get "/whatev_sites?select=*,whatev_projects(*)" `shouldRespondWith`+ [json|+ {+ "details": [+ {+ "cardinality": "many-to-many",+ "relationship": "whatev_jobs using whatev_jobs_site_id_1_fkey(site_id_1) and whatev_jobs_project_id_1_fkey(project_id_1)",+ "embedding": "whatev_sites with whatev_projects"+ },+ {+ "cardinality": "many-to-many",+ "relationship": "whatev_jobs using whatev_jobs_site_id_1_fkey(site_id_1) and whatev_jobs_project_id_2_fkey(project_id_2)",+ "embedding": "whatev_sites with whatev_projects"+ },+ {+ "cardinality": "many-to-many",+ "relationship": "whatev_jobs using whatev_jobs_site_id_2_fkey(site_id_2) and whatev_jobs_project_id_1_fkey(project_id_1)",+ "embedding": "whatev_sites with whatev_projects"+ },+ {+ "cardinality": "many-to-many",+ "relationship": "whatev_jobs using whatev_jobs_site_id_2_fkey(site_id_2) and whatev_jobs_project_id_2_fkey(project_id_2)",+ "embedding": "whatev_sites with whatev_projects"+ }+ ],+ "hint": "Try changing 'whatev_projects' to one of the following: 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs', 'whatev_projects!whatev_jobs'. Find the desired relationship in the 'details' key.",+ "message": "Could not embed because more than one relationship was found for 'whatev_sites' and 'whatev_projects'"+ }+ |]+ { matchStatus = 300+ , matchHeaders = [matchContentTypeJson]+ }++ context "disambiguating requests with embed hints" $ do++ context "using FK to specify the relationship" $ do+ it "can embed by FK name" $+ get "/projects?id=in.(1,3)&select=id,name,client(id,name)" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client":{"id":2,"name":"Apple"}}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can embed by FK name and select the FK column at the same time" $+ get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can 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`+ [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`+ [json| [+ {+ "day": "2019-12-02",+ "fst_shift": {+ "car_id": "CAR-349",+ "schedule": {+ "name": "morning"+ }+ },+ "snd_shift": {+ "camera_id": "CAM-123",+ "schedule": {+ "name": "night"+ }+ },+ "unit_id": 1+ }+ ] |]+ { matchHeaders = [matchContentTypeJson] }++ it "embeds by using two fks pointing to the same table" $+ get "/orders?id=eq.1&select=id, name, billing(address), shipping(address)" `shouldRespondWith`+ [json|[{"id":1,"name":"order 1","billing":{"address": "address 1"},"shipping":{"address": "address 2"}}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "fails if the fk is not known" $+ get "/message?select=id,sender:person!space(name)&id=lt.4" `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 message and person in the schema cache"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson] }++ it "can request a parent with fk" $+ get "/comments?select=content,user(name)" `shouldRespondWith`+ [json|[ { "content": "Needs to be delivered ASAP", "user": { "name": "Angela Martin" } } ]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can request two parents with fks" $+ 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`+ [json|+ [{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},+ {"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},+ {"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can specify a table!fk hint and request children 2 levels" $+ get "/clients?id=eq.1&select=id,projects:projects!client(id,tasks(id))" `shouldRespondWith`+ [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can disambiguate with the fk in case of an o2m and m2m relationship to the same table" $+ get "/sites?select=name,main_project(name)&site_id=eq.1" `shouldRespondWith`+ [json| [ { "name": "site 1", "main_project": { "name": "big project 1" } } ] |]+ { matchHeaders = [matchContentTypeJson] }++ context "using the column name of the FK to specify the relationship" $ do+ it "can embed by column" $+ get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can embed by column and select the column at the same time, if aliased" $+ get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can 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`+ [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`+ [json|+ [{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},+ {"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},+ {"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "will embed using a column that has uppercase chars" $+ get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`+ [json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "embeds by using two columns pointing to the same table" $+ get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`+ [json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can disambiguate with the column in case of an o2m and m2m relationship to the same table" $+ get "/sites?select=name,main_project_id(name)&site_id=eq.1" `shouldRespondWith`+ [json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]+ { matchHeaders = [matchContentTypeJson] }++ context "using the junction to disambiguate the request" $+ it "can specify the junction of an m2m relationship" $ do+ get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`+ [json|+ [+ {+ "big_projects": [+ {+ "name": "big project 1"+ }+ ],+ "main_project_id": 1,+ "name": "site 1",+ "site_id": 1+ },+ {+ "big_projects": [+ {+ "name": "big project 1"+ },+ {+ "name": "big project 2"+ }+ ],+ "main_project_id": null,+ "name": "site 2",+ "site_id": 2+ }+ ]+ |]+ get "/sites?select=*,big_projects!main_jobs(name)&site_id=in.(1,2)" `shouldRespondWith`+ [json|+ [+ {+ "big_projects": [+ {+ "name": "big project 1"+ }+ ],+ "main_project_id": 1,+ "name": "site 1",+ "site_id": 1+ },+ {+ "big_projects": [],+ "main_project_id": null,+ "name": "site 2",+ "site_id": 2+ }+ ]+ |]+ { matchHeaders = [matchContentTypeJson] }++ context "using a FK column and a FK to specify the relationship" $+ it "embeds by using a column and a fk pointing to the same table" $+ get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping(id)" `shouldRespondWith`+ [json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping":{"id":2}}]|]+ { matchHeaders = [matchContentTypeJson] }++ context "tables with self reference foreign keys" $ do+ context "one self reference foreign key" $ do+ it "embeds parents recursively" $+ get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`+ [json|[+ { "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },+ { "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }+ ]|]+ { matchHeaders = [matchContentTypeJson] }++ it "embeds children recursively" $+ get "/family_tree?id=eq.1&select=id,name, children:family_tree!parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`+ [json|[{+ "id": "1", "name": "Parental Unit", "children": [+ { "id": "2", "name": "Kid One", "children": [ { "id": "4", "name": "Grandkid One" } ] },+ { "id": "3", "name": "Kid Two", "children": [ { "id": "5", "name": "Grandkid Two" } ] }+ ]+ }]|] { matchHeaders = [matchContentTypeJson] }++ it "embeds parent and then embeds children" $+ get "/family_tree?id=eq.2&select=id,name,parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`+ [json|[{+ "id": "2", "name": "Kid One", "parent": {+ "id": "1", "name": "Parental Unit", "children": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]+ }+ }]|] { matchHeaders = [matchContentTypeJson] }++ context "two self reference foreign keys" $ do+ it "embeds parents" $+ get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`+ [json|[{+ "id": 3, "name": "Acme",+ "referee": {+ "id": 1,+ "name": "Referee Org"+ },+ "auditor": {+ "id": 2,+ "name": "Auditor Org"+ }+ }]|] { matchHeaders = [matchContentTypeJson] }++ it "embeds children" $ do+ get "/organizations?select=id,name,refereeds:organizations!referee(id,name)&id=eq.1" `shouldRespondWith`+ [json|[{+ "id": 1, "name": "Referee Org",+ "refereeds": [+ {+ "id": 3,+ "name": "Acme"+ },+ {+ "id": 4,+ "name": "Umbrella"+ }+ ]+ }]|] { matchHeaders = [matchContentTypeJson] }+ get "/organizations?select=id,name,auditees:organizations!auditor(id,name)&id=eq.2" `shouldRespondWith`+ [json|[{+ "id": 2, "name": "Auditor Org",+ "auditees": [+ {+ "id": 3,+ "name": "Acme"+ },+ {+ "id": 4,+ "name": "Umbrella"+ }+ ]+ }]|] { matchHeaders = [matchContentTypeJson] }++ it "embeds other relations(manager) besides the self reference" $ do+ get "/organizations?select=name,manager(name),referee(name,manager(name),auditor(name,manager(name))),auditor(name,manager(name),referee(name,manager(name)))&id=eq.5" `shouldRespondWith`+ [json|[{+ "name":"Cyberdyne",+ "manager":{"name":"Cyberdyne Manager"},+ "referee":{+ "name":"Acme",+ "manager":{"name":"Acme Manager"},+ "auditor":{+ "name":"Auditor Org",+ "manager":{"name":"Auditor Manager"}}},+ "auditor":{+ "name":"Umbrella",+ "manager":{"name":"Umbrella Manager"},+ "referee":{+ "name":"Referee Org",+ "manager":{"name":"Referee Manager"}}}+ }]|] { matchHeaders = [matchContentTypeJson] }++ get "/organizations?select=name,manager(name),auditees:organizations!auditor(name,manager(name),refereeds:organizations!referee(name,manager(name)))&id=eq.2" `shouldRespondWith`+ [json|[{+ "name":"Auditor Org",+ "manager":{"name":"Auditor Manager"},+ "auditees":[+ {"name":"Acme",+ "manager":{"name":"Acme Manager"},+ "refereeds":[+ {"name":"Cyberdyne",+ "manager":{"name":"Cyberdyne Manager"}},+ {"name":"Oscorp",+ "manager":{"name":"Oscorp Manager"}}]},+ {"name":"Umbrella",+ "manager":{"name":"Umbrella Manager"},+ "refereeds":[]}]+ }]|] { 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/spec/Feature/EmbedInnerJoinSpec.hs view
@@ -0,0 +1,356 @@+module Feature.EmbedInnerJoinSpec where++import Network.HTTP.Types+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 "Embedding with an inner join" $ do+ context "many-to-one relationships" $ do+ it "ignores null embeddings while the default left join doesn't" $ do+ get "/projects?select=id,clients!inner(id)" `shouldRespondWith`+ [json|[+ {"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},+ {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/projects?select=id,clients!left(id)" `shouldRespondWith`+ [json|[+ {"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}},+ {"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}},+ {"id":5,"clients":null}]|]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/projects?select=id,clients!inner(id)" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-3/4" ]+ }++ it "filters source tables when the embedded table is filtered" $ do+ get "/projects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`+ [json|[+ {"id":1,"clients":{"id":1}},+ {"id":2,"clients":{"id":1}}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/projects?select=id,clients!inner(id)&clients.id=eq.2" `shouldRespondWith`+ [json|[+ {"id":3,"clients":{"id":2}},+ {"id":4,"clients":{"id":2}}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/projects?select=id,clients!inner(id)&clients.id=eq.0" `shouldRespondWith`+ [json|[]|]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/projects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-1/2" ]+ }++ it "filters source tables when a two levels below embedded table is filtered" $ do+ get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" `shouldRespondWith`+ [json|[+ {"id":1,"projects":{"id":1,"clients":{"id":1}}},+ {"id":2,"projects":{"id":1,"clients":{"id":1}}},+ {"id":3,"projects":{"id":2,"clients":{"id":1}}},+ {"id":4,"projects":{"id":2,"clients":{"id":1}}}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`+ [json|[+ {"id":5,"projects":{"id":3,"clients":{"id":2}}},+ {"id":6,"projects":{"id":3,"clients":{"id":2}}},+ {"id":7,"projects":{"id":4,"clients":{"id":2}}},+ {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/tasks?select=id,projects!inner(id,clients!inner(id))&projects.clients.id=eq.1" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-3/4" ]+ }++ it "only affects the source table rows if his direct embedding is an inner join" $ do+ get "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" `shouldRespondWith`+ [json|[+ {"id":1,"projects":null},+ {"id":2,"projects":null},+ {"id":3,"projects":null},+ {"id":4,"projects":null},+ {"id":5,"projects":{"id":3,"clients":{"id":2}}},+ {"id":6,"projects":{"id":3,"clients":{"id":2}}},+ {"id":7,"projects":{"id":4,"clients":{"id":2}}},+ {"id":8,"projects":{"id":4,"clients":{"id":2}}}]|]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/tasks?select=id,projects(id,clients!inner(id))&projects.clients.id=eq.2" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-7/8" ]+ }++ it "works with views" $ do+ get "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" `shouldRespondWith`+ [json| [{"title":"1984","authors":{"name":"George Orwell"}}] |]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/books?select=title,authors!inner(name)&authors.name=eq.George%20Orwell" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-0/1" ]+ }++ context "one-to-many relationships" $ do+ it "ignores empty array embeddings while the default left join doesn't" $ do+ get "/entities?select=id,child_entities!inner(id)" `shouldRespondWith`+ [json|[+ {"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},+ {"id":2,"child_entities":[{"id":3}, {"id":6}]}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/entities?select=id,child_entities!left(id)" `shouldRespondWith`+ [json| [+ {"id":1,"child_entities":[{"id":1}, {"id":2}, {"id":4}, {"id":5}]},+ {"id":2,"child_entities":[{"id":3}, {"id":6}]},+ {"id":3,"child_entities":[]},+ {"id":4,"child_entities":[]}] |]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/entities?select=id,child_entities!inner(id)" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-1/2" ]+ }++ it "filters source tables when the embedded table is filtered" $ do+ get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" `shouldRespondWith`+ [json|[{"id":1,"child_entities":[{"id":1}]}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.3" `shouldRespondWith`+ [json|[{"id":2,"child_entities":[{"id":3}]}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.0" `shouldRespondWith`+ [json|[]|]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/entities?select=id,child_entities!inner(id)&child_entities.id=eq.1" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-0/1" ]+ }++ it "filters source tables when a two levels below embedded table is filtered" $ do+ get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)"+ `shouldRespondWith`+ [json|[+ {+ "id": 1,+ "child_entities": [+ { "id": 1, "grandchild_entities": [ { "id": 1 } ] },+ { "id": 2, "grandchild_entities": [ { "id": 5 } ] }]+ }+ ]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`+ [json|[+ {+ "id": 1,+ "child_entities": [+ { "id": 1, "grandchild_entities": [ { "id": 2 } ] } ]+ }+ ]|]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities!inner(id))&child_entities.grandchild_entities.id=in.(1,5)" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-0/1" ]+ }++ it "only affects the source table rows if his direct embedding is an inner join" $ do+ get "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" `shouldRespondWith`+ [json|[+ {+ "id": 1,+ "child_entities": [+ { "id": 1, "grandchild_entities": [ { "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": [] } ]+ }+ ]|]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/entities?select=id,child_entities!inner(id,grandchild_entities(id))&child_entities.grandchild_entities.id=eq.2" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-1/2" ]+ }++ it "works with views" $ do+ get "/authors?select=*,books!inner(*)&books.title=eq.1984" `shouldRespondWith`+ [json| [{"id":1,"name":"George Orwell","books":[{"id":1,"title":"1984","publication_year":1949,"author_id":1}]}] |]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/authors?select=*,books!inner(*)&books.title=eq.1984" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-0/1" ]+ }++ context "many-to-many relationships" $ do+ it "ignores empty array embeddings while the default left join doesn't" $ do+ get "/products?select=id,suppliers!inner(id)" `shouldRespondWith`+ [json| [+ {"id":1,"suppliers":[{"id":1}, {"id":2}]},+ {"id":2,"suppliers":[{"id":1}, {"id":3}]}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/products?select=id,suppliers!left(id)" `shouldRespondWith`+ [json| [+ {"id":1,"suppliers":[{"id":1}, {"id":2}]},+ {"id":2,"suppliers":[{"id":1}, {"id":3}]},+ {"id":3,"suppliers":[]}] |]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/products?select=id,suppliers!inner(id)" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-1/2" ]+ }++ it "filters source tables when the embedded table is filtered" $ do+ get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" `shouldRespondWith`+ [json| [{"id":1,"suppliers":[{"id":2}]}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.3" `shouldRespondWith`+ [json| [{"id":2,"suppliers":[{"id":3}]}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/products?select=id,suppliers!inner(id)&suppliers.id=eq.0" `shouldRespondWith`+ [json| [] |]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/products?select=id,suppliers!inner(id)&suppliers.id=eq.2" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-0/1" ]+ }++ it "filters source tables when a two levels below embedded table is filtered" $ do+ get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3"+ `shouldRespondWith`+ [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":3}]}]}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.4"+ `shouldRespondWith`+ [json|[{"id":1,"suppliers":[{"id":2,"trade_unions":[{"id":4}]}]}] |]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/products?select=id,suppliers!inner(id,trade_unions!inner(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-0/1" ]+ }++ it "only affects the source table rows if his direct embedding is an inner join" $ do+ get "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" `shouldRespondWith`+ [json|[+ {"id":1,"suppliers":[{"id":1,"trade_unions":[]}, {"id":2,"trade_unions":[{"id":3}]}]},+ {"id":2,"suppliers":[{"id":1,"trade_unions":[]}, {"id":3,"trade_unions":[]}]}]|]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/products?select=id,suppliers!inner(id,trade_unions(id))&suppliers.trade_unions.id=eq.3" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-1/2" ]+ }++ it "works with views" $ do+ get "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" `shouldRespondWith`+ [json| [{"id":1,"name":"john","films":[{"id":12,"title":"douze commandements"}]}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/films?select=*,actors!inner(*)&actors.name=eq.john" `shouldRespondWith`+ [json| [{"id":12,"title":"douze commandements","actors":[{"id":1,"name":"john"}]}] |]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/actors?select=*,films!inner(*)&films.title=eq.douze%20commandements" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-0/1" ]+ }++ it "works with m2o and m2m relationships combined" $ do+ get "/projects?select=name,clients!inner(name),users!inner(name)" `shouldRespondWith`+ [json| [+ {"name":"Windows 7","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}, {"name":"Dwight Schrute"}]},+ {"name":"Windows 10","clients":{"name":"Microsoft"},"users":[{"name":"Angela Martin"}]},+ {"name":"IOS","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}, {"name":"Dwight Schrute"}]},+ {"name":"OSX","clients":{"name":"Apple"},"users":[{"name":"Michael Scott"}]}]|]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/projects?select=name,clients!inner(name),users!inner(name)" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-3/4" ]+ }++ it "works with rpc" $ do+ get "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" `shouldRespondWith`+ [json| [{"id":1,"clients":{"id":1}}, {"id":2,"clients":{"id":1}}] |]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/rpc/getallprojects?select=id,clients!inner(id)&clients.id=eq.1" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-1/2" ]+ }++ it "works when using hints" $ do+ get "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" `shouldRespondWith`+ [json| [{"id":3,"clients":{"id":2}}, {"id":4,"clients":{"id":2}}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/projects?select=id,client!inner(id)&client.id=eq.2" `shouldRespondWith`+ [json| [{"id":3,"client":{"id":2}}, {"id":4,"client":{"id":2}}] |]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/projects?select=id,clients!client!inner(id)&clients.id=eq.2" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-1/2" ]+ }++ it "works with many one-to-many relationships" $ do+ -- https://github.com/PostgREST/postgrest/issues/1977+ get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" `shouldRespondWith`+ [json|[+ {"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}, {"name":"Wilma Wellers"}],"clientinfo":[{"other":"123 Main St"}]},+ {"id":2,"name":"Target", "contact":[{"name":"Tabby Targo"}],"clientinfo":[{"other":"456 South 3rd St"}]},+ {"id":3,"name":"Big Lots","contact":[{"name":"Bobby Bots"}, {"name":"Bonnie Bits"}, {"name":"Billy Boats"}],"clientinfo":[{"other":"789 Palm Tree Ln"}]}+ ]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&contact.name=eq.Wally%20Walton" `shouldRespondWith`+ [json|[+ {"id":1,"name":"Walmart","contact":[{"name":"Wally Walton"}],"clientinfo":[{"other":"123 Main St"}]}+ ]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/client?select=id,name,contact!inner(name),clientinfo!inner(other)&clientinfo.other=eq.456%20South%203rd%20St" `shouldRespondWith`+ [json|[+ {"id":2,"name":"Target","clientinfo":[{"other":"456 South 3rd St"}],"contact":[{"name":"Tabby Targo"}]}+ ]|]+ { matchHeaders = [matchContentTypeJson] }+ request methodHead "/client?select=id,name,contact!inner(name),clientinfo!inner(other)" [("Prefer", "count=exact")] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson+ , "Content-Range" <:> "0-2/3" ]+ }
+ test/spec/Feature/ExtraSearchPathSpec.hs view
@@ -0,0 +1,39 @@+module Feature.ExtraSearchPathSpec where++import Network.HTTP.Types+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 "extra search path" $ do++ it "finds the ltree <@ operator on the public schema" $+ request methodGet "/ltree_sample?path=cd.Top.Science.Astronomy" [] ""+ `shouldRespondWith` [json|[+ {"path":"Top.Science.Astronomy"},+ {"path":"Top.Science.Astronomy.Astrophysics"},+ {"path":"Top.Science.Astronomy.Cosmology"}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "finds the ltree nlevel function on the public schema, used through a computed column" $+ request methodGet "/ltree_sample?select=number_of_labels&path=eq.Top.Science" [] ""+ `shouldRespondWith` [json|[{"number_of_labels":2}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "finds the isn = operator on the extensions schema" $+ request methodGet "/isn_sample?id=eq.978-0-393-04002-9&select=name" [] ""+ `shouldRespondWith` [json|[{"name":"Mathematics: From the Birth of Numbers"}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "finds the isn is_valid function on the extensions schema" $+ 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/spec/Feature/HtmlRawOutputSpec.hs view
@@ -0,0 +1,30 @@+module Feature.HtmlRawOutputSpec where++import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec hiding (pendingWith)+import Test.Hspec.Wai+import Text.Heredoc++import Protolude hiding (get)+import SpecHelper (acceptHdrs)++spec :: SpecWith ((), Application)+spec = describe "When raw-media-types is set to \"text/html\"" $+ it "can get raw output with Accept: text/html" $+ request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""+ `shouldRespondWith`+ [str|+ |<html>+ | <head>+ | <title>PostgREST</title>+ | </head>+ | <body>+ | <h1>Welcome to PostgREST</h1>+ | </body>+ |</html>+ |]+ { matchStatus = 200+ , matchHeaders = ["Content-Type" <:> "text/html"]+ }
+ test/spec/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/spec/Feature/InsertSpec.hs view
@@ -0,0 +1,558 @@+module Feature.InsertSpec where++import Data.List (lookup)+import Network.Wai (Application)+import Network.Wai.Test (SResponse (simpleHeaders))+import Test.Hspec hiding (pendingWith)+import Test.Hspec.Wai.Matcher (bodyEquals)++import Network.HTTP.Types+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON+import Text.Heredoc++import PostgREST.Config.PgVersion (PgVersion, pgVersion110,+ pgVersion112, pgVersion130)++import Protolude hiding (get)+import SpecHelper++spec :: PgVersion -> SpecWith ((), Application)+spec actualPgVersion = do+ describe "Posting new record" $ do+ context "disparate json types" $ do+ it "accepts disparate json types" $ do+ post "/menagerie"+ [json| {+ "integer": 13, "double": 3.14159, "varchar": "testing!"+ , "boolean": false, "date": "1900-01-01", "money": "$3.99"+ , "enum": "foo"+ } |] `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")]+ [json| [{+ "integer": 14, "double": 3.14159, "varchar": "testing!"+ , "boolean": false, "date": "1900-01-01", "money": "$3.99"+ , "enum": "foo"+ }] |] `shouldRespondWith` [json|[{"integer":14,"varchar":"testing!"}]|]+ { matchStatus = 201+ , matchHeaders = [matchContentTypeJson]+ }++ it "ignores &select when return not set or using return=minimal" $ do+ request methodPost "/menagerie?select=integer,varchar" []+ [json| [{+ "integer": 15, "double": 3.14159, "varchar": "testing!"+ , "boolean": false, "date": "1900-01-01", "money": "$3.99"+ , "enum": "foo"+ }] |] `shouldRespondWith` ""+ { matchStatus = 201 }+ request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=minimal")]+ [json| [{+ "integer": 16, "double": 3.14159, "varchar": "testing!"+ , "boolean": false, "date": "1900-01-01", "money": "$3.99"+ , "enum": "foo"+ }] |] `shouldRespondWith` ""+ { matchStatus = 201 }++ context "non uniform json array" $ do+ it "rejects json array that isn't exclusivily composed of objects" $+ post "/articles"+ [json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |]+ `shouldRespondWith`+ [json| {"message":"All object keys must match"} |]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ it "rejects json array that has objects with different keys" $+ post "/articles"+ [json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |]+ `shouldRespondWith`+ [json| {"message":"All object keys must match"} |]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ context "requesting full representation" $ do+ it "includes related data after insert" $+ request methodPost "/projects?select=id,name,clients(id,name)"+ [("Prefer", "return=representation"), ("Prefer", "count=exact")]+ [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"+ , "Content-Range" <:> "*/1" ]+ }++ it "can rename and cast the selected columns" $+ request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"+ [("Prefer", "return=representation")]+ [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" $ do+ 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" <:> "*/*" ]+ }++ when (actualPgVersion >= pgVersion110) $+ it "should not throw and return location header for partitioned tables when selecting without PK" $+ request methodPost "/car_models" [("Prefer", "return=headers-only")]+ [json|{"name":"Enzo","year":2021}|] `shouldRespondWith` ""+ { matchStatus = 201+ , matchHeaders = [ "Location" <:> "/car_models?name=eq.Enzo&year=eq.2021"+ , "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+ 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 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`+ (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 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+ 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+ request methodPost "/no_pk"+ [("Prefer", "return=representation")]+ [json| [] |]+ `shouldRespondWith`+ [json| [] |]+ { matchStatus = 201 }++ it "can post nulls" $ do+ 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+ 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"}]+ |]+ request methodPost "/compound_pk" [] bulkData+ `shouldRespondWith`+ ""+ { matchStatus = 201+ , matchHeaders = [matchHeaderAbsent hLocation]+ }++ context "with invalid json payload" $+ it "fails with 400 and error" $+ post "/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" $+ post "/simple_pk" ""+ `shouldRespondWith`+ [json|{"message":"Error in $: not enough input"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ context "with valid json payload" $+ it "succeeds and returns 201 created" $+ post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |] `shouldRespondWith` 201++ context "attempting to insert a row with the same primary key" $+ it "fails returning a 409 Conflict" $+ post "/simple_pk"+ [json| { "k":"xyyx", "extra":"e1" } |]+ `shouldRespondWith`+ [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" $+ post "/withUnique" [json| { "uni":"nodup", "extra":"e2" } |] `shouldRespondWith` 409++ context "jsonb" $ do+ it "serializes nested object" $ do+ let inserted = [json| { "data": { "foo":"bar" } } |]+ request methodPost "/json_table"+ [("Prefer", "return=representation")]+ inserted+ `shouldRespondWith` [json|[{"data":{"foo":"bar"}}]|]+ { matchStatus = 201+ }++ it "serializes nested array" $ do+ let inserted = [json| { "data": [1,2,3] } |]+ request methodPost "/json_table"+ [("Prefer", "return=representation")]+ inserted+ `shouldRespondWith` [json|[{"data":[1,2,3]}]|]+ { matchStatus = 201+ }++ context "empty objects" $ do+ it "successfully inserts a row with all-default columns" $ do+ post "/items" "{}" `shouldRespondWith` ""+ { matchStatus = 201+ , matchHeaders = []+ }+ post "/items" "[{}]" `shouldRespondWith` ""+ { matchStatus = 201+ , matchHeaders = []+ }++ it "successfully inserts two rows with all-default columns" $+ post "/items" "[{}, {}]" `shouldRespondWith` ""+ { 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|""|]++ 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")]+ [json| {"id": 200, "body": "xxx", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`+ [json|[{"id": 200, "body": "xxx", "owner": "postgrest_test_anonymous"}]|]+ { matchStatus = 201+ , matchHeaders = [] }+ request methodPost "/articles?columns=id,body&select=id,body" [("Prefer", "return=representation")]+ [json| [+ {"id": 201, "body": "yyy", "smth": "here", "other": "stuff", "fake_id": 13},+ {"id": 202, "body": "zzz", "garbage": "%%$&", "kkk": "jjj"},+ {"id": 203, "body": "aaa", "hey": "ho"} ]|] `shouldRespondWith`+ [json|[+ {"id": 201, "body": "yyy"},+ {"id": 202, "body": "zzz"},+ {"id": 203, "body": "aaa"} ]|]+ { matchStatus = 201+ , matchHeaders = [] }++ -- TODO parse columns error message needs to be improved+ it "disallows blank ?columns" $+ post "/articles?columns="+ [json|[+ {"id": 204, "body": "yyy"},+ {"id": 205, "body": "zzz"}]|]+ `shouldRespondWith`+ [json| {"details":"unexpected end of input expecting field name (* or [a..z0..9_])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)"} |]+ { matchStatus = 400+ , matchHeaders = []+ }++ it "disallows array elements that are not json objects" $+ post "/articles?columns=id,body"+ [json|[+ {"id": 204, "body": "yyy"},+ 333,+ "asdf",+ {"id": 205, "body": "zzz"}]|] `shouldRespondWith`+ [json|{+ "code": "22023",+ "details": null,+ "hint": null,+ "message": "argument of json_populate_recordset must be an array of objects"}|]+ { matchStatus = 400+ , matchHeaders = []+ }++ describe "CSV insert" $ do+ context "disparate csv types" $+ it "succeeds with multipart response" $ do+ pendingWith "Decide on what to do with CSV insert"+ let inserted = [str|integer,double,varchar,boolean,date,money,enum+ |13,3.14159,testing!,false,1900-01-01,$3.99,foo+ |12,0.1,a string,true,1929-10-01,12,bar+ |]+ request methodPost "/menagerie" [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] inserted+ `shouldRespondWith` ResponseMatcher+ { matchStatus = 201+ , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]+ , matchBody = bodyEquals inserted+ }++ context "requesting full representation" $ do+ it "returns full details of inserted record" $+ request methodPost "/no_pk"+ [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]+ "a,b\nbar,baz"+ `shouldRespondWith` "a,b\nbar,baz"+ { matchStatus = 201+ , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]+ }++ it "can post nulls" $+ request methodPost "/no_pk"+ [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]+ "a,b\nNULL,foo"+ `shouldRespondWith` "a,b\n,foo"+ { matchStatus = 201+ , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]+ }++ it "only returns the requested column header with its associated data" $+ request methodPost "/projects?select=id"+ [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]+ "id,name,client_id\n8,Xenix,1\n9,Windows NT,1"+ `shouldRespondWith` "id\n8\n9"+ { matchStatus = 201+ , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",+ "Content-Range" <:> "*/*"]+ }++ context "with wrong number of columns" $+ it "fails for too few" $+ request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"+ `shouldRespondWith`+ [json|{"message":"All lines must have same number of fields"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ context "with unicode values" $+ it "succeeds and returns usable location header" $ do+ 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+ get location+ `shouldRespondWith`+ [json|[ { "k":"圍棋", "extra":"¥" } ]|]++ request methodDelete location+ [("Prefer", "tx=commit")]+ ""+ `shouldRespondWith`+ 204++ describe "Row level permission" $+ it "set user_id when inserting rows" $ do+ request methodPost "/authors_only"+ [ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0", ("Prefer", "return=representation") ]+ [json| { "secret": "nyancat" } |]+ `shouldRespondWith`+ [json|[{"owner":"jdoe","secret":"nyancat"}]|]+ { matchStatus = 201 }++ 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" $+ request methodPost "/web_content?select=id,name,parent_content:p_web_id(name)"+ [("Prefer", "return=representation")]+ [json|{"id":6, "name":"wot", "p_web_id":4}|]+ `shouldRespondWith`+ [json|[{"id":6,"name":"wot","parent_content":{"name":"wut"}}]|]+ { matchStatus = 201+ , matchHeaders = [ matchContentTypeJson , "Location" <:> "/web_content?id=eq.6" ]+ }++ 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` [json|[{"article_id":2,"user_id":1}]|]+ { matchStatus = 201+ , matchHeaders = []+ }++ it "fails inserting if more columns are selected" $+ 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+ [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]+ else+ [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]+ )+ { matchStatus = 401+ , matchHeaders = []+ }++ it "fails inserting if select is not specified" $+ request methodPost "/limited_article_stars" [("Prefer", "return=representation")]+ [json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (+ if actualPgVersion >= pgVersion112 then+ [json|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]+ else+ [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+ request methodPost "/insertonly"+ [("Prefer", "return=minimal")]+ [json| { "v":"some value" } |]+ `shouldRespondWith`+ ""+ { matchStatus = 201 }++ 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 ]+ }++ 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/spec/Feature/JsonOperatorSpec.hs view
@@ -0,0 +1,253 @@+module Feature.JsonOperatorSpec where++import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import PostgREST.Config.PgVersion (PgVersion, pgVersion112,+ pgVersion121)++import Protolude hiding (get)+import SpecHelper++spec :: PgVersion -> SpecWith ((), Application)+spec actualPgVersion = describe "json and jsonb operators" $ do+ context "Shaping response with select parameter" $ do+ it "obtains a json subfield one level with casting" $+ get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`+ [json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"+ { matchHeaders = [matchContentTypeJson] }++ it "renames json subfield one level with casting" $+ get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`+ [json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"+ { matchHeaders = [matchContentTypeJson] }++ it "fails on bad casting (data of the wrong format)" $+ get "/complex_items?select=settings->foo->>bar::integer"+ `shouldRespondWith` (+ if actualPgVersion >= pgVersion121 then+ [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]+ else+ [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]+ )+ { matchStatus = 400 , matchHeaders = [] }++ it "obtains a json subfield two levels (string)" $+ get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`+ [json| [{"bar":"baz"}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "renames json subfield two levels (string)" $+ get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`+ [json| [{"myBar":"baz"}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "obtains a json subfield two levels with casting (int)" $+ get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`+ [json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now+ { matchHeaders = [matchContentTypeJson] }++ it "renames json subfield two levels with casting (int)" $+ get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`+ [json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now+ { matchHeaders = [matchContentTypeJson] }++ -- TODO the status code for the error is 404, this is because 42883 represents undefined function+ -- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate+ it "fails when a double arrow ->> is followed with a single arrow ->" $ do+ get "/json_arr?select=data->>c->1"+ `shouldRespondWith` (+ if actualPgVersion >= pgVersion112 then+ [json|+ {"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",+ "details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]+ else+ [json|+ {"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",+ "details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]+ )+ { matchStatus = 404 , matchHeaders = [] }+ get "/json_arr?select=data->>c->b"+ `shouldRespondWith` (+ if actualPgVersion >= pgVersion112 then+ [json|+ {"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",+ "details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]+ else+ [json|+ {"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",+ "details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]+ )+ { matchStatus = 404 , matchHeaders = [] }++ context "with array index" $ do+ it "can get array of ints and alias/cast it" $ do+ get "/json_arr?select=data->>0::int&id=in.(1,2)" `shouldRespondWith`+ [json| [{"data":1}, {"data":4}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/json_arr?select=idx0:data->>0::int,idx1:data->>1::int&id=in.(1,2)" `shouldRespondWith`+ [json| [{"idx0":1,"idx1":2}, {"idx0":4,"idx1":5}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can get nested array of ints" $ do+ get "/json_arr?select=data->0->>1::int&id=in.(3,4)" `shouldRespondWith`+ [json| [{"data":8}, {"data":7}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/json_arr?select=data->0->0->>1::int&id=in.(3,4)" `shouldRespondWith`+ [json| [{"data":null}, {"data":6}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can get array of objects" $ do+ get "/json_arr?select=data->0->>a&id=in.(5,6)" `shouldRespondWith`+ [json| [{"a":"A"}, {"a":"[1,2,3]"}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/json_arr?select=data->0->a->>2&id=in.(5,6)" `shouldRespondWith`+ [json| [{"a":null}, {"a":"3"}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can get array in object keys" $ do+ get "/json_arr?select=data->c->>0::json&id=in.(7,8)" `shouldRespondWith`+ [json| [{"c":1}, {"c":{"d": [4,5,6,7,8]}}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/json_arr?select=data->c->0->d->>4::int&id=in.(7,8)" `shouldRespondWith`+ [json| [{"d":null}, {"d":8}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "only treats well formed numbers as indexes" $+ get "/json_arr?select=data->0->0xy1->1->23-xy-45->1->xy-6->>0::int&id=eq.9" `shouldRespondWith`+ [json| [{"xy-6":3}] |]+ { matchHeaders = [matchContentTypeJson] }++ context "finishing json path with single arrow ->" $ do+ it "works when finishing with a key" $ do+ get "/json_arr?select=data->c&id=in.(7,8)" `shouldRespondWith`+ [json| [{"c":[1,2,3]}, {"c":[{"d": [4,5,6,7,8]}]}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`+ [json| [{"a":"A"}, {"a":[1,2,3]}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "works when finishing with an index" $ do+ get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`+ [json| [{"a":"A"}, {"a":[1,2,3]}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/json_arr?select=data->c->0->d&id=eq.8" `shouldRespondWith`+ [json| [{"d":[4,5,6,7,8]}] |]+ { matchHeaders = [matchContentTypeJson] }++ context "filtering response" $ do+ it "can filter by properties inside json column" $ do+ get "/json_table?data->foo->>bar=eq.baz" `shouldRespondWith`+ [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/json_table?data->foo->>bar=eq.fake" `shouldRespondWith`+ [json| [] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can filter by properties inside json column using not" $+ get "/json_table?data->foo->>bar=not.eq.baz" `shouldRespondWith`+ [json| [] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can filter by properties inside json column using ->>" $+ get "/json_table?data->>id=eq.1" `shouldRespondWith`+ [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can be filtered with and/or" $+ get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`+ [json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }++ it "can filter by array indexes" $ do+ get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`+ [json| [{"data":[1, 2, 3]}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/json_arr?select=data&data->1->>2=eq.13" `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 "can filter jsonb" $ do+ get "/jsonb_test?data=eq.{\"e\":1}" `shouldRespondWith`+ [json| [{"id":4,"data":{"e": 1}}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/jsonb_test?data->a=eq.{\"b\":2}" `shouldRespondWith`+ [json| [{"id":1,"data":{"a": {"b": 2}}}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/jsonb_test?data->c=eq.[1,2,3]" `shouldRespondWith`+ [json| [{"id":2,"data":{"c": [1, 2, 3]}}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/jsonb_test?data->0=eq.{\"d\":\"test\"}" `shouldRespondWith`+ [json| [{"id":3,"data":[{"d": "test"}]}] |]+ { matchHeaders = [matchContentTypeJson] }++ context "ordering response" $ do+ it "orders by a json column property asc" $+ 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_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+ request methodPatch "/json_table?data->>id=eq.3"+ [("Prefer", "return=representation")]+ [json| { "data": { "id":" \"escaped" } } |]+ `shouldRespondWith`+ [json| [{ "data": { "id":" \"escaped" } }] |]++ 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/spec/Feature/LegacyGucsSpec.hs view
@@ -0,0 +1,68 @@+module Feature.LegacyGucsSpec 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)+import SpecHelper++spec :: SpecWith ((), Application)+spec =+ describe "remote procedure call with legacy gucs disabled" $ do+ it "custom header is set" $+ request methodPost "/rpc/get_guc_value" [("Custom-Header", "test")]+ [json| { "prefix": "request.headers", "name": "custom-header" } |]+ `shouldRespondWith`+ [json|"test"|]+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson ]+ }++ it "standard header is set" $+ request methodPost "/rpc/get_guc_value" [("Origin", "http://example.com")]+ [json| { "prefix": "request.headers", "name": "origin" } |]+ `shouldRespondWith`+ [json|"http://example.com"|]+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson ]+ }++ it "current role is available as GUC claim" $+ request methodPost "/rpc/get_guc_value" []+ [json| { "prefix": "request.jwt.claims", "name": "role" } |]+ `shouldRespondWith`+ [json|"postgrest_test_anonymous"|]+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson ]+ }++ it "single cookie ends up as claims" $+ request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]+ [json| {"prefix": "request.cookies", "name":"acookie"} |]+ `shouldRespondWith`+ [json|"cookievalue"|]+ { matchStatus = 200+ , matchHeaders = []+ }++ it "multiple cookies ends up as claims" $+ request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]+ [json| {"prefix": "request.cookies", "name":"secondcookie"} |]+ `shouldRespondWith`+ [json|"anothervalue"|]+ { matchStatus = 200+ , matchHeaders = []+ }++ it "gets the Authorization value" $+ request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]+ [json| {"prefix": "request.headers", "name":"authorization"} |]+ `shouldRespondWith`+ [json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]+ { matchStatus = 200+ , matchHeaders = []+ }
+ test/spec/Feature/MultipleSchemaSpec.hs view
@@ -0,0 +1,331 @@+module Feature.MultipleSchemaSpec 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 (simpleHeaders), simpleBody)++import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import Protolude+import SpecHelper++spec :: SpecWith ((), Application)+spec =+ describe "multiple schemas in single instance" $ do+ context "Reading tables on different schemas" $ do+ it "succeeds in reading table from default schema v1 if no schema is selected via header" $+ request methodGet "/parents" [] "" `shouldRespondWith`+ [json|[+ {"id":1,"name":"parent v1-1"},+ {"id":2,"name":"parent v1-2"}+ ]|]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]+ }++ it "succeeds in reading table from default schema v1 after explicitly passing it in the header" $+ request methodGet "/parents" [("Accept-Profile", "v1")] "" `shouldRespondWith`+ [json|[+ {"id":1,"name":"parent v1-1"},+ {"id":2,"name":"parent v1-2"}+ ]|]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]+ }++ it "succeeds in reading table from schema v2" $+ request methodGet "/parents" [("Accept-Profile", "v2")] "" `shouldRespondWith`+ [json|[+ {"id":3,"name":"parent v2-3"},+ {"id":4,"name":"parent v2-4"}+ ]|]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]+ }++ it "succeeds in reading another_table from schema v2" $+ request methodGet "/another_table" [("Accept-Profile", "v2")] "" `shouldRespondWith`+ [json|[+ {"id":5,"another_value":"value 5"},+ {"id":6,"another_value":"value 6"}+ ]|]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]+ }++ it "doesn't find another_table in schema v1" $+ request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404++ it "fails trying to read table from unkown schema" $+ request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`+ [json|{"message":"The schema must be one of the following: v1, v2"}|]+ {+ matchStatus = 406+ }++ context "Inserting tables on different schemas" $ do+ it "succeeds inserting on default schema and returning it" $+ 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|{"id": 0, "name": "child v1-2", "parent_id": 2}|]+ `shouldRespondWith`+ [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|{"id": 0, "name": "child v2-3", "parent_id": 3}|]+ `shouldRespondWith`+ [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")]+ [json|{"name": "child 4", "parent_id": 4}|]+ `shouldRespondWith`+ [json|{"message":"The schema must be one of the following: v1, v2"}|]+ {+ matchStatus = 406+ }++ context "calling procs on different schemas" $ do+ it "succeeds in calling the default schema proc" $+ request methodGet "/rpc/get_parents_below?id=6" [] ""+ `shouldRespondWith`+ [json|[{"id":1,"name":"parent v1-1"}, {"id":2,"name":"parent v1-2"}]|]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]+ }++ it "succeeds in calling the v1 schema proc and embedding" $+ request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v1")] ""+ `shouldRespondWith`+ [json| [+ {"id":1,"name":"parent v1-1","children":[{"id":1,"name":"child v1-1"}]},+ {"id":2,"name":"parent v1-2","children":[{"id":2,"name":"child v1-2"}]}] |]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]+ }++ it "succeeds in calling the v2 schema proc and embedding" $+ request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v2")] ""+ `shouldRespondWith`+ [json| [+ {"id":3,"name":"parent v2-3","children":[{"id":1,"name":"child v2-3"}]},+ {"id":4,"name":"parent v2-4","children":[]}] |]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]+ }++ it "succeeds in calling the v2 schema proc with POST by using Content-Profile" $+ request methodPost "/rpc/get_parents_below?select=id,name" [("Content-Profile", "v2")]+ [json|{"id": "6"}|]+ `shouldRespondWith`+ [json| [+ {"id":3,"name":"parent v2-3"},+ {"id":4,"name":"parent v2-4"}]|]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]+ }++ context "Modifying tables on different schemas" $ do+ it "succeeds in patching on the v1 schema and returning its parent" $+ request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]+ [json|{"name": "child v1-1 updated"}|]+ `shouldRespondWith`+ [json|[{"name":"child v1-1 updated", "parent": {"name": "parent v1-1"}}]|]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]+ }++ it "succeeds in patching on the v2 schema and returning its parent" $+ request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")]+ [json|{"name": "child v2-1 updated"}|]+ `shouldRespondWith`+ [json|[{"name":"child v2-1 updated", "parent": {"name": "parent v2-3"}}]|]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]+ }++ 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-3", "parent_id": 3}]|]+ { matchHeaders = ["Content-Profile" <:> "v2"] }++ it "succeeds on PUT on the v2 schema" $+ request methodPut "/children?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]+ [json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|]+ `shouldRespondWith`+ [json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|]+ {+ matchStatus = 200+ , matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]+ }++ context "OpenAPI output" $ do+ it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do+ req <- request methodGet "/" [] ""++ liftIO $ do+ simpleHeaders req `shouldSatisfy` matchHeader "Content-Profile" "v1"++ let def = simpleBody req ^? key "definitions" . key "parents"++ def `shouldBe` Just+ [aesonQQ|+ {+ "type" : "object",+ "properties" : {+ "id" : {+ "description" : "Note:\nThis is a Primary Key.<pk/>",+ "format" : "integer",+ "type" : "integer"+ },+ "name" : {+ "format" : "text",+ "type" : "string"+ }+ },+ "required" : [+ "id"+ ]+ }+ |]++ it "succeeds in reading table definition from default schema v1 after explicitly passing it in the header" $ do+ r <- request methodGet "/" [("Accept-Profile", "v1")] ""++ liftIO $ do+ simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v1"++ let def = simpleBody r ^? key "definitions" . key "parents"++ def `shouldBe` Just+ [aesonQQ|+ {+ "type" : "object",+ "properties" : {+ "id" : {+ "description" : "Note:\nThis is a Primary Key.<pk/>",+ "format" : "integer",+ "type" : "integer"+ },+ "name" : {+ "format" : "text",+ "type" : "string"+ }+ },+ "required" : [+ "id"+ ]+ }+ |]++ it "succeeds in reading table definition from schema v2" $ do+ r <- request methodGet "/" [("Accept-Profile", "v2")] ""++ liftIO $ do+ simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"++ let def = simpleBody r ^? key "definitions" . key "parents"++ def `shouldBe` Just+ [aesonQQ|+ {+ "type" : "object",+ "properties" : {+ "id" : {+ "description" : "Note:\nThis is a Primary Key.<pk/>",+ "format" : "integer",+ "type" : "integer"+ },+ "name" : {+ "format" : "text",+ "type" : "string"+ }+ },+ "required" : [+ "id"+ ]+ }+ |]++ it "succeeds in reading another_table definition from schema v2" $ do+ r <- request methodGet "/" [("Accept-Profile", "v2")] ""++ liftIO $ do+ simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"++ let def = simpleBody r ^? key "definitions" . key "another_table"++ def `shouldBe` Just+ [aesonQQ|+ {+ "type" : "object",+ "properties" : {+ "id" : {+ "description" : "Note:\nThis is a Primary Key.<pk/>",+ "format" : "integer",+ "type" : "integer"+ },+ "another_value" : {+ "format" : "text",+ "type" : "string"+ }+ },+ "required" : [+ "id"+ ]+ }+ |]++ it "doesn't find another_table definition in schema v1" $ do+ r <- request methodGet "/" [("Accept-Profile", "v1")] ""++ liftIO $ do+ let def = simpleBody r ^? key "definitions" . key "another_table"+ def `shouldBe` Nothing++ it "fails trying to read definitions from unkown schema" $+ request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith`+ [json|{"message":"The schema must be one of the following: v1, v2"}|]+ {+ matchStatus = 406+ }
+ test/spec/Feature/NoJwtSpec.hs view
@@ -0,0 +1,31 @@+module Feature.NoJwtSpec where++-- {{{ Imports++import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import Protolude+import SpecHelper+-- }}}++spec :: SpecWith ((), Application)+spec = describe "server started without JWT secret" $ do++ -- this test will stop working 9999999999s after the UNIX EPOCH+ it "responds with error on attempted auth" $ do+ let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"+ request methodGet "/authors_only" [auth] ""+ `shouldRespondWith`+ [json|{"message":"Server lacks JWT secret"}|]+ { matchStatus = 500+ , matchHeaders = [ matchContentTypeJson ]+ }++ it "behaves normally when user does not attempt auth" $+ request methodGet "/items" [] ""+ `shouldRespondWith` 200
+ test/spec/Feature/NonexistentSchemaSpec.hs view
@@ -0,0 +1,17 @@+module Feature.NonexistentSchemaSpec where++import Network.Wai (Application)++import Test.Hspec+import Test.Hspec.Wai++import Protolude hiding (get)++spec :: SpecWith ((), Application)+spec =+ describe "Non existent api schema" $ do+ it "succeeds when requesting root path" $+ get "/" `shouldRespondWith` 200++ it "gives 404 when requesting a nonexistent table in this nonexistent schema" $+ get "/nonexistent_table" `shouldRespondWith` 404
+ test/spec/Feature/OpenApiSpec.hs view
@@ -0,0 +1,615 @@+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.Config.PgVersion (PgVersion, pgVersion100,+ pgVersion110)++import PostgREST.Version (docsVersion)+import Protolude hiding (get)+import SpecHelper++spec :: PgVersion -> SpecWith ((), Application)+spec actualPgVersion = 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" }+ ]+ |]++ when (actualPgVersion >= pgVersion100) $ do+ describe "Partitioned table" $++ it "includes partitioned table properties" $ do+ r <- simpleBody <$> get "/"++ let method s = key "paths" . key "/car_models" . key s+ getSummary = r ^? method "get" . key "summary"+ getDescription = r ^? method "get" . key "description"+ getParameterName = r ^? method "get" . key "parameters" . nth 0 . key "$ref"+ getParameterYear = r ^? method "get" . key "parameters" . nth 1 . key "$ref"+ getParameterRef = r ^? method "get" . key "parameters" . nth 2 . key "$ref"++ liftIO $ do++ getSummary `shouldBe` Just "A partitioned table"++ getDescription `shouldBe` Just "A test for partitioned tables"++ getParameterName `shouldBe` Just "#/parameters/rowFilter.car_models.name"++ getParameterYear `shouldBe` Just "#/parameters/rowFilter.car_models.year"++ when (actualPgVersion >= pgVersion110) $+ getParameterRef `shouldBe` Just "#/parameters/rowFilter.car_models.car_brand_name"++ 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"+ }+ |]++ it "json to any" $ do+ r <- simpleBody <$> get "/"++ let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_json"++ liftIO $++ types `shouldBe` Just+ [aesonQQ|+ {+ "format": "json"+ }+ |]++ it "jsonb to any" $ do+ r <- simpleBody <$> get "/"++ let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_jsonb"++ liftIO $++ types `shouldBe` Just+ [aesonQQ|+ {+ "format": "jsonb"+ }+ |]++ describe "Detects default values" $ do++ it "text" $ do+ r <- simpleBody <$> get "/"++ let defaultValue = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "text" . key "default"++ liftIO $++ defaultValue `shouldBe` Just "default"++ it "boolean" $ do+ r <- simpleBody <$> get "/"++ let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "boolean" . key "default"++ liftIO $++ types `shouldBe` Just (Bool False)++ it "integer" $ do+ r <- simpleBody <$> get "/"++ let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "integer" . key "default"++ liftIO $++ types `shouldBe` Just (Number 42)++ it "numeric" $ do+ r <- simpleBody <$> get "/"++ let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "numeric" . key "default"++ liftIO $++ types `shouldBe` Just (Number 42.2)++ it "date" $ do+ r <- simpleBody <$> get "/"++ let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "date" . key "default"++ liftIO $++ types `shouldBe` Just "1900-01-01"++ it "time" $ do+ r <- simpleBody <$> get "/"++ let types = r ^? key "definitions" . key "openapi_defaults" . key "properties" . key "time" . key "default"++ liftIO $++ types `shouldBe` Just "13:00:00"++ 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"+ },+ "jsonb": {+ "format": "jsonb"+ }+ },+ "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/spec/Feature/OptionsSpec.hs view
@@ -0,0 +1,87 @@+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 PostgREST.Config.PgVersion (PgVersion, pgVersion100,+ pgVersion110)++import Protolude+import SpecHelper++spec :: PgVersion -> SpecWith ((), Application)+spec actualPgVersion = 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"++ when (actualPgVersion >= pgVersion100) $+ context "a partitioned table" $ do+ it "includes read/write verbs for writeable partitioned tables" $ do+ r <- request methodOptions "/car_models" [] ""+ liftIO $+ simpleHeaders r `shouldSatisfy`+ matchHeader "Allow" (+ if actualPgVersion >= pgVersion110 then+ "OPTIONS,GET,HEAD,POST,PUT,PATCH,DELETE"+ else+ "OPTIONS,GET,HEAD,POST,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/spec/Feature/ProxySpec.hs view
@@ -0,0 +1,13 @@+module Feature.ProxySpec where++import Network.Wai (Application)+import Test.Hspec hiding (pendingWith)++import Protolude+import SpecHelper++spec :: SpecWith ((), Application)+spec =+ describe "GET / with proxy" $+ it "returns a valid openapi spec with proxy" $+ validateOpenApiResponse [("Accept", "application/openapi+json")]
+ test/spec/Feature/QueryLimitedSpec.hs view
@@ -0,0 +1,69 @@+module Feature.QueryLimitedSpec 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)+import SpecHelper++spec :: SpecWith ((), Application)+spec =+ describe "Requesting many items with server limits(max-rows) enabled" $ do+ it "restricts results" $+ get "/items?order=id"+ `shouldRespondWith`+ [json| [{"id":1},{"id":2}] |]+ { matchHeaders = ["Content-Range" <:> "0-1/*"] }++ it "respects additional client limiting" $ do+ 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}]}]|]+ { 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}}]|]+ { 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}]|]+ { 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"]+ }++ it "gives exact count when query rows <= maxRows" $+ request methodHead "/getallprojects_view?id=lt.3" [("Prefer", "count=estimated")] ""+ `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"]+ }
+ test/spec/Feature/QuerySpec.hs view
@@ -0,0 +1,1125 @@+module Feature.QuerySpec where++import Network.Wai (Application)+import Network.Wai.Test (SResponse (simpleHeaders))++import Network.HTTP.Types+import Test.Hspec hiding (pendingWith)+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import PostgREST.Config.PgVersion (PgVersion, pgVersion110,+ pgVersion112, pgVersion121)+import Protolude hiding (get)+import SpecHelper++spec :: PgVersion -> SpecWith ((), Application)+spec actualPgVersion = do++ describe "Querying a table with a column called count" $+ it "should not confuse count column with pg_catalog.count aggregate" $+ get "/has_count_column" `shouldRespondWith` 200++ describe "Querying a table with a column called t" $+ it "should not conflict with internal postgrest table alias" $+ get "/clashing_column?select=t" `shouldRespondWith` 200++ describe "Querying a nonexistent table" $+ it "causes a 404" $+ get "/faketable" `shouldRespondWith` 404++ describe "Filtering response" $ do+ it "matches with equality" $+ get "/items?id=eq.5"+ `shouldRespondWith` [json| [{"id":5}] |]+ { matchHeaders = ["Content-Range" <:> "0-0/*"] }++ it "matches with equality using not operator" $+ 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/*"] }++ it "matches with more than one condition using not operator" $+ get "/simple_pk?k=like.*yx&extra=not.eq.u" `shouldRespondWith` "[]"++ it "matches with inequality using not operator" $ do+ get "/items?id=not.lt.14&order=id.asc"+ `shouldRespondWith` [json| [{"id":14},{"id":15}] |]+ { matchHeaders = ["Content-Range" <:> "0-1/*"] }+ get "/items?id=not.gt.2&order=id.asc"+ `shouldRespondWith` [json| [{"id":1},{"id":2}] |]+ { matchHeaders = ["Content-Range" <:> "0-1/*"] }++ it "matches items IN" $+ get "/items?id=in.(1,3,5)"+ `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]+ { matchHeaders = ["Content-Range" <:> "0-2/*"] }++ it "matches items NOT IN using not operator" $+ get "/items?id=not.in.(2,4,6,7,8,9,10,11,12,13,14,15)"+ `shouldRespondWith` [json| [{"id":1},{"id":3},{"id":5}] |]+ { matchHeaders = ["Content-Range" <:> "0-2/*"] }++ it "matches nulls using not operator" $+ get "/no_pk?a=not.is.null" `shouldRespondWith`+ [json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "matches nulls in varchar and numeric fields alike" $ do+ get "/no_pk?a=is.null" `shouldRespondWith`+ [json| [{"a": null, "b": null}] |]+ { matchHeaders = [matchContentTypeJson] }++ get "/nullable_integer?a=is.null" `shouldRespondWith` [json|[{"a":null}]|]++ it "matches with trilean values" $ do+ get "/chores?done=is.true" `shouldRespondWith`+ [json| [{"id": 1, "name": "take out the garbage", "done": true }] |]+ { matchHeaders = [matchContentTypeJson] }++ get "/chores?done=is.false" `shouldRespondWith`+ [json| [{"id": 2, "name": "do the laundry", "done": false }] |]+ { matchHeaders = [matchContentTypeJson] }++ get "/chores?done=is.unknown" `shouldRespondWith`+ [json| [{"id": 3, "name": "wash the dishes", "done": null }] |]+ { matchHeaders = [matchContentTypeJson] }++ it "matches with trilean values in upper or mixed case" $ do+ get "/chores?done=is.NULL" `shouldRespondWith`+ [json| [{"id": 3, "name": "wash the dishes", "done": null }] |]+ { matchHeaders = [matchContentTypeJson] }++ get "/chores?done=is.TRUE" `shouldRespondWith`+ [json| [{"id": 1, "name": "take out the garbage", "done": true }] |]+ { matchHeaders = [matchContentTypeJson] }++ get "/chores?done=is.FAlSe" `shouldRespondWith`+ [json| [{"id": 2, "name": "do the laundry", "done": false }] |]+ { matchHeaders = [matchContentTypeJson] }++ get "/chores?done=is.UnKnOwN" `shouldRespondWith`+ [json| [{"id": 3, "name": "wash the dishes", "done": null }] |]+ { matchHeaders = [matchContentTypeJson] }++ it "fails if 'is' used and there's no null or trilean value" $ do+ get "/chores?done=is.nil" `shouldRespondWith` 400+ get "/chores?done=is.ok" `shouldRespondWith` 400++ it "matches with like" $ do+ get "/simple_pk?k=like.*yx" `shouldRespondWith`+ [json|[{"k":"xyyx","extra":"u"}]|]+ get "/simple_pk?k=like.xy*" `shouldRespondWith`+ [json|[{"k":"xyyx","extra":"u"}]|]+ get "/simple_pk?k=like.*YY*" `shouldRespondWith`+ [json|[{"k":"xYYx","extra":"v"}]|]++ it "matches with like using not operator" $+ get "/simple_pk?k=not.like.*yx" `shouldRespondWith`+ [json|[{"k":"xYYx","extra":"v"}]|]++ it "matches with ilike" $ do+ get "/simple_pk?k=ilike.xy*&order=extra.asc" `shouldRespondWith`+ [json|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/simple_pk?k=ilike.*YY*&order=extra.asc" `shouldRespondWith`+ [json|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "matches with ilike using not operator" $+ get "/simple_pk?k=not.ilike.xy*&order=extra.asc" `shouldRespondWith` "[]"++ describe "Full text search operator" $ do+ it "finds matches with to_tsquery" $+ get "/tsearch?text_search_vector=fts.impossible" `shouldRespondWith`+ [json| [{"text_search_vector": "'fun':5 'imposs':9 'kind':3" }] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can use lexeme boolean operators(&=%26, |=%7C, !) in to_tsquery" $ do+ get "/tsearch?text_search_vector=fts.fun%26possible" `shouldRespondWith`+ [json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=fts.impossible%7Cpossible" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},+ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=fts.fun%26!possible" `shouldRespondWith`+ [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "finds matches with plainto_tsquery" $+ get "/tsearch?text_search_vector=plfts.The%20Fat%20Rats" `shouldRespondWith`+ [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]+ { matchHeaders = [matchContentTypeJson] }++ when (actualPgVersion >= pgVersion112) $ do+ it "finds matches with websearch_to_tsquery" $+ get "/tsearch?text_search_vector=wfts.The%20Fat%20Rats" `shouldRespondWith`+ [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can use boolean operators(and, or, -) in websearch_to_tsquery" $ do+ get "/tsearch?text_search_vector=wfts.fun%20and%20possible"+ `shouldRespondWith`+ [json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=wfts.impossible%20or%20possible"+ `shouldRespondWith`+ [json| [+ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},+ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}]+ |]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=wfts.fun%20and%20-possible"+ `shouldRespondWith`+ [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "finds matches with different dictionaries" $ do+ get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith`+ [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=plfts(french).amusant%20impossible" `shouldRespondWith`+ [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]+ { matchHeaders = [matchContentTypeJson] }++ when (actualPgVersion >= pgVersion112) $+ get "/tsearch?text_search_vector=wfts(french).amusant%20impossible"+ `shouldRespondWith`+ [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can be negated with not operator" $ do+ get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=not.fts(english).impossible%7Cfat%7Cfun" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/tsearch?text_search_vector=not.plfts.The%20Fat%20Rats" `shouldRespondWith`+ [json| [+ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"},+ {"text_search_vector": "'also':2 'fun':3 'possibl':8"},+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }+ when (actualPgVersion >= pgVersion112) $+ get "/tsearch?text_search_vector=not.wfts(english).impossible%20or%20fat%20or%20fun"+ `shouldRespondWith`+ [json| [+ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},+ {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }++ 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}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "order by computed column" $+ get "/items?order=anti_id.desc" `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 = [matchContentTypeJson] }++ it "cannot access a computed column that is outside of the config schema" $+ 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| {+ "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]+ }++ it "matches filtering nested items" $+ get "/clients?select=id,projects(id,tasks(id,name))&projects.tasks.name=like.Design*" `shouldRespondWith`+ [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1,"name":"Design w7"}]},{"id":2,"tasks":[{"id":3,"name":"Design w10"}]}]},{"id":2,"projects":[{"id":3,"tasks":[{"id":5,"name":"Design IOS"}]},{"id":4,"tasks":[{"id":7,"name":"Design OSX"}]}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "matches with cs operator" $+ get "/complex_items?select=id&arr_data=cs.{2}" `shouldRespondWith`+ [json|[{"id":2},{"id":3}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "matches with cd operator" $+ get "/complex_items?select=id&arr_data=cd.{1,2,4}" `shouldRespondWith`+ [json|[{"id":1},{"id":2}]|]+ { matchHeaders = [matchContentTypeJson] }++ describe "Shaping response with select parameter" $ do+ it "selectStar works in absense of parameter" $+ get "/complex_items?id=eq.3" `shouldRespondWith`+ [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`+ [json|[{"id":3,"field-with_sep":1}]|]++ it "one simple column" $+ get "/complex_items?select=id" `shouldRespondWith`+ [json| [{"id":1},{"id":2},{"id":3}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "rename simple column" $+ get "/complex_items?id=eq.1&select=myId:id" `shouldRespondWith`+ [json| [{"myId":1}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "one simple column with casting (text)" $+ get "/complex_items?select=id::text" `shouldRespondWith`+ [json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "rename simple column with casting" $+ get "/complex_items?id=eq.1&select=myId:id::text" `shouldRespondWith`+ [json| [{"myId":"1"}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "json column" $+ get "/complex_items?id=eq.1&select=settings" `shouldRespondWith`+ [json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "fails on bad casting (wrong cast type)" $+ get "/complex_items?select=id::fakecolumntype"+ `shouldRespondWith` [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |]+ { matchStatus = 400+ , matchHeaders = []+ }++ it "requesting parents and children" $+ get "/projects?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "requesting parent and renaming primary key" $+ get "/projects?select=name,client:clients(clientId:id,name)" `shouldRespondWith`+ [json|[+ {"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},+ {"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},+ {"name":"IOS","client":{"name": "Apple", "clientId": 2}},+ {"name":"OSX","client":{"name": "Apple", "clientId": 2}},+ {"name":"Orphan","client":null}+ ]|]+ { matchHeaders = [matchContentTypeJson] }++ it "requesting parent and specifying/renaming one key of the composite primary key" $ do+ get "/comments?select=*,users_tasks(userId:user_id)" `shouldRespondWith`+ [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"userId": 2}}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/comments?select=*,users_tasks(taskId:task_id)" `shouldRespondWith`+ [json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "requesting parents and children while renaming them" $+ get "/projects?id=eq.1&select=myId:id, name, project_client:clients(*), project_tasks:tasks(id, name)" `shouldRespondWith`+ [json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "requesting parents and filtering parent columns" $+ get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`+ [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`+ [json|[{"id":1,"clients":{"id":1}},{"id":5,"clients":null}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "rows with no children return [] instead of null" $+ get "/projects?id=in.(5)&select=id,tasks(id)" `shouldRespondWith`+ [json|[{"id":5,"tasks":[]}]|]++ it "requesting children 2 levels" $+ get "/clients?id=eq.1&select=id,projects(id,tasks(id))" `shouldRespondWith`+ [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "requesting many<->many relation" $+ get "/tasks?select=id,users(id)" `shouldRespondWith`+ [json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "requesting many<->many relation with rename" $+ get "/tasks?id=eq.1&select=id,theUsers:users(id)" `shouldRespondWith`+ [json|[{"id":1,"theUsers":[{"id":1},{"id":3}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "requesting many<->many relation reverse" $+ get "/users?select=id,tasks(id)" `shouldRespondWith`+ [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"}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ describe "computed columns" $ do+ it "computed column on table" $+ get "/items?id=eq.1&select=id,always_true" `shouldRespondWith`+ [json|[{"id":1,"always_true":true}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "computed column on rpc" $+ get "/rpc/search?id=1&select=id,always_true" `shouldRespondWith`+ [json|[{"id":1,"always_true":true}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "overloaded computed columns on both tables" $ do+ get "/items?id=eq.1&select=id,computed_overload" `shouldRespondWith`+ [json|[{"id":1,"computed_overload":true}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/items2?id=eq.1&select=id,computed_overload" `shouldRespondWith`+ [json|[{"id":1,"computed_overload":true}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "overloaded computed column on rpc" $+ get "/rpc/search?id=1&select=id,computed_overload" `shouldRespondWith`+ [json|[{"id":1,"computed_overload":true}]|]+ { matchHeaders = [matchContentTypeJson] }++ when (actualPgVersion >= pgVersion110) $ do+ describe "partitioned tables embedding" $ do+ it "can request a table as parent from a partitioned table" $+ get "/car_models?name=in.(DeLorean,Murcielago)&select=name,year,car_brands(name)&order=name.asc" `shouldRespondWith`+ [json|+ [{"name":"DeLorean","year":1981,"car_brands":{"name":"DMC"}},+ {"name":"Murcielago","year":2001,"car_brands":{"name":"Lamborghini"}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can request partitioned tables as children from a table" $+ get "/car_brands?select=name,car_models(name,year)&order=name.asc&car_models.order=name.asc" `shouldRespondWith`+ [json|+ [{"name":"DMC","car_models":[{"name":"DeLorean","year":1981}]},+ {"name":"Ferrari","car_models":[{"name":"F310-B","year":1997}]},+ {"name":"Lamborghini","car_models":[{"name":"Murcielago","year":2001},{"name":"Veneno","year":2013}]}] |]+ { matchHeaders = [matchContentTypeJson] }++ when (actualPgVersion >= pgVersion121) $ do+ it "can request tables as children from a partitioned table" $+ get "/car_models?name=in.(DeLorean,F310-B)&select=name,year,car_racers(name)&order=name.asc" `shouldRespondWith`+ [json|+ [{"name":"DeLorean","year":1981,"car_racers":[]},+ {"name":"F310-B","year":1997,"car_racers":[{"name":"Michael Schumacher"}]}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can request a partitioned table as parent from a table" $+ get "/car_racers?select=name,car_models(name,year)&order=name.asc" `shouldRespondWith`+ [json|+ [{"name":"Alain Prost","car_models":null},+ {"name":"Michael Schumacher","car_models":{"name":"F310-B","year":1997}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can request partitioned tables as children from a partitioned table" $+ get "/car_models?name=in.(DeLorean,Murcielago,Veneno)&select=name,year,car_model_sales(date,quantity)&order=name.asc" `shouldRespondWith`+ [json|+ [{"name":"DeLorean","year":1981,"car_model_sales":[{"date":"2021-01-14","quantity":7},{"date":"2021-01-15","quantity":9}]},+ {"name":"Murcielago","year":2001,"car_model_sales":[{"date":"2021-02-11","quantity":1},{"date":"2021-02-12","quantity":3}]},+ {"name":"Veneno","year":2013,"car_model_sales":[]}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can request a partitioned table as parent from a partitioned table" $ do+ get "/car_model_sales?date=in.(2021-01-15,2021-02-11)&select=date,quantity,car_models(name,year)&order=date.asc" `shouldRespondWith`+ [json|+ [{"date":"2021-01-15","quantity":9,"car_models":{"name":"DeLorean","year":1981}},+ {"date":"2021-02-11","quantity":1,"car_models":{"name":"Murcielago","year":2001}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can request many to many relationships between partitioned tables ignoring the intermediate table partitions" $+ get "/car_models?select=name,year,car_dealers(name,city)&order=name.asc&limit=4" `shouldRespondWith`+ [json|+ [{"name":"DeLorean","year":1981,"car_dealers":[{"name":"Springfield Cars S.A.","city":"Springfield"}]},+ {"name":"F310-B","year":1997,"car_dealers":[]},+ {"name":"Murcielago","year":2001,"car_dealers":[{"name":"The Best Deals S.A.","city":"Franklin"}]},+ {"name":"Veneno","year":2013,"car_dealers":[]}] |]+ { matchStatus = 200+ , matchHeaders = [matchContentTypeJson]+ }++ it "cannot request partitions as children from a partitioned table" $+ get "/car_models?id=in.(1,2,4)&select=id,name,car_model_sales_202101(id)&order=id.asc" `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 car_models and car_model_sales_202101 in the schema cache"} |]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ it "cannot request a partitioned table as parent from a partition" $+ get "/car_model_sales_202101?select=id,name,car_models(id,name)&order=id.asc" `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 car_model_sales_202101 and car_models in the schema cache"} |]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ it "cannot request a partition as parent from a partitioned table" $+ get "/car_model_sales?id=in.(1,3,4)&select=id,name,car_models_default(id,name)&order=id.asc" `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 car_model_sales and car_models_default in the schema cache"} |]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ it "cannot request partitioned tables as children from a partition" $+ get "/car_models_default?select=id,name,car_model_sales(id,name)&order=id.asc" `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 car_models_default and car_model_sales in the schema cache"} |]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ describe "view embedding" $ do+ it "can detect fk relations through views to tables in the public schema" $+ get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200++ it "can detect fk relations through materialized views to tables in the public schema" $+ get "/materialized_projects?select=*,users(*)" `shouldRespondWith` 200++ it "can request two parents" $+ 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`+ [json|[{"id":1,"articleStars":[{"users":{"id":1,"name":"Angela Martin"}},{"users":{"id":2,"name":"Michael Scott"}},{"users":{"id":3,"name":"Dwight Schrute"}}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "works when requesting parents and children on views" $+ get "/projects_view?id=eq.1&select=id, name, clients(*), tasks(id, name)" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "works when requesting parents and children on views with renamed keys" $+ get "/projects_view_alt?t_id=eq.1&select=t_id, name, clients(*), tasks(id, name)" `shouldRespondWith`+ [json|[{"t_id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "detects parent relations when having many views of a private table" $ do+ get "/books?select=title,author:authors(name)&id=eq.5" `shouldRespondWith`+ [json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/forties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`+ [json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/fifties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`+ [json|[ { "title": "The Catcher in the Rye", "author": { "name": "J.D. Salinger" } } ]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/sixties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`+ [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,+ "books":[{"title":"1984"}]} ]|]+ { matchHeaders = [matchContentTypeJson] }++ it "works with views that have case subselects" $+ get "/authors_have_book_in_decade?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"}]} ]|]+ { matchHeaders = [matchContentTypeJson] }++ it "works with views that have subselect in the FROM clause" $+ get "/forties_and_fifties_books?select=title,first_publisher,author:authors(name)&id=eq.1" `shouldRespondWith`+ [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|[+ {"title":"1984","publication_year":1949,"first_publisher":"Secker & Warburg","author":{"name":"George Orwell"}},+ {"title":"The Diary of a Young Girl","publication_year":1947,"first_publisher":"Contact Publishing","author":{"name":"Anne Frank"}},+ {"title":"The Little Prince","publication_year":1947,"first_publisher":"Reynal & Hitchcock","author":{"name":"Antoine de Saint-Exupéry"}} ]|]+ { matchHeaders = [matchContentTypeJson] }++ it "works when having a capitalized table name and camelCase fk column" $+ get "/foos?select=*,bars(*)" `shouldRespondWith` 200++ it "works when embedding a view with a table that has a long compound pk" $ do+ get "/player_view?select=id,contract(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`+ [json|+ [{"id":1,"contract":[{"purchase_price":10}]},+ {"id":3,"contract":[{"purchase_price":30}]},+ {"id":5,"contract":[{"purchase_price":50}]},+ {"id":7,"contract":[]}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/contract?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`+ [json|+ [{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},+ {"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},+ {"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "works when embedding a view with a view that referes to a table that has a long compound pk" $ do+ get "/player_view?select=id,contract_view(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`+ [json|+ [{"id":1,"contract_view":[{"purchase_price":10}]},+ {"id":3,"contract_view":[{"purchase_price":30}]},+ {"id":5,"contract_view":[{"purchase_price":50}]},+ {"id":7,"contract_view":[]}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/contract_view?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`+ [json|+ [{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},+ {"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},+ {"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can embed a view that has group by" $+ get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith`+ [json|+ [{"number_of_projects":1,"client":null},+ {"number_of_projects":2,"client":{"name":"Microsoft"}},+ {"number_of_projects":2,"client":{"name":"Apple"}}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "can embed a view that has a subselect containing a select in a where" $+ get "/authors_w_entities?select=name,entities,books(title)&id=eq.1" `shouldRespondWith`+ [json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |]+ { matchHeaders = [matchContentTypeJson] }++ describe "aliased embeds" $ do+ it "works with child relation" $+ get "/space?select=id,zones:zone(id,name),stores:zone(id,name)&zones.zone_type_id=eq.2&stores.zone_type_id=eq.3" `shouldRespondWith`+ [json|[+ { "id":1,+ "zones": [ {"id":1,"name":"zone 1"}, {"id":2,"name":"zone 2"}],+ "stores": [ {"id":3,"name":"store 3"}, {"id":4,"name":"store 4"}]}+ ]|] { matchHeaders = [matchContentTypeJson] }++ it "works with many to many relation" $+ get "/users?select=id,designTasks:tasks(id,name),codeTasks:tasks(id,name)&designTasks.name=like.*Design*&codeTasks.name=like.*Code*" `shouldRespondWith`+ [json|[+ { "id":1,+ "designTasks":[ { "id":1, "name":"Design w7" }, { "id":3, "name":"Design w10" } ],+ "codeTasks":[ { "id":2, "name":"Code w7" }, { "id":4, "name":"Code w10" } ] },+ { "id":2,+ "designTasks":[ { "id":5, "name":"Design IOS" }, { "id":7, "name":"Design OSX" } ],+ "codeTasks":[ { "id":6, "name":"Code IOS" } ] },+ { "id":3,+ "designTasks":[ { "id":1, "name":"Design w7" }, { "id":5, "name":"Design IOS" } ],+ "codeTasks":[ ] }+ ]|] { matchHeaders = [matchContentTypeJson] }++ it "works with an aliased child plus non aliased child" $+ get "/projects?select=id,name,designTasks:tasks(name,users(id,name))&designTasks.name=like.*Design*&designTasks.users.id=in.(1,2)" `shouldRespondWith`+ [json|[+ {+ "id":1, "name":"Windows 7",+ "designTasks":[ { "name":"Design w7", "users":[ { "id":1, "name":"Angela Martin" } ] } ] },+ {+ "id":2, "name":"Windows 10",+ "designTasks":[ { "name":"Design w10", "users":[ { "id":1, "name":"Angela Martin" } ] } ] },+ {+ "id":3, "name":"IOS",+ "designTasks":[ { "name":"Design IOS", "users":[ { "id":2, "name":"Michael Scott" } ] } ] },+ {+ "id":4, "name":"OSX",+ "designTasks":[ { "name":"Design OSX", "users":[ { "id":2, "name":"Michael Scott" } ] } ] },+ {+ "id":5, "name":"Orphan",+ "designTasks":[ ] }+ ]|] { matchHeaders = [matchContentTypeJson] }++ it "works with two aliased children embeds plus and/or" $+ get "/entities?select=id,children:child_entities(id,gChildren:grandchild_entities(id))&children.and=(id.in.(1,2,3))&children.gChildren.or=(id.eq.1,id.eq.2)" `shouldRespondWith`+ [json|[+ { "id":1,+ "children":[+ {"id":1,"gChildren":[{"id":1}, {"id":2}]},+ {"id":2,"gChildren":[]}]},+ { "id":2,+ "children":[+ {"id":3,"gChildren":[]}]},+ { "id":3,"children":[]},+ { "id":4,"children":[]}+ ]|] { matchHeaders = [matchContentTypeJson] }++ describe "ordering response" $ do+ it "by a column asc" $+ get "/items?id=lte.2&order=id.asc"+ `shouldRespondWith` [json| [{"id":1},{"id":2}] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-1/*"]+ }+++ it "by a column desc" $+ get "/items?id=lte.2&order=id.desc"+ `shouldRespondWith` [json| [{"id":2},{"id":1}] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-1/*"]+ }++ it "by a column with nulls first" $+ get "/no_pk?order=a.nullsfirst"+ `shouldRespondWith` [json| [{"a":null,"b":null},+ {"a":"1","b":"0"},+ {"a":"2","b":"0"}+ ] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-2/*"]+ }++ it "by a column asc with nulls last" $+ get "/no_pk?order=a.asc.nullslast"+ `shouldRespondWith` [json| [{"a":"1","b":"0"},+ {"a":"2","b":"0"},+ {"a":null,"b":null}] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-2/*"]+ }++ it "by a column desc with nulls first" $+ get "/no_pk?order=a.desc.nullsfirst"+ `shouldRespondWith` [json| [{"a":null,"b":null},+ {"a":"2","b":"0"},+ {"a":"1","b":"0"}] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-2/*"]+ }++ it "by a column desc with nulls last" $+ get "/no_pk?order=a.desc.nullslast"+ `shouldRespondWith` [json| [{"a":"2","b":"0"},+ {"a":"1","b":"0"},+ {"a":null,"b":null}] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-2/*"]+ }++ it "by two columns with nulls and direction specified" $+ get "/projects?select=client_id,id,name&order=client_id.desc.nullslast,id.desc"+ `shouldRespondWith` [json|+ [{"client_id":2,"id":4,"name":"OSX"},+ {"client_id":2,"id":3,"name":"IOS"},+ {"client_id":1,"id":2,"name":"Windows 10"},+ {"client_id":1,"id":1,"name":"Windows 7"},+ {"client_id":null,"id":5,"name":"Orphan"}]+ |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-4/*"]+ }++ it "by a column with no direction or nulls specified" $+ get "/items?id=lte.2&order=id"+ `shouldRespondWith` [json| [{"id":1},{"id":2}] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-1/*"]+ }++ it "without other constraints" $+ get "/items?order=id.asc" `shouldRespondWith` 200++ it "ordering embeded entities" $+ get "/projects?id=eq.1&select=id, name, tasks(id, name)&tasks.order=name.asc" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "ordering embeded entities with alias" $+ get "/projects?id=eq.1&select=id, name, the_tasks:tasks(id, name)&tasks.order=name.asc" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","the_tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "ordering embeded entities, two levels" $+ get "/projects?id=eq.1&select=id, name, tasks(id, name, users(id, name))&tasks.order=name.asc&tasks.users.order=name.desc" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7","users":[{"id":1,"name":"Angela Martin"}]},{"id":1,"name":"Design w7","users":[{"id":3,"name":"Dwight Schrute"},{"id":1,"name":"Angela Martin"}]}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "ordering embeded parents does not break things" $+ get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith`+ [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+ get "/items?order=id.ac" `shouldRespondWith`+ [json|{"details":"unexpected \"c\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.ac)\" (line 1, column 4)"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }+ get "/items?order=id.descc" `shouldRespondWith`+ [json|{"details":"unexpected 'c' expecting delimiter (.), \",\" or end of input","message":"\"failed to parse order (id.descc)\" (line 1, column 8)"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }+ get "/items?order=id.nulsfist" `shouldRespondWith`+ [json|{"details":"unexpected \"n\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.nulsfist)\" (line 1, column 4)"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }+ get "/items?order=id.nullslasttt" `shouldRespondWith`+ [json|{"details":"unexpected 't' expecting \",\" or end of input","message":"\"failed to parse order (id.nullslasttt)\" (line 1, column 13)"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }+ get "/items?order=id.smth34" `shouldRespondWith`+ [json|{"details":"unexpected \"s\" expecting \"asc\", \"desc\", \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.smth34)\" (line 1, column 4)"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ it "gives meaningful error messages when nulls{first,last} are misspelled after asc/desc" $ do+ get "/items?order=id.asc.nlsfst" `shouldRespondWith`+ [json|{"details":"unexpected \"l\" expecting \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.asc.nlsfst)\" (line 1, column 8)"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }+ get "/items?order=id.asc.nullslasttt" `shouldRespondWith`+ [json|{"details":"unexpected 't' expecting \",\" or end of input","message":"\"failed to parse order (id.asc.nullslasttt)\" (line 1, column 17)"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }+ get "/items?order=id.asc.smth34" `shouldRespondWith`+ [json|{"details":"unexpected \"s\" expecting \"nullsfirst\" or \"nullslast\"","message":"\"failed to parse order (id.asc.smth34)\" (line 1, column 8)"}|]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ describe "Accept headers" $ do+ it "should respond an unknown accept type with 415" $+ request methodGet "/simple_pk"+ (acceptHdrs "text/unknowntype") ""+ `shouldRespondWith`+ [json|{"message":"None of these Content-Types are available: text/unknowntype"}|]+ { matchStatus = 415+ , matchHeaders = [matchContentTypeJson]+ }++ it "should respond correctly to */* in accept header" $+ request methodGet "/simple_pk"+ (acceptHdrs "*/*") ""+ `shouldRespondWith` 200++ it "*/* should rescue an unknown type" $+ request methodGet "/simple_pk"+ (acceptHdrs "text/unknowntype, */*") ""+ `shouldRespondWith` 200++ it "specific available preference should override */*" $ do+ r <- request methodGet "/simple_pk"+ (acceptHdrs "text/csv, */*") ""+ liftIO $ do+ let respHeaders = simpleHeaders r+ respHeaders `shouldSatisfy` matchHeader+ "Content-Type" "text/csv; charset=utf-8"++ it "honors client preference even when opposite of server preference" $ do+ r <- request methodGet "/simple_pk"+ (acceptHdrs "text/csv, application/json") ""+ liftIO $ do+ let respHeaders = simpleHeaders r+ respHeaders `shouldSatisfy` matchHeader+ "Content-Type" "text/csv; charset=utf-8"++ it "should respond correctly to multiple types in accept header" $+ request methodGet "/simple_pk"+ (acceptHdrs "text/unknowntype, text/csv") ""+ `shouldRespondWith` 200++ it "should respond with CSV to 'text/csv' request" $+ request methodGet "/simple_pk"+ (acceptHdrs "text/csv; version=1") ""+ `shouldRespondWith` "k,extra\nxyyx,u\nxYYx,v"+ { matchStatus = 200+ , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]+ }++ describe "Canonical location" $ do+ it "Sets Content-Location with alphabetized params" $+ get "/no_pk?b=eq.1&a=eq.1"+ `shouldRespondWith` "[]"+ { matchStatus = 200+ , matchHeaders = ["Content-Location" <:> "/no_pk?a=eq.1&b=eq.1"]+ }++ it "Omits question mark when there are no params" $ do+ r <- get "/simple_pk"+ liftIO $ do+ let respHeaders = simpleHeaders r+ respHeaders `shouldSatisfy` matchHeader+ "Content-Location" "/simple_pk"++ describe "weird requests" $ do+ it "can query as normal" $ do+ get "/Escap3e;" `shouldRespondWith`+ [json| [{"so6meIdColumn":1},{"so6meIdColumn":2},{"so6meIdColumn":3},{"so6meIdColumn":4},{"so6meIdColumn":5}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/ghostBusters" `shouldRespondWith`+ [json| [{"escapeId":1},{"escapeId":3},{"escapeId":5}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "fails if an operator is not given" $+ get "/ghostBusters?id=0" `shouldRespondWith` [json| {"details":"unexpected \"0\" expecting \"not\" or operator (eq, gt, ...)","message":"\"failed to parse filter (0)\" (line 1, column 1)"} |]+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ it "will embed a collection" $+ get "/Escap3e;?select=ghostBusters(*)" `shouldRespondWith`+ [json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "will select and filter a column that has spaces" $+ get "/Server%20Today?select=Just%20A%20Server%20Model&Just%20A%20Server%20Model=like.*91*" `shouldRespondWith`+ [json|[+ {"Just A Server Model":" IBM,9113-550 (P5-550)"},+ {"Just A Server Model":" IBM,9113-550 (P5-550)"},+ {"Just A Server Model":" IBM,9131-52A (P5-52A)"},+ {"Just A Server Model":" IBM,9133-55A (P5-55A)"}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "will select and filter a quoted column that has PostgREST reserved characters" $+ get "/pgrst_reserved_chars?select=%22:arr-%3Eow::cast%22,%22(inside,parens)%22,%22a.dotted.column%22,%22%20%20col%20%20w%20%20space%20%20%22&%22*id*%22=eq.1" `shouldRespondWith`+ [json|[{":arr->ow::cast":" arrow-1 ","(inside,parens)":" parens-1 ","a.dotted.column":" dotted-1 "," col w space ":" space-1"}]|]+ { matchHeaders = [matchContentTypeJson] }++ context "binary output" $ do+ it "can query if a single column is selected" $+ 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"]+ }++ it "can get raw output with Accept: text/plain" $+ request methodGet "/projects?select=name&id=eq.1" (acceptHdrs "text/plain") ""+ `shouldRespondWith` "Windows 7"+ { matchStatus = 200+ , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]+ }++ 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 }++ 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"]+ }++ describe "values with quotes in IN and NOT IN" $ do+ it "succeeds when only quoted values are present" $ do+ get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`+ [json| [{"name":"Hebdon, John"}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")" `shouldRespondWith`+ [json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")&limit=3" `shouldRespondWith`+ [json| [ { "name": "David White" }, { "name": "Larry Thompson" }, { "name": "Double O Seven(007)" }] |]+ { matchHeaders = [matchContentTypeJson] }++ it "succeeds w/ and w/o quoted values" $ do+ get "/w_or_wo_comma_names?name=in.(David White,\"Hebdon, John\")" `shouldRespondWith`+ [json| [{"name":"Hebdon, John"},{"name":"David White"}] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",Larry Thompson,\"Smith, Joseph\")&limit=3" `shouldRespondWith`+ [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] }++ context "escaped chars" $ do+ it "accepts escaped double quotes" $+ get "/w_or_wo_comma_names?name=in.(\"Double\\\"Quote\\\"McGraw\\\"\")" `shouldRespondWith`+ [json| [ { "name": "Double\"Quote\"McGraw\"" } ] |]+ { matchHeaders = [matchContentTypeJson] }++ it "accepts escaped backslashes" $ do+ get "/w_or_wo_comma_names?name=in.(\"\\\\\")" `shouldRespondWith`+ [json| [{ "name": "\\" }] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/w_or_wo_comma_names?name=in.(\"/\\\\Slash/\\\\Beast/\\\\\")" `shouldRespondWith`+ [json| [ { "name": "/\\Slash/\\Beast/\\" } ] |]+ { matchHeaders = [matchContentTypeJson] }++ it "passes any escaped char as the same char" $+ get "/w_or_wo_comma_names?name=in.(\"D\\a\\vid W\\h\\ite\")" `shouldRespondWith`+ [json| [{ "name": "David White" }] |]+ { matchHeaders = [matchContentTypeJson] }++ describe "IN values without quotes" $ do+ it "accepts single double quotes as values" $ do+ get "/w_or_wo_comma_names?name=in.(\")" `shouldRespondWith`+ [json| [{ "name": "\"" }] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/w_or_wo_comma_names?name=in.(Double\"Quote\"McGraw\")" `shouldRespondWith`+ [json| [ { "name": "Double\"Quote\"McGraw\"" } ] |]+ { matchHeaders = [matchContentTypeJson] }++ it "accepts backslashes as values" $ do+ get "/w_or_wo_comma_names?name=in.(\\)" `shouldRespondWith`+ [json| [{ "name": "\\" }] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/w_or_wo_comma_names?name=in.(/\\Slash/\\Beast/\\)" `shouldRespondWith`+ [json| [ { "name": "/\\Slash/\\Beast/\\" } ] |]+ { matchHeaders = [matchContentTypeJson] }++ describe "IN and NOT IN empty set" $ do+ context "returns an empty result for IN when no value is present" $ do+ it "works for integer" $+ get "/items_with_different_col_types?int_data=in.()" `shouldRespondWith`+ [json| [] |] { matchHeaders = [matchContentTypeJson] }+ it "works for text" $+ get "/items_with_different_col_types?text_data=in.()" `shouldRespondWith`+ [json| [] |] { matchHeaders = [matchContentTypeJson] }+ it "works for bool" $+ get "/items_with_different_col_types?bool_data=in.()" `shouldRespondWith`+ [json| [] |] { matchHeaders = [matchContentTypeJson] }+ it "works for bytea" $+ get "/items_with_different_col_types?bin_data=in.()" `shouldRespondWith`+ [json| [] |] { matchHeaders = [matchContentTypeJson] }+ it "works for char" $+ get "/items_with_different_col_types?char_data=in.()" `shouldRespondWith`+ [json| [] |] { matchHeaders = [matchContentTypeJson] }+ it "works for date" $+ get "/items_with_different_col_types?date_data=in.()" `shouldRespondWith`+ [json| [] |] { matchHeaders = [matchContentTypeJson] }+ it "works for real" $+ get "/items_with_different_col_types?real_data=in.()" `shouldRespondWith`+ [json| [] |] { matchHeaders = [matchContentTypeJson] }+ it "works for time" $+ get "/items_with_different_col_types?time_data=in.()" `shouldRespondWith`+ [json| [] |] { matchHeaders = [matchContentTypeJson] }++ it "returns all results for not.in when no value is present" $+ get "/items_with_different_col_types?int_data=not.in.()&select=int_data" `shouldRespondWith`+ [json| [{int_data: 1}] |] { matchHeaders = [matchContentTypeJson] }++ it "returns an empty result ignoring spaces" $+ get "/items_with_different_col_types?int_data=in.( )" `shouldRespondWith`+ [json| [] |] { matchHeaders = [matchContentTypeJson] }++ it "only returns an empty result set if the in value is empty" $+ get "/items_with_different_col_types?int_data=in.( ,3,4)"+ `shouldRespondWith` (+ if actualPgVersion >= pgVersion121 then+ [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"\""} |]+ else+ [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]+ )+ { matchStatus = 400+ , matchHeaders = [matchContentTypeJson]+ }++ describe "Embedding when column name = table name" $ do+ it "works with child embeds" $+ get "/being?select=*,descendant(*)&limit=1" `shouldRespondWith`+ [json|[{"being":1,"descendant":[{"descendant":1,"being":1},{"descendant":2,"being":1},{"descendant":3,"being":1}]}]|]+ { matchHeaders = [matchContentTypeJson] }+ it "works with many to many embeds" $+ get "/being?select=*,part(*)&limit=1" `shouldRespondWith`+ [json|[{"being":1,"part":[{"part":1}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ describe "Foreign table" $ do+ it "can be queried by using regular filters" $+ get "/projects_dump?id=in.(1,2,3)" `shouldRespondWith`+ [json| [{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}, {"id":3,"name":"IOS","client_id":2}]|]+ { matchHeaders = [matchContentTypeJson] }+ it "can be queried with select, order and limit" $+ get "/projects_dump?select=id,name&order=id.desc&limit=3" `shouldRespondWith`+ [json| [{"id":5,"name":"Orphan"}, {"id":4,"name":"OSX"}, {"id":3,"name":"IOS"}] |]+ { matchHeaders = [matchContentTypeJson] }++ it "cannot use ltree(in public schema) extension operators if no extra search path added" $+ get "/ltree_sample?path=cd.Top.Science.Astronomy" `shouldRespondWith` 400++ context "VIEW that has a source FK based on a UNIQUE key" $+ it "can be embedded" $+ get "/referrals?select=site,link:pages(url)" `shouldRespondWith`+ [json| [+ {"site":"github.com", "link":{"url":"http://postgrest.org/en/v6.0/api.html"}},+ {"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}+ ]|]+ { matchHeaders = [matchContentTypeJson] }++ it "shouldn't produce a Content-Profile header since only a single schema is exposed" $ do+ r <- get "/items"+ liftIO $ do+ let respHeaders = simpleHeaders r+ respHeaders `shouldSatisfy` noProfileHeader
+ test/spec/Feature/RangeSpec.hs view
@@ -0,0 +1,352 @@+module Feature.RangeSpec where++import qualified Data.ByteString.Lazy as BL++import Network.Wai (Application)+import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import Protolude hiding (get)+import SpecHelper++defaultRange :: BL.ByteString+defaultRange = [json| { "min": 0, "max": 15 } |]++emptyRange :: BL.ByteString+emptyRange = [json| { "min": 2, "max": 2 } |]++spec :: SpecWith ((), Application)+spec = do+ describe "POST /rpc/getitemrange" $ do+ context "without range headers" $ do+ context "with response under server size limit" $+ it "returns whole range with status 200" $+ post "/rpc/getitemrange" defaultRange `shouldRespondWith` 200++ context "when I don't want the count" $ do+ it "returns range Content-Range with */* for empty range" $+ request methodPost "/rpc/getitemrange" [] emptyRange+ `shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]}++ it "returns range Content-Range with range/*" $+ 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+ it "succeeds with partial content" $ do+ r <- request methodPost "/rpc/getitemrange"+ (rangeHdrs $ ByteRangeFromTo 0 1) defaultRange+ liftIO $ do+ simpleHeaders r `shouldSatisfy`+ matchHeader "Content-Range" "0-1/*"+ simpleStatus r `shouldBe` ok200++ it "understands open-ended ranges" $+ request methodPost "/rpc/getitemrange"+ (rangeHdrs $ ByteRangeFrom 0) defaultRange+ `shouldRespondWith` 200++ it "returns an empty body when there are no results" $+ request methodPost "/rpc/getitemrange"+ (rangeHdrs $ ByteRangeFromTo 0 1) emptyRange+ `shouldRespondWith` "[]"+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "*/*"]+ }++ it "allows one-item requests" $ do+ r <- request methodPost "/rpc/getitemrange"+ (rangeHdrs $ ByteRangeFromTo 0 0) defaultRange+ liftIO $ do+ simpleHeaders r `shouldSatisfy`+ matchHeader "Content-Range" "0-0/*"+ simpleStatus r `shouldBe` ok200++ it "handles ranges beyond collection length via truncation" $ do+ r <- request methodPost "/rpc/getitemrange"+ (rangeHdrs $ ByteRangeFromTo 10 100) defaultRange+ liftIO $ do+ simpleHeaders r `shouldSatisfy`+ matchHeader "Content-Range" "10-14/*"+ simpleStatus r `shouldBe` ok200++ context "of invalid range" $ do+ it "fails with 416 for offside range" $+ request methodPost "/rpc/getitemrange"+ (rangeHdrs $ ByteRangeFromTo 1 0) emptyRange+ `shouldRespondWith` 416++ it "refuses a range with nonzero start when there are no items" $+ request methodPost "/rpc/getitemrange"+ (rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange+ `shouldRespondWith` "[]"+ { matchStatus = 416+ , matchHeaders = ["Content-Range" <:> "*/0"]+ }++ it "refuses a range requesting start past last item" $+ request methodPost "/rpc/getitemrange"+ (rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange+ `shouldRespondWith` "[]"+ { matchStatus = 416+ , matchHeaders = ["Content-Range" <:> "*/15"]+ }++ describe "GET /items" $ do+ context "without range headers" $ do+ context "with response under server size limit" $+ it "returns whole range with status 200" $+ get "/items" `shouldRespondWith` 200++ context "when I don't want the count" $ do+ it "returns range Content-Range with /*" $+ request methodGet "/menagerie"+ [("Prefer", "count=none")] ""+ `shouldRespondWith` "[]"+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "*/*"]+ }++ it "returns range Content-Range with range/*" $+ request methodGet "/items?order=id"+ [("Prefer", "count=none")] ""+ `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/*"] }++ it "returns range Content-Range with range/* even using other filters" $+ request methodGet "/items?id=eq.1&order=id"+ [("Prefer", "count=none")] ""+ `shouldRespondWith` [json| [{"id":1}] |]+ { matchHeaders = ["Content-Range" <:> "0-0/*"] }++ context "with limit/offset parameters" $ do+ it "no parameters return everything" $+ get "/items?select=id&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}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-14/*"]+ }+ it "top level limit with parameter" $+ get "/items?select=id&order=id.asc&limit=3"+ `shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-2/*"]+ }+ it "headers override get parameters" $+ request methodGet "/items?select=id&order=id.asc&limit=3"+ (rangeHdrs $ ByteRangeFromTo 0 1) ""+ `shouldRespondWith` [json|[{"id":1},{"id":2}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-1/*"]+ }++ it "limit works on all levels" $+ get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"+ `shouldRespondWith`+ [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-0/*"]+ }++ it "limit and offset works on first level" $ do+ get "/items?select=id&order=id.asc&limit=3&offset=2"+ `shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "2-4/*"]+ }+ request methodHead "/items?select=id&order=id.asc&limit=3&offset=2" [] mempty+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "2-4/*"]+ }++ it "succeeds if offset equals 0 as a no-op" $+ 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}]|]+ { matchHeaders = ["Content-Range" <:> "0-14/*"] }++ it "succeeds if offset is negative as a no-op" $+ 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}]|]+ { matchHeaders = ["Content-Range" <:> "0-14/*"] }++ it "fails if limit equals 0" $+ get "/items?select=id&limit=0"+ `shouldRespondWith` [json|{"message":"HTTP Range error"}|]+ { matchStatus = 416+ , matchHeaders = [matchContentTypeJson]+ }++ it "fails if limit is negative" $+ get "/items?select=id&limit=-1"+ `shouldRespondWith` [json|{"message":"HTTP Range error"}|]+ { matchStatus = 416+ , matchHeaders = [matchContentTypeJson]+ }++ context "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"]+ }++ 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"]+ }++ 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"]+ }++ it "works with two levels" $+ 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+ it "succeeds with partial content" $ do+ r <- request methodGet "/items"+ (rangeHdrs $ ByteRangeFromTo 0 1) ""+ liftIO $ do+ simpleHeaders r `shouldSatisfy`+ matchHeader "Content-Range" "0-1/*"+ simpleStatus r `shouldBe` ok200++ it "understands open-ended ranges" $+ request methodGet "/items"+ (rangeHdrs $ ByteRangeFrom 0) ""+ `shouldRespondWith` 200++ it "returns an empty body when there are no results" $+ request methodGet "/menagerie"+ (rangeHdrs $ ByteRangeFromTo 0 1) ""+ `shouldRespondWith` "[]"+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "*/*"]+ }++ it "allows one-item requests" $ do+ r <- request methodGet "/items"+ (rangeHdrs $ ByteRangeFromTo 0 0) ""+ liftIO $ do+ simpleHeaders r `shouldSatisfy`+ matchHeader "Content-Range" "0-0/*"+ simpleStatus r `shouldBe` ok200++ it "handles ranges beyond collection length via truncation" $ do+ r <- request methodGet "/items"+ (rangeHdrs $ ByteRangeFromTo 10 100) ""+ liftIO $ do+ simpleHeaders r `shouldSatisfy`+ matchHeader "Content-Range" "10-14/*"+ simpleStatus r `shouldBe` ok200++ context "of invalid range" $ do+ it "fails with 416 for offside range" $+ request methodGet "/items"+ (rangeHdrs $ ByteRangeFromTo 1 0) ""+ `shouldRespondWith` 416++ it "refuses a range with nonzero start when there are no items" $+ request methodGet "/menagerie"+ (rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""+ `shouldRespondWith` "[]"+ { matchStatus = 416+ , matchHeaders = ["Content-Range" <:> "*/0"]+ }++ it "refuses a range requesting start past last item" $+ request methodGet "/items"+ (rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""+ `shouldRespondWith` "[]"+ { matchStatus = 416+ , matchHeaders = ["Content-Range" <:> "*/15"]+ }
+ test/spec/Feature/RawOutputTypesSpec.hs view
@@ -0,0 +1,33 @@+module Feature.RawOutputTypesSpec where++import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import Protolude+import SpecHelper (acceptHdrs)++spec :: SpecWith ((), Application)+spec = describe "When raw-media-types config variable is missing or left empty" $ do+ let firefoxAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"+ chromeAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"+ it "responds json to a GET request with Firefox Accept headers" $+ request methodGet "/items?id=eq.1" firefoxAcceptHdrs ""+ `shouldRespondWith` [json| [{"id":1}] |]+ { matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }+ it "responds json to a GET request with Chrome Accept headers" $+ request methodGet "/items?id=eq.1" chromeAcceptHdrs ""+ `shouldRespondWith` [json| [{"id":1}] |]+ { 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" 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" $+ request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""+ `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"] }
+ test/spec/Feature/RollbackSpec.hs view
@@ -0,0 +1,260 @@+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 }++shouldRaiseExceptions reqHeaders respHeaders = do+ it "raises immediate constraints" $ do+ request methodPost "/rpc/raise_constraint"+ reqHeaders+ ""+ `shouldRespondWith`+ [json|{+ "hint":null,+ "details":"Key (col)=(1) already exists.",+ "code":"23505",+ "message":"duplicate key value violates unique constraint \"deferrable_unique_constraint_col_key\""+ }|]+ { matchStatus = 409+ , matchHeaders = respHeaders }++ it "raises deferred constraints" $ do+ request methodPost "/rpc/raise_constraint"+ reqHeaders+ [json|{"deferred": true}|]+ `shouldRespondWith`+ [json|{+ "hint":null,+ "details":"Key (col)=(1) already exists.",+ "code":"23505",+ "message":"duplicate key value violates unique constraint \"deferrable_unique_constraint_col_key\""+ }|]+ { matchStatus = 409+ , 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+ preferDefault `shouldRaiseExceptions` withoutPreferenceApplied++ describe "Prefer tx=commit" $ do+ preferCommit `shouldRespondToReads` withPreferenceCommitApplied+ preferCommit `shouldPersistMutations` withPreferenceCommitApplied+ -- Exceptions are always without preference applied,+ -- because they return before the end of the transaction.+ preferCommit `shouldRaiseExceptions` withoutPreferenceApplied++ describe "Prefer tx=rollback" $ do+ preferRollback `shouldRespondToReads` withPreferenceRollbackApplied+ preferRollback `shouldNotPersistMutations` withPreferenceRollbackApplied+ -- Exceptions are always without preference applied,+ -- because they return before the end of the transaction.+ preferRollback `shouldRaiseExceptions` withoutPreferenceApplied++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+ preferDefault `shouldRaiseExceptions` withoutPreferenceApplied++ describe "Prefer tx=commit" $ do+ preferCommit `shouldRespondToReads` withoutPreferenceApplied+ preferCommit `shouldPersistMutations` withoutPreferenceApplied+ preferCommit `shouldRaiseExceptions` withoutPreferenceApplied++ describe "Prefer tx=rollback" $ do+ preferRollback `shouldRespondToReads` withoutPreferenceApplied+ preferRollback `shouldPersistMutations` withoutPreferenceApplied+ preferRollback `shouldRaiseExceptions` 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+ preferDefault `shouldRaiseExceptions` withoutPreferenceApplied++ describe "Prefer tx=commit" $ do+ preferCommit `shouldRespondToReads` withoutPreferenceApplied+ preferCommit `shouldNotPersistMutations` withoutPreferenceApplied+ preferCommit `shouldRaiseExceptions` withoutPreferenceApplied++ describe "Prefer tx=rollback" $ do+ preferRollback `shouldRespondToReads` withoutPreferenceApplied+ preferRollback `shouldNotPersistMutations` withoutPreferenceApplied+ preferRollback `shouldRaiseExceptions` withoutPreferenceApplied+
+ test/spec/Feature/RootSpec.hs view
@@ -0,0 +1,34 @@+module Feature.RootSpec where++import Network.HTTP.Types+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 "root spec function" $ do+ it "accepts application/openapi+json" $+ request methodGet "/"+ [("Accept","application/openapi+json")] "" `shouldRespondWith`+ [json|{+ "swagger": "2.0",+ "info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}+ }|]+ { matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] }++ it "accepts application/json" $+ request methodGet "/"+ [("Accept", "application/json")] "" `shouldRespondWith`+ [json| {+ "tableName": "orders_view", "tableSchema": "test",+ "tableDeletable": true, "tableUpdatable": true,+ "tableInsertable": true, "tableDescription": null+ } |]+ { matchHeaders = [matchContentTypeJson] }
+ test/spec/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/spec/Feature/RpcSpec.hs view
@@ -0,0 +1,1266 @@+module Feature.RpcSpec where++import qualified Data.ByteString.Lazy as BL (empty, readFile)++import Network.Wai (Application)+import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))++import Network.HTTP.Types+import System.IO.Unsafe (unsafePerformIO)+import Test.Hspec hiding (pendingWith)+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON+import Text.Heredoc++import PostgREST.Config.PgVersion (PgVersion, pgVersion100,+ pgVersion109, pgVersion110,+ pgVersion112, pgVersion114,+ pgVersion140)++import Protolude hiding (get)+import SpecHelper++spec :: PgVersion -> SpecWith ((), Application)+spec actualPgVersion =+ describe "remote procedure call" $ do+ context "a proc that returns a set" $ do+ it "returns paginated results" $ do+ request methodPost "/rpc/getitemrange"+ (rangeHdrs (ByteRangeFromTo 0 0)) [json| { "min": 2, "max": 4 } |]+ `shouldRespondWith` [json| [{"id":3}] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-0/*"]+ }+ request methodGet "/rpc/getitemrange?min=2&max=4"+ (rangeHdrs (ByteRangeFromTo 0 0)) ""+ `shouldRespondWith` [json| [{"id":3}] |]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-0/*"]+ }+ request methodHead "/rpc/getitemrange?min=2&max=4"+ (rangeHdrs (ByteRangeFromTo 0 0)) ""+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "0-0/*"]+ }++ it "includes total count if requested" $ do+ request methodPost "/rpc/getitemrange"+ (rangeHdrsWithCount (ByteRangeFromTo 0 0))+ [json| { "min": 2, "max": 4 } |]+ `shouldRespondWith` [json| [{"id":3}] |]+ { matchStatus = 206 -- it now knows the response is partial+ , matchHeaders = ["Content-Range" <:> "0-0/2"]+ }+ request methodGet "/rpc/getitemrange?min=2&max=4"+ (rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""+ `shouldRespondWith` [json| [{"id":3}] |]+ { matchStatus = 206+ , matchHeaders = ["Content-Range" <:> "0-0/2"]+ }+ request methodHead "/rpc/getitemrange?min=2&max=4"+ (rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""+ `shouldRespondWith` ""+ { matchStatus = 206+ , matchHeaders = ["Content-Range" <:> "0-0/2"]+ }++ it "returns proper json" $ do+ post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`+ [json| [ {"id": 3}, {"id":4} ] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/getitemrange?min=2&max=4" `shouldRespondWith`+ [json| [ {"id": 3}, {"id":4} ] |]+ { matchHeaders = [matchContentTypeJson] }++ it "returns CSV" $ do+ request methodPost "/rpc/getitemrange"+ (acceptHdrs "text/csv")+ [json| { "min": 2, "max": 4 } |]+ `shouldRespondWith` "id\n3\n4"+ { matchStatus = 200+ , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]+ }+ request methodGet "/rpc/getitemrange?min=2&max=4"+ (acceptHdrs "text/csv") ""+ `shouldRespondWith` "id\n3\n4"+ { matchStatus = 200+ , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]+ }+ request methodHead "/rpc/getitemrange?min=2&max=4"+ (acceptHdrs "text/csv") ""+ `shouldRespondWith` ""+ { matchStatus = 200+ , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]+ }++ context "unknown function" $ do+ it "returns 404" $+ post "/rpc/fakefunc" [json| {} |] `shouldRespondWith` 404++ it "should fail with 404 on unknown proc name" $+ get "/rpc/fake" `shouldRespondWith` 404++ it "should fail with 404 on unknown proc args" $ do+ get "/rpc/sayhello" `shouldRespondWith` 404+ get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404++ it "should not ignore unknown args and fail with 404" $+ get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`+ [json| {+ "hint":"If a new function was created in the database with this name and parameters, 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 parameters, try reloading the schema cache.",+ "message":"Could not find the test.sayhello function with a single json or jsonb parameter 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 parameters, 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 parameters, 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 parameters' names but different types" $ do+ it "should fail with 300 Multiple Choices without explicit type casts" $+ get "/rpc/overloaded_same_args?arg=value" `shouldRespondWith`+ [json| {+ "hint":"Try renaming the parameters or the function itself in the database so function overloading can be resolved",+ "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"}|]+ { matchHeaders = [matchContentTypeJson] }+ post "/rpc/quotedFunction"+ [json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]+ `shouldRespondWith`+ [json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]+ { matchHeaders = [matchContentTypeJson] }++ context "shaping the response returned by a proc" $ do+ it "returns a project" $ do+ post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client_id":1}]|]+ get "/rpc/getproject?id=1" `shouldRespondWith`+ [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`+ [json|[{"id":2},{"id":3},{"id":4}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" `shouldRespondWith`+ [json|[{"id":2},{"id":3},{"id":4}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can limit proc results" $ do+ post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id&limit=2&offset=1" [json| {} |]+ `shouldRespondWith` [json|[{"id":3},{"id":4}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "1-2/*"] }+ get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id&limit=2&offset=1"+ `shouldRespondWith` [json|[{"id":3},{"id":4}]|]+ { matchStatus = 200+ , matchHeaders = ["Content-Range" <:> "1-2/*"] }++ it "select works on the first level" $ do+ post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7"}]|]+ get "/rpc/getproject?id=1&select=id,name" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7"}]|]++ context "foreign entities embedding" $ do+ it "can embed if related tables are in the exposed schema" $ do+ post "/rpc/getproject?select=id,name,client:clients(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/getproject?id=1&select=id,name,client:clients(id),tasks(id)" `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "cannot embed if the related table is not in the exposed schema" $ do+ post "/rpc/single_article?select=*,article_stars(*)" [json|{ "id": 1}|]+ `shouldRespondWith` 400+ get "/rpc/single_article?id=1&select=*,article_stars(*)"+ `shouldRespondWith` 400++ it "can embed if the related tables are in a hidden schema but exposed as views" $ do+ post "/rpc/single_article?select=id,articleStars(userId)"+ [json|{ "id": 2}|]+ `shouldRespondWith`+ [json|{"id": 2, "articleStars": [{"userId": 3}]}|]+ get "/rpc/single_article?id=2&select=id,articleStars(userId)"+ `shouldRespondWith`+ [json|{"id": 2, "articleStars": [{"userId": 3}]}|]++ it "can embed an M2M relationship table" $+ get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"+ `shouldRespondWith` [json|[+ {"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]},+ {"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]}+ ]|]+ { matchHeaders = [matchContentTypeJson] }++ it "can embed an M2M relationship table that has a parent relationship table" $+ get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1"+ `shouldRespondWith` [json|[+ {"name":"Michael Scott","tasks":[+ {"name":"Design IOS","project":{"name":"IOS"}},+ {"name":"Code IOS","project":{"name":"IOS"}},+ {"name":"Design OSX","project":{"name":"OSX"}}+ ]},+ {"name":"Dwight Schrute","tasks":[+ {"name":"Design w7","project":{"name":"Windows 7"}},+ {"name":"Design IOS","project":{"name":"IOS"}}+ ]}+ ]|]+ { 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`+ [json| [] |]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/test_empty_rowset" `shouldRespondWith`+ [json| [] |]+ { matchHeaders = [matchContentTypeJson] }++ context "proc return types" $ do+ context "returns text" $ do+ it "returns proper json" $+ post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`+ [json|"Hello, world"|]+ { matchHeaders = [matchContentTypeJson] }++ it "can handle unicode" $+ post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith`+ [json|"Hello, ¥"|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns array" $+ post "/rpc/ret_array" [json|{}|] `shouldRespondWith`+ [json|[1, 2, 3]|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns setof integers" $+ post "/rpc/ret_setof_integers"+ [json|{}|]+ `shouldRespondWith`+ [json|[1,2,3]|]++ it "returns enum value" $+ post "/rpc/ret_enum" [json|{ "val": "foo" }|] `shouldRespondWith`+ [json|"foo"|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns domain value" $+ post "/rpc/ret_domain" [json|{ "val": "8" }|] `shouldRespondWith`+ [json|8|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns range" $+ post "/rpc/ret_range" [json|{ "low": 10, "up": 20 }|] `shouldRespondWith`+ [json|"[10,20)"|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns row of scalars" $+ post "/rpc/ret_scalars" [json|{}|] `shouldRespondWith`+ [json|[{"a":"scalars", "b":"foo", "c":1, "d":"[10,20)"}]|]+ { matchHeaders = [matchContentTypeJson] }++ it "returns composite type in exposed schema" $+ post "/rpc/ret_point_2d"+ [json|{}|]+ `shouldRespondWith`+ [json|{"x": 10, "y": 5}|]++ 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}|]++ it "returns null for void" $+ 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+ -- 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| {+ "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 } } |]+ `shouldRespondWith`+ [json|"object"|]+ { matchHeaders = [matchContentTypeJson] }++ when (actualPgVersion < pgVersion100) $+ it "parses quoted JSON arguments as JSON (Postgres < 10)" $+ post "/rpc/json_argument"+ [json| { "arg": "{ \"key\": 3 }" } |]+ `shouldRespondWith`+ [json|"object"|]+ { matchHeaders = [matchContentTypeJson] }++ when ((actualPgVersion >= pgVersion109 && actualPgVersion < pgVersion110)+ || actualPgVersion >= pgVersion114) $+ it "parses quoted JSON arguments as JSON string (from Postgres 10.9, 11.4)" $+ post "/rpc/json_argument"+ [json| { "arg": "{ \"key\": 3 }" } |]+ `shouldRespondWith`+ [json|"string"|]+ { matchHeaders = [matchContentTypeJson] }++ context "improper input" $ do+ it "rejects unknown content type even if payload is good" $ do+ request methodPost "/rpc/sayhello"+ (acceptHdrs "audio/mpeg3") [json| { "name": "world" } |]+ `shouldRespondWith` 415+ request methodGet "/rpc/sayhello?name=world"+ (acceptHdrs "audio/mpeg3") ""+ `shouldRespondWith` 415+ it "rejects malformed json payload" $ do+ p <- request methodPost "/rpc/sayhello"+ (acceptHdrs "application/json") "sdfsdf"+ liftIO $ do+ simpleStatus p `shouldBe` badRequest400+ isErrorFormat (simpleBody p) `shouldBe` True+ it "treats simple plpgsql raise as invalid input" $ do+ p <- post "/rpc/problem" "{}"+ liftIO $ do+ simpleStatus p `shouldBe` badRequest400+ isErrorFormat (simpleBody p) `shouldBe` True+ it "treats plpgsql assert as internal server error" $ do+ p <- post "/rpc/assert" "{}"+ liftIO $ do+ simpleStatus p `shouldBe` internalServerError500+ isErrorFormat (simpleBody p) `shouldBe` True++ context "unsupported verbs" $ do+ it "DELETE fails" $+ request methodDelete "/rpc/sayhello" [] ""+ `shouldRespondWith`+ [json|{"message":"Bad Request"}|]+ { matchStatus = 405+ , matchHeaders = [matchContentTypeJson]+ }+ it "PATCH fails" $+ request methodPatch "/rpc/sayhello" [] ""+ `shouldRespondWith` 405+ it "OPTIONS fails" $+ -- TODO: should return info about the function+ request methodOptions "/rpc/sayhello" [] ""+ `shouldRespondWith` 405++ it "executes the proc exactly once per request" $ do+ -- 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`+ [json| "Return value of no parameters procedure." |]+ { matchHeaders = [matchContentTypeJson] }+ it "interprets empty string as a function with no args on a get request" $+ get "/rpc/noparamsproc" `shouldRespondWith`+ [json| "Return value of no parameters procedure." |]+ { matchHeaders = [matchContentTypeJson] }++ it "returns proper output when having the same return col name as the proc name" $ do+ post "/rpc/test" [json|{}|] `shouldRespondWith`+ [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }+ get "/rpc/test" `shouldRespondWith`+ [json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }++ context "procs with OUT/INOUT params" $ do+ it "returns an object result when there is a single OUT param" $ do+ get "/rpc/single_out_param?num=5"+ `shouldRespondWith`+ [json|{"num_plus_one":6}|]++ get "/rpc/single_json_out_param?a=1&b=two"+ `shouldRespondWith`+ [json|{"my_json": {"a": 1, "b": "two"}}|]++ 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 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 TABLE return" $ do+ it "returns an object result when there is a single-column TABLE return type" $+ get "/rpc/single_column_table_return"+ `shouldRespondWith`+ [json|[{"a": "A"}]|]++ it "returns an object result when there is a multi-column TABLE return type" $+ get "/rpc/multi_column_table_return"+ `shouldRespondWith`+ [json|[{"a": "A", "b": "B"}]|]++ 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}|]+ 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"+ `shouldRespondWith` [json|{ "hint": "Upgrade your plan", "details": "Quota exceeded" }|]+ { matchStatus = 402+ , matchHeaders = [matchContentTypeJson]+ }++ it "defaults to status 500 if RAISE code is PT not followed by a number" $+ get "/rpc/raise_bad_pt"+ `shouldRespondWith`+ [json|{"hint": null, "details": null}|]+ { matchStatus = 500+ , matchHeaders = [ matchContentTypeJson ]+ }++ context "expects a single json object" $ do+ it "does not expand posted json into parameters" $+ request methodPost "/rpc/singlejsonparam"+ [("prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`+ [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]+ { matchHeaders = [matchContentTypeJson] }++ it "accepts parameters from an html form" $+ request methodPost "/rpc/singlejsonparam"+ [("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]+ ("integer=7&double=2.71828&varchar=forms+are+fun&" <>+ "boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`+ [json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"+ , "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]+ { matchHeaders = [matchContentTypeJson] }++ it "works with GET" $+ request methodGet "/rpc/singlejsonparam?p1=1&p2=text" [("Prefer","params=single-object")] ""+ `shouldRespondWith` [json|{ "p1": "1", "p2": "text"}|]+ { matchHeaders = [matchContentTypeJson] }++ 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"|]++ -- https://github.com/PostgREST/postgrest/issues/1672+ context "embedding overloaded functions with the same signature except for the last param with a default value" $ do+ it "overloaded_default(text default)" $ do+ request methodPost "/rpc/overloaded_default?select=id,name,users(name)"+ [("Content-Type", "application/json")]+ [json|{}|]+ `shouldRespondWith`+ [json|[{"id": 2, "name": "Code w7", "users": [{"name": "Angela Martin"}]}] |]++ it "overloaded_default(int)" $+ request methodPost "/rpc/overloaded_default"+ [("Content-Type", "application/json")]+ [json|{"must_param":1}|]+ `shouldRespondWith`+ [json|{"val":1}|]++ it "overloaded_default(int, text default)" $ do+ request methodPost "/rpc/overloaded_default?select=id,name,users(name)"+ [("Content-Type", "application/json")]+ [json|{"a":4}|]+ `shouldRespondWith`+ [json|[{"id": 5, "name": "Design IOS", "users": [{"name": "Michael Scott"}, {"name": "Dwight Schrute"}]}] |]++ it "overloaded_default(int, int)" $+ request methodPost "/rpc/overloaded_default"+ [("Content-Type", "application/json")]+ [json|{"a":2,"must_param":4}|]+ `shouldRespondWith`+ [json|{"a":2,"val":4}|]++ 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|{name: "John"}|] `shouldRespondWith` 400++ it "ignores json keys not included in ?columns" $+ post "/rpc/sayhello?columns=name"+ [json|{"name": "John", "smth": "here", "other": "stuff", "fake_id": 13}|]+ `shouldRespondWith`+ [json|"Hello, John"|]+ { matchHeaders = [matchContentTypeJson] }++ it "only takes the first object in case of array of objects payload" $+ post "/rpc/add_them"+ [json|[+ {"a": 1, "b": 2},+ {"a": 4, "b": 6},+ {"a": 100, "b": 200} ]|]+ `shouldRespondWith` "3"+ { matchHeaders = [matchContentTypeJson] }++ context "bulk RPC with params=multiple-objects" $ do+ it "works with a scalar function an returns a json array" $+ request methodPost "/rpc/add_them" [("Prefer", "params=multiple-objects")]+ [json|[+ {"a": 1, "b": 2},+ {"a": 4, "b": 6},+ {"a": 100, "b": 200} ]|]+ `shouldRespondWith`+ [json|+ [3, 10, 300]+ |] { matchHeaders = [matchContentTypeJson] }++ it "works with a scalar function an returns a json array when posting CSV" $+ request methodPost "/rpc/add_them" [("Content-Type", "text/csv"), ("Prefer", "params=multiple-objects")]+ "a,b\n1,2\n4,6\n100,200"+ `shouldRespondWith`+ [json|+ [3, 10, 300]+ |]+ { matchStatus = 200+ , matchHeaders = [matchContentTypeJson]+ }++ it "works with a non-scalar result" $+ request methodPost "/rpc/get_projects_below?select=id,name" [("Prefer", "params=multiple-objects")]+ [json|[+ {"id": 1},+ {"id": 5} ]|]+ `shouldRespondWith`+ [json|+ [{"id":1,"name":"Windows 7"},+ {"id":2,"name":"Windows 10"},+ {"id":3,"name":"IOS"},+ {"id":4,"name":"OSX"}]+ |] { matchHeaders = [matchContentTypeJson] }++ context "HTTP request env vars" $ do+ it "custom header is set" $+ request methodPost "/rpc/get_guc_value"+ [("Custom-Header", "test")]+ (+ if actualPgVersion >= pgVersion140 then+ [json| { "prefix": "request.headers", "name": "custom-header" } |]+ else+ [json| { "name": "request.header.custom-header" } |]+ )+ `shouldRespondWith`+ [json|"test"|]+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson ]+ }+ it "standard header is set" $+ request methodPost "/rpc/get_guc_value"+ [("Origin", "http://example.com")]+ (+ if actualPgVersion >= pgVersion140 then+ [json| { "prefix": "request.headers", "name": "origin" } |]+ else+ [json| { "name": "request.header.origin" } |]+ )+ `shouldRespondWith`+ [json|"http://example.com"|]+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson ]+ }+ it "current role is available as GUC claim" $+ request methodPost "/rpc/get_guc_value" []+ (+ if actualPgVersion >= pgVersion140 then+ [json| { "prefix": "request.jwt.claims", "name": "role" } |]+ else+ [json| { "name": "request.jwt.claim.role" } |]+ )+ `shouldRespondWith`+ [json|"postgrest_test_anonymous"|]+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson ]+ }+ it "single cookie ends up as claims" $+ request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]+ (+ if actualPgVersion >= pgVersion140 then+ [json| {"prefix": "request.cookies", "name":"acookie"} |]+ else+ [json| {"name":"request.cookie.acookie"} |]+ )+ `shouldRespondWith`+ [json|"cookievalue"|]+ { matchStatus = 200+ , matchHeaders = []+ }+ it "multiple cookies ends up as claims" $+ request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]+ (+ if actualPgVersion >= pgVersion140 then+ [json| {"prefix": "request.cookies", "name":"secondcookie"} |]+ else+ [json| {"name":"request.cookie.secondcookie"} |]+ )+ `shouldRespondWith`+ [json|"anothervalue"|]+ { matchStatus = 200+ , matchHeaders = []+ }+ it "app settings available" $+ request methodPost "/rpc/get_guc_value" []+ [json| { "name": "app.settings.app_host" } |]+ `shouldRespondWith`+ [json|"localhost"|]+ { matchStatus = 200+ , matchHeaders = [ matchContentTypeJson ]+ }+ it "gets the Authorization value" $+ request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]+ (+ if actualPgVersion >= pgVersion140 then+ [json| {"prefix": "request.headers", "name":"authorization"} |]+ else+ [json| {"name":"request.header.authorization"} |]+ )+ `shouldRespondWith`+ [json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]+ { matchStatus = 200+ , matchHeaders = []+ }+ it "gets the http method" $+ request methodPost "/rpc/get_guc_value" []+ [json| {"name":"request.method"} |]+ `shouldRespondWith`+ [json|"POST"|]+ { matchStatus = 200+ , matchHeaders = []+ }+ it "gets the http path" $+ request methodPost "/rpc/get_guc_value" []+ [json| {"name":"request.path"} |]+ `shouldRespondWith`+ [json|"/rpc/get_guc_value"|]+ { matchStatus = 200+ , matchHeaders = []+ }++ context "binary output" $ do+ context "Proc that returns scalar" $ do+ it "can query without selecting column" $+ 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"]+ }++ it "can get raw output with Accept: text/plain" $+ request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""+ `shouldRespondWith` "Welcome to PostgREST"+ { matchStatus = 200+ , 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"]+ }++ it "fails if a single column is not selected" $+ 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 }++ context "only for GET rpc" $ do+ it "should fail on mutating procs" $ do+ 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`+ [json|[{ "id": 3 }, { "id": 4 }]|]+ { matchHeaders = [matchContentTypeJson] }++ it "should work with filters that have the not operator" $ do+ get "/rpc/get_projects_below?id=5&id=not.gt.2&select=id" `shouldRespondWith`+ [json|[{ "id": 1 }, { "id": 2 }]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/get_projects_below?id=5&id=not.in.(1,3)&select=id" `shouldRespondWith`+ [json|[{ "id": 2 }, { "id": 4 }]|]+ { matchHeaders = [matchContentTypeJson] }++ it "should work with filters that use the plain with language fts operator" $ do+ get "/rpc/get_tsearch?text_search_vector=fts(english).impossible" `shouldRespondWith`+ [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/get_tsearch?text_search_vector=plfts.impossible" `shouldRespondWith`+ [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]+ { matchHeaders = [matchContentTypeJson] }+ get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`+ [json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]+ { matchHeaders = [matchContentTypeJson] }+ when (actualPgVersion >= pgVersion112) $+ get "/rpc/get_tsearch?text_search_vector=wfts.impossible" `shouldRespondWith`+ [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]+ { matchHeaders = [matchContentTypeJson] }++ 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] }++ 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 ]+ }++ context "single unnamed param" $ do+ it "can insert json directly" $+ post "/rpc/unnamed_json_param"+ [json|{"A": 1, "B": 2, "C": 3}|]+ `shouldRespondWith`+ [json|{"A": 1, "B": 2, "C": 3}|]++ it "can insert text directly" $+ request methodPost "/rpc/unnamed_text_param"+ [("Content-Type", "text/plain"), ("Accept", "text/plain")]+ [str|unnamed text arg|]+ `shouldRespondWith`+ [str|unnamed text arg|]++ it "can insert bytea directly" $ do+ let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png"+ r <- request methodPost "/rpc/unnamed_bytea_param"+ [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]+ file+ liftIO $ do+ let respBody = simpleBody r+ respBody `shouldBe` file++ it "will err when no function with single unnamed json parameter exists and application/json is specified" $+ request methodPost "/rpc/unnamed_int_param" [("Content-Type", "application/json")]+ [json|{"x": 1, "y": 2}|]+ `shouldRespondWith`+ [json|{+ "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",+ "message": "Could not find the test.unnamed_int_param(x, y) function or the test.unnamed_int_param function with a single unnamed json or jsonb parameter in the schema cache"+ }|]+ { matchStatus = 404+ , matchHeaders = [ matchContentTypeJson ]+ }++ it "will err when no function with single unnamed text parameter exists and text/plain is specified" $+ request methodPost "/rpc/unnamed_int_param"+ [("Content-Type", "text/plain")]+ [str|a simple text|]+ `shouldRespondWith`+ [json|{+ "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",+ "message": "Could not find the test.unnamed_int_param function with a single unnamed text parameter in the schema cache"+ }|]+ { matchStatus = 404+ , matchHeaders = [ matchContentTypeJson ]+ }++ it "will err when no function with single unnamed bytea parameter exists and application/octet-stream is specified" $+ let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png" in+ request methodPost "/rpc/unnamed_int_param"+ [("Content-Type", "application/octet-stream")]+ file+ `shouldRespondWith`+ [json|{+ "hint": "If a new function was created in the database with this name and parameters, try reloading the schema cache.",+ "message": "Could not find the test.unnamed_int_param function with a single unnamed bytea parameter in the schema cache"+ }|]+ { matchStatus = 404+ , matchHeaders = [ matchContentTypeJson ]+ }++ it "should be able to resolve when a single unnamed json parameter exists and other overloaded functions are found" $ do+ request methodPost "/rpc/overloaded_unnamed_param" [("Content-Type", "application/json")]+ [json|{}|]+ `shouldRespondWith`+ [json| 1 |]+ { matchStatus = 200+ , matchHeaders = [matchContentTypeJson]+ }+ request methodPost "/rpc/overloaded_unnamed_param" [("Content-Type", "application/json")]+ [json|{"x": 1, "y": 2}|]+ `shouldRespondWith`+ [json| 3 |]+ { matchStatus = 200+ , matchHeaders = [matchContentTypeJson]+ }++ it "should be able to fallback to the single unnamed parameter function when other overloaded functions are not found" $ do+ request methodPost "/rpc/overloaded_unnamed_param"+ [("Content-Type", "application/json")]+ [json|{"A": 1, "B": 2, "C": 3}|]+ `shouldRespondWith`+ [json|{"A": 1, "B": 2, "C": 3}|]+ request methodPost "/rpc/overloaded_unnamed_param"+ [("Content-Type", "text/plain"), ("Accept", "text/plain")]+ [str|unnamed text arg|]+ `shouldRespondWith`+ [str|unnamed text arg|]+ let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png"+ r <- request methodPost "/rpc/overloaded_unnamed_param"+ [("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]+ file+ liftIO $ do+ let respBody = simpleBody r+ respBody `shouldBe` file++ it "should call the function with no parameters and not fallback to the single unnamed parameter function when using GET with Content-Type headers" $ do+ request methodGet "/rpc/overloaded_unnamed_param" [("Content-Type", "text/plain")] ""+ `shouldRespondWith`+ [json| 1|]+ { matchStatus = 200 }+ request methodGet "/rpc/overloaded_unnamed_param" [("Content-Type", "application/octet-stream")] ""+ `shouldRespondWith`+ [json| 1|]+ { matchStatus = 200 }++ it "should fail to fallback to any single unnamed parameter function when using an unsupported Content-Type header" $ do+ request methodPost "/rpc/overloaded_unnamed_param"+ [("Content-Type", "text/csv")]+ "a,b\n1,2\n4,6\n100,200"+ `shouldRespondWith`+ [json| {+ "hint":"If a new function was created in the database with this name and parameters, try reloading the schema cache.",+ "message":"Could not find the test.overloaded_unnamed_param(a, b) function in the schema cache"}|]+ { matchStatus = 404+ , matchHeaders = [matchContentTypeJson]+ }++ it "should fail with multiple choices when two fallback functions with single unnamed json and jsonb parameters exist" $ do+ request methodPost "/rpc/overloaded_unnamed_json_jsonb_param" [("Content-Type", "application/json")]+ [json|{"A": 1, "B": 2, "C": 3}|]+ `shouldRespondWith`+ [json| {+ "hint":"Try renaming the parameters or the function itself in the database so function overloading can be resolved",+ "message":"Could not choose the best candidate function between: test.overloaded_unnamed_json_jsonb_param( => json), test.overloaded_unnamed_json_jsonb_param( => jsonb)"}|]+ { matchStatus = 300+ , matchHeaders = [matchContentTypeJson]+ }
+ test/spec/Feature/SingularSpec.hs view
@@ -0,0 +1,324 @@+module Feature.SingularSpec where++import Network.Wai (Application)+import Network.Wai.Test (SResponse (..))++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import Protolude hiding (get)+import SpecHelper+++spec :: SpecWith ((), Application)+spec =+ describe "Requesting singular json object" $ do+ let singular = ("Accept", "application/vnd.pgrst.object+json")++ context "with GET request" $ do+ it "fails for zero rows" $+ request methodGet "/items?id=gt.0&id=lt.0" [singular] ""+ `shouldRespondWith` 406++ it "will select an existing object" $ do+ request methodGet "/items?id=eq.5" [singular] ""+ `shouldRespondWith`+ [json|{"id":5}|]+ { matchHeaders = [matchContentTypeSingular] }+ -- also test without the +json suffix+ request methodGet "/items?id=eq.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`+ [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 = [matchContentTypeSingular] }++ context "when updating rows" $ do+ it "works for one row with return=rep" $ do+ request methodPatch "/addresses?id=eq.1"+ [("Prefer", "return=representation"), singular]+ [json| { address: "B Street" } |]+ `shouldRespondWith`+ [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+ 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.1"+ `shouldRespondWith`+ [json|[{"id":1,"address":"address 1"}]|]++ it "raises an error for multiple rows with return=rep" $ do+ 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.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`+ [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 = [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`+ [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 = [matchContentTypeSingular]+ }++ context "when creating rows" $ do+ 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 with return=minimal" $ do+ request methodPost "/addresses"+ [("Prefer", "return=minimal"), singular]+ [json| [ { id: 103, address: "xxx" } ] |]+ `shouldRespondWith` ""+ { matchStatus = 201+ , matchHeaders = ["Content-Range" <:> "*/*"]+ }++ it "raises an error when attempting to create multiple entities" $ do+ 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`+ "[]"++ it "raises an error when attempting to create multiple entities with return=rep" $ do+ 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`+ "[]"++ it "raises an error regardless of return=minimal" $ do+ request methodPost "/addresses"+ [("Prefer", "tx=commit"), ("Prefer", "return=minimal"), singular]+ [json| [ { id: 204, address: "xxx" }, { id: 205, 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.204"+ `shouldRespondWith`+ "[]"++ it "raises an error when creating zero entities" $+ request methodPost "/addresses"+ [singular]+ [json| [ ] |]+ `shouldRespondWith`+ [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 = [matchContentTypeSingular]+ }++ it "raises an error when creating zero entities with return=rep" $+ request methodPost "/addresses"+ [("Prefer", "return=representation"), singular]+ [json| [ ] |]+ `shouldRespondWith`+ [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 = [matchContentTypeSingular]+ }++ context "when deleting rows" $ 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` [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+ 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" ]+ }++ -- 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+ 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" ]+ }++ -- 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/*"]+ }++ it "raises an error when deleting zero entities" $+ request methodDelete "/items?id=lt.0"+ [singular] ""+ `shouldRespondWith`+ [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 = [matchContentTypeSingular]+ }++ it "raises an error when deleting zero entities with return=rep" $+ request methodDelete "/items?id=lt.0"+ [("Prefer", "return=representation"), singular] ""+ `shouldRespondWith`+ [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 = [matchContentTypeSingular]+ }++ context "when calling a stored proc" $ do+ it "fails for zero rows" $+ request methodPost "/rpc/getproject"+ [singular] [json|{ "id": 9999999}|]+ `shouldRespondWith`+ [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 = [matchContentTypeSingular]+ }++ -- this one may be controversial, should vnd.pgrst.object include+ -- the likes of 2 and "hello?"+ it "succeeds for scalar result" $+ request methodPost "/rpc/sayhello"+ [singular] [json|{ "name": "world"}|]+ `shouldRespondWith` 200++ it "returns a single object for json proc" $+ request methodPost "/rpc/getproject"+ [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`+ [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]+ }++ 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"+ [("Prefer", "tx=commit"), singular]+ [json| {"id_l": 1, "id_h": 2, "name": "changed"} |]+ `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" ]+ }++ -- should rollback function+ post "/rpc/getproject?select=id,name"+ [json| {"id": 1} |]+ `shouldRespondWith`+ [json|[{"id":1,"name":"Windows 7"}]|]
+ test/spec/Feature/UnicodeSpec.hs view
@@ -0,0 +1,34 @@+module Feature.UnicodeSpec 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)++spec :: SpecWith ((), Application)+spec =+ describe "Reading and writing to unicode schema and table names" $+ it "Can read and write values" $ do+ get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"+ `shouldRespondWith` "[]"++ 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 }] |]++ request methodDelete "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"+ [("Prefer", "tx=commit")]+ ""+ `shouldRespondWith`+ 204
+ test/spec/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/spec/Feature/UpsertSpec.hs view
@@ -0,0 +1,427 @@+module Feature.UpsertSpec where++import Network.Wai (Application)++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai+import Test.Hspec.Wai.JSON++import PostgREST.Config.PgVersion (PgVersion, pgVersion110)++import Protolude hiding (get, put)+import SpecHelper++spec :: PgVersion -> SpecWith ((), Application)+spec actualPgVersion =+ describe "UPSERT" $ do+ context "with POST" $ do+ context "when Prefer: resolution=merge-duplicates is specified" $ do+ it "INSERTs and UPDATEs rows on pk conflict" $+ request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]+ [json| [+ { "name": "Javascript", "rank": 6 },+ { "name": "Java", "rank": 2 },+ { "name": "C", "rank": 1 }+ ]|] `shouldRespondWith` [json| [+ { "name": "Javascript", "rank": 6 },+ { "name": "Java", "rank": 2 },+ { "name": "C", "rank": 1 }+ ]|]+ { matchStatus = 201+ , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]+ }++ it "INSERTs and UPDATEs row on composite pk conflict" $+ request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]+ [json| [+ { "first_name": "Frances M.", "last_name": "Roe", "salary": "30000" },+ { "first_name": "Peter S.", "last_name": "Yang", "salary": 42000 }+ ]|] `shouldRespondWith` [json| [+ { "first_name": "Frances M.", "last_name": "Roe", "salary": "$30,000.00", "company": "One-Up Realty", "occupation": "Author" },+ { "first_name": "Peter S.", "last_name": "Yang", "salary": "$42,000.00", "company": null, "occupation": null }+ ]|]+ { matchStatus = 201+ , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]+ }++ when (actualPgVersion >= pgVersion110) $+ it "INSERTs and UPDATEs rows on composite pk conflict for partitioned tables" $+ request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]+ [json| [+ { "name": "Murcielago", "year": 2001, "car_brand_name": null},+ { "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }+ ]|] `shouldRespondWith` [json| [+ { "name": "Murcielago", "year": 2001, "car_brand_name": null},+ { "name": "Roma", "year": 2021, "car_brand_name": "Ferrari" }+ ]|]+ { matchStatus = 201+ , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]+ }++ it "succeeds when the payload has no elements" $+ request methodPost "/articles" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]+ [json|[]|] `shouldRespondWith`+ [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }++ it "INSERTs and UPDATEs rows on single unique key conflict" $+ request methodPost "/single_unique?on_conflict=unique_key" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]+ [json| [+ { "unique_key": 1, "value": "B" },+ { "unique_key": 2, "value": "C" }+ ]|] `shouldRespondWith` [json| [+ { "unique_key": 1, "value": "B" },+ { "unique_key": 2, "value": "C" }+ ]|]+ { matchStatus = 201+ , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]+ }++ it "INSERTs and UPDATEs rows on compound unique keys conflict" $+ request methodPost "/compound_unique?on_conflict=key1,key2" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]+ [json| [+ { "key1": 1, "key2": 1, "value": "B" },+ { "key1": 1, "key2": 2, "value": "C" }+ ]|] `shouldRespondWith` [json| [+ { "key1": 1, "key2": 1, "value": "B" },+ { "key1": 1, "key2": 2, "value": "C" }+ ]|]+ { matchStatus = 201+ , matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]+ }++ context "when Prefer: resolution=ignore-duplicates is specified" $ do+ it "INSERTs and ignores rows on pk conflict" $+ request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]+ [json|[+ { "name": "PHP", "rank": 9 },+ { "name": "Python", "rank": 10 }+ ]|] `shouldRespondWith` [json|[+ { "name": "PHP", "rank": 9 }+ ]|]+ { matchStatus = 201+ , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]+ }++ it "INSERTs and ignores rows on composite pk conflict" $+ request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]+ [json|[+ { "first_name": "Daniel B.", "last_name": "Lyon", "salary": "72000", "company": null, "occupation": null },+ { "first_name": "Sara M.", "last_name": "Torpey", "salary": 60000, "company": "Burstein-Applebee", "occupation": "Soil scientist" }+ ]|] `shouldRespondWith` [json|[+ { "first_name": "Sara M.", "last_name": "Torpey", "salary": "$60,000.00", "company": "Burstein-Applebee", "occupation": "Soil scientist" }+ ]|]+ { matchStatus = 201+ , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]+ }++ when (actualPgVersion >= pgVersion110) $+ it "INSERTs and ignores rows on composite pk conflict for partitioned tables" $+ request methodPost "/car_models" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]+ [json| [+ { "name": "Murcielago", "year": 2001, "car_brand_name": "Ferrari" },+ { "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }+ ]|] `shouldRespondWith` [json| [+ { "name": "Huracán", "year": 2021, "car_brand_name": "Lamborghini" }+ ]|]+ { matchStatus = 201+ , matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]+ }++ 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": 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": 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")]+ [json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|]+ `shouldRespondWith`+ [json|[ { "id": 3} ]|]+ { matchStatus = 201 ,+ matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates",+ matchContentTypeJson] }++ request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]+ [json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]+ `shouldRespondWith`+ [json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]+ { matchStatus = 201 ,+ matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates",+ matchContentTypeJson] }++ it "succeeds and ignores the Prefer: resolution header(no Preference-Applied present) if the table has no PK" $+ request methodPost "/no_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]+ [json|[ { "a": "1", "b": "0" } ]|]+ `shouldRespondWith`+ [json|[ { "a": "1", "b": "0" } ]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }++ it "succeeds if not a single resource is created" $ do+ request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]+ [json|[ { "name": "Java", "rank": 1 } ]|] `shouldRespondWith`+ [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }+ request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]+ [json|[ { "name": "Java", "rank": 1 }, { "name": "C", "rank": 2 } ]|] `shouldRespondWith`+ [json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }++ context "with PUT" $ do+ context "Restrictions" $ do+ it "fails if Range is specified" $+ request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]+ [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"+ [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"+ [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"+ [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"+ [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)"+ [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)"+ [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"+ [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"+ [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" [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"+ [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" [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+ -- 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+ -- assert that the next request will indeed be an insert+ get "/employees?first_name=eq.Susan&last_name=eq.Heidt"+ `shouldRespondWith`+ [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" } ]|]++ when (actualPgVersion >= pgVersion110) $+ it "succeeds on a partitioned table with composite pk" $ do+ -- assert that the next request will indeed be an insert+ get "/car_models?name=eq.Supra&year=eq.2021"+ `shouldRespondWith`+ [json|[]|]++ request methodPut "/car_models?name=eq.Supra&year=eq.2021"+ [("Prefer", "return=representation")]+ [json| [ { "name": "Supra", "year": 2021 } ]|]+ `shouldRespondWith`+ [json| [ { "name": "Supra", "year": 2021, "car_brand_name": null } ]|]++ it "succeeds if the table has only PK cols and no other cols" $ do+ -- 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+ -- 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")]+ [json| [ { "name": "Java", "rank": 13 } ]|]+ `shouldRespondWith`+ [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+ -- 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": "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": "Frances M.", "last_name": "Roe", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]++ when (actualPgVersion >= pgVersion110) $+ it "succeeds on a partitioned table with composite pk" $ do+ -- assert that the next request will indeed be an update+ get "/car_models?name=eq.DeLorean&year=eq.1981"+ `shouldRespondWith`+ [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": "DMC" } ]|]++ request methodPut "/car_models?name=eq.DeLorean&year=eq.1981"+ [("Prefer", "return=representation")]+ [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]+ `shouldRespondWith`+ [json| [ { "name": "DeLorean", "year": 1981, "car_brand_name": null } ]|]++ it "succeeds if the table has only PK cols and no other cols" $ do+ -- 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")]+ [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" $ 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" [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] `shouldRespondWith` 204+ get "/UnitTest?idUnitTest=eq.1" `shouldRespondWith`+ [json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|]
+ test/spec/Main.hs view
@@ -0,0 +1,234 @@+module Main where++import qualified Data.Aeson as JSON+import qualified Hasql.Pool as P+import qualified Hasql.Transaction.Sessions as HT++import Data.Function (id)+import Data.List.NonEmpty (toList)++import Test.Hspec++import PostgREST.App (postgrest)+import PostgREST.Config (AppConfig (..), LogLevel (..))+import PostgREST.Config.Database (queryPgVersion)+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+import qualified Feature.AuthSpec+import qualified Feature.BinaryJwtSecretSpec+import qualified Feature.ConcurrentSpec+import qualified Feature.CorsSpec+import qualified Feature.DeleteSpec+import qualified Feature.DisabledOpenApiSpec+import qualified Feature.EmbedDisambiguationSpec+import qualified Feature.EmbedInnerJoinSpec+import qualified Feature.ExtraSearchPathSpec+import qualified Feature.HtmlRawOutputSpec+import qualified Feature.IgnorePrivOpenApiSpec+import qualified Feature.InsertSpec+import qualified Feature.JsonOperatorSpec+import qualified Feature.LegacyGucsSpec+import qualified Feature.MultipleSchemaSpec+import qualified Feature.NoJwtSpec+import qualified Feature.NonexistentSchemaSpec+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.UnicodeSpec+import qualified Feature.UpdateSpec+import qualified Feature.UpsertSpec+++main :: IO ()+main = do+ 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 queryPgVersion++ baseDbStructure <-+ loadDbStructure pool+ (configDbSchemas $ testCfg testDbConn)+ (configDbExtraSearchPath $ testCfg testDbConn)++ let+ -- For tests that run with the same refDbStructure+ 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+ let config = cfg testDbConn+ customDbStructure <-+ loadDbStructure pool+ (configDbSchemas config)+ (configDbExtraSearchPath config)+ appState <- AppState.initWithPool pool config+ AppState.putPgVersion appState actualPgVersion+ 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+ rootSpecApp = app testCfgRootSpec+ htmlRawOutputApp = app testCfgHtmlRawOutput+ responseHeadersApp = app testCfgResponseHeaders+ disallowRollbackApp = app testCfgDisallowRollback+ forceRollbackApp = app testCfgForceRollback+ testCfgLegacyGucsApp = app testCfgLegacyGucs++ extraSearchPathApp = appDbs testCfgExtraSearchPath+ unicodeApp = appDbs testUnicodeCfg+ nonexistentSchemaApp = appDbs testNonexistentSchemaCfg+ multipleSchemaApp = appDbs testMultipleSchemaCfg+ ignorePrivOpenApi = appDbs testIgnorePrivOpenApiCfg++ let analyze :: IO ()+ analyze = do+ analyzeTable testDbConn "items"+ analyzeTable testDbConn "child_entities"++ specs = uncurry describe <$> [+ ("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.EmbedInnerJoinSpec" , Feature.EmbedInnerJoinSpec.spec)+ , ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)+ , ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)+ , ("Feature.OpenApiSpec" , Feature.OpenApiSpec.spec actualPgVersion)+ , ("Feature.OptionsSpec" , Feature.OptionsSpec.spec actualPgVersion)+ , ("Feature.QuerySpec" , Feature.QuerySpec.spec actualPgVersion)+ , ("Feature.RawOutputTypesSpec" , Feature.RawOutputTypesSpec.spec)+ , ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion)+ , ("Feature.SingularSpec" , Feature.SingularSpec.spec)+ , ("Feature.UpdateSpec" , Feature.UpdateSpec.spec)+ , ("Feature.UpsertSpec" , Feature.UpsertSpec.spec actualPgVersion)+ ]++ hspec $ do+ mapM_ (parallel . before withApp) specs++ -- we analyze to get accurate results from EXPLAIN+ parallel $ beforeAll_ analyze . before withApp $+ describe "Feature.RangeSpec" Feature.RangeSpec.spec++ -- this test runs with a raw-output-media-types set to text/html+ parallel $ before htmlRawOutputApp $+ describe "Feature.HtmlRawOutputSpec" Feature.HtmlRawOutputSpec.spec++ -- this test runs with a different server flag+ parallel $ before maxRowsApp $+ describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec++ -- this test runs with a different schema+ 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+ parallel $ before proxyApp $+ describe "Feature.ProxySpec" Feature.ProxySpec.spec++ -- this test runs without a JWT secret+ parallel $ before noJwtApp $+ describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec++ -- this test runs with a binary JWT secret+ parallel $ before binaryJwtApp $+ describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec++ -- this test runs with a binary JWT secret and an audience claim+ parallel $ before audJwtApp $+ describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec++ -- this test runs with asymmetric JWK+ parallel $ before asymJwkApp $+ describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec++ -- this test runs with asymmetric JWKSet+ parallel $ before asymJwkSetApp $+ describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec++ -- this test runs with a nonexistent db-schema+ parallel $ before nonexistentSchemaApp $+ describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec++ -- this test runs with an extra search path+ parallel $ before extraSearchPathApp $+ describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec++ -- this test runs with a root spec function override+ parallel $ before rootSpecApp $+ describe "Feature.RootSpec" Feature.RootSpec.spec+ parallel $ before responseHeadersApp $+ describe "Feature.RpcPreRequestGucsSpec" Feature.RpcPreRequestGucsSpec.spec++ -- this test runs with multiple schemas+ parallel $ before multipleSchemaApp $+ describe "Feature.MultipleSchemaSpec" Feature.MultipleSchemaSpec.spec++ -- this test runs with db-uses-legacy-gucs = false+ parallel $ before testCfgLegacyGucsApp $+ describe "Feature.LegacyGucsSpec" Feature.LegacyGucsSpec.spec++ -- 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+ loadDbStructure pool schemas extraSearchPath =+ either (panic.show) id <$> P.use pool (HT.transaction HT.ReadCommitted HT.Read $ queryDbStructure (toList schemas) extraSearchPath True)
+ test/spec/QueryCost.hs view
@@ -0,0 +1,89 @@+module Main where++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.Query.QueryBuilder (requestToCallProcQuery)+import PostgREST.Request.Types++import PostgREST.DbStructure.Identifiers+import PostgREST.DbStructure.Proc++import SpecHelper (getEnvVarWithDefault)++import Test.Hspec++main :: IO ()+main = do+ 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 $+ requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")+ (KeyParams [ProcParam "id" "int" True False])+ (Just [str| {"id": 3} |]) False False [])+ liftIO $+ cost `shouldSatisfy` (< Just 40)++ it "should not exceed cost when calling setof composite proc with empty params" $ do+ cost <- exec pool $+ requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "getallprojects") (KeyParams []) Nothing False False [])+ liftIO $+ cost `shouldSatisfy` (< Just 30)++ it "should not exceed cost when calling scalar proc" $ do+ cost <- exec pool $+ requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them")+ (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])+ (Just [str| {"a": 3, "b": 4} |]) True False [])+ liftIO $+ cost `shouldSatisfy` (< Just 10)++ context "params=multiple-objects" $ do+ it "should not exceed cost when calling setof composite proc" $ do+ cost <- exec pool $+ requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "get_projects_below")+ (KeyParams [ProcParam "id" "int" True False])+ (Just [str| [{"id": 1}, {"id": 4}] |]) False True [])+ 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 $+ requestToCallProcQuery (FunctionCall (QualifiedIdentifier "test" "add_them")+ (KeyParams [ProcParam "a" "int" True False, ProcParam "b" "int" True False])+ (Just [str| [{"a": 3, "b": 4}, {"a": 1, "b": 2}, {"a": 8, "b": 7}] |]) True False [])+ liftIO $+ cost `shouldSatisfy` (< Just 10)+++exec :: P.Pool -> H.Snippet -> IO (Maybe Int64)+exec pool query =+ join . rightToMaybe <$>+ P.use pool (HT.transaction HT.ReadCommitted HT.Read $ HT.statement mempty $ explainCost query)++explainCost :: H.Snippet -> H.Statement () (Maybe Int64)+explainCost query =+ H.dynamicallyParameterized snippet decodeExplain False+ where+ snippet = "EXPLAIN (FORMAT JSON) " <> query+ decodeExplain :: HD.Result (Maybe Int64)+ decodeExplain =+ let row = HD.singleRow $ HD.column $ HD.nonNullable HD.bytea in+ (^? L.nth 0 . L.key "Plan" . L.key "Total Cost" . L._Integral) <$> row
+ test/spec/SpecHelper.hs view
@@ -0,0 +1,235 @@+module SpecHelper where++import qualified Data.ByteString.Base64 as B64 (decodeLenient)+import qualified Data.ByteString.Char8 as BS+import qualified Data.ByteString.Lazy as BL+import qualified Data.Map.Strict as M+import qualified Data.Set as S+import qualified System.IO.Error as E++import Data.Aeson (Value (..), decode, encode)+import Data.CaseInsensitive (CI (..), original)+import Data.List (lookup)+import Data.List.NonEmpty (fromList)+import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))+import System.Environment (getEnv)+import System.Process (readProcess)+import Text.Regex.TDFA ((=~))+++import Network.HTTP.Types+import Test.Hspec+import Test.Hspec.Wai+import Text.Heredoc++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"++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 ""+ liftIO $+ let respStatus = simpleStatus r in+ respStatus `shouldSatisfy`+ \s -> s == Status { statusCode = 200, statusMessage="OK" }+ liftIO $+ let respHeaders = simpleHeaders r in+ respHeaders `shouldSatisfy`+ \hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs+ let Just body = decode (simpleBody r)+ Just schema <- liftIO $ decode <$> BL.readFile "test/spec/fixtures/openapi.json"+ let args :: M.Map Text Value+ args = M.fromList+ [ ( "schema", schema )+ , ( "data", body ) ]+ hdrs = acceptHdrs "application/json"+ request methodPost "/rpc/validate_json_schema" hdrs (encode args)+ `shouldRespondWith` "true"+ { matchStatus = 200+ , matchHeaders = []+ }+++getEnvVarWithDefault :: Text -> Text -> IO Text+getEnvVarWithDefault var def = toS <$>+ getEnv (toS var) `E.catchIOError` const (return $ toS def)++_baseCfg :: AppConfig+_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+ , configDbUseLegacyGucs = True+ , 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 { 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, configJWKS = Nothing }++testUnicodeCfg :: Text -> AppConfig+testUnicodeCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["تست"] }++testMaxRowsCfg :: Text -> AppConfig+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) { configOpenApiServerProxyUri = Just "https://postgrest.com/openapi.json" }++testCfgBinaryJWT :: Text -> AppConfig+testCfgBinaryJWT testDbConn =+ let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in+ (testCfg testDbConn) {+ configJwtSecret = secret+ , configJWKS = parseSecret <$> secret+ }++testCfgAudienceJWT :: Text -> AppConfig+testCfgAudienceJWT testDbConn =+ let secret = Just . B64.decodeLenient $ "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" in+ (testCfg testDbConn) {+ configJwtSecret = secret+ , configJwtAudience = Just "youraudience"+ , configJWKS = parseSecret <$> secret+ }++testCfgAsymJWK :: Text -> AppConfig+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 =+ 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) { configDbSchemas = fromList ["nonexistent"] }++testCfgExtraSearchPath :: Text -> AppConfig+testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configDbExtraSearchPath = ["public", "extensions"] }++testCfgRootSpec :: Text -> AppConfig+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) { configDbPreRequest = Just $ QualifiedIdentifier mempty "custom_headers" }++testMultipleSchemaCfg :: Text -> AppConfig+testMultipleSchemaCfg testDbConn = (testCfg testDbConn) { configDbSchemas = fromList ["v1", "v2"] }++testCfgLegacyGucs :: Text -> AppConfig+testCfgLegacyGucs testDbConn = (testCfg testDbConn) { configDbUseLegacyGucs = False }++analyzeTable :: Text -> Text -> IO ()+analyzeTable dbConn tableName =+ void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-a", "-c", toS $ "ANALYZE test.\"" <> tableName <> "\""] []++rangeHdrs :: ByteRange -> [Header]+rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]++rangeHdrsWithCount :: ByteRange -> [Header]+rangeHdrsWithCount r = ("Prefer", "count=exact") : rangeHdrs r++acceptHdrs :: BS.ByteString -> [Header]+acceptHdrs mime = [(hAccept, mime)]++rangeUnit :: Header+rangeUnit = ("Range-Unit" :: CI BS.ByteString, "items")++matchHeader :: CI BS.ByteString -> BS.ByteString -> [Header] -> Bool+matchHeader name valRegex headers =+ maybe False (=~ valRegex) $ lookup name headers++noBlankHeader :: [Header] -> Bool+noBlankHeader = notElem mempty++noProfileHeader :: [Header] -> Bool+noProfileHeader headers = isNothing $ find ((== "Content-Profile") . fst) headers++authHeader :: BS.ByteString -> BS.ByteString -> Header+authHeader typ creds =+ (hAuthorization, typ <> " " <> creds)++authHeaderJWT :: BS.ByteString -> Header+authHeaderJWT = authHeader "Bearer"++-- | 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+isErrorFormat s =+ "message" `S.member` keys &&+ S.null (S.difference keys validKeys)+ where+ obj = decode s :: Maybe (M.Map Text Value)+ keys = maybe S.empty M.keysSet obj+ validKeys = S.fromList ["message", "details", "hint", "code"]
+ test/spec/TestTypes.hs view
@@ -0,0 +1,37 @@+module TestTypes (+ IncPK(..)+, CompoundPK(..)+) where++import Data.Aeson ((.:))+import qualified Data.Aeson as JSON++import Protolude++data IncPK = IncPK {+ incId :: Int+, incNullableStr :: Maybe Text+, incStr :: Text+, incInsert :: Text+} deriving (Eq, Show)++instance JSON.FromJSON IncPK where+ parseJSON (JSON.Object r) = IncPK <$>+ r .: "id" <*>+ r .: "nullable_string" <*>+ r .: "non_nullable_string" <*>+ r .: "inserted_at"+ parseJSON _ = mzero++data CompoundPK = CompoundPK {+ compoundK1 :: Int+, compoundK2 :: Text+, compoundExtra :: Maybe Int+} deriving (Eq, Show)++instance JSON.FromJSON CompoundPK where+ parseJSON (JSON.Object r) = CompoundPK <$>+ r .: "k1" <*>+ r .: "k2" <*>+ r .: "extra"+ parseJSON _ = mzero