Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46b3c7b9a | ||
|
|
079cf0aa54 | ||
|
|
0589ddcd90 | ||
|
|
f85975f5ad | ||
|
|
d1de6615f2 | ||
|
|
8f0ba7c41e | ||
|
|
7392d204bf | ||
|
|
ee82ad1864 | ||
|
|
6446fc962d | ||
|
|
88d4798d5e | ||
|
|
abe87f2f16 | ||
|
|
b08a402df8 | ||
|
|
f95b501232 | ||
|
|
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 | ||
|
|
19b856db3b | ||
|
|
4b911263e3 | ||
|
|
c0fa5c3d4a | ||
|
|
b3055888c5 | ||
|
|
48ee77a64d | ||
|
|
6f9fc7adac | ||
|
|
f678dff735 | ||
|
|
e623cf8198 | ||
|
|
0fd02ddc1d | ||
|
|
8250008130 | ||
|
|
377aeefde9 | ||
|
|
1170137e7f | ||
|
|
a5e7d9aff0 | ||
|
|
ae80d08484 | ||
|
|
029276bd62 | ||
|
|
addd47c09f | ||
|
|
be9cce0043 | ||
|
|
64da08b220 | ||
|
|
50745ad48b | ||
|
|
7f58600dbb | ||
|
|
bc552848ba | ||
|
|
936a368be7 | ||
|
|
d03d68c25f | ||
|
|
1f6fc5cbd8 | ||
|
|
60007b5f10 | ||
|
|
f54742186b | ||
|
|
d2d86b35c4 | ||
|
|
78fd766de3 | ||
|
|
cf2e45d47f | ||
|
|
0cce22f8c1 | ||
|
|
aa2f0287b1 | ||
|
|
f18cfbd7f4 | ||
|
|
f5bb898992 | ||
|
|
7f52430e0e | ||
|
|
c80ab6be0f | ||
|
|
b6a58935f3 | ||
|
|
f3d5759c1a | ||
|
|
87c946ef52 | ||
|
|
2c3a52fc35 | ||
|
|
496cd77510 | ||
|
|
8f04967103 | ||
|
|
b260cff6fd | ||
|
|
ffeb02e72e | ||
|
|
ed18f2b1e8 | ||
|
|
a0af096ff4 | ||
|
|
c21e5a9fc8 | ||
|
|
79caba278e | ||
|
|
80da27237d | ||
|
|
f24dd90e51 | ||
|
|
17920b85c7 | ||
|
|
ffd45ef0b5 | ||
|
|
ad555986d4 | ||
|
|
54eb0d3ec4 | ||
|
|
aa87853e71 | ||
|
|
65c5054c93 | ||
|
|
8711362cf6 | ||
|
|
57aad6baa6 | ||
|
|
0c3545fa09 | ||
|
|
75646247f2 | ||
|
|
3152b24d3f | ||
|
|
8171a9959d | ||
|
|
c4018ad882 | ||
|
|
62951f29ac | ||
|
|
9dc3399d4a | ||
|
|
e0c99f52e0 | ||
|
|
6c0966f656 | ||
|
|
9da7db9e09 | ||
|
|
f47d5e52f4 | ||
|
|
fbe6600ff1 | ||
|
|
a6cfecbc29 | ||
|
|
51e1d8d796 | ||
|
|
0bf3dd9b1c | ||
|
|
98caf9e091 | ||
|
|
71e6d0414d | ||
|
|
3794d358b4 | ||
|
|
2f6254f44c | ||
|
|
6cc493da6b | ||
|
|
e8bac0c739 | ||
|
|
0f5bc34c04 | ||
|
|
6e9a28ba3c | ||
|
|
acb8f8a154 | ||
|
|
eb94c507f9 | ||
|
|
1061854f35 | ||
|
|
b8b073810f | ||
|
|
22c50392e5 | ||
|
|
80928535b0 | ||
|
|
0374d4e651 | ||
|
|
16af8fc61e | ||
|
|
e1e4fe6d5c | ||
|
|
3a682360f3 | ||
|
|
7560fafbab | ||
|
|
62cb8e0453 | ||
|
|
3f1d2d8ed9 | ||
|
|
ad316841f2 | ||
|
|
807e4b7787 | ||
|
|
91f15aa0e3 | ||
|
|
bda5f0a176 | ||
|
|
86d992c21f | ||
|
|
a508f8df37 | ||
|
|
1b28f77854 | ||
|
|
c8479e792f | ||
|
|
22fb13b30a | ||
|
|
9daaf6ba70 | ||
|
|
64172873e4 | ||
|
|
3a659843d2 | ||
|
|
9fa16e2053 | ||
|
|
c25cd1b97e | ||
|
|
1e017b86d3 | ||
|
|
1524a4fe74 | ||
|
|
d4ef343b8d | ||
|
|
2687ad63dd | ||
|
|
08fc83709c | ||
|
|
92174dc0d2 | ||
|
|
2d72847d39 | ||
|
|
1b402abaf2 | ||
|
|
368bf34842 | ||
|
|
c024687629 | ||
|
|
4f7dd12133 | ||
|
|
8b16a1ee33 | ||
|
|
e210283a70 | ||
|
|
3d2a78e962 | ||
|
|
34c153086c | ||
|
|
aab2f0d1f1 | ||
|
|
cfad68f5cb | ||
|
|
37b1d7d692 | ||
|
|
28b7b80bbb | ||
|
|
14e806759d | ||
|
|
5e9d29e07b | ||
|
|
5113187bb1 | ||
|
|
b3e88a37d3 | ||
|
|
4803d7c828 | ||
|
|
ef08af359c | ||
|
|
2e4c862d25 | ||
|
|
6b118819d7 | ||
|
|
2c1236652e | ||
|
|
066d120c0e | ||
|
|
6b7e833023 | ||
|
|
31c3edc183 | ||
|
|
2cfe3651b4 | ||
|
|
246c47dba4 | ||
|
|
6fd0d5648f | ||
|
|
738989c375 | ||
|
|
9458ee3292 | ||
|
|
1c22b8d429 | ||
|
|
c6956d0ff6 | ||
|
|
915ce0fa9d | ||
|
|
4369a5617e | ||
|
|
482a43d722 | ||
|
|
f7e6005087 | ||
|
|
f02b8381ea | ||
|
|
58d009b388 | ||
|
|
d43bac6e8f | ||
|
|
d8b7332acc | ||
|
|
1fdb700bc8 | ||
|
|
bd1826670e | ||
|
|
c265cf829b | ||
|
|
5390fb702d | ||
|
|
aab1500879 | ||
|
|
0ed8ec9868 | ||
|
|
00c8303fed | ||
|
|
cd3a149aa4 | ||
|
|
97d612a60d | ||
|
|
c6d40fff8c | ||
|
|
5de9db0ca1 | ||
|
|
a624eb08ce | ||
|
|
af15046d3f | ||
|
|
7692693aae | ||
|
|
e40dcb1324 | ||
|
|
826de74a5d | ||
|
|
f5fb78ec99 | ||
|
|
c606149c43 | ||
|
|
d4a8716a0e | ||
|
|
21bd921ee7 | ||
|
|
8b28b33da3 | ||
|
|
91dfd47f1d | ||
|
|
aad19b53c7 | ||
|
|
cea4cc5860 | ||
|
|
ca4014f751 | ||
|
|
2662e24991 | ||
|
|
2b8f5f791a | ||
|
|
df00d728ff | ||
|
|
d000a6c61a | ||
|
|
71ef03070e | ||
|
|
9fad028074 | ||
|
|
81ee7cbd5e | ||
|
|
250a4dcfb2 | ||
|
|
6e55017f96 | ||
|
|
a192cadced | ||
|
|
044e3865ac | ||
|
|
2ea7bc29c6 | ||
|
|
e0fe610d7b | ||
|
|
20198367da | ||
|
|
a3aba84ba8 | ||
|
|
4e9afc8096 | ||
|
|
f56efeb039 | ||
|
|
8b5f4e8556 | ||
|
|
7ef5b7b43a | ||
|
|
241a38e958 | ||
|
|
aae55e0282 | ||
|
|
31f1a30d6f | ||
|
|
275002e25d | ||
|
|
3add3f5b6c | ||
|
|
1f80b806bd | ||
|
|
609f1aabca | ||
|
|
d3eca26393 | ||
|
|
864c865e52 | ||
|
|
2c67b8d7ba | ||
|
|
6dbb69a828 | ||
|
|
3733a84a38 | ||
|
|
e37b2d8c59 | ||
|
|
2373a41699 | ||
|
|
2cbf2af6c7 | ||
|
|
fdcf074dfd | ||
|
|
de43ac52c4 | ||
|
|
f6aa93f094 | ||
|
|
5feb334191 | ||
|
|
3bd10004a7 | ||
|
|
02c405ad80 | ||
|
|
3e9f9f300c | ||
|
|
6ba4dc4617 | ||
|
|
2d0c4fecd8 | ||
|
|
8e107e5b24 | ||
|
|
2f551fea97 | ||
|
|
adfd980a60 | ||
|
|
f186d6bb33 | ||
|
|
180d647c70 | ||
|
|
3020812f71 | ||
|
|
ed9eea3e8b | ||
|
|
346170220e | ||
|
|
3a1f7938e8 | ||
|
|
9b222fb93d | ||
|
|
0f1b313d96 | ||
|
|
b675276f31 | ||
|
|
c014d09072 | ||
|
|
4dd508a628 | ||
|
|
e23a49395b | ||
|
|
80f9685bb9 | ||
|
|
7754f96af8 | ||
|
|
7d8523a786 | ||
|
|
e6baafdb8f | ||
|
|
c7666c0a67 | ||
|
|
8ff4b4be66 | ||
|
|
ffeb8b1d5f | ||
|
|
812135d1e5 | ||
|
|
3eb58c511b | ||
|
|
0df6ea57ae | ||
|
|
f0ec46fd11 | ||
|
|
add25ae68e | ||
|
|
7f2c39ef94 | ||
|
|
94b1d2815c | ||
|
|
1da26cac98 | ||
|
|
a4a2c8b886 | ||
|
|
68c7f45be1 | ||
|
|
dbf3d5809b | ||
|
|
bcf3bf5586 | ||
|
|
edff915f9e | ||
|
|
9342cc8c8a | ||
|
|
db13724131 | ||
|
|
380cab6ca3 | ||
|
|
bd4bf489dc | ||
|
|
29ae1bdefd | ||
|
|
efd6580b26 | ||
|
|
f08512eaa9 | ||
|
|
e28b2dd00e | ||
|
|
3d1736e0d3 | ||
|
|
2fb5c5187a | ||
|
|
ff2c0b63e2 | ||
|
|
c42832f1c5 | ||
|
|
770e04c04a | ||
|
|
54d1e4112a | ||
|
|
89fffd1518 | ||
|
|
1fbf276474 | ||
|
|
8783615ebc | ||
|
|
f546dc3ac8 | ||
|
|
f2d6c59bab | ||
|
|
448a81dff8 | ||
|
|
fb92b76a1a | ||
|
|
c0e17c44ba | ||
|
|
6f55e1d389 | ||
|
|
a7b883c922 | ||
|
|
6bd6108619 | ||
|
|
aee0c2c731 | ||
|
|
b5e76f42a5 | ||
|
|
35f05f6fe1 | ||
|
|
cf2f576ec0 | ||
|
|
92a1d8c7e3 | ||
|
|
cc418d3519 | ||
|
|
9c8ac2a489 | ||
|
|
6c16395dfb | ||
|
|
40d4fb0d75 | ||
|
|
b9fec2ce41 | ||
|
|
593f247abb | ||
|
|
add63ac25b | ||
|
|
1ef8cc5048 | ||
|
|
c5836e0c9e | ||
|
|
922aa702a2 | ||
|
|
89c581816e | ||
|
|
3d670b9c03 | ||
|
|
5bf644867f | ||
|
|
fcdae73f49 | ||
|
|
1656fb9f57 | ||
|
|
594327924c | ||
|
|
480800edbd | ||
|
|
9b01d1b1ab | ||
|
|
ed810bc380 | ||
|
|
789db9a017 | ||
|
|
52626cc86d | ||
|
|
32b97ef076 | ||
|
|
8e319321c9 | ||
|
|
cdac6d385c | ||
|
|
d65d011e5e | ||
|
|
28f771324d | ||
|
|
cf04fbd6ea | ||
|
|
bb511f0df2 | ||
|
|
f564fb0977 | ||
|
|
3b017dfdf6 | ||
|
|
cb7d00b839 | ||
|
|
010e18ea0b | ||
|
|
c34f96ce2e | ||
|
|
f9d50018d9 | ||
|
|
6c1233fcec | ||
|
|
acd8e92d24 | ||
|
|
559d370a89 | ||
|
|
4cf51b7005 | ||
|
|
4e77492797 | ||
|
|
f16e2e3ee5 | ||
|
|
ebc8c387e0 | ||
|
|
734484714c | ||
|
|
adac39bd7c | ||
|
|
9d5011e864 | ||
|
|
ca40ba1fda | ||
|
|
894455f2cd | ||
|
|
6cb73062a9 | ||
|
|
86c68d191c | ||
|
|
d20c252cb3 | ||
|
|
cd6b688f7f | ||
|
|
49f41d8edb | ||
|
|
b45953dff8 | ||
|
|
8075d7e51a | ||
|
|
ab0170ffaf | ||
|
|
449cacdacf | ||
|
|
e724c2df00 | ||
|
|
12dc180065 | ||
|
|
8eae978eae | ||
|
|
019d53bca1 | ||
|
|
e1d7dc3dea | ||
|
|
28a2826fa8 | ||
|
|
c76864a653 | ||
|
|
c0d44232a5 | ||
|
|
e34e92eb44 | ||
|
|
956f73d997 | ||
|
|
df04d26c15 | ||
|
|
9c69553373 | ||
|
|
6604293ac1 | ||
|
|
60a61adbce | ||
|
|
b56ab47f84 | ||
|
|
25492a089b | ||
|
|
e87be593c0 | ||
|
|
1937363fc8 | ||
|
|
d68cbec25c | ||
|
|
f4011e5d8c | ||
|
|
e93c96a6f8 | ||
|
|
070f67e9c6 | ||
|
|
61dac3b02b | ||
|
|
169157ec6d | ||
|
|
bd304c9fc3 | ||
|
|
3989aaa144 | ||
|
|
1d51a5f543 | ||
|
|
e4dafad64d | ||
|
|
3f31c60f1d | ||
|
|
83f48dcd15 | ||
|
|
1cc53245c5 | ||
|
|
f24ba048af | ||
|
|
a980db6d2d | ||
|
|
4277284a69 | ||
|
|
b070994912 | ||
|
|
988df54e53 | ||
|
|
4bbf053896 | ||
|
|
da79f1da3e | ||
|
|
c3ad87ffaf | ||
|
|
035acebf59 | ||
|
|
0b665676d7 | ||
|
|
dcf62b020f | ||
|
|
77aecc9e86 | ||
|
|
e35ad0f340 | ||
|
|
709e70561f | ||
|
|
ef0dc26de5 | ||
|
|
35d36d95c1 | ||
|
|
13cda09c7e | ||
|
|
5688030104 | ||
|
|
02228b76cc | ||
|
|
4d7cc3d67e | ||
|
|
ad8700e996 | ||
|
|
fbc90bdb84 | ||
|
|
f4c49f03f4 | ||
|
|
a87f13f9bb | ||
|
|
7d03a71fed | ||
|
|
70d33445db | ||
|
|
4dbcf45555 | ||
|
|
87acee924e | ||
|
|
a22cf82688 | ||
|
|
532cfdff95 | ||
|
|
5807b41997 | ||
|
|
c78d323989 | ||
|
|
9792b9b46a | ||
|
|
ca1c524ede | ||
|
|
a1822a8e08 | ||
|
|
066cdbc697 | ||
|
|
b9c3902bd8 | ||
|
|
03613e2f8e | ||
|
|
de7eecb166 | ||
|
|
417d98d7fa | ||
|
|
6dfbe854a0 | ||
|
|
a9bc119b82 | ||
|
|
42d3d0de6c | ||
|
|
ddb5ba8b64 | ||
|
|
185d5b1c62 | ||
|
|
f7ff08edf7 | ||
|
|
f4e0c12cba | ||
|
|
8ed599a769 | ||
|
|
aeb62e75bd | ||
|
|
5c7ee1effc | ||
|
|
01c67ab793 | ||
|
|
97c9bfe93f | ||
|
|
6e6769ea16 | ||
|
|
1e417aef90 | ||
|
|
60eb826cb0 | ||
|
|
3d3c7277c7 | ||
|
|
5be0b5d5ae | ||
|
|
11e5918690 | ||
|
|
c207e7ee30 | ||
|
|
6474a83221 | ||
|
|
9b15071f23 | ||
|
|
51b379d78b | ||
|
|
cac5daca01 | ||
|
|
4bc66c4ca3 | ||
|
|
eafb1a848f | ||
|
|
328f27e7bd | ||
|
|
a93e6c050d | ||
|
|
337b49f386 | ||
|
|
92daa7d11a | ||
|
|
fa48c86195 | ||
|
|
b88192f95a | ||
|
|
5a1ae934b9 | ||
|
|
58f18181c2 | ||
|
|
eea1bc0cac | ||
|
|
e37d5d9b25 | ||
|
|
5cc77709ba | ||
|
|
5280b9fd6d | ||
|
|
9cb1ced010 | ||
|
|
0a3057e92d | ||
|
|
699d6d78dc | ||
|
|
942ba52f56 | ||
|
|
d77b6df851 | ||
|
|
3662c1d428 | ||
|
|
e909ef3e62 | ||
|
|
9a393c8603 | ||
|
|
ea429e077a | ||
|
|
90f9b00e5e | ||
|
|
f06f3e3394 | ||
|
|
88af46fbe3 | ||
|
|
27450fae66 | ||
|
|
da6318f5a2 | ||
|
|
3151aa2ebc | ||
|
|
e1ad5cb1ae | ||
|
|
ead346816b | ||
|
|
b504473790 | ||
|
|
8073c84809 | ||
|
|
d28dee41a1 |
@@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
db
|
||||
dist
|
||||
.cabal-sandbox
|
||||
@@ -5,3 +6,6 @@ cabal.sandbox.config
|
||||
hscope.out
|
||||
codex.tags
|
||||
.anvil
|
||||
.stack-work
|
||||
tags
|
||||
site
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [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
|
||||
- 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
|
||||
|
||||
### Fixed
|
||||
- Filter columns on embedded parent items - @ruslantalpa
|
||||
|
||||
## [0.3.0.0] - 2015-11-24
|
||||
|
||||
### Fixed
|
||||
- Use reasonable amount of memory during bulk inserts - @begriffs
|
||||
|
||||
### Added
|
||||
- Ensure JWT expires - @calebmer
|
||||
- Postgres connection string argument - @calebmer
|
||||
- Encode JWT for procs that return type `jwt_claims` - @diogob
|
||||
- Full text operators `@>`,`<@` - @ruslantalpa
|
||||
- Shaping of the response body (filter columns, embed relations) with &select parameter for POST/PATCH - @ruslantalpa
|
||||
- Detect relationships between public views and private tables - @calebmer
|
||||
- `Prefer: plurality=singular` for selecting single objects - @calebmer
|
||||
|
||||
### Removed
|
||||
- API versioning feature - @calebmer
|
||||
- `--db-x` command line arguments - @calebmer
|
||||
- Secure flag - @calebmer
|
||||
- PUT request handling - @ruslantalpa
|
||||
|
||||
### Changed
|
||||
- Embed foreign keys with {} rather than () - @begriffs
|
||||
- Remove version number from binary filename in release - @begriffs
|
||||
|
||||
## [0.2.12.1] - 2015-11-12
|
||||
|
||||
### Fixed
|
||||
- Correct order for -> and ->> in a json path - @ruslantalpa
|
||||
- Return empty array instead of 500 when a set returning function returns an empty result set - @diogob
|
||||
|
||||
## [0.2.12.0] - 2015-10-25
|
||||
|
||||
### Added
|
||||
- Embed associations, e.g. `/film?select=*,director(*)` - @ruslantalpa
|
||||
- Filter columns, e.g. `?select=col1,col2` - @ruslantalpa
|
||||
- Does not execute the count total if header "Prefer: count=none" - @diogob
|
||||
|
||||
### Fixed
|
||||
- Tolerate a missing role in user creation - @calebmer
|
||||
- Avoid unnecessary text re-encoding - @ruslantalpa
|
||||
|
||||
## [0.2.11.1] - 2015-09-01
|
||||
|
||||
### Fixed
|
||||
- Accepts `*/*` in Accept header - @diogob
|
||||
|
||||
## [0.2.11.0] - 2015-08-28
|
||||
### Added
|
||||
- Negate any filter in a uniform way, e.g. `?col=not.eq=foo` - @diogob
|
||||
- Call stored procedures
|
||||
- Filter NOT IN values, e.g. `?col=notin.1,2,3` - @rall
|
||||
- CSV responses to GET requests with `Accept: text/csv` - @diogob
|
||||
- Debian init scripts - @mkhon
|
||||
- Allow filters by computed columns - @diogob
|
||||
|
||||
### Fixed
|
||||
- Reset user role on error
|
||||
- Compatible with Stack
|
||||
- Add materialized views to results in GET / - @diogob
|
||||
- Indicate insertable=true for views that are insertable through triggers - @diogob
|
||||
- Builds under GHC 7.10
|
||||
- Allow the use of columns named "count" in relations queried - @diogob
|
||||
|
||||
## [0.2.10.0] - 2015-06-03
|
||||
### Added
|
||||
- Full text search, eg `/foo?text_vector=@@.bar`
|
||||
- Include auth id as well as db role to views (for row-level security)
|
||||
|
||||
## [0.2.9.1] - 2015-05-20
|
||||
### Fixed
|
||||
- Put -Werror behind a cabal flag (for CI) so Hackage accepts package
|
||||
|
||||
## [0.2.9.0] - 2015-05-20
|
||||
### Added
|
||||
- Return range headers in PATCH
|
||||
- Return PATCHed resources if header "Prefer: return=representation"
|
||||
- Allow nested objects and arrays in JSON post for jsonb columns
|
||||
- JSON Web Tokens - [Federico Rampazzo](https://github.com/framp)
|
||||
- Expose PostgREST as a Haskell package
|
||||
|
||||
### Fixed
|
||||
- Return 404 if no records updated by PATCH
|
||||
|
||||
## [0.2.8.0] - 2015-04-17
|
||||
### Added
|
||||
- Option to specify nulls first or last, eg `/people?order=age.desc.nullsfirst`
|
||||
- Filter nulls, `?col=is.null` and `?col=isnot.null`
|
||||
- Filter within jsonb, `?col->a->>b=eq.c`
|
||||
- Accept CSV in post body for bulk inserts
|
||||
|
||||
### Fixed
|
||||
- Allow NULL values in posts
|
||||
- Show full command line usage on param errors
|
||||
|
||||
## [0.2.7.0] - 2015-03-03
|
||||
### Added
|
||||
- Server response logging
|
||||
- Filter IN values, e.g. `?col=in.1,2,3`
|
||||
- Return POSTed resource if header "Prefer: return=representation"
|
||||
- Allow override of default (v1) schema
|
||||
|
||||
## [0.2.6.0] - 2015-02-18
|
||||
### Added
|
||||
- A changelog
|
||||
- Filter by substring match, e.g. `?col=like.*hello*` (or ilike for
|
||||
case insensitivity).
|
||||
- Access-Control-Expose-Headers for CORS
|
||||
|
||||
### Fixed
|
||||
- Make filter position match docs, e.g. `?order=col.asc` rather
|
||||
than `?order=asc.col`.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Contributing to PostgREST
|
||||
|
||||
**First:** if you're unsure or afraid of _anything_, just ask or
|
||||
submit the issue or pull request anyways. You won't be yelled at
|
||||
for giving your best effort. The worst that can happen is that
|
||||
you'll be politely asked to change something. We appreciate any
|
||||
sort of contributions, and don't want a wall of rules to get in the
|
||||
way of that.
|
||||
|
||||
However, for those individuals who want a bit more guidance on the
|
||||
best way to contribute to the project, read on. This document will
|
||||
cover what we're looking for. By addressing all the points we're
|
||||
looking for, it raises the chances we can quickly merge or address
|
||||
your contributions.
|
||||
|
||||
## Issues
|
||||
|
||||
### Reporting an Issue
|
||||
|
||||
* Make sure you test against the latest released version. It is possible
|
||||
we already fixed the bug you're experiencing.
|
||||
|
||||
* Also check the `CHANGELOG.md` to see if any unreleased changes affect
|
||||
the issue. The very newest changes can take a little while to be released
|
||||
as a new official version.
|
||||
|
||||
* Provide steps to reproduce the issue, including your OS version and
|
||||
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).
|
||||
|
||||
## Code
|
||||
|
||||
### Haskell Conventions
|
||||
|
||||
* All contributions must pass the tests before being merged. When
|
||||
you create a pull request your code will automatically be tested.
|
||||
|
||||
* All code must also pass [hlint](http://community.haskell.org/~ndm/hlint/)
|
||||
with no warnings. This helps enforce a uniform style for all
|
||||
committers. Continuous integration will check this as well on every
|
||||
pull request.
|
||||
|
||||
* For help building the Haskell code on your computer check out the [building from
|
||||
source](https://github.com/begriffs/postgrest/wiki/Building-from-source)
|
||||
wiki page.
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Schedule
|
||||
|
||||
Currently I (@begriffs) am the sole maintainer, and while I am
|
||||
overjoyed to help resolve issues I also have to balance this with
|
||||
my other obligations. If you don't get a response right away
|
||||
don't worry, I will definitely get to it. Also you can join the
|
||||
Gitter [chat room](https://gitter.im/begriffs/postgrest) to
|
||||
discuss issues you are having.
|
||||
@@ -1,12 +1,17 @@
|
||||

|
||||
|
||||
[](https://circleci.com/gh/begriffs/postgrest/tree/master)
|
||||
[](https://circleci.com/gh/begriffs/postgrest/tree/master)
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/begriffs/postgrest">
|
||||
<img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy">
|
||||
</a>
|
||||
[](https://gitter.im/begriffs/postgrest)
|
||||
|
||||
PostgREST serves a fully RESTful API from any existing PostgreSQL
|
||||
database. It provides a cleaner, more standards-compliant, faster
|
||||
API than you are likely to write from scratch.
|
||||
|
||||
### Demo [postgrest.herokuapp.com](https://postgrest.herokuapp.com) | Watch [Video](https://begriffs.com/posts/2014-12-30-intro-to-postgrest.html)
|
||||
### 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 with an HTTP client
|
||||
such as [postman](http://www.getpostman.com/). The structure of the
|
||||
@@ -15,28 +20,39 @@ demo database is defined by
|
||||
You can use it as inspiration for test-driven server migrations in
|
||||
your own projects.
|
||||
|
||||
Also try other tools in the PostgREST
|
||||
[ecosystem](http://postgrest.com/install/ecosystem/) like the
|
||||
[ng-admin demo](http://marmelab.com/ng-admin-postgrest).
|
||||
|
||||
### Usage
|
||||
|
||||
Download the binary ([OS X](http://bin.begriffs.com/dbapi/osx/postgrest-0.2.5.0.tar.xz) / [Ubuntu](http://bin.begriffs.com/dbapi/heroku/postgrest-0.2.5.0.tar.xz)) and invoke like so:
|
||||
1. Download the binary ([latest release](https://github.com/begriffs/postgrest/releases/latest))
|
||||
for your platform.
|
||||
2. Invoke like so:
|
||||
|
||||
```bash
|
||||
postgrest --db-host localhost --db-port 5432 \
|
||||
--db-name my_db --db-user postgres \
|
||||
--db-pass foobar --db-pool 200 \
|
||||
--anonymous postgres --secure \
|
||||
--port 3000
|
||||
```
|
||||
```bash
|
||||
postgrest postgres://postgres:foobar@localhost:5432/my_db \
|
||||
--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
|
||||
|
||||
TLDR; subsecond response times for up to 2000 requests/sec on Heroku free tier. ([see the load test](https://github.com/begriffs/postgrest/wiki/Performance-and-Scaling))
|
||||
TLDR; subsecond response times for up to 2000 requests/sec on Heroku
|
||||
free tier. ([see the load
|
||||
test](http://postgrest.com/admin/performance/#benchmarks))
|
||||
|
||||
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
|
||||
in [Haskell](https://new-www.haskell.org/) using the
|
||||
in [Haskell](https://www.haskell.org/) using the
|
||||
[Warp](http://www.yesodweb.com/blog/2011/03/preliminary-warp-cross-language-benchmarks)
|
||||
HTTP server (aka a compiled language with lightweight threads).
|
||||
Next it delegates as much calculation as possible to the database
|
||||
@@ -54,58 +70,64 @@ by
|
||||
|
||||
* Reusing prepared statements
|
||||
* Keeping a pool of db connections
|
||||
* Using the Postgres binary protocol
|
||||
* Using the PostgreSQL binary protocol
|
||||
* 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](https://github.com/begriffs/postgrest/wiki/Performance-and-Scaling).
|
||||
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
|
||||
[Future Features](#future-features).
|
||||
|
||||
### Security
|
||||
|
||||
PostgREST handles authentication (HTTP Basic over SSL) and delegates
|
||||
authorization to the role information defined in the database. This
|
||||
ensures there is a single declarative source of truth for security.
|
||||
When dealing with the database the server assumes the identity of
|
||||
the currently authenticated user, and for the duration of the
|
||||
connection cannot do anything the user themselves couldn't.
|
||||
PostgREST handles authentication (via [JSON Web
|
||||
Tokens](http://postgrest.com/admin/security/#json-web-tokens))
|
||||
and delegates authorization to the role information defined in the
|
||||
database. This ensures there is a single declarative source of truth
|
||||
for security. When dealing with the database the server assumes
|
||||
the identity of the currently authenticated user, and for the
|
||||
duration of the connection cannot do anything the user themselves
|
||||
couldn't. Other forms of authentication can be built on top
|
||||
of the JWT primitive. See the docs for more information.
|
||||
|
||||
Postgres 9.5 will soon support true [row-level
|
||||
security](http://michael.otacoo.com/postgresql-2/postgres-9-5-feature-highlight-row-level-security/).
|
||||
In the meantime what isn't yet implemented can be simulated with
|
||||
triggers and security-barrier views. Because the possible queries
|
||||
to the database are limited to certain templates using
|
||||
PostgreSQL 9.5 supports true [row-level
|
||||
security](http://www.postgresql.org/docs/9.5/static/ddl-rowsecurity.html).
|
||||
In previous versions it can be simulated with triggers and
|
||||
security-barrier views. Because the possible queries to the database
|
||||
are limited to certain templates using
|
||||
[leakproof](http://blog.2ndquadrant.com/how-do-postgresql-security_barrier-views-work/)
|
||||
functions, the trigger workaround does not compromise row-level
|
||||
security.
|
||||
|
||||
For example security patterns see the [security
|
||||
guide](https://github.com/begriffs/postgrest/wiki/Security-and-Permissions).
|
||||
guide](http://postgrest.com/admin/security/).
|
||||
|
||||
### Versioning
|
||||
|
||||
A robust long-lived API needs the freedom to exist in multiple
|
||||
versions. PostgREST supports versioning through HTTP content
|
||||
negotiation. Requests for a certain version translate into switching
|
||||
which database schema to search for tables. PostgreSQL schema search
|
||||
paths allow tables from earlier versions to be reused verbatim in
|
||||
later versions.
|
||||
|
||||
To learn more, see the [guide to versioning](https://github.com/begriffs/postgrest/wiki/API-Versioning).
|
||||
versions. 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. You run an instance of PostgREST per schema and route requests
|
||||
among them with a reverse proxy such as [nginx](http://nginx.org).
|
||||
Learn more [here](http://postgrest.com/admin/versioning/).
|
||||
|
||||
### Self-documention
|
||||
|
||||
Rather than writing and maintaining separate docs yourself let the
|
||||
API explain its own affordances using HTTP. All PostgREST endpoints
|
||||
respond to the OPTIONS verb and explain what they support as well
|
||||
as the data format of their JSON payload.
|
||||
as the data format of their JSON payload. RAML support is an upcoming
|
||||
feature.
|
||||
|
||||
The number of rows returned by an endpoint is reported by - and
|
||||
limited with - range headers. More about
|
||||
The project uses HTTP itself to commicate other metadata. For
|
||||
instance the number of rows returned by an endpoint is reported by -
|
||||
and limited with - range headers. More about
|
||||
[that](http://begriffs.com/posts/2014-03-06-beyond-http-header-links.html).
|
||||
|
||||
There are more opportunities for self-documentation listed in [Future
|
||||
@@ -121,9 +143,9 @@ data (including your API server).
|
||||
The PostgREST exposes HTTP interface with safeguards to prevent
|
||||
surprises, such as enforcing idempotent PUT requests, and
|
||||
|
||||
See examples of [Postgres
|
||||
See examples of [PostgreSQL
|
||||
constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)
|
||||
and the [guide to routing](https://github.com/begriffs/postgrest/wiki/Routing).
|
||||
and the [guide to routing](http://postgrest.com/api/reading/).
|
||||
|
||||
### Future Features
|
||||
|
||||
@@ -136,21 +158,14 @@ and the [guide to routing](https://github.com/begriffs/postgrest/wiki/Routing).
|
||||
* Describe more relationships with Link headers
|
||||
* Depending on accept headers, render OPTIONS as [RAML](http://raml.org/) or a
|
||||
relational diagram
|
||||
* Add two-legged auth with OAuth 1.0a(?)
|
||||
* ... the other [issues](https://github.com/begriffs/postgrest/issues)
|
||||
|
||||
### Guides
|
||||
|
||||
* [Routing](https://github.com/begriffs/postgrest/wiki/Routing)
|
||||
* [Versioning](https://github.com/begriffs/postgrest/wiki/API-Versioning)
|
||||
* [Performance](https://github.com/begriffs/postgrest/wiki/Performance-and-Scaling)
|
||||
* [Security](https://github.com/begriffs/postgrest/wiki/Security-and-Permissions)
|
||||
|
||||
### Thanks
|
||||
|
||||
* [Adam Baker](https://github.com/adambaker) for code
|
||||
contributions and many fundamental design discussions
|
||||
* [Nikita Volkov](https://github.com/nikita-volkov) for writing the
|
||||
wonderful [Hasql](https://github.com/nikita-volkov/hasql) library
|
||||
and helping me use it
|
||||
* [Mikey Casalaina](https://github.com/casalaina) for the cool logo
|
||||
I'm grateful to the generous project
|
||||
[contributors](https://github.com/begriffs/postgrest/graphs/contributors)
|
||||
who have improved PostgREST immensely with their code and good
|
||||
judgement. See more details in the
|
||||
[changelog](https://github.com/begriffs/postgrest/blob/master/CHANGELOG.md).
|
||||
|
||||
The cool logo came from [Mikey Casalaina](https://github.com/casalaina).
|
||||
|
||||
@@ -6,38 +6,49 @@
|
||||
"env": {
|
||||
"BUILDPACK_URL": {
|
||||
"description": "Heroku buildpack for deploying Haskell applications",
|
||||
"value": "https://github.com/mietek/haskell-on-heroku"
|
||||
"value": "https://github.com/begriffs/postgrest-heroku"
|
||||
},
|
||||
"POSTGREST_VER": {
|
||||
"description": "Version of PostgREST to deploy",
|
||||
"value": "0.3.0.2"
|
||||
},
|
||||
"DB_NAME": {
|
||||
"description": "Database name"
|
||||
"description": "Database name",
|
||||
"required": true
|
||||
},
|
||||
"DB_AUTH_ROLE": {
|
||||
"description": "Database role to use checking client authentication"
|
||||
"AUTH_ROLE": {
|
||||
"description": "Database role to use checking client authentication",
|
||||
"required": true
|
||||
},
|
||||
"DB_AUTH_PASS": {
|
||||
"AUTH_PASS": {
|
||||
"description": "Authentication password",
|
||||
"required": false
|
||||
},
|
||||
"DB_ANON_ROLE": {
|
||||
"description": "Database role for non-authenticated requests"
|
||||
"ANONYMOUS_ROLE": {
|
||||
"description": "Database role for non-authenticated requests",
|
||||
"required": true
|
||||
},
|
||||
"DB_HOST": {
|
||||
"description": "Database server hostname",
|
||||
"required": false,
|
||||
"value": "localhost"
|
||||
"required": true
|
||||
},
|
||||
"DB_PORT": {
|
||||
"description": "Database server port",
|
||||
"required": false,
|
||||
"value": "5432"
|
||||
},
|
||||
"DB_POOL_SIZE": {
|
||||
"DB_POOL": {
|
||||
"description": "Maximum number of connections in database pool",
|
||||
"required": false,
|
||||
"value": "10"
|
||||
},
|
||||
"DB_SECURE": {
|
||||
"description": "Redirect all requests to HTTPS",
|
||||
"JWT_SECRET": {
|
||||
"description": "Secret used to encrypt JSON Web Tokens",
|
||||
"required": false,
|
||||
"value": "secret"
|
||||
},
|
||||
"V1SCHEMA": {
|
||||
"description": "DB schema selected whe no version (or version 1) requested",
|
||||
"required": false,
|
||||
"value": "1"
|
||||
}
|
||||
|
||||
+11
-1
@@ -3,4 +3,14 @@ machine:
|
||||
- createuser --superuser --no-password postgrest_test
|
||||
- createdb -O postgrest_test -U ubuntu postgrest_test
|
||||
ghc:
|
||||
version: 7.8.3
|
||||
version: 7.10.1
|
||||
dependencies:
|
||||
override:
|
||||
- cabal update
|
||||
- cabal sandbox init
|
||||
- cabal install --upgrade-dependencies --constraint="template-haskell installed" --dependencies-only --enable-tests
|
||||
- cabal configure --enable-tests -f ci
|
||||
test:
|
||||
post:
|
||||
- cabal exec hlint -- -X QuasiQuotes src/**/*.hs test/**/*.hs
|
||||
- cabal exec packdeps postgrest.cabal || true
|
||||
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
# 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
@@ -0,0 +1,5 @@
|
||||
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
@@ -0,0 +1 @@
|
||||
9
|
||||
Vendored
+196
@@ -0,0 +1,196 @@
|
||||
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
@@ -0,0 +1,32 @@
|
||||
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
@@ -0,0 +1 @@
|
||||
dist-ghc/build/postgrest/postgrest usr/bin
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/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
@@ -0,0 +1,29 @@
|
||||
# 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
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
#!/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
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/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
@@ -0,0 +1 @@
|
||||
3.0 (quilt)
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
version=3
|
||||
http://hackage.haskell.org/package/postgrest/distro-monitor .*-([0-9\.]+)\.(?:zip|tgz|tbz|txz|(?:tar\.(?:gz|bz2|xz)))
|
||||
@@ -0,0 +1 @@
|
||||
postgrest.com
|
||||
@@ -0,0 +1,9 @@
|
||||
## Deployment
|
||||
|
||||
### Heroku
|
||||
|
||||
#### Getting Started
|
||||
|
||||
#### Using Amazon RDS
|
||||
|
||||
### Debian
|
||||
@@ -0,0 +1,9 @@
|
||||
## Data Migration
|
||||
|
||||
### Sqitch
|
||||
|
||||
### Test-Driven Migrations
|
||||
|
||||
#### Structural Tests
|
||||
|
||||
#### Value Tests with pgTAP
|
||||
@@ -0,0 +1,9 @@
|
||||
## Performance
|
||||
|
||||
### Benchmarks
|
||||
|
||||
### Caching
|
||||
|
||||
### Quality of Service
|
||||
|
||||
### Tips
|
||||
@@ -0,0 +1,82 @@
|
||||
## Security
|
||||
|
||||
PostgREST is designed to keep the database at the center of API
|
||||
security. All authorization happens through database roles and
|
||||
permissions. It is PostgREST's job to *authenticate* requests --
|
||||
i.e. verify that a client is who they say they are -- and then let
|
||||
the database *authorize* client actions.
|
||||
|
||||
We use [JSON Web Tokens](http://jwt.io/) to authenticate API requests.
|
||||
As you'll recall a JWT contains a list of cryptographically signed
|
||||
claims. PostgREST cares specifically about a claim called `role`.
|
||||
When request contains a valid JWT with a role claim PostgREST will
|
||||
switch to the database role with that name for the duration of the
|
||||
HTTP request. If the client included no (or an invalid) JWT then
|
||||
PostgREST selects the "anonymous role" which is specified by a
|
||||
command line arguments to the server on startup.
|
||||
|
||||
```js
|
||||
{
|
||||
"role": "jdoe123"
|
||||
}
|
||||
|
||||
// Encoded as JWT with a secret of "secret" this becomes
|
||||
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiamRvZTEyMyJ9.X_ZeWSS9qsKDCDczv8C-GE2fccrPQjOh_ALMZJa5jsU
|
||||
```
|
||||
|
||||
Using JWT allows us to authenticate with external services. A login
|
||||
service needs merely to share a JWT encryption secret with the
|
||||
PostgREST server. The secret is also a server command line option.
|
||||
|
||||
It is even possible to generate JWT from inside a stored procedure
|
||||
in your database. Any SQL stored procedure that returns a type whose
|
||||
name ends in `jwt_claims` will have its return value encoded into
|
||||
JWT. See the [User Management](http://postgrest.com/examples/users/)
|
||||
example for details.
|
||||
|
||||
### Database Roles
|
||||
|
||||
Suppose you start the server like this:
|
||||
|
||||
```bash
|
||||
postgrest postgres://foo@localhost:5432/mydb --anonymous anon
|
||||
```
|
||||
|
||||
This means that `foo` is the so-called *authenticator role* and
|
||||
`anon` is the anonymous role. When a new HTTP request arrives at the
|
||||
server the latter is connected to the database as user `foo`. If
|
||||
no JWT is present, or if it is invalid, or if it does not contain
|
||||
the role claim then the server changes to the anonymous role with
|
||||
the query
|
||||
|
||||
```sql
|
||||
SET LOCAL ROLE anon;
|
||||
```
|
||||
|
||||
Otherwise it sets the role to that specified by JWT. For security
|
||||
your authenticator role should have access to nothing except the
|
||||
ability to become other users. Supposing you have three roles, one
|
||||
for anonymous users, one for authors, and another for the authenticator,
|
||||
you would set it up like this
|
||||
|
||||
```sql
|
||||
CREATE ROLE authenticator NOINHERIT;
|
||||
CREATE ROLE anon;
|
||||
CREATE ROLE author;
|
||||
|
||||
GRANT anon, author TO authenticator;
|
||||
```
|
||||
|
||||
### Row-Level Security
|
||||
|
||||
#### Simulated - PostgreSQL <9.5
|
||||
|
||||
#### Real - PostgreSQL >=9.5
|
||||
|
||||
### Building Auth on top of JWT
|
||||
|
||||
#### Basic Auth
|
||||
|
||||
#### Github Sign-in
|
||||
|
||||
### SSL
|
||||
@@ -0,0 +1,9 @@
|
||||
## API Versioning
|
||||
|
||||
### Schema Search Path
|
||||
|
||||
### Changing a Resource
|
||||
|
||||
### Removing a Resource
|
||||
|
||||
### Avoiding DB and Client Coupling
|
||||
@@ -0,0 +1,339 @@
|
||||
## 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`, `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 (inspired by GraphQL) to embed
|
||||
related information. It can handle one-to-many and 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).
|
||||
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
|
||||
GET /people?order=age.desc.nullslast
|
||||
```
|
||||
|
||||
You can also use [computed
|
||||
columns](http://www.postgresql.org/docs/current/interactive/xfunc-sql.html#XFUNC-SQL-COMPOSITE-FUNCTIONS)
|
||||
to order the results, even though the computed
|
||||
columns will not appear in the output.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
With count suppressed the PostgREST response will look like:
|
||||
|
||||
```
|
||||
Range-Unit: items
|
||||
Content-Range → 0-14/*
|
||||
```
|
||||
|
||||
### Embedding Foreign Entities
|
||||
|
||||
To help you make fewer requests, PostgREST allows the embedding of
|
||||
traditional SQL relationships into a response. 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{*}
|
||||
```
|
||||
|
||||
In the examples above we asked for all columns in the embedded resource
|
||||
but the the select query is recursive. You could for instance specify
|
||||
|
||||
|
||||
```HTTP
|
||||
GET /foo?select=x, y, bar{z, w, baz{*}}
|
||||
```
|
||||
|
||||
You can select not only using table names, but also column names!
|
||||
To embed the same foreign key row from our client example earlier
|
||||
you could do the following:
|
||||
|
||||
```HTTP
|
||||
GET /projects?id=eq.1&select=id, name, client_id{*}
|
||||
```
|
||||
|
||||
In the response there will be a `client_id` object containing all
|
||||
the data for that row.
|
||||
|
||||
However, a `client_id` object doesn't make a lot of sense, so you
|
||||
could do one of two things. Create a view which renames `client_id`
|
||||
to just `client` (this is the hard way), or just try `client{*}`
|
||||
in the select parameter! PostgREST supports smart ducktype checking
|
||||
for common foreign key names, so if your column name ends with
|
||||
`_id`, `_fk`, or any variation of the two (including camelcase)
|
||||
you can embed a row with just the name's beginning.
|
||||
|
||||
So for a complete example:
|
||||
|
||||
```HTTP
|
||||
GET /projects?id=eq.1&select=id, name, client{*}
|
||||
```
|
||||
|
||||
Would embed in the `client` key the row referenced with `client_id`.
|
||||
|
||||
### 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. To request a singular response send the header
|
||||
`Prefer: plurality=singular`.
|
||||
|
||||
### 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.
|
||||
@@ -0,0 +1,123 @@
|
||||
## 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
|
||||
|
||||
You can POST a JSON array or CSV to insert multiple rows in a single
|
||||
HTTP request. Note that using CSV requires less parsing on the server
|
||||
and is **much faster**.
|
||||
|
||||
Example of CSV bulk insert. 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.
|
||||
|
||||
Example of JSON bulk insert. Send an array:
|
||||
|
||||
```HTTP
|
||||
POST /people
|
||||
[
|
||||
{ "name": "J Doe", "age": 62, "height": 70 },
|
||||
{ "name": "Janus", "age": 10, "height": 55 }
|
||||
]
|
||||
```
|
||||
|
||||
If you would like to get the full object back in the response to
|
||||
your request, include the header `Prefer: return=representation`.
|
||||
Chances are you only want certain information back, though, like
|
||||
created ids. You can pass a `select` parameter to affect the shape
|
||||
of the response (further documented in the [reading](/api/reading/)
|
||||
page). For instance
|
||||
|
||||
```HTTP
|
||||
POST /people?select=id
|
||||
[...]
|
||||
```
|
||||
returns something like
|
||||
```json
|
||||
[ { "id": 1 }, { "id": 2 } ]
|
||||
```
|
||||
|
||||
### 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=is.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>
|
||||
@@ -0,0 +1,163 @@
|
||||
## Multi-Tenant Blog
|
||||
|
||||
In our blog app there will be anonymous users and authors. Each
|
||||
author can create and edit their own posts, and read (but not edit)
|
||||
the posts of other authors. Anonymous users cannot edit anything
|
||||
but can sign up for author accounts. Authors can also post comments
|
||||
on articles.
|
||||
|
||||
This example builds off the previous one. We had previously created
|
||||
a signup and login system on top of JWT. We'll use this auth system
|
||||
for the blog. **Run the SQL in the previous example** first, before
|
||||
continuing with this example.
|
||||
|
||||
For your convenience, the complete sql for the blog demo is
|
||||
[here](https://github.com/begriffs/postgrest/blob/master/schema-templates/blog.sql).
|
||||
You can try it out in this [vagrant
|
||||
image](https://github.com/ruslantalpa/blogdemo) as well.
|
||||
|
||||
### Adding Blog-Specific Tables
|
||||
|
||||
Storing the posts and comments is this simple. The comments do not
|
||||
form a tree, they are linear under a post.
|
||||
|
||||
```sql
|
||||
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
|
||||
default basic_auth.current_email(),
|
||||
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
|
||||
default basic_auth.current_email(),
|
||||
post bigint not null references posts (id)
|
||||
on delete cascade on update cascade,
|
||||
created_at timestamptz not null default current_date
|
||||
);
|
||||
```
|
||||
|
||||
### Permissions
|
||||
|
||||
Basic table-level permissions. We'll add an the `authenticator`
|
||||
role which can't do anything itself other than switch into other
|
||||
roles as directed by JWT.
|
||||
|
||||
```sql
|
||||
create role anon;
|
||||
create role author;
|
||||
create role authenticator noinherit;
|
||||
grant anon, author to authenticator;
|
||||
|
||||
grant usage on schema public, basic_auth to anon, author;
|
||||
|
||||
-- anon can create new logins and can read comments/posts
|
||||
grant insert on table basic_auth.users, basic_auth.tokens to anon;
|
||||
grant select on table pg_authid, basic_auth.users, posts, comments to anon;
|
||||
grant execute on function
|
||||
login(text,text),
|
||||
request_password_reset(text),
|
||||
reset_password(text,uuid,text),
|
||||
signup(text, text)
|
||||
to anon;
|
||||
|
||||
-- authors can edit comments/posts
|
||||
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;
|
||||
```
|
||||
|
||||
To ensure that authors cannot edit each others' posts and comments
|
||||
we'll use [row-level
|
||||
security](http://www.postgresql.org/docs/9.5/static/ddl-rowsecurity.html).
|
||||
Note that it requires PostgreSQL 9.5 or later.
|
||||
|
||||
```sql
|
||||
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()
|
||||
);
|
||||
```
|
||||
|
||||
Finally we need to modify the `users` view from the previous example.
|
||||
This is because all authors share a single db role. We could have
|
||||
chosen to assign a new role for every author (all inheriting from
|
||||
`author`) but we choose to tell them apart by their email addresses.
|
||||
The addition below prevents authors from seeing each others' info
|
||||
in the `users` view.
|
||||
|
||||
|
||||
```diff
|
||||
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()
|
||||
+ );
|
||||
```
|
||||
|
||||
### Example client queries
|
||||
|
||||
* Top ten most recent posts
|
||||
|
||||
```HTTP
|
||||
GET /posts?order=created_at.desc
|
||||
Range: 0-9
|
||||
```
|
||||
|
||||
* Single post (randomly chose id=1) with its comments
|
||||
|
||||
```HTTP
|
||||
GET /posts?id=eq.1&select=*,comments{*}
|
||||
```
|
||||
|
||||
* Add a new post
|
||||
|
||||
```HTTP
|
||||
POST /posts
|
||||
Authorization: Bearer [JWT TOKEN]
|
||||
|
||||
{
|
||||
"title": "My first post",
|
||||
"body": "Meh, forgot what I wanted to say."
|
||||
}
|
||||
```
|
||||
|
||||
### Conclusion
|
||||
|
||||
Voilà, a blog API. Most of the code ended up being for defining
|
||||
security. Once you have set up an authentication system, the code
|
||||
to do application specific things like blog posts and comments is
|
||||
short. All the front-end routes and verbs are created automatically
|
||||
for you.
|
||||
@@ -0,0 +1,509 @@
|
||||
## 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 REFERENCES director (name)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
rating real NOT NULL DEFAULT 0,
|
||||
language text NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE festival
|
||||
(
|
||||
name text NOT NULL PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE competition
|
||||
(
|
||||
id serial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
festival text NOT NULL REFERENCES festival (name)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
year date NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE film_nomination
|
||||
(
|
||||
id serial PRIMARY KEY,
|
||||
competition integer NOT NULL REFERENCES competition (id)
|
||||
ON UPDATE NO ACTION ON DELETE NO ACTION,
|
||||
film integer NOT NULL REFERENCES film (id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
won boolean NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
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. (See the [installation instructions](/install/server/).)
|
||||
|
||||
```sh
|
||||
postgrest postgres://postgres:@localhost:5432/demo1 -a postgres --schema 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>
|
||||
|
||||
### Populating Data
|
||||
|
||||
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.
|
||||
|
||||
The server returns HTTP 201 Created. Because we inserted more than one item at once there is no `Location` header in the response. However sometimes you want to learn more about items which you just inserted. To have the server include the full restuls include the header `Prefer: return=representation`.
|
||||
|
||||
At this point 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
|
||||
```
|
||||
|
||||
### Getting and Embedding Data
|
||||
|
||||
First let's review which films are stored in the database:
|
||||
```http
|
||||
GET http://localhost:3000/film
|
||||
```
|
||||
It gives us back a list of JSON objects. What if we care only about the film titles? Use `select` to shape the output:
|
||||
|
||||
```http
|
||||
GET http://localhost:3000/film?select=title
|
||||
```
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "Chuang ru zhe"
|
||||
},
|
||||
{
|
||||
"title": "The Look of Silence"
|
||||
},
|
||||
{
|
||||
"title": "Fires on the Plain"
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
Here is where it gets cool. PostgREST can embed objects in its response through foreign key relationships. Earlier we created a join table called `film_nomination`. It joins films and competitions. We can ask the server about the structure of this table:
|
||||
|
||||
```
|
||||
OPTIONS http://localhost:3000/film_nomination
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"pkey": [
|
||||
"id"
|
||||
],
|
||||
"columns": [
|
||||
{
|
||||
"references": null,
|
||||
"default": "nextval('film_nomination_id_seq'::regclass)",
|
||||
"precision": 32,
|
||||
"updatable": true,
|
||||
"schema": "public",
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"maxLen": null,
|
||||
"enum": [],
|
||||
"nullable": false,
|
||||
"position": 1
|
||||
},
|
||||
{
|
||||
"references": {
|
||||
"schema": "public",
|
||||
"column": "id",
|
||||
"table": "competition"
|
||||
},
|
||||
"default": null,
|
||||
"precision": 32,
|
||||
"updatable": true,
|
||||
"schema": "public",
|
||||
"name": "competition",
|
||||
"type": "integer",
|
||||
"maxLen": null,
|
||||
"enum": [],
|
||||
"nullable": false,
|
||||
"position": 2
|
||||
},
|
||||
{
|
||||
"references": {
|
||||
"schema": "public",
|
||||
"column": "id",
|
||||
"table": "film"
|
||||
},
|
||||
"default": null,
|
||||
"precision": 32,
|
||||
"updatable": true,
|
||||
"schema": "public",
|
||||
"name": "film",
|
||||
"type": "integer",
|
||||
"maxLen": null,
|
||||
"enum": [],
|
||||
"nullable": false,
|
||||
"position": 3
|
||||
},
|
||||
{
|
||||
"references": null,
|
||||
"default": "true",
|
||||
"precision": null,
|
||||
"updatable": true,
|
||||
"schema": "public",
|
||||
"name": "won",
|
||||
"type": "boolean",
|
||||
"maxLen": null,
|
||||
"enum": [],
|
||||
"nullable": false,
|
||||
"position": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
From this you can see that the columns `film` and `competition` reference their eponymous tables. Let's ask the server for each film along with names of the competitions it entered. You don't have to do any custom coding. Send this query:
|
||||
|
||||
```http
|
||||
GET http://localhost:3000/film?select=title,competition{name}
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "Chuang ru zhe",
|
||||
"competition": [
|
||||
{
|
||||
"name": "Golden Lion"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "The Look of Silence",
|
||||
"competition": [
|
||||
{
|
||||
"name": "Golden Lion"
|
||||
}
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
The relation flows both ways. Here is how to get the name of each competition's name and the movies shown at it.
|
||||
|
||||
```http
|
||||
GET http://localhost:3000/competition?select=name,film{title}
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Golden Lion",
|
||||
"film": [
|
||||
{
|
||||
"title": "Chuang ru zhe"
|
||||
},
|
||||
{
|
||||
"title": "The Look of Silence"
|
||||
},
|
||||
...
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Palme d'Or",
|
||||
"film": [
|
||||
{
|
||||
"title": "The Wonders"
|
||||
},
|
||||
{
|
||||
"title": "Foxcatcher"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Why not learn about the directors too? There is a many-to-one relation directly between films and directors. We can alter our previous query to include directors in its results.
|
||||
|
||||
|
||||
```http
|
||||
GET http://localhost:3000/competition?select=name,film{title,director{*}}
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Golden Lion",
|
||||
"film": [
|
||||
{
|
||||
"title": "Manglehorn",
|
||||
"director": {
|
||||
"name": "David Gordon Green"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Belye nochi pochtalona Alekseya Tryapitsyna",
|
||||
"director": {
|
||||
"name": "Andrey Konchalovskiy"
|
||||
}
|
||||
},
|
||||
...
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
### Singular Responses
|
||||
|
||||
How do we ask for a single film, for instance the second one we inserted?
|
||||
|
||||
```http
|
||||
GET http://localhost:3000/film?id=eq.2
|
||||
```
|
||||
It returns
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 2,
|
||||
"title": "The Look of Silence",
|
||||
"year": "2014-01-01",
|
||||
"director": "Joshua Oppenheimer",
|
||||
"rating": 8.3,
|
||||
"language": "Indonesian"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Like any query, it gives us a result *set*, in this case an array with one element. However you and I know that `id` is a primary key, it will never return more than one result. We might want it returned as a JSON object, not an array. To express this preference include the header `Prefer: plurality=singular`. It will respond with
|
||||
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 2,
|
||||
"title": "The Look of Silence",
|
||||
"year": "2014-01-01",
|
||||
"director": "Joshua Oppenheimer",
|
||||
"rating": 8.3,
|
||||
"language": "Indonesian"
|
||||
}
|
||||
```
|
||||
|
||||
<div class="admonition note">
|
||||
<p class="admonition-title">Why this approach to singular responses?</p>
|
||||
|
||||
<p>
|
||||
PostgREST knows which columns comprise a primary key for a
|
||||
table, so why not automatically choose plurality=singular when
|
||||
these column filters are present? The fact is it could come as a
|
||||
shock to a client that by adding one more filter condition it can
|
||||
change the entire response format.
|
||||
</p>
|
||||
<p>
|
||||
Then why not expose another kind of route such as /film/2 to indicate
|
||||
one particular film? Because this does not accommodate compound keys.
|
||||
The convention complects a plurality preference with table key
|
||||
assumptions. We should separate concerns.
|
||||
</p>
|
||||
<p>
|
||||
It turns out you can still have routes like /film/2. Use a
|
||||
proxy such as Nginx. It can rewrite routes such as /films/2
|
||||
into /films?id=eq.2 and add the Prefer header to make the results
|
||||
singular.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
### Conclusion
|
||||
|
||||
This tutorial showed how to create a database with a basic schema, run PostgREST, and interact with the API. The next tutorial will show how to enable security for a multi-tenant blogging API.
|
||||
@@ -0,0 +1,482 @@
|
||||
## User Management
|
||||
|
||||
API clients authenticate with [JSON Web Tokens](http://jwt.io).
|
||||
PostgREST does not support any other authentication mechanism
|
||||
directly, but they can be built on top. In this demo we will build
|
||||
a username and password system on top of JWT using only plpgsql.
|
||||
|
||||
Future examples such as the multi-tenant blogging platform will use
|
||||
the results from this example for their auth. We will build a system
|
||||
for users to sign up, log in, manage their accounts, and for admins
|
||||
to manange other people's accounts. We will also see how to trigger
|
||||
outside events like sending password reset emails.
|
||||
|
||||
Before jumping into the code, a little more about how the tokens
|
||||
work. Every JWT contains cryptographically signed *claims*. PostgREST
|
||||
cares specificaly about a claim called `role`. When a client includes
|
||||
a `role` claim PostgREST executes their request using that database
|
||||
role.
|
||||
|
||||
How would a client include a role claim, or claims in general?
|
||||
Without knowing the server JWT secret a client cannot create a
|
||||
claim. The only place to get a JWT is from the PostgREST server or
|
||||
from another service sharing the secret and acting on its behalf.
|
||||
We'll use a stored procedure returning type `jwt_claims` which is
|
||||
a special type causing the server to encrypt and sign the return
|
||||
value.
|
||||
|
||||
### Storing Users and Passwords
|
||||
|
||||
We create a database schema especially for auth information. We'll
|
||||
also need the postgres extensions
|
||||
[pgcrypto](http://www.postgresql.org/docs/current/static/pgcrypto.html) and
|
||||
[uuid-ossp](http://www.postgresql.org/docs/current/static/uuid-ossp.html).
|
||||
|
||||
```sql
|
||||
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;
|
||||
```
|
||||
|
||||
Next a table to store the mapping from usernames and passwords to
|
||||
database roles. The code below includes triggers and functions to
|
||||
encrypt the password and ensure the role exists.
|
||||
|
||||
```sql
|
||||
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();
|
||||
```
|
||||
|
||||
With the table in place we can make a helper to check passwords.
|
||||
It returns the database role for a user if the email and password
|
||||
are correct.
|
||||
|
||||
```sql
|
||||
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;
|
||||
$$;
|
||||
```
|
||||
|
||||
### Password Reset
|
||||
|
||||
When a user requests a password reset or signs up we create a token
|
||||
they will use later to prove their identity. The tokens go in this
|
||||
table.
|
||||
|
||||
```sql
|
||||
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
|
||||
);
|
||||
```
|
||||
|
||||
In the main schema (as opposed to the `basic_auth` schema) we expose
|
||||
a password reset request function. HTTP clients will call it. The
|
||||
function takes the email address of the user.
|
||||
|
||||
```sql
|
||||
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;
|
||||
$$;
|
||||
```
|
||||
|
||||
This function does not send any emails. It sends a postgres
|
||||
[NOTIFY](http://www.postgresql.org/docs/current/static/sql-notify.html)
|
||||
command. External programs such as a mailer listen for this event
|
||||
and do the work. The most robust way to process these signals is
|
||||
by pushing them onto work queues. Here are two programs to do that:
|
||||
|
||||
1. [aweber/pgsql-listen-exchange](https://github.com/aweber/pgsql-listen-exchange) for RabbitMQ
|
||||
2. [SpiderOak/skeeter](https://github.com/SpiderOak/skeeter) for ZeroMQ
|
||||
|
||||
For experimentation you don't need that though. Here's a sample
|
||||
Node program that listens for the events and logs them to stdout.
|
||||
|
||||
```js
|
||||
var PS = require('pg-pubsub');
|
||||
|
||||
if(process.argv.length !== 3) {
|
||||
console.log("USAGE: DB_URL");
|
||||
process.exit(2);
|
||||
}
|
||||
var url = process.argv[2],
|
||||
ps = new PS(url);
|
||||
|
||||
// password reset request events
|
||||
ps.addChannel('reset', console.log);
|
||||
// email validation required event
|
||||
ps.addChannel('validate', console.log);
|
||||
|
||||
// modify me to send emails
|
||||
```
|
||||
|
||||
Once the user has a reset token they can use it as an argument to
|
||||
the password reset function, calling it through the PostgREST RPC
|
||||
interface.
|
||||
|
||||
```sql
|
||||
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;
|
||||
$$;
|
||||
```
|
||||
|
||||
### Email Validation
|
||||
|
||||
This is similar to password resets. Once again we generate a token.
|
||||
It differs in that there is a trigger to send validations when a
|
||||
new login is added to the users table.
|
||||
|
||||
```sql
|
||||
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();
|
||||
```
|
||||
|
||||
### Editing Own User
|
||||
|
||||
We'll construct a redacted view for users. It hides passwords and
|
||||
shows only those users whose roles the currently logged in user has
|
||||
db permission to access.
|
||||
|
||||
```sql
|
||||
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;
|
||||
-- can also add restriction that current_setting('postgrest.claims.email')
|
||||
-- is equal to email so that user can only see themselves
|
||||
```
|
||||
|
||||
Using this view clients can see themeslves and any other users with
|
||||
the right db roles. This view does not yet support inserts or updates
|
||||
because not all the columns refer directly to underlying columns.
|
||||
Nor do we want it to be auto-updatable because it would allow an escalation
|
||||
of privileges. Someone could update their own row and change their
|
||||
role to become more powerful.
|
||||
|
||||
We'll handle updates with a trigger, but we'll need a helper function
|
||||
to prevent an escalation of privileges.
|
||||
|
||||
```sql
|
||||
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;
|
||||
```
|
||||
|
||||
With the above function we can now make a safe trigger to allow
|
||||
user updates.
|
||||
|
||||
```sql
|
||||
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
|
||||
(new.role, 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 have been 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();
|
||||
```
|
||||
|
||||
Finally add a public function people can use to sign up. You can
|
||||
hard code a default db role in it. It alters the underlying
|
||||
`basic_auth.users` so you can set whatever role you want without
|
||||
restriction.
|
||||
|
||||
```sql
|
||||
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, 'hardcoded-role-here');
|
||||
$$ language sql;
|
||||
```
|
||||
|
||||
### Generating JWT
|
||||
|
||||
As mentioned at the start, clients authenticate with JWT. PostgREST
|
||||
has a special convention to allow your sql functions to return JWT.
|
||||
Any function that returns a type whose name ends in `jwt_claims` will
|
||||
have its return value encoded. For instance, let's make a login function
|
||||
which consults our users table.
|
||||
|
||||
First create a return type:
|
||||
|
||||
```sql
|
||||
drop type if exists basic_auth.jwt_claims cascade;
|
||||
create type basic_auth.jwt_claims AS (role text, email text);
|
||||
```
|
||||
|
||||
And now the function:
|
||||
|
||||
```sql
|
||||
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;
|
||||
$$;
|
||||
```
|
||||
|
||||
An API request to login would look like this.
|
||||
|
||||
```HTTP
|
||||
POST /rpc/login
|
||||
|
||||
{ "email": "foo@bar.com", "pass": "foobar" }
|
||||
```
|
||||
|
||||
Response
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImZvb0BiYXIuY29tIiwicm9sZSI6ImF1dGhvciJ9.KHwYdK9dAMAg-MGCQXuDiFuvbmW-y8FjfYIcMrETnto"
|
||||
}
|
||||
```
|
||||
|
||||
Try decoding the token at [jwt.io](http://jwt.io/). (It was encoded
|
||||
with a secret of `secret` which is the default.) To use this token
|
||||
in a future API request include it in an `Authorization` request
|
||||
header.
|
||||
|
||||
```HTTP
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImZvb0BiYXIuY29tIiwicm9sZSI6ImF1dGhvciJ9.KHwYdK9dAMAg-MGCQXuDiFuvbmW-y8FjfYIcMrETnto
|
||||
```
|
||||
|
||||
### Same-Role Users
|
||||
|
||||
You may not want a separate db role for every user. You can distinguish
|
||||
one user from another in SQL by examining the JWT claims which
|
||||
PostgREST makes available in the SQL variable `postgrest.claims`.
|
||||
Here's a function to get the email of the currently authenticated
|
||||
user.
|
||||
|
||||
```sql
|
||||
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;
|
||||
$$;
|
||||
```
|
||||
|
||||
Remember that the `login` function set the claims `email` and `role`.
|
||||
You can modify `login` to set other claims as well if they are
|
||||
useful for your other SQL functions to reference later.
|
||||
|
||||
### Conclusion
|
||||
|
||||
This section explained the implementation details for building a
|
||||
password based authentication system in pure sql. The next example
|
||||
will put it to work in a multi-tenant blogging API.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,91 @@
|
||||
<style>
|
||||
.videoWrapper {
|
||||
position: relative;
|
||||
padding-bottom: 56.25%; /* 16:9 */
|
||||
padding-top: 25px;
|
||||
height: 0;
|
||||
}
|
||||
.videoWrapper iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
### Intro Video
|
||||
|
||||
Some things have changed since this video was created but the basics are the same. Learn the big vision behind automating APIs.
|
||||
|
||||
<div class="videoWrapper">
|
||||
<iframe src="https://player.vimeo.com/video/115668217" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
### 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>
|
||||
@@ -0,0 +1,24 @@
|
||||
## 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
|
||||
|
||||
* [ruslantalpa/blogdemo](https://github.com/ruslantalpa/blogdemo) - blog api demo in a vagrant image
|
||||
* [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/)
|
||||
@@ -0,0 +1,145 @@
|
||||
## Installation
|
||||
|
||||
### Installing from Pre-Built Release
|
||||
|
||||
The [release page](https://github.com/begriffs/postgrest/releases/latest)
|
||||
has precompiled binaries for Mac OS X, Windows, and several Linux
|
||||
distros. 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 architecture.
|
||||
It would be nice to set up an automated build matrix for various
|
||||
architectures. It should support Mac, Windows and 32- and 64-bit
|
||||
versions of
|
||||
|
||||
<ul><li>Scientific Linux 6</li><li>CentOS</li><li>RHEL 6</li></ul></p>
|
||||
</div>
|
||||
|
||||
### 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](http://docs.haskellstack.org/en/stable/README.html#how-to-install) for your platform
|
||||
```bash
|
||||
#ubuntu example
|
||||
#See the link above for other operating systems
|
||||
|
||||
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
|
||||
|
||||
If you want to run the test suite, stack can do that too: `stack test`.
|
||||
|
||||
### Running the Server
|
||||
|
||||
```bash
|
||||
postgrest postgres://user:pass@host:port/db [flags]
|
||||
```
|
||||
|
||||
The user in the connection string is the "authenticator role," i.e.
|
||||
a role which is used temporarily to switch into other roles depending
|
||||
on the authentication request JWT. For simple API's you can use the
|
||||
same role for authenticator and anonymous.
|
||||
|
||||
The possible flags are:
|
||||
|
||||
<dl>
|
||||
<dt>-p, --port</dt>
|
||||
<dd>The port on which the server will listen for HTTP requests.
|
||||
Defaults to 3000.</dd>
|
||||
|
||||
<dt>-a, --anonymous</dt>
|
||||
<dd>The database role used to execute commands for those requests
|
||||
which provide no JWT authorization.</dd>
|
||||
|
||||
<dt>-s, --schema</dt>
|
||||
<dd>The db schema which you want to expose as an API. For historical
|
||||
reasons it defaults to <code>1</code>, but you're more likely
|
||||
to want to choose a value of <code>public</code>.</dd>
|
||||
|
||||
<dt>-j, --jwt-secret</dt>
|
||||
<dd>The secret passphrase used to encrypt JWT tokens. Defaults to
|
||||
<code>secret</code> but do not use the default in production!
|
||||
Load-balanced PostgREST servers should share the same secret.</dd>
|
||||
|
||||
<dt>-p, --pool</dt>
|
||||
<dd>Max connections to use in db pool. Defaults to to 10, but you
|
||||
should find an optimal value for your db by running the SQL
|
||||
command <code>show max_connections;</code></dd>
|
||||
|
||||
<dt>-m, --max-rows</dt>
|
||||
<dd>Max number of rows to return in a read request. The default is
|
||||
no limit.</dd>
|
||||
</dl>
|
||||
|
||||
<div class="admonition note">
|
||||
<p class="admonition-title">Hiding Password from Process List</p>
|
||||
|
||||
<p>Passing the database password and JWT secret as naked
|
||||
parameters might not be a good idea because the parameters are
|
||||
visible in a <code>ps</code> listing. One solution is to set
|
||||
environment variables such as PASS and use <code>$PASS</code>
|
||||
in the connection string. Another is to use a user-specific
|
||||
<a
|
||||
href="http://www.postgresql.org/docs/current/static/libpq-pgpass.html">.pgpass</a>
|
||||
file.</p>
|
||||
</div>
|
||||
|
||||
### 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 up to 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 (PostgreSQL version 9.3 or greater is required). 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)
|
||||
* [Installer for Windows](http://www.enterprisedb.com/products-services-training/pgdownload#windows)
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
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
|
||||
- User Management: examples/users.md
|
||||
- Multi-Tenant Blog: examples/blog.md
|
||||
+177
-66
@@ -1,89 +1,200 @@
|
||||
name: postgrest
|
||||
version: 0.2.5.0
|
||||
synopsis: The database is your api
|
||||
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
||||
for the tables and views, supporting all HTTP verbs that security
|
||||
permits.
|
||||
version: 0.3.0.2
|
||||
synopsis: REST API for any Postgres database
|
||||
license: MIT
|
||||
license-file: LICENSE
|
||||
author: Joe Nelson, Adam Baker
|
||||
homepage: https://github.com/begriffs/postgrest
|
||||
maintainer: cred+github@begriffs.com
|
||||
category: Web
|
||||
build-type: Simple
|
||||
cabal-version: >=1.10
|
||||
source-repository head
|
||||
type: git
|
||||
location: git://github.com/begriffs/postgrest.git
|
||||
|
||||
Flag CI
|
||||
Description: No warnings allowed in continuous integration
|
||||
Manual: True
|
||||
Default: False
|
||||
|
||||
executable postgrest
|
||||
main-is: Main.hs
|
||||
ghc-options: -Wall -W -Werror -O2
|
||||
if flag(ci)
|
||||
ghc-options: -Wall -W -Werror
|
||||
else
|
||||
ghc-options: -Wall -W -O2
|
||||
|
||||
main-is: PostgREST/Main.hs
|
||||
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
|
||||
default-language: Haskell2010
|
||||
default-extensions: OverloadedStrings
|
||||
other-extensions: QuasiQuotes
|
||||
build-depends: base >=4.6 && <5
|
||||
, hasql == 0.4.*, hasql-backend
|
||||
, hasql-postgres == 0.8.*
|
||||
, warp >= 3.0.2, wai >= 3.0.1
|
||||
, wai-extra, wai-cors
|
||||
, wai-middleware-static >= 0.6.0
|
||||
, HTTP, convertible, http-types
|
||||
build-depends: aeson >= 0.8
|
||||
, base >= 4.8 && < 5
|
||||
, bytestring
|
||||
, case-insensitive
|
||||
, scientific, time
|
||||
, aeson, network >= 2.6
|
||||
, bytestring, text, split, string-conversions
|
||||
, stringsearch, parsec
|
||||
, containers, unordered-containers
|
||||
, optparse-applicative >= 0.9.1 && < 0.10
|
||||
, regex-base, regex-tdfa
|
||||
, regex-tdfa-text
|
||||
, Ranged-sets
|
||||
, cassava
|
||||
, containers
|
||||
, errors
|
||||
, hasql >= 0.7.3 && < 0.8
|
||||
, hasql-backend >= 0.4.1 && < 0.5
|
||||
, hasql-postgres >= 0.10.4 && < 0.11
|
||||
, jwt
|
||||
, optparse-applicative >= 0.11 && < 0.13
|
||||
, parsec
|
||||
, postgrest
|
||||
, regex-tdfa
|
||||
, safe >= 0.3 && < 0.4
|
||||
, scientific
|
||||
, string-conversions
|
||||
, text
|
||||
, time
|
||||
, transformers
|
||||
, bcrypt, base64-string
|
||||
, network-uri >= 2.6
|
||||
, resource-pool
|
||||
, blaze-builder
|
||||
, unordered-containers
|
||||
, vector
|
||||
, mtl
|
||||
Other-Modules: App
|
||||
, Auth
|
||||
, Config
|
||||
, PgStructure
|
||||
, PgQuery
|
||||
, PgError
|
||||
, RangeQuery
|
||||
, Middleware
|
||||
, wai >= 3.0.1
|
||||
, wai-cors
|
||||
, wai-extra
|
||||
, wai-middleware-static >= 0.6.0
|
||||
, warp >= 3.0.2
|
||||
, HTTP, http-types
|
||||
, MissingH
|
||||
, Ranged-sets
|
||||
if !os(windows)
|
||||
build-depends: unix >= 2.7 && < 3
|
||||
|
||||
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
|
||||
if flag(ci)
|
||||
ghc-options: -Wall -W -Werror
|
||||
else
|
||||
ghc-options: -Wall -W -O2
|
||||
|
||||
default-language: Haskell2010
|
||||
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
|
||||
build-depends: aeson
|
||||
, base >=4.6 && <5
|
||||
, bytestring
|
||||
, case-insensitive
|
||||
, cassava
|
||||
, containers
|
||||
, errors
|
||||
, hasql
|
||||
, hasql-backend
|
||||
, hasql-postgres
|
||||
, http-types
|
||||
, jwt
|
||||
, optparse-applicative
|
||||
, parsec
|
||||
, regex-tdfa
|
||||
, safe
|
||||
, scientific
|
||||
, string-conversions
|
||||
, text
|
||||
, time
|
||||
, unordered-containers
|
||||
, vector
|
||||
, wai
|
||||
, wai-cors
|
||||
, wai-extra
|
||||
, wai-middleware-static
|
||||
, HTTP
|
||||
, MissingH
|
||||
, Ranged-sets
|
||||
|
||||
Other-Modules: Paths_postgrest
|
||||
Exposed-Modules: PostgREST.App
|
||||
, PostgREST.Auth
|
||||
, PostgREST.Config
|
||||
, PostgREST.Error
|
||||
, PostgREST.Middleware
|
||||
, PostgREST.Parsers
|
||||
, PostgREST.DbStructure
|
||||
, PostgREST.QueryBuilder
|
||||
, PostgREST.RangeQuery
|
||||
, PostgREST.ApiRequest
|
||||
, PostgREST.Types
|
||||
hs-source-dirs: src
|
||||
|
||||
Test-Suite spec
|
||||
Type: exitcode-stdio-1.0
|
||||
Default-Language: Haskell2010
|
||||
default-extensions: OverloadedStrings
|
||||
other-extensions: QuasiQuotes
|
||||
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
|
||||
Hs-Source-Dirs: test, src
|
||||
ghc-options: -Wall -W -Werror
|
||||
if flag(ci)
|
||||
ghc-options: -Wall -W -Werror
|
||||
else
|
||||
ghc-options: -Wall -W -O2
|
||||
Main-Is: Main.hs
|
||||
Other-Modules: App, Auth, Config, Spec, SpecHelper
|
||||
Build-Depends: base, hspec >= 2.1.2, QuickCheck
|
||||
, hspec-wai >= 0.5.0, hspec-wai-json
|
||||
, hasql == 0.4.*, hasql-backend
|
||||
, hasql-postgres == 0.8.*
|
||||
, warp >= 3.0.2, wai >= 3.0.1
|
||||
, HTTP, convertible
|
||||
, case-insensitive
|
||||
, wai-extra, wai-cors, containers
|
||||
, wai-middleware-static >= 0.6.0
|
||||
, http-types, scientific, time
|
||||
, bytestring, aeson, network >= 2.6
|
||||
, text, optparse-applicative
|
||||
, stringsearch, parsec
|
||||
, unordered-containers
|
||||
, regex-base
|
||||
, string-conversions
|
||||
, http-media, regex-tdfa
|
||||
, regex-tdfa-text
|
||||
, Ranged-sets
|
||||
, transformers
|
||||
, bcrypt
|
||||
Other-Modules: Feature.AuthSpec
|
||||
, Feature.CorsSpec
|
||||
, Feature.DeleteSpec
|
||||
, Feature.InsertSpec
|
||||
, Feature.QuerySpec
|
||||
, Feature.RangeSpec
|
||||
, Feature.StructureSpec
|
||||
, Paths_postgrest
|
||||
, PostgREST.App
|
||||
, PostgREST.Auth
|
||||
, PostgREST.Config
|
||||
, PostgREST.Error
|
||||
, PostgREST.Middleware
|
||||
, PostgREST.Parsers
|
||||
, PostgREST.DbStructure
|
||||
, PostgREST.QueryBuilder
|
||||
, PostgREST.RangeQuery
|
||||
, PostgREST.ApiRequest
|
||||
, PostgREST.Types
|
||||
, SpecHelper
|
||||
, TestTypes
|
||||
Build-Depends: aeson
|
||||
, base
|
||||
, base64-string
|
||||
, split
|
||||
, network-uri >= 2.6
|
||||
, resource-pool
|
||||
, blaze-builder
|
||||
, vector
|
||||
, mtl
|
||||
, bytestring
|
||||
, case-insensitive
|
||||
, cassava
|
||||
, containers
|
||||
, errors
|
||||
, hasql
|
||||
, hasql-backend
|
||||
, hasql-postgres
|
||||
, heredoc
|
||||
, hlint
|
||||
, hspec == 2.2.*
|
||||
, hspec-wai
|
||||
, hspec-wai-json
|
||||
, http-types
|
||||
, jwt
|
||||
, optparse-applicative
|
||||
, packdeps
|
||||
, parsec
|
||||
, process
|
||||
, regex-tdfa
|
||||
, safe
|
||||
, scientific
|
||||
, string-conversions
|
||||
, text
|
||||
, time
|
||||
, unordered-containers
|
||||
, vector
|
||||
, wai
|
||||
, wai-cors
|
||||
, wai-extra
|
||||
, wai-middleware-static
|
||||
, HTTP
|
||||
, MissingH
|
||||
, Ranged-sets
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
-------------------------------------------------------------------------------
|
||||
-- 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;
|
||||
create role author;
|
||||
create role authenticator noinherit;
|
||||
grant anon, author to authenticator;
|
||||
|
||||
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
|
||||
default basic_auth.current_email(),
|
||||
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
|
||||
default basic_auth.current_email(),
|
||||
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 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
export POSTGREST_VER=`grep ^version /app/postgrest.cabal | sed -En 's/.*\s+([0-9\.]+)/\1/p'`
|
||||
|
||||
curl -L http://softlayer-ams.dl.sourceforge.net/project/s3tools/s3cmd/1.5.0-alpha1/s3cmd-1.5.0-alpha1.tar.gz | tar zx
|
||||
curl -L http://sourceforge.net/projects/s3tools/files/s3cmd/1.5.0-alpha1/s3cmd-1.5.0-alpha1.tar.gz | tar zx
|
||||
|
||||
cp /app/dist/build/postgrest/postgrest postgrest-${POSTGREST_VER}
|
||||
tar cJf postgrest-${POSTGREST_VER}.tar.xz postgrest-${POSTGREST_VER}
|
||||
|
||||
-266
@@ -1,266 +0,0 @@
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
module App (app, sqlError, isSqlError) where
|
||||
|
||||
import Control.Monad (join)
|
||||
import Control.Arrow ((***))
|
||||
import Control.Applicative
|
||||
|
||||
import Data.Text hiding (map)
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Text.Regex.TDFA ((=~))
|
||||
import Data.Ord (comparing)
|
||||
import Data.Ranged.Ranges (emptyRange)
|
||||
import Data.HashMap.Strict (keys, elems, filterWithKey, toList)
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.List (sortBy)
|
||||
import Data.Functor.Identity
|
||||
import qualified Data.Set as S
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
|
||||
import Network.HTTP.Types.Status
|
||||
import Network.HTTP.Types.Header
|
||||
import Network.HTTP.Types.URI (parseSimpleQuery)
|
||||
import Network.HTTP.Base (urlEncodeVars)
|
||||
import Network.Wai
|
||||
|
||||
import Data.Aeson
|
||||
import Data.Monoid
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as H
|
||||
|
||||
import Auth
|
||||
import PgQuery
|
||||
import RangeQuery
|
||||
import PgStructure
|
||||
import PgError
|
||||
import Text.Parsec hiding (Column)
|
||||
|
||||
app :: BL.ByteString -> Request -> H.Tx H.Postgres s Response
|
||||
app reqBody req =
|
||||
case (path, verb) of
|
||||
([], _) -> do
|
||||
body <- encode <$> tables (cs schema)
|
||||
return $ responseLBS status200 [jsonH] $ cs body
|
||||
|
||||
([table], "OPTIONS") -> do
|
||||
let t = QualifiedTable schema (cs table)
|
||||
cols <- columns t
|
||||
pkey <- map cs <$> primaryKeyColumns t
|
||||
return $ responseLBS status200 [jsonH, allOrigins]
|
||||
$ encode (TableOptions cols pkey)
|
||||
|
||||
([table], "GET") ->
|
||||
if range == Just emptyRange
|
||||
then return $ responseLBS status416 [] "HTTP Range error"
|
||||
else do
|
||||
let qt = QualifiedTable schema (cs table)
|
||||
let select = coerce $
|
||||
("select ",[],mempty) <>
|
||||
parentheticT (
|
||||
whereT qq $ countRows qt
|
||||
) <> commaq <> (
|
||||
asJsonWithCount
|
||||
. limitT range
|
||||
. orderT (orderParse qq)
|
||||
. whereT qq
|
||||
$ selectStar qt
|
||||
)
|
||||
row <- H.single select
|
||||
let (tableTotal, queryTotal, body) =
|
||||
fromMaybe (0, 0, Just "" :: Maybe Text) row
|
||||
from = fromMaybe 0 $ rangeOffset <$> range
|
||||
to = from+queryTotal-1
|
||||
contentRange = contentRangeH from to tableTotal
|
||||
status = rangeStatus from to tableTotal
|
||||
canonical = urlEncodeVars
|
||||
. sortBy (comparing fst)
|
||||
. map (join (***) cs)
|
||||
. parseSimpleQuery
|
||||
$ rawQueryString req
|
||||
return $ responseLBS status
|
||||
[jsonH, contentRange,
|
||||
("Content-Location",
|
||||
"/" <> cs table <>
|
||||
if Prelude.null canonical then "" else "?" <> cs canonical
|
||||
)
|
||||
] (cs $ fromMaybe "[]" body)
|
||||
|
||||
(["postgrest", "users"], "POST") -> do
|
||||
let user = decode reqBody :: Maybe AuthUser
|
||||
|
||||
case user of
|
||||
Nothing -> return $ responseLBS status400 [jsonH] $
|
||||
encode . object $ [("message", String "Failed to parse user.")]
|
||||
Just u -> do
|
||||
_ <- addUser (cs $ userId u)
|
||||
(cs $ userPass u) (cs $ userRole u)
|
||||
return $ responseLBS status201
|
||||
[ jsonH
|
||||
, (hLocation, "/postgrest/users?id=eq." <> cs (userId u))
|
||||
] ""
|
||||
|
||||
([table], "POST") ->
|
||||
handleJsonObj reqBody $ \obj -> do
|
||||
let qt = QualifiedTable schema (cs table)
|
||||
query = coerce $
|
||||
insertInto qt (map cs $ keys obj) (elems obj)
|
||||
row <- H.single query
|
||||
let (Identity insertedJson) = fromMaybe (Identity "{}" :: Identity Text) row
|
||||
Just inserted = decode (cs insertedJson) :: Maybe Object
|
||||
|
||||
primaryKeys <- map cs <$> primaryKeyColumns qt
|
||||
let primaries = if Prelude.null primaryKeys
|
||||
then inserted
|
||||
else filterWithKey (const . (`elem` primaryKeys)) inserted
|
||||
let params = urlEncodeVars
|
||||
$ map (\t -> (cs $ fst t, "eq." <> cs (unquoted $ snd t)))
|
||||
$ sortBy (comparing fst) $ toList primaries
|
||||
return $ responseLBS status201
|
||||
[ jsonH
|
||||
, (hLocation, "/" <> cs table <> "?" <> cs params)
|
||||
] ""
|
||||
|
||||
([table], "PUT") ->
|
||||
handleJsonObj reqBody $ \obj -> do
|
||||
let qt = QualifiedTable schema (cs table)
|
||||
primaryKeys <- primaryKeyColumns qt
|
||||
let specifiedKeys = map (cs . fst) qq
|
||||
if S.fromList primaryKeys /= S.fromList specifiedKeys
|
||||
then return $ responseLBS status405 []
|
||||
"You must speficy all and only primary keys as params"
|
||||
else do
|
||||
tableCols <- map (cs . colName) <$> columns qt
|
||||
let cols = map cs $ keys obj
|
||||
if S.fromList tableCols == S.fromList cols
|
||||
then do
|
||||
let vals = elems obj
|
||||
H.unit . coerce $ iffNotT
|
||||
(whereT qq $ update qt cols vals)
|
||||
(insertSelect qt cols vals)
|
||||
return $ responseLBS status204 [ jsonH ] ""
|
||||
|
||||
else return $ if Prelude.null tableCols
|
||||
then responseLBS status404 [] ""
|
||||
else responseLBS status400 []
|
||||
"You must specify all columns in PUT request"
|
||||
|
||||
([table], "PATCH") ->
|
||||
handleJsonObj reqBody $ \obj -> do
|
||||
let qt = QualifiedTable schema (cs table)
|
||||
H.unit
|
||||
$ coerce
|
||||
$ whereT qq
|
||||
$ update qt (map cs $ keys obj) (elems obj)
|
||||
return $ responseLBS status204 [ jsonH ] ""
|
||||
|
||||
([table], "DELETE") -> do
|
||||
let qt = QualifiedTable schema (cs table)
|
||||
let del = coerce $ countT
|
||||
. returningStarT
|
||||
. whereT qq
|
||||
$ deleteFrom qt
|
||||
row <- H.single del
|
||||
let (Identity deletedCount) = fromMaybe (Identity 0 :: Identity Int) row
|
||||
return $ if deletedCount == 0
|
||||
then responseLBS status404 [] ""
|
||||
else responseLBS status204 [("Content-Range", "*/"<> cs (show deletedCount))] ""
|
||||
|
||||
(_, _) ->
|
||||
return $ responseLBS status404 [] ""
|
||||
|
||||
where
|
||||
path = pathInfo req
|
||||
verb = requestMethod req
|
||||
qq = queryString req
|
||||
hdrs = requestHeaders req
|
||||
schema = requestedSchema hdrs
|
||||
range = rangeRequested hdrs
|
||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
||||
coerce (q, args, All b) = (q, args, b)
|
||||
|
||||
|
||||
isSqlError :: H.Error -> Maybe H.Error
|
||||
isSqlError = Just
|
||||
|
||||
sqlError :: H.Error -> Response
|
||||
sqlError err =
|
||||
let inside = case err of
|
||||
H.CantConnect _ ->
|
||||
"Message: \"Cannot connect to postgres server\""
|
||||
H.ConnectionLost t -> t
|
||||
H.ErroneousResult t -> t
|
||||
H.UnexpectedResult t -> t
|
||||
H.UnparsableTemplate t -> t
|
||||
H.UnparsableRow t -> t
|
||||
H.NotInTransaction -> "An operation which requires a"
|
||||
<> "database transaction was executed without one" in
|
||||
either
|
||||
(\hint ->
|
||||
responseLBS status500
|
||||
[(hContentType, "application/json")]
|
||||
(cs . encode . object $ [
|
||||
("message", String $
|
||||
"Failed to parse exception:" <> inside)
|
||||
, ("hint", String . cs . show $ hint)]))
|
||||
(\msg ->
|
||||
responseLBS (httpStatus msg)
|
||||
[(hContentType, "application/json")]
|
||||
(encode msg))
|
||||
(parse message "" inside)
|
||||
|
||||
|
||||
rangeStatus :: Int -> Int -> Int -> Status
|
||||
rangeStatus from to total
|
||||
| from > total = status416
|
||||
| (1 + to - from) < total = status206
|
||||
| otherwise = status200
|
||||
|
||||
contentRangeH :: Int -> Int -> Int -> Header
|
||||
contentRangeH from to total =
|
||||
("Content-Range",
|
||||
if total == 0 || from > total
|
||||
then "*/" <> cs (show total)
|
||||
else cs (show from) <> "-"
|
||||
<> cs (show to) <> "/"
|
||||
<> cs (show total)
|
||||
)
|
||||
|
||||
requestedSchema :: RequestHeaders -> Text
|
||||
requestedSchema hdrs =
|
||||
case verStr of
|
||||
Just [[_, ver]] -> ver
|
||||
_ -> "1"
|
||||
|
||||
where verRegex = "version[ ]*=[ ]*([0-9]+)" :: String
|
||||
accept = cs <$> lookup hAccept hdrs :: Maybe Text
|
||||
verStr = (=~ verRegex) <$> accept :: Maybe [[Text]]
|
||||
|
||||
jsonH :: Header
|
||||
jsonH = (hContentType, "application/json")
|
||||
|
||||
handleJsonObj :: BL.ByteString -> (Object -> H.Tx H.Postgres s Response)
|
||||
-> H.Tx H.Postgres s Response
|
||||
handleJsonObj reqBody handler = do
|
||||
let p = eitherDecode reqBody
|
||||
case p of
|
||||
Left err ->
|
||||
return $ responseLBS status400 [jsonH] jErr
|
||||
where
|
||||
jErr = encode . object $
|
||||
[("message", String $ "Failed to parse JSON payload. " <> cs err)]
|
||||
Right (Object o) -> handler o
|
||||
Right _ ->
|
||||
return $ responseLBS status400 [jsonH] jErr
|
||||
where
|
||||
jErr = encode . object $
|
||||
[("message", String "Expecting a JSON object")]
|
||||
|
||||
data TableOptions = TableOptions {
|
||||
tblOptcolumns :: [Column]
|
||||
, tblOptpkey :: [Text]
|
||||
}
|
||||
|
||||
instance ToJSON TableOptions where
|
||||
toJSON t = object [
|
||||
"columns" .= tblOptcolumns t
|
||||
, "pkey" .= tblOptpkey t ]
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
{-# LANGUAGE QuasiQuotes, ScopedTypeVariables, OverloadedStrings #-}
|
||||
module Auth where
|
||||
|
||||
import Data.Aeson
|
||||
import Control.Monad (mzero)
|
||||
import Control.Applicative ( (<*>), (<$>) )
|
||||
import Crypto.BCrypt
|
||||
import Data.Text
|
||||
import Data.Monoid
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as H
|
||||
import Data.String.Conversions (cs)
|
||||
import PgQuery (pgFmtLit)
|
||||
|
||||
import System.IO.Unsafe
|
||||
|
||||
data AuthUser = AuthUser {
|
||||
userId :: String
|
||||
, userPass :: String
|
||||
, userRole :: String
|
||||
} deriving (Show)
|
||||
|
||||
instance FromJSON AuthUser where
|
||||
parseJSON (Object v) = AuthUser <$>
|
||||
v .: "id" <*>
|
||||
v .: "pass" <*>
|
||||
v .: "role"
|
||||
parseJSON _ = mzero
|
||||
|
||||
instance ToJSON AuthUser where
|
||||
toJSON u = object [
|
||||
"id" .= userId u
|
||||
, "pass" .= userPass u
|
||||
, "role" .= userRole u ]
|
||||
|
||||
type DbRole = Text
|
||||
|
||||
data LoginAttempt =
|
||||
NoCredentials
|
||||
| MalformedAuth
|
||||
| LoginFailed
|
||||
| LoginSuccess DbRole
|
||||
deriving (Eq, Show)
|
||||
|
||||
checkPass :: Text -> Text -> Bool
|
||||
checkPass = (. cs) . validatePassword . cs
|
||||
|
||||
setRole :: Text -> H.Tx H.Postgres s ()
|
||||
setRole role = H.unit ("set role " <> cs (pgFmtLit role), [], True)
|
||||
|
||||
resetRole :: H.Tx H.Postgres s ()
|
||||
resetRole = H.unit [H.q|reset role|]
|
||||
|
||||
addUser :: Text -> Text -> Text -> H.Tx H.Postgres s ()
|
||||
addUser identity pass role = do
|
||||
let Just hashed = unsafePerformIO $ hashPasswordUsingPolicy fastBcryptHashingPolicy (cs pass)
|
||||
H.unit $
|
||||
[H.q|insert into postgrest.auth (id, pass, rolname) values (?, ?, ?)|]
|
||||
identity (cs hashed :: Text) role
|
||||
|
||||
signInRole :: Text -> Text -> H.Tx H.Postgres s LoginAttempt
|
||||
signInRole user pass = do
|
||||
u <- H.single $ [H.q|select pass, rolname from postgrest.auth where id = ?|] user
|
||||
return $ maybe LoginFailed (\r ->
|
||||
let (hashed, role) = r in
|
||||
if checkPass hashed pass
|
||||
then LoginSuccess role
|
||||
else LoginFailed
|
||||
) u
|
||||
@@ -1,54 +0,0 @@
|
||||
module Config where
|
||||
|
||||
import Network.Wai
|
||||
import Control.Applicative
|
||||
import Data.Text (strip)
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import Data.String.Conversions (cs)
|
||||
import Options.Applicative hiding (columns)
|
||||
import Network.Wai.Middleware.Cors (CorsResourcePolicy(..))
|
||||
|
||||
data AppConfig = AppConfig {
|
||||
configDbName :: String
|
||||
, configDbPort :: Int
|
||||
, configDbUser :: String
|
||||
, configDbPass :: String
|
||||
, configDbHost :: String
|
||||
|
||||
, configPort :: Int
|
||||
, configAnonRole :: String
|
||||
, configSecure :: Bool
|
||||
, configPool :: Int
|
||||
}
|
||||
|
||||
argParser :: Parser AppConfig
|
||||
argParser = AppConfig
|
||||
<$> strOption (long "db-name" <> short 'd' <> help "name of database")
|
||||
<*> option (long "db-port" <> short 'P' <> value 5432 <> help "postgres server port")
|
||||
<*> strOption (long "db-user" <> short 'U' <> help "postgres authenticator role")
|
||||
<*> strOption (long "db-pass" <> value "" <> help "password for authenticator role")
|
||||
<*> strOption (long "db-host" <> short 'h' <> value "localhost" <> help "postgres server hostname")
|
||||
|
||||
<*> option (long "port" <> short 'p' <> value 3000 <> help "port number on which to run HTTP server")
|
||||
<*> strOption (long "anonymous" <> short 'a' <> help "postgres role to use for non-authenticated requests")
|
||||
<*> switch (long "secure" <> short 's' <> help "Redirect all requests to HTTPS")
|
||||
<*> option (long "db-pool" <> value 10 <> help "Max connections in database pool")
|
||||
|
||||
defaultCorsPolicy :: CorsResourcePolicy
|
||||
defaultCorsPolicy = CorsResourcePolicy Nothing
|
||||
["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] ["Authorization"] Nothing
|
||||
(Just $ 60*60*24) False False True
|
||||
|
||||
corsPolicy :: Request -> Maybe CorsResourcePolicy
|
||||
corsPolicy req = case lookup "origin" headers of
|
||||
Just origin -> Just defaultCorsPolicy {
|
||||
corsOrigins = Just ([origin], True)
|
||||
, corsRequestHeaders = "Authentication":accHeaders
|
||||
}
|
||||
Nothing -> Nothing
|
||||
where
|
||||
headers = requestHeaders req
|
||||
accHeaders = case lookup "access-control-request-headers" headers of
|
||||
Just hdrs -> map (CI.mk . cs . strip . cs) $ BS.split ',' hdrs
|
||||
Nothing -> []
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
module Main where
|
||||
|
||||
import Paths_postgrest (version)
|
||||
|
||||
import App
|
||||
import Middleware
|
||||
|
||||
import Control.Monad (unless)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Control.Exception
|
||||
import Data.String.Conversions (cs)
|
||||
import Network.Wai (strictRequestBody)
|
||||
import Network.Wai.Middleware.Cors (cors)
|
||||
import Network.Wai.Handler.Warp hiding (Connection)
|
||||
import Network.Wai.Middleware.Gzip (gzip, def)
|
||||
import Network.Wai.Middleware.Static (staticPolicy, only)
|
||||
import Data.List (intercalate)
|
||||
import Data.Version (versionBranch)
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as H
|
||||
import Options.Applicative hiding (columns)
|
||||
|
||||
import Config (AppConfig(..), argParser, corsPolicy)
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
conf <- execParser (info (helper <*> argParser) describe)
|
||||
let port = configPort conf
|
||||
|
||||
unless (configSecure conf) $
|
||||
putStrLn "WARNING, running in insecure mode, auth will be in plaintext"
|
||||
Prelude.putStrLn $ "Listening on port " ++
|
||||
(show $ configPort conf :: String)
|
||||
|
||||
let pgSettings = H.ParamSettings (cs $ configDbHost conf)
|
||||
(fromIntegral $ configDbPort conf)
|
||||
(cs $ configDbUser conf)
|
||||
(cs $ configDbPass conf)
|
||||
(cs $ configDbName conf)
|
||||
|
||||
sessSettings <- maybe (fail "Improper session settings") return $
|
||||
H.sessionSettings (fromIntegral $ configPool conf) 30
|
||||
|
||||
let appSettings = setPort port
|
||||
. setServerName (cs $ "postgrest/" <> prettyVersion)
|
||||
$ defaultSettings
|
||||
middle =
|
||||
(if configSecure conf then redirectInsecure else id)
|
||||
. gzip def . cors corsPolicy
|
||||
. staticPolicy (only [("favicon.ico", "static/favicon.ico")])
|
||||
anonRole = cs $ configAnonRole conf
|
||||
currRole = cs $ configDbUser conf
|
||||
|
||||
H.session pgSettings sessSettings $ H.sessionUnlifter >>= \unlift ->
|
||||
liftIO $ runSettings appSettings $ middle $ \req respond -> do
|
||||
body <- strictRequestBody req
|
||||
respond =<< catchJust isSqlError
|
||||
(unlift $ H.tx Nothing
|
||||
$ authenticated currRole anonRole (app body) req)
|
||||
(return . sqlError)
|
||||
|
||||
where
|
||||
describe = progDesc "create a REST API to an existing Postgres database"
|
||||
prettyVersion = intercalate "." $ map show $ versionBranch version
|
||||
@@ -1,76 +0,0 @@
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
module Middleware where
|
||||
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Monoid (mconcat)
|
||||
import Data.Text
|
||||
-- import Data.Pool(withResource, Pool)
|
||||
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as H
|
||||
import Data.String.Conversions(cs)
|
||||
|
||||
import Network.HTTP.Types.Header (hLocation, hAuthorization)
|
||||
import Network.HTTP.Types (RequestHeaders)
|
||||
import Network.HTTP.Types.Status (status400, status401, status301)
|
||||
import Network.Wai (Application, requestHeaders, responseLBS, rawPathInfo,
|
||||
rawQueryString, isSecure, Request(..), Response)
|
||||
import Network.URI (URI(..), parseURI)
|
||||
|
||||
import Auth (LoginAttempt(..), signInRole, setRole, resetRole)
|
||||
import Codec.Binary.Base64.String (decode)
|
||||
|
||||
authenticated :: forall s. Text -> Text ->
|
||||
(Request -> H.Tx H.Postgres s Response) ->
|
||||
Request -> H.Tx H.Postgres s Response
|
||||
authenticated currentRole anon app req = do
|
||||
attempt <- httpRequesterRole (requestHeaders req)
|
||||
case attempt of
|
||||
MalformedAuth ->
|
||||
return $ responseLBS status400 [] "Malformed basic auth header"
|
||||
LoginFailed ->
|
||||
return $ responseLBS status401 [] "Invalid username or password"
|
||||
LoginSuccess role -> if role /= currentRole then runInRole role else app req
|
||||
NoCredentials -> if anon /= currentRole then runInRole anon else app req
|
||||
|
||||
where
|
||||
httpRequesterRole :: RequestHeaders -> H.Tx H.Postgres s LoginAttempt
|
||||
httpRequesterRole hdrs = do
|
||||
let auth = fromMaybe "" $ lookup hAuthorization hdrs
|
||||
case split (==' ') (cs auth) of
|
||||
("Basic" : b64 : _) ->
|
||||
case split (==':') (cs . decode . cs $ b64) of
|
||||
(u:p:_) -> signInRole u p
|
||||
_ -> return MalformedAuth
|
||||
_ -> return NoCredentials
|
||||
|
||||
runInRole :: Text -> H.Tx H.Postgres s Response
|
||||
runInRole r = do
|
||||
setRole r
|
||||
res <- app req
|
||||
resetRole
|
||||
return res
|
||||
|
||||
|
||||
redirectInsecure :: Application -> Application
|
||||
redirectInsecure app req respond = do
|
||||
let hdrs = requestHeaders req
|
||||
host = lookup "host" hdrs
|
||||
uriM = parseURI . cs =<< mconcat [
|
||||
Just "https://",
|
||||
host,
|
||||
Just $ rawPathInfo req,
|
||||
Just $ rawQueryString req]
|
||||
isHerokuSecure = lookup "x-forwarded-proto" hdrs == Just "https"
|
||||
|
||||
if not (isSecure req || isHerokuSecure)
|
||||
then case uriM of
|
||||
Just uri ->
|
||||
respond $ responseLBS status301 [
|
||||
(hLocation, cs . show $ uri { uriScheme = "https:" })
|
||||
] ""
|
||||
Nothing ->
|
||||
respond $ responseLBS status400 [] "SSL is required"
|
||||
else app req respond
|
||||
@@ -1,86 +0,0 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module PgError (Message(..), message, httpStatus) where
|
||||
|
||||
import Text.Parsec
|
||||
import Text.Parsec.Text
|
||||
import qualified Data.Map as M
|
||||
import Text.Regex.TDFA.Text ()
|
||||
import Data.Text hiding (drop, concat, head)
|
||||
import Data.Aeson
|
||||
import Data.Maybe
|
||||
import Control.Monad (void)
|
||||
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.CaseInsensitive (CI, mk)
|
||||
|
||||
import Network.HTTP.Types.Status
|
||||
|
||||
data Message = Message {
|
||||
msgStatus :: Maybe Text
|
||||
, msgCode :: Text
|
||||
, msgText :: Maybe Text
|
||||
, msgHint :: Maybe Text
|
||||
} deriving (Show, Eq)
|
||||
|
||||
message :: Parser Message
|
||||
message = do
|
||||
ps <- sepBy valPair (char ';')
|
||||
let m = M.fromList ps
|
||||
return $ Message
|
||||
(M.lookup "status" m)
|
||||
(fromMaybe "" $ M.lookup "code" m)
|
||||
(M.lookup "message" m)
|
||||
(M.lookup "hint" m)
|
||||
|
||||
valPair :: Parser (CI Text, Text)
|
||||
valPair = do
|
||||
_ <- spaces
|
||||
name <- many1 letter
|
||||
_ <- char ':'
|
||||
spaces
|
||||
_ <- many $ char '"'
|
||||
val <- manyTill anyChar $
|
||||
try
|
||||
(void $ many (char '"') >> (
|
||||
(void . lookAhead $ (char ';'))
|
||||
<|> ((optional $ char '.') >> eof)
|
||||
))
|
||||
return (mk (cs name), cs val)
|
||||
|
||||
|
||||
instance ToJSON Message where
|
||||
toJSON t = object [
|
||||
"message" .= msgText t
|
||||
, "code" .= msgCode t
|
||||
, "status" .= msgStatus t
|
||||
, "hint" .= msgHint t
|
||||
]
|
||||
|
||||
httpStatus :: Message -> Status
|
||||
httpStatus m =
|
||||
let code = cs $ msgCode m :: String in
|
||||
case code of
|
||||
'0' : '8' : _ -> status503 -- pg connection err
|
||||
'0' : '9' : _ -> status500 -- triggered action exception
|
||||
'0' : 'L' : _ -> status403 -- invalid grantor
|
||||
'0' : 'P' : _ -> status403 -- invalid role specification
|
||||
'2' : '5' : _ -> status500 -- invalid tx state
|
||||
'2' : '8' : _ -> status403 -- invalid auth specification
|
||||
'2' : 'D' : _ -> status500 -- invalid tx termination
|
||||
'3' : '8' : _ -> status500 -- external routine exception
|
||||
'3' : '9' : _ -> status500 -- external routine invocation
|
||||
'3' : 'B' : _ -> status500 -- savepoint exception
|
||||
'4' : '0' : _ -> status500 -- tx rollback
|
||||
'5' : '3' : _ -> status503 -- insufficient resources
|
||||
'5' : '4' : _ -> status413 -- too complex
|
||||
'5' : '5' : _ -> status500 -- obj not on prereq state
|
||||
'5' : '7' : _ -> status500 -- operator intervention
|
||||
'5' : '8' : _ -> status500 -- system error
|
||||
'F' : '0' : _ -> status500 -- conf file error
|
||||
'H' : 'V' : _ -> status500 -- foreign data wrapper error
|
||||
'P' : '0' : _ -> status500 -- PL/pgSQL Error
|
||||
'X' : 'X' : _ -> status500 -- internal Error
|
||||
"42P01" -> status404 -- undefined table
|
||||
"42501" -> status404 -- insufficient privilege
|
||||
_ -> status400
|
||||
-234
@@ -1,234 +0,0 @@
|
||||
{-# LANGUAGE TypeSynonymInstances, FlexibleInstances #-}
|
||||
module PgQuery where
|
||||
|
||||
import RangeQuery
|
||||
|
||||
import qualified Hasql.Postgres as H
|
||||
import qualified Hasql.Backend as H
|
||||
|
||||
import Data.Text hiding (map)
|
||||
import Text.Regex.TDFA ( (=~) )
|
||||
import Text.Regex.TDFA.Text ()
|
||||
import qualified Network.HTTP.Types.URI as Net
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import Data.Monoid
|
||||
import Data.Maybe (fromMaybe, mapMaybe)
|
||||
import Data.Functor ( (<$>) )
|
||||
import Control.Monad (join)
|
||||
import Data.String.Conversions (cs)
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.List as L
|
||||
import Data.Scientific (isInteger, formatScientific, FPFormat(..))
|
||||
|
||||
type DynamicSQL = (BS.ByteString, [H.StatementArgument H.Postgres], All)
|
||||
|
||||
type StatementT = DynamicSQL -> DynamicSQL
|
||||
|
||||
data QualifiedTable = QualifiedTable {
|
||||
qtSchema :: Text
|
||||
, qtName :: Text
|
||||
} deriving (Show)
|
||||
|
||||
data OrderTerm = OrderTerm {
|
||||
otTerm :: Text
|
||||
, otDirection :: BS.ByteString
|
||||
}
|
||||
|
||||
limitT :: Maybe NonnegRange -> StatementT
|
||||
limitT r q =
|
||||
q <> (" LIMIT " <> limit <> " OFFSET " <> offset <> " ", [], mempty)
|
||||
where
|
||||
limit = maybe "ALL" (cs . show) $ join $ rangeLimit <$> r
|
||||
offset = cs . show $ fromMaybe 0 $ rangeOffset <$> r
|
||||
|
||||
whereT :: Net.Query -> StatementT
|
||||
whereT params q =
|
||||
if L.null cols
|
||||
then q
|
||||
else q <> (" where ",[],mempty) <> conjunction
|
||||
where
|
||||
cols = [ col | col <- params, fst col `notElem` ["order"] ]
|
||||
conjunction = mconcat $ L.intersperse andq (map wherePred cols)
|
||||
|
||||
orderT :: [OrderTerm] -> StatementT
|
||||
orderT ts q =
|
||||
if L.null ts
|
||||
then q
|
||||
else q <> (" order by ",[],mempty) <> clause
|
||||
where
|
||||
clause = mconcat $ L.intersperse commaq (map queryTerm ts)
|
||||
queryTerm :: OrderTerm -> DynamicSQL
|
||||
queryTerm t = (" " <> cs (pgFmtIdent $ otTerm t) <> " "
|
||||
<> otDirection t <> " "
|
||||
, [], mempty)
|
||||
|
||||
parentheticT :: StatementT
|
||||
parentheticT (sql, params, pre) =
|
||||
(" (" <> sql <> ") ", params, pre)
|
||||
|
||||
iffNotT :: DynamicSQL -> StatementT
|
||||
iffNotT (aq, ap, apre) (bq, bp, bpre) =
|
||||
("WITH aaa AS (" <> aq <> " returning *) " <>
|
||||
bq <> " WHERE NOT EXISTS (SELECT * FROM aaa)"
|
||||
, ap ++ bp
|
||||
, All $ getAll apre && getAll bpre
|
||||
)
|
||||
|
||||
countT :: StatementT
|
||||
countT (sql, params, pre) =
|
||||
("WITH qqq AS (" <> sql <> ") SELECT count(1) FROM qqq"
|
||||
, params
|
||||
, pre)
|
||||
|
||||
countRows :: QualifiedTable -> DynamicSQL
|
||||
countRows t =
|
||||
("select count(1) from " <> fromQt t, [], mempty)
|
||||
|
||||
asJsonWithCount :: StatementT
|
||||
asJsonWithCount (sql, params, pre) = (
|
||||
"count(t), array_to_json(array_agg(row_to_json(t)))::character varying from (" <> sql <> ") t"
|
||||
, params, pre
|
||||
)
|
||||
|
||||
asJsonRow :: StatementT
|
||||
asJsonRow (sql, params, pre) = (
|
||||
"row_to_json(t) from (" <> sql <> ") t", params, pre
|
||||
)
|
||||
|
||||
selectStar :: QualifiedTable -> DynamicSQL
|
||||
selectStar t =
|
||||
("select * from " <> fromQt t, [], mempty)
|
||||
|
||||
returningStarT :: StatementT
|
||||
returningStarT (sql, params, pre) =
|
||||
(sql <> " RETURNING *", params, pre)
|
||||
|
||||
deleteFrom :: QualifiedTable -> DynamicSQL
|
||||
deleteFrom t =
|
||||
("delete from " <> fromQt t, [], mempty)
|
||||
|
||||
insertInto :: QualifiedTable -> [Text] -> [JSON.Value] -> DynamicSQL
|
||||
insertInto t [] _ =
|
||||
("insert into " <> fromQt t <> " default values returning *", [], mempty)
|
||||
insertInto t cols vals =
|
||||
("insert into " <> fromQt t <> " (" <>
|
||||
cs (intercalate ", " (map pgFmtIdent cols)) <>
|
||||
") values (" <>
|
||||
cs (
|
||||
intercalate ", " (map
|
||||
((<> "::unknown") . pgFmtLit . unquoted)
|
||||
vals)
|
||||
) <> ") returning row_to_json(" <> fromQt t <> ".*)"
|
||||
, []
|
||||
, mempty
|
||||
)
|
||||
|
||||
insertSelect :: QualifiedTable -> [Text] -> [JSON.Value] -> DynamicSQL
|
||||
insertSelect t [] _ =
|
||||
("insert into " <> fromQt t <> " default values returning *", [], mempty)
|
||||
insertSelect t cols vals =
|
||||
("insert into " <> fromQt t <> " (" <>
|
||||
cs (intercalate ", " (map pgFmtIdent cols)) <>
|
||||
") select " <>
|
||||
cs (
|
||||
intercalate ", " (map
|
||||
((<> "::unknown") . pgFmtLit . unquoted)
|
||||
vals)
|
||||
)
|
||||
, []
|
||||
, mempty
|
||||
)
|
||||
|
||||
update :: QualifiedTable -> [Text] -> [JSON.Value] -> DynamicSQL
|
||||
update t cols vals =
|
||||
("update " <> fromQt t <> " set (" <>
|
||||
cs (intercalate ", " (map pgFmtIdent cols)) <>
|
||||
") = (" <>
|
||||
cs (
|
||||
intercalate ", " (map
|
||||
((<> "::unknown") . pgFmtLit . unquoted)
|
||||
vals)
|
||||
) <> ")"
|
||||
, []
|
||||
, mempty
|
||||
)
|
||||
|
||||
wherePred :: Net.QueryItem -> DynamicSQL
|
||||
wherePred (col, predicate) =
|
||||
(" " <> cs (pgFmtIdent $ cs col) <> " " <> op <> " " <> cs (pgFmtLit value) <> "::unknown ", [], mempty)
|
||||
|
||||
where
|
||||
opCode:rest = split (=='.') $ cs $ fromMaybe "." predicate
|
||||
value = intercalate "." rest
|
||||
op = case opCode of
|
||||
"eq" -> "="
|
||||
"gt" -> ">"
|
||||
"lt" -> "<"
|
||||
"gte" -> ">="
|
||||
"lte" -> "<="
|
||||
"neq" -> "<>"
|
||||
_ -> "="
|
||||
|
||||
orderParse :: Net.Query -> [OrderTerm]
|
||||
orderParse q =
|
||||
mapMaybe orderParseTerm . split (==',') $ cs order
|
||||
where
|
||||
order = fromMaybe "" $ join (lookup "order" q)
|
||||
|
||||
orderParseTerm :: Text -> Maybe OrderTerm
|
||||
orderParseTerm s =
|
||||
case split (=='.') s of
|
||||
[d,c] ->
|
||||
if d `elem` ["asc", "desc"]
|
||||
then Just $ OrderTerm c $
|
||||
if d == "asc" then "asc" else "desc"
|
||||
else Nothing
|
||||
_ -> Nothing
|
||||
|
||||
commaq :: DynamicSQL
|
||||
commaq = (", ", [], mempty)
|
||||
|
||||
andq :: DynamicSQL
|
||||
andq = (" and ", [], mempty)
|
||||
|
||||
pgFmtIdent :: Text -> Text
|
||||
pgFmtIdent x =
|
||||
let escaped = replace "\"" "\"\"" (trimNullChars $ cs x) in
|
||||
if escaped =~ danger
|
||||
then "\"" <> escaped <> "\""
|
||||
else escaped
|
||||
|
||||
where danger = "^$|^[^a-z_]|[^a-z_0-9]" :: Text
|
||||
|
||||
pgFmtLit :: Text -> Text
|
||||
pgFmtLit x =
|
||||
let trimmed = trimNullChars x
|
||||
escaped = "'" <> replace "'" "''" trimmed <> "'"
|
||||
slashed = replace "\\" "\\\\" escaped in
|
||||
cs $ if escaped =~ ("\\\\" :: Text)
|
||||
then "E" <> slashed
|
||||
else slashed
|
||||
|
||||
trimNullChars :: Text -> Text
|
||||
trimNullChars = Data.Text.takeWhile (/= '\x0')
|
||||
|
||||
fromQt :: QualifiedTable -> BS.ByteString
|
||||
fromQt t = cs $ pgFmtIdent (qtSchema t) <> "." <> pgFmtIdent (qtName t)
|
||||
|
||||
unquoted :: JSON.Value -> Text
|
||||
unquoted (JSON.String t) = t
|
||||
unquoted (JSON.Number n) =
|
||||
cs $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
|
||||
unquoted (JSON.Bool b) = cs . show $ b
|
||||
unquoted _ = ""
|
||||
|
||||
pgParam :: JSON.Value -> H.StatementArgument H.Postgres
|
||||
pgParam (JSON.Number n) = H.renderValue
|
||||
(cs $ formatScientific Fixed
|
||||
(if isInteger n then Just 0 else Nothing) n :: Text)
|
||||
pgParam (JSON.String s) = H.renderValue s
|
||||
pgParam (JSON.Bool b) = H.renderValue $
|
||||
if b then "t" else "f" :: Text
|
||||
pgParam JSON.Null = H.renderValue (Nothing :: Maybe Text)
|
||||
pgParam (JSON.Object o) = H.renderValue $ JSON.encode o
|
||||
pgParam (JSON.Array a) = H.renderValue $ JSON.encode a
|
||||
@@ -1,188 +0,0 @@
|
||||
{-# LANGUAGE QuasiQuotes, OverloadedStrings, TypeSynonymInstances,
|
||||
MultiParamTypeClasses, ScopedTypeVariables #-}
|
||||
module PgStructure where
|
||||
|
||||
import PgQuery (QualifiedTable(..))
|
||||
import Data.Functor ( (<$>) )
|
||||
import Data.Text hiding (foldl, map, zipWith, concat)
|
||||
import Data.Aeson
|
||||
import Data.Functor.Identity
|
||||
import qualified Data.Vector as V
|
||||
import Data.String.Conversions (cs)
|
||||
|
||||
import Control.Applicative ( (<*>) )
|
||||
|
||||
import qualified Data.List as L
|
||||
import qualified Data.Map as Map
|
||||
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Backend as H
|
||||
import qualified Hasql.Postgres as H
|
||||
|
||||
foreignKeys :: QualifiedTable -> H.Tx H.Postgres s (Map.Map Text ForeignKey)
|
||||
foreignKeys table = do
|
||||
r :: [(Text, Text, Text)] <- H.list $ [H.q|
|
||||
select kcu.column_name, ccu.table_name AS foreign_table_name,
|
||||
ccu.column_name AS foreign_column_name
|
||||
from information_schema.table_constraints AS tc
|
||||
join information_schema.key_column_usage AS kcu
|
||||
on tc.constraint_name = kcu.constraint_name
|
||||
join information_schema.constraint_column_usage AS ccu
|
||||
on ccu.constraint_name = tc.constraint_name
|
||||
where constraint_type = 'FOREIGN KEY'
|
||||
and tc.table_name=? and tc.table_schema = ?
|
||||
order by kcu.column_name
|
||||
|] (qtName table) (qtSchema table)
|
||||
|
||||
return $ foldl addKey Map.empty r
|
||||
where
|
||||
addKey m (col, ftab, fcol) = Map.insert col (ForeignKey (cs ftab) (cs fcol)) m
|
||||
|
||||
|
||||
tables :: Text -> H.Tx H.Postgres s [Table]
|
||||
tables schema =
|
||||
H.list $ [H.q|
|
||||
select table_schema, table_name,
|
||||
is_insertable_into
|
||||
from information_schema.tables
|
||||
where table_schema = ?
|
||||
order by table_name
|
||||
|] schema
|
||||
|
||||
|
||||
columns :: QualifiedTable -> H.Tx H.Postgres s [Column]
|
||||
columns table = do
|
||||
cols <- H.list $ [H.q|
|
||||
select info.table_schema as schema, info.table_name as table_name,
|
||||
info.column_name as name, info.ordinal_position as position,
|
||||
info.is_nullable as nullable, info.data_type as col_type,
|
||||
info.is_updatable as updatable,
|
||||
info.character_maximum_length as max_len,
|
||||
info.numeric_precision as precision,
|
||||
info.column_default as default_value,
|
||||
array_to_string(enum_info.vals, ',') as enum
|
||||
from (
|
||||
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
|
||||
where table_schema = ? and table_name = ?
|
||||
) 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 position |] (qtSchema table) (qtName table)
|
||||
|
||||
fks <- foreignKeys table
|
||||
return $ map (\col -> col { colFK = Map.lookup (cs . colName $ col) fks }) cols
|
||||
|
||||
|
||||
primaryKeyColumns :: QualifiedTable -> H.Tx H.Postgres s [Text]
|
||||
primaryKeyColumns table = do
|
||||
r :: [Identity Text] <- H.list $ [H.q|
|
||||
select kc.column_name
|
||||
from
|
||||
information_schema.table_constraints tc,
|
||||
information_schema.key_column_usage kc
|
||||
where
|
||||
tc.constraint_type = 'PRIMARY KEY'
|
||||
and kc.table_name = tc.table_name and kc.table_schema = tc.table_schema
|
||||
and kc.constraint_name = tc.constraint_name
|
||||
and kc.table_schema = ?
|
||||
and kc.table_name = ? |] (qtSchema table) (qtName table)
|
||||
return $ map runIdentity r
|
||||
|
||||
|
||||
vanishNull :: [a] -> Maybe [a]
|
||||
vanishNull xs = if L.null xs then Nothing else Just xs
|
||||
|
||||
toBool :: Text -> Bool
|
||||
toBool = (== "YES")
|
||||
|
||||
data Table = Table {
|
||||
tableSchema :: Text
|
||||
, tableName :: Text
|
||||
, tableInsertable :: Bool
|
||||
} deriving (Show)
|
||||
|
||||
data ForeignKey = ForeignKey {
|
||||
fkTable::Text, fkCol::Text
|
||||
} deriving (Eq, Show)
|
||||
|
||||
data Column = Column {
|
||||
colSchema :: Text
|
||||
, colTable :: Text
|
||||
, colName :: Text
|
||||
, colPosition :: Int
|
||||
, colNullable :: Bool
|
||||
, colType :: Text
|
||||
, colUpdatable :: Bool
|
||||
, colMaxLen :: Maybe Int
|
||||
, colPrecision :: Maybe Int
|
||||
, colDefault :: Maybe Text
|
||||
, colEnum :: [Text]
|
||||
, colFK :: Maybe ForeignKey
|
||||
} deriving (Show)
|
||||
|
||||
instance H.RowParser H.Postgres Column where
|
||||
parseRow r =
|
||||
let schema = H.parseResult $ r V.! 0
|
||||
table = H.parseResult $ r V.! 1
|
||||
name = H.parseResult $ r V.! 2
|
||||
position = H.parseResult $ r V.! 3
|
||||
nullable = toBool <$> (H.parseResult $ r V.! 4 :: Either Text Text)
|
||||
typ = H.parseResult $ r V.! 5
|
||||
updatable = toBool <$> (H.parseResult $ r V.! 6 :: Either Text Text)
|
||||
maxLen = H.parseResult $ r V.! 7
|
||||
precision = H.parseResult $ r V.! 8
|
||||
defValue = H.parseResult $ r V.! 9
|
||||
enum = either (const $ Right []) (Right . split (==','))
|
||||
(H.parseResult $ r V.! 10 :: Either Text Text)
|
||||
in
|
||||
if V.length r /= 11
|
||||
then Left "Wrong number of fields in Column"
|
||||
else Column <$> schema <*> table <*> name <*> position <*> nullable
|
||||
<*> typ <*> updatable <*> maxLen <*> precision
|
||||
<*> defValue <*> enum
|
||||
<*> return Nothing
|
||||
|
||||
|
||||
instance H.RowParser H.Postgres Table where
|
||||
parseRow r =
|
||||
let schema = H.parseResult $ r V.! 0
|
||||
name = H.parseResult $ r V.! 1
|
||||
insertable = toBool <$> (H.parseResult $ r V.! 2 :: Either Text Text) in
|
||||
if V.length r /= 3
|
||||
then Left "Wrong number of fields in Table"
|
||||
else Table <$> schema <*> name <*> insertable
|
||||
|
||||
instance ToJSON Column where
|
||||
toJSON c = object [
|
||||
"schema" .= colSchema c
|
||||
, "name" .= colName c
|
||||
, "position" .= colPosition c
|
||||
, "nullable" .= colNullable c
|
||||
, "type" .= colType c
|
||||
, "updatable" .= colUpdatable c
|
||||
, "maxLen" .= colMaxLen c
|
||||
, "precision" .= colPrecision c
|
||||
, "references".= colFK c
|
||||
, "default" .= colDefault c
|
||||
, "enum" .= colEnum c ]
|
||||
|
||||
instance ToJSON ForeignKey where
|
||||
toJSON fk = object ["table".=fkTable fk, "column".=fkCol fk]
|
||||
|
||||
instance ToJSON Table where
|
||||
toJSON v = object [
|
||||
"schema" .= tableSchema v
|
||||
, "name" .= tableName v
|
||||
, "insertable" .= tableInsertable v ]
|
||||
@@ -0,0 +1,221 @@
|
||||
module PostgREST.ApiRequest where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import qualified Data.Csv as CSV
|
||||
import Data.List (find)
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Data.Set as S
|
||||
import Data.Maybe (fromMaybe, isJust, isNothing,
|
||||
listToMaybe, fromJust)
|
||||
import Control.Monad (join)
|
||||
import Data.Monoid ((<>))
|
||||
import Data.String.Conversions (cs)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Vector as V
|
||||
import Network.Wai (Request (..))
|
||||
import Network.Wai.Parse (parseHttpAccept)
|
||||
import PostgREST.RangeQuery (NonnegRange, rangeRequested)
|
||||
import PostgREST.Types (QualifiedIdentifier (..),
|
||||
Schema, Payload(..),
|
||||
UniformObjects(..))
|
||||
import Data.Ranged.Ranges (singletonRange)
|
||||
|
||||
type RequestBody = BL.ByteString
|
||||
|
||||
-- | Types of things a user wants to do to tables/views/procs
|
||||
data Action = ActionCreate | ActionRead
|
||||
| ActionUpdate | ActionDelete
|
||||
| ActionInfo | ActionInvoke
|
||||
| ActionUnknown BS.ByteString deriving Eq
|
||||
-- | The target db object of a user action
|
||||
data Target = TargetIdent QualifiedIdentifier
|
||||
| TargetRoot
|
||||
| TargetUnknown [T.Text]
|
||||
-- | How to return the inserted data
|
||||
data PreferRepresentation = Full | HeadersOnly | None deriving Eq
|
||||
-- | Enumeration of currently supported content types for
|
||||
-- route responses and upload payloads
|
||||
data ContentType = ApplicationJSON | TextCSV deriving Eq
|
||||
instance Show ContentType where
|
||||
show ApplicationJSON = "application/json"
|
||||
show TextCSV = "text/csv"
|
||||
|
||||
{-|
|
||||
Describes what the user wants to do. This data type is a
|
||||
translation of the raw elements of an HTTP request into domain
|
||||
specific language. There is no guarantee that the intent is
|
||||
sensible, it is up to a later stage of processing to determine
|
||||
if it is an action we are able to perform.
|
||||
-}
|
||||
data ApiRequest = ApiRequest {
|
||||
-- | Set to Nothing for unknown HTTP verbs
|
||||
iAction :: Action
|
||||
-- | Set to Nothing for malformed range
|
||||
, iRange :: NonnegRange
|
||||
-- | Set to Nothing for strangely nested urls
|
||||
, iTarget :: Target
|
||||
-- | The content type the client most desires (or JSON if undecided)
|
||||
, iAccepts :: Either BS.ByteString ContentType
|
||||
-- | Data sent by client and used for mutation actions
|
||||
, iPayload :: Maybe Payload
|
||||
-- | If client wants created items echoed back
|
||||
, iPreferRepresentation :: PreferRepresentation
|
||||
-- | If client wants first row as raw object
|
||||
, iPreferSingular :: Bool
|
||||
-- | Whether the client wants a result count (slower)
|
||||
, iPreferCount :: Bool
|
||||
-- | Filters on the result ("id", "eq.10")
|
||||
, iFilters :: [(String, String)]
|
||||
-- | &select parameter used to shape the response
|
||||
, iSelect :: String
|
||||
-- | &order parameter
|
||||
, iOrder :: Maybe String
|
||||
}
|
||||
|
||||
-- | Examines HTTP request and translates it into user intent.
|
||||
userApiRequest :: Schema -> Request -> RequestBody -> ApiRequest
|
||||
userApiRequest schema req reqBody =
|
||||
let action = case method of
|
||||
"GET" -> ActionRead
|
||||
"POST" -> if isTargetingProc
|
||||
then ActionInvoke
|
||||
else ActionCreate
|
||||
"PATCH" -> ActionUpdate
|
||||
"DELETE" -> ActionDelete
|
||||
"OPTIONS" -> ActionInfo
|
||||
other -> ActionUnknown other
|
||||
target = case path of
|
||||
[] -> TargetRoot
|
||||
[table] -> TargetIdent
|
||||
$ QualifiedIdentifier schema table
|
||||
["rpc", proc] -> TargetIdent
|
||||
$ QualifiedIdentifier schema proc
|
||||
other -> TargetUnknown other
|
||||
payload = case pickContentType (lookupHeader "content-type") of
|
||||
Right ApplicationJSON ->
|
||||
either (PayloadParseError . cs)
|
||||
(\val -> case ensureUniform (pluralize val) of
|
||||
Nothing -> PayloadParseError "All object keys must match"
|
||||
Just json -> PayloadJSON json)
|
||||
(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 singletonRange 0 else rangeRequested hdrs
|
||||
, iTarget = target
|
||||
, iAccepts = pickContentType $ lookupHeader "accept"
|
||||
, iPayload = relevantPayload
|
||||
, iPreferRepresentation = 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
|
||||
path = pathInfo req
|
||||
method = requestMethod req
|
||||
isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path
|
||||
hdrs = requestHeaders req
|
||||
qParams = [(cs k, cs <$> v)|(k,v) <- queryString req]
|
||||
lookupHeader = flip lookup hdrs
|
||||
hasPrefer val = any (\(h,v) -> h == "Prefer" && v == val) hdrs
|
||||
singular = hasPrefer "plurality=singular"
|
||||
representation
|
||||
| hasPrefer "return=representation" = Full
|
||||
| hasPrefer "return=minimal" = None
|
||||
| otherwise = HeadersOnly
|
||||
|
||||
-- PRIVATE ---------------------------------------------------------------
|
||||
|
||||
{-|
|
||||
Picks a preferred content type from an Accept header (or from
|
||||
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
|
||||
pickContentType accept
|
||||
| isNothing accept || has ctAll || has ctJson = Right ApplicationJSON
|
||||
| has ctCsv = Right TextCSV
|
||||
| otherwise = Left accept'
|
||||
where
|
||||
ctAll = "*/*"
|
||||
ctCsv = "text/csv"
|
||||
ctJson = "application/json"
|
||||
Just accept' = accept
|
||||
findInAccept = flip find $ parseHttpAccept accept'
|
||||
has = isJust . findInAccept . BS.isPrefixOf
|
||||
|
||||
type CsvData = V.Vector (M.HashMap T.Text BL.ByteString)
|
||||
|
||||
{-|
|
||||
Converts CSV like
|
||||
a,b
|
||||
1,hi
|
||||
2,bye
|
||||
|
||||
into a JSON array like
|
||||
[ {"a": "1", "b": "hi"}, {"a": 2, "b": "bye"} ]
|
||||
|
||||
The reason for its odd signature is so that it can compose
|
||||
directly with CSV.decodeByName
|
||||
-}
|
||||
csvToJson :: (CSV.Header, CsvData) -> JSON.Array
|
||||
csvToJson (_, vals) =
|
||||
V.map rowToJsonObj vals
|
||||
where
|
||||
rowToJsonObj = JSON.Object .
|
||||
M.map (\str ->
|
||||
if str == "NULL"
|
||||
then JSON.Null
|
||||
else JSON.String $ cs str
|
||||
)
|
||||
|
||||
-- | Convert {foo} to [{foo}], leave arrays unchanged
|
||||
-- and truncate everything else to an empty array.
|
||||
pluralize :: JSON.Value -> JSON.Array
|
||||
pluralize obj@(JSON.Object _) = V.singleton obj
|
||||
pluralize (JSON.Array arr) = arr
|
||||
pluralize _ = V.empty
|
||||
|
||||
-- | Test that Array contains only Objects having the same keys
|
||||
-- and if so mark it as UniformObjects
|
||||
ensureUniform :: JSON.Array -> Maybe UniformObjects
|
||||
ensureUniform arr =
|
||||
let objs :: V.Vector JSON.Object
|
||||
objs = foldr -- filter non-objects, map to raw objects
|
||||
(\val result -> case val of
|
||||
JSON.Object o -> V.cons o result
|
||||
_ -> result)
|
||||
V.empty arr
|
||||
keysPerObj = V.map (S.fromList . M.keys) objs
|
||||
canonicalKeys = fromMaybe S.empty $ keysPerObj V.!? 0
|
||||
areKeysUniform = all (==canonicalKeys) keysPerObj in
|
||||
|
||||
if (V.length objs == V.length arr) && areKeysUniform
|
||||
then Just (UniformObjects objs)
|
||||
else Nothing
|
||||
@@ -0,0 +1,340 @@
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
--module PostgREST.App where
|
||||
module PostgREST.App (
|
||||
app
|
||||
) where
|
||||
|
||||
import Control.Applicative
|
||||
import Control.Arrow ((***))
|
||||
import Control.Monad (join)
|
||||
import Data.Bifunctor (first)
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
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 Text.ParserCombinators.Parsec (parse)
|
||||
|
||||
import Network.HTTP.Base (urlEncodeVars)
|
||||
import Network.HTTP.Types.Header
|
||||
import Network.HTTP.Types.Status
|
||||
import Network.HTTP.Types.URI (parseSimpleQuery)
|
||||
import Network.Wai
|
||||
|
||||
import Data.Aeson
|
||||
import Data.Aeson.Types (emptyArray)
|
||||
import Data.Monoid
|
||||
import qualified Data.Vector as V
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Backend as B
|
||||
import qualified Hasql.Postgres as P
|
||||
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.Parsers
|
||||
import PostgREST.DbStructure
|
||||
import PostgREST.RangeQuery
|
||||
import PostgREST.ApiRequest (ApiRequest(..), ContentType(..)
|
||||
, Action(..), Target(..)
|
||||
, PreferRepresentation (..)
|
||||
, userApiRequest)
|
||||
import PostgREST.Types
|
||||
import PostgREST.Auth (tokenJWT)
|
||||
import PostgREST.Error (errResponse)
|
||||
|
||||
import PostgREST.QueryBuilder ( asJson
|
||||
, callProc
|
||||
, addJoinConditions
|
||||
, sourceCTEName
|
||||
, requestToQuery
|
||||
, requestToCountQuery
|
||||
, addRelations
|
||||
, createReadStatement
|
||||
, createWriteStatement
|
||||
)
|
||||
|
||||
import Prelude
|
||||
|
||||
app :: DbStructure -> AppConfig -> RequestBody -> Request -> H.Tx P.Postgres s Response
|
||||
app dbStructure conf reqBody req =
|
||||
let
|
||||
-- TODO: blow up for Left values (there is a middleware that checks the headers)
|
||||
contentType = either (const ApplicationJSON) id (iAccepts apiRequest)
|
||||
contentTypeH = (hContentType, cs $ show contentType) in
|
||||
|
||||
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
|
||||
|
||||
(ActionRead, TargetIdent qi, Nothing) ->
|
||||
case readSqlParts of
|
||||
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
||||
Right (q, cq) -> do
|
||||
let range = restrictRange (configMaxRows conf) $ iRange apiRequest
|
||||
singular = iPreferSingular apiRequest
|
||||
stm = createReadStatement q cq range singular
|
||||
(iPreferCount apiRequest) (contentType == TextCSV)
|
||||
if range == emptyRange
|
||||
then return $ errResponse status416 "HTTP Range error"
|
||||
else do
|
||||
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 = 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 qi@(QualifiedIdentifier _ table),
|
||||
Just payload@(PayloadJSON (UniformObjects rows))) ->
|
||||
case mutateSqlParts of
|
||||
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
||||
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 qi 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 == Full then fromMaybe "[]" body else ""
|
||||
|
||||
(ActionUpdate, TargetIdent qi, Just payload@(PayloadJSON _)) ->
|
||||
case mutateSqlParts of
|
||||
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
||||
Right (sq,mq) -> do
|
||||
let stm = createWriteStatement qi sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) payload
|
||||
row <- H.maybeEx stm
|
||||
let (_, queryTotal, _, body) = extractQueryResult row
|
||||
r = contentRangeH 0 (queryTotal-1) (Just queryTotal)
|
||||
s = case () of _ | queryTotal == 0 -> status404
|
||||
| iPreferRepresentation apiRequest == Full -> status200
|
||||
| otherwise -> status204
|
||||
return $ responseLBS s [contentTypeH, r]
|
||||
$ if iPreferRepresentation apiRequest == Full then fromMaybe "[]" body else ""
|
||||
|
||||
(ActionDelete, TargetIdent qi, Nothing) ->
|
||||
case mutateSqlParts of
|
||||
Left e -> return $ responseLBS status400 [jsonH] $ cs e
|
||||
Right (sq,mq) -> do
|
||||
let fakeload = PayloadJSON $ UniformObjects V.empty
|
||||
let stm = createWriteStatement qi sq mq False (iPreferRepresentation apiRequest) [] (contentType == TextCSV) fakeload
|
||||
row <- H.maybeEx stm
|
||||
let (_, queryTotal, _, _) = extractQueryResult row
|
||||
return $ if queryTotal == 0
|
||||
then notFound
|
||||
else responseLBS status204 [("Content-Range", "*/"<> cs (show queryTotal))] ""
|
||||
|
||||
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) -> do
|
||||
let cols = filter (filterCol tSchema tTable) $ dbColumns dbStructure
|
||||
pkeys = map pkName $ filter (filterPk tSchema tTable) allPrKeys
|
||||
body = encode (TableOptions cols pkeys)
|
||||
filterCol :: Schema -> TableName -> Column -> Bool
|
||||
filterCol sc tb (Column{colTable=Table{tableSchema=s, tableName=t}}) = s==sc && t==tb
|
||||
filterCol _ _ _ = False
|
||||
return $ responseLBS status200 [jsonH, allOrigins] $ cs body
|
||||
|
||||
(ActionInvoke, TargetIdent qi,
|
||||
Just (PayloadJSON (UniformObjects payload))) -> do
|
||||
exists <- doesProcExist qi
|
||||
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
|
||||
returnJWT <- doesProcReturnJWT qi
|
||||
return $ responseLBS status200 [jsonH]
|
||||
(let body = fromMaybe emptyArray $ runIdentity <$> bodyJson in
|
||||
if returnJWT
|
||||
then "{\"token\":\"" <> cs (tokenJWT jwtSecret body) <> "\"}"
|
||||
else cs $ encode body)
|
||||
else return notFound
|
||||
|
||||
(ActionRead, TargetRoot, Nothing) -> do
|
||||
body <- encode <$> accessibleTables (filter ((== cs schema) . tableSchema) (dbTables dbStructure))
|
||||
return $ responseLBS status200 [jsonH] $ cs body
|
||||
|
||||
(ActionUnknown _, _, _) -> return notFound
|
||||
|
||||
(_, TargetUnknown _, _) -> return notFound
|
||||
|
||||
(_, _, Just (PayloadParseError e)) ->
|
||||
return $ responseLBS status400 [jsonH] $
|
||||
cs (formatGeneralError "Cannot parse request payload" (cs e))
|
||||
|
||||
(_, _, _) -> return notFound
|
||||
|
||||
where
|
||||
notFound = responseLBS status404 [] ""
|
||||
filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk
|
||||
allPrKeys = dbPrimaryKeys dbStructure
|
||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
||||
schema = cs $ configSchema conf
|
||||
apiRequest = userApiRequest schema req reqBody
|
||||
readDbRequest = DbRead <$> buildReadRequest (dbRelations dbStructure) apiRequest
|
||||
mutateDbRequest = DbMutate <$> buildMutateRequest apiRequest
|
||||
selectQuery = requestToQuery schema <$> readDbRequest
|
||||
countQuery = requestToCountQuery schema <$> readDbRequest
|
||||
mutateQuery = requestToQuery schema <$> mutateDbRequest
|
||||
readSqlParts = (,) <$> selectQuery <*> countQuery
|
||||
mutateSqlParts = (,) <$> selectQuery <*> mutateQuery
|
||||
|
||||
rangeStatus :: Int -> Int -> Maybe Int -> Status
|
||||
rangeStatus _ _ Nothing = status200
|
||||
rangeStatus frm to (Just total)
|
||||
| frm > total = status416
|
||||
| (1 + to - frm) < total = status206
|
||||
| otherwise = status200
|
||||
|
||||
contentRangeH :: Int -> Int -> Maybe Int -> Header
|
||||
contentRangeH frm to total =
|
||||
("Content-Range", cs headerValue)
|
||||
where
|
||||
headerValue = rangeString <> "/" <> totalString
|
||||
rangeString
|
||||
| totalNotZero && fromInRange = show frm <> "-" <> cs (show to)
|
||||
| otherwise = "*"
|
||||
totalString = fromMaybe "*" (show <$> total)
|
||||
totalNotZero = fromMaybe True ((/=) 0 <$> total)
|
||||
fromInRange = frm <= to
|
||||
|
||||
jsonH :: Header
|
||||
jsonH = (hContentType, "application/json")
|
||||
|
||||
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 sourceCTEName
|
||||
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=sourceCTEName}}
|
||||
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}
|
||||
| Just mt == (tableName <$> rt) = Just $ r {relLTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> 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 "")
|
||||
@@ -0,0 +1,84 @@
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-|
|
||||
Module : PostgREST.Auth
|
||||
Description : PostgREST authorization functions.
|
||||
|
||||
This module provides functions to deal with the JWT authorization (http://jwt.io).
|
||||
It also can be used to define other authorization functions,
|
||||
in the future Oauth, LDAP and similar integrations can be coded here.
|
||||
|
||||
Authentication should always be implemented in an external service.
|
||||
In the test suite there is an example of simple login function that can be used for a
|
||||
very simple authentication system inside the PostgreSQL database.
|
||||
-}
|
||||
module PostgREST.Auth (
|
||||
setRole
|
||||
, claimsToSQL
|
||||
, jwtClaims
|
||||
, tokenJWT
|
||||
) where
|
||||
|
||||
import Control.Monad (join)
|
||||
import Data.Aeson (Value (..), Object)
|
||||
import Data.Aeson.Types (emptyObject, emptyArray)
|
||||
import Data.Vector as V (null, head)
|
||||
import Data.Map as M (fromList, toList)
|
||||
import Data.Monoid ((<>))
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Text (Text)
|
||||
import Data.Time.Clock (NominalDiffTime)
|
||||
import PostgREST.QueryBuilder (pgFmtLit, pgFmtIdent, unquoted)
|
||||
import qualified Web.JWT as JWT
|
||||
import qualified Data.HashMap.Lazy as H
|
||||
|
||||
{-|
|
||||
Receives a map of JWT claims and returns a list
|
||||
of PostgreSQL statements to set the claims as user defined GUCs.
|
||||
Except if we have a claim called role,
|
||||
this one is mapped to a SET ROLE statement.
|
||||
In case there is any problem decoding the JWT it returns Nothing.
|
||||
-}
|
||||
claimsToSQL :: JWT.ClaimsMap -> [Text]
|
||||
claimsToSQL = map setVar . toList
|
||||
where
|
||||
setVar ("role", String val) = setRole val
|
||||
setVar (k, val) = "set local postgrest.claims." <> pgFmtIdent k <>
|
||||
" = " <> valueToVariable val <> ";"
|
||||
valueToVariable = pgFmtLit . unquoted
|
||||
|
||||
{-|
|
||||
Receives the JWT secret (from config) and a JWT and
|
||||
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
|
||||
jwtClaims secret input time =
|
||||
case join $ claim JWT.exp of
|
||||
Just expires ->
|
||||
if JWT.secondsSinceEpoch expires > time
|
||||
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 a JSON value
|
||||
and returns a signed JWT.
|
||||
-}
|
||||
tokenJWT :: JWT.Secret -> Value -> Text
|
||||
tokenJWT secret (Array a) = JWT.encodeSigned JWT.HS256 secret
|
||||
JWT.def { JWT.unregisteredClaims = fromHashMap o }
|
||||
where
|
||||
Object o = if V.null a then emptyObject else V.head a
|
||||
fromHashMap :: Object -> JWT.ClaimsMap
|
||||
fromHashMap = M.fromList . H.toList
|
||||
tokenJWT secret _ = tokenJWT secret emptyArray
|
||||
@@ -0,0 +1,102 @@
|
||||
{-|
|
||||
Module : PostgREST.Config
|
||||
Description : Manages PostgREST configuration options.
|
||||
|
||||
This module provides a helper function to read the command line arguments using the optparse-applicative
|
||||
and the AppConfig type to store them.
|
||||
It also can be used to define other middleware configuration that 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.
|
||||
|
||||
Other hardcoded options such as the minimum version number also belong here.
|
||||
-}
|
||||
module PostgREST.Config ( prettyVersion
|
||||
, readOptions
|
||||
, corsPolicy
|
||||
, minimumPgVersion
|
||||
, AppConfig (..)
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Applicative
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
import Data.List (intercalate)
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Text (strip)
|
||||
import Data.Version (versionBranch)
|
||||
import Network.Wai
|
||||
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
||||
import Options.Applicative
|
||||
import Paths_postgrest (version)
|
||||
import Safe (readMay)
|
||||
import Web.JWT (Secret, secret)
|
||||
import Prelude
|
||||
|
||||
-- | Data type to store all command line options
|
||||
data AppConfig = AppConfig {
|
||||
configDatabase :: String
|
||||
, configPort :: Int
|
||||
, configAnonRole :: String
|
||||
, configSchema :: String
|
||||
, configJwtSecret :: Secret
|
||||
, configPool :: Int
|
||||
, configMaxRows :: Maybe Int
|
||||
}
|
||||
|
||||
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 "public" <> 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)
|
||||
<*> (readMay <$> strOption (long "max-rows" <> short 'm' <> help "max rows in response" <> metavar "COUNT" <> value "infinity" <> showDefault))
|
||||
|
||||
defaultCorsPolicy :: CorsResourcePolicy
|
||||
defaultCorsPolicy = CorsResourcePolicy Nothing
|
||||
["GET", "POST", "PATCH", "DELETE", "OPTIONS"] ["Authorization"] Nothing
|
||||
(Just $ 60*60*24) False False True
|
||||
|
||||
-- | CORS policy to be used in by Wai Cors middleware
|
||||
corsPolicy :: Request -> Maybe CorsResourcePolicy
|
||||
corsPolicy req = case lookup "origin" headers of
|
||||
Just origin -> Just defaultCorsPolicy {
|
||||
corsOrigins = Just ([origin], True)
|
||||
, corsRequestHeaders = "Authentication":accHeaders
|
||||
, corsExposedHeaders = Just [
|
||||
"Content-Encoding", "Content-Location", "Content-Range", "Content-Type"
|
||||
, "Date", "Location", "Server", "Transfer-Encoding", "Range-Unit"
|
||||
]
|
||||
}
|
||||
Nothing -> Nothing
|
||||
where
|
||||
headers = requestHeaders req
|
||||
accHeaders = case lookup "access-control-request-headers" headers of
|
||||
Just hdrs -> map (CI.mk . cs . strip . cs) $ BS.split ',' hdrs
|
||||
Nothing -> []
|
||||
|
||||
-- | User friendly version number
|
||||
prettyVersion :: String
|
||||
prettyVersion = intercalate "." $ map show $ versionBranch version
|
||||
|
||||
-- | Function to read and parse options from the command line
|
||||
readOptions :: IO AppConfig
|
||||
readOptions = customExecParser parserPrefs opts
|
||||
where
|
||||
opts = info (helper <*> argParser) $
|
||||
fullDesc
|
||||
<> progDesc (
|
||||
"PostgREST "
|
||||
<> prettyVersion
|
||||
<> " / create a REST API to an existing Postgres database"
|
||||
)
|
||||
parserPrefs = prefs showHelpOnError
|
||||
|
||||
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
|
||||
minimumPgVersion :: Integer
|
||||
minimumPgVersion = 90300
|
||||
@@ -0,0 +1,564 @@
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeSynonymInstances #-}
|
||||
module PostgREST.DbStructure (
|
||||
getDbStructure
|
||||
, accessibleTables
|
||||
, doesProcExist
|
||||
, doesProcReturnJWT
|
||||
) where
|
||||
|
||||
import Control.Applicative
|
||||
import Control.Monad (join)
|
||||
import Data.Functor.Identity
|
||||
import Data.List (elemIndex, find, subsequences, sort, transpose)
|
||||
import Data.Maybe (fromMaybe, fromJust, isJust, mapMaybe, listToMaybe)
|
||||
import Data.Monoid
|
||||
import Data.Text (Text, split)
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as P
|
||||
import qualified Hasql.Backend as B
|
||||
import PostgREST.Types
|
||||
|
||||
import GHC.Exts (groupWith)
|
||||
import Prelude
|
||||
|
||||
getDbStructure :: Schema -> H.Tx P.Postgres s DbStructure
|
||||
getDbStructure schema = do
|
||||
tabs <- allTables
|
||||
cols <- allColumns tabs
|
||||
syns <- allSynonyms cols
|
||||
rels <- allRelations tabs cols
|
||||
keys <- allPrimaryKeys tabs
|
||||
|
||||
let rels' = (addManyToManyRelations . raiseRelations schema syns . addParentRelations . addSynonymousRelations syns) rels
|
||||
cols' = addForeignKeys rels' cols
|
||||
keys' = synonymousPrimaryKeys syns keys
|
||||
|
||||
return DbStructure {
|
||||
dbTables = tabs
|
||||
, dbColumns = cols'
|
||||
, dbRelations = rels'
|
||||
, dbPrimaryKeys = keys'
|
||||
}
|
||||
|
||||
doesProc :: forall c s. B.CxValue c Int =>
|
||||
(Text -> Text -> B.Stmt c) -> QualifiedIdentifier -> H.Tx c s Bool
|
||||
doesProc stmt qi = do
|
||||
row :: Maybe (Identity Int) <- H.maybeEx $ stmt (qiSchema qi) (qiName qi)
|
||||
return $ isJust row
|
||||
|
||||
doesProcExist :: QualifiedIdentifier -> H.Tx P.Postgres s Bool
|
||||
doesProcExist = doesProc [H.stmt|
|
||||
SELECT 1
|
||||
FROM pg_catalog.pg_namespace n
|
||||
JOIN pg_catalog.pg_proc p
|
||||
ON pronamespace = n.oid
|
||||
WHERE nspname = ?
|
||||
AND proname = ?
|
||||
|]
|
||||
|
||||
doesProcReturnJWT :: QualifiedIdentifier -> H.Tx P.Postgres s Bool
|
||||
doesProcReturnJWT = doesProc [H.stmt|
|
||||
SELECT 1
|
||||
FROM pg_catalog.pg_namespace n
|
||||
JOIN pg_catalog.pg_proc p
|
||||
ON pronamespace = n.oid
|
||||
WHERE nspname = ?
|
||||
AND proname = ?
|
||||
AND pg_catalog.pg_get_function_result(p.oid) like '%jwt_claims'
|
||||
|]
|
||||
|
||||
accessibleTables :: [Table] -> H.Tx P.Postgres s [Table]
|
||||
accessibleTables allTabs = do
|
||||
accessible <- H.listEx $ [H.stmt|
|
||||
SELECT
|
||||
n.nspname AS table_schema,
|
||||
c.relname AS table_name
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE
|
||||
c.relkind IN ('v','r','m') AND
|
||||
n.nspname NOT IN ('pg_catalog', 'information_schema') 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 table_schema, table_name
|
||||
|]
|
||||
let isAccessible table = isJust $ find (\(s,n) -> tableSchema table == s && tableName table == n) accessible
|
||||
return $ filter isAccessible allTabs
|
||||
|
||||
synonymousColumns :: [(Column,Column)] -> [Column] -> [[Column]]
|
||||
synonymousColumns allSyns cols = synCols'
|
||||
where
|
||||
syns = sort $ filter ((== colTable (head cols)) . colTable . fst) allSyns
|
||||
synCols = transpose $ map (\c -> map snd $ filter ((== c) . fst) syns) cols
|
||||
synCols' = (filter sameTable . filter matchLength) synCols
|
||||
matchLength cs = length cols == length cs
|
||||
sameTable (c:cs) = all (\cc -> colTable c == colTable cc) (c:cs)
|
||||
sameTable [] = False
|
||||
|
||||
addForeignKeys :: [Relation] -> [Column] -> [Column]
|
||||
addForeignKeys rels = map addFk
|
||||
where
|
||||
addFk col = col { colFK = fk col }
|
||||
fk col = join $ relToFk col <$> find (lookupFn col) rels
|
||||
lookupFn :: Column -> Relation -> Bool
|
||||
lookupFn c (Relation{relColumns=cs, relType=rty}) = c `elem` cs && rty==Child
|
||||
-- lookupFn _ _ = False
|
||||
relToFk col (Relation{relColumns=cols, relFColumns=colsF}) = ForeignKey <$> colF
|
||||
where
|
||||
pos = elemIndex col cols
|
||||
colF = (colsF !!) <$> pos
|
||||
|
||||
addSynonymousRelations :: [(Column,Column)] -> [Relation] -> [Relation]
|
||||
addSynonymousRelations _ [] = []
|
||||
addSynonymousRelations syns (rel:rels) = rel : synRelsP ++ synRelsF ++ addSynonymousRelations syns rels
|
||||
where
|
||||
synRelsP = synRels (relColumns rel) (\t cs -> rel{relTable=t,relColumns=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
|
||||
|
||||
addParentRelations :: [Relation] -> [Relation]
|
||||
addParentRelations [] = []
|
||||
addParentRelations (rel@(Relation t c ft fc _ _ _ _):rels) = Relation ft fc t c Parent Nothing Nothing Nothing : rel : addParentRelations rels
|
||||
|
||||
addManyToManyRelations :: [Relation] -> [Relation]
|
||||
addManyToManyRelations rels = rels ++ mapMaybe link2Relation links
|
||||
where
|
||||
links = join $ map (combinations 2) $ filter (not . null) $ groupWith groupFn $ filter ( (==Child). relType) rels
|
||||
groupFn :: Relation -> Text
|
||||
groupFn (Relation{relTable=Table{tableSchema=s, tableName=t}}) = s<>"_"<>t
|
||||
combinations k ns = filter ((k==).length) (subsequences ns)
|
||||
link2Relation [
|
||||
Relation{relTable=lt, relColumns=lc1, relFTable=t, relFColumns=c},
|
||||
Relation{ relColumns=lc2, relFTable=ft, relFColumns=fc}
|
||||
]
|
||||
| lc1 /= lc2 && length lc1 == 1 && length lc2 == 1 = Just $ Relation t c ft fc Many (Just lt) (Just lc1) (Just lc2)
|
||||
| otherwise = Nothing
|
||||
link2Relation _ = Nothing
|
||||
|
||||
raiseRelations :: Schema -> [(Column,Column)] -> [Relation] -> [Relation]
|
||||
raiseRelations schema syns = map raiseRel
|
||||
where
|
||||
raiseRel rel
|
||||
| tableSchema table == schema = rel
|
||||
| isJust newCols = rel{relFTable=fromJust newTable,relFColumns=fromJust newCols}
|
||||
| otherwise = rel
|
||||
where
|
||||
cols = relFColumns rel
|
||||
table = relFTable rel
|
||||
newCols = listToMaybe $ filter ((== schema) . tableSchema . colTable . head) (synonymousColumns syns cols)
|
||||
newTable = (colTable . head) <$> newCols
|
||||
|
||||
synonymousPrimaryKeys :: [(Column,Column)] -> [PrimaryKey] -> [PrimaryKey]
|
||||
synonymousPrimaryKeys _ [] = []
|
||||
synonymousPrimaryKeys syns (key:keys) = key : newKeys ++ synonymousPrimaryKeys syns keys
|
||||
where
|
||||
keySyns = filter ((\c -> colTable c == pkTable key && colName c == pkName key) . fst) syns
|
||||
newKeys = map ((\c -> PrimaryKey{pkTable=colTable c,pkName=colName c}) . snd) keySyns
|
||||
|
||||
allTables :: H.Tx P.Postgres s [Table]
|
||||
allTables = do
|
||||
rows <- H.listEx $ [H.stmt|
|
||||
SELECT
|
||||
n.nspname AS table_schema,
|
||||
c.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 NOT IN ('pg_catalog', 'information_schema')
|
||||
GROUP BY table_schema, table_name, insertable
|
||||
ORDER BY table_schema, table_name
|
||||
|]
|
||||
return $ map tableFromRow rows
|
||||
|
||||
tableFromRow :: (Text, Text, Bool) -> Table
|
||||
tableFromRow (s, n, i) = Table s n i
|
||||
|
||||
allColumns :: [Table] -> H.Tx P.Postgres s [Column]
|
||||
allColumns tabs = do
|
||||
cols <- H.listEx $ [H.stmt|
|
||||
SELECT DISTINCT
|
||||
info.table_schema AS schema,
|
||||
info.table_name AS table_name,
|
||||
info.column_name AS name,
|
||||
info.ordinal_position AS position,
|
||||
info.is_nullable::boolean AS nullable,
|
||||
info.data_type AS col_type,
|
||||
info.is_updatable::boolean AS updatable,
|
||||
info.character_maximum_length AS max_len,
|
||||
info.numeric_precision AS precision,
|
||||
info.column_default AS default_value,
|
||||
array_to_string(enum_info.vals, ',') AS enum
|
||||
FROM (
|
||||
/*
|
||||
-- CTE based on information_schema.columns to remove the owner filter
|
||||
*/
|
||||
WITH columns AS (
|
||||
SELECT current_database()::information_schema.sql_identifier AS table_catalog,
|
||||
nc.nspname::information_schema.sql_identifier AS table_schema,
|
||||
c.relname::information_schema.sql_identifier AS table_name,
|
||||
a.attname::information_schema.sql_identifier AS column_name,
|
||||
a.attnum::information_schema.cardinal_number AS ordinal_position,
|
||||
pg_get_expr(ad.adbin, ad.adrelid)::information_schema.character_data AS column_default,
|
||||
CASE
|
||||
WHEN a.attnotnull OR t.typtype = 'd'::"char" AND t.typnotnull THEN 'NO'::text
|
||||
ELSE 'YES'::text
|
||||
END::information_schema.yes_or_no AS is_nullable,
|
||||
CASE
|
||||
WHEN t.typtype = 'd'::"char" THEN
|
||||
CASE
|
||||
WHEN bt.typelem <> 0::oid AND bt.typlen = (-1) THEN 'ARRAY'::text
|
||||
WHEN nbt.nspname = 'pg_catalog'::name THEN format_type(t.typbasetype, NULL::integer)
|
||||
ELSE 'USER-DEFINED'::text
|
||||
END
|
||||
ELSE
|
||||
CASE
|
||||
WHEN t.typelem <> 0::oid AND t.typlen = (-1) THEN 'ARRAY'::text
|
||||
WHEN nt.nspname = 'pg_catalog'::name THEN format_type(a.atttypid, NULL::integer)
|
||||
ELSE 'USER-DEFINED'::text
|
||||
END
|
||||
END::information_schema.character_data AS data_type,
|
||||
information_schema._pg_char_max_length(information_schema._pg_truetypid(a.*, t.*), information_schema._pg_truetypmod(a.*, t.*))::information_schema.cardinal_number AS character_maximum_length,
|
||||
information_schema._pg_char_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
|
||||
|]
|
||||
return $ mapMaybe (columnFromRow tabs) cols
|
||||
|
||||
columnFromRow :: [Table] ->
|
||||
(Text, Text, Text,
|
||||
Int, Bool, Text,
|
||||
Bool, Maybe Int, Maybe Int,
|
||||
Maybe Text, Maybe Text)
|
||||
-> Maybe Column
|
||||
columnFromRow tabs (s, t, n, pos, nul, typ, u, l, p, d, e) = buildColumn <$> table
|
||||
where
|
||||
buildColumn tbl = Column tbl n pos nul typ u l p d (parseEnum e) Nothing
|
||||
table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
|
||||
parseEnum :: Maybe Text -> [Text]
|
||||
parseEnum str = fromMaybe [] $ split (==',') <$> str
|
||||
|
||||
allRelations :: [Table] -> [Column] -> H.Tx P.Postgres s [Relation]
|
||||
allRelations tabs cols = do
|
||||
rels <- H.listEx $ [H.stmt|
|
||||
SELECT ns1.nspname AS table_schema,
|
||||
tab.relname AS table_name,
|
||||
column_info.cols AS columns,
|
||||
ns2.nspname AS foreign_table_schema,
|
||||
other.relname AS foreign_table_name,
|
||||
column_info.refs AS foreign_columns
|
||||
FROM pg_constraint,
|
||||
LATERAL (SELECT array_agg(cols.attname) AS cols,
|
||||
array_agg(cols.attnum) AS nums,
|
||||
array_agg(refs.attname) AS refs
|
||||
FROM ( SELECT unnest(conkey) AS col, unnest(confkey) AS ref) k,
|
||||
LATERAL (SELECT * FROM pg_attribute
|
||||
WHERE attrelid = conrelid AND attnum = col)
|
||||
AS cols,
|
||||
LATERAL (SELECT * FROM pg_attribute
|
||||
WHERE attrelid = confrelid AND attnum = ref)
|
||||
AS refs)
|
||||
AS column_info,
|
||||
LATERAL (SELECT * FROM pg_namespace WHERE pg_namespace.oid = connamespace) AS ns1,
|
||||
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = conrelid) AS tab,
|
||||
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
|
||||
WHERE confrelid != 0
|
||||
ORDER BY (conrelid, column_info.nums)
|
||||
|]
|
||||
return $ mapMaybe (relationFromRow tabs cols) rels
|
||||
|
||||
relationFromRow :: [Table] -> [Column] -> (Text, Text, [Text], Text, Text, [Text]) -> Maybe Relation
|
||||
relationFromRow allTabs allCols (rs, rt, rcs, frs, frt, frcs) =
|
||||
Relation <$> table <*> cols <*> tableF <*> colsF <*> pure Child <*> pure Nothing <*> pure Nothing <*> pure Nothing
|
||||
where
|
||||
findTable s t = find (\tbl -> tableSchema tbl == s && tableName tbl == t) allTabs
|
||||
findCol s t c = find (\col -> tableSchema (colTable col) == s && tableName (colTable col) == t && colName col == c) allCols
|
||||
table = findTable rs rt
|
||||
tableF = findTable frs frt
|
||||
cols = mapM (findCol rs rt) rcs
|
||||
colsF = mapM (findCol frs frt) frcs
|
||||
|
||||
allPrimaryKeys :: [Table] -> H.Tx P.Postgres s [PrimaryKey]
|
||||
allPrimaryKeys tabs = do
|
||||
pks <- H.listEx $ [H.stmt|
|
||||
/*
|
||||
-- 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
|
||||
kc.table_schema,
|
||||
kc.table_name,
|
||||
kc.column_name
|
||||
FROM
|
||||
/*
|
||||
--information_schema.table_constraints tc,
|
||||
--information_schema.key_column_usage kc
|
||||
*/
|
||||
tc, kc
|
||||
WHERE
|
||||
tc.constraint_type = 'PRIMARY KEY' AND
|
||||
kc.table_name = tc.table_name AND
|
||||
kc.table_schema = tc.table_schema AND
|
||||
kc.constraint_name = tc.constraint_name AND
|
||||
kc.table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
|]
|
||||
return $ mapMaybe (pkFromRow tabs) pks
|
||||
|
||||
pkFromRow :: [Table] -> (Schema, Text, Text) -> Maybe PrimaryKey
|
||||
pkFromRow tabs (s, t, n) = PrimaryKey <$> table <*> pure n
|
||||
where table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
|
||||
|
||||
allSynonyms :: [Column] -> H.Tx P.Postgres s [(Column,Column)]
|
||||
allSynonyms allCols = do
|
||||
syns <- H.listEx $ [H.stmt|
|
||||
WITH synonyms AS (
|
||||
/*
|
||||
-- CTE to replace the view from information_schema because the information in it depended on the logged in role
|
||||
-- notice the commented line
|
||||
*/
|
||||
WITH view_column_usage AS (
|
||||
SELECT DISTINCT
|
||||
CAST(current_database() AS character varying) AS view_catalog,
|
||||
CAST(nv.nspname AS character varying) AS view_schema,
|
||||
CAST(v.relname AS character varying) AS view_name,
|
||||
CAST(current_database() AS character varying) AS table_catalog,
|
||||
CAST(nt.nspname AS character varying) AS table_schema,
|
||||
CAST(t.relname AS character varying) AS table_name,
|
||||
CAST(a.attname AS character varying) AS column_name
|
||||
FROM pg_namespace nv, pg_class v, pg_depend dv,
|
||||
pg_depend dt, pg_class t, pg_namespace nt,
|
||||
pg_attribute a
|
||||
WHERE nv.oid = v.relnamespace
|
||||
AND v.relkind = 'v'
|
||||
AND v.oid = dv.refobjid
|
||||
AND dv.refclassid = 'pg_catalog.pg_class'::regclass
|
||||
AND dv.classid = 'pg_catalog.pg_rewrite'::regclass
|
||||
AND dv.deptype = 'i'
|
||||
AND dv.objid = dt.objid
|
||||
AND dv.refobjid <> dt.refobjid
|
||||
AND dt.classid = 'pg_catalog.pg_rewrite'::regclass
|
||||
AND dt.refclassid = 'pg_catalog.pg_class'::regclass
|
||||
AND dt.refobjid = t.oid
|
||||
AND t.relnamespace = nt.oid
|
||||
AND t.relkind IN ('r', 'v', 'f')
|
||||
AND t.oid = a.attrelid
|
||||
AND dt.refobjsubid = a.attnum
|
||||
/*--AND pg_has_role(t.relowner, 'USAGE')*/
|
||||
)
|
||||
SELECT
|
||||
vcu.table_schema AS src_table_schema,
|
||||
vcu.table_name AS src_table_name,
|
||||
vcu.column_name AS src_column_name,
|
||||
view.schemaname AS syn_table_schema,
|
||||
view.viewname AS syn_table_name,
|
||||
view.definition AS view_definition
|
||||
FROM
|
||||
pg_catalog.pg_views AS view,
|
||||
view_column_usage AS vcu
|
||||
WHERE
|
||||
view.schemaname = vcu.view_schema AND
|
||||
view.viewname = vcu.view_name AND
|
||||
view.schemaname NOT IN ('pg_catalog', 'information_schema')
|
||||
/*--AND (SELECT COUNT(*) FROM information_schema.view_table_usage WHERE view_schema = view.schemaname AND view_name = view.viewname) = 1*/
|
||||
)
|
||||
SELECT
|
||||
src_table_schema, src_table_name, src_column_name,
|
||||
syn_table_schema, syn_table_name,
|
||||
(regexp_matches(view_definition, CONCAT('\.(', src_column_name, ')(?=,|$)'), 'gn'))[1] AS syn_column_name
|
||||
FROM synonyms
|
||||
UNION (
|
||||
SELECT
|
||||
src_table_schema, src_table_name, src_column_name,
|
||||
syn_table_schema, syn_table_name,
|
||||
(regexp_matches(view_definition, CONCAT('\.', src_column_name, '\sAS\s("?)(.+?)\1(,|$)'), 'gn'))[2] AS syn_column_name /* " <- for syntax highlighting */
|
||||
FROM synonyms
|
||||
)
|
||||
|]
|
||||
return $ mapMaybe (synonymFromRow allCols) syns
|
||||
|
||||
synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe (Column,Column)
|
||||
synonymFromRow allCols (s1,t1,c1,s2,t2,c2) = (,) <$> col1 <*> col2
|
||||
where
|
||||
col1 = findCol s1 t1 c1
|
||||
col2 = findCol s2 t2 c2
|
||||
findCol s t c = find (\col -> (tableSchema . colTable) col == s && (tableName . colTable) col == t && colName col == c) allCols
|
||||
@@ -0,0 +1,77 @@
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE TypeSynonymInstances #-}
|
||||
|
||||
module PostgREST.Error (PgError, pgErrResponse, errResponse) where
|
||||
|
||||
|
||||
import Data.Aeson ((.=))
|
||||
import qualified Data.Aeson as JSON
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.String.Utils (replace)
|
||||
import Data.Text (Text)
|
||||
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 Network.Wai (Response, responseLBS)
|
||||
|
||||
type PgError = H.SessionError P.Postgres
|
||||
|
||||
errResponse :: HT.Status -> Text -> Response
|
||||
errResponse status message = responseLBS status [(hContentType, "application/json")] (cs $ T.concat ["{\"message\":\"",message,"\"}"])
|
||||
|
||||
pgErrResponse :: PgError -> Response
|
||||
pgErrResponse e = responseLBS (httpStatus e)
|
||||
[(hContentType, "application/json")] (JSON.encode e)
|
||||
|
||||
instance JSON.ToJSON PgError where
|
||||
toJSON (H.TxError (P.ErroneousResult c m d h)) = JSON.object [
|
||||
"code" .= (cs c::T.Text),
|
||||
"message" .= (cs m::T.Text),
|
||||
"details" .= (fmap cs d::Maybe T.Text),
|
||||
"hint" .= (fmap cs h::Maybe T.Text)]
|
||||
toJSON (H.TxError (P.NoResult d)) = JSON.object [
|
||||
"message" .= ("No response from server"::T.Text),
|
||||
"details" .= (fmap cs d::Maybe T.Text)]
|
||||
toJSON (H.TxError (P.UnexpectedResult m)) = JSON.object ["message" .= m]
|
||||
toJSON (H.TxError P.NotInTransaction) = JSON.object [
|
||||
"message" .= ("Not in transaction"::T.Text)]
|
||||
toJSON (H.CxError (P.CantConnect d)) = JSON.object [
|
||||
"message" .= ("Can't connect to the database"::T.Text),
|
||||
"details" .= (fmap cs d::Maybe T.Text)]
|
||||
toJSON (H.CxError (P.UnsupportedVersion v)) = JSON.object [
|
||||
"message" .= ("Postgres version "++version++" is not supported") ]
|
||||
where version = replace "0" "." (show v)
|
||||
toJSON (H.ResultError m) = JSON.object ["message" .= m]
|
||||
|
||||
httpStatus :: PgError -> HT.Status
|
||||
httpStatus (H.TxError (P.ErroneousResult codeBS _ _ _)) =
|
||||
let code = cs codeBS in
|
||||
case code of
|
||||
'0':'8':_ -> HT.status503 -- pg connection err
|
||||
'0':'9':_ -> HT.status500 -- triggered action exception
|
||||
'0':'L':_ -> HT.status403 -- invalid grantor
|
||||
'0':'P':_ -> HT.status403 -- invalid role specification
|
||||
'2':'5':_ -> HT.status500 -- invalid tx state
|
||||
'2':'8':_ -> HT.status403 -- invalid auth specification
|
||||
'2':'D':_ -> HT.status500 -- invalid tx termination
|
||||
'3':'8':_ -> HT.status500 -- external routine exception
|
||||
'3':'9':_ -> HT.status500 -- external routine invocation
|
||||
'3':'B':_ -> HT.status500 -- savepoint exception
|
||||
'4':'0':_ -> HT.status500 -- tx rollback
|
||||
'5':'3':_ -> HT.status503 -- insufficient resources
|
||||
'5':'4':_ -> HT.status413 -- too complex
|
||||
'5':'5':_ -> HT.status500 -- obj not on prereq state
|
||||
'5':'7':_ -> HT.status500 -- operator intervention
|
||||
'5':'8':_ -> HT.status500 -- system error
|
||||
'F':'0':_ -> HT.status500 -- conf file error
|
||||
'H':'V':_ -> HT.status500 -- foreign data wrapper error
|
||||
'P':'0':_ -> HT.status500 -- PL/pgSQL Error
|
||||
'X':'X':_ -> HT.status500 -- internal Error
|
||||
"42P01" -> HT.status404 -- undefined table
|
||||
"42501" -> HT.status404 -- insufficient privilege
|
||||
_ -> HT.status400
|
||||
httpStatus (H.TxError (P.NoResult _)) = HT.status503
|
||||
httpStatus _ = HT.status500
|
||||
@@ -0,0 +1,97 @@
|
||||
{-# LANGUAGE CPP #-}
|
||||
|
||||
module Main where
|
||||
|
||||
|
||||
import PostgREST.App
|
||||
import PostgREST.Config (AppConfig (..),
|
||||
minimumPgVersion,
|
||||
prettyVersion,
|
||||
readOptions)
|
||||
import PostgREST.DbStructure
|
||||
import PostgREST.Error (PgError, pgErrResponse)
|
||||
import PostgREST.Middleware
|
||||
|
||||
import Control.Monad (unless, void)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Data.Aeson (encode)
|
||||
import Data.Functor.Identity
|
||||
import Data.Monoid ((<>))
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Text (Text)
|
||||
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as P
|
||||
import Network.Wai
|
||||
import Network.Wai.Handler.Warp hiding (Connection)
|
||||
import Network.Wai.Middleware.RequestLogger (logStdout)
|
||||
import System.IO (BufferMode (..),
|
||||
hSetBuffering, stderr,
|
||||
stdin, stdout)
|
||||
import Web.JWT (secret)
|
||||
|
||||
#ifndef mingw32_HOST_OS
|
||||
import System.Posix.Signals
|
||||
import Control.Concurrent (myThreadId)
|
||||
import Control.Exception.Base (throwTo, AsyncException(..))
|
||||
#endif
|
||||
|
||||
isServerVersionSupported :: H.Session P.Postgres IO Bool
|
||||
isServerVersionSupported = do
|
||||
Identity (row :: Text) <- H.tx Nothing $ H.singleEx [H.stmt|SHOW server_version_num|]
|
||||
return $ read (cs row) >= minimumPgVersion
|
||||
|
||||
hasqlError :: PgError -> IO a
|
||||
hasqlError = error . cs . encode
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
hSetBuffering stdout LineBuffering
|
||||
hSetBuffering stdin LineBuffering
|
||||
hSetBuffering stderr NoBuffering
|
||||
|
||||
conf <- readOptions
|
||||
let port = configPort conf
|
||||
|
||||
unless (secret "secret" /= configJwtSecret conf) $
|
||||
putStrLn "WARNING, running in insecure mode, JWT secret is the default value"
|
||||
Prelude.putStrLn $ "Listening on port " ++
|
||||
(show $ configPort conf :: String)
|
||||
|
||||
let pgSettings = P.StringSettings $ cs (configDatabase conf)
|
||||
appSettings = setPort port
|
||||
. setServerName (cs $ "postgrest/" <> prettyVersion)
|
||||
$ defaultSettings
|
||||
middle = logStdout . defaultMiddle
|
||||
|
||||
poolSettings <- maybe (fail "Improper session settings") return $
|
||||
H.poolSettings (fromIntegral $ configPool conf) 30
|
||||
pool :: H.Pool P.Postgres <- H.acquirePool pgSettings poolSettings
|
||||
|
||||
supportedOrError <- H.session pool isServerVersionSupported
|
||||
either hasqlError
|
||||
(\supported ->
|
||||
unless supported $
|
||||
error (
|
||||
"Cannot run in this PostgreSQL version, PostgREST needs at least "
|
||||
<> show minimumPgVersion)
|
||||
) supportedOrError
|
||||
|
||||
#ifndef mingw32_HOST_OS
|
||||
tid <- myThreadId
|
||||
void $ installHandler keyboardSignal (Catch $ do
|
||||
H.releasePool pool
|
||||
throwTo tid UserInterrupt
|
||||
) Nothing
|
||||
#endif
|
||||
|
||||
let txSettings = Just (H.ReadCommitted, Just True)
|
||||
dbOrError <- H.session pool $ H.tx txSettings $ getDbStructure (cs $ configSchema conf)
|
||||
dbStructure <- either hasqlError return dbOrError
|
||||
|
||||
runSettings appSettings $ middle $ \ req respond -> do
|
||||
time <- getPOSIXTime
|
||||
body <- strictRequestBody req
|
||||
resOrError <- liftIO $ H.session pool $ H.tx txSettings $
|
||||
runWithClaims conf time (app dbStructure conf body) req
|
||||
either (respond . pgErrResponse) respond resOrError
|
||||
@@ -0,0 +1,69 @@
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
module PostgREST.Middleware where
|
||||
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Text
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Time.Clock (NominalDiffTime)
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as P
|
||||
|
||||
import Network.HTTP.Types.Header (hAccept, hAuthorization)
|
||||
import Network.HTTP.Types.Status (status415, status400)
|
||||
import Network.Wai (Application, Request (..), Response,
|
||||
requestHeaders)
|
||||
import Network.Wai.Middleware.Cors (cors)
|
||||
import Network.Wai.Middleware.Gzip (def, gzip)
|
||||
import Network.Wai.Middleware.Static (only, staticPolicy)
|
||||
|
||||
import PostgREST.ApiRequest (pickContentType)
|
||||
import PostgREST.Auth (setRole, jwtClaims, claimsToSQL)
|
||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||
import PostgREST.Error (errResponse)
|
||||
|
||||
import Prelude hiding(concat)
|
||||
|
||||
import qualified Data.Vector as V
|
||||
import qualified Hasql.Backend as B
|
||||
import qualified Data.Map.Lazy as M
|
||||
|
||||
runWithClaims :: forall s. AppConfig -> NominalDiffTime ->
|
||||
(Request -> H.Tx P.Postgres s Response) ->
|
||||
Request -> H.Tx P.Postgres s Response
|
||||
runWithClaims conf time app req = do
|
||||
_ <- H.unitEx $ stmt setAnon
|
||||
case split (== ' ') (cs auth) of
|
||||
("Bearer" : tokenStr : _) ->
|
||||
case jwtClaims jwtSecret tokenStr time of
|
||||
Just claims ->
|
||||
if M.member "role" claims
|
||||
then do
|
||||
mapM_ H.unitEx $ stmt <$> claimsToSQL claims
|
||||
app req
|
||||
else invalidJWT
|
||||
_ -> invalidJWT
|
||||
_ -> app req
|
||||
where
|
||||
stmt c = B.Stmt c V.empty True
|
||||
hdrs = requestHeaders req
|
||||
jwtSecret = configJwtSecret conf
|
||||
auth = fromMaybe "" $ lookup hAuthorization hdrs
|
||||
anon = cs $ configAnonRole conf
|
||||
setAnon = setRole anon
|
||||
invalidJWT = return $ errResponse status400 "Invalid JWT"
|
||||
|
||||
unsupportedAccept :: Application -> Application
|
||||
unsupportedAccept app req respond =
|
||||
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 =
|
||||
gzip def
|
||||
. cors corsPolicy
|
||||
. staticPolicy (only [("favicon.ico", "static/favicon.ico")])
|
||||
. unsupportedAccept
|
||||
@@ -0,0 +1,112 @@
|
||||
module PostgREST.Parsers
|
||||
-- ( parseGetRequest
|
||||
-- )
|
||||
where
|
||||
|
||||
import Control.Applicative hiding ((<$>))
|
||||
import Data.Monoid
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Text (Text)
|
||||
import Data.Tree
|
||||
import PostgREST.Types
|
||||
import Text.ParserCombinators.Parsec hiding (many, (<|>))
|
||||
import PostgREST.QueryBuilder (operators)
|
||||
|
||||
pRequestSelect :: Text -> Parser ReadRequest
|
||||
pRequestSelect rootNodeName = do
|
||||
fieldTree <- pFieldForest
|
||||
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 (k, v) = (,) <$> path <*> (Filter <$> fld <*> op <*> val)
|
||||
where
|
||||
treePath = parse pTreePath ("failed to parser tree path (" ++ k ++ ")") k
|
||||
opVal = parse pOpValueExp ("failed to parse filter (" ++ v ++ ")") v
|
||||
path = fst <$> treePath
|
||||
fld = snd <$> treePath
|
||||
op = fst <$> opVal
|
||||
val = snd <$> opVal
|
||||
|
||||
ws :: Parser Text
|
||||
ws = cs <$> many (oneOf " \t")
|
||||
|
||||
lexeme :: Parser a -> Parser a
|
||||
lexeme p = ws *> p <* ws
|
||||
|
||||
pTreePath :: Parser (Path,Field)
|
||||
pTreePath = do
|
||||
p <- pFieldName `sepBy1` pDelimiter
|
||||
jp <- optionMaybe pJsonPath
|
||||
let pp = map cs p
|
||||
jpp = map cs <$> jp
|
||||
return (init pp, (last pp, jpp))
|
||||
|
||||
pFieldForest :: Parser [Tree SelectItem]
|
||||
pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
|
||||
|
||||
pFieldTree :: Parser (Tree SelectItem)
|
||||
pFieldTree = try (Node <$> pSelect <*> between (char '{') (char '}') pFieldForest)
|
||||
<|> Node <$> pSelect <*> pure []
|
||||
|
||||
pStar :: Parser Text
|
||||
pStar = cs <$> (string "*" *> pure ("*"::String))
|
||||
|
||||
pFieldName :: Parser Text
|
||||
pFieldName = cs <$> (many1 (letter <|> digit <|> oneOf "_")
|
||||
<?> "field name (* or [a..z0..9_])")
|
||||
|
||||
pJsonPathStep :: Parser Text
|
||||
pJsonPathStep = cs <$> try (string "->" *> pFieldName)
|
||||
|
||||
pJsonPath :: Parser [Text]
|
||||
pJsonPath = (++) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldName) )
|
||||
|
||||
pField :: Parser Field
|
||||
pField = lexeme $ (,) <$> pFieldName <*> optionMaybe pJsonPath
|
||||
|
||||
pSelect :: Parser SelectItem
|
||||
pSelect = lexeme $
|
||||
try ((,) <$> pField <*>((cs <$>) <$> optionMaybe (string "::" *> many letter)) )
|
||||
<|> do
|
||||
s <- pStar
|
||||
return ((s, Nothing), Nothing)
|
||||
|
||||
pOperator :: Parser Operator
|
||||
pOperator = cs <$> (pOp <?> "operator (eq, gt, ...)")
|
||||
where pOp = foldl (<|>) empty $ map (try . string . cs . fst) operators
|
||||
|
||||
pValue :: Parser FValue
|
||||
pValue = VText <$> (cs <$> many anyChar)
|
||||
|
||||
pDelimiter :: Parser Char
|
||||
pDelimiter = char '.' <?> "delimiter (.)"
|
||||
|
||||
pOperatiorWithNegation :: Parser Operator
|
||||
pOperatiorWithNegation = try ( (<>) <$> ( cs <$> string "not." ) <*> pOperator) <|> pOperator
|
||||
|
||||
pOpValueExp :: Parser (Operator, FValue)
|
||||
pOpValueExp = (,) <$> pOperatiorWithNegation <*> (pDelimiter *> pValue)
|
||||
|
||||
pOrder :: Parser [OrderTerm]
|
||||
pOrder = lexeme pOrderTerm `sepBy` char ','
|
||||
|
||||
pOrderTerm :: Parser OrderTerm
|
||||
pOrderTerm =
|
||||
try ( do
|
||||
c <- pFieldName
|
||||
_ <- pDelimiter
|
||||
d <- (string "asc" *> pure OrderAsc)
|
||||
<|> (string "desc" *> pure OrderDesc)
|
||||
nls <- optionMaybe (pDelimiter *> (
|
||||
try(string "nullslast" *> pure OrderNullsLast)
|
||||
<|> try(string "nullsfirst" *> pure OrderNullsFirst)
|
||||
))
|
||||
return $ OrderTerm c d nls
|
||||
)
|
||||
<|> OrderTerm <$> (cs <$> pFieldName) <*> pure OrderAsc <*> pure Nothing
|
||||
@@ -0,0 +1,471 @@
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
{-|
|
||||
Module : PostgREST.QueryBuilder
|
||||
Description : PostgREST SQL generating functions.
|
||||
|
||||
This module provides functions to consume data types that
|
||||
represent database objects (e.g. Relation, Schema, SqlQuery)
|
||||
and produces SQL Statements.
|
||||
|
||||
Any function that outputs a SQL fragment should be in this module.
|
||||
-}
|
||||
module PostgREST.QueryBuilder (
|
||||
addRelations
|
||||
, addJoinConditions
|
||||
, asJson
|
||||
, callProc
|
||||
, createReadStatement
|
||||
, createWriteStatement
|
||||
, operators
|
||||
, pgFmtIdent
|
||||
, pgFmtLit
|
||||
, requestToQuery
|
||||
, requestToCountQuery
|
||||
, sourceCTEName
|
||||
, unquoted
|
||||
) where
|
||||
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Backend as B
|
||||
import qualified Hasql.Postgres as P
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
|
||||
import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset)
|
||||
import Control.Error (note, fromMaybe, mapMaybe)
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import Data.List (find, (\\))
|
||||
import Data.Monoid ((<>))
|
||||
import Data.Text (Text, intercalate, unwords, replace, isInfixOf, toLower, split)
|
||||
import qualified Data.Text as T (map, takeWhile)
|
||||
import Data.String.Conversions (cs)
|
||||
import Control.Applicative (empty, (<|>))
|
||||
import Control.Monad (join)
|
||||
import Data.Tree (Tree(..))
|
||||
import qualified Data.Vector as V
|
||||
import PostgREST.Types
|
||||
import qualified Data.Map as M
|
||||
import Text.Regex.TDFA ((=~))
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import Data.Scientific ( FPFormat (..)
|
||||
, formatScientific
|
||||
, isInteger
|
||||
)
|
||||
import Prelude hiding (unwords)
|
||||
import PostgREST.ApiRequest (PreferRepresentation (..))
|
||||
|
||||
type PStmt = H.Stmt P.Postgres
|
||||
instance Monoid PStmt where
|
||||
mappend (B.Stmt query params prep) (B.Stmt query' params' prep') =
|
||||
B.Stmt (query <> query') (params <> params') (prep && prep')
|
||||
mempty = B.Stmt "" empty True
|
||||
type StatementT = PStmt -> PStmt
|
||||
|
||||
createReadStatement :: SqlQuery -> SqlQuery -> NonnegRange -> Bool -> Bool -> Bool -> B.Stmt P.Postgres
|
||||
createReadStatement selectQuery countQuery range isSingle countTotal asCsv =
|
||||
B.Stmt (
|
||||
"WITH " <> sourceCTEName <> " AS (" <> selectQuery <> ") " <>
|
||||
"SELECT " <> intercalate ", " [
|
||||
countResultF <> " AS total_result_set",
|
||||
"pg_catalog.count(t) AS page_total",
|
||||
"null AS header",
|
||||
bodyF <> " AS body"
|
||||
] <>
|
||||
" FROM ( SELECT * FROM " <> sourceCTEName <> " " <> limitF range <> ") t"
|
||||
) V.empty True
|
||||
where
|
||||
countResultF = if countTotal then "("<>countQuery<>")" else "null"
|
||||
bodyF
|
||||
| asCsv = asCsvF
|
||||
| isSingle = asJsonSingleF
|
||||
| otherwise = asJsonF
|
||||
|
||||
createWriteStatement :: QualifiedIdentifier -> SqlQuery -> SqlQuery -> Bool -> PreferRepresentation ->
|
||||
[Text] -> Bool -> Payload -> B.Stmt P.Postgres
|
||||
createWriteStatement _ _ _ _ _ _ _ (PayloadParseError _) = undefined
|
||||
createWriteStatement _ _ mutateQuery _ None
|
||||
_ _ (PayloadJSON (UniformObjects rows)) =
|
||||
B.Stmt (
|
||||
"WITH " <> sourceCTEName <> " AS (" <> mutateQuery <> ") " <>
|
||||
"SELECT null, 0, null, null"
|
||||
) (V.singleton . B.encodeValue . JSON.Array . V.map JSON.Object $ rows) True
|
||||
createWriteStatement qi _ mutateQuery isSingle HeadersOnly
|
||||
pKeys _ (PayloadJSON (UniformObjects rows)) =
|
||||
B.Stmt (
|
||||
"WITH " <> sourceCTEName <> " AS (" <> mutateQuery <> " RETURNING " <> fromQi qi <> ".*" <> ") " <>
|
||||
"SELECT " <> intercalate ", " [
|
||||
"null AS total_result_set",
|
||||
"pg_catalog.count(t) AS page_total",
|
||||
if isSingle then locationF pKeys else "null",
|
||||
"null"
|
||||
] <>
|
||||
" FROM (SELECT 1 FROM " <> sourceCTEName <> ") t"
|
||||
) (V.singleton . B.encodeValue . JSON.Array . V.map JSON.Object $ rows) True
|
||||
createWriteStatement qi selectQuery mutateQuery isSingle Full
|
||||
pKeys asCsv (PayloadJSON (UniformObjects rows)) =
|
||||
B.Stmt (
|
||||
"WITH " <> sourceCTEName <> " AS (" <> mutateQuery <> " RETURNING " <> fromQi qi <> ".*" <> ") " <>
|
||||
"SELECT " <> intercalate ", " [
|
||||
"null AS total_result_set", -- when updateing it does not make sense
|
||||
"pg_catalog.count(t) AS page_total",
|
||||
if isSingle then locationF pKeys else "null" <> " AS header",
|
||||
bodyF <> " AS body"
|
||||
] <>
|
||||
" FROM ( "<>selectQuery<>") t"
|
||||
) (V.singleton . B.encodeValue . JSON.Array . V.map JSON.Object $ rows) True
|
||||
where
|
||||
bodyF
|
||||
| asCsv = asCsvF
|
||||
| isSingle = asJsonSingleF
|
||||
| otherwise = asJsonF
|
||||
|
||||
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either Text ReadRequest
|
||||
addRelations schema allRelations parentNode node@(Node readNode@(query, (name, _)) forest) =
|
||||
case parentNode of
|
||||
(Just (Node (Select{from=[parentTable]}, (_, _)) _)) -> Node <$> (addRel readNode <$> rel) <*> updatedForest
|
||||
where
|
||||
rel = note ("no relation between " <> parentTable <> " and " <> name)
|
||||
$ findRelationByTable schema name parentTable
|
||||
<|> findRelationByTable schema parentTable name
|
||||
<|> findRelationByColumn schema parentTable name
|
||||
addRel :: (ReadQuery, (NodeName, Maybe Relation)) -> Relation -> (ReadQuery, (NodeName, Maybe Relation))
|
||||
addRel (q, (n, _)) r = (q {from=fromRelation}, (n, Just r))
|
||||
where fromRelation = map (\t -> if t == n then tableName (relTable r) else t) (from q)
|
||||
|
||||
_ -> Node (query, (name, Nothing)) <$> updatedForest
|
||||
where
|
||||
updatedForest = mapM (addRelations schema allRelations (Just node)) forest
|
||||
-- Searches through all the relations and returns a match given the parameter conditions.
|
||||
-- Will only find a relation where both schemas are in the PostgREST schema.
|
||||
-- `findRelationByColumn` also does a ducktype check to see if the column name has any variation of `id` or `fk`. If so then the relation is returned as a match.
|
||||
findRelationByTable s t1 t2 =
|
||||
find (\r -> s == tableSchema (relTable r) && s == tableSchema (relFTable r) && t1 == tableName (relTable r) && t2 == tableName (relFTable r)) allRelations
|
||||
findRelationByColumn s t c =
|
||||
find (\r -> s == tableSchema (relTable r) && s == tableSchema (relFTable r) && t == tableName (relFTable r) && length (relFColumns r) == 1 && c `colMatches` (colName . head . relFColumns) r) allRelations
|
||||
where n `colMatches` rc = (cs ("^" <> rc <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (cs n :: BS.ByteString)
|
||||
|
||||
addJoinConditions :: Schema -> ReadRequest -> Either Text ReadRequest
|
||||
addJoinConditions schema (Node (query, (n, r)) forest) =
|
||||
case r of
|
||||
Nothing -> Node (updatedQuery, (n,r)) <$> updatedForest -- this is the root node
|
||||
Just rel@(Relation{relType=Child}) -> Node (addCond updatedQuery (getJoinConditions rel),(n,r)) <$> updatedForest
|
||||
Just (Relation{relType=Parent}) -> Node (updatedQuery, (n,r)) <$> updatedForest
|
||||
Just rel@(Relation{relType=Many, relLTable=(Just linkTable)}) ->
|
||||
Node (qq, (n, r)) <$> updatedForest
|
||||
where
|
||||
q = addCond updatedQuery (getJoinConditions rel)
|
||||
qq = q{from=tableName linkTable : from q}
|
||||
_ -> Left "unknown relation"
|
||||
where
|
||||
-- add parentTable and parentJoinConditions to the query
|
||||
updatedQuery = foldr (flip addCond) query parentJoinConditions
|
||||
where
|
||||
parentJoinConditions = map (getJoinConditions . snd) parents
|
||||
parents = mapMaybe (getParents . rootLabel) forest
|
||||
getParents (_, (tbl, Just rel@(Relation{relType=Parent}))) = Just (tbl, rel)
|
||||
getParents _ = Nothing
|
||||
updatedForest = mapM (addJoinConditions schema) forest
|
||||
addCond q con = q{flt_=con ++ flt_ q}
|
||||
|
||||
asJson :: StatementT
|
||||
asJson s = s {
|
||||
B.stmtTemplate =
|
||||
"array_to_json(coalesce(array_agg(row_to_json(t)), '{}'))::character varying from ("
|
||||
<> B.stmtTemplate s <> ") t" }
|
||||
|
||||
callProc :: QualifiedIdentifier -> JSON.Object -> PStmt
|
||||
callProc qi params = do
|
||||
let args = intercalate "," $ map assignment (HM.toList params)
|
||||
B.Stmt ("select * from " <> fromQi qi <> "(" <> args <> ")") empty True
|
||||
where
|
||||
assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v
|
||||
|
||||
operators :: [(Text, SqlFragment)]
|
||||
operators = [
|
||||
("eq", "="),
|
||||
("gte", ">="), -- has to be before gt (parsers)
|
||||
("gt", ">"),
|
||||
("lte", "<="), -- has to be before lt (parsers)
|
||||
("lt", "<"),
|
||||
("neq", "<>"),
|
||||
("like", "like"),
|
||||
("ilike", "ilike"),
|
||||
("in", "in"),
|
||||
("notin", "not in"),
|
||||
("isnot", "is not"), -- has to be before is (parsers)
|
||||
("is", "is"),
|
||||
("@@", "@@"),
|
||||
("@>", "@>"),
|
||||
("<@", "<@")
|
||||
]
|
||||
|
||||
pgFmtIdent :: SqlFragment -> SqlFragment
|
||||
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ cs x) <> "\""
|
||||
|
||||
pgFmtLit :: SqlFragment -> SqlFragment
|
||||
pgFmtLit x =
|
||||
let trimmed = trimNullChars x
|
||||
escaped = "'" <> replace "'" "''" trimmed <> "'"
|
||||
slashed = replace "\\" "\\\\" escaped in
|
||||
if "\\\\" `isInfixOf` escaped
|
||||
then "E" <> slashed
|
||||
else slashed
|
||||
|
||||
requestToCountQuery :: Schema -> DbRequest -> SqlQuery
|
||||
requestToCountQuery _ (DbMutate _) = undefined
|
||||
requestToCountQuery schema (DbRead (Node (Select _ _ conditions _, (mainTbl, _)) _)) =
|
||||
unwords [
|
||||
"SELECT pg_catalog.count(1)",
|
||||
"FROM ", fromQi $ QualifiedIdentifier schema mainTbl,
|
||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition (QualifiedIdentifier schema mainTbl)) localConditions )) `emptyOnNull` localConditions
|
||||
]
|
||||
where
|
||||
fn (Filter{value=VText _}) = True
|
||||
fn (Filter{value=VForeignKey _ _}) = False
|
||||
localConditions = filter fn conditions
|
||||
|
||||
requestToQuery :: Schema -> DbRequest -> SqlQuery
|
||||
requestToQuery _ (DbMutate (Insert _ (PayloadParseError _))) = undefined
|
||||
requestToQuery _ (DbMutate (Update _ (PayloadParseError _) _)) = undefined
|
||||
requestToQuery schema (DbRead (Node (Select colSelects tbls conditions ord, (nodeName, maybeRelation)) forest)) =
|
||||
query
|
||||
where
|
||||
-- TODO! the folloing helper functions are just to remove the "schema" part when the table is "source" which is the name
|
||||
-- of our WITH query part
|
||||
mainTbl = fromMaybe nodeName (tableName . relTable <$> maybeRelation)
|
||||
tblSchema tbl = if tbl == sourceCTEName then "" else schema
|
||||
qi = QualifiedIdentifier (tblSchema mainTbl) mainTbl
|
||||
toQi t = QualifiedIdentifier (tblSchema t) t
|
||||
query = unwords [
|
||||
"SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
|
||||
"FROM ", intercalate ", " (map (fromQi . toQi) tbls),
|
||||
unwords (map joinStr joins),
|
||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) localConditions )) `emptyOnNull` localConditions,
|
||||
orderF (fromMaybe [] ord)
|
||||
]
|
||||
orderF ts =
|
||||
if null ts
|
||||
then ""
|
||||
else "ORDER BY " <> clause
|
||||
where
|
||||
clause = intercalate "," (map queryTerm ts)
|
||||
queryTerm :: OrderTerm -> Text
|
||||
queryTerm t = " "
|
||||
<> cs (pgFmtColumn qi $ otTerm t) <> " "
|
||||
<> (cs.show) (otDirection t) <> " "
|
||||
<> maybe "" (cs.show) (otNullOrder t) <> " "
|
||||
(joins, selects) = foldr getQueryParts ([],[]) forest
|
||||
parentTables = map snd joins
|
||||
parentConditions = join $ map (( `filter` conditions ) . filterParentConditions) parentTables
|
||||
localConditions = conditions \\ parentConditions
|
||||
joinStr :: (SqlFragment, TableName) -> SqlFragment
|
||||
joinStr (sql, t) = "LEFT OUTER JOIN " <> sql <> " ON " <>
|
||||
intercalate " AND " ( map (pgFmtCondition qi ) joinConditions )
|
||||
where
|
||||
joinConditions = filter (filterParentConditions t) conditions
|
||||
filterParentConditions parentTable (Filter _ _ (VForeignKey (QualifiedIdentifier "" t) _)) = parentTable == t
|
||||
filterParentConditions _ _ = False
|
||||
getQueryParts :: Tree ReadNode -> ([(SqlFragment, TableName)], [SqlFragment]) -> ([(SqlFragment,TableName)], [SqlFragment])
|
||||
getQueryParts (Node n@(_, (name, Just (Relation {relType=Child,relTable=Table{tableName=table}}))) forst) (j,s) = (j,sel:s)
|
||||
where
|
||||
sel = "COALESCE(("
|
||||
<> "SELECT array_to_json(array_agg(row_to_json("<>table<>"))) "
|
||||
<> "FROM (" <> subquery <> ") " <> table
|
||||
<> "), '[]') AS " <> pgFmtIdent name
|
||||
where subquery = requestToQuery schema (DbRead (Node n forst))
|
||||
getQueryParts (Node n@(_, (name, Just (Relation {relType=Parent,relTable=Table{tableName=table}}))) forst) (j,s) = (joi:j,sel:s)
|
||||
where
|
||||
sel = "row_to_json(" <> table <> ".*) AS "<>pgFmtIdent name --TODO must be singular
|
||||
joi = ("( " <> subquery <> " ) AS " <> table, table)
|
||||
where subquery = requestToQuery schema (DbRead (Node n forst))
|
||||
getQueryParts (Node n@(_, (name, Just (Relation {relType=Many,relTable=Table{tableName=table}}))) forst) (j,s) = (j,sel:s)
|
||||
where
|
||||
sel = "COALESCE (("
|
||||
<> "SELECT array_to_json(array_agg(row_to_json("<>table<>"))) "
|
||||
<> "FROM (" <> subquery <> ") " <> table
|
||||
<> "), '[]') AS " <> pgFmtIdent name
|
||||
where subquery = requestToQuery schema (DbRead (Node n forst))
|
||||
--the following is just to remove the warning
|
||||
--getQueryParts is not total but requestToQuery is called only after addJoinConditions which ensures the only
|
||||
--posible relations are Child Parent Many
|
||||
getQueryParts (Node (_,(_,Nothing)) _) _ = undefined
|
||||
requestToQuery schema (DbMutate (Insert mainTbl (PayloadJSON (UniformObjects rows)))) =
|
||||
let qi = QualifiedIdentifier schema mainTbl
|
||||
cols = map pgFmtIdent $ fromMaybe [] (HM.keys <$> (rows V.!? 0))
|
||||
colsString = intercalate ", " cols in
|
||||
unwords [
|
||||
"INSERT INTO ", fromQi qi,
|
||||
" (" <> colsString <> ")" <>
|
||||
" SELECT " <> colsString <>
|
||||
" FROM json_populate_recordset(null::" , fromQi qi, ", ?)"
|
||||
]
|
||||
requestToQuery schema (DbMutate (Update mainTbl (PayloadJSON (UniformObjects rows)) conditions)) =
|
||||
case rows V.!? 0 of
|
||||
Just obj ->
|
||||
let assignments = map
|
||||
(\(k,v) -> pgFmtIdent k <> "=" <> insertableValue v) $ HM.toList obj in
|
||||
unwords [
|
||||
"UPDATE ", fromQi qi,
|
||||
" SET " <> intercalate "," assignments <> " ",
|
||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions
|
||||
]
|
||||
Nothing -> undefined
|
||||
where
|
||||
qi = QualifiedIdentifier schema mainTbl
|
||||
|
||||
requestToQuery schema (DbMutate (Delete mainTbl conditions)) =
|
||||
query
|
||||
where
|
||||
qi = QualifiedIdentifier schema mainTbl
|
||||
query = unwords [
|
||||
"DELETE FROM ", fromQi qi,
|
||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions
|
||||
]
|
||||
|
||||
sourceCTEName :: SqlFragment
|
||||
sourceCTEName = "pg_source"
|
||||
|
||||
unquoted :: JSON.Value -> Text
|
||||
unquoted (JSON.String t) = t
|
||||
unquoted (JSON.Number n) =
|
||||
cs $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
|
||||
unquoted (JSON.Bool b) = cs . show $ b
|
||||
unquoted v = cs $ JSON.encode v
|
||||
|
||||
-- private functions
|
||||
asCsvF :: SqlFragment
|
||||
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
|
||||
where
|
||||
asCsvHeaderF =
|
||||
"(SELECT string_agg(a.k, ',')" <>
|
||||
" FROM (" <>
|
||||
" SELECT json_object_keys(r)::TEXT as k" <>
|
||||
" FROM ( " <>
|
||||
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
|
||||
" ) s" <>
|
||||
" ) a" <>
|
||||
")"
|
||||
asCsvBodyF = "coalesce(string_agg(substring(t::text, 2, length(t::text) - 2), '\n'), '')"
|
||||
|
||||
asJsonF :: SqlFragment
|
||||
asJsonF = "array_to_json(array_agg(row_to_json(t)))::character varying"
|
||||
|
||||
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
||||
asJsonSingleF = "string_agg(row_to_json(t)::text, ',')::character varying "
|
||||
|
||||
locationF :: [Text] -> SqlFragment
|
||||
locationF pKeys =
|
||||
"(" <>
|
||||
" 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'), '&')" <>
|
||||
" FROM s, json_each_text(s.r) AS json_data" <>
|
||||
(
|
||||
if null pKeys
|
||||
then ""
|
||||
else " WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')"
|
||||
) <>
|
||||
")"
|
||||
|
||||
limitF :: NonnegRange -> SqlFragment
|
||||
limitF r = "LIMIT " <> limit <> " OFFSET " <> offset
|
||||
where
|
||||
limit = maybe "ALL" (cs . show) $ rangeLimit r
|
||||
offset = cs . show $ rangeOffset r
|
||||
|
||||
fromQi :: QualifiedIdentifier -> SqlFragment
|
||||
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
|
||||
where
|
||||
n = qiName t
|
||||
s = qiSchema t
|
||||
|
||||
getJoinConditions :: Relation -> [Filter]
|
||||
getJoinConditions (Relation t cols ft fcs typ lt lc1 lc2) =
|
||||
case typ of
|
||||
Child -> 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)
|
||||
where
|
||||
s = if typ == Parent then "" else tableSchema t
|
||||
tN = tableName t
|
||||
ftN = tableName ft
|
||||
ltN = fromMaybe "" (tableName <$> lt)
|
||||
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}}))
|
||||
|
||||
emptyOnNull :: Text -> [a] -> Text
|
||||
emptyOnNull val x = if null x then "" else val
|
||||
|
||||
insertableValue :: JSON.Value -> SqlFragment
|
||||
insertableValue JSON.Null = "null"
|
||||
insertableValue v = (<> "::unknown") . pgFmtLit $ unquoted v
|
||||
|
||||
whiteList :: Text -> SqlFragment
|
||||
whiteList val = fromMaybe
|
||||
(cs (pgFmtLit val) <> "::unknown ")
|
||||
(find ((==) . toLower $ val) ["null","true","false"])
|
||||
|
||||
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
|
||||
pgFmtColumn table "*" = fromQi table <> ".*"
|
||||
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
|
||||
|
||||
pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
|
||||
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
|
||||
|
||||
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
|
||||
pgFmtSelectItem table (f@(_, jp), Nothing) = pgFmtField table f <> pgFmtAsJsonPath jp
|
||||
pgFmtSelectItem table (f@(_, jp), Just cast ) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAsJsonPath jp
|
||||
|
||||
pgFmtCondition :: QualifiedIdentifier -> Filter -> SqlFragment
|
||||
pgFmtCondition table (Filter (col,jp) ops val) =
|
||||
notOp <> " " <> sqlCol <> " " <> pgFmtOperator opCode <> " " <>
|
||||
if opCode `elem` ["is","isnot"] then whiteList (getInner val) else sqlValue
|
||||
where
|
||||
headPredicate:rest = split (=='.') ops
|
||||
hasNot caseTrue caseFalse = if headPredicate == "not" then caseTrue else caseFalse
|
||||
opCode = hasNot (head rest) headPredicate
|
||||
notOp = hasNot headPredicate ""
|
||||
sqlCol = case val of
|
||||
VText _ -> pgFmtColumn table col <> pgFmtJsonPath jp
|
||||
VForeignKey qi _ -> pgFmtColumn qi col
|
||||
sqlValue = valToStr val
|
||||
getInner v = case v of
|
||||
VText s -> s
|
||||
_ -> ""
|
||||
valToStr v = case v of
|
||||
VText s -> pgFmtValue opCode s
|
||||
VForeignKey (QualifiedIdentifier s _) (ForeignKey Column{colTable=Table{tableName=ft}, colName=fc}) -> pgFmtColumn qi fc
|
||||
where qi = QualifiedIdentifier (if ft == sourceCTEName then "" else s) ft
|
||||
_ -> ""
|
||||
|
||||
pgFmtValue :: Text -> Text -> SqlFragment
|
||||
pgFmtValue opCode val =
|
||||
case opCode of
|
||||
"like" -> unknownLiteral $ T.map star val
|
||||
"ilike" -> unknownLiteral $ T.map star val
|
||||
"in" -> "(" <> intercalate ", " (map unknownLiteral $ split (==',') val) <> ") "
|
||||
"notin" -> "(" <> intercalate ", " (map unknownLiteral $ split (==',') val) <> ") "
|
||||
"@@" -> "to_tsquery(" <> unknownLiteral val <> ") "
|
||||
_ -> unknownLiteral val
|
||||
where
|
||||
star c = if c == '*' then '%' else c
|
||||
unknownLiteral = (<> "::unknown ") . pgFmtLit
|
||||
|
||||
pgFmtOperator :: Text -> SqlFragment
|
||||
pgFmtOperator opCode = fromMaybe "=" $ M.lookup opCode operatorsMap
|
||||
where
|
||||
operatorsMap = M.fromList operators
|
||||
|
||||
pgFmtJsonPath :: Maybe JsonPath -> SqlFragment
|
||||
pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x
|
||||
pgFmtJsonPath (Just (x:xs)) = "->" <> pgFmtLit x <> pgFmtJsonPath ( Just xs )
|
||||
pgFmtJsonPath _ = ""
|
||||
|
||||
pgFmtAsJsonPath :: Maybe JsonPath -> SqlFragment
|
||||
pgFmtAsJsonPath Nothing = ""
|
||||
pgFmtAsJsonPath (Just xx) = " AS " <> last xx
|
||||
|
||||
trimNullChars :: Text -> Text
|
||||
trimNullChars = T.takeWhile (/= '\x0')
|
||||
@@ -0,0 +1,68 @@
|
||||
module PostgREST.RangeQuery (
|
||||
rangeParse
|
||||
, rangeRequested
|
||||
, rangeLimit
|
||||
, rangeOffset
|
||||
, restrictRange
|
||||
, NonnegRange
|
||||
) where
|
||||
|
||||
|
||||
import Control.Applicative
|
||||
import Network.HTTP.Types.Header
|
||||
import PostgREST.Types ()
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import Data.Ranged.Boundaries
|
||||
import Data.Ranged.Ranges
|
||||
|
||||
import Data.String.Conversions (cs)
|
||||
import Text.Read (readMaybe)
|
||||
import Text.Regex.TDFA ((=~))
|
||||
|
||||
import Data.Maybe (fromMaybe, listToMaybe)
|
||||
|
||||
import Prelude
|
||||
|
||||
type NonnegRange = Range Int
|
||||
|
||||
rangeParse :: BS.ByteString -> NonnegRange
|
||||
rangeParse range = do
|
||||
let rangeRegex = "^([0-9]+)-([0-9]*)$" :: BS.ByteString
|
||||
|
||||
case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of
|
||||
Just parsedRange ->
|
||||
let [_, from, to] = readMaybe . cs <$> parsedRange
|
||||
lower = fromMaybe emptyRange (rangeGeq <$> from)
|
||||
upper = fromMaybe (rangeGeq 0) (rangeLeq <$> to) in
|
||||
rangeIntersection lower upper
|
||||
Nothing -> rangeGeq 0
|
||||
|
||||
rangeRequested :: RequestHeaders -> NonnegRange
|
||||
rangeRequested = rangeParse . fromMaybe "" . lookup hRange
|
||||
|
||||
restrictRange :: Maybe Int -> NonnegRange -> NonnegRange
|
||||
restrictRange Nothing r = r
|
||||
restrictRange (Just limit) r =
|
||||
rangeIntersection r $
|
||||
Range BoundaryBelowAll (BoundaryAbove $ rangeOffset r + limit - 1)
|
||||
|
||||
rangeLimit :: NonnegRange -> Maybe Int
|
||||
rangeLimit range =
|
||||
case [rangeLower range, rangeUpper range] of
|
||||
[BoundaryBelow from, BoundaryAbove to] -> Just (1 + to - from)
|
||||
_ -> Nothing
|
||||
|
||||
rangeOffset :: NonnegRange -> Int
|
||||
rangeOffset range =
|
||||
case rangeLower range of
|
||||
BoundaryBelow from -> from
|
||||
_ -> error "range without lower bound" -- should never happen
|
||||
|
||||
rangeGeq :: Int -> NonnegRange
|
||||
rangeGeq n =
|
||||
Range (BoundaryBelow n) BoundaryAboveAll
|
||||
|
||||
rangeLeq :: Int -> NonnegRange
|
||||
rangeLeq n =
|
||||
Range BoundaryBelowAll (BoundaryAbove n)
|
||||
@@ -0,0 +1,156 @@
|
||||
module PostgREST.Types where
|
||||
import Data.Text
|
||||
import Data.Tree
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.Vector as V
|
||||
import Data.Aeson
|
||||
|
||||
data DbStructure = DbStructure {
|
||||
dbTables :: [Table]
|
||||
, dbColumns :: [Column]
|
||||
, dbRelations :: [Relation]
|
||||
, dbPrimaryKeys :: [PrimaryKey]
|
||||
} deriving (Show, Eq)
|
||||
|
||||
type Schema = Text
|
||||
type TableName = Text
|
||||
type SqlQuery = Text
|
||||
type SqlFragment = Text
|
||||
type RequestBody = BL.ByteString
|
||||
|
||||
data Table = Table {
|
||||
tableSchema :: Schema
|
||||
, tableName :: TableName
|
||||
, tableInsertable :: Bool
|
||||
} deriving (Show, Ord)
|
||||
|
||||
data ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
|
||||
|
||||
data Column =
|
||||
Column {
|
||||
colTable :: Table
|
||||
, colName :: Text
|
||||
, colPosition :: Int
|
||||
, colNullable :: Bool
|
||||
, colType :: Text
|
||||
, colUpdatable :: Bool
|
||||
, colMaxLen :: Maybe Int
|
||||
, colPrecision :: Maybe Int
|
||||
, colDefault :: Maybe Text
|
||||
, colEnum :: [Text]
|
||||
, colFK :: Maybe ForeignKey
|
||||
}
|
||||
| Star { colTable :: Table }
|
||||
deriving (Show, Ord)
|
||||
|
||||
type Synonym = (Column,Column)
|
||||
|
||||
data PrimaryKey = PrimaryKey {
|
||||
pkTable :: Table
|
||||
, pkName :: Text
|
||||
} deriving (Show, Eq)
|
||||
|
||||
data OrderDirection = OrderAsc | OrderDesc deriving (Eq)
|
||||
instance Show OrderDirection where
|
||||
show OrderAsc = "asc"
|
||||
show OrderDesc = "desc"
|
||||
|
||||
data OrderNulls = OrderNullsFirst | OrderNullsLast deriving (Eq)
|
||||
instance Show OrderNulls where
|
||||
show OrderNullsFirst = "nulls first"
|
||||
show OrderNullsLast = "nulls last"
|
||||
|
||||
data OrderTerm = OrderTerm {
|
||||
otTerm :: Text
|
||||
, otDirection :: OrderDirection
|
||||
, otNullOrder :: Maybe OrderNulls
|
||||
} deriving (Show, Eq)
|
||||
|
||||
data QualifiedIdentifier = QualifiedIdentifier {
|
||||
qiSchema :: Schema
|
||||
, qiName :: TableName
|
||||
} deriving (Show, Eq)
|
||||
|
||||
|
||||
data RelationType = Child | Parent | Many deriving (Show, Eq)
|
||||
data Relation = Relation {
|
||||
relTable :: Table
|
||||
, relColumns :: [Column]
|
||||
, relFTable :: Table
|
||||
, relFColumns :: [Column]
|
||||
, relType :: RelationType
|
||||
, relLTable :: Maybe Table
|
||||
, relLCols1 :: Maybe [Column]
|
||||
, relLCols2 :: Maybe [Column]
|
||||
} deriving (Show, Eq)
|
||||
|
||||
-- | An array of JSON objects that has been verified to have
|
||||
-- the same keys in every object
|
||||
newtype UniformObjects = UniformObjects (V.Vector Object)
|
||||
deriving (Show, Eq)
|
||||
|
||||
-- | When Hasql supports the COPY command then we can
|
||||
-- have a special payload just for CSV, but until
|
||||
-- then CSV is converted to a JSON array.
|
||||
data Payload = PayloadJSON UniformObjects
|
||||
| PayloadParseError BS.ByteString
|
||||
deriving (Show, Eq)
|
||||
|
||||
type Operator = Text
|
||||
data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
|
||||
type FieldName = Text
|
||||
type JsonPath = [Text]
|
||||
type Field = (FieldName, Maybe JsonPath)
|
||||
type Cast = Text
|
||||
type NodeName = Text
|
||||
type SelectItem = (Field, Maybe Cast)
|
||||
type Path = [Text]
|
||||
data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter], order::Maybe [OrderTerm] } deriving (Show, Eq)
|
||||
data MutateQuery = Insert { in_::TableName, qPayload::Payload }
|
||||
| Delete { in_::TableName, where_::[Filter] }
|
||||
| Update { in_::TableName, qPayload::Payload, where_::[Filter] } deriving (Show, Eq)
|
||||
data Filter = Filter {field::Field, operator::Operator, value::FValue} deriving (Show, Eq)
|
||||
type ReadNode = (ReadQuery, (NodeName, Maybe Relation))
|
||||
type ReadRequest = Tree ReadNode
|
||||
type MutateRequest = MutateQuery
|
||||
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
|
||||
|
||||
|
||||
instance ToJSON Column where
|
||||
toJSON c = object [
|
||||
"schema" .= tableSchema t
|
||||
, "name" .= colName c
|
||||
, "position" .= colPosition c
|
||||
, "nullable" .= colNullable c
|
||||
, "type" .= colType c
|
||||
, "updatable" .= colUpdatable c
|
||||
, "maxLen" .= colMaxLen c
|
||||
, "precision" .= colPrecision c
|
||||
, "references".= colFK c
|
||||
, "default" .= colDefault c
|
||||
, "enum" .= colEnum c ]
|
||||
where
|
||||
t = colTable c
|
||||
|
||||
instance ToJSON ForeignKey where
|
||||
toJSON fk = object [
|
||||
"schema" .= tableSchema t
|
||||
, "table" .= tableName t
|
||||
, "column" .= colName c ]
|
||||
where
|
||||
c = fkCol fk
|
||||
t = colTable c
|
||||
|
||||
instance ToJSON Table where
|
||||
toJSON v = object [
|
||||
"schema" .= tableSchema v
|
||||
, "name" .= tableName v
|
||||
, "insertable" .= tableInsertable v ]
|
||||
|
||||
instance Eq Table where
|
||||
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
|
||||
|
||||
instance Eq Column where
|
||||
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
|
||||
_ == _ = False
|
||||
@@ -1,58 +0,0 @@
|
||||
module RangeQuery (
|
||||
rangeParse
|
||||
, rangeRequested
|
||||
, rangeLimit
|
||||
, rangeOffset
|
||||
, NonnegRange
|
||||
) where
|
||||
|
||||
import Control.Applicative
|
||||
import Network.HTTP.Types.Header
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
|
||||
import Data.Ranged.Boundaries
|
||||
import Data.Ranged.Ranges
|
||||
|
||||
import Data.String.Conversions (cs)
|
||||
import Text.Regex.TDFA ((=~))
|
||||
import Text.Read (readMaybe)
|
||||
|
||||
import Data.Maybe (fromMaybe, listToMaybe)
|
||||
|
||||
type NonnegRange = Range Int
|
||||
|
||||
rangeParse :: BS.ByteString -> Maybe NonnegRange
|
||||
rangeParse range = do
|
||||
let rangeRegex = "^([0-9]+)-([0-9]*)$" :: BS.ByteString
|
||||
|
||||
parsedRange <- listToMaybe (range =~ rangeRegex :: [[BS.ByteString]])
|
||||
|
||||
let [_, from, to] = readMaybe . cs <$> parsedRange
|
||||
let lower = fromMaybe emptyRange (rangeGeq <$> from)
|
||||
let upper = fromMaybe (rangeGeq 0) (rangeLeq <$> to)
|
||||
|
||||
return $ rangeIntersection lower upper
|
||||
|
||||
rangeRequested :: RequestHeaders -> Maybe NonnegRange
|
||||
rangeRequested = (rangeParse =<<) . lookup hRange
|
||||
|
||||
rangeLimit :: NonnegRange -> Maybe Int
|
||||
rangeLimit range =
|
||||
case [rangeLower range, rangeUpper range]
|
||||
of [BoundaryBelow from, BoundaryAbove to] -> Just (1 + to - from)
|
||||
_ -> Nothing
|
||||
|
||||
rangeOffset :: NonnegRange -> Int
|
||||
rangeOffset range =
|
||||
case rangeLower range
|
||||
of BoundaryBelow from -> from
|
||||
_ -> error "range without lower bound" -- should never happen
|
||||
|
||||
rangeGeq :: Int -> NonnegRange
|
||||
rangeGeq n =
|
||||
Range (BoundaryBelow n) BoundaryAboveAll
|
||||
|
||||
rangeLeq :: Int -> NonnegRange
|
||||
rangeLeq n =
|
||||
Range BoundaryBelowAll (BoundaryAbove n)
|
||||
@@ -1,57 +0,0 @@
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
module Types where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import Data.Aeson.Types (Parser)
|
||||
|
||||
import Data.Scientific (floatingOrInteger)
|
||||
import Data.HashMap.Strict (foldlWithKey')
|
||||
import Data.Text (Text)
|
||||
import Data.Text.Encoding (decodeUtf8)
|
||||
import Data.Time.Calendar (showGregorian)
|
||||
import Control.Monad (mzero)
|
||||
|
||||
instance JSON.FromJSON SqlValue where
|
||||
parseJSON (JSON.Number n) = return $ either toSql iToSql (floatingOrInteger n :: Either Double Int)
|
||||
parseJSON (JSON.String s) = return $ toSql s
|
||||
parseJSON (JSON.Bool b) = return $ toSql b
|
||||
parseJSON JSON.Null = return SqlNull
|
||||
parseJSON (JSON.Object o) = return . toSql $ JSON.encode o
|
||||
parseJSON (JSON.Array a) = return . toSql $ JSON.encode a
|
||||
|
||||
instance JSON.ToJSON SqlValue where
|
||||
toJSON (SqlString s) = JSON.toJSON s
|
||||
toJSON (SqlByteString s) = JSON.toJSON $ decodeUtf8 s
|
||||
toJSON (SqlWord32 w) = JSON.toJSON w
|
||||
toJSON (SqlWord64 w) = JSON.toJSON w
|
||||
toJSON (SqlInt32 i) = JSON.toJSON i
|
||||
toJSON (SqlInt64 i) = JSON.toJSON i
|
||||
toJSON (SqlInteger i) = JSON.toJSON i
|
||||
toJSON (SqlChar c) = JSON.toJSON c
|
||||
toJSON (SqlBool b) = JSON.toJSON b
|
||||
toJSON (SqlDouble n) = JSON.toJSON n
|
||||
toJSON (SqlRational n) = JSON.toJSON n
|
||||
toJSON (SqlLocalDate d) = JSON.toJSON $ showGregorian d
|
||||
toJSON (SqlLocalTimeOfDay t) = JSON.toJSON $ show t
|
||||
toJSON (SqlLocalTime t) = JSON.toJSON $ show t
|
||||
toJSON SqlNull = JSON.Null
|
||||
toJSON x = JSON.toJSON $ show x
|
||||
|
||||
|
||||
newtype SqlRow = SqlRow {getRow :: [(Text, SqlValue)] } deriving (Show)
|
||||
|
||||
sqlRowColumns :: SqlRow -> [Text]
|
||||
sqlRowColumns = map fst . getRow
|
||||
|
||||
sqlRowValues :: SqlRow -> [SqlValue]
|
||||
sqlRowValues = map snd . getRow
|
||||
|
||||
instance JSON.FromJSON SqlRow where
|
||||
parseJSON (JSON.Object m) = foldlWithKey' add (return $ SqlRow []) m
|
||||
where
|
||||
add :: Parser SqlRow -> Text -> JSON.Value -> Parser SqlRow
|
||||
add parser k v = do
|
||||
SqlRow l <- parser
|
||||
sqlV <- JSON.parseJSON v
|
||||
return . SqlRow $ (k, sqlV) : l
|
||||
parseJSON _ = mzero
|
||||
@@ -0,0 +1,7 @@
|
||||
flags: {}
|
||||
packages:
|
||||
- '.'
|
||||
extra-deps:
|
||||
- Ranged-sets-0.3.0
|
||||
- packdeps-0.4.1
|
||||
resolver: nightly-2015-10-27
|
||||
+61
-14
@@ -1,4 +1,3 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
module Feature.AuthSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
@@ -7,19 +6,67 @@ import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Network.HTTP.Types
|
||||
|
||||
import Hasql as H
|
||||
import Hasql.Postgres as P
|
||||
|
||||
import SpecHelper
|
||||
import PostgREST.Types (DbStructure(..))
|
||||
-- }}}
|
||||
|
||||
spec :: Spec
|
||||
spec = around withApp $ describe "authorization" $ do
|
||||
it "hides tables that anonymous does not own" $
|
||||
get "/authors_only" `shouldRespondWith` 404
|
||||
it "indicates login failure" $ do
|
||||
let auth = authHeader "postgrest_test_author" "fakefake"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 401
|
||||
it "allows users with permissions to see their tables" $ do
|
||||
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||
let auth = authHeader "jdoe" "1234"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
spec :: DbStructure -> H.Pool P.Postgres -> Spec
|
||||
spec struct pool = around (withApp cfgDefault struct pool)
|
||||
$ describe "authorization" $ do
|
||||
|
||||
it "hides tables that anonymous does not own" $
|
||||
get "/authors_only" `shouldRespondWith` 404
|
||||
|
||||
it "returns jwt functions as jwt tokens" $
|
||||
post "/rpc/login" [json| { "id": "jdoe", "pass": "1234" } |]
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/json"]
|
||||
}
|
||||
|
||||
it "allows users with permissions to see their tables" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "works with tokens which have extra fields" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIiwia2V5MSI6InZhbHVlMSIsImtleTIiOiJ2YWx1ZTIiLCJrZXkzIjoidmFsdWUzIiwiYSI6MSwiYiI6MiwiYyI6M30.GfydCh-F4wnM379xs0n1zUgalwJIsb6YoBapCo8HlFk"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
it "succeeds with an unexpired token" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.QaPPLWTuyydMu_q7H4noMT7Lk6P4muet1OpJXF6ofhc"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "fails with an expired token" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.enk_qZ_u6gZsXY4R8bREKB_HNExRpM0lIWSLktk9JJQ"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 400
|
||||
|
||||
it "hides tables from users with invalid JWT" $ do
|
||||
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 400
|
||||
|
||||
it "should fail when jwt contains no claims" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MKYc_lOECtB0LJOiykilAdlHodB-I0_id2qHKq35dmc"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 400
|
||||
|
||||
it "hides tables from users with JWT that contain no claims about role" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.zyohGMnrDy4_8eJTl6I2AUXO3MeCCiwR24aGWRkTE9o"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 400
|
||||
|
||||
it "recovers after 400 error with logged in user" $ do
|
||||
_ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||
_ <- request methodPost "/rpc/problem" [auth] ""
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
@@ -6,13 +6,17 @@ import Test.Hspec.Wai
|
||||
import Network.Wai.Test (SResponse(simpleHeaders, simpleBody))
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
|
||||
import Hasql as H
|
||||
import Hasql.Postgres as P
|
||||
|
||||
import SpecHelper
|
||||
import PostgREST.Types (DbStructure(..))
|
||||
|
||||
import Network.HTTP.Types
|
||||
-- }}}
|
||||
|
||||
spec :: Spec
|
||||
spec = around withApp $ describe "CORS" $ do
|
||||
spec :: DbStructure -> H.Pool P.Postgres -> Spec
|
||||
spec struct pool = around (withApp cfgDefault struct pool) $ describe "CORS" $ do
|
||||
let preflightHeaders = [
|
||||
("Accept", "*/*"),
|
||||
("Origin", "http://example.com"),
|
||||
@@ -22,7 +26,7 @@ spec = around withApp $ describe "CORS" $ do
|
||||
("Host", "localhost:3000"),
|
||||
("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:32.0) Gecko/20100101 Firefox/32.0"),
|
||||
("Origin", "http://localhost:8000"),
|
||||
("Accept", "text/plain, */*; q=0.01"),
|
||||
("Accept", "text/csv, */*; q=0.01"),
|
||||
("Accept-Language", "en-US,en;q=0.5"),
|
||||
("Accept-Encoding", "gzip, deflate"),
|
||||
("Referer", "http://localhost:8000/"),
|
||||
@@ -41,7 +45,7 @@ spec = around withApp $ describe "CORS" $ do
|
||||
"true"
|
||||
respHeaders `shouldSatisfy` matchHeader
|
||||
"Access-Control-Allow-Methods"
|
||||
"GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD"
|
||||
"GET, POST, PATCH, DELETE, OPTIONS, HEAD"
|
||||
respHeaders `shouldSatisfy` matchHeader
|
||||
"Access-Control-Allow-Headers"
|
||||
"Authentication, Foo, Bar, Accept, Accept-Language, Content-Language"
|
||||
@@ -53,6 +57,14 @@ spec = around withApp $ describe "CORS" $ do
|
||||
r <- request methodOptions "/" preflightHeaders ""
|
||||
liftIO $ simpleBody r `shouldBe` ""
|
||||
|
||||
describe "regular request" $
|
||||
it "exposes necesssary response headers" $ do
|
||||
r <- request methodGet "/items" [("Origin", "http://example.com")] ""
|
||||
liftIO $ simpleHeaders r `shouldSatisfy` matchHeader
|
||||
"Access-Control-Expose-Headers"
|
||||
"Content-Encoding, Content-Location, Content-Range, Content-Type, \
|
||||
\Date, Location, Server, Transfer-Encoding, Range-Unit"
|
||||
|
||||
describe "postflight request" $
|
||||
it "allows INFO body through even with CORS request headers present" $ do
|
||||
r <- request methodOptions "/items" normalCors ""
|
||||
|
||||
@@ -2,13 +2,19 @@ module Feature.DeleteSpec where
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Text.Heredoc
|
||||
|
||||
import Hasql as H
|
||||
import Hasql.Postgres as P
|
||||
|
||||
import SpecHelper
|
||||
import PostgREST.Types (DbStructure(..))
|
||||
|
||||
import Network.HTTP.Types
|
||||
|
||||
spec :: Spec
|
||||
spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable "items")
|
||||
. around withApp $
|
||||
spec :: DbStructure -> H.Pool P.Postgres -> Spec
|
||||
spec struct pool = beforeAll resetDb
|
||||
. around (withApp cfgDefault struct pool) $
|
||||
describe "Deleting" $ do
|
||||
context "existing record" $ do
|
||||
it "succeeds with 204 and deletion count" $
|
||||
@@ -23,7 +29,7 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
||||
_ <- request methodDelete "/items?id=lt.15" [] ""
|
||||
get "/items"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "[{\"id\":15}]"
|
||||
matchBody = Just [str|[{"id":15}]|]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/1"]
|
||||
}
|
||||
|
||||
+234
-32
@@ -1,36 +1,60 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
module Feature.InsertSpec where
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Network.Wai.Test (SResponse(simpleBody,simpleHeaders,simpleStatus))
|
||||
|
||||
import Hasql as H
|
||||
import Hasql.Postgres as P
|
||||
|
||||
import SpecHelper
|
||||
import PostgREST.Types (DbStructure(..))
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import Data.Maybe (fromJust)
|
||||
import Text.Heredoc
|
||||
import Network.HTTP.Types.Header
|
||||
import Network.HTTP.Types
|
||||
import Control.Monad (replicateM_)
|
||||
|
||||
import TestTypes(IncPK(..), CompoundPK(..))
|
||||
|
||||
--import Debug.Trace
|
||||
|
||||
spec :: Spec
|
||||
spec = around withApp $ do
|
||||
spec :: DbStructure -> H.Pool P.Postgres -> Spec
|
||||
spec struct pool = beforeAll_ resetDb $ around (withApp cfgDefault struct pool) $ do
|
||||
describe "Posting new record" $ do
|
||||
after_ (clearTable "menagerie") . it "accepts disparate json types" $ do
|
||||
p <- post "/menagerie"
|
||||
[json| {
|
||||
"integer": 13, "double": 3.14159, "varchar": "testing!"
|
||||
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
||||
, "enum": "foo"
|
||||
} |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` ""
|
||||
simpleStatus p `shouldBe` created201
|
||||
after_ (clearTable "menagerie") . context "disparate csv types" $ do
|
||||
it "accepts disparate json types" $ do
|
||||
p <- post "/menagerie"
|
||||
[json| {
|
||||
"integer": 13, "double": 3.14159, "varchar": "testing!"
|
||||
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
||||
, "enum": "foo"
|
||||
} |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` ""
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
it "filters columns in result using &select" $
|
||||
request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=representation")]
|
||||
[json| {
|
||||
"integer": 14, "double": 3.14159, "varchar": "testing!"
|
||||
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
||||
, "enum": "foo"
|
||||
} |] `shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [str|{"integer":14,"varchar":"testing!"}|]
|
||||
, matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "application/json"]
|
||||
}
|
||||
|
||||
it "includes related data after insert" $
|
||||
request methodPost "/projects?select=id,name,clients{id,name}" [("Prefer", "return=representation")]
|
||||
[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
|
||||
, matchHeaders = ["Content-Type" <:> "application/json", "Location" <:> "/projects?id=eq.6"]
|
||||
}
|
||||
|
||||
|
||||
context "with no pk supplied" $ do
|
||||
context "into a table with auto-incrementing pk" . after_ (clearTable "auto_incrementing_pk") $
|
||||
@@ -52,7 +76,7 @@ spec = around withApp $ do
|
||||
post "/simple_pk" [json| { "extra":"foo"} |]
|
||||
`shouldRespondWith` 400
|
||||
|
||||
context "into a table with no pk" . after_ (clearTable "no_pk") $
|
||||
context "into a table with no pk" . after_ (clearTable "no_pk") $ do
|
||||
it "succeeds with 201 and a link including all fields" $ do
|
||||
p <- post "/no_pk" [json| { "a":"foo", "b":"bar" } |]
|
||||
liftIO $ do
|
||||
@@ -60,6 +84,33 @@ spec = around withApp $ do
|
||||
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.foo&b=eq.bar"
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
it "returns full details of inserted record if asked" $ do
|
||||
p <- request methodPost "/no_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { "a":"bar", "b":"baz" } |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` [json| { "a":"bar", "b":"baz" } |]
|
||||
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.bar&b=eq.baz"
|
||||
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
|
||||
p <- request methodPost "/no_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { "a":null, "b":"foo" } |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` [json| { "a":null, "b":"foo" } |]
|
||||
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=is.null&b=eq.foo"
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
context "with compound pk supplied" . after_ (clearTable "compound_pk") $
|
||||
it "builds response location header appropriately" $
|
||||
post "/compound_pk" [json| { "k1":12, "k2":42 } |]
|
||||
@@ -71,24 +122,126 @@ spec = around withApp $ do
|
||||
|
||||
context "with invalid json payload" $
|
||||
it "fails with 400 and error" $
|
||||
post "/simple_pk" "}{ x = 2"
|
||||
post "/simple_pk" "}{ x = 2" `shouldRespondWith` 400
|
||||
|
||||
context "jsonb" . after_ (clearTable "json") $ do
|
||||
it "serializes nested object" $ do
|
||||
let inserted = [json| { "data": { "foo":"bar" } } |]
|
||||
request methodPost "/json"
|
||||
[("Prefer", "return=representation")]
|
||||
inserted
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| {"message":"Failed to parse JSON payload. Failed reading: satisfy"} |]
|
||||
, matchStatus = 400
|
||||
, matchHeaders = []
|
||||
matchBody = Just inserted
|
||||
, matchStatus = 201
|
||||
, matchHeaders = ["Location" <:> [str|/json?data=eq.{"foo":"bar"}|]]
|
||||
}
|
||||
|
||||
-- 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
|
||||
let inserted = [json| { "data": [1,2,3] } |]
|
||||
request methodPost "/json"
|
||||
[("Prefer", "return=representation")]
|
||||
inserted
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just inserted
|
||||
, matchStatus = 201
|
||||
, matchHeaders = ["Location" <:> [str|/json?data=eq.[1,2,3]|]]
|
||||
}
|
||||
-- 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
|
||||
|
||||
after_ (clearTable "menagerie") . context "disparate csv types" $
|
||||
it "succeeds with multipart response" $ do
|
||||
pendingWith "Decide on what to do with CSV insert"
|
||||
let inserted = [str|integer,double,varchar,boolean,date,money,enum
|
||||
|13,3.14159,testing!,false,1900-01-01,$3.99,foo
|
||||
|12,0.1,a string,true,1929-10-01,12,bar
|
||||
|]
|
||||
request methodPost "/menagerie" [("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")] inserted
|
||||
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just inserted
|
||||
, matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv"]
|
||||
}
|
||||
-- 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
|
||||
it "returns full details of inserted record" $
|
||||
request methodPost "/no_pk"
|
||||
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||
"a,b\nbar,baz"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "a,b\nbar,baz"
|
||||
, matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv",
|
||||
"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" $
|
||||
request methodPost "/no_pk"
|
||||
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||
"a,b\nNULL,foo"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "a,b\n,foo"
|
||||
, matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv",
|
||||
"Location" <:> "/no_pk?a=is.null&b=eq.foo"]
|
||||
}
|
||||
|
||||
|
||||
after_ (clearTable "no_pk") . context "with wrong number of columns" $
|
||||
it "fails for too few" $ do
|
||||
p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
|
||||
liftIO $ simpleStatus p `shouldBe` badRequest400
|
||||
-- it does not fail because the extra columns are ignored
|
||||
-- it "fails for too many" $ do
|
||||
-- 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 "to unkonwn uri" $
|
||||
it "gives a 404" $
|
||||
it "gives a 404" $ do
|
||||
pendingWith "Decide on PUT usefullness"
|
||||
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" $
|
||||
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
|
||||
@@ -96,13 +249,15 @@ spec = around withApp $ do
|
||||
context "with a fully-specified primary key" $ do
|
||||
|
||||
context "not specifying every column in the table" $
|
||||
it "is rejected for lack of idempotence" $
|
||||
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
|
||||
@@ -119,6 +274,7 @@ spec = around withApp $ do
|
||||
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" []
|
||||
@@ -133,7 +289,8 @@ spec = around withApp $ do
|
||||
|
||||
context "with an auto-incrementing primary key" . after_ (clearTable "auto_incrementing_pk") $
|
||||
|
||||
it "succeeds with 204" $
|
||||
it "succeeds with 204" $ do
|
||||
pendingWith "Decide on PUT usefullness"
|
||||
request methodPut "/auto_incrementing_pk?id=eq.1" []
|
||||
[json| {
|
||||
"id":1,
|
||||
@@ -156,20 +313,23 @@ spec = around withApp $ do
|
||||
`shouldRespondWith` 404
|
||||
|
||||
context "on an empty table" $
|
||||
it "succeeds with no effect" $
|
||||
request methodPatch "/simple_pk" []
|
||||
it "indicates no records found to update" $
|
||||
request methodPatch "/empty_table" []
|
||||
[json| { "extra":20 } |]
|
||||
`shouldRespondWith` 204
|
||||
`shouldRespondWith` 404
|
||||
|
||||
context "in a nonempty table" . before_ (clearTable "items" >> createItems 15) .
|
||||
after_ (clearTable "items") $ do
|
||||
context "in a nonempty table" $ do
|
||||
it "can update a single item" $ do
|
||||
g <- get "/items?id=eq.42"
|
||||
liftIO $ simpleHeaders g
|
||||
`shouldSatisfy` matchHeader "Content-Range" "\\*/0"
|
||||
request methodPatch "/items?id=eq.1" []
|
||||
request methodPatch "/items?id=eq.2" []
|
||||
[json| { "id":42 } |]
|
||||
`shouldRespondWith` 204
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Nothing,
|
||||
matchStatus = 204,
|
||||
matchHeaders = ["Content-Range" <:> "0-0/1"]
|
||||
}
|
||||
g' <- get "/items?id=eq.42"
|
||||
liftIO $ simpleHeaders g'
|
||||
`shouldSatisfy` matchHeader "Content-Range" "0-0/1"
|
||||
@@ -185,3 +345,45 @@ spec = around withApp $ do
|
||||
g <- get "/auto_incrementing_pk?non_nullable_string=eq.c"
|
||||
liftIO $ simpleHeaders g
|
||||
`shouldSatisfy` matchHeader "Content-Range" "0-9/10"
|
||||
|
||||
it "can set a column to NULL" $ do
|
||||
_ <- post "/no_pk" [json| { a: "keepme", b: "nullme" } |]
|
||||
_ <- request methodPatch "/no_pk?b=eq.nullme" [] [json| { b: null } |]
|
||||
get "/no_pk?a=eq.keepme" `shouldRespondWith`
|
||||
[json| [{ a: "keepme", b: null }] |]
|
||||
|
||||
it "can update based on a computed column" $
|
||||
request methodPatch
|
||||
"/items?always_true=eq.false"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 100 } |]
|
||||
`shouldRespondWith` 404
|
||||
it "can provide a representation" $ do
|
||||
_ <- post "/items"
|
||||
[json| { id: 1 } |]
|
||||
request methodPatch
|
||||
"/items?id=eq.1"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 99 } |]
|
||||
`shouldRespondWith` [json| [{id:99}] |]
|
||||
|
||||
describe "Row level permission" $
|
||||
it "set user_id when inserting rows" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||
_ <- post "/postgrest/users" [json| { "id":"jroe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||
|
||||
p1 <- request methodPost "/authors_only"
|
||||
[ auth, ("Prefer", "return=representation") ]
|
||||
[json| { "secret": "nyancat" } |]
|
||||
liftIO $ do
|
||||
simpleBody p1 `shouldBe` [str|{"owner":"jdoe","secret":"nyancat"}|]
|
||||
simpleStatus p1 `shouldBe` created201
|
||||
|
||||
p2 <- request methodPost "/authors_only"
|
||||
-- jwt token for jroe
|
||||
[ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.YuF_VfmyIxWyuceT7crnNKEprIYXsJAyXid3rjPjIow", ("Prefer", "return=representation") ]
|
||||
[json| { "secret": "lolcat", "owner": "hacker" } |]
|
||||
liftIO $ do
|
||||
simpleBody p2 `shouldBe` [str|{"owner":"jroe","secret":"lolcat"}|]
|
||||
simpleStatus p2 `shouldBe` created201
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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 Hasql as H
|
||||
import Hasql.Postgres as P
|
||||
|
||||
import SpecHelper
|
||||
import PostgREST.Types (DbStructure(..))
|
||||
|
||||
spec :: DbStructure -> H.Pool P.Postgres -> Spec
|
||||
spec struct pool =
|
||||
beforeAll resetDb
|
||||
. around (withApp (cfgLimitRows 3) struct pool) $
|
||||
describe "Requesting many items with server limits enabled" $ do
|
||||
it "restricts results" $
|
||||
get "/items"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"id":1},{"id":2},{"id":3}] |]
|
||||
, matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/15"]
|
||||
}
|
||||
|
||||
it "respects additional client limiting" $ do
|
||||
r <- request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "0-1/15"
|
||||
simpleStatus r `shouldBe` partialContent206
|
||||
+349
-25
@@ -1,46 +1,342 @@
|
||||
module Feature.QuerySpec where
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai.Test (SResponse(simpleHeaders))
|
||||
|
||||
import Hasql as H
|
||||
import Hasql.Postgres as P
|
||||
|
||||
import SpecHelper
|
||||
import PostgREST.Types (DbStructure(..))
|
||||
import Text.Heredoc
|
||||
|
||||
spec :: DbStructure -> H.Pool P.Postgres -> Spec
|
||||
spec struct pool = around (withApp cfgDefault struct pool) $ do
|
||||
|
||||
describe "Querying a table with a column called count" $
|
||||
it "should not confuse count column with pg_catalog.count aggregate" $
|
||||
get "/has_count_column" `shouldRespondWith` 200
|
||||
|
||||
spec :: Spec
|
||||
spec = beforeAll (clearTable "items" >> createItems 15)
|
||||
. afterAll_ (clearTable "items") . around withApp $ do
|
||||
describe "Querying a nonexistent table" $
|
||||
it "causes a 404" $
|
||||
get "/faketable" `shouldRespondWith` 404
|
||||
|
||||
describe "Filtering response" $
|
||||
context "column equality" $
|
||||
describe "Filtering response" $ do
|
||||
it "matches with equality" $
|
||||
get "/items?id=eq.5"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"id":5}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/1"]
|
||||
}
|
||||
|
||||
it "matches with equality using not operator" $
|
||||
get "/items?id=not.eq.5"
|
||||
`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}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-13/14"]
|
||||
}
|
||||
|
||||
it "matches with more than one condition using not operator" $
|
||||
get "/simple_pk?k=like.*yx&extra=not.eq.u" `shouldRespondWith` "[]"
|
||||
|
||||
it "matches with inequality using not operator" $ do
|
||||
get "/items?id=not.lt.14&order=id.asc"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"id":14},{"id":15}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
||||
}
|
||||
get "/items?id=not.gt.2&order=id.asc"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"id":1},{"id":2}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
||||
}
|
||||
|
||||
it "matches items IN" $
|
||||
get "/items?id=in.1,3,5"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
||||
}
|
||||
|
||||
it "matches items NOT IN" $
|
||||
get "/items?id=notin.2,4,6,7,8,9,10,11,12,13,14,15"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
||||
}
|
||||
|
||||
it "matches items NOT IN using not operator" $
|
||||
get "/items?id=not.in.2,4,6,7,8,9,10,11,12,13,14,15"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"id":1},{"id":3},{"id":5}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
||||
}
|
||||
|
||||
it "matches nulls using not operator" $
|
||||
get "/no_pk?a=not.is.null" `shouldRespondWith`
|
||||
[json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]
|
||||
|
||||
it "matches nulls in varchar and numeric fields alike" $ do
|
||||
get "/no_pk?a=is.null" `shouldRespondWith`
|
||||
[json| [{"a": null, "b": null}] |]
|
||||
|
||||
get "/nullable_integer?a=is.null" `shouldRespondWith` [str|[{"a":null}]|]
|
||||
|
||||
it "matches with like" $ do
|
||||
get "/simple_pk?k=like.*yx" `shouldRespondWith`
|
||||
[str|[{"k":"xyyx","extra":"u"}]|]
|
||||
get "/simple_pk?k=like.xy*" `shouldRespondWith`
|
||||
[str|[{"k":"xyyx","extra":"u"}]|]
|
||||
get "/simple_pk?k=like.*YY*" `shouldRespondWith`
|
||||
[str|[{"k":"xYYx","extra":"v"}]|]
|
||||
|
||||
it "matches with like using not operator" $
|
||||
get "/simple_pk?k=not.like.*yx" `shouldRespondWith`
|
||||
[str|[{"k":"xYYx","extra":"v"}]|]
|
||||
|
||||
it "matches with ilike" $ do
|
||||
get "/simple_pk?k=ilike.xy*&order=extra.asc" `shouldRespondWith`
|
||||
[str|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]
|
||||
get "/simple_pk?k=ilike.*YY*&order=extra.asc" `shouldRespondWith`
|
||||
[str|[{"k":"xyyx","extra":"u"},{"k":"xYYx","extra":"v"}]|]
|
||||
|
||||
it "matches with ilike using not operator" $
|
||||
get "/simple_pk?k=not.ilike.xy*&order=extra.asc" `shouldRespondWith` "[]"
|
||||
|
||||
it "matches with tsearch @@" $
|
||||
get "/tsearch?text_search_vector=@@.foo" `shouldRespondWith`
|
||||
[json| [{"text_search_vector":"'bar':2 'foo':1"}] |]
|
||||
|
||||
it "matches with tsearch @@ using not operator" $
|
||||
get "/tsearch?text_search_vector=not.@@.foo" `shouldRespondWith`
|
||||
[json| [{"text_search_vector":"'baz':1 'qux':2"}] |]
|
||||
|
||||
it "matches with computed column" $
|
||||
get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
|
||||
[json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}] |]
|
||||
|
||||
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" $
|
||||
get "/clients?select=id,projects{id,tasks{id,name}}&projects.tasks.name=like.Design*" `shouldRespondWith`
|
||||
[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" $
|
||||
get "/complex_items?select=id&arr_data=@>.{2}" `shouldRespondWith`
|
||||
[str|[{"id":2},{"id":3}]|]
|
||||
|
||||
it "matches with <@ operator" $
|
||||
get "/complex_items?select=id&arr_data=<@.{1,2,4}" `shouldRespondWith`
|
||||
[str|[{"id":1},{"id":2}]|]
|
||||
|
||||
|
||||
describe "Shaping response with select parameter" $ do
|
||||
|
||||
it "selectStar works in absense of parameter" $
|
||||
get "/complex_items?id=eq.3" `shouldRespondWith`
|
||||
[str|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3]}]|]
|
||||
|
||||
it "one simple column" $
|
||||
get "/complex_items?select=id" `shouldRespondWith`
|
||||
[json| [{"id":1},{"id":2},{"id":3}] |]
|
||||
|
||||
it "one simple column with casting (text)" $
|
||||
get "/complex_items?select=id::text" `shouldRespondWith`
|
||||
[json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]
|
||||
|
||||
it "json column" $
|
||||
get "/complex_items?id=eq.1&select=settings" `shouldRespondWith`
|
||||
[json| [{"settings":{"foo":{"int":1,"bar":"baz"}}}] |]
|
||||
|
||||
it "json subfield one level with casting (json)" $
|
||||
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"
|
||||
|
||||
it "fails on bad casting (data of the wrong format)" $
|
||||
get "/complex_items?select=settings->foo->>bar::integer"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
||||
, matchStatus = 400
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "fails on bad casting (wrong cast type)" $
|
||||
get "/complex_items?select=id::fakecolumntype"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| {"hint":null,"details":null,"code":"42704","message":"type \"fakecolumntype\" does not exist"} |]
|
||||
, matchStatus = 400
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
|
||||
it "json subfield two levels (string)" $
|
||||
get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
|
||||
[json| [{"bar":"baz"}] |]
|
||||
|
||||
|
||||
it "json subfield two levels with casting (int)" $
|
||||
get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
|
||||
[json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
|
||||
|
||||
it "requesting parents and children" $
|
||||
get "/projects?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
|
||||
|
||||
it "requesting parents and filtering parent columns" $
|
||||
get "/projects?id=eq.1&select=id, name, clients{id}" `shouldRespondWith`
|
||||
[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" $
|
||||
get "/clients?id=eq.1&select=id,projects{id,tasks{id}}" `shouldRespondWith`
|
||||
[str|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
|
||||
|
||||
it "requesting many<->many relation" $
|
||||
get "/tasks?select=id,users{id}" `shouldRespondWith`
|
||||
[str|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
|
||||
|
||||
it "requesting parents and children on views" $
|
||||
get "/projects_view?id=eq.1&select=id, name, clients{*}, tasks{id, name}" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
|
||||
|
||||
it "requesting children with composite key" $
|
||||
get "/users_tasks?user_id=eq.2&task_id=eq.6&select=*, comments{content}" `shouldRespondWith`
|
||||
[str|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]
|
||||
|
||||
it "detect relations in views from exposed schema that are based on tables in private schema and have columns renames" $
|
||||
get "/articles?id=eq.1&select=id,articleStars{users{*}}" `shouldRespondWith`
|
||||
[str|[{"id":1,"articleStars":[{"users":{"id":1,"name":"Angela Martin"}},{"users":{"id":2,"name":"Michael Scott"}},{"users":{"id":3,"name":"Dwight Schrute"}}]}]|]
|
||||
|
||||
it "can select by column name" $
|
||||
get "/projects?id=in.1,3&select=id,name,client_id,client_id{id,name}" `shouldRespondWith`
|
||||
[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"}}]|]
|
||||
|
||||
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 "Plurality singular" $ do
|
||||
it "will select an existing object" $
|
||||
request methodGet "/items?id=eq.5" [("Prefer","plurality=singular")] ""
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| {"id":5} |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "works in the presence of a range header" $
|
||||
let headers = ("Prefer","plurality=singular") :
|
||||
rangeHdrs (ByteRangeFromTo 0 9) in
|
||||
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`
|
||||
[str|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]
|
||||
|
||||
it "matches the predicate" $
|
||||
get "/items?id=eq.5"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "[{\"id\":5}]"
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/1"]
|
||||
}
|
||||
|
||||
describe "ordering response" $ do
|
||||
it "by a column asc" $
|
||||
get "/items?id=lte.2&order=asc.id"
|
||||
get "/items?id=lte.2&order=id.asc"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "[{\"id\":1},{\"id\":2}]"
|
||||
matchBody = Just [json| [{"id":1},{"id":2}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
||||
}
|
||||
it "by a column desc" $
|
||||
get "/items?id=lte.2&order=desc.id"
|
||||
get "/items?id=lte.2&order=id.desc"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "[{\"id\":2},{\"id\":1}]"
|
||||
matchBody = Just [json| [{"id":2},{"id":1}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
||||
}
|
||||
|
||||
it "by a column asc with nulls last" $
|
||||
get "/no_pk?order=a.asc.nullslast"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"a":"1","b":"0"},
|
||||
{"a":"2","b":"0"},
|
||||
{"a":null,"b":null}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
||||
}
|
||||
|
||||
it "by a column desc with nulls first" $
|
||||
get "/no_pk?order=a.desc.nullsfirst"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"a":null,"b":null},
|
||||
{"a":"2","b":"0"},
|
||||
{"a":"1","b":"0"}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
||||
}
|
||||
|
||||
it "by a column desc with nulls last" $
|
||||
get "/no_pk?order=a.desc.nullslast"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"a":"2","b":"0"},
|
||||
{"a":"1","b":"0"},
|
||||
{"a":null,"b":null}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/3"]
|
||||
}
|
||||
|
||||
it "without other constraints" $
|
||||
get "/items?order=asc.id" `shouldRespondWith` 200
|
||||
get "/items?order=id.asc" `shouldRespondWith` 200
|
||||
|
||||
describe "Accept headers" $ do
|
||||
it "should respond an unknown accept type with 415" $
|
||||
request methodGet "/simple_pk"
|
||||
(acceptHdrs "text/unknowntype") ""
|
||||
`shouldRespondWith` 415
|
||||
|
||||
it "should respond correctly to */* in accept header" $
|
||||
request methodGet "/simple_pk"
|
||||
(acceptHdrs "*/*") ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "should respond correctly to multiple types in accept header" $
|
||||
request methodGet "/simple_pk"
|
||||
(acceptHdrs "text/unknowntype, text/csv") ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "should respond with CSV to 'text/csv' request" $
|
||||
request methodGet "/simple_pk"
|
||||
(acceptHdrs "text/csv; version=1") ""
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "k,extra\nxyyx,u\nxYYx,v"
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv"]
|
||||
}
|
||||
|
||||
describe "Canonical location" $ do
|
||||
it "Sets Content-Location with alphabetized params" $
|
||||
@@ -51,10 +347,38 @@ spec = beforeAll (clearTable "items" >> createItems 15)
|
||||
, matchHeaders = ["Content-Location" <:> "/no_pk?a=eq.1&b=eq.1"]
|
||||
}
|
||||
|
||||
it "Omits question mark when there are no params" $
|
||||
get "/no_pk"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "[]"
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Location" <:> "/no_pk"]
|
||||
}
|
||||
it "Omits question mark when there are no params" $ do
|
||||
r <- get "/simple_pk"
|
||||
liftIO $ do
|
||||
let respHeaders = simpleHeaders r
|
||||
respHeaders `shouldSatisfy` matchHeader
|
||||
"Content-Location" "/simple_pk"
|
||||
|
||||
describe "jsonb" $ do
|
||||
it "can filter by properties inside json column" $ do
|
||||
get "/json?data->foo->>bar=eq.baz" `shouldRespondWith`
|
||||
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||
get "/json?data->foo->>bar=eq.fake" `shouldRespondWith`
|
||||
[json| [] |]
|
||||
it "can filter by properties inside json column using not" $
|
||||
get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith`
|
||||
[json| [] |]
|
||||
it "can filter by properties inside json column using ->>" $
|
||||
get "/json?data->>id=eq.1" `shouldRespondWith`
|
||||
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||
|
||||
describe "remote procedure call" $ do
|
||||
context "a proc that returns a set" $
|
||||
it "returns proper json" $
|
||||
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
|
||||
[json| [ {"id": 3}, {"id":4} ] |]
|
||||
|
||||
context "a proc that returns an empty rowset" $
|
||||
it "returns empty json array" $
|
||||
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
|
||||
[json| [] |]
|
||||
|
||||
context "a proc that returns plain text" $
|
||||
it "returns proper json" $
|
||||
post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`
|
||||
[json| [{"sayhello":"Hello, world"}] |]
|
||||
|
||||
@@ -2,21 +2,54 @@ module Feature.RangeSpec where
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus))
|
||||
|
||||
import SpecHelper
|
||||
import Hasql as H
|
||||
import Hasql.Postgres as P
|
||||
|
||||
spec :: Spec
|
||||
spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable "items")
|
||||
. around withApp $
|
||||
import SpecHelper
|
||||
import PostgREST.Types (DbStructure(..))
|
||||
|
||||
spec :: DbStructure -> H.Pool P.Postgres -> Spec
|
||||
spec struct pool = beforeAll resetDb
|
||||
. around (withApp cfgDefault struct pool) $
|
||||
describe "GET /items" $ do
|
||||
|
||||
context "without range headers" $
|
||||
context "without range headers" $ do
|
||||
context "with response under server size limit" $
|
||||
it "returns whole range with status 200" $
|
||||
get "/items" `shouldRespondWith` 200
|
||||
|
||||
context "when I don't want the count" $ do
|
||||
it "returns range Content-Range with /*" $
|
||||
request methodGet "/menagerie"
|
||||
[("Prefer", "count=none")] ""
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "[]"
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "returns range Content-Range with range/*" $
|
||||
request methodGet "/items?order=id"
|
||||
[("Prefer", "count=none")] ""
|
||||
`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/*"]
|
||||
}
|
||||
|
||||
it "returns range Content-Range with range/* even using other filters" $
|
||||
request methodGet "/items?id=eq.1&order=id"
|
||||
[("Prefer", "count=none")] ""
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| [{"id":1}] |]
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
context "with range headers" $ do
|
||||
|
||||
context "of acceptable range" $ do
|
||||
|
||||
+161
-29
@@ -1,41 +1,60 @@
|
||||
{-# LANGUAGE OverloadedStrings, QuasiQuotes #-}
|
||||
module Feature.StructureSpec where
|
||||
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Hasql as H
|
||||
import Hasql.Postgres as P
|
||||
|
||||
import SpecHelper
|
||||
import PostgREST.Types (DbStructure(..))
|
||||
|
||||
import Network.HTTP.Types
|
||||
|
||||
spec :: Spec
|
||||
spec = around withApp $ do
|
||||
spec :: DbStructure -> H.Pool P.Postgres -> Spec
|
||||
spec struct pool = around (withApp cfgDefault struct pool) $ do
|
||||
describe "GET /" $ do
|
||||
it "lists views in schema" $
|
||||
request methodGet "/" [] ""
|
||||
`shouldRespondWith` [json| [
|
||||
{"schema":"1","name":"auto_incrementing_pk","insertable":true}
|
||||
, {"schema":"1","name":"compound_pk","insertable":true}
|
||||
, {"schema":"1","name":"has_fk","insertable":true}
|
||||
, {"schema":"1","name":"items","insertable":true}
|
||||
, {"schema":"1","name":"menagerie","insertable":true}
|
||||
, {"schema":"1","name":"no_pk","insertable":true}
|
||||
, {"schema":"1","name":"simple_pk","insertable":true}
|
||||
{"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":"insertonly","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
|
||||
_ <- post "/postgrest/users" [json| { "id":"jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||
let auth = authHeader "jdoe" "1234"
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||
|
||||
request methodGet "/" [auth] ""
|
||||
`shouldRespondWith` [json| [
|
||||
{"schema":"1","name":"authors_only","insertable":true}
|
||||
{"schema":"test","name":"authors_only","insertable":true}
|
||||
] |]
|
||||
{matchStatus = 200}
|
||||
|
||||
|
||||
describe "Table info" $ do
|
||||
it "is available with OPTIONS verb" $
|
||||
request methodOptions "/menagerie" [] "" `shouldRespondWith`
|
||||
@@ -47,7 +66,7 @@ spec = around withApp $ do
|
||||
"default": null,
|
||||
"precision": 32,
|
||||
"updatable": true,
|
||||
"schema": "1",
|
||||
"schema": "test",
|
||||
"name": "integer",
|
||||
"type": "integer",
|
||||
"maxLen": null,
|
||||
@@ -60,7 +79,7 @@ spec = around withApp $ do
|
||||
"default": null,
|
||||
"precision": 53,
|
||||
"updatable": true,
|
||||
"schema": "1",
|
||||
"schema": "test",
|
||||
"name": "double",
|
||||
"type": "double precision",
|
||||
"maxLen": null,
|
||||
@@ -72,7 +91,7 @@ spec = around withApp $ do
|
||||
"default": null,
|
||||
"precision": null,
|
||||
"updatable": true,
|
||||
"schema": "1",
|
||||
"schema": "test",
|
||||
"name": "varchar",
|
||||
"type": "character varying",
|
||||
"maxLen": null,
|
||||
@@ -85,7 +104,7 @@ spec = around withApp $ do
|
||||
"default": null,
|
||||
"precision": null,
|
||||
"updatable": true,
|
||||
"schema": "1",
|
||||
"schema": "test",
|
||||
"name": "boolean",
|
||||
"type": "boolean",
|
||||
"maxLen": null,
|
||||
@@ -97,7 +116,7 @@ spec = around withApp $ do
|
||||
"default": null,
|
||||
"precision": null,
|
||||
"updatable": true,
|
||||
"schema": "1",
|
||||
"schema": "test",
|
||||
"name": "date",
|
||||
"type": "date",
|
||||
"maxLen": null,
|
||||
@@ -109,7 +128,7 @@ spec = around withApp $ do
|
||||
"default": null,
|
||||
"precision": null,
|
||||
"updatable": true,
|
||||
"schema": "1",
|
||||
"schema": "test",
|
||||
"name": "money",
|
||||
"type": "money",
|
||||
"maxLen": null,
|
||||
@@ -122,7 +141,7 @@ spec = around withApp $ do
|
||||
"default": null,
|
||||
"precision": null,
|
||||
"updatable": true,
|
||||
"schema": "1",
|
||||
"schema": "test",
|
||||
"name": "enum",
|
||||
"type": "USER-DEFINED",
|
||||
"maxLen": null,
|
||||
@@ -139,19 +158,72 @@ spec = around withApp $ do
|
||||
}
|
||||
|]
|
||||
|
||||
it "includes foreign key data" $ do
|
||||
pendingWith "have to resolve issue #107"
|
||||
it "it includes primary and foreign keys for views" $
|
||||
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" $
|
||||
request methodOptions "/has_fk" [] ""
|
||||
`shouldRespondWith` [json|
|
||||
{
|
||||
"pkey": ["id"],
|
||||
"columns":[
|
||||
{
|
||||
"default": "nextval('\"1\".has_fk_id_seq'::regclass)",
|
||||
"default": "nextval('test.has_fk_id_seq'::regclass)",
|
||||
"precision": 64,
|
||||
"updatable": true,
|
||||
"schema": "1",
|
||||
"schema": "test",
|
||||
"name": "id",
|
||||
"type": "bigint",
|
||||
"maxLen": null,
|
||||
@@ -163,27 +235,87 @@ spec = around withApp $ do
|
||||
"default": null,
|
||||
"precision": 32,
|
||||
"updatable": true,
|
||||
"schema": "1",
|
||||
"schema": "test",
|
||||
"name": "auto_inc_fk",
|
||||
"type": "integer",
|
||||
"maxLen": null,
|
||||
"nullable": true,
|
||||
"position": 2,
|
||||
"enum": [],
|
||||
"references": {"table": "auto_incrementing_pk", "column": "id"}
|
||||
"references": {"schema":"test", "table": "auto_incrementing_pk", "column": "id"}
|
||||
}, {
|
||||
"default": null,
|
||||
"precision": null,
|
||||
"updatable": true,
|
||||
"schema": "1",
|
||||
"schema": "test",
|
||||
"name": "simple_fk",
|
||||
"type": "character varying",
|
||||
"maxLen": 255,
|
||||
"nullable": true,
|
||||
"position": 3,
|
||||
"enum": [],
|
||||
"references": {"table": "simple_pk", "column": "k"}
|
||||
"references": {"schema":"test", "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": {
|
||||
"schema": "test",
|
||||
"column": "id",
|
||||
"table": "articles"
|
||||
},
|
||||
"default": null,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|]
|
||||
|
||||
+31
-3
@@ -1,9 +1,37 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
module Main where
|
||||
|
||||
import Test.Hspec
|
||||
import SpecHelper
|
||||
import Spec
|
||||
|
||||
--import PostgREST.Types (DbStructure(..))
|
||||
|
||||
import qualified Feature.AuthSpec
|
||||
import qualified Feature.CorsSpec
|
||||
import qualified Feature.DeleteSpec
|
||||
import qualified Feature.InsertSpec
|
||||
import qualified Feature.QueryLimitedSpec
|
||||
import qualified Feature.QuerySpec
|
||||
import qualified Feature.RangeSpec
|
||||
import qualified Feature.StructureSpec
|
||||
|
||||
main :: IO ()
|
||||
main = resetDb >> hspec spec
|
||||
main = do
|
||||
setupDb
|
||||
|
||||
pool <- specDbPool
|
||||
dbStructure <- specDbStructure pool
|
||||
|
||||
-- Not using hspec-discover because we want to precompute
|
||||
-- the db structure and pass it to specs for speed
|
||||
hspec $ specs dbStructure pool
|
||||
|
||||
where
|
||||
specs dbStructure pool = do
|
||||
describe "Feature.AuthSpec" $ Feature.AuthSpec.spec dbStructure pool
|
||||
describe "Feature.CorsSpec" $ Feature.CorsSpec.spec dbStructure pool
|
||||
describe "Feature.DeleteSpec" $ Feature.DeleteSpec.spec dbStructure pool
|
||||
describe "Feature.InsertSpec" $ Feature.InsertSpec.spec dbStructure pool
|
||||
describe "Feature.QueryLimitedSpec" $ Feature.QueryLimitedSpec.spec dbStructure pool
|
||||
describe "Feature.QuerySpec" $ Feature.QuerySpec.spec dbStructure pool
|
||||
describe "Feature.RangeSpec" $ Feature.RangeSpec.spec dbStructure pool
|
||||
describe "Feature.StructureSpec" $ Feature.StructureSpec.spec dbStructure pool
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{-# OPTIONS_GHC -F -pgmF hspec-discover -optF --no-main #-}
|
||||
+68
-57
@@ -1,5 +1,3 @@
|
||||
{-# LANGUAGE QuasiQuotes, OverloadedStrings #-}
|
||||
|
||||
module SpecHelper where
|
||||
|
||||
import Network.Wai
|
||||
@@ -7,78 +5,93 @@ import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Hasql as H
|
||||
import Hasql.Postgres as H
|
||||
import Hasql.Backend as B
|
||||
import Hasql.Postgres as P
|
||||
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Monoid
|
||||
-- import Control.Exception.Base (bracket, finally)
|
||||
import Data.Text hiding (map)
|
||||
import qualified Data.Vector as V
|
||||
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||
import Control.Monad (void)
|
||||
import Control.Exception
|
||||
|
||||
import Network.HTTP.Types.Header (Header, ByteRange, renderByteRange,
|
||||
hRange, hAuthorization)
|
||||
hRange, hAuthorization, hAccept)
|
||||
import Codec.Binary.Base64.String (encode)
|
||||
import Data.CaseInsensitive (CI(..))
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Text.Regex.TDFA ((=~))
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import Network.Wai.Middleware.Cors (cors)
|
||||
import System.Process (readProcess)
|
||||
import Web.JWT (secret)
|
||||
|
||||
import App (app, sqlError, isSqlError)
|
||||
import Config (AppConfig(..), corsPolicy)
|
||||
import Middleware
|
||||
-- import Auth (addUser)
|
||||
import PostgREST.App (app)
|
||||
import PostgREST.Config (AppConfig(..))
|
||||
import PostgREST.Middleware
|
||||
import PostgREST.Error(pgErrResponse)
|
||||
import PostgREST.DbStructure
|
||||
import PostgREST.Types
|
||||
|
||||
isLeft :: Either a b -> Bool
|
||||
isLeft (Left _ ) = True
|
||||
isLeft _ = False
|
||||
dbString :: String
|
||||
dbString = "postgres://postgrest_test_authenticator@localhost:5432/postgrest_test"
|
||||
|
||||
cfg :: AppConfig
|
||||
cfg = AppConfig "postgrest_test" 5432 "postgrest_test" "" "localhost" 3000 "postgrest_anonymous" False 10
|
||||
cfg :: String -> Maybe Int -> AppConfig
|
||||
cfg conStr = AppConfig conStr 3000 "postgrest_test_anonymous" "test" (secret "safe") 10
|
||||
|
||||
testSettings :: SessionSettings
|
||||
testSettings = fromMaybe (error "bad settings") $ H.sessionSettings 1 30
|
||||
cfgDefault :: AppConfig
|
||||
cfgDefault = cfg dbString Nothing
|
||||
|
||||
pgSettings :: Postgres
|
||||
pgSettings = H.ParamSettings "localhost" 5432 "postgrest_test" "" "postgrest_test"
|
||||
cfgLimitRows :: Int -> AppConfig
|
||||
cfgLimitRows = cfg dbString . Just
|
||||
|
||||
withApp :: ActionWith Application -> IO ()
|
||||
withApp perform =
|
||||
let anonRole = cs $ configAnonRole cfg
|
||||
currRole = cs $ configDbUser cfg in
|
||||
perform $ middle $ \req resp ->
|
||||
H.session pgSettings testSettings $ H.sessionUnlifter >>= \unlift ->
|
||||
liftIO $ do
|
||||
body <- strictRequestBody req
|
||||
resp =<< catchJust isSqlError
|
||||
(unlift $ H.tx Nothing
|
||||
$ authenticated currRole anonRole (app body) req)
|
||||
(return . sqlError)
|
||||
testPoolOpts :: PoolSettings
|
||||
testPoolOpts = fromMaybe (error "bad settings") $ H.poolSettings 1 30
|
||||
|
||||
where middle = cors corsPolicy
|
||||
pgSettings :: P.Settings
|
||||
pgSettings = P.StringSettings $ cs dbString
|
||||
|
||||
specDbPool :: IO (H.Pool P.Postgres)
|
||||
specDbPool = H.acquirePool pgSettings testPoolOpts
|
||||
|
||||
resetDb :: IO ()
|
||||
resetDb = do
|
||||
H.session pgSettings testSettings $
|
||||
H.tx Nothing $ do
|
||||
H.unit [H.q| drop schema if exists "1" cascade |]
|
||||
H.unit [H.q| drop schema if exists private cascade |]
|
||||
H.unit [H.q| drop schema if exists postgrest cascade |]
|
||||
specDbStructure :: H.Pool P.Postgres -> IO DbStructure
|
||||
specDbStructure pool = do
|
||||
dbOrError <- H.session pool $ H.tx specTxSettings
|
||||
$ getDbStructure "test"
|
||||
either (fail . show) return dbOrError
|
||||
|
||||
withApp :: AppConfig -> DbStructure -> H.Pool P.Postgres
|
||||
-> ActionWith Application -> IO ()
|
||||
withApp config dbStructure pool perform = do
|
||||
perform $ middle $ \req resp -> do
|
||||
time <- getPOSIXTime
|
||||
body <- strictRequestBody req
|
||||
result <- liftIO $ H.session pool $ H.tx specTxSettings
|
||||
$ runWithClaims config time (app dbStructure config body) req
|
||||
either (resp . pgErrResponse) resp result
|
||||
|
||||
where middle = defaultMiddle
|
||||
|
||||
setupDb :: IO ()
|
||||
setupDb = do
|
||||
void $ readProcess "psql" ["-d", "postgres", "-a", "-f", "test/fixtures/database.sql"] []
|
||||
loadFixture "roles"
|
||||
loadFixture "schema"
|
||||
loadFixture "privileges"
|
||||
resetDb
|
||||
|
||||
resetDb :: IO ()
|
||||
resetDb = loadFixture "data"
|
||||
|
||||
loadFixture :: FilePath -> IO()
|
||||
loadFixture name =
|
||||
void $ readProcess "psql" ["-U", "postgrest_test", "-d", "postgrest_test", "-a", "-f", "test/fixtures/" ++ name ++ ".sql"] []
|
||||
|
||||
|
||||
rangeHdrs :: ByteRange -> [Header]
|
||||
rangeHdrs r = [rangeUnit, (hRange, renderByteRange r)]
|
||||
|
||||
acceptHdrs :: BS.ByteString -> [Header]
|
||||
acceptHdrs mime = [(hAccept, mime)]
|
||||
|
||||
rangeUnit :: Header
|
||||
rangeUnit = ("Range-Unit" :: CI BS.ByteString, "items")
|
||||
|
||||
@@ -86,24 +99,22 @@ matchHeader :: CI BS.ByteString -> String -> [Header] -> Bool
|
||||
matchHeader name valRegex headers =
|
||||
maybe False (=~ valRegex) $ lookup name headers
|
||||
|
||||
authHeader :: String -> String -> Header
|
||||
authHeader u p =
|
||||
authHeaderBasic :: String -> String -> Header
|
||||
authHeaderBasic u p =
|
||||
(hAuthorization, cs $ "Basic " ++ encode (u ++ ":" ++ p))
|
||||
|
||||
clearTable :: BS.ByteString -> IO ()
|
||||
clearTable table = H.session pgSettings testSettings $ H.tx Nothing $
|
||||
H.unit ("delete from \"1\"."<>table, [], True)
|
||||
authHeaderJWT :: String -> Header
|
||||
authHeaderJWT token =
|
||||
(hAuthorization, cs $ "Bearer " ++ token)
|
||||
|
||||
createItems :: Int -> IO ()
|
||||
createItems n = H.session pgSettings testSettings $ H.tx Nothing txn
|
||||
where
|
||||
txn = sequence_ $ map H.unit stmts
|
||||
stmts = map [H.q|insert into "1".items (id) values (?)|] [1..n]
|
||||
testPool :: IO(H.Pool P.Postgres)
|
||||
testPool = H.acquirePool pgSettings testPoolOpts
|
||||
|
||||
-- for hspec-wai
|
||||
pending_ :: WaiSession ()
|
||||
pending_ = liftIO Test.Hspec.pending
|
||||
clearTable :: Text -> IO ()
|
||||
clearTable table = do
|
||||
pool <- testPool
|
||||
void . liftIO $ H.session pool $ H.tx Nothing $
|
||||
H.unitEx $ B.Stmt ("truncate table test." <> table <> " cascade") V.empty True
|
||||
|
||||
-- for hspec-wai
|
||||
pendingWith_ :: String -> WaiSession ()
|
||||
pendingWith_ = liftIO . Test.Hspec.pendingWith
|
||||
specTxSettings :: Maybe (TxIsolationLevel, Maybe Bool)
|
||||
specTxSettings = Just (H.ReadCommitted, Just True)
|
||||
|
||||
+3
-1
@@ -8,9 +8,11 @@ module TestTypes (
|
||||
import qualified Data.Aeson as JSON
|
||||
import Data.Aeson ((.:))
|
||||
-- import Data.Maybe (fromJust)
|
||||
import Control.Applicative ((<$>), (<*>))
|
||||
import Control.Applicative
|
||||
import Control.Monad (mzero)
|
||||
|
||||
import Prelude
|
||||
|
||||
data IncPK = IncPK {
|
||||
incId :: Int
|
||||
, incNullableStr :: Maybe String
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module Unit.PgStructureSpec where
|
||||
module Unit.DbStructureSpec where
|
||||
|
||||
import Test.Hspec
|
||||
import PgStructure (Table(..), tables, Column(..), columns, ForeignKey(..),
|
||||
import DbStructure (Table(..), tables, Column(..), columns, ForeignKey(..),
|
||||
foreignKeys)
|
||||
|
||||
import Database.HDBC (quickQuery)
|
||||
@@ -12,25 +12,25 @@ spec :: Spec
|
||||
spec = around dbWithSchema $ beforeWith setRole $ do
|
||||
describe "tables" $
|
||||
it "shows all the tables" $ \conn -> do
|
||||
ts <- tables "1" conn
|
||||
ts <- tables "test" conn
|
||||
map tableName ts `shouldBe` ["authors_only","auto_incrementing_pk",
|
||||
"compound_pk","has_fk","items","menagerie","no_pk", "simple_pk"]
|
||||
"compound_pk","has_fk","insertable_view_with_join","items","menagerie","no_pk", "simple_pk"]
|
||||
|
||||
describe "columns" $ do
|
||||
it "responds with each column for the table" $ \conn -> do
|
||||
cs <- columns "1" "auto_incrementing_pk" conn
|
||||
cs <- columns "test" "auto_incrementing_pk" conn
|
||||
map colName cs `shouldBe` ["id","nullable_string","non_nullable_string",
|
||||
"inserted_at"]
|
||||
|
||||
it "includes foreign key data" $ \conn -> do
|
||||
cs <- columns "1" "has_fk" conn
|
||||
cs <- columns "test" "has_fk" conn
|
||||
map colFK cs `shouldBe` [Nothing,
|
||||
Just $ ForeignKey "auto_incrementing_pk" "id",
|
||||
Just $ ForeignKey "simple_pk" "k"]
|
||||
|
||||
describe "foreignKeys" $
|
||||
it "has a description of the foreign key columns" $ \conn ->
|
||||
foreignKeys "1" "has_fk" conn `shouldReturn` M.fromList [
|
||||
foreignKeys "test" "has_fk" conn `shouldReturn` M.fromList [
|
||||
("auto_inc_fk", ForeignKey {fkTable="auto_incrementing_pk", fkCol="id"}),
|
||||
("simple_fk", ForeignKey { fkTable="simple_pk", fkCol="k"})]
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
module Unit.ErrorsSpec where
|
||||
|
||||
import Test.Hspec
|
||||
|
||||
import Text.Parsec
|
||||
import PgError
|
||||
import Data.Either (rights)
|
||||
|
||||
spec :: Spec
|
||||
spec =
|
||||
describe "Parsing Hasql errors" $ do
|
||||
it "can handle status and code" $
|
||||
let p = parse message "" "Status: \"foo\"; Code: \"abc\"." in
|
||||
rights [p] `shouldBe` [
|
||||
Message (Just "foo") "abc" Nothing Nothing
|
||||
]
|
||||
it "can handle weird redundant quotes in status" $
|
||||
let p = parse message "" "Status: \"\"foo\"\"; Code: \"abc\"." in
|
||||
rights [p] `shouldBe` [
|
||||
Message (Just "foo") "abc" Nothing Nothing
|
||||
]
|
||||
it "can handle text and code" $
|
||||
let p = parse message "" "Message: \"foo\"; Code: \"abc\"." in
|
||||
rights [p] `shouldBe` [
|
||||
Message Nothing "abc" (Just "foo") Nothing
|
||||
]
|
||||
it "can handle status, text and code" $
|
||||
let p = parse message "" "Status: \"hi\"; Message: \"foo\"; Code: \"abc\"." in
|
||||
rights [p] `shouldBe` [
|
||||
Message (Just "hi") "abc" (Just "foo") Nothing
|
||||
]
|
||||
it "can handle unescaped quotes in message" $
|
||||
let p = parse message "" "Status: \"hi\"; Message: \"unknown \"foo\"!\"; Code: \"abc\"." in
|
||||
rights [p] `shouldBe` [
|
||||
Message (Just "hi") "abc" (Just "unknown \"foo\"!") Nothing
|
||||
]
|
||||
it "can handle periods in message" $
|
||||
let p = parse message "" "Message: \"unknown \"foo\".bar\"; Code: \"42P01\"." in
|
||||
rights [p] `shouldBe` [
|
||||
Message Nothing "42P01" (Just "unknown \"foo\".bar") Nothing
|
||||
]
|
||||
@@ -32,7 +32,7 @@ spec = around dbWithSchema $ do
|
||||
describe "insert" $
|
||||
describe "with an auto-increment key" $ do
|
||||
it "inserts and responds with a full object description" $ \conn -> do
|
||||
r <- insert "1" "auto_incrementing_pk" (SqlRow [
|
||||
r <- insert "test" "auto_incrementing_pk" (SqlRow [
|
||||
("non_nullable_string", toSql ("a string"::String))]) conn
|
||||
let returnRow = incFromList . toList $ r
|
||||
incStr returnRow `shouldBe` "a string"
|
||||
@@ -43,24 +43,24 @@ spec = around dbWithSchema $ do
|
||||
[returnRow] `shouldBe` map incFromList tRows
|
||||
|
||||
it "throws an exception if the PK is not unique" $ \conn -> do
|
||||
r <- insert "1" "auto_incrementing_pk" (SqlRow [
|
||||
r <- insert "test" "auto_incrementing_pk" (SqlRow [
|
||||
("non_nullable_string", toSql ("a string"::String))]) conn
|
||||
let row = SqlRow . map (Control.Arrow.first cs) . toList $ r
|
||||
insert "1" "auto_incrementing_pk" row conn `shouldThrow` \e ->
|
||||
insert "test" "auto_incrementing_pk" row conn `shouldThrow` \e ->
|
||||
seState e == "23505" -- uniqueness violation code
|
||||
|
||||
it "throws an exception if a required value is missing" $ \conn ->
|
||||
insert "1" "auto_incrementing_pk" (SqlRow [
|
||||
insert "test" "auto_incrementing_pk" (SqlRow [
|
||||
("nullable_string", toSql ("a string"::String))]) conn
|
||||
`shouldThrow` \e -> seState e == "23502"
|
||||
|
||||
it "generates a default values query if no data is provided" $ \c -> do
|
||||
r <- insert "1" "items" (SqlRow []) c
|
||||
r <- insert "test" "items" (SqlRow []) c
|
||||
let [row] = toList r
|
||||
quickALQuery c "select * from \"1\".items where id = ?" [snd row]
|
||||
`shouldReturn` [[row]]
|
||||
|
||||
let {user = "jdoe"; pass = "secret"; role = "test_default_role"}
|
||||
let {user = "jdoe"; pass = "secret"; role = "postgrest_test_default_role"}
|
||||
describe "addUser" $ do
|
||||
it "adds a correct user to the right table" $ \conn -> do
|
||||
addUser user pass role conn
|
||||
@@ -79,7 +79,7 @@ spec = around dbWithSchema $ do
|
||||
addUser user pass role conn
|
||||
return conn) $ do
|
||||
it "accepts correct credentials and return the role" $ \conn ->
|
||||
signInRole user pass conn `shouldReturn` LoginSuccess role
|
||||
signInRole user pass conn `shouldReturn` LoginSuccess role user
|
||||
|
||||
it "returns nothing with bad creds" $ \conn -> do
|
||||
signInRole "not-a-user" pass conn `shouldReturn` LoginFailed
|
||||
|
||||
Vendored
+266
@@ -0,0 +1,266 @@
|
||||
--
|
||||
-- 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: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: auto_incrementing_pk; Type: TABLE DATA; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- 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: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- 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: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- 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', 1, true);
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: json; Type: TABLE DATA; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
TRUNCATE TABLE json CASCADE;
|
||||
INSERT INTO json VALUES ('{"foo":{"bar":"baz"},"id":1}');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: menagerie; Type: TABLE DATA; Schema: test; Owner: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- 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);
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
DROP DATABASE IF EXISTS postgrest_test;
|
||||
DROP ROLE IF EXISTS postgrest_test;
|
||||
CREATE USER postgrest_test createdb createrole;
|
||||
CREATE DATABASE postgrest_test OWNER postgrest_test;
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
-- Privileges for anonymous
|
||||
GRANT USAGE ON SCHEMA
|
||||
postgrest
|
||||
, test
|
||||
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
|
||||
, has_count_column
|
||||
, has_fk
|
||||
, insertable_view_with_join
|
||||
, json
|
||||
, materialized_view
|
||||
, menagerie
|
||||
, no_pk
|
||||
, nullable_integer
|
||||
, projects
|
||||
, projects_view
|
||||
, simple_pk
|
||||
, tasks
|
||||
, tsearch
|
||||
, users
|
||||
, users_projects
|
||||
, users_tasks
|
||||
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
|
||||
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;
|
||||
Vendored
+6
-15
@@ -1,16 +1,7 @@
|
||||
create function pg_temp.create_role_if_not_exists(rolename name, opts character varying) RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT * FROM pg_roles WHERE rolname = rolename) THEN
|
||||
EXECUTE format('CREATE ROLE %I %s', rolename, opts);
|
||||
RETURN 'CREATE ROLE';
|
||||
ELSE
|
||||
RETURN format('ROLE ''%I'' ALREADY EXISTS', rolename);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
DROP ROLE IF EXISTS postgrest_test_authenticator, postgrest_test_anonymous, postgrest_test_default_role, postgrest_test_author;
|
||||
CREATE ROLE postgrest_test_authenticator WITH login;
|
||||
CREATE ROLE postgrest_test_anonymous;
|
||||
CREATE ROLE postgrest_test_default_role;
|
||||
CREATE ROLE postgrest_test_author;
|
||||
|
||||
select pg_temp.create_role_if_not_exists('postgrest_anonymous', 'with nologin') as a
|
||||
, 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;
|
||||
GRANT postgrest_test_anonymous, postgrest_test_default_role, postgrest_test_author TO postgrest_test_authenticator;
|
||||
|
||||
Vendored
+575
-499
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user