Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b407034a8 | ||
|
|
c15f693dbb | ||
|
|
7e2cb5fe1c | ||
|
|
104a7ed4fa | ||
|
|
e72e4491d1 | ||
|
|
16fd3a57ff | ||
|
|
e364cbc3ff | ||
|
|
649a841ef4 | ||
|
|
15c95399f8 | ||
|
|
0d85152e42 | ||
|
|
6c009b71d3 | ||
|
|
0f66e99f78 | ||
|
|
6c275fcec2 | ||
|
|
16ac034aab | ||
|
|
b158b4924e | ||
|
|
98ada3f9ee | ||
|
|
090a62a2c8 | ||
|
|
654ac6e62e | ||
|
|
fec769c80e | ||
|
|
6a02d9efd5 | ||
|
|
b8ddc30252 | ||
|
|
47c4fbc8ef | ||
|
|
ae40641963 | ||
|
|
4df853eff9 | ||
|
|
41c1cf6e01 | ||
|
|
c3822da2d2 | ||
|
|
0fa072d04c | ||
|
|
6dba37be47 | ||
|
|
fce84397f4 | ||
|
|
4064a7b984 | ||
|
|
d78ef56314 | ||
|
|
37d3c851cf | ||
|
|
fbf345fc68 | ||
|
|
9dddd144a4 | ||
|
|
09b63eafad | ||
|
|
9233d90075 | ||
|
|
74e68408e6 | ||
|
|
726b2b9d18 | ||
|
|
0c0396d0d4 | ||
|
|
556b7129ca | ||
|
|
c6cd8145eb | ||
|
|
5d904dfd66 | ||
|
|
dcb3b6ed4d | ||
|
|
191601f129 | ||
|
|
211e3d4141 | ||
|
|
980680f3d6 | ||
|
|
50ae48295d | ||
|
|
9b7685e5d1 | ||
|
|
d17cfb0c5d | ||
|
|
9cd65a4033 | ||
|
|
5a166e8e80 | ||
|
|
06363ccc77 | ||
|
|
2f8ac24128 | ||
|
|
71bc666a8e | ||
|
|
12a8c682bd | ||
|
|
62ed9e2c4d | ||
|
|
449480bf01 | ||
|
|
7bf5b0106d | ||
|
|
fb5fce026d | ||
|
|
35da4809d4 | ||
|
|
a88a704bef | ||
|
|
539df21627 | ||
|
|
0314f4bdea | ||
|
|
ffd2859cba | ||
|
|
362ad7b7d0 | ||
|
|
25c2cd1f2d | ||
|
|
466090c79b | ||
|
|
b9777dec35 | ||
|
|
f49c6aa0f3 | ||
|
|
f3b79bcc40 | ||
|
|
af75988dd4 | ||
|
|
7278507c42 | ||
|
|
6f737056a2 | ||
|
|
298753d59e | ||
|
|
1d5a0e4316 | ||
|
|
35c5b190b4 | ||
|
|
df6cbc4afa | ||
|
|
d93d07795b | ||
|
|
c98450d1d5 | ||
|
|
b6e100096d | ||
|
|
338c4de8b4 | ||
|
|
b5d720e091 | ||
|
|
2b1ca9f5b7 | ||
|
|
1fef1991ef | ||
|
|
e4cf1ce207 | ||
|
|
63046baffd | ||
|
|
d102d954ac | ||
|
|
578ae6b5dd | ||
|
|
f7aa8b7ad5 | ||
|
|
7b9eb86fd0 | ||
|
|
b17fa09393 | ||
|
|
0e172b8030 | ||
|
|
3308012dcf | ||
|
|
376b67be9e | ||
|
|
72aa664b61 | ||
|
|
1372de6f43 | ||
|
|
430baf23b9 | ||
|
|
2f166088c8 | ||
|
|
744bbf7203 | ||
|
|
a253ff325d | ||
|
|
67668a02c2 | ||
|
|
cd27e9dcde | ||
|
|
da97bb84e3 | ||
|
|
0bf4146633 | ||
|
|
9c42a78cf3 | ||
|
|
9106f70cfa | ||
|
|
99d9984111 | ||
|
|
acd75a3997 | ||
|
|
46b3ce5631 | ||
|
|
e571cb9fcb | ||
|
|
c1764c4976 | ||
|
|
86e81c135e | ||
|
|
1a25bb501f | ||
|
|
a9ffde4d5f | ||
|
|
0220040341 | ||
|
|
11aeb4fbda | ||
|
|
d3d1fbe7e5 | ||
|
|
816d577f53 | ||
|
|
a7316aff01 | ||
|
|
e88a0577e5 | ||
|
|
c10bde8d65 | ||
|
|
0be5f5299f | ||
|
|
fd0b354cd0 | ||
|
|
733b2cc05b | ||
|
|
077bb5a434 | ||
|
|
d1ed884a8d | ||
|
|
103fa0550c | ||
|
|
563c5fa778 | ||
|
|
0e456543bf | ||
|
|
d34056b3b9 | ||
|
|
74ea4aba37 | ||
|
|
4b0c5cb36f | ||
|
|
cf4e157de7 | ||
|
|
b916ed907b | ||
|
|
e8426671c0 | ||
|
|
455f086880 | ||
|
|
42110643a3 | ||
|
|
e315dbc91e | ||
|
|
e272c2ed08 | ||
|
|
a875db2b82 | ||
|
|
02c6de4144 | ||
|
|
7563b5e2f4 | ||
|
|
5e3d9442af | ||
|
|
c0c1a260ba | ||
|
|
6ebd7fd2d7 | ||
|
|
24dd4e8626 | ||
|
|
dc727f900d | ||
|
|
0847a38691 | ||
|
|
7c83edc402 | ||
|
|
e76de196e0 | ||
|
|
b7331135a6 | ||
|
|
0940b2dccf | ||
|
|
4f53aef74f | ||
|
|
f4027cb5fd | ||
|
|
38afe71ec7 | ||
|
|
308c006a30 | ||
|
|
c7d863c998 | ||
|
|
44cdc97d71 | ||
|
|
a87dcd5553 | ||
|
|
592dd39222 | ||
|
|
45d0f85b0d | ||
|
|
b68fcd2522 | ||
|
|
abd81c998b | ||
|
|
6a2edb2844 | ||
|
|
5c38b4328b | ||
|
|
2cb04c1d5c | ||
|
|
b089e0a7dd | ||
|
|
a21464ddca | ||
|
|
900b9f1991 | ||
|
|
cf16f90fab | ||
|
|
36a6b10d0d | ||
|
|
2ac3ad9e37 | ||
|
|
9e6542680b | ||
|
|
7e41b620ff | ||
|
|
18e3c30ad8 | ||
|
|
0dbd0ece9a | ||
|
|
c13f0a369b | ||
|
|
cacc725e41 | ||
|
|
0dc33dbf9f | ||
|
|
d9205bd838 | ||
|
|
88aad4b1b6 | ||
|
|
5aadfba84b | ||
|
|
eae5857d0e | ||
|
|
c32d13c8f1 | ||
|
|
0401a8eb13 | ||
|
|
9a1a87ff8e | ||
|
|
16e3b16081 | ||
|
|
200e5a26cc | ||
|
|
b8bbaa7764 | ||
|
|
1470091f1c | ||
|
|
31738d745f | ||
|
|
f19d4300bc | ||
|
|
cd81e9346f | ||
|
|
01355f39a1 | ||
|
|
87298f580a | ||
|
|
3bfe64dd06 | ||
|
|
b9d3eedb9d | ||
|
|
bb4126bf3a | ||
|
|
2e440822cb | ||
|
|
13eed84f57 | ||
|
|
e5fed86965 | ||
|
|
3c5fab009b | ||
|
|
b858626e17 | ||
|
|
330cc91645 | ||
|
|
1037824e11 | ||
|
|
4cc08a11e7 | ||
|
|
358254639a | ||
|
|
43bc9bfa83 | ||
|
|
a779e9eb8b | ||
|
|
f67e195f76 | ||
|
|
508d722fb2 | ||
|
|
14d7364f4b | ||
|
|
bfbce27a65 | ||
|
|
00a23058c8 | ||
|
|
82c74ed21f | ||
|
|
5f0b4977da | ||
|
|
82214856b6 | ||
|
|
c09adb967a | ||
|
|
e5d420b2db | ||
|
|
ef021056c9 | ||
|
|
b7b082cd8e | ||
|
|
a02632f18c | ||
|
|
e43ad54dbf | ||
|
|
8af91e262c | ||
|
|
7b94fb608d | ||
|
|
cf176c4100 | ||
|
|
c61418635e | ||
|
|
dba827d1fd | ||
|
|
e315ad99b4 | ||
|
|
088df7e6be | ||
|
|
40eec0b2ff | ||
|
|
77bec52be7 | ||
|
|
155d1dee6b | ||
|
|
0548d65911 | ||
|
|
40a30d7b02 | ||
|
|
62af792add | ||
|
|
4cd2475bf2 | ||
|
|
fc4c792f9e | ||
|
|
c094e5a0fc | ||
|
|
9d0f3573c6 | ||
|
|
4496a95014 | ||
|
|
893b7a7126 | ||
|
|
3b23c4aa5b | ||
|
|
d466ea45ff | ||
|
|
7ba5363d25 | ||
|
|
f28b03f419 | ||
|
|
de772b9246 | ||
|
|
c28b26d949 | ||
|
|
c02dd4aa98 | ||
|
|
b0974a4e36 | ||
|
|
17acd134c7 | ||
|
|
d4a4bbf966 | ||
|
|
7b7babd1d1 | ||
|
|
072a6ce4c7 | ||
|
|
d5c1438c6e | ||
|
|
30e5032ade | ||
|
|
d7fe59f0b0 | ||
|
|
8a006f07a7 | ||
|
|
01ab540ffe | ||
|
|
de848f64fa | ||
|
|
52e689b830 | ||
|
|
ef3e2511fe | ||
|
|
6b4b763bc4 | ||
|
|
6b1c8b3e39 | ||
|
|
f3293cfac1 | ||
|
|
0dd8a498b2 | ||
|
|
f9b8e6879d | ||
|
|
e73a4c66bc | ||
|
|
fc3c885bb6 | ||
|
|
8b3d224b80 | ||
|
|
ce6e52e9ba | ||
|
|
4dd4eeb421 | ||
|
|
b50882db3a | ||
|
|
0058b5df99 | ||
|
|
f7926e9f28 | ||
|
|
f65557573c | ||
|
|
4dec445b82 | ||
|
|
ccb3eba9e3 | ||
|
|
56426b896a | ||
|
|
7702d38267 | ||
|
|
a044398552 | ||
|
|
17db68ae2d | ||
|
|
e53fb10483 | ||
|
|
33757e537b | ||
|
|
945ef61188 | ||
|
|
f990a519a5 | ||
|
|
2149bea8e3 | ||
|
|
7ce10dbcf1 | ||
|
|
31f46d5220 | ||
|
|
a0ef4eae4d | ||
|
|
aec11e34a7 | ||
|
|
95f26604ba | ||
|
|
c6d47eeb77 | ||
|
|
8500067e0f | ||
|
|
20573632d7 | ||
|
|
03468c83df | ||
|
|
4e3a04ea72 | ||
|
|
e6e324e8ff | ||
|
|
2175ae4d28 | ||
|
|
c887f2b3b4 | ||
|
|
1a3c54793d | ||
|
|
4679e2a514 | ||
|
|
e02dc2e92e | ||
|
|
536acec820 | ||
|
|
55da918240 | ||
|
|
f3d4d1fb60 | ||
|
|
dac31c4f2e | ||
|
|
677c73cfe5 | ||
|
|
b85fc37130 | ||
|
|
f634b7fe98 | ||
|
|
75ebd1bd24 | ||
|
|
cbb2ba7d42 | ||
|
|
616541aaee | ||
|
|
bbf8365cd2 | ||
|
|
a0b390e735 | ||
|
|
1f557a92a4 | ||
|
|
51f71eb53d | ||
|
|
ac73e8d77b | ||
|
|
8b13e7dd73 | ||
|
|
3be04d7f30 | ||
|
|
b9fd083c77 | ||
|
|
fec316b087 | ||
|
|
3844f3ee96 | ||
|
|
cb3977679d | ||
|
|
6122bc4108 | ||
|
|
abc30d5170 | ||
|
|
4b515c5df4 | ||
|
|
2d5210464a | ||
|
|
684b11badb | ||
|
|
7b92449343 | ||
|
|
72cd6c37bd | ||
|
|
d6102cc908 | ||
|
|
5faa80b172 | ||
|
|
74d76c690f | ||
|
|
301d9b6a86 | ||
|
|
b5e6a93b32 | ||
|
|
e7c711002a | ||
|
|
6dce40e454 | ||
|
|
9ba603660e | ||
|
|
5ee44c6c21 | ||
|
|
03bec64097 | ||
|
|
4fcc0fbc94 | ||
|
|
80ade96e9b | ||
|
|
860e437078 | ||
|
|
9a596c2500 | ||
|
|
0d9d74dc1c | ||
|
|
88d98d6d62 | ||
|
|
c93c4d8c30 | ||
|
|
021e78d962 | ||
|
|
2c1f9e7eac | ||
|
|
96533fa2fe | ||
|
|
92df3d3243 | ||
|
|
90d393f968 | ||
|
|
a0b4cd6bf9 | ||
|
|
943c38125f | ||
|
|
4b637bb54e | ||
|
|
576a38c407 | ||
|
|
5408ca26ad | ||
|
|
0161007390 | ||
|
|
7d343fdcca | ||
|
|
07f63090bb | ||
|
|
48f9ce114e | ||
|
|
3a3d4038cb | ||
|
|
04e1186f08 | ||
|
|
651daa00d7 | ||
|
|
72002f452e | ||
|
|
70ff55c8da | ||
|
|
4626b4480b | ||
|
|
a61778dba0 | ||
|
|
bdfb0a7680 | ||
|
|
437a592c65 | ||
|
|
ca2e140c30 | ||
|
|
7e07ee7bea | ||
|
|
7bf65a95d8 | ||
|
|
6534eeb1a2 | ||
|
|
eef4e3c647 | ||
|
|
1d848b8d72 | ||
|
|
b26fbaf4db | ||
|
|
f1fbc98040 | ||
|
|
48c041a99f | ||
|
|
30a844ff58 | ||
|
|
b46b3c7b9a | ||
|
|
079cf0aa54 | ||
|
|
0589ddcd90 | ||
|
|
f85975f5ad | ||
|
|
d1de6615f2 | ||
|
|
8f0ba7c41e | ||
|
|
7392d204bf | ||
|
|
ee82ad1864 | ||
|
|
6446fc962d | ||
|
|
88d4798d5e | ||
|
|
abe87f2f16 | ||
|
|
b08a402df8 | ||
|
|
f95b501232 | ||
|
|
66a34fccdf | ||
|
|
4544ce3255 | ||
|
|
4bc4a68051 | ||
|
|
1acd07cb61 | ||
|
|
2cf903cebe | ||
|
|
9ee2b74a5d | ||
|
|
6ef9000b31 | ||
|
|
bacc899fb4 | ||
|
|
7f0dc82d7a | ||
|
|
155e2d1c0c | ||
|
|
ccadcec6d5 | ||
|
|
02f707cd5c | ||
|
|
848c7da4d9 | ||
|
|
3cd01f9859 | ||
|
|
aed97d97f8 | ||
|
|
b3144aee15 | ||
|
|
87ceac7b00 | ||
|
|
a6512a2a69 | ||
|
|
6f7bf30a2d | ||
|
|
dad41cd3ed | ||
|
|
52849065cc | ||
|
|
728decb0b8 | ||
|
|
4cc0189ac9 | ||
|
|
8b4cb4be6e | ||
|
|
d95aeb15f9 | ||
|
|
6cc8ca707f | ||
|
|
63805d05a1 | ||
|
|
f66f9e7c92 | ||
|
|
213d86f3e3 | ||
|
|
969a95b29b | ||
|
|
ca3ab7babf | ||
|
|
0961524e70 | ||
|
|
027bbc6074 | ||
|
|
c1c44aae05 | ||
|
|
3bc1ad0133 | ||
|
|
ca5078b4f8 | ||
|
|
67a3194903 | ||
|
|
24b7a7d3e9 | ||
|
|
0e56bff5d6 | ||
|
|
b8b5fac03c | ||
|
|
e88fa7db31 | ||
|
|
598b2bfbda | ||
|
|
f4c73c7666 | ||
|
|
cbbb1871bb | ||
|
|
093874469e | ||
|
|
d21120962d | ||
|
|
27ad4641d0 | ||
|
|
65e7744858 | ||
|
|
c2e3dd716c | ||
|
|
1266bad2f8 | ||
|
|
71dab115c9 | ||
|
|
205aec20fe | ||
|
|
83fa3070fb | ||
|
|
a2870494c9 | ||
|
|
614b4dfbab | ||
|
|
cbc6685725 | ||
|
|
f17d47790a | ||
|
|
334f900e15 | ||
|
|
6804d91f8d | ||
|
|
54bf0b460f | ||
|
|
fa281fe59c | ||
|
|
048a8531f2 | ||
|
|
ed51502387 | ||
|
|
e7a47215f0 | ||
|
|
b04e2ec663 | ||
|
|
b651a45734 | ||
|
|
1a54135f0d | ||
|
|
c6f69956f6 | ||
|
|
c681ff2d9d | ||
|
|
5e22538684 | ||
|
|
d6d0ba524f | ||
|
|
cd53402ace |
+4
-1
@@ -6,6 +6,9 @@ cabal.sandbox.config
|
|||||||
hscope.out
|
hscope.out
|
||||||
codex.tags
|
codex.tags
|
||||||
.anvil
|
.anvil
|
||||||
.stack-work
|
.stack-work*
|
||||||
tags
|
tags
|
||||||
site
|
site
|
||||||
|
*~
|
||||||
|
*#*
|
||||||
|
.#*
|
||||||
|
|||||||
+120
@@ -3,6 +3,126 @@
|
|||||||
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.4.0.0] - 2017-01-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Allow test database to be on another host - @dsimunic
|
||||||
|
- `Prefer: params=single-object` to treat payload as single json argument in RPC - @dsimunic
|
||||||
|
- Ability to generate an OpenAPI spec - @mainx07, @hudayou, @ruslantalpa, @begriffs
|
||||||
|
- Ability to generate an OpenAPI spec behind a proxy - @hudayou
|
||||||
|
- Ability to set addresses to listen on - @hudayou
|
||||||
|
- Filtering, shaping and embedding with &select for the /rpc path - @ruslantalpa
|
||||||
|
- Output names of used-defined types (instead of 'USER-DEFINED') - @martingms
|
||||||
|
- Implement support for singular representation responses for POST/PATCH requests - @ehamberg
|
||||||
|
- Include RPC endpoints in OpenAPI output - @begriffs, @LogvinovLeon
|
||||||
|
- Custom request validation with `--pre-request` argument - @begriffs
|
||||||
|
- Ability to order by jsonb keys - @steve-chavez
|
||||||
|
- Ability to specify offset for a deeper level - @ruslantalpa
|
||||||
|
- Ability to use binary base64 encoded secrets - @TrevorBasinger
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Do not apply limit to parent items - @ruslantalpa
|
||||||
|
- Fix bug in relation detection when selecting parents two levels up by using the name of the FK - @ruslantalpa
|
||||||
|
- Customize content negotiation per route - @begriffs
|
||||||
|
- Allow using nulls order without explicit order direction - @steve-chavez
|
||||||
|
- Fatal error on postgres unsupported version, format supported version in error message - @steve-chavez
|
||||||
|
- Prevent database memory cosumption by prepared statements caches - @ruslantalpa
|
||||||
|
- Use specific columns in the RETURNING section - @ruslantalpa
|
||||||
|
- Fix columns alias for RETURNING - @steve-chavez
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Replace `Prefer: plurality=singular` with `Accept: application/vnd.pgrst.object` - @begriffs
|
||||||
|
- Standardize arrays in responses for `Prefer: return=representation` - @begriffs
|
||||||
|
- Calling unknown RPC gives 404, not 400 - @begriffs
|
||||||
|
- Use HTTP 400 for raise\_exception - @begriffs
|
||||||
|
- Remove non-OpenAPI schema description - @begriffs
|
||||||
|
- Use comma rather than semicolon to separate Prefer header values - @begriffs
|
||||||
|
- Omit total query count by default - @begriffs
|
||||||
|
- No more reserved `jwt_claims` return type - @begriffs
|
||||||
|
- HTTP 401 rather than 400 for expired JWT - @begriffs
|
||||||
|
- Remove default JWT secret - @begriffs
|
||||||
|
- Use GUC request.jwt.claim.foo rather than postgrest.claims.foo - @begriffs
|
||||||
|
- Use config file rather than command line arguments - @begriffs
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Miscalculation of time used for expiring tokens - @calebmer
|
||||||
|
- Remove bcrypt dependency to fix Windows build - @begriffs
|
||||||
|
- Detect relations event when authenticator does not have rights to intermediate tables - @ruslantalpa
|
||||||
|
- Ensure db connections released on sigint - @begriffs
|
||||||
|
- Fix #396 include records with missing parents - @ruslantalpa
|
||||||
|
- `pgFmtIdent` always quotes #388 - @calebmer
|
||||||
|
- Default schema, changed from `"1"` to `public` - @calebmer
|
||||||
|
- #414 revert to separate count query - @ruslantalpa
|
||||||
|
- Fix #399, allow inserting in tables with no select privileges using "Prefer: representation=minimal" - @ruslantalpa
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Allow order by computed columns - @diogob
|
||||||
|
- Set max rows in response with --max-rows - @begriffs
|
||||||
|
- Selection by column name (can detect if `_id` is not included) - @calebmer
|
||||||
|
|
||||||
## [0.3.0.1] - 2015-11-27
|
## [0.3.0.1] - 2015-11-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+9
-1
@@ -27,6 +27,14 @@ your contributions.
|
|||||||
* Provide steps to reproduce the issue, including your OS version and
|
* Provide steps to reproduce the issue, including your OS version and
|
||||||
the specific database schema that you are using.
|
the specific database schema that you are using.
|
||||||
|
|
||||||
|
* Please include SQL logs for issues involving runtime problems. To obtain logs first
|
||||||
|
[enable logging all statements](http://www.microhowto.info/howto/log_all_queries_to_a_postgresql_server.html),
|
||||||
|
then [find your logs](http://blog.endpoint.com/2014/11/dear-postgresql-where-are-my-logs.html).
|
||||||
|
|
||||||
|
* If your database schema has changed while the PostgREST server is running,
|
||||||
|
send the server a `SIGHUP` signal or restart it to ensure the schema cache
|
||||||
|
is not stale. This sometimes fixes apparent bugs.
|
||||||
|
|
||||||
## Code
|
## Code
|
||||||
|
|
||||||
### Haskell Conventions
|
### Haskell Conventions
|
||||||
@@ -40,7 +48,7 @@ your contributions.
|
|||||||
pull request.
|
pull request.
|
||||||
|
|
||||||
* For help building the Haskell code on your computer check out the [building from
|
* For help building the Haskell code on your computer check out the [building from
|
||||||
source](https://github.com/begriffs/postgrest/wiki/Building-from-source)
|
source](http://postgrest.com/install/server/#building-from-source)
|
||||||
wiki page.
|
wiki page.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
FROM debian:jessie
|
||||||
|
|
||||||
|
ENV POSTGREST_VERSION 0.4.0.0
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# PostgREST reads /etc/postgrest.conf so map the configuration
|
||||||
|
# file in when you run this container
|
||||||
|
CMD exec postgrest
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
@@ -5,51 +5,39 @@
|
|||||||
<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>
|
||||||
[](https://gitter.im/begriffs/postgrest)
|
[](https://gitter.im/begriffs/postgrest)
|
||||||
|
[](https://postgrest.com)
|
||||||
|
|
||||||
PostgREST serves a fully RESTful API from any existing PostgreSQL
|
PostgREST serves a fully RESTful API from any existing PostgreSQL
|
||||||
database. It provides a cleaner, more standards-compliant, faster
|
database. It provides a cleaner, more standards-compliant, faster
|
||||||
API than you are likely to write from scratch.
|
API than you are likely to write from scratch.
|
||||||
|
|
||||||
### Demo [postgrest.herokuapp.com](https://postgrest.herokuapp.com) | Read [Docs](http://postgrest.com/) | Watch [Video](http://begriffs.com/posts/2014-12-30-intro-to-postgrest.html)
|
Try making requests to the live [demo
|
||||||
|
server](https://postgrest.herokuapp.com) with an HTTP client such
|
||||||
|
as [postman](http://www.getpostman.com/). The structure of the demo
|
||||||
Try making requests to the live demo server with an HTTP client
|
database is defined by
|
||||||
such as [postman](http://www.getpostman.com/). The structure of the
|
|
||||||
demo database is defined by
|
|
||||||
[begriffs/postgrest-example](https://github.com/begriffs/postgrest-example).
|
[begriffs/postgrest-example](https://github.com/begriffs/postgrest-example).
|
||||||
You can use it as inspiration for test-driven server migrations in
|
You can use it as inspiration for test-driven server migrations in
|
||||||
your own projects.
|
your own projects.
|
||||||
|
|
||||||
Also try other tools in the PostgREST
|
Also try other tools in the PostgREST
|
||||||
[ecosystem](http://postgrest.com/install/ecosystem/) like the
|
[ecosystem](http://postgrest.com/en/v0.4/intro.html#ecosystem).
|
||||||
[ng-admin demo](http://marmelab.com/ng-admin-postgrest).
|
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
1. Download the binary ([latest release](https://github.com/begriffs/postgrest/releases/latest))
|
1. Download the binary ([latest release](https://github.com/begriffs/postgrest/releases/latest))
|
||||||
for your platform.
|
for your platform.
|
||||||
2. Invoke like so:
|
2. Invoke for help:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
postgrest postgres://postgres:foobar@localhost:5432/my_db \
|
postgrest --help
|
||||||
--port 3000 \
|
|
||||||
--schema public \
|
|
||||||
--anonymous postgres \
|
|
||||||
--pool 200
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For more information on valid connection strings see the
|
|
||||||
[PostgreSQL docs](http://www.postgresql.org/docs/9.4/static/libpq-connect.html#LIBPQ-CONNSTRING).
|
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
TLDR; subsecond response times for up to 2000 requests/sec on Heroku
|
TLDR; subsecond response times for up to 2000 requests/sec on Heroku
|
||||||
free tier. ([see the load
|
free tier. If you're used to servers written in interpreted languages
|
||||||
test](http://postgrest.com/admin/performance/#benchmarks))
|
(or named after precious gems), prepare to be pleasantly surprised by
|
||||||
|
PostgREST performance.
|
||||||
If you're used to servers written in interpreted languages (or named
|
|
||||||
after precious gems), prepare to be pleasantly surprised by PostgREST
|
|
||||||
performance.
|
|
||||||
|
|
||||||
Three factors contribute to the speed. First the server is written
|
Three factors contribute to the speed. First the server is written
|
||||||
in [Haskell](https://www.haskell.org/) using the
|
in [Haskell](https://www.haskell.org/) using the
|
||||||
@@ -68,32 +56,24 @@ Finally it uses the database efficiently with the
|
|||||||
[Hasql](https://nikita-volkov.github.io/hasql-benchmarks/) library
|
[Hasql](https://nikita-volkov.github.io/hasql-benchmarks/) library
|
||||||
by
|
by
|
||||||
|
|
||||||
* Reusing prepared statements
|
|
||||||
* Keeping a pool of db connections
|
* Keeping a pool of db connections
|
||||||
* Using the PostgreSQL binary protocol
|
* Using the PostgreSQL binary protocol
|
||||||
* Being stateless to allow horizontal scaling
|
* Being stateless to allow horizontal scaling
|
||||||
|
|
||||||
Ultimately the server (when load balanced) is constrained by database
|
|
||||||
performance. This may make it inappropriate for very large traffic
|
|
||||||
load. To learn more about scaling with Heroku and Amazon RDS see
|
|
||||||
the [performance guide](http://postgrest.com/admin/performance/).
|
|
||||||
Alternatively [CitusDB](https://www.citusdata.com/products/what-is-citusdb)
|
|
||||||
supports Postgres clustering for higher performance.
|
|
||||||
|
|
||||||
Other optimizations are possible, and some are outlined in the
|
Other optimizations are possible, and some are outlined in the
|
||||||
[Future Features](#future-features).
|
[Future Features](#future-features).
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
PostgREST handles authentication (via [JSON Web
|
PostgREST [handles
|
||||||
Tokens](http://postgrest.com/admin/security/#json-web-tokens))
|
authentication](http://postgrest.com/en/v0.4/auth.html) (via JSON Web
|
||||||
and delegates authorization to the role information defined in the
|
Tokens) and delegates authorization to the role information defined in
|
||||||
database. This ensures there is a single declarative source of truth
|
the database. This ensures there is a single declarative source of truth
|
||||||
for security. When dealing with the database the server assumes
|
for security. When dealing with the database the server assumes the
|
||||||
the identity of the currently authenticated user, and for the
|
identity of the currently authenticated user, and for the duration of
|
||||||
duration of the connection cannot do anything the user themselves
|
the connection cannot do anything the user themselves couldn't. Other
|
||||||
couldn't. Other forms of authentication can be built on top
|
forms of authentication can be built on top of the JWT primitive. See
|
||||||
of the JWT primitive. See the docs for more information.
|
the docs for more information.
|
||||||
|
|
||||||
PostgreSQL 9.5 supports true [row-level
|
PostgreSQL 9.5 supports true [row-level
|
||||||
security](http://www.postgresql.org/docs/9.5/static/ddl-rowsecurity.html).
|
security](http://www.postgresql.org/docs/9.5/static/ddl-rowsecurity.html).
|
||||||
@@ -104,35 +84,26 @@ are limited to certain templates using
|
|||||||
functions, the trigger workaround does not compromise row-level
|
functions, the trigger workaround does not compromise row-level
|
||||||
security.
|
security.
|
||||||
|
|
||||||
For example security patterns see the [security
|
|
||||||
guide](http://postgrest.com/admin/security/).
|
|
||||||
|
|
||||||
### Versioning
|
### Versioning
|
||||||
|
|
||||||
A robust long-lived API needs the freedom to exist in multiple
|
A robust long-lived API needs the freedom to exist in multiple
|
||||||
versions. PostgREST does versioning through database schemas. This
|
versions. PostgREST does versioning through database schemas. This
|
||||||
allows you to expose tables and views without making the app brittle.
|
allows you to expose tables and views without making the app brittle.
|
||||||
Underlying tables can be superseded and hidden behind public facing
|
Underlying tables can be superseded and hidden behind public facing
|
||||||
views. You run an instance of PostgREST per schema and route requests
|
views.
|
||||||
among them with a reverse proxy such as [nginx](http://nginx.org).
|
|
||||||
Learn more [here](http://postgrest.com/admin/versioning/).
|
|
||||||
|
|
||||||
### Self-documention
|
### Self-documentation
|
||||||
|
|
||||||
Rather than writing and maintaining separate docs yourself let the
|
PostgREST uses the [OpenAPI](https://openapis.org/) standard to
|
||||||
API explain its own affordances using HTTP. All PostgREST endpoints
|
generate up-to-date documentation for APIs. You can use a tool like
|
||||||
respond to the OPTIONS verb and explain what they support as well
|
[Swagger-UI](https://github.com/swagger-api/swagger-ui) to render
|
||||||
as the data format of their JSON payload. RAML support is an upcoming
|
interactive documentation for demo requests against the live API server.
|
||||||
feature.
|
|
||||||
|
|
||||||
The project uses HTTP itself to commicate other metadata. For
|
This project uses HTTP to communicate other metadata as well. 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).
|
||||||
|
|
||||||
There are more opportunities for self-documentation listed in [Future
|
|
||||||
Features](#future-features).
|
|
||||||
|
|
||||||
### Data Integrity
|
### Data Integrity
|
||||||
|
|
||||||
Rather than relying on an Object Relational Mapper and custom
|
Rather than relying on an Object Relational Mapper and custom
|
||||||
@@ -145,20 +116,7 @@ surprises, such as enforcing idempotent PUT requests, and
|
|||||||
|
|
||||||
See examples of [PostgreSQL
|
See examples of [PostgreSQL
|
||||||
constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)
|
constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)
|
||||||
and the [guide to routing](http://postgrest.com/api/reading/).
|
and the [API guide](http://postgrest.com/en/v0.4/api.html).
|
||||||
|
|
||||||
### Future Features
|
|
||||||
|
|
||||||
* Watching endpoint changes with sockets and Postgres pubsub
|
|
||||||
* Specifying per-view HTTP caching
|
|
||||||
* Inferring good default caching policies from the Postgres stats collector
|
|
||||||
* Generating mock data for test clients
|
|
||||||
* Maintaining separate connection pools per role to avoid "set/reset
|
|
||||||
role" performance penalty
|
|
||||||
* Describe more relationships with Link headers
|
|
||||||
* Depending on accept headers, render OPTIONS as [RAML](http://raml.org/) or a
|
|
||||||
relational diagram
|
|
||||||
* ... the other [issues](https://github.com/begriffs/postgrest/issues)
|
|
||||||
|
|
||||||
### Thanks
|
### Thanks
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"POSTGREST_VER": {
|
"POSTGREST_VER": {
|
||||||
"description": "Version of PostgREST to deploy",
|
"description": "Version of PostgREST to deploy",
|
||||||
"value": "0.3.0.1"
|
"value": "0.4.0.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
-13
@@ -1,16 +1,25 @@
|
|||||||
machine:
|
|
||||||
pre:
|
|
||||||
- createuser --superuser --no-password postgrest_test
|
|
||||||
- createdb -O postgrest_test -U ubuntu postgrest_test
|
|
||||||
ghc:
|
|
||||||
version: 7.10.1
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
cache_directories:
|
||||||
|
- "~/.stack"
|
||||||
|
- ".stack-work"
|
||||||
|
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
|
||||||
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 --fast
|
||||||
|
- stack build --fast --test --no-run-tests
|
||||||
|
|
||||||
test:
|
test:
|
||||||
post:
|
override:
|
||||||
- cabal exec hlint -- -X QuasiQuotes src/**/*.hs test/**/*.hs
|
- POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://ubuntu@localhost" postgrest_test) stack test --test-arguments "--skip \"returns a valid openapi\""
|
||||||
- cabal exec packdeps postgrest.cabal
|
- git ls-files | grep '\.l\?hs$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$@"
|
||||||
|
- 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
|
||||||
|
|||||||
Vendored
-52
@@ -1,52 +0,0 @@
|
|||||||
# TODO list to build debian "official" package
|
|
||||||
|
|
||||||
It feels for free to modify, fix or take some task or all.
|
|
||||||
|
|
||||||
## debian/control
|
|
||||||
|
|
||||||
* Fill description field
|
|
||||||
* Add Vcs-Browser
|
|
||||||
* Add Vcs-Git
|
|
||||||
* Add Uploaders field
|
|
||||||
|
|
||||||
## debian/copyright
|
|
||||||
|
|
||||||
* Add more contributers
|
|
||||||
|
|
||||||
## Dependencies packages
|
|
||||||
|
|
||||||
Some libraries dependencies aren't Debian package. Below is the list was built by [cabal-debian](https://wiki.debian.org/Haskell/CollabMaint/GettingStarted). These libraries are necessary to build Postgrest the right way.
|
|
||||||
|
|
||||||
* libghc-base64-string-dev
|
|
||||||
* libghc-base64-string-prof
|
|
||||||
* libghc-bcrypt-dev
|
|
||||||
* libghc-bcrypt-prof
|
|
||||||
* libghc-hasql-dev
|
|
||||||
* libghc-hasql-prof
|
|
||||||
* libghc-hasql-backend-dev
|
|
||||||
* libghc-hasql-backend-prof
|
|
||||||
* libghc-hasql-postgres-dev
|
|
||||||
* libghc-hasql-postgres-prof
|
|
||||||
* libghc-string-conversions-dev
|
|
||||||
* libghc-string-conversions-prof
|
|
||||||
* libghc-wai-cors-dev
|
|
||||||
* libghc-wai-cors-prof
|
|
||||||
* libghc-wai-middleware-static-dev
|
|
||||||
* libghc-wai-middleware-static-prof
|
|
||||||
* libghc-hasql-dev
|
|
||||||
* libghc-hasql-backend-dev
|
|
||||||
* libghc-hasql-postgres-dev
|
|
||||||
* libghc-heredoc-dev
|
|
||||||
* libghc-hspec-wai-dev
|
|
||||||
* libghc-hspec-wai-json-dev
|
|
||||||
* libghc-http-media-dev
|
|
||||||
* libghc-packdeps-dev
|
|
||||||
* libghc-base64-string-doc
|
|
||||||
* libghc-bcrypt-doc
|
|
||||||
* libghc-hasql-doc
|
|
||||||
* libghc-hasql-backend-doc
|
|
||||||
* libghc-hasql-postgres-doc
|
|
||||||
* libghc-string-conversions-doc
|
|
||||||
* libghc-wai-cors-doc
|
|
||||||
* libghc-wai-middleware-static-doc
|
|
||||||
|
|
||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
haskell-postgrest (0.2.11.1-1) UNRELEASED; urgency=low
|
|
||||||
|
|
||||||
* Initial release
|
|
||||||
|
|
||||||
-- Debian Haskell Group <pkg-haskell-maintainers@lists.alioth.debian.org> Wed, 30 Sep 2015 18:52:46 +0000
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
9
|
|
||||||
Vendored
-196
@@ -1,196 +0,0 @@
|
|||||||
Source: haskell-postgrest
|
|
||||||
Maintainer: Debian Haskell Group <pkg-haskell-maintainers@lists.alioth.debian.org>
|
|
||||||
Priority: extra
|
|
||||||
Section: haskell
|
|
||||||
Build-Depends: debhelper (>= 9),
|
|
||||||
haskell-devscripts (>= 0.8),
|
|
||||||
cdbs,
|
|
||||||
ghc,
|
|
||||||
ghc-prof,
|
|
||||||
libghc-http-dev,
|
|
||||||
libghc-http-prof,
|
|
||||||
libghc-missingh-dev,
|
|
||||||
libghc-missingh-prof,
|
|
||||||
libghc-ranged-sets-dev,
|
|
||||||
libghc-ranged-sets-prof,
|
|
||||||
libghc-aeson-dev,
|
|
||||||
libghc-aeson-prof,
|
|
||||||
libghc-base64-string-dev,
|
|
||||||
libghc-base64-string-prof,
|
|
||||||
libghc-bcrypt-dev,
|
|
||||||
libghc-bcrypt-prof,
|
|
||||||
libghc-blaze-builder-dev,
|
|
||||||
libghc-blaze-builder-prof,
|
|
||||||
libghc-case-insensitive-dev,
|
|
||||||
libghc-case-insensitive-prof,
|
|
||||||
libghc-cassava-dev,
|
|
||||||
libghc-cassava-prof,
|
|
||||||
libghc-convertible-dev,
|
|
||||||
libghc-convertible-prof,
|
|
||||||
libghc-hasql-dev,
|
|
||||||
libghc-hasql-prof,
|
|
||||||
libghc-hasql-backend-dev,
|
|
||||||
libghc-hasql-backend-prof,
|
|
||||||
libghc-hasql-postgres-dev,
|
|
||||||
libghc-hasql-postgres-prof,
|
|
||||||
libghc-http-types-dev,
|
|
||||||
libghc-http-types-prof,
|
|
||||||
libghc-jwt-dev,
|
|
||||||
libghc-jwt-prof,
|
|
||||||
libghc-mtl-dev,
|
|
||||||
libghc-mtl-prof,
|
|
||||||
libghc-network-dev,
|
|
||||||
libghc-network-prof,
|
|
||||||
libghc-network-uri-dev,
|
|
||||||
libghc-network-uri-prof,
|
|
||||||
libghc-optparse-applicative-dev,
|
|
||||||
libghc-optparse-applicative-prof,
|
|
||||||
libghc-regex-base-dev,
|
|
||||||
libghc-regex-base-prof,
|
|
||||||
libghc-regex-tdfa-dev,
|
|
||||||
libghc-regex-tdfa-prof,
|
|
||||||
libghc-resource-pool-dev,
|
|
||||||
libghc-resource-pool-prof,
|
|
||||||
libghc-scientific-dev,
|
|
||||||
libghc-scientific-prof,
|
|
||||||
libghc-split-dev,
|
|
||||||
libghc-split-prof,
|
|
||||||
libghc-string-conversions-dev,
|
|
||||||
libghc-string-conversions-prof,
|
|
||||||
libghc-stringsearch-dev,
|
|
||||||
libghc-stringsearch-prof,
|
|
||||||
libghc-text-dev,
|
|
||||||
libghc-text-prof,
|
|
||||||
libghc-unordered-containers-dev,
|
|
||||||
libghc-unordered-containers-prof,
|
|
||||||
libghc-vector-dev,
|
|
||||||
libghc-vector-prof,
|
|
||||||
libghc-wai-dev,
|
|
||||||
libghc-wai-prof,
|
|
||||||
libghc-wai-cors-dev,
|
|
||||||
libghc-wai-cors-prof,
|
|
||||||
libghc-wai-extra-dev,
|
|
||||||
libghc-wai-extra-prof,
|
|
||||||
libghc-wai-middleware-static-dev,
|
|
||||||
libghc-wai-middleware-static-prof,
|
|
||||||
libghc-warp-dev,
|
|
||||||
libghc-warp-prof,
|
|
||||||
libghc-aeson-dev (>= 0.8),
|
|
||||||
libghc-bcrypt-dev (>= 0.0.6),
|
|
||||||
libghc-hasql-dev (>= 0.7.3),
|
|
||||||
libghc-hasql-dev (<< 0.8),
|
|
||||||
libghc-hasql-backend-dev (>= 0.4.1),
|
|
||||||
libghc-hasql-backend-dev (<< 0.5),
|
|
||||||
libghc-hasql-postgres-dev (>= 0.10.4),
|
|
||||||
libghc-hasql-postgres-dev (<< 0.11),
|
|
||||||
libghc-network-dev (>= 2.6),
|
|
||||||
libghc-network-uri-dev (>= 2.6),
|
|
||||||
libghc-optparse-applicative-dev (>= 0.11),
|
|
||||||
libghc-optparse-applicative-dev (<< 0.12),
|
|
||||||
libghc-wai-dev (>= 3.0.1),
|
|
||||||
libghc-wai-middleware-static-dev (>= 0.6.0),
|
|
||||||
libghc-warp-dev (>= 3.0.2),
|
|
||||||
libghc-quickcheck2-dev,
|
|
||||||
libghc-heredoc-dev,
|
|
||||||
libghc-hlint-dev,
|
|
||||||
libghc-hspec-dev (>= 2.1),
|
|
||||||
libghc-hspec-dev (<< 2.2),
|
|
||||||
libghc-hspec-wai-dev,
|
|
||||||
libghc-hspec-wai-json-dev,
|
|
||||||
libghc-http-media-dev,
|
|
||||||
libghc-packdeps-dev,
|
|
||||||
Build-Depends-Indep: ghc-doc,
|
|
||||||
libghc-http-doc,
|
|
||||||
libghc-missingh-doc,
|
|
||||||
libghc-ranged-sets-doc,
|
|
||||||
libghc-aeson-doc,
|
|
||||||
libghc-base64-string-doc,
|
|
||||||
libghc-bcrypt-doc,
|
|
||||||
libghc-blaze-builder-doc,
|
|
||||||
libghc-case-insensitive-doc,
|
|
||||||
libghc-cassava-doc,
|
|
||||||
libghc-convertible-doc,
|
|
||||||
libghc-hasql-doc,
|
|
||||||
libghc-hasql-backend-doc,
|
|
||||||
libghc-hasql-postgres-doc,
|
|
||||||
libghc-http-types-doc,
|
|
||||||
libghc-jwt-doc,
|
|
||||||
libghc-mtl-doc,
|
|
||||||
libghc-network-doc,
|
|
||||||
libghc-network-uri-doc,
|
|
||||||
libghc-optparse-applicative-doc,
|
|
||||||
libghc-regex-base-doc,
|
|
||||||
libghc-regex-tdfa-doc,
|
|
||||||
libghc-resource-pool-doc,
|
|
||||||
libghc-scientific-doc,
|
|
||||||
libghc-split-doc,
|
|
||||||
libghc-string-conversions-doc,
|
|
||||||
libghc-stringsearch-doc,
|
|
||||||
libghc-text-doc,
|
|
||||||
libghc-unordered-containers-doc,
|
|
||||||
libghc-vector-doc,
|
|
||||||
libghc-wai-doc,
|
|
||||||
libghc-wai-cors-doc,
|
|
||||||
libghc-wai-extra-doc,
|
|
||||||
libghc-wai-middleware-static-doc,
|
|
||||||
libghc-warp-doc,
|
|
||||||
Standards-Version: 3.9.6
|
|
||||||
Homepage: https://github.com/begriffs/postgrest
|
|
||||||
Description: REST API for any Postgres database
|
|
||||||
Reads the schema of a PostgreSQL database and creates RESTful routes
|
|
||||||
for the tables and views, supporting all HTTP verbs that security
|
|
||||||
permits.
|
|
||||||
|
|
||||||
Package: libghc-postgrest-dev
|
|
||||||
Architecture: any
|
|
||||||
Depends: ${haskell:Depends},
|
|
||||||
${misc:Depends},
|
|
||||||
${shlibs:Depends},
|
|
||||||
Recommends: ${haskell:Recommends},
|
|
||||||
Suggests: ${haskell:Suggests},
|
|
||||||
Conflicts: ${haskell:Conflicts},
|
|
||||||
Provides: ${haskell:Provides},
|
|
||||||
Description: ${haskell:ShortDescription}${haskell:ShortBlurb}
|
|
||||||
${haskell:LongDescription}
|
|
||||||
.
|
|
||||||
${haskell:Blurb}
|
|
||||||
|
|
||||||
Package: libghc-postgrest-prof
|
|
||||||
Architecture: any
|
|
||||||
Depends: ${haskell:Depends},
|
|
||||||
${misc:Depends},
|
|
||||||
Recommends: ${haskell:Recommends},
|
|
||||||
Suggests: ${haskell:Suggests},
|
|
||||||
Conflicts: ${haskell:Conflicts},
|
|
||||||
Provides: ${haskell:Provides},
|
|
||||||
Description: ${haskell:ShortDescription}${haskell:ShortBlurb}
|
|
||||||
${haskell:LongDescription}
|
|
||||||
.
|
|
||||||
${haskell:Blurb}
|
|
||||||
|
|
||||||
Package: libghc-postgrest-doc
|
|
||||||
Architecture: all
|
|
||||||
Section: doc
|
|
||||||
Depends: ${haskell:Depends},
|
|
||||||
${misc:Depends},
|
|
||||||
Recommends: ${haskell:Recommends},
|
|
||||||
Suggests: ${haskell:Suggests},
|
|
||||||
Conflicts: ${haskell:Conflicts},
|
|
||||||
Description: ${haskell:ShortDescription}${haskell:ShortBlurb}
|
|
||||||
${haskell:LongDescription}
|
|
||||||
.
|
|
||||||
${haskell:Blurb}
|
|
||||||
|
|
||||||
Package: haskell-postgrest-utils
|
|
||||||
Architecture: any
|
|
||||||
Section: misc
|
|
||||||
Depends: ${haskell:Depends},
|
|
||||||
${misc:Depends},
|
|
||||||
Recommends: ${haskell:Recommends},
|
|
||||||
Suggests: ${haskell:Suggests},
|
|
||||||
Conflicts: ${haskell:Conflicts},
|
|
||||||
Provides: ${haskell:Provides},
|
|
||||||
Description: ${haskell:ShortDescription}${haskell:ShortBlurb}
|
|
||||||
${haskell:LongDescription}
|
|
||||||
.
|
|
||||||
${haskell:Blurb}
|
|
||||||
Vendored
-32
@@ -1,32 +0,0 @@
|
|||||||
Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
|
||||||
Upstream-Name: postgrest
|
|
||||||
Upstream-Contact: Joe Nelson <joe@begriffs.com>
|
|
||||||
Source: https://hackage.haskell.org/package/postgrest
|
|
||||||
|
|
||||||
Files: *
|
|
||||||
Copyright: 2014-2015 Joe Nelson <joe@begriffs.com>
|
|
||||||
License: Expat
|
|
||||||
|
|
||||||
Files: debian/*
|
|
||||||
Copyright: 2015 Fernando Ike <fike@midstorm.org>
|
|
||||||
License: Expat
|
|
||||||
|
|
||||||
License: Expat
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
"Software"), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
.
|
|
||||||
The above copyright notice and this permission notice shall be included
|
|
||||||
in all copies or substantial portions of the Software.
|
|
||||||
.
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
dist-ghc/build/postgrest/postgrest usr/bin
|
|
||||||
Vendored
-8
@@ -1,8 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
d=$(dirname $0)
|
|
||||||
if [ -f /etc/default/postgrest ]; then
|
|
||||||
. /etc/default/postgrest
|
|
||||||
fi
|
|
||||||
POSTGREST_LOG=${POSTGREST_LOG:-/var/log/postgrest/postgrest.log}
|
|
||||||
|
|
||||||
exec $d/postgrest "$@" >>$POSTGREST_LOG 2>&1 &
|
|
||||||
Vendored
-29
@@ -1,29 +0,0 @@
|
|||||||
# run service as
|
|
||||||
#POSTGREST_USER=postgrest
|
|
||||||
|
|
||||||
# log file
|
|
||||||
#POSTGREST_LOG=/var/log/postgrest/postgrest.log
|
|
||||||
|
|
||||||
# database host
|
|
||||||
#POSTGREST_DBHOST=localhost
|
|
||||||
|
|
||||||
# database host
|
|
||||||
#POSTGREST_DBPORT=5432
|
|
||||||
|
|
||||||
# database to use
|
|
||||||
#POSTGREST_DBNAME=app
|
|
||||||
|
|
||||||
# database user
|
|
||||||
#POSTGREST_DBUSER=authenticator
|
|
||||||
|
|
||||||
# database password
|
|
||||||
#POSTGREST_DBPASS=
|
|
||||||
|
|
||||||
# database pool
|
|
||||||
#POSTGREST_POOL=10
|
|
||||||
|
|
||||||
# jwt secret
|
|
||||||
#POSTGREST_JWT_SECRET=secret
|
|
||||||
|
|
||||||
# default schema
|
|
||||||
#POSTGREST_SCHEMA=public
|
|
||||||
Vendored
-99
@@ -1,99 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
### BEGIN INIT INFO
|
|
||||||
# Provides: postgrest
|
|
||||||
# Required-Start: $local_fs $network postgresql
|
|
||||||
# Required-Stop: $local_fs $network
|
|
||||||
# Default-Start: 2 3 4 5
|
|
||||||
# Default-Stop: 0 1 6
|
|
||||||
# Description: PostgreSQL REST API daemon
|
|
||||||
### END INIT INFO
|
|
||||||
|
|
||||||
. /lib/lsb/init-functions
|
|
||||||
if test -f /etc/default/postgrest; then
|
|
||||||
. /etc/default/postgrest
|
|
||||||
fi
|
|
||||||
POSTGREST=/usr/local/bin/postgrest
|
|
||||||
CONNECTION_STRING="postgres://"
|
|
||||||
POSTGREST_OPTS=""
|
|
||||||
POSTGREST_USER=${POSTGREST_USER:-postgrest}
|
|
||||||
POSTGREST_PORT=${POSTGREST_PORT:-3000}
|
|
||||||
POSTGREST_DBUSER=${POSTGREST_DBUSER:-authenticator}
|
|
||||||
#POSTGREST_DBPASS=${POSTGREST_DBPASS:-authenticator}
|
|
||||||
POSTGREST_DBHOST=${POSTGREST_DBHOST:-localhost}
|
|
||||||
POSTGREST_DBPORT=${POSTGREST_DBPORT:-5432}
|
|
||||||
POSTGREST_DBNAME=${POSTGREST_DBNAME:-app}
|
|
||||||
POSTGREST_DBPOOL=${POSTGREST_DBPOOL:-10}
|
|
||||||
POSTGREST_ANON=${POSTGREST_ANON:-anonymous}
|
|
||||||
POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET:-secret}
|
|
||||||
POSTGREST_SCHEMA=${POSTGREST_SCHEMA:-public}
|
|
||||||
|
|
||||||
CONNECTION_STRING="$CONNECTION_STRING$POSTGREST_DBUSER"
|
|
||||||
if [ -n "$POSTGREST_DBPASS" ]; then
|
|
||||||
CONNECTION_STRING="$CONNECTION_STRING:$POSTGREST_DBPASS"
|
|
||||||
fi
|
|
||||||
CONNECTION_STRING="$CONNECTION_STRING@$POSTGREST_DBHOST:$POSTGREST_DBPORT/$POSTGREST_DBNAME"
|
|
||||||
|
|
||||||
if [ -n "$POSTGREST_PORT" ]; then
|
|
||||||
POSTGREST_OPTS="$POSTGREST_OPTS --port $POSTGREST_PORT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$POSTGREST_POOL" ]; then
|
|
||||||
POSTGREST_OPTS="$POSTGREST_OPTS --pool $POSTGREST_POOL"
|
|
||||||
fi
|
|
||||||
if [ -n "$POSTGREST_JWT_SECRET" ]; then
|
|
||||||
#export POSTGREST_JWT_SECRET="$POSTGREST_JWT_SECRET"
|
|
||||||
POSTGREST_OPTS="$POSTGREST_OPTS --jwt-secret $POSTGREST_JWT_SECRET"
|
|
||||||
fi
|
|
||||||
if [ -n "$POSTGREST_SCHEMA" ]; then
|
|
||||||
POSTGREST_OPTS="$POSTGREST_OPTS --schema $POSTGREST_SCHEMA"
|
|
||||||
fi
|
|
||||||
if [ -n "$POSTGREST_ANON" ]; then
|
|
||||||
POSTGREST_OPTS="$POSTGREST_OPTS --anonymous $POSTGREST_ANON"
|
|
||||||
fi
|
|
||||||
|
|
||||||
#export CONNECTION_STRING="$CONNECTION_STRING"
|
|
||||||
|
|
||||||
START_PARAMS="$CONNECTION_STRING $POSTGREST_OPTS"
|
|
||||||
|
|
||||||
start()
|
|
||||||
{
|
|
||||||
log_daemon_msg "Starting PostgreSQL REST API daemon" "postgrest" || true
|
|
||||||
if start-stop-daemon --start --quiet --oknodo --chuid ${POSTGREST_USER} --startas /usr/local/bin/postgrest-wrapper --exec $POSTGREST -- $START_PARAMS; then
|
|
||||||
log_end_msg 0 || true
|
|
||||||
else
|
|
||||||
log_end_msg 1 || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
stop()
|
|
||||||
{
|
|
||||||
log_daemon_msg "Stopping PostgreSQL REST API daemon" "postgrest" || true
|
|
||||||
if start-stop-daemon --stop --quiet --oknodo --exec $POSTGREST; then
|
|
||||||
log_end_msg 0 || true
|
|
||||||
else
|
|
||||||
log_end_msg 1 || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
status()
|
|
||||||
{
|
|
||||||
status_of_proc $POSTGREST postgrest && exit 0 || exit $?
|
|
||||||
}
|
|
||||||
|
|
||||||
case "$1" in
|
|
||||||
start)
|
|
||||||
start
|
|
||||||
;;
|
|
||||||
stop)
|
|
||||||
stop
|
|
||||||
;;
|
|
||||||
restart)
|
|
||||||
stop
|
|
||||||
start
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
status
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $0 {start|stop|restart|status}"
|
|
||||||
esac
|
|
||||||
Vendored
-10
@@ -1,10 +0,0 @@
|
|||||||
#!/usr/bin/make -f
|
|
||||||
|
|
||||||
DEB_ENABLE_TESTS = yes
|
|
||||||
DEB_CABAL_PACKAGE = postgrest
|
|
||||||
DEB_DEFAULT_COMPILER = ghc
|
|
||||||
|
|
||||||
include /usr/share/cdbs/1/rules/debhelper.mk
|
|
||||||
include /usr/share/cdbs/1/class/hlibrary.mk
|
|
||||||
|
|
||||||
build/haskell-postgrest-utils:: build-ghc-stamp
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
3.0 (quilt)
|
|
||||||
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
version=3
|
|
||||||
http://hackage.haskell.org/package/postgrest/distro-monitor .*-([0-9\.]+)\.(?:zip|tgz|tbz|txz|(?:tar\.(?:gz|bz2|xz)))
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
postgrest.com
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
## Deployment
|
|
||||||
|
|
||||||
### Heroku
|
|
||||||
|
|
||||||
#### Getting Started
|
|
||||||
|
|
||||||
#### Using Amazon RDS
|
|
||||||
|
|
||||||
### Debian
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
## Data Migration
|
|
||||||
|
|
||||||
### Sqitch
|
|
||||||
|
|
||||||
### Test-Driven Migrations
|
|
||||||
|
|
||||||
#### Structural Tests
|
|
||||||
|
|
||||||
#### Value Tests with pgTAP
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
## Performance
|
|
||||||
|
|
||||||
### Benchmarks
|
|
||||||
|
|
||||||
### Caching
|
|
||||||
|
|
||||||
### Quality of Service
|
|
||||||
|
|
||||||
### Tips
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
## Security
|
|
||||||
|
|
||||||
### SSL
|
|
||||||
|
|
||||||
### Database Roles
|
|
||||||
|
|
||||||
### JSON Web Tokens
|
|
||||||
|
|
||||||
#### Issuing via sql procedures
|
|
||||||
|
|
||||||
### Row-Level Security
|
|
||||||
|
|
||||||
#### Simulated - PostgreSQL <9.5
|
|
||||||
|
|
||||||
#### Real - PostgreSQL >=9.5
|
|
||||||
|
|
||||||
### Building Auth on top of JWT
|
|
||||||
|
|
||||||
#### Basic Auth
|
|
||||||
|
|
||||||
#### Github Sign-in
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
## API Versioning
|
|
||||||
|
|
||||||
### Schema Search Path
|
|
||||||
|
|
||||||
### Changing a Resource
|
|
||||||
|
|
||||||
### Removing a Resource
|
|
||||||
|
|
||||||
### Avoiding DB and Client Coupling
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
## Requesting Information
|
|
||||||
|
|
||||||
### Tables and Views
|
|
||||||
|
|
||||||
* ✅ Cacheable, prefetchable
|
|
||||||
* ✅ Idempotent
|
|
||||||
|
|
||||||
The list of accessible tables and views is provided at
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /
|
|
||||||
```
|
|
||||||
|
|
||||||
Every view and table accessible by the active db role is exposed
|
|
||||||
in a one-level deep route. For instance the full contents of a table
|
|
||||||
`people` is returned at
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /people
|
|
||||||
```
|
|
||||||
|
|
||||||
There are no `deeply/nested/routes`. Each route provides `OPTIONS`,
|
|
||||||
`GET`, `POST`, `PUT`, `PATCH`, and `DELETE` verbs depending entirely
|
|
||||||
on database permissions.
|
|
||||||
|
|
||||||
<div class="admonition note">
|
|
||||||
<p class="admonition-title">Design Consideration</p>
|
|
||||||
|
|
||||||
<p>Why not provide nested routes? Many APIs allow nesting to
|
|
||||||
retrieve related information, such as <code>/films/1/director</code>.
|
|
||||||
We offer a more flexible mechanism instead to embed related
|
|
||||||
information, including many-to-many relationships. This is covered
|
|
||||||
in the section about Embedding.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Stored Procedures
|
|
||||||
|
|
||||||
* ❌ Cannot necessarily be cached or prefetched
|
|
||||||
* ❌ Not necessarily idempotent
|
|
||||||
|
|
||||||
Every stored procedure is accessible under the `/rpc` prefix. The
|
|
||||||
API endpoint supports only POST which executes the function.
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
POST /rpc/proc_name
|
|
||||||
```
|
|
||||||
|
|
||||||
PostgREST supports calling procedures with [named
|
|
||||||
arguments](http://www.postgresql.org/docs/9.4/static/sql-syntax-calling-funcs.html#SQL-SYNTAX-CALLING-FUNCS-NAMED).
|
|
||||||
To do so include a JSON object in the request payload and each
|
|
||||||
key/value of the object will become an argument.
|
|
||||||
|
|
||||||
<div class="admonition note">
|
|
||||||
<p class="admonition-title">Design Consideration</p>
|
|
||||||
|
|
||||||
<p>Why the /rpc prefix? One reason is to avoid name collisions
|
|
||||||
between views and procedures. It also helps emphasize to API
|
|
||||||
consumers that these functions are not normal restful things.
|
|
||||||
The functions can have arbitrary and surprising behavior, not
|
|
||||||
the standard "post creates a resource" thing that users expect
|
|
||||||
from the other routes.</p>
|
|
||||||
|
|
||||||
<p>We considered allowing GET requests for functions that are
|
|
||||||
marked non-volatile but could not reconcile how to pass in
|
|
||||||
parameters. Query string arguments are reserved for shaping/filtering
|
|
||||||
the output, not providing input.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Filtering
|
|
||||||
|
|
||||||
#### Filtering Rows
|
|
||||||
|
|
||||||
You can filter result rows by adding conditions on columns, each
|
|
||||||
condition a query string parameter. For instance, to return people
|
|
||||||
aged under 13 years old:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /people?age=lt.13
|
|
||||||
```
|
|
||||||
|
|
||||||
Adding multiple parameters conjoins the conditions:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /people?age=gte.18&student=is.true
|
|
||||||
```
|
|
||||||
|
|
||||||
These operators are available:
|
|
||||||
|
|
||||||
abbreviation | meaning
|
|
||||||
------------ | -------
|
|
||||||
eq | equals
|
|
||||||
gt | greater than
|
|
||||||
lt | less than
|
|
||||||
gte | greater than or equal
|
|
||||||
lte | less than or equal
|
|
||||||
like | LIKE 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`
|
|
||||||
not | negates another operator, see below
|
|
||||||
|
|
||||||
To negate any operator, prefix it with `not` like `?a=not.eq.2`.
|
|
||||||
|
|
||||||
For more complicated filters (such as those involving condition 1
|
|
||||||
*OR* condition 2) you will have to create a new view in the database.
|
|
||||||
|
|
||||||
Filters may be applied to [computed
|
|
||||||
columns](http://www.postgresql.org/docs/current/interactive/xfunc-sql.html#XFUNC-SQL-COMPOSITE-FUNCTIONS)
|
|
||||||
as well as actual table/view columns, even though the computed
|
|
||||||
columns will not appear in the output.
|
|
||||||
|
|
||||||
#### Filtering Columns
|
|
||||||
|
|
||||||
You can customize which columns are returned by using the `select`
|
|
||||||
parameter:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /people?select=age,height,weight
|
|
||||||
```
|
|
||||||
|
|
||||||
To cast the column types, add a double colon
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /people?select=age::text,height,weight
|
|
||||||
```
|
|
||||||
|
|
||||||
Not all type coercions are possible, and you will get an error
|
|
||||||
describing any problems from selection or type casting.
|
|
||||||
|
|
||||||
The `select` keyword is reserved. You thus cannot filter rows based
|
|
||||||
on a column named select. Then again it is a reserved SQL keyword
|
|
||||||
too, hence an unlikely column name.
|
|
||||||
|
|
||||||
#### Inside JSONB
|
|
||||||
|
|
||||||
PostgreSQL >=9.4.2 supports native JSON columns and can even index
|
|
||||||
them by internal keys using the `jsonb` column type. PostgREST
|
|
||||||
allows you to filter results by internal JSON object values. Use
|
|
||||||
the single- and double-arrows to path into and obtain values, e.g.
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /stuff?json_col->a->>b=eq.2
|
|
||||||
```
|
|
||||||
|
|
||||||
This query finds rows in `stuff` where `json_col->'a'->>'b'` is
|
|
||||||
equal to 2 (or "2" -- it coerces as needed). The final arrow must
|
|
||||||
be the double kind, `->>`, or else PostgREST will not attempt to
|
|
||||||
look inside the JSON.
|
|
||||||
|
|
||||||
### Ordering
|
|
||||||
|
|
||||||
The reserved word `order` reorders the response rows. It uses a
|
|
||||||
comma-separated list of columns and directions:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /people?order=age.desc,height.asc
|
|
||||||
```
|
|
||||||
|
|
||||||
If no direction is specified it defaults to descending order:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /people?order=age
|
|
||||||
```
|
|
||||||
|
|
||||||
If you care where nulls are sorted, add `nullsfirst` or `nullslast`:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /people?order=age.nullsfirst
|
|
||||||
```
|
|
||||||
|
|
||||||
### Limiting and Pagination
|
|
||||||
|
|
||||||
#### Pagination by Limit-Offset
|
|
||||||
|
|
||||||
PostgREST uses HTTP range headers for limiting and describing the
|
|
||||||
size of results. Every response contains the current range and total
|
|
||||||
results:
|
|
||||||
|
|
||||||
```
|
|
||||||
Range-Unit: items
|
|
||||||
Content-Range → 0-14/15
|
|
||||||
```
|
|
||||||
|
|
||||||
This means items zero through fourteen are returned out of a total
|
|
||||||
of fifteen -- i.e. all of them. This information is available in
|
|
||||||
every response and can help you render pagination controls on the
|
|
||||||
client. This is a RFC7233-compliant solution that keeps the response
|
|
||||||
JSON cleaner.
|
|
||||||
|
|
||||||
The client can set the limit and offset of a request by setting the
|
|
||||||
`Range` header. Translate the limit and offset into a range. To
|
|
||||||
request the first five elements, include these request headers:
|
|
||||||
|
|
||||||
```
|
|
||||||
Range-Unit: items
|
|
||||||
Range: 0-4
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also use open-ended ranges for an offset with no limit:
|
|
||||||
`Range: 10-`.
|
|
||||||
|
|
||||||
#### Suppressing Counts
|
|
||||||
|
|
||||||
Sometimes knowing the total row count of a query is unnecessary and
|
|
||||||
only adds extra cost to the database query. So you can skip the
|
|
||||||
count total using a ```Prefer``` header as:
|
|
||||||
|
|
||||||
```
|
|
||||||
Prefer: count=none
|
|
||||||
```
|
|
||||||
|
|
||||||
So the PostgREST response will be something like:
|
|
||||||
|
|
||||||
```
|
|
||||||
Range-Unit: items
|
|
||||||
Content-Range → 0-14/*
|
|
||||||
```
|
|
||||||
|
|
||||||
### Embedding Foreign Entities
|
|
||||||
|
|
||||||
Suppose you have a `projects` table which references `clients` through
|
|
||||||
a foreign key called `client_id`. When listing projects through the
|
|
||||||
API you can have it embed the client within each project response.
|
|
||||||
For example,
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /projects?id=eq.1&select=id, name, clients(*)
|
|
||||||
```
|
|
||||||
|
|
||||||
Notice this is the same `select` keyword which is used to choose
|
|
||||||
which columns to include. When a column name is followed by parentheses
|
|
||||||
that means to fetch the entire record and nest it. You include a
|
|
||||||
list of columns inside the parens, or asterisk to request all
|
|
||||||
columns.
|
|
||||||
|
|
||||||
The embedding works for 1-N, N-1, and N-N relationships. That means
|
|
||||||
you could also ask for a client and all their projects:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET /clients?id=eq.42&select=id, name, projects(*)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Response Format
|
|
||||||
|
|
||||||
Query responses default to JSON but you can get them in CSV as well. Just make your request with the header
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
Accept: text/csv
|
|
||||||
```
|
|
||||||
|
|
||||||
### Singular vs Plural
|
|
||||||
|
|
||||||
Many APIs distinguish plural and singular resources, e.g.`/stories`
|
|
||||||
vs `/stories/1`. Why do we use `/stories?id=eq.1`? It is because a
|
|
||||||
single resource is for us a row determined by a primary key, and
|
|
||||||
primary keys can be *compound* (meaning defined across more than
|
|
||||||
one column). The common urls come from a degenerate case of simple
|
|
||||||
(and overwhelmingly numeric) primary keys often introduced automatically
|
|
||||||
be Object Relational Mapping.
|
|
||||||
|
|
||||||
For consistency's sake all these endpoints return a JSON array,
|
|
||||||
`/stories`, `/stories?genre=eq.mystery`, `/stories?id=eq.1`. They
|
|
||||||
are all filtering a bigger array. However you might want the
|
|
||||||
last one to return a single JSON object, not an array with one
|
|
||||||
element. There is currently an open issue to enable this.
|
|
||||||
|
|
||||||
### Data Schema
|
|
||||||
|
|
||||||
As well as issuing a `GET /` to obtain a list of the tables, views,
|
|
||||||
and stored procedures available, you can get more information about
|
|
||||||
any particular endpoint.
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
OPTIONS /my_view
|
|
||||||
```
|
|
||||||
|
|
||||||
This will include the row names, their types, primary key
|
|
||||||
information, and foreign keys for the given table or view.
|
|
||||||
|
|
||||||
<div class="admonition danger">
|
|
||||||
<p class="admonition-title">Deprecation Warning</p>
|
|
||||||
|
|
||||||
<p>Although we currently use the OPTIONS verb for this, some
|
|
||||||
people <a
|
|
||||||
href="https://www.mnot.net/blog/2012/10/29/NO_OPTIONS">argue</a> that
|
|
||||||
this is inappropriate. We are considering a <code>describedby</code>
|
|
||||||
header link instead.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### CORS
|
|
||||||
|
|
||||||
PostgREST sets highly permissive cross origin resource sharing. It
|
|
||||||
accepts Ajax requests from any domain.
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
## Updating Data
|
|
||||||
|
|
||||||
### Record Creation
|
|
||||||
|
|
||||||
* ❌ Cannot be cached or prefetched
|
|
||||||
* ❌ Not idempotent
|
|
||||||
|
|
||||||
To create a row in a database table post a JSON object whose keys
|
|
||||||
are the names of the columns you would like to create. Missing keys
|
|
||||||
will be set to default values when applicable.
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
POST /table_name
|
|
||||||
{ "col1": "value1", "col2": "value2" }
|
|
||||||
```
|
|
||||||
|
|
||||||
The response will include a `Location` header describing where to
|
|
||||||
find the new object. If you would like to get the full object back
|
|
||||||
in the response to your request, include the header `Prefer:
|
|
||||||
return=representation`. That way you won't have to make another
|
|
||||||
HTTP call to discover properties that may have been filled in on
|
|
||||||
the server side.
|
|
||||||
|
|
||||||
### Bulk Insertion
|
|
||||||
|
|
||||||
* ❌ Cannot be cached or prefetched
|
|
||||||
* ❌ Not idempotent
|
|
||||||
|
|
||||||
While regular insertion uses JSON to encode the value, bulk insertion
|
|
||||||
uses CSV. Simply post to a table route with `Content-Type: text/csv`
|
|
||||||
and include the names of the columns as the first row. For instance
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
POST /people
|
|
||||||
name,age,height
|
|
||||||
J Doe,62,70
|
|
||||||
Jonas,10,55
|
|
||||||
```
|
|
||||||
|
|
||||||
An empty field (`,,`) is coerced to an empty string and the reserved
|
|
||||||
word `NULL` is mapped to the SQL null value. Note that there should
|
|
||||||
be no spaces between the column names and commas.
|
|
||||||
|
|
||||||
The server sends a multipart response for bulk insertions. Each part
|
|
||||||
contains a Location header with URL of each created resource.
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
Content-Type: application/json
|
|
||||||
Location: /festival?name=eq.Venice%20Film%20Festival
|
|
||||||
|
|
||||||
|
|
||||||
--postgrest_boundary
|
|
||||||
Content-Type: application/json
|
|
||||||
Location: /festival?name=eq.Cannes%20Film%20Festival
|
|
||||||
```
|
|
||||||
|
|
||||||
### Upsertion
|
|
||||||
|
|
||||||
* ❌ Cannot be cached or prefetched
|
|
||||||
* ✅ Idempotent
|
|
||||||
|
|
||||||
To insert or update a single row use the `PUT` verb on a properly
|
|
||||||
filtered table url:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
PUT /table_name?primary_key=eq.foo
|
|
||||||
{ "col1": "value1", "col2": "value2" }
|
|
||||||
```
|
|
||||||
|
|
||||||
The request must satisfy two things. First all columns must be
|
|
||||||
specified (because a default value might be a changing value which
|
|
||||||
would violate idempotence). Second the URL must match the URL you
|
|
||||||
would use to get the value of the resource. This means that all
|
|
||||||
primary key columns must be included in the filter (there are more
|
|
||||||
than one when the primary key is compound).
|
|
||||||
|
|
||||||
If you would like to get the full object back in the response to
|
|
||||||
your request, include the header `Prefer: return=representation`.
|
|
||||||
It will of match exactly the object you sent though.
|
|
||||||
|
|
||||||
### Bulk Updates
|
|
||||||
|
|
||||||
* ❌ Cannot be cached or prefetched
|
|
||||||
* ❌ Not idempotent
|
|
||||||
|
|
||||||
To change parts of a resource or resources use the `PATCH` verb.
|
|
||||||
For instance, here is how to mark all young people as children.
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
PATCH /people?age=lt.13
|
|
||||||
{
|
|
||||||
"person_type": "child"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This affects any rows matched by the url param filters, overwrites
|
|
||||||
any fields specified in in the payload JSON and leaves the other
|
|
||||||
fields unaffected. Note that although the payload is not in the
|
|
||||||
JSON patch format specified by
|
|
||||||
[RFC6902](https://tools.ietf.org/html/rfc6902), HTTP does not specify
|
|
||||||
which patch format to use. Our format is more pleasant, meant for
|
|
||||||
basic field replacements, and not at all "incorrect."
|
|
||||||
|
|
||||||
### Deletion
|
|
||||||
|
|
||||||
* ❌ Cannot be cached or prefetched
|
|
||||||
* ✅ Idempotent
|
|
||||||
|
|
||||||
Simply use the `DELETE` verb. All recors that match your filter
|
|
||||||
will be removed. For instance deleting inactive users:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
DELETE /user?active=eq.false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Protecting Dangerous Actions
|
|
||||||
|
|
||||||
Notice that it is very easy to delete or update many records at
|
|
||||||
once. In fact forgetting a filter will affect an entire table!
|
|
||||||
|
|
||||||
|
|
||||||
<div class="admonition warning">
|
|
||||||
<p class="admonition-title">Invitation to Contribute</p>
|
|
||||||
|
|
||||||
<p>We would like to investigate nginx rules to guard dangerous
|
|
||||||
actions, perhaps requiring a confirmation header or query param
|
|
||||||
to perform the action.</p>
|
|
||||||
|
|
||||||
<p>You're invited to research this option and contribute to
|
|
||||||
this documentation.</p>
|
|
||||||
</div>
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
## Getting Started
|
|
||||||
|
|
||||||
### Your First (simple) API
|
|
||||||
|
|
||||||
Let's start with the simplest thing possible. We will expose some tables directly for reading and writing by anyone.
|
|
||||||
|
|
||||||
Start by making a database
|
|
||||||
|
|
||||||
```sh
|
|
||||||
createdb demo1
|
|
||||||
```
|
|
||||||
|
|
||||||
We'll set it up with a film example (courtesy of [Jonathan Harrington](http://blog.jonharrington.org/postgrest-introduction/)). Copy the following into your clipboard:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE TABLE director
|
|
||||||
(
|
|
||||||
name text NOT NULL PRIMARY KEY
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE film
|
|
||||||
(
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
title text NOT NULL,
|
|
||||||
year date NOT NULL,
|
|
||||||
director text,
|
|
||||||
rating real NOT NULL DEFAULT 0,
|
|
||||||
language text NOT NULL,
|
|
||||||
CONSTRAINT film_director_fkey FOREIGN KEY (director)
|
|
||||||
REFERENCES director (name) MATCH SIMPLE
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE festival
|
|
||||||
(
|
|
||||||
name text NOT NULL PRIMARY KEY
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE competition
|
|
||||||
(
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
name text NOT NULL,
|
|
||||||
festival text NOT NULL,
|
|
||||||
year date NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT comp_festival_fkey FOREIGN KEY (festival)
|
|
||||||
REFERENCES festival (name) MATCH SIMPLE
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE film_nomination
|
|
||||||
(
|
|
||||||
id serial PRIMARY KEY,
|
|
||||||
competition integer NOT NULL,
|
|
||||||
film integer NOT NULL,
|
|
||||||
won boolean NOT NULL DEFAULT true,
|
|
||||||
|
|
||||||
CONSTRAINT nomination_competition_fkey FOREIGN KEY (competition)
|
|
||||||
REFERENCES competition (id) MATCH SIMPLE
|
|
||||||
ON UPDATE NO ACTION ON DELETE NO ACTION,
|
|
||||||
CONSTRAINT nomination_film_fkey FOREIGN KEY (film)
|
|
||||||
REFERENCES film (id) MATCH SIMPLE
|
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
```
|
|
||||||
|
|
||||||
Apply it to your new database by running
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# On OS X
|
|
||||||
pbpaste | psql demo1
|
|
||||||
|
|
||||||
# Or Linux
|
|
||||||
# xclip -selection clipboard -o | psql demo1
|
|
||||||
```
|
|
||||||
|
|
||||||
Start the PostgREST server and point it at the new database.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
postgrest -d demo1 -U postgres -a postgres --v1schema public
|
|
||||||
```
|
|
||||||
|
|
||||||
<div class="admonition note">
|
|
||||||
<p class="admonition-title">Note about database users</p>
|
|
||||||
|
|
||||||
<p>If you installed PostgreSQL with Homebrew on Mac then the
|
|
||||||
database username may be your own login rather than
|
|
||||||
<code>postgres</code>.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
Let's use PostgREST to populate the database. Install a REST client such as [Postman](https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop?hl=en). Now let's insert some data as a bulk post in CSV format:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
POST http://localhost:3000/festival
|
|
||||||
Content-Type: text/csv
|
|
||||||
|
|
||||||
name
|
|
||||||
Venice Film Festival
|
|
||||||
Cannes Film Festival
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Note that the server returns a multipart response with URL of each created resource.
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
Content-Type: application/json
|
|
||||||
Location: /festival?name=eq.Venice%20Film%20Festival
|
|
||||||
|
|
||||||
|
|
||||||
--postgrest_boundary
|
|
||||||
Content-Type: application/json
|
|
||||||
Location: /festival?name=eq.Cannes%20Film%20Festival
|
|
||||||
```
|
|
||||||
|
|
||||||
If you send a GET request to `/festival` it should return
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "Venice Film Festival"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Cannes Film Festival"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Now that you've seen how to do a bulk insert, let's do some more and fully populate the database.
|
|
||||||
|
|
||||||
Post the following to `/competition`:
|
|
||||||
|
|
||||||
```csv
|
|
||||||
name,festival,year
|
|
||||||
Golden Lion,Venice Film Festival,2014-01-01
|
|
||||||
Palme d'Or,Cannes Film Festival,2014-01-01
|
|
||||||
```
|
|
||||||
|
|
||||||
Now `/director`:
|
|
||||||
|
|
||||||
```csv
|
|
||||||
name
|
|
||||||
Bertrand Bonello
|
|
||||||
Atom Egoyan
|
|
||||||
David Gordon Green
|
|
||||||
Andrey Konchalovskiy
|
|
||||||
Mario Martone
|
|
||||||
Mike Leigh
|
|
||||||
Roy Andersson
|
|
||||||
Saverio Costanzo
|
|
||||||
Alix Delaporte
|
|
||||||
Jean-Pierre Dardenne
|
|
||||||
Xiaoshuai Wang
|
|
||||||
Kaan Müjdeci
|
|
||||||
Tommy Lee Jones
|
|
||||||
Nuri Bilge Ceylan
|
|
||||||
Michel Hazanavicius
|
|
||||||
Xavier Dolan
|
|
||||||
Ramin Bahrani
|
|
||||||
Alice Rohrwacher
|
|
||||||
Andrew Niccol
|
|
||||||
Rakhshan Bani-Etemad
|
|
||||||
David Oelhoffen
|
|
||||||
Bennett Miller
|
|
||||||
David Cronenberg
|
|
||||||
Shin'ya Tsukamoto
|
|
||||||
Joshua Oppenheimer
|
|
||||||
Olivier Assayas
|
|
||||||
Jean-Luc Godard
|
|
||||||
Alejandro González Iñárritu
|
|
||||||
Benoît Jacquot
|
|
||||||
Fatih Akin
|
|
||||||
Francesco Munzi
|
|
||||||
Ken Loach
|
|
||||||
Abel Ferrara
|
|
||||||
Xavier Beauvois
|
|
||||||
Naomi Kawase
|
|
||||||
```
|
|
||||||
|
|
||||||
And `/film`:
|
|
||||||
|
|
||||||
```csv
|
|
||||||
title,year,director,rating,language
|
|
||||||
Chuang ru zhe,2014-01-01,Xiaoshuai Wang,6.19999981,english
|
|
||||||
The Look of Silence,2014-01-01,Joshua Oppenheimer,8.30000019,Indonesian
|
|
||||||
Fires on the Plain,2014-01-01,Shin'ya Tsukamoto,5.80000019,Japanese
|
|
||||||
Far from Men,2014-01-01,David Oelhoffen,7.5,english
|
|
||||||
Good Kill,2014-01-01,Andrew Niccol,6.0999999,english
|
|
||||||
Leopardi,2014-01-01,Mario Martone,6.9000001,english
|
|
||||||
Sivas,2014-01-01,Kaan Müjdeci,7.69999981,english
|
|
||||||
Black Souls,2014-01-01,Francesco Munzi,7.0999999,english
|
|
||||||
Three Hearts,2014-01-01,Benoît Jacquot,5.80000019,French
|
|
||||||
Pasolini,2014-01-01,Abel Ferrara,5.80000019,english
|
|
||||||
Le dernier coup de marteau,2014-01-01,Alix Delaporte,6.5,english
|
|
||||||
Manglehorn,2014-01-01,David Gordon Green,7.0999999,english
|
|
||||||
Hungry Hearts,2014-01-01,Saverio Costanzo,6.4000001,English
|
|
||||||
Belye nochi pochtalona Alekseya Tryapitsyna,2014-01-01,Andrey Konchalovskiy,6.9000001,Russian
|
|
||||||
99 Homes,2014-01-01,Ramin Bahrani,7.30000019,english
|
|
||||||
The Cut,2014-01-01,Fatih Akin,6,Armenian
|
|
||||||
Birdman: Or (The Unexpected Virtue of Ignorance),2014-01-01,Alejandro González Iñárritu,8,English
|
|
||||||
La rançon de la gloire,2014-01-01,Xavier Beauvois,5.69999981,French
|
|
||||||
A Pigeon Sat on a Branch Reflecting on Existence,2014-01-01,Roy Andersson,7.19999981,english
|
|
||||||
Tales,2014-01-01,Rakhshan Bani-Etemad,6.80000019,english
|
|
||||||
The Wonders,2014-01-01,Alice Rohrwacher,6.80000019,Italian
|
|
||||||
Foxcatcher,2014-01-01,Bennett Miller,7.19999981,English
|
|
||||||
Mr. Turner,2014-01-01,Mike Leigh,7,English
|
|
||||||
Jimmy's Hall,2014-01-01,Ken Loach,6.69999981,English
|
|
||||||
The Homesman,2014-01-01,Tommy Lee Jones,6.5999999,English
|
|
||||||
The Captive,2014-01-01,Atom Egoyan,5.9000001,english
|
|
||||||
Goodbye to Language,2014-01-01,Jean-Luc Godard,6.19999981,French
|
|
||||||
The Search,2014-01-01,Michel Hazanavicius,6.9000001,French
|
|
||||||
Still the Water,2014-01-01,Naomi Kawase,6.9000001,Japanese
|
|
||||||
Mommy,2014-01-01,Xavier Dolan,8.30000019,French
|
|
||||||
"Two Days, One Night",2014-01-01,Jean-Pierre Dardenne,7.4000001,French
|
|
||||||
Maps to the Stars,2014-01-01,David Cronenberg,6.4000001,English
|
|
||||||
Saint Laurent,2014-01-01,Bertrand Bonello,6.5,French
|
|
||||||
Clouds of Sils Maria,2014-01-01,Olivier Assayas,6.9000001,english
|
|
||||||
Winter Sleep,2014-01-01,Nuri Bilge Ceylan,8.5,Turkish
|
|
||||||
```
|
|
||||||
|
|
||||||
Finally `/film_nomination`:
|
|
||||||
|
|
||||||
```csv
|
|
||||||
competition,film,won
|
|
||||||
1,1,f
|
|
||||||
1,2,f
|
|
||||||
1,3,f
|
|
||||||
1,4,f
|
|
||||||
1,5,f
|
|
||||||
1,6,f
|
|
||||||
1,7,f
|
|
||||||
1,8,f
|
|
||||||
1,9,f
|
|
||||||
1,10,f
|
|
||||||
1,11,f
|
|
||||||
1,12,f
|
|
||||||
1,13,f
|
|
||||||
1,14,f
|
|
||||||
1,15,f
|
|
||||||
1,16,f
|
|
||||||
1,17,f
|
|
||||||
1,18,f
|
|
||||||
1,19,f
|
|
||||||
1,20,f
|
|
||||||
2,21,f
|
|
||||||
2,22,f
|
|
||||||
2,23,f
|
|
||||||
2,24,f
|
|
||||||
2,25,f
|
|
||||||
2,26,f
|
|
||||||
2,27,f
|
|
||||||
2,28,f
|
|
||||||
2,29,f
|
|
||||||
2,30,f
|
|
||||||
2,31,f
|
|
||||||
2,32,f
|
|
||||||
2,33,f
|
|
||||||
2,34,f
|
|
||||||
2,35,f
|
|
||||||
```
|
|
||||||
|
|
||||||
At this point nominations are fully specified but it's not a convenient interface for a rest client. Let's make a view they can use. Paste this into `psql demo1`.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
create or replace view nomination as
|
|
||||||
select comp.festival,
|
|
||||||
comp.name as competition,
|
|
||||||
comp.year,
|
|
||||||
film.title,
|
|
||||||
film.director,
|
|
||||||
film.rating
|
|
||||||
from film_nomination as nom
|
|
||||||
left join film on nom.film = film.id
|
|
||||||
left join competition as comp on nom.competition = comp.id
|
|
||||||
order by comp.year desc, comp.festival, competition;
|
|
||||||
```
|
|
||||||
|
|
||||||
Time to try it out. Let's get the contents of the new view, ordered by film rating
|
|
||||||
|
|
||||||
```
|
|
||||||
GET http://localhost:3000/nomination?order=rating.desc
|
|
||||||
```
|
|
||||||
|
|
||||||
If you find it more human readable, add an `Accept: text/csv` header.
|
|
||||||
|
|
||||||
### Releasing a New Version
|
|
||||||
|
|
||||||
Suppose we want this endpoint to cater to those moviegoers with attention deficit disorder. In today's busy world we don't have time to read an extra couple words or compare nuanced reviews. In API version two we will truncate the names and round the ratings!
|
|
||||||
|
|
||||||
Each version lives in a numbered schema, so let's make a schema for version two.
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE SCHEMA "2";
|
|
||||||
GRANT USAGE ON SCHEMA "2" TO PUBLIC;
|
|
||||||
ALTER DATABASE demo1 SET search_path = "2", "public";
|
|
||||||
```
|
|
||||||
|
|
||||||
To override the `films` endpoint create a view in the "2" schema with that name:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
create or replace view "2".film as
|
|
||||||
select id, substring(f.title from 1 for 10) as title,
|
|
||||||
year, director, round(f.rating) as rating, language
|
|
||||||
from "public".film as f;
|
|
||||||
```
|
|
||||||
|
|
||||||
We select the desired version as part of content negotiation. Try this get request:
|
|
||||||
|
|
||||||
```HTTP
|
|
||||||
GET http://localhost:3000/film
|
|
||||||
Accept: text/csv; version=2
|
|
||||||
```
|
|
||||||
|
|
||||||
Then try toggling the version string in the Accept header and watch the results change. Pretty good, now how about writing values? PostgreSQL's nice feature called auto-updatable views allows writes to pass through views. Sadly this view is not eligible because truncation and rounding cannot be uniquely reversed. If we attempt to post a new result it complains:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"hint": null,
|
|
||||||
"details": "View columns that are not columns of their base relation are not updatable.",
|
|
||||||
"code": "0A000",
|
|
||||||
"message": "cannot insert into column \"title\" of view \"film\""
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a case where we need explicit triggers
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- TODO - FIX THIS
|
|
||||||
|
|
||||||
-- CREATE OR REPLACE RULE insert_v2_films AS
|
|
||||||
-- ON INSERT TO "2".film
|
|
||||||
-- DO INSTEAD
|
|
||||||
-- INSERT INTO public.film (id, title, year, director, rating, language)
|
|
||||||
-- VALUES (NEW.id, NEW.title,
|
|
||||||
-- NEW.year, NEW.director,
|
|
||||||
-- NEW.rating, NEW.language)
|
|
||||||
-- RETURNING public.film.*;
|
|
||||||
```
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
@@ -1,68 +0,0 @@
|
|||||||

|
|
||||||
|
|
||||||
## Introduction
|
|
||||||
|
|
||||||
PostgREST is a standalone web server that turns your database directly into a RESTful API. The structural constraints and permissions in the database determine the API endpoints and operations.
|
|
||||||
|
|
||||||
This guide explains how to install the software and provides practical examples of its use. You'll learn how to build a fast, versioned, secure API and how to deploy it to production.
|
|
||||||
|
|
||||||
The project has a friendly and growing community. Here are some ways to get help or get involved:
|
|
||||||
|
|
||||||
* The project [chat room](https://gitter.im/begriffs/postgrest)
|
|
||||||
* Report or search [issues](https://github.com/begriffs/postgrest/issues)
|
|
||||||
|
|
||||||
### Motivation
|
|
||||||
|
|
||||||
Using PostgREST is an alternative to manual CRUD programming. Custom API servers suffer problems. Writing business logic often duplicates, ignores or hobbles database structure. Object-relational mapping is a leaky abstraction leading to slow imperative code. The PostgREST philosophy establishes a single declarative source of truth: the data itself.
|
|
||||||
|
|
||||||
#### Declarative Programming
|
|
||||||
|
|
||||||
It's easier to ask Postgres to join data for you and let its query planner figure out the details than to loop through rows yourself. It's easier to assign permissions to db objects than to add guards in controllers. (This is especially true for cascading permissions in data dependencies.) It's easier set constraints than to litter code with sanity checks.
|
|
||||||
|
|
||||||
#### Leakproof Abstraction
|
|
||||||
|
|
||||||
There is no ORM involved. Creating new views happens in SQL with known performance implications. A database administrator can now create an API from scratch with no custom programming.
|
|
||||||
|
|
||||||
#### Embracing the Relational Model
|
|
||||||
|
|
||||||
In 1970 E. F. Codd criticized the then-dominant hierarchical model of databases in his article <a href="https://www.seas.upenn.edu/~zives/03f/cis550/codd.pdf">A Relational Model of Data for Large Shared Data Banks</a>. Reading the article reveals a striking similarity between hierarchical databases and nested http routes. With PostgREST we attempt to use flexible filtering and embedding rather than nested routes.
|
|
||||||
|
|
||||||
#### One Thing Well
|
|
||||||
|
|
||||||
PostgREST has a focused scope. It works well with other tools like Nginx. This forces you to cleanly separate the data-centric CRUD operations from other concerns. Use a collection of sharp tools rather than building a big ball of mud.
|
|
||||||
|
|
||||||
#### Shared Improvements
|
|
||||||
|
|
||||||
As with any open source project, we all gain from features and fixes in the tool. It's more beneficial than improvements locked inextricably within custom codebases.
|
|
||||||
|
|
||||||
### Myths
|
|
||||||
|
|
||||||
#### You have to make tons of stored procs and triggers
|
|
||||||
|
|
||||||
Modern PostgreSQL features like auto-updatable views and computed columns make this mostly unnecessary. Triggers do play a part, but generally not for irksome boilerplate. When they are required triggers are preferable to ad-hoc app code anyway, since the former work reliably for any codepath.
|
|
||||||
|
|
||||||
#### Exposing the database destroys encapsulation
|
|
||||||
|
|
||||||
PostgREST does versioning through database schemas. This allows you to expose tables and views without making the app brittle. Underlying tables can be superseded and hidden behind public facing views. The chapter about versioning shows how to do this.
|
|
||||||
|
|
||||||
### Conventions
|
|
||||||
|
|
||||||
This guide contains highlighted notes and tangential information interspersed with the text.
|
|
||||||
|
|
||||||
<div class="admonition note">
|
|
||||||
<p class="admonition-title">Design Consideration</p>
|
|
||||||
|
|
||||||
<p>Contains history which informed the current design. Sometimes it discusses unavoidable tradeoffs or a point of theory.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admonition warning">
|
|
||||||
<p class="admonition-title">Invitation to Contribute</p>
|
|
||||||
|
|
||||||
<p>Points out things we know we want to add or improve. They might give you ideas for ways to contribute to the project.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admonition danger">
|
|
||||||
<p class="admonition-title">Deprecation Warning</p>
|
|
||||||
|
|
||||||
<p>Alerts you to features which will be removed in the next major (breaking) release.</p>
|
|
||||||
</div>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
## Ecosystem
|
|
||||||
|
|
||||||
### Client-Side Libraries
|
|
||||||
|
|
||||||
* [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
|
|
||||||
* [JarvusInnovations/jarvus-postgrest-apikit](https://github.com/JarvusInnovations/jarvus-postgrest-apikit) - Sencha framework package for binding models/stores/proxies to PostgREST tables
|
|
||||||
|
|
||||||
### Extensions
|
|
||||||
|
|
||||||
* [srid/spas](https://github.com/srid/spas) - allow file uploads and basic auth
|
|
||||||
|
|
||||||
### Example Apps
|
|
||||||
|
|
||||||
* [timwis/ext-postgrest-crud](https://github.com/timwis/ext-postgrest-crud) - browser-based spreadsheet
|
|
||||||
* [srid/chronicle](https://github.com/srid/chronicle#deploying-to-heroku) - tracking a tree of personal memories
|
|
||||||
* [begriffs/postgrest-example](https://github.com/begriffs/postgrest-example) - how to configure a db for use as an API
|
|
||||||
* [marmelab/ng-admin-postgrest](https://github.com/marmelab/ng-admin-postgrest) - automatic database admin panel
|
|
||||||
* [tyrchen/goodfilm](https://github.com/tyrchen/goodfilm) - example film api
|
|
||||||
|
|
||||||
### In Production
|
|
||||||
|
|
||||||
* [Catarse](https://www.catarse.me/)
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
## Installation
|
|
||||||
|
|
||||||
### Installing from Pre-Built Release
|
|
||||||
|
|
||||||
The [release page](https://github.com/begriffs/postgrest/releases/latest) has precompiled binaries for Mac OS X and 64-bit Ubuntu. Next extract the tarball and run the binary inside with no arguments to see usage instructions:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Untar the release (available at https://github.com/begriffs/postgrest/releases/latest)
|
|
||||||
|
|
||||||
$ tar zxf postgrest-[version]-[platform].tar.xz
|
|
||||||
|
|
||||||
# Try running it
|
|
||||||
$ ./postgrest
|
|
||||||
|
|
||||||
# You should see a usage help message
|
|
||||||
```
|
|
||||||
|
|
||||||
<div class="admonition warning">
|
|
||||||
<p class="admonition-title">Invitation to Contribute</p>
|
|
||||||
|
|
||||||
<p>I currently build the binaries manually for each version. We need to set up an automated build matrix for various architectures. It should support 32- and 64-bit versions of
|
|
||||||
|
|
||||||
<ul><li>Scientific Linux 6</li><li>CentOS</li><li>RHEL 6</li></ul>
|
|
||||||
|
|
||||||
Also it would be good to create a package for apt.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
We'll learn the meaning of the command line flags later, but here is a minimal example of running the app. It does all operations as user `postgres`, including for unauthenticated requests.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ ./postgrest -d dbname -U postgres -a postgres --v1schema public
|
|
||||||
```
|
|
||||||
|
|
||||||
### Building from Source
|
|
||||||
|
|
||||||
When a prebuilt binary does not exist for your system you can build the project from source. You'll also need to do this if you want to help with development. [Stack](https://github.com/commercialhaskell/stack) makes it easy. It will install any necessary Haskell dependencies on your system.
|
|
||||||
|
|
||||||
* [Install Stack](https://github.com/commercialhaskell/stack#how-to-install) for your platform
|
|
||||||
```bash
|
|
||||||
#ubuntu example
|
|
||||||
wget -q -O- https://s3.amazonaws.com/download.fpcomplete.com/ubuntu/fpco.key | sudo apt-key add -
|
|
||||||
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
|
|
||||||
```
|
|
||||||
* Build & install in one step
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/begriffs/postgrest.git
|
|
||||||
cd postgrest
|
|
||||||
sudo stack install --install-ghc --local-bin-path /usr/local/bin
|
|
||||||
```
|
|
||||||
|
|
||||||
* Run the server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
postgrest dbconnectionstring arg1 arg2
|
|
||||||
```
|
|
||||||
|
|
||||||
If you want to run the test suite, stack can do that too: `stack test`.
|
|
||||||
|
|
||||||
### Install via Homebrew (Mac OS X)
|
|
||||||
|
|
||||||
You can use the Homebrew package manager to install PostgREST on Mac
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ensure brew is up to date
|
|
||||||
brew update
|
|
||||||
|
|
||||||
# Check for any problems with brew's setup
|
|
||||||
brew doctor
|
|
||||||
|
|
||||||
# Install the postgrest package
|
|
||||||
brew install postgrest
|
|
||||||
```
|
|
||||||
|
|
||||||
This will automatically install PostgreSQL as a dependency (see the [Installing PostgreSQL](#installing-postgresql) section for setup instructions). The process tends to take around 15 minutes to install the package and its dependencies.
|
|
||||||
|
|
||||||
After installation completes, the tool is added to your $PATH and can be used from anywhere with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
postgrest --help
|
|
||||||
```
|
|
||||||
|
|
||||||
### Installing PostgreSQL
|
|
||||||
|
|
||||||
To use PostgREST you will need an underlying database. You can use something like Amazon [RDS](https://aws.amazon.com/rds/) but installing your own locally is cheaper and more convenient for development.
|
|
||||||
|
|
||||||
* [Instructions for OS X](http://exponential.io/blog/2015/02/21/install-postgresql-on-mac-os-x-via-brew/)
|
|
||||||
* [Instructions for Ubuntu 14.04](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-14-04)
|
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
{-# LANGUAGE CPP #-}
|
||||||
|
|
||||||
|
module Main where
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
import PostgREST.App
|
||||||
|
import PostgREST.Config (AppConfig (..),
|
||||||
|
PgVersion (..),
|
||||||
|
minimumPgVersion,
|
||||||
|
prettyVersion,
|
||||||
|
readOptions)
|
||||||
|
import PostgREST.Error (prettyUsageError)
|
||||||
|
import PostgREST.OpenAPI (isMalformedProxyUri)
|
||||||
|
import PostgREST.DbStructure
|
||||||
|
|
||||||
|
import Control.AutoUpdate
|
||||||
|
import Data.ByteString.Base64 (decode)
|
||||||
|
import Data.String (IsString (..))
|
||||||
|
import Data.Text (stripPrefix, pack, replace)
|
||||||
|
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
|
||||||
|
import Data.Text.IO (hPutStrLn, readFile)
|
||||||
|
import Data.Function (id)
|
||||||
|
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||||
|
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)
|
||||||
|
import Data.IORef
|
||||||
|
#ifndef mingw32_HOST_OS
|
||||||
|
import System.Posix.Signals
|
||||||
|
#endif
|
||||||
|
|
||||||
|
isServerVersionSupported :: H.Session Bool
|
||||||
|
isServerVersionSupported = do
|
||||||
|
ver <- H.query () pgVersion
|
||||||
|
return $ ver >= pgvNum minimumPgVersion
|
||||||
|
where
|
||||||
|
pgVersion =
|
||||||
|
H.statement "SELECT current_setting('server_version_num')::integer"
|
||||||
|
HE.unit (HD.singleRow $ HD.value HD.int4) False
|
||||||
|
|
||||||
|
main :: IO ()
|
||||||
|
main = do
|
||||||
|
hSetBuffering stdout LineBuffering
|
||||||
|
hSetBuffering stdin LineBuffering
|
||||||
|
hSetBuffering stderr NoBuffering
|
||||||
|
|
||||||
|
conf <- loadSecretFile =<< readOptions
|
||||||
|
let host = configHost conf
|
||||||
|
port = configPort conf
|
||||||
|
proxy = configProxyUri conf
|
||||||
|
pgSettings = toS (configDatabase conf)
|
||||||
|
appSettings = setHost ((fromString . toS) host)
|
||||||
|
. setPort port
|
||||||
|
. setServerName (toS $ "postgrest/" <> prettyVersion)
|
||||||
|
$ defaultSettings
|
||||||
|
|
||||||
|
when (isMalformedProxyUri $ toS <$> proxy) $ panic
|
||||||
|
"Malformed proxy uri, a correct example: https://example.com:8443/basePath"
|
||||||
|
|
||||||
|
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
|
||||||
|
|
||||||
|
pool <- P.acquire (configPool conf, 10, pgSettings)
|
||||||
|
|
||||||
|
result <- P.use pool $ do
|
||||||
|
supported <- isServerVersionSupported
|
||||||
|
unless supported $ panic (
|
||||||
|
"Cannot run in this PostgreSQL version, PostgREST needs at least "
|
||||||
|
<> pgvName minimumPgVersion)
|
||||||
|
getDbStructure (toS $ configSchema conf)
|
||||||
|
|
||||||
|
forM_ (lefts [result]) $ \e -> do
|
||||||
|
hPutStrLn stderr (prettyUsageError e)
|
||||||
|
exitFailure
|
||||||
|
|
||||||
|
refDbStructure <- newIORef $ either (panic . 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 (toS $ configSchema conf)
|
||||||
|
liftIO $ atomicWriteIORef refDbStructure s
|
||||||
|
) Nothing
|
||||||
|
#endif
|
||||||
|
|
||||||
|
-- ask for the OS time at most once per second
|
||||||
|
getTime <- mkAutoUpdate
|
||||||
|
defaultUpdateSettings { updateAction = getPOSIXTime }
|
||||||
|
|
||||||
|
runSettings appSettings $ postgrest conf refDbStructure pool getTime
|
||||||
|
|
||||||
|
loadSecretFile :: AppConfig -> IO AppConfig
|
||||||
|
loadSecretFile conf = extractAndTransform mSecret
|
||||||
|
where
|
||||||
|
mSecret = decodeUtf8 <$> configJwtSecret conf
|
||||||
|
isB64 = configJwtSecretIsBase64 conf
|
||||||
|
|
||||||
|
extractAndTransform :: Maybe Text -> IO AppConfig
|
||||||
|
extractAndTransform Nothing = return conf
|
||||||
|
extractAndTransform (Just s) =
|
||||||
|
fmap setSecret $ transformString isB64 =<<
|
||||||
|
case stripPrefix "@" s of
|
||||||
|
Nothing -> return s
|
||||||
|
Just filename -> readFile (toS filename)
|
||||||
|
|
||||||
|
transformString :: Bool -> Text -> IO ByteString
|
||||||
|
transformString False t = return . encodeUtf8 $ t
|
||||||
|
transformString True t =
|
||||||
|
case decode (encodeUtf8 $ replaceUrlChars t) of
|
||||||
|
Left errMsg -> panic $ pack errMsg
|
||||||
|
Right bs -> return bs
|
||||||
|
|
||||||
|
setSecret bs = conf { configJwtSecret = Just bs }
|
||||||
|
|
||||||
|
replaceUrlChars = replace "_" "/" . replace "-" "+" . replace "." "="
|
||||||
|
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
site_name: PostgREST
|
|
||||||
site_url: http://postgrest.com
|
|
||||||
site_description: Building declarative APIs
|
|
||||||
site_author: Joe Nelson
|
|
||||||
site_favicon: favicon.ico
|
|
||||||
|
|
||||||
repo_url: https://github.com/begriffs/postgrest
|
|
||||||
|
|
||||||
pages:
|
|
||||||
- Home: index.md
|
|
||||||
- Install:
|
|
||||||
- The Server: install/server.md
|
|
||||||
- Ecosystem: install/ecosystem.md
|
|
||||||
- API:
|
|
||||||
- Reading: api/reading.md
|
|
||||||
- Writing: api/writing.md
|
|
||||||
- Admin:
|
|
||||||
- Security: admin/security.md
|
|
||||||
- Versioning: admin/versioning.md
|
|
||||||
- Migration: admin/migration.md
|
|
||||||
- Deployment: admin/deployment.md
|
|
||||||
- Performance: admin/performance.md
|
|
||||||
- Examples:
|
|
||||||
- Getting Started: examples/start.md
|
|
||||||
+84
-132
@@ -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.1
|
version: 0.4.0.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,186 +22,138 @@ Flag CI
|
|||||||
Default: False
|
Default: False
|
||||||
|
|
||||||
executable postgrest
|
executable postgrest
|
||||||
if flag(ci)
|
main-is: Main.hs
|
||||||
ghc-options: -Wall -W -Werror
|
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude
|
||||||
else
|
ghc-options:
|
||||||
ghc-options: -Wall -W -O2
|
-threaded
|
||||||
|
-rtsopts
|
||||||
main-is: PostgREST/Main.hs
|
"-with-rtsopts=-N -I2"
|
||||||
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
|
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
build-depends: base >= 4.8 && < 5
|
build-depends: auto-update
|
||||||
|
, base
|
||||||
|
, hasql
|
||||||
|
, hasql-pool
|
||||||
, postgrest
|
, postgrest
|
||||||
, hasql >= 0.7.3 && < 0.8
|
, protolude
|
||||||
, hasql-backend >= 0.4.1 && < 0.5
|
, text
|
||||||
, hasql-postgres >= 0.10.4 && < 0.11
|
, time
|
||||||
, warp >= 3.0.2, wai >= 3.0.1
|
, warp
|
||||||
, wai-extra, wai-cors
|
, bytestring
|
||||||
, wai-middleware-static >= 0.6.0
|
, base64-bytestring
|
||||||
, HTTP, convertible, http-types
|
if !os(windows)
|
||||||
, case-insensitive
|
build-depends: unix
|
||||||
, scientific, time
|
|
||||||
, aeson >= 0.8, network >= 2.6
|
hs-source-dirs: main
|
||||||
, aeson-pretty >= 0.7 && < 0.8
|
|
||||||
, bytestring, text, split, string-conversions
|
|
||||||
, stringsearch
|
|
||||||
, containers, unordered-containers
|
|
||||||
, optparse-applicative >= 0.11 && < 0.13
|
|
||||||
, regex-base, regex-tdfa
|
|
||||||
, Ranged-sets
|
|
||||||
, transformers, MissingH
|
|
||||||
, bcrypt >= 0.0.6, base64-string
|
|
||||||
, network-uri >= 2.6
|
|
||||||
, resource-pool
|
|
||||||
, blaze-builder
|
|
||||||
, vector
|
|
||||||
, mtl
|
|
||||||
, cassava
|
|
||||||
, jwt
|
|
||||||
, parsec
|
|
||||||
, errors
|
|
||||||
, bifunctors
|
|
||||||
hs-source-dirs: src
|
|
||||||
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, QuasiQuotes, NoImplicitPrelude
|
||||||
build-depends: HTTP
|
build-depends: aeson
|
||||||
, MissingH
|
, ansi-wl-pprint
|
||||||
, Ranged-sets
|
, base >= 4.8 && < 6
|
||||||
, aeson
|
|
||||||
, base >=4.6 && <5
|
|
||||||
, base64-string
|
|
||||||
, bcrypt
|
|
||||||
, bifunctors
|
|
||||||
, blaze-builder
|
|
||||||
, bytestring
|
, bytestring
|
||||||
, case-insensitive
|
, case-insensitive
|
||||||
, cassava
|
, cassava
|
||||||
|
, configurator
|
||||||
, containers
|
, containers
|
||||||
, convertible
|
, contravariant
|
||||||
, errors
|
, either
|
||||||
, hasql
|
, hasql
|
||||||
, hasql-backend
|
, hasql-pool == 0.4.1
|
||||||
, hasql-postgres
|
, hasql-transaction == 0.5
|
||||||
|
, heredoc
|
||||||
|
, HTTP
|
||||||
, http-types
|
, http-types
|
||||||
|
, insert-ordered-containers
|
||||||
|
, interpolatedstring-perl6
|
||||||
, jwt
|
, jwt
|
||||||
, mtl
|
, lens
|
||||||
, network
|
, lens-aeson
|
||||||
, network-uri
|
, network-uri
|
||||||
, optparse-applicative
|
, optparse-applicative >= 0.12.0.0 && < 0.13.0.0
|
||||||
, parsec
|
, parsec
|
||||||
, regex-base
|
, protolude
|
||||||
|
, Ranged-sets == 0.3.0
|
||||||
, regex-tdfa
|
, regex-tdfa
|
||||||
, resource-pool
|
, safe
|
||||||
, scientific
|
, scientific
|
||||||
, split
|
, swagger2
|
||||||
, string-conversions
|
|
||||||
, stringsearch
|
|
||||||
, text
|
, text
|
||||||
, time
|
, time
|
||||||
, transformers
|
|
||||||
, unordered-containers
|
, unordered-containers
|
||||||
, vector
|
, vector
|
||||||
, wai
|
, wai
|
||||||
, wai-cors
|
, wai-cors
|
||||||
, wai-extra
|
, wai-extra
|
||||||
, wai-middleware-static
|
, wai-middleware-static
|
||||||
, warp
|
|
||||||
|
|
||||||
Other-Modules: Paths_postgrest
|
Other-Modules: Paths_postgrest
|
||||||
Exposed-Modules: PostgREST.App
|
Exposed-Modules: PostgREST.ApiRequest
|
||||||
|
, PostgREST.App
|
||||||
, PostgREST.Auth
|
, PostgREST.Auth
|
||||||
, PostgREST.Config
|
, PostgREST.Config
|
||||||
|
, PostgREST.DbStructure
|
||||||
|
, PostgREST.DbRequestBuilder
|
||||||
, PostgREST.Error
|
, PostgREST.Error
|
||||||
, PostgREST.Middleware
|
, PostgREST.Middleware
|
||||||
|
, PostgREST.OpenAPI
|
||||||
, PostgREST.Parsers
|
, PostgREST.Parsers
|
||||||
, PostgREST.DbStructure
|
|
||||||
, PostgREST.QueryBuilder
|
, PostgREST.QueryBuilder
|
||||||
, PostgREST.RangeQuery
|
, PostgREST.RangeQuery
|
||||||
, PostgREST.ApiRequest
|
|
||||||
, PostgREST.Types
|
, PostgREST.Types
|
||||||
hs-source-dirs: src
|
hs-source-dirs: src
|
||||||
|
|
||||||
Test-Suite spec
|
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, QuasiQuotes, NoImplicitPrelude
|
||||||
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.BinaryJwtSecretSpec
|
||||||
|
, Feature.ConcurrentSpec
|
||||||
, Feature.CorsSpec
|
, Feature.CorsSpec
|
||||||
, Feature.DeleteSpec
|
, Feature.DeleteSpec
|
||||||
, Feature.InsertSpec
|
, Feature.InsertSpec
|
||||||
|
, Feature.NoJwtSpec
|
||||||
|
, Feature.ProxySpec
|
||||||
|
, Feature.QueryLimitedSpec
|
||||||
, Feature.QuerySpec
|
, Feature.QuerySpec
|
||||||
, Feature.RangeSpec
|
, Feature.RangeSpec
|
||||||
|
, Feature.SingularSpec
|
||||||
, 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
|
|
||||||
, Spec
|
|
||||||
, SpecHelper
|
, SpecHelper
|
||||||
, TestTypes
|
, TestTypes
|
||||||
Build-Depends: base, hspec == 2.2.*, QuickCheck
|
Build-Depends: aeson
|
||||||
, hspec-wai, hspec-wai-json
|
, aeson-qq
|
||||||
, hasql, hasql-backend
|
, async
|
||||||
, hasql-postgres
|
, auto-update
|
||||||
, warp, wai
|
, base
|
||||||
, packdeps, hlint
|
, bytestring
|
||||||
, HTTP, convertible
|
, base64-bytestring
|
||||||
, case-insensitive
|
, case-insensitive
|
||||||
, wai-extra, wai-cors, containers
|
|
||||||
, wai-middleware-static
|
|
||||||
, http-types, scientific, time
|
|
||||||
, bytestring, aeson, network
|
|
||||||
, text, optparse-applicative
|
|
||||||
, stringsearch
|
|
||||||
, unordered-containers
|
|
||||||
, regex-base
|
|
||||||
, string-conversions
|
|
||||||
, http-media, regex-tdfa
|
|
||||||
, Ranged-sets
|
|
||||||
, transformers, MissingH, split
|
|
||||||
, bcrypt, base64-string
|
|
||||||
, network-uri
|
|
||||||
, resource-pool
|
|
||||||
, blaze-builder
|
|
||||||
, vector
|
|
||||||
, mtl
|
|
||||||
, cassava
|
, cassava
|
||||||
, process
|
, containers
|
||||||
|
, contravariant
|
||||||
|
, hasql
|
||||||
|
, hasql-pool
|
||||||
, heredoc
|
, heredoc
|
||||||
, jwt
|
, hjsonpointer
|
||||||
, parsec
|
, hjsonschema
|
||||||
, errors
|
, hspec
|
||||||
, bifunctors
|
, hspec-wai
|
||||||
|
, hspec-wai-json
|
||||||
|
, http-types
|
||||||
|
, lens
|
||||||
|
, lens-aeson
|
||||||
|
, monad-control
|
||||||
|
, postgrest
|
||||||
|
, process
|
||||||
|
, protolude
|
||||||
|
, regex-tdfa
|
||||||
|
, time
|
||||||
|
, transformers-base
|
||||||
|
, wai
|
||||||
|
, wai-extra
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
-------------------------------------------------------------------------------
|
|
||||||
-- Adapted from https://github.com/robconery/pg-auth
|
|
||||||
|
|
||||||
begin;
|
|
||||||
|
|
||||||
-- comment out the role creation statements if
|
|
||||||
-- you want to run this script more than once
|
|
||||||
create role anon noinherit;
|
|
||||||
create role author;
|
|
||||||
|
|
||||||
create extension if not exists pgcrypto;
|
|
||||||
create extension if not exists "uuid-ossp";
|
|
||||||
|
|
||||||
-- We put things inside the basic_auth schema to hide
|
|
||||||
-- them from public view. Certain public procs/views will
|
|
||||||
-- refer to helpers and tables inside.
|
|
||||||
create schema if not exists basic_auth;
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
|
||||||
-- Utility functions
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
basic_auth.clearance_for_role(u name) returns void as
|
|
||||||
$$
|
|
||||||
declare
|
|
||||||
ok boolean;
|
|
||||||
begin
|
|
||||||
select exists (
|
|
||||||
select rolname
|
|
||||||
from pg_authid
|
|
||||||
where pg_has_role(current_user, oid, 'member')
|
|
||||||
and rolname = u
|
|
||||||
) into ok;
|
|
||||||
if not ok then
|
|
||||||
raise invalid_password using message =
|
|
||||||
'current user not member of role ' || u;
|
|
||||||
end if;
|
|
||||||
end
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
|
||||||
-- Users storage and constraints
|
|
||||||
|
|
||||||
create table if not exists
|
|
||||||
basic_auth.users (
|
|
||||||
email text primary key check ( email ~* '^.+@.+\..+$' ),
|
|
||||||
pass text not null check (length(pass) < 512),
|
|
||||||
role name not null check (length(role) < 512),
|
|
||||||
verified boolean not null default false
|
|
||||||
-- If you like add more columns, or a json column
|
|
||||||
);
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
basic_auth.check_role_exists() returns trigger
|
|
||||||
language plpgsql
|
|
||||||
as $$
|
|
||||||
begin
|
|
||||||
if not exists (select 1 from pg_roles as r where r.rolname = new.role) then
|
|
||||||
raise foreign_key_violation using message =
|
|
||||||
'unknown database role: ' || new.role;
|
|
||||||
return null;
|
|
||||||
end if;
|
|
||||||
return new;
|
|
||||||
end
|
|
||||||
$$;
|
|
||||||
|
|
||||||
drop trigger if exists ensure_user_role_exists on basic_auth.users;
|
|
||||||
create constraint trigger ensure_user_role_exists
|
|
||||||
after insert or update on basic_auth.users
|
|
||||||
for each row
|
|
||||||
execute procedure basic_auth.check_role_exists();
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
basic_auth.encrypt_pass() returns trigger
|
|
||||||
language plpgsql
|
|
||||||
as $$
|
|
||||||
begin
|
|
||||||
if tg_op = 'INSERT' or new.pass <> old.pass then
|
|
||||||
new.pass = crypt(new.pass, gen_salt('bf'));
|
|
||||||
end if;
|
|
||||||
return new;
|
|
||||||
end
|
|
||||||
$$;
|
|
||||||
|
|
||||||
drop trigger if exists encrypt_pass on basic_auth.users;
|
|
||||||
create trigger encrypt_pass
|
|
||||||
before insert or update on basic_auth.users
|
|
||||||
for each row
|
|
||||||
execute procedure basic_auth.encrypt_pass();
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
basic_auth.send_validation() returns trigger
|
|
||||||
language plpgsql
|
|
||||||
as $$
|
|
||||||
declare
|
|
||||||
tok uuid;
|
|
||||||
begin
|
|
||||||
select uuid_generate_v4() into tok;
|
|
||||||
insert into basic_auth.tokens (token, token_type, email)
|
|
||||||
values (tok, 'validation', new.email);
|
|
||||||
perform pg_notify('validate',
|
|
||||||
json_build_object(
|
|
||||||
'email', new.email,
|
|
||||||
'token', tok,
|
|
||||||
'token_type', 'validation'
|
|
||||||
)::text
|
|
||||||
);
|
|
||||||
return new;
|
|
||||||
end
|
|
||||||
$$;
|
|
||||||
|
|
||||||
drop trigger if exists send_validation on basic_auth.users;
|
|
||||||
create trigger send_validation
|
|
||||||
after insert on basic_auth.users
|
|
||||||
for each row
|
|
||||||
execute procedure basic_auth.send_validation();
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
|
||||||
-- Email Validation and Password Reset
|
|
||||||
|
|
||||||
drop type if exists token_type_enum cascade;
|
|
||||||
create type token_type_enum as enum ('validation', 'reset');
|
|
||||||
|
|
||||||
create table if not exists
|
|
||||||
basic_auth.tokens (
|
|
||||||
token uuid primary key,
|
|
||||||
token_type token_type_enum not null,
|
|
||||||
email text not null references basic_auth.users (email)
|
|
||||||
on delete cascade on update cascade,
|
|
||||||
created_at timestamptz not null default current_date
|
|
||||||
);
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
|
||||||
-- Login helper
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
basic_auth.user_role(email text, pass text) returns name
|
|
||||||
language plpgsql
|
|
||||||
as $$
|
|
||||||
begin
|
|
||||||
return (
|
|
||||||
select role from basic_auth.users
|
|
||||||
where users.email = user_role.email
|
|
||||||
and users.pass = crypt(user_role.pass, users.pass)
|
|
||||||
);
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
basic_auth.current_email() returns text
|
|
||||||
language plpgsql
|
|
||||||
as $$
|
|
||||||
begin
|
|
||||||
return current_setting('postgrest.claims.email');
|
|
||||||
exception
|
|
||||||
-- handle unrecognized configuration parameter error
|
|
||||||
when undefined_object then return '';
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
|
||||||
-- Public functions (in current schema, not basic_auth)
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
request_password_reset(email text) returns void
|
|
||||||
language plpgsql
|
|
||||||
as $$
|
|
||||||
declare
|
|
||||||
tok uuid;
|
|
||||||
begin
|
|
||||||
delete from basic_auth.tokens
|
|
||||||
where token_type = 'reset'
|
|
||||||
and tokens.email = request_password_reset.email;
|
|
||||||
|
|
||||||
select uuid_generate_v4() into tok;
|
|
||||||
insert into basic_auth.tokens (token, token_type, email)
|
|
||||||
values (tok, 'reset', request_password_reset.email);
|
|
||||||
perform pg_notify('reset',
|
|
||||||
json_build_object(
|
|
||||||
'email', request_password_reset.email,
|
|
||||||
'token', tok,
|
|
||||||
'token_type', 'reset'
|
|
||||||
)::text
|
|
||||||
);
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
reset_password(email text, token uuid, pass text)
|
|
||||||
returns void
|
|
||||||
language plpgsql
|
|
||||||
as $$
|
|
||||||
declare
|
|
||||||
tok uuid;
|
|
||||||
begin
|
|
||||||
if exists(select 1 from basic_auth.tokens
|
|
||||||
where tokens.email = reset_password.email
|
|
||||||
and tokens.token = reset_password.token
|
|
||||||
and token_type = 'reset') then
|
|
||||||
update basic_auth.users set pass=reset_password.pass
|
|
||||||
where users.email = reset_password.email;
|
|
||||||
|
|
||||||
delete from basic_auth.tokens
|
|
||||||
where tokens.email = reset_password.email
|
|
||||||
and tokens.token = reset_password.token
|
|
||||||
and token_type = 'reset';
|
|
||||||
else
|
|
||||||
raise invalid_password using message =
|
|
||||||
'invalid user or token';
|
|
||||||
end if;
|
|
||||||
delete from basic_auth.tokens
|
|
||||||
where token_type = 'reset'
|
|
||||||
and tokens.email = reset_password.email;
|
|
||||||
|
|
||||||
select uuid_generate_v4() into tok;
|
|
||||||
insert into basic_auth.tokens (token, token_type, email)
|
|
||||||
values (tok, 'reset', reset_password.email);
|
|
||||||
perform pg_notify('reset',
|
|
||||||
json_build_object(
|
|
||||||
'email', reset_password.email,
|
|
||||||
'token', tok
|
|
||||||
)::text
|
|
||||||
);
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
drop type if exists basic_auth.jwt_claims cascade;
|
|
||||||
create type
|
|
||||||
basic_auth.jwt_claims AS (role text, email text);
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
login(email text, pass text) returns basic_auth.jwt_claims
|
|
||||||
language plpgsql
|
|
||||||
as $$
|
|
||||||
declare
|
|
||||||
_role name;
|
|
||||||
result basic_auth.jwt_claims;
|
|
||||||
begin
|
|
||||||
select basic_auth.user_role(email, pass) into _role;
|
|
||||||
if _role is null then
|
|
||||||
raise invalid_password using message = 'invalid user or password';
|
|
||||||
end if;
|
|
||||||
-- TODO; check verified flag if you care whether users
|
|
||||||
-- have validated their emails
|
|
||||||
select _role as role, login.email as email into result;
|
|
||||||
return result;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
signup(email text, pass text) returns void
|
|
||||||
as $$
|
|
||||||
insert into basic_auth.users (email, pass, role) values
|
|
||||||
(signup.email, signup.pass, 'author');
|
|
||||||
$$ language sql;
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
|
||||||
-- User management
|
|
||||||
|
|
||||||
create or replace view users as
|
|
||||||
select actual.role as role,
|
|
||||||
'***'::text as pass,
|
|
||||||
actual.email as email,
|
|
||||||
actual.verified as verified
|
|
||||||
from basic_auth.users as actual,
|
|
||||||
(select rolname
|
|
||||||
from pg_authid
|
|
||||||
where pg_has_role(current_user, oid, 'member')
|
|
||||||
) as member_of
|
|
||||||
where actual.role = member_of.rolname
|
|
||||||
and (
|
|
||||||
actual.role <> 'author'
|
|
||||||
or email = basic_auth.current_email()
|
|
||||||
);
|
|
||||||
|
|
||||||
create or replace function
|
|
||||||
update_users() returns trigger
|
|
||||||
language plpgsql
|
|
||||||
AS $$
|
|
||||||
begin
|
|
||||||
if tg_op = 'INSERT' then
|
|
||||||
perform basic_auth.clearance_for_role(new.role);
|
|
||||||
|
|
||||||
insert into basic_auth.users
|
|
||||||
(role, pass, email, verified) values
|
|
||||||
(coalesce(new.role, 'author'), new.pass,
|
|
||||||
new.email, coalesce(new.verified, false));
|
|
||||||
return new;
|
|
||||||
elsif tg_op = 'UPDATE' then
|
|
||||||
-- no need to check clearance for old.role because
|
|
||||||
-- an ineligible row would not even available to update (http 404)
|
|
||||||
perform basic_auth.clearance_for_role(new.role);
|
|
||||||
|
|
||||||
update basic_auth.users set
|
|
||||||
email = new.email,
|
|
||||||
role = new.role,
|
|
||||||
pass = new.pass,
|
|
||||||
verified = coalesce(new.verified, old.verified, false)
|
|
||||||
where email = old.email;
|
|
||||||
return new;
|
|
||||||
elsif tg_op = 'DELETE' then
|
|
||||||
-- no need to check clearance for old.role (see previous case)
|
|
||||||
|
|
||||||
delete from basic_auth.users
|
|
||||||
where basic_auth.email = old.email;
|
|
||||||
return null;
|
|
||||||
end if;
|
|
||||||
end
|
|
||||||
$$;
|
|
||||||
|
|
||||||
drop trigger if exists update_users on users;
|
|
||||||
create trigger update_users
|
|
||||||
instead of insert or update or delete on
|
|
||||||
users for each row execute procedure update_users();
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
|
||||||
-- Blogging stuff!
|
|
||||||
|
|
||||||
create table if not exists
|
|
||||||
posts (
|
|
||||||
id bigserial primary key,
|
|
||||||
title text not null,
|
|
||||||
body text not null,
|
|
||||||
author text not null references basic_auth.users (email)
|
|
||||||
on delete restrict on update cascade,
|
|
||||||
created_at timestamptz not null default current_date
|
|
||||||
);
|
|
||||||
|
|
||||||
create table if not exists
|
|
||||||
comments (
|
|
||||||
id bigserial primary key,
|
|
||||||
body text not null,
|
|
||||||
author text not null references basic_auth.users (email)
|
|
||||||
on delete restrict on update cascade,
|
|
||||||
post bigint not null references posts (id)
|
|
||||||
on delete cascade on update cascade,
|
|
||||||
created_at timestamptz not null default current_date
|
|
||||||
);
|
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
|
||||||
-- Permissions
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
grant author to anon;
|
|
||||||
grant select, insert, update, delete
|
|
||||||
on basic_auth.tokens, basic_auth.users to anon, author;
|
|
||||||
grant select, insert, update, delete
|
|
||||||
on table users, posts, comments to author;
|
|
||||||
grant usage, select on sequence posts_id_seq, comments_id_seq to author;
|
|
||||||
|
|
||||||
grant usage on schema public, basic_auth to anon, author;
|
|
||||||
|
|
||||||
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
|
|
||||||
drop policy if exists authors_eigenedit on posts;
|
|
||||||
create policy authors_eigenedit on posts
|
|
||||||
using (true)
|
|
||||||
with check (
|
|
||||||
author = basic_auth.current_email()
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
|
|
||||||
drop policy if exists authors_eigenedit on comments;
|
|
||||||
create policy authors_eigenedit on comments
|
|
||||||
using (true)
|
|
||||||
with check (
|
|
||||||
author = basic_auth.current_email()
|
|
||||||
);
|
|
||||||
|
|
||||||
commit;
|
|
||||||
+213
-129
@@ -1,26 +1,44 @@
|
|||||||
module PostgREST.ApiRequest where
|
{-|
|
||||||
|
Module : PostgREST.ApiRequest
|
||||||
|
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
|
||||||
|
-}
|
||||||
|
module PostgREST.ApiRequest ( ApiRequest(..)
|
||||||
|
, ApiRequestError(..)
|
||||||
|
, ContentType(..)
|
||||||
|
, Action(..)
|
||||||
|
, Target(..)
|
||||||
|
, PreferRepresentation (..)
|
||||||
|
, mutuallyAgreeable
|
||||||
|
, toHeader
|
||||||
|
, userApiRequest
|
||||||
|
, toMime
|
||||||
|
) where
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
import Protolude
|
||||||
import qualified Data.ByteString as BS
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString as BS
|
||||||
import qualified Data.Csv as CSV
|
import qualified Data.ByteString.Internal as BS (c2w)
|
||||||
import Data.List (find)
|
import qualified Data.ByteString.Lazy as BL
|
||||||
import qualified Data.HashMap.Strict as M
|
import qualified Data.Csv as CSV
|
||||||
import qualified Data.Set as S
|
import qualified Data.List as L
|
||||||
import Data.Maybe (fromMaybe, isJust, isNothing,
|
import Data.List (lookup, last)
|
||||||
listToMaybe, fromJust)
|
import qualified Data.HashMap.Strict as M
|
||||||
import Control.Monad (join)
|
import qualified Data.Set as S
|
||||||
import Data.Monoid ((<>))
|
import Data.Maybe (fromJust)
|
||||||
import Data.String.Conversions (cs)
|
import Control.Arrow ((***))
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
import qualified Data.Vector as V
|
import qualified Data.Vector as V
|
||||||
import Network.Wai (Request (..))
|
import Network.HTTP.Base (urlEncodeVars)
|
||||||
import Network.Wai.Parse (parseHttpAccept)
|
import Network.HTTP.Types.Header (hAuthorization, hContentType, Header)
|
||||||
import PostgREST.RangeQuery (NonnegRange, rangeRequested)
|
import Network.HTTP.Types.URI (parseSimpleQuery)
|
||||||
import PostgREST.Types (QualifiedIdentifier (..),
|
import Network.Wai (Request (..))
|
||||||
Schema, Payload(..),
|
import Network.Wai.Parse (parseHttpAccept)
|
||||||
UniformObjects(..))
|
import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset)
|
||||||
import Data.Ranged.Ranges (singletonRange)
|
import Data.Ranged.Boundaries
|
||||||
|
import PostgREST.Types (QualifiedIdentifier (..),
|
||||||
|
Schema,
|
||||||
|
PayloadJSON(..))
|
||||||
|
import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange)
|
||||||
|
|
||||||
type RequestBody = BL.ByteString
|
type RequestBody = BL.ByteString
|
||||||
|
|
||||||
@@ -28,17 +46,39 @@ 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
|
| ActionInspect
|
||||||
|
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 [Text]
|
||||||
-- | Enumeration of currently supported content types for
|
deriving Eq
|
||||||
-- route responses and upload payloads
|
-- | How to return the inserted data
|
||||||
data ContentType = ApplicationJSON | TextCSV deriving Eq
|
data PreferRepresentation = Full | HeadersOnly | None deriving Eq
|
||||||
instance Show ContentType where
|
--
|
||||||
show ApplicationJSON = "application/json"
|
-- | Enumeration of currently supported response content types
|
||||||
show TextCSV = "text/csv"
|
data ContentType = CTApplicationJSON | CTTextCSV | CTOpenAPI
|
||||||
|
| CTSingularJSON
|
||||||
|
| CTAny | CTOther BS.ByteString deriving Eq
|
||||||
|
|
||||||
|
data ApiRequestError = ErrorActionInappropriate
|
||||||
|
| ErrorInvalidBody ByteString
|
||||||
|
| ErrorInvalidRange
|
||||||
|
deriving (Show, Eq)
|
||||||
|
|
||||||
|
-- | Convert from ContentType to a full HTTP Header
|
||||||
|
toHeader :: ContentType -> Header
|
||||||
|
toHeader ct = (hContentType, toMime ct <> "; charset=utf-8")
|
||||||
|
|
||||||
|
-- | Convert from ContentType to a ByteString representing the mime type
|
||||||
|
toMime :: ContentType -> ByteString
|
||||||
|
toMime CTApplicationJSON = "application/json"
|
||||||
|
toMime CTTextCSV = "text/csv"
|
||||||
|
toMime CTOpenAPI = "application/openapi+json"
|
||||||
|
toMime CTSingularJSON = "application/vnd.pgrst.object+json"
|
||||||
|
toMime CTAny = "*/*"
|
||||||
|
toMime (CTOther ct) = ct
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
Describes what the user wants to do. This data type is a
|
Describes what the user wants to do. This data type is a
|
||||||
@@ -48,125 +88,169 @@ 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 :: Maybe NonnegRange
|
, iRange :: M.HashMap ByteString 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)
|
-- | Content types the client will accept, [CTAny] if no Accept header
|
||||||
, iAccepts :: Either BS.ByteString ContentType
|
, iAccepts :: [ContentType]
|
||||||
-- | Data sent by client and used for mutation actions
|
-- | Data sent by client and used for mutation actions
|
||||||
, iPayload :: Maybe Payload
|
, iPayload :: Maybe PayloadJSON
|
||||||
-- | If client wants created items echoed back
|
-- | If client wants created items echoed back
|
||||||
, iPreferRepresentation :: Bool
|
, iPreferRepresentation :: PreferRepresentation
|
||||||
-- | If client wants first row as raw object
|
-- | Pass all parameters as a single json object to a stored procedure
|
||||||
, iPreferSingular :: Bool
|
, iPreferSingleObjectParameter :: Bool
|
||||||
-- | Whether the client wants a result count (slower)
|
-- | Whether the client wants a result count (slower)
|
||||||
, iPreferCount :: Bool
|
, iPreferCount :: Bool
|
||||||
-- | Filters on the result ("id", "eq.10")
|
-- | Filters on the result ("id", "eq.10")
|
||||||
, iFilters :: [(String, String)]
|
, iFilters :: [(Text, Text)]
|
||||||
-- | &select parameter used to shape the response
|
-- | &select parameter used to shape the response
|
||||||
, iSelect :: String
|
, iSelect :: Text
|
||||||
-- | &order parameter
|
-- | &order parameters for each level
|
||||||
, iOrder :: Maybe String
|
, iOrder :: [(Text, Text)]
|
||||||
|
-- | Alphabetized (canonical) request query string for response URLs
|
||||||
|
, iCanonicalQS :: ByteString
|
||||||
|
-- | JSON Web Token
|
||||||
|
, iJWT :: 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 -> Either ApiRequestError ApiRequest
|
||||||
userApiRequest schema req reqBody =
|
userApiRequest schema req reqBody
|
||||||
let action = case method of
|
| isTargetingProc && method /= "POST" = Left ErrorActionInappropriate
|
||||||
"GET" -> ActionRead
|
| topLevelRange == emptyRange = Left ErrorInvalidRange
|
||||||
"POST" -> if isTargetingProc
|
| shouldParsePayload && isLeft payload = either (Left . ErrorInvalidBody . toS) undefined payload
|
||||||
then ActionInvoke
|
| otherwise = Right ApiRequest {
|
||||||
else ActionCreate
|
iAction = action
|
||||||
"PATCH" -> ActionUpdate
|
, iTarget = target
|
||||||
"DELETE" -> ActionDelete
|
, iRange = ranges
|
||||||
"OPTIONS" -> ActionInfo
|
, iAccepts = fromMaybe [CTAny] $
|
||||||
other -> ActionUnknown other
|
map decodeContentType . parseHttpAccept <$> lookupHeader "accept"
|
||||||
target = case path of
|
, iPayload = relevantPayload
|
||||||
[] -> TargetRoot
|
, iPreferRepresentation = representation
|
||||||
[table] -> TargetIdent
|
, iPreferSingleObjectParameter = singleObject
|
||||||
$ QualifiedIdentifier schema table
|
, iPreferCount = hasPrefer "count=exact"
|
||||||
["rpc", proc] -> TargetIdent
|
, iFilters = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset"] k) ]
|
||||||
$ QualifiedIdentifier schema proc
|
, iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
|
||||||
other -> TargetUnknown other
|
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
|
||||||
payload = case pickContentType (lookupHeader "content-type") of
|
, iCanonicalQS = toS $ urlEncodeVars
|
||||||
Right ApplicationJSON ->
|
. L.sortBy (comparing fst)
|
||||||
either (PayloadParseError . cs)
|
. map (join (***) toS)
|
||||||
(\val -> case ensureUniform (pluralize val) of
|
. parseSimpleQuery
|
||||||
Nothing -> PayloadParseError "All object keys must match"
|
$ rawQueryString req
|
||||||
Just json -> PayloadJSON json)
|
, iJWT = tokenStr
|
||||||
(JSON.eitherDecode reqBody)
|
}
|
||||||
Right TextCSV ->
|
|
||||||
either (PayloadParseError . cs)
|
|
||||||
(\val -> case ensureUniform (csvToJson val) of
|
|
||||||
Nothing -> PayloadParseError "All lines must have same number of fields"
|
|
||||||
Just json -> PayloadJSON json)
|
|
||||||
(CSV.decodeByName reqBody)
|
|
||||||
Left accept ->
|
|
||||||
PayloadParseError $
|
|
||||||
"Content-type not acceptable: " <> accept
|
|
||||||
relevantPayload = case action of
|
|
||||||
ActionCreate -> Just payload
|
|
||||||
ActionUpdate -> Just payload
|
|
||||||
ActionInvoke -> Just payload
|
|
||||||
_ -> Nothing in
|
|
||||||
|
|
||||||
ApiRequest {
|
|
||||||
iAction = action
|
|
||||||
, iRange = if singular then Just (singletonRange 0) else rangeRequested hdrs
|
|
||||||
, iTarget = target
|
|
||||||
, iAccepts = pickContentType $ lookupHeader "accept"
|
|
||||||
, iPayload = relevantPayload
|
|
||||||
, iPreferRepresentation = hasPrefer "return=representation"
|
|
||||||
, iPreferSingular = singular
|
|
||||||
, iPreferCount = not $ hasPrefer "count=none"
|
|
||||||
, iFilters = [ (k, fromJust v) | (k,v) <- qParams, k `notElem` ["select", "order"], isJust v ]
|
|
||||||
, iSelect = if method == "DELETE"
|
|
||||||
then "*"
|
|
||||||
else fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
|
|
||||||
, iOrder = join $ lookup "order" qParams
|
|
||||||
}
|
|
||||||
|
|
||||||
where
|
where
|
||||||
|
isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path
|
||||||
|
payload =
|
||||||
|
case decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type" of
|
||||||
|
CTApplicationJSON ->
|
||||||
|
either Left (\val -> case ensureUniform (pluralize val) of
|
||||||
|
Nothing -> Left "All object keys must match"
|
||||||
|
Just json -> Right json) (JSON.eitherDecode reqBody)
|
||||||
|
CTTextCSV ->
|
||||||
|
either Left (\val -> case ensureUniform (csvToJson val) of
|
||||||
|
Nothing -> Left "All lines must have same number of fields"
|
||||||
|
Just json -> Right json) (CSV.decodeByName reqBody)
|
||||||
|
CTOther "application/x-www-form-urlencoded" ->
|
||||||
|
Right . PayloadJSON . V.singleton . M.fromList
|
||||||
|
. map (toS *** JSON.String . toS) . parseSimpleQuery
|
||||||
|
$ toS reqBody
|
||||||
|
ct ->
|
||||||
|
Left $ toS $ "Content-Type not acceptable: " <> toMime ct
|
||||||
|
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges
|
||||||
|
action = case method of
|
||||||
|
"GET" -> if target == TargetRoot
|
||||||
|
then ActionInspect
|
||||||
|
else ActionRead
|
||||||
|
"POST" -> if isTargetingProc
|
||||||
|
then ActionInvoke
|
||||||
|
else ActionCreate
|
||||||
|
"PATCH" -> ActionUpdate
|
||||||
|
"DELETE" -> ActionDelete
|
||||||
|
"OPTIONS" -> ActionInfo
|
||||||
|
_ -> ActionInspect
|
||||||
|
target = case path of
|
||||||
|
[] -> TargetRoot
|
||||||
|
[table] -> TargetIdent
|
||||||
|
$ QualifiedIdentifier schema table
|
||||||
|
["rpc", proc] -> TargetProc
|
||||||
|
$ QualifiedIdentifier schema proc
|
||||||
|
other -> TargetUnknown other
|
||||||
|
shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionInvoke]
|
||||||
|
relevantPayload = if shouldParsePayload
|
||||||
|
then rightToMaybe payload
|
||||||
|
else Nothing
|
||||||
path = pathInfo req
|
path = pathInfo req
|
||||||
method = requestMethod req
|
method = requestMethod req
|
||||||
isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path
|
|
||||||
hdrs = requestHeaders req
|
hdrs = requestHeaders req
|
||||||
qParams = [(cs k, cs <$> v)|(k,v) <- queryString req]
|
qParams = [(toS k, v)|(k,v) <- queryString req]
|
||||||
lookupHeader = flip lookup hdrs
|
lookupHeader = flip lookup hdrs
|
||||||
hasPrefer val = any (\(h,v) -> h == "Prefer" && v == val) hdrs
|
hasPrefer :: Text -> Bool
|
||||||
singular = hasPrefer "plurality=singular"
|
hasPrefer val = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs
|
||||||
|
where
|
||||||
|
split :: BS.ByteString -> [Text]
|
||||||
|
split = map T.strip . T.split (==',') . toS
|
||||||
|
singleObject = hasPrefer "params=single-object"
|
||||||
|
representation
|
||||||
|
| hasPrefer "return=representation" = Full
|
||||||
|
| hasPrefer "return=minimal" = None
|
||||||
|
| otherwise = HeadersOnly
|
||||||
|
auth = fromMaybe "" $ lookupHeader hAuthorization
|
||||||
|
tokenStr = case T.split (== ' ') (toS auth) of
|
||||||
|
("Bearer" : t : _) -> t
|
||||||
|
_ -> ""
|
||||||
|
endingIn:: [Text] -> Text -> Bool
|
||||||
|
endingIn xx key = lastWord `elem` xx
|
||||||
|
where lastWord = last $ T.split (=='.') key
|
||||||
|
|
||||||
|
headerRange = rangeRequested hdrs
|
||||||
|
replaceLast x s = T.intercalate "." $ L.init (T.split (=='.') s) ++ [x]
|
||||||
|
limitParams :: M.HashMap ByteString NonnegRange
|
||||||
|
limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< (toS <$> v)) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]
|
||||||
|
offsetParams :: M.HashMap ByteString NonnegRange
|
||||||
|
offsetParams = M.fromList [(toS (replaceLast "limit" k), fromMaybe allRange (rangeGeq <$> (readMaybe =<< (toS <$> v)))) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]
|
||||||
|
|
||||||
|
urlRange = M.unionWith f limitParams offsetParams
|
||||||
|
where
|
||||||
|
f rl ro = Range (BoundaryBelow o) (BoundaryAbove $ o + l - 1)
|
||||||
|
where
|
||||||
|
l = fromMaybe 0 $ rangeLimit rl
|
||||||
|
o = rangeOffset ro
|
||||||
|
ranges = M.insert "limit" (rangeIntersection headerRange (fromMaybe allRange (M.lookup "limit" urlRange))) urlRange
|
||||||
|
|
||||||
|
{-|
|
||||||
|
Find the best match from a list of content types accepted by the
|
||||||
|
client in order of decreasing preference and a list of types
|
||||||
|
producible by the server. If there is no match but the client
|
||||||
|
accepts */* then return the top server pick.
|
||||||
|
-}
|
||||||
|
mutuallyAgreeable :: [ContentType] -> [ContentType] -> Maybe ContentType
|
||||||
|
mutuallyAgreeable sProduces cAccepts =
|
||||||
|
let exact = listToMaybe $ L.intersect cAccepts sProduces in
|
||||||
|
if isNothing exact && CTAny `elem` cAccepts
|
||||||
|
then listToMaybe sProduces
|
||||||
|
else exact
|
||||||
|
|
||||||
-- PRIVATE ---------------------------------------------------------------
|
-- PRIVATE ---------------------------------------------------------------
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
Picks a preferred content type from an Accept header (or from
|
Warning: discards MIME parameters
|
||||||
Content-Type as a degenerate case).
|
|
||||||
|
|
||||||
For example
|
|
||||||
text/csv -> TextCSV
|
|
||||||
*/* -> ApplicationJSON
|
|
||||||
text/csv, application/json -> TextCSV
|
|
||||||
application/json, text/csv -> ApplicationJSON
|
|
||||||
-}
|
-}
|
||||||
pickContentType :: Maybe BS.ByteString -> Either BS.ByteString ContentType
|
decodeContentType :: BS.ByteString -> ContentType
|
||||||
pickContentType accept
|
decodeContentType ct =
|
||||||
| isNothing accept || has ctAll || has ctJson = Right ApplicationJSON
|
case BS.takeWhile (/= BS.c2w ';') ct of
|
||||||
| has ctCsv = Right TextCSV
|
"application/json" -> CTApplicationJSON
|
||||||
| otherwise = Left accept'
|
"text/csv" -> CTTextCSV
|
||||||
where
|
"application/openapi+json" -> CTOpenAPI
|
||||||
ctAll = "*/*"
|
"application/vnd.pgrst.object+json" -> CTSingularJSON
|
||||||
ctCsv = "text/csv"
|
"application/vnd.pgrst.object" -> CTSingularJSON
|
||||||
ctJson = "application/json"
|
"*/*" -> CTAny
|
||||||
Just accept' = accept
|
ct' -> CTOther ct'
|
||||||
findInAccept = flip find $ parseHttpAccept accept'
|
|
||||||
has = isJust . findInAccept . BS.isPrefixOf
|
|
||||||
|
|
||||||
type CsvData = V.Vector (M.HashMap T.Text BL.ByteString)
|
type CsvData = V.Vector (M.HashMap Text BL.ByteString)
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
Converts CSV like
|
Converts CSV like
|
||||||
@@ -188,7 +272,7 @@ csvToJson (_, vals) =
|
|||||||
M.map (\str ->
|
M.map (\str ->
|
||||||
if str == "NULL"
|
if str == "NULL"
|
||||||
then JSON.Null
|
then JSON.Null
|
||||||
else JSON.String $ cs str
|
else JSON.String $ toS str
|
||||||
)
|
)
|
||||||
|
|
||||||
-- | Convert {foo} to [{foo}], leave arrays unchanged
|
-- | Convert {foo} to [{foo}], leave arrays unchanged
|
||||||
@@ -199,8 +283,8 @@ pluralize (JSON.Array arr) = arr
|
|||||||
pluralize _ = V.empty
|
pluralize _ = V.empty
|
||||||
|
|
||||||
-- | Test that Array contains only Objects having the same keys
|
-- | Test that Array contains only Objects having the same keys
|
||||||
-- and if so mark it as UniformObjects
|
-- and if so mark it as PayloadJSON
|
||||||
ensureUniform :: JSON.Array -> Maybe UniformObjects
|
ensureUniform :: JSON.Array -> Maybe PayloadJSON
|
||||||
ensureUniform arr =
|
ensureUniform arr =
|
||||||
let objs :: V.Vector JSON.Object
|
let objs :: V.Vector JSON.Object
|
||||||
objs = foldr -- filter non-objects, map to raw objects
|
objs = foldr -- filter non-objects, map to raw objects
|
||||||
@@ -213,5 +297,5 @@ ensureUniform arr =
|
|||||||
areKeysUniform = all (==canonicalKeys) keysPerObj in
|
areKeysUniform = all (==canonicalKeys) keysPerObj in
|
||||||
|
|
||||||
if (V.length objs == V.length arr) && areKeysUniform
|
if (V.length objs == V.length arr) && areKeysUniform
|
||||||
then Just (UniformObjects objs)
|
then Just (PayloadJSON objs)
|
||||||
else Nothing
|
else Nothing
|
||||||
|
|||||||
+267
-285
@@ -3,332 +3,314 @@
|
|||||||
{-# 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 qualified Data.ByteString.Char8 as BS
|
||||||
import Control.Monad (join)
|
import Data.IORef (IORef, readIORef)
|
||||||
import Data.Bifunctor (first)
|
import Data.Text (intercalate)
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import Data.Time.Clock.POSIX (POSIXTime)
|
||||||
import Data.Functor.Identity
|
|
||||||
import Data.List (find, sortBy, delete)
|
|
||||||
import Data.Maybe (fromMaybe, fromJust, mapMaybe)
|
|
||||||
import Data.Ord (comparing)
|
|
||||||
import Data.Ranged.Ranges (emptyRange)
|
|
||||||
import Data.String.Conversions (cs)
|
|
||||||
import Data.Text (Text, replace, strip)
|
|
||||||
import Data.Tree
|
|
||||||
|
|
||||||
import Text.Parsec.Error
|
import qualified Hasql.Pool as P
|
||||||
import Text.ParserCombinators.Parsec (parse)
|
import qualified Hasql.Transaction as HT
|
||||||
|
import qualified Hasql.Transaction.Sessions as HT
|
||||||
|
|
||||||
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 Web.JWT (binarySecret)
|
||||||
|
|
||||||
import Data.Aeson
|
|
||||||
import Data.Aeson.Types (emptyArray)
|
|
||||||
import Data.Monoid
|
|
||||||
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.ApiRequest ( ApiRequest(..), ContentType(..)
|
||||||
|
, Action(..), Target(..)
|
||||||
|
, PreferRepresentation (..)
|
||||||
|
, mutuallyAgreeable
|
||||||
|
, toHeader
|
||||||
|
, userApiRequest
|
||||||
|
, toMime
|
||||||
|
)
|
||||||
|
import PostgREST.Auth (jwtClaims, containsRole)
|
||||||
import PostgREST.Config (AppConfig (..))
|
import PostgREST.Config (AppConfig (..))
|
||||||
import PostgREST.Parsers
|
|
||||||
import PostgREST.DbStructure
|
import PostgREST.DbStructure
|
||||||
import PostgREST.RangeQuery
|
import PostgREST.DbRequestBuilder(readRequest, mutateRequest)
|
||||||
import PostgREST.ApiRequest (ApiRequest(..), ContentType(..)
|
import PostgREST.Error (errResponse, pgErrResponse, apiRequestErrResponse, singularityError)
|
||||||
, Action(..), Target(..)
|
import PostgREST.RangeQuery (allRange, rangeOffset)
|
||||||
, userApiRequest)
|
import PostgREST.Middleware
|
||||||
import PostgREST.Types
|
import PostgREST.QueryBuilder ( callProc
|
||||||
import PostgREST.Auth (tokenJWT)
|
|
||||||
import PostgREST.Error (errResponse)
|
|
||||||
|
|
||||||
import PostgREST.QueryBuilder ( asJson
|
|
||||||
, callProc
|
|
||||||
, addJoinConditions
|
|
||||||
, sourceSubqueryName
|
|
||||||
, requestToQuery
|
, requestToQuery
|
||||||
, addRelations
|
, requestToCountQuery
|
||||||
, createReadStatement
|
, createReadStatement
|
||||||
, createWriteStatement
|
, createWriteStatement
|
||||||
|
, ResultsWithCount
|
||||||
)
|
)
|
||||||
|
import PostgREST.Types
|
||||||
|
import PostgREST.OpenAPI
|
||||||
|
|
||||||
import Prelude
|
import Data.Function (id)
|
||||||
|
import Protolude hiding (intercalate, Proxy)
|
||||||
|
|
||||||
app :: DbStructure -> AppConfig -> RequestBody -> Request -> H.Tx P.Postgres s Response
|
postgrest :: AppConfig -> IORef DbStructure -> P.Pool -> IO POSIXTime ->
|
||||||
app dbStructure conf reqBody req =
|
Application
|
||||||
let
|
postgrest conf refDbStructure pool getTime =
|
||||||
-- TODO: blow up for Left values (there is a middleware that checks the headers)
|
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle in
|
||||||
contentType = either (const ApplicationJSON) id (iAccepts apiRequest)
|
|
||||||
contentTypeH = (hContentType, cs $ show contentType) in
|
|
||||||
|
|
||||||
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
|
middle $ \ req respond -> do
|
||||||
|
time <- getTime
|
||||||
|
body <- strictRequestBody req
|
||||||
|
dbStructure <- readIORef refDbStructure
|
||||||
|
|
||||||
(ActionRead, TargetIdent qi, Nothing) ->
|
response <- case userApiRequest (configSchema conf) req body of
|
||||||
case selectQuery of
|
Left err -> return $ apiRequestErrResponse err
|
||||||
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
Right apiRequest -> do
|
||||||
Right q -> do
|
let jwtSecret = binarySecret <$> configJwtSecret conf
|
||||||
let range = iRange apiRequest
|
eClaims = jwtClaims jwtSecret (iJWT apiRequest) time
|
||||||
singular = iPreferSingular apiRequest
|
authed = containsRole eClaims
|
||||||
stm = createReadStatement q range singular
|
handleReq = runWithClaims conf eClaims (app dbStructure conf) apiRequest
|
||||||
(iPreferCount apiRequest) (contentType == TextCSV)
|
txMode = transactionMode $ iAction apiRequest
|
||||||
if range == Just emptyRange
|
response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq
|
||||||
then return $ errResponse status416 "HTTP Range error"
|
return $ either (pgErrResponse authed) identity response
|
||||||
else do
|
respond response
|
||||||
row <- H.maybeEx stm
|
|
||||||
let (tableTotal, queryTotal, _ , body) = extractQueryResult row
|
|
||||||
if singular
|
|
||||||
then return $ if queryTotal <= 0
|
|
||||||
then responseLBS status404 [] ""
|
|
||||||
else responseLBS status200 [contentTypeH] (fromMaybe "{}" body)
|
|
||||||
else do
|
|
||||||
let frm = fromMaybe 0 $ rangeOffset <$> range
|
|
||||||
to = frm+queryTotal-1
|
|
||||||
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
|
|
||||||
[contentTypeH, contentRange,
|
|
||||||
("Content-Location",
|
|
||||||
"/" <> cs (qiName qi) <>
|
|
||||||
if Prelude.null canonical then "" else "?" <> cs canonical
|
|
||||||
)
|
|
||||||
] (fromMaybe "[]" body)
|
|
||||||
|
|
||||||
(ActionCreate, TargetIdent (QualifiedIdentifier _ table),
|
transactionMode :: Action -> H.Mode
|
||||||
Just payload@(PayloadJSON (UniformObjects rows))) ->
|
transactionMode ActionRead = HT.Read
|
||||||
case queries of
|
transactionMode ActionInfo = HT.Read
|
||||||
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
transactionMode _ = HT.Write
|
||||||
Right (sq,mq) -> do
|
|
||||||
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 stm = createWriteStatement sq mq isSingle (iPreferRepresentation apiRequest) pKeys (contentType == TextCSV) payload
|
|
||||||
row <- H.maybeEx stm
|
|
||||||
let (_, _, location, body) = extractQueryResult row
|
|
||||||
return $ responseLBS status201
|
|
||||||
[
|
|
||||||
contentTypeH,
|
|
||||||
(hLocation, "/" <> cs table <> "?" <> cs (fromMaybe "" location))
|
|
||||||
]
|
|
||||||
$ if iPreferRepresentation apiRequest then fromMaybe "[]" body else ""
|
|
||||||
|
|
||||||
(ActionUpdate, TargetIdent _, Just payload@(PayloadJSON _)) ->
|
app :: DbStructure -> AppConfig -> ApiRequest -> H.Transaction Response
|
||||||
case queries of
|
app dbStructure conf apiRequest =
|
||||||
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
case responseContentTypeOrError (iAccepts apiRequest) (iAction apiRequest) of
|
||||||
Right (sq,mq) -> do
|
Left errorResponse -> return errorResponse
|
||||||
let stm = createWriteStatement sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) payload
|
Right contentType ->
|
||||||
row <- H.maybeEx stm
|
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
|
||||||
let (_, queryTotal, _, body) = extractQueryResult row
|
|
||||||
r = contentRangeH 0 (queryTotal-1) (Just queryTotal)
|
|
||||||
s = case () of _ | queryTotal == 0 -> status404
|
|
||||||
| iPreferRepresentation apiRequest -> status200
|
|
||||||
| otherwise -> status204
|
|
||||||
return $ responseLBS s [contentTypeH, r]
|
|
||||||
$ if iPreferRepresentation apiRequest then fromMaybe "[]" body else ""
|
|
||||||
|
|
||||||
(ActionDelete, TargetIdent _, Nothing) ->
|
(ActionRead, TargetIdent qi, Nothing) ->
|
||||||
case queries of
|
case readSqlParts of
|
||||||
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
Left errorResponse -> return errorResponse
|
||||||
Right (sq,mq) -> do
|
Right (q, cq) -> do
|
||||||
let fakeload = PayloadJSON $ UniformObjects V.empty
|
let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount (contentType == CTTextCSV)
|
||||||
let stm = createWriteStatement sq mq False False [] (contentType == TextCSV) fakeload
|
row <- H.query () stm
|
||||||
row <- H.maybeEx stm
|
let (tableTotal, queryTotal, _ , body) = row
|
||||||
let (_, queryTotal, _, _) = extractQueryResult row
|
(status, contentRange) = rangeHeader queryTotal tableTotal
|
||||||
return $ if queryTotal == 0
|
canonical = iCanonicalQS apiRequest
|
||||||
then notFound
|
return $
|
||||||
else responseLBS status204 [("Content-Range", "*/"<> cs (show queryTotal))] ""
|
if contentType == CTSingularJSON && queryTotal /= 1
|
||||||
|
then singularityError (toInteger queryTotal)
|
||||||
|
else responseLBS status
|
||||||
|
[toHeader contentType, contentRange,
|
||||||
|
("Content-Location",
|
||||||
|
"/" <> toS (qiName qi) <>
|
||||||
|
if BS.null canonical then "" else "?" <> toS canonical
|
||||||
|
)
|
||||||
|
] (toS body)
|
||||||
|
|
||||||
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) -> do
|
(ActionCreate, TargetIdent (QualifiedIdentifier _ table), Just payload@(PayloadJSON rows)) ->
|
||||||
let cols = filter (filterCol tSchema tTable) $ dbColumns dbStructure
|
case mutateSqlParts of
|
||||||
pkeys = map pkName $ filter (filterPk tSchema tTable) allPrKeys
|
Left errorResponse -> return errorResponse
|
||||||
body = encode (TableOptions cols pkeys)
|
Right (sq, mq) -> do
|
||||||
filterCol :: Schema -> TableName -> Column -> Bool
|
let isSingle = (==1) $ V.length rows
|
||||||
filterCol sc tb (Column{colTable=Table{tableSchema=s, tableName=t}}) = s==sc && t==tb
|
if contentType == CTSingularJSON
|
||||||
filterCol _ _ _ = False
|
&& not isSingle
|
||||||
return $ responseLBS status200 [jsonH, allOrigins] $ cs body
|
&& iPreferRepresentation apiRequest == Full
|
||||||
|
then return $ singularityError (toInteger $ V.length rows)
|
||||||
|
else do
|
||||||
|
let pKeys = map pkName $ filter (filterPk schema table) allPrKeys -- would it be ok to move primary key detection in the query itself?
|
||||||
|
stm = createWriteStatement sq mq
|
||||||
|
(contentType == CTSingularJSON) isSingle
|
||||||
|
(contentType == CTTextCSV) (iPreferRepresentation apiRequest)
|
||||||
|
pKeys
|
||||||
|
row <- H.query payload stm
|
||||||
|
let (_, _, fs, body) = extractQueryResult row
|
||||||
|
headers = catMaybes [
|
||||||
|
if null fs
|
||||||
|
then Nothing
|
||||||
|
else Just (hLocation, "/" <> toS table <> renderLocationFields fs)
|
||||||
|
, if iPreferRepresentation apiRequest == Full
|
||||||
|
then Just $ toHeader contentType
|
||||||
|
else Nothing
|
||||||
|
, Just . contentRangeH 1 0 $
|
||||||
|
toInteger <$> if shouldCount then Just (V.length rows) else Nothing
|
||||||
|
]
|
||||||
|
|
||||||
(ActionInvoke, TargetIdent qi,
|
return . responseLBS status201 headers $
|
||||||
Just (PayloadJSON (UniformObjects payload))) -> do
|
if iPreferRepresentation apiRequest == Full
|
||||||
exists <- doesProcExist qi
|
then toS body else ""
|
||||||
if exists
|
|
||||||
then do
|
|
||||||
let p = V.head payload
|
|
||||||
call = B.Stmt "select " V.empty True <>
|
|
||||||
asJson (callProc qi p)
|
|
||||||
jwtSecret = configJwtSecret conf
|
|
||||||
|
|
||||||
bodyJson :: Maybe (Identity Value) <- H.maybeEx call
|
(ActionUpdate, TargetIdent _, Just payload) ->
|
||||||
returnJWT <- doesProcReturnJWT qi
|
case mutateSqlParts of
|
||||||
return $ responseLBS status200 [jsonH]
|
Left errorResponse -> return errorResponse
|
||||||
(let body = fromMaybe emptyArray $ runIdentity <$> bodyJson in
|
Right (sq, mq) -> do
|
||||||
if returnJWT
|
let stm = createWriteStatement sq mq
|
||||||
then "{\"token\":\"" <> cs (tokenJWT jwtSecret body) <> "\"}"
|
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
|
||||||
else cs $ encode body)
|
(iPreferRepresentation apiRequest) []
|
||||||
else return notFound
|
row <- H.query payload stm
|
||||||
|
let (_, queryTotal, _, body) = extractQueryResult row
|
||||||
|
if contentType == CTSingularJSON
|
||||||
|
&& queryTotal /= 1
|
||||||
|
&& iPreferRepresentation apiRequest == Full
|
||||||
|
then do
|
||||||
|
HT.condemn
|
||||||
|
return $ singularityError (toInteger queryTotal)
|
||||||
|
else do
|
||||||
|
let r = contentRangeH 0 (toInteger $ queryTotal-1)
|
||||||
|
(toInteger <$> if shouldCount then Just queryTotal else Nothing)
|
||||||
|
s = if iPreferRepresentation apiRequest == Full
|
||||||
|
then status200
|
||||||
|
else status204
|
||||||
|
return $ if iPreferRepresentation apiRequest == Full
|
||||||
|
then responseLBS s [toHeader contentType, r] (toS body)
|
||||||
|
else responseLBS s [r] ""
|
||||||
|
|
||||||
(ActionRead, TargetRoot, Nothing) -> do
|
(ActionDelete, TargetIdent _, Nothing) ->
|
||||||
body <- encode <$> accessibleTables (filter ((== cs schema) . tableSchema) (dbTables dbStructure))
|
case mutateSqlParts of
|
||||||
return $ responseLBS status200 [jsonH] $ cs body
|
Left errorResponse -> return errorResponse
|
||||||
|
Right (sq, mq) -> do
|
||||||
|
let emptyPayload = PayloadJSON V.empty
|
||||||
|
stm = createWriteStatement sq mq
|
||||||
|
(contentType == CTSingularJSON) False
|
||||||
|
(contentType == CTTextCSV)
|
||||||
|
(iPreferRepresentation apiRequest) []
|
||||||
|
row <- H.query emptyPayload stm
|
||||||
|
let (_, queryTotal, _, body) = extractQueryResult row
|
||||||
|
r = contentRangeH 1 0 $
|
||||||
|
toInteger <$> if shouldCount then Just queryTotal else Nothing
|
||||||
|
if contentType == CTSingularJSON
|
||||||
|
&& queryTotal /= 1
|
||||||
|
&& iPreferRepresentation apiRequest == Full
|
||||||
|
then do
|
||||||
|
HT.condemn
|
||||||
|
return $ singularityError (toInteger queryTotal)
|
||||||
|
else
|
||||||
|
return $ if iPreferRepresentation apiRequest == Full
|
||||||
|
then responseLBS status200 [toHeader contentType, r] (toS body)
|
||||||
|
else responseLBS status204 [r] ""
|
||||||
|
|
||||||
(ActionUnknown _, _, _) -> return notFound
|
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->
|
||||||
|
let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in
|
||||||
|
case mTable of
|
||||||
|
Nothing -> return notFound
|
||||||
|
Just table ->
|
||||||
|
let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in
|
||||||
|
return $ responseLBS status200 [allOrigins, acceptH] ""
|
||||||
|
|
||||||
(_, TargetUnknown _, _) -> return notFound
|
(ActionInvoke, TargetProc qi, Just (PayloadJSON payload)) ->
|
||||||
|
case readSqlParts of
|
||||||
|
Left errorResponse -> return errorResponse
|
||||||
|
Right (q, cq) -> do
|
||||||
|
let p = V.head payload
|
||||||
|
singular = contentType == CTSingularJSON
|
||||||
|
paramsAsSingleObject = iPreferSingleObjectParameter apiRequest
|
||||||
|
row <- H.query () (callProc qi p q cq topLevelRange shouldCount singular paramsAsSingleObject)
|
||||||
|
let (tableTotal, queryTotal, body) =
|
||||||
|
fromMaybe (Just 0, 0, "[]") row
|
||||||
|
(status, contentRange) = rangeHeader queryTotal tableTotal
|
||||||
|
if singular && queryTotal /= 1
|
||||||
|
then do
|
||||||
|
HT.condemn
|
||||||
|
return $ singularityError (toInteger queryTotal)
|
||||||
|
else return $ responseLBS status [jsonH, contentRange] (toS body)
|
||||||
|
|
||||||
(_, _, Just (PayloadParseError e)) ->
|
(ActionInspect, TargetRoot, Nothing) -> do
|
||||||
return $ responseLBS status400 [jsonH] $
|
let host = configHost conf
|
||||||
cs (formatGeneralError "Cannot parse request payload" (cs e))
|
port = toInteger $ configPort conf
|
||||||
|
proxy = pickProxy $ toS <$> configProxyUri conf
|
||||||
|
uri Nothing = ("http", host, port, "/")
|
||||||
|
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
|
||||||
|
uri' = uri proxy
|
||||||
|
encodeApi ti = encodeOpenAPI (map snd $ dbProcs dbStructure) ti uri'
|
||||||
|
body <- encodeApi . toTableInfo <$> H.query schema accessibleTables
|
||||||
|
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
|
||||||
|
|
||||||
(_, _, _) -> return notFound
|
_ -> return notFound
|
||||||
|
|
||||||
where
|
where
|
||||||
notFound = responseLBS status404 [] ""
|
toTableInfo :: [Table] -> [(Table, [Column], [Text])]
|
||||||
filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
|
toTableInfo = map (\t ->
|
||||||
allPrKeys = dbPrimaryKeys dbStructure
|
let tSchema = tableSchema t
|
||||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
tTable = tableName t
|
||||||
schema = cs $ configSchema conf
|
cols = filter (filterCol tSchema tTable) $ dbColumns dbStructure
|
||||||
apiRequest = userApiRequest schema req reqBody
|
pkeys = map pkName $ filter (filterPk tSchema tTable) allPrKeys
|
||||||
selectQuery = requestToQuery schema <$> (DbRead <$> buildReadRequest (dbRelations dbStructure) apiRequest)
|
in (t, cols, pkeys))
|
||||||
mutateQuery = requestToQuery schema <$> (DbMutate <$> buildMutateRequest apiRequest)
|
notFound = responseLBS status404 [] ""
|
||||||
queries = (,) <$> selectQuery <*> mutateQuery
|
filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
|
||||||
|
filterCol :: Schema -> TableName -> Column -> Bool
|
||||||
|
filterCol sc tb Column{colTable=Table{tableSchema=s, tableName=t}} = s==sc && t==tb
|
||||||
|
filterCol _ _ _ = False
|
||||||
|
allPrKeys = dbPrimaryKeys dbStructure
|
||||||
|
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
||||||
|
jsonH = toHeader CTApplicationJSON
|
||||||
|
shouldCount = iPreferCount apiRequest
|
||||||
|
schema = toS $ configSchema conf
|
||||||
|
topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest
|
||||||
|
rangeHeader queryTotal tableTotal =
|
||||||
|
let lower = rangeOffset topLevelRange
|
||||||
|
upper = lower + toInteger queryTotal - 1
|
||||||
|
contentRange = contentRangeH lower upper (toInteger <$> tableTotal)
|
||||||
|
status = rangeStatus lower upper (toInteger <$> tableTotal)
|
||||||
|
in (status, contentRange)
|
||||||
|
|
||||||
rangeStatus :: Int -> Int -> Maybe Int -> Status
|
mapSnd f (a, b) = (a, f b)
|
||||||
|
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) (map (mapSnd pdReturnType) $ dbProcs dbStructure) apiRequest
|
||||||
|
readDbRequest = DbRead <$> readReq
|
||||||
|
mutateDbRequest = DbMutate <$> (mutateRequest apiRequest =<< readReq)
|
||||||
|
selectQuery = requestToQuery schema False <$> readDbRequest
|
||||||
|
mutateQuery = requestToQuery schema False <$> mutateDbRequest
|
||||||
|
countQuery = requestToCountQuery schema <$> readDbRequest
|
||||||
|
readSqlParts = (,) <$> selectQuery <*> countQuery
|
||||||
|
mutateSqlParts = (,) <$> selectQuery <*> mutateQuery
|
||||||
|
|
||||||
|
responseContentTypeOrError :: [ContentType] -> Action -> Either Response ContentType
|
||||||
|
responseContentTypeOrError accepts action = serves contentTypesForRequest accepts
|
||||||
|
where
|
||||||
|
contentTypesForRequest =
|
||||||
|
case action of
|
||||||
|
ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
|
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
|
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
|
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
|
ActionInvoke -> [CTApplicationJSON, CTSingularJSON]
|
||||||
|
ActionInspect -> [CTOpenAPI]
|
||||||
|
ActionInfo -> [CTTextCSV]
|
||||||
|
serves sProduces cAccepts =
|
||||||
|
case mutuallyAgreeable sProduces cAccepts of
|
||||||
|
Nothing -> do
|
||||||
|
let failed = intercalate ", " $ map (toS . toMime) cAccepts
|
||||||
|
Left $ errResponse status415 $
|
||||||
|
"None of these Content-Types are available: " <> failed
|
||||||
|
Just ct -> Right ct
|
||||||
|
|
||||||
|
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 lower upper (Just total)
|
||||||
| frm > total = status416
|
| lower > total = status416
|
||||||
| (1 + to - frm) < total = status206
|
| (1 + upper - lower) < total = status206
|
||||||
| otherwise = status200
|
| otherwise = status200
|
||||||
|
|
||||||
contentRangeH :: Int -> Int -> Maybe Int -> Header
|
contentRangeH :: Integer -> Integer -> Maybe Integer -> Header
|
||||||
contentRangeH frm to total =
|
contentRangeH lower upper total =
|
||||||
("Content-Range", cs headerValue)
|
("Content-Range", headerValue)
|
||||||
where
|
where
|
||||||
headerValue = rangeString <> "/" <> totalString
|
headerValue = rangeString <> "/" <> totalString
|
||||||
rangeString
|
rangeString
|
||||||
| totalNotZero && fromInRange = show frm <> "-" <> cs (show to)
|
| totalNotZero && fromInRange = show lower <> "-" <> show upper
|
||||||
| otherwise = "*"
|
| otherwise = "*"
|
||||||
totalString = fromMaybe "*" (show <$> total)
|
totalString = fromMaybe "*" (show <$> total)
|
||||||
totalNotZero = fromMaybe True ((/=) 0 <$> total)
|
totalNotZero = fromMaybe True ((/=) 0 <$> total)
|
||||||
fromInRange = frm <= to
|
fromInRange = lower <= upper
|
||||||
|
|
||||||
jsonH :: Header
|
extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount
|
||||||
jsonH = (hContentType, "application/json")
|
extractQueryResult = fromMaybe (Nothing, 0, [], "")
|
||||||
|
|
||||||
formatRelationError :: Text -> Text
|
|
||||||
formatRelationError = formatGeneralError
|
|
||||||
"could not find foreign keys between these entities"
|
|
||||||
|
|
||||||
formatParserError :: ParseError -> Text
|
|
||||||
formatParserError e = formatGeneralError message details
|
|
||||||
where
|
|
||||||
message = cs $ show (errorPos e)
|
|
||||||
details = strip $ replace "\n" " " $ cs
|
|
||||||
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
|
|
||||||
|
|
||||||
formatGeneralError :: Text -> Text -> Text
|
|
||||||
formatGeneralError message details = cs $ encode $ object [
|
|
||||||
"message" .= message,
|
|
||||||
"details" .= details]
|
|
||||||
|
|
||||||
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either Text ReadRequest
|
|
||||||
augumentRequestWithJoin schema allRels request =
|
|
||||||
(first formatRelationError . addRelations schema allRels Nothing) request
|
|
||||||
>>= addJoinConditions schema
|
|
||||||
|
|
||||||
buildReadRequest :: [Relation] -> ApiRequest -> Either Text ReadRequest
|
|
||||||
buildReadRequest allRels apiRequest =
|
|
||||||
augumentRequestWithJoin schema rels =<< first formatParserError (foldr addFilter <$> (addOrder <$> readRequest <*> ord) <*> flts)
|
|
||||||
where
|
|
||||||
selStr = iSelect apiRequest
|
|
||||||
orderS = iOrder apiRequest
|
|
||||||
action = iAction apiRequest
|
|
||||||
target = iTarget apiRequest
|
|
||||||
(schema, rootTableName) = fromJust $ -- Make it safe
|
|
||||||
case target of
|
|
||||||
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
|
|
||||||
_ -> Nothing
|
|
||||||
|
|
||||||
rootName = if action == ActionRead
|
|
||||||
then rootTableName
|
|
||||||
else sourceSubqueryName
|
|
||||||
filters = 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
|
|
||||||
rels = case action of
|
|
||||||
ActionCreate -> fakeSourceRelations ++ allRels
|
|
||||||
ActionUpdate -> fakeSourceRelations ++ allRels
|
|
||||||
_ -> allRels
|
|
||||||
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 =
|
|
||||||
mutateApiRequest
|
|
||||||
where
|
|
||||||
action = iAction apiRequest
|
|
||||||
target = iTarget apiRequest
|
|
||||||
payload = fromJust $ iPayload apiRequest
|
|
||||||
rootTableName = -- TODO: Make it safe
|
|
||||||
case target of
|
|
||||||
(TargetIdent (QualifiedIdentifier _ t) ) -> t
|
|
||||||
_ -> undefined
|
|
||||||
mutateApiRequest = case action of
|
|
||||||
ActionCreate -> Insert rootTableName <$> pure payload
|
|
||||||
ActionUpdate -> Update rootTableName <$> pure payload <*> cond
|
|
||||||
ActionDelete -> Delete rootTableName <$> cond
|
|
||||||
_ -> Left "Unsupported HTTP verb"
|
|
||||||
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 ([], flt) (Node (q@(Select {flt_=flts}), i) forest) = Node (q {flt_=flt:flts}, i) forest
|
|
||||||
addFilter (path, flt) (Node rn forest) =
|
|
||||||
case targetNode of
|
|
||||||
Nothing -> Node rn forest -- the filter is silenty dropped in the Request does not contain the required path
|
|
||||||
Just tn -> Node rn (addFilter (remainingPath, flt) tn:restForest)
|
|
||||||
where
|
|
||||||
targetNodeName:remainingPath = path
|
|
||||||
(targetNode,restForest) = splitForest targetNodeName forest
|
|
||||||
splitForest name forst =
|
|
||||||
case maybeNode of
|
|
||||||
Nothing -> (Nothing,forest)
|
|
||||||
Just node -> (Just node, delete node forest)
|
|
||||||
where maybeNode = find ((name==).fst.snd.rootLabel) forst
|
|
||||||
|
|
||||||
-- in a relation where one of the tables mathces "TableName"
|
|
||||||
-- replace the name to that table with pg_source
|
|
||||||
-- this "fake" relations is needed so that in a mutate query
|
|
||||||
-- we can look a the "returning *" part which is wrapped with a "with"
|
|
||||||
-- as just another table that has relations with other tables
|
|
||||||
toSourceRelation :: TableName -> Relation -> Maybe Relation
|
|
||||||
toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
|
|
||||||
| mt == tableName t = Just $ r {relTable=t {tableName=sourceSubqueryName}}
|
|
||||||
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceSubqueryName}}
|
|
||||||
| Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceSubqueryName}) <$> rt}
|
|
||||||
| otherwise = Nothing
|
|
||||||
|
|
||||||
data TableOptions = TableOptions {
|
|
||||||
tblOptcolumns :: [Column]
|
|
||||||
, tblOptpkey :: [Text]
|
|
||||||
}
|
|
||||||
|
|
||||||
instance ToJSON TableOptions where
|
|
||||||
toJSON t = object [
|
|
||||||
"columns" .= tblOptcolumns t
|
|
||||||
, "pkey" .= tblOptpkey t ]
|
|
||||||
|
|
||||||
|
|
||||||
extractQueryResult :: Maybe (Maybe Int, Int, Maybe BL.ByteString, Maybe BL.ByteString)
|
|
||||||
-> (Maybe Int, Int, Maybe BL.ByteString, Maybe BL.ByteString)
|
|
||||||
extractQueryResult = fromMaybe (Just 0, 0, Just "", Just "")
|
|
||||||
|
|||||||
+64
-51
@@ -12,73 +12,86 @@ 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
|
||||||
|
, JWTAttempt(..)
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Monad (join)
|
import Protolude
|
||||||
import Data.Aeson (Value (..), Object)
|
import Control.Lens
|
||||||
import Data.Aeson.Types (emptyObject, emptyArray)
|
import Data.Aeson (Value (..), parseJSON, toJSON)
|
||||||
import Data.Vector as V (null, head)
|
import Data.Aeson.Lens
|
||||||
import Data.Map as M (fromList, toList)
|
import Data.Aeson.Types (parseMaybe, emptyObject, emptyArray)
|
||||||
import Data.Monoid ((<>))
|
import qualified Data.Vector as V
|
||||||
import Data.String.Conversions (cs)
|
import qualified Data.HashMap.Strict as M
|
||||||
import Data.Text (Text)
|
import Data.Maybe (fromJust)
|
||||||
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 -> [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 <> ";") . toS . valueToVariable <$> M.lookup "role" claims
|
||||||
" = " <> valueToVariable val <> ";"
|
varStmts = map setVar $ M.toList (M.delete "role" claims)
|
||||||
valueToVariable = pgFmtLit . unquoted
|
setVar (k, val) = "set local " <> toS (pgFmtIdent $ "request.jwt.claim." <> k)
|
||||||
|
<> " = " <> toS (valueToVariable val) <> ";"
|
||||||
|
valueToVariable = pgFmtLit . unquoted
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
Receives the JWT secret (from config) and a JWT and
|
Possible situations encountered with client JWTs
|
||||||
returns a map of JWT claims
|
|
||||||
In case there is any problem decoding the JWT it returns Nothing.
|
|
||||||
-}
|
-}
|
||||||
jwtClaims :: JWT.Secret -> Text -> NominalDiffTime -> Maybe JWT.ClaimsMap
|
data JWTAttempt = JWTExpired
|
||||||
jwtClaims secret input time =
|
| JWTInvalid
|
||||||
case join $ claim JWT.exp of
|
| JWTMissingSecret
|
||||||
Just expires ->
|
| JWTClaims (M.HashMap Text Value)
|
||||||
if JWT.secondsSinceEpoch expires > time
|
deriving Eq
|
||||||
then customClaims
|
|
||||||
else Nothing
|
|
||||||
_ -> customClaims
|
|
||||||
where
|
|
||||||
decoded = JWT.decodeAndVerifySignature secret input
|
|
||||||
claim :: (JWT.JWTClaimsSet -> a) -> Maybe a
|
|
||||||
claim prop = prop . JWT.claims <$> decoded
|
|
||||||
customClaims = claim JWT.unregisteredClaims
|
|
||||||
|
|
||||||
-- | 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 returns a map
|
||||||
|
of JWT claims.
|
||||||
|
-}
|
||||||
|
jwtClaims :: Maybe JWT.Secret -> Text -> NominalDiffTime -> JWTAttempt
|
||||||
|
jwtClaims _ "" _ = JWTClaims M.empty
|
||||||
|
jwtClaims secret jwt time =
|
||||||
|
case secret of
|
||||||
|
Nothing -> JWTMissingSecret
|
||||||
|
Just s ->
|
||||||
|
let mClaims = toJSON . JWT.claims <$> JWT.decodeAndVerifySignature s jwt in
|
||||||
|
case isExpired <$> mClaims of
|
||||||
|
Just True -> JWTExpired
|
||||||
|
Nothing -> JWTInvalid
|
||||||
|
Just False -> JWTClaims $ value2map $ fromJust mClaims
|
||||||
|
where
|
||||||
|
isExpired claims =
|
||||||
|
let mExp = claims ^? key "exp" . _Integer
|
||||||
|
in fromMaybe False $ (<= time) . fromInteger <$> mExp
|
||||||
|
value2map (Object o) = o
|
||||||
|
value2map _ = M.empty
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
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 :: JWTAttempt -> Bool
|
||||||
|
containsRole (JWTClaims claims) = M.member "role" claims
|
||||||
|
containsRole _ = False
|
||||||
|
|||||||
+132
-43
@@ -2,12 +2,13 @@
|
|||||||
Module : PostgREST.Config
|
Module : PostgREST.Config
|
||||||
Description : Manages PostgREST configuration options.
|
Description : Manages PostgREST configuration options.
|
||||||
|
|
||||||
This module provides a helper function to read the command line arguments using the optparse-applicative
|
This module provides a helper function to read the command line
|
||||||
and the AppConfig type to store them.
|
arguments using the optparse-applicative and the AppConfig type to store
|
||||||
It also can be used to define other middleware configuration that may be delegated to some sort of
|
them. It also can be used to define other middleware configuration that
|
||||||
external configuration.
|
may be delegated to some sort of external configuration.
|
||||||
|
|
||||||
It currently includes a hardcoded CORS policy but this could easly be turned in configurable behaviour if needed.
|
It currently includes a hardcoded CORS policy but this could easly be
|
||||||
|
turned in configurable behaviour if needed.
|
||||||
|
|
||||||
Other hardcoded options such as the minimum version number also belong here.
|
Other hardcoded options such as the minimum version number also belong here.
|
||||||
-}
|
-}
|
||||||
@@ -15,45 +16,51 @@ module PostgREST.Config ( prettyVersion
|
|||||||
, readOptions
|
, readOptions
|
||||||
, corsPolicy
|
, corsPolicy
|
||||||
, minimumPgVersion
|
, minimumPgVersion
|
||||||
|
, PgVersion (..)
|
||||||
, AppConfig (..)
|
, AppConfig (..)
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
|
|
||||||
|
import System.IO.Error (IOError)
|
||||||
import Control.Applicative
|
import Control.Applicative
|
||||||
|
import qualified Data.ByteString as B
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import qualified Data.CaseInsensitive as CI
|
import qualified Data.CaseInsensitive as CI
|
||||||
import Data.List (intercalate)
|
import qualified Data.Configurator as C
|
||||||
import Data.String.Conversions (cs)
|
import qualified Data.Configurator.Types as C
|
||||||
import Data.Text (strip)
|
import Data.List (lookup)
|
||||||
|
import Data.Text (strip, intercalate, lines)
|
||||||
|
import Data.Text.Encoding (encodeUtf8)
|
||||||
|
import Data.Text.IO (hPutStrLn)
|
||||||
import Data.Version (versionBranch)
|
import Data.Version (versionBranch)
|
||||||
import Network.Wai
|
import Network.Wai
|
||||||
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
||||||
import Options.Applicative
|
import Options.Applicative hiding (str)
|
||||||
import Paths_postgrest (version)
|
import Paths_postgrest (version)
|
||||||
import Web.JWT (Secret, secret)
|
import Text.Heredoc
|
||||||
import Prelude
|
import Text.PrettyPrint.ANSI.Leijen hiding ((<>), (<$>))
|
||||||
|
|
||||||
-- | Data type to store all command line options
|
import Protolude hiding (intercalate
|
||||||
|
, (<>))
|
||||||
|
|
||||||
|
-- | Config file settings for the server
|
||||||
data AppConfig = AppConfig {
|
data AppConfig = AppConfig {
|
||||||
configDatabase :: String
|
configDatabase :: Text
|
||||||
, configPort :: Int
|
, configAnonRole :: Text
|
||||||
, configAnonRole :: String
|
, configProxyUri :: Maybe Text
|
||||||
, configSchema :: String
|
, configSchema :: Text
|
||||||
, configJwtSecret :: Secret
|
, configHost :: Text
|
||||||
, configPool :: Int
|
, configPort :: Int
|
||||||
|
|
||||||
|
, configJwtSecret :: Maybe B.ByteString
|
||||||
|
, configJwtSecretIsBase64 :: Bool
|
||||||
|
|
||||||
|
, configPool :: Int
|
||||||
|
, configMaxRows :: Maybe Integer
|
||||||
|
, configReqCheck :: Maybe Text
|
||||||
|
, configQuiet :: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
argParser :: Parser AppConfig
|
|
||||||
argParser = AppConfig
|
|
||||||
<$> argument str (help "database connection string" <> metavar "STRING")
|
|
||||||
|
|
||||||
<*> 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 "1" <> showDefault)
|
|
||||||
<*> (secret . cs <$>
|
|
||||||
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)
|
|
||||||
|
|
||||||
defaultCorsPolicy :: CorsResourcePolicy
|
defaultCorsPolicy :: CorsResourcePolicy
|
||||||
defaultCorsPolicy = CorsResourcePolicy Nothing
|
defaultCorsPolicy = CorsResourcePolicy Nothing
|
||||||
["GET", "POST", "PATCH", "DELETE", "OPTIONS"] ["Authorization"] Nothing
|
["GET", "POST", "PATCH", "DELETE", "OPTIONS"] ["Authorization"] Nothing
|
||||||
@@ -74,26 +81,108 @@ corsPolicy req = case lookup "origin" headers of
|
|||||||
where
|
where
|
||||||
headers = requestHeaders req
|
headers = requestHeaders req
|
||||||
accHeaders = case lookup "access-control-request-headers" headers of
|
accHeaders = case lookup "access-control-request-headers" headers of
|
||||||
Just hdrs -> map (CI.mk . cs . strip . cs) $ BS.split ',' hdrs
|
Just hdrs -> map (CI.mk . toS . strip . toS) $ BS.split ',' hdrs
|
||||||
Nothing -> []
|
Nothing -> []
|
||||||
|
|
||||||
-- | User friendly version number
|
-- | User friendly version number
|
||||||
prettyVersion :: String
|
prettyVersion :: Text
|
||||||
prettyVersion = intercalate "." $ map show $ versionBranch version
|
prettyVersion = intercalate "." $ map show $ versionBranch version
|
||||||
|
|
||||||
-- | Function to read and parse options from the command line
|
-- | Function to read and parse options from the command line
|
||||||
readOptions :: IO AppConfig
|
readOptions :: IO AppConfig
|
||||||
readOptions = customExecParser parserPrefs opts
|
readOptions = do
|
||||||
where
|
-- First read the config file path from command line
|
||||||
opts = info (helper <*> argParser) $
|
cfgPath <- customExecParser parserPrefs opts
|
||||||
fullDesc
|
-- Now read the actual config file
|
||||||
<> progDesc (
|
conf <- catch
|
||||||
"PostgREST "
|
(C.load [C.Required cfgPath])
|
||||||
<> prettyVersion
|
configNotfoundHint
|
||||||
<> " / create a REST API to an existing Postgres database"
|
|
||||||
)
|
handle missingKeyHint $ do
|
||||||
parserPrefs = prefs showHelpOnError
|
-- db ----------------
|
||||||
|
cDbUri <- C.require conf "db-uri"
|
||||||
|
cDbSchema <- C.require conf "db-schema"
|
||||||
|
cDbAnon <- C.require conf "db-anon-role"
|
||||||
|
cPool <- C.lookupDefault 10 conf "db-pool"
|
||||||
|
-- server ------------
|
||||||
|
cHost <- C.lookupDefault "*4" conf "server-host"
|
||||||
|
cPort <- C.lookupDefault 3000 conf "server-port"
|
||||||
|
cProxy <- C.lookup conf "server-proxy-uri"
|
||||||
|
-- jwt ---------------
|
||||||
|
cJwtSec <- C.lookup conf "jwt-secret"
|
||||||
|
cJwtB64 <- C.lookupDefault False conf "secret-is-base64"
|
||||||
|
-- safety ------------
|
||||||
|
cMaxRows <- C.lookup conf "max-rows"
|
||||||
|
cReqCheck <- C.lookup conf "pre-request"
|
||||||
|
|
||||||
|
return $ AppConfig cDbUri cDbAnon cProxy cDbSchema cHost cPort
|
||||||
|
(encodeUtf8 <$> cJwtSec) cJwtB64 cPool cMaxRows cReqCheck False
|
||||||
|
|
||||||
|
where
|
||||||
|
opts = info (helper <*> pathParser) $
|
||||||
|
fullDesc
|
||||||
|
<> progDesc (
|
||||||
|
"PostgREST "
|
||||||
|
<> toS prettyVersion
|
||||||
|
<> " / create a REST API to an existing Postgres database"
|
||||||
|
)
|
||||||
|
<> footerDoc (Just $
|
||||||
|
text "Example Config File:"
|
||||||
|
<> nest 2 (hardline <> exampleCfg)
|
||||||
|
)
|
||||||
|
|
||||||
|
parserPrefs = prefs showHelpOnError
|
||||||
|
|
||||||
|
configNotfoundHint :: IOError -> IO a
|
||||||
|
configNotfoundHint e = do
|
||||||
|
hPutStrLn stderr $
|
||||||
|
"Cannot open config file:\n\t" <> show e
|
||||||
|
exitFailure
|
||||||
|
|
||||||
|
missingKeyHint :: C.KeyError -> IO a
|
||||||
|
missingKeyHint (C.KeyError n) = do
|
||||||
|
hPutStrLn stderr $
|
||||||
|
"Required config parameter \"" <> n <> "\" is missing or of wrong type.\n" <>
|
||||||
|
"Try the --example-config option to see how to configure PostgREST."
|
||||||
|
exitFailure
|
||||||
|
|
||||||
|
exampleCfg :: Doc
|
||||||
|
exampleCfg = vsep . map (text . toS) . lines $
|
||||||
|
[str|db-uri = "postgres://user:pass@localhost:5432/dbname"
|
||||||
|
|db-schema = "public"
|
||||||
|
|db-anon-role = "postgres"
|
||||||
|
|db-pool = 10
|
||||||
|
|
|
||||||
|
|server-host = "*4"
|
||||||
|
|server-port = 3000
|
||||||
|
|
|
||||||
|
|## base url for swagger output
|
||||||
|
|# server-proxy-uri = ""
|
||||||
|
|
|
||||||
|
|## choose a secret to enable JWT auth
|
||||||
|
|## (use "@filename" to load from separate file)
|
||||||
|
|# jwt-secret = "foo"
|
||||||
|
|# secret-is-base64 = false
|
||||||
|
|
|
||||||
|
|## limit rows in response
|
||||||
|
|# max-rows = 1000
|
||||||
|
|
|
||||||
|
|## stored proc to exec immediately after auth
|
||||||
|
|# pre-request = "stored_proc_name"
|
||||||
|
|]
|
||||||
|
|
||||||
|
|
||||||
|
pathParser :: Parser FilePath
|
||||||
|
pathParser =
|
||||||
|
strArgument $
|
||||||
|
metavar "FILENAME" <>
|
||||||
|
help "Path to configuration file"
|
||||||
|
|
||||||
|
data PgVersion = PgVersion {
|
||||||
|
pgvNum :: Int32
|
||||||
|
, pgvName :: Text
|
||||||
|
}
|
||||||
|
|
||||||
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
|
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
|
||||||
minimumPgVersion :: Integer
|
minimumPgVersion :: PgVersion
|
||||||
minimumPgVersion = 90300
|
minimumPgVersion = PgVersion 90300 "9.3"
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
|
module PostgREST.DbRequestBuilder (
|
||||||
|
readRequest
|
||||||
|
, mutateRequest
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Control.Applicative
|
||||||
|
import Control.Lens.Getter (view)
|
||||||
|
import Control.Lens.Tuple (_1)
|
||||||
|
import qualified Data.ByteString.Char8 as BS
|
||||||
|
import Data.List (delete, lookup)
|
||||||
|
import Data.Maybe (fromJust)
|
||||||
|
import Data.Text (isInfixOf, dropWhile, drop)
|
||||||
|
import Data.Tree
|
||||||
|
import Data.Either.Combinators (mapLeft)
|
||||||
|
|
||||||
|
import Text.Parsec.Error
|
||||||
|
|
||||||
|
import Network.HTTP.Types.Status
|
||||||
|
import Network.Wai
|
||||||
|
|
||||||
|
import Data.Foldable (foldr1)
|
||||||
|
import qualified Data.HashMap.Strict as M
|
||||||
|
|
||||||
|
import PostgREST.ApiRequest ( ApiRequest(..)
|
||||||
|
, Action(..), Target(..)
|
||||||
|
, PreferRepresentation (..)
|
||||||
|
)
|
||||||
|
import PostgREST.Error (errResponse, formatParserError)
|
||||||
|
import PostgREST.Parsers
|
||||||
|
import PostgREST.RangeQuery (NonnegRange, restrictRange)
|
||||||
|
import PostgREST.QueryBuilder (getJoinConditions, sourceCTEName)
|
||||||
|
import PostgREST.Types
|
||||||
|
|
||||||
|
import Protolude hiding (from, dropWhile, drop)
|
||||||
|
import Text.Regex.TDFA ((=~))
|
||||||
|
import Unsafe (unsafeHead)
|
||||||
|
|
||||||
|
readRequest :: Maybe Integer -> [Relation] -> [(Text, Text)] -> ApiRequest -> Either Response ReadRequest
|
||||||
|
readRequest maxRows allRels allProcs apiRequest =
|
||||||
|
mapLeft (errResponse status400) $
|
||||||
|
treeRestrictRange maxRows =<<
|
||||||
|
augumentRequestWithJoin schema relations =<<
|
||||||
|
first formatParserError parseReadRequest
|
||||||
|
where
|
||||||
|
(schema, rootTableName) = fromJust $ -- Make it safe
|
||||||
|
let target = iTarget apiRequest in
|
||||||
|
case target of
|
||||||
|
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
|
||||||
|
(TargetProc (QualifiedIdentifier s p) ) -> Just (s, t)
|
||||||
|
where
|
||||||
|
returnType = fromMaybe "" $ lookup p allProcs
|
||||||
|
-- we are looking for results looking like "SETOF schema.tablename" and want to extract tablename
|
||||||
|
t = if "SETOF " `isInfixOf` returnType
|
||||||
|
then drop 1 $ dropWhile (/= '.') returnType
|
||||||
|
else p
|
||||||
|
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
|
action :: Action
|
||||||
|
action = iAction apiRequest
|
||||||
|
|
||||||
|
parseReadRequest :: Either ParseError ReadRequest
|
||||||
|
parseReadRequest = addFiltersOrdersRanges apiRequest <*>
|
||||||
|
pRequestSelect rootName selStr
|
||||||
|
where
|
||||||
|
selStr = iSelect apiRequest
|
||||||
|
rootName = if action == ActionRead
|
||||||
|
then rootTableName
|
||||||
|
else sourceCTEName
|
||||||
|
|
||||||
|
relations :: [Relation]
|
||||||
|
relations = case action of
|
||||||
|
ActionCreate -> fakeSourceRelations ++ allRels
|
||||||
|
ActionUpdate -> fakeSourceRelations ++ allRels
|
||||||
|
ActionDelete -> fakeSourceRelations ++ allRels
|
||||||
|
ActionInvoke -> fakeSourceRelations ++ allRels
|
||||||
|
_ -> allRels
|
||||||
|
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels -- see comment in toSourceRelation
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either Text ReadRequest
|
||||||
|
augumentRequestWithJoin schema allRels request =
|
||||||
|
(first formatRelationError . addRelations schema allRels Nothing) request
|
||||||
|
>>= addJoinConditions schema
|
||||||
|
where
|
||||||
|
formatRelationError = ("could not find foreign keys between these entities, " <>)
|
||||||
|
|
||||||
|
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest
|
||||||
|
addRelations schema allRelations parentNode (Node readNode@(query, (name, _, alias)) forest) =
|
||||||
|
case parentNode of
|
||||||
|
(Just (Node (Select{from=[parentNodeTable]}, (_, _, _)) _)) ->
|
||||||
|
Node <$> readNode' <*> forest'
|
||||||
|
where
|
||||||
|
forest' = updateForest $ hush node'
|
||||||
|
node' = Node <$> readNode' <*> pure forest
|
||||||
|
readNode' = addRel readNode <$> rel
|
||||||
|
rel :: Either Text Relation
|
||||||
|
rel = note ("no relation between " <> parentNodeTable <> " and " <> name)
|
||||||
|
$ findRelation schema name parentNodeTable
|
||||||
|
|
||||||
|
where
|
||||||
|
findRelation s nodeTableName parentNodeTableName =
|
||||||
|
find (\r ->
|
||||||
|
s == tableSchema (relTable r) && -- match schema for relation table
|
||||||
|
s == tableSchema (relFTable r) && -- match schema for relation foriegn table
|
||||||
|
(
|
||||||
|
|
||||||
|
-- (request) => projects { ..., clients{...} }
|
||||||
|
-- will match
|
||||||
|
-- (relation type) => parent
|
||||||
|
-- (entity) => clients {id}
|
||||||
|
-- (foriegn entity) => projects {client_id}
|
||||||
|
(
|
||||||
|
nodeTableName == tableName (relTable r) && -- match relation table name
|
||||||
|
parentNodeTableName == tableName (relFTable r) -- match relation foreign table name
|
||||||
|
) ||
|
||||||
|
|
||||||
|
|
||||||
|
-- (request) => projects { ..., client_id{...} }
|
||||||
|
-- will match
|
||||||
|
-- (relation type) => parent
|
||||||
|
-- (entity) => clients {id}
|
||||||
|
-- (foriegn entity) => projects {client_id}
|
||||||
|
(
|
||||||
|
parentNodeTableName == tableName (relFTable r) &&
|
||||||
|
length (relFColumns r) == 1 &&
|
||||||
|
nodeTableName `colMatches` (colName . unsafeHead . relFColumns) r
|
||||||
|
)
|
||||||
|
|
||||||
|
-- (request) => project_id { ..., client_id{...} }
|
||||||
|
-- will match
|
||||||
|
-- (relation type) => parent
|
||||||
|
-- (entity) => clients {id}
|
||||||
|
-- (foriegn entity) => projects {client_id}
|
||||||
|
-- this case works becasue before reaching this place
|
||||||
|
-- addRelation will turn project_id to project so the above condition will match
|
||||||
|
)
|
||||||
|
) allRelations
|
||||||
|
where n `colMatches` rc = (toS ("^" <> rc <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (toS n :: BS.ByteString)
|
||||||
|
addRel :: (ReadQuery, (NodeName, Maybe Relation, Maybe Alias)) -> Relation -> (ReadQuery, (NodeName, Maybe Relation, Maybe Alias))
|
||||||
|
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 query')
|
||||||
|
|
||||||
|
_ -> n' <$> updateForest (Just (n' forest))
|
||||||
|
where
|
||||||
|
n' = Node (query, (name, Just r, alias))
|
||||||
|
t = Table schema name True -- !!! TODO find another way to get the table from the query
|
||||||
|
r = Relation t [] t [] Root Nothing Nothing Nothing
|
||||||
|
where
|
||||||
|
updateForest :: Maybe ReadRequest -> Either Text [ReadRequest]
|
||||||
|
updateForest n = mapM (addRelations schema allRelations n) forest
|
||||||
|
|
||||||
|
addJoinConditions :: Schema -> ReadRequest -> Either Text ReadRequest
|
||||||
|
addJoinConditions schema (Node nn@(query, (n, r, a)) forest) =
|
||||||
|
case r of
|
||||||
|
Just Relation{relType=Root} -> Node nn <$> updatedForest -- this is the root node
|
||||||
|
Just rel@Relation{relType=Child} -> Node (addCond query (getJoinConditions rel),(n,r,a)) <$> updatedForest
|
||||||
|
Just Relation{relType=Parent} -> Node nn <$> updatedForest
|
||||||
|
Just rel@Relation{relType=Many, relLTable=(Just linkTable)} ->
|
||||||
|
Node (qq, (n, r, a)) <$> updatedForest
|
||||||
|
where
|
||||||
|
query' = addCond query (getJoinConditions rel)
|
||||||
|
qq = query'{from=tableName linkTable : from query'}
|
||||||
|
_ -> Left "unknown relation"
|
||||||
|
where
|
||||||
|
updatedForest = mapM (addJoinConditions schema) forest
|
||||||
|
addCond query' con = query'{flt_=con ++ flt_ query'}
|
||||||
|
|
||||||
|
addFiltersOrdersRanges :: ApiRequest -> Either ParseError (ReadRequest -> ReadRequest)
|
||||||
|
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
||||||
|
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
|
||||||
|
| action == ActionRead = iFilters apiRequest
|
||||||
|
| action == ActionInvoke = iFilters apiRequest
|
||||||
|
| otherwise = filter (( "." `isInfixOf` ) . 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
|
||||||
|
|
||||||
|
addFilterToNode :: Filter -> ReadRequest -> ReadRequest
|
||||||
|
addFilterToNode flt (Node (q@Select {flt_=flts}, i) f) = Node (q {flt_=flt:flts}, i) f
|
||||||
|
|
||||||
|
addFilter :: (Path, Filter) -> ReadRequest -> ReadRequest
|
||||||
|
addFilter = addProperty addFilterToNode
|
||||||
|
|
||||||
|
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
|
||||||
|
Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path
|
||||||
|
Just tn -> Node rn (addProperty f (remainingPath, a) tn:restForest)
|
||||||
|
where
|
||||||
|
targetNodeName:remainingPath = path
|
||||||
|
(targetNode,restForest) = splitForest targetNodeName forest
|
||||||
|
splitForest :: NodeName -> Forest ReadNode -> (Maybe ReadRequest, Forest ReadNode)
|
||||||
|
splitForest name forst =
|
||||||
|
case maybeNode of
|
||||||
|
Nothing -> (Nothing,forest)
|
||||||
|
Just node -> (Just node, delete node forest)
|
||||||
|
where
|
||||||
|
maybeNode :: Maybe ReadRequest
|
||||||
|
maybeNode = find fnd forst
|
||||||
|
where
|
||||||
|
fnd :: ReadRequest -> Bool
|
||||||
|
fnd (Node (_,(n,_,_)) _) = n == name
|
||||||
|
|
||||||
|
-- in a relation where one of the tables mathces "TableName"
|
||||||
|
-- replace the name to that table with pg_source
|
||||||
|
-- this "fake" relations is needed so that in a mutate query
|
||||||
|
-- we can look a the "returning *" part which is wrapped with a "with"
|
||||||
|
-- as just another table that has relations with other tables
|
||||||
|
toSourceRelation :: TableName -> Relation -> Maybe Relation
|
||||||
|
toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
|
||||||
|
| mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
|
||||||
|
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}
|
||||||
|
| Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
|
||||||
|
| otherwise = Nothing
|
||||||
|
|
||||||
|
mutateRequest :: ApiRequest -> ReadRequest -> Either Response MutateRequest
|
||||||
|
mutateRequest apiRequest readReq = mapLeft (errResponse status400) $
|
||||||
|
case action of
|
||||||
|
ActionCreate -> Right $ Insert rootTableName payload returnings
|
||||||
|
ActionUpdate -> Update rootTableName <$> pure payload <*> filters <*> pure returnings
|
||||||
|
ActionDelete -> Delete rootTableName <$> filters <*> pure returnings
|
||||||
|
_ -> Left "Unsupported HTTP verb"
|
||||||
|
where
|
||||||
|
action = iAction apiRequest
|
||||||
|
payload = fromJust $ iPayload apiRequest
|
||||||
|
rootTableName = -- TODO: Make it safe
|
||||||
|
let target = iTarget apiRequest in
|
||||||
|
case target of
|
||||||
|
(TargetIdent (QualifiedIdentifier _ t) ) -> t
|
||||||
|
_ -> undefined
|
||||||
|
fieldNames :: ReadRequest -> PreferRepresentation -> [FieldName]
|
||||||
|
fieldNames _ None = []
|
||||||
|
fieldNames (Node (sel, _) forest) _ =
|
||||||
|
map (fst . view _1) (select sel) ++ map colName fks
|
||||||
|
where
|
||||||
|
fks = concatMap (fromMaybe [] . f) forest
|
||||||
|
f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _)) _) = Just cols
|
||||||
|
f _ = Nothing
|
||||||
|
returnings = fieldNames readReq (iPreferRepresentation apiRequest)
|
||||||
|
filters = first formatParserError $ map snd <$> mapM pRequestFilter mutateFilters
|
||||||
|
where mutateFilters = filter (not . ( "." `isInfixOf` ) . fst) $ iFilters apiRequest -- update/delete filters can be only on the root table
|
||||||
+478
-197
@@ -6,32 +6,34 @@
|
|||||||
module PostgREST.DbStructure (
|
module PostgREST.DbStructure (
|
||||||
getDbStructure
|
getDbStructure
|
||||||
, accessibleTables
|
, accessibleTables
|
||||||
, doesProcExist
|
|
||||||
, doesProcReturnJWT
|
|
||||||
) where
|
) where
|
||||||
|
|
||||||
|
import qualified Hasql.Decoders as HD
|
||||||
|
import qualified Hasql.Encoders as HE
|
||||||
|
import qualified Hasql.Query as H
|
||||||
|
|
||||||
import Control.Applicative
|
import Control.Applicative
|
||||||
import Control.Monad (join)
|
import Data.List (elemIndex)
|
||||||
import Data.Functor.Identity
|
import Data.Maybe (fromJust)
|
||||||
import Data.List (elemIndex, find, subsequences, sort, transpose)
|
import Data.Text (split, strip,
|
||||||
import Data.Maybe (fromMaybe, fromJust, isJust, mapMaybe, listToMaybe)
|
breakOn, dropAround)
|
||||||
import Data.Monoid
|
import qualified Data.Text as T
|
||||||
import Data.Text (Text, split)
|
import qualified Hasql.Session as H
|
||||||
import qualified Hasql as H
|
|
||||||
import qualified Hasql.Postgres as P
|
|
||||||
import qualified Hasql.Backend as B
|
|
||||||
import PostgREST.Types
|
import PostgREST.Types
|
||||||
|
import Text.InterpolatedString.Perl6 (q)
|
||||||
|
|
||||||
import GHC.Exts (groupWith)
|
import GHC.Exts (groupWith)
|
||||||
import Prelude
|
import Protolude
|
||||||
|
import Unsafe (unsafeHead)
|
||||||
|
|
||||||
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
|
||||||
|
procs <- H.query schema accessibleProcs
|
||||||
|
|
||||||
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
|
||||||
@@ -42,59 +44,122 @@ getDbStructure schema = do
|
|||||||
, dbColumns = cols'
|
, dbColumns = cols'
|
||||||
, dbRelations = rels'
|
, dbRelations = rels'
|
||||||
, dbPrimaryKeys = keys'
|
, dbPrimaryKeys = keys'
|
||||||
|
, dbProcs = procs
|
||||||
}
|
}
|
||||||
|
|
||||||
doesProc :: forall c s. B.CxValue c Int =>
|
decodeTables :: HD.Result [Table]
|
||||||
(Text -> Text -> B.Stmt c) -> QualifiedIdentifier -> H.Tx c s Bool
|
decodeTables =
|
||||||
doesProc stmt qi = do
|
HD.rowsList tblRow
|
||||||
row :: Maybe (Identity Int) <- H.maybeEx $ stmt (qiSchema qi) (qiName qi)
|
where
|
||||||
return $ isJust row
|
tblRow = Table <$> HD.value HD.text <*> HD.value HD.text
|
||||||
|
<*> HD.value HD.bool
|
||||||
|
|
||||||
doesProcExist :: QualifiedIdentifier -> H.Tx P.Postgres s Bool
|
decodeColumns :: [Table] -> HD.Result [Column]
|
||||||
doesProcExist = doesProc [H.stmt|
|
decodeColumns tables =
|
||||||
SELECT 1
|
mapMaybe (columnFromRow tables) <$> HD.rowsList colRow
|
||||||
FROM pg_catalog.pg_namespace n
|
where
|
||||||
JOIN pg_catalog.pg_proc p
|
colRow =
|
||||||
ON pronamespace = n.oid
|
(,,,,,,,,,,)
|
||||||
WHERE nspname = ?
|
<$> HD.value HD.text <*> HD.value HD.text
|
||||||
AND proname = ?
|
<*> 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
|
||||||
|
|
||||||
doesProcReturnJWT :: QualifiedIdentifier -> H.Tx P.Postgres s Bool
|
decodeRelations :: [Table] -> [Column] -> HD.Result [Relation]
|
||||||
doesProcReturnJWT = doesProc [H.stmt|
|
decodeRelations tables cols =
|
||||||
SELECT 1
|
mapMaybe (relationFromRow tables cols) <$> HD.rowsList relRow
|
||||||
FROM pg_catalog.pg_namespace n
|
where
|
||||||
JOIN pg_catalog.pg_proc p
|
relRow = (,,,,,)
|
||||||
ON pronamespace = n.oid
|
<$> HD.value HD.text
|
||||||
WHERE nspname = ?
|
<*> HD.value HD.text
|
||||||
AND proname = ?
|
<*> HD.value (HD.array (HD.arrayDimension replicateM (HD.arrayValue HD.text)))
|
||||||
AND pg_catalog.pg_get_function_result(p.oid) like '%jwt_claims'
|
<*> HD.value HD.text
|
||||||
|]
|
<*> HD.value HD.text
|
||||||
|
<*> HD.value (HD.array (HD.arrayDimension replicateM (HD.arrayValue HD.text)))
|
||||||
|
|
||||||
accessibleTables :: [Table] -> H.Tx P.Postgres s [Table]
|
decodePks :: [Table] -> HD.Result [PrimaryKey]
|
||||||
accessibleTables allTabs = do
|
decodePks tables =
|
||||||
accessible <- H.listEx $ [H.stmt|
|
mapMaybe (pkFromRow tables) <$> HD.rowsList pkRow
|
||||||
SELECT
|
where
|
||||||
n.nspname AS table_schema,
|
pkRow = (,,) <$> HD.value HD.text <*> HD.value HD.text <*> HD.value HD.text
|
||||||
c.relname AS table_name
|
|
||||||
FROM pg_class c
|
decodeSynonyms :: [Column] -> HD.Result [(Column,Column)]
|
||||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
decodeSynonyms cols =
|
||||||
WHERE
|
mapMaybe (synonymFromRow cols) <$> HD.rowsList synRow
|
||||||
c.relkind IN ('v','r','m') AND
|
where
|
||||||
n.nspname NOT IN ('pg_catalog', 'information_schema') AND (
|
synRow = (,,,,,)
|
||||||
pg_has_role(c.relowner, 'USAGE'::text) OR
|
<$> HD.value HD.text <*> HD.value HD.text
|
||||||
has_table_privilege(c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR
|
<*> HD.value HD.text <*> HD.value HD.text
|
||||||
has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES'::text)
|
<*> HD.value HD.text <*> HD.value HD.text
|
||||||
)
|
|
||||||
ORDER BY table_schema, table_name
|
accessibleProcs :: H.Query Schema [(Text, ProcDescription)]
|
||||||
|]
|
accessibleProcs =
|
||||||
let isAccessible table = isJust $ find (\(s,n) -> tableSchema table == s && tableName table == n) accessible
|
H.statement sql (HE.value HE.text)
|
||||||
return $ filter isAccessible allTabs
|
(map addName <$> HD.rowsList (ProcDescription <$> HD.value HD.text
|
||||||
|
<*> (parseArgs <$> HD.value HD.text)
|
||||||
|
<*> HD.value HD.text)) True
|
||||||
|
where
|
||||||
|
addName :: ProcDescription -> (Text, ProcDescription)
|
||||||
|
addName pd = (pdName pd, pd)
|
||||||
|
|
||||||
|
parseArgs :: Text -> [PgArg]
|
||||||
|
parseArgs = mapMaybe (parseArg . strip) . split (==',')
|
||||||
|
|
||||||
|
parseArg :: Text -> Maybe PgArg
|
||||||
|
parseArg a =
|
||||||
|
let (body, def) = breakOn " DEFAULT " a
|
||||||
|
(name, typ) = breakOn " " body in
|
||||||
|
if T.null typ
|
||||||
|
then Nothing
|
||||||
|
else Just $
|
||||||
|
PgArg (dropAround (== '"') name) (strip typ) (T.null def)
|
||||||
|
|
||||||
|
sql = [q|
|
||||||
|
SELECT p.proname as "proc_name",
|
||||||
|
pg_get_function_arguments(p.oid) as "args",
|
||||||
|
pg_get_function_result(p.oid) as "return_type"
|
||||||
|
FROM pg_namespace n
|
||||||
|
JOIN pg_proc p
|
||||||
|
ON pronamespace = n.oid
|
||||||
|
WHERE n.nspname = $1|]
|
||||||
|
|
||||||
|
accessibleTables :: H.Query Schema [Table]
|
||||||
|
accessibleTables =
|
||||||
|
H.statement sql (HE.value HE.text) decodeTables True
|
||||||
|
where
|
||||||
|
sql = [q|
|
||||||
|
select
|
||||||
|
n.nspname as table_schema,
|
||||||
|
relname as table_name,
|
||||||
|
c.relkind = 'r' or (c.relkind IN ('v', 'f')) and (pg_relation_is_updatable(c.oid::regclass, false) & 8) = 8
|
||||||
|
or (exists (
|
||||||
|
select 1
|
||||||
|
from pg_trigger
|
||||||
|
where pg_trigger.tgrelid = c.oid and (pg_trigger.tgtype::integer & 69) = 69)
|
||||||
|
) as insertable
|
||||||
|
from
|
||||||
|
pg_class c
|
||||||
|
join pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where
|
||||||
|
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'
|
||||||
where
|
where
|
||||||
syns = sort $ filter ((== colTable (head cols)) . colTable . fst) allSyns
|
syns = case headMay cols of
|
||||||
|
Just firstCol -> sort $ filter ((== colTable firstCol) . colTable . fst) allSyns
|
||||||
|
Nothing -> []
|
||||||
synCols = transpose $ map (\c -> map snd $ filter ((== c) . fst) syns) cols
|
synCols = transpose $ map (\c -> map snd $ filter ((== c) . fst) syns) cols
|
||||||
synCols' = (filter sameTable . filter matchLength) synCols
|
synCols' = (filter sameTable . filter matchLength) synCols
|
||||||
matchLength cs = length cols == length cs
|
matchLength cs = length cols == length cs
|
||||||
@@ -107,12 +172,11 @@ 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
|
relToFk col Relation{relColumns=cols, relFColumns=colsF} = do
|
||||||
relToFk col (Relation{relColumns=cols, relFColumns=colsF}) = ForeignKey <$> colF
|
pos <- elemIndex col cols
|
||||||
where
|
colF <- atMay colsF pos
|
||||||
pos = elemIndex col cols
|
return $ ForeignKey colF
|
||||||
colF = (colsF !!) <$> pos
|
|
||||||
|
|
||||||
addSynonymousRelations :: [(Column,Column)] -> [Relation] -> [Relation]
|
addSynonymousRelations :: [(Column,Column)] -> [Relation] -> [Relation]
|
||||||
addSynonymousRelations _ [] = []
|
addSynonymousRelations _ [] = []
|
||||||
@@ -120,19 +184,21 @@ addSynonymousRelations syns (rel:rels) = rel : synRelsP ++ synRelsF ++ addSynony
|
|||||||
where
|
where
|
||||||
synRelsP = synRels (relColumns rel) (\t cs -> rel{relTable=t,relColumns=cs})
|
synRelsP = synRels (relColumns rel) (\t cs -> rel{relTable=t,relColumns=cs})
|
||||||
synRelsF = synRels (relFColumns rel) (\t cs -> rel{relFTable=t,relFColumns=cs})
|
synRelsF = synRels (relFColumns rel) (\t cs -> rel{relFTable=t,relFColumns=cs})
|
||||||
synRels cols mapFn = map (\cs -> mapFn (colTable $ head cs) cs) $ synonymousColumns syns cols
|
synRels cols mapFn = map (\cs -> mapFn (colTable $ unsafeHead cs) cs) $ synonymousColumns syns cols
|
||||||
|
|
||||||
addParentRelations :: [Relation] -> [Relation]
|
addParentRelations :: [Relation] -> [Relation]
|
||||||
addParentRelations [] = []
|
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}
|
||||||
@@ -151,8 +217,8 @@ raiseRelations schema syns = map raiseRel
|
|||||||
where
|
where
|
||||||
cols = relFColumns rel
|
cols = relFColumns rel
|
||||||
table = relFTable rel
|
table = relFTable rel
|
||||||
newCols = listToMaybe $ filter ((== schema) . tableSchema . colTable . head) (synonymousColumns syns cols)
|
newCols = listToMaybe $ filter ((== schema) . tableSchema . colTable . unsafeHead) (synonymousColumns syns cols)
|
||||||
newTable = (colTable . head) <$> newCols
|
newTable = (colTable . unsafeHead) <$> newCols
|
||||||
|
|
||||||
synonymousPrimaryKeys :: [(Column,Column)] -> [PrimaryKey] -> [PrimaryKey]
|
synonymousPrimaryKeys :: [(Column,Column)] -> [PrimaryKey] -> [PrimaryKey]
|
||||||
synonymousPrimaryKeys _ [] = []
|
synonymousPrimaryKeys _ [] = []
|
||||||
@@ -161,80 +227,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 (
|
/*
|
||||||
SELECT
|
-- CTE based on information_schema.columns to remove the owner filter
|
||||||
table_schema,
|
*/
|
||||||
table_name,
|
WITH columns AS (
|
||||||
column_name,
|
SELECT current_database()::information_schema.sql_identifier AS table_catalog,
|
||||||
ordinal_position,
|
nc.nspname::information_schema.sql_identifier AS table_schema,
|
||||||
is_nullable,
|
c.relname::information_schema.sql_identifier AS table_name,
|
||||||
data_type,
|
a.attname::information_schema.sql_identifier AS column_name,
|
||||||
is_updatable,
|
a.attnum::information_schema.cardinal_number AS ordinal_position,
|
||||||
character_maximum_length,
|
pg_get_expr(ad.adbin, ad.adrelid)::information_schema.character_data AS column_default,
|
||||||
numeric_precision,
|
CASE
|
||||||
column_default,
|
WHEN a.attnotnull OR t.typtype = 'd'::"char" AND t.typnotnull THEN 'NO'::text
|
||||||
udt_name
|
ELSE 'YES'::text
|
||||||
FROM information_schema.columns
|
END::information_schema.yes_or_no AS is_nullable,
|
||||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
CASE
|
||||||
) AS info
|
WHEN t.typtype = 'd'::"char" THEN
|
||||||
LEFT OUTER JOIN (
|
CASE
|
||||||
SELECT
|
WHEN bt.typelem <> 0::oid AND bt.typlen = (-1) THEN 'ARRAY'::text
|
||||||
n.nspname AS s,
|
WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype, NULL::integer)
|
||||||
t.typname AS n,
|
ELSE format_type(a.atttypid, a.atttypmod)
|
||||||
array_agg(e.enumlabel ORDER BY e.enumsortorder) AS vals
|
END
|
||||||
FROM pg_type t
|
ELSE
|
||||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
CASE
|
||||||
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
WHEN t.typelem <> 0::oid AND t.typlen = (-1) THEN 'ARRAY'::text
|
||||||
GROUP BY s,n
|
WHEN nt.nspname = 'pg_catalog'::name THEN format_type(a.atttypid, NULL::integer)
|
||||||
) AS enum_info ON (info.udt_name = enum_info.n)
|
ELSE format_type(a.atttypid, a.atttypmod)
|
||||||
ORDER BY schema, position
|
END
|
||||||
|]
|
END::information_schema.character_data AS data_type,
|
||||||
return $ mapMaybe (columnFromRow tabs) cols
|
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_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_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_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::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_name,
|
||||||
|
CASE
|
||||||
|
WHEN nco.nspname IS NOT NULL THEN current_database()
|
||||||
|
ELSE NULL::name
|
||||||
|
END::information_schema.sql_identifier AS collation_catalog,
|
||||||
|
nco.nspname::information_schema.sql_identifier AS collation_schema,
|
||||||
|
co.collname::information_schema.sql_identifier AS collation_name,
|
||||||
|
CASE
|
||||||
|
WHEN t.typtype = 'd'::"char" THEN current_database()
|
||||||
|
ELSE NULL::name
|
||||||
|
END::information_schema.sql_identifier AS domain_catalog,
|
||||||
|
CASE
|
||||||
|
WHEN t.typtype = 'd'::"char" THEN nt.nspname
|
||||||
|
ELSE NULL::name
|
||||||
|
END::information_schema.sql_identifier AS domain_schema,
|
||||||
|
CASE
|
||||||
|
WHEN t.typtype = 'd'::"char" THEN t.typname
|
||||||
|
ELSE NULL::name
|
||||||
|
END::information_schema.sql_identifier AS domain_name,
|
||||||
|
current_database()::information_schema.sql_identifier AS udt_catalog,
|
||||||
|
COALESCE(nbt.nspname, nt.nspname)::information_schema.sql_identifier AS udt_schema,
|
||||||
|
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_schema,
|
||||||
|
NULL::character varying::information_schema.sql_identifier AS scope_name,
|
||||||
|
NULL::integer::information_schema.cardinal_number AS maximum_cardinality,
|
||||||
|
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_identity,
|
||||||
|
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_increment,
|
||||||
|
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.yes_or_no AS identity_cycle,
|
||||||
|
'NEVER'::character varying::information_schema.character_data AS is_generated,
|
||||||
|
NULL::character varying::information_schema.character_data AS generation_expression,
|
||||||
|
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
|
||||||
|
ELSE 'NO'::text
|
||||||
|
END::information_schema.yes_or_no AS is_updatable
|
||||||
|
FROM pg_attribute a
|
||||||
|
LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid AND a.attnum = ad.adnum
|
||||||
|
JOIN (pg_class c
|
||||||
|
JOIN pg_namespace nc ON c.relnamespace = nc.oid) ON a.attrelid = c.oid
|
||||||
|
JOIN (pg_type t
|
||||||
|
JOIN pg_namespace nt ON t.typnamespace = nt.oid) ON a.atttypid = t.oid
|
||||||
|
LEFT JOIN (pg_type bt
|
||||||
|
JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid) ON t.typtype = 'd'::"char" AND t.typbasetype = bt.oid
|
||||||
|
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)
|
||||||
|
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))*/
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
table_schema,
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
ordinal_position,
|
||||||
|
is_nullable,
|
||||||
|
data_type,
|
||||||
|
is_updatable,
|
||||||
|
character_maximum_length,
|
||||||
|
numeric_precision,
|
||||||
|
column_default,
|
||||||
|
udt_name
|
||||||
|
/*-- FROM information_schema.columns*/
|
||||||
|
FROM columns
|
||||||
|
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
) AS info
|
||||||
|
LEFT OUTER JOIN (
|
||||||
|
SELECT
|
||||||
|
n.nspname AS s,
|
||||||
|
t.typname AS n,
|
||||||
|
array_agg(e.enumlabel ORDER BY e.enumsortorder) AS vals
|
||||||
|
FROM pg_type t
|
||||||
|
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||||
|
GROUP BY s,n
|
||||||
|
) AS enum_info ON (info.udt_name = enum_info.n)
|
||||||
|
ORDER BY schema, position |]
|
||||||
|
|
||||||
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
|
||||||
@@ -244,9 +400,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,
|
||||||
@@ -270,80 +428,203 @@ 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) =
|
||||||
if isJust table && isJust tableF && length cols == length rcs && length colsF == length frcs
|
Relation <$> table <*> cols <*> tableF <*> colsF <*> pure Child <*> pure Nothing <*> pure Nothing <*> pure Nothing
|
||||||
then Just $ Relation (fromJust table) cols (fromJust tableF) colsF Child Nothing Nothing Nothing
|
|
||||||
else Nothing
|
|
||||||
where
|
where
|
||||||
findTable s t = find (\tbl -> tableSchema tbl == s && tableName tbl == t) allTabs
|
findTable s t = find (\tbl -> tableSchema tbl == s && tableName tbl == t) allTabs
|
||||||
findCols s t cs = filter (\col -> tableSchema (colTable col) == s && tableName (colTable col) == t && colName col `elem` cs) allCols
|
findCol s t c = find (\col -> tableSchema (colTable col) == s && tableName (colTable col) == t && colName col == c) allCols
|
||||||
table = findTable rs rt
|
table = findTable rs rt
|
||||||
tableF = findTable frs frt
|
tableF = findTable frs frt
|
||||||
cols = findCols rs rt rcs
|
cols = mapM (findCol rs rt) rcs
|
||||||
colsF = findCols 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
|
||||||
|
sql = [q|
|
||||||
|
/*
|
||||||
|
-- CTE to replace information_schema.table_constraints to remove owner limit
|
||||||
|
*/
|
||||||
|
WITH tc AS (
|
||||||
|
SELECT current_database()::information_schema.sql_identifier AS constraint_catalog,
|
||||||
|
nc.nspname::information_schema.sql_identifier AS constraint_schema,
|
||||||
|
c.conname::information_schema.sql_identifier AS constraint_name,
|
||||||
|
current_database()::information_schema.sql_identifier AS table_catalog,
|
||||||
|
nr.nspname::information_schema.sql_identifier AS table_schema,
|
||||||
|
r.relname::information_schema.sql_identifier AS table_name,
|
||||||
|
CASE c.contype
|
||||||
|
WHEN 'c'::"char" THEN 'CHECK'::text
|
||||||
|
WHEN 'f'::"char" THEN 'FOREIGN KEY'::text
|
||||||
|
WHEN 'p'::"char" THEN 'PRIMARY KEY'::text
|
||||||
|
WHEN 'u'::"char" THEN 'UNIQUE'::text
|
||||||
|
ELSE NULL::text
|
||||||
|
END::information_schema.character_data AS constraint_type,
|
||||||
|
CASE
|
||||||
|
WHEN c.condeferrable THEN 'YES'::text
|
||||||
|
ELSE 'NO'::text
|
||||||
|
END::information_schema.yes_or_no AS is_deferrable,
|
||||||
|
CASE
|
||||||
|
WHEN c.condeferred THEN 'YES'::text
|
||||||
|
ELSE 'NO'::text
|
||||||
|
END::information_schema.yes_or_no AS initially_deferred
|
||||||
|
FROM pg_namespace nc,
|
||||||
|
pg_namespace nr,
|
||||||
|
pg_constraint c,
|
||||||
|
pg_class r
|
||||||
|
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)
|
||||||
|
/*--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))*/
|
||||||
|
UNION ALL
|
||||||
|
SELECT current_database()::information_schema.sql_identifier AS constraint_catalog,
|
||||||
|
nr.nspname::information_schema.sql_identifier AS constraint_schema,
|
||||||
|
(((((nr.oid::text || '_'::text) || r.oid::text) || '_'::text) || a.attnum::text) || '_not_null'::text)::information_schema.sql_identifier AS constraint_name,
|
||||||
|
current_database()::information_schema.sql_identifier AS table_catalog,
|
||||||
|
nr.nspname::information_schema.sql_identifier AS table_schema,
|
||||||
|
r.relname::information_schema.sql_identifier AS table_name,
|
||||||
|
'CHECK'::character varying::information_schema.character_data AS constraint_type,
|
||||||
|
'NO'::character varying::information_schema.yes_or_no AS is_deferrable,
|
||||||
|
'NO'::character varying::information_schema.yes_or_no AS initially_deferred
|
||||||
|
FROM pg_namespace nr,
|
||||||
|
pg_class r,
|
||||||
|
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 (
|
||||||
|
SELECT current_database()::information_schema.sql_identifier AS constraint_catalog,
|
||||||
|
ss.nc_nspname::information_schema.sql_identifier AS constraint_schema,
|
||||||
|
ss.conname::information_schema.sql_identifier AS constraint_name,
|
||||||
|
current_database()::information_schema.sql_identifier AS table_catalog,
|
||||||
|
ss.nr_nspname::information_schema.sql_identifier AS table_schema,
|
||||||
|
ss.relname::information_schema.sql_identifier AS table_name,
|
||||||
|
a.attname::information_schema.sql_identifier AS column_name,
|
||||||
|
(ss.x).n::information_schema.cardinal_number AS ordinal_position,
|
||||||
|
CASE
|
||||||
|
WHEN ss.contype = 'f'::"char" THEN information_schema._pg_index_position(ss.conindid, ss.confkey[(ss.x).n])
|
||||||
|
ELSE NULL::integer
|
||||||
|
END::information_schema.cardinal_number AS position_in_unique_constraint
|
||||||
|
FROM pg_attribute a,
|
||||||
|
( SELECT r.oid AS roid,
|
||||||
|
r.relname,
|
||||||
|
r.relowner,
|
||||||
|
nc.nspname AS nc_nspname,
|
||||||
|
nr.nspname AS nr_nspname,
|
||||||
|
c.oid AS coid,
|
||||||
|
c.conname,
|
||||||
|
c.contype,
|
||||||
|
c.conindid,
|
||||||
|
c.confkey,
|
||||||
|
c.confrelid,
|
||||||
|
information_schema._pg_expandarray(c.conkey) AS x
|
||||||
|
FROM pg_namespace nr,
|
||||||
|
pg_class r,
|
||||||
|
pg_namespace nc,
|
||||||
|
pg_constraint c
|
||||||
|
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
|
||||||
|
/*--AND (pg_has_role(ss.relowner, 'USAGE'::text) OR has_column_privilege(ss.roid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
kc.table_schema,
|
kc.table_schema,
|
||||||
kc.table_name,
|
kc.table_name,
|
||||||
kc.column_name
|
kc.column_name
|
||||||
FROM
|
FROM
|
||||||
information_schema.table_constraints tc,
|
/*
|
||||||
information_schema.key_column_usage kc
|
--information_schema.table_constraints tc,
|
||||||
|
--information_schema.key_column_usage kc
|
||||||
|
*/
|
||||||
|
tc, kc
|
||||||
WHERE
|
WHERE
|
||||||
tc.constraint_type = 'PRIMARY KEY' AND
|
tc.constraint_type = 'PRIMARY KEY' AND
|
||||||
kc.table_name = tc.table_name AND
|
kc.table_name = tc.table_name AND
|
||||||
kc.table_schema = tc.table_schema AND
|
kc.table_schema = tc.table_schema AND
|
||||||
kc.constraint_name = tc.constraint_name AND
|
kc.constraint_name = tc.constraint_name AND
|
||||||
kc.table_schema NOT IN ('pg_catalog', 'information_schema')
|
kc.table_schema NOT IN ('pg_catalog', 'information_schema') |]
|
||||||
|]
|
|
||||||
return $ mapMaybe (pkFromRow tabs) pks
|
|
||||||
|
|
||||||
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
|
||||||
SELECT
|
-- query explanation at https://gist.github.com/ruslantalpa/2eab8c930a65e8043d8f
|
||||||
vcu.table_schema AS src_table_schema,
|
sql = [q|
|
||||||
vcu.table_name AS src_table_name,
|
with view_columns as (
|
||||||
vcu.column_name AS src_column_name,
|
select
|
||||||
view.table_schema AS syn_table_schema,
|
c.oid as view_oid,
|
||||||
view.table_name AS syn_table_name,
|
a.attname::information_schema.sql_identifier as column_name
|
||||||
view.view_definition AS view_definition
|
from pg_attribute a
|
||||||
FROM
|
join pg_class c on a.attrelid = c.oid
|
||||||
information_schema.views AS view,
|
join pg_namespace nc on c.relnamespace = nc.oid
|
||||||
information_schema.view_column_usage AS vcu
|
where
|
||||||
WHERE
|
not pg_is_other_temp_schema(nc.oid)
|
||||||
view.table_schema = vcu.view_schema AND
|
and a.attnum > 0
|
||||||
view.table_name = vcu.view_name AND
|
and not a.attisdropped
|
||||||
view.table_schema NOT IN ('pg_catalog', 'information_schema') AND
|
and (c.relkind = 'v'::"char")
|
||||||
(SELECT COUNT(*) FROM information_schema.view_table_usage WHERE view_schema = view.table_schema AND view_name = view.table_name) = 1
|
and nc.nspname not in ('information_schema', 'pg_catalog')
|
||||||
)
|
),
|
||||||
SELECT
|
view_column_usage as (
|
||||||
src_table_schema, src_table_name, src_column_name,
|
select distinct
|
||||||
syn_table_schema, syn_table_name,
|
v.oid as view_oid,
|
||||||
(regexp_matches(view_definition, CONCAT('\.(', src_column_name, ')(?=,|$)'), 'gn'))[1]
|
nv.nspname::information_schema.sql_identifier as view_schema,
|
||||||
FROM synonyms
|
v.relname::information_schema.sql_identifier as view_name,
|
||||||
UNION (
|
nt.nspname::information_schema.sql_identifier as table_schema,
|
||||||
SELECT
|
t.relname::information_schema.sql_identifier as table_name,
|
||||||
src_table_schema, src_table_name, src_column_name,
|
a.attname::information_schema.sql_identifier as column_name,
|
||||||
syn_table_schema, syn_table_name,
|
pg_get_viewdef(v.oid)::information_schema.character_data as view_definition
|
||||||
(regexp_matches(view_definition, CONCAT('\.', src_column_name, '\sAS\s("?)(.+?)\1(,|$)'), 'gn'))[2] /* " <- for syntax highlighting */
|
from pg_namespace nv
|
||||||
FROM synonyms
|
join pg_class v on nv.oid = v.relnamespace
|
||||||
|
join pg_depend dv on v.oid = dv.refobjid
|
||||||
|
join pg_depend dt on dv.objid = dt.objid
|
||||||
|
join pg_class t on dt.refobjid = t.oid
|
||||||
|
join pg_namespace nt on t.relnamespace = nt.oid
|
||||||
|
join pg_attribute a on t.oid = a.attrelid and dt.refobjsubid = a.attnum
|
||||||
|
|
||||||
|
where
|
||||||
|
nv.nspname not in ('information_schema', 'pg_catalog')
|
||||||
|
and v.relkind = 'v'::"char"
|
||||||
|
and dv.refclassid = 'pg_class'::regclass::oid
|
||||||
|
and dv.classid = 'pg_rewrite'::regclass::oid
|
||||||
|
and dv.deptype = 'i'::"char"
|
||||||
|
and dv.refobjid <> dt.refobjid
|
||||||
|
and dt.classid = 'pg_rewrite'::regclass::oid
|
||||||
|
and dt.refclassid = 'pg_class'::regclass::oid
|
||||||
|
and (t.relkind = any (array['r'::"char", 'v'::"char", 'f'::"char"]))
|
||||||
|
),
|
||||||
|
candidates as (
|
||||||
|
select
|
||||||
|
vcu.*,
|
||||||
|
(
|
||||||
|
select case when match is not null then coalesce(match[8], 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, ')(\s+AS\s+("([^"]+)"|([^, \n\t]+)))?.*?FROM.*?',vcu.table_schema,'\.(\2|',vcu.table_name,'\s+(as\s)?\3)'),
|
||||||
|
'nsi'
|
||||||
|
) match
|
||||||
|
) as view_column_name
|
||||||
|
from view_column_usage as vcu
|
||||||
)
|
)
|
||||||
|
select
|
||||||
|
c.table_schema,
|
||||||
|
c.table_name,
|
||||||
|
c.column_name as table_column_name,
|
||||||
|
c.view_schema,
|
||||||
|
c.view_name,
|
||||||
|
c.view_column_name
|
||||||
|
from view_columns as vc, candidates as c
|
||||||
|
where
|
||||||
|
vc.view_oid = c.view_oid
|
||||||
|
and 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
|
||||||
|
|||||||
+100
-42
@@ -2,58 +2,114 @@
|
|||||||
{-# LANGUAGE FlexibleInstances #-}
|
{-# LANGUAGE FlexibleInstances #-}
|
||||||
{-# LANGUAGE TypeSynonymInstances #-}
|
{-# LANGUAGE TypeSynonymInstances #-}
|
||||||
|
|
||||||
module PostgREST.Error (PgError, pgErrResponse, errResponse) where
|
module PostgREST.Error (apiRequestErrResponse, pgErrResponse, errResponse, prettyUsageError, singularityError, formatGeneralError, formatParserError) where
|
||||||
|
|
||||||
|
|
||||||
|
import Protolude
|
||||||
import Data.Aeson ((.=))
|
import Data.Aeson ((.=))
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
import Data.String.Conversions (cs)
|
import Data.Text (replace, strip, unwords)
|
||||||
import Data.String.Utils (replace)
|
import qualified Hasql.Pool as P
|
||||||
import Data.Text (Text)
|
import qualified Hasql.Session as H
|
||||||
import qualified Data.Text as T
|
|
||||||
import qualified Hasql as H
|
|
||||||
import qualified Hasql.Postgres as P
|
|
||||||
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)
|
||||||
|
import PostgREST.ApiRequest (toHeader, toMime, ContentType(..), ApiRequestError(..))
|
||||||
|
import Text.Parsec.Error
|
||||||
|
|
||||||
type PgError = H.SessionError P.Postgres
|
apiRequestErrResponse :: ApiRequestError -> Response
|
||||||
|
apiRequestErrResponse err =
|
||||||
|
case err of
|
||||||
|
ErrorActionInappropriate -> errResponse HT.status405 "Bad Request"
|
||||||
|
ErrorInvalidBody errorMessage -> errResponse HT.status400 $ toS errorMessage
|
||||||
|
ErrorInvalidRange -> errResponse HT.status416 "HTTP Range error"
|
||||||
|
|
||||||
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 = jsonErrResponse status $ JSON.object ["message" .= message]
|
||||||
|
|
||||||
pgErrResponse :: PgError -> Response
|
jsonErrResponse :: HT.Status -> JSON.Value -> Response
|
||||||
pgErrResponse e = responseLBS (httpStatus e)
|
jsonErrResponse status message = responseLBS status [toHeader CTApplicationJSON] $ JSON.encode message
|
||||||
[(hContentType, "application/json")] (JSON.encode e)
|
|
||||||
|
|
||||||
instance JSON.ToJSON PgError where
|
pgErrResponse :: Bool -> P.UsageError -> Response
|
||||||
toJSON (H.TxError (P.ErroneousResult c m d h)) = JSON.object [
|
pgErrResponse authed e =
|
||||||
"code" .= (cs c::T.Text),
|
let status = httpStatus authed e
|
||||||
"message" .= (cs m::T.Text),
|
jsonType = toHeader CTApplicationJSON
|
||||||
"details" .= (fmap cs d::Maybe T.Text),
|
wwwAuth = ("WWW-Authenticate", "Bearer")
|
||||||
"hint" .= (fmap cs h::Maybe T.Text)]
|
hdrs = if status == HT.status401
|
||||||
toJSON (H.TxError (P.NoResult d)) = JSON.object [
|
then [jsonType, wwwAuth]
|
||||||
"message" .= ("No response from server"::T.Text),
|
else [jsonType] in
|
||||||
"details" .= (fmap cs d::Maybe T.Text)]
|
responseLBS status hdrs (JSON.encode e)
|
||||||
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
|
prettyUsageError :: P.UsageError -> Text
|
||||||
httpStatus (H.TxError (P.ErroneousResult codeBS _ _ _)) =
|
prettyUsageError (P.ConnectionError e) =
|
||||||
let code = cs codeBS in
|
"Database connection error:\n" <> toS (fromMaybe "" e)
|
||||||
case code of
|
prettyUsageError e = show $ JSON.encode e
|
||||||
|
|
||||||
|
singularityError :: Integer -> Response
|
||||||
|
singularityError numRows =
|
||||||
|
responseLBS HT.status406
|
||||||
|
[toHeader CTSingularJSON]
|
||||||
|
$ toS . formatGeneralError
|
||||||
|
"JSON object requested, multiple (or no) rows returned"
|
||||||
|
$ unwords
|
||||||
|
[ "Results contain", show numRows, "rows,"
|
||||||
|
, toS (toMime CTSingularJSON), "requires 1 row"
|
||||||
|
]
|
||||||
|
|
||||||
|
formatParserError :: ParseError -> Text
|
||||||
|
formatParserError e = formatGeneralError message details
|
||||||
|
where
|
||||||
|
message = show $ errorPos e
|
||||||
|
details = strip $ replace "\n" " " $ toS
|
||||||
|
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
|
||||||
|
|
||||||
|
formatGeneralError :: Text -> Text -> Text
|
||||||
|
formatGeneralError message details = toS . JSON.encode $
|
||||||
|
JSON.object ["message" .= message, "details" .= details]
|
||||||
|
|
||||||
|
instance JSON.ToJSON P.UsageError where
|
||||||
|
toJSON (P.ConnectionError e) = JSON.object [
|
||||||
|
"code" .= ("" :: Text),
|
||||||
|
"message" .= ("Connection error" :: Text),
|
||||||
|
"details" .= (toS $ fromMaybe "" e :: 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" .= (toS c::Text),
|
||||||
|
"message" .= (toS m::Text),
|
||||||
|
"details" .= (fmap toS d::Maybe Text),
|
||||||
|
"hint" .= (fmap toS h::Maybe Text)]
|
||||||
|
toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [
|
||||||
|
"message" .= (m::Text)]
|
||||||
|
toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [
|
||||||
|
"message" .= ("Row error: end of input"::Text),
|
||||||
|
"details" .=
|
||||||
|
("Attempt to parse more columns than there are in the result"::Text),
|
||||||
|
"details" .= (("Row number " <> show i)::Text)]
|
||||||
|
toJSON (H.ResultError (H.RowError i H.UnexpectedNull)) = JSON.object [
|
||||||
|
"message" .= ("Row error: unexpected null"::Text),
|
||||||
|
"details" .= ("Attempt to parse a NULL as some value."::Text),
|
||||||
|
"details" .= (("Row number " <> show i)::Text)]
|
||||||
|
toJSON (H.ResultError (H.RowError i (H.ValueError d))) = JSON.object [
|
||||||
|
"message" .= ("Row error: Wrong value parser used"::Text),
|
||||||
|
"details" .= d,
|
||||||
|
"details" .= (("Row number " <> show i)::Text)]
|
||||||
|
toJSON (H.ResultError (H.UnexpectedAmountOfRows i)) = JSON.object [
|
||||||
|
"message" .= ("Unexpected amount of rows"::Text),
|
||||||
|
"details" .= i]
|
||||||
|
toJSON (H.ClientError d) = JSON.object [
|
||||||
|
"message" .= ("Database client error"::Text),
|
||||||
|
"details" .= (fmap toS d::Maybe Text)]
|
||||||
|
|
||||||
|
httpStatus :: Bool -> P.UsageError -> HT.Status
|
||||||
|
httpStatus _ (P.ConnectionError _) = HT.status500
|
||||||
|
httpStatus authed (P.SessionError (H.ResultError (H.ServerError c _ _ _))) =
|
||||||
|
case toS 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
|
||||||
@@ -68,10 +124,12 @@ httpStatus (H.TxError (P.ErroneousResult codeBS _ _ _)) =
|
|||||||
'5':'8':_ -> HT.status500 -- system error
|
'5':'8':_ -> HT.status500 -- system error
|
||||||
'F':'0':_ -> HT.status500 -- conf file error
|
'F':'0':_ -> HT.status500 -- conf file error
|
||||||
'H':'V':_ -> HT.status500 -- foreign data wrapper error
|
'H':'V':_ -> HT.status500 -- foreign data wrapper error
|
||||||
|
"P0001" -> HT.status400 -- default code for "raise"
|
||||||
'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
|
"42883" -> HT.status404 -- undefined function
|
||||||
"42501" -> HT.status404 -- insufficient privilege
|
"42P01" -> HT.status404 -- undefined table
|
||||||
_ -> HT.status400
|
"42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege
|
||||||
httpStatus (H.TxError (P.NoResult _)) = HT.status503
|
_ -> HT.status400
|
||||||
httpStatus _ = HT.status500
|
httpStatus _ (P.SessionError (H.ResultError _)) = HT.status500
|
||||||
|
httpStatus _ (P.SessionError (H.ClientError _)) = HT.status503
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
module Main where
|
|
||||||
|
|
||||||
|
|
||||||
import PostgREST.App
|
|
||||||
import PostgREST.Config (AppConfig (..),
|
|
||||||
minimumPgVersion,
|
|
||||||
prettyVersion,
|
|
||||||
readOptions)
|
|
||||||
import PostgREST.Error (pgErrResponse, PgError)
|
|
||||||
import PostgREST.Middleware
|
|
||||||
import PostgREST.DbStructure
|
|
||||||
|
|
||||||
import Control.Monad (unless)
|
|
||||||
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 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)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
body <- strictRequestBody req
|
|
||||||
resOrError <- liftIO $ H.session pool $ H.tx txSettings $
|
|
||||||
runWithClaims conf (app dbStructure conf body) req
|
|
||||||
either (respond . pgErrResponse) respond resOrError
|
|
||||||
+34
-51
@@ -3,70 +3,53 @@
|
|||||||
|
|
||||||
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 qualified Hasql.Transaction as H
|
||||||
import Data.Time.Clock.POSIX (getPOSIXTime)
|
|
||||||
import qualified Hasql as H
|
|
||||||
import qualified Hasql.Postgres as P
|
|
||||||
|
|
||||||
import Network.HTTP.Types.Header (hAccept, hAuthorization)
|
import Network.HTTP.Types.Status (unauthorized401, status500)
|
||||||
import Network.HTTP.Types.Status (status415, status400)
|
import Network.Wai (Application, Response,
|
||||||
import Network.Wai (Application, Request (..), Response,
|
responseLBS)
|
||||||
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(..), ContentType(..),
|
||||||
import PostgREST.Auth (setRole, jwtClaims, claimsToSQL)
|
toHeader)
|
||||||
|
import PostgREST.Auth (claimsToSQL, JWTAttempt(..))
|
||||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||||
import PostgREST.Error (errResponse)
|
import PostgREST.Error (errResponse)
|
||||||
|
|
||||||
import System.IO.Unsafe (unsafePerformIO)
|
import Protolude hiding (concat, null)
|
||||||
|
|
||||||
import Prelude hiding(concat)
|
runWithClaims :: AppConfig -> JWTAttempt ->
|
||||||
|
(ApiRequest -> H.Transaction Response) ->
|
||||||
import qualified Data.Vector as V
|
ApiRequest -> H.Transaction Response
|
||||||
import qualified Hasql.Backend as B
|
runWithClaims conf eClaims app req =
|
||||||
import qualified Data.Map.Lazy as M
|
case eClaims of
|
||||||
|
JWTExpired -> return $ unauthed "JWT expired"
|
||||||
runWithClaims :: forall s. AppConfig ->
|
JWTInvalid -> return $ unauthed "JWT invalid"
|
||||||
(Request -> H.Tx P.Postgres s Response) ->
|
JWTMissingSecret -> return $ errResponse status500 "Server lacks JWT secret"
|
||||||
Request -> H.Tx P.Postgres s Response
|
JWTClaims claims -> do
|
||||||
runWithClaims conf app req = do
|
-- role claim defaults to anon if not specified in jwt
|
||||||
_ <- H.unitEx $ stmt setAnon
|
let setClaims = claimsToSQL (M.union claims (M.singleton "role" anon))
|
||||||
let time = unsafePerformIO getPOSIXTime
|
H.sql $ mconcat setClaims
|
||||||
case split (== ' ') (cs auth) of
|
mapM_ H.sql customReqCheck
|
||||||
("Bearer" : tokenStr : _) ->
|
app req
|
||||||
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 . toS $ configAnonRole conf
|
||||||
hdrs = requestHeaders req
|
customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf
|
||||||
jwtSecret = configJwtSecret conf
|
unauthed message = responseLBS unauthorized401
|
||||||
auth = fromMaybe "" $ lookup hAuthorization hdrs
|
[ toHeader CTApplicationJSON
|
||||||
anon = cs $ configAnonRole conf
|
, ( "WWW-Authenticate"
|
||||||
setAnon = setRole anon
|
, "Bearer error=\"invalid_token\", " <>
|
||||||
invalidJWT = return $ errResponse status400 "Invalid JWT"
|
"error_description=\"" <> message <> "\""
|
||||||
|
)
|
||||||
unsupportedAccept :: Application -> Application
|
]
|
||||||
unsupportedAccept app req respond =
|
(toS $ "{\"message\":\""<>message<>"\"}")
|
||||||
case accept of
|
|
||||||
Left _ -> respond $ errResponse status415 "Unsupported Accept header, try: application/json"
|
|
||||||
Right _ -> app req respond
|
|
||||||
where accept = pickContentType $ lookup hAccept $ requestHeaders req
|
|
||||||
|
|
||||||
defaultMiddle :: Application -> Application
|
defaultMiddle :: Application -> Application
|
||||||
defaultMiddle =
|
defaultMiddle =
|
||||||
gzip def
|
gzip def
|
||||||
. cors corsPolicy
|
. cors corsPolicy
|
||||||
. staticPolicy (only [("favicon.ico", "static/favicon.ico")])
|
. staticPolicy (only [("favicon.ico", "static/favicon.ico")])
|
||||||
. unsupportedAccept
|
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
module PostgREST.OpenAPI (
|
||||||
|
encodeOpenAPI
|
||||||
|
, isMalformedProxyUri
|
||||||
|
, pickProxy
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Control.Lens
|
||||||
|
import Data.Aeson (decode, encode)
|
||||||
|
import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList)
|
||||||
|
import Data.Maybe (fromJust)
|
||||||
|
import Data.String (IsString (..))
|
||||||
|
import Data.Text (unpack, pack, concat, intercalate, init, tail, toLower)
|
||||||
|
import qualified Data.Set as Set
|
||||||
|
import Network.URI (parseURI, isAbsoluteURI,
|
||||||
|
URI (..), URIAuth (..))
|
||||||
|
|
||||||
|
import Protolude hiding (concat, (&), Proxy, get, intercalate)
|
||||||
|
|
||||||
|
import Data.Swagger
|
||||||
|
|
||||||
|
import PostgREST.ApiRequest (ContentType(..), toMime)
|
||||||
|
import PostgREST.Config (prettyVersion)
|
||||||
|
import PostgREST.QueryBuilder (operators)
|
||||||
|
import PostgREST.Types (Table(..), Column(..), PgArg(..),
|
||||||
|
Proxy(..), ProcDescription(..))
|
||||||
|
|
||||||
|
makeMimeList :: [ContentType] -> MimeList
|
||||||
|
makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs
|
||||||
|
|
||||||
|
toSwaggerType :: Text -> SwaggerType t
|
||||||
|
toSwaggerType "text" = SwaggerString
|
||||||
|
toSwaggerType "integer" = SwaggerInteger
|
||||||
|
toSwaggerType "boolean" = SwaggerBoolean
|
||||||
|
toSwaggerType "numeric" = SwaggerNumber
|
||||||
|
toSwaggerType _ = SwaggerString
|
||||||
|
|
||||||
|
makeTableDef :: (Table, [Column], [Text]) -> (Text, Schema)
|
||||||
|
makeTableDef (t, cs, _) =
|
||||||
|
let tn = tableName t in
|
||||||
|
(tn, (mempty :: Schema)
|
||||||
|
& type_ .~ SwaggerObject
|
||||||
|
& properties .~ fromList (map makeProperty cs))
|
||||||
|
|
||||||
|
makeProperty :: Column -> (Text, Referenced Schema)
|
||||||
|
makeProperty c = (colName c, Inline u)
|
||||||
|
where
|
||||||
|
r = mempty :: Schema
|
||||||
|
s = if null $ colEnum c
|
||||||
|
then r
|
||||||
|
else r & enum_ .~ decode (encode (colEnum c))
|
||||||
|
t = s & type_ .~ toSwaggerType (colType c)
|
||||||
|
u = t & format ?~ colType c
|
||||||
|
|
||||||
|
makeProcDef :: ProcDescription -> (Text, Schema)
|
||||||
|
makeProcDef pd = ("(rpc) " <> pdName pd, s)
|
||||||
|
where
|
||||||
|
s = (mempty :: Schema)
|
||||||
|
& type_ .~ SwaggerObject
|
||||||
|
& properties .~ fromList (map makeProcProperty (pdArgs pd))
|
||||||
|
& required .~ map pgaName (filter pgaReq (pdArgs pd))
|
||||||
|
|
||||||
|
makeProcProperty :: PgArg -> (Text, Referenced Schema)
|
||||||
|
makeProcProperty (PgArg n t _) = (n, Inline s)
|
||||||
|
where
|
||||||
|
s = (mempty :: Schema)
|
||||||
|
& type_ .~ toSwaggerType t
|
||||||
|
& format ?~ t
|
||||||
|
|
||||||
|
makeOperatorPattern :: Text
|
||||||
|
makeOperatorPattern =
|
||||||
|
intercalate "|"
|
||||||
|
[ concat ["^", x, y, "[.]"] |
|
||||||
|
x <- ["not[.]", ""],
|
||||||
|
y <- map fst operators ]
|
||||||
|
|
||||||
|
makeRowFilter :: Column -> Param
|
||||||
|
makeRowFilter c =
|
||||||
|
(mempty :: Param)
|
||||||
|
& name .~ colName c
|
||||||
|
& required ?~ False
|
||||||
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
|
& in_ .~ ParamQuery
|
||||||
|
& type_ .~ SwaggerString
|
||||||
|
& format ?~ colType c
|
||||||
|
& pattern ?~ makeOperatorPattern)
|
||||||
|
|
||||||
|
makeRowFilters :: [Column] -> [Param]
|
||||||
|
makeRowFilters = map makeRowFilter
|
||||||
|
|
||||||
|
makeOrderItems :: [Column] -> [Text]
|
||||||
|
makeOrderItems cs =
|
||||||
|
[ concat [x, y, z] |
|
||||||
|
x <- map colName cs,
|
||||||
|
y <- [".asc", ".desc", ""],
|
||||||
|
z <- [".nullsfirst", ".nulllast", ""]
|
||||||
|
]
|
||||||
|
|
||||||
|
makeRangeParams :: [Param]
|
||||||
|
makeRangeParams =
|
||||||
|
[ (mempty :: Param)
|
||||||
|
& name .~ "Range"
|
||||||
|
& description ?~ "Limiting and Pagination"
|
||||||
|
& required ?~ False
|
||||||
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
|
& in_ .~ ParamHeader
|
||||||
|
& type_ .~ SwaggerString)
|
||||||
|
, (mempty :: Param)
|
||||||
|
& name .~ "Range-Unit"
|
||||||
|
& description ?~ "Limiting and Pagination"
|
||||||
|
& required ?~ False
|
||||||
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
|
& in_ .~ ParamHeader
|
||||||
|
& type_ .~ SwaggerString
|
||||||
|
& default_ .~ decode "\"items\"")
|
||||||
|
, (mempty :: Param)
|
||||||
|
& name .~ "offset"
|
||||||
|
& description ?~ "Limiting and Pagination"
|
||||||
|
& required ?~ False
|
||||||
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
|
& in_ .~ ParamQuery
|
||||||
|
& type_ .~ SwaggerString)
|
||||||
|
, (mempty :: Param)
|
||||||
|
& name .~ "limit"
|
||||||
|
& description ?~ "Limiting and Pagination"
|
||||||
|
& required ?~ False
|
||||||
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
|
& in_ .~ ParamQuery
|
||||||
|
& type_ .~ SwaggerString)
|
||||||
|
]
|
||||||
|
|
||||||
|
makePreferParam :: [Text] -> Param
|
||||||
|
makePreferParam ts =
|
||||||
|
(mempty :: Param)
|
||||||
|
& name .~ "Prefer"
|
||||||
|
& description ?~ "Preference"
|
||||||
|
& required ?~ False
|
||||||
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
|
& in_ .~ ParamHeader
|
||||||
|
& type_ .~ SwaggerString
|
||||||
|
& enum_ .~ decode (encode ts))
|
||||||
|
|
||||||
|
makeSelectParam :: Param
|
||||||
|
makeSelectParam =
|
||||||
|
(mempty :: Param)
|
||||||
|
& name .~ "select"
|
||||||
|
& description ?~ "Filtering Columns"
|
||||||
|
& required ?~ False
|
||||||
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
|
& in_ .~ ParamQuery
|
||||||
|
& type_ .~ SwaggerString)
|
||||||
|
|
||||||
|
makeGetParams :: [Column] -> [Param]
|
||||||
|
makeGetParams [] =
|
||||||
|
makeRangeParams ++
|
||||||
|
[ makeSelectParam
|
||||||
|
, makePreferParam ["count=none"]
|
||||||
|
]
|
||||||
|
makeGetParams cs =
|
||||||
|
makeRangeParams ++
|
||||||
|
[ makeSelectParam
|
||||||
|
, (mempty :: Param)
|
||||||
|
& name .~ "order"
|
||||||
|
& description ?~ "Ordering"
|
||||||
|
& required ?~ False
|
||||||
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
|
& in_ .~ ParamQuery
|
||||||
|
& type_ .~ SwaggerString
|
||||||
|
& enum_ .~ decode (encode $ makeOrderItems cs))
|
||||||
|
, makePreferParam ["count=none"]
|
||||||
|
]
|
||||||
|
|
||||||
|
makePostParams :: Text -> [Param]
|
||||||
|
makePostParams tn =
|
||||||
|
[ makePreferParam ["return=representation",
|
||||||
|
"return=minimal", "return=none"]
|
||||||
|
, (mempty :: Param)
|
||||||
|
& name .~ "body"
|
||||||
|
& description ?~ tn
|
||||||
|
& required ?~ False
|
||||||
|
& schema .~ ParamBody (Ref (Reference tn))
|
||||||
|
]
|
||||||
|
|
||||||
|
makeProcParam :: Text -> [Param]
|
||||||
|
makeProcParam refName =
|
||||||
|
[ makePreferParam ["params=single-object"]
|
||||||
|
, (mempty :: Param)
|
||||||
|
& name .~ "args"
|
||||||
|
& required ?~ True
|
||||||
|
& schema .~ ParamBody (Ref (Reference refName))
|
||||||
|
]
|
||||||
|
|
||||||
|
makeDeleteParams :: [Param]
|
||||||
|
makeDeleteParams =
|
||||||
|
[ makePreferParam ["return=representation", "return=minimal", "return=none"] ]
|
||||||
|
|
||||||
|
makePathItem :: (Table, [Column], [Text]) -> (FilePath, PathItem)
|
||||||
|
makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
|
||||||
|
where
|
||||||
|
tOp = (mempty :: Operation)
|
||||||
|
& tags .~ Set.fromList [tn]
|
||||||
|
& produces ?~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
|
& at 200 ?~ "OK"
|
||||||
|
getOp = tOp
|
||||||
|
& parameters .~ map Inline (makeGetParams cs ++ rs)
|
||||||
|
& at 206 ?~ "Partial Content"
|
||||||
|
postOp = tOp
|
||||||
|
& consumes ?~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
|
& parameters .~ map Inline (makePostParams tn)
|
||||||
|
& at 201 ?~ "Created"
|
||||||
|
patchOp = tOp
|
||||||
|
& consumes ?~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
|
& parameters .~ map Inline (makePostParams tn ++ rs)
|
||||||
|
& at 204 ?~ "No Content"
|
||||||
|
deletOp = tOp
|
||||||
|
& parameters .~ map Inline (makeDeleteParams ++ rs)
|
||||||
|
pr = (mempty :: PathItem) & get ?~ getOp
|
||||||
|
pw = pr & post ?~ postOp & patch ?~ patchOp & delete ?~ deletOp
|
||||||
|
p False = pr
|
||||||
|
p True = pw
|
||||||
|
rs = makeRowFilters cs
|
||||||
|
tn = tableName t
|
||||||
|
|
||||||
|
makeProcPathItem :: ProcDescription -> (FilePath, PathItem)
|
||||||
|
makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe)
|
||||||
|
where
|
||||||
|
postOp = (mempty :: Operation)
|
||||||
|
& parameters .~ map Inline (makeProcParam $ "(rpc) " <> pdName pd)
|
||||||
|
& tags .~ Set.fromList ["(rpc) " <> pdName pd]
|
||||||
|
& produces ?~ makeMimeList [CTApplicationJSON, CTSingularJSON]
|
||||||
|
& at 200 ?~ "OK"
|
||||||
|
pe = (mempty :: PathItem) & post ?~ postOp
|
||||||
|
|
||||||
|
makeRootPathItem :: (FilePath, PathItem)
|
||||||
|
makeRootPathItem = ("/", p)
|
||||||
|
where
|
||||||
|
getOp = (mempty :: Operation)
|
||||||
|
& tags .~ Set.fromList ["/"]
|
||||||
|
& produces ?~ makeMimeList [CTOpenAPI]
|
||||||
|
& at 200 ?~ "OK"
|
||||||
|
pr = (mempty :: PathItem) & get ?~ getOp
|
||||||
|
p = pr
|
||||||
|
|
||||||
|
makePathItems :: [ProcDescription] -> [(Table, [Column], [Text])] -> InsOrdHashMap FilePath PathItem
|
||||||
|
makePathItems pds ti = fromList $ makeRootPathItem :
|
||||||
|
map makePathItem ti ++ map makeProcPathItem pds
|
||||||
|
|
||||||
|
escapeHostName :: Text -> Text
|
||||||
|
escapeHostName "*" = "0.0.0.0"
|
||||||
|
escapeHostName "*4" = "0.0.0.0"
|
||||||
|
escapeHostName "!4" = "0.0.0.0"
|
||||||
|
escapeHostName "*6" = "0.0.0.0"
|
||||||
|
escapeHostName "!6" = "0.0.0.0"
|
||||||
|
escapeHostName h = h
|
||||||
|
|
||||||
|
postgrestSpec :: [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> Swagger
|
||||||
|
postgrestSpec pds ti (s, h, p, b) = (mempty :: Swagger)
|
||||||
|
& basePath ?~ unpack b
|
||||||
|
& schemes ?~ [s']
|
||||||
|
& info .~ ((mempty :: Info)
|
||||||
|
& version .~ prettyVersion
|
||||||
|
& title .~ "PostgREST API"
|
||||||
|
& description ?~ "This is a dynamic API generated by PostgREST")
|
||||||
|
& host .~ h'
|
||||||
|
& definitions .~ fromList (map makeTableDef ti <> map makeProcDef pds)
|
||||||
|
& paths .~ makePathItems pds ti
|
||||||
|
where
|
||||||
|
s' = if s == "http" then Http else Https
|
||||||
|
h' = Just $ Host (unpack $ escapeHostName h) (Just (fromInteger p))
|
||||||
|
|
||||||
|
encodeOpenAPI :: [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> LByteString
|
||||||
|
encodeOpenAPI pds ti uri = encode $ postgrestSpec pds ti uri
|
||||||
|
|
||||||
|
{-|
|
||||||
|
Test whether a proxy uri is malformed or not.
|
||||||
|
A valid proxy uri should be an absolute uri without query and user info,
|
||||||
|
only http(s) schemes are valid, port number range is 1-65535.
|
||||||
|
|
||||||
|
For example
|
||||||
|
http://postgrest.com/openapi.json
|
||||||
|
https://postgrest.com:8080/openapi.json
|
||||||
|
-}
|
||||||
|
isMalformedProxyUri :: Maybe Text -> Bool
|
||||||
|
isMalformedProxyUri Nothing = False
|
||||||
|
isMalformedProxyUri (Just uri)
|
||||||
|
| isAbsoluteURI (toS uri) = not $ isUriValid $ toURI uri
|
||||||
|
| otherwise = True
|
||||||
|
|
||||||
|
toURI :: Text -> URI
|
||||||
|
toURI uri = fromJust $ parseURI (toS uri)
|
||||||
|
|
||||||
|
pickProxy :: Maybe Text -> Maybe Proxy
|
||||||
|
pickProxy proxy
|
||||||
|
| isNothing proxy = Nothing
|
||||||
|
-- should never happen
|
||||||
|
-- since the request would have been rejected by the middleware if proxy uri
|
||||||
|
-- is malformed
|
||||||
|
| isMalformedProxyUri proxy = Nothing
|
||||||
|
| otherwise = Just Proxy {
|
||||||
|
proxyScheme = scheme
|
||||||
|
, proxyHost = host'
|
||||||
|
, proxyPort = port''
|
||||||
|
, proxyPath = path'
|
||||||
|
}
|
||||||
|
where
|
||||||
|
uri = toURI $ fromJust proxy
|
||||||
|
scheme = init $ toLower $ pack $ uriScheme uri
|
||||||
|
path URI {uriPath = ""} = "/"
|
||||||
|
path URI {uriPath = p} = p
|
||||||
|
path' = pack $ path uri
|
||||||
|
authority = fromJust $ uriAuthority uri
|
||||||
|
host' = pack $ uriRegName authority
|
||||||
|
port' = uriPort authority
|
||||||
|
readPort = fromMaybe 80 . readMaybe
|
||||||
|
port'' :: Integer
|
||||||
|
port'' = case (port', scheme) of
|
||||||
|
("", "http") -> 80
|
||||||
|
("", "https") -> 443
|
||||||
|
_ -> readPort $ unpack $ tail $ pack port'
|
||||||
|
|
||||||
|
isUriValid:: URI -> Bool
|
||||||
|
isUriValid = fAnd [isSchemeValid, isQueryValid, isAuthorityValid]
|
||||||
|
|
||||||
|
fAnd :: [a -> Bool] -> a -> Bool
|
||||||
|
fAnd fs x = all ($x) fs
|
||||||
|
|
||||||
|
isSchemeValid :: URI -> Bool
|
||||||
|
isSchemeValid URI {uriScheme = s}
|
||||||
|
| toLower (pack s) == "https:" = True
|
||||||
|
| toLower (pack s) == "http:" = True
|
||||||
|
| otherwise = False
|
||||||
|
|
||||||
|
isQueryValid :: URI -> Bool
|
||||||
|
isQueryValid URI {uriQuery = ""} = True
|
||||||
|
isQueryValid _ = False
|
||||||
|
|
||||||
|
isAuthorityValid :: URI -> Bool
|
||||||
|
isAuthorityValid URI {uriAuthority = a}
|
||||||
|
| isJust a = fAnd [isUserInfoValid, isHostValid, isPortValid] $ fromJust a
|
||||||
|
| otherwise = False
|
||||||
|
|
||||||
|
isUserInfoValid :: URIAuth -> Bool
|
||||||
|
isUserInfoValid URIAuth {uriUserInfo = ""} = True
|
||||||
|
isUserInfoValid _ = False
|
||||||
|
|
||||||
|
isHostValid :: URIAuth -> Bool
|
||||||
|
isHostValid URIAuth {uriRegName = ""} = False
|
||||||
|
isHostValid _ = True
|
||||||
|
|
||||||
|
isPortValid :: URIAuth -> Bool
|
||||||
|
isPortValid URIAuth {uriPort = ""} = True
|
||||||
|
isPortValid URIAuth {uriPort = (':':p)} =
|
||||||
|
case readMaybe p of
|
||||||
|
Just i -> i > (0 :: Integer) && i < 65536
|
||||||
|
Nothing -> False
|
||||||
|
isPortValid _ = False
|
||||||
+85
-43
@@ -1,94 +1,135 @@
|
|||||||
module PostgREST.Parsers
|
module PostgREST.Parsers where
|
||||||
-- ( parseGetRequest
|
|
||||||
-- )
|
|
||||||
where
|
|
||||||
|
|
||||||
import Control.Applicative hiding ((<$>))
|
import Protolude hiding (try, intercalate)
|
||||||
import Data.Monoid
|
import Control.Monad ((>>))
|
||||||
import Data.String.Conversions (cs)
|
import Data.Text (intercalate)
|
||||||
import Data.Text (Text)
|
import Data.List (init, last)
|
||||||
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 -> Text -> Either ParseError ReadRequest
|
||||||
pRequestSelect rootNodeName = do
|
pRequestSelect rootName selStr =
|
||||||
fieldTree <- pFieldForest
|
parse (pReadRequest rootName) ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
|
||||||
return $ foldr treeEntry (Node (Select [] [rootNodeName] [] Nothing, (rootNodeName, Nothing)) []) fieldTree
|
|
||||||
where
|
|
||||||
treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest
|
|
||||||
treeEntry (Node fld@((fn, _),_) fldForest) (Node (q, i) rForest) =
|
|
||||||
case fldForest of
|
|
||||||
[] -> Node (q {select=fld:select q}, i) rForest
|
|
||||||
_ -> Node (q, i) (foldr treeEntry (Node (Select [] [fn] [] Nothing, (fn, Nothing)) []) fldForest:rForest)
|
|
||||||
|
|
||||||
pRequestFilter :: (String, String) -> Either ParseError (Path, Filter)
|
pRequestFilter :: (Text, Text) -> Either ParseError (Path, Filter)
|
||||||
pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val)
|
pRequestFilter (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val)
|
||||||
where
|
where
|
||||||
treePath = parse pTreePath ("failed to parser tree path (" ++ k ++ ")") k
|
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
||||||
opVal = parse pOpValueExp ("failed to parse filter (" ++ v ++ ")") v
|
opVal = parse pOpValueExp ("failed to parse filter (" ++ toS v ++ ")") $ toS v
|
||||||
path = fst <$> treePath
|
path = fst <$> treePath
|
||||||
fld = snd <$> treePath
|
fld = snd <$> treePath
|
||||||
op = fst <$> opVal
|
op = fst <$> opVal
|
||||||
val = snd <$> opVal
|
val = snd <$> opVal
|
||||||
|
|
||||||
|
pRequestOrder :: (Text, Text) -> Either ParseError (Path, [OrderTerm])
|
||||||
|
pRequestOrder (k, v) = (,) <$> path <*> ord'
|
||||||
|
where
|
||||||
|
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
||||||
|
path = fst <$> treePath
|
||||||
|
ord' = parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v
|
||||||
|
|
||||||
|
pRequestRange :: (ByteString, NonnegRange) -> Either ParseError (Path, NonnegRange)
|
||||||
|
pRequestRange (k, v) = (,) <$> path <*> pure v
|
||||||
|
where
|
||||||
|
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
||||||
|
path = fst <$> treePath
|
||||||
|
|
||||||
ws :: Parser Text
|
ws :: Parser Text
|
||||||
ws = cs <$> many (oneOf " \t")
|
ws = toS <$> many (oneOf " \t")
|
||||||
|
|
||||||
lexeme :: Parser a -> Parser a
|
lexeme :: Parser a -> Parser a
|
||||||
lexeme p = ws *> p <* ws
|
lexeme p = ws *> p <* ws
|
||||||
|
|
||||||
|
pReadRequest :: Text -> Parser ReadRequest
|
||||||
|
pReadRequest rootNodeName = do
|
||||||
|
fieldTree <- pFieldForest
|
||||||
|
return $ foldr treeEntry (Node (readQuery, (rootNodeName, Nothing, Nothing)) []) fieldTree
|
||||||
|
where
|
||||||
|
readQuery = Select [] [rootNodeName] [] Nothing allRange
|
||||||
|
treeEntry :: Tree SelectItem -> ReadRequest -> ReadRequest
|
||||||
|
treeEntry (Node fld@((fn, _),_,alias) fldForest) (Node (q, i) rForest) =
|
||||||
|
case fldForest of
|
||||||
|
[] -> Node (q {select=fld:select q}, i) rForest
|
||||||
|
_ -> Node (q, i) newForest
|
||||||
|
where
|
||||||
|
newForest =
|
||||||
|
foldr treeEntry (Node (Select [] [fn] [] Nothing allRange, (fn, Nothing, alias)) []) fldForest:rForest
|
||||||
|
|
||||||
pTreePath :: Parser (Path,Field)
|
pTreePath :: Parser (Path,Field)
|
||||||
pTreePath = do
|
pTreePath = do
|
||||||
p <- pFieldName `sepBy1` pDelimiter
|
p <- pFieldName `sepBy1` pDelimiter
|
||||||
jp <- optionMaybe pJsonPath
|
jp <- optionMaybe pJsonPath
|
||||||
let pp = map cs p
|
return (init p, (last p, jp))
|
||||||
jpp = map cs <$> jp
|
|
||||||
return (init pp, (last pp, jpp))
|
|
||||||
|
|
||||||
pFieldForest :: Parser [Tree SelectItem]
|
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 = toS <$> (string "*" *> pure ("*"::ByteString))
|
||||||
|
|
||||||
|
|
||||||
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 toS 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 = toS <$> try (string "->" *> pFieldName)
|
||||||
|
|
||||||
pJsonPath :: Parser [Text]
|
pJsonPath :: Parser [Text]
|
||||||
pJsonPath = (++) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldName) )
|
pJsonPath = (<>) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldName) )
|
||||||
|
|
||||||
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, toS <$> 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 = toS <$> (pOp <?> "operator (eq, gt, ...)")
|
||||||
where pOp = foldl (<|>) empty $ map (try . string . cs . fst) operators
|
where pOp = foldl (<|>) empty $ map (try . string . toS . fst) operators
|
||||||
|
|
||||||
pValue :: Parser FValue
|
pValue :: Parser FValue
|
||||||
pValue = VText <$> (cs <$> many anyChar)
|
pValue = VText <$> (toS <$> many anyChar)
|
||||||
|
|
||||||
pDelimiter :: Parser Char
|
pDelimiter :: Parser Char
|
||||||
pDelimiter = char '.' <?> "delimiter (.)"
|
pDelimiter = char '.' <?> "delimiter (.)"
|
||||||
|
|
||||||
pOperatiorWithNegation :: Parser Operator
|
pOperatiorWithNegation :: Parser Operator
|
||||||
pOperatiorWithNegation = try ( (<>) <$> ( cs <$> string "not." ) <*> pOperator) <|> pOperator
|
pOperatiorWithNegation = try ( (<>) <$> ( toS <$> string "not." ) <*> pOperator) <|> pOperator
|
||||||
|
|
||||||
pOpValueExp :: Parser (Operator, FValue)
|
pOpValueExp :: Parser (Operator, FValue)
|
||||||
pOpValueExp = (,) <$> pOperatiorWithNegation <*> (pDelimiter *> pValue)
|
pOpValueExp = (,) <$> pOperatiorWithNegation <*> (pDelimiter *> pValue)
|
||||||
@@ -99,14 +140,15 @@ pOrder = lexeme pOrderTerm `sepBy` char ','
|
|||||||
pOrderTerm :: Parser OrderTerm
|
pOrderTerm :: Parser OrderTerm
|
||||||
pOrderTerm =
|
pOrderTerm =
|
||||||
try ( do
|
try ( do
|
||||||
c <- pFieldName
|
c <- pField
|
||||||
_ <- pDelimiter
|
d <- optionMaybe (try $ pDelimiter *> (
|
||||||
d <- (string "asc" *> pure OrderAsc)
|
try(string "asc" *> pure OrderAsc)
|
||||||
<|> (string "desc" *> pure OrderDesc)
|
<|> try(string "desc" *> pure OrderDesc)
|
||||||
|
))
|
||||||
nls <- optionMaybe (pDelimiter *> (
|
nls <- optionMaybe (pDelimiter *> (
|
||||||
try(string "nullslast" *> pure OrderNullsLast)
|
try(string "nullslast" *> pure OrderNullsLast)
|
||||||
<|> try(string "nullsfirst" *> pure OrderNullsFirst)
|
<|> try(string "nullsfirst" *> pure OrderNullsFirst)
|
||||||
))
|
))
|
||||||
return $ OrderTerm c d nls
|
return $ OrderTerm c d nls
|
||||||
)
|
)
|
||||||
<|> OrderTerm <$> (cs <$> pFieldName) <*> pure OrderAsc <*> pure Nothing
|
<|> OrderTerm <$> pField <*> pure Nothing <*> pure Nothing
|
||||||
|
|||||||
+261
-224
@@ -1,5 +1,5 @@
|
|||||||
{-# LANGUAGE FlexibleInstances #-}
|
{-# LANGUAGE FlexibleInstances #-}
|
||||||
{-# LANGUAGE TupleSections #-}
|
{-# LANGUAGE TupleSections #-}
|
||||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||||
{-|
|
{-|
|
||||||
Module : PostgREST.QueryBuilder
|
Module : PostgREST.QueryBuilder
|
||||||
@@ -12,139 +12,172 @@ and produces SQL Statements.
|
|||||||
Any function that outputs a SQL fragment should be in this module.
|
Any function that outputs a SQL fragment should be in this module.
|
||||||
-}
|
-}
|
||||||
module PostgREST.QueryBuilder (
|
module PostgREST.QueryBuilder (
|
||||||
addRelations
|
callProc
|
||||||
, addJoinConditions
|
|
||||||
, asJson
|
|
||||||
, callProc
|
|
||||||
, createReadStatement
|
, createReadStatement
|
||||||
, createWriteStatement
|
, createWriteStatement
|
||||||
|
, getJoinConditions
|
||||||
, operators
|
, operators
|
||||||
, pgFmtIdent
|
, pgFmtIdent
|
||||||
, pgFmtLit
|
, pgFmtLit
|
||||||
, requestToQuery
|
, requestToQuery
|
||||||
, sourceSubqueryName
|
, requestToCountQuery
|
||||||
|
, 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 PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset)
|
import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset, allRange)
|
||||||
import Control.Error (note, fromMaybe, mapMaybe)
|
import Data.Functor.Contravariant (contramap)
|
||||||
import Control.Monad (join)
|
|
||||||
import qualified Data.HashMap.Strict as HM
|
import qualified Data.HashMap.Strict as HM
|
||||||
import Data.List (find)
|
import Data.Text (intercalate, unwords, replace, isInfixOf, toLower, split)
|
||||||
import Data.Monoid ((<>))
|
import qualified Data.Text as T (map, takeWhile, null)
|
||||||
import Data.Text (Text, intercalate, unwords, replace, isInfixOf, toLower, split)
|
import qualified Data.Text.Encoding as T
|
||||||
import qualified Data.Text as T (map, takeWhile)
|
|
||||||
import Data.String.Conversions (cs)
|
|
||||||
import Control.Applicative (empty, (<|>))
|
|
||||||
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.Regex.TDFA ((=~))
|
import Text.InterpolatedString.Perl6 (qc)
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import Data.Scientific ( FPFormat (..)
|
import Data.Scientific ( FPFormat (..)
|
||||||
, formatScientific
|
, formatScientific
|
||||||
, isInteger
|
, isInteger
|
||||||
)
|
)
|
||||||
import Prelude hiding (unwords)
|
import Protolude hiding (from, intercalate, ord, cast)
|
||||||
|
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 -> Maybe NonnegRange -> Bool -> Bool -> Bool -> B.Stmt P.Postgres
|
standardRow :: HD.Row ResultsWithCount
|
||||||
createReadStatement selectQuery range isSingle countTable asCsv =
|
standardRow = (,,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8
|
||||||
B.Stmt (
|
<*> HD.value header <*> HD.value HD.bytea
|
||||||
wrapQuery selectQuery [
|
|
||||||
if countTable then countAllF else countNoneF,
|
|
||||||
countF,
|
|
||||||
"null", -- location header can not be calucalted
|
|
||||||
if asCsv
|
|
||||||
then asCsvF
|
|
||||||
else if isSingle then asJsonSingleF else asJsonF
|
|
||||||
] selectStarF range
|
|
||||||
) V.empty True
|
|
||||||
|
|
||||||
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool ->
|
|
||||||
[Text] -> Bool -> Payload -> B.Stmt P.Postgres
|
|
||||||
createWriteStatement _ _ _ _ _ _ (PayloadParseError _) = undefined
|
|
||||||
createWriteStatement selectQuery mutateQuery isSingle echoRequested
|
|
||||||
pKeys asCsv (PayloadJSON (UniformObjects rows)) =
|
|
||||||
B.Stmt (
|
|
||||||
wrapQuery mutateQuery [
|
|
||||||
countNoneF, -- when updateing it does not make sense
|
|
||||||
countF,
|
|
||||||
if isSingle then locationF pKeys else "null",
|
|
||||||
if echoRequested
|
|
||||||
then
|
|
||||||
if asCsv
|
|
||||||
then asCsvF
|
|
||||||
else if isSingle then asJsonSingleF else asJsonF
|
|
||||||
else "null"
|
|
||||||
|
|
||||||
] selectQuery Nothing
|
|
||||||
) (V.singleton . B.encodeValue . JSON.Array . V.map JSON.Object $ rows) True
|
|
||||||
|
|
||||||
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest
|
|
||||||
addRelations schema allRelations parentNode node@(Node n@(query, (table, _)) forest) =
|
|
||||||
case parentNode of
|
|
||||||
Nothing -> Node (query, (table, Nothing)) <$> updatedForest
|
|
||||||
(Just (Node (_, (parentTable, _)) _)) -> Node <$> (addRel n <$> rel) <*> updatedForest
|
|
||||||
where
|
|
||||||
rel = note ("no relation between " <> table <> " and " <> parentTable)
|
|
||||||
$ findRelation schema table parentTable
|
|
||||||
<|> findRelation schema parentTable table
|
|
||||||
addRel :: (ReadQuery, (NodeName, Maybe Relation)) -> Relation -> (ReadQuery, (NodeName, Maybe Relation))
|
|
||||||
addRel (q, (t, _)) r = (q, (t, Just r))
|
|
||||||
where
|
where
|
||||||
updatedForest = mapM (addRelations schema allRelations (Just node)) forest
|
header = HD.array $ HD.arrayDimension replicateM $ HD.arrayValue HD.bytea
|
||||||
findRelation s t1 t2 =
|
|
||||||
find (\r -> s == (tableSchema . relTable) r && t1 == (tableName . relTable) r && t2 == (tableName . relFTable) r) allRelations
|
|
||||||
|
|
||||||
addJoinConditions :: Schema -> ReadRequest -> Either Text ReadRequest
|
noLocationF :: Text
|
||||||
addJoinConditions schema (Node (query, (n, r)) forest) =
|
noLocationF = "array[]::text[]"
|
||||||
case r of
|
|
||||||
Nothing -> Node (updatedQuery, (n,r)) <$> updatedForest -- this is the root node
|
{-| Read and Write api requests use a similar response format which includes
|
||||||
Just rel@(Relation{relType=Child}) -> Node (addCond updatedQuery (getJoinConditions rel),(n,r)) <$> updatedForest
|
various record counts and possible location header. This is the decoder
|
||||||
Just (Relation{relType=Parent}) -> Node (updatedQuery, (n,r)) <$> updatedForest
|
for that common type of query.
|
||||||
Just rel@(Relation{relType=Many, relLTable=(Just linkTable)}) ->
|
-}
|
||||||
Node (qq, (n, r)) <$> updatedForest
|
decodeStandard :: HD.Result ResultsWithCount
|
||||||
where
|
decodeStandard =
|
||||||
q = addCond updatedQuery (getJoinConditions rel)
|
HD.singleRow standardRow
|
||||||
qq = q{from=tableName linkTable : from q}
|
|
||||||
_ -> Left "unknown relation"
|
decodeStandardMay :: HD.Result (Maybe ResultsWithCount)
|
||||||
|
decodeStandardMay =
|
||||||
|
HD.maybeRow standardRow
|
||||||
|
|
||||||
|
{-| JSON and CSV payloads from the client are given to us as
|
||||||
|
PayloadJSON (objects who all have the same keys),
|
||||||
|
and we turn this into an old fasioned JSON array
|
||||||
|
-}
|
||||||
|
encodeUniformObjs :: HE.Params PayloadJSON
|
||||||
|
encodeUniformObjs =
|
||||||
|
contramap (JSON.Array . V.map JSON.Object . unPayloadJSON) (HE.value HE.json)
|
||||||
|
|
||||||
|
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
|
||||||
|
H.Query () ResultsWithCount
|
||||||
|
createReadStatement selectQuery countQuery isSingle countTotal asCsv =
|
||||||
|
unicodeStatement sql HE.unit decodeStandard False
|
||||||
|
where
|
||||||
|
sql = [qc|
|
||||||
|
WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols}
|
||||||
|
FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]
|
||||||
|
countResultF = if countTotal then "("<>countQuery<>")" else "null"
|
||||||
|
cols = intercalate ", " [
|
||||||
|
countResultF <> " AS total_result_set",
|
||||||
|
"pg_catalog.count(_postgrest_t) AS page_total",
|
||||||
|
noLocationF <> " AS header",
|
||||||
|
bodyF <> " AS body"
|
||||||
|
]
|
||||||
|
bodyF
|
||||||
|
| asCsv = asCsvF
|
||||||
|
| isSingle = asJsonSingleF
|
||||||
|
| otherwise = asJsonF
|
||||||
|
|
||||||
|
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
|
||||||
|
PreferRepresentation -> [Text] ->
|
||||||
|
H.Query PayloadJSON (Maybe ResultsWithCount)
|
||||||
|
createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys =
|
||||||
|
unicodeStatement sql encodeUniformObjs decodeStandardMay True
|
||||||
|
|
||||||
|
where
|
||||||
|
sql = case rep of
|
||||||
|
None -> [qc|
|
||||||
|
WITH {sourceCTEName} AS ({mutateQuery})
|
||||||
|
SELECT '', 0, {noLocationF}, '' |]
|
||||||
|
HeadersOnly -> [qc|
|
||||||
|
WITH {sourceCTEName} AS ({mutateQuery})
|
||||||
|
SELECT {cols}
|
||||||
|
FROM (SELECT 1 FROM {sourceCTEName}) _postgrest_t |]
|
||||||
|
Full -> [qc|
|
||||||
|
WITH {sourceCTEName} AS ({mutateQuery})
|
||||||
|
SELECT {cols}
|
||||||
|
FROM ({selectQuery}) _postgrest_t |]
|
||||||
|
|
||||||
|
cols = intercalate ", " [
|
||||||
|
"'' AS total_result_set", -- when updateing it does not make sense
|
||||||
|
"pg_catalog.count(_postgrest_t) AS page_total",
|
||||||
|
if wantHdrs
|
||||||
|
then locationF pKeys
|
||||||
|
else noLocationF <> " AS header",
|
||||||
|
if rep == Full
|
||||||
|
then bodyF <> " AS body"
|
||||||
|
else "''"
|
||||||
|
]
|
||||||
|
|
||||||
|
bodyF
|
||||||
|
| asCsv = asCsvF
|
||||||
|
| wantSingle = asJsonSingleF
|
||||||
|
| otherwise = asJsonF
|
||||||
|
|
||||||
|
type ProcResults = (Maybe Int64, Int64, ByteString)
|
||||||
|
callProc :: QualifiedIdentifier -> JSON.Object -> SqlQuery -> SqlQuery -> NonnegRange -> Bool -> Bool -> Bool -> H.Query () (Maybe ProcResults)
|
||||||
|
callProc qi params selectQuery countQuery _ countTotal isSingle paramsAsJson =
|
||||||
|
unicodeStatement sql HE.unit decodeProc True
|
||||||
where
|
where
|
||||||
-- add parentTable and parentJoinConditions to the query
|
sql = [qc|
|
||||||
updatedQuery = foldr (flip addCond) query parentJoinConditions
|
WITH {sourceCTEName} AS ({_callSql})
|
||||||
where
|
SELECT
|
||||||
parentJoinConditions = map (getJoinConditions . snd) parents
|
{countResultF} AS total_result_set,
|
||||||
parents = mapMaybe (getParents . rootLabel) forest
|
pg_catalog.count(_postgrest_t) AS page_total,
|
||||||
getParents (_, (tbl, Just rel@(Relation{relType=Parent}))) = Just (tbl, rel)
|
case
|
||||||
getParents _ = Nothing
|
when pg_catalog.count(*) > 1 then
|
||||||
updatedForest = mapM (addJoinConditions schema) forest
|
{bodyF}
|
||||||
addCond q con = q{flt_=con ++ flt_ q}
|
else
|
||||||
|
coalesce(((array_agg(row_to_json(_postgrest_t)))[1]->{_procName})::character varying, {bodyF})
|
||||||
|
|
||||||
asJson :: StatementT
|
end as body
|
||||||
asJson s = s {
|
FROM ({selectQuery}) _postgrest_t;
|
||||||
B.stmtTemplate =
|
|]
|
||||||
"array_to_json(coalesce(array_agg(row_to_json(t)), '{}'))::character varying from ("
|
-- FROM (select * from {sourceCTEName} {limitF range}) t;
|
||||||
<> B.stmtTemplate s <> ") t" }
|
countResultF = if countTotal then "("<>countQuery<>")" else "null::bigint" :: Text
|
||||||
|
_args = if paramsAsJson
|
||||||
callProc :: QualifiedIdentifier -> JSON.Object -> PStmt
|
then insertableValueWithType "json" $ JSON.Object params
|
||||||
callProc qi params = do
|
else intercalate "," $ map _assignment (HM.toList params)
|
||||||
let args = intercalate "," $ map assignment (HM.toList params)
|
_procName = pgFmtLit $ qiName qi
|
||||||
B.Stmt ("select * from " <> fromQi qi <> "(" <> args <> ")") empty True
|
_assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v
|
||||||
where
|
_callSql = [qc|select * from {fromQi qi}({_args}) |] :: Text
|
||||||
assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v
|
_countExpr = if countTotal
|
||||||
|
then [qc|(select pg_catalog.count(*) from {sourceCTEName})|]
|
||||||
|
else "null::bigint" :: Text
|
||||||
|
decodeProc = HD.maybeRow procRow
|
||||||
|
procRow = (,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8
|
||||||
|
<*> HD.value HD.bytea
|
||||||
|
bodyF
|
||||||
|
| isSingle = asJsonSingleF
|
||||||
|
| otherwise = asJsonF
|
||||||
|
|
||||||
operators :: [(Text, SqlFragment)]
|
operators :: [(Text, SqlFragment)]
|
||||||
operators = [
|
operators = [
|
||||||
@@ -166,77 +199,109 @@ operators = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
pgFmtIdent :: SqlFragment -> SqlFragment
|
pgFmtIdent :: SqlFragment -> SqlFragment
|
||||||
pgFmtIdent x =
|
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
|
||||||
let escaped = replace "\"" "\"\"" (trimNullChars $ cs x) in
|
|
||||||
if (cs escaped :: BS.ByteString) =~ danger
|
|
||||||
then "\"" <> escaped <> "\""
|
|
||||||
else escaped
|
|
||||||
where danger = "^$|^[^a-z_]|[^a-z_0-9]" :: BS.ByteString
|
|
||||||
|
|
||||||
pgFmtLit :: SqlFragment -> SqlFragment
|
pgFmtLit :: SqlFragment -> SqlFragment
|
||||||
pgFmtLit x =
|
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
|
||||||
|
|
||||||
requestToQuery :: Schema -> DbRequest -> SqlQuery
|
requestToCountQuery :: Schema -> DbRequest -> SqlQuery
|
||||||
requestToQuery _ (DbMutate (Insert _ (PayloadParseError _))) = undefined
|
requestToCountQuery _ (DbMutate _) = undefined
|
||||||
requestToQuery _ (DbMutate (Update _ (PayloadParseError _) _)) = undefined
|
requestToCountQuery schema (DbRead (Node (Select _ _ conditions _ _, (mainTbl, _, _)) _)) =
|
||||||
requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord, (mainTbl, _)) forest)) =
|
unwords [
|
||||||
|
"SELECT pg_catalog.count(*)",
|
||||||
|
"FROM ", fromQi qi,
|
||||||
|
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi) localConditions )) `emptyOnNull` localConditions
|
||||||
|
]
|
||||||
|
where
|
||||||
|
qi = if mainTbl == sourceCTEName
|
||||||
|
then QualifiedIdentifier "" mainTbl
|
||||||
|
else QualifiedIdentifier schema mainTbl
|
||||||
|
fn Filter{value=VText _} = True
|
||||||
|
fn Filter{value=VForeignKey _ _} = False
|
||||||
|
localConditions = filter fn conditions
|
||||||
|
|
||||||
|
requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
|
||||||
|
requestToQuery schema isParent (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 following helper functions are just to remove the "schema" part when the table is "source" which is the name
|
||||||
-- of our WITH query part
|
-- of our WITH query part
|
||||||
tblSchema tbl = if tbl == sourceSubqueryName then "" else schema
|
mainTbl = fromMaybe nodeName (tableName . relTable <$> maybeRelation)
|
||||||
|
tblSchema tbl = if tbl == sourceCTEName then "" else schema
|
||||||
qi = QualifiedIdentifier (tblSchema mainTbl) mainTbl
|
qi = QualifiedIdentifier (tblSchema mainTbl) mainTbl
|
||||||
toQi t = QualifiedIdentifier (tblSchema t) t
|
toQi t = QualifiedIdentifier (tblSchema t) t
|
||||||
query = unwords [
|
query = unwords [
|
||||||
("WITH " <> intercalate ", " (map fst withs)) `emptyOnNull` withs,
|
|
||||||
"SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
|
"SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
|
||||||
"FROM ", intercalate ", " (map (fromQi . toQi) tbls ++ map snd withs),
|
"FROM ", intercalate ", " (map (fromQi . toQi) tbls),
|
||||||
|
unwords joins,
|
||||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
||||||
orderF (fromMaybe [] ord)
|
orderF (fromMaybe [] ord),
|
||||||
|
if isParent then "" else limitF range
|
||||||
]
|
]
|
||||||
(withs, selects) = foldr getQueryParts ([],[]) forest
|
orderF ts =
|
||||||
getQueryParts :: Tree ReadNode -> ([(SqlFragment, Text)], [SqlFragment]) -> ([(SqlFragment,Text)], [SqlFragment])
|
if null ts
|
||||||
getQueryParts (Node n@(_, (table, Just (Relation {relType=Child}))) forst) (w,s) = (w,sel:s)
|
then ""
|
||||||
|
else "ORDER BY " <> clause
|
||||||
|
where
|
||||||
|
clause = intercalate "," (map queryTerm ts)
|
||||||
|
queryTerm :: OrderTerm -> Text
|
||||||
|
queryTerm t = " "
|
||||||
|
<> toS (pgFmtField qi $ otTerm t) <> " "
|
||||||
|
<> maybe "" show (otDirection t) <> " "
|
||||||
|
<> maybe "" show (otNullOrder t) <> " "
|
||||||
|
(joins, selects) = foldr getQueryParts ([],[]) forest
|
||||||
|
|
||||||
|
getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
|
||||||
|
getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias)) forst) (j,s) = (j,sel:s)
|
||||||
where
|
where
|
||||||
sel = "("
|
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 " <> table
|
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
|
||||||
where subquery = requestToQuery schema (DbRead (Node n forst))
|
where subquery = requestToQuery schema False (DbRead (Node n forst))
|
||||||
getQueryParts (Node n@(_, (table, Just (Relation {relType=Parent}))) forst) (w,s) = (wit:w,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 "<>table --TODO must be singular
|
node_name = fromMaybe name alias
|
||||||
wit = (table <> " AS ( " <> subquery <> " )", table)
|
local_table_name = table <> "_" <> node_name
|
||||||
where subquery = requestToQuery schema (DbRead (Node n forst))
|
replaceTableName localTableName (Filter a b (VForeignKey (QualifiedIdentifier "" _) c)) = Filter a b (VForeignKey (QualifiedIdentifier "" localTableName) c)
|
||||||
getQueryParts (Node n@(_, (table, Just (Relation {relType=Many}))) forst) (w,s) = (w,sel:s)
|
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 True (DbRead (Node n forst))
|
||||||
|
getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias)) forst) (j,s) = (j,sel:s)
|
||||||
where
|
where
|
||||||
sel = "("
|
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 " <> table
|
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
|
||||||
where subquery = requestToQuery schema (DbRead (Node n forst))
|
where subquery = requestToQuery schema False (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 _ _ = undefined
|
||||||
requestToQuery schema (DbMutate (Insert mainTbl (PayloadJSON (UniformObjects rows)))) =
|
requestToQuery schema _ (DbMutate (Insert mainTbl (PayloadJSON rows) returnings)) =
|
||||||
let qi = QualifiedIdentifier schema mainTbl
|
insInto <> vals <> ret
|
||||||
cols = map pgFmtIdent $ fromMaybe [] (HM.keys <$> (rows V.!? 0))
|
where qi = QualifiedIdentifier schema mainTbl
|
||||||
colsString = intercalate ", " cols in
|
cols = map pgFmtIdent $ fromMaybe [] (HM.keys <$> (rows V.!? 0))
|
||||||
unwords [
|
colsString = intercalate ", " cols
|
||||||
"INSERT INTO ", fromQi qi,
|
insInto = unwords [ "INSERT INTO" , fromQi qi,
|
||||||
" (" <> colsString <> ")" <>
|
if T.null colsString then "" else "(" <> colsString <> ")"
|
||||||
" SELECT " <> colsString <>
|
]
|
||||||
" FROM json_populate_recordset(null::" , fromQi qi, ", ?)",
|
vals = unwords $
|
||||||
" RETURNING " <> fromQi qi <> ".*"
|
if T.null colsString
|
||||||
]
|
then if V.null rows then ["SELECT null WHERE false"] else ["DEFAULT VALUES"]
|
||||||
requestToQuery schema (DbMutate (Update mainTbl (PayloadJSON (UniformObjects rows)) conditions)) =
|
else ["SELECT", colsString, "FROM json_populate_recordset(null::" , fromQi qi, ", $1)"]
|
||||||
|
ret = if null returnings
|
||||||
|
then ""
|
||||||
|
else unwords [" RETURNING ", intercalate ", " (map (pgFmtColumn qi) returnings)]
|
||||||
|
requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) conditions returnings)) =
|
||||||
case rows V.!? 0 of
|
case rows V.!? 0 of
|
||||||
Just obj ->
|
Just obj ->
|
||||||
let assignments = map
|
let assignments = map
|
||||||
@@ -245,74 +310,71 @@ requestToQuery schema (DbMutate (Update mainTbl (PayloadJSON (UniformObjects row
|
|||||||
"UPDATE ", fromQi qi,
|
"UPDATE ", fromQi qi,
|
||||||
" SET " <> intercalate "," assignments <> " ",
|
" SET " <> intercalate "," assignments <> " ",
|
||||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
||||||
"RETURNING " <> fromQi qi <> ".*"
|
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings
|
||||||
]
|
]
|
||||||
Nothing -> undefined
|
Nothing -> undefined
|
||||||
where
|
where
|
||||||
qi = QualifiedIdentifier schema mainTbl
|
qi = QualifiedIdentifier schema mainTbl
|
||||||
|
requestToQuery schema _ (DbMutate (Delete mainTbl conditions returnings)) =
|
||||||
requestToQuery schema (DbMutate (Delete mainTbl conditions)) =
|
|
||||||
query
|
query
|
||||||
where
|
where
|
||||||
qi = QualifiedIdentifier schema mainTbl
|
qi = QualifiedIdentifier schema mainTbl
|
||||||
query = unwords [
|
query = unwords [
|
||||||
"DELETE FROM ", fromQi qi,
|
"DELETE FROM ", fromQi qi,
|
||||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions,
|
||||||
"RETURNING " <> fromQi qi <> ".*"
|
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings
|
||||||
]
|
]
|
||||||
|
|
||||||
sourceSubqueryName :: SqlFragment
|
sourceCTEName :: SqlFragment
|
||||||
sourceSubqueryName = "pg_source"
|
sourceCTEName = "pg_source"
|
||||||
|
|
||||||
unquoted :: JSON.Value -> Text
|
unquoted :: JSON.Value -> Text
|
||||||
unquoted (JSON.String t) = t
|
unquoted (JSON.String t) = t
|
||||||
unquoted (JSON.Number n) =
|
unquoted (JSON.Number n) =
|
||||||
cs $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
|
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
|
||||||
unquoted (JSON.Bool b) = cs . show $ b
|
unquoted (JSON.Bool b) = show b
|
||||||
unquoted v = cs $ JSON.encode v
|
unquoted v = toS $ JSON.encode v
|
||||||
|
|
||||||
-- private functions
|
-- private functions
|
||||||
asCsvF :: SqlFragment
|
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 ( " <>
|
||||||
" SELECT row_to_json(hh) as r from " <> sourceSubqueryName <> " as hh limit 1" <>
|
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
|
||||||
" ) s" <>
|
" ) s" <>
|
||||||
" ) a" <>
|
" ) a" <>
|
||||||
")"
|
")"
|
||||||
asCsvBodyF = "coalesce(string_agg(substring(t::text, 2, length(t::text) - 2), '\n'), '')"
|
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_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(_postgrest_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(_postgrest_t)::text, ','), '')::character varying "
|
||||||
|
|
||||||
countAllF :: SqlFragment
|
|
||||||
countAllF = "(SELECT pg_catalog.count(1) FROM (SELECT * FROM " <> sourceSubqueryName <> ") a )"
|
|
||||||
|
|
||||||
countF :: SqlFragment
|
|
||||||
countF = "pg_catalog.count(t)"
|
|
||||||
|
|
||||||
countNoneF :: SqlFragment
|
|
||||||
countNoneF = "null"
|
|
||||||
|
|
||||||
locationF :: [Text] -> SqlFragment
|
locationF :: [Text] -> SqlFragment
|
||||||
locationF pKeys =
|
locationF pKeys =
|
||||||
"(" <>
|
"(" <>
|
||||||
" WITH s AS (SELECT row_to_json(ss) as r from " <> sourceSubqueryName <> " 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 r = if r == allRange
|
||||||
|
then ""
|
||||||
|
else "LIMIT " <> limit <> " OFFSET " <> offset
|
||||||
|
where
|
||||||
|
limit = maybe "ALL" show $ rangeLimit r
|
||||||
|
offset = show $ rangeOffset r
|
||||||
|
|
||||||
fromQi :: QualifiedIdentifier -> SqlFragment
|
fromQi :: QualifiedIdentifier -> SqlFragment
|
||||||
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
|
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
|
||||||
@@ -326,6 +388,7 @@ getJoinConditions (Relation t cols ft fcs typ lt lc1 lc2) =
|
|||||||
Child -> zipWith (toFilter tN ftN) cols fcs
|
Child -> zipWith (toFilter tN ftN) cols fcs
|
||||||
Parent -> zipWith (toFilter tN ftN) cols fcs
|
Parent -> zipWith (toFilter tN ftN) cols fcs
|
||||||
Many -> zipWith (toFilter tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toFilter ftN ltN) fcs (fromMaybe [] lc2)
|
Many -> zipWith (toFilter tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toFilter ftN ltN) fcs (fromMaybe [] lc2)
|
||||||
|
Root -> undefined --error "undefined getJoinConditions"
|
||||||
where
|
where
|
||||||
s = if typ == Parent then "" else tableSchema t
|
s = if typ == Parent then "" else tableSchema t
|
||||||
tN = tableName t
|
tN = tableName t
|
||||||
@@ -334,29 +397,23 @@ 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
|
||||||
|
|
||||||
orderF :: [OrderTerm] -> SqlFragment
|
|
||||||
orderF ts =
|
|
||||||
if null ts
|
|
||||||
then ""
|
|
||||||
else "ORDER BY " <> clause
|
|
||||||
where
|
|
||||||
clause = intercalate "," (map queryTerm ts)
|
|
||||||
queryTerm :: OrderTerm -> Text
|
|
||||||
queryTerm t = " "
|
|
||||||
<> cs (pgFmtIdent $ otTerm t) <> " "
|
|
||||||
<> (cs.show) (otDirection t) <> " "
|
|
||||||
<> maybe "" (cs.show) (otNullOrder t) <> " "
|
|
||||||
|
|
||||||
insertableValue :: JSON.Value -> SqlFragment
|
insertableValue :: JSON.Value -> SqlFragment
|
||||||
insertableValue JSON.Null = "null"
|
insertableValue JSON.Null = "null"
|
||||||
insertableValue v = (<> "::unknown") . pgFmtLit $ unquoted v
|
insertableValue v = (<> "::unknown") . pgFmtLit $ unquoted v
|
||||||
|
|
||||||
|
insertableValueWithType :: Text -> JSON.Value -> SqlFragment
|
||||||
|
insertableValueWithType t v =
|
||||||
|
pgFmtLit (unquoted v) <> "::" <> t
|
||||||
|
|
||||||
whiteList :: Text -> SqlFragment
|
whiteList :: Text -> SqlFragment
|
||||||
whiteList val = fromMaybe
|
whiteList val = fromMaybe
|
||||||
(cs (pgFmtLit val) <> "::unknown ")
|
(toS (pgFmtLit val) <> "::unknown ")
|
||||||
(find ((==) . toLower $ val) ["null","true","false"])
|
(find ((==) . toLower $ val) ["null","true","false"])
|
||||||
|
|
||||||
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
|
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
|
||||||
@@ -367,8 +424,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) =
|
||||||
@@ -377,7 +434,7 @@ pgFmtCondition table (Filter (col,jp) ops val) =
|
|||||||
where
|
where
|
||||||
headPredicate:rest = split (=='.') ops
|
headPredicate:rest = split (=='.') ops
|
||||||
hasNot caseTrue caseFalse = if headPredicate == "not" then caseTrue else caseFalse
|
hasNot caseTrue caseFalse = if headPredicate == "not" then caseTrue else caseFalse
|
||||||
opCode = hasNot (head rest) headPredicate
|
opCode = hasNot (headDef "eq" rest) headPredicate
|
||||||
notOp = hasNot headPredicate ""
|
notOp = hasNot headPredicate ""
|
||||||
sqlCol = case val of
|
sqlCol = case val of
|
||||||
VText _ -> pgFmtColumn table col <> pgFmtJsonPath jp
|
VText _ -> pgFmtColumn table col <> pgFmtJsonPath jp
|
||||||
@@ -389,7 +446,7 @@ pgFmtCondition table (Filter (col,jp) ops val) =
|
|||||||
valToStr v = case v of
|
valToStr v = case v of
|
||||||
VText s -> pgFmtValue opCode s
|
VText s -> pgFmtValue opCode s
|
||||||
VForeignKey (QualifiedIdentifier s _) (ForeignKey Column{colTable=Table{tableName=ft}, colName=fc}) -> pgFmtColumn qi fc
|
VForeignKey (QualifiedIdentifier s _) (ForeignKey Column{colTable=Table{tableName=ft}, colName=fc}) -> pgFmtColumn qi fc
|
||||||
where qi = QualifiedIdentifier (if ft == sourceSubqueryName then "" else s) ft
|
where qi = QualifiedIdentifier (if ft == sourceCTEName then "" else s) ft
|
||||||
_ -> ""
|
_ -> ""
|
||||||
|
|
||||||
pgFmtValue :: Text -> Text -> SqlFragment
|
pgFmtValue :: Text -> Text -> SqlFragment
|
||||||
@@ -415,32 +472,12 @@ 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 = case lastMay xx of
|
||||||
|
Just alias -> " AS " <> pgFmtIdent alias
|
||||||
|
Nothing -> ""
|
||||||
|
pgFmtAs _ (Just alias) = " AS " <> pgFmtIdent alias
|
||||||
|
|
||||||
trimNullChars :: Text -> Text
|
trimNullChars :: Text -> Text
|
||||||
trimNullChars = T.takeWhile (/= '\x0')
|
trimNullChars = T.takeWhile (/= '\x0')
|
||||||
|
|
||||||
withSourceF :: SqlFragment -> SqlFragment
|
|
||||||
withSourceF s = "WITH " <> sourceSubqueryName <> " AS (" <> s <>")"
|
|
||||||
|
|
||||||
fromF :: SqlFragment -> SqlFragment -> SqlFragment
|
|
||||||
fromF sel limit = "FROM (" <> sel <> " " <> limit <> ") t"
|
|
||||||
|
|
||||||
limitF :: Maybe NonnegRange -> SqlFragment
|
|
||||||
limitF r = "LIMIT " <> limit <> " OFFSET " <> offset
|
|
||||||
where
|
|
||||||
limit = maybe "ALL" (cs . show) $ join $ rangeLimit <$> r
|
|
||||||
offset = cs . show $ fromMaybe 0 $ rangeOffset <$> r
|
|
||||||
|
|
||||||
selectStarF :: SqlFragment
|
|
||||||
selectStarF = "SELECT * FROM " <> sourceSubqueryName
|
|
||||||
|
|
||||||
wrapQuery :: SqlQuery -> [Text] -> Text -> Maybe NonnegRange -> SqlQuery
|
|
||||||
wrapQuery source selectColumns returnSelect range =
|
|
||||||
withSourceF source <>
|
|
||||||
" SELECT " <>
|
|
||||||
intercalate ", " selectColumns <>
|
|
||||||
" " <>
|
|
||||||
fromF returnSelect ( limitF range )
|
|
||||||
|
|||||||
+32
-22
@@ -3,59 +3,69 @@ module PostgREST.RangeQuery (
|
|||||||
, rangeRequested
|
, rangeRequested
|
||||||
, rangeLimit
|
, rangeLimit
|
||||||
, rangeOffset
|
, rangeOffset
|
||||||
|
, 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
|
||||||
import Data.Ranged.Ranges
|
import Data.Ranged.Ranges
|
||||||
|
|
||||||
import Data.String.Conversions (cs)
|
|
||||||
import Text.Read (readMaybe)
|
|
||||||
import Text.Regex.TDFA ((=~))
|
import Text.Regex.TDFA ((=~))
|
||||||
|
|
||||||
import Data.Maybe (fromMaybe, listToMaybe)
|
import Data.List (lookup)
|
||||||
|
|
||||||
import Prelude
|
import Protolude
|
||||||
|
|
||||||
type NonnegRange = Range Int
|
type NonnegRange = Range Integer
|
||||||
|
|
||||||
rangeParse :: BS.ByteString -> Maybe NonnegRange
|
rangeParse :: BS.ByteString -> NonnegRange
|
||||||
rangeParse range = do
|
rangeParse range = do
|
||||||
let rangeRegex = "^([0-9]+)-([0-9]*)$" :: BS.ByteString
|
let rangeRegex = "^([0-9]+)-([0-9]*)$" :: BS.ByteString
|
||||||
|
|
||||||
parsedRange <- listToMaybe (range =~ rangeRegex :: [[BS.ByteString]])
|
case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of
|
||||||
|
Just parsedRange ->
|
||||||
|
let [_, mLower, mUpper] = readMaybe . toS <$> parsedRange
|
||||||
|
lower = fromMaybe emptyRange (rangeGeq <$> mLower)
|
||||||
|
upper = fromMaybe allRange (rangeLeq <$> mUpper) in
|
||||||
|
rangeIntersection lower upper
|
||||||
|
Nothing -> allRange
|
||||||
|
|
||||||
let [_, from, to] = readMaybe . cs <$> parsedRange
|
rangeRequested :: RequestHeaders -> NonnegRange
|
||||||
let lower = fromMaybe emptyRange (rangeGeq <$> from)
|
rangeRequested headers = fromMaybe allRange $
|
||||||
let upper = fromMaybe (rangeGeq 0) (rangeLeq <$> to)
|
rangeParse <$> lookup hRange headers
|
||||||
|
|
||||||
return $ rangeIntersection lower upper
|
restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange
|
||||||
|
restrictRange Nothing r = r
|
||||||
|
restrictRange (Just limit) r =
|
||||||
|
rangeIntersection r $
|
||||||
|
Range BoundaryBelowAll (BoundaryAbove $ rangeOffset r + limit - 1)
|
||||||
|
|
||||||
rangeRequested :: RequestHeaders -> Maybe NonnegRange
|
rangeLimit :: NonnegRange -> Maybe Integer
|
||||||
rangeRequested = (rangeParse =<<) . lookup hRange
|
|
||||||
|
|
||||||
rangeLimit :: NonnegRange -> Maybe Int
|
|
||||||
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 lower, BoundaryAbove upper] -> Just (1 + upper - lower)
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
|
|
||||||
rangeOffset :: NonnegRange -> Int
|
rangeOffset :: NonnegRange -> Integer
|
||||||
rangeOffset range =
|
rangeOffset range =
|
||||||
case rangeLower range of
|
case rangeLower range of
|
||||||
BoundaryBelow from -> from
|
BoundaryBelow lower -> lower
|
||||||
_ -> error "range without lower bound" -- should never happen
|
_ -> panic "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)
|
||||||
|
|||||||
+44
-26
@@ -1,16 +1,30 @@
|
|||||||
module PostgREST.Types where
|
module PostgREST.Types where
|
||||||
import Data.Text
|
import Protolude
|
||||||
import Data.Tree
|
import qualified GHC.Show
|
||||||
|
import Data.Aeson
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as BL
|
||||||
import qualified Data.ByteString as BS
|
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]
|
||||||
|
, dbProcs :: [(Text,ProcDescription)]
|
||||||
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
data PgArg = PgArg {
|
||||||
|
pgaName :: Text
|
||||||
|
, pgaType :: Text
|
||||||
|
, pgaReq :: Bool
|
||||||
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
data ProcDescription = ProcDescription {
|
||||||
|
pdName :: Text
|
||||||
|
, pdArgs :: [PgArg]
|
||||||
|
, pdReturnType :: Text
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
type Schema = Text
|
type Schema = Text
|
||||||
@@ -31,12 +45,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
|
||||||
@@ -62,8 +76,8 @@ instance Show OrderNulls where
|
|||||||
show OrderNullsLast = "nulls last"
|
show OrderNullsLast = "nulls last"
|
||||||
|
|
||||||
data OrderTerm = OrderTerm {
|
data OrderTerm = OrderTerm {
|
||||||
otTerm :: Text
|
otTerm :: Field
|
||||||
, otDirection :: OrderDirection
|
, otDirection :: Maybe OrderDirection
|
||||||
, otNullOrder :: Maybe OrderNulls
|
, otNullOrder :: Maybe OrderNulls
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
@@ -73,7 +87,7 @@ data QualifiedIdentifier = QualifiedIdentifier {
|
|||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
|
||||||
data RelationType = Child | Parent | Many deriving (Show, Eq)
|
data RelationType = Child | Parent | Many | Root deriving (Show, Eq)
|
||||||
data Relation = Relation {
|
data Relation = Relation {
|
||||||
relTable :: Table
|
relTable :: Table
|
||||||
, relColumns :: [Column]
|
, relColumns :: [Column]
|
||||||
@@ -87,31 +101,35 @@ data Relation = Relation {
|
|||||||
|
|
||||||
-- | An array of JSON objects that has been verified to have
|
-- | An array of JSON objects that has been verified to have
|
||||||
-- the same keys in every object
|
-- the same keys in every object
|
||||||
newtype UniformObjects = UniformObjects (V.Vector Object)
|
newtype PayloadJSON = PayloadJSON (V.Vector Object)
|
||||||
deriving (Show, Eq)
|
deriving (Show, Eq)
|
||||||
|
|
||||||
-- | When Hasql supports the COPY command then we can
|
unPayloadJSON :: PayloadJSON -> V.Vector Object
|
||||||
-- have a special payload just for CSV, but until
|
unPayloadJSON (PayloadJSON objs) = objs
|
||||||
-- then CSV is converted to a JSON array.
|
|
||||||
data Payload = PayloadJSON UniformObjects
|
data Proxy = Proxy {
|
||||||
| PayloadParseError BS.ByteString
|
proxyScheme :: Text
|
||||||
deriving (Show, Eq)
|
, proxyHost :: Text
|
||||||
|
, proxyPort :: Integer
|
||||||
|
, proxyPath :: Text
|
||||||
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
type Operator = Text
|
type Operator = Text
|
||||||
data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
|
data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
|
||||||
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::[Text], 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_::Text, qPayload::Payload }
|
data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] }
|
||||||
| Delete { in_::Text, where_::[Filter] }
|
| Delete { in_::TableName, where_::[Filter], returning::[FieldName] }
|
||||||
| Update { in_::Text, qPayload::Payload, where_::[Filter] } deriving (Show, Eq)
|
| Update { in_::TableName, qPayload::PayloadJSON, where_::[Filter], returning::[FieldName] } 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
|
||||||
|
|||||||
+7
-5
@@ -1,7 +1,9 @@
|
|||||||
flags: {}
|
resolver: lts-7.4
|
||||||
packages:
|
|
||||||
- '.'
|
|
||||||
extra-deps:
|
extra-deps:
|
||||||
- Ranged-sets-0.3.0
|
- Ranged-sets-0.3.0
|
||||||
- packdeps-0.4.1
|
- hasql-pool-0.4.1
|
||||||
resolver: nightly-2015-10-27
|
- hasql-transaction-0.5
|
||||||
|
ghc-options:
|
||||||
|
postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints
|
||||||
|
nix:
|
||||||
|
packages: [postgresql, zlib]
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
FROM debian:jessie
|
||||||
|
|
||||||
|
ENV PATH /root/.local/bin:$PATH
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y wget libpq-dev pkg-config libpcre3 libpcre3-dev \
|
||||||
|
postgresql-client debconf locales \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
||||||
|
&& echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen \
|
||||||
|
&& locale-gen \
|
||||||
|
&& echo 'export LC_ALL=en_US.UTF-8' >> /etc/profile \
|
||||||
|
&& wget -qO- https://get.haskellstack.org/ | sh
|
||||||
|
|
||||||
+100
-17
@@ -1,31 +1,68 @@
|
|||||||
module Feature.AuthSpec where
|
module Feature.AuthSpec where
|
||||||
|
|
||||||
-- {{{ Imports
|
-- {{{ Imports
|
||||||
|
import Text.Heredoc
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Network.HTTP.Types
|
import Network.HTTP.Types
|
||||||
|
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
spec :: Spec
|
spec :: SpecWith Application
|
||||||
spec = beforeAll
|
spec = describe "authorization" $ do
|
||||||
(clearTable "postgrest.auth") . afterAll_ (clearTable "postgrest.auth")
|
let single = ("Accept","application/vnd.pgrst.object+json")
|
||||||
$ around withApp
|
|
||||||
$ 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" } |]
|
request methodPost "/rpc/login" [single]
|
||||||
|
[json| { "id": "jdoe", "pass": "1234" } |]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |]
|
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.P2G9EVSVI22MWxXWFuhEYd9BZerLS1WDlqzdqplM15s"} |]
|
||||||
, 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" $
|
||||||
|
request methodPost "/rpc/jwt_test" [single] "{}"
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.IHF16ZSU6XTbOnUWO8CCpUn2fJwt8P00rlYVyXQjpWc"} |]
|
||||||
|
, 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` [str|[{"iss":"joe","sub":"fun","aud":"everyone","exp":9999999999,"nbf":1300819380,"iat":1300819380,"jti":"foo","http://postgrest.com/foo":true}]|]
|
||||||
|
|
||||||
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] ""
|
||||||
@@ -45,26 +82,72 @@ spec = beforeAll
|
|||||||
it "fails with an expired token" $ do
|
it "fails with an expired token" $ do
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.enk_qZ_u6gZsXY4R8bREKB_HNExRpM0lIWSLktk9JJQ"
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.enk_qZ_u6gZsXY4R8bREKB_HNExRpM0lIWSLktk9JJQ"
|
||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` 400
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Nothing
|
||||||
|
, matchStatus = 401
|
||||||
|
, matchHeaders = [
|
||||||
|
"WWW-Authenticate" <:>
|
||||||
|
"Bearer error=\"invalid_token\", error_description=\"JWT expired\""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
it "hides tables from users with invalid JWT" $ do
|
it "hides tables from users with invalid JWT" $ do
|
||||||
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` 400
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Nothing
|
||||||
|
, matchStatus = 401
|
||||||
|
, matchHeaders = [
|
||||||
|
"WWW-Authenticate" <:>
|
||||||
|
"Bearer error=\"invalid_token\", error_description=\"JWT invalid\""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
it "should fail when jwt contains no claims" $ do
|
it "should fail when jwt contains no claims" $ do
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MKYc_lOECtB0LJOiykilAdlHodB-I0_id2qHKq35dmc"
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.lu-rG8aSCiw-aOlN0IxpRGz5r7Jwq7K9r3tuMPUpytI"
|
||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` 400
|
`shouldRespondWith` 401
|
||||||
|
|
||||||
it "hides tables from users with JWT that contain no claims about role" $ do
|
it "hides tables from users with JWT that contain no claims about role" $ do
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.zyohGMnrDy4_8eJTl6I2AUXO3MeCCiwR24aGWRkTE9o"
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.Jneso9X519Vh0z7i9PbXIu7W1HEoq9RRw9BBbyQKFCQ"
|
||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` 400
|
`shouldRespondWith` 401
|
||||||
|
|
||||||
it "recovers after 400 error with logged in user" $ do
|
it "recovers after 401 error with logged in user" $ do
|
||||||
_ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]
|
_ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||||
_ <- request methodPost "/rpc/problem" [auth] ""
|
_ <- request methodPost "/rpc/problem" [auth] ""
|
||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` 200
|
`shouldRespondWith` 200
|
||||||
|
|
||||||
|
describe "custom pre-request proc acting on id claim" $ do
|
||||||
|
|
||||||
|
it "able to switch to postgrest_test_author role (id=1)" $
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.mI2HNoOum6xM3sc4oHLxU4yLv-_WV5W1kqBfY_wEvLw" in
|
||||||
|
request methodPost "/rpc/get_current_user" [auth]
|
||||||
|
[json| {} |]
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|"postgrest_test_author"|]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
|
||||||
|
it "able to switch to postgrest_test_default_role (id=2)" $
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.W7jLsG-zswM91AJkCvZeIMHrnz7_6ceY2jnscVl3Yhk" in
|
||||||
|
request methodPost "/rpc/get_current_user" [auth]
|
||||||
|
[json| {} |]
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|"postgrest_test_default_role"|]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
|
||||||
|
it "raises error (id=3)" $
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.15Gy8PezQhJIaHYDJVLa-Gmz9T3sJnW66EKAYIsXc7c" in
|
||||||
|
request methodPost "/rpc/get_current_user" [auth]
|
||||||
|
[json| {} |]
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]
|
||||||
|
, matchStatus = 400
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
module Feature.BinaryJwtSecretSpec where
|
||||||
|
|
||||||
|
-- {{{ Imports
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Network.HTTP.Types
|
||||||
|
|
||||||
|
import SpecHelper
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec = describe "server started with binary JWT secret" $
|
||||||
|
|
||||||
|
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||||
|
it "succeeds with jwt token encoded with a binary secret" $ do
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.l_EcSRWeNtL4OKUTIplrHyioNrff9Rd0MV7RXNCxCyk"
|
||||||
|
request methodGet "/authors_only" [auth] ""
|
||||||
|
`shouldRespondWith` 200
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{-# 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)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -9,10 +9,14 @@ import qualified Data.ByteString.Lazy as BL
|
|||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
|
||||||
import Network.HTTP.Types
|
import Network.HTTP.Types
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
spec :: Spec
|
spec :: SpecWith Application
|
||||||
spec = around withApp $ describe "CORS" $ do
|
spec =
|
||||||
|
describe "CORS" $ do
|
||||||
let preflightHeaders = [
|
let preflightHeaders = [
|
||||||
("Accept", "*/*"),
|
("Accept", "*/*"),
|
||||||
("Origin", "http://example.com"),
|
("Origin", "http://example.com"),
|
||||||
@@ -67,4 +71,4 @@ spec = around withApp $ describe "CORS" $ do
|
|||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleHeaders r `shouldSatisfy` matchHeader
|
simpleHeaders r `shouldSatisfy` matchHeader
|
||||||
"Access-Control-Allow-Origin" "\\*"
|
"Access-Control-Allow-Origin" "\\*"
|
||||||
simpleBody r `shouldSatisfy` not . BL.null
|
simpleBody r `shouldSatisfy` BL.null
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ module Feature.DeleteSpec where
|
|||||||
|
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import SpecHelper
|
import Text.Heredoc
|
||||||
|
|
||||||
import Network.HTTP.Types
|
import Network.HTTP.Types
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
spec :: Spec
|
import Protolude hiding (get)
|
||||||
spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable "items")
|
|
||||||
. around withApp $
|
spec :: SpecWith Application
|
||||||
|
spec =
|
||||||
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" $
|
||||||
@@ -16,21 +18,52 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
|||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Nothing
|
matchBody = Nothing
|
||||||
, matchStatus = 204
|
, matchStatus = 204
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "returns the deleted item and count if requested" $
|
||||||
|
request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|[{"id":2}]|]
|
||||||
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "*/1"]
|
, 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" <:> "*/*"]
|
||||||
|
}
|
||||||
|
it "can rename and cast the selected columns" $
|
||||||
|
request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
|
||||||
|
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
|
||||||
|
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" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
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"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just "[{\"id\":15}]"
|
matchBody = Just [str|[{"id":15}]|]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/1"]
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
context "known route, unknown record" $
|
context "known route, no records matched" $
|
||||||
it "fails with 404" $
|
it "includes [] body if return=rep" $
|
||||||
request methodDelete "/items?id=eq.101" [] "" `shouldRespondWith` 404
|
request methodDelete "/items?id=eq.101"
|
||||||
|
[("Prefer", "return=representation")] ""
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just "[]"
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
context "totally unknown route" $
|
context "totally unknown route" $
|
||||||
it "fails with 404" $
|
it "fails with 404" $
|
||||||
|
|||||||
+252
-169
@@ -8,18 +8,22 @@ import Network.Wai.Test (SResponse(simpleBody,simpleHeaders,simpleStatus))
|
|||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
|
import Data.List (lookup)
|
||||||
import Data.Maybe (fromJust)
|
import Data.Maybe (fromJust)
|
||||||
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 :: Spec
|
import Protolude hiding (get)
|
||||||
spec = afterAll_ resetDb $ around withApp $ do
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
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| {
|
||||||
@@ -30,30 +34,56 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
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")]
|
||||||
[json| {
|
[json| [{
|
||||||
"integer": 14, "double": 3.14159, "varchar": "testing!"
|
"integer": 14, "double": 3.14159, "varchar": "testing!"
|
||||||
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
||||||
, "enum": "foo"
|
, "enum": "foo"
|
||||||
} |] `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"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context "requesting full representation" $ do
|
||||||
it "includes related data after insert" $
|
it "includes related data after insert" $
|
||||||
request methodPost "/projects?select=id,name,clients{id,name}" [("Prefer", "return=representation")]
|
request methodPost "/projects?select=id,name,clients{id,name}"
|
||||||
[str|{"id":5,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {
|
[("Prefer", "return=representation"), ("Prefer", "count=exact")]
|
||||||
matchBody = Just [str|{"id":5,"name":"New Project","clients":{"id":2,"name":"Apple"}}|]
|
[str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {
|
||||||
|
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.5"]
|
, matchHeaders = [ "Content-Type" <:> "application/json; charset=utf-8"
|
||||||
|
, "Location" <:> "/projects?id=eq.6"
|
||||||
|
, "Content-Range" <:> "*/1" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "can rename and cast the selected columns" $
|
||||||
|
request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[str|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|[{"pId":"7","pName":"New Project","cId":"2"}]|]
|
||||||
|
, matchStatus = 201
|
||||||
|
, matchHeaders = [ "Content-Type" <:> "application/json; charset=utf-8"
|
||||||
|
, "Location" <:> "/projects?id=eq.7"
|
||||||
|
, "Content-Range" <:> "*/*" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -68,11 +98,13 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
incNullableStr record `shouldBe` Nothing
|
incNullableStr record `shouldBe` Nothing
|
||||||
|
|
||||||
context "into a table with simple pk" $
|
context "into a table with simple pk" $
|
||||||
it "fails with 400 and error" $
|
it "fails with 400 and error" $ do
|
||||||
post "/simple_pk" [json| { "extra":"foo"} |]
|
p <- post "/simple_pk" [json| { "extra":"foo"} |]
|
||||||
`shouldRespondWith` 400
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` badRequest400
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
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
|
||||||
@@ -85,71 +117,141 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
[json| { "a":"bar", "b":"baz" } |]
|
[json| { "a":"bar", "b":"baz" } |]
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleBody p `shouldBe` [json| { "a":"bar", "b":"baz" } |]
|
simpleBody p `shouldBe` [json| [{ "a":"bar", "b":"baz" }] |]
|
||||||
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.bar&b=eq.baz"
|
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.bar&b=eq.baz"
|
||||||
simpleStatus p `shouldBe` created201
|
simpleStatus p `shouldBe` created201
|
||||||
|
|
||||||
|
it "returns empty array when no items inserted, and return=rep" $ do
|
||||||
|
p <- request methodPost "/no_pk"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json| [] |]
|
||||||
|
liftIO $ do
|
||||||
|
simpleBody p `shouldBe` [json| [] |]
|
||||||
|
simpleStatus p `shouldBe` created201
|
||||||
|
|
||||||
|
it "can insert in tables with no select privileges" $ do
|
||||||
|
p <- request methodPost "/insertonly"
|
||||||
|
[("Prefer", "return=minimal")]
|
||||||
|
[json| { "v":"some value" } |]
|
||||||
|
liftIO $ do
|
||||||
|
simpleBody p `shouldBe` ""
|
||||||
|
simpleStatus p `shouldBe` created201
|
||||||
|
|
||||||
|
|
||||||
it "can post nulls" $ do
|
it "can post nulls" $ do
|
||||||
p <- request methodPost "/no_pk"
|
p <- request methodPost "/no_pk"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
[json| { "a":null, "b":"foo" } |]
|
[json| { "a":null, "b":"foo" } |]
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleBody p `shouldBe` [json| { "a":null, "b":"foo" } |]
|
simpleBody p `shouldBe` [json| [{ "a":null, "b":"foo" }] |]
|
||||||
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" $ do
|
||||||
post "/simple_pk" "}{ x = 2" `shouldRespondWith` 400
|
p <- post "/simple_pk" "}{ x = 2"
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` badRequest400
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
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 [str|[{"data":{"foo":"bar"}}]|]
|
||||||
, 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 [str|[{"data":[1,2,3]}]|]
|
||||||
, 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 = []
|
||||||
|
}
|
||||||
|
context "table with limited privileges" $ do
|
||||||
|
it "succeeds if correct select is applied" $
|
||||||
|
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
|
||||||
|
[json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|[{"article_id":2,"user_id":1}]|]
|
||||||
|
, matchStatus = 201
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
it "fails if more columns are selected" $
|
||||||
|
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
|
||||||
|
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||||
|
, matchStatus = 401
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
it "fails if select is not specified" $
|
||||||
|
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
|
||||||
|
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||||
|
, matchStatus = 401
|
||||||
|
, 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
|
||||||
@@ -161,18 +263,10 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
`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")]
|
||||||
@@ -180,21 +274,10 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
`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")]
|
||||||
@@ -202,98 +285,46 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
`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"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "only returns the requested column header with its associated data" $
|
||||||
|
request methodPost "/projects?select=id"
|
||||||
|
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||||
|
"id,name,client_id\n8,Xenix,1\n9,Windows NT,1"
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just "id\n8\n9"
|
||||||
|
, matchStatus = 201
|
||||||
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
||||||
|
"Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
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 $ do
|
||||||
-- it does not fail because the extra columns are ignored
|
simpleStatus p `shouldBe` badRequest400
|
||||||
-- it "fails for too many" $ do
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
-- p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz,bat,bad"
|
|
||||||
-- liftIO $ simpleStatus p `shouldBe` badRequest400
|
|
||||||
|
|
||||||
describe "Putting record" $ do
|
context "with unicode values" $
|
||||||
|
it "succeeds and returns usable location header" $ do
|
||||||
|
let payload = [json| { "a":"圍棋", "b":"¥" } |]
|
||||||
|
p <- request methodPost "/no_pk"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
payload
|
||||||
|
liftIO $ do
|
||||||
|
simpleBody p `shouldBe` "["<>payload<>"]"
|
||||||
|
simpleStatus p `shouldBe` created201
|
||||||
|
|
||||||
context "to unkonwn uri" $
|
let Just location = lookup hLocation $ simpleHeaders p
|
||||||
it "gives a 404" $ do
|
r <- get location
|
||||||
pendingWith "Decide on PUT usefullness"
|
liftIO $ simpleBody r `shouldBe` "["<>payload<>"]"
|
||||||
request methodPut "/fake" []
|
|
||||||
[json| { "real": false } |]
|
|
||||||
`shouldRespondWith` 404
|
|
||||||
|
|
||||||
context "to a known uri" $ do
|
|
||||||
context "without a fully-specified primary key" $
|
|
||||||
it "is not an allowed operation" $ do
|
|
||||||
pendingWith "Decide on PUT usefullness"
|
|
||||||
request methodPut "/compound_pk?k1=eq.12" []
|
|
||||||
[json| { "k1":12, "k2":42 } |]
|
|
||||||
`shouldRespondWith` 405
|
|
||||||
|
|
||||||
context "with a fully-specified primary key" $ do
|
|
||||||
|
|
||||||
context "not specifying every column in the table" $
|
|
||||||
it "is rejected for lack of idempotence" $ do
|
|
||||||
pendingWith "Decide on PUT usefullness"
|
|
||||||
request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []
|
|
||||||
[json| { "k1":12, "k2":42 } |]
|
|
||||||
`shouldRespondWith` 400
|
|
||||||
|
|
||||||
context "specifying every column in the table" . after_ (clearTable "compound_pk") $ do
|
|
||||||
it "can create a new record" $ do
|
|
||||||
pendingWith "Decide on PUT usefullness"
|
|
||||||
p <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []
|
|
||||||
[json| { "k1":12, "k2":42, "extra":3 } |]
|
|
||||||
liftIO $ do
|
|
||||||
simpleBody p `shouldBe` ""
|
|
||||||
simpleStatus p `shouldBe` status204
|
|
||||||
|
|
||||||
r <- get "/compound_pk?k1=eq.12&k2=eq.42"
|
|
||||||
let rows = fromJust (JSON.decode $ simpleBody r :: Maybe [CompoundPK])
|
|
||||||
liftIO $ do
|
|
||||||
length rows `shouldBe` 1
|
|
||||||
let record = head rows
|
|
||||||
compoundK1 record `shouldBe` 12
|
|
||||||
compoundK2 record `shouldBe` 42
|
|
||||||
compoundExtra record `shouldBe` Just 3
|
|
||||||
|
|
||||||
it "can update an existing record" $ do
|
|
||||||
pendingWith "Decide on PUT usefullness"
|
|
||||||
_ <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []
|
|
||||||
[json| { "k1":12, "k2":42, "extra":4 } |]
|
|
||||||
_ <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []
|
|
||||||
[json| { "k1":12, "k2":42, "extra":5 } |]
|
|
||||||
|
|
||||||
r <- get "/compound_pk?k1=eq.12&k2=eq.42"
|
|
||||||
let rows = fromJust (JSON.decode $ simpleBody r :: Maybe [CompoundPK])
|
|
||||||
liftIO $ do
|
|
||||||
length rows `shouldBe` 1
|
|
||||||
let record = head rows
|
|
||||||
compoundExtra record `shouldBe` Just 5
|
|
||||||
|
|
||||||
context "with an auto-incrementing primary key" . after_ (clearTable "auto_incrementing_pk") $
|
|
||||||
|
|
||||||
it "succeeds with 204" $ do
|
|
||||||
pendingWith "Decide on PUT usefullness"
|
|
||||||
request methodPut "/auto_incrementing_pk?id=eq.1" []
|
|
||||||
[json| {
|
|
||||||
"id":1,
|
|
||||||
"nullable_string":"hi",
|
|
||||||
"non_nullable_string":"bye",
|
|
||||||
"inserted_at": "2020-11-11"
|
|
||||||
} |]
|
|
||||||
`shouldRespondWith` ResponseMatcher {
|
|
||||||
matchBody = Nothing,
|
|
||||||
matchStatus = 204,
|
|
||||||
matchHeaders = []
|
|
||||||
}
|
|
||||||
|
|
||||||
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 } |]
|
||||||
@@ -301,26 +332,51 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
|
|
||||||
context "on an empty table" $
|
context "on an empty table" $
|
||||||
it "indicates no records found to update" $
|
it "indicates no records found to update" $
|
||||||
request methodPatch "/simple_pk" []
|
request methodPatch "/empty_table" []
|
||||||
[json| { "extra":20 } |]
|
[json| { "extra":20 } |]
|
||||||
`shouldRespondWith` 404
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just "",
|
||||||
|
matchStatus = 204,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
context "in a nonempty table" . before_ (clearTable "items" >> createItems 15) .
|
context "in a nonempty table" $ do
|
||||||
after_ (clearTable "items") $ do
|
|
||||||
it "can update a single item" $ do
|
it "can update a single item" $ do
|
||||||
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" "\\*/\\*"
|
||||||
request methodPatch "/items?id=eq.1" []
|
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/*"]
|
||||||
matchHeaders = ["Content-Range" <:> "0-0/1"]
|
}
|
||||||
}
|
liftIO $ lookup hContentType (simpleHeaders p) `shouldBe` Nothing
|
||||||
|
|
||||||
|
-- check it really got updated
|
||||||
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/\\*"
|
||||||
|
-- put value back for other tests
|
||||||
|
void $ request methodPatch "/items?id=eq.42" [] [json| { "id":2 } |]
|
||||||
|
|
||||||
|
it "returns empty array when no rows updated and return=rep" $
|
||||||
|
request methodPatch "/items?id=eq.999999"
|
||||||
|
[("Prefer", "return=representation")] [json| { "id":999999 } |]
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just "[]",
|
||||||
|
matchStatus = 200,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "returns updated object as array when return=rep" $
|
||||||
|
request methodPatch "/items?id=eq.2"
|
||||||
|
[("Prefer", "return=representation")] [json| { "id":2 } |]
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|[{"id":2}]|],
|
||||||
|
matchStatus = 200,
|
||||||
|
matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
|
}
|
||||||
|
|
||||||
it "can update multiple items" $ do
|
it "can update multiple items" $ do
|
||||||
replicateM_ 10 $ post "/auto_incrementing_pk"
|
replicateM_ 10 $ post "/auto_incrementing_pk"
|
||||||
@@ -332,7 +388,7 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
[json| { non_nullable_string: "c" } |]
|
[json| { non_nullable_string: "c" } |]
|
||||||
g <- get "/auto_incrementing_pk?non_nullable_string=eq.c"
|
g <- get "/auto_incrementing_pk?non_nullable_string=eq.c"
|
||||||
liftIO $ simpleHeaders g
|
liftIO $ simpleHeaders g
|
||||||
`shouldSatisfy` matchHeader "Content-Range" "0-9/10"
|
`shouldSatisfy` matchHeader "Content-Range" "0-9/\\*"
|
||||||
|
|
||||||
it "can set a column to NULL" $ do
|
it "can set a column to NULL" $ do
|
||||||
_ <- post "/no_pk" [json| { a: "keepme", b: "nullme" } |]
|
_ <- post "/no_pk" [json| { a: "keepme", b: "nullme" } |]
|
||||||
@@ -340,12 +396,28 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
get "/no_pk?a=eq.keepme" `shouldRespondWith`
|
get "/no_pk?a=eq.keepme" `shouldRespondWith`
|
||||||
[json| [{ a: "keepme", b: null }] |]
|
[json| [{ a: "keepme", b: null }] |]
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
}
|
||||||
|
|
||||||
it "can update based on a computed column" $
|
it "can update based on a computed column" $
|
||||||
request methodPatch
|
request methodPatch
|
||||||
"/items?always_true=eq.false"
|
"/items?always_true=eq.false"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
[json| { id: 100 } |]
|
[json| { id: 100 } |]
|
||||||
`shouldRespondWith` 404
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just "[]",
|
||||||
|
matchStatus = 200,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
it "can provide a representation" $ do
|
it "can provide a representation" $ do
|
||||||
_ <- post "/items"
|
_ <- post "/items"
|
||||||
[json| { id: 1 } |]
|
[json| { id: 1 } |]
|
||||||
@@ -355,6 +427,17 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
[json| { id: 99 } |]
|
[json| { id: 99 } |]
|
||||||
`shouldRespondWith` [json| [{id:99}] |]
|
`shouldRespondWith` [json| [{id:99}] |]
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -365,13 +448,13 @@ spec = afterAll_ resetDb $ around withApp $ do
|
|||||||
[ auth, ("Prefer", "return=representation") ]
|
[ auth, ("Prefer", "return=representation") ]
|
||||||
[json| { "secret": "nyancat" } |]
|
[json| { "secret": "nyancat" } |]
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleBody p1 `shouldBe` [str|{"owner":"jdoe","secret":"nyancat"}|]
|
simpleBody p1 `shouldBe` [str|[{"owner":"jdoe","secret":"nyancat"}]|]
|
||||||
simpleStatus p1 `shouldBe` created201
|
simpleStatus p1 `shouldBe` created201
|
||||||
|
|
||||||
p2 <- request methodPost "/authors_only"
|
p2 <- request methodPost "/authors_only"
|
||||||
-- jwt token for jroe
|
-- jwt token for jroe
|
||||||
[ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.YuF_VfmyIxWyuceT7crnNKEprIYXsJAyXid3rjPjIow", ("Prefer", "return=representation") ]
|
[ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.YuF_VfmyIxWyuceT7crnNKEprIYXsJAyXid3rjPjIow", ("Prefer", "return=representation") ]
|
||||||
[json| { "secret": "lolcat", "owner": "hacker" } |]
|
[json| { "secret": "lolcat", "owner": "hacker" } |]
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleBody p2 `shouldBe` [str|{"owner":"jroe","secret":"lolcat"}|]
|
simpleBody p2 `shouldBe` [str|[{"owner":"jroe","secret":"lolcat"}]|]
|
||||||
simpleStatus p2 `shouldBe` created201
|
simpleStatus p2 `shouldBe` created201
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module Feature.NoJwtSpec where
|
||||||
|
|
||||||
|
-- {{{ Imports
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Network.HTTP.Types
|
||||||
|
|
||||||
|
import SpecHelper
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec = describe "server started without JWT secret" $ do
|
||||||
|
|
||||||
|
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||||
|
it "responds with error on attempted auth" $ do
|
||||||
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.QaPPLWTuyydMu_q7H4noMT7Lk6P4muet1OpJXF6ofhc"
|
||||||
|
request methodGet "/authors_only" [auth] ""
|
||||||
|
`shouldRespondWith` 500
|
||||||
|
|
||||||
|
it "behaves normally when user does not attempt auth" $
|
||||||
|
request methodGet "/items" [] ""
|
||||||
|
`shouldRespondWith` 200
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module Feature.ProxySpec where
|
||||||
|
|
||||||
|
import Test.Hspec hiding (pendingWith)
|
||||||
|
|
||||||
|
import SpecHelper
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec =
|
||||||
|
describe "GET / with proxy" $
|
||||||
|
it "returns a valid openapi spec with proxy" $
|
||||||
|
validateOpenApiResponse [("Accept", "application/openapi+json")]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
module Feature.QueryLimitedSpec where
|
||||||
|
|
||||||
|
import Test.Hspec hiding (pendingWith)
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus))
|
||||||
|
import Text.Heredoc
|
||||||
|
import SpecHelper
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec =
|
||||||
|
describe "Requesting many items with server limits enabled" $ do
|
||||||
|
it "restricts results" $
|
||||||
|
get "/items"
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [json| [{"id":1},{"id":2}] |]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "respects additional client limiting" $ do
|
||||||
|
r <- request methodGet "/items"
|
||||||
|
(rangeHdrs $ ByteRangeFromTo 0 0) ""
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders r `shouldSatisfy`
|
||||||
|
matchHeader "Content-Range" "0-0/*"
|
||||||
|
simpleStatus r `shouldBe` ok200
|
||||||
|
|
||||||
|
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 = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "limit is not applied to parent embeds" $
|
||||||
|
get "/tasks?select=id,project{id}&id=gt.5"
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
|
}
|
||||||
|
|
||||||
+311
-73
@@ -4,30 +4,25 @@ import Test.Hspec hiding (pendingWith)
|
|||||||
import Test.Hspec.Wai
|
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))
|
import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus,simpleBody))
|
||||||
|
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Text.Heredoc
|
import Text.Heredoc
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
|
||||||
spec :: Spec
|
spec :: SpecWith Application
|
||||||
spec =
|
spec = do
|
||||||
beforeAll (clearTable "items" >> createItems 15)
|
|
||||||
. beforeAll clearProjectsTable
|
|
||||||
. beforeAll (clearTable "complex_items" >> createComplexItems)
|
|
||||||
. beforeAll (clearTable "nullable_integer" >> createNullInteger)
|
|
||||||
. beforeAll (
|
|
||||||
clearTable "no_pk" >>
|
|
||||||
createNulls 2 >>
|
|
||||||
createLikableStrings >>
|
|
||||||
createJsonData)
|
|
||||||
. afterAll_ (clearTable "items" >> clearTable "complex_items" >> clearTable "no_pk" >> clearTable "simple_pk")
|
|
||||||
. around withApp $ 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" $
|
||||||
get "/has_count_column" `shouldRespondWith` 200
|
get "/has_count_column" `shouldRespondWith` 200
|
||||||
|
|
||||||
|
describe "Querying a table with a column called t" $
|
||||||
|
it "should not conflict with internal postgrest table alias" $
|
||||||
|
get "/clashing_column?select=t" `shouldRespondWith` 200
|
||||||
|
|
||||||
describe "Querying a nonexistent table" $
|
describe "Querying a nonexistent table" $
|
||||||
it "causes a 404" $
|
it "causes a 404" $
|
||||||
get "/faketable" `shouldRespondWith` 404
|
get "/faketable" `shouldRespondWith` 404
|
||||||
@@ -38,7 +33,7 @@ spec =
|
|||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| [{"id":5}] |]
|
matchBody = Just [json| [{"id":5}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/1"]
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "matches with equality using not operator" $
|
it "matches with equality using not operator" $
|
||||||
@@ -46,7 +41,7 @@ spec =
|
|||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
|
matchBody = Just [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-13/14"]
|
, matchHeaders = ["Content-Range" <:> "0-13/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "matches with more than one condition using not operator" $
|
it "matches with more than one condition using not operator" $
|
||||||
@@ -57,13 +52,13 @@ spec =
|
|||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| [{"id":14},{"id":15}] |]
|
matchBody = Just [json| [{"id":14},{"id":15}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
get "/items?id=not.gt.2&order=id.asc"
|
get "/items?id=not.gt.2&order=id.asc"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| [{"id":1},{"id":2}] |]
|
matchBody = Just [json| [{"id":1},{"id":2}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "matches items IN" $
|
it "matches items IN" $
|
||||||
@@ -71,7 +66,7 @@ spec =
|
|||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "matches items NOT IN" $
|
it "matches items NOT IN" $
|
||||||
@@ -79,7 +74,7 @@ spec =
|
|||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "matches items NOT IN using not operator" $
|
it "matches items NOT IN using not operator" $
|
||||||
@@ -87,7 +82,7 @@ spec =
|
|||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "matches nulls using not operator" $
|
it "matches nulls using not operator" $
|
||||||
@@ -98,25 +93,25 @@ spec =
|
|||||||
get "/no_pk?a=is.null" `shouldRespondWith`
|
get "/no_pk?a=is.null" `shouldRespondWith`
|
||||||
[json| [{"a": null, "b": null}] |]
|
[json| [{"a": null, "b": null}] |]
|
||||||
|
|
||||||
get "/nullable_integer?a=is.null" `shouldRespondWith` "[{\"a\":null}]"
|
get "/nullable_integer?a=is.null" `shouldRespondWith` [str|[{"a":null}]|]
|
||||||
|
|
||||||
it "matches with like" $ do
|
it "matches with like" $ do
|
||||||
get "/simple_pk?k=like.*yx" `shouldRespondWith`
|
get "/simple_pk?k=like.*yx" `shouldRespondWith`
|
||||||
"[{\"k\":\"xyyx\",\"extra\":\"u\"}]"
|
[str|[{"k":"xyyx","extra":"u"}]|]
|
||||||
get "/simple_pk?k=like.xy*" `shouldRespondWith`
|
get "/simple_pk?k=like.xy*" `shouldRespondWith`
|
||||||
"[{\"k\":\"xyyx\",\"extra\":\"u\"}]"
|
[str|[{"k":"xyyx","extra":"u"}]|]
|
||||||
get "/simple_pk?k=like.*YY*" `shouldRespondWith`
|
get "/simple_pk?k=like.*YY*" `shouldRespondWith`
|
||||||
"[{\"k\":\"xYYx\",\"extra\":\"v\"}]"
|
[str|[{"k":"xYYx","extra":"v"}]|]
|
||||||
|
|
||||||
it "matches with like using not operator" $
|
it "matches with like using not operator" $
|
||||||
get "/simple_pk?k=not.like.*yx" `shouldRespondWith`
|
get "/simple_pk?k=not.like.*yx" `shouldRespondWith`
|
||||||
"[{\"k\":\"xYYx\",\"extra\":\"v\"}]"
|
[str|[{"k":"xYYx","extra":"v"}]|]
|
||||||
|
|
||||||
it "matches with ilike" $ do
|
it "matches with ilike" $ do
|
||||||
get "/simple_pk?k=ilike.xy*&order=extra.asc" `shouldRespondWith`
|
get "/simple_pk?k=ilike.xy*&order=extra.asc" `shouldRespondWith`
|
||||||
"[{\"k\":\"xyyx\",\"extra\":\"u\"},{\"k\":\"xYYx\",\"extra\":\"v\"}]"
|
[str|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]
|
||||||
get "/simple_pk?k=ilike.*YY*&order=extra.asc" `shouldRespondWith`
|
get "/simple_pk?k=ilike.*YY*&order=extra.asc" `shouldRespondWith`
|
||||||
"[{\"k\":\"xyyx\",\"extra\":\"u\"},{\"k\":\"xYYx\",\"extra\":\"v\"}]"
|
[str|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]
|
||||||
|
|
||||||
it "matches with ilike using not operator" $
|
it "matches with ilike using not operator" $
|
||||||
get "/simple_pk?k=not.ilike.xy*&order=extra.asc" `shouldRespondWith` "[]"
|
get "/simple_pk?k=not.ilike.xy*&order=extra.asc" `shouldRespondWith` "[]"
|
||||||
@@ -130,12 +125,24 @@ spec =
|
|||||||
[json| [{"text_search_vector":"'baz':1 'qux':2"}] |]
|
[json| [{"text_search_vector":"'baz':1 'qux':2"}] |]
|
||||||
|
|
||||||
it "matches with computed column" $
|
it "matches with computed column" $
|
||||||
get "/items?always_true=eq.true" `shouldRespondWith`
|
get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
|
||||||
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
|
[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}] |]
|
||||||
|
|
||||||
|
it "order by computed column" $
|
||||||
|
get "/items?order=anti_id.desc" `shouldRespondWith`
|
||||||
|
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
|
||||||
|
|
||||||
|
it "matches filtering nested items 2" $
|
||||||
|
get "/clients?select=id,projects{id,tasks2{id,name}}&projects.tasks.name=like.Design*"
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [json| {"message":"could not find foreign keys between these entities, no relation between projects and tasks2"}|]
|
||||||
|
, matchStatus = 400
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
|
||||||
it "matches filtering nested items" $
|
it "matches filtering nested items" $
|
||||||
get "/clients?select=id,projects{id,tasks{id,name}}&projects.tasks.name=like.Design*" `shouldRespondWith`
|
get "/clients?select=id,projects{id,tasks{id,name}}&projects.tasks.name=like.Design*" `shouldRespondWith`
|
||||||
"[{\"id\":1,\"projects\":[{\"id\":1,\"tasks\":[{\"id\":1,\"name\":\"Design w7\"}]},{\"id\":2,\"tasks\":[{\"id\":3,\"name\":\"Design w10\"}]}]},{\"id\":2,\"projects\":[{\"id\":3,\"tasks\":[{\"id\":5,\"name\":\"Design IOS\"}]},{\"id\":4,\"tasks\":[{\"id\":7,\"name\":\"Design OSX\"}]}]}]"
|
[str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1,"name":"Design w7"}]},{"id":2,"tasks":[{"id":3,"name":"Design w10"}]}]},{"id":2,"projects":[{"id":3,"tasks":[{"id":5,"name":"Design IOS"}]},{"id":4,"tasks":[{"id":7,"name":"Design OSX"}]}]}]|]
|
||||||
|
|
||||||
it "matches with @> operator" $
|
it "matches with @> operator" $
|
||||||
get "/complex_items?select=id&arr_data=@>.{2}" `shouldRespondWith`
|
get "/complex_items?select=id&arr_data=@>.{2}" `shouldRespondWith`
|
||||||
@@ -150,16 +157,29 @@ spec =
|
|||||||
|
|
||||||
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"}}}] |]
|
||||||
@@ -168,6 +188,10 @@ spec =
|
|||||||
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 {
|
||||||
@@ -189,63 +213,94 @@ spec =
|
|||||||
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`
|
||||||
"[{\"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 two levels up while using FK to specify the link" $
|
||||||
|
get "/tasks?id=eq.1&select=id,name,project:project_id{id,name,client:client_id{id,name}}" `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
||||||
|
|
||||||
|
it "requesting parents two levels up while using FK to specify the link (with rename)" $
|
||||||
|
get "/tasks?id=eq.1&select=id,name,project:project_id{id,name,client:client_id{id,name}}" `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
||||||
|
|
||||||
|
|
||||||
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`
|
||||||
"[{\"id\":1,\"name\":\"Windows 7\",\"clients\":{\"id\":1}}]"
|
[str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]
|
||||||
|
|
||||||
|
it "rows with missing parents are included" $
|
||||||
|
get "/projects?id=in.1,5&select=id,clients{id}" `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"clients":{"id":1}},{"id":5,"clients":null}]|]
|
||||||
|
|
||||||
|
it "rows with no children return [] instead of null" $
|
||||||
|
get "/projects?id=in.5&select=id,tasks{id}" `shouldRespondWith`
|
||||||
|
[str|[{"id":5,"tasks":[]}]|]
|
||||||
|
|
||||||
it "requesting children 2 levels" $
|
it "requesting children 2 levels" $
|
||||||
get "/clients?id=eq.1&select=id,projects{id,tasks{id}}" `shouldRespondWith`
|
get "/clients?id=eq.1&select=id,projects{id,tasks{id}}" `shouldRespondWith`
|
||||||
"[{\"id\":1,\"projects\":[{\"id\":1,\"tasks\":[{\"id\":1},{\"id\":2}]},{\"id\":2,\"tasks\":[{\"id\":3},{\"id\":4}]}]}]"
|
[str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
|
||||||
|
|
||||||
it "requesting many<->many relation" $
|
it "requesting many<->many relation" $
|
||||||
get "/tasks?select=id,users{id}" `shouldRespondWith`
|
get "/tasks?select=id,users{id}" `shouldRespondWith`
|
||||||
"[{\"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\":null}]"
|
[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`
|
||||||
"[{\"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 "requesting parents and children on views with renamed keys" $
|
||||||
|
get "/projects_view_alt?t_id=eq.1&select=t_id, name, clients{*}, tasks{id, name}" `shouldRespondWith`
|
||||||
|
[str|[{"t_id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
|
||||||
|
|
||||||
|
|
||||||
it "requesting children with composite key" $
|
it "requesting children with composite key" $
|
||||||
get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments{content}" `shouldRespondWith`
|
get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments{content}" `shouldRespondWith`
|
||||||
"[{\"user_id\":2,\"task_id\":6,\"comments\":[{\"content\":\"Needs to be delivered ASAP\"}]}]"
|
[str|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]
|
||||||
|
|
||||||
describe "Plurality singular" $ do
|
it "detect relations in views from exposed schema that are based on tables in private schema and have columns renames" $
|
||||||
it "will select an existing object" $
|
get "/articles?id=eq.1&select=id,articleStars{users{*}}" `shouldRespondWith`
|
||||||
request methodGet "/items?id=eq.5" [("Prefer","plurality=singular")] ""
|
[str|[{"id":1,"articleStars":[{"users":{"id":1,"name":"Angela Martin"}},{"users":{"id":2,"name":"Michael Scott"}},{"users":{"id":3,"name":"Dwight Schrute"}}]}]|]
|
||||||
`shouldRespondWith` ResponseMatcher {
|
|
||||||
matchBody = Just [json| {"id":5} |]
|
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = []
|
|
||||||
}
|
|
||||||
|
|
||||||
it "works in the presence of a range header" $
|
it "can select by column name" $
|
||||||
let headers = ("Prefer","plurality=singular") :
|
get "/projects?id=in.1,3&select=id,name,client_id,client_id{id,name}" `shouldRespondWith`
|
||||||
rangeHdrs (ByteRangeFromTo 0 9) in
|
[str|[{"id":1,"name":"Windows 7","client_id":1,"client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client_id":{"id":2,"name":"Apple"}}]|]
|
||||||
request methodGet "/items" headers ""
|
|
||||||
`shouldRespondWith` ResponseMatcher {
|
|
||||||
matchBody = Just [json| {"id":1} |]
|
|
||||||
, matchStatus = 200
|
|
||||||
, matchHeaders = []
|
|
||||||
}
|
|
||||||
|
|
||||||
it "will respond with 404 when not found" $
|
|
||||||
request methodGet "/items?id=eq.9999" [("Prefer","plurality=singular")] ""
|
|
||||||
`shouldRespondWith` 404
|
|
||||||
|
|
||||||
it "can shape plurality singular object routes" $
|
|
||||||
request methodGet "/projects_view?id=eq.1&select=id,name,clients{*},tasks{id,name}" [("Prefer","plurality=singular")] ""
|
|
||||||
`shouldRespondWith`
|
|
||||||
"{\"id\":1,\"name\":\"Windows 7\",\"clients\":{\"id\":1,\"name\":\"Microsoft\"},\"tasks\":[{\"id\":1,\"name\":\"Design w7\"},{\"id\":2,\"name\":\"Code w7\"}]}"
|
|
||||||
|
|
||||||
|
it "can select by column name sans id" $
|
||||||
|
get "/projects?id=in.1,3&select=id,name,client_id,client{id,name}" `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
||||||
|
|
||||||
describe "ordering response" $ do
|
describe "ordering response" $ do
|
||||||
it "by a column asc" $
|
it "by a column asc" $
|
||||||
@@ -253,14 +308,25 @@ spec =
|
|||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| [{"id":1},{"id":2}] |]
|
matchBody = Just [json| [{"id":1},{"id":2}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
it "by a column desc" $
|
it "by a column desc" $
|
||||||
get "/items?id=lte.2&order=id.desc"
|
get "/items?id=lte.2&order=id.desc"
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just [json| [{"id":2},{"id":1}] |]
|
matchBody = Just [json| [{"id":2},{"id":1}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "by a column with nulls first" $
|
||||||
|
get "/no_pk?order=a.nullsfirst"
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [json| [{"a":null,"b":null},
|
||||||
|
{"a":"1","b":"0"},
|
||||||
|
{"a":"2","b":"0"}
|
||||||
|
] |]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "by a column asc with nulls last" $
|
it "by a column asc with nulls last" $
|
||||||
@@ -270,7 +336,7 @@ spec =
|
|||||||
{"a":"2","b":"0"},
|
{"a":"2","b":"0"},
|
||||||
{"a":null,"b":null}] |]
|
{"a":null,"b":null}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "by a column desc with nulls first" $
|
it "by a column desc with nulls first" $
|
||||||
@@ -280,7 +346,7 @@ spec =
|
|||||||
{"a":"2","b":"0"},
|
{"a":"2","b":"0"},
|
||||||
{"a":"1","b":"0"}] |]
|
{"a":"1","b":"0"}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "by a column desc with nulls last" $
|
it "by a column desc with nulls last" $
|
||||||
@@ -290,12 +356,42 @@ spec =
|
|||||||
{"a":"1","b":"0"},
|
{"a":"1","b":"0"},
|
||||||
{"a":null,"b":null}] |]
|
{"a":null,"b":null}] |]
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "by a json column property asc" $
|
||||||
|
get "/json?order=data->>id.asc" `shouldRespondWith`
|
||||||
|
[json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
|
||||||
|
|
||||||
|
it "by a json column with two level property nulls first" $
|
||||||
|
get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
|
||||||
|
[json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||||
|
|
||||||
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"
|
||||||
@@ -307,6 +403,27 @@ spec =
|
|||||||
(acceptHdrs "*/*") ""
|
(acceptHdrs "*/*") ""
|
||||||
`shouldRespondWith` 200
|
`shouldRespondWith` 200
|
||||||
|
|
||||||
|
it "*/* should rescue an unknown type" $
|
||||||
|
request methodGet "/simple_pk"
|
||||||
|
(acceptHdrs "text/unknowntype, */*") ""
|
||||||
|
`shouldRespondWith` 200
|
||||||
|
|
||||||
|
it "specific available preference should override */*" $ do
|
||||||
|
r <- request methodGet "/simple_pk"
|
||||||
|
(acceptHdrs "text/csv, */*") ""
|
||||||
|
liftIO $ do
|
||||||
|
let respHeaders = simpleHeaders r
|
||||||
|
respHeaders `shouldSatisfy` matchHeader
|
||||||
|
"Content-Type" "text/csv; charset=utf-8"
|
||||||
|
|
||||||
|
it "honors client preference even when opposite of server preference" $ do
|
||||||
|
r <- request methodGet "/simple_pk"
|
||||||
|
(acceptHdrs "text/csv, application/json") ""
|
||||||
|
liftIO $ do
|
||||||
|
let respHeaders = simpleHeaders r
|
||||||
|
respHeaders `shouldSatisfy` matchHeader
|
||||||
|
"Content-Type" "text/csv; charset=utf-8"
|
||||||
|
|
||||||
it "should respond correctly to multiple types in accept header" $
|
it "should respond correctly to multiple types in accept header" $
|
||||||
request methodGet "/simple_pk"
|
request methodGet "/simple_pk"
|
||||||
(acceptHdrs "text/unknowntype, text/csv") ""
|
(acceptHdrs "text/unknowntype, text/csv") ""
|
||||||
@@ -318,7 +435,7 @@ spec =
|
|||||||
`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
|
||||||
@@ -351,18 +468,139 @@ spec =
|
|||||||
[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" . before_ (clearTable "items" >> createItems 10) .
|
context "a proc that returns a set" $ do
|
||||||
after_ (clearTable "items") $
|
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 = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "includes total count if requested" $
|
||||||
|
request methodPost "/rpc/getitemrange"
|
||||||
|
(rangeHdrsWithCount (ByteRangeFromTo 0 0))
|
||||||
|
[json| { "min": 2, "max": 4 } |]
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [json| [{"id":3}] |]
|
||||||
|
, matchStatus = 206 -- it now knows the response is partial
|
||||||
|
, 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} ] |]
|
||||||
|
|
||||||
|
context "unknown function" $
|
||||||
|
it "returns 404" $
|
||||||
|
post "/rpc/fakefunc" [json| {} |] `shouldRespondWith` 404
|
||||||
|
|
||||||
|
context "shaping the response returned by a proc" $ do
|
||||||
|
it "returns a project" $
|
||||||
|
post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Windows 7","client_id":1}]|]
|
||||||
|
|
||||||
|
it "can filter proc results" $
|
||||||
|
post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`
|
||||||
|
[json|[{"id":2},{"id":3},{"id":4}]|]
|
||||||
|
|
||||||
|
it "can limit proc results" $
|
||||||
|
post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1" [json| {} |]
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [json|[{"id":3},{"id":4}]|]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "1-2/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "select works on the first level" $
|
||||||
|
post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Windows 7"}]|]
|
||||||
|
|
||||||
|
it "can embed foreign entities to the items returned by a proc" $
|
||||||
|
post "/rpc/getproject?select=id,name,client{id},tasks{id}" [json| { "id": 1} |] `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
||||||
|
|
||||||
context "a proc that returns an empty rowset" $
|
context "a proc that returns an empty rowset" $
|
||||||
it "returns empty json array" $
|
it "returns empty json array" $
|
||||||
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|"Hello, world"|]
|
||||||
|
|
||||||
|
it "can handle unicode" $
|
||||||
|
post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith`
|
||||||
|
[json|"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" $ do
|
||||||
|
p <- request methodPost "/rpc/sayhello"
|
||||||
|
(acceptHdrs "application/json") "sdfsdf"
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` badRequest400
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
it "treats simple plpgsql raise as invalid input" $ do
|
||||||
|
p <- post "/rpc/problem" "{}"
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` badRequest400
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
|
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|1|]
|
||||||
|
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
|
||||||
|
[json|2|]
|
||||||
|
|
||||||
|
context "expects a single json object" $ do
|
||||||
|
it "does not expand posted json into parameters" $
|
||||||
|
request methodPost "/rpc/singlejsonparam"
|
||||||
|
[("Prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
|
||||||
|
[json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
|
||||||
|
|
||||||
|
it "accepts parameters from an html form" $
|
||||||
|
request methodPost "/rpc/singlejsonparam"
|
||||||
|
[("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]
|
||||||
|
("integer=7&double=2.71828&varchar=forms+are+fun&" <>
|
||||||
|
"boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
|
||||||
|
[json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
|
||||||
|
, "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
|
||||||
|
|
||||||
|
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}}] |]
|
||||||
|
|||||||
+152
-13
@@ -6,13 +6,111 @@ 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 qualified Data.ByteString.Lazy as BL
|
||||||
|
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
import Text.Heredoc
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
|
||||||
|
defaultRange :: BL.ByteString
|
||||||
|
defaultRange = [json| { "min": 0, "max": 15 } |]
|
||||||
|
|
||||||
|
emptyRange :: BL.ByteString
|
||||||
|
emptyRange = [json| { "min": 2, "max": 2 } |]
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec = do
|
||||||
|
describe "POST /rpc/getitemrange" $ do
|
||||||
|
context "without range headers" $ do
|
||||||
|
context "with response under server size limit" $
|
||||||
|
it "returns whole range with status 200" $
|
||||||
|
post "/rpc/getitemrange" defaultRange `shouldRespondWith` 200
|
||||||
|
|
||||||
|
context "when I don't want the count" $ do
|
||||||
|
it "returns range Content-Range with */* for empty range" $
|
||||||
|
request methodPost "/rpc/getitemrange" [] emptyRange
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [json| [] |]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "returns range Content-Range with range/*" $
|
||||||
|
request methodPost "/rpc/getitemrange" [] 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/*"
|
||||||
|
simpleStatus r `shouldBe` ok200
|
||||||
|
|
||||||
|
it "understands open-ended ranges" $
|
||||||
|
request methodPost "/rpc/getitemrange"
|
||||||
|
(rangeHdrs $ ByteRangeFrom 0) defaultRange
|
||||||
|
`shouldRespondWith` 200
|
||||||
|
|
||||||
|
it "returns an empty body when there are no results" $
|
||||||
|
request methodPost "/rpc/getitemrange"
|
||||||
|
(rangeHdrs $ ByteRangeFromTo 0 1) emptyRange
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just "[]"
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "allows one-item requests" $ do
|
||||||
|
r <- request methodPost "/rpc/getitemrange"
|
||||||
|
(rangeHdrs $ ByteRangeFromTo 0 0) defaultRange
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders r `shouldSatisfy`
|
||||||
|
matchHeader "Content-Range" "0-0/*"
|
||||||
|
simpleStatus r `shouldBe` ok200
|
||||||
|
|
||||||
|
it "handles ranges beyond collection length via truncation" $ do
|
||||||
|
r <- request methodPost "/rpc/getitemrange"
|
||||||
|
(rangeHdrs $ ByteRangeFromTo 10 100) defaultRange
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders r `shouldSatisfy`
|
||||||
|
matchHeader "Content-Range" "10-14/*"
|
||||||
|
simpleStatus r `shouldBe` ok200
|
||||||
|
|
||||||
|
context "of invalid range" $ do
|
||||||
|
it "fails with 416 for offside range" $
|
||||||
|
request methodPost "/rpc/getitemrange"
|
||||||
|
(rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
|
||||||
|
`shouldRespondWith` 416
|
||||||
|
|
||||||
|
it "refuses a range with nonzero start when there are no items" $
|
||||||
|
request methodPost "/rpc/getitemrange"
|
||||||
|
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Nothing
|
||||||
|
, matchStatus = 416
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "refuses a range requesting start past last item" $
|
||||||
|
request methodPost "/rpc/getitemrange"
|
||||||
|
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Nothing
|
||||||
|
, matchStatus = 416
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||||
|
}
|
||||||
|
|
||||||
spec :: Spec
|
|
||||||
spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable "items")
|
|
||||||
. around withApp $
|
|
||||||
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" $
|
||||||
@@ -46,6 +144,47 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
|||||||
, 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/*"]
|
||||||
|
}
|
||||||
|
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 = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||||
|
}
|
||||||
|
it "headers override get parameters" $
|
||||||
|
request methodGet "/items?select=id&order=id.asc&limit=3"
|
||||||
|
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|[{"id":1},{"id":2}]|]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "limit works on all levels" $
|
||||||
|
get "/clients?select=id,projects{id,tasks{id}}&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
it "limit and offset works on first level" $
|
||||||
|
get "/items?select=id&order=id.asc&limit=3&offset=2"
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|[{"id":3},{"id":4},{"id":5}]|]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
||||||
|
}
|
||||||
|
|
||||||
context "with range headers" $ do
|
context "with range headers" $ do
|
||||||
|
|
||||||
context "of acceptable range" $ do
|
context "of acceptable range" $ do
|
||||||
@@ -54,8 +193,8 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
|||||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleHeaders r `shouldSatisfy`
|
simpleHeaders r `shouldSatisfy`
|
||||||
matchHeader "Content-Range" "0-1/15"
|
matchHeader "Content-Range" "0-1/*"
|
||||||
simpleStatus r `shouldBe` partialContent206
|
simpleStatus r `shouldBe` ok200
|
||||||
|
|
||||||
it "understands open-ended ranges" $
|
it "understands open-ended ranges" $
|
||||||
request methodGet "/items"
|
request methodGet "/items"
|
||||||
@@ -68,7 +207,7 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
|||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Just "[]"
|
matchBody = Just "[]"
|
||||||
, matchStatus = 200
|
, matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "allows one-item requests" $ do
|
it "allows one-item requests" $ do
|
||||||
@@ -76,16 +215,16 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
|||||||
(rangeHdrs $ ByteRangeFromTo 0 0) ""
|
(rangeHdrs $ ByteRangeFromTo 0 0) ""
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleHeaders r `shouldSatisfy`
|
simpleHeaders r `shouldSatisfy`
|
||||||
matchHeader "Content-Range" "0-0/15"
|
matchHeader "Content-Range" "0-0/*"
|
||||||
simpleStatus r `shouldBe` partialContent206
|
simpleStatus r `shouldBe` ok200
|
||||||
|
|
||||||
it "handles ranges beyond collection length via truncation" $ do
|
it "handles ranges beyond collection length via truncation" $ do
|
||||||
r <- request methodGet "/items"
|
r <- request methodGet "/items"
|
||||||
(rangeHdrs $ ByteRangeFromTo 10 100) ""
|
(rangeHdrs $ ByteRangeFromTo 10 100) ""
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleHeaders r `shouldSatisfy`
|
simpleHeaders r `shouldSatisfy`
|
||||||
matchHeader "Content-Range" "10-14/15"
|
matchHeader "Content-Range" "10-14/*"
|
||||||
simpleStatus r `shouldBe` partialContent206
|
simpleStatus r `shouldBe` ok200
|
||||||
|
|
||||||
context "of invalid range" $ do
|
context "of invalid range" $ do
|
||||||
it "fails with 416 for offside range" $
|
it "fails with 416 for offside range" $
|
||||||
@@ -95,7 +234,7 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
|||||||
|
|
||||||
it "refuses a range with nonzero start when there are no items" $
|
it "refuses a range with nonzero start when there are no items" $
|
||||||
request methodGet "/menagerie"
|
request methodGet "/menagerie"
|
||||||
(rangeHdrs $ ByteRangeFromTo 1 2) ""
|
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Nothing
|
matchBody = Nothing
|
||||||
, matchStatus = 416
|
, matchStatus = 416
|
||||||
@@ -104,7 +243,7 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
|||||||
|
|
||||||
it "refuses a range requesting start past last item" $
|
it "refuses a range requesting start past last item" $
|
||||||
request methodGet "/items"
|
request methodGet "/items"
|
||||||
(rangeHdrs $ ByteRangeFromTo 100 199) ""
|
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
|
||||||
`shouldRespondWith` ResponseMatcher {
|
`shouldRespondWith` ResponseMatcher {
|
||||||
matchBody = Nothing
|
matchBody = Nothing
|
||||||
, matchStatus = 416
|
, matchStatus = 416
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
module Feature.SingularSpec where
|
||||||
|
|
||||||
|
import Text.Heredoc
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Network.Wai.Test (SResponse(..))
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import SpecHelper
|
||||||
|
import Protolude hiding (get)
|
||||||
|
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec =
|
||||||
|
describe "Requesting singular json object" $ do
|
||||||
|
let pgrstObj = "application/vnd.pgrst.object+json"
|
||||||
|
singular = ("Accept", pgrstObj)
|
||||||
|
|
||||||
|
context "with GET request" $ do
|
||||||
|
it "fails for zero rows" $
|
||||||
|
request methodGet "/items?id=gt.0&id=lt.0" [singular] ""
|
||||||
|
`shouldRespondWith` 406
|
||||||
|
|
||||||
|
it "will select an existing object" $ do
|
||||||
|
request methodGet "/items?id=eq.5" [singular] ""
|
||||||
|
`shouldRespondWith` [str|{"id":5}|]
|
||||||
|
-- also test without the +json suffix
|
||||||
|
request methodGet "/items?id=eq.5"
|
||||||
|
[("Accept", "application/vnd.pgrst.object")] ""
|
||||||
|
`shouldRespondWith` [str|{"id":5}|]
|
||||||
|
|
||||||
|
it "can combine multiple prefer values" $
|
||||||
|
request methodGet "/items?id=eq.5" [singular, ("Prefer","count=none")] ""
|
||||||
|
`shouldRespondWith` [str|{"id":5}|]
|
||||||
|
|
||||||
|
it "can shape plurality singular object routes" $
|
||||||
|
request methodGet "/projects_view?id=eq.1&select=id,name,clients{*},tasks{id,name}" [singular] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]
|
||||||
|
|
||||||
|
context "when updating rows" $ do
|
||||||
|
|
||||||
|
it "works for one row" $ do
|
||||||
|
_ <- post "/addresses" [json| { id: 97, address: "A Street" } |]
|
||||||
|
request methodPatch
|
||||||
|
"/addresses?id=eq.97"
|
||||||
|
[("Prefer", "return=representation"), singular]
|
||||||
|
[json| { address: "B Street" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|{"id":97,"address":"B Street"}|]
|
||||||
|
|
||||||
|
it "raises an error for multiple rows" $ do
|
||||||
|
_ <- post "/addresses" [json| { id: 98, address: "xxx" } |]
|
||||||
|
_ <- post "/addresses" [json| { id: 99, address: "yyy" } |]
|
||||||
|
p <- request methodPatch
|
||||||
|
"/addresses?id=gt.0"
|
||||||
|
[("Prefer", "return=representation"), singular]
|
||||||
|
[json| { address: "zzz" } |]
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` notAcceptable406
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
|
-- the rows should not be updated, either
|
||||||
|
get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|]
|
||||||
|
|
||||||
|
it "raises an error for zero rows" $ do
|
||||||
|
p <- request methodPatch "/items?id=gt.0&id=lt.0"
|
||||||
|
[("Prefer", "return=representation"), singular] [json|{"id":1}|]
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` notAcceptable406
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
|
context "when creating rows" $ do
|
||||||
|
|
||||||
|
it "works for one row" $ do
|
||||||
|
p <- request methodPost
|
||||||
|
"/addresses"
|
||||||
|
[("Prefer", "return=representation"), singular]
|
||||||
|
[json| [ { id: 100, address: "xxx" } ] |]
|
||||||
|
liftIO $ simpleBody p `shouldBe` [str|{"id":100,"address":"xxx"}|]
|
||||||
|
|
||||||
|
it "works for one row even with return=minimal" $ do
|
||||||
|
request methodPost "/addresses"
|
||||||
|
[("Prefer", "return=minimal"), singular]
|
||||||
|
[json| [ { id: 101, address: "xxx" } ] |]
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just ""
|
||||||
|
, matchStatus = 201
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
-- and the element should exist
|
||||||
|
get "/addresses?id=eq.101"
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just [str|[{"id":101,"address":"xxx"}]|]
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
|
||||||
|
it "raises an error when attempting to create multiple entities" $ do
|
||||||
|
p <- request methodPost
|
||||||
|
"/addresses"
|
||||||
|
[("Prefer", "return=representation"), singular]
|
||||||
|
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
||||||
|
liftIO $ simpleStatus p `shouldBe` notAcceptable406
|
||||||
|
|
||||||
|
-- the rows should not exist, either
|
||||||
|
get "/addresses?id=eq.200" `shouldRespondWith` "[]"
|
||||||
|
|
||||||
|
it "return=minimal allows request to create multiple elements" $
|
||||||
|
request methodPost "/addresses"
|
||||||
|
[("Prefer", "return=minimal"), singular]
|
||||||
|
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Just ""
|
||||||
|
, matchStatus = 201
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "raises an error when creating zero entities" $ do
|
||||||
|
p <- request methodPost
|
||||||
|
"/addresses"
|
||||||
|
[("Prefer", "return=representation"), singular]
|
||||||
|
[json| [ ] |]
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` notAcceptable406
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
|
context "when deleting rows" $ do
|
||||||
|
|
||||||
|
it "works for one row" $ do
|
||||||
|
p <- request methodDelete
|
||||||
|
"/items?id=eq.11"
|
||||||
|
[("Prefer", "return=representation"), singular] ""
|
||||||
|
liftIO $ simpleBody p `shouldBe` [str|{"id":11}|]
|
||||||
|
|
||||||
|
it "raises an error when attempting to delete multiple entities" $ do
|
||||||
|
let firstItems = "/items?id=gt.0&id=lt.11"
|
||||||
|
request methodDelete firstItems
|
||||||
|
[("Prefer", "return=representation"), singular] ""
|
||||||
|
`shouldRespondWith` 406
|
||||||
|
|
||||||
|
-- the rows should not exist, either
|
||||||
|
get firstItems
|
||||||
|
`shouldRespondWith` ResponseMatcher {
|
||||||
|
matchBody = Nothing
|
||||||
|
, matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-9/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "raises an error when deleting zero entities" $ do
|
||||||
|
p <- request methodDelete "/items?id=lt.0"
|
||||||
|
[("Prefer", "return=representation"), singular] ""
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` notAcceptable406
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
|
context "when calling a stored proc" $ do
|
||||||
|
|
||||||
|
it "fails for zero rows" $ do
|
||||||
|
p <- request methodPost "/rpc/getproject"
|
||||||
|
[singular] [json|{ "id": 9999999}|]
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` notAcceptable406
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
|
-- this one may be controversial, should vnd.pgrst.object include
|
||||||
|
-- the likes of 2 and "hello?"
|
||||||
|
it "succeeds for scalar result" $
|
||||||
|
request methodPost "/rpc/sayhello"
|
||||||
|
[singular] [json|{ "name": "world"}|]
|
||||||
|
`shouldRespondWith` 200
|
||||||
|
|
||||||
|
it "returns a single object for json proc" $
|
||||||
|
request methodPost "/rpc/getproject"
|
||||||
|
[singular] [json|{ "id": 1}|] `shouldRespondWith`
|
||||||
|
[str|{"id":1,"name":"Windows 7","client_id":1}|]
|
||||||
|
|
||||||
|
it "fails for multiple rows" $ do
|
||||||
|
p <- request methodPost "/rpc/getallprojects" [singular] "{}"
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` notAcceptable406
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
|
it "executes the proc exactly once per request" $ do
|
||||||
|
request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]
|
||||||
|
`shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]
|
||||||
|
p <- request methodPost "/rpc/setprojects" [singular]
|
||||||
|
[json| {"id_l": 1, "id_h": 2, "name": "changed"} |]
|
||||||
|
liftIO $ do
|
||||||
|
simpleStatus p `shouldBe` notAcceptable406
|
||||||
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
|
-- should not actually have executed the function
|
||||||
|
request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]
|
||||||
|
`shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]
|
||||||
+82
-301
@@ -2,317 +2,98 @@ module Feature.StructureSpec where
|
|||||||
|
|
||||||
import Test.Hspec hiding (pendingWith)
|
import Test.Hspec hiding (pendingWith)
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Network.HTTP.Types
|
||||||
|
|
||||||
|
import Control.Lens ((^?))
|
||||||
|
import Data.Aeson.Lens
|
||||||
|
import Data.Aeson.QQ
|
||||||
|
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
|
||||||
import Network.HTTP.Types
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse(..))
|
||||||
|
|
||||||
spec :: Spec
|
import Protolude hiding (get)
|
||||||
spec = around withApp $ do
|
|
||||||
describe "GET /" $ do
|
|
||||||
it "lists views in schema" $
|
|
||||||
request methodGet "/" [] ""
|
|
||||||
`shouldRespondWith` [json| [
|
|
||||||
{"schema":"test","name":"articleStars","insertable":true}
|
|
||||||
, {"schema":"test","name":"articles","insertable":true}
|
|
||||||
, {"schema":"test","name":"auto_incrementing_pk","insertable":true}
|
|
||||||
, {"schema":"test","name":"clients","insertable":true}
|
|
||||||
, {"schema":"test","name":"comments","insertable":true}
|
|
||||||
, {"schema":"test","name":"complex_items","insertable":true}
|
|
||||||
, {"schema":"test","name":"compound_pk","insertable":true}
|
|
||||||
, {"schema":"test","name":"has_count_column","insertable":false}
|
|
||||||
, {"schema":"test","name":"has_fk","insertable":true}
|
|
||||||
, {"schema":"test","name":"insertable_view_with_join","insertable":true}
|
|
||||||
, {"schema":"test","name":"items","insertable":true}
|
|
||||||
, {"schema":"test","name":"json","insertable":true}
|
|
||||||
, {"schema":"test","name":"materialized_view","insertable":false}
|
|
||||||
, {"schema":"test","name":"menagerie","insertable":true}
|
|
||||||
, {"schema":"test","name":"no_pk","insertable":true}
|
|
||||||
, {"schema":"test","name":"nullable_integer","insertable":true}
|
|
||||||
, {"schema":"test","name":"projects","insertable":true}
|
|
||||||
, {"schema":"test","name":"projects_view","insertable":true}
|
|
||||||
, {"schema":"test","name":"simple_pk","insertable":true}
|
|
||||||
, {"schema":"test","name":"tasks","insertable":true}
|
|
||||||
, {"schema":"test","name":"tsearch","insertable":true}
|
|
||||||
, {"schema":"test","name":"users","insertable":true}
|
|
||||||
, {"schema":"test","name":"users_projects","insertable":true}
|
|
||||||
, {"schema":"test","name":"users_tasks","insertable":true}
|
|
||||||
] |]
|
|
||||||
{matchStatus = 200}
|
|
||||||
|
|
||||||
it "lists only views user has permission to see" $ do
|
spec :: SpecWith Application
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
spec = do
|
||||||
|
|
||||||
request methodGet "/" [auth] ""
|
describe "OpenAPI" $ do
|
||||||
`shouldRespondWith` [json| [
|
it "root path returns a valid openapi spec" $
|
||||||
{"schema":"test","name":"authors_only","insertable":true}
|
validateOpenApiResponse [("Accept", "application/openapi+json")]
|
||||||
] |]
|
|
||||||
{matchStatus = 200}
|
|
||||||
|
|
||||||
describe "Table info" $ do
|
it "should respond to openapi request on none root path with 415" $
|
||||||
it "is available with OPTIONS verb" $
|
request methodGet "/items"
|
||||||
request methodOptions "/menagerie" [] "" `shouldRespondWith`
|
(acceptHdrs "application/openapi+json") ""
|
||||||
[json|
|
`shouldRespondWith` 415
|
||||||
{
|
|
||||||
"pkey":["integer"],
|
|
||||||
"columns":[
|
|
||||||
{
|
|
||||||
"default": null,
|
|
||||||
"precision": 32,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "integer",
|
|
||||||
"type": "integer",
|
|
||||||
"maxLen": null,
|
|
||||||
"enum": [],
|
|
||||||
"nullable": false,
|
|
||||||
"position": 1,
|
|
||||||
"references": null,
|
|
||||||
"default": null
|
|
||||||
}, {
|
|
||||||
"default": null,
|
|
||||||
"precision": 53,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "double",
|
|
||||||
"type": "double precision",
|
|
||||||
"maxLen": null,
|
|
||||||
"enum": [],
|
|
||||||
"nullable": false,
|
|
||||||
"references": null,
|
|
||||||
"position": 2
|
|
||||||
}, {
|
|
||||||
"default": null,
|
|
||||||
"precision": null,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "varchar",
|
|
||||||
"type": "character varying",
|
|
||||||
"maxLen": null,
|
|
||||||
"enum": [],
|
|
||||||
"nullable": false,
|
|
||||||
"position": 3,
|
|
||||||
"references": null,
|
|
||||||
"default": null
|
|
||||||
}, {
|
|
||||||
"default": null,
|
|
||||||
"precision": null,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "boolean",
|
|
||||||
"type": "boolean",
|
|
||||||
"maxLen": null,
|
|
||||||
"enum": [],
|
|
||||||
"nullable": false,
|
|
||||||
"references": null,
|
|
||||||
"position": 4
|
|
||||||
}, {
|
|
||||||
"default": null,
|
|
||||||
"precision": null,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "date",
|
|
||||||
"type": "date",
|
|
||||||
"maxLen": null,
|
|
||||||
"enum": [],
|
|
||||||
"nullable": false,
|
|
||||||
"references": null,
|
|
||||||
"position": 5
|
|
||||||
}, {
|
|
||||||
"default": null,
|
|
||||||
"precision": null,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "money",
|
|
||||||
"type": "money",
|
|
||||||
"maxLen": null,
|
|
||||||
"enum": [],
|
|
||||||
"nullable": false,
|
|
||||||
"position": 6,
|
|
||||||
"references": null,
|
|
||||||
"default": null
|
|
||||||
}, {
|
|
||||||
"default": null,
|
|
||||||
"precision": null,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "enum",
|
|
||||||
"type": "USER-DEFINED",
|
|
||||||
"maxLen": null,
|
|
||||||
"enum": [
|
|
||||||
"foo",
|
|
||||||
"bar"
|
|
||||||
],
|
|
||||||
"nullable": false,
|
|
||||||
"position": 7,
|
|
||||||
"references": null,
|
|
||||||
"default": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|]
|
|
||||||
|
|
||||||
it "it includes primary and foreign keys for views" $
|
describe "RPC" $
|
||||||
request methodOptions "/projects_view" [] "" `shouldRespondWith`
|
|
||||||
[json|
|
|
||||||
{
|
|
||||||
"pkey":[
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"columns":[
|
|
||||||
{
|
|
||||||
"references":null,
|
|
||||||
"default":null,
|
|
||||||
"precision":32,
|
|
||||||
"updatable":true,
|
|
||||||
"schema":"test",
|
|
||||||
"name":"id",
|
|
||||||
"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":"clients"
|
|
||||||
},
|
|
||||||
"default":null,
|
|
||||||
"precision":32,
|
|
||||||
"updatable":true,
|
|
||||||
"schema":"test",
|
|
||||||
"name":"client_id",
|
|
||||||
"type":"integer",
|
|
||||||
"maxLen":null,
|
|
||||||
"enum":[],
|
|
||||||
"nullable":true,
|
|
||||||
"position":3
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|]
|
|
||||||
|
|
||||||
it "includes foreign key data" $ do
|
it "includes a representative function with parameters" $ do
|
||||||
pendingWith "have to resolve issue #107"
|
r <- simpleBody <$> get "/"
|
||||||
|
let ref = r ^? key "paths" . key "/rpc/varied_arguments"
|
||||||
|
. key "post" . key "parameters"
|
||||||
|
. nth 1 . key "schema"
|
||||||
|
. key "$ref" . _String
|
||||||
|
args = r ^? key "definitions" . key "(rpc) varied_arguments"
|
||||||
|
|
||||||
request methodOptions "/has_fk" [] ""
|
liftIO $ do
|
||||||
`shouldRespondWith` [json|
|
ref `shouldBe` Just "#/definitions/(rpc) varied_arguments"
|
||||||
{
|
args `shouldBe` Just
|
||||||
"pkey": ["id"],
|
[aesonQQ|
|
||||||
"columns":[
|
|
||||||
{
|
|
||||||
"default": "nextval('\"1\".has_fk_id_seq'::regclass)",
|
|
||||||
"precision": 64,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "id",
|
|
||||||
"type": "bigint",
|
|
||||||
"maxLen": null,
|
|
||||||
"nullable": false,
|
|
||||||
"position": 1,
|
|
||||||
"enum": [],
|
|
||||||
"references": null
|
|
||||||
}, {
|
|
||||||
"default": null,
|
|
||||||
"precision": 32,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "auto_inc_fk",
|
|
||||||
"type": "integer",
|
|
||||||
"maxLen": null,
|
|
||||||
"nullable": true,
|
|
||||||
"position": 2,
|
|
||||||
"enum": [],
|
|
||||||
"references": {"table": "auto_incrementing_pk", "column": "id"}
|
|
||||||
}, {
|
|
||||||
"default": null,
|
|
||||||
"precision": null,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "simple_fk",
|
|
||||||
"type": "character varying",
|
|
||||||
"maxLen": 255,
|
|
||||||
"nullable": true,
|
|
||||||
"position": 3,
|
|
||||||
"enum": [],
|
|
||||||
"references": {"table": "simple_pk", "column": "k"}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|]
|
|
||||||
|
|
||||||
it "includes all information on views for renamed columns, and raises relations to correct schema" $
|
|
||||||
request methodOptions "/articleStars" [] ""
|
|
||||||
`shouldRespondWith` [json|
|
|
||||||
{
|
|
||||||
"pkey": [
|
|
||||||
"articleId",
|
|
||||||
"userId"
|
|
||||||
],
|
|
||||||
"columns": [
|
|
||||||
{
|
{
|
||||||
"references": {
|
"required": [
|
||||||
"schema": "test",
|
"double",
|
||||||
"column": "id",
|
"varchar",
|
||||||
"table": "articles"
|
"boolean",
|
||||||
|
"date",
|
||||||
|
"money",
|
||||||
|
"enum"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"double": {
|
||||||
|
"format": "double precision",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"varchar": {
|
||||||
|
"format": "character varying",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"boolean": {
|
||||||
|
"format": "boolean",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"format": "date",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"money": {
|
||||||
|
"format": "money",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"enum": {
|
||||||
|
"format": "test.enum_menagerie_type",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"integer": {
|
||||||
|
"format": "integer",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"default": null,
|
"type": "object"
|
||||||
"precision": 32,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "articleId",
|
|
||||||
"type": "integer",
|
|
||||||
"maxLen": null,
|
|
||||||
"enum": [],
|
|
||||||
"nullable": true,
|
|
||||||
"position": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"references": {
|
|
||||||
"schema": "test",
|
|
||||||
"column": "id",
|
|
||||||
"table": "users"
|
|
||||||
},
|
|
||||||
"default": null,
|
|
||||||
"precision": 32,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "userId",
|
|
||||||
"type": "integer",
|
|
||||||
"maxLen": null,
|
|
||||||
"enum": [],
|
|
||||||
"nullable": true,
|
|
||||||
"position": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"references": null,
|
|
||||||
"default": null,
|
|
||||||
"precision": null,
|
|
||||||
"updatable": true,
|
|
||||||
"schema": "test",
|
|
||||||
"name": "createdAt",
|
|
||||||
"type": "timestamp without time zone",
|
|
||||||
"maxLen": null,
|
|
||||||
"enum": [],
|
|
||||||
"nullable": true,
|
|
||||||
"position": 3
|
|
||||||
}
|
}
|
||||||
]
|
|]
|
||||||
}
|
|
||||||
|]
|
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"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
module Feature.UnicodeSpec where
|
||||||
|
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
import Network.Wai (Application)
|
||||||
|
import Control.Monad (void)
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
|
||||||
|
spec :: SpecWith Application
|
||||||
|
spec =
|
||||||
|
describe "Reading and writing to unicode schema and table names" $
|
||||||
|
it "Can read and write values" $ do
|
||||||
|
get "/%D9%85%D9%88%D8%A7%D8%B1%D8%AF"
|
||||||
|
`shouldRespondWith` "[]"
|
||||||
|
|
||||||
|
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 }] |]
|
||||||
+81
-2
@@ -2,7 +2,86 @@ module Main where
|
|||||||
|
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Spec
|
|
||||||
|
import qualified Hasql.Pool as P
|
||||||
|
|
||||||
|
import PostgREST.DbStructure (getDbStructure)
|
||||||
|
import PostgREST.App (postgrest)
|
||||||
|
import Control.AutoUpdate
|
||||||
|
import Data.Function (id)
|
||||||
|
import Data.IORef
|
||||||
|
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||||
|
|
||||||
|
import qualified Feature.AuthSpec
|
||||||
|
import qualified Feature.BinaryJwtSecretSpec
|
||||||
|
import qualified Feature.ConcurrentSpec
|
||||||
|
import qualified Feature.CorsSpec
|
||||||
|
import qualified Feature.DeleteSpec
|
||||||
|
import qualified Feature.InsertSpec
|
||||||
|
import qualified Feature.NoJwtSpec
|
||||||
|
import qualified Feature.QueryLimitedSpec
|
||||||
|
import qualified Feature.QuerySpec
|
||||||
|
import qualified Feature.RangeSpec
|
||||||
|
import qualified Feature.StructureSpec
|
||||||
|
import qualified Feature.SingularSpec
|
||||||
|
import qualified Feature.UnicodeSpec
|
||||||
|
import qualified Feature.ProxySpec
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = resetDb >> hspec spec
|
main = do
|
||||||
|
testDbConn <- getEnvVarWithDefault "POSTGREST_TEST_CONNECTION" "postgres://postgrest_test@localhost/postgrest_test"
|
||||||
|
setupDb testDbConn
|
||||||
|
|
||||||
|
pool <- P.acquire (3, 10, toS testDbConn)
|
||||||
|
-- ask for the OS time at most once per second
|
||||||
|
getTime <- mkAutoUpdate
|
||||||
|
defaultUpdateSettings { updateAction = getPOSIXTime }
|
||||||
|
|
||||||
|
|
||||||
|
result <- P.use pool $ getDbStructure "test"
|
||||||
|
refDbStructure <- newIORef $ either (panic.show) id result
|
||||||
|
let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool getTime
|
||||||
|
ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool getTime
|
||||||
|
unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool getTime
|
||||||
|
proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool getTime
|
||||||
|
noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool getTime
|
||||||
|
binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool getTime
|
||||||
|
|
||||||
|
let reset = resetDb testDbConn
|
||||||
|
hspec $ do
|
||||||
|
mapM_ (beforeAll_ reset . before withApp) specs
|
||||||
|
|
||||||
|
-- this test runs with a different server flag
|
||||||
|
beforeAll_ reset . before ltdApp $
|
||||||
|
describe "Feature.QueryLimitedSpec" Feature.QueryLimitedSpec.spec
|
||||||
|
|
||||||
|
-- this test runs with a different schema
|
||||||
|
beforeAll_ reset . before unicodeApp $
|
||||||
|
describe "Feature.UnicodeSpec" Feature.UnicodeSpec.spec
|
||||||
|
|
||||||
|
-- this test runs with a proxy
|
||||||
|
beforeAll_ reset . before proxyApp $
|
||||||
|
describe "Feature.ProxySpec" Feature.ProxySpec.spec
|
||||||
|
|
||||||
|
-- this test runs without a JWT secret
|
||||||
|
beforeAll_ reset . before noJwtApp $
|
||||||
|
describe "Feature.NoJwtSpec" Feature.NoJwtSpec.spec
|
||||||
|
|
||||||
|
-- this test runs with a binary JWT secret
|
||||||
|
beforeAll_ reset . before binaryJwtApp $
|
||||||
|
describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec
|
||||||
|
|
||||||
|
where
|
||||||
|
specs = map (uncurry describe) [
|
||||||
|
("Feature.AuthSpec" , Feature.AuthSpec.spec)
|
||||||
|
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
|
||||||
|
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
|
||||||
|
, ("Feature.DeleteSpec" , Feature.DeleteSpec.spec)
|
||||||
|
, ("Feature.InsertSpec" , Feature.InsertSpec.spec)
|
||||||
|
, ("Feature.QuerySpec" , Feature.QuerySpec.spec)
|
||||||
|
, ("Feature.RangeSpec" , Feature.RangeSpec.spec)
|
||||||
|
, ("Feature.SingularSpec" , Feature.SingularSpec.spec)
|
||||||
|
, ("Feature.StructureSpec" , Feature.StructureSpec.spec)
|
||||||
|
]
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{-# OPTIONS_GHC -F -pgmF hspec-discover -optF --no-main #-}
|
|
||||||
+100
-142
@@ -1,182 +1,140 @@
|
|||||||
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.Monoid
|
|
||||||
import Data.Text hiding (map)
|
|
||||||
import qualified Data.Vector as V
|
|
||||||
import Control.Monad (void)
|
import Control.Monad (void)
|
||||||
import Control.Applicative
|
|
||||||
|
|
||||||
import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange,
|
import qualified System.IO.Error as E
|
||||||
hRange, hAuthorization, hAccept)
|
import System.Environment (getEnv)
|
||||||
import Codec.Binary.Base64.String (encode)
|
|
||||||
|
import qualified Data.ByteString.Base64 as B64 (encode, decodeLenient)
|
||||||
import Data.CaseInsensitive (CI(..))
|
import Data.CaseInsensitive (CI(..))
|
||||||
import Data.Maybe (fromMaybe)
|
import qualified Data.Set as S
|
||||||
|
import qualified Data.Map.Strict as M
|
||||||
|
import Data.List (lookup)
|
||||||
import Text.Regex.TDFA ((=~))
|
import Text.Regex.TDFA ((=~))
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
|
import qualified Data.ByteString.Lazy as BL
|
||||||
import System.Process (readProcess)
|
import System.Process (readProcess)
|
||||||
import Web.JWT (secret)
|
|
||||||
|
|
||||||
import qualified Data.Aeson.Types as J
|
|
||||||
|
|
||||||
import PostgREST.App (app)
|
|
||||||
import PostgREST.Config (AppConfig(..))
|
import PostgREST.Config (AppConfig(..))
|
||||||
import PostgREST.Middleware
|
|
||||||
import PostgREST.Error(pgErrResponse)
|
|
||||||
import PostgREST.DbStructure
|
|
||||||
|
|
||||||
dbString :: String
|
import Test.Hspec hiding (pendingWith)
|
||||||
dbString = "postgres://postgrest_test@localhost:5432/postgrest_test"
|
import Test.Hspec.Wai
|
||||||
|
|
||||||
isLeft :: Either a b -> Bool
|
import Network.HTTP.Types
|
||||||
isLeft (Left _ ) = True
|
import Network.Wai.Test (SResponse(simpleStatus, simpleHeaders, simpleBody))
|
||||||
isLeft _ = False
|
|
||||||
|
|
||||||
cfg :: AppConfig
|
import Data.Maybe (fromJust)
|
||||||
cfg = AppConfig dbString 3000 "postgrest_anonymous" "test" (secret "safe") 10
|
import Data.Aeson (decode, Value(..))
|
||||||
|
import qualified Data.JsonSchema.Draft4 as D4
|
||||||
|
|
||||||
testPoolOpts :: PoolSettings
|
import Protolude
|
||||||
testPoolOpts = fromMaybe (error "bad settings") $ H.poolSettings 1 30
|
|
||||||
|
|
||||||
pgSettings :: P.Settings
|
validateOpenApiResponse :: [Header] -> WaiSession ()
|
||||||
pgSettings = P.StringSettings $ cs dbString
|
validateOpenApiResponse headers = do
|
||||||
|
r <- request methodGet "/" headers ""
|
||||||
|
liftIO $
|
||||||
|
let respStatus = simpleStatus r in
|
||||||
|
respStatus `shouldSatisfy`
|
||||||
|
\s -> s == Status { statusCode = 200, statusMessage="OK" }
|
||||||
|
liftIO $
|
||||||
|
let respHeaders = simpleHeaders r in
|
||||||
|
respHeaders `shouldSatisfy`
|
||||||
|
\hs -> ("Content-Type", "application/openapi+json; charset=utf-8") `elem` hs
|
||||||
|
liftIO $
|
||||||
|
let respBody = simpleBody r
|
||||||
|
schema :: D4.Schema
|
||||||
|
schema = D4.emptySchema { D4._schemaRef = Just "openapi.json" }
|
||||||
|
schemaContext :: D4.SchemaWithURI D4.Schema
|
||||||
|
schemaContext = D4.SchemaWithURI
|
||||||
|
{ D4._swSchema = schema
|
||||||
|
, D4._swURI = Just "test/fixtures/openapi.json"
|
||||||
|
}
|
||||||
|
in
|
||||||
|
D4.fetchFilesystemAndValidate schemaContext ((fromJust . decode) respBody) `shouldReturn` Right ()
|
||||||
|
|
||||||
withApp :: ActionWith Application -> IO ()
|
getEnvVarWithDefault :: Text -> Text -> IO Text
|
||||||
withApp perform = do
|
getEnvVarWithDefault var def = do
|
||||||
pool :: H.Pool P.Postgres
|
varValue <- getEnv (toS var) `E.catchIOError` const (return $ toS def)
|
||||||
<- H.acquirePool pgSettings testPoolOpts
|
return $ toS varValue
|
||||||
|
|
||||||
let txSettings = Just (H.ReadCommitted, Just True)
|
_baseCfg :: AppConfig
|
||||||
dbOrError <- H.session pool $ H.tx txSettings $ getDbStructure (cs $ configSchema cfg)
|
_baseCfg = -- Connection Settings
|
||||||
db <- either (fail . show) return dbOrError
|
AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000
|
||||||
|
-- Jwt settings
|
||||||
|
(Just $ encodeUtf8 "safe") False
|
||||||
|
-- Connection Modifiers
|
||||||
|
10 Nothing (Just "test.switch_role")
|
||||||
|
-- Debug Settings
|
||||||
|
True
|
||||||
|
|
||||||
perform $ middle $ \req resp -> do
|
testCfg :: Text -> AppConfig
|
||||||
body <- strictRequestBody req
|
testCfg testDbConn = _baseCfg { configDatabase = testDbConn }
|
||||||
result <- liftIO $ H.session pool $ H.tx txSettings
|
|
||||||
$ runWithClaims cfg (app db cfg body) req
|
|
||||||
either (resp . pgErrResponse) resp result
|
|
||||||
|
|
||||||
where middle = defaultMiddle
|
testCfgNoJWT :: Text -> AppConfig
|
||||||
|
testCfgNoJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Nothing }
|
||||||
|
|
||||||
|
testUnicodeCfg :: Text -> AppConfig
|
||||||
|
testUnicodeCfg testDbConn = (testCfg testDbConn) { configSchema = "تست" }
|
||||||
|
|
||||||
|
testLtdRowsCfg :: Text -> AppConfig
|
||||||
|
testLtdRowsCfg testDbConn = (testCfg testDbConn) { configMaxRows = Just 2 }
|
||||||
|
|
||||||
|
testProxyCfg :: Text -> AppConfig
|
||||||
|
testProxyCfg testDbConn = (testCfg testDbConn) { configProxyUri = Just "https://postgrest.com/openapi.json" }
|
||||||
|
|
||||||
|
testCfgBinaryJWT :: Text -> AppConfig
|
||||||
|
testCfgBinaryJWT testDbConn = (testCfg testDbConn) { configJwtSecret = Just secretBs }
|
||||||
|
where secretBs = B64.decodeLenient "h2CGB1FoBd51aQooCS2g+UmRgYQfTPQ6v3+9ALbaqM4="
|
||||||
|
|
||||||
|
|
||||||
resetDb :: IO ()
|
setupDb :: Text -> IO ()
|
||||||
resetDb = do
|
setupDb dbConn = do
|
||||||
pool :: H.Pool P.Postgres
|
loadFixture dbConn "database"
|
||||||
<- H.acquirePool pgSettings testPoolOpts
|
loadFixture dbConn "roles"
|
||||||
void . liftIO $ H.session pool $
|
loadFixture dbConn "schema"
|
||||||
H.tx Nothing $ do
|
loadFixture dbConn "jwt"
|
||||||
H.unitEx [H.stmt| drop schema if exists test cascade |]
|
loadFixture dbConn "privileges"
|
||||||
H.unitEx [H.stmt| drop schema if exists private cascade |]
|
resetDb dbConn
|
||||||
H.unitEx [H.stmt| drop schema if exists postgrest cascade |]
|
|
||||||
|
|
||||||
loadFixture "roles"
|
resetDb :: Text -> IO ()
|
||||||
loadFixture "schema"
|
resetDb dbConn = loadFixture dbConn "data"
|
||||||
|
|
||||||
|
|
||||||
loadFixture :: FilePath -> IO()
|
|
||||||
loadFixture name =
|
|
||||||
void $ readProcess "psql" ["-U", "postgrest_test", "-d", "postgrest_test", "-a", "-f", "test/fixtures/" ++ name ++ ".sql"] []
|
|
||||||
|
|
||||||
|
loadFixture :: Text -> FilePath -> IO()
|
||||||
|
loadFixture dbConn name =
|
||||||
|
void $ readProcess "psql" [toS dbConn, "-a", "-f", "test/fixtures/" ++ name ++ ".sql"] []
|
||||||
|
|
||||||
rangeHdrs :: ByteRange -> [Header]
|
rangeHdrs :: ByteRange -> [Header]
|
||||||
rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]
|
rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]
|
||||||
|
|
||||||
|
rangeHdrsWithCount :: ByteRange -> [Header]
|
||||||
|
rangeHdrsWithCount r = ("Prefer", "count=exact") : rangeHdrs r
|
||||||
|
|
||||||
acceptHdrs :: BS.ByteString -> [Header]
|
acceptHdrs :: BS.ByteString -> [Header]
|
||||||
acceptHdrs mime = [(hAccept, mime)]
|
acceptHdrs mime = [(hAccept, mime)]
|
||||||
|
|
||||||
rangeUnit :: Header
|
rangeUnit :: Header
|
||||||
rangeUnit = ("Range-Unit" :: CI BS.ByteString, "items")
|
rangeUnit = ("Range-Unit" :: CI BS.ByteString, "items")
|
||||||
|
|
||||||
matchHeader :: CI BS.ByteString -> String -> [Header] -> Bool
|
matchHeader :: CI BS.ByteString -> BS.ByteString -> [Header] -> Bool
|
||||||
matchHeader name valRegex headers =
|
matchHeader name valRegex headers =
|
||||||
maybe False (=~ valRegex) $ lookup name headers
|
maybe False (=~ valRegex) $ lookup name headers
|
||||||
|
|
||||||
authHeaderBasic :: String -> String -> Header
|
authHeaderBasic :: BS.ByteString -> BS.ByteString -> Header
|
||||||
authHeaderBasic u p =
|
authHeaderBasic u p =
|
||||||
(hAuthorization, cs $ "Basic " ++ encode (u ++ ":" ++ p))
|
(hAuthorization, "Basic " <> (toS . B64.encode . toS $ u <> ":" <> p))
|
||||||
|
|
||||||
authHeaderJWT :: String -> Header
|
authHeaderJWT :: BS.ByteString -> Header
|
||||||
authHeaderJWT token =
|
authHeaderJWT token =
|
||||||
(hAuthorization, cs $ "Bearer " ++ token)
|
(hAuthorization, "Bearer " <> token)
|
||||||
|
|
||||||
testPool :: IO(H.Pool P.Postgres)
|
-- | Tests whether the text can be parsed as a json object comtaining
|
||||||
testPool = H.acquirePool pgSettings testPoolOpts
|
-- the key "message", and optional keys "details", "hint", "code",
|
||||||
|
-- and no extraneous keys
|
||||||
clearTable :: Text -> IO ()
|
isErrorFormat :: BL.ByteString -> Bool
|
||||||
clearTable table = do
|
isErrorFormat s =
|
||||||
pool <- testPool
|
"message" `S.member` keys &&
|
||||||
void . liftIO $ H.session pool $ H.tx Nothing $
|
S.null (S.difference keys validKeys)
|
||||||
H.unitEx $ B.Stmt ("delete from test."<>table) V.empty True
|
where
|
||||||
|
obj = decode s :: Maybe (M.Map Text Value)
|
||||||
clearProjectsTable :: IO ()
|
keys = fromMaybe S.empty (M.keysSet <$> obj)
|
||||||
clearProjectsTable = do
|
validKeys = S.fromList ["message", "details", "hint", "code"]
|
||||||
pool <- testPool
|
|
||||||
void . liftIO $ H.session pool $ H.tx Nothing $
|
|
||||||
H.unitEx $ B.Stmt "delete from test.projects where id > 4" V.empty True
|
|
||||||
|
|
||||||
|
|
||||||
createItems :: Int -> IO ()
|
|
||||||
createItems n = do
|
|
||||||
pool <- testPool
|
|
||||||
void . liftIO $ H.session pool $ H.tx Nothing txn
|
|
||||||
where
|
|
||||||
txn = mapM_ H.unitEx stmts
|
|
||||||
stmts = map [H.stmt|insert into test.items (id) values (?)|] [1..n]
|
|
||||||
|
|
||||||
createComplexItems :: IO ()
|
|
||||||
createComplexItems = do
|
|
||||||
pool <- testPool
|
|
||||||
void . liftIO $ H.session pool $ H.tx Nothing txn
|
|
||||||
where
|
|
||||||
txn = mapM_ H.unitEx stmts
|
|
||||||
stmts = getZipList $ [H.stmt|insert into test.complex_items (id, name, settings, arr_data) values (?,?,?,?)|]
|
|
||||||
<$> ZipList ([1..3]::[Int])
|
|
||||||
<*> ZipList (["One", "Two", "Three"]::[Text])
|
|
||||||
<*> ZipList [jobj,jobj,jobj]
|
|
||||||
<*> ZipList ([[1], [1,2], [1,2,3]]::[[Int]])
|
|
||||||
jobj = J.object [("foo", J.object [("int", J.Number 1),("bar", J.String "baz")])]
|
|
||||||
|
|
||||||
createNulls :: Int -> IO ()
|
|
||||||
createNulls n = do
|
|
||||||
pool <- testPool
|
|
||||||
void . liftIO $ H.session pool $ H.tx Nothing txn
|
|
||||||
where
|
|
||||||
txn = mapM_ H.unitEx (stmt':stmts)
|
|
||||||
stmt' = [H.stmt|insert into test.no_pk (a,b) values (null,null)|]
|
|
||||||
stmts = map [H.stmt|insert into test.no_pk (a,b) values (?,0)|] [1..n]
|
|
||||||
|
|
||||||
createNullInteger :: IO ()
|
|
||||||
createNullInteger = do
|
|
||||||
pool <- testPool
|
|
||||||
void . liftIO $ H.session pool $ H.tx Nothing $
|
|
||||||
H.unitEx $ [H.stmt| insert into "test".nullable_integer (a) values (null) |]
|
|
||||||
|
|
||||||
createLikableStrings :: IO ()
|
|
||||||
createLikableStrings = do
|
|
||||||
pool <- testPool
|
|
||||||
void . liftIO $ H.session pool $ H.tx Nothing $ do
|
|
||||||
H.unitEx $ insertSimplePk "xyyx" "u"
|
|
||||||
H.unitEx $ insertSimplePk "xYYx" "v"
|
|
||||||
where
|
|
||||||
insertSimplePk :: Text -> Text -> H.Stmt P.Postgres
|
|
||||||
insertSimplePk = [H.stmt|insert into test.simple_pk (k, extra) values (?,?)|]
|
|
||||||
|
|
||||||
createJsonData :: IO ()
|
|
||||||
createJsonData = do
|
|
||||||
pool <- testPool
|
|
||||||
void . liftIO $ H.session pool $ H.tx Nothing $
|
|
||||||
H.unitEx $
|
|
||||||
[H.stmt|
|
|
||||||
insert into test.json (data) values (?)
|
|
||||||
|]
|
|
||||||
(J.object [("id", J.Number 1)
|
|
||||||
,("foo", J.object [("bar", J.String "baz")])
|
|
||||||
])
|
|
||||||
|
|||||||
+6
-24
@@ -1,23 +1,18 @@
|
|||||||
module TestTypes (
|
module TestTypes (
|
||||||
IncPK(..)
|
IncPK(..)
|
||||||
, CompoundPK(..)
|
, CompoundPK(..)
|
||||||
-- , incFromList
|
|
||||||
-- , compoundFromList
|
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
import Data.Aeson ((.:))
|
import Data.Aeson ((.:))
|
||||||
-- import Data.Maybe (fromJust)
|
|
||||||
import Control.Applicative
|
|
||||||
import Control.Monad (mzero)
|
|
||||||
|
|
||||||
import Prelude
|
import Protolude
|
||||||
|
|
||||||
data IncPK = IncPK {
|
data IncPK = IncPK {
|
||||||
incId :: Int
|
incId :: Int
|
||||||
, incNullableStr :: Maybe String
|
, incNullableStr :: Maybe Text
|
||||||
, incStr :: String
|
, incStr :: Text
|
||||||
, incInsert :: String
|
, incInsert :: Text
|
||||||
} deriving (Eq, Show)
|
} deriving (Eq, Show)
|
||||||
|
|
||||||
instance JSON.FromJSON IncPK where
|
instance JSON.FromJSON IncPK where
|
||||||
@@ -28,18 +23,11 @@ instance JSON.FromJSON IncPK where
|
|||||||
r .: "inserted_at"
|
r .: "inserted_at"
|
||||||
parseJSON _ = mzero
|
parseJSON _ = mzero
|
||||||
|
|
||||||
-- incFromList :: [(String, SqlValue)] -> IncPK
|
|
||||||
-- incFromList row = IncPK
|
|
||||||
-- (fromSql . fromJust $ lookup "id" row)
|
|
||||||
-- (fromSql . fromJust $ lookup "nullable_string" row)
|
|
||||||
-- (fromSql . fromJust $ lookup "non_nullable_string" row)
|
|
||||||
-- (fromSql . fromJust $ lookup "inserted_at" row)
|
|
||||||
|
|
||||||
data CompoundPK = CompoundPK {
|
data CompoundPK = CompoundPK {
|
||||||
compoundK1 :: Int
|
compoundK1 :: Int
|
||||||
, compoundK2 :: Int
|
, compoundK2 :: Text
|
||||||
, 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 <$>
|
||||||
@@ -47,9 +35,3 @@ instance JSON.FromJSON CompoundPK where
|
|||||||
r .: "k2" <*>
|
r .: "k2" <*>
|
||||||
r .: "extra"
|
r .: "extra"
|
||||||
parseJSON _ = mzero
|
parseJSON _ = mzero
|
||||||
|
|
||||||
-- compoundFromList :: [(String, SqlValue)] -> CompoundPK
|
|
||||||
-- compoundFromList row = CompoundPK
|
|
||||||
-- (fromSql . fromJust $ lookup "k1" row)
|
|
||||||
-- (fromSql . fromJust $ lookup "k2" row)
|
|
||||||
-- (fromSql . fromJust $ lookup "extra" row)
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ spec = around dbWithSchema $ do
|
|||||||
quickALQuery c "select * from \"1\".items where id = ?" [snd row]
|
quickALQuery c "select * from \"1\".items where id = ?" [snd row]
|
||||||
`shouldReturn` [[row]]
|
`shouldReturn` [[row]]
|
||||||
|
|
||||||
let {user = "jdoe"; pass = "secret"; role = "test_default_role"}
|
let {user = "jdoe"; pass = "secret"; role = "postgrest_test_default_role"}
|
||||||
describe "addUser" $ do
|
describe "addUser" $ do
|
||||||
it "adds a correct user to the right table" $ \conn -> do
|
it "adds a correct user to the right table" $ \conn -> do
|
||||||
addUser user pass role conn
|
addUser user pass role conn
|
||||||
|
|||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#! /bin/bash
|
||||||
|
if [ -z "$1" ]
|
||||||
|
then
|
||||||
|
echo "Please supply the connection uri for the user with create database privileges"
|
||||||
|
exit -1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$2" ]
|
||||||
|
then
|
||||||
|
echo "Please supply the test database name"
|
||||||
|
exit -1
|
||||||
|
fi
|
||||||
|
if [[ $1 != postgres://* ]]
|
||||||
|
then
|
||||||
|
echo "Please use a valid connection URI (https://www.postgresql.org/docs/current/static/libpq-connect.html#AEN45347)"
|
||||||
|
exit -1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASEPATH=$( cd $(dirname $0) ; pwd -P )
|
||||||
|
#Remove database path from the connection uri--prevents setting up the new database name with PGDATABASE
|
||||||
|
URI=$(echo $1 | cut -d'/' -f1-3)
|
||||||
|
#Extract host and port--we need this to form the new connection string
|
||||||
|
HOST_PORT=$(echo $URI | cut -d'/' -f3 | cut -d'@' -f2 )
|
||||||
|
DB=$2
|
||||||
|
# Specify the username of choice, or let the script create a random unique user by appending the database name
|
||||||
|
TEST_USER_NAME=postgrest_test_authenticator
|
||||||
|
# New password will get assigned only if the user does not already exist
|
||||||
|
# Otherwise make sure to provide the correct password for the existing user
|
||||||
|
TEST_USER_PASS=$(cat /dev/urandom | env LC_CTYPE=C tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)
|
||||||
|
|
||||||
|
PGOPTIONS='-c client_min_messages=WARNING' psql "$URI" -Xq >/dev/null -c 'select rolcreatedb from pg_authid where rolname = current_user;' 2>/dev/null
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "ERROR: Please specify the user with 'Create DB' permissions, and ensure that the default database for the username exists."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# plpgsql does not like psql variables, easier to pull this part off with bash variables
|
||||||
|
PGOPTIONS='-c client_min_messages=WARNING' psql "$URI" -Xq >/dev/null <<EOF
|
||||||
|
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE pg_stat_activity.datname = '$DB'
|
||||||
|
AND pid <> pg_backend_pid();
|
||||||
|
|
||||||
|
DROP DATABASE IF EXISTS $DB;
|
||||||
|
DROP ROLE IF EXISTS $TEST_USER_NAME;
|
||||||
|
CREATE USER $TEST_USER_NAME WITH LOGIN NOINHERIT PASSWORD '$TEST_USER_PASS' CREATEROLE;
|
||||||
|
CREATE DATABASE $DB OWNER $TEST_USER_NAME;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
PGDATABASE=$DB PGOPTIONS='-c client_min_messages=WARNING' psql "$URI" --set=db=$DB -Xq <<EOF
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
ALTER DATABASE ${DB} SET request.jwt.claim.id = '-1';
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create a new connection string to use with the test runner
|
||||||
|
echo 'postgres://'${TEST_USER_NAME}':'$TEST_USER_PASS'@'$HOST_PORT'/'$DB
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#! /bin/bash
|
||||||
|
if [ -z "$1" ]
|
||||||
|
then
|
||||||
|
echo "Please supply the connection uri for the user with create database privileges"
|
||||||
|
exit -1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$2" ]
|
||||||
|
then
|
||||||
|
echo "Please supply the test database name"
|
||||||
|
exit -1
|
||||||
|
fi
|
||||||
|
if [[ $1 != postgres://* ]]
|
||||||
|
then
|
||||||
|
echo "Please use a valid connection URI (https://www.postgresql.org/docs/current/static/libpq-connect.html#AEN45347)"
|
||||||
|
exit -1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASEPATH=$( cd $(dirname $0) ; pwd -P )
|
||||||
|
#Remove database path from the connection uri
|
||||||
|
URI=$(echo $1 | cut -d'/' -f1-3)
|
||||||
|
DB=$2
|
||||||
|
|
||||||
|
PGOPTIONS='-c client_min_messages=WARNING' psql "$URI" -Xq >/dev/null -c 'select rolcreatedb from pg_authid where rolname = current_user;' 2>/dev/null
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "ERROR: Please specify the user with 'Create DB' permissions, and ensure that the default database for the username exists."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# plpgsql does not like psql variables, easier to pull this part off with bash variables
|
||||||
|
PGOPTIONS='-c client_min_messages=WARNING' psql "$URI" -Xq >/dev/null <<EOF
|
||||||
|
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE pg_stat_activity.datname = '$DB'
|
||||||
|
AND pid <> pg_backend_pid();
|
||||||
|
|
||||||
|
drop database if exists "$DB";
|
||||||
|
|
||||||
|
-- Find all test users that were members of role 'postgrest_test_author'--that way we don't have to know the
|
||||||
|
-- test user name (in case it was auto-generated).
|
||||||
|
DO \$\$
|
||||||
|
DECLARE
|
||||||
|
mem text;
|
||||||
|
BEGIN
|
||||||
|
FOR mem IN
|
||||||
|
SELECT pg_get_userbyid(member)
|
||||||
|
FROM pg_roles r
|
||||||
|
JOIN pg_auth_members m
|
||||||
|
ON m.roleid = r.oid
|
||||||
|
WHERE rolname = 'postgrest_test_author'
|
||||||
|
LOOP
|
||||||
|
EXECUTE 'drop role '|| mem || ';';
|
||||||
|
END LOOP;
|
||||||
|
END \$\$;
|
||||||
|
|
||||||
|
DO \$\$
|
||||||
|
DECLARE
|
||||||
|
r text;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
VALUES('postgrest_test_author'),('postgrest_test_anonymous'),('postgrest_test_default_role')
|
||||||
|
LOOP
|
||||||
|
EXECUTE 'drop role if exists '|| r || ';';
|
||||||
|
END LOOP;
|
||||||
|
END \$\$;
|
||||||
|
EOF
|
||||||
Vendored
+288
@@ -0,0 +1,288 @@
|
|||||||
|
--
|
||||||
|
-- PostgreSQL database dump
|
||||||
|
--
|
||||||
|
|
||||||
|
-- Dumped from database version 9.5beta1
|
||||||
|
-- Dumped by pg_dump version 9.5beta1
|
||||||
|
|
||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
|
||||||
|
SET search_path = postgrest, pg_catalog;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: auth; Type: TABLE DATA; Schema: postgrest; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE auth CASCADE;
|
||||||
|
INSERT INTO auth VALUES ('jdoe', 'postgrest_test_author', '1234 ');
|
||||||
|
|
||||||
|
|
||||||
|
SET search_path = private, pg_catalog;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: articles; Type: TABLE DATA; Schema: private; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE articles CASCADE;
|
||||||
|
INSERT INTO articles VALUES (1, 'No… It''s a thing; it''s like a plan, but with more greatness.', 'diogo');
|
||||||
|
INSERT INTO articles VALUES (2, 'Stop talking, brain thinking. Hush.', 'diogo');
|
||||||
|
INSERT INTO articles VALUES (3, 'It''s a fez. I wear a fez now. Fezes are cool.', 'diogo');
|
||||||
|
|
||||||
|
|
||||||
|
SET search_path = test, pg_catalog;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: users; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE users CASCADE;
|
||||||
|
INSERT INTO users VALUES (1, 'Angela Martin');
|
||||||
|
INSERT INTO users VALUES (2, 'Michael Scott');
|
||||||
|
INSERT INTO users VALUES (3, 'Dwight Schrute');
|
||||||
|
|
||||||
|
|
||||||
|
SET search_path = private, pg_catalog;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: article_stars; Type: TABLE DATA; Schema: private; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE article_stars CASCADE;
|
||||||
|
INSERT INTO article_stars VALUES (1, 1, '2015-12-08 04:22:57.472738');
|
||||||
|
INSERT INTO article_stars VALUES (1, 2, '2015-12-08 04:22:57.472738');
|
||||||
|
INSERT INTO article_stars VALUES (2, 3, '2015-12-08 04:22:57.472738');
|
||||||
|
INSERT INTO article_stars VALUES (3, 2, '2015-12-08 04:22:57.472738');
|
||||||
|
INSERT INTO article_stars VALUES (1, 3, '2015-12-08 04:22:57.472738');
|
||||||
|
|
||||||
|
|
||||||
|
SET search_path = test, pg_catalog;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- 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: -
|
||||||
|
--
|
||||||
|
TRUNCATE TABLE auto_incrementing_pk CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: auto_incrementing_pk_id_seq; Type: SEQUENCE SET; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
SELECT pg_catalog.setval('auto_incrementing_pk_id_seq', 1, true);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: clients; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE clients CASCADE;
|
||||||
|
INSERT INTO clients VALUES (1, 'Microsoft');
|
||||||
|
INSERT INTO clients VALUES (2, 'Apple');
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: projects; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE projects CASCADE;
|
||||||
|
INSERT INTO projects VALUES (1, 'Windows 7', 1);
|
||||||
|
INSERT INTO projects VALUES (2, 'Windows 10', 1);
|
||||||
|
INSERT INTO projects VALUES (3, 'IOS', 2);
|
||||||
|
INSERT INTO projects VALUES (4, 'OSX', 2);
|
||||||
|
INSERT INTO projects VALUES (5, 'Orphan', NULL);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: tasks; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE tasks CASCADE;
|
||||||
|
INSERT INTO tasks VALUES (1, 'Design w7', 1);
|
||||||
|
INSERT INTO tasks VALUES (2, 'Code w7', 1);
|
||||||
|
INSERT INTO tasks VALUES (3, 'Design w10', 2);
|
||||||
|
INSERT INTO tasks VALUES (4, 'Code w10', 2);
|
||||||
|
INSERT INTO tasks VALUES (5, 'Design IOS', 3);
|
||||||
|
INSERT INTO tasks VALUES (6, 'Code IOS', 3);
|
||||||
|
INSERT INTO tasks VALUES (7, 'Design OSX', 4);
|
||||||
|
INSERT INTO tasks VALUES (8, 'Code OSX', 4);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: users_tasks; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE users_tasks CASCADE;
|
||||||
|
INSERT INTO users_tasks VALUES (1, 1);
|
||||||
|
INSERT INTO users_tasks VALUES (1, 2);
|
||||||
|
INSERT INTO users_tasks VALUES (1, 3);
|
||||||
|
INSERT INTO users_tasks VALUES (1, 4);
|
||||||
|
INSERT INTO users_tasks VALUES (2, 5);
|
||||||
|
INSERT INTO users_tasks VALUES (2, 6);
|
||||||
|
INSERT INTO users_tasks VALUES (2, 7);
|
||||||
|
INSERT INTO users_tasks VALUES (3, 1);
|
||||||
|
INSERT INTO users_tasks VALUES (3, 5);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: comments; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE comments CASCADE;
|
||||||
|
INSERT INTO comments VALUES (1, 1, 2, 6, 'Needs to be delivered ASAP');
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: complex_items; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE complex_items CASCADE;
|
||||||
|
INSERT INTO complex_items VALUES (1, 'One', '{"foo":{"int":1,"bar":"baz"}}', '{1}');
|
||||||
|
INSERT INTO complex_items VALUES (2, 'Two', '{"foo":{"int":1,"bar":"baz"}}', '{1,2}');
|
||||||
|
INSERT INTO complex_items VALUES (3, 'Three', '{"foo":{"int":1,"bar":"baz"}}', '{1,2,3}');
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: compound_pk; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
TRUNCATE TABLE compound_pk CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: simple_pk; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE simple_pk CASCADE;
|
||||||
|
INSERT INTO simple_pk VALUES ('xyyx', 'u');
|
||||||
|
INSERT INTO simple_pk VALUES ('xYYx', 'v');
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: has_fk; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
TRUNCATE TABLE has_fk CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: has_fk_id_seq; Type: SEQUENCE SET; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
SELECT pg_catalog.setval('has_fk_id_seq', 1, false);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: items; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE items CASCADE;
|
||||||
|
INSERT INTO items VALUES (1);
|
||||||
|
INSERT INTO items VALUES (2);
|
||||||
|
INSERT INTO items VALUES (3);
|
||||||
|
INSERT INTO items VALUES (4);
|
||||||
|
INSERT INTO items VALUES (5);
|
||||||
|
INSERT INTO items VALUES (6);
|
||||||
|
INSERT INTO items VALUES (7);
|
||||||
|
INSERT INTO items VALUES (8);
|
||||||
|
INSERT INTO items VALUES (9);
|
||||||
|
INSERT INTO items VALUES (10);
|
||||||
|
INSERT INTO items VALUES (11);
|
||||||
|
INSERT INTO items VALUES (12);
|
||||||
|
INSERT INTO items VALUES (13);
|
||||||
|
INSERT INTO items VALUES (14);
|
||||||
|
INSERT INTO items VALUES (15);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: items_id_seq; Type: SEQUENCE SET; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
SELECT pg_catalog.setval('items_id_seq', 15, true);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: json; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE json CASCADE;
|
||||||
|
INSERT INTO json VALUES ('{"foo":{"bar":"baz"},"id":1}');
|
||||||
|
INSERT INTO json VALUES ('{"id":3}');
|
||||||
|
INSERT INTO json VALUES ('{"id":0}');
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: menagerie; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
TRUNCATE TABLE menagerie CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: no_pk; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE no_pk CASCADE;
|
||||||
|
INSERT INTO no_pk VALUES (NULL, NULL);
|
||||||
|
INSERT INTO no_pk VALUES ('1', '0');
|
||||||
|
INSERT INTO no_pk VALUES ('2', '0');
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: nullable_integer; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE nullable_integer CASCADE;
|
||||||
|
INSERT INTO nullable_integer VALUES (NULL);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: tsearch; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE tsearch CASCADE;
|
||||||
|
INSERT INTO tsearch VALUES ('''bar'':2 ''foo'':1');
|
||||||
|
INSERT INTO tsearch VALUES ('''baz'':1 ''qux'':2');
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: users_projects; Type: TABLE DATA; Schema: test; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
TRUNCATE TABLE users_projects CASCADE;
|
||||||
|
INSERT INTO users_projects VALUES (1, 1);
|
||||||
|
INSERT INTO users_projects VALUES (1, 2);
|
||||||
|
INSERT INTO users_projects VALUES (2, 3);
|
||||||
|
INSERT INTO users_projects VALUES (2, 4);
|
||||||
|
INSERT INTO users_projects VALUES (3, 1);
|
||||||
|
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
|
||||||
|
--
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
set client_min_messages to warning;
|
||||||
|
DROP SCHEMA IF EXISTS test, private, postgrest, jwt, تست CASCADE;
|
||||||
|
DROP TYPE IF EXISTS jwt_token CASCADE;
|
||||||
Vendored
+151
@@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
"id": "draft04.json",
|
||||||
|
"$schema": "draft04.json",
|
||||||
|
"description": "Core schema meta-schema",
|
||||||
|
"definitions": {
|
||||||
|
"schemaArray": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#" }
|
||||||
|
},
|
||||||
|
"positiveInteger": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"positiveIntegerDefault0": {
|
||||||
|
"allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ]
|
||||||
|
},
|
||||||
|
"simpleTypes": {
|
||||||
|
"enum": [ "array", "boolean", "integer", "null", "number", "object", "string" ]
|
||||||
|
},
|
||||||
|
"stringArray": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"$schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": {},
|
||||||
|
"multipleOf": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"exclusiveMinimum": true
|
||||||
|
},
|
||||||
|
"maximum": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"exclusiveMaximum": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"minimum": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"exclusiveMinimum": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"maxLength": { "$ref": "#/definitions/positiveInteger" },
|
||||||
|
"minLength": { "$ref": "#/definitions/positiveIntegerDefault0" },
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "regex"
|
||||||
|
},
|
||||||
|
"additionalItems": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "type": "boolean" },
|
||||||
|
{ "$ref": "#" }
|
||||||
|
],
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "$ref": "#" },
|
||||||
|
{ "$ref": "#/definitions/schemaArray" }
|
||||||
|
],
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"maxItems": { "$ref": "#/definitions/positiveInteger" },
|
||||||
|
"minItems": { "$ref": "#/definitions/positiveIntegerDefault0" },
|
||||||
|
"uniqueItems": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"maxProperties": { "$ref": "#/definitions/positiveInteger" },
|
||||||
|
"minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" },
|
||||||
|
"required": { "$ref": "#/definitions/stringArray" },
|
||||||
|
"additionalProperties": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "type": "boolean" },
|
||||||
|
{ "$ref": "#" }
|
||||||
|
],
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": { "$ref": "#" },
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": { "$ref": "#" },
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"patternProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": { "$ref": "#" },
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "$ref": "#" },
|
||||||
|
{ "$ref": "#/definitions/stringArray" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enum": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "$ref": "#/definitions/simpleTypes" },
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/definitions/simpleTypes" },
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"format": { "type": "string" },
|
||||||
|
"allOf": { "$ref": "#/definitions/schemaArray" },
|
||||||
|
"anyOf": { "$ref": "#/definitions/schemaArray" },
|
||||||
|
"oneOf": { "$ref": "#/definitions/schemaArray" },
|
||||||
|
"not": { "$ref": "#" }
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"exclusiveMaximum": [ "maximum" ],
|
||||||
|
"exclusiveMinimum": [ "minimum" ]
|
||||||
|
},
|
||||||
|
"default": {}
|
||||||
|
}
|
||||||
Vendored
+65
@@ -0,0 +1,65 @@
|
|||||||
|
-- From michelp/pgjwt commit c02bbd3
|
||||||
|
BEGIN;
|
||||||
|
set client_min_messages to warning;
|
||||||
|
DROP SCHEMA IF EXISTS jwt CASCADE;
|
||||||
|
CREATE SCHEMA jwt;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION jwt.url_encode(data bytea) RETURNS text LANGUAGE sql AS $$
|
||||||
|
SELECT translate(encode(data, 'base64'), E'+/=\n', '-_');
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION jwt.url_decode(data text) RETURNS bytea LANGUAGE sql AS $$
|
||||||
|
WITH t AS (SELECT translate(data, '-_', '+/')),
|
||||||
|
rem AS (SELECT length((SELECT * FROM t)) % 4) -- compute padding size
|
||||||
|
SELECT decode(
|
||||||
|
(SELECT * FROM t) ||
|
||||||
|
CASE WHEN (SELECT * FROM rem) > 0
|
||||||
|
THEN repeat('=', (4 - (SELECT * FROM rem)))
|
||||||
|
ELSE '' END,
|
||||||
|
'base64');
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION jwt.algorithm_sign(signables text, secret text, algorithm text)
|
||||||
|
RETURNS text LANGUAGE sql AS $$
|
||||||
|
WITH
|
||||||
|
alg AS (
|
||||||
|
SELECT CASE
|
||||||
|
WHEN algorithm = 'HS256' THEN 'sha256'
|
||||||
|
WHEN algorithm = 'HS384' THEN 'sha384'
|
||||||
|
WHEN algorithm = 'HS512' THEN 'sha512'
|
||||||
|
ELSE '' END) -- hmac throws error
|
||||||
|
SELECT jwt.url_encode(hmac(signables, secret, (select * FROM alg)));
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION jwt.sign(payload json, secret text, algorithm text DEFAULT 'HS256')
|
||||||
|
RETURNS text LANGUAGE sql AS $$
|
||||||
|
WITH
|
||||||
|
header AS (
|
||||||
|
SELECT jwt.url_encode(convert_to('{"alg":"' || algorithm || '","typ":"JWT"}', 'utf8'))
|
||||||
|
),
|
||||||
|
payload AS (
|
||||||
|
SELECT jwt.url_encode(convert_to(payload::text, 'utf8'))
|
||||||
|
),
|
||||||
|
signables AS (
|
||||||
|
SELECT (SELECT * FROM header) || '.' || (SELECT * FROM payload)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT * FROM signables)
|
||||||
|
|| '.' ||
|
||||||
|
jwt.algorithm_sign((SELECT * FROM signables), secret, algorithm);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION jwt.verify(token text, secret text, algorithm text DEFAULT 'HS256')
|
||||||
|
RETURNS table(header json, payload json, valid boolean) LANGUAGE sql AS $$
|
||||||
|
SELECT
|
||||||
|
convert_from(jwt.url_decode(r[1]), 'utf8')::json AS header,
|
||||||
|
convert_from(jwt.url_decode(r[2]), 'utf8')::json AS payload,
|
||||||
|
r[3] = jwt.algorithm_sign(r[1] || '.' || r[2], secret, algorithm) AS valid
|
||||||
|
FROM regexp_split_to_array(token, '\.') r;
|
||||||
|
$$;
|
||||||
|
COMMIT;
|
||||||
Vendored
+1607
File diff suppressed because it is too large
Load Diff
Vendored
+63
@@ -0,0 +1,63 @@
|
|||||||
|
-- Privileges for anonymous
|
||||||
|
GRANT USAGE ON SCHEMA
|
||||||
|
postgrest
|
||||||
|
, test
|
||||||
|
, jwt
|
||||||
|
, "تست"
|
||||||
|
TO postgrest_test_anonymous;
|
||||||
|
|
||||||
|
-- Schema test objects
|
||||||
|
SET search_path = test, "تست", pg_catalog;
|
||||||
|
|
||||||
|
GRANT ALL ON TABLE
|
||||||
|
items
|
||||||
|
, "articleStars"
|
||||||
|
, articles
|
||||||
|
, auto_incrementing_pk
|
||||||
|
, clients
|
||||||
|
, comments
|
||||||
|
, complex_items
|
||||||
|
, compound_pk
|
||||||
|
, empty_table
|
||||||
|
, has_count_column
|
||||||
|
, has_fk
|
||||||
|
, insertable_view_with_join
|
||||||
|
, json
|
||||||
|
, materialized_view
|
||||||
|
, menagerie
|
||||||
|
, no_pk
|
||||||
|
, nullable_integer
|
||||||
|
, projects
|
||||||
|
, projects_view
|
||||||
|
, projects_view_alt
|
||||||
|
, simple_pk
|
||||||
|
, tasks
|
||||||
|
, filtered_tasks
|
||||||
|
, tsearch
|
||||||
|
, users
|
||||||
|
, users_projects
|
||||||
|
, users_tasks
|
||||||
|
, "Escap3e;"
|
||||||
|
, "ghostBusters"
|
||||||
|
, "withUnique"
|
||||||
|
, "clashing_column"
|
||||||
|
, "موارد"
|
||||||
|
, addresses
|
||||||
|
, orders
|
||||||
|
TO postgrest_test_anonymous;
|
||||||
|
|
||||||
|
GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous;
|
||||||
|
|
||||||
|
GRANT USAGE ON SEQUENCE
|
||||||
|
auto_incrementing_pk_id_seq
|
||||||
|
, items_id_seq
|
||||||
|
, callcounter_count
|
||||||
|
TO postgrest_test_anonymous;
|
||||||
|
|
||||||
|
-- Privileges for non anonymous users
|
||||||
|
GRANT USAGE ON SCHEMA test TO postgrest_test_author;
|
||||||
|
GRANT ALL ON TABLE authors_only TO postgrest_test_author;
|
||||||
|
|
||||||
|
GRANT SELECT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
||||||
|
GRANT INSERT (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
||||||
|
GRANT UPDATE (article_id, user_id) ON TABLE limited_article_stars TO postgrest_test_anonymous;
|
||||||
Vendored
+6
-15
@@ -1,16 +1,7 @@
|
|||||||
create function pg_temp.create_role_if_not_exists(rolename name, opts character varying) RETURNS text
|
\set AUTHENTICATOR current_user
|
||||||
LANGUAGE plpgsql
|
DROP ROLE IF EXISTS postgrest_test_anonymous, postgrest_test_default_role, postgrest_test_author;
|
||||||
AS $$
|
CREATE ROLE postgrest_test_anonymous;
|
||||||
BEGIN
|
CREATE ROLE postgrest_test_default_role;
|
||||||
IF NOT EXISTS (SELECT * FROM pg_roles WHERE rolname = rolename) THEN
|
CREATE ROLE postgrest_test_author;
|
||||||
EXECUTE format('CREATE ROLE %I %s', rolename, opts);
|
|
||||||
RETURN 'CREATE ROLE';
|
|
||||||
ELSE
|
|
||||||
RETURN format('ROLE ''%I'' ALREADY EXISTS', rolename);
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
select pg_temp.create_role_if_not_exists('postgrest_anonymous', 'with nologin') as a
|
GRANT postgrest_test_anonymous, postgrest_test_default_role, postgrest_test_author TO :USER;
|
||||||
, pg_temp.create_role_if_not_exists('test_default_role', 'with nologin') as b
|
|
||||||
, pg_temp.create_role_if_not_exists('postgrest_test_author', 'with nologin') into temp shh;
|
|
||||||
Vendored
+818
-487
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user