Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6050c8615 | ||
|
|
7ffac522e3 | ||
|
|
6c4208d9e7 | ||
|
|
98bf4d861d | ||
|
|
bfcd289855 | ||
|
|
7c0fbf9b3f | ||
|
|
6fae07241f | ||
|
|
a4f687fdfd | ||
|
|
b1a101c253 | ||
|
|
10c363b588 | ||
|
|
9a52632024 | ||
|
|
f57caf0987 | ||
|
|
f02904a959 | ||
|
|
8b41b71db7 | ||
|
|
d4cf8e7abb | ||
|
|
9134171b95 | ||
|
|
328c3453f8 | ||
|
|
bb27eb57a9 | ||
|
|
dbc3aa28c4 | ||
|
|
ad92c207f2 | ||
|
|
dea6c5eb92 | ||
|
|
3baefa1d96 | ||
|
|
c0546e0e46 | ||
|
|
d5f1d1b1ad | ||
|
|
1c19bbde93 | ||
|
|
3da5a2875e | ||
|
|
2e6a094d48 | ||
|
|
178c5d54d5 | ||
|
|
a7c396e464 | ||
|
|
24db4a1e25 | ||
|
|
ae77cf9a08 | ||
|
|
4a0a37588f | ||
|
|
5838214910 | ||
|
|
b609d8491e | ||
|
|
18538707ab | ||
|
|
e59c72cff3 | ||
|
|
da573a1805 | ||
|
|
e24a7d005a | ||
|
|
79399686db | ||
|
|
f6ce93f2c8 | ||
|
|
8c35c9d711 | ||
|
|
be674eb41d | ||
|
|
052843ac9b | ||
|
|
c524531784 | ||
|
|
82fa1d8812 | ||
|
|
2b61a63686 | ||
|
|
18e45659ea | ||
|
|
426637a47c | ||
|
|
ababf7d4fa | ||
|
|
691bb5640d | ||
|
|
a80eb2ff0e | ||
|
|
fe59f9bedf | ||
|
|
0f8838623b | ||
|
|
5b5945e427 | ||
|
|
dea57bd1be | ||
|
|
3e81a38438 | ||
|
|
dfdf3d30b3 | ||
|
|
60b64d3e81 | ||
|
|
962fba4d16 | ||
|
|
de218e900b | ||
|
|
b75e7cef90 | ||
|
|
9b1224827a | ||
|
|
c7f78fa7fc | ||
|
|
7dade7f466 | ||
|
|
b20e1150a5 | ||
|
|
aa0d6a6831 | ||
|
|
663faa1f82 | ||
|
|
99b13fa25f | ||
|
|
e12c1319b6 | ||
|
|
7f365bf60b | ||
|
|
2e6c78d723 | ||
|
|
f9c64d9f65 | ||
|
|
4ef6926791 | ||
|
|
9645f1011c | ||
|
|
9847e60dca | ||
|
|
cb3d9ab625 | ||
|
|
db41fb454e | ||
|
|
3b133d5554 | ||
|
|
80f763448f | ||
|
|
a3701f5de8 | ||
|
|
ed2bfc09a6 | ||
|
|
337f821e00 | ||
|
|
f2b126f147 | ||
|
|
1173bc277b | ||
|
|
50f2cc16ab | ||
|
|
eebe319bfd | ||
|
|
75a42b77ea | ||
|
|
d71d3450af | ||
|
|
f080159268 | ||
|
|
0183d32c7f | ||
|
|
94f5894d7f | ||
|
|
81e5a62f25 | ||
|
|
186381bab2 | ||
|
|
e044488f73 | ||
|
|
b077974ebc | ||
|
|
200540dfc3 | ||
|
|
3c00f46e36 | ||
|
|
620721dea7 | ||
|
|
68cbe34c11 | ||
|
|
e21b010c6e | ||
|
|
0846d4d7b2 | ||
|
|
2183a2a1ae | ||
|
|
e8475b18d3 | ||
|
|
cdc1177762 | ||
|
|
97035e0b8b | ||
|
|
aaf62c1c96 | ||
|
|
4d0661fd9b | ||
|
|
713b214c9a | ||
|
|
681388631b | ||
|
|
ae9e27a0c7 | ||
|
|
e83144ce7f | ||
|
|
1c54c7130a | ||
|
|
1a8d5fed8a | ||
|
|
57ebf43e85 | ||
|
|
b87734343e | ||
|
|
c80c9ef726 | ||
|
|
d5758523f3 | ||
|
|
ee40e7e0d7 | ||
|
|
53b606e1c1 | ||
|
|
47c0141c49 | ||
|
|
5b8a17e366 | ||
|
|
64a86b899f | ||
|
|
312e295a47 | ||
|
|
e7544687d1 | ||
|
|
291de5bc1c | ||
|
|
c37a9f5ec3 | ||
|
|
f5cef205f1 | ||
|
|
afb7266f17 | ||
|
|
e639c77aa2 | ||
|
|
ea97055449 | ||
|
|
64dc6ab9ac | ||
|
|
25dedd1098 | ||
|
|
617bf7b6a3 | ||
|
|
e3a53de8a6 | ||
|
|
4cc91fd5b1 | ||
|
|
296a12e394 | ||
|
|
da7aa1d72f | ||
|
|
367ad8ea43 | ||
|
|
2c3bc2d75e | ||
|
|
3cce6ca02b | ||
|
|
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 |
@@ -1,66 +1,9 @@
|
|||||||
version: 2
|
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:
|
jobs:
|
||||||
build-test:
|
build-cache:
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/buildpack-deps:trusty
|
- image: circleci/buildpack-deps:trusty
|
||||||
environment:
|
|
||||||
- PGHOST=localhost
|
|
||||||
- image: circleci/postgres:9.4.14
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=circleci
|
|
||||||
- POSTGRES_DB=circleci
|
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- restore_cache:
|
- restore_cache:
|
||||||
@@ -69,15 +12,83 @@ jobs:
|
|||||||
- run:
|
- run:
|
||||||
name: install stack & dependencies
|
name: install stack & dependencies
|
||||||
command: |
|
command: |
|
||||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.1.2/stack-1.1.2-linux-x86_64.tar.gz | tar zx -C /tmp
|
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||||
sudo mv /tmp/stack-1.1.2-linux-x86_64/stack /usr/bin
|
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libgmp-dev
|
sudo apt-get install -y libgmp-dev
|
||||||
sudo apt-get install -y --only-upgrade binutils
|
sudo apt-get install -y --only-upgrade binutils
|
||||||
sudo apt-get install -y postgresql-client
|
sudo apt-get install -y postgresql-client
|
||||||
stack setup
|
stack setup
|
||||||
rm -rf $(stack path --dist-dir) $(stack path --local-install-root)
|
- run:
|
||||||
stack install hlint packdeps cabal-install
|
name: build src and tests dependencies
|
||||||
|
command: |
|
||||||
|
stack build --fast -j1 --only-dependencies
|
||||||
|
stack build --fast --test --no-run-tests --only-dependencies
|
||||||
|
- save_cache:
|
||||||
|
paths:
|
||||||
|
- "~/.stack"
|
||||||
|
- ".stack-work"
|
||||||
|
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||||
|
- run:
|
||||||
|
name: build src and tests
|
||||||
|
command: |
|
||||||
|
stack build --fast -j1
|
||||||
|
stack build --fast --test --no-run-tests
|
||||||
|
|
||||||
|
style:
|
||||||
|
docker:
|
||||||
|
- image: nixos/nix:2.3
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- run:
|
||||||
|
name: Install linting and styling scripts
|
||||||
|
command: nix-env -f default.nix -iA lint style
|
||||||
|
- run:
|
||||||
|
name: Run linter
|
||||||
|
command: |
|
||||||
|
echo 'Note: For checking this locally, use `make lint`. Or, if using `nix`, run `nix-shell --run postgrest-lint`'
|
||||||
|
postgrest-lint
|
||||||
|
- run:
|
||||||
|
name: Run style check
|
||||||
|
command: |
|
||||||
|
echo 'Note: For checking this locally, use `make style`. Or, if using `nix`, run `nix-shell --run postgrest-style`'
|
||||||
|
postgrest-style-check
|
||||||
|
|
||||||
|
build-test-9.4:
|
||||||
|
docker:
|
||||||
|
- image: circleci/buildpack-deps:trusty
|
||||||
|
environment:
|
||||||
|
- PGHOST=localhost
|
||||||
|
- image: circleci/postgres:9.4.26
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=circleci
|
||||||
|
- POSTGRES_DB=circleci
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
|
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/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||||
|
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgmp-dev
|
||||||
|
sudo apt-get install -y --only-upgrade binutils
|
||||||
|
sudo apt-get install -y postgresql-client
|
||||||
|
stack setup
|
||||||
|
- run:
|
||||||
|
name: build src and tests dependencies
|
||||||
|
command: |
|
||||||
|
stack build --fast -j1 --only-dependencies
|
||||||
|
stack build --fast --test --no-run-tests --only-dependencies
|
||||||
- run:
|
- run:
|
||||||
name: build src and tests
|
name: build src and tests
|
||||||
command: |
|
command: |
|
||||||
@@ -88,33 +99,17 @@ jobs:
|
|||||||
command: |
|
command: |
|
||||||
POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
||||||
test/io-tests.sh
|
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: extra checks
|
|
||||||
command: |
|
|
||||||
stack exec -- cabal update
|
|
||||||
stack exec --no-ghc-package-path -- cabal install --only-d --dry-run
|
|
||||||
stack exec -- packdeps *.cabal || true
|
|
||||||
stack exec -- cabal check
|
|
||||||
stack haddock --no-haddock-deps
|
|
||||||
stack sdist
|
|
||||||
- save_cache:
|
|
||||||
paths:
|
|
||||||
- "~/.stack"
|
|
||||||
- ".stack-work"
|
|
||||||
key: v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
|
||||||
|
|
||||||
build-test-9.6:
|
build-test-9.6:
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/buildpack-deps:trusty
|
- image: circleci/buildpack-deps:trusty
|
||||||
environment:
|
environment:
|
||||||
- PGHOST=localhost
|
- PGHOST=localhost
|
||||||
- image: circleci/postgres:9.6.2
|
- image: circleci/postgres:9.6.17
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=circleci
|
- POSTGRES_USER=circleci
|
||||||
- POSTGRES_DB=circleci
|
- POSTGRES_DB=circleci
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- restore_cache:
|
- restore_cache:
|
||||||
@@ -123,8 +118,8 @@ jobs:
|
|||||||
- run:
|
- run:
|
||||||
name: install stack & dependencies
|
name: install stack & dependencies
|
||||||
command: |
|
command: |
|
||||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.1.2/stack-1.1.2-linux-x86_64.tar.gz | tar zx -C /tmp
|
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||||
sudo mv /tmp/stack-1.1.2-linux-x86_64/stack /usr/bin
|
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libgmp-dev
|
sudo apt-get install -y libgmp-dev
|
||||||
sudo apt-get install -y postgresql-client
|
sudo apt-get install -y postgresql-client
|
||||||
@@ -136,7 +131,106 @@ jobs:
|
|||||||
stack build --fast --test --no-run-tests
|
stack build --fast --test --no-run-tests
|
||||||
- run:
|
- run:
|
||||||
name: run tests
|
name: run tests
|
||||||
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test
|
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test postgrest:spec
|
||||||
|
|
||||||
|
build-test-10:
|
||||||
|
docker:
|
||||||
|
- image: circleci/buildpack-deps:trusty
|
||||||
|
environment:
|
||||||
|
- PGHOST=localhost
|
||||||
|
- image: circleci/postgres:10.12
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=circleci
|
||||||
|
- POSTGRES_DB=circleci
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- restore_cache:
|
||||||
|
keys:
|
||||||
|
- v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||||
|
- run:
|
||||||
|
name: install stack & dependencies
|
||||||
|
command: |
|
||||||
|
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||||
|
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgmp-dev
|
||||||
|
sudo apt-get install -y postgresql-client
|
||||||
|
stack setup
|
||||||
|
- run:
|
||||||
|
name: build src and tests
|
||||||
|
command: |
|
||||||
|
stack build --fast -j1
|
||||||
|
stack build --fast --test --no-run-tests
|
||||||
|
- run:
|
||||||
|
name: run tests
|
||||||
|
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test postgrest:spec
|
||||||
|
|
||||||
|
build-test-11:
|
||||||
|
docker:
|
||||||
|
- image: circleci/buildpack-deps:trusty
|
||||||
|
environment:
|
||||||
|
- PGHOST=localhost
|
||||||
|
- image: circleci/postgres:11.7
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=circleci
|
||||||
|
- POSTGRES_DB=circleci
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- restore_cache:
|
||||||
|
keys:
|
||||||
|
- v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||||
|
- run:
|
||||||
|
name: install stack & dependencies
|
||||||
|
command: |
|
||||||
|
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||||
|
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgmp-dev
|
||||||
|
sudo apt-get install -y postgresql-client
|
||||||
|
stack setup
|
||||||
|
- run:
|
||||||
|
name: build src and tests
|
||||||
|
command: |
|
||||||
|
stack build --fast -j1
|
||||||
|
stack build --fast --test --no-run-tests
|
||||||
|
- run:
|
||||||
|
name: run tests
|
||||||
|
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test postgrest:spec
|
||||||
|
|
||||||
|
build-test-12:
|
||||||
|
docker:
|
||||||
|
- image: circleci/buildpack-deps:trusty
|
||||||
|
environment:
|
||||||
|
- PGHOST=localhost
|
||||||
|
- image: circleci/postgres:12.2
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=circleci
|
||||||
|
- POSTGRES_DB=circleci
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- restore_cache:
|
||||||
|
keys:
|
||||||
|
- v1-stack-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||||
|
- run:
|
||||||
|
name: install stack & dependencies
|
||||||
|
command: |
|
||||||
|
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||||
|
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgmp-dev
|
||||||
|
sudo apt-get install -y postgresql-client
|
||||||
|
stack setup
|
||||||
|
- run:
|
||||||
|
name: build src and tests
|
||||||
|
command: |
|
||||||
|
stack build --fast -j1
|
||||||
|
stack build --fast --test --no-run-tests
|
||||||
|
- run:
|
||||||
|
name: run tests
|
||||||
|
command: POSTGREST_TEST_CONNECTION=$(test/create_test_db "postgres://circleci@localhost" postgrest_test) stack test postgrest:spec
|
||||||
|
|
||||||
build-prof-test:
|
build-prof-test:
|
||||||
docker:
|
docker:
|
||||||
@@ -144,10 +238,11 @@ jobs:
|
|||||||
environment:
|
environment:
|
||||||
- PGHOST=localhost
|
- PGHOST=localhost
|
||||||
- TERM=xterm
|
- TERM=xterm
|
||||||
- image: circleci/postgres:9.6.2
|
- image: circleci/postgres:9.6.17
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=circleci
|
- POSTGRES_USER=circleci
|
||||||
- POSTGRES_DB=circleci
|
- POSTGRES_DB=circleci
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- restore_cache:
|
- restore_cache:
|
||||||
@@ -156,12 +251,21 @@ jobs:
|
|||||||
- run:
|
- run:
|
||||||
name: install stack & dependencies
|
name: install stack & dependencies
|
||||||
command: |
|
command: |
|
||||||
curl -L https://github.com/commercialhaskell/stack/releases/download/v1.1.2/stack-1.1.2-linux-x86_64.tar.gz | tar zx -C /tmp
|
curl -L https://github.com/commercialhaskell/stack/releases/download/v2.1.3/stack-2.1.3-linux-x86_64.tar.gz | tar zx -C /tmp
|
||||||
sudo mv /tmp/stack-1.1.2-linux-x86_64/stack /usr/bin
|
sudo mv /tmp/stack-2.1.3-linux-x86_64/stack /usr/bin
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libgmp-dev
|
sudo apt-get install -y libgmp-dev
|
||||||
sudo apt-get install -y postgresql-client
|
sudo apt-get install -y postgresql-client
|
||||||
stack setup
|
stack setup
|
||||||
|
- run:
|
||||||
|
name: build dependencies with profiling enabled
|
||||||
|
command: |
|
||||||
|
stack build --profile -j1 --only-dependencies
|
||||||
|
- save_cache:
|
||||||
|
paths:
|
||||||
|
- "~/.stack"
|
||||||
|
- ".stack-work"
|
||||||
|
key: v1-stack-prof-dependencies-{{ checksum "postgrest.cabal" }}-{{ checksum "stack.yaml" }}
|
||||||
- run:
|
- run:
|
||||||
name: build with profiling enabled
|
name: build with profiling enabled
|
||||||
command: |
|
command: |
|
||||||
@@ -170,33 +274,11 @@ jobs:
|
|||||||
name: run memory usage tests
|
name: run memory usage tests
|
||||||
command: |
|
command: |
|
||||||
test/create_test_db "postgres://circleci@localhost" postgrest_test
|
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/privileges.sql
|
|
||||||
test/memory-tests.sh
|
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:
|
release:
|
||||||
docker:
|
docker:
|
||||||
- image: circleci/golang:1.8
|
- image: circleci/golang:1.9
|
||||||
steps:
|
steps:
|
||||||
- attach_workspace:
|
- attach_workspace:
|
||||||
at: /tmp/workspace
|
at: /tmp/workspace
|
||||||
@@ -220,56 +302,132 @@ jobs:
|
|||||||
docker tag postgrest postgrest/postgrest:latest
|
docker tag postgrest postgrest/postgrest:latest
|
||||||
docker push postgrest/postgrest:latest
|
docker push postgrest/postgrest:latest
|
||||||
|
|
||||||
|
build-nix:
|
||||||
|
machine: true
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- run:
|
||||||
|
name: Install Nix
|
||||||
|
command: |
|
||||||
|
curl -L https://nixos.org/nix/install | sh
|
||||||
|
echo "source $HOME/.nix-profile/etc/profile.d/nix.sh" >> $BASH_ENV
|
||||||
|
- run:
|
||||||
|
name: Install and use the Cachix binary cache
|
||||||
|
command: |
|
||||||
|
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
||||||
|
cachix use postgrest
|
||||||
|
- run:
|
||||||
|
name: Build the dynamic PostgREST executable
|
||||||
|
command: |
|
||||||
|
# We build the dynamic postgrest executable first in order to fail fast if anything
|
||||||
|
# should be wrong with the source code.
|
||||||
|
nix-build -A postgrest
|
||||||
|
- restore_cache:
|
||||||
|
keys:
|
||||||
|
- v1-cabal-nix-dependencies-{{ checksum "postgrest.cabal" }}
|
||||||
|
- run:
|
||||||
|
name: Run the spec tests against all supported PostgreSQL versions
|
||||||
|
command: |
|
||||||
|
# Our utility scripts use cabal to run the tests, which will pick up all the libraries that
|
||||||
|
# are built with Nix. So this should only ever build the PostgREST itself and be reasonably
|
||||||
|
# quick. We accelerate that part further by caching `~/.cabal` and `dist-newstyle`.
|
||||||
|
nix-shell --run postgrest-test-spec-all
|
||||||
|
- save_cache:
|
||||||
|
paths:
|
||||||
|
- "~/.cabal"
|
||||||
|
- "dist-newstyle"
|
||||||
|
key: v1-cabal-nix-dependencies-{{ checksum "postgrest.cabal" }}
|
||||||
|
- run:
|
||||||
|
name: Build all derivations from default.nix and push results to Cachix
|
||||||
|
command: |
|
||||||
|
# Only push to the cache when CircleCI makes the CACHIX_SIGNING_KEY available (e.g. not for
|
||||||
|
# pull requests).
|
||||||
|
if [ -n "${CACHIX_SIGNING_KEY:-""}" ]; then
|
||||||
|
echo "Building and caching all derivations..."
|
||||||
|
nix-build | cachix push postgrest
|
||||||
|
else
|
||||||
|
echo "Building all derivations (caching skipped for outside pull requests)..."
|
||||||
|
nix-build
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-static-bin:
|
||||||
|
machine: true
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
- run:
|
||||||
|
name: Install Nix
|
||||||
|
command: |
|
||||||
|
curl -L https://nixos.org/nix/install | sh
|
||||||
|
echo "source $HOME/.nix-profile/etc/profile.d/nix.sh" >> $BASH_ENV
|
||||||
|
- run:
|
||||||
|
name: Install and use the Cachix binary cache
|
||||||
|
command: |
|
||||||
|
nix-env -iA cachix -f https://cachix.org/api/v1/install
|
||||||
|
cachix use postgrest
|
||||||
|
- run:
|
||||||
|
name: Build the PostgREST statically linked binary
|
||||||
|
command: |
|
||||||
|
nix-build -A postgrestStatic
|
||||||
|
- run:
|
||||||
|
name: compress binary
|
||||||
|
command: |
|
||||||
|
mkdir -p /tmp/workspace/bin
|
||||||
|
cd /tmp/workspace/bin
|
||||||
|
tar cvJf postgrest-$CIRCLE_TAG-linux-x64-static.tar.xz -C ~/project/result/bin postgrest
|
||||||
|
- persist_to_workspace:
|
||||||
|
root: /tmp/workspace
|
||||||
|
paths:
|
||||||
|
- bin/*
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
version: 2
|
version: 2
|
||||||
build-test-release:
|
build-test-release:
|
||||||
jobs:
|
jobs:
|
||||||
- build-test:
|
- build-cache:
|
||||||
filters:
|
filters:
|
||||||
tags:
|
tags:
|
||||||
only: /v[0-9]+(\.[0-9]+)*/
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
- style:
|
||||||
|
filters:
|
||||||
|
tags:
|
||||||
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
- build-test-9.4:
|
||||||
|
filters:
|
||||||
|
tags:
|
||||||
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
requires:
|
||||||
|
- build-cache
|
||||||
- build-test-9.6:
|
- build-test-9.6:
|
||||||
filters:
|
filters:
|
||||||
tags:
|
tags:
|
||||||
only: /v[0-9]+(\.[0-9]+)*/
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
requires:
|
||||||
|
- build-cache
|
||||||
|
- build-test-10:
|
||||||
|
filters:
|
||||||
|
tags:
|
||||||
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
requires:
|
||||||
|
- build-cache
|
||||||
|
- build-test-11:
|
||||||
|
filters:
|
||||||
|
tags:
|
||||||
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
requires:
|
||||||
|
- build-cache
|
||||||
|
- build-test-12:
|
||||||
|
filters:
|
||||||
|
tags:
|
||||||
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
requires:
|
||||||
|
- build-cache
|
||||||
- build-prof-test:
|
- build-prof-test:
|
||||||
filters:
|
filters:
|
||||||
tags:
|
tags:
|
||||||
only: /v[0-9]+(\.[0-9]+)*/
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
- centos6:
|
- build-static-bin:
|
||||||
requires:
|
requires:
|
||||||
- build-test
|
- build-nix
|
||||||
- build-test-9.6
|
|
||||||
- build-prof-test
|
|
||||||
filters:
|
|
||||||
tags:
|
|
||||||
only: /v[0-9]+(\.[0-9]+)*/
|
|
||||||
branches:
|
|
||||||
ignore: /.*/
|
|
||||||
- centos7:
|
|
||||||
requires:
|
|
||||||
- build-test
|
|
||||||
- build-test-9.6
|
|
||||||
- build-prof-test
|
|
||||||
filters:
|
|
||||||
tags:
|
|
||||||
only: /v[0-9]+(\.[0-9]+)*/
|
|
||||||
branches:
|
|
||||||
ignore: /.*/
|
|
||||||
- ubuntu:
|
|
||||||
requires:
|
|
||||||
- build-test
|
|
||||||
- build-test-9.6
|
|
||||||
- build-prof-test
|
|
||||||
filters:
|
|
||||||
tags:
|
|
||||||
only: /v[0-9]+(\.[0-9]+)*/
|
|
||||||
branches:
|
|
||||||
ignore: /.*/
|
|
||||||
- ubuntui386:
|
|
||||||
requires:
|
|
||||||
- build-test
|
|
||||||
- build-test-9.6
|
|
||||||
- build-prof-test
|
- build-prof-test
|
||||||
filters:
|
filters:
|
||||||
tags:
|
tags:
|
||||||
@@ -278,10 +436,11 @@ workflows:
|
|||||||
ignore: /.*/
|
ignore: /.*/
|
||||||
- release:
|
- release:
|
||||||
requires:
|
requires:
|
||||||
- centos6
|
- build-static-bin
|
||||||
- centos7
|
filters:
|
||||||
- ubuntu
|
tags:
|
||||||
- ubuntui386
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
- build-nix:
|
||||||
filters:
|
filters:
|
||||||
tags:
|
tags:
|
||||||
only: /v[0-9]+(\.[0-9]+)*/
|
only: /v[0-9]+(\.[0-9]+)*/
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
freebsd_instance:
|
||||||
|
image: freebsd-12-1-release-amd64
|
||||||
|
|
||||||
|
build_task:
|
||||||
|
env:
|
||||||
|
ASSUME_ALWAYS_YES: YES
|
||||||
|
install_script:
|
||||||
|
## TODO: Some of these workaround steps might be unneeded now
|
||||||
|
## gmake required, otherwise bsd make is used and some cryptic "No operator" errors are shown
|
||||||
|
- pkg install -y gmake
|
||||||
|
## gcc required, otherwise warning: macro expansion producing 'defined' has undefined behavior [-Wexpansion-to-defined]
|
||||||
|
- pkg install -y gcc
|
||||||
|
- pkg install -y libiconv
|
||||||
|
- pkg install -y gmp
|
||||||
|
- pkg install -y postgresql95-client
|
||||||
|
- pkg install -y pkgconf
|
||||||
|
- pkg install -y ghc
|
||||||
|
- pkg install -y hs-cabal-install
|
||||||
|
cabal_cache:
|
||||||
|
folder: ~/.cabal
|
||||||
|
fingerprint_script: cat postgrest.cabal
|
||||||
|
build_script:
|
||||||
|
- cabal v2-update
|
||||||
|
- cabal v2-build
|
||||||
|
binaries_artifacts:
|
||||||
|
path: "dist-newstyle/build/x86_64-freebsd/ghc-8.6.5/postgrest-7.0.1/x/postgrest/build/postgrest/*"
|
||||||
|
#binary_script:
|
||||||
|
#- mkdir bin
|
||||||
|
## cabal v2-install --installdir=.
|
||||||
|
## the installdir option is not working on this cabal version(form hs-cabal-install), so the binary must be extrated from the path
|
||||||
|
## this is too brittle so for now we don't use it
|
||||||
|
#- ldd /tmp/cirrus-ci-build/dist-newstyle/build/x86_64-freebsd/ghc-8.6.5/postgrest-7.0.1/x/postgrest/build/postgrest/postgrest
|
||||||
|
#- mv /tmp/cirrus-ci-build/dist-newstyle/build/x86_64-freebsd/ghc-8.6.5/postgrest-7.0.1/x/postgrest/build/postgrest/postgrest bin/
|
||||||
|
## https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction
|
||||||
|
#binaries_artifacts:
|
||||||
|
#path: bin/
|
||||||
|
cleanup_script:
|
||||||
|
- rm -rf ~/.cabal/logs
|
||||||
|
- rm ~/.cabal/config
|
||||||
|
|
||||||
|
#publish_task:
|
||||||
|
#skip: $CIRRUS_TAG == ""
|
||||||
|
#env:
|
||||||
|
#TOKEN: ENCRYPTED[c99babbf04e8a33962ffbbc75bf20d3abe408f9bede5ed5d8956cd24da6fdb34266c920984cc43f8106ef3423fba0e98]
|
||||||
|
#depends_on:
|
||||||
|
#- build
|
||||||
|
#script:
|
||||||
|
## https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction
|
||||||
|
#- wget https://api.cirrus-ci.com/v1/artifact/github/$CIRRUS_REPO_FULL_NAME/build/binaries/bin/postgrest
|
||||||
|
#- tar cjf postgrest-$CIRRUS_TAG-freebsd.tar.xz postgrest
|
||||||
|
### The CIRRUS_RELEASE env var is not the same as the tag. It's an id for the release.
|
||||||
|
#- curl -X POST --data-binary @$NAME \
|
||||||
|
#-H "Authorization:token ${TOKEN}" \
|
||||||
|
#-H "Content-Type:application/octet-stream" \
|
||||||
|
#"https://uploads.github.com/repos/$CIRRUS_REPO_FULL_NAME/releases/${CIRRUS_RELEASE}/assets?name=${NAME}"
|
||||||
@@ -32,7 +32,7 @@ your contributions.
|
|||||||
then [find your logs](http://blog.endpoint.com/2014/11/dear-postgresql-where-are-my-logs.html).
|
then [find your logs](http://blog.endpoint.com/2014/11/dear-postgresql-where-are-my-logs.html).
|
||||||
|
|
||||||
* If your database schema has changed while the PostgREST server is running,
|
* If your database schema has changed while the PostgREST server is running,
|
||||||
send the server a `SIGHUP` signal or restart it to ensure the schema cache
|
[send the server a `SIGUSR1` signal](http://postgrest.org/en/v5.2/admin.html#schema-reloading) or restart it to ensure the schema cache
|
||||||
is not stale. This sometimes fixes apparent bugs.
|
is not stale. This sometimes fixes apparent bugs.
|
||||||
|
|
||||||
## Code
|
## Code
|
||||||
@@ -42,14 +42,13 @@ your contributions.
|
|||||||
* All contributions must pass the tests before being merged. When
|
* All contributions must pass the tests before being merged. When
|
||||||
you create a pull request your code will automatically be tested.
|
you create a pull request your code will automatically be tested.
|
||||||
|
|
||||||
* All code must also pass [hlint](http://community.haskell.org/~ndm/hlint/)
|
* All code must also pass [hlint](http://community.haskell.org/~ndm/hlint/) and [stylish-haskell](https://github.com/jaspervdj/stylish-haskell)
|
||||||
with no warnings. This helps enforce a uniform style for all
|
with no warnings. This helps enforce a uniform style for all committers. Continuous integration will check this as well on every
|
||||||
committers. Continuous integration will check this as well on every
|
pull request. There's a useful Makefile that helps with checking this locally. You can run `make commit-check` to do this manually but
|
||||||
pull request.
|
we recommend adding it to `.git/hooks/pre-commit` to automatically check this before doing a commit.
|
||||||
|
|
||||||
* For help building the Haskell code on your computer check out the [building from
|
* For help building the Haskell code on your computer check out the [building from
|
||||||
source](https://postgrest.com/en/stable/install.html#build-from-source)
|
source](https://postgrest.com/en/stable/install.html#build-from-source) docs section.
|
||||||
wiki page.
|
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
patreon: postgrest
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<!--
|
||||||
|
Before reporting a bug:
|
||||||
|
If your database schema has changed while the PostgREST server is running, send the server a SIGUSR1 signal or restart it(http://postgrest.org/en/v5.2/admin.html#schema-reloading) to ensure the schema cache is not stale. This sometimes fixes apparent bugs.
|
||||||
|
-->
|
||||||
### Environment
|
### Environment
|
||||||
|
|
||||||
* PostgreSQL version: (if using docker, specify the image)
|
* PostgreSQL version: (if using docker, specify the image)
|
||||||
|
|||||||
@@ -12,3 +12,6 @@ site
|
|||||||
*~
|
*~
|
||||||
*#*
|
*#*
|
||||||
.#*
|
.#*
|
||||||
|
*.swp
|
||||||
|
result*
|
||||||
|
dist-newstyle
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ steps:
|
|||||||
# - none: Do not perform any alignment.
|
# - none: Do not perform any alignment.
|
||||||
#
|
#
|
||||||
# Default: global.
|
# Default: global.
|
||||||
align: global
|
align: group
|
||||||
|
|
||||||
# Folowing options affect only import list alignment.
|
# The following options affect only import list alignment.
|
||||||
#
|
#
|
||||||
# List align has following options:
|
# List align has following options:
|
||||||
#
|
#
|
||||||
@@ -64,6 +64,25 @@ steps:
|
|||||||
# Default: after_alias
|
# Default: after_alias
|
||||||
list_align: after_alias
|
list_align: after_alias
|
||||||
|
|
||||||
|
# Right-pad the module names to align imports in a group:
|
||||||
|
#
|
||||||
|
# - true: a little more readable
|
||||||
|
#
|
||||||
|
# > import qualified Data.List as List (concat, foldl, foldr,
|
||||||
|
# > init, last, length)
|
||||||
|
# > import qualified Data.List.Extra as List (concat, foldl, foldr,
|
||||||
|
# > init, last, length)
|
||||||
|
#
|
||||||
|
# - false: diff-safe
|
||||||
|
#
|
||||||
|
# > import qualified Data.List as List (concat, foldl, foldr, init,
|
||||||
|
# > last, length)
|
||||||
|
# > import qualified Data.List.Extra as List (concat, foldl, foldr,
|
||||||
|
# > init, last, length)
|
||||||
|
#
|
||||||
|
# Default: true
|
||||||
|
pad_module_names: true
|
||||||
|
|
||||||
# Long list align style takes effect when import is too long. This is
|
# Long list align style takes effect when import is too long. This is
|
||||||
# determined by 'columns' setting.
|
# determined by 'columns' setting.
|
||||||
#
|
#
|
||||||
@@ -75,7 +94,7 @@ steps:
|
|||||||
# short enough to fit to single line. Otherwise it'll be multiline.
|
# short enough to fit to single line. Otherwise it'll be multiline.
|
||||||
#
|
#
|
||||||
# - multiline: One line per import list entry.
|
# - multiline: One line per import list entry.
|
||||||
# Type with contructor list acts like single import.
|
# Type with constructor list acts like single import.
|
||||||
#
|
#
|
||||||
# > import qualified Data.Map as M
|
# > import qualified Data.Map as M
|
||||||
# > ( empty
|
# > ( empty
|
||||||
@@ -109,7 +128,7 @@ steps:
|
|||||||
# Useful for 'file' and 'group' align settings.
|
# Useful for 'file' and 'group' align settings.
|
||||||
list_padding: 4
|
list_padding: 4
|
||||||
|
|
||||||
# Separate lists option affects formating of import list for type
|
# Separate lists option affects formatting of import list for type
|
||||||
# or class. The only difference is single space between type and list
|
# or class. The only difference is single space between type and list
|
||||||
# of constructors, selectors and class functions.
|
# of constructors, selectors and class functions.
|
||||||
#
|
#
|
||||||
@@ -126,6 +145,22 @@ steps:
|
|||||||
# Default: true
|
# Default: true
|
||||||
separate_lists: true
|
separate_lists: true
|
||||||
|
|
||||||
|
# Space surround option affects formatting of import lists on a single
|
||||||
|
# line. The only difference is single space after the initial
|
||||||
|
# parenthesis and a single space before the terminal parenthesis.
|
||||||
|
#
|
||||||
|
# - true: There is single space associated with the enclosing
|
||||||
|
# parenthesis.
|
||||||
|
#
|
||||||
|
# > import Data.Foo ( foo )
|
||||||
|
#
|
||||||
|
# - false: There is no space associated with the enclosing parenthesis
|
||||||
|
#
|
||||||
|
# > import Data.Foo (foo)
|
||||||
|
#
|
||||||
|
# Default: false
|
||||||
|
space_surround: false
|
||||||
|
|
||||||
# Language pragmas
|
# Language pragmas
|
||||||
- language_pragmas:
|
- language_pragmas:
|
||||||
# We can generate different styles of language pragma lists.
|
# We can generate different styles of language pragma lists.
|
||||||
@@ -142,7 +177,7 @@ steps:
|
|||||||
|
|
||||||
# Align affects alignment of closing pragma brackets.
|
# Align affects alignment of closing pragma brackets.
|
||||||
#
|
#
|
||||||
# - true: Brackets are aligned in same collumn.
|
# - true: Brackets are aligned in same column.
|
||||||
#
|
#
|
||||||
# - false: Brackets are not aligned together. There is only one space
|
# - false: Brackets are not aligned together. There is only one space
|
||||||
# between actual import and closing bracket.
|
# between actual import and closing bracket.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
## Travis is only used for building an OSX binary ,
|
||||||
|
## no tests are run here.
|
||||||
language: generic
|
language: generic
|
||||||
|
|
||||||
sudo: false
|
sudo: false
|
||||||
@@ -40,11 +42,15 @@ install:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
script:
|
script:
|
||||||
|
## Building the whole project can take longer than 50 minutes. Since Travis has a global timeout of 50 minutes
|
||||||
|
## we compile for 30 minutes tops(`gtimeout 1800`) and quit compiling with no error.
|
||||||
|
## Since we CACHE the compile results we can continue compiling from where we left off
|
||||||
|
## on the next commit.
|
||||||
- gtimeout 1800 stack build --no-terminal --only-snapshot --install-ghc || true
|
- gtimeout 1800 stack build --no-terminal --only-snapshot --install-ghc || true
|
||||||
- |
|
- |
|
||||||
if test ! "$TRAVIS_TAG"
|
if test ! "$TRAVIS_TAG"
|
||||||
then
|
then
|
||||||
echo 'No tag pushed. Skipping build.'
|
echo 'No tag pushed. Skip building binary.'
|
||||||
else
|
else
|
||||||
stack build --no-terminal --copy-bins --local-bin-path .
|
stack build --no-terminal --copy-bins --local-bin-path .
|
||||||
fi
|
fi
|
||||||
@@ -59,6 +65,6 @@ script:
|
|||||||
END='## \['
|
END='## \['
|
||||||
BODY=$(sed -n "1,/$START/d;/$END/q;p" CHANGELOG.md)
|
BODY=$(sed -n "1,/$START/d;/$END/q;p" CHANGELOG.md)
|
||||||
strip postgrest
|
strip postgrest
|
||||||
tar cjf postgrest-$TRAVIS_TAG-osx.tar.xz 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
|
ghr -t $GITHUB_TOKEN -u $OWNER -r $REPO -b "$BODY"--replace $TRAVIS_TAG postgrest-$TRAVIS_TAG-osx.tar.xz
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -2,13 +2,62 @@
|
|||||||
|
|
||||||
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).
|
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
|
## Lead Backers
|
||||||
|
|
||||||
- [Christiaan Westerbeek](https://devotis.nl)
|
|
||||||
- [Daniel Babiak](https://github.com/d-babiak)
|
- [Daniel Babiak](https://github.com/d-babiak)
|
||||||
- [Michel Pelletier](https://github.com/michelp/)
|
- Evans Fernandes
|
||||||
|
- [Jan Sommer](https://github.com/nerfpops)
|
||||||
|
|
||||||
## Backers
|
## Backers
|
||||||
|
|
||||||
- Tsingson Qin
|
- Tsingson Qin
|
||||||
|
- Michel Pelletier
|
||||||
- Jay Hannah
|
- Jay Hannah
|
||||||
|
- Robert Stolarz
|
||||||
|
- Kofi Gumbs
|
||||||
|
- Nicholas DiBiase
|
||||||
|
- Christopher Reid
|
||||||
|
- Nathan Bouscal
|
||||||
|
- Daniel Rafaj
|
||||||
|
- David Fenko
|
||||||
|
- Remo Rechkemmer
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|||||||
@@ -9,6 +9,125 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
## [7.0.1] - 2020-05-18
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #1473, Fix overloaded computed columns on RPC - @wolfgangwalther
|
||||||
|
- #1471, Fix POST, PATCH, DELETE with ?select= and return=minimal and PATCH with empty body - @wolfgangwalther
|
||||||
|
- #1500, Fix missing `openapi-server-proxy-uri` config option - @steve-chavez
|
||||||
|
- #1508, Fix `Content-Profile` not working for POST RPC - @steve-chavez
|
||||||
|
- #1452, Fix PUT restriction for all columns - @steve-chavez
|
||||||
|
|
||||||
|
## [7.0.0] - 2020-04-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- #1417, `Accept: application/vnd.pgrst.object+json` behavior is now enforced for POST/PATCH/DELETE regardless of `Prefer: return=representation/minimal` - @dwagin
|
||||||
|
- #1415, Add support for user defined socket permission via `server-unix-socket-mode` config option - @Dansvidania
|
||||||
|
- #1383, Add support for HEAD request - @steve-chavez
|
||||||
|
- #1378, Add support for `Prefer: count=planned` and `Prefer: count=estimated` on GET /table - @steve-chavez, @LorenzHenk
|
||||||
|
- #1327, Add support for optional query parameter `on_conflict` to upsert with specified keys for POST - @ykst
|
||||||
|
- #1430, Allow specifying the foreign key constraint name(`/source?select=fk_constraint(*)`) to disambiguate an embedding - @steve-chavez
|
||||||
|
- #1168, Allow access to the `Authorization` header through the `request.header.authorization` GUC - @steve-chavez
|
||||||
|
- #1435, Add `request.method` and `request.path` GUCs - @steve-chavez
|
||||||
|
- #1088, Allow adding headers to GET/POST/PATCH/PUT/DELETE responses through the `response.headers` GUC - @steve-chavez
|
||||||
|
- #1427, Allow overriding provided headers(Location, Content-Type, etc) through the `response.headers` GUC - @steve-chavez
|
||||||
|
- #1450, Allow multiple schemas to be exposed in one instance. The schema to use can be selected through the headers `Accept-Profile` for GET/HEAD and `Content-Profile` for POST/PATCH/PUT/DELETE - @steve-chavez, @mahmoudkassem
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #1301, Fix self join resource embedding on PATCH - @herulume, @steve-chavez
|
||||||
|
- #1389, Fix many to many resource embedding on RPC/PATCH - @steve-chavez
|
||||||
|
- #1355, Allow PATCH/DELETE without `return=minimal` on tables with no select privileges - @steve-chavez
|
||||||
|
- #1361, Fix embedding a VIEW when its source foreign key is UNIQUE - @bwbroersma
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- #1385, bulk RPC call now should be done by specifying a `Prefer: params=multiple-objects` header - @steve-chavez
|
||||||
|
- #1401, resource embedding now outputs an error when multiple relationships between two tables are found - @steve-chavez
|
||||||
|
- #1423, default Unix Socket file mode from 755 to 660 - @dwagin
|
||||||
|
- #1430, Remove embedding with duck typed column names `GET /projects?select=client(*)`- @steve-chavez
|
||||||
|
+ You can rename the foreign key to `client` to make this request work in the new version: `alter table projects rename constraint projects_client_id_fkey to client`
|
||||||
|
- #1413, Change `server-proxy-uri` config option to `openapi-server-proxy-uri` - @steve-chavez
|
||||||
|
|
||||||
|
## [6.0.2] - 2019-08-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #1369, Change `raw-media-types` to accept a string of comma separated MIME types - @Dansvidania
|
||||||
|
- #1368, Fix long column descriptions being truncated at 63 characters in PostgreSQL 12 - @amedeedaboville
|
||||||
|
- #1348, Go back to converting plus "+" to space " " in querystrings by default - @steve-chavez
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- #1348, Deprecate `.` symbol for disambiguating resource embedding(added in #918). The url-safe '!' should be used instead. We refrained from using `+` as part of our syntax because it conflicts with some http clients and proxies.
|
||||||
|
|
||||||
|
## [6.0.1] - 2019-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- #1349, Add user defined raw output media types via `raw-media-types` config option - @Dansvidania
|
||||||
|
- #1243, Add websearch_to_tsquery support - @herulume
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #1336, Error when testing on Chrome/Firefox: text/html requested but a single column was not selected - @Dansvidania
|
||||||
|
- #1334, Unable to compile v6.0.0 on windows - @steve-chavez
|
||||||
|
|
||||||
|
## [6.0.0] - 2019-06-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- #1186, Add support for user defined unix socket via `server-unix-socket` config option - @Dansvidania
|
||||||
|
- #690, Add `?columns` query parameter for faster bulk inserts, also ignores unspecified json keys in a payload - @steve-chavez
|
||||||
|
- #1239, Add support for resource embedding on materialized views - @vitorbaptista
|
||||||
|
- #1264, Add support for bulk RPC call - @steve-chavez
|
||||||
|
- #1278, Add db-pool-timeout config option - @qu4tro
|
||||||
|
- #1285, Abort on wrong database password - @qu4tro
|
||||||
|
- #790, Allow override of OpenAPI spec through `root-spec` config option - @steve-chavez
|
||||||
|
- #1308, Accept `text/plain` and `text/html` for raw output - @steve-chavez
|
||||||
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #1223, Fix incorrect OpenAPI externalDocs url - @steve-chavez
|
||||||
|
- #1221, Fix embedding other resources when having a self join - @steve-chavez
|
||||||
|
- #1242, Fix embedding a view having a select in a where - @steve-chavez
|
||||||
|
- #1238, Fix PostgreSQL to OpenAPI type mappings for numeric and character types - @fpusch
|
||||||
|
- #1265, Fix query generated on bulk upsert with an empty array - @qu4tro
|
||||||
|
- #1273, Fix RPC ignoring unknown arguments by default - @steve-chavez
|
||||||
|
- #1257, Fix incorrect status when a PATCH request doesn't find rows to change - @qu4tro
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- #1288, Change server-host default of 127.0.0.1 to !4
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- #1288, Deprecate `.` symbol for disambiguating resource embedding(added in #918). '+' should be used instead. Though '+' is url safe, certain clients might need to encode it to '%2B'.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- #1288, Removed support for schema reloading with SIGHUP, SIGUSR1 should be used instead - @steve-chavez
|
||||||
|
|
||||||
|
## [5.2.0] - 2018-12-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- #1205, Add support for parsing JSON Web Key Sets - @russelldavies
|
||||||
|
- #1203, Add support for reading db-uri from a separate file - @zhoufeng1989
|
||||||
|
- #1200, Add db-extra-search-path config for adding schemas to the search_path, solves issues related to extensions created on the public schema - @steve-chavez
|
||||||
|
- #1219, Add ability to quote column names on filters - @steve-chavez
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #1182, Fix embedding on views with composite pks - @steve-chavez
|
||||||
|
- #1180, Fix embedding on views with subselects in pg10 - @steve-chavez
|
||||||
|
- #1197, Allow CORS for PUT - @bkylerussell
|
||||||
|
- #1181, Correctly qualify function argument of custom type in public schema - @steve-chavez
|
||||||
|
- #1008, Allow columns that contain spaces in filters - @steve-chavez
|
||||||
|
|
||||||
## [5.1.0] - 2018-08-31
|
## [5.1.0] - 2018-08-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
Copyright (c) 2014 Joe Nelson
|
Copyright (c) 2014 Joe Nelson
|
||||||
|
Copyright (c) 2019 Steve Chavez
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
a copy of this software and associated documentation files (the
|
a copy of this software and associated documentation files (the
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
.PHONY: commit-check check clean lint style test test-watch coverage circleci circleci-prof-test check-dburi prompt-clean prompt-long-process
|
||||||
|
|
||||||
|
commit-check: lint style
|
||||||
|
|
||||||
|
check: lint style test
|
||||||
|
|
||||||
|
clean: prompt-clean
|
||||||
|
stack clean --full
|
||||||
|
|
||||||
|
# For running these you'll need to install hlint and stylish-haskell first. Run:
|
||||||
|
# stack install hlint stylish-haskell
|
||||||
|
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 && git diff-index --exit-code HEAD -- '*.hs' '*.lhs'
|
||||||
|
|
||||||
|
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,20 +1,47 @@
|
|||||||

|

|
||||||
|
|
||||||
[](https://circleci.com/gh/PostgREST/postgrest/tree/master)
|
[](https://www.patreon.com/postgrest)
|
||||||
|
[](https://www.paypal.me/postgrest)
|
||||||
<a href="https://heroku.com/deploy?template=https://github.com/PostgREST/postgrest">
|
<a href="https://heroku.com/deploy?template=https://github.com/PostgREST/postgrest">
|
||||||
<img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy">
|
<img src="https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg" alt="Deploy">
|
||||||
</a>
|
</a>
|
||||||
[](https://gitter.im/begriffs/postgrest)
|
[](https://gitter.im/begriffs/postgrest)
|
||||||
[](http://postgrest.org)
|
[](http://postgrest.org)
|
||||||
[](http://hackage.haskell.org/package/postgrest)
|
|
||||||
[](https://hub.docker.com/r/postgrest/postgrest/)
|
[](https://hub.docker.com/r/postgrest/postgrest/)
|
||||||
[](https://www.patreon.com/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
|
PostgREST serves a fully RESTful API from any existing PostgreSQL
|
||||||
database. It provides a cleaner, more standards-compliant, faster
|
database. It provides a cleaner, more standards-compliant, faster
|
||||||
API than you are likely to write from scratch.
|
API than you are likely to write from scratch.
|
||||||
|
|
||||||
### Usage
|
## Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://www.cybertec-postgresql.com/en/" target="_blank">
|
||||||
|
<img width="222px" src="static/cybertec.png">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://www.2ndquadrant.com/en/?utm_campaign=External%20Websites&utm_source=PostgREST&utm_medium=Logo" target="_blank">
|
||||||
|
<img width="222px" src="static/2ndquadrant.png">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||||
|
<img width="222px" src="static/retool.png">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Big thanks to our sponsors! You can join them by supporting PostgREST on [Patreon](https://www.patreon.com/postgrest).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
1. Download the binary ([latest release](https://github.com/PostgREST/postgrest/releases/latest))
|
1. Download the binary ([latest release](https://github.com/PostgREST/postgrest/releases/latest))
|
||||||
for your platform.
|
for your platform.
|
||||||
@@ -23,13 +50,15 @@ API than you are likely to write from scratch.
|
|||||||
```bash
|
```bash
|
||||||
postgrest --help
|
postgrest --help
|
||||||
```
|
```
|
||||||
|
## [Documentation](http://postgrest.org)
|
||||||
|
|
||||||
### Performance
|
Latest documentation is at [postgrest.org](http://postgrest.org). You can contribute to the docs in [PostgREST/postgrest-docs](https://github.com/PostgREST/postgrest-docs).
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
TLDR; subsecond response times for up to 2000 requests/sec on Heroku
|
TLDR; subsecond response times for up to 2000 requests/sec on Heroku
|
||||||
free tier. If you're used to servers written in interpreted languages
|
free tier. If you're used to servers written in interpreted languages,
|
||||||
(or named after precious gems), prepare to be pleasantly surprised by
|
prepare to be pleasantly surprised by PostgREST performance.
|
||||||
PostgREST performance.
|
|
||||||
|
|
||||||
Three factors contribute to the speed. First the server is written
|
Three factors contribute to the speed. First the server is written
|
||||||
in [Haskell](https://www.haskell.org/) using the
|
in [Haskell](https://www.haskell.org/) using the
|
||||||
@@ -52,7 +81,7 @@ by
|
|||||||
* Using the PostgreSQL binary protocol
|
* Using the PostgreSQL binary protocol
|
||||||
* Being stateless to allow horizontal scaling
|
* Being stateless to allow horizontal scaling
|
||||||
|
|
||||||
### Security
|
## Security
|
||||||
|
|
||||||
PostgREST [handles
|
PostgREST [handles
|
||||||
authentication](http://postgrest.org/en/stable/auth.html) (via JSON Web
|
authentication](http://postgrest.org/en/stable/auth.html) (via JSON Web
|
||||||
@@ -73,7 +102,7 @@ are limited to certain templates using
|
|||||||
functions, the trigger workaround does not compromise row-level
|
functions, the trigger workaround does not compromise row-level
|
||||||
security.
|
security.
|
||||||
|
|
||||||
### Versioning
|
## Versioning
|
||||||
|
|
||||||
A robust long-lived API needs the freedom to exist in multiple
|
A robust long-lived API needs the freedom to exist in multiple
|
||||||
versions. PostgREST does versioning through database schemas. This
|
versions. PostgREST does versioning through database schemas. This
|
||||||
@@ -81,7 +110,7 @@ allows you to expose tables and views without making the app brittle.
|
|||||||
Underlying tables can be superseded and hidden behind public facing
|
Underlying tables can be superseded and hidden behind public facing
|
||||||
views.
|
views.
|
||||||
|
|
||||||
### Self-documentation
|
## Self-documentation
|
||||||
|
|
||||||
PostgREST uses the [OpenAPI](https://openapis.org/) standard to
|
PostgREST uses the [OpenAPI](https://openapis.org/) standard to
|
||||||
generate up-to-date documentation for APIs. You can use a tool like
|
generate up-to-date documentation for APIs. You can use a tool like
|
||||||
@@ -93,7 +122,7 @@ instance the number of rows returned by an endpoint is reported by -
|
|||||||
and limited with - range headers. More about
|
and limited with - range headers. More about
|
||||||
[that](http://begriffs.com/posts/2014-03-06-beyond-http-header-links.html).
|
[that](http://begriffs.com/posts/2014-03-06-beyond-http-header-links.html).
|
||||||
|
|
||||||
### Data Integrity
|
## Data Integrity
|
||||||
|
|
||||||
Rather than relying on an Object Relational Mapper and custom
|
Rather than relying on an Object Relational Mapper and custom
|
||||||
imperative coding, this system requires you put declarative constraints
|
imperative coding, this system requires you put declarative constraints
|
||||||
@@ -107,7 +136,17 @@ See examples of [PostgreSQL
|
|||||||
constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)
|
constraints](http://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)
|
||||||
and the [API guide](http://postgrest.org/en/stable/api.html).
|
and the [API guide](http://postgrest.org/en/stable/api.html).
|
||||||
|
|
||||||
### Thanks
|
## Supporting development
|
||||||
|
|
||||||
|
You can help PostgREST ongoing maintenance and development by:
|
||||||
|
|
||||||
|
- Making a regular donation through Patreon https://www.patreon.com/postgrest
|
||||||
|
|
||||||
|
- Alternatively, you can make a one-time donation via Paypal https://www.paypal.me/postgrest
|
||||||
|
|
||||||
|
Every donation will be spent on making PostgREST better for the whole community.
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
|
||||||
The PostgREST organization is grateful to:
|
The PostgREST organization is grateful to:
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"POSTGREST_VER": {
|
"POSTGREST_VER": {
|
||||||
"description": "Version of PostgREST to deploy",
|
"description": "Version of PostgREST to deploy",
|
||||||
"value": "5.1.0"
|
"value": "7.0.1"
|
||||||
},
|
},
|
||||||
"DB_URI": {
|
"DB_URI": {
|
||||||
"description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb",
|
"description": "Database connection string, e.g. postgres://user:pass@xxxxxxx.rds.amazonaws.com/mydb",
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
|
## AppVeyor is only used for building a Windows binary, no tests are run here.
|
||||||
platform: x64
|
platform: x64
|
||||||
|
image: Visual Studio 2015
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
- "c:\\sr"
|
- "c:\\sr"
|
||||||
- .stack-work
|
- .stack-work
|
||||||
|
- "c:\\Users\\appveyor\\AppData\\Local\\Programs\\stack"
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
global:
|
global:
|
||||||
STACK_ROOT: "c:\\sr"
|
STACK_ROOT: "c:\\sr"
|
||||||
GOPATH: c:\gopath
|
GOPATH: c:\gopath
|
||||||
|
TMP: "c:\\tmp"
|
||||||
|
|
||||||
test: off
|
test: off
|
||||||
|
|
||||||
skip_non_tags: true
|
|
||||||
|
|
||||||
skip_branch_with_pr: true
|
|
||||||
|
|
||||||
branches:
|
|
||||||
only:
|
|
||||||
- master
|
|
||||||
|
|
||||||
install:
|
install:
|
||||||
- set PATH=C:\Program Files\PostgreSQL\9.6\bin\;%PATH%
|
- set PATH=C:\Program Files\PostgreSQL\9.6\bin\;%PATH%
|
||||||
- curl -sS -ostack.zip -L --insecure http://www.stackage.org/stack/windows-x86_64
|
- curl -sS -ostack.zip -L --insecure http://www.stackage.org/stack/windows-x86_64
|
||||||
@@ -28,11 +24,13 @@ install:
|
|||||||
|
|
||||||
build_script:
|
build_script:
|
||||||
- stack setup --no-terminal > nul
|
- stack setup --no-terminal > nul
|
||||||
- stack build -j1 --copy-bins --local-bin-path .
|
# Appveyor has a timeout of 60 mins, building can take longer, limit the time and make sure this succeeds,
|
||||||
|
# previous work will get cached and finish on next commit
|
||||||
|
- bash -lc "timeout 2700 'C:\projects\postgrest\stack.exe' build -j1 --copy-bins --local-bin-path . || true"
|
||||||
|
|
||||||
artifacts:
|
artifacts:
|
||||||
- path: postgrest.exe
|
- path: postgrest.exe
|
||||||
|
|
||||||
deploy_script:
|
deploy_script:
|
||||||
- 7z a -tzip postgrest-%APPVEYOR_REPO_TAG_NAME%-windows-x64.zip postgrest.exe
|
- IF DEFINED APPVEYOR_REPO_TAG_NAME 7z a -tzip postgrest-%APPVEYOR_REPO_TAG_NAME%-windows-x64.zip postgrest.exe
|
||||||
- bash -lc "exec 0</dev/null && cd $APPVEYOR_BUILD_FOLDER && ghr -t $GITHUB_TOKEN -u $APPVEYOR_ACCOUNT_NAME -r $APPVEYOR_PROJECT_NAME -b \"$(sed -n \"1,/$(echo $APPVEYOR_REPO_TAG_NAME | cut -c2-)/d;/## \[/q;p\" CHANGELOG.md)\" --replace $APPVEYOR_REPO_TAG_NAME postgrest-$APPVEYOR_REPO_TAG_NAME-windows-x64.zip"
|
- IF DEFINED APPVEYOR_REPO_TAG_NAME bash -lc " exec 0</dev/null && cd $APPVEYOR_BUILD_FOLDER && ghr -t $GITHUB_TOKEN -u $APPVEYOR_ACCOUNT_NAME -r $APPVEYOR_PROJECT_NAME -b \"$(sed -n \"1,/$(echo $APPVEYOR_REPO_TAG_NAME | cut -c2-)/d;/## \[/q;p\" CHANGELOG.md)\" --replace $APPVEYOR_REPO_TAG_NAME postgrest-$APPVEYOR_REPO_TAG_NAME-windows-x64.zip"
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
let
|
||||||
|
name =
|
||||||
|
"postgrest";
|
||||||
|
|
||||||
|
compiler =
|
||||||
|
"ghc883";
|
||||||
|
|
||||||
|
# PostgREST source files, filtered based on the rules in the .gitignore files
|
||||||
|
# and file extensions. We want to include as litte as possible, as the files
|
||||||
|
# added here will increase the space used in the Nix store and trigger the
|
||||||
|
# build of new Nix derivations when changed.
|
||||||
|
src =
|
||||||
|
pkgs.lib.sourceFilesBySuffices
|
||||||
|
(pkgs.gitignoreSource ./.)
|
||||||
|
[ ".cabal" ".hs" ".lhs" "LICENSE" ];
|
||||||
|
|
||||||
|
# Commit of the Nixpkgs repository that we want to use.
|
||||||
|
nixpkgsVersion =
|
||||||
|
import nix/nixpkgs-version.nix;
|
||||||
|
|
||||||
|
# Nix files that describe the Nixpkgs repository. We evaluate the expression
|
||||||
|
# using `import` below.
|
||||||
|
nixpkgs =
|
||||||
|
builtins.fetchTarball {
|
||||||
|
url = "https://github.com/nixos/nixpkgs/archive/${nixpkgsVersion.rev}.tar.gz";
|
||||||
|
sha256 = nixpkgsVersion.tarballHash;
|
||||||
|
};
|
||||||
|
|
||||||
|
overlays =
|
||||||
|
[
|
||||||
|
(import nix/overlays/postgresql-legacy.nix)
|
||||||
|
(import nix/overlays/gitignore.nix)
|
||||||
|
(import nix/overlays/haskell-packages)
|
||||||
|
];
|
||||||
|
|
||||||
|
# Evaluated expression of the Nixpkgs repository.
|
||||||
|
pkgs =
|
||||||
|
import nixpkgs { inherit overlays; };
|
||||||
|
|
||||||
|
postgresqlVersions =
|
||||||
|
[
|
||||||
|
pkgs.postgresql_12
|
||||||
|
pkgs.postgresql_11
|
||||||
|
pkgs.postgresql_10
|
||||||
|
pkgs.postgresql_9_6
|
||||||
|
pkgs.postgresql_9_5
|
||||||
|
pkgs.postgresql_9_4
|
||||||
|
];
|
||||||
|
|
||||||
|
patches =
|
||||||
|
pkgs.callPackage nix/patches {};
|
||||||
|
|
||||||
|
# Base dynamic derivation for the PostgREST package.
|
||||||
|
drv =
|
||||||
|
pkgs.haskellPackages.callCabal2nix name src {};
|
||||||
|
|
||||||
|
# Static derivation for the PostgREST executable.
|
||||||
|
drvStatic =
|
||||||
|
import nix/static { inherit nixpkgs name src compiler patches; };
|
||||||
|
|
||||||
|
lib =
|
||||||
|
pkgs.haskell.lib;
|
||||||
|
in
|
||||||
|
rec {
|
||||||
|
inherit nixpkgs pkgs;
|
||||||
|
|
||||||
|
# Derivation for the PostgREST Haskell package, including the executable,
|
||||||
|
# libraries and documentation. We disable running the test suite on Nix
|
||||||
|
# builds, as they require a database to be set up.
|
||||||
|
postgrestWithLib =
|
||||||
|
lib.dontCheck drv;
|
||||||
|
|
||||||
|
# Derivation for just the PostgREST binary, where we strip all dynamic
|
||||||
|
# libraries and documentation, leaving only the executable. Note that the
|
||||||
|
# executable is static with regards to Haskell libraries, but not system
|
||||||
|
# libraries like glibc and libpq.
|
||||||
|
postgrest =
|
||||||
|
lib.justStaticExecutables postgrestWithLib;
|
||||||
|
|
||||||
|
# Static executable.
|
||||||
|
postgrestStatic =
|
||||||
|
lib.justStaticExecutables (lib.dontCheck drvStatic);
|
||||||
|
|
||||||
|
# Docker image and loading script.
|
||||||
|
docker =
|
||||||
|
pkgs.callPackage nix/docker { postgrest = postgrestStatic; };
|
||||||
|
|
||||||
|
# Environment in which PostgREST can be built with cabal, useful e.g. for
|
||||||
|
# defining a shell for nix-shell.
|
||||||
|
env =
|
||||||
|
drv.env;
|
||||||
|
|
||||||
|
# Utility for updating the pinned version of Nixpkgs.
|
||||||
|
nixpkgsUpgrade =
|
||||||
|
pkgs.callPackage nix/nixpkgs-upgrade.nix {};
|
||||||
|
|
||||||
|
tests =
|
||||||
|
pkgs.callPackage nix/tests.nix
|
||||||
|
{
|
||||||
|
inherit postgresqlVersions;
|
||||||
|
postgrestBuildEnv = env;
|
||||||
|
};
|
||||||
|
|
||||||
|
style =
|
||||||
|
pkgs.callPackage nix/style.nix {};
|
||||||
|
|
||||||
|
lint =
|
||||||
|
pkgs.callPackage nix/lint.nix {};
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
FROM debian:jessie
|
# To build use:
|
||||||
|
# docker build --build-arg POSTGREST_VERSION=<v5.2.0 or another version> -t postgrest ./docker/
|
||||||
|
|
||||||
|
FROM debian:buster-slim
|
||||||
|
|
||||||
ARG POSTGREST_VERSION
|
ARG POSTGREST_VERSION
|
||||||
|
|
||||||
@@ -28,15 +31,24 @@ ENV PGRST_DB_URI= \
|
|||||||
PGRST_DB_SCHEMA=public \
|
PGRST_DB_SCHEMA=public \
|
||||||
PGRST_DB_ANON_ROLE= \
|
PGRST_DB_ANON_ROLE= \
|
||||||
PGRST_DB_POOL=100 \
|
PGRST_DB_POOL=100 \
|
||||||
|
PGRST_DB_EXTRA_SEARCH_PATH=public \
|
||||||
PGRST_SERVER_HOST=*4 \
|
PGRST_SERVER_HOST=*4 \
|
||||||
PGRST_SERVER_PORT=3000 \
|
PGRST_SERVER_PORT=3000 \
|
||||||
PGRST_SERVER_PROXY_URI= \
|
PGRST_OPENAPI_SERVER_PROXY_URI= \
|
||||||
PGRST_JWT_SECRET= \
|
PGRST_JWT_SECRET= \
|
||||||
PGRST_SECRET_IS_BASE64=false \
|
PGRST_SECRET_IS_BASE64=false \
|
||||||
PGRST_JWT_AUD= \
|
PGRST_JWT_AUD= \
|
||||||
PGRST_MAX_ROWS= \
|
PGRST_MAX_ROWS= \
|
||||||
PGRST_PRE_REQUEST= \
|
PGRST_PRE_REQUEST= \
|
||||||
PGRST_ROLE_CLAIM_KEY=".role"
|
PGRST_ROLE_CLAIM_KEY=".role" \
|
||||||
|
PGRST_ROOT_SPEC= \
|
||||||
|
PGRST_RAW_MEDIA_TYPES=
|
||||||
|
|
||||||
|
RUN groupadd -g 1000 postgrest && \
|
||||||
|
useradd -r -u 1000 -g postgrest postgrest && \
|
||||||
|
chown postgrest:postgrest /etc/postgrest.conf
|
||||||
|
|
||||||
|
USER 1000
|
||||||
|
|
||||||
# PostgREST reads /etc/postgrest.conf so map the configuration
|
# PostgREST reads /etc/postgrest.conf so map the configuration
|
||||||
# file in when you run this container
|
# file in when you run this container
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
db-uri = "postgres://app_user:password@postgres:5432/app_db"
|
|
||||||
db-schema = "public"
|
|
||||||
db-anon-role = "app_user"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
FROM centos:centos6
|
|
||||||
|
|
||||||
RUN yum -y update
|
|
||||||
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
|
|
||||||
RUN yum -y install https://download.postgresql.org/pub/repos/yum/9.3/redhat/rhel-6-x86_64/pgdg-centos93-9.3-2.noarch.rpm
|
|
||||||
RUN yum -y install postgresql93-devel
|
|
||||||
RUN yum clean all
|
|
||||||
RUN curl -sSL https://get.haskellstack.org/ | sh
|
|
||||||
|
|
||||||
ENV PATH $PATH:/usr/pgsql-9.3/bin
|
|
||||||
|
|
||||||
# To disable warning when building
|
|
||||||
ENV PATH $PATH:/root/.local/bin
|
|
||||||
|
|
||||||
RUN mkdir /source
|
|
||||||
WORKDIR /source
|
|
||||||
|
|
||||||
ENTRYPOINT ["stack"]
|
|
||||||
@@ -2,12 +2,12 @@ FROM centos:centos7
|
|||||||
|
|
||||||
RUN yum -y update
|
RUN yum -y update
|
||||||
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
|
RUN yum -y install perl make automake gcc gmp-devel libffi zlib zlib-devel xz tar
|
||||||
RUN yum -y install yum install https://download.postgresql.org/pub/repos/yum/9.3/redhat/rhel-7-x86_64/pgdg-centos93-9.3-2.noarch.rpm
|
RUN yum -y install yum install https://download.postgresql.org/pub/repos/yum/10/redhat/rhel-7-x86_64/pgdg-centos10-10-2.noarch.rpm
|
||||||
RUN yum -y install postgresql93-devel
|
RUN yum -y install postgresql10-devel
|
||||||
RUN yum clean all
|
RUN yum clean all
|
||||||
RUN curl -sSL https://get.haskellstack.org/ | sh
|
RUN curl -sSL https://get.haskellstack.org/ | sh
|
||||||
|
|
||||||
ENV PATH $PATH:/usr/pgsql-9.3/bin
|
ENV PATH $PATH:/usr/pgsql-10/bin
|
||||||
|
|
||||||
# To disable warning when building
|
# To disable warning when building
|
||||||
ENV PATH $PATH:/root/.local/bin
|
ENV PATH $PATH:/root/.local/bin
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
FROM ubuntu:16.04
|
FROM ubuntu:16.04
|
||||||
|
|
||||||
|
## TODO pin the stack version
|
||||||
|
#
|
||||||
RUN BUILD_DEPS="curl ca-certificates build-essential" && \
|
RUN BUILD_DEPS="curl ca-certificates build-essential" && \
|
||||||
apt-get -qq update && \
|
apt-get -qq update && \
|
||||||
apt-get -qqy --no-install-recommends install \
|
apt-get -qqy --no-install-recommends install \
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
FROM 32bit/ubuntu:16.04
|
FROM i386/ubuntu:16.04
|
||||||
|
|
||||||
|
## TODO pin the stack version
|
||||||
|
|
||||||
RUN BUILD_DEPS="curl ca-certificates build-essential" && \
|
RUN BUILD_DEPS="curl ca-certificates build-essential" && \
|
||||||
apt-get -qq update && \
|
apt-get -qq update && \
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ db-uri = "$(PGRST_DB_URI)"
|
|||||||
db-schema = "$(PGRST_DB_SCHEMA)"
|
db-schema = "$(PGRST_DB_SCHEMA)"
|
||||||
db-anon-role = "$(PGRST_DB_ANON_ROLE)"
|
db-anon-role = "$(PGRST_DB_ANON_ROLE)"
|
||||||
db-pool = "$(PGRST_DB_POOL)"
|
db-pool = "$(PGRST_DB_POOL)"
|
||||||
|
db-extra-search-path = "$(PGRST_DB_EXTRA_SEARCH_PATH)"
|
||||||
|
|
||||||
server-host = "$(PGRST_SERVER_HOST)"
|
server-host = "$(PGRST_SERVER_HOST)"
|
||||||
server-port = "$(PGRST_SERVER_PORT)"
|
server-port = "$(PGRST_SERVER_PORT)"
|
||||||
|
|
||||||
server-proxy-uri = "$(PGRST_SERVER_PROXY_URI)"
|
openapi-server-proxy-uri = "$(PGRST_OPENAPI_SERVER_PROXY_URI)"
|
||||||
jwt-secret = "$(PGRST_JWT_SECRET)"
|
jwt-secret = "$(PGRST_JWT_SECRET)"
|
||||||
secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)"
|
secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)"
|
||||||
jwt-aud = "$(PGRST_JWT_AUD)"
|
jwt-aud = "$(PGRST_JWT_AUD)"
|
||||||
@@ -14,3 +15,5 @@ role-claim-key = "$(PGRST_ROLE_CLAIM_KEY)"
|
|||||||
|
|
||||||
max-rows = "$(PGRST_MAX_ROWS)"
|
max-rows = "$(PGRST_MAX_ROWS)"
|
||||||
pre-request = "$(PGRST_PRE_REQUEST)"
|
pre-request = "$(PGRST_PRE_REQUEST)"
|
||||||
|
root-spec = "$(PGRST_ROOT_SPEC)"
|
||||||
|
raw-media-types = "$(PGRST_RAW_MEDIA_TYPES)"
|
||||||
|
|||||||
@@ -2,44 +2,47 @@
|
|||||||
|
|
||||||
module Main where
|
module Main where
|
||||||
|
|
||||||
|
import qualified Data.ByteString as BS
|
||||||
|
import qualified Data.ByteString.Base64 as B64
|
||||||
|
import qualified Hasql.Pool as P
|
||||||
|
import qualified Hasql.Transaction.Sessions as HT
|
||||||
|
|
||||||
import PostgREST.App (postgrest)
|
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
|
||||||
import PostgREST.Config (AppConfig (..),
|
updateAction)
|
||||||
minimumPgVersion,
|
import Control.Retry (RetryStatus, capDelay,
|
||||||
prettyVersion, readOptions)
|
exponentialBackoff, retrying,
|
||||||
import PostgREST.DbStructure (getDbStructure, getPgVersion)
|
rsPreviousDelay)
|
||||||
import PostgREST.Error (encodeError)
|
import Data.Either.Combinators (whenLeft)
|
||||||
import PostgREST.OpenAPI (isMalformedProxyUri)
|
import Data.IORef (IORef, atomicWriteIORef, newIORef,
|
||||||
import PostgREST.Types (DbStructure, Schema, PgVersion(..))
|
readIORef)
|
||||||
import Protolude hiding (hPutStrLn, replace)
|
import Data.String (IsString (..))
|
||||||
|
import Data.Text (pack, replace, strip, stripPrefix)
|
||||||
|
import Data.Text.IO (hPutStrLn)
|
||||||
|
import Data.Time.Clock (getCurrentTime)
|
||||||
|
import Network.Wai.Handler.Warp (defaultSettings, runSettings,
|
||||||
|
setHost, setPort, setServerName)
|
||||||
|
import System.IO (BufferMode (..), hSetBuffering)
|
||||||
|
|
||||||
|
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, head, replace, toS)
|
||||||
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
import Control.AutoUpdate (defaultUpdateSettings,
|
|
||||||
mkAutoUpdate, updateAction)
|
|
||||||
import Control.Retry (RetryStatus, capDelay,
|
|
||||||
exponentialBackoff,
|
|
||||||
retrying, rsPreviousDelay)
|
|
||||||
import qualified Data.ByteString as BS
|
|
||||||
import qualified Data.ByteString.Base64 as B64
|
|
||||||
import Data.IORef (IORef, atomicWriteIORef,
|
|
||||||
newIORef, readIORef)
|
|
||||||
import Data.String (IsString (..))
|
|
||||||
import Data.Text (pack, replace, stripPrefix, strip)
|
|
||||||
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
|
|
||||||
import Data.Text.IO (hPutStrLn)
|
|
||||||
import Data.Time.Clock (getCurrentTime)
|
|
||||||
import qualified Hasql.Pool as P
|
|
||||||
import qualified Hasql.Session as H
|
|
||||||
import Network.Wai.Handler.Warp (defaultSettings,
|
|
||||||
runSettings, setHost,
|
|
||||||
setPort, setServerName)
|
|
||||||
import System.IO (BufferMode (..),
|
|
||||||
hSetBuffering)
|
|
||||||
|
|
||||||
#ifndef mingw32_HOST_OS
|
#ifndef mingw32_HOST_OS
|
||||||
import System.Posix.Signals
|
import System.Posix.Signals
|
||||||
|
import UnixSocket
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
The purpose of this worker is to fill the refDbStructure created in 'main'
|
The purpose of this worker is to fill the refDbStructure created in 'main'
|
||||||
with the 'DbStructure' returned from calling 'getDbStructure'. This method
|
with the 'DbStructure' returned from calling 'getDbStructure'. This method
|
||||||
@@ -61,11 +64,11 @@ import System.Posix.Signals
|
|||||||
connectionWorker
|
connectionWorker
|
||||||
:: ThreadId -- ^ This thread is killed if pg version is unsupported
|
:: ThreadId -- ^ This thread is killed if pg version is unsupported
|
||||||
-> P.Pool -- ^ The PostgreSQL connection pool
|
-> P.Pool -- ^ The PostgreSQL connection pool
|
||||||
-> Schema -- ^ Schema PostgREST is serving up
|
-> [Schema] -- ^ Schemas PostgREST is serving up
|
||||||
-> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
|
-> IORef (Maybe DbStructure) -- ^ mutable reference to 'DbStructure'
|
||||||
-> IORef Bool -- ^ Used as a binary Semaphore
|
-> IORef Bool -- ^ Used as a binary Semaphore
|
||||||
-> IO ()
|
-> IO ()
|
||||||
connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
connectionWorker mainTid pool schemas refDbStructure refIsWorkerOn = do
|
||||||
isWorkerOn <- readIORef refIsWorkerOn
|
isWorkerOn <- readIORef refIsWorkerOn
|
||||||
unless isWorkerOn $ do
|
unless isWorkerOn $ do
|
||||||
atomicWriteIORef refIsWorkerOn True
|
atomicWriteIORef refIsWorkerOn True
|
||||||
@@ -74,25 +77,24 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
|||||||
work = do
|
work = do
|
||||||
atomicWriteIORef refDbStructure Nothing
|
atomicWriteIORef refDbStructure Nothing
|
||||||
putStrLn ("Attempting to connect to the database..." :: Text)
|
putStrLn ("Attempting to connect to the database..." :: Text)
|
||||||
connected <- connectingSucceeded pool
|
connected <- connectionStatus pool
|
||||||
when connected $ do
|
case connected of
|
||||||
result <- P.use pool $ do
|
FatalConnectionError reason -> hPutStrLn stderr reason
|
||||||
actualPgVersion <- getPgVersion
|
>> killThread mainTid -- Fatal error when connecting
|
||||||
unless (actualPgVersion >= minimumPgVersion) $ liftIO $ do
|
NotConnected -> return () -- Unreachable
|
||||||
hPutStrLn stderr
|
Connected actualPgVersion -> do -- Procede with initialization
|
||||||
("Cannot run in this PostgreSQL version, PostgREST needs at least "
|
result <- P.use pool $ do
|
||||||
<> pgvName minimumPgVersion)
|
dbStructure <- HT.transaction HT.ReadCommitted HT.Read $ getDbStructure schemas actualPgVersion
|
||||||
killThread mainTid
|
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
|
||||||
dbStructure <- getDbStructure schema actualPgVersion
|
case result of
|
||||||
liftIO $ atomicWriteIORef refDbStructure $ Just dbStructure
|
Left e -> do
|
||||||
case result of
|
putStrLn ("Failed to query the database. Retrying." :: Text)
|
||||||
Left e -> do
|
hPutStrLn stderr . toS . errorPayload $ PgError False e
|
||||||
putStrLn ("Failed to query the database. Retrying." :: Text)
|
work
|
||||||
hPutStrLn stderr (toS $ encodeError e)
|
|
||||||
work
|
Right _ -> do
|
||||||
Right _ -> do
|
atomicWriteIORef refIsWorkerOn False
|
||||||
atomicWriteIORef refIsWorkerOn False
|
putStrLn ("Connection successful" :: Text)
|
||||||
putStrLn ("Connection successful" :: Text)
|
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
Used by 'connectionWorker' to check if the provided db-uri lets
|
Used by 'connectionWorker' to check if the provided db-uri lets
|
||||||
@@ -103,26 +105,37 @@ connectionWorker mainTid pool schema refDbStructure refIsWorkerOn = do
|
|||||||
The connection tries are capped, but if the connection times out no error is
|
The connection tries are capped, but if the connection times out no error is
|
||||||
thrown, just 'False' is returned.
|
thrown, just 'False' is returned.
|
||||||
-}
|
-}
|
||||||
connectingSucceeded :: P.Pool -> IO Bool
|
connectionStatus :: P.Pool -> IO ConnectionStatus
|
||||||
connectingSucceeded pool =
|
connectionStatus pool =
|
||||||
retrying (capDelay 32000000 $ exponentialBackoff 1000000)
|
retrying (capDelay 32000000 $ exponentialBackoff 1000000)
|
||||||
shouldRetry
|
shouldRetry
|
||||||
(const $ P.release pool >> isConnectionSuccessful)
|
(const $ P.release pool >> getConnectionStatus)
|
||||||
where
|
where
|
||||||
isConnectionSuccessful :: IO Bool
|
getConnectionStatus :: IO ConnectionStatus
|
||||||
isConnectionSuccessful = do
|
getConnectionStatus = do
|
||||||
testConn <- P.use pool $ H.sql "SELECT 1"
|
pgVersion <- P.use pool getPgVersion
|
||||||
case testConn of
|
case pgVersion of
|
||||||
Left e -> hPutStrLn stderr (toS $ encodeError e) >> pure False
|
Left e -> do
|
||||||
_ -> pure True
|
let err = PgError False e
|
||||||
shouldRetry :: RetryStatus -> Bool -> IO Bool
|
hPutStrLn stderr . toS $ errorPayload err
|
||||||
|
case checkIsFatal err of
|
||||||
|
Just reason -> return $ FatalConnectionError reason
|
||||||
|
Nothing -> return NotConnected
|
||||||
|
|
||||||
|
Right version ->
|
||||||
|
if version < minimumPgVersion
|
||||||
|
then return . FatalConnectionError $ "Cannot run in this PostgreSQL version, PostgREST needs at least " <> pgvName minimumPgVersion
|
||||||
|
else return . Connected $ version
|
||||||
|
|
||||||
|
shouldRetry :: RetryStatus -> ConnectionStatus -> IO Bool
|
||||||
shouldRetry rs isConnSucc = do
|
shouldRetry rs isConnSucc = do
|
||||||
delay <- pure $ fromMaybe 0 (rsPreviousDelay rs) `div` 1000000
|
let delay = fromMaybe 0 (rsPreviousDelay rs) `div` 1000000
|
||||||
itShould <- pure $ not isConnSucc
|
itShould = NotConnected == isConnSucc
|
||||||
when itShould $
|
when itShould $
|
||||||
putStrLn $ "Attempting to reconnect to the database in " <> (show delay::Text) <> " seconds..."
|
putStrLn $ "Attempting to reconnect to the database in " <> (show delay::Text) <> " seconds..."
|
||||||
return itShould
|
return itShould
|
||||||
|
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
This is where everything starts.
|
This is where everything starts.
|
||||||
-}
|
-}
|
||||||
@@ -139,10 +152,13 @@ main = do
|
|||||||
--
|
--
|
||||||
-- readOptions builds the 'AppConfig' from the config file specified on the
|
-- readOptions builds the 'AppConfig' from the config file specified on the
|
||||||
-- command line
|
-- command line
|
||||||
conf <- loadSecretFile =<< readOptions
|
conf <- loadDbUriFile =<< loadSecretFile =<< readOptions
|
||||||
let host = configHost conf
|
let schemas = toList $ configSchemas conf
|
||||||
|
host = configHost conf
|
||||||
port = configPort conf
|
port = configPort conf
|
||||||
proxy = configProxyUri conf
|
proxy = configOpenAPIProxyUri conf
|
||||||
|
maybeSocketAddr = configSocket conf
|
||||||
|
socketFileMode = configSocketMode conf
|
||||||
pgSettings = toS (configDatabase conf) -- is the db-uri
|
pgSettings = toS (configDatabase conf) -- is the db-uri
|
||||||
roleClaimKey = configRoleClaimKey conf
|
roleClaimKey = configRoleClaimKey conf
|
||||||
appSettings =
|
appSettings =
|
||||||
@@ -151,20 +167,21 @@ main = do
|
|||||||
. setServerName (toS $ "postgrest/" <> prettyVersion) $
|
. setServerName (toS $ "postgrest/" <> prettyVersion) $
|
||||||
defaultSettings
|
defaultSettings
|
||||||
|
|
||||||
|
|
||||||
|
whenLeft socketFileMode panic
|
||||||
|
|
||||||
-- Checks that the provided proxy uri is formated correctly
|
-- Checks that the provided proxy uri is formated correctly
|
||||||
when (isMalformedProxyUri $ toS <$> proxy) $
|
when (isMalformedProxyUri $ toS <$> proxy) $
|
||||||
panic
|
panic
|
||||||
"Malformed proxy uri, a correct example: https://example.com:8443/basePath"
|
"Malformed proxy uri, a correct example: https://example.com:8443/basePath"
|
||||||
|
|
||||||
-- Checks that the provided jspath is valid
|
-- Checks that the provided jspath is valid
|
||||||
when (isLeft roleClaimKey) $
|
whenLeft roleClaimKey $
|
||||||
panic $ show roleClaimKey
|
panic $ show roleClaimKey
|
||||||
|
|
||||||
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
|
|
||||||
--
|
|
||||||
-- create connection pool with the provided settings, returns either
|
-- create connection pool with the provided settings, returns either
|
||||||
-- a 'Connection' or a 'ConnectionError'. Does not throw.
|
-- a 'Connection' or a 'ConnectionError'. Does not throw.
|
||||||
pool <- P.acquire (configPool conf, 10, pgSettings)
|
pool <- P.acquire (configPool conf, configPoolTimeout' conf, pgSettings)
|
||||||
--
|
--
|
||||||
-- To be filled in by connectionWorker
|
-- To be filled in by connectionWorker
|
||||||
refDbStructure <- newIORef Nothing
|
refDbStructure <- newIORef Nothing
|
||||||
@@ -180,7 +197,7 @@ main = do
|
|||||||
connectionWorker
|
connectionWorker
|
||||||
mainTid
|
mainTid
|
||||||
pool
|
pool
|
||||||
(configSchema conf)
|
schemas
|
||||||
refDbStructure
|
refDbStructure
|
||||||
refIsWorkerOn
|
refIsWorkerOn
|
||||||
--
|
--
|
||||||
@@ -198,34 +215,43 @@ main = do
|
|||||||
throwTo mainTid UserInterrupt
|
throwTo mainTid UserInterrupt
|
||||||
) Nothing
|
) Nothing
|
||||||
|
|
||||||
forM_ [sigHUP, sigUSR1] $ \sig ->
|
void $ installHandler sigUSR1 (
|
||||||
void $ installHandler sig (
|
Catch $ connectionWorker
|
||||||
Catch $ connectionWorker
|
mainTid
|
||||||
mainTid
|
pool
|
||||||
pool
|
schemas
|
||||||
(configSchema conf)
|
refDbStructure
|
||||||
refDbStructure
|
refIsWorkerOn
|
||||||
refIsWorkerOn
|
) Nothing
|
||||||
) Nothing
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|
||||||
-- ask for the OS time at most once per second
|
-- ask for the OS time at most once per second
|
||||||
getTime <- mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}
|
getTime <- mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}
|
||||||
|
|
||||||
|
let postgrestApplication =
|
||||||
|
postgrest
|
||||||
|
conf
|
||||||
|
refDbStructure
|
||||||
|
pool
|
||||||
|
getTime
|
||||||
|
(connectionWorker
|
||||||
|
mainTid
|
||||||
|
pool
|
||||||
|
schemas
|
||||||
|
refDbStructure
|
||||||
|
refIsWorkerOn)
|
||||||
|
|
||||||
|
-- run the postgrest application with user defined socket. Only for UNIX systems.
|
||||||
|
#ifndef mingw32_HOST_OS
|
||||||
|
whenJust maybeSocketAddr $
|
||||||
|
runAppInSocket appSettings postgrestApplication socketFileMode
|
||||||
|
#endif
|
||||||
|
|
||||||
-- run the postgrest application
|
-- run the postgrest application
|
||||||
runSettings appSettings $
|
whenNothing maybeSocketAddr $ do
|
||||||
postgrest
|
putStrLn $ ("Listening on port " :: Text) <> show (configPort conf)
|
||||||
conf
|
runSettings appSettings postgrestApplication
|
||||||
refDbStructure
|
|
||||||
pool
|
|
||||||
getTime
|
|
||||||
(connectionWorker
|
|
||||||
mainTid
|
|
||||||
pool
|
|
||||||
(configSchema conf)
|
|
||||||
refDbStructure
|
|
||||||
refIsWorkerOn)
|
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
The purpose of this function is to load the JWT secret from a file if
|
The purpose of this function is to load the JWT secret from a file if
|
||||||
@@ -278,3 +304,27 @@ loadSecretFile conf = extractAndTransform mSecret
|
|||||||
-- replace: Replace every occurrence of one substring with another
|
-- replace: Replace every occurrence of one substring with another
|
||||||
replaceUrlChars =
|
replaceUrlChars =
|
||||||
replace "_" "/" . replace "-" "+" . replace "." "="
|
replace "_" "/" . replace "-" "+" . replace "." "="
|
||||||
|
|
||||||
|
{-
|
||||||
|
Load database uri from a separate file if `db-uri` is a filepath.
|
||||||
|
-}
|
||||||
|
loadDbUriFile :: AppConfig -> IO AppConfig
|
||||||
|
loadDbUriFile conf = extractDbUri mDbUri
|
||||||
|
where
|
||||||
|
mDbUri = configDatabase conf
|
||||||
|
extractDbUri :: Text -> IO AppConfig
|
||||||
|
extractDbUri dbUri =
|
||||||
|
fmap setDbUri $
|
||||||
|
case stripPrefix "@" dbUri of
|
||||||
|
Nothing -> return dbUri
|
||||||
|
Just filename -> strip <$> readFile (toS filename)
|
||||||
|
setDbUri dbUri = conf {configDatabase = dbUri}
|
||||||
|
|
||||||
|
-- Utilitarian functions.
|
||||||
|
whenJust :: Applicative f => Maybe a -> (a -> f ()) -> f ()
|
||||||
|
whenJust (Just x) f = f x
|
||||||
|
whenJust Nothing _ = pass
|
||||||
|
|
||||||
|
whenNothing :: Applicative f => Maybe a -> f () -> f ()
|
||||||
|
whenNothing Nothing f = f
|
||||||
|
whenNothing _ _ = pass
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
module UnixSocket (
|
||||||
|
runAppInSocket
|
||||||
|
)where
|
||||||
|
|
||||||
|
import Network.Socket (Family (AF_UNIX),
|
||||||
|
SockAddr (SockAddrUnix), Socket,
|
||||||
|
SocketType (Stream), bind, close,
|
||||||
|
defaultProtocol, listen,
|
||||||
|
maxListenQueue, socket)
|
||||||
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Handler.Warp
|
||||||
|
import System.Directory (removeFile)
|
||||||
|
import System.IO.Error (isDoesNotExistError)
|
||||||
|
import System.Posix.Files (setFileMode)
|
||||||
|
import System.Posix.Types (FileMode)
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
|
||||||
|
createAndBindSocket :: FilePath -> Maybe FileMode -> IO Socket
|
||||||
|
createAndBindSocket socketFilePath maybeSocketFileMode = do
|
||||||
|
deleteSocketFileIfExist socketFilePath
|
||||||
|
sock <- socket AF_UNIX Stream defaultProtocol
|
||||||
|
bind sock $ SockAddrUnix socketFilePath
|
||||||
|
mapM_ (setFileMode socketFilePath) maybeSocketFileMode
|
||||||
|
return sock
|
||||||
|
where
|
||||||
|
deleteSocketFileIfExist path = removeFile path `catch` handleDoesNotExist
|
||||||
|
handleDoesNotExist e
|
||||||
|
| isDoesNotExistError e = return ()
|
||||||
|
| otherwise = throwIO e
|
||||||
|
|
||||||
|
-- run the postgrest application with user defined socket.
|
||||||
|
runAppInSocket :: Settings -> Application -> Either Text FileMode -> FilePath -> IO ()
|
||||||
|
runAppInSocket settings app socketFileMode sockPath = do
|
||||||
|
sock <- createAndBindSocket sockPath (rightToMaybe socketFileMode)
|
||||||
|
putStrLn $ ("Listening on unix socket " :: Text) <> show sockPath
|
||||||
|
listen sock maxListenQueue
|
||||||
|
runSettingsSocket settings sock app
|
||||||
|
-- clean socket up when done
|
||||||
|
close sock
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Nix development and build environment
|
||||||
|
|
||||||
|
With Nix it's possible to quickly and reliably recreate the full environments
|
||||||
|
for developing, testing and building PostgREST.
|
||||||
|
|
||||||
|
## Getting started with Nix
|
||||||
|
|
||||||
|
You'll need to [get Nix](https://nixos.org/download.html). The installer will
|
||||||
|
create your Nix store in the `/nix/` directory, where all build artifacts and
|
||||||
|
their dependencies will be stored. It will also link the Nix executables like
|
||||||
|
`nix-env`, `nix-build` and `nix-shell` into your PATH. Nix will manage all
|
||||||
|
other PostgREST dependencies from here on out. To clean up older build
|
||||||
|
artifacts from the `/nix/store`, you can run `nix-collect-garbage`.
|
||||||
|
|
||||||
|
## Building PostgREST
|
||||||
|
|
||||||
|
To build PostgREST from your local checkout of the repository, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-build --attr postgrest
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a `result` directory that contains the PostgREST binary at
|
||||||
|
`result/bin/postgrest`. The `--attr` parameter (or short: `-A`) tells Nix to
|
||||||
|
build the `postgrest` attribute from the Nix expression it finds in our
|
||||||
|
`default.nix` (see below for details). Nix will take care of getting the right
|
||||||
|
GHC version and all the build dependencies.
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
A development environment for PostgREST is available with `nix-shell`. The
|
||||||
|
following command will put you into a new shell that has GHC and Cabal on the
|
||||||
|
PATH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-shell
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Within `nix-shell`, you can run Cabal commands as usual. You can also run
|
||||||
|
stack with the `--nix` option, which causes stack to pick up the non-Haskell
|
||||||
|
dependencies from the same pinned Nixpkgs version that the Nix builds use.
|
||||||
|
|
||||||
|
## Aside: Working with `nix-shell` and the PostgREST utility scripts
|
||||||
|
|
||||||
|
The PostgREST utilities available in `nix-shell` all have names that begin
|
||||||
|
with `postgrest-`, so you can use tab completion (`postgrest-<tab>`) in
|
||||||
|
`nix-shell` to see all that are available:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Note: The utilities listed here might not be up to date.
|
||||||
|
[nix-shell:~/Projects/postgrest]$ postgrest-
|
||||||
|
postgrest-lint postgrest-test-spec-postgresql-10.12
|
||||||
|
postgrest-style postgrest-test-spec-postgresql-11.7
|
||||||
|
postgrest-style-check postgrest-test-spec-postgresql-12.2
|
||||||
|
postgrest-test-all postgrest-test-spec-postgresql-9.4.24
|
||||||
|
postgrest-test-spec postgrest-test-spec-postgresql-9.5.21
|
||||||
|
postgrest-test-spec-all postgrest-test-spec-postgresql-9.6.17
|
||||||
|
|
||||||
|
[nix-shell:~/Projects/postgrest]$
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
To run one-off commands, you can also use `nix-shell --run <command>`, which
|
||||||
|
will lauch the Nix shell, run that one command and exit. Note that the tab
|
||||||
|
completion will not work with `nix-shell --run`, as Nix has yet to evaluate
|
||||||
|
our Nix expressions to see which utilities are available.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ nix-shell --run postgrest-style
|
||||||
|
|
||||||
|
# Note that you need to quote any arguments that you would like to pass to
|
||||||
|
# the command to be run in nix-shell:
|
||||||
|
$ nix-shell --run "postgrest-foo --bar"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
A third option is to install utilities that you use very often locally:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ nix-env -f default.nix -iA style
|
||||||
|
|
||||||
|
# `postgrest-style` can now be run directly:
|
||||||
|
$ postgrest-style
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that this does not yet work for all utilities (e.g. `postgrest-test-spec` currently
|
||||||
|
needs to be run within the Nix shell environment).
|
||||||
|
|
||||||
|
If you use `nix-shell` very often, you might like to use https://github.com/xzfc/cached-nix-shell,
|
||||||
|
which skips evaluating all our Nix expressions if nothing changed, reducing startup time for the
|
||||||
|
shell considerably.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
In nix-shell, you'll find utility scripts that make it very easy to run the
|
||||||
|
Haskell test suite, including setting up all required dependencies and
|
||||||
|
temporary test databases:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the tests against the most recent version of PostgreSQL:
|
||||||
|
$ nix-shell --run postgrest-test-spec
|
||||||
|
|
||||||
|
# Run the tests against all supported versions of PostgreSQL:
|
||||||
|
$ nix-shell --run postgrest-test-spec-all
|
||||||
|
|
||||||
|
# Run the tests against a specific version of PostgreSQL (use tab-completion in
|
||||||
|
# nix-shell to see all available versions):
|
||||||
|
$ nix-shell --run postgrest-test-spec-postgresql-9.5.21
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linting and styling code
|
||||||
|
|
||||||
|
The nix-shell also contains scripts for linting and styling the PostgREST
|
||||||
|
source code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linting
|
||||||
|
$ nix-shell --run postgrest-lint
|
||||||
|
|
||||||
|
# Styling / auto-formatting code
|
||||||
|
$ nix-shell --run postgrest-style
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tour
|
||||||
|
|
||||||
|
The following is not required for working on PostgREST with Nix, but it will
|
||||||
|
give you some more background and details on how it works.
|
||||||
|
|
||||||
|
### `default.nix`
|
||||||
|
|
||||||
|
[`default.nix`](../default.nix) is our 'respository expression' that pulls all
|
||||||
|
the pieces that we define with Nix together. It returns a set (like a dict in
|
||||||
|
other programming languages), where each attribute is a derivation that Nix
|
||||||
|
knows how to build, like the `postgrest` attribute from earlier.
|
||||||
|
|
||||||
|
Internally, our `default.nix` uses the `pkgs.callPackage` function to import
|
||||||
|
the modules that we defined in the `nix` directory. It automatically passes the
|
||||||
|
arguments those modules require if they are available in `pkgs` (this means
|
||||||
|
that `pkgs` is defined in terms of itself, better not to think too much about
|
||||||
|
that).
|
||||||
|
|
||||||
|
We also use `default.nix` to load our pinned version of the `nixpkgs`
|
||||||
|
repository. This set of packages will always be the same, independently from
|
||||||
|
where or when you use it. The pinned version can be upgraded with the small
|
||||||
|
`nixpkgs-upgrade` utility. Running `nixpkgs-upgrade > nix/nixpkgs-version.nix`
|
||||||
|
in `nix-shell` will upgrade the pinned version to the latest `nixpkgs-unstable`
|
||||||
|
version.
|
||||||
|
|
||||||
|
### `shell.nix`
|
||||||
|
|
||||||
|
[`shell.nix`](../shell.nix) defines an environment in which PostgREST can be
|
||||||
|
built and developed. It extends the build enviroment from our `postgrest`
|
||||||
|
attribute with useful utilities that will be put on the PATH in `nix-shell`.
|
||||||
|
|
||||||
|
### `nix/overlays`
|
||||||
|
|
||||||
|
Our overlays to the Nix package set are defined here. They allow us to tweak our
|
||||||
|
`pkgs` in `default.nix` by adding new packages or overriding existing ones.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{ postgrest, dockerTools, writeShellScriptBin }:
|
||||||
|
let
|
||||||
|
image =
|
||||||
|
tag:
|
||||||
|
dockerTools.buildImage {
|
||||||
|
inherit tag;
|
||||||
|
|
||||||
|
name = "postgrest/postgrest";
|
||||||
|
contents = postgrest;
|
||||||
|
|
||||||
|
# Set the current time as the image creation date. This makes the build
|
||||||
|
# non-reproducible, but that should not be an issue for us.
|
||||||
|
created = "now";
|
||||||
|
|
||||||
|
extraCommands =
|
||||||
|
''
|
||||||
|
mkdir etc
|
||||||
|
cp ${./postgrest.conf} etc/postgrest.conf
|
||||||
|
'';
|
||||||
|
|
||||||
|
config = {
|
||||||
|
Cmd = [ "/bin/postgrest" "/etc/postgrest.conf" ];
|
||||||
|
Env = [
|
||||||
|
"PGRST_DB_URI=postgresql://?user=postgres"
|
||||||
|
"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_OPENAPI_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"
|
||||||
|
"PGRST_ROOT_SPEC="
|
||||||
|
"PGRST_RAW_MEDIA_TYPES="
|
||||||
|
];
|
||||||
|
User = "1000";
|
||||||
|
ExposedPorts = {
|
||||||
|
"3000/tcp" = {};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
rec {
|
||||||
|
imageLatest =
|
||||||
|
image "latest";
|
||||||
|
|
||||||
|
imageWithVersion =
|
||||||
|
image "v${postgrest.version}";
|
||||||
|
|
||||||
|
load =
|
||||||
|
writeShellScriptBin "postgrest-docker-load"
|
||||||
|
''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker load -i ${imageLatest}
|
||||||
|
docker load -i ${imageWithVersion}
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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)"
|
||||||
|
|
||||||
|
openapi-server-proxy-uri = "$(PGRST_OPENAPI_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)"
|
||||||
|
root-spec = "$(PGRST_ROOT_SPEC)"
|
||||||
|
raw-media-types = "$(PGRST_RAW_MEDIA_TYPES)"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{ writeShellScriptBin, git, silver-searcher, hlint }:
|
||||||
|
|
||||||
|
writeShellScriptBin "postgrest-lint"
|
||||||
|
''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
rootdir="$(${git}/bin/git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
# Lint Haskell files
|
||||||
|
${silver-searcher}/bin/ag -l -g '\.l?hs$' "$rootdir" \
|
||||||
|
| xargs ${hlint}/bin/hlint -X QuasiQuotes -X NoPatternSynonyms
|
||||||
|
''
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{ curl
|
||||||
|
, jq
|
||||||
|
, writeShellScriptBin
|
||||||
|
, nix
|
||||||
|
}:
|
||||||
|
# Utility script for pinning the latest unstable version of Nixpkgs.
|
||||||
|
|
||||||
|
# Instead of pinning Nixpkgs based on the huge Git repository, we reference a
|
||||||
|
# specific tarball that only contains the source of the revision that we want
|
||||||
|
# to pin.
|
||||||
|
let
|
||||||
|
name =
|
||||||
|
"nixpkgs-upgrade";
|
||||||
|
|
||||||
|
refUrl =
|
||||||
|
https://api.github.com/repos/nixos/nixpkgs/git/ref/heads/nixpkgs-unstable;
|
||||||
|
|
||||||
|
githubV3Header =
|
||||||
|
"Accept: application/vnd.github.v3+json";
|
||||||
|
|
||||||
|
tarballUrlBase =
|
||||||
|
https://github.com/nixos/nixpkgs/archive/;
|
||||||
|
in
|
||||||
|
writeShellScriptBin name
|
||||||
|
''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
commitHash="$(${curl}/bin/curl "${refUrl}" -H "${githubV3Header}" | ${jq}/bin/jq -r .object.sha)"
|
||||||
|
tarballUrl="${tarballUrlBase}$commitHash.tar.gz"
|
||||||
|
tarballHash="$(${nix}/bin/nix-prefetch-url --unpack "$tarballUrl")"
|
||||||
|
currentDate="$(date --iso)"
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
# Pinned version of Nixpkgs, generated with ${name}.
|
||||||
|
{
|
||||||
|
date = "$currentDate";
|
||||||
|
rev = "$commitHash";
|
||||||
|
tarballHash = "$tarballHash";
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
''
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Pinned version of Nixpkgs, generated with nixpkgs-upgrade.
|
||||||
|
{
|
||||||
|
date = "2020-04-19";
|
||||||
|
rev = "10100a97c8964e82b30f180fda41ade8e6f69e41";
|
||||||
|
tarballHash = "011f36kr3c1ria7rag7px26bh73d1b0xpqadd149bysf4hg17rln";
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
self: super:
|
||||||
|
# Overlay that adds the `gitignoreSource` function from Hercules-CI.
|
||||||
|
# This function is useful for filtering which files are added to the Nix store.
|
||||||
|
# See: https://github.com/hercules-ci/gitignore.nix
|
||||||
|
|
||||||
|
# To update to a newer revision, the simplest way is to add a new commit hash
|
||||||
|
# from GitHub under `rev` and to then add the hash that Nix suggests on first
|
||||||
|
# use.
|
||||||
|
{
|
||||||
|
gitignoreSource =
|
||||||
|
let
|
||||||
|
gitignoreSrc = super.fetchFromGitHub {
|
||||||
|
owner = "hercules-ci";
|
||||||
|
repo = "gitignore";
|
||||||
|
rev = "2ced4519f865341adcb143c5d668f955a2cb997f";
|
||||||
|
sha256 = "sha256:0fc5bgv9syfcblp23y05kkfnpgh3gssz6vn24frs8dzw39algk2z";
|
||||||
|
};
|
||||||
|
in (super.callPackage gitignoreSrc {}).gitignoreSource;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
The `.nix` files in this directory pin specific versions of Haskell packages
|
||||||
|
from Hackage. They were generated with `cabal2nix`, e.g.:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cabal2nix cabal://protolude > protolude.nix
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Those overrides might become obsolete as the pinned versions are adopted into
|
||||||
|
our pinned version of nixpkgs.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
self: super:
|
||||||
|
|
||||||
|
{
|
||||||
|
haskellPackages =
|
||||||
|
super.haskellPackages.override {
|
||||||
|
overrides =
|
||||||
|
final: prev: rec {
|
||||||
|
protolude = prev.callPackage ./protolude.nix {};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{ mkDerivation
|
||||||
|
, array
|
||||||
|
, async
|
||||||
|
, base
|
||||||
|
, bytestring
|
||||||
|
, containers
|
||||||
|
, deepseq
|
||||||
|
, ghc-prim
|
||||||
|
, hashable
|
||||||
|
, mtl
|
||||||
|
, mtl-compat
|
||||||
|
, stdenv
|
||||||
|
, stm
|
||||||
|
, text
|
||||||
|
, transformers
|
||||||
|
, transformers-compat
|
||||||
|
}:
|
||||||
|
mkDerivation {
|
||||||
|
pname = "protolude";
|
||||||
|
version = "0.3.0";
|
||||||
|
sha256 = "4083385a9e03fab9201f63ce198b9ced3fbc1c50d6d42574db5e36c757bedcac";
|
||||||
|
isLibrary = true;
|
||||||
|
isExecutable = true;
|
||||||
|
libraryHaskellDepends = [
|
||||||
|
array
|
||||||
|
async
|
||||||
|
base
|
||||||
|
bytestring
|
||||||
|
containers
|
||||||
|
deepseq
|
||||||
|
ghc-prim
|
||||||
|
hashable
|
||||||
|
mtl
|
||||||
|
mtl-compat
|
||||||
|
stm
|
||||||
|
text
|
||||||
|
transformers
|
||||||
|
transformers-compat
|
||||||
|
];
|
||||||
|
homepage = "https://github.com/sdiehl/protolude";
|
||||||
|
description = "A small prelude";
|
||||||
|
license = stdenv.lib.licenses.mit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
self: super:
|
||||||
|
# Overlay that adds legacy versions of PostgreSQL that are supported by
|
||||||
|
# PostgREST.
|
||||||
|
{
|
||||||
|
# PostgreSQL 9.4 was removed from Nixpkgs with
|
||||||
|
# https://github.com/NixOS/nixpkgs/commit/8e2fc57a80d761c46702c3250e61c1bffe021e25
|
||||||
|
# We pin its parent commit 3b5b9a7 to get the last version that was available.
|
||||||
|
postgresql_9_4 =
|
||||||
|
let
|
||||||
|
rev = "3b5b9a73f59ff93d50156e250203410bdd07f4e0";
|
||||||
|
tarballHash = "0kr25xqbv1ldp72jbmml21pc5hl7xcfqhclv5qxa5f860jddjznk";
|
||||||
|
|
||||||
|
pinnedPkgs =
|
||||||
|
builtins.fetchTarball {
|
||||||
|
url = "https://github.com/nixos/nixpkgs/archive/${rev}.tar.gz";
|
||||||
|
sha256 = tarballHash;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
(import pinnedPkgs {}).pkgs.postgresql_9_4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{ runCommand }:
|
||||||
|
|
||||||
|
{
|
||||||
|
# Apply patches to a directory.
|
||||||
|
applyPatches =
|
||||||
|
name: path: patches:
|
||||||
|
runCommand name { inherit patches; }
|
||||||
|
''
|
||||||
|
set -eou pipefail
|
||||||
|
|
||||||
|
cp -r ${path} $out
|
||||||
|
chmod -R u+w $out
|
||||||
|
|
||||||
|
for patch in $patches; do
|
||||||
|
echo "Applying patch $patch"
|
||||||
|
patch -d "$out" -p1 < "$patch"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
|
||||||
|
static-haskell-nix-postgrest-libpq =
|
||||||
|
./static-haskell-nix-postgrest-libpq.patch;
|
||||||
|
|
||||||
|
nixpkgs-revert-ghc-bootstrap =
|
||||||
|
./nixpkgs-revert-ghc-bootstrap.patch;
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
From 93cc144b215654ca9cb0d2bcb58bb8922895ccab Mon Sep 17 00:00:00 2001
|
||||||
|
From: Remo <remo>
|
||||||
|
Date: Sat, 25 Apr 2020 17:31:55 +0200
|
||||||
|
Subject: [PATCH] Revert "ghc: 8.6.3-binary -> 8.6.5-binary"
|
||||||
|
|
||||||
|
This reverts commit 3c7ef6bcd85e3e133ac6f2ab5e238934d56d902e.
|
||||||
|
---
|
||||||
|
.../{8.6.5-binary.nix => 8.6.3-binary.nix} | 13 ++++++------
|
||||||
|
pkgs/top-level/haskell-packages.nix | 20 +++++++++----------
|
||||||
|
2 files changed, 16 insertions(+), 17 deletions(-)
|
||||||
|
rename pkgs/development/compilers/ghc/{8.6.5-binary.nix => 8.6.3-binary.nix} (95%)
|
||||||
|
|
||||||
|
diff --git a/pkgs/development/compilers/ghc/8.6.5-binary.nix b/pkgs/development/compilers/ghc/8.6.3-binary.nix
|
||||||
|
similarity index 95%
|
||||||
|
rename from pkgs/development/compilers/ghc/8.6.5-binary.nix
|
||||||
|
rename to pkgs/development/compilers/ghc/8.6.3-binary.nix
|
||||||
|
index 97793d912895..9e0fd12b96cf 100644
|
||||||
|
--- a/pkgs/development/compilers/ghc/8.6.5-binary.nix
|
||||||
|
+++ b/pkgs/development/compilers/ghc/8.6.3-binary.nix
|
||||||
|
@@ -27,19 +27,18 @@ let
|
||||||
|
in
|
||||||
|
|
||||||
|
stdenv.mkDerivation rec {
|
||||||
|
- version = "8.6.5";
|
||||||
|
+ version = "8.6.3";
|
||||||
|
|
||||||
|
name = "ghc-${version}-binary";
|
||||||
|
|
||||||
|
- # https://downloads.haskell.org/~ghc/8.6.5/
|
||||||
|
src = fetchurl ({
|
||||||
|
i686-linux = {
|
||||||
|
- url = "http://haskell.org/ghc/dist/${version}/ghc-${version}-i386-deb9-linux.tar.xz";
|
||||||
|
- sha256 = "1p2h29qghql19ajk755xa0yxkn85slbds8m9n5196ris743vkp8w";
|
||||||
|
+ url = "http://haskell.org/ghc/dist/${version}/ghc-${version}-i386-deb8-linux.tar.xz";
|
||||||
|
+ sha256 = "0bw8a7fxcbskf93rb4m542ff66vrmx5i5kj77qx37cbhijx70w5m";
|
||||||
|
};
|
||||||
|
x86_64-linux = {
|
||||||
|
- url = "http://haskell.org/ghc/dist/${version}/ghc-${version}-x86_64-deb9-linux.tar.xz";
|
||||||
|
- sha256 = "1pqlx6rdjs2110g0y1i9f8x18lmdizibjqd15f5xahcz39hgaxdw";
|
||||||
|
+ url = "http://haskell.org/ghc/dist/${version}/ghc-${version}-x86_64-deb8-linux.tar.xz";
|
||||||
|
+ sha256 = "1m9gaga2pzi2cx5gvasg0rx1dlvr68gmi20l67652kag6xjsa719";
|
||||||
|
};
|
||||||
|
aarch64-linux = {
|
||||||
|
url = "http://haskell.org/ghc/dist/${version}/ghc-${version}-aarch64-ubuntu18.04-linux.tar.xz";
|
||||||
|
@@ -47,7 +46,7 @@ stdenv.mkDerivation rec {
|
||||||
|
};
|
||||||
|
x86_64-darwin = {
|
||||||
|
url = "http://haskell.org/ghc/dist/${version}/ghc-${version}-x86_64-apple-darwin.tar.xz";
|
||||||
|
- sha256 = "0s9188vhhgf23q3rjarwhbr524z6h2qga5xaaa2pma03sfqvvhfz";
|
||||||
|
+ sha256 = "1hbzk57v45176kxcx848p5jn5p1xbp2129ramkbzsk6plyhnkl3r";
|
||||||
|
};
|
||||||
|
}.${stdenv.hostPlatform.system}
|
||||||
|
or (throw "cannot bootstrap GHC on this platform"));
|
||||||
|
diff --git a/pkgs/top-level/haskell-packages.nix b/pkgs/top-level/haskell-packages.nix
|
||||||
|
index aa7ef02a3886..712a98e147e0 100644
|
||||||
|
--- a/pkgs/top-level/haskell-packages.nix
|
||||||
|
+++ b/pkgs/top-level/haskell-packages.nix
|
||||||
|
@@ -4,7 +4,7 @@ let
|
||||||
|
# These are attributes in compiler and packages that don't support integer-simple.
|
||||||
|
integerSimpleExcludes = [
|
||||||
|
"ghc822Binary"
|
||||||
|
- "ghc865Binary"
|
||||||
|
+ "ghc863Binary"
|
||||||
|
"ghc844"
|
||||||
|
"ghcjs"
|
||||||
|
"ghcjs86"
|
||||||
|
@@ -42,7 +42,7 @@ in {
|
||||||
|
|
||||||
|
ghc822Binary = callPackage ../development/compilers/ghc/8.2.2-binary.nix { };
|
||||||
|
|
||||||
|
- ghc865Binary = callPackage ../development/compilers/ghc/8.6.5-binary.nix { };
|
||||||
|
+ ghc863Binary = callPackage ../development/compilers/ghc/8.6.3-binary.nix { };
|
||||||
|
|
||||||
|
ghc844 = callPackage ../development/compilers/ghc/8.4.4.nix {
|
||||||
|
bootPkgs = packages.ghc822Binary;
|
||||||
|
@@ -57,31 +57,31 @@ in {
|
||||||
|
llvmPackages = pkgs.llvmPackages_6;
|
||||||
|
};
|
||||||
|
ghc881 = callPackage ../development/compilers/ghc/8.8.1.nix {
|
||||||
|
- bootPkgs = packages.ghc865Binary;
|
||||||
|
+ bootPkgs = packages.ghc863Binary;
|
||||||
|
inherit (buildPackages.python3Packages) sphinx;
|
||||||
|
buildLlvmPackages = buildPackages.llvmPackages_7;
|
||||||
|
llvmPackages = pkgs.llvmPackages_7;
|
||||||
|
};
|
||||||
|
ghc882 = callPackage ../development/compilers/ghc/8.8.2.nix {
|
||||||
|
- bootPkgs = packages.ghc865Binary;
|
||||||
|
+ bootPkgs = packages.ghc863Binary;
|
||||||
|
inherit (buildPackages.python3Packages) sphinx;
|
||||||
|
buildLlvmPackages = buildPackages.llvmPackages_7;
|
||||||
|
llvmPackages = pkgs.llvmPackages_7;
|
||||||
|
};
|
||||||
|
ghc883 = callPackage ../development/compilers/ghc/8.8.3.nix {
|
||||||
|
- bootPkgs = packages.ghc865Binary;
|
||||||
|
+ bootPkgs = packages.ghc863Binary;
|
||||||
|
inherit (buildPackages.python3Packages) sphinx;
|
||||||
|
buildLlvmPackages = buildPackages.llvmPackages_7;
|
||||||
|
llvmPackages = pkgs.llvmPackages_7;
|
||||||
|
};
|
||||||
|
ghc8101 = callPackage ../development/compilers/ghc/8.10.1.nix {
|
||||||
|
- bootPkgs = packages.ghc865Binary;
|
||||||
|
+ bootPkgs = packages.ghc863Binary;
|
||||||
|
inherit (buildPackages.python3Packages) sphinx;
|
||||||
|
buildLlvmPackages = buildPackages.llvmPackages_9;
|
||||||
|
llvmPackages = pkgs.llvmPackages_9;
|
||||||
|
};
|
||||||
|
ghcHEAD = callPackage ../development/compilers/ghc/head.nix {
|
||||||
|
- bootPkgs = packages.ghc883; # no binary yet
|
||||||
|
+ bootPkgs = packages.ghc863Binary;
|
||||||
|
inherit (buildPackages.python3Packages) sphinx;
|
||||||
|
buildLlvmPackages = buildPackages.llvmPackages_9;
|
||||||
|
llvmPackages = pkgs.llvmPackages_9;
|
||||||
|
@@ -118,9 +118,9 @@ in {
|
||||||
|
compilerConfig = callPackage ../development/haskell-modules/configuration-ghc-8.2.x.nix { };
|
||||||
|
packageSetConfig = bootstrapPackageSet;
|
||||||
|
};
|
||||||
|
- ghc865Binary = callPackage ../development/haskell-modules {
|
||||||
|
- buildHaskellPackages = bh.packages.ghc865Binary;
|
||||||
|
- ghc = bh.compiler.ghc865Binary;
|
||||||
|
+ ghc863Binary = callPackage ../development/haskell-modules {
|
||||||
|
+ buildHaskellPackages = bh.packages.ghc863Binary;
|
||||||
|
+ ghc = bh.compiler.ghc863Binary;
|
||||||
|
compilerConfig = callPackage ../development/haskell-modules/configuration-ghc-8.6.x.nix { };
|
||||||
|
packageSetConfig = bootstrapPackageSet;
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
From bb4c1e27e391eff01591fe60830ff68a9ada41ef Mon Sep 17 00:00:00 2001
|
||||||
|
From: Remo <remo>
|
||||||
|
Date: Wed, 22 Apr 2020 11:58:07 +0200
|
||||||
|
Subject: [PATCH] Add postgrest libpq linking fix
|
||||||
|
|
||||||
|
---
|
||||||
|
survey/default.nix | 5 +++++
|
||||||
|
1 file changed, 5 insertions(+)
|
||||||
|
|
||||||
|
diff --git a/survey/default.nix b/survey/default.nix
|
||||||
|
index b28606c..fd7cde7 100644
|
||||||
|
--- a/survey/default.nix
|
||||||
|
+++ b/survey/default.nix
|
||||||
|
@@ -1052,6 +1052,11 @@ let
|
||||||
|
super.squeal-postgresql
|
||||||
|
[ final.openssl ]
|
||||||
|
"--libs openssl";
|
||||||
|
+ postgrest =
|
||||||
|
+ addStaticLinkerFlagsWithPkgconfig
|
||||||
|
+ super.postgrest
|
||||||
|
+ [ final.openssl ]
|
||||||
|
+ "--libs openssl";
|
||||||
|
|
||||||
|
xml-to-json =
|
||||||
|
addStaticLinkerFlagsWithPkgconfig
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{ mkDerivation
|
||||||
|
, array
|
||||||
|
, base
|
||||||
|
, base-compat
|
||||||
|
, base-orphans
|
||||||
|
, binary
|
||||||
|
, bytestring
|
||||||
|
, containers
|
||||||
|
, deepseq
|
||||||
|
, Diff
|
||||||
|
, directory
|
||||||
|
, filepath
|
||||||
|
, integer-logarithms
|
||||||
|
, mtl
|
||||||
|
, optparse-applicative
|
||||||
|
, parsec
|
||||||
|
, pretty
|
||||||
|
, process
|
||||||
|
, QuickCheck
|
||||||
|
, stdenv
|
||||||
|
, stm
|
||||||
|
, tagged
|
||||||
|
, tar
|
||||||
|
, tasty
|
||||||
|
, tasty-golden
|
||||||
|
, tasty-hunit
|
||||||
|
, tasty-quickcheck
|
||||||
|
, temporary
|
||||||
|
, text
|
||||||
|
, time
|
||||||
|
, transformers
|
||||||
|
, tree-diff
|
||||||
|
, unix
|
||||||
|
}:
|
||||||
|
mkDerivation {
|
||||||
|
pname = "Cabal";
|
||||||
|
version = "3.0.0.0";
|
||||||
|
sha256 = "5143ec26d740c1a508c93a8860e64407e7546c29b9817db20ff1595c1968d287";
|
||||||
|
setupHaskellDepends = [ mtl parsec ];
|
||||||
|
libraryHaskellDepends = [
|
||||||
|
array
|
||||||
|
base
|
||||||
|
binary
|
||||||
|
bytestring
|
||||||
|
containers
|
||||||
|
deepseq
|
||||||
|
directory
|
||||||
|
filepath
|
||||||
|
mtl
|
||||||
|
parsec
|
||||||
|
pretty
|
||||||
|
process
|
||||||
|
text
|
||||||
|
time
|
||||||
|
transformers
|
||||||
|
unix
|
||||||
|
];
|
||||||
|
testHaskellDepends = [
|
||||||
|
array
|
||||||
|
base
|
||||||
|
base-compat
|
||||||
|
base-orphans
|
||||||
|
binary
|
||||||
|
bytestring
|
||||||
|
containers
|
||||||
|
deepseq
|
||||||
|
Diff
|
||||||
|
directory
|
||||||
|
filepath
|
||||||
|
integer-logarithms
|
||||||
|
optparse-applicative
|
||||||
|
pretty
|
||||||
|
process
|
||||||
|
QuickCheck
|
||||||
|
stm
|
||||||
|
tagged
|
||||||
|
tar
|
||||||
|
tasty
|
||||||
|
tasty-golden
|
||||||
|
tasty-hunit
|
||||||
|
tasty-quickcheck
|
||||||
|
temporary
|
||||||
|
text
|
||||||
|
tree-diff
|
||||||
|
];
|
||||||
|
doCheck = false;
|
||||||
|
homepage = "http://www.haskell.org/cabal/";
|
||||||
|
description = "A framework for packaging Haskell software";
|
||||||
|
license = stdenv.lib.licenses.bsd3;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Turn a Haskell source package (given its name and source) into a derivation
|
||||||
|
# for a fully static executable.
|
||||||
|
{ nixpkgs, compiler, name, src, patches }:
|
||||||
|
let
|
||||||
|
# The nh2/static-haskell-nix project does all the hard work for us for
|
||||||
|
# building static Haskell executables. We apply a patch for PostgREST below
|
||||||
|
# until the respective pull request is merged. See:
|
||||||
|
# https://github.com/nh2/static-haskell-nix/pull/91
|
||||||
|
# statix-haskell-nix builds everything based on Musl instead of glibc, so
|
||||||
|
# there will be a _lot_ to rebuild if you don't use a binary cache.
|
||||||
|
static-haskell-nix =
|
||||||
|
let
|
||||||
|
rev = "749707fc90b781c3e653e67917a7d571fe82ae7b";
|
||||||
|
in
|
||||||
|
builtins.fetchTarball {
|
||||||
|
url = "https://github.com/nh2/static-haskell-nix/archive/${rev}.tar.gz";
|
||||||
|
sha256 = "155spda2lww378bhx68w6dxwqd5y6s9kin3qbgl2m23r3vmk3m3w";
|
||||||
|
};
|
||||||
|
|
||||||
|
patched-static-haskell-nix =
|
||||||
|
patches.applyPatches "patched-static-haskell-nix" static-haskell-nix
|
||||||
|
[
|
||||||
|
patches.static-haskell-nix-postgrest-libpq
|
||||||
|
];
|
||||||
|
|
||||||
|
haskellPackagesOverrides =
|
||||||
|
lib: final: prev:
|
||||||
|
{
|
||||||
|
# Add our source package.
|
||||||
|
"${name}" = prev.callCabal2nix name src {};
|
||||||
|
|
||||||
|
# cabal2nix depends on Cabal 3.0.*, while our pinned version of Nixpkgs
|
||||||
|
# only provides 2.4 or 3.2. So we pinned 3.0.0.0 in ./Cabal.nix
|
||||||
|
cabal2nix =
|
||||||
|
prev.cabal2nix.overrideScope
|
||||||
|
(self: super: { Cabal = self.callPackage ./Cabal.nix {}; });
|
||||||
|
|
||||||
|
# Pinned version of protolude.
|
||||||
|
protolude =
|
||||||
|
prev.callPackage ../overlays/haskell-packages/protolude.nix {};
|
||||||
|
|
||||||
|
# The tests for the packages below took a long time on static
|
||||||
|
# builds, so we disable them for now - to be investigated.
|
||||||
|
happy = lib.dontCheck prev.happy;
|
||||||
|
};
|
||||||
|
|
||||||
|
# This overlay adds our source package and applies adjustments to the
|
||||||
|
# derivation of other packages that it depends on. The overlay applies to
|
||||||
|
# Haskell packages for the given compiler, which we will later use
|
||||||
|
# with the static-haskell-nix survey.
|
||||||
|
overlay =
|
||||||
|
self: super:
|
||||||
|
# Override the set of Haskell packages at
|
||||||
|
# pkgs.haskell.packages."${compiler}".
|
||||||
|
{
|
||||||
|
haskell = super.haskell // {
|
||||||
|
packages = super.haskell.packages // {
|
||||||
|
"${compiler}" =
|
||||||
|
super.haskell.packages."${compiler}".override
|
||||||
|
{ overrides = haskellPackagesOverrides self.haskell.lib; };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
patchedNixpkgs =
|
||||||
|
patches.applyPatches "patched-nixpkgs" nixpkgs
|
||||||
|
[
|
||||||
|
patches.nixpkgs-revert-ghc-bootstrap
|
||||||
|
];
|
||||||
|
|
||||||
|
# Apply our overlay to the given pkgs.
|
||||||
|
normalPkgs =
|
||||||
|
(import patchedNixpkgs {}).appendOverlays [ overlay ];
|
||||||
|
|
||||||
|
# Each version of GHC needs a specific version of Cabal.
|
||||||
|
defaultCabalPackageVersionComingWithGhc =
|
||||||
|
{
|
||||||
|
ghc883 = "Cabal_3_2_0_0";
|
||||||
|
}."${compiler}";
|
||||||
|
|
||||||
|
# Let the static-haskell-nix project do the hard work of deriving a set of
|
||||||
|
# fully static Haskell executables, including one for the our source package
|
||||||
|
# that we added through the overlay.
|
||||||
|
survey =
|
||||||
|
import "${patched-static-haskell-nix}/survey"
|
||||||
|
{ inherit normalPkgs compiler defaultCabalPackageVersionComingWithGhc; };
|
||||||
|
in
|
||||||
|
# Return the fully static derivation of our source package.
|
||||||
|
survey.haskellPackages."${name}"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{ writeShellScriptBin
|
||||||
|
, git
|
||||||
|
, nixpkgs-fmt
|
||||||
|
, silver-searcher
|
||||||
|
, stylish-haskell
|
||||||
|
, buildEnv
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
style =
|
||||||
|
writeShellScriptBin "postgrest-style"
|
||||||
|
''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
rootdir="$(${git}/bin/git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
# Format Nix files
|
||||||
|
${nixpkgs-fmt}/bin/nixpkgs-fmt "$rootdir" > /dev/null 2> /dev/null
|
||||||
|
|
||||||
|
# Format Haskell files
|
||||||
|
${silver-searcher}/bin/ag -l -g '\.l?hs$' "$rootdir" \
|
||||||
|
| xargs ${stylish-haskell}/bin/stylish-haskell -i
|
||||||
|
'';
|
||||||
|
|
||||||
|
check =
|
||||||
|
writeShellScriptBin "postgrest-style-check"
|
||||||
|
''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
${style}/bin/${style.name}
|
||||||
|
|
||||||
|
${git}/bin/git diff-index --exit-code HEAD -- '*.hs' '*.lhs' '*.nix'
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
buildEnv {
|
||||||
|
name = "postgrest-style";
|
||||||
|
paths = [ style check ];
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Utilities for running the PostgREST test suite
|
||||||
|
|
||||||
|
{ buildEnv
|
||||||
|
, cabal-install
|
||||||
|
, git
|
||||||
|
, lib
|
||||||
|
, postgrestBuildEnv
|
||||||
|
, postgresqlVersions
|
||||||
|
, runtimeShell
|
||||||
|
, writeShellScript
|
||||||
|
, writeShellScriptBin
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
# Wrap the `test/with_tmp_db` script with the required dependencies from Nix.
|
||||||
|
withTmpDb =
|
||||||
|
postgresql:
|
||||||
|
writeShellScript "postgrest-test-${postgresql.name}"
|
||||||
|
''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export PATH=${postgresql}/bin:${git}/bin:${runtimeShell}/bin:"$PATH"
|
||||||
|
|
||||||
|
exec ${../test/with_tmp_db} "$@"
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Script to run the Haskell test suite against a specific version of
|
||||||
|
# PostgreSQL.
|
||||||
|
testSpec =
|
||||||
|
name: postgresql:
|
||||||
|
writeShellScriptBin name
|
||||||
|
''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
Running spec against ${postgresql.name}...
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# TODO: Make this work outside nix-shell when installed with nix-env.
|
||||||
|
# Probably using postgrestBuildEnv somehow?
|
||||||
|
${withTmpDb postgresql} ${cabal-install}/bin/cabal v2-test \
|
||||||
|
--test-show-detail=direct
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
Done running spec against ${postgresql.name}.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
'';
|
||||||
|
|
||||||
|
# The PostgreSQL version that we run the tests against by default.
|
||||||
|
defaultPostgresql =
|
||||||
|
builtins.head postgresqlVersions;
|
||||||
|
|
||||||
|
defaultTestSpec =
|
||||||
|
testSpec "postgrest-test-spec" defaultPostgresql;
|
||||||
|
|
||||||
|
# Create a `testSpec` for each PostgreSQL version that we want to test
|
||||||
|
# against.
|
||||||
|
testSpecVersions =
|
||||||
|
map
|
||||||
|
(postgresql: testSpec "postgrest-test-spec-${postgresql.name}" postgresql)
|
||||||
|
postgresqlVersions;
|
||||||
|
|
||||||
|
# Helper script for running the tests against all PostgreSQL versions.
|
||||||
|
testSpecAllVersions =
|
||||||
|
let
|
||||||
|
testRunners =
|
||||||
|
map (test: "${test}/bin/${test.name}") testSpecVersions;
|
||||||
|
in
|
||||||
|
writeShellScriptBin "postgrest-test-spec-all"
|
||||||
|
''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
${lib.concatStringsSep "\n" testRunners}
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
# Create an environment that contains all the utility scripts for running tests
|
||||||
|
# that we defined above.
|
||||||
|
buildEnv {
|
||||||
|
name =
|
||||||
|
"postgrest-tests";
|
||||||
|
|
||||||
|
paths =
|
||||||
|
[
|
||||||
|
defaultTestSpec
|
||||||
|
testSpecAllVersions
|
||||||
|
] ++ testSpecVersions;
|
||||||
|
}
|
||||||
@@ -1,173 +1,240 @@
|
|||||||
name: postgrest
|
name: postgrest
|
||||||
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
version: 7.0.1
|
||||||
for the tables and views, supporting all HTTP verbs that security
|
synopsis: REST API for any Postgres database
|
||||||
permits.
|
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
||||||
version: 5.1.0
|
for the tables and views, supporting all HTTP verbs that security
|
||||||
synopsis: REST API for any Postgres database
|
permits.
|
||||||
license: MIT
|
license: MIT
|
||||||
license-file: LICENSE
|
license-file: LICENSE
|
||||||
author: Joe Nelson, Adam Baker
|
author: Joe Nelson, Adam Baker, Steve Chavez
|
||||||
homepage: https://postgrest.org
|
maintainer: Steve Chavez <stevechavezast@gmail.com>
|
||||||
maintainer: Steve Chávez <stevechavezast@gmail.com>
|
category: Executable, PostgreSQL, Network APIs
|
||||||
bug-reports: https://github.com/PostgREST/postgrest/issues
|
homepage: https://postgrest.org
|
||||||
category: Executable, PostgreSQL, Network APIs
|
bug-reports: https://github.com/PostgREST/postgrest/issues
|
||||||
extra-source-files: CHANGELOG.md
|
build-type: Simple
|
||||||
build-type: Simple
|
extra-source-files: CHANGELOG.md
|
||||||
cabal-version: >=1.10
|
cabal-version: >= 1.10
|
||||||
|
|
||||||
source-repository head
|
source-repository head
|
||||||
type: git
|
type: git
|
||||||
location: git://github.com/PostgREST/postgrest.git
|
location: git://github.com/PostgREST/postgrest.git
|
||||||
|
|
||||||
Flag CI
|
flag ci
|
||||||
Description: No warnings allowed in continuous integration
|
default: False
|
||||||
Manual: True
|
manual: True
|
||||||
Default: False
|
description: No warnings allowed in continuous integration
|
||||||
|
|
||||||
executable postgrest
|
|
||||||
main-is: Main.hs
|
|
||||||
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude
|
|
||||||
ghc-options:
|
|
||||||
-threaded
|
|
||||||
-rtsopts
|
|
||||||
"-with-rtsopts=-N -I2"
|
|
||||||
default-language: Haskell2010
|
|
||||||
build-depends: auto-update
|
|
||||||
, base >= 4.8 && < 4.10
|
|
||||||
, hasql == 1.1
|
|
||||||
, hasql-pool == 0.4.3
|
|
||||||
, postgrest
|
|
||||||
, protolude == 0.2.2
|
|
||||||
, text
|
|
||||||
, time
|
|
||||||
, warp
|
|
||||||
, bytestring
|
|
||||||
, base64-bytestring
|
|
||||||
, retry
|
|
||||||
if !os(windows)
|
|
||||||
build-depends: unix
|
|
||||||
|
|
||||||
hs-source-dirs: main
|
|
||||||
|
|
||||||
library
|
library
|
||||||
default-language: Haskell2010
|
exposed-modules: PostgREST.ApiRequest
|
||||||
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude
|
PostgREST.App
|
||||||
build-depends: aeson
|
PostgREST.Auth
|
||||||
, ansi-wl-pprint
|
PostgREST.Config
|
||||||
, base >= 4.8 && < 4.10
|
PostgREST.DbRequestBuilder
|
||||||
, base64-bytestring
|
PostgREST.DbStructure
|
||||||
, bytestring
|
PostgREST.Error
|
||||||
, case-insensitive
|
PostgREST.Middleware
|
||||||
, cassava
|
PostgREST.OpenAPI
|
||||||
, configurator-ng == 0.0.0.1
|
PostgREST.Parsers
|
||||||
, containers
|
PostgREST.QueryBuilder
|
||||||
, contravariant
|
PostgREST.Statements
|
||||||
, contravariant-extras
|
PostgREST.RangeQuery
|
||||||
, either
|
PostgREST.Types
|
||||||
, gitrev
|
other-modules: Paths_postgrest
|
||||||
, hasql == 1.1
|
PostgREST.Private.Common
|
||||||
, hasql-pool == 0.4.3
|
PostgREST.Private.QueryFragment
|
||||||
, hasql-transaction == 0.5.2
|
hs-source-dirs: src
|
||||||
, heredoc
|
build-depends: base >= 4.9 && < 4.15
|
||||||
, HTTP
|
, HTTP >= 4000.3.7 && < 4000.4
|
||||||
, http-types
|
, Ranged-sets >= 0.3 && < 0.5
|
||||||
, insert-ordered-containers
|
, aeson >= 1.4.7 && < 1.5
|
||||||
, interpolatedstring-perl6
|
, ansi-wl-pprint >= 0.6.7 && < 0.7
|
||||||
, jose == 0.7.0.0
|
, base64-bytestring >= 1 && < 1.2
|
||||||
, lens
|
, bytestring >= 0.10.8 && < 0.11
|
||||||
, lens-aeson
|
, case-insensitive >= 1.2 && < 1.3
|
||||||
, network-uri
|
, cassava >= 0.4.5 && < 0.6
|
||||||
, optparse-applicative >= 0.13 && < 0.15
|
, configurator-pg >= 0.2 && < 0.3
|
||||||
, parsec
|
, containers >= 0.5.7 && < 0.7
|
||||||
, protolude == 0.2.2
|
, contravariant >= 1.4 && < 1.6
|
||||||
, Ranged-sets == 0.3.0
|
, contravariant-extras >= 0.3.3 && < 0.4
|
||||||
, regex-tdfa
|
, cookie >= 0.4.2 && < 0.5
|
||||||
, scientific
|
, either >= 4.4.1 && < 5.1
|
||||||
, swagger2
|
, gitrev >= 1.2 && < 1.4
|
||||||
, text
|
, hasql >= 1.4 && < 1.5
|
||||||
, time
|
, hasql-pool >= 0.5 && < 0.6
|
||||||
, unordered-containers
|
, hasql-transaction >= 0.7.2 && < 1.1
|
||||||
, vector
|
, heredoc >= 0.2 && < 0.3
|
||||||
, wai
|
, http-types >= 0.12.2 && < 0.13
|
||||||
, wai-cors
|
, insert-ordered-containers >= 0.2.2 && < 0.3
|
||||||
, wai-extra
|
, interpolatedstring-perl6 >= 1 && < 1.1
|
||||||
, wai-middleware-static
|
, jose >= 0.8.1 && < 0.9
|
||||||
, cookie
|
, lens >= 4.14 && < 4.20
|
||||||
|
, lens-aeson >= 1.0.1 && < 1.2
|
||||||
|
, network-uri >= 2.6.1 && < 2.8
|
||||||
|
, optparse-applicative >= 0.13 && < 0.16
|
||||||
|
, parsec >= 3.1.11 && < 3.2
|
||||||
|
, protolude >= 0.3 && < 0.4
|
||||||
|
, regex-tdfa >= 1.2.2 && < 1.4
|
||||||
|
, scientific >= 0.3.4 && < 0.4
|
||||||
|
, swagger2 >= 2.4 && < 2.7
|
||||||
|
, text >= 1.2.2 && < 1.3
|
||||||
|
, time >= 1.6 && < 1.11
|
||||||
|
, 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
|
||||||
|
ghc-options: -O2 -Werror -Wall -fwarn-identities
|
||||||
|
-fno-spec-constr -optP-Wno-nonportable-include-path
|
||||||
|
-- -fno-spec-constr may help keep compile time memory use in check,
|
||||||
|
-- see https://gitlab.haskell.org/ghc/ghc/issues/16017#note_219304
|
||||||
|
-- -optP-Wno-nonportable-include-path
|
||||||
|
-- prevents build failures on case-insensitive filesystems (macos),
|
||||||
|
-- see https://github.com/commercialhaskell/stack/issues/3918
|
||||||
|
|
||||||
Other-Modules: Paths_postgrest
|
executable postgrest
|
||||||
Exposed-Modules: PostgREST.ApiRequest
|
main-is: Main.hs
|
||||||
, PostgREST.App
|
hs-source-dirs: main
|
||||||
, PostgREST.Auth
|
build-depends: base >= 4.9 && < 4.15
|
||||||
, PostgREST.Config
|
, auto-update >= 0.1.4 && < 0.2
|
||||||
, PostgREST.DbStructure
|
, base64-bytestring >= 1 && < 1.2
|
||||||
, PostgREST.DbRequestBuilder
|
, bytestring >= 0.10.8 && < 0.11
|
||||||
, PostgREST.Error
|
, directory >= 1.2.6 && < 1.4
|
||||||
, PostgREST.Middleware
|
, either >= 4.4.1 && < 5.1
|
||||||
, PostgREST.OpenAPI
|
, hasql >= 1.4 && < 1.5
|
||||||
, PostgREST.Parsers
|
, hasql-pool >= 0.5 && < 0.6
|
||||||
, PostgREST.QueryBuilder
|
, hasql-transaction >= 0.7.2 && < 1.1
|
||||||
, PostgREST.RangeQuery
|
, network < 3.2
|
||||||
, PostgREST.Types
|
, postgrest
|
||||||
hs-source-dirs: src
|
, protolude >= 0.3 && < 0.4
|
||||||
|
, retry >= 0.7.4 && < 0.9
|
||||||
|
, text >= 1.2.2 && < 1.3
|
||||||
|
, time >= 1.6 && < 1.11
|
||||||
|
, wai >= 3.2.1 && < 3.3
|
||||||
|
, warp >= 3.2.12 && < 3.4
|
||||||
|
default-language: Haskell2010
|
||||||
|
default-extensions: OverloadedStrings
|
||||||
|
QuasiQuotes
|
||||||
|
NoImplicitPrelude
|
||||||
|
ghc-options: -threaded -rtsopts "-with-rtsopts=-N -I2"
|
||||||
|
-O2 -Werror -Wall -fwarn-identities
|
||||||
|
-fno-spec-constr -optP-Wno-nonportable-include-path
|
||||||
|
|
||||||
Test-Suite spec
|
if !os(windows)
|
||||||
|
build-depends: unix
|
||||||
|
other-modules: UnixSocket
|
||||||
|
|
||||||
|
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.EmbedDisambiguationSpec
|
||||||
|
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
|
||||||
|
Feature.RawOutputTypesSpec
|
||||||
|
Feature.HtmlRawOutputSpec
|
||||||
|
Feature.MultipleSchemaSpec
|
||||||
|
SpecHelper
|
||||||
|
TestTypes
|
||||||
|
hs-source-dirs: test
|
||||||
|
build-depends: base >= 4.9 && < 4.15
|
||||||
|
, aeson >= 1.4.7 && < 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.2
|
||||||
|
, 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 && < 1.1
|
||||||
|
, heredoc >= 0.2 && < 0.3
|
||||||
|
, hspec >= 2.3 && < 2.8
|
||||||
|
, hspec-wai >= 0.10 && < 0.11
|
||||||
|
, hspec-wai-json >= 0.10 && < 0.11
|
||||||
|
, http-types >= 0.12.3 && < 0.13
|
||||||
|
, lens >= 4.14 && < 4.20
|
||||||
|
, lens-aeson >= 1.0.1 && < 1.2
|
||||||
|
, monad-control >= 1.0.1 && < 1.1
|
||||||
|
, postgrest
|
||||||
|
, process >= 1.4.2 && < 1.7
|
||||||
|
, protolude >= 0.3 && < 0.4
|
||||||
|
, regex-tdfa >= 1.2.2 && < 1.4
|
||||||
|
, text >= 1.2.2 && < 1.3
|
||||||
|
, time >= 1.6 && < 1.11
|
||||||
|
, 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
|
||||||
|
|
||||||
|
Test-Suite spec-querycost
|
||||||
Type: exitcode-stdio-1.0
|
Type: exitcode-stdio-1.0
|
||||||
Default-Language: Haskell2010
|
Default-Language: Haskell2010
|
||||||
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude
|
default-extensions: OverloadedStrings, QuasiQuotes, NoImplicitPrelude
|
||||||
ghc-options: -threaded -rtsopts -with-rtsopts=-N
|
|
||||||
Hs-Source-Dirs: test
|
Hs-Source-Dirs: test
|
||||||
Main-Is: Main.hs
|
Main-Is: QueryCost.hs
|
||||||
Other-Modules: Feature.AudienceJwtSecretSpec
|
Other-Modules: SpecHelper
|
||||||
, Feature.AuthSpec
|
Build-Depends: base >= 4.9 && < 4.15
|
||||||
, Feature.AsymmetricJwtSpec
|
, aeson >= 1.4.7 && < 1.5
|
||||||
, Feature.BinaryJwtSecretSpec
|
, aeson-qq >= 0.8.1 && < 0.9
|
||||||
, Feature.ConcurrentSpec
|
, async >= 2.1.1 && < 2.3
|
||||||
, Feature.CorsSpec
|
, auto-update >= 0.1.4 && < 0.2
|
||||||
, Feature.DeleteSpec
|
, base64-bytestring >= 1 && < 1.2
|
||||||
, Feature.InsertSpec
|
, bytestring >= 0.10.8 && < 0.11
|
||||||
, Feature.JsonOperatorSpec
|
, case-insensitive >= 1.2 && < 1.3
|
||||||
, Feature.NoJwtSpec
|
, cassava >= 0.4.5 && < 0.6
|
||||||
, Feature.PgVersion95Spec
|
, containers >= 0.5.7 && < 0.7
|
||||||
, Feature.PgVersion96Spec
|
, contravariant >= 1.4 && < 1.6
|
||||||
, Feature.ProxySpec
|
, hasql >= 1.4 && < 1.5
|
||||||
, Feature.QueryLimitedSpec
|
, hasql-pool >= 0.5 && < 0.6
|
||||||
, Feature.QuerySpec
|
, hasql-transaction >= 0.7.2 && < 1.1
|
||||||
, Feature.RangeSpec
|
, heredoc >= 0.2 && < 0.3
|
||||||
, Feature.SingularSpec
|
, hspec >= 2.3 && < 2.8
|
||||||
, Feature.StructureSpec
|
, hspec-wai >= 0.10 && < 0.11
|
||||||
, Feature.UnicodeSpec
|
, hspec-wai-json >= 0.10 && < 0.11
|
||||||
, Feature.AndOrParamsSpec
|
, http-types >= 0.12.3 && < 0.13
|
||||||
, Feature.RpcSpec
|
, lens >= 4.14 && < 4.20
|
||||||
, Feature.NonexistentSchemaSpec
|
, lens-aeson >= 1.0.1 && < 1.2
|
||||||
, Feature.UpsertSpec
|
, monad-control >= 1.0.1 && < 1.1
|
||||||
, SpecHelper
|
|
||||||
, TestTypes
|
|
||||||
Build-Depends: aeson
|
|
||||||
, aeson-qq
|
|
||||||
, async
|
|
||||||
, auto-update
|
|
||||||
, base >= 4.8 && < 4.10
|
|
||||||
, bytestring
|
|
||||||
, base64-bytestring
|
|
||||||
, case-insensitive
|
|
||||||
, cassava
|
|
||||||
, containers
|
|
||||||
, contravariant
|
|
||||||
, hasql == 1.1
|
|
||||||
, hasql-pool == 0.4.3
|
|
||||||
, heredoc
|
|
||||||
, hjsonschema == 1.5.0.1
|
|
||||||
, hspec
|
|
||||||
, hspec-wai >= 0.7.0
|
|
||||||
, hspec-wai-json
|
|
||||||
, http-types
|
|
||||||
, lens
|
|
||||||
, lens-aeson
|
|
||||||
, monad-control
|
|
||||||
, postgrest
|
, postgrest
|
||||||
, process
|
, process >= 1.4.2 && < 1.7
|
||||||
, protolude == 0.2.2
|
, protolude >= 0.3 && < 0.4
|
||||||
, regex-tdfa
|
, regex-tdfa >= 1.2.2 && < 1.4
|
||||||
, time
|
, text >= 1.2.2 && < 1.3
|
||||||
, transformers-base
|
, time >= 1.6 && < 1.11
|
||||||
, wai
|
, transformers-base >= 0.4.4 && < 0.5
|
||||||
, wai-extra
|
, wai >= 3.2.1 && < 3.3
|
||||||
|
, wai-extra >= 3.0.19 && < 3.1
|
||||||
|
|||||||
@@ -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,23 @@
|
|||||||
|
with (import ./default.nix);
|
||||||
|
pkgs.lib.overrideDerivation env (
|
||||||
|
base: {
|
||||||
|
buildInputs =
|
||||||
|
base.buildInputs ++ [
|
||||||
|
pkgs.cabal-install
|
||||||
|
pkgs.stack
|
||||||
|
pkgs.cabal2nix
|
||||||
|
pkgs.postgresql
|
||||||
|
nixpkgsUpgrade
|
||||||
|
tests
|
||||||
|
style
|
||||||
|
lint
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook =
|
||||||
|
''
|
||||||
|
# Set our pinned version of Nixpkgs in the NIX_PATH so that
|
||||||
|
# `stack --nix` also uses that version.
|
||||||
|
NIX_PATH="nixpkgs=${nixpkgs}"
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1,60 +1,72 @@
|
|||||||
{-# LANGUAGE LambdaCase #-}
|
|
||||||
{-|
|
{-|
|
||||||
Module : PostgREST.ApiRequest
|
Module : PostgREST.ApiRequest
|
||||||
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
|
Description : PostgREST functions to translate HTTP request to a domain type called ApiRequest.
|
||||||
-}
|
-}
|
||||||
module PostgREST.ApiRequest ( ApiRequest(..)
|
{-# LANGUAGE LambdaCase #-}
|
||||||
, ContentType(..)
|
{-# LANGUAGE MultiWayIf #-}
|
||||||
, Action(..)
|
|
||||||
, Target(..)
|
|
||||||
, PreferRepresentation (..)
|
|
||||||
, mutuallyAgreeable
|
|
||||||
, userApiRequest
|
|
||||||
) where
|
|
||||||
|
|
||||||
import Protolude
|
module PostgREST.ApiRequest (
|
||||||
import qualified Data.Aeson as JSON
|
ApiRequest(..)
|
||||||
import Data.Aeson.Types (emptyObject, emptyArray)
|
, InvokeMethod(..)
|
||||||
import qualified Data.ByteString as BS
|
, ContentType(..)
|
||||||
import qualified Data.ByteString.Internal as BS (c2w)
|
, Action(..)
|
||||||
import qualified Data.ByteString.Lazy as BL
|
, Target(..)
|
||||||
import qualified Data.Csv as CSV
|
, mutuallyAgreeable
|
||||||
import qualified Data.List as L
|
, userApiRequest
|
||||||
import Data.List (lookup, last, partition)
|
) where
|
||||||
import qualified Data.HashMap.Strict as M
|
|
||||||
import qualified Data.Set as S
|
import qualified Data.Aeson as JSON
|
||||||
import Data.Maybe (fromJust)
|
import qualified Data.ByteString as BS
|
||||||
import Control.Arrow ((***))
|
import qualified Data.ByteString.Lazy as BL
|
||||||
import qualified Data.Text as T
|
import qualified Data.CaseInsensitive as CI
|
||||||
import qualified Data.Vector as V
|
import qualified Data.Csv as CSV
|
||||||
import Network.HTTP.Base (urlEncodeVars)
|
import qualified Data.HashMap.Strict as M
|
||||||
import Network.HTTP.Types.Header (hAuthorization, hCookie)
|
import qualified Data.List as L
|
||||||
import Network.HTTP.Types.URI (parseSimpleQuery)
|
import qualified Data.Set as S
|
||||||
import Network.Wai (Request (..))
|
import qualified Data.Text as T
|
||||||
import Network.Wai.Parse (parseHttpAccept)
|
import qualified Data.Vector as V
|
||||||
import PostgREST.RangeQuery (NonnegRange, rangeRequested, restrictRange, rangeGeq, allRange, rangeLimit, rangeOffset)
|
|
||||||
import Data.Ranged.Boundaries
|
import Control.Arrow ((***))
|
||||||
import PostgREST.Types
|
import Data.Aeson.Types (emptyArray, emptyObject)
|
||||||
import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange)
|
import Data.List (last, lookup, partition)
|
||||||
import qualified Data.CaseInsensitive as CI
|
import Data.List.NonEmpty (head)
|
||||||
import Web.Cookie (parseCookiesText)
|
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 hiding (head, toS)
|
||||||
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
type RequestBody = BL.ByteString
|
type RequestBody = BL.ByteString
|
||||||
|
|
||||||
|
data InvokeMethod = InvHead | InvGet | InvPost deriving Eq
|
||||||
-- | Types of things a user wants to do to tables/views/procs
|
-- | Types of things a user wants to do to tables/views/procs
|
||||||
data Action = ActionCreate | ActionRead
|
data Action = ActionCreate | ActionRead{isHead :: Bool}
|
||||||
| ActionUpdate | ActionDelete
|
| ActionUpdate | ActionDelete
|
||||||
| ActionInfo | ActionInvoke{isReadOnly :: Bool}
|
| ActionSingleUpsert | ActionInvoke InvokeMethod
|
||||||
| ActionInspect | ActionSingleUpsert
|
| ActionInfo | ActionInspect{isHead :: Bool}
|
||||||
deriving Eq
|
deriving Eq
|
||||||
-- | The target db object of a user action
|
-- | The target db object of a user action
|
||||||
data Target = TargetIdent QualifiedIdentifier
|
data Target = TargetIdent QualifiedIdentifier
|
||||||
| TargetProc QualifiedIdentifier
|
| TargetProc{tpQi :: QualifiedIdentifier, tpIsRootSpec :: Bool}
|
||||||
| TargetRoot
|
| TargetDefaultSpec{tdsSchema :: Schema} -- The default spec offered at root "/"
|
||||||
| TargetUnknown [Text]
|
| TargetUnknown [Text]
|
||||||
deriving Eq
|
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
|
Describes what the user wants to do. This data type is a
|
||||||
@@ -64,144 +76,190 @@ data PreferRepresentation = Full | HeadersOnly | None deriving Eq
|
|||||||
if it is an action we are able to perform.
|
if it is an action we are able to perform.
|
||||||
-}
|
-}
|
||||||
data ApiRequest = ApiRequest {
|
data ApiRequest = ApiRequest {
|
||||||
-- | Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
|
iAction :: Action -- ^ Similar but not identical to HTTP verb, e.g. Create/Invoke both POST
|
||||||
iAction :: Action
|
, iRange :: M.HashMap ByteString NonnegRange -- ^ Requested range of rows within response
|
||||||
-- | Requested range of rows within response
|
, iTopLevelRange :: NonnegRange -- ^ Requested range of rows from the top level
|
||||||
, iRange :: M.HashMap ByteString NonnegRange
|
, iTarget :: Target -- ^ The target, be it calling a proc or accessing a table
|
||||||
-- | The target, be it calling a proc or accessing a table
|
, iAccepts :: [ContentType] -- ^ Content types the client will accept, [CTAny] if no Accept header
|
||||||
, iTarget :: Target
|
, iPayload :: Maybe PayloadJSON -- ^ Data sent by client and used for mutation actions
|
||||||
-- | Content types the client will accept, [CTAny] if no Accept header
|
, iPreferRepresentation :: PreferRepresentation -- ^ If client wants created items echoed back
|
||||||
, iAccepts :: [ContentType]
|
, iPreferParameters :: Maybe PreferParameters -- ^ How to pass parameters to a stored procedure
|
||||||
-- | Data sent by client and used for mutation actions
|
, iPreferCount :: Maybe PreferCount -- ^ Whether the client wants a result count
|
||||||
, iPayload :: Maybe PayloadJSON
|
, iPreferResolution :: Maybe PreferResolution -- ^ Whether the client wants to UPSERT or ignore records on PK conflict
|
||||||
-- | If client wants created items echoed back
|
, iFilters :: [(Text, Text)] -- ^ Filters on the result ("id", "eq.10")
|
||||||
, iPreferRepresentation :: PreferRepresentation
|
, iLogic :: [(Text, Text)] -- ^ &and and &or parameters used for complex boolean logic
|
||||||
-- | Pass all parameters as a single json object to a stored procedure
|
, iSelect :: Maybe Text -- ^ &select parameter used to shape the response
|
||||||
, iPreferSingleObjectParameter :: Bool
|
, iOnConflict :: Maybe Text -- ^ &on_conflict parameter used to upsert on specific unique keys
|
||||||
-- | Whether the client wants a result count (slower)
|
, iColumns :: Maybe Text -- ^ &columns parameter used to shape the payload
|
||||||
, iPreferCount :: Bool
|
, iOrder :: [(Text, Text)] -- ^ &order parameters for each level
|
||||||
-- | Whether the client wants to UPSERT or ignore records on PK conflict
|
, iCanonicalQS :: ByteString -- ^ Alphabetized (canonical) request query string for response URLs
|
||||||
, iPreferResolution :: Maybe PreferResolution
|
, iJWT :: Text -- ^ JSON Web Token
|
||||||
-- | Filters on the result ("id", "eq.10")
|
, iHeaders :: [(Text, Text)] -- ^ HTTP request headers
|
||||||
, iFilters :: [(Text, Text)]
|
, iCookies :: [(Text, Text)] -- ^ Request Cookies
|
||||||
-- | &and and &or parameters used for complex boolean logic
|
, iPath :: ByteString -- ^ Raw request path
|
||||||
, iLogic :: [(Text, Text)]
|
, iMethod :: ByteString -- ^ Raw request method
|
||||||
-- | &select parameter used to shape the response
|
, iProfile :: Maybe Schema -- ^ The request profile for enabling use of multiple schemas. Follows the spec in hhttps://www.w3.org/TR/dx-prof-conneg/ttps://www.w3.org/TR/dx-prof-conneg/.
|
||||||
, iSelect :: Text
|
, iSchema :: Schema -- ^ The request schema. Can vary depending on iProfile.
|
||||||
-- | &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.
|
-- | Examines HTTP request and translates it into user intent.
|
||||||
userApiRequest :: Schema -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
userApiRequest :: NonEmpty Schema -> Maybe Text -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||||
userApiRequest schema req reqBody
|
userApiRequest confSchemas rootSpec req reqBody
|
||||||
| isTargetingProc && method `notElem` ["GET", "POST"] = Left ActionInappropriate
|
| isJust profile && fromJust profile `notElem` confSchemas = Left $ UnacceptableSchema $ toList confSchemas
|
||||||
|
| isTargetingProc && method `notElem` ["HEAD", "GET", "POST"] = Left ActionInappropriate
|
||||||
| topLevelRange == emptyRange = Left InvalidRange
|
| topLevelRange == emptyRange = Left InvalidRange
|
||||||
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
|
| shouldParsePayload && isLeft payload = either (Left . InvalidBody . toS) witness payload
|
||||||
| otherwise = Right ApiRequest {
|
| otherwise = Right ApiRequest {
|
||||||
iAction = action
|
iAction = action
|
||||||
, iTarget = target
|
, iTarget = target
|
||||||
, iRange = ranges
|
, iRange = ranges
|
||||||
, iAccepts = fromMaybe [CTAny] $
|
, iTopLevelRange = topLevelRange
|
||||||
map decodeContentType . parseHttpAccept <$> lookupHeader "accept"
|
, iAccepts = maybe [CTAny] (map decodeContentType . parseHttpAccept) $ lookupHeader "accept"
|
||||||
, iPayload = relevantPayload
|
, iPayload = relevantPayload
|
||||||
, iPreferRepresentation = representation
|
, iPreferRepresentation = representation
|
||||||
, iPreferSingleObjectParameter = singleObject
|
, iPreferParameters = if | hasPrefer (show SingleObject) -> Just SingleObject
|
||||||
, iPreferCount = hasPrefer "count=exact"
|
| hasPrefer (show MultipleObjects) -> Just MultipleObjects
|
||||||
, iPreferResolution = if hasPrefer (show MergeDuplicates) then Just MergeDuplicates
|
| otherwise -> Nothing
|
||||||
else if hasPrefer (show IgnoreDuplicates) then Just IgnoreDuplicates
|
, iPreferCount = if | hasPrefer (show ExactCount) -> Just ExactCount
|
||||||
else Nothing
|
| hasPrefer (show PlannedCount) -> Just PlannedCount
|
||||||
|
| hasPrefer (show EstimatedCount) -> Just EstimatedCount
|
||||||
|
| otherwise -> Nothing
|
||||||
|
, iPreferResolution = if | hasPrefer (show MergeDuplicates) -> Just MergeDuplicates
|
||||||
|
| hasPrefer (show IgnoreDuplicates) -> Just IgnoreDuplicates
|
||||||
|
| otherwise -> Nothing
|
||||||
, iFilters = filters
|
, iFilters = filters
|
||||||
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
|
, iLogic = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["and", "or"] k ]
|
||||||
, iSelect = toS $ fromMaybe "*" $ fromMaybe (Just "*") $ lookup "select" qParams
|
, iSelect = toS <$> join (lookup "select" qParams)
|
||||||
|
, iOnConflict = toS <$> join (lookup "on_conflict" qParams)
|
||||||
|
, iColumns = columns
|
||||||
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
|
, iOrder = [(toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, endingIn ["order"] k ]
|
||||||
, iCanonicalQS = toS $ urlEncodeVars
|
, iCanonicalQS = toS $ urlEncodeVars
|
||||||
. L.sortBy (comparing fst)
|
. L.sortOn fst
|
||||||
. map (join (***) toS)
|
. map (join (***) toS . second (fromMaybe BS.empty))
|
||||||
. parseSimpleQuery
|
$ qString
|
||||||
$ rawQueryString req
|
|
||||||
, iJWT = tokenStr
|
, iJWT = tokenStr
|
||||||
, iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hAuthorization, k /= hCookie]
|
, iHeaders = [ (toS $ CI.foldedCase k, toS v) | (k,v) <- hdrs, k /= hCookie]
|
||||||
, iCookies = fromMaybe [] $ parseCookiesText <$> lookupHeader "Cookie"
|
, iCookies = maybe [] parseCookiesText $ lookupHeader "Cookie"
|
||||||
|
, iPath = rawPathInfo req
|
||||||
|
, iMethod = method
|
||||||
|
, iProfile = profile
|
||||||
|
, iSchema = schema
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
|
-- queryString with '+' converted to ' '(space)
|
||||||
|
qString = parseQueryReplacePlus True $ rawQueryString req
|
||||||
-- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
|
-- rpcQParams = Rpc query params e.g. /rpc/name?param1=val1, similar to filter but with no operator(eq, lt..)
|
||||||
(filters, rpcQParams) =
|
(filters, rpcQParams) =
|
||||||
case action of
|
case action of
|
||||||
ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts
|
ActionInvoke InvGet -> partitionFlts
|
||||||
_ -> (flts, [])
|
ActionInvoke InvHead -> partitionFlts
|
||||||
flts = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset", "and", "or"] k) ]
|
_ -> (flts, [])
|
||||||
|
partitionFlts = partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) 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) $
|
hasOperator val = any (`T.isPrefixOf` val) $
|
||||||
((<> ".") <$> "not":M.keys operators) ++
|
((<> ".") <$> "not":M.keys operators) ++
|
||||||
((<> "(") <$> M.keys ftsOperators)
|
((<> "(") <$> M.keys ftsOperators)
|
||||||
isEmbedPath = T.isInfixOf "."
|
isEmbedPath = T.isInfixOf "."
|
||||||
isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path
|
isTargetingProc = case target of
|
||||||
|
TargetProc _ _ -> True
|
||||||
|
_ -> False
|
||||||
|
isTargetingDefaultSpec = case target of
|
||||||
|
TargetDefaultSpec _ -> True
|
||||||
|
_ -> False
|
||||||
|
contentType = decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type"
|
||||||
|
columns
|
||||||
|
| action `elem` [ActionCreate, ActionUpdate, ActionInvoke InvPost] = toS <$> join (lookup "columns" qParams)
|
||||||
|
| otherwise = Nothing
|
||||||
payload =
|
payload =
|
||||||
case (decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type", action) of
|
case (contentType, action) of
|
||||||
(_, ActionInvoke{isReadOnly=True}) ->
|
(_, ActionInvoke InvGet) -> Right rpcPrmsToJson
|
||||||
Right $ PayloadJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) PJObject (S.fromList $ fst <$> rpcQParams)
|
(_, ActionInvoke InvHead) -> Right rpcPrmsToJson
|
||||||
(CTApplicationJSON, _) ->
|
(CTApplicationJSON, _) ->
|
||||||
note "All object keys must match" . payloadAttributes reqBody
|
if isJust columns
|
||||||
=<< if BL.null reqBody && isTargetingProc
|
then Right $ RawJSON reqBody
|
||||||
then Right emptyObject
|
else note "All object keys must match" . payloadAttributes reqBody
|
||||||
else JSON.eitherDecode reqBody
|
=<< if BL.null reqBody && isTargetingProc
|
||||||
|
then Right emptyObject
|
||||||
|
else JSON.eitherDecode reqBody
|
||||||
(CTTextCSV, _) -> do
|
(CTTextCSV, _) -> do
|
||||||
json <- csvToJson <$> CSV.decodeByName reqBody
|
json <- csvToJson <$> CSV.decodeByName reqBody
|
||||||
note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json
|
note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json
|
||||||
(CTOther "application/x-www-form-urlencoded", _) ->
|
(CTOther "application/x-www-form-urlencoded", _) ->
|
||||||
let json = M.fromList . map (toS *** JSON.String . toS) . parseSimpleQuery $ toS reqBody
|
let json = M.fromList . map (toS *** JSON.String . toS) . parseSimpleQuery $ toS reqBody
|
||||||
keys = S.fromList $ M.keys json in
|
keys = S.fromList $ M.keys json in
|
||||||
Right $ PayloadJSON (JSON.encode json) PJObject keys
|
Right $ ProcessedJSON (JSON.encode json) keys
|
||||||
(ct, _) ->
|
(ct, _) ->
|
||||||
Left $ toS $ "Content-Type not acceptable: " <> toMime ct
|
Left $ toS $ "Content-Type not acceptable: " <> toMime ct
|
||||||
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges
|
rpcPrmsToJson = ProcessedJSON (JSON.encode $ M.fromList $ second JSON.toJSON <$> rpcQParams) (S.fromList $ fst <$> rpcQParams)
|
||||||
|
topLevelRange = fromMaybe allRange $ M.lookup "limit" ranges -- if no limit is specified, get all the request rows
|
||||||
action =
|
action =
|
||||||
case method of
|
case method of
|
||||||
"GET" | target == TargetRoot -> ActionInspect
|
-- The HEAD method is identical to GET except that the server MUST NOT return a message-body in the response
|
||||||
| isTargetingProc -> ActionInvoke{isReadOnly=True}
|
-- From https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
|
||||||
| otherwise -> ActionRead
|
"HEAD" | isTargetingDefaultSpec -> ActionInspect{isHead=True}
|
||||||
|
| isTargetingProc -> ActionInvoke InvHead
|
||||||
|
| otherwise -> ActionRead{isHead=True}
|
||||||
|
"GET" | isTargetingDefaultSpec -> ActionInspect{isHead=False}
|
||||||
|
| isTargetingProc -> ActionInvoke InvGet
|
||||||
|
| otherwise -> ActionRead{isHead=False}
|
||||||
"POST" -> if isTargetingProc
|
"POST" -> if isTargetingProc
|
||||||
then ActionInvoke{isReadOnly=False}
|
then ActionInvoke InvPost
|
||||||
else ActionCreate
|
else ActionCreate
|
||||||
"PATCH" -> ActionUpdate
|
"PATCH" -> ActionUpdate
|
||||||
"PUT" -> ActionSingleUpsert
|
"PUT" -> ActionSingleUpsert
|
||||||
"DELETE" -> ActionDelete
|
"DELETE" -> ActionDelete
|
||||||
"OPTIONS" -> ActionInfo
|
"OPTIONS" -> ActionInfo
|
||||||
_ -> ActionInspect
|
_ -> ActionInspect{isHead=False}
|
||||||
|
|
||||||
|
defaultSchema = head confSchemas
|
||||||
|
profile
|
||||||
|
| length confSchemas <= 1 -- only enable content negotiation by profile when there are multiple schemas specified in the config
|
||||||
|
= Nothing
|
||||||
|
| action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionDelete, ActionInvoke InvPost] -- POST/PATCH/PUT/DELETE don't use the same header as per the spec
|
||||||
|
= Just $ maybe defaultSchema toS $ lookupHeader "Content-Profile"
|
||||||
|
| action `elem` [ActionRead True, ActionRead False, ActionInvoke InvGet, ActionInvoke InvHead,
|
||||||
|
ActionInspect False, ActionInspect True, ActionInfo]
|
||||||
|
= Just $ maybe defaultSchema toS $ lookupHeader "Accept-Profile"
|
||||||
|
| otherwise = Nothing
|
||||||
|
schema = fromMaybe defaultSchema profile
|
||||||
target = case path of
|
target = case path of
|
||||||
[] -> TargetRoot
|
[] -> case rootSpec of
|
||||||
[table] -> TargetIdent
|
Just pName -> TargetProc (QualifiedIdentifier schema pName) True
|
||||||
$ QualifiedIdentifier schema table
|
Nothing -> TargetDefaultSpec schema
|
||||||
["rpc", proc] -> TargetProc
|
[table] -> TargetIdent $ QualifiedIdentifier schema table
|
||||||
$ QualifiedIdentifier schema proc
|
["rpc", proc] -> TargetProc (QualifiedIdentifier schema proc) False
|
||||||
other -> TargetUnknown other
|
other -> TargetUnknown other
|
||||||
shouldParsePayload = action `elem` [ActionCreate, ActionUpdate, ActionSingleUpsert, ActionInvoke{isReadOnly=False}, ActionInvoke{isReadOnly=True}]
|
|
||||||
|
shouldParsePayload =
|
||||||
|
action `elem`
|
||||||
|
[ActionCreate, ActionUpdate, ActionSingleUpsert,
|
||||||
|
ActionInvoke InvPost,
|
||||||
|
-- Though ActionInvoke{isGet=True}(a GET /rpc/..) doesn't really have a payload, we use the payload variable as a way
|
||||||
|
-- to store the query string arguments to the function.
|
||||||
|
ActionInvoke InvGet,
|
||||||
|
ActionInvoke InvHead]
|
||||||
relevantPayload | shouldParsePayload = rightToMaybe payload
|
relevantPayload | shouldParsePayload = rightToMaybe payload
|
||||||
| otherwise = Nothing
|
| otherwise = Nothing
|
||||||
path = pathInfo req
|
path = pathInfo req
|
||||||
method = requestMethod req
|
method = requestMethod req
|
||||||
hdrs = requestHeaders req
|
hdrs = requestHeaders req
|
||||||
qParams = [(toS k, v)|(k,v) <- queryString req]
|
qParams = [(toS k, v)|(k,v) <- qString]
|
||||||
lookupHeader = flip lookup hdrs
|
lookupHeader = flip lookup hdrs
|
||||||
hasPrefer :: Text -> Bool
|
hasPrefer :: Text -> Bool
|
||||||
hasPrefer val = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs
|
hasPrefer val = any (\(h,v) -> h == "Prefer" && val `elem` split v) hdrs
|
||||||
where
|
where
|
||||||
split :: BS.ByteString -> [Text]
|
split :: BS.ByteString -> [Text]
|
||||||
split = map T.strip . T.split (==',') . toS
|
split = map T.strip . T.split (==',') . toS
|
||||||
singleObject = hasPrefer "params=single-object"
|
|
||||||
representation
|
representation
|
||||||
| hasPrefer "return=representation" = Full
|
| hasPrefer (show Full) = Full
|
||||||
| hasPrefer "return=minimal" = None
|
| hasPrefer (show None) = None
|
||||||
| otherwise = HeadersOnly
|
| otherwise = if action == ActionCreate
|
||||||
|
then HeadersOnly -- Assume the user wants the Location header(for POST) by default
|
||||||
|
else None
|
||||||
auth = fromMaybe "" $ lookupHeader hAuthorization
|
auth = fromMaybe "" $ lookupHeader hAuthorization
|
||||||
tokenStr = case T.split (== ' ') (toS auth) of
|
tokenStr = case T.split (== ' ') (toS auth) of
|
||||||
("Bearer" : t : _) -> t
|
("Bearer" : t : _) -> t
|
||||||
@@ -215,7 +273,7 @@ userApiRequest schema req reqBody
|
|||||||
limitParams :: M.HashMap ByteString NonnegRange
|
limitParams :: M.HashMap ByteString NonnegRange
|
||||||
limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< (toS <$> v)) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]
|
limitParams = M.fromList [(toS (replaceLast "limit" k), restrictRange (readMaybe =<< (toS <$> v)) allRange) | (k,v) <- qParams, isJust v, endingIn ["limit"] k]
|
||||||
offsetParams :: M.HashMap ByteString NonnegRange
|
offsetParams :: M.HashMap ByteString NonnegRange
|
||||||
offsetParams = M.fromList [(toS (replaceLast "limit" k), fromMaybe allRange (rangeGeq <$> (readMaybe =<< (toS <$> v)))) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]
|
offsetParams = M.fromList [(toS (replaceLast "limit" k), maybe allRange rangeGeq (readMaybe =<< (toS <$> v))) | (k,v) <- qParams, isJust v, endingIn ["offset"] k]
|
||||||
|
|
||||||
urlRange = M.unionWith f limitParams offsetParams
|
urlRange = M.unionWith f limitParams offsetParams
|
||||||
where
|
where
|
||||||
@@ -238,23 +296,6 @@ mutuallyAgreeable sProduces cAccepts =
|
|||||||
then listToMaybe sProduces
|
then listToMaybe sProduces
|
||||||
else exact
|
else exact
|
||||||
|
|
||||||
-- PRIVATE ---------------------------------------------------------------
|
|
||||||
|
|
||||||
{-|
|
|
||||||
Warning: discards MIME parameters
|
|
||||||
-}
|
|
||||||
decodeContentType :: BS.ByteString -> ContentType
|
|
||||||
decodeContentType ct =
|
|
||||||
case BS.takeWhile (/= BS.c2w ';') ct of
|
|
||||||
"application/json" -> CTApplicationJSON
|
|
||||||
"text/csv" -> CTTextCSV
|
|
||||||
"application/openapi+json" -> CTOpenAPI
|
|
||||||
"application/vnd.pgrst.object+json" -> CTSingularJSON
|
|
||||||
"application/vnd.pgrst.object" -> CTSingularJSON
|
|
||||||
"application/octet-stream" -> CTOctetStream
|
|
||||||
"*/*" -> CTAny
|
|
||||||
ct' -> CTOther ct'
|
|
||||||
|
|
||||||
type CsvData = V.Vector (M.HashMap Text BL.ByteString)
|
type CsvData = V.Vector (M.HashMap Text BL.ByteString)
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
@@ -292,14 +333,14 @@ payloadAttributes raw json =
|
|||||||
JSON.Object x -> S.fromList (M.keys x) == canonicalKeys
|
JSON.Object x -> S.fromList (M.keys x) == canonicalKeys
|
||||||
_ -> False) arr in
|
_ -> False) arr in
|
||||||
if areKeysUniform
|
if areKeysUniform
|
||||||
then Just $ PayloadJSON raw (PJArray $ V.length arr) canonicalKeys
|
then Just $ ProcessedJSON raw canonicalKeys
|
||||||
else Nothing
|
else Nothing
|
||||||
Just _ -> Nothing
|
Just _ -> Nothing
|
||||||
Nothing -> Just emptyPJArray
|
Nothing -> Just emptyPJArray
|
||||||
|
|
||||||
JSON.Object o -> Just $ PayloadJSON raw PJObject (S.fromList $ M.keys o)
|
JSON.Object o -> Just $ ProcessedJSON raw (S.fromList $ M.keys o)
|
||||||
|
|
||||||
-- truncate everything else to an empty array.
|
-- truncate everything else to an empty array.
|
||||||
_ -> Just emptyPJArray
|
_ -> Just emptyPJArray
|
||||||
where
|
where
|
||||||
emptyPJArray = PayloadJSON (JSON.encode emptyArray) (PJArray 0) S.empty
|
emptyPJArray = ProcessedJSON (JSON.encode emptyArray) S.empty
|
||||||
|
|||||||
@@ -1,244 +1,249 @@
|
|||||||
|
{-|
|
||||||
|
Module : PostgREST.App
|
||||||
|
Description : PostgREST main application
|
||||||
|
|
||||||
|
This module is in charge of mapping HTTP requests to PostgreSQL queries.
|
||||||
|
Some of its functionality includes:
|
||||||
|
|
||||||
|
- Mapping HTTP request methods to proper SQL statements. For example, a GET request is translated to executing a SELECT query in a read-only TRANSACTION.
|
||||||
|
- Producing HTTP Headers according to RFCs.
|
||||||
|
- Content Negotiation
|
||||||
|
-}
|
||||||
{-# LANGUAGE FlexibleContexts #-}
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
|
{-# LANGUAGE MultiWayIf #-}
|
||||||
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
{-# LANGUAGE NamedFieldPuns #-}
|
|
||||||
|
|
||||||
module PostgREST.App (
|
module PostgREST.App (
|
||||||
postgrest
|
postgrest
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Applicative
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import Data.Aeson as JSON
|
import qualified Data.HashMap.Strict as M
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.List as L (union)
|
||||||
import Data.Maybe
|
import qualified Data.Set as S
|
||||||
import Data.IORef (IORef, readIORef)
|
|
||||||
import Data.Text (intercalate)
|
|
||||||
import Data.Time.Clock (UTCTime)
|
|
||||||
import qualified Data.Set as S
|
|
||||||
|
|
||||||
import qualified Hasql.Pool as P
|
import qualified Hasql.Pool as P
|
||||||
|
import qualified Hasql.Transaction as H
|
||||||
import qualified Hasql.Transaction as HT
|
import qualified Hasql.Transaction as HT
|
||||||
import qualified Hasql.Transaction.Sessions as HT
|
import qualified Hasql.Transaction.Sessions as HT
|
||||||
|
|
||||||
import Network.HTTP.Types.Header
|
import Data.Function (id)
|
||||||
import Network.HTTP.Types.Status
|
import Data.IORef (IORef, readIORef)
|
||||||
import Network.HTTP.Types.URI (renderSimpleQuery)
|
import Data.Time.Clock (UTCTime)
|
||||||
import Network.Wai
|
import Network.HTTP.Types.URI (renderSimpleQuery)
|
||||||
import Network.Wai.Middleware.RequestLogger (logStdout)
|
import Network.Wai.Middleware.RequestLogger (logStdout)
|
||||||
|
|
||||||
import qualified Hasql.Transaction as H
|
import Control.Applicative
|
||||||
|
import Data.Maybe
|
||||||
|
import Network.HTTP.Types.Header
|
||||||
|
import Network.HTTP.Types.Status
|
||||||
|
import Network.Wai
|
||||||
|
|
||||||
import qualified Data.HashMap.Strict as M
|
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
|
||||||
|
InvokeMethod (..), Target (..),
|
||||||
import PostgREST.ApiRequest ( ApiRequest(..), ContentType(..)
|
mutuallyAgreeable, userApiRequest)
|
||||||
, Action(..), Target(..)
|
import PostgREST.Auth (containsRole, jwtClaims,
|
||||||
, PreferRepresentation (..)
|
parseSecret)
|
||||||
, mutuallyAgreeable
|
import PostgREST.Config (AppConfig (..))
|
||||||
, userApiRequest
|
import PostgREST.DbRequestBuilder (mutateRequest, readRequest,
|
||||||
)
|
returningCols)
|
||||||
import PostgREST.Auth (jwtClaims, containsRole, parseJWK)
|
import PostgREST.DbStructure
|
||||||
import PostgREST.Config (AppConfig (..))
|
import PostgREST.Error (PgError (..), SimpleError (..),
|
||||||
import PostgREST.DbStructure
|
errorResponseFor, singularityError)
|
||||||
import PostgREST.DbRequestBuilder( readRequest
|
import PostgREST.Middleware
|
||||||
, mutateRequest
|
import PostgREST.OpenAPI
|
||||||
, fieldNames
|
import PostgREST.Parsers (pRequestColumns)
|
||||||
)
|
import PostgREST.QueryBuilder (limitedQuery, mutateRequestToQuery,
|
||||||
import PostgREST.Error ( simpleError, pgError
|
readRequestToCountQuery,
|
||||||
, apiRequestError
|
readRequestToQuery,
|
||||||
, singularityError, binaryFieldError
|
requestToCallProcQuery)
|
||||||
, connectionLostError, gucHeadersError
|
import PostgREST.RangeQuery (allRange, contentRangeH,
|
||||||
)
|
rangeStatusHeader)
|
||||||
import PostgREST.RangeQuery (allRange, rangeOffset)
|
import PostgREST.Statements (callProcStatement,
|
||||||
import PostgREST.Middleware
|
createExplainStatement,
|
||||||
import PostgREST.QueryBuilder ( callProc
|
createReadStatement,
|
||||||
, requestToQuery
|
createWriteStatement)
|
||||||
, requestToCountQuery
|
import PostgREST.Types
|
||||||
, createReadStatement
|
import Protolude hiding (Proxy, intercalate, toS)
|
||||||
, createWriteStatement
|
import Protolude.Conv (toS)
|
||||||
, ResultsWithCount
|
|
||||||
)
|
|
||||||
import PostgREST.Types
|
|
||||||
import PostgREST.OpenAPI
|
|
||||||
|
|
||||||
import Data.Function (id)
|
|
||||||
import Protolude hiding (intercalate, Proxy)
|
|
||||||
|
|
||||||
postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> IO () -> Application
|
postgrest :: AppConfig -> IORef (Maybe DbStructure) -> P.Pool -> IO UTCTime -> IO () -> Application
|
||||||
postgrest conf refDbStructure pool getTime worker =
|
postgrest conf refDbStructure pool getTime worker =
|
||||||
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle
|
let middle = (if configQuiet conf then id else logStdout) . defaultMiddle
|
||||||
jwtSecret = parseJWK <$> configJwtSecret conf in
|
jwtSecret = parseSecret <$> configJwtSecret conf in
|
||||||
|
|
||||||
middle $ \ req respond -> do
|
middle $ \ req respond -> do
|
||||||
time <- getTime
|
time <- getTime
|
||||||
body <- strictRequestBody req
|
body <- strictRequestBody req
|
||||||
maybeDbStructure <- readIORef refDbStructure
|
maybeDbStructure <- readIORef refDbStructure
|
||||||
case maybeDbStructure of
|
case maybeDbStructure of
|
||||||
Nothing -> respond connectionLostError
|
Nothing -> respond . errorResponseFor $ ConnectionLostError
|
||||||
Just dbStructure -> do
|
Just dbStructure -> do
|
||||||
response <- case userApiRequest (configSchema conf) req body of
|
response <- do
|
||||||
Left err -> return $ apiRequestError err
|
-- Need to parse ?columns early because findProc needs it to solve overloaded functions.
|
||||||
Right apiRequest -> do
|
-- TODO: move this logic to the app function
|
||||||
eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) time (rightToMaybe $ configRoleClaimKey conf)
|
let apiReq = userApiRequest (configSchemas conf) (configRootSpec conf) req body
|
||||||
|
apiReqCols = (,) <$> apiReq <*> (pRequestColumns =<< iColumns <$> apiReq)
|
||||||
let authed = containsRole eClaims
|
case apiReqCols of
|
||||||
proc = case (iTarget apiRequest, iPayload apiRequest, iPreferSingleObjectParameter apiRequest) of
|
Left err -> return . errorResponseFor $ err
|
||||||
(TargetProc qi, Just PayloadJSON{pjKeys}, s) -> findProc qi pjKeys s $ dbProcs dbStructure
|
Right (apiRequest, maybeCols) -> do
|
||||||
_ -> Nothing
|
eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) time (rightToMaybe $ configRoleClaimKey conf)
|
||||||
handleReq = runWithClaims conf eClaims (app dbStructure proc conf) apiRequest
|
let authed = containsRole eClaims
|
||||||
txMode = transactionMode proc (iAction apiRequest)
|
cols = case (iPayload apiRequest, maybeCols) of
|
||||||
response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq
|
(Just ProcessedJSON{pjKeys}, _) -> pjKeys
|
||||||
return $ either (pgError authed) identity response
|
(Just RawJSON{}, Just cls) -> cls
|
||||||
|
_ -> S.empty
|
||||||
|
proc = case iTarget apiRequest of
|
||||||
|
TargetProc qi _ -> findProc qi cols (iPreferParameters apiRequest == Just SingleObject) $ dbProcs dbStructure
|
||||||
|
_ -> Nothing
|
||||||
|
handleReq = runWithClaims conf eClaims (app dbStructure proc cols conf) apiRequest
|
||||||
|
txMode = transactionMode proc (iAction apiRequest)
|
||||||
|
response <- P.use pool $ HT.transaction HT.ReadCommitted txMode handleReq
|
||||||
|
return $ either (errorResponseFor . PgError authed) identity response
|
||||||
when (responseStatus response == status503) worker
|
when (responseStatus response == status503) worker
|
||||||
respond response
|
respond response
|
||||||
|
|
||||||
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> M.HashMap Text [ProcDescription] -> Maybe ProcDescription
|
transactionMode :: Maybe ProcDescription -> Action -> HT.Mode
|
||||||
findProc qi payloadKeys paramsAsSingleObject allProcs =
|
|
||||||
let procs = M.lookup (qiName qi) allProcs in
|
|
||||||
-- Handle overloaded functions case
|
|
||||||
join $ (case length <$> procs of
|
|
||||||
Just 1 -> headMay -- if it's not an overloaded function then immediatly get the ProcDescription
|
|
||||||
_ -> find (\x ->
|
|
||||||
if paramsAsSingleObject
|
|
||||||
then length (pdArgs x) == 1 -- if the arg is not of json type let the db give the err
|
|
||||||
else payloadKeys `S.isSubsetOf` S.fromList (pgaName <$> pdArgs x))
|
|
||||||
) <$> procs
|
|
||||||
|
|
||||||
transactionMode :: Maybe ProcDescription -> Action -> H.Mode
|
|
||||||
transactionMode proc action =
|
transactionMode proc action =
|
||||||
case action of
|
case action of
|
||||||
ActionRead -> HT.Read
|
ActionRead _ -> HT.Read
|
||||||
ActionInfo -> HT.Read
|
ActionInfo -> HT.Read
|
||||||
ActionInspect -> HT.Read
|
ActionInspect _ -> HT.Read
|
||||||
ActionInvoke{isReadOnly=False} ->
|
ActionInvoke InvGet -> HT.Read
|
||||||
let v = fromMaybe Volatile $ pdVolatility <$> proc in
|
ActionInvoke InvHead -> HT.Read
|
||||||
|
ActionInvoke InvPost ->
|
||||||
|
let v = maybe Volatile pdVolatility proc in
|
||||||
if v == Stable || v == Immutable
|
if v == Stable || v == Immutable
|
||||||
then HT.Read
|
then HT.Read
|
||||||
else HT.Write
|
else HT.Write
|
||||||
ActionInvoke{isReadOnly=True} -> HT.Read
|
|
||||||
_ -> HT.Write
|
_ -> HT.Write
|
||||||
|
|
||||||
app :: DbStructure -> Maybe ProcDescription -> AppConfig -> ApiRequest -> H.Transaction Response
|
app :: DbStructure -> Maybe ProcDescription -> S.Set FieldName -> AppConfig -> ApiRequest -> H.Transaction Response
|
||||||
app dbStructure proc conf apiRequest =
|
app dbStructure proc cols conf apiRequest =
|
||||||
case responseContentTypeOrError (iAccepts apiRequest) (iAction apiRequest) of
|
let rawContentTypes = (decodeContentType <$> configRawMediaTypes conf) `L.union` [ CTOctetStream, CTTextPlain ] in
|
||||||
|
case responseContentTypeOrError (iAccepts apiRequest) rawContentTypes (iAction apiRequest) (iTarget apiRequest) of
|
||||||
Left errorResponse -> return errorResponse
|
Left errorResponse -> return errorResponse
|
||||||
Right contentType ->
|
Right contentType ->
|
||||||
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
|
case (iAction apiRequest, iTarget apiRequest, iPayload apiRequest) of
|
||||||
|
|
||||||
(ActionRead, TargetIdent qi, Nothing) ->
|
(ActionRead headersOnly, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
|
||||||
let partsField = (,) <$> readSqlParts
|
case readSqlParts tSchema tName of
|
||||||
<*> (binaryField contentType =<< fldNames) in
|
|
||||||
case partsField of
|
|
||||||
Left errorResponse -> return errorResponse
|
Left errorResponse -> return errorResponse
|
||||||
Right ((q, cq), bField) -> do
|
Right (q, cq, bField, _) -> do
|
||||||
let stm = createReadStatement q cq (contentType == CTSingularJSON) shouldCount
|
let cQuery = if estimatedCount
|
||||||
(contentType == CTTextCSV) bField
|
then limitedQuery cq ((+ 1) <$> maxRows) -- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
|
||||||
row <- H.query () stm
|
else cq
|
||||||
let (tableTotal, queryTotal, _ , body) = row
|
stm = createReadStatement q cQuery (contentType == CTSingularJSON) shouldCount
|
||||||
(status, contentRange) = rangeHeader queryTotal tableTotal
|
(contentType == CTTextCSV) bField pgVer
|
||||||
canonical = iCanonicalQS apiRequest
|
explStm = createExplainStatement cq
|
||||||
return $
|
row <- H.statement () stm
|
||||||
if contentType == CTSingularJSON && queryTotal /= 1
|
let (tableTotal, queryTotal, _ , body, gucHeaders) = row
|
||||||
then singularityError (toInteger queryTotal)
|
case gucHeaders of
|
||||||
else responseLBS status
|
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||||
[toHeader contentType, contentRange,
|
Right ghdrs -> do
|
||||||
("Content-Location",
|
total <- if | plannedCount -> H.statement () explStm
|
||||||
"/" <> toS (qiName qi) <>
|
| estimatedCount -> if tableTotal > (fromIntegral <$> maxRows)
|
||||||
if BS.null canonical then "" else "?" <> toS canonical
|
then do estTotal <- H.statement () explStm
|
||||||
)
|
pure $ if estTotal > tableTotal then estTotal else tableTotal
|
||||||
] (toS body)
|
else pure tableTotal
|
||||||
|
| otherwise -> pure tableTotal
|
||||||
|
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal total
|
||||||
|
headers = addHeadersIfNotIncluded (catMaybes [
|
||||||
|
Just $ toHeader contentType, Just contentRange,
|
||||||
|
Just $ contentLocationH tName (iCanonicalQS apiRequest), profileH])
|
||||||
|
(unwrapGucHeader <$> ghdrs)
|
||||||
|
rBody = if headersOnly then mempty else toS body
|
||||||
|
return $
|
||||||
|
if contentType == CTSingularJSON && queryTotal /= 1
|
||||||
|
then errorResponseFor . singularityError $ queryTotal
|
||||||
|
else responseLBS status headers rBody
|
||||||
|
|
||||||
(ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just PayloadJSON{pjRaw, pjType}) ->
|
(ActionCreate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
|
||||||
case mutateSqlParts tSchema tName of
|
case mutateSqlParts tSchema tName of
|
||||||
Left errorResponse -> return errorResponse
|
Left errorResponse -> return errorResponse
|
||||||
Right (sq, mq) -> do
|
Right (sq, mq) -> do
|
||||||
let (isSingle, nRows) = case pjType of
|
let pkCols = tablePKCols dbStructure tSchema tName
|
||||||
PJArray len -> (len == 1, len)
|
stm = createWriteStatement sq mq
|
||||||
PJObject -> (True, 1)
|
(contentType == CTSingularJSON) True
|
||||||
if contentType == CTSingularJSON
|
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols pgVer
|
||||||
&& not isSingle
|
row <- H.statement (toS $ pjRaw pJson) stm
|
||||||
&& iPreferRepresentation apiRequest == Full
|
let (_, queryTotal, fields, body, gucHeaders) = row
|
||||||
then return $ singularityError (toInteger nRows)
|
case gucHeaders of
|
||||||
else do
|
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||||
let pkCols = tablePKCols dbStructure tSchema tName
|
Right ghdrs -> do
|
||||||
stm = createWriteStatement sq mq
|
let
|
||||||
(contentType == CTSingularJSON) isSingle
|
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||||
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) pkCols
|
then ([Just $ toHeader contentType, profileH], toS body)
|
||||||
row <- H.query (toS pjRaw) stm
|
else ([], mempty)
|
||||||
let (_, _, fs, body) = extractQueryResult row
|
headers = addHeadersIfNotIncluded (catMaybes ([
|
||||||
headers = catMaybes [
|
if null fields
|
||||||
if null fs
|
|
||||||
then Nothing
|
then Nothing
|
||||||
else Just (hLocation, "/" <> toS tName <> renderLocationFields fs)
|
else Just $ locationH tName fields
|
||||||
, if iPreferRepresentation apiRequest == Full
|
, Just $ contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
|
||||||
then Just $ toHeader contentType
|
, if null pkCols && isNothing (iOnConflict apiRequest)
|
||||||
else Nothing
|
|
||||||
, Just . contentRangeH 1 0 $
|
|
||||||
toInteger <$> if shouldCount then Just nRows else Nothing
|
|
||||||
, if null pkCols
|
|
||||||
then Nothing
|
then Nothing
|
||||||
else (\x -> ("Preference-Applied", show x)) <$> iPreferResolution apiRequest
|
else (\x -> ("Preference-Applied", encodeUtf8 (show x))) <$> iPreferResolution apiRequest
|
||||||
]
|
] ++ ctHeaders)) (unwrapGucHeader <$> ghdrs)
|
||||||
|
if contentType == CTSingularJSON && queryTotal /= 1
|
||||||
|
then do
|
||||||
|
HT.condemn
|
||||||
|
return . errorResponseFor . singularityError $ queryTotal
|
||||||
|
else
|
||||||
|
return $ responseLBS status201 headers rBody
|
||||||
|
|
||||||
return . responseLBS status201 headers $
|
(ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
|
||||||
if iPreferRepresentation apiRequest == Full
|
case mutateSqlParts tSchema tName of
|
||||||
then toS body else ""
|
Left errorResponse -> return errorResponse
|
||||||
|
Right (sq, mq) -> do
|
||||||
(ActionUpdate, TargetIdent (QualifiedIdentifier tSchema tName), Just p@PayloadJSON{pjRaw}) ->
|
|
||||||
case (mutateSqlParts tSchema tName, pjIsEmpty p, iPreferRepresentation apiRequest == Full) of
|
|
||||||
(Left errorResponse, _, _) -> return errorResponse
|
|
||||||
(_, True, True) -> return $ responseLBS status200 [contentRangeH 1 0 Nothing] "[]"
|
|
||||||
(_, True, False) -> return $ responseLBS status204 [contentRangeH 1 0 Nothing] ""
|
|
||||||
(Right (sq, mq), _, _) -> do
|
|
||||||
let stm = createWriteStatement sq mq
|
let stm = createWriteStatement sq mq
|
||||||
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
|
(contentType == CTSingularJSON) False (contentType == CTTextCSV)
|
||||||
(iPreferRepresentation apiRequest) []
|
(iPreferRepresentation apiRequest) [] pgVer
|
||||||
row <- H.query (toS pjRaw) stm
|
row <- H.statement (toS $ pjRaw pJson) stm
|
||||||
let (_, queryTotal, _, body) = extractQueryResult row
|
let (_, queryTotal, _, body, gucHeaders) = row
|
||||||
if contentType == CTSingularJSON
|
case gucHeaders of
|
||||||
&& queryTotal /= 1
|
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||||
&& iPreferRepresentation apiRequest == Full
|
Right ghdrs -> do
|
||||||
then do
|
let
|
||||||
HT.condemn
|
updateIsNoOp = S.null cols
|
||||||
return $ singularityError (toInteger queryTotal)
|
status | queryTotal == 0 && not updateIsNoOp = status404
|
||||||
else do
|
| iPreferRepresentation apiRequest == Full = status200
|
||||||
let r = contentRangeH 0 (toInteger $ queryTotal-1)
|
| otherwise = status204
|
||||||
(toInteger <$> if shouldCount then Just queryTotal else Nothing)
|
contentRangeHeader = contentRangeH 0 (queryTotal - 1) $ if shouldCount then Just queryTotal else Nothing
|
||||||
s = if iPreferRepresentation apiRequest == Full
|
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||||
then status200
|
then ([Just $ toHeader contentType, profileH], toS body)
|
||||||
else status204
|
else ([], mempty)
|
||||||
return $ if iPreferRepresentation apiRequest == Full
|
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
|
||||||
then responseLBS s [toHeader contentType, r] (toS body)
|
if contentType == CTSingularJSON && queryTotal /= 1
|
||||||
else responseLBS s [r] ""
|
then do
|
||||||
|
HT.condemn
|
||||||
|
return . errorResponseFor . singularityError $ queryTotal
|
||||||
|
else
|
||||||
|
return $ responseLBS status headers rBody
|
||||||
|
|
||||||
(ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just PayloadJSON{pjRaw, pjType, pjKeys}) ->
|
(ActionSingleUpsert, TargetIdent (QualifiedIdentifier tSchema tName), Just pJson) ->
|
||||||
case mutateSqlParts tSchema tName of
|
case mutateSqlParts tSchema tName of
|
||||||
Left errorResponse -> return errorResponse
|
Left errorResponse -> return errorResponse
|
||||||
Right (sq, mq) -> do
|
Right (sq, mq) ->
|
||||||
let isSingle = case pjType of
|
|
||||||
PJArray len -> len == 1
|
|
||||||
PJObject -> True
|
|
||||||
colNames = colName <$> tableCols dbStructure tSchema tName
|
|
||||||
if topLevelRange /= allRange
|
if topLevelRange /= allRange
|
||||||
then return $ simpleError status400 [] "Range header and limit/offset querystring parameters are not allowed for PUT"
|
then return . errorResponseFor $ PutRangeNotAllowedError
|
||||||
else if not isSingle
|
|
||||||
then return $ simpleError status400 [] "PUT payload must contain a single row"
|
|
||||||
else if S.fromList colNames /= pjKeys
|
|
||||||
then return $ simpleError status400 [] "You must specify all columns in the payload when using PUT"
|
|
||||||
else do
|
else do
|
||||||
row <- H.query (toS pjRaw) $
|
row <- H.statement (toS $ pjRaw pJson) $
|
||||||
createWriteStatement sq mq (contentType == CTSingularJSON) False
|
createWriteStatement sq mq (contentType == CTSingularJSON) False
|
||||||
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) []
|
(contentType == CTTextCSV) (iPreferRepresentation apiRequest) [] pgVer
|
||||||
let (_, queryTotal, _, body) = extractQueryResult row
|
let (_, queryTotal, _, body, gucHeaders) = row
|
||||||
-- Makes sure the querystring pk matches the payload pk
|
case gucHeaders of
|
||||||
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
|
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||||
-- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done
|
Right ghdrs -> do
|
||||||
if queryTotal /= 1
|
let headers = addHeadersIfNotIncluded (catMaybes [Just $ toHeader contentType, profileH]) (unwrapGucHeader <$> ghdrs)
|
||||||
then do
|
(status, rBody) = if iPreferRepresentation apiRequest == Full then (status200, toS body) else (status204, mempty)
|
||||||
HT.condemn
|
-- Makes sure the querystring pk matches the payload pk
|
||||||
return $ simpleError status400 [] "Payload values do not match URL in primary key column(s)"
|
-- e.g. PUT /items?id=eq.1 { "id" : 1, .. } is accepted, PUT /items?id=eq.14 { "id" : 2, .. } is rejected
|
||||||
else
|
-- If this condition is not satisfied then nothing is inserted, check the WHERE for INSERT in QueryBuilder.hs to see how it's done
|
||||||
return $ if iPreferRepresentation apiRequest == Full
|
if queryTotal /= 1
|
||||||
then responseLBS status200 [toHeader contentType] (toS body)
|
then do
|
||||||
else responseLBS status204 [] ""
|
HT.condemn
|
||||||
|
return . errorResponseFor $ PutMatchingPkError
|
||||||
|
else
|
||||||
|
return $ responseLBS status headers rBody
|
||||||
|
|
||||||
(ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
|
(ActionDelete, TargetIdent (QualifiedIdentifier tSchema tName), Nothing) ->
|
||||||
case mutateSqlParts tSchema tName of
|
case mutateSqlParts tSchema tName of
|
||||||
@@ -247,159 +252,168 @@ app dbStructure proc conf apiRequest =
|
|||||||
let stm = createWriteStatement sq mq
|
let stm = createWriteStatement sq mq
|
||||||
(contentType == CTSingularJSON) False
|
(contentType == CTSingularJSON) False
|
||||||
(contentType == CTTextCSV)
|
(contentType == CTTextCSV)
|
||||||
(iPreferRepresentation apiRequest) []
|
(iPreferRepresentation apiRequest) [] pgVer
|
||||||
row <- H.query mempty stm
|
row <- H.statement mempty stm
|
||||||
let (_, queryTotal, _, body) = extractQueryResult row
|
let (_, queryTotal, _, body, gucHeaders) = row
|
||||||
r = contentRangeH 1 0 $
|
case gucHeaders of
|
||||||
toInteger <$> if shouldCount then Just queryTotal else Nothing
|
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||||
if contentType == CTSingularJSON
|
Right ghdrs -> do
|
||||||
&& queryTotal /= 1
|
let
|
||||||
&& iPreferRepresentation apiRequest == Full
|
status = if iPreferRepresentation apiRequest == Full then status200 else status204
|
||||||
then do
|
contentRangeHeader = contentRangeH 1 0 $ if shouldCount then Just queryTotal else Nothing
|
||||||
HT.condemn
|
(ctHeaders, rBody) = if iPreferRepresentation apiRequest == Full
|
||||||
return $ singularityError (toInteger queryTotal)
|
then ([Just $ toHeader contentType, profileH], toS body)
|
||||||
else
|
else ([], mempty)
|
||||||
return $ if iPreferRepresentation apiRequest == Full
|
headers = addHeadersIfNotIncluded (catMaybes ctHeaders ++ [contentRangeHeader]) (unwrapGucHeader <$> ghdrs)
|
||||||
then responseLBS status200 [toHeader contentType, r] (toS body)
|
if contentType == CTSingularJSON
|
||||||
else responseLBS status204 [r] ""
|
&& queryTotal /= 1
|
||||||
|
then do
|
||||||
|
HT.condemn
|
||||||
|
return . errorResponseFor . singularityError $ queryTotal
|
||||||
|
else
|
||||||
|
return $ responseLBS status headers rBody
|
||||||
|
|
||||||
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->
|
(ActionInfo, TargetIdent (QualifiedIdentifier tSchema tTable), Nothing) ->
|
||||||
let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in
|
let mTable = find (\t -> tableName t == tTable && tableSchema t == tSchema) (dbTables dbStructure) in
|
||||||
case mTable of
|
case mTable of
|
||||||
Nothing -> return notFound
|
Nothing -> return notFound
|
||||||
Just table ->
|
Just table ->
|
||||||
let acceptH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET") in
|
let allowH = (hAllow, if tableInsertable table then "GET,POST,PATCH,DELETE" else "GET")
|
||||||
return $ responseLBS status200 [allOrigins, acceptH] ""
|
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header in
|
||||||
|
return $ responseLBS status200 [allOrigins, allowH] mempty
|
||||||
|
|
||||||
(ActionInvoke _, TargetProc qi, Just PayloadJSON{pjRaw, pjType, pjKeys}) ->
|
(ActionInvoke invMethod, TargetProc qi@(QualifiedIdentifier tSchema pName) _, Just pJson) ->
|
||||||
let returnsScalar = case proc of
|
let tName = fromMaybe pName $ procTableName =<< proc in
|
||||||
Just ProcDescription{pdReturnType = (Single (Scalar _))} -> True
|
case readSqlParts tSchema tName of
|
||||||
_ -> False
|
|
||||||
rpcBinaryField = if returnsScalar
|
|
||||||
then Right Nothing
|
|
||||||
else binaryField contentType =<< fldNames
|
|
||||||
parts = (,) <$> readSqlParts <*> rpcBinaryField in
|
|
||||||
case parts of
|
|
||||||
Left errorResponse -> return errorResponse
|
Left errorResponse -> return errorResponse
|
||||||
Right ((q, cq), bField) -> do
|
Right (q, cq, bField, returning) -> do
|
||||||
let isObject = case pjType of
|
let
|
||||||
PJObject -> True
|
preferParams = iPreferParameters apiRequest
|
||||||
PJArray _ -> False
|
pq = requestToCallProcQuery qi (specifiedProcArgs cols proc) returnsScalar preferParams returning
|
||||||
singular = contentType == CTSingularJSON
|
stm = callProcStatement returnsScalar pq q cq shouldCount (contentType == CTSingularJSON)
|
||||||
specifiedPgArgs = filter ((`S.member` pjKeys) . pgaName) $ fromMaybe [] (pdArgs <$> proc)
|
(contentType == CTTextCSV) (contentType `elem` rawContentTypes) (preferParams == Just MultipleObjects)
|
||||||
row <- H.query (toS pjRaw) $
|
bField pgVer
|
||||||
callProc qi specifiedPgArgs returnsScalar q cq shouldCount
|
row <- H.statement (toS $ pjRaw pJson) stm
|
||||||
singular (iPreferSingleObjectParameter apiRequest)
|
let (tableTotal, queryTotal, body, gucHeaders) = row
|
||||||
(contentType == CTTextCSV)
|
case gucHeaders of
|
||||||
(contentType == CTOctetStream) bField isObject
|
Left _ -> return . errorResponseFor $ GucHeadersError
|
||||||
(pgVersion dbStructure)
|
Right ghdrs -> do
|
||||||
let (tableTotal, queryTotal, body, jsonHeaders) =
|
let (status, contentRange) = rangeStatusHeader topLevelRange queryTotal tableTotal
|
||||||
fromMaybe (Just 0, 0, "[]", "[]") row
|
headers = addHeadersIfNotIncluded
|
||||||
(status, contentRange) = rangeHeader queryTotal tableTotal
|
(catMaybes [Just $ toHeader contentType, Just contentRange, profileH])
|
||||||
decodedHeaders = first toS $ JSON.eitherDecode $ toS jsonHeaders :: Either Text [GucHeader]
|
(unwrapGucHeader <$> ghdrs)
|
||||||
case decodedHeaders of
|
rBody = if invMethod == InvHead then mempty else toS body
|
||||||
Left _ -> return gucHeadersError
|
if contentType == CTSingularJSON && queryTotal /= 1
|
||||||
Right hs ->
|
|
||||||
if singular && queryTotal /= 1
|
|
||||||
then do
|
then do
|
||||||
HT.condemn
|
HT.condemn
|
||||||
return $ singularityError (toInteger queryTotal)
|
return . errorResponseFor . singularityError $ queryTotal
|
||||||
else return $ responseLBS status ([toHeader contentType, contentRange] ++ toHeaders hs) (toS body)
|
else
|
||||||
|
return $ responseLBS status headers rBody
|
||||||
|
|
||||||
(ActionInspect, TargetRoot, Nothing) -> do
|
(ActionInspect headersOnly, TargetDefaultSpec tSchema, Nothing) -> do
|
||||||
let host = configHost conf
|
let host = configHost conf
|
||||||
port = toInteger $ configPort conf
|
port = toInteger $ configPort conf
|
||||||
proxy = pickProxy $ toS <$> configProxyUri conf
|
proxy = pickProxy $ toS <$> configOpenAPIProxyUri conf
|
||||||
uri Nothing = ("http", host, port, "/")
|
uri Nothing = ("http", host, port, "/")
|
||||||
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
|
uri (Just Proxy { proxyScheme = s, proxyHost = h, proxyPort = p, proxyPath = b }) = (s, h, p, b)
|
||||||
uri' = uri proxy
|
uri' = uri proxy
|
||||||
toTableInfo :: [Table] -> [(Table, [Column], [Text])]
|
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))
|
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
|
encodeApi ti sd procs = encodeOpenAPI (concat $ M.elems procs) (toTableInfo ti) uri' sd $ dbPrimaryKeys dbStructure
|
||||||
body <- encodeApi <$> H.query schema accessibleTables <*> H.query schema schemaDescription <*> H.query schema accessibleProcs
|
|
||||||
return $ responseLBS status200 [toHeader CTOpenAPI] $ toS body
|
body <- encodeApi <$>
|
||||||
|
H.statement tSchema accessibleTables <*>
|
||||||
|
H.statement tSchema schemaDescription <*>
|
||||||
|
H.statement tSchema accessibleProcs
|
||||||
|
return $ responseLBS status200 (catMaybes [Just $ toHeader CTOpenAPI, profileH]) (if headersOnly then mempty else toS body)
|
||||||
|
|
||||||
_ -> return notFound
|
_ -> return notFound
|
||||||
|
|
||||||
where
|
where
|
||||||
notFound = responseLBS status404 [] ""
|
notFound = responseLBS status404 [] ""
|
||||||
allOrigins = ("Access-Control-Allow-Origin", "*") :: Header
|
maxRows = configMaxRows conf
|
||||||
shouldCount = iPreferCount apiRequest
|
exactCount = iPreferCount apiRequest == Just ExactCount
|
||||||
schema = toS $ configSchema conf
|
estimatedCount = iPreferCount apiRequest == Just EstimatedCount
|
||||||
topLevelRange = fromMaybe allRange $ M.lookup "limit" $ iRange apiRequest
|
plannedCount = iPreferCount apiRequest == Just PlannedCount
|
||||||
rangeHeader queryTotal tableTotal =
|
shouldCount = exactCount || estimatedCount
|
||||||
let lower = rangeOffset topLevelRange
|
topLevelRange = iTopLevelRange apiRequest
|
||||||
upper = lower + toInteger queryTotal - 1
|
returnsScalar = maybe False procReturnsScalar proc
|
||||||
contentRange = contentRangeH lower upper (toInteger <$> tableTotal)
|
pgVer = pgVersion dbStructure
|
||||||
status = rangeStatus lower upper (toInteger <$> tableTotal)
|
profileH = contentProfileH <$> iProfile apiRequest
|
||||||
in (status, contentRange)
|
|
||||||
|
|
||||||
readReq = readRequest (configMaxRows conf) (dbRelations dbStructure) proc apiRequest
|
readSqlParts s t =
|
||||||
fldNames = fieldNames <$> readReq
|
let
|
||||||
readDbRequest = DbRead <$> readReq
|
readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest
|
||||||
selectQuery = requestToQuery schema False <$> readDbRequest
|
returnings :: ReadRequest -> Either Response [FieldName]
|
||||||
countQuery = requestToCountQuery schema <$> readDbRequest
|
returnings rr = Right (returningCols rr)
|
||||||
readSqlParts = (,) <$> selectQuery <*> countQuery
|
in
|
||||||
mutateSqlParts s t =
|
(,,,) <$>
|
||||||
(,) <$> selectQuery
|
(readRequestToQuery <$> readReq) <*>
|
||||||
<*> (requestToQuery schema False . DbMutate <$> (mutateRequest apiRequest t (tablePKCols dbStructure s t) =<< fldNames))
|
(readRequestToCountQuery <$> readReq) <*>
|
||||||
|
(binaryField contentType rawContentTypes returnsScalar =<< readReq) <*>
|
||||||
|
(returnings =<< readReq)
|
||||||
|
|
||||||
responseContentTypeOrError :: [ContentType] -> Action -> Either Response ContentType
|
mutateSqlParts s t =
|
||||||
responseContentTypeOrError accepts action = serves contentTypesForRequest accepts
|
let
|
||||||
|
readReq = readRequest s t maxRows (dbRelations dbStructure) apiRequest
|
||||||
|
mutReq = mutateRequest s t apiRequest cols (tablePKCols dbStructure s t) =<< readReq
|
||||||
|
in
|
||||||
|
(,) <$>
|
||||||
|
(readRequestToQuery <$> readReq) <*>
|
||||||
|
(mutateRequestToQuery <$> mutReq)
|
||||||
|
|
||||||
|
responseContentTypeOrError :: [ContentType] -> [ContentType] -> Action -> Target -> Either Response ContentType
|
||||||
|
responseContentTypeOrError accepts rawContentTypes action target = serves contentTypesForRequest accepts
|
||||||
where
|
where
|
||||||
contentTypesForRequest =
|
contentTypesForRequest = case action of
|
||||||
case action of
|
ActionRead _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionRead -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream]
|
++ rawContentTypes
|
||||||
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionCreate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionUpdate -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionDelete -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV, CTOctetStream]
|
ActionInvoke _ -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
ActionInspect -> [CTOpenAPI, CTApplicationJSON]
|
++ rawContentTypes
|
||||||
ActionInfo -> [CTTextCSV]
|
++ [CTOpenAPI | tpIsRootSpec target]
|
||||||
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
ActionInspect _ -> [CTOpenAPI, CTApplicationJSON]
|
||||||
|
ActionInfo -> [CTTextCSV]
|
||||||
|
ActionSingleUpsert -> [CTApplicationJSON, CTSingularJSON, CTTextCSV]
|
||||||
serves sProduces cAccepts =
|
serves sProduces cAccepts =
|
||||||
case mutuallyAgreeable sProduces cAccepts of
|
case mutuallyAgreeable sProduces cAccepts of
|
||||||
Nothing -> do
|
Nothing -> Left . errorResponseFor . ContentTypeError . map toMime $ cAccepts
|
||||||
let failed = intercalate ", " $ map (toS . toMime) cAccepts
|
|
||||||
Left $ simpleError status415 [] $
|
|
||||||
"None of these Content-Types are available: " <> failed
|
|
||||||
Just ct -> Right ct
|
Just ct -> Right ct
|
||||||
|
|
||||||
binaryField :: ContentType -> [FieldName] -> Either Response (Maybe FieldName)
|
{-
|
||||||
binaryField CTOctetStream fldNames =
|
| If raw(binary) output is requested, check that ContentType is one of the admitted rawContentTypes and that
|
||||||
if length fldNames == 1 && fieldName /= Just "*"
|
| `?select=...` contains only one field other than `*`
|
||||||
then Right fieldName
|
-}
|
||||||
else Left binaryFieldError
|
binaryField :: ContentType -> [ContentType] -> Bool -> ReadRequest -> Either Response (Maybe FieldName)
|
||||||
|
binaryField ct rawContentTypes isScalarProc readReq
|
||||||
|
| isScalarProc = Right Nothing
|
||||||
|
| 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
|
||||||
where
|
where
|
||||||
fieldName = headMay fldNames
|
fldNames = fstFieldNames readReq
|
||||||
binaryField _ _ = Right Nothing
|
|
||||||
|
|
||||||
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
|
locationH :: TableName -> [BS.ByteString] -> Header
|
||||||
splitKeyValue kv = (k, BS.tail v)
|
locationH tName fields =
|
||||||
where (k, v) = BS.break (== '=') kv
|
let
|
||||||
|
locationFields = renderSimpleQuery True $ splitKeyValue <$> fields
|
||||||
|
in
|
||||||
|
(hLocation, "/" <> toS tName <> locationFields)
|
||||||
|
where
|
||||||
|
splitKeyValue :: BS.ByteString -> (BS.ByteString, BS.ByteString)
|
||||||
|
splitKeyValue kv =
|
||||||
|
let (k, v) = BS.break (== '=') kv
|
||||||
|
in (k, BS.tail v)
|
||||||
|
|
||||||
renderLocationFields :: [BS.ByteString] -> BS.ByteString
|
contentLocationH :: TableName -> ByteString -> Header
|
||||||
renderLocationFields fields =
|
contentLocationH tName qString =
|
||||||
renderSimpleQuery True $ map splitKeyValue fields
|
("Content-Location", "/" <> toS tName <> if BS.null qString then mempty else "?" <> toS qString)
|
||||||
|
|
||||||
rangeStatus :: Integer -> Integer -> Maybe Integer -> Status
|
contentProfileH :: Schema -> Header
|
||||||
rangeStatus _ _ Nothing = status200
|
contentProfileH schema =
|
||||||
rangeStatus lower upper (Just total)
|
("Content-Profile", toS schema)
|
||||||
| lower > total = status416
|
|
||||||
| (1 + upper - lower) < total = status206
|
|
||||||
| otherwise = status200
|
|
||||||
|
|
||||||
contentRangeH :: Integer -> Integer -> Maybe Integer -> Header
|
|
||||||
contentRangeH lower upper total =
|
|
||||||
("Content-Range", headerValue)
|
|
||||||
where
|
|
||||||
headerValue = rangeString <> "/" <> totalString
|
|
||||||
rangeString
|
|
||||||
| totalNotZero && fromInRange = show lower <> "-" <> show upper
|
|
||||||
| otherwise = "*"
|
|
||||||
totalString = fromMaybe "*" (show <$> total)
|
|
||||||
totalNotZero = fromMaybe True ((/=) 0 <$> total)
|
|
||||||
fromInRange = lower <= upper
|
|
||||||
|
|
||||||
extractQueryResult :: Maybe ResultsWithCount -> ResultsWithCount
|
|
||||||
extractQueryResult = fromMaybe (Nothing, 0, [], "")
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{-# LANGUAGE FlexibleContexts #-}
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
{-# LANGUAGE LambdaCase #-}
|
{-# LANGUAGE LambdaCase #-}
|
||||||
{-|
|
{-|
|
||||||
Module : PostgREST.Auth
|
Module : PostgREST.Auth
|
||||||
Description : PostgREST authorization functions.
|
Description : PostgREST authorization functions.
|
||||||
@@ -16,20 +16,23 @@ module PostgREST.Auth (
|
|||||||
containsRole
|
containsRole
|
||||||
, jwtClaims
|
, jwtClaims
|
||||||
, JWTAttempt(..)
|
, JWTAttempt(..)
|
||||||
, parseJWK
|
, parseSecret
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Lens.Operators
|
import qualified Crypto.JOSE.Types as JOSE.Types
|
||||||
import Control.Lens (set)
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.HashMap.Strict as M
|
||||||
import qualified Data.HashMap.Strict as M
|
import Data.Vector as V
|
||||||
import Data.Time.Clock (UTCTime)
|
|
||||||
import Data.Vector as V
|
|
||||||
import PostgREST.Types
|
|
||||||
import Protolude
|
|
||||||
|
|
||||||
import qualified Crypto.JOSE.Types as JOSE.Types
|
import Control.Lens (set)
|
||||||
import Crypto.JWT
|
import Data.Time.Clock (UTCTime)
|
||||||
|
|
||||||
|
import Control.Lens.Operators
|
||||||
|
import Crypto.JWT
|
||||||
|
|
||||||
|
import PostgREST.Types
|
||||||
|
import Protolude hiding (toS)
|
||||||
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
Possible situations encountered with client JWTs
|
Possible situations encountered with client JWTs
|
||||||
@@ -43,7 +46,7 @@ data JWTAttempt = JWTInvalid JWTError
|
|||||||
Receives the JWT secret and audience (from config) and a JWT and returns a map
|
Receives the JWT secret and audience (from config) and a JWT and returns a map
|
||||||
of JWT claims.
|
of JWT claims.
|
||||||
-}
|
-}
|
||||||
jwtClaims :: Maybe JWK -> Maybe StringOrURI -> LByteString -> UTCTime -> Maybe JSPath -> IO JWTAttempt
|
jwtClaims :: Maybe JWKSet -> Maybe StringOrURI -> LByteString -> UTCTime -> Maybe JSPath -> IO JWTAttempt
|
||||||
jwtClaims _ _ "" _ _ = return $ JWTClaims M.empty
|
jwtClaims _ _ "" _ _ = return $ JWTClaims M.empty
|
||||||
jwtClaims secret audience payload time jspath =
|
jwtClaims secret audience payload time jspath =
|
||||||
case secret of
|
case secret of
|
||||||
@@ -83,19 +86,29 @@ containsRole :: JWTAttempt -> Bool
|
|||||||
containsRole (JWTClaims claims) = M.member "role" claims
|
containsRole (JWTClaims claims) = M.member "role" claims
|
||||||
containsRole _ = False
|
containsRole _ = False
|
||||||
|
|
||||||
parseJWK :: ByteString -> JWK
|
{-|
|
||||||
parseJWK str =
|
Parse `jwt-secret` configuration option and turn into a JWKSet.
|
||||||
fromMaybe (hs256jwk str) (JSON.decode (toS str) :: Maybe JWK)
|
|
||||||
|
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]
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
Internal helper to generate HMAC-SHA256. When the jwt key in the
|
Internal helper to generate a symmetric HMAC-SHA256 JWK from a text secret.
|
||||||
config file is a simple string rather than a JWK object, we'll
|
|
||||||
apply this function to it.
|
|
||||||
-}
|
-}
|
||||||
hs256jwk :: ByteString -> JWK
|
jwkFromSecret :: ByteString -> JWK
|
||||||
hs256jwk key =
|
jwkFromSecret key =
|
||||||
fromKeyMaterial km
|
fromKeyMaterial km
|
||||||
& jwkUse .~ Just Sig
|
& jwkUse ?~ Sig
|
||||||
& jwkAlg .~ (Just $ JWSAlg HS256)
|
& jwkAlg ?~ JWSAlg HS256
|
||||||
where
|
where
|
||||||
km = OctKeyMaterial (OctKeyParameters (JOSE.Types.Base64Octets key))
|
km = OctKeyMaterial (OctKeyParameters (JOSE.Types.Base64Octets key))
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
{-# LANGUAGE LambdaCase, TemplateHaskell #-}
|
|
||||||
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
|
|
||||||
{-|
|
{-|
|
||||||
Module : PostgREST.Config
|
Module : PostgREST.Config
|
||||||
Description : Manages PostgREST configuration options.
|
Description : Manages PostgREST configuration options.
|
||||||
@@ -14,78 +12,95 @@ turned in configurable behaviour if needed.
|
|||||||
|
|
||||||
Other hardcoded options such as the minimum version number also belong here.
|
Other hardcoded options such as the minimum version number also belong here.
|
||||||
-}
|
-}
|
||||||
|
{-# LANGUAGE LambdaCase #-}
|
||||||
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
|
{-# LANGUAGE TemplateHaskell #-}
|
||||||
|
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
|
||||||
|
|
||||||
module PostgREST.Config ( prettyVersion
|
module PostgREST.Config ( prettyVersion
|
||||||
, docsVersion
|
, docsVersion
|
||||||
, readOptions
|
, readOptions
|
||||||
, corsPolicy
|
, corsPolicy
|
||||||
, minimumPgVersion
|
|
||||||
, pgVersion95
|
|
||||||
, pgVersion96
|
|
||||||
, AppConfig (..)
|
, AppConfig (..)
|
||||||
|
, configPoolTimeout'
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
|
|
||||||
import Control.Applicative
|
|
||||||
import Control.Monad (fail)
|
|
||||||
import Control.Lens (preview)
|
|
||||||
import Crypto.JWT (StringOrURI,
|
|
||||||
stringOrUri)
|
|
||||||
import qualified Data.ByteString as B
|
import qualified Data.ByteString as B
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import qualified Data.ByteString.Char8 as BS
|
||||||
import qualified Data.CaseInsensitive as CI
|
import qualified Data.CaseInsensitive as CI
|
||||||
import qualified Data.Configurator as C
|
import qualified Data.Configurator as C
|
||||||
import qualified Data.Configurator.Parser as C
|
|
||||||
import Data.Configurator.Types as C
|
|
||||||
import Data.List (lookup)
|
|
||||||
import Data.Monoid
|
|
||||||
import Data.Scientific (floatingOrInteger)
|
|
||||||
import Data.String (String)
|
|
||||||
import Data.Text (dropAround,
|
|
||||||
intercalate, lines,
|
|
||||||
strip, take)
|
|
||||||
import Data.Text.Encoding (encodeUtf8)
|
|
||||||
import Data.Text.IO (hPutStrLn)
|
|
||||||
import Data.Version (versionBranch)
|
|
||||||
import Development.GitRev (gitHash)
|
|
||||||
import Network.Wai
|
|
||||||
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
|
||||||
import Options.Applicative hiding (str)
|
|
||||||
import Paths_postgrest (version)
|
|
||||||
import PostgREST.Parsers (pRoleClaimKey)
|
|
||||||
import PostgREST.Types (PgVersion(..), ApiRequestError(..),
|
|
||||||
JSPath, JSPathExp(..))
|
|
||||||
import Protolude hiding (hPutStrLn, take,
|
|
||||||
intercalate, (<>))
|
|
||||||
import System.IO (hPrint)
|
|
||||||
import System.IO.Error (IOError)
|
|
||||||
import Text.Heredoc
|
|
||||||
import Text.PrettyPrint.ANSI.Leijen hiding ((<$>), (<>))
|
|
||||||
import qualified Text.PrettyPrint.ANSI.Leijen as L
|
import qualified Text.PrettyPrint.ANSI.Leijen as L
|
||||||
|
|
||||||
|
import Control.Lens (preview)
|
||||||
|
import Control.Monad (fail)
|
||||||
|
import Crypto.JWT (StringOrURI, stringOrUri)
|
||||||
|
import Data.List (lookup)
|
||||||
|
import Data.List.NonEmpty (fromList)
|
||||||
|
import Data.Scientific (floatingOrInteger)
|
||||||
|
import Data.Text (dropEnd, dropWhileEnd,
|
||||||
|
intercalate, splitOn, strip, take,
|
||||||
|
unpack)
|
||||||
|
import Data.Text.IO (hPutStrLn)
|
||||||
|
import Data.Version (versionBranch)
|
||||||
|
import Development.GitRev (gitHash)
|
||||||
|
import Network.Wai.Middleware.Cors (CorsResourcePolicy (..))
|
||||||
|
import Numeric (readOct)
|
||||||
|
import Paths_postgrest (version)
|
||||||
|
import System.IO.Error (IOError)
|
||||||
|
import System.Posix.Types (FileMode)
|
||||||
|
|
||||||
|
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 (..))
|
||||||
|
import Protolude hiding (concat, hPutStrLn, intercalate, null,
|
||||||
|
take, toS, (<>))
|
||||||
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
|
|
||||||
-- | Config file settings for the server
|
-- | Config file settings for the server
|
||||||
data AppConfig = AppConfig {
|
data AppConfig = AppConfig {
|
||||||
configDatabase :: Text
|
configDatabase :: Text
|
||||||
, configAnonRole :: Text
|
, configAnonRole :: Text
|
||||||
, configProxyUri :: Maybe Text
|
, configOpenAPIProxyUri :: Maybe Text
|
||||||
, configSchema :: Text
|
, configSchemas :: NonEmpty Text
|
||||||
, configHost :: Text
|
, configHost :: Text
|
||||||
, configPort :: Int
|
, configPort :: Int
|
||||||
|
, configSocket :: Maybe FilePath
|
||||||
|
, configSocketMode :: Either Text FileMode
|
||||||
|
|
||||||
, configJwtSecret :: Maybe B.ByteString
|
, configJwtSecret :: Maybe B.ByteString
|
||||||
, configJwtSecretIsBase64 :: Bool
|
, configJwtSecretIsBase64 :: Bool
|
||||||
, configJwtAudience :: Maybe StringOrURI
|
, configJwtAudience :: Maybe StringOrURI
|
||||||
|
|
||||||
, configPool :: Int
|
, configPool :: Int
|
||||||
|
, configPoolTimeout :: Int
|
||||||
, configMaxRows :: Maybe Integer
|
, configMaxRows :: Maybe Integer
|
||||||
, configReqCheck :: Maybe Text
|
, configReqCheck :: Maybe Text
|
||||||
, configQuiet :: Bool
|
, configQuiet :: Bool
|
||||||
, configSettings :: [(Text, Text)]
|
, configSettings :: [(Text, Text)]
|
||||||
, configRoleClaimKey :: Either ApiRequestError JSPath
|
, configRoleClaimKey :: Either ApiRequestError JSPath
|
||||||
|
, configExtraSearchPath :: [Text]
|
||||||
|
|
||||||
|
, configRootSpec :: Maybe Text
|
||||||
|
, configRawMediaTypes :: [B.ByteString]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configPoolTimeout' :: (Fractional a) => AppConfig -> a
|
||||||
|
configPoolTimeout' =
|
||||||
|
fromRational . toRational . configPoolTimeout
|
||||||
|
|
||||||
|
|
||||||
defaultCorsPolicy :: CorsResourcePolicy
|
defaultCorsPolicy :: CorsResourcePolicy
|
||||||
defaultCorsPolicy = CorsResourcePolicy Nothing
|
defaultCorsPolicy = CorsResourcePolicy Nothing
|
||||||
["GET", "POST", "PATCH", "DELETE", "OPTIONS"] ["Authorization"] Nothing
|
["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] ["Authorization"] Nothing
|
||||||
(Just $ 60*60*24) False False True
|
(Just $ 60*60*24) False False True
|
||||||
|
|
||||||
-- | CORS policy to be used in by Wai Cors middleware
|
-- | CORS policy to be used in by Wai Cors middleware
|
||||||
@@ -114,7 +129,7 @@ prettyVersion =
|
|||||||
|
|
||||||
-- | Version number used in docs
|
-- | Version number used in docs
|
||||||
docsVersion :: Text
|
docsVersion :: Text
|
||||||
docsVersion = "v" <> dropAround (== '.') (dropAround (/= '.') prettyVersion)
|
docsVersion = "v" <> dropEnd 1 (dropWhileEnd (/= '.') prettyVersion)
|
||||||
|
|
||||||
-- | Function to read and parse options from the command line
|
-- | Function to read and parse options from the command line
|
||||||
readOptions :: IO AppConfig
|
readOptions :: IO AppConfig
|
||||||
@@ -122,62 +137,103 @@ readOptions = do
|
|||||||
-- First read the config file path from command line
|
-- First read the config file path from command line
|
||||||
cfgPath <- customExecParser parserPrefs opts
|
cfgPath <- customExecParser parserPrefs opts
|
||||||
-- Now read the actual config file
|
-- Now read the actual config file
|
||||||
conf <- catch
|
conf <- catches (C.load cfgPath)
|
||||||
(C.readConfig =<< C.load [C.Required cfgPath])
|
[ Handler (\(ex :: IOError) -> exitErr $ "Cannot open config file:\n\t" <> show ex)
|
||||||
configNotfoundHint
|
, Handler (\(C.ParseError err) -> exitErr $ "Error parsing config file:\n" <> err)
|
||||||
|
]
|
||||||
|
|
||||||
let (mAppConf, errs) = flip C.runParserM conf $
|
case C.runParser parseConfig conf of
|
||||||
AppConfig
|
Left err ->
|
||||||
<$> C.key "db-uri"
|
exitErr $ "Error parsing config file:\n\t" <> err
|
||||||
<*> C.key "db-anon-role"
|
Right appConf ->
|
||||||
<*> (mfilter (/= "") <$> C.key "server-proxy-uri")
|
|
||||||
<*> C.key "db-schema"
|
|
||||||
<*> (fromMaybe "127.0.0.1" . mfilter (/= "") <$> C.key "server-host")
|
|
||||||
<*> (fromMaybe 3000 . join . fmap coerceInt <$> C.key "server-port")
|
|
||||||
<*> (fmap encodeUtf8 . mfilter (/= "") <$> C.key "jwt-secret")
|
|
||||||
<*> (fromMaybe False . join . fmap coerceBool <$> C.key "secret-is-base64")
|
|
||||||
<*> parseJwtAudience "jwt-aud"
|
|
||||||
<*> (fromMaybe 10 . join . fmap coerceInt <$> C.key "db-pool")
|
|
||||||
<*> (join . fmap coerceInt <$> C.key "max-rows")
|
|
||||||
<*> (mfilter (/= "") <$> C.key "pre-request")
|
|
||||||
<*> pure False
|
|
||||||
<*> (fmap (fmap coerceText) <$> C.subassocs "app.settings")
|
|
||||||
<*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> C.key "role-claim-key")
|
|
||||||
|
|
||||||
case mAppConf of
|
|
||||||
Nothing -> do
|
|
||||||
forM_ errs $ hPrint stderr
|
|
||||||
exitFailure
|
|
||||||
Just appConf ->
|
|
||||||
return appConf
|
return appConf
|
||||||
|
|
||||||
where
|
where
|
||||||
parseJwtAudience :: Name -> C.ConfigParserM (Maybe StringOrURI)
|
parseConfig =
|
||||||
|
AppConfig
|
||||||
|
<$> reqString "db-uri"
|
||||||
|
<*> reqString "db-anon-role"
|
||||||
|
<*> optString "openapi-server-proxy-uri"
|
||||||
|
<*> (fromList . splitOnCommas <$> reqValue "db-schema")
|
||||||
|
<*> (fromMaybe "!4" <$> optString "server-host")
|
||||||
|
<*> (fromMaybe 3000 <$> optInt "server-port")
|
||||||
|
<*> (fmap unpack <$> optString "server-unix-socket")
|
||||||
|
<*> parseSocketFileMode "server-unix-socket-mode"
|
||||||
|
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
|
||||||
|
<*> (fromMaybe False <$> optBool "secret-is-base64")
|
||||||
|
<*> parseJwtAudience "jwt-aud"
|
||||||
|
<*> (fromMaybe 10 <$> optInt "db-pool")
|
||||||
|
<*> (fromMaybe 10 <$> optInt "db-pool-timeout")
|
||||||
|
<*> optInt "max-rows"
|
||||||
|
<*> optString "pre-request"
|
||||||
|
<*> pure False
|
||||||
|
<*> (fmap (fmap coerceText) <$> C.subassocs "app.settings" C.value)
|
||||||
|
<*> (maybe (Right [JSPKey "role"]) parseRoleClaimKey <$> optValue "role-claim-key")
|
||||||
|
<*> (maybe ["public"] splitOnCommas <$> optValue "db-extra-search-path")
|
||||||
|
<*> optString "root-spec"
|
||||||
|
<*> (maybe [] (fmap encodeUtf8 . splitOnCommas) <$> optValue "raw-media-types")
|
||||||
|
|
||||||
|
parseSocketFileMode :: C.Key -> C.Parser C.Config (Either Text FileMode)
|
||||||
|
parseSocketFileMode k =
|
||||||
|
C.optional k C.string >>= \case
|
||||||
|
Nothing -> pure $ Right 432 -- return default 660 mode if no value was provided
|
||||||
|
Just fileModeText ->
|
||||||
|
case (readOct . unpack) fileModeText of
|
||||||
|
[] ->
|
||||||
|
pure $ Left "Invalid server-unix-socket-mode: not an octal"
|
||||||
|
(fileMode, _):_ ->
|
||||||
|
if fileMode < 384 || fileMode > 511
|
||||||
|
then pure $ Left "Invalid server-unix-socket-mode: needs to be between 600 and 777"
|
||||||
|
else pure $ Right fileMode
|
||||||
|
|
||||||
|
parseJwtAudience :: C.Key -> C.Parser C.Config (Maybe StringOrURI)
|
||||||
parseJwtAudience k =
|
parseJwtAudience k =
|
||||||
C.key k >>= \case
|
C.optional k C.string >>= \case
|
||||||
Nothing -> pure Nothing -- no audience in config file
|
Nothing -> pure Nothing -- no audience in config file
|
||||||
Just aud -> case preview stringOrUri (aud :: String) of
|
Just aud -> case preview stringOrUri (unpack aud) of
|
||||||
Nothing -> fail "Invalid Jwt audience. Check your configuration."
|
Nothing -> fail "Invalid Jwt audience. Check your configuration."
|
||||||
(Just "") -> pure Nothing
|
(Just "") -> pure Nothing
|
||||||
aud' -> pure aud'
|
aud' -> pure aud'
|
||||||
|
|
||||||
coerceText :: Value -> Text
|
reqString :: C.Key -> C.Parser C.Config Text
|
||||||
coerceText (String s) = s
|
reqString k = C.required k C.string
|
||||||
coerceText v = show v
|
|
||||||
|
|
||||||
coerceInt :: (Read i, Integral i) => Value -> Maybe i
|
reqValue :: C.Key -> C.Parser C.Config C.Value
|
||||||
coerceInt (Number x) = rightToMaybe $ floatingOrInteger x
|
reqValue k = C.required k C.value
|
||||||
coerceInt (String x) = readMaybe $ toS x
|
|
||||||
coerceInt _ = Nothing
|
|
||||||
|
|
||||||
coerceBool :: Value -> Maybe Bool
|
optString :: C.Key -> C.Parser C.Config (Maybe Text)
|
||||||
coerceBool (Bool b) = Just b
|
optString k = mfilter (/= "") <$> C.optional k C.string
|
||||||
coerceBool (String b) = readMaybe $ toS b
|
|
||||||
coerceBool _ = Nothing
|
|
||||||
|
|
||||||
parseRoleClaimKey :: Value -> Either ApiRequestError JSPath
|
optValue :: C.Key -> C.Parser C.Config (Maybe C.Value)
|
||||||
parseRoleClaimKey (String s) = pRoleClaimKey s
|
optValue k = C.optional k C.value
|
||||||
parseRoleClaimKey v = pRoleClaimKey $ show v
|
|
||||||
|
optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)
|
||||||
|
optInt k = join <$> C.optional k (coerceInt <$> C.value)
|
||||||
|
|
||||||
|
optBool :: C.Key -> C.Parser C.Config (Maybe Bool)
|
||||||
|
optBool k = join <$> C.optional k (coerceBool <$> C.value)
|
||||||
|
|
||||||
|
coerceText :: C.Value -> Text
|
||||||
|
coerceText (C.String s) = s
|
||||||
|
coerceText v = show v
|
||||||
|
|
||||||
|
coerceInt :: (Read i, Integral i) => C.Value -> Maybe i
|
||||||
|
coerceInt (C.Number x) = rightToMaybe $ floatingOrInteger x
|
||||||
|
coerceInt (C.String x) = readMaybe $ toS x
|
||||||
|
coerceInt _ = Nothing
|
||||||
|
|
||||||
|
coerceBool :: C.Value -> Maybe Bool
|
||||||
|
coerceBool (C.Bool b) = Just b
|
||||||
|
coerceBool (C.String b) = readMaybe $ toS b
|
||||||
|
coerceBool _ = Nothing
|
||||||
|
|
||||||
|
parseRoleClaimKey :: C.Value -> Either ApiRequestError JSPath
|
||||||
|
parseRoleClaimKey (C.String s) = pRoleClaimKey s
|
||||||
|
parseRoleClaimKey v = pRoleClaimKey $ show v
|
||||||
|
|
||||||
|
splitOnCommas :: C.Value -> [Text]
|
||||||
|
splitOnCommas (C.String s) = strip <$> splitOn "," s
|
||||||
|
splitOnCommas _ = []
|
||||||
|
|
||||||
opts = info (helper <*> pathParser) $
|
opts = info (helper <*> pathParser) $
|
||||||
fullDesc
|
fullDesc
|
||||||
@@ -193,28 +249,35 @@ readOptions = do
|
|||||||
|
|
||||||
parserPrefs = prefs showHelpOnError
|
parserPrefs = prefs showHelpOnError
|
||||||
|
|
||||||
configNotfoundHint :: IOError -> IO a
|
exitErr :: Text -> IO a
|
||||||
configNotfoundHint e = do
|
exitErr err = do
|
||||||
hPutStrLn stderr $
|
hPutStrLn stderr err
|
||||||
"Cannot open config file:\n\t" <> show e
|
|
||||||
exitFailure
|
exitFailure
|
||||||
|
|
||||||
exampleCfg :: Doc
|
exampleCfg :: Doc
|
||||||
exampleCfg = vsep . map (text . toS) . lines $
|
exampleCfg = vsep . map (text . toS) . lines $
|
||||||
[str|db-uri = "postgres://user:pass@localhost:5432/dbname"
|
[str|db-uri = "postgres://user:pass@localhost:5432/dbname"
|
||||||
|db-schema = "public"
|
|db-schema = "public" # this schema gets added to the search_path of every request
|
||||||
|db-anon-role = "postgres"
|
|db-anon-role = "postgres"
|
||||||
|db-pool = 10
|
|db-pool = 10
|
||||||
|
|db-pool-timeout = 10
|
||||||
|
|
|
|
||||||
|server-host = "127.0.0.1"
|
|server-host = "!4"
|
||||||
|server-port = 3000
|
|server-port = 3000
|
||||||
|
|
|
|
||||||
|## base url for swagger output
|
|## unix socket location
|
||||||
|# server-proxy-uri = ""
|
|## if specified it takes precedence over server-port
|
||||||
|
|# server-unix-socket = "/tmp/pgrst.sock"
|
||||||
|
|## unix socket file mode
|
||||||
|
|## when none is provided, 660 is applied by default
|
||||||
|
|# server-unix-socket-mode = "660"
|
||||||
|
|
|
|
||||||
|## choose a secret to enable JWT auth
|
|## base url for swagger output
|
||||||
|
|# openapi-server-proxy-uri = ""
|
||||||
|
|
|
||||||
|
|## choose a secret, JSON Web Key (or set) to enable JWT auth
|
||||||
|## (use "@filename" to load from separate file)
|
|## (use "@filename" to load from separate file)
|
||||||
|# jwt-secret = "foo"
|
|# jwt-secret = "secret_with_at_least_32_characters"
|
||||||
|# secret-is-base64 = false
|
|# secret-is-base64 = false
|
||||||
|# jwt-aud = "your_audience_claim"
|
|# jwt-aud = "your_audience_claim"
|
||||||
|
|
|
|
||||||
@@ -226,6 +289,16 @@ readOptions = do
|
|||||||
|
|
|
|
||||||
|## jspath to the role claim key
|
|## jspath to the role claim key
|
||||||
|# role-claim-key = ".role"
|
|# role-claim-key = ".role"
|
||||||
|
|
|
||||||
|
|## extra schemas to add to the search_path of every request
|
||||||
|
|# db-extra-search-path = "extensions, util"
|
||||||
|
|
|
||||||
|
|## stored proc that overrides the root "/" spec
|
||||||
|
|## it must be inside the db-schema
|
||||||
|
|# root-spec = "stored_proc_name"
|
||||||
|
|
|
||||||
|
|## content types to produce raw output
|
||||||
|
|# raw-media-types="image/png, image/jpg"
|
||||||
|]
|
|]
|
||||||
|
|
||||||
pathParser :: Parser FilePath
|
pathParser :: Parser FilePath
|
||||||
@@ -233,13 +306,3 @@ pathParser =
|
|||||||
strArgument $
|
strArgument $
|
||||||
metavar "FILENAME" <>
|
metavar "FILENAME" <>
|
||||||
help "Path to configuration file"
|
help "Path to configuration file"
|
||||||
|
|
||||||
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
|
|
||||||
minimumPgVersion :: PgVersion
|
|
||||||
minimumPgVersion = PgVersion 90400 "9.4"
|
|
||||||
|
|
||||||
pgVersion96 :: PgVersion
|
|
||||||
pgVersion96 = PgVersion 90600 "9.6"
|
|
||||||
|
|
||||||
pgVersion95 :: PgVersion
|
|
||||||
pgVersion95 = PgVersion 90500 "9.5"
|
|
||||||
|
|||||||
@@ -1,242 +1,231 @@
|
|||||||
{-# LANGUAGE FlexibleContexts #-}
|
{-|
|
||||||
{-# LANGUAGE DuplicateRecordFields#-}
|
Module : PostgREST.DbRequestBuilder
|
||||||
{-# LANGUAGE LambdaCase #-}
|
Description : PostgREST database request builder
|
||||||
{-# LANGUAGE MultiWayIf #-}
|
|
||||||
{-# LANGUAGE NamedFieldPuns #-}
|
This module is in charge of building an intermediate representation(ReadRequest, MutateRequest) between the HTTP request and the final resulting SQL query.
|
||||||
|
|
||||||
|
A query tree is built in case of resource embedding. By inferring the relationship between tables, join conditions are added for every embedded resource.
|
||||||
|
-}
|
||||||
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
|
{-# LANGUAGE LambdaCase #-}
|
||||||
|
{-# LANGUAGE NamedFieldPuns #-}
|
||||||
|
|
||||||
module PostgREST.DbRequestBuilder (
|
module PostgREST.DbRequestBuilder (
|
||||||
readRequest
|
readRequest
|
||||||
, mutateRequest
|
, mutateRequest
|
||||||
, fieldNames
|
, returningCols
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Applicative
|
import qualified Data.HashMap.Strict as M
|
||||||
import Control.Arrow ((***))
|
import qualified Data.Set as S
|
||||||
import Control.Lens.Getter (view)
|
|
||||||
import Control.Lens.Tuple (_1)
|
|
||||||
import qualified Data.ByteString.Char8 as BS
|
|
||||||
import Data.List (delete)
|
|
||||||
import Data.Maybe (fromJust)
|
|
||||||
import qualified Data.Set as S
|
|
||||||
import Data.Text (isInfixOf)
|
|
||||||
import Data.Tree
|
|
||||||
import Data.Either.Combinators (mapLeft)
|
|
||||||
|
|
||||||
import Network.Wai
|
import Control.Arrow ((***))
|
||||||
|
import Data.Either.Combinators (mapLeft)
|
||||||
|
import Data.Foldable (foldr1)
|
||||||
|
import Data.List (delete)
|
||||||
|
import Data.Text (isInfixOf)
|
||||||
|
|
||||||
import Data.Foldable (foldr1)
|
import Control.Applicative
|
||||||
import qualified Data.HashMap.Strict as M
|
import Data.Tree
|
||||||
|
import Network.Wai
|
||||||
|
|
||||||
import PostgREST.ApiRequest ( ApiRequest(..)
|
import PostgREST.ApiRequest (Action (..), ApiRequest (..))
|
||||||
, PreferRepresentation(..)
|
import PostgREST.Error (ApiRequestError (..), errorResponseFor)
|
||||||
, Action(..), Target(..)
|
import PostgREST.Parsers
|
||||||
, PreferRepresentation (..)
|
import PostgREST.RangeQuery (NonnegRange, allRange, restrictRange)
|
||||||
)
|
import PostgREST.Types
|
||||||
import PostgREST.Error (apiRequestError)
|
import Protolude hiding (from)
|
||||||
import PostgREST.Parsers
|
|
||||||
import PostgREST.RangeQuery (NonnegRange, restrictRange, allRange)
|
|
||||||
import PostgREST.Types
|
|
||||||
|
|
||||||
import Protolude hiding (from, dropWhile, drop)
|
readRequest :: Schema -> TableName -> Maybe Integer -> [Relation] -> ApiRequest -> Either Response ReadRequest
|
||||||
import Text.Regex.TDFA ((=~))
|
readRequest schema rootTableName maxRows allRels apiRequest =
|
||||||
import Unsafe (unsafeHead)
|
mapLeft errorResponseFor $
|
||||||
|
|
||||||
readRequest :: Maybe Integer -> [Relation] -> Maybe ProcDescription -> ApiRequest -> Either Response ReadRequest
|
|
||||||
readRequest maxRows allRels proc apiRequest =
|
|
||||||
mapLeft apiRequestError $
|
|
||||||
treeRestrictRange maxRows =<<
|
treeRestrictRange maxRows =<<
|
||||||
augumentRequestWithJoin schema relations =<<
|
augmentRequestWithJoin schema rootRels =<<
|
||||||
addFiltersOrdersRanges apiRequest <*>
|
addFiltersOrdersRanges apiRequest <*>
|
||||||
(buildReadRequest <$> pRequestSelect (iSelect apiRequest))
|
(initReadRequest rootName <$> pRequestSelect sel)
|
||||||
where
|
where
|
||||||
action = iAction apiRequest
|
sel = fromMaybe "*" $ iSelect apiRequest -- default to all columns requested (SELECT *) for a non existent ?select querystring param
|
||||||
(schema, rootTableName) = fromJust $ -- Make it safe
|
(rootName, rootRels) = rootWithRels schema rootTableName allRels (iAction apiRequest)
|
||||||
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
|
-- Get the root table name with its relationships according to the Action type.
|
||||||
|
-- This is done because of the shape of the final SQL Query. The mutation cases are wrapped in a WITH {sourceCTEName}(see Statements.hs).
|
||||||
|
-- So we need a FROM {sourceCTEName} instead of FROM {tableName}.
|
||||||
|
rootWithRels :: Schema -> TableName -> [Relation] -> Action -> (QualifiedIdentifier, [Relation])
|
||||||
|
rootWithRels schema rootTableName allRels action = case action of
|
||||||
|
ActionRead _ -> (QualifiedIdentifier schema rootTableName, allRels) -- normal read case
|
||||||
|
_ -> (QualifiedIdentifier mempty sourceCTEName, mapMaybe toSourceRel allRels ++ allRels) -- mutation cases and calling proc
|
||||||
|
where
|
||||||
|
-- To enable embedding in the sourceCTEName cases we need to replace the foreign key tableName in the Relation
|
||||||
|
-- with {sourceCTEName}. This way findRel can find relationships with sourceCTEName.
|
||||||
|
toSourceRel :: Relation -> Maybe Relation
|
||||||
|
toSourceRel r@Relation{relTable=t}
|
||||||
|
| rootTableName == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
|
||||||
|
| otherwise = Nothing
|
||||||
|
|
||||||
-- Build tree with a Depth attribute so when a self join occurs we can differentiate the parent and child tables by having
|
-- Build the initial 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.
|
-- an alias like "table_depth", this is related to http://github.com/PostgREST/postgrest/issues/987.
|
||||||
buildReadRequest :: [Tree SelectItem] -> ReadRequest
|
initReadRequest :: QualifiedIdentifier -> [Tree SelectItem] -> ReadRequest
|
||||||
buildReadRequest fieldTree =
|
initReadRequest rootQi =
|
||||||
let rootDepth = 0
|
foldr (treeEntry rootDepth) initial
|
||||||
rootNodeName = if action == ActionRead then rootTableName else sourceCTEName in
|
where
|
||||||
foldr (treeEntry rootDepth) (Node (Select [] [rootNodeName] [] [] [] allRange, (rootNodeName, Nothing, Nothing, Nothing, rootDepth)) []) fieldTree
|
rootDepth = 0
|
||||||
where
|
rootSchema = qiSchema rootQi
|
||||||
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
|
rootName = qiName rootQi
|
||||||
treeEntry depth (Node fld@((fn, _),_,alias,relationDetail) fldForest) (Node (q, i) rForest) =
|
initial = Node (Select [] rootQi Nothing [] [] [] [] allRange, (rootName, Nothing, Nothing, Nothing, rootDepth)) []
|
||||||
let nxtDepth = succ depth in
|
treeEntry :: Depth -> Tree SelectItem -> ReadRequest -> ReadRequest
|
||||||
case fldForest of
|
treeEntry depth (Node fld@((fn, _),_,alias, embedHint) fldForest) (Node (q, i) rForest) =
|
||||||
[] -> Node (q {select=fld:select q}, i) rForest
|
let nxtDepth = succ depth in
|
||||||
_ -> Node (q, i) $
|
case fldForest of
|
||||||
foldr (treeEntry nxtDepth) (Node (Select [] [fn] [] [] [] allRange, (fn, Nothing, alias, relationDetail, nxtDepth)) []) fldForest:rForest
|
[] -> Node (q {select=fld:select q}, i) rForest
|
||||||
|
_ -> Node (q, i) $
|
||||||
relations :: [Relation]
|
foldr (treeEntry nxtDepth)
|
||||||
relations = case action of
|
(Node (Select [] (QualifiedIdentifier rootSchema fn) Nothing [] [] [] [] allRange,
|
||||||
ActionCreate -> fakeSourceRelations ++ allRels
|
(fn, Nothing, alias, embedHint, nxtDepth)) [])
|
||||||
ActionUpdate -> fakeSourceRelations ++ allRels
|
fldForest:rForest
|
||||||
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 a the "returning *" part which is wrapped with a "with"
|
|
||||||
-- as just another table that has relations with other tables
|
|
||||||
toSourceRelation :: TableName -> Relation -> Maybe Relation
|
|
||||||
toSourceRelation mt r@(Relation t _ ft _ _ rt _ _)
|
|
||||||
| mt == tableName t = Just $ r {relTable=t {tableName=sourceCTEName}}
|
|
||||||
| mt == tableName ft = Just $ r {relFTable=t {tableName=sourceCTEName}}
|
|
||||||
| Just mt == (tableName <$> rt) = Just $ r {relLinkTable=(\tbl -> tbl {tableName=sourceCTEName}) <$> rt}
|
|
||||||
| otherwise = Nothing
|
|
||||||
|
|
||||||
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
|
treeRestrictRange :: Maybe Integer -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
treeRestrictRange maxRows_ request = pure $ nodeRestrictRange maxRows_ `fmap` request
|
treeRestrictRange maxRows request = pure $ nodeRestrictRange maxRows <$> request
|
||||||
where
|
where
|
||||||
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
nodeRestrictRange :: Maybe Integer -> ReadNode -> ReadNode
|
||||||
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
nodeRestrictRange m (q@Select {range_=r}, i) = (q{range_=restrictRange m r }, i)
|
||||||
|
|
||||||
augumentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
|
augmentRequestWithJoin :: Schema -> [Relation] -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
augumentRequestWithJoin schema allRels request =
|
augmentRequestWithJoin schema allRels request =
|
||||||
addRelations schema allRels Nothing request
|
addRels schema allRels Nothing request
|
||||||
>>= addJoinConditions schema
|
>>= addJoinConditions Nothing
|
||||||
|
|
||||||
addRelations :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
addRels :: Schema -> [Relation] -> Maybe ReadRequest -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
addRelations schema allRelations parentNode (Node (query, (nodeName, _, alias, relationDetail, depth)) forest) =
|
addRels schema allRels parentNode (Node (query@Select{from=tbl}, (nodeName, _, alias, hint, depth)) forest) =
|
||||||
case parentNode of
|
case parentNode of
|
||||||
Just (Node (Select{from=[parentNodeTable]}, _) _) ->
|
Just (Node (Select{from=parentNodeQi}, _) _) ->
|
||||||
let newFrom r = (\tName -> if tName == nodeName then tableName (relTable r) else tName) <$> from query
|
let newFrom r = if qiName tbl == nodeName then tableQi (relFTable r) else tbl
|
||||||
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
|
newReadNode = (\r -> (query{from=newFrom r}, (nodeName, Just r, alias, Nothing, depth))) <$> rel
|
||||||
rel :: Either ApiRequestError Relation
|
rel = findRel schema allRels (qiName parentNodeQi) nodeName hint
|
||||||
rel = note (NoRelationBetween parentNodeTable nodeName) $
|
in
|
||||||
findRelation schema allRelations nodeName parentNodeTable relationDetail in
|
|
||||||
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
|
Node <$> newReadNode <*> (updateForest . hush $ Node <$> newReadNode <*> pure forest)
|
||||||
_ ->
|
_ ->
|
||||||
let rn = (query, (nodeName, Just r, alias, Nothing, depth))
|
let rn = (query, (nodeName, Nothing, alias, Nothing, depth)) in
|
||||||
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)
|
Node rn <$> updateForest (Just $ Node rn forest)
|
||||||
where
|
where
|
||||||
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
|
updateForest :: Maybe ReadRequest -> Either ApiRequestError [ReadRequest]
|
||||||
updateForest rq = mapM (addRelations schema allRelations rq) forest
|
updateForest rq = mapM (addRels schema allRels rq) forest
|
||||||
|
|
||||||
findRelation :: Schema -> [Relation] -> NodeName -> TableName -> Maybe RelationDetail -> Maybe Relation
|
-- Finds a relationship between an origin and a target in the request: /origin?select=target(*)
|
||||||
findRelation schema allRelations nodeTableName parentNodeTableName relationDetail =
|
-- If more than one relationship is found then the request is ambiguous and we return an error.
|
||||||
find (\Relation{relTable, relColumns, relFTable, relFColumns, relType, relLinkTable} ->
|
-- In that case the request can be disambiguated by adding precision to the target or by using a hint: /origin?select=target!hint(*)
|
||||||
-- Both relation ends need to be on the exposed schema
|
-- The elements will be matched according to these rules:
|
||||||
schema == tableSchema relTable && schema == tableSchema relFTable &&
|
-- origin = table / view
|
||||||
case relationDetail of
|
-- target = table / view / constraint / column-from-origin
|
||||||
Nothing ->
|
-- hint = table / view / constraint / column-from-origin / column-from-target
|
||||||
|
-- (hint can take table / view values to aid in finding the junction in an m2m relationship)
|
||||||
-- (request) => projects { ..., clients{...} }
|
findRel :: Schema -> [Relation] -> NodeName -> NodeName -> Maybe EmbedHint -> Either ApiRequestError Relation
|
||||||
-- will match
|
findRel schema allRels origin target hint =
|
||||||
-- (relation type) => parent
|
case rel of
|
||||||
-- (entity) => clients {id}
|
[] -> Left $ NoRelBetween origin target
|
||||||
-- (foriegn entity) => projects {client_id}
|
[r] -> Right r
|
||||||
(
|
rs ->
|
||||||
nodeTableName == tableName relTable && -- match relation table name
|
-- Return error if more than one relationship is found, unless we're in a self reference case.
|
||||||
parentNodeTableName == tableName relFTable -- match relation foreign table name
|
--
|
||||||
) ||
|
-- Here we handle a self reference relationship to not cause a breaking change:
|
||||||
|
-- In a self reference we get two relationships with the same foreign key and relTable/relFtable but with different cardinalities(m2o/o2m)
|
||||||
-- (request) => projects { ..., client_id{...} }
|
-- We output the O2M rel, the M2O rel can be obtained by using the origin column as an embed hint.
|
||||||
-- will match
|
let [rel0, rel1] = take 2 rs in
|
||||||
-- (relation type) => parent
|
if length rs == 2 && relConstraint rel0 == relConstraint rel1 && relTable rel0 == relTable rel1 && relFTable rel0 == relFTable rel1
|
||||||
-- (entity) => clients {id}
|
then note (NoRelBetween origin target) (find (\r -> relType r == O2M) rs)
|
||||||
-- (foriegn entity) => projects {client_id}
|
else Left $ AmbiguousRelBetween origin target rs
|
||||||
(
|
|
||||||
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
|
|
||||||
|
|
||||||
addJoinConditions :: Schema -> ReadRequest -> Either ApiRequestError ReadRequest
|
|
||||||
addJoinConditions schema (Node node@(query, nodeProps@(_, relation, _, _, _)) 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{from=tableName linkTable:from rq}, nodeProps) <$> updatedForest
|
|
||||||
_ -> Left UnknownRelation
|
|
||||||
where
|
where
|
||||||
updatedForest = mapM (addJoinConditions schema) forest
|
matchFKSingleCol hint_ cols = length cols == 1 && hint_ == (colName <$> head cols)
|
||||||
augmentQuery rel = foldr addJoinCond query (getJoinConditions rel)
|
rel = filter (
|
||||||
addJoinCond :: JoinCondition -> ReadQuery -> ReadQuery
|
\Relation{relTable, relColumns, relConstraint, relFTable, relFColumns, relType, relJunction} ->
|
||||||
addJoinCond jc rq@Select{joinConditions=jcs} = rq{joinConditions=jc:jcs}
|
-- Both relationship ends need to be on the exposed schema
|
||||||
|
schema == tableSchema relTable && schema == tableSchema relFTable &&
|
||||||
|
(
|
||||||
|
-- /projects?select=clients(*)
|
||||||
|
origin == tableName relTable && -- projects
|
||||||
|
target == tableName relFTable || -- clients
|
||||||
|
|
||||||
getJoinConditions :: Relation -> [JoinCondition]
|
-- /projects?select=projects_client_id_fkey(*)
|
||||||
getJoinConditions (Relation Table{tableSchema=tSchema, tableName=tN} cols Table{tableName=ftN} fcs typ lt lc1 lc2) =
|
(
|
||||||
if | typ == Child || typ == Parent ->
|
origin == tableName relTable && -- projects
|
||||||
zipWith (toJoinCondition tN ftN) cols fcs
|
Just target == relConstraint -- projects_client_id_fkey
|
||||||
| typ == Many ->
|
) ||
|
||||||
let ltN = fromMaybe "" (tableName <$> lt) in
|
-- /projects?select=client_id(*)
|
||||||
zipWith (toJoinCondition tN ltN) cols (fromMaybe [] lc1) ++ zipWith (toJoinCondition ftN ltN) fcs (fromMaybe [] lc2)
|
(
|
||||||
| typ == Root -> witness
|
origin == tableName relTable && -- projects
|
||||||
|
matchFKSingleCol (Just target) relColumns -- client_id
|
||||||
|
)
|
||||||
|
) && (
|
||||||
|
isNothing hint || -- hint is optional
|
||||||
|
|
||||||
|
-- /projects?select=clients!projects_client_id_fkey(*)
|
||||||
|
hint == relConstraint || -- projects_client_id_fkey
|
||||||
|
|
||||||
|
-- /projects?select=clients!client_id(*) or /projects?select=clients!id(*)
|
||||||
|
matchFKSingleCol hint relColumns || -- client_id
|
||||||
|
matchFKSingleCol hint relFColumns || -- id
|
||||||
|
|
||||||
|
-- /users?select=tasks!users_tasks(*)
|
||||||
|
(
|
||||||
|
relType == M2M && -- many-to-many between users and tasks
|
||||||
|
hint == (tableName . junTable <$> relJunction) -- users_tasks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) allRels
|
||||||
|
|
||||||
|
-- previousAlias is only used for the case of self joins
|
||||||
|
addJoinConditions :: Maybe Alias -> ReadRequest -> Either ApiRequestError ReadRequest
|
||||||
|
addJoinConditions previousAlias (Node node@(query@Select{from=tbl}, nodeProps@(_, rel, _, _, depth)) forest) =
|
||||||
|
case rel of
|
||||||
|
Just r@Relation{relType=O2M} -> Node (augmentQuery r, nodeProps) <$> updatedForest
|
||||||
|
Just r@Relation{relType=M2O} -> Node (augmentQuery r, nodeProps) <$> updatedForest
|
||||||
|
Just r@Relation{relType=M2M, relJunction=junction} ->
|
||||||
|
case junction of
|
||||||
|
Just Junction{junTable} ->
|
||||||
|
let rq = augmentQuery r in
|
||||||
|
Node (rq{implicitJoins=tableQi junTable:implicitJoins rq}, nodeProps) <$> updatedForest
|
||||||
|
Nothing ->
|
||||||
|
Left UnknownRelation
|
||||||
|
Nothing -> Node node <$> updatedForest
|
||||||
|
where
|
||||||
|
newAlias = case isSelfReference <$> rel of
|
||||||
|
Just True
|
||||||
|
| depth /= 0 -> Just (qiName tbl <> "_" <> show depth) -- root node doesn't get aliased
|
||||||
|
| otherwise -> Nothing
|
||||||
|
_ -> Nothing
|
||||||
|
augmentQuery r =
|
||||||
|
foldr
|
||||||
|
(\jc rq@Select{joinConditions=jcs} -> rq{joinConditions=jc:jcs})
|
||||||
|
query{fromAlias=newAlias}
|
||||||
|
(getJoinConditions previousAlias newAlias r)
|
||||||
|
updatedForest = mapM (addJoinConditions 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 jun) =
|
||||||
|
case typ of
|
||||||
|
O2M ->
|
||||||
|
zipWith (toJoinCondition tN ftN) cols fCols
|
||||||
|
M2O ->
|
||||||
|
zipWith (toJoinCondition tN ftN) cols fCols
|
||||||
|
M2M -> case jun of
|
||||||
|
Just (Junction jt _ jc1 _ jc2) ->
|
||||||
|
let jtn = tableName jt in
|
||||||
|
zipWith (toJoinCondition tN jtn) cols jc1 ++ zipWith (toJoinCondition ftN jtn) fCols jc2
|
||||||
|
Nothing -> []
|
||||||
where
|
where
|
||||||
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
|
toJoinCondition :: Text -> Text -> Column -> Column -> JoinCondition
|
||||||
toJoinCondition tb ftb c fc =
|
toJoinCondition tb ftb c fc =
|
||||||
JoinCondition (QualifiedIdentifier tSchema tb, Nothing, colName c)
|
let qi1 = removeSourceCTESchema tSchema tb
|
||||||
(QualifiedIdentifier tSchema ftb, Nothing, colName fc)
|
qi2 = removeSourceCTESchema tSchema ftb in
|
||||||
|
JoinCondition (maybe qi1 (QualifiedIdentifier mempty) previousAlias, colName c)
|
||||||
|
(maybe qi2 (QualifiedIdentifier mempty) newAlias, colName fc)
|
||||||
|
|
||||||
|
-- On mutation and calling proc cases we wrap the target table in a WITH {sourceCTEName}
|
||||||
|
-- if this happens remove the schema `FROM "schema"."{sourceCTEName}"` and use only the
|
||||||
|
-- `FROM "{sourceCTEName}"`. If the schema remains the FROM would be invalid.
|
||||||
|
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
|
||||||
|
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then mempty else schema) tbl
|
||||||
|
|
||||||
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
|
addFiltersOrdersRanges :: ApiRequest -> Either ApiRequestError (ReadRequest -> ReadRequest)
|
||||||
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
||||||
@@ -247,7 +236,7 @@ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
|||||||
]
|
]
|
||||||
{-
|
{-
|
||||||
The esence of what is going on above is that we are composing tree functions
|
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
|
of type (ReadRequest->ReadRequest) that are in (Either ApiRequestError a) context
|
||||||
-}
|
-}
|
||||||
where
|
where
|
||||||
filters :: Either ApiRequestError [(EmbedPath, Filter)]
|
filters :: Either ApiRequestError [(EmbedPath, Filter)]
|
||||||
@@ -259,7 +248,7 @@ addFiltersOrdersRanges apiRequest = foldr1 (liftA2 (.)) [
|
|||||||
(flts, logFrst) =
|
(flts, logFrst) =
|
||||||
case action of
|
case action of
|
||||||
ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)
|
ActionInvoke _ -> (iFilters apiRequest, iLogic apiRequest)
|
||||||
ActionRead -> (iFilters apiRequest, iLogic apiRequest)
|
ActionRead _ -> (iFilters apiRequest, iLogic apiRequest)
|
||||||
_ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
|
_ -> join (***) (filter (( "." `isInfixOf` ) . fst)) (iFilters apiRequest, iLogic apiRequest)
|
||||||
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
|
orders :: Either ApiRequestError [(EmbedPath, [OrderTerm])]
|
||||||
orders = mapM pRequestOrder $ iOrder apiRequest
|
orders = mapM pRequestOrder $ iOrder apiRequest
|
||||||
@@ -299,11 +288,15 @@ addProperty f (targetNodeName:remainingPath, a) (Node rn forest) =
|
|||||||
where
|
where
|
||||||
pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
|
pathNode = find (\(Node (_,(nodeName,_,alias,_,_)) _) -> nodeName == targetNodeName || alias == Just targetNodeName) forest
|
||||||
|
|
||||||
mutateRequest :: ApiRequest -> TableName -> [Text] -> [FieldName] -> Either Response MutateRequest
|
mutateRequest :: Schema -> TableName -> ApiRequest -> S.Set FieldName -> [FieldName] -> ReadRequest -> Either Response MutateRequest
|
||||||
mutateRequest apiRequest tName pkCols fldNames = mapLeft apiRequestError $
|
mutateRequest schema tName apiRequest cols pkCols readReq = mapLeft errorResponseFor $
|
||||||
case action of
|
case action of
|
||||||
ActionCreate -> Right $ Insert tName pkCols payload (iPreferResolution apiRequest) [] returnings
|
ActionCreate -> do
|
||||||
ActionUpdate -> Update tName payload <$> combinedLogic <*> pure returnings
|
confCols <- case iOnConflict apiRequest of
|
||||||
|
Nothing -> pure pkCols
|
||||||
|
Just param -> pRequestOnConflict param
|
||||||
|
pure $ Insert qi cols ((,) <$> iPreferResolution apiRequest <*> Just confCols) [] returnings
|
||||||
|
ActionUpdate -> Update qi cols <$> combinedLogic <*> pure returnings
|
||||||
ActionSingleUpsert ->
|
ActionSingleUpsert ->
|
||||||
(\flts ->
|
(\flts ->
|
||||||
if null (iLogic apiRequest) &&
|
if null (iLogic apiRequest) &&
|
||||||
@@ -312,15 +305,18 @@ mutateRequest apiRequest tName pkCols fldNames = mapLeft apiRequestError $
|
|||||||
all (\case
|
all (\case
|
||||||
Filter _ (OpExpr False (Op "eq" _)) -> True
|
Filter _ (OpExpr False (Op "eq" _)) -> True
|
||||||
_ -> False) flts
|
_ -> False) flts
|
||||||
then Insert tName pkCols payload (Just MergeDuplicates) <$> combinedLogic <*> pure returnings
|
then Insert qi cols (Just (MergeDuplicates, pkCols)) <$> combinedLogic <*> pure returnings
|
||||||
else
|
else
|
||||||
Left InvalidFilters) =<< filters
|
Left InvalidFilters) =<< filters
|
||||||
ActionDelete -> Delete tName <$> combinedLogic <*> pure returnings
|
ActionDelete -> Delete qi <$> combinedLogic <*> pure returnings
|
||||||
_ -> Left UnsupportedVerb
|
_ -> Left UnsupportedVerb
|
||||||
where
|
where
|
||||||
|
qi = QualifiedIdentifier schema tName
|
||||||
action = iAction apiRequest
|
action = iAction apiRequest
|
||||||
payload = fromJust $ iPayload apiRequest
|
returnings =
|
||||||
returnings = if iPreferRepresentation apiRequest == None then [] else fldNames
|
if iPreferRepresentation apiRequest == None
|
||||||
|
then []
|
||||||
|
else returningCols readReq
|
||||||
filters = map snd <$> mapM pRequestFilter mutateFilters
|
filters = map snd <$> mapM pRequestFilter mutateFilters
|
||||||
logic = map snd <$> mapM pRequestLogicTree logicFilters
|
logic = map snd <$> mapM pRequestLogicTree logicFilters
|
||||||
combinedLogic = foldr addFilterToLogicForest <$> logic <*> filters
|
combinedLogic = foldr addFilterToLogicForest <$> logic <*> filters
|
||||||
@@ -328,13 +324,26 @@ mutateRequest apiRequest tName pkCols fldNames = mapLeft apiRequestError $
|
|||||||
(mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)
|
(mutateFilters, logicFilters) = join (***) onlyRoot (iFilters apiRequest, iLogic apiRequest)
|
||||||
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
|
onlyRoot = filter (not . ( "." `isInfixOf` ) . fst)
|
||||||
|
|
||||||
fieldNames :: ReadRequest -> [FieldName]
|
returningCols :: ReadRequest -> [FieldName]
|
||||||
fieldNames (Node (sel, _) forest) =
|
returningCols rr@(Node _ forest) = returnings
|
||||||
map (fst . view _1) (select sel) ++ map colName fks
|
|
||||||
where
|
where
|
||||||
fks = concatMap (fromMaybe [] . f) forest
|
fldNames = fstFieldNames rr
|
||||||
f (Node (_, (_, Just Relation{relFColumns=cols, relType=Parent}, _, _, _)) _) = Just cols
|
-- Without fkCols, when a mutateRequest to /projects?select=name,clients(name) occurs, the RETURNING SQL part would be
|
||||||
f _ = Nothing
|
-- `RETURNING name`(see QueryBuilder).
|
||||||
|
-- This would make the embedding fail because the following JOIN would need the "client_id" column from projects.
|
||||||
|
-- So this adds the foreign key columns to ensure the embedding succeeds, result would be `RETURNING name, client_id`.
|
||||||
|
-- This also works for the other relType's.
|
||||||
|
fkCols = concat $ mapMaybe (\case
|
||||||
|
Node (_, (_, Just Relation{relColumns=cols, relType=relTyp}, _, _, _)) _ -> case relTyp of
|
||||||
|
O2M -> Just cols
|
||||||
|
M2O -> Just cols
|
||||||
|
M2M -> Just cols
|
||||||
|
_ -> Nothing
|
||||||
|
) forest
|
||||||
|
-- However if the "client_id" is present, e.g. mutateRequest to /projects?select=client_id,name,clients(name)
|
||||||
|
-- we would get `RETURNING client_id, name, client_id` and then we would produce the "column reference \"client_id\" is ambiguous"
|
||||||
|
-- error from PostgreSQL. So we deduplicate with Set:
|
||||||
|
returnings = S.toList . S.fromList $ fldNames ++ (colName <$> fkCols)
|
||||||
|
|
||||||
-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
|
-- Traditional filters(e.g. id=eq.1) are added as root nodes of the LogicTree
|
||||||
-- they are later concatenated with AND in the QueryBuilder
|
-- they are later concatenated with AND in the QueryBuilder
|
||||||
|
|||||||
@@ -1,97 +1,74 @@
|
|||||||
|
{-|
|
||||||
|
Module : PostgREST.Error
|
||||||
|
Description : PostgREST error HTTP responses
|
||||||
|
-}
|
||||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||||
{-# LANGUAGE FlexibleInstances #-}
|
{-# LANGUAGE FlexibleInstances #-}
|
||||||
{-# LANGUAGE TypeSynonymInstances #-}
|
|
||||||
|
|
||||||
module PostgREST.Error (
|
module PostgREST.Error (
|
||||||
apiRequestError
|
errorResponseFor
|
||||||
, pgError
|
, ApiRequestError(..)
|
||||||
, simpleError
|
, PgError(..)
|
||||||
|
, SimpleError(..)
|
||||||
|
, errorPayload
|
||||||
|
, checkIsFatal
|
||||||
, singularityError
|
, singularityError
|
||||||
, binaryFieldError
|
|
||||||
, connectionLostError
|
|
||||||
, encodeError
|
|
||||||
, gucHeadersError
|
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Protolude
|
|
||||||
import Data.Aeson ((.=))
|
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.Aeson as JSON
|
||||||
import Data.Text (unwords)
|
import qualified Data.Text as T
|
||||||
import qualified Hasql.Pool as P
|
import qualified Hasql.Pool as P
|
||||||
import qualified Hasql.Session as H
|
import qualified Hasql.Session as H
|
||||||
import Network.HTTP.Types.Header
|
|
||||||
import qualified Network.HTTP.Types.Status as HT
|
import qualified Network.HTTP.Types.Status as HT
|
||||||
import Network.Wai (Response, responseLBS)
|
|
||||||
import PostgREST.Types
|
|
||||||
import Text.Read (readMaybe)
|
|
||||||
|
|
||||||
apiRequestError :: ApiRequestError -> Response
|
import Data.Aeson ((.=))
|
||||||
apiRequestError err =
|
import Network.Wai (Response, responseLBS)
|
||||||
errorResponse status
|
|
||||||
[toHeader CTApplicationJSON] err
|
|
||||||
where
|
|
||||||
status =
|
|
||||||
case err of
|
|
||||||
ActionInappropriate -> HT.status405
|
|
||||||
UnsupportedVerb -> HT.status405
|
|
||||||
InvalidBody _ -> HT.status400
|
|
||||||
ParseRequestError _ _ -> HT.status400
|
|
||||||
NoRelationBetween _ _ -> HT.status400
|
|
||||||
InvalidRange -> HT.status416
|
|
||||||
UnknownRelation -> HT.status404
|
|
||||||
InvalidFilters -> HT.status405
|
|
||||||
|
|
||||||
simpleError :: HT.Status -> [Header] -> Text -> Response
|
import Network.HTTP.Types.Header
|
||||||
simpleError status hdrs message =
|
|
||||||
errorResponse status (toHeader CTApplicationJSON : hdrs) $
|
|
||||||
JSON.object ["message" .= message]
|
|
||||||
|
|
||||||
errorResponse :: JSON.ToJSON a => HT.Status -> [Header] -> a -> Response
|
import PostgREST.Types
|
||||||
errorResponse status hdrs e =
|
import Protolude hiding (toS)
|
||||||
responseLBS status hdrs $ encodeError e
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
pgError :: Bool -> P.UsageError -> Response
|
|
||||||
pgError authed e =
|
|
||||||
let status = httpStatus authed e
|
|
||||||
jsonType = toHeader CTApplicationJSON
|
|
||||||
wwwAuth = ("WWW-Authenticate", "Bearer")
|
|
||||||
hdrs = if status == HT.status401
|
|
||||||
then [jsonType, wwwAuth]
|
|
||||||
else [jsonType] in
|
|
||||||
responseLBS status hdrs (encodeError e)
|
|
||||||
|
|
||||||
singularityError :: Integer -> Response
|
|
||||||
singularityError numRows =
|
|
||||||
responseLBS HT.status406
|
|
||||||
[toHeader CTSingularJSON]
|
|
||||||
$ toS . formatGeneralError
|
|
||||||
"JSON object requested, multiple (or no) rows returned"
|
|
||||||
$ unwords
|
|
||||||
[ "Results contain", show numRows, "rows,"
|
|
||||||
, toS (toMime CTSingularJSON), "requires 1 row"
|
|
||||||
]
|
|
||||||
where
|
|
||||||
formatGeneralError :: Text -> Text -> Text
|
|
||||||
formatGeneralError message details = toS . JSON.encode $
|
|
||||||
JSON.object ["message" .= message, "details" .= details]
|
|
||||||
|
|
||||||
|
|
||||||
binaryFieldError :: Response
|
class (JSON.ToJSON a) => PgrstError a where
|
||||||
binaryFieldError =
|
status :: a -> HT.Status
|
||||||
simpleError HT.status406 [] (toS (toMime CTOctetStream) <>
|
headers :: a -> [Header]
|
||||||
" requested but a single column was not selected")
|
|
||||||
|
|
||||||
gucHeadersError :: Response
|
errorPayload :: a -> LByteString
|
||||||
gucHeadersError =
|
errorPayload = JSON.encode
|
||||||
simpleError HT.status500 []
|
|
||||||
"response.headers guc must be a JSON array composed of objects with a single key and a string value"
|
|
||||||
|
|
||||||
connectionLostError :: Response
|
errorResponseFor :: a -> Response
|
||||||
connectionLostError =
|
errorResponseFor err = responseLBS (status err) (headers err) $ errorPayload err
|
||||||
simpleError HT.status503 [] "Database connection lost, retrying the connection."
|
|
||||||
|
|
||||||
encodeError :: JSON.ToJSON a => a -> LByteString
|
|
||||||
encodeError = JSON.encode
|
|
||||||
|
data ApiRequestError
|
||||||
|
= ActionInappropriate
|
||||||
|
| InvalidRange
|
||||||
|
| InvalidBody ByteString
|
||||||
|
| ParseRequestError Text Text
|
||||||
|
| NoRelBetween Text Text
|
||||||
|
| AmbiguousRelBetween Text Text [Relation]
|
||||||
|
| InvalidFilters
|
||||||
|
| UnacceptableSchema [Text]
|
||||||
|
| 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 (NoRelBetween _ _) = HT.status400
|
||||||
|
status AmbiguousRelBetween{} = HT.status300
|
||||||
|
status (UnacceptableSchema _) = HT.status406
|
||||||
|
|
||||||
|
headers _ = [toHeader CTApplicationJSON]
|
||||||
|
|
||||||
instance JSON.ToJSON ApiRequestError where
|
instance JSON.ToJSON ApiRequestError where
|
||||||
toJSON (ParseRequestError message details) = JSON.object [
|
toJSON (ParseRequestError message details) = JSON.object [
|
||||||
@@ -104,85 +81,198 @@ instance JSON.ToJSON ApiRequestError where
|
|||||||
"message" .= ("HTTP Range error" :: Text)]
|
"message" .= ("HTTP Range error" :: Text)]
|
||||||
toJSON UnknownRelation = JSON.object [
|
toJSON UnknownRelation = JSON.object [
|
||||||
"message" .= ("Unknown relation" :: Text)]
|
"message" .= ("Unknown relation" :: Text)]
|
||||||
toJSON (NoRelationBetween parent child) = JSON.object [
|
toJSON (NoRelBetween parent child) = JSON.object [
|
||||||
"message" .= ("Could not find foreign keys between these entities, No relation found between " <> parent <> " and " <> child :: Text)]
|
"message" .= ("Could not find foreign keys between these entities. No relationship found between " <> parent <> " and " <> child :: Text)]
|
||||||
|
toJSON (AmbiguousRelBetween parent child rels) = JSON.object [
|
||||||
|
"hint" .= ("By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)" :: Text),
|
||||||
|
"message" .= ("More than one relationship was found for " <> parent <> " and " <> child :: Text),
|
||||||
|
"details" .= (compressedRel <$> rels) ]
|
||||||
toJSON UnsupportedVerb = JSON.object [
|
toJSON UnsupportedVerb = JSON.object [
|
||||||
"message" .= ("Unsupported HTTP verb" :: Text)]
|
"message" .= ("Unsupported HTTP verb" :: Text)]
|
||||||
toJSON InvalidFilters = JSON.object [
|
toJSON InvalidFilters = JSON.object [
|
||||||
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
|
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text)]
|
||||||
|
toJSON (UnacceptableSchema schemas) = JSON.object [
|
||||||
|
"message" .= ("The schema must be one of the following: " <> T.intercalate ", " schemas)]
|
||||||
|
|
||||||
|
compressedRel :: Relation -> JSON.Value
|
||||||
|
compressedRel rel =
|
||||||
|
let
|
||||||
|
fmtTbl tbl = tableSchema tbl <> "." <> tableName tbl
|
||||||
|
fmtEls els = "[" <> T.intercalate ", " els <> "]"
|
||||||
|
in
|
||||||
|
JSON.object $ [
|
||||||
|
"origin" .= fmtTbl (relTable rel)
|
||||||
|
, "target" .= fmtTbl (relFTable rel)
|
||||||
|
, "cardinality" .= (show $ relType rel :: Text)
|
||||||
|
] ++
|
||||||
|
case (relType rel, relJunction rel, relConstraint rel) of
|
||||||
|
(M2M, Just (Junction jt (Just const1) _ (Just const2) _), _) -> [
|
||||||
|
"relationship" .= (fmtTbl jt <> fmtEls [const1] <> fmtEls [const2])
|
||||||
|
]
|
||||||
|
(_, _, Just relCon) -> [
|
||||||
|
"relationship" .= (relCon <> fmtEls (colName <$> relColumns rel) <> fmtEls (colName <$> relFColumns rel))
|
||||||
|
]
|
||||||
|
(_, _, _) ->
|
||||||
|
mempty
|
||||||
|
|
||||||
|
data PgError = PgError Authenticated P.UsageError
|
||||||
|
type Authenticated = Bool
|
||||||
|
|
||||||
|
instance PgrstError PgError where
|
||||||
|
status (PgError authed usageError) = pgErrorStatus authed usageError
|
||||||
|
|
||||||
|
headers err =
|
||||||
|
if status err == HT.status401
|
||||||
|
then [toHeader CTApplicationJSON, ("WWW-Authenticate", "Bearer") :: Header]
|
||||||
|
else [toHeader CTApplicationJSON]
|
||||||
|
|
||||||
|
instance JSON.ToJSON PgError where
|
||||||
|
toJSON (PgError _ usageError) = JSON.toJSON usageError
|
||||||
|
|
||||||
instance JSON.ToJSON P.UsageError where
|
instance JSON.ToJSON P.UsageError where
|
||||||
toJSON (P.ConnectionError e) = JSON.object [
|
toJSON (P.ConnectionError e) = JSON.object [
|
||||||
"code" .= ("" :: Text),
|
"code" .= ("" :: Text),
|
||||||
"message" .= ("Database connection error" :: Text),
|
"message" .= ("Database connection error" :: Text),
|
||||||
"details" .= (toS $ fromMaybe "" e :: Text)]
|
"details" .= (toS $ fromMaybe "" e :: Text)]
|
||||||
toJSON (P.SessionError e) = JSON.toJSON e -- H.Error
|
toJSON (P.SessionError e) = JSON.toJSON e -- H.Error
|
||||||
|
|
||||||
instance JSON.ToJSON H.Error where
|
instance JSON.ToJSON H.QueryError where
|
||||||
|
toJSON (H.QueryError _ _ e) = JSON.toJSON e
|
||||||
|
|
||||||
|
instance JSON.ToJSON H.CommandError where
|
||||||
toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of
|
toJSON (H.ResultError (H.ServerError c m d h)) = case toS c of
|
||||||
'P':'T':_ ->
|
'P':'T':_ -> JSON.object [
|
||||||
JSON.object [
|
"details" .= (fmap toS d :: Maybe Text),
|
||||||
"details" .= (fmap toS d::Maybe Text),
|
"hint" .= (fmap toS h :: Maybe Text)]
|
||||||
"hint" .= (fmap toS h::Maybe Text)]
|
|
||||||
_ ->
|
_ -> JSON.object [
|
||||||
JSON.object [
|
"code" .= (toS c :: Text),
|
||||||
"code" .= (toS c::Text),
|
"message" .= (toS m :: Text),
|
||||||
"message" .= (toS m::Text),
|
"details" .= (fmap toS d :: Maybe Text),
|
||||||
"details" .= (fmap toS d::Maybe Text),
|
"hint" .= (fmap toS h :: Maybe Text)]
|
||||||
"hint" .= (fmap toS h::Maybe Text)]
|
|
||||||
toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [
|
toJSON (H.ResultError (H.UnexpectedResult m)) = JSON.object [
|
||||||
"message" .= (m::Text)]
|
"message" .= (m :: Text)]
|
||||||
toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [
|
toJSON (H.ResultError (H.RowError i H.EndOfInput)) = JSON.object [
|
||||||
"message" .= ("Row error: end of input"::Text),
|
"message" .= ("Row error: end of input" :: Text),
|
||||||
"details" .=
|
"details" .= ("Attempt to parse more columns than there are in the result" :: Text),
|
||||||
("Attempt to parse more columns than there are in the result"::Text),
|
"hint" .= (("Row number " <> show i) :: Text)]
|
||||||
"details" .= (("Row number " <> show i)::Text)]
|
|
||||||
toJSON (H.ResultError (H.RowError i H.UnexpectedNull)) = JSON.object [
|
toJSON (H.ResultError (H.RowError i H.UnexpectedNull)) = JSON.object [
|
||||||
"message" .= ("Row error: unexpected null"::Text),
|
"message" .= ("Row error: unexpected null" :: Text),
|
||||||
"details" .= ("Attempt to parse a NULL as some value."::Text),
|
"details" .= ("Attempt to parse a NULL as some value." :: Text),
|
||||||
"details" .= (("Row number " <> show i)::Text)]
|
"hint" .= (("Row number " <> show i) :: Text)]
|
||||||
toJSON (H.ResultError (H.RowError i (H.ValueError d))) = JSON.object [
|
toJSON (H.ResultError (H.RowError i (H.ValueError d))) = JSON.object [
|
||||||
"message" .= ("Row error: Wrong value parser used"::Text),
|
"message" .= ("Row error: Wrong value parser used" :: Text),
|
||||||
"details" .= d,
|
"details" .= d,
|
||||||
"details" .= (("Row number " <> show i)::Text)]
|
"hint" .= (("Row number " <> show i) :: Text)]
|
||||||
toJSON (H.ResultError (H.UnexpectedAmountOfRows i)) = JSON.object [
|
toJSON (H.ResultError (H.UnexpectedAmountOfRows i)) = JSON.object [
|
||||||
"message" .= ("Unexpected amount of rows"::Text),
|
"message" .= ("Unexpected amount of rows" :: Text),
|
||||||
"details" .= i]
|
"details" .= i]
|
||||||
toJSON (H.ClientError d) = JSON.object [
|
toJSON (H.ClientError d) = JSON.object [
|
||||||
"message" .= ("Database client error"::Text),
|
"message" .= ("Database client error" :: Text),
|
||||||
"details" .= (fmap toS d::Maybe Text)]
|
"details" .= (fmap toS d :: Maybe Text)]
|
||||||
|
|
||||||
httpStatus :: Bool -> P.UsageError -> HT.Status
|
pgErrorStatus :: Bool -> P.UsageError -> HT.Status
|
||||||
httpStatus _ (P.ConnectionError _) = HT.status503
|
pgErrorStatus _ (P.ConnectionError _) = HT.status503
|
||||||
httpStatus authed (P.SessionError (H.ResultError (H.ServerError c m _ _))) =
|
pgErrorStatus _ (P.SessionError (H.QueryError _ _ (H.ClientError _))) = HT.status503
|
||||||
case toS c of
|
pgErrorStatus authed (P.SessionError (H.QueryError _ _ (H.ResultError rError))) =
|
||||||
'0':'8':_ -> HT.status503 -- pg connection err
|
case rError of
|
||||||
'0':'9':_ -> HT.status500 -- triggered action exception
|
(H.ServerError c m _ _) ->
|
||||||
'0':'L':_ -> HT.status403 -- invalid grantor
|
case toS c of
|
||||||
'0':'P':_ -> HT.status403 -- invalid role specification
|
'0':'8':_ -> HT.status503 -- pg connection err
|
||||||
"23503" -> HT.status409 -- foreign_key_violation
|
'0':'9':_ -> HT.status500 -- triggered action exception
|
||||||
"23505" -> HT.status409 -- unique_violation
|
'0':'L':_ -> HT.status403 -- invalid grantor
|
||||||
'2':'5':_ -> HT.status500 -- invalid tx state
|
'0':'P':_ -> HT.status403 -- invalid role specification
|
||||||
'2':'8':_ -> HT.status403 -- invalid auth specification
|
"23503" -> HT.status409 -- foreign_key_violation
|
||||||
'2':'D':_ -> HT.status500 -- invalid tx termination
|
"23505" -> HT.status409 -- unique_violation
|
||||||
'3':'8':_ -> HT.status500 -- external routine exception
|
'2':'5':_ -> HT.status500 -- invalid tx state
|
||||||
'3':'9':_ -> HT.status500 -- external routine invocation
|
'2':'8':_ -> HT.status403 -- invalid auth specification
|
||||||
'3':'B':_ -> HT.status500 -- savepoint exception
|
'2':'D':_ -> HT.status500 -- invalid tx termination
|
||||||
'4':'0':_ -> HT.status500 -- tx rollback
|
'3':'8':_ -> HT.status500 -- external routine exception
|
||||||
'5':'3':_ -> HT.status503 -- insufficient resources
|
'3':'9':_ -> HT.status500 -- external routine invocation
|
||||||
'5':'4':_ -> HT.status413 -- too complex
|
'3':'B':_ -> HT.status500 -- savepoint exception
|
||||||
'5':'5':_ -> HT.status500 -- obj not on prereq state
|
'4':'0':_ -> HT.status500 -- tx rollback
|
||||||
'5':'7':_ -> HT.status500 -- operator intervention
|
'5':'3':_ -> HT.status503 -- insufficient resources
|
||||||
'5':'8':_ -> HT.status500 -- system error
|
'5':'4':_ -> HT.status413 -- too complex
|
||||||
'F':'0':_ -> HT.status500 -- conf file error
|
'5':'5':_ -> HT.status500 -- obj not on prereq state
|
||||||
'H':'V':_ -> HT.status500 -- foreign data wrapper error
|
'5':'7':_ -> HT.status500 -- operator intervention
|
||||||
"P0001" -> HT.status400 -- default code for "raise"
|
'5':'8':_ -> HT.status500 -- system error
|
||||||
'P':'0':_ -> HT.status500 -- PL/pgSQL Error
|
'F':'0':_ -> HT.status500 -- conf file error
|
||||||
'X':'X':_ -> HT.status500 -- internal Error
|
'H':'V':_ -> HT.status500 -- foreign data wrapper error
|
||||||
"42883" -> HT.status404 -- undefined function
|
"P0001" -> HT.status400 -- default code for "raise"
|
||||||
"42P01" -> HT.status404 -- undefined table
|
'P':'0':_ -> HT.status500 -- PL/pgSQL Error
|
||||||
"42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege
|
'X':'X':_ -> HT.status500 -- internal Error
|
||||||
'P':'T':n -> fromMaybe HT.status500 (HT.mkStatus <$> readMaybe n <*> pure m)
|
"42883" -> HT.status404 -- undefined function
|
||||||
_ -> HT.status400
|
"42P01" -> HT.status404 -- undefined table
|
||||||
httpStatus _ (P.SessionError (H.ResultError _)) = HT.status500
|
"42501" -> if authed then HT.status403 else HT.status401 -- insufficient privilege
|
||||||
httpStatus _ (P.SessionError (H.ClientError _)) = HT.status503
|
'P':'T':n -> fromMaybe HT.status500 (HT.mkStatus <$> readMaybe n <*> pure m)
|
||||||
|
_ -> HT.status400
|
||||||
|
|
||||||
|
_ -> HT.status500
|
||||||
|
|
||||||
|
checkIsFatal :: PgError -> Maybe Text
|
||||||
|
checkIsFatal (PgError _ (P.ConnectionError e))
|
||||||
|
| isAuthFailureMessage = Just $ toS failureMessage
|
||||||
|
| otherwise = Nothing
|
||||||
|
where isAuthFailureMessage = "FATAL: password authentication failed" `isPrefixOf` toS failureMessage
|
||||||
|
failureMessage = fromMaybe "" e
|
||||||
|
checkIsFatal _ = Nothing
|
||||||
|
|
||||||
|
|
||||||
|
data SimpleError
|
||||||
|
= GucHeadersError
|
||||||
|
| BinaryFieldError ContentType
|
||||||
|
| ConnectionLostError
|
||||||
|
| PutMatchingPkError
|
||||||
|
| PutRangeNotAllowedError
|
||||||
|
| 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 PutMatchingPkError = HT.status400
|
||||||
|
status PutRangeNotAllowedError = HT.status400
|
||||||
|
status JwtTokenMissing = HT.status500
|
||||||
|
status (JwtTokenInvalid _) = HT.unauthorized401
|
||||||
|
status (SingularityError _) = HT.status406
|
||||||
|
status (ContentTypeError _) = HT.status415
|
||||||
|
|
||||||
|
headers (SingularityError _) = [toHeader CTSingularJSON]
|
||||||
|
headers (JwtTokenInvalid m) = [toHeader CTApplicationJSON, invalidTokenHeader m]
|
||||||
|
headers _ = [toHeader CTApplicationJSON]
|
||||||
|
|
||||||
|
instance JSON.ToJSON SimpleError where
|
||||||
|
toJSON GucHeadersError = JSON.object [
|
||||||
|
"message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text)]
|
||||||
|
toJSON (BinaryFieldError ct) = JSON.object [
|
||||||
|
"message" .= ((toS (toMime ct) <> " requested but more than one column was selected") :: Text)]
|
||||||
|
toJSON ConnectionLostError = JSON.object [
|
||||||
|
"message" .= ("Database connection lost, retrying the connection." :: Text)]
|
||||||
|
|
||||||
|
toJSON PutRangeNotAllowedError = JSON.object [
|
||||||
|
"message" .= ("Range header and limit/offset querystring parameters are not allowed for 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" .= T.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=" <> encodeUtf8 (show m))
|
||||||
|
|
||||||
|
singularityError :: (Integral a) => a -> SimpleError
|
||||||
|
singularityError = SingularityError . toInteger
|
||||||
|
|||||||
@@ -1,63 +1,71 @@
|
|||||||
|
{-|
|
||||||
|
Module : PostgREST.Middleware
|
||||||
|
Description : Sets the PostgreSQL GUCs, role, search_path and pre-request function. Validates JWT.
|
||||||
|
-}
|
||||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||||
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
{-# LANGUAGE ScopedTypeVariables #-}
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
{-# LANGUAGE FlexibleContexts #-}
|
|
||||||
|
|
||||||
module PostgREST.Middleware where
|
module PostgREST.Middleware where
|
||||||
|
|
||||||
import Crypto.JWT
|
import qualified Data.Aeson as JSON
|
||||||
import qualified Data.Aeson as JSON
|
import qualified Data.HashMap.Strict as M
|
||||||
import qualified Data.HashMap.Strict as M
|
import Data.Scientific (FPFormat (..), formatScientific,
|
||||||
import qualified Hasql.Transaction as H
|
isInteger)
|
||||||
|
import qualified Hasql.Transaction as H
|
||||||
|
|
||||||
import Network.HTTP.Types.Status (unauthorized401, status500)
|
import Network.Wai (Application, Response)
|
||||||
import Network.Wai (Application, Response)
|
import Network.Wai.Middleware.Cors (cors)
|
||||||
import Network.Wai.Middleware.Cors (cors)
|
import Network.Wai.Middleware.Gzip (def, gzip)
|
||||||
import Network.Wai.Middleware.Gzip (def, gzip)
|
import Network.Wai.Middleware.Static (only, staticPolicy)
|
||||||
import Network.Wai.Middleware.Static (only, staticPolicy)
|
|
||||||
|
|
||||||
import PostgREST.ApiRequest (ApiRequest(..))
|
import Crypto.JWT
|
||||||
import PostgREST.Auth (JWTAttempt(..))
|
|
||||||
import PostgREST.Config (AppConfig (..), corsPolicy)
|
|
||||||
import PostgREST.Error (simpleError)
|
|
||||||
import PostgREST.QueryBuilder (pgFmtLit, unquoted, pgFmtSetLocal)
|
|
||||||
|
|
||||||
import Protolude hiding (concat, null)
|
import PostgREST.ApiRequest (ApiRequest (..))
|
||||||
|
import PostgREST.Auth (JWTAttempt (..))
|
||||||
|
import PostgREST.Config (AppConfig (..), corsPolicy)
|
||||||
|
import PostgREST.Error (SimpleError (JwtTokenInvalid, JwtTokenMissing),
|
||||||
|
errorResponseFor)
|
||||||
|
import PostgREST.QueryBuilder (setLocalQuery, setLocalSearchPathQuery)
|
||||||
|
import Protolude hiding (head, toS)
|
||||||
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
runWithClaims :: AppConfig -> JWTAttempt ->
|
runWithClaims :: AppConfig -> JWTAttempt ->
|
||||||
(ApiRequest -> H.Transaction Response) ->
|
(ApiRequest -> H.Transaction Response) ->
|
||||||
ApiRequest -> H.Transaction Response
|
ApiRequest -> H.Transaction Response
|
||||||
runWithClaims conf eClaims app req =
|
runWithClaims conf eClaims app req =
|
||||||
case eClaims of
|
case eClaims of
|
||||||
JWTInvalid JWTExpired -> return $ unauthed "JWT expired"
|
JWTMissingSecret -> return . errorResponseFor $ JwtTokenMissing
|
||||||
JWTInvalid e -> return $ unauthed $ show e
|
JWTInvalid JWTExpired -> return . errorResponseFor . JwtTokenInvalid $ "JWT expired"
|
||||||
JWTMissingSecret -> return $ simpleError status500 [] "Server lacks JWT secret"
|
JWTInvalid e -> return . errorResponseFor . JwtTokenInvalid . show $ e
|
||||||
JWTClaims claims -> do
|
JWTClaims claims -> do
|
||||||
H.sql $ toS.mconcat $ setSchemaSql ++ setRoleSql ++ claimsSql ++ headersSql ++ cookiesSql ++ appSettingsSql
|
H.sql $ toS . mconcat $ setSearchPathSql : setRoleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql
|
||||||
mapM_ H.sql customReqCheck
|
mapM_ H.sql customReqCheck
|
||||||
app req
|
app req
|
||||||
where
|
where
|
||||||
headersSql = pgFmtSetLocal "request.header." <$> iHeaders req
|
methodSql = setLocalQuery mempty ("request.method", toS $ iMethod req)
|
||||||
cookiesSql = pgFmtSetLocal "request.cookie." <$> iCookies req
|
pathSql = setLocalQuery mempty ("request.path", toS $ iPath req)
|
||||||
claimsSql = pgFmtSetLocal "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]
|
headersSql = setLocalQuery "request.header." <$> iHeaders req
|
||||||
appSettingsSql = pgFmtSetLocal mempty <$> configSettings conf
|
cookiesSql = setLocalQuery "request.cookie." <$> iCookies req
|
||||||
setRoleSql = maybeToList $
|
claimsSql = setLocalQuery "request.jwt.claim." <$> [(c,unquoted v) | (c,v) <- M.toList claimsWithRole]
|
||||||
(\r -> "set local role " <> r <> ";") . toS . pgFmtLit . unquoted <$> M.lookup "role" claimsWithRole
|
appSettingsSql = setLocalQuery mempty <$> configSettings conf
|
||||||
setSchemaSql = ["set schema " <> pgFmtLit (configSchema conf) <> ";"] :: [Text]
|
setRoleSql = maybeToList $ (\x ->
|
||||||
|
setLocalQuery mempty ("role", unquoted x)) <$> M.lookup "role" claimsWithRole
|
||||||
|
setSearchPathSql = setLocalSearchPathQuery (iSchema req : configExtraSearchPath conf)
|
||||||
-- role claim defaults to anon if not specified in jwt
|
-- role claim defaults to anon if not specified in jwt
|
||||||
claimsWithRole = M.union claims (M.singleton "role" anon)
|
claimsWithRole = M.union claims (M.singleton "role" anon)
|
||||||
anon = JSON.String . toS $ configAnonRole conf
|
anon = JSON.String . toS $ configAnonRole conf
|
||||||
customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf
|
customReqCheck = (\f -> "select " <> toS f <> "();") <$> configReqCheck conf
|
||||||
where
|
|
||||||
unauthed message = simpleError
|
|
||||||
unauthorized401
|
|
||||||
[( "WWW-Authenticate"
|
|
||||||
, "Bearer error=\"invalid_token\", " <>
|
|
||||||
"error_description=" <> show message
|
|
||||||
)]
|
|
||||||
message
|
|
||||||
|
|
||||||
defaultMiddle :: Application -> Application
|
defaultMiddle :: Application -> Application
|
||||||
defaultMiddle =
|
defaultMiddle =
|
||||||
gzip def
|
gzip def
|
||||||
. cors corsPolicy
|
. cors corsPolicy
|
||||||
. staticPolicy (only [("favicon.ico", "static/favicon.ico")])
|
. staticPolicy (only [("favicon.ico", "static/favicon.ico")])
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,47 +1,62 @@
|
|||||||
|
{-|
|
||||||
|
Module : PostgREST.OpenAPI
|
||||||
|
Description : Generates the OpenAPI output
|
||||||
|
-}
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
module PostgREST.OpenAPI (
|
module PostgREST.OpenAPI (
|
||||||
encodeOpenAPI
|
encodeOpenAPI
|
||||||
, isMalformedProxyUri
|
, isMalformedProxyUri
|
||||||
, pickProxy
|
, pickProxy
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Arrow ((&&&))
|
import qualified Data.HashSet.InsOrd as Set
|
||||||
import Control.Lens
|
|
||||||
import Data.Aeson (decode, encode)
|
|
||||||
import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList)
|
|
||||||
import Data.Maybe (fromJust)
|
|
||||||
import qualified Data.Set as Set
|
|
||||||
import Data.String (IsString (..))
|
|
||||||
import Data.Text (unpack, pack, init, tail, toLower, intercalate, append, dropWhile, breakOn)
|
|
||||||
import Network.URI (parseURI, isAbsoluteURI,
|
|
||||||
URI (..), URIAuth (..))
|
|
||||||
|
|
||||||
import Protolude hiding ((&), Proxy, get, intercalate, dropWhile)
|
import Control.Arrow ((&&&))
|
||||||
|
import Data.Aeson (decode, encode)
|
||||||
|
import Data.HashMap.Strict.InsOrd (InsOrdHashMap, fromList)
|
||||||
|
import Data.Maybe (fromJust)
|
||||||
|
import Data.String (IsString (..))
|
||||||
|
import Data.Text (append, breakOn, dropWhile, init,
|
||||||
|
intercalate, pack, tail, toLower,
|
||||||
|
unpack)
|
||||||
|
import Network.URI (URI (..), URIAuth (..),
|
||||||
|
isAbsoluteURI, parseURI)
|
||||||
|
|
||||||
import Data.Swagger
|
import Control.Lens
|
||||||
|
import Data.Swagger
|
||||||
|
|
||||||
import PostgREST.ApiRequest (ContentType(..))
|
import PostgREST.ApiRequest (ContentType (..))
|
||||||
import PostgREST.Config (prettyVersion, docsVersion)
|
import PostgREST.Config (docsVersion, prettyVersion)
|
||||||
import PostgREST.Types (Table(..), Column(..), PgArg(..), ForeignKey(..),
|
import PostgREST.Types (Column (..), ForeignKey (..), PgArg (..),
|
||||||
PrimaryKey(..), Proxy(..), ProcDescription(..), toMime)
|
PrimaryKey (..), ProcDescription (..),
|
||||||
|
Proxy (..), Table (..), toMime)
|
||||||
|
import Protolude hiding (Proxy, dropWhile, get,
|
||||||
|
intercalate, toLower, toS, (&))
|
||||||
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
makeMimeList :: [ContentType] -> MimeList
|
makeMimeList :: [ContentType] -> MimeList
|
||||||
makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs
|
makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs
|
||||||
|
|
||||||
toSwaggerType :: Text -> SwaggerType t
|
toSwaggerType :: Text -> SwaggerType t
|
||||||
toSwaggerType "text" = SwaggerString
|
toSwaggerType "character varying" = SwaggerString
|
||||||
toSwaggerType "integer" = SwaggerInteger
|
toSwaggerType "character" = SwaggerString
|
||||||
toSwaggerType "boolean" = SwaggerBoolean
|
toSwaggerType "text" = SwaggerString
|
||||||
toSwaggerType "numeric" = SwaggerNumber
|
toSwaggerType "boolean" = SwaggerBoolean
|
||||||
toSwaggerType _ = SwaggerString
|
toSwaggerType "smallint" = SwaggerInteger
|
||||||
|
toSwaggerType "integer" = SwaggerInteger
|
||||||
|
toSwaggerType "bigint" = SwaggerInteger
|
||||||
|
toSwaggerType "numeric" = SwaggerNumber
|
||||||
|
toSwaggerType "real" = SwaggerNumber
|
||||||
|
toSwaggerType "double precision" = SwaggerNumber
|
||||||
|
toSwaggerType _ = SwaggerString
|
||||||
|
|
||||||
makeTableDef :: [PrimaryKey] -> (Table, [Column], [Text]) -> (Text, Schema)
|
makeTableDef :: [PrimaryKey] -> (Table, [Column], [Text]) -> (Text, Schema)
|
||||||
makeTableDef pks (t, cs, _) =
|
makeTableDef pks (t, cs, _) =
|
||||||
let tn = tableName t in
|
let tn = tableName t in
|
||||||
(tn, (mempty :: Schema)
|
(tn, (mempty :: Schema)
|
||||||
& description .~ tableDescription t
|
& description .~ tableDescription t
|
||||||
& type_ .~ SwaggerObject
|
& type_ ?~ SwaggerObject
|
||||||
& properties .~ fromList (map (makeProperty pks) cs)
|
& properties .~ fromList (map (makeProperty pks) cs)
|
||||||
& required .~ map colName (filter (not . colNullable) cs))
|
& required .~ map colName (filter (not . colNullable) cs))
|
||||||
|
|
||||||
@@ -60,7 +75,7 @@ makeProperty pks c = (colName c, Inline s)
|
|||||||
]
|
]
|
||||||
d =
|
d =
|
||||||
if length n > 1 then
|
if length n > 1 then
|
||||||
Just $ append (fromMaybe "" ((`append` "\n\n") <$> colDescription c)) (intercalate "\n" n)
|
Just $ append (maybe "" (`append` "\n\n") $ colDescription c) (intercalate "\n" n)
|
||||||
else
|
else
|
||||||
colDescription c
|
colDescription c
|
||||||
s =
|
s =
|
||||||
@@ -70,13 +85,13 @@ makeProperty pks c = (colName c, Inline s)
|
|||||||
& enum_ .~ e
|
& enum_ .~ e
|
||||||
& format ?~ colType c
|
& format ?~ colType c
|
||||||
& maxLength .~ (fromIntegral <$> colMaxLen c)
|
& maxLength .~ (fromIntegral <$> colMaxLen c)
|
||||||
& type_ .~ toSwaggerType (colType c)
|
& type_ ?~ toSwaggerType (colType c)
|
||||||
|
|
||||||
makeProcSchema :: ProcDescription -> Schema
|
makeProcSchema :: ProcDescription -> Schema
|
||||||
makeProcSchema pd =
|
makeProcSchema pd =
|
||||||
(mempty :: Schema)
|
(mempty :: Schema)
|
||||||
& description .~ pdDescription pd
|
& description .~ pdDescription pd
|
||||||
& type_ .~ SwaggerObject
|
& type_ ?~ SwaggerObject
|
||||||
& properties .~ fromList (map makeProcProperty (pdArgs pd))
|
& properties .~ fromList (map makeProcProperty (pdArgs pd))
|
||||||
& required .~ map pgaName (filter pgaReq (pdArgs pd))
|
& required .~ map pgaName (filter pgaReq (pdArgs pd))
|
||||||
|
|
||||||
@@ -84,7 +99,7 @@ makeProcProperty :: PgArg -> (Text, Referenced Schema)
|
|||||||
makeProcProperty (PgArg n t _) = (n, Inline s)
|
makeProcProperty (PgArg n t _) = (n, Inline s)
|
||||||
where
|
where
|
||||||
s = (mempty :: Schema)
|
s = (mempty :: Schema)
|
||||||
& type_ .~ toSwaggerType t
|
& type_ ?~ toSwaggerType t
|
||||||
& format ?~ t
|
& format ?~ t
|
||||||
|
|
||||||
makePreferParam :: [Text] -> Param
|
makePreferParam :: [Text] -> Param
|
||||||
@@ -95,7 +110,7 @@ makePreferParam ts =
|
|||||||
& required ?~ False
|
& required ?~ False
|
||||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
& in_ .~ ParamHeader
|
& in_ .~ ParamHeader
|
||||||
& type_ .~ SwaggerString
|
& type_ ?~ SwaggerString
|
||||||
& enum_ .~ decode (encode ts))
|
& enum_ .~ decode (encode ts))
|
||||||
|
|
||||||
makeProcParam :: ProcDescription -> [Referenced Param]
|
makeProcParam :: ProcDescription -> [Referenced Param]
|
||||||
@@ -103,7 +118,7 @@ makeProcParam pd =
|
|||||||
[ Inline $ (mempty :: Param)
|
[ Inline $ (mempty :: Param)
|
||||||
& name .~ "args"
|
& name .~ "args"
|
||||||
& required ?~ True
|
& required ?~ True
|
||||||
& schema .~ (ParamBody $ Inline $ makeProcSchema pd)
|
& schema .~ ParamBody (Inline $ makeProcSchema pd)
|
||||||
, Ref $ Reference "preferParams"
|
, Ref $ Reference "preferParams"
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -118,28 +133,35 @@ makeParamDefs ti =
|
|||||||
& required ?~ False
|
& required ?~ False
|
||||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
& in_ .~ ParamQuery
|
& in_ .~ ParamQuery
|
||||||
& type_ .~ SwaggerString))
|
& type_ ?~ SwaggerString))
|
||||||
|
, ("on_conflict", (mempty :: Param)
|
||||||
|
& name .~ "on_conflict"
|
||||||
|
& description ?~ "On Conflict"
|
||||||
|
& required ?~ False
|
||||||
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
|
& in_ .~ ParamQuery
|
||||||
|
& type_ ?~ SwaggerString))
|
||||||
, ("order", (mempty :: Param)
|
, ("order", (mempty :: Param)
|
||||||
& name .~ "order"
|
& name .~ "order"
|
||||||
& description ?~ "Ordering"
|
& description ?~ "Ordering"
|
||||||
& required ?~ False
|
& required ?~ False
|
||||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
& in_ .~ ParamQuery
|
& in_ .~ ParamQuery
|
||||||
& type_ .~ SwaggerString))
|
& type_ ?~ SwaggerString))
|
||||||
, ("range", (mempty :: Param)
|
, ("range", (mempty :: Param)
|
||||||
& name .~ "Range"
|
& name .~ "Range"
|
||||||
& description ?~ "Limiting and Pagination"
|
& description ?~ "Limiting and Pagination"
|
||||||
& required ?~ False
|
& required ?~ False
|
||||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
& in_ .~ ParamHeader
|
& in_ .~ ParamHeader
|
||||||
& type_ .~ SwaggerString))
|
& type_ ?~ SwaggerString))
|
||||||
, ("rangeUnit", (mempty :: Param)
|
, ("rangeUnit", (mempty :: Param)
|
||||||
& name .~ "Range-Unit"
|
& name .~ "Range-Unit"
|
||||||
& description ?~ "Limiting and Pagination"
|
& description ?~ "Limiting and Pagination"
|
||||||
& required ?~ False
|
& required ?~ False
|
||||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
& in_ .~ ParamHeader
|
& in_ .~ ParamHeader
|
||||||
& type_ .~ SwaggerString
|
& type_ ?~ SwaggerString
|
||||||
& default_ .~ decode "\"items\""))
|
& default_ .~ decode "\"items\""))
|
||||||
, ("offset", (mempty :: Param)
|
, ("offset", (mempty :: Param)
|
||||||
& name .~ "offset"
|
& name .~ "offset"
|
||||||
@@ -147,14 +169,14 @@ makeParamDefs ti =
|
|||||||
& required ?~ False
|
& required ?~ False
|
||||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
& in_ .~ ParamQuery
|
& in_ .~ ParamQuery
|
||||||
& type_ .~ SwaggerString))
|
& type_ ?~ SwaggerString))
|
||||||
, ("limit", (mempty :: Param)
|
, ("limit", (mempty :: Param)
|
||||||
& name .~ "limit"
|
& name .~ "limit"
|
||||||
& description ?~ "Limiting and Pagination"
|
& description ?~ "Limiting and Pagination"
|
||||||
& required ?~ False
|
& required ?~ False
|
||||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
& in_ .~ ParamQuery
|
& in_ .~ ParamQuery
|
||||||
& type_ .~ SwaggerString))
|
& type_ ?~ SwaggerString))
|
||||||
]
|
]
|
||||||
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) cs
|
<> concat [ makeObjectBody (tableName t) : makeRowFilters (tableName t) cs
|
||||||
| (t, cs, _) <- ti
|
| (t, cs, _) <- ti
|
||||||
@@ -176,7 +198,7 @@ makeRowFilter tn c =
|
|||||||
& required ?~ False
|
& required ?~ False
|
||||||
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
& schema .~ ParamOther ((mempty :: ParamOtherSchema)
|
||||||
& in_ .~ ParamQuery
|
& in_ .~ ParamQuery
|
||||||
& type_ .~ SwaggerString
|
& type_ ?~ SwaggerString
|
||||||
& format ?~ colType c))
|
& format ?~ colType c))
|
||||||
|
|
||||||
makeRowFilters :: Text -> [Column] -> [(Text, Param)]
|
makeRowFilters :: Text -> [Column] -> [(Text, Param)]
|
||||||
@@ -199,12 +221,12 @@ makePathItem (t, cs, _) = ("/" ++ unpack tn, p $ tableInsertable t)
|
|||||||
& at 200 ?~ Inline ((mempty :: Response)
|
& at 200 ?~ Inline ((mempty :: Response)
|
||||||
& description .~ "OK"
|
& description .~ "OK"
|
||||||
& schema ?~ Inline (mempty
|
& schema ?~ Inline (mempty
|
||||||
& type_ .~ SwaggerArray
|
& type_ ?~ SwaggerArray
|
||||||
& items ?~ (SwaggerItemsObject $ Ref $ Reference $ tableName t)
|
& items ?~ SwaggerItemsObject (Ref $ Reference $ tableName t)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
postOp = tOp
|
postOp = tOp
|
||||||
& parameters .~ map ref ["body." <> tn, "preferReturn"]
|
& parameters .~ map ref ["body." <> tn, "select", "preferReturn"]
|
||||||
& at 201 ?~ "Created"
|
& at 201 ?~ "Created"
|
||||||
patchOp = tOp
|
patchOp = tOp
|
||||||
& parameters .~ map ref (rs <> ["body." <> tn, "preferReturn"])
|
& parameters .~ map ref (rs <> ["body." <> tn, "preferReturn"])
|
||||||
@@ -319,7 +341,7 @@ pickProxy proxy
|
|||||||
uri = toURI $ fromJust proxy
|
uri = toURI $ fromJust proxy
|
||||||
scheme = init $ toLower $ pack $ uriScheme uri
|
scheme = init $ toLower $ pack $ uriScheme uri
|
||||||
path URI {uriPath = ""} = "/"
|
path URI {uriPath = ""} = "/"
|
||||||
path URI {uriPath = p} = p
|
path URI {uriPath = p} = p
|
||||||
path' = pack $ path uri
|
path' = pack $ path uri
|
||||||
authority = fromJust $ uriAuthority uri
|
authority = fromJust $ uriAuthority uri
|
||||||
host' = pack $ uriRegName authority
|
host' = pack $ uriRegName authority
|
||||||
@@ -327,15 +349,15 @@ pickProxy proxy
|
|||||||
readPort = fromMaybe 80 . readMaybe
|
readPort = fromMaybe 80 . readMaybe
|
||||||
port'' :: Integer
|
port'' :: Integer
|
||||||
port'' = case (port', scheme) of
|
port'' = case (port', scheme) of
|
||||||
("", "http") -> 80
|
("", "http") -> 80
|
||||||
("", "https") -> 443
|
("", "https") -> 443
|
||||||
_ -> readPort $ unpack $ tail $ pack port'
|
_ -> readPort $ unpack $ tail $ pack port'
|
||||||
|
|
||||||
isUriValid:: URI -> Bool
|
isUriValid:: URI -> Bool
|
||||||
isUriValid = fAnd [isSchemeValid, isQueryValid, isAuthorityValid]
|
isUriValid = fAnd [isSchemeValid, isQueryValid, isAuthorityValid]
|
||||||
|
|
||||||
fAnd :: [a -> Bool] -> a -> Bool
|
fAnd :: [a -> Bool] -> a -> Bool
|
||||||
fAnd fs x = all ($x) fs
|
fAnd fs x = all ($ x) fs
|
||||||
|
|
||||||
isSchemeValid :: URI -> Bool
|
isSchemeValid :: URI -> Bool
|
||||||
isSchemeValid URI {uriScheme = s}
|
isSchemeValid URI {uriScheme = s}
|
||||||
@@ -345,7 +367,7 @@ isSchemeValid URI {uriScheme = s}
|
|||||||
|
|
||||||
isQueryValid :: URI -> Bool
|
isQueryValid :: URI -> Bool
|
||||||
isQueryValid URI {uriQuery = ""} = True
|
isQueryValid URI {uriQuery = ""} = True
|
||||||
isQueryValid _ = False
|
isQueryValid _ = False
|
||||||
|
|
||||||
isAuthorityValid :: URI -> Bool
|
isAuthorityValid :: URI -> Bool
|
||||||
isAuthorityValid URI {uriAuthority = a}
|
isAuthorityValid URI {uriAuthority = a}
|
||||||
@@ -354,16 +376,16 @@ isAuthorityValid URI {uriAuthority = a}
|
|||||||
|
|
||||||
isUserInfoValid :: URIAuth -> Bool
|
isUserInfoValid :: URIAuth -> Bool
|
||||||
isUserInfoValid URIAuth {uriUserInfo = ""} = True
|
isUserInfoValid URIAuth {uriUserInfo = ""} = True
|
||||||
isUserInfoValid _ = False
|
isUserInfoValid _ = False
|
||||||
|
|
||||||
isHostValid :: URIAuth -> Bool
|
isHostValid :: URIAuth -> Bool
|
||||||
isHostValid URIAuth {uriRegName = ""} = False
|
isHostValid URIAuth {uriRegName = ""} = False
|
||||||
isHostValid _ = True
|
isHostValid _ = True
|
||||||
|
|
||||||
isPortValid :: URIAuth -> Bool
|
isPortValid :: URIAuth -> Bool
|
||||||
isPortValid URIAuth {uriPort = ""} = True
|
isPortValid URIAuth {uriPort = ""} = True
|
||||||
isPortValid URIAuth {uriPort = (':':p)} =
|
isPortValid URIAuth {uriPort = (':':p)} =
|
||||||
case readMaybe p of
|
case readMaybe p of
|
||||||
Just i -> i > (0 :: Integer) && i < 65536
|
Just i -> i > (0 :: Integer) && i < 65536
|
||||||
Nothing -> False
|
Nothing -> False
|
||||||
isPortValid _ = False
|
isPortValid _ = False
|
||||||
|
|||||||
@@ -1,23 +1,39 @@
|
|||||||
|
{-|
|
||||||
|
Module : PostgREST.Parsers
|
||||||
|
Description : PostgREST parser combinators
|
||||||
|
|
||||||
|
This module is in charge of parsing all the querystring values in an url, e.g. the select, id, order in `/projects?select=id,name&id=eq.1&order=id,name.desc`.
|
||||||
|
-}
|
||||||
module PostgREST.Parsers where
|
module PostgREST.Parsers where
|
||||||
|
|
||||||
import Protolude hiding (try, intercalate, replace, option)
|
import qualified Data.HashMap.Strict as M
|
||||||
import Control.Monad ((>>))
|
import qualified Data.Set as S
|
||||||
import Data.Foldable (foldl1)
|
|
||||||
import qualified Data.HashMap.Strict as M
|
import Data.Either.Combinators (mapLeft)
|
||||||
import Data.Text (intercalate, replace, strip)
|
import Data.Foldable (foldl1)
|
||||||
import Data.List (init, last)
|
import Data.List (init, last)
|
||||||
import Data.Tree
|
import Data.Text (intercalate, replace, strip)
|
||||||
import Data.Either.Combinators (mapLeft)
|
import Text.Read (read)
|
||||||
import PostgREST.RangeQuery (NonnegRange)
|
|
||||||
import PostgREST.Types
|
import Data.Tree
|
||||||
import Text.ParserCombinators.Parsec hiding (many, (<|>))
|
import Text.Parsec.Error
|
||||||
import Text.Parsec.Error
|
import Text.ParserCombinators.Parsec hiding (many, (<|>))
|
||||||
import Text.Read (read)
|
|
||||||
|
import PostgREST.Error (ApiRequestError (ParseRequestError))
|
||||||
|
import PostgREST.RangeQuery (NonnegRange)
|
||||||
|
import PostgREST.Types
|
||||||
|
import Protolude hiding (intercalate, option, replace, toS,
|
||||||
|
try)
|
||||||
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]
|
pRequestSelect :: Text -> Either ApiRequestError [Tree SelectItem]
|
||||||
pRequestSelect selStr =
|
pRequestSelect selStr =
|
||||||
mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
|
mapError $ parse pFieldForest ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr)
|
||||||
|
|
||||||
|
pRequestOnConflict :: Text -> Either ApiRequestError [FieldName]
|
||||||
|
pRequestOnConflict oncStr =
|
||||||
|
mapError $ parse pColumns ("failed to parse on_conflict parameter (" <> toS oncStr <> ")") (toS oncStr)
|
||||||
|
|
||||||
pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)
|
pRequestFilter :: (Text, Text) -> Either ApiRequestError (EmbedPath, Filter)
|
||||||
pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
|
pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper)
|
||||||
where
|
where
|
||||||
@@ -44,9 +60,18 @@ pRequestLogicTree (k, v) = mapError $ (,) <$> embedPath <*> logicTree
|
|||||||
where
|
where
|
||||||
path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k
|
path = parse pLogicPath ("failed to parser logic path (" ++ toS k ++ ")") $ toS k
|
||||||
embedPath = fst <$> path
|
embedPath = fst <$> path
|
||||||
op = snd <$> path
|
logicTree = do
|
||||||
-- Concat op and v to make pLogicTree argument regular, in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)"
|
op <- snd <$> path
|
||||||
logicTree = join $ parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") . toS <$> ((<>) <$> op <*> pure v)
|
-- Concat op and v to make pLogicTree argument regular,
|
||||||
|
-- in the form of "?and=and(.. , ..)" instead of "?and=(.. , ..)"
|
||||||
|
parse pLogicTree ("failed to parse logic tree (" ++ toS v ++ ")") $ toS (op <> v)
|
||||||
|
|
||||||
|
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 :: Parser Text
|
||||||
ws = toS <$> many (oneOf " \t")
|
ws = toS <$> many (oneOf " \t")
|
||||||
@@ -68,17 +93,18 @@ pFieldForest = pFieldTree `sepBy1` lexeme (char ',')
|
|||||||
Node <$> pFieldSelect <*> pure []
|
Node <$> pFieldSelect <*> pure []
|
||||||
|
|
||||||
pStar :: Parser Text
|
pStar :: Parser Text
|
||||||
pStar = toS <$> (string "*" *> pure ("*"::ByteString))
|
pStar = toS <$> (string "*" $> ("*"::ByteString))
|
||||||
|
|
||||||
pFieldName :: Parser Text
|
pFieldName :: Parser Text
|
||||||
pFieldName = do
|
pFieldName =
|
||||||
matches <- (many1 (letter <|> digit <|> oneOf "_") `sepBy1` dash) <?> "field name (* or [a..z0..9_])"
|
pQuotedValue <|>
|
||||||
return $ intercalate "-" $ map toS matches
|
intercalate "-" . map toS <$> (many1 (letter <|> digit <|> oneOf "_ ") `sepBy1` dash) <?>
|
||||||
|
"field name (* or [a..z0..9_])"
|
||||||
where
|
where
|
||||||
isDash :: GenParser Char st ()
|
isDash :: GenParser Char st ()
|
||||||
isDash = try ( char '-' >> notFollowedBy (char '>') )
|
isDash = try ( char '-' >> notFollowedBy (char '>') )
|
||||||
dash :: Parser Char
|
dash :: Parser Char
|
||||||
dash = isDash *> pure '-'
|
dash = isDash $> '-'
|
||||||
|
|
||||||
pJsonPath :: Parser JsonPath
|
pJsonPath :: Parser JsonPath
|
||||||
pJsonPath = many pJsonOperation
|
pJsonPath = many pJsonOperation
|
||||||
@@ -108,9 +134,12 @@ pRelationSelect :: Parser SelectItem
|
|||||||
pRelationSelect = lexeme $ try ( do
|
pRelationSelect = lexeme $ try ( do
|
||||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||||
fld <- pField
|
fld <- pField
|
||||||
relationDetail <- optionMaybe ( try( char '.' *> pFieldName ) )
|
hint <- optionMaybe (
|
||||||
|
try ( char '!' *> pFieldName) <|>
|
||||||
return (fld, Nothing, alias, relationDetail)
|
-- deprecated, remove in next major version
|
||||||
|
try ( char '.' *> pFieldName)
|
||||||
|
)
|
||||||
|
return (fld, Nothing, alias, hint)
|
||||||
)
|
)
|
||||||
|
|
||||||
pFieldSelect :: Parser SelectItem
|
pFieldSelect :: Parser SelectItem
|
||||||
@@ -151,10 +180,10 @@ pListVal :: Parser ListVal
|
|||||||
pListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')
|
pListVal = lexeme (char '(') *> pListElement `sepBy1` char ',' <* lexeme (char ')')
|
||||||
|
|
||||||
pListElement :: Parser Text
|
pListElement :: Parser Text
|
||||||
pListElement = try pQuotedValue <|> (toS <$> many (noneOf ",)"))
|
pListElement = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> (toS <$> many (noneOf ",)"))
|
||||||
|
|
||||||
pQuotedValue :: Parser Text
|
pQuotedValue :: Parser Text
|
||||||
pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"' <* notFollowedBy (noneOf ",)"))
|
pQuotedValue = toS <$> (char '"' *> many (noneOf "\"") <* char '"')
|
||||||
|
|
||||||
pDelimiter :: Parser Char
|
pDelimiter :: Parser Char
|
||||||
pDelimiter = char '.' <?> "delimiter (.)"
|
pDelimiter = char '.' <?> "delimiter (.)"
|
||||||
@@ -184,16 +213,16 @@ pLogicTree = Stmnt <$> try pLogicFilter
|
|||||||
pLogicFilter :: Parser Filter
|
pLogicFilter :: Parser Filter
|
||||||
pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal
|
pLogicFilter = Filter <$> pField <* pDelimiter <*> pOpExpr pLogicSingleVal
|
||||||
pNot :: Parser Bool
|
pNot :: Parser Bool
|
||||||
pNot = try (string "not" *> pDelimiter *> pure True)
|
pNot = try (string "not" *> pDelimiter $> True)
|
||||||
<|> pure False
|
<|> pure False
|
||||||
<?> "negation operator (not)"
|
<?> "negation operator (not)"
|
||||||
pLogicOp :: Parser LogicOperator
|
pLogicOp :: Parser LogicOperator
|
||||||
pLogicOp = try (string "and" *> pure And)
|
pLogicOp = try (string "and" $> And)
|
||||||
<|> string "or" *> pure Or
|
<|> string "or" $> Or
|
||||||
<?> "logic operator (and, or)"
|
<?> "logic operator (and, or)"
|
||||||
|
|
||||||
pLogicSingleVal :: Parser SingleVal
|
pLogicSingleVal :: Parser SingleVal
|
||||||
pLogicSingleVal = try pQuotedValue <|> try pPgArray <|> (toS <$> many (noneOf ",)"))
|
pLogicSingleVal = try (pQuotedValue <* notFollowedBy (noneOf ",)")) <|> try pPgArray <|> (toS <$> many (noneOf ",)"))
|
||||||
where
|
where
|
||||||
pPgArray :: Parser Text
|
pPgArray :: Parser Text
|
||||||
pPgArray = do
|
pPgArray = do
|
||||||
@@ -209,6 +238,9 @@ pLogicPath = do
|
|||||||
notOp = "not." <> op
|
notOp = "not." <> op
|
||||||
return (filter (/= "not") (init path), if "not" `elem` path then notOp else op)
|
return (filter (/= "not") (init path), if "not" `elem` path then notOp else op)
|
||||||
|
|
||||||
|
pColumns :: Parser [FieldName]
|
||||||
|
pColumns = pFieldName `sepBy1` lexeme (char ',')
|
||||||
|
|
||||||
mapError :: Either ParseError a -> Either ApiRequestError a
|
mapError :: Either ParseError a -> Either ApiRequestError a
|
||||||
mapError = mapLeft translateError
|
mapError = mapLeft translateError
|
||||||
where
|
where
|
||||||
@@ -234,9 +266,7 @@ pJSPath = toJSPath <$> (period *> pPath `sepBy` period <* eof)
|
|||||||
pPath = (,) <$> pJSPKey <*> optionMaybe pJSPIdx
|
pPath = (,) <$> pJSPKey <*> optionMaybe pJSPIdx
|
||||||
|
|
||||||
pJSPKey :: Parser Text
|
pJSPKey :: Parser Text
|
||||||
pJSPKey = toS <$> (many1 (alphaNum <|> oneOf "_$@") <|> pQuoted) <?> "attribute name [a..z0..9_$@])"
|
pJSPKey = toS <$> many1 (alphaNum <|> oneOf "_$@") <|> pQuotedValue <?> "attribute name [a..z0..9_$@])"
|
||||||
where
|
|
||||||
pQuoted = char '"' *> many (noneOf "\"") <* char '"'
|
|
||||||
|
|
||||||
pJSPIdx :: Parser Int
|
pJSPIdx :: Parser Int
|
||||||
pJSPIdx = char '[' *> (read <$> many1 digit) <* char ']' <?> "array index [0..n]"
|
pJSPIdx = char '[' *> (read <$> many1 digit) <* char ']' <?> "array index [0..n]"
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{-|
|
||||||
|
Module : PostgREST.Common
|
||||||
|
Description : Common helper functions.
|
||||||
|
-}
|
||||||
|
module PostgREST.Private.Common where
|
||||||
|
|
||||||
|
import Data.Maybe
|
||||||
|
import qualified Hasql.Decoders as HD
|
||||||
|
import qualified Hasql.Encoders as HE
|
||||||
|
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
|
||||||
|
|
||||||
|
arrayParam :: HE.Value a -> HE.Params [a]
|
||||||
|
arrayParam = param . HE.array . HE.dimension foldl' . HE.element . HE.nonNullable
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
{-# LANGUAGE LambdaCase #-}
|
||||||
|
{-|
|
||||||
|
Module : PostgREST.Private.QueryFragment
|
||||||
|
Description : Helper functions for PostgREST.QueryBuilder.
|
||||||
|
|
||||||
|
Any function that outputs a SqlFragment should be in this module.
|
||||||
|
-}
|
||||||
|
module PostgREST.Private.QueryFragment where
|
||||||
|
|
||||||
|
import qualified Data.HashMap.Strict as HM
|
||||||
|
import Data.Maybe
|
||||||
|
import Data.Text (intercalate,
|
||||||
|
isInfixOf, replace,
|
||||||
|
toLower)
|
||||||
|
import qualified Data.Text as T (map, null,
|
||||||
|
takeWhile)
|
||||||
|
import PostgREST.Types
|
||||||
|
import Protolude hiding (cast,
|
||||||
|
intercalate, replace,
|
||||||
|
toLower)
|
||||||
|
import Text.InterpolatedString.Perl6 (qc)
|
||||||
|
|
||||||
|
noLocationF :: SqlFragment
|
||||||
|
noLocationF = "array[]::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 = "pgrst_ignored_body AS (SELECT $1::text) "
|
||||||
|
|
||||||
|
-- |
|
||||||
|
-- These CTEs convert a json object into a json array, this way we can use json_populate_recordset for all json payloads
|
||||||
|
-- Otherwise we'd have to use json_populate_record for json objects and json_populate_recordset for json arrays
|
||||||
|
-- We do this in SQL to avoid processing the JSON in application code
|
||||||
|
normalizedBody :: SqlFragment
|
||||||
|
normalizedBody =
|
||||||
|
unwords [
|
||||||
|
"pgrst_payload AS (SELECT $1::json AS json_data),",
|
||||||
|
"pgrst_body AS (",
|
||||||
|
"SELECT",
|
||||||
|
"CASE WHEN json_typeof(json_data) = 'array'",
|
||||||
|
"THEN json_data",
|
||||||
|
"ELSE json_build_array(json_data)",
|
||||||
|
"END AS val",
|
||||||
|
"FROM pgrst_payload)"]
|
||||||
|
|
||||||
|
selectBody :: SqlFragment
|
||||||
|
selectBody = "(SELECT val FROM pgrst_body)"
|
||||||
|
|
||||||
|
pgFmtLit :: SqlFragment -> SqlFragment
|
||||||
|
pgFmtLit x =
|
||||||
|
let trimmed = trimNullChars x
|
||||||
|
escaped = "'" <> replace "'" "''" trimmed <> "'"
|
||||||
|
slashed = replace "\\" "\\\\" escaped in
|
||||||
|
if "\\" `isInfixOf` escaped
|
||||||
|
then "E" <> slashed
|
||||||
|
else slashed
|
||||||
|
|
||||||
|
pgFmtIdent :: SqlFragment -> SqlFragment
|
||||||
|
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
|
||||||
|
|
||||||
|
asCsvF :: SqlFragment
|
||||||
|
asCsvF = asCsvHeaderF <> " || '\n' || " <> asCsvBodyF
|
||||||
|
where
|
||||||
|
asCsvHeaderF =
|
||||||
|
"(SELECT coalesce(string_agg(a.k, ','), '')" <>
|
||||||
|
" FROM (" <>
|
||||||
|
" SELECT json_object_keys(r)::TEXT as k" <>
|
||||||
|
" FROM ( " <>
|
||||||
|
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
|
||||||
|
" ) s" <>
|
||||||
|
" ) a" <>
|
||||||
|
")"
|
||||||
|
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
|
||||||
|
|
||||||
|
asJsonF :: SqlFragment
|
||||||
|
asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
|
||||||
|
|
||||||
|
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
||||||
|
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
|
||||||
|
|
||||||
|
asBinaryF :: FieldName -> SqlFragment
|
||||||
|
asBinaryF fieldName = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
|
||||||
|
|
||||||
|
locationF :: [Text] -> SqlFragment
|
||||||
|
locationF pKeys = [qc|(
|
||||||
|
WITH data AS (SELECT row_to_json(_) AS row FROM {sourceCTEName} AS _ LIMIT 1)
|
||||||
|
SELECT array_agg(json_data.key || '=' || coalesce('eq.' || json_data.value, 'is.null'))
|
||||||
|
FROM data CROSS JOIN json_each_text(data.row) AS json_data
|
||||||
|
{("WHERE json_data.key IN ('" <> intercalate "','" pKeys <> "')") `emptyOnFalse` null pKeys}
|
||||||
|
)|]
|
||||||
|
|
||||||
|
fromQi :: QualifiedIdentifier -> SqlFragment
|
||||||
|
fromQi t = (if s == "" then "" else pgFmtIdent s <> ".") <> pgFmtIdent n
|
||||||
|
where
|
||||||
|
n = qiName t
|
||||||
|
s = qiSchema t
|
||||||
|
|
||||||
|
emptyOnFalse :: Text -> Bool -> Text
|
||||||
|
emptyOnFalse val cond = if cond then "" else val
|
||||||
|
|
||||||
|
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
|
||||||
|
pgFmtColumn table "*" = fromQi table <> ".*"
|
||||||
|
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
|
||||||
|
|
||||||
|
pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
|
||||||
|
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
|
||||||
|
|
||||||
|
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
|
||||||
|
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias
|
||||||
|
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs fName jp alias
|
||||||
|
|
||||||
|
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment
|
||||||
|
pgFmtOrderTerm qi ot = unwords [
|
||||||
|
toS . pgFmtField qi $ otTerm ot,
|
||||||
|
maybe "" show $ otDirection ot,
|
||||||
|
maybe "" show $ otNullOrder ot]
|
||||||
|
|
||||||
|
pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
|
||||||
|
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
|
||||||
|
Op op val -> pgFmtFieldOp op <> " " <> case op of
|
||||||
|
"like" -> unknownLiteral (T.map star val)
|
||||||
|
"ilike" -> unknownLiteral (T.map star val)
|
||||||
|
"is" -> whiteList val
|
||||||
|
_ -> unknownLiteral val
|
||||||
|
|
||||||
|
In vals -> pgFmtField table fld <> " " <>
|
||||||
|
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
|
||||||
|
case (&&) (length vals == 1) . T.null <$> headMay vals of
|
||||||
|
Just False -> sqlOperator "in" <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") "
|
||||||
|
Just True -> emptyValForIn
|
||||||
|
Nothing -> emptyValForIn
|
||||||
|
|
||||||
|
Fts op lang val ->
|
||||||
|
pgFmtFieldOp op
|
||||||
|
<> "("
|
||||||
|
<> maybe "" ((<> ", ") . pgFmtLit) lang
|
||||||
|
<> unknownLiteral val
|
||||||
|
<> ") "
|
||||||
|
where
|
||||||
|
pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op
|
||||||
|
sqlOperator o = HM.lookupDefault "=" o operators
|
||||||
|
notOp = if hasNot then "NOT" else ""
|
||||||
|
star c = if c == '*' then '%' else c
|
||||||
|
unknownLiteral = (<> "::unknown ") . pgFmtLit
|
||||||
|
whiteList :: Text -> SqlFragment
|
||||||
|
whiteList v = fromMaybe
|
||||||
|
(toS (pgFmtLit v) <> "::unknown ")
|
||||||
|
(find ((==) . toLower $ v) ["null","true","false"])
|
||||||
|
|
||||||
|
pgFmtJoinCondition :: JoinCondition -> SqlFragment
|
||||||
|
pgFmtJoinCondition (JoinCondition (qi1, col1) (qi2, col2)) =
|
||||||
|
pgFmtColumn qi1 col1 <> " = " <> pgFmtColumn qi2 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
|
||||||
|
|
||||||
|
trimNullChars :: Text -> Text
|
||||||
|
trimNullChars = T.takeWhile (/= '\x0')
|
||||||
|
|
||||||
|
countF :: SqlQuery -> Bool -> (SqlFragment, SqlFragment)
|
||||||
|
countF countQuery shouldCount =
|
||||||
|
if shouldCount
|
||||||
|
then (
|
||||||
|
", pgrst_source_count AS (" <> countQuery <> ")"
|
||||||
|
, "(SELECT pg_catalog.count(*) FROM pgrst_source_count)" )
|
||||||
|
else (
|
||||||
|
mempty
|
||||||
|
, "null::bigint")
|
||||||
|
|
||||||
|
returningF :: QualifiedIdentifier -> [FieldName] -> SqlFragment
|
||||||
|
returningF qi returnings =
|
||||||
|
if null returnings
|
||||||
|
then "RETURNING 1" -- For mutation cases where there's no ?select, we return 1 to know how many rows were modified
|
||||||
|
else "RETURNING " <> intercalate ", " (pgFmtColumn qi <$> returnings)
|
||||||
|
|
||||||
|
responseHeadersF :: PgVersion -> SqlFragment
|
||||||
|
responseHeadersF pgVer =
|
||||||
|
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
|
||||||
@@ -1,477 +1,193 @@
|
|||||||
{-# LANGUAGE FlexibleInstances #-}
|
|
||||||
{-# LANGUAGE FlexibleContexts #-}
|
|
||||||
{-# LANGUAGE DuplicateRecordFields #-}
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
{-# LANGUAGE LambdaCase #-}
|
{-# LANGUAGE FlexibleContexts #-}
|
||||||
|
{-# LANGUAGE FlexibleInstances #-}
|
||||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||||
{-|
|
{-|
|
||||||
Module : PostgREST.QueryBuilder
|
Module : PostgREST.QueryBuilder
|
||||||
Description : PostgREST SQL generating functions.
|
Description : PostgREST SQL queries generating functions.
|
||||||
|
|
||||||
This module provides functions to consume data types that
|
This module provides functions to consume data types that
|
||||||
represent database objects (e.g. Relation, Schema, SqlQuery)
|
represent database queries (e.g. ReadRequest, MutateRequest) and SqlFragment
|
||||||
and produces SQL Statements.
|
to produce SqlQuery type outputs.
|
||||||
|
|
||||||
Any function that outputs a SQL fragment should be in this module.
|
|
||||||
-}
|
-}
|
||||||
module PostgREST.QueryBuilder (
|
module PostgREST.QueryBuilder (
|
||||||
callProc
|
readRequestToQuery
|
||||||
, createReadStatement
|
, mutateRequestToQuery
|
||||||
, createWriteStatement
|
, readRequestToCountQuery
|
||||||
, pgFmtIdent
|
, requestToCallProcQuery
|
||||||
, pgFmtLit
|
, limitedQuery
|
||||||
, requestToQuery
|
, setLocalQuery
|
||||||
, requestToCountQuery
|
, setLocalSearchPathQuery
|
||||||
, unquoted
|
|
||||||
, ResultsWithCount
|
|
||||||
, pgFmtSetLocal
|
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Hasql.Query as H
|
import qualified Data.Set as S
|
||||||
import qualified Hasql.Encoders as HE
|
|
||||||
import qualified Hasql.Decoders as HD
|
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
import Data.Text (intercalate)
|
||||||
|
import Data.Tree (Tree (..))
|
||||||
|
|
||||||
import PostgREST.Config (pgVersion96)
|
import Data.Maybe
|
||||||
import PostgREST.RangeQuery (rangeLimit, rangeOffset, allRange)
|
|
||||||
import qualified Data.HashMap.Strict as HM
|
|
||||||
import Data.Maybe
|
|
||||||
import qualified Data.Set as S
|
|
||||||
import Data.Text (intercalate, unwords, replace, isInfixOf, toLower)
|
|
||||||
import qualified Data.Text as T (map, takeWhile, null)
|
|
||||||
import qualified Data.Text.Encoding as T
|
|
||||||
import Data.Tree (Tree(..))
|
|
||||||
import PostgREST.Types
|
|
||||||
import Text.InterpolatedString.Perl6 (qc)
|
|
||||||
import qualified Data.ByteString.Char8 as BS
|
|
||||||
import Data.Scientific ( FPFormat (..)
|
|
||||||
, formatScientific
|
|
||||||
, isInteger
|
|
||||||
)
|
|
||||||
import Protolude hiding (from, intercalate, ord, cast, replace)
|
|
||||||
import PostgREST.ApiRequest (PreferRepresentation (..))
|
|
||||||
|
|
||||||
{-| The generic query result format used by API responses. The location header
|
import PostgREST.Private.QueryFragment
|
||||||
is represented as a list of strings containing variable bindings like
|
import PostgREST.RangeQuery (allRange, rangeLimit,
|
||||||
@"k1=eq.42"@, or the empty list if there is no location header.
|
rangeOffset)
|
||||||
-}
|
import PostgREST.Types
|
||||||
type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString)
|
import Protolude hiding (cast, intercalate,
|
||||||
|
replace)
|
||||||
|
|
||||||
standardRow :: HD.Row ResultsWithCount
|
readRequestToQuery :: ReadRequest -> SqlQuery
|
||||||
standardRow = (,,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8
|
readRequestToQuery (Node (Select colSelects mainQi tblAlias implJoins logicForest joinConditions_ ordts range, _) forest) =
|
||||||
<*> HD.value header <*> HD.value HD.bytea
|
|
||||||
where
|
|
||||||
header = HD.array $ HD.arrayDimension replicateM $ HD.arrayValue HD.bytea
|
|
||||||
|
|
||||||
noLocationF :: Text
|
|
||||||
noLocationF = "array[]::text[]"
|
|
||||||
|
|
||||||
{-| Read and Write api requests use a similar response format which includes
|
|
||||||
various record counts and possible location header. This is the decoder
|
|
||||||
for that common type of query.
|
|
||||||
-}
|
|
||||||
decodeStandard :: HD.Result ResultsWithCount
|
|
||||||
decodeStandard =
|
|
||||||
HD.singleRow standardRow
|
|
||||||
|
|
||||||
decodeStandardMay :: HD.Result (Maybe ResultsWithCount)
|
|
||||||
decodeStandardMay =
|
|
||||||
HD.maybeRow standardRow
|
|
||||||
|
|
||||||
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName ->
|
|
||||||
H.Query () ResultsWithCount
|
|
||||||
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField =
|
|
||||||
unicodeStatement sql HE.unit decodeStandard False
|
|
||||||
where
|
|
||||||
sql = [qc|
|
|
||||||
WITH {sourceCTEName} AS ({selectQuery}) SELECT {cols}
|
|
||||||
FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]
|
|
||||||
countResultF = if countTotal then "("<>countQuery<>")" else "null"
|
|
||||||
cols = intercalate ", " [
|
|
||||||
countResultF <> " AS total_result_set",
|
|
||||||
"pg_catalog.count(_postgrest_t) AS page_total",
|
|
||||||
noLocationF <> " AS header",
|
|
||||||
bodyF <> " AS body"
|
|
||||||
]
|
|
||||||
bodyF
|
|
||||||
| asCsv = asCsvF
|
|
||||||
| isSingle = asJsonSingleF
|
|
||||||
| isJust binaryField = asBinaryF $ fromJust binaryField
|
|
||||||
| otherwise = asJsonF
|
|
||||||
|
|
||||||
|
|
||||||
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
|
|
||||||
PreferRepresentation -> [Text] ->
|
|
||||||
H.Query ByteString (Maybe ResultsWithCount)
|
|
||||||
createWriteStatement selectQuery mutateQuery wantSingle wantHdrs asCsv rep pKeys =
|
|
||||||
unicodeStatement sql (HE.value 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 wantHdrs
|
|
||||||
then "coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")"
|
|
||||||
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 -> Bool -> PgVersion ->
|
|
||||||
H.Query ByteString (Maybe ProcResults)
|
|
||||||
callProc qi pgArgs returnsScalar selectQuery countQuery countTotal isSingle paramsAsSingleObject asCsv asBinary binaryField isObject pgVer =
|
|
||||||
unicodeStatement sql (HE.value HE.unknown) decodeProc True
|
|
||||||
where
|
|
||||||
sql =
|
|
||||||
if returnsScalar then [qc|
|
|
||||||
WITH {argsRecord},
|
|
||||||
{sourceCTEName} AS (
|
|
||||||
SELECT {fromQi qi}({args})
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
{countResultF} AS total_result_set,
|
|
||||||
1 AS page_total,
|
|
||||||
{scalarBodyF} AS body,
|
|
||||||
{responseHeaders} AS response_headers
|
|
||||||
FROM ({selectQuery}) _postgrest_t;|]
|
|
||||||
else [qc|
|
|
||||||
WITH {argsRecord},
|
|
||||||
{sourceCTEName} AS (
|
|
||||||
SELECT * FROM {fromQi qi}({args})
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
{countResultF} AS total_result_set,
|
|
||||||
pg_catalog.count(_postgrest_t) AS page_total,
|
|
||||||
{bodyF} AS body,
|
|
||||||
{responseHeaders} AS response_headers
|
|
||||||
FROM ({selectQuery}) _postgrest_t;|]
|
|
||||||
|
|
||||||
(argsRecord, args) | paramsAsSingleObject = ("_args_record AS (SELECT NULL)", "$1::json")
|
|
||||||
| null pgArgs = (ignoredBody, "")
|
|
||||||
| otherwise = (
|
|
||||||
unwords [
|
|
||||||
"_args_record AS (",
|
|
||||||
"SELECT * FROM " <> (if isObject then "json_to_record" else "json_to_recordset") <> "($1)",
|
|
||||||
"AS _(" <> intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " " <> pgaType a) <$> pgArgs) <> ")",
|
|
||||||
")"]
|
|
||||||
, intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> " := (SELECT " <> pgFmtIdent (pgaName a) <> " FROM _args_record)") <$> pgArgs))
|
|
||||||
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.maybeRow procRow
|
|
||||||
procRow = (,,,) <$> HD.nullableValue HD.int8 <*> HD.value HD.int8
|
|
||||||
<*> HD.value HD.bytea <*> HD.value HD.bytea
|
|
||||||
scalarBodyF
|
|
||||||
| asBinary = asBinaryF _procName
|
|
||||||
| otherwise = "(row_to_json(_postgrest_t)->" <> pgFmtLit _procName <> ")::character varying"
|
|
||||||
|
|
||||||
bodyF
|
|
||||||
| isSingle = asJsonSingleF
|
|
||||||
| asCsv = asCsvF
|
|
||||||
| isJust binaryField = asBinaryF $ fromJust binaryField
|
|
||||||
| otherwise = asJsonF
|
|
||||||
|
|
||||||
pgFmtIdent :: SqlFragment -> SqlFragment
|
|
||||||
pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\""
|
|
||||||
|
|
||||||
pgFmtLit :: SqlFragment -> SqlFragment
|
|
||||||
pgFmtLit x =
|
|
||||||
let trimmed = trimNullChars x
|
|
||||||
escaped = "'" <> replace "'" "''" trimmed <> "'"
|
|
||||||
slashed = replace "\\" "\\\\" escaped in
|
|
||||||
if "\\" `isInfixOf` escaped
|
|
||||||
then "E" <> slashed
|
|
||||||
else slashed
|
|
||||||
|
|
||||||
requestToCountQuery :: Schema -> DbRequest -> SqlQuery
|
|
||||||
requestToCountQuery _ (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 tbls logicForest joinConditions_ ordts range, (nodeName, maybeRelation, _, _, depth)) forest)) =
|
|
||||||
unwords [
|
unwords [
|
||||||
"SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
|
"SELECT " <> intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects),
|
||||||
"FROM " <> intercalate ", " tables,
|
"FROM " <> intercalate ", " (tabl : implJs),
|
||||||
unwords joins,
|
unwords joins,
|
||||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConds))
|
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_))
|
||||||
`emptyOnFalse` (null logicForest && null joinConds),
|
`emptyOnFalse` (null logicForest && null joinConditions_),
|
||||||
("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts,
|
("ORDER BY " <> intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts,
|
||||||
("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (isParent || range == allRange) ]
|
("LIMIT " <> maybe "ALL" show (rangeLimit range) <> " OFFSET " <> show (rangeOffset range)) `emptyOnFalse` (range == allRange)
|
||||||
|
]
|
||||||
where
|
where
|
||||||
mainTbl = fromMaybe nodeName (tableName . relTable <$> maybeRelation)
|
implJs = fromQi <$> implJoins
|
||||||
isSelfJoin = maybe False (\r -> relType r /= Root && relTable r == relFTable r) maybeRelation
|
tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias
|
||||||
(qi, tables, joinConds) =
|
qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias
|
||||||
let depthAlias name dpth = if dpth /= 0 then name <> "_" <> show dpth else name in -- Root node doesn't get aliased
|
(joins, selects) = foldr getJoinsSelects ([],[]) forest
|
||||||
if isSelfJoin
|
|
||||||
then (
|
|
||||||
QualifiedIdentifier "" (depthAlias mainTbl depth),
|
|
||||||
(\t -> fromQi (removeSourceCTESchema schema t) <> " AS " <> pgFmtIdent (depthAlias t depth)) <$> tbls,
|
|
||||||
(\(JoinCondition (qi1, _, c1) (qi2, _, c2)) ->
|
|
||||||
JoinCondition (qi1, Just $ depthAlias (qiName qi1) depth, c1)
|
|
||||||
(qi2, Just $ depthAlias (qiName qi2) (depth - 1), c2)) <$> joinConditions_)
|
|
||||||
else (
|
|
||||||
removeSourceCTESchema schema mainTbl,
|
|
||||||
fromQi . removeSourceCTESchema schema <$> tbls,
|
|
||||||
joinConditions_)
|
|
||||||
|
|
||||||
(joins, selects) = foldr getQueryParts ([],[]) forest
|
getJoinsSelects :: ReadRequest -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
|
||||||
|
getJoinsSelects rr@(Node (_, (name, Just Relation{relType=relTyp,relTable=Table{tableName=table}}, alias, _, _)) _) (j,s) =
|
||||||
|
let subquery = readRequestToQuery rr in
|
||||||
|
case relTyp of
|
||||||
|
M2O ->
|
||||||
|
let 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 " in
|
||||||
|
(joi:j,sel:s)
|
||||||
|
_ ->
|
||||||
|
let sel = "COALESCE (("
|
||||||
|
<> "SELECT json_agg(" <> pgFmtIdent table <> ".*) "
|
||||||
|
<> "FROM (" <> subquery <> ") " <> pgFmtIdent table
|
||||||
|
<> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias) in
|
||||||
|
(j,sel:s)
|
||||||
|
getJoinsSelects (Node (_, (_, Nothing, _, _, _)) _) _ = ([], [])
|
||||||
|
|
||||||
getQueryParts :: Tree ReadNode -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment])
|
mutateRequestToQuery :: MutateRequest -> SqlQuery
|
||||||
getQueryParts (Node n@(_, (name, Just Relation{relType=Child,relTable=Table{tableName=table}}, alias, _, _)) forst) (j,s) = (j,sel:s)
|
mutateRequestToQuery (Insert mainQi iCols onConflct putConditions returnings) =
|
||||||
where
|
|
||||||
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
|
|
||||||
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 = "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 _ _ = witness
|
|
||||||
requestToQuery schema _ (DbMutate (Insert mainTbl pkCols p@(PayloadJSON _ pType pKeys) onConflct logicForest returnings)) =
|
|
||||||
unwords [
|
unwords [
|
||||||
("WITH " <> ignoredBody) `emptyOnFalse` not payloadIsEmpty,
|
"WITH " <> normalizedBody,
|
||||||
"INSERT INTO ", fromQi qi, if payloadIsEmpty then " " else "(" <> cols <> ")",
|
"INSERT INTO ", fromQi mainQi, if S.null iCols then " " else "(" <> cols <> ")",
|
||||||
case (pType, payloadIsEmpty) of
|
unwords [
|
||||||
(PJArray _, True) -> "SELECT null WHERE false"
|
"SELECT " <> cols <> " FROM",
|
||||||
(PJObject, True) -> "DEFAULT VALUES"
|
"json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ") _",
|
||||||
_ -> unwords [
|
-- Only used for PUT
|
||||||
"SELECT " <> cols <> " FROM",
|
("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "_") <$> putConditions)) `emptyOnFalse` null putConditions],
|
||||||
case pType of
|
maybe "" (\(oncDo, oncCols) -> (
|
||||||
PJObject -> "json_populate_record"
|
"ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of
|
||||||
PJArray _ -> "json_populate_recordset", "(null::", fromQi qi, ", $1) _",
|
|
||||||
-- Only used for PUT
|
|
||||||
("WHERE " <> intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier "" "_") <$> logicForest)) `emptyOnFalse` null logicForest],
|
|
||||||
maybe "" (\x -> (
|
|
||||||
"ON CONFLICT(" <> intercalate ", " (pgFmtIdent <$> pkCols) <> ") " <> case x of
|
|
||||||
IgnoreDuplicates ->
|
IgnoreDuplicates ->
|
||||||
"DO NOTHING"
|
"DO NOTHING"
|
||||||
MergeDuplicates ->
|
MergeDuplicates ->
|
||||||
"DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList pKeys)
|
if S.null iCols
|
||||||
) `emptyOnFalse` null pkCols) onConflct,
|
then "DO NOTHING"
|
||||||
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings]
|
else "DO UPDATE SET " <> intercalate ", " (pgFmtIdent <> const " = EXCLUDED." <> pgFmtIdent <$> S.toList iCols)
|
||||||
where
|
) `emptyOnFalse` null oncCols) onConflct,
|
||||||
qi = QualifiedIdentifier schema mainTbl
|
returningF mainQi returnings
|
||||||
cols = intercalate ", " $ pgFmtIdent <$> S.toList pKeys
|
|
||||||
payloadIsEmpty = pjIsEmpty p
|
|
||||||
requestToQuery schema _ (DbMutate (Update mainTbl p@(PayloadJSON _ pType keys) logicForest returnings)) =
|
|
||||||
if pjIsEmpty p
|
|
||||||
then "WITH " <> ignoredBody <> "SELECT ''"
|
|
||||||
else
|
|
||||||
unwords [
|
|
||||||
"UPDATE " <> fromQi qi <> " SET " <> cols,
|
|
||||||
"FROM (SELECT * FROM ",
|
|
||||||
case pType of
|
|
||||||
PJObject -> " json_populate_record"
|
|
||||||
PJArray _ -> " json_populate_recordset", "(null::", fromQi qi, ", $1)) _ ",
|
|
||||||
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest,
|
|
||||||
("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnFalse` null returnings
|
|
||||||
]
|
|
||||||
where
|
|
||||||
qi = QualifiedIdentifier schema mainTbl
|
|
||||||
cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList keys)
|
|
||||||
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
|
where
|
||||||
qi = QualifiedIdentifier schema mainTbl
|
cols = intercalate ", " $ pgFmtIdent <$> S.toList iCols
|
||||||
|
mutateRequestToQuery (Update mainQi uCols logicForest returnings) =
|
||||||
-- Due to the use of the `unknown` encoder we need to cast '$1' when the value is not used in the main query
|
if S.null uCols
|
||||||
-- otherwise the query will err with a `could not determine data type of parameter $1`.
|
-- if there are no columns we cannot do UPDATE table SET {empty}, it'd be invalid syntax
|
||||||
-- This happens because `unknown` relies on the context to determine the value type.
|
-- selecting an empty resultset from mainQi gives us the column names to prevent errors when using &select=
|
||||||
-- The error also happens on raw libpq used with C.
|
-- the select has to be based on "returnings" to make computed overloaded functions not throw
|
||||||
ignoredBody :: SqlFragment
|
then "WITH " <> ignoredBody <> "SELECT " <> empty_body_returned_columns <> " FROM " <> fromQi mainQi <> " WHERE false"
|
||||||
ignoredBody = "ignored_body AS (SELECT $1::text) "
|
else
|
||||||
|
unwords [
|
||||||
removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier
|
"WITH " <> normalizedBody,
|
||||||
removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl
|
"UPDATE " <> fromQi mainQi <> " SET " <> cols,
|
||||||
|
"FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ")) _ ",
|
||||||
unquoted :: JSON.Value -> Text
|
("WHERE " <> intercalate " AND " (pgFmtLogicTree mainQi <$> logicForest)) `emptyOnFalse` null logicForest,
|
||||||
unquoted (JSON.String t) = t
|
returningF mainQi returnings
|
||||||
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
|
where
|
||||||
asCsvHeaderF =
|
cols = intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols)
|
||||||
"(SELECT coalesce(string_agg(a.k, ','), '')" <>
|
empty_body_returned_columns :: SqlFragment
|
||||||
" FROM (" <>
|
empty_body_returned_columns
|
||||||
" SELECT json_object_keys(r)::TEXT as k" <>
|
| null returnings = "NULL"
|
||||||
" FROM ( " <>
|
| otherwise = intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings)
|
||||||
" SELECT row_to_json(hh) as r from " <> sourceCTEName <> " as hh limit 1" <>
|
mutateRequestToQuery (Delete mainQi logicForest returnings) =
|
||||||
" ) s" <>
|
unwords [
|
||||||
" ) a" <>
|
"WITH " <> ignoredBody,
|
||||||
")"
|
"DELETE FROM ", fromQi mainQi,
|
||||||
asCsvBodyF = "coalesce(string_agg(substring(_postgrest_t::text, 2, length(_postgrest_t::text) - 2), '\n'), '')"
|
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree mainQi) logicForest)) `emptyOnFalse` null logicForest,
|
||||||
|
returningF mainQi returnings
|
||||||
|
]
|
||||||
|
|
||||||
asJsonF :: SqlFragment
|
requestToCallProcQuery :: QualifiedIdentifier -> [PgArg] -> Bool -> Maybe PreferParameters -> [FieldName] -> SqlQuery
|
||||||
asJsonF = "coalesce(json_agg(_postgrest_t), '[]')::character varying"
|
requestToCallProcQuery qi pgArgs returnsScalar preferParams returnings =
|
||||||
|
unwords [
|
||||||
asJsonSingleF :: SqlFragment --TODO! unsafe when the query actually returns multiple rows, used only on inserting and returning single element
|
"WITH",
|
||||||
asJsonSingleF = "coalesce(string_agg(row_to_json(_postgrest_t)::text, ','), '')::character varying "
|
argsCTE,
|
||||||
|
sourceBody ]
|
||||||
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
|
where
|
||||||
n = qiName t
|
paramsAsSingleObject = preferParams == Just SingleObject
|
||||||
s = qiSchema t
|
paramsAsMultipleObjects = preferParams == Just MultipleObjects
|
||||||
|
|
||||||
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Query a b
|
(argsCTE, args)
|
||||||
unicodeStatement = H.statement . T.encodeUtf8
|
| null pgArgs = (ignoredBody, "")
|
||||||
|
| paramsAsSingleObject = ("pgrst_args AS (SELECT NULL)", "$1::json")
|
||||||
|
| otherwise = (
|
||||||
|
unwords [
|
||||||
|
normalizedBody <> ",",
|
||||||
|
"pgrst_args AS (",
|
||||||
|
"SELECT * FROM json_to_recordset(" <> selectBody <> ") AS _(" <> fmtArgs (\a -> " " <> pgaType a) <> ")",
|
||||||
|
")"]
|
||||||
|
, if paramsAsMultipleObjects
|
||||||
|
then fmtArgs (\a -> " := pgrst_args." <> pgFmtIdent (pgaName a))
|
||||||
|
else fmtArgs (\a -> " := (SELECT " <> pgFmtIdent (pgaName a) <> " FROM pgrst_args LIMIT 1)")
|
||||||
|
)
|
||||||
|
|
||||||
emptyOnFalse :: Text -> Bool -> Text
|
fmtArgs :: (PgArg -> SqlFragment) -> SqlFragment
|
||||||
emptyOnFalse val cond = if cond then "" else val
|
fmtArgs argFrag = intercalate ", " ((\a -> pgFmtIdent (pgaName a) <> argFrag a) <$> pgArgs)
|
||||||
|
|
||||||
pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment
|
sourceBody :: SqlFragment
|
||||||
pgFmtColumn table "*" = fromQi table <> ".*"
|
sourceBody
|
||||||
pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c
|
| paramsAsMultipleObjects =
|
||||||
|
if returnsScalar
|
||||||
|
then "SELECT " <> callIt <> " AS pgrst_scalar FROM pgrst_args"
|
||||||
|
else unwords [ "SELECT pgrst_lat_args.*"
|
||||||
|
, "FROM pgrst_args,"
|
||||||
|
, "LATERAL ( SELECT " <> returned_columns <> " FROM " <> callIt <> " ) pgrst_lat_args" ]
|
||||||
|
| otherwise =
|
||||||
|
if returnsScalar
|
||||||
|
then "SELECT " <> callIt <> " AS pgrst_scalar"
|
||||||
|
else "SELECT " <> returned_columns <> " FROM " <> callIt
|
||||||
|
|
||||||
pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment
|
callIt :: SqlFragment
|
||||||
pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp
|
callIt = fromQi qi <> "(" <> args <> ")"
|
||||||
|
|
||||||
pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment
|
returned_columns :: SqlFragment
|
||||||
pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias
|
returned_columns
|
||||||
pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs fName jp alias
|
| null returnings = "*"
|
||||||
|
| otherwise = intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName qi) <$> returnings)
|
||||||
|
|
||||||
pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment
|
|
||||||
pgFmtOrderTerm qi ot = unwords [
|
|
||||||
toS . pgFmtField qi $ otTerm ot,
|
|
||||||
maybe "" show $ otDirection ot,
|
|
||||||
maybe "" show $ otNullOrder ot]
|
|
||||||
|
|
||||||
pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment
|
-- | SQL query meant for COUNTing the root node of the Tree.
|
||||||
pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of
|
-- It only takes WHERE into account and doesn't include LIMIT/OFFSET because it would reduce the COUNT.
|
||||||
Op op val -> pgFmtFieldOp op <> " " <> case op of
|
-- SELECT 1 is done instead of SELECT * to prevent doing expensive operations(like functions based on the columns)
|
||||||
"like" -> unknownLiteral (T.map star val)
|
-- inside the FROM target.
|
||||||
"ilike" -> unknownLiteral (T.map star val)
|
readRequestToCountQuery :: ReadRequest -> SqlQuery
|
||||||
"is" -> whiteList val
|
readRequestToCountQuery (Node (Select{from=qi, where_=logicForest}, _) _) =
|
||||||
_ -> unknownLiteral val
|
unwords [
|
||||||
|
"SELECT 1",
|
||||||
|
"FROM " <> fromQi qi,
|
||||||
|
("WHERE " <> intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest
|
||||||
|
]
|
||||||
|
|
||||||
In vals -> pgFmtField table fld <> " " <>
|
limitedQuery :: SqlQuery -> Maybe Integer -> SqlQuery
|
||||||
let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')"
|
limitedQuery query maxRows = query <> maybe mempty (\x -> " LIMIT " <> show x) maxRows
|
||||||
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 ->
|
setLocalQuery :: Text -> (Text, Text) -> SqlQuery
|
||||||
pgFmtFieldOp op
|
setLocalQuery prefix (k, v) =
|
||||||
<> "("
|
"SET LOCAL " <> pgFmtIdent (prefix <> k) <> " = " <> pgFmtLit v <> ";"
|
||||||
<> 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
|
setLocalSearchPathQuery :: [Text] -> SqlQuery
|
||||||
pgFmtJoinCondition (JoinCondition (qi, al1, col1) (QualifiedIdentifier schema fTable, al2, col2)) =
|
setLocalSearchPathQuery vals =
|
||||||
pgFmtColumn (fromMaybe qi $ aliasToQi al1) col1 <> " = " <>
|
"SET LOCAL search_path = " <> intercalate ", " (pgFmtLit <$> vals) <> ";"
|
||||||
pgFmtColumn (fromMaybe (removeSourceCTESchema schema fTable) $ aliasToQi al2) col2
|
|
||||||
where
|
|
||||||
aliasToQi al = QualifiedIdentifier "" <$> al
|
|
||||||
|
|
||||||
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 <> ";"
|
|
||||||
|
|
||||||
trimNullChars :: Text -> Text
|
|
||||||
trimNullChars = T.takeWhile (/= '\x0')
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
{-|
|
||||||
|
Module : PostgREST.RangeQuery
|
||||||
|
Description : Logic regarding the `Range`/`Content-Range` headers and `limit`/`offset` querystring arguments.
|
||||||
|
-}
|
||||||
module PostgREST.RangeQuery (
|
module PostgREST.RangeQuery (
|
||||||
rangeParse
|
rangeParse
|
||||||
, rangeRequested
|
, rangeRequested
|
||||||
@@ -7,21 +11,23 @@ module PostgREST.RangeQuery (
|
|||||||
, rangeGeq
|
, rangeGeq
|
||||||
, allRange
|
, allRange
|
||||||
, NonnegRange
|
, NonnegRange
|
||||||
|
, rangeStatusHeader
|
||||||
|
, contentRangeH
|
||||||
) where
|
) where
|
||||||
|
|
||||||
|
import qualified Data.ByteString.Char8 as BS
|
||||||
|
|
||||||
import Control.Applicative
|
import Data.List (lookup)
|
||||||
import Network.HTTP.Types.Header
|
import Text.Regex.TDFA ((=~))
|
||||||
|
|
||||||
import qualified Data.ByteString.Char8 as BS
|
import Control.Applicative
|
||||||
import Data.Ranged.Boundaries
|
import Data.Ranged.Boundaries
|
||||||
import Data.Ranged.Ranges
|
import Data.Ranged.Ranges
|
||||||
|
import Network.HTTP.Types.Header
|
||||||
|
import Network.HTTP.Types.Status
|
||||||
|
|
||||||
import Text.Regex.TDFA ((=~))
|
import Protolude hiding (toS)
|
||||||
|
import Protolude.Conv (toS)
|
||||||
import Data.List (lookup)
|
|
||||||
|
|
||||||
import Protolude
|
|
||||||
|
|
||||||
type NonnegRange = Range Integer
|
type NonnegRange = Range Integer
|
||||||
|
|
||||||
@@ -32,14 +38,13 @@ rangeParse range = do
|
|||||||
case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of
|
case listToMaybe (range =~ rangeRegex :: [[BS.ByteString]]) of
|
||||||
Just parsedRange ->
|
Just parsedRange ->
|
||||||
let [_, mLower, mUpper] = readMaybe . toS <$> parsedRange
|
let [_, mLower, mUpper] = readMaybe . toS <$> parsedRange
|
||||||
lower = fromMaybe emptyRange (rangeGeq <$> mLower)
|
lower = maybe emptyRange rangeGeq mLower
|
||||||
upper = fromMaybe allRange (rangeLeq <$> mUpper) in
|
upper = maybe allRange rangeLeq mUpper in
|
||||||
rangeIntersection lower upper
|
rangeIntersection lower upper
|
||||||
Nothing -> allRange
|
Nothing -> allRange
|
||||||
|
|
||||||
rangeRequested :: RequestHeaders -> NonnegRange
|
rangeRequested :: RequestHeaders -> NonnegRange
|
||||||
rangeRequested headers = fromMaybe allRange $
|
rangeRequested headers = maybe allRange rangeParse $ lookup hRange headers
|
||||||
rangeParse <$> lookup hRange headers
|
|
||||||
|
|
||||||
restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange
|
restrictRange :: Maybe Integer -> NonnegRange -> NonnegRange
|
||||||
restrictRange Nothing r = r
|
restrictRange Nothing r = r
|
||||||
@@ -57,7 +62,7 @@ rangeOffset :: NonnegRange -> Integer
|
|||||||
rangeOffset range =
|
rangeOffset range =
|
||||||
case rangeLower range of
|
case rangeLower range of
|
||||||
BoundaryBelow lower -> lower
|
BoundaryBelow lower -> lower
|
||||||
_ -> panic "range without lower bound" -- should never happen
|
_ -> panic "range without lower bound" -- should never happen
|
||||||
|
|
||||||
rangeGeq :: Integer -> NonnegRange
|
rangeGeq :: Integer -> NonnegRange
|
||||||
rangeGeq n =
|
rangeGeq n =
|
||||||
@@ -69,3 +74,30 @@ allRange = rangeGeq 0
|
|||||||
rangeLeq :: Integer -> NonnegRange
|
rangeLeq :: Integer -> NonnegRange
|
||||||
rangeLeq n =
|
rangeLeq n =
|
||||||
Range BoundaryBelowAll (BoundaryAbove n)
|
Range BoundaryBelowAll (BoundaryAbove n)
|
||||||
|
|
||||||
|
rangeStatusHeader :: NonnegRange -> Int64 -> Maybe Int64 -> (Status, Header)
|
||||||
|
rangeStatusHeader topLevelRange 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)
|
||||||
|
where
|
||||||
|
rangeStatus :: Integer -> Integer -> Maybe Integer -> Status
|
||||||
|
rangeStatus _ _ Nothing = status200
|
||||||
|
rangeStatus lower upper (Just total)
|
||||||
|
| lower > total = status416 -- 416 Range Not Satisfiable
|
||||||
|
| (1 + upper - lower) < total = status206 -- 206 Partial Content
|
||||||
|
| otherwise = status200 -- 200 OK
|
||||||
|
|
||||||
|
contentRangeH :: (Integral a, Show a) => a -> a -> Maybe a -> Header
|
||||||
|
contentRangeH lower upper total =
|
||||||
|
("Content-Range", toUtf8 headerValue)
|
||||||
|
where
|
||||||
|
headerValue = rangeString <> "/" <> totalString :: Text
|
||||||
|
rangeString
|
||||||
|
| totalNotZero && fromInRange = show lower <> "-" <> show upper
|
||||||
|
| otherwise = "*"
|
||||||
|
totalString = maybe "*" show total
|
||||||
|
totalNotZero = maybe True (0 /=) total
|
||||||
|
fromInRange = lower <= upper
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
{-|
|
||||||
|
Module : PostgREST.Statements
|
||||||
|
Description : PostgREST single SQL statements.
|
||||||
|
|
||||||
|
This module constructs single SQL statements that can be parametrized and prepared.
|
||||||
|
|
||||||
|
- It consumes the SqlQuery types generated by the QueryBuilder module.
|
||||||
|
- It generates the body format and some headers of the final HTTP response.
|
||||||
|
|
||||||
|
TODO: Currently, createReadStatement is not using prepared statements. See https://github.com/PostgREST/postgrest/issues/718.
|
||||||
|
-}
|
||||||
|
module PostgREST.Statements (
|
||||||
|
createWriteStatement
|
||||||
|
, createReadStatement
|
||||||
|
, callProcStatement
|
||||||
|
, createExplainStatement
|
||||||
|
) where
|
||||||
|
|
||||||
|
|
||||||
|
import Control.Lens ((^?))
|
||||||
|
import Data.Aeson as JSON
|
||||||
|
import qualified Data.Aeson.Lens as L
|
||||||
|
import qualified Data.ByteString.Char8 as BS
|
||||||
|
import Data.Maybe
|
||||||
|
import qualified Hasql.Decoders as HD
|
||||||
|
import qualified Hasql.Encoders as HE
|
||||||
|
import qualified Hasql.Statement as H
|
||||||
|
import PostgREST.Private.Common
|
||||||
|
import PostgREST.Private.QueryFragment
|
||||||
|
import PostgREST.Types
|
||||||
|
import Protolude hiding (cast,
|
||||||
|
replace, toS)
|
||||||
|
import Protolude.Conv (toS)
|
||||||
|
import Text.InterpolatedString.Perl6 (qc)
|
||||||
|
|
||||||
|
{-| 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, Either Text [GucHeader])
|
||||||
|
|
||||||
|
createWriteStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool ->
|
||||||
|
PreferRepresentation -> [Text] -> PgVersion ->
|
||||||
|
H.Statement ByteString ResultsWithCount
|
||||||
|
createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys pgVer =
|
||||||
|
unicodeStatement sql (param HE.unknown) decodeStandard True
|
||||||
|
where
|
||||||
|
sql = [qc|
|
||||||
|
WITH
|
||||||
|
{sourceCTEName} AS ({mutateQuery})
|
||||||
|
SELECT
|
||||||
|
'' AS total_result_set,
|
||||||
|
pg_catalog.count(_postgrest_t) AS page_total,
|
||||||
|
{locF} AS header,
|
||||||
|
{bodyF} AS body,
|
||||||
|
{responseHeadersF pgVer} AS response_headers
|
||||||
|
FROM ({selectF}) _postgrest_t |]
|
||||||
|
|
||||||
|
locF =
|
||||||
|
if isInsert && rep `elem` [Full, HeadersOnly]
|
||||||
|
then unwords [
|
||||||
|
"CASE WHEN pg_catalog.count(_postgrest_t) = 1",
|
||||||
|
"THEN coalesce(" <> locationF pKeys <> ", " <> noLocationF <> ")",
|
||||||
|
"ELSE " <> noLocationF,
|
||||||
|
"END"]
|
||||||
|
else noLocationF
|
||||||
|
|
||||||
|
bodyF
|
||||||
|
| rep `elem` [None, HeadersOnly] = "''"
|
||||||
|
| asCsv = asCsvF
|
||||||
|
| wantSingle = asJsonSingleF
|
||||||
|
| otherwise = asJsonF
|
||||||
|
|
||||||
|
selectF
|
||||||
|
-- prevent using any of the column names in ?select= when no response is returned from the CTE
|
||||||
|
| rep `elem` [None, HeadersOnly] = "SELECT * FROM " <> sourceCTEName
|
||||||
|
| otherwise = selectQuery
|
||||||
|
|
||||||
|
decodeStandard :: HD.Result ResultsWithCount
|
||||||
|
decodeStandard =
|
||||||
|
fromMaybe (Nothing, 0, [], mempty, Right []) <$> HD.rowMaybe standardRow
|
||||||
|
|
||||||
|
createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion ->
|
||||||
|
H.Statement () ResultsWithCount
|
||||||
|
createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField pgVer =
|
||||||
|
unicodeStatement sql HE.noParams decodeStandard False
|
||||||
|
where
|
||||||
|
sql = [qc|
|
||||||
|
WITH
|
||||||
|
{sourceCTEName} AS ({selectQuery})
|
||||||
|
{countCTEF}
|
||||||
|
SELECT
|
||||||
|
{countResultF} AS total_result_set,
|
||||||
|
pg_catalog.count(_postgrest_t) AS page_total,
|
||||||
|
{noLocationF} AS header,
|
||||||
|
{bodyF} AS body,
|
||||||
|
{responseHeadersF pgVer} AS response_headers
|
||||||
|
FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |]
|
||||||
|
|
||||||
|
(countCTEF, countResultF) = countF countQuery countTotal
|
||||||
|
|
||||||
|
bodyF
|
||||||
|
| asCsv = asCsvF
|
||||||
|
| isSingle = asJsonSingleF
|
||||||
|
| isJust binaryField = asBinaryF $ fromJust binaryField
|
||||||
|
| otherwise = asJsonF
|
||||||
|
|
||||||
|
decodeStandard :: HD.Result ResultsWithCount
|
||||||
|
decodeStandard =
|
||||||
|
HD.singleRow standardRow
|
||||||
|
|
||||||
|
{-| 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.
|
||||||
|
-}
|
||||||
|
standardRow :: HD.Row ResultsWithCount
|
||||||
|
standardRow = (,,,,) <$> nullableColumn HD.int8 <*> column HD.int8
|
||||||
|
<*> column header <*> column HD.bytea <*> column decodeGucHeaders
|
||||||
|
where
|
||||||
|
header = HD.array $ HD.dimension replicateM $ element HD.bytea
|
||||||
|
|
||||||
|
type ProcResults = (Maybe Int64, Int64, ByteString, Either Text [GucHeader])
|
||||||
|
|
||||||
|
callProcStatement :: Bool -> SqlQuery -> SqlQuery -> SqlQuery -> Bool ->
|
||||||
|
Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion ->
|
||||||
|
H.Statement ByteString ProcResults
|
||||||
|
callProcStatement returnsScalar callProcQuery selectQuery countQuery countTotal isSingle asCsv asBinary multObjects binaryField pgVer =
|
||||||
|
unicodeStatement sql (param HE.unknown) decodeProc True
|
||||||
|
where
|
||||||
|
sql = [qc|
|
||||||
|
WITH {sourceCTEName} AS ({callProcQuery})
|
||||||
|
{countCTEF}
|
||||||
|
SELECT
|
||||||
|
{countResultF} AS total_result_set,
|
||||||
|
pg_catalog.count(_postgrest_t) AS page_total,
|
||||||
|
{bodyF} AS body,
|
||||||
|
{responseHeadersF pgVer} AS response_headers
|
||||||
|
FROM ({selectQuery}) _postgrest_t;|]
|
||||||
|
|
||||||
|
(countCTEF, countResultF) = countF countQuery countTotal
|
||||||
|
|
||||||
|
bodyF
|
||||||
|
| returnsScalar = scalarBodyF
|
||||||
|
| isSingle = asJsonSingleF
|
||||||
|
| asCsv = asCsvF
|
||||||
|
| isJust binaryField = asBinaryF $ fromJust binaryField
|
||||||
|
| otherwise = asJsonF
|
||||||
|
|
||||||
|
scalarBodyF
|
||||||
|
| asBinary = asBinaryF "pgrst_scalar"
|
||||||
|
| multObjects = "json_agg(_postgrest_t.pgrst_scalar)::character varying"
|
||||||
|
| otherwise = "(json_agg(_postgrest_t.pgrst_scalar)->0)::character varying"
|
||||||
|
|
||||||
|
decodeProc :: HD.Result ProcResults
|
||||||
|
decodeProc =
|
||||||
|
fromMaybe (Just 0, 0, mempty, Right []) <$> HD.rowMaybe procRow
|
||||||
|
where
|
||||||
|
procRow = (,,,) <$> nullableColumn HD.int8 <*> column HD.int8
|
||||||
|
<*> column HD.bytea <*> column decodeGucHeaders
|
||||||
|
|
||||||
|
createExplainStatement :: SqlQuery -> H.Statement () (Maybe Int64)
|
||||||
|
createExplainStatement countQuery =
|
||||||
|
unicodeStatement sql HE.noParams decodeExplain False
|
||||||
|
where
|
||||||
|
sql = [qc| EXPLAIN (FORMAT JSON) {countQuery} |]
|
||||||
|
-- |
|
||||||
|
-- An `EXPLAIN (FORMAT JSON) select * from items;` output looks like this:
|
||||||
|
-- [{
|
||||||
|
-- "Plan": {
|
||||||
|
-- "Node Type": "Seq Scan", "Parallel Aware": false, "Relation Name": "items",
|
||||||
|
-- "Alias": "items", "Startup Cost": 0.00, "Total Cost": 32.60,
|
||||||
|
-- "Plan Rows": 2260,"Plan Width": 8} }]
|
||||||
|
-- We only obtain the Plan Rows here.
|
||||||
|
decodeExplain :: HD.Result (Maybe Int64)
|
||||||
|
decodeExplain =
|
||||||
|
let row = HD.singleRow $ column HD.bytea in
|
||||||
|
(^? L.nth 0 . L.key "Plan" . L.key "Plan Rows" . L._Integral) <$> row
|
||||||
|
|
||||||
|
unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Statement a b
|
||||||
|
unicodeStatement = H.Statement . encodeUtf8
|
||||||
|
|
||||||
|
decodeGucHeaders :: HD.Value (Either Text [GucHeader])
|
||||||
|
decodeGucHeaders = first toS . JSON.eitherDecode . toS <$> HD.bytea
|
||||||
@@ -1,43 +1,113 @@
|
|||||||
{-# LANGUAGE DuplicateRecordFields #-}
|
{-|
|
||||||
|
Module : PostgREST.Types
|
||||||
|
Description : PostgREST common types and functions used by the rest of the modules
|
||||||
|
-}
|
||||||
|
{-# LANGUAGE DeriveGeneric #-}
|
||||||
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
|
|
||||||
module PostgREST.Types where
|
module PostgREST.Types where
|
||||||
import Protolude
|
|
||||||
|
import Control.Lens.Getter (view)
|
||||||
|
import Control.Lens.Tuple (_1)
|
||||||
|
|
||||||
|
import qualified Data.Aeson as JSON
|
||||||
|
import qualified Data.ByteString as BS
|
||||||
|
import qualified Data.ByteString.Internal as BS (c2w)
|
||||||
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
import qualified Data.CaseInsensitive as CI
|
||||||
|
import qualified Data.HashMap.Strict as M
|
||||||
|
import qualified Data.Set as S
|
||||||
import qualified GHC.Show
|
import qualified GHC.Show
|
||||||
import qualified Data.Aeson as JSON
|
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import Network.HTTP.Types.Header (Header, hContentType)
|
||||||
import qualified Data.CaseInsensitive as CI
|
|
||||||
import qualified Data.HashMap.Strict as M
|
import Data.Tree
|
||||||
import qualified Data.Set as S
|
|
||||||
import Data.Tree
|
import PostgREST.RangeQuery (NonnegRange)
|
||||||
import PostgREST.RangeQuery (NonnegRange)
|
import Protolude hiding (toS)
|
||||||
import Network.HTTP.Types.Header (hContentType, Header)
|
import Protolude.Conv (toS)
|
||||||
|
|
||||||
-- | Enumeration of currently supported response content types
|
-- | Enumeration of currently supported response content types
|
||||||
data ContentType = CTApplicationJSON | CTTextCSV | CTOpenAPI
|
data ContentType = CTApplicationJSON | CTSingularJSON
|
||||||
| CTSingularJSON | CTOctetStream
|
| CTTextCSV | CTTextPlain
|
||||||
| CTAny | CTOther ByteString deriving Eq
|
| CTOpenAPI | CTOctetStream
|
||||||
|
| CTAny | CTOther ByteString deriving (Show, Eq)
|
||||||
|
|
||||||
data ApiRequestError = ActionInappropriate
|
-- | Convert from ContentType to a full HTTP Header
|
||||||
| InvalidBody ByteString
|
toHeader :: ContentType -> Header
|
||||||
| InvalidRange
|
toHeader ct = (hContentType, toMime ct <> "; charset=utf-8")
|
||||||
| ParseRequestError Text Text
|
|
||||||
| UnknownRelation
|
-- | Convert from ContentType to a ByteString representing the mime type
|
||||||
| NoRelationBetween Text Text
|
toMime :: ContentType -> ByteString
|
||||||
| UnsupportedVerb
|
toMime CTApplicationJSON = "application/json"
|
||||||
| InvalidFilters
|
toMime CTTextCSV = "text/csv"
|
||||||
deriving (Show, Eq)
|
toMime CTTextPlain = "text/plain"
|
||||||
|
toMime CTOpenAPI = "application/openapi+json"
|
||||||
|
toMime CTSingularJSON = "application/vnd.pgrst.object+json"
|
||||||
|
toMime CTOctetStream = "application/octet-stream"
|
||||||
|
toMime CTAny = "*/*"
|
||||||
|
toMime (CTOther ct) = ct
|
||||||
|
|
||||||
|
-- | Convert from ByteString to ContentType. Warning: discards MIME parameters
|
||||||
|
decodeContentType :: BS.ByteString -> ContentType
|
||||||
|
decodeContentType ct = case BS.takeWhile (/= BS.c2w ';') ct of
|
||||||
|
"application/json" -> CTApplicationJSON
|
||||||
|
"text/csv" -> CTTextCSV
|
||||||
|
"text/plain" -> CTTextPlain
|
||||||
|
"application/openapi+json" -> CTOpenAPI
|
||||||
|
"application/vnd.pgrst.object+json" -> CTSingularJSON
|
||||||
|
"application/vnd.pgrst.object" -> CTSingularJSON
|
||||||
|
"application/octet-stream" -> CTOctetStream
|
||||||
|
"*/*" -> CTAny
|
||||||
|
ct' -> CTOther ct'
|
||||||
|
|
||||||
|
-- | A SQL query that can be executed independently
|
||||||
|
type SqlQuery = Text
|
||||||
|
|
||||||
|
-- | A part of a SQL query that cannot be executed independently
|
||||||
|
type SqlFragment = Text
|
||||||
|
|
||||||
data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq
|
data PreferResolution = MergeDuplicates | IgnoreDuplicates deriving Eq
|
||||||
instance Show PreferResolution where
|
instance Show PreferResolution where
|
||||||
show MergeDuplicates = "resolution=merge-duplicates"
|
show MergeDuplicates = "resolution=merge-duplicates"
|
||||||
show IgnoreDuplicates = "resolution=ignore-duplicates"
|
show IgnoreDuplicates = "resolution=ignore-duplicates"
|
||||||
|
|
||||||
|
-- | How to return the mutated data. From https://tools.ietf.org/html/rfc7240#section-4.2
|
||||||
|
data PreferRepresentation = Full -- ^ Return the body plus the Location header(in case of POST).
|
||||||
|
| HeadersOnly -- ^ Return the Location header(in case of POST). This needs a SELECT privilege on the pk.
|
||||||
|
| None -- ^ Return nothing from the mutated data.
|
||||||
|
deriving Eq
|
||||||
|
instance Show PreferRepresentation where
|
||||||
|
show Full = "return=representation"
|
||||||
|
show None = "return=minimal"
|
||||||
|
show HeadersOnly = mempty
|
||||||
|
|
||||||
|
data PreferParameters
|
||||||
|
= SingleObject -- ^ Pass all parameters as a single json object to a stored procedure
|
||||||
|
| MultipleObjects -- ^ Pass an array of json objects as params to a stored procedure
|
||||||
|
deriving Eq
|
||||||
|
|
||||||
|
instance Show PreferParameters where
|
||||||
|
show SingleObject = "params=single-object"
|
||||||
|
show MultipleObjects = "params=multiple-objects"
|
||||||
|
|
||||||
|
data PreferCount
|
||||||
|
= ExactCount -- ^ exact count(slower)
|
||||||
|
| PlannedCount -- ^ PostgreSQL query planner rows count guess. Done by using EXPLAIN {query}.
|
||||||
|
| EstimatedCount -- ^ use the query planner rows if the count is superior to max-rows, otherwise get the exact count.
|
||||||
|
deriving Eq
|
||||||
|
|
||||||
|
instance Show PreferCount where
|
||||||
|
show ExactCount = "count=exact"
|
||||||
|
show PlannedCount = "count=planned"
|
||||||
|
show EstimatedCount = "count=estimated"
|
||||||
|
|
||||||
data DbStructure = DbStructure {
|
data DbStructure = DbStructure {
|
||||||
dbTables :: [Table]
|
dbTables :: [Table]
|
||||||
, dbColumns :: [Column]
|
, dbColumns :: [Column]
|
||||||
, dbRelations :: [Relation]
|
, dbRelations :: [Relation]
|
||||||
, dbPrimaryKeys :: [PrimaryKey]
|
, dbPrimaryKeys :: [PrimaryKey]
|
||||||
-- ProcDescription is a list because a function can be overloaded
|
, dbProcs :: ProcsMap
|
||||||
, dbProcs :: M.HashMap Text [ProcDescription]
|
|
||||||
, pgVersion :: PgVersion
|
, pgVersion :: PgVersion
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
@@ -63,7 +133,8 @@ data ProcVolatility = Volatile | Stable | Immutable
|
|||||||
deriving (Eq, Show, Ord)
|
deriving (Eq, Show, Ord)
|
||||||
|
|
||||||
data ProcDescription = ProcDescription {
|
data ProcDescription = ProcDescription {
|
||||||
pdName :: Text
|
pdSchema :: Schema
|
||||||
|
, pdName :: Text
|
||||||
, pdDescription :: Maybe Text
|
, pdDescription :: Maybe Text
|
||||||
, pdArgs :: [PgArg]
|
, pdArgs :: [PgArg]
|
||||||
, pdReturnType :: RetType
|
, pdReturnType :: RetType
|
||||||
@@ -72,15 +143,57 @@ data ProcDescription = ProcDescription {
|
|||||||
|
|
||||||
-- Order by least number of args in the case of overloaded functions
|
-- Order by least number of args in the case of overloaded functions
|
||||||
instance Ord ProcDescription where
|
instance Ord ProcDescription where
|
||||||
ProcDescription name1 des1 args1 rt1 vol1 `compare` ProcDescription name2 des2 args2 rt2 vol2
|
ProcDescription schema1 name1 des1 args1 rt1 vol1 `compare` ProcDescription schema2 name2 des2 args2 rt2 vol2
|
||||||
| name1 == name2 && length args1 < length args2 = LT
|
| schema1 == schema2 && name1 == name2 && length args1 < length args2 = LT
|
||||||
| name1 == name2 && length args1 > length args2 = GT
|
| schema2 == schema2 && name1 == name2 && length args1 > length args2 = GT
|
||||||
| otherwise = (name1, des1, args1, rt1, vol1) `compare` (name2, des2, args2, rt2, vol2)
|
| otherwise = (schema1, name1, des1, args1, rt1, vol1) `compare` (schema2, name2, des2, args2, rt2, vol2)
|
||||||
|
|
||||||
|
-- | A map of all procs, all of which can be overloaded(one entry will have more than one ProcDescription).
|
||||||
|
-- | It uses a HashMap for a faster lookup.
|
||||||
|
type ProcsMap = M.HashMap QualifiedIdentifier [ProcDescription]
|
||||||
|
|
||||||
|
{-|
|
||||||
|
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.
|
||||||
|
Ideally, handling overloaded functions should be left to pg itself. But we need to know certain proc attributes in advance.
|
||||||
|
-}
|
||||||
|
findProc :: QualifiedIdentifier -> S.Set Text -> Bool -> ProcsMap -> Maybe ProcDescription
|
||||||
|
findProc qi payloadKeys paramsAsSingleObject allProcs =
|
||||||
|
case M.lookup 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
|
||||||
|
|
||||||
|
procReturnsScalar :: ProcDescription -> Bool
|
||||||
|
procReturnsScalar proc = case proc of
|
||||||
|
ProcDescription{pdReturnType = (Single (Scalar _))} -> True
|
||||||
|
_ -> False
|
||||||
|
|
||||||
|
procTableName :: ProcDescription -> Maybe TableName
|
||||||
|
procTableName proc = case pdReturnType proc of
|
||||||
|
SetOf (Composite qi) -> Just $ qiName qi
|
||||||
|
Single (Composite qi) -> Just $ qiName qi
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
type Schema = Text
|
type Schema = Text
|
||||||
type TableName = Text
|
type TableName = Text
|
||||||
type SqlQuery = Text
|
|
||||||
type SqlFragment = Text
|
|
||||||
|
|
||||||
data Table = Table {
|
data Table = Table {
|
||||||
tableSchema :: Schema
|
tableSchema :: Schema
|
||||||
@@ -89,12 +202,18 @@ data Table = Table {
|
|||||||
, tableInsertable :: Bool
|
, tableInsertable :: Bool
|
||||||
} deriving (Show, Ord)
|
} deriving (Show, Ord)
|
||||||
|
|
||||||
|
instance Eq Table where
|
||||||
|
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
|
||||||
|
|
||||||
|
tableQi :: Table -> QualifiedIdentifier
|
||||||
|
tableQi Table{tableSchema=s, tableName=n} = QualifiedIdentifier s n
|
||||||
|
|
||||||
newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
|
newtype ForeignKey = ForeignKey { fkCol :: Column } deriving (Show, Eq, Ord)
|
||||||
|
|
||||||
data Column =
|
data Column =
|
||||||
Column {
|
Column {
|
||||||
colTable :: Table
|
colTable :: Table
|
||||||
, colName :: Text
|
, colName :: FieldName
|
||||||
, colDescription :: Maybe Text
|
, colDescription :: Maybe Text
|
||||||
, colPosition :: Int32
|
, colPosition :: Int32
|
||||||
, colNullable :: Bool
|
, colNullable :: Bool
|
||||||
@@ -107,8 +226,11 @@ data Column =
|
|||||||
, colFK :: Maybe ForeignKey
|
, colFK :: Maybe ForeignKey
|
||||||
} deriving (Show, Ord)
|
} deriving (Show, Ord)
|
||||||
|
|
||||||
-- | A view column that refers to a table column
|
instance Eq Column where
|
||||||
type Synonym = (Column, ViewColumn)
|
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
|
||||||
|
|
||||||
|
-- | The source table column a view column refers to
|
||||||
|
type SourceColumn = (Column, ViewColumn)
|
||||||
type ViewColumn = Column
|
type ViewColumn = Column
|
||||||
|
|
||||||
data PrimaryKey = PrimaryKey {
|
data PrimaryKey = PrimaryKey {
|
||||||
@@ -132,54 +254,77 @@ data OrderTerm = OrderTerm {
|
|||||||
, otNullOrder :: Maybe OrderNulls
|
, otNullOrder :: Maybe OrderNulls
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
{-|
|
||||||
|
Represents a pg identifier with a prepended schema name "schema.table"
|
||||||
|
When qiSchema is "", the schema is defined by the pg search_path
|
||||||
|
-}
|
||||||
data QualifiedIdentifier = QualifiedIdentifier {
|
data QualifiedIdentifier = QualifiedIdentifier {
|
||||||
qiSchema :: Schema
|
qiSchema :: Schema
|
||||||
, qiName :: TableName
|
, qiName :: TableName
|
||||||
} deriving (Show, Eq, Ord)
|
} deriving (Show, Eq, Ord, Generic)
|
||||||
|
instance Hashable QualifiedIdentifier
|
||||||
|
|
||||||
|
-- | The relationship [cardinality](https://en.wikipedia.org/wiki/Cardinality_(data_modeling)).
|
||||||
|
-- | TODO: missing one-to-one
|
||||||
|
data Cardinality = O2M -- ^ one-to-many, previously known as Parent
|
||||||
|
| M2O -- ^ many-to-one, previously known as Child
|
||||||
|
| M2M -- ^ many-to-many, previously known as Many
|
||||||
|
deriving Eq
|
||||||
|
instance Show Cardinality where
|
||||||
|
show O2M = "o2m"
|
||||||
|
show M2O = "m2o"
|
||||||
|
show M2M = "m2m"
|
||||||
|
|
||||||
data RelationType = Child | Parent | Many | Root deriving (Show, Eq)
|
type ConstraintName = Text
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
The name 'Relation' here is used with the meaning
|
"Relation"ship between two tables.
|
||||||
"What is the relation between the current node and the parent node".
|
The order of the relColumns and relFColumns should be maintained to get the join conditions right.
|
||||||
It has nothing to do with PostgreSQL referring to tables/views as relations.
|
TODO merge relColumns and relFColumns to a tuple or Data.Bimap
|
||||||
-}
|
-}
|
||||||
data Relation = Relation {
|
data Relation = Relation {
|
||||||
relTable :: Table
|
relTable :: Table
|
||||||
, relColumns :: [Column]
|
, relColumns :: [Column]
|
||||||
, relFTable :: Table
|
, relConstraint :: Maybe ConstraintName -- ^ Just on O2M/M2O, Nothing on M2M
|
||||||
, relFColumns :: [Column]
|
, relFTable :: Table
|
||||||
, relType :: RelationType
|
, relFColumns :: [Column]
|
||||||
-- The Link attrs are used when RelationType == Many
|
, relType :: Cardinality
|
||||||
, relLinkTable :: Maybe Table
|
, relJunction :: Maybe Junction -- ^ Junction for M2M Cardinality
|
||||||
, relLinkCols1 :: Maybe [Column]
|
|
||||||
, relLinkCols2 :: Maybe [Column]
|
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
-- | Cached attributes of a JSON payload
|
-- | Junction table on an M2M relationship
|
||||||
data PayloadJSON = PayloadJSON {
|
data Junction = Junction {
|
||||||
-- | This is the raw ByteString that comes from the request body.
|
junTable :: Table
|
||||||
-- We cache this instead of an Aeson Value because it was detected that for large payloads the encoding
|
, junConstraint1 :: Maybe ConstraintName
|
||||||
-- had high memory usage, see #1005 for more details
|
, junCols1 :: [Column]
|
||||||
pjRaw :: BL.ByteString
|
, junConstraint2 :: Maybe ConstraintName
|
||||||
, pjType :: PJType
|
, junCols2 :: [Column]
|
||||||
-- | 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
|
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
isSelfReference :: Relation -> Bool
|
||||||
|
isSelfReference r = 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 https://github.com/PostgREST/postgrest/pull/1005 for more details
|
||||||
|
pjRaw :: BL.ByteString
|
||||||
|
-- | 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 PJType = PJArray { pjaLength :: Int } | PJObject deriving (Show, Eq)
|
||||||
|
|
||||||
-- | e.g. whether it is []/{} or not
|
|
||||||
pjIsEmpty :: PayloadJSON -> Bool
|
|
||||||
pjIsEmpty (PayloadJSON _ PJObject keys) = S.size keys == 0
|
|
||||||
pjIsEmpty (PayloadJSON _ (PJArray l) _) = l == 0
|
|
||||||
|
|
||||||
data Proxy = Proxy {
|
data Proxy = Proxy {
|
||||||
proxyScheme :: Text
|
proxyScheme :: Text
|
||||||
, proxyHost :: Text
|
, proxyHost :: Text
|
||||||
, proxyPort :: Integer
|
, proxyPort :: Integer
|
||||||
, proxyPath :: Text
|
, proxyPath :: Text
|
||||||
} deriving (Show, Eq)
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
type Operator = Text
|
type Operator = Text
|
||||||
@@ -208,7 +353,8 @@ ftsOperators :: M.HashMap Operator SqlFragment
|
|||||||
ftsOperators = M.fromList [
|
ftsOperators = M.fromList [
|
||||||
("fts", "@@ to_tsquery"),
|
("fts", "@@ to_tsquery"),
|
||||||
("plfts", "@@ plainto_tsquery"),
|
("plfts", "@@ plainto_tsquery"),
|
||||||
("phfts", "@@ phraseto_tsquery")
|
("phfts", "@@ phraseto_tsquery"),
|
||||||
|
("wfts", "@@ websearch_to_tsquery")
|
||||||
]
|
]
|
||||||
|
|
||||||
data OpExpr = OpExpr Bool Operation deriving (Eq, Show)
|
data OpExpr = OpExpr Bool Operation deriving (Eq, Show)
|
||||||
@@ -224,8 +370,8 @@ type ListVal = [Text]
|
|||||||
|
|
||||||
data LogicOperator = And | Or deriving Eq
|
data LogicOperator = And | Or deriving Eq
|
||||||
instance Show LogicOperator where
|
instance Show LogicOperator where
|
||||||
show And = "AND"
|
show And = "AND"
|
||||||
show Or = "OR"
|
show Or = "OR"
|
||||||
{-|
|
{-|
|
||||||
Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:
|
Boolean logic expression tree e.g. "and(name.eq.N,or(id.eq.1,id.eq.2))" is:
|
||||||
|
|
||||||
@@ -259,70 +405,133 @@ type RpcQParam = (Text, Text)
|
|||||||
Custom guc header, it's obtained by parsing the json in a:
|
Custom guc header, it's obtained by parsing the json in a:
|
||||||
`SET LOCAL "response.headers" = '[{"Set-Cookie": ".."}]'
|
`SET LOCAL "response.headers" = '[{"Set-Cookie": ".."}]'
|
||||||
-}
|
-}
|
||||||
newtype GucHeader = GucHeader (Text, Text)
|
newtype GucHeader = GucHeader (CI.CI ByteString, ByteString)
|
||||||
|
deriving (Show, Eq)
|
||||||
|
|
||||||
instance JSON.FromJSON GucHeader where
|
instance JSON.FromJSON GucHeader where
|
||||||
parseJSON (JSON.Object o) = case headMay (M.toList o) of
|
parseJSON (JSON.Object o) = case headMay (M.toList o) of
|
||||||
Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (k, s)
|
Just (k, JSON.String s) | M.size o == 1 -> pure $ GucHeader (CI.mk $ toS k, toS s)
|
||||||
| otherwise -> mzero
|
| otherwise -> mzero
|
||||||
_ -> mzero
|
_ -> mzero
|
||||||
parseJSON _ = mzero
|
parseJSON _ = mzero
|
||||||
|
|
||||||
toHeaders :: [GucHeader] -> [Header]
|
unwrapGucHeader :: GucHeader -> Header
|
||||||
toHeaders = map $ \(GucHeader (k, v)) -> (CI.mk $ toS k, toS v)
|
unwrapGucHeader (GucHeader (k, v)) = (k, v)
|
||||||
|
|
||||||
|
-- | Add headers not already included to allow the user to override them instead of duplicating them
|
||||||
|
addHeadersIfNotIncluded :: [Header] -> [Header] -> [Header]
|
||||||
|
addHeadersIfNotIncluded newHeaders initialHeaders =
|
||||||
|
filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
|
||||||
|
initialHeaders
|
||||||
|
|
||||||
{-|
|
{-|
|
||||||
This type will hold information about which particular 'Relation' between two tables to choose when there are multiple ones.
|
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.
|
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 EmbedHint)
|
||||||
type SelectItem = (Field, Maybe Cast, Maybe Alias, Maybe RelationDetail)
|
-- | Disambiguates an embedding operation when there's multiple relationships between two tables.
|
||||||
|
-- | Can be the name of a foreign key constraint, column name or the junction in an m2m relationship.
|
||||||
|
type EmbedHint = Text
|
||||||
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"]
|
-- | Path of the embedded levels, e.g "clients.projects.name=eq.." gives Path ["clients", "projects"]
|
||||||
type EmbedPath = [Text]
|
type EmbedPath = [Text]
|
||||||
data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq)
|
data Filter = Filter { field::Field, opExpr::OpExpr } deriving (Show, Eq)
|
||||||
data JoinCondition = JoinCondition (QualifiedIdentifier, Maybe Alias, FieldName)
|
data JoinCondition = JoinCondition (QualifiedIdentifier, FieldName)
|
||||||
(QualifiedIdentifier, Maybe Alias, FieldName) deriving (Show, Eq)
|
(QualifiedIdentifier, FieldName) deriving (Show, Eq)
|
||||||
|
|
||||||
|
data ReadQuery = Select {
|
||||||
|
select :: [SelectItem]
|
||||||
|
, from :: QualifiedIdentifier
|
||||||
|
-- | 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 :: [QualifiedIdentifier]
|
||||||
|
, where_ :: [LogicTree]
|
||||||
|
, joinConditions :: [JoinCondition]
|
||||||
|
, order :: [OrderTerm]
|
||||||
|
, range_ :: NonnegRange
|
||||||
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
|
data MutateQuery =
|
||||||
|
Insert {
|
||||||
|
in_ :: QualifiedIdentifier
|
||||||
|
, insCols :: S.Set FieldName
|
||||||
|
, onConflict :: Maybe (PreferResolution, [FieldName])
|
||||||
|
, where_ :: [LogicTree]
|
||||||
|
, returning :: [FieldName]
|
||||||
|
}|
|
||||||
|
Update {
|
||||||
|
in_ :: QualifiedIdentifier
|
||||||
|
, updCols :: S.Set FieldName
|
||||||
|
, where_ :: [LogicTree]
|
||||||
|
, returning :: [FieldName]
|
||||||
|
}|
|
||||||
|
Delete {
|
||||||
|
in_ :: QualifiedIdentifier
|
||||||
|
, where_ :: [LogicTree]
|
||||||
|
, returning :: [FieldName]
|
||||||
|
} deriving (Show, Eq)
|
||||||
|
|
||||||
data ReadQuery = Select { select::[SelectItem], from::[TableName], where_::[LogicTree], joinConditions::[JoinCondition], order::[OrderTerm], range_::NonnegRange } deriving (Show, Eq)
|
|
||||||
data MutateQuery = Insert { in_::TableName, insPkCols::[Text], qPayload::PayloadJSON, onConflict:: Maybe PreferResolution, where_::[LogicTree], returning::[FieldName] }
|
|
||||||
| Delete { in_::TableName, where_::[LogicTree], returning::[FieldName] }
|
|
||||||
| Update { in_::TableName, qPayload::PayloadJSON, where_::[LogicTree], returning::[FieldName] } deriving (Show, Eq)
|
|
||||||
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe RelationDetail, Depth))
|
|
||||||
type ReadRequest = Tree ReadNode
|
type ReadRequest = Tree ReadNode
|
||||||
-- Depth of the ReadRequest tree
|
|
||||||
type Depth = Integer
|
|
||||||
type MutateRequest = MutateQuery
|
type MutateRequest = MutateQuery
|
||||||
data DbRequest = DbRead ReadRequest | DbMutate MutateRequest
|
|
||||||
|
|
||||||
instance Eq Table where
|
type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias, Maybe EmbedHint, Depth))
|
||||||
Table{tableSchema=s1,tableName=n1} == Table{tableSchema=s2,tableName=n2} = s1 == s2 && n1 == n2
|
type Depth = Integer
|
||||||
|
|
||||||
instance Eq Column where
|
-- First level FieldNames(e.g get a,b from /table?select=a,b,other(c,d))
|
||||||
Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2
|
fstFieldNames :: ReadRequest -> [FieldName]
|
||||||
|
fstFieldNames (Node (sel, _) _) =
|
||||||
-- | Convert from ContentType to a full HTTP Header
|
fst . view _1 <$> select sel
|
||||||
toHeader :: ContentType -> Header
|
|
||||||
toHeader ct = (hContentType, toMime ct <> "; charset=utf-8")
|
|
||||||
|
|
||||||
-- | Convert from ContentType to a ByteString representing the mime type
|
|
||||||
toMime :: ContentType -> ByteString
|
|
||||||
toMime CTApplicationJSON = "application/json"
|
|
||||||
toMime CTTextCSV = "text/csv"
|
|
||||||
toMime CTOpenAPI = "application/openapi+json"
|
|
||||||
toMime CTSingularJSON = "application/vnd.pgrst.object+json"
|
|
||||||
toMime CTOctetStream = "application/octet-stream"
|
|
||||||
toMime CTAny = "*/*"
|
|
||||||
toMime (CTOther ct) = ct
|
|
||||||
|
|
||||||
data PgVersion = PgVersion {
|
data PgVersion = PgVersion {
|
||||||
pgvNum :: Int32
|
pgvNum :: Int32
|
||||||
, pgvName :: Text
|
, pgvName :: Text
|
||||||
} deriving (Eq, Ord, Show)
|
} deriving (Eq, Show)
|
||||||
|
|
||||||
|
instance Ord PgVersion where
|
||||||
|
(PgVersion v1 _) `compare` (PgVersion v2 _) = v1 `compare` v2
|
||||||
|
|
||||||
|
-- | Tells the minimum PostgreSQL version required by this version of PostgREST
|
||||||
|
minimumPgVersion :: PgVersion
|
||||||
|
minimumPgVersion = pgVersion94
|
||||||
|
|
||||||
|
pgVersion94 :: PgVersion
|
||||||
|
pgVersion94 = PgVersion 90400 "9.4"
|
||||||
|
|
||||||
|
pgVersion95 :: PgVersion
|
||||||
|
pgVersion95 = PgVersion 90500 "9.5"
|
||||||
|
|
||||||
|
pgVersion96 :: PgVersion
|
||||||
|
pgVersion96 = PgVersion 90600 "9.6"
|
||||||
|
|
||||||
|
pgVersion100 :: PgVersion
|
||||||
|
pgVersion100 = PgVersion 100000 "10"
|
||||||
|
|
||||||
|
pgVersion109 :: PgVersion
|
||||||
|
pgVersion109 = PgVersion 100009 "10.9"
|
||||||
|
|
||||||
|
pgVersion110 :: PgVersion
|
||||||
|
pgVersion110 = PgVersion 110000 "11.0"
|
||||||
|
|
||||||
|
pgVersion112 :: PgVersion
|
||||||
|
pgVersion112 = PgVersion 110002 "11.2"
|
||||||
|
|
||||||
|
pgVersion114 :: PgVersion
|
||||||
|
pgVersion114 = PgVersion 110004 "11.4"
|
||||||
|
|
||||||
|
pgVersion121 :: PgVersion
|
||||||
|
pgVersion121 = PgVersion 120001 "12.1"
|
||||||
|
|
||||||
sourceCTEName :: SqlFragment
|
sourceCTEName :: SqlFragment
|
||||||
sourceCTEName = "pg_source"
|
sourceCTEName = "pgrst_source"
|
||||||
|
|
||||||
-- | full jspath, e.g. .property[0].attr.detail
|
-- | full jspath, e.g. .property[0].attr.detail
|
||||||
type JSPath = [JSPathExp]
|
type JSPath = [JSPathExp]
|
||||||
-- | jspath expression, e.g. .property, .property[0] or ."property-dash"
|
-- | jspath expression, e.g. .property, .property[0] or ."property-dash"
|
||||||
data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)
|
data JSPathExp = JSPKey Text | JSPIdx Int deriving (Eq, Show)
|
||||||
|
|
||||||
|
-- | Current database connection status data ConnectionStatus
|
||||||
|
data ConnectionStatus
|
||||||
|
= NotConnected
|
||||||
|
| Connected PgVersion
|
||||||
|
| FatalConnectionError Text
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
resolver: lts-9.6
|
# stack is used for circleci and appveyor CI builds
|
||||||
extra-deps:
|
resolver: lts-15.8
|
||||||
- configurator-ng-0.0.0.1
|
|
||||||
- critbit-0.2.0.0
|
|
||||||
- hjsonpointer-1.1.1
|
|
||||||
- hjsonschema-1.5.0.1
|
|
||||||
- Ranged-sets-0.3.0
|
|
||||||
- protolude-0.2.2
|
|
||||||
- hasql-1.1
|
|
||||||
- hasql-pool-0.4.3
|
|
||||||
- hasql-transaction-0.5.2
|
|
||||||
- jose-0.7.0.0
|
|
||||||
- postgresql-libpq-0.9.4.1
|
|
||||||
ghc-options:
|
|
||||||
postgrest: -O2 -Werror -Wall -fwarn-identities -fno-warn-redundant-constraints
|
|
||||||
nix:
|
nix:
|
||||||
packages: [postgresql, zlib]
|
packages: [pcre, pkgconfig, postgresql, zlib]
|
||||||
|
# disable pure by default so that the test enviroment can be passed
|
||||||
|
pure: false
|
||||||
|
extra-deps:
|
||||||
|
- configurator-pg-0.2.2@sha256:8f3f7c66dd4a53852283c70806ebc101a7219f8b01c3e80c5130e9749c998dc8,2987
|
||||||
|
- hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
|
||||||
|
- interpolatedstring-perl6-1.0.2@sha256:7ce49c8a69a2a1b89c001ed79db2aab656ffd0faf2a7a701a553b6deb5c8ba7f,1073
|
||||||
|
- protolude-0.3.0@sha256:8361b811b420585b122a7ba715aa5923834db6e8c36309bf267df2dbf66b95ef,2693
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# This file was autogenerated by Stack.
|
||||||
|
# You should not edit this file by hand.
|
||||||
|
# For more information, please see the documentation at:
|
||||||
|
# https://docs.haskellstack.org/en/stable/lock_files
|
||||||
|
|
||||||
|
packages:
|
||||||
|
- completed:
|
||||||
|
hackage: configurator-pg-0.2.2@sha256:8f3f7c66dd4a53852283c70806ebc101a7219f8b01c3e80c5130e9749c998dc8,2987
|
||||||
|
pantry-tree:
|
||||||
|
size: 2462
|
||||||
|
sha256: a669366632e19f396dfe52599d54414b9c92d5664ea4391c2c797be271f9aa0e
|
||||||
|
original:
|
||||||
|
hackage: configurator-pg-0.2.2@sha256:8f3f7c66dd4a53852283c70806ebc101a7219f8b01c3e80c5130e9749c998dc8,2987
|
||||||
|
- completed:
|
||||||
|
hackage: hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
|
||||||
|
pantry-tree:
|
||||||
|
size: 349
|
||||||
|
sha256: fb9e89b79cde3276baa484c860c6b9eeebdbc1a5c43301293351a25bc4c08e87
|
||||||
|
original:
|
||||||
|
hackage: hspec-wai-json-0.10.1@sha256:67b405c38f0a9e2771480c8d3ecd8aeb8d8776a35d3b2906cb1b76c9538617e4,1629
|
||||||
|
- completed:
|
||||||
|
hackage: interpolatedstring-perl6-1.0.2@sha256:7ce49c8a69a2a1b89c001ed79db2aab656ffd0faf2a7a701a553b6deb5c8ba7f,1073
|
||||||
|
pantry-tree:
|
||||||
|
size: 300
|
||||||
|
sha256: f7f0b06c58db814d1f12ed6f4f4cb67ff8c72e51da61c04db12c2aedb896542e
|
||||||
|
original:
|
||||||
|
hackage: interpolatedstring-perl6-1.0.2@sha256:7ce49c8a69a2a1b89c001ed79db2aab656ffd0faf2a7a701a553b6deb5c8ba7f,1073
|
||||||
|
- completed:
|
||||||
|
hackage: protolude-0.3.0@sha256:8361b811b420585b122a7ba715aa5923834db6e8c36309bf267df2dbf66b95ef,2693
|
||||||
|
pantry-tree:
|
||||||
|
size: 1644
|
||||||
|
sha256: babf32b414f25f790b7a4ce6bae5c960bc51a11a289e7c47335b222e6762560c
|
||||||
|
original:
|
||||||
|
hackage: protolude-0.3.0
|
||||||
|
snapshots:
|
||||||
|
- completed:
|
||||||
|
size: 492015
|
||||||
|
url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/15/8.yaml
|
||||||
|
sha256: 926bc3d70249dd0ba05277ff00943c0addb35b627cb641752669e7cf771310d0
|
||||||
|
original: lts-15.8
|
||||||
|
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 |
@@ -4,7 +4,7 @@ ENV PATH /root/.local/bin:$PATH
|
|||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y wget libpq-dev pkg-config libpcre3 libpcre3-dev \
|
&& apt-get install -y wget libpq-dev pkg-config libpcre3 libpcre3-dev \
|
||||||
postgresql-client debconf locales \
|
postgresql-client debconf locales build-essential libffi-dev libgmp-dev git \
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
&& apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
|
||||||
&& echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen \
|
&& echo 'en_US.UTF-8 UTF-8' > /etc/locale.gen \
|
||||||
&& locale-gen \
|
&& locale-gen \
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
module Feature.AndOrParamsSpec where
|
module Feature.AndOrParamsSpec where
|
||||||
import Test.Hspec
|
|
||||||
import Test.Hspec.Wai
|
|
||||||
import Test.Hspec.Wai.JSON
|
|
||||||
import Network.HTTP.Types
|
|
||||||
|
|
||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
|
||||||
|
import PostgREST.Types (PgVersion, pgVersion112)
|
||||||
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Protolude hiding (get)
|
|
||||||
|
|
||||||
|
spec :: PgVersion -> SpecWith ((), Application)
|
||||||
spec :: SpecWith Application
|
spec actualPgVersion =
|
||||||
spec =
|
|
||||||
describe "and/or params used for complex boolean logic" $ do
|
describe "and/or params used for complex boolean logic" $ do
|
||||||
context "used with GET" $ do
|
context "used with GET" $ do
|
||||||
context "or param" $ do
|
context "or param" $ do
|
||||||
@@ -79,6 +80,19 @@ spec =
|
|||||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
||||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}
|
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}
|
||||||
]|] { matchHeaders = [matchContentTypeJson] }
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
when (actualPgVersion >= pgVersion112) $
|
||||||
|
it "can handle wfts (websearch_to_tsquery)" $
|
||||||
|
get "/tsearch?or=(text_search_vector.plfts(german).Art,text_search_vector.plfts(french).amusant,text_search_vector.not.wfts(english).impossible)"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"text_search_vector": "'also':2 'fun':3 'possibl':8" },
|
||||||
|
{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" },
|
||||||
|
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" },
|
||||||
|
{"text_search_vector": "'art':4 'spass':5 'unmog':7" }
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can handle cs and cd" $
|
it "can handle cs and cd" $
|
||||||
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
|
get "/entities?or=(arr.cs.{1,2,3},arr.cd.{1})&select=id" `shouldRespondWith`
|
||||||
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
[json|[{ "id": 1 },{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
@@ -113,6 +127,34 @@ spec =
|
|||||||
get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`
|
get "/ranges?range=adj.(3,10]&select=id" `shouldRespondWith`
|
||||||
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
[json|[{ "id": 1 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can handle array operators" $ do
|
||||||
|
get "/entities?arr=eq.{1,2,3}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=neq.{1,2}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=lt.{2,3}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=lt.{2,0}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=gt.{1,1}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=gt.{3}&select=id" `shouldRespondWith`
|
||||||
|
[json|[]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=lte.{2,1}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=lte.{1,2,3}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=lte.{1,2}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=cs.{1,2}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=cd.{1,2,6}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=ov.{3}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/entities?arr=ov.{2,3}&select=id" `shouldRespondWith`
|
||||||
|
[json|[{ "id": 2 }, { "id": 3 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "operators with not" $ do
|
context "operators with not" $ do
|
||||||
it "eq, cs, like can be negated" $
|
it "eq, cs, like can be negated" $
|
||||||
get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
|
get "/entities?and=(arr.not.cs.{1,2,3},and(id.not.eq.2,name.not.like.*3))&select=id" `shouldRespondWith`
|
||||||
@@ -167,7 +209,7 @@ spec =
|
|||||||
|
|
||||||
context "used with POST" $
|
context "used with POST" $
|
||||||
it "includes related data with filters" $
|
it "includes related data with filters" $
|
||||||
request methodPost "/child_entities?entities.or=(id.eq.2,id.eq.3)&select=id,entities(id)"
|
request methodPost "/child_entities?select=id,entities(id)&entities.or=(id.eq.2,id.eq.3)&entities.order=id"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
[json|[{"id":4,"name":"entity 4","parent_id":1},
|
[json|[{"id":4,"name":"entity 4","parent_id":1},
|
||||||
{"id":5,"name":"entity 5","parent_id":2},
|
{"id":5,"name":"entity 5","parent_id":2},
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
module Feature.AsymmetricJwtSpec where
|
module Feature.AsymmetricJwtSpec where
|
||||||
|
|
||||||
-- {{{ Imports
|
-- {{{ Imports
|
||||||
import Test.Hspec
|
|
||||||
import Test.Hspec.Wai
|
|
||||||
import Network.HTTP.Types
|
|
||||||
|
|
||||||
import SpecHelper
|
|
||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
import SpecHelper
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith ((), Application)
|
||||||
spec = describe "server started with asymmetric JWK" $
|
spec = describe "server started with asymmetric JWK" $
|
||||||
|
|
||||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
module Feature.AudienceJwtSecretSpec where
|
module Feature.AudienceJwtSecretSpec where
|
||||||
|
|
||||||
-- {{{ Imports
|
-- {{{ Imports
|
||||||
import Test.Hspec
|
|
||||||
import Test.Hspec.Wai
|
|
||||||
import Network.HTTP.Types
|
|
||||||
|
|
||||||
import SpecHelper
|
|
||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
import SpecHelper
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith ((), Application)
|
||||||
spec = describe "test handling of aud claims in JWT" $ do
|
spec = describe "test handling of aud claims in JWT" $ do
|
||||||
|
|
||||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||||
|
|||||||
@@ -1,26 +1,36 @@
|
|||||||
module Feature.AuthSpec where
|
module Feature.AuthSpec where
|
||||||
|
|
||||||
import Text.Heredoc
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Network.HTTP.Types
|
import Text.Heredoc
|
||||||
|
|
||||||
|
import PostgREST.Types (PgVersion, pgVersion112)
|
||||||
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
spec :: PgVersion -> SpecWith ((), Application)
|
||||||
|
spec actualPgVersion = describe "authorization" $ do
|
||||||
spec :: SpecWith Application
|
|
||||||
spec = describe "authorization" $ do
|
|
||||||
let single = ("Accept","application/vnd.pgrst.object+json")
|
let single = ("Accept","application/vnd.pgrst.object+json")
|
||||||
|
|
||||||
it "denies access to tables that anonymous does not own" $
|
it "denies access to tables that anonymous does not own" $
|
||||||
get "/authors_only" `shouldRespondWith` [json| {
|
get "/authors_only" `shouldRespondWith` (
|
||||||
|
if actualPgVersion >= pgVersion112 then
|
||||||
|
[json| {
|
||||||
|
"hint":null,
|
||||||
|
"details":null,
|
||||||
|
"code":"42501",
|
||||||
|
"message":"permission denied for table authors_only"} |]
|
||||||
|
else
|
||||||
|
[json| {
|
||||||
"hint":null,
|
"hint":null,
|
||||||
"details":null,
|
"details":null,
|
||||||
"code":"42501",
|
"code":"42501",
|
||||||
"message":"permission denied for relation authors_only"} |]
|
"message":"permission denied for relation authors_only"} |]
|
||||||
|
)
|
||||||
{ matchStatus = 401
|
{ matchStatus = 401
|
||||||
, matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
|
, matchHeaders = ["WWW-Authenticate" <:> "Bearer"]
|
||||||
}
|
}
|
||||||
@@ -28,11 +38,20 @@ spec = describe "authorization" $ do
|
|||||||
it "denies access to tables that postgrest_test_author does not own" $
|
it "denies access to tables that postgrest_test_author does not own" $
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA" in
|
||||||
request methodGet "/private_table" [auth] ""
|
request methodGet "/private_table" [auth] ""
|
||||||
`shouldRespondWith` [json| {
|
`shouldRespondWith` (
|
||||||
|
if actualPgVersion >= pgVersion112 then
|
||||||
|
[json| {
|
||||||
|
"hint":null,
|
||||||
|
"details":null,
|
||||||
|
"code":"42501",
|
||||||
|
"message":"permission denied for table private_table"} |]
|
||||||
|
else
|
||||||
|
[json| {
|
||||||
"hint":null,
|
"hint":null,
|
||||||
"details":null,
|
"details":null,
|
||||||
"code":"42501",
|
"code":"42501",
|
||||||
"message":"permission denied for relation private_table"} |]
|
"message":"permission denied for relation private_table"} |]
|
||||||
|
)
|
||||||
{ matchStatus = 403
|
{ matchStatus = 403
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
@@ -98,11 +117,11 @@ spec = describe "authorization" $ do
|
|||||||
it "hides tables from users with invalid JWT" $ do
|
it "hides tables from users with invalid JWT" $ do
|
||||||
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
|
||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError \"expected 3 parts, got 2\")"} |]
|
`shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"} |]
|
||||||
{ matchStatus = 401
|
{ matchStatus = 401
|
||||||
, matchHeaders = [
|
, matchHeaders = [
|
||||||
"WWW-Authenticate" <:>
|
"WWW-Authenticate" <:>
|
||||||
"Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError \\\"expected 3 parts, got 2\\\")\""
|
"Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)\""
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
module Feature.BinaryJwtSecretSpec where
|
module Feature.BinaryJwtSecretSpec where
|
||||||
|
|
||||||
-- {{{ Imports
|
-- {{{ Imports
|
||||||
import Test.Hspec
|
|
||||||
import Test.Hspec.Wai
|
|
||||||
import Network.HTTP.Types
|
|
||||||
|
|
||||||
import SpecHelper
|
|
||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
import SpecHelper
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith ((), Application)
|
||||||
spec = describe "server started with binary JWT secret" $
|
spec = describe "server started with binary JWT secret" $
|
||||||
|
|
||||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
{-# LANGUAGE MultiParamTypeClasses, TypeFamilies, UndecidableInstances #-}
|
{-# LANGUAGE MultiParamTypeClasses #-}
|
||||||
|
{-# LANGUAGE TypeFamilies #-}
|
||||||
|
{-# LANGUAGE UndecidableInstances #-}
|
||||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||||
module Feature.ConcurrentSpec where
|
module Feature.ConcurrentSpec where
|
||||||
|
|
||||||
import Control.Monad (void)
|
|
||||||
import Control.Monad.Base
|
|
||||||
|
|
||||||
import Control.Monad.Trans.Control
|
|
||||||
import Control.Concurrent.Async (mapConcurrently)
|
import Control.Concurrent.Async (mapConcurrently)
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
import Test.Hspec hiding (pendingWith)
|
import Control.Monad.Base
|
||||||
import Test.Hspec.Wai.Internal
|
import Control.Monad.Trans.Control
|
||||||
|
|
||||||
|
import Network.Wai.Test (Session)
|
||||||
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.Internal
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Network.Wai.Test (Session)
|
|
||||||
|
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import Protolude hiding (get)
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith ((), Application)
|
||||||
spec =
|
spec =
|
||||||
describe "Queryiny in parallel" $
|
describe "Querying in parallel" $
|
||||||
it "should not raise 'transaction in progress' error" $
|
it "should not raise 'transaction in progress' error" $
|
||||||
raceTest 10 $
|
raceTest 10 $
|
||||||
get "/fakefake"
|
get "/fakefake"
|
||||||
@@ -34,13 +34,13 @@ spec =
|
|||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
raceTest :: Int -> WaiExpectation -> WaiExpectation
|
raceTest :: Int -> WaiExpectation st -> WaiExpectation st
|
||||||
raceTest times = liftBaseDiscard go
|
raceTest times = liftBaseDiscard go
|
||||||
where
|
where
|
||||||
go test = void $ mapConcurrently (const test) [1..times]
|
go test = void $ mapConcurrently (const test) [1..times]
|
||||||
|
|
||||||
instance MonadBaseControl IO WaiSession where
|
instance MonadBaseControl IO (WaiSession st) where
|
||||||
type StM WaiSession a = StM Session a
|
type StM (WaiSession st) a = StM Session a
|
||||||
liftBaseWith f = WaiSession $
|
liftBaseWith f = WaiSession $
|
||||||
liftBaseWith $ \runInBase ->
|
liftBaseWith $ \runInBase ->
|
||||||
f $ \k -> runInBase (unWaiSession k)
|
f $ \k -> runInBase (unWaiSession k)
|
||||||
@@ -48,5 +48,5 @@ instance MonadBaseControl IO WaiSession where
|
|||||||
{-# INLINE liftBaseWith #-}
|
{-# INLINE liftBaseWith #-}
|
||||||
{-# INLINE restoreM #-}
|
{-# INLINE restoreM #-}
|
||||||
|
|
||||||
instance MonadBase IO WaiSession where
|
instance MonadBase IO (WaiSession st) where
|
||||||
liftBase = liftIO
|
liftBase = liftIO
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
module Feature.CorsSpec where
|
module Feature.CorsSpec where
|
||||||
|
|
||||||
-- {{{ Imports
|
-- {{{ Imports
|
||||||
import Test.Hspec
|
|
||||||
import Test.Hspec.Wai
|
|
||||||
import Network.Wai.Test (SResponse(simpleHeaders, simpleBody))
|
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
|
||||||
import SpecHelper
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders))
|
||||||
|
|
||||||
import Network.HTTP.Types
|
import Network.HTTP.Types
|
||||||
import Network.Wai (Application)
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import Protolude
|
||||||
|
import SpecHelper
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith ((), Application)
|
||||||
spec =
|
spec =
|
||||||
describe "CORS" $ do
|
describe "CORS" $ do
|
||||||
let preflightHeaders = [
|
let preflightHeaders = [
|
||||||
@@ -45,7 +45,7 @@ spec =
|
|||||||
"true"
|
"true"
|
||||||
respHeaders `shouldSatisfy` matchHeader
|
respHeaders `shouldSatisfy` matchHeader
|
||||||
"Access-Control-Allow-Methods"
|
"Access-Control-Allow-Methods"
|
||||||
"GET, POST, PATCH, DELETE, OPTIONS, HEAD"
|
"GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD"
|
||||||
respHeaders `shouldSatisfy` matchHeader
|
respHeaders `shouldSatisfy` matchHeader
|
||||||
"Access-Control-Allow-Headers"
|
"Access-Control-Allow-Headers"
|
||||||
"Authentication, Foo, Bar, Accept, Accept-Language, Content-Language"
|
"Authentication, Foo, Bar, Accept, Accept-Language, Content-Language"
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
module Feature.DeleteSpec where
|
module Feature.DeleteSpec where
|
||||||
|
|
||||||
import Test.Hspec
|
import Network.Wai (Application)
|
||||||
import Test.Hspec.Wai
|
|
||||||
import Text.Heredoc
|
|
||||||
|
|
||||||
import Network.HTTP.Types
|
import Network.HTTP.Types
|
||||||
import Network.Wai (Application)
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
import Text.Heredoc
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import Protolude hiding (get)
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith ((), Application)
|
||||||
spec =
|
spec =
|
||||||
describe "Deleting" $ do
|
describe "Deleting" $ do
|
||||||
context "existing record" $ do
|
context "existing record" $ do
|
||||||
@@ -26,17 +27,32 @@ spec =
|
|||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "*/1"]
|
, matchHeaders = ["Content-Range" <:> "*/1"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "ignores ?select= when return not set or return=minimal" $ do
|
||||||
|
request methodDelete "/items?id=eq.3&select=id" [] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 204
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
request methodDelete "/items?id=eq.3&select=id" [("Prefer", "return=minimal")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 204
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
it "returns the deleted item and shapes the response" $
|
it "returns the deleted item and shapes the response" $
|
||||||
request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
|
request methodDelete "/complex_items?id=eq.2&select=id,name" [("Prefer", "return=representation")] ""
|
||||||
`shouldRespondWith` [str|[{"id":2,"name":"Two"}]|]
|
`shouldRespondWith` [str|[{"id":2,"name":"Two"}]|]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "can rename and cast the selected columns" $
|
it "can rename and cast the selected columns" $
|
||||||
request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
|
request methodDelete "/complex_items?id=eq.3&select=ciId:id::text,ciName:name" [("Prefer", "return=representation")] ""
|
||||||
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
|
`shouldRespondWith` [str|[{"ciId":"3","ciName":"Three"}]|]
|
||||||
|
|
||||||
it "can embed (parent) entities" $
|
it "can embed (parent) entities" $
|
||||||
request methodDelete "/tasks?id=eq.8&select=id,name,project(id)" [("Prefer", "return=representation")] ""
|
request methodDelete "/tasks?id=eq.8&select=id,name,project:projects(id)" [("Prefer", "return=representation")] ""
|
||||||
`shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
|
`shouldRespondWith` [str|[{"id":8,"name":"Code OSX","project":{"id":4}}]|]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
@@ -62,3 +78,24 @@ spec =
|
|||||||
context "totally unknown route" $
|
context "totally unknown route" $
|
||||||
it "fails with 404" $
|
it "fails with 404" $
|
||||||
request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404
|
request methodDelete "/foozle?id=eq.101" [] "" `shouldRespondWith` 404
|
||||||
|
|
||||||
|
context "table with limited privileges" $ do
|
||||||
|
it "fails deleting the row when return=representation and selecting all the columns" $
|
||||||
|
request methodDelete "/app_users?id=eq.1" [("Prefer", "return=representation")] mempty
|
||||||
|
`shouldRespondWith` 401
|
||||||
|
|
||||||
|
it "succeeds deleting the row when return=representation and selecting only the privileged columns" $
|
||||||
|
request methodDelete "/app_users?id=eq.1&select=id,email" [("Prefer", "return=representation")]
|
||||||
|
[json| { "password": "passxyz" } |]
|
||||||
|
`shouldRespondWith` [json|[ { "id": 1, "email": "test@123.com" } ]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "suceeds deleting the row with no explicit select when using return=minimal" $
|
||||||
|
request methodDelete "/app_users?id=eq.2" [("Prefer", "return=minimal")] mempty
|
||||||
|
`shouldRespondWith` 204
|
||||||
|
|
||||||
|
it "suceeds deleting the row with no explicit select by default" $
|
||||||
|
request methodDelete "/app_users?id=eq.3" [] mempty
|
||||||
|
`shouldRespondWith` 204
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
module Feature.EmbedDisambiguationSpec where
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Test.Hspec hiding (pendingWith)
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
import Text.Heredoc
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
import SpecHelper
|
||||||
|
|
||||||
|
spec :: SpecWith ((), Application)
|
||||||
|
spec =
|
||||||
|
describe "resource embedding disambiguation" $ do
|
||||||
|
context "ambiguous requests that give 300 Multiple Choices" $ do
|
||||||
|
it "errs when there's a table and view that point to the same fk" $
|
||||||
|
get "/message?select=id,body,sender(name,sent)" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
{
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"cardinality": "m2o",
|
||||||
|
"relationship": "message_sender_fkey[sender][id]",
|
||||||
|
"origin": "test.message",
|
||||||
|
"target": "test.person"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cardinality": "m2o",
|
||||||
|
"relationship": "message_sender_fkey[sender][id]",
|
||||||
|
"origin": "test.message",
|
||||||
|
"target": "test.person_detail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hint": "By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)",
|
||||||
|
"message": "More than one relationship was found for message and sender"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
{ matchStatus = 300
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "errs when there are o2m and m2m cardinalities to the target table" $
|
||||||
|
get "/sites?select=*,big_projects(*)" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
{
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"cardinality": "m2o",
|
||||||
|
"relationship": "main_project[main_project_id][big_project_id]",
|
||||||
|
"origin": "test.sites",
|
||||||
|
"target": "test.big_projects"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cardinality": "m2m",
|
||||||
|
"relationship": "test.jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",
|
||||||
|
"origin": "test.sites",
|
||||||
|
"target": "test.big_projects"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cardinality": "m2m",
|
||||||
|
"relationship": "test.main_jobs[jobs_site_id_fkey][jobs_big_project_id_fkey]",
|
||||||
|
"origin": "test.sites",
|
||||||
|
"target": "test.big_projects"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hint": "By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)",
|
||||||
|
"message": "More than one relationship was found for sites and big_projects"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
{ matchStatus = 300
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "errs on an ambiguous embed that has a circular reference" $
|
||||||
|
get "/agents?select=*,departments(*)" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
{
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"cardinality": "m2o",
|
||||||
|
"relationship": "agents_department_id_fkey[department_id][id]",
|
||||||
|
"origin": "test.agents",
|
||||||
|
"target": "test.departments"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cardinality": "o2m",
|
||||||
|
"relationship": "departments_head_id_fkey[id][head_id]",
|
||||||
|
"origin": "test.agents",
|
||||||
|
"target": "test.departments"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hint": "By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)",
|
||||||
|
"message": "More than one relationship was found for agents and departments"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
{ matchStatus = 300
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "errs when there are more than two fks on a junction table(currently impossible to disambiguate, only choice is to split the table)" $
|
||||||
|
-- We have 4 possibilities for doing the junction JOIN here.
|
||||||
|
-- This could be solved by specifying two additional fks, like whatev_projects!fk1!fk2(*)
|
||||||
|
-- If the need arises this capability can be added later without causing a breaking change
|
||||||
|
get "/whatev_sites?select=*,whatev_projects(*)" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
{
|
||||||
|
"details": [
|
||||||
|
{
|
||||||
|
"cardinality": "m2m",
|
||||||
|
"relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_1_fkey]",
|
||||||
|
"origin": "test.whatev_sites",
|
||||||
|
"target": "test.whatev_projects"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cardinality": "m2m",
|
||||||
|
"relationship": "test.whatev_jobs[whatev_jobs_site_id_1_fkey][whatev_jobs_project_id_2_fkey]",
|
||||||
|
"origin": "test.whatev_sites",
|
||||||
|
"target": "test.whatev_projects"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cardinality": "m2m",
|
||||||
|
"relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_1_fkey]",
|
||||||
|
"origin": "test.whatev_sites",
|
||||||
|
"target": "test.whatev_projects"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cardinality": "m2m",
|
||||||
|
"relationship": "test.whatev_jobs[whatev_jobs_site_id_2_fkey][whatev_jobs_project_id_2_fkey]",
|
||||||
|
"origin": "test.whatev_sites",
|
||||||
|
"target": "test.whatev_projects"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hint": "By following the 'details' key, disambiguate the request by changing the url to /origin?select=relationship(*) or /origin?select=target!relationship(*)",
|
||||||
|
"message": "More than one relationship was found for whatev_sites and whatev_projects"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
{ matchStatus = 300
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "disambiguating requests with embed hints" $ do
|
||||||
|
|
||||||
|
context "using FK to specify the relationship" $ do
|
||||||
|
it "can embed by FK name" $
|
||||||
|
get "/projects?id=in.(1,3)&select=id,name,client(id,name)" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client":{"id":2,"name":"Apple"}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can embed by FK name and select the FK column at the same time" $
|
||||||
|
get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can embed parent with view!fk and grandparent by using fk" $
|
||||||
|
get "/tasks?id=eq.1&select=id,name,projects_view!project(id,name,client(id,name))" `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Design w7","projects_view":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
||||||
|
|
||||||
|
it "can embed by using a composite FK name" $
|
||||||
|
get "/unit_workdays?select=unit_id,day,fst_shift(car_id,schedule(name)),snd_shift(camera_id,schedule(name))" `shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{
|
||||||
|
"day": "2019-12-02",
|
||||||
|
"fst_shift": {
|
||||||
|
"car_id": "CAR-349",
|
||||||
|
"schedule": {
|
||||||
|
"name": "morning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snd_shift": {
|
||||||
|
"camera_id": "CAM-123",
|
||||||
|
"schedule": {
|
||||||
|
"name": "night"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unit_id": 1
|
||||||
|
}
|
||||||
|
] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "embeds by using two fks pointing to the same table" $
|
||||||
|
get "/orders?id=eq.1&select=id, name, billing(address), shipping(address)" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"order 1","billing":{"address": "address 1"},"shipping":{"address": "address 2"}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "fails if the fk is not known" $
|
||||||
|
get "/message?select=id,sender:person!space(name)&id=lt.4" `shouldRespondWith`
|
||||||
|
[json|{"message":"Could not find foreign keys between these entities. No relationship found between message and person"}|]
|
||||||
|
{ matchStatus = 400
|
||||||
|
, matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can request a parent with fk" $
|
||||||
|
get "/comments?select=content,user(name)" `shouldRespondWith`
|
||||||
|
[json|[ { "content": "Needs to be delivered ASAP", "user": { "name": "Angela Martin" } } ]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can request two parents with fks" $
|
||||||
|
get "/articleStars?select=createdAt,article(owner),user(name)&limit=1" `shouldRespondWith`
|
||||||
|
[json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can specify a view!fk" $
|
||||||
|
get "/message?select=id,body,sender:person_detail!message_sender_fkey(name,sent),recipient:person_detail!message_recipient_fkey(name,received)&id=lt.4" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
[{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},
|
||||||
|
{"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
|
||||||
|
{"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can specify a table!fk hint and request children 2 levels" $
|
||||||
|
get "/clients?id=eq.1&select=id,projects:projects!client(id,tasks(id))" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can disambiguate with the fk in case of an o2m and m2m relationship to the same table" $
|
||||||
|
get "/sites?select=name,main_project(name)&site_id=eq.1" `shouldRespondWith`
|
||||||
|
[json| [ { "name": "site 1", "main_project": { "name": "big project 1" } } ] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "using the column name of the FK to specify the relationship" $ do
|
||||||
|
it "can embed by column" $
|
||||||
|
get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can embed by column and select the column at the same time, if aliased" $
|
||||||
|
get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can embed parent by using view!column and grandparent by using the column" $
|
||||||
|
get "/tasks?id=eq.1&select=id,name,project:projects_view!project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
|
||||||
|
[str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
||||||
|
|
||||||
|
it "can specify table!column" $
|
||||||
|
get "/message?select=id,body,sender:person!sender(name),recipient:person!recipient(name)&id=lt.4" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
[{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
|
||||||
|
{"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
|
||||||
|
{"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "will embed using a column that has uppercase chars" $
|
||||||
|
get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`
|
||||||
|
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "embeds by using two columns pointing to the same table" $
|
||||||
|
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can disambiguate with the column in case of an o2m and m2m relationship to the same table" $
|
||||||
|
get "/sites?select=name,main_project_id(name)&site_id=eq.1" `shouldRespondWith`
|
||||||
|
[json| [ { "name": "site 1", "main_project_id": { "name": "big project 1" } } ] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "using the junction to disambiguate the request" $
|
||||||
|
it "can specify the junction of an m2m relationship" $ do
|
||||||
|
get "/sites?select=*,big_projects!jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"big_projects": [
|
||||||
|
{
|
||||||
|
"name": "big project 1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"main_project_id": 1,
|
||||||
|
"name": "site 1",
|
||||||
|
"site_id": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"big_projects": [
|
||||||
|
{
|
||||||
|
"name": "big project 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "big project 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"main_project_id": null,
|
||||||
|
"name": "site 2",
|
||||||
|
"site_id": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|]
|
||||||
|
get "/sites?select=*,big_projects!main_jobs(name)&site_id=in.(1,2)" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"big_projects": [
|
||||||
|
{
|
||||||
|
"name": "big project 1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"main_project_id": 1,
|
||||||
|
"name": "site 1",
|
||||||
|
"site_id": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"big_projects": [],
|
||||||
|
"main_project_id": null,
|
||||||
|
"name": "site 2",
|
||||||
|
"site_id": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "using a FK column and a FK to specify the relationship" $
|
||||||
|
it "embeds by using a column and a fk pointing to the same table" $
|
||||||
|
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping(id)" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping":{"id":2}}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "tables with self reference foreign keys" $ do
|
||||||
|
context "one self reference foreign key" $ do
|
||||||
|
it "embeds parents recursively" $
|
||||||
|
get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{ "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },
|
||||||
|
{ "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "embeds children recursively" $
|
||||||
|
get "/family_tree?id=eq.1&select=id,name, children:family_tree!parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`
|
||||||
|
[json|[{
|
||||||
|
"id": "1", "name": "Parental Unit", "children": [
|
||||||
|
{ "id": "2", "name": "Kid One", "children": [ { "id": "4", "name": "Grandkid One" } ] },
|
||||||
|
{ "id": "3", "name": "Kid Two", "children": [ { "id": "5", "name": "Grandkid Two" } ] }
|
||||||
|
]
|
||||||
|
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "embeds parent and then embeds children" $
|
||||||
|
get "/family_tree?id=eq.2&select=id,name,parent(id,name,children:family_tree!parent(id,name))" `shouldRespondWith`
|
||||||
|
[json|[{
|
||||||
|
"id": "2", "name": "Kid One", "parent": {
|
||||||
|
"id": "1", "name": "Parental Unit", "children": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]
|
||||||
|
}
|
||||||
|
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "two self reference foreign keys" $ do
|
||||||
|
it "embeds parents" $
|
||||||
|
get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
|
||||||
|
[json|[{
|
||||||
|
"id": 3, "name": "Acme",
|
||||||
|
"referee": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Referee Org"
|
||||||
|
},
|
||||||
|
"auditor": {
|
||||||
|
"id": 2,
|
||||||
|
"name": "Auditor Org"
|
||||||
|
}
|
||||||
|
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "embeds children" $ do
|
||||||
|
get "/organizations?select=id,name,refereeds:organizations!referee(id,name)&id=eq.1" `shouldRespondWith`
|
||||||
|
[json|[{
|
||||||
|
"id": 1, "name": "Referee Org",
|
||||||
|
"refereeds": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Acme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "Umbrella"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/organizations?select=id,name,auditees:organizations!auditor(id,name)&id=eq.2" `shouldRespondWith`
|
||||||
|
[json|[{
|
||||||
|
"id": 2, "name": "Auditor Org",
|
||||||
|
"auditees": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Acme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "Umbrella"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "embeds other relations(manager) besides the self reference" $ do
|
||||||
|
get "/organizations?select=name,manager(name),referee(name,manager(name),auditor(name,manager(name))),auditor(name,manager(name),referee(name,manager(name)))&id=eq.5" `shouldRespondWith`
|
||||||
|
[json|[{
|
||||||
|
"name":"Cyberdyne",
|
||||||
|
"manager":{"name":"Cyberdyne Manager"},
|
||||||
|
"referee":{
|
||||||
|
"name":"Acme",
|
||||||
|
"manager":{"name":"Acme Manager"},
|
||||||
|
"auditor":{
|
||||||
|
"name":"Auditor Org",
|
||||||
|
"manager":{"name":"Auditor Manager"}}},
|
||||||
|
"auditor":{
|
||||||
|
"name":"Umbrella",
|
||||||
|
"manager":{"name":"Umbrella Manager"},
|
||||||
|
"referee":{
|
||||||
|
"name":"Referee Org",
|
||||||
|
"manager":{"name":"Referee Manager"}}}
|
||||||
|
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
get "/organizations?select=name,manager(name),auditees:organizations!auditor(name,manager(name),refereeds:organizations!referee(name,manager(name)))&id=eq.2" `shouldRespondWith`
|
||||||
|
[json|[{
|
||||||
|
"name":"Auditor Org",
|
||||||
|
"manager":{"name":"Auditor Manager"},
|
||||||
|
"auditees":[
|
||||||
|
{"name":"Acme",
|
||||||
|
"manager":{"name":"Acme Manager"},
|
||||||
|
"refereeds":[
|
||||||
|
{"name":"Cyberdyne",
|
||||||
|
"manager":{"name":"Cyberdyne Manager"}},
|
||||||
|
{"name":"Oscorp",
|
||||||
|
"manager":{"name":"Oscorp Manager"}}]},
|
||||||
|
{"name":"Umbrella",
|
||||||
|
"manager":{"name":"Umbrella Manager"},
|
||||||
|
"refereeds":[]}]
|
||||||
|
}]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
-- TODO Remove in next major version
|
||||||
|
describe "old dot '.' symbol, deprecated" $
|
||||||
|
it "still works" $ do
|
||||||
|
get "/clients?id=eq.1&select=id,projects:projects.client_id(id,tasks(id))" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/tasks?select=id,users:users.users_tasks(id)" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
@@ -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] }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
module Feature.HtmlRawOutputSpec where
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec hiding (pendingWith)
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Text.Heredoc
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
|
import SpecHelper (acceptHdrs)
|
||||||
|
|
||||||
|
spec :: SpecWith ((), Application)
|
||||||
|
spec = describe "When raw-media-types is set to \"text/html\"" $
|
||||||
|
it "can get raw output with Accept: text/html" $
|
||||||
|
request methodGet "/rpc/welcome.html" (acceptHdrs "text/html") ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|
|
||||||
|
|<html>
|
||||||
|
| <head>
|
||||||
|
| <title>PostgREST</title>
|
||||||
|
| </head>
|
||||||
|
| <body>
|
||||||
|
| <h1>Welcome to PostgREST</h1>
|
||||||
|
| </body>
|
||||||
|
|</html>
|
||||||
|
|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "text/html; charset=utf-8"]
|
||||||
|
}
|
||||||
@@ -1,28 +1,26 @@
|
|||||||
module Feature.InsertSpec where
|
module Feature.InsertSpec where
|
||||||
|
|
||||||
import Test.Hspec hiding (pendingWith)
|
import qualified Data.Aeson as JSON
|
||||||
|
|
||||||
|
import Data.List (lookup)
|
||||||
|
import Data.Maybe (fromJust)
|
||||||
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
|
||||||
|
import Test.Hspec hiding (pendingWith)
|
||||||
|
import Test.Hspec.Wai.Matcher (bodyEquals)
|
||||||
|
import TestTypes (CompoundPK (..), IncPK (..))
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Test.Hspec.Wai.Matcher (bodyEquals)
|
import Text.Heredoc
|
||||||
import Network.Wai.Test (SResponse(simpleBody,simpleHeaders,simpleStatus))
|
|
||||||
|
|
||||||
|
import PostgREST.Types (PgVersion, pgVersion112)
|
||||||
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
|
||||||
import qualified Data.Aeson as JSON
|
spec :: PgVersion -> SpecWith ((), Application)
|
||||||
import Data.List (lookup)
|
spec actualPgVersion = do
|
||||||
import Data.Maybe (fromJust)
|
|
||||||
import Text.Heredoc
|
|
||||||
import Network.HTTP.Types.Header
|
|
||||||
import Network.HTTP.Types
|
|
||||||
import Control.Monad (replicateM_, void)
|
|
||||||
|
|
||||||
import TestTypes(IncPK(..), CompoundPK(..))
|
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
|
||||||
|
|
||||||
spec :: SpecWith Application
|
|
||||||
spec = do
|
|
||||||
describe "Posting new record" $ do
|
describe "Posting new record" $ do
|
||||||
context "disparate json types" $ do
|
context "disparate json types" $ do
|
||||||
it "accepts disparate json types" $ do
|
it "accepts disparate json types" $ do
|
||||||
@@ -49,11 +47,40 @@ spec = do
|
|||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "ignores &select when return not set or using return=minimal" $ do
|
||||||
|
request methodPost "/menagerie?select=integer,varchar" []
|
||||||
|
[json| [{
|
||||||
|
"integer": 15, "double": 3.14159, "varchar": "testing!"
|
||||||
|
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
||||||
|
, "enum": "foo"
|
||||||
|
}] |] `shouldRespondWith` ""
|
||||||
|
{ matchStatus = 201 }
|
||||||
|
request methodPost "/menagerie?select=integer,varchar" [("Prefer", "return=minimal")]
|
||||||
|
[json| [{
|
||||||
|
"integer": 16, "double": 3.14159, "varchar": "testing!"
|
||||||
|
, "boolean": false, "date": "1900-01-01", "money": "$3.99"
|
||||||
|
, "enum": "foo"
|
||||||
|
}] |] `shouldRespondWith` ""
|
||||||
|
{ matchStatus = 201 }
|
||||||
|
|
||||||
context "non uniform json array" $ do
|
context "non uniform json array" $ do
|
||||||
it "rejects json array that isn't exclusivily composed of objects" $
|
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` 400
|
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" $
|
it "rejects json array that has objects with different keys" $
|
||||||
post "/articles" [json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |] `shouldRespondWith` 400
|
post "/articles"
|
||||||
|
[json| [{"id": 100, "body": "xxxxx"}, {"id": 111, "body": "xxxx", "owner": "me"}] |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| {"message":"All object keys must match"} |]
|
||||||
|
{ matchStatus = 400
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
context "requesting full representation" $ do
|
context "requesting full representation" $ do
|
||||||
it "includes related data after insert" $
|
it "includes related data after insert" $
|
||||||
@@ -103,11 +130,13 @@ spec = do
|
|||||||
incNullableStr record `shouldBe` Nothing
|
incNullableStr record `shouldBe` Nothing
|
||||||
|
|
||||||
context "into a table with simple pk" $
|
context "into a table with simple pk" $
|
||||||
it "fails with 400 and error" $ do
|
it "fails with 400 and error" $
|
||||||
p <- post "/simple_pk" [json| { "extra":"foo"} |]
|
post "/simple_pk" [json| { "extra":"foo"} |]
|
||||||
liftIO $ do
|
`shouldRespondWith`
|
||||||
simpleStatus p `shouldBe` badRequest400
|
[json|{"hint":null,"details":"Failing row contains (null, foo).","code":"23502","message":"null value in column \"k\" violates not-null constraint"}|]
|
||||||
isErrorFormat (simpleBody p) `shouldBe` True
|
{ matchStatus = 400
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
context "into a table with no pk" $ do
|
context "into a table with no pk" $ do
|
||||||
it "succeeds with 201 and a link including all fields" $ do
|
it "succeeds with 201 and a link including all fields" $ do
|
||||||
@@ -134,15 +163,6 @@ spec = do
|
|||||||
simpleBody p `shouldBe` [json| [] |]
|
simpleBody p `shouldBe` [json| [] |]
|
||||||
simpleStatus p `shouldBe` created201
|
simpleStatus p `shouldBe` created201
|
||||||
|
|
||||||
it "can insert in tables with no select privileges" $ do
|
|
||||||
p <- request methodPost "/insertonly"
|
|
||||||
[("Prefer", "return=minimal")]
|
|
||||||
[json| { "v":"some value" } |]
|
|
||||||
liftIO $ do
|
|
||||||
simpleBody p `shouldBe` ""
|
|
||||||
simpleStatus p `shouldBe` created201
|
|
||||||
|
|
||||||
|
|
||||||
it "can post nulls" $ do
|
it "can post nulls" $ do
|
||||||
p <- request methodPost "/no_pk"
|
p <- request methodPost "/no_pk"
|
||||||
[("Prefer", "return=representation")]
|
[("Prefer", "return=representation")]
|
||||||
@@ -181,11 +201,22 @@ spec = do
|
|||||||
lookup hLocation (simpleHeaders p) `shouldBe` Nothing
|
lookup hLocation (simpleHeaders p) `shouldBe` Nothing
|
||||||
|
|
||||||
context "with invalid json payload" $
|
context "with invalid json payload" $
|
||||||
it "fails with 400 and error" $ do
|
it "fails with 400 and error" $
|
||||||
p <- post "/simple_pk" "}{ x = 2"
|
post "/simple_pk" "}{ x = 2"
|
||||||
liftIO $ do
|
`shouldRespondWith`
|
||||||
simpleStatus p `shouldBe` badRequest400
|
[json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]
|
||||||
isErrorFormat (simpleBody p) `shouldBe` True
|
{ matchStatus = 400
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "with no payload" $
|
||||||
|
it "fails with 400 and error" $
|
||||||
|
post "/simple_pk" ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"Error in $: not enough input"}|]
|
||||||
|
{ matchStatus = 400
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
context "with valid json payload" $
|
context "with valid json payload" $
|
||||||
it "succeeds and returns 201 created" $
|
it "succeeds and returns 201 created" $
|
||||||
@@ -193,7 +224,12 @@ spec = do
|
|||||||
|
|
||||||
context "attempting to insert a row with the same primary key" $
|
context "attempting to insert a row with the same primary key" $
|
||||||
it "fails returning a 409 Conflict" $
|
it "fails returning a 409 Conflict" $
|
||||||
post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |] `shouldRespondWith` 409
|
post "/simple_pk" [json| { "k":"k1", "extra":"e1" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|{"hint":null,"details":"Key (k)=(k1) already exists.","code":"23505","message":"duplicate key value violates unique constraint \"contacts_pkey\""}|]
|
||||||
|
{ matchStatus = 409
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
context "attempting to insert a row with conflicting unique constraint" $
|
context "attempting to insert a row with conflicting unique constraint" $
|
||||||
it "fails returning a 409 Conflict" $
|
it "fails returning a 409 Conflict" $
|
||||||
@@ -222,35 +258,85 @@ spec = do
|
|||||||
, matchHeaders = ["Location" <:> location]
|
, matchHeaders = ["Location" <:> location]
|
||||||
}
|
}
|
||||||
|
|
||||||
context "empty object" $
|
context "empty objects" $ do
|
||||||
it "successfully populates table with all-default columns" $
|
it "successfully inserts a row with all-default columns" $ do
|
||||||
post "/items" "{}" `shouldRespondWith` ""
|
post "/items" "{}" `shouldRespondWith` ""
|
||||||
{ matchStatus = 201
|
{ matchStatus = 201
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
context "table with limited privileges" $ do
|
post "/items" "[{}]" `shouldRespondWith` ""
|
||||||
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
|
{ matchStatus = 201
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
it "fails if more columns are selected" $
|
|
||||||
request methodPost "/limited_article_stars?select=article_id,user_id,created_at" [("Prefer", "return=representation")]
|
it "successfully inserts two rows with all-default columns" $
|
||||||
[json| {"article_id": 2, "user_id": 2} |] `shouldRespondWith`
|
post "/items" "[{}, {}]" `shouldRespondWith` ""
|
||||||
[str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
{ matchStatus = 201
|
||||||
{ matchStatus = 401
|
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
it "fails if select is not specified" $
|
|
||||||
request methodPost "/limited_article_stars" [("Prefer", "return=representation")]
|
it "successfully inserts a row with all-default columns with prefer=rep" $
|
||||||
[json| {"article_id": 3, "user_id": 1} |] `shouldRespondWith` [str|{"hint":null,"details":null,"code":"42501","message":"permission denied for relation limited_article_stars"}|]
|
request methodPost "/items" [("Prefer", "return=representation")] "{}"
|
||||||
{ matchStatus = 401
|
`shouldRespondWith` [json|[{ id: 20 }]|]
|
||||||
|
{ matchStatus = 201,
|
||||||
|
matchHeaders = []
|
||||||
|
}
|
||||||
|
|
||||||
|
it "successfully inserts a row with all-default columns with prefer=rep and &select=" $
|
||||||
|
request methodPost "/items?select=id" [("Prefer", "return=representation")] "{}"
|
||||||
|
`shouldRespondWith` [json|[{ id: 21 }]|]
|
||||||
|
{ matchStatus = 201,
|
||||||
|
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 = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
describe "CSV insert" $ do
|
describe "CSV insert" $ do
|
||||||
|
|
||||||
context "disparate csv types" $
|
context "disparate csv types" $
|
||||||
it "succeeds with multipart response" $ do
|
it "succeeds with multipart response" $ do
|
||||||
pendingWith "Decide on what to do with CSV insert"
|
pendingWith "Decide on what to do with CSV insert"
|
||||||
@@ -297,11 +383,13 @@ spec = do
|
|||||||
}
|
}
|
||||||
|
|
||||||
context "with wrong number of columns" $
|
context "with wrong number of columns" $
|
||||||
it "fails for too few" $ do
|
it "fails for too few" $
|
||||||
p <- request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
|
request methodPost "/no_pk" [("Content-Type", "text/csv")] "a,b\nfoo,bar\nbaz"
|
||||||
liftIO $ do
|
`shouldRespondWith`
|
||||||
simpleStatus p `shouldBe` badRequest400
|
[json|{"message":"All lines must have same number of fields"}|]
|
||||||
isErrorFormat (simpleBody p) `shouldBe` True
|
{ matchStatus = 400
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
context "with unicode values" $
|
context "with unicode values" $
|
||||||
it "succeeds and returns usable location header" $ do
|
it "succeeds and returns usable location header" $ do
|
||||||
@@ -317,22 +405,38 @@ spec = do
|
|||||||
r <- get location
|
r <- get location
|
||||||
liftIO $ simpleBody r `shouldBe` "["<>payload<>"]"
|
liftIO $ simpleBody r `shouldBe` "["<>payload<>"]"
|
||||||
|
|
||||||
|
|
||||||
describe "Patching record" $ do
|
describe "Patching record" $ do
|
||||||
|
|
||||||
context "to unknown uri" $
|
context "to unknown uri" $
|
||||||
it "gives a 404" $
|
it "indicates no table found by returning 404" $
|
||||||
request methodPatch "/fake" []
|
request methodPatch "/fake" []
|
||||||
[json| { "real": false } |]
|
[json| { "real": false } |]
|
||||||
`shouldRespondWith` 404
|
`shouldRespondWith` 404
|
||||||
|
|
||||||
context "on an empty table" $
|
context "on an empty table" $
|
||||||
it "indicates no records found to update" $
|
it "indicates no records found to update by returning 404" $
|
||||||
request methodPatch "/empty_table" []
|
request methodPatch "/empty_table" []
|
||||||
[json| { "extra":20 } |]
|
[json| { "extra":20 } |]
|
||||||
`shouldRespondWith` ""
|
`shouldRespondWith` ""
|
||||||
{ matchStatus = 204,
|
{ matchStatus = 404,
|
||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
matchHeaders = []
|
||||||
|
}
|
||||||
|
|
||||||
|
context "with invalid json payload" $
|
||||||
|
it "fails with 400 and error" $
|
||||||
|
request methodPatch "/simple_pk" [] "}{ x = 2"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'"}|]
|
||||||
|
{ matchStatus = 400,
|
||||||
|
matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "with no payload" $
|
||||||
|
it "fails with 400 and error" $
|
||||||
|
request methodPatch "/items" [] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"Error in $: not enough input"}|]
|
||||||
|
{ matchStatus = 400,
|
||||||
|
matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
|
|
||||||
context "in a nonempty table" $ do
|
context "in a nonempty table" $ do
|
||||||
@@ -359,10 +463,15 @@ spec = do
|
|||||||
[("Prefer", "return=representation")] [json| { "id":999999 } |]
|
[("Prefer", "return=representation")] [json| { "id":999999 } |]
|
||||||
`shouldRespondWith` "[]"
|
`shouldRespondWith` "[]"
|
||||||
{
|
{
|
||||||
matchStatus = 200,
|
matchStatus = 404,
|
||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
matchHeaders = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "gives a 404 when no rows updated" $
|
||||||
|
request methodPatch "/items?id=eq.99999999" []
|
||||||
|
[json| { "id": 42 } |]
|
||||||
|
`shouldRespondWith` 404
|
||||||
|
|
||||||
it "returns updated object as array when return=rep" $
|
it "returns updated object as array when return=rep" $
|
||||||
request methodPatch "/items?id=eq.2"
|
request methodPatch "/items?id=eq.2"
|
||||||
[("Prefer", "return=representation")] [json| { "id":2 } |]
|
[("Prefer", "return=representation")] [json| { "id":2 } |]
|
||||||
@@ -390,57 +499,144 @@ spec = do
|
|||||||
[json| [{ a: "keepme", b: null }] |]
|
[json| [{ a: "keepme", b: null }] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can update based on a computed column" $
|
context "filtering by a computed column" $ do
|
||||||
request methodPatch
|
it "is successful" $
|
||||||
"/items?always_true=eq.false"
|
request methodPatch
|
||||||
[("Prefer", "return=representation")]
|
"/items?is_first=eq.true"
|
||||||
[json| { id: 100 } |]
|
[("Prefer", "return=representation")]
|
||||||
`shouldRespondWith` "[]"
|
[json| { id: 100 } |]
|
||||||
{ matchStatus = 200,
|
`shouldRespondWith` [json| [{ id: 100 }] |]
|
||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
{ matchStatus = 200,
|
||||||
}
|
matchHeaders = [matchContentTypeJson, "Content-Range" <:> "0-0/*"]
|
||||||
|
}
|
||||||
|
|
||||||
it "can provide a representation" $ do
|
it "indicates no records updated by returning 404" $
|
||||||
_ <- post "/items"
|
request methodPatch
|
||||||
[json| { id: 1 } |]
|
"/items?always_true=eq.false"
|
||||||
request methodPatch
|
[("Prefer", "return=representation")]
|
||||||
"/items?id=eq.1"
|
[json| { id: 100 } |]
|
||||||
[("Prefer", "return=representation")]
|
`shouldRespondWith` "[]"
|
||||||
[json| { id: 99 } |]
|
{ matchStatus = 404,
|
||||||
`shouldRespondWith` [json| [{id:99}] |]
|
matchHeaders = []
|
||||||
{ 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
|
context "with representation requested" $ do
|
||||||
request methodPatch "/items" [] [json| {} |]
|
it "can provide a representation" $ do
|
||||||
|
_ <- post "/items"
|
||||||
|
[json| { id: 1 } |]
|
||||||
|
request methodPatch
|
||||||
|
"/items?id=eq.1"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json| { id: 99 } |]
|
||||||
|
`shouldRespondWith` [json| [{id:99}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
-- put value back for other tests
|
||||||
|
void $ request methodPatch "/items?id=eq.99" [] [json| { "id":1 } |]
|
||||||
|
|
||||||
|
it "can return computed columns" $
|
||||||
|
request methodPatch
|
||||||
|
"/items?id=eq.1&select=id,always_true"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json| { id: 1 } |]
|
||||||
|
`shouldRespondWith` [json| [{ id: 1, always_true: true }] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can select overloaded computed columns" $ do
|
||||||
|
request methodPatch
|
||||||
|
"/items?id=eq.1&select=id,computed_overload"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json| { id: 1 } |]
|
||||||
|
`shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
request methodPatch
|
||||||
|
"/items2?id=eq.1&select=id,computed_overload"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json| { id: 1 } |]
|
||||||
|
`shouldRespondWith` [json| [{ id: 1, computed_overload: true }] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "ignores ?select= when return not set or return=minimal" $ do
|
||||||
|
request methodPatch "/items?id=eq.1&select=id" [] [json| { id:1 } |]
|
||||||
`shouldRespondWith` ""
|
`shouldRespondWith` ""
|
||||||
{
|
{
|
||||||
matchStatus = 204,
|
matchStatus = 204,
|
||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
request methodPatch "/items?id=eq.1&select=id" [("Prefer", "return=minimal")] [json| { id:1 } |]
|
||||||
request methodPatch "/items" [] [json| [] |]
|
|
||||||
`shouldRespondWith` ""
|
`shouldRespondWith` ""
|
||||||
{
|
{
|
||||||
matchStatus = 204,
|
matchStatus = 204,
|
||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
get "/items" `shouldRespondWith`
|
context "when patching with an empty body" $ do
|
||||||
[json|[{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15},{id:16},{"id":2},{"id":1}]|]
|
it "makes no updates and returns 204 without return= and without ?select=" $ do
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
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" $ do
|
request methodPatch "/items" [] [json| [] |]
|
||||||
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
|
`shouldRespondWith` ""
|
||||||
`shouldRespondWith` "[]"
|
{
|
||||||
{
|
matchStatus = 204,
|
||||||
matchStatus = 200,
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
matchHeaders = ["Content-Range" <:> "*/*"]
|
}
|
||||||
}
|
|
||||||
get "/items" `shouldRespondWith`
|
request methodPatch "/items" [] [json| [{}] |]
|
||||||
[json| [{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15},{id:16},{"id":2},{"id":1}] |]
|
`shouldRespondWith` ""
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{
|
||||||
|
matchStatus = 204,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "makes no updates and returns 204 without return= and with ?select=" $ do
|
||||||
|
request methodPatch "/items?select=id" [] [json| {} |]
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{
|
||||||
|
matchStatus = 204,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
request methodPatch "/items?select=id" [] [json| [] |]
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{
|
||||||
|
matchStatus = 204,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
request methodPatch "/items?select=id" [] [json| [{}] |]
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{
|
||||||
|
matchStatus = 204,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "makes no updates and returns 200 with return=rep and without ?select=" $
|
||||||
|
request methodPatch "/items" [("Prefer", "return=representation")] [json| {} |]
|
||||||
|
`shouldRespondWith` "[]"
|
||||||
|
{
|
||||||
|
matchStatus = 200,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "makes no updates and returns 200 with return=rep and with ?select=" $
|
||||||
|
request methodPatch "/items?select=id" [("Prefer", "return=representation")] [json| {} |]
|
||||||
|
`shouldRespondWith` "[]"
|
||||||
|
{
|
||||||
|
matchStatus = 200,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "makes no updates and returns 200 with return=rep and with ?select= for overloaded computed columns" $
|
||||||
|
request methodPatch "/items?select=id,computed_overload" [("Prefer", "return=representation")] [json| {} |]
|
||||||
|
`shouldRespondWith` "[]"
|
||||||
|
{
|
||||||
|
matchStatus = 200,
|
||||||
|
matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
|
}
|
||||||
|
|
||||||
context "with unicode values" $
|
context "with unicode values" $
|
||||||
it "succeeds and returns values intact" $ do
|
it "succeeds and returns values intact" $ do
|
||||||
@@ -453,6 +649,18 @@ spec = do
|
|||||||
simpleBody p `shouldBe` "["<>payload<>"]"
|
simpleBody p `shouldBe` "["<>payload<>"]"
|
||||||
simpleStatus p `shouldBe` ok200
|
simpleStatus p `shouldBe` ok200
|
||||||
|
|
||||||
|
context "PATCH with ?columns parameter" $ do
|
||||||
|
it "ignores json keys not included in ?columns" $
|
||||||
|
request methodPatch "/articles?id=eq.200&columns=body" [("Prefer", "return=representation")]
|
||||||
|
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith`
|
||||||
|
[json|[{"id": 200, "body": "Some real content", "owner": "postgrest_test_anonymous"}]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [] }
|
||||||
|
|
||||||
|
it "ignores json keys and gives 404 if no record updated" $
|
||||||
|
request methodPatch "/articles?id=eq.2001&columns=body" [("Prefer", "return=representation")]
|
||||||
|
[json| {"body": "Some real content", "smth": "here", "other": "stuff", "fake_id": 13} |] `shouldRespondWith` 404
|
||||||
|
|
||||||
describe "Row level permission" $
|
describe "Row level permission" $
|
||||||
it "set user_id when inserting rows" $ do
|
it "set user_id when inserting rows" $ do
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.B-lReuGNDwAlU1GOC476MlO0vAt9JNoHIlxg2vwMaO0"
|
||||||
@@ -473,3 +681,129 @@ spec = do
|
|||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleBody p2 `shouldBe` [str|[{"owner":"jroe","secret":"lolcat"}]|]
|
simpleBody p2 `shouldBe` [str|[{"owner":"jroe","secret":"lolcat"}]|]
|
||||||
simpleStatus p2 `shouldBe` created201
|
simpleStatus p2 `shouldBe` created201
|
||||||
|
|
||||||
|
context "tables with self reference foreign keys" $ do
|
||||||
|
it "embeds parent after insert" $
|
||||||
|
request methodPost "/web_content?select=id,name,parent_content:p_web_id(name)"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json|{"id":6, "name":"wot", "p_web_id":4}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":6,"name":"wot","parent_content":{"name":"wut"}}]|]
|
||||||
|
{ matchStatus = 201
|
||||||
|
, matchHeaders = [ matchContentTypeJson , "Location" <:> "/web_content?id=eq.6" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "embeds children after update" $
|
||||||
|
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name)"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json|{"name": "tardis-patched"}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
[ { "id": 0, "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
|
||||||
|
|]
|
||||||
|
{ matchStatus = 200,
|
||||||
|
matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "embeds parent, children and grandchildren after update" $
|
||||||
|
request methodPatch "/web_content?id=eq.0&select=id,name,web_content(name,web_content(name)),parent_content:p_web_id(name)"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json|{"name": "tardis-patched-2"}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"name": "tardis-patched-2",
|
||||||
|
"parent_content": { "name": "wat" },
|
||||||
|
"web_content": [
|
||||||
|
{ "name": "fezz", "web_content": [ { "name": "wut" } ] },
|
||||||
|
{ "name": "foo", "web_content": [] },
|
||||||
|
{ "name": "bar", "web_content": [] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] |]
|
||||||
|
{ matchStatus = 200,
|
||||||
|
matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "embeds children after update without explicitly including the id in the ?select" $
|
||||||
|
request methodPatch "/web_content?id=eq.0&select=name,web_content(name)"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json|{"name": "tardis-patched"}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
[ { "name": "tardis-patched", "web_content": [ { "name": "fezz" }, { "name": "foo" }, { "name": "bar" } ]} ]
|
||||||
|
|]
|
||||||
|
{ matchStatus = 200,
|
||||||
|
matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "embeds an M2M relationship plus parent after update" $
|
||||||
|
request methodPatch "/users?id=eq.1&select=name,tasks(name,project:projects(name))"
|
||||||
|
[("Prefer", "return=representation")]
|
||||||
|
[json|{"name": "Kevin Malone"}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{
|
||||||
|
"name": "Kevin Malone",
|
||||||
|
"tasks": [
|
||||||
|
{ "name": "Design w7", "project": { "name": "Windows 7" } },
|
||||||
|
{ "name": "Code w7", "project": { "name": "Windows 7" } },
|
||||||
|
{ "name": "Design w10", "project": { "name": "Windows 10" } },
|
||||||
|
{ "name": "Code w10", "project": { "name": "Windows 10" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]|]
|
||||||
|
{ matchStatus = 200,
|
||||||
|
matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "table with limited privileges" $ do
|
||||||
|
it "succeeds inserting 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 inserting 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 inserting 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 = []
|
||||||
|
}
|
||||||
|
|
||||||
|
it "can insert in a table with no select and return=minimal" $ do
|
||||||
|
p <- request methodPost "/insertonly"
|
||||||
|
[("Prefer", "return=minimal")]
|
||||||
|
[json| { "v":"some value" } |]
|
||||||
|
liftIO $ do
|
||||||
|
simpleBody p `shouldBe` ""
|
||||||
|
simpleStatus p `shouldBe` created201
|
||||||
|
|
||||||
|
it "succeeds updating row and gives a 204 when using return=minimal" $
|
||||||
|
request methodPatch "/app_users?id=eq.1" [("Prefer", "return=minimal")]
|
||||||
|
[json| { "password": "passxyz" } |]
|
||||||
|
`shouldRespondWith` 204
|
||||||
|
|
||||||
|
it "can update without return=minimal and no explicit select" $
|
||||||
|
request methodPatch "/app_users?id=eq.1" []
|
||||||
|
[json| { "password": "passabc" } |]
|
||||||
|
`shouldRespondWith` 204
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
module Feature.JsonOperatorSpec where
|
module Feature.JsonOperatorSpec where
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Network.HTTP.Types
|
|
||||||
|
|
||||||
|
import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
|
||||||
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
spec :: PgVersion -> SpecWith ((), Application)
|
||||||
|
spec actualPgVersion = describe "json and jsonb operators" $ do
|
||||||
spec :: SpecWith Application
|
|
||||||
spec = describe "json and jsonb operators" $ do
|
|
||||||
context "Shaping response with select parameter" $ do
|
context "Shaping response with select parameter" $ do
|
||||||
it "obtains a json subfield one level with casting" $
|
it "obtains a json subfield one level with casting" $
|
||||||
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
|
get "/complex_items?id=eq.1&select=settings->>foo::json" `shouldRespondWith`
|
||||||
@@ -25,7 +26,12 @@ spec = describe "json and jsonb operators" $ do
|
|||||||
|
|
||||||
it "fails on bad casting (data of the wrong format)" $
|
it "fails on bad casting (data of the wrong format)" $
|
||||||
get "/complex_items?select=settings->foo->>bar::integer"
|
get "/complex_items?select=settings->foo->>bar::integer"
|
||||||
`shouldRespondWith` [json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
`shouldRespondWith` (
|
||||||
|
if actualPgVersion >= pgVersion121 then
|
||||||
|
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"baz\""} |]
|
||||||
|
else
|
||||||
|
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"baz\""} |]
|
||||||
|
)
|
||||||
{ matchStatus = 400 , matchHeaders = [] }
|
{ matchStatus = 400 , matchHeaders = [] }
|
||||||
|
|
||||||
it "obtains a json subfield two levels (string)" $
|
it "obtains a json subfield two levels (string)" $
|
||||||
@@ -52,14 +58,28 @@ spec = describe "json and jsonb operators" $ do
|
|||||||
-- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate
|
-- 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
|
it "fails when a double arrow ->> is followed with a single arrow ->" $ do
|
||||||
get "/json_arr?select=data->>c->1"
|
get "/json_arr?select=data->>c->1"
|
||||||
`shouldRespondWith` [json|
|
`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.",
|
{"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"} |]
|
"details":null,"code":"42883","message":"operator does not exist: text -> integer"} |]
|
||||||
|
)
|
||||||
{ matchStatus = 404 , matchHeaders = [] }
|
{ matchStatus = 404 , matchHeaders = [] }
|
||||||
get "/json_arr?select=data->>c->b"
|
get "/json_arr?select=data->>c->b"
|
||||||
`shouldRespondWith` [json|
|
`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.",
|
{"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"} |]
|
"details":null,"code":"42883","message":"operator does not exist: text -> unknown"} |]
|
||||||
|
)
|
||||||
{ matchStatus = 404 , matchHeaders = [] }
|
{ matchStatus = 404 , matchHeaders = [] }
|
||||||
|
|
||||||
context "with array index" $ do
|
context "with array index" $ do
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
module Feature.MultipleSchemaSpec where
|
||||||
|
|
||||||
|
import Control.Lens ((^?))
|
||||||
|
import Data.Aeson.Lens
|
||||||
|
import Data.Aeson.QQ
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (simpleHeaders), simpleBody)
|
||||||
|
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
import SpecHelper
|
||||||
|
|
||||||
|
import PostgREST.Types (PgVersion, pgVersion96)
|
||||||
|
|
||||||
|
spec :: PgVersion -> SpecWith ((), Application)
|
||||||
|
spec actualPgVersion =
|
||||||
|
describe "multiple schemas in single instance" $ do
|
||||||
|
context "Reading tables on different schemas" $ do
|
||||||
|
it "succeeds in reading table from default schema v1 if no schema is selected via header" $
|
||||||
|
request methodGet "/parents" [] "" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"name":"parent v1-1"},
|
||||||
|
{"id":2,"name":"parent v1-2"}
|
||||||
|
]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in reading table from default schema v1 after explicitly passing it in the header" $
|
||||||
|
request methodGet "/parents" [("Accept-Profile", "v1")] "" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":1,"name":"parent v1-1"},
|
||||||
|
{"id":2,"name":"parent v1-2"}
|
||||||
|
]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in reading table from schema v2" $
|
||||||
|
request methodGet "/parents" [("Accept-Profile", "v2")] "" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":3,"name":"parent v2-3"},
|
||||||
|
{"id":4,"name":"parent v2-4"}
|
||||||
|
]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in reading another_table from schema v2" $
|
||||||
|
request methodGet "/another_table" [("Accept-Profile", "v2")] "" `shouldRespondWith`
|
||||||
|
[json|[
|
||||||
|
{"id":5,"another_value":"value 5"},
|
||||||
|
{"id":6,"another_value":"value 6"}
|
||||||
|
]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "doesn't find another_table in schema v1" $
|
||||||
|
request methodGet "/another_table" [("Accept-Profile", "v1")] "" `shouldRespondWith` 404
|
||||||
|
|
||||||
|
it "fails trying to read table from unkown schema" $
|
||||||
|
request methodGet "/parents" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
|
||||||
|
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||||
|
{
|
||||||
|
matchStatus = 406
|
||||||
|
}
|
||||||
|
|
||||||
|
context "Inserting tables on different schemas" $ do
|
||||||
|
it "succeeds inserting on default schema and returning it" $
|
||||||
|
request methodPost "/children" [("Prefer", "return=representation")] [json|{"name": "child v1-1", "parent_id": 1}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":1, "name": "child v1-1", "parent_id": 1}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 201
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds inserting on the v1 schema and returning its parent" $
|
||||||
|
request methodPost "/children?select=id,parent(*)" [("Prefer", "return=representation"), ("Content-Profile", "v1")]
|
||||||
|
[json|{"name": "child v1-2", "parent_id": 2}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":2, "parent": {"id": 2, "name": "parent v1-2"}}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 201
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds inserting on the v2 schema and returning its parent" $
|
||||||
|
request methodPost "/children?select=id,parent(*)" [("Prefer", "return=representation"), ("Content-Profile", "v2")]
|
||||||
|
[json|{"name": "child v2-3", "parent_id": 3}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":1, "parent": {"id": 3, "name": "parent v2-3"}}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 201
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "fails when inserting on an unknown schema" $
|
||||||
|
request methodPost "/children" [("Content-Profile", "unknown")]
|
||||||
|
[json|{"name": "child 4", "parent_id": 4}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||||
|
{
|
||||||
|
matchStatus = 406
|
||||||
|
}
|
||||||
|
|
||||||
|
context "calling procs on different schemas" $ do
|
||||||
|
it "succeeds in calling the default schema proc" $
|
||||||
|
request methodGet "/rpc/get_parents_below?id=6" [] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"id":1,"name":"parent v1-1"}, {"id":2,"name":"parent v1-2"}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in calling the v1 schema proc and embedding" $
|
||||||
|
request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v1")] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"id":1,"name":"parent v1-1","children":[{"id":1,"name":"child v1-1"}]},
|
||||||
|
{"id":2,"name":"parent v1-2","children":[{"id":2,"name":"child v1-2"}]}] |]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in calling the v2 schema proc and embedding" $
|
||||||
|
request methodGet "/rpc/get_parents_below?id=6&select=id,name,children(id,name)" [("Accept-Profile", "v2")] ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"id":3,"name":"parent v2-3","children":[{"id":1,"name":"child v2-3"}]},
|
||||||
|
{"id":4,"name":"parent v2-4","children":[]}] |]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in calling the v2 schema proc with POST by using Content-Profile" $
|
||||||
|
request methodPost "/rpc/get_parents_below?select=id,name" [("Content-Profile", "v2")]
|
||||||
|
[json|{"id": "6"}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"id":3,"name":"parent v2-3"},
|
||||||
|
{"id":4,"name":"parent v2-4"}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "Modifying tables on different schemas" $ do
|
||||||
|
it "succeeds in patching on the v1 schema and returning its parent" $
|
||||||
|
request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v1"), ("Prefer", "return=representation")]
|
||||||
|
[json|{"name": "child v1-1 updated"}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"name":"child v1-1 updated", "parent": {"name": "parent v1-1"}}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v1"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds in patching on the v2 schema and returning its parent" $
|
||||||
|
request methodPatch "/children?select=name,parent(name)&id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
|
||||||
|
[json|{"name": "child v2-1 updated"}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{"name":"child v2-1 updated", "parent": {"name": "parent v2-3"}}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds on deleting on the v2 schema" $ do
|
||||||
|
request methodDelete "/children?id=eq.1" [("Content-Profile", "v2"), ("Prefer", "return=representation")] ""
|
||||||
|
`shouldRespondWith` [json|[{"id": 1, "name": "child v2-1 updated", "parent_id": 3}]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
request methodGet "/children?id=eq.1" [("Accept-Profile", "v2")] ""
|
||||||
|
`shouldRespondWith` "[]"
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
when (actualPgVersion >= pgVersion96) $
|
||||||
|
it "succeeds on PUT on the v2 schema" $
|
||||||
|
request methodPut "/children?id=eq.111" [("Content-Profile", "v2"), ("Prefer", "return=representation")]
|
||||||
|
[json| [ { "id": 111, "name": "child v2-111", "parent_id": null } ]|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|[{ "id": 111, "name": "child v2-111", "parent_id": null }]|]
|
||||||
|
{
|
||||||
|
matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson, "Content-Profile" <:> "v2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "OpenAPI output" $ do
|
||||||
|
it "succeeds in reading table definition from default schema v1 if no schema is selected via header" $ do
|
||||||
|
req <- request methodGet "/" [] ""
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders req `shouldSatisfy` matchHeader "Content-Profile" "v1"
|
||||||
|
|
||||||
|
let def = simpleBody req ^? key "definitions" . key "parents"
|
||||||
|
|
||||||
|
def `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"type" : "object",
|
||||||
|
"properties" : {
|
||||||
|
"id" : {
|
||||||
|
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||||
|
"format" : "integer",
|
||||||
|
"type" : "integer"
|
||||||
|
},
|
||||||
|
"name" : {
|
||||||
|
"format" : "text",
|
||||||
|
"type" : "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" : [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "succeeds in reading table definition from default schema v1 after explicitly passing it in the header" $ do
|
||||||
|
r <- request methodGet "/" [("Accept-Profile", "v1")] ""
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v1"
|
||||||
|
|
||||||
|
let def = simpleBody r ^? key "definitions" . key "parents"
|
||||||
|
|
||||||
|
def `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"type" : "object",
|
||||||
|
"properties" : {
|
||||||
|
"id" : {
|
||||||
|
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||||
|
"format" : "integer",
|
||||||
|
"type" : "integer"
|
||||||
|
},
|
||||||
|
"name" : {
|
||||||
|
"format" : "text",
|
||||||
|
"type" : "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" : [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "succeeds in reading table definition from schema v2" $ do
|
||||||
|
r <- request methodGet "/" [("Accept-Profile", "v2")] ""
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
|
||||||
|
|
||||||
|
let def = simpleBody r ^? key "definitions" . key "parents"
|
||||||
|
|
||||||
|
def `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"type" : "object",
|
||||||
|
"properties" : {
|
||||||
|
"id" : {
|
||||||
|
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||||
|
"format" : "integer",
|
||||||
|
"type" : "integer"
|
||||||
|
},
|
||||||
|
"name" : {
|
||||||
|
"format" : "text",
|
||||||
|
"type" : "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" : [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "succeeds in reading another_table definition from schema v2" $ do
|
||||||
|
r <- request methodGet "/" [("Accept-Profile", "v2")] ""
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
simpleHeaders r `shouldSatisfy` matchHeader "Content-Profile" "v2"
|
||||||
|
|
||||||
|
let def = simpleBody r ^? key "definitions" . key "another_table"
|
||||||
|
|
||||||
|
def `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"type" : "object",
|
||||||
|
"properties" : {
|
||||||
|
"id" : {
|
||||||
|
"description" : "Note:\nThis is a Primary Key.<pk/>",
|
||||||
|
"format" : "integer",
|
||||||
|
"type" : "integer"
|
||||||
|
},
|
||||||
|
"another_value" : {
|
||||||
|
"format" : "text",
|
||||||
|
"type" : "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" : [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "doesn't find another_table definition in schema v1" $ do
|
||||||
|
r <- request methodGet "/" [("Accept-Profile", "v1")] ""
|
||||||
|
|
||||||
|
liftIO $ do
|
||||||
|
let def = simpleBody r ^? key "definitions" . key "another_table"
|
||||||
|
def `shouldBe` Nothing
|
||||||
|
|
||||||
|
it "fails trying to read definitions from unkown schema" $
|
||||||
|
request methodGet "/" [("Accept-Profile", "unkown")] "" `shouldRespondWith`
|
||||||
|
[json|{"message":"The schema must be one of the following: v1, v2"}|]
|
||||||
|
{
|
||||||
|
matchStatus = 406
|
||||||
|
}
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
module Feature.NoJwtSpec where
|
module Feature.NoJwtSpec where
|
||||||
|
|
||||||
-- {{{ Imports
|
-- {{{ Imports
|
||||||
import Test.Hspec
|
|
||||||
import Test.Hspec.Wai
|
|
||||||
import Network.HTTP.Types
|
|
||||||
|
|
||||||
import SpecHelper
|
|
||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
import SpecHelper
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith ((), Application)
|
||||||
spec = describe "server started without JWT secret" $ do
|
spec = describe "server started without JWT secret" $ do
|
||||||
|
|
||||||
-- this test will stop working 9999999999s after the UNIX EPOCH
|
-- this test will stop working 9999999999s after the UNIX EPOCH
|
||||||
it "responds with error on attempted auth" $ do
|
it "responds with error on attempted auth" $ do
|
||||||
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
|
||||||
request methodGet "/authors_only" [auth] ""
|
request methodGet "/authors_only" [auth] ""
|
||||||
`shouldRespondWith` 500
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"Server lacks JWT secret"}|]
|
||||||
|
{ matchStatus = 500
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
|
||||||
it "behaves normally when user does not attempt auth" $
|
it "behaves normally when user does not attempt auth" $
|
||||||
request methodGet "/items" [] ""
|
request methodGet "/items" [] ""
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
module Feature.NonexistentSchemaSpec where
|
module Feature.NonexistentSchemaSpec where
|
||||||
|
|
||||||
import Network.Wai (Application)
|
import Network.Wai (Application)
|
||||||
import Protolude hiding (get)
|
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
|
|
||||||
spec :: SpecWith Application
|
import Protolude hiding (get)
|
||||||
|
|
||||||
|
spec :: SpecWith ((), Application)
|
||||||
spec =
|
spec =
|
||||||
describe "Non existent api schema" $ do
|
describe "Non existent api schema" $ do
|
||||||
it "succeeds when requesting root path" $
|
it "succeeds when requesting root path" $
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
module Feature.PgVersion95Spec where
|
module Feature.PgVersion95Spec where
|
||||||
|
|
||||||
import Test.Hspec hiding (pendingWith)
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
spec :: SpecWith ((), Application)
|
||||||
|
|
||||||
spec :: SpecWith Application
|
|
||||||
spec = describe "features supported on PostgreSQL 9.5" $
|
spec = describe "features supported on PostgreSQL 9.5" $
|
||||||
context "json array negative index" $ do
|
context "json array negative index" $ do
|
||||||
it "can select with negative indexes" $ do
|
it "can select with negative indexes" $ do
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
module Feature.PgVersion96Spec where
|
module Feature.PgVersion96Spec where
|
||||||
|
|
||||||
import Test.Hspec hiding (pendingWith)
|
import Network.HTTP.Types
|
||||||
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (simpleHeaders))
|
||||||
|
|
||||||
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
spec :: SpecWith ((), Application)
|
||||||
|
|
||||||
spec :: SpecWith Application
|
|
||||||
spec =
|
spec =
|
||||||
describe "features supported on PostgreSQL 9.6" $ do
|
describe "features supported on PostgreSQL 9.6" $ do
|
||||||
context "GUC headers" $ do
|
context "GUC headers on function calls" $ do
|
||||||
it "succeeds setting the headers" $ do
|
it "succeeds setting the headers" $ do
|
||||||
get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id"
|
get "/rpc/get_projects_and_guc_headers?id=eq.2&select=id"
|
||||||
`shouldRespondWith` [json|[{"id": 2}]|]
|
`shouldRespondWith` [json|[{"id": 2}]|]
|
||||||
@@ -34,10 +36,30 @@ spec =
|
|||||||
"X-Test-2" <:> "key1=val1"]}
|
"X-Test-2" <:> "key1=val1"]}
|
||||||
|
|
||||||
it "fails when setting headers with wrong json structure" $ do
|
it "fails when setting headers with wrong json structure" $ do
|
||||||
get "/rpc/bad_guc_headers_1" `shouldRespondWith` 500
|
get "/rpc/bad_guc_headers_1"
|
||||||
get "/rpc/bad_guc_headers_2" `shouldRespondWith` 500
|
`shouldRespondWith`
|
||||||
get "/rpc/bad_guc_headers_3" `shouldRespondWith` 500
|
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
|
||||||
post "/rpc/bad_guc_headers_1" [json|{}|] `shouldRespondWith` 500
|
{ matchStatus = 500
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
get "/rpc/bad_guc_headers_2"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
|
||||||
|
{ matchStatus = 500
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
get "/rpc/bad_guc_headers_3"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
|
||||||
|
{ matchStatus = 500
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
post "/rpc/bad_guc_headers_1" [json|{}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"response.headers guc must be a JSON array composed of objects with a single key and a string value"}|]
|
||||||
|
{ matchStatus = 500
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
|
||||||
it "can set the same http header twice" $
|
it "can set the same http header twice" $
|
||||||
get "/rpc/set_cookie_twice"
|
get "/rpc/set_cookie_twice"
|
||||||
@@ -47,6 +69,98 @@ spec =
|
|||||||
"Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/",
|
"Set-Cookie" <:> "sessionid=38afes7a8; HttpOnly; Path=/",
|
||||||
"Set-Cookie" <:> "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"]}
|
"Set-Cookie" <:> "id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly"]}
|
||||||
|
|
||||||
|
context "GUC headers on all other methods via pre-request" $ do
|
||||||
|
it "succeeds setting the headers on GET and HEAD" $ do
|
||||||
|
request methodGet "/items?id=eq.1" [("User-Agent", "MSIE 6.0")] mempty
|
||||||
|
`shouldRespondWith` [json|[{"id": 1}]|]
|
||||||
|
{matchHeaders = [
|
||||||
|
matchContentTypeJson,
|
||||||
|
"Cache-Control" <:> "no-cache, no-store, must-revalidate"]}
|
||||||
|
|
||||||
|
request methodHead "/items?id=eq.1" [("User-Agent", "MSIE 7.0")] mempty
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{matchHeaders = ["Cache-Control" <:> "no-cache, no-store, must-revalidate"]}
|
||||||
|
|
||||||
|
request methodHead "/projects" [("Accept", "text/csv")] mempty
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{matchHeaders = ["Content-Disposition" <:> "attachment; filename=projects.csv"]}
|
||||||
|
|
||||||
|
it "succeeds setting the headers on POST" $
|
||||||
|
request methodPost "/items" [] [json|[{"id": 11111}]|]
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 201
|
||||||
|
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds setting the headers on PATCH" $
|
||||||
|
request methodPatch "/items?id=eq.11111" [] [json|[{"id": 11111}]|]
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 204
|
||||||
|
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds setting the headers on PUT" $
|
||||||
|
request methodPut "/items?id=eq.11111" [] [json|[{"id": 11111}]|]
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 204
|
||||||
|
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "succeeds setting the headers on DELETE" $
|
||||||
|
request methodDelete "/items?id=eq.11111" [] mempty
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 204
|
||||||
|
, matchHeaders = ["X-Custom-Header" <:> "mykey=myval"]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "Override provided headers by using GUC headers" $ do
|
||||||
|
it "can override the Content-Type header" $ do
|
||||||
|
request methodHead "/clients?id=eq.1" [] mempty
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "application/geo+json"]
|
||||||
|
}
|
||||||
|
request methodHead "/rpc/getallprojects" [] mempty
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "application/geo+json"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "can override the Location header" $
|
||||||
|
request methodPost "/stuff" [] [json|[{"id": 1, "name": "stuff 1"}]|]
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 201
|
||||||
|
, matchHeaders = ["Location" <:> "/stuff?id=eq.1&overriden=true"]
|
||||||
|
}
|
||||||
|
|
||||||
|
-- On https://github.com/PostgREST/postgrest/issues/1427#issuecomment-595907535
|
||||||
|
-- it was reported that blank headers ` : ` where added and that cause proxies to fail the requests.
|
||||||
|
-- These tests are to ensure no blank headers are added.
|
||||||
|
context "Blank headers bug" $ do
|
||||||
|
it "shouldn't add blank headers on POST" $ do
|
||||||
|
r <- request methodPost "/loc_test" [] [json|{"id": "1", "c": "c1"}|]
|
||||||
|
liftIO $ do
|
||||||
|
let respHeaders = simpleHeaders r
|
||||||
|
respHeaders `shouldSatisfy` noBlankHeader
|
||||||
|
|
||||||
|
it "shouldn't add blank headers on PATCH" $ do
|
||||||
|
r <- request methodPatch "/loc_test?id=eq.1" [] [json|{"c": "c2"}|]
|
||||||
|
liftIO $ do
|
||||||
|
let respHeaders = simpleHeaders r
|
||||||
|
respHeaders `shouldSatisfy` noBlankHeader
|
||||||
|
|
||||||
|
it "shouldn't add blank headers on GET" $ do
|
||||||
|
r <- request methodGet "/loc_test" [] ""
|
||||||
|
liftIO $ do
|
||||||
|
let respHeaders = simpleHeaders r
|
||||||
|
respHeaders `shouldSatisfy` noBlankHeader
|
||||||
|
|
||||||
|
it "shouldn't add blank headers on DELETE" $ do
|
||||||
|
r <- request methodDelete "/loc_test?id=eq.1" [] ""
|
||||||
|
liftIO $ do
|
||||||
|
let respHeaders = simpleHeaders r
|
||||||
|
respHeaders `shouldSatisfy` noBlankHeader
|
||||||
|
|
||||||
context "Use of the phraseto_tsquery function" $ do
|
context "Use of the phraseto_tsquery function" $ do
|
||||||
it "finds matches" $
|
it "finds matches" $
|
||||||
get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith`
|
get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith`
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
module Feature.ProxySpec where
|
module Feature.ProxySpec where
|
||||||
|
|
||||||
import Test.Hspec hiding (pendingWith)
|
import Network.Wai (Application)
|
||||||
|
import Test.Hspec hiding (pendingWith)
|
||||||
|
|
||||||
|
import Protolude
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
|
||||||
import Network.Wai (Application)
|
spec :: SpecWith ((), Application)
|
||||||
|
|
||||||
import Protolude hiding (get)
|
|
||||||
|
|
||||||
spec :: SpecWith Application
|
|
||||||
spec =
|
spec =
|
||||||
describe "GET / with proxy" $
|
describe "GET / with proxy" $
|
||||||
it "returns a valid openapi spec with proxy" $
|
it "returns a valid openapi spec with proxy" $
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
module Feature.QueryLimitedSpec where
|
module Feature.QueryLimitedSpec where
|
||||||
|
|
||||||
import Test.Hspec hiding (pendingWith)
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Network.HTTP.Types
|
|
||||||
import Network.Wai.Test (SResponse(simpleHeaders, simpleStatus))
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
spec :: SpecWith ((), Application)
|
||||||
|
|
||||||
spec :: SpecWith Application
|
|
||||||
spec =
|
spec =
|
||||||
describe "Requesting many items with server limits enabled" $ do
|
describe "Requesting many items with server limits(max-rows) enabled" $ do
|
||||||
it "restricts results" $
|
it "restricts results" $
|
||||||
get "/items"
|
get "/items"
|
||||||
`shouldRespondWith` [json| [{"id":1},{"id":2}] |]
|
`shouldRespondWith` [json| [{"id":1},{"id":2}] |]
|
||||||
@@ -28,16 +29,45 @@ spec =
|
|||||||
matchHeader "Content-Range" "0-0/*"
|
matchHeader "Content-Range" "0-0/*"
|
||||||
simpleStatus r `shouldBe` ok200
|
simpleStatus r `shouldBe` ok200
|
||||||
|
|
||||||
it "limit works on all levels" $
|
it "works on all levels" $
|
||||||
get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
|
get "/users?select=id,tasks(id)&order=id.asc&tasks.order=id.asc"
|
||||||
`shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
|
`shouldRespondWith` [json|[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":5},{"id":6}]}]|]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "limit is not applied to parent embeds" $
|
it "succeeds in getting parent embeds despite the limit, see #647" $
|
||||||
get "/tasks?select=id,project(id)&id=gt.5"
|
get "/tasks?select=id,project:projects(id)&id=gt.5"
|
||||||
`shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
|
`shouldRespondWith` [json|[{"id":6,"project":{"id":3}},{"id":7,"project":{"id":4}}]|]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "can offset the parent embed, being consistent with the other embed types" $
|
||||||
|
get "/tasks?select=id,project:projects(id)&id=gt.5&project.offset=1"
|
||||||
|
`shouldRespondWith` [json|[{"id":6,"project":null}, {"id":7,"project":null}]|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "count=estimated" $ do
|
||||||
|
it "uses the query planner guess when query rows > maxRows" $
|
||||||
|
request methodHead "/getallprojects_view" [("Prefer", "count=estimated")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/2019"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "gives exact count when query rows <= maxRows" $
|
||||||
|
request methodHead "/getallprojects_view?id=lt.3" [("Prefer", "count=estimated")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "only uses the query planner guess if it's indeed greater than the exact count" $
|
||||||
|
request methodHead "/get_projects_above_view" [("Prefer", "count=estimated")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/3"]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
module Feature.QuerySpec where
|
module Feature.QuerySpec where
|
||||||
|
|
||||||
import Test.Hspec hiding (pendingWith)
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (simpleHeaders))
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec hiding (pendingWith)
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Network.HTTP.Types
|
|
||||||
import Network.Wai.Test (SResponse(simpleHeaders))
|
|
||||||
|
|
||||||
import SpecHelper
|
|
||||||
import Text.Heredoc
|
import Text.Heredoc
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import PostgREST.Types (PgVersion, pgVersion112, pgVersion121)
|
||||||
|
import Protolude hiding (get)
|
||||||
|
import SpecHelper
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: PgVersion -> SpecWith ((), Application)
|
||||||
spec = do
|
spec actualPgVersion = do
|
||||||
|
|
||||||
describe "Querying a table with a column called count" $
|
describe "Querying a table with a column called count" $
|
||||||
it "should not confuse count column with pg_catalog.count aggregate" $
|
it "should not confuse count column with pg_catalog.count aggregate" $
|
||||||
@@ -118,6 +120,29 @@ spec = do
|
|||||||
[json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
|
[json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
when (actualPgVersion >= pgVersion112) $ do
|
||||||
|
it "finds matches with websearch_to_tsquery" $
|
||||||
|
get "/tsearch?text_search_vector=wfts.The%20Fat%20Rats" `shouldRespondWith`
|
||||||
|
[json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can use boolean operators(and, or, -) in websearch_to_tsquery" $ do
|
||||||
|
get "/tsearch?text_search_vector=wfts.fun%20and%20possible"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [ {"text_search_vector": "'also':2 'fun':3 'possibl':8"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/tsearch?text_search_vector=wfts.impossible%20or%20possible"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"text_search_vector": "'fun':5 'imposs':9 'kind':3"},
|
||||||
|
{"text_search_vector": "'also':2 'fun':3 'possibl':8"}]
|
||||||
|
|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/tsearch?text_search_vector=wfts.fun%20and%20-possible"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "finds matches with different dictionaries" $ do
|
it "finds matches with different dictionaries" $ do
|
||||||
get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith`
|
get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith`
|
||||||
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
|
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
|
||||||
@@ -126,6 +151,12 @@ spec = do
|
|||||||
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
|
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
when (actualPgVersion >= pgVersion112) $
|
||||||
|
get "/tsearch?text_search_vector=wfts(french).amusant%20impossible"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can be negated with not operator" $ do
|
it "can be negated with not operator" $ do
|
||||||
get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith`
|
get "/tsearch?text_search_vector=not.fts.impossible%7Cfat%7Cfun" `shouldRespondWith`
|
||||||
[json| [
|
[json| [
|
||||||
@@ -144,6 +175,13 @@ spec = do
|
|||||||
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
|
{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"},
|
||||||
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
|
{"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
when (actualPgVersion >= pgVersion112) $
|
||||||
|
get "/tsearch?text_search_vector=not.wfts(english).impossible%20or%20fat%20or%20fun"
|
||||||
|
`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 "matches with computed column" $
|
it "matches with computed column" $
|
||||||
get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
|
get "/items?always_true=eq.true&order=id.asc" `shouldRespondWith`
|
||||||
@@ -160,7 +198,7 @@ spec = do
|
|||||||
|
|
||||||
it "matches filtering nested items 2" $
|
it "matches filtering nested items 2" $
|
||||||
get "/clients?select=id,projects(id,tasks2(id,name))&projects.tasks.name=like.Design*"
|
get "/clients?select=id,projects(id,tasks2(id,name))&projects.tasks.name=like.Design*"
|
||||||
`shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities, No relation found between projects and tasks2"}|]
|
`shouldRespondWith` [json| {"message":"Could not find foreign keys between these entities. No relationship found between projects and tasks2"}|]
|
||||||
{ matchStatus = 400
|
{ matchStatus = 400
|
||||||
, matchHeaders = [matchContentTypeJson]
|
, matchHeaders = [matchContentTypeJson]
|
||||||
}
|
}
|
||||||
@@ -181,7 +219,6 @@ spec = do
|
|||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
describe "Shaping response with select parameter" $ do
|
describe "Shaping response with select parameter" $ do
|
||||||
|
|
||||||
it "selectStar works in absense of parameter" $
|
it "selectStar works in absense of parameter" $
|
||||||
get "/complex_items?id=eq.3" `shouldRespondWith`
|
get "/complex_items?id=eq.3" `shouldRespondWith`
|
||||||
[str|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|]
|
[str|[{"id":3,"name":"Three","settings":{"foo":{"int":1,"bar":"baz"}},"arr_data":[1,2,3],"field-with_sep":1}]|]
|
||||||
@@ -200,7 +237,6 @@ spec = do
|
|||||||
[json| [{"myId":1}] |]
|
[json| [{"myId":1}] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
|
||||||
it "one simple column with casting (text)" $
|
it "one simple column with casting (text)" $
|
||||||
get "/complex_items?select=id::text" `shouldRespondWith`
|
get "/complex_items?select=id::text" `shouldRespondWith`
|
||||||
[json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]
|
[json| [{"id":"1"},{"id":"2"},{"id":"3"}] |]
|
||||||
@@ -228,19 +264,8 @@ spec = do
|
|||||||
[json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
|
[json|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"},"tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "requesting parent without specifying primary key" $
|
|
||||||
get "/projects?select=name,client(name)" `shouldRespondWith`
|
|
||||||
[json|[
|
|
||||||
{"name":"Windows 7","client":{"name": "Microsoft"}},
|
|
||||||
{"name":"Windows 10","client":{"name": "Microsoft"}},
|
|
||||||
{"name":"IOS","client":{"name": "Apple"}},
|
|
||||||
{"name":"OSX","client":{"name": "Apple"}},
|
|
||||||
{"name":"Orphan","client":null}
|
|
||||||
]|]
|
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
|
||||||
|
|
||||||
it "requesting parent and renaming primary key" $
|
it "requesting parent and renaming primary key" $
|
||||||
get "/projects?select=name,client(clientId:id,name)" `shouldRespondWith`
|
get "/projects?select=name,client:clients(clientId:id,name)" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},
|
{"name":"Windows 7","client":{"name": "Microsoft", "clientId": 1}},
|
||||||
{"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},
|
{"name":"Windows 10","client":{"name": "Microsoft", "clientId": 1}},
|
||||||
@@ -258,24 +283,11 @@ spec = do
|
|||||||
[json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]
|
[json|[{"id":1,"commenter_id":1,"user_id":2,"task_id":6,"content":"Needs to be delivered ASAP","users_tasks":{"taskId": 6}}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "embed data with two fk pointing to the same table" $
|
|
||||||
get "/orders?id=eq.1&select=id, name, billing_address_id(id), shipping_address_id(id)" `shouldRespondWith`
|
|
||||||
[str|[{"id":1,"name":"order 1","billing_address_id":{"id":1},"shipping_address_id":{"id":2}}]|]
|
|
||||||
|
|
||||||
|
|
||||||
it "requesting parents and children while renaming them" $
|
it "requesting parents and children while renaming them" $
|
||||||
get "/projects?id=eq.1&select=myId:id, name, project_client:client_id(*), project_tasks:tasks(id, name)" `shouldRespondWith`
|
get "/projects?id=eq.1&select=myId:id, name, project_client:clients(*), project_tasks:tasks(id, name)" `shouldRespondWith`
|
||||||
[json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
|
[json|[{"myId":1,"name":"Windows 7","project_client":{"id":1,"name":"Microsoft"},"project_tasks":[{"id":1,"name":"Design w7"},{"id":2,"name":"Code w7"}]}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "requesting parents two levels up while using FK to specify the link" $
|
|
||||||
get "/tasks?id=eq.1&select=id,name,project:project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
|
|
||||||
[str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
|
||||||
|
|
||||||
it "requesting parents two levels up while using FK to specify the link (with rename)" $
|
|
||||||
get "/tasks?id=eq.1&select=id,name,project:project_id(id,name,client:client_id(id,name))" `shouldRespondWith`
|
|
||||||
[str|[{"id":1,"name":"Design w7","project":{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}}]|]
|
|
||||||
|
|
||||||
it "requesting parents and filtering parent columns" $
|
it "requesting parents and filtering parent columns" $
|
||||||
get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`
|
get "/projects?id=eq.1&select=id, name, clients(id)" `shouldRespondWith`
|
||||||
[str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]
|
[str|[{"id":1,"name":"Windows 7","clients":{"id":1}}]|]
|
||||||
@@ -314,27 +326,39 @@ spec = do
|
|||||||
[json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]
|
[json|[{"user_id":2,"task_id":6,"comments":[{"content":"Needs to be delivered ASAP"}]}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can embed by FK column name" $
|
describe "computed columns" $ do
|
||||||
get "/projects?id=in.(1,3)&select=id,name,client_id(id,name)" `shouldRespondWith`
|
it "computed column on table" $
|
||||||
[json|[{"id":1,"name":"Windows 7","client_id":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":{"id":2,"name":"Apple"}}]|]
|
get "/items?id=eq.1&select=id,always_true" `shouldRespondWith`
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
[json|[{"id":1,"always_true":true}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can embed by FK column name and select the FK value at the same time, if aliased" $
|
it "computed column on rpc" $
|
||||||
get "/projects?id=in.(1,3)&select=id,name,client_id,client:client_id(id,name)" `shouldRespondWith`
|
get "/rpc/search?id=1&select=id,always_true" `shouldRespondWith`
|
||||||
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
[json|[{"id":1,"always_true":true}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "can select by column name sans id" $
|
it "overloaded computed columns on both tables" $ do
|
||||||
get "/projects?id=in.(1,3)&select=id,name,client_id,client(id,name)" `shouldRespondWith`
|
get "/items?id=eq.1&select=id,computed_overload" `shouldRespondWith`
|
||||||
[json|[{"id":1,"name":"Windows 7","client_id":1,"client":{"id":1,"name":"Microsoft"}},{"id":3,"name":"IOS","client_id":2,"client":{"id":2,"name":"Apple"}}]|]
|
[json|[{"id":1,"computed_overload":true}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/items2?id=eq.1&select=id,computed_overload" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"computed_overload":true}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "overloaded computed column on rpc" $
|
||||||
|
get "/rpc/search?id=1&select=id,computed_overload" `shouldRespondWith`
|
||||||
|
[json|[{"id":1,"computed_overload":true}]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
describe "view embedding" $ do
|
describe "view embedding" $ do
|
||||||
it "can detect fk relations through views to tables in the public schema" $
|
it "can detect fk relations through views to tables in the public schema" $
|
||||||
get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200
|
get "/consumers_view?select=*,orders_view(*)" `shouldRespondWith` 200
|
||||||
|
|
||||||
it "can request parent without specifying primary key" $
|
it "can detect fk relations through materialized views to tables in the public schema" $
|
||||||
get "/articleStars?select=createdAt,article(owner),user(name)&limit=1" `shouldRespondWith`
|
get "/materialized_projects?select=*,users(*)" `shouldRespondWith` 200
|
||||||
|
|
||||||
|
it "can request two parents" $
|
||||||
|
get "/articleStars?select=createdAt,article:articles(owner),user:users(name)&limit=1" `shouldRespondWith`
|
||||||
[json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|]
|
[json|[{"createdAt":"2015-12-08T04:22:57.472738","article":{"owner": "postgrest_test_authenticator"},"user":{"name": "Angela Martin"}}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
@@ -354,16 +378,16 @@ spec = do
|
|||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "detects parent relations when having many views of a private table" $ do
|
it "detects parent relations when having many views of a private table" $ do
|
||||||
get "/books?select=title,author(name)&id=eq.5" `shouldRespondWith`
|
get "/books?select=title,author:authors(name)&id=eq.5" `shouldRespondWith`
|
||||||
[json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|]
|
[json|[ { "title": "Farenheit 451", "author": { "name": "Ray Bradbury" } } ]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/forties_books?select=title,author(name)&limit=1" `shouldRespondWith`
|
get "/forties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
|
||||||
[json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|]
|
[json|[ { "title": "1984", "author": { "name": "George Orwell" } } ]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/fifties_books?select=title,author(name)&limit=1" `shouldRespondWith`
|
get "/fifties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
|
||||||
[json|[ { "title": "The Catcher in the Rye", "author": { "name": "J.D. Salinger" } } ]|]
|
[json|[ { "title": "The Catcher in the Rye", "author": { "name": "J.D. Salinger" } } ]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/sixties_books?select=title,author(name)&limit=1" `shouldRespondWith`
|
get "/sixties_books?select=title,author:authors(name)&limit=1" `shouldRespondWith`
|
||||||
[json|[ { "title": "To Kill a Mockingbird", "author": { "name": "Harper Lee" } } ]|]
|
[json|[ { "title": "To Kill a Mockingbird", "author": { "name": "Harper Lee" } } ]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
@@ -395,31 +419,47 @@ spec = do
|
|||||||
it "works when having a capitalized table name and camelCase fk column" $
|
it "works when having a capitalized table name and camelCase fk column" $
|
||||||
get "/foos?select=*,bars(*)" `shouldRespondWith` 200
|
get "/foos?select=*,bars(*)" `shouldRespondWith` 200
|
||||||
|
|
||||||
describe "path fixed" $ do
|
it "works when embedding a view with a table that has a long compound pk" $ do
|
||||||
it "works when requesting children 2 levels" $
|
get "/player_view?select=id,contract(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`
|
||||||
get "/clients?id=eq.1&select=id,projects:projects.client_id(id,tasks(id))" `shouldRespondWith`
|
|
||||||
[json|[{"id":1,"projects":[{"id":1,"tasks":[{"id":1},{"id":2}]},{"id":2,"tasks":[{"id":3},{"id":4}]}]}]|]
|
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
|
||||||
|
|
||||||
it "works with parent relation" $
|
|
||||||
get "/message?select=id,body,sender:person.sender(name),recipient:person.recipient(name)&id=lt.4" `shouldRespondWith`
|
|
||||||
[json|
|
[json|
|
||||||
[{"id":1,"body":"Hello Jane","sender":{"name":"John"},"recipient":{"name":"Jane"}},
|
[{"id":1,"contract":[{"purchase_price":10}]},
|
||||||
{"id":2,"body":"Hi John","sender":{"name":"Jane"},"recipient":{"name":"John"}},
|
{"id":3,"contract":[{"purchase_price":30}]},
|
||||||
{"id":3,"body":"How are you doing?","sender":{"name":"John"},"recipient":{"name":"Jane"}}] |]
|
{"id":5,"contract":[{"purchase_price":50}]},
|
||||||
|
{"id":7,"contract":[]}] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/contract?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`
|
||||||
it "works with a parent view relation" $
|
|
||||||
get "/message?select=id,body,sender:person_detail.sender(name,sent),recipient:person_detail.recipient(name,received)&id=lt.4" `shouldRespondWith`
|
|
||||||
[json|
|
[json|
|
||||||
[{"id":1,"body":"Hello Jane","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}},
|
[{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},
|
||||||
{"id":2,"body":"Hi John","sender":{"name":"Jane","sent":1},"recipient":{"name":"John","received":1}},
|
{"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},
|
||||||
{"id":3,"body":"How are you doing?","sender":{"name":"John","sent":2},"recipient":{"name":"Jane","received":2}}] |]
|
{"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "works with many<->many relation" $
|
it "works when embedding a view with a view that referes to a table that has a long compound pk" $ do
|
||||||
get "/tasks?select=id,users:users.users_tasks(id)" `shouldRespondWith`
|
get "/player_view?select=id,contract_view(purchase_price)&id=in.(1,3,5,7)" `shouldRespondWith`
|
||||||
[json|[{"id":1,"users":[{"id":1},{"id":3}]},{"id":2,"users":[{"id":1}]},{"id":3,"users":[{"id":1}]},{"id":4,"users":[{"id":1}]},{"id":5,"users":[{"id":2},{"id":3}]},{"id":6,"users":[{"id":2}]},{"id":7,"users":[{"id":2}]},{"id":8,"users":[]}]|]
|
[json|
|
||||||
|
[{"id":1,"contract_view":[{"purchase_price":10}]},
|
||||||
|
{"id":3,"contract_view":[{"purchase_price":30}]},
|
||||||
|
{"id":5,"contract_view":[{"purchase_price":50}]},
|
||||||
|
{"id":7,"contract_view":[]}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
get "/contract_view?select=tournament,player_view(first_name)&limit=3" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
[{"tournament":"tournament_1","player_view":{"first_name":"first_name_1"}},
|
||||||
|
{"tournament":"tournament_2","player_view":{"first_name":"first_name_2"}},
|
||||||
|
{"tournament":"tournament_3","player_view":{"first_name":"first_name_3"}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can embed a view that has group by" $
|
||||||
|
get "/projects_count_grouped_by?select=number_of_projects,client:clients(name)&order=number_of_projects" `shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
[{"number_of_projects":1,"client":null},
|
||||||
|
{"number_of_projects":2,"client":{"name":"Microsoft"}},
|
||||||
|
{"number_of_projects":2,"client":{"name":"Apple"}}] |]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can embed a view that has a subselect containing a select in a where" $
|
||||||
|
get "/authors_w_entities?select=name,entities,books(title)&id=eq.1" `shouldRespondWith`
|
||||||
|
[json| [{"name":"George Orwell","entities":[3, 4],"books":[{"title":"1984"}]}] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
describe "aliased embeds" $ do
|
describe "aliased embeds" $ do
|
||||||
@@ -465,92 +505,20 @@ spec = do
|
|||||||
"designTasks":[ ] }
|
"designTasks":[ ] }
|
||||||
]|] { matchHeaders = [matchContentTypeJson] }
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "works with two aliased childs embeds plus and/or" $
|
it "works with two aliased children embeds plus and/or" $
|
||||||
get "/entities?select=id,childs:child_entities(id,gChilds:grandchild_entities(id))&childs.and=(id.in.(1,2,3))&childs.gChilds.or=(id.eq.1,id.eq.2)" `shouldRespondWith`
|
get "/entities?select=id,children:child_entities(id,gChildren:grandchild_entities(id))&children.and=(id.in.(1,2,3))&children.gChildren.or=(id.eq.1,id.eq.2)" `shouldRespondWith`
|
||||||
[json|[
|
[json|[
|
||||||
{ "id":1,
|
{ "id":1,
|
||||||
"childs":[
|
"children":[
|
||||||
{"id":1,"gChilds":[{"id":1}, {"id":2}]},
|
{"id":1,"gChildren":[{"id":1}, {"id":2}]},
|
||||||
{"id":2,"gChilds":[]}]},
|
{"id":2,"gChildren":[]}]},
|
||||||
{ "id":2,
|
{ "id":2,
|
||||||
"childs":[
|
"children":[
|
||||||
{"id":3,"gChilds":[]}]},
|
{"id":3,"gChildren":[]}]},
|
||||||
{ "id":3,"childs":[]},
|
{ "id":3,"children":[]},
|
||||||
{ "id":4,"childs":[]}
|
{ "id":4,"children":[]}
|
||||||
]|] { matchHeaders = [matchContentTypeJson] }
|
]|] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
describe "tables with self reference foreign keys" $ do
|
|
||||||
context "one self reference foreign key" $ do
|
|
||||||
it "embeds parents recursively" $
|
|
||||||
get "/family_tree?id=in.(3,4)&select=id,parent(id,name,parent(*))" `shouldRespondWith`
|
|
||||||
[json|[
|
|
||||||
{ "id": "3", "parent": { "id": "1", "name": "Parental Unit", "parent": null } },
|
|
||||||
{ "id": "4", "parent": { "id": "2", "name": "Kid One", "parent": { "id": "1", "name": "Parental Unit", "parent": null } } }
|
|
||||||
]|]
|
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
|
||||||
|
|
||||||
it "embeds childs recursively" $
|
|
||||||
get "/family_tree?id=eq.1&select=id,name, childs:family_tree.parent(id,name,childs:family_tree.parent(id,name))" `shouldRespondWith`
|
|
||||||
[json|[{
|
|
||||||
"id": "1", "name": "Parental Unit", "childs": [
|
|
||||||
{ "id": "2", "name": "Kid One", "childs": [ { "id": "4", "name": "Grandkid One" } ] },
|
|
||||||
{ "id": "3", "name": "Kid Two", "childs": [ { "id": "5", "name": "Grandkid Two" } ] }
|
|
||||||
]
|
|
||||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
|
||||||
|
|
||||||
it "embeds parent and then embeds childs" $
|
|
||||||
get "/family_tree?id=eq.2&select=id,name,parent(id,name,childs:family_tree.parent(id,name))" `shouldRespondWith`
|
|
||||||
[json|[{
|
|
||||||
"id": "2", "name": "Kid One", "parent": {
|
|
||||||
"id": "1", "name": "Parental Unit", "childs": [ { "id": "2", "name": "Kid One" }, { "id": "3", "name": "Kid Two"} ]
|
|
||||||
}
|
|
||||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
|
||||||
|
|
||||||
context "two self reference foreign keys" $ do
|
|
||||||
it "embeds parents" $
|
|
||||||
get "/organizations?select=id,name,referee(id,name),auditor(id,name)&id=eq.3" `shouldRespondWith`
|
|
||||||
[json|[{
|
|
||||||
"id": 3, "name": "Acme",
|
|
||||||
"referee": {
|
|
||||||
"id": 1,
|
|
||||||
"name": "Referee Org"
|
|
||||||
},
|
|
||||||
"auditor": {
|
|
||||||
"id": 2,
|
|
||||||
"name": "Auditor Org"
|
|
||||||
}
|
|
||||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
|
||||||
|
|
||||||
it "embeds childs" $ do
|
|
||||||
get "/organizations?select=id,name,refereeds:organizations.referee(id,name)&id=eq.1" `shouldRespondWith`
|
|
||||||
[json|[{
|
|
||||||
"id": 1, "name": "Referee Org",
|
|
||||||
"refereeds": [
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "Acme"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"name": "Umbrella"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
|
||||||
get "/organizations?select=id,name,auditees:organizations.auditor(id,name)&id=eq.2" `shouldRespondWith`
|
|
||||||
[json|[{
|
|
||||||
"id": 2, "name": "Auditor Org",
|
|
||||||
"auditees": [
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "Acme"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"name": "Umbrella"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}]|] { matchHeaders = [matchContentTypeJson] }
|
|
||||||
|
|
||||||
describe "ordering response" $ do
|
describe "ordering response" $ do
|
||||||
it "by a column asc" $
|
it "by a column asc" $
|
||||||
get "/items?id=lte.2&order=id.asc"
|
get "/items?id=lte.2&order=id.asc"
|
||||||
@@ -646,10 +614,6 @@ spec = do
|
|||||||
get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith`
|
get "/projects?id=eq.1&select=id, name, clients(id, name)&clients.order=name.asc" `shouldRespondWith`
|
||||||
[str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|]
|
[str|[{"id":1,"name":"Windows 7","clients":{"id":1,"name":"Microsoft"}}]|]
|
||||||
|
|
||||||
it "ordering embeded parents does not break things when using ducktape names" $
|
|
||||||
get "/projects?id=eq.1&select=id, name, client(id, name)&client.order=name.asc" `shouldRespondWith`
|
|
||||||
[str|[{"id":1,"name":"Windows 7","client":{"id":1,"name":"Microsoft"}}]|]
|
|
||||||
|
|
||||||
context "order syntax errors" $ do
|
context "order syntax errors" $ do
|
||||||
it "gives meaningful error messages when asc/desc/nulls{first,last} are misspelled" $ do
|
it "gives meaningful error messages when asc/desc/nulls{first,last} are misspelled" $ do
|
||||||
get "/items?order=id.ac" `shouldRespondWith`
|
get "/items?order=id.ac" `shouldRespondWith`
|
||||||
@@ -699,7 +663,11 @@ spec = do
|
|||||||
it "should respond an unknown accept type with 415" $
|
it "should respond an unknown accept type with 415" $
|
||||||
request methodGet "/simple_pk"
|
request methodGet "/simple_pk"
|
||||||
(acceptHdrs "text/unknowntype") ""
|
(acceptHdrs "text/unknowntype") ""
|
||||||
`shouldRespondWith` 415
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"None of these Content-Types are available: text/unknowntype"}|]
|
||||||
|
{ matchStatus = 415
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
it "should respond correctly to */* in accept header" $
|
it "should respond correctly to */* in accept header" $
|
||||||
request methodGet "/simple_pk"
|
request methodGet "/simple_pk"
|
||||||
@@ -755,7 +723,6 @@ spec = do
|
|||||||
respHeaders `shouldSatisfy` matchHeader
|
respHeaders `shouldSatisfy` matchHeader
|
||||||
"Content-Location" "/simple_pk"
|
"Content-Location" "/simple_pk"
|
||||||
|
|
||||||
|
|
||||||
describe "weird requests" $ do
|
describe "weird requests" $ do
|
||||||
it "can query as normal" $ do
|
it "can query as normal" $ do
|
||||||
get "/Escap3e;" `shouldRespondWith`
|
get "/Escap3e;" `shouldRespondWith`
|
||||||
@@ -776,107 +743,53 @@ spec = do
|
|||||||
[json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
|
[json| [{"ghostBusters":[{"escapeId":1}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":3}]},{"ghostBusters":[]},{"ghostBusters":[{"escapeId":5}]}] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "will embed using a column" $
|
it "will select and filter a column that has spaces" $
|
||||||
get "/ghostBusters?select=escapeId(*)" `shouldRespondWith`
|
get "/Server%20Today?select=Just%20A%20Server%20Model&Just%20A%20Server%20Model=like.*91*" `shouldRespondWith`
|
||||||
[json| [{"escapeId":{"so6meIdColumn":1}},{"escapeId":{"so6meIdColumn":3}},{"escapeId":{"so6meIdColumn":5}}] |]
|
[json|[
|
||||||
|
{"Just A Server Model":" IBM,9113-550 (P5-550)"},
|
||||||
|
{"Just A Server Model":" IBM,9113-550 (P5-550)"},
|
||||||
|
{"Just A Server Model":" IBM,9131-52A (P5-52A)"},
|
||||||
|
{"Just A Server Model":" IBM,9133-55A (P5-55A)"}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
describe "binary output" $ do
|
it "will select and filter a quoted column that has PostgREST reserved characters" $
|
||||||
context "on GET" $ do
|
get "/pgrst_reserved_chars?select=%22:arr-%3Eow::cast%22,%22(inside,parens)%22,%22a.dotted.column%22,%22%20%20col%20%20w%20%20space%20%20%22&%22*id*%22=eq.1" `shouldRespondWith`
|
||||||
it "can query if a single column is selected" $
|
[json|[{":arr->ow::cast":" arrow-1 ","(inside,parens)":" parens-1 ","a.dotted.column":" dotted-1 "," col w space ":" space-1"}]|]
|
||||||
request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
|
|
||||||
{ matchStatus = 200
|
|
||||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
|
||||||
}
|
|
||||||
|
|
||||||
it "fails if a single column is not selected" $ do
|
context "binary output" $ do
|
||||||
request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
it "can query if a single column is selected" $
|
||||||
`shouldRespondWith` 406
|
request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||||
request methodGet "/images?select=*&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
|
||||||
`shouldRespondWith` 406
|
{ matchStatus = 200
|
||||||
request methodGet "/images?name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
||||||
`shouldRespondWith` 406
|
}
|
||||||
|
|
||||||
it "concatenates results if more than one row is returned" $
|
it "can get raw output with Accept: text/plain" $
|
||||||
request methodGet "/images_base64?select=img&name=in.(A.png,B.png)" (acceptHdrs "application/octet-stream") ""
|
request methodGet "/projects?select=name&id=eq.1" (acceptHdrs "text/plain") ""
|
||||||
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
|
`shouldRespondWith` "Windows 7"
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
|
||||||
}
|
}
|
||||||
|
|
||||||
context "on RPC" $ do
|
it "fails if a single column is not selected" $ do
|
||||||
context "Proc that returns scalar" $
|
request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||||
it "can query without selecting column" $
|
`shouldRespondWith`
|
||||||
request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") ""
|
[json| {"message":"application/octet-stream requested but more than one column was selected"} |]
|
||||||
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
|
{ matchStatus = 406
|
||||||
{ matchStatus = 200
|
, matchHeaders = [matchContentTypeJson]
|
||||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
}
|
||||||
}
|
request methodGet "/images?select=*&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||||
|
`shouldRespondWith` 406
|
||||||
|
request methodGet "/images?name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||||
|
`shouldRespondWith` 406
|
||||||
|
|
||||||
context "Proc that returns rows" $ do
|
it "concatenates results if more than one row is returned" $
|
||||||
it "can query if a single column is selected" $
|
request methodGet "/images_base64?select=img&name=in.(A.png,B.png)" (acceptHdrs "application/octet-stream") ""
|
||||||
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="
|
||||||
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
|
{ matchStatus = 200
|
||||||
{ matchStatus = 200
|
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
||||||
, 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` 406
|
|
||||||
|
|
||||||
describe "HTTP request env vars" $ do
|
|
||||||
it "custom header is set" $
|
|
||||||
request methodPost "/rpc/get_guc_value"
|
|
||||||
[("Custom-Header", "test")]
|
|
||||||
[json| { "name": "request.header.custom-header" } |]
|
|
||||||
`shouldRespondWith`
|
|
||||||
[str|"test"|]
|
|
||||||
{ matchStatus = 200
|
|
||||||
, matchHeaders = [ matchContentTypeJson ]
|
|
||||||
}
|
|
||||||
it "standard header is set" $
|
|
||||||
request methodPost "/rpc/get_guc_value"
|
|
||||||
[("Origin", "http://example.com")]
|
|
||||||
[json| { "name": "request.header.origin" } |]
|
|
||||||
`shouldRespondWith`
|
|
||||||
[str|"http://example.com"|]
|
|
||||||
{ matchStatus = 200
|
|
||||||
, matchHeaders = [ matchContentTypeJson ]
|
|
||||||
}
|
|
||||||
it "current role is available as GUC claim" $
|
|
||||||
request methodPost "/rpc/get_guc_value" []
|
|
||||||
[json| { "name": "request.jwt.claim.role" } |]
|
|
||||||
`shouldRespondWith`
|
|
||||||
[str|"postgrest_test_anonymous"|]
|
|
||||||
{ matchStatus = 200
|
|
||||||
, matchHeaders = [ matchContentTypeJson ]
|
|
||||||
}
|
|
||||||
it "single cookie ends up as claims" $
|
|
||||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
|
|
||||||
[json| {"name":"request.cookie.acookie"} |]
|
|
||||||
`shouldRespondWith`
|
|
||||||
[str|"cookievalue"|]
|
|
||||||
{ matchStatus = 200
|
|
||||||
, matchHeaders = []
|
|
||||||
}
|
|
||||||
it "multiple cookies ends up as claims" $
|
|
||||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
|
|
||||||
[json| {"name":"request.cookie.secondcookie"} |]
|
|
||||||
`shouldRespondWith`
|
|
||||||
[str|"anothervalue"|]
|
|
||||||
{ matchStatus = 200
|
|
||||||
, matchHeaders = []
|
|
||||||
}
|
|
||||||
it "app settings available" $
|
|
||||||
request methodPost "/rpc/get_guc_value" []
|
|
||||||
[json| { "name": "app.settings.app_host" } |]
|
|
||||||
`shouldRespondWith`
|
|
||||||
[str|"localhost"|]
|
|
||||||
{ matchStatus = 200
|
|
||||||
, matchHeaders = [ matchContentTypeJson ]
|
|
||||||
}
|
|
||||||
|
|
||||||
describe "values with quotes in IN and NOT IN" $ do
|
describe "values with quotes in IN and NOT IN" $ do
|
||||||
it "succeeds when only quoted values are present" $ do
|
it "succeeds when only quoted values are present" $ do
|
||||||
@@ -942,7 +855,16 @@ spec = do
|
|||||||
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
[json| [] |] { matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
it "only returns an empty result set if the in value is empty" $
|
it "only returns an empty result set if the in value is empty" $
|
||||||
get "/items_with_different_col_types?int_data=in.( ,3,4)" `shouldRespondWith` 400
|
get "/items_with_different_col_types?int_data=in.( ,3,4)"
|
||||||
|
`shouldRespondWith` (
|
||||||
|
if actualPgVersion >= pgVersion121 then
|
||||||
|
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for type integer: \"\""} |]
|
||||||
|
else
|
||||||
|
[json| {"hint":null,"details":null,"code":"22P02","message":"invalid input syntax for integer: \"\""} |]
|
||||||
|
)
|
||||||
|
{ matchStatus = 400
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
describe "Embedding when column name = table name" $ do
|
describe "Embedding when column name = table name" $ do
|
||||||
it "works with child embeds" $
|
it "works with child embeds" $
|
||||||
@@ -963,3 +885,21 @@ spec = do
|
|||||||
get "/projects_dump?select=id,name&order=id.desc&limit=3" `shouldRespondWith`
|
get "/projects_dump?select=id,name&order=id.desc&limit=3" `shouldRespondWith`
|
||||||
[json| [{"id":5,"name":"Orphan"}, {"id":4,"name":"OSX"}, {"id":3,"name":"IOS"}] |]
|
[json| [{"id":5,"name":"Orphan"}, {"id":4,"name":"OSX"}, {"id":3,"name":"IOS"}] |]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "cannot use ltree(in public schema) extension operators if no extra search path added" $
|
||||||
|
get "/ltree_sample?path=cd.Top.Science.Astronomy" `shouldRespondWith` 400
|
||||||
|
|
||||||
|
context "VIEW that has a source FK based on a UNIQUE key" $
|
||||||
|
it "can be embedded" $
|
||||||
|
get "/referrals?select=site,link:pages(url)" `shouldRespondWith`
|
||||||
|
[json| [
|
||||||
|
{"site":"github.com", "link":{"url":"http://postgrest.org/en/v6.0/api.html"}},
|
||||||
|
{"site":"hub.docker.com", "link":{"url":"http://postgrest.org/en/v6.0/admin.html"}}
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "shouldn't produce a Content-Profile header since only a single schema is exposed" $ do
|
||||||
|
r <- get "/items"
|
||||||
|
liftIO $ do
|
||||||
|
let respHeaders = simpleHeaders r
|
||||||
|
respHeaders `shouldSatisfy` noProfileHeader
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
module Feature.RangeSpec where
|
module Feature.RangeSpec where
|
||||||
|
|
||||||
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (simpleHeaders, simpleStatus))
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Network.HTTP.Types
|
|
||||||
import Network.Wai.Test (SResponse(simpleHeaders,simpleStatus))
|
|
||||||
|
|
||||||
import qualified Data.ByteString.Lazy as BL
|
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
|
||||||
|
|
||||||
defaultRange :: BL.ByteString
|
defaultRange :: BL.ByteString
|
||||||
defaultRange = [json| { "min": 0, "max": 15 } |]
|
defaultRange = [json| { "min": 0, "max": 15 } |]
|
||||||
@@ -19,7 +19,7 @@ defaultRange = [json| { "min": 0, "max": 15 } |]
|
|||||||
emptyRange :: BL.ByteString
|
emptyRange :: BL.ByteString
|
||||||
emptyRange = [json| { "min": 2, "max": 2 } |]
|
emptyRange = [json| { "min": 2, "max": 2 } |]
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith ((), Application)
|
||||||
spec = do
|
spec = do
|
||||||
describe "POST /rpc/getitemrange" $ do
|
describe "POST /rpc/getitemrange" $ do
|
||||||
context "without range headers" $ do
|
context "without range headers" $ do
|
||||||
@@ -38,7 +38,6 @@ spec = do
|
|||||||
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||||
|
|
||||||
context "with range headers" $ do
|
context "with range headers" $ do
|
||||||
|
|
||||||
context "of acceptable range" $ do
|
context "of acceptable range" $ do
|
||||||
it "succeeds with partial content" $ do
|
it "succeeds with partial content" $ do
|
||||||
r <- request methodPost "/rpc/getitemrange"
|
r <- request methodPost "/rpc/getitemrange"
|
||||||
@@ -156,16 +155,108 @@ spec = do
|
|||||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it "limit and offset works on first level" $ do
|
||||||
it "limit and offset works on first level" $
|
|
||||||
get "/items?select=id&order=id.asc&limit=3&offset=2"
|
get "/items?select=id&order=id.asc&limit=3&offset=2"
|
||||||
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
|
`shouldRespondWith` [json|[{"id":3},{"id":4},{"id":5}]|]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
, matchHeaders = ["Content-Range" <:> "2-4/*"]
|
||||||
}
|
}
|
||||||
|
request methodHead "/items?select=id&order=id.asc&limit=3&offset=2" [] mempty
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ 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 "when count=planned" $ do
|
||||||
|
it "obtains a filtered range" $ do
|
||||||
|
request methodGet "/items?select=id&id=gt.8" [("Prefer", "count=planned")] ""
|
||||||
|
`shouldRespondWith` [json|[{"id":9}, {"id":10}, {"id":11}, {"id":12}, {"id":13}, {"id":14}, {"id":15}]|]
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-6/8"]
|
||||||
|
}
|
||||||
|
request methodGet "/child_entities?select=id&id=gt.3" [("Prefer", "count=planned")] ""
|
||||||
|
`shouldRespondWith` [json|[{"id":4}, {"id":5}, {"id":6}]|]
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-2/4"]
|
||||||
|
}
|
||||||
|
request methodGet "/getallprojects_view?select=id&id=lt.3" [("Prefer", "count=planned")] ""
|
||||||
|
`shouldRespondWith` [json|[{"id":1}, {"id":2}]|]
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/673"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "obtains the full range" $ do
|
||||||
|
request methodHead "/items" [("Prefer", "count=planned")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-14/15"]
|
||||||
|
}
|
||||||
|
request methodHead "/child_entities" [("Prefer", "count=planned")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-5/6"]
|
||||||
|
}
|
||||||
|
request methodHead "/getallprojects_view" [("Prefer", "count=planned")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-4/2019"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "ignores limit/offset on the planned count" $ do
|
||||||
|
request methodHead "/items?limit=2&offset=3" [("Prefer", "count=planned")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "3-4/15"]
|
||||||
|
}
|
||||||
|
request methodHead "/child_entities?limit=2" [("Prefer", "count=planned")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/6"]
|
||||||
|
}
|
||||||
|
request methodHead "/getallprojects_view?limit=2" [("Prefer", "count=planned")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-1/2019"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "works with two levels" $
|
||||||
|
request methodHead "/child_entities?select=*,entities(*)" [("Prefer", "count=planned")] ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-5/6"]
|
||||||
|
}
|
||||||
|
|
||||||
context "with range headers" $ do
|
context "with range headers" $ do
|
||||||
|
|
||||||
context "of acceptable range" $ do
|
context "of acceptable range" $ do
|
||||||
it "succeeds with partial content" $ do
|
it "succeeds with partial content" $ do
|
||||||
r <- request methodGet "/items"
|
r <- request methodGet "/items"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
module Feature.RawOutputTypesSpec where
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
|
|
||||||
|
import Protolude
|
||||||
|
import SpecHelper (acceptHdrs)
|
||||||
|
|
||||||
|
spec :: SpecWith ((), Application)
|
||||||
|
spec = describe "When raw-media-types config variable is missing or left empty" $ do
|
||||||
|
let firefoxAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||||
|
chromeAcceptHdrs = acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3"
|
||||||
|
it "responds json to a GET request with Firefox Accept headers" $
|
||||||
|
request methodGet "/items?id=eq.1" firefoxAcceptHdrs ""
|
||||||
|
`shouldRespondWith` [json| [{"id":1}] |]
|
||||||
|
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||||
|
it "responds json to a GET request with Chrome Accept headers" $
|
||||||
|
request methodGet "/items?id=eq.1" chromeAcceptHdrs ""
|
||||||
|
`shouldRespondWith` [json| [{"id":1}] |]
|
||||||
|
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||||
|
|
||||||
|
it "responds json to a GET request to RPC with Firefox Accept headers" $
|
||||||
|
request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""
|
||||||
|
`shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
|
||||||
|
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||||
|
it "responds json to a GET request to RPC with Chrome Accept headers" $
|
||||||
|
request methodGet "/rpc/get_projects_below?id=3" chromeAcceptHdrs ""
|
||||||
|
`shouldRespondWith` [json|[{"id":1,"name":"Windows 7","client_id":1}, {"id":2,"name":"Windows 10","client_id":1}]|]
|
||||||
|
{ matchHeaders= ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||||
@@ -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] }
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
module Feature.RpcSpec where
|
module Feature.RpcSpec where
|
||||||
|
|
||||||
import Test.Hspec hiding (pendingWith)
|
|
||||||
import Test.Hspec.Wai
|
|
||||||
import Test.Hspec.Wai.JSON
|
|
||||||
import Network.HTTP.Types
|
|
||||||
import Network.Wai.Test (SResponse(simpleStatus, simpleBody))
|
|
||||||
import qualified Data.ByteString.Lazy as BL (empty)
|
import qualified Data.ByteString.Lazy as BL (empty)
|
||||||
|
|
||||||
import SpecHelper
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (simpleBody, simpleStatus))
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec hiding (pendingWith)
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
import Test.Hspec.Wai.JSON
|
||||||
import Text.Heredoc
|
import Text.Heredoc
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
import PostgREST.Types (PgVersion, pgVersion100, pgVersion109,
|
||||||
|
pgVersion110, pgVersion112, pgVersion114,
|
||||||
|
pgVersion95)
|
||||||
|
import Protolude hiding (get)
|
||||||
|
import SpecHelper
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: PgVersion -> SpecWith ((), Application)
|
||||||
spec =
|
spec actualPgVersion =
|
||||||
describe "remote procedure call" $ do
|
describe "remote procedure call" $ do
|
||||||
context "a proc that returns a set" $ do
|
context "a proc that returns a set" $ do
|
||||||
it "returns paginated results" $ do
|
it "returns paginated results" $ do
|
||||||
@@ -30,6 +34,12 @@ spec =
|
|||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
}
|
}
|
||||||
|
request methodHead "/rpc/getitemrange?min=2&max=4"
|
||||||
|
(rangeHdrs (ByteRangeFromTo 0 0)) ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-0/*"]
|
||||||
|
}
|
||||||
|
|
||||||
it "includes total count if requested" $ do
|
it "includes total count if requested" $ do
|
||||||
request methodPost "/rpc/getitemrange"
|
request methodPost "/rpc/getitemrange"
|
||||||
@@ -45,6 +55,12 @@ spec =
|
|||||||
{ matchStatus = 206
|
{ matchStatus = 206
|
||||||
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
||||||
}
|
}
|
||||||
|
request methodHead "/rpc/getitemrange?min=2&max=4"
|
||||||
|
(rangeHdrsWithCount (ByteRangeFromTo 0 0)) ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 206
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-0/2"]
|
||||||
|
}
|
||||||
|
|
||||||
it "returns proper json" $ do
|
it "returns proper json" $ do
|
||||||
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
|
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
|
||||||
@@ -68,6 +84,12 @@ spec =
|
|||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||||
}
|
}
|
||||||
|
request methodHead "/rpc/getitemrange?min=2&max=4"
|
||||||
|
(acceptHdrs "text/csv") ""
|
||||||
|
`shouldRespondWith` ""
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "text/csv; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
context "unknown function" $ do
|
context "unknown function" $ do
|
||||||
it "returns 404" $
|
it "returns 404" $
|
||||||
@@ -77,6 +99,21 @@ spec =
|
|||||||
it "should fail with 404 on unknown proc args" $ do
|
it "should fail with 404 on unknown proc args" $ do
|
||||||
get "/rpc/sayhello" `shouldRespondWith` 404
|
get "/rpc/sayhello" `shouldRespondWith` 404
|
||||||
get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404
|
get "/rpc/sayhello?any_arg=value" `shouldRespondWith` 404
|
||||||
|
it "should not ignore unknown args and fail with 404" $
|
||||||
|
get "/rpc/add_them?a=1&b=2&smthelse=blabla" `shouldRespondWith`
|
||||||
|
let
|
||||||
|
message :: Text
|
||||||
|
message
|
||||||
|
| actualPgVersion < pgVersion95 = "function test.add_them(a := integer, b := integer, smthelse := text) does not exist"
|
||||||
|
| otherwise = "function test.add_them(a => integer, b => integer, smthelse => text) does not exist"
|
||||||
|
in [json| {
|
||||||
|
"code": "42883",
|
||||||
|
"details": null,
|
||||||
|
"hint": "No function matches the given name and argument types. You might need to add explicit type casts.",
|
||||||
|
"message": #{message} } |]
|
||||||
|
{ matchStatus = 404
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
it "works when having uppercase identifiers" $ do
|
it "works when having uppercase identifiers" $ do
|
||||||
get "/rpc/quotedFunction?user=mscott&fullName=Michael Scott&SSN=401-32-XXXX" `shouldRespondWith`
|
get "/rpc/quotedFunction?user=mscott&fullName=Michael Scott&SSN=401-32-XXXX" `shouldRespondWith`
|
||||||
@@ -121,10 +158,10 @@ spec =
|
|||||||
|
|
||||||
context "foreign entities embedding" $ do
|
context "foreign entities embedding" $ do
|
||||||
it "can embed if related tables are in the exposed schema" $ do
|
it "can embed if related tables are in the exposed schema" $ do
|
||||||
post "/rpc/getproject?select=id,name,client(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
|
post "/rpc/getproject?select=id,name,client:clients(id),tasks(id)" [json| { "id": 1} |] `shouldRespondWith`
|
||||||
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
get "/rpc/getproject?id=1&select=id,name,client(id),tasks(id)" `shouldRespondWith`
|
get "/rpc/getproject?id=1&select=id,name,client:clients(id),tasks(id)" `shouldRespondWith`
|
||||||
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
[json|[{"id":1,"name":"Windows 7","client":{"id":1},"tasks":[{"id":1},{"id":2}]}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
@@ -142,6 +179,29 @@ spec =
|
|||||||
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
|
`shouldRespondWith` [json|[{"id": 2, "articleStars": [{"userId": 3}]}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can embed an M2M relationship table" $
|
||||||
|
get "/rpc/getallusers?select=name,tasks(name)&id=gt.1"
|
||||||
|
`shouldRespondWith` [json|[
|
||||||
|
{"name":"Michael Scott", "tasks":[{"name":"Design IOS"}, {"name":"Code IOS"}, {"name":"Design OSX"}]},
|
||||||
|
{"name":"Dwight Schrute","tasks":[{"name":"Design w7"}, {"name":"Design IOS"}]}
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "can embed an M2M relationship table that has a parent relationship table" $
|
||||||
|
get "/rpc/getallusers?select=name,tasks(name,project:projects(name))&id=gt.1"
|
||||||
|
`shouldRespondWith` [json|[
|
||||||
|
{"name":"Michael Scott","tasks":[
|
||||||
|
{"name":"Design IOS","project":{"name":"IOS"}},
|
||||||
|
{"name":"Code IOS","project":{"name":"IOS"}},
|
||||||
|
{"name":"Design OSX","project":{"name":"OSX"}}
|
||||||
|
]},
|
||||||
|
{"name":"Dwight Schrute","tasks":[
|
||||||
|
{"name":"Design w7","project":{"name":"Windows 7"}},
|
||||||
|
{"name":"Design IOS","project":{"name":"IOS"}}
|
||||||
|
]}
|
||||||
|
]|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "a proc that returns an empty rowset" $
|
context "a proc that returns an empty rowset" $
|
||||||
it "returns empty json array" $ do
|
it "returns empty json array" $ do
|
||||||
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
|
post "/rpc/test_empty_rowset" [json| {} |] `shouldRespondWith`
|
||||||
@@ -213,6 +273,48 @@ spec =
|
|||||||
[json|null|]
|
[json|null|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "proc argument types" $ do
|
||||||
|
it "accepts a variety of arguments" $
|
||||||
|
post "/rpc/varied_arguments"
|
||||||
|
[json| {
|
||||||
|
"double": 3.1,
|
||||||
|
"varchar": "hello",
|
||||||
|
"boolean": true,
|
||||||
|
"date": "20190101",
|
||||||
|
"money": 0,
|
||||||
|
"enum": "foo",
|
||||||
|
"integer": 43,
|
||||||
|
"json": {"some key": "some value"},
|
||||||
|
"jsonb": {"another key": [1, 2, "3"]}
|
||||||
|
} |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|"Hi"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "parses embedded JSON arguments as JSON" $
|
||||||
|
post "/rpc/json_argument"
|
||||||
|
[json| { "arg": { "key": 3 } } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|"object"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
when (actualPgVersion < pgVersion100) $
|
||||||
|
it "parses quoted JSON arguments as JSON (Postgres < 10)" $
|
||||||
|
post "/rpc/json_argument"
|
||||||
|
[json| { "arg": "{ \"key\": 3 }" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|"object"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
when ((actualPgVersion >= pgVersion109 && actualPgVersion < pgVersion110)
|
||||||
|
|| actualPgVersion >= pgVersion114) $
|
||||||
|
it "parses quoted JSON arguments as JSON string (from Postgres 10.9, 11.4)" $
|
||||||
|
post "/rpc/json_argument"
|
||||||
|
[json| { "arg": "{ \"key\": 3 }" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|"string"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
context "improper input" $ do
|
context "improper input" $ do
|
||||||
it "rejects unknown content type even if payload is good" $ do
|
it "rejects unknown content type even if payload is good" $ do
|
||||||
request methodPost "/rpc/sayhello"
|
request methodPost "/rpc/sayhello"
|
||||||
@@ -236,7 +338,11 @@ spec =
|
|||||||
context "unsupported verbs" $ do
|
context "unsupported verbs" $ do
|
||||||
it "DELETE fails" $
|
it "DELETE fails" $
|
||||||
request methodDelete "/rpc/sayhello" [] ""
|
request methodDelete "/rpc/sayhello" [] ""
|
||||||
`shouldRespondWith` 405
|
`shouldRespondWith`
|
||||||
|
[json|{"message":"Bad Request"}|]
|
||||||
|
{ matchStatus = 405
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
it "PATCH fails" $
|
it "PATCH fails" $
|
||||||
request methodPatch "/rpc/sayhello" [] ""
|
request methodPatch "/rpc/sayhello" [] ""
|
||||||
`shouldRespondWith` 405
|
`shouldRespondWith` 405
|
||||||
@@ -302,7 +408,12 @@ spec =
|
|||||||
}
|
}
|
||||||
|
|
||||||
it "defaults to status 500 if RAISE code is PT not followed by a number" $
|
it "defaults to status 500 if RAISE code is PT not followed by a number" $
|
||||||
get "/rpc/raise_bad_pt" `shouldRespondWith` 500
|
get "/rpc/raise_bad_pt"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|{"hint": null, "details": null}|]
|
||||||
|
{ matchStatus = 500
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
|
||||||
context "expects a single json object" $ do
|
context "expects a single json object" $ do
|
||||||
it "does not expand posted json into parameters" $
|
it "does not expand posted json into parameters" $
|
||||||
@@ -339,10 +450,170 @@ spec =
|
|||||||
get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|]
|
get "/rpc/overloaded?a=1&b=2" `shouldRespondWith` [str|3|]
|
||||||
get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [str|"123"|]
|
get "/rpc/overloaded?a=1&b=2&c=3" `shouldRespondWith` [str|"123"|]
|
||||||
|
|
||||||
context "only for POST rpc" $
|
context "only for POST rpc" $ do
|
||||||
it "gives a parse filter error if GET style proc args are specified" $
|
it "gives a parse filter error if GET style proc args are specified" $
|
||||||
post "/rpc/sayhello?name=John" [json|{}|] `shouldRespondWith` 400
|
post "/rpc/sayhello?name=John" [json|{}|] `shouldRespondWith` 400
|
||||||
|
|
||||||
|
it "ignores json keys not included in ?columns" $
|
||||||
|
post "/rpc/sayhello?columns=name"
|
||||||
|
[json|{"name": "John", "smth": "here", "other": "stuff", "fake_id": 13}|]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|"Hello, John"|]
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
it "only takes the first object in case of array of objects payload" $
|
||||||
|
post "/rpc/add_them"
|
||||||
|
[json|[
|
||||||
|
{"a": 1, "b": 2},
|
||||||
|
{"a": 4, "b": 6},
|
||||||
|
{"a": 100, "b": 200} ]|]
|
||||||
|
`shouldRespondWith` "3"
|
||||||
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
|
||||||
|
context "bulk RPC with params=multiple-objects" $ do
|
||||||
|
it "works with a scalar function an returns a json array" $
|
||||||
|
request methodPost "/rpc/add_them" [("Prefer", "params=multiple-objects")]
|
||||||
|
[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"), ("Prefer", "params=multiple-objects")]
|
||||||
|
"a,b\n1,2\n4,6\n100,200"
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json|
|
||||||
|
[3, 10, 300]
|
||||||
|
|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "works with a non-scalar result" $
|
||||||
|
request methodPost "/rpc/get_projects_below?select=id,name" [("Prefer", "params=multiple-objects")]
|
||||||
|
[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 "HTTP request env vars" $ do
|
||||||
|
it "custom header is set" $
|
||||||
|
request methodPost "/rpc/get_guc_value"
|
||||||
|
[("Custom-Header", "test")]
|
||||||
|
[json| { "name": "request.header.custom-header" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"test"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
it "standard header is set" $
|
||||||
|
request methodPost "/rpc/get_guc_value"
|
||||||
|
[("Origin", "http://example.com")]
|
||||||
|
[json| { "name": "request.header.origin" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"http://example.com"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
it "current role is available as GUC claim" $
|
||||||
|
request methodPost "/rpc/get_guc_value" []
|
||||||
|
[json| { "name": "request.jwt.claim.role" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"postgrest_test_anonymous"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
it "single cookie ends up as claims" $
|
||||||
|
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
|
||||||
|
[json| {"name":"request.cookie.acookie"} |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"cookievalue"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
it "multiple cookies ends up as claims" $
|
||||||
|
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
|
||||||
|
[json| {"name":"request.cookie.secondcookie"} |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"anothervalue"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
it "app settings available" $
|
||||||
|
request methodPost "/rpc/get_guc_value" []
|
||||||
|
[json| { "name": "app.settings.app_host" } |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"localhost"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = [ matchContentTypeJson ]
|
||||||
|
}
|
||||||
|
it "gets the Authorization value" $
|
||||||
|
request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
|
||||||
|
[json| {"name":"request.header.authorization"} |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
it "gets the http method" $
|
||||||
|
request methodPost "/rpc/get_guc_value" []
|
||||||
|
[json| {"name":"request.method"} |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"POST"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
it "gets the http path" $
|
||||||
|
request methodPost "/rpc/get_guc_value" []
|
||||||
|
[json| {"name":"request.path"} |]
|
||||||
|
`shouldRespondWith`
|
||||||
|
[str|"/rpc/get_guc_value"|]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = []
|
||||||
|
}
|
||||||
|
|
||||||
|
context "binary output" $ do
|
||||||
|
context "Proc that returns scalar" $ do
|
||||||
|
it "can query without selecting column" $
|
||||||
|
request methodPost "/rpc/ret_base64_bin" (acceptHdrs "application/octet-stream") ""
|
||||||
|
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "can get raw output with Accept: text/plain" $
|
||||||
|
request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
|
||||||
|
`shouldRespondWith` "Welcome to PostgREST"
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
|
context "Proc that returns rows" $ do
|
||||||
|
it "can query if a single column is selected" $
|
||||||
|
request methodPost "/rpc/ret_rows_with_base64_bin?select=img" (acceptHdrs "application/octet-stream") ""
|
||||||
|
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCCiVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII="
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Type" <:> "application/octet-stream; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "fails if a single column is not selected" $
|
||||||
|
request methodPost "/rpc/ret_rows_with_base64_bin" (acceptHdrs "application/octet-stream") ""
|
||||||
|
`shouldRespondWith`
|
||||||
|
[json| {"message":"application/octet-stream requested but more than one column was selected"} |]
|
||||||
|
{ matchStatus = 406
|
||||||
|
, matchHeaders = [matchContentTypeJson]
|
||||||
|
}
|
||||||
|
|
||||||
context "only for GET rpc" $ do
|
context "only for GET rpc" $ do
|
||||||
it "should fail on mutating procs" $ do
|
it "should fail on mutating procs" $ do
|
||||||
get "/rpc/callcounter" `shouldRespondWith` 500
|
get "/rpc/callcounter" `shouldRespondWith` 500
|
||||||
@@ -371,3 +642,12 @@ spec =
|
|||||||
get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`
|
get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith`
|
||||||
[json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]
|
[json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|]
|
||||||
{ matchHeaders = [matchContentTypeJson] }
|
{ matchHeaders = [matchContentTypeJson] }
|
||||||
|
when (actualPgVersion >= pgVersion112) $
|
||||||
|
get "/rpc/get_tsearch?text_search_vector=wfts.impossible" `shouldRespondWith`
|
||||||
|
[json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|]
|
||||||
|
{ 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] }
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
module Feature.SingularSpec where
|
module Feature.SingularSpec where
|
||||||
|
|
||||||
import Text.Heredoc
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (..))
|
||||||
|
|
||||||
|
import Network.HTTP.Types
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Network.HTTP.Types
|
import Text.Heredoc
|
||||||
import Network.Wai.Test (SResponse(..))
|
|
||||||
|
|
||||||
import Network.Wai (Application)
|
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
import Protolude hiding (get)
|
|
||||||
|
|
||||||
|
|
||||||
spec :: SpecWith Application
|
spec :: SpecWith ((), Application)
|
||||||
spec =
|
spec =
|
||||||
describe "Requesting singular json object" $ do
|
describe "Requesting singular json object" $ do
|
||||||
let pgrstObj = "application/vnd.pgrst.object+json"
|
let pgrstObj = "application/vnd.pgrst.object+json"
|
||||||
@@ -43,7 +43,6 @@ spec =
|
|||||||
{ matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"] }
|
{ matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"] }
|
||||||
|
|
||||||
context "when updating rows" $ do
|
context "when updating rows" $ do
|
||||||
|
|
||||||
it "works for one row" $ do
|
it "works for one row" $ do
|
||||||
_ <- post "/addresses" [json| { id: 97, address: "A Street" } |]
|
_ <- post "/addresses" [json| { id: 97, address: "A Street" } |]
|
||||||
request methodPatch
|
request methodPatch
|
||||||
@@ -56,10 +55,9 @@ spec =
|
|||||||
it "raises an error for multiple rows" $ do
|
it "raises an error for multiple rows" $ do
|
||||||
_ <- post "/addresses" [json| { id: 98, address: "xxx" } |]
|
_ <- post "/addresses" [json| { id: 98, address: "xxx" } |]
|
||||||
_ <- post "/addresses" [json| { id: 99, address: "yyy" } |]
|
_ <- post "/addresses" [json| { id: 99, address: "yyy" } |]
|
||||||
p <- request methodPatch
|
p <- request methodPatch "/addresses?id=gt.0"
|
||||||
"/addresses?id=gt.0"
|
[singular]
|
||||||
[("Prefer", "return=representation"), singular]
|
[json| { address: "zzz" } |]
|
||||||
[json| { address: "zzz" } |]
|
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleStatus p `shouldBe` notAcceptable406
|
simpleStatus p `shouldBe` notAcceptable406
|
||||||
isErrorFormat (simpleBody p) `shouldBe` True
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
@@ -67,33 +65,56 @@ spec =
|
|||||||
-- the rows should not be updated, either
|
-- the rows should not be updated, either
|
||||||
get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|]
|
get "/addresses?id=eq.98" `shouldRespondWith` [str|[{"id":98,"address":"xxx"}]|]
|
||||||
|
|
||||||
it "raises an error for zero rows" $ do
|
it "raises an error for multiple rows with return=rep" $ do
|
||||||
p <- request methodPatch "/items?id=gt.0&id=lt.0"
|
_ <- post "/addresses" [json| { id: 100, address: "xxx" } |]
|
||||||
[("Prefer", "return=representation"), singular] [json|{"id":1}|]
|
_ <- post "/addresses" [json| { id: 101, address: "yyy" } |]
|
||||||
|
p <- request methodPatch "/addresses?id=gt.0"
|
||||||
|
[("Prefer", "return=representation"), singular]
|
||||||
|
[json| { address: "zzz" } |]
|
||||||
liftIO $ do
|
liftIO $ do
|
||||||
simpleStatus p `shouldBe` notAcceptable406
|
simpleStatus p `shouldBe` notAcceptable406
|
||||||
isErrorFormat (simpleBody p) `shouldBe` True
|
isErrorFormat (simpleBody p) `shouldBe` True
|
||||||
|
|
||||||
context "when creating rows" $ do
|
-- the rows should not be updated, either
|
||||||
|
get "/addresses?id=eq.100" `shouldRespondWith` [str|[{"id":100,"address":"xxx"}]|]
|
||||||
|
|
||||||
|
it "raises an error for zero rows" $
|
||||||
|
request methodPatch "/items?id=gt.0&id=lt.0"
|
||||||
|
[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"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "raises an error for zero rows with return=rep" $
|
||||||
|
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
|
it "works for one row" $ do
|
||||||
p <- request methodPost
|
p <- request methodPost
|
||||||
"/addresses"
|
"/addresses"
|
||||||
[("Prefer", "return=representation"), singular]
|
[("Prefer", "return=representation"), singular]
|
||||||
[json| [ { id: 100, address: "xxx" } ] |]
|
[json| [ { id: 102, address: "xxx" } ] |]
|
||||||
liftIO $ simpleBody p `shouldBe` [str|{"id":100,"address":"xxx"}|]
|
liftIO $ simpleBody p `shouldBe` [str|{"id":102,"address":"xxx"}|]
|
||||||
|
|
||||||
it "works for one row even with return=minimal" $ do
|
it "works for one row even with return=minimal" $ do
|
||||||
request methodPost "/addresses"
|
request methodPost "/addresses"
|
||||||
[("Prefer", "return=minimal"), singular]
|
[("Prefer", "return=minimal"), singular]
|
||||||
[json| [ { id: 101, address: "xxx" } ] |]
|
[json| [ { id: 103, address: "xxx" } ] |]
|
||||||
`shouldRespondWith` ""
|
`shouldRespondWith` ""
|
||||||
{ matchStatus = 201
|
{ matchStatus = 201
|
||||||
, matchHeaders = ["Content-Range" <:> "*/*"]
|
, matchHeaders = ["Content-Range" <:> "*/*"]
|
||||||
}
|
}
|
||||||
-- and the element should exist
|
-- and the element should exist
|
||||||
get "/addresses?id=eq.101"
|
get "/addresses?id=eq.103"
|
||||||
`shouldRespondWith` [str|[{"id":101,"address":"xxx"}]|]
|
`shouldRespondWith` [str|[{"id":103,"address":"xxx"}]|]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = []
|
, matchHeaders = []
|
||||||
}
|
}
|
||||||
@@ -101,33 +122,57 @@ spec =
|
|||||||
it "raises an error when attempting to create multiple entities" $ do
|
it "raises an error when attempting to create multiple entities" $ do
|
||||||
p <- request methodPost
|
p <- request methodPost
|
||||||
"/addresses"
|
"/addresses"
|
||||||
[("Prefer", "return=representation"), singular]
|
[singular]
|
||||||
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
[json| [ { id: 200, address: "xxx" }, { id: 201, address: "yyy" } ] |]
|
||||||
liftIO $ simpleStatus p `shouldBe` notAcceptable406
|
liftIO $ simpleStatus p `shouldBe` notAcceptable406
|
||||||
|
|
||||||
-- the rows should not exist, either
|
-- the rows should not exist, either
|
||||||
get "/addresses?id=eq.200" `shouldRespondWith` "[]"
|
get "/addresses?id=eq.200" `shouldRespondWith` "[]"
|
||||||
|
|
||||||
it "return=minimal allows request to create multiple elements" $
|
it "raises an error when attempting to create multiple entities with return=rep" $ do
|
||||||
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" $ do
|
|
||||||
p <- request methodPost
|
p <- request methodPost
|
||||||
"/addresses"
|
"/addresses"
|
||||||
[("Prefer", "return=representation"), singular]
|
[("Prefer", "return=representation"), singular]
|
||||||
[json| [ ] |]
|
[json| [ { id: 202, address: "xxx" }, { id: 203, address: "yyy" } ] |]
|
||||||
liftIO $ do
|
liftIO $ simpleStatus p `shouldBe` notAcceptable406
|
||||||
simpleStatus p `shouldBe` notAcceptable406
|
|
||||||
isErrorFormat (simpleBody p) `shouldBe` True
|
-- the rows should not exist, either
|
||||||
|
get "/addresses?id=eq.202" `shouldRespondWith` "[]"
|
||||||
|
|
||||||
|
it "raises an error regardless of return=minimal" $ do
|
||||||
|
request methodPost "/addresses"
|
||||||
|
[("Prefer", "return=minimal"), singular]
|
||||||
|
[json| [ { id: 204, address: "xxx" }, { id: 205, address: "yyy" } ] |]
|
||||||
|
`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"]
|
||||||
|
}
|
||||||
|
|
||||||
|
-- the rows should not exist, either
|
||||||
|
get "/addresses?id=eq.204" `shouldRespondWith` "[]"
|
||||||
|
|
||||||
|
it "raises an error when creating zero entities" $
|
||||||
|
request methodPost "/addresses"
|
||||||
|
[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"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "raises an error when creating zero entities with return=rep" $
|
||||||
|
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
|
context "when deleting rows" $ do
|
||||||
|
|
||||||
it "works for one row" $ do
|
it "works for one row" $ do
|
||||||
p <- request methodDelete
|
p <- request methodDelete
|
||||||
"/items?id=eq.11"
|
"/items?id=eq.11"
|
||||||
@@ -135,32 +180,56 @@ spec =
|
|||||||
liftIO $ simpleBody p `shouldBe` [str|{"id":11}|]
|
liftIO $ simpleBody p `shouldBe` [str|{"id":11}|]
|
||||||
|
|
||||||
it "raises an error when attempting to delete multiple entities" $ do
|
it "raises an error when attempting to delete multiple entities" $ do
|
||||||
let firstItems = "/items?id=gt.0&id=lt.11"
|
let firstItems = "/items?id=gt.0&id=lt.6"
|
||||||
|
request methodDelete firstItems
|
||||||
|
[singular] ""
|
||||||
|
`shouldRespondWith` 406
|
||||||
|
|
||||||
|
get firstItems
|
||||||
|
`shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5}] |]
|
||||||
|
{ matchStatus = 200
|
||||||
|
, matchHeaders = ["Content-Range" <:> "0-4/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "raises an error when attempting to delete multiple entities with return=rep" $ do
|
||||||
|
let firstItems = "/items?id=gt.5&id=lt.11"
|
||||||
request methodDelete firstItems
|
request methodDelete firstItems
|
||||||
[("Prefer", "return=representation"), singular] ""
|
[("Prefer", "return=representation"), singular] ""
|
||||||
`shouldRespondWith` 406
|
`shouldRespondWith` 406
|
||||||
|
|
||||||
get firstItems
|
get firstItems
|
||||||
`shouldRespondWith` [json| [{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
|
`shouldRespondWith` [json| [{"id":6},{"id":7},{"id":8},{"id":9},{"id":10}] |]
|
||||||
{ matchStatus = 200
|
{ matchStatus = 200
|
||||||
, matchHeaders = ["Content-Range" <:> "0-9/*"]
|
, matchHeaders = ["Content-Range" <:> "0-4/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
it "raises an error when deleting zero entities" $ do
|
it "raises an error when deleting zero entities" $
|
||||||
p <- request methodDelete "/items?id=lt.0"
|
request methodDelete "/items?id=lt.0"
|
||||||
[("Prefer", "return=representation"), singular] ""
|
[singular] ""
|
||||||
liftIO $ do
|
`shouldRespondWith`
|
||||||
simpleStatus p `shouldBe` notAcceptable406
|
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||||
isErrorFormat (simpleBody p) `shouldBe` True
|
{ matchStatus = 406
|
||||||
|
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
|
it "raises an error when deleting zero entities with return=rep" $
|
||||||
|
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
|
context "when calling a stored proc" $ do
|
||||||
|
it "fails for zero rows" $
|
||||||
it "fails for zero rows" $ do
|
request methodPost "/rpc/getproject"
|
||||||
p <- request methodPost "/rpc/getproject"
|
[singular] [json|{ "id": 9999999}|]
|
||||||
[singular] [json|{ "id": 9999999}|]
|
`shouldRespondWith`
|
||||||
liftIO $ do
|
[str|{"details":"Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||||
simpleStatus p `shouldBe` notAcceptable406
|
{ matchStatus = 406
|
||||||
isErrorFormat (simpleBody p) `shouldBe` True
|
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
-- this one may be controversial, should vnd.pgrst.object include
|
-- this one may be controversial, should vnd.pgrst.object include
|
||||||
-- the likes of 2 and "hello?"
|
-- the likes of 2 and "hello?"
|
||||||
@@ -174,20 +243,26 @@ spec =
|
|||||||
[singular] [json|{ "id": 1}|] `shouldRespondWith`
|
[singular] [json|{ "id": 1}|] `shouldRespondWith`
|
||||||
[str|{"id":1,"name":"Windows 7","client_id":1}|]
|
[str|{"id":1,"name":"Windows 7","client_id":1}|]
|
||||||
|
|
||||||
it "fails for multiple rows" $ do
|
it "fails for multiple rows" $
|
||||||
p <- request methodPost "/rpc/getallprojects" [singular] "{}"
|
request methodPost "/rpc/getallprojects"
|
||||||
liftIO $ do
|
[singular] "{}"
|
||||||
simpleStatus p `shouldBe` notAcceptable406
|
`shouldRespondWith`
|
||||||
isErrorFormat (simpleBody p) `shouldBe` True
|
[str|{"details":"Results contain 5 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||||
|
{ matchStatus = 406
|
||||||
|
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
it "executes the proc exactly once per request" $ do
|
it "executes the proc exactly once per request" $ do
|
||||||
request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]
|
request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]
|
||||||
`shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]
|
`shouldRespondWith` [str|[{"id":1,"name":"Windows 7"}]|]
|
||||||
p <- request methodPost "/rpc/setprojects" [singular]
|
|
||||||
[json| {"id_l": 1, "id_h": 2, "name": "changed"} |]
|
request methodPost "/rpc/setprojects" [singular]
|
||||||
liftIO $ do
|
[json| {"id_l": 1, "id_h": 2, "name": "changed"} |]
|
||||||
simpleStatus p `shouldBe` notAcceptable406
|
`shouldRespondWith`
|
||||||
isErrorFormat (simpleBody p) `shouldBe` True
|
[str|{"details":"Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row","message":"JSON object requested, multiple (or no) rows returned"}|]
|
||||||
|
{ matchStatus = 406
|
||||||
|
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||||
|
}
|
||||||
|
|
||||||
-- should not actually have executed the function
|
-- should not actually have executed the function
|
||||||
request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]
|
request methodPost "/rpc/getproject?select=id,name" [] [json| {"id": 1} |]
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
module Feature.StructureSpec where
|
module Feature.StructureSpec where
|
||||||
|
|
||||||
import Test.Hspec hiding (pendingWith)
|
import Control.Lens ((^?))
|
||||||
import Test.Hspec.Wai
|
|
||||||
import Network.HTTP.Types
|
|
||||||
|
|
||||||
import PostgREST.Config (docsVersion)
|
|
||||||
import Control.Lens ((^?))
|
|
||||||
import Data.Aeson.Types (Value (..))
|
import Data.Aeson.Types (Value (..))
|
||||||
|
import Network.Wai (Application)
|
||||||
|
import Network.Wai.Test (SResponse (..))
|
||||||
|
|
||||||
import Data.Aeson.Lens
|
import Data.Aeson.Lens
|
||||||
import Data.Aeson.QQ
|
import Data.Aeson.QQ
|
||||||
|
import Network.HTTP.Types
|
||||||
|
import Test.Hspec hiding (pendingWith)
|
||||||
|
import Test.Hspec.Wai
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import PostgREST.Config (docsVersion)
|
||||||
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
|
||||||
import Network.Wai (Application)
|
spec :: SpecWith ((), Application)
|
||||||
import Network.Wai.Test (SResponse(..))
|
|
||||||
|
|
||||||
import Protolude hiding (get)
|
|
||||||
|
|
||||||
spec :: SpecWith Application
|
|
||||||
spec = do
|
spec = do
|
||||||
|
|
||||||
describe "OpenAPI" $ do
|
describe "OpenAPI" $ do
|
||||||
it "root path returns a valid openapi spec" $
|
it "root path returns a valid openapi spec" $ do
|
||||||
validateOpenApiResponse [("Accept", "application/openapi+json")]
|
validateOpenApiResponse [("Accept", "application/openapi+json")]
|
||||||
|
request methodHead "/" (acceptHdrs "application/openapi+json") ""
|
||||||
|
`shouldRespondWith` "" { matchStatus = 200 }
|
||||||
|
|
||||||
it "should respond to openapi request on none root path with 415" $
|
it "should respond to openapi request on none root path with 415" $
|
||||||
request methodGet "/items"
|
request methodGet "/items"
|
||||||
@@ -45,6 +47,7 @@ spec = do
|
|||||||
childGetSummary = r ^? method "get" . key "summary"
|
childGetSummary = r ^? method "get" . key "summary"
|
||||||
childGetDescription = r ^? method "get" . key "description"
|
childGetDescription = r ^? method "get" . key "description"
|
||||||
getParameters = r ^? method "get" . key "parameters"
|
getParameters = r ^? method "get" . key "parameters"
|
||||||
|
postParameters = r ^? method "post" . key "parameters"
|
||||||
postResponse = r ^? method "post" . key "responses" . key "201" . key "description"
|
postResponse = r ^? method "post" . key "responses" . key "201" . key "description"
|
||||||
patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description"
|
patchResponse = r ^? method "patch" . key "responses" . key "204" . key "description"
|
||||||
deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description"
|
deleteResponse = r ^? method "delete" . key "responses" . key "204" . key "description"
|
||||||
@@ -79,6 +82,15 @@ spec = do
|
|||||||
]
|
]
|
||||||
|]
|
|]
|
||||||
|
|
||||||
|
postParameters `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
[
|
||||||
|
{ "$ref": "#/parameters/body.child_entities" },
|
||||||
|
{ "$ref": "#/parameters/select" },
|
||||||
|
{ "$ref": "#/parameters/preferReturn" }
|
||||||
|
]
|
||||||
|
|]
|
||||||
|
|
||||||
postResponse `shouldBe` Just "Created"
|
postResponse `shouldBe` Just "Created"
|
||||||
|
|
||||||
patchResponse `shouldBe` Just "No Content"
|
patchResponse `shouldBe` Just "No Content"
|
||||||
@@ -125,7 +137,7 @@ spec = do
|
|||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"description": "child_entities name comment",
|
"description": "child_entities name comment. Can be longer than sixty-three characters long",
|
||||||
"format": "text",
|
"format": "text",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -219,6 +231,175 @@ spec = do
|
|||||||
]
|
]
|
||||||
|]
|
|]
|
||||||
|
|
||||||
|
describe "VIEW that has a source FK based on a UNIQUE key" $
|
||||||
|
|
||||||
|
it "includes fk description" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let referralLink = r ^? key "definitions" . key "referrals" . key "properties" . key "link"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
referralLink `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "integer",
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Note:\nThis is a Foreign Key to `pages.link`.<fk table='pages' column='link'/>"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
describe "PostgreSQL to Swagger Type Mapping" $ do
|
||||||
|
|
||||||
|
it "character varying to string" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character_varying"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "character varying",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
it "character(1) to string" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_character"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"maxLength": 1,
|
||||||
|
"format": "character",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "text to string" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_text"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "text",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "boolean to boolean" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_boolean"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "boolean",
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "smallint to integer" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_smallint"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "smallint",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "integer to integer" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_integer"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "integer",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "bigint to integer" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_bigint"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "bigint",
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "numeric to number" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_numeric"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "numeric",
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "real to number" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_real"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "real",
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
|
it "double_precision to number" $ do
|
||||||
|
r <- simpleBody <$> get "/"
|
||||||
|
|
||||||
|
let types = r ^? key "definitions" . key "openapi_types" . key "properties" . key "a_double_precision"
|
||||||
|
|
||||||
|
liftIO $
|
||||||
|
|
||||||
|
types `shouldBe` Just
|
||||||
|
[aesonQQ|
|
||||||
|
{
|
||||||
|
"format": "double precision",
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
|]
|
||||||
|
|
||||||
describe "RPC" $ do
|
describe "RPC" $ do
|
||||||
|
|
||||||
it "includes function summary/description and body schema for arguments" $ do
|
it "includes function summary/description and body schema for arguments" $ do
|
||||||
@@ -249,7 +430,7 @@ spec = do
|
|||||||
"properties": {
|
"properties": {
|
||||||
"double": {
|
"double": {
|
||||||
"format": "double precision",
|
"format": "double precision",
|
||||||
"type": "string"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"varchar": {
|
"varchar": {
|
||||||
"format": "character varying",
|
"format": "character varying",
|
||||||
@@ -274,6 +455,14 @@ spec = do
|
|||||||
"integer": {
|
"integer": {
|
||||||
"format": "integer",
|
"format": "integer",
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"format": "json",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"jsonb": {
|
||||||
|
"format": "jsonb",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
module Feature.UnicodeSpec where
|
module Feature.UnicodeSpec where
|
||||||
|
|
||||||
|
import Network.Wai (Application)
|
||||||
|
|
||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import Test.Hspec.Wai
|
import Test.Hspec.Wai
|
||||||
import Test.Hspec.Wai.JSON
|
import Test.Hspec.Wai.JSON
|
||||||
import Network.Wai (Application)
|
|
||||||
import Control.Monad (void)
|
|
||||||
|
|
||||||
|
import Protolude hiding (get)
|
||||||
import SpecHelper
|
import SpecHelper
|
||||||
|
|
||||||
import Protolude hiding (get)
|
spec :: SpecWith ((), Application)
|
||||||
|
|
||||||
spec :: SpecWith Application
|
|
||||||
spec =
|
spec =
|
||||||
describe "Reading and writing to unicode schema and table names" $
|
describe "Reading and writing to unicode schema and table names" $
|
||||||
it "Can read and write values" $ do
|
it "Can read and write values" $ do
|
||||||
|
|||||||