Compare commits

...
248 Commits
Author SHA1 Message Date
Joe Nelson e8426671c0 v0.3.2.0 2016-06-10 22:43:58 -07:00
Joe NelsonandGitHub 455f086880 Remove unix dependency for tests (#636) 2016-06-10 22:35:29 -07:00
Joe NelsonandGitHub 42110643a3 Use newer deps for GHC 8 compatibility (#619) 2016-06-09 18:50:13 -07:00
Joe Nelson e315dbc91e Include allow header in options response (#628) 2016-06-08 23:12:29 -07:00
Joe Nelson e272c2ed08 Merge pull request #626 from edofic/update-operator-docs
Update operator documentation
2016-06-03 23:26:09 -07:00
Andraz Bajt a875db2b82 Update operator documentation 2016-06-03 08:59:39 +02:00
Joe Nelson 02c6de4144 Merge pull request #618 from begriffs/post-empty-obj
Use table defaults for empty object insert
2016-06-02 08:46:47 -07:00
Joe Nelson 7563b5e2f4 Move unwords higher for branch parity 2016-06-02 08:40:24 -07:00
Joe Nelson 5e3d9442af Use table defaults for empty object insert
Fixes #616
2016-06-02 08:40:24 -07:00
Joe Nelson c0c1a260ba Merge pull request #625 from ruslantalpa/return_data_on_delete
Implement select/return representation for DELETE queries (fix #518)
2016-06-02 08:38:46 -07:00
Ruslan Talpa 6ebd7fd2d7 implement select/return representation for DELETE queries (fix #518) 2016-06-02 13:37:33 +03:00
Joe Nelson 24dd4e8626 Merge pull request #608 from ruslantalpa/multilevel_limit
Limit embeded items
2016-05-31 07:57:16 -07:00
Ruslan Talpa dc727f900d suggested cleaup by @begriffs 2016-05-31 14:55:31 +03:00
Michal ŠkopandJoe Nelson 0847a38691 adding check for verified flag before login into Users example 2016-05-27 16:53:46 -07:00
Ruslan Talpa 7c83edc402 Limit embeded items 2016-05-26 09:56:28 +03:00
Joe Nelson e76de196e0 Merge pull request #604 from begriffs/less-frequent-gc
Run GC every 2s rather than 0.3s
2016-05-25 23:29:20 -07:00
Joe Nelson b7331135a6 Merge pull request #607 from league/urlencode-location
Simplify serialization of location header
2016-05-25 20:53:41 -07:00
Christopher League 0940b2dccf Simplify serialization of location header
Possible after bug fix in a Hasql that we picked up with the new
dependency bounds in #606. Also includes test to ensure location header
is omitted on bulk insert.
2016-05-22 21:54:26 -04:00
Joe Nelson 4f53aef74f Merge pull request #606 from begriffs/newdeps 2016-05-22 11:36:15 -07:00
Joe Nelson f4027cb5fd Pin hasql-* dep versions
This allows packdeps to warn us when they get out of date
2016-05-22 11:02:37 -07:00
Joe Nelson 38afe71ec7 Upgrade extra-deps and LTS 2016-05-22 11:00:37 -07:00
Joe Nelson 308c006a30 Quote with-rtsopts correctly 2016-05-21 12:18:10 -07:00
Joe Nelson c7d863c998 Merge pull request #605 from diogob/microlens
Replace lens dependency for microlens
2016-05-21 11:48:07 -07:00
Joe Nelson 44cdc97d71 Run GC every 2s rather than 0.3s
Fixes #565
2016-05-21 11:21:42 -07:00
Joe Nelson a87dcd5553 Merge pull request #603 from diogob/refactor-jwtClaims
jwtClaims should always return Left for invalid JWT
2016-05-21 11:14:57 -07:00
Joe Nelson 592dd39222 Merge pull request #602 from ruslantalpa/order_limit_embeded
Ability to order embedded items (closes #509)
2016-05-21 11:08:24 -07:00
Ruslan Talpa 45d0f85b0d ability to order embeded items (closes #509) 2016-05-21 20:52:13 +03:00
Diogo Biazus b68fcd2522 Replace lens dependency for microlens 2016-05-21 13:31:39 -04:00
Diogo Biazus abd81c998b jwtClaims should always return Left for invalid JWT 2016-05-21 13:18:45 -04:00
Joe Nelson 6a2edb2844 Merge pull request #595 from league/master
URL-encode Location header in 201 response (#588)
2016-05-21 09:52:09 -07:00
Christopher League 5c38b4328b URL-encode Location header (closes #588)
The database returns an array of key-value strings like `"k1=eq.hello
world"`. Haskell URL-encodes the portion after the equal sign and joins
them with `&`.

Includes updates to tests in Feature.InsertSpec: The CompoundPK has been
modified to have one Int and one String. We attempt to add a key with a
String that has spaces and other special characters. This requires that
the returned Location header is properly URL-encoded.
2016-05-20 10:42:55 -04:00
Joe Nelson 2cb04c1d5c Merge pull request #597 from league/avoid-recompile
Tweak .cabal to avoid unneeded recompilation
2016-05-18 09:05:13 -07:00
Christopher League b089e0a7dd Tweak .cabal to avoid unneeded recompilation
Previously when making a change and running `stack test`, it would
compile each module 3 times: for the library, the executable, and the
test suite.

This change more cleanly segregates the hs-source-dirs for each target,
which avoids recompilation. The only source change is moving Main.hs
into its own directory (but it's otherwise unchanged). See also:
<http://stackoverflow.com/questions/6711151/how-to-avoid-recompiling-in-this-cabal-file>
2016-05-18 09:55:52 -04:00
Joe Nelson a21464ddca Merge pull request #592 from begriffs/form-urlencoded
Accept POST requests from HTML forms
2016-05-18 00:38:42 -07:00
Joe Nelson 900b9f1991 Explain use of Left value 2016-05-18 00:06:16 -07:00
Joe Nelson cf16f90fab Merge pull request #590 from begriffs/proper-403
Return proper 401/403 when access denied
2016-05-17 22:56:55 -07:00
Joe Nelson 36a6b10d0d Merge pull request #594 from ruslantalpa/multiple_fks
Fix include entities from the same parent table using two different foreign keys
2016-05-17 22:54:12 -07:00
Douglas CuthbertsonandJoe Nelson 2ac3ad9e37 Fix Windows build issue 589 (#593) 2016-05-17 22:47:39 -07:00
Ruslan Talpa 9e6542680b Fix include entities from the same parent table using two different foreign keys 2016-05-16 15:36:18 +03:00
Joe Nelson 7e41b620ff Accept POST requests from HTML forms 2016-05-15 20:55:07 -07:00
Joe Nelson 18e3c30ad8 Return proper 401/403 when access denied
Fixes #584
2016-05-15 00:56:47 -07:00
Joe Nelson 0dbd0ece9a Merge pull request #586 from ruslantalpa/rename_order_limit_feature
Ability to rename columns/nodes in the output and support "-" in column names
2016-05-15 00:55:12 -07:00
Ruslan Talpa c13f0a369b Support node/column renaming #310 2016-05-12 10:22:19 +03:00
Ruslan Talpa cacc725e41 support dash in column names fix #462 2016-05-11 10:43:34 +03:00
opensrckenandJoe Nelson 0dc33dbf9f fix row level security readme per https://github.com/begriffs/postgre… (#579)
* fix row level security readme per https://github.com/begriffs/postgrest/issues/554

* handle anonymous access to posts / comments tables

* address insertion use case in row-level security readme
2016-05-08 09:31:28 -07:00
Joe Nelson d9205bd838 Do not include Content-Type header for empty body (#580)
* Do not include Content-Type header for empty body

Fixes #544

* Fix lint

* Changelog
2016-05-03 21:18:23 -07:00
Joe Nelson 88aad4b1b6 Reload schema definition on SIGHUP (#570) 2016-04-26 07:50:23 -07:00
Joe Nelson 5aadfba84b Use read-only transaction mode for read requests (#561)
* Make middleware use ApiRequest rather than Request

* Fix outdated comments

* Use read-only transaction mode for read requests

This allows API requests against read replicas
2016-04-15 12:26:40 -07:00
Joe Nelson eae5857d0e Set role only once, and set it before other GUC vars (#560)
* Set role only once, and set it before other GUC vars

Fixes #559

* Unify role/claim logic in claimsToSQL

Suggested by @diogob
2016-04-15 07:30:36 -07:00
Joe Nelson c32d13c8f1 Avoid slow PL/pgSQL exception handling in example (#543) 2016-04-10 15:36:32 -07:00
Joe Nelson 0401a8eb13 Add Docker Hub badge 2016-04-09 15:34:46 -07:00
Joe Nelson 9a1a87ff8e Merge pull request #538 from jpierre03/patch-1
Update postgrest version to 0.3.1.1 in Dockerfile
2016-03-29 18:17:28 -07:00
Jean-Pierre PRUNARET 16e3b16081 Update postgrest version to 0.3.1.1 2016-03-29 22:54:22 +02:00
Joe Nelson 200e5a26cc Merge pull request #536 from begriffs/build-0.3.1.1
Bump version
2016-03-28 15:09:04 -07:00
Joe Nelson b8bbaa7764 Bump version 2016-03-28 13:26:06 -07:00
Joe Nelson 1470091f1c Merge pull request #534 from begriffs/unicode-schema
Regression test for read/write unicode table names
2016-03-27 00:04:30 -07:00
Joe Nelson 31738d745f Regression test for read/write unicode table names 2016-03-25 15:14:00 -07:00
Joe Nelson f19d4300bc Merge pull request #533 from begriffs/no-count-singular
Do not do table count when plurality=singular
2016-03-23 20:43:05 -07:00
Joe Nelson cd81e9346f Do not do table count when plurality=singular
Rebasing commits by @ruslantalpa
2016-03-23 20:30:51 -07:00
Joe Nelson 01355f39a1 Merge pull request #524 from begriffs/unicode-inserts
Preserve unicode in requests and responses
2016-03-18 11:59:36 -07:00
Joe Nelson 87298f580a Merge pull request #528 from rowdypixel/patch-1
Fix typo-d flag in the installation docs.
2016-03-18 09:47:46 -07:00
Joe Nelson 3bfe64dd06 Create monomorphic statement function to force use of Text 2016-03-16 21:04:18 -07:00
Dan Walker b9d3eedb9d Fix typo-d flag in the installation docs. 2016-03-14 21:28:46 -04:00
Joe Nelson bb4126bf3a Merge pull request #526 from daurnimator/no-uuid-ossp
Remove remaining uuid-ossp references
2016-03-14 09:18:45 -07:00
daurnimator 2e440822cb remove unnessecary create extension "uuid-ossp" 2016-03-14 20:52:51 +11:00
daurnimator 13eed84f57 Use gen_random_uuid instead of uuid_generate_v4 2016-03-14 20:51:35 +11:00
Joe Nelson e5fed86965 Changelog 2016-03-13 14:33:00 -07:00
Joe Nelson 3c5fab009b Remove ancient test comments 2016-03-13 14:22:36 -07:00
Joe Nelson b858626e17 For correctness include charset=utf-8 in responses 2016-03-13 14:22:17 -07:00
Joe Nelson 330cc91645 Protect unicode values in requests 2016-03-13 14:20:54 -07:00
Joe Nelson 1037824e11 Merge pull request #523 from begriffs/single-proc-call
Prevent duplicate call to stored procs
2016-03-12 23:34:25 -08:00
Joe Nelson 4cc08a11e7 Prevent duplicate call to stored procs
Reuse a CTE for results of call
2016-03-12 18:28:36 -08:00
Joe Nelson 358254639a Merge @ruslantalpa's fk improved detection 2016-03-12 12:42:57 -08:00
Joe Nelson 43bc9bfa83 Merge pull request #522 from begriffs/full-jwt
Allow SQL functions to generate registered JWT claims
2016-03-12 12:26:36 -08:00
Joe Nelson a779e9eb8b Batch the sql commands to set local vars 2016-03-11 23:45:05 -08:00
Joe Nelson f67e195f76 Expose all claims via sql postgrest.claims 2016-03-11 20:51:22 -08:00
Joe Nelson 508d722fb2 Allow SQL functions to generate registered JWT claims 2016-03-10 21:58:43 -08:00
Joe Nelson 14d7364f4b Merge pull request #521 from dex-ethics/spelling
Spelling fixes in documentation
2016-03-09 12:15:28 -08:00
Remco Bloemen bfbce27a65 Spelling fixes in documentation 2016-03-09 15:47:12 +01:00
Joe Nelson 00a23058c8 Merge pull request #511 from dex-ethics/docker-exec
Use `CMD exec` in Dockerfile
2016-03-07 22:34:16 -08:00
Remco Bloemen 82c74ed21f Use CMD exec in Dockerfile
Without exec the `postgrest` process is not run with PID 1 (it
is a child process of the shell that starts it). This means
signals send to the docker (like `docker stop` or ^C) will
not be handled correctly.

However, Linux treats PID 1 as special and sets the SIGTERM
handler to ignore by default. It is also necessary to install
a SIGTERM handler.

This commit adds `exec` to resolve this problem, as per the
recommendation in the Dockerfile documentation:

https://docs.docker.com/engine/reference/builder/#shell-form-entrypoint-example
2016-03-07 18:30:40 +01:00
Joe Nelson 5f0b4977da Merge pull request #514 from dex-ethics/docs
Minor changes in documentation
2016-03-07 09:15:16 -08:00
Remco Bloemen 82214856b6 Split build and install in build from source instructions.
Stack refuses to build when run under sudo.
2016-03-07 17:54:37 +01:00
Remco Bloemen c09adb967a Use gen_random_uuid() in user management example.
The function uuid_generate_v4() is not available
without extensions.
2016-03-07 17:53:55 +01:00
Remco Bloemen e5d420b2db Gracefull exit on sigTERM
Like the sigINT that was already handled, postgrest
should gracefuly shut down on a sigTERM. This is a
common way of stopping processes, used amongst
others by docker.

See: https://stackoverflow.com/questions/4042201/how-does-sigint-relate-to-the-other-termination-signals
2016-03-07 17:45:53 +01:00
Joe Nelson ef021056c9 Merge pull request #497 from bobcolner/bobcolner-dockerfile
PostgREST Dockerfile
2016-03-05 13:20:05 -08:00
Bob Colner b7b082cd8e updated Dockerfile to use postgrest 3.1.0 2016-03-05 13:07:11 -08:00
Bob Colner a02632f18c Update Dockerfile 2016-03-05 12:53:33 -08:00
Ruslan Talpa e43ad54dbf Merge branch 'master' of https://github.com/begriffs/postgrest 2016-03-01 17:54:55 +02:00
Joe Nelson 8af91e262c Merge pull request #508 from begriffs/test-plain-build
Test that binary builds, not just that suite passes
2016-02-29 22:49:33 -08:00
Ruslan Talpa 7b94fb608d suggestions by @diogob 2016-03-01 08:08:57 +02:00
Joe Nelson cf176c4100 Allow aeson v11, but forbid deadly v10 2016-02-29 21:03:42 -08:00
Joe Nelson c61418635e Ensure helper binaries get re-installed
Sadly causes all extra-deps to rebuild every time
2016-02-29 20:45:34 -08:00
Joe Nelson dba827d1fd List missing other-module in spec 2016-02-29 14:52:01 -08:00
Joe Nelson e315ad99b4 Name the main module "Main" as required 2016-02-29 14:50:44 -08:00
Joe Nelson 088df7e6be Test that binary build succeeds
Work around https://github.com/commercialhaskell/stack/issues/1846
2016-02-29 14:25:34 -08:00
Ruslan Talpa 40eec0b2ff code beautify using stylish-haskell 2016-02-29 14:53:41 +02:00
Ruslan Talpa 77bec52be7 Fix compile notice 2016-02-29 14:11:48 +02:00
Ruslan Talpa 155d1dee6b changelog entry 2016-02-29 13:59:04 +02:00
Ruslan Talpa 0548d65911 main module of the executable needs to be Main, with PostgREST.Main build fails 2016-02-29 13:57:34 +02:00
Ruslan Talpa 40a30d7b02 Fix view column source detection 2016-02-29 13:10:33 +02:00
Ruslan Talpa 62af792add Add failing test to test correct view column detection 2016-02-29 11:37:23 +02:00
Joe Nelson 4cd2475bf2 v0.3.1.0 2016-02-28 21:45:17 -08:00
Joe Nelson fc4c792f9e Move section in changelog 2016-02-26 12:17:24 -08:00
Joe Nelson c094e5a0fc Merge pull request #489 from diogob/apply_range_headers_to_rpc
Apply range headers to rpc
2016-02-26 12:10:33 -08:00
Diogo Biazus 9d0f3573c6 Implements query counting in proc call and adds Content-Rage to response
headers in /rpc calls.
2016-02-26 14:51:35 -05:00
Diogo Biazus 4496a95014 Updates changelog 2016-02-26 14:41:27 -05:00
Diogo Biazus 893b7a7126 Applies range headers to /rpc calls using LIMIT/OFFSET. 2016-02-26 14:41:27 -05:00
Joe Nelson 3b23c4aa5b Merge pull request #503 from begriffs/one-tx-per-client
Reduces pool resource locking (2)
2016-02-26 10:11:47 -08:00
Joe Nelson d466ea45ff Add changelog entry
Nice work guys, this took a lot of cooperation
2016-02-26 10:06:54 -08:00
Joe Nelson 7ba5363d25 Upgrade hasql to fix prepared statement problem 2016-02-26 08:18:47 -08:00
Joe Nelson f28b03f419 Allow new hasql-transaction to do rollbacks 2016-02-25 20:13:03 -08:00
Joe Nelson de772b9246 Modified the concurrent test to illustrate problem with prepared statement 2016-02-22 20:58:44 -08:00
Joe Nelson c28b26d949 Run QueryLimitedSpec with its own server flags 2016-02-22 19:21:39 -08:00
Joe Nelson c02dd4aa98 Enable real threads in test 2016-02-22 17:53:10 -08:00
Joe Nelson b0974a4e36 Reset db between each test suite 2016-02-22 17:50:00 -08:00
Joe Nelson 17acd134c7 Suppress server logging in test mode 2016-02-22 17:48:33 -08:00
Joe Nelson d4a4bbf966 Roll back on db errors 2016-02-22 16:52:50 -08:00
Joe Nelson 7b7babd1d1 Fix frozen tests
Problem found by @ruslantalpa
2016-02-22 08:41:31 -08:00
Joe Nelson 072a6ce4c7 Bump hasql to 0.19.8 2016-02-21 18:37:17 -08:00
Joe Nelson d5c1438c6e Use hasql-transaction
Also use hspec before-wrapper
2016-02-21 18:05:25 -08:00
Joe Nelson 30e5032ade Use lower optimization to speed up regular dev builds 2016-02-21 14:11:46 -08:00
Joe Nelson d7fe59f0b0 WIP: share server code between tests and program
- Share server code in Main
- Switch to hasql-pool
- Use pool in tests
- DRY up test runner
2016-02-21 12:22:18 -08:00
Joe Nelson 8a006f07a7 Show error text more clearly 2016-02-20 18:11:46 -08:00
Diogo BiazusandJoe Nelson 01ab540ffe Simplify return from withResource in Main.hs 2016-02-20 18:03:17 -08:00
Diogo BiazusandJoe Nelson de848f64fa Return the results from withResource function before applying the respond continuation. This ensures that the pool resource is freed as soon as the database operation is complete 2016-02-20 18:03:06 -08:00
Joe Nelson 52e689b830 Add concurrent test for "transaction in progress"
MonadBaseControl wizardry courtesy of @jwiegley
2016-02-20 17:45:55 -08:00
Bob Colner ef3e2511fe PostgRest Dockerfile
PostgRest Dockerfile with ENV parameter passthrough.
2016-02-17 10:41:34 -08:00
Joe Nelson 6b4b763bc4 Merge pull request #494 from begriffs/test-raw-cabal
Ensure plain cabal can determine a build plan
2016-02-15 11:46:31 -08:00
Joe Nelson 6b1c8b3e39 Ensure plain cabal can determine a build plan
For those wishing to use postgrest as a library
2016-02-14 22:17:26 -08:00
Joe Nelson f3293cfac1 Do not name import of void directly as it is used conditionally 2016-02-12 23:15:09 -08:00
Joe Nelson 0dd8a498b2 v0.3.0.4 2016-02-12 20:45:03 -08:00
Joe Nelson f9b8e6879d Merge pull request #492 from begriffs/threaded
Do not block threads on libpq ffi
2016-02-12 20:32:40 -08:00
Joe Nelson e73a4c66bc Do not block threads on libpq ffi
Fixes #445
2016-02-12 14:43:00 -08:00
Joe Nelson fc3c885bb6 Merge pull request #491 from begriffs/payload-error
Ensure payload parse errors are not suppressed
2016-02-11 16:39:13 -08:00
Joe Nelson 8b3d224b80 Ensure payload parse errors are not suppressed
Fixes #490
2016-02-11 16:24:02 -08:00
Joe Nelson ce6e52e9ba Merge pull request #487 from motiz88/nitpick
Minor proofreading in InsertSpec
2016-02-08 08:23:22 -08:00
Moti Zilberman 4dd4eeb421 [ci skip] s/unkonwn/unknown/ 2016-02-08 16:39:32 +02:00
Moti Zilberman b50882db3a [ci skip] Fix typo in InsertSpec 2016-02-08 15:53:15 +02:00
Moti Zilberman 0058b5df99 Minor proofreading in InsertSpec
s/csv/json/ in line 24
2016-02-08 14:29:47 +02:00
Joe Nelson f7926e9f28 Merge pull request #486 from begriffs/improved-usage
Adjust usage message to indicate required arguments
2016-02-06 18:02:09 -08:00
Joe Nelson f65557573c Match new config param order in tests 2016-02-05 09:39:43 -08:00
Joe Nelson 4dec445b82 Add example db connection string to usage 2016-02-05 09:30:42 -08:00
Joe Nelson ccb3eba9e3 Reorder arg list, note that schema is not required 2016-02-05 09:27:24 -08:00
Joe Nelson 56426b896a Changelog entry 2016-02-04 16:38:35 -08:00
Joe Nelson 7702d38267 Update docs to note rquired arguments 2016-02-04 16:34:36 -08:00
Joe Nelson a044398552 Adjust usage message to indicate required arguments 2016-02-04 16:27:54 -08:00
Joe Nelson 17db68ae2d Merge branch 'feature/options-not-found-error' 2016-02-03 11:44:36 -08:00
Joe Nelson e53fb10483 Fix lint 2016-02-03 11:36:17 -08:00
calebmerandJoe Nelson 33757e537b Fix options on non existant tables, closes #442 2016-02-03 11:20:45 -08:00
Joe Nelson 945ef61188 Merge pull request #483 from begriffs/rpc-post-only
Issue http 405 for anything but POST on RPC
2016-02-03 11:16:51 -08:00
Joe Nelson f990a519a5 More extensive tests 2016-02-02 16:45:54 -08:00
Joe Nelson 2149bea8e3 Merge pull request #485 from motiz88/patch-1
Document default order direction correctly, fix #484
2016-02-02 15:47:14 -08:00
Moti Zilberman 7ce10dbcf1 Correct default order direction, fix #484 2016-02-03 01:36:54 +02:00
Joe Nelson 31f46d5220 Direct stack download is faster than apt-get 2016-02-01 23:40:02 -08:00
Joe Nelson a0ef4eae4d Issue http 405 for anything but POST on RPC
Fixes #481
2016-02-01 22:52:57 -08:00
Joe Nelson aec11e34a7 Merge pull request #480 from eGullGolf/issue169
Explain how to connect without a password through Unix socket
2016-02-01 12:55:42 -08:00
Eric Bréchemier 95f26604ba Explain how to connect without a password through Unix socket
I expanded the section 'Running the Server' in the documentation.

Based on this comment:
https://github.com/begriffs/postgrest/issues/169#issuecomment-177031035

Related issue: #169
2016-02-01 11:16:26 +01:00
Joe Nelson c6d47eeb77 Merge pull request #478 from pra85/patch-1
Typo in Readme.md
2016-01-30 20:07:06 -08:00
Prayag Verma 8500067e0f Typo in Readme.md
commicate > communicate
2016-01-31 09:18:39 +05:30
Joe Nelson 20573632d7 Merge pull request #476 from begriffs/stack-ci
Switch to Stack on CircleCI
2016-01-29 14:34:27 -08:00
Joe Nelson 03468c83df Merge pull request #477 from sscarduzio/patch-3
#475
2016-01-29 10:10:49 -08:00
Simone Scarduzio 4e3a04ea72 #475 2016-01-29 18:06:29 +00:00
Joe Nelson e6e324e8ff Trim unused packages 2016-01-28 17:32:38 -08:00
Joe Nelson 2175ae4d28 Build test deps in cacheable phase 2016-01-28 16:36:45 -08:00
Joe Nelson c887f2b3b4 Move ghc-options in stack config and out of cabal file 2016-01-28 14:03:44 -08:00
Joe Nelson 1a3c54793d Build only haddocks for postgrest, not deps 2016-01-28 14:00:33 -08:00
Joe Nelson 4679e2a514 Haddock coverage report not working, removing for now 2016-01-28 13:17:49 -08:00
Joe Nelson e02dc2e92e Remove redundant do 2016-01-27 18:51:53 -08:00
Joe Nelson 536acec820 Turn down warning level a little 2016-01-27 17:31:57 -08:00
Joe Nelson 55da918240 Build with stack on CI and use harsher setttings 2016-01-27 16:12:48 -08:00
Joe Nelson f3d4d1fb60 Merge pull request #472 from begriffs/hasql-19
Upgrade to Hasql 19
2016-01-27 14:59:08 -08:00
Joe Nelson dac31c4f2e Use newer LTS to avoid potential aeson problem 2016-01-27 11:00:40 -08:00
Joe Nelson 677c73cfe5 New versions of Warp do not export Connection 2016-01-24 18:09:21 -08:00
Joe Nelson b85fc37130 Protect against nulls that choke our decoder 2016-01-24 18:09:20 -08:00
Joe Nelson f634b7fe98 Rollback test connection on errors 2016-01-24 18:09:20 -08:00
Joe Nelson 75ebd1bd24 Derp, it is "commit" not "end" 2016-01-24 18:09:20 -08:00
Joe Nelson cbb2ba7d42 Appease hlint 2016-01-24 18:09:20 -08:00
Joe Nelson 616541aaee Use reorder-goals in CI to make vanilla cabal work 2016-01-24 18:09:20 -08:00
Joe Nelson bbf8365cd2 Upgrade hasql 2016-01-24 18:09:20 -08:00
Joe Nelson a0b390e735 Avoid possibilities of null for our chosen decoder 2016-01-24 18:09:20 -08:00
Joe Nelson 1f557a92a4 Pass query args properly 2016-01-24 18:09:20 -08:00
Joe Nelson 51f71eb53d Run queries in a transaction again 2016-01-24 18:09:19 -08:00
Joe Nelson ac73e8d77b Remove connection pooling in test 2016-01-24 18:09:19 -08:00
Joe Nelson 8b13e7dd73 Header cannot be null even when it is n/a 2016-01-24 18:09:19 -08:00
Joe Nelson 3be04d7f30 Upgrade hasql to fix connection error 2016-01-24 18:09:19 -08:00
Joe Nelson b9fd083c77 It all compiles but all requests give a postgres error 2016-01-24 18:09:19 -08:00
Joe Nelson fec316b087 WIP: fixing compiler errors in specs 2016-01-24 18:09:19 -08:00
Joe Nelson 3844f3ee96 The app compiles but totally untested 2016-01-24 18:09:19 -08:00
Joe Nelson cb3977679d App.hs compiles (dubiously)
Removed query body is no longer a maybe value
2016-01-24 18:09:19 -08:00
Joe Nelson 6122bc4108 Middleware compiles 2016-01-24 18:09:18 -08:00
Joe Nelson abc30d5170 DbStructure compiles 2016-01-24 18:09:18 -08:00
Joe Nelson 4b515c5df4 WIP: converting DbStructure 2016-01-24 18:09:18 -08:00
Joe Nelson 2d5210464a WIP: converting dbstructure 2016-01-24 18:09:18 -08:00
Joe Nelson 684b11badb WIP: converting DbStructure 2016-01-24 18:09:18 -08:00
Joe Nelson 7b92449343 QueryBuilder compiles with hasql 19 2016-01-24 18:09:18 -08:00
Joe Nelson 72cd6c37bd Change some import statements and Session types 2016-01-24 18:09:18 -08:00
Joe Nelson d6102cc908 Use the correct Error types for hasql 0.19 2016-01-24 18:09:18 -08:00
Joe Nelson 5faa80b172 Merge pull request #470 from sscarduzio/patch-1
The correct HTTP status code for integrity constraint violation is 509, fixes #469
2016-01-24 15:31:55 -08:00
Simone Scarduzio 74d76c690f Adding two test cases for 469 2016-01-24 14:52:14 +00:00
Joe Nelson 301d9b6a86 Merge pull request #468 from guido4000/patch-1
Rename endpoint for consistent endpoint names
2016-01-21 08:25:59 -08:00
Simone Scarduzio b5e6a93b32 Being more specific 2016-01-21 15:21:30 +00:00
Simone Scarduzio e7c711002a The correct HTTP status code for integrity constraint violation is 509, fixes #469 2016-01-21 14:37:47 +00:00
Guido Hoermann 6dce40e454 Rename endpoint for consistent endpoint names
In line 126 the endpoint url is api_token. Therefore it should be the same in line 178.
2016-01-21 20:26:25 +07:00
Joe Nelson 9ba603660e Merge pull request #466 from diogob/adds_multiple_insert_section
Adds section about multiple insertion/update to api/writing docs [#405]
2016-01-20 21:13:29 -08:00
Diogo Biazus 5ee44c6c21 Adds section about multiple insertion/update to api/writing docs [#405] 2016-01-20 23:10:24 -05:00
Joe Nelson 03bec64097 Merge pull request #450 from daurnimator/patch-1
docs/examples/users.md: pgcrypto includes uuidv4 generation
2016-01-20 18:28:03 -08:00
Joe Nelson 4fcc0fbc94 Merge pull request #464 from eGullGolf/issue463
Add step to install libpq-dev when building from source on Ubuntu
2016-01-19 11:25:26 -08:00
Eric Bréchemier 80ade96e9b Add step to install libpq-dev when building from source on Ubuntu
This is a dependency of postgresql-libpq-0.9.1.1, which results in
a failure when missing during stack build.

Closes #463.
2016-01-19 20:14:29 +01:00
Joe Nelson 860e437078 Merge pull request #459 from motiz88/tentative-fix-458
pgFmtLit: Change condition for using E"..." syntax
2016-01-15 09:14:35 -08:00
Moti Zilberman 9a596c2500 pgFmtLit: Change condition for using E"..." syntax, fix #458.
Includes a test spec by @diogob.
2016-01-15 11:59:25 +02:00
Joe Nelson 0d9d74dc1c Merge pull request #454 from ruslantalpa/fix_453
fix #453 by sending the request to the db (instead of using the cached data)
2016-01-13 20:29:05 -08:00
Ruslan Talpa 88d98d6d62 fix #453 by sending the request to the db (instead of using the cached data) 2016-01-11 10:17:34 +02:00
Joe Nelson c93c4d8c30 Merge pull request #452 from manufaktor/patch-1
Set login permission for authenticator role
2016-01-09 10:15:35 -08:00
Immanuel Häussermann 021e78d962 Set login permission for authenticator role
This was confusing when I got started with the docs. When using CREATE ROLE, the role will not have the LOGIN permission and starting the server with `postgrest postgres://authenticator@localhost:5432/mydb --anonymous anon` will result in `FATAL: role "authenticator" is not permitted to log in`. The docs should either use CREATE USER or set LOGIN when using CREATE ROLE, as I understand it.
2016-01-09 15:47:38 +01:00
Joe Nelson 2c1f9e7eac v0.3.0.3 2016-01-08 18:27:14 -08:00
daurnimator 96533fa2fe docs/examples/users.md: pgcrypto includes uuidv4 generation
No need to use the uuid-ossp extension
2016-01-08 13:20:57 +11:00
Joe Nelson 92df3d3243 Merge pull request #448 from chrishepner/readme-typo-fix
Fix README typo
2016-01-07 10:04:30 -08:00
Chris Hepner 90d393f968 Fix README typo 2016-01-07 09:21:49 -08:00
Joe Nelson a0b4cd6bf9 Add link to Caleb's awesome client 2016-01-01 22:42:33 -08:00
Joe Nelson 943c38125f Reorganized examples in docs menu 2015-12-30 17:30:44 -08:00
Joe Nelson 4b637bb54e Move permissions statements into user example
This makes it self-contained

Shorten blog permissions to compensate
2015-12-30 17:26:03 -08:00
Joe Nelson 576a38c407 Merge pull request #435 from davidthewatson/document-python-requests-jwt
Document python requests jwt
2015-12-30 13:36:42 -08:00
David Watson 5408ca26ad Add python client menu link 2015-12-30 16:32:53 -05:00
David Watson 0161007390 Remove extraneous newline 2015-12-30 16:26:25 -05:00
David Watson 7d343fdcca Rewrite docs for separate client repo 2015-12-30 15:47:49 -05:00
David Watson 07f63090bb Add link to python client repo 2015-12-30 14:43:22 -05:00
Joe Nelson 48f9ce114e Merge pull request #433 from calebmer/hotfix/consistent-escape
Consistent read query escaping
2015-12-29 13:07:26 -08:00
calebmer 3a3d4038cb Add tests for wierd usecases 2015-12-29 15:24:27 -05:00
calebmer 04e1186f08 Consistent read query escaping 2015-12-29 15:24:27 -05:00
Joe Nelson 651daa00d7 Merge pull request #439 from ruslantalpa/master
Fix bug in many-many relation detection
2015-12-26 08:26:54 -08:00
Ruslan Talpa 72002f452e add chengelog entry 2015-12-25 08:18:24 +02:00
David Watson 70ff55c8da Extract resultset size from Content-Range 2015-12-22 15:16:09 -05:00
David Watson 4626b4480b Update doc with pagination using Range header 2015-12-22 12:55:54 -05:00
Ruslan Talpa a61778dba0 Fix bug in many-many relation detection introduced but prev PR 2015-12-22 16:36:02 +02:00
David Watson bdfb0a7680 Layout tweaks 2015-12-22 01:26:23 -05:00
Joe Nelson 437a592c65 Merge pull request #434 from elimisteve/patch-1
examples/start.md: Fixed typo
2015-12-21 22:24:45 -08:00
David Watson ca2e140c30 Add example docs for python-requests-jwt 2015-12-22 01:21:22 -05:00
Steven Phillips 7e07ee7bea examples/start.md: Fixed typo
Should be 'results'
2015-12-21 22:17:17 -08:00
Joe Nelson 7bf65a95d8 Merge pull request #432 from diogob/external_auth_example
External auth example
2015-12-20 13:30:14 -08:00
Diogo Biazus 6534eeb1a2 Adds conclusion and note about token TTL 2015-12-20 15:04:18 -05:00
Diogo Biazus eef4e3c647 Tweaks the text, adds a design remark about SQL functions and adds the token TTL in HTTP cache headers 2015-12-20 14:45:22 -05:00
Diogo Biazus 1d848b8d72 Removes is_owner_or_admin and adds token expiration claim 2015-12-20 12:38:05 -05:00
Joe Nelson b26fbaf4db Merge pull request #427 from ruslantalpa/fix_407_week_authenticator_role
Fix errors in tests when setting authenticator to noinherit #407
2015-12-19 09:34:24 -08:00
Joe Nelson f1fbc98040 Merge pull request #429 from diogob/renames_v1schema_to_schema
Renames v1schema in app.json to make consistent with current parameter semantics
2015-12-18 14:16:03 -08:00
Diogo Biazus 48c041a99f Renames v1schema in app.json to make consistent with current parameter semantics 2015-12-18 16:51:42 -05:00
Diogo Biazus 30a844ff58 Adds External Authentication in docs' examples section 2015-12-17 00:36:58 -05:00
Ruslan Talpa 66a34fccdf Fix errors in tests when setting authenticator to noinherit 2015-12-16 11:44:28 +02:00
49 changed files with 2900 additions and 1372 deletions
+62 -1
View File
@@ -3,6 +3,67 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/). This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
### Added
### Fixed
## [0.3.2.0] - 2016-06-10
### Added
- Reload database schema on SIGHUP - @begriffs
- Support "-" in column names - @ruslantalpa
- Support column/node renaming `alias:column` - @ruslantalpa
- Accept posts from HTML forms - @begriffs
- Ability to order embedded entities - @ruslantalpa
- Ability to paginate using &limit and &offset parameters - @ruslantalpa
- Ability to apply limits to embedded entities and enforce --max-rows on all levels - @ruslantalpa, @begriffs
- Add allow response header in OPTIONS - @begriffs
### Fixed
- Return 401 or 403 for access denied rather than 404 - @begriffs
- Omit Content-Type header for empty body - @begriffs
- Prevent role from being changed twice - @begriffs
- Use read-only transaction for read requests - @ruslantalpa
- Include entities from the same parent table using two different foreign keys - @ruslantalpa
- Ensure that Location header in 201 response is URL-encoded - @league
- Fix garbage collector CPU leak - @ruslantalpa et al.
- Return deleted items when return=representation header is sent - @ruslantalpa
- Use table default values for empty object inserts - @begriffs
## [0.3.1.1] - 2016-03-28
### Fixed
- Preserve unicode values in insert,update,rpc (regression) - @begriffs
- Prevent duplicate call to stored procs (regression) - @begriffs
- Allow SQL functions to generate registered JWT claims - @begriffs
- Terminate gracefully on SIGTERM (for use in Docker) - @recmo
- Relation detection fix for views that depend on multiple tables - @ruslantalpa
- Avoid count on plurality=singular and allow multiple Prefer values - @ruslantalpa
## [0.3.1.0] - 2016-02-28
### Fixed
- Prevent query error from infecting later connection - @begriffs, @ruslantalpa, @nikita-volkov, @jwiegley
### Added
- Applies range headers to RPC calls - @diogob
## [0.3.0.4] - 2016-02-12
### Fixed
- Improved usage screen - @begriffs
- Reject non-POSTs to rpc endpoints - @begriffs
- Throw an error for OPTIONS on nonexistent tables - @calebmer
- Remove deadlock on simultaneous contentious updates - @ruslantalpa, @begriffs
## [0.3.0.3] - 2016-01-08
### Fixed
- Fix bug in many-many relation detection - @ruslantalpa
- Inconsistent escaping of table names in read queries - @calebmer
## [0.3.0.2] - 2015-12-16 ## [0.3.0.2] - 2015-12-16
### Fixed ### Fixed
@@ -13,7 +74,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Fix #396 include records with missing parents - @ruslantalpa - Fix #396 include records with missing parents - @ruslantalpa
- `pgFmtIdent` always quotes #388 - @calebmer - `pgFmtIdent` always quotes #388 - @calebmer
- Default schema, changed from `"1"` to `public` - @calebmer - Default schema, changed from `"1"` to `public` - @calebmer
- #414 revert to separate count query - #414 revert to separate count query - @ruslantalpa
- Fix #399, allow inserting in tables with no select privileges using "Prefer: representation=minimal" - @ruslantalpa - Fix #399, allow inserting in tables with no select privileges using "Prefer: representation=minimal" - @ruslantalpa
### Added ### Added
+27
View File
@@ -0,0 +1,27 @@
FROM debian:jessie
ENV POSTGREST_VERSION 0.3.2.0
ENV POSTGREST_SCHEMA public
ENV POSTGREST_ANONYMOUS postgres
ENV POSTGREST_JWT_SECRET thisisnotarealsecret
ENV POSTGREST_MAX_ROWS 1000000
ENV POSTGREST_POOL 200
RUN apt-get update && \
apt-get install -y tar xz-utils wget libpq-dev && \
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN wget http://github.com/begriffs/postgrest/releases/download/v${POSTGREST_VERSION}/postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
tar --xz -xvf postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
mv postgrest /usr/local/bin/postgrest && \
rm postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz
CMD exec postgrest postgres://${PG_ENV_POSTGRES_USER}:${PG_ENV_POSTGRES_PASSWORD}@${PG_PORT_5432_TCP_ADDR}:${PG_PORT_5432_TCP_PORT}/${PG_ENV_POSTGRES_DB} \
--port 3000 \
--schema ${POSTGREST_SCHEMA} \
--anonymous ${POSTGREST_ANONYMOUS} \
--pool ${POSTGREST_POOL} \
--jwt-secret ${POSTGREST_JWT_SECRET} \
--max-rows ${POSTGREST_MAX_ROWS}
EXPOSE 3000
+3 -2
View File
@@ -5,6 +5,7 @@
<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)
[![Docker Hub](https://img.shields.io/badge/Docker%20Hub-%E2%86%92-blue.svg)](https://hub.docker.com/r/begriffs/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
@@ -117,7 +118,7 @@ views. You run an instance of PostgREST per schema and route requests
among them with a reverse proxy such as [nginx](http://nginx.org). among them with a reverse proxy such as [nginx](http://nginx.org).
Learn more [here](http://postgrest.com/admin/versioning/). Learn more [here](http://postgrest.com/admin/versioning/).
### Self-documention ### Self-documentation
Rather than writing and maintaining separate docs yourself let the Rather than writing and maintaining separate docs yourself let the
API explain its own affordances using HTTP. All PostgREST endpoints API explain its own affordances using HTTP. All PostgREST endpoints
@@ -125,7 +126,7 @@ respond to the OPTIONS verb and explain what they support as well
as the data format of their JSON payload. RAML support is an upcoming as the data format of their JSON payload. RAML support is an upcoming
feature. feature.
The project uses HTTP itself to commicate other metadata. For The project uses HTTP itself to communicate other metadata. For
instance the number of rows returned by an endpoint is reported by - 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).
+3 -3
View File
@@ -10,7 +10,7 @@
}, },
"POSTGREST_VER": { "POSTGREST_VER": {
"description": "Version of PostgREST to deploy", "description": "Version of PostgREST to deploy",
"value": "0.3.0.2" "value": "0.3.2.0"
}, },
"DB_NAME": { "DB_NAME": {
"description": "Database name", "description": "Database name",
@@ -47,8 +47,8 @@
"required": false, "required": false,
"value": "secret" "value": "secret"
}, },
"V1SCHEMA": { "SCHEMA": {
"description": "DB schema selected whe no version (or version 1) requested", "description": "DB schema to be exported",
"required": false, "required": false,
"value": "1" "value": "1"
} }
+22 -11
View File
@@ -1,16 +1,27 @@
machine: dependencies:
cache_directories:
- "~/.stack"
- ".stack-work"
pre: pre:
- 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
- sudo mv /tmp/stack-1.1.2-linux-x86_64/stack /usr/bin
- sudo apt-get update; sudo apt-get install --only-upgrade binutils
- createuser --superuser --no-password postgrest_test - createuser --superuser --no-password postgrest_test
- createdb -O postgrest_test -U ubuntu postgrest_test - createdb -O postgrest_test -U ubuntu postgrest_test
ghc:
version: 7.10.1
dependencies:
override: override:
- cabal update - stack setup
- cabal sandbox init - rm -fr $(stack path --dist-dir) $(stack path --local-install-root)
- cabal install --upgrade-dependencies --constraint="template-haskell installed" --dependencies-only --enable-tests - stack install hlint packdeps cabal-install
- cabal configure --enable-tests -f ci - stack build
- stack build --test --no-run-tests
test: test:
post: override:
- cabal exec hlint -- -X QuasiQuotes src/**/*.hs test/**/*.hs - stack test
- cabal exec packdeps postgrest.cabal || true - git ls-files | grep '\.l\?hs$' | xargs stack exec -- hlint -X QuasiQuotes "$@"
- 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
+1 -1
View File
@@ -60,7 +60,7 @@ for anonymous users, one for authors, and another for the authenticator,
you would set it up like this you would set it up like this
```sql ```sql
CREATE ROLE authenticator NOINHERIT; CREATE ROLE authenticator NOINHERIT LOGIN;
CREATE ROLE anon; CREATE ROLE anon;
CREATE ROLE author; CREATE ROLE author;
+66 -15
View File
@@ -91,15 +91,20 @@ These operators are available:
abbreviation | meaning abbreviation | meaning
------------ | ------- ------------ | -------
eq | equals eq | equals
gt | greater than
lt | less than
gte | greater than or equal gte | greater than or equal
gt | greater than
lte | less than or equal lte | less than or equal
lt | less than
neq | not equal
like | LIKE operator (use * in place of %) like | LIKE operator (use * in place of %)
ilike | ILIKE operator (use * in place of %) ilike | ILIKE operator (use * in place of %)
@@ | full-text search using to_tsquery
is | checking for exact equality (null,true,false)
in | one of a list of values e.g. `?a=in.1,2,3` in | one of a list of values e.g. `?a=in.1,2,3`
notin | not one of a list of values e.g. `?a=notin.1,2,3`
is | checking for exact equality (null,true,false)
isnot | checking for exact inequality (null,true,false)
@@ | full-text search using to_tsquery
@> | contains e.g. `?tags=@>.{example, new}`
<@ | contained in e.g. `values=<@{1,2,3}`
not | negates another operator, see below not | negates another operator, see below
To negate any operator, prefix it with `not` like `?a=not.eq.2`. To negate any operator, prefix it with `not` like `?a=not.eq.2`.
@@ -159,7 +164,7 @@ comma-separated list of columns and directions:
GET /people?order=age.desc,height.asc GET /people?order=age.desc,height.asc
``` ```
If no direction is specified it defaults to descending order: If no direction is specified it defaults to ascending order:
```HTTP ```HTTP
GET /people?order=age GET /people?order=age
@@ -172,6 +177,12 @@ GET /people?order=age.nullsfirst
GET /people?order=age.desc.nullslast GET /people?order=age.desc.nullslast
``` ```
To order the embedded items, you need to specify the tree path for the order param like so.
```HTTP
GET /projects?select=id,name,tasks{id,name}&order=id.asc&tasks.order=name.asc
```
You can also use [computed You can also use [computed
columns](http://www.postgresql.org/docs/current/interactive/xfunc-sql.html#XFUNC-SQL-COMPOSITE-FUNCTIONS) columns](http://www.postgresql.org/docs/current/interactive/xfunc-sql.html#XFUNC-SQL-COMPOSITE-FUNCTIONS)
to order the results, even though the computed to order the results, even though the computed
@@ -208,6 +219,15 @@ Range: 0-4
You can also use open-ended ranges for an offset with no limit: You can also use open-ended ranges for an offset with no limit:
`Range: 10-`. `Range: 10-`.
In addition to the `Range` header, you can use `&limit` and `&offset` parameters
to achieve the same result.
You can also set a limit (but not offset) for the embedded items like so
```HTTP
/posts?select=id,title,body,comments{id,email,body}&limit=10&comments.limit=3
```
The above request will return the first 10 posts and for each of the posts, 3 comments at most
#### Suppressing Counts #### Suppressing Counts
Sometimes knowing the total row count of a query is unnecessary and Sometimes knowing the total row count of a query is unnecessary and
@@ -258,7 +278,8 @@ but the the select query is recursive. You could for instance specify
GET /foo?select=x, y, bar{z, w, baz{*}} GET /foo?select=x, y, bar{z, w, baz{*}}
``` ```
You can select not only using table names, but also column names! You can select not only using table names, but also foreign key column names!
This is especially needed when you have a table with two foreign keys pointing to the same table, for example billing_address_id and shipping_address_id.
To embed the same foreign key row from our client example earlier To embed the same foreign key row from our client example earlier
you could do the following: you could do the following:
@@ -270,8 +291,7 @@ In the response there will be a `client_id` object containing all
the data for that row. the data for that row.
However, a `client_id` object doesn't make a lot of sense, so you However, a `client_id` object doesn't make a lot of sense, so you
could do one of two things. Create a view which renames `client_id` could do one of two things. Tell PostgREST that you want the key renamed by using the `alias` feature like so `client:client_id{*}`, or just try `client{*}`
to just `client` (this is the hard way), or just try `client{*}`
in the select parameter! PostgREST supports smart ducktype checking in the select parameter! PostgREST supports smart ducktype checking
for common foreign key names, so if your column name ends with for common foreign key names, so if your column name ends with
`_id`, `_fk`, or any variation of the two (including camelcase) `_id`, `_fk`, or any variation of the two (including camelcase)
@@ -285,6 +305,37 @@ GET /projects?id=eq.1&select=id, name, client{*}
Would embed in the `client` key the row referenced with `client_id`. Would embed in the `client` key the row referenced with `client_id`.
The `alias` feature works for embedded entities and also for regular columns. This is useful in situations where for example you use different naming conventions in the database and frontend.
The following request will produce the output below:
```HTTP
GET /orders?id=eq.1&select=orderId:id, customer:customer_id{customerId:id, customerName:name}
```
```json
[
{
"orderId": 1,
"customer": {
"customerId": 1,
"customerName": "John Smith"
}
}
]
```
If you want to apply filters to the embedded items, you can do that like so:
```HTTP
GET /clients?id=eq.42&select=id,name,projects{id,name,is_active}&projects.is_active=eq.true
```
The above request will return the client with id=42 and all the projects for that client that are still active
<div class="admonition note">
<p class="admonition-title">Design Consideration</p>
<p>In order for this feature to work as expected after a schema change, PostgREST currently requires to be restarted.</p>
</div>
### Response Format ### Response Format
Query responses default to JSON but you can get them in CSV as well. Just make your request with the header Query responses default to JSON but you can get them in CSV as well. Just make your request with the header
@@ -323,14 +374,14 @@ OPTIONS /my_view
This will include the row names, their types, primary key This will include the row names, their types, primary key
information, and foreign keys for the given table or view. information, and foreign keys for the given table or view.
<div class="admonition danger"> <div class="admonition warning">
<p class="admonition-title">Deprecation Warning</p> <p class="admonition-title">Schema Changes</p>
<p>Although we currently use the OPTIONS verb for this, some <p>Note that when the schema of your database changes PostgREST will not reflect
people <a the change. You have to either restart PostgREST or send its running process
href="https://www.mnot.net/blog/2012/10/29/NO_OPTIONS">argue</a> that a HUP signal:
this is inappropriate. We are considering a <code>describedby</code>
header link instead.</p> <pre><code>killall -HUP postgrest</code></pre>
</div> </div>
### CORS ### CORS
+88 -1
View File
@@ -71,6 +71,93 @@ returns something like
[ { "id": 1 }, { "id": 2 } ] [ { "id": 1 }, { "id": 2 } ]
``` ```
### Multiple Tables Insertion or Update
The cleanest way to insert or update data into multiple tables using only one POST/PATCH request
is to create a view that will join all target tables and present a single endpoint.
In our example let's assume one users table and one companies table.
In this case, we want a signup endpoint to create the first user within a company.
And for this endpoint we want to insert with one request both user and company.
```SQL
CREATE TABLE companies (
id serial primary key,
name text unique
);
CREATE TABLE users (
id serial primary key,
name text not null,
pass text,
company_id integer not null references companies
);
```
Having both tables created we create a view that joins them to be used
as a ```/signup``` endpoint.
```SQL
CREATE VIEW signup AS
SELECT
c.name AS company_name,
u.name AS user_name,
u.pass
FROM
public.users u
JOIN public.companies c ON c.id = u.company_id;
```
After the signup view creation, we can issue ```GET``` requests to read data
from users and companies, but any atempt to ```POST``` or ```PATCH``` data will fail.
PostgreSQL won't allow any data change on views that have a ```JOIN```
clause in their ```FROM``` without a proper ```INSTEAD OF``` trigger.
So in the example bellow we create a trigger to allow insertion of data in the signup view.
The trigger is a simple PL/pgSQL function that first inserts into the companies table and
uses the newly create company_id to create its first user.
```SQL
CREATE FUNCTION signup()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
vcompany_id int;
BEGIN
INSERT INTO companies (name) VALUES (new.company_name) RETURNING id INTO vcompany_id;
INSERT INTO users (name, pass, company_id) VALUES (new.user_name, new.pass, vcompany_id);
RETURN new;
END;
$$;
CREATE TRIGGER signup
INSTEAD OF INSERT ON signup
FOR EACH ROW
EXECUTE PROCEDURE signup();
```
After the trigger creation we can issue a normal ```POST``` request to our signup endpoint:
```HTTP
POST /signup
{ "company_name": "foo", "user_name": "bar" }
```
For an endpoint such as signup its usually not desirable to have a ```PATCH``` route for updates,
and we will skip this example for the sake of brevity. But it would be implemented in a very
similar way to our ```POST``` example.
<div class="admonition note">
<p class="admonition-title">Design Consideration</p>
<p>It's advisable to create a separate trigger for <code>UPDATE</code> and <code>INSERT</code>
avoiding conditionals that decide which is the trigger current operation.
This makes it easier to change code for (or even disable) one operation without interfering with others while
improving readability.
</p>
</div>
### Bulk Updates ### Bulk Updates
* ❌ Cannot be cached or prefetched * ❌ Cannot be cached or prefetched
@@ -99,7 +186,7 @@ basic field replacements, and not at all "incorrect."
* ❌ Cannot be cached or prefetched * ❌ Cannot be cached or prefetched
* ✅ Idempotent * ✅ Idempotent
Simply use the `DELETE` verb. All recors that match your filter Simply use the `DELETE` verb. All records that match your filter
will be removed. For instance deleting inactive users: will be removed. For instance deleting inactive users:
```HTTP ```HTTP
+47 -29
View File
@@ -6,10 +6,10 @@ the posts of other authors. Anonymous users cannot edit anything
but can sign up for author accounts. Authors can also post comments but can sign up for author accounts. Authors can also post comments
on articles. on articles.
This example builds off the previous one. We had previously created This example builds off the previous previous [User Management](users/)
a signup and login system on top of JWT. We'll use this auth system one. We had previously created a signup and login system on top of
for the blog. **Run the SQL in the previous example** first, before JWT. We'll use this auth system for the blog. **Run the SQL in the
continuing with this example. previous example** first, before continuing with this example.
For your convenience, the complete sql for the blog demo is For your convenience, the complete sql for the blog demo is
[here](https://github.com/begriffs/postgrest/blob/master/schema-templates/blog.sql). [here](https://github.com/begriffs/postgrest/blob/master/schema-templates/blog.sql).
@@ -48,31 +48,18 @@ comments (
### Permissions ### Permissions
Basic table-level permissions. We'll add an the `authenticator` On top of the `authenticator` and `anon` access granted in the
role which can't do anything itself other than switch into other previous example, blogs have an `author` role with extra permissions.
roles as directed by JWT.
```sql ```sql
create role anon;
create role author; create role author;
create role authenticator noinherit; grant author to authenticator;
grant anon, author to authenticator;
grant usage on schema public, basic_auth to anon, author; grant usage on schema public, basic_auth to author;
-- anon can create new logins and can read comments/posts
grant insert on table basic_auth.users, basic_auth.tokens to anon;
grant select on table pg_authid, basic_auth.users, posts, comments to anon;
grant execute on function
login(text,text),
request_password_reset(text),
reset_password(text,uuid,text),
signup(text, text)
to anon;
-- authors can edit comments/posts -- authors can edit comments/posts
grant select, insert, update, delete grant select, insert, update, delete
on basic_auth.tokens, basic_auth.users to anon, author; on basic_auth.tokens, basic_auth.users to author;
grant select, insert, update, delete grant select, insert, update, delete
on table users, posts, comments to author; on table users, posts, comments to author;
grant usage, select on sequence posts_id_seq, comments_id_seq to author; grant usage, select on sequence posts_id_seq, comments_id_seq to author;
@@ -84,21 +71,52 @@ security](http://www.postgresql.org/docs/9.5/static/ddl-rowsecurity.html).
Note that it requires PostgreSQL 9.5 or later. Note that it requires PostgreSQL 9.5 or later.
```sql ```sql
grant select on posts, comments to anon;
ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
drop policy if exists authors_eigenedit on posts; ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
create policy authors_eigenedit on posts
using (true) drop policy if exists posts_select_unsecure on posts;
create policy posts_select_unsecure on posts for select
using (true);
drop policy if exists comments_select_unsecure on comments;
create policy comments_select_unsecure on comments for select
using (true);
drop policy if exists authors_eigencreate on posts;
create policy authors_eigencreate on posts for insert
with check ( with check (
author = basic_auth.current_email() author = basic_auth.current_email()
); );
ALTER TABLE comments ENABLE ROW LEVEL SECURITY; drop policy if exists authors_eigencreate on comments;
drop policy if exists authors_eigenedit on comments; create policy authors_eigencreate on comments for insert
create policy authors_eigenedit on comments with check (
using (true) author = basic_auth.current_email()
);
drop policy if exists authors_eigenedit on posts;
create policy authors_eigenedit on posts for update
using (author = basic_auth.current_email())
with check ( with check (
author = basic_auth.current_email() author = basic_auth.current_email()
); );
drop policy if exists authors_eigenedit on comments;
create policy authors_eigenedit on comments for update
using (author = basic_auth.current_email())
with check (
author = basic_auth.current_email()
);
drop policy if exists authors_eigendelete on posts;
create policy authors_eigendelete on posts for delete
using (author = basic_auth.current_email());
drop policy if exists authors_eigendelete on comments;
create policy authors_eigendelete on comments for delete
using (author = basic_auth.current_email());
``` ```
Finally we need to modify the `users` view from the previous example. Finally we need to modify the `users` view from the previous example.
+193
View File
@@ -0,0 +1,193 @@
## External Authentication
API clients authenticate with [JSON Web Tokens](http://jwt.io).
PostgREST does not support any other authentication mechanism
directly, but they can be built on top. In this demo we will build
a system that works with an external authentication server
and integrates with a PostgREST server by sharing the same JWT secret.
For a better understanding of JWT and PostgREST authentication system you should read
the [User Management](users/) example as well.
I'll use a [Rails](http://rubyonrails.org) application using [Devise](https://github.com/plataformatec/devise)
just to make the example more concrete, but this could be replicated for
any other external authentication system using the same principles.
In case Rails is not your cup of tea you can continue reading and
just skip the Ruby code samples. I'll also assume
the use of JQuery for some client-side code samples for the sake of simplicity.
I won't delve into Devise authentication details, for this would require a tutorial on its own,
so I'm assuming that the reader's authentication system is already working.
### Sharing the JWT Secret
Allowing a third party to generate valid JWTs for your PostgREST API
is just a matter of sharing a secret. So you need to give your authenticator
software the same secret that was used in your API server under the ```--jwt-secret```
parameter.
This could be done easly using environment variables. You set a ```JWT_SECRET``` variable
in the environment where you run your rails app and it will be accessible in the global
variable ```ENV['JWT_SECRET']```.
### User Model
We will map each user in this example to two database roles.
So our application users are either ```admin``` or ```customer```.
If they are just visitors (not logged in) to our website they will be ```anonymous```.
One way of mapping users is to add a field in our users table indicating their database role.
I'll add a text field called role to my users table:
```sql
ALTER TABLE users ADD role text NOT NULL DEFAULT 'customer';
```
Besides the main user that PostgREST uses to connect to PostgreSQL
and the anonymous user, we will need two additional roles for our example:
* admin - to be used by users that access all the system rows.
* customer - to be used when user has restricted access to database rows.
Bellow we have the commands to create all roles that will be used:
```sql
CREATE USER authenticator NOINHERIT;
CREATE ROLE anonymous;
CREATE ROLE admin;
CREATE ROLE customer;
GRANT customer, admin, anonymous TO authenticator;
```
### Generating a JWT
Several libraries are available to generate JWT, you will find a very handy list in [their website](http://jwt.io)
under **Libraries**.
To continue our Rails example I'll use the ruby library [json_web_token](https://github.com/garyf/json_web_token).
In order to make the gem available in my Rails project I add the following line to my Gemfile:
```
gem 'json_web_token'
```
Then we create a Rails controller to serve JWTs for my authenticated users.
For this I just open a file ```app/controllers/api_tokens_controller.rb``` with the content:
```ruby
class ApiTokensController < ApplicationController
TOKEN_TTL = 1.hour
def show
unless ENV['JWT_SECRET'].present?
return render json: {error: "you need to have JWT_SECRET configured to get an API token"}, status: 500
end
unless current_user.present?
return render json: {error: "only authenticated users can request the API token"}, status: 401
end
expires_in TOKEN_TTL, public: false
render json: {token: jwt}, status: 200
end
private
def jwt
JsonWebToken.sign(claims, key: ENV['JWT_SECRET'])
end
def claims
# This token will expire 1 hour after being issued
{
role: current_user.role,
user_id: current_user.id.to_s,
exp: (Time.now + TOKEN_TTL).to_i
}
end
end
```
<div class="admonition note">
<p class="admonition-title">Token Time to Live</p>
<p>
In the code above we leverage the HTTP time based cache headers to expire the
endpoint cache at the same time as the token. In this example we have a token
that will be refresh one hour after its issuing time.
That's why both are based on the <code>TOKEN_TTL</code> constant.
</p>
</div>
We also need to create a route in the ```config/routes.rb``` file:
```ruby
resource :api_token, only: [:show]
```
Now, any authenticated user in our rails application can request an api_token making a GET
request to ```/api_token```. This endpoint will return a json object with one property
whose value is the token the API requests should use.
### Orders Endpoint
Here is how to create a view to generate an endpoint ```/orders``` filtered by
the logged in user:
```sql
ALTER DATABASE mydb SET postgrest.claims.user_id TO '';
CREATE OR REPLACE FUNCTION current_user_id()
RETURNS integer
STABLE
LANGUAGE SQL
AS $$
SELECT nullif(current_setting('postgrest.claims.user_id'), '')::integer;
$$;
CREATE SCHEMA private;
CREATE TABLE private.orders (
id serial primary key,
user_id int references users,
created_at timestamp not null default current_timestamp,
updated_at timestamp not null default current_timestamp
);
CREATE VIEW orders AS
SELECT
id, user_id, created_at, updated_at
FROM
private.orders o
WHERE
current_user = 'admin' OR o.user_id = current_user_id();
```
<div class="admonition note">
<p class="admonition-title">DRY priviledge checking conditions</p>
<p>
You can encapsulate conditions that will be commonly used to check for privileges while reading a database row.
We used a function <code>current_user_id()</code> but we could add more conditions to functions
as the system becomes more complex.<br/>
Remeber to mark your functions as <code>STABLE</code> so that PostgreSQL can inline then while planning the query.
</p>
</div>
### Using the JWT
Now whenever you are authenticated in your Rails application you can use some Javascript
code to get the token and use it:
```javascript
$.getJSON('/api_token').done(function(data){
$.ajax('/orders', {'Authorization': 'Bearer ' + data.token}).done(function(data){
console.log('Visible Orders: ', data);
})
}).fail(function(){
console.log('Error fetching API token');
})
```
We could also store the token to avoid having to fetch it again in the same page.
### Conclusion
This section explained the implementation details for building an
external authentication system working with PostgREST.
With the previous [User Management](users/) example this should give a clearer
idea of how to set up authentication for your API.
+40
View File
@@ -0,0 +1,40 @@
## Python Client for PostgREST API
### Setup PostgreSQL
This code relies on setting up the PostgreSQL auth functions and grants correctly first. Follow [these instructions](http://postgrest.com/examples/users/).
After completing the PostgreSQL configuration, be sure to create a user with email, password, role, and verified flag. We'll use that user to login in the code below.
### Setup PostgREST
Next, setup PostgREST according to the documentation [http://postgrest.com/install/server/](here).
### Setup Python Client
Finally, we'll install and configure the python client. Follow the instructions in the [README](https://github.com/davidthewatson/postgrest_python_requests_client/blob/master/README.md). Be sure to set the [credentials](https://github.com/davidthewatson/postgrest_python_requests_client/blob/master/config.in#L3-L5) and [urls](https://github.com/davidthewatson/postgrest_python_requests_client/blob/master/config.in#L7-L9) in config.py.
### Python Client Functions
There are four primary functions to the python client:
* login
* construct_jwt_auth
* get_result_size
* get_range
The *login* and *construct_jwt_auth* functions will be required for any REST client using a PostgREST server, since a JWT auth instance is presumed.
The *get_result_size* and *get_range* functions are designed specifically for result sets where pagination is required. You can certainly use them for a single page result set that does not require pagination, but that may be overkill.
### Login
The [login function](https://github.com/davidthewatson/postgrest_python_requests_client/blob/master/client.py#L12-L17) takes email and password strings (credentials.email and credentials.password, respectively from the config.py) and return the response.
### Construct JWT Auth
The [construct_jwt_auth](https://github.com/davidthewatson/postgrest_python_requests_client/blob/master/client.py#L20-L23) function takes the auth response returned by the login function, retrieves the token in the response, and returns a JWT auth instance to the caller. The JWT auth instance can then be used for successive calls to the same PostgREST service.
### Get Result Size
The [get_result_size](https://github.com/davidthewatson/postgrest_python_requests_client/blob/master/client.py#L26-L30) function takes a JWT auth instance calls the URL at urls.data, extracts the size of the result set from the response object and returns the size.
### Get Range
The [get_range](https://github.com/davidthewatson/postgrest_python_requests_client/blob/master/client.py#L26-L30) function takes a beginning range, ending range, page size, and JWT auth instance, gets only that range of the available result set and returns JSON for that result set.
+1 -1
View File
@@ -101,7 +101,7 @@ In Postman it will look like this
Notice that the post type is `raw` and that `Content-Type: text/csv` set in the Headers tab. Notice that the post type is `raw` and that `Content-Type: text/csv` set in the Headers tab.
The server returns HTTP 201 Created. Because we inserted more than one item at once there is no `Location` header in the response. However sometimes you want to learn more about items which you just inserted. To have the server include the full restuls include the header `Prefer: return=representation`. The server returns HTTP 201 Created. Because we inserted more than one item at once there is no `Location` header in the response. However sometimes you want to learn more about items which you just inserted. To have the server include the full results, include the header `Prefer: return=representation`.
At this point if you send a GET request to `/festival` it should return At this point if you send a GET request to `/festival` it should return
+45 -14
View File
@@ -8,12 +8,12 @@ a username and password system on top of JWT using only plpgsql.
Future examples such as the multi-tenant blogging platform will use Future examples such as the multi-tenant blogging platform will use
the results from this example for their auth. We will build a system the results from this example for their auth. We will build a system
for users to sign up, log in, manage their accounts, and for admins for users to sign up, log in, manage their accounts, and for admins
to manange other people's accounts. We will also see how to trigger to manage other people's accounts. We will also see how to trigger
outside events like sending password reset emails. outside events like sending password reset emails.
Before jumping into the code, a little more about how the tokens Before jumping into the code, a little more about how the tokens
work. Every JWT contains cryptographically signed *claims*. PostgREST work. Every JWT contains cryptographically signed *claims*. PostgREST
cares specificaly about a claim called `role`. When a client includes cares specifically about a claim called `role`. When a client includes
a `role` claim PostgREST executes their request using that database a `role` claim PostgREST executes their request using that database
role. role.
@@ -28,13 +28,11 @@ value.
### Storing Users and Passwords ### Storing Users and Passwords
We create a database schema especially for auth information. We'll We create a database schema especially for auth information. We'll
also need the postgres extensions also need the postgres extension
[pgcrypto](http://www.postgresql.org/docs/current/static/pgcrypto.html) and [pgcrypto](http://www.postgresql.org/docs/current/static/pgcrypto.html).
[uuid-ossp](http://www.postgresql.org/docs/current/static/uuid-ossp.html).
```sql ```sql
create extension if not exists pgcrypto; create extension if not exists pgcrypto;
create extension if not exists "uuid-ossp";
-- We put things inside the basic_auth schema to hide -- We put things inside the basic_auth schema to hide
-- them from public view. Certain public procs/views will -- them from public view. Certain public procs/views will
@@ -150,7 +148,7 @@ begin
where token_type = 'reset' where token_type = 'reset'
and tokens.email = request_password_reset.email; and tokens.email = request_password_reset.email;
select uuid_generate_v4() into tok; select gen_random_uuid() into tok;
insert into basic_auth.tokens (token, token_type, email) insert into basic_auth.tokens (token, token_type, email)
values (tok, 'reset', request_password_reset.email); values (tok, 'reset', request_password_reset.email);
perform pg_notify('reset', perform pg_notify('reset',
@@ -226,7 +224,7 @@ begin
where token_type = 'reset' where token_type = 'reset'
and tokens.email = reset_password.email; and tokens.email = reset_password.email;
select uuid_generate_v4() into tok; select gen_random_uuid() into tok;
insert into basic_auth.tokens (token, token_type, email) insert into basic_auth.tokens (token, token_type, email)
values (tok, 'reset', reset_password.email); values (tok, 'reset', reset_password.email);
perform pg_notify('reset', perform pg_notify('reset',
@@ -253,7 +251,7 @@ basic_auth.send_validation() returns trigger
declare declare
tok uuid; tok uuid;
begin begin
select uuid_generate_v4() into tok; select gen_random_uuid() into tok;
insert into basic_auth.tokens (token, token_type, email) insert into basic_auth.tokens (token, token_type, email)
values (tok, 'validation', new.email); values (tok, 'validation', new.email);
perform pg_notify('validate', perform pg_notify('validate',
@@ -296,7 +294,7 @@ where actual.role = member_of.rolname;
-- is equal to email so that user can only see themselves -- is equal to email so that user can only see themselves
``` ```
Using this view clients can see themeslves and any other users with Using this view clients can see themselves and any other users with
the right db roles. This view does not yet support inserts or updates the right db roles. This view does not yet support inserts or updates
because not all the columns refer directly to underlying columns. because not all the columns refer directly to underlying columns.
Nor do we want it to be auto-updatable because it would allow an escalation Nor do we want it to be auto-updatable because it would allow an escalation
@@ -411,14 +409,22 @@ login(email text, pass text) returns basic_auth.jwt_claims
as $$ as $$
declare declare
_role name; _role name;
_verified boolean;
_email text;
result basic_auth.jwt_claims; result basic_auth.jwt_claims;
begin begin
-- check email and password
select basic_auth.user_role(email, pass) into _role; select basic_auth.user_role(email, pass) into _role;
if _role is null then if _role is null then
raise invalid_password using message = 'invalid user or password'; raise invalid_password using message = 'invalid user or password';
end if; end if;
-- TODO; check verified flag if you care whether users -- check verified flag whether users
-- have validated their emails -- have validated their emails
_email := email;
select verified from basic_auth.users as u where u.email=_email limit 1 into _verified;
if not _verified then
raise invalid_authorization_specification using message = 'user is not verified';
end if;
select _role as role, login.email as email into result; select _role as role, login.email as email into result;
return result; return result;
end; end;
@@ -458,15 +464,16 @@ Here's a function to get the email of the currently authenticated
user. user.
```sql ```sql
-- Prevent current_setting('postgrest.claims.email') from raising
-- an exception if the setting is not present. Default it to ''.
ALTER DATABASE your_db_name SET postgrest.claims.email TO '';
create or replace function create or replace function
basic_auth.current_email() returns text basic_auth.current_email() returns text
language plpgsql language plpgsql
as $$ as $$
begin begin
return current_setting('postgrest.claims.email'); return current_setting('postgrest.claims.email');
exception
-- handle unrecognized configuration parameter error
when undefined_object then return '';
end; end;
$$; $$;
``` ```
@@ -475,6 +482,30 @@ Remember that the `login` function set the claims `email` and `role`.
You can modify `login` to set other claims as well if they are You can modify `login` to set other claims as well if they are
useful for your other SQL functions to reference later. useful for your other SQL functions to reference later.
### Permissions
Basic table-level permissions. We'll add an the `authenticator`
role which can't do anything itself other than switch into other
roles as directed by JWT.
```sql
create role anon;
create role authenticator noinherit;
grant anon to authenticator;
grant usage on schema public, basic_auth to anon;
-- anon can create new logins
grant insert on table basic_auth.users, basic_auth.tokens to anon;
grant select on table pg_authid, basic_auth.users to anon;
grant execute on function
login(text,text),
request_password_reset(text),
reset_password(text,uuid,text),
signup(text, text)
to anon;
```
### Conclusion ### Conclusion
This section explained the implementation details for building a This section explained the implementation details for building a
+2
View File
@@ -2,9 +2,11 @@
### Client-Side Libraries ### Client-Side Libraries
* [calebmer/postgrest-client](https://github.com/calebmer/postgrest-client) - Advanced JS client for the PostgREST API
* [mithril.postgrest](https://github.com/catarse/mithril.postgrest) - Mithril plugin to create and authenticate requests * [mithril.postgrest](https://github.com/catarse/mithril.postgrest) - Mithril plugin to create and authenticate requests
* [lewisjared/postgrest-request](https://github.com/lewisjared/postgrest-request) - node interface to postgrest instances * [lewisjared/postgrest-request](https://github.com/lewisjared/postgrest-request) - node interface to postgrest instances
* [JarvusInnovations/jarvus-postgrest-apikit](https://github.com/JarvusInnovations/jarvus-postgrest-apikit) - Sencha framework package for binding models/stores/proxies to PostgREST tables * [JarvusInnovations/jarvus-postgrest-apikit](https://github.com/JarvusInnovations/jarvus-postgrest-apikit) - Sencha framework package for binding models/stores/proxies to PostgREST tables
* [davidthewatson/postgrest_python_requests_client](https://github.com/davidthewatson/postgrest_python_requests_client) - python client featuring JWT auth and pagination of result sets
### Extensions ### Extensions
+41 -5
View File
@@ -46,12 +46,17 @@ wget -q -O- https://s3.amazonaws.com/download.fpcomplete.com/ubuntu/fpco.key | s
echo 'deb http://download.fpcomplete.com/ubuntu/trusty stable main'|sudo tee /etc/apt/sources.list.d/fpco.list echo 'deb http://download.fpcomplete.com/ubuntu/trusty stable main'|sudo tee /etc/apt/sources.list.d/fpco.list
sudo apt-get update && sudo apt-get install stack -y sudo apt-get update && sudo apt-get install stack -y
``` ```
* Install libpq-dev
```
sudo apt-get install -y libpq-dev
```
* Build & install in one step * Build & install in one step
```bash ```bash
git clone https://github.com/begriffs/postgrest.git git clone https://github.com/begriffs/postgrest.git
cd postgrest cd postgrest
sudo stack install --install-ghc --local-bin-path /usr/local/bin stack build --install-ghc
sudo stack install --allow-different-user --local-bin-path /usr/local/bin
``` ```
* Run the server * Run the server
@@ -61,7 +66,7 @@ If you want to run the test suite, stack can do that too: `stack test`.
### Running the Server ### Running the Server
```bash ```bash
postgrest postgres://user:pass@host:port/db [flags] postgrest postgres://user:pass@host:port/db -a anon_user [other flags]
``` ```
The user in the connection string is the "authenticator role," i.e. The user in the connection string is the "authenticator role," i.e.
@@ -69,14 +74,14 @@ a role which is used temporarily to switch into other roles depending
on the authentication request JWT. For simple API's you can use the on the authentication request JWT. For simple API's you can use the
same role for authenticator and anonymous. same role for authenticator and anonymous.
The possible flags are: The complete list of options:
<dl> <dl>
<dt>-p, --port</dt> <dt>-p, --port</dt>
<dd>The port on which the server will listen for HTTP requests. <dd>The port on which the server will listen for HTTP requests.
Defaults to 3000.</dd> Defaults to 3000.</dd>
<dt>-a, --anonymous</dt> <dt>-a, --anonymous (required)</dt>
<dd>The database role used to execute commands for those requests <dd>The database role used to execute commands for those requests
which provide no JWT authorization.</dd> which provide no JWT authorization.</dd>
@@ -90,7 +95,7 @@ The possible flags are:
<code>secret</code> but do not use the default in production! <code>secret</code> but do not use the default in production!
Load-balanced PostgREST servers should share the same secret.</dd> Load-balanced PostgREST servers should share the same secret.</dd>
<dt>-p, --pool</dt> <dt>-o, --pool</dt>
<dd>Max connections to use in db pool. Defaults to to 10, but you <dd>Max connections to use in db pool. Defaults to to 10, but you
should find an optimal value for your db by running the SQL should find an optimal value for your db by running the SQL
command <code>show max_connections;</code></dd> command <code>show max_connections;</code></dd>
@@ -113,6 +118,37 @@ The possible flags are:
file.</p> file.</p>
</div> </div>
When running `postgrest` on the same machine as PostgreSQL, it is also
possible to connect to the database using the [Unix socket]
(https://en.wikipedia.org/wiki/Unix_domain_socket) and the
[Peer Authentication method]
(http://www.postgresql.org/docs/current/static/auth-methods.html#AUTH-PEER)
as an alternative to TCP/IP communication and authentication with a password.
The Peer Authentication grants access to the database to any Unix user
who connects as a user of the same name in the database.
Since the empty host resolves to the Unix socket]
(http://www.postgresql.org/docs/current/static/libpq-connect.html#AEN42494)
and the password can be omitted in this case,
the command line is reduced to:
```sh
sudo -u user postgrest postgres://user@/db [flags]
```
where the `sudo -u user` command runs the following command as given `user`.
If you create a Unix user `postgrest` and a database user `postgrest`
for example, the command becomes:
```sh
sudo -u postgrest postgrest postgres://postgrest@/db [flags]
```
The first `postgrest` is the Unix user name, the second `postgrest`
is the name of the executable, the third `postgrest` is the name
of the database user.
### Install via Homebrew (Mac OS X) ### Install via Homebrew (Mac OS X)
You can use the Homebrew package manager to install PostgREST on Mac You can use the Homebrew package manager to install PostgREST on Mac
+87
View File
@@ -0,0 +1,87 @@
{-# LANGUAGE CPP #-}
module Main where
import PostgREST.App
import PostgREST.Config (AppConfig (..),
minimumPgVersion,
prettyVersion,
readOptions)
import PostgREST.DbStructure
import Control.Monad
import Data.Monoid ((<>))
import Data.String.Conversions (cs)
import qualified Hasql.Query as H
import qualified Hasql.Session as H
import qualified Hasql.Decoders as HD
import qualified Hasql.Encoders as HE
import qualified Hasql.Pool as P
import Network.Wai.Handler.Warp
import System.IO (BufferMode (..),
hSetBuffering, stderr,
stdin, stdout)
import Web.JWT (secret)
import Data.IORef
#ifndef mingw32_HOST_OS
import Control.Monad.IO.Class (liftIO)
import System.Posix.Signals
import Control.Concurrent (myThreadId)
import Control.Exception.Base (throwTo, AsyncException(..))
#endif
isServerVersionSupported :: H.Session Bool
isServerVersionSupported = do
ver <- H.query () pgVersion
return $ read (cs ver) >= minimumPgVersion
where
pgVersion =
H.statement "SHOW server_version_num"
HE.unit (HD.singleRow $ HD.value HD.text) True
main :: IO ()
main = do
hSetBuffering stdout LineBuffering
hSetBuffering stdin LineBuffering
hSetBuffering stderr NoBuffering
conf <- readOptions
let port = configPort conf
pgSettings = cs (configDatabase conf)
appSettings = setPort port
. setServerName (cs $ "postgrest/" <> prettyVersion)
$ defaultSettings
unless (secret "secret" /= configJwtSecret conf) $
putStrLn "WARNING, running in insecure mode, JWT secret is the default value"
Prelude.putStrLn $ "Listening on port " ++
(show $ configPort conf :: String)
pool <- P.acquire (configPool conf, 10, pgSettings)
result <- P.use pool $ do
supported <- isServerVersionSupported
unless supported $ error (
"Cannot run in this PostgreSQL version, PostgREST needs at least "
<> show minimumPgVersion)
getDbStructure (cs $ configSchema conf)
refDbStructure <- newIORef $ either (error.show) id result
#ifndef mingw32_HOST_OS
tid <- myThreadId
forM_ [sigINT, sigTERM] $ \sig ->
void $ installHandler sig (Catch $ do
P.release pool
throwTo tid UserInterrupt
) Nothing
void $ installHandler sigHUP (
Catch . void . P.use pool $ do
s <- getDbStructure (cs $ configSchema conf)
liftIO $ atomicWriteIORef refDbStructure s
) Nothing
#endif
runSettings appSettings $ postgrest conf refDbStructure pool
+2
View File
@@ -24,3 +24,5 @@ pages:
- Getting Started: examples/start.md - Getting Started: examples/start.md
- User Management: examples/users.md - User Management: examples/users.md
- Multi-Tenant Blog: examples/blog.md - Multi-Tenant Blog: examples/blog.md
- External Authentication: examples/external_auth.md
- Python Client: examples/python-requests-jwt.md
+54 -64
View File
@@ -2,7 +2,7 @@ name: postgrest
description: Reads the schema of a PostgreSQL database and creates RESTful routes description: Reads the schema of a PostgreSQL database and creates RESTful routes
for the tables and views, supporting all HTTP verbs that security for the tables and views, supporting all HTTP verbs that security
permits. permits.
version: 0.3.0.2 version: 0.3.2.0
synopsis: REST API for any Postgres database synopsis: REST API for any Postgres database
license: MIT license: MIT
license-file: LICENSE license-file: LICENSE
@@ -22,27 +22,34 @@ Flag CI
Default: False Default: False
executable postgrest executable postgrest
if flag(ci) main-is: Main.hs
ghc-options: -Wall -W -Werror
else
ghc-options: -Wall -W -O2
main-is: PostgREST/Main.hs
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
ghc-options:
-threaded
-rtsopts
"-with-rtsopts=-N -I2"
default-language: Haskell2010 default-language: Haskell2010
build-depends: aeson >= 0.8 build-depends: aeson (>= 0.8 && < 0.10) || (>= 0.11 && < 0.12)
, base >= 4.8 && < 5 , base >= 4.8 && < 6
, bytestring , bytestring
, bytestring-tree-builder == 0.2.7
, case-insensitive , case-insensitive
, cassava , cassava
, containers , containers
, contravariant
, errors , errors
, hasql >= 0.7.3 && < 0.8 , hasql == 0.19.12
, hasql-backend >= 0.4.1 && < 0.5 , hasql-pool == 0.4.1
, hasql-postgres >= 0.10.4 && < 0.11 , hasql-transaction == 0.4.5
, http-types
, interpolatedstring-perl6
, jwt , jwt
, microlens >= 0.4.2 && < 0.5
, microlens-aeson >= 2.1.1 && < 2.2
, mtl
, optparse-applicative >= 0.11 && < 0.13 , optparse-applicative >= 0.11 && < 0.13
, parsec , parsec
, postgresql-binary == 0.9.0.1
, postgrest , postgrest
, regex-tdfa , regex-tdfa
, safe >= 0.3 && < 0.4 , safe >= 0.3 && < 0.4
@@ -57,47 +64,34 @@ executable postgrest
, wai-cors , wai-cors
, wai-extra , wai-extra
, wai-middleware-static >= 0.6.0 , wai-middleware-static >= 0.6.0
, warp >= 3.0.2 , warp >= 3.1.0
, HTTP, http-types , HTTP
, MissingH
, Ranged-sets , Ranged-sets
if !os(windows) if !os(windows)
build-depends: unix >= 2.7 && < 3 build-depends: unix >= 2.7 && < 3
hs-source-dirs: src hs-source-dirs: main
other-modules: Paths_postgrest
, PostgREST.App
, PostgREST.Auth
, PostgREST.Config
, PostgREST.Error
, PostgREST.Middleware
, PostgREST.Parsers
, PostgREST.DbStructure
, PostgREST.QueryBuilder
, PostgREST.RangeQuery
, PostgREST.ApiRequest
, PostgREST.Types
library library
if flag(ci)
ghc-options: -Wall -W -Werror
else
ghc-options: -Wall -W -O2
default-language: Haskell2010 default-language: Haskell2010
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
build-depends: aeson build-depends: aeson
, base >=4.6 && <5 , base
, bytestring , bytestring
, case-insensitive , case-insensitive
, cassava , cassava
, containers , containers
, contravariant
, errors , errors
, hasql , hasql
, hasql-backend , hasql-transaction
, hasql-postgres , hasql-pool
, http-types , http-types
, interpolatedstring-perl6
, jwt , jwt
, microlens
, microlens-aeson
, mtl
, optparse-applicative , optparse-applicative
, parsec , parsec
, regex-tdfa , regex-tdfa
@@ -108,13 +102,13 @@ library
, time , time
, unordered-containers , unordered-containers
, vector , vector
, wai , HTTP
, Ranged-sets
, wai >= 3.0.1
, wai-cors , wai-cors
, wai-extra , wai-extra
, wai-middleware-static , wai-middleware-static >= 0.6.0
, HTTP , warp >= 3.1.0
, MissingH
, Ranged-sets
Other-Modules: Paths_postgrest Other-Modules: Paths_postgrest
Exposed-Modules: PostgREST.App Exposed-Modules: PostgREST.App
@@ -134,54 +128,48 @@ Test-Suite spec
Type: exitcode-stdio-1.0 Type: exitcode-stdio-1.0
Default-Language: Haskell2010 Default-Language: Haskell2010
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
Hs-Source-Dirs: test, src ghc-options: -threaded -rtsopts -with-rtsopts=-N
if flag(ci) Hs-Source-Dirs: test
ghc-options: -Wall -W -Werror
else
ghc-options: -Wall -W -O2
Main-Is: Main.hs Main-Is: Main.hs
Other-Modules: Feature.AuthSpec Other-Modules: Feature.AuthSpec
, Feature.ConcurrentSpec
, Feature.CorsSpec , Feature.CorsSpec
, Feature.DeleteSpec , Feature.DeleteSpec
, Feature.InsertSpec , Feature.InsertSpec
, Feature.QuerySpec , Feature.QuerySpec
, Feature.QueryLimitedSpec
, Feature.RangeSpec , Feature.RangeSpec
, Feature.StructureSpec , Feature.StructureSpec
, Paths_postgrest , Feature.UnicodeSpec
, PostgREST.App
, PostgREST.Auth
, PostgREST.Config
, PostgREST.Error
, PostgREST.Middleware
, PostgREST.Parsers
, PostgREST.DbStructure
, PostgREST.QueryBuilder
, PostgREST.RangeQuery
, PostgREST.ApiRequest
, PostgREST.Types
, SpecHelper , SpecHelper
, TestTypes , TestTypes
Build-Depends: aeson Build-Depends: aeson
, async
, base , base
, base64-string , base64-string
, bytestring , bytestring
, case-insensitive , case-insensitive
, cassava , cassava
, containers , containers
, contravariant
, errors , errors
, hasql , hasql
, hasql-backend , hasql-pool
, hasql-postgres , hasql-transaction
, heredoc , heredoc
, hlint , hspec
, hspec == 2.2.*
, hspec-wai , hspec-wai
, hspec-wai-json , hspec-wai-json
, http-types , http-types
, interpolatedstring-perl6
, jwt , jwt
, microlens
, microlens-aeson
, monad-control
, mtl
, optparse-applicative , optparse-applicative
, packdeps
, parsec , parsec
, postgrest
, process , process
, regex-tdfa , regex-tdfa
, safe , safe
@@ -189,12 +177,14 @@ Test-Suite spec
, string-conversions , string-conversions
, text , text
, time , time
, transformers
, transformers-base
, unordered-containers , unordered-containers
, vector , vector
, wai , wai
, wai-cors , wai-cors
, wai-extra , wai-extra
, wai-middleware-static , wai-middleware-static
, warp
, HTTP , HTTP
, MissingH
, Ranged-sets , Ranged-sets
+3 -4
View File
@@ -11,7 +11,6 @@ create role authenticator noinherit;
grant anon, author to authenticator; grant anon, author to authenticator;
create extension if not exists pgcrypto; create extension if not exists pgcrypto;
create extension if not exists "uuid-ossp";
-- We put things inside the basic_auth schema to hide -- We put things inside the basic_auth schema to hide
-- them from public view. Certain public procs/views will -- them from public view. Certain public procs/views will
@@ -97,7 +96,7 @@ basic_auth.send_validation() returns trigger
declare declare
tok uuid; tok uuid;
begin begin
select uuid_generate_v4() into tok; select gen_random_uuid() into tok;
insert into basic_auth.tokens (token, token_type, email) insert into basic_auth.tokens (token, token_type, email)
values (tok, 'validation', new.email); values (tok, 'validation', new.email);
perform pg_notify('validate', perform pg_notify('validate',
@@ -175,7 +174,7 @@ begin
where token_type = 'reset' where token_type = 'reset'
and tokens.email = request_password_reset.email; and tokens.email = request_password_reset.email;
select uuid_generate_v4() into tok; select gen_random_uuid() into tok;
insert into basic_auth.tokens (token, token_type, email) insert into basic_auth.tokens (token, token_type, email)
values (tok, 'reset', request_password_reset.email); values (tok, 'reset', request_password_reset.email);
perform pg_notify('reset', perform pg_notify('reset',
@@ -215,7 +214,7 @@ begin
where token_type = 'reset' where token_type = 'reset'
and tokens.email = reset_password.email; and tokens.email = reset_password.email;
select uuid_generate_v4() into tok; select gen_random_uuid() into tok;
insert into basic_auth.tokens (token, token_type, email) insert into basic_auth.tokens (token, token_type, email)
values (tok, 'reset', reset_password.email); values (tok, 'reset', reset_password.email);
perform pg_notify('reset', perform pg_notify('reset',
+95 -48
View File
@@ -1,26 +1,32 @@
module PostgREST.ApiRequest where module PostgREST.ApiRequest where
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import qualified Data.Csv as CSV import qualified Data.Csv as CSV
import Data.List (find) import Data.List (find, sortBy)
import qualified Data.HashMap.Strict as M import qualified Data.HashMap.Strict as M
import qualified Data.Set as S import qualified Data.Set as S
import Data.Maybe (fromMaybe, isJust, isNothing, import Data.Maybe (fromMaybe, isJust, isNothing,
listToMaybe, fromJust) listToMaybe, fromJust)
import Control.Monad (join) import Control.Arrow ((***))
import Data.Monoid ((<>)) import Control.Monad (join)
import Data.String.Conversions (cs) import Data.Monoid ((<>))
import qualified Data.Text as T import Data.Ord (comparing)
import qualified Data.Vector as V import Data.String.Conversions (cs)
import Network.Wai (Request (..)) import qualified Data.Text as T
import Network.Wai.Parse (parseHttpAccept) import Text.Read (readMaybe)
import PostgREST.RangeQuery (NonnegRange, rangeRequested) import qualified Data.Vector as V
import PostgREST.Types (QualifiedIdentifier (..), import Network.HTTP.Base (urlEncodeVars)
Schema, Payload(..), import Network.HTTP.Types.Header (hAuthorization)
UniformObjects(..)) import Network.HTTP.Types.URI (parseSimpleQuery)
import Data.Ranged.Ranges (singletonRange) import Network.Wai (Request (..))
import Network.Wai.Parse (parseHttpAccept)
import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange)
import PostgREST.Types (QualifiedIdentifier (..),
Schema, Payload(..),
UniformObjects(..))
import Data.Ranged.Ranges (singletonRange, rangeIntersection)
type RequestBody = BL.ByteString type RequestBody = BL.ByteString
@@ -28,9 +34,11 @@ type RequestBody = BL.ByteString
data Action = ActionCreate | ActionRead data Action = ActionCreate | ActionRead
| ActionUpdate | ActionDelete | ActionUpdate | ActionDelete
| ActionInfo | ActionInvoke | ActionInfo | ActionInvoke
| ActionUnknown BS.ByteString deriving Eq | ActionInappropriate
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
| TargetRoot | TargetRoot
| TargetUnknown [T.Text] | TargetUnknown [T.Text]
-- | How to return the inserted data -- | How to return the inserted data
@@ -39,8 +47,8 @@ data PreferRepresentation = Full | HeadersOnly | None deriving Eq
-- route responses and upload payloads -- route responses and upload payloads
data ContentType = ApplicationJSON | TextCSV deriving Eq data ContentType = ApplicationJSON | TextCSV deriving Eq
instance Show ContentType where instance Show ContentType where
show ApplicationJSON = "application/json" show ApplicationJSON = "application/json; charset=utf-8"
show TextCSV = "text/csv" show TextCSV = "text/csv; charset=utf-8"
{-| {-|
Describes what the user wants to do. This data type is a Describes what the user wants to do. This data type is a
@@ -50,11 +58,11 @@ instance Show ContentType where
if it is an action we are able to perform. if it is an action we are able to perform.
-} -}
data ApiRequest = ApiRequest { data ApiRequest = ApiRequest {
-- | Set to Nothing for unknown HTTP verbs -- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
iAction :: Action iAction :: Action
-- | Set to Nothing for malformed range -- | Requested range of rows within response
, iRange :: NonnegRange , iRange :: M.HashMap String NonnegRange
-- | Set to Nothing for strangely nested urls -- | The target, be it calling a proc or accessing a table
, iTarget :: Target , iTarget :: Target
-- | The content type the client most desires (or JSON if undecided) -- | The content type the client most desires (or JSON if undecided)
, iAccepts :: Either BS.ByteString ContentType , iAccepts :: Either BS.ByteString ContentType
@@ -70,27 +78,36 @@ data ApiRequest = ApiRequest {
, iFilters :: [(String, String)] , iFilters :: [(String, String)]
-- | &select parameter used to shape the response -- | &select parameter used to shape the response
, iSelect :: String , iSelect :: String
-- | &order parameter -- | &order parameters for each level
, iOrder :: Maybe String , iOrder :: [(String,String)]
-- | Alphabetized (canonical) request query string for response URLs
, iCanonicalQS :: String
-- | JSON Web Token
, iJWT :: T.Text
} }
-- | Examines HTTP request and translates it into user intent. -- | Examines HTTP request and translates it into user intent.
userApiRequest :: Schema -> Request -> RequestBody -> ApiRequest userApiRequest :: Schema -> Request -> RequestBody -> ApiRequest
userApiRequest schema req reqBody = userApiRequest schema req reqBody =
let action = case method of let action =
"GET" -> ActionRead if isTargetingProc
"POST" -> if isTargetingProc then
then ActionInvoke if method == "POST"
else ActionCreate then ActionInvoke
"PATCH" -> ActionUpdate else ActionInappropriate
"DELETE" -> ActionDelete else
"OPTIONS" -> ActionInfo case method of
other -> ActionUnknown other "GET" -> ActionRead
"POST" -> ActionCreate
"PATCH" -> ActionUpdate
"DELETE" -> ActionDelete
"OPTIONS" -> ActionInfo
_ -> ActionInappropriate
target = case path of target = case path of
[] -> TargetRoot [] -> TargetRoot
[table] -> TargetIdent [table] -> TargetIdent
$ QualifiedIdentifier schema table $ QualifiedIdentifier schema table
["rpc", proc] -> TargetIdent ["rpc", proc] -> TargetProc
$ QualifiedIdentifier schema proc $ QualifiedIdentifier schema proc
other -> TargetUnknown other other -> TargetUnknown other
payload = case pickContentType (lookupHeader "content-type") of payload = case pickContentType (lookupHeader "content-type") of
@@ -106,6 +123,13 @@ userApiRequest schema req reqBody =
Nothing -> PayloadParseError "All lines must have same number of fields" Nothing -> PayloadParseError "All lines must have same number of fields"
Just json -> PayloadJSON json) Just json -> PayloadJSON json)
(CSV.decodeByName reqBody) (CSV.decodeByName reqBody)
-- This is a Left value because form-urlencoded is not a content
-- type which we ever use for responses, only something we handle
-- just this once for requests
Left "application/x-www-form-urlencoded" ->
PayloadJSON . UniformObjects . V.singleton . M.fromList
. map (cs *** JSON.String . cs) . parseSimpleQuery
$ cs reqBody
Left accept -> Left accept ->
PayloadParseError $ PayloadParseError $
"Content-type not acceptable: " <> accept "Content-type not acceptable: " <> accept
@@ -117,18 +141,23 @@ userApiRequest schema req reqBody =
ApiRequest { ApiRequest {
iAction = action iAction = action
, iRange = if singular then singletonRange 0 else rangeRequested hdrs
, iTarget = target , iTarget = target
, iRange = M.insert "limit" (rangeIntersection headerRange urlRange) $
M.fromList [ (cs k, restrictRange (readMaybe =<< v) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k ]
, iAccepts = pickContentType $ lookupHeader "accept" , iAccepts = pickContentType $ lookupHeader "accept"
, iPayload = relevantPayload , iPayload = relevantPayload
, iPreferRepresentation = representation , iPreferRepresentation = representation
, iPreferSingular = singular , iPreferSingular = singular
, iPreferCount = not $ hasPrefer "count=none" , iPreferCount = not $ singular || hasPrefer "count=none"
, iFilters = [ (k, fromJust v) | (k,v) <- qParams, k `notElem` ["select", "order"], isJust v ] , iFilters = [ (cs k, fromJust v) | (k,v) <- qParams, isJust v, k /= "select", k /= "offset", not (endingIn ["order", "limit"] k) ]
, iSelect = if method == "DELETE" , iSelect = fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
then "*" , iOrder = [(cs k, fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
else fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams , iCanonicalQS = urlEncodeVars
, iOrder = join $ lookup "order" qParams . sortBy (comparing fst)
. map (join (***) cs)
. parseSimpleQuery
$ rawQueryString req
, iJWT = tokenStr
} }
where where
@@ -138,12 +167,30 @@ userApiRequest schema req reqBody =
hdrs = requestHeaders req hdrs = requestHeaders req
qParams = [(cs k, cs <$> v)|(k,v) <- queryString req] qParams = [(cs k, cs <$> v)|(k,v) <- queryString req]
lookupHeader = flip lookup hdrs lookupHeader = flip lookup hdrs
hasPrefer val = any (\(h,v) -> h == "Prefer" && v == val) hdrs hasPrefer :: T.Text -> Bool
hasPrefer val = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs
where
split :: BS.ByteString -> [T.Text]
split = map T.strip . T.split (==';') . cs
singular = hasPrefer "plurality=singular" singular = hasPrefer "plurality=singular"
representation representation
| hasPrefer "return=representation" = Full | hasPrefer "return=representation" = Full
| hasPrefer "return=minimal" = None | hasPrefer "return=minimal" = None
| otherwise = HeadersOnly | otherwise = HeadersOnly
auth = fromMaybe "" $ lookupHeader hAuthorization
tokenStr = case T.split (== ' ') (cs auth) of
("Bearer" : t : _) -> t
_ -> ""
endingIn:: [T.Text] -> T.Text -> Bool
endingIn xx key = lastWord `elem` xx
where lastWord = last $ T.split (=='.') key
headerRange = if singular then singletonRange 0 else rangeRequested hdrs
urlOffsetRange = rangeGeq . fromMaybe (0::Integer) $
readMaybe =<< join (lookup "offset" qParams)
urlRange = restrictRange
(readMaybe =<< join (lookup "limit" qParams))
urlOffsetRange
-- PRIVATE --------------------------------------------------------------- -- PRIVATE ---------------------------------------------------------------
+226 -128
View File
@@ -3,54 +3,53 @@
{-# LANGUAGE TupleSections #-} {-# LANGUAGE TupleSections #-}
--module PostgREST.App where --module PostgREST.App where
module PostgREST.App ( module PostgREST.App (
app postgrest
) where ) where
import Control.Applicative import Control.Applicative
import Control.Arrow ((***))
import Control.Monad (join)
import Data.Bifunctor (first) import Data.Bifunctor (first)
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Char8 as BS
import Data.Functor.Identity import Data.IORef (IORef, readIORef)
import Data.List (find, sortBy, delete) import Data.List (find, delete)
import Data.Maybe (fromMaybe, fromJust, mapMaybe) import Data.Maybe (fromMaybe, fromJust, mapMaybe)
import Data.Ord (comparing)
import Data.Ranged.Ranges (emptyRange) import Data.Ranged.Ranges (emptyRange)
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.Text (Text, replace, strip) import Data.Text (Text, replace, strip)
import Data.Tree import Data.Tree
import qualified Hasql.Pool as P
import qualified Hasql.Transaction as HT
import Text.Parsec.Error import Text.Parsec.Error
import Text.ParserCombinators.Parsec (parse) import Text.ParserCombinators.Parsec (parse)
import Network.HTTP.Base (urlEncodeVars)
import Network.HTTP.Types.Header import Network.HTTP.Types.Header
import Network.HTTP.Types.Status import Network.HTTP.Types.Status
import Network.HTTP.Types.URI (parseSimpleQuery) import Network.HTTP.Types.URI (renderSimpleQuery)
import Network.Wai import Network.Wai
import Network.Wai.Middleware.RequestLogger (logStdout)
import Data.Aeson import Data.Aeson
import Data.Aeson.Types (emptyArray) import Data.Aeson.Types (emptyArray)
import Data.Monoid import Data.Monoid
import Data.Time.Clock.POSIX (getPOSIXTime)
import qualified Data.Vector as V import qualified Data.Vector as V
import qualified Hasql as H import qualified Hasql.Transaction as H
import qualified Hasql.Backend as B
import qualified Hasql.Postgres as P import qualified Data.HashMap.Strict as M
import PostgREST.Config (AppConfig (..))
import PostgREST.Parsers
import PostgREST.DbStructure
import PostgREST.RangeQuery
import PostgREST.ApiRequest (ApiRequest(..), ContentType(..) import PostgREST.ApiRequest (ApiRequest(..), ContentType(..)
, Action(..), Target(..) , Action(..), Target(..)
, PreferRepresentation (..) , PreferRepresentation (..)
, userApiRequest) , userApiRequest)
import PostgREST.Types import PostgREST.Auth (tokenJWT, jwtClaims, containsRole)
import PostgREST.Auth (tokenJWT) import PostgREST.Config (AppConfig (..))
import PostgREST.Error (errResponse) import PostgREST.DbStructure
import PostgREST.Error (errResponse, pgErrResponse)
import PostgREST.QueryBuilder ( asJson import PostgREST.Parsers
, callProc import PostgREST.RangeQuery (NonnegRange, allRange, rangeOffset, restrictRange)
import PostgREST.Middleware
import PostgREST.QueryBuilder ( callProc
, addJoinConditions , addJoinConditions
, sourceCTEName , sourceCTEName
, requestToQuery , requestToQuery
@@ -58,12 +57,40 @@ import PostgREST.QueryBuilder ( asJson
, addRelations , addRelations
, createReadStatement , createReadStatement
, createWriteStatement , createWriteStatement
, ResultsWithCount
) )
import PostgREST.Types
import Prelude import Prelude
app :: DbStructure -> AppConfig -> RequestBody -> Request -> H.Tx P.Postgres s Response
app dbStructure conf reqBody req = postgrest :: AppConfig -> IORef DbStructure -> P.Pool -> Application
postgrest conf refDbStructure pool =
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle in
middle $ \ req respond -> do
time <- getPOSIXTime
body <- strictRequestBody req
dbStructure <- readIORef refDbStructure
let schema = cs $ configSchema conf
apiRequest = userApiRequest schema req body
eClaims = jwtClaims (configJwtSecret conf) (iJWT apiRequest) time
authed = containsRole eClaims
handleReq = runWithClaims conf eClaims (app dbStructure conf) apiRequest
txMode = transactionMode $ iAction apiRequest
resp <- either (pgErrResponse authed) id <$> P.use pool
(HT.run handleReq HT.ReadCommitted txMode)
respond resp
transactionMode :: Action -> H.Mode
transactionMode ActionRead = HT.Read
transactionMode ActionInfo = HT.Read
transactionMode _ = HT.Write
app :: DbStructure -> AppConfig -> ApiRequest -> H.Transaction Response
app dbStructure conf apiRequest =
let let
-- TODO: blow up for Left values (there is a middleware that checks the headers) -- TODO: blow up for Left values (there is a middleware that checks the headers)
contentType = either (const ApplicationJSON) id (iAccepts apiRequest) contentType = either (const ApplicationJSON) id (iAccepts apiRequest)
@@ -75,120 +102,121 @@ app dbStructure conf reqBody req =
case readSqlParts of case readSqlParts of
Left e -> return $ responseLBS status400 [jsonH] $ cs e Left e -> return $ responseLBS status400 [jsonH] $ cs e
Right (q, cq) -> do Right (q, cq) -> do
let range = restrictRange (configMaxRows conf) $ iRange apiRequest let singular = iPreferSingular apiRequest
singular = iPreferSingular apiRequest stm = createReadStatement q cq singular
stm = createReadStatement q cq range singular shouldCount (contentType == TextCSV)
(iPreferCount apiRequest) (contentType == TextCSV) respondToRange $ do
if range == emptyRange row <- H.query () stm
then return $ errResponse status416 "HTTP Range error" let (tableTotal, queryTotal, _ , body) = row
else do
row <- H.maybeEx stm
let (tableTotal, queryTotal, _ , body) = extractQueryResult row
if singular if singular
then return $ if queryTotal <= 0 then return $ if queryTotal <= 0
then responseLBS status404 [] "" then responseLBS status404 [] ""
else responseLBS status200 [contentTypeH] (fromMaybe "{}" body) else responseLBS status200 [contentTypeH] (cs body)
else do else do
let frm = rangeOffset range let (status, contentRange) = rangeHeader queryTotal tableTotal
to = frm+queryTotal-1 canonical = iCanonicalQS apiRequest
contentRange = contentRangeH frm to tableTotal
status = rangeStatus frm to tableTotal
canonical = urlEncodeVars -- should this be moved to the dbStructure (location)?
. sortBy (comparing fst)
. map (join (***) cs)
. parseSimpleQuery
$ rawQueryString req
return $ responseLBS status return $ responseLBS status
[contentTypeH, contentRange, [contentTypeH, contentRange,
("Content-Location", ("Content-Location",
"/" <> cs (qiName qi) <> "/" <> cs (qiName qi) <>
if Prelude.null canonical then "" else "?" <> cs canonical if Prelude.null canonical then "" else "?" <> cs canonical
) )
] (fromMaybe "[]" body) ] (cs body)
(ActionCreate, TargetIdent qi@(QualifiedIdentifier _ table), (ActionCreate, TargetIdent qi@(QualifiedIdentifier _ table),
Just payload@(PayloadJSON (UniformObjects rows))) -> Just payload@(PayloadJSON uniform@(UniformObjects rows))) ->
case mutateSqlParts of case mutateSqlParts of
Left e -> return $ responseLBS status400 [jsonH] $ cs e Left e -> return $ responseLBS status400 [jsonH] $ cs e
Right (sq,mq) -> do Right (sq,mq) -> do
let isSingle = (==1) $ V.length rows let isSingle = (==1) $ V.length rows
let pKeys = map pkName $ filter (filterPk schema table) allPrKeys -- would it be ok to move primary key detection in the query itself? let pKeys = map pkName $ filter (filterPk schema table) allPrKeys -- would it be ok to move primary key detection in the query itself?
let stm = createWriteStatement qi sq mq isSingle (iPreferRepresentation apiRequest) pKeys (contentType == TextCSV) payload let stm = createWriteStatement qi sq mq isSingle (iPreferRepresentation apiRequest) pKeys (contentType == TextCSV) payload
row <- H.maybeEx stm row <- H.query uniform stm
let (_, _, location, body) = extractQueryResult row let (_, _, fs, body) = extractQueryResult row
return $ responseLBS status201 header =
[ if null fs then []
contentTypeH, else [(hLocation, "/" <> cs table <> renderLocationFields fs)]
(hLocation, "/" <> cs table <> "?" <> cs (fromMaybe "" location))
]
$ if iPreferRepresentation apiRequest == Full then fromMaybe "[]" body else ""
(ActionUpdate, TargetIdent qi, Just payload@(PayloadJSON _)) -> return $ if iPreferRepresentation apiRequest == Full
then responseLBS status201 (contentTypeH : header) (cs body)
else responseLBS status201 header ""
(ActionUpdate, TargetIdent qi, Just payload@(PayloadJSON uniform)) ->
case mutateSqlParts of case mutateSqlParts of
Left e -> return $ responseLBS status400 [jsonH] $ cs e Left e -> return $ responseLBS status400 [jsonH] $ cs e
Right (sq,mq) -> do Right (sq,mq) -> do
let stm = createWriteStatement qi sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) payload let stm = createWriteStatement qi sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) payload
row <- H.maybeEx stm row <- H.query uniform stm
let (_, queryTotal, _, body) = extractQueryResult row let (_, queryTotal, _, body) = extractQueryResult row
r = contentRangeH 0 (queryTotal-1) (Just queryTotal) r = contentRangeH 0 (toInteger $ queryTotal-1) (toInteger <$> Just queryTotal)
s = case () of _ | queryTotal == 0 -> status404 s = case () of _ | queryTotal == 0 -> status404
| iPreferRepresentation apiRequest == Full -> status200 | iPreferRepresentation apiRequest == Full -> status200
| otherwise -> status204 | otherwise -> status204
return $ responseLBS s [contentTypeH, r] return $ if iPreferRepresentation apiRequest == Full
$ if iPreferRepresentation apiRequest == Full then fromMaybe "[]" body else "" then responseLBS s [contentTypeH, r] (cs body)
else responseLBS s [r] ""
(ActionDelete, TargetIdent qi, Nothing) -> (ActionDelete, TargetIdent qi, Nothing) ->
case mutateSqlParts of case mutateSqlParts of
Left e -> return $ responseLBS status400 [jsonH] $ cs e Left e -> return $ responseLBS status400 [jsonH] $ cs e
Right (sq,mq) -> do Right (sq,mq) -> do
let fakeload = PayloadJSON $ UniformObjects V.empty let emptyUniform = UniformObjects V.empty
let stm = createWriteStatement qi sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) fakeload fakeload = PayloadJSON emptyUniform
row <- H.maybeEx stm stm = createWriteStatement qi sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) fakeload
let (_, queryTotal, _, _) = extractQueryResult row row <- H.query emptyUniform stm
let (_, queryTotal, _, body) = extractQueryResult row
r = contentRangeH 1 0 (toInteger <$> Just queryTotal)
return $ if queryTotal == 0 return $ if queryTotal == 0
then notFound then notFound
else responseLBS status204 [("Content-Range", "*/"<> cs (show queryTotal))] "" else if iPreferRepresentation apiRequest == Full
then responseLBS status200 [contentTypeH, r] (cs body)
else responseLBS status204 [r] ""
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) -> do (ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->
let cols = filter (filterCol tSchema tTable) $ dbColumns dbStructure let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in
pkeys = map pkName $ filter (filterPk tSchema tTable) allPrKeys case mTable of
body = encode (TableOptions cols pkeys) Nothing -> return notFound
filterCol :: Schema -> TableName -> Column -> Bool Just table ->
filterCol sc tb (Column{colTable=Table{tableSchema=s, tableName=t}}) = s==sc && t==tb let cols = filter (filterCol tSchema tTable) $ dbColumns dbStructure
filterCol _ _ _ = False pkeys = map pkName $ filter (filterPk tSchema tTable) allPrKeys
return $ responseLBS status200 [jsonH, allOrigins] $ cs body body = encode (TableOptions cols pkeys)
filterCol :: Schema -> TableName -> Column -> Bool
filterCol sc tb Column{colTable=Table{tableSchema=s, tableName=t}} = s==sc && t==tb
filterCol _ _ _ = False
acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in
return $ responseLBS status200 [jsonH, allOrigins, acceptH] $ cs body
(ActionInvoke, TargetIdent qi, (ActionInvoke, TargetProc qi,
Just (PayloadJSON (UniformObjects payload))) -> do Just (PayloadJSON (UniformObjects payload))) -> do
exists <- doesProcExist qi exists <- H.query qi doesProcExist
if exists if exists
then do then do
let p = V.head payload let p = V.head payload
call = B.Stmt "select " V.empty True <>
asJson (callProc qi p)
jwtSecret = configJwtSecret conf jwtSecret = configJwtSecret conf
respondToRange $ do
bodyJson :: Maybe (Identity Value) <- H.maybeEx call row <- H.query () (callProc qi p topLevelRange shouldCount)
returnJWT <- doesProcReturnJWT qi returnJWT <- H.query qi doesProcReturnJWT
return $ responseLBS status200 [jsonH] let (tableTotal, queryTotal, body) = fromMaybe (Just 0, 0, emptyArray) row
(let body = fromMaybe emptyArray $ runIdentity <$> bodyJson in (status, contentRange) = rangeHeader queryTotal tableTotal
if returnJWT in
then "{\"token\":\"" <> cs (tokenJWT jwtSecret body) <> "\"}" return $ responseLBS status [jsonH, contentRange]
else cs $ encode body) (if returnJWT
then "{\"token\":\"" <> cs (tokenJWT jwtSecret body) <> "\"}"
else cs $ encode body)
else return notFound else return notFound
(ActionRead, TargetRoot, Nothing) -> do (ActionRead, TargetRoot, Nothing) -> do
body <- encode <$> accessibleTables (filter ((== cs schema) . tableSchema) (dbTables dbStructure)) body <- encode <$> H.query schema accessibleTables
return $ responseLBS status200 [jsonH] $ cs body return $ responseLBS status200 [jsonH] $ cs body
(ActionUnknown _, _, _) -> return notFound (ActionInappropriate, _, _) -> return $ responseLBS status405 [] ""
(_, TargetUnknown _, _) -> return notFound
(_, _, Just (PayloadParseError e)) -> (_, _, Just (PayloadParseError e)) ->
return $ responseLBS status400 [jsonH] $ return $ responseLBS status400 [jsonH] $
cs (formatGeneralError "Cannot parse request payload" (cs e)) cs (formatGeneralError "Cannot parse request payload" (cs e))
(_, TargetUnknown _, _) -> return notFound
(_, _, _) -> return notFound (_, _, _) -> return notFound
where where
@@ -197,23 +225,40 @@ app dbStructure conf reqBody req =
allPrKeys = dbPrimaryKeys dbStructure allPrKeys = dbPrimaryKeys dbStructure
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
schema = cs $ configSchema conf schema = cs $ configSchema conf
apiRequest = userApiRequest schema req reqBody shouldCount = iPreferCount apiRequest
readDbRequest = DbRead <$> buildReadRequest (dbRelations dbStructure) apiRequest topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest
readDbRequest = DbRead <$> buildReadRequest (configMaxRows conf) (dbRelations dbStructure) apiRequest
mutateDbRequest = DbMutate <$> buildMutateRequest apiRequest mutateDbRequest = DbMutate <$> buildMutateRequest apiRequest
selectQuery = requestToQuery schema <$> readDbRequest selectQuery = requestToQuery schema <$> readDbRequest
countQuery = requestToCountQuery schema <$> readDbRequest countQuery = requestToCountQuery schema <$> readDbRequest
mutateQuery = requestToQuery schema <$> mutateDbRequest mutateQuery = requestToQuery schema <$> mutateDbRequest
readSqlParts = (,) <$> selectQuery <*> countQuery readSqlParts = (,) <$> selectQuery <*> countQuery
mutateSqlParts = (,) <$> selectQuery <*> mutateQuery mutateSqlParts = (,) <$> selectQuery <*> mutateQuery
respondToRange response = if topLevelRange == emptyRange
then return $ errResponse status416 "HTTP Range error"
else response
rangeHeader queryTotal tableTotal = let frm = rangeOffset topLevelRange
to = frm + toInteger queryTotal - 1
contentRange = contentRangeH frm to (toInteger <$> tableTotal)
status = rangeStatus frm to (toInteger <$> tableTotal)
in (status, contentRange)
rangeStatus :: Int -> Int -> Maybe Int -> Status splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
splitKeyValue kv = (k, BS.tail v)
where (k, v) = BS.break (== '=') kv
renderLocationFields :: [BS.ByteString] -> BS.ByteString
renderLocationFields fields =
renderSimpleQuery True $ map splitKeyValue fields
rangeStatus :: Integer -> Integer -> Maybe Integer -> Status
rangeStatus _ _ Nothing = status200 rangeStatus _ _ Nothing = status200
rangeStatus frm to (Just total) rangeStatus frm to (Just total)
| frm > total = status416 | frm > total = status416
| (1 + to - frm) < total = status206 | (1 + to - frm) < total = status206
| otherwise = status200 | otherwise = status200
contentRangeH :: Int -> Int -> Maybe Int -> Header contentRangeH :: Integer -> Integer -> Maybe Integer -> Header
contentRangeH frm to total = contentRangeH frm to total =
("Content-Range", cs headerValue) ("Content-Range", cs headerValue)
where where
@@ -226,7 +271,7 @@ contentRangeH frm to total =
fromInRange = frm <= to fromInRange = frm <= to
jsonH :: Header jsonH :: Header
jsonH = (hContentType, "application/json") jsonH = (hContentType, "application/json; charset=utf-8")
formatRelationError :: Text -> Text formatRelationError :: Text -> Text
formatRelationError = formatGeneralError formatRelationError = formatGeneralError
@@ -249,68 +294,122 @@ augumentRequestWithJoin schema allRels request =
(first formatRelationError . addRelations schema allRels Nothing) request (first formatRelationError . addRelations schema allRels Nothing) request
>>= addJoinConditions schema >>= addJoinConditions schema
buildReadRequest :: [Relation] -> ApiRequest -> Either Text ReadRequest addFiltersOrdersRanges :: ApiRequest -> Either ParseError (ReadRequest -> ReadRequest)
buildReadRequest allRels apiRequest = addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
augumentRequestWithJoin schema rels =<< first formatParserError (foldr addFilter <$> (addOrder <$> readRequest <*> ord) <*> flts) flip (foldr addFilter) <$> filters,
flip (foldr addOrder) <$> orders,
flip (foldr addRange) <$> ranges
]
{-
The esence of what is going on above is that we are composing tree functions
of type (ReadRequest->ReadRequest) that are in (Either ParseError a) context
-}
where
filters :: Either ParseError [(Path, Filter)]
filters = mapM pRequestFilter flts
where
action = iAction apiRequest
flts = if action == ActionRead
then iFilters apiRequest
else filter (( '.' `elem` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update
orders :: Either ParseError [(Path, [OrderTerm])]
orders = mapM pRequestOrder $ iOrder apiRequest
ranges :: Either ParseError [(Path, NonnegRange)]
ranges = mapM pRequestRange $ M.toList $ iRange apiRequest
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either Text ReadRequest
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request
where
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
buildReadRequest :: Maybe Integer -> [Relation] -> ApiRequest -> Either Text ReadRequest
buildReadRequest maxRows allRels apiRequest =
treeRestrictRange maxRows =<<
augumentRequestWithJoin schema relations =<<
first formatParserError readRequest
where where
selStr = iSelect apiRequest
orderS = iOrder apiRequest
action = iAction apiRequest
target = iTarget apiRequest
(schema, rootTableName) = fromJust $ -- Make it safe (schema, rootTableName) = fromJust $ -- Make it safe
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)
_ -> Nothing _ -> Nothing
rootName = if action == ActionRead action :: Action
then rootTableName action = iAction apiRequest
else sourceCTEName
filters = if action == ActionRead readRequest :: Either ParseError ReadRequest
then iFilters apiRequest readRequest = addFiltersOrdersRanges apiRequest <*>
else filter (( '.' `elem` ) . fst) $ iFilters apiRequest -- there can be no filters on the root table whre we are doing insert/update parse (pRequestSelect rootName) ("failed to parse select parameter <<"++selStr++">>") selStr
rels = case action of where
selStr = iSelect apiRequest
rootName = if action == ActionRead
then rootTableName
else sourceCTEName
relations :: [Relation]
relations = case action of
ActionCreate -> fakeSourceRelations ++ allRels ActionCreate -> fakeSourceRelations ++ allRels
ActionUpdate -> fakeSourceRelations ++ allRels ActionUpdate -> fakeSourceRelations ++ allRels
ActionDelete -> fakeSourceRelations ++ allRels
_ -> allRels _ -> allRels
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation
readRequest = parse (pRequestSelect rootName) ("failed to parse select parameter <<"++selStr++">>") selStr
addOrder (Node (q,i) f) o = Node (q{order=o}, i) f
flts = mapM pRequestFilter filters
ord = traverse (parse pOrder ("failed to parse order parameter <<"++fromMaybe "" orderS++">>")) orderS
buildMutateRequest :: ApiRequest -> Either Text MutateRequest buildMutateRequest :: ApiRequest -> Either Text MutateRequest
buildMutateRequest apiRequest = buildMutateRequest apiRequest = case action of
mutateApiRequest ActionCreate -> Insert rootTableName <$> pure payload
ActionUpdate -> Update rootTableName <$> pure payload <*> filters
ActionDelete -> Delete rootTableName <$> filters
_ -> Left "Unsupported HTTP verb"
where where
action = iAction apiRequest action = iAction apiRequest
target = iTarget apiRequest
payload = fromJust $ iPayload apiRequest payload = fromJust $ iPayload apiRequest
rootTableName = -- TODO: Make it safe rootTableName = -- TODO: Make it safe
let target = iTarget apiRequest in
case target of case target of
(TargetIdent (QualifiedIdentifier _ t) ) -> t (TargetIdent (QualifiedIdentifier _ t) ) -> t
_ -> undefined _ -> undefined
mutateApiRequest = case action of filters = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters
ActionCreate -> Insert rootTableName <$> pure payload where mutateFilters = filter (not . ( '.' `elem` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table
ActionUpdate -> Update rootTableName <$> pure payload <*> cond
ActionDelete -> Delete rootTableName <$> cond addFilterToNode :: Filter -> ReadRequest -> ReadRequest
_ -> Left "Unsupported HTTP verb" addFilterToNode flt (Node (q@Select {flt_=flts}, i) f) = Node (q {flt_=flt:flts}, i) f
mutateFilters = filter (not . ( '.' `elem` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table
cond = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters
addFilter :: (Path, Filter) -> ReadRequest -> ReadRequest addFilter :: (Path, Filter) -> ReadRequest -> ReadRequest
addFilter ([], flt) (Node (q@(Select {flt_=flts}), i) forest) = Node (q {flt_=flt:flts}, i) forest addFilter = addProperty addFilterToNode
addFilter (path, flt) (Node rn forest) =
addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest
addOrderToNode o (Node (q,i) f) = Node (q{order=Just o}, i) f
addOrder :: (Path, [OrderTerm]) -> ReadRequest -> ReadRequest
addOrder = addProperty addOrderToNode
addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest
addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f
addRange :: (Path, NonnegRange) -> ReadRequest -> ReadRequest
addRange = addProperty addRangeToNode
addProperty :: (a -> ReadRequest -> ReadRequest) -> (Path, a) -> ReadRequest -> ReadRequest
addProperty f ([], a) n = f a n
addProperty f (path, a) (Node rn forest) =
case targetNode of case targetNode of
Nothing -> Node rn forest -- the filter 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 (addFilter (remainingPath, flt) tn:restForest) Just tn -> Node rn (addProperty f (remainingPath, a) tn:restForest)
where where
targetNodeName:remainingPath = path targetNodeName:remainingPath = path
(targetNode,restForest) = splitForest targetNodeName forest (targetNode,restForest) = splitForest targetNodeName forest
splitForest :: NodeName -> Forest ReadNode -> (Maybe ReadRequest, Forest ReadNode)
splitForest name forst = splitForest name forst =
case maybeNode of case maybeNode of
Nothing -> (Nothing,forest) Nothing -> (Nothing,forest)
Just node -> (Just node, delete node forest) Just node -> (Just node, delete node forest)
where maybeNode = find ((name==).fst.snd.rootLabel) forst 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" -- in a relation where one of the tables mathces "TableName"
-- replace the name to that table with pg_source -- replace the name to that table with pg_source
@@ -335,6 +434,5 @@ instance ToJSON TableOptions where
, "pkey" .= tblOptpkey t ] , "pkey" .= tblOptpkey t ]
extractQueryResult :: Maybe (Maybe Int, Int, Maybe BL.ByteString, Maybe BL.ByteString) extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount
-> (Maybe Int, Int, Maybe BL.ByteString, Maybe BL.ByteString) extractQueryResult = fromMaybe (Nothing, 0, [], "")
extractQueryResult = fromMaybe (Just 0, 0, Just "", Just "")
+51 -47
View File
@@ -12,73 +12,77 @@ In the test suite there is an example of simple login function that can be used
very simple authentication system inside the PostgreSQL database. very simple authentication system inside the PostgreSQL database.
-} -}
module PostgREST.Auth ( module PostgREST.Auth (
setRole claimsToSQL
, claimsToSQL , containsRole
, jwtClaims , jwtClaims
, tokenJWT , tokenJWT
) where ) where
import Control.Monad (join) import Lens.Micro
import Data.Aeson (Value (..), Object) import Lens.Micro.Aeson
import Data.Aeson.Types (emptyObject, emptyArray) import Data.Aeson (Value (..), parseJSON, toJSON)
import Data.Vector as V (null, head) import Data.Aeson.Types (parseMaybe, emptyObject, emptyArray)
import Data.Map as M (fromList, toList) import qualified Data.ByteString as BS
import qualified Data.Vector as V
import qualified Data.HashMap.Strict as M
import Data.Maybe (fromMaybe, maybeToList, fromJust)
import Data.Monoid ((<>)) import Data.Monoid ((<>))
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.Text (Text) import Data.Text (Text)
import Data.Time.Clock (NominalDiffTime) import Data.Time.Clock (NominalDiffTime)
import PostgREST.QueryBuilder (pgFmtLit, pgFmtIdent, unquoted) import PostgREST.QueryBuilder (pgFmtIdent, pgFmtLit, unquoted)
import qualified Web.JWT as JWT import qualified Web.JWT as JWT
import qualified Data.HashMap.Lazy as H
{-| {-|
Receives a map of JWT claims and returns a list Receives a map of JWT claims and returns a list of PostgreSQL
of PostgreSQL statements to set the claims as user defined GUCs. statements to set the claims as user defined GUCs. Except if we
Except if we have a claim called role, have a claim called role, this one is mapped to a SET ROLE
this one is mapped to a SET ROLE statement. statement.
In case there is any problem decoding the JWT it returns Nothing.
-} -}
claimsToSQL :: JWT.ClaimsMap -> [Text] claimsToSQL :: M.HashMap Text Value -> [BS.ByteString]
claimsToSQL = map setVar . toList claimsToSQL claims = roleStmts <> varStmts
where where
setVar ("role", String val) = setRole val roleStmts = maybeToList $
setVar (k, val) = "set local postgrest.claims." <> pgFmtIdent k <> (\r -> "set local role " <> r <> ";") . cs . valueToVariable <$> M.lookup "role" claims
" = " <> valueToVariable val <> ";" varStmts = map setVar $ M.toList (M.delete "role" claims)
valueToVariable = pgFmtLit . unquoted setVar (k, val) = "set local " <> cs (pgFmtIdent $ "postgrest.claims." <> k)
<> " = " <> cs (valueToVariable val) <> ";"
valueToVariable = pgFmtLit . unquoted
{-| {-|
Receives the JWT secret (from config) and a JWT and Receives the JWT secret (from config) and a JWT and
returns a map of JWT claims returns a map of JWT claims
In case there is any problem decoding the JWT it returns Nothing. In case there is any problem decoding the JWT it returns an error Text
-} -}
jwtClaims :: JWT.Secret -> Text -> NominalDiffTime -> Maybe JWT.ClaimsMap jwtClaims :: JWT.Secret -> Text -> NominalDiffTime -> Either Text (M.HashMap Text Value)
jwtClaims secret input time = jwtClaims _ "" _ = Right M.empty
case join $ claim JWT.exp of jwtClaims secret jwt time =
Just expires -> case isExpired <$> mClaims of
if JWT.secondsSinceEpoch expires > time Just True -> Left "JWT expired"
then customClaims Nothing -> Left "Invalid JWT"
else Nothing Just False -> Right $ value2map $ fromJust mClaims
_ -> customClaims where
where isExpired claims =
decoded = JWT.decodeAndVerifySignature secret input let mExp = claims ^? key "exp" . _Integer
claim :: (JWT.JWTClaimsSet -> a) -> Maybe a in fromMaybe False $ (<= time) . fromInteger <$> mExp
claim prop = prop . JWT.claims <$> decoded mClaims = toJSON . JWT.claims <$> JWT.decodeAndVerifySignature secret jwt
customClaims = claim JWT.unregisteredClaims value2map (Object o) = o
value2map _ = M.empty
-- | Receives the name of a role and returns a SET ROLE statement
setRole :: Text -> Text
setRole role = "set local role " <> cs (pgFmtLit role) <> ";"
{-| {-|
Receives the JWT secret (from config) and a JWT and a JSON value Receives the JWT secret (from config) and a JWT and a JSON value
and returns a signed JWT. and returns a signed JWT.
-} -}
tokenJWT :: JWT.Secret -> Value -> Text tokenJWT :: JWT.Secret -> Value -> Text
tokenJWT secret (Array a) = JWT.encodeSigned JWT.HS256 secret tokenJWT secret (Array arr) =
JWT.def { JWT.unregisteredClaims = fromHashMap o } let obj = if V.null arr then emptyObject else V.head arr
where jcs = parseMaybe parseJSON obj :: Maybe JWT.JWTClaimsSet in
Object o = if V.null a then emptyObject else V.head a JWT.encodeSigned JWT.HS256 secret $ fromMaybe JWT.def jcs
fromHashMap :: Object -> JWT.ClaimsMap tokenJWT secret _ = tokenJWT secret emptyArray
fromHashMap = M.fromList . H.toList
tokenJWT secret _ = tokenJWT secret emptyArray {-|
Whether a response from jwtClaims contains a role claim
-}
containsRole :: Either Text (M.HashMap Text Value) -> Bool
containsRole (Left _) = False
containsRole (Right claims) = M.member "role" claims
+8 -7
View File
@@ -30,32 +30,33 @@ import Network.Wai
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..)) import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
import Options.Applicative import Options.Applicative
import Paths_postgrest (version) import Paths_postgrest (version)
import Prelude
import Safe (readMay) import Safe (readMay)
import Web.JWT (Secret, secret) import Web.JWT (Secret, secret)
import Prelude
-- | Data type to store all command line options -- | Data type to store all command line options
data AppConfig = AppConfig { data AppConfig = AppConfig {
configDatabase :: String configDatabase :: String
, configPort :: Int
, configAnonRole :: String , configAnonRole :: String
, configSchema :: String , configSchema :: String
, configPort :: Int
, configJwtSecret :: Secret , configJwtSecret :: Secret
, configPool :: Int , configPool :: Int
, configMaxRows :: Maybe Int , configMaxRows :: Maybe Integer
, configQuiet :: Bool
} }
argParser :: Parser AppConfig argParser :: Parser AppConfig
argParser = AppConfig argParser = AppConfig
<$> argument str (help "database connection string" <> metavar "STRING") <$> argument str (help "(REQUIRED) database connection string, e.g. postgres://user:pass@host:port/db" <> metavar "DB_URL")
<*> strOption (long "anonymous" <> short 'a' <> help "(REQUIRED) postgres role to use for non-authenticated requests" <> metavar "ROLE")
<*> option auto (long "port" <> short 'p' <> help "port number on which to run HTTP server" <> metavar "PORT" <> value 3000 <> showDefault)
<*> strOption (long "anonymous" <> short 'a' <> help "postgres role to use for non-authenticated requests" <> metavar "ROLE")
<*> strOption (long "schema" <> short 's' <> help "schema to use for API routes" <> metavar "NAME" <> value "public" <> showDefault) <*> strOption (long "schema" <> short 's' <> help "schema to use for API routes" <> metavar "NAME" <> value "public" <> showDefault)
<*> option auto (long "port" <> short 'p' <> help "port number on which to run HTTP server" <> metavar "PORT" <> value 3000 <> showDefault)
<*> (secret . cs <$> <*> (secret . cs <$>
strOption (long "jwt-secret" <> short 'j' <> help "secret used to encrypt and decrypt JWT tokens" <> metavar "SECRET" <> value "secret" <> showDefault)) strOption (long "jwt-secret" <> short 'j' <> help "secret used to encrypt and decrypt JWT tokens" <> metavar "SECRET" <> value "secret" <> showDefault))
<*> option auto (long "pool" <> short 'o' <> help "max connections in database pool" <> metavar "COUNT" <> value 10 <> showDefault) <*> option auto (long "pool" <> short 'o' <> help "max connections in database pool" <> metavar "COUNT" <> value 10 <> showDefault)
<*> (readMay <$> strOption (long "max-rows" <> short 'm' <> help "max rows in response" <> metavar "COUNT" <> value "infinity" <> showDefault)) <*> (readMay <$> strOption (long "max-rows" <> short 'm' <> help "max rows in response" <> metavar "COUNT" <> value "infinity" <> showDefault))
<*> pure False
defaultCorsPolicy :: CorsResourcePolicy defaultCorsPolicy :: CorsResourcePolicy
defaultCorsPolicy = CorsResourcePolicy Nothing defaultCorsPolicy = CorsResourcePolicy Nothing
+468 -395
View File
@@ -10,28 +10,34 @@ module PostgREST.DbStructure (
, doesProcReturnJWT , doesProcReturnJWT
) where ) where
import Control.Applicative import qualified Hasql.Decoders as HD
import Control.Monad (join) import qualified Hasql.Encoders as HE
import Data.Functor.Identity import qualified Hasql.Query as H
import Data.List (elemIndex, find, subsequences, sort, transpose)
import Data.Maybe (fromMaybe, fromJust, isJust, mapMaybe, listToMaybe)
import Data.Monoid
import Data.Text (Text, split)
import qualified Hasql as H
import qualified Hasql.Postgres as P
import qualified Hasql.Backend as B
import PostgREST.Types
import GHC.Exts (groupWith) import Control.Applicative
import Control.Monad (join, replicateM)
import Data.Functor.Contravariant (contramap)
import Data.List (elemIndex, find, sort,
subsequences, transpose)
import Data.Maybe (fromJust, fromMaybe, isJust,
listToMaybe, mapMaybe)
import Data.Monoid
import Data.Text (Text, split)
import qualified Hasql.Session as H
import PostgREST.Types
import Text.InterpolatedString.Perl6 (q)
import Data.Int (Int32)
import GHC.Exts (groupWith)
import Prelude import Prelude
getDbStructure :: Schema -> H.Tx P.Postgres s DbStructure getDbStructure :: Schema -> H.Session DbStructure
getDbStructure schema = do getDbStructure schema = do
tabs <- allTables tabs <- H.query () allTables
cols <- allColumns tabs cols <- H.query () $ allColumns tabs
syns <- allSynonyms cols syns <- H.query () $ allSynonyms cols
rels <- allRelations tabs cols rels <- H.query () $ allRelations tabs cols
keys <- allPrimaryKeys tabs keys <- H.query () $ allPrimaryKeys tabs
let rels' = (addManyToManyRelations . raiseRelations schema syns . addParentRelations . addSynonymousRelations syns) rels let rels' = (addManyToManyRelations . raiseRelations schema syns . addParentRelations . addSynonymousRelations syns) rels
cols' = addForeignKeys rels' cols cols' = addForeignKeys rels' cols
@@ -44,52 +50,113 @@ getDbStructure schema = do
, dbPrimaryKeys = keys' , dbPrimaryKeys = keys'
} }
doesProc :: forall c s. B.CxValue c Int => encodeQi :: HE.Params QualifiedIdentifier
(Text -> Text -> B.Stmt c) -> QualifiedIdentifier -> H.Tx c s Bool encodeQi =
doesProc stmt qi = do contramap qiSchema (HE.value HE.text) <>
row :: Maybe (Identity Int) <- H.maybeEx $ stmt (qiSchema qi) (qiName qi) contramap qiName (HE.value HE.text)
return $ isJust row
doesProcExist :: QualifiedIdentifier -> H.Tx P.Postgres s Bool decodeTables :: HD.Result [Table]
doesProcExist = doesProc [H.stmt| decodeTables =
HD.rowsList tblRow
where
tblRow = Table <$> HD.value HD.text <*> HD.value HD.text
<*> HD.value HD.bool
decodeColumns :: [Table] -> HD.Result [Column]
decodeColumns tables =
mapMaybe (columnFromRow tables) <$> HD.rowsList colRow
where
colRow =
(,,,,,,,,,,)
<$> HD.value HD.text <*> HD.value HD.text
<*> HD.value HD.text <*> HD.value HD.int4
<*> HD.value HD.bool <*> HD.value HD.text
<*> HD.value HD.bool
<*> HD.nullableValue HD.int4
<*> HD.nullableValue HD.int4
<*> HD.nullableValue HD.text
<*> HD.nullableValue HD.text
decodeRelations :: [Table] -> [Column] -> HD.Result [Relation]
decodeRelations tables cols =
mapMaybe (relationFromRow tables cols) <$> HD.rowsList relRow
where
relRow = (,,,,,)
<$> HD.value HD.text
<*> HD.value HD.text
<*> HD.value (HD.array (HD.arrayDimension replicateM (HD.arrayValue HD.text)))
<*> HD.value HD.text
<*> HD.value HD.text
<*> HD.value (HD.array (HD.arrayDimension replicateM (HD.arrayValue HD.text)))
decodePks :: [Table] -> HD.Result [PrimaryKey]
decodePks tables =
mapMaybe (pkFromRow tables) <$> HD.rowsList pkRow
where
pkRow = (,,) <$> HD.value HD.text <*> HD.value HD.text <*> HD.value HD.text
decodeSynonyms :: [Column] -> HD.Result [(Column,Column)]
decodeSynonyms cols =
mapMaybe (synonymFromRow cols) <$> HD.rowsList synRow
where
synRow = (,,,,,)
<$> HD.value HD.text <*> HD.value HD.text
<*> HD.value HD.text <*> HD.value HD.text
<*> HD.value HD.text <*> HD.value HD.text
doesProcExist :: H.Query QualifiedIdentifier Bool
doesProcExist =
H.statement sql encodeQi (HD.singleRow (HD.value HD.bool)) True
where
sql = [q| SELECT EXISTS (
SELECT 1 SELECT 1
FROM pg_catalog.pg_namespace n FROM pg_catalog.pg_namespace n
JOIN pg_catalog.pg_proc p JOIN pg_catalog.pg_proc p
ON pronamespace = n.oid ON pronamespace = n.oid
WHERE nspname = ? WHERE nspname = $1
AND proname = ? AND proname = $2
|] ) |]
doesProcReturnJWT :: QualifiedIdentifier -> H.Tx P.Postgres s Bool doesProcReturnJWT :: H.Query QualifiedIdentifier Bool
doesProcReturnJWT = doesProc [H.stmt| doesProcReturnJWT =
H.statement sql encodeQi (HD.singleRow (HD.value HD.bool)) True
where
sql = [q| SELECT EXISTS (
SELECT 1 SELECT 1
FROM pg_catalog.pg_namespace n FROM pg_catalog.pg_namespace n
JOIN pg_catalog.pg_proc p JOIN pg_catalog.pg_proc p
ON pronamespace = n.oid ON pronamespace = n.oid
WHERE nspname = ? WHERE nspname = $1
AND proname = ? AND proname = $2
AND pg_catalog.pg_get_function_result(p.oid) like '%jwt_claims' AND pg_catalog.pg_get_function_result(p.oid) like '%jwt_claims'
|] ) |]
accessibleTables :: [Table] -> H.Tx P.Postgres s [Table] accessibleTables :: H.Query Schema [Table]
accessibleTables allTabs = do accessibleTables =
accessible <- H.listEx $ [H.stmt| H.statement sql (HE.value HE.text) decodeTables True
SELECT where
n.nspname AS table_schema, sql = [q|
c.relname AS table_name select
FROM pg_class c n.nspname as table_schema,
JOIN pg_namespace n ON n.oid = c.relnamespace relname as table_name,
WHERE c.relkind = 'r' or (c.relkind IN ('v', 'f')) and (pg_relation_is_updatable(c.oid::regclass, false) & 8) = 8
c.relkind IN ('v','r','m') AND or (exists (
n.nspname NOT IN ('pg_catalog', 'information_schema') AND ( select 1
pg_has_role(c.relowner, 'USAGE'::text) OR from pg_trigger
has_table_privilege(c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR where pg_trigger.tgrelid = c.oid and (pg_trigger.tgtype::integer & 69) = 69)
has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES'::text) ) as insertable
) from
ORDER BY table_schema, table_name pg_class c
|] join pg_namespace n on n.oid = c.relnamespace
let isAccessible table = isJust $ find (\(s,n) -> tableSchema table == s && tableName table == n) accessible where
return $ filter isAccessible allTabs c.relkind in ('v', 'r', 'm')
and n.nspname = $1
and (
pg_has_role(c.relowner, 'USAGE'::text)
or has_table_privilege(c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text)
or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES'::text)
)
order by relname |]
synonymousColumns :: [(Column,Column)] -> [Column] -> [[Column]] synonymousColumns :: [(Column,Column)] -> [Column] -> [[Column]]
synonymousColumns allSyns cols = synCols' synonymousColumns allSyns cols = synCols'
@@ -107,9 +174,9 @@ addForeignKeys rels = map addFk
addFk col = col { colFK = fk col } addFk col = col { colFK = fk col }
fk col = join $ relToFk col <$> find (lookupFn col) rels fk col = join $ relToFk col <$> find (lookupFn col) rels
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
-- lookupFn _ _ = False -- lookupFn _ _ = False
relToFk col (Relation{relColumns=cols, relFColumns=colsF}) = ForeignKey <$> colF relToFk col Relation{relColumns=cols, relFColumns=colsF} = ForeignKey <$> colF
where where
pos = elemIndex col cols pos = elemIndex col cols
colF = (colsF !!) <$> pos colF = (colsF !!) <$> pos
@@ -127,12 +194,14 @@ addParentRelations [] = []
addParentRelations (rel@(Relation t c ft fc _ _ _ _):rels) = Relation ft fc t c Parent Nothing Nothing Nothing : rel : addParentRelations rels 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 ++ 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
combinations k ns = filter ((k==).length) (subsequences ns) combinations k ns = filter ((k==).length) (subsequences ns)
addMirrorRelation [] = []
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}
@@ -161,173 +230,170 @@ synonymousPrimaryKeys syns (key:keys) = key : newKeys ++ synonymousPrimaryKeys s
keySyns = filter ((\c -> colTable c == pkTable key && colName c == pkName key) . fst) syns 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 newKeys = map ((\c -> PrimaryKey{pkTable=colTable c,pkName=colName c}) . snd) keySyns
allTables :: H.Tx P.Postgres s [Table] allTables :: H.Query () [Table]
allTables = do allTables =
rows <- H.listEx $ [H.stmt| H.statement sql HE.unit decodeTables True
SELECT where
n.nspname AS table_schema, sql = [q|
c.relname AS table_name, SELECT
c.relkind = 'r' OR (c.relkind IN ('v','f')) n.nspname AS table_schema,
AND (pg_relation_is_updatable(c.oid::regclass, FALSE) & 8) = 8 c.relname AS table_name,
OR (EXISTS c.relkind = 'r' OR (c.relkind IN ('v','f'))
( SELECT 1 AND (pg_relation_is_updatable(c.oid::regclass, FALSE) & 8) = 8
FROM pg_trigger OR (EXISTS
WHERE pg_trigger.tgrelid = c.oid ( SELECT 1
AND (pg_trigger.tgtype::integer & 69) = 69) ) AS insertable FROM pg_trigger
FROM pg_class c WHERE pg_trigger.tgrelid = c.oid
JOIN pg_namespace n ON n.oid = c.relnamespace AND (pg_trigger.tgtype::integer & 69) = 69) ) AS insertable
WHERE c.relkind IN ('v','r','m') FROM pg_class c
AND n.nspname NOT IN ('pg_catalog', 'information_schema') JOIN pg_namespace n ON n.oid = c.relnamespace
GROUP BY table_schema, table_name, insertable WHERE c.relkind IN ('v','r','m')
ORDER BY table_schema, table_name AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|] GROUP BY table_schema, table_name, insertable
return $ map tableFromRow rows ORDER BY table_schema, table_name |]
tableFromRow :: (Text, Text, Bool) -> Table allColumns :: [Table] -> H.Query () [Column]
tableFromRow (s, n, i) = Table s n i allColumns tabs =
H.statement sql HE.unit (decodeColumns tabs) True
allColumns :: [Table] -> H.Tx P.Postgres s [Column] where
allColumns tabs = do sql = [q|
cols <- H.listEx $ [H.stmt| SELECT DISTINCT
SELECT DISTINCT info.table_schema AS schema,
info.table_schema AS schema, info.table_name AS table_name,
info.table_name AS table_name, info.column_name AS name,
info.column_name AS name, info.ordinal_position AS position,
info.ordinal_position AS position, info.is_nullable::boolean AS nullable,
info.is_nullable::boolean AS nullable, info.data_type AS col_type,
info.data_type AS col_type, info.is_updatable::boolean AS updatable,
info.is_updatable::boolean AS updatable, info.character_maximum_length AS max_len,
info.character_maximum_length AS max_len, info.numeric_precision AS precision,
info.numeric_precision AS precision, info.column_default AS default_value,
info.column_default AS default_value, array_to_string(enum_info.vals, ',') AS enum
array_to_string(enum_info.vals, ',') AS enum FROM (
FROM ( /*
/* -- CTE based on information_schema.columns to remove the owner filter
-- CTE based on information_schema.columns to remove the owner filter */
*/ WITH columns AS (
WITH columns AS ( SELECT current_database()::information_schema.sql_identifier AS table_catalog,
SELECT current_database()::information_schema.sql_identifier AS table_catalog, 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, 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 WHEN a.attnotnull OR t.typtype = 'd'::"char" AND t.typnotnull THEN 'NO'::text
WHEN a.attnotnull OR t.typtype = 'd'::"char" AND t.typnotnull THEN 'NO'::text ELSE 'YES'::text
ELSE 'YES'::text END::information_schema.yes_or_no AS is_nullable,
END::information_schema.yes_or_no AS is_nullable, CASE
CASE WHEN t.typtype = 'd'::"char" THEN
WHEN t.typtype = 'd'::"char" THEN CASE
CASE WHEN bt.typelem <> 0::oid AND bt.typlen = (-1) THEN 'ARRAY'::text
WHEN bt.typelem <> 0::oid AND bt.typlen = (-1) THEN 'ARRAY'::text WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype, NULL::integer)
WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype, NULL::integer) ELSE 'USER-DEFINED'::text
ELSE 'USER-DEFINED'::text END
END ELSE
ELSE CASE
CASE WHEN t.typelem <> 0::oid AND t.typlen = (-1) THEN 'ARRAY'::text
WHEN t.typelem <> 0::oid AND t.typlen = (-1) THEN 'ARRAY'::text WHEN nt.nspname = 'pg_catalog'::name THEN format_type(a.atttypid, NULL::integer)
WHEN nt.nspname = 'pg_catalog'::name THEN format_type(a.atttypid, NULL::integer) ELSE 'USER-DEFINED'::text
ELSE 'USER-DEFINED'::text END
END END::information_schema.character_data AS data_type,
END::information_schema.character_data AS data_type, information_schema._pg_char_max_length(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS character_maximum_length,
information_schema._pg_char_max_length(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS character_maximum_length, information_schema._pg_char_octet_length(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS character_octet_length,
information_schema._pg_char_octet_length(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS character_octet_length, information_schema._pg_numeric_precision(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS numeric_precision,
information_schema._pg_numeric_precision(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS numeric_precision, information_schema._pg_numeric_precision_radix(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS numeric_precision_radix,
information_schema._pg_numeric_precision_radix(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS numeric_precision_radix, information_schema._pg_numeric_scale(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS numeric_scale,
information_schema._pg_numeric_scale(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS numeric_scale, information_schema._pg_datetime_precision(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS datetime_precision,
information_schema._pg_datetime_precision(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS datetime_precision, information_schema._pg_interval_type(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.character_data AS interval_type,
information_schema._pg_interval_type(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.character_data AS interval_type, NULL::integer::information_schema.cardinal_number AS interval_precision,
NULL::integer::information_schema.cardinal_number AS interval_precision, NULL::character varying::information_schema.sql_identifier AS character_set_catalog,
NULL::character varying::information_schema.sql_identifier AS character_set_catalog, NULL::character varying::information_schema.sql_identifier AS character_set_schema,
NULL::character varying::information_schema.sql_identifier AS character_set_schema, NULL::character varying::information_schema.sql_identifier AS character_set_name,
NULL::character varying::information_schema.sql_identifier AS character_set_name, CASE
CASE WHEN nco.nspname IS NOT NULL THEN current_database()
WHEN nco.nspname IS NOT NULL THEN current_database() ELSE NULL::name
ELSE NULL::name END::information_schema.sql_identifier AS collation_catalog,
END::information_schema.sql_identifier AS collation_catalog, nco.nspname::information_schema.sql_identifier AS collation_schema,
nco.nspname::information_schema.sql_identifier AS collation_schema, co.collname::information_schema.sql_identifier AS collation_name,
co.collname::information_schema.sql_identifier AS collation_name, CASE
CASE WHEN t.typtype = 'd'::"char" THEN current_database()
WHEN t.typtype = 'd'::"char" THEN current_database() ELSE NULL::name
ELSE NULL::name END::information_schema.sql_identifier AS domain_catalog,
END::information_schema.sql_identifier AS domain_catalog, CASE
CASE WHEN t.typtype = 'd'::"char" THEN nt.nspname
WHEN t.typtype = 'd'::"char" THEN nt.nspname ELSE NULL::name
ELSE NULL::name END::information_schema.sql_identifier AS domain_schema,
END::information_schema.sql_identifier AS domain_schema, CASE
CASE WHEN t.typtype = 'd'::"char" THEN t.typname
WHEN t.typtype = 'd'::"char" THEN t.typname ELSE NULL::name
ELSE NULL::name END::information_schema.sql_identifier AS domain_name,
END::information_schema.sql_identifier AS domain_name, current_database()::information_schema.sql_identifier AS udt_catalog,
current_database()::information_schema.sql_identifier AS udt_catalog, COALESCE(nbt.nspname, nt.nspname)::information_schema.sql_identifier AS udt_schema,
COALESCE(nbt.nspname, nt.nspname)::information_schema.sql_identifier AS udt_schema, COALESCE(bt.typname, t.typname)::information_schema.sql_identifier AS udt_name,
COALESCE(bt.typname, t.typname)::information_schema.sql_identifier AS udt_name, NULL::character varying::information_schema.sql_identifier AS scope_catalog,
NULL::character varying::information_schema.sql_identifier AS scope_catalog, NULL::character varying::information_schema.sql_identifier AS scope_schema,
NULL::character varying::information_schema.sql_identifier AS scope_schema, NULL::character varying::information_schema.sql_identifier AS scope_name,
NULL::character varying::information_schema.sql_identifier AS scope_name, NULL::integer::information_schema.cardinal_number AS maximum_cardinality,
NULL::integer::information_schema.cardinal_number AS maximum_cardinality, a.attnum::information_schema.sql_identifier AS dtd_identifier,
a.attnum::information_schema.sql_identifier AS dtd_identifier, 'NO'::character varying::information_schema.yes_or_no AS is_self_referencing,
'NO'::character varying::information_schema.yes_or_no AS is_self_referencing, 'NO'::character varying::information_schema.yes_or_no AS is_identity,
'NO'::character varying::information_schema.yes_or_no AS is_identity, NULL::character varying::information_schema.character_data AS identity_generation,
NULL::character varying::information_schema.character_data AS identity_generation, NULL::character varying::information_schema.character_data AS identity_start,
NULL::character varying::information_schema.character_data AS identity_start, NULL::character varying::information_schema.character_data AS identity_increment,
NULL::character varying::information_schema.character_data AS identity_increment, NULL::character varying::information_schema.character_data AS identity_maximum,
NULL::character varying::information_schema.character_data AS identity_maximum, NULL::character varying::information_schema.character_data AS identity_minimum,
NULL::character varying::information_schema.character_data AS identity_minimum, NULL::character varying::information_schema.yes_or_no AS identity_cycle,
NULL::character varying::information_schema.yes_or_no AS identity_cycle, 'NEVER'::character varying::information_schema.character_data AS is_generated,
'NEVER'::character varying::information_schema.character_data AS is_generated, NULL::character varying::information_schema.character_data AS generation_expression,
NULL::character varying::information_schema.character_data AS generation_expression, CASE
CASE WHEN c.relkind = 'r'::"char" OR (c.relkind = ANY (ARRAY['v'::"char", 'f'::"char"])) AND pg_column_is_updatable(c.oid::regclass, a.attnum, false) THEN 'YES'::text
WHEN c.relkind = 'r'::"char" OR (c.relkind = ANY (ARRAY['v'::"char", 'f'::"char"])) AND pg_column_is_updatable(c.oid::regclass, a.attnum, false) THEN 'YES'::text ELSE 'NO'::text
ELSE 'NO'::text END::information_schema.yes_or_no AS is_updatable
END::information_schema.yes_or_no AS is_updatable FROM pg_attribute a
FROM pg_attribute a LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum JOIN (pg_class c
JOIN (pg_class c JOIN pg_namespace nc ON c.relnamespace = nc.oid) ON a.attrelid = c.oid
JOIN pg_namespace nc ON c.relnamespace = nc.oid) ON a.attrelid = c.oid JOIN (pg_type t
JOIN (pg_type t JOIN pg_namespace nt ON t.typnamespace = nt.oid) ON a.atttypid = t.oid
JOIN pg_namespace nt ON t.typnamespace = nt.oid) ON a.atttypid = t.oid LEFT JOIN (pg_type bt
LEFT JOIN (pg_type bt JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid) ON t.typtype = 'd'::"char" AND t.typbasetype = bt.oid
JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid) ON t.typtype = 'd'::"char" AND t.typbasetype = bt.oid LEFT JOIN (pg_collation co
LEFT JOIN (pg_collation co JOIN pg_namespace nco ON co.collnamespace = nco.oid) ON a.attcollation = co.oid AND (nco.nspname <> 'pg_catalog'::name OR co.collname <> 'default'::name)
JOIN pg_namespace nco ON co.collnamespace = nco.oid) ON a.attcollation = co.oid AND (nco.nspname <> 'pg_catalog'::name OR co.collname <> 'default'::name) WHERE NOT pg_is_other_temp_schema(nc.oid) AND a.attnum > 0 AND NOT a.attisdropped AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char"]))
WHERE NOT pg_is_other_temp_schema(nc.oid) AND a.attnum > 0 AND NOT a.attisdropped AND (c.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char"])) /*--AND (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))*/ )
) SELECT
SELECT table_schema,
table_schema, table_name,
table_name, column_name,
column_name, ordinal_position,
ordinal_position, is_nullable,
is_nullable, data_type,
data_type, is_updatable,
is_updatable, character_maximum_length,
character_maximum_length, numeric_precision,
numeric_precision, column_default,
column_default, udt_name
udt_name /*-- FROM information_schema.columns*/
/*-- FROM information_schema.columns*/ FROM columns
FROM columns WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') ) AS info
) AS info LEFT OUTER JOIN (
LEFT OUTER JOIN ( SELECT
SELECT n.nspname AS s,
n.nspname AS s, t.typname AS n,
t.typname AS n, array_agg(e.enumlabel ORDER BY e.enumsortorder) AS vals
array_agg(e.enumlabel ORDER BY e.enumsortorder) AS vals FROM pg_type t
FROM pg_type t JOIN pg_enum e ON t.oid = e.enumtypid
JOIN pg_enum e ON t.oid = e.enumtypid JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace GROUP BY s,n
GROUP BY s,n ) AS enum_info ON (info.udt_name = enum_info.n)
) AS enum_info ON (info.udt_name = enum_info.n) ORDER BY schema, position |]
ORDER BY schema, position
|]
return $ mapMaybe (columnFromRow tabs) cols
columnFromRow :: [Table] -> columnFromRow :: [Table] ->
(Text, Text, Text, (Text, Text, Text,
Int, Bool, Text, Int32, Bool, Text,
Bool, Maybe Int, Maybe Int, Bool, Maybe Int32, Maybe Int32,
Maybe Text, Maybe Text) Maybe Text, Maybe Text)
-> Maybe Column -> Maybe Column
columnFromRow tabs (s, t, n, pos, nul, typ, u, l, p, d, e) = buildColumn <$> table columnFromRow tabs (s, t, n, pos, nul, typ, u, l, p, d, e) = buildColumn <$> table
@@ -337,9 +403,11 @@ columnFromRow tabs (s, t, n, pos, nul, typ, u, l, p, d, e) = buildColumn <$> tab
parseEnum :: Maybe Text -> [Text] parseEnum :: Maybe Text -> [Text]
parseEnum str = fromMaybe [] $ split (==',') <$> str parseEnum str = fromMaybe [] $ split (==',') <$> str
allRelations :: [Table] -> [Column] -> H.Tx P.Postgres s [Relation] allRelations :: [Table] -> [Column] -> H.Query () [Relation]
allRelations tabs cols = do allRelations tabs cols =
rels <- H.listEx $ [H.stmt| H.statement sql HE.unit (decodeRelations tabs cols) True
where
sql = [q|
SELECT ns1.nspname AS table_schema, SELECT ns1.nspname AS table_schema,
tab.relname AS table_name, tab.relname AS table_name,
column_info.cols AS columns, column_info.cols AS columns,
@@ -363,9 +431,7 @@ allRelations tabs cols = do
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = confrelid) AS other, LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = confrelid) AS other,
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = other.relnamespace) AS ns2 LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = other.relnamespace) AS ns2
WHERE confrelid != 0 WHERE confrelid != 0
ORDER BY (conrelid, column_info.nums) ORDER BY (conrelid, column_info.nums) |]
|]
return $ mapMaybe (relationFromRow tabs cols) rels
relationFromRow :: [Table] -> [Column] -> (Text, Text, [Text], Text, Text, [Text]) -> Maybe Relation relationFromRow :: [Table] -> [Column] -> (Text, Text, [Text], Text, Text, [Text]) -> Maybe Relation
relationFromRow allTabs allCols (rs, rt, rcs, frs, frt, frcs) = relationFromRow allTabs allCols (rs, rt, rcs, frs, frt, frcs) =
@@ -378,183 +444,190 @@ 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.Tx P.Postgres s [PrimaryKey] allPrimaryKeys :: [Table] -> H.Query () [PrimaryKey]
allPrimaryKeys tabs = do allPrimaryKeys tabs =
pks <- H.listEx $ [H.stmt| H.statement sql HE.unit (decodePks tabs) True
/* where
-- CTE to replace information_schema.table_constraints to remove owner limit sql = [q|
*/ /*
WITH tc AS ( -- CTE to replace information_schema.table_constraints to remove owner limit
SELECT current_database()::information_schema.sql_identifier AS constraint_catalog, */
nc.nspname::information_schema.sql_identifier AS constraint_schema, WITH tc AS (
c.conname::information_schema.sql_identifier AS constraint_name, SELECT current_database()::information_schema.sql_identifier AS constraint_catalog,
current_database()::information_schema.sql_identifier AS table_catalog, nc.nspname::information_schema.sql_identifier AS constraint_schema,
nr.nspname::information_schema.sql_identifier AS table_schema, c.conname::information_schema.sql_identifier AS constraint_name,
r.relname::information_schema.sql_identifier AS table_name, current_database()::information_schema.sql_identifier AS table_catalog,
CASE c.contype nr.nspname::information_schema.sql_identifier AS table_schema,
WHEN 'c'::"char" THEN 'CHECK'::text r.relname::information_schema.sql_identifier AS table_name,
WHEN 'f'::"char" THEN 'FOREIGN KEY'::text CASE c.contype
WHEN 'p'::"char" THEN 'PRIMARY KEY'::text WHEN 'c'::"char" THEN 'CHECK'::text
WHEN 'u'::"char" THEN 'UNIQUE'::text WHEN 'f'::"char" THEN 'FOREIGN KEY'::text
ELSE NULL::text WHEN 'p'::"char" THEN 'PRIMARY KEY'::text
END::information_schema.character_data AS constraint_type, WHEN 'u'::"char" THEN 'UNIQUE'::text
CASE ELSE NULL::text
WHEN c.condeferrable THEN 'YES'::text END::information_schema.character_data AS constraint_type,
ELSE 'NO'::text CASE
END::information_schema.yes_or_no AS is_deferrable, WHEN c.condeferrable THEN 'YES'::text
CASE ELSE 'NO'::text
WHEN c.condeferred THEN 'YES'::text END::information_schema.yes_or_no AS is_deferrable,
ELSE 'NO'::text CASE
END::information_schema.yes_or_no AS initially_deferred WHEN c.condeferred THEN 'YES'::text
FROM pg_namespace nc, ELSE 'NO'::text
pg_namespace nr, END::information_schema.yes_or_no AS initially_deferred
pg_constraint c, FROM pg_namespace nc,
pg_class r pg_namespace nr,
WHERE nc.oid = c.connamespace AND nr.oid = r.relnamespace AND c.conrelid = r.oid AND (c.contype <> ALL (ARRAY['t'::"char", 'x'::"char"])) AND r.relkind = 'r'::"char" AND NOT pg_is_other_temp_schema(nr.oid) pg_constraint c,
/*--AND (pg_has_role(r.relowner, 'USAGE'::text) OR has_table_privilege(r.oid, 'INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR has_any_column_privilege(r.oid, 'INSERT, UPDATE, REFERENCES'::text))*/ pg_class r
UNION ALL WHERE nc.oid = c.connamespace AND nr.oid = r.relnamespace AND c.conrelid = r.oid AND (c.contype <> ALL (ARRAY['t'::"char", 'x'::"char"])) AND r.relkind = 'r'::"char" AND NOT pg_is_other_temp_schema(nr.oid)
SELECT current_database()::information_schema.sql_identifier AS constraint_catalog, /*--AND (pg_has_role(r.relowner, 'USAGE'::text) OR has_table_privilege(r.oid, 'INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR has_any_column_privilege(r.oid, 'INSERT, UPDATE, REFERENCES'::text))*/
nr.nspname::information_schema.sql_identifier AS constraint_schema, UNION ALL
(((((nr.oid::text || '_'::text) || r.oid::text) || '_'::text) || a.attnum::text) || '_not_null'::text)::information_schema.sql_identifier AS constraint_name, SELECT current_database()::information_schema.sql_identifier AS constraint_catalog,
current_database()::information_schema.sql_identifier AS table_catalog, nr.nspname::information_schema.sql_identifier AS constraint_schema,
nr.nspname::information_schema.sql_identifier AS table_schema, (((((nr.oid::text || '_'::text) || r.oid::text) || '_'::text) || a.attnum::text) || '_not_null'::text)::information_schema.sql_identifier AS constraint_name,
r.relname::information_schema.sql_identifier AS table_name, current_database()::information_schema.sql_identifier AS table_catalog,
'CHECK'::character varying::information_schema.character_data AS constraint_type, nr.nspname::information_schema.sql_identifier AS table_schema,
'NO'::character varying::information_schema.yes_or_no AS is_deferrable, r.relname::information_schema.sql_identifier AS table_name,
'NO'::character varying::information_schema.yes_or_no AS initially_deferred 'CHECK'::character varying::information_schema.character_data AS constraint_type,
FROM pg_namespace nr, 'NO'::character varying::information_schema.yes_or_no AS is_deferrable,
pg_class r, 'NO'::character varying::information_schema.yes_or_no AS initially_deferred
pg_attribute a FROM pg_namespace nr,
WHERE nr.oid = r.relnamespace AND r.oid = a.attrelid AND a.attnotnull AND a.attnum > 0 AND NOT a.attisdropped AND r.relkind = 'r'::"char" AND NOT pg_is_other_temp_schema(nr.oid) pg_class r,
/*--AND (pg_has_role(r.relowner, 'USAGE'::text) OR has_table_privilege(r.oid, 'INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR has_any_column_privilege(r.oid, 'INSERT, UPDATE, REFERENCES'::text))*/ pg_attribute a
), WHERE nr.oid = r.relnamespace AND r.oid = a.attrelid AND a.attnotnull AND a.attnum > 0 AND NOT a.attisdropped AND r.relkind = 'r'::"char" AND NOT pg_is_other_temp_schema(nr.oid)
/* /*--AND (pg_has_role(r.relowner, 'USAGE'::text) OR has_table_privilege(r.oid, 'INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR has_any_column_privilege(r.oid, 'INSERT, UPDATE, REFERENCES'::text))*/
-- CTE to replace information_schema.key_column_usage to remove owner limit ),
*/ /*
kc AS ( -- CTE to replace information_schema.key_column_usage to remove owner limit
SELECT current_database()::information_schema.sql_identifier AS constraint_catalog, */
ss.nc_nspname::information_schema.sql_identifier AS constraint_schema, kc AS (
ss.conname::information_schema.sql_identifier AS constraint_name, SELECT current_database()::information_schema.sql_identifier AS constraint_catalog,
current_database()::information_schema.sql_identifier AS table_catalog, ss.nc_nspname::information_schema.sql_identifier AS constraint_schema,
ss.nr_nspname::information_schema.sql_identifier AS table_schema, ss.conname::information_schema.sql_identifier AS constraint_name,
ss.relname::information_schema.sql_identifier AS table_name, current_database()::information_schema.sql_identifier AS table_catalog,
a.attname::information_schema.sql_identifier AS column_name, ss.nr_nspname::information_schema.sql_identifier AS table_schema,
(ss.x).n::information_schema.cardinal_number AS ordinal_position, ss.relname::information_schema.sql_identifier AS table_name,
CASE a.attname::information_schema.sql_identifier AS column_name,
WHEN ss.contype = 'f'::"char" THEN information_schema._pg_index_position(ss.conindid, ss.confkey[(ss.x).n]) (ss.x).n::information_schema.cardinal_number AS ordinal_position,
ELSE NULL::integer CASE
END::information_schema.cardinal_number AS position_in_unique_constraint WHEN ss.contype = 'f'::"char" THEN information_schema._pg_index_position(ss.conindid, ss.confkey[(ss.x).n])
FROM pg_attribute a, ELSE NULL::integer
( SELECT r.oid AS roid, END::information_schema.cardinal_number AS position_in_unique_constraint
r.relname, FROM pg_attribute a,
r.relowner, ( SELECT r.oid AS roid,
nc.nspname AS nc_nspname, r.relname,
nr.nspname AS nr_nspname, r.relowner,
c.oid AS coid, nc.nspname AS nc_nspname,
c.conname, nr.nspname AS nr_nspname,
c.contype, c.oid AS coid,
c.conindid, c.conname,
c.confkey, c.contype,
c.confrelid, c.conindid,
information_schema._pg_expandarray(c.conkey) AS x c.confkey,
FROM pg_namespace nr, c.confrelid,
pg_class r, information_schema._pg_expandarray(c.conkey) AS x
pg_namespace nc, FROM pg_namespace nr,
pg_constraint c pg_class r,
WHERE nr.oid = r.relnamespace AND r.oid = c.conrelid AND nc.oid = c.connamespace AND (c.contype = ANY (ARRAY['p'::"char", 'u'::"char", 'f'::"char"])) AND r.relkind = 'r'::"char" AND NOT pg_is_other_temp_schema(nr.oid)) ss pg_namespace nc,
WHERE ss.roid = a.attrelid AND a.attnum = (ss.x).x AND NOT a.attisdropped pg_constraint c
/*--AND (pg_has_role(ss.relowner, 'USAGE'::text) OR has_column_privilege(ss.roid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/ WHERE nr.oid = r.relnamespace AND r.oid = c.conrelid AND nc.oid = c.connamespace AND (c.contype = ANY (ARRAY['p'::"char", 'u'::"char", 'f'::"char"])) AND r.relkind = 'r'::"char" AND NOT pg_is_other_temp_schema(nr.oid)) ss
) WHERE ss.roid = a.attrelid AND a.attnum = (ss.x).x AND NOT a.attisdropped
SELECT /*--AND (pg_has_role(ss.relowner, 'USAGE'::text) OR has_column_privilege(ss.roid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
kc.table_schema, )
kc.table_name, SELECT
kc.column_name kc.table_schema,
FROM kc.table_name,
/* kc.column_name
--information_schema.table_constraints tc, FROM
--information_schema.key_column_usage kc /*
*/ --information_schema.table_constraints tc,
tc, kc --information_schema.key_column_usage kc
WHERE */
tc.constraint_type = 'PRIMARY KEY' AND tc, kc
kc.table_name = tc.table_name AND WHERE
kc.table_schema = tc.table_schema AND tc.constraint_type = 'PRIMARY KEY' AND
kc.constraint_name = tc.constraint_name AND kc.table_name = tc.table_name AND
kc.table_schema NOT IN ('pg_catalog', 'information_schema') kc.table_schema = tc.table_schema AND
|] kc.constraint_name = tc.constraint_name AND
return $ mapMaybe (pkFromRow tabs) pks kc.table_schema NOT IN ('pg_catalog', 'information_schema') |]
pkFromRow :: [Table] -> (Schema, Text, Text) -> Maybe PrimaryKey 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.Tx P.Postgres s [(Column,Column)] allSynonyms :: [Column] -> H.Query () [(Column,Column)]
allSynonyms allCols = do allSynonyms cols =
syns <- H.listEx $ [H.stmt| H.statement sql HE.unit (decodeSynonyms cols) True
WITH synonyms AS ( where
/* -- query explanation at https://gist.github.com/ruslantalpa/2eab8c930a65e8043d8f
-- CTE to replace the view from information_schema because the information in it depended on the logged in role sql = [q|
-- notice the commented line WITH view_columns AS (
*/ SELECT
WITH view_column_usage AS ( c.oid AS view_oid,
SELECT DISTINCT a.attname::information_schema.sql_identifier AS column_name
CAST(current_database() AS character varying) AS view_catalog, FROM pg_attribute a
CAST(nv.nspname AS character varying) AS view_schema, JOIN pg_class c ON a.attrelid = c.oid
CAST(v.relname AS character varying) AS view_name, JOIN pg_namespace nc ON c.relnamespace = nc.oid
CAST(current_database() AS character varying) AS table_catalog, WHERE
CAST(nt.nspname AS character varying) AS table_schema, NOT pg_is_other_temp_schema(nc.oid)
CAST(t.relname AS character varying) AS table_name, AND a.attnum > 0
CAST(a.attname AS character varying) AS column_name AND NOT a.attisdropped
FROM pg_namespace nv, pg_class v, pg_depend dv, AND (c.relkind = 'v'::"char")
pg_depend dt, pg_class t, pg_namespace nt, AND nc.nspname NOT IN ('information_schema', 'pg_catalog')
pg_attribute a ),
WHERE nv.oid = v.relnamespace view_column_usage AS (
AND v.relkind = 'v' SELECT DISTINCT
AND v.oid = dv.refobjid v.oid as view_oid,
AND dv.refclassid = 'pg_catalog.pg_class'::regclass nv.nspname::information_schema.sql_identifier AS view_schema,
AND dv.classid = 'pg_catalog.pg_rewrite'::regclass v.relname::information_schema.sql_identifier AS view_name,
AND dv.deptype = 'i' nt.nspname::information_schema.sql_identifier AS table_schema,
AND dv.objid = dt.objid t.relname::information_schema.sql_identifier AS table_name,
AND dv.refobjid <> dt.refobjid a.attname::information_schema.sql_identifier AS column_name,
AND dt.classid = 'pg_catalog.pg_rewrite'::regclass pg_get_viewdef(v.oid)::information_schema.character_data AS view_definition
AND dt.refclassid = 'pg_catalog.pg_class'::regclass FROM pg_namespace nv
AND dt.refobjid = t.oid JOIN pg_class v ON nv.oid = v.relnamespace
AND t.relnamespace = nt.oid JOIN pg_depend dv ON v.oid = dv.refobjid
AND t.relkind IN ('r', 'v', 'f') JOIN pg_depend dt ON dv.objid = dt.objid
AND t.oid = a.attrelid JOIN pg_class t ON dt.refobjid = t.oid
AND dt.refobjsubid = a.attnum JOIN pg_namespace nt ON t.relnamespace = nt.oid
/*--AND pg_has_role(t.relowner, 'USAGE')*/ JOIN pg_attribute a ON t.oid = a.attrelid AND dt.refobjsubid = a.attnum
)
SELECT WHERE
vcu.table_schema AS src_table_schema, nv.nspname not in ('information_schema', 'pg_catalog')
vcu.table_name AS src_table_name, AND v.relkind = 'v'::"char"
vcu.column_name AS src_column_name, AND dv.refclassid = 'pg_class'::regclass::oid
view.schemaname AS syn_table_schema, AND dv.classid = 'pg_rewrite'::regclass::oid
view.viewname AS syn_table_name, AND dv.deptype = 'i'::"char"
view.definition AS view_definition AND dv.refobjid <> dt.refobjid
FROM AND dt.classid = 'pg_rewrite'::regclass::oid
pg_catalog.pg_views AS view, AND dt.refclassid = 'pg_class'::regclass::oid
view_column_usage AS vcu AND (t.relkind = ANY (ARRAY['r'::"char", 'v'::"char", 'f'::"char"]))
WHERE ),
view.schemaname = vcu.view_schema AND candidates AS (
view.viewname = vcu.view_name AND SELECT
view.schemaname NOT IN ('pg_catalog', 'information_schema') vcu.*,
/*--AND (SELECT COUNT(*) FROM information_schema.view_table_usage WHERE view_schema = view.schemaname AND view_name = view.viewname) = 1*/ (
SELECT CASE WHEN match IS NOT NULL THEN coalesce(match[7], match[4]) END
FROM REGEXP_MATCHES(
CONCAT('SELECT ', SPLIT_PART(vcu.view_definition, 'SELECT', 2)),
CONCAT('SELECT.*?((',vcu.table_name,')|(\w+))\.(', vcu.column_name, ')(\sAS\s(")?([^"]+)\6)?.*?FROM.*?',vcu.table_schema,'\.(\2|',vcu.table_name,'\s+(AS\s)?\3)'),
'ns'
) match
) AS view_column_name
FROM view_column_usage AS vcu
) )
SELECT SELECT
src_table_schema, src_table_name, src_column_name, c.table_schema,
syn_table_schema, syn_table_name, c.table_name,
(regexp_matches(view_definition, CONCAT('\.(', src_column_name, ')(?=,|$)'), 'gn'))[1] AS syn_column_name c.column_name AS table_column_name,
FROM synonyms c.view_schema,
UNION ( c.view_name,
SELECT c.view_column_name
src_table_schema, src_table_name, src_column_name, FROM view_columns AS vc, candidates AS c
syn_table_schema, syn_table_name, WHERE
(regexp_matches(view_definition, CONCAT('\.', src_column_name, '\sAS\s("?)(.+?)\1(,|$)'), 'gn'))[2] AS syn_column_name /* " <- for syntax highlighting */ vc.view_oid = c.view_oid AND
FROM synonyms vc.column_name = c.view_column_name
) ORDER BY c.view_schema, c.view_name, c.table_name, c.view_column_name
|] |]
return $ mapMaybe (synonymFromRow allCols) syns
synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe (Column,Column) synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe (Column,Column)
synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2 synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
+54 -32
View File
@@ -2,58 +2,80 @@
{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE TypeSynonymInstances #-} {-# LANGUAGE TypeSynonymInstances #-}
module PostgREST.Error (PgError, pgErrResponse, errResponse) where module PostgREST.Error (pgErrResponse, errResponse) where
import Data.Aeson ((.=)) import Data.Aeson ((.=))
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import Data.Maybe (fromMaybe)
import Data.Monoid ((<>))
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.String.Utils (replace)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Hasql as H import qualified Hasql.Pool as P
import qualified Hasql.Postgres as P import qualified Hasql.Session as H
import Network.HTTP.Types.Header 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 Network.Wai (Response, responseLBS)
type PgError = H.SessionError P.Postgres
errResponse :: HT.Status -> Text -> Response errResponse :: HT.Status -> Text -> Response
errResponse status message = responseLBS status [(hContentType, "application/json")] (cs $ T.concat ["{\"message\":\"",message,"\"}"]) errResponse status message = responseLBS status [(hContentType, "application/json")] (cs $ T.concat ["{\"message\":\"",message,"\"}"])
pgErrResponse :: PgError -> Response pgErrResponse :: Bool -> P.UsageError -> Response
pgErrResponse e = responseLBS (httpStatus e) pgErrResponse authed e =
[(hContentType, "application/json")] (JSON.encode e) let status = httpStatus authed e
jsonType = (hContentType, "application/json")
wwwAuth = ("WWW-Authenticate", "Bearer")
hdrs = if status == HT.status401
then [jsonType, wwwAuth]
else [jsonType] in
responseLBS status hdrs (JSON.encode e)
instance JSON.ToJSON PgError where instance JSON.ToJSON P.UsageError where
toJSON (H.TxError (P.ErroneousResult c m d h)) = JSON.object [ toJSON (P.ConnectionError e) = JSON.object [
"code" .= ("" :: T.Text),
"message" .= ("Connection error" :: T.Text),
"details" .= (cs (fromMaybe "" e) :: T.Text)]
toJSON (P.SessionError e) = JSON.toJSON e -- H.Error
instance JSON.ToJSON H.Error where
toJSON (H.ResultError (H.ServerError c m d h)) = JSON.object [
"code" .= (cs c::T.Text), "code" .= (cs c::T.Text),
"message" .= (cs m::T.Text), "message" .= (cs m::T.Text),
"details" .= (fmap cs d::Maybe T.Text), "details" .= (fmap cs d::Maybe T.Text),
"hint" .= (fmap cs h::Maybe T.Text)] "hint" .= (fmap cs h::Maybe T.Text)]
toJSON (H.TxError (P.NoResult d)) = JSON.object [ toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [
"message" .= ("No response from server"::T.Text), "message" .= (cs m::T.Text)]
toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [
"message" .= ("Row error: end of input"::String),
"details" .=
("Attempt to parse more columns than there are in the result"::String),
"details" .= ("Row number " <> show i)]
toJSON (H.ResultError (H.RowError i H.UnexpectedNull)) = JSON.object [
"message" .= ("Row error: unexpected null"::String),
"details" .= ("Attempt to parse a NULL as some value."::String),
"details" .= ("Row number " <> show i)]
toJSON (H.ResultError (H.RowError i (H.ValueError d))) = JSON.object [
"message" .= ("Row error: Wrong value parser used"::String),
"details" .= d,
"details" .= ("Row number " <> show i)]
toJSON (H.ResultError (H.UnexpectedAmountOfRows i)) = JSON.object [
"message" .= ("Unexpected amount of rows"::String),
"details" .= i]
toJSON (H.ClientError d) = JSON.object [
"message" .= ("Database client error"::String),
"details" .= (fmap cs d::Maybe T.Text)] "details" .= (fmap cs d::Maybe T.Text)]
toJSON (H.TxError (P.UnexpectedResult m)) = JSON.object ["message" .= m]
toJSON (H.TxError P.NotInTransaction) = JSON.object [
"message" .= ("Not in transaction"::T.Text)]
toJSON (H.CxError (P.CantConnect d)) = JSON.object [
"message" .= ("Can't connect to the database"::T.Text),
"details" .= (fmap cs d::Maybe T.Text)]
toJSON (H.CxError (P.UnsupportedVersion v)) = JSON.object [
"message" .= ("Postgres version "++version++" is not supported") ]
where version = replace "0" "." (show v)
toJSON (H.ResultError m) = JSON.object ["message" .= m]
httpStatus :: PgError -> HT.Status httpStatus :: Bool -> P.UsageError -> HT.Status
httpStatus (H.TxError (P.ErroneousResult codeBS _ _ _)) = httpStatus _ (P.ConnectionError _) = HT.status500
let code = cs codeBS in httpStatus authed (P.SessionError (H.ResultError (H.ServerError c _ _ _))) =
case code of case cs c of
'0':'8':_ -> HT.status503 -- pg connection err '0':'8':_ -> HT.status503 -- pg connection err
'0':'9':_ -> HT.status500 -- triggered action exception '0':'9':_ -> HT.status500 -- triggered action exception
'0':'L':_ -> HT.status403 -- invalid grantor '0':'L':_ -> HT.status403 -- invalid grantor
'0':'P':_ -> HT.status403 -- invalid role specification '0':'P':_ -> HT.status403 -- invalid role specification
"23503" -> HT.status409 -- foreign_key_violation
"23505" -> HT.status409 -- unique_violation
'2':'5':_ -> HT.status500 -- invalid tx state '2':'5':_ -> HT.status500 -- invalid tx state
'2':'8':_ -> HT.status403 -- invalid auth specification '2':'8':_ -> HT.status403 -- invalid auth specification
'2':'D':_ -> HT.status500 -- invalid tx termination '2':'D':_ -> HT.status500 -- invalid tx termination
@@ -70,8 +92,8 @@ httpStatus (H.TxError (P.ErroneousResult codeBS _ _ _)) =
'H':'V':_ -> HT.status500 -- foreign data wrapper error 'H':'V':_ -> HT.status500 -- foreign data wrapper error
'P':'0':_ -> HT.status500 -- PL/pgSQL Error 'P':'0':_ -> HT.status500 -- PL/pgSQL Error
'X':'X':_ -> HT.status500 -- internal Error 'X':'X':_ -> HT.status500 -- internal Error
"42P01" -> HT.status404 -- undefined table "42P01" -> HT.status404 -- undefined table
"42501" -> HT.status404 -- insufficient privilege "42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege
_ -> HT.status400 _ -> HT.status400
httpStatus (H.TxError (P.NoResult _)) = HT.status503 httpStatus _ (P.SessionError (H.ResultError _)) = HT.status500
httpStatus _ = HT.status500 httpStatus _ (P.SessionError (H.ClientError _)) = HT.status503
-97
View File
@@ -1,97 +0,0 @@
{-# LANGUAGE CPP #-}
module Main where
import PostgREST.App
import PostgREST.Config (AppConfig (..),
minimumPgVersion,
prettyVersion,
readOptions)
import PostgREST.DbStructure
import PostgREST.Error (PgError, pgErrResponse)
import PostgREST.Middleware
import Control.Monad (unless, void)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (encode)
import Data.Functor.Identity
import Data.Monoid ((<>))
import Data.String.Conversions (cs)
import Data.Text (Text)
import Data.Time.Clock.POSIX (getPOSIXTime)
import qualified Hasql as H
import qualified Hasql.Postgres as P
import Network.Wai
import Network.Wai.Handler.Warp hiding (Connection)
import Network.Wai.Middleware.RequestLogger (logStdout)
import System.IO (BufferMode (..),
hSetBuffering, stderr,
stdin, stdout)
import Web.JWT (secret)
#ifndef mingw32_HOST_OS
import System.Posix.Signals
import Control.Concurrent (myThreadId)
import Control.Exception.Base (throwTo, AsyncException(..))
#endif
isServerVersionSupported :: H.Session P.Postgres IO Bool
isServerVersionSupported = do
Identity (row :: Text) <- H.tx Nothing $ H.singleEx [H.stmt|SHOW server_version_num|]
return $ read (cs row) >= minimumPgVersion
hasqlError :: PgError -> IO a
hasqlError = error . cs . encode
main :: IO ()
main = do
hSetBuffering stdout LineBuffering
hSetBuffering stdin LineBuffering
hSetBuffering stderr NoBuffering
conf <- readOptions
let port = configPort conf
unless (secret "secret" /= configJwtSecret conf) $
putStrLn "WARNING, running in insecure mode, JWT secret is the default value"
Prelude.putStrLn $ "Listening on port " ++
(show $ configPort conf :: String)
let pgSettings = P.StringSettings $ cs (configDatabase conf)
appSettings = setPort port
. setServerName (cs $ "postgrest/" <> prettyVersion)
$ defaultSettings
middle = logStdout . defaultMiddle
poolSettings <- maybe (fail "Improper session settings") return $
H.poolSettings (fromIntegral $ configPool conf) 30
pool :: H.Pool P.Postgres <- H.acquirePool pgSettings poolSettings
supportedOrError <- H.session pool isServerVersionSupported
either hasqlError
(\supported ->
unless supported $
error (
"Cannot run in this PostgreSQL version, PostgREST needs at least "
<> show minimumPgVersion)
) supportedOrError
#ifndef mingw32_HOST_OS
tid <- myThreadId
void $ installHandler keyboardSignal (Catch $ do
H.releasePool pool
throwTo tid UserInterrupt
) Nothing
#endif
let txSettings = Just (H.ReadCommitted, Just True)
dbOrError <- H.session pool $ H.tx txSettings $ getDbStructure (cs $ configSchema conf)
dbStructure <- either hasqlError return dbOrError
runSettings appSettings $ middle $ \ req respond -> do
time <- getPOSIXTime
body <- strictRequestBody req
resOrError <- liftIO $ H.session pool $ H.tx txSettings $
runWithClaims conf time (app dbStructure conf body) req
either (respond . pgErrResponse) respond resOrError
+23 -39
View File
@@ -3,56 +3,40 @@
module PostgREST.Middleware where module PostgREST.Middleware where
import Data.Maybe (fromMaybe) import Data.Aeson (Value (..))
import Data.Text import qualified Data.HashMap.Strict as M
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.Time.Clock (NominalDiffTime) import Data.Text
import qualified Hasql as H import qualified Hasql.Transaction as H
import qualified Hasql.Postgres as P
import Network.HTTP.Types.Header (hAccept, hAuthorization) import Network.HTTP.Types.Header (hAccept)
import Network.HTTP.Types.Status (status415, status400) import Network.HTTP.Types.Status (status400, status415)
import Network.Wai (Application, Request (..), Response, import Network.Wai (Application, Request (..),
requestHeaders) Response, requestHeaders)
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 (pickContentType) import PostgREST.ApiRequest (ApiRequest(..), pickContentType)
import PostgREST.Auth (setRole, jwtClaims, claimsToSQL) import PostgREST.Auth (claimsToSQL)
import PostgREST.Config (AppConfig (..), corsPolicy) import PostgREST.Config (AppConfig (..), corsPolicy)
import PostgREST.Error (errResponse) import PostgREST.Error (errResponse)
import Prelude hiding(concat) import Prelude hiding (concat, null)
import qualified Data.Vector as V runWithClaims :: AppConfig -> Either Text (M.HashMap Text Value) ->
import qualified Hasql.Backend as B (ApiRequest -> H.Transaction Response) ->
import qualified Data.Map.Lazy as M ApiRequest -> H.Transaction Response
runWithClaims conf eClaims app req =
runWithClaims :: forall s. AppConfig -> NominalDiffTime -> case eClaims of
(Request -> H.Tx P.Postgres s Response) -> Left e -> clientErr e
Request -> H.Tx P.Postgres s Response Right claims -> do
runWithClaims conf time app req = do -- role claim defaults to anon if not specified in jwt
_ <- H.unitEx $ stmt setAnon H.sql . mconcat . claimsToSQL $ M.union claims (M.singleton "role" anon)
case split (== ' ') (cs auth) of app req
("Bearer" : tokenStr : _) ->
case jwtClaims jwtSecret tokenStr time of
Just claims ->
if M.member "role" claims
then do
mapM_ H.unitEx $ stmt <$> claimsToSQL claims
app req
else invalidJWT
_ -> invalidJWT
_ -> app req
where where
stmt c = B.Stmt c V.empty True anon = String . cs $ configAnonRole conf
hdrs = requestHeaders req clientErr = return . errResponse status400
jwtSecret = configJwtSecret conf
auth = fromMaybe "" $ lookup hAuthorization hdrs
anon = cs $ configAnonRole conf
setAnon = setRole anon
invalidJWT = return $ errResponse status400 "Invalid JWT"
unsupportedAccept :: Application -> Application unsupportedAccept :: Application -> Application
unsupportedAccept app req respond = unsupportedAccept app req respond =
+53 -11
View File
@@ -3,25 +3,30 @@ module PostgREST.Parsers
-- ) -- )
where where
import Control.Applicative hiding ((<$>)) import Control.Applicative hiding ((<$>))
import Data.Monoid import Data.Monoid
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.Text (Text) import Data.Text (Text, intercalate)
import Data.Tree import Data.Tree
import PostgREST.QueryBuilder (operators)
import PostgREST.Types import PostgREST.Types
import Text.ParserCombinators.Parsec hiding (many, (<|>)) import Text.ParserCombinators.Parsec hiding (many, (<|>))
import PostgREST.QueryBuilder (operators) import PostgREST.RangeQuery (NonnegRange,allRange)
pRequestSelect :: Text -> Parser ReadRequest pRequestSelect :: Text -> Parser ReadRequest
pRequestSelect rootNodeName = do pRequestSelect rootNodeName = do
fieldTree <- pFieldForest fieldTree <- pFieldForest
return $ foldr treeEntry (Node (Select [] [rootNodeName] [] Nothing, (rootNodeName, Nothing)) []) fieldTree return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing)) []) fieldTree
where where
readQuery = Select [] [rootNodeName] [] Nothing allRange
treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest
treeEntry (Node fld@((fn, _),_) fldForest) (Node (q, i) rForest) = treeEntry (Node fld@((fn, _),_,alias) fldForest) (Node (q, i) rForest) =
case fldForest of case fldForest of
[] -> Node (q {select=fld:select q}, i) rForest [] -> Node (q {select=fld:select q}, i) rForest
_ -> Node (q, i) (foldr treeEntry (Node (Select [] [fn] [] Nothing, (fn, Nothing)) []) fldForest:rForest) _ -> Node (q, i) newForest
where
newForest =
foldr treeEntry (Node (Select [] [fn] [] Nothing allRange, (fn, Nothing, alias)) []) fldForest:rForest
pRequestFilter :: (String, String) -> Either ParseError (Path, Filter) pRequestFilter :: (String, String) -> Either ParseError (Path, Filter)
pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val) pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val)
@@ -33,6 +38,19 @@ pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val)
op = fst <$> opVal op = fst <$> opVal
val = snd <$> opVal val = snd <$> opVal
pRequestOrder :: (String, String) -> Either ParseError (Path, [OrderTerm])
pRequestOrder (k, v) = (,) <$> path <*> ord
where
treePath = parse pTreePath ("failed to parser tree path (" ++ k ++ ")") k
path = fst <$> treePath
ord = parse pOrder ("failed to parse order (" ++ v ++ ")") v
pRequestRange :: (String, NonnegRange) -> Either ParseError (Path, NonnegRange)
pRequestRange (k, v) = (,) <$> path <*> pure v
where
treePath = parse pTreePath ("failed to parser tree path (" ++ k ++ ")") k
path = fst <$> treePath
ws :: Parser Text ws :: Parser Text
ws = cs <$> many (oneOf " \t") ws = cs <$> many (oneOf " \t")
@@ -51,15 +69,23 @@ pFieldForest :: Parser [Tree SelectItem]
pFieldForest = pFieldTree `sepBy1` lexeme (char ',') pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
pFieldTree :: Parser (Tree SelectItem) pFieldTree :: Parser (Tree SelectItem)
pFieldTree = try (Node <$> pSelect <*> between (char '{') (char '}') pFieldForest) pFieldTree = try (Node <$> pSimpleSelect <*> between (char '{') (char '}') pFieldForest)
<|> Node <$> pSelect <*> pure [] <|> Node <$> pSelect <*> pure []
pStar :: Parser Text pStar :: Parser Text
pStar = cs <$> (string "*" *> pure ("*"::String)) pStar = cs <$> (string "*" *> pure ("*"::String))
pFieldName :: Parser Text pFieldName :: Parser Text
pFieldName = cs <$> (many1 (letter <|> digit <|> oneOf "_") pFieldName = do
<?> "field name (* or [a..z0..9_])") matches <- (many1 (letter <|> digit <|> oneOf "_") `sepBy1` dash) <?> "field name (* or [a..z0..9_])"
return $ intercalate "-" $ map cs matches
where
isDash :: GenParser Char st ()
isDash = try ( char '-' >> notFollowedBy (char '>') )
dash :: Parser Char
dash = isDash *> pure '-'
pJsonPathStep :: Parser Text pJsonPathStep :: Parser Text
pJsonPathStep = cs <$> try (string "->" *> pFieldName) pJsonPathStep = cs <$> try (string "->" *> pFieldName)
@@ -70,12 +96,28 @@ pJsonPath = (++) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldN
pField :: Parser Field pField :: Parser Field
pField = lexeme $ (,) <$> pFieldName <*> optionMaybe pJsonPath pField = lexeme $ (,) <$> pFieldName <*> optionMaybe pJsonPath
aliasSeparator :: Parser ()
aliasSeparator = char ':' >> notFollowedBy (char ':')
pSimpleSelect :: Parser SelectItem
pSimpleSelect = lexeme $ try ( do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField
return (fld, Nothing, alias)
)
pSelect :: Parser SelectItem pSelect :: Parser SelectItem
pSelect = lexeme $ pSelect = lexeme $
try ((,) <$> pField <*>((cs <$>) <$> optionMaybe (string "::" *> many letter)) ) try (
do
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
fld <- pField
cast <- optionMaybe (string "::" *> many letter)
return (fld, cs <$> cast, alias)
)
<|> do <|> do
s <- pStar s <- pStar
return ((s, Nothing), Nothing) return ((s, Nothing), Nothing, Nothing)
pOperator :: Parser Operator pOperator :: Parser Operator
pOperator = cs <$> (pOp <?> "operator (eq, gt, ...)") pOperator = cs <$> (pOp <?> "operator (eq, gt, ...)")
+194 -147
View File
@@ -15,7 +15,6 @@ Any function that outputs a SQL fragment should be in this module.
module PostgREST.QueryBuilder ( module PostgREST.QueryBuilder (
addRelations addRelations
, addJoinConditions , addJoinConditions
, asJson
, callProc , callProc
, createReadStatement , createReadStatement
, createWriteStatement , createWriteStatement
@@ -26,28 +25,33 @@ module PostgREST.QueryBuilder (
, requestToCountQuery , requestToCountQuery
, sourceCTEName , sourceCTEName
, unquoted , unquoted
, ResultsWithCount
) where ) where
import qualified Hasql as H import qualified Hasql.Query as H
import qualified Hasql.Backend as B import qualified Hasql.Encoders as HE
import qualified Hasql.Postgres as P import qualified Hasql.Decoders as HD
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import Data.Int (Int64)
import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset) import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset, allRange)
import Control.Error (note, fromMaybe, mapMaybe) import Control.Error (note, fromMaybe)
import Data.Functor.Contravariant (contramap)
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import Data.List (find, (\\)) import Data.List (find)
import Data.Monoid ((<>)) import Data.Monoid ((<>))
import Data.Text (Text, intercalate, unwords, replace, isInfixOf, toLower, split) import Data.Text (Text, intercalate, unwords, replace, isInfixOf, toLower, split)
import qualified Data.Text as T (map, takeWhile) import qualified Data.Text as T (map, takeWhile, null)
import qualified Data.Text.Encoding as T
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Control.Applicative (empty, (<|>)) import Control.Applicative ((<|>))
import Control.Monad (join) import Control.Monad (replicateM)
import Data.Tree (Tree(..)) import Data.Tree (Tree(..))
import qualified Data.Vector as V import qualified Data.Vector as V
import PostgREST.Types import PostgREST.Types
import qualified Data.Map as M import qualified Data.Map as M
import Text.InterpolatedString.Perl6 (qc)
import Text.Regex.TDFA ((=~)) import Text.Regex.TDFA ((=~))
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import Data.Scientific ( FPFormat (..) import Data.Scientific ( FPFormat (..)
@@ -57,85 +61,120 @@ import Data.Scientific ( FPFormat (..)
import Prelude hiding (unwords) import Prelude hiding (unwords)
import PostgREST.ApiRequest (PreferRepresentation (..)) import PostgREST.ApiRequest (PreferRepresentation (..))
type PStmt = H.Stmt P.Postgres {-| The generic query result format used by API responses. The location header
instance Monoid PStmt where is represented as a list of strings containing variable bindings like
mappend (B.Stmt query params prep) (B.Stmt query' params' prep') = @"k1=eq.42"@, or the empty list if there is no location header.
B.Stmt (query <> query') (params <> params') (prep && prep') -}
mempty = B.Stmt "" empty True type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString)
type StatementT = PStmt -> PStmt
createReadStatement :: SqlQuery -> SqlQuery -> NonnegRange -> Bool -> Bool -> Bool -> B.Stmt P.Postgres standardRow :: HD.Row ResultsWithCount
createReadStatement selectQuery countQuery range isSingle countTotal asCsv = standardRow = (,,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8
B.Stmt ( <*> HD.value header <*> HD.value HD.bytea
"WITH " <> sourceCTEName <> " AS (" <> selectQuery <> ") " <> where
"SELECT " <> intercalate ", " [ 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
UniformObjects (objects who all have the same keys),
and we turn this into an old fasioned JSON array
-}
encodeUniformObjs :: HE.Params UniformObjects
encodeUniformObjs =
contramap (JSON.Array . V.map JSON.Object . unUniformObjects) (HE.value HE.json)
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
H.Query () ResultsWithCount
createReadStatement selectQuery countQuery isSingle countTotal asCsv =
unicodeStatement sql HE.unit decodeStandard True
where
sql = [qc|
WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols}
FROM ( SELECT * FROM {sourceCTEName}) t |]
countResultF = if countTotal then "("<>countQuery<>")" else "null"
cols = intercalate ", " [
countResultF <> " AS total_result_set", countResultF <> " AS total_result_set",
"pg_catalog.count(t) AS page_total", "pg_catalog.count(t) AS page_total",
"null AS header", noLocationF <> " AS header",
bodyF <> " AS body" bodyF <> " AS body"
] <> ]
" FROM ( SELECT * FROM " <> sourceCTEName <> " " <> limitF range <> ") t" bodyF
) V.empty True | asCsv = asCsvF
where | isSingle = asJsonSingleF
countResultF = if countTotal then "("<>countQuery<>")" else "null" | otherwise = asJsonF
bodyF
| asCsv = asCsvF
| isSingle = asJsonSingleF
| otherwise = asJsonF
createWriteStatement :: QualifiedIdentifier -> SqlQuery -> SqlQuery -> Bool -> PreferRepresentation -> createWriteStatement :: QualifiedIdentifier -> SqlQuery -> SqlQuery -> Bool ->
[Text] -> Bool -> Payload -> B.Stmt P.Postgres PreferRepresentation -> [Text] -> Bool -> Payload ->
H.Query UniformObjects (Maybe ResultsWithCount)
createWriteStatement _ _ _ _ _ _ _ (PayloadParseError _) = undefined createWriteStatement _ _ _ _ _ _ _ (PayloadParseError _) = undefined
createWriteStatement _ _ mutateQuery _ None createWriteStatement _ _ mutateQuery _ None
_ _ (PayloadJSON (UniformObjects rows)) = _ _ (PayloadJSON (UniformObjects _)) =
B.Stmt ( unicodeStatement sql encodeUniformObjs decodeStandardMay True
"WITH " <> sourceCTEName <> " AS (" <> mutateQuery <> ") " <> where
"SELECT null, 0, null, null" sql = [qc|
) (V.singleton . B.encodeValue . JSON.Array . V.map JSON.Object $ rows) True WITH {sourceCTEName} AS ({mutateQuery})
SELECT '', 0, {noLocationF}, '' |]
createWriteStatement qi _ mutateQuery isSingle HeadersOnly createWriteStatement qi _ mutateQuery isSingle HeadersOnly
pKeys _ (PayloadJSON (UniformObjects rows)) = pKeys _ (PayloadJSON (UniformObjects _)) =
B.Stmt ( unicodeStatement sql encodeUniformObjs decodeStandardMay True
"WITH " <> sourceCTEName <> " AS (" <> mutateQuery <> " RETURNING " <> fromQi qi <> ".*" <> ") " <> where
"SELECT " <> intercalate ", " [ sql = [qc|
"null AS total_result_set", WITH {sourceCTEName} AS ({mutateQuery} RETURNING {fromQi qi}.*)
SELECT {cols}
FROM (SELECT 1 FROM {sourceCTEName}) t |]
cols = intercalate ", " [
"'' AS total_result_set",
"pg_catalog.count(t) AS page_total", "pg_catalog.count(t) AS page_total",
if isSingle then locationF pKeys else "null", if isSingle then locationF pKeys else noLocationF,
"null" "''"
] <> ]
" FROM (SELECT 1 FROM " <> sourceCTEName <> ") t"
) (V.singleton . B.encodeValue . JSON.Array . V.map JSON.Object $ rows) True
createWriteStatement qi selectQuery mutateQuery isSingle Full createWriteStatement qi selectQuery mutateQuery isSingle Full
pKeys asCsv (PayloadJSON (UniformObjects rows)) = pKeys asCsv (PayloadJSON (UniformObjects _)) =
B.Stmt ( unicodeStatement sql encodeUniformObjs decodeStandardMay True
"WITH " <> sourceCTEName <> " AS (" <> mutateQuery <> " RETURNING " <> fromQi qi <> ".*" <> ") " <> where
"SELECT " <> intercalate ", " [ sql = [qc|
"null AS total_result_set", -- when updateing it does not make sense WITH {sourceCTEName} AS ({mutateQuery} RETURNING {fromQi qi}.*)
SELECT {cols}
FROM ({selectQuery}) t |]
cols = intercalate ", " [
"'' AS total_result_set", -- when updateing it does not make sense
"pg_catalog.count(t) AS page_total", "pg_catalog.count(t) AS page_total",
if isSingle then locationF pKeys else "null" <> " AS header", if isSingle then locationF pKeys else noLocationF <> " AS header",
bodyF <> " AS body" bodyF <> " AS body"
] <> ]
" FROM ( "<>selectQuery<>") t" bodyF
) (V.singleton . B.encodeValue . JSON.Array . V.map JSON.Object $ rows) True | asCsv = asCsvF
where | isSingle = asJsonSingleF
bodyF | otherwise = asJsonF
| asCsv = asCsvF
| isSingle = asJsonSingleF
| otherwise = asJsonF
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest
addRelations schema allRelations parentNode node@(Node readNode@(query, (name, _)) forest) = addRelations schema allRelations parentNode node@(Node readNode@(query, (name, _, alias)) forest) =
case parentNode of case parentNode of
(Just (Node (Select{from=[parentTable]}, (_, _)) _)) -> Node <$> (addRel readNode <$> rel) <*> updatedForest (Just (Node (Select{from=[parentTable]}, (_, _, _)) _)) -> Node <$> (addRel readNode <$> rel) <*> updatedForest
where where
rel = note ("no relation between " <> parentTable <> " and " <> name) rel = note ("no relation between " <> parentTable <> " and " <> name)
$ findRelationByTable schema name parentTable $ findRelationByTable schema name parentTable
<|> findRelationByTable schema parentTable name
<|> findRelationByColumn schema parentTable name <|> findRelationByColumn schema parentTable name
addRel :: (ReadQuery, (NodeName, Maybe Relation)) -> Relation -> (ReadQuery, (NodeName, Maybe Relation)) addRel :: (ReadQuery, (NodeName, Maybe Relation, Maybe Alias)) -> Relation -> (ReadQuery, (NodeName, Maybe Relation, Maybe Alias))
addRel (q, (n, _)) r = (q {from=fromRelation}, (n, Just r)) addRel (query', (n, _, a)) r = (query' {from=fromRelation}, (n, Just r, a))
where fromRelation = map (\t -> if t == n then tableName (relTable r) else t) (from q) where fromRelation = map (\t -> if t == n then tableName (relTable r) else t) (from query')
_ -> Node (query, (name, Nothing)) <$> updatedForest _ -> Node (query, (name, Nothing, alias)) <$> updatedForest
where where
updatedForest = mapM (addRelations schema allRelations (Just node)) forest updatedForest = mapM (addRelations schema allRelations (Just node)) forest
-- Searches through all the relations and returns a match given the parameter conditions. -- Searches through all the relations and returns a match given the parameter conditions.
@@ -148,40 +187,45 @@ addRelations schema allRelations parentNode node@(Node readNode@(query, (name, _
where n `colMatches` rc = (cs ("^" <> rc <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (cs n :: BS.ByteString) where n `colMatches` rc = (cs ("^" <> rc <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (cs n :: BS.ByteString)
addJoinConditions :: Schema -> ReadRequest -> Either Text ReadRequest addJoinConditions :: Schema -> ReadRequest -> Either Text ReadRequest
addJoinConditions schema (Node (query, (n, r)) forest) = addJoinConditions schema (Node nn@(query, (n, r, a)) forest) =
case r of case r of
Nothing -> Node (updatedQuery, (n,r)) <$> updatedForest -- this is the root node Nothing -> Node nn <$> updatedForest -- this is the root node
Just rel@(Relation{relType=Child}) -> Node (addCond updatedQuery (getJoinConditions rel),(n,r)) <$> updatedForest Just rel@Relation{relType=Child} -> Node (addCond query (getJoinConditions rel),(n,r,a)) <$> updatedForest
Just (Relation{relType=Parent}) -> Node (updatedQuery, (n,r)) <$> updatedForest Just Relation{relType=Parent} -> Node nn <$> updatedForest
Just rel@(Relation{relType=Many, relLTable=(Just linkTable)}) -> Just rel@Relation{relType=Many, relLTable=(Just linkTable)} ->
Node (qq, (n, r)) <$> updatedForest Node (qq, (n, r, a)) <$> updatedForest
where where
q = addCond updatedQuery (getJoinConditions rel) query' = addCond query (getJoinConditions rel)
qq = q{from=tableName linkTable : from q} qq = query'{from=tableName linkTable : from query'}
_ -> Left "unknown relation" _ -> Left "unknown relation"
where where
-- add parentTable and parentJoinConditions to the query
updatedQuery = foldr (flip addCond) query parentJoinConditions
where
parentJoinConditions = map (getJoinConditions . snd) parents
parents = mapMaybe (getParents . rootLabel) forest
getParents (_, (tbl, Just rel@(Relation{relType=Parent}))) = Just (tbl, rel)
getParents _ = Nothing
updatedForest = mapM (addJoinConditions schema) forest updatedForest = mapM (addJoinConditions schema) forest
addCond q con = q{flt_=con ++ flt_ q} addCond query' con = query'{flt_=con ++ flt_ query'}
asJson :: StatementT type ProcResults = (Maybe Int64, Int64, JSON.Value)
asJson s = s { callProc :: QualifiedIdentifier -> JSON.Object -> NonnegRange -> Bool -> H.Query () (Maybe ProcResults)
B.stmtTemplate = callProc qi params range countTotal =
"array_to_json(coalesce(array_agg(row_to_json(t)), '{}'))::character varying from (" unicodeStatement sql HE.unit decodeProc True
<> B.stmtTemplate s <> ") t" }
callProc :: QualifiedIdentifier -> JSON.Object -> PStmt
callProc qi params = do
let args = intercalate "," $ map assignment (HM.toList params)
B.Stmt ("select * from " <> fromQi qi <> "(" <> args <> ")") empty True
where where
assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v sql = [qc|
WITH t AS (select * {_callSql})
SELECT
{_countExpr} as countTotal,
pg_catalog.count(1) as countResult,
array_to_json(
coalesce(array_agg(row_to_json(r)), '\{}')
)::character varying
FROM (select * from t {limitF range}) r;
|]
_args = intercalate "," $ map _assignment (HM.toList params)
_assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v
_callSql = [qc| from {fromQi qi}({_args}) |] :: Text
_countExpr = if countTotal
then "(select pg_catalog.count(1) from t)"
else "null::bigint" :: Text
decodeProc = HD.maybeRow procRow
procRow = (,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8
<*> HD.value HD.json
operators :: [(Text, SqlFragment)] operators :: [(Text, SqlFragment)]
operators = [ operators = [
@@ -210,27 +254,27 @@ pgFmtLit x =
let trimmed = trimNullChars x let trimmed = trimNullChars x
escaped = "'" <> replace "'" "''" trimmed <> "'" escaped = "'" <> replace "'" "''" trimmed <> "'"
slashed = replace "\\" "\\\\" escaped in slashed = replace "\\" "\\\\" escaped in
if "\\\\" `isInfixOf` escaped if "\\" `isInfixOf` escaped
then "E" <> slashed then "E" <> slashed
else slashed else slashed
requestToCountQuery :: Schema -> DbRequest -> SqlQuery requestToCountQuery :: Schema -> DbRequest -> SqlQuery
requestToCountQuery _ (DbMutate _) = undefined requestToCountQuery _ (DbMutate _) = undefined
requestToCountQuery schema (DbRead (Node (Select _ _ conditions _, (mainTbl, _)) _)) = requestToCountQuery schema (DbRead (Node (Select _ _ conditions _ _, (mainTbl, _, _)) _)) =
unwords [ unwords [
"SELECT pg_catalog.count(1)", "SELECT pg_catalog.count(1)",
"FROM ", fromQi $ QualifiedIdentifier schema mainTbl, "FROM ", fromQi $ QualifiedIdentifier schema mainTbl,
("WHERE " <> intercalate " AND " ( map (pgFmtCondition (QualifiedIdentifier schema mainTbl)) localConditions )) `emptyOnNull` localConditions ("WHERE " <> intercalate " AND " ( map (pgFmtCondition (QualifiedIdentifier schema mainTbl)) localConditions )) `emptyOnNull` localConditions
] ]
where where
fn (Filter{value=VText _}) = True fn Filter{value=VText _} = True
fn (Filter{value=VForeignKey _ _}) = False fn Filter{value=VForeignKey _ _} = False
localConditions = filter fn conditions localConditions = filter fn conditions
requestToQuery :: Schema -> DbRequest -> SqlQuery requestToQuery :: Schema -> DbRequest -> SqlQuery
requestToQuery _ (DbMutate (Insert _ (PayloadParseError _))) = undefined requestToQuery _ (DbMutate (Insert _ (PayloadParseError _))) = undefined
requestToQuery _ (DbMutate (Update _ (PayloadParseError _) _)) = undefined requestToQuery _ (DbMutate (Update _ (PayloadParseError _) _)) = undefined
requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord, (nodeName, maybeRelation)) forest)) = requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord range, (nodeName, maybeRelation, _)) forest)) =
query query
where where
-- TODO! the folloing helper functions are just to remove the "schema" part when the table is "source" which is the name -- TODO! the folloing helper functions are just to remove the "schema" part when the table is "source" which is the name
@@ -242,9 +286,10 @@ requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord, (nod
query = unwords [ query = unwords [
"SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects), "SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
"FROM ", intercalate ", " (map (fromQi . toQi) tbls), "FROM ", intercalate ", " (map (fromQi . toQi) tbls),
unwords (map joinStr joins), unwords joins,
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) localConditions )) `emptyOnNull` localConditions, ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
orderF (fromMaybe [] ord) orderF (fromMaybe [] ord),
limitF range
] ]
orderF ts = orderF ts =
if null ts if null ts
@@ -258,50 +303,48 @@ requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord, (nod
<> (cs.show) (otDirection t) <> " " <> (cs.show) (otDirection t) <> " "
<> maybe "" (cs.show) (otNullOrder t) <> " " <> maybe "" (cs.show) (otNullOrder t) <> " "
(joins, selects) = foldr getQueryParts ([],[]) forest (joins, selects) = foldr getQueryParts ([],[]) forest
parentTables = map snd joins
parentConditions = join $ map (( `filter` conditions ) . filterParentConditions) parentTables getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
localConditions = conditions \\ parentConditions getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias)) forst) (j,s) = (j,sel:s)
joinStr :: (SqlFragment, TableName) -> SqlFragment
joinStr (sql, t) = "LEFT OUTER JOIN " <> sql <> " ON " <>
intercalate " AND " ( map (pgFmtCondition qi ) joinConditions )
where
joinConditions = filter (filterParentConditions t) conditions
filterParentConditions parentTable (Filter _ _ (VForeignKey (QualifiedIdentifier "" t) _)) = parentTable == t
filterParentConditions _ _ = False
getQueryParts :: Tree ReadNode -> ([(SqlFragment, TableName)], [SqlFragment]) -> ([(SqlFragment,TableName)], [SqlFragment])
getQueryParts (Node n@(_, (name, Just (Relation {relType=Child,relTable=Table{tableName=table}}))) forst) (j,s) = (j,sel:s)
where where
sel = "COALESCE((" sel = "COALESCE(("
<> "SELECT array_to_json(array_agg(row_to_json("<>table<>"))) " <> "SELECT array_to_json(array_agg(row_to_json("<>pgFmtIdent table<>"))) "
<> "FROM (" <> subquery <> ") " <> table <> "FROM (" <> subquery <> ") " <> pgFmtIdent table
<> "), '[]') AS " <> pgFmtIdent name <> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
where subquery = requestToQuery schema (DbRead (Node n forst)) where subquery = requestToQuery schema (DbRead (Node n forst))
getQueryParts (Node n@(_, (name, Just (Relation {relType=Parent,relTable=Table{tableName=table}}))) forst) (j,s) = (joi:j,sel:s) getQueryParts (Node n@(_, (name, Just r@Relation{relType=Parent,relTable=Table{tableName=table}}, alias)) forst) (j,s) = (joi:j,sel:s)
where where
sel = "row_to_json(" <> table <> ".*) AS "<>pgFmtIdent name --TODO must be singular node_name = fromMaybe name alias
joi = ("( " <> subquery <> " ) AS " <> table, table) local_table_name = table <> "_" <> node_name
replaceTableName localTableName (Filter a b (VForeignKey (QualifiedIdentifier "" _) c)) = Filter a b (VForeignKey (QualifiedIdentifier "" localTableName) c)
replaceTableName _ x = x
sel = "row_to_json(" <> pgFmtIdent local_table_name <> ".*) AS " <> pgFmtIdent node_name
joi = " LEFT OUTER JOIN ( " <> subquery <> " ) AS " <> pgFmtIdent local_table_name <>
" ON " <> intercalate " AND " ( map (pgFmtCondition qi . replaceTableName local_table_name) (getJoinConditions r) )
where subquery = requestToQuery schema (DbRead (Node n forst)) where subquery = requestToQuery schema (DbRead (Node n forst))
getQueryParts (Node n@(_, (name, Just (Relation {relType=Many,relTable=Table{tableName=table}}))) 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 array_to_json(array_agg(row_to_json("<>table<>"))) " <> "SELECT array_to_json(array_agg(row_to_json("<>pgFmtIdent table<>"))) "
<> "FROM (" <> subquery <> ") " <> table <> "FROM (" <> subquery <> ") " <> pgFmtIdent table
<> "), '[]') AS " <> pgFmtIdent name <> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
where subquery = requestToQuery schema (DbRead (Node n forst)) where subquery = requestToQuery schema (DbRead (Node n forst))
--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 (Node (_,(_,Nothing)) _) _ = undefined getQueryParts (Node (_,(_,Nothing,_)) _) _ = undefined
requestToQuery schema (DbMutate (Insert mainTbl (PayloadJSON (UniformObjects rows)))) = requestToQuery schema (DbMutate (Insert mainTbl (PayloadJSON (UniformObjects rows)))) =
let qi = QualifiedIdentifier schema mainTbl let qi = QualifiedIdentifier schema mainTbl
cols = map pgFmtIdent $ fromMaybe [] (HM.keys <$> (rows V.!? 0)) cols = map pgFmtIdent $ fromMaybe [] (HM.keys <$> (rows V.!? 0))
colsString = intercalate ", " cols in colsString = intercalate ", " cols
unwords [ insInto = unwords [ "INSERT INTO" , fromQi qi,
"INSERT INTO ", fromQi qi, if T.null colsString then "" else "(" <> colsString <> ")"
" (" <> colsString <> ")" <> ]
" SELECT " <> colsString <> vals = unwords $ if T.null colsString
" FROM json_populate_recordset(null::" , fromQi qi, ", ?)" then ["DEFAULT VALUES"]
] else ["SELECT", colsString, "FROM json_populate_recordset(null::" , fromQi qi, ", $1)"] in
insInto <> vals
requestToQuery schema (DbMutate (Update mainTbl (PayloadJSON (UniformObjects rows)) conditions)) = requestToQuery schema (DbMutate (Update mainTbl (PayloadJSON (UniformObjects rows)) conditions)) =
case rows V.!? 0 of case rows V.!? 0 of
Just obj -> Just obj ->
@@ -315,7 +358,6 @@ requestToQuery schema (DbMutate (Update mainTbl (PayloadJSON (UniformObjects row
Nothing -> undefined Nothing -> undefined
where where
qi = QualifiedIdentifier schema mainTbl qi = QualifiedIdentifier schema mainTbl
requestToQuery schema (DbMutate (Delete mainTbl conditions)) = requestToQuery schema (DbMutate (Delete mainTbl conditions)) =
query query
where where
@@ -340,7 +382,7 @@ asCsvF :: SqlFragment
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
where where
asCsvHeaderF = asCsvHeaderF =
"(SELECT string_agg(a.k, ',')" <> "(SELECT coalesce(string_agg(a.k, ','), '')" <>
" FROM (" <> " FROM (" <>
" SELECT json_object_keys(r)::TEXT as k" <> " SELECT json_object_keys(r)::TEXT as k" <>
" FROM ( " <> " FROM ( " <>
@@ -351,26 +393,27 @@ asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
asCsvBodyF = "coalesce(string_agg(substring(t::text, 2, length(t::text) - 2), '\n'), '')" asCsvBodyF = "coalesce(string_agg(substring(t::text, 2, length(t::text) - 2), '\n'), '')"
asJsonF :: SqlFragment asJsonF :: SqlFragment
asJsonF = "array_to_json(array_agg(row_to_json(t)))::character varying" asJsonF = "coalesce(array_to_json(array_agg(row_to_json(t))), '[]')::character varying"
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
asJsonSingleF = "string_agg(row_to_json(t)::text, ',')::character varying " asJsonSingleF = "coalesce(string_agg(row_to_json(t)::text, ','), '')::character varying "
locationF :: [Text] -> SqlFragment locationF :: [Text] -> SqlFragment
locationF pKeys = locationF pKeys =
"(" <> "(" <>
" WITH s AS (SELECT row_to_json(ss) as r from " <> sourceCTEName <> " as ss limit 1)" <> " WITH s AS (SELECT row_to_json(ss) as r from " <> sourceCTEName <> " as ss limit 1)" <>
" SELECT string_agg(json_data.key || '=' || coalesce( 'eq.' || json_data.value, 'is.null'), '&')" <> " SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))" <>
" FROM s, json_each_text(s.r) AS json_data" <> " FROM s, json_each_text(s.r) AS json_data" <>
( (
if null pKeys if null pKeys
then "" then ""
else " WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')" else " WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')"
) <> ) <> ")"
")"
limitF :: NonnegRange -> SqlFragment limitF :: NonnegRange -> SqlFragment
limitF r = "LIMIT " <> limit <> " OFFSET " <> offset limitF r = if r == allRange
then ""
else "LIMIT " <> limit <> " OFFSET " <> offset
where where
limit = maybe "ALL" (cs . show) $ rangeLimit r limit = maybe "ALL" (cs . show) $ rangeLimit r
offset = cs . show $ rangeOffset r offset = cs . show $ rangeOffset r
@@ -395,6 +438,9 @@ getJoinConditions (Relation t cols ft fcs typ lt lc1 lc2) =
toFilter :: Text -> Text -> Column -> Column -> Filter toFilter :: Text -> Text -> Column -> Column -> Filter
toFilter tb ftb c fc = Filter (colName c, Nothing) "=" (VForeignKey (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}})) toFilter tb ftb c fc = Filter (colName c, Nothing) "=" (VForeignKey (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}}))
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Query a b
unicodeStatement = H.statement . T.encodeUtf8
emptyOnNull :: Text -> [a] -> Text emptyOnNull :: Text -> [a] -> Text
emptyOnNull val x = if null x then "" else val emptyOnNull val x = if null x then "" else val
@@ -415,8 +461,8 @@ pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
pgFmtSelectItem table (f@(_, jp), Nothing) = pgFmtField table f <> pgFmtAsJsonPath jp pgFmtSelectItem table (f@(_, jp), Nothing, alias) = pgFmtField table f <> pgFmtAs jp alias
pgFmtSelectItem table (f@(_, jp), Just cast ) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAsJsonPath jp pgFmtSelectItem table (f@(_, jp), Just cast, alias) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs jp alias
pgFmtCondition :: QualifiedIdentifier -> Filter -> SqlFragment pgFmtCondition :: QualifiedIdentifier -> Filter -> SqlFragment
pgFmtCondition table (Filter (col,jp) ops val) = pgFmtCondition table (Filter (col,jp) ops val) =
@@ -463,9 +509,10 @@ pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x
pgFmtJsonPath (Just (x:xs)) = "->" <> pgFmtLit x <> pgFmtJsonPath ( Just xs ) pgFmtJsonPath (Just (x:xs)) = "->" <> pgFmtLit x <> pgFmtJsonPath ( Just xs )
pgFmtJsonPath _ = "" pgFmtJsonPath _ = ""
pgFmtAsJsonPath :: Maybe JsonPath -> SqlFragment pgFmtAs :: Maybe JsonPath -> Maybe Alias -> SqlFragment
pgFmtAsJsonPath Nothing = "" pgFmtAs Nothing Nothing = ""
pgFmtAsJsonPath (Just xx) = " AS " <> last xx pgFmtAs (Just xx) Nothing = " AS " <> pgFmtIdent (last xx)
pgFmtAs _ (Just alias) = " AS " <> pgFmtIdent alias
trimNullChars :: Text -> Text trimNullChars :: Text -> Text
trimNullChars = T.takeWhile (/= '\x0') trimNullChars = T.takeWhile (/= '\x0')
+17 -12
View File
@@ -4,13 +4,14 @@ module PostgREST.RangeQuery (
, rangeLimit , rangeLimit
, rangeOffset , rangeOffset
, restrictRange , restrictRange
, rangeGeq
, allRange
, NonnegRange , NonnegRange
) where ) where
import Control.Applicative import Control.Applicative
import Network.HTTP.Types.Header import Network.HTTP.Types.Header
import PostgREST.Types ()
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import Data.Ranged.Boundaries import Data.Ranged.Boundaries
@@ -24,7 +25,7 @@ import Data.Maybe (fromMaybe, listToMaybe)
import Prelude import Prelude
type NonnegRange = Range Int type NonnegRange = Range Integer
rangeParse :: BS.ByteString -> NonnegRange rangeParse :: BS.ByteString -> NonnegRange
rangeParse range = do rangeParse range = do
@@ -34,35 +35,39 @@ rangeParse range = do
Just parsedRange -> Just parsedRange ->
let [_, from, to] = readMaybe . cs <$> parsedRange let [_, from, to] = readMaybe . cs <$> parsedRange
lower = fromMaybe emptyRange (rangeGeq <$> from) lower = fromMaybe emptyRange (rangeGeq <$> from)
upper = fromMaybe (rangeGeq 0) (rangeLeq <$> to) in upper = fromMaybe allRange (rangeLeq <$> to) in
rangeIntersection lower upper rangeIntersection lower upper
Nothing -> rangeGeq 0 Nothing -> allRange
rangeRequested :: RequestHeaders -> NonnegRange rangeRequested :: RequestHeaders -> NonnegRange
rangeRequested = rangeParse . fromMaybe "" . lookup hRange rangeRequested headers = fromMaybe allRange $
rangeParse <$> lookup hRange headers
restrictRange :: Maybe Int -> NonnegRange -> NonnegRange restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange
restrictRange Nothing r = r restrictRange Nothing r = r
restrictRange (Just limit) r = restrictRange (Just limit) r =
rangeIntersection r $ rangeIntersection r $
Range BoundaryBelowAll (BoundaryAbove $ rangeOffset r + limit - 1) Range BoundaryBelowAll (BoundaryAbove $ rangeOffset r + limit - 1)
rangeLimit :: NonnegRange -> Maybe Int rangeLimit :: NonnegRange -> Maybe Integer
rangeLimit range = rangeLimit range =
case [rangeLower range, rangeUpper range] of case [rangeLower range, rangeUpper range] of
[BoundaryBelow from, BoundaryAbove to] -> Just (1 + to - from) [BoundaryBelow from, BoundaryAbove to] -> Just (1 + to - from)
_ -> Nothing _ -> Nothing
rangeOffset :: NonnegRange -> Int rangeOffset :: NonnegRange -> Integer
rangeOffset range = rangeOffset range =
case rangeLower range of case rangeLower range of
BoundaryBelow from -> from BoundaryBelow from -> from
_ -> error "range without lower bound" -- should never happen _ -> error "range without lower bound" -- should never happen
rangeGeq :: Int -> NonnegRange rangeGeq :: Integer -> NonnegRange
rangeGeq n = rangeGeq n =
Range (BoundaryBelow n) BoundaryAboveAll Range (BoundaryBelow n) BoundaryAboveAll
rangeLeq :: Int -> NonnegRange allRange :: NonnegRange
allRange = rangeGeq 0
rangeLeq :: Integer -> NonnegRange
rangeLeq n = rangeLeq n =
Range BoundaryBelowAll (BoundaryAbove n) Range BoundaryBelowAll (BoundaryAbove n)
+19 -13
View File
@@ -1,15 +1,17 @@
module PostgREST.Types where module PostgREST.Types where
import Data.Text import Data.Aeson
import Data.Tree
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import Data.Int (Int32)
import Data.Text
import Data.Tree
import qualified Data.Vector as V import qualified Data.Vector as V
import Data.Aeson import PostgREST.RangeQuery (NonnegRange)
data DbStructure = DbStructure { data DbStructure = DbStructure {
dbTables :: [Table] dbTables :: [Table]
, dbColumns :: [Column] , dbColumns :: [Column]
, dbRelations :: [Relation] , dbRelations :: [Relation]
, dbPrimaryKeys :: [PrimaryKey] , dbPrimaryKeys :: [PrimaryKey]
} deriving (Show, Eq) } deriving (Show, Eq)
@@ -31,12 +33,12 @@ data Column =
Column { Column {
colTable :: Table colTable :: Table
, colName :: Text , colName :: Text
, colPosition :: Int , colPosition :: Int32
, colNullable :: Bool , colNullable :: Bool
, colType :: Text , colType :: Text
, colUpdatable :: Bool , colUpdatable :: Bool
, colMaxLen :: Maybe Int , colMaxLen :: Maybe Int32
, colPrecision :: Maybe Int , colPrecision :: Maybe Int32
, colDefault :: Maybe Text , colDefault :: Maybe Text
, colEnum :: [Text] , colEnum :: [Text]
, colFK :: Maybe ForeignKey , colFK :: Maybe ForeignKey
@@ -90,6 +92,9 @@ data Relation = Relation {
newtype UniformObjects = UniformObjects (V.Vector Object) newtype UniformObjects = UniformObjects (V.Vector Object)
deriving (Show, Eq) deriving (Show, Eq)
unUniformObjects :: UniformObjects -> V.Vector Object
unUniformObjects (UniformObjects objs) = objs
-- | When Hasql supports the COPY command then we can -- | When Hasql supports the COPY command then we can
-- have a special payload just for CSV, but until -- have a special payload just for CSV, but until
-- then CSV is converted to a JSON array. -- then CSV is converted to a JSON array.
@@ -102,16 +107,17 @@ data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (
type FieldName = Text type FieldName = Text
type JsonPath = [Text] type JsonPath = [Text]
type Field = (FieldName, Maybe JsonPath) type Field = (FieldName, Maybe JsonPath)
type Alias = Text
type Cast = Text type Cast = Text
type NodeName = Text type NodeName = Text
type SelectItem = (Field, Maybe Cast) type SelectItem = (Field, Maybe Cast, Maybe Alias)
type Path = [Text] type Path = [Text]
data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter], order::Maybe [OrderTerm] } deriving (Show, Eq) data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter], order::Maybe [OrderTerm], range_::NonnegRange } deriving (Show, Eq)
data MutateQuery = Insert { in_::TableName, qPayload::Payload } data MutateQuery = Insert { in_::TableName, qPayload::Payload }
| Delete { in_::TableName, where_::[Filter] } | Delete { in_::TableName, where_::[Filter] }
| Update { in_::TableName, qPayload::Payload, where_::[Filter] } deriving (Show, Eq) | Update { in_::TableName, qPayload::Payload, where_::[Filter] } deriving (Show, Eq)
data Filter = Filter {field::Field, operator::Operator, value::FValue} deriving (Show, Eq) data Filter = Filter {field::Field, operator::Operator, value::FValue} deriving (Show, Eq)
type ReadNode = (ReadQuery, (NodeName, Maybe Relation)) type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias))
type ReadRequest = Tree ReadNode type ReadRequest = Tree ReadNode
type MutateRequest = MutateQuery type MutateRequest = MutateQuery
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
+22 -5
View File
@@ -1,7 +1,24 @@
flags: {} resolver: lts-6.2
packages:
- '.'
extra-deps: extra-deps:
- Ranged-sets-0.3.0 - Ranged-sets-0.3.0
- packdeps-0.4.1 - bytestring-tree-builder-0.2.7
resolver: nightly-2015-10-27 - hasql-0.19.12
- hasql-pool-0.4.1
- hasql-transaction-0.4.5
- jwt-0.7.2
- postgresql-binary-0.9.0.1
- binary-parser-0.5.2
- contravariant-extras-0.3.2
- placeholders-0.1
- postgresql-error-codes-1
- success-0.2.6
- tuple-th-0.2.5
- wai-cors-0.2.5
- cryptohash-sha256-0.11.100.0
- hackage-security-0.5.2.1
ghc-options:
postgrest: -O2 -Werror -Wall -fwarn-identities
packages:
- .
+43 -10
View File
@@ -6,28 +6,61 @@ import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Network.HTTP.Types
import Hasql as H
import Hasql.Postgres as P
import SpecHelper import SpecHelper
import PostgREST.Types (DbStructure(..)) import Network.Wai (Application)
-- }}} -- }}}
spec :: DbStructure -> H.Pool P.Postgres -> Spec spec :: SpecWith Application
spec struct pool = around (withApp cfgDefault struct pool) spec = describe "authorization" $ do
$ describe "authorization" $ do
it "hides tables that anonymous does not own" $ it "denies access to tables that anonymous does not own" $
get "/authors_only" `shouldRespondWith` 404 get "/authors_only" `shouldRespondWith` ResponseMatcher {
matchBody = Just [json| {
"hint":null,
"details":null,
"code":"42501",
"message":"permission denied for relation authors_only"} |]
, matchStatus = 401
, matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
}
it "denies access to tables that postgrest_test_author does not own" $
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" in
request methodGet "/private_table" [auth] ""
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json| {
"hint":null,
"details":null,
"code":"42501",
"message":"permission denied for relation private_table"} |]
, matchStatus = 403
, matchHeaders = []
}
it "returns jwt functions as jwt tokens" $ it "returns jwt functions as jwt tokens" $
post "/rpc/login" [json| { "id": "jdoe", "pass": "1234" } |] post "/rpc/login" [json| { "id": "jdoe", "pass": "1234" } |]
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |] matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |]
, matchStatus = 200 , matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json"] , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
} }
it "sql functions can encode custom and standard claims" $
post "/rpc/jwt_test" "{}"
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmdW4iLCJqdGkiOiJmb28iLCJuYmYiOjEzMDA4MTkzODAsImV4cCI6MTMwMDgxOTM4MCwiaHR0cDovL3Bvc3RncmVzdC5jb20vZm9vIjp0cnVlLCJpc3MiOiJqb2UiLCJyb2xlIjoicG9zdGdyZXN0X3Rlc3QiLCJpYXQiOjEzMDA4MTkzODAsImF1ZCI6ImV2ZXJ5b25lIn0._tQCF79-ZZGMlLktd3csM_bVaiMg7A8YvIb6K2hcu5w"} |]
, matchStatus = 200
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
}
it "sql functions can read custom and standard claims variables" $ do
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmdW4iLCJqdGkiOiJmb28iLCJuYmYiOjEzMDA4MTkzODAsImV4cCI6OTk5OTk5OTk5OSwiaHR0cDovL3Bvc3RncmVzdC5jb20vZm9vIjp0cnVlLCJpc3MiOiJqb2UiLCJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWF0IjoxMzAwODE5MzgwLCJhdWQiOiJldmVyeW9uZSJ9.AQmCA7CMScvfaDRMqRPeUY6eNf--69gpW-kxaWfq9X0"
request methodPost "/rpc/reveal_big_jwt" [auth] "{}"
`shouldRespondWith` [json| [
{"sub":"fun", "jti":"foo", "nbf":1300819380, "exp":9999999999,
"http://postgrest.com/foo":true, "iss":"joe", "iat":1300819380,
"aud":"everyone"}] |]
it "allows users with permissions to see their tables" $ do it "allows users with permissions to see their tables" $ do
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
+51
View File
@@ -0,0 +1,51 @@
{-# LANGUAGE MultiParamTypeClasses, TypeFamilies, UndecidableInstances #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Feature.ConcurrentSpec where
import Control.Monad (void)
import Control.Monad.Base
import Control.Monad.Trans.Control
import Control.Concurrent.Async (mapConcurrently)
import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai.Internal
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.Wai.Test (Session)
import Network.Wai (Application)
spec :: SpecWith Application
spec =
describe "Queryiny in parallel" $
it "should not raise 'transaction in progress' error" $
raceTest 10 $
get "/fakefake"
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json|
{ "hint": null,
"details":null,
"code":"42P01",
"message":"relation \"test.fakefake\" does not exist"
} |]
, matchStatus = 404
, matchHeaders = []
}
raceTest :: Int -> WaiExpectation -> WaiExpectation
raceTest times = liftBaseDiscard go
where
go test = void $ mapConcurrently (const test) [1..times]
instance MonadBaseControl IO WaiSession where
type StM WaiSession a = StM Session a
liftBaseWith f = WaiSession $
liftBaseWith $ \runInBase ->
f $ \k -> runInBase (unWaiSession k)
restoreM = WaiSession . restoreM
{-# INLINE liftBaseWith #-}
{-# INLINE restoreM #-}
instance MonadBase IO WaiSession where
liftBase = liftIO
+4 -6
View File
@@ -6,17 +6,15 @@ import Test.Hspec.Wai
import Network.Wai.Test (SResponse(simpleHeaders, simpleBody)) import Network.Wai.Test (SResponse(simpleHeaders, simpleBody))
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import Hasql as H
import Hasql.Postgres as P
import SpecHelper import SpecHelper
import PostgREST.Types (DbStructure(..))
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai (Application)
-- }}} -- }}}
spec :: DbStructure -> H.Pool P.Postgres -> Spec spec :: SpecWith Application
spec struct pool = around (withApp cfgDefault struct pool) $ describe "CORS" $ do spec =
describe "CORS" $ do
let preflightHeaders = [ let preflightHeaders = [
("Accept", "*/*"), ("Accept", "*/*"),
("Origin", "http://example.com"), ("Origin", "http://example.com"),
+25 -9
View File
@@ -4,17 +4,11 @@ import Test.Hspec
import Test.Hspec.Wai import Test.Hspec.Wai
import Text.Heredoc import Text.Heredoc
import Hasql as H
import Hasql.Postgres as P
import SpecHelper
import PostgREST.Types (DbStructure(..))
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai (Application)
spec :: DbStructure -> H.Pool P.Postgres -> Spec spec :: SpecWith Application
spec struct pool = beforeAll resetDb spec =
. around (withApp cfgDefault struct pool) $
describe "Deleting" $ do describe "Deleting" $ do
context "existing record" $ do context "existing record" $ do
it "succeeds with 204 and deletion count" $ it "succeeds with 204 and deletion count" $
@@ -25,6 +19,28 @@ spec struct pool = beforeAll resetDb
, matchHeaders = ["Content-Range" <:> "*/1"] , matchHeaders = ["Content-Range" <:> "*/1"]
} }
it "returns the deleted item" $
request methodDelete "/items?id=eq.2" [("Prefer", "return=representation")] ""
`shouldRespondWith` ResponseMatcher {
matchBody = Just [str|[{"id":2}]|]
, matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/1"]
}
it "returns the deleted item and shapes the response" $
request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
`shouldRespondWith` ResponseMatcher {
matchBody = Just [str|[{"id":2,"name":"Two"}]|]
, matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/1"]
}
it "can embed (parent) entities" $
request methodDelete "/tasks?id=eq.8&select=id,name,project{id}" [("Prefer", "return=representation")] ""
`shouldRespondWith` ResponseMatcher {
matchBody = Just [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
, matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/1"]
}
it "actually clears items ouf the db" $ do it "actually clears items ouf the db" $ do
_ <- request methodDelete "/items?id=lt.15" [] "" _ <- request methodDelete "/items?id=lt.15" [] ""
get "/items" get "/items"
+130 -77
View File
@@ -5,25 +5,23 @@ import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.Wai.Test (SResponse(simpleBody,simpleHeaders,simpleStatus)) import Network.Wai.Test (SResponse(simpleBody,simpleHeaders,simpleStatus))
import Hasql as H
import Hasql.Postgres as P
import SpecHelper import SpecHelper
import PostgREST.Types (DbStructure(..))
import qualified Data.Aeson as JSON import qualified Data.Aeson as JSON
import Data.Maybe (fromJust) import Data.Maybe (fromJust)
import Data.Monoid ((<>))
import Text.Heredoc import Text.Heredoc
import Network.HTTP.Types.Header import Network.HTTP.Types.Header
import Network.HTTP.Types import Network.HTTP.Types
import Control.Monad (replicateM_) import Control.Monad (replicateM_, void)
import TestTypes(IncPK(..), CompoundPK(..)) import TestTypes(IncPK(..), CompoundPK(..))
import Network.Wai (Application)
spec :: DbStructure -> H.Pool P.Postgres -> Spec spec :: SpecWith Application
spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool) $ do spec = do
describe "Posting new record" $ do describe "Posting new record" $ do
after_ (clearTable "menagerie") . context "disparate csv types" $ do context "disparate json types" $ do
it "accepts disparate json types" $ do it "accepts disparate json types" $ do
p <- post "/menagerie" p <- post "/menagerie"
[json| { [json| {
@@ -34,6 +32,8 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
liftIO $ do liftIO $ do
simpleBody p `shouldBe` "" simpleBody p `shouldBe` ""
simpleStatus p `shouldBe` created201 simpleStatus p `shouldBe` created201
-- should not have content type set when body is empty
lookup hContentType (simpleHeaders p) `shouldBe` Nothing
it "filters columns in result using &select" $ it "filters columns in result using &select" $
request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=representation")] request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=representation")]
@@ -44,7 +44,7 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
} |] `shouldRespondWith` ResponseMatcher { } |] `shouldRespondWith` ResponseMatcher {
matchBody = Just [str|{"integer":14,"varchar":"testing!"}|] matchBody = Just [str|{"integer":14,"varchar":"testing!"}|]
, matchStatus = 201 , matchStatus = 201
, matchHeaders = ["Content-Type" <:> "application/json"] , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
} }
it "includes related data after insert" $ it "includes related data after insert" $
@@ -52,12 +52,21 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
[str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher { [str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {
matchBody = Just [str|{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}|] matchBody = Just [str|{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}|]
, matchStatus = 201 , matchStatus = 201
, matchHeaders = ["Content-Type" <:> "application/json", "Location" <:> "/projects?id=eq.6"] , matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8", "Location" <:> "/projects?id=eq.6"]
} }
context "from an html form" $
it "accepts disparate json types" $ do
p <- request methodPost "/menagerie"
[("Content-Type", "application/x-www-form-urlencoded")]
("integer=7&double=2.71828&varchar=forms+are+fun&" <>
"boolean=false&date=1900-01-01&money=$3.99&enum=foo")
liftIO $ do
simpleBody p `shouldBe` ""
simpleStatus p `shouldBe` created201
context "with no pk supplied" $ do context "with no pk supplied" $ do
context "into a table with auto-incrementing pk" . after_ (clearTable "auto_incrementing_pk") $ context "into a table with auto-incrementing pk" $
it "succeeds with 201 and link" $ do it "succeeds with 201 and link" $ do
p <- post "/auto_incrementing_pk" [json| { "non_nullable_string":"not null"} |] p <- post "/auto_incrementing_pk" [json| { "non_nullable_string":"not null"} |]
liftIO $ do liftIO $ do
@@ -76,7 +85,7 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
post "/simple_pk" [json| { "extra":"foo"} |] post "/simple_pk" [json| { "extra":"foo"} |]
`shouldRespondWith` 400 `shouldRespondWith` 400
context "into a table with no pk" . after_ (clearTable "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
p <- post "/no_pk" [json| { "a":"foo", "b":"bar" } |] p <- post "/no_pk" [json| { "a":"foo", "b":"bar" } |]
liftIO $ do liftIO $ do
@@ -111,58 +120,86 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=is.null&b=eq.foo" simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=is.null&b=eq.foo"
simpleStatus p `shouldBe` created201 simpleStatus p `shouldBe` created201
context "with compound pk supplied" . after_ (clearTable "compound_pk") $ context "with compound pk supplied" $
it "builds response location header appropriately" $ it "builds response location header appropriately" $ do
post "/compound_pk" [json| { "k1":12, "k2":42 } |] let inserted = [json| { "k1":12, "k2":"Rock & R+ll" } |]
`shouldRespondWith` ResponseMatcher { expectedObj = CompoundPK 12 "Rock & R+ll" Nothing
matchBody = Nothing, expectedLoc = "/compound_pk?k1=eq.12&k2=eq.Rock%20%26%20R%2Bll"
matchStatus = 201, p <- request methodPost "/compound_pk"
matchHeaders = ["Location" <:> "/compound_pk?k1=eq.12&k2=eq.42"] [("Prefer", "return=representation")]
} inserted
liftIO $ do
JSON.decode (simpleBody p) `shouldBe` Just expectedObj
simpleStatus p `shouldBe` created201
lookup hLocation (simpleHeaders p) `shouldBe` Just expectedLoc
r <- get expectedLoc
liftIO $ do
JSON.decode (simpleBody r) `shouldBe` Just [expectedObj]
simpleStatus r `shouldBe` ok200
context "with bulk insert" $
it "returns 201 but no location header" $ do
let bulkData = [json| [ {"k1":21, "k2":"hello world"}
, {"k1":22, "k2":"bye for now"}]
|]
p <- request methodPost "/compound_pk" [] bulkData
liftIO $ do
simpleStatus p `shouldBe` created201
lookup hLocation (simpleHeaders p) `shouldBe` Nothing
context "with invalid json payload" $ context "with invalid json payload" $
it "fails with 400 and error" $ it "fails with 400 and error" $
post "/simple_pk" "}{ x = 2" `shouldRespondWith` 400 post "/simple_pk" "}{ x = 2" `shouldRespondWith` 400
context "jsonb" . after_ (clearTable "json") $ do context "with valid json payload" $
it "succeeds and returns 201 created" $
post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |] `shouldRespondWith` 201
context "attempting to insert a row with the same primary key" $
it "fails returning a 409 Conflict" $
post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |] `shouldRespondWith` 409
context "attempting to insert a row with conflicting unique constraint" $
it "fails returning a 409 Conflict" $
post "/withUnique" [json| { "uni":"nodup", "extra":"e2" } |] `shouldRespondWith` 409
context "jsonb" $ do
it "serializes nested object" $ do it "serializes nested object" $ do
let inserted = [json| { "data": { "foo":"bar" } } |] let inserted = [json| { "data": { "foo":"bar" } } |]
location = "/json?data=eq.%7B%22foo%22%3A%22bar%22%7D"
request methodPost "/json" request methodPost "/json"
[("Prefer", "return=representation")] [("Prefer", "return=representation")]
inserted inserted
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just inserted matchBody = Just inserted
, matchStatus = 201 , matchStatus = 201
, matchHeaders = ["Location" <:> [str|/json?data=eq.{"foo":"bar"}|]] , matchHeaders = ["Location" <:> location]
} }
-- TODO! the test above seems right, why was the one below working before and not now
-- p <- request methodPost "/json" [("Prefer", "return=representation")] inserted
-- liftIO $ do
-- simpleBody p `shouldBe` inserted
-- simpleHeaders p `shouldSatisfy` matchHeader hLocation "/json\\?data=eq\\.%7B%22foo%22%3A%22bar%22%7D"
-- simpleStatus p `shouldBe` created201
it "serializes nested array" $ do it "serializes nested array" $ do
let inserted = [json| { "data": [1,2,3] } |] let inserted = [json| { "data": [1,2,3] } |]
location = "/json?data=eq.%5B1%2C2%2C3%5D"
request methodPost "/json" request methodPost "/json"
[("Prefer", "return=representation")] [("Prefer", "return=representation")]
inserted inserted
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just inserted matchBody = Just inserted
, matchStatus = 201 , matchStatus = 201
, matchHeaders = ["Location" <:> [str|/json?data=eq.[1,2,3]|]] , matchHeaders = ["Location" <:> location]
}
context "empty object" $
it "successfully populates table with all-default columns" $
post "/items" "{}" `shouldRespondWith` ResponseMatcher {
matchBody = Just ""
, matchStatus = 201
, matchHeaders = []
} }
-- TODO! the test above seems right, why was the one below working before and not now
-- p <- request methodPost "/json" [("Prefer", "return=representation")] inserted
-- liftIO $ do
-- simpleBody p `shouldBe` inserted
-- simpleHeaders p `shouldSatisfy` matchHeader hLocation "/json\\?data=eq\\.%5B1%2C2%2C3%5D"
-- simpleStatus p `shouldBe` created201
describe "CSV insert" $ do describe "CSV insert" $ do
after_ (clearTable "menagerie") . 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"
let inserted = [str|integer,double,varchar,boolean,date,money,enum let inserted = [str|integer,double,varchar,boolean,date,money,enum
@@ -174,18 +211,10 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just inserted matchBody = Just inserted
, matchStatus = 201 , matchStatus = 201
, matchHeaders = ["Content-Type" <:> "text/csv"] , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
} }
-- p <- request methodPost "/menagerie" [("Content-Type", "text/csv")]
-- [str|integer,double,varchar,boolean,date,money,enum
-- |13,3.14159,testing!,false,1900-01-01,$3.99,foo
-- |12,0.1,a string,true,1929-10-01,12,bar
-- |]
-- liftIO $ do
-- simpleBody p `shouldBe` "Content-Type: application/json\nLocation: /menagerie?integer=eq.13\n\n\n--postgrest_boundary\nContent-Type: application/json\nLocation: /menagerie?integer=eq.12\n\n"
-- simpleStatus p `shouldBe` created201
after_ (clearTable "no_pk") . context "requesting full representation" $ do context "requesting full representation" $ do
it "returns full details of inserted record" $ it "returns full details of inserted record" $
request methodPost "/no_pk" request methodPost "/no_pk"
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
@@ -193,21 +222,10 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just "a,b\nbar,baz" matchBody = Just "a,b\nbar,baz"
, matchStatus = 201 , matchStatus = 201
, matchHeaders = ["Content-Type" <:> "text/csv", , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
"Location" <:> "/no_pk?a=eq.bar&b=eq.baz"] "Location" <:> "/no_pk?a=eq.bar&b=eq.baz"]
} }
-- it "can post nulls (old way)" $ do
-- pendingWith "changed the response when in csv mode"
-- request methodPost "/no_pk"
-- [("Content-Type", "text/csv"), ("Prefer", "return=representation")]
-- "a,b\nNULL,foo"
-- `shouldRespondWith` ResponseMatcher {
-- matchBody = Just [json| { "a":null, "b":"foo" } |]
-- , matchStatus = 201
-- , matchHeaders = ["Content-Type" <:> "application/json",
-- "Location" <:> "/no_pk?a=is.null&b=eq.foo"]
-- }
it "can post nulls" $ it "can post nulls" $
request methodPost "/no_pk" request methodPost "/no_pk"
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
@@ -215,23 +233,34 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just "a,b\n,foo" matchBody = Just "a,b\n,foo"
, matchStatus = 201 , matchStatus = 201
, matchHeaders = ["Content-Type" <:> "text/csv", , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
"Location" <:> "/no_pk?a=is.null&b=eq.foo"] "Location" <:> "/no_pk?a=is.null&b=eq.foo"]
} }
after_ (clearTable "no_pk") . context "with wrong number of columns" $ context "with wrong number of columns" $
it "fails for too few" $ do it "fails for too few" $ do
p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz" p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
liftIO $ simpleStatus p `shouldBe` badRequest400 liftIO $ simpleStatus p `shouldBe` badRequest400
-- it does not fail because the extra columns are ignored
-- it "fails for too many" $ do context "with unicode values" $
-- p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz,bat,bad" it "succeeds and returns usable location header" $ do
-- liftIO $ simpleStatus p `shouldBe` badRequest400 let payload = [json| { "a":"圍棋", "b":"" } |]
p <- request methodPost "/no_pk"
[("Prefer", "return=representation")]
payload
liftIO $ do
simpleBody p `shouldBe` payload
simpleStatus p `shouldBe` created201
let Just location = lookup hLocation $ simpleHeaders p
r <- get location
liftIO $ simpleBody r `shouldBe` "["<>payload<>"]"
describe "Putting record" $ do describe "Putting record" $ do
context "to unkonwn uri" $ context "to unknown uri" $
it "gives a 404" $ do it "gives a 404" $ do
pendingWith "Decide on PUT usefullness" pendingWith "Decide on PUT usefullness"
request methodPut "/fake" [] request methodPut "/fake" []
@@ -255,7 +284,7 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
[json| { "k1":12, "k2":42 } |] [json| { "k1":12, "k2":42 } |]
`shouldRespondWith` 400 `shouldRespondWith` 400
context "specifying every column in the table" . after_ (clearTable "compound_pk") $ do context "specifying every column in the table" $ do
it "can create a new record" $ do it "can create a new record" $ do
pendingWith "Decide on PUT usefullness" pendingWith "Decide on PUT usefullness"
p <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" [] p <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []
@@ -270,7 +299,7 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
length rows `shouldBe` 1 length rows `shouldBe` 1
let record = head rows let record = head rows
compoundK1 record `shouldBe` 12 compoundK1 record `shouldBe` 12
compoundK2 record `shouldBe` 42 compoundK2 record `shouldBe` "42"
compoundExtra record `shouldBe` Just 3 compoundExtra record `shouldBe` Just 3
it "can update an existing record" $ do it "can update an existing record" $ do
@@ -287,7 +316,7 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
let record = head rows let record = head rows
compoundExtra record `shouldBe` Just 5 compoundExtra record `shouldBe` Just 5
context "with an auto-incrementing primary key" . after_ (clearTable "auto_incrementing_pk") $ context "with an auto-incrementing primary key"$
it "succeeds with 204" $ do it "succeeds with 204" $ do
pendingWith "Decide on PUT usefullness" pendingWith "Decide on PUT usefullness"
@@ -306,7 +335,7 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
describe "Patching record" $ do describe "Patching record" $ do
context "to unkonwn uri" $ context "to unknown uri" $
it "gives a 404" $ it "gives a 404" $
request methodPatch "/fake" [] request methodPatch "/fake" []
[json| { "real": false } |] [json| { "real": false } |]
@@ -323,13 +352,15 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
g <- get "/items?id=eq.42" g <- get "/items?id=eq.42"
liftIO $ simpleHeaders g liftIO $ simpleHeaders g
`shouldSatisfy` matchHeader "Content-Range" "\\*/0" `shouldSatisfy` matchHeader "Content-Range" "\\*/0"
request methodPatch "/items?id=eq.2" [] p <- request methodPatch "/items?id=eq.2" [] [json| { "id":42 } |]
[json| { "id":42 } |] pure p `shouldRespondWith` ResponseMatcher {
`shouldRespondWith` ResponseMatcher { matchBody = Nothing,
matchBody = Nothing, matchStatus = 204,
matchStatus = 204, matchHeaders = ["Content-Range" <:> "0-0/1"]
matchHeaders = ["Content-Range" <:> "0-0/1"] }
} liftIO $
lookup hContentType (simpleHeaders p) `shouldBe` Nothing
g' <- get "/items?id=eq.42" g' <- get "/items?id=eq.42"
liftIO $ simpleHeaders g' liftIO $ simpleHeaders g'
`shouldSatisfy` matchHeader "Content-Range" "0-0/1" `shouldSatisfy` matchHeader "Content-Range" "0-0/1"
@@ -367,6 +398,28 @@ spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool)
[json| { id: 99 } |] [json| { id: 99 } |]
`shouldRespondWith` [json| [{id:99}] |] `shouldRespondWith` [json| [{id:99}] |]
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` ResponseMatcher {
matchBody = Just [json| [{ "data": { "escaped":" \"bar" } }] |]
, matchStatus = 200
, matchHeaders = []
}
context "with unicode values" $
it "succeeds and returns values intact" $ do
void $ request methodPost "/no_pk" []
[json| { "a":"patchme", "b":"patchme" } |]
let payload = [json| { "a":"圍棋", "b":"" } |]
p <- request methodPatch "/no_pk?a=eq.patchme&b=eq.patchme"
[("Prefer", "return=representation")] payload
liftIO $ do
simpleBody p `shouldBe` "["<>payload<>"]"
simpleStatus p `shouldBe` ok200
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.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0" let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
+16 -13
View File
@@ -5,30 +5,33 @@ import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus)) import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus))
import Text.Heredoc
import Hasql as H
import Hasql.Postgres as P
import SpecHelper import SpecHelper
import PostgREST.Types (DbStructure(..)) import Network.Wai (Application)
spec :: DbStructure -> H.Pool P.Postgres -> Spec spec :: SpecWith Application
spec struct pool = spec =
beforeAll resetDb
. around (withApp (cfgLimitRows 3) struct pool) $
describe "Requesting many items with server limits enabled" $ do describe "Requesting many items with server limits enabled" $ do
it "restricts results" $ it "restricts results" $
get "/items" get "/items"
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just [json| [{"id":1},{"id":2},{"id":3}] |] matchBody = Just [json| [{"id":1},{"id":2}] |]
, matchStatus = 206 , matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-2/15"] , matchHeaders = ["Content-Range" <:> "0-1/15"]
} }
it "respects additional client limiting" $ do it "respects additional client limiting" $ do
r <- request methodGet "/items" r <- request methodGet "/items"
(rangeHdrs $ ByteRangeFromTo 0 1) "" (rangeHdrs $ ByteRangeFromTo 0 0) ""
liftIO $ do liftIO $ do
simpleHeaders r `shouldSatisfy` simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "0-1/15" matchHeader "Content-Range" "0-0/15"
simpleStatus r `shouldBe` partialContent206 simpleStatus r `shouldBe` partialContent206
it "limit works on all levels" $
get "/users?select=id,tasks{id}&order=id.asc&tasks.order=id.asc"
`shouldRespondWith` ResponseMatcher {
matchBody = Just [str|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
, matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-1/3"]
}
+143 -10
View File
@@ -6,15 +6,12 @@ import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai.Test (SResponse(simpleHeaders)) import Network.Wai.Test (SResponse(simpleHeaders))
import Hasql as H
import Hasql.Postgres as P
import SpecHelper import SpecHelper
import PostgREST.Types (DbStructure(..))
import Text.Heredoc import Text.Heredoc
import Network.Wai (Application)
spec :: DbStructure -> H.Pool P.Postgres -> Spec spec :: SpecWith Application
spec struct pool = around (withApp cfgDefault struct pool) $ do spec = do
describe "Querying a table with a column called count" $ describe "Querying a table with a column called count" $
it "should not confuse count column with pg_catalog.count aggregate" $ it "should not confuse count column with pg_catalog.count aggregate" $
@@ -146,16 +143,29 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
it "selectStar works in absense of parameter" $ it "selectStar works in absense of parameter" $
get "/complex_items?id=eq.3" `shouldRespondWith` get "/complex_items?id=eq.3" `shouldRespondWith`
[str|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3]}]|] [str|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|]
it "dash `-` in column names is accepted" $
get "/complex_items?id=eq.3&select=id,field-with_sep" `shouldRespondWith`
[str|[{"id":3,"field-with_sep":1}]|]
it "one simple column" $ it "one simple column" $
get "/complex_items?select=id" `shouldRespondWith` get "/complex_items?select=id" `shouldRespondWith`
[json| [{"id":1},{"id":2},{"id":3}] |] [json| [{"id":1},{"id":2},{"id":3}] |]
it "rename simple column" $
get "/complex_items?id=eq.1&select=myId:id" `shouldRespondWith`
[json| [{"myId":1}] |]
it "one simple column with casting (text)" $ it "one simple column with casting (text)" $
get "/complex_items?select=id::text" `shouldRespondWith` get "/complex_items?select=id::text" `shouldRespondWith`
[json| [{"id":"1"},{"id":"2"},{"id":"3"}] |] [json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]
it "rename simple column with casting" $
get "/complex_items?id=eq.1&select=myId:id::text" `shouldRespondWith`
[json| [{"myId":"1"}] |]
it "json column" $ it "json column" $
get "/complex_items?id=eq.1&select=settings" `shouldRespondWith` get "/complex_items?id=eq.1&select=settings" `shouldRespondWith`
[json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |] [json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |]
@@ -164,6 +174,10 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith` 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" [json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
it "rename json subfield one level with casting (json)" $
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"
it "fails on bad casting (data of the wrong format)" $ it "fails on bad casting (data of the wrong format)" $
get "/complex_items?select=settings->foo->>bar::integer" get "/complex_items?select=settings->foo->>bar::integer"
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
@@ -185,15 +199,33 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith` get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
[json| [{"bar":"baz"}] |] [json| [{"bar":"baz"}] |]
it "rename json subfield two levels (string)" $
get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
[json| [{"myBar":"baz"}] |]
it "json subfield two levels with casting (int)" $ it "json subfield two levels with casting (int)" $
get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith` 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 [json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
it "rename 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
it "requesting parents and children" $ it "requesting parents and children" $
get "/projects?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith` get "/projects?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|] [str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
it "embed data with two fk pointing to the same table" $
get "/orders?id=eq.1&select=id, name, billing_address_id{id}, shipping_address_id{id}" `shouldRespondWith`
[str|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
it "requesting parents and children while renaming them" $
get "/projects?id=eq.1&select=myId:id, name, project_client:client_id{*}, project_tasks:tasks{id, name}" `shouldRespondWith`
[str|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
it "requesting parents and filtering parent columns" $ it "requesting parents and filtering parent columns" $
get "/projects?id=eq.1&select=id, name, clients{id}" `shouldRespondWith` get "/projects?id=eq.1&select=id, name, clients{id}" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|] [str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]
@@ -214,6 +246,15 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
get "/tasks?select=id,users{id}" `shouldRespondWith` get "/tasks?select=id,users{id}" `shouldRespondWith`
[str|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|] [str|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
it "requesting many<->many relation with rename" $
get "/tasks?id=eq.1&select=id,theUsers:users{id}" `shouldRespondWith`
[str|[{"id":1,"theUsers":[{"id":1},{"id":3}]}]|]
it "requesting many<->many relation reverse" $
get "/users?select=id,tasks{id}" `shouldRespondWith`
[str|[{"id":1,"tasks":[{"id":1},{"id":2},{"id":3},{"id":4}]},{"id":2,"tasks":[{"id":5},{"id":6},{"id":7}]},{"id":3,"tasks":[{"id":1},{"id":5}]}]|]
it "requesting parents and children on views" $ it "requesting parents and children on views" $
get "/projects_view?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith` get "/projects_view?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|] [str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
@@ -244,6 +285,14 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
, matchHeaders = [] , matchHeaders = []
} }
it "can combine multiple prefer values" $
request methodGet "/items?id=eq.5" [("Prefer","plurality=singular ; future=new; count=none")] ""
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json| {"id":5} |]
, matchStatus = 200
, matchHeaders = []
}
it "works in the presence of a range header" $ it "works in the presence of a range header" $
let headers = ("Prefer","plurality=singular") : let headers = ("Prefer","plurality=singular") :
rangeHdrs (ByteRangeFromTo 0 9) in rangeHdrs (ByteRangeFromTo 0 9) in
@@ -313,6 +362,28 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
it "without other constraints" $ it "without other constraints" $
get "/items?order=id.asc" `shouldRespondWith` 200 get "/items?order=id.asc" `shouldRespondWith` 200
it "ordering embeded entities" $
get "/projects?id=eq.1&select=id, name, tasks{id, name}&tasks.order=name.asc" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]
it "ordering embeded entities with alias" $
get "/projects?id=eq.1&select=id, name, the_tasks:tasks{id, name}&tasks.order=name.asc" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","the_tasks":[{"id":2,"name":"Code w7"},{"id":1,"name":"Design w7"}]}]|]
it "ordering embeded entities, two levels" $
get "/projects?id=eq.1&select=id, name, tasks{id, name, users{id, name}}&tasks.order=name.asc&tasks.users.order=name.desc" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","tasks":[{"id":2,"name":"Code w7","users":[{"id":1,"name":"Angela Martin"}]},{"id":1,"name":"Design w7","users":[{"id":3,"name":"Dwight Schrute"},{"id":1,"name":"Angela Martin"}]}]}]|]
it "ordering embeded parents does not break things" $
get "/projects?id=eq.1&select=id, name, clients{id, name}&clients.order=name.asc" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|]
it "ordering embeded parents does not break things when using ducktape names" $
get "/projects?id=eq.1&select=id, name, client{id, name}&client.order=name.asc" `shouldRespondWith`
[str|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}]|]
describe "Accept headers" $ do describe "Accept headers" $ do
it "should respond an unknown accept type with 415" $ it "should respond an unknown accept type with 415" $
request methodGet "/simple_pk" request methodGet "/simple_pk"
@@ -335,7 +406,7 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
`shouldRespondWith` ResponseMatcher { `shouldRespondWith` ResponseMatcher {
matchBody = Just "k,extra\nxyyx,u\nxYYx,v" matchBody = Just "k,extra\nxyyx,u\nxYYx,v"
, matchStatus = 200 , matchStatus = 200
, matchHeaders = ["Content-Type" <:> "text/csv"] , matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
} }
describe "Canonical location" $ do describe "Canonical location" $ do
@@ -368,7 +439,17 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |] [json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
describe "remote procedure call" $ do describe "remote procedure call" $ do
context "a proc that returns a set" $ context "a proc that returns a set" $ do
it "returns paginated results" $
request methodPost "/rpc/getitemrange"
(rangeHdrs (ByteRangeFromTo 0 0)) [json| { "min": 2, "max": 4 } |]
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json| [{"id":3}] |]
, matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-0/2"]
}
it "returns proper json" $ it "returns proper json" $
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith` post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
[json| [ {"id": 3}, {"id":4} ] |] [json| [ {"id": 3}, {"id":4} ] |]
@@ -378,7 +459,59 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith` post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
[json| [] |] [json| [] |]
context "a proc that returns plain text" $ context "a proc that returns plain text" $ do
it "returns proper json" $ it "returns proper json" $
post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith` post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`
[json| [{"sayhello":"Hello, world"}] |] [json| [{"sayhello":"Hello, world"}] |]
it "can handle unicode" $
post "/rpc/sayhello" [json| { "name": "" } |] `shouldRespondWith`
[json| [{"sayhello":"Hello, ¥"}] |]
context "improper input" $ do
it "rejects unknown content type even if payload is good" $
request methodPost "/rpc/sayhello"
(acceptHdrs "audio/mpeg3") [json| { "name": "world" } |]
`shouldRespondWith` 415
it "rejects malformed json payload" $
request methodPost "/rpc/sayhello"
(acceptHdrs "application/json") "sdfsdf"
`shouldRespondWith` 400
context "unsupported verbs" $ do
it "DELETE fails" $
request methodDelete "/rpc/sayhello" [] ""
`shouldRespondWith` 405
it "PATCH fails" $
request methodPatch "/rpc/sayhello" [] ""
`shouldRespondWith` 405
it "OPTIONS fails" $
-- TODO: should return info about the function
request methodOptions "/rpc/sayhello" [] ""
`shouldRespondWith` 405
it "GET fails with 405 on unknown procs" $
-- TODO: should this be 404?
get "/rpc/fake" `shouldRespondWith` 405
it "GET with 405 on known procs" $
get "/rpc/sayhello" `shouldRespondWith` 405
it "executes the proc exactly once per request" $ do
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
[json| [{"callcounter":1}] |]
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
[json| [{"callcounter":2}] |]
describe "weird requests" $ do
it "can query as normal" $ do
get "/Escap3e;" `shouldRespondWith`
[json| [{"so6meIdColumn":1},{"so6meIdColumn":2},{"so6meIdColumn":3},{"so6meIdColumn":4},{"so6meIdColumn":5}] |]
get "/ghostBusters" `shouldRespondWith`
[json| [{"escapeId":1},{"escapeId":3},{"escapeId":5}] |]
it "will embed a collection" $
get "/Escap3e;?select=ghostBusters{*}" `shouldRespondWith`
[json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
it "will embed using a column" $
get "/ghostBusters?select=escapeId{*}" `shouldRespondWith`
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
+144 -7
View File
@@ -6,17 +6,110 @@ import Test.Hspec.Wai.JSON
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus)) import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus))
import Hasql as H import qualified Data.ByteString.Lazy as BL
import Hasql.Postgres as P
import SpecHelper import SpecHelper
import PostgREST.Types (DbStructure(..)) import Text.Heredoc
import Network.Wai (Application)
spec :: DbStructure -> H.Pool P.Postgres -> Spec defaultRange :: BL.ByteString
spec struct pool = beforeAll resetDb defaultRange = [json| { "min": 0, "max": 15 } |]
. around (withApp cfgDefault struct pool) $
emptyRange :: BL.ByteString
emptyRange = [json| { "min": 2, "max": 2 } |]
spec :: SpecWith Application
spec = do
describe "POST /rpc/getitemrange" $ do
context "without range headers" $ do
context "with response under server size limit" $
it "returns whole range with status 200" $
post "/rpc/getitemrange" defaultRange `shouldRespondWith` 200
context "when I don't want the count" $ do
it "returns range Content-Range with */* for empty range" $
request methodPost "/rpc/getitemrange"
[("Prefer", "count=none")] emptyRange
`shouldRespondWith` ResponseMatcher {
matchBody = Just [json| [] |]
, matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/*"]
}
it "returns range Content-Range with range/*" $
request methodPost "/rpc/getitemrange"
[("Prefer", "count=none")] defaultRange
`shouldRespondWith` ResponseMatcher {
matchBody = Just [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/*"]
}
context "with range headers" $ do
context "of acceptable range" $ do
it "succeeds with partial content" $ do
r <- request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 0 1) defaultRange
liftIO $ do
simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "0-1/15"
simpleStatus r `shouldBe` partialContent206
it "understands open-ended ranges" $
request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFrom 0) defaultRange
`shouldRespondWith` 200
it "returns an empty body when there are no results" $
request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 0 1) emptyRange
`shouldRespondWith` ResponseMatcher {
matchBody = Just "[]"
, matchStatus = 200
, matchHeaders = ["Content-Range" <:> "*/0"]
}
it "allows one-item requests" $ do
r <- request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 0 0) defaultRange
liftIO $ do
simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "0-0/15"
simpleStatus r `shouldBe` partialContent206
it "handles ranges beyond collection length via truncation" $ do
r <- request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 10 100) defaultRange
liftIO $ do
simpleHeaders r `shouldSatisfy`
matchHeader "Content-Range" "10-14/15"
simpleStatus r `shouldBe` partialContent206
context "of invalid range" $ do
it "fails with 416 for offside range" $
request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
`shouldRespondWith` 416
it "refuses a range with nonzero start when there are no items" $
request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 1 2) emptyRange
`shouldRespondWith` ResponseMatcher {
matchBody = Nothing
, matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/0"]
}
it "refuses a range requesting start past last item" $
request methodPost "/rpc/getitemrange"
(rangeHdrs $ ByteRangeFromTo 100 199) defaultRange
`shouldRespondWith` ResponseMatcher {
matchBody = Nothing
, matchStatus = 416
, matchHeaders = ["Content-Range" <:> "*/15"]
}
describe "GET /items" $ do describe "GET /items" $ do
context "without range headers" $ do context "without range headers" $ do
context "with response under server size limit" $ context "with response under server size limit" $
it "returns whole range with status 200" $ it "returns whole range with status 200" $
@@ -50,6 +143,50 @@ spec struct pool = beforeAll resetDb
, matchHeaders = ["Content-Range" <:> "0-0/*"] , matchHeaders = ["Content-Range" <:> "0-0/*"]
} }
context "with limit/offset parameters" $ do
it "no parameters return everything" $
get "/items?select=id&order=id.asc"
`shouldRespondWith` ResponseMatcher {
matchBody = Just [str|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
, matchStatus = 200
, matchHeaders = ["Content-Range" <:> "0-14/15"]
}
it "top level limit with parameter" $
get "/items?select=id&order=id.asc&limit=3"
`shouldRespondWith` ResponseMatcher {
matchBody = Just [str|[{"id":1},{"id":2},{"id":3}]|]
, matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-2/15"]
}
it "headers override get parameters" $
request methodGet "/items?select=id&order=id.asc&limit=3"
(rangeHdrs $ ByteRangeFromTo 0 1) ""
`shouldRespondWith` ResponseMatcher {
matchBody = Just [str|[{"id":1},{"id":2}]|]
, matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-1/15"]
}
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=1&projects.tasks.order=id.asc&projects.tasks.limit=2"
`shouldRespondWith` ResponseMatcher {
matchBody = Just [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]}]}]|]
, matchStatus = 206
, matchHeaders = ["Content-Range" <:> "0-0/2"]
}
it "fails on offset specified below level 1" $
get "/clients?select=id,projects{id,tasks{id}}&projects.offset=2&projects.limit=1"
`shouldRespondWith` 400
it "limit and offset works on first level" $
get "/items?select=id&order=id.asc&limit=3&offset=2"
`shouldRespondWith` ResponseMatcher {
matchBody = Just [str|[{"id":3},{"id":4},{"id":5}]|]
, matchStatus = 206
, matchHeaders = ["Content-Range" <:> "2-4/15"]
}
context "with range headers" $ do context "with range headers" $ do
context "of acceptable range" $ do context "of acceptable range" $ do
+85 -7
View File
@@ -4,27 +4,31 @@ import Test.Hspec hiding (pendingWith)
import Test.Hspec.Wai import Test.Hspec.Wai
import Test.Hspec.Wai.JSON import Test.Hspec.Wai.JSON
import Hasql as H
import Hasql.Postgres as P
import SpecHelper import SpecHelper
import PostgREST.Types (DbStructure(..))
import Network.HTTP.Types import Network.HTTP.Types
import Network.Wai (Application)
import Network.Wai.Test (SResponse(simpleHeaders))
spec :: SpecWith Application
spec = do
spec :: DbStructure -> H.Pool P.Postgres -> Spec
spec struct pool = around (withApp cfgDefault struct pool) $ do
describe "GET /" $ do describe "GET /" $ do
it "lists views in schema" $ it "lists views in schema" $
request methodGet "/" [] "" request methodGet "/" [] ""
`shouldRespondWith` [json| [ `shouldRespondWith` [json| [
{"schema":"test","name":"articleStars","insertable":true} {"schema":"test","name":"Escap3e;","insertable":true}
, {"schema":"test","name":"addresses","insertable":true}
, {"schema":"test","name":"articleStars","insertable":true}
, {"schema":"test","name":"articles","insertable":true} , {"schema":"test","name":"articles","insertable":true}
, {"schema":"test","name":"auto_incrementing_pk","insertable":true} , {"schema":"test","name":"auto_incrementing_pk","insertable":true}
, {"schema":"test","name":"clients","insertable":true} , {"schema":"test","name":"clients","insertable":true}
, {"schema":"test","name":"comments","insertable":true} , {"schema":"test","name":"comments","insertable":true}
, {"schema":"test","name":"complex_items","insertable":true} , {"schema":"test","name":"complex_items","insertable":true}
, {"schema":"test","name":"compound_pk","insertable":true} , {"schema":"test","name":"compound_pk","insertable":true}
, {"schema":"test","name":"empty_table","insertable":true}
, {"schema":"test","name":"filtered_tasks","insertable":true}
, {"schema":"test","name":"ghostBusters","insertable":true}
, {"schema":"test","name":"has_count_column","insertable":false} , {"schema":"test","name":"has_count_column","insertable":false}
, {"schema":"test","name":"has_fk","insertable":true} , {"schema":"test","name":"has_fk","insertable":true}
, {"schema":"test","name":"insertable_view_with_join","insertable":true} , {"schema":"test","name":"insertable_view_with_join","insertable":true}
@@ -35,6 +39,7 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
, {"schema":"test","name":"menagerie","insertable":true} , {"schema":"test","name":"menagerie","insertable":true}
, {"schema":"test","name":"no_pk","insertable":true} , {"schema":"test","name":"no_pk","insertable":true}
, {"schema":"test","name":"nullable_integer","insertable":true} , {"schema":"test","name":"nullable_integer","insertable":true}
, {"schema":"test","name":"orders","insertable":true}
, {"schema":"test","name":"projects","insertable":true} , {"schema":"test","name":"projects","insertable":true}
, {"schema":"test","name":"projects_view","insertable":true} , {"schema":"test","name":"projects_view","insertable":true}
, {"schema":"test","name":"simple_pk","insertable":true} , {"schema":"test","name":"simple_pk","insertable":true}
@@ -43,6 +48,7 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
, {"schema":"test","name":"users","insertable":true} , {"schema":"test","name":"users","insertable":true}
, {"schema":"test","name":"users_projects","insertable":true} , {"schema":"test","name":"users_projects","insertable":true}
, {"schema":"test","name":"users_tasks","insertable":true} , {"schema":"test","name":"users_tasks","insertable":true}
, {"schema":"test","name":"withUnique","insertable":true}
] |] ] |]
{matchStatus = 200} {matchStatus = 200}
@@ -56,6 +62,61 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
{matchStatus = 200} {matchStatus = 200}
describe "Table info" $ do describe "Table info" $ do
it "The structure of complex views is correctly detected" $
request methodOptions "/filtered_tasks" [] "" `shouldRespondWith`
[json|
{
"pkey": [
"myId"
],
"columns": [
{
"references": null,
"default": null,
"precision": 32,
"updatable": true,
"schema": "test",
"name": "myId",
"type": "integer",
"maxLen": null,
"enum": [],
"nullable": true,
"position": 1
},
{
"references": null,
"default": null,
"precision": null,
"updatable": true,
"schema": "test",
"name": "name",
"type": "text",
"maxLen": null,
"enum": [],
"nullable": true,
"position": 2
},
{
"references": {
"schema": "test",
"column": "id",
"table": "projects"
},
"default": null,
"precision": 32,
"updatable": true,
"schema": "test",
"name": "projectID",
"type": "integer",
"maxLen": null,
"enum": [],
"nullable": true,
"position": 3
}
]
}
|]
it "is available with OPTIONS verb" $ it "is available with OPTIONS verb" $
request methodOptions "/menagerie" [] "" `shouldRespondWith` request methodOptions "/menagerie" [] "" `shouldRespondWith`
[json| [json|
@@ -319,3 +380,20 @@ spec struct pool = around (withApp cfgDefault struct pool) $ do
] ]
} }
|] |]
it "errors for non existant tables" $
request methodOptions "/dne" [] "" `shouldRespondWith` 404
describe "Allow header" $ do
it "includes read/write verbs for writeable table" $ do
r <- request methodOptions "/items" [] ""
liftIO $
simpleHeaders r `shouldSatisfy`
matchHeader "Allow" "GET,POST,PATCH,DELETE"
it "includes read verbs for read-only table" $ do
r <- request methodOptions "/has_count_column" [] ""
liftIO $
simpleHeaders r `shouldSatisfy`
matchHeader "Allow" "GET"
+20
View File
@@ -0,0 +1,20 @@
module Feature.UnicodeSpec where
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
import Network.Wai (Application)
import Control.Monad (void)
spec :: SpecWith Application
spec =
describe "Reading and writing to unicode schema and table names" $
it "Can read and write values" $ do
get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
`shouldRespondWith` "[]"
void $ post "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
[json| { "هویت": 1 } |]
get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
`shouldRespondWith` [json| [{ "هویت": 1 }] |]
+35 -15
View File
@@ -3,9 +3,15 @@ module Main where
import Test.Hspec import Test.Hspec
import SpecHelper import SpecHelper
--import PostgREST.Types (DbStructure(..)) import qualified Hasql.Pool as P
import PostgREST.DbStructure (getDbStructure)
import PostgREST.App (postgrest)
import Data.IORef
import Data.String.Conversions (cs)
import qualified Feature.AuthSpec import qualified Feature.AuthSpec
import qualified Feature.ConcurrentSpec
import qualified Feature.CorsSpec import qualified Feature.CorsSpec
import qualified Feature.DeleteSpec import qualified Feature.DeleteSpec
import qualified Feature.InsertSpec import qualified Feature.InsertSpec
@@ -13,25 +19,39 @@ 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.StructureSpec
import qualified Feature.UnicodeSpec
main :: IO () main :: IO ()
main = do main = do
setupDb setupDb
pool <- specDbPool pool <- P.acquire (3, 10, cs testDbConn)
dbStructure <- specDbStructure pool
-- Not using hspec-discover because we want to precompute result <- P.use pool $ getDbStructure "test"
-- the db structure and pass it to specs for speed refDbStructure <- newIORef $ either (error.show) id result
hspec $ specs dbStructure pool let withApp = return $ postgrest testCfg refDbStructure pool
ltdApp = return $ postgrest testLtdRowsCfg refDbStructure pool
unicodeApp = return $ postgrest testUnicodeCfg refDbStructure pool
hspec $ do
mapM_ (beforeAll_ resetDb . before withApp) specs
-- this test runs with a different server flag
beforeAll_ resetDb . before ltdApp $
describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec
-- this test runs with a different schema
beforeAll_ resetDb . before unicodeApp $
describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec
where where
specs dbStructure pool = do specs = map (uncurry describe) [
describe "Feature.AuthSpec" $ Feature.AuthSpec.spec dbStructure pool ("Feature.AuthSpec" , Feature.AuthSpec.spec)
describe "Feature.CorsSpec" $ Feature.CorsSpec.spec dbStructure pool , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
describe "Feature.DeleteSpec" $ Feature.DeleteSpec.spec dbStructure pool , ("Feature.CorsSpec" , Feature.CorsSpec.spec)
describe "Feature.InsertSpec" $ Feature.InsertSpec.spec dbStructure pool , ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
describe "Feature.QueryLimitedSpec" $ Feature.QueryLimitedSpec.spec dbStructure pool , ("Feature.InsertSpec" , Feature.InsertSpec.spec)
describe "Feature.QuerySpec" $ Feature.QuerySpec.spec dbStructure pool , ("Feature.QuerySpec" , Feature.QuerySpec.spec)
describe "Feature.RangeSpec" $ Feature.RangeSpec.spec dbStructure pool , ("Feature.RangeSpec" , Feature.RangeSpec.spec)
describe "Feature.StructureSpec" $ Feature.StructureSpec.spec dbStructure pool , ("Feature.StructureSpec" , Feature.StructureSpec.spec)
]
+11 -65
View File
@@ -1,75 +1,33 @@
module SpecHelper where module SpecHelper where
import Network.Wai
import Test.Hspec
import Test.Hspec.Wai
import Hasql as H
import Hasql.Backend as B
import Hasql.Postgres as P
import Data.String.Conversions (cs) import Data.String.Conversions (cs)
import Data.Monoid
import Data.Text hiding (map)
import qualified Data.Vector as V
import Data.Time.Clock.POSIX (getPOSIXTime)
import Control.Monad (void) import Control.Monad (void)
import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange, import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange,
hRange, hAuthorization, hAccept) hRange, hAuthorization, hAccept)
import Codec.Binary.Base64.String (encode) import Codec.Binary.Base64.String (encode)
import Data.CaseInsensitive (CI(..)) import Data.CaseInsensitive (CI(..))
import Data.Maybe (fromMaybe)
import Text.Regex.TDFA ((=~)) import Text.Regex.TDFA ((=~))
import qualified Data.ByteString.Char8 as BS import qualified Data.ByteString.Char8 as BS
import System.Process (readProcess) import System.Process (readProcess)
import Web.JWT (secret) import Web.JWT (secret)
import PostgREST.App (app)
import PostgREST.Config (AppConfig(..)) import PostgREST.Config (AppConfig(..))
import PostgREST.Middleware
import PostgREST.Error(pgErrResponse)
import PostgREST.DbStructure
import PostgREST.Types
dbString :: String testDbConn :: String
dbString = "postgres://postgrest_test_authenticator@localhost:5432/postgrest_test" testDbConn = "postgres://postgrest_test_authenticator@localhost:5432/postgrest_test"
cfg :: String -> Maybe Int -> AppConfig testCfg :: AppConfig
cfg conStr = AppConfig conStr 3000 "postgrest_test_anonymous" "test" (secret "safe") 10 testCfg =
AppConfig testDbConn "postgrest_test_anonymous" "test" 3000 (secret "safe") 10 Nothing True
cfgDefault :: AppConfig testUnicodeCfg :: AppConfig
cfgDefault = cfg dbString Nothing testUnicodeCfg =
AppConfig testDbConn "postgrest_test_anonymous" "تست" 3000 (secret "safe") 10 Nothing True
cfgLimitRows :: Int -> AppConfig testLtdRowsCfg :: AppConfig
cfgLimitRows = cfg dbString . Just testLtdRowsCfg =
AppConfig testDbConn "postgrest_test_anonymous" "test" 3000 (secret "safe") 10 (Just 2) True
testPoolOpts :: PoolSettings
testPoolOpts = fromMaybe (error "bad settings") $ H.poolSettings 1 30
pgSettings :: P.Settings
pgSettings = P.StringSettings $ cs dbString
specDbPool :: IO (H.Pool P.Postgres)
specDbPool = H.acquirePool pgSettings testPoolOpts
specDbStructure :: H.Pool P.Postgres -> IO DbStructure
specDbStructure pool = do
dbOrError <- H.session pool $ H.tx specTxSettings
$ getDbStructure "test"
either (fail . show) return dbOrError
withApp :: AppConfig -> DbStructure -> H.Pool P.Postgres
-> ActionWith Application -> IO ()
withApp config dbStructure pool perform = do
perform $ middle $ \req resp -> do
time <- getPOSIXTime
body <- strictRequestBody req
result <- liftIO $ H.session pool $ H.tx specTxSettings
$ runWithClaims config time (app dbStructure config body) req
either (resp . pgErrResponse) resp result
where middle = defaultMiddle
setupDb :: IO () setupDb :: IO ()
setupDb = do setupDb = do
@@ -106,15 +64,3 @@ authHeaderBasic u p =
authHeaderJWT :: String -> Header authHeaderJWT :: String -> Header
authHeaderJWT token = authHeaderJWT token =
(hAuthorization, cs $ "Bearer " ++ token) (hAuthorization, cs $ "Bearer " ++ token)
testPool :: IO(H.Pool P.Postgres)
testPool = H.acquirePool pgSettings testPoolOpts
clearTable :: Text -> IO ()
clearTable table = do
pool <- testPool
void . liftIO $ H.session pool $ H.tx Nothing $
H.unitEx $ B.Stmt ("truncate table test." <> table <> " cascade") V.empty True
specTxSettings :: Maybe (TxIsolationLevel, Maybe Bool)
specTxSettings = Just (H.ReadCommitted, Just True)
+2 -2
View File
@@ -37,9 +37,9 @@ instance JSON.FromJSON IncPK where
data CompoundPK = CompoundPK { data CompoundPK = CompoundPK {
compoundK1 :: Int compoundK1 :: Int
, compoundK2 :: Int , compoundK2 :: String
, compoundExtra :: Maybe Int , compoundExtra :: Maybe Int
} } deriving (Eq, Show)
instance JSON.FromJSON CompoundPK where instance JSON.FromJSON CompoundPK where
parseJSON (JSON.Object r) = CompoundPK <$> parseJSON (JSON.Object r) = CompoundPK <$>
+26 -6
View File
@@ -65,13 +65,13 @@ SET search_path = test, pg_catalog;
-- --
-- Data for Name: authors_only; Type: TABLE DATA; Schema: test; Owner: - -- Data for Name: authors_only; Type: TABLE DATA; Schema: test; Owner: -
-- --
TRUNCATE TABLE authors_only CASCADE;
-- --
-- Data for Name: auto_incrementing_pk; Type: TABLE DATA; Schema: test; Owner: - -- Data for Name: auto_incrementing_pk; Type: TABLE DATA; Schema: test; Owner: -
-- --
TRUNCATE TABLE auto_incrementing_pk CASCADE;
-- --
@@ -154,7 +154,7 @@ INSERT INTO complex_items VALUES (3, 'Three', '{"foo":{"int":1,"bar":"baz"}}', '
-- --
-- Data for Name: compound_pk; Type: TABLE DATA; Schema: test; Owner: - -- Data for Name: compound_pk; Type: TABLE DATA; Schema: test; Owner: -
-- --
TRUNCATE TABLE compound_pk CASCADE;
-- --
@@ -168,7 +168,7 @@ INSERT INTO simple_pk VALUES ('xYYx', 'v');
-- --
-- Data for Name: has_fk; Type: TABLE DATA; Schema: test; Owner: - -- Data for Name: has_fk; Type: TABLE DATA; Schema: test; Owner: -
-- --
TRUNCATE TABLE has_fk CASCADE;
-- --
@@ -204,7 +204,7 @@ INSERT INTO items VALUES (15);
-- Name: items_id_seq; Type: SEQUENCE SET; Schema: test; Owner: - -- Name: items_id_seq; Type: SEQUENCE SET; Schema: test; Owner: -
-- --
SELECT pg_catalog.setval('items_id_seq', 1, true); SELECT pg_catalog.setval('items_id_seq', 15, true);
-- --
@@ -218,7 +218,7 @@ INSERT INTO json VALUES ('{"foo":{"bar":"baz"},"id":1}');
-- --
-- Data for Name: menagerie; Type: TABLE DATA; Schema: test; Owner: - -- Data for Name: menagerie; Type: TABLE DATA; Schema: test; Owner: -
-- --
TRUNCATE TABLE menagerie CASCADE;
-- --
@@ -260,6 +260,26 @@ INSERT INTO users_projects VALUES (2, 4);
INSERT INTO users_projects VALUES (3, 1); INSERT INTO users_projects VALUES (3, 1);
INSERT INTO users_projects VALUES (3, 3); INSERT INTO users_projects VALUES (3, 3);
TRUNCATE TABLE "Escap3e;" CASCADE;
INSERT INTO "Escap3e;" VALUES (1), (2), (3), (4), (5);
TRUNCATE TABLE "ghostBusters" CASCADE;
INSERT INTO "ghostBusters" VALUES (1), (3), (5);
TRUNCATE TABLE "withUnique" CASCADE;
INSERT INTO "withUnique" VALUES ('nodup', 'blah');
TRUNCATE TABLE addresses CASCADE;
INSERT INTO addresses VALUES (1, 'address 1');
INSERT INTO addresses VALUES (2, 'address 2');
INSERT INTO addresses VALUES (3, 'address 3');
INSERT INTO addresses VALUES (4, 'address 4');
TRUNCATE TABLE orders CASCADE;
INSERT INTO orders VALUES (1, 'order 1', 1, 2);
INSERT INTO orders VALUES (2, 'order 2', 3, 4);
-- --
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
+11 -1
View File
@@ -2,10 +2,11 @@
GRANT USAGE ON SCHEMA GRANT USAGE ON SCHEMA
postgrest postgrest
, test , test
, "تست"
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
-- Schema test objects -- Schema test objects
SET search_path = test, pg_catalog; SET search_path = test, "تست", pg_catalog;
GRANT ALL ON TABLE GRANT ALL ON TABLE
items items
@@ -16,6 +17,7 @@ GRANT ALL ON TABLE
, comments , comments
, complex_items , complex_items
, compound_pk , compound_pk
, empty_table
, has_count_column , has_count_column
, has_fk , has_fk
, insertable_view_with_join , insertable_view_with_join
@@ -28,10 +30,17 @@ GRANT ALL ON TABLE
, projects_view , projects_view
, simple_pk , simple_pk
, tasks , tasks
, filtered_tasks
, tsearch , tsearch
, users , users
, users_projects , users_projects
, users_tasks , users_tasks
, "Escap3e;"
, "ghostBusters"
, "withUnique"
, "موارد"
, addresses
, orders
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;
@@ -39,6 +48,7 @@ GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
GRANT USAGE ON SEQUENCE GRANT USAGE ON SEQUENCE
auto_incrementing_pk_id_seq auto_incrementing_pk_id_seq
, items_id_seq , items_id_seq
, callcounter_count
TO postgrest_test_anonymous; TO postgrest_test_anonymous;
-- Privileges for non anonymous users -- Privileges for non anonymous users
+1 -1
View File
@@ -1,5 +1,5 @@
DROP ROLE IF EXISTS postgrest_test_authenticator, postgrest_test_anonymous, postgrest_test_default_role, postgrest_test_author; DROP ROLE IF EXISTS postgrest_test_authenticator, postgrest_test_anonymous, postgrest_test_default_role, postgrest_test_author;
CREATE ROLE postgrest_test_authenticator WITH login; CREATE ROLE postgrest_test_authenticator WITH login noinherit;
CREATE ROLE postgrest_test_anonymous; CREATE ROLE postgrest_test_anonymous;
CREATE ROLE postgrest_test_default_role; CREATE ROLE postgrest_test_default_role;
CREATE ROLE postgrest_test_author; CREATE ROLE postgrest_test_author;
+136 -11
View File
@@ -33,6 +33,13 @@ CREATE SCHEMA private;
CREATE SCHEMA test; CREATE SCHEMA test;
--
-- Name: تست; Type: SCHEMA; Schema: -; Owner: -
--
CREATE SCHEMA تست;
-- --
-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: - -- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: -
-- --
@@ -50,6 +57,23 @@ CREATE TYPE jwt_claims AS (
id text id text
); );
--
-- Name: big_jwt_claims; Type: TYPE; Schema: public; Owner: -
--
CREATE TYPE big_jwt_claims AS (
iss text,
sub text,
aud text,
exp integer,
nbf integer,
iat integer,
jti text,
role text,
"http://postgrest.com/foo" boolean
);
SET search_path = test, pg_catalog; SET search_path = test, pg_catalog;
@@ -145,6 +169,14 @@ CREATE FUNCTION anti_id(test.items) RETURNS bigint
AS $_$ SELECT $1.id * -1 $_$; AS $_$ SELECT $1.id * -1 $_$;
SET search_path = تست, pg_catalog;
CREATE TABLE موارد (
هویت bigint NOT NULL
);
SET search_path = test, pg_catalog; SET search_path = test, pg_catalog;
-- --
@@ -183,6 +215,43 @@ SELECT rolname::text, id::text FROM postgrest.auth WHERE id = id AND pass = pass
$$; $$;
--
-- Name: jwt_test(); Type: FUNCTION; Schema: test; Owner: -
--
CREATE FUNCTION jwt_test() RETURNS public.big_jwt_claims
LANGUAGE sql SECURITY DEFINER
AS $$
SELECT 'joe'::text as iss, 'fun'::text as sub, 'everyone'::text as aud,
1300819380 as exp, 1300819380 as nbf, 1300819380 as iat,
'foo'::text as jti, 'postgrest_test'::text as role,
true as "http://postgrest.com/foo";
$$;
--
-- Name: reveal_big_jwt(); Type: FUNCTION; Schema: test; Owner: -
--
CREATE FUNCTION reveal_big_jwt() RETURNS TABLE (
iss text, sub text, aud text, exp bigint,
nbf bigint, iat bigint, jti text, "http://postgrest.com/foo" boolean
)
LANGUAGE sql SECURITY DEFINER
AS $$
SELECT current_setting('postgrest.claims.iss') as iss,
current_setting('postgrest.claims.sub') as sub,
current_setting('postgrest.claims.aud') as aud,
current_setting('postgrest.claims.exp')::bigint as exp,
current_setting('postgrest.claims.nbf')::bigint as nbf,
current_setting('postgrest.claims.iat')::bigint as iat,
current_setting('postgrest.claims.jti') as jti,
-- role is not included in the claims list
current_setting('postgrest.claims.http://postgrest.com/foo')::boolean
as "http://postgrest.com/foo";
$$;
-- --
-- Name: problem(); Type: FUNCTION; Schema: test; Owner: - -- Name: problem(); Type: FUNCTION; Schema: test; Owner: -
-- --
@@ -207,6 +276,18 @@ CREATE FUNCTION sayhello(name text) RETURNS text
$_$; $_$;
--
-- Name: callcounter(); Type: FUNCTION; Schema: test; Owner: -
--
CREATE SEQUENCE callcounter_count START 1;
CREATE FUNCTION callcounter() RETURNS bigint
LANGUAGE sql
AS $_$
SELECT nextval('test.callcounter_count');
$_$;
-- --
-- Name: test_empty_rowset(); Type: FUNCTION; Schema: test; Owner: - -- Name: test_empty_rowset(); Type: FUNCTION; Schema: test; Owner: -
-- --
@@ -351,7 +432,8 @@ CREATE TABLE complex_items (
id bigint NOT NULL, id bigint NOT NULL,
name text, name text,
settings pg_catalog.json, settings pg_catalog.json,
arr_data integer[] arr_data integer[],
"field-with_sep" integer default 1 not null
); );
@@ -361,7 +443,7 @@ CREATE TABLE complex_items (
CREATE TABLE compound_pk ( CREATE TABLE compound_pk (
k1 integer NOT NULL, k1 integer NOT NULL,
k2 integer NOT NULL, k2 text NOT NULL,
extra integer extra integer
); );
@@ -376,6 +458,13 @@ CREATE TABLE empty_table (
); );
--
-- Name: private_table; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE private_table ();
-- --
-- Name: has_count_column; Type: VIEW; Schema: test; Owner: - -- Name: has_count_column; Type: VIEW; Schema: test; Owner: -
-- --
@@ -540,6 +629,15 @@ CREATE TABLE simple_pk (
extra character varying NOT NULL extra character varying NOT NULL
); );
--
-- Name: users_projects; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE users_projects (
user_id integer NOT NULL,
project_id integer NOT NULL
);
-- --
-- Name: tasks; Type: TABLE; Schema: test; Owner: - -- Name: tasks; Type: TABLE; Schema: test; Owner: -
@@ -551,6 +649,16 @@ CREATE TABLE tasks (
project_id integer project_id integer
); );
CREATE OR REPLACE VIEW filtered_tasks AS
SELECT id AS "myId", name, project_id AS "projectID"
FROM tasks
WHERE project_id IN (
SELECT id FROM projects WHERE id = 1
) AND
project_id IN (
SELECT project_id FROM users_projects WHERE user_id = 1
);
-- --
-- Name: tsearch; Type: TABLE; Schema: test; Owner: - -- Name: tsearch; Type: TABLE; Schema: test; Owner: -
@@ -571,15 +679,6 @@ CREATE TABLE users (
); );
--
-- Name: users_projects; Type: TABLE; Schema: test; Owner: -
--
CREATE TABLE users_projects (
user_id integer NOT NULL,
project_id integer NOT NULL
);
-- --
-- Name: users_tasks; Type: TABLE; Schema: test; Owner: - -- Name: users_tasks; Type: TABLE; Schema: test; Owner: -
@@ -591,6 +690,20 @@ CREATE TABLE users_tasks (
); );
CREATE TABLE "Escap3e;" (
"so6meIdColumn" integer primary key
);
CREATE TABLE "ghostBusters" (
"escapeId" integer not null references "Escap3e;"("so6meIdColumn")
);
CREATE TABLE "withUnique" (
uni text UNIQUE,
extra text
);
-- --
-- Name: id; Type: DEFAULT; Schema: test; Owner: - -- Name: id; Type: DEFAULT; Schema: test; Owner: -
-- --
@@ -896,6 +1009,18 @@ ALTER TABLE ONLY users_tasks
ADD CONSTRAINT users_tasks_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ADD CONSTRAINT users_tasks_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
create table addresses (
id int not null unique,
address text not null
);
create table orders (
id int not null unique,
name text not null,
billing_address_id int references addresses(id),
shipping_address_id int references addresses(id)
);
-- --
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --