Compare commits

...
95 Commits
Author SHA1 Message Date
Steve ChávezandGitHub 713b214c9a Bump to version v6.0.2
* Add missing CHANGELOG entry for #1368
2019-08-22 11:12:01 -05:00
steve-chavez 681388631b Add raw-media-types to Dockerfile 2019-08-21 10:34:28 -05:00
steve-chavez ae9e27a0c7 Fix #1348, go back to converting plus "+" to space
Not doing this conflicts with some http clients and proxies.

Use the alternative url-safe character '!' instead for
disambiguating resource embedding.
2019-08-21 10:34:28 -05:00
Steve ChávezandGitHub e83144ce7f Add documentation links 2019-08-16 12:41:24 -05:00
Joe NelsonandGitHub 1c54c7130a Another funding link 2019-08-14 14:05:12 -05:00
Steve ChávezandGitHub 1a8d5fed8a Update ISSUE_TEMPLATE.md 2019-08-13 08:15:11 -05:00
Nathan BouscalandSteve Chávez 57ebf43e85 Allow select in OpenAPI for POST requests (#1375)
If you use `return=representation`, `select` is useful in `POST` requests.
It already works correctly, but is missing from the OpenAPI spec.
2019-08-09 14:10:20 -05:00
steve-chavez b87734343e Update BACKERS.md 2019-08-08 18:50:34 -05:00
Dan AmorosoandSteve Chávez c80c9ef726 Reword error message for BinaryFieldError (#1372) 2019-08-08 14:20:06 -05:00
Dan AmorosoandSteve Chávez d5758523f3 Change raw-media-types to accept a string of comma separated MIME types (#1370) 2019-08-06 13:23:52 -05:00
Amédée d'AbovilleandSteve Chávez ee40e7e0d7 Fix long column descriptions being truncated at 63 characters in pg12 (#1368)
* Lengthen child_entities name comment to make tests check it does not get truncated at 63 chars.

* Don't cast column description to sql_identifier type (maxlen 63 chars) when introspecting schema in DbStructure.hs
2019-08-02 16:13:01 +00:00
steve-chavez 53b606e1c1 Remove raw-media-types config from Docker
Passing a list through an env var is not working
in configurator-pg
2019-07-31 12:06:19 -05:00
steve-chavez 47c0141c49 Bump to v6.0.1 2019-07-30 22:34:28 +00:00
steve-chavez 5b8a17e366 Correct appveyor conditional deploy 2019-07-30 22:34:28 +00:00
steve-chavez 64a86b899f Remove centos6 release
Centos 6 is missing ghc 8.4.4 support, see
https://github.com/commercialhaskell/stack/issues/4161

The static binary release for linux can be used instead.
2019-07-30 22:34:28 +00:00
steve-chavez 312e295a47 Add raw-media-types config option to Docker 2019-07-30 22:34:28 +00:00
steve-chavez e7544687d1 Fix/organize latest entries in CHANGELOG 2019-07-30 22:34:28 +00:00
Diogo BiazusandSteve Chávez 291de5bc1c LTS 13.29 (#1364)
* Update resolver to lts-13.29 and add lock file to repository

* Upgrade stack version

* Save cache after building dependencies only to have faster feedback loop when tests fail

* Move private functions from QueryBuilder to a separate Private module

* Move more functions over to private trying to make compilation consume less memory

* Split private in 4 modules

* Remove unused LambdaCase pragma

* Add profile to memory-tests.sh so it can find postgrest executable

* Move save dependencies before building and running tests for faster feedback loop
2019-07-29 17:14:06 +00:00
Steve ChávezandGitHub c37a9f5ec3 Fix windows build (#1354)
* Fix windows build by pinning network and text-printer versions
* Enable travis and appveyor builds on PRs
2019-07-23 22:27:35 +00:00
Dan AmorosoandSteve Chávez f5cef205f1 Introduced raw-media-types config option (#1349)
* extracted rawOutputTypes to config variable raw-output-media-types

* removed CTTextHtml from Types.hs
2019-07-22 23:37:53 +00:00
RobertandSteve Chávez afb7266f17 Update jose to 0.8.1 to fix Windows build (and adapt error message test) (#1351) 2019-07-15 15:14:22 +00:00
Eduardo JorgeandSteve Chávez e639c77aa2 Add websearch_to_tsquery support (#1339) 2019-07-08 13:25:58 -05:00
Robert VollmertandSteve Chávez ea97055449 appveyor: cache stack appdata directory
This is where stack installs GHC versions.
2019-07-07 11:40:41 -05:00
RobertandSteve Chávez 64dc6ab9ac Fix json test versioning for postgresql 10.9 bugfix (#1340)
This removes the test for buggy behaviour, it seems not useful
anymore.
2019-07-05 12:54:44 -05:00
Eduardo JorgeandSteve Chávez 25dedd1098 Add tests for overlap operator in array types (#1337)
* Add basic tests for some array operators
  - eq
  - neq
  - tl
  - gt
  - lte
  - gte
  - cs
  - cd
  - ov
2019-07-04 11:42:19 -05:00
RobertandSteve Chávez 617bf7b6a3 Update jose to 0.8 (and adapt error message test) (#1324)
The message changes between jose versions 0.7 and 0.8.
2019-07-04 10:56:45 -05:00
Robert VollmertandSteve Chávez e3a53de8a6 circleci: bump postgres to 11.4 to test json bugfix 2019-06-28 11:45:27 -05:00
Robert VollmertandSteve Chávez 4cc91fd5b1 Update json rpc argument tests for bugfix in PostgreSQL 11.4
See https://www.postgresql.org/docs/release/11.4/.
2019-06-28 11:45:27 -05:00
RobertandSteve Chávez 296a12e394 Work around macos case sensitivity issue (#1332)
See also https://github.com/haskell/cabal/issues/4739.

/Users/travis/build/PostgREST/postgrest/<built-in>:15:10: error:
     error: non-portable path to file '".stack-work/dist/x86_64-osx/Cabal-2.2.0.1/build/PostgREST/autogen/cabal_macros.h"'; specified path differs in case from file name on disk [-Werror,-Wnonportable-include-path]
         ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
         ".stack-work/dist/x86_64-osx/Cabal-2.2.0.1/build/PostgREST/autogen/cabal_macros.h"
2019-06-27 10:33:38 -05:00
steve-chavez da7aa1d72f Add root-spec to official Dockerfile
* Delete unused config file
2019-06-26 18:14:36 -05:00
steve-chavez 367ad8ea43 Update pgdg url for centos dockerfiles
* Also update ubuntu i386 image repo
2019-06-26 18:14:36 -05:00
steve-chavez 2c3bc2d75e AppVeyor workaround for TMP issue, also add timeout
See https://github.com/quasicomputational/hpack/commit/ce868d17e53289eab3703261a5a3dc8d48364533,
and https://github.com/commercialhaskell/stack/issues/3944
2019-06-26 18:14:36 -05:00
steve-chavez 3cce6ca02b Change docker image to postgres 11.3
PostgreSQL 11.4 patched an error for wich we had a test.
See https://www.postgresql.org/docs/release/11.4/ json_to_record.
2019-06-26 18:14:36 -05:00
Steve ChávezandGitHub dd86fe372c Bump to v6.0.0 (#1329)
* Also update BACKERS.md
2019-06-21 13:11:48 -05:00
Steve ChávezandGitHub ea7d747107 Accept text/plain and text/html for raw output (#1330) 2019-06-21 11:51:54 -05:00
steve-chavez 40ae7ce2b1 Add CYBERTEC as Sponsor 2019-06-19 23:48:02 -05:00
RobertandSteve Chávez 9bcf39f41f Upgrade hasql to 1.4 (#1325) 2019-06-17 16:54:45 -05:00
steve-chavez 51d3a7864a Rename logo to invalidate cache 2019-06-16 15:20:22 -05:00
steve-chavez e2dc432385 Correct url and adjust logos sizes 2019-06-16 15:03:52 -05:00
steve-chavez b101d5f0f9 Add 2ndQuadrant as sponsor 2019-06-15 16:09:40 -05:00
RobertandSteve Chávez 23ca27d27e Update stackage GHC to 8.4.4 (#1322)
* Update stack.yaml to lts-12.26 (GHC 8.4.4) with simplified nix config

(On GHC 8.6.5, profiled build was exiting with:

--  While building package postgrest-5.2.0 using:
      /home/circleci/.stack/setup-exe-cache/x86_64-linux/Cabal-simple_mPHDZzAJ_2.4.0.1_ghc-8.6.5 --builddir=.stack-work/dist/x86_64-linux/Cabal-2.4.0.1 build lib:postgrest exe:postgrest --ghc-options " -ddump-hi -ddump-to-file -fdiagnostics-color=always"
    Process exited with code: ExitFailure (-9) (THIS MAY INDICATE OUT OF MEMORY)
Exited with code 1)

* stack.yaml: Add postgresql to nix packages
2019-06-11 13:20:04 -05:00
Steve ChávezandGitHub 1df749a7a8 Add option for overriding root spec (#1317)
* Only for pg >= 9.6

* Disallow specifying schema on root-spec

* Increase memory test upper bound
2019-06-10 13:45:50 -05:00
RobertandSteve Chávez ea82b9f820 Update cabal bounds (#1319)
* Relax some cabal upper bounds

Primarily based on building against GHC 8.6.5 with stackage lts-13.23.

* Add swagger2 upper bound

With swagger2 2.4, we get a number of errors like the following:

src/PostgREST/OpenAPI.hs:58:11: error:
    • Couldn't match type ‘Maybe
                             (SwaggerType 'Data.Swagger.Internal.SwaggerKindSchema)’
                     with ‘SwaggerType 'Data.Swagger.Internal.SwaggerKindSchema’
        arising from a functional dependency between:
          constraint ‘HasType
                        Schema (SwaggerType 'Data.Swagger.Internal.SwaggerKindSchema)’
            arising from a use of ‘type_’
          instance ‘HasType
                      Schema
                      (Maybe (SwaggerType 'Data.Swagger.Internal.SwaggerKindSchema))’
            at <no location info>
    • In the first argument of ‘(.~)’, namely ‘type_’
      In the second argument of ‘(&)’, namely ‘type_ .~ SwaggerObject’
      In the first argument of ‘(&)’, namely
        ‘(mempty :: Schema) & description .~ tableDescription t
           & type_ .~ SwaggerObject’
   |
58 |         & type_ .~ SwaggerObject
   |           ^^^^^

* Require base 4.9 / GHC 8.0.1

The DuplicateRecordFields extension, which is used here,
was introduced with GHC 8.0.

* Set cabal lower bounds

These are based on building against stackage lts-7.24 / GHC 8.0.1.

* Set cabal upper bounds, and truncate lower bounds

These are primarily based on the output of `cabal bounds`, with a good
build against GHC 8.6.5. Some test dependencies were missing; these
were filled out based on a build against stackage nightly.

Also I truncated lower bounds to the third level, since that is what
`cabal bounds` seems to suggest anyway.

* Format postgrest.cabal using stylish-cabal

* Sort build-depends list
2019-06-08 21:42:28 -05:00
RobertandSteve Chávez 7356327e5b Tweaks to circle-ci profiling build (#1321)
* circleci: Update stack versions from 1.1.2 to 1.9.3.

* circleci: Don't try to install unused packdeps and cabal-install.

* memory-tests: Rely on `stack build` to have been called already.

This happens in the previous circleci step.
2019-06-08 21:12:55 -05:00
RobertandSteve Chávez 33532cfbb6 Fix hlint warnings for hlint v2.1.20 (#1320)
* Apply some hlint suggestions.

* Simplify config parser (to avoid hlint error)

* Reorganize for clarity (and fix hlint error)

* Remove redundant language extension

* Reformat slice more conventionally to avoid hlint bug

* Refactor for clarity (and to avoid hlint error)

* Simplify (and avoid hlint error)

* Fix hlint complaint
2019-06-08 20:52:48 -05:00
Dan AmorosoandSteve Chávez 78e5677fbe Add server-unix-socket config option (#1303)
Add ncat workaround to test socket connection on CircleCI
2019-06-05 12:57:47 -05:00
RobertandSteve Chávez e292fb5eb9 Replace configurator-ng by configurator-pg (#1312)
The change should not affect parse results for valid
configuration files. Error messages should be somewhat
improved.
2019-06-04 15:49:37 -05:00
Robert VollmertandSteve Chávez 8fe9e94e24 io-tests: more consistent output
This brings the messages for passing/non-passing of individual
tests closer together, making it easier to compare successful
and failing test runs.
2019-06-03 12:07:05 -05:00
Robert VollmertandSteve Chávez 30d5a81156 Test json rpc arguments
In particular, check buggy behavior around embedded quoted JSON
and Postgres versions, compare

https://www.postgresql.org/message-id/D6921B37-BD8E-4664-8D5F-DB3525765DCD%40vllmrt.net

This adds version-bounded tests for the handling of quoted JSON,
and a pending test that documents the assumption that Postgres >=10
intends to parse quoted JSON as a string (similar to how jsonb works
now).
2019-06-02 01:26:52 -05:00
Robert VollmertandSteve Chávez 1f69822fa3 Fail tests when sql fixtures fail to load 2019-06-02 01:26:52 -05:00
Robert VollmertandSteve Chávez 65fc672417 Change tests to use postgres-json-schema instead of hjsonschema.
Embeds https://github.com/gavinwahl/postgres-json-schema.
2019-06-01 23:26:33 -05:00
Robert VollmertandSteve Chávez e34669b137 More helpful usage messages for test/create_test_db 2019-05-31 18:27:11 -05:00
Robert VollmertandSteve Chávez ea2f89e234 Fix non-exhaustive pattern match warning by duplicating a line. 2019-05-29 11:26:58 -05:00
Robert VollmertandSteve Chávez 97ea99402d Add lower bound for http-types
parseQueryReplacePlus was introduced with version 0.12.2.
2019-05-29 10:19:50 -05:00
Xavier FranciscoandSteve Chávez 35cef22254 Add makefile with common commands (#1300) 2019-05-24 17:40:39 -05:00
Xavier FranciscoandSteve Chávez 28b3d6cafd Update stylish haskell config; apply all; add CI config (#1299)
* Update config default; Copy non-defaults

* Update .stylish-haskell config version to match pgrst

* Apply stylish haskell to all files

* CircleCI config

* Remove redundant import.

What is used from Network.HTTP.Types.Headers is also exported by Network.HTTP.Types.

* Grouped imports

* Show un-styled files on CircleCI failure

* Fix styling imports

* Apply adhoc standard correctly
2019-05-23 10:44:34 -05:00
Steve ChávezandGitHub 16af470a99 Add shell.nix (#1298) 2019-05-22 10:26:36 -05:00
Xavier FranciscoandSteve Chávez 1cf54e6575 Small refactor around error responses and more tests (#1282)
* Fix #880, Clean and consolidate error responses
* Fix #1285, Abort on fatal errors
* Add / Detail tests
2019-05-22 09:30:33 -05:00
steve-chavez e2d917f7b9 Update BACKERS.md 2019-05-21 10:39:54 -05:00
Russell DaviesandSteve Chávez 8d8374cef0 Update Dockerfile base image to debian:stretch-slim 2019-05-19 15:22:19 -05:00
steve-chavez 3078a11144 Remove circleci extra cabal checks
Currently these extra checks are giving an out of memory error on CircleCI:
Hint: Max memory usage of this container is 4231065600
2019-05-19 13:18:03 -05:00
steve-chavez 3c7738a8c7 Deprecate '.' for disambiguating resource embedding
'+' should be used instead.
2019-05-19 13:18:03 -05:00
steve-chavez 181b608c04 Change server-host default of 127.0.0.1 to !4
This way the server works in LAN settings by default.
2019-05-19 13:18:03 -05:00
steve-chavez 37de12d376 Remove support for schema reloading with SIGHUP
SIGUSR1 should be used instead
2019-05-19 13:18:03 -05:00
David ErnstandSteve Chávez bcc317db81 Fix reference to deprecated SIGUP call (#1284)
This updates the instructions about sending a `SIGUP` call (deprecated) to `SIGUSR1`, and links to relevant docs: http://postgrest.org/en/v5.2/admin.html#schema-reloading

Per the docs:

> As of PostgREST v5.1 reloading with SIGHUP is deprecated, it’s still supported but will be removed in v6.0. SIGUSR1 should be used instead.

See [5.1.0 CHANGELOG](https://github.com/PostgREST/postgrest/blob/d32f373e1ee95ba68141a3f3a48ab8c94ea2453a/CHANGELOG.md#deprecated) or https://github.com/PostgREST/postgrest/issues/724
2019-04-26 19:46:20 -05:00
Xavier FranciscoandSteve Chávez d32f373e1e Add config option for pool timeout (#1280) 2019-04-25 12:23:20 -05:00
steve-chavez 2044f77d49 Fix #1273, don't ignore RPC arguments by default
* Add different error message for pg 9.4
2019-04-19 16:03:42 -05:00
steve-chavez 033ee5a06e Refactor: remove pjKeys from RawJSON 2019-04-19 16:03:42 -05:00
Xavier FranciscoandSteve Chávez 553531711b Fix wrong status 404 when PATCH request didn't change anything (#1272) 2019-04-19 13:50:19 -05:00
Xavier FranciscoandSteve Chávez 87f7e86aa7 Set fts tests language instead of depending on system default (#1276) 2019-04-13 19:50:45 -05:00
Xavier FranciscoandSteve Chávez 74e38a1d80 Fix invalid SQL generated for bulk upsert with empty array (#1270) 2019-04-12 23:26:36 -05:00
Xavier FranciscoandSteve Chávez 63826e9509 Fix tests with postgres instances with a different locale (#1269)
* Fix LC_MONETARY

* Add other local parameters

* Change syntax to support 9.4
2019-04-08 10:34:59 -05:00
Xavier FranciscoandSteve Chávez 32725f2f35 Fix tests expected responses due to changes in pg11 (#1268) 2019-04-07 19:03:02 -05:00
steve-chavez b53e8932e5 Add CircleCI test suite for pg11 2019-04-07 18:39:12 -05:00
steve-chavez bdfb11001e Fix #1264, allow bulk RPC call 2019-04-05 19:10:00 -05:00
steve-chavez 40b004c9f7 Comment OpenAPI tests that time out
These should be run locally for now.
2019-04-05 19:10:00 -05:00
Tobias FlühmannandSteve Chávez fe56029f61 The process should not run as root in docker(#1267) 2019-04-04 10:38:11 -05:00
fpuschandSteve Chávez cefbe8f07f Add numeric and character types to OpenAPI spec (#1259)
* Add numeric and character types

* Remove decimal type mapping

It is treated as numeric by PostgreSQL so the mapping was redundant.
2019-03-29 11:09:42 -05:00
Vitor BaptistaandSteve Chávez 9387e70b66 Fixes #1239, support resource embedding on materialized views 2019-03-14 16:59:55 -05:00
steve-chavez 1f513f24a5 Fix #1242, embed a view having a select in a where 2019-03-09 16:03:26 -05:00
steve-chavez 7c376d6e84 Delete unused files 2019-03-09 16:03:26 -05:00
steve-chavez 39adbefb9d Add TimescaleDB sponsorship
* Add Sponsors to BACKERS.md
2019-03-01 13:56:24 -05:00
steve-chavez c9b2830e52 Fix #690, add columns query arg for RPC/POST/PATCH
* Refactor normalizing json CTE
* Refactor CTE to use CASE instead of UNION
2019-02-26 11:38:21 -05:00
steve-chavez 3946dfbc64 Remove pjIsEmpty and rm pjType from ActionInvoke
* Remove PayloadJSON from Insert/Update
2019-02-26 11:38:21 -05:00
steve-chavez 50509b52b8 Fix POST/PATCH error on a [{}] json 2019-02-26 11:38:21 -05:00
steve-chavez 16059ad470 Remove pjType from ActionCreate
* Remove isSingle from ActionCreate
* Remove nRows from ActionCreate
2019-02-26 11:38:21 -05:00
steve-chavez 00a0d8b9b7 Remove pjType from QueryBuilder Insert/Update
* Refactor Insert onConflict
2019-02-26 11:38:21 -05:00
steve-chavez 36e9d779fc Update BACKERS.md, resize logos 2019-02-19 17:04:09 -05:00
steve-chavez 3fc8a105ec Add Retool sponsorship 2019-02-19 12:50:30 -05:00
steve-chavez 673aa25082 Fix #1221, embedding when having a self join 2019-02-04 10:22:02 -05:00
steve-chavez 1037313e77 Add comments to modules 2019-02-04 10:22:02 -05:00
steve-chavez 86e460c5c4 Add implicitJoins to ReadQuery type 2019-02-04 10:22:02 -05:00
steve-chavez fb5adce5ce Reformat and reorder Types.hs 2019-02-04 10:22:02 -05:00
Steve ChávezandGitHub 04ab0ea753 Fix #1223, incorrect OpenAPI externalDocs url (#1232) 2019-01-25 12:04:36 -05:00
Vassilis PapakonstantinouandSteve Chávez 3900baa6ce Add db-extra-search-path config param to dockerized postgREST (#1229)
* add db-extra-search-path config param

* add default value for PGRST_DB_EXTRA_SEARCH_PATH env variable
2019-01-16 10:55:16 -05:00
88 changed files with 4098 additions and 2180 deletions
+75 -42
View File
@@ -66,18 +66,33 @@ jobs:
- restore_cache: - restore_cache:
keys: keys:
- v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }} - v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
- run:
name: install ncat
command: |
# utility needed to test socket connection with curl < 7.40
sudo apt-get install nmap
- run: - run:
name: install stack & dependencies name: install stack & dependencies
command: | command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.1.2/stack-1.1.2-linux-x86_64.tar.gz | tar zx -C /tmp curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.1.2-linux-x86_64/stack /usr/bin sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
sudo apt-get update sudo apt-get update
sudo apt-get install -y libgmp-dev sudo apt-get install -y libgmp-dev
sudo apt-get install -y --only-upgrade binutils sudo apt-get install -y --only-upgrade binutils
sudo apt-get install -y postgresql-client sudo apt-get install -y postgresql-client
stack setup stack setup
rm -rf $(stack path --dist-dir) $(stack path --local-install-root) rm -rf $(stack path --dist-dir) $(stack path --local-install-root)
stack install hlint packdeps cabal-install stack install hlint stylish-haskell
- run:
name: build src and tests dependencies
command: |
stack build --fast -j1 --only-dependencies
stack build --fast --test --no-run-tests --only-dependencies
- save_cache:
paths:
- "~/.stack"
- ".stack-work"
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
- run: - run:
name: build src and tests name: build src and tests
command: | command: |
@@ -92,19 +107,8 @@ jobs:
name: run linter name: run linter
command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$@" command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$@"
- run: - run:
name: extra checks name: run styler
command: | command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- stylish-haskell -i && git diff-index --exit-code HEAD --
stack exec -- cabal update
stack exec --no-ghc-package-path -- cabal install --only-d --dry-run
stack exec -- packdeps *.cabal || true
stack exec -- cabal check
stack haddock --no-haddock-deps
stack sdist
- save_cache:
paths:
- "~/.stack"
- ".stack-work"
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
build-test-9.6: build-test-9.6:
docker: docker:
@@ -123,8 +127,8 @@ jobs:
- run: - run:
name: install stack & dependencies name: install stack & dependencies
command: | command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.1.2/stack-1.1.2-linux-x86_64.tar.gz | tar zx -C /tmp curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.1.2-linux-x86_64/stack /usr/bin sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
sudo apt-get update sudo apt-get update
sudo apt-get install -y libgmp-dev sudo apt-get install -y libgmp-dev
sudo apt-get install -y postgresql-client sudo apt-get install -y postgresql-client
@@ -155,8 +159,40 @@ jobs:
- run: - run:
name: install stack & dependencies name: install stack & dependencies
command: | command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.1.2/stack-1.1.2-linux-x86_64.tar.gz | tar zx -C /tmp curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.1.2-linux-x86_64/stack /usr/bin sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
sudo apt-get update
sudo apt-get install -y libgmp-dev
sudo apt-get install -y postgresql-client
stack setup
- run:
name: build src and tests
command: |
stack build --fast -j1
stack build --fast --test --no-run-tests
- run:
name: run tests
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
build-test-11:
docker:
- image: circleci/buildpack-deps:trusty
environment:
- PGHOST=localhost
- image: circleci/postgres:11.4
environment:
- POSTGRES_USER=circleci
- POSTGRES_DB=circleci
steps:
- checkout
- restore_cache:
keys:
- v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
- run:
name: install stack & dependencies
command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
sudo apt-get update sudo apt-get update
sudo apt-get install -y libgmp-dev sudo apt-get install -y libgmp-dev
sudo apt-get install -y postgresql-client sudo apt-get install -y postgresql-client
@@ -188,12 +224,21 @@ jobs:
- run: - run:
name: install stack & dependencies name: install stack & dependencies
command: | command: |
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.1.2/stack-1.1.2-linux-x86_64.tar.gz | tar zx -C /tmp curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
sudo mv /tmp/stack-1.1.2-linux-x86_64/stack /usr/bin sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
sudo apt-get update sudo apt-get update
sudo apt-get install -y libgmp-dev sudo apt-get install -y libgmp-dev
sudo apt-get install -y postgresql-client sudo apt-get install -y postgresql-client
stack setup stack setup
- run:
name: build dependencies with profiling enabled
command: |
stack build --profile -j1 --only-dependencies
- save_cache:
paths:
- "~/.stack"
- ".stack-work"
key: v1-stack-prof-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
- run: - run:
name: build with profiling enabled name: build with profiling enabled
command: | command: |
@@ -206,16 +251,9 @@ jobs:
psql "postgres:///postgrest_test" -f test/fixtures/roles.sql psql "postgres:///postgrest_test" -f test/fixtures/roles.sql
psql "postgres:///postgrest_test" -f test/fixtures/schema.sql psql "postgres:///postgrest_test" -f test/fixtures/schema.sql
psql "postgres:///postgrest_test" -f test/fixtures/jwt.sql psql "postgres:///postgrest_test" -f test/fixtures/jwt.sql
psql "postgres:///postgrest_test" -f test/fixtures/jsonschema.sql
psql "postgres:///postgrest_test" -f test/fixtures/privileges.sql psql "postgres:///postgrest_test" -f test/fixtures/privileges.sql
test/memory-tests.sh test/memory-tests.sh
- save_cache:
paths:
- "~/.stack"
- ".stack-work"
key: v1-stack-prof-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
centos6:
<<: *build-distro-bin
centos7: centos7:
<<: *build-distro-bin <<: *build-distro-bin
@@ -268,26 +306,20 @@ workflows:
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
- build-test-11:
filters:
tags:
only: /v[0-9]+(\.[0-9]+)*/
- build-prof-test: - build-prof-test:
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
- centos6:
requires:
- build-test-9.4
- build-test-9.6
- build-test-10
- build-prof-test
filters:
tags:
only: /v[0-9]+(\.[0-9]+)*/
branches:
ignore: /.*/
- centos7: - centos7:
requires: requires:
- build-test-9.4 - build-test-9.4
- build-test-9.6 - build-test-9.6
- build-test-10 - build-test-10
- build-test-11
- build-prof-test - build-prof-test
filters: filters:
tags: tags:
@@ -299,6 +331,7 @@ workflows:
- build-test-9.4 - build-test-9.4
- build-test-9.6 - build-test-9.6
- build-test-10 - build-test-10
- build-test-11
- build-prof-test - build-prof-test
filters: filters:
tags: tags:
@@ -310,6 +343,7 @@ workflows:
- build-test-9.4 - build-test-9.4
- build-test-9.6 - build-test-9.6
- build-test-10 - build-test-10
- build-test-11
- build-prof-test - build-prof-test
filters: filters:
tags: tags:
@@ -318,7 +352,6 @@ workflows:
ignore: /.*/ ignore: /.*/
- release: - release:
requires: requires:
- centos6
- centos7 - centos7
- ubuntu - ubuntu
- ubuntui386 - ubuntui386
+1 -1
View File
@@ -32,7 +32,7 @@ your contributions.
then [find your logs](http://blog.endpoint.com/2014/11/dear-postgresql-where-are-my-logs.html). then [find your logs](http://blog.endpoint.com/2014/11/dear-postgresql-where-are-my-logs.html).
* If your database schema has changed while the PostgREST server is running, * If your database schema has changed while the PostgREST server is running,
send the server a `SIGHUP` signal or restart it to ensure the schema cache [send the server a `SIGUSR1` signal](http://postgrest.org/en/v5.2/admin.html#schema-reloading) or restart it to ensure the schema cache
is not stale. This sometimes fixes apparent bugs. is not stale. This sometimes fixes apparent bugs.
## Code ## Code
+3
View File
@@ -0,0 +1,3 @@
# These are supported funding model platforms
patreon: postgrest
+4
View File
@@ -1,3 +1,7 @@
<!--
Before reporting a bug:
If your database schema has changed while the PostgREST server is running, send the server a SIGUSR1 signal or restart it(http://postgrest.org/en/v5.2/admin.html#schema-reloading) to ensure the schema cache is not stale. This sometimes fixes apparent bugs.
-->
### Environment ### Environment
* PostgreSQL version: (if using docker, specify the image) * PostgreSQL version: (if using docker, specify the image)
+40 -5
View File
@@ -39,9 +39,9 @@ steps:
# - none: Do not perform any alignment. # - none: Do not perform any alignment.
# #
# Default: global. # Default: global.
align: global align: group
# Folowing options affect only import list alignment. # The following options affect only import list alignment.
# #
# List align has following options: # List align has following options:
# #
@@ -64,6 +64,25 @@ steps:
# Default: after_alias # Default: after_alias
list_align: after_alias list_align: after_alias
# Right-pad the module names to align imports in a group:
#
# - true: a little more readable
#
# > import qualified Data.List as List (concat, foldl, foldr,
# > init, last, length)
# > import qualified Data.List.Extra as List (concat, foldl, foldr,
# > init, last, length)
#
# - false: diff-safe
#
# > import qualified Data.List as List (concat, foldl, foldr, init,
# > last, length)
# > import qualified Data.List.Extra as List (concat, foldl, foldr,
# > init, last, length)
#
# Default: true
pad_module_names: true
# Long list align style takes effect when import is too long. This is # Long list align style takes effect when import is too long. This is
# determined by 'columns' setting. # determined by 'columns' setting.
# #
@@ -75,7 +94,7 @@ steps:
# short enough to fit to single line. Otherwise it'll be multiline. # short enough to fit to single line. Otherwise it'll be multiline.
# #
# - multiline: One line per import list entry. # - multiline: One line per import list entry.
# Type with contructor list acts like single import. # Type with constructor list acts like single import.
# #
# > import qualified Data.Map as M # > import qualified Data.Map as M
# > ( empty # > ( empty
@@ -109,7 +128,7 @@ steps:
# Useful for 'file' and 'group' align settings. # Useful for 'file' and 'group' align settings.
list_padding: 4 list_padding: 4
# Separate lists option affects formating of import list for type # Separate lists option affects formatting of import list for type
# or class. The only difference is single space between type and list # or class. The only difference is single space between type and list
# of constructors, selectors and class functions. # of constructors, selectors and class functions.
# #
@@ -126,6 +145,22 @@ steps:
# Default: true # Default: true
separate_lists: true separate_lists: true
# Space surround option affects formatting of import lists on a single
# line. The only difference is single space after the initial
# parenthesis and a single space before the terminal parenthesis.
#
# - true: There is single space associated with the enclosing
# parenthesis.
#
# > import Data.Foo ( foo )
#
# - false: There is no space associated with the enclosing parenthesis
#
# > import Data.Foo (foo)
#
# Default: false
space_surround: false
# Language pragmas # Language pragmas
- language_pragmas: - language_pragmas:
# We can generate different styles of language pragma lists. # We can generate different styles of language pragma lists.
@@ -142,7 +177,7 @@ steps:
# Align affects alignment of closing pragma brackets. # Align affects alignment of closing pragma brackets.
# #
# - true: Brackets are aligned in same collumn. # - true: Brackets are aligned in same column.
# #
# - false: Brackets are not aligned together. There is only one space # - false: Brackets are not aligned together. There is only one space
# between actual import and closing bracket. # between actual import and closing bracket.
+7 -1
View File
@@ -1,3 +1,5 @@
## Travis is only used for building an OSX binary ,
## no tests are run here.
language: generic language: generic
sudo: false sudo: false
@@ -40,11 +42,15 @@ install:
fi fi
script: script:
## Building the whole project can take longer than 50 minutes. Since Travis has a global timeout of 50 minutes
## we compile for 30 minutes tops(`gtimeout 1800`) and quit compiling with no error.
## Since we CACHE the compile results we can continue compiling from where we left off
## on the next commit.
- gtimeout 1800 stack build --no-terminal --only-snapshot --install-ghc || true - gtimeout 1800 stack build --no-terminal --only-snapshot --install-ghc || true
- | - |
if test ! "$TRAVIS_TAG" if test ! "$TRAVIS_TAG"
then then
echo 'No tag pushed. Skipping build.' echo 'No tag pushed. Skip building binary.'
else else
stack build --no-terminal --copy-bins --local-bin-path . stack build --no-terminal --copy-bins --local-bin-path .
fi fi
+47 -2
View File
@@ -2,13 +2,58 @@
PostgREST ongoing development is only possible thanks to our Sponsors and Backers, listed below. If you'd like to join them, you can do so by supporting the PostgREST organization on [Patreon](https://www.patreon.com/postgrest). PostgREST ongoing development is only possible thanks to our Sponsors and Backers, listed below. If you'd like to join them, you can do so by supporting the PostgREST organization on [Patreon](https://www.patreon.com/postgrest).
## Sponsors
<table>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://www.cybertec-postgresql.com/en/" target="_blank">
<img width="222px" src="static/cybertec.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://www.2ndquadrant.com/en/?utm_campaign=External%20Websites&utm_source=PostgREST&utm_medium=Logo" target="_blank">
<img width="222px" src="static/2ndquadrant.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="222px" src="static/retool.png">
</a>
</td>
</tr>
</tbody>
</table>
## Lead Backers ## Lead Backers
- [Christiaan Westerbeek](https://devotis.nl)
- [Daniel Babiak](https://github.com/d-babiak) - [Daniel Babiak](https://github.com/d-babiak)
- [Michel Pelletier](https://github.com/michelp/) - Evans Fernandes
## Backers ## Backers
- Tsingson Qin - Tsingson Qin
- Michel Pelletier
- Jay Hannah - Jay Hannah
- Robert Stolarz
- Kofi Gumbs
- Nicholas DiBiase
- Christopher Reid
- Nathan Bouscal
## Former Backers
<table>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://www.timescale.com?utm_campaign=postgrest&utm_source=sponsor&utm_medium=referral&utm_content=github" target="_blank">
<img width="222px" src="static/timescaledb.png">
</a>
</td>
</tr>
</tbody>
</table>
- [Christiaan Westerbeek](https://devotis.nl)
+60
View File
@@ -9,6 +9,66 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed ### Fixed
## [6.0.2] - 2019-08-22
### Fixed
- #1369, Change `raw-media-types` to accept a string of comma separated MIME types - @Dansvidania
- #1368, Fix long column descriptions being truncated at 63 characters in PostgreSQL 12 - @amedeedaboville
- #1348, Go back to converting plus "+" to space " " in querystrings by default - @steve-chavez
### Deprecated
- #1348, Deprecate `.` symbol for disambiguating resource embedding(added in #918). The url-safe '!' should be used instead. We refrained from using `+` as part of our syntax because it conflicts with some http clients and proxies.
## [6.0.1] - 2019-07-30
### Added
- #1349, Add user defined raw output media types via `raw-media-types` config option - @Dansvidania
- #1243, Add websearch_to_tsquery support - @herulume
### Fixed
- #1336, Error when testing on Chrome/Firefox: text/html requested but a single column was not selected - @Dansvidania
- #1334, Unable to compile v6.0.0 on windows - @steve-chavez
## [6.0.0] - 2019-06-21
### Added
- #1186, Add support for user defined unix socket via `server-unix-socket` config option - @Dansvidania
- #690, Add `?columns` query parameter for faster bulk inserts, also ignores unspecified json keys in a payload - @steve-chavez
- #1239, Add support for resource embedding on materialized views - @vitorbaptista
- #1264, Add support for bulk RPC call - @steve-chavez
- #1278, Add db-pool-timeout config option - @qu4tro
- #1285, Abort on wrong database password - @qu4tro
- #790, Allow override of OpenAPI spec through `root-spec` config option - @steve-chavez
- #1308, Accept `text/plain` and `text/html` for raw output - @steve-chavez
### Fixed
- #1223, Fix incorrect OpenAPI externalDocs url - @steve-chavez
- #1221, Fix embedding other resources when having a self join - @steve-chavez
- #1242, Fix embedding a view having a select in a where - @steve-chavez
- #1238, Fix PostgreSQL to OpenAPI type mappings for numeric and character types - @fpusch
- #1265, Fix query generated on bulk upsert with an empty array - @qu4tro
- #1273, Fix RPC ignoring unknown arguments by default - @steve-chavez
- #1257, Fix incorrect status when a PATCH request doesn't find rows to change - @qu4tro
### Changed
- #1288, Change server-host default of 127.0.0.1 to !4
### Deprecated
- #1288, Deprecate `.` symbol for disambiguating resource embedding(added in #918). '+' should be used instead. Though '+' is url safe, certain clients might need to encode it to '%2B'.
### Removed
- #1288, Removed support for schema reloading with SIGHUP, SIGUSR1 should be used instead - @steve-chavez
## [5.2.0] - 2018-12-12 ## [5.2.0] - 2018-12-12
### Added ### Added
+40
View File
@@ -0,0 +1,40 @@
.PHONY: check clean lint style test test-watch coverage circleci circleci-prof-test check-dburi prompt-clean prompt-long-process
check: lint style test
clean: prompt-clean
stack clean --full
lint:
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$$@"
style:
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- stylish-haskell -i
test: check-dburi
stack test
test-watch: check-dburi
stack build --file-watch --test --test-arguments '--rerun --failure-report=.TESTREPORT --rerun-all-on-success'
coverage: check-dburi clean
stack build --coverage
stack test --coverage
circleci: prompt-long-process
circleci local execute --job build-test-9.4
circleci-prof-test: prompt-long-process
circleci local execute --job build-prof-test
check-dburi:
test -n "$(POSTGREST_TEST_CONNECTION)" # Requires POSTGREST_TEST_CONNECTION environmental variable
prompt-clean:
@echo -n 'Are you sure? You will have to rebuild. [y/N] ' && read ans && [ $${ans:-N} = y ]
prompt-long-process:
@echo -n 'Are you sure? This might take a while. [y/N] ' && read ans && [ $${ans:-N} = y ]
+32 -4
View File
@@ -1,4 +1,4 @@
![Logo](static/logo.png "Logo") ![Logo](static/bigger-logo.png "Logo")
[![Donate](https://img.shields.io/badge/Donate-Patreon-orange.svg?colorB=F96854)](https://www.patreon.com/postgrest) [![Donate](https://img.shields.io/badge/Donate-Patreon-orange.svg?colorB=F96854)](https://www.patreon.com/postgrest)
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/postgrest) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/postgrest)
@@ -15,6 +15,32 @@ PostgREST serves a fully RESTful API from any existing PostgreSQL
database. It provides a cleaner, more standards-compliant, faster database. It provides a cleaner, more standards-compliant, faster
API than you are likely to write from scratch. API than you are likely to write from scratch.
## Sponsors
<table>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://www.cybertec-postgresql.com/en/" target="_blank">
<img width="222px" src="static/cybertec.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://www.2ndquadrant.com/en/?utm_campaign=External%20Websites&utm_source=PostgREST&utm_medium=Logo" target="_blank">
<img width="222px" src="static/2ndquadrant.png">
</a>
</td>
<td align="center" valign="middle">
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
<img width="222px" src="static/retool.png">
</a>
</td>
</tr>
</tbody>
</table>
Big thanks to our sponsors! You can join them by supporting PostgREST on [Patreon](https://www.patreon.com/postgrest).
## Usage ## Usage
1. Download the binary ([latest release](https://github.com/PostgREST/postgrest/releases/latest)) 1. Download the binary ([latest release](https://github.com/PostgREST/postgrest/releases/latest))
@@ -24,13 +50,15 @@ API than you are likely to write from scratch.
```bash ```bash
postgrest --help postgrest --help
``` ```
## [Documentation](http://postgrest.org)
Latest documentation is at [postgrest.org](http://postgrest.org). You can contribute to the docs in [PostgREST/postgrest-docs](https://github.com/PostgREST/postgrest-docs).
## Performance ## Performance
TLDR; subsecond response times for up to 2000 requests/sec on Heroku TLDR; subsecond response times for up to 2000 requests/sec on Heroku
free tier. If you're used to servers written in interpreted languages free tier. If you're used to servers written in interpreted languages,
(or named after precious gems), prepare to be pleasantly surprised by prepare to be pleasantly surprised by PostgREST performance.
PostgREST performance.
Three factors contribute to the speed. First the server is written Three factors contribute to the speed. First the server is written
in [Haskell](https://www.haskell.org/) using the in [Haskell](https://www.haskell.org/) using the
+1 -1
View File
@@ -10,7 +10,7 @@
}, },
"POSTGREST_VER": { "POSTGREST_VER": {
"description": "Version of PostgREST to deploy", "description": "Version of PostgREST to deploy",
"value": "5.2.0" "value": "6.0.2"
}, },
"DB_URI": { "DB_URI": {
"description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb", "description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb",
+8 -11
View File
@@ -1,24 +1,19 @@
## AppVeyor is only used for building a Windows binary, no tests are run here.
platform: x64 platform: x64
cache: cache:
- "c:\\sr" - "c:\\sr"
- .stack-work - .stack-work
- "c:\\Users\\appveyor\\AppData\\Local\\Programs\\stack"
environment: environment:
global: global:
STACK_ROOT: "c:\\sr" STACK_ROOT: "c:\\sr"
GOPATH: c:\gopath GOPATH: c:\gopath
TMP: "c:\\tmp"
test: off test: off
skip_non_tags: true
skip_branch_with_pr: true
branches:
only:
- master
install: install:
- set PATH=C:\Program Files\PostgreSQL\9.6\bin\;%PATH% - set PATH=C:\Program Files\PostgreSQL\9.6\bin\;%PATH%
- curl -sS -ostack.zip -L --insecure http://www.stackage.org/stack/windows-x86_64 - curl -sS -ostack.zip -L --insecure http://www.stackage.org/stack/windows-x86_64
@@ -28,11 +23,13 @@ install:
build_script: build_script:
- stack setup --no-terminal > nul - stack setup --no-terminal > nul
- stack build -j1 --copy-bins --local-bin-path . # Appveyor has a timeout of 60 mins, building can take longer, limit the time and make sure this succeeds,
# previous work will get cached and finish on next commit
- bash -lc "timeout 2700 'C:\projects\postgrest\stack.exe' build -j1 --copy-bins --local-bin-path . || true"
artifacts: artifacts:
- path: postgrest.exe - path: postgrest.exe
deploy_script: deploy_script:
- 7z a -tzip postgrest-%APPVEYOR_REPO_TAG_NAME%-windows-x64.zip postgrest.exe - IF DEFINED APPVEYOR_REPO_TAG_NAME 7z a -tzip postgrest-%APPVEYOR_REPO_TAG_NAME%-windows-x64.zip postgrest.exe
- bash -lc "exec 0</dev/null && cd $APPVEYOR_BUILD_FOLDER && ghr -t $GITHUB_TOKEN -u $APPVEYOR_ACCOUNT_NAME -r $APPVEYOR_PROJECT_NAME -b \"$(sed -n \"1,/$(echo $APPVEYOR_REPO_TAG_NAME | cut -c2-)/d;/## \[/q;p\" CHANGELOG.md)\" --replace $APPVEYOR_REPO_TAG_NAME postgrest-$APPVEYOR_REPO_TAG_NAME-windows-x64.zip" - IF DEFINED APPVEYOR_REPO_TAG_NAME bash -lc " exec 0</dev/null && cd $APPVEYOR_BUILD_FOLDER && ghr -t $GITHUB_TOKEN -u $APPVEYOR_ACCOUNT_NAME -r $APPVEYOR_PROJECT_NAME -b \"$(sed -n \"1,/$(echo $APPVEYOR_REPO_TAG_NAME | cut -c2-)/d;/## \[/q;p\" CHANGELOG.md)\" --replace $APPVEYOR_REPO_TAG_NAME postgrest-$APPVEYOR_REPO_TAG_NAME-windows-x64.zip"
+11 -2
View File
@@ -1,4 +1,4 @@
FROM debian:jessie FROM debian:stretch-slim
ARG POSTGREST_VERSION ARG POSTGREST_VERSION
@@ -28,6 +28,7 @@ ENV PGRST_DB_URI= \
PGRST_DB_SCHEMA=public \ PGRST_DB_SCHEMA=public \
PGRST_DB_ANON_ROLE= \ PGRST_DB_ANON_ROLE= \
PGRST_DB_POOL=100 \ PGRST_DB_POOL=100 \
PGRST_DB_EXTRA_SEARCH_PATH=public \
PGRST_SERVER_HOST=*4 \ PGRST_SERVER_HOST=*4 \
PGRST_SERVER_PORT=3000 \ PGRST_SERVER_PORT=3000 \
PGRST_SERVER_PROXY_URI= \ PGRST_SERVER_PROXY_URI= \
@@ -36,7 +37,15 @@ ENV PGRST_DB_URI= \
PGRST_JWT_AUD= \ PGRST_JWT_AUD= \
PGRST_MAX_ROWS= \ PGRST_MAX_ROWS= \
PGRST_PRE_REQUEST= \ PGRST_PRE_REQUEST= \
PGRST_ROLE_CLAIM_KEY=".role" PGRST_ROLE_CLAIM_KEY=".role" \
PGRST_ROOT_SPEC= \
PGRST_RAW_MEDIA_TYPES=
RUN groupadd -g 1000 postgrest && \
useradd -r -u 1000 -g postgrest postgrest && \
chown postgrest:postgrest /etc/postgrest.conf
USER 1000
# PostgREST reads /etc/postgrest.conf so map the configuration # PostgREST reads /etc/postgrest.conf so map the configuration
# file in when you run this container # file in when you run this container
-3
View File
@@ -1,3 +0,0 @@
db-uri = "postgres://app_user:password@postgres:5432/app_db"
db-schema = "public"
db-anon-role = "app_user"
-18
View File
@@ -1,18 +0,0 @@
FROM centos:centos6
RUN yum -y update
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
RUN yum -y install https://download.postgresql.org/pub/repos/yum/9.3/redhat/rhel-6-x86_64/pgdg-centos93-9.3-2.noarch.rpm
RUN yum -y install postgresql93-devel
RUN yum clean all
RUN curl -sSL https://get.haskellstack.org/ | sh
ENV PATH $PATH:/usr/pgsql-9.3/bin
# To disable warning when building
ENV PATH $PATH:/root/.local/bin
RUN mkdir /source
WORKDIR /source
ENTRYPOINT ["stack"]
+3 -3
View File
@@ -2,12 +2,12 @@ FROM centos:centos7
RUN yum -y update RUN yum -y update
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
RUN yum -y install yum install https://download.postgresql.org/pub/repos/yum/9.3/redhat/rhel-7-x86_64/pgdg-centos93-9.3-2.noarch.rpm RUN yum -y install yum install https://download.postgresql.org/pub/repos/yum/10/redhat/rhel-7-x86_64/pgdg-centos10-10-2.noarch.rpm
RUN yum -y install postgresql93-devel RUN yum -y install postgresql10-devel
RUN yum clean all RUN yum clean all
RUN curl -sSL https://get.haskellstack.org/ | sh RUN curl -sSL https://get.haskellstack.org/ | sh
ENV PATH $PATH:/usr/pgsql-9.3/bin ENV PATH $PATH:/usr/pgsql-10/bin
# To disable warning when building # To disable warning when building
ENV PATH $PATH:/root/.local/bin ENV PATH $PATH:/root/.local/bin
+2
View File
@@ -1,5 +1,7 @@
FROM ubuntu:16.04 FROM ubuntu:16.04
## TODO pin the stack version
#
RUN BUILD_DEPS="curl ca-certificates build-essential" && \ RUN BUILD_DEPS="curl ca-certificates build-essential" && \
apt-get -qq update && \ apt-get -qq update && \
apt-get -qqy --no-install-recommends install \ apt-get -qqy --no-install-recommends install \
+3 -1
View File
@@ -1,4 +1,6 @@
FROM 32bit/ubuntu:16.04 FROM i386/ubuntu:16.04
## TODO pin the stack version
RUN BUILD_DEPS="curl ca-certificates build-essential" && \ RUN BUILD_DEPS="curl ca-certificates build-essential" && \
apt-get -qq update && \ apt-get -qq update && \
+3
View File
@@ -2,6 +2,7 @@ db-uri = "$(PGRST_DB_URI)"
db-schema = "$(PGRST_DB_SCHEMA)" db-schema = "$(PGRST_DB_SCHEMA)"
db-anon-role = "$(PGRST_DB_ANON_ROLE)" db-anon-role = "$(PGRST_DB_ANON_ROLE)"
db-pool = "$(PGRST_DB_POOL)" db-pool = "$(PGRST_DB_POOL)"
db-extra-search-path = "$(PGRST_DB_EXTRA_SEARCH_PATH)"
server-host = "$(PGRST_SERVER_HOST)" server-host = "$(PGRST_SERVER_HOST)"
server-port = "$(PGRST_SERVER_PORT)" server-port = "$(PGRST_SERVER_PORT)"
@@ -14,3 +15,5 @@ role-claim-key = "$(PGRST_ROLE_CLAIM_KEY)"
max-rows = "$(PGRST_MAX_ROWS)" max-rows = "$(PGRST_MAX_ROWS)"
pre-request = "$(PGRST_PRE_REQUEST)" pre-request = "$(PGRST_PRE_REQUEST)"
root-spec = "$(PGRST_ROOT_SPEC)"
raw-media-types = "$(PGRST_RAW_MEDIA_TYPES)"
+128 -86
View File
@@ -2,42 +2,51 @@
module Main where module Main where
import PostgREST.App (postgrest)
import PostgREST.Config (AppConfig (..),
prettyVersion, readOptions)
import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.Error (encodeError)
import PostgREST.OpenAPI (isMalformedProxyUri)
import PostgREST.Types (DbStructure, Schema, PgVersion(..), minimumPgVersion)
import Protolude hiding (hPutStrLn, replace)
import Control.AutoUpdate (defaultUpdateSettings,
mkAutoUpdate, updateAction)
import Control.Retry (RetryStatus, capDelay,
exponentialBackoff,
retrying, rsPreviousDelay)
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Base64 as B64
import Data.IORef (IORef, atomicWriteIORef,
newIORef, readIORef)
import Data.String (IsString (..))
import Data.Text (pack, replace, stripPrefix, strip)
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Text.IO (hPutStrLn, readFile)
import Data.Time.Clock (getCurrentTime)
import qualified Hasql.Pool as P import qualified Hasql.Pool as P
import qualified Hasql.Session as H
import qualified Hasql.Transaction.Sessions as HT import qualified Hasql.Transaction.Sessions as HT
import Network.Wai.Handler.Warp (defaultSettings,
runSettings, setHost, import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
setPort, setServerName) updateAction)
import System.IO (BufferMode (..), import Control.Retry (RetryStatus, capDelay,
hSetBuffering) exponentialBackoff, retrying,
rsPreviousDelay)
import Data.IORef (IORef, atomicWriteIORef, newIORef,
readIORef)
import Data.String (IsString (..))
import Data.Text (pack, replace, strip, stripPrefix,
unpack)
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Text.IO (hPutStrLn, readFile)
import Data.Time.Clock (getCurrentTime)
import Network.Socket (Family (AF_UNIX),
SockAddr (SockAddrUnix), Socket,
SocketType (Stream), bind, close,
defaultProtocol, listen,
maxListenQueue, socket)
import Network.Wai.Handler.Warp (defaultSettings, runSettings,
runSettingsSocket, setHost, setPort,
setServerName)
import System.Directory (removeFile)
import System.IO (BufferMode (..), hSetBuffering)
import System.IO.Error (isDoesNotExistError)
import PostgREST.App (postgrest)
import PostgREST.Config (AppConfig (..), configPoolTimeout',
prettyVersion, readOptions)
import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.Error (PgError (PgError), checkIsFatal,
errorPayload)
import PostgREST.OpenAPI (isMalformedProxyUri)
import PostgREST.Types (ConnectionStatus (..), DbStructure,
PgVersion (..), Schema,
minimumPgVersion)
import Protolude hiding (hPutStrLn, replace)
#ifndef mingw32_HOST_OS #ifndef mingw32_HOST_OS
import System.Posix.Signals import System.Posix.Signals
#endif #endif
{-| {-|
@@ -74,25 +83,24 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
work = do work = do
atomicWriteIORef refDbStructure Nothing atomicWriteIORef refDbStructure Nothing
putStrLn ("Attempting to connect to the database..." :: Text) putStrLn ("Attempting to connect to the database..." :: Text)
connected <- connectingSucceeded pool connected <- connectionStatus pool
when connected $ do case connected of
result <- P.use pool $ do FatalConnectionError reason -> hPutStrLn stderr reason
actualPgVersion <- getPgVersion >> killThread mainTid -- Fatal error when connecting
unless (actualPgVersion >= minimumPgVersion) $ liftIO $ do NotConnected -> return () -- Unreachable
hPutStrLn stderr Connected actualPgVersion -> do -- Procede with initialization
("Cannot run in this PostgreSQL version, PostgREST needs at least " result <- P.use pool $ do
<> pgvName minimumPgVersion) dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schema actualPgVersion
killThread mainTid liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schema actualPgVersion case result of
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure Left e -> do
case result of putStrLn ("Failed to query the database. Retrying." :: Text)
Left e -> do hPutStrLn stderr . toS . errorPayload $ PgError False e
putStrLn ("Failed to query the database. Retrying." :: Text) work
hPutStrLn stderr (toS $ encodeError e)
work Right _ -> do
Right _ -> do atomicWriteIORef refIsWorkerOn False
atomicWriteIORef refIsWorkerOn False putStrLn ("Connection successful" :: Text)
putStrLn ("Connection successful" :: Text)
{-| {-|
Used by 'connectionWorker' to check if the provided db-uri lets Used by 'connectionWorker' to check if the provided db-uri lets
@@ -103,26 +111,37 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
The connection tries are capped, but if the connection times out no error is The connection tries are capped, but if the connection times out no error is
thrown, just 'False' is returned. thrown, just 'False' is returned.
-} -}
connectingSucceeded :: P.Pool -> IO Bool connectionStatus :: P.Pool -> IO ConnectionStatus
connectingSucceeded pool = connectionStatus pool =
retrying (capDelay 32000000 $ exponentialBackoff 1000000) retrying (capDelay 32000000 $ exponentialBackoff 1000000)
shouldRetry shouldRetry
(const $ P.release pool >> isConnectionSuccessful) (const $ P.release pool >> getConnectionStatus)
where where
isConnectionSuccessful :: IO Bool getConnectionStatus :: IO ConnectionStatus
isConnectionSuccessful = do getConnectionStatus = do
testConn <- P.use pool $ H.sql "SELECT 1" pgVersion <- P.use pool getPgVersion
case testConn of case pgVersion of
Left e -> hPutStrLn stderr (toS $ encodeError e) >> pure False Left e -> do
_ -> pure True let err = PgError False e
shouldRetry :: RetryStatus -> Bool -> IO Bool hPutStrLn stderr . toS $ errorPayload err
case checkIsFatal err of
Just reason -> return $ FatalConnectionError reason
Nothing -> return NotConnected
Right version ->
if version < minimumPgVersion
then return . FatalConnectionError $ "Cannot run in this PostgreSQL version, PostgREST needs at least " <> pgvName minimumPgVersion
else return . Connected $ version
shouldRetry :: RetryStatus -> ConnectionStatus -> IO Bool
shouldRetry rs isConnSucc = do shouldRetry rs isConnSucc = do
delay <- pure $ fromMaybe 0 (rsPreviousDelay rs) `div` 1000000 let delay = fromMaybe 0 (rsPreviousDelay rs) `div` 1000000
itShould <- pure $ not isConnSucc itShould = NotConnected == isConnSucc
when itShould $ when itShould $
putStrLn $ "Attempting to reconnect to the database in " <> (show delay::Text) <> " seconds..." putStrLn $ "Attempting to reconnect to the database in " <> (show delay::Text) <> " seconds..."
return itShould return itShould
{-| {-|
This is where everything starts. This is where everything starts.
-} -}
@@ -143,6 +162,7 @@ main = do
let host = configHost conf let host = configHost conf
port = configPort conf port = configPort conf
proxy = configProxyUri conf proxy = configProxyUri conf
maybeSocketAddr = configSocket conf
pgSettings = toS (configDatabase conf) -- is the db-uri pgSettings = toS (configDatabase conf) -- is the db-uri
roleClaimKey = configRoleClaimKey conf roleClaimKey = configRoleClaimKey conf
appSettings = appSettings =
@@ -160,11 +180,10 @@ main = do
when (isLeft roleClaimKey) $ when (isLeft roleClaimKey) $
panic $ show roleClaimKey panic $ show roleClaimKey
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
-- --
-- create connection pool with the provided settings, returns either -- create connection pool with the provided settings, returns either
-- a 'Connection' or a 'ConnectionError'. Does not throw. -- a 'Connection' or a 'ConnectionError'. Does not throw.
pool <- P.acquire (configPool conf, 10, pgSettings) pool <- P.acquire (configPool conf, configPoolTimeout' conf, pgSettings)
-- --
-- To be filled in by connectionWorker -- To be filled in by connectionWorker
refDbStructure <- newIORef Nothing refDbStructure <- newIORef Nothing
@@ -198,34 +217,45 @@ main = do
throwTo mainTid UserInterrupt throwTo mainTid UserInterrupt
) Nothing ) Nothing
forM_ [sigHUP, sigUSR1] $ \sig -> void $ installHandler sigUSR1 (
void $ installHandler sig ( Catch $ connectionWorker
Catch $ connectionWorker mainTid
mainTid pool
pool (configSchema conf)
(configSchema conf) refDbStructure
refDbStructure refIsWorkerOn
refIsWorkerOn ) Nothing
) Nothing
#endif #endif
-- ask for the OS time at most once per second -- ask for the OS time at most once per second
getTime <- mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime} getTime <- mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}
-- run the postgrest application let postgrestApplication =
runSettings appSettings $ postgrest
postgrest conf
conf refDbStructure
refDbStructure pool
pool getTime
getTime (connectionWorker
(connectionWorker mainTid
mainTid pool
pool (configSchema conf)
(configSchema conf) refDbStructure
refDbStructure refIsWorkerOn)
refIsWorkerOn) in case maybeSocketAddr of
Nothing -> do
-- run the postgrest application
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
runSettings appSettings postgrestApplication
Just socketAddr -> do
-- run postgrest application with user defined socket
sock <- createAndBindSocket (unpack socketAddr)
listen sock maxListenQueue
putStrLn $ ("Listening on unix socket " :: Text) <> show socketAddr
runSettingsSocket appSettings sock postgrestApplication
-- clean socket up when done
close sock
{-| {-|
The purpose of this function is to load the JWT secret from a file if The purpose of this function is to load the JWT secret from a file if
@@ -290,6 +320,18 @@ loadDbUriFile conf = extractDbUri mDbUri
extractDbUri dbUri = extractDbUri dbUri =
fmap setDbUri $ fmap setDbUri $
case stripPrefix "@" dbUri of case stripPrefix "@" dbUri of
Nothing -> return dbUri Nothing -> return dbUri
Just filename -> strip <$> readFile (toS filename) Just filename -> strip <$> readFile (toS filename)
setDbUri dbUri = conf {configDatabase = dbUri} setDbUri dbUri = conf {configDatabase = dbUri}
createAndBindSocket :: FilePath -> IO Socket
createAndBindSocket filePath = do
deleteSocketFileIfExist filePath
sock <- socket AF_UNIX Stream defaultProtocol
bind sock $ SockAddrUnix filePath
return sock
where
deleteSocketFileIfExist path = removeFile path `catch` handleDoesNotExist
handleDoesNotExist e
| isDoesNotExistError e = return ()
| otherwise = throwIO e
+181 -169
View File
@@ -1,176 +1,188 @@
name: postgrest name: postgrest
description: Reads the schema of a PostgreSQL database and creates RESTful routes version: 6.0.2
for the tables and views, supporting all HTTP verbs that security synopsis: REST API for any Postgres database
permits. description: Reads the schema of a PostgreSQL database and creates RESTful routes
version: 5.2.0 for the tables and views, supporting all HTTP verbs that security
synopsis: REST API for any Postgres database permits.
license: MIT license: MIT
license-file: LICENSE license-file: LICENSE
author: Joe Nelson, Adam Baker author: Joe Nelson, Adam Baker
homepage: https://postgrest.org maintainer: Steve Chávez <stevechavezast@gmail.com>
maintainer: Steve Chávez <stevechavezast@gmail.com> category: Executable, PostgreSQL, Network APIs
bug-reports: https://github.com/PostgREST/postgrest/issues homepage: https://postgrest.org
category: Executable, PostgreSQL, Network APIs bug-reports: https://github.com/PostgREST/postgrest/issues
extra-source-files: CHANGELOG.md build-type: Simple
build-type: Simple extra-source-files: CHANGELOG.md
cabal-version: >=1.10 cabal-version: >= 1.10
source-repository head source-repository head
type: git type: git
location: git://github.com/PostgREST/postgrest.git location: git://github.com/PostgREST/postgrest.git
Flag CI flag ci
Description: No warnings allowed in continuous integration default: False
Manual: True manual: True
Default: False description: No warnings allowed in continuous integration
executable postgrest
main-is: Main.hs
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude
ghc-options:
-threaded
-rtsopts
"-with-rtsopts=-N -I2"
default-language: Haskell2010
build-depends: auto-update
, base >= 4.8 && < 4.10
, hasql >= 1.3 && < 1.4
, hasql-pool >= 0.5 && < 0.6
, hasql-transaction >= 0.7 && < 0.8
, postgrest
, protolude == 0.2.2
, text
, time
, warp
, bytestring
, base64-bytestring
, retry
if !os(windows)
build-depends: unix
hs-source-dirs: main
library library
default-language: Haskell2010 exposed-modules: PostgREST.ApiRequest
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude PostgREST.App
build-depends: aeson PostgREST.Auth
, ansi-wl-pprint PostgREST.Config
, base >= 4.8 && < 4.10 PostgREST.DbRequestBuilder
, base64-bytestring PostgREST.DbStructure
, bytestring PostgREST.Error
, case-insensitive PostgREST.Middleware
, cassava PostgREST.OpenAPI
, configurator-ng == 0.0.0.1 PostgREST.Parsers
, containers PostgREST.QueryBuilder
, contravariant PostgREST.RangeQuery
, contravariant-extras PostgREST.Types
, either other-modules: Paths_postgrest
, gitrev PostgREST.QueryBuilder.Private
, hasql >= 1.3 && < 1.4 PostgREST.QueryBuilder.Procedure
, hasql-pool >= 0.5 && < 0.6 PostgREST.QueryBuilder.ReadStatement
, hasql-transaction >= 0.7 && < 0.8 PostgREST.QueryBuilder.WriteStatement
, heredoc hs-source-dirs: src
, HTTP build-depends: base >= 4.9 && < 4.13
, http-types , HTTP >= 4000.3.7 && < 4000.4
, insert-ordered-containers , Ranged-sets >= 0.3 && < 0.5
, interpolatedstring-perl6 , aeson >= 0.11.3 && < 1.5
, jose == 0.7.0.0 , ansi-wl-pprint >= 0.6.7 && < 0.7
, lens , base64-bytestring >= 1 && < 1.1
, lens-aeson , bytestring >= 0.10.8 && < 0.11
, network-uri , case-insensitive >= 1.2 && < 1.3
, optparse-applicative >= 0.13 && < 0.15 , cassava >= 0.4.5 && < 0.6
, parsec , configurator-pg >= 0.1 && < 0.2
, protolude == 0.2.2 , containers >= 0.5.7 && < 0.7
, Ranged-sets == 0.3.0 , contravariant >= 1.4 && < 1.6
, regex-tdfa , contravariant-extras >= 0.3.3 && < 0.4
, scientific , cookie >= 0.4.2 && < 0.5
, swagger2 , either >= 4.4.1 && < 5.1
, text , gitrev >= 1.2 && < 1.4
, time , hasql >= 1.4 && < 1.5
, unordered-containers , hasql-pool >= 0.5 && < 0.6
, vector , hasql-transaction >= 0.7.2 && < 0.8
, wai , heredoc >= 0.2 && < 0.3
, wai-cors , http-types >= 0.12.2 && < 0.13
, wai-extra , insert-ordered-containers >= 0.1 && < 0.3
, wai-middleware-static , interpolatedstring-perl6 >= 1 && < 1.1
, cookie , jose >= 0.8.1 && < 0.9
, lens >= 4.14 && < 4.18
, lens-aeson >= 1.0.1 && < 1.1
, network-uri >= 2.6.1 && < 2.7
, optparse-applicative >= 0.13 && < 0.15
, parsec >= 3.1.11 && < 3.2
, protolude >= 0.2.2 && < 0.3
, regex-tdfa >= 1.2.2 && < 1.3
, scientific >= 0.3.4 && < 0.4
, swagger2 >= 2.1.4 && < 2.4
, text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.9
, unordered-containers >= 0.2.8 && < 0.3
, vector >= 0.11 && < 0.13
, wai >= 3.2.1 && < 3.3
, wai-cors >= 0.2.5 && < 0.3
, wai-extra >= 3.0.19 && < 3.1
, wai-middleware-static >= 0.8.1 && < 0.9
default-language: Haskell2010
default-extensions: OverloadedStrings
QuasiQuotes
NoImplicitPrelude
Other-Modules: Paths_postgrest executable postgrest
Exposed-Modules: PostgREST.ApiRequest main-is: Main.hs
, PostgREST.App hs-source-dirs: main
, PostgREST.Auth build-depends: base >= 4.9 && < 4.13
, PostgREST.Config , auto-update >= 0.1.4 && < 0.2
, PostgREST.DbStructure , base64-bytestring >= 1 && < 1.1
, PostgREST.DbRequestBuilder , bytestring >= 0.10.8 && < 0.11
, PostgREST.Error , directory >= 1.2.6 && < 1.4
, PostgREST.Middleware , hasql >= 1.4 && < 1.5
, PostgREST.OpenAPI , hasql-pool >= 0.5 && < 0.6
, PostgREST.Parsers , hasql-transaction >= 0.7.2 && < 0.8
, PostgREST.QueryBuilder , network < 2.9
, PostgREST.RangeQuery , postgrest
, PostgREST.Types , protolude >= 0.2.2 && < 0.3
hs-source-dirs: src , retry >= 0.7.4 && < 0.9
, text >= 1.2.2 && < 1.3
, time >= 1.6 && < 1.9
, warp >= 3.2.12 && < 3.3
default-language: Haskell2010
default-extensions: OverloadedStrings
QuasiQuotes
NoImplicitPrelude
ghc-options: -threaded -rtsopts "-with-rtsopts=-N -I2"
Test-Suite spec if !os(windows)
Type: exitcode-stdio-1.0 build-depends: unix
Default-Language: Haskell2010
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude test-suite spec
ghc-options: -threaded -rtsopts -with-rtsopts=-N type: exitcode-stdio-1.0
Hs-Source-Dirs: test main-is: Main.hs
Main-Is: Main.hs other-modules: Feature.AndOrParamsSpec
Other-Modules: Feature.AudienceJwtSecretSpec Feature.AsymmetricJwtSpec
, Feature.AuthSpec Feature.AudienceJwtSecretSpec
, Feature.AsymmetricJwtSpec Feature.AuthSpec
, Feature.BinaryJwtSecretSpec Feature.BinaryJwtSecretSpec
, Feature.ConcurrentSpec Feature.ConcurrentSpec
, Feature.CorsSpec Feature.CorsSpec
, Feature.DeleteSpec Feature.DeleteSpec
, Feature.ExtraSearchPathSpec Feature.ExtraSearchPathSpec
, Feature.InsertSpec Feature.InsertSpec
, Feature.JsonOperatorSpec Feature.JsonOperatorSpec
, Feature.NoJwtSpec Feature.NoJwtSpec
, Feature.PgVersion95Spec Feature.NonexistentSchemaSpec
, Feature.PgVersion96Spec Feature.PgVersion95Spec
, Feature.ProxySpec Feature.PgVersion96Spec
, Feature.QueryLimitedSpec Feature.ProxySpec
, Feature.QuerySpec Feature.QueryLimitedSpec
, Feature.RangeSpec Feature.QuerySpec
, Feature.SingularSpec Feature.RangeSpec
, Feature.StructureSpec Feature.RootSpec
, Feature.UnicodeSpec Feature.RpcSpec
, Feature.AndOrParamsSpec Feature.SingularSpec
, Feature.RpcSpec Feature.StructureSpec
, Feature.NonexistentSchemaSpec Feature.UnicodeSpec
, Feature.UpsertSpec Feature.UpsertSpec
, SpecHelper Feature.RawOutputTypesSpec
, TestTypes Feature.HtmlRawOutputSpec
Build-Depends: aeson SpecHelper
, aeson-qq TestTypes
, async hs-source-dirs: test
, auto-update build-depends: base >= 4.9 && < 4.13
, base >= 4.8 && < 4.10 , aeson >= 0.11.3 && < 1.5
, bytestring , aeson-qq >= 0.8.1 && < 0.9
, base64-bytestring , async >= 2.1.1 && < 2.3
, case-insensitive , auto-update >= 0.1.4 && < 0.2
, cassava , base64-bytestring >= 1 && < 1.1
, containers , bytestring >= 0.10.8 && < 0.11
, contravariant , case-insensitive >= 1.2 && < 1.3
, hasql >= 1.3 && < 1.4 , cassava >= 0.4.5 && < 0.6
, hasql-pool >= 0.5 && < 0.6 , containers >= 0.5.7 && < 0.7
, hasql-transaction >= 0.7 && < 0.8 , contravariant >= 1.4 && < 1.6
, heredoc , hasql >= 1.4 && < 1.5
, hjsonschema == 1.5.0.1 , hasql-pool >= 0.5 && < 0.6
, hspec , hasql-transaction >= 0.7.2 && < 0.8
, hspec-wai >= 0.7.0 , heredoc >= 0.2 && < 0.3
, hspec-wai-json , hspec >= 2.3 && < 2.8
, http-types , hspec-wai >= 0.7 && < 0.10
, lens , hspec-wai-json >= 0.7 && < 0.10
, lens-aeson , http-types >= 0.12.3 && < 0.13
, monad-control , lens >= 4.14 && < 4.18
, postgrest , lens-aeson >= 1.0.1 && < 1.1
, process , monad-control >= 1.0.1 && < 1.1
, protolude == 0.2.2 , postgrest
, regex-tdfa , process >= 1.4.2 && < 1.7
, time , protolude >= 0.2.2 && < 0.3
, transformers-base , regex-tdfa >= 1.2.2 && < 1.3
, wai , text >= 1.2.2 && < 1.3
, wai-extra , time >= 1.6 && < 1.9
, transformers-base >= 0.4.4 && < 0.5
, wai >= 3.2.1 && < 3.3
, wai-extra >= 3.0.19 && < 3.1
default-language: Haskell2010
default-extensions: OverloadedStrings
QuasiQuotes
NoImplicitPrelude
ghc-options: -threaded -rtsopts -with-rtsopts=-N
-10
View File
@@ -1,10 +0,0 @@
export POSTGREST_VER=`grep ^version /app/postgrest.cabal | sed -En 's/.*\s+([0-9\.]+)/\1/p'`
curl -L http://sourceforge.net/projects/s3tools/files/s3cmd/1.5.0-alpha1/s3cmd-1.5.0-alpha1.tar.gz | tar zx
cp /app/dist/build/postgrest/postgrest postgrest-${POSTGREST_VER}
tar cJf postgrest-${POSTGREST_VER}.tar.xz postgrest-${POSTGREST_VER}
touch ~/.s3cfg
s3cmd-1.5.0-alpha1/s3cmd put --access_key=${S3_ACCESS_KEY} --secret_key=${S3_SECRET_KEY} -P -f postgrest-${POSTGREST_VER}.tar.xz $S3_BUCKET/postgrest-${POSTGREST_VER}.tar.xz
+106 -97
View File
@@ -1,43 +1,53 @@
{-# LANGUAGE LambdaCase #-}
{-| {-|
Module : PostgREST.ApiRequest Module : PostgREST.ApiRequest
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest. Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
-} -}
module PostgREST.ApiRequest ( ApiRequest(..) {-# LANGUAGE LambdaCase #-}
, ContentType(..)
, Action(..)
, Target(..)
, PreferRepresentation (..)
, mutuallyAgreeable
, userApiRequest
) where
import Protolude module PostgREST.ApiRequest (
import qualified Data.Aeson as JSON ApiRequest(..)
import Data.Aeson.Types (emptyObject, emptyArray) , ContentType(..)
import qualified Data.ByteString as BS , Action(..)
import qualified Data.ByteString.Internal as BS (c2w) , Target(..)
import qualified Data.ByteString.Lazy as BL , PreferRepresentation (..)
import qualified Data.Csv as CSV , mutuallyAgreeable
import qualified Data.List as L , userApiRequest
import Data.List (lookup, last, partition) ) where
import qualified Data.HashMap.Strict as M
import qualified Data.Set as S import qualified Data.Aeson as JSON
import Data.Maybe (fromJust) import qualified Data.ByteString as BS
import Control.Arrow ((***)) import qualified Data.ByteString.Lazy as BL
import qualified Data.Text as T import qualified Data.CaseInsensitive as CI
import qualified Data.Vector as V import qualified Data.Csv as CSV
import Network.HTTP.Base (urlEncodeVars) import qualified Data.HashMap.Strict as M
import Network.HTTP.Types.Header (hAuthorization, hCookie) import qualified Data.List as L
import Network.HTTP.Types.URI (parseSimpleQuery) import qualified Data.Set as S
import Network.Wai (Request (..)) import qualified Data.Text as T
import Network.Wai.Parse (parseHttpAccept) import qualified Data.Vector as V
import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset)
import Data.Ranged.Boundaries import Control.Arrow ((***))
import PostgREST.Types import Data.Aeson.Types (emptyArray, emptyObject)
import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange) import Data.List (last, lookup, partition)
import qualified Data.CaseInsensitive as CI import Data.Maybe (fromJust)
import Web.Cookie (parseCookiesText) import Data.Ranged.Ranges (Range (..), emptyRange,
rangeIntersection)
import Network.HTTP.Base (urlEncodeVars)
import Network.HTTP.Types.Header (hAuthorization, hCookie)
import Network.HTTP.Types.URI (parseQueryReplacePlus,
parseSimpleQuery)
import Network.Wai (Request (..))
import Network.Wai.Parse (parseHttpAccept)
import Web.Cookie (parseCookiesText)
import Data.Ranged.Boundaries
import PostgREST.Error (ApiRequestError (..))
import PostgREST.RangeQuery (NonnegRange, allRange, rangeGeq,
rangeLimit, rangeOffset, rangeRequested,
restrictRange)
import PostgREST.Types
import Protolude
type RequestBody = BL.ByteString type RequestBody = BL.ByteString
@@ -49,8 +59,8 @@ data Action = ActionCreate | ActionRead
deriving Eq deriving Eq
-- | The target db object of a user action -- | The target db object of a user action
data Target = TargetIdent QualifiedIdentifier data Target = TargetIdent QualifiedIdentifier
| TargetProc QualifiedIdentifier | TargetProc{tpQi :: QualifiedIdentifier, tpIsRootSpec :: Bool}
| TargetRoot | TargetDefaultSpec -- The default spec offered at root "/"
| TargetUnknown [Text] | TargetUnknown [Text]
deriving Eq deriving Eq
-- | How to return the inserted data -- | How to return the inserted data
@@ -65,44 +75,46 @@ data PreferRepresentation = Full | HeadersOnly | None deriving Eq
-} -}
data ApiRequest = ApiRequest { data ApiRequest = ApiRequest {
-- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST -- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
iAction :: Action iAction :: Action
-- | Requested range of rows within response -- | Requested range of rows within response
, iRange :: M.HashMap ByteString NonnegRange , iRange :: M.HashMap ByteString NonnegRange
-- | The target, be it calling a proc or accessing a table -- | The target, be it calling a proc or accessing a table
, iTarget :: Target , iTarget :: Target
-- | Content types the client will accept, [CTAny] if no Accept header -- | Content types the client will accept, [CTAny] if no Accept header
, iAccepts :: [ContentType] , iAccepts :: [ContentType]
-- | Data sent by client and used for mutation actions -- | Data sent by client and used for mutation actions
, iPayload :: Maybe PayloadJSON , iPayload :: Maybe PayloadJSON
-- | If client wants created items echoed back -- | If client wants created items echoed back
, iPreferRepresentation :: PreferRepresentation , iPreferRepresentation :: PreferRepresentation
-- | Pass all parameters as a single json object to a stored procedure -- | Pass all parameters as a single json object to a stored procedure
, iPreferSingleObjectParameter :: Bool , iPreferSingleObjectParameter :: Bool
-- | Whether the client wants a result count (slower) -- | Whether the client wants a result count (slower)
, iPreferCount :: Bool , iPreferCount :: Bool
-- | Whether the client wants to UPSERT or ignore records on PK conflict -- | Whether the client wants to UPSERT or ignore records on PK conflict
, iPreferResolution :: Maybe PreferResolution , iPreferResolution :: Maybe PreferResolution
-- | Filters on the result ("id", "eq.10") -- | Filters on the result ("id", "eq.10")
, iFilters :: [(Text, Text)] , iFilters :: [(Text, Text)]
-- | &and and &or parameters used for complex boolean logic -- | &and and &or parameters used for complex boolean logic
, iLogic :: [(Text, Text)] , iLogic :: [(Text, Text)]
-- | &select parameter used to shape the response -- | &select parameter used to shape the response
, iSelect :: Text , iSelect :: Text
-- | &columns parameter used to shape the payload
, iColumns :: Maybe Text
-- | &order parameters for each level -- | &order parameters for each level
, iOrder :: [(Text, Text)] , iOrder :: [(Text, Text)]
-- | Alphabetized (canonical) request query string for response URLs -- | Alphabetized (canonical) request query string for response URLs
, iCanonicalQS :: ByteString , iCanonicalQS :: ByteString
-- | JSON Web Token -- | JSON Web Token
, iJWT :: Text , iJWT :: Text
-- | HTTP request headers -- | HTTP request headers
, iHeaders :: [(Text, Text)] , iHeaders :: [(Text, Text)]
-- | Request Cookies -- | Request Cookies
, iCookies :: [(Text, Text)] , iCookies :: [(Text, Text)]
} }
-- | Examines HTTP request and translates it into user intent. -- | Examines HTTP request and translates it into user intent.
userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError ApiRequest userApiRequest :: Schema -> Maybe QualifiedIdentifier -> Request -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest schema req reqBody userApiRequest schema rootSpec req reqBody
| isTargetingProc && method `notElem` ["GET", "POST"] = Left ActionInappropriate | isTargetingProc && method `notElem` ["GET", "POST"] = Left ActionInappropriate
| topLevelRange == emptyRange = Left InvalidRange | topLevelRange == emptyRange = Left InvalidRange
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload | shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
@@ -120,51 +132,64 @@ userApiRequest schema req reqBody
else Nothing else Nothing
, iFilters = filters , iFilters = filters
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ] , iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
, iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams , iSelect = toS $ fromMaybe "*" $ join $ lookup "select" qParams
, iColumns = columns
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ] , iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
, iCanonicalQS = toS $ urlEncodeVars , iCanonicalQS = toS $ urlEncodeVars
. L.sortBy (comparing fst) . L.sortOn fst
. map (join (***) toS) . map (join (***) toS . second (fromMaybe BS.empty))
. parseSimpleQuery $ qString
$ rawQueryString req
, iJWT = tokenStr , iJWT = tokenStr
, iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hAuthorization, k /= hCookie] , iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hAuthorization, k /= hCookie]
, iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie" , iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
} }
where where
-- queryString with '+' converted to ' '(space)
qString = parseQueryReplacePlus True $ rawQueryString req
-- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..) -- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
(filters, rpcQParams) = (filters, rpcQParams) =
case action of case action of
ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
_ -> (flts, []) _ -> (flts, [])
flts = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset", "and", "or"] k) ] flts =
[ (toS k, toS $ fromJust v) |
(k,v) <- qParams, isJust v,
k `notElem` ["select", "columns"],
not (endingIn ["order", "limit", "offset", "and", "or"] k) ]
hasOperator val = any (`T.isPrefixOf` val) $ hasOperator val = any (`T.isPrefixOf` val) $
((<> ".") <$> "not":M.keys operators) ++ ((<> ".") <$> "not":M.keys operators) ++
((<> "(") <$> M.keys ftsOperators) ((<> "(") <$> M.keys ftsOperators)
isEmbedPath = T.isInfixOf "." isEmbedPath = T.isInfixOf "."
isTargetingProc = (== Just "rpc") $ listToMaybe path isTargetingProc = case target of
TargetProc _ _ -> True
_ -> False
contentType = decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type"
columns | action `elem` [ActionCreate, ActionUpdate, ActionInvoke{isReadOnly=False}] = toS <$> join (lookup "columns" qParams)
| otherwise = Nothing
payload = payload =
case (decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type", action) of case (contentType, action) of
(_, ActionInvoke{isReadOnly=True}) -> (_, ActionInvoke{isReadOnly=True}) ->
Right $ PayloadJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) PJObject (S.fromList $ fst <$> rpcQParams) Right $ ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) PJObject (S.fromList $ fst <$> rpcQParams)
(CTApplicationJSON, _) -> (CTApplicationJSON, _) ->
note "All object keys must match" . payloadAttributes reqBody if isJust columns
=<< if BL.null reqBody && isTargetingProc then Right $ RawJSON reqBody
then Right emptyObject else note "All object keys must match" . payloadAttributes reqBody
else JSON.eitherDecode reqBody =<< if BL.null reqBody && isTargetingProc
then Right emptyObject
else JSON.eitherDecode reqBody
(CTTextCSV, _) -> do (CTTextCSV, _) -> do
json <- csvToJson <$> CSV.decodeByName reqBody json <- csvToJson <$> CSV.decodeByName reqBody
note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json
(CTOther "application/x-www-form-urlencoded", _) -> (CTOther "application/x-www-form-urlencoded", _) ->
let json = M.fromList . map (toS *** JSON.String . toS) . parseSimpleQuery $ toS reqBody let json = M.fromList . map (toS *** JSON.String . toS) . parseSimpleQuery $ toS reqBody
keys = S.fromList $ M.keys json in keys = S.fromList $ M.keys json in
Right $ PayloadJSON (JSON.encode json) PJObject keys Right $ ProcessedJSON (JSON.encode json) PJObject keys
(ct, _) -> (ct, _) ->
Left $ toS $ "Content-Type not acceptable: " <> toMime ct Left $ toS $ "Content-Type not acceptable: " <> toMime ct
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges
action = action =
case method of case method of
"GET" | target == TargetRoot -> ActionInspect "GET" | target == TargetDefaultSpec -> ActionInspect
| isTargetingProc -> ActionInvoke{isReadOnly=True} | isTargetingProc -> ActionInvoke{isReadOnly=True}
| otherwise -> ActionRead | otherwise -> ActionRead
@@ -177,19 +202,20 @@ userApiRequest schema req reqBody
"OPTIONS" -> ActionInfo "OPTIONS" -> ActionInfo
_ -> ActionInspect _ -> ActionInspect
target = case path of target = case path of
[] -> TargetRoot [] -> case rootSpec of
[table] -> TargetIdent Just rsQi -> TargetProc rsQi True
$ QualifiedIdentifier schema table Nothing -> TargetDefaultSpec
["rpc", proc] -> TargetProc [table] -> TargetIdent $ QualifiedIdentifier schema table
$ QualifiedIdentifier schema proc ["rpc", proc] -> TargetProc (QualifiedIdentifier schema proc) False
other -> TargetUnknown other other -> TargetUnknown other
shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke{isReadOnly=False}, ActionInvoke{isReadOnly=True}] shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke{isReadOnly=False}, ActionInvoke{isReadOnly=True}]
relevantPayload | shouldParsePayload = rightToMaybe payload relevantPayload | shouldParsePayload = rightToMaybe payload
| otherwise = Nothing | otherwise = Nothing
path = pathInfo req path = pathInfo req
method = requestMethod req method = requestMethod req
hdrs = requestHeaders req hdrs = requestHeaders req
qParams = [(toS k, v)|(k,v) <- queryString req] qParams = [(toS k, v)|(k,v) <- qString]
lookupHeader = flip lookup hdrs lookupHeader = flip lookup hdrs
hasPrefer :: Text -> Bool hasPrefer :: Text -> Bool
hasPrefer val = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs hasPrefer val = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs
@@ -237,23 +263,6 @@ mutuallyAgreeable sProduces cAccepts =
then listToMaybe sProduces then listToMaybe sProduces
else exact else exact
-- PRIVATE ---------------------------------------------------------------
{-|
Warning: discards MIME parameters
-}
decodeContentType :: BS.ByteString -> ContentType
decodeContentType ct =
case BS.takeWhile (/= BS.c2w ';') ct of
"application/json" -> CTApplicationJSON
"text/csv" -> CTTextCSV
"application/openapi+json" -> CTOpenAPI
"application/vnd.pgrst.object+json" -> CTSingularJSON
"application/vnd.pgrst.object" -> CTSingularJSON
"application/octet-stream" -> CTOctetStream
"*/*" -> CTAny
ct' -> CTOther ct'
type CsvData = V.Vector (M.HashMap Text BL.ByteString) type CsvData = V.Vector (M.HashMap Text BL.ByteString)
{-| {-|
@@ -291,14 +300,14 @@ payloadAttributes raw json =
JSON.Object x -> S.fromList (M.keys x) == canonicalKeys JSON.Object x -> S.fromList (M.keys x) == canonicalKeys
_ -> False) arr in _ -> False) arr in
if areKeysUniform if areKeysUniform
then Just $ PayloadJSON raw (PJArray $ V.length arr) canonicalKeys then Just $ ProcessedJSON raw (PJArray $ V.length arr) canonicalKeys
else Nothing else Nothing
Just _ -> Nothing Just _ -> Nothing
Nothing -> Just emptyPJArray Nothing -> Just emptyPJArray
JSON.Object o -> Just $ PayloadJSON raw PJObject (S.fromList $ M.keys o) JSON.Object o -> Just $ ProcessedJSON raw PJObject (S.fromList $ M.keys o)
-- truncate everything else to an empty array. -- truncate everything else to an empty array.
_ -> Just emptyPJArray _ -> Just emptyPJArray
where where
emptyPJArray = PayloadJSON (JSON.encode emptyArray) (PJArray 0) S.empty emptyPJArray = ProcessedJSON (JSON.encode emptyArray) (PJArray 0) S.empty
+176 -184
View File
@@ -1,107 +1,92 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NamedFieldPuns #-}
module PostgREST.App ( module PostgREST.App (
postgrest postgrest
) where ) where
import Control.Applicative import qualified Data.ByteString.Char8 as BS
import Data.Aeson as JSON import qualified Data.HashMap.Strict as M
import qualified Data.ByteString.Char8 as BS import qualified Data.List as L (union)
import Data.Maybe import qualified Data.Set as S
import Data.IORef (IORef, readIORef)
import Data.Text (intercalate)
import Data.Time.Clock (UTCTime)
import qualified Data.Set as S
import qualified Hasql.Pool as P import qualified Hasql.Pool as P
import qualified Hasql.Transaction as H
import qualified Hasql.Transaction as HT import qualified Hasql.Transaction as HT
import qualified Hasql.Transaction.Sessions as HT import qualified Hasql.Transaction.Sessions as HT
import Network.HTTP.Types.Header import Data.Aeson as JSON
import Network.HTTP.Types.Status import Data.Function (id)
import Network.HTTP.Types.URI (renderSimpleQuery) import Data.IORef (IORef, readIORef)
import Network.Wai import Data.Time.Clock (UTCTime)
import Network.Wai.Middleware.RequestLogger (logStdout) import Network.HTTP.Types.URI (renderSimpleQuery)
import Network.Wai.Middleware.RequestLogger (logStdout)
import qualified Hasql.Transaction as H import Control.Applicative
import Data.Maybe
import Network.HTTP.Types.Header
import Network.HTTP.Types.Status
import Network.Wai
import qualified Data.HashMap.Strict as M import PostgREST.ApiRequest (Action (..), ApiRequest (..),
ContentType (..),
import PostgREST.ApiRequest ( ApiRequest(..), ContentType(..) PreferRepresentation (..),
, Action(..), Target(..) Target (..), mutuallyAgreeable,
, PreferRepresentation (..) userApiRequest)
, mutuallyAgreeable import PostgREST.Auth (containsRole, jwtClaims,
, userApiRequest parseSecret)
) import PostgREST.Config (AppConfig (..))
import PostgREST.Auth (jwtClaims, containsRole, parseSecret) import PostgREST.DbRequestBuilder (fieldNames, mutateRequest,
import PostgREST.Config (AppConfig (..)) readRequest)
import PostgREST.DbStructure import PostgREST.DbStructure
import PostgREST.DbRequestBuilder( readRequest import PostgREST.Error (PgError (..), SimpleError (..),
, mutateRequest errorResponseFor, singularityError)
, fieldNames import PostgREST.Middleware
) import PostgREST.OpenAPI
import PostgREST.Error ( simpleError, pgError import PostgREST.Parsers (pRequestColumns)
, apiRequestError import PostgREST.QueryBuilder (ResultsWithCount, callProc,
, singularityError, binaryFieldError createReadStatement,
, connectionLostError, gucHeadersError createWriteStatement,
) requestToCountQuery,
import PostgREST.RangeQuery (allRange, rangeOffset) requestToQuery)
import PostgREST.Middleware import PostgREST.RangeQuery (allRange, rangeOffset)
import PostgREST.QueryBuilder ( callProc import PostgREST.Types
, requestToQuery import Protolude hiding (Proxy, intercalate)
, requestToCountQuery
, createReadStatement
, createWriteStatement
, ResultsWithCount
)
import PostgREST.Types
import PostgREST.OpenAPI
import Data.Function (id)
import Protolude hiding (intercalate, Proxy)
postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> IO () -> Application postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> IO () -> Application
postgrest conf refDbStructure pool getTime worker = postgrest conf refDbStructure pool getTime worker =
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle let middle = (if configQuiet conf then id else logStdout) . defaultMiddle
jwtSecret = parseSecret <$> configJwtSecret conf in jwtSecret = parseSecret <$> configJwtSecret conf in
middle $ \ req respond -> do middle $ \ req respond -> do
time <- getTime time <- getTime
body <- strictRequestBody req body <- strictRequestBody req
maybeDbStructure <- readIORef refDbStructure maybeDbStructure <- readIORef refDbStructure
case maybeDbStructure of case maybeDbStructure of
Nothing -> respond connectionLostError Nothing -> respond . errorResponseFor $ ConnectionLostError
Just dbStructure -> do Just dbStructure -> do
response <- case userApiRequest (configSchema conf) req body of response <- do
Left err -> return $ apiRequestError err -- Need to parse ?columns early because findProc needs it to solve overloaded functions
Right apiRequest -> do let apiReq = userApiRequest (configSchema conf) (configRootSpec conf) req body
eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) time (rightToMaybe $ configRoleClaimKey conf) apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
case apiReqCols of
let authed = containsRole eClaims Left err -> return . errorResponseFor $ err
proc = case (iTarget apiRequest, iPayload apiRequest, iPreferSingleObjectParameter apiRequest) of Right (apiRequest, maybeCols) -> do
(TargetProc qi, Just PayloadJSON{pjKeys}, s) -> findProc qi pjKeys s $ dbProcs dbStructure eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) time (rightToMaybe $ configRoleClaimKey conf)
_ -> Nothing let authed = containsRole eClaims
handleReq = runWithClaims conf eClaims (app dbStructure proc conf) apiRequest cols = case (iPayload apiRequest, maybeCols) of
txMode = transactionMode proc (iAction apiRequest) (Just ProcessedJSON{pjKeys}, _) -> pjKeys
response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq (Just RawJSON{}, Just cls) -> cls
return $ either (pgError authed) identity response _ -> S.empty
proc = case iTarget apiRequest of
TargetProc qi _ -> findProc qi cols (iPreferSingleObjectParameter apiRequest) $ dbProcs dbStructure
_ -> Nothing
handleReq = runWithClaims conf eClaims (app dbStructure proc cols conf) apiRequest
txMode = transactionMode proc (iAction apiRequest)
response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq
return $ either (errorResponseFor . PgError authed) identity response
when (responseStatus response == status503) worker when (responseStatus response == status503) worker
respond response respond response
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> M.HashMap Text [ProcDescription] -> Maybe ProcDescription
findProc qi payloadKeys paramsAsSingleObject allProcs =
let procs = M.lookup (qiName qi) allProcs in
-- Handle overloaded functions case
join $ (case length <$> procs of
Just 1 -> headMay -- if it's not an overloaded function then immediatly get the ProcDescription
_ -> find (\x ->
if paramsAsSingleObject
then length (pdArgs x) == 1 -- if the arg is not of json type let the db give the err
else payloadKeys `S.isSubsetOf` S.fromList (pgaName <$> pdArgs x))
) <$> procs
transactionMode :: Maybe ProcDescription -> Action -> HT.Mode transactionMode :: Maybe ProcDescription -> Action -> HT.Mode
transactionMode proc action = transactionMode proc action =
case action of case action of
@@ -109,23 +94,23 @@ transactionMode proc action =
ActionInfo -> HT.Read ActionInfo -> HT.Read
ActionInspect -> HT.Read ActionInspect -> HT.Read
ActionInvoke{isReadOnly=False} -> ActionInvoke{isReadOnly=False} ->
let v = maybe Volatile pdVolatility proc in let v = maybe Volatile pdVolatility proc in
if v == Stable || v == Immutable if v == Stable || v == Immutable
then HT.Read then HT.Read
else HT.Write else HT.Write
ActionInvoke{isReadOnly=True} -> HT.Read ActionInvoke{isReadOnly=True} -> HT.Read
_ -> HT.Write _ -> HT.Write
app :: DbStructure -> Maybe ProcDescription -> AppConfig -> ApiRequest -> H.Transaction Response app :: DbStructure -> Maybe ProcDescription -> S.Set FieldName -> AppConfig -> ApiRequest -> H.Transaction Response
app dbStructure proc conf apiRequest = app dbStructure proc cols conf apiRequest =
case responseContentTypeOrError (iAccepts apiRequest) (iAction apiRequest) of case responseContentTypeOrError (iAccepts apiRequest) rawContentTypes (iAction apiRequest) (iTarget apiRequest) of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right contentType -> Right contentType ->
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
(ActionRead, TargetIdent qi, Nothing) -> (ActionRead, TargetIdent qi, Nothing) ->
let partsField = (,) <$> readSqlParts let partsField = (,) <$> readSqlParts
<*> (binaryField contentType =<< fldNames) in <*> (binaryField contentType rawContentTypes =<< fldNames) in
case partsField of case partsField of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right ((q, cq), bField) -> do Right ((q, cq), bField) -> do
@@ -137,7 +122,7 @@ app dbStructure proc conf apiRequest =
canonical = iCanonicalQS apiRequest canonical = iCanonicalQS apiRequest
return $ return $
if contentType == CTSingularJSON && queryTotal /= 1 if contentType == CTSingularJSON && queryTotal /= 1
then singularityError (toInteger queryTotal) then errorResponseFor . singularityError $ queryTotal
else responseLBS status else responseLBS status
[toHeader contentType, contentRange, [toHeader contentType, contentRange,
("Content-Location", ("Content-Location",
@@ -146,83 +131,86 @@ app dbStructure proc conf apiRequest =
) )
] (toS body) ] (toS body)
(ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just PayloadJSON{pjRaw, pjType}) -> (ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
case mutateSqlParts tSchema tName of case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right (sq, mq) -> do Right (sq, mq) -> do
let (isSingle, nRows) = case pjType of let pkCols = tablePKCols dbStructure tSchema tName
PJArray len -> (len == 1, len) stm = createWriteStatement sq mq
PJObject -> (True, 1) (contentType == CTSingularJSON) True
if contentType == CTSingularJSON (contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols
&& not isSingle row <- H.statement (toS $ pjRaw pJson) stm
&& iPreferRepresentation apiRequest == Full let (_, queryTotal, fs, body) = extractQueryResult row
then return $ singularityError (toInteger nRows) headers = catMaybes [
else do if null fs
let pkCols = tablePKCols dbStructure tSchema tName then Nothing
stm = createWriteStatement sq mq else Just (hLocation, "/" <> toS tName <> renderLocationFields fs)
(contentType == CTSingularJSON) isSingle , if iPreferRepresentation apiRequest == Full
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols then Just $ toHeader contentType
row <- H.statement (toS pjRaw) stm else Nothing
let (_, _, fs, body) = extractQueryResult row , Just $ contentRangeH 1 0 $
headers = catMaybes [ if shouldCount then Just queryTotal else Nothing
if null fs , if null pkCols
then Nothing then Nothing
else Just (hLocation, "/" <> toS tName <> renderLocationFields fs) else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
, if iPreferRepresentation apiRequest == Full ]
then Just $ toHeader contentType
else Nothing
, Just . contentRangeH 1 0 $
toInteger <$> if shouldCount then Just nRows else Nothing
, if null pkCols
then Nothing
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
]
return . responseLBS status201 headers $
if iPreferRepresentation apiRequest == Full
then toS body else ""
(ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just p@PayloadJSON{pjRaw}) ->
case (mutateSqlParts tSchema tName, pjIsEmpty p, iPreferRepresentation apiRequest == Full) of
(Left errorResponse, _, _) -> return errorResponse
(_, True, True) -> return $ responseLBS status200 [contentRangeH 1 0 Nothing] "[]"
(_, True, False) -> return $ responseLBS status204 [contentRangeH 1 0 Nothing] ""
(Right (sq, mq), _, _) -> do
let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
(iPreferRepresentation apiRequest) []
row <- H.statement (toS pjRaw) stm
let (_, queryTotal, _, body) = extractQueryResult row
if contentType == CTSingularJSON if contentType == CTSingularJSON
&& queryTotal /= 1 && queryTotal /= 1
&& iPreferRepresentation apiRequest == Full && iPreferRepresentation apiRequest == Full
then do then do
HT.condemn HT.condemn
return $ singularityError (toInteger queryTotal) return . errorResponseFor . singularityError $ queryTotal
else do else
let r = contentRangeH 0 (toInteger $ queryTotal-1) return . responseLBS status201 headers $
(toInteger <$> if shouldCount then Just queryTotal else Nothing) if iPreferRepresentation apiRequest == Full
s = if iPreferRepresentation apiRequest == Full then toS body else ""
then status200
else status204
return $ if iPreferRepresentation apiRequest == Full
then responseLBS s [toHeader contentType, r] (toS body)
else responseLBS s [r] ""
(ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just PayloadJSON{pjRaw, pjType, pjKeys}) -> (ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse
Right (sq, mq) -> do
let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
(iPreferRepresentation apiRequest) []
row <- H.statement (toS $ pjRaw pJson) stm
let (_, queryTotal, _, body) = extractQueryResult row
updateIsNoOp = S.null cols
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $
if shouldCount then Just queryTotal else Nothing
minimalHeaders = [contentRangeHeader]
fullHeaders = toHeader contentType : minimalHeaders
status | queryTotal == 0 && not updateIsNoOp = status404
| iPreferRepresentation apiRequest == Full = status200
| otherwise = status204
case (contentType, iPreferRepresentation apiRequest) of
(CTSingularJSON, Full)
| queryTotal == 1 -> return $ responseLBS status fullHeaders (toS body)
| otherwise -> HT.condemn >> (return . errorResponseFor . singularityError) queryTotal
(_, Full) ->
return $ responseLBS status fullHeaders (toS body)
(_, _) ->
return $ responseLBS status minimalHeaders mempty
(ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just ProcessedJSON{pjRaw, pjType, pjKeys}) ->
case mutateSqlParts tSchema tName of case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right (sq, mq) -> do Right (sq, mq) -> do
let isSingle = case pjType of let isSingle = case pjType of
PJArray len -> len == 1 PJArray len -> len == 1
PJObject -> True PJObject -> True
colNames = colName <$> tableCols dbStructure tSchema tName colNames = colName <$> tableCols dbStructure tSchema tName
if topLevelRange /= allRange if topLevelRange /= allRange
then return $ simpleError status400 [] "Range header and limit/offset querystring parameters are not allowed for PUT" then return . errorResponseFor $ PutRangeNotAllowedError
else if not isSingle else if not isSingle
then return $ simpleError status400 [] "PUT payload must contain a single row" then return . errorResponseFor $ PutSingletonError
else if S.fromList colNames /= pjKeys else if S.fromList colNames /= pjKeys
then return $ simpleError status400 [] "You must specify all columns in the payload when using PUT" then return . errorResponseFor $ PutPayloadIncompleteError
else do else do
row <- H.statement (toS pjRaw) $ row <- H.statement (toS pjRaw) $
createWriteStatement sq mq (contentType == CTSingularJSON) False createWriteStatement sq mq (contentType == CTSingularJSON) False
@@ -234,7 +222,7 @@ app dbStructure proc conf apiRequest =
if queryTotal /= 1 if queryTotal /= 1
then do then do
HT.condemn HT.condemn
return $ simpleError status400 [] "Payload values do not match URL in primary key column(s)" return . errorResponseFor $ PutMatchingPkError
else else
return $ if iPreferRepresentation apiRequest == Full return $ if iPreferRepresentation apiRequest == Full
then responseLBS status200 [toHeader contentType] (toS body) then responseLBS status200 [toHeader contentType] (toS body)
@@ -251,13 +239,13 @@ app dbStructure proc conf apiRequest =
row <- H.statement mempty stm row <- H.statement mempty stm
let (_, queryTotal, _, body) = extractQueryResult row let (_, queryTotal, _, body) = extractQueryResult row
r = contentRangeH 1 0 $ r = contentRangeH 1 0 $
toInteger <$> if shouldCount then Just queryTotal else Nothing if shouldCount then Just queryTotal else Nothing
if contentType == CTSingularJSON if contentType == CTSingularJSON
&& queryTotal /= 1 && queryTotal /= 1
&& iPreferRepresentation apiRequest == Full && iPreferRepresentation apiRequest == Full
then do then do
HT.condemn HT.condemn
return $ singularityError (toInteger queryTotal) return . errorResponseFor . singularityError $ queryTotal
else else
return $ if iPreferRepresentation apiRequest == Full return $ if iPreferRepresentation apiRequest == Full
then responseLBS status200 [toHeader contentType, r] (toS body) then responseLBS status200 [toHeader contentType, r] (toS body)
@@ -271,42 +259,38 @@ app dbStructure proc conf apiRequest =
let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in
return $ responseLBS status200 [allOrigins, acceptH] "" return $ responseLBS status200 [allOrigins, acceptH] ""
(ActionInvoke _, TargetProc qi, Just PayloadJSON{pjRaw, pjType, pjKeys}) -> (ActionInvoke _, TargetProc qi _, Just pJson) ->
let returnsScalar = case proc of let returnsScalar = case proc of
Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True
_ -> False _ -> False
rpcBinaryField = if returnsScalar rpcBinaryField = if returnsScalar
then Right Nothing then Right Nothing
else binaryField contentType =<< fldNames else binaryField contentType rawContentTypes =<< fldNames
parts = (,) <$> readSqlParts <*> rpcBinaryField in parts = (,) <$> readSqlParts <*> rpcBinaryField in
case parts of case parts of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right ((q, cq), bField) -> do Right ((q, cq), bField) -> do
let isObject = case pjType of let singular = contentType == CTSingularJSON
PJObject -> True row <- H.statement (toS $ pjRaw pJson) $
PJArray _ -> False callProc qi (specifiedProcArgs cols proc) returnsScalar q cq shouldCount
singular = contentType == CTSingularJSON
specifiedPgArgs = filter ((`S.member` pjKeys) . pgaName) $ maybe [] pdArgs proc
row <- H.statement (toS pjRaw) $
callProc qi specifiedPgArgs returnsScalar q cq shouldCount
singular (iPreferSingleObjectParameter apiRequest) singular (iPreferSingleObjectParameter apiRequest)
(contentType == CTTextCSV) (contentType == CTTextCSV)
(contentType == CTOctetStream) bField isObject (contentType `elem` rawContentTypes) bField
(pgVersion dbStructure) (pgVersion dbStructure)
let (tableTotal, queryTotal, body, jsonHeaders) = let (tableTotal, queryTotal, body, jsonHeaders) =
fromMaybe (Just 0, 0, "[]", "[]") row fromMaybe (Just 0, 0, "[]", "[]") row
(status, contentRange) = rangeHeader queryTotal tableTotal (status, contentRange) = rangeHeader queryTotal tableTotal
decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader] decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader]
case decodedHeaders of case decodedHeaders of
Left _ -> return gucHeadersError Left _ -> return . errorResponseFor $ GucHeadersError
Right hs -> Right hs ->
if singular && queryTotal /= 1 if singular && queryTotal /= 1
then do then do
HT.condemn HT.condemn
return $ singularityError (toInteger queryTotal) return . errorResponseFor . singularityError $ queryTotal
else return $ responseLBS status ([toHeader contentType, contentRange] ++ toHeaders hs) (toS body) else return $ responseLBS status ([toHeader contentType, contentRange] ++ toHeaders hs) (toS body)
(ActionInspect, TargetRoot, Nothing) -> do (ActionInspect, TargetDefaultSpec, Nothing) -> do
let host = configHost conf let host = configHost conf
port = toInteger $ configPort conf port = toInteger $ configPort conf
proxy = pickProxy $ toS <$> configProxyUri conf proxy = pickProxy $ toS <$> configProxyUri conf
@@ -316,6 +300,7 @@ app dbStructure proc conf apiRequest =
toTableInfo :: [Table] -> [(Table, [Column], [Text])] toTableInfo :: [Table] -> [(Table, [Column], [Text])]
toTableInfo = map (\t -> let (s, tn) = (tableSchema t, tableName t) in (t, tableCols dbStructure s tn, tablePKCols dbStructure s tn)) toTableInfo = map (\t -> let (s, tn) = (tableSchema t, tableName t) in (t, tableCols dbStructure s tn, tablePKCols dbStructure s tn))
encodeApi ti sd procs = encodeOpenAPI (concat $ M.elems procs) (toTableInfo ti) uri' sd $ dbPrimaryKeys dbStructure encodeApi ti sd procs = encodeOpenAPI (concat $ M.elems procs) (toTableInfo ti) uri' sd $ dbPrimaryKeys dbStructure
body <- encodeApi <$> H.statement schema accessibleTables <*> H.statement schema schemaDescription <*> H.statement schema accessibleProcs body <- encodeApi <$> H.statement schema accessibleTables <*> H.statement schema schemaDescription <*> H.statement schema accessibleProcs
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
@@ -340,39 +325,46 @@ app dbStructure proc conf apiRequest =
selectQuery = requestToQuery schema False <$> readDbRequest selectQuery = requestToQuery schema False <$> readDbRequest
countQuery = requestToCountQuery schema <$> readDbRequest countQuery = requestToCountQuery schema <$> readDbRequest
readSqlParts = (,) <$> selectQuery <*> countQuery readSqlParts = (,) <$> selectQuery <*> countQuery
mutationDbRequest s t = mutateRequest apiRequest t cols (tablePKCols dbStructure s t) =<< fldNames
mutateSqlParts s t = mutateSqlParts s t =
(,) <$> selectQuery (,) <$> selectQuery
<*> (requestToQuery schema False . DbMutate <$> (mutateRequest apiRequest t (tablePKCols dbStructure s t) =<< fldNames)) <*> (requestToQuery schema False . DbMutate <$> mutationDbRequest s t)
rawContentTypes =
(decodeContentType <$> configRawMediaTypes conf) `L.union`
[ CTOctetStream, CTTextPlain ]
responseContentTypeOrError :: [ContentType] -> Action -> Either Response ContentType responseContentTypeOrError :: [ContentType] -> [ContentType] -> Action -> Target -> Either Response ContentType
responseContentTypeOrError accepts action = serves contentTypesForRequest accepts responseContentTypeOrError accepts rawContentTypes action target = serves contentTypesForRequest accepts
where where
contentTypesForRequest = contentTypesForRequest = case action of
case action of ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream] ++ rawContentTypes
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream] ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
ActionInspect -> [CTOpenAPI, CTApplicationJSON] ++ rawContentTypes
ActionInfo -> [CTTextCSV] ++ [CTOpenAPI | tpIsRootSpec target]
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ActionInspect -> [CTOpenAPI, CTApplicationJSON]
ActionInfo -> [CTTextCSV]
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
serves sProduces cAccepts = serves sProduces cAccepts =
case mutuallyAgreeable sProduces cAccepts of case mutuallyAgreeable sProduces cAccepts of
Nothing -> do Nothing -> Left . errorResponseFor . ContentTypeError . map toMime $ cAccepts
let failed = intercalate ", " $ map (toS . toMime) cAccepts
Left $ simpleError status415 [] $
"None of these Content-Types are available: " <> failed
Just ct -> Right ct Just ct -> Right ct
binaryField :: ContentType -> [FieldName] -> Either Response (Maybe FieldName) {-
binaryField CTOctetStream fldNames = | If raw(binary) output is requested, check that ContentType is one of the admitted rawContentTypes and that
if length fldNames == 1 && fieldName /= Just "*" | `?select=...` contains only one field other than `*`
then Right fieldName -}
else Left binaryFieldError binaryField :: ContentType -> [ContentType]-> [FieldName] -> Either Response (Maybe FieldName)
where binaryField ct rawContentTypes fldNames
fieldName = headMay fldNames | ct `elem` rawContentTypes =
binaryField _ _ = Right Nothing let fieldName = headMay fldNames in
if length fldNames == 1 && fieldName /= Just "*"
then Right fieldName
else Left . errorResponseFor $ BinaryFieldError ct
| otherwise = Right Nothing
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString) splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
splitKeyValue kv = (k, BS.tail v) splitKeyValue kv = (k, BS.tail v)
@@ -389,7 +381,7 @@ rangeStatus lower upper (Just total)
| (1 + upper - lower) < total = status206 | (1 + upper - lower) < total = status206
| otherwise = status200 | otherwise = status200
contentRangeH :: Integer -> Integer -> Maybe Integer -> Header contentRangeH :: (Integral a, Show a) => a -> a -> Maybe a -> Header
contentRangeH lower upper total = contentRangeH lower upper total =
("Content-Range", headerValue) ("Content-Range", headerValue)
where where
+13 -11
View File
@@ -1,5 +1,5 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE LambdaCase #-}
{-| {-|
Module : PostgREST.Auth Module : PostgREST.Auth
Description : PostgREST authorization functions. Description : PostgREST authorization functions.
@@ -19,17 +19,19 @@ module PostgREST.Auth (
, parseSecret , parseSecret
) where ) where
import Control.Lens.Operators import qualified Crypto.JOSE.Types as JOSE.Types
import Control.Lens (set) import qualified Data.Aeson as JSON
import qualified Data.Aeson as JSON import qualified Data.HashMap.Strict as M
import qualified Data.HashMap.Strict as M import Data.Vector as V
import Data.Time.Clock (UTCTime)
import Data.Vector as V
import PostgREST.Types
import Protolude
import qualified Crypto.JOSE.Types as JOSE.Types import Control.Lens (set)
import Crypto.JWT import Data.Time.Clock (UTCTime)
import Control.Lens.Operators
import Crypto.JWT
import PostgREST.Types
import Protolude
{-| {-|
Possible situations encountered with client JWTs Possible situations encountered with client JWTs
+132 -86
View File
@@ -1,5 +1,3 @@
{-# LANGUAGE LambdaCase, TemplateHaskell #-}
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
{-| {-|
Module : PostgREST.Config Module : PostgREST.Config
Description : Manages PostgREST configuration options. Description : Manages PostgREST configuration options.
@@ -14,51 +12,58 @@ turned in configurable behaviour if needed.
Other hardcoded options such as the minimum version number also belong here. Other hardcoded options such as the minimum version number also belong here.
-} -}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
module PostgREST.Config ( prettyVersion module PostgREST.Config ( prettyVersion
, docsVersion , docsVersion
, readOptions , readOptions
, corsPolicy , corsPolicy
, AppConfig (..) , AppConfig (..)
, configPoolTimeout'
) )
where where
import Control.Applicative
import Control.Monad (fail)
import Control.Lens (preview)
import Crypto.JWT (StringOrURI,
stringOrUri)
import qualified Data.ByteString as B import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import qualified Data.CaseInsensitive as CI import qualified Data.CaseInsensitive as CI
import qualified Data.Configurator as C import qualified Data.Configurator as C
import qualified Data.Configurator.Parser as C
import Data.Configurator.Types as C
import Data.List (lookup)
import Data.Monoid
import Data.Scientific (floatingOrInteger)
import Data.String (String)
import Data.Text (dropAround,
intercalate, lines,
strip, take, splitOn)
import Data.Text.Encoding (encodeUtf8)
import Data.Text.IO (hPutStrLn)
import Data.Version (versionBranch)
import Development.GitRev (gitHash)
import Network.Wai
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
import Options.Applicative hiding (str)
import Paths_postgrest (version)
import PostgREST.Parsers (pRoleClaimKey)
import PostgREST.Types (ApiRequestError(..),
JSPath, JSPathExp(..))
import Protolude hiding (hPutStrLn, take,
intercalate, (<>))
import System.IO (hPrint)
import System.IO.Error (IOError)
import Text.Heredoc
import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>))
import qualified Text.PrettyPrint.ANSI.Leijen as L import qualified Text.PrettyPrint.ANSI.Leijen as L
import Control.Exception (Handler (..))
import Control.Lens (preview)
import Control.Monad (fail)
import Crypto.JWT (StringOrURI, stringOrUri)
import Data.List (lookup)
import Data.Scientific (floatingOrInteger)
import Data.Text (dropEnd, dropWhileEnd,
intercalate, lines, splitOn,
strip, take, unpack)
import Data.Text.Encoding (encodeUtf8)
import Data.Text.IO (hPutStrLn)
import Data.Version (versionBranch)
import Development.GitRev (gitHash)
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
import Paths_postgrest (version)
import System.IO.Error (IOError)
import Control.Applicative
import Data.Monoid
import Network.Wai
import Options.Applicative hiding (str)
import Text.Heredoc
import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>))
import PostgREST.Error (ApiRequestError (..))
import PostgREST.Parsers (pRoleClaimKey)
import PostgREST.Types (JSPath, JSPathExp (..),
QualifiedIdentifier (..))
import Protolude hiding (concat, hPutStrLn, intercalate, null,
take, (<>))
-- | Config file settings for the server -- | Config file settings for the server
data AppConfig = AppConfig { data AppConfig = AppConfig {
configDatabase :: Text configDatabase :: Text
@@ -67,20 +72,30 @@ data AppConfig = AppConfig {
, configSchema :: Text , configSchema :: Text
, configHost :: Text , configHost :: Text
, configPort :: Int , configPort :: Int
, configSocket :: Maybe Text
, configJwtSecret :: Maybe B.ByteString , configJwtSecret :: Maybe B.ByteString
, configJwtSecretIsBase64 :: Bool , configJwtSecretIsBase64 :: Bool
, configJwtAudience :: Maybe StringOrURI , configJwtAudience :: Maybe StringOrURI
, configPool :: Int , configPool :: Int
, configPoolTimeout :: Int
, configMaxRows :: Maybe Integer , configMaxRows :: Maybe Integer
, configReqCheck :: Maybe Text , configReqCheck :: Maybe Text
, configQuiet :: Bool , configQuiet :: Bool
, configSettings :: [(Text, Text)] , configSettings :: [(Text, Text)]
, configRoleClaimKey :: Either ApiRequestError JSPath , configRoleClaimKey :: Either ApiRequestError JSPath
, configExtraSearchPath :: [Text] , configExtraSearchPath :: [Text]
, configRootSpec :: Maybe QualifiedIdentifier
, configRawMediaTypes :: [B.ByteString]
} }
configPoolTimeout' :: (Fractional a) => AppConfig -> a
configPoolTimeout' =
fromRational . toRational . configPoolTimeout
defaultCorsPolicy :: CorsResourcePolicy defaultCorsPolicy :: CorsResourcePolicy
defaultCorsPolicy = CorsResourcePolicy Nothing defaultCorsPolicy = CorsResourcePolicy Nothing
["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] ["Authorization"] Nothing ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] ["Authorization"] Nothing
@@ -112,7 +127,7 @@ prettyVersion =
-- | Version number used in docs -- | Version number used in docs
docsVersion :: Text docsVersion :: Text
docsVersion = "v" <> dropAround (== '.') (dropAround (/= '.') prettyVersion) docsVersion = "v" <> dropEnd 1 (dropWhileEnd (/= '.') prettyVersion)
-- | Function to read and parse options from the command line -- | Function to read and parse options from the command line
readOptions :: IO AppConfig readOptions :: IO AppConfig
@@ -120,67 +135,87 @@ readOptions = do
-- First read the config file path from command line -- First read the config file path from command line
cfgPath <- customExecParser parserPrefs opts cfgPath <- customExecParser parserPrefs opts
-- Now read the actual config file -- Now read the actual config file
conf <- catch conf <- catches (C.load cfgPath)
(C.readConfig =<< C.load [C.Required cfgPath]) [ Handler (\(ex :: IOError) -> exitErr $ "Cannot open config file:\n\t" <> show ex)
configNotfoundHint , Handler (\(C.ParseError err) -> exitErr $ "Error parsing config file:\n\t" <> err)
]
let (mAppConf, errs) = flip C.runParserM conf $ case C.runParser parseConfig conf of
AppConfig Left err ->
<$> C.key "db-uri" exitErr $ "Error parsing config file:\n\t" <> err
<*> C.key "db-anon-role" Right appConf ->
<*> (mfilter (/= "") <$> C.key "server-proxy-uri")
<*> C.key "db-schema"
<*> (fromMaybe "127.0.0.1" . mfilter (/= "") <$> C.key "server-host")
<*> (fromMaybe 3000 . join . fmap coerceInt <$> C.key "server-port")
<*> (fmap encodeUtf8 . mfilter (/= "") <$> C.key "jwt-secret")
<*> (fromMaybe False . join . fmap coerceBool <$> C.key "secret-is-base64")
<*> parseJwtAudience "jwt-aud"
<*> (fromMaybe 10 . join . fmap coerceInt <$> C.key "db-pool")
<*> (join . fmap coerceInt <$> C.key "max-rows")
<*> (mfilter (/= "") <$> C.key "pre-request")
<*> pure False
<*> (fmap (fmap coerceText) <$> C.subassocs "app.settings")
<*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> C.key "role-claim-key")
<*> (maybe ["public"] splitExtraSearchPath <$> C.key "db-extra-search-path")
case mAppConf of
Nothing -> do
forM_ errs $ hPrint stderr
exitFailure
Just appConf ->
return appConf return appConf
where where
parseJwtAudience :: Name -> C.ConfigParserM (Maybe StringOrURI) dbSchema = reqString "db-schema"
parseConfig =
AppConfig
<$> reqString "db-uri"
<*> reqString "db-anon-role"
<*> optString "server-proxy-uri"
<*> dbSchema
<*> (fromMaybe "!4" <$> optString "server-host")
<*> (fromMaybe 3000 <$> optInt "server-port")
<*> optString "server-unix-socket"
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
<*> (fromMaybe False <$> optBool "secret-is-base64")
<*> parseJwtAudience "jwt-aud"
<*> (fromMaybe 10 <$> optInt "db-pool")
<*> (fromMaybe 10 <$> optInt "db-pool-timeout")
<*> optInt "max-rows"
<*> optString "pre-request"
<*> pure False
<*> (fmap (fmap coerceText) <$> C.subassocs "app.settings" C.value)
<*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> optValue "role-claim-key")
<*> (maybe ["public"] splitOnCommas <$> optValue "db-extra-search-path")
<*> ((\x y -> QualifiedIdentifier x <$> y) <$> dbSchema <*> optString "root-spec")
<*> (maybe [] (fmap encodeUtf8 . splitOnCommas) <$> optValue "raw-media-types")
parseJwtAudience :: C.Key -> C.Parser C.Config (Maybe StringOrURI)
parseJwtAudience k = parseJwtAudience k =
C.key k >>= \case C.optional k C.string >>= \case
Nothing -> pure Nothing -- no audience in config file Nothing -> pure Nothing -- no audience in config file
Just aud -> case preview stringOrUri (aud :: String) of Just aud -> case preview stringOrUri (unpack aud) of
Nothing -> fail "Invalid Jwt audience. Check your configuration." Nothing -> fail "Invalid Jwt audience. Check your configuration."
(Just "") -> pure Nothing (Just "") -> pure Nothing
aud' -> pure aud' aud' -> pure aud'
coerceText :: Value -> Text reqString :: C.Key -> C.Parser C.Config Text
coerceText (String s) = s reqString k = C.required k C.string
coerceText v = show v
coerceInt :: (Read i, Integral i) => Value -> Maybe i optString :: C.Key -> C.Parser C.Config (Maybe Text)
coerceInt (Number x) = rightToMaybe $ floatingOrInteger x optString k = mfilter (/= "") <$> C.optional k C.string
coerceInt (String x) = readMaybe $ toS x
coerceInt _ = Nothing
coerceBool :: Value -> Maybe Bool optValue :: C.Key -> C.Parser C.Config (Maybe C.Value)
coerceBool (Bool b) = Just b optValue k = C.optional k C.value
coerceBool (String b) = readMaybe $ toS b
coerceBool _ = Nothing
parseRoleClaimKey :: Value -> Either ApiRequestError JSPath optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)
parseRoleClaimKey (String s) = pRoleClaimKey s optInt k = join <$> C.optional k (coerceInt <$> C.value)
parseRoleClaimKey v = pRoleClaimKey $ show v
splitExtraSearchPath :: Value -> [Text] optBool :: C.Key -> C.Parser C.Config (Maybe Bool)
splitExtraSearchPath (String s) = strip <$> splitOn "," s optBool k = join <$> C.optional k (coerceBool <$> C.value)
splitExtraSearchPath _ = []
coerceText :: C.Value -> Text
coerceText (C.String s) = s
coerceText v = show v
coerceInt :: (Read i, Integral i) => C.Value -> Maybe i
coerceInt (C.Number x) = rightToMaybe $ floatingOrInteger x
coerceInt (C.String x) = readMaybe $ toS x
coerceInt _ = Nothing
coerceBool :: C.Value -> Maybe Bool
coerceBool (C.Bool b) = Just b
coerceBool (C.String b) = readMaybe $ toS b
coerceBool _ = Nothing
parseRoleClaimKey :: C.Value -> Either ApiRequestError JSPath
parseRoleClaimKey (C.String s) = pRoleClaimKey s
parseRoleClaimKey v = pRoleClaimKey $ show v
splitOnCommas :: C.Value -> [Text]
splitOnCommas (C.String s) = strip <$> splitOn "," s
splitOnCommas _ = []
opts = info (helper <*> pathParser) $ opts = info (helper <*> pathParser) $
fullDesc fullDesc
@@ -196,10 +231,9 @@ readOptions = do
parserPrefs = prefs showHelpOnError parserPrefs = prefs showHelpOnError
configNotfoundHint :: IOError -> IO a exitErr :: Text -> IO a
configNotfoundHint e = do exitErr err = do
hPutStrLn stderr $ hPutStrLn stderr err
"Cannot open config file:\n\t" <> show e
exitFailure exitFailure
exampleCfg :: Doc exampleCfg :: Doc
@@ -208,10 +242,15 @@ readOptions = do
|db-schema = "public" # this schema gets added to the search_path of every request |db-schema = "public" # this schema gets added to the search_path of every request
|db-anon-role = "postgres" |db-anon-role = "postgres"
|db-pool = 10 |db-pool = 10
|db-pool-timeout = 10
| |
|server-host = "127.0.0.1" |server-host = "!4"
|server-port = 3000 |server-port = 3000
| |
|## unix socket location
|## if specified it takes precedence over server-port
|# server-unix-socket = "/tmp/pgrst.sock"
|
|## base url for swagger output |## base url for swagger output
|# server-proxy-uri = "" |# server-proxy-uri = ""
| |
@@ -232,6 +271,13 @@ readOptions = do
| |
|## extra schemas to add to the search_path of every request |## extra schemas to add to the search_path of every request
|# db-extra-search-path = "extensions, util" |# db-extra-search-path = "extensions, util"
|
|## stored proc that overrides the root "/" spec
|## it must be inside the db-schema
|# root-spec = "stored_proc_name"
|
|## content types to produce raw output
|# raw-media-types="image/png, image/jpg"
|] |]
pathParser :: Parser FilePath pathParser :: Parser FilePath
+82 -64
View File
@@ -1,48 +1,53 @@
{-# LANGUAGE FlexibleContexts #-} {-|
{-# LANGUAGE DuplicateRecordFields#-} Module : PostgREST.DbRequestBuilder
{-# LANGUAGE LambdaCase #-} Description : PostgREST database request builder
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-} This module is in charge of building an intermediate representation(ReadRequest, MutateRequest) between the HTTP request and the final resulting SQL query.
A query tree is built in case of resource embedding. By inferring the relationship between tables, join conditions are added for every embedded resource.
-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
module PostgREST.DbRequestBuilder ( module PostgREST.DbRequestBuilder (
readRequest readRequest
, mutateRequest , mutateRequest
, fieldNames , fieldNames
) where ) where
import Control.Applicative import qualified Data.ByteString.Char8 as BS
import Control.Arrow ((***)) import qualified Data.HashMap.Strict as M
import Control.Lens.Getter (view) import qualified Data.Set as S
import Control.Lens.Tuple (_1)
import qualified Data.ByteString.Char8 as BS
import Data.List (delete)
import Data.Maybe (fromJust)
import qualified Data.Set as S
import Data.Text (isInfixOf)
import Data.Tree
import Data.Either.Combinators (mapLeft)
import Network.Wai import Control.Arrow ((***))
import Control.Lens.Getter (view)
import Control.Lens.Tuple (_1)
import Data.Either.Combinators (mapLeft)
import Data.Foldable (foldr1)
import Data.List (delete)
import Data.Maybe (fromJust)
import Data.Text (isInfixOf)
import Text.Regex.TDFA ((=~))
import Unsafe (unsafeHead)
import Data.Foldable (foldr1) import Control.Applicative
import qualified Data.HashMap.Strict as M import Data.Tree
import Network.Wai
import PostgREST.ApiRequest ( ApiRequest(..) import PostgREST.ApiRequest (Action (..), ApiRequest (..),
, PreferRepresentation(..) PreferRepresentation (..),
, Action(..), Target(..) PreferRepresentation (..), Target (..))
, PreferRepresentation (..) import PostgREST.Error (ApiRequestError (..), errorResponseFor)
) import PostgREST.Parsers
import PostgREST.Error (apiRequestError) import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange)
import PostgREST.Parsers import PostgREST.Types
import PostgREST.RangeQuery (NonnegRange, restrictRange, allRange) import Protolude hiding (from)
import PostgREST.Types
import Protolude hiding (from)
import Text.Regex.TDFA ((=~))
import Unsafe (unsafeHead)
readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest
readRequest maxRows allRels proc apiRequest = readRequest maxRows allRels proc apiRequest =
mapLeft apiRequestError $ mapLeft errorResponseFor $
treeRestrictRange maxRows =<< treeRestrictRange maxRows =<<
augumentRequestWithJoin schema relations =<< augumentRequestWithJoin schema relations =<<
addFiltersOrdersRanges apiRequest <*> addFiltersOrdersRanges apiRequest <*>
@@ -53,12 +58,12 @@ readRequest maxRows allRels proc apiRequest =
let target = iTarget apiRequest in let target = iTarget apiRequest in
case target of case target of
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t) (TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
(TargetProc (QualifiedIdentifier s pName) ) -> Just (s, tName) (TargetProc (QualifiedIdentifier s pName) _ ) -> Just (s, tName)
where where
tName = case pdReturnType <$> proc of tName = case pdReturnType <$> proc of
Just (SetOf (Composite qi)) -> qiName qi Just (SetOf (Composite qi)) -> qiName qi
Just (Single (Composite qi)) -> qiName qi Just (Single (Composite qi)) -> qiName qi
_ -> pName _ -> pName
_ -> Nothing _ -> Nothing
@@ -68,7 +73,7 @@ readRequest maxRows allRels proc apiRequest =
buildReadRequest fieldTree = buildReadRequest fieldTree =
let rootDepth = 0 let rootDepth = 0
rootNodeName = if action == ActionRead then rootTableName else sourceCTEName in rootNodeName = if action == ActionRead then rootTableName else sourceCTEName in
foldr (treeEntry rootDepth) (Node (Select [] [rootNodeName] [] [] [] allRange, (rootNodeName, Nothing, Nothing, Nothing, rootDepth)) []) fieldTree foldr (treeEntry rootDepth) (Node (Select [] rootNodeName Nothing [] [] [] [] allRange, (rootNodeName, Nothing, Nothing, Nothing, rootDepth)) []) fieldTree
where where
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
treeEntry depth (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) = treeEntry depth (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) =
@@ -76,7 +81,7 @@ readRequest maxRows allRels proc apiRequest =
case fldForest of case fldForest of
[] -> Node (q {select=fld:select q}, i) rForest [] -> Node (q {select=fld:select q}, i) rForest
_ -> Node (q, i) $ _ -> Node (q, i) $
foldr (treeEntry nxtDepth) (Node (Select [] [fn] [] [] [] allRange, (fn, Nothing, alias, relationDetail, nxtDepth)) []) fldForest:rForest foldr (treeEntry nxtDepth) (Node (Select [] fn Nothing [] [] [] [] allRange, (fn, Nothing, alias, relationDetail, nxtDepth)) []) fldForest:rForest
relations :: [Relation] relations :: [Relation]
relations = case action of relations = case action of
@@ -84,7 +89,7 @@ readRequest maxRows allRels proc apiRequest =
ActionUpdate -> fakeSourceRelations ++ allRels ActionUpdate -> fakeSourceRelations ++ allRels
ActionDelete -> fakeSourceRelations ++ allRels ActionDelete -> fakeSourceRelations ++ allRels
ActionInvoke _ -> fakeSourceRelations ++ allRels ActionInvoke _ -> fakeSourceRelations ++ allRels
_ -> allRels _ -> allRels
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels
-- in a relation where one of the tables matches "TableName" -- in a relation where one of the tables matches "TableName"
@@ -108,13 +113,13 @@ treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` re
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
augumentRequestWithJoin schema allRels request = augumentRequestWithJoin schema allRels request =
addRelations schema allRels Nothing request addRelations schema allRels Nothing request
>>= addJoinConditions schema >>= addJoinConditions schema Nothing
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
addRelations schema allRelations parentNode (Node (query, (nodeName, _, alias, relationDetail, depth)) forest) = addRelations schema allRelations parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, relationDetail, depth)) forest) =
case parentNode of case parentNode of
Just (Node (Select{from=[parentNodeTable]}, _) _) -> Just (Node (Select{from=parentNodeTable}, _) _) ->
let newFrom r = (\tName -> if tName == nodeName then tableName (relTable r) else tName) <$> from query let newFrom r = if tbl == nodeName then tableName (relTable r) else tbl
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
rel :: Either ApiRequestError Relation rel :: Either ApiRequestError Relation
rel = note (NoRelationBetween parentNodeTable nodeName) $ rel = note (NoRelationBetween parentNodeTable nodeName) $
@@ -208,35 +213,49 @@ findRelation schema allRelations nodeTableName parentNodeTableName relationDetai
) )
) allRelations ) allRelations
addJoinConditions :: Schema -> ReadRequest -> Either ApiRequestError ReadRequest -- previousAlias is only used for the case of self joins
addJoinConditions schema (Node node@(query, nodeProps@(_, relation, _, _, _)) forest) = addJoinConditions :: Schema -> Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
addJoinConditions schema previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, relation, _, _, depth)) forest) =
case relation of case relation of
Just Relation{relType=Root} -> Node node <$> updatedForest -- this is the root node Just Relation{relType=Root} -> Node node <$> updatedForest -- this is the root node
Just rel@Relation{relType=Parent} -> Node (augmentQuery rel, nodeProps) <$> updatedForest Just rel@Relation{relType=Parent} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
Just rel@Relation{relType=Child} -> Node (augmentQuery rel, nodeProps) <$> updatedForest Just rel@Relation{relType=Child} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
Just rel@Relation{relType=Many, relLinkTable=(Just linkTable)} -> Just rel@Relation{relType=Many, relLinkTable=(Just linkTable)} ->
let rq = augmentQuery rel in let rq = augmentQuery rel in
Node (rq{from=tableName linkTable:from rq}, nodeProps) <$> updatedForest Node (rq{implicitJoins=tableName linkTable:implicitJoins rq}, nodeProps) <$> updatedForest
_ -> Left UnknownRelation _ -> Left UnknownRelation
where where
updatedForest = mapM (addJoinConditions schema) forest newAlias = case isSelfJoin <$> relation of
augmentQuery rel = foldr addJoinCond query (getJoinConditions rel) Just True
addJoinCond :: JoinCondition -> ReadQuery -> ReadQuery | depth /= 0 -> Just (tbl <> "_" <> show depth) -- root node doesn't get aliased
addJoinCond jc rq@Select{joinConditions=jcs} = rq{joinConditions=jc:jcs} | otherwise -> Nothing
_ -> Nothing
augmentQuery rel =
foldr
(\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})
query{fromAlias=newAlias}
(getJoinConditions previousAlias newAlias rel)
updatedForest = mapM (addJoinConditions schema newAlias) forest
getJoinConditions :: Relation -> [JoinCondition] -- previousAlias and newAlias are used in the case of self joins
getJoinConditions (Relation Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols typ lt lc1 lc2) = getJoinConditions :: Maybe Alias -> Maybe Alias -> Relation -> [JoinCondition]
if | typ == Child || typ == Parent -> getJoinConditions previousAlias newAlias (Relation Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols typ lt lc1 lc2) =
case typ of
Child ->
zipWith (toJoinCondition tN ftN) cols fCols zipWith (toJoinCondition tN ftN) cols fCols
| typ == Many -> Parent ->
zipWith (toJoinCondition tN ftN) cols fCols
Many ->
let ltN = maybe "" tableName lt in let ltN = maybe "" tableName lt in
zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fCols (fromMaybe [] lc2) zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fCols (fromMaybe [] lc2)
| typ == Root -> witness Root -> witness
where where
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
toJoinCondition tb ftb c fc = toJoinCondition tb ftb c fc =
JoinCondition (QualifiedIdentifier tSchema tb, Nothing, colName c) let qi1 = QualifiedIdentifier tSchema tb
(QualifiedIdentifier tSchema ftb, Nothing, colName fc) qi2 = QualifiedIdentifier tSchema ftb in
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) newAlias, colName c)
(maybe qi2 (QualifiedIdentifier mempty) previousAlias, colName fc)
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest) addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
@@ -299,11 +318,11 @@ addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
where where
pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
mutateRequest :: ApiRequest -> TableName -> [Text] -> [FieldName] -> Either Response MutateRequest mutateRequest :: ApiRequest -> TableName -> S.Set FieldName -> [FieldName] -> [FieldName] -> Either Response MutateRequest
mutateRequest apiRequest tName pkCols fldNames = mapLeft apiRequestError $ mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
case action of case action of
ActionCreate -> Right $ Insert tName pkCols payload (iPreferResolution apiRequest) [] returnings ActionCreate -> Right $ Insert tName cols ((,) <$> iPreferResolution apiRequest <*> Just pkCols) [] returnings
ActionUpdate -> Update tName payload <$> combinedLogic <*> pure returnings ActionUpdate -> Update tName cols <$> combinedLogic <*> pure returnings
ActionSingleUpsert -> ActionSingleUpsert ->
(\flts -> (\flts ->
if null (iLogic apiRequest) && if null (iLogic apiRequest) &&
@@ -312,14 +331,13 @@ mutateRequest apiRequest tName pkCols fldNames = mapLeft apiRequestError $
all (\case all (\case
Filter _ (OpExpr False (Op "eq" _)) -> True Filter _ (OpExpr False (Op "eq" _)) -> True
_ -> False) flts _ -> False) flts
then Insert tName pkCols payload (Just MergeDuplicates) <$> combinedLogic <*> pure returnings then Insert tName cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
else else
Left InvalidFilters) =<< filters Left InvalidFilters) =<< filters
ActionDelete -> Delete tName <$> combinedLogic <*> pure returnings ActionDelete -> Delete tName <$> combinedLogic <*> pure returnings
_ -> Left UnsupportedVerb _ -> Left UnsupportedVerb
where where
action = iAction apiRequest action = iAction apiRequest
payload = fromJust $ iPayload apiRequest
returnings = if iPreferRepresentation apiRequest == None then [] else fldNames returnings = if iPreferRepresentation apiRequest == None then [] else fldNames
filters = map snd <$> mapM pRequestFilter mutateFilters filters = map snd <$> mapM pRequestFilter mutateFilters
logic = map snd <$> mapM pRequestLogicTree logicFilters logic = map snd <$> mapM pRequestLogicTree logicFilters
+109 -75
View File
@@ -1,9 +1,19 @@
{-|
Module : PostgREST.DbStructure
Description : PostgREST schema cache
This module contains queries that target PostgreSQL system catalogs, these are used to build the schema cache(DbStructure).
The schema cache is necessary for resource embedding, foreign keys are used for inferring the relationships between tables.
These queries are executed once at startup or when PostgREST is reloaded.
-}
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeSynonymInstances #-} {-# LANGUAGE TypeSynonymInstances #-}
{-# LANGUAGE NamedFieldPuns #-}
module PostgREST.DbStructure ( module PostgREST.DbStructure (
getDbStructure getDbStructure
, accessibleTables , accessibleTables
@@ -12,26 +22,38 @@ module PostgREST.DbStructure (
, getPgVersion , getPgVersion
) where ) where
import qualified Hasql.Decoders as HD import qualified Data.HashMap.Strict as M
import qualified Hasql.Encoders as HE import qualified Data.List as L
import qualified Hasql.Statement as H import qualified Data.Text as T
import qualified Hasql.Decoders as HD
import qualified Hasql.Encoders as HE
import qualified Hasql.Session as H
import qualified Hasql.Statement as H
import qualified Hasql.Transaction as HT
import Control.Applicative import Data.Set as S (fromList)
import qualified Data.HashMap.Strict as M import Data.Text (breakOn, dropAround, split,
import qualified Data.List as L splitOn, strip)
import Data.Set as S (fromList) import GHC.Exts (groupWith)
import Data.Text (split, strip, import Text.InterpolatedString.Perl6 (q, qc)
breakOn, dropAround, import Unsafe (unsafeHead)
splitOn)
import qualified Data.Text as T
import qualified Hasql.Session as H
import qualified Hasql.Transaction as HT
import PostgREST.Types
import Text.InterpolatedString.Perl6 (q, qc)
import GHC.Exts (groupWith) import Control.Applicative
import Protolude
import Unsafe (unsafeHead) import PostgREST.Types
import Protolude
column :: HD.Value a -> HD.Row a
column = HD.column . HD.nonNullable
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
nullableColumn = HD.column . HD.nullable
element :: HD.Value a -> HD.Array a
element = HD.element . HD.nonNullable
param :: HE.Value a -> HE.Params a
param = HE.param . HE.nonNullable
getDbStructure :: Schema -> PgVersion -> HT.Transaction DbStructure getDbStructure :: Schema -> PgVersion -> HT.Transaction DbStructure
getDbStructure schema pgVer = do getDbStructure schema pgVer = do
@@ -60,10 +82,10 @@ decodeTables :: HD.Result [Table]
decodeTables = decodeTables =
HD.rowList tblRow HD.rowList tblRow
where where
tblRow = Table <$> HD.column HD.text tblRow = Table <$> column HD.text
<*> HD.column HD.text <*> column HD.text
<*> HD.nullableColumn HD.text <*> nullableColumn HD.text
<*> HD.column HD.bool <*> column HD.bool
decodeColumns :: [Table] -> HD.Result [Column] decodeColumns :: [Table] -> HD.Result [Column]
decodeColumns tables = decodeColumns tables =
@@ -71,41 +93,41 @@ decodeColumns tables =
where where
colRow = colRow =
(,,,,,,,,,,,) (,,,,,,,,,,,)
<$> HD.column HD.text <*> HD.column HD.text <$> column HD.text <*> column HD.text
<*> HD.column HD.text <*> HD.nullableColumn HD.text <*> column HD.text <*> nullableColumn HD.text
<*> HD.column HD.int4 <*> HD.column HD.bool <*> column HD.int4 <*> column HD.bool
<*> HD.column HD.text <*> HD.column HD.bool <*> column HD.text <*> column HD.bool
<*> HD.nullableColumn HD.int4 <*> nullableColumn HD.int4
<*> HD.nullableColumn HD.int4 <*> nullableColumn HD.int4
<*> HD.nullableColumn HD.text <*> nullableColumn HD.text
<*> HD.nullableColumn HD.text <*> nullableColumn HD.text
decodeRelations :: [Table] -> [Column] -> HD.Result [Relation] decodeRelations :: [Table] -> [Column] -> HD.Result [Relation]
decodeRelations tables cols = decodeRelations tables cols =
mapMaybe (relationFromRow tables cols) <$> HD.rowList relRow mapMaybe (relationFromRow tables cols) <$> HD.rowList relRow
where where
relRow = (,,,,,) relRow = (,,,,,)
<$> HD.column HD.text <$> column HD.text
<*> HD.column HD.text <*> column HD.text
<*> HD.column (HD.array (HD.dimension replicateM (HD.element HD.text))) <*> column (HD.array (HD.dimension replicateM (element HD.text)))
<*> HD.column HD.text <*> column HD.text
<*> HD.column HD.text <*> column HD.text
<*> HD.column (HD.array (HD.dimension replicateM (HD.element HD.text))) <*> column (HD.array (HD.dimension replicateM (element HD.text)))
decodePks :: [Table] -> HD.Result [PrimaryKey] decodePks :: [Table] -> HD.Result [PrimaryKey]
decodePks tables = decodePks tables =
mapMaybe (pkFromRow tables) <$> HD.rowList pkRow mapMaybe (pkFromRow tables) <$> HD.rowList pkRow
where where
pkRow = (,,) <$> HD.column HD.text <*> HD.column HD.text <*> HD.column HD.text pkRow = (,,) <$> column HD.text <*> column HD.text <*> column HD.text
decodeSynonyms :: [Column] -> HD.Result [Synonym] decodeSynonyms :: [Column] -> HD.Result [Synonym]
decodeSynonyms cols = decodeSynonyms cols =
mapMaybe (synonymFromRow cols) <$> HD.rowList synRow mapMaybe (synonymFromRow cols) <$> HD.rowList synRow
where where
synRow = (,,,,,) synRow = (,,,,,)
<$> HD.column HD.text <*> HD.column HD.text <$> column HD.text <*> column HD.text
<*> HD.column HD.text <*> HD.column HD.text <*> column HD.text <*> column HD.text
<*> HD.column HD.text <*> HD.column HD.text <*> column HD.text <*> column HD.text
decodeProcs :: HD.Result (M.HashMap Text [ProcDescription]) decodeProcs :: HD.Result (M.HashMap Text [ProcDescription])
decodeProcs = decodeProcs =
@@ -113,15 +135,15 @@ decodeProcs =
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addName) <$> HD.rowList tblRow map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addName) <$> HD.rowList tblRow
where where
tblRow = ProcDescription tblRow = ProcDescription
<$> HD.column HD.text <$> column HD.text
<*> HD.nullableColumn HD.text <*> nullableColumn HD.text
<*> (parseArgs <$> HD.column HD.text) <*> (parseArgs <$> column HD.text)
<*> (parseRetType <*> (parseRetType
<$> HD.column HD.text <$> column HD.text
<*> HD.column HD.text <*> column HD.text
<*> HD.column HD.bool <*> column HD.bool
<*> HD.column HD.char) <*> column HD.char)
<*> (parseVolatility <$> HD.column HD.char) <*> (parseVolatility <$> column HD.char)
addName :: ProcDescription -> (Text, ProcDescription) addName :: ProcDescription -> (Text, ProcDescription)
addName pd = (pdName pd, pd) addName pd = (pdName pd, pd)
@@ -158,10 +180,10 @@ decodeProcs =
| otherwise = Volatile -- only 'v' can happen here | otherwise = Volatile -- only 'v' can happen here
allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription]) allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
allProcs = H.Statement (toS procsSqlQuery) (HE.param HE.text) decodeProcs True allProcs = H.Statement (toS procsSqlQuery) (param HE.text) decodeProcs True
accessibleProcs :: H.Statement Schema (M.HashMap Text [ProcDescription]) accessibleProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
accessibleProcs = H.Statement (toS sql) (HE.param HE.text) decodeProcs True accessibleProcs = H.Statement (toS sql) (param HE.text) decodeProcs True
where where
sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')" sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')"
@@ -186,7 +208,7 @@ procsSqlQuery = [q|
schemaDescription :: H.Statement Schema (Maybe Text) schemaDescription :: H.Statement Schema (Maybe Text)
schemaDescription = schemaDescription =
H.Statement sql (HE.param HE.text) (join <$> HD.rowMaybe (HD.nullableColumn HD.text)) True H.Statement sql (param HE.text) (join <$> HD.rowMaybe (nullableColumn HD.text)) True
where where
sql = [q| sql = [q|
select select
@@ -199,7 +221,7 @@ schemaDescription =
accessibleTables :: H.Statement Schema [Table] accessibleTables :: H.Statement Schema [Table]
accessibleTables = accessibleTables =
H.Statement sql (HE.param HE.text) decodeTables True H.Statement sql (param HE.text) decodeTables True
where where
sql = [q| sql = [q|
select select
@@ -230,7 +252,7 @@ addForeignKeys :: [Relation] -> [Column] -> [Column]
addForeignKeys rels = map addFk addForeignKeys rels = map addFk
where where
addFk col = col { colFK = fk col } addFk col = col { colFK = fk col }
fk col = join $ relToFk col <$> find (lookupFn col) rels fk col = find (lookupFn col) rels >>= relToFk col
lookupFn :: Column -> Relation -> Bool lookupFn :: Column -> Relation -> Bool
lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==Child lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==Child
relToFk col Relation{relColumns=cols, relFColumns=colsF} = do relToFk col Relation{relColumns=cols, relFColumns=colsF} = do
@@ -285,19 +307,28 @@ addViewChildRelations allSyns = concatMap (\rel ->
-- Relation is dependent on the order of relColumns and relFColumns to get the join conditions right in the generated query. -- Relation is dependent on the order of relColumns and relFColumns to get the join conditions right in the generated query.
-- So we need to change the order of the synonyms to match the relColumns -- So we need to change the order of the synonyms to match the relColumns
-- This could be avoided if the Relation type is improved with a structure that maintains the association of relColumns and relFColumns -- This could be avoided if the Relation type is improved with a structure that maintains the association of relColumns and relFColumns
syns `sortAccordingTo` columns = sortOn (\(k, _) -> L.lookup k $ zip columns [0::Int ..]) syns in syns `sortAccordingTo` columns = sortOn (\(k, _) -> L.lookup k $ zip columns [0::Int ..]) syns
-- View Table Child Relations viewTableChild =
[Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns) relFTable relFColumns Child Nothing Nothing Nothing [ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns)
| syns <- colsSyns, syns `allSynsOf` relColumns] ++ relFTable relFColumns
Child Nothing Nothing Nothing
| syns <- colsSyns, syns `allSynsOf` relColumns ]
-- Table View Child Relations tableViewChild =
[Relation relTable relColumns (getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns) Child Nothing Nothing Nothing [ Relation relTable relColumns
| fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns] ++ (getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns)
Child Nothing Nothing Nothing
| fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ]
-- View View Child Relations viewViewChild =
[Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns) (getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns) Child Nothing Nothing Nothing [ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns)
| syns <- colsSyns, fSyns <- fColsSyns, syns `allSynsOf` relColumns, fSyns `allSynsOf` relFColumns] (getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns)
Child Nothing Nothing Nothing
| syns <- colsSyns, syns `allSynsOf` relColumns
, fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ]
in viewTableChild ++ tableViewChild ++ viewViewChild
_ -> []) _ -> [])
@@ -332,7 +363,7 @@ addViewPrimaryKeys syns = concatMap (\pk ->
allTables :: H.Statement () [Table] allTables :: H.Statement () [Table]
allTables = allTables =
H.Statement sql HE.unit decodeTables True H.Statement sql HE.noParams decodeTables True
where where
sql = [q| sql = [q|
SELECT SELECT
@@ -355,7 +386,7 @@ allTables =
allColumns :: [Table] -> H.Statement Schema [Column] allColumns :: [Table] -> H.Statement Schema [Column]
allColumns tabs = allColumns tabs =
H.Statement sql (HE.param HE.text) (decodeColumns tabs) True H.Statement sql (param HE.text) (decodeColumns tabs) True
where where
sql = [q| sql = [q|
SELECT DISTINCT SELECT DISTINCT
@@ -406,7 +437,7 @@ allColumns tabs =
nc.nspname::information_schema.sql_identifier AS table_schema, nc.nspname::information_schema.sql_identifier AS table_schema,
c.relname::information_schema.sql_identifier AS table_name, c.relname::information_schema.sql_identifier AS table_name,
a.attname::information_schema.sql_identifier AS column_name, a.attname::information_schema.sql_identifier AS column_name,
d.description::information_schema.sql_identifier AS description, d.description AS description,
a.attnum::information_schema.cardinal_number AS ordinal_position, a.attnum::information_schema.cardinal_number AS ordinal_position,
pg_get_expr(ad.adbin, ad.adrelid)::information_schema.character_data AS column_default, pg_get_expr(ad.adbin, ad.adrelid)::information_schema.character_data AS column_default,
CASE CASE
@@ -542,7 +573,7 @@ columnFromRow tabs (s, t, n, desc, pos, nul, typ, u, l, p, d, e) = buildColumn <
allChildRelations :: [Table] -> [Column] -> H.Statement () [Relation] allChildRelations :: [Table] -> [Column] -> H.Statement () [Relation]
allChildRelations tabs cols = allChildRelations tabs cols =
H.Statement sql HE.unit (decodeRelations tabs cols) True H.Statement sql HE.noParams (decodeRelations tabs cols) True
where where
sql = [q| sql = [q|
SELECT ns1.nspname AS table_schema, SELECT ns1.nspname AS table_schema,
@@ -583,7 +614,7 @@ relationFromRow allTabs allCols (rs, rt, rcs, frs, frt, frcs) =
allPrimaryKeys :: [Table] -> H.Statement () [PrimaryKey] allPrimaryKeys :: [Table] -> H.Statement () [PrimaryKey]
allPrimaryKeys tabs = allPrimaryKeys tabs =
H.Statement sql HE.unit (decodePks tabs) True H.Statement sql HE.noParams (decodePks tabs) True
where where
sql = [q| sql = [q|
/* /*
@@ -693,12 +724,15 @@ pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
allSynonyms :: [Column] -> PgVersion -> H.Statement Schema [Synonym] allSynonyms :: [Column] -> PgVersion -> H.Statement Schema [Synonym]
allSynonyms cols pgVer = allSynonyms cols pgVer =
H.Statement sql (HE.param HE.text) (decodeSynonyms cols) True H.Statement sql (param HE.text) (decodeSynonyms cols) True
-- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569 -- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569
where where
subselectRegex :: Text subselectRegex :: Text
subselectRegex | pgVer < pgVersion100 = ":subselect {.*?:constraintDeps <>} :location" -- "result" appears when the subselect is used inside "case when", see `authors_have_book_in_decade` fixture
| otherwise = ":subselect {.*?:stmt_len 0} :location" -- "resno" appears in every other case
-- when copying the query into pg make sure you omit one backslash from \\d+, it should be like `\d+` for the regex
subselectRegex | pgVer < pgVersion100 = ":subselect {.*?:constraintDeps <>} :location \\d+} :res(no|ult)"
| otherwise = ":subselect {.*?:stmt_len 0} :location \\d+} :res(no|ult)"
sql = [qc| sql = [qc|
with with
views as ( views as (
@@ -709,7 +743,7 @@ allSynonyms cols pgVer =
from pg_class c from pg_class c
join pg_namespace n on n.oid = c.relnamespace join pg_namespace n on n.oid = c.relnamespace
join pg_rewrite r on r.ev_class = c.oid join pg_rewrite r on r.ev_class = c.oid
where (c.relkind = 'v'::char) and n.nspname = $1 where (c.relkind in ('v', 'm')) and n.nspname = $1
), ),
removed_subselects as( removed_subselects as(
select select
@@ -765,7 +799,7 @@ synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
getPgVersion :: H.Session PgVersion getPgVersion :: H.Session PgVersion
getPgVersion = H.statement () $ H.Statement sql HE.unit versionRow False getPgVersion = H.statement () $ H.Statement sql HE.noParams versionRow False
where where
sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')" sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')"
versionRow = HD.singleRow $ PgVersion <$> HD.column HD.int4 <*> HD.column HD.text versionRow = HD.singleRow $ PgVersion <$> column HD.int4 <*> column HD.text
+199 -134
View File
@@ -1,97 +1,70 @@
{-|
Module : PostgREST.Error
Description : PostgREST error HTTP responses
-}
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE TypeSynonymInstances #-}
module PostgREST.Error ( module PostgREST.Error (
apiRequestError errorResponseFor
, pgError , ApiRequestError(..)
, simpleError , PgError(..)
, SimpleError(..)
, errorPayload
, checkIsFatal
, singularityError , singularityError
, binaryFieldError
, connectionLostError
, encodeError
, gucHeadersError
) where ) where
import Protolude
import Data.Aeson ((.=))
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import Data.Text (unwords)
import qualified Hasql.Pool as P import qualified Hasql.Pool as P
import qualified Hasql.Session as H import qualified Hasql.Session as H
import Network.HTTP.Types.Header
import qualified Network.HTTP.Types.Status as HT import qualified Network.HTTP.Types.Status as HT
import Network.Wai (Response, responseLBS)
import PostgREST.Types
import Text.Read (readMaybe)
apiRequestError :: ApiRequestError -> Response import Data.Aeson ((.=))
apiRequestError err = import Data.Text (unwords)
errorResponse status import Network.Wai (Response, responseLBS)
[toHeader CTApplicationJSON] err import Text.Read (readMaybe)
where
status =
case err of
ActionInappropriate -> HT.status405
UnsupportedVerb -> HT.status405
InvalidBody _ -> HT.status400
ParseRequestError _ _ -> HT.status400
NoRelationBetween _ _ -> HT.status400
InvalidRange -> HT.status416
UnknownRelation -> HT.status404
InvalidFilters -> HT.status405
simpleError :: HT.Status -> [Header] -> Text -> Response import Network.HTTP.Types.Header
simpleError status hdrs message =
errorResponse status (toHeader CTApplicationJSON : hdrs) $
JSON.object ["message" .= message]
errorResponse :: JSON.ToJSON a => HT.Status -> [Header] -> a -> Response import PostgREST.Types
errorResponse status hdrs e = import Protolude
responseLBS status hdrs $ encodeError e
pgError :: Bool -> P.UsageError -> Response
pgError authed e =
let status = httpStatus authed e
jsonType = toHeader CTApplicationJSON
wwwAuth = ("WWW-Authenticate", "Bearer")
hdrs = if status == HT.status401
then [jsonType, wwwAuth]
else [jsonType] in
responseLBS status hdrs (encodeError e)
singularityError :: Integer -> Response
singularityError numRows =
responseLBS HT.status406
[toHeader CTSingularJSON]
$ toS . formatGeneralError
"JSON object requested, multiple (or no) rows returned"
$ unwords
[ "Results contain", show numRows, "rows,"
, toS (toMime CTSingularJSON), "requires 1 row"
]
where
formatGeneralError :: Text -> Text -> Text
formatGeneralError message details = toS . JSON.encode $
JSON.object ["message" .= message, "details" .= details]
binaryFieldError :: Response class (JSON.ToJSON a) => PgrstError a where
binaryFieldError = status :: a -> HT.Status
simpleError HT.status406 [] (toS (toMime CTOctetStream) <> headers :: a -> [Header]
" requested but a single column was not selected")
gucHeadersError :: Response errorPayload :: a -> LByteString
gucHeadersError = errorPayload = JSON.encode
simpleError HT.status500 []
"response.headers guc must be a JSON array composed of objects with a single key and a string value"
connectionLostError :: Response errorResponseFor :: a -> Response
connectionLostError = errorResponseFor err = responseLBS (status err) (headers err) $ errorPayload err
simpleError HT.status503 [] "Database connection lost, retrying the connection."
encodeError :: JSON.ToJSON a => a -> LByteString
encodeError = JSON.encode
data ApiRequestError
= ActionInappropriate
| InvalidRange
| InvalidBody ByteString
| ParseRequestError Text Text
| NoRelationBetween Text Text
| InvalidFilters
| UnknownRelation -- Unreachable?
| UnsupportedVerb -- Unreachable?
deriving (Show, Eq)
instance PgrstError ApiRequestError where
status InvalidRange = HT.status416
status InvalidFilters = HT.status405
status (InvalidBody _) = HT.status400
status UnsupportedVerb = HT.status405
status UnknownRelation = HT.status404
status ActionInappropriate = HT.status405
status (ParseRequestError _ _) = HT.status400
status (NoRelationBetween _ _) = HT.status400
headers _ = [toHeader CTApplicationJSON]
instance JSON.ToJSON ApiRequestError where instance JSON.ToJSON ApiRequestError where
toJSON (ParseRequestError message details) = JSON.object [ toJSON (ParseRequestError message details) = JSON.object [
@@ -111,9 +84,24 @@ instance JSON.ToJSON ApiRequestError where
toJSON InvalidFilters = JSON.object [ toJSON InvalidFilters = JSON.object [
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)] "message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
data PgError = PgError Authenticated P.UsageError
type Authenticated = Bool
instance PgrstError PgError where
status (PgError authed usageError) = pgErrorStatus authed usageError
headers err =
if status err == HT.status401
then [toHeader CTApplicationJSON, ("WWW-Authenticate", "Bearer") :: Header]
else [toHeader CTApplicationJSON]
instance JSON.ToJSON PgError where
toJSON (PgError _ usageError) = JSON.toJSON usageError
instance JSON.ToJSON P.UsageError where instance JSON.ToJSON P.UsageError where
toJSON (P.ConnectionError e) = JSON.object [ toJSON (P.ConnectionError e) = JSON.object [
"code" .= ("" :: Text), "code" .= ("" :: Text),
"message" .= ("Database connection error" :: Text), "message" .= ("Database connection error" :: Text),
"details" .= (toS $ fromMaybe "" e :: Text)] "details" .= (toS $ fromMaybe "" e :: Text)]
toJSON (P.SessionError e) = JSON.toJSON e -- H.Error toJSON (P.SessionError e) = JSON.toJSON e -- H.Error
@@ -123,69 +111,146 @@ instance JSON.ToJSON H.QueryError where
instance JSON.ToJSON H.CommandError where instance JSON.ToJSON H.CommandError where
toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of
'P':'T':_ -> 'P':'T':_ -> JSON.object [
JSON.object [ "details" .= (fmap toS d :: Maybe Text),
"details" .= (fmap toS d::Maybe Text), "hint" .= (fmap toS h :: Maybe Text)]
"hint" .= (fmap toS h::Maybe Text)]
_ -> _ -> JSON.object [
JSON.object [ "code" .= (toS c :: Text),
"code" .= (toS c::Text), "message" .= (toS m :: Text),
"message" .= (toS m::Text), "details" .= (fmap toS d :: Maybe Text),
"details" .= (fmap toS d::Maybe Text), "hint" .= (fmap toS h :: Maybe Text)]
"hint" .= (fmap toS h::Maybe Text)]
toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [ toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [
"message" .= (m::Text)] "message" .= (m :: Text)]
toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [ toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [
"message" .= ("Row error: end of input"::Text), "message" .= ("Row error: end of input" :: Text),
"details" .= "details" .= ("Attempt to parse more columns than there are in the result" :: Text),
("Attempt to parse more columns than there are in the result"::Text), "hint" .= (("Row number " <> show i) :: Text)]
"details" .= (("Row number " <> show i)::Text)]
toJSON (H.ResultError (H.RowError i H.UnexpectedNull)) = JSON.object [ toJSON (H.ResultError (H.RowError i H.UnexpectedNull)) = JSON.object [
"message" .= ("Row error: unexpected null"::Text), "message" .= ("Row error: unexpected null" :: Text),
"details" .= ("Attempt to parse a NULL as some value."::Text), "details" .= ("Attempt to parse a NULL as some value." :: Text),
"details" .= (("Row number " <> show i)::Text)] "hint" .= (("Row number " <> show i) :: Text)]
toJSON (H.ResultError (H.RowError i (H.ValueError d))) = JSON.object [ toJSON (H.ResultError (H.RowError i (H.ValueError d))) = JSON.object [
"message" .= ("Row error: Wrong value parser used"::Text), "message" .= ("Row error: Wrong value parser used" :: Text),
"details" .= d, "details" .= d,
"details" .= (("Row number " <> show i)::Text)] "hint" .= (("Row number " <> show i) :: Text)]
toJSON (H.ResultError (H.UnexpectedAmountOfRows i)) = JSON.object [ toJSON (H.ResultError (H.UnexpectedAmountOfRows i)) = JSON.object [
"message" .= ("Unexpected amount of rows"::Text), "message" .= ("Unexpected amount of rows" :: Text),
"details" .= i] "details" .= i]
toJSON (H.ClientError d) = JSON.object [ toJSON (H.ClientError d) = JSON.object [
"message" .= ("Database client error"::Text), "message" .= ("Database client error" :: Text),
"details" .= (fmap toS d::Maybe Text)] "details" .= (fmap toS d :: Maybe Text)]
httpStatus :: Bool -> P.UsageError -> HT.Status pgErrorStatus :: Bool -> P.UsageError -> HT.Status
httpStatus _ (P.ConnectionError _) = HT.status503 pgErrorStatus _ (P.ConnectionError _) = HT.status503
httpStatus authed (P.SessionError (H.QueryError _ _ (H.ResultError (H.ServerError c m _ _)))) = pgErrorStatus _ (P.SessionError (H.QueryError _ _ (H.ClientError _))) = HT.status503
case toS c of pgErrorStatus authed (P.SessionError (H.QueryError _ _ (H.ResultError rError))) =
'0':'8':_ -> HT.status503 -- pg connection err case rError of
'0':'9':_ -> HT.status500 -- triggered action exception (H.ServerError c m _ _) ->
'0':'L':_ -> HT.status403 -- invalid grantor case toS c of
'0':'P':_ -> HT.status403 -- invalid role specification '0':'8':_ -> HT.status503 -- pg connection err
"23503" -> HT.status409 -- foreign_key_violation '0':'9':_ -> HT.status500 -- triggered action exception
"23505" -> HT.status409 -- unique_violation '0':'L':_ -> HT.status403 -- invalid grantor
'2':'5':_ -> HT.status500 -- invalid tx state '0':'P':_ -> HT.status403 -- invalid role specification
'2':'8':_ -> HT.status403 -- invalid auth specification "23503" -> HT.status409 -- foreign_key_violation
'2':'D':_ -> HT.status500 -- invalid tx termination "23505" -> HT.status409 -- unique_violation
'3':'8':_ -> HT.status500 -- external routine exception '2':'5':_ -> HT.status500 -- invalid tx state
'3':'9':_ -> HT.status500 -- external routine invocation '2':'8':_ -> HT.status403 -- invalid auth specification
'3':'B':_ -> HT.status500 -- savepoint exception '2':'D':_ -> HT.status500 -- invalid tx termination
'4':'0':_ -> HT.status500 -- tx rollback '3':'8':_ -> HT.status500 -- external routine exception
'5':'3':_ -> HT.status503 -- insufficient resources '3':'9':_ -> HT.status500 -- external routine invocation
'5':'4':_ -> HT.status413 -- too complex '3':'B':_ -> HT.status500 -- savepoint exception
'5':'5':_ -> HT.status500 -- obj not on prereq state '4':'0':_ -> HT.status500 -- tx rollback
'5':'7':_ -> HT.status500 -- operator intervention '5':'3':_ -> HT.status503 -- insufficient resources
'5':'8':_ -> HT.status500 -- system error '5':'4':_ -> HT.status413 -- too complex
'F':'0':_ -> HT.status500 -- conf file error '5':'5':_ -> HT.status500 -- obj not on prereq state
'H':'V':_ -> HT.status500 -- foreign data wrapper error '5':'7':_ -> HT.status500 -- operator intervention
"P0001" -> HT.status400 -- default code for "raise" '5':'8':_ -> HT.status500 -- system error
'P':'0':_ -> HT.status500 -- PL/pgSQL Error 'F':'0':_ -> HT.status500 -- conf file error
'X':'X':_ -> HT.status500 -- internal Error 'H':'V':_ -> HT.status500 -- foreign data wrapper error
"42883" -> HT.status404 -- undefined function "P0001" -> HT.status400 -- default code for "raise"
"42P01" -> HT.status404 -- undefined table 'P':'0':_ -> HT.status500 -- PL/pgSQL Error
"42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege 'X':'X':_ -> HT.status500 -- internal Error
'P':'T':n -> fromMaybe HT.status500 (HT.mkStatus <$> readMaybe n <*> pure m) "42883" -> HT.status404 -- undefined function
_ -> HT.status400 "42P01" -> HT.status404 -- undefined table
httpStatus _ (P.SessionError (H.QueryError _ _ (H.ResultError _))) = HT.status500 "42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege
httpStatus _ (P.SessionError (H.QueryError _ _ (H.ClientError _))) = HT.status503 'P':'T':n -> fromMaybe HT.status500 (HT.mkStatus <$> readMaybe n <*> pure m)
_ -> HT.status400
_ -> HT.status500
checkIsFatal :: PgError -> Maybe Text
checkIsFatal (PgError _ (P.ConnectionError e))
| isAuthFailureMessage = Just $ toS failureMessage
| otherwise = Nothing
where isAuthFailureMessage = "FATAL: password authentication failed" `isPrefixOf` toS failureMessage
failureMessage = fromMaybe "" e
checkIsFatal _ = Nothing
data SimpleError
= GucHeadersError
| BinaryFieldError ContentType
| ConnectionLostError
| PutSingletonError
| PutMatchingPkError
| PutRangeNotAllowedError
| PutPayloadIncompleteError
| JwtTokenMissing
| JwtTokenInvalid Text
| SingularityError Integer
| ContentTypeError [ByteString]
deriving (Show, Eq)
instance PgrstError SimpleError where
status GucHeadersError = HT.status500
status (BinaryFieldError _) = HT.status406
status ConnectionLostError = HT.status503
status PutSingletonError = HT.status400
status PutMatchingPkError = HT.status400
status PutRangeNotAllowedError = HT.status400
status PutPayloadIncompleteError = HT.status400
status JwtTokenMissing = HT.status500
status (JwtTokenInvalid _) = HT.unauthorized401
status (SingularityError _) = HT.status406
status (ContentTypeError _) = HT.status415
headers (SingularityError _) = [toHeader CTSingularJSON]
headers (JwtTokenInvalid m) = [toHeader CTApplicationJSON, invalidTokenHeader m]
headers _ = [toHeader CTApplicationJSON]
instance JSON.ToJSON SimpleError where
toJSON GucHeadersError = JSON.object [
"message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text)]
toJSON (BinaryFieldError ct) = JSON.object [
"message" .= ((toS (toMime ct) <> " requested but more than one column was selected") :: Text)]
toJSON ConnectionLostError = JSON.object [
"message" .= ("Database connection lost, retrying the connection." :: Text)]
toJSON PutSingletonError = JSON.object [
"message" .= ("PUT payload must contain a single row" :: Text)]
toJSON PutRangeNotAllowedError = JSON.object [
"message" .= ("Range header and limit/offset querystring parameters are not allowed for PUT" :: Text)]
toJSON PutPayloadIncompleteError = JSON.object [
"message" .= ("You must specify all columns in the payload when using PUT" :: Text)]
toJSON PutMatchingPkError = JSON.object [
"message" .= ("Payload values do not match URL in primary key column(s)" :: Text)]
toJSON (ContentTypeError cts) = JSON.object [
"message" .= ("None of these Content-Types are available: " <> (toS . intercalate ", " . map toS) cts :: Text)]
toJSON (SingularityError n) = JSON.object [
"message" .= ("JSON object requested, multiple (or no) rows returned" :: Text),
"details" .= unwords ["Results contain", show n, "rows,", toS (toMime CTSingularJSON), "requires 1 row"]]
toJSON JwtTokenMissing = JSON.object [
"message" .= ("Server lacks JWT secret" :: Text)]
toJSON (JwtTokenInvalid message) = JSON.object [
"message" .= (message :: Text)]
invalidTokenHeader :: Text -> Header
invalidTokenHeader m =
("WWW-Authenticate", "Bearer error=\"invalid_token\", " <> "error_description=" <> show m)
singularityError :: (Integral a) => a -> SimpleError
singularityError = SingularityError . toInteger
+25 -28
View File
@@ -1,37 +1,42 @@
{-|
Module : PostgREST.Middleware
Description : Sets the PostgreSQL GUCs, role, search_path and pre-request function. Validates JWT.
-}
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE FlexibleContexts #-}
module PostgREST.Middleware where module PostgREST.Middleware where
import Crypto.JWT import qualified Data.Aeson as JSON
import qualified Data.Aeson as JSON import qualified Data.HashMap.Strict as M
import qualified Data.HashMap.Strict as M import qualified Hasql.Transaction as H
import qualified Hasql.Transaction as H
import Network.HTTP.Types.Status (unauthorized401, status500) import Network.Wai (Application, Response)
import Network.Wai (Application, Response) import Network.Wai.Middleware.Cors (cors)
import Network.Wai.Middleware.Cors (cors) import Network.Wai.Middleware.Gzip (def, gzip)
import Network.Wai.Middleware.Gzip (def, gzip) import Network.Wai.Middleware.Static (only, staticPolicy)
import Network.Wai.Middleware.Static (only, staticPolicy)
import PostgREST.ApiRequest (ApiRequest(..)) import Crypto.JWT
import PostgREST.Auth (JWTAttempt(..))
import PostgREST.Config (AppConfig (..), corsPolicy)
import PostgREST.Error (simpleError)
import PostgREST.QueryBuilder (unquoted, pgFmtSetLocal, pgFmtSetLocalSearchPath)
import Protolude import PostgREST.ApiRequest (ApiRequest (..))
import PostgREST.Auth (JWTAttempt (..))
import PostgREST.Config (AppConfig (..), corsPolicy)
import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing),
errorResponseFor)
import PostgREST.QueryBuilder (pgFmtSetLocal, pgFmtSetLocalSearchPath,
unquoted)
import Protolude
runWithClaims :: AppConfig -> JWTAttempt -> runWithClaims :: AppConfig -> JWTAttempt ->
(ApiRequest -> H.Transaction Response) -> (ApiRequest -> H.Transaction Response) ->
ApiRequest -> H.Transaction Response ApiRequest -> H.Transaction Response
runWithClaims conf eClaims app req = runWithClaims conf eClaims app req =
case eClaims of case eClaims of
JWTInvalid JWTExpired -> return $ unauthed "JWT expired" JWTMissingSecret -> return . errorResponseFor $ JwtTokenMissing
JWTInvalid e -> return $ unauthed $ show e JWTInvalid JWTExpired -> return . errorResponseFor . JwtTokenInvalid $ "JWT expired"
JWTMissingSecret -> return $ simpleError status500 [] "Server lacks JWT secret" JWTInvalid e -> return . errorResponseFor . JwtTokenInvalid . show $ e
JWTClaims claims -> do JWTClaims claims -> do
H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql ++ appSettingsSql H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql ++ appSettingsSql
mapM_ H.sql customReqCheck mapM_ H.sql customReqCheck
app req app req
@@ -47,14 +52,6 @@ runWithClaims conf eClaims app req =
claimsWithRole = M.union claims (M.singleton "role" anon) claimsWithRole = M.union claims (M.singleton "role" anon)
anon = JSON.String . toS $ configAnonRole conf anon = JSON.String . toS $ configAnonRole conf
customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf
where
unauthed message = simpleError
unauthorized401
[( "WWW-Authenticate"
, "Bearer error=\"invalid_token\", " <>
"error_description=" <> show message
)]
message
defaultMiddle :: Application -> Application defaultMiddle :: Application -> Application
defaultMiddle = defaultMiddle =
+47 -33
View File
@@ -1,40 +1,54 @@
{-|
Module : PostgREST.OpenAPI
Description : Generates the OpenAPI output
-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
module PostgREST.OpenAPI ( module PostgREST.OpenAPI (
encodeOpenAPI encodeOpenAPI
, isMalformedProxyUri , isMalformedProxyUri
, pickProxy , pickProxy
) where ) where
import Control.Arrow ((&&&)) import qualified Data.Set as Set
import Control.Lens
import Data.Aeson (decode, encode)
import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList)
import Data.Maybe (fromJust)
import qualified Data.Set as Set
import Data.String (IsString (..))
import Data.Text (unpack, pack, init, tail, toLower, intercalate, append, dropWhile, breakOn)
import Network.URI (parseURI, isAbsoluteURI,
URI (..), URIAuth (..))
import Protolude hiding ((&), Proxy, get, intercalate, dropWhile) import Control.Arrow ((&&&))
import Data.Aeson (decode, encode)
import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList)
import Data.Maybe (fromJust)
import Data.String (IsString (..))
import Data.Text (append, breakOn, dropWhile, init,
intercalate, pack, tail, toLower,
unpack)
import Network.URI (URI (..), URIAuth (..),
isAbsoluteURI, parseURI)
import Data.Swagger import Control.Lens
import Data.Swagger
import PostgREST.ApiRequest (ContentType(..)) import PostgREST.ApiRequest (ContentType (..))
import PostgREST.Config (prettyVersion, docsVersion) import PostgREST.Config (docsVersion, prettyVersion)
import PostgREST.Types (Table(..), Column(..), PgArg(..), ForeignKey(..), import PostgREST.Types (Column (..), ForeignKey (..), PgArg (..),
PrimaryKey(..), Proxy(..), ProcDescription(..), toMime) PrimaryKey (..), ProcDescription (..),
Proxy (..), Table (..), toMime)
import Protolude hiding (Proxy, dropWhile, get,
intercalate, (&))
makeMimeList :: [ContentType] -> MimeList makeMimeList :: [ContentType] -> MimeList
makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs
toSwaggerType :: Text -> SwaggerType t toSwaggerType :: Text -> SwaggerType t
toSwaggerType "text" = SwaggerString toSwaggerType "character varying" = SwaggerString
toSwaggerType "integer" = SwaggerInteger toSwaggerType "character" = SwaggerString
toSwaggerType "boolean" = SwaggerBoolean toSwaggerType "text" = SwaggerString
toSwaggerType "numeric" = SwaggerNumber toSwaggerType "boolean" = SwaggerBoolean
toSwaggerType _ = SwaggerString toSwaggerType "smallint" = SwaggerInteger
toSwaggerType "integer" = SwaggerInteger
toSwaggerType "bigint" = SwaggerInteger
toSwaggerType "numeric" = SwaggerNumber
toSwaggerType "real" = SwaggerNumber
toSwaggerType "double precision" = SwaggerNumber
toSwaggerType _ = SwaggerString
makeTableDef :: [PrimaryKey] -> (Table, [Column], [Text]) -> (Text, Schema) makeTableDef :: [PrimaryKey] -> (Table, [Column], [Text]) -> (Text, Schema)
makeTableDef pks (t, cs, _) = makeTableDef pks (t, cs, _) =
@@ -204,7 +218,7 @@ makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
) )
) )
postOp = tOp postOp = tOp
& parameters .~ map ref ["body." <> tn, "preferReturn"] & parameters .~ map ref ["body." <> tn, "select", "preferReturn"]
& at 201 ?~ "Created" & at 201 ?~ "Created"
patchOp = tOp patchOp = tOp
& parameters .~ map ref (rs <> ["body." <> tn, "preferReturn"]) & parameters .~ map ref (rs <> ["body." <> tn, "preferReturn"])
@@ -319,7 +333,7 @@ pickProxy proxy
uri = toURI $ fromJust proxy uri = toURI $ fromJust proxy
scheme = init $ toLower $ pack $ uriScheme uri scheme = init $ toLower $ pack $ uriScheme uri
path URI {uriPath = ""} = "/" path URI {uriPath = ""} = "/"
path URI {uriPath = p} = p path URI {uriPath = p} = p
path' = pack $ path uri path' = pack $ path uri
authority = fromJust $ uriAuthority uri authority = fromJust $ uriAuthority uri
host' = pack $ uriRegName authority host' = pack $ uriRegName authority
@@ -327,15 +341,15 @@ pickProxy proxy
readPort = fromMaybe 80 . readMaybe readPort = fromMaybe 80 . readMaybe
port'' :: Integer port'' :: Integer
port'' = case (port', scheme) of port'' = case (port', scheme) of
("", "http") -> 80 ("", "http") -> 80
("", "https") -> 443 ("", "https") -> 443
_ -> readPort $ unpack $ tail $ pack port' _ -> readPort $ unpack $ tail $ pack port'
isUriValid:: URI -> Bool isUriValid:: URI -> Bool
isUriValid = fAnd [isSchemeValid, isQueryValid, isAuthorityValid] isUriValid = fAnd [isSchemeValid, isQueryValid, isAuthorityValid]
fAnd :: [a -> Bool] -> a -> Bool fAnd :: [a -> Bool] -> a -> Bool
fAnd fs x = all ($x) fs fAnd fs x = all ($ x) fs
isSchemeValid :: URI -> Bool isSchemeValid :: URI -> Bool
isSchemeValid URI {uriScheme = s} isSchemeValid URI {uriScheme = s}
@@ -345,7 +359,7 @@ isSchemeValid URI {uriScheme = s}
isQueryValid :: URI -> Bool isQueryValid :: URI -> Bool
isQueryValid URI {uriQuery = ""} = True isQueryValid URI {uriQuery = ""} = True
isQueryValid _ = False isQueryValid _ = False
isAuthorityValid :: URI -> Bool isAuthorityValid :: URI -> Bool
isAuthorityValid URI {uriAuthority = a} isAuthorityValid URI {uriAuthority = a}
@@ -354,16 +368,16 @@ isAuthorityValid URI {uriAuthority = a}
isUserInfoValid :: URIAuth -> Bool isUserInfoValid :: URIAuth -> Bool
isUserInfoValid URIAuth {uriUserInfo = ""} = True isUserInfoValid URIAuth {uriUserInfo = ""} = True
isUserInfoValid _ = False isUserInfoValid _ = False
isHostValid :: URIAuth -> Bool isHostValid :: URIAuth -> Bool
isHostValid URIAuth {uriRegName = ""} = False isHostValid URIAuth {uriRegName = ""} = False
isHostValid _ = True isHostValid _ = True
isPortValid :: URIAuth -> Bool isPortValid :: URIAuth -> Bool
isPortValid URIAuth {uriPort = ""} = True isPortValid URIAuth {uriPort = ""} = True
isPortValid URIAuth {uriPort = (':':p)} = isPortValid URIAuth {uriPort = (':':p)} =
case readMaybe p of case readMaybe p of
Just i -> i > (0 :: Integer) && i < 65536 Just i -> i > (0 :: Integer) && i < 65536
Nothing -> False Nothing -> False
isPortValid _ = False isPortValid _ = False
+44 -18
View File
@@ -1,19 +1,30 @@
{-|
Module : PostgREST.Parsers
Description : PostgREST parser combinators
This module is in charge of parsing all the querystring values in an url, e.g. the select, id, order in `/projects?select=id,name&id=eq.1&order=id,name.desc`.
-}
module PostgREST.Parsers where module PostgREST.Parsers where
import Protolude hiding (try, intercalate, replace, option) import qualified Data.HashMap.Strict as M
import Control.Monad ((>>)) import qualified Data.Set as S
import Data.Foldable (foldl1)
import Data.Functor (($>)) import Control.Monad ((>>))
import qualified Data.HashMap.Strict as M import Data.Either.Combinators (mapLeft)
import Data.Text (intercalate, replace, strip) import Data.Foldable (foldl1)
import Data.List (init, last) import Data.Functor (($>))
import Data.Tree import Data.List (init, last)
import Data.Either.Combinators (mapLeft) import Data.Text (intercalate, replace, strip)
import PostgREST.RangeQuery (NonnegRange) import Text.Read (read)
import PostgREST.Types
import Text.ParserCombinators.Parsec hiding (many, (<|>)) import Data.Tree
import Text.Parsec.Error import Text.Parsec.Error
import Text.Read (read) import Text.ParserCombinators.Parsec hiding (many, (<|>))
import PostgREST.Error (ApiRequestError (ParseRequestError))
import PostgREST.RangeQuery (NonnegRange)
import PostgREST.Types
import Protolude hiding (intercalate, option, replace, try)
pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem] pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]
pRequestSelect selStr = pRequestSelect selStr =
@@ -45,9 +56,18 @@ pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree
where where
path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k
embedPath = fst <$> path embedPath = fst <$> path
op = snd <$> path logicTree = do
-- Concat op and v to make pLogicTree argument regular, in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)" op <- snd <$> path
logicTree = join $ parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") . toS <$> ((<>) <$> op <*> pure v) -- Concat op and v to make pLogicTree argument regular,
-- in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)"
parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") $ toS (op <> v)
pRequestColumns :: Maybe Text -> Either ApiRequestError (Maybe (S.Set FieldName))
pRequestColumns colStr =
case colStr of
Just str ->
mapError $ Just . S.fromList <$> parse pColumns ("failed to parse columns parameter (" <> toS str <> ")") (toS str)
_ -> Right Nothing
ws :: Parser Text ws :: Parser Text
ws = toS <$> many (oneOf " \t") ws = toS <$> many (oneOf " \t")
@@ -110,7 +130,10 @@ pRelationSelect :: Parser SelectItem
pRelationSelect = lexeme $ try ( do pRelationSelect = lexeme $ try ( do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) ) alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField fld <- pField
relationDetail <- optionMaybe ( try( char '.' *> pFieldName ) ) relationDetail <- optionMaybe (
try ( char '!' *> pFieldName ) <|>
try ( char '.' *> pFieldName ) -- TODO deprecated, remove in next major version
)
return (fld, Nothing, alias, relationDetail) return (fld, Nothing, alias, relationDetail)
) )
@@ -211,6 +234,9 @@ pLogicPath = do
notOp = "not." <> op notOp = "not." <> op
return (filter (/= "not") (init path), if "not" `elem` path then notOp else op) return (filter (/= "not") (init path), if "not" `elem` path then notOp else op)
pColumns :: Parser [FieldName]
pColumns = pFieldName `sepBy1` lexeme (char ',')
mapError :: Either ParseError a -> Either ApiRequestError a mapError :: Either ParseError a -> Either ApiRequestError a
mapError = mapLeft translateError mapError = mapLeft translateError
where where
+48 -376
View File
@@ -1,7 +1,6 @@
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
{-| {-|
Module : PostgREST.QueryBuilder Module : PostgREST.QueryBuilder
@@ -17,8 +16,6 @@ module PostgREST.QueryBuilder (
callProc callProc
, createReadStatement , createReadStatement
, createWriteStatement , createWriteStatement
, pgFmtIdent
, pgFmtLit
, requestToQuery , requestToQuery
, requestToCountQuery , requestToCountQuery
, unquoted , unquoted
@@ -27,185 +24,24 @@ module PostgREST.QueryBuilder (
, pgFmtSetLocalSearchPath , pgFmtSetLocalSearchPath
) where ) where
import qualified Hasql.Statement as H import qualified Data.Aeson as JSON
import qualified Hasql.Encoders as HE import qualified Data.Set as S
import qualified Hasql.Decoders as HD
import qualified Data.Aeson as JSON import Data.Scientific (FPFormat (..), formatScientific, isInteger)
import Data.Text (intercalate, unwords)
import Data.Tree (Tree (..))
import PostgREST.RangeQuery (rangeLimit, rangeOffset, allRange) import Data.Maybe
import qualified Data.HashMap.Strict as HM
import Data.Maybe
import qualified Data.Set as S
import Data.Text (intercalate, unwords, replace, isInfixOf, toLower)
import qualified Data.Text as T (map, takeWhile, null)
import qualified Data.Text.Encoding as T
import Data.Tree (Tree(..))
import PostgREST.Types
import Text.InterpolatedString.Perl6 (qc)
import qualified Data.ByteString.Char8 as BS
import Data.Scientific ( FPFormat (..)
, formatScientific
, isInteger
)
import Protolude hiding ( intercalate, cast, replace)
import PostgREST.ApiRequest (PreferRepresentation (..))
{-| The generic query result format used by API responses. The location header import PostgREST.QueryBuilder.Private
is represented as a list of strings containing variable bindings like import PostgREST.QueryBuilder.Procedure
@"k1=eq.42"@, or the empty list if there is no location header. import PostgREST.QueryBuilder.ReadStatement
-} import PostgREST.QueryBuilder.WriteStatement
type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString) import PostgREST.RangeQuery (allRange, rangeLimit,
rangeOffset)
standardRow :: HD.Row ResultsWithCount import PostgREST.Types
standardRow = (,,,) <$> HD.nullableColumn HD.int8 <*> HD.column HD.int8 import Protolude hiding (cast,
<*> HD.column header <*> HD.column HD.bytea intercalate, replace)
where
header = HD.array $ HD.dimension replicateM $ HD.element HD.bytea
noLocationF :: Text
noLocationF = "array[]::text[]"
{-| Read and Write api requests use a similar response format which includes
various record counts and possible location header. This is the decoder
for that common type of query.
-}
decodeStandard :: HD.Result ResultsWithCount
decodeStandard =
HD.singleRow standardRow
decodeStandardMay :: HD.Result (Maybe ResultsWithCount)
decodeStandardMay =
HD.rowMaybe standardRow
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName ->
H.Statement () ResultsWithCount
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField =
unicodeStatement sql HE.unit decodeStandard False
where
sql = [qc|
WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols}
FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]
countResultF = if countTotal then "("<>countQuery<>")" else "null"
cols = intercalate ", " [
countResultF <> " AS total_result_set",
"pg_catalog.count(_postgrest_t) AS page_total",
noLocationF <> " AS header",
bodyF <> " AS body"
]
bodyF
| asCsv = asCsvF
| isSingle = asJsonSingleF
| isJust binaryField = asBinaryF $ fromJust binaryField
| otherwise = asJsonF
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
PreferRepresentation -> [Text] ->
H.Statement ByteString (Maybe ResultsWithCount)
createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys =
unicodeStatement sql (HE.param HE.unknown) decodeStandardMay True
where
sql = case rep of
None -> [qc|
WITH {sourceCTEName} AS ({mutateQuery})
SELECT '', 0, {noLocationF}, '' |]
HeadersOnly -> [qc|
WITH {sourceCTEName} AS ({mutateQuery})
SELECT {cols}
FROM (SELECT 1 FROM {sourceCTEName}) _postgrest_t |]
Full -> [qc|
WITH {sourceCTEName} AS ({mutateQuery})
SELECT {cols}
FROM ({selectQuery}) _postgrest_t |]
cols = intercalate ", " [
"'' AS total_result_set", -- when updateing it does not make sense
"pg_catalog.count(_postgrest_t) AS page_total",
if wantHdrs
then "coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")"
else noLocationF <> " AS header",
if rep == Full
then bodyF <> " AS body"
else "''"
]
bodyF
| asCsv = asCsvF
| wantSingle = asJsonSingleF
| otherwise = asJsonF
type ProcResults = (Maybe Int64, Int64, ByteString, ByteString)
callProc :: QualifiedIdentifier -> [PgArg] -> Bool -> SqlQuery -> SqlQuery -> Bool ->
Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> Bool -> PgVersion ->
H.Statement ByteString (Maybe ProcResults)
callProc qi pgArgs returnsScalar selectQuery countQuery countTotal isSingle paramsAsSingleObject asCsv asBinary binaryField isObject pgVer =
unicodeStatement sql (HE.param HE.unknown) decodeProc True
where
sql =
if returnsScalar then [qc|
WITH {argsRecord},
{sourceCTEName} AS (
SELECT {fromQi qi}({args})
)
SELECT
{countResultF} AS total_result_set,
1 AS page_total,
{scalarBodyF} AS body,
{responseHeaders} AS response_headers
FROM ({selectQuery}) _postgrest_t;|]
else [qc|
WITH {argsRecord},
{sourceCTEName} AS (
SELECT * FROM {fromQi qi}({args})
)
SELECT
{countResultF} AS total_result_set,
pg_catalog.count(_postgrest_t) AS page_total,
{bodyF} AS body,
{responseHeaders} AS response_headers
FROM ({selectQuery}) _postgrest_t;|]
(argsRecord, args) | paramsAsSingleObject = ("_args_record AS (SELECT NULL)", "$1::json")
| null pgArgs = (ignoredBody, "")
| otherwise = (
unwords [
"_args_record AS (",
"SELECT * FROM " <> (if isObject then "json_to_record" else "json_to_recordset") <> "($1)",
"AS _(" <> intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " " <> pgaType a) <$> pgArgs) <> ")",
")"]
, intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " := (SELECT " <> pgFmtIdent (pgaName a) <> " FROM _args_record)") <$> pgArgs))
countResultF = if countTotal then "( "<> countQuery <> ")" else "null::bigint" :: Text
_procName = qiName qi
responseHeaders =
if pgVer >= pgVersion96
then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
else "'[]'" :: Text
decodeProc = HD.rowMaybe procRow
procRow = (,,,) <$> HD.nullableColumn HD.int8 <*> HD.column HD.int8
<*> HD.column HD.bytea <*> HD.column HD.bytea
scalarBodyF
| asBinary = asBinaryF _procName
| otherwise = "(row_to_json(_postgrest_t)->" <> pgFmtLit _procName <> ")::character varying"
bodyF
| isSingle = asJsonSingleF
| asCsv = asCsvF
| isJust binaryField = asBinaryF $ fromJust binaryField
| otherwise = asJsonF
pgFmtIdent :: SqlFragment -> SqlFragment
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
pgFmtLit :: SqlFragment -> SqlFragment
pgFmtLit x =
let trimmed = trimNullChars x
escaped = "'" <> replace "'" "''" trimmed <> "'"
slashed = replace "\\" "\\\\" escaped in
if "\\" `isInfixOf` escaped
then "E" <> slashed
else slashed
requestToCountQuery :: Schema -> DbRequest -> SqlQuery requestToCountQuery :: Schema -> DbRequest -> SqlQuery
requestToCountQuery _ (DbMutate _) = witness requestToCountQuery _ (DbMutate _) = witness
@@ -219,32 +55,21 @@ requestToCountQuery schema (DbRead (Node (Select{where_=logicForest}, (mainTbl,
qi = removeSourceCTESchema schema mainTbl qi = removeSourceCTESchema schema mainTbl
requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
requestToQuery schema isParent (DbRead (Node (Select colSelects tbls logicForest joinConditions_ ordts range, (nodeName, maybeRelation, _, _, depth)) forest)) = requestToQuery schema isParent (DbRead (Node (Select colSelects tbl tblAlias implJoins logicForest joinConditions_ ordts range, _) forest)) =
unwords [ unwords [
"SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects), "SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
"FROM " <> intercalate ", " tables, "FROM " <> intercalate ", " (tabl : implJs),
unwords joins, unwords joins,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConds)) ("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_))
`emptyOnFalse` (null logicForest && null joinConds), `emptyOnFalse` (null logicForest && null joinConditions_),
("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts, ("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts,
("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (isParent || range == allRange) ] ("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (isParent || range == allRange) ]
where where
mainTbl = maybe nodeName (tableName . relTable) maybeRelation implJs = fromQi . QualifiedIdentifier schema <$> implJoins
isSelfJoin = maybe False (\r -> relType r /= Root && relTable r == relFTable r) maybeRelation mainQi = removeSourceCTESchema schema tbl
(qi, tables, joinConds) = tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias
let depthAlias name dpth = if dpth /= 0 then name <> "_" <> show dpth else name in -- Root node doesn't get aliased qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias
if isSelfJoin
then (
QualifiedIdentifier "" (depthAlias mainTbl depth),
(\t -> fromQi (removeSourceCTESchema schema t) <> " AS " <> pgFmtIdent (depthAlias t depth)) <$> tbls,
(\(JoinCondition (qi1, _, c1) (qi2, _, c2)) ->
JoinCondition (qi1, Just $ depthAlias (qiName qi1) depth, c1)
(qi2, Just $ depthAlias (qiName qi2) (depth - 1), c2)) <$> joinConditions_)
else (
removeSourceCTESchema schema mainTbl,
fromQi . removeSourceCTESchema schema <$> tbls,
joinConditions_)
(joins, selects) = foldr getQueryParts ([],[]) forest (joins, selects) = foldr getQueryParts ([],[]) forest
@@ -274,48 +99,42 @@ requestToQuery schema isParent (DbRead (Node (Select colSelects tbls logicForest
--getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only --getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only
--posible relations are Child Parent Many --posible relations are Child Parent Many
getQueryParts _ _ = witness getQueryParts _ _ = witness
requestToQuery schema _ (DbMutate (Insert mainTbl pkCols p@(PayloadJSON _ pType pKeys) onConflct logicForest returnings)) = requestToQuery schema _ (DbMutate (Insert mainTbl iCols onConflct putConditions returnings)) =
unwords [ unwords [
("WITH " <> ignoredBody) `emptyOnFalse` not payloadIsEmpty, "WITH " <> normalizedBody,
"INSERT INTO ", fromQi qi, if payloadIsEmpty then " " else "(" <> cols <> ")", "INSERT INTO ", fromQi qi, if S.null iCols then " " else "(" <> cols <> ")",
case (pType, payloadIsEmpty) of unwords [
(PJArray _, True) -> "SELECT null WHERE false" "SELECT " <> cols <> " FROM",
(PJObject, True) -> "DEFAULT VALUES" "json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ") _",
_ -> unwords [ -- Only used for PUT
"SELECT " <> cols <> " FROM", ("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier "" "_") <$> putConditions)) `emptyOnFalse` null putConditions],
case pType of maybe "" (\(oncDo, oncCols) -> (
PJObject -> "json_populate_record" "ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
PJArray _ -> "json_populate_recordset", "(null::", fromQi qi, ", $1) _",
-- Only used for PUT
("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier "" "_") <$> logicForest)) `emptyOnFalse` null logicForest],
maybe "" (\x -> (
"ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> pkCols) <> ") " <> case x of
IgnoreDuplicates -> IgnoreDuplicates ->
"DO NOTHING" "DO NOTHING"
MergeDuplicates -> MergeDuplicates ->
"DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList pKeys) if S.null iCols
) `emptyOnFalse` null pkCols) onConflct, then "DO NOTHING"
else "DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols)
) `emptyOnFalse` null oncCols) onConflct,
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings] ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings]
where where
qi = QualifiedIdentifier schema mainTbl qi = QualifiedIdentifier schema mainTbl
cols = intercalate ", " $ pgFmtIdent <$> S.toList pKeys cols = intercalate ", " $ pgFmtIdent <$> S.toList iCols
payloadIsEmpty = pjIsEmpty p requestToQuery schema _ (DbMutate (Update mainTbl uCols logicForest returnings)) =
requestToQuery schema _ (DbMutate (Update mainTbl p@(PayloadJSON _ pType keys) logicForest returnings)) = if S.null uCols
if pjIsEmpty p then "WITH " <> ignoredBody <> "SELECT null WHERE false" -- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
then "WITH " <> ignoredBody <> "SELECT ''"
else else
unwords [ unwords [
"WITH " <> normalizedBody,
"UPDATE " <> fromQi qi <> " SET " <> cols, "UPDATE " <> fromQi qi <> " SET " <> cols,
"FROM (SELECT * FROM ", "FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ")) _ ",
case pType of ("WHERE " <> intercalate " AND " (pgFmtLogicTree qi <$> logicForest)) `emptyOnFalse` null logicForest,
PJObject -> " json_populate_record" ("RETURNING " <> intercalate ", " (pgFmtColumn qi <$> returnings)) `emptyOnFalse` null returnings
PJArray _ -> " json_populate_recordset", "(null::", fromQi qi, ", $1)) _ ",
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest,
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
] ]
where where
qi = QualifiedIdentifier schema mainTbl qi = QualifiedIdentifier schema mainTbl
cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList keys) cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols)
requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) = requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) =
unwords [ unwords [
"WITH " <> ignoredBody, "WITH " <> ignoredBody,
@@ -326,156 +145,9 @@ requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) =
where where
qi = QualifiedIdentifier schema mainTbl qi = QualifiedIdentifier schema mainTbl
-- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query
-- otherwise the query will err with a `could not determine data type of parameter $1`.
-- This happens because `unknown` relies on the context to determine the value type.
-- The error also happens on raw libpq used with C.
ignoredBody :: SqlFragment
ignoredBody = "ignored_body AS (SELECT $1::text) "
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl
unquoted :: JSON.Value -> Text unquoted :: JSON.Value -> Text
unquoted (JSON.String t) = t unquoted (JSON.String t) = t
unquoted (JSON.Number n) = unquoted (JSON.Number n) =
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
unquoted (JSON.Bool b) = show b unquoted (JSON.Bool b) = show b
unquoted v = toS $ JSON.encode v unquoted v = toS $ JSON.encode v
-- private functions
asCsvF :: SqlFragment
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
where
asCsvHeaderF =
"(SELECT coalesce(string_agg(a.k, ','), '')" <>
" FROM (" <>
" SELECT json_object_keys(r)::TEXT as k" <>
" FROM ( " <>
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
" ) s" <>
" ) a" <>
")"
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
asJsonF :: SqlFragment
asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
asBinaryF :: FieldName -> SqlFragment
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
locationF :: [Text] -> SqlFragment
locationF pKeys = [qc|(
WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))
FROM data CROSS JOIN json_each_text(data.row) AS json_data
{("WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')") `emptyOnFalse` null pKeys}
)|]
fromQi :: QualifiedIdentifier -> SqlFragment
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
where
n = qiName t
s = qiSchema t
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b
unicodeStatement = H.Statement . T.encodeUtf8
emptyOnFalse :: Text -> Bool -> Text
emptyOnFalse val cond = if cond then "" else val
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
pgFmtColumn table "*" = fromQi table <> ".*"
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs fName jp alias
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment
pgFmtOrderTerm qi ot = unwords [
toS . pgFmtField qi $ otTerm ot,
maybe "" show $ otDirection ot,
maybe "" show $ otNullOrder ot]
pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
Op op val -> pgFmtFieldOp op <> " " <> case op of
"like" -> unknownLiteral (T.map star val)
"ilike" -> unknownLiteral (T.map star val)
"is" -> whiteList val
_ -> unknownLiteral val
In vals -> pgFmtField table fld <> " " <>
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
case (&&) (length vals == 1) . T.null <$> headMay vals of
Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
Just True -> emptyValForIn
Nothing -> emptyValForIn
Fts op lang val ->
pgFmtFieldOp op
<> "("
<> maybe "" ((<> ", ") . pgFmtLit) lang
<> unknownLiteral val
<> ") "
where
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
sqlOperator o = HM.lookupDefault "=" o operators
notOp = if hasNot then "NOT" else ""
star c = if c == '*' then '%' else c
unknownLiteral = (<> "::unknown ") . pgFmtLit
whiteList :: Text -> SqlFragment
whiteList v = fromMaybe
(toS (pgFmtLit v) <> "::unknown ")
(find ((==) . toLower $ v) ["null","true","false"])
pgFmtJoinCondition :: JoinCondition -> SqlFragment
pgFmtJoinCondition (JoinCondition (qi, al1, col1) (QualifiedIdentifier schema fTable, al2, col2)) =
pgFmtColumn (fromMaybe qi $ aliasToQi al1) col1 <> " = " <>
pgFmtColumn (fromMaybe (removeSourceCTESchema schema fTable) $ aliasToQi al2) col2
where
aliasToQi al = QualifiedIdentifier "" <$> al
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment
pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"
where notOp = if hasNot then "NOT" else ""
pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt
pgFmtJsonPath :: JsonPath -> SqlFragment
pgFmtJsonPath = \case
[] -> ""
(JArrow x:xs) -> "->" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
(J2Arrow x:xs) -> "->>" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
where
pgFmtJsonOperand (JKey k) = pgFmtLit k
pgFmtJsonOperand (JIdx i) = pgFmtLit i <> "::int"
pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment
pgFmtAs _ [] Nothing = ""
pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
Just (JKey key) -> " AS " <> pgFmtIdent key
Just (JIdx _) -> " AS " <> pgFmtIdent (fromMaybe fName lastKey)
-- We get the lastKey because on:
-- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ]
-- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ]
where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp)
Nothing -> ""
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
pgFmtSetLocal :: Text -> (Text, Text) -> SqlFragment
pgFmtSetLocal prefix (k, v) =
"SET LOCAL " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
pgFmtSetLocalSearchPath :: [Text] -> SqlFragment
pgFmtSetLocalSearchPath vals =
"SET LOCAL search_path = " <> intercalate ", " (pgFmtLit <$> vals) <> ";"
trimNullChars :: Text -> Text
trimNullChars = T.takeWhile (/= '\x0')
+237
View File
@@ -0,0 +1,237 @@
{-# LANGUAGE LambdaCase #-}
{-|
Module : PostgREST.QueryBuilder.Private
Description : Helper functions for PostgREST.QueryBuilder.
-}
module PostgREST.QueryBuilder.Private where
import qualified Data.ByteString.Char8 as BS
import qualified Data.HashMap.Strict as HM
import Data.Maybe
import Data.Text (intercalate,
isInfixOf, replace,
toLower, unwords)
import qualified Data.Text as T (map, null,
takeWhile)
import qualified Data.Text.Encoding as T
import qualified Hasql.Decoders as HD
import qualified Hasql.Encoders as HE
import qualified Hasql.Statement as H
import PostgREST.Types
import Protolude hiding (cast,
intercalate, replace)
import Text.InterpolatedString.Perl6 (qc)
column :: HD.Value a -> HD.Row a
column = HD.column . HD.nonNullable
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
nullableColumn = HD.column . HD.nullable
element :: HD.Value a -> HD.Array a
element = HD.element . HD.nonNullable
param :: HE.Value a -> HE.Params a
param = HE.param . HE.nonNullable
{-| The generic query result format used by API responses. The location header
is represented as a list of strings containing variable bindings like
@"k1=eq.42"@, or the empty list if there is no location header.
-}
type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString)
standardRow :: HD.Row ResultsWithCount
standardRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8
<*> column header <*> column HD.bytea
where
header = HD.array $ HD.dimension replicateM $ element HD.bytea
noLocationF :: Text
noLocationF = "array[]::text[]"
{-| Read and Write api requests use a similar response format which includes
various record counts and possible location header. This is the decoder
for that common type of query.
-}
decodeStandard :: HD.Result ResultsWithCount
decodeStandard =
HD.singleRow standardRow
decodeStandardMay :: HD.Result (Maybe ResultsWithCount)
decodeStandardMay =
HD.rowMaybe standardRow
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl
-- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query
-- otherwise the query will err with a `could not determine data type of parameter $1`.
-- This happens because `unknown` relies on the context to determine the value type.
-- The error also happens on raw libpq used with C.
ignoredBody :: SqlFragment
ignoredBody = "ignored_body AS (SELECT $1::text) "
-- |
-- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads
-- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays
-- We do this in SQL to avoid processing the JSON in application code
normalizedBody :: SqlFragment
normalizedBody =
unwords [
"pgrst_payload AS (SELECT $1::json AS json_data),",
"pgrst_body AS (",
"SELECT",
"CASE WHEN json_typeof(json_data) = 'array'",
"THEN json_data",
"ELSE json_build_array(json_data)",
"END AS val",
"FROM pgrst_payload)"]
selectBody :: SqlFragment
selectBody = "(SELECT val FROM pgrst_body)"
pgFmtLit :: SqlFragment -> SqlFragment
pgFmtLit x =
let trimmed = trimNullChars x
escaped = "'" <> replace "'" "''" trimmed <> "'"
slashed = replace "\\" "\\\\" escaped in
if "\\" `isInfixOf` escaped
then "E" <> slashed
else slashed
pgFmtIdent :: SqlFragment -> SqlFragment
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
asCsvF :: SqlFragment
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
where
asCsvHeaderF =
"(SELECT coalesce(string_agg(a.k, ','), '')" <>
" FROM (" <>
" SELECT json_object_keys(r)::TEXT as k" <>
" FROM ( " <>
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
" ) s" <>
" ) a" <>
")"
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
asJsonF :: SqlFragment
asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
asBinaryF :: FieldName -> SqlFragment
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
locationF :: [Text] -> SqlFragment
locationF pKeys = [qc|(
WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))
FROM data CROSS JOIN json_each_text(data.row) AS json_data
{("WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')") `emptyOnFalse` null pKeys}
)|]
fromQi :: QualifiedIdentifier -> SqlFragment
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
where
n = qiName t
s = qiSchema t
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b
unicodeStatement = H.Statement . T.encodeUtf8
emptyOnFalse :: Text -> Bool -> Text
emptyOnFalse val cond = if cond then "" else val
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
pgFmtColumn table "*" = fromQi table <> ".*"
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs fName jp alias
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment
pgFmtOrderTerm qi ot = unwords [
toS . pgFmtField qi $ otTerm ot,
maybe "" show $ otDirection ot,
maybe "" show $ otNullOrder ot]
pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
Op op val -> pgFmtFieldOp op <> " " <> case op of
"like" -> unknownLiteral (T.map star val)
"ilike" -> unknownLiteral (T.map star val)
"is" -> whiteList val
_ -> unknownLiteral val
In vals -> pgFmtField table fld <> " " <>
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
case (&&) (length vals == 1) . T.null <$> headMay vals of
Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
Just True -> emptyValForIn
Nothing -> emptyValForIn
Fts op lang val ->
pgFmtFieldOp op
<> "("
<> maybe "" ((<> ", ") . pgFmtLit) lang
<> unknownLiteral val
<> ") "
where
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
sqlOperator o = HM.lookupDefault "=" o operators
notOp = if hasNot then "NOT" else ""
star c = if c == '*' then '%' else c
unknownLiteral = (<> "::unknown ") . pgFmtLit
whiteList :: Text -> SqlFragment
whiteList v = fromMaybe
(toS (pgFmtLit v) <> "::unknown ")
(find ((==) . toLower $ v) ["null","true","false"])
pgFmtJoinCondition :: JoinCondition -> SqlFragment
pgFmtJoinCondition (JoinCondition (qi, col1) (QualifiedIdentifier schema fTable, col2)) =
pgFmtColumn qi col1 <> " = " <>
pgFmtColumn (removeSourceCTESchema schema fTable) col2
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment
pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"
where notOp = if hasNot then "NOT" else ""
pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt
pgFmtJsonPath :: JsonPath -> SqlFragment
pgFmtJsonPath = \case
[] -> ""
(JArrow x:xs) -> "->" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
(J2Arrow x:xs) -> "->>" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
where
pgFmtJsonOperand (JKey k) = pgFmtLit k
pgFmtJsonOperand (JIdx i) = pgFmtLit i <> "::int"
pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment
pgFmtAs _ [] Nothing = ""
pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
Just (JKey key) -> " AS " <> pgFmtIdent key
Just (JIdx _) -> " AS " <> pgFmtIdent (fromMaybe fName lastKey)
-- We get the lastKey because on:
-- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ]
-- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ]
where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp)
Nothing -> ""
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
pgFmtSetLocal :: Text -> (Text, Text) -> SqlFragment
pgFmtSetLocal prefix (k, v) =
"SET LOCAL " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
pgFmtSetLocalSearchPath :: [Text] -> SqlFragment
pgFmtSetLocalSearchPath vals =
"SET LOCAL search_path = " <> intercalate ", " (pgFmtLit <$> vals) <> ";"
trimNullChars :: Text -> Text
trimNullChars = T.takeWhile (/= '\x0')
+85
View File
@@ -0,0 +1,85 @@
module PostgREST.QueryBuilder.Procedure where
import Data.Maybe
import Data.Text (intercalate, unwords)
import qualified Hasql.Decoders as HD
import qualified Hasql.Encoders as HE
import qualified Hasql.Statement as H
import PostgREST.QueryBuilder.Private
import PostgREST.Types
import Protolude hiding (cast,
intercalate, replace)
import Text.InterpolatedString.Perl6 (qc)
type ProcResults = (Maybe Int64, Int64, ByteString, ByteString)
callProc :: QualifiedIdentifier -> [PgArg] -> Bool -> SqlQuery -> SqlQuery -> Bool ->
Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion ->
H.Statement ByteString (Maybe ProcResults)
callProc qi pgArgs returnsScalar selectQuery countQuery countTotal isSingle paramsAsSingleObject asCsv asBinary binaryField pgVer =
unicodeStatement sql (param HE.unknown) decodeProc True
where
sql =[qc|
WITH
{argsRecord},
{sourceCTEName} AS (
{sourceBody}
)
SELECT
{countResultF} AS total_result_set,
pg_catalog.count(_postgrest_t) AS page_total,
{bodyF} AS body,
{responseHeaders} AS response_headers
FROM ({selectQuery}) _postgrest_t;|]
(argsRecord, args)
| paramsAsSingleObject = ("_args_record AS (SELECT NULL)", "$1::json")
| null pgArgs = (ignoredBody, "")
| otherwise = (
unwords [
normalizedBody <> ",",
"_args_record AS (",
"SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <>
intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " " <> pgaType a) <$> pgArgs) <> ")",
")"]
, intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " := _args_record." <> pgFmtIdent (pgaName a)) <$> pgArgs))
sourceBody :: SqlFragment
sourceBody
| paramsAsSingleObject || null pgArgs =
if returnsScalar
then [qc| SELECT {fromQi qi}({args}) |]
else [qc| SELECT * FROM {fromQi qi}({args}) |]
| otherwise =
if returnsScalar
then [qc| SELECT {fromQi qi}({args}) FROM _args_record |]
else [qc| SELECT _.*
FROM _args_record,
LATERAL ( SELECT * FROM {fromQi qi}({args}) ) _ |]
bodyF
| returnsScalar = scalarBodyF
| isSingle = asJsonSingleF
| asCsv = asCsvF
| isJust binaryField = asBinaryF $ fromJust binaryField
| otherwise = asJsonF
scalarBodyF
| asBinary = asBinaryF _procName
| otherwise = unwords [
"CASE",
"WHEN pg_catalog.count(_postgrest_t) = 1",
"THEN (json_agg(_postgrest_t." <> pgFmtIdent _procName <> ")->0)::character varying",
"ELSE (json_agg(_postgrest_t." <> pgFmtIdent _procName <> "))::character varying",
"END"]
countResultF = if countTotal then "( "<> countQuery <> ")" else "null::bigint" :: Text
_procName = qiName qi
responseHeaders =
if pgVer >= pgVersion96
then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
else "'[]'" :: Text
decodeProc = HD.rowMaybe procRow
procRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8
<*> column HD.bytea <*> column HD.bytea
@@ -0,0 +1,32 @@
module PostgREST.QueryBuilder.ReadStatement where
import Data.Maybe
import Data.Text (intercalate)
import qualified Hasql.Encoders as HE
import qualified Hasql.Statement as H
import PostgREST.QueryBuilder.Private
import PostgREST.Types
import Protolude hiding (cast,
intercalate, replace)
import Text.InterpolatedString.Perl6 (qc)
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName ->
H.Statement () ResultsWithCount
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField =
unicodeStatement sql HE.noParams decodeStandard False
where
sql = [qc|
WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols}
FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]
countResultF = if countTotal then "("<>countQuery<>")" else "null"
cols = intercalate ", " [
countResultF <> " AS total_result_set",
"pg_catalog.count(_postgrest_t) AS page_total",
noLocationF <> " AS header",
bodyF <> " AS body"
]
bodyF
| asCsv = asCsvF
| isSingle = asJsonSingleF
| isJust binaryField = asBinaryF $ fromJust binaryField
| otherwise = asJsonF
@@ -0,0 +1,53 @@
module PostgREST.QueryBuilder.WriteStatement where
import Data.Maybe
import Data.Text (intercalate, unwords)
import qualified Hasql.Encoders as HE
import qualified Hasql.Statement as H
import PostgREST.ApiRequest (PreferRepresentation (..))
import PostgREST.QueryBuilder.Private
import PostgREST.Types
import Protolude hiding (cast,
intercalate, replace)
import Text.InterpolatedString.Perl6 (qc)
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
PreferRepresentation -> [Text] ->
H.Statement ByteString (Maybe ResultsWithCount)
createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys =
unicodeStatement sql (param HE.unknown) decodeStandardMay True
where
sql = case rep of
None -> [qc|
WITH {sourceCTEName} AS ({mutateQuery})
SELECT '', 0, {noLocationF}, '' |]
HeadersOnly -> [qc|
WITH {sourceCTEName} AS ({mutateQuery})
SELECT {cols}
FROM (SELECT 1 FROM {sourceCTEName}) _postgrest_t |]
Full -> [qc|
WITH {sourceCTEName} AS ({mutateQuery})
SELECT {cols}
FROM ({selectQuery}) _postgrest_t |]
cols = intercalate ", " [
"'' AS total_result_set", -- when updateing it does not make sense
"pg_catalog.count(_postgrest_t) AS page_total",
if isInsert
then unwords [
"CASE",
"WHEN pg_catalog.count(_postgrest_t) = 1 THEN",
"coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")",
"ELSE " <> noLocationF,
"END AS header"]
else noLocationF <> "AS header",
if rep == Full
then bodyF <> " AS body"
else "''"
]
bodyF
| asCsv = asCsvF
| wantSingle = asJsonSingleF
| otherwise = asJsonF
+13 -11
View File
@@ -1,3 +1,7 @@
{-|
Module : PostgREST.RangeQuery
Description : Logic regarding the `Range` header and `limit`, `offset` querystring arguments.
-}
module PostgREST.RangeQuery ( module PostgREST.RangeQuery (
rangeParse rangeParse
, rangeRequested , rangeRequested
@@ -9,19 +13,17 @@ module PostgREST.RangeQuery (
, NonnegRange , NonnegRange
) where ) where
import qualified Data.ByteString.Char8 as BS
import Control.Applicative import Data.List (lookup)
import Network.HTTP.Types.Header import Text.Regex.TDFA ((=~))
import qualified Data.ByteString.Char8 as BS import Control.Applicative
import Data.Ranged.Boundaries import Data.Ranged.Boundaries
import Data.Ranged.Ranges import Data.Ranged.Ranges
import Network.HTTP.Types.Header
import Text.Regex.TDFA ((=~)) import Protolude
import Data.List (lookup)
import Protolude
type NonnegRange = Range Integer type NonnegRange = Range Integer
@@ -56,7 +58,7 @@ rangeOffset :: NonnegRange -> Integer
rangeOffset range = rangeOffset range =
case rangeLower range of case rangeLower range of
BoundaryBelow lower -> lower BoundaryBelow lower -> lower
_ -> panic "range without lower bound" -- should never happen _ -> panic "range without lower bound" -- should never happen
rangeGeq :: Integer -> NonnegRange rangeGeq :: Integer -> NonnegRange
rangeGeq n = rangeGeq n =
+186 -82
View File
@@ -1,30 +1,60 @@
{-# LANGUAGE DuplicateRecordFields #-} {-|
Module : PostgREST.Types
Description : PostgREST common types and functions used by the rest of the modules
-}
{-# LANGUAGE DuplicateRecordFields #-}
module PostgREST.Types where module PostgREST.Types where
import Protolude
import qualified Data.Aeson as JSON
import qualified Data.ByteString as BS
import qualified Data.ByteString.Internal as BS (c2w)
import qualified Data.ByteString.Lazy as BL
import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Strict as M
import qualified Data.Set as S
import qualified GHC.Show import qualified GHC.Show
import qualified Data.Aeson as JSON
import qualified Data.ByteString.Lazy as BL import Network.HTTP.Types.Header (Header, hContentType)
import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Strict as M import Data.Tree
import qualified Data.Set as S
import Data.Tree import PostgREST.RangeQuery (NonnegRange)
import PostgREST.RangeQuery (NonnegRange) import Protolude
import Network.HTTP.Types.Header (hContentType, Header)
-- | Enumeration of currently supported response content types -- | Enumeration of currently supported response content types
data ContentType = CTApplicationJSON | CTTextCSV | CTOpenAPI data ContentType = CTApplicationJSON | CTSingularJSON
| CTSingularJSON | CTOctetStream | CTTextCSV | CTTextPlain
| CTAny | CTOther ByteString deriving Eq | CTOpenAPI | CTOctetStream
| CTAny | CTOther ByteString deriving (Show, Eq)
data ApiRequestError = ActionInappropriate -- | Convert from ContentType to a full HTTP Header
| InvalidBody ByteString toHeader :: ContentType -> Header
| InvalidRange toHeader ct = (hContentType, toMime ct <> "; charset=utf-8")
| ParseRequestError Text Text
| UnknownRelation -- | Convert from ContentType to a ByteString representing the mime type
| NoRelationBetween Text Text toMime :: ContentType -> ByteString
| UnsupportedVerb toMime CTApplicationJSON = "application/json"
| InvalidFilters toMime CTTextCSV = "text/csv"
deriving (Show, Eq) toMime CTTextPlain = "text/plain"
toMime CTOpenAPI = "application/openapi+json"
toMime CTSingularJSON = "application/vnd.pgrst.object+json"
toMime CTOctetStream = "application/octet-stream"
toMime CTAny = "*/*"
toMime (CTOther ct) = ct
-- | Convert from ByteString to ContentType. Warning: discards MIME parameters
decodeContentType :: BS.ByteString -> ContentType
decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of
"application/json" -> CTApplicationJSON
"text/csv" -> CTTextCSV
"text/plain" -> CTTextPlain
"application/openapi+json" -> CTOpenAPI
"application/vnd.pgrst.object+json" -> CTSingularJSON
"application/vnd.pgrst.object" -> CTSingularJSON
"application/octet-stream" -> CTOctetStream
"*/*" -> CTAny
ct' -> CTOther ct'
data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq
instance Show PreferResolution where instance Show PreferResolution where
@@ -77,6 +107,34 @@ instance Ord ProcDescription where
| name1 == name2 && length args1 > length args2 = GT | name1 == name2 && length args1 > length args2 = GT
| otherwise = (name1, des1, args1, rt1, vol1) `compare` (name2, des2, args2, rt2, vol2) | otherwise = (name1, des1, args1, rt1, vol1) `compare` (name2, des2, args2, rt2, vol2)
{-|
Search a pg procedure by its parameters. Since a function can be overloaded, the name is not enough to find it.
An overloaded function can have a different volatility or even a different return type.
-}
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> M.HashMap Text [ProcDescription] -> Maybe ProcDescription
findProc qi payloadKeys paramsAsSingleObject allProcs =
case M.lookup (qiName qi) allProcs of
Nothing -> Nothing
Just [proc] -> Just proc -- if it's not an overloaded function then immediately get the ProcDescription
Just procs -> find matches procs -- Handle overloaded functions case
where
matches proc =
if paramsAsSingleObject
-- if the arg is not of json type let the db give the err
then length (pdArgs proc) == 1
else payloadKeys `S.isSubsetOf` S.fromList (pgaName <$> pdArgs proc)
{-|
Search the procedure parameters by matching them with the specified keys.
If the key doesn't match a parameter, a parameter with a default type "text" is assumed.
-}
specifiedProcArgs :: S.Set FieldName -> Maybe ProcDescription -> [PgArg]
specifiedProcArgs keys proc =
let
args = maybe [] pdArgs proc
in
(\k -> fromMaybe (PgArg k "text" True) (find ((==) k . pgaName) args)) <$> S.toList keys
type Schema = Text type Schema = Text
type TableName = Text type TableName = Text
type SqlQuery = Text type SqlQuery = Text
@@ -89,12 +147,15 @@ data Table = Table {
, tableInsertable :: Bool , tableInsertable :: Bool
} deriving (Show, Ord) } deriving (Show, Ord)
instance Eq Table where
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord) newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
data Column = data Column =
Column { Column {
colTable :: Table colTable :: Table
, colName :: Text , colName :: FieldName
, colDescription :: Maybe Text , colDescription :: Maybe Text
, colPosition :: Int32 , colPosition :: Int32
, colNullable :: Bool , colNullable :: Bool
@@ -107,6 +168,9 @@ data Column =
, colFK :: Maybe ForeignKey , colFK :: Maybe ForeignKey
} deriving (Show, Ord) } deriving (Show, Ord)
instance Eq Column where
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
-- | A view column that refers to a table column -- | A view column that refers to a table column
type Synonym = (Column, ViewColumn) type Synonym = (Column, ViewColumn)
type ViewColumn = Column type ViewColumn = Column
@@ -132,6 +196,10 @@ data OrderTerm = OrderTerm {
, otNullOrder :: Maybe OrderNulls , otNullOrder :: Maybe OrderNulls
} deriving (Show, Eq) } deriving (Show, Eq)
{-|
Represents a pg identifier with a prepended schema name "schema.table"
When qiSchema is "", the schema is defined by the pg search_path
-}
data QualifiedIdentifier = QualifiedIdentifier { data QualifiedIdentifier = QualifiedIdentifier {
qiSchema :: Schema qiSchema :: Schema
, qiName :: TableName , qiName :: TableName
@@ -149,40 +217,42 @@ data RelationType = Child | Parent | Many | Root deriving (Show, Eq)
TODO merge relColumns and relFColumns to a tuple or Data.Bimap TODO merge relColumns and relFColumns to a tuple or Data.Bimap
-} -}
data Relation = Relation { data Relation = Relation {
relTable :: Table relTable :: Table
, relColumns :: [Column] , relColumns :: [Column]
, relFTable :: Table , relFTable :: Table
, relFColumns :: [Column] , relFColumns :: [Column]
, relType :: RelationType , relType :: RelationType
-- The Link attrs are used when RelationType == Many -- The Link attrs are used when RelationType == Many
, relLinkTable :: Maybe Table , relLinkTable :: Maybe Table
, relLinkCols1 :: Maybe [Column] , relLinkCols1 :: Maybe [Column]
, relLinkCols2 :: Maybe [Column] , relLinkCols2 :: Maybe [Column]
} deriving (Show, Eq) } deriving (Show, Eq)
-- | Cached attributes of a JSON payload isSelfJoin :: Relation -> Bool
data PayloadJSON = PayloadJSON { isSelfJoin r = relType r /= Root && relTable r == relFTable r
-- | This is the raw ByteString that comes from the request body.
-- We cache this instead of an Aeson Value because it was detected that for large payloads the encoding data PayloadJSON =
-- had high memory usage, see #1005 for more details -- | Cached attributes of a JSON payload
pjRaw :: BL.ByteString ProcessedJSON {
, pjType :: PJType -- | This is the raw ByteString that comes from the request body.
-- | Keys of the object or if it's an array these keys are guaranteed to be the same across all its objects -- We cache this instead of an Aeson Value because it was detected that for large payloads the encoding
, pjKeys :: S.Set Text -- had high memory usage, see #1005 for more details
} deriving (Show, Eq) pjRaw :: BL.ByteString
, pjType :: PJType
-- | Keys of the object or if it's an array these keys are guaranteed to be the same across all its objects
, pjKeys :: S.Set Text
}|
RawJSON {
pjRaw :: BL.ByteString
} deriving (Show, Eq)
data PJType = PJArray { pjaLength :: Int } | PJObject deriving (Show, Eq) data PJType = PJArray { pjaLength :: Int } | PJObject deriving (Show, Eq)
-- | e.g. whether it is []/{} or not
pjIsEmpty :: PayloadJSON -> Bool
pjIsEmpty (PayloadJSON _ PJObject keys) = S.size keys == 0
pjIsEmpty (PayloadJSON _ (PJArray l) _) = l == 0
data Proxy = Proxy { data Proxy = Proxy {
proxyScheme :: Text proxyScheme :: Text
, proxyHost :: Text , proxyHost :: Text
, proxyPort :: Integer , proxyPort :: Integer
, proxyPath :: Text , proxyPath :: Text
} deriving (Show, Eq) } deriving (Show, Eq)
type Operator = Text type Operator = Text
@@ -211,7 +281,8 @@ ftsOperators :: M.HashMap Operator SqlFragment
ftsOperators = M.fromList [ ftsOperators = M.fromList [
("fts", "@@ to_tsquery"), ("fts", "@@ to_tsquery"),
("plfts", "@@ plainto_tsquery"), ("plfts", "@@ plainto_tsquery"),
("phfts", "@@ phraseto_tsquery") ("phfts", "@@ phraseto_tsquery"),
("wfts", "@@ websearch_to_tsquery")
] ]
data OpExpr = OpExpr Bool Operation deriving (Eq, Show) data OpExpr = OpExpr Bool Operation deriving (Eq, Show)
@@ -227,8 +298,8 @@ type ListVal = [Text]
data LogicOperator = And | Or deriving Eq data LogicOperator = And | Or deriving Eq
instance Show LogicOperator where instance Show LogicOperator where
show And = "AND" show And = "AND"
show Or = "OR" show Or = "OR"
{-| {-|
Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is: Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:
@@ -283,39 +354,48 @@ type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe RelationDetail)
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"] -- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"]
type EmbedPath = [Text] type EmbedPath = [Text]
data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq) data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq)
data JoinCondition = JoinCondition (QualifiedIdentifier, Maybe Alias, FieldName) data JoinCondition = JoinCondition (QualifiedIdentifier, FieldName)
(QualifiedIdentifier, Maybe Alias, FieldName) deriving (Show, Eq) (QualifiedIdentifier, FieldName) deriving (Show, Eq)
data ReadQuery = Select { select::[SelectItem], from::[TableName], where_::[LogicTree], joinConditions::[JoinCondition], order::[OrderTerm], range_::NonnegRange } deriving (Show, Eq) data ReadQuery = Select {
data MutateQuery = Insert { in_::TableName, insPkCols::[Text], qPayload::PayloadJSON, onConflict:: Maybe PreferResolution, where_::[LogicTree], returning::[FieldName] } select :: [SelectItem]
| Delete { in_::TableName, where_::[LogicTree], returning::[FieldName] } , from :: TableName
| Update { in_::TableName, qPayload::PayloadJSON, where_::[LogicTree], returning::[FieldName] } deriving (Show, Eq) -- | A table alias is used in case of self joins
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail, Depth)) , fromAlias :: Maybe Alias
-- | Only used for Many to Many joins. Parent and Child joins use explicit joins.
, implicitJoins :: [TableName]
, where_ :: [LogicTree]
, joinConditions :: [JoinCondition]
, order :: [OrderTerm]
, range_ :: NonnegRange
} deriving (Show, Eq)
data MutateQuery =
Insert {
in_ :: TableName
, insCols :: S.Set FieldName
, onConflict :: Maybe (PreferResolution, [FieldName])
, where_ :: [LogicTree]
, returning :: [FieldName]
}|
Update {
in_ :: TableName
, updCols :: S.Set FieldName
, where_ :: [LogicTree]
, returning :: [FieldName]
}|
Delete {
in_ :: TableName
, where_ :: [LogicTree]
, returning :: [FieldName]
} deriving (Show, Eq)
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
type ReadRequest = Tree ReadNode type ReadRequest = Tree ReadNode
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail, Depth))
-- Depth of the ReadRequest tree -- Depth of the ReadRequest tree
type Depth = Integer type Depth = Integer
type MutateRequest = MutateQuery type MutateRequest = MutateQuery
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
instance Eq Table where
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
instance Eq Column where
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
-- | Convert from ContentType to a full HTTP Header
toHeader :: ContentType -> Header
toHeader ct = (hContentType, toMime ct <> "; charset=utf-8")
-- | Convert from ContentType to a ByteString representing the mime type
toMime :: ContentType -> ByteString
toMime CTApplicationJSON = "application/json"
toMime CTTextCSV = "text/csv"
toMime CTOpenAPI = "application/openapi+json"
toMime CTSingularJSON = "application/vnd.pgrst.object+json"
toMime CTOctetStream = "application/octet-stream"
toMime CTAny = "*/*"
toMime (CTOther ct) = ct
data PgVersion = PgVersion { data PgVersion = PgVersion {
pgvNum :: Int32 pgvNum :: Int32
@@ -327,7 +407,10 @@ instance Ord PgVersion where
-- | Tells the minimum PostgreSQL version required by this version of PostgREST -- | Tells the minimum PostgreSQL version required by this version of PostgREST
minimumPgVersion :: PgVersion minimumPgVersion :: PgVersion
minimumPgVersion = PgVersion 90400 "9.4" minimumPgVersion = pgVersion94
pgVersion94 :: PgVersion
pgVersion94 = PgVersion 90400 "9.4"
pgVersion95 :: PgVersion pgVersion95 :: PgVersion
pgVersion95 = PgVersion 90500 "9.5" pgVersion95 = PgVersion 90500 "9.5"
@@ -338,6 +421,18 @@ pgVersion96 = PgVersion 90600 "9.6"
pgVersion100 :: PgVersion pgVersion100 :: PgVersion
pgVersion100 = PgVersion 100000 "10" pgVersion100 = PgVersion 100000 "10"
pgVersion109 :: PgVersion
pgVersion109 = PgVersion 100009 "10.9"
pgVersion110 :: PgVersion
pgVersion110 = PgVersion 110000 "11.0"
pgVersion112 :: PgVersion
pgVersion112 = PgVersion 110002 "11.2"
pgVersion114 :: PgVersion
pgVersion114 = PgVersion 110004 "11.4"
sourceCTEName :: SqlFragment sourceCTEName :: SqlFragment
sourceCTEName = "pg_source" sourceCTEName = "pg_source"
@@ -345,3 +440,12 @@ sourceCTEName = "pg_source"
type JSPath = [JSPathExp] type JSPath = [JSPathExp]
-- | jspath expression, e.g. .property, .property[0] or ."property-dash" -- | jspath expression, e.g. .property, .property[0] or ."property-dash"
data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show) data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)
-- | Current database connection status data ConnectionStatus
data ConnectionStatus
= NotConnected
| Connected PgVersion
| FatalConnectionError Text
deriving (Eq, Show)
+18 -15
View File
@@ -1,18 +1,21 @@
resolver: lts-9.6 # stack.yaml is used for circle-ci tests. Profiling build fails on circleci
# with GHC 8.6, so we build with 8.4 for now.
resolver: lts-13.29
extra-deps: extra-deps:
- configurator-ng-0.0.0.1 - Ranged-sets-0.4.0
- critbit-0.2.0.0 - configurator-pg-0.1.0.3
- hjsonpointer-1.1.1 - http-types-0.12.3
- hjsonschema-1.5.0.1 - hasql-1.4
- Ranged-sets-0.3.0 - hasql-pool-0.5.1
- protolude-0.2.2 - hasql-transaction-0.7.2
- hasql-1.3 - text-builder-0.6.5.1
- hasql-pool-0.5 - deferred-folds-0.9.10.1
- hasql-transaction-0.7 - primitive-0.6.4.0
- text-builder-0.5.1.1 - jose-0.8.1.0
- jose-0.7.0.0 - text-printer-0.5.0.1
- postgresql-libpq-0.9.4.1 - network-2.7.0.1
ghc-options: ghc-options:
postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints -optP-Wno-nonportable-include-path
nix: nix:
packages: [postgresql, zlib] packages: [pcre, pkgconfig, postgresql, zlib]
+96
View File
@@ -0,0 +1,96 @@
# This file was autogenerated by Stack.
# You should not edit this file by hand.
# For more information, please see the documentation at:
# https://docs.haskellstack.org/en/stable/lock_files
packages:
- completed:
hackage: Ranged-sets-0.4.0@sha256:04bb4ce482fbdc052c9ee3346ba210986b33002b8c3440b714d62750144f86b6,1373
pantry-tree:
size: 566
sha256: ae6809a20be4da39729ac3c6e10b5c311b80628c767184da95903ecff6efc095
original:
hackage: Ranged-sets-0.4.0
- completed:
hackage: configurator-pg-0.1.0.3@sha256:ddccf34fef0a5c4f1364ec4c6fda459f089a592e18662b0263ed85ea943c9c70,2885
pantry-tree:
size: 1008
sha256: 5eb16e5536e8bfd921286fb4c97eaaf0c1cb9bdbdf9630a7ffdf1cc64942b79b
original:
hackage: configurator-pg-0.1.0.3
- completed:
hackage: http-types-0.12.3@sha256:f35229edb1bc7b3ae27f961b2407dadb5bfa69d43a8f5337ab46cdc79ca4afe9,2035
pantry-tree:
size: 833
sha256: c9b77e1ba204fffbe4e1be80412bc48e47440a07e4b7db4cdc77d573a3e21b9a
original:
hackage: http-types-0.12.3
- completed:
hackage: hasql-1.4@sha256:fcb1b0046c1e888b6c4cad53c972d23318dbc6ababa9ceb7d9cfd3b546732bcd,6515
pantry-tree:
size: 2567
sha256: 2e253e7f3052ae4f838d26191355eb0c0b9f344e265b7dec50e13b799ef32450
original:
hackage: hasql-1.4
- completed:
hackage: hasql-pool-0.5.1@sha256:a98f2fc38f60eb037a8ac6c5e17591b090089e305f367219c4879812592aaafe,2436
pantry-tree:
size: 412
sha256: 22e4cea8c23ea0eaa871388236c1d3e10349d993fd1bbc602f40aeb4909d6100
original:
hackage: hasql-pool-0.5.1
- completed:
hackage: hasql-transaction-0.7.2@sha256:d6d8ceb0b32be75686fe31c4b5bc15c569a71023fc60394893508ea733e8714b,2835
pantry-tree:
size: 1028
sha256: 9bd8c7bf3e30d033192ff53c97e5f1f5e9c08ebfe9a1b3364ab0f9e8f2c2872e
original:
hackage: hasql-transaction-0.7.2
- completed:
hackage: text-builder-0.6.5.1@sha256:547f292707c7488c0fbee415adb5fa107d725b720f8697e966a3e3e00cac02cd,4210
pantry-tree:
size: 542
sha256: 5b2be8c9530d3460cadfe142429e0f82bb4b5c238737de473b6f9a25a33e232f
original:
hackage: text-builder-0.6.5.1
- completed:
hackage: deferred-folds-0.9.10.1@sha256:eb2634488e2a836da7d5aed9afd15ad2bead38817249f60bb68e459cc32fb0b0,2928
pantry-tree:
size: 958
sha256: b9132db4ffe78f11254871ed88071c5abe1ff41eeffcbec30ce484f24db81c19
original:
hackage: deferred-folds-0.9.10.1
- completed:
hackage: primitive-0.6.4.0@sha256:5b6a2c3cc70a35aabd4565fcb9bb1dd78fe2814a36e62428a9a1aae8c32441a1,2079
pantry-tree:
size: 1517
sha256: 5d5e591311664886e88ade3da6880c32adf0d1fe80c55f40a3c93bb91df8fdeb
original:
hackage: primitive-0.6.4.0
- completed:
hackage: jose-0.8.1.0@sha256:904e64203f0e074c4601529be2b57c94eec9fd588b19e16165391f8f9e84a6a0,3353
pantry-tree:
size: 1935
sha256: 8d5f80f184b61e89fedbf61d4d7b54ce85326fe67d8ec888117387522c06504d
original:
hackage: jose-0.8.1.0
- completed:
hackage: text-printer-0.5.0.1@sha256:9171204826a67c97bc3578968d8b3dcfbd81be08dfd18fef2af71eaf7b2737b1,1502
pantry-tree:
size: 461
sha256: 656053744f42551bc9cffd76746bfae77a249f54ca69497f6835e35312c7b5bb
original:
hackage: text-printer-0.5.0.1
- completed:
hackage: network-2.7.0.1@sha256:e8ab30822597c44f0520875699e005c8b284d19000bdacfddbc980c7dfa00bec,2823
pantry-tree:
size: 2313
sha256: 5729e7f6993505243e13fde01833e768024303052becb72f915d8cff1c20e177
original:
hackage: network-2.7.0.1
snapshots:
- completed:
size: 500539
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/13/29.yaml
sha256: 006398c5e92d1d64737b7e98ae4d63987c36808814504d1451f56ebd98093f75
original: lts-13.29
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+50 -8
View File
@@ -1,17 +1,18 @@
module Feature.AndOrParamsSpec where module Feature.AndOrParamsSpec where
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import Network.Wai (Application) import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get)
import SpecHelper import SpecHelper
import Protolude hiding (get)
spec :: PgVersion -> SpecWith Application
spec :: SpecWith Application spec actualPgVersion =
spec =
describe "and/or params used for complex boolean logic" $ do describe "and/or params used for complex boolean logic" $ do
context "used with GET" $ do context "used with GET" $ do
context "or param" $ do context "or param" $ do
@@ -79,6 +80,19 @@ spec =
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }, {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
{"text_search_vector": "'art':4 'spass':5 'unmog':7"} {"text_search_vector": "'art':4 'spass':5 'unmog':7"}
]|] { matchHeaders = [matchContentTypeJson] } ]|] { 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" $ it "can handle cs and cd" $
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith` get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] } [json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
@@ -113,6 +127,34 @@ spec =
get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith` get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] } [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 context "operators with not" $ do
it "eq, cs, like can be negated" $ 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` get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
+5 -5
View File
@@ -1,14 +1,14 @@
module Feature.AsymmetricJwtSpec where module Feature.AsymmetricJwtSpec where
-- {{{ Imports -- {{{ Imports
import Test.Hspec
import Test.Hspec.Wai
import Network.HTTP.Types
import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Protolude import Protolude
import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
+7 -7
View File
@@ -1,14 +1,14 @@
module Feature.AudienceJwtSecretSpec where module Feature.AudienceJwtSecretSpec where
-- {{{ Imports -- {{{ Imports
import Test.Hspec
import Test.Hspec.Wai
import Network.HTTP.Types
import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Protolude hiding (get) import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Protolude hiding (get)
import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
@@ -44,4 +44,4 @@ spec = describe "test handling of aud claims in JWT" $ do
`shouldRespondWith` 200 `shouldRespondWith` 200
it "requests without JWT token should work" $ it "requests without JWT token should work" $
get "/has_count_column" `shouldRespondWith` 200 get "/has_count_column" `shouldRespondWith` 200
+30 -11
View File
@@ -1,26 +1,36 @@
module Feature.AuthSpec where module Feature.AuthSpec where
import Text.Heredoc import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Text.Heredoc
import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get)
import SpecHelper import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get) spec :: PgVersion -> SpecWith Application
spec actualPgVersion = describe "authorization" $ do
spec :: SpecWith Application
spec = describe "authorization" $ do
let single = ("Accept","application/vnd.pgrst.object+json") let single = ("Accept","application/vnd.pgrst.object+json")
it "denies access to tables that anonymous does not own" $ it "denies access to tables that anonymous does not own" $
get "/authors_only" `shouldRespondWith` [json| { 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, "hint":null,
"details":null, "details":null,
"code":"42501", "code":"42501",
"message":"permission denied for relation authors_only"} |] "message":"permission denied for relation authors_only"} |]
)
{ matchStatus = 401 { matchStatus = 401
, matchHeaders = ["WWW-Authenticate" <:> "Bearer"] , matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
} }
@@ -28,11 +38,20 @@ spec = describe "authorization" $ do
it "denies access to tables that postgrest_test_author does not own" $ it "denies access to tables that postgrest_test_author does not own" $
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
request methodGet "/private_table" [auth] "" request methodGet "/private_table" [auth] ""
`shouldRespondWith` [json| { `shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[json| {
"hint":null,
"details":null,
"code":"42501",
"message":"permission denied for table private_table"} |]
else
[json| {
"hint":null, "hint":null,
"details":null, "details":null,
"code":"42501", "code":"42501",
"message":"permission denied for relation private_table"} |] "message":"permission denied for relation private_table"} |]
)
{ matchStatus = 403 { matchStatus = 403
, matchHeaders = [] , matchHeaders = []
} }
@@ -98,11 +117,11 @@ spec = describe "authorization" $ do
it "hides tables from users with invalid JWT" $ do it "hides tables from users with invalid JWT" $ do
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
`shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError \"expected 3 parts, got 2\")"} |] `shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"} |]
{ matchStatus = 401 { matchStatus = 401
, matchHeaders = [ , matchHeaders = [
"WWW-Authenticate" <:> "WWW-Authenticate" <:>
"Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError \\\"expected 3 parts, got 2\\\")\"" "Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)\""
] ]
} }
+5 -5
View File
@@ -1,14 +1,14 @@
module Feature.BinaryJwtSecretSpec where module Feature.BinaryJwtSecretSpec where
-- {{{ Imports -- {{{ Imports
import Test.Hspec
import Test.Hspec.Wai
import Network.HTTP.Types
import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Protolude import Protolude
import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
+10 -9
View File
@@ -1,20 +1,21 @@
{-# LANGUAGE MultiParamTypeClasses, TypeFamilies, UndecidableInstances #-} {-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# OPTIONS_GHC -fno-warn-orphans #-}
module Feature.ConcurrentSpec where module Feature.ConcurrentSpec where
import Control.Monad (void)
import Control.Monad.Base
import Control.Monad.Trans.Control
import Control.Concurrent.Async (mapConcurrently) import Control.Concurrent.Async (mapConcurrently)
import Control.Monad (void)
import Network.Wai (Application)
import Control.Monad.Base
import Control.Monad.Trans.Control
import Network.Wai.Test (Session)
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai.Internal
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.Internal
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.Wai.Test (Session)
import Network.Wai (Application)
import Protolude hiding (get) import Protolude hiding (get)
+5 -5
View File
@@ -1,17 +1,17 @@
module Feature.CorsSpec where module Feature.CorsSpec where
-- {{{ Imports -- {{{ Imports
import Test.Hspec
import Test.Hspec.Wai
import Network.Wai.Test (SResponse(simpleHeaders, simpleBody))
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import SpecHelper import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders))
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai (Application) import Test.Hspec
import Test.Hspec.Wai
import Protolude import Protolude
import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
+3 -3
View File
@@ -1,12 +1,12 @@
module Feature.DeleteSpec where module Feature.DeleteSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Text.Heredoc import Text.Heredoc
import Network.HTTP.Types
import Network.Wai (Application)
import Protolude hiding (get) import Protolude hiding (get)
spec :: SpecWith Application spec :: SpecWith Application
+3 -4
View File
@@ -1,14 +1,13 @@
module Feature.ExtraSearchPathSpec where module Feature.ExtraSearchPathSpec where
import Network.HTTP.Types
import Network.Wai (Application)
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import SpecHelper
import Network.Wai (Application)
import Protolude import Protolude
import SpecHelper
spec :: SpecWith Application spec :: SpecWith Application
spec = describe "extra search path" $ do spec = describe "extra search path" $ do
+30
View File
@@ -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; charset=utf-8"]
}
+182 -67
View File
@@ -1,28 +1,27 @@
module Feature.InsertSpec where module Feature.InsertSpec where
import Test.Hspec hiding (pendingWith) import qualified Data.Aeson as JSON
import Control.Monad (replicateM_, void)
import Data.List (lookup)
import Data.Maybe (fromJust)
import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai.Matcher (bodyEquals)
import TestTypes (CompoundPK (..), IncPK (..))
import Network.HTTP.Types
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Test.Hspec.Wai.Matcher (bodyEquals) import Text.Heredoc
import Network.Wai.Test (SResponse(simpleBody,simpleHeaders,simpleStatus))
import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get)
import SpecHelper import SpecHelper
import qualified Data.Aeson as JSON spec :: PgVersion -> SpecWith Application
import Data.List (lookup) spec actualPgVersion = do
import Data.Maybe (fromJust)
import Text.Heredoc
import Network.HTTP.Types.Header
import Network.HTTP.Types
import Control.Monad (replicateM_, void)
import TestTypes(IncPK(..), CompoundPK(..))
import Network.Wai (Application)
import Protolude hiding (get)
spec :: SpecWith Application
spec = do
describe "Posting new record" $ do describe "Posting new record" $ do
context "disparate json types" $ do context "disparate json types" $ do
it "accepts disparate json types" $ do it "accepts disparate json types" $ do
@@ -51,9 +50,22 @@ spec = do
context "non uniform json array" $ do context "non uniform json array" $ do
it "rejects json array that isn't exclusivily composed of objects" $ 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` 400 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" $ it "rejects json array that has objects with different keys" $
post "/articles" [json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |] `shouldRespondWith` 400 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 context "requesting full representation" $ do
it "includes related data after insert" $ it "includes related data after insert" $
@@ -103,11 +115,13 @@ spec = do
incNullableStr record `shouldBe` Nothing incNullableStr record `shouldBe` Nothing
context "into a table with simple pk" $ context "into a table with simple pk" $
it "fails with 400 and error" $ do it "fails with 400 and error" $
p <- post "/simple_pk" [json| { "extra":"foo"} |] post "/simple_pk" [json| { "extra":"foo"} |]
liftIO $ do `shouldRespondWith`
simpleStatus p `shouldBe` badRequest400 [json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" violates not-null constraint"}|]
isErrorFormat (simpleBody p) `shouldBe` True { matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "into a table with no pk" $ do context "into a table with no pk" $ do
it "succeeds with 201 and a link including all fields" $ do it "succeeds with 201 and a link including all fields" $ do
@@ -181,11 +195,13 @@ spec = do
lookup hLocation (simpleHeaders p) `shouldBe` Nothing lookup hLocation (simpleHeaders p) `shouldBe` Nothing
context "with invalid json payload" $ context "with invalid json payload" $
it "fails with 400 and error" $ do it "fails with 400 and error" $
p <- post "/simple_pk" "}{ x = 2" post "/simple_pk" "}{ x = 2"
liftIO $ do `shouldRespondWith`
simpleStatus p `shouldBe` badRequest400 [json|{"message":"Error in $: Failed reading: not a valid json value"}|]
isErrorFormat (simpleBody p) `shouldBe` True { matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "with valid json payload" $ context "with valid json payload" $
it "succeeds and returns 201 created" $ it "succeeds and returns 201 created" $
@@ -193,7 +209,12 @@ spec = do
context "attempting to insert a row with the same primary key" $ context "attempting to insert a row with the same primary key" $
it "fails returning a 409 Conflict" $ it "fails returning a 409 Conflict" $
post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |] `shouldRespondWith` 409 post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |]
`shouldRespondWith`
[json|{"hint":null,"details":"Key (k)=(k1) already exists.","code":"23505","message":"duplicate key value violates unique constraint \"contacts_pkey\""}|]
{ matchStatus = 409
, matchHeaders = [matchContentTypeJson]
}
context "attempting to insert a row with conflicting unique constraint" $ context "attempting to insert a row with conflicting unique constraint" $
it "fails returning a 409 Conflict" $ it "fails returning a 409 Conflict" $
@@ -222,12 +243,23 @@ spec = do
, matchHeaders = ["Location" <:> location] , matchHeaders = ["Location" <:> location]
} }
context "empty object" $ context "empty objects" $ do
it "successfully populates table with all-default columns" $ it "successfully inserts a row with all-default columns" $ do
post "/items" "{}" `shouldRespondWith` "" post "/items" "{}" `shouldRespondWith` ""
{ matchStatus = 201 { matchStatus = 201
, matchHeaders = [] , matchHeaders = []
} }
post "/items" "[{}]" `shouldRespondWith` ""
{ matchStatus = 201
, matchHeaders = []
}
it "successfully inserts two rows with all-default columns" $
post "/items" "[{}, {}]" `shouldRespondWith` ""
{ matchStatus = 201
, matchHeaders = []
}
context "table with limited privileges" $ do context "table with limited privileges" $ do
it "succeeds if correct select is applied" $ it "succeeds if correct select is applied" $
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")] request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
@@ -237,20 +269,75 @@ spec = do
} }
it "fails if more columns are selected" $ it "fails if more columns are selected" $
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")] request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` [json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|] if actualPgVersion >= pgVersion112 then
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
else
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
)
{ matchStatus = 401 { matchStatus = 401
, matchHeaders = [] , matchHeaders = []
} }
it "fails if select is not specified" $ it "fails if select is not specified" $
request methodPost "/limited_article_stars" [("Prefer", "return=representation")] request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|] [json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
else
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
)
{ matchStatus = 401 { matchStatus = 401
, matchHeaders = [] , matchHeaders = []
} }
describe "CSV insert" $ do 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" $ context "disparate csv types" $
it "succeeds with multipart response" $ do it "succeeds with multipart response" $ do
pendingWith "Decide on what to do with CSV insert" pendingWith "Decide on what to do with CSV insert"
@@ -297,11 +384,13 @@ spec = do
} }
context "with wrong number of columns" $ context "with wrong number of columns" $
it "fails for too few" $ do it "fails for too few" $
p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz" request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
liftIO $ do `shouldRespondWith`
simpleStatus p `shouldBe` badRequest400 [json|{"message":"All lines must have same number of fields"}|]
isErrorFormat (simpleBody p) `shouldBe` True { matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "with unicode values" $ context "with unicode values" $
it "succeeds and returns usable location header" $ do it "succeeds and returns usable location header" $ do
@@ -317,22 +406,20 @@ spec = do
r <- get location r <- get location
liftIO $ simpleBody r `shouldBe` "["<>payload<>"]" liftIO $ simpleBody r `shouldBe` "["<>payload<>"]"
describe "Patching record" $ do describe "Patching record" $ do
context "to unknown uri" $ context "to unknown uri" $
it "gives a 404" $ it "indicates no table found by returning 404" $
request methodPatch "/fake" [] request methodPatch "/fake" []
[json| { "real": false } |] [json| { "real": false } |]
`shouldRespondWith` 404 `shouldRespondWith` 404
context "on an empty table" $ context "on an empty table" $
it "indicates no records found to update" $ it "indicates no records found to update by returning 404" $
request methodPatch "/empty_table" [] request methodPatch "/empty_table" []
[json| { "extra":20 } |] [json| { "extra":20 } |]
`shouldRespondWith` "" `shouldRespondWith` ""
{ matchStatus = 204, { matchStatus = 404,
matchHeaders = ["Content-Range" <:> "*/*"] matchHeaders = []
} }
context "in a nonempty table" $ do context "in a nonempty table" $ do
@@ -359,10 +446,15 @@ spec = do
[("Prefer", "return=representation")] [json| { "id":999999 } |] [("Prefer", "return=representation")] [json| { "id":999999 } |]
`shouldRespondWith` "[]" `shouldRespondWith` "[]"
{ {
matchStatus = 200, matchStatus = 404,
matchHeaders = ["Content-Range" <:> "*/*"] 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" $ it "returns updated object as array when return=rep" $
request methodPatch "/items?id=eq.2" request methodPatch "/items?id=eq.2"
[("Prefer", "return=representation")] [json| { "id":2 } |] [("Prefer", "return=representation")] [json| { "id":2 } |]
@@ -390,15 +482,26 @@ spec = do
[json| [{ a: "keepme", b: null }] |] [json| [{ a: "keepme", b: null }] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "can update based on a computed column" $ context "filtering by a computed column" $ do
request methodPatch it "is successful" $
"/items?always_true=eq.false" request methodPatch
[("Prefer", "return=representation")] "/items?is_first=eq.true"
[json| { id: 100 } |] [("Prefer", "return=representation")]
`shouldRespondWith` "[]" [json| { id: 100 } |]
{ matchStatus = 200, `shouldRespondWith` [json| [{ id: 100 }] |]
matchHeaders = ["Content-Range" <:> "*/*"] { 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 = []
}
it "can provide a representation" $ do it "can provide a representation" $ do
_ <- post "/items" _ <- post "/items"
@@ -427,21 +530,21 @@ spec = do
matchHeaders = ["Content-Range" <:> "*/*"] matchHeaders = ["Content-Range" <:> "*/*"]
} }
get "/items" `shouldRespondWith` request methodPatch "/items" [] [json| [{}] |]
[json|[{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15},{id:16},{"id":2},{"id":1}]|] `shouldRespondWith` ""
{ matchHeaders = [matchContentTypeJson] } {
matchStatus = 204,
matchHeaders = ["Content-Range" <:> "*/*"]
}
it "makes no updates and and returns 200, when patching with an empty json object and return=rep" $ do it "makes no updates and and returns 200, when patching with an empty json object and return=rep" $
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |] request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
`shouldRespondWith` "[]" `shouldRespondWith` "[]"
{ {
matchStatus = 200, matchStatus = 200,
matchHeaders = ["Content-Range" <:> "*/*"] matchHeaders = ["Content-Range" <:> "*/*"]
} }
get "/items" `shouldRespondWith`
[json| [{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15},{id:16},{"id":2},{"id":1}] |]
{ matchHeaders = [matchContentTypeJson] }
context "with unicode values" $ context "with unicode values" $
it "succeeds and returns values intact" $ do it "succeeds and returns values intact" $ do
void $ request methodPost "/no_pk" [] void $ request methodPost "/no_pk" []
@@ -453,6 +556,18 @@ spec = do
simpleBody p `shouldBe` "["<>payload<>"]" simpleBody p `shouldBe` "["<>payload<>"]"
simpleStatus p `shouldBe` ok200 simpleStatus p `shouldBe` ok200
context "PATCH with ?columns parameter" $ do
it "ignores json keys not included in ?columns" $
request methodPatch "/articles?id=eq.200&columns=body" [("Prefer", "return=representation")]
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`
[json|[{"id": 200, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]
{ matchStatus = 200
, matchHeaders = [] }
it "ignores json keys and gives 404 if no record updated" $
request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404
describe "Row level permission" $ describe "Row level permission" $
it "set user_id when inserting rows" $ do it "set user_id when inserting rows" $ do
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0" let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
+23 -8
View File
@@ -1,17 +1,18 @@
module Feature.JsonOperatorSpec where module Feature.JsonOperatorSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get)
import SpecHelper import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get) spec :: PgVersion -> SpecWith Application
spec actualPgVersion = describe "json and jsonb operators" $ do
spec :: SpecWith Application
spec = describe "json and jsonb operators" $ do
context "Shaping response with select parameter" $ do context "Shaping response with select parameter" $ do
it "obtains a json subfield one level with casting" $ it "obtains a json subfield one level with casting" $
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith` get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
@@ -52,14 +53,28 @@ spec = describe "json and jsonb operators" $ do
-- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate -- 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 it "fails when a double arrow ->> is followed with a single arrow ->" $ do
get "/json_arr?select=data->>c->1" get "/json_arr?select=data->>c->1"
`shouldRespondWith` [json| `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.", {"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"} |] "details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
)
{ matchStatus = 404 , matchHeaders = [] } { matchStatus = 404 , matchHeaders = [] }
get "/json_arr?select=data->>c->b" get "/json_arr?select=data->>c->b"
`shouldRespondWith` [json| `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.", {"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"} |] "details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
)
{ matchStatus = 404 , matchHeaders = [] } { matchStatus = 404 , matchHeaders = [] }
context "with array index" $ do context "with array index" $ do
+11 -5
View File
@@ -1,14 +1,16 @@
module Feature.NoJwtSpec where module Feature.NoJwtSpec where
-- {{{ Imports -- {{{ Imports
import Test.Hspec
import Test.Hspec.Wai
import Network.HTTP.Types
import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude import Protolude
import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
@@ -18,7 +20,11 @@ spec = describe "server started without JWT secret" $ do
it "responds with error on attempted auth" $ do it "responds with error on attempted auth" $ do
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA" let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 500 `shouldRespondWith`
[json|{"message":"Server lacks JWT secret"}|]
{ matchStatus = 500
, matchHeaders = [ matchContentTypeJson ]
}
it "behaves normally when user does not attempt auth" $ it "behaves normally when user does not attempt auth" $
request methodGet "/items" [] "" request methodGet "/items" [] ""
+3 -1
View File
@@ -1,10 +1,12 @@
module Feature.NonexistentSchemaSpec where module Feature.NonexistentSchemaSpec where
import Network.Wai (Application) import Network.Wai (Application)
import Protolude hiding (get)
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Protolude hiding (get)
spec :: SpecWith Application spec :: SpecWith Application
spec = spec =
describe "Non existent api schema" $ do describe "Non existent api schema" $ do
+3 -3
View File
@@ -1,13 +1,13 @@
module Feature.PgVersion95Spec where module Feature.PgVersion95Spec where
import Network.Wai (Application)
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get)
spec :: SpecWith Application spec :: SpecWith Application
spec = describe "features supported on PostgreSQL 9.5" $ spec = describe "features supported on PostgreSQL 9.5" $
+27 -7
View File
@@ -1,13 +1,13 @@
module Feature.PgVersion96Spec where module Feature.PgVersion96Spec where
import Network.Wai (Application)
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get)
spec :: SpecWith Application spec :: SpecWith Application
spec = spec =
@@ -34,10 +34,30 @@ spec =
"X-Test-2" <:> "key1=val1"]} "X-Test-2" <:> "key1=val1"]}
it "fails when setting headers with wrong json structure" $ do it "fails when setting headers with wrong json structure" $ do
get "/rpc/bad_guc_headers_1" `shouldRespondWith` 500 get "/rpc/bad_guc_headers_1"
get "/rpc/bad_guc_headers_2" `shouldRespondWith` 500 `shouldRespondWith`
get "/rpc/bad_guc_headers_3" `shouldRespondWith` 500 [json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
post "/rpc/bad_guc_headers_1" [json|{}|] `shouldRespondWith` 500 { 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" $ it "can set the same http header twice" $
get "/rpc/set_cookie_twice" get "/rpc/set_cookie_twice"
+2 -4
View File
@@ -1,12 +1,10 @@
module Feature.ProxySpec where module Feature.ProxySpec where
import Test.Hspec
import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Test.Hspec hiding (pendingWith)
import Protolude import Protolude
import SpecHelper
spec :: SpecWith Application spec :: SpecWith Application
spec = spec =
+6 -5
View File
@@ -1,14 +1,15 @@
module Feature.QueryLimitedSpec where module Feature.QueryLimitedSpec where
import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))
import Network.HTTP.Types
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus))
import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application spec :: SpecWith Application
spec = spec =
+154 -60
View File
@@ -1,19 +1,21 @@
module Feature.QuerySpec where module Feature.QuerySpec where
import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleHeaders))
import Network.HTTP.Types
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import Network.Wai.Test (SResponse(simpleHeaders))
import SpecHelper
import Text.Heredoc import Text.Heredoc
import Network.Wai (Application)
import Protolude hiding (get) import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application spec :: PgVersion -> SpecWith Application
spec = do spec actualPgVersion = do
describe "Querying a table with a column called count" $ describe "Querying a table with a column called count" $
it "should not confuse count column with pg_catalog.count aggregate" $ it "should not confuse count column with pg_catalog.count aggregate" $
@@ -118,6 +120,29 @@ spec = do
[json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |] [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
{ matchHeaders = [matchContentTypeJson] } { 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 it "finds matches with different dictionaries" $ do
get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith` get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith`
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |] [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
@@ -126,6 +151,12 @@ spec = do
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |] [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
{ matchHeaders = [matchContentTypeJson] } { 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 it "can be negated with not operator" $ do
get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith` get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith`
[json| [ [json| [
@@ -144,6 +175,13 @@ spec = do
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"}, {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|] {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
{ matchHeaders = [matchContentTypeJson] } { 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] }
it "matches with computed column" $ it "matches with computed column" $
get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith` get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
@@ -181,7 +219,6 @@ spec = do
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
describe "Shaping response with select parameter" $ do describe "Shaping response with select parameter" $ do
it "selectStar works in absense of parameter" $ it "selectStar works in absense of parameter" $
get "/complex_items?id=eq.3" `shouldRespondWith` get "/complex_items?id=eq.3" `shouldRespondWith`
[str|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|] [str|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|]
@@ -333,6 +370,9 @@ spec = do
it "can detect fk relations through views to tables in the public schema" $ it "can detect fk relations through views to tables in the public schema" $
get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200 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 parent without specifying primary key" $ it "can request parent without specifying primary key" $
get "/articleStars?select=createdAt,article(owner),user(name)&limit=1" `shouldRespondWith` get "/articleStars?select=createdAt,article(owner),user(name)&limit=1" `shouldRespondWith`
[json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|] [json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|]
@@ -433,22 +473,33 @@ spec = do
{"number_of_projects":2,"client":{"name":"Apple"}}] |] {"number_of_projects":2,"client":{"name":"Apple"}}] |]
{ matchHeaders = [matchContentTypeJson] } { 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 "path fixed" $ do describe "path fixed" $ do
it "works when requesting children 2 levels" $ it "works when requesting children 2 levels" $
get "/clients?id=eq.1&select=id,projects:projects.client_id(id,tasks(id))" `shouldRespondWith` get "/clients?id=eq.1&select=id,projects:projects!client_id(id,tasks(id))" `shouldRespondWith`
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|] [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "works with parent relation" $ it "works with parent relation" $
get "/message?select=id,body,sender:person.sender(name),recipient:person.recipient(name)&id=lt.4" `shouldRespondWith` get "/message?select=id,body,sender:person!sender(name),recipient:person!recipient(name)&id=lt.4" `shouldRespondWith`
[json| [json|
[{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}}, [{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
{"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}}, {"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
{"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |] {"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "fails with an unknown relation" $
get "/message?select=id,sender:person.space(name)&id=lt.4" `shouldRespondWith`
[json|{"message":"Could not find foreign keys between these entities, No relation found between message and person"}|]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson] }
it "works with a parent view relation" $ it "works with a parent view relation" $
get "/message?select=id,body,sender:person_detail.sender(name,sent),recipient:person_detail.recipient(name,received)&id=lt.4" `shouldRespondWith` get "/message?select=id,body,sender:person_detail!sender(name,sent),recipient:person_detail!recipient(name,received)&id=lt.4" `shouldRespondWith`
[json| [json|
[{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}, [{"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":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
@@ -456,10 +507,20 @@ spec = do
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "works with many<->many relation" $ it "works with many<->many relation" $
get "/tasks?select=id,users:users.users_tasks(id)" `shouldRespondWith` get "/tasks?select=id,users:users!users_tasks(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":[]}]|] [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] } { matchHeaders = [matchContentTypeJson] }
-- TODO Remove in next major version(7.0)
describe "old dot '.' symbol, deprecated" $
it "still works" $ do
get "/clients?id=eq.1&select=id,projects:projects.client_id(id,tasks(id))" `shouldRespondWith`
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/tasks?select=id,users:users.users_tasks(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] }
describe "aliased embeds" $ do describe "aliased embeds" $ do
it "works with child relation" $ 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` 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`
@@ -528,7 +589,7 @@ spec = do
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "embeds childs recursively" $ it "embeds childs recursively" $
get "/family_tree?id=eq.1&select=id,name, childs:family_tree.parent(id,name,childs:family_tree.parent(id,name))" `shouldRespondWith` get "/family_tree?id=eq.1&select=id,name, childs:family_tree!parent(id,name,childs:family_tree!parent(id,name))" `shouldRespondWith`
[json|[{ [json|[{
"id": "1", "name": "Parental Unit", "childs": [ "id": "1", "name": "Parental Unit", "childs": [
{ "id": "2", "name": "Kid One", "childs": [ { "id": "4", "name": "Grandkid One" } ] }, { "id": "2", "name": "Kid One", "childs": [ { "id": "4", "name": "Grandkid One" } ] },
@@ -537,7 +598,7 @@ spec = do
}]|] { matchHeaders = [matchContentTypeJson] } }]|] { matchHeaders = [matchContentTypeJson] }
it "embeds parent and then embeds childs" $ it "embeds parent and then embeds childs" $
get "/family_tree?id=eq.2&select=id,name,parent(id,name,childs:family_tree.parent(id,name))" `shouldRespondWith` get "/family_tree?id=eq.2&select=id,name,parent(id,name,childs:family_tree!parent(id,name))" `shouldRespondWith`
[json|[{ [json|[{
"id": "2", "name": "Kid One", "parent": { "id": "2", "name": "Kid One", "parent": {
"id": "1", "name": "Parental Unit", "childs": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ] "id": "1", "name": "Parental Unit", "childs": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]
@@ -560,7 +621,7 @@ spec = do
}]|] { matchHeaders = [matchContentTypeJson] } }]|] { matchHeaders = [matchContentTypeJson] }
it "embeds childs" $ do it "embeds childs" $ do
get "/organizations?select=id,name,refereeds:organizations.referee(id,name)&id=eq.1" `shouldRespondWith` get "/organizations?select=id,name,refereeds:organizations!referee(id,name)&id=eq.1" `shouldRespondWith`
[json|[{ [json|[{
"id": 1, "name": "Referee Org", "id": 1, "name": "Referee Org",
"refereeds": [ "refereeds": [
@@ -574,7 +635,7 @@ spec = do
} }
] ]
}]|] { matchHeaders = [matchContentTypeJson] } }]|] { matchHeaders = [matchContentTypeJson] }
get "/organizations?select=id,name,auditees:organizations.auditor(id,name)&id=eq.2" `shouldRespondWith` get "/organizations?select=id,name,auditees:organizations!auditor(id,name)&id=eq.2" `shouldRespondWith`
[json|[{ [json|[{
"id": 2, "name": "Auditor Org", "id": 2, "name": "Auditor Org",
"auditees": [ "auditees": [
@@ -589,6 +650,42 @@ spec = do
] ]
}]|] { matchHeaders = [matchContentTypeJson] } }]|] { 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] }
describe "ordering response" $ do describe "ordering response" $ do
it "by a column asc" $ it "by a column asc" $
get "/items?id=lte.2&order=id.asc" get "/items?id=lte.2&order=id.asc"
@@ -737,7 +834,11 @@ spec = do
it "should respond an unknown accept type with 415" $ it "should respond an unknown accept type with 415" $
request methodGet "/simple_pk" request methodGet "/simple_pk"
(acceptHdrs "text/unknowntype") "" (acceptHdrs "text/unknowntype") ""
`shouldRespondWith` 415 `shouldRespondWith`
[json|{"message":"None of these Content-Types are available: text/unknowntype"}|]
{ matchStatus = 415
, matchHeaders = [matchContentTypeJson]
}
it "should respond correctly to */* in accept header" $ it "should respond correctly to */* in accept header" $
request methodGet "/simple_pk" request methodGet "/simple_pk"
@@ -793,7 +894,6 @@ spec = do
respHeaders `shouldSatisfy` matchHeader respHeaders `shouldSatisfy` matchHeader
"Content-Location" "/simple_pk" "Content-Location" "/simple_pk"
describe "weird requests" $ do describe "weird requests" $ do
it "can query as normal" $ do it "can query as normal" $ do
get "/Escap3e;" `shouldRespondWith` get "/Escap3e;" `shouldRespondWith`
@@ -833,50 +933,39 @@ spec = do
[json|[{":arr->ow::cast":" arrow-1 ","(inside,parens)":" parens-1 ","a.dotted.column":" dotted-1 "," col w space ":" space-1"}]|] [json|[{":arr->ow::cast":" arrow-1 ","(inside,parens)":" parens-1 ","a.dotted.column":" dotted-1 "," col w space ":" space-1"}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
describe "binary output" $ do context "binary output" $ do
context "on GET" $ do it "can query if a single column is selected" $
it "can query if a single column is selected" $ request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") "" `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC" { matchStatus = 200
{ matchStatus = 200 , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] }
}
it "fails if a single column is not selected" $ do it "can get raw output with Accept: text/plain" $
request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") "" request methodGet "/projects?select=name&id=eq.1" (acceptHdrs "text/plain") ""
`shouldRespondWith` 406 `shouldRespondWith` "Windows 7"
request methodGet "/images?select=*&name=eq.A.png" (acceptHdrs "application/octet-stream") "" { matchStatus = 200
`shouldRespondWith` 406 , matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
request methodGet "/images?name=eq.A.png" (acceptHdrs "application/octet-stream") "" }
`shouldRespondWith` 406
it "concatenates results if more than one row is returned" $ it "fails if a single column is not selected" $ do
request methodGet "/images_base64?select=img&name=in.(A.png,B.png)" (acceptHdrs "application/octet-stream") "" request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII=" `shouldRespondWith`
{ matchStatus = 200 [json| {"message":"application/octet-stream requested but more than one column was selected"} |]
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"] { matchStatus = 406
} , matchHeaders = [matchContentTypeJson]
}
request methodGet "/images?select=*&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` 406
request methodGet "/images?name=eq.A.png" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` 406
context "on RPC" $ do it "concatenates results if more than one row is returned" $
context "Proc that returns scalar" $ request methodGet "/images_base64?select=img&name=in.(A.png,B.png)" (acceptHdrs "application/octet-stream") ""
it "can query without selecting column" $ `shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") "" { matchStatus = 200
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC" , matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
{ matchStatus = 200 }
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
}
context "Proc that returns rows" $ do
it "can query if a single column is selected" $
request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
}
it "fails if a single column is not selected" $
request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` 406
describe "HTTP request env vars" $ do describe "HTTP request env vars" $ do
it "custom header is set" $ it "custom header is set" $
@@ -994,7 +1083,12 @@ spec = do
[json| [] |] { matchHeaders = [matchContentTypeJson] } [json| [] |] { matchHeaders = [matchContentTypeJson] }
it "only returns an empty result set if the in value is empty" $ 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` 400 get "/items_with_different_col_types?int_data=in.( ,3,4)"
`shouldRespondWith`
[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 describe "Embedding when column name = table name" $ do
it "works with child embeds" $ it "works with child embeds" $
+37 -10
View File
@@ -1,17 +1,17 @@
module Feature.RangeSpec where 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
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus))
import qualified Data.ByteString.Lazy as BL
import Protolude hiding (get)
import SpecHelper import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get)
defaultRange :: BL.ByteString defaultRange :: BL.ByteString
defaultRange = [json| { "min": 0, "max": 15 } |] defaultRange = [json| { "min": 0, "max": 15 } |]
@@ -38,7 +38,6 @@ spec = do
{ matchHeaders = ["Content-Range" <:> "0-14/*"] } { matchHeaders = ["Content-Range" <:> "0-14/*"] }
context "with range headers" $ do context "with range headers" $ do
context "of acceptable range" $ do context "of acceptable range" $ do
it "succeeds with partial content" $ do it "succeeds with partial content" $ do
r <- request methodPost "/rpc/getitemrange" r <- request methodPost "/rpc/getitemrange"
@@ -156,7 +155,6 @@ spec = do
, matchHeaders = ["Content-Range" <:> "0-0/*"] , matchHeaders = ["Content-Range" <:> "0-0/*"]
} }
it "limit and offset works on first level" $ it "limit and offset works on first level" $
get "/items?select=id&order=id.asc&limit=3&offset=2" get "/items?select=id&order=id.asc&limit=3&offset=2"
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|] `shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
@@ -164,8 +162,37 @@ spec = do
, matchHeaders = ["Content-Range" <:> "2-4/*"] , matchHeaders = ["Content-Range" <:> "2-4/*"]
} }
context "with range headers" $ do it "succeeds if offset equals 0 as a no-op" $
get "/items?select=id&offset=0"
`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 "succeeds if offset is negative as a no-op" $
get "/items?select=id&offset=-4"
`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 "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 "with range headers" $ do
context "of acceptable range" $ do context "of acceptable range" $ do
it "succeeds with partial content" $ do it "succeeds with partial content" $ do
r <- request methodGet "/items" r <- request methodGet "/items"
+33
View File
@@ -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" 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"] }
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"] }
+30
View File
@@ -0,0 +1,30 @@
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| [{"table": "items"}, {"table": "subitems"}] |]
{ matchHeaders = [matchContentTypeJson] }
+161 -13
View File
@@ -1,20 +1,24 @@
module Feature.RpcSpec where module Feature.RpcSpec where
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import Network.Wai.Test (SResponse(simpleStatus, simpleBody))
import qualified Data.ByteString.Lazy as BL (empty) import qualified Data.ByteString.Lazy as BL (empty)
import SpecHelper import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleBody, simpleStatus))
import Network.HTTP.Types
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc import Text.Heredoc
import Network.Wai (Application)
import Protolude hiding (get) import PostgREST.Types (PgVersion, pgVersion100, pgVersion109,
pgVersion110, pgVersion112, pgVersion114,
pgVersion95)
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application spec :: PgVersion -> SpecWith Application
spec = spec actualPgVersion =
describe "remote procedure call" $ do describe "remote procedure call" $ do
context "a proc that returns a set" $ do context "a proc that returns a set" $ do
it "returns paginated results" $ do it "returns paginated results" $ do
@@ -77,6 +81,21 @@ spec =
it "should fail with 404 on unknown proc args" $ do it "should fail with 404 on unknown proc args" $ do
get "/rpc/sayhello" `shouldRespondWith` 404 get "/rpc/sayhello" `shouldRespondWith` 404
get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404 get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404
it "should not ignore unknown args and fail with 404" $
get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`
let
message :: Text
message
| actualPgVersion < pgVersion95 = "function test.add_them(a := integer, b := integer, smthelse := text) does not exist"
| otherwise = "function test.add_them(a => integer, b => integer, smthelse => text) does not exist"
in [json| {
"code": "42883",
"details": null,
"hint": "No function matches the given name and argument types. You might need to add explicit type casts.",
"message": #{message} } |]
{ matchStatus = 404
, matchHeaders = [matchContentTypeJson]
}
it "works when having uppercase identifiers" $ do it "works when having uppercase identifiers" $ do
get "/rpc/quotedFunction?user=mscott&fullName=Michael Scott&SSN=401-32-XXXX" `shouldRespondWith` get "/rpc/quotedFunction?user=mscott&fullName=Michael Scott&SSN=401-32-XXXX" `shouldRespondWith`
@@ -213,6 +232,48 @@ spec =
[json|null|] [json|null|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
context "proc argument types" $ do
it "accepts a variety of arguments" $
post "/rpc/varied_arguments"
[json| {
"double": 3.1,
"varchar": "hello",
"boolean": true,
"date": "20190101",
"money": 0,
"enum": "foo",
"integer": 43,
"json": {"some key": "some value"},
"jsonb": {"another key": [1, 2, "3"]}
} |]
`shouldRespondWith`
[json|"Hi"|]
{ 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 context "improper input" $ do
it "rejects unknown content type even if payload is good" $ do it "rejects unknown content type even if payload is good" $ do
request methodPost "/rpc/sayhello" request methodPost "/rpc/sayhello"
@@ -236,7 +297,11 @@ spec =
context "unsupported verbs" $ do context "unsupported verbs" $ do
it "DELETE fails" $ it "DELETE fails" $
request methodDelete "/rpc/sayhello" [] "" request methodDelete "/rpc/sayhello" [] ""
`shouldRespondWith` 405 `shouldRespondWith`
[json|{"message":"Bad Request"}|]
{ matchStatus = 405
, matchHeaders = [matchContentTypeJson]
}
it "PATCH fails" $ it "PATCH fails" $
request methodPatch "/rpc/sayhello" [] "" request methodPatch "/rpc/sayhello" [] ""
`shouldRespondWith` 405 `shouldRespondWith` 405
@@ -302,7 +367,12 @@ spec =
} }
it "defaults to status 500 if RAISE code is PT not followed by a number" $ it "defaults to status 500 if RAISE code is PT not followed by a number" $
get "/rpc/raise_bad_pt" `shouldRespondWith` 500 get "/rpc/raise_bad_pt"
`shouldRespondWith`
[json|{"hint": null, "details": null}|]
{ matchStatus = 500
, matchHeaders = [ matchContentTypeJson ]
}
context "expects a single json object" $ do context "expects a single json object" $ do
it "does not expand posted json into parameters" $ it "does not expand posted json into parameters" $
@@ -339,10 +409,84 @@ spec =
get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|] get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|]
get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [str|"123"|] get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [str|"123"|]
context "only for POST rpc" $ context "only for POST rpc" $ do
it "gives a parse filter error if GET style proc args are specified" $ it "gives a parse filter error if GET style proc args are specified" $
post "/rpc/sayhello?name=John" [json|{}|] `shouldRespondWith` 400 post "/rpc/sayhello?name=John" [json|{}|] `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] }
context "bulk RPC" $ do
it "works with a scalar function an returns a json array" $
post "/rpc/add_them"
[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")]
"a,b\n1,2\n4,6\n100,200"
`shouldRespondWith`
[json|
[3, 10, 300]
|]
{ matchStatus = 200
, matchHeaders = [matchContentTypeJson]
}
it "works with a non-scalar result" $
post "/rpc/get_projects_below?select=id,name"
[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 "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; charset=utf-8"]
}
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 rows" $ do
it "can query if a single column is selected" $
request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (acceptHdrs "application/octet-stream") ""
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
{ matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
}
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
, matchHeaders = [matchContentTypeJson]
}
context "only for GET rpc" $ do context "only for GET rpc" $ do
it "should fail on mutating procs" $ do it "should fail on mutating procs" $ do
get "/rpc/callcounter" `shouldRespondWith` 500 get "/rpc/callcounter" `shouldRespondWith` 500
@@ -371,6 +515,10 @@ spec =
get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith` 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"}]|] [json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]
{ matchHeaders = [matchContentTypeJson] } { 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 an argument of custom type in public schema" $ it "should work with an argument of custom type in public schema" $
get "/rpc/test_arg?my_arg=something" `shouldRespondWith` get "/rpc/test_arg?my_arg=something" `shouldRespondWith`
+58 -50
View File
@@ -1,16 +1,16 @@
module Feature.SingularSpec where module Feature.SingularSpec where
import Text.Heredoc import Network.Wai (Application)
import Network.Wai.Test (SResponse (..))
import Network.HTTP.Types
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Text.Heredoc
import Network.Wai.Test (SResponse(..))
import Network.Wai (Application)
import Protolude hiding (get)
import SpecHelper import SpecHelper
import Protolude hiding (get)
spec :: SpecWith Application spec :: SpecWith Application
@@ -43,7 +43,6 @@ spec =
{ matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"] } { matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"] }
context "when updating rows" $ do context "when updating rows" $ do
it "works for one row" $ do it "works for one row" $ do
_ <- post "/addresses" [json| { id: 97, address: "A Street" } |] _ <- post "/addresses" [json| { id: 97, address: "A Street" } |]
request methodPatch request methodPatch
@@ -56,10 +55,9 @@ spec =
it "raises an error for multiple rows" $ do it "raises an error for multiple rows" $ do
_ <- post "/addresses" [json| { id: 98, address: "xxx" } |] _ <- post "/addresses" [json| { id: 98, address: "xxx" } |]
_ <- post "/addresses" [json| { id: 99, address: "yyy" } |] _ <- post "/addresses" [json| { id: 99, address: "yyy" } |]
p <- request methodPatch p <- request methodPatch "/addresses?id=gt.0"
"/addresses?id=gt.0" [("Prefer", "return=representation"), singular]
[("Prefer", "return=representation"), singular] [json| { address: "zzz" } |]
[json| { address: "zzz" } |]
liftIO $ do liftIO $ do
simpleStatus p `shouldBe` notAcceptable406 simpleStatus p `shouldBe` notAcceptable406
isErrorFormat (simpleBody p) `shouldBe` True isErrorFormat (simpleBody p) `shouldBe` True
@@ -67,15 +65,16 @@ spec =
-- the rows should not be updated, either -- the rows should not be updated, either
get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|] get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|]
it "raises an error for zero rows" $ do it "raises an error for zero rows" $
p <- request methodPatch "/items?id=gt.0&id=lt.0" request methodPatch "/items?id=gt.0&id=lt.0"
[("Prefer", "return=representation"), singular] [json|{"id":1}|] [("Prefer", "return=representation"), singular] [json|{"id":1}|]
liftIO $ do `shouldRespondWith`
simpleStatus p `shouldBe` notAcceptable406 [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
isErrorFormat (simpleBody p) `shouldBe` True { matchStatus = 406
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
context "when creating rows" $ do context "when creating rows" $ do
it "works for one row" $ do it "works for one row" $ do
p <- request methodPost p <- request methodPost
"/addresses" "/addresses"
@@ -117,17 +116,17 @@ spec =
, matchHeaders = ["Content-Range" <:> "*/*"] , matchHeaders = ["Content-Range" <:> "*/*"]
} }
it "raises an error when creating zero entities" $ do it "raises an error when creating zero entities" $
p <- request methodPost request methodPost "/addresses"
"/addresses" [("Prefer", "return=representation"), singular]
[("Prefer", "return=representation"), singular] [json| [ ] |]
[json| [ ] |] `shouldRespondWith`
liftIO $ do [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
simpleStatus p `shouldBe` notAcceptable406 { matchStatus = 406
isErrorFormat (simpleBody p) `shouldBe` True , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
context "when deleting rows" $ do context "when deleting rows" $ do
it "works for one row" $ do it "works for one row" $ do
p <- request methodDelete p <- request methodDelete
"/items?id=eq.11" "/items?id=eq.11"
@@ -146,21 +145,24 @@ spec =
, matchHeaders = ["Content-Range" <:> "0-9/*"] , matchHeaders = ["Content-Range" <:> "0-9/*"]
} }
it "raises an error when deleting zero entities" $ do it "raises an error when deleting zero entities" $
p <- request methodDelete "/items?id=lt.0" request methodDelete "/items?id=lt.0"
[("Prefer", "return=representation"), singular] "" [("Prefer", "return=representation"), singular] ""
liftIO $ do `shouldRespondWith`
simpleStatus p `shouldBe` notAcceptable406 [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
isErrorFormat (simpleBody p) `shouldBe` True { matchStatus = 406
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
context "when calling a stored proc" $ do context "when calling a stored proc" $ do
it "fails for zero rows" $
it "fails for zero rows" $ do request methodPost "/rpc/getproject"
p <- request methodPost "/rpc/getproject" [singular] [json|{ "id": 9999999}|]
[singular] [json|{ "id": 9999999}|] `shouldRespondWith`
liftIO $ do [str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
simpleStatus p `shouldBe` notAcceptable406 { matchStatus = 406
isErrorFormat (simpleBody p) `shouldBe` True , matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
-- this one may be controversial, should vnd.pgrst.object include -- this one may be controversial, should vnd.pgrst.object include
-- the likes of 2 and "hello?" -- the likes of 2 and "hello?"
@@ -174,20 +176,26 @@ spec =
[singular] [json|{ "id": 1}|] `shouldRespondWith` [singular] [json|{ "id": 1}|] `shouldRespondWith`
[str|{"id":1,"name":"Windows 7","client_id":1}|] [str|{"id":1,"name":"Windows 7","client_id":1}|]
it "fails for multiple rows" $ do it "fails for multiple rows" $
p <- request methodPost "/rpc/getallprojects" [singular] "{}" request methodPost "/rpc/getallprojects"
liftIO $ do [singular] "{}"
simpleStatus p `shouldBe` notAcceptable406 `shouldRespondWith`
isErrorFormat (simpleBody p) `shouldBe` True [str|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
it "executes the proc exactly once per request" $ do it "executes the proc exactly once per request" $ do
request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |] request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]
`shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|] `shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]
p <- request methodPost "/rpc/setprojects" [singular]
[json| {"id_l": 1, "id_h": 2, "name": "changed"} |] request methodPost "/rpc/setprojects" [singular]
liftIO $ do [json| {"id_l": 1, "id_h": 2, "name": "changed"} |]
simpleStatus p `shouldBe` notAcceptable406 `shouldRespondWith`
isErrorFormat (simpleBody p) `shouldBe` True [str|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
{ matchStatus = 406
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
}
-- should not actually have executed the function -- should not actually have executed the function
request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |] request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]
+183 -13
View File
@@ -1,22 +1,22 @@
module Feature.StructureSpec where module Feature.StructureSpec where
import Test.Hspec import Control.Lens ((^?))
import Test.Hspec.Wai
import Network.HTTP.Types
import PostgREST.Config (docsVersion)
import Control.Lens ((^?))
import Data.Aeson.Types (Value (..)) import Data.Aeson.Types (Value (..))
import Network.Wai (Application)
import Network.Wai.Test (SResponse (..))
import Data.Aeson.Lens import Data.Aeson.Lens
import Data.Aeson.QQ import Data.Aeson.QQ
import Network.HTTP.Types
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import PostgREST.Config (docsVersion)
import Protolude hiding (get)
import SpecHelper import SpecHelper
import Network.Wai (Application)
import Network.Wai.Test (SResponse(..))
import Protolude hiding (get)
spec :: SpecWith Application spec :: SpecWith Application
spec = do spec = do
@@ -45,6 +45,7 @@ spec = do
childGetSummary = r ^? method "get" . key "summary" childGetSummary = r ^? method "get" . key "summary"
childGetDescription = r ^? method "get" . key "description" childGetDescription = r ^? method "get" . key "description"
getParameters = r ^? method "get" . key "parameters" getParameters = r ^? method "get" . key "parameters"
postParameters = r ^? method "post" . key "parameters"
postResponse = r ^? method "post" . key "responses" . key "201" . key "description" postResponse = r ^? method "post" . key "responses" . key "201" . key "description"
patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description" patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description"
deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description" deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description"
@@ -79,6 +80,15 @@ spec = do
] ]
|] |]
postParameters `shouldBe` Just
[aesonQQ|
[
{ "$ref": "#/parameters/body.child_entities" },
{ "$ref": "#/parameters/select" },
{ "$ref": "#/parameters/preferReturn" }
]
|]
postResponse `shouldBe` Just "Created" postResponse `shouldBe` Just "Created"
patchResponse `shouldBe` Just "No Content" patchResponse `shouldBe` Just "No Content"
@@ -125,7 +135,7 @@ spec = do
"type": "integer" "type": "integer"
}, },
"name": { "name": {
"description": "child_entities name comment", "description": "child_entities name comment. Can be longer than sixty-three characters long",
"format": "text", "format": "text",
"type": "string" "type": "string"
}, },
@@ -219,6 +229,158 @@ spec = do
] ]
|] |]
describe "PostgreSQL to Swagger Type Mapping" $ do
it "character varying to string" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character_varying"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "character varying",
"type": "string"
}
|]
it "character(1) to string" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"maxLength": 1,
"format": "character",
"type": "string"
}
|]
it "text to string" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_text"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "text",
"type": "string"
}
|]
it "boolean to boolean" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_boolean"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "boolean",
"type": "boolean"
}
|]
it "smallint to integer" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_smallint"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "smallint",
"type": "integer"
}
|]
it "integer to integer" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_integer"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "integer",
"type": "integer"
}
|]
it "bigint to integer" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bigint"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "bigint",
"type": "integer"
}
|]
it "numeric to number" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_numeric"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "numeric",
"type": "number"
}
|]
it "real to number" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_real"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "real",
"type": "number"
}
|]
it "double_precision to number" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_double_precision"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "double precision",
"type": "number"
}
|]
describe "RPC" $ do describe "RPC" $ do
it "includes function summary/description and body schema for arguments" $ do it "includes function summary/description and body schema for arguments" $ do
@@ -249,7 +411,7 @@ spec = do
"properties": { "properties": {
"double": { "double": {
"format": "double precision", "format": "double precision",
"type": "string" "type": "number"
}, },
"varchar": { "varchar": {
"format": "character varying", "format": "character varying",
@@ -274,6 +436,14 @@ spec = do
"integer": { "integer": {
"format": "integer", "format": "integer",
"type": "integer" "type": "integer"
},
"json": {
"format": "json",
"type": "string"
},
"jsonb": {
"format": "jsonb",
"type": "string"
} }
}, },
"type": "object", "type": "object",
+4 -4
View File
@@ -1,15 +1,15 @@
module Feature.UnicodeSpec where module Feature.UnicodeSpec where
import Control.Monad (void)
import Network.Wai (Application)
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.Wai (Application)
import Control.Monad (void)
import Protolude hiding (get)
import SpecHelper import SpecHelper
import Protolude hiding (get)
spec :: SpecWith Application spec :: SpecWith Application
spec = spec =
describe "Reading and writing to unicode schema and table names" $ describe "Reading and writing to unicode schema and table names" $
+76 -22
View File
@@ -1,16 +1,16 @@
module Feature.UpsertSpec where module Feature.UpsertSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import SpecHelper
import Network.Wai (Application)
import Protolude hiding (get, put)
import Text.Heredoc import Text.Heredoc
import Protolude hiding (get, put)
import SpecHelper
spec :: SpecWith Application spec :: SpecWith Application
spec = spec =
describe "UPSERT" $ do describe "UPSERT" $ do
@@ -44,6 +44,12 @@ spec =
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson] , 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] }
context "when Prefer: resolution=ignore-duplicates is specified" $ do context "when Prefer: resolution=ignore-duplicates is specified" $ do
it "INSERTs and ignores rows on pk conflict" $ it "INSERTs and ignores rows on pk conflict" $
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")] request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
@@ -104,48 +110,96 @@ spec =
context "Restrictions" $ do context "Restrictions" $ do
it "fails if Range is specified" $ it "fails if Range is specified" $
request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")] request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]
[str| [ { "name": "Javascript", "rank": 1 } ]|] `shouldRespondWith` 400 [str| [ { "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" $ it "fails if limit is specified" $
put "/tiobe_pls?name=eq.Javascript&limit=1" put "/tiobe_pls?name=eq.Javascript&limit=1"
[str| [ { "name": "Javascript", "rank": 1 } ]|] `shouldRespondWith` 400 [str| [ { "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" $ it "fails if offset is specified" $
put "/tiobe_pls?name=eq.Javascript&offset=1" put "/tiobe_pls?name=eq.Javascript&offset=1"
[str| [ { "name": "Javascript", "rank": 1 } ]|] `shouldRespondWith` 400 [str| [ { "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 the payload has more than one row" $ it "fails if the payload has more than one row" $
put "/tiobe_pls?name=eq.Go" put "/tiobe_pls?name=eq.Go"
[str| [ { "name": "Go", "rank": 19 }, { "name": "Swift", "rank": 12 } ]|] `shouldRespondWith` 400 [str| [ { "name": "Go", "rank": 19 }, { "name": "Swift", "rank": 12 } ]|]
`shouldRespondWith`
[json|{"message":"PUT payload must contain a single row"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
it "fails if not all columns are specified" $ do it "fails if not all columns are specified" $ do
put "/tiobe_pls?name=eq.Go" put "/tiobe_pls?name=eq.Go"
[str| [ { "name": "Go" } ]|] `shouldRespondWith` 400 [str| [ { "name": "Go" } ]|]
`shouldRespondWith`
[json|{"message":"You must specify all columns in the payload when using PUT"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
put "/employees?first_name=eq.Susan&last_name=eq.Heidt" put "/employees?first_name=eq.Susan&last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000" } ]|] `shouldRespondWith` 400 [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000" } ]|]
`shouldRespondWith`
[json|{"message":"You must specify all columns in the payload when using PUT"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
it "rejects every other filter than pk cols eq's" $ do it "rejects every other filter than pk cols eq's" $ do
put "/tiobe_pls?rank=eq.19" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405 put "/tiobe_pls?rank=eq.19"
put "/tiobe_pls?id=not.eq.Java" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405 [str| [ { "name": "Go", "rank": 19 } ]|]
put "/tiobe_pls?id=in.(Go)" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405 `shouldRespondWith`
put "/tiobe_pls?and=(id.eq.Go)" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 405 [json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
put "/tiobe_pls?id=not.eq.Java"
[str| [ { "name": "Go", "rank": 19 } ]|]
`shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
put "/tiobe_pls?id=in.(Go)"
[str| [ { "name": "Go", "rank": 19 } ]|]
`shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
put "/tiobe_pls?and=(id.eq.Go)"
[str| [ { "name": "Go", "rank": 19 } ]|]
`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 it "fails if not all composite key cols are specified as eq filters" $ do
put "/employees?first_name=eq.Susan" put "/employees?first_name=eq.Susan"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|] [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` 405 `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" put "/employees?last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|] [str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` 405 `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 it "fails if the uri primary key doesn't match the payload primary key" $ do
put "/tiobe_pls?name=eq.MATLAB" put "/tiobe_pls?name=eq.MATLAB" [str| [ { "name": "Perl", "rank": 17 } ]|]
[str| [ { "name": "Perl", "rank": 17 } ]|] `shouldRespondWith` 400 `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" put "/employees?first_name=eq.Wendy&last_name=eq.Anderson"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|] `shouldRespondWith` 400 [str| [ { "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" $ it "fails if the table has no PK" $
put "/no_pk?a=eq.one&b=eq.two" [str| [ { "a": "one", "b": "two" } ]|] `shouldRespondWith` 405 put "/no_pk?a=eq.one&b=eq.two" [str| [ { "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 context "Inserting row" $ do
it "succeeds on table with single pk col" $ do it "succeeds on table with single pk col" $ do
+48 -30
View File
@@ -1,45 +1,51 @@
module Main where module Main where
import Test.Hspec import qualified Hasql.Pool as P
import SpecHelper
import qualified Hasql.Pool as P
import qualified Hasql.Transaction.Sessions as HT import qualified Hasql.Transaction.Sessions as HT
import PostgREST.App (postgrest) import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
import PostgREST.DbStructure (getDbStructure, getPgVersion) updateAction)
import PostgREST.Types (DbStructure(..), pgVersion95, pgVersion96) import Data.Function (id)
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate, updateAction) import Data.Time.Clock (getCurrentTime)
import Data.Function (id)
import Data.IORef
import Data.Time.Clock (getCurrentTime)
import qualified Feature.AuthSpec import Data.IORef
import Test.Hspec
import PostgREST.App (postgrest)
import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.Types (DbStructure (..), pgVersion95,
pgVersion96)
import Protolude
import SpecHelper
import qualified Feature.AndOrParamsSpec
import qualified Feature.AsymmetricJwtSpec import qualified Feature.AsymmetricJwtSpec
import qualified Feature.BinaryJwtSecretSpec
import qualified Feature.AudienceJwtSecretSpec import qualified Feature.AudienceJwtSecretSpec
import qualified Feature.AuthSpec
import qualified Feature.BinaryJwtSecretSpec
import qualified Feature.ConcurrentSpec import qualified Feature.ConcurrentSpec
import qualified Feature.CorsSpec import qualified Feature.CorsSpec
import qualified Feature.DeleteSpec import qualified Feature.DeleteSpec
import qualified Feature.ExtraSearchPathSpec import qualified Feature.ExtraSearchPathSpec
import qualified Feature.HtmlRawOutputSpec
import qualified Feature.InsertSpec import qualified Feature.InsertSpec
import qualified Feature.JsonOperatorSpec import qualified Feature.JsonOperatorSpec
import qualified Feature.NoJwtSpec import qualified Feature.NoJwtSpec
import qualified Feature.QueryLimitedSpec
import qualified Feature.QuerySpec
import qualified Feature.RangeSpec
import qualified Feature.StructureSpec
import qualified Feature.SingularSpec
import qualified Feature.UnicodeSpec
import qualified Feature.ProxySpec
import qualified Feature.AndOrParamsSpec
import qualified Feature.RpcSpec
import qualified Feature.NonexistentSchemaSpec import qualified Feature.NonexistentSchemaSpec
import qualified Feature.PgVersion95Spec import qualified Feature.PgVersion95Spec
import qualified Feature.PgVersion96Spec import qualified Feature.PgVersion96Spec
import qualified Feature.ProxySpec
import qualified Feature.QueryLimitedSpec
import qualified Feature.QuerySpec
import qualified Feature.RangeSpec
import qualified Feature.RawOutputTypesSpec
import qualified Feature.RootSpec
import qualified Feature.RpcSpec
import qualified Feature.SingularSpec
import qualified Feature.StructureSpec
import qualified Feature.UnicodeSpec
import qualified Feature.UpsertSpec import qualified Feature.UpsertSpec
import Protolude
main :: IO () main :: IO ()
main = do main = do
@@ -52,7 +58,7 @@ main = do
ver <- getPgVersion ver <- getPgVersion
HT.transaction HT.ReadCommitted HT.Read $ getDbStructure "test" ver HT.transaction HT.ReadCommitted HT.Read $ getDbStructure "test" ver
dbStructure <- pure $ either (panic.show) id result let dbStructure = either (panic.show) id result
getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime } getTime <- mkAutoUpdate defaultUpdateSettings { updateAction = getCurrentTime }
@@ -69,6 +75,8 @@ main = do
asymJwkSetApp = return $ postgrest (testCfgAsymJWKSet testDbConn) refDbStructure pool getTime $ pure () asymJwkSetApp = return $ postgrest (testCfgAsymJWKSet testDbConn) refDbStructure pool getTime $ pure ()
nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool getTime $ pure () nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool getTime $ pure ()
extraSearchPathApp = return $ postgrest (testCfgExtraSearchPath testDbConn) refDbStructure pool getTime $ pure () extraSearchPathApp = return $ postgrest (testCfgExtraSearchPath testDbConn) refDbStructure pool getTime $ pure ()
rootSpecApp = return $ postgrest (testCfgRootSpec testDbConn) refDbStructure pool getTime $ pure ()
htmlRawOutputApp = return $ postgrest (testCfgHtmlRawOutput testDbConn) refDbStructure pool getTime $ pure ()
let reset :: IO () let reset :: IO ()
reset = resetDb testDbConn reset = resetDb testDbConn
@@ -80,23 +88,28 @@ main = do
[("Feature.PgVersion96Spec", Feature.PgVersion96Spec.spec) | actualPgVersion >= pgVersion96] [("Feature.PgVersion96Spec", Feature.PgVersion96Spec.spec) | actualPgVersion >= pgVersion96]
specs = uncurry describe <$> [ specs = uncurry describe <$> [
("Feature.AuthSpec" , Feature.AuthSpec.spec) ("Feature.AuthSpec" , Feature.AuthSpec.spec actualPgVersion)
, ("Feature.RawOutputTypesSpec" , Feature.RawOutputTypesSpec.spec)
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec) , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
, ("Feature.CorsSpec" , Feature.CorsSpec.spec) , ("Feature.CorsSpec" , Feature.CorsSpec.spec)
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec) , ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
, ("Feature.InsertSpec" , Feature.InsertSpec.spec) , ("Feature.InsertSpec" , Feature.InsertSpec.spec actualPgVersion)
, ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec) , ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)
, ("Feature.QuerySpec" , Feature.QuerySpec.spec) , ("Feature.QuerySpec" , Feature.QuerySpec.spec actualPgVersion)
, ("Feature.RpcSpec" , Feature.RpcSpec.spec) , ("Feature.RpcSpec" , Feature.RpcSpec.spec actualPgVersion)
, ("Feature.RangeSpec" , Feature.RangeSpec.spec) , ("Feature.RangeSpec" , Feature.RangeSpec.spec)
, ("Feature.SingularSpec" , Feature.SingularSpec.spec) , ("Feature.SingularSpec" , Feature.SingularSpec.spec)
, ("Feature.StructureSpec" , Feature.StructureSpec.spec) , ("Feature.StructureSpec" , Feature.StructureSpec.spec)
, ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec) , ("Feature.AndOrParamsSpec" , Feature.AndOrParamsSpec.spec actualPgVersion)
] ++ extraSpecs ] ++ extraSpecs
hspec $ do hspec $ do
mapM_ (beforeAll_ reset . before withApp) specs mapM_ (beforeAll_ reset . before withApp) specs
-- this test runs with a raw-output-media-types set to text/html
beforeAll_ reset . before htmlRawOutputApp $
describe "Feature.HtmlRawOutputSpec" Feature.HtmlRawOutputSpec.spec
-- this test runs with a different server flag -- this test runs with a different server flag
beforeAll_ reset . before ltdApp $ beforeAll_ reset . before ltdApp $
describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec
@@ -136,3 +149,8 @@ main = do
-- this test runs with an extra search path -- this test runs with an extra search path
beforeAll_ reset . before extraSearchPathApp $ beforeAll_ reset . before extraSearchPathApp $
describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec describe "Feature.ExtraSearchPathSpec" Feature.ExtraSearchPathSpec.spec
-- this test runs with a root spec function override
when (actualPgVersion >= pgVersion96) $
beforeAll_ reset . before rootSpecApp $
describe "Feature.RootSpec" Feature.RootSpec.spec
+47 -37
View File
@@ -1,34 +1,29 @@
module SpecHelper where module SpecHelper where
import Control.Monad (void) 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 qualified System.IO.Error as E import Control.Monad (void)
import System.Environment (getEnv) import Data.Aeson (Value (..), decode, encode)
import Data.CaseInsensitive (CI (..))
import Data.List (lookup)
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
import System.Environment (getEnv)
import System.Process (readProcess)
import Text.Regex.TDFA ((=~))
import qualified Data.ByteString.Base64 as B64 (encode, decodeLenient)
import Data.CaseInsensitive (CI(..))
import qualified Data.Set as S
import qualified Data.Map.Strict as M
import Data.List (lookup)
import Text.Regex.TDFA ((=~))
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as BL
import System.Process (readProcess)
import Text.Heredoc
import PostgREST.Config (AppConfig(..))
import PostgREST.Types (JSPathExp(..))
import Test.Hspec
import Test.Hspec.Wai
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai.Test (SResponse(simpleStatus, simpleHeaders, simpleBody)) import Test.Hspec
import Test.Hspec.Wai
import Data.Maybe (fromJust) import Text.Heredoc
import Data.Aeson (decode, Value(..))
import qualified JSONSchema.Draft4 as D4
import PostgREST.Config (AppConfig (..))
import PostgREST.Types (JSPathExp (..), QualifiedIdentifier (..))
import Protolude import Protolude
matchContentTypeJson :: MatchHeader matchContentTypeJson :: MatchHeader
@@ -48,17 +43,19 @@ validateOpenApiResponse headers = do
let respHeaders = simpleHeaders r in let respHeaders = simpleHeaders r in
respHeaders `shouldSatisfy` respHeaders `shouldSatisfy`
\hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs \hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs
liftIO $ let Just body = decode (simpleBody r)
let respBody = simpleBody r Just schema <- liftIO $ decode <$> BL.readFile "test/fixtures/openapi.json"
schema :: D4.Schema let args :: M.Map Text Value
schema = D4.emptySchema { D4._schemaRef = Just "openapi.json" } args = M.fromList
schemaContext :: D4.SchemaWithURI D4.Schema [ ( "schema", schema )
schemaContext = D4.SchemaWithURI , ( "data", body ) ]
{ D4._swSchema = schema hdrs = acceptHdrs "application/json"
, D4._swURI = Just "test/fixtures/openapi.json" request methodPost "/rpc/validate_json_schema" hdrs (encode args)
} `shouldRespondWith` "true"
in { matchStatus = 200
D4.fetchFilesystemAndValidate schemaContext ((fromJust . decode) respBody) `shouldReturn` Right () , matchHeaders = []
}
getEnvVarWithDefault :: Text -> Text -> IO Text getEnvVarWithDefault :: Text -> Text -> IO Text
getEnvVarWithDefault var def = toS <$> getEnvVarWithDefault var def = toS <$>
@@ -67,10 +64,12 @@ getEnvVarWithDefault var def = toS <$>
_baseCfg :: AppConfig _baseCfg :: AppConfig
_baseCfg = -- Connection Settings _baseCfg = -- Connection Settings
AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000 AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000
-- No user configured Unix Socket
Nothing
-- Jwt settings -- Jwt settings
(Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False Nothing (Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False Nothing
-- Connection Modifiers -- Connection Modifiers
10 Nothing (Just "test.switch_role") 10 10 Nothing (Just "test.switch_role")
-- Debug Settings -- Debug Settings
True True
[ ("app.settings.app_host", "localhost") [ ("app.settings.app_host", "localhost")
@@ -80,6 +79,10 @@ _baseCfg = -- Connection Settings
(Right [JSPKey "role"]) (Right [JSPKey "role"])
-- Empty db-extra-search-path -- Empty db-extra-search-path
[] []
-- No root spec override
Nothing
-- Raw output media types
[]
testCfg :: Text -> AppConfig testCfg :: Text -> AppConfig
testCfg testDbConn = _baseCfg { configDatabase = testDbConn } testCfg testDbConn = _baseCfg { configDatabase = testDbConn }
@@ -127,12 +130,19 @@ testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "non
testCfgExtraSearchPath :: Text -> AppConfig testCfgExtraSearchPath :: Text -> AppConfig
testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] } testCfgExtraSearchPath testDbConn = (testCfg testDbConn) { configExtraSearchPath = ["public", "extensions"] }
testCfgRootSpec :: Text -> AppConfig
testCfgRootSpec testDbConn = (testCfg testDbConn) { configRootSpec = Just $ QualifiedIdentifier "test" "root"}
testCfgHtmlRawOutput :: Text -> AppConfig
testCfgHtmlRawOutput testDbConn = (testCfg testDbConn) { configRawMediaTypes = ["text/html"] }
setupDb :: Text -> IO () setupDb :: Text -> IO ()
setupDb dbConn = do setupDb dbConn = do
loadFixture dbConn "database" loadFixture dbConn "database"
loadFixture dbConn "roles" loadFixture dbConn "roles"
loadFixture dbConn "schema" loadFixture dbConn "schema"
loadFixture dbConn "jwt" loadFixture dbConn "jwt"
loadFixture dbConn "jsonschema"
loadFixture dbConn "privileges" loadFixture dbConn "privileges"
resetDb dbConn resetDb dbConn
@@ -141,7 +151,7 @@ resetDb dbConn = loadFixture dbConn "data"
loadFixture :: Text -> FilePath -> IO() loadFixture :: Text -> FilePath -> IO()
loadFixture dbConn name = loadFixture dbConn name =
void $ readProcess "psql" [toS dbConn, "-a", "-f", "test/fixtures/" ++ name ++ ".sql"] [] void $ readProcess "psql" ["--set", "ON_ERROR_STOP=1", toS dbConn, "-a", "-f", "test/fixtures/" ++ name ++ ".sql"] []
rangeHdrs :: ByteRange -> [Header] rangeHdrs :: ByteRange -> [Header]
rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)] rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]
+6 -6
View File
@@ -3,16 +3,16 @@ module TestTypes (
, CompoundPK(..) , CompoundPK(..)
) where ) where
import Data.Aeson ((.:))
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import Data.Aeson ((.:))
import Protolude import Protolude
data IncPK = IncPK { data IncPK = IncPK {
incId :: Int incId :: Int
, incNullableStr :: Maybe Text , incNullableStr :: Maybe Text
, incStr :: Text , incStr :: Text
, incInsert :: Text , incInsert :: Text
} deriving (Eq, Show) } deriving (Eq, Show)
instance JSON.FromJSON IncPK where instance JSON.FromJSON IncPK where
@@ -24,8 +24,8 @@ instance JSON.FromJSON IncPK where
parseJSON _ = mzero parseJSON _ = mzero
data CompoundPK = CompoundPK { data CompoundPK = CompoundPK {
compoundK1 :: Int compoundK1 :: Int
, compoundK2 :: Text , compoundK2 :: Text
, compoundExtra :: Maybe Int , compoundExtra :: Maybe Int
} deriving (Eq, Show) } deriving (Eq, Show)
-37
View File
@@ -1,37 +0,0 @@
module Unit.DbStructureSpec where
import Test.Hspec
import DbStructure (Table(..), tables, Column(..), columns, ForeignKey(..),
foreignKeys)
import Database.HDBC (quickQuery)
import SpecHelper(dbWithSchema)
import qualified Data.Map as M;
spec :: Spec
spec = around dbWithSchema $ beforeWith setRole $ do
describe "tables" $
it "shows all the tables" $ \conn -> do
ts <- tables "test" conn
map tableName ts `shouldBe` ["authors_only","auto_incrementing_pk",
"compound_pk","has_fk","insertable_view_with_join","items","menagerie","no_pk", "simple_pk"]
describe "columns" $ do
it "responds with each column for the table" $ \conn -> do
cs <- columns "test" "auto_incrementing_pk" conn
map colName cs `shouldBe` ["id","nullable_string","non_nullable_string",
"inserted_at"]
it "includes foreign key data" $ \conn -> do
cs <- columns "test" "has_fk" conn
map colFK cs `shouldBe` [Nothing,
Just $ ForeignKey "auto_incrementing_pk" "id",
Just $ ForeignKey "simple_pk" "k"]
describe "foreignKeys" $
it "has a description of the foreign key columns" $ \conn ->
foreignKeys "test" "has_fk" conn `shouldReturn` M.fromList [
("auto_inc_fk", ForeignKey {fkTable="auto_incrementing_pk", fkCol="id"}),
("simple_fk", ForeignKey { fkTable="simple_pk", fkCol="k"})]
where setRole conn = quickQuery conn "set role postgrest_test" [] >> return conn
-99
View File
@@ -1,99 +0,0 @@
module Unit.PgQuerySpec where
import Test.Hspec
import Test.QuickCheck
import Test.QuickCheck.Monadic
import Database.HDBC (IConnection, SqlValue, toSql, prepare,
quickQuery, fromSql, execute, seState, fetchAllRowsAL)
import PgQuery (LoginAttempt(..), insert, addUser, signInRole, checkPass
, pgFmtIdent, pgFmtLit)
import Types (SqlRow(SqlRow))
import TestTypes (incFromList, incStr, incNullableStr, incInsert, incId)
import Data.Map (toList)
import Data.String.Conversions (cs)
import Data.Monoid ((<>))
import Control.Arrow
import SpecHelper(dbWithSchema)
quickALQuery :: IConnection conn => conn ->
String ->
[SqlValue] ->
IO [[(String, SqlValue)]]
quickALQuery conn q bind = do
sth <- prepare conn q
_ <- execute sth bind
fetchAllRowsAL sth
spec :: Spec
spec = around dbWithSchema $ do
describe "insert" $
describe "with an auto-increment key" $ do
it "inserts and responds with a full object description" $ \conn -> do
r <- insert "test" "auto_incrementing_pk" (SqlRow [
("non_nullable_string", toSql ("a string"::String))]) conn
let returnRow = incFromList . toList $ r
incStr returnRow `shouldBe` "a string"
incNullableStr returnRow `shouldBe` Nothing
incInsert returnRow `shouldSatisfy` not . null
incId returnRow `shouldSatisfy` (>= 0)
tRows <- quickALQuery conn "select * from \"1\".auto_incrementing_pk" []
[returnRow] `shouldBe` map incFromList tRows
it "throws an exception if the PK is not unique" $ \conn -> do
r <- insert "test" "auto_incrementing_pk" (SqlRow [
("non_nullable_string", toSql ("a string"::String))]) conn
let row = SqlRow . map (Control.Arrow.first cs) . toList $ r
insert "test" "auto_incrementing_pk" row conn `shouldThrow` \e ->
seState e == "23505" -- uniqueness violation code
it "throws an exception if a required value is missing" $ \conn ->
insert "test" "auto_incrementing_pk" (SqlRow [
("nullable_string", toSql ("a string"::String))]) conn
`shouldThrow` \e -> seState e == "23502"
it "generates a default values query if no data is provided" $ \c -> do
r <- insert "test" "items" (SqlRow []) c
let [row] = toList r
quickALQuery c "select * from \"1\".items where id = ?" [snd row]
`shouldReturn` [[row]]
let {user = "jdoe"; pass = "secret"; role = "postgrest_test_default_role"}
describe "addUser" $ do
it "adds a correct user to the right table" $ \conn -> do
addUser user pass role conn
[r] <- quickQuery conn "select * from postgrest.auth" []
let [newUser, newRole, encryptedPass] = map fromSql r :: [String]
cs newUser `shouldBe` user
cs newRole `shouldBe` role
checkPass (cs encryptedPass) pass `shouldBe` True
it "will not add a user with an unknown role" $ \conn ->
addUser user pass "not-a-real-role" conn `shouldThrow` \e ->
take 2 (seState e) == "23" --integrity constraint violation
describe "signInRole" $ beforeWith (\conn -> do
addUser user pass role conn
return conn) $ do
it "accepts correct credentials and return the role" $ \conn ->
signInRole user pass conn `shouldReturn` LoginSuccess role user
it "returns nothing with bad creds" $ \conn -> do
signInRole "not-a-user" pass conn `shouldReturn` LoginFailed
signInRole user (pass <> "crap") conn `shouldReturn` LoginFailed
describe "pgFmtIdent" $
it "Does what format %I would do" $ \conn -> property $ \fuzz ->
monadicIO $ do
[[row]] <- run $ quickALQuery conn "select format('%I', ? :: varchar)" [toSql (fuzz :: String)]
assert $ fromSql (snd row) == pgFmtIdent (cs fuzz)
describe "pgFmtLit" $
it "Does what format %L would do" $ \conn ->
property $ monadicIO $ do
fuzz <- pick arbitrary
[[row]] <- run $ quickALQuery conn "select format('%L', ? :: varchar)" [toSql (fuzz :: String)]
assert $ fromSql (snd row) == pgFmtLit (cs fuzz)
+21 -12
View File
@@ -1,19 +1,23 @@
#! /usr/bin/env bash #! /usr/bin/env bash
if [ -z "$1" ]
then usage() {
echo "Please supply the connection uri for the user with create database privileges" echo "$0 <db-uri> <test-database>"
exit -1 exit -1
}
if [ -z "$1" ]; then
echo "Please supply the connection uri for the user with create database privileges"
usage
fi fi
if [ -z "$2" ] if [ -z "$2" ]; then
then echo "Please supply the test database name"
echo "Please supply the test database name" usage
exit -1
fi fi
if [[ $1 != postgres://* ]]
then if [[ $1 != postgres://* ]]; then
echo "Please use a valid connection URI (https://www.postgresql.org/docs/current/static/libpq-connect.html#AEN45347)" echo "Please use a valid connection URI (https://www.postgresql.org/docs/current/static/libpq-connect.html#AEN45347)"
exit -1 usage
fi fi
BASEPATH=$( cd $(dirname $0) ; pwd -P ) BASEPATH=$( cd $(dirname $0) ; pwd -P )
@@ -43,9 +47,14 @@ WHERE pg_stat_activity.datname = '$DB'
DROP DATABASE IF EXISTS $DB; DROP DATABASE IF EXISTS $DB;
DROP ROLE IF EXISTS $TEST_USER_NAME; DROP ROLE IF EXISTS $TEST_USER_NAME;
CREATE USER $TEST_USER_NAME WITH SUPERUSER LOGIN NOINHERIT PASSWORD '$TEST_USER_PASS' CREATEROLE; CREATE USER $TEST_USER_NAME WITH SUPERUSER LOGIN NOINHERIT PASSWORD '$TEST_USER_PASS' CREATEROLE;
CREATE DATABASE $DB OWNER $TEST_USER_NAME; ALTER ROLE postgrest_test_authenticator SET default_text_search_config TO english;
CREATE DATABASE $DB OWNER $TEST_USER_NAME LC_COLLATE 'POSIX' LC_CTYPE 'POSIX' TEMPLATE template0;
\\connect $DB \\connect $DB
ALTER SCHEMA public OWNER TO $TEST_USER_NAME; ALTER SCHEMA public OWNER TO $TEST_USER_NAME;
ALTER DATABASE $DB SET LC_MESSAGES = 'POSIX';
ALTER DATABASE $DB SET LC_MONETARY = 'POSIX';
ALTER DATABASE $DB SET LC_NUMERIC = 'POSIX';
ALTER DATABASE $DB SET LC_TIME = 'POSIX';
EOF EOF
#Remove database path from the connection uri--prevents setting up the new database name with PGDATABASE #Remove database path from the connection uri--prevents setting up the new database name with PGDATABASE
+14 -4
View File
@@ -354,11 +354,21 @@ INSERT INTO family_tree VALUES ('3', 'Kid Two', '1');
INSERT INTO family_tree VALUES ('4', 'Grandkid One', '2'); INSERT INTO family_tree VALUES ('4', 'Grandkid One', '2');
INSERT INTO family_tree VALUES ('5', 'Grandkid Two', '3'); INSERT INTO family_tree VALUES ('5', 'Grandkid Two', '3');
TRUNCATE TABLE managers CASCADE;
INSERT INTO managers VALUES (1, 'Referee Manager');
INSERT INTO managers VALUES (2, 'Auditor Manager');
INSERT INTO managers VALUES (3, 'Acme Manager');
INSERT INTO managers VALUES (4, 'Umbrella Manager');
INSERT INTO managers VALUES (5, 'Cyberdyne Manager');
INSERT INTO managers VALUES (6, 'Oscorp Manager');
TRUNCATE TABLE organizations CASCADE; TRUNCATE TABLE organizations CASCADE;
INSERT INTO organizations VALUES (1, 'Referee Org', null, null); INSERT INTO organizations VALUES (1, 'Referee Org', null, null, 1);
INSERT INTO organizations VALUES (2, 'Auditor Org', null, null); INSERT INTO organizations VALUES (2, 'Auditor Org', null, null, 2);
INSERT INTO organizations VALUES (3, 'Acme', 1, 2); INSERT INTO organizations VALUES (3, 'Acme', 1, 2, 3);
INSERT INTO organizations VALUES (4, 'Umbrella', 1, 2); INSERT INTO organizations VALUES (4, 'Umbrella', 1, 2, 4);
INSERT INTO organizations VALUES (5, 'Cyberdyne', 3, 4, 5);
INSERT INTO organizations VALUES (6, 'Oscorp', 3, 4, 6);
SET search_path = private, pg_catalog; SET search_path = private, pg_catalog;
+280
View File
@@ -0,0 +1,280 @@
-- from gavinwahl/postgres-json-schema commit 5a257e19a1569a77b82e9182b0b7d9fc8b6f6382
/*
Copyright (c) 2016, Gavin Wahl
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose, without fee, and without a written agreement is
hereby granted, provided that the above copyright notice and this paragraph and
the following two paragraphs appear in all copies.
IN NO EVENT SHALL GAVIN WAHL BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT,
SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING
OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF GAVIN WAHL HAS
BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
GAVIN WAHL SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND GAVIN WAHL
HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR
MODIFICATIONS.
*/
CREATE OR REPLACE FUNCTION _validate_json_schema_type(type text, data jsonb) RETURNS boolean AS $f$
BEGIN
IF type = 'integer' THEN
IF jsonb_typeof(data) != 'number' THEN
RETURN false;
END IF;
IF trunc(data::text::numeric) != data::text::numeric THEN
RETURN false;
END IF;
ELSE
IF type != jsonb_typeof(data) THEN
RETURN false;
END IF;
END IF;
RETURN true;
END;
$f$ LANGUAGE 'plpgsql' IMMUTABLE;
CREATE OR REPLACE FUNCTION test.validate_json_schema(schema jsonb, data jsonb, root_schema jsonb DEFAULT NULL) RETURNS boolean AS $f$
DECLARE
prop text;
item jsonb;
path text[];
types text[];
pattern text;
props text[];
BEGIN
IF root_schema IS NULL THEN
root_schema = schema;
END IF;
IF schema ? 'type' THEN
IF jsonb_typeof(schema->'type') = 'array' THEN
types = ARRAY(SELECT jsonb_array_elements_text(schema->'type'));
ELSE
types = ARRAY[schema->>'type'];
END IF;
IF (SELECT NOT bool_or(public._validate_json_schema_type(type, data)) FROM unnest(types) type) THEN
RETURN false;
END IF;
END IF;
IF schema ? 'properties' THEN
FOR prop IN SELECT jsonb_object_keys(schema->'properties') LOOP
IF data ? prop AND NOT validate_json_schema(schema->'properties'->prop, data->prop, root_schema) THEN
RETURN false;
END IF;
END LOOP;
END IF;
IF schema ? 'required' AND jsonb_typeof(data) = 'object' THEN
IF NOT ARRAY(SELECT jsonb_object_keys(data)) @>
ARRAY(SELECT jsonb_array_elements_text(schema->'required')) THEN
RETURN false;
END IF;
END IF;
IF schema ? 'items' AND jsonb_typeof(data) = 'array' THEN
IF jsonb_typeof(schema->'items') = 'object' THEN
FOR item IN SELECT jsonb_array_elements(data) LOOP
IF NOT validate_json_schema(schema->'items', item, root_schema) THEN
RETURN false;
END IF;
END LOOP;
ELSE
IF NOT (
SELECT bool_and(i > jsonb_array_length(schema->'items') OR validate_json_schema(schema->'items'->(i::int - 1), elem, root_schema))
FROM jsonb_array_elements(data) WITH ORDINALITY AS t(elem, i)
) THEN
RETURN false;
END IF;
END IF;
END IF;
IF jsonb_typeof(schema->'additionalItems') = 'boolean' and NOT (schema->'additionalItems')::text::boolean AND jsonb_typeof(schema->'items') = 'array' THEN
IF jsonb_array_length(data) > jsonb_array_length(schema->'items') THEN
RETURN false;
END IF;
END IF;
IF jsonb_typeof(schema->'additionalItems') = 'object' THEN
IF NOT (
SELECT bool_and(validate_json_schema(schema->'additionalItems', elem, root_schema))
FROM jsonb_array_elements(data) WITH ORDINALITY AS t(elem, i)
WHERE i > jsonb_array_length(schema->'items')
) THEN
RETURN false;
END IF;
END IF;
IF schema ? 'minimum' AND jsonb_typeof(data) = 'number' THEN
IF data::text::numeric < (schema->>'minimum')::numeric THEN
RETURN false;
END IF;
END IF;
IF schema ? 'maximum' AND jsonb_typeof(data) = 'number' THEN
IF data::text::numeric > (schema->>'maximum')::numeric THEN
RETURN false;
END IF;
END IF;
IF COALESCE((schema->'exclusiveMinimum')::text::bool, FALSE) THEN
IF data::text::numeric = (schema->>'minimum')::numeric THEN
RETURN false;
END IF;
END IF;
IF COALESCE((schema->'exclusiveMaximum')::text::bool, FALSE) THEN
IF data::text::numeric = (schema->>'maximum')::numeric THEN
RETURN false;
END IF;
END IF;
IF schema ? 'anyOf' THEN
IF NOT (SELECT bool_or(validate_json_schema(sub_schema, data, root_schema)) FROM jsonb_array_elements(schema->'anyOf') sub_schema) THEN
RETURN false;
END IF;
END IF;
IF schema ? 'allOf' THEN
IF NOT (SELECT bool_and(validate_json_schema(sub_schema, data, root_schema)) FROM jsonb_array_elements(schema->'allOf') sub_schema) THEN
RETURN false;
END IF;
END IF;
IF schema ? 'oneOf' THEN
IF 1 != (SELECT COUNT(*) FROM jsonb_array_elements(schema->'oneOf') sub_schema WHERE validate_json_schema(sub_schema, data, root_schema)) THEN
RETURN false;
END IF;
END IF;
IF COALESCE((schema->'uniqueItems')::text::boolean, false) THEN
IF (SELECT COUNT(*) FROM jsonb_array_elements(data)) != (SELECT count(DISTINCT val) FROM jsonb_array_elements(data) val) THEN
RETURN false;
END IF;
END IF;
IF schema ? 'additionalProperties' AND jsonb_typeof(data) = 'object' THEN
props := ARRAY(
SELECT key
FROM jsonb_object_keys(data) key
WHERE key NOT IN (SELECT jsonb_object_keys(schema->'properties'))
AND NOT EXISTS (SELECT * FROM jsonb_object_keys(schema->'patternProperties') pat WHERE key ~ pat)
);
IF jsonb_typeof(schema->'additionalProperties') = 'boolean' THEN
IF NOT (schema->'additionalProperties')::text::boolean AND jsonb_typeof(data) = 'object' AND NOT props <@ ARRAY(SELECT jsonb_object_keys(schema->'properties')) THEN
RETURN false;
END IF;
ELSEIF NOT (
SELECT bool_and(validate_json_schema(schema->'additionalProperties', data->key, root_schema))
FROM unnest(props) key
) THEN
RETURN false;
END IF;
END IF;
IF schema ? '$ref' THEN
path := ARRAY(
SELECT regexp_replace(regexp_replace(path_part, '~1', '/'), '~0', '~')
FROM UNNEST(regexp_split_to_array(schema->>'$ref', '/')) path_part
);
-- ASSERT path[1] = '#', 'only refs anchored at the root are supported';
IF NOT validate_json_schema(root_schema #> path[2:array_length(path, 1)], data, root_schema) THEN
RETURN false;
END IF;
END IF;
IF schema ? 'enum' THEN
IF NOT EXISTS (SELECT * FROM jsonb_array_elements(schema->'enum') val WHERE val = data) THEN
RETURN false;
END IF;
END IF;
IF schema ? 'minLength' AND jsonb_typeof(data) = 'string' THEN
IF char_length(data #>> '{}') < (schema->>'minLength')::numeric THEN
RETURN false;
END IF;
END IF;
IF schema ? 'maxLength' AND jsonb_typeof(data) = 'string' THEN
IF char_length(data #>> '{}') > (schema->>'maxLength')::numeric THEN
RETURN false;
END IF;
END IF;
IF schema ? 'not' THEN
IF validate_json_schema(schema->'not', data, root_schema) THEN
RETURN false;
END IF;
END IF;
IF schema ? 'maxProperties' AND jsonb_typeof(data) = 'object' THEN
IF (SELECT count(*) FROM jsonb_object_keys(data)) > (schema->>'maxProperties')::numeric THEN
RETURN false;
END IF;
END IF;
IF schema ? 'minProperties' AND jsonb_typeof(data) = 'object' THEN
IF (SELECT count(*) FROM jsonb_object_keys(data)) < (schema->>'minProperties')::numeric THEN
RETURN false;
END IF;
END IF;
IF schema ? 'maxItems' AND jsonb_typeof(data) = 'array' THEN
IF (SELECT count(*) FROM jsonb_array_elements(data)) > (schema->>'maxItems')::numeric THEN
RETURN false;
END IF;
END IF;
IF schema ? 'minItems' AND jsonb_typeof(data) = 'array' THEN
IF (SELECT count(*) FROM jsonb_array_elements(data)) < (schema->>'minItems')::numeric THEN
RETURN false;
END IF;
END IF;
IF schema ? 'dependencies' THEN
FOR prop IN SELECT jsonb_object_keys(schema->'dependencies') LOOP
IF data ? prop THEN
IF jsonb_typeof(schema->'dependencies'->prop) = 'array' THEN
IF NOT (SELECT bool_and(data ? dep) FROM jsonb_array_elements_text(schema->'dependencies'->prop) dep) THEN
RETURN false;
END IF;
ELSE
IF NOT validate_json_schema(schema->'dependencies'->prop, data, root_schema) THEN
RETURN false;
END IF;
END IF;
END IF;
END LOOP;
END IF;
IF schema ? 'pattern' AND jsonb_typeof(data) = 'string' THEN
IF (data #>> '{}') !~ (schema->>'pattern') THEN
RETURN false;
END IF;
END IF;
IF schema ? 'patternProperties' AND jsonb_typeof(data) = 'object' THEN
FOR prop IN SELECT jsonb_object_keys(data) LOOP
FOR pattern IN SELECT jsonb_object_keys(schema->'patternProperties') LOOP
RAISE NOTICE 'prop %s, pattern %, schema %', prop, pattern, schema->'patternProperties'->pattern;
IF prop ~ pattern AND NOT validate_json_schema(schema->'patternProperties'->pattern, data->prop, root_schema) THEN
RETURN false;
END IF;
END LOOP;
END LOOP;
END IF;
IF schema ? 'multipleOf' AND jsonb_typeof(data) = 'number' THEN
IF data::text::numeric % (schema->>'multipleOf')::numeric != 0 THEN
RETURN false;
END IF;
END IF;
RETURN true;
END;
$f$ LANGUAGE 'plpgsql' IMMUTABLE;
+3
View File
@@ -68,6 +68,7 @@ GRANT ALL ON TABLE
, tiobe_pls , tiobe_pls
, only_pk , only_pk
, family_tree , family_tree
, managers
, organizations , organizations
, authors , authors
, books , books
@@ -98,6 +99,8 @@ GRANT ALL ON TABLE
, projects_count_grouped_by , projects_count_grouped_by
, "Server Today" , "Server Today"
, pgrst_reserved_chars , pgrst_reserved_chars
, authors_w_entities
, openapi_types
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
+108 -27
View File
@@ -116,6 +116,11 @@ CREATE FUNCTION always_true(test.items) RETURNS boolean
LANGUAGE sql STABLE LANGUAGE sql STABLE
AS $$ SELECT true $$; AS $$ SELECT true $$;
CREATE FUNCTION is_first(test.items) RETURNS boolean
LANGUAGE sql STABLE
AS $$ SELECT $1.id = 1 $$;
CREATE FUNCTION anti_id(test.items) RETURNS bigint CREATE FUNCTION anti_id(test.items) RETURNS bigint
LANGUAGE sql STABLE LANGUAGE sql STABLE
AS $_$ SELECT $1.id * -1 $_$; AS $_$ SELECT $1.id * -1 $_$;
@@ -176,20 +181,6 @@ CREATE FUNCTION noparamsproc() RETURNS text
SELECT a FROM (VALUES ('Return value of no parameters procedure.')) s(a); SELECT a FROM (VALUES ('Return value of no parameters procedure.')) s(a);
$$; $$;
--
-- Name: insert_insertable_view_with_join(); Type: FUNCTION; Schema: test; Owner: -
--
CREATE FUNCTION insert_insertable_view_with_join() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
INSERT INTO test.auto_incrementing_pk (nullable_string, non_nullable_string) VALUES (NEW.nullable_string, NEW.non_nullable_string);
RETURN NEW;
end;
$$;
-- --
-- Name: login(text, text); Type: FUNCTION; Schema: test; Owner: - -- Name: login(text, text); Type: FUNCTION; Schema: test; Owner: -
-- --
@@ -215,18 +206,28 @@ CREATE FUNCTION varied_arguments(
date date, date date,
money money, money money,
enum enum_menagerie_type, enum enum_menagerie_type,
"integer" integer default 42 "integer" integer default 42,
json json default '{}',
jsonb jsonb default '{}'
) RETURNS text ) RETURNS text
LANGUAGE sql LANGUAGE sql
AS $_$ AS $_$
SELECT 'Hi'::text; SELECT 'Hi'::text;
$_$; $_$;
COMMENT ON FUNCTION varied_arguments(double precision, character varying, boolean, date, money, enum_menagerie_type, integer) IS COMMENT ON FUNCTION varied_arguments(double precision, character varying, boolean, date, money, enum_menagerie_type, integer, json, jsonb) IS
$_$An RPC function $_$An RPC function
Just a test for RPC function arguments$_$; Just a test for RPC function arguments$_$;
CREATE FUNCTION json_argument(arg json) RETURNS text
LANGUAGE sql
AS $_$
SELECT json_typeof(arg);
$_$;
-- --
-- Name: jwt_test(); Type: FUNCTION; Schema: test; Owner: - -- Name: jwt_test(); Type: FUNCTION; Schema: test; Owner: -
-- --
@@ -964,13 +965,6 @@ CREATE TRIGGER articles_owner_track BEFORE INSERT OR UPDATE ON articles FOR EACH
SET search_path = test, pg_catalog; SET search_path = test, pg_catalog;
--
-- Name: insert_insertable_view_with_join; Type: TRIGGER; Schema: test; Owner: -
--
CREATE TRIGGER insert_insertable_view_with_join INSTEAD OF INSERT ON insertable_view_with_join FOR EACH ROW EXECUTE PROCEDURE insert_insertable_view_with_join();
-- --
-- Name: secrets_owner_track; Type: TRIGGER; Schema: test; Owner: - -- Name: secrets_owner_track; Type: TRIGGER; Schema: test; Owner: -
-- --
@@ -1115,8 +1109,8 @@ CREATE FUNCTION setprojects(id_l int, id_h int, name text) RETURNS SETOF project
$_$; $_$;
create table images ( create table images (
name text not null, name text not null,
img bytea not null img bytea not null
); );
create view images_base64 as ( create view images_base64 as (
@@ -1232,7 +1226,7 @@ create table ranges (
comment on table child_entities is 'child_entities comment'; comment on table child_entities is 'child_entities comment';
comment on column child_entities.id is 'child_entities id comment'; comment on column child_entities.id is 'child_entities id comment';
comment on column child_entities.name is 'child_entities name comment'; comment on column child_entities.name is 'child_entities name comment. Can be longer than sixty-three characters long';
comment on table grandchild_entities is comment on table grandchild_entities is
$$grandchild_entities summary $$grandchild_entities summary
@@ -1386,11 +1380,17 @@ create table test.family_tree (
); );
alter table only test.family_tree add constraint pptr foreign key (parent) references test.family_tree(id); alter table only test.family_tree add constraint pptr foreign key (parent) references test.family_tree(id);
create table test.managers (
id integer primary key,
name text
);
create table test.organizations ( create table test.organizations (
id integer primary key, id integer primary key,
name text, name text,
referee integer, referee integer,
auditor integer auditor integer,
manager_id integer references managers(id)
); );
alter table only test.organizations add constraint pptr1 foreign key (referee) references test.organizations(id); alter table only test.organizations add constraint pptr1 foreign key (referee) references test.organizations(id);
alter table only test.organizations add constraint pptr2 foreign key (auditor) references test.organizations(id); alter table only test.organizations add constraint pptr2 foreign key (auditor) references test.organizations(id);
@@ -1560,6 +1560,19 @@ select
from projects from projects
group by client_id; group by client_id;
create view test.authors_w_entities as
select
id,
name,
(
select json_agg(id)
from test.entities
where id not in (
select parent_id from test.child_entities
)
) as entities
from private.authors;
CREATE TABLE test."Foo"( CREATE TABLE test."Foo"(
id int primary key, id int primary key,
name text name text
@@ -1649,3 +1662,71 @@ create table test.pgrst_reserved_chars (
"a.dotted.column" text, "a.dotted.column" text,
" col w space " text " col w space " text
); );
CREATE TABLE test.openapi_types(
"a_character_varying" character varying,
"a_character" character(1),
"a_text" text,
"a_boolean" boolean,
"a_smallint" smallint,
"a_integer" integer,
"a_bigint" bigint,
"a_numeric" numeric,
"a_real" real,
"a_double_precision" double precision
);
create function add_them(a integer, b integer)
returns integer as $$
select a + b;
$$ language sql;
create function root() returns jsonb as $_$
declare
openapi jsonb = $$
{
"swagger": "2.0",
"info":{
"title":"PostgREST API",
"description":"This is a dynamic API generated by PostgREST"
}
}
$$;
simple jsonb = $$
[
{
"table":"items"
},
{
"table":"subitems"
}
]
$$;
begin
case current_setting('request.header.accept', true)
when 'application/openapi+json' then
return openapi;
when 'application/json' then
return simple;
else
return openapi;
end case;
end
$_$ language plpgsql;
create or replace function welcome() returns text as $$
select 'Welcome to PostgREST'::text;
$$ language sql;
create or replace function "welcome.html"() returns text as $_$
select $$
<html>
<head>
<title>PostgREST</title>
</head>
<body>
<h1>Welcome to PostgREST</h1>
</body>
</html>
$$::text;
$_$ language sql;
+37 -10
View File
@@ -74,7 +74,7 @@ readSecretFromFile(){
then then
ok "authentication with $2 secret read from a file" ok "authentication with $2 secret read from a file"
else else
ko "failed to authenticate using JWT for $2 secret: $httpStatus" ko "authentication with $2 secret read from a file: $httpStatus"
fi fi
else else
ko "failed to read $2 secret from a file" ko "failed to read $2 secret from a file"
@@ -95,7 +95,7 @@ readDbUriFromFile(){
then then
ok "connection with $2 dburi read from a file" ok "connection with $2 dburi read from a file"
else else
ko "failed to read $2 dburi from a file" ko "connection with $2 dburi read from a file"
fi fi
pgrStop pgrStop
} }
@@ -113,9 +113,9 @@ reqWithRoleClaimKey(){
httpStatus="$( authorsStatus "$authorsJwt" )" httpStatus="$( authorsStatus "$authorsJwt" )"
if test "$httpStatus" -eq $3 if test "$httpStatus" -eq $3
then then
ok "request with \"$1\" role-claim-key for $2 jwt gave $3" ok "request with \"$1\" role-claim-key for $2 jwt: $httpStatus"
else else
ko "request with \"$1\" role-claim-key for $2 jwt gave $httpStatus" ko "request with \"$1\" role-claim-key for $2 jwt: $httpStatus"
fi fi
pgrStop pgrStop
} }
@@ -131,9 +131,9 @@ invalidRoleClaimKey(){
done done
if pgrStarted if pgrStarted
then then
ko "invalid jspath \"$1\" accepted" ko "invalid jspath \"$1\": accepted"
else else
ok "invalid jspath \"$1\" rejected" ok "invalid jspath \"$1\": rejected"
fi fi
pgrStop pgrStop
} }
@@ -152,12 +152,12 @@ ensureIatClaimWorks(){
httpStatus="$( authorsStatus $iatJwt )" httpStatus="$( authorsStatus $iatJwt )"
if test "$httpStatus" -ne 200 if test "$httpStatus" -ne 200
then then
ko "iat claim rejected with $httpStatus" ko "iat claim rejected: $httpStatus"
return return
fi fi
sleep .5;\ sleep .5;\
done done
ok "accepted iat claim" ok "iat claim accepted"
pgrStop pgrStop
} }
@@ -174,9 +174,33 @@ ensureAppSettings(){
response=$(curl -s "http://localhost:$pgrPort/rpc/get_guc_value?name=app.settings.external_api_secret") response=$(curl -s "http://localhost:$pgrPort/rpc/get_guc_value?name=app.settings.external_api_secret")
if test "$response" = "\"0123456789abcdef\"" if test "$response" = "\"0123456789abcdef\""
then then
ok "GET /rpc/get_guc_value response is $response" ok "GET /rpc/get_guc_value: $response"
else else
ko "GET /rpc/get_guc_value response was $response" ko "GET /rpc/get_guc_value: $response"
fi
pgrStop
}
getSocketStatus() {
curl -sL -w "%{http_code}\\n" -o /dev/null localhost:54321
}
socketConnection(){
# map port 54321 traffic to unix socket as workaround for curl below 7.40
# not supporting --unix-socket flag
ncat -vlk 54321 -c 'ncat -U /tmp/postgrest.sock' &
pgrStart "./configs/unix-socket.config"
while pgrStarted && test "$( getSocketStatus )" -ne 200
do
# wait for the server to start
sleep 0.1 \
|| sleep 1 # fallback: subsecond sleep is not standard and may fail
done
if test $( getSocketStatus ) -eq 200
then
ok "Succesfully connected through unix socket"
else
ko "Failed to connect through unix socket"
fi fi
pgrStop pgrStop
} }
@@ -191,6 +215,8 @@ setUp
echo "Running IO tests.." echo "Running IO tests.."
socketConnection
readSecretFromFile word.noeol 'simple (no EOL)' readSecretFromFile word.noeol 'simple (no EOL)'
readSecretFromFile word.txt 'simple' readSecretFromFile word.txt 'simple'
readSecretFromFile ascii.noeol 'ASCII (no EOL)' readSecretFromFile ascii.noeol 'ASCII (no EOL)'
@@ -224,6 +250,7 @@ invalidRoleClaimKey 1234
ensureIatClaimWorks ensureIatClaimWorks
ensureAppSettings ensureAppSettings
cleanUp cleanUp
exit $failedTests exit $failedTests
+7
View File
@@ -0,0 +1,7 @@
db-uri = "postgres:///postgrest_test"
db-schema = "test"
db-anon-role = "postgrest_test_anonymous"
db-pool = 1
server-host = "127.0.0.1"
server-unix-socket = "/tmp/postgrest.sock"
jwt-secret = "reallyreallyreallyreallyverysafe"
+14 -16
View File
@@ -7,9 +7,9 @@ ko(){ result 'not ok' "- $1"; failedTests=$(( $failedTests + 1 )); }
pgrPort=49421 pgrPort=49421
pgrStopAll(){ pkill -f "$(stack path --local-install-root)/bin/postgrest"; } pgrStopAll(){ pkill -f "$(stack path --profile --local-install-root)/bin/postgrest"; }
pgrStart(){ stack exec -- postgrest test/memory-tests/config +RTS -p -h >/dev/null & pgrPID="$!"; } pgrStart(){ stack exec --profile -- postgrest test/memory-tests/config +RTS -p -h >/dev/null & pgrPID="$!"; }
pgrStop(){ kill "$pgrPID" 2>/dev/null; } pgrStop(){ kill "$pgrPID" 2>/dev/null; }
setUp(){ pgrStopAll; } setUp(){ pgrStopAll; }
@@ -90,27 +90,25 @@ postJsonArrayTest(){
fi fi
} }
stack build --profile
setUp setUp
echo "Running memory usage tests.." echo "Running memory usage tests.."
jsonKeyTest "1M" "POST" "/rpc/leak" "20M" jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "12M"
jsonKeyTest "1M" "POST" "/leak" "20M" jsonKeyTest "1M" "POST" "/leak?columns=blob" "12M"
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1" "20M" jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "12M"
jsonKeyTest "10M" "POST" "/rpc/leak" "105M" jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "40M"
jsonKeyTest "10M" "POST" "/leak" "105M" jsonKeyTest "10M" "POST" "/leak?columns=blob" "40M"
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1" "105M" jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "40M"
jsonKeyTest "50M" "POST" "/rpc/leak" "500M" jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "170M"
jsonKeyTest "50M" "POST" "/leak" "500M" jsonKeyTest "50M" "POST" "/leak?columns=blob" "170M"
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1" "500M" jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "170M"
postJsonArrayTest "1000" "/perf_articles" "20M" postJsonArrayTest "1000" "/perf_articles?columns=id,body" "10M"
postJsonArrayTest "10000" "/perf_articles" "150M" postJsonArrayTest "10000" "/perf_articles?columns=id,body" "10M"
postJsonArrayTest "100000" "/perf_articles" "1.15G" postJsonArrayTest "100000" "/perf_articles?columns=id,body" "20M"
cleanUp cleanUp