Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd86fe372c | ||
|
|
ea7d747107 | ||
|
|
40ae7ce2b1 | ||
|
|
9bcf39f41f | ||
|
|
51d3a7864a | ||
|
|
e2dc432385 | ||
|
|
b101d5f0f9 | ||
|
|
23ca27d27e | ||
|
|
1df749a7a8 | ||
|
|
ea82b9f820 | ||
|
|
7356327e5b | ||
|
|
33532cfbb6 | ||
|
|
78e5677fbe | ||
|
|
e292fb5eb9 | ||
|
|
8fe9e94e24 | ||
|
|
30d5a81156 | ||
|
|
1f69822fa3 | ||
|
|
65fc672417 | ||
|
|
e34669b137 | ||
|
|
ea2f89e234 | ||
|
|
97ea99402d | ||
|
|
35cef22254 | ||
|
|
28b3d6cafd | ||
|
|
16af470a99 | ||
|
|
1cf54e6575 | ||
|
|
e2d917f7b9 | ||
|
|
8d8374cef0 | ||
|
|
3078a11144 | ||
|
|
3c7738a8c7 | ||
|
|
181b608c04 | ||
|
|
37de12d376 | ||
|
|
bcc317db81 | ||
|
|
d32f373e1e | ||
|
|
2044f77d49 | ||
|
|
033ee5a06e | ||
|
|
553531711b | ||
|
|
87f7e86aa7 | ||
|
|
74e38a1d80 | ||
|
|
63826e9509 | ||
|
|
32725f2f35 | ||
|
|
b53e8932e5 | ||
|
|
bdfb11001e | ||
|
|
40b004c9f7 | ||
|
|
fe56029f61 | ||
|
|
cefbe8f07f | ||
|
|
9387e70b66 | ||
|
|
1f513f24a5 | ||
|
|
7c376d6e84 | ||
|
|
39adbefb9d | ||
|
|
c9b2830e52 | ||
|
|
3946dfbc64 | ||
|
|
50509b52b8 | ||
|
|
16059ad470 | ||
|
|
00a0d8b9b7 | ||
|
|
36e9d779fc | ||
|
|
3fc8a105ec | ||
|
|
673aa25082 | ||
|
|
1037313e77 | ||
|
|
86e460c5c4 | ||
|
|
fb5adce5ce | ||
|
|
04ab0ea753 | ||
|
|
3900baa6ce | ||
|
|
1e732ac94a | ||
|
|
6b2778749f | ||
|
|
36f86827ee | ||
|
|
d78410473e | ||
|
|
501edc718d | ||
|
|
0d6d112b38 | ||
|
|
473ac70789 | ||
|
|
dadfe965b9 | ||
|
|
63ead89470 | ||
|
|
ab23ed7999 | ||
|
|
2da6bd6d1c | ||
|
|
5bfb68b982 | ||
|
|
dc834572d6 | ||
|
|
b48824bddd | ||
|
|
6d326fe341 | ||
|
|
d94cf2ed72 | ||
|
|
27ca6b4e90 | ||
|
|
e0cc4d1571 | ||
|
|
3cef4b70b0 | ||
|
|
6f97c34a86 | ||
|
|
bdac90491d | ||
|
|
5961f7a116 | ||
|
|
17cd2725fd | ||
|
|
5e7606134a | ||
|
|
dfa9055c34 | ||
|
|
6907e7f979 | ||
|
|
8cf68c63d9 | ||
|
|
30b5859b28 | ||
|
|
0a1d83ce8f | ||
|
|
d7511a2637 | ||
|
|
2066220244 | ||
|
|
93f10adb3c | ||
|
|
56bd5d5f91 | ||
|
|
70e95649fd | ||
|
|
2b46afe1ec | ||
|
|
fa1e92fdf2 | ||
|
|
b1a8bd2391 | ||
|
|
69a76a627f | ||
|
|
105671e51a | ||
|
|
ecf0e9213f | ||
|
|
1c6ded16d1 | ||
|
|
6fc9d5191a | ||
|
|
80f09780cc | ||
|
|
9e3454129f | ||
|
|
d34afe861a | ||
|
|
30dfadec7b | ||
|
|
2513c00039 | ||
|
|
100bf494ac | ||
|
|
e8188b0d41 | ||
|
|
3958ebbb05 | ||
|
|
37e7398a85 | ||
|
|
9ea7529f30 | ||
|
|
384767708b | ||
|
|
6bcbb124d2 | ||
|
|
f6c1ff810e | ||
|
|
f80cfbf165 | ||
|
|
d8896be2c1 | ||
|
|
903a8d5f5a | ||
|
|
ca76a8e6be | ||
|
|
28845e0f43 | ||
|
|
30cf1d100a | ||
|
|
05180f6539 | ||
|
|
b00f57ac34 | ||
|
|
e9aaf05335 | ||
|
|
79a7ce49f2 | ||
|
|
3a1213f53b | ||
|
|
f033c2c4b5 | ||
|
|
5c87fe2704 | ||
|
|
58f4b4bc33 | ||
|
|
32c7e32bdf | ||
|
|
062a5581f5 | ||
|
|
50512e1117 | ||
|
|
edae60f8c1 | ||
|
|
243e692192 | ||
|
|
349a5ae076 | ||
|
|
ff709a65e5 | ||
|
|
8e2a0e05ea | ||
|
|
108f3cd651 | ||
|
|
6675821c64 | ||
|
|
85b1dc0eb4 | ||
|
|
102392e4ab | ||
|
|
a46b6f5020 | ||
|
|
70ce1b9329 | ||
|
|
516976e32f | ||
|
|
f7e7834a1c | ||
|
|
38f3bcf4a6 | ||
|
|
f159233de8 | ||
|
|
02a286a4b1 | ||
|
|
85d9feeeab | ||
|
|
f9e770b583 | ||
|
|
effbec234f | ||
|
|
e4183780a9 | ||
|
|
804c0b7f6b | ||
|
|
fef7d949d9 | ||
|
|
be630aa680 | ||
|
|
678b855614 | ||
|
|
546b766022 | ||
|
|
57477749aa | ||
|
|
188f947437 | ||
|
|
b9a591aecb | ||
|
|
38de56de4a | ||
|
|
d9a250d2cb | ||
|
|
2b5ae34c5a | ||
|
|
d1a8c3a6f8 | ||
|
|
7a3f350f1c | ||
|
|
e1cab584a3 | ||
|
|
8f49f731d0 | ||
|
|
7bf384d0f8 | ||
|
|
65c9d549c1 | ||
|
|
a6cce691b5 | ||
|
|
3ccae4bb8b | ||
|
|
4ba27d84a4 | ||
|
|
cf19ad0369 | ||
|
|
32117ba477 | ||
|
|
893b66c969 | ||
|
|
6c2f179b48 | ||
|
|
dff4d766a8 | ||
|
|
d98a05023d | ||
|
|
0d37be9017 | ||
|
|
b478e5158b | ||
|
|
4901f4327f | ||
|
|
adb3f5c8c0 | ||
|
|
9b5ce3a8af | ||
|
|
9af8db0e76 | ||
|
|
59a320fd44 | ||
|
|
c72bc37630 | ||
|
|
69070b64f9 | ||
|
|
d89937a6f3 | ||
|
|
9af539b51c | ||
|
|
0ed4215a0d | ||
|
|
2243f4b653 | ||
|
|
b03e3fbec7 | ||
|
|
6d5f72bf5f | ||
|
|
7fcdbf9153 | ||
|
|
968bf9ce59 | ||
|
|
d6c47006b2 | ||
|
|
c47e37177d | ||
|
|
1513c88f77 | ||
|
|
cce3138cc1 | ||
|
|
7cc38330a8 | ||
|
|
f0bd507ab9 | ||
|
|
ecd58e1ad2 | ||
|
|
4cf1bcec64 | ||
|
|
b007428142 | ||
|
|
35d5e930ea | ||
|
|
3165d5dbc0 | ||
|
|
1c09058628 | ||
|
|
50de0536de | ||
|
|
74227d3c2b | ||
|
|
3dbcf9cbc3 | ||
|
|
44b3bd5fa5 | ||
|
|
b09b677cd8 | ||
|
|
8593600cfa | ||
|
|
b8eb2cd9c1 | ||
|
|
59abecaf5b | ||
|
|
3e26c1a83f | ||
|
|
c912601e1c | ||
|
|
7ba880e902 | ||
|
|
d99462be01 | ||
|
|
0b436d49e6 | ||
|
|
f4d84e4b58 | ||
|
|
c8f4173262 | ||
|
|
0a9d9cdded | ||
|
|
5fffbbe381 | ||
|
|
3e7a8b5f85 | ||
|
|
8557ee55f5 | ||
|
|
56bdf59e14 | ||
|
|
77af16c9e4 | ||
|
|
0cf49bb8b6 | ||
|
|
10f6ce45db | ||
|
|
9b4b45671c | ||
|
|
0b486ccf44 | ||
|
|
47b023e858 | ||
|
|
206ab163b6 | ||
|
|
2aabbbae58 | ||
|
|
728e40c96c | ||
|
|
98438c437f | ||
|
|
84f68c68cb | ||
|
|
1d8318ce26 | ||
|
|
a61c783e2d | ||
|
|
69fd5f1b33 | ||
|
|
9b407034a8 | ||
|
|
c15f693dbb | ||
|
|
7e2cb5fe1c | ||
|
|
104a7ed4fa | ||
|
|
e72e4491d1 | ||
|
|
16fd3a57ff | ||
|
|
e364cbc3ff | ||
|
|
649a841ef4 | ||
|
|
15c95399f8 | ||
|
|
0d85152e42 | ||
|
|
6c009b71d3 | ||
|
|
0f66e99f78 | ||
|
|
6c275fcec2 | ||
|
|
16ac034aab | ||
|
|
b158b4924e | ||
|
|
98ada3f9ee | ||
|
|
090a62a2c8 | ||
|
|
654ac6e62e | ||
|
|
fec769c80e | ||
|
|
6a02d9efd5 | ||
|
|
b8ddc30252 | ||
|
|
47c4fbc8ef | ||
|
|
ae40641963 | ||
|
|
4df853eff9 | ||
|
|
41c1cf6e01 | ||
|
|
c3822da2d2 | ||
|
|
0fa072d04c | ||
|
|
6dba37be47 | ||
|
|
fce84397f4 | ||
|
|
4064a7b984 | ||
|
|
d78ef56314 | ||
|
|
37d3c851cf | ||
|
|
fbf345fc68 | ||
|
|
9dddd144a4 | ||
|
|
09b63eafad | ||
|
|
9233d90075 | ||
|
|
74e68408e6 | ||
|
|
726b2b9d18 | ||
|
|
0c0396d0d4 | ||
|
|
556b7129ca | ||
|
|
c6cd8145eb | ||
|
|
5d904dfd66 | ||
|
|
dcb3b6ed4d | ||
|
|
191601f129 | ||
|
|
211e3d4141 | ||
|
|
980680f3d6 | ||
|
|
50ae48295d | ||
|
|
9b7685e5d1 | ||
|
|
d17cfb0c5d | ||
|
|
9cd65a4033 | ||
|
|
5a166e8e80 | ||
|
|
06363ccc77 | ||
|
|
2f8ac24128 | ||
|
|
71bc666a8e | ||
|
|
12a8c682bd | ||
|
|
62ed9e2c4d | ||
|
|
449480bf01 | ||
|
|
7bf5b0106d | ||
|
|
fb5fce026d | ||
|
|
35da4809d4 | ||
|
|
a88a704bef | ||
|
|
539df21627 | ||
|
|
0314f4bdea | ||
|
|
ffd2859cba | ||
|
|
362ad7b7d0 | ||
|
|
25c2cd1f2d | ||
|
|
466090c79b | ||
|
|
b9777dec35 | ||
|
|
f49c6aa0f3 | ||
|
|
f3b79bcc40 | ||
|
|
af75988dd4 | ||
|
|
7278507c42 | ||
|
|
6f737056a2 | ||
|
|
298753d59e | ||
|
|
1d5a0e4316 | ||
|
|
35c5b190b4 | ||
|
|
df6cbc4afa | ||
|
|
d93d07795b | ||
|
|
c98450d1d5 | ||
|
|
b6e100096d | ||
|
|
338c4de8b4 | ||
|
|
b5d720e091 | ||
|
|
2b1ca9f5b7 | ||
|
|
1fef1991ef | ||
|
|
e4cf1ce207 | ||
|
|
63046baffd | ||
|
|
d102d954ac | ||
|
|
578ae6b5dd | ||
|
|
f7aa8b7ad5 | ||
|
|
7b9eb86fd0 | ||
|
|
b17fa09393 | ||
|
|
0e172b8030 | ||
|
|
3308012dcf | ||
|
|
376b67be9e | ||
|
|
72aa664b61 | ||
|
|
1372de6f43 | ||
|
|
430baf23b9 | ||
|
|
2f166088c8 | ||
|
|
744bbf7203 | ||
|
|
a253ff325d | ||
|
|
67668a02c2 | ||
|
|
cd27e9dcde | ||
|
|
da97bb84e3 | ||
|
|
0bf4146633 | ||
|
|
9c42a78cf3 | ||
|
|
9106f70cfa | ||
|
|
99d9984111 | ||
|
|
acd75a3997 | ||
|
|
46b3ce5631 | ||
|
|
e571cb9fcb | ||
|
|
c1764c4976 | ||
|
|
86e81c135e | ||
|
|
1a25bb501f | ||
|
|
a9ffde4d5f | ||
|
|
0220040341 | ||
|
|
11aeb4fbda | ||
|
|
d3d1fbe7e5 | ||
|
|
816d577f53 | ||
|
|
a7316aff01 | ||
|
|
e88a0577e5 | ||
|
|
c10bde8d65 | ||
|
|
0be5f5299f | ||
|
|
fd0b354cd0 | ||
|
|
733b2cc05b | ||
|
|
077bb5a434 | ||
|
|
d1ed884a8d | ||
|
|
103fa0550c | ||
|
|
563c5fa778 | ||
|
|
0e456543bf | ||
|
|
d34056b3b9 | ||
|
|
74ea4aba37 | ||
|
|
4b0c5cb36f | ||
|
|
cf4e157de7 | ||
|
|
b916ed907b | ||
|
|
e8426671c0 | ||
|
|
455f086880 | ||
|
|
42110643a3 | ||
|
|
e315dbc91e | ||
|
|
e272c2ed08 | ||
|
|
a875db2b82 | ||
|
|
02c6de4144 | ||
|
|
7563b5e2f4 | ||
|
|
5e3d9442af | ||
|
|
c0c1a260ba | ||
|
|
6ebd7fd2d7 | ||
|
|
24dd4e8626 | ||
|
|
dc727f900d | ||
|
|
0847a38691 | ||
|
|
7c83edc402 | ||
|
|
e76de196e0 | ||
|
|
b7331135a6 | ||
|
|
0940b2dccf | ||
|
|
4f53aef74f | ||
|
|
f4027cb5fd | ||
|
|
38afe71ec7 | ||
|
|
308c006a30 | ||
|
|
c7d863c998 | ||
|
|
44cdc97d71 | ||
|
|
a87dcd5553 | ||
|
|
592dd39222 | ||
|
|
45d0f85b0d | ||
|
|
b68fcd2522 | ||
|
|
abd81c998b | ||
|
|
6a2edb2844 | ||
|
|
5c38b4328b | ||
|
|
2cb04c1d5c | ||
|
|
b089e0a7dd | ||
|
|
a21464ddca | ||
|
|
900b9f1991 | ||
|
|
cf16f90fab | ||
|
|
36a6b10d0d | ||
|
|
2ac3ad9e37 | ||
|
|
9e6542680b | ||
|
|
7e41b620ff | ||
|
|
18e3c30ad8 | ||
|
|
0dbd0ece9a | ||
|
|
c13f0a369b | ||
|
|
cacc725e41 | ||
|
|
0dc33dbf9f | ||
|
|
d9205bd838 | ||
|
|
88aad4b1b6 | ||
|
|
5aadfba84b | ||
|
|
eae5857d0e | ||
|
|
c32d13c8f1 | ||
|
|
0401a8eb13 | ||
|
|
9a1a87ff8e | ||
|
|
16e3b16081 | ||
|
|
200e5a26cc | ||
|
|
b8bbaa7764 | ||
|
|
1470091f1c | ||
|
|
31738d745f | ||
|
|
f19d4300bc | ||
|
|
cd81e9346f | ||
|
|
01355f39a1 | ||
|
|
87298f580a | ||
|
|
3bfe64dd06 | ||
|
|
b9d3eedb9d | ||
|
|
bb4126bf3a | ||
|
|
2e440822cb | ||
|
|
13eed84f57 | ||
|
|
e5fed86965 | ||
|
|
3c5fab009b | ||
|
|
b858626e17 | ||
|
|
330cc91645 | ||
|
|
1037824e11 | ||
|
|
4cc08a11e7 | ||
|
|
358254639a | ||
|
|
43bc9bfa83 | ||
|
|
a779e9eb8b | ||
|
|
f67e195f76 | ||
|
|
508d722fb2 | ||
|
|
14d7364f4b | ||
|
|
bfbce27a65 | ||
|
|
00a23058c8 | ||
|
|
82c74ed21f | ||
|
|
5f0b4977da | ||
|
|
82214856b6 | ||
|
|
c09adb967a | ||
|
|
e5d420b2db | ||
|
|
ef021056c9 | ||
|
|
b7b082cd8e | ||
|
|
a02632f18c | ||
|
|
e43ad54dbf | ||
|
|
8af91e262c | ||
|
|
7b94fb608d | ||
|
|
cf176c4100 | ||
|
|
c61418635e | ||
|
|
dba827d1fd | ||
|
|
e315ad99b4 | ||
|
|
088df7e6be | ||
|
|
40eec0b2ff | ||
|
|
77bec52be7 | ||
|
|
155d1dee6b | ||
|
|
0548d65911 | ||
|
|
40a30d7b02 | ||
|
|
62af792add | ||
|
|
4cd2475bf2 | ||
|
|
fc4c792f9e | ||
|
|
c094e5a0fc | ||
|
|
9d0f3573c6 | ||
|
|
4496a95014 | ||
|
|
893b7a7126 | ||
|
|
3b23c4aa5b | ||
|
|
d466ea45ff | ||
|
|
7ba5363d25 | ||
|
|
f28b03f419 | ||
|
|
de772b9246 | ||
|
|
c28b26d949 | ||
|
|
c02dd4aa98 | ||
|
|
b0974a4e36 | ||
|
|
17acd134c7 | ||
|
|
d4a4bbf966 | ||
|
|
7b7babd1d1 | ||
|
|
072a6ce4c7 | ||
|
|
d5c1438c6e | ||
|
|
30e5032ade | ||
|
|
d7fe59f0b0 | ||
|
|
8a006f07a7 | ||
|
|
01ab540ffe | ||
|
|
de848f64fa | ||
|
|
52e689b830 | ||
|
|
ef3e2511fe | ||
|
|
6b4b763bc4 | ||
|
|
6b1c8b3e39 | ||
|
|
f3293cfac1 | ||
|
|
0dd8a498b2 | ||
|
|
f9b8e6879d | ||
|
|
e73a4c66bc | ||
|
|
fc3c885bb6 | ||
|
|
8b3d224b80 | ||
|
|
ce6e52e9ba | ||
|
|
4dd4eeb421 | ||
|
|
b50882db3a | ||
|
|
0058b5df99 | ||
|
|
f7926e9f28 | ||
|
|
f65557573c | ||
|
|
4dec445b82 | ||
|
|
ccb3eba9e3 | ||
|
|
56426b896a | ||
|
|
7702d38267 | ||
|
|
a044398552 | ||
|
|
17db68ae2d | ||
|
|
e53fb10483 | ||
|
|
33757e537b | ||
|
|
945ef61188 | ||
|
|
f990a519a5 | ||
|
|
2149bea8e3 | ||
|
|
7ce10dbcf1 | ||
|
|
31f46d5220 | ||
|
|
a0ef4eae4d | ||
|
|
aec11e34a7 | ||
|
|
95f26604ba | ||
|
|
c6d47eeb77 | ||
|
|
8500067e0f | ||
|
|
20573632d7 | ||
|
|
03468c83df | ||
|
|
4e3a04ea72 | ||
|
|
e6e324e8ff | ||
|
|
2175ae4d28 | ||
|
|
c887f2b3b4 | ||
|
|
1a3c54793d | ||
|
|
4679e2a514 | ||
|
|
e02dc2e92e | ||
|
|
536acec820 | ||
|
|
55da918240 | ||
|
|
f3d4d1fb60 | ||
|
|
dac31c4f2e | ||
|
|
677c73cfe5 | ||
|
|
b85fc37130 | ||
|
|
f634b7fe98 | ||
|
|
75ebd1bd24 | ||
|
|
cbb2ba7d42 | ||
|
|
616541aaee | ||
|
|
bbf8365cd2 | ||
|
|
a0b390e735 | ||
|
|
1f557a92a4 | ||
|
|
51f71eb53d | ||
|
|
ac73e8d77b | ||
|
|
8b13e7dd73 | ||
|
|
3be04d7f30 | ||
|
|
b9fd083c77 | ||
|
|
fec316b087 | ||
|
|
3844f3ee96 | ||
|
|
cb3977679d | ||
|
|
6122bc4108 | ||
|
|
abc30d5170 | ||
|
|
4b515c5df4 | ||
|
|
2d5210464a | ||
|
|
684b11badb | ||
|
|
7b92449343 | ||
|
|
72cd6c37bd | ||
|
|
d6102cc908 | ||
|
|
5faa80b172 | ||
|
|
74d76c690f | ||
|
|
301d9b6a86 | ||
|
|
b5e6a93b32 | ||
|
|
e7c711002a | ||
|
|
6dce40e454 | ||
|
|
9ba603660e | ||
|
|
5ee44c6c21 | ||
|
|
03bec64097 | ||
|
|
4fcc0fbc94 | ||
|
|
80ade96e9b | ||
|
|
860e437078 | ||
|
|
9a596c2500 | ||
|
|
0d9d74dc1c | ||
|
|
88d98d6d62 | ||
|
|
c93c4d8c30 | ||
|
|
021e78d962 | ||
|
|
2c1f9e7eac | ||
|
|
96533fa2fe | ||
|
|
92df3d3243 | ||
|
|
90d393f968 | ||
|
|
a0b4cd6bf9 | ||
|
|
943c38125f | ||
|
|
4b637bb54e | ||
|
|
576a38c407 | ||
|
|
5408ca26ad | ||
|
|
0161007390 | ||
|
|
7d343fdcca | ||
|
|
07f63090bb | ||
|
|
48f9ce114e | ||
|
|
3a3d4038cb | ||
|
|
04e1186f08 | ||
|
|
651daa00d7 | ||
|
|
72002f452e | ||
|
|
70ff55c8da | ||
|
|
4626b4480b | ||
|
|
a61778dba0 | ||
|
|
bdfb0a7680 | ||
|
|
437a592c65 | ||
|
|
ca2e140c30 | ||
|
|
7e07ee7bea | ||
|
|
7bf65a95d8 | ||
|
|
6534eeb1a2 | ||
|
|
eef4e3c647 | ||
|
|
1d848b8d72 | ||
|
|
b26fbaf4db | ||
|
|
f1fbc98040 | ||
|
|
48c041a99f | ||
|
|
30a844ff58 | ||
|
|
b46b3c7b9a | ||
|
|
079cf0aa54 | ||
|
|
0589ddcd90 | ||
|
|
f85975f5ad | ||
|
|
d1de6615f2 | ||
|
|
8f0ba7c41e | ||
|
|
7392d204bf | ||
|
|
ee82ad1864 | ||
|
|
6446fc962d | ||
|
|
88d4798d5e | ||
|
|
abe87f2f16 | ||
|
|
b08a402df8 | ||
|
|
f95b501232 | ||
|
|
66a34fccdf | ||
|
|
4544ce3255 | ||
|
|
4bc4a68051 | ||
|
|
1acd07cb61 | ||
|
|
2cf903cebe | ||
|
|
9ee2b74a5d | ||
|
|
6ef9000b31 | ||
|
|
bacc899fb4 | ||
|
|
7f0dc82d7a | ||
|
|
155e2d1c0c | ||
|
|
ccadcec6d5 | ||
|
|
02f707cd5c | ||
|
|
848c7da4d9 | ||
|
|
3cd01f9859 | ||
|
|
aed97d97f8 | ||
|
|
b3144aee15 | ||
|
|
87ceac7b00 | ||
|
|
a6512a2a69 | ||
|
|
6f7bf30a2d | ||
|
|
dad41cd3ed | ||
|
|
52849065cc | ||
|
|
728decb0b8 | ||
|
|
4cc0189ac9 | ||
|
|
8b4cb4be6e | ||
|
|
d95aeb15f9 | ||
|
|
6cc8ca707f | ||
|
|
63805d05a1 | ||
|
|
f66f9e7c92 | ||
|
|
213d86f3e3 | ||
|
|
969a95b29b | ||
|
|
ca3ab7babf | ||
|
|
0961524e70 | ||
|
|
027bbc6074 | ||
|
|
c1c44aae05 | ||
|
|
3bc1ad0133 | ||
|
|
ca5078b4f8 | ||
|
|
67a3194903 | ||
|
|
24b7a7d3e9 | ||
|
|
0e56bff5d6 | ||
|
|
b8b5fac03c | ||
|
|
e88fa7db31 | ||
|
|
598b2bfbda | ||
|
|
f4c73c7666 | ||
|
|
cbbb1871bb | ||
|
|
093874469e | ||
|
|
d21120962d | ||
|
|
27ad4641d0 | ||
|
|
65e7744858 | ||
|
|
c2e3dd716c | ||
|
|
1266bad2f8 | ||
|
|
71dab115c9 | ||
|
|
205aec20fe | ||
|
|
83fa3070fb | ||
|
|
a2870494c9 | ||
|
|
614b4dfbab | ||
|
|
cbc6685725 | ||
|
|
f17d47790a | ||
|
|
334f900e15 | ||
|
|
6804d91f8d | ||
|
|
54bf0b460f | ||
|
|
fa281fe59c | ||
|
|
048a8531f2 | ||
|
|
ed51502387 | ||
|
|
e7a47215f0 | ||
|
|
b04e2ec663 | ||
|
|
b651a45734 | ||
|
|
1a54135f0d | ||
|
|
c6f69956f6 | ||
|
|
c681ff2d9d | ||
|
|
5e22538684 | ||
|
|
d6d0ba524f | ||
|
|
cd53402ace | ||
|
|
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 | ||
|
|
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 | ||
|
|
1b402abaf2 | ||
|
|
368bf34842 | ||
|
|
c024687629 | ||
|
|
e210283a70 | ||
|
|
3d2a78e962 | ||
|
|
34c153086c | ||
|
|
aab2f0d1f1 | ||
|
|
cfad68f5cb | ||
|
|
37b1d7d692 | ||
|
|
28b7b80bbb | ||
|
|
14e806759d | ||
|
|
b3e88a37d3 | ||
|
|
4803d7c828 | ||
|
|
2e4c862d25 | ||
|
|
6b118819d7 | ||
|
|
2c1236652e | ||
|
|
066d120c0e | ||
|
|
6b7e833023 | ||
|
|
246c47dba4 | ||
|
|
6fd0d5648f | ||
|
|
738989c375 | ||
|
|
9458ee3292 | ||
|
|
915ce0fa9d | ||
|
|
482a43d722 | ||
|
|
f7e6005087 | ||
|
|
f02b8381ea | ||
|
|
58d009b388 | ||
|
|
d43bac6e8f | ||
|
|
d8b7332acc | ||
|
|
1fdb700bc8 | ||
|
|
c265cf829b | ||
|
|
5390fb702d | ||
|
|
00c8303fed | ||
|
|
cd3a149aa4 | ||
|
|
97d612a60d | ||
|
|
c6d40fff8c | ||
|
|
5de9db0ca1 | ||
|
|
a624eb08ce | ||
|
|
af15046d3f | ||
|
|
7692693aae | ||
|
|
e40dcb1324 | ||
|
|
826de74a5d | ||
|
|
f5fb78ec99 | ||
|
|
c606149c43 | ||
|
|
d4a8716a0e | ||
|
|
21bd921ee7 | ||
|
|
8b28b33da3 | ||
|
|
91dfd47f1d | ||
|
|
aad19b53c7 | ||
|
|
cea4cc5860 | ||
|
|
ca4014f751 | ||
|
|
2662e24991 | ||
|
|
2b8f5f791a | ||
|
|
d000a6c61a | ||
|
|
71ef03070e | ||
|
|
81ee7cbd5e | ||
|
|
250a4dcfb2 | ||
|
|
6e55017f96 | ||
|
|
a192cadced | ||
|
|
044e3865ac | ||
|
|
2ea7bc29c6 | ||
|
|
a3aba84ba8 | ||
|
|
4e9afc8096 | ||
|
|
f56efeb039 | ||
|
|
8b5f4e8556 | ||
|
|
7ef5b7b43a | ||
|
|
241a38e958 | ||
|
|
aae55e0282 | ||
|
|
31f1a30d6f | ||
|
|
275002e25d | ||
|
|
3add3f5b6c | ||
|
|
1f80b806bd | ||
|
|
609f1aabca | ||
|
|
2c67b8d7ba | ||
|
|
6dbb69a828 | ||
|
|
3733a84a38 | ||
|
|
e37b2d8c59 | ||
|
|
2373a41699 | ||
|
|
2cbf2af6c7 | ||
|
|
fdcf074dfd | ||
|
|
de43ac52c4 | ||
|
|
f6aa93f094 | ||
|
|
5feb334191 | ||
|
|
3bd10004a7 | ||
|
|
02c405ad80 |
@@ -0,0 +1,367 @@
|
||||
version: 2
|
||||
|
||||
build-distro-bin: &build-distro-bin
|
||||
machine: true
|
||||
steps:
|
||||
- checkout
|
||||
# cannot interpolate env var and use as a cache key so just copy the Dockerfile to another filename
|
||||
- run: cp docker/distro_release/Dockerfile.$CIRCLE_JOB docker/distro_release/Dockerfile
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-{{ .Environment.CIRCLE_JOB }}-image-{{ checksum "docker/distro_release/Dockerfile" }}
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-{{ .Environment.CIRCLE_JOB }}-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
- run:
|
||||
name: load or build docker image
|
||||
command: |
|
||||
if [[ -e ~/image.tar ]]; then
|
||||
docker load -i ~/image.tar
|
||||
else
|
||||
docker build --rm=false -t $CIRCLE_JOB -f docker/distro_release/Dockerfile.$CIRCLE_JOB docker/distro_release/
|
||||
docker save $CIRCLE_JOB > ~/image.tar
|
||||
fi
|
||||
- run:
|
||||
name: build binary
|
||||
command: |
|
||||
docker run -it \
|
||||
-v $HOME/.stack:/root/.stack \
|
||||
-v $(pwd):/source \
|
||||
-v $HOME/bin/:/root/.local/bin/ \
|
||||
$CIRCLE_JOB build --allow-different-user --install-ghc --copy-bins
|
||||
# volumes owned by root if chown is not done the save_cache step fails silently
|
||||
sudo chown -R circleci:circleci ~/.stack .stack-work
|
||||
- run:
|
||||
name: compress binary
|
||||
command: |
|
||||
mkdir -p /tmp/workspace/bin
|
||||
cd /tmp/workspace/bin
|
||||
tar cvJf postgrest-$CIRCLE_TAG-$CIRCLE_JOB.tar.xz -C ~/bin postgrest
|
||||
- persist_to_workspace:
|
||||
root: /tmp/workspace
|
||||
paths:
|
||||
- bin/*
|
||||
- save_cache:
|
||||
paths:
|
||||
- ~/image.tar
|
||||
key: v1-{{ .Environment.CIRCLE_JOB }}-image-{{ checksum "docker/distro_release/Dockerfile" }}
|
||||
- save_cache:
|
||||
paths:
|
||||
- "~/.stack"
|
||||
- ".stack-work"
|
||||
key: v1-{{ .Environment.CIRCLE_JOB }}-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
|
||||
jobs:
|
||||
build-test-9.4:
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:trusty
|
||||
environment:
|
||||
- PGHOST=localhost
|
||||
- image: circleci/postgres:9.4.14
|
||||
environment:
|
||||
- POSTGRES_USER=circleci
|
||||
- POSTGRES_DB=circleci
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
- run:
|
||||
name: install ncat
|
||||
command: |
|
||||
# utility needed to test socket connection with curl < 7.40
|
||||
sudo apt-get install nmap
|
||||
- run:
|
||||
name: install stack & dependencies
|
||||
command: |
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgmp-dev
|
||||
sudo apt-get install -y --only-upgrade binutils
|
||||
sudo apt-get install -y postgresql-client
|
||||
stack setup
|
||||
rm -rf $(stack path --dist-dir) $(stack path --local-install-root)
|
||||
stack install hlint stylish-haskell
|
||||
- run:
|
||||
name: build src and tests
|
||||
command: |
|
||||
stack build --fast -j1
|
||||
stack build --fast --test --no-run-tests
|
||||
- run:
|
||||
name: run tests
|
||||
command: |
|
||||
POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
||||
test/io-tests.sh
|
||||
- run:
|
||||
name: run linter
|
||||
command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$@"
|
||||
- run:
|
||||
name: run styler
|
||||
command: git ls-files | grep '\.l\?hs$' | xargs stack exec -- stylish-haskell -i && git diff-index --exit-code HEAD --
|
||||
- save_cache:
|
||||
paths:
|
||||
- "~/.stack"
|
||||
- ".stack-work"
|
||||
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
|
||||
build-test-9.6:
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:trusty
|
||||
environment:
|
||||
- PGHOST=localhost
|
||||
- image: circleci/postgres:9.6.2
|
||||
environment:
|
||||
- POSTGRES_USER=circleci
|
||||
- POSTGRES_DB=circleci
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
- run:
|
||||
name: install stack & dependencies
|
||||
command: |
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgmp-dev
|
||||
sudo apt-get install -y postgresql-client
|
||||
stack setup
|
||||
- run:
|
||||
name: build src and tests
|
||||
command: |
|
||||
stack build --fast -j1
|
||||
stack build --fast --test --no-run-tests
|
||||
- run:
|
||||
name: run tests
|
||||
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
||||
|
||||
build-test-10:
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:trusty
|
||||
environment:
|
||||
- PGHOST=localhost
|
||||
- image: circleci/postgres:10.5
|
||||
environment:
|
||||
- POSTGRES_USER=circleci
|
||||
- POSTGRES_DB=circleci
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
- run:
|
||||
name: install stack & dependencies
|
||||
command: |
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgmp-dev
|
||||
sudo apt-get install -y postgresql-client
|
||||
stack setup
|
||||
- run:
|
||||
name: build src and tests
|
||||
command: |
|
||||
stack build --fast -j1
|
||||
stack build --fast --test --no-run-tests
|
||||
- run:
|
||||
name: run tests
|
||||
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
||||
|
||||
build-test-11:
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:trusty
|
||||
environment:
|
||||
- PGHOST=localhost
|
||||
- image: circleci/postgres:11
|
||||
environment:
|
||||
- POSTGRES_USER=circleci
|
||||
- POSTGRES_DB=circleci
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
- run:
|
||||
name: install stack & dependencies
|
||||
command: |
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgmp-dev
|
||||
sudo apt-get install -y postgresql-client
|
||||
stack setup
|
||||
- run:
|
||||
name: build src and tests
|
||||
command: |
|
||||
stack build --fast -j1
|
||||
stack build --fast --test --no-run-tests
|
||||
- run:
|
||||
name: run tests
|
||||
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
||||
|
||||
build-prof-test:
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:trusty
|
||||
environment:
|
||||
- PGHOST=localhost
|
||||
- TERM=xterm
|
||||
- image: circleci/postgres:9.6.2
|
||||
environment:
|
||||
- POSTGRES_USER=circleci
|
||||
- POSTGRES_DB=circleci
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
keys:
|
||||
- v1-stack-prof-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
- run:
|
||||
name: install stack & dependencies
|
||||
command: |
|
||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.9.3/stack-1.9.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||
sudo mv /tmp/stack-1.9.3-linux-x86_64/stack /usr/bin
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgmp-dev
|
||||
sudo apt-get install -y postgresql-client
|
||||
stack setup
|
||||
- run:
|
||||
name: build with profiling enabled
|
||||
command: |
|
||||
stack build --profile -j1
|
||||
- run:
|
||||
name: run memory usage tests
|
||||
command: |
|
||||
test/create_test_db "postgres://circleci@localhost" postgrest_test
|
||||
psql "postgres:///postgrest_test" -f test/fixtures/database.sql
|
||||
psql "postgres:///postgrest_test" -f test/fixtures/roles.sql
|
||||
psql "postgres:///postgrest_test" -f test/fixtures/schema.sql
|
||||
psql "postgres:///postgrest_test" -f test/fixtures/jwt.sql
|
||||
psql "postgres:///postgrest_test" -f test/fixtures/jsonschema.sql
|
||||
psql "postgres:///postgrest_test" -f test/fixtures/privileges.sql
|
||||
test/memory-tests.sh
|
||||
- save_cache:
|
||||
paths:
|
||||
- "~/.stack"
|
||||
- ".stack-work"
|
||||
key: v1-stack-prof-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||
|
||||
centos6:
|
||||
<<: *build-distro-bin
|
||||
|
||||
centos7:
|
||||
<<: *build-distro-bin
|
||||
|
||||
ubuntu:
|
||||
<<: *build-distro-bin
|
||||
|
||||
ubuntui386:
|
||||
<<: *build-distro-bin
|
||||
|
||||
release:
|
||||
docker:
|
||||
- image: circleci/golang:1.8
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: /tmp/workspace
|
||||
- checkout
|
||||
- run:
|
||||
name: add body and tars to github release
|
||||
command: |
|
||||
go get -u github.com/tcnksm/ghr
|
||||
START=$(echo $CIRCLE_TAG | cut -c2-)
|
||||
END='## \['
|
||||
BODY=$(sed -n "1,/$START/d;/$END/q;p" CHANGELOG.md)
|
||||
ghr -t $GITHUB_TOKEN -u $CIRCLE_PROJECT_USERNAME -r $CIRCLE_PROJECT_REPONAME -b "$BODY" --replace $CIRCLE_TAG /tmp/workspace/bin
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: publish docker image
|
||||
command: |
|
||||
docker build --build-arg POSTGREST_VERSION=$CIRCLE_TAG -t postgrest ./docker/
|
||||
docker login -u $DOCKER_USER -p $DOCKER_PASS
|
||||
docker tag postgrest postgrest/postgrest:$CIRCLE_TAG
|
||||
docker push postgrest/postgrest:$CIRCLE_TAG
|
||||
docker tag postgrest postgrest/postgrest:latest
|
||||
docker push postgrest/postgrest:latest
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build-test-release:
|
||||
jobs:
|
||||
- build-test-9.4:
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
- build-test-9.6:
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
- build-test-10:
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
- build-test-11:
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
- build-prof-test:
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
- centos6:
|
||||
requires:
|
||||
- build-test-9.4
|
||||
- build-test-9.6
|
||||
- build-test-10
|
||||
- build-test-11
|
||||
- build-prof-test
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
- centos7:
|
||||
requires:
|
||||
- build-test-9.4
|
||||
- build-test-9.6
|
||||
- build-test-10
|
||||
- build-test-11
|
||||
- build-prof-test
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
- ubuntu:
|
||||
requires:
|
||||
- build-test-9.4
|
||||
- build-test-9.6
|
||||
- build-test-10
|
||||
- build-test-11
|
||||
- build-prof-test
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
- ubuntui386:
|
||||
requires:
|
||||
- build-test-9.4
|
||||
- build-test-9.6
|
||||
- build-test-10
|
||||
- build-test-11
|
||||
- build-prof-test
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
- release:
|
||||
requires:
|
||||
- centos6
|
||||
- centos7
|
||||
- ubuntu
|
||||
- ubuntui386
|
||||
filters:
|
||||
tags:
|
||||
only: /v[0-9]+(\.[0-9]+)*/
|
||||
@@ -0,0 +1 @@
|
||||
/CHANGELOG.md merge=union
|
||||
@@ -27,6 +27,14 @@ your contributions.
|
||||
* 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).
|
||||
|
||||
* If your database schema has changed while the PostgREST server is running,
|
||||
[send the server a `SIGUSR1` signal](http://postgrest.org/en/v5.2/admin.html#schema-reloading) or restart it to ensure the schema cache
|
||||
is not stale. This sometimes fixes apparent bugs.
|
||||
|
||||
## Code
|
||||
|
||||
### Haskell Conventions
|
||||
@@ -40,16 +48,11 @@ your contributions.
|
||||
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)
|
||||
source](https://postgrest.com/en/stable/install.html#build-from-source)
|
||||
wiki page.
|
||||
|
||||
## Maintenance
|
||||
### Running Tests
|
||||
|
||||
### Schedule
|
||||
For instructions on running tests, see the official docs hosted here:
|
||||
|
||||
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.
|
||||
https://postgrest.com/en/stable/install.html#postgrest-test-suite
|
||||
@@ -0,0 +1,11 @@
|
||||
### Environment
|
||||
|
||||
* PostgreSQL version: (if using docker, specify the image)
|
||||
* PostgREST version: (if using docker, specify the image)
|
||||
* Operating system:
|
||||
|
||||
### Description of issue
|
||||
|
||||
(Expected behavior vs actual behavior)
|
||||
|
||||
(Steps to reproduce: Include a minimal SQL definition plus how you make the request to PostgREST and the response body)
|
||||
@@ -6,6 +6,9 @@ cabal.sandbox.config
|
||||
hscope.out
|
||||
codex.tags
|
||||
.anvil
|
||||
.stack-work
|
||||
.stack-work*
|
||||
tags
|
||||
site
|
||||
*~
|
||||
*#*
|
||||
.#*
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# stylish-haskell configuration file
|
||||
# ==================================
|
||||
|
||||
# The stylish-haskell tool is mainly configured by specifying steps. These steps
|
||||
# are a list, so they have an order, and one specific step may appear more than
|
||||
# once (if needed). Each file is processed by these steps in the given order.
|
||||
steps:
|
||||
# Convert some ASCII sequences to their Unicode equivalents. This is disabled
|
||||
# by default.
|
||||
# - unicode_syntax:
|
||||
# # In order to make this work, we also need to insert the UnicodeSyntax
|
||||
# # language pragma. If this flag is set to true, we insert it when it's
|
||||
# # not already present. You may want to disable it if you configure
|
||||
# # language extensions using some other method than pragmas. Default:
|
||||
# # true.
|
||||
# add_language_pragma: true
|
||||
|
||||
# Align the right hand side of some elements. This is quite conservative
|
||||
# and only applies to statements where each element occupies a single
|
||||
# line.
|
||||
- simple_align:
|
||||
cases: true
|
||||
top_level_patterns: true
|
||||
records: true
|
||||
|
||||
# Import cleanup
|
||||
- imports:
|
||||
# There are different ways we can align names and lists.
|
||||
#
|
||||
# - global: Align the import names and import list throughout the entire
|
||||
# file.
|
||||
#
|
||||
# - file: Like global, but don't add padding when there are no qualified
|
||||
# imports in the file.
|
||||
#
|
||||
# - group: Only align the imports per group (a group is formed by adjacent
|
||||
# import lines).
|
||||
#
|
||||
# - none: Do not perform any alignment.
|
||||
#
|
||||
# Default: global.
|
||||
align: group
|
||||
|
||||
# The following options affect only import list alignment.
|
||||
#
|
||||
# List align has following options:
|
||||
#
|
||||
# - after_alias: Import list is aligned with end of import including
|
||||
# 'as' and 'hiding' keywords.
|
||||
#
|
||||
# > import qualified Data.List as List (concat, foldl, foldr, head,
|
||||
# > init, last, length)
|
||||
#
|
||||
# - with_alias: Import list is aligned with start of alias or hiding.
|
||||
#
|
||||
# > import qualified Data.List as List (concat, foldl, foldr, head,
|
||||
# > init, last, length)
|
||||
#
|
||||
# - new_line: Import list starts always on new line.
|
||||
#
|
||||
# > import qualified Data.List as List
|
||||
# > (concat, foldl, foldr, head, init, last, length)
|
||||
#
|
||||
# Default: after_alias
|
||||
list_align: after_alias
|
||||
|
||||
# Right-pad the module names to align imports in a group:
|
||||
#
|
||||
# - true: a little more readable
|
||||
#
|
||||
# > import qualified Data.List as List (concat, foldl, foldr,
|
||||
# > init, last, length)
|
||||
# > import qualified Data.List.Extra as List (concat, foldl, foldr,
|
||||
# > init, last, length)
|
||||
#
|
||||
# - false: diff-safe
|
||||
#
|
||||
# > import qualified Data.List as List (concat, foldl, foldr, init,
|
||||
# > last, length)
|
||||
# > import qualified Data.List.Extra as List (concat, foldl, foldr,
|
||||
# > init, last, length)
|
||||
#
|
||||
# Default: true
|
||||
pad_module_names: true
|
||||
|
||||
# Long list align style takes effect when import is too long. This is
|
||||
# determined by 'columns' setting.
|
||||
#
|
||||
# - inline: This option will put as much specs on same line as possible.
|
||||
#
|
||||
# - new_line: Import list will start on new line.
|
||||
#
|
||||
# - new_line_multiline: Import list will start on new line when it's
|
||||
# short enough to fit to single line. Otherwise it'll be multiline.
|
||||
#
|
||||
# - multiline: One line per import list entry.
|
||||
# Type with constructor list acts like single import.
|
||||
#
|
||||
# > import qualified Data.Map as M
|
||||
# > ( empty
|
||||
# > , singleton
|
||||
# > , ...
|
||||
# > , delete
|
||||
# > )
|
||||
#
|
||||
# Default: inline
|
||||
long_list_align: inline
|
||||
|
||||
# Align empty list (importing instances)
|
||||
#
|
||||
# Empty list align has following options
|
||||
#
|
||||
# - inherit: inherit list_align setting
|
||||
#
|
||||
# - right_after: () is right after the module name:
|
||||
#
|
||||
# > import Vector.Instances ()
|
||||
#
|
||||
# Default: inherit
|
||||
empty_list_align: inherit
|
||||
|
||||
# List padding determines indentation of import list on lines after import.
|
||||
# This option affects 'long_list_align'.
|
||||
#
|
||||
# - <integer>: constant value
|
||||
#
|
||||
# - module_name: align under start of module name.
|
||||
# Useful for 'file' and 'group' align settings.
|
||||
list_padding: 4
|
||||
|
||||
# Separate lists option affects formatting of import list for type
|
||||
# or class. The only difference is single space between type and list
|
||||
# of constructors, selectors and class functions.
|
||||
#
|
||||
# - true: There is single space between Foldable type and list of it's
|
||||
# functions.
|
||||
#
|
||||
# > import Data.Foldable (Foldable (fold, foldl, foldMap))
|
||||
#
|
||||
# - false: There is no space between Foldable type and list of it's
|
||||
# functions.
|
||||
#
|
||||
# > import Data.Foldable (Foldable(fold, foldl, foldMap))
|
||||
#
|
||||
# Default: true
|
||||
separate_lists: true
|
||||
|
||||
# Space surround option affects formatting of import lists on a single
|
||||
# line. The only difference is single space after the initial
|
||||
# parenthesis and a single space before the terminal parenthesis.
|
||||
#
|
||||
# - true: There is single space associated with the enclosing
|
||||
# parenthesis.
|
||||
#
|
||||
# > import Data.Foo ( foo )
|
||||
#
|
||||
# - false: There is no space associated with the enclosing parenthesis
|
||||
#
|
||||
# > import Data.Foo (foo)
|
||||
#
|
||||
# Default: false
|
||||
space_surround: false
|
||||
|
||||
# Language pragmas
|
||||
- language_pragmas:
|
||||
# We can generate different styles of language pragma lists.
|
||||
#
|
||||
# - vertical: Vertical-spaced language pragmas, one per line.
|
||||
#
|
||||
# - compact: A more compact style.
|
||||
#
|
||||
# - compact_line: Similar to compact, but wrap each line with
|
||||
# `{-#LANGUAGE #-}'.
|
||||
#
|
||||
# Default: vertical.
|
||||
style: vertical
|
||||
|
||||
# Align affects alignment of closing pragma brackets.
|
||||
#
|
||||
# - true: Brackets are aligned in same column.
|
||||
#
|
||||
# - false: Brackets are not aligned together. There is only one space
|
||||
# between actual import and closing bracket.
|
||||
#
|
||||
# Default: true
|
||||
align: true
|
||||
|
||||
# stylish-haskell can detect redundancy of some language pragmas. If this
|
||||
# is set to true, it will remove those redundant pragmas. Default: true.
|
||||
remove_redundant: true
|
||||
|
||||
# Replace tabs by spaces. This is disabled by default.
|
||||
# - tabs:
|
||||
# # Number of spaces to use for each tab. Default: 8, as specified by the
|
||||
# # Haskell report.
|
||||
# spaces: 8
|
||||
|
||||
# Remove trailing whitespace
|
||||
- trailing_whitespace: {}
|
||||
|
||||
# A common setting is the number of columns (parts of) code will be wrapped
|
||||
# to. Different steps take this into account. Default: 80.
|
||||
columns: 70
|
||||
|
||||
# By default, line endings are converted according to the OS. You can override
|
||||
# preferred format here.
|
||||
#
|
||||
# - native: Native newline format. CRLF on Windows, LF on other OSes.
|
||||
#
|
||||
# - lf: Convert to LF ("\n").
|
||||
#
|
||||
# - crlf: Convert to CRLF ("\r\n").
|
||||
#
|
||||
# Default: native.
|
||||
newline: native
|
||||
|
||||
# Sometimes, language extensions are specified in a cabal file or from the
|
||||
# command line instead of using language pragmas in the file. stylish-haskell
|
||||
# needs to be aware of these, so it can parse the file correctly.
|
||||
#
|
||||
# No language extensions are enabled by default.
|
||||
language_extensions:
|
||||
- TemplateHaskell
|
||||
- QuasiQuotes
|
||||
- CPP
|
||||
@@ -0,0 +1,64 @@
|
||||
language: generic
|
||||
|
||||
sudo: false
|
||||
|
||||
os:
|
||||
- osx
|
||||
|
||||
cache:
|
||||
timeout: 1000
|
||||
directories:
|
||||
- $HOME/.stack
|
||||
- $HOME/.local/bin
|
||||
|
||||
before_install:
|
||||
- mkdir -p "$HOME/.local/bin"
|
||||
- export PATH="$PATH:$HOME/.local/bin"
|
||||
|
||||
install:
|
||||
- |
|
||||
if test -f "$HOME/.local/bin/stack"
|
||||
then
|
||||
echo 'Stack is already installed.'
|
||||
else
|
||||
echo "Installing Stack..."
|
||||
travis_retry curl -L https://www.stackage.org/stack/osx-x86_64 > stack.tar.gz
|
||||
gunzip stack.tar.gz
|
||||
tar -x -f stack.tar --strip-components 1
|
||||
mv stack "$HOME/.local/bin/"
|
||||
rm stack.tar
|
||||
fi
|
||||
- |
|
||||
if test -f "$HOME/.local/bin/ghr"
|
||||
then
|
||||
echo 'ghr is already installed.'
|
||||
else
|
||||
echo "Installing ghr..."
|
||||
travis_retry curl -L https://github.com/tcnksm/ghr/releases/download/v0.5.4/ghr_v0.5.4_darwin_386.zip > ghr.zip
|
||||
unzip ghr.zip -d "$HOME/.local/bin"
|
||||
rm ghr.zip
|
||||
fi
|
||||
|
||||
script:
|
||||
- gtimeout 1800 stack build --no-terminal --only-snapshot --install-ghc || true
|
||||
- |
|
||||
if test ! "$TRAVIS_TAG"
|
||||
then
|
||||
echo 'No tag pushed. Skipping build.'
|
||||
else
|
||||
stack build --no-terminal --copy-bins --local-bin-path .
|
||||
fi
|
||||
- |
|
||||
if test ! "$TRAVIS_TAG"
|
||||
then
|
||||
echo 'No tag pushed. Skipping release.'
|
||||
else
|
||||
OWNER="$(echo "$TRAVIS_REPO_SLUG" | cut -f1 -d/)"
|
||||
REPO="$(echo "$TRAVIS_REPO_SLUG" | cut -f2 -d/)"
|
||||
START=$(echo $TRAVIS_TAG | cut -c2-)
|
||||
END='## \['
|
||||
BODY=$(sed -n "1,/$START/d;/$END/q;p" CHANGELOG.md)
|
||||
strip postgrest
|
||||
tar cjf postgrest-$TRAVIS_TAG-osx.tar.xz postgrest
|
||||
ghr -t $GITHUB_TOKEN -u $OWNER -r $REPO -b "$BODY"--replace $TRAVIS_TAG postgrest-$TRAVIS_TAG-osx.tar.xz
|
||||
fi
|
||||
@@ -0,0 +1,57 @@
|
||||
# Sponsors & Backers
|
||||
|
||||
PostgREST ongoing development is only possible thanks to our Sponsors and Backers, listed below. If you'd like to join them, you can do so by supporting the PostgREST organization on [Patreon](https://www.patreon.com/postgrest).
|
||||
|
||||
## Sponsors
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.cybertec-postgresql.com/en/" target="_blank">
|
||||
<img width="222px" src="static/cybertec.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.2ndquadrant.com/en/?utm_campaign=External%20Websites&utm_source=PostgREST&utm_medium=Logo" target="_blank">
|
||||
<img width="222px" src="static/2ndquadrant.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="222px" src="static/retool.png">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Lead Backers
|
||||
|
||||
- [Daniel Babiak](https://github.com/d-babiak)
|
||||
- [Michel Pelletier](https://github.com/michelp/)
|
||||
- Evans Fernandes
|
||||
|
||||
## Backers
|
||||
|
||||
- Tsingson Qin
|
||||
- Jay Hannah
|
||||
- Robert Stolarz
|
||||
- Kofi Gumbs
|
||||
- Nicholas DiBiase
|
||||
|
||||
## Former Backers
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.timescale.com?utm_campaign=postgrest&utm_source=sponsor&utm_medium=referral&utm_content=github" target="_blank">
|
||||
<img width="222px" src="static/timescaledb.png">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
- [Christiaan Westerbeek](https://devotis.nl)
|
||||
@@ -3,6 +3,349 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
### Fixed
|
||||
|
||||
## [6.0.0] - 2019-06-21
|
||||
|
||||
### Added
|
||||
|
||||
- #1186, Add support for user defined unix socket via `server-unix-socket` config option
|
||||
- #690, Add `?columns` query parameter for faster bulk inserts, also ignores unspecified json keys in a payload - @steve-chavez
|
||||
- #1239, Add support for resource embedding on materialized views - @vitorbaptista
|
||||
- #1264, Add support for bulk RPC call - @steve-chavez
|
||||
- #1278, Add db-pool-timeout config option - @qu4tro
|
||||
- #1285, Abort on wrong database password - @qu4tro
|
||||
- #790, Allow override of OpenAPI spec through `root-spec` config option - @steve-chavez
|
||||
- #1308, Accept `text/plain` and `text/html` for raw output - @steve-chavez
|
||||
|
||||
### Fixed
|
||||
|
||||
- #1223, Fix incorrect OpenAPI externalDocs url - @steve-chavez
|
||||
- #1221, Fix embedding other resources when having a self join - @steve-chavez
|
||||
- #1242, Fix embedding a view having a select in a where - @steve-chavez
|
||||
- #1238, Fix PostgreSQL to OpenAPI type mappings for numeric and character types - @fpusch
|
||||
- #1265, Fix query generated on bulk upsert with an empty array - @qu4tro
|
||||
- #1273, Fix RPC ignoring unknown arguments by default - @steve-chavez
|
||||
- #1257, Fix incorrect status when a PATCH request doesn't find rows to change - @qu4tro
|
||||
|
||||
### Changed
|
||||
|
||||
- #1288, Change server-host default of 127.0.0.1 to !4
|
||||
|
||||
### Deprecated
|
||||
|
||||
- #1288, Deprecate `.` symbol for disambiguating resource embedding(added in #918). '+' should be used instead. Though '+' is url safe, certain clients might need to encode it to '%2B'.
|
||||
|
||||
### Removed
|
||||
|
||||
- #1288, Removed support for schema reloading with SIGHUP, SIGUSR1 should be used instead - @steve-chavez
|
||||
|
||||
## [5.2.0] - 2018-12-12
|
||||
|
||||
### Added
|
||||
|
||||
- #1205, Add support for parsing JSON Web Key Sets - @russelldavies
|
||||
- #1203, Add support for reading db-uri from a separate file - @zhoufeng1989
|
||||
- #1200, Add db-extra-search-path config for adding schemas to the search_path, solves issues related to extensions created on the public schema - @steve-chavez
|
||||
- #1219, Add ability to quote column names on filters - @steve-chavez
|
||||
|
||||
### Fixed
|
||||
|
||||
- #1182, Fix embedding on views with composite pks - @steve-chavez
|
||||
- #1180, Fix embedding on views with subselects in pg10 - @steve-chavez
|
||||
- #1197, Allow CORS for PUT - @bkylerussell
|
||||
- #1181, Correctly qualify function argument of custom type in public schema - @steve-chavez
|
||||
- #1008, Allow columns that contain spaces in filters - @steve-chavez
|
||||
|
||||
## [5.1.0] - 2018-08-31
|
||||
|
||||
### Added
|
||||
|
||||
- #1099, Add support for getting json/jsonb by array index - @steve-chavez
|
||||
- #1145, Add materialized view columns to OpenAPI output - @steve-chavez
|
||||
- #709, Allow embedding on views with subselects/CTE - @steve-chavez
|
||||
- #1148, OpenAPI: add `required` section for the non-nullable columns - @laughedelic
|
||||
- #1158, Add summary to OpenAPI doc for RPC functions - @mdr1384
|
||||
|
||||
### Fixed
|
||||
|
||||
- #1113, Fix UPSERT failing when having a camel case PK column - @steve-chavez
|
||||
- #945, Fix slow start-up time on big schemas - @steve-chavez
|
||||
- #1129, Fix view embedding when table is capitalized - @steve-chavez
|
||||
- #1149, OpenAPI: Change `GET` response type to array - @laughedelic
|
||||
- #1152, Fix RPC failing when having arguments with reserved or uppercase keywords - @mdr1384
|
||||
- #905, Fix intermittent empty replies - @steve-chavez
|
||||
- #1139, Fix JWTIssuedAtFuture failure for valid iat claim - @steve-chavez
|
||||
- #1141, Fix app.settings resetting on pool timeout - @steve-chavez
|
||||
|
||||
### Changed
|
||||
|
||||
- #1099, Numbers in json path `?select=data->1->>key` now get treated as json array indexes instead of keys - @steve-chavez
|
||||
- #1128, Allow finishing a json path with a single arrow `->`. Now a json can be obtained without resorting to casting, Previously: `/json_arr?select=data->>2::json`, now: `/json_arr?select=data->2` - @steve-chavez
|
||||
- #724, Change server-host default of *4 to 127.0.0.1
|
||||
|
||||
### Deprecated
|
||||
|
||||
- #724, SIGHUP deprecated, SIGUSR1 should be used instead
|
||||
|
||||
## [0.5.0.0] - 2018-05-14
|
||||
|
||||
### Added
|
||||
|
||||
- The configuration (e.g. `postgrest.conf`) now accepts arbitrary settings that will be passed through as session-local database settings. This can be used to pass in secret keys directly as strings, or via OS environment variables. For instance: `app.settings.jwt_secret = "$(MYAPP_JWT_SECRET)"` will take `MYAPP_JWT_SECRET` from the environment and make it available to postgresql functions as `current_setting('app.settings.jwt_secret')`. Only `app.settings.*` values in the configuration file are treated in this way. - @canadaduane
|
||||
- #256, Add support for bulk UPSERT with POST and single UPSERT with PUT - @steve-chavez
|
||||
- #1078, Add ability to specify source column in embed - @steve-chavez
|
||||
- #821, Allow embeds alias to be used in filters - @steve-chavez
|
||||
- #906, Add jspath configurable `role-claim-key` - @steve-chavez
|
||||
- #1061, Add foreign tables to OpenAPI output - @rhyamada
|
||||
|
||||
### Fixed
|
||||
|
||||
- #828, Fix computed column only working in public schema - @steve-chavez
|
||||
- #925, Fix RPC high memory usage by using parametrized query and avoiding json encoding - @steve-chavez
|
||||
- #987, Fix embedding with self-reference foreign key - @steve-chavez
|
||||
- #1044, Fix view parent embedding when having many views - @steve-chavez
|
||||
- #781, Fix accepting misspelled desc/asc ordering modificators - @onporat, @steve-chavez
|
||||
|
||||
### Changed
|
||||
|
||||
- #828, A `SET SCHEMA <db-schema>` is done on each request, this has the following implications:
|
||||
- Computed columns now only work if they belong to the db-schema
|
||||
- Stored procedures might require a `search_path` to work properly, for further details see https://postgrest.org/en/v5.0/api.html#explicit-qualification
|
||||
- To use RPC now the `json_to_record/json_to_recordset` functions are needed, these are available starting from PostgreSQL 9.4 - @steve-chavez
|
||||
- Overloaded functions now depend on the `dbStructure`, restart/sighup may be needed for their correct functioning - @steve-chavez
|
||||
- #1098, Removed support for:
|
||||
+ curly braces `{}` in embeds, i.e. `/clients?select=*,projects{*}` can no longer be used, from now on parens `()` should be used `/clients?select=*,projects(*)` - @steve-chavez
|
||||
+ "in" operator without parens, i.e. `/clients?id=in.1,2,3` no longer supported, `/clients?id=in.(1,2,3)` should be used - @steve-chavez
|
||||
+ "@@", "@>" and "<@" operators, from now on their mnemonic equivalents should be used "fts", "cs" and "cd" respectively - @steve-chavez
|
||||
|
||||
## [0.4.4.0] - 2018-01-08
|
||||
|
||||
### Added
|
||||
|
||||
- #887, #601, #1007, Allow specifying dictionary and plain/phrase tsquery in full text search - @steve-chavez
|
||||
- #328, Allow doing GET on rpc - @steve-chavez
|
||||
- #917, Add ability to map RAISE errorcode/message to http status - @steve-chavez
|
||||
- #940, Add ability to map GUC to http response headers - @steve-chavez
|
||||
- #1022, Include git sha in version report - @begriffs
|
||||
- Faster queries using json_agg - @ruslantalpa
|
||||
|
||||
### Fixed
|
||||
|
||||
- #876, Read secret files as binary, discard final LF if any - @eric-brechemier
|
||||
- #968, Treat blank proxy uri as missing - @begriffs
|
||||
- #933, OpenAPI externals docs url to current version - @steve-chavez
|
||||
- #962, OpenAPI don't err on nonexistent schema - @steve-chavez
|
||||
- #954, make OpenAPI rpc output dependent on user privileges - @steve-chavez
|
||||
- #955, Support configurable aud claim - @statik
|
||||
- #996, Fix embedded column conflicts table name - @grotsev
|
||||
- #974, Fix RPC error when function has single OUT param - @steve-chavez
|
||||
- #1021, Reduce join size in allColumns for faster program start - @nextstopsun
|
||||
- #411, Remove the need for pk in &select for parent embed - @steve-chavez
|
||||
- #1016, Fix anonymous requests when configured with jwt-aud - @ruslantalpa
|
||||
|
||||
## [0.4.3.0] - 2017-09-06
|
||||
|
||||
### Added
|
||||
|
||||
- #567, Support more JWT signing algorithms, including RSA - @begriffs
|
||||
- #889, Allow more than two conditions in a single and/or - @steve-chavez
|
||||
- #883, Binary output support for RPC - @steve-chavez
|
||||
- #885, Postgres COMMENTs on SCHEMA/TABLE/COLUMN are used for OpenAPI - @ldesgoui
|
||||
- #907, Ability to embed using a specific relation when there are multiple between tables - @ruslantalpa
|
||||
- #930, Split table comment on newline to get OpenAPI operation summary and description - @daurnimator
|
||||
- #938, Support for range operators - @russelldavies
|
||||
|
||||
### Fixed
|
||||
|
||||
- #877, Base64 secret read from a file ending with a newline - @eric-brechemier
|
||||
- #896, Boolean env var interpolation in config file - @begriffs
|
||||
- #885, OpenAPI repetition reduced by using more definitions- @ldesgoui
|
||||
- #924, Improve relations initialization time - @9too
|
||||
- #927, Treat blank pre-request as missing - @begriffs
|
||||
|
||||
### Changed
|
||||
|
||||
- #938, Deprecate symbol operators with mnemonic names. - @russelldavies
|
||||
|
||||
## [0.4.2.0] - 2017-06-11
|
||||
|
||||
### Added
|
||||
|
||||
- #742, Add connection retrying on startup and SIGHUP - @steve-chavez
|
||||
- #652, Add and/or params for complex boolean logic - @steve-chavez
|
||||
- #808, Env var interpolation in config file (helps Docker) - @begriffs
|
||||
- #878 - CSV output support for RPC - @begriffs
|
||||
|
||||
### Fixed
|
||||
|
||||
- #822, Treat blank string JWT secret as no secret - @begriffs
|
||||
|
||||
## [0.4.1.0] - 2017-04-25
|
||||
|
||||
### Added
|
||||
- Allow requesting binary output on GET - @steve-chavez
|
||||
- Accept clients requesting `Content-Type: application/json` from / - @feynmanliang
|
||||
- #493, Updating with empty JSON object makes zero updates @koulakis
|
||||
- Make HTTP headers and cookies available as GUCs #800 - @ruslantalpa
|
||||
- #701, Ability to quote values on IN filters - @steve-chavez
|
||||
- #641, Allow IN filter to have no values - @steve-chavez
|
||||
|
||||
### Fixed
|
||||
- #827, Avoid Warp reaper, extend socket timeout to 1 hour - @majorcode
|
||||
- #791, malformed nested JSON error - @diogob
|
||||
- Resource embedding in views referencing tables in public schema - @fab1an
|
||||
- #777, Empty body is allowed when calling a non-parameterized RPC - @koulakis
|
||||
- #831, Fix proc resource embedding issue with search_path - @steve-chavez
|
||||
- #547, Use read-only transaction for stable/immutable RPC - @begriffs
|
||||
|
||||
## [0.4.0.0] - 2017-01-19
|
||||
|
||||
### Added
|
||||
- Allow test database to be on another host - @dsimunic
|
||||
- `Prefer: params=single-object` to treat payload as single json argument in RPC - @dsimunic
|
||||
- Ability to generate an OpenAPI spec - @mainx07, @hudayou, @ruslantalpa, @begriffs
|
||||
- Ability to generate an OpenAPI spec behind a proxy - @hudayou
|
||||
- Ability to set addresses to listen on - @hudayou
|
||||
- Filtering, shaping and embedding with &select for the /rpc path - @ruslantalpa
|
||||
- Output names of used-defined types (instead of 'USER-DEFINED') - @martingms
|
||||
- Implement support for singular representation responses for POST/PATCH requests - @ehamberg
|
||||
- Include RPC endpoints in OpenAPI output - @begriffs, @LogvinovLeon
|
||||
- Custom request validation with `--pre-request` argument - @begriffs
|
||||
- Ability to order by jsonb keys - @steve-chavez
|
||||
- Ability to specify offset for a deeper level - @ruslantalpa
|
||||
- Ability to use binary base64 encoded secrets - @TrevorBasinger
|
||||
|
||||
### Fixed
|
||||
- Do not apply limit to parent items - @ruslantalpa
|
||||
- Fix bug in relation detection when selecting parents two levels up by using the name of the FK - @ruslantalpa
|
||||
- Customize content negotiation per route - @begriffs
|
||||
- Allow using nulls order without explicit order direction - @steve-chavez
|
||||
- Fatal error on postgres unsupported version, format supported version in error message - @steve-chavez
|
||||
- Prevent database memory cosumption by prepared statements caches - @ruslantalpa
|
||||
- Use specific columns in the RETURNING section - @ruslantalpa
|
||||
- Fix columns alias for RETURNING - @steve-chavez
|
||||
|
||||
### Changed
|
||||
- Replace `Prefer: plurality=singular` with `Accept: application/vnd.pgrst.object` - @begriffs
|
||||
- Standardize arrays in responses for `Prefer: return=representation` - @begriffs
|
||||
- Calling unknown RPC gives 404, not 400 - @begriffs
|
||||
- Use HTTP 400 for raise\_exception - @begriffs
|
||||
- Remove non-OpenAPI schema description - @begriffs
|
||||
- Use comma rather than semicolon to separate Prefer header values - @begriffs
|
||||
- Omit total query count by default - @begriffs
|
||||
- No more reserved `jwt_claims` return type - @begriffs
|
||||
- HTTP 401 rather than 400 for expired JWT - @begriffs
|
||||
- Remove default JWT secret - @begriffs
|
||||
- Use GUC request.jwt.claim.foo rather than postgrest.claims.foo - @begriffs
|
||||
- Use config file rather than command line arguments - @begriffs
|
||||
|
||||
## [0.3.2.0] - 2016-06-10
|
||||
|
||||
### Added
|
||||
- Reload database schema on SIGHUP - @begriffs
|
||||
- Support "-" in column names - @ruslantalpa
|
||||
- Support column/node renaming `alias:column` - @ruslantalpa
|
||||
- Accept posts from HTML forms - @begriffs
|
||||
- Ability to order embedded entities - @ruslantalpa
|
||||
- Ability to paginate using &limit and &offset parameters - @ruslantalpa
|
||||
- Ability to apply limits to embedded entities and enforce --max-rows on all levels - @ruslantalpa, @begriffs
|
||||
- Add allow response header in OPTIONS - @begriffs
|
||||
|
||||
### Fixed
|
||||
- Return 401 or 403 for access denied rather than 404 - @begriffs
|
||||
- Omit Content-Type header for empty body - @begriffs
|
||||
- Prevent role from being changed twice - @begriffs
|
||||
- Use read-only transaction for read requests - @ruslantalpa
|
||||
- Include entities from the same parent table using two different foreign keys - @ruslantalpa
|
||||
- Ensure that Location header in 201 response is URL-encoded - @league
|
||||
- Fix garbage collector CPU leak - @ruslantalpa et al.
|
||||
- Return deleted items when return=representation header is sent - @ruslantalpa
|
||||
- Use table default values for empty object inserts - @begriffs
|
||||
|
||||
## [0.3.1.1] - 2016-03-28
|
||||
|
||||
### Fixed
|
||||
- Preserve unicode values in insert,update,rpc (regression) - @begriffs
|
||||
- Prevent duplicate call to stored procs (regression) - @begriffs
|
||||
- Allow SQL functions to generate registered JWT claims - @begriffs
|
||||
- Terminate gracefully on SIGTERM (for use in Docker) - @recmo
|
||||
- Relation detection fix for views that depend on multiple tables - @ruslantalpa
|
||||
- Avoid count on plurality=singular and allow multiple Prefer values - @ruslantalpa
|
||||
|
||||
## [0.3.1.0] - 2016-02-28
|
||||
|
||||
### Fixed
|
||||
- Prevent query error from infecting later connection - @begriffs, @ruslantalpa, @nikita-volkov, @jwiegley
|
||||
|
||||
### Added
|
||||
- Applies range headers to RPC calls - @diogob
|
||||
|
||||
## [0.3.0.4] - 2016-02-12
|
||||
|
||||
### Fixed
|
||||
- Improved usage screen - @begriffs
|
||||
- Reject non-POSTs to rpc endpoints - @begriffs
|
||||
- Throw an error for OPTIONS on nonexistent tables - @calebmer
|
||||
- Remove deadlock on simultaneous contentious updates - @ruslantalpa, @begriffs
|
||||
|
||||
## [0.3.0.3] - 2016-01-08
|
||||
|
||||
### Fixed
|
||||
- Fix bug in many-many relation detection - @ruslantalpa
|
||||
- Inconsistent escaping of table names in read queries - @calebmer
|
||||
|
||||
## [0.3.0.2] - 2015-12-16
|
||||
|
||||
### Fixed
|
||||
- Miscalculation of time used for expiring tokens - @calebmer
|
||||
- Remove bcrypt dependency to fix Windows build - @begriffs
|
||||
- Detect relations event when authenticator does not have rights to intermediate tables - @ruslantalpa
|
||||
- Ensure db connections released on sigint - @begriffs
|
||||
- Fix #396 include records with missing parents - @ruslantalpa
|
||||
- `pgFmtIdent` always quotes #388 - @calebmer
|
||||
- Default schema, changed from `"1"` to `public` - @calebmer
|
||||
- #414 revert to separate count query - @ruslantalpa
|
||||
- Fix #399, allow inserting in tables with no select privileges using "Prefer: representation=minimal" - @ruslantalpa
|
||||
|
||||
### Added
|
||||
- Allow order by computed columns - @diogob
|
||||
- Set max rows in response with --max-rows - @begriffs
|
||||
- Selection by column name (can detect if `_id` is not included) - @calebmer
|
||||
|
||||
## [0.3.0.1] - 2015-11-27
|
||||
|
||||
### 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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
.PHONY: check clean lint style test test-watch coverage circleci circleci-prof-test check-dburi prompt-clean prompt-long-process
|
||||
|
||||
check: lint style test
|
||||
|
||||
clean: prompt-clean
|
||||
stack clean --full
|
||||
|
||||
lint:
|
||||
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- hlint -X QuasiQuotes -X NoPatternSynonyms "$$@"
|
||||
|
||||
style:
|
||||
git ls-files | grep '\.l\?hs$$' | xargs stack exec -- stylish-haskell -i
|
||||
|
||||
|
||||
test: check-dburi
|
||||
stack test
|
||||
|
||||
test-watch: check-dburi
|
||||
stack build --file-watch --test --test-arguments '--rerun --failure-report=.TESTREPORT --rerun-all-on-success'
|
||||
|
||||
coverage: check-dburi clean
|
||||
stack build --coverage
|
||||
stack test --coverage
|
||||
|
||||
|
||||
circleci: prompt-long-process
|
||||
circleci local execute --job build-test-9.4
|
||||
|
||||
circleci-prof-test: prompt-long-process
|
||||
circleci local execute --job build-prof-test
|
||||
|
||||
|
||||
check-dburi:
|
||||
test -n "$(POSTGREST_TEST_CONNECTION)" # Requires POSTGREST_TEST_CONNECTION environmental variable
|
||||
|
||||
prompt-clean:
|
||||
@echo -n 'Are you sure? You will have to rebuild. [y/N] ' && read ans && [ $${ans:-N} = y ]
|
||||
|
||||
prompt-long-process:
|
||||
@echo -n 'Are you sure? This might take a while. [y/N] ' && read ans && [ $${ans:-N} = y ]
|
||||
@@ -1,51 +1,65 @@
|
||||

|
||||

|
||||
|
||||
[](https://circleci.com/gh/begriffs/postgrest/tree/master)
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/begriffs/postgrest">
|
||||
[](https://www.patreon.com/postgrest)
|
||||
[](https://www.paypal.me/postgrest)
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/PostgREST/postgrest">
|
||||
<img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy">
|
||||
</a>
|
||||
[](https://gitter.im/begriffs/postgrest)
|
||||
[](http://postgrest.org)
|
||||
[](https://hub.docker.com/r/postgrest/postgrest/)
|
||||
[](https://circleci.com/gh/PostgREST/postgrest/tree/master)
|
||||
[](http://hackage.haskell.org/package/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](http://begriffs.com/posts/2014-12-30-intro-to-postgrest.html) | [GUI Demo](http://marmelab.com/ng-admin-postgrest)
|
||||
## Sponsors
|
||||
|
||||
Try making requests to the live demo server with an HTTP client
|
||||
such as [postman](http://www.getpostman.com/). The structure of the
|
||||
demo database is defined by
|
||||
[begriffs/postgrest-example](https://github.com/begriffs/postgrest-example).
|
||||
You can use it as inspiration for test-driven server migrations in
|
||||
your own projects.
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.cybertec-postgresql.com/en/" target="_blank">
|
||||
<img width="222px" src="static/cybertec.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.2ndquadrant.com/en/?utm_campaign=External%20Websites&utm_source=PostgREST&utm_medium=Logo" target="_blank">
|
||||
<img width="222px" src="static/2ndquadrant.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="222px" src="static/retool.png">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Usage
|
||||
Big thanks to our sponsors! You can join them by supporting PostgREST on [Patreon](https://www.patreon.com/postgrest).
|
||||
|
||||
Download the binary ([latest release](https://github.com/begriffs/postgrest/releases/latest)) and invoke like so:
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
postgrest --db-host localhost --db-port 5432 \
|
||||
--db-name my_db --db-user postgres \
|
||||
--db-pass foobar --db-pool 200 \
|
||||
--anonymous postgres --port 3000 \
|
||||
--v1schema public
|
||||
```
|
||||
1. Download the binary ([latest release](https://github.com/PostgREST/postgrest/releases/latest))
|
||||
for your platform.
|
||||
2. Invoke for help:
|
||||
|
||||
In production include the `--secure` option which redirects all
|
||||
requests to HTTPS. Note that PostgREST does not handle the SSL
|
||||
internally and must be put behind another server that does (such
|
||||
as nginx or the Heroku load balancer).
|
||||
```bash
|
||||
postgrest --help
|
||||
```
|
||||
|
||||
### Performance
|
||||
## 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))
|
||||
|
||||
If you're used to servers written in interpreted languages (or named
|
||||
after precious gems), prepare to be pleasantly surprised by PostgREST
|
||||
performance.
|
||||
TLDR; subsecond response times for up to 2000 requests/sec on Heroku
|
||||
free tier. 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
|
||||
@@ -61,68 +75,52 @@ Finally it uses the database efficiently with the
|
||||
[Hasql](https://nikita-volkov.github.io/hasql-benchmarks/) library
|
||||
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).
|
||||
## Security
|
||||
|
||||
Other optimizations are possible, and some are outlined in the
|
||||
[Future Features](#future-features).
|
||||
PostgREST [handles
|
||||
authentication](http://postgrest.org/en/stable/auth.html) (via 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.
|
||||
|
||||
### Security
|
||||
|
||||
PostgREST handles authentication (HTTP Basic over SSL or [JSON Web
|
||||
Tokens](https://github.com/begriffs/postgrest/wiki/Security-and-Permissions#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.
|
||||
|
||||
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).
|
||||
|
||||
### Versioning
|
||||
## 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.
|
||||
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.
|
||||
|
||||
To learn more, see the [guide to versioning](https://github.com/begriffs/postgrest/wiki/API-Versioning).
|
||||
## Self-documentation
|
||||
|
||||
### Self-documention
|
||||
PostgREST uses the [OpenAPI](https://openapis.org/) standard to
|
||||
generate up-to-date documentation for APIs. You can use a tool like
|
||||
[Swagger-UI](https://github.com/swagger-api/swagger-ui) to render
|
||||
interactive documentation for demo requests against the live API server.
|
||||
|
||||
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.
|
||||
|
||||
The number of rows returned by an endpoint is reported by - and
|
||||
limited with - range headers. More about
|
||||
This project uses HTTP to communicate other metadata as well. 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
|
||||
Features](#future-features).
|
||||
|
||||
### Data Integrity
|
||||
## Data Integrity
|
||||
|
||||
Rather than relying on an Object Relational Mapper and custom
|
||||
imperative coding, this system requires you put declarative constraints
|
||||
@@ -130,48 +128,28 @@ directly into your database. Hence no application can corrupt your
|
||||
data (including your API server).
|
||||
|
||||
The PostgREST exposes HTTP interface with safeguards to prevent
|
||||
surprises, such as enforcing idempotent PUT requests, and
|
||||
surprises, such as enforcing idempotent PUT requests.
|
||||
|
||||
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 [API guide](http://postgrest.org/en/stable/api.html).
|
||||
|
||||
### Future Features
|
||||
## Supporting development
|
||||
|
||||
* Watching endpoint changes with sockets and Postgres pubsub
|
||||
* Specifying per-view HTTP caching
|
||||
* Inferring good default caching policies from the Postgres stats collector
|
||||
* Generating mock data for test clients
|
||||
* Maintaining separate connection pools per role to avoid "set/reset
|
||||
role" performance penalty
|
||||
* Describe more relationships with Link headers
|
||||
* Depending on accept headers, render OPTIONS as [RAML](http://raml.org/) or a
|
||||
relational diagram
|
||||
* Add two-legged auth with OAuth 1.0a(?)
|
||||
* ... the other [issues](https://github.com/begriffs/postgrest/issues)
|
||||
You can help PostgREST ongoing maintenance and development by:
|
||||
|
||||
### Guides
|
||||
- Making a regular donation through Patreon https://www.patreon.com/postgrest
|
||||
|
||||
* [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)
|
||||
* [Tutorial](http://blog.jonharrington.org/postgrest-introduction/) (external)
|
||||
* [Heroku](https://github.com/begriffs/postgrest/wiki/Heroku)
|
||||
- Alternatively, you can make a one-time donation via Paypal https://www.paypal.me/postgrest
|
||||
|
||||
### Thanks
|
||||
Every donation will be spent on making PostgREST better for the whole community.
|
||||
|
||||
* [Ruslan Talpa](https://github.com/ruslantalpa) for rewriting the
|
||||
route parsing and query generation code to support resource embedding
|
||||
* [Adam Baker](https://github.com/adambaker) for code
|
||||
contributions and many fundamental design discussions
|
||||
* [Diogo Biazus](https://github.com/diogob) for many improvements
|
||||
and deep postgresql knowledge
|
||||
* [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
|
||||
* [Jonathan Harrington](https://github.com/prio) for writing a [nice
|
||||
tutorial](http://blog.jonharrington.org/postgrest-introduction/)
|
||||
* [Federico Rampazzo](https://github.com/framp) for suggesting and
|
||||
implementing [JWT](http://jwt.io/) support
|
||||
## Thanks
|
||||
|
||||
The PostgREST organization is grateful to:
|
||||
|
||||
- The project [sponsors and backers](https://github.com/PostgREST/postgrest/blob/master/BACKERS.md) who support PostgREST's development.
|
||||
- The project [contributors](https://github.com/PostgREST/postgrest/graphs/contributors) who have improved PostgREST immensely with their code
|
||||
and good judgement. See more details in the [changelog](https://github.com/PostgREST/postgrest/blob/master/CHANGELOG.md).
|
||||
|
||||
The cool logo came from [Mikey Casalaina](https://github.com/casalaina).
|
||||
|
||||
@@ -1,56 +1,59 @@
|
||||
{
|
||||
"name": "PostgREST",
|
||||
"description": "RESTful API for any PostgreSQL database.",
|
||||
"logo": "https://halcyon.sh/logo.svg",
|
||||
"repository": "https://github.com/begriffs/postgrest",
|
||||
"logo": "https://avatars2.githubusercontent.com/u/15115011",
|
||||
"repository": "https://github.com/PostgREST/postgrest",
|
||||
"env": {
|
||||
"BUILDPACK_URL": {
|
||||
"description": "Heroku buildpack for deploying Haskell applications",
|
||||
"value": "https://github.com/begriffs/postgrest-heroku"
|
||||
"value": "https://github.com/PostgREST/postgrest-heroku"
|
||||
},
|
||||
"POSTGREST_VER": {
|
||||
"description": "Version of PostgREST to deploy",
|
||||
"value": "0.2.12.1"
|
||||
"value": "6.0.0"
|
||||
},
|
||||
"DB_NAME": {
|
||||
"description": "Database name",
|
||||
"DB_URI": {
|
||||
"description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb",
|
||||
"required": true
|
||||
},
|
||||
"AUTH_ROLE": {
|
||||
"description": "Database role to use checking client authentication",
|
||||
"DB_SCHEMA": {
|
||||
"description": "The database schema to expose to REST clients. Tables, views and stored procedures in this schema will get API endpoints",
|
||||
"required": true,
|
||||
"value": "public"
|
||||
},
|
||||
"DB_ANON_ROLE": {
|
||||
"description": "The database role to use when executing commands on behalf of unauthenticated clients",
|
||||
"required": true
|
||||
},
|
||||
"AUTH_PASS": {
|
||||
"description": "Authentication password",
|
||||
"required": false
|
||||
},
|
||||
"ANONYMOUS_ROLE": {
|
||||
"description": "Database role for non-authenticated requests",
|
||||
"required": true
|
||||
},
|
||||
"DB_HOST": {
|
||||
"description": "Database server hostname",
|
||||
"required": true
|
||||
},
|
||||
"DB_PORT": {
|
||||
"description": "Database server port",
|
||||
"required": false,
|
||||
"value": "5432"
|
||||
},
|
||||
"DB_POOL": {
|
||||
"description": "Maximum number of connections in database pool",
|
||||
"description": "Number of connections to keep open in PostgREST’s database pool",
|
||||
"required": false,
|
||||
"value": "10"
|
||||
},
|
||||
"JWT_SECRET": {
|
||||
"description": "Secret used to encrypt JSON Web Tokens",
|
||||
"required": false,
|
||||
"value": "secret"
|
||||
"SERVER_PROXY_URI": {
|
||||
"description": "Overrides the base URL used within the OpenAPI self-documentation hosted at the API root path",
|
||||
"required": false
|
||||
},
|
||||
"V1SCHEMA": {
|
||||
"description": "DB schema selected whe no version (or version 1) requested",
|
||||
"JWT_SECRET": {
|
||||
"description": "The secret used to decode JWT tokens clients provide for authentication",
|
||||
"required": false
|
||||
},
|
||||
"SECRET_IS_BASE64": {
|
||||
"description": "When this is set to true, the value derived from jwt-secret will be treated as a base64 encoded secret",
|
||||
"required": false,
|
||||
"value": "1"
|
||||
"value": "false"
|
||||
},
|
||||
"JWT_AUD": {
|
||||
"description": "The audience that should be validated if the JWT token contains an aud claim",
|
||||
"required": false
|
||||
},
|
||||
"MAX_ROWS": {
|
||||
"description": "A hard limit to the number of rows PostgREST will fetch from a view, table, or stored procedure",
|
||||
"required": false
|
||||
},
|
||||
"PRE_REQUEST": {
|
||||
"description": "A schema-qualified stored procedure name to call right after switching roles for a client request",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
platform: x64
|
||||
|
||||
cache:
|
||||
- "c:\\sr"
|
||||
- .stack-work
|
||||
|
||||
environment:
|
||||
global:
|
||||
STACK_ROOT: "c:\\sr"
|
||||
GOPATH: c:\gopath
|
||||
|
||||
test: off
|
||||
|
||||
skip_non_tags: true
|
||||
|
||||
skip_branch_with_pr: true
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
|
||||
install:
|
||||
- set PATH=C:\Program Files\PostgreSQL\9.6\bin\;%PATH%
|
||||
- curl -sS -ostack.zip -L --insecure http://www.stackage.org/stack/windows-x86_64
|
||||
- 7z x stack.zip stack.exe
|
||||
- set PATH=%GOPATH%\bin;c:\go\bin;%PATH%
|
||||
- go get -u github.com/tcnksm/ghr
|
||||
|
||||
build_script:
|
||||
- stack setup --no-terminal > nul
|
||||
- stack build -j1 --copy-bins --local-bin-path .
|
||||
|
||||
artifacts:
|
||||
- path: postgrest.exe
|
||||
|
||||
deploy_script:
|
||||
- 7z a -tzip postgrest-%APPVEYOR_REPO_TAG_NAME%-windows-x64.zip postgrest.exe
|
||||
- bash -lc "exec 0</dev/null && cd $APPVEYOR_BUILD_FOLDER && ghr -t $GITHUB_TOKEN -u $APPVEYOR_ACCOUNT_NAME -r $APPVEYOR_PROJECT_NAME -b \"$(sed -n \"1,/$(echo $APPVEYOR_REPO_TAG_NAME | cut -c2-)/d;/## \[/q;p\" CHANGELOG.md)\" --replace $APPVEYOR_REPO_TAG_NAME postgrest-$APPVEYOR_REPO_TAG_NAME-windows-x64.zip"
|
||||
@@ -1,16 +0,0 @@
|
||||
machine:
|
||||
pre:
|
||||
- createuser --superuser --no-password postgrest_test
|
||||
- createdb -O postgrest_test -U ubuntu postgrest_test
|
||||
ghc:
|
||||
version: 7.8.3
|
||||
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
|
||||
@@ -1,52 +0,0 @@
|
||||
# TODO list to build debian "official" package
|
||||
|
||||
It feels for free to modify, fix or take some task or all.
|
||||
|
||||
## debian/control
|
||||
|
||||
* Fill description field
|
||||
* Add Vcs-Browser
|
||||
* Add Vcs-Git
|
||||
* Add Uploaders field
|
||||
|
||||
## debian/copyright
|
||||
|
||||
* Add more contributers
|
||||
|
||||
## Dependencies packages
|
||||
|
||||
Some libraries dependencies aren't Debian package. Below is the list was built by [cabal-debian](https://wiki.debian.org/Haskell/CollabMaint/GettingStarted). These libraries are necessary to build Postgrest the right way.
|
||||
|
||||
* libghc-base64-string-dev
|
||||
* libghc-base64-string-prof
|
||||
* libghc-bcrypt-dev
|
||||
* libghc-bcrypt-prof
|
||||
* libghc-hasql-dev
|
||||
* libghc-hasql-prof
|
||||
* libghc-hasql-backend-dev
|
||||
* libghc-hasql-backend-prof
|
||||
* libghc-hasql-postgres-dev
|
||||
* libghc-hasql-postgres-prof
|
||||
* libghc-string-conversions-dev
|
||||
* libghc-string-conversions-prof
|
||||
* libghc-wai-cors-dev
|
||||
* libghc-wai-cors-prof
|
||||
* libghc-wai-middleware-static-dev
|
||||
* libghc-wai-middleware-static-prof
|
||||
* libghc-hasql-dev
|
||||
* libghc-hasql-backend-dev
|
||||
* libghc-hasql-postgres-dev
|
||||
* libghc-heredoc-dev
|
||||
* libghc-hspec-wai-dev
|
||||
* libghc-hspec-wai-json-dev
|
||||
* libghc-http-media-dev
|
||||
* libghc-packdeps-dev
|
||||
* libghc-base64-string-doc
|
||||
* libghc-bcrypt-doc
|
||||
* libghc-hasql-doc
|
||||
* libghc-hasql-backend-doc
|
||||
* libghc-hasql-postgres-doc
|
||||
* libghc-string-conversions-doc
|
||||
* libghc-wai-cors-doc
|
||||
* libghc-wai-middleware-static-doc
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
haskell-postgrest (0.2.11.1-1) UNRELEASED; urgency=low
|
||||
|
||||
* Initial release
|
||||
|
||||
-- Debian Haskell Group <pkg-haskell-maintainers@lists.alioth.debian.org> Wed, 30 Sep 2015 18:52:46 +0000
|
||||
@@ -1 +0,0 @@
|
||||
9
|
||||
@@ -1,196 +0,0 @@
|
||||
Source: haskell-postgrest
|
||||
Maintainer: Debian Haskell Group <pkg-haskell-maintainers@lists.alioth.debian.org>
|
||||
Priority: extra
|
||||
Section: haskell
|
||||
Build-Depends: debhelper (>= 9),
|
||||
haskell-devscripts (>= 0.8),
|
||||
cdbs,
|
||||
ghc,
|
||||
ghc-prof,
|
||||
libghc-http-dev,
|
||||
libghc-http-prof,
|
||||
libghc-missingh-dev,
|
||||
libghc-missingh-prof,
|
||||
libghc-ranged-sets-dev,
|
||||
libghc-ranged-sets-prof,
|
||||
libghc-aeson-dev,
|
||||
libghc-aeson-prof,
|
||||
libghc-base64-string-dev,
|
||||
libghc-base64-string-prof,
|
||||
libghc-bcrypt-dev,
|
||||
libghc-bcrypt-prof,
|
||||
libghc-blaze-builder-dev,
|
||||
libghc-blaze-builder-prof,
|
||||
libghc-case-insensitive-dev,
|
||||
libghc-case-insensitive-prof,
|
||||
libghc-cassava-dev,
|
||||
libghc-cassava-prof,
|
||||
libghc-convertible-dev,
|
||||
libghc-convertible-prof,
|
||||
libghc-hasql-dev,
|
||||
libghc-hasql-prof,
|
||||
libghc-hasql-backend-dev,
|
||||
libghc-hasql-backend-prof,
|
||||
libghc-hasql-postgres-dev,
|
||||
libghc-hasql-postgres-prof,
|
||||
libghc-http-types-dev,
|
||||
libghc-http-types-prof,
|
||||
libghc-jwt-dev,
|
||||
libghc-jwt-prof,
|
||||
libghc-mtl-dev,
|
||||
libghc-mtl-prof,
|
||||
libghc-network-dev,
|
||||
libghc-network-prof,
|
||||
libghc-network-uri-dev,
|
||||
libghc-network-uri-prof,
|
||||
libghc-optparse-applicative-dev,
|
||||
libghc-optparse-applicative-prof,
|
||||
libghc-regex-base-dev,
|
||||
libghc-regex-base-prof,
|
||||
libghc-regex-tdfa-dev,
|
||||
libghc-regex-tdfa-prof,
|
||||
libghc-resource-pool-dev,
|
||||
libghc-resource-pool-prof,
|
||||
libghc-scientific-dev,
|
||||
libghc-scientific-prof,
|
||||
libghc-split-dev,
|
||||
libghc-split-prof,
|
||||
libghc-string-conversions-dev,
|
||||
libghc-string-conversions-prof,
|
||||
libghc-stringsearch-dev,
|
||||
libghc-stringsearch-prof,
|
||||
libghc-text-dev,
|
||||
libghc-text-prof,
|
||||
libghc-unordered-containers-dev,
|
||||
libghc-unordered-containers-prof,
|
||||
libghc-vector-dev,
|
||||
libghc-vector-prof,
|
||||
libghc-wai-dev,
|
||||
libghc-wai-prof,
|
||||
libghc-wai-cors-dev,
|
||||
libghc-wai-cors-prof,
|
||||
libghc-wai-extra-dev,
|
||||
libghc-wai-extra-prof,
|
||||
libghc-wai-middleware-static-dev,
|
||||
libghc-wai-middleware-static-prof,
|
||||
libghc-warp-dev,
|
||||
libghc-warp-prof,
|
||||
libghc-aeson-dev (>= 0.8),
|
||||
libghc-bcrypt-dev (>= 0.0.6),
|
||||
libghc-hasql-dev (>= 0.7.3),
|
||||
libghc-hasql-dev (<< 0.8),
|
||||
libghc-hasql-backend-dev (>= 0.4.1),
|
||||
libghc-hasql-backend-dev (<< 0.5),
|
||||
libghc-hasql-postgres-dev (>= 0.10.4),
|
||||
libghc-hasql-postgres-dev (<< 0.11),
|
||||
libghc-network-dev (>= 2.6),
|
||||
libghc-network-uri-dev (>= 2.6),
|
||||
libghc-optparse-applicative-dev (>= 0.11),
|
||||
libghc-optparse-applicative-dev (<< 0.12),
|
||||
libghc-wai-dev (>= 3.0.1),
|
||||
libghc-wai-middleware-static-dev (>= 0.6.0),
|
||||
libghc-warp-dev (>= 3.0.2),
|
||||
libghc-quickcheck2-dev,
|
||||
libghc-heredoc-dev,
|
||||
libghc-hlint-dev,
|
||||
libghc-hspec-dev (>= 2.1),
|
||||
libghc-hspec-dev (<< 2.2),
|
||||
libghc-hspec-wai-dev,
|
||||
libghc-hspec-wai-json-dev,
|
||||
libghc-http-media-dev,
|
||||
libghc-packdeps-dev,
|
||||
Build-Depends-Indep: ghc-doc,
|
||||
libghc-http-doc,
|
||||
libghc-missingh-doc,
|
||||
libghc-ranged-sets-doc,
|
||||
libghc-aeson-doc,
|
||||
libghc-base64-string-doc,
|
||||
libghc-bcrypt-doc,
|
||||
libghc-blaze-builder-doc,
|
||||
libghc-case-insensitive-doc,
|
||||
libghc-cassava-doc,
|
||||
libghc-convertible-doc,
|
||||
libghc-hasql-doc,
|
||||
libghc-hasql-backend-doc,
|
||||
libghc-hasql-postgres-doc,
|
||||
libghc-http-types-doc,
|
||||
libghc-jwt-doc,
|
||||
libghc-mtl-doc,
|
||||
libghc-network-doc,
|
||||
libghc-network-uri-doc,
|
||||
libghc-optparse-applicative-doc,
|
||||
libghc-regex-base-doc,
|
||||
libghc-regex-tdfa-doc,
|
||||
libghc-resource-pool-doc,
|
||||
libghc-scientific-doc,
|
||||
libghc-split-doc,
|
||||
libghc-string-conversions-doc,
|
||||
libghc-stringsearch-doc,
|
||||
libghc-text-doc,
|
||||
libghc-unordered-containers-doc,
|
||||
libghc-vector-doc,
|
||||
libghc-wai-doc,
|
||||
libghc-wai-cors-doc,
|
||||
libghc-wai-extra-doc,
|
||||
libghc-wai-middleware-static-doc,
|
||||
libghc-warp-doc,
|
||||
Standards-Version: 3.9.6
|
||||
Homepage: https://github.com/begriffs/postgrest
|
||||
Description: REST API for any Postgres database
|
||||
Reads the schema of a PostgreSQL database and creates RESTful routes
|
||||
for the tables and views, supporting all HTTP verbs that security
|
||||
permits.
|
||||
|
||||
Package: libghc-postgrest-dev
|
||||
Architecture: any
|
||||
Depends: ${haskell:Depends},
|
||||
${misc:Depends},
|
||||
${shlibs:Depends},
|
||||
Recommends: ${haskell:Recommends},
|
||||
Suggests: ${haskell:Suggests},
|
||||
Conflicts: ${haskell:Conflicts},
|
||||
Provides: ${haskell:Provides},
|
||||
Description: ${haskell:ShortDescription}${haskell:ShortBlurb}
|
||||
${haskell:LongDescription}
|
||||
.
|
||||
${haskell:Blurb}
|
||||
|
||||
Package: libghc-postgrest-prof
|
||||
Architecture: any
|
||||
Depends: ${haskell:Depends},
|
||||
${misc:Depends},
|
||||
Recommends: ${haskell:Recommends},
|
||||
Suggests: ${haskell:Suggests},
|
||||
Conflicts: ${haskell:Conflicts},
|
||||
Provides: ${haskell:Provides},
|
||||
Description: ${haskell:ShortDescription}${haskell:ShortBlurb}
|
||||
${haskell:LongDescription}
|
||||
.
|
||||
${haskell:Blurb}
|
||||
|
||||
Package: libghc-postgrest-doc
|
||||
Architecture: all
|
||||
Section: doc
|
||||
Depends: ${haskell:Depends},
|
||||
${misc:Depends},
|
||||
Recommends: ${haskell:Recommends},
|
||||
Suggests: ${haskell:Suggests},
|
||||
Conflicts: ${haskell:Conflicts},
|
||||
Description: ${haskell:ShortDescription}${haskell:ShortBlurb}
|
||||
${haskell:LongDescription}
|
||||
.
|
||||
${haskell:Blurb}
|
||||
|
||||
Package: haskell-postgrest-utils
|
||||
Architecture: any
|
||||
Section: misc
|
||||
Depends: ${haskell:Depends},
|
||||
${misc:Depends},
|
||||
Recommends: ${haskell:Recommends},
|
||||
Suggests: ${haskell:Suggests},
|
||||
Conflicts: ${haskell:Conflicts},
|
||||
Provides: ${haskell:Provides},
|
||||
Description: ${haskell:ShortDescription}${haskell:ShortBlurb}
|
||||
${haskell:LongDescription}
|
||||
.
|
||||
${haskell:Blurb}
|
||||
@@ -1,32 +0,0 @@
|
||||
Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: postgrest
|
||||
Upstream-Contact: Joe Nelson <joe@begriffs.com>
|
||||
Source: https://hackage.haskell.org/package/postgrest
|
||||
|
||||
Files: *
|
||||
Copyright: 2014-2015 Joe Nelson <joe@begriffs.com>
|
||||
License: Expat
|
||||
|
||||
Files: debian/*
|
||||
Copyright: 2015 Fernando Ike <fike@midstorm.org>
|
||||
License: Expat
|
||||
|
||||
License: Expat
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
.
|
||||
The above copyright notice and this permission notice shall be included
|
||||
in all copies or substantial portions of the Software.
|
||||
.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1 +0,0 @@
|
||||
dist-ghc/build/postgrest/postgrest usr/bin
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
d=$(dirname $0)
|
||||
if [ -f /etc/default/postgrest ]; then
|
||||
. /etc/default/postgrest
|
||||
fi
|
||||
POSTGREST_LOG=${POSTGREST_LOG:-/var/log/postgrest/postgrest.log}
|
||||
|
||||
exec $d/postgrest "$@" >>$POSTGREST_LOG 2>&1 &
|
||||
@@ -1,23 +0,0 @@
|
||||
# run service as
|
||||
#POSTGREST_USER=postgrest
|
||||
|
||||
# log file
|
||||
#POSTGREST_LOG=/var/log/postgrest/postgrest.log
|
||||
|
||||
# database host
|
||||
#POSTGREST_DBHOST=localhost
|
||||
|
||||
# database to use
|
||||
#POSTGREST_DBNAME=
|
||||
|
||||
# database user
|
||||
#POSTGREST_DBUSER=postgres
|
||||
|
||||
# database password
|
||||
#POSTGREST_DBPASS=
|
||||
|
||||
# database pool
|
||||
#POSTGREST_DBPOOL=10
|
||||
|
||||
# additional options
|
||||
#POSTGREST_OPTS=
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/bin/sh
|
||||
### BEGIN INIT INFO
|
||||
# Provides: postgrest
|
||||
# Required-Start: $local_fs $network postgresql
|
||||
# Required-Stop: $local_fs $network
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Description: PostgreSQL REST API daemon
|
||||
### END INIT INFO
|
||||
|
||||
. /lib/lsb/init-functions
|
||||
if test -f /etc/default/postgrest; then
|
||||
. /etc/default/postgrest
|
||||
fi
|
||||
POSTGREST=/usr/local/bin/postgrest
|
||||
POSTGREST_USER=${POSTGREST_USER:-postgrest}
|
||||
POSTGREST_DBNAME=${POSTGREST_DBNAME:-postgres}
|
||||
POSTGREST_DBUSER=${POSTGREST_DBUSER:-postgres}
|
||||
if [ -n "$POSTGREST_DBHOST" ]; then
|
||||
POSTGREST_OPTS="$POSTGREST_OPTS --db-host $POSTGREST_DBHOST"
|
||||
fi
|
||||
if [ -n "$POSTGREST_DBNAME" ]; then
|
||||
POSTGREST_OPTS="$POSTGREST_OPTS --db-name $POSTGREST_DBNAME"
|
||||
fi
|
||||
if [ -n "$POSTGREST_DBUSER" ]; then
|
||||
POSTGREST_OPTS="$POSTGREST_OPTS --db-user $POSTGREST_DBUSER"
|
||||
POSTGREST_OPTS="$POSTGREST_OPTS --anonymous $POSTGREST_DBUSER"
|
||||
fi
|
||||
if [ -n "$POSTGREST_DBPASS" ]; then
|
||||
POSTGREST_OPTS="$POSTGREST_OPTS --db-pass $POSTGREST_DBPASS"
|
||||
fi
|
||||
if [ -n "$POSTGREST_DBPOOL" ]; then
|
||||
POSTGREST_OPTS="$POSTGREST_OPTS --db-pool $POSTGREST_DBPOOL"
|
||||
fi
|
||||
POSTGREST_OPTS="$POSTGREST_OPTS --v1schema public"
|
||||
|
||||
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 -- $POSTGREST_OPTS; 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
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
DEB_ENABLE_TESTS = yes
|
||||
DEB_CABAL_PACKAGE = postgrest
|
||||
DEB_DEFAULT_COMPILER = ghc
|
||||
|
||||
include /usr/share/cdbs/1/rules/debhelper.mk
|
||||
include /usr/share/cdbs/1/class/hlibrary.mk
|
||||
|
||||
build/haskell-postgrest-utils:: build-ghc-stamp
|
||||
@@ -1 +0,0 @@
|
||||
3.0 (quilt)
|
||||
@@ -1,2 +0,0 @@
|
||||
version=3
|
||||
http://hackage.haskell.org/package/postgrest/distro-monitor .*-([0-9\.]+)\.(?:zip|tgz|tbz|txz|(?:tar\.(?:gz|bz2|xz)))
|
||||
@@ -0,0 +1,52 @@
|
||||
FROM debian:stretch-slim
|
||||
|
||||
ARG POSTGREST_VERSION
|
||||
|
||||
# Install libpq5
|
||||
RUN apt-get -qq update && \
|
||||
apt-get -qq install -y --no-install-recommends libpq5 && \
|
||||
apt-get -qq clean && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Install postgrest
|
||||
RUN BUILD_DEPS="curl ca-certificates xz-utils" && \
|
||||
apt-get -qq update && \
|
||||
apt-get -qq install -y --no-install-recommends $BUILD_DEPS && \
|
||||
cd /tmp && \
|
||||
curl -SLO https://github.com/PostgREST/postgrest/releases/download/${POSTGREST_VERSION}/postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
|
||||
tar -xJvf postgrest-${POSTGREST_VERSION}-ubuntu.tar.xz && \
|
||||
mv postgrest /usr/local/bin/postgrest && \
|
||||
cd / && \
|
||||
apt-get -qq purge --auto-remove -y $BUILD_DEPS && \
|
||||
apt-get -qq clean && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
COPY postgrest.conf /etc/postgrest.conf
|
||||
|
||||
|
||||
ENV PGRST_DB_URI= \
|
||||
PGRST_DB_SCHEMA=public \
|
||||
PGRST_DB_ANON_ROLE= \
|
||||
PGRST_DB_POOL=100 \
|
||||
PGRST_DB_EXTRA_SEARCH_PATH=public \
|
||||
PGRST_SERVER_HOST=*4 \
|
||||
PGRST_SERVER_PORT=3000 \
|
||||
PGRST_SERVER_PROXY_URI= \
|
||||
PGRST_JWT_SECRET= \
|
||||
PGRST_SECRET_IS_BASE64=false \
|
||||
PGRST_JWT_AUD= \
|
||||
PGRST_MAX_ROWS= \
|
||||
PGRST_PRE_REQUEST= \
|
||||
PGRST_ROLE_CLAIM_KEY=".role"
|
||||
|
||||
RUN groupadd -g 1000 postgrest && \
|
||||
useradd -r -u 1000 -g postgrest postgrest && \
|
||||
chown postgrest:postgrest /etc/postgrest.conf
|
||||
|
||||
USER 1000
|
||||
|
||||
# PostgREST reads /etc/postgrest.conf so map the configuration
|
||||
# file in when you run this container
|
||||
CMD exec postgrest /etc/postgrest.conf
|
||||
|
||||
EXPOSE 3000
|
||||
@@ -0,0 +1,3 @@
|
||||
db-uri = "postgres://app_user:password@postgres:5432/app_db"
|
||||
db-schema = "public"
|
||||
db-anon-role = "app_user"
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM centos:centos6
|
||||
|
||||
RUN yum -y update
|
||||
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
|
||||
RUN yum -y install https://download.postgresql.org/pub/repos/yum/9.3/redhat/rhel-6-x86_64/pgdg-centos93-9.3-2.noarch.rpm
|
||||
RUN yum -y install postgresql93-devel
|
||||
RUN yum clean all
|
||||
RUN curl -sSL https://get.haskellstack.org/ | sh
|
||||
|
||||
ENV PATH $PATH:/usr/pgsql-9.3/bin
|
||||
|
||||
# To disable warning when building
|
||||
ENV PATH $PATH:/root/.local/bin
|
||||
|
||||
RUN mkdir /source
|
||||
WORKDIR /source
|
||||
|
||||
ENTRYPOINT ["stack"]
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM centos:centos7
|
||||
|
||||
RUN yum -y update
|
||||
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
|
||||
RUN yum -y install yum install https://download.postgresql.org/pub/repos/yum/9.3/redhat/rhel-7-x86_64/pgdg-centos93-9.3-2.noarch.rpm
|
||||
RUN yum -y install postgresql93-devel
|
||||
RUN yum clean all
|
||||
RUN curl -sSL https://get.haskellstack.org/ | sh
|
||||
|
||||
ENV PATH $PATH:/usr/pgsql-9.3/bin
|
||||
|
||||
# To disable warning when building
|
||||
ENV PATH $PATH:/root/.local/bin
|
||||
|
||||
RUN mkdir /source
|
||||
WORKDIR /source
|
||||
|
||||
ENTRYPOINT ["stack"]
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM ubuntu:16.04
|
||||
|
||||
RUN BUILD_DEPS="curl ca-certificates build-essential" && \
|
||||
apt-get -qq update && \
|
||||
apt-get -qqy --no-install-recommends install \
|
||||
$BUILD_DEPS \
|
||||
libpq-dev && \
|
||||
curl -sSL https://get.haskellstack.org/ | sh && \
|
||||
apt-get -qq clean && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# To disable warning when building
|
||||
ENV PATH $PATH:/root/.local/bin
|
||||
|
||||
RUN mkdir /source
|
||||
WORKDIR /source
|
||||
|
||||
ENTRYPOINT ["stack"]
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM 32bit/ubuntu:16.04
|
||||
|
||||
RUN BUILD_DEPS="curl ca-certificates build-essential" && \
|
||||
apt-get -qq update && \
|
||||
apt-get -qqy --no-install-recommends install \
|
||||
$BUILD_DEPS \
|
||||
libpq-dev && \
|
||||
curl -sSL https://get.haskellstack.org/ | sh && \
|
||||
apt-get -qq clean && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# To disable warning when building
|
||||
ENV PATH $PATH:/root/.local/bin
|
||||
|
||||
RUN mkdir /source
|
||||
WORKDIR /source
|
||||
|
||||
ENTRYPOINT ["stack"]
|
||||
@@ -0,0 +1,19 @@
|
||||
stgrest:
|
||||
image: pg_local
|
||||
ports:
|
||||
- "3000:3000"
|
||||
links:
|
||||
- postgres:postgres
|
||||
environment:
|
||||
PGRST_DB_URI: postgres://app_user:password@postgres:5432/app_db
|
||||
PGRST_DB_SCHEMA: public
|
||||
PGRST_DB_ANON_ROLE: app_user
|
||||
|
||||
postgres:
|
||||
image: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: app_db
|
||||
POSTGRES_USER: app_user
|
||||
POSTGRES_PASSWORD: password
|
||||
@@ -0,0 +1,17 @@
|
||||
db-uri = "$(PGRST_DB_URI)"
|
||||
db-schema = "$(PGRST_DB_SCHEMA)"
|
||||
db-anon-role = "$(PGRST_DB_ANON_ROLE)"
|
||||
db-pool = "$(PGRST_DB_POOL)"
|
||||
db-extra-search-path = "$(PGRST_DB_EXTRA_SEARCH_PATH)"
|
||||
|
||||
server-host = "$(PGRST_SERVER_HOST)"
|
||||
server-port = "$(PGRST_SERVER_PORT)"
|
||||
|
||||
server-proxy-uri = "$(PGRST_SERVER_PROXY_URI)"
|
||||
jwt-secret = "$(PGRST_JWT_SECRET)"
|
||||
secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)"
|
||||
jwt-aud = "$(PGRST_JWT_AUD)"
|
||||
role-claim-key = "$(PGRST_ROLE_CLAIM_KEY)"
|
||||
|
||||
max-rows = "$(PGRST_MAX_ROWS)"
|
||||
pre-request = "$(PGRST_PRE_REQUEST)"
|
||||
@@ -1 +0,0 @@
|
||||
postgrest.com
|
||||
@@ -1,9 +0,0 @@
|
||||
## Deployment
|
||||
|
||||
### Heroku
|
||||
|
||||
#### Getting Started
|
||||
|
||||
#### Using Amazon RDS
|
||||
|
||||
### Debian
|
||||
@@ -1,9 +0,0 @@
|
||||
## Data Migration
|
||||
|
||||
### Sqitch
|
||||
|
||||
### Test-Driven Migrations
|
||||
|
||||
#### Structural Tests
|
||||
|
||||
#### Value Tests with pgTAP
|
||||
@@ -1,9 +0,0 @@
|
||||
## Performance
|
||||
|
||||
### Benchmarks
|
||||
|
||||
### Caching
|
||||
|
||||
### Quality of Service
|
||||
|
||||
### Tips
|
||||
@@ -1,21 +0,0 @@
|
||||
## Security
|
||||
|
||||
### SSL
|
||||
|
||||
### Database Roles
|
||||
|
||||
### JSON Web Tokens
|
||||
|
||||
#### Issuing via sql procedures
|
||||
|
||||
### Row-Level Security
|
||||
|
||||
#### Simulated - PostgreSQL <9.5
|
||||
|
||||
#### Real - PostgreSQL >=9.5
|
||||
|
||||
### Building Auth on top of JWT
|
||||
|
||||
#### Basic Auth
|
||||
|
||||
#### Github Sign-in
|
||||
@@ -1,9 +0,0 @@
|
||||
## API Versioning
|
||||
|
||||
### Schema Search Path
|
||||
|
||||
### Changing a Resource
|
||||
|
||||
### Removing a Resource
|
||||
|
||||
### Avoiding DB and Client Coupling
|
||||
@@ -1,296 +0,0 @@
|
||||
## Requesting Information
|
||||
|
||||
### Tables and Views
|
||||
|
||||
* ✅ Cacheable, prefetchable
|
||||
* ✅ Idempotent
|
||||
|
||||
The list of accessible tables and views is provided at
|
||||
|
||||
```HTTP
|
||||
GET /
|
||||
```
|
||||
|
||||
Every view and table accessible by the active db role is exposed
|
||||
in a one-level deep route. For instance the full contents of a table
|
||||
`people` is returned at
|
||||
|
||||
```HTTP
|
||||
GET /people
|
||||
```
|
||||
|
||||
There are no `deeply/nested/routes`. Each route provides `OPTIONS`,
|
||||
`GET`, `POST`, `PUT`, `PATCH`, and `DELETE` verbs depending entirely
|
||||
on database permissions.
|
||||
|
||||
<div class="admonition note">
|
||||
<p class="admonition-title">Design Consideration</p>
|
||||
|
||||
<p>Why not provide nested routes? Many APIs allow nesting to
|
||||
retrieve related information, such as <code>/films/1/director</code>.
|
||||
We offer a more flexible mechanism instead to embed related
|
||||
information, including many-to-many relationships. This is covered
|
||||
in the section about Embedding.</p>
|
||||
</div>
|
||||
|
||||
### Stored Procedures
|
||||
|
||||
* ❌ Cannot necessarily be cached or prefetched
|
||||
* ❌ Not necessarily idempotent
|
||||
|
||||
Every stored procedure is accessible under the `/rpc` prefix. The
|
||||
API endpoint supports only POST which executes the function.
|
||||
|
||||
```HTTP
|
||||
POST /rpc/proc_name
|
||||
```
|
||||
|
||||
PostgREST supports calling procedures with [named
|
||||
arguments](http://www.postgresql.org/docs/9.4/static/sql-syntax-calling-funcs.html#SQL-SYNTAX-CALLING-FUNCS-NAMED).
|
||||
To do so include a JSON object in the request payload and each
|
||||
key/value of the object will become an argument.
|
||||
|
||||
<div class="admonition note">
|
||||
<p class="admonition-title">Design Consideration</p>
|
||||
|
||||
<p>Why the /rpc prefix? One reason is to avoid name collisions
|
||||
between views and procedures. It also helps emphasize to API
|
||||
consumers that these functions are not normal restful things.
|
||||
The functions can have arbitrary and surprising behavior, not
|
||||
the standard "post creates a resource" thing that users expect
|
||||
from the other routes.</p>
|
||||
|
||||
<p>We considered allowing GET requests for functions that are
|
||||
marked non-volatile but could not reconcile how to pass in
|
||||
parameters. Query string arguments are reserved for shaping/filtering
|
||||
the output, not providing input.</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
### Filtering
|
||||
|
||||
#### Filtering Rows
|
||||
|
||||
You can filter result rows by adding conditions on columns, each
|
||||
condition a query string parameter. For instance, to return people
|
||||
aged under 13 years old:
|
||||
|
||||
```HTTP
|
||||
GET /people?age=lt.13
|
||||
```
|
||||
|
||||
Adding multiple parameters conjoins the conditions:
|
||||
|
||||
```HTTP
|
||||
GET /people?age=gte.18&student=is.true
|
||||
```
|
||||
|
||||
These operators are available:
|
||||
|
||||
abbreviation | meaning
|
||||
------------ | -------
|
||||
eq | equals
|
||||
gt | greater than
|
||||
lt | less than
|
||||
gte | greater than or equal
|
||||
lte | less than or equal
|
||||
like | LIKE operator (use * in place of %)
|
||||
ilike | ILIKE operator (use * in place of %)
|
||||
@@ | full-text search using to_tsquery
|
||||
is | checking for exact equality (null,true,false)
|
||||
in | one of a list of values e.g. `?a=in.1,2,3`
|
||||
not | negates another operator, see below
|
||||
|
||||
To negate any operator, prefix it with `not` like `?a=not.eq.2`.
|
||||
|
||||
For more complicated filters (such as those involving condition 1
|
||||
*OR* condition 2) you will have to create a new view in the database.
|
||||
|
||||
Filters may be applied to [computed
|
||||
columns](http://www.postgresql.org/docs/current/interactive/xfunc-sql.html#XFUNC-SQL-COMPOSITE-FUNCTIONS)
|
||||
as well as actual table/view columns, even though the computed
|
||||
columns will not appear in the output.
|
||||
|
||||
#### Filtering Columns
|
||||
|
||||
You can customize which columns are returned by using the `select`
|
||||
parameter:
|
||||
|
||||
```HTTP
|
||||
GET /people?select=age,height,weight
|
||||
```
|
||||
|
||||
To cast the column types, add a double colon
|
||||
|
||||
```HTTP
|
||||
GET /people?select=age::text,height,weight
|
||||
```
|
||||
|
||||
Not all type coercions are possible, and you will get an error
|
||||
describing any problems from selection or type casting.
|
||||
|
||||
The `select` keyword is reserved. You thus cannot filter rows based
|
||||
on a column named select. Then again it is a reserved SQL keyword
|
||||
too, hence an unlikely column name.
|
||||
|
||||
#### Inside JSONB
|
||||
|
||||
PostgreSQL >=9.4.2 supports native JSON columns and can even index
|
||||
them by internal keys using the `jsonb` column type. PostgREST
|
||||
allows you to filter results by internal JSON object values. Use
|
||||
the single- and double-arrows to path into and obtain values, e.g.
|
||||
|
||||
```HTTP
|
||||
GET /stuff?json_col->a->>b=eq.2
|
||||
```
|
||||
|
||||
This query finds rows in `stuff` where `json_col->'a'->>'b'` is
|
||||
equal to 2 (or "2" -- it coerces as needed). The final arrow must
|
||||
be the double kind, `->>`, or else PostgREST will not attempt to
|
||||
look inside the JSON.
|
||||
|
||||
### Ordering
|
||||
|
||||
The reserved word `order` reorders the response rows. It uses a
|
||||
comma-separated list of columns and directions:
|
||||
|
||||
```HTTP
|
||||
GET /people?order=age.desc,height.asc
|
||||
```
|
||||
|
||||
If no direction is specified it defaults to descending order:
|
||||
|
||||
```HTTP
|
||||
GET /people?order=age
|
||||
```
|
||||
|
||||
If you care where nulls are sorted, add `nullsfirst` or `nullslast`:
|
||||
|
||||
```HTTP
|
||||
GET /people?order=age.nullsfirst
|
||||
```
|
||||
|
||||
### Limiting and Pagination
|
||||
|
||||
#### Pagination by Limit-Offset
|
||||
|
||||
PostgREST uses HTTP range headers for limiting and describing the
|
||||
size of results. Every response contains the current range and total
|
||||
results:
|
||||
|
||||
```
|
||||
Range-Unit: items
|
||||
Content-Range → 0-14/15
|
||||
```
|
||||
|
||||
This means items zero through fourteen are returned out of a total
|
||||
of fifteen -- i.e. all of them. This information is available in
|
||||
every response and can help you render pagination controls on the
|
||||
client. This is a RFC7233-compliant solution that keeps the response
|
||||
JSON cleaner.
|
||||
|
||||
The client can set the limit and offset of a request by setting the
|
||||
`Range` header. Translate the limit and offset into a range. To
|
||||
request the first five elements, include these request headers:
|
||||
|
||||
```
|
||||
Range-Unit: items
|
||||
Range: 0-4
|
||||
```
|
||||
|
||||
You can also use open-ended ranges for an offset with no limit:
|
||||
`Range: 10-`.
|
||||
|
||||
#### Suppressing Counts
|
||||
|
||||
Sometimes knowing the total row count of a query is unnecessary and
|
||||
only adds extra cost to the database query. So you can skip the
|
||||
count total using a ```Prefer``` header as:
|
||||
|
||||
```
|
||||
Prefer: count=none
|
||||
```
|
||||
|
||||
So the PostgREST response will be something like:
|
||||
|
||||
```
|
||||
Range-Unit: items
|
||||
Content-Range → 0-14/*
|
||||
```
|
||||
|
||||
### Embedding Foreign Entities
|
||||
|
||||
Suppose you have a `projects` table which references `clients` through
|
||||
a foreign key called `client_id`. When listing projects through the
|
||||
API you can have it embed the client within each project response.
|
||||
For example,
|
||||
|
||||
```HTTP
|
||||
GET /projects?id=eq.1&select=id, name, clients(*)
|
||||
```
|
||||
|
||||
Notice this is the same `select` keyword which is used to choose
|
||||
which columns to include. When a column name is followed by parentheses
|
||||
that means to fetch the entire record and nest it. You include a
|
||||
list of columns inside the parens, or asterisk to request all
|
||||
columns.
|
||||
|
||||
The embedding works for 1-N, N-1, and N-N relationships. That means
|
||||
you could also ask for a client and all their projects:
|
||||
|
||||
```HTTP
|
||||
GET /clients?id=eq.42&select=id, name, projects(*)
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
Query responses default to JSON but you can get them in CSV as well. Just make your request with the header
|
||||
|
||||
```HTTP
|
||||
Accept: text/csv
|
||||
```
|
||||
|
||||
### Singular vs Plural
|
||||
|
||||
Many APIs distinguish plural and singular resources, e.g.`/stories`
|
||||
vs `/stories/1`. Why do we use `/stories?id=eq.1`? It is because a
|
||||
single resource is for us a row determined by a primary key, and
|
||||
primary keys can be *compound* (meaning defined across more than
|
||||
one column). The common urls come from a degenerate case of simple
|
||||
(and overwhelmingly numeric) primary keys often introduced automatically
|
||||
be Object Relational Mapping.
|
||||
|
||||
For consistency's sake all these endpoints return a JSON array,
|
||||
`/stories`, `/stories?genre=eq.mystery`, `/stories?id=eq.1`. They
|
||||
are all filtering a bigger array. However you might want the
|
||||
last one to return a single JSON object, not an array with one
|
||||
element. There is currently an open issue to enable this.
|
||||
|
||||
### Data Schema
|
||||
|
||||
As well as issuing a `GET /` to obtain a list of the tables, views,
|
||||
and stored procedures available, you can get more information about
|
||||
any particular endpoint.
|
||||
|
||||
```HTTP
|
||||
OPTIONS /my_view
|
||||
```
|
||||
|
||||
This will include the row names, their types, primary key
|
||||
information, and foreign keys for the given table or view.
|
||||
|
||||
<div class="admonition danger">
|
||||
<p class="admonition-title">Deprecation Warning</p>
|
||||
|
||||
<p>Although we currently use the OPTIONS verb for this, some
|
||||
people <a
|
||||
href="https://www.mnot.net/blog/2012/10/29/NO_OPTIONS">argue</a> that
|
||||
this is inappropriate. We are considering a <code>describedby</code>
|
||||
header link instead.</p>
|
||||
</div>
|
||||
|
||||
### CORS
|
||||
|
||||
PostgREST sets highly permissive cross origin resource sharing. It
|
||||
accepts Ajax requests from any domain.
|
||||
@@ -1,131 +0,0 @@
|
||||
## Updating Data
|
||||
|
||||
### Record Creation
|
||||
|
||||
* ❌ Cannot be cached or prefetched
|
||||
* ❌ Not idempotent
|
||||
|
||||
To create a row in a database table post a JSON object whose keys
|
||||
are the names of the columns you would like to create. Missing keys
|
||||
will be set to default values when applicable.
|
||||
|
||||
```HTTP
|
||||
POST /table_name
|
||||
{ "col1": "value1", "col2": "value2" }
|
||||
```
|
||||
|
||||
The response will include a `Location` header describing where to
|
||||
find the new object. If you would like to get the full object back
|
||||
in the response to your request, include the header `Prefer:
|
||||
return=representation`. That way you won't have to make another
|
||||
HTTP call to discover properties that may have been filled in on
|
||||
the server side.
|
||||
|
||||
### Bulk Insertion
|
||||
|
||||
* ❌ Cannot be cached or prefetched
|
||||
* ❌ Not idempotent
|
||||
|
||||
While regular insertion uses JSON to encode the value, bulk insertion
|
||||
uses CSV. Simply post to a table route with `Content-Type: text/csv`
|
||||
and include the names of the columns as the first row. For instance
|
||||
|
||||
```HTTP
|
||||
POST /people
|
||||
name,age,height
|
||||
J Doe,62,70
|
||||
Jonas,10,55
|
||||
```
|
||||
|
||||
An empty field (`,,`) is coerced to an empty string and the reserved
|
||||
word `NULL` is mapped to the SQL null value. Note that there should
|
||||
be no spaces between the column names and commas.
|
||||
|
||||
The server sends a multipart response for bulk insertions. Each part
|
||||
contains a Location header with URL of each created resource.
|
||||
|
||||
```HTTP
|
||||
Content-Type: application/json
|
||||
Location: /festival?name=eq.Venice%20Film%20Festival
|
||||
|
||||
|
||||
--postgrest_boundary
|
||||
Content-Type: application/json
|
||||
Location: /festival?name=eq.Cannes%20Film%20Festival
|
||||
```
|
||||
|
||||
### Upsertion
|
||||
|
||||
* ❌ Cannot be cached or prefetched
|
||||
* ✅ Idempotent
|
||||
|
||||
To insert or update a single row use the `PUT` verb on a properly
|
||||
filtered table url:
|
||||
|
||||
```HTTP
|
||||
PUT /table_name?primary_key=eq.foo
|
||||
{ "col1": "value1", "col2": "value2" }
|
||||
```
|
||||
|
||||
The request must satisfy two things. First all columns must be
|
||||
specified (because a default value might be a changing value which
|
||||
would violate idempotence). Second the URL must match the URL you
|
||||
would use to get the value of the resource. This means that all
|
||||
primary key columns must be included in the filter (there are more
|
||||
than one when the primary key is compound).
|
||||
|
||||
If you would like to get the full object back in the response to
|
||||
your request, include the header `Prefer: return=representation`.
|
||||
It will of match exactly the object you sent though.
|
||||
|
||||
### Bulk Updates
|
||||
|
||||
* ❌ Cannot be cached or prefetched
|
||||
* ❌ Not idempotent
|
||||
|
||||
To change parts of a resource or resources use the `PATCH` verb.
|
||||
For instance, here is how to mark all young people as children.
|
||||
|
||||
```HTTP
|
||||
PATCH /people?age=lt.13
|
||||
{
|
||||
"person_type": "child"
|
||||
}
|
||||
```
|
||||
|
||||
This affects any rows matched by the url param filters, overwrites
|
||||
any fields specified in in the payload JSON and leaves the other
|
||||
fields unaffected. Note that although the payload is not in the
|
||||
JSON patch format specified by
|
||||
[RFC6902](https://tools.ietf.org/html/rfc6902), HTTP does not specify
|
||||
which patch format to use. Our format is more pleasant, meant for
|
||||
basic field replacements, and not at all "incorrect."
|
||||
|
||||
### Deletion
|
||||
|
||||
* ❌ Cannot be cached or prefetched
|
||||
* ✅ Idempotent
|
||||
|
||||
Simply use the `DELETE` verb. All recors that match your filter
|
||||
will be removed. For instance deleting inactive users:
|
||||
|
||||
```HTTP
|
||||
DELETE /user?active=eq.false
|
||||
```
|
||||
|
||||
### Protecting Dangerous Actions
|
||||
|
||||
Notice that it is very easy to delete or update many records at
|
||||
once. In fact forgetting a filter will affect an entire table!
|
||||
|
||||
|
||||
<div class="admonition warning">
|
||||
<p class="admonition-title">Invitation to Contribute</p>
|
||||
|
||||
<p>We would like to investigate nginx rules to guard dangerous
|
||||
actions, perhaps requiring a confirmation header or query param
|
||||
to perform the action.</p>
|
||||
|
||||
<p>You're invited to research this option and contribute to
|
||||
this documentation.</p>
|
||||
</div>
|
||||
@@ -1,346 +0,0 @@
|
||||
## Getting Started
|
||||
|
||||
### Your First (simple) API
|
||||
|
||||
Let's start with the simplest thing possible. We will expose some tables directly for reading and writing by anyone.
|
||||
|
||||
Start by making a database
|
||||
|
||||
```sh
|
||||
createdb demo1
|
||||
```
|
||||
|
||||
We'll set it up with a film example (courtesy of [Jonathan Harrington](http://blog.jonharrington.org/postgrest-introduction/)). Copy the following into your clipboard:
|
||||
|
||||
```sql
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE director
|
||||
(
|
||||
name text NOT NULL PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE film
|
||||
(
|
||||
id serial PRIMARY KEY,
|
||||
title text NOT NULL,
|
||||
year date NOT NULL,
|
||||
director text,
|
||||
rating real NOT NULL DEFAULT 0,
|
||||
language text NOT NULL,
|
||||
CONSTRAINT film_director_fkey FOREIGN KEY (director)
|
||||
REFERENCES director (name) MATCH SIMPLE
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE festival
|
||||
(
|
||||
name text NOT NULL PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE competition
|
||||
(
|
||||
id serial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
festival text NOT NULL,
|
||||
year date NOT NULL,
|
||||
|
||||
CONSTRAINT comp_festival_fkey FOREIGN KEY (festival)
|
||||
REFERENCES festival (name) MATCH SIMPLE
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE film_nomination
|
||||
(
|
||||
id serial PRIMARY KEY,
|
||||
competition integer NOT NULL,
|
||||
film integer NOT NULL,
|
||||
won boolean NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT nomination_competition_fkey FOREIGN KEY (competition)
|
||||
REFERENCES competition (id) MATCH SIMPLE
|
||||
ON UPDATE NO ACTION ON DELETE NO ACTION,
|
||||
CONSTRAINT nomination_film_fkey FOREIGN KEY (film)
|
||||
REFERENCES film (id) MATCH SIMPLE
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
Apply it to your new database by running
|
||||
|
||||
```sh
|
||||
# On OS X
|
||||
pbpaste | psql demo1
|
||||
|
||||
# Or Linux
|
||||
# xclip -selection clipboard -o | psql demo1
|
||||
```
|
||||
|
||||
Start the PostgREST server and point it at the new database.
|
||||
|
||||
```sh
|
||||
postgrest -d demo1 -U postgres -a postgres --v1schema public
|
||||
```
|
||||
|
||||
<div class="admonition note">
|
||||
<p class="admonition-title">Note about database users</p>
|
||||
|
||||
<p>If you installed PostgreSQL with Homebrew on Mac then the
|
||||
database username may be your own login rather than
|
||||
<code>postgres</code>.</p>
|
||||
</div>
|
||||
|
||||
Let's use PostgREST to populate the database. Install a REST client such as [Postman](https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop?hl=en). Now let's insert some data as a bulk post in CSV format:
|
||||
|
||||
```HTTP
|
||||
POST http://localhost:3000/festival
|
||||
Content-Type: text/csv
|
||||
|
||||
name
|
||||
Venice Film Festival
|
||||
Cannes Film Festival
|
||||
```
|
||||
|
||||
In Postman it will look like this
|
||||
|
||||

|
||||
|
||||
Notice that the post type is `raw` and that `Content-Type: text/csv` set in the Headers tab.
|
||||
|
||||
Note that the server returns a multipart response with URL of each created resource.
|
||||
|
||||
```HTTP
|
||||
Content-Type: application/json
|
||||
Location: /festival?name=eq.Venice%20Film%20Festival
|
||||
|
||||
|
||||
--postgrest_boundary
|
||||
Content-Type: application/json
|
||||
Location: /festival?name=eq.Cannes%20Film%20Festival
|
||||
```
|
||||
|
||||
If you send a GET request to `/festival` it should return
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Venice Film Festival"
|
||||
},
|
||||
{
|
||||
"name": "Cannes Film Festival"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Now that you've seen how to do a bulk insert, let's do some more and fully populate the database.
|
||||
|
||||
Post the following to `/competition`:
|
||||
|
||||
```csv
|
||||
name,festival,year
|
||||
Golden Lion,Venice Film Festival,2014-01-01
|
||||
Palme d'Or,Cannes Film Festival,2014-01-01
|
||||
```
|
||||
|
||||
Now `/director`:
|
||||
|
||||
```csv
|
||||
name
|
||||
Bertrand Bonello
|
||||
Atom Egoyan
|
||||
David Gordon Green
|
||||
Andrey Konchalovskiy
|
||||
Mario Martone
|
||||
Mike Leigh
|
||||
Roy Andersson
|
||||
Saverio Costanzo
|
||||
Alix Delaporte
|
||||
Jean-Pierre Dardenne
|
||||
Xiaoshuai Wang
|
||||
Kaan Müjdeci
|
||||
Tommy Lee Jones
|
||||
Nuri Bilge Ceylan
|
||||
Michel Hazanavicius
|
||||
Xavier Dolan
|
||||
Ramin Bahrani
|
||||
Alice Rohrwacher
|
||||
Andrew Niccol
|
||||
Rakhshan Bani-Etemad
|
||||
David Oelhoffen
|
||||
Bennett Miller
|
||||
David Cronenberg
|
||||
Shin'ya Tsukamoto
|
||||
Joshua Oppenheimer
|
||||
Olivier Assayas
|
||||
Jean-Luc Godard
|
||||
Alejandro González Iñárritu
|
||||
Benoît Jacquot
|
||||
Fatih Akin
|
||||
Francesco Munzi
|
||||
Ken Loach
|
||||
Abel Ferrara
|
||||
Xavier Beauvois
|
||||
Naomi Kawase
|
||||
```
|
||||
|
||||
And `/film`:
|
||||
|
||||
```csv
|
||||
title,year,director,rating,language
|
||||
Chuang ru zhe,2014-01-01,Xiaoshuai Wang,6.19999981,english
|
||||
The Look of Silence,2014-01-01,Joshua Oppenheimer,8.30000019,Indonesian
|
||||
Fires on the Plain,2014-01-01,Shin'ya Tsukamoto,5.80000019,Japanese
|
||||
Far from Men,2014-01-01,David Oelhoffen,7.5,english
|
||||
Good Kill,2014-01-01,Andrew Niccol,6.0999999,english
|
||||
Leopardi,2014-01-01,Mario Martone,6.9000001,english
|
||||
Sivas,2014-01-01,Kaan Müjdeci,7.69999981,english
|
||||
Black Souls,2014-01-01,Francesco Munzi,7.0999999,english
|
||||
Three Hearts,2014-01-01,Benoît Jacquot,5.80000019,French
|
||||
Pasolini,2014-01-01,Abel Ferrara,5.80000019,english
|
||||
Le dernier coup de marteau,2014-01-01,Alix Delaporte,6.5,english
|
||||
Manglehorn,2014-01-01,David Gordon Green,7.0999999,english
|
||||
Hungry Hearts,2014-01-01,Saverio Costanzo,6.4000001,English
|
||||
Belye nochi pochtalona Alekseya Tryapitsyna,2014-01-01,Andrey Konchalovskiy,6.9000001,Russian
|
||||
99 Homes,2014-01-01,Ramin Bahrani,7.30000019,english
|
||||
The Cut,2014-01-01,Fatih Akin,6,Armenian
|
||||
Birdman: Or (The Unexpected Virtue of Ignorance),2014-01-01,Alejandro González Iñárritu,8,English
|
||||
La rançon de la gloire,2014-01-01,Xavier Beauvois,5.69999981,French
|
||||
A Pigeon Sat on a Branch Reflecting on Existence,2014-01-01,Roy Andersson,7.19999981,english
|
||||
Tales,2014-01-01,Rakhshan Bani-Etemad,6.80000019,english
|
||||
The Wonders,2014-01-01,Alice Rohrwacher,6.80000019,Italian
|
||||
Foxcatcher,2014-01-01,Bennett Miller,7.19999981,English
|
||||
Mr. Turner,2014-01-01,Mike Leigh,7,English
|
||||
Jimmy's Hall,2014-01-01,Ken Loach,6.69999981,English
|
||||
The Homesman,2014-01-01,Tommy Lee Jones,6.5999999,English
|
||||
The Captive,2014-01-01,Atom Egoyan,5.9000001,english
|
||||
Goodbye to Language,2014-01-01,Jean-Luc Godard,6.19999981,French
|
||||
The Search,2014-01-01,Michel Hazanavicius,6.9000001,French
|
||||
Still the Water,2014-01-01,Naomi Kawase,6.9000001,Japanese
|
||||
Mommy,2014-01-01,Xavier Dolan,8.30000019,French
|
||||
"Two Days, One Night",2014-01-01,Jean-Pierre Dardenne,7.4000001,French
|
||||
Maps to the Stars,2014-01-01,David Cronenberg,6.4000001,English
|
||||
Saint Laurent,2014-01-01,Bertrand Bonello,6.5,French
|
||||
Clouds of Sils Maria,2014-01-01,Olivier Assayas,6.9000001,english
|
||||
Winter Sleep,2014-01-01,Nuri Bilge Ceylan,8.5,Turkish
|
||||
```
|
||||
|
||||
Finally `/film_nomination`:
|
||||
|
||||
```csv
|
||||
competition,film,won
|
||||
1,1,f
|
||||
1,2,f
|
||||
1,3,f
|
||||
1,4,f
|
||||
1,5,f
|
||||
1,6,f
|
||||
1,7,f
|
||||
1,8,f
|
||||
1,9,f
|
||||
1,10,f
|
||||
1,11,f
|
||||
1,12,f
|
||||
1,13,f
|
||||
1,14,f
|
||||
1,15,f
|
||||
1,16,f
|
||||
1,17,f
|
||||
1,18,f
|
||||
1,19,f
|
||||
1,20,f
|
||||
2,21,f
|
||||
2,22,f
|
||||
2,23,f
|
||||
2,24,f
|
||||
2,25,f
|
||||
2,26,f
|
||||
2,27,f
|
||||
2,28,f
|
||||
2,29,f
|
||||
2,30,f
|
||||
2,31,f
|
||||
2,32,f
|
||||
2,33,f
|
||||
2,34,f
|
||||
2,35,f
|
||||
```
|
||||
|
||||
At this point nominations are fully specified but it's not a convenient interface for a rest client. Let's make a view they can use. Paste this into `psql demo1`.
|
||||
|
||||
```sql
|
||||
create or replace view nomination as
|
||||
select comp.festival,
|
||||
comp.name as competition,
|
||||
comp.year,
|
||||
film.title,
|
||||
film.director,
|
||||
film.rating
|
||||
from film_nomination as nom
|
||||
left join film on nom.film = film.id
|
||||
left join competition as comp on nom.competition = comp.id
|
||||
order by comp.year desc, comp.festival, competition;
|
||||
```
|
||||
|
||||
Time to try it out. Let's get the contents of the new view, ordered by film rating
|
||||
|
||||
```
|
||||
GET http://localhost:3000/nomination?order=rating.desc
|
||||
```
|
||||
|
||||
If you find it more human readable, add an `Accept: text/csv` header.
|
||||
|
||||
### Releasing a New Version
|
||||
|
||||
Suppose we want this endpoint to cater to those moviegoers with attention deficit disorder. In today's busy world we don't have time to read an extra couple words or compare nuanced reviews. In API version two we will truncate the names and round the ratings!
|
||||
|
||||
Each version lives in a numbered schema, so let's make a schema for version two.
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA "2";
|
||||
GRANT USAGE ON SCHEMA "2" TO PUBLIC;
|
||||
ALTER DATABASE demo1 SET search_path = "2", "public";
|
||||
```
|
||||
|
||||
To override the `films` endpoint create a view in the "2" schema with that name:
|
||||
|
||||
```sql
|
||||
create or replace view "2".film as
|
||||
select id, substring(f.title from 1 for 10) as title,
|
||||
year, director, round(f.rating) as rating, language
|
||||
from "public".film as f;
|
||||
```
|
||||
|
||||
We select the desired version as part of content negotiation. Try this get request:
|
||||
|
||||
```HTTP
|
||||
GET http://localhost:3000/film
|
||||
Accept: text/csv; version=2
|
||||
```
|
||||
|
||||
Then try toggling the version string in the Accept header and watch the results change. Pretty good, now how about writing values? PostgreSQL's nice feature called auto-updatable views allows writes to pass through views. Sadly this view is not eligible because truncation and rounding cannot be uniquely reversed. If we attempt to post a new result it complains:
|
||||
|
||||
```json
|
||||
{
|
||||
"hint": null,
|
||||
"details": "View columns that are not columns of their base relation are not updatable.",
|
||||
"code": "0A000",
|
||||
"message": "cannot insert into column \"title\" of view \"film\""
|
||||
}
|
||||
```
|
||||
|
||||
This is a case where we need explicit triggers
|
||||
|
||||
```sql
|
||||
-- TODO - FIX THIS
|
||||
|
||||
-- CREATE OR REPLACE RULE insert_v2_films AS
|
||||
-- ON INSERT TO "2".film
|
||||
-- DO INSTEAD
|
||||
-- INSERT INTO public.film (id, title, year, director, rating, language)
|
||||
-- VALUES (NEW.id, NEW.title,
|
||||
-- NEW.year, NEW.director,
|
||||
-- NEW.rating, NEW.language)
|
||||
-- RETURNING public.film.*;
|
||||
```
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 54 KiB |
@@ -1,68 +0,0 @@
|
||||

|
||||
|
||||
## Introduction
|
||||
|
||||
PostgREST is a standalone web server that turns your database directly into a RESTful API. The structural constraints and permissions in the database determine the API endpoints and operations.
|
||||
|
||||
This guide explains how to install the software and provides practical examples of its use. You'll learn how to build a fast, versioned, secure API and how to deploy it to production.
|
||||
|
||||
The project has a friendly and growing community. Here are some ways to get help or get involved:
|
||||
|
||||
* The project [chat room](https://gitter.im/begriffs/postgrest)
|
||||
* Report or search [issues](https://github.com/begriffs/postgrest/issues)
|
||||
|
||||
### Motivation
|
||||
|
||||
Using PostgREST is an alternative to manual CRUD programming. Custom API servers suffer problems. Writing business logic often duplicates, ignores or hobbles database structure. Object-relational mapping is a leaky abstraction leading to slow imperative code. The PostgREST philosophy establishes a single declarative source of truth: the data itself.
|
||||
|
||||
#### Declarative Programming
|
||||
|
||||
It's easier to ask Postgres to join data for you and let its query planner figure out the details than to loop through rows yourself. It's easier to assign permissions to db objects than to add guards in controllers. (This is especially true for cascading permissions in data dependencies.) It's easier set constraints than to litter code with sanity checks.
|
||||
|
||||
#### Leakproof Abstraction
|
||||
|
||||
There is no ORM involved. Creating new views happens in SQL with known performance implications. A database administrator can now create an API from scratch with no custom programming.
|
||||
|
||||
#### Embracing the Relational Model
|
||||
|
||||
In 1970 E. F. Codd criticized the then-dominant hierarchical model of databases in his article <a href="https://www.seas.upenn.edu/~zives/03f/cis550/codd.pdf">A Relational Model of Data for Large Shared Data Banks</a>. Reading the article reveals a striking similarity between hierarchical databases and nested http routes. With PostgREST we attempt to use flexible filtering and embedding rather than nested routes.
|
||||
|
||||
#### One Thing Well
|
||||
|
||||
PostgREST has a focused scope. It works well with other tools like Nginx. This forces you to cleanly separate the data-centric CRUD operations from other concerns. Use a collection of sharp tools rather than building a big ball of mud.
|
||||
|
||||
#### Shared Improvements
|
||||
|
||||
As with any open source project, we all gain from features and fixes in the tool. It's more beneficial than improvements locked inextricably within custom codebases.
|
||||
|
||||
### Myths
|
||||
|
||||
#### You have to make tons of stored procs and triggers
|
||||
|
||||
Modern PostgreSQL features like auto-updatable views and computed columns make this mostly unnecessary. Triggers do play a part, but generally not for irksome boilerplate. When they are required triggers are preferable to ad-hoc app code anyway, since the former work reliably for any codepath.
|
||||
|
||||
#### Exposing the database destroys encapsulation
|
||||
|
||||
PostgREST does versioning through database schemas. This allows you to expose tables and views without making the app brittle. Underlying tables can be superseded and hidden behind public facing views. The chapter about versioning shows how to do this.
|
||||
|
||||
### Conventions
|
||||
|
||||
This guide contains highlighted notes and tangential information interspersed with the text.
|
||||
|
||||
<div class="admonition note">
|
||||
<p class="admonition-title">Design Consideration</p>
|
||||
|
||||
<p>Contains history which informed the current design. Sometimes it discusses unavoidable tradeoffs or a point of theory.</p>
|
||||
</div>
|
||||
|
||||
<div class="admonition warning">
|
||||
<p class="admonition-title">Invitation to Contribute</p>
|
||||
|
||||
<p>Points out things we know we want to add or improve. They might give you ideas for ways to contribute to the project.</p>
|
||||
</div>
|
||||
|
||||
<div class="admonition danger">
|
||||
<p class="admonition-title">Deprecation Warning</p>
|
||||
|
||||
<p>Alerts you to features which will be removed in the next major (breaking) release.</p>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
## Ecosystem
|
||||
|
||||
### Client-Side Libraries
|
||||
|
||||
* [mithril.postgrest](https://github.com/catarse/mithril.postgrest) - Mithril plugin to create and authenticate requests
|
||||
* [lewisjared/postgrest-request](https://github.com/lewisjared/postgrest-request) - node interface to postgrest instances
|
||||
* [JarvusInnovations/jarvus-postgrest-apikit](https://github.com/JarvusInnovations/jarvus-postgrest-apikit) - Sencha framework package for binding models/stores/proxies to PostgREST tables
|
||||
|
||||
### Extensions
|
||||
|
||||
* [srid/spas](https://github.com/srid/spas) - allow file uploads and basic auth
|
||||
|
||||
### Example Apps
|
||||
|
||||
* [timwis/ext-postgrest-crud](https://github.com/timwis/ext-postgrest-crud) - browser-based spreadsheet
|
||||
* [srid/chronicle](https://github.com/srid/chronicle#deploying-to-heroku) - tracking a tree of personal memories
|
||||
* [begriffs/postgrest-example](https://github.com/begriffs/postgrest-example) - how to configure a db for use as an API
|
||||
* [marmelab/ng-admin-postgrest](https://github.com/marmelab/ng-admin-postgrest) - automatic database admin panel
|
||||
* [tyrchen/goodfilm](https://github.com/tyrchen/goodfilm) - example film api
|
||||
|
||||
### In Production
|
||||
|
||||
* [Catarse](https://www.catarse.me/)
|
||||
@@ -1,89 +0,0 @@
|
||||
## Installation
|
||||
|
||||
### Installing from Pre-Built Release
|
||||
|
||||
The [release page](https://github.com/begriffs/postgrest/releases/latest) has precompiled binaries for Mac OS X and 64-bit Ubuntu. Next extract the tarball and run the binary inside with no arguments to see usage instructions:
|
||||
|
||||
```sh
|
||||
# Untar the release (available at https://github.com/begriffs/postgrest/releases/latest)
|
||||
|
||||
$ tar zxf postgrest-0.2.12.1-osx.tar.xz
|
||||
|
||||
# Try running it
|
||||
$ ./postgrest
|
||||
|
||||
# You should see a usage help message
|
||||
```
|
||||
|
||||
<div class="admonition warning">
|
||||
<p class="admonition-title">Invitation to Contribute</p>
|
||||
|
||||
<p>I currently build the binaries manually for each version. We need to set up an automated build matrix for various architectures. It should support 32- and 64-bit versions of
|
||||
|
||||
<ul><li>Scientific Linux 6</li><li>CentOS</li><li>RHEL 6</li></ul>
|
||||
|
||||
Also it would be good to create a package for apt.</p>
|
||||
</div>
|
||||
|
||||
We'll learn the meaning of the command line flags later, but here is a minimal example of running the app. It does all operations as user `postgres`, including for unauthenticated requests.
|
||||
|
||||
```sh
|
||||
$ ./postgrest -d dbname -U postgres -a postgres --v1schema public
|
||||
```
|
||||
|
||||
### Building from Source
|
||||
|
||||
When a prebuilt binary does not exist for your system you can build the project from source. You'll also need to do this if you want to help with development. [Stack](https://github.com/commercialhaskell/stack) makes it easy. It will install any necessary Haskell dependencies on your system.
|
||||
|
||||
* [Install Stack](https://github.com/commercialhaskell/stack#how-to-install) for your platform
|
||||
```bash
|
||||
#ubuntu example
|
||||
wget -q -O- https://s3.amazonaws.com/download.fpcomplete.com/ubuntu/fpco.key | sudo apt-key add -
|
||||
echo 'deb http://download.fpcomplete.com/ubuntu/trusty stable main'|sudo tee /etc/apt/sources.list.d/fpco.list
|
||||
sudo apt-get update && sudo apt-get install stack -y
|
||||
```
|
||||
* Build & install in one step
|
||||
|
||||
```bash
|
||||
git clone https://github.com/begriffs/postgrest.git
|
||||
cd postgrest
|
||||
sudo stack install --install-ghc --local-bin-path /usr/local/bin
|
||||
```
|
||||
|
||||
* Run the server
|
||||
|
||||
```bash
|
||||
postgrest dbconnectionstring arg1 arg2
|
||||
```
|
||||
|
||||
If you want to run the test suite, stack can do that too: `stack test`.
|
||||
|
||||
### Install via Homebrew (Mac OS X)
|
||||
|
||||
You can use the Homebrew package manager to install PostgREST on Mac
|
||||
|
||||
```bash
|
||||
# Ensure brew is up to date
|
||||
brew update
|
||||
|
||||
# Check for any problems with brew's setup
|
||||
brew doctor
|
||||
|
||||
# Install the postgrest package
|
||||
brew install postgrest
|
||||
```
|
||||
|
||||
This will automatically install PostgreSQL as a dependency (see the [Installing PostgreSQL](#installing-postgresql) section for setup instructions). The process tends to take around 15 minutes to install the package and its dependencies.
|
||||
|
||||
After installation completes, the tool is added to your $PATH and can be used from anywhere with:
|
||||
|
||||
```bash
|
||||
postgrest --help
|
||||
```
|
||||
|
||||
### Installing PostgreSQL
|
||||
|
||||
To use PostgREST you will need an underlying database. You can use something like Amazon [RDS](https://aws.amazon.com/rds/) but installing your own locally is cheaper and more convenient for development.
|
||||
|
||||
* [Instructions for OS X](http://exponential.io/blog/2015/02/21/install-postgresql-on-mac-os-x-via-brew/)
|
||||
* [Instructions for Ubuntu 14.04](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-14-04)
|
||||
@@ -0,0 +1,337 @@
|
||||
{-# LANGUAGE CPP #-}
|
||||
|
||||
module Main where
|
||||
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Base64 as B64
|
||||
import qualified Hasql.Pool as P
|
||||
import qualified Hasql.Transaction.Sessions as HT
|
||||
|
||||
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
|
||||
updateAction)
|
||||
import Control.Retry (RetryStatus, capDelay,
|
||||
exponentialBackoff, retrying,
|
||||
rsPreviousDelay)
|
||||
import Data.IORef (IORef, atomicWriteIORef, newIORef,
|
||||
readIORef)
|
||||
import Data.String (IsString (..))
|
||||
import Data.Text (pack, replace, strip, stripPrefix,
|
||||
unpack)
|
||||
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
||||
import Data.Text.IO (hPutStrLn, readFile)
|
||||
import Data.Time.Clock (getCurrentTime)
|
||||
import Network.Socket (Family (AF_UNIX),
|
||||
SockAddr (SockAddrUnix), Socket,
|
||||
SocketType (Stream), bind, close,
|
||||
defaultProtocol, listen,
|
||||
maxListenQueue, socket)
|
||||
import Network.Wai.Handler.Warp (defaultSettings, runSettings,
|
||||
runSettingsSocket, setHost, setPort,
|
||||
setServerName)
|
||||
import System.Directory (removeFile)
|
||||
import System.IO (BufferMode (..), hSetBuffering)
|
||||
import System.IO.Error (isDoesNotExistError)
|
||||
|
||||
import PostgREST.App (postgrest)
|
||||
import PostgREST.Config (AppConfig (..), configPoolTimeout',
|
||||
prettyVersion, readOptions)
|
||||
import PostgREST.DbStructure (getDbStructure, getPgVersion)
|
||||
import PostgREST.Error (PgError (PgError), checkIsFatal,
|
||||
errorPayload)
|
||||
import PostgREST.OpenAPI (isMalformedProxyUri)
|
||||
import PostgREST.Types (ConnectionStatus (..), DbStructure,
|
||||
PgVersion (..), Schema,
|
||||
minimumPgVersion)
|
||||
import Protolude hiding (hPutStrLn, replace)
|
||||
|
||||
|
||||
#ifndef mingw32_HOST_OS
|
||||
import System.Posix.Signals
|
||||
#endif
|
||||
|
||||
{-|
|
||||
The purpose of this worker is to fill the refDbStructure created in 'main'
|
||||
with the 'DbStructure' returned from calling 'getDbStructure'. This method
|
||||
is meant to be called by multiple times by the same thread, but does nothing if
|
||||
the previous invocation has not terminated. In all cases this method does not
|
||||
halt the calling thread, the work is preformed in a separate thread.
|
||||
|
||||
Note: 'atomicWriteIORef' is essentially a lazy semaphore that prevents two
|
||||
threads from running 'connectionWorker' at the same time.
|
||||
|
||||
Background thread that does the following :
|
||||
1. Tries to connect to pg server and will keep trying until success.
|
||||
2. Checks if the pg version is supported and if it's not it kills the main
|
||||
program.
|
||||
3. Obtains the dbStructure.
|
||||
4. If 2 or 3 fail to give their result it means the connection is down so it
|
||||
goes back to 1, otherwise it finishes his work successfully.
|
||||
-}
|
||||
connectionWorker
|
||||
:: ThreadId -- ^ This thread is killed if pg version is unsupported
|
||||
-> P.Pool -- ^ The PostgreSQL connection pool
|
||||
-> Schema -- ^ Schema PostgREST is serving up
|
||||
-> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
|
||||
-> IORef Bool -- ^ Used as a binary Semaphore
|
||||
-> IO ()
|
||||
connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
||||
isWorkerOn <- readIORef refIsWorkerOn
|
||||
unless isWorkerOn $ do
|
||||
atomicWriteIORef refIsWorkerOn True
|
||||
void $ forkIO work
|
||||
where
|
||||
work = do
|
||||
atomicWriteIORef refDbStructure Nothing
|
||||
putStrLn ("Attempting to connect to the database..." :: Text)
|
||||
connected <- connectionStatus pool
|
||||
case connected of
|
||||
FatalConnectionError reason -> hPutStrLn stderr reason
|
||||
>> killThread mainTid -- Fatal error when connecting
|
||||
NotConnected -> return () -- Unreachable
|
||||
Connected actualPgVersion -> do -- Procede with initialization
|
||||
result <- P.use pool $ do
|
||||
dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schema actualPgVersion
|
||||
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
|
||||
case result of
|
||||
Left e -> do
|
||||
putStrLn ("Failed to query the database. Retrying." :: Text)
|
||||
hPutStrLn stderr . toS . errorPayload $ PgError False e
|
||||
work
|
||||
|
||||
Right _ -> do
|
||||
atomicWriteIORef refIsWorkerOn False
|
||||
putStrLn ("Connection successful" :: Text)
|
||||
|
||||
{-|
|
||||
Used by 'connectionWorker' to check if the provided db-uri lets
|
||||
the application access the PostgreSQL database. This method is used
|
||||
the first time the connection is tested, but only to test before
|
||||
calling 'getDbStructure' inside the 'connectionWorker' method.
|
||||
|
||||
The connection tries are capped, but if the connection times out no error is
|
||||
thrown, just 'False' is returned.
|
||||
-}
|
||||
connectionStatus :: P.Pool -> IO ConnectionStatus
|
||||
connectionStatus pool =
|
||||
retrying (capDelay 32000000 $ exponentialBackoff 1000000)
|
||||
shouldRetry
|
||||
(const $ P.release pool >> getConnectionStatus)
|
||||
where
|
||||
getConnectionStatus :: IO ConnectionStatus
|
||||
getConnectionStatus = do
|
||||
pgVersion <- P.use pool getPgVersion
|
||||
case pgVersion of
|
||||
Left e -> do
|
||||
let err = PgError False e
|
||||
hPutStrLn stderr . toS $ errorPayload err
|
||||
case checkIsFatal err of
|
||||
Just reason -> return $ FatalConnectionError reason
|
||||
Nothing -> return NotConnected
|
||||
|
||||
Right version ->
|
||||
if version < minimumPgVersion
|
||||
then return . FatalConnectionError $ "Cannot run in this PostgreSQL version, PostgREST needs at least " <> pgvName minimumPgVersion
|
||||
else return . Connected $ version
|
||||
|
||||
shouldRetry :: RetryStatus -> ConnectionStatus -> IO Bool
|
||||
shouldRetry rs isConnSucc = do
|
||||
let delay = fromMaybe 0 (rsPreviousDelay rs) `div` 1000000
|
||||
itShould = NotConnected == isConnSucc
|
||||
when itShould $
|
||||
putStrLn $ "Attempting to reconnect to the database in " <> (show delay::Text) <> " seconds..."
|
||||
return itShould
|
||||
|
||||
|
||||
{-|
|
||||
This is where everything starts.
|
||||
-}
|
||||
main :: IO ()
|
||||
main = do
|
||||
--
|
||||
-- LineBuffering: the entire output buffer is flushed whenever a newline is
|
||||
-- output, the buffer overflows, a hFlush is issued or the handle is closed
|
||||
--
|
||||
-- NoBuffering: output is written immediately and never stored in the buffer
|
||||
hSetBuffering stdout LineBuffering
|
||||
hSetBuffering stdin LineBuffering
|
||||
hSetBuffering stderr NoBuffering
|
||||
--
|
||||
-- readOptions builds the 'AppConfig' from the config file specified on the
|
||||
-- command line
|
||||
conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
|
||||
let host = configHost conf
|
||||
port = configPort conf
|
||||
proxy = configProxyUri conf
|
||||
maybeSocketAddr = configSocket conf
|
||||
pgSettings = toS (configDatabase conf) -- is the db-uri
|
||||
roleClaimKey = configRoleClaimKey conf
|
||||
appSettings =
|
||||
setHost ((fromString . toS) host) -- Warp settings
|
||||
. setPort port
|
||||
. setServerName (toS $ "postgrest/" <> prettyVersion) $
|
||||
defaultSettings
|
||||
|
||||
-- Checks that the provided proxy uri is formated correctly
|
||||
when (isMalformedProxyUri $ toS <$> proxy) $
|
||||
panic
|
||||
"Malformed proxy uri, a correct example: https://example.com:8443/basePath"
|
||||
|
||||
-- Checks that the provided jspath is valid
|
||||
when (isLeft roleClaimKey) $
|
||||
panic $ show roleClaimKey
|
||||
|
||||
--
|
||||
-- create connection pool with the provided settings, returns either
|
||||
-- a 'Connection' or a 'ConnectionError'. Does not throw.
|
||||
pool <- P.acquire (configPool conf, configPoolTimeout' conf, pgSettings)
|
||||
--
|
||||
-- To be filled in by connectionWorker
|
||||
refDbStructure <- newIORef Nothing
|
||||
--
|
||||
-- Helper ref to make sure just one connectionWorker can run at a time
|
||||
refIsWorkerOn <- newIORef False
|
||||
--
|
||||
-- This is passed to the connectionWorker method so it can kill the main
|
||||
-- thread if the PostgreSQL's version is not supported.
|
||||
mainTid <- myThreadId
|
||||
--
|
||||
-- Sets the refDbStructure
|
||||
connectionWorker
|
||||
mainTid
|
||||
pool
|
||||
(configSchema conf)
|
||||
refDbStructure
|
||||
refIsWorkerOn
|
||||
--
|
||||
-- Only for systems with signals:
|
||||
--
|
||||
-- releases the connection pool whenever the program is terminated,
|
||||
-- see issue #268
|
||||
--
|
||||
-- Plus the SIGHUP signal updates the internal 'DbStructure' by running
|
||||
-- 'connectionWorker' exactly as before.
|
||||
#ifndef mingw32_HOST_OS
|
||||
forM_ [sigINT, sigTERM] $ \sig ->
|
||||
void $ installHandler sig (Catch $ do
|
||||
P.release pool
|
||||
throwTo mainTid UserInterrupt
|
||||
) Nothing
|
||||
|
||||
void $ installHandler sigUSR1 (
|
||||
Catch $ connectionWorker
|
||||
mainTid
|
||||
pool
|
||||
(configSchema conf)
|
||||
refDbStructure
|
||||
refIsWorkerOn
|
||||
) Nothing
|
||||
#endif
|
||||
|
||||
|
||||
-- ask for the OS time at most once per second
|
||||
getTime <- mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}
|
||||
|
||||
let postgrestApplication =
|
||||
postgrest
|
||||
conf
|
||||
refDbStructure
|
||||
pool
|
||||
getTime
|
||||
(connectionWorker
|
||||
mainTid
|
||||
pool
|
||||
(configSchema conf)
|
||||
refDbStructure
|
||||
refIsWorkerOn)
|
||||
in case maybeSocketAddr of
|
||||
Nothing -> do
|
||||
-- run the postgrest application
|
||||
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
|
||||
runSettings appSettings postgrestApplication
|
||||
Just socketAddr -> do
|
||||
-- run postgrest application with user defined socket
|
||||
sock <- createAndBindSocket (unpack socketAddr)
|
||||
listen sock maxListenQueue
|
||||
putStrLn $ ("Listening on unix socket " :: Text) <> show socketAddr
|
||||
runSettingsSocket appSettings sock postgrestApplication
|
||||
-- clean socket up when done
|
||||
close sock
|
||||
|
||||
{-|
|
||||
The purpose of this function is to load the JWT secret from a file if
|
||||
configJwtSecret is actually a filepath and replaces some characters if the JWT
|
||||
is base64 encoded.
|
||||
|
||||
The reason some characters need to be replaced is because JWT is actually
|
||||
base64url encoded which must be turned into just base64 before decoding.
|
||||
|
||||
To check if the JWT secret is provided is in fact a file path, it must be
|
||||
decoded as 'Text' to be processed.
|
||||
|
||||
decodeUtf8: Decode a ByteString containing UTF-8 encoded text that is known to
|
||||
be valid.
|
||||
-}
|
||||
loadSecretFile :: AppConfig -> IO AppConfig
|
||||
loadSecretFile conf = extractAndTransform mSecret
|
||||
where
|
||||
mSecret = decodeUtf8 <$> configJwtSecret conf
|
||||
isB64 = configJwtSecretIsBase64 conf
|
||||
--
|
||||
-- The Text (variable name secret) here is mSecret from above which is the JWT
|
||||
-- decoded as Utf8
|
||||
--
|
||||
-- stripPrefix: Return the suffix of the second string if its prefix matches
|
||||
-- the entire first string.
|
||||
--
|
||||
-- The configJwtSecret is a filepath instead of the JWT secret itself if the
|
||||
-- secret has @ as its prefix.
|
||||
extractAndTransform :: Maybe Text -> IO AppConfig
|
||||
extractAndTransform Nothing = return conf
|
||||
extractAndTransform (Just secret) =
|
||||
fmap setSecret $
|
||||
transformString isB64 =<<
|
||||
case stripPrefix "@" secret of
|
||||
Nothing -> return . encodeUtf8 $ secret
|
||||
Just filename -> chomp <$> BS.readFile (toS filename)
|
||||
where
|
||||
chomp bs = fromMaybe bs (BS.stripSuffix "\n" bs)
|
||||
--
|
||||
-- Turns the Base64url encoded JWT into Base64
|
||||
transformString :: Bool -> ByteString -> IO ByteString
|
||||
transformString False t = return t
|
||||
transformString True t =
|
||||
case B64.decode $ encodeUtf8 $ strip $ replaceUrlChars $ decodeUtf8 t of
|
||||
Left errMsg -> panic $ pack errMsg
|
||||
Right bs -> return bs
|
||||
setSecret bs = conf {configJwtSecret = Just bs}
|
||||
--
|
||||
-- replace: Replace every occurrence of one substring with another
|
||||
replaceUrlChars =
|
||||
replace "_" "/" . replace "-" "+" . replace "." "="
|
||||
|
||||
{-
|
||||
Load database uri from a separate file if `db-uri` is a filepath.
|
||||
-}
|
||||
loadDbUriFile :: AppConfig -> IO AppConfig
|
||||
loadDbUriFile conf = extractDbUri mDbUri
|
||||
where
|
||||
mDbUri = configDatabase conf
|
||||
extractDbUri :: Text -> IO AppConfig
|
||||
extractDbUri dbUri =
|
||||
fmap setDbUri $
|
||||
case stripPrefix "@" dbUri of
|
||||
Nothing -> return dbUri
|
||||
Just filename -> strip <$> readFile (toS filename)
|
||||
setDbUri dbUri = conf {configDatabase = dbUri}
|
||||
|
||||
createAndBindSocket :: FilePath -> IO Socket
|
||||
createAndBindSocket filePath = do
|
||||
deleteSocketFileIfExist filePath
|
||||
sock <- socket AF_UNIX Stream defaultProtocol
|
||||
bind sock $ SockAddrUnix filePath
|
||||
return sock
|
||||
where
|
||||
deleteSocketFileIfExist path = removeFile path `catch` handleDoesNotExist
|
||||
handleDoesNotExist e
|
||||
| isDoesNotExistError e = return ()
|
||||
| otherwise = throwIO e
|
||||
@@ -1,24 +0,0 @@
|
||||
site_name: PostgREST
|
||||
site_url: http://postgrest.com
|
||||
site_description: Building declarative APIs
|
||||
site_author: Joe Nelson
|
||||
site_favicon: favicon.ico
|
||||
|
||||
repo_url: https://github.com/begriffs/postgrest
|
||||
|
||||
pages:
|
||||
- Home: index.md
|
||||
- Install:
|
||||
- The Server: install/server.md
|
||||
- Ecosystem: install/ecosystem.md
|
||||
- API:
|
||||
- Reading: api/reading.md
|
||||
- Writing: api/writing.md
|
||||
- Admin:
|
||||
- Security: admin/security.md
|
||||
- Versioning: admin/versioning.md
|
||||
- Migration: admin/migration.md
|
||||
- Deployment: admin/deployment.md
|
||||
- Performance: admin/performance.md
|
||||
- Examples:
|
||||
- Getting Started: examples/start.md
|
||||
@@ -1,167 +1,182 @@
|
||||
name: postgrest
|
||||
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.2.12.1
|
||||
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
|
||||
name: postgrest
|
||||
version: 6.0.0
|
||||
synopsis: REST API for any Postgres database
|
||||
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
||||
for the tables and views, supporting all HTTP verbs that security
|
||||
permits.
|
||||
license: MIT
|
||||
license-file: LICENSE
|
||||
author: Joe Nelson, Adam Baker
|
||||
maintainer: Steve Chávez <stevechavezast@gmail.com>
|
||||
category: Executable, PostgreSQL, Network APIs
|
||||
homepage: https://postgrest.org
|
||||
bug-reports: https://github.com/PostgREST/postgrest/issues
|
||||
build-type: Simple
|
||||
extra-source-files: CHANGELOG.md
|
||||
cabal-version: >= 1.10
|
||||
|
||||
source-repository head
|
||||
type: git
|
||||
location: git://github.com/begriffs/postgrest.git
|
||||
type: git
|
||||
location: git://github.com/PostgREST/postgrest.git
|
||||
|
||||
Flag CI
|
||||
Description: No warnings allowed in continuous integration
|
||||
Manual: True
|
||||
Default: False
|
||||
|
||||
executable postgrest
|
||||
main-is: PostgREST/Main.hs
|
||||
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
|
||||
default-language: Haskell2010
|
||||
build-depends: base >=4.6 && <5
|
||||
, postgrest
|
||||
, hasql >= 0.7.3 && < 0.8
|
||||
, hasql-backend >= 0.4.1 && < 0.5
|
||||
, hasql-postgres >= 0.10.4 && < 0.11
|
||||
, warp >= 3.0.2, wai >= 3.0.1
|
||||
, wai-extra, wai-cors
|
||||
, wai-middleware-static >= 0.6.0
|
||||
, HTTP, convertible, http-types
|
||||
, case-insensitive
|
||||
, scientific, time
|
||||
, aeson >= 0.8, network >= 2.6
|
||||
, bytestring, text, split, string-conversions
|
||||
, stringsearch
|
||||
, containers, unordered-containers
|
||||
, optparse-applicative >= 0.11 && < 0.13
|
||||
, regex-base, regex-tdfa
|
||||
, Ranged-sets
|
||||
, transformers, MissingH
|
||||
, bcrypt >= 0.0.6, base64-string
|
||||
, network-uri >= 2.6
|
||||
, resource-pool
|
||||
, blaze-builder
|
||||
, vector
|
||||
, mtl
|
||||
, cassava
|
||||
, jwt
|
||||
, parsec
|
||||
, errors
|
||||
, bifunctors
|
||||
hs-source-dirs: src
|
||||
flag ci
|
||||
default: False
|
||||
manual: True
|
||||
description: No warnings allowed in continuous integration
|
||||
|
||||
library
|
||||
if flag(ci)
|
||||
ghc-options: -Wall -W -Werror
|
||||
else
|
||||
ghc-options: -Wall -W -O2
|
||||
exposed-modules: PostgREST.ApiRequest
|
||||
PostgREST.App
|
||||
PostgREST.Auth
|
||||
PostgREST.Config
|
||||
PostgREST.DbRequestBuilder
|
||||
PostgREST.DbStructure
|
||||
PostgREST.Error
|
||||
PostgREST.Middleware
|
||||
PostgREST.OpenAPI
|
||||
PostgREST.Parsers
|
||||
PostgREST.QueryBuilder
|
||||
PostgREST.RangeQuery
|
||||
PostgREST.Types
|
||||
other-modules: Paths_postgrest
|
||||
hs-source-dirs: src
|
||||
build-depends: base >= 4.9 && < 4.13
|
||||
, HTTP >= 4000.3.7 && < 4000.4
|
||||
, Ranged-sets >= 0.3 && < 0.5
|
||||
, aeson >= 0.11.3 && < 1.5
|
||||
, ansi-wl-pprint >= 0.6.7 && < 0.7
|
||||
, base64-bytestring >= 1 && < 1.1
|
||||
, bytestring >= 0.10.8 && < 0.11
|
||||
, case-insensitive >= 1.2 && < 1.3
|
||||
, cassava >= 0.4.5 && < 0.6
|
||||
, configurator-pg >= 0.1 && < 0.2
|
||||
, containers >= 0.5.7 && < 0.7
|
||||
, contravariant >= 1.4 && < 1.6
|
||||
, contravariant-extras >= 0.3.3 && < 0.4
|
||||
, cookie >= 0.4.2 && < 0.5
|
||||
, either >= 4.4.1 && < 5.1
|
||||
, gitrev >= 1.2 && < 1.4
|
||||
, hasql >= 1.4 && < 1.5
|
||||
, hasql-pool >= 0.5 && < 0.6
|
||||
, hasql-transaction >= 0.7.2 && < 0.8
|
||||
, heredoc >= 0.2 && < 0.3
|
||||
, http-types >= 0.12.2 && < 0.13
|
||||
, insert-ordered-containers >= 0.1 && < 0.3
|
||||
, interpolatedstring-perl6 >= 1 && < 1.1
|
||||
, jose >= 0.7 && < 0.8
|
||||
, lens >= 4.14 && < 4.18
|
||||
, lens-aeson >= 1.0.1 && < 1.1
|
||||
, network-uri >= 2.6.1 && < 2.7
|
||||
, optparse-applicative >= 0.13 && < 0.15
|
||||
, parsec >= 3.1.11 && < 3.2
|
||||
, protolude >= 0.2.2 && < 0.3
|
||||
, regex-tdfa >= 1.2.2 && < 1.3
|
||||
, scientific >= 0.3.4 && < 0.4
|
||||
, swagger2 >= 2.1.4 && < 2.4
|
||||
, text >= 1.2.2 && < 1.3
|
||||
, time >= 1.6 && < 1.9
|
||||
, unordered-containers >= 0.2.8 && < 0.3
|
||||
, vector >= 0.11 && < 0.13
|
||||
, wai >= 3.2.1 && < 3.3
|
||||
, wai-cors >= 0.2.5 && < 0.3
|
||||
, wai-extra >= 3.0.19 && < 3.1
|
||||
, wai-middleware-static >= 0.8.1 && < 0.9
|
||||
default-language: Haskell2010
|
||||
default-extensions: OverloadedStrings
|
||||
QuasiQuotes
|
||||
NoImplicitPrelude
|
||||
|
||||
default-language: Haskell2010
|
||||
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
|
||||
build-depends: base >=4.6 && <5
|
||||
, hasql, hasql-backend
|
||||
, hasql-postgres
|
||||
, warp, wai
|
||||
, wai-extra, wai-cors
|
||||
, wai-middleware-static
|
||||
, HTTP, convertible, http-types
|
||||
, case-insensitive
|
||||
, scientific, time
|
||||
, aeson, network
|
||||
, bytestring, text, split, string-conversions
|
||||
, stringsearch
|
||||
, containers, unordered-containers
|
||||
, optparse-applicative
|
||||
, regex-base, regex-tdfa
|
||||
, Ranged-sets
|
||||
, transformers, MissingH
|
||||
, bcrypt, base64-string
|
||||
, network-uri
|
||||
, resource-pool
|
||||
, blaze-builder
|
||||
, vector
|
||||
, mtl
|
||||
, cassava
|
||||
, jwt
|
||||
, parsec
|
||||
, errors
|
||||
, bifunctors
|
||||
executable postgrest
|
||||
main-is: Main.hs
|
||||
hs-source-dirs: main
|
||||
build-depends: base >= 4.9 && < 4.13
|
||||
, auto-update >= 0.1.4 && < 0.2
|
||||
, base64-bytestring >= 1 && < 1.1
|
||||
, bytestring >= 0.10.8 && < 0.11
|
||||
, directory >= 1.2.6 && < 1.4
|
||||
, hasql >= 1.4 && < 1.5
|
||||
, hasql-pool >= 0.5 && < 0.6
|
||||
, hasql-transaction >= 0.7.2 && < 0.8
|
||||
, network < 2.9
|
||||
, postgrest
|
||||
, protolude >= 0.2.2 && < 0.3
|
||||
, retry >= 0.7.4 && < 0.9
|
||||
, text >= 1.2.2 && < 1.3
|
||||
, time >= 1.6 && < 1.9
|
||||
, warp >= 3.2.12 && < 3.3
|
||||
default-language: Haskell2010
|
||||
default-extensions: OverloadedStrings
|
||||
QuasiQuotes
|
||||
NoImplicitPrelude
|
||||
ghc-options: -threaded -rtsopts "-with-rtsopts=-N -I2"
|
||||
|
||||
Other-Modules: Paths_postgrest
|
||||
Exposed-Modules: PostgREST.App
|
||||
, PostgREST.Types
|
||||
, PostgREST.Parsers
|
||||
, PostgREST.QueryBuilder
|
||||
, PostgREST.Auth
|
||||
, PostgREST.Config
|
||||
, PostgREST.Error
|
||||
, PostgREST.Middleware
|
||||
, PostgREST.PgQuery
|
||||
, PostgREST.PgStructure
|
||||
, PostgREST.RangeQuery
|
||||
hs-source-dirs: src
|
||||
if !os(windows)
|
||||
build-depends: unix
|
||||
|
||||
Test-Suite spec
|
||||
Type: exitcode-stdio-1.0
|
||||
Default-Language: Haskell2010
|
||||
default-extensions: OverloadedStrings, ScopedTypeVariables, QuasiQuotes
|
||||
Hs-Source-Dirs: test, src
|
||||
if flag(ci)
|
||||
ghc-options: -Wall -W -Werror
|
||||
else
|
||||
ghc-options: -Wall -W -O2
|
||||
Main-Is: Main.hs
|
||||
Other-Modules: PostgREST.App
|
||||
, PostgREST.Types
|
||||
, PostgREST.Parsers
|
||||
, PostgREST.QueryBuilder
|
||||
, PostgREST.Auth
|
||||
, PostgREST.Config
|
||||
, PostgREST.Error
|
||||
, PostgREST.Middleware
|
||||
, PostgREST.PgQuery
|
||||
, PostgREST.PgStructure
|
||||
, PostgREST.RangeQuery
|
||||
, Spec
|
||||
, SpecHelper
|
||||
, Paths_postgrest
|
||||
Build-Depends: base, hspec == 2.1.*, QuickCheck
|
||||
, hspec-wai, hspec-wai-json
|
||||
, hasql, hasql-backend
|
||||
, hasql-postgres
|
||||
, warp, wai
|
||||
, packdeps, hlint
|
||||
, HTTP, convertible
|
||||
, case-insensitive
|
||||
, wai-extra, wai-cors, containers
|
||||
, wai-middleware-static
|
||||
, http-types, scientific, time
|
||||
, bytestring, aeson, network
|
||||
, text, optparse-applicative
|
||||
, stringsearch
|
||||
, unordered-containers
|
||||
, regex-base
|
||||
, string-conversions
|
||||
, http-media, regex-tdfa
|
||||
, Ranged-sets
|
||||
, transformers, MissingH, split
|
||||
, bcrypt, base64-string
|
||||
, network-uri
|
||||
, resource-pool
|
||||
, blaze-builder
|
||||
, vector
|
||||
, mtl
|
||||
, cassava
|
||||
, process
|
||||
, heredoc
|
||||
, jwt
|
||||
, parsec
|
||||
, errors
|
||||
, bifunctors
|
||||
test-suite spec
|
||||
type: exitcode-stdio-1.0
|
||||
main-is: Main.hs
|
||||
other-modules: Feature.AndOrParamsSpec
|
||||
Feature.AsymmetricJwtSpec
|
||||
Feature.AudienceJwtSecretSpec
|
||||
Feature.AuthSpec
|
||||
Feature.BinaryJwtSecretSpec
|
||||
Feature.ConcurrentSpec
|
||||
Feature.CorsSpec
|
||||
Feature.DeleteSpec
|
||||
Feature.ExtraSearchPathSpec
|
||||
Feature.InsertSpec
|
||||
Feature.JsonOperatorSpec
|
||||
Feature.NoJwtSpec
|
||||
Feature.NonexistentSchemaSpec
|
||||
Feature.PgVersion95Spec
|
||||
Feature.PgVersion96Spec
|
||||
Feature.ProxySpec
|
||||
Feature.QueryLimitedSpec
|
||||
Feature.QuerySpec
|
||||
Feature.RangeSpec
|
||||
Feature.RootSpec
|
||||
Feature.RpcSpec
|
||||
Feature.SingularSpec
|
||||
Feature.StructureSpec
|
||||
Feature.UnicodeSpec
|
||||
Feature.UpsertSpec
|
||||
SpecHelper
|
||||
TestTypes
|
||||
hs-source-dirs: test
|
||||
build-depends: base >= 4.9 && < 4.13
|
||||
, aeson >= 0.11.3 && < 1.5
|
||||
, aeson-qq >= 0.8.1 && < 0.9
|
||||
, async >= 2.1.1 && < 2.3
|
||||
, auto-update >= 0.1.4 && < 0.2
|
||||
, base64-bytestring >= 1 && < 1.1
|
||||
, bytestring >= 0.10.8 && < 0.11
|
||||
, case-insensitive >= 1.2 && < 1.3
|
||||
, cassava >= 0.4.5 && < 0.6
|
||||
, containers >= 0.5.7 && < 0.7
|
||||
, contravariant >= 1.4 && < 1.6
|
||||
, hasql >= 1.4 && < 1.5
|
||||
, hasql-pool >= 0.5 && < 0.6
|
||||
, hasql-transaction >= 0.7.2 && < 0.8
|
||||
, heredoc >= 0.2 && < 0.3
|
||||
, hspec >= 2.3 && < 2.8
|
||||
, hspec-wai >= 0.7 && < 0.10
|
||||
, hspec-wai-json >= 0.7 && < 0.10
|
||||
, http-types >= 0.12.3 && < 0.13
|
||||
, lens >= 4.14 && < 4.18
|
||||
, lens-aeson >= 1.0.1 && < 1.1
|
||||
, monad-control >= 1.0.1 && < 1.1
|
||||
, postgrest
|
||||
, process >= 1.4.2 && < 1.7
|
||||
, protolude >= 0.2.2 && < 0.3
|
||||
, regex-tdfa >= 1.2.2 && < 1.3
|
||||
, text >= 1.2.2 && < 1.3
|
||||
, time >= 1.6 && < 1.9
|
||||
, transformers-base >= 0.4.4 && < 0.5
|
||||
, wai >= 3.2.1 && < 3.3
|
||||
, wai-extra >= 3.0.19 && < 3.1
|
||||
default-language: Haskell2010
|
||||
default-extensions: OverloadedStrings
|
||||
QuasiQuotes
|
||||
NoImplicitPrelude
|
||||
ghc-options: -threaded -rtsopts -with-rtsopts=-N
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export POSTGREST_VER=`grep ^version /app/postgrest.cabal | sed -En 's/.*\s+([0-9\.]+)/\1/p'`
|
||||
|
||||
curl -L http://sourceforge.net/projects/s3tools/files/s3cmd/1.5.0-alpha1/s3cmd-1.5.0-alpha1.tar.gz | tar zx
|
||||
|
||||
cp /app/dist/build/postgrest/postgrest postgrest-${POSTGREST_VER}
|
||||
tar cJf postgrest-${POSTGREST_VER}.tar.xz postgrest-${POSTGREST_VER}
|
||||
|
||||
touch ~/.s3cfg
|
||||
|
||||
s3cmd-1.5.0-alpha1/s3cmd put --access_key=${S3_ACCESS_KEY} --secret_key=${S3_SECRET_KEY} -P -f postgrest-${POSTGREST_VER}.tar.xz $S3_BUCKET/postgrest-${POSTGREST_VER}.tar.xz
|
||||
@@ -0,0 +1,313 @@
|
||||
{-|
|
||||
Module : PostgREST.ApiRequest
|
||||
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
|
||||
-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
|
||||
module PostgREST.ApiRequest (
|
||||
ApiRequest(..)
|
||||
, ContentType(..)
|
||||
, Action(..)
|
||||
, Target(..)
|
||||
, PreferRepresentation (..)
|
||||
, mutuallyAgreeable
|
||||
, userApiRequest
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
import qualified Data.Csv as CSV
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Data.List as L
|
||||
import qualified Data.Set as S
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Vector as V
|
||||
|
||||
import Control.Arrow ((***))
|
||||
import Data.Aeson.Types (emptyArray, emptyObject)
|
||||
import Data.List (last, lookup, partition)
|
||||
import Data.Maybe (fromJust)
|
||||
import Data.Ranged.Ranges (Range (..), emptyRange,
|
||||
rangeIntersection)
|
||||
import Network.HTTP.Base (urlEncodeVars)
|
||||
import Network.HTTP.Types.Header (hAuthorization, hCookie)
|
||||
import Network.HTTP.Types.URI (parseQueryReplacePlus,
|
||||
parseSimpleQuery)
|
||||
import Network.Wai (Request (..))
|
||||
import Network.Wai.Parse (parseHttpAccept)
|
||||
import Web.Cookie (parseCookiesText)
|
||||
|
||||
|
||||
import Data.Ranged.Boundaries
|
||||
|
||||
import PostgREST.Error (ApiRequestError (..))
|
||||
import PostgREST.RangeQuery (NonnegRange, allRange, rangeGeq,
|
||||
rangeLimit, rangeOffset, rangeRequested,
|
||||
restrictRange)
|
||||
import PostgREST.Types
|
||||
import Protolude
|
||||
|
||||
type RequestBody = BL.ByteString
|
||||
|
||||
-- | Types of things a user wants to do to tables/views/procs
|
||||
data Action = ActionCreate | ActionRead
|
||||
| ActionUpdate | ActionDelete
|
||||
| ActionInfo | ActionInvoke{isReadOnly :: Bool}
|
||||
| ActionInspect | ActionSingleUpsert
|
||||
deriving Eq
|
||||
-- | The target db object of a user action
|
||||
data Target = TargetIdent QualifiedIdentifier
|
||||
| TargetProc{tpQi :: QualifiedIdentifier, tpIsRootSpec :: Bool}
|
||||
| TargetDefaultSpec -- The default spec offered at root "/"
|
||||
| TargetUnknown [Text]
|
||||
deriving Eq
|
||||
-- | How to return the inserted data
|
||||
data PreferRepresentation = Full | HeadersOnly | None deriving Eq
|
||||
|
||||
{-|
|
||||
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 {
|
||||
-- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
|
||||
iAction :: Action
|
||||
-- | Requested range of rows within response
|
||||
, iRange :: M.HashMap ByteString NonnegRange
|
||||
-- | The target, be it calling a proc or accessing a table
|
||||
, iTarget :: Target
|
||||
-- | Content types the client will accept, [CTAny] if no Accept header
|
||||
, iAccepts :: [ContentType]
|
||||
-- | Data sent by client and used for mutation actions
|
||||
, iPayload :: Maybe PayloadJSON
|
||||
-- | If client wants created items echoed back
|
||||
, iPreferRepresentation :: PreferRepresentation
|
||||
-- | Pass all parameters as a single json object to a stored procedure
|
||||
, iPreferSingleObjectParameter :: Bool
|
||||
-- | Whether the client wants a result count (slower)
|
||||
, iPreferCount :: Bool
|
||||
-- | Whether the client wants to UPSERT or ignore records on PK conflict
|
||||
, iPreferResolution :: Maybe PreferResolution
|
||||
-- | Filters on the result ("id", "eq.10")
|
||||
, iFilters :: [(Text, Text)]
|
||||
-- | &and and &or parameters used for complex boolean logic
|
||||
, iLogic :: [(Text, Text)]
|
||||
-- | &select parameter used to shape the response
|
||||
, iSelect :: Text
|
||||
-- | &columns parameter used to shape the payload
|
||||
, iColumns :: Maybe Text
|
||||
-- | &order parameters for each level
|
||||
, iOrder :: [(Text, Text)]
|
||||
-- | Alphabetized (canonical) request query string for response URLs
|
||||
, iCanonicalQS :: ByteString
|
||||
-- | JSON Web Token
|
||||
, iJWT :: Text
|
||||
-- | HTTP request headers
|
||||
, iHeaders :: [(Text, Text)]
|
||||
-- | Request Cookies
|
||||
, iCookies :: [(Text, Text)]
|
||||
}
|
||||
|
||||
-- | Examines HTTP request and translates it into user intent.
|
||||
userApiRequest :: Schema -> Maybe QualifiedIdentifier -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||
userApiRequest schema rootSpec req reqBody
|
||||
| isTargetingProc && method `notElem` ["GET", "POST"] = Left ActionInappropriate
|
||||
| topLevelRange == emptyRange = Left InvalidRange
|
||||
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
|
||||
| otherwise = Right ApiRequest {
|
||||
iAction = action
|
||||
, iTarget = target
|
||||
, iRange = ranges
|
||||
, iAccepts = maybe [CTAny] (map decodeContentType . parseHttpAccept) $ lookupHeader "accept"
|
||||
, iPayload = relevantPayload
|
||||
, iPreferRepresentation = representation
|
||||
, iPreferSingleObjectParameter = singleObject
|
||||
, iPreferCount = hasPrefer "count=exact"
|
||||
, iPreferResolution = if hasPrefer (show MergeDuplicates) then Just MergeDuplicates
|
||||
else if hasPrefer (show IgnoreDuplicates) then Just IgnoreDuplicates
|
||||
else Nothing
|
||||
, iFilters = filters
|
||||
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
|
||||
, iSelect = toS $ fromMaybe "*" $ join $ lookup "select" qParams
|
||||
, iColumns = columns
|
||||
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
|
||||
, iCanonicalQS = toS $ urlEncodeVars
|
||||
. L.sortOn fst
|
||||
. map (join (***) toS . second (fromMaybe BS.empty))
|
||||
$ queryStringWPlus
|
||||
, iJWT = tokenStr
|
||||
, iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hAuthorization, k /= hCookie]
|
||||
, iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
|
||||
}
|
||||
where
|
||||
-- queryString with '+' not converted to ' '
|
||||
queryStringWPlus = parseQueryReplacePlus False $ rawQueryString req
|
||||
-- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
|
||||
(filters, rpcQParams) =
|
||||
case action of
|
||||
ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
|
||||
_ -> (flts, [])
|
||||
flts =
|
||||
[ (toS k, toS $ fromJust v) |
|
||||
(k,v) <- qParams, isJust v,
|
||||
k `notElem` ["select", "columns"],
|
||||
not (endingIn ["order", "limit", "offset", "and", "or"] k) ]
|
||||
hasOperator val = any (`T.isPrefixOf` val) $
|
||||
((<> ".") <$> "not":M.keys operators) ++
|
||||
((<> "(") <$> M.keys ftsOperators)
|
||||
isEmbedPath = T.isInfixOf "."
|
||||
isTargetingProc = case target of
|
||||
TargetProc _ _ -> True
|
||||
_ -> False
|
||||
contentType = decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type"
|
||||
columns | action `elem` [ActionCreate, ActionUpdate, ActionInvoke{isReadOnly=False}] = toS <$> join (lookup "columns" qParams)
|
||||
| otherwise = Nothing
|
||||
payload =
|
||||
case (contentType, action) of
|
||||
(_, ActionInvoke{isReadOnly=True}) ->
|
||||
Right $ ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) PJObject (S.fromList $ fst <$> rpcQParams)
|
||||
(CTApplicationJSON, _) ->
|
||||
if isJust columns
|
||||
then Right $ RawJSON reqBody
|
||||
else note "All object keys must match" . payloadAttributes reqBody
|
||||
=<< if BL.null reqBody && isTargetingProc
|
||||
then Right emptyObject
|
||||
else JSON.eitherDecode reqBody
|
||||
(CTTextCSV, _) -> do
|
||||
json <- csvToJson <$> CSV.decodeByName reqBody
|
||||
note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json
|
||||
(CTOther "application/x-www-form-urlencoded", _) ->
|
||||
let json = M.fromList . map (toS *** JSON.String . toS) . parseSimpleQuery $ toS reqBody
|
||||
keys = S.fromList $ M.keys json in
|
||||
Right $ ProcessedJSON (JSON.encode json) PJObject keys
|
||||
(ct, _) ->
|
||||
Left $ toS $ "Content-Type not acceptable: " <> toMime ct
|
||||
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges
|
||||
action =
|
||||
case method of
|
||||
"GET" | target == TargetDefaultSpec -> ActionInspect
|
||||
| isTargetingProc -> ActionInvoke{isReadOnly=True}
|
||||
| otherwise -> ActionRead
|
||||
|
||||
"POST" -> if isTargetingProc
|
||||
then ActionInvoke{isReadOnly=False}
|
||||
else ActionCreate
|
||||
"PATCH" -> ActionUpdate
|
||||
"PUT" -> ActionSingleUpsert
|
||||
"DELETE" -> ActionDelete
|
||||
"OPTIONS" -> ActionInfo
|
||||
_ -> ActionInspect
|
||||
target = case path of
|
||||
[] -> case rootSpec of
|
||||
Just rsQi -> TargetProc rsQi True
|
||||
Nothing -> TargetDefaultSpec
|
||||
[table] -> TargetIdent $ QualifiedIdentifier schema table
|
||||
["rpc", proc] -> TargetProc (QualifiedIdentifier schema proc) False
|
||||
other -> TargetUnknown other
|
||||
|
||||
shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke{isReadOnly=False}, ActionInvoke{isReadOnly=True}]
|
||||
relevantPayload | shouldParsePayload = rightToMaybe payload
|
||||
| otherwise = Nothing
|
||||
path = pathInfo req
|
||||
method = requestMethod req
|
||||
hdrs = requestHeaders req
|
||||
qParams = [(toS k, v)|(k,v) <- queryStringWPlus]
|
||||
lookupHeader = flip lookup hdrs
|
||||
hasPrefer :: Text -> Bool
|
||||
hasPrefer val = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs
|
||||
where
|
||||
split :: BS.ByteString -> [Text]
|
||||
split = map T.strip . T.split (==',') . toS
|
||||
singleObject = hasPrefer "params=single-object"
|
||||
representation
|
||||
| hasPrefer "return=representation" = Full
|
||||
| hasPrefer "return=minimal" = None
|
||||
| otherwise = HeadersOnly
|
||||
auth = fromMaybe "" $ lookupHeader hAuthorization
|
||||
tokenStr = case T.split (== ' ') (toS auth) of
|
||||
("Bearer" : t : _) -> t
|
||||
_ -> ""
|
||||
endingIn:: [Text] -> Text -> Bool
|
||||
endingIn xx key = lastWord `elem` xx
|
||||
where lastWord = last $ T.split (=='.') key
|
||||
|
||||
headerRange = rangeRequested hdrs
|
||||
replaceLast x s = T.intercalate "." $ L.init (T.split (=='.') s) ++ [x]
|
||||
limitParams :: M.HashMap ByteString NonnegRange
|
||||
limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< (toS <$> v)) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]
|
||||
offsetParams :: M.HashMap ByteString NonnegRange
|
||||
offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe =<< (toS <$> v))) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]
|
||||
|
||||
urlRange = M.unionWith f limitParams offsetParams
|
||||
where
|
||||
f rl ro = Range (BoundaryBelow o) (BoundaryAbove $ o + l - 1)
|
||||
where
|
||||
l = fromMaybe 0 $ rangeLimit rl
|
||||
o = rangeOffset ro
|
||||
ranges = M.insert "limit" (rangeIntersection headerRange (fromMaybe allRange (M.lookup "limit" urlRange))) urlRange
|
||||
|
||||
{-|
|
||||
Find the best match from a list of content types accepted by the
|
||||
client in order of decreasing preference and a list of types
|
||||
producible by the server. If there is no match but the client
|
||||
accepts */* then return the top server pick.
|
||||
-}
|
||||
mutuallyAgreeable :: [ContentType] -> [ContentType] -> Maybe ContentType
|
||||
mutuallyAgreeable sProduces cAccepts =
|
||||
let exact = listToMaybe $ L.intersect cAccepts sProduces in
|
||||
if isNothing exact && CTAny `elem` cAccepts
|
||||
then listToMaybe sProduces
|
||||
else exact
|
||||
|
||||
type CsvData = V.Vector (M.HashMap 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.Value
|
||||
csvToJson (_, vals) =
|
||||
JSON.Array $ V.map rowToJsonObj vals
|
||||
where
|
||||
rowToJsonObj = JSON.Object .
|
||||
M.map (\str ->
|
||||
if str == "NULL"
|
||||
then JSON.Null
|
||||
else JSON.String $ toS str
|
||||
)
|
||||
|
||||
payloadAttributes :: RequestBody -> JSON.Value -> Maybe PayloadJSON
|
||||
payloadAttributes raw json =
|
||||
-- Test that Array contains only Objects having the same keys
|
||||
case json of
|
||||
JSON.Array arr ->
|
||||
case arr V.!? 0 of
|
||||
Just (JSON.Object o) ->
|
||||
let canonicalKeys = S.fromList $ M.keys o
|
||||
areKeysUniform = all (\case
|
||||
JSON.Object x -> S.fromList (M.keys x) == canonicalKeys
|
||||
_ -> False) arr in
|
||||
if areKeysUniform
|
||||
then Just $ ProcessedJSON raw (PJArray $ V.length arr) canonicalKeys
|
||||
else Nothing
|
||||
Just _ -> Nothing
|
||||
Nothing -> Just emptyPJArray
|
||||
|
||||
JSON.Object o -> Just $ ProcessedJSON raw PJObject (S.fromList $ M.keys o)
|
||||
|
||||
-- truncate everything else to an empty array.
|
||||
_ -> Just emptyPJArray
|
||||
where
|
||||
emptyPJArray = ProcessedJSON (JSON.encode emptyArray) (PJArray 0) S.empty
|
||||
@@ -1,416 +1,392 @@
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
module PostgREST.App (
|
||||
app
|
||||
, sqlError
|
||||
, isSqlError
|
||||
, contentTypeForAccept
|
||||
, jsonH
|
||||
, requestedSchema
|
||||
, TableOptions(..)
|
||||
postgrest
|
||||
) where
|
||||
|
||||
import qualified Blaze.ByteString.Builder as BB
|
||||
import Control.Applicative
|
||||
import Control.Arrow (second, (***))
|
||||
import Control.Monad (join)
|
||||
import Data.Bifunctor (first)
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.CaseInsensitive (original)
|
||||
import qualified Data.Csv as CSV
|
||||
import Data.Functor.Identity
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import Data.List (find, sortBy)
|
||||
import Data.Maybe (fromMaybe, isJust, isNothing,
|
||||
mapMaybe)
|
||||
import Data.Ord (comparing)
|
||||
import Data.Ranged.Ranges (emptyRange)
|
||||
import qualified Data.Set as S
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Text (Text, replace, strip)
|
||||
import Text.Regex.TDFA ((=~))
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Data.Set as S
|
||||
import qualified Hasql.Pool as P
|
||||
import qualified Hasql.Transaction as H
|
||||
import qualified Hasql.Transaction as HT
|
||||
import qualified Hasql.Transaction.Sessions as HT
|
||||
|
||||
import Text.Parsec.Error
|
||||
import Data.Aeson as JSON
|
||||
import Data.Function (id)
|
||||
import Data.IORef (IORef, readIORef)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import Network.HTTP.Types.URI (renderSimpleQuery)
|
||||
import Network.Wai.Middleware.RequestLogger (logStdout)
|
||||
|
||||
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 Network.Wai.Internal (Response (..))
|
||||
import Network.Wai.Parse (parseHttpAccept)
|
||||
import Control.Applicative
|
||||
import Data.Maybe
|
||||
import Network.HTTP.Types.Header
|
||||
import Network.HTTP.Types.Status
|
||||
import Network.Wai
|
||||
|
||||
import Data.Aeson
|
||||
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.ApiRequest (Action (..), ApiRequest (..),
|
||||
ContentType (..),
|
||||
PreferRepresentation (..),
|
||||
Target (..), mutuallyAgreeable,
|
||||
userApiRequest)
|
||||
import PostgREST.Auth (containsRole, jwtClaims,
|
||||
parseSecret)
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.DbRequestBuilder (fieldNames, mutateRequest,
|
||||
readRequest)
|
||||
import PostgREST.DbStructure
|
||||
import PostgREST.Error (PgError (..), SimpleError (..),
|
||||
errorResponseFor, singularityError)
|
||||
import PostgREST.Middleware
|
||||
import PostgREST.OpenAPI
|
||||
import PostgREST.Parsers (pRequestColumns)
|
||||
import PostgREST.QueryBuilder (ResultsWithCount, callProc,
|
||||
createReadStatement,
|
||||
createWriteStatement,
|
||||
requestToCountQuery,
|
||||
requestToQuery)
|
||||
import PostgREST.RangeQuery (allRange, rangeOffset)
|
||||
import PostgREST.Types
|
||||
import Protolude hiding (Proxy, intercalate)
|
||||
|
||||
import PostgREST.Auth
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.Parsers
|
||||
import PostgREST.PgQuery
|
||||
import PostgREST.PgStructure
|
||||
import PostgREST.QueryBuilder
|
||||
import PostgREST.RangeQuery
|
||||
import PostgREST.Types
|
||||
postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> IO () -> Application
|
||||
postgrest conf refDbStructure pool getTime worker =
|
||||
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle
|
||||
jwtSecret = parseSecret <$> configJwtSecret conf in
|
||||
|
||||
import Prelude
|
||||
middle $ \ req respond -> do
|
||||
time <- getTime
|
||||
body <- strictRequestBody req
|
||||
maybeDbStructure <- readIORef refDbStructure
|
||||
case maybeDbStructure of
|
||||
Nothing -> respond . errorResponseFor $ ConnectionLostError
|
||||
Just dbStructure -> do
|
||||
response <- do
|
||||
-- Need to parse ?columns early because findProc needs it to solve overloaded functions
|
||||
let apiReq = userApiRequest (configSchema conf) (configRootSpec conf) req body
|
||||
apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
|
||||
case apiReqCols of
|
||||
Left err -> return . errorResponseFor $ err
|
||||
Right (apiRequest, maybeCols) -> do
|
||||
eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) time (rightToMaybe $ configRoleClaimKey conf)
|
||||
let authed = containsRole eClaims
|
||||
cols = case (iPayload apiRequest, maybeCols) of
|
||||
(Just ProcessedJSON{pjKeys}, _) -> pjKeys
|
||||
(Just RawJSON{}, Just cls) -> cls
|
||||
_ -> S.empty
|
||||
proc = case iTarget apiRequest of
|
||||
TargetProc qi _ -> findProc qi cols (iPreferSingleObjectParameter apiRequest) $ dbProcs dbStructure
|
||||
_ -> Nothing
|
||||
handleReq = runWithClaims conf eClaims (app dbStructure proc cols conf) apiRequest
|
||||
txMode = transactionMode proc (iAction apiRequest)
|
||||
response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq
|
||||
return $ either (errorResponseFor . PgError authed) identity response
|
||||
when (responseStatus response == status503) worker
|
||||
respond response
|
||||
|
||||
app :: DbStructure -> AppConfig -> BL.ByteString -> DbRole -> Request -> H.Tx P.Postgres s Response
|
||||
app dbstructure conf reqBody dbrole req =
|
||||
case (path, verb) of
|
||||
transactionMode :: Maybe ProcDescription -> Action -> HT.Mode
|
||||
transactionMode proc action =
|
||||
case action of
|
||||
ActionRead -> HT.Read
|
||||
ActionInfo -> HT.Read
|
||||
ActionInspect -> HT.Read
|
||||
ActionInvoke{isReadOnly=False} ->
|
||||
let v = maybe Volatile pdVolatility proc in
|
||||
if v == Stable || v == Immutable
|
||||
then HT.Read
|
||||
else HT.Write
|
||||
ActionInvoke{isReadOnly=True} -> HT.Read
|
||||
_ -> HT.Write
|
||||
|
||||
([], _) -> do
|
||||
let body = encode $ filter (filterTableAcl dbrole) $ filter ((cs schema==).tableSchema) allTabs
|
||||
return $ responseLBS status200 [jsonH] $ cs body
|
||||
app :: DbStructure -> Maybe ProcDescription -> S.Set FieldName -> AppConfig -> ApiRequest -> H.Transaction Response
|
||||
app dbStructure proc cols conf apiRequest =
|
||||
case responseContentTypeOrError (iAccepts apiRequest) (iAction apiRequest) (iTarget apiRequest) of
|
||||
Left errorResponse -> return errorResponse
|
||||
Right contentType ->
|
||||
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
|
||||
|
||||
([table], "OPTIONS") -> do
|
||||
let cols = filter (filterCol schema table) allCols
|
||||
pkeys = map pkName $ filter (filterPk schema table) allPrKeys
|
||||
body = encode (TableOptions cols pkeys)
|
||||
return $ responseLBS status200 [jsonH, allOrigins] $ cs body
|
||||
(ActionRead, TargetIdent qi, Nothing) ->
|
||||
let partsField = (,) <$> readSqlParts
|
||||
<*> (binaryField contentType =<< fldNames) in
|
||||
case partsField of
|
||||
Left errorResponse -> return errorResponse
|
||||
Right ((q, cq), bField) -> do
|
||||
let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount
|
||||
(contentType == CTTextCSV) bField
|
||||
row <- H.statement () stm
|
||||
let (tableTotal, queryTotal, _ , body) = row
|
||||
(status, contentRange) = rangeHeader queryTotal tableTotal
|
||||
canonical = iCanonicalQS apiRequest
|
||||
return $
|
||||
if contentType == CTSingularJSON && queryTotal /= 1
|
||||
then errorResponseFor . singularityError $ queryTotal
|
||||
else responseLBS status
|
||||
[toHeader contentType, contentRange,
|
||||
("Content-Location",
|
||||
"/" <> toS (qiName qi) <>
|
||||
if BS.null canonical then "" else "?" <> toS canonical
|
||||
)
|
||||
] (toS body)
|
||||
|
||||
([table], "GET") ->
|
||||
if range == Just emptyRange
|
||||
then return $ responseLBS status416 [] "HTTP Range error"
|
||||
else
|
||||
case queries of
|
||||
Left e -> return $ responseLBS status400 [("Content-Type", "application/json")] $ cs e
|
||||
Right (qs, cqs) -> do
|
||||
let qt = qualify table
|
||||
count = if hasPrefer "count=none"
|
||||
then countNone
|
||||
else cqs
|
||||
q = B.Stmt "select " V.empty True <>
|
||||
parentheticT count
|
||||
<> commaq <> (
|
||||
bodyForAccept contentType qt -- TODO! when in csv mode, the first row (columns) is not correct when requesting sub tables
|
||||
. limitT range
|
||||
$ qs
|
||||
)
|
||||
row <- H.maybeEx q
|
||||
let (tableTotal, queryTotal, body) = fromMaybe (Just (0::Int), 0::Int, Just "" :: Maybe BL.ByteString) row
|
||||
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
|
||||
[contentTypeH, contentRange,
|
||||
("Content-Location",
|
||||
"/" <> cs table <>
|
||||
if Prelude.null canonical then "" else "?" <> cs canonical
|
||||
)
|
||||
] (fromMaybe "[]" body)
|
||||
(ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
|
||||
case mutateSqlParts tSchema tName of
|
||||
Left errorResponse -> return errorResponse
|
||||
Right (sq, mq) -> do
|
||||
let pkCols = tablePKCols dbStructure tSchema tName
|
||||
stm = createWriteStatement sq mq
|
||||
(contentType == CTSingularJSON) True
|
||||
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols
|
||||
row <- H.statement (toS $ pjRaw pJson) stm
|
||||
let (_, queryTotal, fs, body) = extractQueryResult row
|
||||
headers = catMaybes [
|
||||
if null fs
|
||||
then Nothing
|
||||
else Just (hLocation, "/" <> toS tName <> renderLocationFields fs)
|
||||
, if iPreferRepresentation apiRequest == Full
|
||||
then Just $ toHeader contentType
|
||||
else Nothing
|
||||
, Just $ contentRangeH 1 0 $
|
||||
if shouldCount then Just queryTotal else Nothing
|
||||
, if null pkCols
|
||||
then Nothing
|
||||
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
|
||||
]
|
||||
if contentType == CTSingularJSON
|
||||
&& queryTotal /= 1
|
||||
&& iPreferRepresentation apiRequest == Full
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor . singularityError $ queryTotal
|
||||
else
|
||||
return . responseLBS status201 headers $
|
||||
if iPreferRepresentation apiRequest == Full
|
||||
then toS body else ""
|
||||
|
||||
where
|
||||
from = fromMaybe 0 $ rangeOffset <$> range
|
||||
apiRequest = first formatParserError (parseGetRequest req)
|
||||
>>= first formatRelationError . addRelations schema allRels Nothing
|
||||
>>= addJoinConditions schema allCols
|
||||
where
|
||||
formatRelationError :: Text -> Text
|
||||
formatRelationError e = cs $ encode $ object [
|
||||
"mesage" .= ("could not find foreign keys between these entities"::String),
|
||||
"details" .= e]
|
||||
formatParserError :: ParseError -> Text
|
||||
formatParserError e = cs $ encode $ object [
|
||||
"message" .= message,
|
||||
"details" .= details]
|
||||
where
|
||||
message = show (errorPos e)
|
||||
details = strip $ replace "\n" " " $ cs
|
||||
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
|
||||
(ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
|
||||
case mutateSqlParts tSchema tName of
|
||||
Left errorResponse -> return errorResponse
|
||||
Right (sq, mq) -> do
|
||||
let stm = createWriteStatement sq mq
|
||||
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
|
||||
(iPreferRepresentation apiRequest) []
|
||||
row <- H.statement (toS $ pjRaw pJson) stm
|
||||
let (_, queryTotal, _, body) = extractQueryResult row
|
||||
|
||||
query = requestToQuery schema <$> apiRequest
|
||||
countQuery = requestToCountQuery schema <$> apiRequest
|
||||
queries = (,) <$> query <*> countQuery
|
||||
updateIsNoOp = S.null cols
|
||||
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $
|
||||
if shouldCount then Just queryTotal else Nothing
|
||||
minimalHeaders = [contentRangeHeader]
|
||||
fullHeaders = toHeader contentType : minimalHeaders
|
||||
|
||||
status | queryTotal == 0 && not updateIsNoOp = status404
|
||||
| iPreferRepresentation apiRequest == Full = status200
|
||||
| otherwise = status204
|
||||
|
||||
case (contentType, iPreferRepresentation apiRequest) of
|
||||
(CTSingularJSON, Full)
|
||||
| queryTotal == 1 -> return $ responseLBS status fullHeaders (toS body)
|
||||
| otherwise -> HT.condemn >> (return . errorResponseFor . singularityError) queryTotal
|
||||
|
||||
(_, Full) ->
|
||||
return $ responseLBS status fullHeaders (toS body)
|
||||
|
||||
(_, _) ->
|
||||
return $ responseLBS status minimalHeaders mempty
|
||||
|
||||
|
||||
(["postgrest", "users"], "POST") -> do
|
||||
let user = decode reqBody :: Maybe AuthUser
|
||||
(ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just ProcessedJSON{pjRaw, pjType, pjKeys}) ->
|
||||
case mutateSqlParts tSchema tName of
|
||||
Left errorResponse -> return errorResponse
|
||||
Right (sq, mq) -> do
|
||||
let isSingle = case pjType of
|
||||
PJArray len -> len == 1
|
||||
PJObject -> True
|
||||
colNames = colName <$> tableCols dbStructure tSchema tName
|
||||
if topLevelRange /= allRange
|
||||
then return . errorResponseFor $ PutRangeNotAllowedError
|
||||
else if not isSingle
|
||||
then return . errorResponseFor $ PutSingletonError
|
||||
else if S.fromList colNames /= pjKeys
|
||||
then return . errorResponseFor $ PutPayloadIncompleteError
|
||||
else do
|
||||
row <- H.statement (toS pjRaw) $
|
||||
createWriteStatement sq mq (contentType == CTSingularJSON) False
|
||||
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) []
|
||||
let (_, queryTotal, _, body) = extractQueryResult row
|
||||
-- Makes sure the querystring pk matches the payload pk
|
||||
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
|
||||
-- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done
|
||||
if queryTotal /= 1
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor $ PutMatchingPkError
|
||||
else
|
||||
return $ if iPreferRepresentation apiRequest == Full
|
||||
then responseLBS status200 [toHeader contentType] (toS body)
|
||||
else responseLBS status204 [] ""
|
||||
|
||||
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))
|
||||
] ""
|
||||
(ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
|
||||
case mutateSqlParts tSchema tName of
|
||||
Left errorResponse -> return errorResponse
|
||||
Right (sq, mq) -> do
|
||||
let stm = createWriteStatement sq mq
|
||||
(contentType == CTSingularJSON) False
|
||||
(contentType == CTTextCSV)
|
||||
(iPreferRepresentation apiRequest) []
|
||||
row <- H.statement mempty stm
|
||||
let (_, queryTotal, _, body) = extractQueryResult row
|
||||
r = contentRangeH 1 0 $
|
||||
if shouldCount then Just queryTotal else Nothing
|
||||
if contentType == CTSingularJSON
|
||||
&& queryTotal /= 1
|
||||
&& iPreferRepresentation apiRequest == Full
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor . singularityError $ queryTotal
|
||||
else
|
||||
return $ if iPreferRepresentation apiRequest == Full
|
||||
then responseLBS status200 [toHeader contentType, r] (toS body)
|
||||
else responseLBS status204 [r] ""
|
||||
|
||||
(["postgrest", "tokens"], "POST") ->
|
||||
case jwtSecret of
|
||||
"secret" -> return $ responseLBS status500 [jsonH] $
|
||||
encode . object $ [("message", String "JWT Secret is set as \"secret\" which is an unsafe default.")]
|
||||
_ -> do
|
||||
let user = decode reqBody :: Maybe AuthUser
|
||||
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->
|
||||
let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in
|
||||
case mTable of
|
||||
Nothing -> return notFound
|
||||
Just table ->
|
||||
let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in
|
||||
return $ responseLBS status200 [allOrigins, acceptH] ""
|
||||
|
||||
case user of
|
||||
Nothing -> return $ responseLBS status400 [jsonH] $
|
||||
encode . object $ [("message", String "Failed to parse user.")]
|
||||
Just u -> do
|
||||
setRole authenticator
|
||||
login <- signInRole (cs $ userId u) (cs $ userPass u)
|
||||
case login of
|
||||
LoginSuccess role uid ->
|
||||
return $ responseLBS status201 [ jsonH ] $
|
||||
encode . object $ [("token", String $ tokenJWT jwtSecret uid role)]
|
||||
_ -> return $ responseLBS status401 [jsonH] $
|
||||
encode . object $ [("message", String "Failed authentication.")]
|
||||
(ActionInvoke _, TargetProc qi _, Just pJson) ->
|
||||
let returnsScalar = case proc of
|
||||
Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True
|
||||
_ -> False
|
||||
rpcBinaryField = if returnsScalar
|
||||
then Right Nothing
|
||||
else binaryField contentType =<< fldNames
|
||||
parts = (,) <$> readSqlParts <*> rpcBinaryField in
|
||||
case parts of
|
||||
Left errorResponse -> return errorResponse
|
||||
Right ((q, cq), bField) -> do
|
||||
let singular = contentType == CTSingularJSON
|
||||
row <- H.statement (toS $ pjRaw pJson) $
|
||||
callProc qi (specifiedProcArgs cols proc) returnsScalar q cq shouldCount
|
||||
singular (iPreferSingleObjectParameter apiRequest)
|
||||
(contentType == CTTextCSV)
|
||||
(contentType `elem` rawContentTypes) bField
|
||||
(pgVersion dbStructure)
|
||||
let (tableTotal, queryTotal, body, jsonHeaders) =
|
||||
fromMaybe (Just 0, 0, "[]", "[]") row
|
||||
(status, contentRange) = rangeHeader queryTotal tableTotal
|
||||
decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader]
|
||||
case decodedHeaders of
|
||||
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||
Right hs ->
|
||||
if singular && queryTotal /= 1
|
||||
then do
|
||||
HT.condemn
|
||||
return . errorResponseFor . singularityError $ queryTotal
|
||||
else return $ responseLBS status ([toHeader contentType, contentRange] ++ toHeaders hs) (toS body)
|
||||
|
||||
([table], "POST") -> do
|
||||
let qt = qualify table
|
||||
echoRequested = hasPrefer "return=representation"
|
||||
parsed :: Either String (V.Vector Text, V.Vector (V.Vector Value))
|
||||
parsed = if lookupHeader "Content-Type" == Just csvMT
|
||||
then do
|
||||
rows <- CSV.decode CSV.NoHeader reqBody
|
||||
if V.null rows then Left "CSV requires header"
|
||||
else Right (V.head rows, (V.map $ V.map $ parseCsvCell . cs) (V.tail rows))
|
||||
else eitherDecode reqBody >>= \val ->
|
||||
case val of
|
||||
Object obj -> Right . second V.singleton . V.unzip . V.fromList $
|
||||
M.toList obj
|
||||
_ -> Left "Expecting single JSON object or CSV rows"
|
||||
case parsed of
|
||||
Left err -> return $ responseLBS status400 [] $
|
||||
encode . object $ [("message", String $ "Failed to parse JSON payload. " <> cs err)]
|
||||
Right toBeInserted -> do
|
||||
rows :: [Identity Text] <- H.listEx $ uncurry (insertInto qt) toBeInserted
|
||||
let inserted :: [Object] = mapMaybe (decode . cs . runIdentity) rows
|
||||
pKeys = map pkName $ filter (filterPk schema table) allPrKeys
|
||||
responses = flip map inserted $ \obj -> do
|
||||
let primaries =
|
||||
if Prelude.null pKeys
|
||||
then obj
|
||||
else M.filterWithKey (const . (`elem` pKeys)) obj
|
||||
let params = urlEncodeVars
|
||||
$ map (\t -> (cs $ fst t, cs (paramFilter $ snd t)))
|
||||
$ sortBy (comparing fst) $ M.toList primaries
|
||||
responseLBS status201
|
||||
[ jsonH
|
||||
, (hLocation, "/" <> cs table <> "?" <> cs params)
|
||||
] $ if echoRequested then encode obj else ""
|
||||
return $ multipart status201 responses
|
||||
(ActionInspect, TargetDefaultSpec, Nothing) -> do
|
||||
let host = configHost conf
|
||||
port = toInteger $ configPort conf
|
||||
proxy = pickProxy $ toS <$> configProxyUri conf
|
||||
uri Nothing = ("http", host, port, "/")
|
||||
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
|
||||
uri' = uri proxy
|
||||
toTableInfo :: [Table] -> [(Table, [Column], [Text])]
|
||||
toTableInfo = map (\t -> let (s, tn) = (tableSchema t, tableName t) in (t, tableCols dbStructure s tn, tablePKCols dbStructure s tn))
|
||||
encodeApi ti sd procs = encodeOpenAPI (concat $ M.elems procs) (toTableInfo ti) uri' sd $ dbPrimaryKeys dbStructure
|
||||
|
||||
(["rpc", proc], "POST") -> do
|
||||
let qi = QualifiedIdentifier schema (cs proc)
|
||||
exists <- doesProcExist schema proc
|
||||
if exists
|
||||
then do
|
||||
let call = B.Stmt "select " V.empty True <>
|
||||
asJson (callProc qi $ fromMaybe M.empty (decode reqBody))
|
||||
body :: Maybe (Identity Text) <- H.maybeEx call
|
||||
return $ responseLBS status200 [jsonH]
|
||||
(cs $ fromMaybe "[]" $ runIdentity <$> body)
|
||||
else return $ responseLBS status404 [] ""
|
||||
body <- encodeApi <$> H.statement schema accessibleTables <*> H.statement schema schemaDescription <*> H.statement schema accessibleProcs
|
||||
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
|
||||
|
||||
-- check that proc exists
|
||||
-- check that arg names are all specified
|
||||
-- select * from "1".proc(a := "foo"::undefined) where whereT limit limitT
|
||||
_ -> return notFound
|
||||
|
||||
([table], "PUT") ->
|
||||
handleJsonObj reqBody $ \obj -> do
|
||||
let qt = qualify table
|
||||
pKeys = map pkName $ filter (filterPk schema table) allPrKeys
|
||||
specifiedKeys = map (cs . fst) qq
|
||||
if S.fromList pKeys /= S.fromList specifiedKeys
|
||||
then return $ responseLBS status405 []
|
||||
"You must speficy all and only primary keys as params"
|
||||
else do
|
||||
let tableCols = map (cs . colName) $ filter (filterCol schema table) allCols
|
||||
cols = map cs $ M.keys obj
|
||||
if S.fromList tableCols == S.fromList cols
|
||||
then do
|
||||
let vals = M.elems obj
|
||||
H.unitEx $ iffNotT
|
||||
(whereT qt qq $ update qt cols vals)
|
||||
(insertSelect qt cols vals)
|
||||
return $ responseLBS status204 [ jsonH ] ""
|
||||
where
|
||||
notFound = responseLBS status404 [] ""
|
||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
||||
shouldCount = iPreferCount apiRequest
|
||||
schema = toS $ configSchema conf
|
||||
topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest
|
||||
rangeHeader queryTotal tableTotal =
|
||||
let lower = rangeOffset topLevelRange
|
||||
upper = lower + toInteger queryTotal - 1
|
||||
contentRange = contentRangeH lower upper (toInteger <$> tableTotal)
|
||||
status = rangeStatus lower upper (toInteger <$> tableTotal)
|
||||
in (status, contentRange)
|
||||
|
||||
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 = qualify table
|
||||
up = returningStarT
|
||||
. whereT qt qq
|
||||
$ update qt (map cs $ M.keys obj) (M.elems obj)
|
||||
patch = withT up "t" $ B.Stmt
|
||||
"select count(t), array_to_json(array_agg(row_to_json(t)))::character varying"
|
||||
V.empty True
|
||||
|
||||
row <- H.maybeEx patch
|
||||
let (queryTotal, body) =
|
||||
fromMaybe (0 :: Int, Just "" :: Maybe Text) row
|
||||
r = contentRangeH 0 (queryTotal-1) (Just queryTotal)
|
||||
echoRequested = hasPrefer "return=representation"
|
||||
s = case () of _ | queryTotal == 0 -> status404
|
||||
| echoRequested -> status200
|
||||
| otherwise -> status204
|
||||
return $ responseLBS s [ jsonH, r ] $ if echoRequested then cs $ fromMaybe "[]" body else ""
|
||||
|
||||
([table], "DELETE") -> do
|
||||
let qt = qualify table
|
||||
del = countT
|
||||
. returningStarT
|
||||
. whereT qt qq
|
||||
$ deleteFrom qt
|
||||
row <- H.maybeEx 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 [] ""
|
||||
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) proc apiRequest
|
||||
fldNames = fieldNames <$> readReq
|
||||
readDbRequest = DbRead <$> readReq
|
||||
selectQuery = requestToQuery schema False <$> readDbRequest
|
||||
countQuery = requestToCountQuery schema <$> readDbRequest
|
||||
readSqlParts = (,) <$> selectQuery <*> countQuery
|
||||
mutationDbRequest s t = mutateRequest apiRequest t cols (tablePKCols dbStructure s t) =<< fldNames
|
||||
mutateSqlParts s t =
|
||||
(,) <$> selectQuery
|
||||
<*> (requestToQuery schema False . DbMutate <$> mutationDbRequest s t)
|
||||
|
||||
responseContentTypeOrError :: [ContentType] -> Action -> Target -> Either Response ContentType
|
||||
responseContentTypeOrError accepts action target = serves contentTypesForRequest accepts
|
||||
where
|
||||
allTabs = tables dbstructure
|
||||
allRels = relations dbstructure
|
||||
allCols = columns dbstructure
|
||||
allPrKeys = primaryKeys dbstructure
|
||||
filterCol sc table (Column{colSchema=s, colTable=t}) = s==sc && table==t
|
||||
filterCol _ _ _ = False
|
||||
filterPk sc table pk = sc == pkSchema pk && table == pkTable pk
|
||||
contentTypesForRequest = case action of
|
||||
ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ++ rawContentTypes
|
||||
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV] ++ rawContentTypes ++
|
||||
[CTOpenAPI | tpIsRootSpec target]
|
||||
ActionInspect -> [CTOpenAPI, CTApplicationJSON]
|
||||
ActionInfo -> [CTTextCSV]
|
||||
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
serves sProduces cAccepts =
|
||||
case mutuallyAgreeable sProduces cAccepts of
|
||||
Nothing -> Left . errorResponseFor . ContentTypeError . map toMime $ cAccepts
|
||||
Just ct -> Right ct
|
||||
|
||||
filterTableAcl :: Text -> Table -> Bool
|
||||
filterTableAcl r (Table{tableAcl=a}) = r `elem` a
|
||||
path = pathInfo req
|
||||
verb = requestMethod req
|
||||
qq = queryString req
|
||||
qualify = QualifiedIdentifier schema
|
||||
hdrs = requestHeaders req
|
||||
lookupHeader = flip lookup hdrs
|
||||
hasPrefer val = any (\(h,v) -> h == "Prefer" && v == val) hdrs
|
||||
accept = lookupHeader hAccept
|
||||
schema = requestedSchema (cs $ configV1Schema conf) accept
|
||||
authenticator = cs $ configDbUser conf
|
||||
jwtSecret = cs $ configJwtSecret conf
|
||||
range = rangeRequested hdrs
|
||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
||||
contentType = fromMaybe "application/json" $ contentTypeForAccept accept
|
||||
contentTypeH = (hContentType, contentType)
|
||||
{-
|
||||
| If raw(binary) output is requested, check that ContentType is one of the admitted rawContentTypes and that
|
||||
| `?select=...` contains only one field other than `*`
|
||||
-}
|
||||
binaryField :: ContentType -> [FieldName] -> Either Response (Maybe FieldName)
|
||||
binaryField ct fldNames
|
||||
| ct `elem` rawContentTypes =
|
||||
let fieldName = headMay fldNames in
|
||||
if length fldNames == 1 && fieldName /= Just "*"
|
||||
then Right fieldName
|
||||
else Left . errorResponseFor $ BinaryFieldError ct
|
||||
| otherwise = Right Nothing
|
||||
|
||||
sqlError :: t
|
||||
sqlError = undefined
|
||||
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
|
||||
splitKeyValue kv = (k, BS.tail v)
|
||||
where (k, v) = BS.break (== '=') kv
|
||||
|
||||
isSqlError :: t
|
||||
isSqlError = undefined
|
||||
renderLocationFields :: [BS.ByteString] -> BS.ByteString
|
||||
renderLocationFields fields =
|
||||
renderSimpleQuery True $ map splitKeyValue fields
|
||||
|
||||
rangeStatus :: Int -> Int -> Maybe Int -> Status
|
||||
rangeStatus :: Integer -> Integer -> Maybe Integer -> Status
|
||||
rangeStatus _ _ Nothing = status200
|
||||
rangeStatus from to (Just total)
|
||||
| from > total = status416
|
||||
| (1 + to - from) < total = status206
|
||||
rangeStatus lower upper (Just total)
|
||||
| lower > total = status416
|
||||
| (1 + upper - lower) < total = status206
|
||||
| otherwise = status200
|
||||
|
||||
contentRangeH :: Int -> Int -> Maybe Int -> Header
|
||||
contentRangeH from to total =
|
||||
("Content-Range", cs headerValue)
|
||||
contentRangeH :: (Integral a, Show a) => a -> a -> Maybe a -> Header
|
||||
contentRangeH lower upper total =
|
||||
("Content-Range", headerValue)
|
||||
where
|
||||
headerValue = rangeString <> "/" <> totalString
|
||||
rangeString
|
||||
| totalNotZero && fromInRange = show from <> "-" <> cs (show to)
|
||||
| totalNotZero && fromInRange = show lower <> "-" <> show upper
|
||||
| otherwise = "*"
|
||||
totalString = fromMaybe "*" (show <$> total)
|
||||
totalNotZero = fromMaybe True ((/=) 0 <$> total)
|
||||
fromInRange = from <= to
|
||||
totalString = maybe "*" show total
|
||||
totalNotZero = maybe True (0 /=) total
|
||||
fromInRange = lower <= upper
|
||||
|
||||
requestedSchema :: Text -> Maybe BS.ByteString -> Text
|
||||
requestedSchema v1schema accept =
|
||||
case verStr of
|
||||
Just [[_, ver]] -> if ver == "1" then v1schema else cs ver
|
||||
_ -> v1schema
|
||||
|
||||
where
|
||||
verRegex = "version[ ]*=[ ]*([0-9]+)" :: BS.ByteString
|
||||
verStr = (=~ verRegex) <$> accept :: Maybe [[BS.ByteString]]
|
||||
|
||||
|
||||
jsonMT :: BS.ByteString
|
||||
jsonMT = "application/json"
|
||||
|
||||
csvMT :: BS.ByteString
|
||||
csvMT = "text/csv"
|
||||
|
||||
allMT :: BS.ByteString
|
||||
allMT = "*/*"
|
||||
|
||||
jsonH :: Header
|
||||
jsonH = (hContentType, jsonMT)
|
||||
|
||||
contentTypeForAccept :: Maybe BS.ByteString -> Maybe BS.ByteString
|
||||
contentTypeForAccept accept
|
||||
| isNothing accept || has allMT || has jsonMT = Just jsonMT
|
||||
| has csvMT = Just csvMT
|
||||
| otherwise = Nothing
|
||||
where
|
||||
Just acceptH = accept
|
||||
findInAccept = flip find $ parseHttpAccept acceptH
|
||||
has = isJust . findInAccept . BS.isPrefixOf
|
||||
|
||||
bodyForAccept :: BS.ByteString -> QualifiedIdentifier -> StatementT
|
||||
bodyForAccept contentType table
|
||||
| contentType == csvMT = asCsvWithCount table
|
||||
| otherwise = asJsonWithCount -- defaults to JSON
|
||||
|
||||
handleJsonObj :: BL.ByteString -> (Object -> H.Tx P.Postgres s Response)
|
||||
-> H.Tx P.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")]
|
||||
|
||||
parseCsvCell :: BL.ByteString -> Value
|
||||
parseCsvCell s = if s == "NULL" then Null else String $ cs s
|
||||
|
||||
multipart :: Status -> [Response] -> Response
|
||||
multipart _ [] = responseLBS status204 [] ""
|
||||
multipart _ [r] = r
|
||||
multipart s rs =
|
||||
responseLBS s [(hContentType, "multipart/mixed; boundary=\"postgrest_boundary\"")] $
|
||||
BL.intercalate "\n--postgrest_boundary\n" (map renderResponseBody rs)
|
||||
|
||||
where
|
||||
renderHeader :: Header -> BL.ByteString
|
||||
renderHeader (k, v) = cs (original k) <> ": " <> cs v
|
||||
|
||||
renderResponseBody :: Response -> BL.ByteString
|
||||
renderResponseBody (ResponseBuilder _ headers b) =
|
||||
BL.intercalate "\n" (map renderHeader headers)
|
||||
<> "\n\n" <> BB.toLazyByteString b
|
||||
renderResponseBody _ = error
|
||||
"Unable to create multipart response from non-ResponseBuilder"
|
||||
|
||||
data TableOptions = TableOptions {
|
||||
tblOptcolumns :: [Column]
|
||||
, tblOptpkey :: [Text]
|
||||
}
|
||||
|
||||
instance ToJSON TableOptions where
|
||||
toJSON t = object [
|
||||
"columns" .= tblOptcolumns t
|
||||
, "pkey" .= tblOptpkey t ]
|
||||
extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount
|
||||
extractQueryResult = fromMaybe (Nothing, 0, [], "")
|
||||
|
||||
@@ -1,104 +1,113 @@
|
||||
module PostgREST.Auth where
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-|
|
||||
Module : PostgREST.Auth
|
||||
Description : PostgREST authorization functions.
|
||||
|
||||
import Control.Applicative
|
||||
import Control.Monad (mzero)
|
||||
import Crypto.BCrypt
|
||||
import Data.Aeson
|
||||
import Data.Map
|
||||
import Data.Monoid
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Text
|
||||
import Data.Maybe (isNothing)
|
||||
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.PgQuery (pgFmtLit)
|
||||
import Prelude
|
||||
import qualified Web.JWT as JWT
|
||||
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.
|
||||
|
||||
import System.IO.Unsafe
|
||||
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 (
|
||||
containsRole
|
||||
, jwtClaims
|
||||
, JWTAttempt(..)
|
||||
, parseSecret
|
||||
) where
|
||||
|
||||
data AuthUser = AuthUser {
|
||||
userId :: String
|
||||
, userPass :: String
|
||||
, userRole :: Maybe String
|
||||
} deriving (Show)
|
||||
import qualified Crypto.JOSE.Types as JOSE.Types
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import Data.Vector as V
|
||||
|
||||
instance FromJSON AuthUser where
|
||||
parseJSON (Object v) = AuthUser <$>
|
||||
v .: "id" <*>
|
||||
v .: "pass" <*>
|
||||
v .:? "role"
|
||||
parseJSON _ = mzero
|
||||
import Control.Lens (set)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
|
||||
instance ToJSON AuthUser where
|
||||
toJSON u = object [
|
||||
"id" .= userId u
|
||||
, "pass" .= userPass u
|
||||
, "role" .= userRole u ]
|
||||
import Control.Lens.Operators
|
||||
import Crypto.JWT
|
||||
|
||||
type DbRole = Text
|
||||
type UserId = Text
|
||||
import PostgREST.Types
|
||||
import Protolude
|
||||
|
||||
data LoginAttempt =
|
||||
NoCredentials
|
||||
| MalformedAuth
|
||||
| LoginFailed
|
||||
| LoginSuccess DbRole UserId
|
||||
deriving (Eq, Show)
|
||||
{-|
|
||||
Possible situations encountered with client JWTs
|
||||
-}
|
||||
data JWTAttempt = JWTInvalid JWTError
|
||||
| JWTMissingSecret
|
||||
| JWTClaims (M.HashMap Text JSON.Value)
|
||||
deriving (Eq, Show)
|
||||
|
||||
checkPass :: Text -> Text -> Bool
|
||||
checkPass = (. cs) . validatePassword . cs
|
||||
{-|
|
||||
Receives the JWT secret and audience (from config) and a JWT and returns a map
|
||||
of JWT claims.
|
||||
-}
|
||||
jwtClaims :: Maybe JWKSet -> Maybe StringOrURI -> LByteString -> UTCTime -> Maybe JSPath -> IO JWTAttempt
|
||||
jwtClaims _ _ "" _ _ = return $ JWTClaims M.empty
|
||||
jwtClaims secret audience payload time jspath =
|
||||
case secret of
|
||||
Nothing -> return JWTMissingSecret
|
||||
Just s -> do
|
||||
let validation = set allowedSkew 1 $ defaultJWTValidationSettings (maybe (const True) (==) audience)
|
||||
eJwt <- runExceptT $ do
|
||||
jwt <- decodeCompact payload
|
||||
verifyClaimsAt validation s time jwt
|
||||
return $ case eJwt of
|
||||
Left e -> JWTInvalid e
|
||||
Right jwt -> JWTClaims $ claims2map jwt jspath
|
||||
|
||||
setRole :: Text -> H.Tx P.Postgres s ()
|
||||
setRole role = H.unitEx $ B.Stmt ("set local role " <> cs (pgFmtLit role)) V.empty True
|
||||
{-|
|
||||
Turn JWT ClaimSet into something easier to work with,
|
||||
also here the jspath is applied to put the "role" in the map
|
||||
-}
|
||||
claims2map :: ClaimsSet -> Maybe JSPath -> M.HashMap Text JSON.Value
|
||||
claims2map claims jspath = (\case
|
||||
val@(JSON.Object o) ->
|
||||
let role = maybe M.empty (M.singleton "role") $
|
||||
walkJSPath (Just val) =<< jspath in
|
||||
M.delete "role" o `M.union` role -- mutating the map
|
||||
_ -> M.empty
|
||||
) $ JSON.toJSON claims
|
||||
|
||||
setUserId :: Text -> H.Tx P.Postgres s ()
|
||||
setUserId uid =
|
||||
if uid /= ""
|
||||
then H.unitEx $ B.Stmt ("set local user_vars.user_id = " <> cs (pgFmtLit uid)) V.empty True
|
||||
else resetUserId
|
||||
walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
|
||||
walkJSPath x [] = x
|
||||
walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (M.lookup key o) rest
|
||||
walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
|
||||
walkJSPath _ _ = Nothing
|
||||
|
||||
resetUserId :: H.Tx P.Postgres s ()
|
||||
resetUserId = H.unitEx [H.stmt|reset user_vars.user_id|]
|
||||
{-|
|
||||
Whether a response from jwtClaims contains a role claim
|
||||
-}
|
||||
containsRole :: JWTAttempt -> Bool
|
||||
containsRole (JWTClaims claims) = M.member "role" claims
|
||||
containsRole _ = False
|
||||
|
||||
addUser :: Text -> Text -> Maybe Text -> H.Tx P.Postgres s ()
|
||||
addUser identity pass role =
|
||||
H.unitEx $
|
||||
if isNothing role
|
||||
then [H.stmt|insert into postgrest.auth (id, pass) values (?, ?)|]
|
||||
identity hashedText
|
||||
else [H.stmt|insert into postgrest.auth (id, pass, rolname) values (?, ?, ?)|]
|
||||
identity hashedText role
|
||||
where Just hashed = unsafePerformIO $ hashPasswordUsingPolicy fastBcryptHashingPolicy (cs pass)
|
||||
hashedText = cs hashed :: Text
|
||||
{-|
|
||||
Parse `jwt-secret` configuration option and turn into a JWKSet.
|
||||
|
||||
signInRole :: Text -> Text -> H.Tx P.Postgres s LoginAttempt
|
||||
signInRole user pass = do
|
||||
u <- H.maybeEx $ [H.stmt|select id, pass, rolname from postgrest.auth where id = ?|] user
|
||||
return $ maybe LoginFailed (\r ->
|
||||
let (uid, hashed, role) = r in
|
||||
if checkPass hashed pass
|
||||
then LoginSuccess role uid
|
||||
else LoginFailed
|
||||
) u
|
||||
There are three ways to specify `jwt-secret`: text secret, JSON Web Key
|
||||
(JWK), or JSON Web Key Set (JWKS). The first two are converted into a JWKSet
|
||||
with one key and the last is converted as is.
|
||||
-}
|
||||
parseSecret :: ByteString -> JWKSet
|
||||
parseSecret str =
|
||||
fromMaybe (maybe secret (\jwk' -> JWKSet [jwk']) maybeJWK)
|
||||
maybeJWKSet
|
||||
where
|
||||
maybeJWKSet = JSON.decode (toS str) :: Maybe JWKSet
|
||||
maybeJWK = JSON.decode (toS str) :: Maybe JWK
|
||||
secret = JWKSet [jwkFromSecret str]
|
||||
|
||||
signInWithJWT :: Text -> Text -> LoginAttempt
|
||||
signInWithJWT secret input = case maybeRole of
|
||||
Just (Just (String role)) -> case maybeUserId of
|
||||
Just (Just (String uid)) -> LoginSuccess (cs role) (cs uid)
|
||||
_ -> LoginFailed
|
||||
_ -> LoginFailed
|
||||
where
|
||||
maybeRole = (Data.Map.lookup "role" <$> claims) ::Maybe (Maybe Value)
|
||||
maybeUserId = (Data.Map.lookup "id" <$> claims) ::Maybe (Maybe Value)
|
||||
claims = JWT.unregisteredClaims <$> JWT.claims <$> decoded
|
||||
decoded = JWT.decodeAndVerifySignature (JWT.secret secret) input
|
||||
|
||||
tokenJWT :: Text -> Text -> Text -> Text
|
||||
tokenJWT secret uid role = JWT.encodeSigned JWT.HS256 (JWT.secret secret) claimsSet
|
||||
where
|
||||
claimsSet = JWT.def {
|
||||
JWT.unregisteredClaims = Data.Map.fromList [("id", String uid), ("role", String role)]
|
||||
}
|
||||
{-|
|
||||
Internal helper to generate a symmetric HMAC-SHA256 JWK from a text secret.
|
||||
-}
|
||||
jwkFromSecret :: ByteString -> JWK
|
||||
jwkFromSecret key =
|
||||
fromKeyMaterial km
|
||||
& jwkUse ?~ Sig
|
||||
& jwkAlg ?~ JWSAlg HS256
|
||||
where
|
||||
km = OctKeyMaterial (OctKeyParameters (JOSE.Types.Base64Octets key))
|
||||
|
||||
@@ -2,70 +2,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.
|
||||
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.
|
||||
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.
|
||||
-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TemplateHaskell #-}
|
||||
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
|
||||
|
||||
module PostgREST.Config ( prettyVersion
|
||||
, docsVersion
|
||||
, readOptions
|
||||
, corsPolicy
|
||||
, minimumPgVersion
|
||||
, AppConfig (..)
|
||||
, configPoolTimeout'
|
||||
)
|
||||
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 hiding (columns)
|
||||
import Paths_postgrest (version)
|
||||
import Prelude
|
||||
import qualified Data.ByteString as B
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
import qualified Data.Configurator as C
|
||||
import qualified Text.PrettyPrint.ANSI.Leijen as L
|
||||
|
||||
-- | Data type to store all command line options
|
||||
import Control.Exception (Handler (..))
|
||||
import Control.Lens (preview)
|
||||
import Control.Monad (fail)
|
||||
import Crypto.JWT (StringOrURI, stringOrUri)
|
||||
import Data.List (lookup)
|
||||
import Data.Scientific (floatingOrInteger)
|
||||
import Data.Text (dropEnd, dropWhileEnd,
|
||||
intercalate, lines, splitOn,
|
||||
strip, take, unpack)
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Text.IO (hPutStrLn)
|
||||
import Data.Version (versionBranch)
|
||||
import Development.GitRev (gitHash)
|
||||
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
||||
import Paths_postgrest (version)
|
||||
import System.IO.Error (IOError)
|
||||
|
||||
import Control.Applicative
|
||||
import Data.Monoid
|
||||
import Network.Wai
|
||||
import Options.Applicative hiding (str)
|
||||
import Text.Heredoc
|
||||
import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>))
|
||||
|
||||
import PostgREST.Error (ApiRequestError (..))
|
||||
import PostgREST.Parsers (pRoleClaimKey)
|
||||
import PostgREST.Types (JSPath, JSPathExp (..),
|
||||
QualifiedIdentifier (..))
|
||||
import Protolude hiding (concat, hPutStrLn, intercalate, null,
|
||||
take, (<>))
|
||||
|
||||
|
||||
-- | Config file settings for the server
|
||||
data AppConfig = AppConfig {
|
||||
configDbName :: String
|
||||
, configDbPort :: Int
|
||||
, configDbUser :: String
|
||||
, configDbPass :: String
|
||||
, configDbHost :: String
|
||||
configDatabase :: Text
|
||||
, configAnonRole :: Text
|
||||
, configProxyUri :: Maybe Text
|
||||
, configSchema :: Text
|
||||
, configHost :: Text
|
||||
, configPort :: Int
|
||||
, configSocket :: Maybe Text
|
||||
|
||||
, configPort :: Int
|
||||
, configAnonRole :: String
|
||||
, configSecure :: Bool
|
||||
, configPool :: Int
|
||||
, configV1Schema :: String
|
||||
, configJwtSecret :: String
|
||||
, configJwtSecret :: Maybe B.ByteString
|
||||
, configJwtSecretIsBase64 :: Bool
|
||||
, configJwtAudience :: Maybe StringOrURI
|
||||
|
||||
, configPool :: Int
|
||||
, configPoolTimeout :: Int
|
||||
, configMaxRows :: Maybe Integer
|
||||
, configReqCheck :: Maybe Text
|
||||
, configQuiet :: Bool
|
||||
, configSettings :: [(Text, Text)]
|
||||
, configRoleClaimKey :: Either ApiRequestError JSPath
|
||||
, configExtraSearchPath :: [Text]
|
||||
|
||||
, configRootSpec :: Maybe QualifiedIdentifier
|
||||
}
|
||||
|
||||
argParser :: Parser AppConfig
|
||||
argParser = AppConfig
|
||||
<$> strOption (long "db-name" <> short 'd' <> metavar "NAME" <> help "name of database")
|
||||
<*> option auto (long "db-port" <> short 'P' <> metavar "PORT" <> value 5432 <> help "postgres server port" <> showDefault)
|
||||
<*> strOption (long "db-user" <> short 'U' <> metavar "ROLE" <> help "postgres authenticator role")
|
||||
<*> strOption (long "db-pass" <> metavar "PASS" <> value "" <> help "password for authenticator role")
|
||||
<*> strOption (long "db-host" <> metavar "HOST" <> value "localhost" <> help "postgres server hostname" <> showDefault)
|
||||
configPoolTimeout' :: (Fractional a) => AppConfig -> a
|
||||
configPoolTimeout' =
|
||||
fromRational . toRational . configPoolTimeout
|
||||
|
||||
<*> option auto (long "port" <> short 'p' <> metavar "PORT" <> value 3000 <> help "port number on which to run HTTP server" <> showDefault)
|
||||
<*> strOption (long "anonymous" <> short 'a' <> metavar "ROLE" <> help "postgres role to use for non-authenticated requests")
|
||||
<*> switch (long "secure" <> short 's' <> help "Redirect all requests to HTTPS")
|
||||
<*> option auto (long "db-pool" <> metavar "COUNT" <> value 10 <> help "Max connections in database pool" <> showDefault)
|
||||
<*> strOption (long "v1schema" <> metavar "NAME" <> value "1" <> help "Schema to use for nonspecified version (or explicit v1)" <> showDefault)
|
||||
<*> strOption (long "jwt-secret" <> metavar "SECRET" <> value "secret" <> help "Secret used to encrypt and decrypt JWT tokens)" <> showDefault)
|
||||
|
||||
defaultCorsPolicy :: CorsResourcePolicy
|
||||
defaultCorsPolicy = CorsResourcePolicy Nothing
|
||||
["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] ["Authorization"] Nothing
|
||||
["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] ["Authorization"] Nothing
|
||||
(Just $ 60*60*24) False False True
|
||||
|
||||
-- | CORS policy to be used in by Wai Cors middleware
|
||||
@@ -83,26 +115,168 @@ corsPolicy req = case lookup "origin" headers of
|
||||
where
|
||||
headers = requestHeaders req
|
||||
accHeaders = case lookup "access-control-request-headers" headers of
|
||||
Just hdrs -> map (CI.mk . cs . strip . cs) $ BS.split ',' hdrs
|
||||
Just hdrs -> map (CI.mk . toS . strip . toS) $ BS.split ',' hdrs
|
||||
Nothing -> []
|
||||
|
||||
-- | User friendly version number
|
||||
prettyVersion :: String
|
||||
prettyVersion = intercalate "." $ map show $ versionBranch version
|
||||
prettyVersion :: Text
|
||||
prettyVersion =
|
||||
intercalate "." (map show $ versionBranch version)
|
||||
<> " (" <> take 7 $(gitHash) <> ")"
|
||||
|
||||
-- | Version number used in docs
|
||||
docsVersion :: Text
|
||||
docsVersion = "v" <> dropEnd 1 (dropWhileEnd (/= '.') prettyVersion)
|
||||
|
||||
-- | Function to read and parse options from the command line
|
||||
readOptions :: IO AppConfig
|
||||
readOptions = customExecParser parserPrefs opts
|
||||
readOptions = do
|
||||
-- First read the config file path from command line
|
||||
cfgPath <- customExecParser parserPrefs opts
|
||||
-- Now read the actual config file
|
||||
conf <- catches (C.load cfgPath)
|
||||
[ Handler (\(ex :: IOError) -> exitErr $ "Cannot open config file:\n\t" <> show ex)
|
||||
, Handler (\(C.ParseError err) -> exitErr $ "Error parsing config file:\n\t" <> err)
|
||||
]
|
||||
|
||||
case C.runParser parseConfig conf of
|
||||
Left err ->
|
||||
exitErr $ "Error parsing config file:\n\t" <> err
|
||||
Right appConf ->
|
||||
return appConf
|
||||
|
||||
where
|
||||
opts = info (helper <*> argParser) $
|
||||
fullDesc
|
||||
<> progDesc (
|
||||
"PostgREST "
|
||||
<> prettyVersion
|
||||
<> " / create a REST API to an existing Postgres database"
|
||||
)
|
||||
dbSchema = reqString "db-schema"
|
||||
parseConfig =
|
||||
AppConfig
|
||||
<$> reqString "db-uri"
|
||||
<*> reqString "db-anon-role"
|
||||
<*> optString "server-proxy-uri"
|
||||
<*> dbSchema
|
||||
<*> (fromMaybe "!4" <$> optString "server-host")
|
||||
<*> (fromMaybe 3000 <$> optInt "server-port")
|
||||
<*> optString "server-unix-socket"
|
||||
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
|
||||
<*> (fromMaybe False <$> optBool "secret-is-base64")
|
||||
<*> parseJwtAudience "jwt-aud"
|
||||
<*> (fromMaybe 10 <$> optInt "db-pool")
|
||||
<*> (fromMaybe 10 <$> optInt "db-pool-timeout")
|
||||
<*> optInt "max-rows"
|
||||
<*> optString "pre-request"
|
||||
<*> pure False
|
||||
<*> (fmap (fmap coerceText) <$> C.subassocs "app.settings" C.value)
|
||||
<*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> optValue "role-claim-key")
|
||||
<*> (maybe ["public"] splitExtraSearchPath <$> optValue "db-extra-search-path")
|
||||
<*> ((\x y -> QualifiedIdentifier x <$> y) <$> dbSchema <*> optString "root-spec")
|
||||
|
||||
parseJwtAudience :: C.Key -> C.Parser C.Config (Maybe StringOrURI)
|
||||
parseJwtAudience k =
|
||||
C.optional k C.string >>= \case
|
||||
Nothing -> pure Nothing -- no audience in config file
|
||||
Just aud -> case preview stringOrUri (unpack aud) of
|
||||
Nothing -> fail "Invalid Jwt audience. Check your configuration."
|
||||
(Just "") -> pure Nothing
|
||||
aud' -> pure aud'
|
||||
|
||||
reqString :: C.Key -> C.Parser C.Config Text
|
||||
reqString k = C.required k C.string
|
||||
|
||||
optString :: C.Key -> C.Parser C.Config (Maybe Text)
|
||||
optString k = mfilter (/= "") <$> C.optional k C.string
|
||||
|
||||
optValue :: C.Key -> C.Parser C.Config (Maybe C.Value)
|
||||
optValue k = C.optional k C.value
|
||||
|
||||
optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)
|
||||
optInt k = join <$> C.optional k (coerceInt <$> C.value)
|
||||
|
||||
optBool :: C.Key -> C.Parser C.Config (Maybe Bool)
|
||||
optBool k = join <$> C.optional k (coerceBool <$> C.value)
|
||||
|
||||
coerceText :: C.Value -> Text
|
||||
coerceText (C.String s) = s
|
||||
coerceText v = show v
|
||||
|
||||
coerceInt :: (Read i, Integral i) => C.Value -> Maybe i
|
||||
coerceInt (C.Number x) = rightToMaybe $ floatingOrInteger x
|
||||
coerceInt (C.String x) = readMaybe $ toS x
|
||||
coerceInt _ = Nothing
|
||||
|
||||
coerceBool :: C.Value -> Maybe Bool
|
||||
coerceBool (C.Bool b) = Just b
|
||||
coerceBool (C.String b) = readMaybe $ toS b
|
||||
coerceBool _ = Nothing
|
||||
|
||||
parseRoleClaimKey :: C.Value -> Either ApiRequestError JSPath
|
||||
parseRoleClaimKey (C.String s) = pRoleClaimKey s
|
||||
parseRoleClaimKey v = pRoleClaimKey $ show v
|
||||
|
||||
splitExtraSearchPath :: C.Value -> [Text]
|
||||
splitExtraSearchPath (C.String s) = strip <$> splitOn "," s
|
||||
splitExtraSearchPath _ = []
|
||||
|
||||
opts = info (helper <*> pathParser) $
|
||||
fullDesc
|
||||
<> progDesc (
|
||||
"PostgREST "
|
||||
<> toS prettyVersion
|
||||
<> " / create a REST API to an existing Postgres database"
|
||||
)
|
||||
<> footerDoc (Just $
|
||||
text "Example Config File:"
|
||||
L.<> nest 2 (hardline L.<> exampleCfg)
|
||||
)
|
||||
|
||||
parserPrefs = prefs showHelpOnError
|
||||
|
||||
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
|
||||
minimumPgVersion :: Integer
|
||||
minimumPgVersion = 90300
|
||||
exitErr :: Text -> IO a
|
||||
exitErr err = do
|
||||
hPutStrLn stderr err
|
||||
exitFailure
|
||||
|
||||
exampleCfg :: Doc
|
||||
exampleCfg = vsep . map (text . toS) . lines $
|
||||
[str|db-uri = "postgres://user:pass@localhost:5432/dbname"
|
||||
|db-schema = "public" # this schema gets added to the search_path of every request
|
||||
|db-anon-role = "postgres"
|
||||
|db-pool = 10
|
||||
|db-pool-timeout = 10
|
||||
|
|
||||
|server-host = "!4"
|
||||
|server-port = 3000
|
||||
|
|
||||
|## unix socket location
|
||||
|## if specified it takes precedence over server-port
|
||||
|# server-unix-socket = "/tmp/pgrst.sock"
|
||||
|
|
||||
|## base url for swagger output
|
||||
|# server-proxy-uri = ""
|
||||
|
|
||||
|## choose a secret, JSON Web Key (or set) to enable JWT auth
|
||||
|## (use "@filename" to load from separate file)
|
||||
|# jwt-secret = "foo"
|
||||
|# secret-is-base64 = false
|
||||
|# jwt-aud = "your_audience_claim"
|
||||
|
|
||||
|## limit rows in response
|
||||
|# max-rows = 1000
|
||||
|
|
||||
|## stored proc to exec immediately after auth
|
||||
|# pre-request = "stored_proc_name"
|
||||
|
|
||||
|## jspath to the role claim key
|
||||
|# role-claim-key = ".role"
|
||||
|
|
||||
|## extra schemas to add to the search_path of every request
|
||||
|# db-extra-search-path = "extensions, util"
|
||||
|
|
||||
|## stored proc that overrides the root "/" spec
|
||||
|## it must be inside the db-schema
|
||||
|# root-spec = "stored_proc_name"
|
||||
|]
|
||||
|
||||
pathParser :: Parser FilePath
|
||||
pathParser =
|
||||
strArgument $
|
||||
metavar "FILENAME" <>
|
||||
help "Path to configuration file"
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
{-|
|
||||
Module : PostgREST.DbRequestBuilder
|
||||
Description : PostgREST database request builder
|
||||
|
||||
This module is in charge of building an intermediate representation(ReadRequest, MutateRequest) between the HTTP request and the final resulting SQL query.
|
||||
|
||||
A query tree is built in case of resource embedding. By inferring the relationship between tables, join conditions are added for every embedded resource.
|
||||
-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
|
||||
module PostgREST.DbRequestBuilder (
|
||||
readRequest
|
||||
, mutateRequest
|
||||
, fieldNames
|
||||
) where
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Data.Set as S
|
||||
|
||||
import Control.Arrow ((***))
|
||||
import Control.Lens.Getter (view)
|
||||
import Control.Lens.Tuple (_1)
|
||||
import Data.Either.Combinators (mapLeft)
|
||||
import Data.Foldable (foldr1)
|
||||
import Data.List (delete)
|
||||
import Data.Maybe (fromJust)
|
||||
import Data.Text (isInfixOf)
|
||||
import Text.Regex.TDFA ((=~))
|
||||
import Unsafe (unsafeHead)
|
||||
|
||||
import Control.Applicative
|
||||
import Data.Tree
|
||||
import Network.Wai
|
||||
|
||||
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
|
||||
PreferRepresentation (..),
|
||||
PreferRepresentation (..), Target (..))
|
||||
import PostgREST.Error (ApiRequestError (..), errorResponseFor)
|
||||
import PostgREST.Parsers
|
||||
import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange)
|
||||
import PostgREST.Types
|
||||
import Protolude hiding (from)
|
||||
|
||||
readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest
|
||||
readRequest maxRows allRels proc apiRequest =
|
||||
mapLeft errorResponseFor $
|
||||
treeRestrictRange maxRows =<<
|
||||
augumentRequestWithJoin schema relations =<<
|
||||
addFiltersOrdersRanges apiRequest <*>
|
||||
(buildReadRequest <$> pRequestSelect (iSelect apiRequest))
|
||||
where
|
||||
action = iAction apiRequest
|
||||
(schema, rootTableName) = fromJust $ -- Make it safe
|
||||
let target = iTarget apiRequest in
|
||||
case target of
|
||||
(TargetIdent (QualifiedIdentifier s t) ) -> Just (s, t)
|
||||
(TargetProc (QualifiedIdentifier s pName) _ ) -> Just (s, tName)
|
||||
where
|
||||
tName = case pdReturnType <$> proc of
|
||||
Just (SetOf (Composite qi)) -> qiName qi
|
||||
Just (Single (Composite qi)) -> qiName qi
|
||||
_ -> pName
|
||||
|
||||
_ -> Nothing
|
||||
|
||||
-- Build tree with a Depth attribute so when a self join occurs we can differentiate the parent and child tables by having
|
||||
-- an alias like "table_depth", this is related to issue #987.
|
||||
buildReadRequest :: [Tree SelectItem] -> ReadRequest
|
||||
buildReadRequest fieldTree =
|
||||
let rootDepth = 0
|
||||
rootNodeName = if action == ActionRead then rootTableName else sourceCTEName in
|
||||
foldr (treeEntry rootDepth) (Node (Select [] rootNodeName Nothing [] [] [] [] allRange, (rootNodeName, Nothing, Nothing, Nothing, rootDepth)) []) fieldTree
|
||||
where
|
||||
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
|
||||
treeEntry depth (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) =
|
||||
let nxtDepth = succ depth in
|
||||
case fldForest of
|
||||
[] -> Node (q {select=fld:select q}, i) rForest
|
||||
_ -> Node (q, i) $
|
||||
foldr (treeEntry nxtDepth) (Node (Select [] fn Nothing [] [] [] [] allRange, (fn, Nothing, alias, relationDetail, nxtDepth)) []) fldForest:rForest
|
||||
|
||||
relations :: [Relation]
|
||||
relations = case action of
|
||||
ActionCreate -> fakeSourceRelations ++ allRels
|
||||
ActionUpdate -> fakeSourceRelations ++ allRels
|
||||
ActionDelete -> fakeSourceRelations ++ allRels
|
||||
ActionInvoke _ -> fakeSourceRelations ++ allRels
|
||||
_ -> allRels
|
||||
where fakeSourceRelations = mapMaybe (toSourceRelation rootTableName) allRels
|
||||
|
||||
-- in a relation where one of the tables matches "TableName"
|
||||
-- replace the name to that table with pg_source
|
||||
-- this "fake" relations is needed so that in a mutate query
|
||||
-- we can look at the "returning *" part which is wrapped with a "with"
|
||||
-- as just another table that has relations with other tables
|
||||
toSourceRelation :: TableName -> Relation -> Maybe Relation
|
||||
toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
|
||||
| mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
|
||||
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}
|
||||
| Just mt == (tableName <$> rt) = Just $ r {relLinkTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
|
||||
| otherwise = Nothing
|
||||
|
||||
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request
|
||||
where
|
||||
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
||||
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
||||
|
||||
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
augumentRequestWithJoin schema allRels request =
|
||||
addRelations schema allRels Nothing request
|
||||
>>= addJoinConditions schema Nothing
|
||||
|
||||
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
addRelations schema allRelations parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, relationDetail, depth)) forest) =
|
||||
case parentNode of
|
||||
Just (Node (Select{from=parentNodeTable}, _) _) ->
|
||||
let newFrom r = if tbl == nodeName then tableName (relTable r) else tbl
|
||||
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
|
||||
rel :: Either ApiRequestError Relation
|
||||
rel = note (NoRelationBetween parentNodeTable nodeName) $
|
||||
findRelation schema allRelations nodeName parentNodeTable relationDetail in
|
||||
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
|
||||
_ ->
|
||||
let rn = (query, (nodeName, Just r, alias, Nothing, depth))
|
||||
r = Relation t [] t [] Root Nothing Nothing Nothing
|
||||
t = Table schema nodeName Nothing True in -- !!! TODO find another way to get the table from the query
|
||||
Node rn <$> updateForest (Just $ Node rn forest)
|
||||
where
|
||||
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
|
||||
updateForest rq = mapM (addRelations schema allRelations rq) forest
|
||||
|
||||
findRelation :: Schema -> [Relation] -> NodeName -> TableName -> Maybe RelationDetail -> Maybe Relation
|
||||
findRelation schema allRelations nodeTableName parentNodeTableName relationDetail =
|
||||
find (\Relation{relTable, relColumns, relFTable, relFColumns, relType, relLinkTable} ->
|
||||
-- Both relation ends need to be on the exposed schema
|
||||
schema == tableSchema relTable && schema == tableSchema relFTable &&
|
||||
case relationDetail of
|
||||
Nothing ->
|
||||
|
||||
-- (request) => projects { ..., clients{...} }
|
||||
-- will match
|
||||
-- (relation type) => parent
|
||||
-- (entity) => clients {id}
|
||||
-- (foriegn entity) => projects {client_id}
|
||||
(
|
||||
nodeTableName == tableName relTable && -- match relation table name
|
||||
parentNodeTableName == tableName relFTable -- match relation foreign table name
|
||||
) ||
|
||||
|
||||
-- (request) => projects { ..., client_id{...} }
|
||||
-- will match
|
||||
-- (relation type) => parent
|
||||
-- (entity) => clients {id}
|
||||
-- (foriegn entity) => projects {client_id}
|
||||
(
|
||||
parentNodeTableName == tableName relFTable &&
|
||||
length relFColumns == 1 &&
|
||||
-- match common foreign key names(table_name_id, table_name_fk) to table_name
|
||||
(toS ("^" <> colName (unsafeHead relFColumns) <> "_?(?:|[iI][dD]|[fF][kK])$") :: BS.ByteString) =~ (toS nodeTableName :: BS.ByteString)
|
||||
)
|
||||
|
||||
-- (request) => project_id { ..., client_id{...} }
|
||||
-- will match
|
||||
-- (relation type) => parent
|
||||
-- (entity) => clients {id}
|
||||
-- (foriegn entity) => projects {client_id}
|
||||
-- this case works becasue before reaching this place
|
||||
-- addRelation will turn project_id to project so the above condition will match
|
||||
|
||||
Just rd ->
|
||||
|
||||
-- (request) => clients { ..., projects.client_id{...} }
|
||||
-- will match
|
||||
-- (relation type) => child
|
||||
-- (entity) => clients {id}
|
||||
-- (foriegn entity) => projects {client_id}
|
||||
(
|
||||
relType == Child &&
|
||||
nodeTableName == tableName relTable && -- match relation table name
|
||||
parentNodeTableName == tableName relFTable && -- match relation foreign table name
|
||||
length relColumns == 1 &&
|
||||
rd == colName (unsafeHead relColumns)
|
||||
) ||
|
||||
|
||||
-- (request) => message { ..., person_detail.sender{...} }
|
||||
-- will match
|
||||
-- (relation type) => parent
|
||||
-- (entity) => message {sender}
|
||||
-- (foriegn entity) => person_detail {id}
|
||||
(
|
||||
relType == Parent &&
|
||||
nodeTableName == tableName relTable && -- match relation table name
|
||||
parentNodeTableName == tableName relFTable && -- match relation foreign table name
|
||||
length relFColumns == 1 &&
|
||||
rd == colName (unsafeHead relFColumns)
|
||||
) ||
|
||||
|
||||
-- (request) => tasks { ..., users.tasks_users{...} }
|
||||
-- will match
|
||||
-- (relation type) => many
|
||||
-- (entity) => users
|
||||
-- (foriegn entity) => tasks
|
||||
(
|
||||
relType == Many &&
|
||||
nodeTableName == tableName relTable && -- match relation table name
|
||||
parentNodeTableName == tableName relFTable && -- match relation foreign table name
|
||||
rd == tableName (fromJust relLinkTable)
|
||||
)
|
||||
) allRelations
|
||||
|
||||
-- previousAlias is only used for the case of self joins
|
||||
addJoinConditions :: Schema -> Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||
addJoinConditions schema previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, relation, _, _, depth)) forest) =
|
||||
case relation of
|
||||
Just Relation{relType=Root} -> Node node <$> updatedForest -- this is the root node
|
||||
Just rel@Relation{relType=Parent} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
|
||||
Just rel@Relation{relType=Child} -> Node (augmentQuery rel, nodeProps) <$> updatedForest
|
||||
Just rel@Relation{relType=Many, relLinkTable=(Just linkTable)} ->
|
||||
let rq = augmentQuery rel in
|
||||
Node (rq{implicitJoins=tableName linkTable:implicitJoins rq}, nodeProps) <$> updatedForest
|
||||
_ -> Left UnknownRelation
|
||||
where
|
||||
newAlias = case isSelfJoin <$> relation of
|
||||
Just True
|
||||
| depth /= 0 -> Just (tbl <> "_" <> show depth) -- root node doesn't get aliased
|
||||
| otherwise -> Nothing
|
||||
_ -> Nothing
|
||||
augmentQuery rel =
|
||||
foldr
|
||||
(\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})
|
||||
query{fromAlias=newAlias}
|
||||
(getJoinConditions previousAlias newAlias rel)
|
||||
updatedForest = mapM (addJoinConditions schema newAlias) forest
|
||||
|
||||
-- previousAlias and newAlias are used in the case of self joins
|
||||
getJoinConditions :: Maybe Alias -> Maybe Alias -> Relation -> [JoinCondition]
|
||||
getJoinConditions previousAlias newAlias (Relation Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fCols typ lt lc1 lc2) =
|
||||
case typ of
|
||||
Child ->
|
||||
zipWith (toJoinCondition tN ftN) cols fCols
|
||||
Parent ->
|
||||
zipWith (toJoinCondition tN ftN) cols fCols
|
||||
Many ->
|
||||
let ltN = maybe "" tableName lt in
|
||||
zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fCols (fromMaybe [] lc2)
|
||||
Root -> witness
|
||||
where
|
||||
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
|
||||
toJoinCondition tb ftb c fc =
|
||||
let qi1 = QualifiedIdentifier tSchema tb
|
||||
qi2 = QualifiedIdentifier tSchema ftb in
|
||||
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) newAlias, colName c)
|
||||
(maybe qi2 (QualifiedIdentifier mempty) previousAlias, colName fc)
|
||||
|
||||
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
|
||||
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
||||
flip (foldr addFilter) <$> filters,
|
||||
flip (foldr addOrder) <$> orders,
|
||||
flip (foldr addRange) <$> ranges,
|
||||
flip (foldr addLogicTree) <$> logicForest
|
||||
]
|
||||
{-
|
||||
The esence of what is going on above is that we are composing tree functions
|
||||
of type (ReadRequest->ReadRequest) that are in (Either ParseError a) context
|
||||
-}
|
||||
where
|
||||
filters :: Either ApiRequestError [(EmbedPath, Filter)]
|
||||
filters = mapM pRequestFilter flts
|
||||
logicForest :: Either ApiRequestError [(EmbedPath, LogicTree)]
|
||||
logicForest = mapM pRequestLogicTree logFrst
|
||||
action = iAction apiRequest
|
||||
-- there can be no filters on the root table when we are doing insert/update/delete
|
||||
(flts, logFrst) =
|
||||
case action of
|
||||
ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)
|
||||
ActionRead -> (iFilters apiRequest, iLogic apiRequest)
|
||||
_ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
|
||||
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
|
||||
orders = mapM pRequestOrder $ iOrder apiRequest
|
||||
ranges :: Either ApiRequestError [(EmbedPath, NonnegRange)]
|
||||
ranges = mapM pRequestRange $ M.toList $ iRange apiRequest
|
||||
|
||||
addFilterToNode :: Filter -> ReadRequest -> ReadRequest
|
||||
addFilterToNode flt (Node (q@Select {where_=lf}, i) f) = Node (q{where_=addFilterToLogicForest flt lf}::ReadQuery, i) f
|
||||
|
||||
addFilter :: (EmbedPath, Filter) -> ReadRequest -> ReadRequest
|
||||
addFilter = addProperty addFilterToNode
|
||||
|
||||
addOrderToNode :: [OrderTerm] -> ReadRequest -> ReadRequest
|
||||
addOrderToNode o (Node (q,i) f) = Node (q{order=o}, i) f
|
||||
|
||||
addOrder :: (EmbedPath, [OrderTerm]) -> ReadRequest -> ReadRequest
|
||||
addOrder = addProperty addOrderToNode
|
||||
|
||||
addRangeToNode :: NonnegRange -> ReadRequest -> ReadRequest
|
||||
addRangeToNode r (Node (q,i) f) = Node (q{range_=r}, i) f
|
||||
|
||||
addRange :: (EmbedPath, NonnegRange) -> ReadRequest -> ReadRequest
|
||||
addRange = addProperty addRangeToNode
|
||||
|
||||
addLogicTreeToNode :: LogicTree -> ReadRequest -> ReadRequest
|
||||
addLogicTreeToNode t (Node (q@Select{where_=lf},i) f) = Node (q{where_=t:lf}::ReadQuery, i) f
|
||||
|
||||
addLogicTree :: (EmbedPath, LogicTree) -> ReadRequest -> ReadRequest
|
||||
addLogicTree = addProperty addLogicTreeToNode
|
||||
|
||||
addProperty :: (a -> ReadRequest -> ReadRequest) -> (EmbedPath, a) -> ReadRequest -> ReadRequest
|
||||
addProperty f ([], a) rr = f a rr
|
||||
addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
|
||||
case pathNode of
|
||||
Nothing -> Node rn forest -- the property is silenty dropped in the Request does not contain the required path
|
||||
Just tn -> Node rn (addProperty f (remainingPath, a) tn:delete tn forest)
|
||||
where
|
||||
pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
|
||||
|
||||
mutateRequest :: ApiRequest -> TableName -> S.Set FieldName -> [FieldName] -> [FieldName] -> Either Response MutateRequest
|
||||
mutateRequest apiRequest tName cols pkCols fldNames = mapLeft errorResponseFor $
|
||||
case action of
|
||||
ActionCreate -> Right $ Insert tName cols ((,) <$> iPreferResolution apiRequest <*> Just pkCols) [] returnings
|
||||
ActionUpdate -> Update tName cols <$> combinedLogic <*> pure returnings
|
||||
ActionSingleUpsert ->
|
||||
(\flts ->
|
||||
if null (iLogic apiRequest) &&
|
||||
S.fromList (fst <$> iFilters apiRequest) == S.fromList pkCols &&
|
||||
not (null (S.fromList pkCols)) &&
|
||||
all (\case
|
||||
Filter _ (OpExpr False (Op "eq" _)) -> True
|
||||
_ -> False) flts
|
||||
then Insert tName cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
|
||||
else
|
||||
Left InvalidFilters) =<< filters
|
||||
ActionDelete -> Delete tName <$> combinedLogic <*> pure returnings
|
||||
_ -> Left UnsupportedVerb
|
||||
where
|
||||
action = iAction apiRequest
|
||||
returnings = if iPreferRepresentation apiRequest == None then [] else fldNames
|
||||
filters = map snd <$> mapM pRequestFilter mutateFilters
|
||||
logic = map snd <$> mapM pRequestLogicTree logicFilters
|
||||
combinedLogic = foldr addFilterToLogicForest <$> logic <*> filters
|
||||
-- update/delete filters can be only on the root table
|
||||
(mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)
|
||||
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
|
||||
|
||||
fieldNames :: ReadRequest -> [FieldName]
|
||||
fieldNames (Node (sel, _) forest) =
|
||||
map (fst . view _1) (select sel) ++ map colName fks
|
||||
where
|
||||
fks = concatMap (fromMaybe [] . f) forest
|
||||
f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _, _, _)) _) = Just cols
|
||||
f _ = Nothing
|
||||
|
||||
-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
|
||||
-- they are later concatenated with AND in the QueryBuilder
|
||||
addFilterToLogicForest :: Filter -> [LogicTree] -> [LogicTree]
|
||||
addFilterToLogicForest flt lf = Stmnt flt : lf
|
||||
@@ -0,0 +1,805 @@
|
||||
{-|
|
||||
Module : PostgREST.DbStructure
|
||||
Description : PostgREST schema cache
|
||||
|
||||
This module contains queries that target PostgreSQL system catalogs, these are used to build the schema cache(DbStructure).
|
||||
|
||||
The schema cache is necessary for resource embedding, foreign keys are used for inferring the relationships between tables.
|
||||
|
||||
These queries are executed once at startup or when PostgREST is reloaded.
|
||||
-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeSynonymInstances #-}
|
||||
module PostgREST.DbStructure (
|
||||
getDbStructure
|
||||
, accessibleTables
|
||||
, accessibleProcs
|
||||
, schemaDescription
|
||||
, getPgVersion
|
||||
) where
|
||||
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Data.List as L
|
||||
import qualified Data.Text as T
|
||||
import qualified Hasql.Decoders as HD
|
||||
import qualified Hasql.Encoders as HE
|
||||
import qualified Hasql.Session as H
|
||||
import qualified Hasql.Statement as H
|
||||
import qualified Hasql.Transaction as HT
|
||||
|
||||
import Data.Set as S (fromList)
|
||||
import Data.Text (breakOn, dropAround, split,
|
||||
splitOn, strip)
|
||||
import GHC.Exts (groupWith)
|
||||
import Text.InterpolatedString.Perl6 (q, qc)
|
||||
import Unsafe (unsafeHead)
|
||||
|
||||
import Control.Applicative
|
||||
|
||||
import PostgREST.Types
|
||||
import Protolude
|
||||
|
||||
column :: HD.Value a -> HD.Row a
|
||||
column = HD.column . HD.nonNullable
|
||||
|
||||
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
|
||||
nullableColumn = HD.column . HD.nullable
|
||||
|
||||
element :: HD.Value a -> HD.Array a
|
||||
element = HD.element . HD.nonNullable
|
||||
|
||||
param :: HE.Value a -> HE.Params a
|
||||
param = HE.param . HE.nonNullable
|
||||
|
||||
getDbStructure :: Schema -> PgVersion -> HT.Transaction DbStructure
|
||||
getDbStructure schema pgVer = do
|
||||
HT.sql "set local schema ''" -- for getting the fully qualified name(schema.name) of every db object
|
||||
tabs <- HT.statement () allTables
|
||||
cols <- HT.statement schema $ allColumns tabs
|
||||
syns <- HT.statement schema $ allSynonyms cols pgVer
|
||||
childRels <- HT.statement () $ allChildRelations tabs cols
|
||||
keys <- HT.statement () $ allPrimaryKeys tabs
|
||||
procs <- HT.statement schema allProcs
|
||||
|
||||
let rels = addManyToManyRelations . addParentRelations $ addViewChildRelations syns childRels
|
||||
cols' = addForeignKeys rels cols
|
||||
keys' = addViewPrimaryKeys syns keys
|
||||
|
||||
return DbStructure {
|
||||
dbTables = tabs
|
||||
, dbColumns = cols'
|
||||
, dbRelations = rels
|
||||
, dbPrimaryKeys = keys'
|
||||
, dbProcs = procs
|
||||
, pgVersion = pgVer
|
||||
}
|
||||
|
||||
decodeTables :: HD.Result [Table]
|
||||
decodeTables =
|
||||
HD.rowList tblRow
|
||||
where
|
||||
tblRow = Table <$> column HD.text
|
||||
<*> column HD.text
|
||||
<*> nullableColumn HD.text
|
||||
<*> column HD.bool
|
||||
|
||||
decodeColumns :: [Table] -> HD.Result [Column]
|
||||
decodeColumns tables =
|
||||
mapMaybe (columnFromRow tables) <$> HD.rowList colRow
|
||||
where
|
||||
colRow =
|
||||
(,,,,,,,,,,,)
|
||||
<$> column HD.text <*> column HD.text
|
||||
<*> column HD.text <*> nullableColumn HD.text
|
||||
<*> column HD.int4 <*> column HD.bool
|
||||
<*> column HD.text <*> column HD.bool
|
||||
<*> nullableColumn HD.int4
|
||||
<*> nullableColumn HD.int4
|
||||
<*> nullableColumn HD.text
|
||||
<*> nullableColumn HD.text
|
||||
|
||||
decodeRelations :: [Table] -> [Column] -> HD.Result [Relation]
|
||||
decodeRelations tables cols =
|
||||
mapMaybe (relationFromRow tables cols) <$> HD.rowList relRow
|
||||
where
|
||||
relRow = (,,,,,)
|
||||
<$> column HD.text
|
||||
<*> column HD.text
|
||||
<*> column (HD.array (HD.dimension replicateM (element HD.text)))
|
||||
<*> column HD.text
|
||||
<*> column HD.text
|
||||
<*> column (HD.array (HD.dimension replicateM (element HD.text)))
|
||||
|
||||
decodePks :: [Table] -> HD.Result [PrimaryKey]
|
||||
decodePks tables =
|
||||
mapMaybe (pkFromRow tables) <$> HD.rowList pkRow
|
||||
where
|
||||
pkRow = (,,) <$> column HD.text <*> column HD.text <*> column HD.text
|
||||
|
||||
decodeSynonyms :: [Column] -> HD.Result [Synonym]
|
||||
decodeSynonyms cols =
|
||||
mapMaybe (synonymFromRow cols) <$> HD.rowList synRow
|
||||
where
|
||||
synRow = (,,,,,)
|
||||
<$> column HD.text <*> column HD.text
|
||||
<*> column HD.text <*> column HD.text
|
||||
<*> column HD.text <*> column HD.text
|
||||
|
||||
decodeProcs :: HD.Result (M.HashMap Text [ProcDescription])
|
||||
decodeProcs =
|
||||
-- Duplicate rows for a function means they're overloaded, order these by least args according to ProcDescription Ord instance
|
||||
map sort . M.fromListWith (++) . map ((\(x,y) -> (x, [y])) . addName) <$> HD.rowList tblRow
|
||||
where
|
||||
tblRow = ProcDescription
|
||||
<$> column HD.text
|
||||
<*> nullableColumn HD.text
|
||||
<*> (parseArgs <$> column HD.text)
|
||||
<*> (parseRetType
|
||||
<$> column HD.text
|
||||
<*> column HD.text
|
||||
<*> column HD.bool
|
||||
<*> column HD.char)
|
||||
<*> (parseVolatility <$> column HD.char)
|
||||
|
||||
addName :: ProcDescription -> (Text, ProcDescription)
|
||||
addName pd = (pdName pd, pd)
|
||||
|
||||
parseArgs :: Text -> [PgArg]
|
||||
parseArgs = mapMaybe parseArg . filter (not . isPrefixOf "OUT" . toS) . map strip . split (==',')
|
||||
|
||||
parseArg :: Text -> Maybe PgArg
|
||||
parseArg a =
|
||||
let arg = lastDef "" $ splitOn "INOUT " a
|
||||
(body, def) = breakOn " DEFAULT " arg
|
||||
(name, typ) = breakOn " " body in
|
||||
if T.null typ
|
||||
then Nothing
|
||||
else Just $
|
||||
PgArg (dropAround (== '"') name) (strip typ) (T.null def)
|
||||
|
||||
parseRetType :: Text -> Text -> Bool -> Char -> RetType
|
||||
parseRetType schema name isSetOf typ
|
||||
| isSetOf = SetOf pgType
|
||||
| otherwise = Single pgType
|
||||
where
|
||||
qi = QualifiedIdentifier schema name
|
||||
pgType = case typ of
|
||||
'c' -> Composite qi
|
||||
'p' -> if name == "record" -- Only pg pseudo type that is a row type is 'record'
|
||||
then Composite qi
|
||||
else Scalar qi
|
||||
_ -> Scalar qi -- 'b'ase, 'd'omain, 'e'num, 'r'ange
|
||||
|
||||
parseVolatility :: Char -> ProcVolatility
|
||||
parseVolatility v | v == 'i' = Immutable
|
||||
| v == 's' = Stable
|
||||
| otherwise = Volatile -- only 'v' can happen here
|
||||
|
||||
allProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
|
||||
allProcs = H.Statement (toS procsSqlQuery) (param HE.text) decodeProcs True
|
||||
|
||||
accessibleProcs :: H.Statement Schema (M.HashMap Text [ProcDescription])
|
||||
accessibleProcs = H.Statement (toS sql) (param HE.text) decodeProcs True
|
||||
where
|
||||
sql = procsSqlQuery <> " AND has_function_privilege(p.oid, 'execute')"
|
||||
|
||||
procsSqlQuery :: SqlQuery
|
||||
procsSqlQuery = [q|
|
||||
SELECT p.proname as "proc_name",
|
||||
d.description as "proc_description",
|
||||
pg_get_function_arguments(p.oid) as "args",
|
||||
tn.nspname as "rettype_schema",
|
||||
coalesce(comp.relname, t.typname) as "rettype_name",
|
||||
p.proretset as "rettype_is_setof",
|
||||
t.typtype as "rettype_typ",
|
||||
p.provolatile
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace pn ON pn.oid = p.pronamespace
|
||||
JOIN pg_type t ON t.oid = p.prorettype
|
||||
JOIN pg_namespace tn ON tn.oid = t.typnamespace
|
||||
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
|
||||
LEFT JOIN pg_catalog.pg_description as d on d.objoid = p.oid
|
||||
WHERE pn.nspname = $1
|
||||
|]
|
||||
|
||||
schemaDescription :: H.Statement Schema (Maybe Text)
|
||||
schemaDescription =
|
||||
H.Statement sql (param HE.text) (join <$> HD.rowMaybe (nullableColumn HD.text)) True
|
||||
where
|
||||
sql = [q|
|
||||
select
|
||||
description
|
||||
from
|
||||
pg_catalog.pg_namespace n
|
||||
left join pg_catalog.pg_description d on d.objoid = n.oid
|
||||
where
|
||||
n.nspname = $1 |]
|
||||
|
||||
accessibleTables :: H.Statement Schema [Table]
|
||||
accessibleTables =
|
||||
H.Statement sql (param HE.text) decodeTables True
|
||||
where
|
||||
sql = [q|
|
||||
select
|
||||
n.nspname as table_schema,
|
||||
relname as table_name,
|
||||
d.description as table_description,
|
||||
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
|
||||
left join pg_catalog.pg_description as d on d.objoid = c.oid and d.objsubid = 0
|
||||
where
|
||||
c.relkind in ('v', 'r', 'm', 'f')
|
||||
and n.nspname = $1
|
||||
and (
|
||||
pg_has_role(c.relowner, 'USAGE'::text)
|
||||
or has_table_privilege(c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text)
|
||||
or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES'::text)
|
||||
)
|
||||
order by relname |]
|
||||
|
||||
addForeignKeys :: [Relation] -> [Column] -> [Column]
|
||||
addForeignKeys rels = map addFk
|
||||
where
|
||||
addFk col = col { colFK = fk col }
|
||||
fk col = find (lookupFn col) rels >>= relToFk col
|
||||
lookupFn :: Column -> Relation -> Bool
|
||||
lookupFn c Relation{relColumns=cs, relType=rty} = c `elem` cs && rty==Child
|
||||
relToFk col Relation{relColumns=cols, relFColumns=colsF} = do
|
||||
pos <- L.elemIndex col cols
|
||||
colF <- atMay colsF pos
|
||||
return $ ForeignKey colF
|
||||
|
||||
{-
|
||||
Adds Views Child Relations based on Synonyms found, the logic is as follows:
|
||||
|
||||
Having a Relation{relTable=t1, relColumns=[c1], relFTable=t2, relFColumns=[c2], relType=Child} represented by:
|
||||
|
||||
t1.c1------t2.c2
|
||||
|
||||
When only having a t1_view.c1 synonym, we need to add a View to Table Child Relation
|
||||
|
||||
t1.c1----t2.c2 t1.c1----------t2.c2
|
||||
-> ________/
|
||||
/
|
||||
t1_view.c1 t1_view.c1
|
||||
|
||||
|
||||
When only having a t2_view.c2 synonym, we need to add a Table to View Child Relation
|
||||
|
||||
t1.c1----t2.c2 t1.c1----------t2.c2
|
||||
-> \________
|
||||
\
|
||||
t2_view.c2 t2_view.c1
|
||||
|
||||
When having t1_view.c1 and a t2_view.c2 synonyms, we need to add a View to View Child Relation in addition to the prior
|
||||
|
||||
t1.c1----t2.c2 t1.c1----------t2.c2
|
||||
-> \________/
|
||||
/ \
|
||||
t1_view.c1 t2_view.c2 t1_view.c1-------t2_view.c1
|
||||
|
||||
The logic for composite pks is similar just need to make sure all the Relation columns have synonyms.
|
||||
-}
|
||||
addViewChildRelations :: [Synonym] -> [Relation] -> [Relation]
|
||||
addViewChildRelations allSyns = concatMap (\rel ->
|
||||
rel : case rel of
|
||||
Relation{relType=Child, relTable, relColumns, relFTable, relFColumns} ->
|
||||
|
||||
let colSynsGroupedByView :: [Column] -> [[Synonym]]
|
||||
colSynsGroupedByView relCols = L.groupBy (\(_, viewCol1) (_, viewCol2) -> colTable viewCol1 == colTable viewCol2) $
|
||||
filter (\(c, _) -> c `elem` relCols) allSyns
|
||||
colsSyns = colSynsGroupedByView relColumns
|
||||
fColsSyns = colSynsGroupedByView relFColumns
|
||||
getView :: [Synonym] -> Table
|
||||
getView = colTable . snd . unsafeHead
|
||||
syns `allSynsOf` cols = S.fromList (fst <$> syns) == S.fromList cols
|
||||
-- Relation is dependent on the order of relColumns and relFColumns to get the join conditions right in the generated query.
|
||||
-- So we need to change the order of the synonyms to match the relColumns
|
||||
-- This could be avoided if the Relation type is improved with a structure that maintains the association of relColumns and relFColumns
|
||||
syns `sortAccordingTo` columns = sortOn (\(k, _) -> L.lookup k $ zip columns [0::Int ..]) syns
|
||||
|
||||
viewTableChild =
|
||||
[ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns)
|
||||
relFTable relFColumns
|
||||
Child Nothing Nothing Nothing
|
||||
| syns <- colsSyns, syns `allSynsOf` relColumns ]
|
||||
|
||||
tableViewChild =
|
||||
[ Relation relTable relColumns
|
||||
(getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns)
|
||||
Child Nothing Nothing Nothing
|
||||
| fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ]
|
||||
|
||||
viewViewChild =
|
||||
[ Relation (getView syns) (snd <$> syns `sortAccordingTo` relColumns)
|
||||
(getView fSyns) (snd <$> fSyns `sortAccordingTo` relFColumns)
|
||||
Child Nothing Nothing Nothing
|
||||
| syns <- colsSyns, syns `allSynsOf` relColumns
|
||||
, fSyns <- fColsSyns, fSyns `allSynsOf` relFColumns ]
|
||||
|
||||
in viewTableChild ++ tableViewChild ++ viewViewChild
|
||||
|
||||
_ -> [])
|
||||
|
||||
addParentRelations :: [Relation] -> [Relation]
|
||||
addParentRelations = concatMap (\rel@(Relation t c ft fc _ _ _ _) -> [rel, Relation ft fc t c Parent Nothing Nothing Nothing])
|
||||
|
||||
addManyToManyRelations :: [Relation] -> [Relation]
|
||||
addManyToManyRelations rels = rels ++ addMirrorRelation (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
|
||||
-- Reference : https://wiki.haskell.org/99_questions/Solutions/26
|
||||
combinations :: Int -> [a] -> [[a]]
|
||||
combinations 0 _ = [ [] ]
|
||||
combinations n xs = [ y:ys | y:xs' <- tails xs
|
||||
, ys <- combinations (n-1) xs']
|
||||
addMirrorRelation = concatMap (\rel@(Relation t c ft fc _ lt lc1 lc2) -> [rel, Relation ft fc t c Many lt lc2 lc1])
|
||||
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
|
||||
|
||||
addViewPrimaryKeys :: [Synonym] -> [PrimaryKey] -> [PrimaryKey]
|
||||
addViewPrimaryKeys syns = concatMap (\pk ->
|
||||
let viewPks = (\(_, viewCol) -> PrimaryKey{pkTable=colTable viewCol, pkName=colName viewCol}) <$>
|
||||
filter (\(col, _) -> colTable col == pkTable pk && colName col == pkName pk) syns in
|
||||
pk : viewPks)
|
||||
|
||||
allTables :: H.Statement () [Table]
|
||||
allTables =
|
||||
H.Statement sql HE.noParams decodeTables True
|
||||
where
|
||||
sql = [q|
|
||||
SELECT
|
||||
n.nspname AS table_schema,
|
||||
c.relname AS table_name,
|
||||
NULL AS table_description,
|
||||
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','f')
|
||||
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||
GROUP BY table_schema, table_name, insertable
|
||||
ORDER BY table_schema, table_name |]
|
||||
|
||||
allColumns :: [Table] -> H.Statement Schema [Column]
|
||||
allColumns tabs =
|
||||
H.Statement sql (param HE.text) (decodeColumns tabs) True
|
||||
where
|
||||
sql = [q|
|
||||
SELECT DISTINCT
|
||||
info.table_schema AS schema,
|
||||
info.table_name AS table_name,
|
||||
info.column_name AS name,
|
||||
info.description AS description,
|
||||
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 pg_catalog to get only Primary and Foreign key columns outside api schema
|
||||
*/
|
||||
WITH key_columns AS (
|
||||
SELECT
|
||||
r.oid AS r_oid,
|
||||
c.oid AS c_oid,
|
||||
n.nspname,
|
||||
c.relname,
|
||||
r.conname,
|
||||
r.contype,
|
||||
unnest(r.conkey) AS conkey
|
||||
FROM
|
||||
pg_catalog.pg_constraint r,
|
||||
pg_catalog.pg_class c,
|
||||
pg_catalog.pg_namespace n
|
||||
WHERE
|
||||
r.contype IN ('f', 'p')
|
||||
AND c.relkind IN ('r', 'v', 'f', 'm')
|
||||
AND r.conrelid = c.oid
|
||||
AND c.relnamespace = n.oid
|
||||
AND n.nspname NOT IN ('pg_catalog', 'information_schema', $1)
|
||||
),
|
||||
/*
|
||||
-- CTE based on information_schema.columns
|
||||
-- changed:
|
||||
-- remove the owner filter
|
||||
-- limit columns to the ones in the api schema or PK/FK columns
|
||||
*/
|
||||
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,
|
||||
d.description::information_schema.sql_identifier AS description,
|
||||
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 format_type(a.atttypid, a.atttypmod)
|
||||
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 format_type(a.atttypid, a.atttypmod)
|
||||
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 key_columns kc ON kc.conkey = a.attnum AND kc.c_oid = a.attrelid
|
||||
LEFT JOIN pg_catalog.pg_description AS d ON d.objoid = a.attrelid and d.objsubid = a.attnum
|
||||
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", 'm'::"char"]))
|
||||
AND (nc.nspname = $1 OR kc.r_oid IS NOT NULL) /*--filter only columns that are FK/PK or in the api schema */
|
||||
/*--AND (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,
|
||||
description,
|
||||
ordinal_position,
|
||||
is_nullable,
|
||||
data_type,
|
||||
is_updatable,
|
||||
character_maximum_length,
|
||||
numeric_precision,
|
||||
column_default,
|
||||
udt_name
|
||||
/*-- FROM information_schema.columns*/
|
||||
FROM columns
|
||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
) AS info
|
||||
LEFT OUTER JOIN (
|
||||
SELECT
|
||||
n.nspname AS s,
|
||||
t.typname AS n,
|
||||
array_agg(e.enumlabel ORDER BY e.enumsortorder) AS vals
|
||||
FROM pg_type t
|
||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||
GROUP BY s,n
|
||||
) AS enum_info ON (info.udt_name = enum_info.n)
|
||||
ORDER BY schema, position |]
|
||||
|
||||
columnFromRow :: [Table] ->
|
||||
(Text, Text, Text,
|
||||
Maybe Text, Int32, Bool,
|
||||
Text, Bool, Maybe Int32,
|
||||
Maybe Int32, Maybe Text, Maybe Text)
|
||||
-> Maybe Column
|
||||
columnFromRow tabs (s, t, n, desc, pos, nul, typ, u, l, p, d, e) = buildColumn <$> table
|
||||
where
|
||||
buildColumn tbl = Column tbl n desc pos nul typ u l p d (parseEnum e) Nothing
|
||||
table = find (\tbl -> tableSchema tbl == s && tableName tbl == t) tabs
|
||||
parseEnum :: Maybe Text -> [Text]
|
||||
parseEnum = maybe [] (split (==','))
|
||||
|
||||
allChildRelations :: [Table] -> [Column] -> H.Statement () [Relation]
|
||||
allChildRelations tabs cols =
|
||||
H.Statement sql HE.noParams (decodeRelations tabs cols) True
|
||||
where
|
||||
sql = [q|
|
||||
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) |]
|
||||
|
||||
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.Statement () [PrimaryKey]
|
||||
allPrimaryKeys tabs =
|
||||
H.Statement sql HE.noParams (decodePks tabs) True
|
||||
where
|
||||
sql = [q|
|
||||
/*
|
||||
-- CTE to replace information_schema.table_constraints to remove owner limit
|
||||
*/
|
||||
WITH tc AS (
|
||||
SELECT current_database()::information_schema.sql_identifier AS constraint_catalog,
|
||||
nc.nspname::information_schema.sql_identifier AS constraint_schema,
|
||||
c.conname::information_schema.sql_identifier AS constraint_name,
|
||||
current_database()::information_schema.sql_identifier AS table_catalog,
|
||||
nr.nspname::information_schema.sql_identifier AS table_schema,
|
||||
r.relname::information_schema.sql_identifier AS table_name,
|
||||
CASE c.contype
|
||||
WHEN 'c'::"char" THEN 'CHECK'::text
|
||||
WHEN 'f'::"char" THEN 'FOREIGN KEY'::text
|
||||
WHEN 'p'::"char" THEN 'PRIMARY KEY'::text
|
||||
WHEN 'u'::"char" THEN 'UNIQUE'::text
|
||||
ELSE NULL::text
|
||||
END::information_schema.character_data AS constraint_type,
|
||||
CASE
|
||||
WHEN c.condeferrable THEN 'YES'::text
|
||||
ELSE 'NO'::text
|
||||
END::information_schema.yes_or_no AS is_deferrable,
|
||||
CASE
|
||||
WHEN c.condeferred THEN 'YES'::text
|
||||
ELSE 'NO'::text
|
||||
END::information_schema.yes_or_no AS initially_deferred
|
||||
FROM pg_namespace nc,
|
||||
pg_namespace nr,
|
||||
pg_constraint c,
|
||||
pg_class r
|
||||
WHERE nc.oid = c.connamespace AND nr.oid = r.relnamespace AND c.conrelid = r.oid AND (c.contype <> ALL (ARRAY['t'::"char", 'x'::"char"])) AND r.relkind = 'r'::"char" AND NOT pg_is_other_temp_schema(nr.oid)
|
||||
/*--AND (pg_has_role(r.relowner, 'USAGE'::text) OR has_table_privilege(r.oid, 'INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR has_any_column_privilege(r.oid, 'INSERT, UPDATE, REFERENCES'::text))*/
|
||||
UNION ALL
|
||||
SELECT current_database()::information_schema.sql_identifier AS constraint_catalog,
|
||||
nr.nspname::information_schema.sql_identifier AS constraint_schema,
|
||||
(((((nr.oid::text || '_'::text) || r.oid::text) || '_'::text) || a.attnum::text) || '_not_null'::text)::information_schema.sql_identifier AS constraint_name,
|
||||
current_database()::information_schema.sql_identifier AS table_catalog,
|
||||
nr.nspname::information_schema.sql_identifier AS table_schema,
|
||||
r.relname::information_schema.sql_identifier AS table_name,
|
||||
'CHECK'::character varying::information_schema.character_data AS constraint_type,
|
||||
'NO'::character varying::information_schema.yes_or_no AS is_deferrable,
|
||||
'NO'::character varying::information_schema.yes_or_no AS initially_deferred
|
||||
FROM pg_namespace nr,
|
||||
pg_class r,
|
||||
pg_attribute a
|
||||
WHERE nr.oid = r.relnamespace AND r.oid = a.attrelid AND a.attnotnull AND a.attnum > 0 AND NOT a.attisdropped AND r.relkind = 'r'::"char" AND NOT pg_is_other_temp_schema(nr.oid)
|
||||
/*--AND (pg_has_role(r.relowner, 'USAGE'::text) OR has_table_privilege(r.oid, 'INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR has_any_column_privilege(r.oid, 'INSERT, UPDATE, REFERENCES'::text))*/
|
||||
),
|
||||
/*
|
||||
-- CTE to replace information_schema.key_column_usage to remove owner limit
|
||||
*/
|
||||
kc AS (
|
||||
SELECT current_database()::information_schema.sql_identifier AS constraint_catalog,
|
||||
ss.nc_nspname::information_schema.sql_identifier AS constraint_schema,
|
||||
ss.conname::information_schema.sql_identifier AS constraint_name,
|
||||
current_database()::information_schema.sql_identifier AS table_catalog,
|
||||
ss.nr_nspname::information_schema.sql_identifier AS table_schema,
|
||||
ss.relname::information_schema.sql_identifier AS table_name,
|
||||
a.attname::information_schema.sql_identifier AS column_name,
|
||||
(ss.x).n::information_schema.cardinal_number AS ordinal_position,
|
||||
CASE
|
||||
WHEN ss.contype = 'f'::"char" THEN information_schema._pg_index_position(ss.conindid, ss.confkey[(ss.x).n])
|
||||
ELSE NULL::integer
|
||||
END::information_schema.cardinal_number AS position_in_unique_constraint
|
||||
FROM pg_attribute a,
|
||||
( SELECT r.oid AS roid,
|
||||
r.relname,
|
||||
r.relowner,
|
||||
nc.nspname AS nc_nspname,
|
||||
nr.nspname AS nr_nspname,
|
||||
c.oid AS coid,
|
||||
c.conname,
|
||||
c.contype,
|
||||
c.conindid,
|
||||
c.confkey,
|
||||
c.confrelid,
|
||||
information_schema._pg_expandarray(c.conkey) AS x
|
||||
FROM pg_namespace nr,
|
||||
pg_class r,
|
||||
pg_namespace nc,
|
||||
pg_constraint c
|
||||
WHERE nr.oid = r.relnamespace AND r.oid = c.conrelid AND nc.oid = c.connamespace AND (c.contype = ANY (ARRAY['p'::"char", 'u'::"char", 'f'::"char"])) AND r.relkind = 'r'::"char" AND NOT pg_is_other_temp_schema(nr.oid)) ss
|
||||
WHERE ss.roid = a.attrelid AND a.attnum = (ss.x).x AND NOT a.attisdropped
|
||||
/*--AND (pg_has_role(ss.relowner, 'USAGE'::text) OR has_column_privilege(ss.roid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'::text))*/
|
||||
)
|
||||
SELECT
|
||||
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') |]
|
||||
|
||||
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] -> PgVersion -> H.Statement Schema [Synonym]
|
||||
allSynonyms cols pgVer =
|
||||
H.Statement sql (param HE.text) (decodeSynonyms cols) True
|
||||
-- query explanation at https://gist.github.com/steve-chavez/7ee0e6590cddafb532e5f00c46275569
|
||||
where
|
||||
subselectRegex :: Text
|
||||
-- "result" appears when the subselect is used inside "case when", see `authors_have_book_in_decade` fixture
|
||||
-- "resno" appears in every other case
|
||||
-- when copying the query into pg make sure you omit one backslash from \\d+, it should be like `\d+` for the regex
|
||||
subselectRegex | pgVer < pgVersion100 = ":subselect {.*?:constraintDeps <>} :location \\d+} :res(no|ult)"
|
||||
| otherwise = ":subselect {.*?:stmt_len 0} :location \\d+} :res(no|ult)"
|
||||
sql = [qc|
|
||||
with
|
||||
views as (
|
||||
select
|
||||
n.nspname as view_schema,
|
||||
c.relname as view_name,
|
||||
r.ev_action as view_definition
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
join pg_rewrite r on r.ev_class = c.oid
|
||||
where (c.relkind in ('v', 'm')) and n.nspname = $1
|
||||
),
|
||||
removed_subselects as(
|
||||
select
|
||||
view_schema, view_name,
|
||||
regexp_replace(view_definition, '{subselectRegex}', '', 'g') as x
|
||||
from views
|
||||
),
|
||||
target_lists as(
|
||||
select
|
||||
view_schema, view_name,
|
||||
regexp_split_to_array(x, 'targetList') as x
|
||||
from removed_subselects
|
||||
),
|
||||
last_target_list_wo_tail as(
|
||||
select
|
||||
view_schema, view_name,
|
||||
(regexp_split_to_array(x[array_upper(x, 1)], ':onConflict'))[1] as x
|
||||
from target_lists
|
||||
),
|
||||
target_entries as(
|
||||
select
|
||||
view_schema, view_name,
|
||||
unnest(regexp_split_to_array(x, 'TARGETENTRY')) as entry
|
||||
from last_target_list_wo_tail
|
||||
),
|
||||
results as(
|
||||
select
|
||||
view_schema, view_name,
|
||||
substring(entry from ':resname (.*?) :') as view_colum_name,
|
||||
substring(entry from ':resorigtbl (.*?) :') as resorigtbl,
|
||||
substring(entry from ':resorigcol (.*?) :') as resorigcol
|
||||
from target_entries
|
||||
)
|
||||
select
|
||||
sch.nspname as table_schema,
|
||||
tbl.relname as table_name,
|
||||
col.attname as table_column_name,
|
||||
res.view_schema,
|
||||
res.view_name,
|
||||
res.view_colum_name
|
||||
from results res
|
||||
join pg_class tbl on tbl.oid::text = res.resorigtbl
|
||||
join pg_attribute col on col.attrelid = tbl.oid and col.attnum::text = res.resorigcol
|
||||
join pg_namespace sch on sch.oid = tbl.relnamespace
|
||||
where resorigtbl <> '0'
|
||||
order by view_schema, view_name, view_colum_name; |]
|
||||
|
||||
synonymFromRow :: [Column] -> (Text,Text,Text,Text,Text,Text) -> Maybe Synonym
|
||||
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
|
||||
|
||||
getPgVersion :: H.Session PgVersion
|
||||
getPgVersion = H.statement () $ H.Statement sql HE.noParams versionRow False
|
||||
where
|
||||
sql = "SELECT current_setting('server_version_num')::integer, current_setting('server_version')"
|
||||
versionRow = HD.singleRow $ PgVersion <$> column HD.int4 <*> column HD.text
|
||||
@@ -1,73 +1,256 @@
|
||||
{-|
|
||||
Module : PostgREST.Error
|
||||
Description : PostgREST error HTTP responses
|
||||
-}
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE TypeSynonymInstances #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
|
||||
module PostgREST.Error (PgError, errResponse) where
|
||||
module PostgREST.Error (
|
||||
errorResponseFor
|
||||
, ApiRequestError(..)
|
||||
, PgError(..)
|
||||
, SimpleError(..)
|
||||
, errorPayload
|
||||
, checkIsFatal
|
||||
, singularityError
|
||||
) where
|
||||
|
||||
|
||||
import Data.Aeson ((.=))
|
||||
import qualified Data.Aeson as JSON
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.String.Utils (replace)
|
||||
import qualified Data.Text as T
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as P
|
||||
import Network.HTTP.Types.Header
|
||||
import qualified Hasql.Pool as P
|
||||
import qualified Hasql.Session as H
|
||||
import qualified Network.HTTP.Types.Status as HT
|
||||
import Network.Wai (Response, responseLBS)
|
||||
|
||||
type PgError = H.SessionError P.Postgres
|
||||
import Data.Aeson ((.=))
|
||||
import Data.Text (unwords)
|
||||
import Network.Wai (Response, responseLBS)
|
||||
import Text.Read (readMaybe)
|
||||
|
||||
errResponse :: PgError -> Response
|
||||
errResponse e = responseLBS (httpStatus e)
|
||||
[(hContentType, "application/json")] (JSON.encode e)
|
||||
import Network.HTTP.Types.Header
|
||||
|
||||
import PostgREST.Types
|
||||
import Protolude
|
||||
|
||||
|
||||
class (JSON.ToJSON a) => PgrstError a where
|
||||
status :: a -> HT.Status
|
||||
headers :: a -> [Header]
|
||||
|
||||
errorPayload :: a -> LByteString
|
||||
errorPayload = JSON.encode
|
||||
|
||||
errorResponseFor :: a -> Response
|
||||
errorResponseFor err = responseLBS (status err) (headers err) $ errorPayload err
|
||||
|
||||
|
||||
|
||||
data ApiRequestError
|
||||
= ActionInappropriate
|
||||
| InvalidRange
|
||||
| InvalidBody ByteString
|
||||
| ParseRequestError Text Text
|
||||
| NoRelationBetween Text Text
|
||||
| InvalidFilters
|
||||
| UnknownRelation -- Unreachable?
|
||||
| UnsupportedVerb -- Unreachable?
|
||||
deriving (Show, Eq)
|
||||
|
||||
instance PgrstError ApiRequestError where
|
||||
status InvalidRange = HT.status416
|
||||
status InvalidFilters = HT.status405
|
||||
status (InvalidBody _) = HT.status400
|
||||
status UnsupportedVerb = HT.status405
|
||||
status UnknownRelation = HT.status404
|
||||
status ActionInappropriate = HT.status405
|
||||
status (ParseRequestError _ _) = HT.status400
|
||||
status (NoRelationBetween _ _) = HT.status400
|
||||
|
||||
headers _ = [toHeader CTApplicationJSON]
|
||||
|
||||
instance JSON.ToJSON ApiRequestError where
|
||||
toJSON (ParseRequestError message details) = JSON.object [
|
||||
"message" .= message, "details" .= details]
|
||||
toJSON ActionInappropriate = JSON.object [
|
||||
"message" .= ("Bad Request" :: Text)]
|
||||
toJSON (InvalidBody errorMessage) = JSON.object [
|
||||
"message" .= (toS errorMessage :: Text)]
|
||||
toJSON InvalidRange = JSON.object [
|
||||
"message" .= ("HTTP Range error" :: Text)]
|
||||
toJSON UnknownRelation = JSON.object [
|
||||
"message" .= ("Unknown relation" :: Text)]
|
||||
toJSON (NoRelationBetween parent child) = JSON.object [
|
||||
"message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)]
|
||||
toJSON UnsupportedVerb = JSON.object [
|
||||
"message" .= ("Unsupported HTTP verb" :: Text)]
|
||||
toJSON InvalidFilters = JSON.object [
|
||||
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
|
||||
|
||||
|
||||
data PgError = PgError Authenticated P.UsageError
|
||||
type Authenticated = Bool
|
||||
|
||||
instance PgrstError PgError where
|
||||
status (PgError authed usageError) = pgErrorStatus authed usageError
|
||||
|
||||
headers err =
|
||||
if status err == HT.status401
|
||||
then [toHeader CTApplicationJSON, ("WWW-Authenticate", "Bearer") :: Header]
|
||||
else [toHeader CTApplicationJSON]
|
||||
|
||||
instance JSON.ToJSON PgError where
|
||||
toJSON (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]
|
||||
toJSON (PgError _ usageError) = JSON.toJSON usageError
|
||||
|
||||
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
|
||||
instance JSON.ToJSON P.UsageError where
|
||||
toJSON (P.ConnectionError e) = JSON.object [
|
||||
"code" .= ("" :: Text),
|
||||
"message" .= ("Database connection error" :: Text),
|
||||
"details" .= (toS $ fromMaybe "" e :: Text)]
|
||||
toJSON (P.SessionError e) = JSON.toJSON e -- H.Error
|
||||
|
||||
instance JSON.ToJSON H.QueryError where
|
||||
toJSON (H.QueryError _ _ e) = JSON.toJSON e
|
||||
|
||||
instance JSON.ToJSON H.CommandError where
|
||||
toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of
|
||||
'P':'T':_ -> JSON.object [
|
||||
"details" .= (fmap toS d :: Maybe Text),
|
||||
"hint" .= (fmap toS h :: Maybe Text)]
|
||||
|
||||
_ -> JSON.object [
|
||||
"code" .= (toS c :: Text),
|
||||
"message" .= (toS m :: Text),
|
||||
"details" .= (fmap toS d :: Maybe Text),
|
||||
"hint" .= (fmap toS h :: Maybe Text)]
|
||||
|
||||
toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [
|
||||
"message" .= (m :: Text)]
|
||||
toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [
|
||||
"message" .= ("Row error: end of input" :: Text),
|
||||
"details" .= ("Attempt to parse more columns than there are in the result" :: Text),
|
||||
"hint" .= (("Row number " <> show i) :: Text)]
|
||||
toJSON (H.ResultError (H.RowError i H.UnexpectedNull)) = JSON.object [
|
||||
"message" .= ("Row error: unexpected null" :: Text),
|
||||
"details" .= ("Attempt to parse a NULL as some value." :: Text),
|
||||
"hint" .= (("Row number " <> show i) :: Text)]
|
||||
toJSON (H.ResultError (H.RowError i (H.ValueError d))) = JSON.object [
|
||||
"message" .= ("Row error: Wrong value parser used" :: Text),
|
||||
"details" .= d,
|
||||
"hint" .= (("Row number " <> show i) :: Text)]
|
||||
toJSON (H.ResultError (H.UnexpectedAmountOfRows i)) = JSON.object [
|
||||
"message" .= ("Unexpected amount of rows" :: Text),
|
||||
"details" .= i]
|
||||
toJSON (H.ClientError d) = JSON.object [
|
||||
"message" .= ("Database client error" :: Text),
|
||||
"details" .= (fmap toS d :: Maybe Text)]
|
||||
|
||||
pgErrorStatus :: Bool -> P.UsageError -> HT.Status
|
||||
pgErrorStatus _ (P.ConnectionError _) = HT.status503
|
||||
pgErrorStatus _ (P.SessionError (H.QueryError _ _ (H.ClientError _))) = HT.status503
|
||||
pgErrorStatus authed (P.SessionError (H.QueryError _ _ (H.ResultError rError))) =
|
||||
case rError of
|
||||
(H.ServerError c m _ _) ->
|
||||
case toS c 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
|
||||
"23503" -> HT.status409 -- foreign_key_violation
|
||||
"23505" -> HT.status409 -- unique_violation
|
||||
'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
|
||||
"P0001" -> HT.status400 -- default code for "raise"
|
||||
'P':'0':_ -> HT.status500 -- PL/pgSQL Error
|
||||
'X':'X':_ -> HT.status500 -- internal Error
|
||||
"42883" -> HT.status404 -- undefined function
|
||||
"42P01" -> HT.status404 -- undefined table
|
||||
"42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege
|
||||
'P':'T':n -> fromMaybe HT.status500 (HT.mkStatus <$> readMaybe n <*> pure m)
|
||||
_ -> HT.status400
|
||||
|
||||
_ -> HT.status500
|
||||
|
||||
checkIsFatal :: PgError -> Maybe Text
|
||||
checkIsFatal (PgError _ (P.ConnectionError e))
|
||||
| isAuthFailureMessage = Just $ toS failureMessage
|
||||
| otherwise = Nothing
|
||||
where isAuthFailureMessage = "FATAL: password authentication failed" `isPrefixOf` toS failureMessage
|
||||
failureMessage = fromMaybe "" e
|
||||
checkIsFatal _ = Nothing
|
||||
|
||||
|
||||
data SimpleError
|
||||
= GucHeadersError
|
||||
| BinaryFieldError ContentType
|
||||
| ConnectionLostError
|
||||
| PutSingletonError
|
||||
| PutMatchingPkError
|
||||
| PutRangeNotAllowedError
|
||||
| PutPayloadIncompleteError
|
||||
| JwtTokenMissing
|
||||
| JwtTokenInvalid Text
|
||||
| SingularityError Integer
|
||||
| ContentTypeError [ByteString]
|
||||
deriving (Show, Eq)
|
||||
|
||||
instance PgrstError SimpleError where
|
||||
status GucHeadersError = HT.status500
|
||||
status (BinaryFieldError _) = HT.status406
|
||||
status ConnectionLostError = HT.status503
|
||||
status PutSingletonError = HT.status400
|
||||
status PutMatchingPkError = HT.status400
|
||||
status PutRangeNotAllowedError = HT.status400
|
||||
status PutPayloadIncompleteError = HT.status400
|
||||
status JwtTokenMissing = HT.status500
|
||||
status (JwtTokenInvalid _) = HT.unauthorized401
|
||||
status (SingularityError _) = HT.status406
|
||||
status (ContentTypeError _) = HT.status415
|
||||
|
||||
headers (SingularityError _) = [toHeader CTSingularJSON]
|
||||
headers (JwtTokenInvalid m) = [toHeader CTApplicationJSON, invalidTokenHeader m]
|
||||
headers _ = [toHeader CTApplicationJSON]
|
||||
|
||||
instance JSON.ToJSON SimpleError where
|
||||
toJSON GucHeadersError = JSON.object [
|
||||
"message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text)]
|
||||
toJSON (BinaryFieldError ct) = JSON.object [
|
||||
"message" .= ((toS (toMime ct) <> " requested but a single column was not selected") :: Text)]
|
||||
toJSON ConnectionLostError = JSON.object [
|
||||
"message" .= ("Database connection lost, retrying the connection." :: Text)]
|
||||
|
||||
toJSON PutSingletonError = JSON.object [
|
||||
"message" .= ("PUT payload must contain a single row" :: Text)]
|
||||
toJSON PutRangeNotAllowedError = JSON.object [
|
||||
"message" .= ("Range header and limit/offset querystring parameters are not allowed for PUT" :: Text)]
|
||||
toJSON PutPayloadIncompleteError = JSON.object [
|
||||
"message" .= ("You must specify all columns in the payload when using PUT" :: Text)]
|
||||
toJSON PutMatchingPkError = JSON.object [
|
||||
"message" .= ("Payload values do not match URL in primary key column(s)" :: Text)]
|
||||
|
||||
toJSON (ContentTypeError cts) = JSON.object [
|
||||
"message" .= ("None of these Content-Types are available: " <> (toS . intercalate ", " . map toS) cts :: Text)]
|
||||
toJSON (SingularityError n) = JSON.object [
|
||||
"message" .= ("JSON object requested, multiple (or no) rows returned" :: Text),
|
||||
"details" .= unwords ["Results contain", show n, "rows,", toS (toMime CTSingularJSON), "requires 1 row"]]
|
||||
|
||||
toJSON JwtTokenMissing = JSON.object [
|
||||
"message" .= ("Server lacks JWT secret" :: Text)]
|
||||
toJSON (JwtTokenInvalid message) = JSON.object [
|
||||
"message" .= (message :: Text)]
|
||||
|
||||
invalidTokenHeader :: Text -> Header
|
||||
invalidTokenHeader m =
|
||||
("WWW-Authenticate", "Bearer error=\"invalid_token\", " <> "error_description=" <> show m)
|
||||
|
||||
singularityError :: (Integral a) => a -> SimpleError
|
||||
singularityError = SingularityError . toInteger
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
module Main where
|
||||
|
||||
|
||||
import PostgREST.PgStructure
|
||||
import PostgREST.Types
|
||||
import Network.Wai
|
||||
|
||||
import PostgREST.App
|
||||
import PostgREST.Error (errResponse)
|
||||
import PostgREST.Middleware
|
||||
|
||||
import Control.Monad (unless)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Data.Functor.Identity
|
||||
import Data.Monoid ((<>))
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Text (Text)
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as P
|
||||
import Network.Wai.Handler.Warp hiding (Connection)
|
||||
import Network.Wai.Middleware.RequestLogger (logStdout)
|
||||
|
||||
import System.IO (BufferMode (..),
|
||||
hSetBuffering, stderr,
|
||||
stdin, stdout)
|
||||
|
||||
import PostgREST.Config (AppConfig (..),
|
||||
prettyVersion,
|
||||
readOptions,
|
||||
minimumPgVersion)
|
||||
|
||||
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
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
hSetBuffering stdout LineBuffering
|
||||
hSetBuffering stdin LineBuffering
|
||||
hSetBuffering stderr NoBuffering
|
||||
|
||||
conf <- readOptions
|
||||
let port = configPort conf
|
||||
|
||||
unless (configSecure conf) $
|
||||
putStrLn "WARNING, running in insecure mode, auth will be in plaintext"
|
||||
unless ("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.ParamSettings (cs $ configDbHost conf)
|
||||
(fromIntegral $ configDbPort conf)
|
||||
(cs $ configDbUser conf)
|
||||
(cs $ configDbPass conf)
|
||||
(cs $ configDbName conf)
|
||||
appSettings = setPort port
|
||||
. setServerName (cs $ "postgrest/" <> prettyVersion)
|
||||
$ defaultSettings
|
||||
middle = logStdout . defaultMiddle (configSecure conf)
|
||||
|
||||
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 (fail . show)
|
||||
(\supported ->
|
||||
unless supported $
|
||||
fail "Cannot run in this PostgreSQL version, PostgREST needs at least 9.2.0"
|
||||
) supportedOrError
|
||||
|
||||
let txSettings = Just (H.ReadCommitted, Just True)
|
||||
metadata <- H.session pool $ H.tx txSettings $ do
|
||||
tabs <- allTables
|
||||
rels <- allRelations
|
||||
cols <- allColumns rels
|
||||
keys <- allPrimaryKeys
|
||||
return (tabs, rels, cols, keys)
|
||||
|
||||
dbstructure <- case metadata of
|
||||
Left e -> fail $ show e
|
||||
Right (tabs, rels, cols, keys) ->
|
||||
return DbStructure {
|
||||
tables=tabs
|
||||
, columns=cols
|
||||
, relations=rels
|
||||
, primaryKeys=keys
|
||||
}
|
||||
|
||||
|
||||
runSettings appSettings $ middle $ \ req respond -> do
|
||||
body <- strictRequestBody req
|
||||
resOrError <- liftIO $ H.session pool $ H.tx txSettings $
|
||||
authenticated conf (app dbstructure conf body) req
|
||||
either (respond . errResponse) respond resOrError
|
||||
@@ -1,105 +1,60 @@
|
||||
{-|
|
||||
Module : PostgREST.Middleware
|
||||
Description : Sets the PostgreSQL GUCs, role, search_path and pre-request function. Validates JWT.
|
||||
-}
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
module PostgREST.Middleware where
|
||||
|
||||
import Data.Maybe (fromMaybe, isNothing)
|
||||
import Data.Monoid
|
||||
import Data.Text
|
||||
import Data.String.Conversions (cs)
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as P
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Hasql.Transaction as H
|
||||
|
||||
import Network.HTTP.Types (RequestHeaders)
|
||||
import Network.HTTP.Types.Header (hAccept, hAuthorization,
|
||||
hLocation)
|
||||
import Network.HTTP.Types.Status (status301, status400, status401,
|
||||
status415)
|
||||
import Network.URI (URI (..), parseURI)
|
||||
import Network.Wai (Application, Request (..),
|
||||
Response, isSecure, rawPathInfo,
|
||||
rawQueryString, requestHeaders,
|
||||
responseLBS)
|
||||
import Network.Wai.Middleware.Cors (cors)
|
||||
import Network.Wai.Middleware.Gzip (def, gzip)
|
||||
import Network.Wai.Middleware.Static (only, staticPolicy)
|
||||
import Network.Wai (Application, Response)
|
||||
import Network.Wai.Middleware.Cors (cors)
|
||||
import Network.Wai.Middleware.Gzip (def, gzip)
|
||||
import Network.Wai.Middleware.Static (only, staticPolicy)
|
||||
|
||||
import Codec.Binary.Base64.String (decode)
|
||||
import PostgREST.App (contentTypeForAccept)
|
||||
import PostgREST.Auth (DbRole, LoginAttempt (..),
|
||||
setRole, setUserId, signInRole,
|
||||
signInWithJWT)
|
||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||
import Crypto.JWT
|
||||
|
||||
import Prelude
|
||||
import PostgREST.ApiRequest (ApiRequest (..))
|
||||
import PostgREST.Auth (JWTAttempt (..))
|
||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||
import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing),
|
||||
errorResponseFor)
|
||||
import PostgREST.QueryBuilder (pgFmtSetLocal, pgFmtSetLocalSearchPath,
|
||||
unquoted)
|
||||
import Protolude
|
||||
|
||||
authenticated :: forall s. AppConfig ->
|
||||
(DbRole -> Request -> H.Tx P.Postgres s Response) ->
|
||||
Request -> H.Tx P.Postgres s Response
|
||||
authenticated conf 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 uid -> if role /= currentRole then runInRole role uid else app currentRole req
|
||||
NoCredentials -> if anon /= currentRole then runInRole anon "" else app currentRole req
|
||||
runWithClaims :: AppConfig -> JWTAttempt ->
|
||||
(ApiRequest -> H.Transaction Response) ->
|
||||
ApiRequest -> H.Transaction Response
|
||||
runWithClaims conf eClaims app req =
|
||||
case eClaims of
|
||||
JWTMissingSecret -> return . errorResponseFor $ JwtTokenMissing
|
||||
JWTInvalid JWTExpired -> return . errorResponseFor . JwtTokenInvalid $ "JWT expired"
|
||||
JWTInvalid e -> return . errorResponseFor . JwtTokenInvalid . show $ e
|
||||
JWTClaims claims -> do
|
||||
H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql ++ appSettingsSql
|
||||
mapM_ H.sql customReqCheck
|
||||
app req
|
||||
where
|
||||
headersSql = pgFmtSetLocal "request.header." <$> iHeaders req
|
||||
cookiesSql = pgFmtSetLocal "request.cookie." <$> iCookies req
|
||||
claimsSql = pgFmtSetLocal "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]
|
||||
appSettingsSql = pgFmtSetLocal mempty <$> configSettings conf
|
||||
setRoleSql = maybeToList $ (\x ->
|
||||
pgFmtSetLocal mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
|
||||
setSearchPathSql = pgFmtSetLocalSearchPath $ configSchema conf : configExtraSearchPath conf
|
||||
-- role claim defaults to anon if not specified in jwt
|
||||
claimsWithRole = M.union claims (M.singleton "role" anon)
|
||||
anon = JSON.String . toS $ configAnonRole conf
|
||||
customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf
|
||||
|
||||
where
|
||||
jwtSecret = cs $ configJwtSecret conf
|
||||
currentRole = cs $ configDbUser conf
|
||||
anon = cs $ configAnonRole conf
|
||||
httpRequesterRole :: RequestHeaders -> H.Tx P.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
|
||||
("Bearer" : jwt : _) ->
|
||||
return $ signInWithJWT jwtSecret jwt
|
||||
_ -> return NoCredentials
|
||||
|
||||
runInRole :: Text -> Text -> H.Tx P.Postgres s Response
|
||||
runInRole r uid = do
|
||||
setUserId uid
|
||||
setRole r
|
||||
app r req
|
||||
|
||||
|
||||
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
|
||||
|
||||
unsupportedAccept :: Application -> Application
|
||||
unsupportedAccept app req respond = do
|
||||
let
|
||||
accept = lookup hAccept $ requestHeaders req
|
||||
if isNothing $ contentTypeForAccept accept
|
||||
then respond $ responseLBS status415 [] "Unsupported Accept header, try: application/json"
|
||||
else app req respond
|
||||
|
||||
defaultMiddle :: Bool -> Application -> Application
|
||||
defaultMiddle secure = (if secure then redirectInsecure else id)
|
||||
. gzip def . cors corsPolicy
|
||||
defaultMiddle :: Application -> Application
|
||||
defaultMiddle =
|
||||
gzip def
|
||||
. cors corsPolicy
|
||||
. staticPolicy (only [("favicon.ico", "static/favicon.ico")])
|
||||
. unsupportedAccept
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
{-|
|
||||
Module : PostgREST.OpenAPI
|
||||
Description : Generates the OpenAPI output
|
||||
-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module PostgREST.OpenAPI (
|
||||
encodeOpenAPI
|
||||
, isMalformedProxyUri
|
||||
, pickProxy
|
||||
) where
|
||||
|
||||
import qualified Data.Set as Set
|
||||
|
||||
import Control.Arrow ((&&&))
|
||||
import Data.Aeson (decode, encode)
|
||||
import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList)
|
||||
import Data.Maybe (fromJust)
|
||||
import Data.String (IsString (..))
|
||||
import Data.Text (append, breakOn, dropWhile, init,
|
||||
intercalate, pack, tail, toLower,
|
||||
unpack)
|
||||
import Network.URI (URI (..), URIAuth (..),
|
||||
isAbsoluteURI, parseURI)
|
||||
|
||||
import Control.Lens
|
||||
import Data.Swagger
|
||||
|
||||
import PostgREST.ApiRequest (ContentType (..))
|
||||
import PostgREST.Config (docsVersion, prettyVersion)
|
||||
import PostgREST.Types (Column (..), ForeignKey (..), PgArg (..),
|
||||
PrimaryKey (..), ProcDescription (..),
|
||||
Proxy (..), Table (..), toMime)
|
||||
import Protolude hiding (Proxy, dropWhile, get,
|
||||
intercalate, (&))
|
||||
|
||||
makeMimeList :: [ContentType] -> MimeList
|
||||
makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs
|
||||
|
||||
toSwaggerType :: Text -> SwaggerType t
|
||||
toSwaggerType "character varying" = SwaggerString
|
||||
toSwaggerType "character" = SwaggerString
|
||||
toSwaggerType "text" = SwaggerString
|
||||
toSwaggerType "boolean" = SwaggerBoolean
|
||||
toSwaggerType "smallint" = SwaggerInteger
|
||||
toSwaggerType "integer" = SwaggerInteger
|
||||
toSwaggerType "bigint" = SwaggerInteger
|
||||
toSwaggerType "numeric" = SwaggerNumber
|
||||
toSwaggerType "real" = SwaggerNumber
|
||||
toSwaggerType "double precision" = SwaggerNumber
|
||||
toSwaggerType _ = SwaggerString
|
||||
|
||||
makeTableDef :: [PrimaryKey] -> (Table, [Column], [Text]) -> (Text, Schema)
|
||||
makeTableDef pks (t, cs, _) =
|
||||
let tn = tableName t in
|
||||
(tn, (mempty :: Schema)
|
||||
& description .~ tableDescription t
|
||||
& type_ .~ SwaggerObject
|
||||
& properties .~ fromList (map (makeProperty pks) cs)
|
||||
& required .~ map colName (filter (not . colNullable) cs))
|
||||
|
||||
makeProperty :: [PrimaryKey] -> Column -> (Text, Referenced Schema)
|
||||
makeProperty pks c = (colName c, Inline s)
|
||||
where
|
||||
e = if null $ colEnum c then Nothing else decode $ encode $ colEnum c
|
||||
fk ForeignKey{fkCol=Column{colTable=Table{tableName=a}, colName=b}} =
|
||||
intercalate "" ["This is a Foreign Key to `", a, ".", b, "`.<fk table='", a, "' column='", b, "'/>"]
|
||||
pk :: Bool
|
||||
pk = any (\p -> pkTable p == colTable c && pkName p == colName c) pks
|
||||
n = catMaybes
|
||||
[ Just "Note:"
|
||||
, if pk then Just "This is a Primary Key.<pk/>" else Nothing
|
||||
, fk <$> colFK c
|
||||
]
|
||||
d =
|
||||
if length n > 1 then
|
||||
Just $ append (maybe "" (`append` "\n\n") $ colDescription c) (intercalate "\n" n)
|
||||
else
|
||||
colDescription c
|
||||
s =
|
||||
(mempty :: Schema)
|
||||
& default_ .~ (decode . toS =<< colDefault c)
|
||||
& description .~ d
|
||||
& enum_ .~ e
|
||||
& format ?~ colType c
|
||||
& maxLength .~ (fromIntegral <$> colMaxLen c)
|
||||
& type_ .~ toSwaggerType (colType c)
|
||||
|
||||
makeProcSchema :: ProcDescription -> Schema
|
||||
makeProcSchema pd =
|
||||
(mempty :: Schema)
|
||||
& description .~ pdDescription pd
|
||||
& type_ .~ SwaggerObject
|
||||
& properties .~ fromList (map makeProcProperty (pdArgs pd))
|
||||
& required .~ map pgaName (filter pgaReq (pdArgs pd))
|
||||
|
||||
makeProcProperty :: PgArg -> (Text, Referenced Schema)
|
||||
makeProcProperty (PgArg n t _) = (n, Inline s)
|
||||
where
|
||||
s = (mempty :: Schema)
|
||||
& type_ .~ toSwaggerType t
|
||||
& format ?~ t
|
||||
|
||||
makePreferParam :: [Text] -> Param
|
||||
makePreferParam ts =
|
||||
(mempty :: Param)
|
||||
& name .~ "Prefer"
|
||||
& description ?~ "Preference"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamHeader
|
||||
& type_ .~ SwaggerString
|
||||
& enum_ .~ decode (encode ts))
|
||||
|
||||
makeProcParam :: ProcDescription -> [Referenced Param]
|
||||
makeProcParam pd =
|
||||
[ Inline $ (mempty :: Param)
|
||||
& name .~ "args"
|
||||
& required ?~ True
|
||||
& schema .~ (ParamBody $ Inline $ makeProcSchema pd)
|
||||
, Ref $ Reference "preferParams"
|
||||
]
|
||||
|
||||
makeParamDefs :: [(Table, [Column], [Text])] -> [(Text, Param)]
|
||||
makeParamDefs ti =
|
||||
[ ("preferParams", makePreferParam ["params=single-object"])
|
||||
, ("preferReturn", makePreferParam ["return=representation", "return=minimal", "return=none"])
|
||||
, ("preferCount", makePreferParam ["count=none"])
|
||||
, ("select", (mempty :: Param)
|
||||
& name .~ "select"
|
||||
& description ?~ "Filtering Columns"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ .~ SwaggerString))
|
||||
, ("order", (mempty :: Param)
|
||||
& name .~ "order"
|
||||
& description ?~ "Ordering"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ .~ SwaggerString))
|
||||
, ("range", (mempty :: Param)
|
||||
& name .~ "Range"
|
||||
& description ?~ "Limiting and Pagination"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamHeader
|
||||
& type_ .~ SwaggerString))
|
||||
, ("rangeUnit", (mempty :: Param)
|
||||
& name .~ "Range-Unit"
|
||||
& description ?~ "Limiting and Pagination"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamHeader
|
||||
& type_ .~ SwaggerString
|
||||
& default_ .~ decode "\"items\""))
|
||||
, ("offset", (mempty :: Param)
|
||||
& name .~ "offset"
|
||||
& description ?~ "Limiting and Pagination"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ .~ SwaggerString))
|
||||
, ("limit", (mempty :: Param)
|
||||
& name .~ "limit"
|
||||
& description ?~ "Limiting and Pagination"
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ .~ SwaggerString))
|
||||
]
|
||||
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) cs
|
||||
| (t, cs, _) <- ti
|
||||
]
|
||||
|
||||
makeObjectBody :: Text -> (Text, Param)
|
||||
makeObjectBody tn =
|
||||
("body." <> tn, (mempty :: Param)
|
||||
& name .~ tn
|
||||
& description ?~ tn
|
||||
& required ?~ False
|
||||
& schema .~ ParamBody (Ref (Reference tn)))
|
||||
|
||||
makeRowFilter :: Text -> Column -> (Text, Param)
|
||||
makeRowFilter tn c =
|
||||
(intercalate "." ["rowFilter", tn, colName c], (mempty :: Param)
|
||||
& name .~ colName c
|
||||
& description .~ colDescription c
|
||||
& required ?~ False
|
||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||
& in_ .~ ParamQuery
|
||||
& type_ .~ SwaggerString
|
||||
& format ?~ colType c))
|
||||
|
||||
makeRowFilters :: Text -> [Column] -> [(Text, Param)]
|
||||
makeRowFilters tn = map (makeRowFilter tn)
|
||||
|
||||
makePathItem :: (Table, [Column], [Text]) -> (FilePath, PathItem)
|
||||
makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
|
||||
where
|
||||
-- Use first line of table description as summary; rest as description (if present)
|
||||
-- We strip leading newlines from description so that users can include a blank line between summary and description
|
||||
(tSum, tDesc) = fmap fst &&& fmap (dropWhile (=='\n') . snd) $
|
||||
breakOn "\n" <$> tableDescription t
|
||||
tOp = (mempty :: Operation)
|
||||
& tags .~ Set.fromList [tn]
|
||||
& summary .~ tSum
|
||||
& description .~ mfilter (/="") tDesc
|
||||
getOp = tOp
|
||||
& parameters .~ map ref (rs <> ["select", "order", "range", "rangeUnit", "offset", "limit", "preferCount"])
|
||||
& at 206 ?~ "Partial Content"
|
||||
& at 200 ?~ Inline ((mempty :: Response)
|
||||
& description .~ "OK"
|
||||
& schema ?~ Inline (mempty
|
||||
& type_ .~ SwaggerArray
|
||||
& items ?~ (SwaggerItemsObject $ Ref $ Reference $ tableName t)
|
||||
)
|
||||
)
|
||||
postOp = tOp
|
||||
& parameters .~ map ref ["body." <> tn, "preferReturn"]
|
||||
& at 201 ?~ "Created"
|
||||
patchOp = tOp
|
||||
& parameters .~ map ref (rs <> ["body." <> tn, "preferReturn"])
|
||||
& at 204 ?~ "No Content"
|
||||
deletOp = tOp
|
||||
& parameters .~ map ref (rs <> ["preferReturn"])
|
||||
& at 204 ?~ "No Content"
|
||||
pr = (mempty :: PathItem) & get ?~ getOp
|
||||
pw = pr & post ?~ postOp & patch ?~ patchOp & delete ?~ deletOp
|
||||
p False = pr
|
||||
p True = pw
|
||||
tn = tableName t
|
||||
rs = [ intercalate "." ["rowFilter", tn, colName c ] | c <- cs ]
|
||||
ref = Ref . Reference
|
||||
|
||||
makeProcPathItem :: ProcDescription -> (FilePath, PathItem)
|
||||
makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe)
|
||||
where
|
||||
-- Use first line of proc description as summary; rest as description (if present)
|
||||
-- We strip leading newlines from description so that users can include a blank line between summary and description
|
||||
(pSum, pDesc) = fmap fst &&& fmap (dropWhile (=='\n') . snd) $
|
||||
breakOn "\n" <$> pdDescription pd
|
||||
postOp = (mempty :: Operation)
|
||||
& summary .~ pSum
|
||||
& description .~ mfilter (/="") pDesc
|
||||
& parameters .~ makeProcParam pd
|
||||
& tags .~ Set.fromList ["(rpc) " <> pdName pd]
|
||||
& produces ?~ makeMimeList [CTApplicationJSON, CTSingularJSON]
|
||||
& at 200 ?~ "OK"
|
||||
pe = (mempty :: PathItem) & post ?~ postOp
|
||||
|
||||
makeRootPathItem :: (FilePath, PathItem)
|
||||
makeRootPathItem = ("/", p)
|
||||
where
|
||||
getOp = (mempty :: Operation)
|
||||
& tags .~ Set.fromList ["Introspection"]
|
||||
& summary ?~ "OpenAPI description (this document)"
|
||||
& produces ?~ makeMimeList [CTOpenAPI, CTApplicationJSON]
|
||||
& at 200 ?~ "OK"
|
||||
pr = (mempty :: PathItem) & get ?~ getOp
|
||||
p = pr
|
||||
|
||||
makePathItems :: [ProcDescription] -> [(Table, [Column], [Text])] -> InsOrdHashMap FilePath PathItem
|
||||
makePathItems pds ti = fromList $ makeRootPathItem :
|
||||
map makePathItem ti ++ map makeProcPathItem pds
|
||||
|
||||
escapeHostName :: Text -> Text
|
||||
escapeHostName "*" = "0.0.0.0"
|
||||
escapeHostName "*4" = "0.0.0.0"
|
||||
escapeHostName "!4" = "0.0.0.0"
|
||||
escapeHostName "*6" = "0.0.0.0"
|
||||
escapeHostName "!6" = "0.0.0.0"
|
||||
escapeHostName h = h
|
||||
|
||||
postgrestSpec :: [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> Maybe Text -> [PrimaryKey] -> Swagger
|
||||
postgrestSpec pds ti (s, h, p, b) sd pks = (mempty :: Swagger)
|
||||
& basePath ?~ unpack b
|
||||
& schemes ?~ [s']
|
||||
& info .~ ((mempty :: Info)
|
||||
& version .~ prettyVersion
|
||||
& title .~ "PostgREST API"
|
||||
& description ?~ d)
|
||||
& externalDocs ?~ ((mempty :: ExternalDocs)
|
||||
& description ?~ "PostgREST Documentation"
|
||||
& url .~ URL ("https://postgrest.org/en/" <> docsVersion <> "/api.html"))
|
||||
& host .~ h'
|
||||
& definitions .~ fromList (map (makeTableDef pks) ti)
|
||||
& parameters .~ fromList (makeParamDefs ti)
|
||||
& paths .~ makePathItems pds ti
|
||||
& produces .~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
& consumes .~ makeMimeList [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||
where
|
||||
s' = if s == "http" then Http else Https
|
||||
h' = Just $ Host (unpack $ escapeHostName h) (Just (fromInteger p))
|
||||
d = fromMaybe "This is a dynamic API generated by PostgREST" sd
|
||||
|
||||
encodeOpenAPI :: [ProcDescription] -> [(Table, [Column], [Text])] -> (Text, Text, Integer, Text) -> Maybe Text -> [PrimaryKey] -> LByteString
|
||||
encodeOpenAPI pds ti uri sd pks = encode $ postgrestSpec pds ti uri sd pks
|
||||
|
||||
{-|
|
||||
Test whether a proxy uri is malformed or not.
|
||||
A valid proxy uri should be an absolute uri without query and user info,
|
||||
only http(s) schemes are valid, port number range is 1-65535.
|
||||
|
||||
For example
|
||||
http://postgrest.com/openapi.json
|
||||
https://postgrest.com:8080/openapi.json
|
||||
-}
|
||||
isMalformedProxyUri :: Maybe Text -> Bool
|
||||
isMalformedProxyUri Nothing = False
|
||||
isMalformedProxyUri (Just uri)
|
||||
| isAbsoluteURI (toS uri) = not $ isUriValid $ toURI uri
|
||||
| otherwise = True
|
||||
|
||||
toURI :: Text -> URI
|
||||
toURI uri = fromJust $ parseURI (toS uri)
|
||||
|
||||
pickProxy :: Maybe Text -> Maybe Proxy
|
||||
pickProxy proxy
|
||||
| isNothing proxy = Nothing
|
||||
-- should never happen
|
||||
-- since the request would have been rejected by the middleware if proxy uri
|
||||
-- is malformed
|
||||
| isMalformedProxyUri proxy = Nothing
|
||||
| otherwise = Just Proxy {
|
||||
proxyScheme = scheme
|
||||
, proxyHost = host'
|
||||
, proxyPort = port''
|
||||
, proxyPath = path'
|
||||
}
|
||||
where
|
||||
uri = toURI $ fromJust proxy
|
||||
scheme = init $ toLower $ pack $ uriScheme uri
|
||||
path URI {uriPath = ""} = "/"
|
||||
path URI {uriPath = p} = p
|
||||
path' = pack $ path uri
|
||||
authority = fromJust $ uriAuthority uri
|
||||
host' = pack $ uriRegName authority
|
||||
port' = uriPort authority
|
||||
readPort = fromMaybe 80 . readMaybe
|
||||
port'' :: Integer
|
||||
port'' = case (port', scheme) of
|
||||
("", "http") -> 80
|
||||
("", "https") -> 443
|
||||
_ -> readPort $ unpack $ tail $ pack port'
|
||||
|
||||
isUriValid:: URI -> Bool
|
||||
isUriValid = fAnd [isSchemeValid, isQueryValid, isAuthorityValid]
|
||||
|
||||
fAnd :: [a -> Bool] -> a -> Bool
|
||||
fAnd fs x = all ($ x) fs
|
||||
|
||||
isSchemeValid :: URI -> Bool
|
||||
isSchemeValid URI {uriScheme = s}
|
||||
| toLower (pack s) == "https:" = True
|
||||
| toLower (pack s) == "http:" = True
|
||||
| otherwise = False
|
||||
|
||||
isQueryValid :: URI -> Bool
|
||||
isQueryValid URI {uriQuery = ""} = True
|
||||
isQueryValid _ = False
|
||||
|
||||
isAuthorityValid :: URI -> Bool
|
||||
isAuthorityValid URI {uriAuthority = a}
|
||||
| isJust a = fAnd [isUserInfoValid, isHostValid, isPortValid] $ fromJust a
|
||||
| otherwise = False
|
||||
|
||||
isUserInfoValid :: URIAuth -> Bool
|
||||
isUserInfoValid URIAuth {uriUserInfo = ""} = True
|
||||
isUserInfoValid _ = False
|
||||
|
||||
isHostValid :: URIAuth -> Bool
|
||||
isHostValid URIAuth {uriRegName = ""} = False
|
||||
isHostValid _ = True
|
||||
|
||||
isPortValid :: URIAuth -> Bool
|
||||
isPortValid URIAuth {uriPort = ""} = True
|
||||
isPortValid URIAuth {uriPort = (':':p)} =
|
||||
case readMaybe p of
|
||||
Just i -> i > (0 :: Integer) && i < 65536
|
||||
Nothing -> False
|
||||
isPortValid _ = False
|
||||
@@ -1,160 +1,268 @@
|
||||
module PostgREST.Parsers
|
||||
( parseGetRequest
|
||||
)
|
||||
where
|
||||
{-|
|
||||
Module : PostgREST.Parsers
|
||||
Description : PostgREST parser combinators
|
||||
|
||||
import Control.Applicative hiding ((<$>))
|
||||
--lines needed for ghc 7.8
|
||||
import Data.Functor ((<$>))
|
||||
import Data.Traversable (traverse)
|
||||
This module is in charge of parsing all the querystring values in an url, e.g. the select, id, order in `/projects?select=id,name&id=eq.1&order=id,name.desc`.
|
||||
-}
|
||||
module PostgREST.Parsers where
|
||||
|
||||
import Control.Monad (join)
|
||||
import Data.List (delete, find)
|
||||
import Data.Maybe
|
||||
import Data.Monoid
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Text (Text)
|
||||
import Data.Tree
|
||||
import Network.Wai (Request, pathInfo, queryString)
|
||||
import PostgREST.Types
|
||||
import Text.ParserCombinators.Parsec hiding (many, (<|>))
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Data.Set as S
|
||||
|
||||
parseGetRequest :: Request -> Either ParseError ApiRequest
|
||||
parseGetRequest httpRequest =
|
||||
foldr addFilter <$> (addOrder <$> apiRequest <*> ord) <*> flts
|
||||
import Control.Monad ((>>))
|
||||
import Data.Either.Combinators (mapLeft)
|
||||
import Data.Foldable (foldl1)
|
||||
import Data.Functor (($>))
|
||||
import Data.List (init, last)
|
||||
import Data.Text (intercalate, replace, strip)
|
||||
import Text.Read (read)
|
||||
|
||||
import Data.Tree
|
||||
import Text.Parsec.Error
|
||||
import Text.ParserCombinators.Parsec hiding (many, (<|>))
|
||||
|
||||
import PostgREST.Error (ApiRequestError (ParseRequestError))
|
||||
import PostgREST.RangeQuery (NonnegRange)
|
||||
import PostgREST.Types
|
||||
import Protolude hiding (intercalate, option, replace, try)
|
||||
|
||||
pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]
|
||||
pRequestSelect selStr =
|
||||
mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
|
||||
|
||||
pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)
|
||||
pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
|
||||
where
|
||||
apiRequest = parse (pRequestSelect rootTableName) ("failed to parse select parameter <<"++selectStr++">>") $ cs selectStr
|
||||
addOrder (Node r f) o = Node r{order=o} f
|
||||
flts = mapM pRequestFilter whereFilters
|
||||
rootTableName = cs $ head $ pathInfo httpRequest -- TODO unsafe head
|
||||
qString = [(cs k, cs <$> v)|(k,v) <- queryString httpRequest]
|
||||
orderStr = join $ lookup "order" qString
|
||||
ord = traverse (parse pOrder ("failed to parse order parameter <<"++fromMaybe "" orderStr++">>")) orderStr
|
||||
selectStr = fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qString --in case the parametre is missing or empty we default to *
|
||||
whereFilters = [ (k, fromJust v) | (k,v) <- qString, k `notElem` ["select", "order"], isJust v ]
|
||||
|
||||
pRequestSelect :: Text -> Parser ApiRequest
|
||||
pRequestSelect rootNodeName = do
|
||||
fieldTree <- pFieldForest
|
||||
return $ foldr treeEntry (Node (Select rootNodeName [] [] [] Nothing Nothing) []) fieldTree
|
||||
where
|
||||
treeEntry :: Tree SelectItem -> ApiRequest -> ApiRequest
|
||||
treeEntry (Node fld@((fn, _),_) fldForest) (Node rNode rForest) =
|
||||
case fldForest of
|
||||
[] -> Node (rNode {fields=fld:fields rNode}) rForest
|
||||
_ -> Node rNode (foldr treeEntry (Node (Select fn [] [] [] Nothing 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
|
||||
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
||||
oper = parse (pOpExpr pSingleVal) ("failed to parse filter (" ++ toS v ++ ")") $ toS v
|
||||
path = fst <$> treePath
|
||||
fld = snd <$> treePath
|
||||
op = fst <$> opVal
|
||||
val = snd <$> opVal
|
||||
|
||||
addFilter :: (Path, Filter) -> ApiRequest -> ApiRequest
|
||||
addFilter ([], flt) (Node rn@(Select {filters=flts}) forest) = Node (rn {filters=flt:flts}) 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)
|
||||
pRequestOrder :: (Text, Text) -> Either ApiRequestError (EmbedPath, [OrderTerm])
|
||||
pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord'
|
||||
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==).mainTable.rootLabel) forst
|
||||
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
||||
path = fst <$> treePath
|
||||
ord' = parse pOrder ("failed to parse order (" ++ toS v ++ ")") $ toS v
|
||||
|
||||
pRequestRange :: (ByteString, NonnegRange) -> Either ApiRequestError (EmbedPath, NonnegRange)
|
||||
pRequestRange (k, v) = mapError $ (,) <$> path <*> pure v
|
||||
where
|
||||
treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k
|
||||
path = fst <$> treePath
|
||||
|
||||
pRequestLogicTree :: (Text, Text) -> Either ApiRequestError (EmbedPath, LogicTree)
|
||||
pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree
|
||||
where
|
||||
path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k
|
||||
embedPath = fst <$> path
|
||||
logicTree = do
|
||||
op <- snd <$> path
|
||||
-- Concat op and v to make pLogicTree argument regular,
|
||||
-- in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)"
|
||||
parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") $ toS (op <> v)
|
||||
|
||||
pRequestColumns :: Maybe Text -> Either ApiRequestError (Maybe (S.Set FieldName))
|
||||
pRequestColumns colStr =
|
||||
case colStr of
|
||||
Just str ->
|
||||
mapError $ Just . S.fromList <$> parse pColumns ("failed to parse columns parameter (" <> toS str <> ")") (toS str)
|
||||
_ -> Right Nothing
|
||||
|
||||
ws :: Parser Text
|
||||
ws = cs <$> many (oneOf " \t")
|
||||
ws = toS <$> many (oneOf " \t")
|
||||
|
||||
lexeme :: Parser a -> Parser a
|
||||
lexeme p = ws *> p <* ws
|
||||
|
||||
pTreePath :: Parser (Path,Field)
|
||||
pTreePath :: Parser (EmbedPath, 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))
|
||||
where
|
||||
|
||||
jp <- option [] pJsonPath
|
||||
return (init p, (last p, jp))
|
||||
|
||||
pFieldForest :: Parser [Tree SelectItem]
|
||||
pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
|
||||
|
||||
pFieldTree :: Parser (Tree SelectItem)
|
||||
pFieldTree = try (Node <$> pSelect <*> ( char '(' *> pFieldForest <* char ')'))
|
||||
<|> Node <$> pSelect <*> pure []
|
||||
where
|
||||
pFieldTree :: Parser (Tree SelectItem)
|
||||
pFieldTree = try (Node <$> pRelationSelect <*> between (char '(') (char ')') pFieldForest) <|>
|
||||
Node <$> pFieldSelect <*> pure []
|
||||
|
||||
pStar :: Parser Text
|
||||
pStar = cs <$> (string "*" *> pure ("*"::String))
|
||||
pStar = toS <$> (string "*" $> ("*"::ByteString))
|
||||
|
||||
pFieldName :: Parser Text
|
||||
pFieldName = cs <$> (many1 (letter <|> digit <|> oneOf "_")
|
||||
<?> "field name (* or [a..z0..9_])")
|
||||
pFieldName =
|
||||
pQuotedValue <|>
|
||||
intercalate "-" . map toS <$> (many1 (letter <|> digit <|> oneOf "_ ") `sepBy1` dash) <?>
|
||||
"field name (* or [a..z0..9_])"
|
||||
where
|
||||
isDash :: GenParser Char st ()
|
||||
isDash = try ( char '-' >> notFollowedBy (char '>') )
|
||||
dash :: Parser Char
|
||||
dash = isDash $> '-'
|
||||
|
||||
pJsonPathStep :: Parser Text
|
||||
pJsonPathStep = cs <$> try (string "->" *> pFieldName)
|
||||
pJsonPath :: Parser JsonPath
|
||||
pJsonPath = many pJsonOperation
|
||||
where
|
||||
pJsonOperation :: Parser JsonOperation
|
||||
pJsonOperation = pJsonArrow <*> pJsonOperand
|
||||
|
||||
pJsonPath :: Parser [Text]
|
||||
pJsonPath = (++) <$> many pJsonPathStep <*> ( (:[]) <$> (string "->>" *> pFieldName) )
|
||||
pJsonArrow =
|
||||
try (string "->>" $> J2Arrow) <|>
|
||||
try (string "->" $> JArrow)
|
||||
|
||||
pJsonOperand =
|
||||
let pJKey = JKey . toS <$> pFieldName
|
||||
pJIdx = JIdx . toS <$> ((:) <$> option '+' (char '-') <*> many1 digit) <* pEnd
|
||||
pEnd = try (void $ lookAhead (string "->")) <|>
|
||||
try (void $ lookAhead (string "::")) <|>
|
||||
try eof in
|
||||
try pJIdx <|> try pJKey
|
||||
|
||||
pField :: Parser Field
|
||||
pField = lexeme $ (,) <$> pFieldName <*> optionMaybe pJsonPath
|
||||
pField = lexeme $ (,) <$> pFieldName <*> option [] pJsonPath
|
||||
|
||||
pSelect :: Parser SelectItem
|
||||
pSelect = lexeme $
|
||||
try ((,) <$> pField <*>((cs <$>) <$> optionMaybe (string "::" *> many letter)) )
|
||||
aliasSeparator :: Parser ()
|
||||
aliasSeparator = char ':' >> notFollowedBy (char ':')
|
||||
|
||||
pRelationSelect :: Parser SelectItem
|
||||
pRelationSelect = lexeme $ try ( do
|
||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||
fld <- pField
|
||||
relationDetail <- optionMaybe (
|
||||
try ( char '+' *> pFieldName ) <|>
|
||||
try ( char '.' *> pFieldName ) -- TODO deprecated, remove in next major version
|
||||
)
|
||||
|
||||
return (fld, Nothing, alias, relationDetail)
|
||||
)
|
||||
|
||||
pFieldSelect :: Parser SelectItem
|
||||
pFieldSelect = lexeme $
|
||||
try (
|
||||
do
|
||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||
fld <- pField
|
||||
cast' <- optionMaybe (string "::" *> many letter)
|
||||
return (fld, toS <$> cast', alias, Nothing)
|
||||
)
|
||||
<|> do
|
||||
s <- pStar
|
||||
return ((s, Nothing), Nothing)
|
||||
return ((s, []), Nothing, Nothing, Nothing)
|
||||
|
||||
pOperator :: Parser Operator
|
||||
pOperator = cs <$> ( try (string "lte") -- has to be before lt
|
||||
<|> try (string "lt")
|
||||
<|> try (string "eq")
|
||||
<|> try (string "gte") -- has to be before gh
|
||||
<|> try (string "gt")
|
||||
<|> try (string "lt")
|
||||
<|> try (string "neq")
|
||||
<|> try (string "like")
|
||||
<|> try (string "ilike")
|
||||
<|> try (string "in")
|
||||
<|> try (string "notin")
|
||||
<|> try (string "is" )
|
||||
<|> try (string "isnot")
|
||||
<|> try (string "@@")
|
||||
<?> "operator (eq, gt, ...)"
|
||||
)
|
||||
pOpExpr :: Parser SingleVal -> Parser OpExpr
|
||||
pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation)) <|> OpExpr False <$> pOperation
|
||||
where
|
||||
pOperation :: Parser Operation
|
||||
pOperation =
|
||||
Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal
|
||||
<|> In <$> (try (string "in" *> pDelimiter) *> pListVal)
|
||||
<|> pFts
|
||||
<?> "operator (eq, gt, ...)"
|
||||
|
||||
pValue :: Parser FValue
|
||||
pValue = VText <$> (cs <$> many anyChar)
|
||||
pFts = do
|
||||
op <- foldl1 (<|>) (try . string . toS <$> ftsOps)
|
||||
lang <- optionMaybe $ try (between (char '(') (char ')') (many (letter <|> digit <|> oneOf "_")))
|
||||
pDelimiter >> Fts (toS op) (toS <$> lang) <$> pSVal
|
||||
|
||||
ops = M.filterWithKey (const . flip notElem ("in":ftsOps)) operators
|
||||
ftsOps = M.keys ftsOperators
|
||||
|
||||
pSingleVal :: Parser SingleVal
|
||||
pSingleVal = toS <$> many anyChar
|
||||
|
||||
pListVal :: Parser ListVal
|
||||
pListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')
|
||||
|
||||
pListElement :: Parser Text
|
||||
pListElement = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> (toS <$> many (noneOf ",)"))
|
||||
|
||||
pQuotedValue :: Parser Text
|
||||
pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"')
|
||||
|
||||
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 ','
|
||||
pOrder = lexeme pOrderTerm `sepBy1` char ','
|
||||
|
||||
pOrderTerm :: Parser OrderTerm
|
||||
pOrderTerm =
|
||||
try ( do
|
||||
c <- pFieldName
|
||||
_ <- pDelimiter
|
||||
d <- string "asc" <|> string "desc"
|
||||
nls <- optionMaybe (pDelimiter *> ( try(string "nullslast" *> pure ("nulls last"::String)) <|> try(string "nullsfirst" *> pure ("nulls first"::String))))
|
||||
return $ OrderTerm (cs c) (cs d) (cs <$> nls)
|
||||
)
|
||||
<|> OrderTerm <$> (cs <$> pFieldName) <*> pure "asc" <*> pure Nothing
|
||||
pOrderTerm = do
|
||||
fld <- pField
|
||||
dir <- optionMaybe $
|
||||
try (pDelimiter *> string "asc" $> OrderAsc) <|>
|
||||
try (pDelimiter *> string "desc" $> OrderDesc)
|
||||
nls <- optionMaybe pNulls <* pEnd <|>
|
||||
pEnd $> Nothing
|
||||
return $ OrderTerm fld dir nls
|
||||
where
|
||||
pNulls = try (pDelimiter *> string "nullsfirst" $> OrderNullsFirst) <|>
|
||||
try (pDelimiter *> string "nullslast" $> OrderNullsLast)
|
||||
pEnd = try (void $ lookAhead (char ',')) <|>
|
||||
try eof
|
||||
|
||||
pLogicTree :: Parser LogicTree
|
||||
pLogicTree = Stmnt <$> try pLogicFilter
|
||||
<|> Expr <$> pNot <*> pLogicOp <*> (lexeme (char '(') *> pLogicTree `sepBy1` lexeme (char ',') <* lexeme (char ')'))
|
||||
where
|
||||
pLogicFilter :: Parser Filter
|
||||
pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal
|
||||
pNot :: Parser Bool
|
||||
pNot = try (string "not" *> pDelimiter $> True)
|
||||
<|> pure False
|
||||
<?> "negation operator (not)"
|
||||
pLogicOp :: Parser LogicOperator
|
||||
pLogicOp = try (string "and" $> And)
|
||||
<|> string "or" $> Or
|
||||
<?> "logic operator (and, or)"
|
||||
|
||||
pLogicSingleVal :: Parser SingleVal
|
||||
pLogicSingleVal = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> try pPgArray <|> (toS <$> many (noneOf ",)"))
|
||||
where
|
||||
pPgArray :: Parser Text
|
||||
pPgArray = do
|
||||
a <- string "{"
|
||||
b <- many (noneOf "{}")
|
||||
c <- string "}"
|
||||
toS <$> pure (a ++ b ++ c)
|
||||
|
||||
pLogicPath :: Parser (EmbedPath, Text)
|
||||
pLogicPath = do
|
||||
path <- pFieldName `sepBy1` pDelimiter
|
||||
let op = last path
|
||||
notOp = "not." <> op
|
||||
return (filter (/= "not") (init path), if "not" `elem` path then notOp else op)
|
||||
|
||||
pColumns :: Parser [FieldName]
|
||||
pColumns = pFieldName `sepBy1` lexeme (char ',')
|
||||
|
||||
mapError :: Either ParseError a -> Either ApiRequestError a
|
||||
mapError = mapLeft translateError
|
||||
where
|
||||
translateError e =
|
||||
ParseRequestError message details
|
||||
where
|
||||
message = show $ errorPos e
|
||||
details = strip $ replace "\n" " " $ toS
|
||||
$ showErrorMessages "or" "unknown parse error" "expecting" "unexpected" "end of input" (errorMessages e)
|
||||
|
||||
-- Used for the config value "role-claim-key"
|
||||
pRoleClaimKey :: Text -> Either ApiRequestError JSPath
|
||||
pRoleClaimKey selStr =
|
||||
mapError $ parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr)
|
||||
|
||||
pJSPath :: Parser JSPath
|
||||
pJSPath = toJSPath <$> (period *> pPath `sepBy` period <* eof)
|
||||
where
|
||||
toJSPath :: [(Text, Maybe Int)] -> JSPath
|
||||
toJSPath = concatMap (\(key, idx) -> JSPKey key : maybeToList (JSPIdx <$> idx))
|
||||
period = char '.' <?> "period (.)"
|
||||
pPath :: Parser (Text, Maybe Int)
|
||||
pPath = (,) <$> pJSPKey <*> optionMaybe pJSPIdx
|
||||
|
||||
pJSPKey :: Parser Text
|
||||
pJSPKey = toS <$> many1 (alphaNum <|> oneOf "_$@") <|> pQuotedValue <?> "attribute name [a..z0..9_$@])"
|
||||
|
||||
pJSPIdx :: Parser Int
|
||||
pJSPIdx = char '[' *> (read <$> many1 digit) <* char ']' <?> "array index [0..n]"
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE MultiWayIf #-}
|
||||
{-# LANGUAGE TypeSynonymInstances #-}
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
|
||||
module PostgREST.PgQuery where
|
||||
|
||||
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Backend as B
|
||||
import qualified Hasql.Postgres as P
|
||||
import PostgREST.RangeQuery
|
||||
import PostgREST.Types (OrderTerm (..), QualifiedIdentifier(..))
|
||||
|
||||
import Control.Monad (join)
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import Data.Functor
|
||||
import qualified Data.HashMap.Strict as H
|
||||
import qualified Data.List as L
|
||||
import Data.Maybe (fromMaybe)
|
||||
import Data.Monoid
|
||||
import Data.Scientific (FPFormat (..), formatScientific,
|
||||
isInteger)
|
||||
import Data.String.Conversions (cs)
|
||||
import qualified Data.Text as T
|
||||
import Data.Vector (empty)
|
||||
import qualified Data.Vector as V
|
||||
import qualified Network.HTTP.Types.URI as Net
|
||||
import Text.Regex.TDFA ((=~))
|
||||
|
||||
import Prelude
|
||||
|
||||
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
|
||||
|
||||
|
||||
limitT :: Maybe NonnegRange -> StatementT
|
||||
limitT r q =
|
||||
q <> B.Stmt (" LIMIT " <> limit <> " OFFSET " <> offset <> " ") empty True
|
||||
where
|
||||
limit = maybe "ALL" (cs . show) $ join $ rangeLimit <$> r
|
||||
offset = cs . show $ fromMaybe 0 $ rangeOffset <$> r
|
||||
|
||||
whereT :: QualifiedIdentifier -> Net.Query -> StatementT
|
||||
whereT table params q =
|
||||
if L.null cols
|
||||
then q
|
||||
else q <> B.Stmt " where " empty True <> conjunction
|
||||
where
|
||||
cols = [ col | col <- params, fst col `notElem` ["order","select"] ]
|
||||
wherePredTable = wherePred table
|
||||
conjunction = mconcat $ L.intersperse andq (map wherePredTable cols)
|
||||
|
||||
withT :: PStmt -> T.Text -> StatementT
|
||||
withT (B.Stmt eq ep epre) v (B.Stmt wq wp wpre) =
|
||||
B.Stmt ("WITH " <> v <> " AS (" <> eq <> ") " <> wq <> " from " <> v)
|
||||
(ep <> wp)
|
||||
(epre && wpre)
|
||||
|
||||
orderT :: [OrderTerm] -> StatementT
|
||||
orderT ts q =
|
||||
if L.null ts
|
||||
then q
|
||||
else q <> B.Stmt " order by " empty True <> clause
|
||||
where
|
||||
clause = mconcat $ L.intersperse commaq (map queryTerm ts)
|
||||
queryTerm :: OrderTerm -> PStmt
|
||||
queryTerm t = B.Stmt
|
||||
(" " <> cs (pgFmtIdent $ otTerm t) <> " "
|
||||
<> cs (otDirection t) <> " "
|
||||
<> maybe "" cs (otNullOrder t) <> " ")
|
||||
empty True
|
||||
|
||||
parentheticT :: StatementT
|
||||
parentheticT s =
|
||||
s { B.stmtTemplate = " (" <> B.stmtTemplate s <> ") " }
|
||||
|
||||
iffNotT :: PStmt -> StatementT
|
||||
iffNotT (B.Stmt aq ap apre) (B.Stmt bq bp bpre) =
|
||||
B.Stmt
|
||||
("WITH aaa AS (" <> aq <> " returning *) " <>
|
||||
bq <> " WHERE NOT EXISTS (SELECT * FROM aaa)")
|
||||
(ap <> bp)
|
||||
(apre && bpre)
|
||||
|
||||
countT :: StatementT
|
||||
countT s =
|
||||
s { B.stmtTemplate = "WITH qqq AS (" <> B.stmtTemplate s <> ") SELECT pg_catalog.count(1) FROM qqq" }
|
||||
|
||||
countRows :: QualifiedIdentifier -> PStmt
|
||||
countRows t = B.Stmt ("select pg_catalog.count(1) from " <> fromQi t) empty True
|
||||
|
||||
countNone :: PStmt
|
||||
countNone = B.Stmt "select null" empty True
|
||||
|
||||
asCsvWithCount :: QualifiedIdentifier -> StatementT
|
||||
asCsvWithCount table = withCount . asCsv table
|
||||
|
||||
asCsv :: QualifiedIdentifier -> StatementT
|
||||
asCsv table s = s {
|
||||
B.stmtTemplate =
|
||||
"(select string_agg(quote_ident(column_name::text), ',') from "
|
||||
<> "(select column_name from information_schema.columns where quote_ident(table_schema) || '.' || table_name = '"
|
||||
<> fromQi table <> "' order by ordinal_position) h) || '\r' || "
|
||||
<> "coalesce(string_agg(substring(t::text, 2, length(t::text) - 2), '\r'), '') from ("
|
||||
<> B.stmtTemplate s <> ") t" }
|
||||
|
||||
asJsonWithCount :: StatementT
|
||||
asJsonWithCount = withCount . asJson
|
||||
|
||||
asJson :: StatementT
|
||||
asJson s = s {
|
||||
B.stmtTemplate =
|
||||
"array_to_json(coalesce(array_agg(row_to_json(t)), '{}'))::character varying from ("
|
||||
<> B.stmtTemplate s <> ") t" }
|
||||
|
||||
withCount :: StatementT
|
||||
withCount s = s { B.stmtTemplate = "pg_catalog.count(t), " <> B.stmtTemplate s }
|
||||
|
||||
asJsonRow :: StatementT
|
||||
asJsonRow s = s { B.stmtTemplate = "row_to_json(t) from (" <> B.stmtTemplate s <> ") t" }
|
||||
|
||||
returningStarT :: StatementT
|
||||
returningStarT s = s { B.stmtTemplate = B.stmtTemplate s <> " RETURNING *" }
|
||||
|
||||
deleteFrom :: QualifiedIdentifier -> PStmt
|
||||
deleteFrom t = B.Stmt ("delete from " <> fromQi t) empty True
|
||||
|
||||
insertInto :: QualifiedIdentifier
|
||||
-> V.Vector T.Text
|
||||
-> V.Vector (V.Vector JSON.Value)
|
||||
-> PStmt
|
||||
insertInto t cols vals
|
||||
| V.null cols = B.Stmt ("insert into " <> fromQi t <> " default values returning *") empty True
|
||||
| otherwise = B.Stmt
|
||||
("insert into " <> fromQi t <> " (" <>
|
||||
T.intercalate ", " (V.toList $ V.map pgFmtIdent cols) <>
|
||||
") values "
|
||||
<> T.intercalate ", "
|
||||
(V.toList $ V.map (\v -> "("
|
||||
<> T.intercalate ", " (V.toList $ V.map insertableValue v)
|
||||
<> ")"
|
||||
) vals
|
||||
)
|
||||
<> " returning row_to_json(" <> fromQi t <> ".*)")
|
||||
empty True
|
||||
|
||||
insertSelect :: QualifiedIdentifier -> [T.Text] -> [JSON.Value] -> PStmt
|
||||
insertSelect t [] _ = B.Stmt
|
||||
("insert into " <> fromQi t <> " default values returning *") empty True
|
||||
insertSelect t cols vals = B.Stmt
|
||||
("insert into " <> fromQi t <> " ("
|
||||
<> T.intercalate ", " (map pgFmtIdent cols)
|
||||
<> ") select "
|
||||
<> T.intercalate ", " (map insertableValue vals))
|
||||
empty True
|
||||
|
||||
update :: QualifiedIdentifier -> [T.Text] -> [JSON.Value] -> PStmt
|
||||
update t cols vals = B.Stmt
|
||||
("update " <> fromQi t <> " set ("
|
||||
<> T.intercalate ", " (map pgFmtIdent cols)
|
||||
<> ") = ("
|
||||
<> T.intercalate ", " (map insertableValue vals)
|
||||
<> ")")
|
||||
empty True
|
||||
|
||||
callProc :: QualifiedIdentifier -> JSON.Object -> PStmt
|
||||
callProc qi params = do
|
||||
let args = T.intercalate "," $ map assignment (H.toList params)
|
||||
B.Stmt ("select * from " <> fromQi qi <> "(" <> args <> ")") empty True
|
||||
where
|
||||
assignment (n,v) = pgFmtIdent n <> ":=" <> insertableValue v
|
||||
|
||||
wherePred :: QualifiedIdentifier -> Net.QueryItem -> PStmt
|
||||
wherePred table (col, predicate) =
|
||||
B.Stmt (notOp <> " " <> pgFmtJsonbPath table (cs col) <> " " <> op <> " " <>
|
||||
if opCode `elem` ["is","isnot"] then whiteList value
|
||||
else cs sqlValue)
|
||||
empty True
|
||||
|
||||
where
|
||||
headPredicate:rest = T.split (=='.') $ cs $ fromMaybe "." predicate
|
||||
hasNot caseTrue caseFalse = if headPredicate == "not" then caseTrue else caseFalse
|
||||
opCode = hasNot (head rest) headPredicate
|
||||
notOp = hasNot headPredicate ""
|
||||
value = hasNot (T.intercalate "." $ tail rest) (T.intercalate "." rest)
|
||||
sqlValue = pgFmtValue opCode value
|
||||
op = pgFmtOperator opCode
|
||||
|
||||
|
||||
whiteList :: T.Text -> T.Text
|
||||
whiteList val = fromMaybe
|
||||
(cs (pgFmtLit val) <> "::unknown ")
|
||||
(L.find ((==) . T.toLower $ val) ["null","true","false"])
|
||||
|
||||
pgFmtValue :: T.Text -> T.Text -> T.Text
|
||||
pgFmtValue opCode value =
|
||||
case opCode of
|
||||
"like" -> unknownLiteral $ T.map star value
|
||||
"ilike" -> unknownLiteral $ T.map star value
|
||||
"in" -> "(" <> T.intercalate ", " (map unknownLiteral $ T.split (==',') value) <> ") "
|
||||
"notin" -> "(" <> T.intercalate ", " (map unknownLiteral $ T.split (==',') value) <> ") "
|
||||
"@@" -> "to_tsquery(" <> unknownLiteral value <> ") "
|
||||
_ -> unknownLiteral value
|
||||
where
|
||||
star c = if c == '*' then '%' else c
|
||||
unknownLiteral = (<> "::unknown ") . pgFmtLit
|
||||
|
||||
pgFmtOperator :: T.Text -> T.Text
|
||||
pgFmtOperator opCode =
|
||||
case opCode of
|
||||
"eq" -> "="
|
||||
"gt" -> ">"
|
||||
"lt" -> "<"
|
||||
"gte" -> ">="
|
||||
"lte" -> "<="
|
||||
"neq" -> "<>"
|
||||
"like"-> "like"
|
||||
"ilike"-> "ilike"
|
||||
"in" -> "in"
|
||||
"notin" -> "not in"
|
||||
"is" -> "is"
|
||||
"isnot" -> "is not"
|
||||
"@@" -> "@@"
|
||||
_ -> "="
|
||||
|
||||
commaq :: PStmt
|
||||
commaq = B.Stmt ", " empty True
|
||||
|
||||
andq :: PStmt
|
||||
andq = B.Stmt " and " empty True
|
||||
|
||||
data JsonbPath =
|
||||
ColIdentifier T.Text
|
||||
| KeyIdentifier T.Text
|
||||
| SingleArrow JsonbPath JsonbPath
|
||||
| DoubleArrow JsonbPath JsonbPath
|
||||
deriving (Show)
|
||||
|
||||
parseJsonbPath :: T.Text -> Maybe JsonbPath
|
||||
parseJsonbPath p =
|
||||
case T.splitOn "->>" p of
|
||||
[a,b] ->
|
||||
let i:is = T.splitOn "->" a in
|
||||
Just $ DoubleArrow
|
||||
(foldl SingleArrow (ColIdentifier i) (map KeyIdentifier is))
|
||||
(KeyIdentifier b)
|
||||
_ -> Nothing
|
||||
|
||||
pgFmtJsonbPath :: QualifiedIdentifier -> T.Text -> T.Text
|
||||
pgFmtJsonbPath table p =
|
||||
pgFmtJsonbPath' $ fromMaybe (ColIdentifier p) (parseJsonbPath p)
|
||||
where
|
||||
pgFmtJsonbPath' (ColIdentifier i) = fromQi table <> "." <> pgFmtIdent i
|
||||
pgFmtJsonbPath' (KeyIdentifier i) = pgFmtLit i
|
||||
pgFmtJsonbPath' (SingleArrow a b) =
|
||||
pgFmtJsonbPath' a <> "->" <> pgFmtJsonbPath' b
|
||||
pgFmtJsonbPath' (DoubleArrow a b) =
|
||||
pgFmtJsonbPath' a <> "->>" <> pgFmtJsonbPath' b
|
||||
|
||||
pgFmtIdent :: T.Text -> T.Text
|
||||
pgFmtIdent x =
|
||||
let escaped = T.replace "\"" "\"\"" (trimNullChars $ cs x) in
|
||||
if (cs escaped :: BS.ByteString) =~ danger
|
||||
then "\"" <> escaped <> "\""
|
||||
else escaped
|
||||
|
||||
where danger = "^$|^[^a-z_]|[^a-z_0-9]" :: BS.ByteString
|
||||
|
||||
pgFmtLit :: T.Text -> T.Text
|
||||
pgFmtLit x =
|
||||
let trimmed = trimNullChars x
|
||||
escaped = "'" <> T.replace "'" "''" trimmed <> "'"
|
||||
slashed = T.replace "\\" "\\\\" escaped in
|
||||
if T.isInfixOf "\\\\" escaped
|
||||
then "E" <> slashed
|
||||
else slashed
|
||||
|
||||
trimNullChars :: T.Text -> T.Text
|
||||
trimNullChars = T.takeWhile (/= '\x0')
|
||||
|
||||
fromQi :: QualifiedIdentifier -> T.Text
|
||||
fromQi t = pgFmtIdent (qiSchema t) <> "." <> pgFmtIdent (qiName t)
|
||||
|
||||
unquoted :: JSON.Value -> T.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
|
||||
|
||||
insertableText :: T.Text -> T.Text
|
||||
insertableText = (<> "::unknown") . pgFmtLit
|
||||
|
||||
insertableValue :: JSON.Value -> T.Text
|
||||
insertableValue JSON.Null = "null"
|
||||
insertableValue v = insertableText $ unquoted v
|
||||
|
||||
paramFilter :: JSON.Value -> T.Text
|
||||
paramFilter JSON.Null = "is.null"
|
||||
paramFilter v = "eq." <> unquoted v
|
||||
@@ -1,264 +0,0 @@
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeSynonymInstances #-}
|
||||
module PostgREST.PgStructure where
|
||||
|
||||
import Control.Applicative
|
||||
import Control.Monad (join)
|
||||
import Data.Functor.Identity
|
||||
import Data.List (elemIndex, find)
|
||||
import Data.Maybe (fromMaybe, isJust, mapMaybe)
|
||||
import Data.Monoid
|
||||
import Data.Text (Text, split)
|
||||
import qualified Hasql as H
|
||||
import qualified Hasql.Postgres as P
|
||||
import PostgREST.PgQuery ()
|
||||
import PostgREST.Types
|
||||
|
||||
import GHC.Exts (groupWith)
|
||||
import Prelude
|
||||
|
||||
|
||||
doesProcExist :: Text -> Text -> H.Tx P.Postgres s Bool
|
||||
doesProcExist schema proc = do
|
||||
row :: Maybe (Identity Int) <- H.maybeEx $ [H.stmt|
|
||||
SELECT 1
|
||||
FROM pg_catalog.pg_namespace n
|
||||
JOIN pg_catalog.pg_proc p
|
||||
ON pronamespace = n.oid
|
||||
WHERE nspname = ?
|
||||
AND proname = ?
|
||||
|] schema proc
|
||||
return $ isJust row
|
||||
|
||||
|
||||
tableFromRow :: (Text, Text, Bool, Maybe Text) -> Table
|
||||
tableFromRow (s, n, i, a) = Table s n i (parseAcl a)
|
||||
where
|
||||
parseAcl :: Maybe Text -> [Text]
|
||||
parseAcl str = fromMaybe [] $ split (==',') <$> str
|
||||
|
||||
columnFromRow :: (Text, Text, Text,
|
||||
Int, Bool, Text,
|
||||
Bool, Maybe Int, Maybe Int,
|
||||
Maybe Text, Maybe Text)
|
||||
-> Column
|
||||
columnFromRow (s, t, n, pos, nul, typ, u, l, p, d, e) =
|
||||
Column s t n pos nul typ u l p d (parseEnum e) Nothing
|
||||
|
||||
where
|
||||
parseEnum :: Maybe Text -> [Text]
|
||||
parseEnum str = fromMaybe [] $ split (==',') <$> str
|
||||
|
||||
|
||||
relationFromRow :: (Text, Text, [Text], Text, [Text]) -> Relation
|
||||
relationFromRow (s, t, cs, ft, fcs) = Relation s t cs ft fcs Child Nothing Nothing Nothing
|
||||
|
||||
pkFromRow :: (Text, Text, Text) -> PrimaryKey
|
||||
pkFromRow (s, t, n) = PrimaryKey s t n
|
||||
|
||||
|
||||
addParentRelation :: Relation -> [Relation] -> [Relation]
|
||||
addParentRelation rel@(Relation s t c ft fc _ _ _ _) rels = Relation s ft fc t c Parent Nothing Nothing Nothing:rel:rels
|
||||
|
||||
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,
|
||||
array_to_string(array_agg(r.rolname), ',') AS acl
|
||||
FROM pg_class c
|
||||
CROSS JOIN pg_roles r
|
||||
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(r.rolname, c.relowner, 'USAGE'::text) OR
|
||||
has_table_privilege(r.rolname, c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'::text) OR
|
||||
has_any_column_privilege(r.rolname, c.oid, 'SELECT, INSERT, UPDATE, REFERENCES'::text) )
|
||||
|
||||
GROUP BY table_schema, table_name, insertable
|
||||
ORDER BY table_schema, table_name
|
||||
|]
|
||||
return $ map tableFromRow rows
|
||||
|
||||
allRelations :: H.Tx P.Postgres s [Relation]
|
||||
allRelations = do
|
||||
rels <- H.listEx $ [H.stmt|
|
||||
WITH table_fk AS (
|
||||
SELECT ns.nspname AS table_schema,
|
||||
tab.relname AS table_name,
|
||||
column_info.cols AS columns,
|
||||
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 ns,
|
||||
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = conrelid) AS tab,
|
||||
LATERAL (SELECT * FROM pg_class WHERE pg_class.oid = confrelid) AS other
|
||||
WHERE confrelid != 0
|
||||
ORDER BY (conrelid, column_info.nums)
|
||||
)
|
||||
|
||||
SELECT * FROM table_fk
|
||||
UNION
|
||||
(
|
||||
SELECT
|
||||
vcu.table_schema,
|
||||
vcu.view_name AS table_name,
|
||||
array_agg(vcu.column_name::text) AS columns,
|
||||
table_fk.foreign_table_name,
|
||||
table_fk.foreign_columns
|
||||
FROM information_schema.view_column_usage as vcu
|
||||
JOIN table_fk ON
|
||||
table_fk.table_schema = vcu.view_schema AND
|
||||
table_fk.table_name = vcu.table_name AND
|
||||
vcu.column_name = ANY (table_fk.columns)
|
||||
WHERE vcu.view_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
AND columns = table_fk.columns
|
||||
GROUP BY vcu.table_schema, vcu.view_name, table_fk.foreign_table_name, table_fk.foreign_columns
|
||||
)
|
||||
UNION
|
||||
(
|
||||
SELECT
|
||||
vcu.view_schema as table_schema,
|
||||
table_fk.table_name,
|
||||
table_fk.columns,
|
||||
vcu.view_name as foreign_table_name,
|
||||
array_agg(vcu.column_name::text) as foreign_columns
|
||||
FROM information_schema.view_column_usage as vcu
|
||||
JOIN table_fk ON
|
||||
table_fk.table_schema = vcu.view_schema AND
|
||||
table_fk.foreign_table_name = vcu.table_name AND
|
||||
vcu.column_name = ANY (table_fk.foreign_columns)
|
||||
WHERE vcu.view_schema NOT IN ('pg_catalog', 'information_schema')
|
||||
AND foreign_columns = table_fk.foreign_columns
|
||||
GROUP BY vcu.view_schema, table_fk.table_name, vcu.view_name, table_fk.columns
|
||||
)
|
||||
|]
|
||||
let simpleRelations = foldr (addParentRelation.relationFromRow) [] rels
|
||||
let links = filter ((==2).length) $ groupWith groupFn $ filter ( (==Child). relType) simpleRelations
|
||||
return $ simpleRelations ++ mapMaybe link2Relation links
|
||||
where
|
||||
groupFn :: Relation -> Text
|
||||
groupFn (Relation{relSchema=s, relTable=t}) = s<>"_"<>t
|
||||
link2Relation [
|
||||
Relation{relSchema=sc, relTable=lt, relColumns=lc1, relFTable=t, relFColumns=c},
|
||||
Relation{ relColumns=lc2, relFTable=ft, relFColumns=fc}
|
||||
] = Just $ Relation sc t c ft fc Many (Just lt) (Just lc1) (Just lc2)
|
||||
link2Relation _ = Nothing
|
||||
|
||||
allColumns :: [Relation] -> H.Tx P.Postgres s [Column]
|
||||
allColumns rels = do
|
||||
cols <- H.listEx $ [H.stmt|
|
||||
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::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 (
|
||||
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 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 $ map (addFK . columnFromRow) cols
|
||||
|
||||
where
|
||||
addFK col = col { colFK = fk col }
|
||||
fk col = join $ relToFk (colName col) <$> find (lookupFn col) rels
|
||||
lookupFn :: Column -> Relation -> Bool
|
||||
lookupFn (Column{colSchema=cs, colTable=ct, colName=cn}) (Relation{relSchema=rs, relTable=rt, relColumns=rc, relType=rty}) =
|
||||
cs==rs && ct==rt && cn `elem` rc && rty==Child
|
||||
lookupFn _ _ = False
|
||||
relToFk cName (Relation{relFTable=t, relColumns=cs, relFColumns=fcs}) = ForeignKey t <$> c
|
||||
where
|
||||
pos = elemIndex cName cs
|
||||
c = (fcs !!) <$> pos
|
||||
|
||||
allPrimaryKeys :: H.Tx P.Postgres s [PrimaryKey]
|
||||
allPrimaryKeys = do
|
||||
pks <- H.listEx $ [H.stmt|
|
||||
WITH table_pk AS (
|
||||
SELECT
|
||||
kc.table_schema,
|
||||
kc.table_name,
|
||||
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 NOT IN ('pg_catalog', 'information_schema')
|
||||
)
|
||||
SELECT table_schema,
|
||||
table_name,
|
||||
column_name
|
||||
FROM table_pk
|
||||
UNION (
|
||||
SELECT
|
||||
vcu.view_schema,
|
||||
vcu.view_name,
|
||||
vcu.column_name
|
||||
FROM information_schema.view_column_usage AS vcu
|
||||
JOIN
|
||||
table_pk ON table_pk.table_schema = vcu.view_schema AND
|
||||
table_pk.table_name = vcu.table_name AND
|
||||
table_pk.column_name = vcu.column_name
|
||||
WHERE vcu.view_schema NOT IN ('pg_catalog','information_schema')
|
||||
)
|
||||
|]
|
||||
return $ map pkFromRow pks
|
||||
@@ -1,165 +1,511 @@
|
||||
module PostgREST.QueryBuilder
|
||||
where
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE FlexibleInstances #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# 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.
|
||||
|
||||
import Control.Error
|
||||
import Data.List (find)
|
||||
import Data.Monoid
|
||||
import Data.Text hiding (filter, find, foldr, head, last, map,
|
||||
null, zipWith)
|
||||
import Control.Applicative
|
||||
import Data.Tree
|
||||
import PostgREST.PgQuery (PStmt, fromQi,
|
||||
orderT, pgFmtIdent, pgFmtLit, pgFmtOperator,
|
||||
pgFmtValue, whiteList)
|
||||
import PostgREST.Types
|
||||
import qualified Data.Vector as V (empty)
|
||||
import qualified Hasql.Backend as B
|
||||
Any function that outputs a SQL fragment should be in this module.
|
||||
-}
|
||||
module PostgREST.QueryBuilder (
|
||||
callProc
|
||||
, createReadStatement
|
||||
, createWriteStatement
|
||||
, pgFmtIdent
|
||||
, pgFmtLit
|
||||
, requestToQuery
|
||||
, requestToCountQuery
|
||||
, unquoted
|
||||
, ResultsWithCount
|
||||
, pgFmtSetLocal
|
||||
, pgFmtSetLocalSearchPath
|
||||
) where
|
||||
|
||||
findRelation :: [Relation] -> Text -> Text -> Text -> Maybe Relation
|
||||
findRelation allRelations s t1 t2 =
|
||||
find (\r -> s == relSchema r && t1 == relTable r && t2 == relFTable r) allRelations
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import qualified Data.Set as S
|
||||
import qualified Data.Text as T (map, null, takeWhile)
|
||||
import qualified Data.Text.Encoding as T
|
||||
import qualified Hasql.Decoders as HD
|
||||
import qualified Hasql.Encoders as HE
|
||||
import qualified Hasql.Statement as H
|
||||
|
||||
addRelations :: Text -> [Relation] -> Maybe ApiRequest -> ApiRequest -> Either Text ApiRequest
|
||||
addRelations schema allRelations parentNode node@(Node query@(Select {mainTable=table}) forest) =
|
||||
case parentNode of
|
||||
Nothing -> Node query{relation=Nothing} <$> updatedForest
|
||||
(Just (Node (Select{mainTable=parentTable}) _)) -> Node <$> (addRel query <$> rel) <*> updatedForest
|
||||
where
|
||||
rel = note ("no relation between " <> table <> " and " <> parentTable)
|
||||
$ findRelation allRelations schema table parentTable
|
||||
<|> findRelation allRelations schema parentTable table
|
||||
addRel :: Query -> Relation -> Query
|
||||
addRel q r = q{relation = Just r}
|
||||
import Data.Scientific (FPFormat (..), formatScientific,
|
||||
isInteger)
|
||||
import Data.Text (intercalate, isInfixOf, replace,
|
||||
toLower, unwords)
|
||||
import Data.Tree (Tree (..))
|
||||
import Text.InterpolatedString.Perl6 (qc)
|
||||
|
||||
import Data.Maybe
|
||||
|
||||
import PostgREST.ApiRequest (PreferRepresentation (..))
|
||||
import PostgREST.RangeQuery (allRange, rangeLimit, rangeOffset)
|
||||
import PostgREST.Types
|
||||
import Protolude hiding (cast, intercalate, replace)
|
||||
|
||||
column :: HD.Value a -> HD.Row a
|
||||
column = HD.column . HD.nonNullable
|
||||
|
||||
nullableColumn :: HD.Value a -> HD.Row (Maybe a)
|
||||
nullableColumn = HD.column . HD.nullable
|
||||
|
||||
element :: HD.Value a -> HD.Array a
|
||||
element = HD.element . HD.nonNullable
|
||||
|
||||
param :: HE.Value a -> HE.Params a
|
||||
param = HE.param . HE.nonNullable
|
||||
|
||||
{-| The generic query result format used by API responses. The location header
|
||||
is represented as a list of strings containing variable bindings like
|
||||
@"k1=eq.42"@, or the empty list if there is no location header.
|
||||
-}
|
||||
type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString)
|
||||
|
||||
standardRow :: HD.Row ResultsWithCount
|
||||
standardRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8
|
||||
<*> column header <*> column HD.bytea
|
||||
where
|
||||
updatedForest = mapM (addRelations schema allRelations (Just node)) forest
|
||||
header = HD.array $ HD.dimension replicateM $ element HD.bytea
|
||||
|
||||
getJoinConditions :: Relation -> [Filter]
|
||||
getJoinConditions (Relation s t cs ft fcs typ lt lc1 lc2) =
|
||||
case typ of
|
||||
Child -> zipWith (toFilter t ft) cs fcs
|
||||
Parent -> zipWith (toFilter t ft) cs fcs
|
||||
Many -> zipWith (toFilter t (fromMaybe "" lt)) cs (fromMaybe [] lc1) ++ zipWith (toFilter ft (fromMaybe "" lt)) fcs (fromMaybe [] lc2)
|
||||
noLocationF :: Text
|
||||
noLocationF = "array[]::text[]"
|
||||
|
||||
{-| Read and Write api requests use a similar response format which includes
|
||||
various record counts and possible location header. This is the decoder
|
||||
for that common type of query.
|
||||
-}
|
||||
decodeStandard :: HD.Result ResultsWithCount
|
||||
decodeStandard =
|
||||
HD.singleRow standardRow
|
||||
|
||||
decodeStandardMay :: HD.Result (Maybe ResultsWithCount)
|
||||
decodeStandardMay =
|
||||
HD.rowMaybe standardRow
|
||||
|
||||
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName ->
|
||||
H.Statement () ResultsWithCount
|
||||
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField =
|
||||
unicodeStatement sql HE.noParams decodeStandard False
|
||||
where
|
||||
sql = [qc|
|
||||
WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols}
|
||||
FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]
|
||||
countResultF = if countTotal then "("<>countQuery<>")" else "null"
|
||||
cols = intercalate ", " [
|
||||
countResultF <> " AS total_result_set",
|
||||
"pg_catalog.count(_postgrest_t) AS page_total",
|
||||
noLocationF <> " AS header",
|
||||
bodyF <> " AS body"
|
||||
]
|
||||
bodyF
|
||||
| asCsv = asCsvF
|
||||
| isSingle = asJsonSingleF
|
||||
| isJust binaryField = asBinaryF $ fromJust binaryField
|
||||
| otherwise = asJsonF
|
||||
|
||||
|
||||
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
|
||||
PreferRepresentation -> [Text] ->
|
||||
H.Statement ByteString (Maybe ResultsWithCount)
|
||||
createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys =
|
||||
unicodeStatement sql (param HE.unknown) decodeStandardMay True
|
||||
|
||||
where
|
||||
sql = case rep of
|
||||
None -> [qc|
|
||||
WITH {sourceCTEName} AS ({mutateQuery})
|
||||
SELECT '', 0, {noLocationF}, '' |]
|
||||
HeadersOnly -> [qc|
|
||||
WITH {sourceCTEName} AS ({mutateQuery})
|
||||
SELECT {cols}
|
||||
FROM (SELECT 1 FROM {sourceCTEName}) _postgrest_t |]
|
||||
Full -> [qc|
|
||||
WITH {sourceCTEName} AS ({mutateQuery})
|
||||
SELECT {cols}
|
||||
FROM ({selectQuery}) _postgrest_t |]
|
||||
|
||||
cols = intercalate ", " [
|
||||
"'' AS total_result_set", -- when updateing it does not make sense
|
||||
"pg_catalog.count(_postgrest_t) AS page_total",
|
||||
if isInsert
|
||||
then unwords [
|
||||
"CASE",
|
||||
"WHEN pg_catalog.count(_postgrest_t) = 1 THEN",
|
||||
"coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")",
|
||||
"ELSE " <> noLocationF,
|
||||
"END AS header"]
|
||||
else noLocationF <> "AS header",
|
||||
if rep == Full
|
||||
then bodyF <> " AS body"
|
||||
else "''"
|
||||
]
|
||||
|
||||
bodyF
|
||||
| asCsv = asCsvF
|
||||
| wantSingle = asJsonSingleF
|
||||
| otherwise = asJsonF
|
||||
|
||||
type ProcResults = (Maybe Int64, Int64, ByteString, ByteString)
|
||||
callProc :: QualifiedIdentifier -> [PgArg] -> Bool -> SqlQuery -> SqlQuery -> Bool ->
|
||||
Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion ->
|
||||
H.Statement ByteString (Maybe ProcResults)
|
||||
callProc qi pgArgs returnsScalar selectQuery countQuery countTotal isSingle paramsAsSingleObject asCsv asBinary binaryField pgVer =
|
||||
unicodeStatement sql (param HE.unknown) decodeProc True
|
||||
where
|
||||
toFilter :: Text -> Text -> FieldName -> FieldName -> Filter
|
||||
toFilter tb ftb c fc = Filter (c, Nothing) "=" (VForeignKey (QualifiedIdentifier s tb) (ForeignKey ftb fc))
|
||||
sql =[qc|
|
||||
WITH
|
||||
{argsRecord},
|
||||
{sourceCTEName} AS (
|
||||
{sourceBody}
|
||||
)
|
||||
SELECT
|
||||
{countResultF} AS total_result_set,
|
||||
pg_catalog.count(_postgrest_t) AS page_total,
|
||||
{bodyF} AS body,
|
||||
{responseHeaders} AS response_headers
|
||||
FROM ({selectQuery}) _postgrest_t;|]
|
||||
|
||||
(argsRecord, args)
|
||||
| paramsAsSingleObject = ("_args_record AS (SELECT NULL)", "$1::json")
|
||||
| null pgArgs = (ignoredBody, "")
|
||||
| otherwise = (
|
||||
unwords [
|
||||
normalizedBody <> ",",
|
||||
"_args_record AS (",
|
||||
"SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <>
|
||||
intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " " <> pgaType a) <$> pgArgs) <> ")",
|
||||
")"]
|
||||
, intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " := _args_record." <> pgFmtIdent (pgaName a)) <$> pgArgs))
|
||||
|
||||
sourceBody :: SqlFragment
|
||||
sourceBody
|
||||
| paramsAsSingleObject || null pgArgs =
|
||||
if returnsScalar
|
||||
then [qc| SELECT {fromQi qi}({args}) |]
|
||||
else [qc| SELECT * FROM {fromQi qi}({args}) |]
|
||||
| otherwise =
|
||||
if returnsScalar
|
||||
then [qc| SELECT {fromQi qi}({args}) FROM _args_record |]
|
||||
else [qc| SELECT _.*
|
||||
FROM _args_record,
|
||||
LATERAL ( SELECT * FROM {fromQi qi}({args}) ) _ |]
|
||||
|
||||
bodyF
|
||||
| returnsScalar = scalarBodyF
|
||||
| isSingle = asJsonSingleF
|
||||
| asCsv = asCsvF
|
||||
| isJust binaryField = asBinaryF $ fromJust binaryField
|
||||
| otherwise = asJsonF
|
||||
|
||||
scalarBodyF
|
||||
| asBinary = asBinaryF _procName
|
||||
| otherwise = unwords [
|
||||
"CASE",
|
||||
"WHEN pg_catalog.count(_postgrest_t) = 1",
|
||||
"THEN (json_agg(_postgrest_t." <> pgFmtIdent _procName <> ")->0)::character varying",
|
||||
"ELSE (json_agg(_postgrest_t." <> pgFmtIdent _procName <> "))::character varying",
|
||||
"END"]
|
||||
|
||||
countResultF = if countTotal then "( "<> countQuery <> ")" else "null::bigint" :: Text
|
||||
_procName = qiName qi
|
||||
responseHeaders =
|
||||
if pgVer >= pgVersion96
|
||||
then "coalesce(nullif(current_setting('response.headers', true), ''), '[]')" :: Text -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
|
||||
else "'[]'" :: Text
|
||||
|
||||
decodeProc = HD.rowMaybe procRow
|
||||
procRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8
|
||||
<*> column HD.bytea <*> column HD.bytea
|
||||
|
||||
pgFmtIdent :: SqlFragment -> SqlFragment
|
||||
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
|
||||
|
||||
pgFmtLit :: SqlFragment -> SqlFragment
|
||||
pgFmtLit x =
|
||||
let trimmed = trimNullChars x
|
||||
escaped = "'" <> replace "'" "''" trimmed <> "'"
|
||||
slashed = replace "\\" "\\\\" escaped in
|
||||
if "\\" `isInfixOf` escaped
|
||||
then "E" <> slashed
|
||||
else slashed
|
||||
|
||||
requestToCountQuery :: Schema -> DbRequest -> SqlQuery
|
||||
requestToCountQuery _ (DbMutate _) = witness
|
||||
requestToCountQuery schema (DbRead (Node (Select{where_=logicForest}, (mainTbl, _, _, _, _)) _)) =
|
||||
unwords [
|
||||
"SELECT pg_catalog.count(*)",
|
||||
"FROM ", fromQi qi,
|
||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest
|
||||
]
|
||||
where
|
||||
qi = removeSourceCTESchema schema mainTbl
|
||||
|
||||
requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery
|
||||
requestToQuery schema isParent (DbRead (Node (Select colSelects tbl tblAlias implJoins logicForest joinConditions_ ordts range, _) forest)) =
|
||||
unwords [
|
||||
"SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
|
||||
"FROM " <> intercalate ", " (tabl : implJs),
|
||||
unwords joins,
|
||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_))
|
||||
`emptyOnFalse` (null logicForest && null joinConditions_),
|
||||
("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts,
|
||||
("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (isParent || range == allRange) ]
|
||||
|
||||
addJoinConditions :: Text -> [Column] -> ApiRequest -> Either Text ApiRequest
|
||||
addJoinConditions schema allColumns (Node query@(Select{relation=r}) forest) =
|
||||
case r of
|
||||
Nothing -> Node updatedQuery <$> updatedForest -- this is the root node
|
||||
Just rel@(Relation{relType=Child}) -> Node (addCond updatedQuery (getJoinConditions rel)) <$> updatedForest
|
||||
Just (Relation{relType=Parent}) -> Node updatedQuery <$> updatedForest
|
||||
Just rel@(Relation{relType=Many, relLTable=(Just linkTable)}) ->
|
||||
Node <$> pure qq <*> updatedForest
|
||||
where
|
||||
q = addCond updatedQuery (getJoinConditions rel)
|
||||
qq = q{joinTables=linkTable:joinTables q}
|
||||
_ -> Left "unknow relation"
|
||||
where
|
||||
-- add parentTable and parentJoinConditions to the query
|
||||
updatedQuery = foldr (flip addCond) (query{joinTables = parentTables ++ joinTables query}) parentJoinConditions
|
||||
where
|
||||
parentJoinConditions = map (getJoinConditions.snd) parents
|
||||
parentTables = map fst parents
|
||||
parents = mapMaybe (getParents.rootLabel) forest
|
||||
getParents qq@(Select{relation=(Just rel@(Relation{relType=Parent}))}) = Just (mainTable qq, rel)
|
||||
getParents _ = Nothing
|
||||
updatedForest = mapM (addJoinConditions schema allColumns) forest
|
||||
addCond q con = q{filters=con ++ filters q}
|
||||
implJs = fromQi . QualifiedIdentifier schema <$> implJoins
|
||||
mainQi = removeSourceCTESchema schema tbl
|
||||
tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias
|
||||
qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias
|
||||
|
||||
requestToCountQuery :: Text -> ApiRequest -> PStmt
|
||||
requestToCountQuery schema (Node (Select mainTbl _ _ conditions _ _) _) =
|
||||
B.Stmt query V.empty True
|
||||
where
|
||||
query = Data.Text.unwords [
|
||||
"SELECT pg_catalog.count(1)",
|
||||
"FROM ", fromQi $ QualifiedIdentifier schema mainTbl,
|
||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition (QualifiedIdentifier schema mainTbl)) localConditions )) `emptyOnNull` localConditions
|
||||
]
|
||||
emptyOnNull val x = if null x then "" else val
|
||||
localConditions = filter fn conditions
|
||||
where
|
||||
fn (Filter{value=VText _}) = True
|
||||
fn (Filter{value=VForeignKey _ _}) = False
|
||||
(joins, selects) = foldr getQueryParts ([],[]) forest
|
||||
|
||||
requestToQuery :: Text -> ApiRequest -> PStmt
|
||||
requestToQuery schema (Node (Select mainTbl colSelects tbls conditions ord _) forest) =
|
||||
orderT (fromMaybe [] ord) query
|
||||
where
|
||||
query = B.Stmt qStr V.empty True
|
||||
qStr = Data.Text.unwords [
|
||||
("WITH " <> intercalate ", " withs) `emptyOnNull` withs,
|
||||
"SELECT ", intercalate ", " (map (pgFmtSelectItem (QualifiedIdentifier schema mainTbl)) colSelects ++ selects),
|
||||
"FROM ", intercalate ", " (map (fromQi . QualifiedIdentifier schema) (mainTbl:tbls)),
|
||||
("WHERE " <> intercalate " AND " ( map (pgFmtCondition (QualifiedIdentifier schema mainTbl) ) conditions )) `emptyOnNull` conditions
|
||||
]
|
||||
emptyOnNull val x = if null x then "" else val
|
||||
(withs, selects) = foldr getQueryParts ([],[]) forest
|
||||
getQueryParts :: Tree Query -> ([Text], [Text]) -> ([Text], [Text])
|
||||
getQueryParts (Node q@(Select{mainTable=table, relation=(Just (Relation {relType=Child}))}) forst) (w,s) = (w,sel:s)
|
||||
getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
|
||||
getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
|
||||
where
|
||||
sel = "("
|
||||
<> "SELECT array_to_json(array_agg(row_to_json("<>table<>"))) "
|
||||
<> "FROM (" <> subquery <> ") " <> table
|
||||
<> ") AS " <> table
|
||||
where (B.Stmt subquery _ _) = requestToQuery schema (Node q forst)
|
||||
|
||||
getQueryParts (Node q@(Select{mainTable=table, relation=(Just (Relation{relType=Parent}))}) forst) (w,s) = (wit:w,sel:s)
|
||||
sel = "COALESCE(("
|
||||
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
|
||||
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table
|
||||
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
|
||||
where subquery = requestToQuery schema False (DbRead (Node n forst))
|
||||
getQueryParts (Node n@(_, (name, Just Relation{relType=Parent,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (joi:j,sel:s)
|
||||
where
|
||||
sel = "row_to_json(" <> table <> ".*) AS "<>table --TODO must be singular
|
||||
wit = table <> " AS ( " <> subquery <> " )"
|
||||
where (B.Stmt subquery _ _) = requestToQuery schema (Node q forst)
|
||||
|
||||
getQueryParts (Node q@(Select{mainTable=table, relation=(Just (Relation {relType=Many}))}) forst) (w,s) = (w,sel:s)
|
||||
aliasOrName = fromMaybe name alias
|
||||
localTableName = pgFmtIdent $ table <> "_" <> aliasOrName
|
||||
sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName
|
||||
joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE "
|
||||
where subquery = requestToQuery schema True (DbRead (Node n forst))
|
||||
getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
|
||||
where
|
||||
sel = "("
|
||||
<> "SELECT array_to_json(array_agg(row_to_json("<>table<>"))) "
|
||||
<> "FROM (" <> subquery <> ") " <> table
|
||||
<> ") AS " <> table
|
||||
where (B.Stmt subquery _ _) = requestToQuery schema (Node q forst)
|
||||
|
||||
-- the following is just to remove the warning
|
||||
sel = "COALESCE (("
|
||||
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
|
||||
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table
|
||||
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias)
|
||||
where subquery = requestToQuery schema False (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 (Select{relation=Nothing}) _) _ = undefined
|
||||
|
||||
pgFmtCondition :: QualifiedIdentifier -> Filter -> Text
|
||||
pgFmtCondition table (Filter (col,jp) ops val) =
|
||||
notOp <> " " <> sqlCol <> " " <> pgFmtOperator opCode <> " " <>
|
||||
if opCode `elem` ["is","isnot"] then whiteList (getInner val) else sqlValue
|
||||
getQueryParts _ _ = witness
|
||||
requestToQuery schema _ (DbMutate (Insert mainTbl iCols onConflct putConditions returnings)) =
|
||||
unwords [
|
||||
"WITH " <> normalizedBody,
|
||||
"INSERT INTO ", fromQi qi, if S.null iCols then " " else "(" <> cols <> ")",
|
||||
unwords [
|
||||
"SELECT " <> cols <> " FROM",
|
||||
"json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ") _",
|
||||
-- Only used for PUT
|
||||
("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier "" "_") <$> putConditions)) `emptyOnFalse` null putConditions],
|
||||
maybe "" (\(oncDo, oncCols) -> (
|
||||
"ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
|
||||
IgnoreDuplicates ->
|
||||
"DO NOTHING"
|
||||
MergeDuplicates ->
|
||||
if S.null iCols
|
||||
then "DO NOTHING"
|
||||
else "DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols)
|
||||
) `emptyOnFalse` null oncCols) onConflct,
|
||||
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings]
|
||||
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 ft fc) -> pgFmtColumn (QualifiedIdentifier s ft) fc
|
||||
qi = QualifiedIdentifier schema mainTbl
|
||||
cols = intercalate ", " $ pgFmtIdent <$> S.toList iCols
|
||||
requestToQuery schema _ (DbMutate (Update mainTbl uCols logicForest returnings)) =
|
||||
if S.null uCols
|
||||
then "WITH " <> ignoredBody <> "SELECT null WHERE false" -- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
|
||||
else
|
||||
unwords [
|
||||
"WITH " <> normalizedBody,
|
||||
"UPDATE " <> fromQi qi <> " SET " <> cols,
|
||||
"FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi qi, ", " <> selectBody <> ")) _ ",
|
||||
("WHERE " <> intercalate " AND " (pgFmtLogicTree qi <$> logicForest)) `emptyOnFalse` null logicForest,
|
||||
("RETURNING " <> intercalate ", " (pgFmtColumn qi <$> returnings)) `emptyOnFalse` null returnings
|
||||
]
|
||||
where
|
||||
qi = QualifiedIdentifier schema mainTbl
|
||||
cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols)
|
||||
requestToQuery schema _ (DbMutate (Delete mainTbl logicForest returnings)) =
|
||||
unwords [
|
||||
"WITH " <> ignoredBody,
|
||||
"DELETE FROM ", fromQi qi,
|
||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest,
|
||||
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
|
||||
]
|
||||
where
|
||||
qi = QualifiedIdentifier schema mainTbl
|
||||
|
||||
pgFmtColumn :: QualifiedIdentifier -> Text -> Text
|
||||
-- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query
|
||||
-- otherwise the query will err with a `could not determine data type of parameter $1`.
|
||||
-- This happens because `unknown` relies on the context to determine the value type.
|
||||
-- The error also happens on raw libpq used with C.
|
||||
ignoredBody :: SqlFragment
|
||||
ignoredBody = "ignored_body AS (SELECT $1::text) "
|
||||
|
||||
-- |
|
||||
-- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads
|
||||
-- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays
|
||||
-- We do this in SQL to avoid processing the JSON in application code
|
||||
normalizedBody :: SqlFragment
|
||||
normalizedBody =
|
||||
unwords [
|
||||
"pgrst_payload AS (SELECT $1::json AS json_data),",
|
||||
"pgrst_body AS (",
|
||||
"SELECT",
|
||||
"CASE WHEN json_typeof(json_data) = 'array'",
|
||||
"THEN json_data",
|
||||
"ELSE json_build_array(json_data)",
|
||||
"END AS val",
|
||||
"FROM pgrst_payload)"]
|
||||
|
||||
selectBody :: SqlFragment
|
||||
selectBody = "(SELECT val FROM pgrst_body)"
|
||||
|
||||
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
|
||||
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl
|
||||
|
||||
unquoted :: JSON.Value -> Text
|
||||
unquoted (JSON.String t) = t
|
||||
unquoted (JSON.Number n) =
|
||||
toS $ formatScientific Fixed (if isInteger n then Just 0 else Nothing) n
|
||||
unquoted (JSON.Bool b) = show b
|
||||
unquoted v = toS $ JSON.encode v
|
||||
|
||||
-- private functions
|
||||
asCsvF :: SqlFragment
|
||||
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
|
||||
where
|
||||
asCsvHeaderF =
|
||||
"(SELECT coalesce(string_agg(a.k, ','), '')" <>
|
||||
" FROM (" <>
|
||||
" SELECT json_object_keys(r)::TEXT as k" <>
|
||||
" FROM ( " <>
|
||||
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
|
||||
" ) s" <>
|
||||
" ) a" <>
|
||||
")"
|
||||
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
|
||||
|
||||
asJsonF :: SqlFragment
|
||||
asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
|
||||
|
||||
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
||||
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
|
||||
|
||||
asBinaryF :: FieldName -> SqlFragment
|
||||
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
|
||||
|
||||
locationF :: [Text] -> SqlFragment
|
||||
locationF pKeys = [qc|(
|
||||
WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
|
||||
SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))
|
||||
FROM data CROSS JOIN json_each_text(data.row) AS json_data
|
||||
{("WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')") `emptyOnFalse` null pKeys}
|
||||
)|]
|
||||
|
||||
fromQi :: QualifiedIdentifier -> SqlFragment
|
||||
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
|
||||
where
|
||||
n = qiName t
|
||||
s = qiSchema t
|
||||
|
||||
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b
|
||||
unicodeStatement = H.Statement . T.encodeUtf8
|
||||
|
||||
emptyOnFalse :: Text -> Bool -> Text
|
||||
emptyOnFalse val cond = if cond then "" else val
|
||||
|
||||
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
|
||||
pgFmtColumn table "*" = fromQi table <> ".*"
|
||||
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
|
||||
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
|
||||
|
||||
pgFmtJsonPath :: Maybe JsonPath -> Text
|
||||
pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x
|
||||
pgFmtJsonPath (Just (x:xs)) = "->" <> pgFmtLit x <> pgFmtJsonPath ( Just xs )
|
||||
pgFmtJsonPath _ = ""
|
||||
pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
|
||||
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
|
||||
|
||||
pgFmtTable :: Table -> Text
|
||||
pgFmtTable Table{tableSchema=s, tableName=n} = fromQi $ QualifiedIdentifier s n
|
||||
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
|
||||
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias
|
||||
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs fName jp alias
|
||||
|
||||
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> Text
|
||||
pgFmtSelectItem table ((c, jp), Nothing) = pgFmtColumn table c <> pgFmtJsonPath jp <> asJsonPath jp
|
||||
pgFmtSelectItem table ((c, jp), Just cast ) = "CAST (" <> pgFmtColumn table c <> pgFmtJsonPath jp <> " AS " <> cast <> " )" <> asJsonPath jp
|
||||
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment
|
||||
pgFmtOrderTerm qi ot = unwords [
|
||||
toS . pgFmtField qi $ otTerm ot,
|
||||
maybe "" show $ otDirection ot,
|
||||
maybe "" show $ otNullOrder ot]
|
||||
|
||||
asJsonPath :: Maybe JsonPath -> Text
|
||||
asJsonPath Nothing = ""
|
||||
asJsonPath (Just xx) = " AS " <> last xx
|
||||
pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
|
||||
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
|
||||
Op op val -> pgFmtFieldOp op <> " " <> case op of
|
||||
"like" -> unknownLiteral (T.map star val)
|
||||
"ilike" -> unknownLiteral (T.map star val)
|
||||
"is" -> whiteList val
|
||||
_ -> unknownLiteral val
|
||||
|
||||
In vals -> pgFmtField table fld <> " " <>
|
||||
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
|
||||
case (&&) (length vals == 1) . T.null <$> headMay vals of
|
||||
Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
|
||||
Just True -> emptyValForIn
|
||||
Nothing -> emptyValForIn
|
||||
|
||||
Fts op lang val ->
|
||||
pgFmtFieldOp op
|
||||
<> "("
|
||||
<> maybe "" ((<> ", ") . pgFmtLit) lang
|
||||
<> unknownLiteral val
|
||||
<> ") "
|
||||
where
|
||||
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
|
||||
sqlOperator o = HM.lookupDefault "=" o operators
|
||||
notOp = if hasNot then "NOT" else ""
|
||||
star c = if c == '*' then '%' else c
|
||||
unknownLiteral = (<> "::unknown ") . pgFmtLit
|
||||
whiteList :: Text -> SqlFragment
|
||||
whiteList v = fromMaybe
|
||||
(toS (pgFmtLit v) <> "::unknown ")
|
||||
(find ((==) . toLower $ v) ["null","true","false"])
|
||||
|
||||
pgFmtJoinCondition :: JoinCondition -> SqlFragment
|
||||
pgFmtJoinCondition (JoinCondition (qi, col1) (QualifiedIdentifier schema fTable, col2)) =
|
||||
pgFmtColumn qi col1 <> " = " <>
|
||||
pgFmtColumn (removeSourceCTESchema schema fTable) col2
|
||||
|
||||
pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment
|
||||
pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> intercalate (" " <> show op <> " ") (pgFmtLogicTree qi <$> forest) <> ")"
|
||||
where notOp = if hasNot then "NOT" else ""
|
||||
pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt
|
||||
|
||||
pgFmtJsonPath :: JsonPath -> SqlFragment
|
||||
pgFmtJsonPath = \case
|
||||
[] -> ""
|
||||
(JArrow x:xs) -> "->" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
|
||||
(J2Arrow x:xs) -> "->>" <> pgFmtJsonOperand x <> pgFmtJsonPath xs
|
||||
where
|
||||
pgFmtJsonOperand (JKey k) = pgFmtLit k
|
||||
pgFmtJsonOperand (JIdx i) = pgFmtLit i <> "::int"
|
||||
|
||||
pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment
|
||||
pgFmtAs _ [] Nothing = ""
|
||||
pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of
|
||||
Just (JKey key) -> " AS " <> pgFmtIdent key
|
||||
Just (JIdx _) -> " AS " <> pgFmtIdent (fromMaybe fName lastKey)
|
||||
-- We get the lastKey because on:
|
||||
-- `select=data->1->mycol->>2`, we need to show the result as [ {"mycol": ..}, {"mycol": ..} ]
|
||||
-- `select=data->3`, we need to show the result as [ {"data": ..}, {"data": ..} ]
|
||||
where lastKey = jVal <$> find (\case JKey{} -> True; _ -> False) (jOp <$> reverse jp)
|
||||
Nothing -> ""
|
||||
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
|
||||
|
||||
pgFmtSetLocal :: Text -> (Text, Text) -> SqlFragment
|
||||
pgFmtSetLocal prefix (k, v) =
|
||||
"SET LOCAL " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
|
||||
|
||||
pgFmtSetLocalSearchPath :: [Text] -> SqlFragment
|
||||
pgFmtSetLocalSearchPath vals =
|
||||
"SET LOCAL search_path = " <> intercalate ", " (pgFmtLit <$> vals) <> ";"
|
||||
|
||||
trimNullChars :: Text -> Text
|
||||
trimNullChars = T.takeWhile (/= '\x0')
|
||||
|
||||
@@ -1,61 +1,72 @@
|
||||
{-|
|
||||
Module : PostgREST.RangeQuery
|
||||
Description : Logic regarding the `Range` header and `limit`, `offset` querystring arguments.
|
||||
-}
|
||||
module PostgREST.RangeQuery (
|
||||
rangeParse
|
||||
, rangeRequested
|
||||
, rangeLimit
|
||||
, rangeOffset
|
||||
, restrictRange
|
||||
, rangeGeq
|
||||
, allRange
|
||||
, NonnegRange
|
||||
) where
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
|
||||
import Control.Applicative
|
||||
import Network.HTTP.Types.Header
|
||||
import PostgREST.Types ()
|
||||
import Data.List (lookup)
|
||||
import Text.Regex.TDFA ((=~))
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import Data.Ranged.Boundaries
|
||||
import Data.Ranged.Ranges
|
||||
import Control.Applicative
|
||||
import Data.Ranged.Boundaries
|
||||
import Data.Ranged.Ranges
|
||||
import Network.HTTP.Types.Header
|
||||
|
||||
import Data.String.Conversions (cs)
|
||||
import Text.Read (readMaybe)
|
||||
import Text.Regex.TDFA ((=~))
|
||||
import Protolude
|
||||
|
||||
import Data.Maybe (fromMaybe, listToMaybe)
|
||||
type NonnegRange = Range Integer
|
||||
|
||||
import Prelude
|
||||
|
||||
type NonnegRange = Range Int
|
||||
|
||||
rangeParse :: BS.ByteString -> Maybe NonnegRange
|
||||
rangeParse :: BS.ByteString -> NonnegRange
|
||||
rangeParse range = do
|
||||
let rangeRegex = "^([0-9]+)-([0-9]*)$" :: BS.ByteString
|
||||
|
||||
parsedRange <- listToMaybe (range =~ rangeRegex :: [[BS.ByteString]])
|
||||
case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of
|
||||
Just parsedRange ->
|
||||
let [_, mLower, mUpper] = readMaybe . toS <$> parsedRange
|
||||
lower = maybe emptyRange rangeGeq mLower
|
||||
upper = maybe allRange rangeLeq mUpper in
|
||||
rangeIntersection lower upper
|
||||
Nothing -> allRange
|
||||
|
||||
let [_, from, to] = readMaybe . cs <$> parsedRange
|
||||
let lower = fromMaybe emptyRange (rangeGeq <$> from)
|
||||
let upper = fromMaybe (rangeGeq 0) (rangeLeq <$> to)
|
||||
rangeRequested :: RequestHeaders -> NonnegRange
|
||||
rangeRequested headers = maybe allRange rangeParse $ lookup hRange headers
|
||||
|
||||
return $ rangeIntersection lower upper
|
||||
restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange
|
||||
restrictRange Nothing r = r
|
||||
restrictRange (Just limit) r =
|
||||
rangeIntersection r $
|
||||
Range BoundaryBelowAll (BoundaryAbove $ rangeOffset r + limit - 1)
|
||||
|
||||
rangeRequested :: RequestHeaders -> Maybe NonnegRange
|
||||
rangeRequested = (rangeParse =<<) . lookup hRange
|
||||
|
||||
rangeLimit :: NonnegRange -> Maybe Int
|
||||
rangeLimit :: NonnegRange -> Maybe Integer
|
||||
rangeLimit range =
|
||||
case [rangeLower range, rangeUpper range] of
|
||||
[BoundaryBelow from, BoundaryAbove to] -> Just (1 + to - from)
|
||||
[BoundaryBelow lower, BoundaryAbove upper] -> Just (1 + upper - lower)
|
||||
_ -> Nothing
|
||||
|
||||
rangeOffset :: NonnegRange -> Int
|
||||
rangeOffset :: NonnegRange -> Integer
|
||||
rangeOffset range =
|
||||
case rangeLower range of
|
||||
BoundaryBelow from -> from
|
||||
_ -> error "range without lower bound" -- should never happen
|
||||
BoundaryBelow lower -> lower
|
||||
_ -> panic "range without lower bound" -- should never happen
|
||||
|
||||
rangeGeq :: Int -> NonnegRange
|
||||
rangeGeq :: Integer -> NonnegRange
|
||||
rangeGeq n =
|
||||
Range (BoundaryBelow n) BoundaryAboveAll
|
||||
|
||||
rangeLeq :: Int -> NonnegRange
|
||||
allRange :: NonnegRange
|
||||
allRange = rangeGeq 0
|
||||
|
||||
rangeLeq :: Integer -> NonnegRange
|
||||
rangeLeq n =
|
||||
Range BoundaryBelowAll (BoundaryAbove n)
|
||||
|
||||
@@ -1,113 +1,447 @@
|
||||
{-|
|
||||
Module : PostgREST.Types
|
||||
Description : PostgREST common types and functions used by the rest of the modules
|
||||
-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
|
||||
module PostgREST.Types where
|
||||
import Data.Text
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Internal as BS (c2w)
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
import qualified Data.HashMap.Strict as M
|
||||
import qualified Data.Set as S
|
||||
import qualified GHC.Show
|
||||
|
||||
import Network.HTTP.Types.Header (Header, hContentType)
|
||||
|
||||
import Data.Tree
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import Data.Aeson
|
||||
|
||||
import PostgREST.RangeQuery (NonnegRange)
|
||||
import Protolude
|
||||
|
||||
-- | Enumeration of currently supported response content types
|
||||
data ContentType = CTApplicationJSON | CTSingularJSON
|
||||
| CTTextCSV | CTTextPlain | CTTextHtml
|
||||
| CTOpenAPI | CTOctetStream
|
||||
| CTAny | CTOther ByteString deriving (Show, Eq)
|
||||
|
||||
-- | Convert from ContentType to a full HTTP Header
|
||||
toHeader :: ContentType -> Header
|
||||
toHeader ct = (hContentType, toMime ct <> "; charset=utf-8")
|
||||
|
||||
-- | Convert from ContentType to a ByteString representing the mime type
|
||||
toMime :: ContentType -> ByteString
|
||||
toMime CTApplicationJSON = "application/json"
|
||||
toMime CTTextCSV = "text/csv"
|
||||
toMime CTTextPlain = "text/plain"
|
||||
toMime CTTextHtml = "text/html"
|
||||
toMime CTOpenAPI = "application/openapi+json"
|
||||
toMime CTSingularJSON = "application/vnd.pgrst.object+json"
|
||||
toMime CTOctetStream = "application/octet-stream"
|
||||
toMime CTAny = "*/*"
|
||||
toMime (CTOther ct) = ct
|
||||
|
||||
-- | Convert from ByteString to ContentType. Warning: discards MIME parameters
|
||||
decodeContentType :: BS.ByteString -> ContentType
|
||||
decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of
|
||||
"application/json" -> CTApplicationJSON
|
||||
"text/csv" -> CTTextCSV
|
||||
"text/plain" -> CTTextPlain
|
||||
"text/html" -> CTTextHtml
|
||||
"application/openapi+json" -> CTOpenAPI
|
||||
"application/vnd.pgrst.object+json" -> CTSingularJSON
|
||||
"application/vnd.pgrst.object" -> CTSingularJSON
|
||||
"application/octet-stream" -> CTOctetStream
|
||||
"*/*" -> CTAny
|
||||
ct' -> CTOther ct'
|
||||
|
||||
-- | ContentTypes that can get a raw/unwrapped response
|
||||
rawContentTypes :: [ContentType]
|
||||
rawContentTypes = [CTOctetStream, CTTextPlain, CTTextHtml]
|
||||
|
||||
data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq
|
||||
instance Show PreferResolution where
|
||||
show MergeDuplicates = "resolution=merge-duplicates"
|
||||
show IgnoreDuplicates = "resolution=ignore-duplicates"
|
||||
|
||||
data DbStructure = DbStructure {
|
||||
tables :: [Table]
|
||||
, columns :: [Column]
|
||||
, relations :: [Relation]
|
||||
, primaryKeys :: [PrimaryKey]
|
||||
}
|
||||
dbTables :: [Table]
|
||||
, dbColumns :: [Column]
|
||||
, dbRelations :: [Relation]
|
||||
, dbPrimaryKeys :: [PrimaryKey]
|
||||
-- ProcDescription is a list because a function can be overloaded
|
||||
, dbProcs :: M.HashMap Text [ProcDescription]
|
||||
, pgVersion :: PgVersion
|
||||
} deriving (Show, Eq)
|
||||
|
||||
-- TODO Table could hold references to all its Columns
|
||||
tableCols :: DbStructure -> Schema -> TableName -> [Column]
|
||||
tableCols dbs tSchema tName = filter (\Column{colTable=Table{tableSchema=s, tableName=t}} -> s==tSchema && t==tName) $ dbColumns dbs
|
||||
|
||||
-- TODO Table could hold references to all its PrimaryKeys
|
||||
tablePKCols :: DbStructure -> Schema -> TableName -> [Text]
|
||||
tablePKCols dbs tSchema tName = pkName <$> filter (\pk -> tSchema == (tableSchema . pkTable) pk && tName == (tableName . pkTable) pk) (dbPrimaryKeys dbs)
|
||||
|
||||
data PgArg = PgArg {
|
||||
pgaName :: Text
|
||||
, pgaType :: Text
|
||||
, pgaReq :: Bool
|
||||
} deriving (Show, Eq, Ord)
|
||||
|
||||
data PgType = Scalar QualifiedIdentifier | Composite QualifiedIdentifier deriving (Eq, Show, Ord)
|
||||
|
||||
data RetType = Single PgType | SetOf PgType deriving (Eq, Show, Ord)
|
||||
|
||||
data ProcVolatility = Volatile | Stable | Immutable
|
||||
deriving (Eq, Show, Ord)
|
||||
|
||||
data ProcDescription = ProcDescription {
|
||||
pdName :: Text
|
||||
, pdDescription :: Maybe Text
|
||||
, pdArgs :: [PgArg]
|
||||
, pdReturnType :: RetType
|
||||
, pdVolatility :: ProcVolatility
|
||||
} deriving (Show, Eq)
|
||||
|
||||
-- Order by least number of args in the case of overloaded functions
|
||||
instance Ord ProcDescription where
|
||||
ProcDescription name1 des1 args1 rt1 vol1 `compare` ProcDescription name2 des2 args2 rt2 vol2
|
||||
| name1 == name2 && length args1 < length args2 = LT
|
||||
| name1 == name2 && length args1 > length args2 = GT
|
||||
| otherwise = (name1, des1, args1, rt1, vol1) `compare` (name2, des2, args2, rt2, vol2)
|
||||
|
||||
{-|
|
||||
Search a pg procedure by its parameters. Since a function can be overloaded, the name is not enough to find it.
|
||||
An overloaded function can have a different volatility or even a different return type.
|
||||
-}
|
||||
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> M.HashMap Text [ProcDescription] -> Maybe ProcDescription
|
||||
findProc qi payloadKeys paramsAsSingleObject allProcs =
|
||||
case M.lookup (qiName qi) allProcs of
|
||||
Nothing -> Nothing
|
||||
Just [proc] -> Just proc -- if it's not an overloaded function then immediately get the ProcDescription
|
||||
Just procs -> find matches procs -- Handle overloaded functions case
|
||||
where
|
||||
matches proc =
|
||||
if paramsAsSingleObject
|
||||
-- if the arg is not of json type let the db give the err
|
||||
then length (pdArgs proc) == 1
|
||||
else payloadKeys `S.isSubsetOf` S.fromList (pgaName <$> pdArgs proc)
|
||||
|
||||
{-|
|
||||
Search the procedure parameters by matching them with the specified keys.
|
||||
If the key doesn't match a parameter, a parameter with a default type "text" is assumed.
|
||||
-}
|
||||
specifiedProcArgs :: S.Set FieldName -> Maybe ProcDescription -> [PgArg]
|
||||
specifiedProcArgs keys proc =
|
||||
let
|
||||
args = maybe [] pdArgs proc
|
||||
in
|
||||
(\k -> fromMaybe (PgArg k "text" True) (find ((==) k . pgaName) args)) <$> S.toList keys
|
||||
|
||||
type Schema = Text
|
||||
type TableName = Text
|
||||
type SqlQuery = Text
|
||||
type SqlFragment = Text
|
||||
|
||||
data Table = Table {
|
||||
tableSchema :: Text
|
||||
, tableName :: Text
|
||||
, tableInsertable :: Bool
|
||||
, tableAcl :: [Text]
|
||||
} deriving (Show)
|
||||
tableSchema :: Schema
|
||||
, tableName :: TableName
|
||||
, tableDescription :: Maybe Text
|
||||
, tableInsertable :: Bool
|
||||
} deriving (Show, Ord)
|
||||
|
||||
data ForeignKey = ForeignKey {
|
||||
fkTable::Text, fkCol::Text
|
||||
} deriving (Show, Eq)
|
||||
instance Eq Table where
|
||||
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
|
||||
|
||||
newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
|
||||
|
||||
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
|
||||
} | Star {colSchema :: Text, colTable :: Text } deriving (Show)
|
||||
data Column =
|
||||
Column {
|
||||
colTable :: Table
|
||||
, colName :: FieldName
|
||||
, colDescription :: Maybe Text
|
||||
, colPosition :: Int32
|
||||
, colNullable :: Bool
|
||||
, colType :: Text
|
||||
, colUpdatable :: Bool
|
||||
, colMaxLen :: Maybe Int32
|
||||
, colPrecision :: Maybe Int32
|
||||
, colDefault :: Maybe Text
|
||||
, colEnum :: [Text]
|
||||
, colFK :: Maybe ForeignKey
|
||||
} deriving (Show, Ord)
|
||||
|
||||
instance Eq Column where
|
||||
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
|
||||
|
||||
-- | A view column that refers to a table column
|
||||
type Synonym = (Column, ViewColumn)
|
||||
type ViewColumn = Column
|
||||
|
||||
data PrimaryKey = PrimaryKey {
|
||||
pkSchema::Text, pkTable::Text, pkName::Text
|
||||
}
|
||||
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 :: BS.ByteString
|
||||
, otNullOrder :: Maybe BS.ByteString
|
||||
otTerm :: Field
|
||||
, otDirection :: Maybe OrderDirection
|
||||
, otNullOrder :: Maybe OrderNulls
|
||||
} deriving (Show, Eq)
|
||||
|
||||
{-|
|
||||
Represents a pg identifier with a prepended schema name "schema.table"
|
||||
When qiSchema is "", the schema is defined by the pg search_path
|
||||
-}
|
||||
data QualifiedIdentifier = QualifiedIdentifier {
|
||||
qiSchema :: Text
|
||||
, qiName :: Text
|
||||
} deriving (Show, Eq)
|
||||
qiSchema :: Schema
|
||||
, qiName :: TableName
|
||||
} deriving (Show, Eq, Ord)
|
||||
|
||||
|
||||
data RelationType = Child | Parent | Many deriving (Show, Eq)
|
||||
data RelationType = Child | Parent | Many | Root deriving (Show, Eq)
|
||||
|
||||
{-|
|
||||
The name 'Relation' here is used with the meaning
|
||||
"What is the relation between the current node and the parent node".
|
||||
It has nothing to do with PostgreSQL referring to tables/views as relations.
|
||||
The order of the relColumns and relFColumns should be maintained to get
|
||||
the join conditions right.
|
||||
TODO merge relColumns and relFColumns to a tuple or Data.Bimap
|
||||
-}
|
||||
data Relation = Relation {
|
||||
relSchema :: Text
|
||||
, relTable :: Text
|
||||
, relColumns :: [Text]
|
||||
, relFTable :: Text
|
||||
, relFColumns :: [Text]
|
||||
, relType :: RelationType
|
||||
, relLTable :: Maybe Text
|
||||
, relLCols1 :: Maybe [Text]
|
||||
, relLCols2 :: Maybe [Text]
|
||||
relTable :: Table
|
||||
, relColumns :: [Column]
|
||||
, relFTable :: Table
|
||||
, relFColumns :: [Column]
|
||||
, relType :: RelationType
|
||||
-- The Link attrs are used when RelationType == Many
|
||||
, relLinkTable :: Maybe Table
|
||||
, relLinkCols1 :: Maybe [Column]
|
||||
, relLinkCols2 :: Maybe [Column]
|
||||
} deriving (Show, Eq)
|
||||
|
||||
isSelfJoin :: Relation -> Bool
|
||||
isSelfJoin r = relType r /= Root && relTable r == relFTable r
|
||||
|
||||
data PayloadJSON =
|
||||
-- | Cached attributes of a JSON payload
|
||||
ProcessedJSON {
|
||||
-- | This is the raw ByteString that comes from the request body.
|
||||
-- We cache this instead of an Aeson Value because it was detected that for large payloads the encoding
|
||||
-- had high memory usage, see #1005 for more details
|
||||
pjRaw :: BL.ByteString
|
||||
, pjType :: PJType
|
||||
-- | Keys of the object or if it's an array these keys are guaranteed to be the same across all its objects
|
||||
, pjKeys :: S.Set Text
|
||||
}|
|
||||
RawJSON {
|
||||
pjRaw :: BL.ByteString
|
||||
} deriving (Show, Eq)
|
||||
|
||||
data PJType = PJArray { pjaLength :: Int } | PJObject deriving (Show, Eq)
|
||||
|
||||
data Proxy = Proxy {
|
||||
proxyScheme :: Text
|
||||
, proxyHost :: Text
|
||||
, proxyPort :: Integer
|
||||
, proxyPath :: Text
|
||||
} deriving (Show, Eq)
|
||||
|
||||
type Operator = Text
|
||||
data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq)
|
||||
operators :: M.HashMap Operator SqlFragment
|
||||
operators = M.union (M.fromList [
|
||||
("eq", "="),
|
||||
("gte", ">="),
|
||||
("gt", ">"),
|
||||
("lte", "<="),
|
||||
("lt", "<"),
|
||||
("neq", "<>"),
|
||||
("like", "LIKE"),
|
||||
("ilike", "ILIKE"),
|
||||
("in", "IN"),
|
||||
("is", "IS"),
|
||||
("cs", "@>"),
|
||||
("cd", "<@"),
|
||||
("ov", "&&"),
|
||||
("sl", "<<"),
|
||||
("sr", ">>"),
|
||||
("nxr", "&<"),
|
||||
("nxl", "&>"),
|
||||
("adj", "-|-")]) ftsOperators
|
||||
|
||||
ftsOperators :: M.HashMap Operator SqlFragment
|
||||
ftsOperators = M.fromList [
|
||||
("fts", "@@ to_tsquery"),
|
||||
("plfts", "@@ plainto_tsquery"),
|
||||
("phfts", "@@ phraseto_tsquery")
|
||||
]
|
||||
|
||||
data OpExpr = OpExpr Bool Operation deriving (Eq, Show)
|
||||
data Operation = Op Operator SingleVal |
|
||||
In ListVal |
|
||||
Fts Operator (Maybe Language) SingleVal deriving (Eq, Show)
|
||||
type Language = Text
|
||||
|
||||
-- | Represents a single value in a filter, e.g. id=eq.singleval
|
||||
type SingleVal = Text
|
||||
-- | Represents a list value in a filter, e.g. id=in.(val1,val2,val3)
|
||||
type ListVal = [Text]
|
||||
|
||||
data LogicOperator = And | Or deriving Eq
|
||||
instance Show LogicOperator where
|
||||
show And = "AND"
|
||||
show Or = "OR"
|
||||
{-|
|
||||
Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:
|
||||
|
||||
And
|
||||
/ \
|
||||
name.eq.N Or
|
||||
/ \
|
||||
id.eq.1 id.eq.2
|
||||
-}
|
||||
data LogicTree = Expr Bool LogicOperator [LogicTree] | Stmnt Filter deriving (Show, Eq)
|
||||
|
||||
type FieldName = Text
|
||||
type JsonPath = [Text]
|
||||
type Field = (FieldName, Maybe JsonPath)
|
||||
{-|
|
||||
Json path operations as specified in https://www.postgresql.org/docs/9.4/static/functions-json.html
|
||||
-}
|
||||
type JsonPath = [JsonOperation]
|
||||
-- | Represents the single arrow `->` or double arrow `->>` operators
|
||||
data JsonOperation = JArrow{jOp :: JsonOperand} | J2Arrow{jOp :: JsonOperand} deriving (Show, Eq)
|
||||
-- | Represents the key(`->'key'`) or index(`->'1`::int`), the index is Text because we reuse our escaping functons and let pg do the casting with '1'::int
|
||||
data JsonOperand = JKey{jVal :: Text} | JIdx{jVal :: Text} deriving (Show, Eq)
|
||||
|
||||
type Field = (FieldName, JsonPath)
|
||||
type Alias = Text
|
||||
type Cast = Text
|
||||
type SelectItem = (Field, Maybe Cast)
|
||||
type Path = [Text]
|
||||
data Query = Select {
|
||||
mainTable::Text
|
||||
, fields::[SelectItem]
|
||||
, joinTables::[Text]
|
||||
, filters::[Filter]
|
||||
, order::Maybe [OrderTerm]
|
||||
, relation::Maybe Relation
|
||||
type NodeName = Text
|
||||
|
||||
-- Rpc query param, only used for GET rpcs
|
||||
type RpcQParam = (Text, Text)
|
||||
|
||||
{-|
|
||||
Custom guc header, it's obtained by parsing the json in a:
|
||||
`SET LOCAL "response.headers" = '[{"Set-Cookie": ".."}]'
|
||||
-}
|
||||
newtype GucHeader = GucHeader (Text, Text)
|
||||
|
||||
instance JSON.FromJSON GucHeader where
|
||||
parseJSON (JSON.Object o) = case headMay (M.toList o) of
|
||||
Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (k, s)
|
||||
| otherwise -> mzero
|
||||
_ -> mzero
|
||||
parseJSON _ = mzero
|
||||
|
||||
toHeaders :: [GucHeader] -> [Header]
|
||||
toHeaders = map $ \(GucHeader (k, v)) -> (CI.mk $ toS k, toS v)
|
||||
|
||||
{-|
|
||||
This type will hold information about which particular 'Relation' between two tables to choose when there are multiple ones.
|
||||
Specifically, it will contain the name of the foreign key or the join table in many to many relations.
|
||||
-}
|
||||
type RelationDetail = Text
|
||||
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe RelationDetail)
|
||||
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"]
|
||||
type EmbedPath = [Text]
|
||||
data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq)
|
||||
data JoinCondition = JoinCondition (QualifiedIdentifier, FieldName)
|
||||
(QualifiedIdentifier, FieldName) deriving (Show, Eq)
|
||||
|
||||
data ReadQuery = Select {
|
||||
select :: [SelectItem]
|
||||
, from :: TableName
|
||||
-- | A table alias is used in case of self joins
|
||||
, fromAlias :: Maybe Alias
|
||||
-- | Only used for Many to Many joins. Parent and Child joins use explicit joins.
|
||||
, implicitJoins :: [TableName]
|
||||
, where_ :: [LogicTree]
|
||||
, joinConditions :: [JoinCondition]
|
||||
, order :: [OrderTerm]
|
||||
, range_ :: NonnegRange
|
||||
} deriving (Show, Eq)
|
||||
data Filter = Filter {field::Field, operator::Operator, value::FValue} deriving (Show, Eq)
|
||||
type ApiRequest = Tree Query
|
||||
|
||||
data MutateQuery =
|
||||
Insert {
|
||||
in_ :: TableName
|
||||
, insCols :: S.Set FieldName
|
||||
, onConflict :: Maybe (PreferResolution, [FieldName])
|
||||
, where_ :: [LogicTree]
|
||||
, returning :: [FieldName]
|
||||
}|
|
||||
Update {
|
||||
in_ :: TableName
|
||||
, updCols :: S.Set FieldName
|
||||
, where_ :: [LogicTree]
|
||||
, returning :: [FieldName]
|
||||
}|
|
||||
Delete {
|
||||
in_ :: TableName
|
||||
, where_ :: [LogicTree]
|
||||
, returning :: [FieldName]
|
||||
} deriving (Show, Eq)
|
||||
|
||||
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
|
||||
type ReadRequest = Tree ReadNode
|
||||
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail, Depth))
|
||||
-- Depth of the ReadRequest tree
|
||||
type Depth = Integer
|
||||
type MutateRequest = MutateQuery
|
||||
|
||||
data PgVersion = PgVersion {
|
||||
pgvNum :: Int32
|
||||
, pgvName :: Text
|
||||
} deriving (Eq, Show)
|
||||
|
||||
instance Ord PgVersion where
|
||||
(PgVersion v1 _) `compare` (PgVersion v2 _) = v1 `compare` v2
|
||||
|
||||
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
|
||||
minimumPgVersion :: PgVersion
|
||||
minimumPgVersion = pgVersion94
|
||||
|
||||
pgVersion94 :: PgVersion
|
||||
pgVersion94 = PgVersion 90400 "9.4"
|
||||
|
||||
pgVersion95 :: PgVersion
|
||||
pgVersion95 = PgVersion 90500 "9.5"
|
||||
|
||||
pgVersion96 :: PgVersion
|
||||
pgVersion96 = PgVersion 90600 "9.6"
|
||||
|
||||
pgVersion100 :: PgVersion
|
||||
pgVersion100 = PgVersion 100000 "10"
|
||||
|
||||
pgVersion112 :: PgVersion
|
||||
pgVersion112 = PgVersion 110002 "11.2"
|
||||
|
||||
sourceCTEName :: SqlFragment
|
||||
sourceCTEName = "pg_source"
|
||||
|
||||
-- | full jspath, e.g. .property[0].attr.detail
|
||||
type JSPath = [JSPathExp]
|
||||
-- | jspath expression, e.g. .property, .property[0] or ."property-dash"
|
||||
data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)
|
||||
|
||||
|
||||
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 ]
|
||||
-- | Current database connection status data ConnectionStatus
|
||||
data ConnectionStatus
|
||||
= NotConnected
|
||||
| Connected PgVersion
|
||||
| FatalConnectionError Text
|
||||
deriving (Eq, Show)
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
flags: {}
|
||||
packages:
|
||||
- '.'
|
||||
extra-deps:
|
||||
- Ranged-sets-0.3.0
|
||||
- packdeps-0.4.1
|
||||
resolver: lts-3.10
|
||||
# stack.yaml is used for circle-ci tests. Profiling build fails on circleci
|
||||
# with GHC 8.6, so we build with 8.4 for now.
|
||||
|
||||
resolver: lts-12.26
|
||||
extra-deps:
|
||||
- Ranged-sets-0.4.0
|
||||
- configurator-pg-0.1.0.3
|
||||
- http-types-0.12.3
|
||||
- hasql-1.4
|
||||
- hasql-pool-0.5.1
|
||||
- hasql-transaction-0.7.2
|
||||
- text-builder-0.6.5.1
|
||||
- deferred-folds-0.9.10.1
|
||||
- primitive-0.6.4.0
|
||||
ghc-options:
|
||||
postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints
|
||||
nix:
|
||||
packages: [pcre, pkgconfig, postgresql, zlib]
|
||||
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,13 @@
|
||||
FROM debian:jessie
|
||||
|
||||
ENV PATH /root/.local/bin:$PATH
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y wget libpq-dev pkg-config libpcre3 libpcre3-dev \
|
||||
postgresql-client debconf locales \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
||||
&& echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen \
|
||||
&& locale-gen \
|
||||
&& echo 'export LC_ALL=en_US.UTF-8' >> /etc/profile \
|
||||
&& wget -qO- https://get.haskellstack.org/ | sh
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
module Feature.AndOrParamsSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
describe "and/or params used for complex boolean logic" $ do
|
||||
context "used with GET" $ do
|
||||
context "or param" $ do
|
||||
it "can do simple logic" $
|
||||
get "/entities?or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can negate simple logic" $
|
||||
get "/entities?not.or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can be combined with traditional filters" $
|
||||
get "/entities?or=(id.eq.1,id.eq.2)&name=eq.entity 1&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "embedded levels" $ do
|
||||
it "can do logic on the second level" $
|
||||
get "/entities?child_entities.or=(id.eq.1,name.eq.child entity 2)&select=id,child_entities(id)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id": 1, "child_entities": [ { "id": 1 }, { "id": 2 } ] }, { "id": 2, "child_entities": []},
|
||||
{"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}
|
||||
]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can do logic on the third level" $
|
||||
get "/entities?child_entities.grandchild_entities.or=(id.eq.1,id.eq.2)&select=id,child_entities(id,grandchild_entities(id))" `shouldRespondWith`
|
||||
[json|[
|
||||
{"id": 1, "child_entities": [ { "id": 1, "grandchild_entities": [ { "id": 1 }, { "id": 2 } ]}, { "id": 2, "grandchild_entities": []}]},
|
||||
{"id": 2, "child_entities": [ { "id": 3, "grandchild_entities": []} ]},
|
||||
{"id": 3, "child_entities": []}, {"id": 4, "child_entities": []}
|
||||
]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "and/or params combined" $ do
|
||||
it "can be nested inside the same expression" $
|
||||
get "/entities?or=(and(name.eq.entity 2,id.eq.2),and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can be negated while nested" $
|
||||
get "/entities?or=(not.and(name.eq.entity 2,id.eq.2),not.and(name.eq.entity 1,id.eq.1))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can be combined unnested" $
|
||||
get "/entities?and=(id.eq.1,name.eq.entity 1)&or=(id.eq.1,id.eq.2)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "operators inside and/or" $ do
|
||||
it "can handle eq and neq" $
|
||||
get "/entities?and=(id.eq.1,id.neq.2))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle lt and gt" $
|
||||
get "/entities?or=(id.lt.2,id.gt.3)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle lte and gte" $
|
||||
get "/entities?or=(id.lte.2,id.gte.3)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle like and ilike" $
|
||||
get "/entities?or=(name.like.*1,name.ilike.*ENTITY 2)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle in" $
|
||||
get "/entities?or=(id.in.(1,2),id.in.(3,4))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle is" $
|
||||
get "/entities?and=(name.is.null,arr.is.null)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle fts" $ do
|
||||
get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/tsearch?or=(text_search_vector.plfts(german).Art%20Spass, text_search_vector.plfts(french).amusant%20impossible, text_search_vector.fts(english).impossible)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"text_search_vector": "'fun':5 'imposs':9 'kind':3" },
|
||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}
|
||||
]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can handle cs and cd" $
|
||||
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can handle range operators" $ do
|
||||
get "/ranges?range=eq.[1,3]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=neq.[1,3]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=lt.[1,10]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=gt.[8,11]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=lte.[1,3]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=gte.[2,3]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=cs.[1,2]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=cd.[1,6]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=ov.[0,4]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=sl.[9,10]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=sr.[3,4]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=nxr.[4,7]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=nxl.[4,7]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "operators with not" $ do
|
||||
it "eq, cs, like can be negated" $
|
||||
get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "in, is, fts can be negated" $
|
||||
get "/entities?and=(id.not.in.(1,3),and(name.not.is.null,text_search_vector.not.fts.foo))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "lt, gte, cd can be negated" $
|
||||
get "/entities?and=(arr.not.cd.{1},or(id.not.lt.1,id.not.gte.3))&select=id" `shouldRespondWith`
|
||||
[json|[{"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "gt, lte, ilike can be negated" $
|
||||
get "/entities?and=(name.not.ilike.*ITY2,or(id.not.gt.4,id.not.lte.1))&select=id" `shouldRespondWith`
|
||||
[json|[{"id": 1}, {"id": 2}, {"id": 3}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "and/or params with quotes" $ do
|
||||
it "eq can have quotes" $
|
||||
get "/grandchild_entities?or=(name.eq.\"(grandchild,entity,4)\",name.eq.\"(grandchild,entity,5)\")&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "like and ilike can have quotes" $
|
||||
get "/grandchild_entities?or=(name.like.\"*ity,4*\",name.ilike.\"*ITY,5)\")&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 4 }, { "id": 5 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "in can have quotes" $
|
||||
get "/grandchild_entities?or=(id.in.(\"1\",\"2\"),id.in.(\"3\",\"4\"))&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }, { "id": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "allows whitespace" $
|
||||
get "/entities?and=( and ( id.in.( 1, 2, 3 ) , id.eq.3 ) , or ( id.eq.2 , id.eq.3 ) )&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "multiple and/or conditions" $ do
|
||||
it "cannot have zero conditions" $
|
||||
get "/entities?or=()" `shouldRespondWith`
|
||||
[json|{
|
||||
"details": "unexpected \")\" expecting field name (* or [a..z0..9_]), negation operator (not) or logic operator (and, or)",
|
||||
"message": "\"failed to parse logic tree (())\" (line 1, column 4)"
|
||||
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
it "can have a single condition" $ do
|
||||
get "/entities?or=(id.eq.1)&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?and=(id.eq.1)&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can have three conditions" $ do
|
||||
get "/grandchild_entities?or=(id.eq.1, id.eq.2, id.eq.3)&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}, {"id":2}, {"id":3}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/grandchild_entities?and=(id.in.(1,2), id.in.(3,1), id.in.(1,4))&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "can have four conditions combining and/or" $ do
|
||||
get "/grandchild_entities?or=( id.eq.1, id.eq.2, and(id.in.(1,3), id.in.(2,3)), id.eq.4 )&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}, {"id":2}, {"id":3}, {"id":4}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/grandchild_entities?and=( id.eq.1, not.or(id.eq.2, id.eq.3), id.in.(1,4), or(id.eq.1, id.eq.4) )&select=id" `shouldRespondWith`
|
||||
[json|[{"id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "used with POST" $
|
||||
it "includes related data with filters" $
|
||||
request methodPost "/child_entities?select=id,entities(id)&entities.or=(id.eq.2,id.eq.3)&entities.order=id"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|[{"id":4,"name":"entity 4","parent_id":1},
|
||||
{"id":5,"name":"entity 5","parent_id":2},
|
||||
{"id":6,"name":"entity 6","parent_id":3}]|] `shouldRespondWith`
|
||||
[json|[{"id": 4, "entities":null}, {"id": 5, "entities": {"id": 2}}, {"id": 6, "entities": {"id": 3}}]|]
|
||||
{ matchStatus = 201, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "used with PATCH" $
|
||||
it "succeeds when using and/or params" $
|
||||
request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{ name : "updated grandchild entity"}|] `shouldRespondWith`
|
||||
[json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "used with DELETE" $
|
||||
it "succeeds when using and/or params" $
|
||||
request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
||||
[("Prefer", "return=representation")] "" `shouldRespondWith`
|
||||
[json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can query columns that begin with and/or reserved words" $
|
||||
get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
|
||||
|
||||
it "fails when using IN without () and provides meaningful error message" $
|
||||
get "/entities?or=(id.in.1,2,id.eq.3)" `shouldRespondWith`
|
||||
[json|{
|
||||
"details": "unexpected \"1\" expecting \"(\"",
|
||||
"message": "\"failed to parse logic tree ((id.in.1,2,id.eq.3))\" (line 1, column 10)"
|
||||
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails on malformed query params and provides meaningful error message" $ do
|
||||
get "/entities?or=)(" `shouldRespondWith`
|
||||
[json|{
|
||||
"details": "unexpected \")\" expecting \"(\"",
|
||||
"message": "\"failed to parse logic tree ()()\" (line 1, column 3)"
|
||||
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?and=(ord(id.eq.1,id.eq.1),id.eq.2)" `shouldRespondWith`
|
||||
[json|{
|
||||
"details": "unexpected \"d\" expecting \"(\"",
|
||||
"message": "\"failed to parse logic tree ((ord(id.eq.1,id.eq.1),id.eq.2))\" (line 1, column 7)"
|
||||
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
get "/entities?or=(id.eq.1,not.xor(id.eq.2,id.eq.3))" `shouldRespondWith`
|
||||
[json|{
|
||||
"details": "unexpected \"x\" expecting logic operator (and, or)",
|
||||
"message": "\"failed to parse logic tree ((id.eq.1,not.xor(id.eq.2,id.eq.3)))\" (line 1, column 16)"
|
||||
}|] { matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
@@ -0,0 +1,21 @@
|
||||
module Feature.AsymmetricJwtSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec = describe "server started with asymmetric JWK" $
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
it "succeeds with jwt token signed with an asymmetric key" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlIjogInBvc3RncmVzdF90ZXN0X2F1dGhvciJ9Cg.CBOYWDvqgAR0YYnZnyDGTQi6AJLc2Pds6_eV3YuBG6I36mj_h05eLhkEKNEDA5ZteMzCiY83P60rC_xtxVd7B6vo3BeF5uoanPS3rrbuHzKPwzsrgrD_CqvEuJ4n7Q9epkQiLsNkcexneENZDRqFjbwZx3DrXiCWwlK3Ytr5NAIGxmy0od-0xNpb2U1nXQyO_Q3mumWFViRt4tmFn_3goDHNKG3Ha_AzImfUNvHnWL78kAc4rbn15vLtWXD8PwtSnZaB4lY4V6RfsaW937srQsmRetvytM1i_bHBnjkjQLAqGbXPyItjtlXPs0uGNBadE8-wgkLtfmSCC4v2DjUthw"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
@@ -0,0 +1,47 @@
|
||||
module Feature.AudienceJwtSecretSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec = describe "test handling of aud claims in JWT" $ do
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
it "succeeds with jwt token containing with an audience claim" $ do
|
||||
{- This is the decoded contents of authHeaderJWT
|
||||
|
||||
{
|
||||
"exp": 9999999999,
|
||||
"role": "postgrest_test_author",
|
||||
"id": "jdoe",
|
||||
"aud": "youraudience"
|
||||
}
|
||||
|
||||
-}
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "succeeds with jwt token that does not contain an audience claim" $ do
|
||||
{- This is the decoded contents of authHeaderJWT
|
||||
|
||||
{
|
||||
"exp": 9999999999,
|
||||
"role": "postgrest_test_author",
|
||||
"id": "jdoe"
|
||||
}
|
||||
-}
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "requests without JWT token should work" $
|
||||
get "/has_count_column" `shouldRespondWith` 200
|
||||
@@ -1,72 +1,172 @@
|
||||
module Feature.AuthSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Network.HTTP.Types
|
||||
import Text.Heredoc
|
||||
|
||||
import PostgREST.Types (PgVersion, pgVersion112)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: Spec
|
||||
spec = beforeAll
|
||||
(clearTable "postgrest.auth") . afterAll_ (clearTable "postgrest.auth")
|
||||
$ around withApp
|
||||
$ describe "authorization" $ do
|
||||
spec :: PgVersion -> SpecWith Application
|
||||
spec actualPgVersion = describe "authorization" $ do
|
||||
let single = ("Accept","application/vnd.pgrst.object+json")
|
||||
|
||||
it "hides tables that anonymous does not own" $
|
||||
get "/authors_only" `shouldRespondWith` 404
|
||||
it "denies access to tables that anonymous does not own" $
|
||||
get "/authors_only" `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[json| {
|
||||
"hint":null,
|
||||
"details":null,
|
||||
"code":"42501",
|
||||
"message":"permission denied for table authors_only"} |]
|
||||
else
|
||||
[json| {
|
||||
"hint":null,
|
||||
"details":null,
|
||||
"code":"42501",
|
||||
"message":"permission denied for relation authors_only"} |]
|
||||
)
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
|
||||
}
|
||||
|
||||
it "indicates login failure (BasicAuth)" $ do
|
||||
let auth = authHeaderBasic "postgrest_test_author" "fakefake"
|
||||
it "denies access to tables that postgrest_test_author does not own" $
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
|
||||
request methodGet "/private_table" [auth] ""
|
||||
`shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[json| {
|
||||
"hint":null,
|
||||
"details":null,
|
||||
"code":"42501",
|
||||
"message":"permission denied for table private_table"} |]
|
||||
else
|
||||
[json| {
|
||||
"hint":null,
|
||||
"details":null,
|
||||
"code":"42501",
|
||||
"message":"permission denied for relation private_table"} |]
|
||||
)
|
||||
{ matchStatus = 403
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "denies execution on functions that anonymous does not own" $
|
||||
post "/rpc/privileged_hello" [json|{"name": "anonymous"}|] `shouldRespondWith` 401
|
||||
|
||||
it "allows execution on a function that postgrest_test_author owns" $
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
|
||||
request methodPost "/rpc/privileged_hello" [auth] [json|{"name": "jdoe"}|]
|
||||
`shouldRespondWith` [json|"Privileged hello to jdoe"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "returns jwt functions as jwt tokens" $
|
||||
request methodPost "/rpc/login" [single]
|
||||
[json| { "id": "jdoe", "pass": "1234" } |]
|
||||
`shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xuYW1lIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.KO-0PGp_rU-utcDBP6qwdd-Th2Fk-ICVt01I7QtTDWs"} |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
it "sql functions can encode custom and standard claims" $
|
||||
request methodPost "/rpc/jwt_test" [single] "{}"
|
||||
`shouldRespondWith` [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb2UiLCJzdWIiOiJmdW4iLCJhdWQiOiJldmVyeW9uZSIsImV4cCI6MTMwMDgxOTM4MCwibmJmIjoxMzAwODE5MzgwLCJpYXQiOjEzMDA4MTkzODAsImp0aSI6ImZvbyIsInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdCIsImh0dHA6Ly9wb3N0Z3Jlc3QuY29tL2ZvbyI6dHJ1ZX0.G2REtPnOQMUrVRDA9OnkPJTd8R0tf4wdYOlauh1E2Ek"} |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeSingular]
|
||||
}
|
||||
|
||||
it "sql functions can read custom and standard claims variables" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmdW4iLCJqdGkiOiJmb28iLCJuYmYiOjEzMDA4MTkzODAsImV4cCI6OTk5OTk5OTk5OSwiaHR0cDovL3Bvc3RncmVzdC5jb20vZm9vIjp0cnVlLCJpc3MiOiJqb2UiLCJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWF0IjoxMzAwODE5MzgwfQ.V5fEpXfpb7feqwVqlcDleFdKu86bdwU2cBRT4fcMhXg"
|
||||
request methodPost "/rpc/reveal_big_jwt" [auth] "{}"
|
||||
`shouldRespondWith` [str|[{"iss":"joe","sub":"fun","exp":9999999999,"nbf":1300819380,"iat":1300819380,"jti":"foo","http://postgrest.com/foo":true}]|]
|
||||
|
||||
it "allows users with permissions to see their tables" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "works with tokens which have extra fields" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIiwia2V5MSI6InZhbHVlMSIsImtleTIiOiJ2YWx1ZTIiLCJrZXkzIjoidmFsdWUzIiwiYSI6MSwiYiI6MiwiYyI6M30.b0eglDKYEmGi-hCvD-ddSqFl7vnDO5qkUaviaHXm3es"
|
||||
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.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "fails with an expired token" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.f8__E6VQwYcDqwHmr9PG03uaZn8Zh1b0vbJ9DYS0AdM"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` [json| {"message":"JWT expired"} |]
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = [
|
||||
"WWW-Authenticate" <:>
|
||||
"Bearer error=\"invalid_token\", error_description=\"JWT expired\""
|
||||
]
|
||||
}
|
||||
|
||||
it "hides tables from users with invalid JWT" $ do
|
||||
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError \"expected 3 parts, got 2\")"} |]
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = [
|
||||
"WWW-Authenticate" <:>
|
||||
"Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError \\\"expected 3 parts, got 2\\\")\""
|
||||
]
|
||||
}
|
||||
|
||||
it "should fail when jwt contains no claims" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.CUIP5V9thWsGGFsFyGijSZf1fJMfarLHI9CEJL-TGNk"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 401
|
||||
|
||||
it "allows users with permissions to see their tables (BasicAuth)" $ do
|
||||
_ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||
let auth = authHeaderBasic "jdoe" "1234"
|
||||
it "hides tables from users with JWT that contain no claims about role" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Impkb2UifQ.RVlZDaSyKbFPvxUf3V_NQXybfRB4dlBIkAUQXVXLUAI"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
`shouldRespondWith` 401
|
||||
|
||||
it "respects database constraints for role" $
|
||||
post "/postgrest/users" [json| { "id": "ssmith", "pass": "1234", "role": "SUPER_ADMIN_TRUNCATE_POWERS" } |]
|
||||
`shouldRespondWith` 400
|
||||
|
||||
it "does not send a value when no role is provided" $ do
|
||||
post "/postgrest/users" [json| { "id": "bdeey", "pass": "1234" } |]
|
||||
`shouldRespondWith` 201
|
||||
let auth = authHeaderBasic "jdoe" "1234"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "recovers after 400 error with logged in user" $ do
|
||||
_ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||
let auth = authHeaderBasic "jdoe" "1234"
|
||||
it "recovers after 401 error with logged in user" $ do
|
||||
_ <- post "/authors_only" [json| { "owner": "jdoe", "secret": "test content" } |]
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
|
||||
_ <- request methodPost "/rpc/problem" [auth] ""
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "allows users to login (JWT)" $ do
|
||||
_ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||
post "/postgrest/tokens" [json| { "id": "jdoe", "pass": "1234" } |]
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"} |]
|
||||
, matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "application/json"]
|
||||
}
|
||||
describe "custom pre-request proc acting on id claim" $ do
|
||||
|
||||
it "indicates login failure (JWT)" $ do
|
||||
_ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||
post "/postgrest/tokens" [json| { "id": "jdoe", "pass": "NOPE" } |]
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| {"message":"Failed authentication."} |]
|
||||
, matchStatus = 401
|
||||
, matchHeaders = ["Content-Type" <:> "application/json"]
|
||||
}
|
||||
it "able to switch to postgrest_test_author role (id=1)" $
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.gKw7qI50i9hMrSJW8BlTpdMEVmMXJYxlAqueGqpa_mE" in
|
||||
request methodPost "/rpc/get_current_user" [auth]
|
||||
[json| {} |]
|
||||
`shouldRespondWith` [str|"postgrest_test_author"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "allows users with permissions to see their tables (JWT)" $ do
|
||||
_ <- post "/postgrest/users" [json| { "id": "jdoe", "pass": "1234", "role": "postgrest_test_author" } |]
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
it "able to switch to postgrest_test_default_role (id=2)" $
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Mn0.nwzjMI0YLvVGJQTeoCPEBsK983b__gxdpLXisBNaO2A" in
|
||||
request methodPost "/rpc/get_current_user" [auth]
|
||||
[json| {} |]
|
||||
`shouldRespondWith` [str|"postgrest_test_default_role"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "raises error (id=3)" $
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6M30.OGxEJAf60NKZiTn-tIb2jy4rqKs_ZruLGWZ40TjrJsM" in
|
||||
request methodPost "/rpc/get_current_user" [auth]
|
||||
[json| {} |]
|
||||
`shouldRespondWith` [str|{"hint":"Please contact administrator","details":null,"code":"P0001","message":"Disabled ID --> 3"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
module Feature.BinaryJwtSecretSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec = describe "server started with binary JWT secret" $
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
it "succeeds with jwt token encoded with a binary secret" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith` 200
|
||||
@@ -0,0 +1,53 @@
|
||||
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||
{-# LANGUAGE TypeFamilies #-}
|
||||
{-# LANGUAGE UndecidableInstances #-}
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
module Feature.ConcurrentSpec where
|
||||
|
||||
import Control.Concurrent.Async (mapConcurrently)
|
||||
import Control.Monad (void)
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Control.Monad.Base
|
||||
import Control.Monad.Trans.Control
|
||||
|
||||
import Network.Wai.Test (Session)
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.Internal
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
describe "Queryiny in parallel" $
|
||||
it "should not raise 'transaction in progress' error" $
|
||||
raceTest 10 $
|
||||
get "/fakefake"
|
||||
`shouldRespondWith` [json|
|
||||
{ "hint": null,
|
||||
"details":null,
|
||||
"code":"42P01",
|
||||
"message":"relation \"test.fakefake\" does not exist"
|
||||
} |]
|
||||
{ matchStatus = 404
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
raceTest :: Int -> WaiExpectation -> WaiExpectation
|
||||
raceTest times = liftBaseDiscard go
|
||||
where
|
||||
go test = void $ mapConcurrently (const test) [1..times]
|
||||
|
||||
instance MonadBaseControl IO WaiSession where
|
||||
type StM WaiSession a = StM Session a
|
||||
liftBaseWith f = WaiSession $
|
||||
liftBaseWith $ \runInBase ->
|
||||
f $ \k -> runInBase (unWaiSession k)
|
||||
restoreM = WaiSession . restoreM
|
||||
{-# INLINE liftBaseWith #-}
|
||||
{-# INLINE restoreM #-}
|
||||
|
||||
instance MonadBase IO WaiSession where
|
||||
liftBase = liftIO
|
||||
@@ -1,18 +1,22 @@
|
||||
module Feature.CorsSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Network.Wai.Test (SResponse(simpleHeaders, simpleBody))
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
|
||||
import SpecHelper
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: Spec
|
||||
spec = around withApp $ describe "CORS" $ do
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
describe "CORS" $ do
|
||||
let preflightHeaders = [
|
||||
("Accept", "*/*"),
|
||||
("Origin", "http://example.com"),
|
||||
@@ -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, PUT, DELETE, OPTIONS, HEAD"
|
||||
respHeaders `shouldSatisfy` matchHeader
|
||||
"Access-Control-Allow-Headers"
|
||||
"Authentication, Foo, Bar, Accept, Accept-Language, Content-Language"
|
||||
@@ -67,4 +71,4 @@ spec = around withApp $ describe "CORS" $ do
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy` matchHeader
|
||||
"Access-Control-Allow-Origin" "\\*"
|
||||
simpleBody r `shouldSatisfy` not . BL.null
|
||||
simpleBody r `shouldSatisfy` BL.null
|
||||
|
||||
@@ -1,36 +1,63 @@
|
||||
module Feature.DeleteSpec where
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import SpecHelper
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Text.Heredoc
|
||||
|
||||
spec :: Spec
|
||||
spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable "items")
|
||||
. around withApp $
|
||||
import Protolude hiding (get)
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
describe "Deleting" $ do
|
||||
context "existing record" $ do
|
||||
it "succeeds with 204 and deletion count" $
|
||||
request methodDelete "/items?id=eq.1" [] ""
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Nothing
|
||||
, matchStatus = 204
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "returns the deleted item and count if requested" $
|
||||
request methodDelete "/items?id=eq.2" [("Prefer", "return=representation"), ("Prefer", "count=exact")] ""
|
||||
`shouldRespondWith` [str|[{"id":2}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/1"]
|
||||
}
|
||||
it "returns the deleted item and shapes the response" $
|
||||
request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` [str|[{"id":2,"name":"Two"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
it "can rename and cast the selected columns" $
|
||||
request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
|
||||
it "can embed (parent) entities" $
|
||||
request methodDelete "/tasks?id=eq.8&select=id,name,project(id)" [("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "actually clears items ouf the db" $ do
|
||||
_ <- request methodDelete "/items?id=lt.15" [] ""
|
||||
get "/items"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "[{\"id\":15}]"
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/1"]
|
||||
`shouldRespondWith` [str|[{"id":15}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
context "known route, unknown record" $
|
||||
it "fails with 404" $
|
||||
request methodDelete "/items?id=eq.101" [] "" `shouldRespondWith` 404
|
||||
context "known route, no records matched" $
|
||||
it "includes [] body if return=rep" $
|
||||
request methodDelete "/items?id=eq.101"
|
||||
[("Prefer", "return=representation")] ""
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
context "totally unknown route" $
|
||||
it "fails with 404" $
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
module Feature.ExtraSearchPathSpec where
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec = describe "extra search path" $ do
|
||||
|
||||
it "finds the ltree <@ operator on the public schema" $
|
||||
request methodGet "/ltree_sample?path=cd.Top.Science.Astronomy" [] ""
|
||||
`shouldRespondWith` [json|[
|
||||
{"path":"Top.Science.Astronomy"},
|
||||
{"path":"Top.Science.Astronomy.Astrophysics"},
|
||||
{"path":"Top.Science.Astronomy.Cosmology"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "finds the ltree nlevel function on the public schema, used through a computed column" $
|
||||
request methodGet "/ltree_sample?select=number_of_labels&path=eq.Top.Science" [] ""
|
||||
`shouldRespondWith` [json|[{"number_of_labels":2}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "finds the isn = operator on the extensions schema" $
|
||||
request methodGet "/isn_sample?id=eq.978-0-393-04002-9&select=name" [] ""
|
||||
`shouldRespondWith` [json|[{"name":"Mathematics: From the Birth of Numbers"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "finds the isn is_valid function on the extensions schema" $
|
||||
request methodGet "/rpc/is_valid_isbn?input=978-0-393-04002-9" [] ""
|
||||
`shouldRespondWith` [json|true|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
@@ -1,37 +1,106 @@
|
||||
module Feature.InsertSpec where
|
||||
|
||||
import Test.Hspec
|
||||
import qualified Data.Aeson as JSON
|
||||
|
||||
import Control.Monad (replicateM_, void)
|
||||
import Data.List (lookup)
|
||||
import Data.Maybe (fromJust)
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai.Matcher (bodyEquals)
|
||||
import TestTypes (CompoundPK (..), IncPK (..))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Network.Wai.Test (SResponse(simpleBody,simpleHeaders,simpleStatus))
|
||||
import Text.Heredoc
|
||||
|
||||
import PostgREST.Types (PgVersion, pgVersion112)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
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(..))
|
||||
|
||||
spec :: Spec
|
||||
spec = afterAll_ resetDb $ around withApp $ do
|
||||
spec :: PgVersion -> SpecWith Application
|
||||
spec actualPgVersion = 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
|
||||
context "disparate json 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
|
||||
-- should not have content type set when body is empty
|
||||
lookup hContentType (simpleHeaders p) `shouldBe` Nothing
|
||||
|
||||
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` [str|[{"integer":14,"varchar":"testing!"}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "non uniform json array" $ do
|
||||
it "rejects json array that isn't exclusivily composed of objects" $
|
||||
post "/articles"
|
||||
[json| [{"id": 100, "body": "xxxxx"}, 123, "xxxx", {"id": 111, "body": "xxxx"}] |]
|
||||
`shouldRespondWith`
|
||||
[json| {"message":"All object keys must match"} |]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "rejects json array that has objects with different keys" $
|
||||
post "/articles"
|
||||
[json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |]
|
||||
`shouldRespondWith`
|
||||
[json| {"message":"All object keys must match"} |]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "requesting full representation" $ do
|
||||
it "includes related data after insert" $
|
||||
request methodPost "/projects?select=id,name,clients(id,name)"
|
||||
[("Prefer", "return=representation"), ("Prefer", "count=exact")]
|
||||
[str|{"id":6,"name":"New Project","client_id":2}|] `shouldRespondWith` [str|[{"id":6,"name":"New Project","clients":{"id":2,"name":"Apple"}}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Location" <:> "/projects?id=eq.6"
|
||||
, "Content-Range" <:> "*/1" ]
|
||||
}
|
||||
|
||||
it "can rename and cast the selected columns" $
|
||||
request methodPost "/projects?select=pId:id::text,pName:name,cId:client_id::text"
|
||||
[("Prefer", "return=representation")]
|
||||
[str|{"id":7,"name":"New Project","client_id":2}|] `shouldRespondWith`
|
||||
[str|[{"pId":"7","pName":"New Project","cId":"2"}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Location" <:> "/projects?id=eq.7"
|
||||
, "Content-Range" <:> "*/*" ]
|
||||
}
|
||||
|
||||
context "from an html form" $
|
||||
it "accepts disparate json types" $ do
|
||||
p <- request methodPost "/menagerie"
|
||||
[("Content-Type", "application/x-www-form-urlencoded")]
|
||||
("integer=7&double=2.71828&varchar=forms+are+fun&" <>
|
||||
"boolean=false&date=1900-01-01&money=$3.99&enum=foo")
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` ""
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
context "with no pk supplied" $ do
|
||||
context "into a table with auto-incrementing pk" . after_ (clearTable "auto_incrementing_pk") $
|
||||
context "into a table with auto-incrementing pk" $
|
||||
it "succeeds with 201 and link" $ do
|
||||
p <- post "/auto_incrementing_pk" [json| { "non_nullable_string":"not null"} |]
|
||||
liftIO $ do
|
||||
@@ -48,9 +117,13 @@ spec = afterAll_ resetDb $ around withApp $ do
|
||||
context "into a table with simple pk" $
|
||||
it "fails with 400 and error" $
|
||||
post "/simple_pk" [json| { "extra":"foo"} |]
|
||||
`shouldRespondWith` 400
|
||||
`shouldRespondWith`
|
||||
[json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" violates not-null constraint"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "into a table with no pk" . after_ (clearTable "no_pk") $ do
|
||||
context "into a table with no pk" $ do
|
||||
it "succeeds with 201 and a link including all fields" $ do
|
||||
p <- post "/no_pk" [json| { "a":"foo", "b":"bar" } |]
|
||||
liftIO $ do
|
||||
@@ -63,191 +136,332 @@ spec = afterAll_ resetDb $ around withApp $ do
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { "a":"bar", "b":"baz" } |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` [json| { "a":"bar", "b":"baz" } |]
|
||||
simpleBody p `shouldBe` [json| [{ "a":"bar", "b":"baz" }] |]
|
||||
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=eq.bar&b=eq.baz"
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
it "returns empty array when no items inserted, and return=rep" $ do
|
||||
p <- request methodPost "/no_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| [] |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` [json| [] |]
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
it "can insert in tables with no select privileges" $ do
|
||||
p <- request methodPost "/insertonly"
|
||||
[("Prefer", "return=minimal")]
|
||||
[json| { "v":"some value" } |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` ""
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
|
||||
it "can post nulls" $ do
|
||||
p <- request methodPost "/no_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { "a":null, "b":"foo" } |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` [json| { "a":null, "b":"foo" } |]
|
||||
simpleBody p `shouldBe` [json| [{ "a":null, "b":"foo" }] |]
|
||||
simpleHeaders p `shouldSatisfy` matchHeader hLocation "/no_pk\\?a=is.null&b=eq.foo"
|
||||
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 } |]
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Nothing,
|
||||
matchStatus = 201,
|
||||
matchHeaders = ["Location" <:> "/compound_pk?k1=eq.12&k2=eq.42"]
|
||||
}
|
||||
context "with compound pk supplied" $
|
||||
it "builds response location header appropriately" $ do
|
||||
let inserted = [json| { "k1":12, "k2":"Rock & R+ll" } |]
|
||||
expectedObj = CompoundPK 12 "Rock & R+ll" Nothing
|
||||
expectedLoc = "/compound_pk?k1=eq.12&k2=eq.Rock%20%26%20R%2Bll"
|
||||
p <- request methodPost "/compound_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
inserted
|
||||
liftIO $ do
|
||||
JSON.decode (simpleBody p) `shouldBe` Just [expectedObj]
|
||||
simpleStatus p `shouldBe` created201
|
||||
lookup hLocation (simpleHeaders p) `shouldBe` Just expectedLoc
|
||||
|
||||
r <- get expectedLoc
|
||||
liftIO $ do
|
||||
JSON.decode (simpleBody r) `shouldBe` Just [expectedObj]
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
context "with bulk insert" $
|
||||
it "returns 201 but no location header" $ do
|
||||
let bulkData = [json| [ {"k1":21, "k2":"hello world"}
|
||||
, {"k1":22, "k2":"bye for now"}]
|
||||
|]
|
||||
p <- request methodPost "/compound_pk" [] bulkData
|
||||
liftIO $ do
|
||||
simpleStatus p `shouldBe` created201
|
||||
lookup hLocation (simpleHeaders p) `shouldBe` Nothing
|
||||
|
||||
context "with invalid json payload" $
|
||||
it "fails with 400 and error" $
|
||||
post "/simple_pk" "}{ x = 2" `shouldRespondWith` 400
|
||||
post "/simple_pk" "}{ x = 2"
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Error in $: Failed reading: not a valid json value"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "jsonb" . after_ (clearTable "json") $ do
|
||||
context "with valid json payload" $
|
||||
it "succeeds and returns 201 created" $
|
||||
post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |] `shouldRespondWith` 201
|
||||
|
||||
context "attempting to insert a row with the same primary key" $
|
||||
it "fails returning a 409 Conflict" $
|
||||
post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |]
|
||||
`shouldRespondWith`
|
||||
[json|{"hint":null,"details":"Key (k)=(k1) already exists.","code":"23505","message":"duplicate key value violates unique constraint \"contacts_pkey\""}|]
|
||||
{ matchStatus = 409
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "attempting to insert a row with conflicting unique constraint" $
|
||||
it "fails returning a 409 Conflict" $
|
||||
post "/withUnique" [json| { "uni":"nodup", "extra":"e2" } |] `shouldRespondWith` 409
|
||||
|
||||
context "jsonb" $ do
|
||||
it "serializes nested object" $ do
|
||||
let inserted = [json| { "data": { "foo":"bar" } } |]
|
||||
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
|
||||
location = "/json?data=eq.%7B%22foo%22%3A%22bar%22%7D"
|
||||
request methodPost "/json"
|
||||
[("Prefer", "return=representation")]
|
||||
inserted
|
||||
`shouldRespondWith` [str|[{"data":{"foo":"bar"}}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Location" <:> location]
|
||||
}
|
||||
|
||||
it "serializes nested array" $ do
|
||||
let inserted = [json| { "data": [1,2,3] } |]
|
||||
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
|
||||
location = "/json?data=eq.%5B1%2C2%2C3%5D"
|
||||
request methodPost "/json"
|
||||
[("Prefer", "return=representation")]
|
||||
inserted
|
||||
`shouldRespondWith` [str|[{"data":[1,2,3]}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Location" <:> location]
|
||||
}
|
||||
|
||||
context "empty objects" $ do
|
||||
it "successfully inserts a row with all-default columns" $ do
|
||||
post "/items" "{}" `shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = []
|
||||
}
|
||||
post "/items" "[{}]" `shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "successfully inserts two rows with all-default columns" $
|
||||
post "/items" "[{}, {}]" `shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
context "table with limited privileges" $ do
|
||||
it "succeeds if correct select is applied" $
|
||||
request methodPost "/limited_article_stars?select=article_id,user_id" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 2, "user_id": 1} |] `shouldRespondWith` [str|[{"article_id":2,"user_id":1}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "fails if more columns are selected" $
|
||||
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
|
||||
else
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||
)
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "fails if select is not specified" $
|
||||
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
|
||||
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for view limited_article_stars"}|]
|
||||
else
|
||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
||||
)
|
||||
{ matchStatus = 401
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
context "POST with ?columns parameter" $ do
|
||||
it "ignores json keys not included in ?columns" $ do
|
||||
request methodPost "/articles?columns=id,body" [("Prefer", "return=representation")]
|
||||
[json| {"id": 200, "body": "xxx", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`
|
||||
[json|[{"id": 200, "body": "xxx", "owner": "postgrest_test_anonymous"}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [] }
|
||||
request methodPost "/articles?columns=id,body&select=id,body" [("Prefer", "return=representation")]
|
||||
[json| [
|
||||
{"id": 201, "body": "yyy", "smth": "here", "other": "stuff", "fake_id": 13},
|
||||
{"id": 202, "body": "zzz", "garbage": "%%$&", "kkk": "jjj"},
|
||||
{"id": 203, "body": "aaa", "hey": "ho"} ]|] `shouldRespondWith`
|
||||
[json|[
|
||||
{"id": 201, "body": "yyy"},
|
||||
{"id": 202, "body": "zzz"},
|
||||
{"id": 203, "body": "aaa"} ]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [] }
|
||||
|
||||
-- TODO parse columns error message needs to be improved
|
||||
it "disallows blank ?columns" $
|
||||
post "/articles?columns="
|
||||
[json|[
|
||||
{"id": 204, "body": "yyy"},
|
||||
{"id": 205, "body": "zzz"}]|]
|
||||
`shouldRespondWith`
|
||||
[json| {"details":"unexpected end of input expecting field name (* or [a..z0..9_])","message":"\"failed to parse columns parameter ()\" (line 1, column 1)"} |]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "disallows array elements that are not json objects" $
|
||||
post "/articles?columns=id,body"
|
||||
[json|[
|
||||
{"id": 204, "body": "yyy"},
|
||||
333,
|
||||
"asdf",
|
||||
{"id": 205, "body": "zzz"}]|] `shouldRespondWith`
|
||||
[json|{
|
||||
"code": "22023",
|
||||
"details": null,
|
||||
"hint": null,
|
||||
"message": "argument of json_populate_recordset must be an array of objects"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
describe "CSV insert" $ do
|
||||
|
||||
after_ (clearTable "menagerie") . context "disparate csv types" $
|
||||
context "disparate csv types" $
|
||||
it "succeeds with multipart response" $ do
|
||||
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
|
||||
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
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||
, matchBody = bodyEquals inserted
|
||||
}
|
||||
|
||||
after_ (clearTable "no_pk") . context "requesting full representation" $ do
|
||||
context "requesting full representation" $ do
|
||||
it "returns full details of inserted record" $
|
||||
request methodPost "/no_pk"
|
||||
[("Content-Type", "text/csv"), ("Prefer", "return=representation")]
|
||||
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||
"a,b\nbar,baz"
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just [json| { "a":"bar", "b":"baz" } |]
|
||||
, matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "application/json",
|
||||
`shouldRespondWith` "a,b\nbar,baz"
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
||||
"Location" <:> "/no_pk?a=eq.bar&b=eq.baz"]
|
||||
}
|
||||
|
||||
it "can post nulls" $
|
||||
request methodPost "/no_pk"
|
||||
[("Content-Type", "text/csv"), ("Prefer", "return=representation")]
|
||||
[("Content-Type", "text/csv"), ("Accept", "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",
|
||||
`shouldRespondWith` "a,b\n,foo"
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
||||
"Location" <:> "/no_pk?a=is.null&b=eq.foo"]
|
||||
}
|
||||
|
||||
after_ (clearTable "no_pk") . context "with wrong number of columns" $ do
|
||||
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 "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
|
||||
it "only returns the requested column header with its associated data" $
|
||||
request methodPost "/projects?select=id"
|
||||
[("Content-Type", "text/csv"), ("Accept", "text/csv"), ("Prefer", "return=representation")]
|
||||
"id,name,client_id\n8,Xenix,1\n9,Windows NT,1"
|
||||
`shouldRespondWith` "id\n8\n9"
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8",
|
||||
"Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
describe "Putting record" $ do
|
||||
context "with wrong number of columns" $
|
||||
it "fails for too few" $
|
||||
request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"All lines must have same number of fields"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "to unkonwn uri" $
|
||||
it "gives a 404" $
|
||||
request methodPut "/fake" []
|
||||
[json| { "real": false } |]
|
||||
`shouldRespondWith` 404
|
||||
context "with unicode values" $
|
||||
it "succeeds and returns usable location header" $ do
|
||||
let payload = [json| { "a":"圍棋", "b":"¥" } |]
|
||||
p <- request methodPost "/no_pk"
|
||||
[("Prefer", "return=representation")]
|
||||
payload
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` "["<>payload<>"]"
|
||||
simpleStatus p `shouldBe` created201
|
||||
|
||||
context "to a known uri" $ do
|
||||
context "without a fully-specified primary key" $
|
||||
it "is not an allowed operation" $
|
||||
request methodPut "/compound_pk?k1=eq.12" []
|
||||
[json| { "k1":12, "k2":42 } |]
|
||||
`shouldRespondWith` 405
|
||||
|
||||
context "with a fully-specified primary key" $ do
|
||||
|
||||
context "not specifying every column in the table" $
|
||||
it "is rejected for lack of idempotence" $
|
||||
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
|
||||
p <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []
|
||||
[json| { "k1":12, "k2":42, "extra":3 } |]
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` ""
|
||||
simpleStatus p `shouldBe` status204
|
||||
|
||||
r <- get "/compound_pk?k1=eq.12&k2=eq.42"
|
||||
let rows = fromJust (JSON.decode $ simpleBody r :: Maybe [CompoundPK])
|
||||
liftIO $ do
|
||||
length rows `shouldBe` 1
|
||||
let record = head rows
|
||||
compoundK1 record `shouldBe` 12
|
||||
compoundK2 record `shouldBe` 42
|
||||
compoundExtra record `shouldBe` Just 3
|
||||
|
||||
it "can update an existing record" $ do
|
||||
_ <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []
|
||||
[json| { "k1":12, "k2":42, "extra":4 } |]
|
||||
_ <- request methodPut "/compound_pk?k1=eq.12&k2=eq.42" []
|
||||
[json| { "k1":12, "k2":42, "extra":5 } |]
|
||||
|
||||
r <- get "/compound_pk?k1=eq.12&k2=eq.42"
|
||||
let rows = fromJust (JSON.decode $ simpleBody r :: Maybe [CompoundPK])
|
||||
liftIO $ do
|
||||
length rows `shouldBe` 1
|
||||
let record = head rows
|
||||
compoundExtra record `shouldBe` Just 5
|
||||
|
||||
context "with an auto-incrementing primary key" . after_ (clearTable "auto_incrementing_pk") $
|
||||
|
||||
it "succeeds with 204" $
|
||||
request methodPut "/auto_incrementing_pk?id=eq.1" []
|
||||
[json| {
|
||||
"id":1,
|
||||
"nullable_string":"hi",
|
||||
"non_nullable_string":"bye",
|
||||
"inserted_at": "2020-11-11"
|
||||
} |]
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Nothing,
|
||||
matchStatus = 204,
|
||||
matchHeaders = []
|
||||
}
|
||||
let Just location = lookup hLocation $ simpleHeaders p
|
||||
r <- get location
|
||||
liftIO $ simpleBody r `shouldBe` "["<>payload<>"]"
|
||||
|
||||
describe "Patching record" $ do
|
||||
|
||||
context "to unkonwn uri" $
|
||||
it "gives a 404" $
|
||||
context "to unknown uri" $
|
||||
it "indicates no table found by returning 404" $
|
||||
request methodPatch "/fake" []
|
||||
[json| { "real": false } |]
|
||||
`shouldRespondWith` 404
|
||||
|
||||
context "on an empty table" $
|
||||
it "indicates no records found to update" $
|
||||
request methodPatch "/simple_pk" []
|
||||
it "indicates no records found to update by returning 404" $
|
||||
request methodPatch "/empty_table" []
|
||||
[json| { "extra":20 } |]
|
||||
`shouldRespondWith` 404
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 404,
|
||||
matchHeaders = []
|
||||
}
|
||||
|
||||
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" []
|
||||
[json| { "id":42 } |]
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Nothing,
|
||||
matchStatus = 204,
|
||||
matchHeaders = ["Content-Range" <:> "0-0/1"]
|
||||
}
|
||||
`shouldSatisfy` matchHeader "Content-Range" "\\*/\\*"
|
||||
p <- request methodPatch "/items?id=eq.2" [] [json| { "id":42 } |]
|
||||
pure p `shouldRespondWith` ""
|
||||
{ matchStatus = 204,
|
||||
matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
liftIO $ lookup hContentType (simpleHeaders p) `shouldBe` Nothing
|
||||
|
||||
-- check it really got updated
|
||||
g' <- get "/items?id=eq.42"
|
||||
liftIO $ simpleHeaders g'
|
||||
`shouldSatisfy` matchHeader "Content-Range" "0-0/1"
|
||||
`shouldSatisfy` matchHeader "Content-Range" "0-0/\\*"
|
||||
-- put value back for other tests
|
||||
void $ request methodPatch "/items?id=eq.42" [] [json| { "id":2 } |]
|
||||
|
||||
it "returns empty array when no rows updated and return=rep" $
|
||||
request methodPatch "/items?id=eq.999999"
|
||||
[("Prefer", "return=representation")] [json| { "id":999999 } |]
|
||||
`shouldRespondWith` "[]"
|
||||
{
|
||||
matchStatus = 404,
|
||||
matchHeaders = []
|
||||
}
|
||||
|
||||
it "gives a 404 when no rows updated" $
|
||||
request methodPatch "/items?id=eq.99999999" []
|
||||
[json| { "id": 42 } |]
|
||||
`shouldRespondWith` 404
|
||||
|
||||
it "returns updated object as array when return=rep" $
|
||||
request methodPatch "/items?id=eq.2"
|
||||
[("Prefer", "return=representation")] [json| { "id":2 } |]
|
||||
`shouldRespondWith` [str|[{"id":2}]|]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
it "can update multiple items" $ do
|
||||
replicateM_ 10 $ post "/auto_incrementing_pk"
|
||||
@@ -259,20 +473,36 @@ spec = afterAll_ resetDb $ around withApp $ do
|
||||
[json| { non_nullable_string: "c" } |]
|
||||
g <- get "/auto_incrementing_pk?non_nullable_string=eq.c"
|
||||
liftIO $ simpleHeaders g
|
||||
`shouldSatisfy` matchHeader "Content-Range" "0-9/10"
|
||||
`shouldSatisfy` matchHeader "Content-Range" "0-9/\\*"
|
||||
|
||||
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 }] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "filtering by a computed column" $ do
|
||||
it "is successful" $
|
||||
request methodPatch
|
||||
"/items?is_first=eq.true"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 100 } |]
|
||||
`shouldRespondWith` [json| [{ id: 100 }] |]
|
||||
{ matchStatus = 200,
|
||||
matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
it "indicates no records updated by returning 404" $
|
||||
request methodPatch
|
||||
"/items?always_true=eq.false"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 100 } |]
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 404,
|
||||
matchHeaders = []
|
||||
}
|
||||
|
||||
it "can 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 } |]
|
||||
@@ -281,23 +511,80 @@ spec = afterAll_ resetDb $ around withApp $ do
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { id: 99 } |]
|
||||
`shouldRespondWith` [json| [{id:99}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
-- put value back for other tests
|
||||
void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]
|
||||
|
||||
it "makes no updates and returns 204, when patching with an empty json object/array" $ do
|
||||
request methodPatch "/items" [] [json| {} |]
|
||||
`shouldRespondWith` ""
|
||||
{
|
||||
matchStatus = 204,
|
||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
request methodPatch "/items" [] [json| [] |]
|
||||
`shouldRespondWith` ""
|
||||
{
|
||||
matchStatus = 204,
|
||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
request methodPatch "/items" [] [json| [{}] |]
|
||||
`shouldRespondWith` ""
|
||||
{
|
||||
matchStatus = 204,
|
||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "makes no updates and and returns 200, when patching with an empty json object and return=rep" $
|
||||
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
|
||||
`shouldRespondWith` "[]"
|
||||
{
|
||||
matchStatus = 200,
|
||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
context "with unicode values" $
|
||||
it "succeeds and returns values intact" $ do
|
||||
void $ request methodPost "/no_pk" []
|
||||
[json| { "a":"patchme", "b":"patchme" } |]
|
||||
let payload = [json| { "a":"圍棋", "b":"¥" } |]
|
||||
p <- request methodPatch "/no_pk?a=eq.patchme&b=eq.patchme"
|
||||
[("Prefer", "return=representation")] payload
|
||||
liftIO $ do
|
||||
simpleBody p `shouldBe` "["<>payload<>"]"
|
||||
simpleStatus p `shouldBe` ok200
|
||||
|
||||
context "PATCH with ?columns parameter" $ do
|
||||
it "ignores json keys not included in ?columns" $
|
||||
request methodPatch "/articles?id=eq.200&columns=body" [("Prefer", "return=representation")]
|
||||
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`
|
||||
[json|[{"id": 200, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [] }
|
||||
|
||||
it "ignores json keys and gives 404 if no record updated" $
|
||||
request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
|
||||
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404
|
||||
|
||||
describe "Row level permission" $
|
||||
it "set user_id when inserting rows" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
|
||||
_ <- 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"
|
||||
[ authHeaderBasic "jdoe" "1234", ("Prefer", "return=representation") ]
|
||||
[ auth, ("Prefer", "return=representation") ]
|
||||
[json| { "secret": "nyancat" } |]
|
||||
liftIO $ do
|
||||
simpleBody p1 `shouldBe` [json| { "owner":"jdoe", "secret":"nyancat" } |]
|
||||
simpleStatus p1 `shouldBe` created201
|
||||
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") ]
|
||||
[ authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqcm9lIn0.2e7mx0U4uDcInlbJVOBGlrRufwqWLINDIEDC1vS0nw8", ("Prefer", "return=representation") ]
|
||||
[json| { "secret": "lolcat", "owner": "hacker" } |]
|
||||
liftIO $ do
|
||||
simpleBody p2 `shouldBe` [json| { "owner":"jroe", "secret":"lolcat" } |]
|
||||
simpleStatus p2 `shouldBe` created201
|
||||
simpleBody p2 `shouldBe` [str|[{"owner":"jroe","secret":"lolcat"}]|]
|
||||
simpleStatus p2 `shouldBe` created201
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
module Feature.JsonOperatorSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import PostgREST.Types (PgVersion, pgVersion112)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith Application
|
||||
spec actualPgVersion = describe "json and jsonb operators" $ do
|
||||
context "Shaping response with select parameter" $ do
|
||||
it "obtains a json subfield one level with casting" $
|
||||
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
|
||||
[json| [{"foo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "renames json subfield one level with casting" $
|
||||
get "/complex_items?id=eq.1&select=myFoo:settings->>foo::json" `shouldRespondWith`
|
||||
[json| [{"myFoo":{"int":1,"bar":"baz"}}] |] -- the value of foo here is of type "text"
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails on bad casting (data of the wrong format)" $
|
||||
get "/complex_items?select=settings->foo->>bar::integer"
|
||||
`shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
||||
{ matchStatus = 400 , matchHeaders = [] }
|
||||
|
||||
it "obtains a json subfield two levels (string)" $
|
||||
get "/complex_items?id=eq.1&select=settings->foo->>bar" `shouldRespondWith`
|
||||
[json| [{"bar":"baz"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "renames json subfield two levels (string)" $
|
||||
get "/complex_items?id=eq.1&select=myBar:settings->foo->>bar" `shouldRespondWith`
|
||||
[json| [{"myBar":"baz"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "obtains a json subfield two levels with casting (int)" $
|
||||
get "/complex_items?id=eq.1&select=settings->foo->>int::integer" `shouldRespondWith`
|
||||
[json| [{"int":1}] |] -- the value in the db is an int, but here we expect a string for now
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "renames json subfield two levels with casting (int)" $
|
||||
get "/complex_items?id=eq.1&select=myInt:settings->foo->>int::integer" `shouldRespondWith`
|
||||
[json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
-- TODO the status code for the error is 404, this is because 42883 represents undefined function
|
||||
-- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate
|
||||
it "fails when a double arrow ->> is followed with a single arrow ->" $ do
|
||||
get "/json_arr?select=data->>c->1"
|
||||
`shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[json|
|
||||
{"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
|
||||
"details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
|
||||
else
|
||||
[json|
|
||||
{"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
|
||||
"details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
|
||||
)
|
||||
{ matchStatus = 404 , matchHeaders = [] }
|
||||
get "/json_arr?select=data->>c->b"
|
||||
`shouldRespondWith` (
|
||||
if actualPgVersion >= pgVersion112 then
|
||||
[json|
|
||||
{"hint":"No operator matches the given name and argument types. You might need to add explicit type casts.",
|
||||
"details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
|
||||
else
|
||||
[json|
|
||||
{"hint":"No operator matches the given name and argument type(s). You might need to add explicit type casts.",
|
||||
"details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
|
||||
)
|
||||
{ matchStatus = 404 , matchHeaders = [] }
|
||||
|
||||
context "with array index" $ do
|
||||
it "can get array of ints and alias/cast it" $ do
|
||||
get "/json_arr?select=data->>0::int&id=in.(1,2)" `shouldRespondWith`
|
||||
[json| [{"data":1}, {"data":4}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=idx0:data->>0::int,idx1:data->>1::int&id=in.(1,2)" `shouldRespondWith`
|
||||
[json| [{"idx0":1,"idx1":2}, {"idx0":4,"idx1":5}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can get nested array of ints" $ do
|
||||
get "/json_arr?select=data->0->>1::int&id=in.(3,4)" `shouldRespondWith`
|
||||
[json| [{"data":8}, {"data":7}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->0->0->>1::int&id=in.(3,4)" `shouldRespondWith`
|
||||
[json| [{"data":null}, {"data":6}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can get array of objects" $ do
|
||||
get "/json_arr?select=data->0->>a&id=in.(5,6)" `shouldRespondWith`
|
||||
[json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->0->a->>2&id=in.(5,6)" `shouldRespondWith`
|
||||
[json| [{"a":null}, {"a":"3"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can get array in object keys" $ do
|
||||
get "/json_arr?select=data->c->>0::json&id=in.(7,8)" `shouldRespondWith`
|
||||
[json| [{"c":1}, {"c":{"d": [4,5,6,7,8]}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->c->0->d->>4::int&id=in.(7,8)" `shouldRespondWith`
|
||||
[json| [{"d":null}, {"d":8}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "only treats well formed numbers as indexes" $
|
||||
get "/json_arr?select=data->0->0xy1->1->23-xy-45->1->xy-6->>0::int&id=eq.9" `shouldRespondWith`
|
||||
[json| [{"xy-6":3}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "finishing json path with single arrow ->" $ do
|
||||
it "works when finishing with a key" $ do
|
||||
get "/json_arr?select=data->c&id=in.(7,8)" `shouldRespondWith`
|
||||
[json| [{"c":[1,2,3]}, {"c":[{"d": [4,5,6,7,8]}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
|
||||
[json| [{"a":"A"}, {"a":[1,2,3]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "works when finishing with an index" $ do
|
||||
get "/json_arr?select=data->0->a&id=in.(5,6)" `shouldRespondWith`
|
||||
[json| [{"a":"A"}, {"a":[1,2,3]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->c->0->d&id=eq.8" `shouldRespondWith`
|
||||
[json| [{"d":[4,5,6,7,8]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "filtering response" $ do
|
||||
it "can filter by properties inside json column" $ do
|
||||
get "/json?data->foo->>bar=eq.baz" `shouldRespondWith`
|
||||
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json?data->foo->>bar=eq.fake" `shouldRespondWith`
|
||||
[json| [] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter by properties inside json column using not" $
|
||||
get "/json?data->foo->>bar=not.eq.baz" `shouldRespondWith`
|
||||
[json| [] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter by properties inside json column using ->>" $
|
||||
get "/json?data->>id=eq.1" `shouldRespondWith`
|
||||
[json| [{"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can be filtered with and/or" $
|
||||
get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
|
||||
[json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter by array indexes" $ do
|
||||
get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`
|
||||
[json| [{"data":[1, 2, 3]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->1->>2=eq.13" `shouldRespondWith`
|
||||
[json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->1->>b=eq.B" `shouldRespondWith`
|
||||
[json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->1->b->>1=eq.5" `shouldRespondWith`
|
||||
[json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter jsonb" $ do
|
||||
get "/jsonb_test?data=eq.{\"e\":1}" `shouldRespondWith`
|
||||
[json| [{"id":4,"data":{"e": 1}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/jsonb_test?data->a=eq.{\"b\":2}" `shouldRespondWith`
|
||||
[json| [{"id":1,"data":{"a": {"b": 2}}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/jsonb_test?data->c=eq.[1,2,3]" `shouldRespondWith`
|
||||
[json| [{"id":2,"data":{"c": [1, 2, 3]}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/jsonb_test?data->0=eq.{\"d\":\"test\"}" `shouldRespondWith`
|
||||
[json| [{"id":3,"data":[{"d": "test"}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "ordering response" $ do
|
||||
it "orders by a json column property asc" $
|
||||
get "/json?order=data->>id.asc" `shouldRespondWith`
|
||||
[json| [{"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}, {"data": {"id": 3}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "orders by a json column with two level property nulls first" $
|
||||
get "/json?order=data->foo->>bar.nullsfirst" `shouldRespondWith`
|
||||
[json| [{"data": {"id": 3}}, {"data": {"id": 0}}, {"data": {"id": 1, "foo": {"bar": "baz"}}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "Patching record, in a nonempty table" $
|
||||
it "can set a json column to escaped value" $ do
|
||||
_ <- post "/json" [json| { data: {"escaped":"bar"} } |]
|
||||
request methodPatch "/json?data->>escaped=eq.bar"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| { "data": { "escaped":" \"bar" } } |]
|
||||
`shouldRespondWith` [json| [{ "data": { "escaped":" \"bar" } }] |]
|
||||
{ matchStatus = 200 , matchHeaders = [] }
|
||||
@@ -0,0 +1,31 @@
|
||||
module Feature.NoJwtSpec where
|
||||
|
||||
-- {{{ Imports
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
-- }}}
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec = describe "server started without JWT secret" $ do
|
||||
|
||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||
it "responds with error on attempted auth" $ do
|
||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Server lacks JWT secret"}|]
|
||||
{ matchStatus = 500
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
it "behaves normally when user does not attempt auth" $
|
||||
request methodGet "/items" [] ""
|
||||
`shouldRespondWith` 200
|
||||
@@ -0,0 +1,17 @@
|
||||
module Feature.NonexistentSchemaSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Protolude hiding (get)
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
describe "Non existent api schema" $ do
|
||||
it "succeeds when requesting root path" $
|
||||
get "/" `shouldRespondWith` 200
|
||||
|
||||
it "gives 404 when requesting a nonexistent table in this nonexistent schema" $
|
||||
get "/nonexistent_table" `shouldRespondWith` 404
|
||||
@@ -0,0 +1,55 @@
|
||||
module Feature.PgVersion95Spec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec = describe "features supported on PostgreSQL 9.5" $
|
||||
context "json array negative index" $ do
|
||||
it "can select with negative indexes" $ do
|
||||
get "/json_arr?select=data->>-1::int&id=in.(1,2)" `shouldRespondWith`
|
||||
[json| [{"data":3}, {"data":6}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->0->>-2::int&id=in.(3,4)" `shouldRespondWith`
|
||||
[json| [{"data":8}, {"data":7}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->-2->>a&id=in.(5,6)" `shouldRespondWith`
|
||||
[json| [{"a":"A"}, {"a":"[1,2,3]"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter with negative indexes" $ do
|
||||
get "/json_arr?select=data&data->>-3=eq.1" `shouldRespondWith`
|
||||
[json| [{"data":[1, 2, 3]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->-1->>-3=eq.11" `shouldRespondWith`
|
||||
[json| [{"data":[[9, 8, 7], [11, 12, 13]]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->-1->>b=eq.B" `shouldRespondWith`
|
||||
[json| [{"data":[{"a": "A"}, {"b": "B"}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data&data->-1->b->>-1=eq.5" `shouldRespondWith`
|
||||
[json| [{"data":[{"a": [1,2,3]}, {"b": [4,5]}]}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "should fail on badly formed negatives" $ do
|
||||
get "/json_arr?select=data->>-78xy" `shouldRespondWith`
|
||||
[json|
|
||||
{"details": "unexpected 'x' expecting digit, \"->\", \"::\" or end of input",
|
||||
"message": "\"failed to parse select parameter (data->>-78xy)\" (line 1, column 11)"} |]
|
||||
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->>--34" `shouldRespondWith`
|
||||
[json|
|
||||
{"details": "unexpected \"-\" expecting digit",
|
||||
"message": "\"failed to parse select parameter (data->>--34)\" (line 1, column 9)"} |]
|
||||
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
get "/json_arr?select=data->>-xy-4" `shouldRespondWith`
|
||||
[json|
|
||||
{"details":"unexpected \"x\" expecting digit",
|
||||
"message":"\"failed to parse select parameter (data->>-xy-4)\" (line 1, column 9)"} |]
|
||||
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
@@ -0,0 +1,101 @@
|
||||
module Feature.PgVersion96Spec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
describe "features supported on PostgreSQL 9.6" $ do
|
||||
context "GUC headers" $ do
|
||||
it "succeeds setting the headers" $ do
|
||||
get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id"
|
||||
`shouldRespondWith` [json|[{"id": 2}]|]
|
||||
{matchHeaders = [
|
||||
matchContentTypeJson,
|
||||
"X-Test" <:> "key1=val1; someValue; key2=val2",
|
||||
"X-Test-2" <:> "key1=val1"]}
|
||||
get "/rpc/get_int_and_guc_headers?num=1"
|
||||
`shouldRespondWith` [json|1|]
|
||||
{matchHeaders = [
|
||||
matchContentTypeJson,
|
||||
"X-Test" <:> "key1=val1; someValue; key2=val2",
|
||||
"X-Test-2" <:> "key1=val1"]}
|
||||
post "/rpc/get_int_and_guc_headers" [json|{"num": 1}|]
|
||||
`shouldRespondWith` [json|1|]
|
||||
{matchHeaders = [
|
||||
matchContentTypeJson,
|
||||
"X-Test" <:> "key1=val1; someValue; key2=val2",
|
||||
"X-Test-2" <:> "key1=val1"]}
|
||||
|
||||
it "fails when setting headers with wrong json structure" $ do
|
||||
get "/rpc/bad_guc_headers_1"
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
|
||||
{ matchStatus = 500
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
get "/rpc/bad_guc_headers_2"
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
|
||||
{ matchStatus = 500
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
get "/rpc/bad_guc_headers_3"
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
|
||||
{ matchStatus = 500
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
post "/rpc/bad_guc_headers_1" [json|{}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
|
||||
{ matchStatus = 500
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
it "can set the same http header twice" $
|
||||
get "/rpc/set_cookie_twice"
|
||||
`shouldRespondWith` "null"
|
||||
{matchHeaders = [
|
||||
matchContentTypeJson,
|
||||
"Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/",
|
||||
"Set-Cookie" <:> "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"]}
|
||||
|
||||
context "Use of the phraseto_tsquery function" $ do
|
||||
it "finds matches" $
|
||||
get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith`
|
||||
[json| [{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "finds matches with different dictionaries" $
|
||||
get "/tsearch?text_search_vector=phfts(german).Art%20Spass" `shouldRespondWith`
|
||||
[json| [{"text_search_vector": "'art':4 'spass':5 'unmog':7" }] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can be negated with not operator" $
|
||||
get "/tsearch?text_search_vector=not.phfts(english).The%20Fat%20Cats" `shouldRespondWith`
|
||||
[json| [
|
||||
{"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
|
||||
{"text_search_vector": "'also':2 'fun':3 'possibl':8"},
|
||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
|
||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can be used with or query param" $
|
||||
get "/tsearch?or=(text_search_vector.phfts(german).Art%20Spass, text_search_vector.phfts(french).amusant, text_search_vector.fts(english).impossible)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"text_search_vector": "'fun':5 'imposs':9 'kind':3" },
|
||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}
|
||||
]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "should work when used with GET RPC" $
|
||||
get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith`
|
||||
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
@@ -0,0 +1,13 @@
|
||||
module Feature.ProxySpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
describe "GET / with proxy" $
|
||||
it "returns a valid openapi spec with proxy" $
|
||||
validateOpenApiResponse [("Accept", "application/openapi+json")]
|
||||
@@ -0,0 +1,44 @@
|
||||
module Feature.QueryLimitedSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
describe "Requesting many items with server limits enabled" $ do
|
||||
it "restricts results" $
|
||||
get "/items"
|
||||
`shouldRespondWith` [json| [{"id":1},{"id":2}] |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||
}
|
||||
|
||||
it "respects additional client limiting" $ do
|
||||
r <- request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 0) ""
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "0-0/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
it "limit works on all levels" $
|
||||
get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
|
||||
`shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||
}
|
||||
|
||||
it "limit is not applied to parent embeds" $
|
||||
get "/tasks?select=id,project(id)&id=gt.5"
|
||||
`shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||
}
|
||||
@@ -1,18 +1,104 @@
|
||||
module Feature.RangeSpec where
|
||||
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus))
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: Spec
|
||||
spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable "items")
|
||||
. around withApp $
|
||||
describe "GET /items" $ do
|
||||
defaultRange :: BL.ByteString
|
||||
defaultRange = [json| { "min": 0, "max": 15 } |]
|
||||
|
||||
emptyRange :: BL.ByteString
|
||||
emptyRange = [json| { "min": 2, "max": 2 } |]
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec = do
|
||||
describe "POST /rpc/getitemrange" $ do
|
||||
context "without range headers" $ do
|
||||
context "with response under server size limit" $
|
||||
it "returns whole range with status 200" $
|
||||
post "/rpc/getitemrange" defaultRange `shouldRespondWith` 200
|
||||
|
||||
context "when I don't want the count" $ do
|
||||
it "returns range Content-Range with */* for empty range" $
|
||||
request methodPost "/rpc/getitemrange" [] emptyRange
|
||||
`shouldRespondWith` [json| [] |] {matchHeaders = ["Content-Range" <:> "*/*"]}
|
||||
|
||||
it "returns range Content-Range with range/*" $
|
||||
request methodPost "/rpc/getitemrange" [] defaultRange
|
||||
`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}] |]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||
|
||||
context "with range headers" $ do
|
||||
context "of acceptable range" $ do
|
||||
it "succeeds with partial content" $ do
|
||||
r <- request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) defaultRange
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "0-1/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
it "understands open-ended ranges" $
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFrom 0) defaultRange
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "returns an empty body when there are no results" $
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) emptyRange
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "allows one-item requests" $ do
|
||||
r <- request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 0) defaultRange
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "0-0/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
it "handles ranges beyond collection length via truncation" $ do
|
||||
r <- request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFromTo 10 100) defaultRange
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "10-14/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
context "of invalid range" $ do
|
||||
it "fails with 416 for offside range" $
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs $ ByteRangeFromTo 1 0) emptyRange
|
||||
`shouldRespondWith` 416
|
||||
|
||||
it "refuses a range with nonzero start when there are no items" $
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) emptyRange
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||
}
|
||||
|
||||
it "refuses a range requesting start past last item" $
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) defaultRange
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||
}
|
||||
|
||||
describe "GET /items" $ do
|
||||
context "without range headers" $ do
|
||||
context "with response under server size limit" $
|
||||
it "returns whole range with status 200" $
|
||||
@@ -22,40 +108,99 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
||||
it "returns range Content-Range with /*" $
|
||||
request methodGet "/menagerie"
|
||||
[("Prefer", "count=none")] ""
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "[]"
|
||||
, matchStatus = 200
|
||||
`shouldRespondWith` "[]"
|
||||
{ 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/*"]
|
||||
}
|
||||
`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}] |]
|
||||
{ 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/*"]
|
||||
}
|
||||
`shouldRespondWith` [json| [{"id":1}] |]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-0/*"] }
|
||||
|
||||
context "with limit/offset parameters" $ do
|
||||
it "no parameters return everything" $
|
||||
get "/items?select=id&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}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-14/*"]
|
||||
}
|
||||
it "top level limit with parameter" $
|
||||
get "/items?select=id&order=id.asc&limit=3"
|
||||
`shouldRespondWith` [json|[{"id":1},{"id":2},{"id":3}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-2/*"]
|
||||
}
|
||||
it "headers override get parameters" $
|
||||
request methodGet "/items?select=id&order=id.asc&limit=3"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||
`shouldRespondWith` [json|[{"id":1},{"id":2}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||
}
|
||||
|
||||
it "limit works on all levels" $
|
||||
get "/clients?select=id,projects(id,tasks(id))&order=id.asc&limit=1&projects.order=id.asc&projects.limit=2&projects.tasks.order=id.asc&projects.tasks.limit=1"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1}]},{"id":2,"tasks":[{"id":3}]}]}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
it "limit and offset works on first level" $
|
||||
get "/items?select=id&order=id.asc&limit=3&offset=2"
|
||||
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
||||
}
|
||||
|
||||
it "succeeds if offset equals 0 as a no-op" $
|
||||
get "/items?select=id&offset=0"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-14/*"]
|
||||
}
|
||||
|
||||
it "succeeds if offset is negative as a no-op" $
|
||||
get "/items?select=id&offset=-4"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-14/*"]
|
||||
}
|
||||
|
||||
it "fails if limit equals 0" $
|
||||
get "/items?select=id&limit=0"
|
||||
`shouldRespondWith` [json|{"message":"HTTP Range error"}|]
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "fails if limit is negative" $
|
||||
get "/items?select=id&limit=-1"
|
||||
`shouldRespondWith` [json|{"message":"HTTP Range error"}|]
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "with range headers" $ do
|
||||
|
||||
context "of acceptable range" $ do
|
||||
it "succeeds with partial content" $ do
|
||||
r <- request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "0-1/15"
|
||||
simpleStatus r `shouldBe` partialContent206
|
||||
matchHeader "Content-Range" "0-1/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
it "understands open-ended ranges" $
|
||||
request methodGet "/items"
|
||||
@@ -65,10 +210,9 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
||||
it "returns an empty body when there are no results" $
|
||||
request methodGet "/menagerie"
|
||||
(rangeHdrs $ ByteRangeFromTo 0 1) ""
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Just "[]"
|
||||
, matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "allows one-item requests" $ do
|
||||
@@ -76,16 +220,16 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
||||
(rangeHdrs $ ByteRangeFromTo 0 0) ""
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "0-0/15"
|
||||
simpleStatus r `shouldBe` partialContent206
|
||||
matchHeader "Content-Range" "0-0/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
it "handles ranges beyond collection length via truncation" $ do
|
||||
r <- request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFromTo 10 100) ""
|
||||
liftIO $ do
|
||||
simpleHeaders r `shouldSatisfy`
|
||||
matchHeader "Content-Range" "10-14/15"
|
||||
simpleStatus r `shouldBe` partialContent206
|
||||
matchHeader "Content-Range" "10-14/*"
|
||||
simpleStatus r `shouldBe` ok200
|
||||
|
||||
context "of invalid range" $ do
|
||||
it "fails with 416 for offside range" $
|
||||
@@ -95,18 +239,16 @@ spec = beforeAll (clearTable "items" >> createItems 15) . afterAll_ (clearTable
|
||||
|
||||
it "refuses a range with nonzero start when there are no items" $
|
||||
request methodGet "/menagerie"
|
||||
(rangeHdrs $ ByteRangeFromTo 1 2) ""
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Nothing
|
||||
, matchStatus = 416
|
||||
(rangeHdrsWithCount $ ByteRangeFromTo 1 2) ""
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||
}
|
||||
|
||||
it "refuses a range requesting start past last item" $
|
||||
request methodGet "/items"
|
||||
(rangeHdrs $ ByteRangeFromTo 100 199) ""
|
||||
`shouldRespondWith` ResponseMatcher {
|
||||
matchBody = Nothing
|
||||
, matchStatus = 416
|
||||
(rangeHdrsWithCount $ ByteRangeFromTo 100 199) ""
|
||||
`shouldRespondWith` "[]"
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/15"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
module Feature.RootSpec where
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
describe "root spec function" $ do
|
||||
it "accepts application/openapi+json" $
|
||||
request methodGet "/"
|
||||
[("Accept","application/openapi+json")] "" `shouldRespondWith`
|
||||
[json|{
|
||||
"swagger": "2.0",
|
||||
"info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}
|
||||
}|]
|
||||
{ matchHeaders = ["Content-Type" <:> "application/openapi+json; charset=utf-8"] }
|
||||
|
||||
it "accepts application/json" $
|
||||
request methodGet "/"
|
||||
[("Accept", "application/json")] "" `shouldRespondWith`
|
||||
[json| [{"table": "items"}, {"table": "subitems"}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
@@ -0,0 +1,553 @@
|
||||
module Feature.RpcSpec where
|
||||
|
||||
import qualified Data.ByteString.Lazy as BL (empty)
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleBody, simpleStatus))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Text.Heredoc
|
||||
|
||||
import PostgREST.Types (PgVersion, pgVersion100, pgVersion95,
|
||||
pgVersion96)
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: PgVersion -> SpecWith Application
|
||||
spec actualPgVersion =
|
||||
describe "remote procedure call" $ do
|
||||
context "a proc that returns a set" $ do
|
||||
it "returns paginated results" $ do
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrs (ByteRangeFromTo 0 0)) [json| { "min": 2, "max": 4 } |]
|
||||
`shouldRespondWith` [json| [{"id":3}] |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
request methodGet "/rpc/getitemrange?min=2&max=4"
|
||||
(rangeHdrs (ByteRangeFromTo 0 0)) ""
|
||||
`shouldRespondWith` [json| [{"id":3}] |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||
}
|
||||
|
||||
it "includes total count if requested" $ do
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(rangeHdrsWithCount (ByteRangeFromTo 0 0))
|
||||
[json| { "min": 2, "max": 4 } |]
|
||||
`shouldRespondWith` [json| [{"id":3}] |]
|
||||
{ matchStatus = 206 -- it now knows the response is partial
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
||||
}
|
||||
request methodGet "/rpc/getitemrange?min=2&max=4"
|
||||
(rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""
|
||||
`shouldRespondWith` [json| [{"id":3}] |]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
||||
}
|
||||
|
||||
it "returns proper json" $ do
|
||||
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
|
||||
[json| [ {"id": 3}, {"id":4} ] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/getitemrange?min=2&max=4" `shouldRespondWith`
|
||||
[json| [ {"id": 3}, {"id":4} ] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns CSV" $ do
|
||||
request methodPost "/rpc/getitemrange"
|
||||
(acceptHdrs "text/csv")
|
||||
[json| { "min": 2, "max": 4 } |]
|
||||
`shouldRespondWith` "id\n3\n4"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||
}
|
||||
request methodGet "/rpc/getitemrange?min=2&max=4"
|
||||
(acceptHdrs "text/csv") ""
|
||||
`shouldRespondWith` "id\n3\n4"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||
}
|
||||
|
||||
context "unknown function" $ do
|
||||
it "returns 404" $
|
||||
post "/rpc/fakefunc" [json| {} |] `shouldRespondWith` 404
|
||||
it "should fail with 404 on unknown proc name" $
|
||||
get "/rpc/fake" `shouldRespondWith` 404
|
||||
it "should fail with 404 on unknown proc args" $ do
|
||||
get "/rpc/sayhello" `shouldRespondWith` 404
|
||||
get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404
|
||||
it "should not ignore unknown args and fail with 404" $
|
||||
get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`
|
||||
let
|
||||
message :: Text
|
||||
message
|
||||
| actualPgVersion < pgVersion95 = "function test.add_them(a := integer, b := integer, smthelse := text) does not exist"
|
||||
| otherwise = "function test.add_them(a => integer, b => integer, smthelse => text) does not exist"
|
||||
in [json| {
|
||||
"code": "42883",
|
||||
"details": null,
|
||||
"hint": "No function matches the given name and argument types. You might need to add explicit type casts.",
|
||||
"message": #{message} } |]
|
||||
{ matchStatus = 404
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "works when having uppercase identifiers" $ do
|
||||
get "/rpc/quotedFunction?user=mscott&fullName=Michael Scott&SSN=401-32-XXXX" `shouldRespondWith`
|
||||
[json|{"user": "mscott", "fullName": "Michael Scott", "SSN": "401-32-XXXX"}|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
post "/rpc/quotedFunction"
|
||||
[json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"user": "dschrute", "fullName": "Dwight Schrute", "SSN": "030-18-XXXX"}|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "shaping the response returned by a proc" $ do
|
||||
it "returns a project" $ do
|
||||
post "/rpc/getproject" [json| { "id": 1} |] `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Windows 7","client_id":1}]|]
|
||||
get "/rpc/getproject?id=1" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Windows 7","client_id":1}]|]
|
||||
|
||||
it "can filter proc results" $ do
|
||||
post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" [json| {} |] `shouldRespondWith`
|
||||
[json|[{"id":2},{"id":3},{"id":4}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id" `shouldRespondWith`
|
||||
[json|[{"id":2},{"id":3},{"id":4}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can limit proc results" $ do
|
||||
post "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1" [json| {} |]
|
||||
`shouldRespondWith` [json|[{"id":3},{"id":4}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "1-2/*"] }
|
||||
get "/rpc/getallprojects?id=gt.1&id=lt.5&select=id?limit=2&offset=1"
|
||||
`shouldRespondWith` [json|[{"id":3},{"id":4}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "1-2/*"] }
|
||||
|
||||
it "select works on the first level" $ do
|
||||
post "/rpc/getproject?select=id,name" [json| { "id": 1} |] `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Windows 7"}]|]
|
||||
get "/rpc/getproject?id=1&select=id,name" `shouldRespondWith`
|
||||
[str|[{"id":1,"name":"Windows 7"}]|]
|
||||
|
||||
context "foreign entities embedding" $ do
|
||||
it "can embed if related tables are in the exposed schema" $ do
|
||||
post "/rpc/getproject?select=id,name,client(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/getproject?id=1&select=id,name,client(id),tasks(id)" `shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "cannot embed if the related table is not in the exposed schema" $ do
|
||||
post "/rpc/single_article?select=*,article_stars(*)" [json|{ "id": 1}|]
|
||||
`shouldRespondWith` 400
|
||||
get "/rpc/single_article?id=1&select=*,article_stars(*)"
|
||||
`shouldRespondWith` 400
|
||||
|
||||
it "can embed if the related tables are in a hidden schema but exposed as views" $ do
|
||||
post "/rpc/single_article?select=id,articleStars(userId)" [json|{ "id": 2}|]
|
||||
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/single_article?id=2&select=id,articleStars(userId)"
|
||||
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "a proc that returns an empty rowset" $
|
||||
it "returns empty json array" $ do
|
||||
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
|
||||
[json| [] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/test_empty_rowset" `shouldRespondWith`
|
||||
[json| [] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "proc return types" $ do
|
||||
context "returns text" $ do
|
||||
it "returns proper json" $
|
||||
post "/rpc/sayhello" [json| { "name": "world" } |] `shouldRespondWith`
|
||||
[json|"Hello, world"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can handle unicode" $
|
||||
post "/rpc/sayhello" [json| { "name": "¥" } |] `shouldRespondWith`
|
||||
[json|"Hello, ¥"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns array" $
|
||||
post "/rpc/ret_array" [json|{}|] `shouldRespondWith`
|
||||
[json|[1, 2, 3]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns setof integers" $
|
||||
post "/rpc/ret_setof_integers" [json|{}|] `shouldRespondWith`
|
||||
[json|[{ "ret_setof_integers": 1 },
|
||||
{ "ret_setof_integers": 2 },
|
||||
{ "ret_setof_integers": 3 }]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns enum value" $
|
||||
post "/rpc/ret_enum" [json|{ "val": "foo" }|] `shouldRespondWith`
|
||||
[json|"foo"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns domain value" $
|
||||
post "/rpc/ret_domain" [json|{ "val": "8" }|] `shouldRespondWith`
|
||||
[json|8|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns range" $
|
||||
post "/rpc/ret_range" [json|{ "low": 10, "up": 20 }|] `shouldRespondWith`
|
||||
[json|"[10,20)"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns row of scalars" $
|
||||
post "/rpc/ret_scalars" [json|{}|] `shouldRespondWith`
|
||||
[json|[{"a":"scalars", "b":"foo", "c":1, "d":"[10,20)"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns composite type in exposed schema" $
|
||||
post "/rpc/ret_point_2d" [json|{}|] `shouldRespondWith`
|
||||
[json|[{"x": 10, "y": 5}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "cannot return composite type in hidden schema" $
|
||||
post "/rpc/ret_point_3d" [json|{}|] `shouldRespondWith` 401
|
||||
|
||||
it "returns single row from table" $
|
||||
post "/rpc/single_article?select=id" [json|{"id": 2}|] `shouldRespondWith`
|
||||
[json|[{"id": 2}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns null for void" $
|
||||
post "/rpc/ret_void" [json|{}|] `shouldRespondWith`
|
||||
[json|null|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "proc argument types" $ do
|
||||
it "accepts a variety of arguments" $
|
||||
post "/rpc/varied_arguments"
|
||||
[json| {
|
||||
"double": 3.1,
|
||||
"varchar": "hello",
|
||||
"boolean": true,
|
||||
"date": "20190101",
|
||||
"money": 0,
|
||||
"enum": "foo",
|
||||
"integer": 43,
|
||||
"json": {"some key": "some value"},
|
||||
"jsonb": {"another key": [1, 2, "3"]}
|
||||
} |]
|
||||
`shouldRespondWith`
|
||||
[json|"Hi"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "parses embedded JSON arguments as JSON" $
|
||||
post "/rpc/json_argument"
|
||||
[json| { "arg": { "key": 3 } } |]
|
||||
`shouldRespondWith`
|
||||
[json|"object"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
when (actualPgVersion <= pgVersion96) $
|
||||
it "parses quoted JSON arguments as JSON (Postgres <= 9.6)" $
|
||||
post "/rpc/json_argument"
|
||||
[json| { "arg": "{ \"key\": 3 }" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"object"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
when (actualPgVersion >= pgVersion100) $ do
|
||||
it "parses quoted JSON arguments as JSON string (Postgres >= 10)" $ do
|
||||
-- Postgres bug report:
|
||||
-- https://www.postgresql.org/message-id/D6921B37-BD8E-4664-8D5F-DB3525765DCD%40vllmrt.net
|
||||
-- * json_to_record fails (see following test)
|
||||
-- * jsonb_to_record parses the embedded quoted JSON to a JSON string,
|
||||
-- so that's probably the expected behavior for Postgres >= 10
|
||||
pendingWith "Postgres >= 10 fails to parse quoted embedded JSON"
|
||||
post "/rpc/json_argument"
|
||||
[json| { "arg": "{ \"key\": 3 }" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"string"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails to parse quoted JSON arguments (Postgres >= 10)" $
|
||||
-- Confirming buggy Postgres behavior (see previous test)
|
||||
post "/rpc/json_argument"
|
||||
[json| { "arg": "{ \"key\": 3 }" } |]
|
||||
`shouldRespondWith`
|
||||
[json|{"hint":null,"details":"Token \"key\" is invalid.","code":"22P02","message":"invalid input syntax for type json"}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "improper input" $ do
|
||||
it "rejects unknown content type even if payload is good" $ do
|
||||
request methodPost "/rpc/sayhello"
|
||||
(acceptHdrs "audio/mpeg3") [json| { "name": "world" } |]
|
||||
`shouldRespondWith` 415
|
||||
request methodGet "/rpc/sayhello?name=world"
|
||||
(acceptHdrs "audio/mpeg3") ""
|
||||
`shouldRespondWith` 415
|
||||
it "rejects malformed json payload" $ do
|
||||
p <- request methodPost "/rpc/sayhello"
|
||||
(acceptHdrs "application/json") "sdfsdf"
|
||||
liftIO $ do
|
||||
simpleStatus p `shouldBe` badRequest400
|
||||
isErrorFormat (simpleBody p) `shouldBe` True
|
||||
it "treats simple plpgsql raise as invalid input" $ do
|
||||
p <- post "/rpc/problem" "{}"
|
||||
liftIO $ do
|
||||
simpleStatus p `shouldBe` badRequest400
|
||||
isErrorFormat (simpleBody p) `shouldBe` True
|
||||
|
||||
context "unsupported verbs" $ do
|
||||
it "DELETE fails" $
|
||||
request methodDelete "/rpc/sayhello" [] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Bad Request"}|]
|
||||
{ matchStatus = 405
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
it "PATCH fails" $
|
||||
request methodPatch "/rpc/sayhello" [] ""
|
||||
`shouldRespondWith` 405
|
||||
it "OPTIONS fails" $
|
||||
-- TODO: should return info about the function
|
||||
request methodOptions "/rpc/sayhello" [] ""
|
||||
`shouldRespondWith` 405
|
||||
|
||||
it "executes the proc exactly once per request" $ do
|
||||
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
|
||||
[json|1|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
post "/rpc/callcounter" [json| {} |] `shouldRespondWith`
|
||||
[json|2|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "a proc that receives no parameters" $ do
|
||||
it "interprets empty string as empty json object on a post request" $
|
||||
post "/rpc/noparamsproc" BL.empty `shouldRespondWith`
|
||||
[json| "Return value of no parameters procedure." |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "interprets empty string as a function with no args on a get request" $
|
||||
get "/rpc/noparamsproc" `shouldRespondWith`
|
||||
[json| "Return value of no parameters procedure." |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns proper output when having the same return col name as the proc name" $ do
|
||||
post "/rpc/test" [json|{}|] `shouldRespondWith`
|
||||
[json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/test" `shouldRespondWith`
|
||||
[json|[{"test":"hello","value":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "procs with OUT/INOUT params" $ do
|
||||
it "returns a scalar result when there is a single OUT param" $ do
|
||||
get "/rpc/single_out_param?num=5" `shouldRespondWith`
|
||||
[json|6|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/single_json_out_param?a=1&b=two" `shouldRespondWith`
|
||||
[json|{"a": 1, "b": "two"}|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns a scalar result when there is a single INOUT param" $
|
||||
get "/rpc/single_inout_param?num=2" `shouldRespondWith`
|
||||
[json|3|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns a row result when there are many OUT params" $
|
||||
get "/rpc/many_out_params" `shouldRespondWith`
|
||||
[json|[{"my_json":{"a": 1, "b": "two"},"num":3,"str":"four"}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "returns a row result when there are many INOUT params" $
|
||||
get "/rpc/many_inout_params?num=1&str=two&b=false" `shouldRespondWith`
|
||||
[json| [{"num":1,"str":"two","b":false}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can handle procs with args that have a DEFAULT value" $ do
|
||||
get "/rpc/many_inout_params?num=1&str=two" `shouldRespondWith`
|
||||
[json| [{"num":1,"str":"two","b":true}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/three_defaults?b=4" `shouldRespondWith`
|
||||
[json|8|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can map a RAISE error code and message to a http status" $
|
||||
get "/rpc/raise_pt402"
|
||||
`shouldRespondWith` [json|{ "hint": "Upgrade your plan", "details": "Quota exceeded" }|]
|
||||
{ matchStatus = 402
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "defaults to status 500 if RAISE code is PT not followed by a number" $
|
||||
get "/rpc/raise_bad_pt"
|
||||
`shouldRespondWith`
|
||||
[json|{"hint": null, "details": null}|]
|
||||
{ matchStatus = 500
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
context "expects a single json object" $ do
|
||||
it "does not expand posted json into parameters" $
|
||||
request methodPost "/rpc/singlejsonparam"
|
||||
[("prefer","params=single-object")] [json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |] `shouldRespondWith`
|
||||
[json| { "p1": 1, "p2": "text", "p3" : {"obj":"text"} } |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "accepts parameters from an html form" $
|
||||
request methodPost "/rpc/singlejsonparam"
|
||||
[("Prefer","params=single-object"),("Content-Type", "application/x-www-form-urlencoded")]
|
||||
("integer=7&double=2.71828&varchar=forms+are+fun&" <>
|
||||
"boolean=false&date=1900-01-01&money=$3.99&enum=foo") `shouldRespondWith`
|
||||
[json| { "integer": "7", "double": "2.71828", "varchar" : "forms are fun"
|
||||
, "boolean":"false", "date":"1900-01-01", "money":"$3.99", "enum":"foo" } |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "works with GET" $
|
||||
request methodGet "/rpc/singlejsonparam?p1=1&p2=text" [("Prefer","params=single-object")] ""
|
||||
`shouldRespondWith` [json|{ "p1": "1", "p2": "text"}|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "should work with an overloaded function" $ do
|
||||
get "/rpc/overloaded" `shouldRespondWith`
|
||||
[json|[{ "overloaded": 1 },
|
||||
{ "overloaded": 2 },
|
||||
{ "overloaded": 3 }]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
request methodPost "/rpc/overloaded" [("Prefer","params=single-object")]
|
||||
[json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"x": 1, "y": "first"}, {"x": 2, "y": "second"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|]
|
||||
get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [str|"123"|]
|
||||
|
||||
context "only for POST rpc" $ do
|
||||
it "gives a parse filter error if GET style proc args are specified" $
|
||||
post "/rpc/sayhello?name=John" [json|{}|] `shouldRespondWith` 400
|
||||
|
||||
it "ignores json keys not included in ?columns" $
|
||||
post "/rpc/sayhello?columns=name"
|
||||
[json|{"name": "John", "smth": "here", "other": "stuff", "fake_id": 13}|] `shouldRespondWith`
|
||||
[json|"Hello, John"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "bulk RPC" $ do
|
||||
it "works with a scalar function an returns a json array" $
|
||||
post "/rpc/add_them"
|
||||
[json|[
|
||||
{"a": 1, "b": 2},
|
||||
{"a": 4, "b": 6},
|
||||
{"a": 100, "b": 200}
|
||||
]|] `shouldRespondWith`
|
||||
[json|
|
||||
[3, 10, 300]
|
||||
|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "works with a scalar function an returns a json array when posting CSV" $
|
||||
request methodPost "/rpc/add_them" [("Content-Type", "text/csv")]
|
||||
"a,b\n1,2\n4,6\n100,200"
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[3, 10, 300]
|
||||
|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "works with a non-scalar result" $
|
||||
post "/rpc/get_projects_below?select=id,name"
|
||||
[json|[
|
||||
{"id": 1},
|
||||
{"id": 5}
|
||||
]|] `shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"name":"Windows 7"},
|
||||
{"id":2,"name":"Windows 10"},
|
||||
{"id":3,"name":"IOS"},
|
||||
{"id":4,"name":"OSX"}]
|
||||
|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "binary output" $ do
|
||||
context "Proc that returns scalar" $ do
|
||||
it "can query without selecting column" $
|
||||
request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") ""
|
||||
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "can get raw output with Accept: text/html" $
|
||||
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
|
||||
`shouldRespondWith`
|
||||
[str|
|
||||
|<html>
|
||||
| <head>
|
||||
| <title>PostgREST</title>
|
||||
| </head>
|
||||
| <body>
|
||||
| <h1>Welcome to PostgREST</h1>
|
||||
| </body>
|
||||
|</html>
|
||||
|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/html; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "can get raw output with Accept: text/plain" $
|
||||
request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
|
||||
`shouldRespondWith` "Welcome to PostgREST"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
|
||||
}
|
||||
|
||||
context "Proc that returns rows" $ do
|
||||
it "can query if a single column is selected" $
|
||||
request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (acceptHdrs "application/octet-stream") ""
|
||||
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "fails if a single column is not selected" $
|
||||
request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") ""
|
||||
`shouldRespondWith`
|
||||
[json| {"message":"application/octet-stream requested but a single column was not selected"} |]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "only for GET rpc" $ do
|
||||
it "should fail on mutating procs" $ do
|
||||
get "/rpc/callcounter" `shouldRespondWith` 500
|
||||
get "/rpc/setprojects?id_l=1&id_h=5&name=FreeBSD" `shouldRespondWith` 500
|
||||
|
||||
it "should filter a proc that has arg name = filter name" $
|
||||
get "/rpc/get_projects_below?id=5&id=gt.2&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 3 }, { "id": 4 }]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "should work with filters that have the not operator" $ do
|
||||
get "/rpc/get_projects_below?id=5&id=not.gt.2&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 1 }, { "id": 2 }]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/get_projects_below?id=5&id=not.in.(1,3)&select=id" `shouldRespondWith`
|
||||
[json|[{ "id": 2 }, { "id": 4 }]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "should work with filters that use the plain with language fts operator" $ do
|
||||
get "/rpc/get_tsearch?text_search_vector=fts(english).impossible" `shouldRespondWith`
|
||||
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/get_tsearch?text_search_vector=plfts.impossible" `shouldRespondWith`
|
||||
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`
|
||||
[json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "should work with an argument of custom type in public schema" $
|
||||
get "/rpc/test_arg?my_arg=something" `shouldRespondWith`
|
||||
[json|"foobar"|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
@@ -0,0 +1,202 @@
|
||||
module Feature.SingularSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (..))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Text.Heredoc
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
|
||||
spec :: SpecWith Application
|
||||
spec =
|
||||
describe "Requesting singular json object" $ do
|
||||
let pgrstObj = "application/vnd.pgrst.object+json"
|
||||
singular = ("Accept", pgrstObj)
|
||||
|
||||
context "with GET request" $ do
|
||||
it "fails for zero rows" $
|
||||
request methodGet "/items?id=gt.0&id=lt.0" [singular] ""
|
||||
`shouldRespondWith` 406
|
||||
|
||||
it "will select an existing object" $ do
|
||||
request methodGet "/items?id=eq.5" [singular] ""
|
||||
`shouldRespondWith` [str|{"id":5}|]
|
||||
-- also test without the +json suffix
|
||||
request methodGet "/items?id=eq.5"
|
||||
[("Accept", "application/vnd.pgrst.object")] ""
|
||||
`shouldRespondWith` [str|{"id":5}|]
|
||||
|
||||
it "can combine multiple prefer values" $
|
||||
request methodGet "/items?id=eq.5" [singular, ("Prefer","count=none")] ""
|
||||
`shouldRespondWith` [str|{"id":5}|]
|
||||
|
||||
it "can shape plurality singular object routes" $
|
||||
request methodGet "/projects_view?id=eq.1&select=id,name,clients(*),tasks(id,name)" [singular] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}|]
|
||||
{ matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"] }
|
||||
|
||||
context "when updating rows" $ do
|
||||
it "works for one row" $ do
|
||||
_ <- post "/addresses" [json| { id: 97, address: "A Street" } |]
|
||||
request methodPatch
|
||||
"/addresses?id=eq.97"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| { address: "B Street" } |]
|
||||
`shouldRespondWith`
|
||||
[str|{"id":97,"address":"B Street"}|]
|
||||
|
||||
it "raises an error for multiple rows" $ do
|
||||
_ <- post "/addresses" [json| { id: 98, address: "xxx" } |]
|
||||
_ <- post "/addresses" [json| { id: 99, address: "yyy" } |]
|
||||
p <- request methodPatch "/addresses?id=gt.0"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| { address: "zzz" } |]
|
||||
liftIO $ do
|
||||
simpleStatus p `shouldBe` notAcceptable406
|
||||
isErrorFormat (simpleBody p) `shouldBe` True
|
||||
|
||||
-- the rows should not be updated, either
|
||||
get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|]
|
||||
|
||||
it "raises an error for zero rows" $
|
||||
request methodPatch "/items?id=gt.0&id=lt.0"
|
||||
[("Prefer", "return=representation"), singular] [json|{"id":1}|]
|
||||
`shouldRespondWith`
|
||||
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
context "when creating rows" $ do
|
||||
it "works for one row" $ do
|
||||
p <- request methodPost
|
||||
"/addresses"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| [ { id: 100, address: "xxx" } ] |]
|
||||
liftIO $ simpleBody p `shouldBe` [str|{"id":100,"address":"xxx"}|]
|
||||
|
||||
it "works for one row even with return=minimal" $ do
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "return=minimal"), singular]
|
||||
[json| [ { id: 101, address: "xxx" } ] |]
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
-- and the element should exist
|
||||
get "/addresses?id=eq.101"
|
||||
`shouldRespondWith` [str|[{"id":101,"address":"xxx"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "raises an error when attempting to create multiple entities" $ do
|
||||
p <- request methodPost
|
||||
"/addresses"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
||||
liftIO $ simpleStatus p `shouldBe` notAcceptable406
|
||||
|
||||
-- the rows should not exist, either
|
||||
get "/addresses?id=eq.200" `shouldRespondWith` "[]"
|
||||
|
||||
it "return=minimal allows request to create multiple elements" $
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "return=minimal"), singular]
|
||||
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
||||
`shouldRespondWith` ""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||
}
|
||||
|
||||
it "raises an error when creating zero entities" $
|
||||
request methodPost "/addresses"
|
||||
[("Prefer", "return=representation"), singular]
|
||||
[json| [ ] |]
|
||||
`shouldRespondWith`
|
||||
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
context "when deleting rows" $ do
|
||||
it "works for one row" $ do
|
||||
p <- request methodDelete
|
||||
"/items?id=eq.11"
|
||||
[("Prefer", "return=representation"), singular] ""
|
||||
liftIO $ simpleBody p `shouldBe` [str|{"id":11}|]
|
||||
|
||||
it "raises an error when attempting to delete multiple entities" $ do
|
||||
let firstItems = "/items?id=gt.0&id=lt.11"
|
||||
request methodDelete firstItems
|
||||
[("Prefer", "return=representation"), singular] ""
|
||||
`shouldRespondWith` 406
|
||||
|
||||
get firstItems
|
||||
`shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-9/*"]
|
||||
}
|
||||
|
||||
it "raises an error when deleting zero entities" $
|
||||
request methodDelete "/items?id=lt.0"
|
||||
[("Prefer", "return=representation"), singular] ""
|
||||
`shouldRespondWith`
|
||||
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
context "when calling a stored proc" $ do
|
||||
it "fails for zero rows" $
|
||||
request methodPost "/rpc/getproject"
|
||||
[singular] [json|{ "id": 9999999}|]
|
||||
`shouldRespondWith`
|
||||
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
-- this one may be controversial, should vnd.pgrst.object include
|
||||
-- the likes of 2 and "hello?"
|
||||
it "succeeds for scalar result" $
|
||||
request methodPost "/rpc/sayhello"
|
||||
[singular] [json|{ "name": "world"}|]
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "returns a single object for json proc" $
|
||||
request methodPost "/rpc/getproject"
|
||||
[singular] [json|{ "id": 1}|] `shouldRespondWith`
|
||||
[str|{"id":1,"name":"Windows 7","client_id":1}|]
|
||||
|
||||
it "fails for multiple rows" $
|
||||
request methodPost "/rpc/getallprojects"
|
||||
[singular] "{}"
|
||||
`shouldRespondWith`
|
||||
[str|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "executes the proc exactly once per request" $ do
|
||||
request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]
|
||||
`shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]
|
||||
|
||||
request methodPost "/rpc/setprojects" [singular]
|
||||
[json| {"id_l": 1, "id_h": 2, "name": "changed"} |]
|
||||
`shouldRespondWith`
|
||||
[str|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
-- should not actually have executed the function
|
||||
request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]
|
||||
`shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]
|
||||