Compare commits

...
184 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
steve-chavez 1e732ac94a Bump to v5.2.0 2018-12-12 18:59:10 -05:00
steve-chavez 6b2778749f Add ability to quote column names on filters 2018-12-12 12:07:30 -05:00
steve-chavez 36f86827ee Fix #1008, Handle columns that contain spaces 2018-12-12 12:07:30 -05:00
steve-chavez d78410473e Add test for embedding on a view with group by 2018-12-12 12:07:30 -05:00
Steve ChávezandGitHub 501edc718d Add db-extra-search-path config (#1218)
For adding schemas to the search_path, solves issues related to extensions created in the public schema.
2018-12-08 11:39:31 -05:00
周丰andSteve Chávez 0d6d112b38 Read db-uri configuration from a separate file. (#1215)
* Read dburi configuration from a separate file.

* Add changelog entry.
2018-12-04 13:58:29 -05:00
Russell DaviesandSteve Chávez 473ac70789 Add support for parsing JSON Web Key Sets 2018-11-13 13:58:17 -05:00
steve-chavez dadfe965b9 Do hlint 2.1 hints 2018-11-06 11:54:10 -05:00
steve-chavez 63ead89470 Fix #1181, correct qualify of function argument type 2018-11-06 11:54:10 -05:00
steve-chavez ab23ed7999 Add CHANGELOG entry for allowing CORS on PUT 2018-10-21 13:38:34 -05:00
Kyle RussellandSteve Chávez 2da6bd6d1c Config: apply CorsResourcePolicy to PUT requests
PUT requests are part of the public upsert API, so if postgrest
runs at a different origin, these requests will fail because they
don't pass CORS.
2018-10-21 13:30:52 -05:00
steve-chavez 5bfb68b982 Fix #1180, embedding on views with composite pks
Also add CHANGELOG entries for previous fixes.
2018-10-12 09:24:22 -05:00
steve-chavez dc834572d6 Fix #1182, fix subselect view embedding for pg10 2018-10-12 09:24:22 -05:00
steve-chavez b48824bddd Fix #1065, correct test for pg10
Also add pg10 testing job on CircleCI
2018-10-12 09:24:22 -05:00
steve-chavez 6d326fe341 Update README.md 2018-10-12 09:22:58 -05:00
Roger LAMandSteve Chávez d94cf2ed72 tiny typo? 2018-10-11 09:00:27 -05:00
steve-chavez 27ca6b4e90 Pin hasql versions in cabal and stack 2018-09-28 08:57:26 -05:00
Ben GamariandSteve Chávez e0cc4d1571 Update hasql
Move to hasql 1.3.
2018-09-28 08:57:26 -05:00
steve-chavez 3cef4b70b0 Bump to v5.1.0 2018-08-31 22:09:03 -05:00
steve-chavez 6f97c34a86 Increase memory usage limit for tests
Sometimes there's more memory usage when running the tests on CircleCI
2018-08-31 22:09:03 -05:00
steve-chavez bdac90491d Add CHANGELOG and better categories to cabal file
Also update appveyor.yml
2018-08-31 22:09:03 -05:00
steve-chavez 5961f7a116 Update appveyor.yml 2018-08-31 09:09:17 -05:00
steve-chavez 17cd2725fd Pin hasql, protolude and jose versions in cabal 2018-08-31 09:09:17 -05:00
steve-chavez 5e7606134a Restrict base package version and cabal additions
There are issues with ghc 8.2 and 8.4,
see https://github.com/PostgREST/postgrest/issues/1109
2018-08-31 09:09:17 -05:00
mdr1384andSteve Chávez dfa9055c34 Close #1158, Add summary to OpenAPI doc for RPC functions (#1170) 2018-08-27 10:22:21 -05:00
steve-chavez 6907e7f979 Go back to *4 host for docker
127.0.0.1 cause issues on macos/windows
2018-08-23 13:03:12 -05:00
steve-chavez 8cf68c63d9 Fix segmentation fault/access violation on windows 10
This was reported in https://gitter.im/begriffs/postgrest?at=5b7431eb5b07ae730ac083af
and according to https://ghc.haskell.org/trac/ghc/ticket/13112#comment:25
this is an issue with linking in postgresql-libpq. The 0.9.4.1 version
contains a patch(https://github.com/lpsmith/postgresql-libpq/pull/45) that fixes the issue.
2018-08-21 11:27:44 -05:00
Steve ChávezandGitHub 30b5859b28 Fix #1141, app.settings resetting on pool timeout (#1169)
* Add test for ensuring app.settings don't reset
2018-08-20 11:02:03 -05:00
Steve ChávezandGitHub 0a1d83ce8f Fix JWTIssuedAtFuture for valid iat claim (#1166)
* Add test for ensuring "iat" works with time cache
2018-08-16 12:29:32 -05:00
steve-chavez d7511a2637 Add SIGUSR1 as another signal for schema reloading 2018-08-16 11:15:09 -05:00
steve-chavez 2066220244 Change server-host default of *4 to 127.0.0.1 2018-08-16 11:15:09 -05:00
steve-chavez 93f10adb3c Fix #905, intermittent empty replies 2018-08-09 08:59:19 -05:00
mdr1384andSteve Chávez 56bd5d5f91 Quote identifier names in RPC function arguments (#1153) 2018-07-30 09:11:09 -05:00
Alexey AlekhinandSteve Chávez 70e95649fd OpenAPI: Change GET response type to array (#1149) 2018-07-24 12:26:45 -05:00
Alexey AlekhinandSteve Chávez 2b46afe1ec OpenAPI: add required section for the non-nullable columns (#1148) 2018-07-23 11:11:45 -05:00
steve-chavez fa1e92fdf2 Add Lead Backer. Thank you Michel. 2018-07-19 11:05:46 -05:00
steve-chavez b1a8bd2391 Close #709, add test for embedding a view with CTE
Also add test for embedding a view with subselect in FROM clause
2018-07-18 09:43:42 -05:00
steve-chavez 69a76a627f Close #1145, OpenAPI materialized view columns 2018-07-18 09:43:42 -05:00
steve-chavez 105671e51a Fix #1129, view embedding on capitalized table
- Now also works on camelCase fk column
2018-07-18 09:43:42 -05:00
steve-chavez ecf0e9213f Remove array slice in allSynonyms query
This is for ensuring compatibility with pg 9.4 which doesn't support
omitting the array slice upper bound, [2:] gives an error.

The query still works fine because the array slice was to avoid having
a "({" result but this gets removed in the later join.
2018-07-18 09:43:42 -05:00
steve-chavez 1c6ded16d1 Add improved query for allSynonyms
This query works with views with subselects, and it's a lot faster,
on a complex schema the previous query was taking around 8 mins,
this query takes less than half a second(403.072 ms).

Also reorder view embedding tests
2018-07-18 09:43:42 -05:00
steve-chavez 6fc9d5191a Move create/destroy db scripts to bash
There was a "[[: not found" error with sh
2018-06-22 10:48:54 -05:00
steve-chavez 80f09780cc Add logo and db uri example to heroku button 2018-06-21 11:04:31 -05:00
steve-chavez 9e3454129f Add tests for jsonb equality filtering 2018-06-19 11:17:59 -05:00
steve-chavez d34afe861a Allow finishing a json path with single arrow ->
Now a json can be obtained without resorting to casting

- Previously: `/json_arr?select=data->>2::json`
- Now: `/json_arr?select=data->2`
2018-06-19 11:17:59 -05:00
steve-chavez 30dfadec7b Add support for getting json by array index
* Also support json negative array index
2018-06-19 11:17:59 -05:00
steve-chavez 2513c00039 Refactor Field type,move tests to JsonOperatorSpec 2018-06-19 11:17:59 -05:00
steve-chavez 100bf494ac Move database creation/destruction scripts to sh
Bash is not available on /bin/bash on some linux distros like NixOS
2018-06-19 11:17:59 -05:00
steve-chavez e8188b0d41 Add Lead Backer. Thank you Daniel. 2018-06-18 11:49:15 -05:00
steve-chavez 3958ebbb05 Add CHANGELOG note about stored procedures 2018-05-31 11:57:42 -05:00
steve-chavez 37e7398a85 Fix UPSERT bug on camelCase PK column 2018-05-31 11:57:42 -05:00
Felix YanandSteve Chávez 9ea7529f30 Use protolude 0.2.2, replace undefined with witness
protolude provides headMay since 0.2.2, it can be used instead of safe.
2018-05-28 13:08:27 -05:00
ilovezfsandSteve Chávez 384767708b postgrest.cabal: fix constraints on protolude and hasql-transaction 2018-05-27 08:54:46 -05:00
steve-chavez 6bcbb124d2 Add PGRST_ROLE_CLAIM_KEY to ENV 2018-05-25 10:27:09 -05:00
DominikandSteve Chávez f6c1ff810e Add role-claim-key to docker postgrest.conf
With #1091, the role-claim-key was added, but it is not configurable in docker containers as the template config doesn't allow it.
This patch adds the necessary line to the postgrest.conf of the docker container.
2018-05-25 09:26:44 -05:00
steve-chavez f80cfbf165 Add BACKERS.md 2018-05-21 13:11:24 -05:00
steve-chavez d8896be2c1 Add more details to issue template 2018-05-16 09:25:24 -05:00
steve-chavez 903a8d5f5a Bump to v0.5.0.0 2018-05-14 19:08:51 -05:00
steve-chavez ca76a8e6be Update links, add patreon badge 2018-05-14 18:44:52 -05:00
steve-chavez 28845e0f43 Fix accepting misspellings in order syntax 2018-05-14 07:34:15 -05:00
steve-chavez 30cf1d100a Add foreign tables to OpenAPI output 2018-05-07 10:14:26 -05:00
Joe NelsonandGitHub 05180f6539 Remove consulting prompt in issue template 2018-05-04 16:55:07 -05:00
steve-chavez b00f57ac34 Fix appveyor build
Appveyor failed on latest release https://ci.appveyor.com/project/begriffs/postgrest/build/1.0.5
according to https://github.com/commercialhaskell/stack/issues/2617#issuecomment-271966495
this can be fixed by adding `-j1`.
2018-05-02 13:06:44 -05:00
steve-chavez e9aaf05335 Remove support for non url safe operators 2018-05-02 13:06:44 -05:00
steve-chavez 79a7ce49f2 Remove support for {} embed and "in" op w/o parens 2018-05-02 13:06:44 -05:00
steve-chavez 3a1213f53b Add CHANGELOG entry for role-claim-key 2018-04-30 11:38:56 -05:00
steve-chavez f033c2c4b5 Add role-claim-key config value 2018-04-30 11:31:06 -05:00
steve-chavez 5c87fe2704 Add getCurrentTime cache for jwt validation 2018-04-30 11:31:06 -05:00
Joe Nelson 58f4b4bc33 Issue template 2018-04-12 21:28:11 -05:00
steve-chavez 32c7e32bdf Allow embeds alias to be used in filters, fix #821 2018-04-02 11:09:45 -05:00
steve-chavez 062a5581f5 Allow specifying source column in embed, fix #1078 2018-04-02 11:09:45 -05:00
steve-chavez 50512e1117 Refactor addRelations and findRelation functions
Just reordering and renaming variables to reduce indentation, logic remains the same.
2018-04-02 11:09:45 -05:00
steve-chavez edae60f8c1 Only alias tables on self join cases 2018-04-02 11:09:45 -05:00
steve-chavez 243e692192 Refactor some functions to use concatMap 2018-03-17 07:53:41 -05:00
steve-chavez 349a5ae076 Fix Parent Relation when having many views(#1044) 2018-03-17 07:53:41 -05:00
steve-chavez ff709a65e5 Add FROM targets aliasing to avoid conflict in embeds 2018-02-21 09:23:08 -05:00
steve-chavez 8e2a0e05ea Refactor QueryBuilder and DbRequestBuilder
- Refactor ORDER BY and LIMIT query fragments
- Move ReadRequest building to DbRequestBuilder
- Clarify Relation link table attributes
- Change Join Operation to JoinCond
2018-02-21 09:23:08 -05:00
steve-chavez 108f3cd651 Add Preference-Applied header for POST upsert
- Ensure creating nothing on ignore-duplicates succeeds
- Refactor locationF query
2018-02-21 07:33:54 -05:00
steve-chavez 6675821c64 Allow PUT method: UPSERT of a single row 2018-02-21 07:33:54 -05:00
steve-chavez 85b1dc0eb4 Add UPSERT for POST with Prefer:resoultion=merge/ignore-duplicates 2018-02-21 07:33:54 -05:00
Joe Nelson 102392e4ab Move canadaduane's changelog entry to Added 2018-02-19 21:55:03 -06:00
Duane JohnsonandJoe Nelson a46b6f5020 Pass custom settings to the DB's SESSION (#1063)
- allows queries to refer to current_setting('app.settings.foo') to retrieve variables
- useful for 12-factor apps (app data can be in environment)
- provides workaround for AWS Relational Database Service (RDS) not
  allowing `ALTER DATABASE SET 'app.[KEY]' TO '[VALUE]'` on database.
2018-02-19 12:41:22 -06:00
steve-chavez 70ce1b9329 Reduce memory usage of uniform json array restriction 2018-01-27 13:55:20 -05:00
steve-chavez 516976e32f Add memory usage tests 2018-01-10 11:43:16 -05:00
steve-chavez f7e7834a1c Handle overloaded function case
* Add test for params=single-object on GET

* Add tests for procs with DEFAULT args

* Add tests for overloaded functions

* Add test for PATCHing with an empty json array, this previously
  gave a "Something is wrong" error
2018-01-10 11:43:16 -05:00
steve-chavez 38f3bcf4a6 Reduce memory usage by avoiding Aeson encode
It was detected that Aeson encoding had high memory usage when
the json payload was large, around x60 the payload size.
With this change we get around x10 payload size memory usage.

The encodeUtf8(when doing a Text -> ByteString with `toS`) function
on a large payload also contributed to the high memory usage.

Main idea to reduce the memory usage was to let the ByteString coming
from the request body go to the database unchanged.
2018-01-10 11:43:16 -05:00
steve-chavez f159233de8 Change UPDATE query to a parametrized query 2018-01-10 11:43:16 -05:00
steve-chavez 02a286a4b1 Change callProc query to a parametrized query 2018-01-10 11:43:16 -05:00
steve-chavez 85d9feeeab Add set schema to middleware
Fix #828, breaking change: computed columns now only work if they are on
the config schema.

Fix #835, tests now not depend on the search_path of the
postgrest_test_authenticator.
2018-01-10 09:50:20 -05:00
100 changed files with 6952 additions and 2933 deletions
+157 -32
View File
@@ -52,7 +52,7 @@ build-distro-bin: &build-distro-bin
key: v1-{{ .Environment.CIRCLE_JOB }}-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }} key: v1-{{ .Environment.CIRCLE_JOB }}-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
jobs: jobs:
build-test: build-test-9.4:
docker: docker:
- image: circleci/buildpack-deps:trusty - image: circleci/buildpack-deps:trusty
environment: environment:
@@ -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
@@ -138,8 +142,118 @@ jobs:
name: run tests name: run tests
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
centos6: build-test-10:
<<: *build-distro-bin docker:
- image: circleci/buildpack-deps:trusty
environment:
- PGHOST=localhost
- image: circleci/postgres:10.5
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 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 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-prof-test:
docker:
- image: circleci/buildpack-deps:trusty
environment:
- PGHOST=localhost
- TERM=xterm
- image: circleci/postgres:9.6.2
environment:
- POSTGRES_USER=circleci
- POSTGRES_DB=circleci
steps:
- checkout
- restore_cache:
keys:
- v1-stack-prof-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 install -y libgmp-dev
sudo apt-get install -y postgresql-client
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:
name: build with profiling enabled
command: |
stack build --profile -j1
- run:
name: run memory usage tests
command: |
test/create_test_db "postgres://circleci@localhost" postgrest_test
psql "postgres:///postgrest_test" -f test/fixtures/database.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/jwt.sql
psql "postgres:///postgrest_test" -f test/fixtures/jsonschema.sql
psql "postgres:///postgrest_test" -f test/fixtures/privileges.sql
test/memory-tests.sh
centos7: centos7:
<<: *build-distro-bin <<: *build-distro-bin
@@ -180,7 +294,7 @@ workflows:
version: 2 version: 2
build-test-release: build-test-release:
jobs: jobs:
- build-test: - build-test-9.4:
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
@@ -188,19 +302,25 @@ workflows:
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
- centos6: - build-test-10:
requires: filters:
- build-test tags:
- build-test-9.6 only: /v[0-9]+(\.[0-9]+)*/
- build-test-11:
filters:
tags:
only: /v[0-9]+(\.[0-9]+)*/
- build-prof-test:
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
branches:
ignore: /.*/
- centos7: - centos7:
requires: requires:
- build-test - build-test-9.4
- build-test-9.6 - build-test-9.6
- build-test-10
- build-test-11
- build-prof-test
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
@@ -208,8 +328,11 @@ workflows:
ignore: /.*/ ignore: /.*/
- ubuntu: - ubuntu:
requires: requires:
- build-test - build-test-9.4
- build-test-9.6 - build-test-9.6
- build-test-10
- build-test-11
- build-prof-test
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
@@ -217,8 +340,11 @@ workflows:
ignore: /.*/ ignore: /.*/
- ubuntui386: - ubuntui386:
requires: requires:
- build-test - build-test-9.4
- build-test-9.6 - build-test-9.6
- build-test-10
- build-test-11
- build-prof-test
filters: filters:
tags: tags:
only: /v[0-9]+(\.[0-9]+)*/ only: /v[0-9]+(\.[0-9]+)*/
@@ -226,7 +352,6 @@ workflows:
ignore: /.*/ ignore: /.*/
- release: - release:
requires: requires:
- centos6
- centos7 - centos7
- ubuntu - ubuntu
- ubuntui386 - ubuntui386
+2 -13
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
@@ -51,18 +51,7 @@ your contributions.
source](https://postgrest.com/en/stable/install.html#build-from-source) source](https://postgrest.com/en/stable/install.html#build-from-source)
wiki page. wiki page.
## Maintenance ### Running Tests
### Schedule
Currently I (@begriffs) am the sole maintainer, and while I am
overjoyed to help resolve issues I also have to balance this with
my other obligations. If you don't get a response right away
don't worry, I will definitely get to it. Also you can join the
Gitter [chat room](https://gitter.im/begriffs/postgrest) to
discuss issues you are having.
## Running Tests
For instructions on running tests, see the official docs hosted here: For instructions on running tests, see the official docs hosted here:
+3
View File
@@ -0,0 +1,3 @@
# These are supported funding model platforms
patreon: postgrest
+15
View File
@@ -0,0 +1,15 @@
<!--
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
* PostgreSQL version: (if using docker, specify the image)
* PostgREST version: (if using docker, specify the image)
* Operating system:
### Description of issue
(Expected behavior vs actual behavior)
(Steps to reproduce: Include a minimal SQL definition plus how you make the request to PostgREST and the response body)
+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
+59
View File
@@ -0,0 +1,59 @@
# Sponsors & Backers
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
- [Daniel Babiak](https://github.com/d-babiak)
- Evans Fernandes
## Backers
- Tsingson Qin
- Michel Pelletier
- 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)
+139
View File
@@ -9,6 +9,145 @@ 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
### Added
- #1205, Add support for parsing JSON Web Key Sets - @russelldavies
- #1203, Add support for reading db-uri from a separate file - @zhoufeng1989
- #1200, Add db-extra-search-path config for adding schemas to the search_path, solves issues related to extensions created on the public schema - @steve-chavez
- #1219, Add ability to quote column names on filters - @steve-chavez
### Fixed
- #1182, Fix embedding on views with composite pks - @steve-chavez
- #1180, Fix embedding on views with subselects in pg10 - @steve-chavez
- #1197, Allow CORS for PUT - @bkylerussell
- #1181, Correctly qualify function argument of custom type in public schema - @steve-chavez
- #1008, Allow columns that contain spaces in filters - @steve-chavez
## [5.1.0] - 2018-08-31
### Added
- #1099, Add support for getting json/jsonb by array index - @steve-chavez
- #1145, Add materialized view columns to OpenAPI output - @steve-chavez
- #709, Allow embedding on views with subselects/CTE - @steve-chavez
- #1148, OpenAPI: add `required` section for the non-nullable columns - @laughedelic
- #1158, Add summary to OpenAPI doc for RPC functions - @mdr1384
### Fixed
- #1113, Fix UPSERT failing when having a camel case PK column - @steve-chavez
- #945, Fix slow start-up time on big schemas - @steve-chavez
- #1129, Fix view embedding when table is capitalized - @steve-chavez
- #1149, OpenAPI: Change `GET` response type to array - @laughedelic
- #1152, Fix RPC failing when having arguments with reserved or uppercase keywords - @mdr1384
- #905, Fix intermittent empty replies - @steve-chavez
- #1139, Fix JWTIssuedAtFuture failure for valid iat claim - @steve-chavez
- #1141, Fix app.settings resetting on pool timeout - @steve-chavez
### Changed
- #1099, Numbers in json path `?select=data->1->>key` now get treated as json array indexes instead of keys - @steve-chavez
- #1128, Allow finishing a json path with a single arrow `->`. Now a json can be obtained without resorting to casting, Previously: `/json_arr?select=data->>2::json`, now: `/json_arr?select=data->2` - @steve-chavez
- #724, Change server-host default of *4 to 127.0.0.1
### Deprecated
- #724, SIGHUP deprecated, SIGUSR1 should be used instead
## [0.5.0.0] - 2018-05-14
### Added
- The configuration (e.g. `postgrest.conf`) now accepts arbitrary settings that will be passed through as session-local database settings. This can be used to pass in secret keys directly as strings, or via OS environment variables. For instance: `app.settings.jwt_secret = "$(MYAPP_JWT_SECRET)"` will take `MYAPP_JWT_SECRET` from the environment and make it available to postgresql functions as `current_setting('app.settings.jwt_secret')`. Only `app.settings.*` values in the configuration file are treated in this way. - @canadaduane
- #256, Add support for bulk UPSERT with POST and single UPSERT with PUT - @steve-chavez
- #1078, Add ability to specify source column in embed - @steve-chavez
- #821, Allow embeds alias to be used in filters - @steve-chavez
- #906, Add jspath configurable `role-claim-key` - @steve-chavez
- #1061, Add foreign tables to OpenAPI output - @rhyamada
### Fixed
- #828, Fix computed column only working in public schema - @steve-chavez
- #925, Fix RPC high memory usage by using parametrized query and avoiding json encoding - @steve-chavez
- #987, Fix embedding with self-reference foreign key - @steve-chavez
- #1044, Fix view parent embedding when having many views - @steve-chavez
- #781, Fix accepting misspelled desc/asc ordering modificators - @onporat, @steve-chavez
### Changed
- #828, A `SET SCHEMA <db-schema>` is done on each request, this has the following implications:
- Computed columns now only work if they belong to the db-schema
- Stored procedures might require a `search_path` to work properly, for further details see https://postgrest.org/en/v5.0/api.html#explicit-qualification
- To use RPC now the `json_to_record/json_to_recordset` functions are needed, these are available starting from PostgreSQL 9.4 - @steve-chavez
- Overloaded functions now depend on the `dbStructure`, restart/sighup may be needed for their correct functioning - @steve-chavez
- #1098, Removed support for:
+ curly braces `{}` in embeds, i.e. `/clients?select=*,projects{*}` can no longer be used, from now on parens `()` should be used `/clients?select=*,projects(*)` - @steve-chavez
+ "in" operator without parens, i.e. `/clients?id=in.1,2,3` no longer supported, `/clients?id=in.(1,2,3)` should be used - @steve-chavez
+ "@@", "@>" and "<@" operators, from now on their mnemonic equivalents should be used "fts", "cs" and "cd" respectively - @steve-chavez
## [0.4.4.0] - 2018-01-08 ## [0.4.4.0] - 2018-01-08
### 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 ]
+63 -25
View File
@@ -1,33 +1,64 @@
![Logo](static/logo.png "Logo") ![Logo](static/bigger-logo.png "Logo")
[![Build Status](https://circleci.com/gh/begriffs/postgrest.png?style=shield&circle-token=f723c01686abf0364de1e2eaae5aff1f68bd3ff2)](https://circleci.com/gh/begriffs/postgrest/tree/master) [![Donate](https://img.shields.io/badge/Donate-Patreon-orange.svg?colorB=F96854)](https://www.patreon.com/postgrest)
<a href="https://heroku.com/deploy?template=https://github.com/begriffs/postgrest"> [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.me/postgrest)
<a href="https://heroku.com/deploy?template=https://github.com/PostgREST/postgrest">
<img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy"> <img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy">
</a> </a>
[![Join the chat at https://gitter.im/begriffs/postgrest](https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg)](https://gitter.im/begriffs/postgrest) [![Join the chat at https://gitter.im/begriffs/postgrest](https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg)](https://gitter.im/begriffs/postgrest)
[![Docs](https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat)](http://postgrest.com) [![Docs](https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat)](http://postgrest.org)
[![Docker Stars](https://img.shields.io/docker/pulls/postgrest/postgrest.svg)](https://hub.docker.com/r/postgrest/postgrest/) [![Docker Stars](https://img.shields.io/docker/pulls/postgrest/postgrest.svg)](https://hub.docker.com/r/postgrest/postgrest/)
[![Build Status](https://circleci.com/gh/PostgREST/postgrest/tree/master.svg?style=shield)](https://circleci.com/gh/PostgREST/postgrest/tree/master)
[![Hackage docs](https://img.shields.io/hackage/v/postgrest.svg?label=hackage)](http://hackage.haskell.org/package/postgrest)
PostgREST serves a fully RESTful API from any existing PostgreSQL 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.
### Usage ## Sponsors
1. Download the binary ([latest release](https://github.com/begriffs/postgrest/releases/latest)) <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
1. Download the binary ([latest release](https://github.com/PostgREST/postgrest/releases/latest))
for your platform. for your platform.
2. Invoke for help: 2. Invoke for help:
```bash ```bash
postgrest --help postgrest --help
``` ```
## [Documentation](http://postgrest.org)
### Performance 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
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
@@ -50,13 +81,10 @@ by
* Using the PostgreSQL binary protocol * Using the PostgreSQL binary protocol
* Being stateless to allow horizontal scaling * Being stateless to allow horizontal scaling
Other optimizations are possible, and some are outlined in the ## Security
[Future Features](#future-features).
### Security
PostgREST [handles PostgREST [handles
authentication](http://postgrest.com/en/stable/auth.html) (via JSON Web authentication](http://postgrest.org/en/stable/auth.html) (via JSON Web
Tokens) and delegates authorization to the role information defined in Tokens) and delegates authorization to the role information defined in
the database. This ensures there is a single declarative source of truth the database. This ensures there is a single declarative source of truth
for security. When dealing with the database the server assumes the for security. When dealing with the database the server assumes the
@@ -74,7 +102,7 @@ are limited to certain templates using
functions, the trigger workaround does not compromise row-level functions, the trigger workaround does not compromise row-level
security. security.
### Versioning ## Versioning
A robust long-lived API needs the freedom to exist in multiple A robust long-lived API needs the freedom to exist in multiple
versions. PostgREST does versioning through database schemas. This versions. PostgREST does versioning through database schemas. This
@@ -82,7 +110,7 @@ allows you to expose tables and views without making the app brittle.
Underlying tables can be superseded and hidden behind public facing Underlying tables can be superseded and hidden behind public facing
views. views.
### Self-documentation ## Self-documentation
PostgREST uses the [OpenAPI](https://openapis.org/) standard to PostgREST uses the [OpenAPI](https://openapis.org/) standard to
generate up-to-date documentation for APIs. You can use a tool like generate up-to-date documentation for APIs. You can use a tool like
@@ -94,7 +122,7 @@ instance the number of rows returned by an endpoint is reported by -
and limited with - range headers. More about and limited with - range headers. More about
[that](http://begriffs.com/posts/2014-03-06-beyond-http-header-links.html). [that](http://begriffs.com/posts/2014-03-06-beyond-http-header-links.html).
### Data Integrity ## Data Integrity
Rather than relying on an Object Relational Mapper and custom Rather than relying on an Object Relational Mapper and custom
imperative coding, this system requires you put declarative constraints imperative coding, this system requires you put declarative constraints
@@ -106,14 +134,24 @@ surprises, such as enforcing idempotent PUT requests.
See examples of [PostgreSQL See examples of [PostgreSQL
constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm) constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)
and the [API guide](http://postgrest.com/en/stable/api.html). and the [API guide](http://postgrest.org/en/stable/api.html).
### Thanks ## Supporting development
I'm grateful to the generous project You can help PostgREST ongoing maintenance and development by:
[contributors](https://github.com/begriffs/postgrest/graphs/contributors)
who have improved PostgREST immensely with their code and good - Making a regular donation through Patreon https://www.patreon.com/postgrest
judgement. See more details in the
[changelog](https://github.com/begriffs/postgrest/blob/master/CHANGELOG.md). - Alternatively, you can make a one-time donation via Paypal https://www.paypal.me/postgrest
Every donation will be spent on making PostgREST better for the whole community.
## Thanks
The PostgREST organization is grateful to:
- The project [sponsors and backers](https://github.com/PostgREST/postgrest/blob/master/BACKERS.md) who support PostgREST's development.
- The project [contributors](https://github.com/PostgREST/postgrest/graphs/contributors) who have improved PostgREST immensely with their code
and good judgement. See more details in the [changelog](https://github.com/PostgREST/postgrest/blob/master/CHANGELOG.md).
The cool logo came from [Mikey Casalaina](https://github.com/casalaina). The cool logo came from [Mikey Casalaina](https://github.com/casalaina).
+5 -5
View File
@@ -1,19 +1,19 @@
{ {
"name": "PostgREST", "name": "PostgREST",
"description": "RESTful API for any PostgreSQL database.", "description": "RESTful API for any PostgreSQL database.",
"logo": "https://halcyon.sh/logo.svg", "logo": "https://avatars2.githubusercontent.com/u/15115011",
"repository": "https://github.com/begriffs/postgrest", "repository": "https://github.com/PostgREST/postgrest",
"env": { "env": {
"BUILDPACK_URL": { "BUILDPACK_URL": {
"description": "Heroku buildpack for deploying Haskell applications", "description": "Heroku buildpack for deploying Haskell applications",
"value": "https://github.com/begriffs/postgrest-heroku" "value": "https://github.com/PostgREST/postgrest-heroku"
}, },
"POSTGREST_VER": { "POSTGREST_VER": {
"description": "Version of PostgREST to deploy", "description": "Version of PostgREST to deploy",
"value": "0.4.4.0" "value": "6.0.2"
}, },
"DB_URI": { "DB_URI": {
"description": "Database connection string", "description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb",
"required": true "required": true
}, },
"DB_SCHEMA": { "DB_SCHEMA": {
+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 --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"
+13 -3
View File
@@ -1,4 +1,4 @@
FROM debian:jessie FROM debian:stretch-slim
ARG POSTGREST_VERSION ARG POSTGREST_VERSION
@@ -13,7 +13,7 @@ RUN BUILD_DEPS="curl ca-certificates xz-utils" && \
apt-get -qq update && \ apt-get -qq update && \
apt-get -qq install -y --no-install-recommends $BUILD_DEPS && \ apt-get -qq install -y --no-install-recommends $BUILD_DEPS && \
cd /tmp && \ cd /tmp && \
curl -SLO https://github.com/begriffs/postgrest/releases/download/${POSTGREST_VERSION}/postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \ curl -SLO https://github.com/PostgREST/postgrest/releases/download/${POSTGREST_VERSION}/postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
tar -xJvf postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \ tar -xJvf postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
mv postgrest /usr/local/bin/postgrest && \ mv postgrest /usr/local/bin/postgrest && \
cd / && \ cd / && \
@@ -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= \
@@ -35,7 +36,16 @@ ENV PGRST_DB_URI= \
PGRST_SECRET_IS_BASE64=false \ PGRST_SECRET_IS_BASE64=false \
PGRST_JWT_AUD= \ PGRST_JWT_AUD= \
PGRST_MAX_ROWS= \ PGRST_MAX_ROWS= \
PGRST_PRE_REQUEST= PGRST_PRE_REQUEST= \
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 && \
+4
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)"
@@ -10,6 +11,9 @@ server-proxy-uri = "$(PGRST_SERVER_PROXY_URI)"
jwt-secret = "$(PGRST_JWT_SECRET)" jwt-secret = "$(PGRST_JWT_SECRET)"
secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)" secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)"
jwt-aud = "$(PGRST_JWT_AUD)" jwt-aud = "$(PGRST_JWT_AUD)"
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)"
+152 -82
View File
@@ -2,38 +2,51 @@
module Main where module Main where
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as B64
import qualified Hasql.Pool as P
import qualified Hasql.Transaction.Sessions as HT
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
updateAction)
import Control.Retry (RetryStatus, capDelay,
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)
import PostgREST.App (postgrest)
import PostgREST.Config (AppConfig (..),
minimumPgVersion,
prettyVersion, readOptions)
import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.Error (encodeError)
import PostgREST.OpenAPI (isMalformedProxyUri)
import PostgREST.Types (DbStructure, Schema, PgVersion(..))
import Protolude hiding (hPutStrLn, replace)
import Control.Retry (RetryStatus, capDelay,
exponentialBackoff,
retrying, rsPreviousDelay)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as B64
import Data.IORef (IORef, atomicWriteIORef,
newIORef, readIORef)
import Data.String (IsString (..))
import Data.Text (pack, replace, stripPrefix, strip)
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Text.IO (hPutStrLn)
import qualified Hasql.Pool as P
import qualified Hasql.Session as H
import Network.Wai.Handler.Warp (defaultSettings,
runSettings, setHost,
setPort, setServerName,
setTimeout)
import System.IO (BufferMode (..),
hSetBuffering)
#ifndef mingw32_HOST_OS #ifndef mingw32_HOST_OS
import System.Posix.Signals import System.Posix.Signals
#endif #endif
{-| {-|
@@ -70,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 <- 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
@@ -99,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.
-} -}
@@ -135,28 +158,32 @@ main = do
-- --
-- readOptions builds the 'AppConfig' from the config file specified on the -- readOptions builds the 'AppConfig' from the config file specified on the
-- command line -- command line
conf <- loadSecretFile =<< readOptions conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
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
appSettings = appSettings =
setHost ((fromString . toS) host) -- Warp settings setHost ((fromString . toS) host) -- Warp settings
. setPort port . setPort port
. setServerName (toS $ "postgrest/" <> prettyVersion) . setServerName (toS $ "postgrest/" <> prettyVersion) $
. setTimeout 3600 $
defaultSettings defaultSettings
--
-- Checks that the provided proxy uri is formated correctly, -- Checks that the provided proxy uri is formated correctly
-- does not test if it works here.
when (isMalformedProxyUri $ toS <$> proxy) $ when (isMalformedProxyUri $ toS <$> proxy) $
panic panic
"Malformed proxy uri, a correct example: https://example.com:8443/basePath" "Malformed proxy uri, a correct example: https://example.com:8443/basePath"
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
-- Checks that the provided jspath is valid
when (isLeft roleClaimKey) $
panic $ show roleClaimKey
-- --
-- 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
@@ -190,7 +217,7 @@ main = do
throwTo mainTid UserInterrupt throwTo mainTid UserInterrupt
) Nothing ) Nothing
void $ installHandler sigHUP ( void $ installHandler sigUSR1 (
Catch $ connectionWorker Catch $ connectionWorker
mainTid mainTid
pool pool
@@ -200,19 +227,35 @@ main = do
) Nothing ) Nothing
#endif #endif
--
-- run the postgrest application -- ask for the OS time at most once per second
runSettings appSettings $ getTime <- mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}
postgrest
conf let postgrestApplication =
refDbStructure postgrest
pool conf
(connectionWorker refDbStructure
mainTid pool
pool getTime
(configSchema conf) (connectionWorker
refDbStructure mainTid
refIsWorkerOn) pool
(configSchema conf)
refDbStructure
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
@@ -265,3 +308,30 @@ loadSecretFile conf = extractAndTransform mSecret
-- replace: Replace every occurrence of one substring with another -- replace: Replace every occurrence of one substring with another
replaceUrlChars = replaceUrlChars =
replace "_" "/" . replace "-" "+" . replace "." "=" replace "_" "/" . replace "-" "+" . replace "." "="
{-
Load database uri from a separate file if `db-uri` is a filepath.
-}
loadDbUriFile :: AppConfig -> IO AppConfig
loadDbUriFile conf = extractDbUri mDbUri
where
mDbUri = configDatabase conf
extractDbUri :: Text -> IO AppConfig
extractDbUri dbUri =
fmap setDbUri $
case stripPrefix "@" dbUri of
Nothing -> return dbUri
Just filename -> strip <$> readFile (toS filename)
setDbUri dbUri = conf {configDatabase = dbUri}
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
+182 -157
View File
@@ -1,163 +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: 0.4.4.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://github.com/begriffs/postgrest maintainer: Steve Chávez <stevechavezast@gmail.com>
maintainer: cred+github@begriffs.com category: Executable, PostgreSQL, Network APIs
category: Web homepage: https://postgrest.org
build-type: Simple bug-reports: https://github.com/PostgREST/postgrest/issues
cabal-version: >=1.10 build-type: Simple
extra-source-files: CHANGELOG.md
cabal-version: >= 1.10
source-repository head source-repository head
type: git type: git
location: git://github.com/begriffs/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: base
, hasql
, hasql-pool
, postgrest
, protolude
, text
, 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 && < 6 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
, either PostgREST.Types
, gitrev other-modules: Paths_postgrest
, hasql PostgREST.QueryBuilder.Private
, hasql-pool == 0.4.1 PostgREST.QueryBuilder.Procedure
, hasql-transaction == 0.5 PostgREST.QueryBuilder.ReadStatement
, heredoc PostgREST.QueryBuilder.WriteStatement
, HTTP hs-source-dirs: src
, http-types build-depends: base >= 4.9 && < 4.13
, insert-ordered-containers , HTTP >= 4000.3.7 && < 4000.4
, interpolatedstring-perl6 , Ranged-sets >= 0.3 && < 0.5
, jose >= 0.6 , aeson >= 0.11.3 && < 1.5
, lens , ansi-wl-pprint >= 0.6.7 && < 0.7
, lens-aeson , base64-bytestring >= 1 && < 1.1
, network-uri , bytestring >= 0.10.8 && < 0.11
, optparse-applicative >= 0.13 && < 0.15 , case-insensitive >= 1.2 && < 1.3
, parsec , cassava >= 0.4.5 && < 0.6
, protolude >= 0.2 , configurator-pg >= 0.1 && < 0.2
, Ranged-sets == 0.3.0 , containers >= 0.5.7 && < 0.7
, regex-tdfa , contravariant >= 1.4 && < 1.6
, safe , 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
, unordered-containers , hasql >= 1.4 && < 1.5
, vector , hasql-pool >= 0.5 && < 0.6
, wai , hasql-transaction >= 0.7.2 && < 0.8
, wai-cors , heredoc >= 0.2 && < 0.3
, wai-extra , http-types >= 0.12.2 && < 0.13
, wai-middleware-static , insert-ordered-containers >= 0.1 && < 0.3
, cookie , interpolatedstring-perl6 >= 1 && < 1.1
, 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.InsertSpec Feature.ExtraSearchPathSpec
, Feature.NoJwtSpec Feature.InsertSpec
, Feature.PgVersion96Spec Feature.JsonOperatorSpec
, Feature.ProxySpec Feature.NoJwtSpec
, Feature.QueryLimitedSpec Feature.NonexistentSchemaSpec
, Feature.QuerySpec Feature.PgVersion95Spec
, Feature.RangeSpec Feature.PgVersion96Spec
, Feature.SingularSpec Feature.ProxySpec
, Feature.StructureSpec Feature.QueryLimitedSpec
, Feature.UnicodeSpec Feature.QuerySpec
, Feature.AndOrParamsSpec Feature.RangeSpec
, Feature.RpcSpec Feature.RootSpec
, Feature.NonexistentSchemaSpec Feature.RpcSpec
, SpecHelper Feature.SingularSpec
, TestTypes Feature.StructureSpec
Build-Depends: aeson Feature.UnicodeSpec
, aeson-qq Feature.UpsertSpec
, async Feature.RawOutputTypesSpec
, base Feature.HtmlRawOutputSpec
, bytestring SpecHelper
, base64-bytestring TestTypes
, case-insensitive hs-source-dirs: test
, cassava build-depends: base >= 4.9 && < 4.13
, containers , aeson >= 0.11.3 && < 1.5
, contravariant , aeson-qq >= 0.8.1 && < 0.9
, hasql , async >= 2.1.1 && < 2.3
, hasql-pool , auto-update >= 0.1.4 && < 0.2
, heredoc , base64-bytestring >= 1 && < 1.1
, hjsonschema == 1.5.0.1 , bytestring >= 0.10.8 && < 0.11
, hspec , case-insensitive >= 1.2 && < 1.3
, hspec-wai >= 0.7.0 , cassava >= 0.4.5 && < 0.6
, hspec-wai-json , containers >= 0.5.7 && < 0.7
, http-types , contravariant >= 1.4 && < 1.6
, lens , hasql >= 1.4 && < 1.5
, lens-aeson , hasql-pool >= 0.5 && < 0.6
, monad-control , hasql-transaction >= 0.7.2 && < 0.8
, postgrest , heredoc >= 0.2 && < 0.3
, process , hspec >= 2.3 && < 2.8
, protolude , hspec-wai >= 0.7 && < 0.10
, regex-tdfa , hspec-wai-json >= 0.7 && < 0.10
, transformers-base , http-types >= 0.12.3 && < 0.13
, wai , lens >= 4.14 && < 4.18
, wai-extra , lens-aeson >= 1.0.1 && < 1.1
, monad-control >= 1.0.1 && < 1.1
, postgrest
, process >= 1.4.2 && < 1.7
, protolude >= 0.2.2 && < 0.3
, regex-tdfa >= 1.2.2 && < 1.3
, text >= 1.2.2 && < 1.3
, 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
+152 -146
View File
@@ -2,66 +2,70 @@
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) , 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 ( QualifiedIdentifier (..) import Data.Aeson.Types (emptyArray, emptyObject)
, Schema import Data.List (last, lookup, partition)
, PayloadJSON(..) import Data.Maybe (fromJust)
, ContentType(..) import Data.Ranged.Ranges (Range (..), emptyRange,
, ApiRequestError(..) rangeIntersection)
, toMime import Network.HTTP.Base (urlEncodeVars)
, operators import Network.HTTP.Types.Header (hAuthorization, hCookie)
, ftsOperators) import Network.HTTP.Types.URI (parseQueryReplacePlus,
import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange) parseSimpleQuery)
import qualified Data.CaseInsensitive as CI import Network.Wai (Request (..))
import Web.Cookie (parseCookiesText) 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
-- | Types of things a user wants to do to tables/views/procs -- | Types of things a user wants to do to tables/views/procs
data Action = ActionCreate | ActionRead data Action = ActionCreate | ActionRead
| ActionUpdate | ActionDelete | ActionUpdate | ActionDelete
| ActionInfo | ActionInvoke{isReadOnly :: Bool} | ActionInfo | ActionInvoke{isReadOnly :: Bool}
| ActionInspect | ActionInspect | ActionSingleUpsert
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
data PreferRepresentation = Full | HeadersOnly | None deriving Eq data PreferRepresentation = Full | HeadersOnly | None deriving Eq
--
{-| {-|
Describes what the user wants to do. This data type is a Describes what the user wants to do. This data type is a
translation of the raw elements of an HTTP request into domain translation of the raw elements of an HTTP request into domain
@@ -71,102 +75,121 @@ 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
, 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)]
-- | Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
, iRpcQParams :: [(Text, Text)]
} }
-- | Examines HTTP request and translates it into user intent. -- | 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) undefined payload | shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
| otherwise = Right ApiRequest { | otherwise = Right ApiRequest {
iAction = action iAction = action
, iTarget = target , iTarget = target
, iRange = ranges , iRange = ranges
, iAccepts = fromMaybe [CTAny] $ , iAccepts = maybe [CTAny] (map decodeContentType . parseHttpAccept) $ lookupHeader "accept"
map decodeContentType . parseHttpAccept <$> lookupHeader "accept"
, iPayload = relevantPayload , iPayload = relevantPayload
, iPreferRepresentation = representation , iPreferRepresentation = representation
, iPreferSingleObjectParameter = singleObject , iPreferSingleObjectParameter = singleObject
, iPreferCount = hasPrefer "count=exact" , iPreferCount = hasPrefer "count=exact"
, iPreferResolution = if hasPrefer (show MergeDuplicates) then Just MergeDuplicates
else if hasPrefer (show IgnoreDuplicates) then Just IgnoreDuplicates
else Nothing
, iFilters = filters , iFilters = filters
, iRpcQParams = rpcQParams
, 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 = fromMaybe [] $ 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..)
(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 = fromMaybe False $ (== "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" of case (contentType, action) of
CTApplicationJSON -> (_, ActionInvoke{isReadOnly=True}) ->
note "All object keys must match" . ensureUniform . pluralize Right $ ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) PJObject (S.fromList $ fst <$> rpcQParams)
=<< if BL.null reqBody && isTargetingProc (CTApplicationJSON, _) ->
then Right emptyObject if isJust columns
else JSON.eitherDecode reqBody then Right $ RawJSON reqBody
CTTextCSV -> else note "All object keys must match" . payloadAttributes reqBody
note "All lines must have same number of fields" . ensureUniform . csvToJson =<< if BL.null reqBody && isTargetingProc
=<< CSV.decodeByName reqBody then Right emptyObject
CTOther "application/x-www-form-urlencoded" -> else JSON.eitherDecode reqBody
Right . PayloadJSON . V.singleton . M.fromList (CTTextCSV, _) -> do
. map (toS *** JSON.String . toS) . parseSimpleQuery json <- csvToJson <$> CSV.decodeByName reqBody
$ toS reqBody note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json
ct -> (CTOther "application/x-www-form-urlencoded", _) ->
let json = M.fromList . map (toS *** JSON.String . toS) . parseSimpleQuery $ toS reqBody
keys = S.fromList $ M.keys json in
Right $ ProcessedJSON (JSON.encode json) PJObject keys
(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
@@ -174,24 +197,25 @@ userApiRequest schema req reqBody
then ActionInvoke{isReadOnly=False} then ActionInvoke{isReadOnly=False}
else ActionCreate else ActionCreate
"PATCH" -> ActionUpdate "PATCH" -> ActionUpdate
"PUT" -> ActionSingleUpsert
"DELETE" -> ActionDelete "DELETE" -> ActionDelete
"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, ActionInvoke{isReadOnly=False}]
relevantPayload | action == ActionInvoke{isReadOnly=True} = Nothing shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke{isReadOnly=False}, ActionInvoke{isReadOnly=True}]
| 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
@@ -216,7 +240,7 @@ userApiRequest schema req reqBody
limitParams :: M.HashMap ByteString NonnegRange limitParams :: M.HashMap ByteString NonnegRange
limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< (toS <$> v)) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k] limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< (toS <$> v)) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]
offsetParams :: M.HashMap ByteString NonnegRange offsetParams :: M.HashMap ByteString NonnegRange
offsetParams = M.fromList [(toS (replaceLast "limit" k), fromMaybe allRange (rangeGeq <$> (readMaybe =<< (toS <$> v)))) | (k,v) <- qParams, isJust v, endingIn ["offset"] k] offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe =<< (toS <$> v))) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]
urlRange = M.unionWith f limitParams offsetParams urlRange = M.unionWith f limitParams offsetParams
where where
@@ -239,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)
{-| {-|
@@ -270,9 +277,9 @@ type CsvData = V.Vector (M.HashMap Text BL.ByteString)
The reason for its odd signature is so that it can compose The reason for its odd signature is so that it can compose
directly with CSV.decodeByName directly with CSV.decodeByName
-} -}
csvToJson :: (CSV.Header, CsvData) -> JSON.Array csvToJson :: (CSV.Header, CsvData) -> JSON.Value
csvToJson (_, vals) = csvToJson (_, vals) =
V.map rowToJsonObj vals JSON.Array $ V.map rowToJsonObj vals
where where
rowToJsonObj = JSON.Object . rowToJsonObj = JSON.Object .
M.map (\str -> M.map (\str ->
@@ -281,27 +288,26 @@ csvToJson (_, vals) =
else JSON.String $ toS str else JSON.String $ toS str
) )
-- | Convert {foo} to [{foo}], leave arrays unchanged payloadAttributes :: RequestBody -> JSON.Value -> Maybe PayloadJSON
-- and truncate everything else to an empty array. payloadAttributes raw json =
pluralize :: JSON.Value -> JSON.Array -- Test that Array contains only Objects having the same keys
pluralize obj@(JSON.Object _) = V.singleton obj case json of
pluralize (JSON.Array arr) = arr JSON.Array arr ->
pluralize _ = V.empty case arr V.!? 0 of
Just (JSON.Object o) ->
let canonicalKeys = S.fromList $ M.keys o
areKeysUniform = all (\case
JSON.Object x -> S.fromList (M.keys x) == canonicalKeys
_ -> False) arr in
if areKeysUniform
then Just $ ProcessedJSON raw (PJArray $ V.length arr) canonicalKeys
else Nothing
Just _ -> Nothing
Nothing -> Just emptyPJArray
-- | Test that Array contains only Objects having the same keys JSON.Object o -> Just $ ProcessedJSON raw PJObject (S.fromList $ M.keys o)
-- and if so mark it as PayloadJSON
ensureUniform :: JSON.Array -> Maybe PayloadJSON
ensureUniform arr =
let objs :: V.Vector JSON.Object
objs = foldr -- filter non-objects, map to raw objects
(\val result -> case val of
JSON.Object o -> V.cons o result
_ -> result)
V.empty arr
keysPerObj = V.map (S.fromList . M.keys) objs
canonicalKeys = fromMaybe S.empty $ keysPerObj V.!? 0
areKeysUniform = all (==canonicalKeys) keysPerObj in
if (V.length objs == V.length arr) && areKeysUniform -- truncate everything else to an empty array.
then Just (PayloadJSON objs) _ -> Just emptyPJArray
else Nothing where
emptyPJArray = ProcessedJSON (JSON.encode emptyArray) (PJArray 0) S.empty
+225 -200
View File
@@ -1,133 +1,128 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
module PostgREST.App ( module PostgREST.App (
postgrest postgrest
) where ) where
import Control.Applicative import qualified Data.ByteString.Char8 as BS
import Data.Aeson (toJSON, eitherDecode) 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 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 Data.Vector as V import Control.Applicative
import qualified Hasql.Transaction as H 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 (..),
PreferRepresentation (..),
Target (..), mutuallyAgreeable,
userApiRequest)
import PostgREST.Auth (containsRole, jwtClaims,
parseSecret)
import PostgREST.Config (AppConfig (..))
import PostgREST.DbRequestBuilder (fieldNames, mutateRequest,
readRequest)
import PostgREST.DbStructure
import PostgREST.Error (PgError (..), SimpleError (..),
errorResponseFor, singularityError)
import PostgREST.Middleware
import PostgREST.OpenAPI
import PostgREST.Parsers (pRequestColumns)
import PostgREST.QueryBuilder (ResultsWithCount, callProc,
createReadStatement,
createWriteStatement,
requestToCountQuery,
requestToQuery)
import PostgREST.RangeQuery (allRange, rangeOffset)
import PostgREST.Types
import Protolude hiding (Proxy, intercalate)
import PostgREST.ApiRequest ( ApiRequest(..), ContentType(..) postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> IO () -> Application
, Action(..), Target(..) postgrest conf refDbStructure pool getTime worker =
, PreferRepresentation (..)
, mutuallyAgreeable
, userApiRequest
)
import PostgREST.Auth (jwtClaims, containsRole, parseJWK)
import PostgREST.Config (AppConfig (..))
import PostgREST.DbStructure
import PostgREST.DbRequestBuilder( readRequest
, mutateRequest
, readRpcRequest
, fieldNames
)
import PostgREST.Error ( simpleError, pgError
, apiRequestError
, singularityError, binaryFieldError
, connectionLostError, gucHeadersError
)
import PostgREST.RangeQuery (allRange, rangeOffset)
import PostgREST.Middleware
import PostgREST.QueryBuilder ( callProc
, requestToQuery
, requestToCountQuery
, createReadStatement
, createWriteStatement
, ResultsWithCount
)
import PostgREST.Types
import PostgREST.OpenAPI
import Data.Function (id)
import Protolude hiding (intercalate, Proxy)
import Safe (headMay)
postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO () -> Application
postgrest conf refDbStructure pool worker =
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle let middle = (if configQuiet conf then id else logStdout) . defaultMiddle
jwtSecret = parseJWK <$> configJwtSecret conf in jwtSecret = parseSecret <$> configJwtSecret conf in
middle $ \ req respond -> do middle $ \ req respond -> do
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) apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
case apiReqCols of
let authed = containsRole eClaims Left err -> return . errorResponseFor $ err
handleReq = runWithClaims conf eClaims (app dbStructure conf) apiRequest Right (apiRequest, maybeCols) -> do
txMode = transactionMode dbStructure eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) time (rightToMaybe $ configRoleClaimKey conf)
(iTarget apiRequest) (iAction apiRequest) let authed = containsRole eClaims
response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq cols = case (iPayload apiRequest, maybeCols) of
return $ either (pgError authed) identity response (Just ProcessedJSON{pjKeys}, _) -> pjKeys
(Just RawJSON{}, Just cls) -> cls
_ -> 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
transactionMode :: DbStructure -> Target -> Action -> H.Mode transactionMode :: Maybe ProcDescription -> Action -> HT.Mode
transactionMode structure target action = transactionMode proc action =
case action of case action of
ActionRead -> HT.Read ActionRead -> HT.Read
ActionInfo -> HT.Read ActionInfo -> HT.Read
ActionInspect -> HT.Read ActionInspect -> HT.Read
ActionInvoke{isReadOnly=False} -> ActionInvoke{isReadOnly=False} ->
let proc = let v = maybe Volatile pdVolatility proc in
case target of
(TargetProc qi) -> M.lookup (qiName qi) $
dbProcs structure
_ -> Nothing
v = fromMaybe 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 -> AppConfig -> ApiRequest -> H.Transaction Response app :: DbStructure -> Maybe ProcDescription -> S.Set FieldName -> AppConfig -> ApiRequest -> H.Transaction Response
app dbStructure 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
let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount
(contentType == CTTextCSV) bField (contentType == CTTextCSV) bField
row <- H.query () stm row <- H.statement () stm
let (tableTotal, queryTotal, _ , body) = row let (tableTotal, queryTotal, _ , body) = row
(status, contentRange) = rangeHeader queryTotal tableTotal (status, contentRange) = rangeHeader queryTotal tableTotal
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",
@@ -136,84 +131,121 @@ app dbStructure conf apiRequest =
) )
] (toS body) ] (toS body)
(ActionCreate, TargetIdent (QualifiedIdentifier _ table), Just payload@(PayloadJSON rows)) -> (ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
case mutateSqlParts of case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right (sq, mq) -> do Right (sq, mq) -> do
let isSingle = (==1) $ V.length rows let pkCols = tablePKCols dbStructure tSchema tName
stm = createWriteStatement sq mq
(contentType == CTSingularJSON) True
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols
row <- H.statement (toS $ pjRaw pJson) stm
let (_, queryTotal, fs, body) = extractQueryResult row
headers = catMaybes [
if null fs
then Nothing
else Just (hLocation, "/" <> toS tName <> renderLocationFields fs)
, if iPreferRepresentation apiRequest == Full
then Just $ toHeader contentType
else Nothing
, Just $ contentRangeH 1 0 $
if shouldCount then Just queryTotal else Nothing
, if null pkCols
then Nothing
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
]
if contentType == CTSingularJSON if contentType == CTSingularJSON
&& not isSingle && queryTotal /= 1
&& iPreferRepresentation apiRequest == Full && iPreferRepresentation apiRequest == Full
then return $ singularityError (toInteger $ V.length rows) then do
else do HT.condemn
let pKeys = map pkName $ filter (filterPk schema table) allPrKeys -- would it be ok to move primary key detection in the query itself? return . errorResponseFor . singularityError $ queryTotal
stm = createWriteStatement sq mq else
(contentType == CTSingularJSON) isSingle return . responseLBS status201 headers $
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) if iPreferRepresentation apiRequest == Full
pKeys then toS body else ""
row <- H.query payload stm
let (_, _, fs, body) = extractQueryResult row
headers = catMaybes [
if null fs
then Nothing
else Just (hLocation, "/" <> toS table <> renderLocationFields fs)
, if iPreferRepresentation apiRequest == Full
then Just $ toHeader contentType
else Nothing
, Just . contentRangeH 1 0 $
toInteger <$> if shouldCount then Just (V.length rows) else Nothing
]
return . responseLBS status201 headers $ (ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
if iPreferRepresentation apiRequest == Full case mutateSqlParts tSchema tName of
then toS body else "" Left errorResponse -> return errorResponse
Right (sq, mq) -> do
(ActionUpdate, TargetIdent _, Just payload@(PayloadJSON rows)) ->
case (mutateSqlParts, null <$> rows V.!? 0, iPreferRepresentation apiRequest == Full) of
(Left errorResponse, _, _) -> return errorResponse
(_, Just True, True) -> return $ responseLBS status200 [contentRangeH 1 0 Nothing] "[]"
(_, Just True, False) -> return $ responseLBS status204 [contentRangeH 1 0 Nothing] ""
(Right (sq, mq), _, _) -> do
let stm = createWriteStatement sq mq let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False (contentType == CTTextCSV) (contentType == CTSingularJSON) False (contentType == CTTextCSV)
(iPreferRepresentation apiRequest) [] (iPreferRepresentation apiRequest) []
row <- H.query payload stm row <- H.statement (toS $ pjRaw pJson) stm
let (_, queryTotal, _, body) = extractQueryResult row let (_, queryTotal, _, body) = extractQueryResult row
if contentType == CTSingularJSON
&& queryTotal /= 1
&& iPreferRepresentation apiRequest == Full
then do
HT.condemn
return $ singularityError (toInteger queryTotal)
else do
let r = contentRangeH 0 (toInteger $ queryTotal-1)
(toInteger <$> if shouldCount then Just queryTotal else Nothing)
s = if iPreferRepresentation apiRequest == Full
then status200
else status204
return $ if iPreferRepresentation apiRequest == Full
then responseLBS s [toHeader contentType, r] (toS body)
else responseLBS s [r] ""
(ActionDelete, TargetIdent _, Nothing) -> updateIsNoOp = S.null cols
case mutateSqlParts of 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
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right (sq, mq) -> do Right (sq, mq) -> do
let emptyPayload = PayloadJSON V.empty let isSingle = case pjType of
stm = createWriteStatement sq mq PJArray len -> len == 1
PJObject -> True
colNames = colName <$> tableCols dbStructure tSchema tName
if topLevelRange /= allRange
then return . errorResponseFor $ PutRangeNotAllowedError
else if not isSingle
then return . errorResponseFor $ PutSingletonError
else if S.fromList colNames /= pjKeys
then return . errorResponseFor $ PutPayloadIncompleteError
else do
row <- H.statement (toS pjRaw) $
createWriteStatement sq mq (contentType == CTSingularJSON) False
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) []
let (_, queryTotal, _, body) = extractQueryResult row
-- Makes sure the querystring pk matches the payload pk
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
-- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done
if queryTotal /= 1
then do
HT.condemn
return . errorResponseFor $ PutMatchingPkError
else
return $ if iPreferRepresentation apiRequest == Full
then responseLBS status200 [toHeader contentType] (toS body)
else responseLBS status204 [] ""
(ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
case mutateSqlParts tSchema tName of
Left errorResponse -> return errorResponse
Right (sq, mq) -> do
let stm = createWriteStatement sq mq
(contentType == CTSingularJSON) False (contentType == CTSingularJSON) False
(contentType == CTTextCSV) (contentType == CTTextCSV)
(iPreferRepresentation apiRequest) [] (iPreferRepresentation apiRequest) []
row <- H.query emptyPayload 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)
@@ -227,69 +259,55 @@ app dbStructure 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 _isReadOnly, TargetProc qi, payload) -> (ActionInvoke _, TargetProc qi _, Just pJson) ->
let proc = M.lookup (qiName qi) allProcs let returnsScalar = case proc of
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 <*> rpcQParams in parts = (,) <$> readSqlParts <*> rpcBinaryField in
case parts of case parts of
Left errorResponse -> return errorResponse Left errorResponse -> return errorResponse
Right ((q, cq), bField, params) -> do Right ((q, cq), bField) -> do
let prms = case payload of let singular = contentType == CTSingularJSON
Just (PayloadJSON pld) -> V.head pld row <- H.statement (toS $ pjRaw pJson) $
Nothing -> M.fromList $ second toJSON <$> params -- toJSON is just for reusing the callProc function callProc qi (specifiedProcArgs cols proc) returnsScalar q cq shouldCount
singular = contentType == CTSingularJSON singular (iPreferSingleObjectParameter apiRequest)
paramsAsSingleObject = iPreferSingleObjectParameter apiRequest
row <- H.query () $
callProc qi prms returnsScalar q cq shouldCount
singular paramsAsSingleObject
(contentType == CTTextCSV) (contentType == CTTextCSV)
(contentType == CTOctetStream) _isReadOnly bField (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 $ 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
uri Nothing = ("http", host, port, "/") uri Nothing = ("http", host, port, "/")
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b) uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
uri' = uri proxy uri' = uri proxy
encodeApi ti sd procs = encodeOpenAPI (M.elems procs) (toTableInfo ti) uri' sd (dbPrimaryKeys dbStructure) toTableInfo :: [Table] -> [(Table, [Column], [Text])]
body <- encodeApi <$> H.query schema accessibleTables <*> H.query schema schemaDescription <*> H.query schema accessibleProcs 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
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
_ -> return notFound _ -> return notFound
where where
toTableInfo :: [Table] -> [(Table, [Column], [Text])]
toTableInfo = map (\t ->
let tSchema = tableSchema t
tTable = tableName t
cols = filter (filterCol tSchema tTable) $ dbColumns dbStructure
pkeys = map pkName $ filter (filterPk tSchema tTable) allPrKeys
in (t, cols, pkeys))
notFound = responseLBS status404 [] "" notFound = responseLBS status404 [] ""
filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
filterCol :: Schema -> TableName -> Column -> Bool
filterCol sc tb Column{colTable=Table{tableSchema=s, tableName=t}} = s==sc && t==tb
allPrKeys = dbPrimaryKeys dbStructure
allProcs = dbProcs dbStructure
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
shouldCount = iPreferCount apiRequest shouldCount = iPreferCount apiRequest
schema = toS $ configSchema conf schema = toS $ configSchema conf
@@ -301,45 +319,52 @@ app dbStructure conf apiRequest =
status = rangeStatus lower upper (toInteger <$> tableTotal) status = rangeStatus lower upper (toInteger <$> tableTotal)
in (status, contentRange) in (status, contentRange)
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) allProcs apiRequest readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) proc apiRequest
fldNames = fieldNames <$> readReq fldNames = fieldNames <$> readReq
readDbRequest = DbRead <$> readReq readDbRequest = DbRead <$> readReq
mutateDbRequest = DbMutate <$> (mutateRequest apiRequest =<< fldNames)
rpcQParams = readRpcRequest apiRequest
selectQuery = requestToQuery schema False <$> readDbRequest selectQuery = requestToQuery schema False <$> readDbRequest
mutateQuery = requestToQuery schema False <$> mutateDbRequest
countQuery = requestToCountQuery schema <$> readDbRequest countQuery = requestToCountQuery schema <$> readDbRequest
readSqlParts = (,) <$> selectQuery <*> countQuery readSqlParts = (,) <$> selectQuery <*> countQuery
mutateSqlParts = (,) <$> selectQuery <*> mutateQuery mutationDbRequest s t = mutateRequest apiRequest t cols (tablePKCols dbStructure s t) =<< fldNames
mutateSqlParts s t =
(,) <$> selectQuery
<*> (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]
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)
@@ -356,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
@@ -364,8 +389,8 @@ contentRangeH lower upper total =
rangeString rangeString
| totalNotZero && fromInRange = show lower <> "-" <> show upper | totalNotZero && fromInRange = show lower <> "-" <> show upper
| otherwise = "*" | otherwise = "*"
totalString = fromMaybe "*" (show <$> total) totalString = maybe "*" show total
totalNotZero = fromMaybe True ((/=) 0 <$> total) totalNotZero = maybe True (0 /=) total
fromInRange = lower <= upper fromInRange = lower <= upper
extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount
+58 -32
View File
@@ -1,4 +1,5 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-| {-|
Module : PostgREST.Auth Module : PostgREST.Auth
Description : PostgREST authorization functions. Description : PostgREST authorization functions.
@@ -15,42 +16,67 @@ module PostgREST.Auth (
containsRole containsRole
, jwtClaims , jwtClaims
, JWTAttempt(..) , JWTAttempt(..)
, parseJWK , parseSecret
) where ) where
import Control.Lens.Operators import qualified Crypto.JOSE.Types as JOSE.Types
import Data.Aeson (Value (..), decode, toJSON) import qualified Data.Aeson as JSON
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import Protolude import Data.Vector as V
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
-} -}
data JWTAttempt = JWTInvalid JWTError data JWTAttempt = JWTInvalid JWTError
| JWTMissingSecret | JWTMissingSecret
| JWTClaims (M.HashMap Text Value) | JWTClaims (M.HashMap Text JSON.Value)
deriving (Eq, Show) deriving (Eq, Show)
{-| {-|
Receives the JWT secret and audience (from config) and a JWT and returns a map Receives the JWT secret and audience (from config) and a JWT and returns a map
of JWT claims. of JWT claims.
-} -}
jwtClaims :: Maybe JWK -> Maybe StringOrURI -> LByteString -> IO JWTAttempt jwtClaims :: Maybe JWKSet -> Maybe StringOrURI -> LByteString -> UTCTime -> Maybe JSPath -> IO JWTAttempt
jwtClaims _ _ "" = return $ JWTClaims M.empty jwtClaims _ _ "" _ _ = return $ JWTClaims M.empty
jwtClaims secret audience payload = jwtClaims secret audience payload time jspath =
case secret of case secret of
Nothing -> return JWTMissingSecret Nothing -> return JWTMissingSecret
Just s -> do Just s -> do
let validation = defaultJWTValidationSettings (maybe (const True) (==) audience) let validation = set allowedSkew 1 $ defaultJWTValidationSettings (maybe (const True) (==) audience)
eJwt <- runExceptT $ do eJwt <- runExceptT $ do
jwt <- decodeCompact payload jwt <- decodeCompact payload
verifyClaims validation s jwt verifyClaimsAt validation s time jwt
return $ case eJwt of return $ case eJwt of
Left e -> JWTInvalid e Left e -> JWTInvalid e
Right jwt -> JWTClaims . claims2map $ jwt Right jwt -> JWTClaims $ claims2map jwt jspath
{-|
Turn JWT ClaimSet into something easier to work with,
also here the jspath is applied to put the "role" in the map
-}
claims2map :: ClaimsSet -> Maybe JSPath -> M.HashMap Text JSON.Value
claims2map claims jspath = (\case
val@(JSON.Object o) ->
let role = maybe M.empty (M.singleton "role") $
walkJSPath (Just val) =<< jspath in
M.delete "role" o `M.union` role -- mutating the map
_ -> M.empty
) $ JSON.toJSON claims
walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
walkJSPath x [] = x
walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (M.lookup key o) rest
walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
walkJSPath _ _ = Nothing
{-| {-|
Whether a response from jwtClaims contains a role claim Whether a response from jwtClaims contains a role claim
@@ -60,28 +86,28 @@ containsRole (JWTClaims claims) = M.member "role" claims
containsRole _ = False containsRole _ = False
{-| {-|
Internal helper used to turn JWT ClaimSet into something Parse `jwt-secret` configuration option and turn into a JWKSet.
easier to work with
-}
claims2map :: ClaimsSet -> M.HashMap Text Value
claims2map = val2map . toJSON
where
val2map (Object o) = o
val2map _ = M.empty
parseJWK :: ByteString -> JWK There are three ways to specify `jwt-secret`: text secret, JSON Web Key
parseJWK str = (JWK), or JSON Web Key Set (JWKS). The first two are converted into a JWKSet
fromMaybe (hs256jwk str) (decode (toS str) :: Maybe JWK) with one key and the last is converted as is.
-}
parseSecret :: ByteString -> JWKSet
parseSecret str =
fromMaybe (maybe secret (\jwk' -> JWKSet [jwk']) maybeJWK)
maybeJWKSet
where
maybeJWKSet = JSON.decode (toS str) :: Maybe JWKSet
maybeJWK = JSON.decode (toS str) :: Maybe JWK
secret = JWKSet [jwkFromSecret str]
{-| {-|
Internal helper to generate HMAC-SHA256. When the jwt key in the Internal helper to generate a symmetric HMAC-SHA256 JWK from a text secret.
config file is a simple string rather than a JWK object, we'll
apply this function to it.
-} -}
hs256jwk :: ByteString -> JWK jwkFromSecret :: ByteString -> JWK
hs256jwk key = jwkFromSecret key =
fromKeyMaterial km fromKeyMaterial km
& jwkUse .~ Just Sig & jwkUse ?~ Sig
& jwkAlg .~ (Just $ JWSAlg HS256) & jwkAlg ?~ JWSAlg HS256
where where
km = OctKeyMaterial (OctKeyParameters (JOSE.Types.Base64Octets key)) km = OctKeyMaterial (OctKeyParameters (JOSE.Types.Base64Octets key))
+147 -84
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
, minimumPgVersion
, pgVersion96
, AppConfig (..) , AppConfig (..)
, configPoolTimeout'
) )
where where
import PostgREST.Types (PgVersion(..))
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)
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 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,33 @@ 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)]
, configRoleClaimKey :: Either ApiRequestError JSPath
, 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", "DELETE", "OPTIONS"] ["Authorization"] Nothing ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] ["Authorization"] Nothing
(Just $ 60*60*24) False False True (Just $ 60*60*24) False False True
-- | CORS policy to be used in by Wai Cors middleware -- | CORS policy to be used in by Wai Cors middleware
@@ -109,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
@@ -117,52 +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 "*4" . mfilter (/= "") <$> C.key "server-host")
<*> (fromMaybe 3000 . join . fmap coerceInt <$> C.key "server-port")
<*> (fmap encodeUtf8 . mfilter (/= "") <$> C.key "jwt-secret")
<*> (fromMaybe False . join . fmap coerceBool <$> C.key "secret-is-base64")
<*> parseJwtAudience "jwt-aud"
<*> (fromMaybe 10 . join . fmap coerceInt <$> C.key "db-pool")
<*> (join . fmap coerceInt <$> C.key "max-rows")
<*> (mfilter (/= "") <$> C.key "pre-request")
<*> pure False
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'
coerceInt :: (Read i, Integral i) => Value -> Maybe i reqString :: C.Key -> C.Parser C.Config Text
coerceInt (Number x) = rightToMaybe $ floatingOrInteger x reqString k = C.required k C.string
coerceInt (String x) = readMaybe $ toS x
coerceInt _ = Nothing
coerceBool :: Value -> Maybe Bool optString :: C.Key -> C.Parser C.Config (Maybe Text)
coerceBool (Bool b) = Just b optString k = mfilter (/= "") <$> C.optional k C.string
coerceBool (String x) = readMaybe $ toS x
coerceBool _ = Nothing optValue :: C.Key -> C.Parser C.Config (Maybe C.Value)
optValue k = C.optional k C.value
optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)
optInt k = join <$> C.optional k (coerceInt <$> C.value)
optBool :: C.Key -> C.Parser C.Config (Maybe Bool)
optBool k = join <$> C.optional k (coerceBool <$> C.value)
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
@@ -178,26 +231,30 @@ 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
exampleCfg = vsep . map (text . toS) . lines $ exampleCfg = vsep . map (text . toS) . lines $
[str|db-uri = "postgres://user:pass@localhost:5432/dbname" [str|db-uri = "postgres://user:pass@localhost:5432/dbname"
|db-schema = "public" |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 = "*4" |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 = ""
| |
|## choose a secret to enable JWT auth |## choose a secret, JSON Web Key (or set) to enable JWT auth
|## (use "@filename" to load from separate file) |## (use "@filename" to load from separate file)
|# jwt-secret = "foo" |# jwt-secret = "foo"
|# secret-is-base64 = false |# secret-is-base64 = false
@@ -208,6 +265,19 @@ readOptions = do
| |
|## stored proc to exec immediately after auth |## stored proc to exec immediately after auth
|# pre-request = "stored_proc_name" |# pre-request = "stored_proc_name"
|
|## jspath to the role claim key
|# role-claim-key = ".role"
|
|## extra schemas to add to the search_path of every request
|# db-extra-search-path = "extensions, util"
|
|## stored proc that overrides the root "/" spec
|## it must be inside the db-schema
|# root-spec = "stored_proc_name"
|
|## content types to produce raw output
|# raw-media-types="image/png, image/jpg"
|] |]
pathParser :: Parser FilePath pathParser :: Parser FilePath
@@ -215,10 +285,3 @@ pathParser =
strArgument $ strArgument $
metavar "FILENAME" <> metavar "FILENAME" <>
help "Path to configuration file" help "Path to configuration file"
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
minimumPgVersion :: PgVersion
minimumPgVersion = PgVersion 90300 "9.3"
pgVersion96 :: PgVersion
pgVersion96 = PgVersion 90600 "9.6"
+227 -213
View File
@@ -1,74 +1,87 @@
{-# LANGUAGE FlexibleContexts #-} {-|
{-# LANGUAGE DuplicateRecordFields #-} Module : PostgREST.DbRequestBuilder
Description : PostgREST database request builder
This module is in charge of building an intermediate representation(ReadRequest, MutateRequest) between the HTTP request and the final resulting SQL query.
A query tree is built in case of resource embedding. By inferring the relationship between tables, join conditions are added for every embedded resource.
-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
module PostgREST.DbRequestBuilder ( module PostgREST.DbRequestBuilder (
readRequest readRequest
, mutateRequest , mutateRequest
, readRpcRequest
, 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 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) import Protolude hiding (from)
import PostgREST.Types
import Protolude hiding (from, dropWhile, drop) readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest
import Text.Regex.TDFA ((=~)) readRequest maxRows allRels proc apiRequest =
import Unsafe (unsafeHead) mapLeft errorResponseFor $
readRequest :: Maybe Integer -> [Relation] -> M.HashMap Text ProcDescription -> ApiRequest -> Either Response ReadRequest
readRequest maxRows allRels allProcs apiRequest =
mapLeft apiRequestError $
treeRestrictRange maxRows =<< treeRestrictRange maxRows =<<
augumentRequestWithJoin schema relations =<< augumentRequestWithJoin schema relations =<<
parseReadRequest addFiltersOrdersRanges apiRequest <*>
(buildReadRequest <$> pRequestSelect (iSelect apiRequest))
where where
action = iAction apiRequest
(schema, rootTableName) = fromJust $ -- Make it safe (schema, rootTableName) = fromJust $ -- Make it safe
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 proc) ) -> Just (s, tName) (TargetProc (QualifiedIdentifier s pName) _ ) -> Just (s, tName)
where where
retType = pdReturnType <$> M.lookup proc allProcs tName = case pdReturnType <$> proc of
tName = case retType 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
_ -> proc _ -> pName
_ -> Nothing _ -> Nothing
action :: Action -- Build tree with a Depth attribute so when a self join occurs we can differentiate the parent and child tables by having
action = iAction apiRequest -- an alias like "table_depth", this is related to issue #987.
buildReadRequest :: [Tree SelectItem] -> ReadRequest
parseReadRequest :: Either ApiRequestError ReadRequest buildReadRequest fieldTree =
parseReadRequest = addFiltersOrdersRanges apiRequest <*> let rootDepth = 0
pRequestSelect rootName selStr rootNodeName = if action == ActionRead then rootTableName else sourceCTEName in
foldr (treeEntry rootDepth) (Node (Select [] rootNodeName Nothing [] [] [] [] allRange, (rootNodeName, Nothing, Nothing, Nothing, rootDepth)) []) fieldTree
where where
selStr = iSelect apiRequest treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
rootName = if action == ActionRead treeEntry depth (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) =
then rootTableName let nxtDepth = succ depth in
else sourceCTEName case fldForest of
[] -> Node (q {select=fld:select q}, i) rForest
_ -> Node (q, i) $
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
@@ -76,8 +89,20 @@ readRequest maxRows allRels allProcs 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 -- see comment in toSourceRelation where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels
-- in a relation where one of the tables matches "TableName"
-- replace the name to that table with pg_source
-- this "fake" relations is needed so that in a mutate query
-- we can look at the "returning *" part which is wrapped with a "with"
-- as just another table that has relations with other tables
toSourceRelation :: TableName -> Relation -> Maybe Relation
toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
| mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}
| Just mt == (tableName <$> rt) = Just $ r {relLinkTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
| otherwise = Nothing
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request
@@ -88,136 +113,149 @@ 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
>>= addJoinFilters 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 readNode@(query, (name, _, alias, relationDetail)) 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}, _) _) ->
Node <$> readNode' <*> forest' let newFrom r = if tbl == nodeName then tableName (relTable r) else tbl
where newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
forest' = updateForest $ hush node' rel :: Either ApiRequestError Relation
node' = Node <$> readNode' <*> pure forest rel = note (NoRelationBetween parentNodeTable nodeName) $
readNode' = addRel readNode <$> rel findRelation schema allRelations nodeName parentNodeTable relationDetail in
rel :: Either ApiRequestError Relation Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
rel = note (NoRelationBetween parentNodeTable name) _ ->
$ findRelation schema name parentNodeTable relationDetail let rn = (query, (nodeName, Just r, alias, Nothing, depth))
where r = Relation t [] t [] Root Nothing Nothing Nothing
t = Table schema nodeName Nothing True in -- !!! TODO find another way to get the table from the query
findRelation s nodeTableName parentNodeTableName Nothing = Node rn <$> updateForest (Just $ Node rn forest)
find (\r ->
s == tableSchema (relTable r) && -- match schema for relation table
s == tableSchema (relFTable r) && -- match schema for relation foriegn table
(
-- (request) => projects { ..., clients{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
nodeTableName == tableName (relTable r) && -- match relation table name
parentNodeTableName == tableName (relFTable r) -- match relation foreign table name
) ||
-- (request) => projects { ..., client_id{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
parentNodeTableName == tableName (relFTable r) &&
length (relFColumns r) == 1 &&
nodeTableName `colMatches` (colName . unsafeHead . relFColumns) r
)
-- (request) => project_id { ..., client_id{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
-- this case works becasue before reaching this place
-- addRelation will turn project_id to project so the above condition will match
)
) allRelations
findRelation s nodeTableName parentNodeTableName (Just rd) =
find (\r ->
s == tableSchema (relTable r) && -- match schema for relation table
s == tableSchema (relFTable r) && -- match schema for relation foriegn table
(
-- (request) => clients { ..., project.client_id{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
nodeTableName == tableName (relTable r) && -- match relation table name
parentNodeTableName == tableName (relFTable r) && -- && -- match relation foreign table name
length (relColumns r) == 1 &&
rd == (colName . unsafeHead . relColumns) r
)
||
-- (request) => tasks { ..., users.tasks_users{...} }
-- will match
-- (relation type) => many
-- (entity) => users
-- (foriegn entity) => tasks
(
relType r == Many &&
nodeTableName == tableName (relTable r) && -- match relation table name
parentNodeTableName == tableName (relFTable r) && -- match relation foreign table name
rd == tableName (fromJust (relLTable r))
)
)
) allRelations
n `colMatches` rc = (toS ("^" <> rc <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (toS n :: BS.ByteString)
addRel :: (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail)) -> Relation -> (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail))
addRel (query', (n, _, a, _)) r = (query' {from=fromRelation}, (n, Just r, a, Nothing))
where fromRelation = map (\t -> if t == n then tableName (relTable r) else t) (from query')
_ -> n' <$> updateForest (Just (n' forest))
where
n' = Node (query, (name, Just r, alias, Nothing))
t = Table schema name Nothing True -- !!! TODO find another way to get the table from the query
r = Relation t [] t [] Root Nothing Nothing Nothing
where where
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest] updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
updateForest n = mapM (addRelations schema allRelations n) forest updateForest rq = mapM (addRelations schema allRelations rq) forest
addJoinFilters :: Schema -> ReadRequest -> Either ApiRequestError ReadRequest findRelation :: Schema -> [Relation] -> NodeName -> TableName -> Maybe RelationDetail -> Maybe Relation
addJoinFilters schema (Node node@(query, nodeProps@(_, relation, _, _)) forest) = findRelation schema allRelations nodeTableName parentNodeTableName relationDetail =
find (\Relation{relTable, relColumns, relFTable, relFColumns, relType, relLinkTable} ->
-- Both relation ends need to be on the exposed schema
schema == tableSchema relTable && schema == tableSchema relFTable &&
case relationDetail of
Nothing ->
-- (request) => projects { ..., clients{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
nodeTableName == tableName relTable && -- match relation table name
parentNodeTableName == tableName relFTable -- match relation foreign table name
) ||
-- (request) => projects { ..., client_id{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
parentNodeTableName == tableName relFTable &&
length relFColumns == 1 &&
-- match common foreign key names(table_name_id, table_name_fk) to table_name
(toS ("^" <> colName (unsafeHead relFColumns) <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (toS nodeTableName :: BS.ByteString)
)
-- (request) => project_id { ..., client_id{...} }
-- will match
-- (relation type) => parent
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
-- this case works becasue before reaching this place
-- addRelation will turn project_id to project so the above condition will match
Just rd ->
-- (request) => clients { ..., projects.client_id{...} }
-- will match
-- (relation type) => child
-- (entity) => clients {id}
-- (foriegn entity) => projects {client_id}
(
relType == Child &&
nodeTableName == tableName relTable && -- match relation table name
parentNodeTableName == tableName relFTable && -- match relation foreign table name
length relColumns == 1 &&
rd == colName (unsafeHead relColumns)
) ||
-- (request) => message { ..., person_detail.sender{...} }
-- will match
-- (relation type) => parent
-- (entity) => message {sender}
-- (foriegn entity) => person_detail {id}
(
relType == Parent &&
nodeTableName == tableName relTable && -- match relation table name
parentNodeTableName == tableName relFTable && -- match relation foreign table name
length relFColumns == 1 &&
rd == colName (unsafeHead relFColumns)
) ||
-- (request) => tasks { ..., users.tasks_users{...} }
-- will match
-- (relation type) => many
-- (entity) => users
-- (foriegn entity) => tasks
(
relType == Many &&
nodeTableName == tableName relTable && -- match relation table name
parentNodeTableName == tableName relFTable && -- match relation foreign table name
rd == tableName (fromJust relLinkTable)
)
) allRelations
-- previousAlias is only used for the case of self joins
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, relLTable=(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 (addJoinFilters schema) forest newAlias = case isSelfJoin <$> relation of
augmentQuery rel = foldr addFilterToReadQuery query (getJoinFilters rel) Just True
addFilterToReadQuery flt rq@Select{where_=lf} = rq{where_=addFilterToLogicForest flt lf}::ReadQuery | depth /= 0 -> Just (tbl <> "_" <> show depth) -- root node doesn't get aliased
| 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
getJoinFilters :: Relation -> [Filter] -- previousAlias and newAlias are used in the case of self joins
getJoinFilters (Relation t cols ft fcs typ lt lc1 lc2) = getJoinConditions :: Maybe Alias -> Maybe Alias -> Relation -> [JoinCondition]
getJoinConditions previousAlias newAlias (Relation Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols typ lt lc1 lc2) =
case typ of case typ of
Child -> zipWith (toFilter tN ftN) cols fcs Child ->
Parent -> zipWith (toFilter tN ftN) cols fcs zipWith (toJoinCondition tN ftN) cols fCols
Many -> zipWith (toFilter tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toFilter ftN ltN) fcs (fromMaybe [] lc2) Parent ->
Root -> undefined --error "undefined getJoinFilters" zipWith (toJoinCondition tN ftN) cols fCols
Many ->
let ltN = maybe "" tableName lt in
zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fCols (fromMaybe [] lc2)
Root -> witness
where where
s = if typ == Parent then "" else tableSchema t toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
tN = tableName t toJoinCondition tb ftb c fc =
ftN = tableName ft let qi1 = QualifiedIdentifier tSchema tb
ltN = fromMaybe "" (tableName <$> lt) qi2 = QualifiedIdentifier tSchema ftb in
toFilter :: Text -> Text -> Column -> Column -> Filter JoinCondition (maybe qi1 (QualifiedIdentifier mempty) newAlias, colName c)
toFilter tb ftb c fc = Filter (colName c, Nothing) (OpExpr False (Join (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}}))) (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 (.)) [
@@ -254,7 +292,7 @@ addFilter :: (EmbedPath, Filter) -> ReadRequest -> ReadRequest
addFilter = addProperty addFilterToNode addFilter = addProperty addFilterToNode
addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest
addOrderToNode o (Node (q,i) f) = Node (q{order=Just o}, i) f addOrderToNode o (Node (q,i) f) = Node (q{order=o}, i) f
addOrder :: (EmbedPath, [OrderTerm]) -> ReadRequest -> ReadRequest addOrder :: (EmbedPath, [OrderTerm]) -> ReadRequest -> ReadRequest
addOrder = addProperty addOrderToNode addOrder = addProperty addOrderToNode
@@ -272,53 +310,34 @@ addLogicTree :: (EmbedPath, LogicTree) -> ReadRequest -> ReadRequest
addLogicTree = addProperty addLogicTreeToNode addLogicTree = addProperty addLogicTreeToNode
addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest
addProperty f ([], a) n = f a n addProperty f ([], a) rr = f a rr
addProperty f (path, a) (Node rn forest) = addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
case targetNode of case pathNode of
Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path
Just tn -> Node rn (addProperty f (remainingPath, a) tn:restForest) Just tn -> Node rn (addProperty f (remainingPath, a) tn:delete tn forest)
where where
targetNodeName:remainingPath = path pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
(targetNode,restForest) = splitForest targetNodeName forest
splitForest :: NodeName -> Forest ReadNode -> (Maybe ReadRequest, Forest ReadNode)
splitForest name forst =
case maybeNode of
Nothing -> (Nothing,forest)
Just node -> (Just node, delete node forest)
where
maybeNode :: Maybe ReadRequest
maybeNode = find fnd forst
where
fnd :: ReadRequest -> Bool
fnd (Node (_,(n,_,_,_)) _) = n == name
-- in a relation where one of the tables mathces "TableName" mutateRequest :: ApiRequest -> TableName -> S.Set FieldName -> [FieldName] -> [FieldName] -> Either Response MutateRequest
-- replace the name to that table with pg_source mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
-- this "fake" relations is needed so that in a mutate query
-- we can look a the "returning *" part which is wrapped with a "with"
-- as just another table that has relations with other tables
toSourceRelation :: TableName -> Relation -> Maybe Relation
toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
| mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}
| Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
| otherwise = Nothing
mutateRequest :: ApiRequest -> [FieldName] -> Either Response MutateRequest
mutateRequest apiRequest fldNames = mapLeft apiRequestError $
case action of case action of
ActionCreate -> Right $ Insert rootTableName payload returnings ActionCreate -> Right $ Insert tName cols ((,) <$> iPreferResolution apiRequest <*> Just pkCols) [] returnings
ActionUpdate -> Update rootTableName <$> pure payload <*> combinedLogic <*> pure returnings ActionUpdate -> Update tName cols <$> combinedLogic <*> pure returnings
ActionDelete -> Delete rootTableName <$> combinedLogic <*> pure returnings ActionSingleUpsert ->
_ -> Left UnsupportedVerb (\flts ->
if null (iLogic apiRequest) &&
S.fromList (fst <$> iFilters apiRequest) == S.fromList pkCols &&
not (null (S.fromList pkCols)) &&
all (\case
Filter _ (OpExpr False (Op "eq" _)) -> True
_ -> False) flts
then Insert tName cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
else
Left InvalidFilters) =<< filters
ActionDelete -> Delete tName <$> combinedLogic <*> pure returnings
_ -> Left UnsupportedVerb
where where
action = iAction apiRequest action = iAction apiRequest
payload = fromJust $ iPayload apiRequest
rootTableName = -- TODO: Make it safe
let target = iTarget apiRequest in
case target of
(TargetIdent (QualifiedIdentifier _ t) ) -> t
_ -> undefined
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
@@ -327,17 +346,12 @@ mutateRequest apiRequest fldNames = mapLeft apiRequestError $
(mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest) (mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst) onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
readRpcRequest :: ApiRequest -> Either Response [RpcQParam]
readRpcRequest apiRequest = mapLeft apiRequestError rpcQParams
where
rpcQParams = mapM pRequestRpcQParam $ iRpcQParams apiRequest
fieldNames :: ReadRequest -> [FieldName] fieldNames :: ReadRequest -> [FieldName]
fieldNames (Node (sel, _) forest) = fieldNames (Node (sel, _) forest) =
map (fst . view _1) (select sel) ++ map colName fks map (fst . view _1) (select sel) ++ map colName fks
where where
fks = concatMap (fromMaybe [] . f) forest fks = concatMap (fromMaybe [] . f) forest
f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _, _)) _) = Just cols f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _, _, _)) _) = Just cols
f _ = Nothing f _ = Nothing
-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree -- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
+265 -208
View File
@@ -1,5 +1,16 @@
{-|
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 #-}
@@ -11,42 +22,57 @@ 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.Query 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 Data.List (elemIndex) splitOn, strip)
import Data.Maybe (fromJust) import GHC.Exts (groupWith)
import Data.Text (split, strip, import Text.InterpolatedString.Perl6 (q, qc)
breakOn, dropAround, splitOn) import Unsafe (unsafeHead)
import qualified Data.Text as T
import qualified Hasql.Session as H
import PostgREST.Types
import Text.InterpolatedString.Perl6 (q)
import GHC.Exts (groupWith) import Control.Applicative
import Protolude
import Unsafe (unsafeHead)
getDbStructure :: Schema -> PgVersion -> H.Session DbStructure 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 pgVer = do getDbStructure schema pgVer = do
tabs <- H.query () allTables HT.sql "set local schema ''" -- for getting the fully qualified name(schema.name) of every db object
cols <- H.query schema $ allColumns tabs tabs <- HT.statement () allTables
syns <- H.query () $ allSynonyms cols cols <- HT.statement schema $ allColumns tabs
rels <- H.query () $ allRelations tabs cols syns <- HT.statement schema $ allSynonyms cols pgVer
keys <- H.query () $ allPrimaryKeys tabs childRels <- HT.statement () $ allChildRelations tabs cols
procs <- H.query schema allProcs keys <- HT.statement () $ allPrimaryKeys tabs
procs <- HT.statement schema allProcs
let rels' = (addManyToManyRelations . raiseRelations schema syns . addParentRelations . addSynonymousRelations syns) rels let rels = addManyToManyRelations . addParentRelations $ addViewChildRelations syns childRels
cols' = addForeignKeys rels' cols cols' = addForeignKeys rels cols
keys' = synonymousPrimaryKeys syns keys keys' = addViewPrimaryKeys syns keys
return DbStructure { return DbStructure {
dbTables = tabs dbTables = tabs
, dbColumns = cols' , dbColumns = cols'
, dbRelations = rels' , dbRelations = rels
, dbPrimaryKeys = keys' , dbPrimaryKeys = keys'
, dbProcs = procs , dbProcs = procs
, pgVersion = pgVer , pgVersion = pgVer
@@ -54,69 +80,70 @@ getDbStructure schema pgVer = do
decodeTables :: HD.Result [Table] decodeTables :: HD.Result [Table]
decodeTables = decodeTables =
HD.rowsList tblRow HD.rowList tblRow
where where
tblRow = Table <$> HD.value HD.text tblRow = Table <$> column HD.text
<*> HD.value HD.text <*> column HD.text
<*> HD.nullableValue HD.text <*> nullableColumn HD.text
<*> HD.value HD.bool <*> column HD.bool
decodeColumns :: [Table] -> HD.Result [Column] decodeColumns :: [Table] -> HD.Result [Column]
decodeColumns tables = decodeColumns tables =
mapMaybe (columnFromRow tables) <$> HD.rowsList colRow mapMaybe (columnFromRow tables) <$> HD.rowList colRow
where where
colRow = colRow =
(,,,,,,,,,,,) (,,,,,,,,,,,)
<$> HD.value HD.text <*> HD.value HD.text <$> column HD.text <*> column HD.text
<*> HD.value HD.text <*> HD.nullableValue HD.text <*> column HD.text <*> nullableColumn HD.text
<*> HD.value HD.int4 <*> HD.value HD.bool <*> column HD.int4 <*> column HD.bool
<*> HD.value HD.text <*> HD.value HD.bool <*> column HD.text <*> column HD.bool
<*> HD.nullableValue HD.int4 <*> nullableColumn HD.int4
<*> HD.nullableValue HD.int4 <*> nullableColumn HD.int4
<*> HD.nullableValue HD.text <*> nullableColumn HD.text
<*> HD.nullableValue 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.rowsList relRow mapMaybe (relationFromRow tables cols) <$> HD.rowList relRow
where where
relRow = (,,,,,) relRow = (,,,,,)
<$> HD.value HD.text <$> column HD.text
<*> HD.value HD.text <*> column HD.text
<*> HD.value (HD.array (HD.arrayDimension replicateM (HD.arrayValue HD.text))) <*> column (HD.array (HD.dimension replicateM (element HD.text)))
<*> HD.value HD.text <*> column HD.text
<*> HD.value HD.text <*> column HD.text
<*> HD.value (HD.array (HD.arrayDimension replicateM (HD.arrayValue 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.rowsList pkRow mapMaybe (pkFromRow tables) <$> HD.rowList pkRow
where where
pkRow = (,,) <$> HD.value HD.text <*> HD.value HD.text <*> HD.value HD.text pkRow = (,,) <$> column HD.text <*> column HD.text <*> column HD.text
decodeSynonyms :: [Column] -> HD.Result [(Column,Column)] decodeSynonyms :: [Column] -> HD.Result [Synonym]
decodeSynonyms cols = decodeSynonyms cols =
mapMaybe (synonymFromRow cols) <$> HD.rowsList synRow mapMaybe (synonymFromRow cols) <$> HD.rowList synRow
where where
synRow = (,,,,,) synRow = (,,,,,)
<$> HD.value HD.text <*> HD.value HD.text <$> column HD.text <*> column HD.text
<*> HD.value HD.text <*> HD.value HD.text <*> column HD.text <*> column HD.text
<*> HD.value HD.text <*> HD.value HD.text <*> column HD.text <*> column HD.text
decodeProcs :: HD.Result (M.HashMap Text ProcDescription) decodeProcs :: HD.Result (M.HashMap Text [ProcDescription])
decodeProcs = decodeProcs =
M.fromList . map addName <$> HD.rowsList tblRow -- Duplicate rows for a function means they're overloaded, order these by least args according to ProcDescription Ord instance
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addName) <$> HD.rowList tblRow
where where
tblRow = ProcDescription tblRow = ProcDescription
<$> HD.value HD.text <$> column HD.text
<*> HD.nullableValue HD.text <*> nullableColumn HD.text
<*> (parseArgs <$> HD.value HD.text) <*> (parseArgs <$> column HD.text)
<*> (parseRetType <*> (parseRetType
<$> HD.value HD.text <$> column HD.text
<*> HD.value HD.text <*> column HD.text
<*> HD.value HD.bool <*> column HD.bool
<*> HD.value HD.char) <*> column HD.char)
<*> (parseVolatility <$> HD.value HD.char) <*> (parseVolatility <$> column HD.char)
addName :: ProcDescription -> (Text, ProcDescription) addName :: ProcDescription -> (Text, ProcDescription)
addName pd = (pdName pd, pd) addName pd = (pdName pd, pd)
@@ -152,11 +179,11 @@ decodeProcs =
| v == 's' = Stable | v == 's' = Stable
| otherwise = Volatile -- only 'v' can happen here | otherwise = Volatile -- only 'v' can happen here
allProcs :: H.Query Schema (M.HashMap Text ProcDescription) allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
allProcs = H.statement (toS procsSqlQuery) (HE.value HE.text) decodeProcs True allProcs = H.Statement (toS procsSqlQuery) (param HE.text) decodeProcs True
accessibleProcs :: H.Query Schema (M.HashMap Text ProcDescription) accessibleProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
accessibleProcs = H.statement (toS sql) (HE.value 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')"
@@ -179,9 +206,9 @@ procsSqlQuery = [q|
WHERE pn.nspname = $1 WHERE pn.nspname = $1
|] |]
schemaDescription :: H.Query Schema (Maybe Text) schemaDescription :: H.Statement Schema (Maybe Text)
schemaDescription = schemaDescription =
H.statement sql (HE.value HE.text) (join <$> HD.maybeRow (HD.nullableValue HD.text)) True H.Statement sql (param HE.text) (join <$> HD.rowMaybe (nullableColumn HD.text)) True
where where
sql = [q| sql = [q|
select select
@@ -192,9 +219,9 @@ schemaDescription =
where where
n.nspname = $1 |] n.nspname = $1 |]
accessibleTables :: H.Query Schema [Table] accessibleTables :: H.Statement Schema [Table]
accessibleTables = accessibleTables =
H.statement sql (HE.value HE.text) decodeTables True H.Statement sql (param HE.text) decodeTables True
where where
sql = [q| sql = [q|
select select
@@ -212,7 +239,7 @@ accessibleTables =
join pg_namespace n on n.oid = c.relnamespace join pg_namespace n on n.oid = c.relnamespace
left join pg_catalog.pg_description as d on d.objoid = c.oid and d.objsubid = 0 left join pg_catalog.pg_description as d on d.objoid = c.oid and d.objsubid = 0
where where
c.relkind in ('v', 'r', 'm') c.relkind in ('v', 'r', 'm', 'f')
and n.nspname = $1 and n.nspname = $1
and ( and (
pg_has_role(c.relowner, 'USAGE'::text) pg_has_role(c.relowner, 'USAGE'::text)
@@ -221,55 +248,105 @@ accessibleTables =
) )
order by relname |] order by relname |]
synonymousColumns :: [(Column,Column)] -> [Column] -> [[Column]]
synonymousColumns allSyns cols = synCols'
where
syns = case headMay cols of
Just firstCol -> sort $ filter ((== colTable firstCol) . colTable . fst) allSyns
Nothing -> []
synCols  = transpose $ map (\c -> map snd $ filter ((== c) . fst) syns) cols
synCols' = (filter sameTable . filter matchLength) synCols
matchLength cs = length cols == length cs
sameTable (c:cs) = all (\cc -> colTable c == colTable cc) (c:cs)
sameTable [] = False
addForeignKeys :: [Relation] -> [Column] -> [Column] 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
pos <- elemIndex col cols pos <- L.elemIndex col cols
colF <- atMay colsF pos colF <- atMay colsF pos
return $ ForeignKey colF return $ ForeignKey colF
addSynonymousRelations :: [(Column,Column)] -> [Relation] -> [Relation] {-
addSynonymousRelations _ [] = [] Adds Views Child Relations based on Synonyms found, the logic is as follows:
addSynonymousRelations syns (rel:rels) = rel : synRelsP ++ synRelsF ++ addSynonymousRelations syns rels
where Having a Relation{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relType=Child} represented by:
synRelsP = synRels (relColumns rel) (\t cs -> rel{relTable=t,relColumns=cs})
synRelsF = synRels (relFColumns rel) (\t cs -> rel{relFTable=t,relFColumns=cs}) t1.c1------t2.c2
synRels cols mapFn = map (\cs -> mapFn (colTable $ unsafeHead cs) cs) $ synonymousColumns syns cols
When only having a t1_view.c1 synonym, we need to add a View to Table Child Relation
t1.c1----t2.c2 t1.c1----------t2.c2
-> ________/
/
t1_view.c1 t1_view.c1
When only having a t2_view.c2 synonym, we need to add a Table to View Child Relation
t1.c1----t2.c2 t1.c1----------t2.c2
-> \________
\
t2_view.c2 t2_view.c1
When having t1_view.c1 and a t2_view.c2 synonyms, we need to add a View to View Child Relation in addition to the prior
t1.c1----t2.c2 t1.c1----------t2.c2
-> \________/
/ \
t1_view.c1 t2_view.c2 t1_view.c1-------t2_view.c1
The logic for composite pks is similar just need to make sure all the Relation columns have synonyms.
-}
addViewChildRelations :: [Synonym] -> [Relation] -> [Relation]
addViewChildRelations allSyns = concatMap (\rel ->
rel : case rel of
Relation{relType=Child, relTable, relColumns, relFTable, relFColumns} ->
let colSynsGroupedByView :: [Column] -> [[Synonym]]
colSynsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $
filter (\(c, _) -> c `elem` relCols) allSyns
colsSyns = colSynsGroupedByView relColumns
fColsSyns = colSynsGroupedByView relFColumns
getView :: [Synonym] -> Table
getView = colTable . snd . unsafeHead
syns `allSynsOf` cols = S.fromList (fst <$> syns) == S.fromList cols
-- Relation is dependent on the order of relColumns and relFColumns to get the join conditions right in the generated query.
-- So we need to change the order of the 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
syns `sortAccordingTo` columns = sortOn (\(k, _) -> L.lookup k $ zip columns [0::Int ..]) syns
viewTableChild =
[ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns)
relFTable relFColumns
Child Nothing Nothing Nothing
| syns <- colsSyns, syns `allSynsOf` relColumns ]
tableViewChild =
[ Relation relTable relColumns
(getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns)
Child Nothing Nothing Nothing
| fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ]
viewViewChild =
[ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns)
(getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns)
Child Nothing Nothing Nothing
| syns <- colsSyns, syns `allSynsOf` relColumns
, fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ]
in viewTableChild ++ tableViewChild ++ viewViewChild
_ -> [])
addParentRelations :: [Relation] -> [Relation] addParentRelations :: [Relation] -> [Relation]
addParentRelations [] = [] addParentRelations = concatMap (\rel@(Relation t c ft fc _ _ _ _) -> [rel, Relation ft fc t c Parent Nothing Nothing Nothing])
addParentRelations (rel@(Relation t c ft fc _ _ _ _):rels) = Relation ft fc t c Parent Nothing Nothing Nothing : rel : addParentRelations rels
addManyToManyRelations :: [Relation] -> [Relation] addManyToManyRelations :: [Relation] -> [Relation]
addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation links) addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation links)
where where
links = join $ map (combinations 2) $ filter (not . null) $ groupWith groupFn $ filter ( (==Child). relType) rels links = join $ map (combinations 2) $ filter (not . null) $ groupWith groupFn $ filter ( (==Child). relType) rels
groupFn :: Relation -> Text groupFn :: Relation -> Text
groupFn Relation{relTable=Table{tableSchema=s, tableName=t}} = s<>"_"<>t groupFn Relation{relTable=Table{tableSchema=s, tableName=t}} = s <> "_" <> t
-- Reference : https://wiki.haskell.org/99_questions/Solutions/26 -- Reference : https://wiki.haskell.org/99_questions/Solutions/26
combinations :: Int -> [a] -> [[a]] combinations :: Int -> [a] -> [[a]]
combinations 0 _ = [ [] ] combinations 0 _ = [ [] ]
combinations n xs = [ y:ys | y:xs' <- tails xs combinations n xs = [ y:ys | y:xs' <- tails xs
, ys <- combinations (n-1) xs'] , ys <- combinations (n-1) xs']
addMirrorRelation [] = [] addMirrorRelation = concatMap (\rel@(Relation t c ft fc _ lt lc1 lc2) -> [rel, Relation ft fc t c Many lt lc2 lc1])
addMirrorRelation (rel@(Relation t c ft fc _ lt lc1 lc2):rels') = Relation ft fc t c Many lt lc2 lc1 : rel : addMirrorRelation rels'
link2Relation [ link2Relation [
Relation{relTable=lt, relColumns=lc1, relFTable=t, relFColumns=c}, Relation{relTable=lt, relColumns=lc1, relFTable=t, relFColumns=c},
Relation{ relColumns=lc2, relFTable=ft, relFColumns=fc} Relation{ relColumns=lc2, relFTable=ft, relFColumns=fc}
@@ -278,29 +355,15 @@ addManyToManyRelations rels = rels ++ addMirrorRelation (mapMaybe link2Relation
| otherwise = Nothing | otherwise = Nothing
link2Relation _ = Nothing link2Relation _ = Nothing
raiseRelations :: Schema -> [(Column,Column)] -> [Relation] -> [Relation] addViewPrimaryKeys :: [Synonym] -> [PrimaryKey] -> [PrimaryKey]
raiseRelations schema syns = map raiseRel addViewPrimaryKeys syns = concatMap (\pk ->
where let viewPks = (\(_, viewCol) -> PrimaryKey{pkTable=colTable viewCol, pkName=colName viewCol}) <$>
raiseRel rel filter (\(col, _) -> colTable col == pkTable pk && colName col == pkName pk) syns in
| tableSchema table == schema = rel pk : viewPks)
| isJust newCols = rel{relFTable=fromJust newTable,relFColumns=fromJust newCols}
| otherwise = rel
where
cols = relFColumns rel
table = relFTable rel
newCols = listToMaybe $ filter ((== schema) . tableSchema . colTable . unsafeHead) (synonymousColumns syns cols)
newTable = (colTable . unsafeHead) <$> newCols
synonymousPrimaryKeys :: [(Column,Column)] -> [PrimaryKey] -> [PrimaryKey] allTables :: H.Statement () [Table]
synonymousPrimaryKeys _ [] = []
synonymousPrimaryKeys syns (key:keys) = key : newKeys ++ synonymousPrimaryKeys syns keys
where
keySyns = filter ((\c -> colTable c == pkTable key && colName c == pkName key) . fst) syns
newKeys = map ((\c -> PrimaryKey{pkTable=colTable c,pkName=colName c}) . snd) keySyns
allTables :: H.Query () [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
@@ -316,14 +379,14 @@ allTables =
AND (pg_trigger.tgtype::integer & 69) = 69) ) AS insertable AND (pg_trigger.tgtype::integer & 69) = 69) ) AS insertable
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
WHERE c.relkind IN ('v','r','m') WHERE c.relkind IN ('v','r','m','f')
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
GROUP BY table_schema, table_name, insertable GROUP BY table_schema, table_name, insertable
ORDER BY table_schema, table_name |] ORDER BY table_schema, table_name |]
allColumns :: [Table] -> H.Query Schema [Column] allColumns :: [Table] -> H.Statement Schema [Column]
allColumns tabs = allColumns tabs =
H.statement sql (HE.value HE.text) (decodeColumns tabs) True H.Statement sql (param HE.text) (decodeColumns tabs) True
where where
sql = [q| sql = [q|
SELECT DISTINCT SELECT DISTINCT
@@ -358,7 +421,7 @@ allColumns tabs =
pg_catalog.pg_namespace n pg_catalog.pg_namespace n
WHERE WHERE
r.contype IN ('f', 'p') r.contype IN ('f', 'p')
AND c.relkind IN ('r', 'v', 'f', 'mv') AND c.relkind IN ('r', 'v', 'f', 'm')
AND r.conrelid = c.oid AND r.conrelid = c.oid
AND c.relnamespace = n.oid AND c.relnamespace = n.oid
AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1) AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1)
@@ -374,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
@@ -462,7 +525,7 @@ allColumns tabs =
NOT pg_is_other_temp_schema(nc.oid) NOT pg_is_other_temp_schema(nc.oid)
AND a.attnum > 0 AND a.attnum > 0
AND NOT a.attisdropped AND NOT a.attisdropped
AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char"])) AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char", 'm'::"char"]))
AND (nc.nspname = $1 OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */ AND (nc.nspname = $1 OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
/*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/ /*--AND (pg_has_role(c.relowner, 'USAGE'::text) OR has_column_privilege(c.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
) )
@@ -506,11 +569,11 @@ columnFromRow tabs (s, t, n, desc, pos, nul, typ, u, l, p, d, e) = buildColumn <
buildColumn tbl = Column tbl n desc pos nul typ u l p d (parseEnum e) Nothing buildColumn tbl = Column tbl n desc pos nul typ u l p d (parseEnum e) Nothing
table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
parseEnum :: Maybe Text -> [Text] parseEnum :: Maybe Text -> [Text]
parseEnum str = fromMaybe [] $ split (==',') <$> str parseEnum = maybe [] (split (==','))
allRelations :: [Table] -> [Column] -> H.Query () [Relation] allChildRelations :: [Table] -> [Column] -> H.Statement () [Relation]
allRelations 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,
@@ -549,9 +612,9 @@ relationFromRow allTabs allCols (rs, rt, rcs, frs, frt, frcs) =
cols = mapM (findCol rs rt) rcs cols = mapM (findCol rs rt) rcs
colsF = mapM (findCol frs frt) frcs colsF = mapM (findCol frs frt) frcs
allPrimaryKeys :: [Table] -> H.Query () [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|
/* /*
@@ -659,82 +722,76 @@ pkFromRow :: [Table] -> (Schema, Text, Text) -> Maybe PrimaryKey
pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
allSynonyms :: [Column] -> H.Query () [(Column,Column)] allSynonyms :: [Column] -> PgVersion -> H.Statement Schema [Synonym]
allSynonyms cols = allSynonyms cols pgVer =
H.statement sql HE.unit (decodeSynonyms cols) True H.Statement sql (param HE.text) (decodeSynonyms cols) True
where -- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569
-- query explanation at https://gist.github.com/ruslantalpa/2eab8c930a65e8043d8f where
sql = [q| subselectRegex :: Text
with view_columns as ( -- "result" appears when the subselect is used inside "case when", see `authors_have_book_in_decade` fixture
-- "resno" appears in every other case
-- when copying the query into pg make sure you omit one backslash from \\d+, it should be like `\d+` for the regex
subselectRegex | pgVer < pgVersion100 = ":subselect {.*?:constraintDeps <>} :location \\d+} :res(no|ult)"
| otherwise = ":subselect {.*?:stmt_len 0} :location \\d+} :res(no|ult)"
sql = [qc|
with
views as (
select select
c.oid as view_oid, n.nspname as view_schema,
a.attname::information_schema.sql_identifier as column_name c.relname as view_name,
from pg_attribute a r.ev_action as view_definition
join pg_class c on a.attrelid = c.oid from pg_class c
join pg_namespace nc on c.relnamespace = nc.oid join pg_namespace n on n.oid = c.relnamespace
where join pg_rewrite r on r.ev_class = c.oid
not pg_is_other_temp_schema(nc.oid) where (c.relkind in ('v', 'm')) and n.nspname = $1
and a.attnum > 0 ),
and not a.attisdropped removed_subselects as(
and (c.relkind = 'v'::"char")
and nc.nspname not in ('information_schema', 'pg_catalog')
),
view_column_usage as (
select distinct
v.oid as view_oid,
nv.nspname::information_schema.sql_identifier as view_schema,
v.relname::information_schema.sql_identifier as view_name,
nt.nspname::information_schema.sql_identifier as table_schema,
t.relname::information_schema.sql_identifier as table_name,
a.attname::information_schema.sql_identifier as column_name,
pg_get_viewdef(v.oid)::information_schema.character_data as view_definition
from pg_namespace nv
join pg_class v on nv.oid = v.relnamespace
join pg_depend dv on v.oid = dv.refobjid
join pg_depend dt on dv.objid = dt.objid
join pg_class t on dt.refobjid = t.oid
join pg_namespace nt on t.relnamespace = nt.oid
join pg_attribute a on t.oid = a.attrelid and dt.refobjsubid = a.attnum
where
nv.nspname not in ('information_schema', 'pg_catalog')
and v.relkind = 'v'::"char"
and dv.refclassid = 'pg_class'::regclass::oid
and dv.classid = 'pg_rewrite'::regclass::oid
and dv.deptype = 'i'::"char"
and dv.refobjid <> dt.refobjid
and dt.classid = 'pg_rewrite'::regclass::oid
and dt.refclassid = 'pg_class'::regclass::oid
and (t.relkind = any (array['r'::"char", 'v'::"char", 'f'::"char"]))
),
candidates as (
select select
vcu.*, view_schema, view_name,
( regexp_replace(view_definition, '{subselectRegex}', '', 'g') as x
select case when match is not null then coalesce(match[8], match[7], match[4]) end from views
from regexp_matches( ),
CONCAT('SELECT ', SPLIT_PART(vcu.view_definition, 'SELECT', 2)), target_lists as(
CONCAT('SELECT.*?((',vcu.table_name,')|(\w+))\.(', vcu.column_name, ')(\s+AS\s+("([^"]+)"|([^, \n\t]+)))?.*?FROM.*?(',vcu.table_schema,'\.|)(\2|',vcu.table_name,'\s+(as\s)?\3)'), select
'nsi' view_schema, view_name,
) match regexp_split_to_array(x, 'targetList') as x
) as view_column_name from removed_subselects
from view_column_usage as vcu ),
) last_target_list_wo_tail as(
select select
c.table_schema, view_schema, view_name,
c.table_name, (regexp_split_to_array(x[array_upper(x, 1)], ':onConflict'))[1] as x
c.column_name as table_column_name, from target_lists
c.view_schema, ),
c.view_name, target_entries as(
c.view_column_name select
from view_columns as vc, candidates as c view_schema, view_name,
where unnest(regexp_split_to_array(x, 'TARGETENTRY')) as entry
vc.view_oid = c.view_oid from last_target_list_wo_tail
and vc.column_name = c.view_column_name ),
order by c.view_schema, c.view_name, c.table_name, c.view_column_name results as(
|] select
view_schema, view_name,
substring(entry from ':resname (.*?) :') as view_colum_name,
substring(entry from ':resorigtbl (.*?) :') as resorigtbl,
substring(entry from ':resorigcol (.*?) :') as resorigcol
from target_entries
)
select
sch.nspname as table_schema,
tbl.relname as table_name,
col.attname as table_column_name,
res.view_schema,
res.view_name,
res.view_colum_name
from results res
join pg_class tbl on tbl.oid::text = res.resorigtbl
join pg_attribute col on col.attrelid = tbl.oid and col.attnum::text = res.resorigcol
join pg_namespace sch on sch.oid = tbl.relnamespace
where resorigtbl <> '0'
order by view_schema, view_name, view_colum_name; |]
synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe (Column,Column) synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe Synonym
synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2 synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
where where
col1 = findCol s1 t1 c1 col1 = findCol s1 t1 c1
@@ -742,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.query () $ 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.value HD.int4 <*> HD.value HD.text versionRow = HD.singleRow $ PgVersion <$> column HD.int4 <*> column HD.text
+205 -134
View File
@@ -1,96 +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
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 [
@@ -107,79 +81,176 @@ instance JSON.ToJSON ApiRequestError where
"message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)] "message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)]
toJSON UnsupportedVerb = JSON.object [ toJSON UnsupportedVerb = JSON.object [
"message" .= ("Unsupported HTTP verb" :: Text)] "message" .= ("Unsupported HTTP verb" :: Text)]
toJSON InvalidFilters = JSON.object [
"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
instance JSON.ToJSON H.Error where instance JSON.ToJSON H.QueryError where
toJSON (H.QueryError _ _ e) = JSON.toJSON e
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.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.ResultError _)) = HT.status500 "42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege
httpStatus _ (P.SessionError (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
+34 -35
View File
@@ -1,58 +1,57 @@
{-|
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 Data.Aeson (Value (..)) 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 (pgFmtLit, unquoted, pgFmtEnvVar)
import Protolude hiding (concat, null) 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 $ setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql ++ appSettingsSql
mapM_ H.sql customReqCheck mapM_ H.sql customReqCheck
app req app req
where where
headersSql = map (pgFmtEnvVar "request.header.") $ iHeaders req headersSql = pgFmtSetLocal "request.header." <$> iHeaders req
cookiesSql = map (pgFmtEnvVar "request.cookie.") $ iCookies req cookiesSql = pgFmtSetLocal "request.cookie." <$> iCookies req
claimsSql = map (pgFmtEnvVar "request.jwt.claim.") [(c,unquoted v) | (c,v) <- M.toList claimsWithRole] claimsSql = pgFmtSetLocal "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]
setRoleSql = maybeToList $ appSettingsSql = pgFmtSetLocal mempty <$> configSettings conf
(\r -> "set local role " <> r <> ";") . toS . pgFmtLit . unquoted <$> M.lookup "role" claimsWithRole setRoleSql = maybeToList $ (\x ->
pgFmtSetLocal mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
setSearchPathSql = pgFmtSetLocalSearchPath $ configSchema conf : configExtraSearchPath conf
-- role claim defaults to anon if not specified in jwt -- role claim defaults to anon if not specified in jwt
claimsWithRole = M.union claims (M.singleton "role" anon) claimsWithRole = M.union claims (M.singleton "role" anon)
anon = 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 =
+61 -38
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, _) =
@@ -42,7 +56,8 @@ makeTableDef pks (t, cs, _) =
(tn, (mempty :: Schema) (tn, (mempty :: Schema)
& description .~ tableDescription t & description .~ tableDescription t
& type_ .~ SwaggerObject & type_ .~ SwaggerObject
& properties .~ fromList (map (makeProperty pks) cs)) & properties .~ fromList (map (makeProperty pks) cs)
& required .~ map colName (filter (not . colNullable) cs))
makeProperty :: [PrimaryKey] -> Column -> (Text, Referenced Schema) makeProperty :: [PrimaryKey] -> Column -> (Text, Referenced Schema)
makeProperty pks c = (colName c, Inline s) makeProperty pks c = (colName c, Inline s)
@@ -59,7 +74,7 @@ makeProperty pks c = (colName c, Inline s)
] ]
d = d =
if length n > 1 then if length n > 1 then
Just $ append (fromMaybe "" ((`append` "\n\n") <$> colDescription c)) (intercalate "\n" n) Just $ append (maybe "" (`append` "\n\n") $ colDescription c) (intercalate "\n" n)
else else
colDescription c colDescription c
s = s =
@@ -197,10 +212,13 @@ makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
& at 206 ?~ "Partial Content" & at 206 ?~ "Partial Content"
& at 200 ?~ Inline ((mempty :: Response) & at 200 ?~ Inline ((mempty :: Response)
& description .~ "OK" & description .~ "OK"
& schema ?~ (Ref $ Reference $ tableName t) & schema ?~ Inline (mempty
& type_ .~ SwaggerArray
& items ?~ (SwaggerItemsObject $ Ref $ Reference $ tableName 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"])
@@ -219,8 +237,13 @@ makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
makeProcPathItem :: ProcDescription -> (FilePath, PathItem) makeProcPathItem :: ProcDescription -> (FilePath, PathItem)
makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe) makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe)
where where
-- Use first line of proc description as summary; rest as description (if present)
-- We strip leading newlines from description so that users can include a blank line between summary and description
(pSum, pDesc) = fmap fst &&& fmap (dropWhile (=='\n') . snd) $
breakOn "\n" <$> pdDescription pd
postOp = (mempty :: Operation) postOp = (mempty :: Operation)
& description .~ pdDescription pd & summary .~ pSum
& description .~ mfilter (/="") pDesc
& parameters .~ makeProcParam pd & parameters .~ makeProcParam pd
& tags .~ Set.fromList ["(rpc) " <> pdName pd] & tags .~ Set.fromList ["(rpc) " <> pdName pd]
& produces ?~ makeMimeList [CTApplicationJSON, CTSingularJSON] & produces ?~ makeMimeList [CTApplicationJSON, CTSingularJSON]
@@ -260,7 +283,7 @@ postgrestSpec pds ti (s, h, p, b) sd pks = (mempty :: Swagger)
& description ?~ d) & description ?~ d)
& externalDocs ?~ ((mempty :: ExternalDocs) & externalDocs ?~ ((mempty :: ExternalDocs)
& description ?~ "PostgREST Documentation" & description ?~ "PostgREST Documentation"
& url .~ URL ("https://postgrest.com/en/" <> docsVersion <> "/api.html")) & url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))
& host .~ h' & host .~ h'
& definitions .~ fromList (map (makeTableDef pks) ti) & definitions .~ fromList (map (makeTableDef pks) ti)
& parameters .~ fromList (makeParamDefs ti) & parameters .~ fromList (makeParamDefs ti)
@@ -310,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
@@ -318,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}
@@ -336,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}
@@ -345,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
+121 -89
View File
@@ -1,27 +1,40 @@
{-|
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 qualified Data.HashMap.Strict as M
import Data.Text (intercalate, replace, strip)
import Data.List (init, last)
import Data.Tree
import Data.Either.Combinators (mapLeft)
import PostgREST.RangeQuery (NonnegRange,allRange)
import PostgREST.Types
import Text.ParserCombinators.Parsec hiding (many, (<|>))
import Text.Parsec.Error
pRequestSelect :: Text -> Text -> Either ApiRequestError ReadRequest import Control.Monad ((>>))
pRequestSelect rootName selStr = import Data.Either.Combinators (mapLeft)
mapError $ parse (pReadRequest rootName) ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr) import Data.Foldable (foldl1)
import Data.Functor (($>))
import Data.List (init, last)
import Data.Text (intercalate, replace, strip)
import Text.Read (read)
import Data.Tree
import Text.Parsec.Error
import Text.ParserCombinators.Parsec hiding (many, (<|>))
import PostgREST.Error (ApiRequestError (ParseRequestError))
import PostgREST.RangeQuery (NonnegRange)
import PostgREST.Types
import Protolude hiding (intercalate, option, replace, try)
pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]
pRequestSelect selStr =
mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter) pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)
pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper) pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
where where
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
oper = parse (pOpExpr pSingleVal pListVal) ("failed to parse filter (" ++ toS v ++ ")") $ toS v oper = parse (pOpExpr pSingleVal) ("failed to parse filter (" ++ toS v ++ ")") $ toS v
path = fst <$> treePath path = fst <$> treePath
fld = snd <$> treePath fld = snd <$> treePath
@@ -43,15 +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)
pRequestRpcQParam :: (Text, Text) -> Either ApiRequestError RpcQParam pRequestColumns :: Maybe Text -> Either ApiRequestError (Maybe (S.Set FieldName))
pRequestRpcQParam (k, v) = mapError $ (,) <$> name <*> val pRequestColumns colStr =
where case colStr of
name = parse pFieldName ("failed to parse rpc arg name (" ++ toS k ++ ")") $ toS k Just str ->
val = toS <$> parse (many anyChar) ("failed to parse rpc arg value (" ++ toS v ++ ")") v 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")
@@ -59,57 +75,53 @@ ws = toS <$> many (oneOf " \t")
lexeme :: Parser a -> Parser a lexeme :: Parser a -> Parser a
lexeme p = ws *> p <* ws lexeme p = ws *> p <* ws
pReadRequest :: Text -> Parser ReadRequest
pReadRequest rootNodeName = do
fieldTree <- pFieldForest
return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing, Nothing)) []) fieldTree
where
readQuery = Select [] [rootNodeName] [] Nothing allRange
treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest
treeEntry (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) =
case fldForest of
[] -> Node (q {select=fld:select q}, i) rForest
_ -> Node (q, i) newForest
where
newForest =
foldr treeEntry (Node (Select [] [fn] [] Nothing allRange, (fn, Nothing, alias, relationDetail)) []) fldForest:rForest
pTreePath :: Parser (EmbedPath, Field) pTreePath :: Parser (EmbedPath, Field)
pTreePath = do pTreePath = do
p <- pFieldName `sepBy1` pDelimiter p <- pFieldName `sepBy1` pDelimiter
jp <- optionMaybe pJsonPath jp <- option [] pJsonPath
return (init p, (last p, jp)) return (init p, (last p, jp))
pFieldForest :: Parser [Tree SelectItem] pFieldForest :: Parser [Tree SelectItem]
pFieldForest = pFieldTree `sepBy1` lexeme (char ',') pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
where
pFieldTree :: Parser (Tree SelectItem) pFieldTree :: Parser (Tree SelectItem)
pFieldTree = try (Node <$> pRelationSelect <*> between (char '{') (char '}') pFieldForest) -- TODO: "{}" deprecated pFieldTree = try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) <|>
<|> try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) Node <$> pFieldSelect <*> pure []
<|> Node <$> pFieldSelect <*> pure []
pStar :: Parser Text pStar :: Parser Text
pStar = toS <$> (string "*" *> pure ("*"::ByteString)) pStar = toS <$> (string "*" $> ("*"::ByteString))
pFieldName :: Parser Text pFieldName :: Parser Text
pFieldName = do pFieldName =
matches <- (many1 (letter <|> digit <|> oneOf "_") `sepBy1` dash) <?> "field name (* or [a..z0..9_])" pQuotedValue <|>
return $ intercalate "-" $ map toS matches intercalate "-" . map toS <$> (many1 (letter <|> digit <|> oneOf "_ ") `sepBy1` dash) <?>
"field name (* or [a..z0..9_])"
where where
isDash :: GenParser Char st () isDash :: GenParser Char st ()
isDash = try ( char '-' >> notFollowedBy (char '>') ) isDash = try ( char '-' >> notFollowedBy (char '>') )
dash :: Parser Char dash :: Parser Char
dash = isDash *> pure '-' dash = isDash $> '-'
pJsonPathStep :: Parser Text pJsonPath :: Parser JsonPath
pJsonPathStep = toS <$> try (string "->" *> pFieldName) pJsonPath = many pJsonOperation
where
pJsonOperation :: Parser JsonOperation
pJsonOperation = pJsonArrow <*> pJsonOperand
pJsonPath :: Parser [Text] pJsonArrow =
pJsonPath = (<>) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldName) ) try (string "->>" $> J2Arrow) <|>
try (string "->" $> JArrow)
pJsonOperand =
let pJKey = JKey . toS <$> pFieldName
pJIdx = JIdx . toS <$> ((:) <$> option '+' (char '-') <*> many1 digit) <* pEnd
pEnd = try (void $ lookAhead (string "->")) <|>
try (void $ lookAhead (string "::")) <|>
try eof in
try pJIdx <|> try pJKey
pField :: Parser Field pField :: Parser Field
pField = lexeme $ (,) <$> pFieldName <*> optionMaybe pJsonPath pField = lexeme $ (,) <$> pFieldName <*> option [] pJsonPath
aliasSeparator :: Parser () aliasSeparator :: Parser ()
aliasSeparator = char ':' >> notFollowedBy (char ':') aliasSeparator = char ':' >> notFollowedBy (char ':')
@@ -118,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)
) )
@@ -134,15 +149,15 @@ pFieldSelect = lexeme $
) )
<|> do <|> do
s <- pStar s <- pStar
return ((s, Nothing), Nothing, Nothing, Nothing) return ((s, []), Nothing, Nothing, Nothing)
pOpExpr :: Parser SingleVal -> Parser ListVal -> Parser OpExpr pOpExpr :: Parser SingleVal -> Parser OpExpr
pOpExpr pSVal pLVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation
where where
pOperation :: Parser Operation pOperation :: Parser Operation
pOperation = pOperation =
Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal
<|> In <$> (string "in" *> pDelimiter *> pLVal) <|> In <$> (try (string "in" *> pDelimiter) *> pListVal)
<|> pFts <|> pFts
<?> "operator (eq, gt, ...)" <?> "operator (eq, gt, ...)"
@@ -158,56 +173,53 @@ pSingleVal :: Parser SingleVal
pSingleVal = toS <$> many anyChar pSingleVal = toS <$> many anyChar
pListVal :: Parser ListVal pListVal :: Parser ListVal
pListVal = try (lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')) pListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')
<|> lexeme pListElement `sepBy1` char ',' -- TODO: "in.3,4,5" deprecated, parens e.g. "in.(3,4,5)" should be used
pListElement :: Parser Text pListElement :: Parser Text
pListElement = try pQuotedValue <|> (toS <$> many (noneOf ",)")) pListElement = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> (toS <$> many (noneOf ",)"))
pQuotedValue :: Parser Text pQuotedValue :: Parser Text
pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"' <* notFollowedBy (noneOf ",)")) pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"')
pDelimiter :: Parser Char pDelimiter :: Parser Char
pDelimiter = char '.' <?> "delimiter (.)" pDelimiter = char '.' <?> "delimiter (.)"
pOrder :: Parser [OrderTerm] pOrder :: Parser [OrderTerm]
pOrder = lexeme pOrderTerm `sepBy` char ',' pOrder = lexeme pOrderTerm `sepBy1` char ','
pOrderTerm :: Parser OrderTerm pOrderTerm :: Parser OrderTerm
pOrderTerm = pOrderTerm = do
try ( do fld <- pField
c <- pField dir <- optionMaybe $
d <- optionMaybe (try $ pDelimiter *> ( try (pDelimiter *> string "asc" $> OrderAsc) <|>
try(string "asc" *> pure OrderAsc) try (pDelimiter *> string "desc" $> OrderDesc)
<|> try(string "desc" *> pure OrderDesc) nls <- optionMaybe pNulls <* pEnd <|>
)) pEnd $> Nothing
nls <- optionMaybe (pDelimiter *> ( return $ OrderTerm fld dir nls
try(string "nullslast" *> pure OrderNullsLast) where
<|> try(string "nullsfirst" *> pure OrderNullsFirst) pNulls = try (pDelimiter *> string "nullsfirst" $> OrderNullsFirst) <|>
)) try (pDelimiter *> string "nullslast" $> OrderNullsLast)
return $ OrderTerm c d nls pEnd = try (void $ lookAhead (char ',')) <|>
) try eof
<|> OrderTerm <$> pField <*> pure Nothing <*> pure Nothing
pLogicTree :: Parser LogicTree pLogicTree :: Parser LogicTree
pLogicTree = Stmnt <$> try pLogicFilter pLogicTree = Stmnt <$> try pLogicFilter
<|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')')) <|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')'))
where where
pLogicFilter :: Parser Filter pLogicFilter :: Parser Filter
pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal pLogicListVal pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal
pNot :: Parser Bool pNot :: Parser Bool
pNot = try (string "not" *> pDelimiter *> pure True) pNot = try (string "not" *> pDelimiter $> True)
<|> pure False <|> pure False
<?> "negation operator (not)" <?> "negation operator (not)"
pLogicOp :: Parser LogicOperator pLogicOp :: Parser LogicOperator
pLogicOp = try (string "and" *> pure And) pLogicOp = try (string "and" $> And)
<|> string "or" *> pure Or <|> string "or" $> Or
<?> "logic operator (and, or)" <?> "logic operator (and, or)"
pLogicSingleVal :: Parser SingleVal pLogicSingleVal :: Parser SingleVal
pLogicSingleVal = try pQuotedValue <|> try pPgArray <|> (toS <$> many (noneOf ",)")) pLogicSingleVal = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> try pPgArray <|> (toS <$> many (noneOf ",)"))
where where
-- TODO: "{}" deprecated, after removal pPgArray can be removed
pPgArray :: Parser Text pPgArray :: Parser Text
pPgArray = do pPgArray = do
a <- string "{" a <- string "{"
@@ -215,9 +227,6 @@ pLogicSingleVal = try pQuotedValue <|> try pPgArray <|> (toS <$> many (noneOf ",
c <- string "}" c <- string "}"
toS <$> pure (a ++ b ++ c) toS <$> pure (a ++ b ++ c)
pLogicListVal :: Parser ListVal
pLogicListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')
pLogicPath :: Parser (EmbedPath, Text) pLogicPath :: Parser (EmbedPath, Text)
pLogicPath = do pLogicPath = do
path <- pFieldName `sepBy1` pDelimiter path <- pFieldName `sepBy1` pDelimiter
@@ -225,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
@@ -234,3 +246,23 @@ mapError = mapLeft translateError
message = show $ errorPos e message = show $ errorPos e
details = strip $ replace "\n" " " $ toS details = strip $ replace "\n" " " $ toS
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e) $ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
-- Used for the config value "role-claim-key"
pRoleClaimKey :: Text -> Either ApiRequestError JSPath
pRoleClaimKey selStr =
mapError $ parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr)
pJSPath :: Parser JSPath
pJSPath = toJSPath <$> (period *> pPath `sepBy` period <* eof)
where
toJSPath :: [(Text, Maybe Int)] -> JSPath
toJSPath = concatMap (\(key, idx) -> JSPKey key : maybeToList (JSPIdx <$> idx))
period = char '.' <?> "period (.)"
pPath :: Parser (Text, Maybe Int)
pPath = (,) <$> pJSPKey <*> optionMaybe pJSPIdx
pJSPKey :: Parser Text
pJSPKey = toS <$> many1 (alphaNum <|> oneOf "_$@") <|> pQuotedValue <?> "attribute name [a..z0..9_$@])"
pJSPIdx :: Parser Int
pJSPIdx = char '[' *> (read <$> many1 digit) <* char ']' <?> "array index [0..n]"
+81 -383
View File
@@ -1,5 +1,7 @@
{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE DuplicateRecordFields #-}
{-# OPTIONS_GHC -fno-warn-orphans #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
{-| {-|
Module : PostgREST.QueryBuilder Module : PostgREST.QueryBuilder
Description : PostgREST SQL generating functions. Description : PostgREST SQL generating functions.
@@ -14,253 +16,79 @@ module PostgREST.QueryBuilder (
callProc callProc
, createReadStatement , createReadStatement
, createWriteStatement , createWriteStatement
, pgFmtIdent
, pgFmtLit
, requestToQuery , requestToQuery
, requestToCountQuery , requestToCountQuery
, unquoted , unquoted
, ResultsWithCount , ResultsWithCount
, pgFmtEnvVar , pgFmtSetLocal
, pgFmtSetLocalSearchPath
) where ) where
import qualified Hasql.Query 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.Config (pgVersion96) import Data.Maybe
import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset, allRange)
import Data.Functor.Contravariant (contramap)
import qualified Data.HashMap.Strict as HM
import Data.Maybe
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 qualified Data.Vector as V
import PostgREST.Types
import Text.InterpolatedString.Perl6 (qc)
import qualified Data.ByteString.Char8 as BS
import Data.Scientific ( FPFormat (..)
, formatScientific
, isInteger
)
import Protolude hiding (from, intercalate, ord, 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.nullableValue HD.int8 <*> HD.value HD.int8 import Protolude hiding (cast,
<*> HD.value header <*> HD.value HD.bytea intercalate, replace)
where
header = HD.array $ HD.arrayDimension replicateM $ HD.arrayValue 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.maybeRow standardRow
{-| JSON and CSV payloads from the client are given to us as
PayloadJSON (objects who all have the same keys),
and we turn this into an old fasioned JSON array
-}
encodeUniformObjs :: HE.Params PayloadJSON
encodeUniformObjs =
contramap (JSON.Array . V.map JSON.Object . unPayloadJSON) (HE.value HE.json)
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName ->
H.Query () 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.Query PayloadJSON (Maybe ResultsWithCount)
createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys =
unicodeStatement sql encodeUniformObjs 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 locationF pKeys
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 -> JSON.Object -> Bool -> SqlQuery -> SqlQuery -> Bool ->
Bool -> Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion -> H.Query () (Maybe ProcResults)
callProc qi params returnsScalar selectQuery countQuery countTotal isSingle paramsAsJson asCsv asBinary isReadOnly binaryField pgVer =
unicodeStatement sql HE.unit decodeProc True
where
sql =
if returnsScalar then [qc|
WITH {sourceCTEName} AS (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 {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;|]
countResultF = if countTotal then "( "<> countQuery <> ")" else "null::bigint" :: Text
_args = if paramsAsJson && not isReadOnly
then insertableValueWithType "json" $ JSON.Object params
else intercalate "," $ map _assignment (HM.toList params)
_procName = qiName qi
_assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v
responseHeaders =
if pgVer >= pgVersion96
then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
else "'[]'" :: Text
decodeProc = HD.maybeRow procRow
procRow = (,,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8
<*> HD.value HD.bytea <*> HD.value 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 _) = undefined requestToCountQuery _ (DbMutate _) = witness
requestToCountQuery schema (DbRead (Node (Select _ _ logicForest _ _, (mainTbl, _, _, _)) _)) = requestToCountQuery schema (DbRead (Node (Select{where_=logicForest}, (mainTbl, _, _, _, _)) _)) =
unwords [ unwords [
"SELECT pg_catalog.count(*)", "SELECT pg_catalog.count(*)",
"FROM ", fromQi qi, "FROM ", fromQi qi,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) filteredLogic)) `emptyOnFalse` null filteredLogic ("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest
] ]
where where
qi = removeSourceCTESchema schema mainTbl qi = removeSourceCTESchema schema mainTbl
-- all foreing key filters are root nodes(see addFilterToLogicForest), only those are filtered
nonFKRoot :: LogicTree -> Bool
nonFKRoot (Stmnt (Filter _ (OpExpr _ (Join _ _)))) = False
nonFKRoot (Stmnt _) = True
nonFKRoot Expr{} = True
filteredLogic = filter nonFKRoot logicForest
requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
requestToQuery schema isParent (DbRead (Node (Select colSelects tbls logicForest ord range, (nodeName, maybeRelation, _, _)) forest)) = requestToQuery schema isParent (DbRead (Node (Select colSelects tbl tblAlias implJoins logicForest joinConditions_ ordts range, _) forest)) =
query unwords [
"SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
"FROM " <> intercalate ", " (tabl : implJs),
unwords joins,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_))
`emptyOnFalse` (null logicForest && null joinConditions_),
("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts,
("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (isParent || range == allRange) ]
where where
mainTbl = fromMaybe nodeName (tableName . relTable <$> maybeRelation) implJs = fromQi . QualifiedIdentifier schema <$> implJoins
qi = removeSourceCTESchema schema mainTbl mainQi = removeSourceCTESchema schema tbl
toQi = removeSourceCTESchema schema tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias
query = unwords [ qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias
"SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
"FROM ", intercalate ", " (map (fromQi . toQi) tbls),
unwords joins,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest,
orderF (fromMaybe [] ord),
if isParent then "" else limitF range
]
orderF ts =
if null ts
then ""
else "ORDER BY " <> clause
where
clause = intercalate "," (map queryTerm ts)
queryTerm :: OrderTerm -> Text
queryTerm t = " "
<> toS (pgFmtField qi $ otTerm t) <> " "
<> maybe "" show (otDirection t) <> " "
<> maybe "" show (otNullOrder t) <> " "
(joins, selects) = foldr getQueryParts ([],[]) forest (joins, selects) = foldr getQueryParts ([],[]) forest
getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment]) getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias, _)) forst) (j,s) = (j,sel:s) getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
where where
sel = "COALESCE((" sel = "COALESCE(("
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) " <> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table <> "FROM (" <> subquery <> ") " <> pgFmtIdent table
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias) <> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
where subquery = requestToQuery schema False (DbRead (Node n forst)) where subquery = requestToQuery schema False (DbRead (Node n forst))
getQueryParts (Node n@(_, (name, Just Relation{relType=Parent,relTable=Table{tableName=table}}, alias, _)) forst) (j,s) = (joi:j,sel:s) getQueryParts (Node n@(_, (name, Just Relation{relType=Parent,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (joi:j,sel:s)
where where
aliasOrName = fromMaybe name alias aliasOrName = fromMaybe name alias
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName
joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE " joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE "
where subquery = requestToQuery schema True (DbRead (Node n forst)) where subquery = requestToQuery schema True (DbRead (Node n forst))
getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias, _)) forst) (j,s) = (j,sel:s) getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
where where
sel = "COALESCE ((" sel = "COALESCE (("
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) " <> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
@@ -270,48 +98,52 @@ requestToQuery schema isParent (DbRead (Node (Select colSelects tbls logicForest
--the following is just to remove the warning --the following is just to remove the warning
--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 _ _ = undefined getQueryParts _ _ = witness
requestToQuery schema _ (DbMutate (Insert mainTbl (PayloadJSON rows) returnings)) = requestToQuery schema _ (DbMutate (Insert mainTbl iCols onConflct putConditions returnings)) =
insInto <> vals <> ret unwords [
where qi = QualifiedIdentifier schema mainTbl "WITH " <> normalizedBody,
cols = map pgFmtIdent $ fromMaybe [] (HM.keys <$> (rows V.!? 0)) "INSERT INTO ", fromQi qi, if S.null iCols then " " else "(" <> cols <> ")",
colsString = intercalate ", " cols unwords [
insInto = unwords [ "INSERT INTO" , fromQi qi, "SELECT " <> cols <> " FROM",
if T.null colsString then "" else "(" <> colsString <> ")" "json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ") _",
] -- Only used for PUT
vals = unwords $ ("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier "" "_") <$> putConditions)) `emptyOnFalse` null putConditions],
if T.null colsString maybe "" (\(oncDo, oncCols) -> (
then if V.null rows then ["SELECT null WHERE false"] else ["DEFAULT VALUES"] "ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
else ["SELECT", colsString, "FROM json_populate_recordset(null::" , fromQi qi, ", $1)"] IgnoreDuplicates ->
ret = if null returnings "DO NOTHING"
then "" MergeDuplicates ->
else unwords [" RETURNING ", intercalate ", " (map (pgFmtColumn qi) returnings)] if S.null iCols
requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) logicForest returnings)) = then "DO NOTHING"
case rows V.!? 0 of else "DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols)
Just obj -> ) `emptyOnFalse` null oncCols) onConflct,
let assignments = map ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings]
(\(k,v) -> pgFmtIdent k <> "=" <> insertableValue v) $ HM.toList obj in where
qi = QualifiedIdentifier schema mainTbl
cols = intercalate ", " $ pgFmtIdent <$> S.toList iCols
requestToQuery schema _ (DbMutate (Update mainTbl uCols logicForest returnings)) =
if S.null uCols
then "WITH " <> ignoredBody <> "SELECT null WHERE false" -- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
else
unwords [ unwords [
"UPDATE ", fromQi qi, "WITH " <> normalizedBody,
" SET " <> intercalate "," assignments <> " ", "UPDATE " <> fromQi qi <> " SET " <> cols,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest, "FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ")) _ ",
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings ("WHERE " <> intercalate " AND " (pgFmtLogicTree qi <$> logicForest)) `emptyOnFalse` null logicForest,
("RETURNING " <> intercalate ", " (pgFmtColumn qi <$> returnings)) `emptyOnFalse` null returnings
] ]
Nothing -> undefined
where where
qi = QualifiedIdentifier schema mainTbl qi = QualifiedIdentifier schema mainTbl
cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols)
requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) = requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) =
query unwords [
"WITH " <> ignoredBody,
"DELETE FROM ", fromQi qi,
("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
query = unwords [
"DELETE FROM ", fromQi qi,
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest,
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
]
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
@@ -319,137 +151,3 @@ 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 =
"(" <>
" WITH s AS (SELECT row_to_json(ss) as r from " <> sourceCTEName <> " as ss limit 1)" <>
" SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))" <>
" FROM s, json_each_text(s.r) AS json_data" <>
(
if null pKeys
then ""
else " WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')"
) <> ")"
limitF :: NonnegRange -> SqlFragment
limitF r = if r == allRange
then ""
else "LIMIT " <> limit <> " OFFSET " <> offset
where
limit = maybe "ALL" show $ rangeLimit r
offset = show $ rangeOffset r
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.Query a b
unicodeStatement = H.statement . T.encodeUtf8
emptyOnFalse :: Text -> Bool -> Text
emptyOnFalse val cond = if cond then "" else val
insertableValue :: JSON.Value -> SqlFragment
insertableValue JSON.Null = "null"
insertableValue v = (<> "::unknown") . pgFmtLit $ unquoted v
insertableValueWithType :: Text -> JSON.Value -> SqlFragment
insertableValueWithType t v =
pgFmtLit (unquoted v) <> "::" <> t
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@(_, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs jp alias
pgFmtSelectItem table (f@(_, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs jp alias
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
<> ") "
Join fQi (ForeignKey Column{colTable=Table{tableName=fTableName}, colName=fColName}) ->
pgFmtField fQi fld <> " = " <> pgFmtColumn (removeSourceCTESchema (qiSchema fQi) fTableName) fColName
where
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
sqlOperator o = HM.lookupDefault "=" o operators
notOp = if hasNot then "NOT" else ""
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"])
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 :: Maybe JsonPath -> SqlFragment
pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x
pgFmtJsonPath (Just (x:xs)) = "->" <> pgFmtLit x <> pgFmtJsonPath ( Just xs )
pgFmtJsonPath _ = ""
pgFmtAs :: Maybe JsonPath -> Maybe Alias -> SqlFragment
pgFmtAs Nothing Nothing = ""
pgFmtAs (Just xx) Nothing = case lastMay xx of
Just alias -> " AS " <> pgFmtIdent alias
Nothing -> ""
pgFmtAs _ (Just alias) = " AS " <> pgFmtIdent alias
pgFmtEnvVar :: Text -> (Text, Text) -> SqlFragment
pgFmtEnvVar prefix (k, v) =
"set local " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
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
+16 -15
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
@@ -32,14 +34,13 @@ rangeParse range = do
case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of
Just parsedRange -> Just parsedRange ->
let [_, mLower, mUpper] = readMaybe . toS <$> parsedRange let [_, mLower, mUpper] = readMaybe . toS <$> parsedRange
lower = fromMaybe emptyRange (rangeGeq <$> mLower) lower = maybe emptyRange rangeGeq mLower
upper = fromMaybe allRange (rangeLeq <$> mUpper) in upper = maybe allRange rangeLeq mUpper in
rangeIntersection lower upper rangeIntersection lower upper
Nothing -> allRange Nothing -> allRange
rangeRequested :: RequestHeaders -> NonnegRange rangeRequested :: RequestHeaders -> NonnegRange
rangeRequested headers = fromMaybe allRange $ rangeRequested headers = maybe allRange rangeParse $ lookup hRange headers
rangeParse <$> lookup hRange headers
restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange
restrictRange Nothing r = r restrictRange Nothing r = r
@@ -57,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 =
+265 -96
View File
@@ -1,51 +1,96 @@
{-# 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 Data.Aeson
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 Data.Tree
import qualified Data.Vector as V 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"
deriving (Show, Eq) toMime CTTextCSV = "text/csv"
toMime CTTextPlain = "text/plain"
toMime CTOpenAPI = "application/openapi+json"
toMime CTSingularJSON = "application/vnd.pgrst.object+json"
toMime CTOctetStream = "application/octet-stream"
toMime CTAny = "*/*"
toMime (CTOther ct) = ct
-- | Convert from ByteString to ContentType. Warning: discards MIME parameters
decodeContentType :: BS.ByteString -> ContentType
decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of
"application/json" -> CTApplicationJSON
"text/csv" -> CTTextCSV
"text/plain" -> CTTextPlain
"application/openapi+json" -> CTOpenAPI
"application/vnd.pgrst.object+json" -> CTSingularJSON
"application/vnd.pgrst.object" -> CTSingularJSON
"application/octet-stream" -> CTOctetStream
"*/*" -> CTAny
ct' -> CTOther ct'
data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq
instance Show PreferResolution where
show MergeDuplicates = "resolution=merge-duplicates"
show IgnoreDuplicates = "resolution=ignore-duplicates"
data DbStructure = DbStructure { data DbStructure = DbStructure {
dbTables :: [Table] dbTables :: [Table]
, dbColumns :: [Column] , dbColumns :: [Column]
, dbRelations :: [Relation] , dbRelations :: [Relation]
, dbPrimaryKeys :: [PrimaryKey] , dbPrimaryKeys :: [PrimaryKey]
, dbProcs :: M.HashMap Text ProcDescription -- ProcDescription is a list because a function can be overloaded
, dbProcs :: M.HashMap Text [ProcDescription]
, pgVersion :: PgVersion , pgVersion :: PgVersion
} deriving (Show, Eq) } deriving (Show, Eq)
-- TODO Table could hold references to all its Columns
tableCols :: DbStructure -> Schema -> TableName -> [Column]
tableCols dbs tSchema tName = filter (\Column{colTable=Table{tableSchema=s, tableName=t}} -> s==tSchema && t==tName) $ dbColumns dbs
-- TODO Table could hold references to all its PrimaryKeys
tablePKCols :: DbStructure -> Schema -> TableName -> [Text]
tablePKCols dbs tSchema tName = pkName <$> filter (\pk -> tSchema == (tableSchema . pkTable) pk && tName == (tableName . pkTable) pk) (dbPrimaryKeys dbs)
data PgArg = PgArg { data PgArg = PgArg {
pgaName :: Text pgaName :: Text
, pgaType :: Text , pgaType :: Text
, pgaReq :: Bool , pgaReq :: Bool
} deriving (Show, Eq) } deriving (Show, Eq, Ord)
data PgType = Scalar QualifiedIdentifier | Composite QualifiedIdentifier deriving (Eq, Show) data PgType = Scalar QualifiedIdentifier | Composite QualifiedIdentifier deriving (Eq, Show, Ord)
data RetType = Single PgType | SetOf PgType deriving (Eq, Show) data RetType = Single PgType | SetOf PgType deriving (Eq, Show, Ord)
data ProcVolatility = Volatile | Stable | Immutable data ProcVolatility = Volatile | Stable | Immutable
deriving (Eq, Show) deriving (Eq, Show, Ord)
data ProcDescription = ProcDescription { data ProcDescription = ProcDescription {
pdName :: Text pdName :: Text
@@ -55,11 +100,45 @@ data ProcDescription = ProcDescription {
, pdVolatility :: ProcVolatility , pdVolatility :: ProcVolatility
} deriving (Show, Eq) } deriving (Show, Eq)
-- Order by least number of args in the case of overloaded functions
instance Ord ProcDescription where
ProcDescription name1 des1 args1 rt1 vol1 `compare` ProcDescription name2 des2 args2 rt2 vol2
| name1 == name2 && length args1 < length args2 = LT
| name1 == name2 && length args1 > length args2 = GT
| 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
type SqlFragment = Text type SqlFragment = Text
type RequestBody = BL.ByteString
data Table = Table { data Table = Table {
tableSchema :: Schema tableSchema :: Schema
@@ -68,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
@@ -86,7 +168,12 @@ data Column =
, colFK :: Maybe ForeignKey , colFK :: Maybe ForeignKey
} deriving (Show, Ord) } deriving (Show, Ord)
type Synonym = (Column,Column) 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
type Synonym = (Column, ViewColumn)
type ViewColumn = Column
data PrimaryKey = PrimaryKey { data PrimaryKey = PrimaryKey {
pkTable :: Table pkTable :: Table
@@ -95,13 +182,13 @@ data PrimaryKey = PrimaryKey {
data OrderDirection = OrderAsc | OrderDesc deriving (Eq) data OrderDirection = OrderAsc | OrderDesc deriving (Eq)
instance Show OrderDirection where instance Show OrderDirection where
show OrderAsc = "asc" show OrderAsc = "ASC"
show OrderDesc = "desc" show OrderDesc = "DESC"
data OrderNulls = OrderNullsFirst | OrderNullsLast deriving (Eq) data OrderNulls = OrderNullsFirst | OrderNullsLast deriving (Eq)
instance Show OrderNulls where instance Show OrderNulls where
show OrderNullsFirst = "nulls first" show OrderNullsFirst = "NULLS FIRST"
show OrderNullsLast = "nulls last" show OrderNullsLast = "NULLS LAST"
data OrderTerm = OrderTerm { data OrderTerm = OrderTerm {
otTerm :: Field otTerm :: Field
@@ -109,10 +196,14 @@ 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
} deriving (Show, Eq) } deriving (Show, Eq, Ord)
data RelationType = Child | Parent | Many | Root deriving (Show, Eq) data RelationType = Child | Parent | Many | Root deriving (Show, Eq)
@@ -121,31 +212,47 @@ data RelationType = Child | Parent | Many | Root deriving (Show, Eq)
The name 'Relation' here is used with the meaning The name 'Relation' here is used with the meaning
"What is the relation between the current node and the parent node". "What is the relation between the current node and the parent node".
It has nothing to do with PostgreSQL referring to tables/views as relations. It has nothing to do with PostgreSQL referring to tables/views as relations.
The order of the relColumns and relFColumns should be maintained to get
the join conditions right.
TODO merge relColumns and relFColumns to a tuple or Data.Bimap
-} -}
data Relation = Relation { data Relation = Relation {
relTable :: Table relTable :: Table
, relColumns :: [Column] , relColumns :: [Column]
, relFTable :: Table , relFTable :: Table
, relFColumns :: [Column] , relFColumns :: [Column]
, relType :: RelationType , relType :: RelationType
, relLTable :: Maybe Table -- The Link attrs are used when RelationType == Many
, relLCols1 :: Maybe [Column] , relLinkTable :: Maybe Table
, relLCols2 :: Maybe [Column] , relLinkCols1 :: Maybe [Column]
, relLinkCols2 :: Maybe [Column]
} deriving (Show, Eq) } deriving (Show, Eq)
-- | An array of JSON objects that has been verified to have isSelfJoin :: Relation -> Bool
-- the same keys in every object isSelfJoin r = relType r /= Root && relTable r == relFTable r
newtype PayloadJSON = PayloadJSON (V.Vector Object)
deriving (Show, Eq)
unPayloadJSON :: PayloadJSON -> V.Vector Object data PayloadJSON =
unPayloadJSON (PayloadJSON objs) = objs -- | Cached attributes of a JSON payload
ProcessedJSON {
-- | This is the raw ByteString that comes from the request body.
-- We cache this instead of an Aeson Value because it was detected that for large payloads the encoding
-- had high memory usage, see #1005 for more details
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 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
@@ -168,24 +275,20 @@ operators = M.union (M.fromList [
("sr", ">>"), ("sr", ">>"),
("nxr", "&<"), ("nxr", "&<"),
("nxl", "&>"), ("nxl", "&>"),
("adj", "-|-"), ("adj", "-|-")]) ftsOperators
-- TODO: these are deprecated and should be removed in v0.5.0.0
("@>", "@>"),
("<@", "<@")]) ftsOperators
ftsOperators :: M.HashMap Operator SqlFragment ftsOperators :: M.HashMap Operator SqlFragment
ftsOperators = M.fromList [ ftsOperators = M.fromList [
("@@", "@@ to_tsquery"), -- TODO: '@@' deprecated
("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)
data Operation = Op Operator SingleVal | data Operation = Op Operator SingleVal |
In ListVal | In ListVal |
Fts Operator (Maybe Language) SingleVal | Fts Operator (Maybe Language) SingleVal deriving (Eq, Show)
Join QualifiedIdentifier ForeignKey deriving (Eq, Show)
type Language = Text type Language = Text
-- | Represents a single value in a filter, e.g. id=eq.singleval -- | Represents a single value in a filter, e.g. id=eq.singleval
@@ -195,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:
@@ -209,8 +312,16 @@ instance Show LogicOperator where
data LogicTree = Expr Bool LogicOperator [LogicTree] | Stmnt Filter deriving (Show, Eq) data LogicTree = Expr Bool LogicOperator [LogicTree] | Stmnt Filter deriving (Show, Eq)
type FieldName = Text type FieldName = Text
type JsonPath = [Text] {-|
type Field = (FieldName, Maybe JsonPath) Json path operations as specified in https://www.postgresql.org/docs/9.4/static/functions-json.html
-}
type JsonPath = [JsonOperation]
-- | Represents the single arrow `->` or double arrow `->>` operators
data JsonOperation = JArrow{jOp :: JsonOperand} | J2Arrow{jOp :: JsonOperand} deriving (Show, Eq)
-- | Represents the key(`->'key'`) or index(`->'1`::int`), the index is Text because we reuse our escaping functons and let pg do the casting with '1'::int
data JsonOperand = JKey{jVal :: Text} | JIdx{jVal :: Text} deriving (Show, Eq)
type Field = (FieldName, JsonPath)
type Alias = Text type Alias = Text
type Cast = Text type Cast = Text
type NodeName = Text type NodeName = Text
@@ -224,10 +335,10 @@ type RpcQParam = (Text, Text)
-} -}
newtype GucHeader = GucHeader (Text, Text) newtype GucHeader = GucHeader (Text, Text)
instance FromJSON GucHeader where instance JSON.FromJSON GucHeader where
parseJSON (Object o) = case headMay (M.toList o) of parseJSON (JSON.Object o) = case headMay (M.toList o) of
Just (k, String s) | M.size o == 1 -> pure $ GucHeader (k, s) Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (k, s)
| otherwise -> mzero | otherwise -> mzero
_ -> mzero _ -> mzero
parseJSON _ = mzero parseJSON _ = mzero
@@ -243,40 +354,98 @@ 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, FieldName)
(QualifiedIdentifier, FieldName) deriving (Show, Eq)
data ReadQuery = Select {
select :: [SelectItem]
, from :: TableName
-- | A table alias is used in case of self joins
, fromAlias :: Maybe Alias
-- | Only used for Many to Many joins. Parent and Child joins use explicit joins.
, implicitJoins :: [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 ReadQuery = Select { select::[SelectItem], from::[TableName], where_::[LogicTree], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq)
data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] }
| Delete { in_::TableName, where_::[LogicTree], returning::[FieldName] }
| Update { in_::TableName, qPayload::PayloadJSON, where_::[LogicTree], returning::[FieldName] } deriving (Show, Eq)
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail))
type ReadRequest = Tree ReadNode
type MutateRequest = MutateQuery
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
type ReadRequest = Tree ReadNode
instance Eq Table where type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail, Depth))
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2 -- Depth of the ReadRequest tree
type Depth = Integer
instance Eq Column where type MutateRequest = MutateQuery
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
, pgvName :: Text , pgvName :: Text
} deriving (Eq, Ord, Show) } deriving (Eq, Show)
instance Ord PgVersion where
(PgVersion v1 _) `compare` (PgVersion v2 _) = v1 `compare` v2
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
minimumPgVersion :: PgVersion
minimumPgVersion = pgVersion94
pgVersion94 :: PgVersion
pgVersion94 = PgVersion 90400 "9.4"
pgVersion95 :: PgVersion
pgVersion95 = PgVersion 90500 "9.5"
pgVersion96 :: PgVersion
pgVersion96 = PgVersion 90600 "9.6"
pgVersion100 :: PgVersion
pgVersion100 = PgVersion 100000 "10"
pgVersion109 :: PgVersion
pgVersion109 = PgVersion 100009 "10.9"
pgVersion110 :: PgVersion
pgVersion110 = PgVersion 110000 "11.0"
pgVersion112 :: PgVersion
pgVersion112 = PgVersion 110002 "11.2"
pgVersion114 :: PgVersion
pgVersion114 = PgVersion 110004 "11.4"
sourceCTEName :: SqlFragment sourceCTEName :: SqlFragment
sourceCTEName = "pg_source" sourceCTEName = "pg_source"
-- | full jspath, e.g. .property[0].attr.detail
type JSPath = [JSPathExp]
-- | jspath expression, e.g. .property, .property[0] or ."property-dash"
data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)
-- | Current database connection status data ConnectionStatus
data ConnectionStatus
= NotConnected
| Connected PgVersion
| FatalConnectionError Text
deriving (Eq, Show)
+18 -9
View File
@@ -1,12 +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 - hasql-transaction-0.7.2
- text-builder-0.6.5.1
- deferred-folds-0.9.10.1
- primitive-0.6.4.0
- jose-0.8.1.0
- text-printer-0.5.0.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

+57 -34
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
@@ -27,13 +28,13 @@ spec =
context "embedded levels" $ do context "embedded levels" $ do
it "can do logic on the second level" $ it "can do logic on the second level" $
get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities{id}" `shouldRespondWith` get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities(id)" `shouldRespondWith`
[json|[ [json|[
{"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []}, {"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []},
{"id": 3, "child_entities": []}, {"id": 4, "child_entities": []} {"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}
]|] { matchHeaders = [matchContentTypeJson] } ]|] { matchHeaders = [matchContentTypeJson] }
it "can do logic on the third level" $ it "can do logic on the third level" $
get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities{id,grandchild_entities{id}}" `shouldRespondWith` get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities(id,grandchild_entities(id))" `shouldRespondWith`
[json|[ [json|[
{"id": 1, "child_entities": [ { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]}, { "id": 2, "grandchild_entities": []}]}, {"id": 1, "child_entities": [ { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]}, { "id": 2, "grandchild_entities": []}]},
{"id": 2, "child_entities": [ { "id": 3, "grandchild_entities": []} ]}, {"id": 2, "child_entities": [ { "id": 3, "grandchild_entities": []} ]},
@@ -79,15 +80,22 @@ 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] }
-- TODO: remove in 0.5.0 as deprecated
get "/entities?or=(text_search_vector.@@.bar,text_search_vector.@@.baz)&select=id" `shouldRespondWith` when (actualPgVersion >= pgVersion112) $
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] } it "can handle wfts (websearch_to_tsquery)" $
it "can handle cs and cd" $ do get "/tsearch?or=(text_search_vector.plfts(german).Art,text_search_vector.plfts(french).amusant,text_search_vector.not.wfts(english).impossible)"
`shouldRespondWith`
[json|[
{"text_search_vector": "'also':2 'fun':3 'possibl':8" },
{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" },
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
{"text_search_vector": "'art':4 'spass':5 'unmog':7" }
]|]
{ matchHeaders = [matchContentTypeJson] }
it "can handle cs and cd" $
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith` 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] }
-- TODO: remove in 0.5.0 as deprecated
get "/entities?or=(arr.@>.{1,2,3},arr.<@.{1})&select=id" `shouldRespondWith`
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
it "can handle range operators" $ do it "can handle range operators" $ do
get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith` get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith`
@@ -119,25 +127,44 @@ 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" $ do it "eq, cs, like can be negated" $
get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith` get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
[json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] } [json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated it "in, is, fts can be negated" $
get "/entities?and=(arr.not.@>.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
[json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }
it "in, is, fts can be negated" $ do
get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith` get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith`
[json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] } [json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated it "lt, gte, cd can be negated" $
get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.@@.foo))&select=id" `shouldRespondWith`
[json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }
it "lt, gte, cd can be negated" $ do
get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith` get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`
[json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] } [json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
-- TODO: remove in 0.5.0 as deprecated
get "/entities?and=(arr.not.<@.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`
[json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
it "gt, lte, ilike can be negated" $ it "gt, lte, ilike can be negated" $
get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith` get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith`
[json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] } [json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
@@ -182,7 +209,7 @@ spec =
context "used with POST" $ context "used with POST" $
it "includes related data with filters" $ it "includes related data with filters" $
request methodPost "/child_entities?entities.or=(id.eq.2,id.eq.3)&select=id,entities{id}" request methodPost "/child_entities?select=id,entities(id)&entities.or=(id.eq.2,id.eq.3)&entities.order=id"
[("Prefer", "return=representation")] [("Prefer", "return=representation")]
[json|[{"id":4,"name":"entity 4","parent_id":1}, [json|[{"id":4,"name":"entity 4","parent_id":1},
{"id":5,"name":"entity 5","parent_id":2}, {"id":5,"name":"entity 5","parent_id":2},
@@ -208,10 +235,6 @@ spec =
it "can query columns that begin with and/or reserved words" $ it "can query columns that begin with and/or reserved words" $
get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200 get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
it "can query jsonb columns" $
get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
[json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
it "fails when using IN without () and provides meaningful error message" $ it "fails when using IN without () and provides meaningful error message" $
get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith` get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith`
[json|{ [json|{
+6 -6
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 Protolude hiding (get) import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
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)\""
] ]
} }
+6 -6
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 Protolude hiding (get) import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Protolude
import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
+11 -10
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 Test.Hspec hiding (pendingWith) import Control.Monad.Base
import Test.Hspec.Wai.Internal import Control.Monad.Trans.Control
import Network.Wai.Test (Session)
import Test.Hspec
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)
+7 -7
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 hiding (get) import Protolude
import SpecHelper
-- }}} -- }}}
spec :: SpecWith Application spec :: SpecWith Application
@@ -45,7 +45,7 @@ spec =
"true" "true"
respHeaders `shouldSatisfy` matchHeader respHeaders `shouldSatisfy` matchHeader
"Access-Control-Allow-Methods" "Access-Control-Allow-Methods"
"GET, POST, PATCH, DELETE, OPTIONS, HEAD" "GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD"
respHeaders `shouldSatisfy` matchHeader respHeaders `shouldSatisfy` matchHeader
"Access-Control-Allow-Headers" "Access-Control-Allow-Headers"
"Authentication, Foo, Bar, Accept, Accept-Language, Content-Language" "Authentication, Foo, Bar, Accept, Accept-Language, Content-Language"
+4 -4
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
@@ -36,7 +36,7 @@ spec =
request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] "" request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|] `shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
it "can embed (parent) entities" $ it "can embed (parent) entities" $
request methodDelete "/tasks?id=eq.8&select=id,name,project{id}" [("Prefer", "return=representation")] "" request methodDelete "/tasks?id=eq.8&select=id,name,project(id)" [("Prefer", "return=representation")] ""
`shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|] `shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"] , matchHeaders = ["Content-Range" <:> "*/*"]
+36
View File
@@ -0,0 +1,36 @@
module Feature.ExtraSearchPathSpec where
import Network.HTTP.Types
import Network.Wai (Application)
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude
import SpecHelper
spec :: SpecWith Application
spec = describe "extra search path" $ do
it "finds the ltree <@ operator on the public schema" $
request methodGet "/ltree_sample?path=cd.Top.Science.Astronomy" [] ""
`shouldRespondWith` [json|[
{"path":"Top.Science.Astronomy"},
{"path":"Top.Science.Astronomy.Astrophysics"},
{"path":"Top.Science.Astronomy.Cosmology"}]|]
{ matchHeaders = [matchContentTypeJson] }
it "finds the ltree nlevel function on the public schema, used through a computed column" $
request methodGet "/ltree_sample?select=number_of_labels&path=eq.Top.Science" [] ""
`shouldRespondWith` [json|[{"number_of_labels":2}]|]
{ matchHeaders = [matchContentTypeJson] }
it "finds the isn = operator on the extensions schema" $
request methodGet "/isn_sample?id=eq.978-0-393-04002-9&select=name" [] ""
`shouldRespondWith` [json|[{"name":"Mathematics: From the Birth of Numbers"}]|]
{ matchHeaders = [matchContentTypeJson] }
it "finds the isn is_valid function on the extensions schema" $
request methodGet "/rpc/is_valid_isbn?input=978-0-393-04002-9" [] ""
`shouldRespondWith` [json|true|]
{ matchHeaders = [matchContentTypeJson] }
+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"]
}
+195 -76
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
@@ -49,9 +48,28 @@ spec = do
, matchHeaders = [matchContentTypeJson] , matchHeaders = [matchContentTypeJson]
} }
context "non uniform json array" $ do
it "rejects json array that isn't exclusivily composed of objects" $
post "/articles"
[json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |]
`shouldRespondWith`
[json| {"message":"All object keys must match"} |]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
it "rejects json array that has objects with different keys" $
post "/articles"
[json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |]
`shouldRespondWith`
[json| {"message":"All object keys must match"} |]
{ matchStatus = 400
, matchHeaders = [matchContentTypeJson]
}
context "requesting full representation" $ do context "requesting full representation" $ do
it "includes related data after insert" $ it "includes related data after insert" $
request methodPost "/projects?select=id,name,clients{id,name}" request methodPost "/projects?select=id,name,clients(id,name)"
[("Prefer", "return=representation"), ("Prefer", "count=exact")] [("Prefer", "return=representation"), ("Prefer", "count=exact")]
[str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [str|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|] [str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [str|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]
{ matchStatus = 201 { matchStatus = 201
@@ -97,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
@@ -175,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" $
@@ -187,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" $
@@ -216,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")]
@@ -231,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"
@@ -291,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
@@ -311,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
@@ -353,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 } |]
@@ -384,25 +482,26 @@ spec = do
[json| [{ a: "keepme", b: null }] |] [json| [{ a: "keepme", b: null }] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "can set a json column to escaped value" $ do context "filtering by a computed column" $ do
_ <- post "/json" [json| { data: {"escaped":"bar"} } |] it "is successful" $
request methodPatch "/json?data->>escaped=eq.bar" request methodPatch
[("Prefer", "return=representation")] "/items?is_first=eq.true"
[json| { "data": { "escaped":" \"bar" } } |] [("Prefer", "return=representation")]
`shouldRespondWith` [json| [{ "data": { "escaped":" \"bar" } }] |] [json| { id: 100 } |]
{ matchStatus = 200 `shouldRespondWith` [json| [{ id: 100 }] |]
, matchHeaders = [] { matchStatus = 200,
} matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]
}
it "can update based on a computed column" $ it "indicates no records updated by returning 404" $
request methodPatch request methodPatch
"/items?always_true=eq.false" "/items?always_true=eq.false"
[("Prefer", "return=representation")] [("Prefer", "return=representation")]
[json| { id: 100 } |] [json| { id: 100 } |]
`shouldRespondWith` "[]" `shouldRespondWith` "[]"
{ matchStatus = 200, { matchStatus = 404,
matchHeaders = ["Content-Range" <:> "*/*"] matchHeaders = []
} }
it "can provide a representation" $ do it "can provide a representation" $ do
_ <- post "/items" _ <- post "/items"
@@ -416,28 +515,36 @@ spec = do
-- put value back for other tests -- put value back for other tests
void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |] void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]
it "makes no updates and returns 204, when patching with an empty json object" $ do it "makes no updates and returns 204, when patching with an empty json object/array" $ do
request methodPatch "/items" [] [json| {} |] request methodPatch "/items" [] [json| {} |]
`shouldRespondWith` "" `shouldRespondWith` ""
{ {
matchStatus = 204, matchStatus = 204,
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] }
it "makes no updates and and returns 200, when patching with an empty json object and return=rep" $ do request methodPatch "/items" [] [json| [] |]
`shouldRespondWith` ""
{
matchStatus = 204,
matchHeaders = ["Content-Range" <:> "*/*"]
}
request methodPatch "/items" [] [json| [{}] |]
`shouldRespondWith` ""
{
matchStatus = 204,
matchHeaders = ["Content-Range" <:> "*/*"]
}
it "makes no updates and 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" []
@@ -449,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"
+204
View File
@@ -0,0 +1,204 @@
module Feature.JsonOperatorSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import PostgREST.Types (PgVersion, pgVersion112)
import Protolude hiding (get)
import SpecHelper
spec :: PgVersion -> SpecWith Application
spec actualPgVersion = describe "json and jsonb operators" $ do
context "Shaping response with select parameter" $ do
it "obtains a json subfield one level with casting" $
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
[json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
{ matchHeaders = [matchContentTypeJson] }
it "renames json subfield one level with casting" $
get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`
[json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
{ matchHeaders = [matchContentTypeJson] }
it "fails on bad casting (data of the wrong format)" $
get "/complex_items?select=settings->foo->>bar::integer"
`shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
{ matchStatus = 400 , matchHeaders = [] }
it "obtains a json subfield two levels (string)" $
get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
[json| [{"bar":"baz"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "renames json subfield two levels (string)" $
get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
[json| [{"myBar":"baz"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "obtains a json subfield two levels with casting (int)" $
get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
[json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
{ matchHeaders = [matchContentTypeJson] }
it "renames json subfield two levels with casting (int)" $
get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`
[json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
{ matchHeaders = [matchContentTypeJson] }
-- TODO the status code for the error is 404, this is because 42883 represents undefined function
-- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate
it "fails when a double arrow ->> is followed with a single arrow ->" $ do
get "/json_arr?select=data->>c->1"
`shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[json|
{"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
"details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
else
[json|
{"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
"details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
)
{ matchStatus = 404 , matchHeaders = [] }
get "/json_arr?select=data->>c->b"
`shouldRespondWith` (
if actualPgVersion >= pgVersion112 then
[json|
{"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
"details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
else
[json|
{"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
"details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
)
{ matchStatus = 404 , matchHeaders = [] }
context "with array index" $ do
it "can get array of ints and alias/cast it" $ do
get "/json_arr?select=data->>0::int&id=in.(1,2)" `shouldRespondWith`
[json| [{"data":1}, {"data":4}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=idx0:data->>0::int,idx1:data->>1::int&id=in.(1,2)" `shouldRespondWith`
[json| [{"idx0":1,"idx1":2}, {"idx0":4,"idx1":5}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can get nested array of ints" $ do
get "/json_arr?select=data->0->>1::int&id=in.(3,4)" `shouldRespondWith`
[json| [{"data":8}, {"data":7}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->0->>1::int&id=in.(3,4)" `shouldRespondWith`
[json| [{"data":null}, {"data":6}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can get array of objects" $ do
get "/json_arr?select=data->0->>a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->a->>2&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":null}, {"a":"3"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can get array in object keys" $ do
get "/json_arr?select=data->c->>0::json&id=in.(7,8)" `shouldRespondWith`
[json| [{"c":1}, {"c":{"d": [4,5,6,7,8]}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->c->0->d->>4::int&id=in.(7,8)" `shouldRespondWith`
[json| [{"d":null}, {"d":8}] |]
{ matchHeaders = [matchContentTypeJson] }
it "only treats well formed numbers as indexes" $
get "/json_arr?select=data->0->0xy1->1->23-xy-45->1->xy-6->>0::int&id=eq.9" `shouldRespondWith`
[json| [{"xy-6":3}] |]
{ matchHeaders = [matchContentTypeJson] }
context "finishing json path with single arrow ->" $ do
it "works when finishing with a key" $ do
get "/json_arr?select=data->c&id=in.(7,8)" `shouldRespondWith`
[json| [{"c":[1,2,3]}, {"c":[{"d": [4,5,6,7,8]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":[1,2,3]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "works when finishing with an index" $ do
get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":[1,2,3]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->c->0->d&id=eq.8" `shouldRespondWith`
[json| [{"d":[4,5,6,7,8]}] |]
{ matchHeaders = [matchContentTypeJson] }
context "filtering response" $ do
it "can filter by properties inside json column" $ do
get "/json?data->foo->>bar=eq.baz" `shouldRespondWith`
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json?data->foo->>bar=eq.fake" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter by properties inside json column using not" $
get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith`
[json| [] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter by properties inside json column using ->>" $
get "/json?data->>id=eq.1" `shouldRespondWith`
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can be filtered with and/or" $
get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
[json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
it "can filter by array indexes" $ do
get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`
[json| [{"data":[1, 2, 3]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->1->>2=eq.13" `shouldRespondWith`
[json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->1->>b=eq.B" `shouldRespondWith`
[json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->1->b->>1=eq.5" `shouldRespondWith`
[json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter jsonb" $ do
get "/jsonb_test?data=eq.{\"e\":1}" `shouldRespondWith`
[json| [{"id":4,"data":{"e": 1}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/jsonb_test?data->a=eq.{\"b\":2}" `shouldRespondWith`
[json| [{"id":1,"data":{"a": {"b": 2}}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/jsonb_test?data->c=eq.[1,2,3]" `shouldRespondWith`
[json| [{"id":2,"data":{"c": [1, 2, 3]}}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/jsonb_test?data->0=eq.{\"d\":\"test\"}" `shouldRespondWith`
[json| [{"id":3,"data":[{"d": "test"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
context "ordering response" $ do
it "orders by a json column property asc" $
get "/json?order=data->>id.asc" `shouldRespondWith`
[json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
{ matchHeaders = [matchContentTypeJson] }
it "orders by a json column with two level property nulls first" $
get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
[json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
{ matchHeaders = [matchContentTypeJson] }
context "Patching record, in a nonempty table" $
it "can set a json column to escaped value" $ do
_ <- post "/json" [json| { data: {"escaped":"bar"} } |]
request methodPatch "/json?data->>escaped=eq.bar"
[("Prefer", "return=representation")]
[json| { "data": { "escaped":" \"bar" } } |]
`shouldRespondWith` [json| [{ "data": { "escaped":" \"bar" } }] |]
{ matchStatus = 200 , matchHeaders = [] }
+12 -6
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 Protolude hiding (get) import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
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
+55
View File
@@ -0,0 +1,55 @@
module Feature.PgVersion95Spec where
import Network.Wai (Application)
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Protolude hiding (get)
import SpecHelper
spec :: SpecWith Application
spec = describe "features supported on PostgreSQL 9.5" $
context "json array negative index" $ do
it "can select with negative indexes" $ do
get "/json_arr?select=data->>-1::int&id=in.(1,2)" `shouldRespondWith`
[json| [{"data":3}, {"data":6}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->0->>-2::int&id=in.(3,4)" `shouldRespondWith`
[json| [{"data":8}, {"data":7}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->-2->>a&id=in.(5,6)" `shouldRespondWith`
[json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "can filter with negative indexes" $ do
get "/json_arr?select=data&data->>-3=eq.1" `shouldRespondWith`
[json| [{"data":[1, 2, 3]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->-1->>-3=eq.11" `shouldRespondWith`
[json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->-1->>b=eq.B" `shouldRespondWith`
[json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data&data->-1->b->>-1=eq.5" `shouldRespondWith`
[json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
{ matchHeaders = [matchContentTypeJson] }
it "should fail on badly formed negatives" $ do
get "/json_arr?select=data->>-78xy" `shouldRespondWith`
[json|
{"details": "unexpected 'x' expecting digit, \"->\", \"::\" or end of input",
"message": "\"failed to parse select parameter (data->>-78xy)\" (line 1, column 11)"} |]
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->>--34" `shouldRespondWith`
[json|
{"details": "unexpected \"-\" expecting digit",
"message": "\"failed to parse select parameter (data->>--34)\" (line 1, column 9)"} |]
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
get "/json_arr?select=data->>-xy-4" `shouldRespondWith`
[json|
{"details":"unexpected \"x\" expecting digit",
"message":"\"failed to parse select parameter (data->>-xy-4)\" (line 1, column 9)"} |]
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
+28 -8
View File
@@ -1,13 +1,13 @@
module Feature.PgVersion96Spec where module Feature.PgVersion96Spec where
import Test.Hspec hiding (pendingWith) import Network.Wai (Application)
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"
+3 -5
View File
@@ -1,12 +1,10 @@
module Feature.ProxySpec where module Feature.ProxySpec where
import Test.Hspec hiding (pendingWith)
import SpecHelper
import Network.Wai (Application) import Network.Wai (Application)
import Test.Hspec hiding (pendingWith)
import Protolude hiding (get) import Protolude
import SpecHelper
spec :: SpecWith Application spec :: SpecWith Application
spec = spec =
+9 -8
View File
@@ -1,14 +1,15 @@
module Feature.QueryLimitedSpec where module Feature.QueryLimitedSpec where
import Test.Hspec hiding (pendingWith) import Network.Wai (Application)
import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
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 =
@@ -29,14 +30,14 @@ spec =
simpleStatus r `shouldBe` ok200 simpleStatus r `shouldBe` ok200
it "limit works on all levels" $ it "limit works on all levels" $
get "/users?select=id,tasks{id}&order=id.asc&tasks.order=id.asc" get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
`shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|] `shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"] , matchHeaders = ["Content-Range" <:> "0-1/*"]
} }
it "limit is not applied to parent embeds" $ it "limit is not applied to parent embeds" $
get "/tasks?select=id,project{id}&id=gt.5" get "/tasks?select=id,project(id)&id=gt.5"
`shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|] `shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
{ matchStatus = 200 { matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-1/*"] , matchHeaders = ["Content-Range" <:> "0-1/*"]
+560 -254
View File
File diff suppressed because it is too large Load Diff
+38 -11
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"
@@ -149,14 +148,13 @@ spec = do
} }
it "limit works on all levels" $ it "limit works on all levels" $
get "/clients?select=id,projects{id,tasks{id}}&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1" get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"
`shouldRespondWith` `shouldRespondWith`
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|] [json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
{ matchStatus = 200 { matchStatus = 200
, 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] }
+223 -42
View File
@@ -1,20 +1,24 @@
module Feature.RpcSpec where module Feature.RpcSpec where
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.HTTP.Types
import Network.Wai.Test (SResponse(simpleStatus, simpleBody))
import qualified Data.ByteString.Lazy as BL (empty) import 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,31 @@ 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
get "/rpc/quotedFunction?user=mscott&fullName=Michael Scott&SSN=401-32-XXXX" `shouldRespondWith`
[json|{"user": "mscott", "fullName": "Michael Scott", "SSN": "401-32-XXXX"}|]
{ matchHeaders = [matchContentTypeJson] }
post "/rpc/quotedFunction"
[json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]
`shouldRespondWith`
[json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]
{ matchHeaders = [matchContentTypeJson] }
context "shaping the response returned by a proc" $ do context "shaping the response returned by a proc" $ do
it "returns a project" $ do it "returns a project" $ do
@@ -111,24 +140,24 @@ spec =
context "foreign entities embedding" $ do context "foreign entities embedding" $ do
it "can embed if related tables are in the exposed schema" $ do it "can embed if related tables are in the exposed schema" $ do
post "/rpc/getproject?select=id,name,client{id},tasks{id}" [json| { "id": 1} |] `shouldRespondWith` post "/rpc/getproject?select=id,name,client(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|] [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
get "/rpc/getproject?id=1&select=id,name,client{id},tasks{id}" `shouldRespondWith` get "/rpc/getproject?id=1&select=id,name,client(id),tasks(id)" `shouldRespondWith`
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|] [json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "cannot embed if the related table is not in the exposed schema" $ do it "cannot embed if the related table is not in the exposed schema" $ do
post "/rpc/single_article?select=*,article_stars{*}" [json|{ "id": 1}|] post "/rpc/single_article?select=*,article_stars(*)" [json|{ "id": 1}|]
`shouldRespondWith` 400 `shouldRespondWith` 400
get "/rpc/single_article?id=1&select=*,article_stars{*}" get "/rpc/single_article?id=1&select=*,article_stars(*)"
`shouldRespondWith` 400 `shouldRespondWith` 400
it "can embed if the related tables are in a hidden schema but exposed as views" $ do it "can embed if the related tables are in a hidden schema but exposed as views" $ do
post "/rpc/single_article?select=id,articleStars{userId}" [json|{ "id": 2}|] post "/rpc/single_article?select=id,articleStars(userId)" [json|{ "id": 2}|]
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|] `shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
get "/rpc/single_article?id=2&select=id,articleStars{userId}" get "/rpc/single_article?id=2&select=id,articleStars(userId)"
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|] `shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
@@ -203,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"
@@ -226,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
@@ -275,8 +350,14 @@ spec =
[json|[{"my_json":{"a": 1, "b": "two"},"num":3,"str":"four"}]|] { matchHeaders = [matchContentTypeJson] } [json|[{"my_json":{"a": 1, "b": "two"},"num":3,"str":"four"}]|] { matchHeaders = [matchContentTypeJson] }
it "returns a row result when there are many INOUT params" $ it "returns a row result when there are many INOUT params" $
get "/rpc/many_inout_params?num=1&str=two" `shouldRespondWith` get "/rpc/many_inout_params?num=1&str=two&b=false" `shouldRespondWith`
[json| [{"num":1,"str":"two","b":true}]|] { matchHeaders = [matchContentTypeJson] } [json| [{"num":1,"str":"two","b":false}]|] { matchHeaders = [matchContentTypeJson] }
it "can handle procs with args that have a DEFAULT value" $ do
get "/rpc/many_inout_params?num=1&str=two" `shouldRespondWith`
[json| [{"num":1,"str":"two","b":true}]|] { matchHeaders = [matchContentTypeJson] }
get "/rpc/three_defaults?b=4" `shouldRespondWith`
[json|8|] { matchHeaders = [matchContentTypeJson] }
it "can map a RAISE error code and message to a http status" $ it "can map a RAISE error code and message to a http status" $
get "/rpc/raise_pt402" get "/rpc/raise_pt402"
@@ -286,28 +367,126 @@ 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
it "does not expand posted json into parameters" $
request methodPost "/rpc/singlejsonparam"
[("prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
[json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
{ matchHeaders = [matchContentTypeJson] }
it "accepts parameters from an html form" $
request methodPost "/rpc/singlejsonparam"
[("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]
("integer=7&double=2.71828&varchar=forms+are+fun&" <>
"boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
[json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
, "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
{ matchHeaders = [matchContentTypeJson] }
it "works with GET" $
request methodGet "/rpc/singlejsonparam?p1=1&p2=text" [("Prefer","params=single-object")] ""
`shouldRespondWith` [json|{ "p1": "1", "p2": "text"}|]
{ matchHeaders = [matchContentTypeJson] }
it "should work with an overloaded function" $ do
get "/rpc/overloaded" `shouldRespondWith`
[json|[{ "overloaded": 1 },
{ "overloaded": 2 },
{ "overloaded": 3 }]|]
{ matchHeaders = [matchContentTypeJson] }
request methodPost "/rpc/overloaded" [("Prefer","params=single-object")]
[json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]
`shouldRespondWith`
[json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]
{ matchHeaders = [matchContentTypeJson] }
get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|]
get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [str|"123"|]
context "only for POST rpc" $ do context "only for POST rpc" $ do
context "expects a single json object" $ do
it "does not expand posted json into parameters" $
request methodPost "/rpc/singlejsonparam"
[("Prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
[json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
{ matchHeaders = [matchContentTypeJson] }
it "accepts parameters from an html form" $
request methodPost "/rpc/singlejsonparam"
[("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]
("integer=7&double=2.71828&varchar=forms+are+fun&" <>
"boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
[json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
, "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
{ matchHeaders = [matchContentTypeJson] }
it "gives a parse filter error if GET style proc args are specified" $ 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
@@ -336,10 +515,12 @@ 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] }
-- TODO: '@@' deprecated when (actualPgVersion >= pgVersion112) $
get "/rpc/get_tsearch?text_search_vector=@@(english).impossible" `shouldRespondWith` get "/rpc/get_tsearch?text_search_vector=wfts.impossible" `shouldRespondWith`
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|] [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
get "/rpc/get_tsearch?text_search_vector=not.@@(english).fun%7Crat" `shouldRespondWith`
[json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|] it "should work with an argument of custom type in public schema" $
get "/rpc/test_arg?my_arg=something" `shouldRespondWith`
[json|"foobar"|]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
+59 -51
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
@@ -37,13 +37,12 @@ spec =
`shouldRespondWith` [str|{"id":5}|] `shouldRespondWith` [str|{"id":5}|]
it "can shape plurality singular object routes" $ it "can shape plurality singular object routes" $
request methodGet "/projects_view?id=eq.1&select=id,name,clients{*},tasks{id,name}" [singular] "" request methodGet "/projects_view?id=eq.1&select=id,name,clients(*),tasks(id,name)" [singular] ""
`shouldRespondWith` `shouldRespondWith`
[json|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|] [json|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]
{ matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"] } { 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} |]
+289 -23
View File
@@ -1,22 +1,22 @@
module Feature.StructureSpec where module Feature.StructureSpec where
import Test.Hspec hiding (pendingWith) 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
@@ -29,12 +29,12 @@ spec = do
(acceptHdrs "application/openapi+json") "" (acceptHdrs "application/openapi+json") ""
`shouldRespondWith` 415 `shouldRespondWith` 415
it "includes postgrest.com current version api docs" $ do it "includes postgrest.org current version api docs" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
let docsUrl = r ^? key "externalDocs" . key "url" let docsUrl = r ^? key "externalDocs" . key "url"
liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.com/en/" <> docsVersion <> "/api.html")) liftIO $ docsUrl `shouldBe` Just (String ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))
describe "table" $ do describe "table" $ 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,12 +80,42 @@ 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"
deleteResponse `shouldBe` Just "No Content" deleteResponse `shouldBe` Just "No Content"
it "includes an array type for GET responses" $ do
r <- simpleBody <$> get "/"
let childGetSchema = r ^? key "paths"
. key "/child_entities"
. key "get"
. key "responses"
. key "200"
. key "schema"
liftIO $
childGetSchema `shouldBe` Just
[aesonQQ|
{
"items": {
"$ref": "#/definitions/child_entities"
},
"type": "array"
}
|]
it "includes definitions to tables" $ do it "includes definitions to tables" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
@@ -104,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"
}, },
@@ -113,7 +144,10 @@ spec = do
"format": "integer", "format": "integer",
"type": "integer" "type": "integer"
} }
} },
"required": [
"id"
]
} }
|] |]
@@ -131,15 +165,238 @@ spec = do
. nth 0 . nth 0
liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|] liftIO $ tableTag `shouldBe` Just [aesonQQ|"authors_only"|]
describe "RPC" $ do describe "Foreign table" $
it "includes body schema for arguments" $ do it "includes foreign table properties" $ do
r <- simpleBody <$> get "/" r <- simpleBody <$> get "/"
let args = r ^? key "paths" . key "/rpc/varied_arguments"
. key "post" . key "parameters" let method s = key "paths" . key "/projects_dump" . key s
. nth 0 . key "schema" getSummary = r ^? method "get" . key "summary"
getDescription = r ^? method "get" . key "description"
getParameters = r ^? method "get" . key "parameters"
liftIO $ do
getSummary `shouldBe` Just "A temporary projects dump"
getDescription `shouldBe` Just "Just a test for foreign tables"
getParameters `shouldBe` Just
[aesonQQ|
[
{ "$ref": "#/parameters/rowFilter.projects_dump.id" },
{ "$ref": "#/parameters/rowFilter.projects_dump.name" },
{ "$ref": "#/parameters/rowFilter.projects_dump.client_id" },
{ "$ref": "#/parameters/select" },
{ "$ref": "#/parameters/order" },
{ "$ref": "#/parameters/range" },
{ "$ref": "#/parameters/rangeUnit" },
{ "$ref": "#/parameters/offset" },
{ "$ref": "#/parameters/limit" },
{ "$ref": "#/parameters/preferCount" }
]
|]
describe "Materialized view" $
it "includes materialized view properties" $ do
r <- simpleBody <$> get "/"
let method s = key "paths" . key "/materialized_projects" . key s
summary = r ^? method "get" . key "summary"
description = r ^? method "get" . key "description"
parameters = r ^? method "get" . key "parameters"
liftIO $ do
summary `shouldBe` Just "A materialized view for projects"
description `shouldBe` Just "Just a test for materialized views"
parameters `shouldBe` Just
[aesonQQ|
[
{ "$ref": "#/parameters/rowFilter.materialized_projects.id" },
{ "$ref": "#/parameters/rowFilter.materialized_projects.name" },
{ "$ref": "#/parameters/rowFilter.materialized_projects.client_id" },
{ "$ref": "#/parameters/select" },
{ "$ref": "#/parameters/order" },
{ "$ref": "#/parameters/range" },
{ "$ref": "#/parameters/rangeUnit" },
{ "$ref": "#/parameters/offset" },
{ "$ref": "#/parameters/limit" },
{ "$ref": "#/parameters/preferCount" }
]
|]
describe "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 $ liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "character varying",
"type": "string"
}
|]
it "character(1) to string" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"maxLength": 1,
"format": "character",
"type": "string"
}
|]
it "text to string" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_text"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "text",
"type": "string"
}
|]
it "boolean to boolean" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_boolean"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "boolean",
"type": "boolean"
}
|]
it "smallint to integer" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_smallint"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "smallint",
"type": "integer"
}
|]
it "integer to integer" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_integer"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "integer",
"type": "integer"
}
|]
it "bigint to integer" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bigint"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "bigint",
"type": "integer"
}
|]
it "numeric to number" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_numeric"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "numeric",
"type": "number"
}
|]
it "real to number" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_real"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "real",
"type": "number"
}
|]
it "double_precision to number" $ do
r <- simpleBody <$> get "/"
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_double_precision"
liftIO $
types `shouldBe` Just
[aesonQQ|
{
"format": "double precision",
"type": "number"
}
|]
describe "RPC" $ do
it "includes function summary/description and body schema for arguments" $ do
r <- simpleBody <$> get "/"
let method s = key "paths" . key "/rpc/varied_arguments" . key s
args = r ^? method "post" . key "parameters" . nth 0 . key "schema"
summary = r ^? method "post" . key "summary"
description = r ^? method "post" . key "description"
liftIO $ do
summary `shouldBe` Just "An RPC function"
description `shouldBe` Just "Just a test for RPC function arguments"
args `shouldBe` Just args `shouldBe` Just
[aesonQQ| [aesonQQ|
{ {
@@ -154,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",
@@ -173,15 +430,24 @@ spec = do
"type": "string" "type": "string"
}, },
"enum": { "enum": {
"format": "test.enum_menagerie_type", "format": "enum_menagerie_type",
"type": "string" "type": "string"
}, },
"integer": { "integer": {
"format": "integer", "format": "integer",
"type": "integer" "type": "integer"
},
"json": {
"format": "json",
"type": "string"
},
"jsonb": {
"format": "jsonb",
"type": "string"
} }
}, },
"type": "object" "type": "object",
"description": "An RPC function\n\nJust a test for RPC function arguments"
} }
|] |]
+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" $
+281
View File
@@ -0,0 +1,281 @@
module Feature.UpsertSpec where
import Network.Wai (Application)
import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Text.Heredoc
import Protolude hiding (get, put)
import SpecHelper
spec :: SpecWith Application
spec =
describe "UPSERT" $ do
context "with POST" $ do
context "when Prefer: resolution=merge-duplicates is specified" $ do
it "INSERTs and UPDATEs rows on pk conflict" $
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "name": "Javascript", "rank": 6 },
{ "name": "Java", "rank": 2 },
{ "name": "C", "rank": 1 }
]|] `shouldRespondWith` [json| [
{ "name": "Javascript", "rank": 6 },
{ "name": "Java", "rank": 2 },
{ "name": "C", "rank": 1 }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
}
it "INSERTs and UPDATEs row on composite pk conflict" $
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "first_name": "Frances M.", "last_name": "Roe", "salary": "30000" },
{ "first_name": "Peter S.", "last_name": "Yang", "salary": 42000 }
]|] `shouldRespondWith` [json| [
{ "first_name": "Frances M.", "last_name": "Roe", "salary": "$30,000.00", "company": "One-Up Realty", "occupation": "Author" },
{ "first_name": "Peter S.", "last_name": "Yang", "salary": "$42,000.00", "company": null, "occupation": null }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
}
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
it "INSERTs and ignores rows on pk conflict" $
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[
{ "name": "PHP", "rank": 9 },
{ "name": "Python", "rank": 10 }
]|] `shouldRespondWith` [json|[
{ "name": "PHP", "rank": 9 }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
}
it "INSERTs and ignores rows on composite pk conflict" $
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[
{ "first_name": "Daniel B.", "last_name": "Lyon", "salary": "72000", "company": null, "occupation": null },
{ "first_name": "Sara M.", "last_name": "Torpey", "salary": 60000, "company": "Burstein-Applebee", "occupation": "Soil scientist" }
]|] `shouldRespondWith` [json|[
{ "first_name": "Sara M.", "last_name": "Torpey", "salary": "$60,000.00", "company": "Burstein-Applebee", "occupation": "Soil scientist" }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
}
it "succeeds if the table has only PK cols and no other cols" $ do
request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[ { "id": 1 }, { "id": 2 }, { "id": 3} ]|]
`shouldRespondWith`
[json|[ { "id": 3} ]|]
{ matchStatus = 201 ,
matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates",
matchContentTypeJson] }
request methodPost "/only_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
`shouldRespondWith`
[json|[ { "id": 1 }, { "id": 2 }, { "id": 4} ]|]
{ matchStatus = 201 ,
matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates",
matchContentTypeJson] }
it "succeeds and ignores the Prefer: resolution header(no Preference-Applied present) if the table has no PK" $
request methodPost "/no_pk" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json|[ { "a": "1", "b": "0" } ]|]
`shouldRespondWith`
[json|[ { "a": "1", "b": "0" } ]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
it "succeeds if not a single resource is created" $ do
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[ { "name": "Java", "rank": 1 } ]|] `shouldRespondWith`
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json|[ { "name": "Java", "rank": 1 }, { "name": "C", "rank": 2 } ]|] `shouldRespondWith`
[json|[]|] { matchStatus = 201 , matchHeaders = [matchContentTypeJson] }
context "with PUT" $ do
context "Restrictions" $ do
it "fails if Range is specified" $
request methodPut "/tiobe_pls?name=eq.Javascript" [("Range", "0-5")]
[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" $
put "/tiobe_pls?name=eq.Javascript&limit=1"
[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" $
put "/tiobe_pls?name=eq.Javascript&offset=1"
[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" $
put "/tiobe_pls?name=eq.Go"
[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
put "/tiobe_pls?name=eq.Go"
[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"
[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
put "/tiobe_pls?rank=eq.19"
[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=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
put "/employees?first_name=eq.Susan"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
put "/employees?last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
it "fails if the uri primary key doesn't match the payload primary key" $ do
put "/tiobe_pls?name=eq.MATLAB" [str| [ { "name": "Perl", "rank": 17 } ]|]
`shouldRespondWith`
[json|{"message":"Payload values do not match URL in primary key column(s)"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
put "/employees?first_name=eq.Wendy&last_name=eq.Anderson"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith`
[json|{"message":"Payload values do not match URL in primary key column(s)"}|]
{ matchStatus = 400 , matchHeaders = [matchContentTypeJson] }
it "fails if the table has no PK" $
put "/no_pk?a=eq.one&b=eq.two" [str| [ { "a": "one", "b": "two" } ]|]
`shouldRespondWith`
[json|{"message":"Filters must include all and only primary key columns with 'eq' operators"}|]
{ matchStatus = 405 , matchHeaders = [matchContentTypeJson] }
context "Inserting row" $ do
it "succeeds on table with single pk col" $ do
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` "[]"
put "/tiobe_pls?name=eq.Go" [str| [ { "name": "Go", "rank": 19 } ]|] `shouldRespondWith` 204
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json| [ { "name": "Go", "rank": 19 } ]|] { matchHeaders = [matchContentTypeJson] }
it "succeeds on table with composite pk" $ do
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
`shouldRespondWith` "[]"
put "/employees?first_name=eq.Susan&last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` 204
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
`shouldRespondWith`
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
{ matchHeaders = [matchContentTypeJson] }
it "succeeds if the table has only PK cols and no other cols" $ do
get "/only_pk?id=eq.10" `shouldRespondWith` "[]"
put "/only_pk?id=eq.10" [str|[ { "id": 10 } ]|] `shouldRespondWith` 204
get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }
context "Updating row" $ do
it "succeeds on table with single pk col" $ do
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json|[ { "name": "Go", "rank": 19 } ]|] { matchHeaders = [matchContentTypeJson] }
put "/tiobe_pls?name=eq.Go" [str| [ { "name": "Go", "rank": 13 } ]|] `shouldRespondWith` 204
get "/tiobe_pls?name=eq.Go" `shouldRespondWith` [json| [ { "name": "Go", "rank": 13 } ]|] { matchHeaders = [matchContentTypeJson] }
it "succeeds on table with composite pk" $ do
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
`shouldRespondWith`
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
{ matchHeaders = [matchContentTypeJson] }
put "/employees?first_name=eq.Susan&last_name=eq.Heidt"
[str| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "60000", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
`shouldRespondWith` 204
get "/employees?first_name=eq.Susan&last_name=eq.Heidt"
`shouldRespondWith`
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$60,000.00", "company": "Gamma Gas", "occupation": "Railroad engineer" } ]|]
{ matchHeaders = [matchContentTypeJson] }
it "succeeds if the table has only PK cols and no other cols" $ do
get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }
put "/only_pk?id=eq.10" [str|[ { "id": 10 } ]|] `shouldRespondWith` 204
get "/only_pk?id=eq.10" `shouldRespondWith` [json|[ { "id": 10 } ]|] { matchHeaders = [matchContentTypeJson] }
it "works with return=representation and vnd.pgrst.object+json" $
request methodPut "/tiobe_pls?name=eq.Ruby"
[("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
[str| [ { "name": "Ruby", "rank": 11 } ]|]
`shouldRespondWith` [json|{ "name": "Ruby", "rank": 11 }|] { matchHeaders = [matchContentTypeSingular] }
context "with a camel case pk column" $ do
it "works with POST and merge-duplicates/ignore-duplicates headers" $ do
request methodPost "/UnitTest" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
[json| [
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
]|] `shouldRespondWith` [json|[
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates", matchContentTypeJson]
}
request methodPost "/UnitTest" [("Prefer", "return=representation"), ("Prefer", "resolution=ignore-duplicates")]
[json| [
{ "idUnitTest": 1, "nameUnitTest": "name of unittest 1" },
{ "idUnitTest": 2, "nameUnitTest": "name of unittest 2" }
]|] `shouldRespondWith` [json|[]|]
{ matchStatus = 201
, matchHeaders = ["Preference-Applied" <:> "resolution=ignore-duplicates", matchContentTypeJson]
}
it "works with PUT" $ do
put "/UnitTest?idUnitTest=eq.1" [str| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] `shouldRespondWith` 204
get "/UnitTest?idUnitTest=eq.1" `shouldRespondWith`
[json| [ { "idUnitTest": 1, "nameUnitTest": "unit test 1" } ]|] { matchHeaders = [matchContentTypeJson] }
+82 -40
View File
@@ -1,39 +1,51 @@
module Main where module Main where
import qualified Hasql.Pool as P
import qualified Hasql.Transaction.Sessions as HT
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
updateAction)
import Data.Function (id)
import Data.Time.Clock (getCurrentTime)
import Data.IORef
import Test.Hspec import Test.Hspec
import PostgREST.App (postgrest)
import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.Types (DbStructure (..), pgVersion95,
pgVersion96)
import Protolude
import SpecHelper import SpecHelper
import qualified Hasql.Pool as P import qualified Feature.AndOrParamsSpec
import PostgREST.App (postgrest)
import PostgREST.Config (pgVersion96)
import PostgREST.DbStructure (getDbStructure, getPgVersion)
import PostgREST.Types (DbStructure(..))
import Data.Function (id)
import Data.IORef
import qualified Feature.AuthSpec
import qualified Feature.AsymmetricJwtSpec import qualified Feature.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.HtmlRawOutputSpec
import qualified Feature.InsertSpec import qualified Feature.InsertSpec
import qualified Feature.JsonOperatorSpec
import qualified Feature.NoJwtSpec import qualified Feature.NoJwtSpec
import qualified Feature.NonexistentSchemaSpec
import qualified Feature.PgVersion95Spec
import qualified Feature.PgVersion96Spec
import qualified Feature.ProxySpec
import qualified Feature.QueryLimitedSpec import qualified Feature.QueryLimitedSpec
import qualified Feature.QuerySpec import qualified Feature.QuerySpec
import qualified Feature.RangeSpec import qualified Feature.RangeSpec
import qualified Feature.StructureSpec import qualified Feature.RawOutputTypesSpec
import qualified Feature.SingularSpec import qualified Feature.RootSpec
import qualified Feature.UnicodeSpec
import qualified Feature.ProxySpec
import qualified Feature.AndOrParamsSpec
import qualified Feature.RpcSpec import qualified Feature.RpcSpec
import qualified Feature.NonexistentSchemaSpec import qualified Feature.SingularSpec
import qualified Feature.PgVersion96Spec import qualified Feature.StructureSpec
import qualified Feature.UnicodeSpec
import qualified Feature.UpsertSpec
import Protolude
main :: IO () main :: IO ()
main = do main = do
@@ -42,45 +54,62 @@ main = do
pool <- P.acquire (3, 10, toS testDbConn) pool <- P.acquire (3, 10, toS testDbConn)
result <- P.use pool $ getDbStructure "test" =<< getPgVersion result <- P.use pool $ do
ver <- getPgVersion
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 }
refDbStructure <- newIORef $ Just dbStructure refDbStructure <- newIORef $ Just dbStructure
let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool $ pure () let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool getTime $ pure ()
ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool $ pure () ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime $ pure ()
unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool $ pure () unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime $ pure ()
proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool $ pure () proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime $ pure ()
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool $ pure () noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime $ pure ()
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool $ pure () binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime $ pure ()
audJwtApp = return $ postgrest (testCfgAudienceJWT testDbConn) refDbStructure pool $ pure () audJwtApp = return $ postgrest (testCfgAudienceJWT testDbConn) refDbStructure pool getTime $ pure ()
asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool $ pure () asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool getTime $ pure ()
nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool $ pure () asymJwkSetApp = return $ postgrest (testCfgAsymJWKSet testDbConn) refDbStructure pool getTime $ pure ()
nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg 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 ()
reset = resetDb testDbConn
let reset = resetDb testDbConn
actualPgVersion = pgVersion dbStructure actualPgVersion = pgVersion dbStructure
pg96spec | actualPgVersion >= pgVersion96 = [("Feature.PgVersion96Spec" , Feature.PgVersion96Spec.spec)] extraSpecs =
| otherwise = [] [("Feature.UpsertSpec", Feature.UpsertSpec.spec) | actualPgVersion >= pgVersion95] ++
[("Feature.PgVersion95Spec", Feature.PgVersion95Spec.spec) | actualPgVersion >= pgVersion95] ++
[("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.QuerySpec" , Feature.QuerySpec.spec) , ("Feature.JsonOperatorSpec" , Feature.JsonOperatorSpec.spec actualPgVersion)
, ("Feature.RpcSpec" , Feature.RpcSpec.spec) , ("Feature.QuerySpec" , Feature.QuerySpec.spec actualPgVersion)
, ("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)
, ("Feature.NonexistentSchemaSpec" , Feature.NonexistentSchemaSpec.spec) ] ++ extraSpecs
] ++ pg96spec
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
@@ -109,6 +138,19 @@ main = do
beforeAll_ reset . before asymJwkApp $ beforeAll_ reset . before asymJwkApp $
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-- this test runs with asymmetric JWKSet
beforeAll_ reset . before asymJwkSetApp $
describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec
-- this test runs with a nonexistent db-schema -- this test runs with a nonexistent db-schema
beforeAll_ reset . before nonexistentSchemaApp $ beforeAll_ reset . before nonexistentSchemaApp $
describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec describe "Feature.NonexistentSchemaSpec" Feature.NonexistentSchemaSpec.spec
-- this test runs with an extra search path
beforeAll_ reset . before extraSearchPathApp $
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
+66 -40
View File
@@ -1,33 +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 Test.Hspec hiding (pendingWith)
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
@@ -47,32 +43,46 @@ 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 = do getEnvVarWithDefault var def = toS <$>
varValue <- getEnv (toS var) `E.catchIOError` const (return $ toS def) getEnv (toS var) `E.catchIOError` const (return $ toS def)
return $ toS varValue
_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.external_api_secret", "0123456789abcdef")
]
-- Default role claim key
(Right [JSPKey "role"])
-- Empty db-extra-search-path
[]
-- No root spec override
Nothing
-- Raw output media types
[]
testCfg :: Text -> AppConfig testCfg :: Text -> AppConfig
testCfg testDbConn = _baseCfg { configDatabase = testDbConn } testCfg testDbConn = _baseCfg { configDatabase = testDbConn }
@@ -108,15 +118,31 @@ testCfgAsymJWK testDbConn = (testCfg testDbConn) {
[str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|] [str|{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}|]
} }
testCfgAsymJWKSet :: Text -> AppConfig
testCfgAsymJWKSet testDbConn = (testCfg testDbConn) {
configJwtSecret = Just $ encodeUtf8
[str|{"keys": [{"alg":"RS256","e":"AQAB","key_ops":["verify"],"kty":"RSA","n":"0etQ2Tg187jb04MWfpuogYGV75IFrQQBxQaGH75eq_FpbkyoLcEpRUEWSbECP2eeFya2yZ9vIO5ScD-lPmovePk4Aa4SzZ8jdjhmAbNykleRPCxMg0481kz6PQhnHRUv3nF5WP479CnObJKqTVdEagVL66oxnX9VhZG9IZA7k0Th5PfKQwrKGyUeTGczpOjaPqbxlunP73j9AfnAt4XCS8epa-n3WGz1j-wfpr_ys57Aq-zBCfqP67UYzNpeI1AoXsJhD9xSDOzvJgFRvc3vm2wjAW4LEMwi48rCplamOpZToIHEPIaPzpveYQwDnB1HFTR1ove9bpKJsHmi-e2uzQ","use":"sig"}]}|]
}
testNonexistentSchemaCfg :: Text -> AppConfig testNonexistentSchemaCfg :: Text -> AppConfig
testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" } testNonexistentSchemaCfg testDbConn = (testCfg testDbConn) { configSchema = "nonexistent" }
testCfgExtraSearchPath :: Text -> AppConfig
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
@@ -125,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)]
@@ -160,5 +186,5 @@ isErrorFormat s =
S.null (S.difference keys validKeys) S.null (S.difference keys validKeys)
where where
obj = decode s :: Maybe (M.Map Text Value) obj = decode s :: Maybe (M.Map Text Value)
keys = fromMaybe S.empty (M.keysSet <$> obj) keys = maybe S.empty M.keysSet obj
validKeys = S.fromList ["message", "details", "hint", "code"] validKeys = S.fromList ["message", "details", "hint", "code"]
+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)
+22 -13
View File
@@ -1,19 +1,23 @@
#! /bin/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
+1 -1
View File
@@ -1,4 +1,4 @@
#! /bin/bash #! /usr/bin/env bash
if [ -z "$1" ] if [ -z "$1" ]
then then
echo "Please supply the connection uri for the user with create database privileges" echo "Please supply the connection uri for the user with create database privileges"
+166 -4
View File
@@ -250,7 +250,7 @@ INSERT INTO tsearch VALUES (to_tsvector('It''s kind of fun to do the impossible'
INSERT INTO tsearch VALUES (to_tsvector('But also fun to do what is possible')); INSERT INTO tsearch VALUES (to_tsvector('But also fun to do what is possible'));
INSERT INTO tsearch VALUES (to_tsvector('Fat cats ate rats')); INSERT INTO tsearch VALUES (to_tsvector('Fat cats ate rats'));
INSERT INTO tsearch VALUES (to_tsvector('french', 'C''est un peu amusant de faire l''impossible')); INSERT INTO tsearch VALUES (to_tsvector('french', 'C''est un peu amusant de faire l''impossible'));
INSERT INTO tsearch VALUES (to_tsvector('german', 'Es ist eine Art Spaß, das Unmögliche zu machen')); INSERT INTO tsearch VALUES (to_tsvector('german', 'Es ist eine Art Spaß, das Unmögliche zu machen'));
-- --
-- Data for Name: users_projects; Type: TABLE DATA; Schema: test; Owner: - -- Data for Name: users_projects; Type: TABLE DATA; Schema: test; Owner: -
@@ -334,6 +334,168 @@ INSERT INTO part VALUES (1), (2), (3), (4);
TRUNCATE TABLE being_part CASCADE; TRUNCATE TABLE being_part CASCADE;
INSERT INTO being_part VALUES (1,1), (2,1), (3,2), (4,3); INSERT INTO being_part VALUES (1,1), (2,1), (3,2), (4,3);
--
-- PostgreSQL database dump complete TRUNCATE TABLE employees CASCADE;
-- INSERT INTO employees VALUES
('Frances M.', 'Roe', '24000', 'One-Up Realty', 'Author'),
('Daniel B.', 'Lyon', '36000', 'Dubrow''s Cafeteria', 'Packer'),
('Edwin S.', 'Smith', '48000', 'Pro Garden Management', 'Marine biologist');
TRUNCATE TABLE tiobe_pls CASCADE;
INSERT INTO tiobe_pls VALUES ('Java', 1), ('C', 2), ('Python', 4);
TRUNCATE TABLE only_pk CASCADE;
INSERT INTO only_pk VALUES (1), (2);
TRUNCATE TABLE family_tree CASCADE;
INSERT INTO family_tree VALUES ('1', 'Parental Unit', NULL);
INSERT INTO family_tree VALUES ('2', 'Kid One', '1');
INSERT INTO family_tree VALUES ('3', 'Kid Two', '1');
INSERT INTO family_tree VALUES ('4', 'Grandkid One', '2');
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;
INSERT INTO organizations VALUES (1, 'Referee Org', null, null, 1);
INSERT INTO organizations VALUES (2, 'Auditor Org', null, null, 2);
INSERT INTO organizations VALUES (3, 'Acme', 1, 2, 3);
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;
TRUNCATE TABLE authors CASCADE;
INSERT INTO authors VALUES (1, 'George Orwell');
INSERT INTO authors VALUES (2, 'Anne Frank');
INSERT INTO authors VALUES (3, 'Antoine de Saint-Exupéry');
INSERT INTO authors VALUES (4, 'J.D. Salinger');
INSERT INTO authors VALUES (5, 'Ray Bradbury');
INSERT INTO authors VALUES (6, 'William Golding');
INSERT INTO authors VALUES (7, 'Harper Lee');
INSERT INTO authors VALUES (8, 'Kurt Vonnegut');
INSERT INTO authors VALUES (9, 'Ken Kesey');
TRUNCATE TABLE publishers CASCADE;
INSERT INTO publishers VALUES (1, 'Secker & Warburg');
INSERT INTO publishers VALUES (2, 'Contact Publishing');
INSERT INTO publishers VALUES (3, 'Reynal & Hitchcock');
INSERT INTO publishers VALUES (4, 'Little, Brown and Company');
INSERT INTO publishers VALUES (5, 'Ballantine Books');
INSERT INTO publishers VALUES (6, 'Faber and Faber');
INSERT INTO publishers VALUES (7, 'J. B. Lippincott & Co.');
INSERT INTO publishers VALUES (8, 'Delacorte');
INSERT INTO publishers VALUES (9, 'Viking Press & Signet Books');
TRUNCATE TABLE books CASCADE;
INSERT INTO books VALUES (1, '1984', 1949, 1, 1);
INSERT INTO books VALUES (2, 'The Diary of a Young Girl', 1947, 2, 2);
INSERT INTO books VALUES (3, 'The Little Prince', 1947, 3, 3);
INSERT INTO books VALUES (4, 'The Catcher in the Rye', 1951, 4, 4);
INSERT INTO books VALUES (5, 'Farenheit 451', 1953, 5, 5);
INSERT INTO books VALUES (6, 'Lord of the Flies', 1954, 6, 6);
INSERT INTO books VALUES (7, 'To Kill a Mockingbird', 1960, 7, 7);
INSERT INTO books VALUES (8, 'Slaughterhouse-Five', 1969, 8, 8);
INSERT INTO books VALUES (9, 'One Flew Over the Cuckoo''s Nest', 1962, 9, 9);
SET search_path = test, pg_catalog;
TRUNCATE TABLE person CASCADE;
INSERT INTO person VALUES (1, 'John');
INSERT INTO person VALUES (2, 'Jane');
INSERT INTO person VALUES (3, 'Jake');
INSERT INTO person VALUES (4, 'Julie');
TRUNCATE TABLE message CASCADE;
INSERT INTO message VALUES (1, 'Hello Jane', 1, 2);
INSERT INTO message VALUES (2, 'Hi John', 2, 1);
INSERT INTO message VALUES (3, 'How are you doing?', 1, 2);
INSERT INTO message VALUES (4, 'Hey Julie', 3, 4);
INSERT INTO message VALUES (5, 'What''s up Jake', 4, 3);
TRUNCATE TABLE space CASCADE;
INSERT INTO space VALUES (1, 'space 1');
TRUNCATE TABLE zone CASCADE;
INSERT INTO zone VALUES (1, 'zone 1', 2, 1);
INSERT INTO zone VALUES (2, 'zone 2', 2, 1);
INSERT INTO zone VALUES (3, 'store 3', 3, 1);
INSERT INTO zone VALUES (4, 'store 4', 3, 1);
-- for foreign table projects_dump
copy (select id, name, client_id from projects) to '/tmp/projects_dump.csv' with csv;
TRUNCATE TABLE "UnitTest" CASCADE;
INSERT INTO "UnitTest" VALUES (1, 'unit test 1');
TRUNCATE TABLE json_arr CASCADE;
INSERT INTO json_arr VALUES (1, '[1, 2, 3]');
INSERT INTO json_arr VALUES (2, '[4, 5, 6]');
INSERT INTO json_arr VALUES (3, '[[9, 8, 7], [11, 12, 13]]');
INSERT INTO json_arr VALUES (4, '[[[5, 6], 7, 8]]');
INSERT INTO json_arr VALUES (5, '[{"a": "A"}, {"b": "B"}]');
INSERT INTO json_arr VALUES (6, '[{"a": [1,2,3]}, {"b": [4,5]}]');
INSERT INTO json_arr VALUES (7, '{"c": [1,2,3], "d": [4,5]}');
INSERT INTO json_arr VALUES (8, '{"c": [{"d": [4,5,6,7,8]}]}');
INSERT INTO json_arr VALUES (9, '[{"0xy1": [1,{"23-xy-45": [2, {"xy-6": [3]}]}]}]');
TRUNCATE TABLE jsonb_test CASCADE;
INSERT INTO jsonb_test VALUES (1, '{ "a": {"b": 2} }');
INSERT INTO jsonb_test VALUES (2, '{ "c": [1,2,3] }');
INSERT INTO jsonb_test VALUES (3, '[{ "d": "test" }]');
INSERT INTO jsonb_test VALUES (4, '{ "e": 1 }');
TRUNCATE TABLE private.player CASCADE;
INSERT into private.player
SELECT
generate_series,
'first_name_' || generate_series,
'last_name_' || generate_series,
'2018-10-11'
FROM generate_series(1, 12);
TRUNCATE TABLE contract CASCADE;
insert into contract
select
'tournament_' || generate_series,
tsrange(now()::timestamp, null),
10*generate_series,
generate_series,
'first_name_' || generate_series,
'last_name_' || generate_series,
'2018-10-11'
from generate_series(1, 6);
TRUNCATE TABLE ltree_sample CASCADE;
INSERT INTO ltree_sample VALUES ('Top');
INSERT INTO ltree_sample VALUES ('Top.Science');
INSERT INTO ltree_sample VALUES ('Top.Science.Astronomy');
INSERT INTO ltree_sample VALUES ('Top.Science.Astronomy.Astrophysics');
INSERT INTO ltree_sample VALUES ('Top.Science.Astronomy.Cosmology');
TRUNCATE TABLE isn_sample CASCADE;
INSERT INTO isn_sample VALUES ('978-0-393-04002-9', 'Mathematics: From the Birth of Numbers');
TRUNCATE TABLE "Server Today" CASCADE;
COPY "Server Today" ("cHostname", "Just A Server Model") FROM STDIN CSV DELIMITER '|';
argnim1 | IBM,9113-550 (P5-550)
argnim2 | IBM,9113-550 (P5-550)
daaa2nim71 | IBM,9131-52A (P5-52A)
daah3nim71 | IBM,8406-71Y (P7-PS701)
hbnim1 | IBM,9133-55A (P5-55A)
\.
TRUNCATE TABLE pgrst_reserved_chars CASCADE;
COPY pgrst_reserved_chars ("*id*", ":arr->ow::cast", "(inside,parens)", "a.dotted.column", " col w space ") FROM STDIN CSV DELIMITER '|';
1 | arrow-1 | parens-1 | dotted-1 | space-1
2 | arrow-2 | parens-2 | dotted-2 | space-2
3 | arrow-3 | parens-3 | dotted-3 | space-3
\.
+1 -1
View File
@@ -1,3 +1,3 @@
set client_min_messages to warning; set client_min_messages to warning;
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست CASCADE; DROP SCHEMA IF EXISTS test, private, postgrest, jwt, public, تست, extensions CASCADE;
DROP TYPE IF EXISTS jwt_token CASCADE; DROP TYPE IF EXISTS jwt_token CASCADE;
+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;
+1 -2
View File
@@ -4,7 +4,6 @@ set client_min_messages to warning;
DROP SCHEMA IF EXISTS jwt CASCADE; DROP SCHEMA IF EXISTS jwt CASCADE;
CREATE SCHEMA jwt; CREATE SCHEMA jwt;
CREATE OR REPLACE FUNCTION jwt.url_encode(data bytea) RETURNS text LANGUAGE sql AS $$ CREATE OR REPLACE FUNCTION jwt.url_encode(data bytea) RETURNS text LANGUAGE sql AS $$
SELECT translate(encode(data, 'base64'), E'+/=\n', '-_'); SELECT translate(encode(data, 'base64'), E'+/=\n', '-_');
$$; $$;
@@ -31,7 +30,7 @@ WITH
WHEN algorithm = 'HS384' THEN 'sha384' WHEN algorithm = 'HS384' THEN 'sha384'
WHEN algorithm = 'HS512' THEN 'sha512' WHEN algorithm = 'HS512' THEN 'sha512'
ELSE '' END) -- hmac throws error ELSE '' END) -- hmac throws error
SELECT jwt.url_encode(hmac(signables, secret, (select * FROM alg))); SELECT jwt.url_encode(public.hmac(signables, secret, (select * FROM alg)));
$$; $$;
+43
View File
@@ -5,6 +5,7 @@ GRANT USAGE ON SCHEMA
, jwt , jwt
, public , public
, "تست" , "تست"
, extensions
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
-- Schema test objects -- Schema test objects
@@ -61,6 +62,45 @@ GRANT ALL ON TABLE
, descendant , descendant
, being_part , being_part
, part , part
, leak
, perf_articles
, employees
, tiobe_pls
, only_pk
, family_tree
, managers
, organizations
, authors
, books
, forties_books
, fifties_books
, sixties_books
, person
, message
, person_detail
, space
, zone
, projects_dump
, "UnitTest"
, json_arr
, jsonb_test
, authors_books_number
, authors_have_book_in_decade
, forties_and_fifties_books
, odd_years_publications
, foos
, bars
, materialized_projects
, contract
, player_view
, contract_view
, ltree_sample
, isn_sample
, projects_count_grouped_by
, "Server Today"
, 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;
@@ -69,6 +109,7 @@ GRANT USAGE ON SEQUENCE
auto_incrementing_pk_id_seq auto_incrementing_pk_id_seq
, items_id_seq , items_id_seq
, callcounter_count , callcounter_count
, leak_id_seq
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
-- Privileges for non anonymous users -- Privileges for non anonymous users
@@ -81,3 +122,5 @@ GRANT UPDATE (article_id, user_id) ON TABLE limited_article_stars TO postgrest_t
REVOKE EXECUTE ON FUNCTION privileged_hello(text) FROM PUBLIC; -- All functions are available to every role(PUBLIC) by default REVOKE EXECUTE ON FUNCTION privileged_hello(text) FROM PUBLIC; -- All functions are available to every role(PUBLIC) by default
GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author; GRANT EXECUTE ON FUNCTION privileged_hello(text) TO postgrest_test_author;
GRANT USAGE ON SCHEMA test TO postgrest_test_default_role;
+453 -58
View File
@@ -17,7 +17,7 @@ CREATE SCHEMA postgrest;
CREATE SCHEMA private; CREATE SCHEMA private;
CREATE SCHEMA test; CREATE SCHEMA test;
CREATE SCHEMA تست; CREATE SCHEMA تست;
CREATE SCHEMA extensions;
-- --
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: - -- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: -
@@ -112,16 +112,24 @@ CREATE TABLE items (
); );
SET search_path = public, pg_catalog;
--
-- Name: always_true(test.items); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION always_true(test.items) RETURNS boolean 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
LANGUAGE sql STABLE
AS $_$ SELECT $1.id * -1 $_$;
SET search_path = public, pg_catalog;
CREATE FUNCTION always_false(test.items) RETURNS boolean
LANGUAGE sql STABLE
AS $$ SELECT false $$;
create table public_consumers ( create table public_consumers (
id serial not null unique, id serial not null unique,
@@ -136,16 +144,6 @@ create table public_orders (
primary key (id) primary key (id)
); );
--
-- Name: anti_id(test.items); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION anti_id(test.items) RETURNS bigint
LANGUAGE sql STABLE
AS $_$ SELECT $1.id * -1 $_$;
SET search_path = تست, pg_catalog; SET search_path = تست, pg_catalog;
CREATE TABLE موارد ( CREATE TABLE موارد (
@@ -183,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: -
-- --
@@ -222,13 +206,27 @@ 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, json, jsonb) IS
$_$An RPC function
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: -
@@ -638,6 +636,9 @@ CREATE TABLE no_pk (
b character varying b character varying
); );
CREATE TABLE only_pk (
id integer primary key
);
-- --
-- Name: nullable_integer; Type: TABLE; Schema: test; Owner: - -- Name: nullable_integer; Type: TABLE; 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 (
@@ -1124,17 +1118,17 @@ create view images_base64 as (
select name, replace(encode(img, 'base64'), E'\n', '') as img from images select name, replace(encode(img, 'base64'), E'\n', '') as img from images
); );
create function test.ret_enum(val text) returns test.enum_menagerie_type as $$ create function test.ret_enum(val text) returns test.enum_menagerie_type as $$
select val::test.enum_menagerie_type; select val::test.enum_menagerie_type;
$$ language sql; $$ language sql;
create domain one_nine as integer check (value >= 1 and value <= 9); create domain one_nine as integer check (value >= 1 and value <= 9);
create function test.ret_array() returns integer[] as $$ create function test.ret_array() returns integer[] as $$
select '{1,2,3}'::integer[]; select '{1,2,3}'::integer[];
$$ language sql; $$ language sql;
create function test.ret_domain(val integer) returns test.one_nine as $$ create function test.ret_domain(val integer) returns test.one_nine as $$
select val::test.one_nine; select val::test.one_nine;
$$ language sql; $$ language sql;
@@ -1148,20 +1142,20 @@ $$ language sql;
create function test.ret_scalars() returns table( create function test.ret_scalars() returns table(
a text, b test.enum_menagerie_type, c test.one_nine, d int4range a text, b test.enum_menagerie_type, c test.one_nine, d int4range
) as $$ ) as $$
select row('scalars'::text, enum_first(null::test.enum_menagerie_type), select row('scalars'::text, enum_first(null::test.enum_menagerie_type),
1::test.one_nine, int4range(10, 20)); 1::test.one_nine, int4range(10, 20));
$$ language sql; $$ language sql;
create type test.point_2d as (x integer, y integer); create type test.point_2d as (x integer, y integer);
create function test.ret_point_2d() returns test.point_2d as $$ create function test.ret_point_2d() returns test.point_2d as $$
select row(10, 5)::test.point_2d; select row(10, 5)::test.point_2d;
$$ language sql; $$ language sql;
create type private.point_3d as (x integer, y integer, z integer); create type private.point_3d as (x integer, y integer, z integer);
create function test.ret_point_3d() returns private.point_3d as $$ create function test.ret_point_3d() returns private.point_3d as $$
select row(7, -3, 4)::private.point_3d; select row(7, -3, 4)::private.point_3d;
$$ language sql; $$ language sql;
@@ -1175,17 +1169,17 @@ create function test.ret_rows_with_base64_bin() returns setof test.images_base64
select i.name, i.img from test.images_base64 i; select i.name, i.img from test.images_base64 i;
$$ language sql; $$ language sql;
create function test.single_article(id integer) returns test.articles as $$ create function test.single_article(id integer) returns test.articles as $$
select a.* from test.articles a where a.id = $1; select a.* from test.articles a where a.id = $1;
$$ language sql; $$ language sql;
create function test.get_guc_value(name text) returns text as $$ create function test.get_guc_value(name text) returns text as $$
select nullif(current_setting(name), '')::text; select nullif(current_setting(name), '')::text;
$$ language sql; $$ language sql;
create table w_or_wo_comma_names ( name text ); create table w_or_wo_comma_names ( name text );
create table items_with_different_col_types ( create table items_with_different_col_types (
int_data integer, int_data integer,
text_data text, text_data text,
bool_data bool, bool_data bool,
@@ -1198,20 +1192,20 @@ create table items_with_different_col_types (
-- Tables used for testing complex boolean logic with and/or query params -- Tables used for testing complex boolean logic with and/or query params
create table entities ( create table entities (
id integer primary key, id integer primary key,
name text, name text,
arr integer[], arr integer[],
text_search_vector tsvector text_search_vector tsvector
); );
create table child_entities ( create table child_entities (
id integer primary key, id integer primary key,
name text, name text,
parent_id integer references entities(id) parent_id integer references entities(id)
); );
create table grandchild_entities ( create table grandchild_entities (
id integer primary key, id integer primary key,
name text, name text,
parent_id integer references child_entities(id), parent_id integer references child_entities(id),
@@ -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
@@ -1332,6 +1326,407 @@ $$ language sql;
create or replace function test.set_cookie_twice() returns void as $$ create or replace function test.set_cookie_twice() returns void as $$
set local "response.headers" = '[{"Set-Cookie": "sessionid=38afes7a8; HttpOnly; Path=/"}, {"Set-Cookie": "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"}]'; set local "response.headers" = '[{"Set-Cookie": "sessionid=38afes7a8; HttpOnly; Path=/"}, {"Set-Cookie": "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"}]';
$$ language sql; $$ language sql;
--
-- PostgreSQL database dump complete create or replace function test.three_defaults(a int default 1, b int default 2, c int default 3) returns int as $$
-- select a + b + c
$$ language sql;
create or replace function test.overloaded() returns setof int as $$
values (1), (2), (3);
$$ language sql;
create or replace function test.overloaded(pg_catalog.json) returns table(x int, y text) as $$
select * from json_to_recordset($1) as r(x int, y text);
$$ language sql;
create or replace function test.overloaded(a int, b int) returns int as $$
select a + b
$$ language sql;
create or replace function test.overloaded(a text, b text, c text) returns text as $$
select a || b || c
$$ language sql;
create table test.leak(
id serial primary key,
blob bytea
);
create function test.leak(blob bytea) returns void as $$ begin end; $$ language plpgsql;
create table test.perf_articles(
id integer not null,
body text not null
);
create table test.employees(
first_name text,
last_name text,
salary money,
company text,
occupation text,
primary key(first_name, last_name)
);
create table test.tiobe_pls(
name text primary key,
rank smallint
);
create table test.family_tree (
id text not null primary key,
name text not null,
parent text
);
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 (
id integer primary key,
name text,
referee 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 pptr2 foreign key (auditor) references test.organizations(id);
create table private.authors(
id integer primary key,
name text
);
create table private.publishers(
id integer primary key,
name text
);
create table private.books(
id integer primary key,
title text,
publication_year smallint,
author_id integer references private.authors(id),
first_publisher_id integer references private.publishers(id)
);
create view test.authors as select id, name from private.authors;
create view test.books as select id, title, publication_year, author_id from private.books;
create view test.forties_books as select id, title, publication_year, author_id from private.books where publication_year >= 1940 and publication_year < 1950;
create view test.fifties_books as select id, title, publication_year, author_id from private.books where publication_year >= 1950 and publication_year < 1960;
create view test.sixties_books as select id, title, publication_year, author_id from private.books where publication_year >= 1960 and publication_year < 1970;
create table person (
id integer primary key,
name character varying not null);
create table message (
id integer primary key,
body text not null default '',
sender bigint not null references person(id),
recipient bigint not null references person(id));
create view person_detail as
select p.id, p.name, s.count as sent, r.count as received
from person p
join lateral (select message.sender, count(message.id) as count from message group by message.sender) s on s.sender = p.id
join lateral (select message.recipient, count(message.id) as count from message group by message.recipient) r on r.recipient = p.id;
create table space(
id integer primary key,
name text);
create table zone(
id integer primary key,
name text,
zone_type_id integer,
space_id integer references space(id));
-- foreign table tests
create extension file_fdw;
create server import_csv foreign data wrapper file_fdw;
create foreign table projects_dump (
id integer,
name text,
client_id integer
) server import_csv options ( filename '/tmp/projects_dump.csv', format 'csv');
comment on foreign table projects_dump is
$$A temporary projects dump
Just a test for foreign tables$$;
create table "UnitTest"(
"idUnitTest" integer primary key,
"nameUnitTest" text
);
create table json_arr(
id integer primary key,
data pg_catalog.json
);
create table jsonb_test(
id integer primary key,
data jsonb
);
create view test.authors_books_number as
select
id,
name,
(
select
count(*)
from forties_books where author_id = authors.id
) as num_in_forties,
(
select
count(*)
from fifties_books where author_id = authors.id
) as num_in_fifties,
(
select
count(*)
from sixties_books where author_id = authors.id
) as num_in_sixties,
(
select
count(*)
from (
select id
from forties_books where author_id = authors.id
union
select id
from fifties_books where author_id = authors.id
union
select id
from sixties_books where author_id = authors.id
) _
) as num_in_all_decades
from private.authors;
create view test.authors_have_book_in_decade as
select
id,
name,
case
when (x.id in (select author_id from test.forties_books))
then true
else false
end as has_book_in_forties,
case
when (x.id in (select author_id from test.fifties_books))
then true
else false
end as has_book_in_fifties,
case
when (x.id in (select author_id from test.sixties_books))
then true
else false
end as has_book_in_sixties
from private.authors x;
create view test.forties_and_fifties_books as
select x.id, x.title, x.publication_year, y.name as first_publisher, x.author_id
from (
select id, title, publication_year, author_id, first_publisher_id from private.books
where publication_year >= 1940 and publication_year < 1960) x
join private.publishers y on y.id = x.first_publisher_id;
create view test.odd_years_publications as
with
odd_years_books as(
select id, title, publication_year, author_id, first_publisher_id
from private.books
where publication_year % 2 <> 0
)
select
x.id, x.title, x.publication_year,
y.name as first_publisher, x.author_id
from odd_years_books x
join private.publishers y on y.id = x.first_publisher_id;
create view test.projects_count_grouped_by as
select
client_id,
count(id) as number_of_projects
from projects
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"(
id int primary key,
name text
);
CREATE TABLE test.bar(
id int primary key,
name text,
"fooId" int references "Foo"(id)
);
CREATE VIEW test.foos as select id,name from "Foo";
CREATE VIEW test.bars as select id, "fooId", name from bar;
create materialized view materialized_projects as
select id, name, client_id from projects;
comment on materialized view materialized_projects is
$$A materialized view for projects
Just a test for materialized views$$;
create or replace function test."quotedFunction"("user" text, "fullName" text, "SSN" text)
returns jsonb AS $$
select format('{"user": "%s", "fullName": "%s", "SSN": "%s"}', "user", "fullName", "SSN")::jsonb;
$$ language sql;
create table private.player (
id integer not null,
first_name text not null,
last_name text not null,
birth_date date,
primary key (last_name, id, first_name, birth_date) -- just for testing a long compound pk
);
create table test.contract (
tournament text not null,
time tsrange not null,
purchase_price int not null,
id integer not null,
first_name text not null,
last_name text not null,
birth_date date,
foreign key (last_name, id, first_name, birth_date) references private.player
);
create view test.player_view as select * from private.player;
create view test.contract_view as select * from test.contract;
create type public.my_type AS enum ('something');
create function test.test_arg(my_arg public.my_type) returns text as $$
select 'foobar'::text;
$$ language sql;
create extension if not exists ltree with schema public;
create table test.ltree_sample (
path public.ltree
);
CREATE FUNCTION test.number_of_labels(test.ltree_sample) RETURNS integer AS $$
SELECT nlevel($1.path)
$$ language sql;
create extension if not exists isn with schema extensions;
create table test.isn_sample (
id extensions.isbn,
name text
);
create function test.is_valid_isbn(input text) returns boolean as $$
select is_valid(input::isbn);
$$ language sql;
create table "Server Today"(
"cHostname" text,
"Just A Server Model" text
);
create table test.pgrst_reserved_chars (
"*id*" integer,
":arr->ow::cast" text,
"(inside,parens)" text,
"a.dotted.column" 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;
+150 -5
View File
@@ -40,9 +40,10 @@ pgrStopAll(){ pkill -f "$(stack path --local-install-root)/bin/postgrest"; }
rootStatus(){ rootStatus(){
curl -s -o /dev/null -w '%{http_code}' "http://localhost:$pgrPort/" curl -s -o /dev/null -w '%{http_code}' "http://localhost:$pgrPort/"
} }
authorsStatus(){ authorsStatus(){
curl -s -o /dev/null -w '%{http_code}' \ curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $( cat "$1" )" \ -H "Authorization: Bearer $1" \
"http://localhost:$pgrPort/authors_only" "http://localhost:$pgrPort/authors_only"
} }
@@ -68,12 +69,12 @@ readSecretFromFile(){
if pgrStarted if pgrStarted
then then
authorsJwt="./secrets/${1%.*}.jwt" authorsJwt="./secrets/${1%.*}.jwt"
httpStatus="$( authorsStatus "$authorsJwt" )" httpStatus="$( authorsStatus $(cat "$authorsJwt") )"
if test "$httpStatus" -eq 200 if test "$httpStatus" -eq 200
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"
@@ -81,6 +82,129 @@ readSecretFromFile(){
pgrStop pgrStop
} }
readDbUriFromFile(){
pgrConfig="dburi-from-file.config"
pgrStartRead "./configs/$pgrConfig" "./dburis/$1"
while pgrStarted && test "$( rootStatus )" -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 pgrStarted
then
ok "connection with $2 dburi read from a file"
else
ko "connection with $2 dburi read from a file"
fi
pgrStop
}
reqWithRoleClaimKey(){
export ROLE_CLAIM_KEY=$1
pgrStart "./configs/role-claim-key.config"
while pgrStarted && test "$( rootStatus )" -ne 200
do
# wait for the server to start
sleep 0.1 \
|| sleep 1 # fallback: subsecond sleep is not standard and may fail
done
authorsJwt=$(psql -qtAX postgrest_test -c "select jwt.sign('$2', 'reallyreallyreallyreallyverysafe');")
httpStatus="$( authorsStatus "$authorsJwt" )"
if test "$httpStatus" -eq $3
then
ok "request with \"$1\" role-claim-key for $2 jwt: $httpStatus"
else
ko "request with \"$1\" role-claim-key for $2 jwt: $httpStatus"
fi
pgrStop
}
invalidRoleClaimKey(){
export ROLE_CLAIM_KEY=$1
pgrStart "./configs/role-claim-key.config"
while pgrStarted && test "$( rootStatus )" -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 pgrStarted
then
ko "invalid jspath \"$1\": accepted"
else
ok "invalid jspath \"$1\": rejected"
fi
pgrStop
}
# ensure iat claim is successful in the presence of pgrst time cache, see https://github.com/PostgREST/postgrest/issues/1139
ensureIatClaimWorks(){
pgrStart "./configs/simple.config"
while pgrStarted && test "$( rootStatus )" -ne 200
do
# wait for the server to start
sleep 0.1 \
|| sleep 1 # fallback: subsecond sleep is not standard and may fail
done
for i in {1..10}; do \
iatJwt=$(psql -qtAX postgrest_test -c "select jwt.sign(row_to_json(r), 'reallyreallyreallyreallyverysafe') from ( select 'postgrest_test_author' as role, extract(epoch from now()) as iat) r")
httpStatus="$( authorsStatus $iatJwt )"
if test "$httpStatus" -ne 200
then
ko "iat claim rejected: $httpStatus"
return
fi
sleep .5;\
done
ok "iat claim accepted"
pgrStop
}
# ensure app settings don't reset on pool timeout of 10 seconds, see https://github.com/PostgREST/postgrest/issues/1141
ensureAppSettings(){
pgrStart "./configs/app-settings.config"
while pgrStarted && test "$( rootStatus )" -ne 200
do
# wait for the server to start
sleep 0.1 \
|| sleep 1 # fallback: subsecond sleep is not standard and may fail
done
sleep 11
response=$(curl -s "http://localhost:$pgrPort/rpc/get_guc_value?name=app.settings.external_api_secret")
if test "$response" = "\"0123456789abcdef\""
then
ok "GET /rpc/get_guc_value: $response"
else
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
pgrStop
}
# PRE: curl must be available # PRE: curl must be available
test -n "$(command -v curl)" || bailOut 'curl is not available' test -n "$(command -v curl)" || bailOut 'curl is not available'
@@ -89,8 +213,9 @@ psql -l 1>/dev/null 2>/dev/null || bailOut 'postgres is not running'
setUp setUp
totalTests=12 echo "Running IO tests.."
echo "1..$totalTests"
socketConnection
readSecretFromFile word.noeol 'simple (no EOL)' readSecretFromFile word.noeol 'simple (no EOL)'
readSecretFromFile word.txt 'simple' readSecretFromFile word.txt 'simple'
@@ -106,6 +231,26 @@ readSecretFromFile ascii.b64 'Base64 (ASCII)'
readSecretFromFile utf8.b64 'Base64 (UTF-8)' readSecretFromFile utf8.b64 'Base64 (UTF-8)'
readSecretFromFile binary.b64 'Base64 (binary)' readSecretFromFile binary.b64 'Base64 (binary)'
readDbUriFromFile uri.noeol "(no EOL)"
readDbUriFromFile uri.txt "(EOL)"
reqWithRoleClaimKey '.postgrest.a_role' '{"postgrest":{"a_role":"postgrest_test_author"}}' 200
reqWithRoleClaimKey '.customObject.manyRoles[1]' '{"customObject":{"manyRoles": ["other", "postgrest_test_author"]}}' 200
reqWithRoleClaimKey '."https://www.example.com/roles"[0].value' '{"https://www.example.com/roles":[{"value":"postgrest_test_author"}]}' 200
reqWithRoleClaimKey '.myDomain[3]' '{"myDomain":["other","postgrest_test_author"]}' 401
reqWithRoleClaimKey '.myRole' '{"role":"postgrest_test_author"}' 401
invalidRoleClaimKey 'role.other'
invalidRoleClaimKey '.role##'
invalidRoleClaimKey '.my_role;;domain'
invalidRoleClaimKey '.#$%&$%/'
invalidRoleClaimKey ''
invalidRoleClaimKey 1234
ensureIatClaimWorks
ensureAppSettings
cleanUp cleanUp
exit $failedTests exit $failedTests
@@ -0,0 +1,8 @@
db-uri = "postgres:///postgrest_test"
db-schema = "test"
db-anon-role = "postgrest_test_anonymous"
db-pool = 1
server-host = "127.0.0.1"
server-port = 49421
app.settings.external_api_secret = "0123456789abcdef"
@@ -2,7 +2,7 @@ db-uri = "postgres:///postgrest_test"
db-schema = "test" db-schema = "test"
db-anon-role = "postgrest_test_anonymous" db-anon-role = "postgrest_test_anonymous"
db-pool = 1 db-pool = 1
server-host = "*4" server-host = "127.0.0.1"
server-port = 49421 server-port = 49421
# Read secret from a file: /dev/stdin (alias for standard input) # Read secret from a file: /dev/stdin (alias for standard input)
@@ -0,0 +1,7 @@
db-uri = "@/dev/stdin"
db-schema = "test"
db-anon-role = "postgrest_test_anonymous"
db-pool = 1
server-host = "127.0.0.1"
server-port = 49421
jwt-secret = "reallyreallyreallyreallyverysafe"
@@ -0,0 +1,8 @@
db-uri = "postgres:///postgrest_test"
db-schema = "test"
db-anon-role = "postgrest_test_anonymous"
db-pool = 1
server-host = "127.0.0.1"
server-port = 49421
role-claim-key = "$(ROLE_CLAIM_KEY)"
jwt-secret = "reallyreallyreallyreallyverysafe"
@@ -2,7 +2,7 @@ db-uri = "postgres:///postgrest_test"
db-schema = "test" db-schema = "test"
db-anon-role = "postgrest_test_anonymous" db-anon-role = "postgrest_test_anonymous"
db-pool = 1 db-pool = 1
server-host = "*4" server-host = "127.0.0.1"
server-port = 49421 server-port = 49421
# Read secret from a file: /dev/stdin (alias for standard input) # Read secret from a file: /dev/stdin (alias for standard input)
+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-port = 49421
jwt-secret = "reallyreallyreallyreallyverysafe"
+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"
+1
View File
@@ -0,0 +1 @@
postgres:///postgrest_test
+1
View File
@@ -0,0 +1 @@
postgres:///postgrest_test
+115
View File
@@ -0,0 +1,115 @@
#! /usr/bin/env bash
currentTest=1
failedTests=0
result(){ echo "$1 $currentTest $2"; currentTest=$(( $currentTest + 1 )); }
ok(){ result 'ok' "- $1"; }
ko(){ result 'not ok' "- $1"; failedTests=$(( $failedTests + 1 )); }
pgrPort=49421
pgrStopAll(){ pkill -f "$(stack path --profile --local-install-root)/bin/postgrest"; }
pgrStart(){ stack exec --profile -- postgrest test/memory-tests/config +RTS -p -h >/dev/null & pgrPID="$!"; }
pgrStop(){ kill "$pgrPID" 2>/dev/null; }
setUp(){ pgrStopAll; }
cleanUp(){ pgrStopAll; }
checkPgrStarted(){
while pgrStarted && test $(rootStatus) -ne 200
do
sleep 1
done
}
pgrStarted(){ kill -0 "$pgrPID" 2>/dev/null; }
rootStatus(){
curl -s -o /dev/null -I -w '%{http_code}' "http://localhost:$pgrPort/"
}
jsonKeyTest(){
pgrStart
checkPgrStarted
factor=$(( 3*$(numfmt --from=si $1)/4 )) # 3/4 on $1 is need to maintain the specified size because of base64
payload="{\"blob\" : \"$(dd if=/dev/zero bs=$factor count=1 status=none | base64)\"}"
httpStatus=$(echo $payload | curl -s -H "Content-Type: application/json" --request $2 -d @- -w '%{http_code}' http://localhost:$pgrPort$3 | tr -d '"')
if test "$httpStatus" -ge 200 && test "$httpStatus" -lt 210
then
pgrStop
while [ ! -s postgrest.prof ]
do
sleep 1
done
BYTES_FMT=$(cat postgrest.prof | grep -o -P '(?<=alloc =).*(?=bytes)' | tr -d ' ')
BYTES=$(echo $BYTES_FMT | tr -d ',')
MAX_BYTES=$(numfmt --from=si $4)
if test $BYTES -le $MAX_BYTES
then
ok "$2 $3: with a json key of $1 the memory usage($BYTES_FMT bytes) is less than $4"
else
ko "$2 $3: with a json key of $1 the memory usage($BYTES_FMT bytes) is more than $4"
fi
else
pgrStop
ko "$2 $3: request failed with http $httpStatus"
fi
}
postJsonArrayTest(){
pgrStart
checkPgrStarted
arr=()
arr+=('[')
for i in $(seq 1 $(expr $1 - 1))
do
arr+=("{\"id\": $i, \"body\": \"xxxxxxx\"},")
done
arr+=("{\"id\": $1, \"body\": \"xxxxxxx\"}")
arr+=(']')
payload=$(echo ${arr[*]})
httpStatus=$(echo $payload | curl -s -H "Content-Type: application/json" -d @- -w '%{http_code}' http://localhost:$pgrPort$2 | tr -d '"')
if test "$httpStatus" -ge 200 && test "$httpStatus" -lt 210
then
pgrStop
while [ ! -s postgrest.prof ]
do
sleep 1
done
BYTES_FMT=$(cat postgrest.prof | grep -o -P '(?<=alloc =).*(?=bytes)' | tr -d ' ')
BYTES=$(echo $BYTES_FMT | tr -d ',')
MAX_BYTES=$(numfmt --from=si $3)
PAYLOAD_SIZE=$(echo $payload | wc -c | numfmt --to=si)
if test $BYTES -le $MAX_BYTES
then
ok "POST $2: with a json payload of $PAYLOAD_SIZE that has $1 array values the memory usage($BYTES_FMT bytes) is less than $3"
else
ko "POST $2: with a json payload of $PAYLOAD_SIZE that has $1 array values the memory usage($BYTES_FMT bytes) is more than $3"
fi
else
pgrStop
ko "POST $2: request failed with http $httpStatus"
fi
}
setUp
echo "Running memory usage tests.."
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "12M"
jsonKeyTest "1M" "POST" "/leak?columns=blob" "12M"
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "12M"
jsonKeyTest "10M" "POST" "/rpc/leak?columns=blob" "40M"
jsonKeyTest "10M" "POST" "/leak?columns=blob" "40M"
jsonKeyTest "10M" "PATCH" "/leak?id=eq.1&columns=blob" "40M"
jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "170M"
jsonKeyTest "50M" "POST" "/leak?columns=blob" "170M"
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "170M"
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "10M"
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "10M"
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "20M"
cleanUp
exit $failedTests
+8
View File
@@ -0,0 +1,8 @@
db-uri = "postgres:///postgrest_test"
db-schema = "test"
db-anon-role = "postgrest_test_anonymous"
db-pool = 1
server-host = "127.0.0.1"
server-port = 49421
jwt-secret = "reallyreallyreallyreallyverysafe"