Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd38da56d5 | ||
|
|
229a4e4cd6 | ||
|
|
c3f7440e33 | ||
|
|
4e03ee252b | ||
|
|
0ac4d0d0a9 | ||
|
|
ceda77c5de | ||
|
|
a6e4a0078b | ||
|
|
15f4157cc5 | ||
|
|
50ea1999b2 | ||
|
|
c92f16a2cb | ||
|
|
1cebc03313 | ||
|
|
00580bc8cb | ||
|
|
9fe11249f9 | ||
|
|
7640de34e2 | ||
|
|
31ce39ba36 | ||
|
|
ca5eb64deb | ||
|
|
a72241ad4e | ||
|
|
b080f59bac | ||
|
|
558e9d40e8 | ||
|
|
958339b8d3 | ||
|
|
3d56f8435d | ||
|
|
dfa875c8c7 | ||
|
|
33891e3a73 | ||
|
|
8483459d59 | ||
|
|
b538ab9823 | ||
|
|
850b15fe13 | ||
|
|
df97a5071f | ||
|
|
1c60b50e2e | ||
|
|
c3301a1653 | ||
|
|
abcb21c69a | ||
|
|
f7bf2157f3 | ||
|
|
125f10a60f | ||
|
|
3c1a7f2641 | ||
|
|
f10d4139fe | ||
|
|
99b705d44f | ||
|
|
379d9d6298 | ||
|
|
2c35257eec | ||
|
|
ed90774f53 | ||
|
|
96ca177c31 | ||
|
|
226400a5bc | ||
|
|
b235227119 | ||
|
|
5c9c7f4ff4 | ||
|
|
82b38341bf | ||
|
|
4a90e9fbd9 | ||
|
|
14d030b96c | ||
|
|
6920a88dc0 | ||
|
|
966f8df33f | ||
|
|
98bd1de1cd | ||
|
|
d94286c185 | ||
|
|
618f93dec1 | ||
|
|
317619bf62 | ||
|
|
eb238ad678 | ||
|
|
54786a6c04 | ||
|
|
00f3cb3746 | ||
|
|
e977032847 | ||
|
|
f10b4c3268 | ||
|
|
dc01c748ae | ||
|
|
056c748c5f | ||
|
|
818387f24e | ||
|
|
a9d6c318fd | ||
|
|
2aa58164ab | ||
|
|
df08d7f3ff | ||
|
|
3dd292be46 | ||
|
|
0703f27d2b | ||
|
|
af0e369c65 | ||
|
|
910950dbac | ||
|
|
4c44782d15 | ||
|
|
3b1eb51744 | ||
|
|
cf7ee67dfe | ||
|
|
290d90609b | ||
|
|
3c1cdd434a | ||
|
|
2825ac059e | ||
|
|
a6e3eda5b2 | ||
|
|
90e3a5e29f | ||
|
|
add4dfeed5 | ||
|
|
fca039a54d | ||
|
|
1b4dae5a7a | ||
|
|
37a3f818dd | ||
|
|
c3169b7dc2 | ||
|
|
d64b71cbf0 | ||
|
|
c195eece65 | ||
|
|
fa182c216e | ||
|
|
30474c424c | ||
|
|
2888f351d1 | ||
|
|
8d3c9f8435 | ||
|
|
d8a91453d1 | ||
|
|
3f5e840baf | ||
|
|
2358b6670f | ||
|
|
8eed576826 | ||
|
|
07fef25591 | ||
|
|
7dc6e2b899 | ||
|
|
57fa2719dd | ||
|
|
b8b3145c5c | ||
|
|
531a183b44 | ||
|
|
739f056b0a | ||
|
|
32b77cae6c | ||
|
|
2434724edd | ||
|
|
87d6a0d0fe |
@@ -3,4 +3,16 @@ When submitting a new feature or fix:
|
||||
|
||||
- Add a new entry to the CHANGELOG - https://github.com/PostgREST/postgrest/blob/main/CHANGELOG.md#unreleased
|
||||
- If relevant, update the docs - https://github.com/PostgREST/postgrest-docs
|
||||
- Use a prefix for the PR title or commits, e.g. "fix: description of the fix".
|
||||
+ `fix`, bug fixes
|
||||
+ `feat`, new features added
|
||||
+ `perf`, performance improvements
|
||||
+ `nix`, related to the Nix development environment
|
||||
+ `ci`, related to the Continuous Integration modules
|
||||
+ `test`, related to the testing modules
|
||||
+ `refactor`, refactoring code
|
||||
+ `deprecate`, deprecating a feature
|
||||
+ `chore`, maintenance (changelog, build process, etc.)
|
||||
+ Other prefixes may be used if necessary
|
||||
- If there's a breaking change, add `BREAKING CHANGE` and an explanation to your commit message
|
||||
-->
|
||||
|
||||
@@ -7,14 +7,23 @@ inputs:
|
||||
description: Token to pass to cachix
|
||||
tools:
|
||||
description: Tools to install with nix-env -iA <tools>
|
||||
cache-id:
|
||||
description: Cache id to use for cache-nix-action
|
||||
default: "default"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: cachix/install-nix-action@v22
|
||||
- uses: nixbuild/nix-quick-install-action@v26
|
||||
with:
|
||||
install_url: https://releases.nixos.org/nix/nix-2.13.3/install
|
||||
- uses: cachix/cachix-action@v12
|
||||
nix_version: '2.13.6'
|
||||
- name: Restore and cache Nix store
|
||||
uses: nix-community/cache-nix-action@v4.0.3
|
||||
with:
|
||||
key: cache-nix-${{ runner.os }}-id-${{ inputs.cache-id }}-${{ hashFiles('nix/**/*.nix', '.github/actions/setup-nix/*') }}
|
||||
restore-keys: |
|
||||
cache-nix-${{ runner.os }}-common-
|
||||
- uses: cachix/cachix-action@v13
|
||||
with:
|
||||
name: postgrest
|
||||
authToken: ${{ inputs.authToken }}
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
name: Seed ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
|
||||
+57
-22
@@ -11,17 +11,39 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- rel-*
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
# Terminate all previous runs of the same workflow and branch/tag, except for main and release branches/tags
|
||||
cancel-in-progress: "${{ !(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/rel-')) }}"
|
||||
|
||||
jobs:
|
||||
Prepopulate-Nix-Cache-Linux:
|
||||
name: Prepopulate Nix cache for Linux runners
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
cache-id: common
|
||||
- name: Put all tools to store to be cached afterwards
|
||||
run: |
|
||||
# shellcheck disable=SC2046
|
||||
nix-store -v --realize $( nix-instantiate default.nix )
|
||||
shell: bash
|
||||
|
||||
Lint-Style:
|
||||
name: Lint & check code style
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Prepopulate-Nix-Cache-Linux]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
tools: style
|
||||
cache-id: common
|
||||
- name: Run linter (check locally with `nix-shell --run postgrest-lint`)
|
||||
run: postgrest-lint
|
||||
- name: Run style check (auto-format with `nix-shell --run postgrest-style`)
|
||||
@@ -31,17 +53,19 @@ jobs:
|
||||
Test-Nix:
|
||||
name: Test (Nix)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Prepopulate-Nix-Cache-Linux]
|
||||
defaults:
|
||||
run:
|
||||
# Hack for enabling color output, see:
|
||||
# https://github.com/actions/runner/issues/241#issuecomment-842566950
|
||||
shell: script -qec "bash --noprofile --norc -eo pipefail {0}"
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
tools: tests
|
||||
cache-id: common
|
||||
|
||||
- name: Run coverage (IO tests and Spec tests against PostgreSQL 15)
|
||||
run: postgrest-coverage
|
||||
@@ -63,20 +87,24 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
pgVersion: [9.6, 10, 11, 12, 13, 14, 15]
|
||||
pgVersion: [9.6, 10, 11, 12, 13, 14, 15, 16]
|
||||
name: Test PG ${{ matrix.pgVersion }} (Nix)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Prepopulate-Nix-Cache-Linux]
|
||||
defaults:
|
||||
run:
|
||||
# Hack for enabling color output, see:
|
||||
# https://github.com/actions/runner/issues/241#issuecomment-842566950
|
||||
shell: script -qec "bash --noprofile --norc -eo pipefail {0}"
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
tools: tests withTools
|
||||
# It seems like they are installing the same set of derivations, so we can assign them the same cache id.
|
||||
# This would decrease the amount of caches dowloaded on merge cache step and will prevent disk space issues.
|
||||
cache-id: common
|
||||
|
||||
- name: Run spec tests
|
||||
if: always()
|
||||
@@ -90,12 +118,14 @@ jobs:
|
||||
Test-Memory-Nix:
|
||||
name: Test memory (Nix)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Prepopulate-Nix-Cache-Linux]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
tools: memory
|
||||
cache-id: common
|
||||
- name: Run memory tests
|
||||
run: postgrest-test-memory
|
||||
|
||||
@@ -103,12 +133,14 @@ jobs:
|
||||
Build-Static-Nix:
|
||||
name: Build Linux static (Nix)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [Prepopulate-Nix-Cache-Linux]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
tools: tests
|
||||
cache-id: common
|
||||
|
||||
- name: Build static executable
|
||||
run: nix-build -A postgrestStatic
|
||||
@@ -130,12 +162,11 @@ jobs:
|
||||
path: postgrest-docker.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
Build-Macos-Nix:
|
||||
name: Build MacOS (Nix)
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
|
||||
@@ -175,12 +206,12 @@ jobs:
|
||||
name: Build ${{ matrix.name }} (Stack)
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Stack working files cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ matrix.cache }}
|
||||
key: ${{ runner.os }}-${{ hashFiles('stack.yaml.lock') }}
|
||||
key: cache-stack-${{ runner.os }}-${{ hashFiles('stack.yaml.lock') }}
|
||||
- name: Install dependencies
|
||||
if: ${{ matrix.deps }}
|
||||
run: ${{ matrix.deps }}
|
||||
@@ -199,7 +230,7 @@ jobs:
|
||||
name: Get FreeBSD build from CirrusCI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Get FreeBSD executable from CirrusCI
|
||||
env:
|
||||
# GITHUB_SHA does weird things for pull request, so we roll our own:
|
||||
@@ -221,7 +252,7 @@ jobs:
|
||||
name: Build Linux (Cabal, GHC ${{ matrix.ghc }})
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Workaround runner image issue
|
||||
# https://github.com/actions/runner-images/issues/7061
|
||||
run: sudo chown -R "$USER" /usr/local/.ghcup
|
||||
@@ -229,16 +260,20 @@ jobs:
|
||||
run: |
|
||||
ghcup install ghc ${{ matrix.ghc }}
|
||||
ghcup set ghc ${{ matrix.ghc }}
|
||||
- name: Copy cabal.project
|
||||
- name: Copy cabal.project & fix caching
|
||||
run: |
|
||||
mkdir ~/.cabal
|
||||
cp cabal.project.non-nix cabal.project
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cabal
|
||||
key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('**/*.cabal') }}-${{ hashFiles('**/cabal.project') }}
|
||||
path: |
|
||||
~/.cabal/packages
|
||||
~/.cabal/store
|
||||
dist-newstyle
|
||||
key: cache-cabal-${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('**/*.cabal', '**/cabal.project') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.ghc }}-
|
||||
cache-cabal-${{ runner.os }}-${{ matrix.ghc }}-
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cabal update
|
||||
@@ -260,7 +295,7 @@ jobs:
|
||||
GITHUB_COMMIT: ${{ github.sha }}
|
||||
GHC_VERSION: ${{ matrix.ghc }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- id: Remote-Dir
|
||||
name: Unique directory name for the remote build
|
||||
run: echo "remotepath=postgrest-build-$(uuidgen)" >> "$GITHUB_OUTPUT"
|
||||
@@ -324,7 +359,7 @@ jobs:
|
||||
version: ${{ steps.Identify-Version.outputs.version }}
|
||||
isprerelease: ${{ steps.Identify-Version.outputs.isprerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- id: Identify-Version
|
||||
name: Identify the version to be released
|
||||
run: |
|
||||
@@ -377,7 +412,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.Prepare-Release.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
@@ -443,7 +478,7 @@ jobs:
|
||||
VERSION: ${{ needs.Prepare-Release.outputs.version }}
|
||||
ISPRERELEASE: ${{ needs.Prepare-Release.outputs.isprerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
@@ -496,7 +531,7 @@ jobs:
|
||||
VERSION: ${{ needs.Prepare-Release.outputs.version }}
|
||||
ISPRERELEASE: ${{ needs.Prepare-Release.outputs.isprerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Publish images for ARM builds on Docker Hub
|
||||
uses: appleboy/ssh-action@master
|
||||
env:
|
||||
@@ -523,7 +558,7 @@ jobs:
|
||||
env:
|
||||
REMOTE_DIR: ${{ needs.Build-Cabal-Arm.outputs.remotepath }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Remove uploaded files from server
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
|
||||
@@ -11,20 +11,29 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
Loadtest-Nix:
|
||||
name: Loadtest (Nix)
|
||||
Loadtest-PR-Nix:
|
||||
name: Loadtest PR (Nix)
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
tools: loadtest
|
||||
cache-id: test-loadtest
|
||||
- uses: actions-ecosystem/action-get-latest-tag@v1
|
||||
id: get-latest-tag
|
||||
with:
|
||||
semver_only: true
|
||||
- name: Run loadtest
|
||||
run: |
|
||||
postgrest-loadtest-against main
|
||||
postgrest-loadtest-against main ${{ steps.get-latest-tag.outputs.tag }}
|
||||
postgrest-loadtest-report > loadtest/loadtest.md
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@v3
|
||||
@@ -32,3 +41,32 @@ jobs:
|
||||
name: loadtest.md
|
||||
path: loadtest/loadtest.md
|
||||
if-no-files-found: error
|
||||
|
||||
Loadtest-Merge-Nix:
|
||||
name: Loadtest Merge (Nix)
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions-ecosystem/action-get-latest-tag@v1
|
||||
id: get-latest-tag
|
||||
with:
|
||||
semver_only: true
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
tools: loadtest
|
||||
cache-id: test-loadtest
|
||||
- name: Run loadtest
|
||||
run: |
|
||||
postgrest-loadtest-against ${{ steps.get-latest-tag.outputs.tag }}
|
||||
postgrest-loadtest-report > loadtest/loadtest.md
|
||||
- name: Upload report
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: loadtest.md
|
||||
path: loadtest/loadtest.md
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
+31
-20
@@ -4,42 +4,38 @@ PostgREST ongoing development is only possible thanks to our Sponsors and Backer
|
||||
|
||||
## Sponsors
|
||||
|
||||
<table>
|
||||
<table align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.cybertec-postgresql.com/en/?utm_source=postgrest.org&utm_medium=referral&utm_campaign=postgrest" target="_blank">
|
||||
<img width="222px" src="static/cybertec-new.png">
|
||||
<img width="296px" src="static/cybertec-new.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="296px" 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="296px" src="static/retool.png">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr></tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://gnuhost.eu/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="static/gnuhost.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://supabase.io?utm_source=postgrest%20backers&utm_medium=open%20source%20partner&utm_campaign=postgrest%20backers%20github&utm_term=homepage" target="_blank">
|
||||
<img width="296px" src="static/supabase.png">
|
||||
<a href="https://neon.tech/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="static/neon.jpg">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr></tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://code.build/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="static/code-build.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://oblivious.ai/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="static/oblivious.jpg">
|
||||
<a href="https://supabase.io?utm_source=postgrest%20backers&utm_medium=open%20source%20partner&utm_campaign=postgrest%20backers%20github&utm_term=homepage" target="_blank">
|
||||
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/supabase.png">
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -75,7 +71,22 @@ PostgREST ongoing development is only possible thanks to our Sponsors and Backer
|
||||
<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">
|
||||
<img width="222px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/timescaledb.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img max-width="222px" height="88" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/retool.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://oblivious.ai/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="222px" src="static/oblivious.jpg">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -5,6 +5,77 @@ This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
## [12.0.1] - 2023-12-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- #3054, Fix not allowing special characters in JSON keys - @laurenceisla
|
||||
- #2344, Replace JSON parser error with a clearer generic message - @develop7
|
||||
- #3100, Add missing in-database configuration option for `jwt-cache-max-lifetime` - @laurenceisla
|
||||
- #3089, The any media type handler now sets `Content-Type: application/octet-stream` by default instead of `Content-Type: application/json` - @steve-chavez
|
||||
|
||||
## [12.0.0] - 2023-12-01
|
||||
|
||||
### Added
|
||||
|
||||
- #1614, Add `db-pool-automatic-recovery` configuration to disable connection retrying - @taimoorzaeem
|
||||
- #2492, Allow full response control when raising exceptions - @taimoorzaeem, @laurenceisla
|
||||
- #2771, #2983, #3062, #3055 Add `Server-Timing` response header - @taimoorzaeem, @develop7, @laurenceisla
|
||||
- #2698, Add config `jwt-cache-max-lifetime` and implement JWT caching - @taimoorzaeem
|
||||
- #2943, Add `handling=strict/lenient` for Prefer header - @taimoorzaeem
|
||||
- #2441, Add config `server-cors-allowed-origins` to specify CORS origins - @taimoorzaeem
|
||||
- #2825, SQL handlers for custom media types - @steve-chavez
|
||||
+ Solves #1548, #2699, #2763, #2170, #1462, #1102, #1374, #2901
|
||||
- #2799, Add timezone in Prefer header - @taimoorzaeem
|
||||
- #3001, Add `statement_timeout` set on functions - @taimoorzaeem
|
||||
- #3045, Apply superuser settings on impersonated roles if they have PostgreSQL 15 `GRANT SET ON PARAMETER` privilege - @steve-chavez
|
||||
- #915, Add support for aggregate functions - @timabdulla
|
||||
+ The aggregate functions SUM(), MAX(), MIN(), AVG(), and COUNT() are now supported.
|
||||
+ It's disabled by default, you can enable it with `db-aggregates-enabled`.
|
||||
- #3057, Log all internal database errors to stderr - @laurenceisla
|
||||
|
||||
### Fixed
|
||||
|
||||
- #3015, Fix unnecessary count() on RPC returning single - @steve-chavez
|
||||
- #1070, Fix HTTP status responses for upserts - @taimoorzaeem
|
||||
+ `PUT` returns `201` instead of `200` when rows are inserted
|
||||
+ `POST` with `Prefer: resolution=merge-duplicates` returns `200` instead of `201` when no rows are inserted
|
||||
- #3019, Transaction-Scoped Settings are now shown clearly in the Postgres logs - @laurenceisla
|
||||
+ Shows `set_config('pgrst.setting_name', $1)` instead of `setconfig($1, $2)`
|
||||
+ Does not apply to role settings and `app.settings.*`
|
||||
- #2420, Fix bogus message when listening on port 0 - @develop7
|
||||
- #3067, Fix Acquision Timeout errors logging to stderr when `log-level=crit` - @laurenceisla
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed [raw-media-types config](https://postgrest.org/en/v11.1/references/configuration.html#raw-media-types) - @steve-chavez
|
||||
- Removed `application/octet-stream`, `text/plain`, `text/xml` [builtin support for scalar results](https://postgrest.org/en/v11.1/references/api/resource_representation.html#scalar-function-response-format) - @steve-chavez
|
||||
- Removed default `application/openapi+json` media type for [db-root-spec](https://postgrest.org/en/v11.1/references/configuration.html#db-root-spec) - @steve-chavez
|
||||
- Removed [db-use-legacy-gucs](https://postgrest.org/en/v11.2/references/configuration.html#db-use-legacy-gucs) - @laurenceisla
|
||||
|
||||
## [11.2.2] - 2023-10-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- #2824, Fix regression by reverting fix that returned 206 when first position = length in a `Range` header - @laurenceisla, @strengthless
|
||||
|
||||
## [11.2.1] - 2023-10-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- #2899, Fix `application/vnd.pgrst.array` not accepted as a valid mediatype - @taimoorzaeem
|
||||
- #2524, Fix schema cache and configuration reloading with `NOTIFY` not working on Windows - @diogob, @laurenceisla
|
||||
- #2915, Fix duplicate headers in response - @taimoorzaeem
|
||||
- #2824, Fix range request with first position same as length return status 206 - @taimoorzaeem
|
||||
- #2939, Fix wrong `Preference-Applied` with `Prefer: tx=commit` when transaction is rollbacked - @steve-chavez
|
||||
- #2939, Fix `count=exact` not being included in `Preference-Applied` - @steve-chavez
|
||||
- #2800, Fix not including to-one embed resources that had a `NULL` value in any of the selected fields when doing null filtering on them - @laurenceisla
|
||||
- #2846, Fix error when requesting `Prefer: count=<type>` and doing null filtering on embedded resources - @laurenceisla
|
||||
- #2959, Fix setting `default_transaction_isolation` unnecessarily - @steve-chavez
|
||||
- #2929, Fix arrow filtering on RPC returning dynamic TABLE with composite type - @steve-chavez
|
||||
- #2963, Fix RPCs not embedding correctly when using overloaded functions for computed relationships - @laurenceisla
|
||||
- #2970, Fix regression that rejects URI connection strings with certain unescaped characters in the password - @laurenceisla, @steve-chavez
|
||||
|
||||
## [11.2.0] - 2023-08-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -15,42 +15,38 @@ API than you are likely to write from scratch.
|
||||
|
||||
## Sponsors
|
||||
|
||||
<table>
|
||||
<table align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.cybertec-postgresql.com/en/?utm_source=postgrest.org&utm_medium=referral&utm_campaign=postgrest" target="_blank">
|
||||
<img width="222px" src="static/cybertec-new.png">
|
||||
<img width="296px" src="static/cybertec-new.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="296px" 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="296px" src="static/retool.png">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr></tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://gnuhost.eu/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="static/gnuhost.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://supabase.io?utm_source=postgrest%20backers&utm_medium=open%20source%20partner&utm_campaign=postgrest%20backers%20github&utm_term=homepage" target="_blank">
|
||||
<img width="296px" src="static/supabase.png">
|
||||
<a href="https://neon.tech/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="static/neon.jpg">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr></tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://code.build/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="static/code-build.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://oblivious.ai/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="static/oblivious.jpg">
|
||||
<a href="https://supabase.io?utm_source=postgrest%20backers&utm_medium=open%20source%20partner&utm_campaign=postgrest%20backers%20github&utm_term=homepage" target="_blank">
|
||||
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/supabase.png">
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
index-state: hackage.haskell.org 2023-10-13T13:54:33Z
|
||||
+13
@@ -50,6 +50,19 @@ let
|
||||
|
||||
postgresqlVersions =
|
||||
[
|
||||
{
|
||||
name = "postgresql-16";
|
||||
postgresql = pkgs.postgresql_16.withPackages (p: [
|
||||
p.postgis
|
||||
(p.pg_safeupdate.overrideAttrs (old: {
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin
|
||||
cp safeupdate.dylib safeupdate.so || true
|
||||
install -D safeupdate.so -t $out/lib
|
||||
'';
|
||||
}))
|
||||
]);
|
||||
}
|
||||
{ name = "postgresql-15"; postgresql = pkgs.postgresql_15.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||
{ name = "postgresql-14"; postgresql = pkgs.postgresql_14.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||
{ name = "postgresql-13"; postgresql = pkgs.postgresql_13.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||
|
||||
+1
-22
@@ -1,37 +1,16 @@
|
||||
{-# LANGUAGE CPP #-}
|
||||
|
||||
module Main (main) where
|
||||
|
||||
import System.IO (BufferMode (..), hSetBuffering)
|
||||
|
||||
import qualified PostgREST.App as App
|
||||
import qualified PostgREST.CLI as CLI
|
||||
|
||||
import Protolude
|
||||
|
||||
#ifndef mingw32_HOST_OS
|
||||
import qualified PostgREST.Unix as Unix
|
||||
#endif
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
setBuffering
|
||||
opts <- CLI.readCLIShowHelp
|
||||
CLI.main installSignalHandlers runAppInSocket opts
|
||||
|
||||
installSignalHandlers :: App.SignalHandlerInstaller
|
||||
#ifndef mingw32_HOST_OS
|
||||
installSignalHandlers = Unix.installSignalHandlers
|
||||
#else
|
||||
installSignalHandlers _ = pass
|
||||
#endif
|
||||
|
||||
runAppInSocket :: Maybe App.SocketRunner
|
||||
#ifndef mingw32_HOST_OS
|
||||
runAppInSocket = Just Unix.runAppWithSocket
|
||||
#else
|
||||
runAppInSocket = Nothing
|
||||
#endif
|
||||
CLI.main opts
|
||||
|
||||
setBuffering :: IO ()
|
||||
setBuffering = do
|
||||
|
||||
+34
-9
@@ -22,6 +22,15 @@ build the `postgrestPackage` 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.
|
||||
|
||||
You can also build a statically linked binary with:
|
||||
|
||||
```bash
|
||||
$ nix-build --attr postgrestStatic
|
||||
|
||||
$ ldd result/bin/postgrest
|
||||
$ not a dynamic executable
|
||||
```
|
||||
|
||||
## Binary cache
|
||||
|
||||
We recommend that you use the PostgREST binary cache on
|
||||
@@ -82,7 +91,7 @@ Some additional modules like `memory`, `docker` and `release`
|
||||
have large dependencies that would need to be built before the shell becomes
|
||||
available, which could take an especially long time if the cachix binary cache
|
||||
is not used. You can activate those by passing a flag to `nix-shell` with
|
||||
`nix-shell --arg <module> true`. This will make the respective utilites available:
|
||||
`nix-shell --arg <module> true`. This will make the respective utilities available:
|
||||
|
||||
```bash
|
||||
$ nix-shell --arg memory true
|
||||
@@ -104,7 +113,7 @@ postgrest-test-memory
|
||||
Note that `postgrest-test-memory` is now also available.
|
||||
|
||||
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
|
||||
will launch 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.
|
||||
|
||||
@@ -212,15 +221,14 @@ doctests for some of our modules are also available:
|
||||
|
||||
## Code coverage
|
||||
|
||||
Code coverage is available under the `postgrest-coverage` command. This will produce a `./coverage` directory that can be visualized with a simple http server.
|
||||
Code coverage is available under the `postgrest-coverage` command. This will produce a `./coverage` directory that can be visualized on a browser.
|
||||
|
||||
```bash
|
||||
# Will run all the tests and produce a coverage dir
|
||||
[nix-shell]$ postgrest-coverage
|
||||
...
|
||||
|
||||
# Visualize the output
|
||||
[nix-shell]$ cd coverage
|
||||
[nix-shell]$ python -mSimpleHTTPServer 8080
|
||||
postgrest-coverage: To see the results, visit file://$(pwd)/coverage/check/hpc_index.html
|
||||
```
|
||||
|
||||
## Linting and styling code
|
||||
@@ -238,11 +246,11 @@ $ nix-shell --run postgrest-style
|
||||
```
|
||||
|
||||
There is also `postgrest-style-check` that exits with a non-zero exit code if
|
||||
the check resulted in any uncommited changes. It's mostly useful for CI.
|
||||
the check resulted in any uncommitted changes. It's mostly useful for CI.
|
||||
|
||||
## General development tools
|
||||
|
||||
Tools like `postgrest-build`, `postgrest-run` etc. are simple wrappers around
|
||||
Tools like `postgrest-build`, `postgrest-run`, `postgrest-repl` etc. are simple wrappers around
|
||||
`cabal` and should do what you expect. `postgrest-check` runs most checks that will
|
||||
also run in CI, with the exception of the IO and Memory checks that need to be run
|
||||
separately.
|
||||
@@ -256,6 +264,23 @@ run against the latest PostgreSQL version by default.
|
||||
file is changed. For example, `postgrest-watch postgrest-with-all postgrest-test-spec`
|
||||
will re-run the full spec test suite against all PostgreSQL versions on every change.
|
||||
|
||||
## REPL
|
||||
|
||||
You can use `postgrest-repl` to manually inspect the PostgREST modules.
|
||||
|
||||
```bash
|
||||
$ postgrest-repl
|
||||
|
||||
ghci> import PostgREST.<tab>
|
||||
PostgREST.Admin PostgREST.Config.Database PostgREST.Plan.MutatePlan PostgREST.Response.OpenAPI
|
||||
PostgREST.ApiRequest PostgREST.Config.JSPath PostgREST.Plan.ReadPlan PostgREST.SchemaCache
|
||||
...
|
||||
|
||||
ghci> import PostgREST.MediaType
|
||||
ghci> decodeMediaType "application/json"
|
||||
MTApplicationJSON
|
||||
```
|
||||
|
||||
## Tour
|
||||
|
||||
The following is not required for working on PostgREST with Nix, but it will
|
||||
@@ -284,7 +309,7 @@ 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`
|
||||
built and developed. It extends the build environment from our `postgrest`
|
||||
attribute with useful utilities that will be put on the PATH in `nix-shell`.
|
||||
|
||||
### `nix/overlays`
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ required to avoid build timeouts in CI.
|
||||
|
||||
You'll need to set the `CACHIX_SIGNING_KEY` before proceeding, e.g. by creating
|
||||
a file containing `export CACHIX_SIGNING_KEY=...` and sourcing that file, which
|
||||
avoids having the secret in you shell history.
|
||||
avoids having the secret in your shell history.
|
||||
|
||||
To push all new artifacts to Cachix, run:
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ let
|
||||
(prev.callHackageDirect
|
||||
{
|
||||
pkg = "hasql-notifications";
|
||||
ver = "0.2.0.5";
|
||||
sha256 = "sha256-KV36zs/RTgJh/oBBPZaNiQshusmakWnjYTcdoNa4JLA=";
|
||||
ver = "0.2.0.6";
|
||||
sha256 = "sha256-7PyFlB2B70njudOjaX6tk1m77ol9vnF5fI0LF86kVAI=";
|
||||
}
|
||||
{ });
|
||||
|
||||
|
||||
@@ -4,16 +4,16 @@ self: super:
|
||||
{
|
||||
## Example for including a postgresql version from a specific nixpks commit:
|
||||
##
|
||||
# postgresql_14 =
|
||||
# let
|
||||
# rev = "76b1e16c6659ccef7187ca69b287525fea133244";
|
||||
# tarballHash = "1vsahpcx80k2bgslspb0sa6j4bmhdx77sw6la455drqcrqhdqj6a";
|
||||
#
|
||||
# pinnedPkgs =
|
||||
# builtins.fetchTarball {
|
||||
# url = "https://github.com/nixos/nixpkgs/archive/${rev}.tar.gz";
|
||||
# sha256 = tarballHash;
|
||||
# };
|
||||
# in
|
||||
# (import pinnedPkgs { }).pkgs.postgresql_14;
|
||||
postgresql_16 =
|
||||
let
|
||||
rev = "5148520bfab61f99fd25fb9ff7bfbb50dad3c9db";
|
||||
tarballHash = "1dfjmz65h8z4lk845724vypzmf3dbgsdndjpj8ydlhx6c7rpcq3p";
|
||||
|
||||
pinnedPkgs =
|
||||
builtins.fetchTarball {
|
||||
url = "https://github.com/nixos/nixpkgs/archive/${rev}.tar.gz";
|
||||
sha256 = tarballHash;
|
||||
};
|
||||
in
|
||||
(import pinnedPkgs { }).pkgs.postgresql_16;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,18 @@ let
|
||||
postgrest "''${_arg_leftovers[@]}"
|
||||
'';
|
||||
|
||||
repl =
|
||||
checkedShellScript
|
||||
{
|
||||
name = "postgrest-repl";
|
||||
docs = "Interact with PostgREST modules using the cabal repl";
|
||||
args = [ "ARG_LEFTOVERS([cabal v2-repl arguments])" ];
|
||||
inRootDir = true;
|
||||
withEnv = postgrest.env;
|
||||
}
|
||||
''
|
||||
exec ${cabal-install}/bin/cabal v2-repl "''${_arg_leftovers[@]}"
|
||||
'';
|
||||
in
|
||||
buildToolbox
|
||||
{
|
||||
@@ -65,5 +77,6 @@ buildToolbox
|
||||
build
|
||||
clean
|
||||
run
|
||||
repl
|
||||
];
|
||||
}
|
||||
|
||||
+11
-8
@@ -76,13 +76,12 @@ let
|
||||
inherit name;
|
||||
docs =
|
||||
''
|
||||
Run the vegeta loadtest twice:
|
||||
- once on the <target> branch
|
||||
Run the vegeta loadtest against every target branch and HEAD:
|
||||
- once on the every <target-#> branch
|
||||
- once in the current worktree
|
||||
'';
|
||||
args = [
|
||||
"ARG_POSITIONAL_SINGLE([target], [Commit-ish reference to compare with])"
|
||||
"ARG_LEFTOVERS([additional vegeta arguments])"
|
||||
"ARG_POSITIONAL_INF([target], [Commit-ish reference to compare with], 1)"
|
||||
];
|
||||
positionalCompletion =
|
||||
''
|
||||
@@ -93,9 +92,11 @@ let
|
||||
inRootDir = true;
|
||||
}
|
||||
''
|
||||
for tgt in "''${_arg_target[@]}"; do
|
||||
|
||||
cat << EOF
|
||||
|
||||
Running loadtest on "$_arg_target"...
|
||||
Running loadtest on "$tgt"...
|
||||
|
||||
EOF
|
||||
|
||||
@@ -104,21 +105,23 @@ let
|
||||
# Save the results in the current working tree, too,
|
||||
# otherwise they'd be lost in the temporary working tree
|
||||
# created by withTools.withGit.
|
||||
${withTools.withGit} "$_arg_target" ${loadtest} --output "$PWD/loadtest/$_arg_target.bin" --testdir "$PWD/test/load" "''${_arg_leftovers[@]}"
|
||||
${withTools.withGit} "$tgt" ${loadtest} --output "$PWD/loadtest/$tgt.bin" --testdir "$PWD/test/load"
|
||||
|
||||
cat << EOF
|
||||
|
||||
Done running on "$_arg_target".
|
||||
Done running on "$tgt".
|
||||
|
||||
EOF
|
||||
|
||||
done
|
||||
|
||||
cat << EOF
|
||||
|
||||
Running loadtest on HEAD...
|
||||
|
||||
EOF
|
||||
|
||||
${loadtest} --output "$PWD/loadtest/head.bin" --testdir "$PWD/test/load" "''${_arg_leftovers[@]}"
|
||||
${loadtest} --output "$PWD/loadtest/head.bin" --testdir "$PWD/test/load"
|
||||
|
||||
cat << EOF
|
||||
|
||||
|
||||
@@ -56,8 +56,8 @@ let
|
||||
inRootDir = true;
|
||||
}
|
||||
''
|
||||
trap "echo You need to be on the main branch to proceed. Exiting ..." ERR
|
||||
[ "$(git rev-parse --abbrev-ref HEAD)" == "main" ]
|
||||
trap "echo You need to be on the main branch or a release branch to proceed. Exiting ..." ERR
|
||||
[[ "$(git rev-parse --abbrev-ref HEAD)" =~ ^main$|^rel- ]]
|
||||
trap "" ERR
|
||||
|
||||
trap "echo You have uncommitted changes in postgrest.cabal. Exiting ..." ERR
|
||||
@@ -111,7 +111,7 @@ let
|
||||
remote="$(git remote -v | grep PostgREST/postgrest | grep push | cut -f1)"
|
||||
trap "" ERR
|
||||
|
||||
push="git push --atomic $remote main v$new_version"
|
||||
push="git push --atomic $remote $(git rev-parse --abbrev-ref HEAD) v$new_version"
|
||||
|
||||
echo "To push both the branch and the new tag, the following will be run:"
|
||||
echo
|
||||
|
||||
@@ -12,30 +12,30 @@ write from scratch.
|
||||
|
||||
## Sponsors
|
||||
|
||||
<table>
|
||||
<table align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://www.cybertec-postgresql.com/en/?utm_source=postgrest.org&utm_medium=referral&utm_campaign=postgrest" target="_blank">
|
||||
<img width="222px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/cybertec-new.png">
|
||||
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/cybertec-new.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="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/2ndquadrant.png">
|
||||
<a href="https://gnuhost.eu/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/gnuhost.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://tryretool.com/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/retool.png">
|
||||
<a href="https://neon.tech/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/neon.jpg">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr></tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://gnuhost.eu/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/gnuhost.png">
|
||||
<a href="https://code.build/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/code-build.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
@@ -43,11 +43,7 @@ write from scratch.
|
||||
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/supabase.png">
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" valign="middle">
|
||||
<a href="https://oblivious.ai/?utm_source=sponsor&utm_campaign=postgrest" target="_blank">
|
||||
<img width="296px" src="https://raw.githubusercontent.com/PostgREST/postgrest/main/static/oblivious.jpg">
|
||||
</a>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -58,13 +54,13 @@ To learn how to use this container, see the [PostgREST Docker
|
||||
documentation](https://postgrest.org/en/stable/install.html#docker).
|
||||
|
||||
You can configure the PostgREST image by setting
|
||||
[enviroment variables](https://postgrest.org/en/stable/configuration.html).
|
||||
[environment variables](https://postgrest.org/en/stable/configuration.html).
|
||||
|
||||
# How this image is built
|
||||
|
||||
The image is built from scratch using
|
||||
[Nix](https://nixos.org/nixpkgs/manual/#sec-pkgs-dockerTools) instead of a
|
||||
`Dockerfile`, which yields a higly secure and optimized image. This is also why
|
||||
`Dockerfile`, which yields a highly secure and optimized image. This is also why
|
||||
no commands are listed in the image history. See the [PostgREST
|
||||
respository](https://github.com/PostgREST/postgrest/tree/main/nix/tools/docker) for
|
||||
details on the build process and how to inspect the image.
|
||||
|
||||
+2
-2
@@ -164,7 +164,7 @@ let
|
||||
${ghc}/bin/hpc markup --highlight-covered --destdir=coverage/overlay "$tmpdir"/overlay.tix || true
|
||||
${ghc}/bin/hpc markup --highlight-covered --destdir=coverage/check "$tmpdir"/check.tix || true
|
||||
echo "ERROR: Something is covered by both the tests and the overlay:"
|
||||
echo "file://$(pwd)/coverage/check/hpc_index.html"
|
||||
echo "postgrest-coverage: To see the results, visit file://$(pwd)/coverage/check/hpc_index.html"
|
||||
exit 1
|
||||
else
|
||||
# copy the result .tix file to the coverage/ dir to make it available to postgrest-coverage-draft-overlay, too
|
||||
@@ -174,7 +174,7 @@ let
|
||||
|
||||
# create html and stdout reports
|
||||
${ghc}/bin/hpc markup --destdir=coverage coverage/postgrest.tix
|
||||
echo "file://$(pwd)/coverage/hpc_index.html"
|
||||
echo "postgrest-coverage: To see the results, visit file://$(pwd)/coverage/hpc_index.html"
|
||||
${ghc}/bin/hpc report coverage/postgrest.tix "''${_arg_leftovers[@]}"
|
||||
fi
|
||||
''
|
||||
|
||||
@@ -72,7 +72,7 @@ let
|
||||
# We try to make the database cluster as independent as possible from the host
|
||||
# by specifying the timezone, locale and encoding.
|
||||
# initdb -U creates a superuser(man initdb)
|
||||
PGTZ=UTC initdb --no-locale --encoding=UTF8 --nosync -U "${superuserRole}" --auth=trust \
|
||||
TZ=$PGTZ initdb --no-locale --encoding=UTF8 --nosync -U "${superuserRole}" --auth=trust \
|
||||
>> "$setuplog"
|
||||
|
||||
log "Starting the database cluster..."
|
||||
|
||||
+17
-10
@@ -1,5 +1,5 @@
|
||||
name: postgrest
|
||||
version: 11.2.0
|
||||
version: 12.0.1
|
||||
synopsis: REST API for any Postgres database
|
||||
description: Reads the schema of a PostgreSQL database and creates RESTful routes
|
||||
for tables, views, and functions, supporting all HTTP methods that security
|
||||
@@ -64,6 +64,7 @@ library
|
||||
PostgREST.Plan.ReadPlan
|
||||
PostgREST.Plan.Types
|
||||
PostgREST.RangeQuery
|
||||
PostgREST.Unix
|
||||
PostgREST.ApiRequest
|
||||
PostgREST.ApiRequest.Preferences
|
||||
PostgREST.ApiRequest.QueryParams
|
||||
@@ -71,6 +72,7 @@ library
|
||||
PostgREST.Response
|
||||
PostgREST.Response.OpenAPI
|
||||
PostgREST.Response.GucHeader
|
||||
PostgREST.Response.Performance
|
||||
PostgREST.Version
|
||||
other-modules: Paths_postgrest
|
||||
build-depends: base >= 4.9 && < 4.17
|
||||
@@ -80,19 +82,22 @@ library
|
||||
, auto-update >= 0.1.4 && < 0.2
|
||||
, base64-bytestring >= 1 && < 1.3
|
||||
, bytestring >= 0.10.8 && < 0.12
|
||||
, cache >= 0.1.3 && < 0.2.0
|
||||
, case-insensitive >= 1.2 && < 1.3
|
||||
, cassava >= 0.4.5 && < 0.6
|
||||
, clock >= 0.8.3 && < 0.9.0
|
||||
, configurator-pg >= 0.2 && < 0.3
|
||||
, containers >= 0.5.7 && < 0.7
|
||||
, contravariant-extras >= 0.3.3 && < 0.4
|
||||
, cookie >= 0.4.2 && < 0.5
|
||||
, directory >= 1.2.6 && < 1.4
|
||||
, either >= 4.4.1 && < 5.1
|
||||
, extra >= 1.7.0 && < 2.0
|
||||
, fuzzyset >= 0.2.3
|
||||
, gitrev >= 1.2 && < 1.4
|
||||
, hasql >= 1.6.1.1 && < 1.7
|
||||
, hasql-dynamic-statements >= 0.3.1 && < 0.4
|
||||
, hasql-notifications >= 0.1 && < 0.3
|
||||
, hasql-notifications >= 0.2.0.6 && < 0.3
|
||||
, hasql-pool >= 0.10 && < 0.11
|
||||
, hasql-transaction >= 1.0.1 && < 1.1
|
||||
, heredoc >= 0.2 && < 0.3
|
||||
@@ -111,10 +116,13 @@ library
|
||||
, regex-tdfa >= 1.2.2 && < 1.4
|
||||
, retry >= 0.7.4 && < 0.10
|
||||
, scientific >= 0.3.4 && < 0.4
|
||||
, streaming-commons >= 0.1.1 && < 0.3
|
||||
, swagger2 >= 2.4 && < 2.9
|
||||
, text >= 1.2.2 && < 1.3
|
||||
, time >= 1.6 && < 1.12
|
||||
, timeit >= 2.0 && < 2.1
|
||||
, unordered-containers >= 0.2.8 && < 0.3
|
||||
, unix-compat >= 0.5.4 && < 0.6
|
||||
, vault >= 0.3.1.5 && < 0.4
|
||||
, vector >= 0.11 && < 0.14
|
||||
, wai >= 3.2.1 && < 3.3
|
||||
@@ -144,9 +152,6 @@ library
|
||||
if !os(windows)
|
||||
build-depends:
|
||||
unix
|
||||
, directory >= 1.2.6 && < 1.4
|
||||
exposed-modules:
|
||||
PostgREST.Unix
|
||||
|
||||
executable postgrest
|
||||
default-language: Haskell2010
|
||||
@@ -188,7 +193,6 @@ test-suite spec
|
||||
Feature.ConcurrentSpec
|
||||
Feature.CorsSpec
|
||||
Feature.ExtraSearchPathSpec
|
||||
Feature.LegacyGucsSpec
|
||||
Feature.NoSuperuserSpec
|
||||
Feature.ObservabilitySpec
|
||||
Feature.OpenApi.DisabledOpenApiSpec
|
||||
@@ -198,27 +202,30 @@ test-suite spec
|
||||
Feature.OpenApi.RootSpec
|
||||
Feature.OpenApi.SecurityOpenApiSpec
|
||||
Feature.OptionsSpec
|
||||
Feature.Query.AggregateFunctionsSpec
|
||||
Feature.Query.AndOrParamsSpec
|
||||
Feature.Query.ComputedRelsSpec
|
||||
Feature.Query.CustomMediaSpec
|
||||
Feature.Query.DeleteSpec
|
||||
Feature.Query.EmbedDisambiguationSpec
|
||||
Feature.Query.EmbedInnerJoinSpec
|
||||
Feature.Query.PlanSpec
|
||||
Feature.Query.HtmlRawOutputSpec
|
||||
Feature.Query.ErrorSpec
|
||||
Feature.Query.InsertSpec
|
||||
Feature.Query.JsonOperatorSpec
|
||||
Feature.Query.MultipleSchemaSpec
|
||||
Feature.Query.ErrorSpec
|
||||
Feature.Query.NullsStripSpec
|
||||
Feature.Query.PgSafeUpdateSpec
|
||||
Feature.Query.PlanSpec
|
||||
Feature.Query.PostGISSpec
|
||||
Feature.Query.PreferencesSpec
|
||||
Feature.Query.QueryLimitedSpec
|
||||
Feature.Query.QuerySpec
|
||||
Feature.Query.RangeSpec
|
||||
Feature.Query.RawOutputTypesSpec
|
||||
Feature.Query.RelatedQueriesSpec
|
||||
Feature.Query.RpcSpec
|
||||
Feature.Query.ServerTimingSpec
|
||||
Feature.Query.SingularSpec
|
||||
Feature.Query.NullsStrip
|
||||
Feature.Query.SpreadQueriesSpec
|
||||
Feature.Query.UnicodeSpec
|
||||
Feature.Query.UpdateSpec
|
||||
|
||||
+17
-40
@@ -1,11 +1,9 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
|
||||
module PostgREST.Admin
|
||||
( runAdmin
|
||||
) where
|
||||
|
||||
import qualified Data.Text as T
|
||||
import qualified Hasql.Session as SQL
|
||||
import qualified Network.HTTP.Types.Status as HTTP
|
||||
import qualified Network.Wai as Wai
|
||||
@@ -22,24 +20,25 @@ import PostgREST.Config (AppConfig (..))
|
||||
import qualified PostgREST.AppState as AppState
|
||||
|
||||
import Protolude
|
||||
import Protolude.Partial (fromJust)
|
||||
|
||||
runAdmin :: AppConfig -> AppState -> Warp.Settings -> IO ()
|
||||
runAdmin conf@AppConfig{configAdminServerPort} appState settings =
|
||||
whenJust configAdminServerPort $ \adminPort -> do
|
||||
AppState.logWithZTime appState $ "Admin server listening on port " <> show adminPort
|
||||
void . forkIO $ Warp.runSettings (settings & Warp.setPort adminPort) adminApp
|
||||
whenJust (AppState.getSocketAdmin appState) $ \adminSocket -> do
|
||||
AppState.logWithZTime appState $ "Admin server listening on port " <> show (fromIntegral (fromJust configAdminServerPort) :: Integer)
|
||||
void . forkIO $ Warp.runSettingsSocket settings adminSocket adminApp
|
||||
where
|
||||
adminApp = admin appState conf
|
||||
|
||||
-- | PostgREST admin application
|
||||
admin :: AppState.AppState -> AppConfig -> Wai.Application
|
||||
admin appState appConfig req respond = do
|
||||
isMainAppReachable <- any isRight <$> reachMainApp appConfig
|
||||
isMainAppReachable <- isRight <$> reachMainApp (AppState.getSocketREST appState)
|
||||
isSchemaCacheLoaded <- isJust <$> AppState.getSchemaCache appState
|
||||
isConnectionUp <-
|
||||
if configDbChannelEnabled appConfig
|
||||
then AppState.getIsListenerOn appState
|
||||
else isRight <$> AppState.usePool appState (SQL.sql "SELECT 1")
|
||||
else isRight <$> AppState.usePool appState appConfig (SQL.sql "SELECT 1")
|
||||
|
||||
case Wai.pathInfo req of
|
||||
["ready"] ->
|
||||
@@ -51,37 +50,15 @@ admin appState appConfig req respond = do
|
||||
|
||||
-- Try to connect to the main app socket
|
||||
-- Note that it doesn't even send a valid HTTP request, we just want to check that the main app is accepting connections
|
||||
-- The code for resolving the "*4", "!4", "*6", "!6", "*" special values is taken from
|
||||
-- https://hackage.haskell.org/package/streaming-commons-0.2.2.4/docs/src/Data.Streaming.Network.html#bindPortGenEx
|
||||
reachMainApp :: AppConfig -> IO [Either IOException ()]
|
||||
reachMainApp AppConfig{..} =
|
||||
case configServerUnixSocket of
|
||||
Just path -> do
|
||||
sock <- socket AF_UNIX Stream 0
|
||||
(:[]) <$> try (do
|
||||
connect sock $ SockAddrUnix path
|
||||
withSocketsDo $ bracket (pure sock) close sendEmpty)
|
||||
Nothing -> do
|
||||
let
|
||||
host | configServerHost `elem` ["*4", "!4", "*6", "!6", "*"] = Nothing
|
||||
| otherwise = Just configServerHost
|
||||
filterAddrs xs =
|
||||
case configServerHost of
|
||||
"*4" -> ipv4Addrs xs ++ ipv6Addrs xs
|
||||
"!4" -> ipv4Addrs xs
|
||||
"*6" -> ipv6Addrs xs ++ ipv4Addrs xs
|
||||
"!6" -> ipv6Addrs xs
|
||||
_ -> xs
|
||||
ipv4Addrs = filter ((/=) AF_INET6 . addrFamily)
|
||||
ipv6Addrs = filter ((==) AF_INET6 . addrFamily)
|
||||
|
||||
addrs <- getAddrInfo (Just $ defaultHints { addrSocketType = Stream }) (T.unpack <$> host) (Just . show $ configServerPort)
|
||||
tryAddr `traverse` filterAddrs addrs
|
||||
reachMainApp :: Socket -> IO (Either IOException ())
|
||||
reachMainApp appSock = do
|
||||
sockAddr <- getSocketName appSock
|
||||
sock <- socket (addrFamily sockAddr) Stream defaultProtocol
|
||||
try $ do
|
||||
connect sock sockAddr
|
||||
withSocketsDo $ bracket (pure sock) close sendEmpty
|
||||
where
|
||||
sendEmpty sock = void $ send sock mempty
|
||||
tryAddr :: AddrInfo -> IO (Either IOException ())
|
||||
tryAddr addr = do
|
||||
sock <- socket (addrFamily addr) (addrSocketType addr) (addrProtocol addr)
|
||||
try $ do
|
||||
connect sock $ addrAddress addr
|
||||
withSocketsDo $ bracket (pure sock) close sendEmpty
|
||||
addrFamily (SockAddrInet _ _) = AF_INET
|
||||
addrFamily (SockAddrInet6 {}) = AF_INET6
|
||||
addrFamily (SockAddrUnix _) = AF_UNIX
|
||||
|
||||
+12
-51
@@ -26,7 +26,6 @@ import qualified Data.ByteString.Lazy as LBS
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
import qualified Data.Csv as CSV
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import qualified Data.List as L
|
||||
import qualified Data.List.NonEmpty as NonEmptyList
|
||||
import qualified Data.Map.Strict as M
|
||||
import qualified Data.Set as S
|
||||
@@ -37,7 +36,7 @@ import Data.Either.Combinators (mapBoth)
|
||||
|
||||
import Control.Arrow ((***))
|
||||
import Data.Aeson.Types (emptyArray, emptyObject)
|
||||
import Data.List (lookup, union)
|
||||
import Data.List (lookup)
|
||||
import Data.Ranged.Ranges (emptyRange, rangeIntersection,
|
||||
rangeIsEmpty)
|
||||
import Network.HTTP.Types.Header (RequestHeaders, hCookie)
|
||||
@@ -51,12 +50,12 @@ import PostgREST.ApiRequest.Types (ApiRequestError (..),
|
||||
RangeError (..))
|
||||
import PostgREST.Config (AppConfig (..),
|
||||
OpenAPIMode (..))
|
||||
import PostgREST.MediaType (MTPlanFormat (..),
|
||||
MediaType (..))
|
||||
import PostgREST.MediaType (MediaType (..))
|
||||
import PostgREST.RangeQuery (NonnegRange, allRange,
|
||||
convertToLimitZeroRange,
|
||||
hasLimitZero,
|
||||
rangeRequested)
|
||||
import PostgREST.SchemaCache (SchemaCache (..))
|
||||
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||
QualifiedIdentifier (..),
|
||||
Schema)
|
||||
@@ -131,17 +130,16 @@ data ApiRequest = ApiRequest {
|
||||
, iMethod :: ByteString -- ^ Raw request method
|
||||
, iSchema :: Schema -- ^ The request schema. Can vary depending on profile headers.
|
||||
, iNegotiatedByProfile :: Bool -- ^ If schema was was chosen according to the profile spec https://www.w3.org/TR/dx-prof-conneg/
|
||||
, iAcceptMediaType :: MediaType -- ^ The media type in the Accept header
|
||||
, iAcceptMediaType :: [MediaType] -- ^ The resolved media types in the Accept, considering quality(q) factors
|
||||
, iContentMediaType :: MediaType -- ^ The media type in the Content-Type header
|
||||
}
|
||||
|
||||
-- | Examines HTTP request and translates it into user intent.
|
||||
userApiRequest :: AppConfig -> Request -> RequestBody -> Either ApiRequestError ApiRequest
|
||||
userApiRequest conf req reqBody = do
|
||||
userApiRequest :: AppConfig -> Request -> RequestBody -> SchemaCache -> Either ApiRequestError ApiRequest
|
||||
userApiRequest conf req reqBody sCache = do
|
||||
pInfo@PathInfo{..} <- getPathInfo conf $ pathInfo req
|
||||
act <- getAction pInfo method
|
||||
qPrms <- first QueryParamError $ QueryParams.parse (pathIsProc && act `elem` [ActionInvoke InvGet, ActionInvoke InvHead]) $ rawQueryString req
|
||||
(acceptMediaType, contentMediaType) <- getMediaTypes conf hdrs act pInfo
|
||||
(schema, negotiatedByProfile) <- getSchema conf hdrs method
|
||||
(topLevelRange, ranges) <- getRanges method qPrms hdrs
|
||||
(payload, columns) <- getPayload reqBody contentMediaType qPrms act pInfo
|
||||
@@ -153,7 +151,7 @@ userApiRequest conf req reqBody = do
|
||||
, iRange = ranges
|
||||
, iTopLevelRange = topLevelRange
|
||||
, iPayload = payload
|
||||
, iPreferences = Preferences.fromHeaders hdrs
|
||||
, iPreferences = Preferences.fromHeaders (configDbTxAllowOverride conf) (dbTimezones sCache) hdrs
|
||||
, iQueryParams = qPrms
|
||||
, iColumns = columns
|
||||
, iHeaders = iHdrs
|
||||
@@ -162,7 +160,7 @@ userApiRequest conf req reqBody = do
|
||||
, iMethod = method
|
||||
, iSchema = schema
|
||||
, iNegotiatedByProfile = negotiatedByProfile
|
||||
, iAcceptMediaType = acceptMediaType
|
||||
, iAcceptMediaType = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept"
|
||||
, iContentMediaType = contentMediaType
|
||||
}
|
||||
where
|
||||
@@ -171,6 +169,7 @@ userApiRequest conf req reqBody = do
|
||||
lookupHeader = flip lookup hdrs
|
||||
iHdrs = [ (CI.foldedCase k, v) | (k,v) <- hdrs, k /= hCookie]
|
||||
iCkies = maybe [] parseCookies $ lookupHeader "Cookie"
|
||||
contentMediaType = maybe MTApplicationJSON MediaType.decodeMediaType $ lookupHeader "content-type"
|
||||
|
||||
getPathInfo :: AppConfig -> [Text] -> Either ApiRequestError PathInfo
|
||||
getPathInfo AppConfig{configOpenApiMode, configDbRootSpec} path =
|
||||
@@ -204,15 +203,6 @@ getAction PathInfo{pathIsProc, pathIsDefSpec} method =
|
||||
"OPTIONS" -> Right ActionInfo
|
||||
_ -> Left $ UnsupportedMethod method
|
||||
|
||||
getMediaTypes :: AppConfig -> RequestHeaders -> Action -> PathInfo -> Either ApiRequestError (MediaType, MediaType)
|
||||
getMediaTypes conf hdrs action path = do
|
||||
acceptMediaType <- negotiateContent conf action path accepts
|
||||
pure (acceptMediaType, contentMediaType)
|
||||
where
|
||||
accepts = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept"
|
||||
contentMediaType = maybe MTApplicationJSON MediaType.decodeMediaType $ lookupHeader "content-type"
|
||||
lookupHeader = flip lookup hdrs
|
||||
|
||||
getSchema :: AppConfig -> RequestHeaders -> ByteString -> Either ApiRequestError (Schema, Bool)
|
||||
getSchema AppConfig{configDbSchemas} hdrs method = do
|
||||
case profile of
|
||||
@@ -269,7 +259,9 @@ getPayload reqBody contentMediaType QueryParams{qsColumns} action PathInfo{pathI
|
||||
else note "All object keys must match" . payloadAttributes reqBody
|
||||
=<< if LBS.null reqBody && pathIsProc
|
||||
then Right emptyObject
|
||||
else first BS.pack $ JSON.eitherDecode reqBody
|
||||
else first BS.pack $
|
||||
-- Drop parsing error message in favor of generic one (https://github.com/PostgREST/postgrest/issues/2344)
|
||||
maybe (Left "Empty or invalid json") Right $ JSON.decode reqBody
|
||||
(MTTextCSV, _) -> do
|
||||
json <- csvToJson <$> first BS.pack (CSV.decodeByName reqBody)
|
||||
note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json
|
||||
@@ -346,34 +338,3 @@ payloadAttributes raw json =
|
||||
_ -> Just emptyPJArray
|
||||
where
|
||||
emptyPJArray = ProcessedJSON (JSON.encode emptyArray) S.empty
|
||||
|
||||
|
||||
-- | Do content negotiation. i.e. choose a media type based on the intersection of accepted/produced media types.
|
||||
negotiateContent :: AppConfig -> Action -> PathInfo -> [MediaType] -> Either ApiRequestError MediaType
|
||||
negotiateContent conf action path accepts =
|
||||
case firstAcceptedPick of
|
||||
Just MTAny -> Right MTApplicationJSON -- by default(for */*) we respond with json
|
||||
Just mt -> Right mt
|
||||
Nothing -> Left . MediaTypeError $ map MediaType.toMime accepts
|
||||
where
|
||||
-- if there are multiple accepted media types, pick the first
|
||||
firstAcceptedPick = listToMaybe $ L.intersect accepts $ producedMediaTypes conf action path
|
||||
|
||||
producedMediaTypes :: AppConfig -> Action -> PathInfo -> [MediaType]
|
||||
producedMediaTypes conf action path =
|
||||
case action of
|
||||
ActionRead _ -> defaultMediaTypes ++ rawMediaTypes
|
||||
ActionInvoke _ -> invokeMediaTypes
|
||||
ActionInfo -> defaultMediaTypes
|
||||
ActionMutate _ -> defaultMediaTypes
|
||||
ActionInspect _ -> inspectMediaTypes
|
||||
where
|
||||
inspectMediaTypes = [MTOpenAPI, MTApplicationJSON, MTArrayJSONStrip, MTAny]
|
||||
invokeMediaTypes =
|
||||
defaultMediaTypes
|
||||
++ rawMediaTypes
|
||||
++ [MTOpenAPI | pathIsRootSpec path]
|
||||
defaultMediaTypes =
|
||||
[MTApplicationJSON, MTArrayJSONStrip, MTSingularJSON True, MTSingularJSON False, MTGeoJSON, MTTextCSV] ++
|
||||
[MTPlan MTApplicationJSON PlanText mempty | configDbPlanEnabled conf] ++ [MTAny]
|
||||
rawMediaTypes = configRawMediaTypes conf `union` [MTOctetStream, MTTextPlain, MTTextXML]
|
||||
|
||||
@@ -6,25 +6,30 @@
|
||||
--
|
||||
-- [1] https://datatracker.ietf.org/doc/html/rfc7240
|
||||
--
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
module PostgREST.ApiRequest.Preferences
|
||||
( Preferences(..)
|
||||
, PreferCount(..)
|
||||
, PreferHandling(..)
|
||||
, PreferMissing(..)
|
||||
, PreferParameters(..)
|
||||
, PreferRepresentation(..)
|
||||
, PreferResolution(..)
|
||||
, PreferTransaction(..)
|
||||
, PreferTimezone(..)
|
||||
, fromHeaders
|
||||
, ToAppliedHeader(..)
|
||||
, shouldCount
|
||||
, prefAppliedHeader
|
||||
) where
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.Map as Map
|
||||
import qualified Data.Set as S
|
||||
import qualified Network.HTTP.Types.Header as HTTP
|
||||
|
||||
import Protolude
|
||||
import PostgREST.Config.Database (TimezoneNames)
|
||||
|
||||
import Protolude
|
||||
|
||||
-- $setup
|
||||
-- Setup for doctests
|
||||
@@ -35,6 +40,8 @@ import Protolude
|
||||
-- >>> deriving instance Show PreferCount
|
||||
-- >>> deriving instance Show PreferTransaction
|
||||
-- >>> deriving instance Show PreferMissing
|
||||
-- >>> deriving instance Show PreferHandling
|
||||
-- >>> deriving instance Show PreferTimezone
|
||||
-- >>> deriving instance Show Preferences
|
||||
|
||||
-- | Preferences recognized by the application.
|
||||
@@ -46,14 +53,18 @@ data Preferences
|
||||
, preferCount :: Maybe PreferCount
|
||||
, preferTransaction :: Maybe PreferTransaction
|
||||
, preferMissing :: Maybe PreferMissing
|
||||
, preferHandling :: Maybe PreferHandling
|
||||
, preferTimezone :: Maybe PreferTimezone
|
||||
, invalidPrefs :: [ByteString]
|
||||
}
|
||||
|
||||
-- |
|
||||
-- Parse HTTP headers based on RFC7240[1] to identify preferences.
|
||||
--
|
||||
-- One header with comma-separated values can be used to set multiple preferences:
|
||||
-- >>> let sc = S.fromList ["America/Los_Angeles"]
|
||||
--
|
||||
-- >>> pPrint $ fromHeaders [("Prefer", "resolution=ignore-duplicates, count=exact")]
|
||||
-- One header with comma-separated values can be used to set multiple preferences:
|
||||
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates, count=exact, timezone=America/Los_Angeles")]
|
||||
-- Preferences
|
||||
-- { preferResolution = Just IgnoreDuplicates
|
||||
-- , preferRepresentation = Nothing
|
||||
@@ -61,11 +72,15 @@ data Preferences
|
||||
-- , preferCount = Just ExactCount
|
||||
-- , preferTransaction = Nothing
|
||||
-- , preferMissing = Nothing
|
||||
-- , preferHandling = Nothing
|
||||
-- , preferTimezone = Just
|
||||
-- ( PreferTimezone "America/Los_Angeles" )
|
||||
-- , invalidPrefs = []
|
||||
-- }
|
||||
--
|
||||
-- Multiple headers can also be used:
|
||||
--
|
||||
-- >>> pPrint $ fromHeaders [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null")]
|
||||
-- >>> pPrint $ fromHeaders True sc [("Prefer", "resolution=ignore-duplicates"), ("Prefer", "count=exact"), ("Prefer", "missing=null"), ("Prefer", "handling=lenient"), ("Prefer", "invalid")]
|
||||
-- Preferences
|
||||
-- { preferResolution = Just IgnoreDuplicates
|
||||
-- , preferRepresentation = Nothing
|
||||
@@ -73,31 +88,30 @@ data Preferences
|
||||
-- , preferCount = Just ExactCount
|
||||
-- , preferTransaction = Nothing
|
||||
-- , preferMissing = Just ApplyNulls
|
||||
-- , preferHandling = Just Lenient
|
||||
-- , preferTimezone = Nothing
|
||||
-- , invalidPrefs = [ "invalid" ]
|
||||
-- }
|
||||
--
|
||||
-- If a preference is set more than once, only the first is used:
|
||||
--
|
||||
-- >>> preferTransaction $ fromHeaders [("Prefer", "tx=commit, tx=rollback")]
|
||||
-- >>> preferTransaction $ fromHeaders True sc [("Prefer", "tx=commit, tx=rollback")]
|
||||
-- Just Commit
|
||||
--
|
||||
-- This is also the case across multiple headers:
|
||||
--
|
||||
-- >>> :{
|
||||
-- preferResolution . fromHeaders $
|
||||
-- preferResolution . fromHeaders True sc $
|
||||
-- [ ("Prefer", "resolution=ignore-duplicates")
|
||||
-- , ("Prefer", "resolution=merge-duplicates")
|
||||
-- ]
|
||||
-- :}
|
||||
-- Just IgnoreDuplicates
|
||||
--
|
||||
-- Preferences not recognized by the application are ignored:
|
||||
--
|
||||
-- >>> preferResolution $ fromHeaders [("Prefer", "resolution=foo")]
|
||||
-- Nothing
|
||||
--
|
||||
-- Preferences can be separated by arbitrary amounts of space, lower-case header is also recognized:
|
||||
--
|
||||
-- >>> pPrint $ fromHeaders [("prefer", "count=exact, tx=commit ,return=representation , missing=default")]
|
||||
-- >>> pPrint $ fromHeaders True sc [("prefer", "count=exact, tx=commit ,return=representation , missing=default, handling=strict, anything")]
|
||||
-- Preferences
|
||||
-- { preferResolution = Nothing
|
||||
-- , preferRepresentation = Just Full
|
||||
@@ -105,22 +119,43 @@ data Preferences
|
||||
-- , preferCount = Just ExactCount
|
||||
-- , preferTransaction = Just Commit
|
||||
-- , preferMissing = Just ApplyDefaults
|
||||
-- , preferHandling = Just Strict
|
||||
-- , preferTimezone = Nothing
|
||||
-- , invalidPrefs = [ "anything" ]
|
||||
-- }
|
||||
--
|
||||
fromHeaders :: [HTTP.Header] -> Preferences
|
||||
fromHeaders headers =
|
||||
fromHeaders :: Bool -> TimezoneNames -> [HTTP.Header] -> Preferences
|
||||
fromHeaders allowTxDbOverride acceptedTzNames headers =
|
||||
Preferences
|
||||
{ preferResolution = parsePrefs [MergeDuplicates, IgnoreDuplicates]
|
||||
, preferRepresentation = parsePrefs [Full, None, HeadersOnly]
|
||||
, preferParameters = parsePrefs [SingleObject]
|
||||
, preferCount = parsePrefs [ExactCount, PlannedCount, EstimatedCount]
|
||||
, preferTransaction = parsePrefs [Commit, Rollback]
|
||||
, preferTransaction = if allowTxDbOverride then parsePrefs [Commit, Rollback] else Nothing
|
||||
, preferMissing = parsePrefs [ApplyDefaults, ApplyNulls]
|
||||
, preferHandling = parsePrefs [Strict, Lenient]
|
||||
, preferTimezone = if isTimezonePrefAccepted then PreferTimezone <$> timezonePref else Nothing
|
||||
, invalidPrefs = filter checkPrefs prefs
|
||||
}
|
||||
where
|
||||
mapToHeadVal :: ToHeaderValue a => [a] -> [ByteString]
|
||||
mapToHeadVal = map toHeaderValue
|
||||
acceptedPrefs = mapToHeadVal [MergeDuplicates, IgnoreDuplicates] ++
|
||||
mapToHeadVal [Full, None, HeadersOnly] ++
|
||||
mapToHeadVal [SingleObject] ++
|
||||
mapToHeadVal [ExactCount, PlannedCount, EstimatedCount] ++
|
||||
mapToHeadVal [Commit, Rollback] ++
|
||||
mapToHeadVal [ApplyDefaults, ApplyNulls] ++
|
||||
mapToHeadVal [Strict, Lenient]
|
||||
|
||||
prefHeaders = filter ((==) HTTP.hPrefer . fst) headers
|
||||
prefs = fmap BS.strip . concatMap (BS.split ',' . snd) $ prefHeaders
|
||||
|
||||
timezonePref = listToMaybe $ mapMaybe (BS.stripPrefix "timezone=") prefs
|
||||
isTimezonePrefAccepted = (S.member <$> timezonePref <*> pure acceptedTzNames) == Just True
|
||||
|
||||
checkPrefs p = p `notElem` acceptedPrefs && not isTimezonePrefAccepted
|
||||
|
||||
parsePrefs :: ToHeaderValue a => [a] -> Maybe a
|
||||
parsePrefs vals =
|
||||
head $ mapMaybe (flip Map.lookup $ prefMap vals) prefs
|
||||
@@ -128,6 +163,24 @@ fromHeaders headers =
|
||||
prefMap :: ToHeaderValue a => [a] -> Map.Map ByteString a
|
||||
prefMap = Map.fromList . fmap (\pref -> (toHeaderValue pref, pref))
|
||||
|
||||
prefAppliedHeader :: Preferences -> Maybe HTTP.Header
|
||||
prefAppliedHeader Preferences {preferResolution, preferRepresentation, preferParameters, preferCount, preferTransaction, preferMissing, preferHandling, preferTimezone } =
|
||||
if null prefsVals
|
||||
then Nothing
|
||||
else Just (HTTP.hPreferenceApplied, combined)
|
||||
where
|
||||
combined = BS.intercalate ", " prefsVals
|
||||
prefsVals = catMaybes [
|
||||
toHeaderValue <$> preferResolution
|
||||
, toHeaderValue <$> preferMissing
|
||||
, toHeaderValue <$> preferRepresentation
|
||||
, toHeaderValue <$> preferParameters
|
||||
, toHeaderValue <$> preferCount
|
||||
, toHeaderValue <$> preferTransaction
|
||||
, toHeaderValue <$> preferHandling
|
||||
, toHeaderValue <$> preferTimezone
|
||||
]
|
||||
|
||||
-- |
|
||||
-- Convert a preference into the value that we look for in the 'Prefer' headers.
|
||||
--
|
||||
@@ -137,27 +190,16 @@ fromHeaders headers =
|
||||
class ToHeaderValue a where
|
||||
toHeaderValue :: a -> ByteString
|
||||
|
||||
-- |
|
||||
-- Header to indicate that a preference has been applied.
|
||||
--
|
||||
-- >>> toAppliedHeader MergeDuplicates
|
||||
-- ("Preference-Applied","resolution=merge-duplicates")
|
||||
--
|
||||
class ToHeaderValue a => ToAppliedHeader a where
|
||||
toAppliedHeader :: a -> HTTP.Header
|
||||
toAppliedHeader x = (HTTP.hPreferenceApplied, toHeaderValue x)
|
||||
|
||||
-- | How to handle duplicate values.
|
||||
data PreferResolution
|
||||
= MergeDuplicates
|
||||
| IgnoreDuplicates
|
||||
deriving Eq
|
||||
|
||||
instance ToHeaderValue PreferResolution where
|
||||
toHeaderValue MergeDuplicates = "resolution=merge-duplicates"
|
||||
toHeaderValue IgnoreDuplicates = "resolution=ignore-duplicates"
|
||||
|
||||
instance ToAppliedHeader PreferResolution
|
||||
|
||||
-- |
|
||||
-- How to return the mutated data.
|
||||
--
|
||||
@@ -168,8 +210,6 @@ data PreferRepresentation
|
||||
| None -- ^ Return nothing from the mutated data.
|
||||
deriving Eq
|
||||
|
||||
instance ToAppliedHeader PreferRepresentation
|
||||
|
||||
instance ToHeaderValue PreferRepresentation where
|
||||
toHeaderValue Full = "return=representation"
|
||||
toHeaderValue None = "return=minimal"
|
||||
@@ -209,8 +249,6 @@ instance ToHeaderValue PreferTransaction where
|
||||
toHeaderValue Commit = "tx=commit"
|
||||
toHeaderValue Rollback = "tx=rollback"
|
||||
|
||||
instance ToAppliedHeader PreferTransaction
|
||||
|
||||
-- |
|
||||
-- How to handle the insertion/update when the keys specified in ?columns are not present
|
||||
-- in the json body.
|
||||
@@ -223,4 +261,20 @@ instance ToHeaderValue PreferMissing where
|
||||
toHeaderValue ApplyDefaults = "missing=default"
|
||||
toHeaderValue ApplyNulls = "missing=null"
|
||||
|
||||
instance ToAppliedHeader PreferMissing
|
||||
-- |
|
||||
-- Handling of unrecognised preferences
|
||||
data PreferHandling
|
||||
= Strict -- ^ Throw error on unrecognised preferences
|
||||
| Lenient -- ^ Ignore unrecognised preferences
|
||||
deriving Eq
|
||||
|
||||
instance ToHeaderValue PreferHandling where
|
||||
toHeaderValue Strict = "handling=strict"
|
||||
toHeaderValue Lenient = "handling=lenient"
|
||||
|
||||
-- |
|
||||
-- Change timezone
|
||||
newtype PreferTimezone = PreferTimezone ByteString
|
||||
|
||||
instance ToHeaderValue PreferTimezone where
|
||||
toHeaderValue (PreferTimezone tz) = "timezone=" <> tz
|
||||
|
||||
@@ -31,8 +31,8 @@ import Data.Tree (Tree (..))
|
||||
import Text.Parsec.Error (errorMessages,
|
||||
showErrorMessages)
|
||||
import Text.ParserCombinators.Parsec (GenParser, ParseError, Parser,
|
||||
anyChar, between, char, digit,
|
||||
eof, errorPos, letter,
|
||||
anyChar, between, char, choice,
|
||||
digit, eof, errorPos, letter,
|
||||
lookAhead, many1, noneOf,
|
||||
notFollowedBy, oneOf,
|
||||
optionMaybe, sepBy, sepBy1,
|
||||
@@ -43,7 +43,8 @@ import PostgREST.RangeQuery (NonnegRange, allRange,
|
||||
rangeOffset, restrictRange)
|
||||
import PostgREST.SchemaCache.Identifiers (FieldName)
|
||||
|
||||
import PostgREST.ApiRequest.Types (EmbedParam (..), EmbedPath, Field,
|
||||
import PostgREST.ApiRequest.Types (AggregateFunction (..),
|
||||
EmbedParam (..), EmbedPath, Field,
|
||||
Filter (..), FtsOperator (..),
|
||||
Hint, JoinType (..),
|
||||
JsonOperand (..),
|
||||
@@ -58,7 +59,7 @@ import PostgREST.ApiRequest.Types (EmbedParam (..), EmbedPath, Field,
|
||||
SimpleOperator (..), SingleVal,
|
||||
TrileanVal (..))
|
||||
|
||||
import Protolude hiding (try)
|
||||
import Protolude hiding (Sum, try)
|
||||
|
||||
data QueryParams =
|
||||
QueryParams
|
||||
@@ -99,7 +100,7 @@ data QueryParams =
|
||||
-- 'select' is a reserved parameter that selects the fields to be returned:
|
||||
--
|
||||
-- >>> qsSelect <$> parse False "select=name,location"
|
||||
-- Right [Node {rootLabel = SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectField {selField = ("location",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]
|
||||
-- Right [Node {rootLabel = SelectField {selField = ("name",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectField {selField = ("location",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]
|
||||
--
|
||||
-- Filters are parameters whose value contains an operator, separated by a '.' from its value:
|
||||
--
|
||||
@@ -282,16 +283,16 @@ pTreePath = do
|
||||
-- Parse select= into a Forest of SelectItems
|
||||
--
|
||||
-- >>> P.parse pFieldForest "" "id"
|
||||
-- Right [Node {rootLabel = SelectField {selField = ("id",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]
|
||||
-- Right [Node {rootLabel = SelectField {selField = ("id",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]
|
||||
--
|
||||
-- >>> P.parse pFieldForest "" "client(id)"
|
||||
-- Right [Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("id",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}]
|
||||
-- Right [Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("id",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]}]
|
||||
--
|
||||
-- >>> P.parse pFieldForest "" "*,client(*,nested(*))"
|
||||
-- Right [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "nested", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}]}]
|
||||
-- Right [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "client", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SelectRelation {selRelation = "nested", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]}]}]
|
||||
--
|
||||
-- >>> P.parse pFieldForest "" "*,...client(*),other(*)"
|
||||
-- Right [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SpreadRelation {selRelation = "client", selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]},Node {rootLabel = SelectRelation {selRelation = "other", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing}, subForest = []}]}]
|
||||
-- Right [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []},Node {rootLabel = SpreadRelation {selRelation = "client", selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]},Node {rootLabel = SelectRelation {selRelation = "other", selAlias = Nothing, selHint = Nothing, selJoinType = Nothing}, subForest = [Node {rootLabel = SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing}, subForest = []}]}]
|
||||
--
|
||||
-- >>> P.parse pFieldForest "" ""
|
||||
-- Right []
|
||||
@@ -299,7 +300,7 @@ pTreePath = do
|
||||
-- >>> P.parse pFieldForest "" "id,clients(name[])"
|
||||
-- Left (line 1, column 16):
|
||||
-- unexpected '['
|
||||
-- expecting letter, digit, "-", "->>", "->", "::", ")", "," or end of input
|
||||
-- expecting letter, digit, "-", "->>", "->", "::", ".", ")", "," or end of input
|
||||
--
|
||||
-- >>> P.parse pFieldForest "" "data->>-78xy"
|
||||
-- Left (line 1, column 11):
|
||||
@@ -349,8 +350,12 @@ pFieldForest = pFieldTree `sepBy` lexeme (char ',')
|
||||
pFieldName :: Parser Text
|
||||
pFieldName =
|
||||
pQuotedValue <|>
|
||||
T.intercalate "-" . map toS <$> (pIdentifier `sepBy1` dash) <?>
|
||||
sepByDash pIdentifier <?>
|
||||
"field name (* or [a..z0..9_$])"
|
||||
|
||||
sepByDash :: Parser Text -> Parser Text
|
||||
sepByDash fieldIdent =
|
||||
T.intercalate "-" . map toS <$> (fieldIdent `sepBy1` dash)
|
||||
where
|
||||
isDash :: GenParser Char st ()
|
||||
isDash = try ( char '-' >> notFollowedBy (char '>') )
|
||||
@@ -363,12 +368,18 @@ pFieldName =
|
||||
-- >>> P.parse pJsonPath "" "->text"
|
||||
-- Right [JArrow {jOp = JKey {jVal = "text"}}]
|
||||
--
|
||||
-- >>> P.parse pJsonPath "" "->!@#$%^&*_a"
|
||||
-- Right [JArrow {jOp = JKey {jVal = "!@#$%^&*_a"}}]
|
||||
--
|
||||
-- >>> P.parse pJsonPath "" "->1"
|
||||
-- Right [JArrow {jOp = JIdx {jVal = "+1"}}]
|
||||
--
|
||||
-- >>> P.parse pJsonPath "" "->>text"
|
||||
-- Right [J2Arrow {jOp = JKey {jVal = "text"}}]
|
||||
--
|
||||
-- >>> P.parse pJsonPath "" "->>!@#$%^&*_a"
|
||||
-- Right [J2Arrow {jOp = JKey {jVal = "!@#$%^&*_a"}}]
|
||||
--
|
||||
-- >>> P.parse pJsonPath "" "->>1"
|
||||
-- Right [J2Arrow {jOp = JIdx {jVal = "+1"}}]
|
||||
--
|
||||
@@ -405,7 +416,7 @@ pJsonPath = many pJsonOperation
|
||||
try (string "->" $> JArrow)
|
||||
|
||||
pJsonOperand =
|
||||
let pJKey = JKey . toS <$> pFieldName
|
||||
let pJKey = JKey . toS <$> pJsonKeyName
|
||||
pJIdx = JIdx . toS <$> ((:) <$> P.option '+' (char '-') <*> many1 digit) <* pEnd
|
||||
pEnd = try (void $ lookAhead (string "->")) <|>
|
||||
try (void $ lookAhead (string "::")) <|>
|
||||
@@ -414,6 +425,15 @@ pJsonPath = many pJsonOperation
|
||||
try eof in
|
||||
try pJIdx <|> try pJKey
|
||||
|
||||
pJsonKeyName :: Parser Text
|
||||
pJsonKeyName =
|
||||
pQuotedValue <|>
|
||||
sepByDash pJsonKeyIdentifier <?>
|
||||
"any non reserved character different from: .,>()"
|
||||
|
||||
pJsonKeyIdentifier :: Parser Text
|
||||
pJsonKeyIdentifier = T.strip . toS <$> many1 (noneOf "(-:.,>)")
|
||||
|
||||
pField :: Parser Field
|
||||
pField = lexeme $ (,) <$> pFieldName <*> P.option [] pJsonPath
|
||||
|
||||
@@ -452,35 +472,40 @@ pRelationSelect :: Parser SelectItem
|
||||
pRelationSelect = lexeme $ do
|
||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||
name <- pFieldName
|
||||
guard (name /= "count")
|
||||
(hint, jType) <- pEmbedParams
|
||||
try (void $ lookAhead (string "("))
|
||||
return $ SelectRelation name alias hint jType
|
||||
|
||||
|
||||
-- |
|
||||
-- Parse regular fields in select
|
||||
--
|
||||
-- >>> P.parse pFieldSelect "" "name"
|
||||
-- Right (SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Nothing})
|
||||
-- Right (SelectField {selField = ("name",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing})
|
||||
--
|
||||
-- >>> P.parse pFieldSelect "" "name->jsonpath"
|
||||
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selCast = Nothing, selAlias = Nothing})
|
||||
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing})
|
||||
--
|
||||
-- >>> P.parse pFieldSelect "" "name::cast"
|
||||
-- Right (SelectField {selField = ("name",[]), selCast = Just "cast", selAlias = Nothing})
|
||||
-- Right (SelectField {selField = ("name",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Just "cast", selAlias = Nothing})
|
||||
--
|
||||
-- >>> P.parse pFieldSelect "" "alias:name"
|
||||
-- Right (SelectField {selField = ("name",[]), selCast = Nothing, selAlias = Just "alias"})
|
||||
-- Right (SelectField {selField = ("name",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Just "alias"})
|
||||
--
|
||||
-- >>> P.parse pFieldSelect "" "alias:name->jsonpath::cast"
|
||||
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selCast = Just "cast", selAlias = Just "alias"})
|
||||
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "jsonpath"}}]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Just "cast", selAlias = Just "alias"})
|
||||
--
|
||||
-- >>> P.parse pFieldSelect "" "alias:name->!@#$%^&*_a::cast"
|
||||
-- Right (SelectField {selField = ("name",[JArrow {jOp = JKey {jVal = "!@#$%^&*_a"}}]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Just "cast", selAlias = Just "alias"})
|
||||
--
|
||||
-- >>> P.parse pFieldSelect "" "*"
|
||||
-- Right (SelectField {selField = ("*",[]), selCast = Nothing, selAlias = Nothing})
|
||||
-- Right (SelectField {selField = ("*",[]), selAggregateFunction = Nothing, selAggregateCast = Nothing, selCast = Nothing, selAlias = Nothing})
|
||||
--
|
||||
-- >>> P.parse pFieldSelect "" "name!hint"
|
||||
-- Left (line 1, column 5):
|
||||
-- unexpected '!'
|
||||
-- expecting letter, digit, "-", "->>", "->", "::", ")", "," or end of input
|
||||
-- expecting letter, digit, "-", "->>", "->", "::", ".", ")", "," or end of input
|
||||
--
|
||||
-- >>> P.parse pFieldSelect "" "*!hint"
|
||||
-- Left (line 1, column 2):
|
||||
@@ -495,18 +520,36 @@ pFieldSelect :: Parser SelectItem
|
||||
pFieldSelect = lexeme $ try (do
|
||||
s <- pStar
|
||||
pEnd
|
||||
return $ SelectField (s, []) Nothing Nothing)
|
||||
<|> do
|
||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||
fld <- pField
|
||||
cast' <- optionMaybe (string "::" *> pIdentifier)
|
||||
return $ SelectField (s, []) Nothing Nothing Nothing Nothing)
|
||||
<|> try (do
|
||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||
_ <- string "count()"
|
||||
aggCast' <- optionMaybe (string "::" *> pIdentifier)
|
||||
pEnd
|
||||
return $ SelectField fld (toS <$> cast') alias
|
||||
return $ SelectField ("*", []) (Just Count) (toS <$> aggCast') Nothing alias)
|
||||
<|> do
|
||||
alias <- optionMaybe ( try(pFieldName <* aliasSeparator) )
|
||||
fld <- pField
|
||||
cast' <- optionMaybe (string "::" *> pIdentifier)
|
||||
agg <- optionMaybe (try (char '.' *> pAggregation <* string "()"))
|
||||
aggCast' <- optionMaybe (string "::" *> pIdentifier)
|
||||
pEnd
|
||||
return $ SelectField fld agg (toS <$> aggCast') (toS <$> cast') alias
|
||||
where
|
||||
pEnd = try (void $ lookAhead (string ")")) <|>
|
||||
try (void $ lookAhead (string ",")) <|>
|
||||
try eof
|
||||
pStar = string "*" $> "*"
|
||||
pAggregation = choice
|
||||
[ string "sum" $> Sum
|
||||
, string "avg" $> Avg
|
||||
, string "count" $> Count
|
||||
-- Using 'try' for "min" and "max" to allow backtracking.
|
||||
-- This is necessary because both start with the same character 'm',
|
||||
-- and without 'try', a partial match on "max" would prevent "min" from being tried.
|
||||
, try (string "max") $> Max
|
||||
, try (string "min") $> Min
|
||||
]
|
||||
|
||||
|
||||
-- |
|
||||
@@ -657,9 +700,15 @@ pDelimiter = char '.' <?> "delimiter (.)"
|
||||
-- >>> P.parse pOrder "" "json_col->key.asc.nullslast"
|
||||
-- Right [OrderTerm {otTerm = ("json_col",[JArrow {jOp = JKey {jVal = "key"}}]), otDirection = Just OrderAsc, otNullOrder = Just OrderNullsLast}]
|
||||
--
|
||||
-- >>> P.parse pOrder "" "json_col->!@#$%^&*_a.asc.nullslast"
|
||||
-- Right [OrderTerm {otTerm = ("json_col",[JArrow {jOp = JKey {jVal = "!@#$%^&*_a"}}]), otDirection = Just OrderAsc, otNullOrder = Just OrderNullsLast}]
|
||||
--
|
||||
-- >>> P.parse pOrder "" "clients(json_col->key).desc.nullsfirst"
|
||||
-- Right [OrderRelationTerm {otRelation = "clients", otRelTerm = ("json_col",[JArrow {jOp = JKey {jVal = "key"}}]), otDirection = Just OrderDesc, otNullOrder = Just OrderNullsFirst}]
|
||||
--
|
||||
-- >>> P.parse pOrder "" "clients(json_col->!@#$%^&*_a).desc.nullsfirst"
|
||||
-- Right [OrderRelationTerm {otRelation = "clients", otRelTerm = ("json_col",[JArrow {jOp = JKey {jVal = "!@#$%^&*_a"}}]), otDirection = Just OrderDesc, otNullOrder = Just OrderNullsFirst}]
|
||||
--
|
||||
-- >>> P.parse pOrder "" "clients(name,id)"
|
||||
-- Left (line 1, column 8):
|
||||
-- unexpected '('
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
module PostgREST.ApiRequest.Types
|
||||
( Alias
|
||||
( AggregateFunction(..)
|
||||
, Alias
|
||||
, Cast
|
||||
, Depth
|
||||
, EmbedParam(..)
|
||||
@@ -42,12 +43,14 @@ import PostgREST.SchemaCache.Routine (Routine (..))
|
||||
|
||||
import Protolude
|
||||
|
||||
-- | The value in `/tbl?select=alias:field::cast`
|
||||
-- | The value in `/tbl?select=alias:field.aggregateFunction()::cast`
|
||||
data SelectItem
|
||||
= SelectField
|
||||
{ selField :: Field
|
||||
, selCast :: Maybe Cast
|
||||
, selAlias :: Maybe Alias
|
||||
{ selField :: Field
|
||||
, selAggregateFunction :: Maybe AggregateFunction
|
||||
, selAggregateCast :: Maybe Cast
|
||||
, selCast :: Maybe Cast
|
||||
, selAlias :: Maybe Alias
|
||||
}
|
||||
-- | The value in `/tbl?select=alias:another_tbl(*)`
|
||||
| SelectRelation
|
||||
@@ -65,12 +68,13 @@ data SelectItem
|
||||
deriving (Eq, Show)
|
||||
|
||||
data ApiRequestError
|
||||
= AmbiguousRelBetween Text Text [Relationship]
|
||||
= AggregatesNotAllowed
|
||||
| AmbiguousRelBetween Text Text [Relationship]
|
||||
| AmbiguousRpc [Routine]
|
||||
| BinaryFieldError MediaType
|
||||
| MediaTypeError [ByteString]
|
||||
| InvalidBody ByteString
|
||||
| InvalidFilters
|
||||
| InvalidPreferences [ByteString]
|
||||
| InvalidRange RangeError
|
||||
| InvalidRpcMethod ByteString
|
||||
| LimitNoOrderError
|
||||
@@ -86,6 +90,12 @@ data ApiRequestError
|
||||
| UnacceptableSchema [Text]
|
||||
| UnsupportedMethod ByteString
|
||||
| ColumnNotFound Text Text
|
||||
| GucHeadersError
|
||||
| GucStatusError
|
||||
| OffLimitsChangesError Int64 Integer
|
||||
| PutMatchingPkError
|
||||
| SingularityError Integer
|
||||
| PGRSTParseError
|
||||
deriving Show
|
||||
|
||||
data QPError = QPError Text Text
|
||||
@@ -128,6 +138,9 @@ type Cast = Text
|
||||
type Alias = Text
|
||||
type Hint = Text
|
||||
|
||||
data AggregateFunction = Sum | Avg | Max | Min | Count
|
||||
deriving (Show, Eq)
|
||||
|
||||
data EmbedParam
|
||||
-- | Disambiguates an embedding operation when there's multiple relationships
|
||||
-- between two tables. Can be the name of a foreign key constraint, column
|
||||
|
||||
+118
-79
@@ -9,11 +9,10 @@ Some of its functionality includes:
|
||||
- Producing HTTP Headers according to RFCs.
|
||||
- Content Negotiation
|
||||
-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
module PostgREST.App
|
||||
( SignalHandlerInstaller
|
||||
, SocketRunner
|
||||
, postgrest
|
||||
( postgrest
|
||||
, run
|
||||
) where
|
||||
|
||||
@@ -24,7 +23,6 @@ import Data.Maybe (fromJust)
|
||||
import Data.String (IsString (..))
|
||||
import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort,
|
||||
setServerName)
|
||||
import System.Posix.Types (FileMode)
|
||||
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import qualified Data.Text.Encoding as T
|
||||
@@ -43,32 +41,36 @@ import qualified PostgREST.Logger as Logger
|
||||
import qualified PostgREST.Plan as Plan
|
||||
import qualified PostgREST.Query as Query
|
||||
import qualified PostgREST.Response as Response
|
||||
import qualified PostgREST.Unix as Unix (installSignalHandlers)
|
||||
|
||||
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
|
||||
Mutation (..), Target (..))
|
||||
import PostgREST.AppState (AppState)
|
||||
import PostgREST.Auth (AuthResult (..))
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.Config.PgVersion (PgVersion (..))
|
||||
import PostgREST.Error (Error)
|
||||
import PostgREST.Query (DbHandler)
|
||||
import PostgREST.SchemaCache (SchemaCache (..))
|
||||
import PostgREST.SchemaCache.Routine (Routine (..))
|
||||
import PostgREST.Version (docsVersion, prettyVersion)
|
||||
import PostgREST.ApiRequest (Action (..), ApiRequest (..),
|
||||
Mutation (..), Target (..))
|
||||
import PostgREST.AppState (AppState)
|
||||
import PostgREST.Auth (AuthResult (..))
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.Config.PgVersion (PgVersion (..))
|
||||
import PostgREST.Error (Error)
|
||||
import PostgREST.Query (DbHandler)
|
||||
import PostgREST.Response.Performance (ServerTiming (..),
|
||||
serverTimingHeader)
|
||||
import PostgREST.SchemaCache (SchemaCache (..))
|
||||
import PostgREST.SchemaCache.Routine (Routine (..))
|
||||
import PostgREST.Version (docsVersion, prettyVersion)
|
||||
|
||||
import Protolude hiding (Handler)
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.List as L
|
||||
import qualified Network.HTTP.Types as HTTP
|
||||
import qualified Network.Socket as NS
|
||||
import Protolude hiding (Handler)
|
||||
import System.TimeIt (timeItT)
|
||||
|
||||
type Handler = ExceptT Error
|
||||
|
||||
type SignalHandlerInstaller = AppState -> IO()
|
||||
|
||||
type SocketRunner = Warp.Settings -> Wai.Application -> FileMode -> FilePath -> IO()
|
||||
|
||||
run :: SignalHandlerInstaller -> Maybe SocketRunner -> AppState -> IO ()
|
||||
run installHandlers maybeRunWithSocket appState = do
|
||||
run :: AppState -> IO ()
|
||||
run appState = do
|
||||
conf@AppConfig{..} <- AppState.getConfig appState
|
||||
AppState.connectionWorker appState -- Loads the initial SchemaCache
|
||||
installHandlers appState
|
||||
Unix.installSignalHandlers (AppState.getMainThreadId appState) (AppState.connectionWorker appState) (AppState.reReadConfig False appState)
|
||||
-- reload schema cache + config on NOTIFY
|
||||
AppState.runListener conf appState
|
||||
|
||||
@@ -76,19 +78,14 @@ run installHandlers maybeRunWithSocket appState = do
|
||||
|
||||
let app = postgrest conf appState (AppState.connectionWorker appState)
|
||||
|
||||
case configServerUnixSocket of
|
||||
Just socket ->
|
||||
-- run the postgrest application with user defined socket. Only for UNIX systems
|
||||
case maybeRunWithSocket of
|
||||
Just runWithSocket -> do
|
||||
AppState.logWithZTime appState $ "Listening on unix socket " <> show socket
|
||||
runWithSocket (serverSettings conf) app configServerUnixSocketMode socket
|
||||
Nothing ->
|
||||
panic "Cannot run with unix socket on non-unix platforms."
|
||||
Nothing ->
|
||||
do
|
||||
AppState.logWithZTime appState $ "Listening on port " <> show configServerPort
|
||||
Warp.runSettings (serverSettings conf) app
|
||||
what <- case configServerUnixSocket of
|
||||
Just path -> pure $ "unix socket " <> show path
|
||||
Nothing -> do
|
||||
port <- NS.socketPort $ AppState.getSocketREST appState
|
||||
pure $ "port " <> show port
|
||||
AppState.logWithZTime appState $ "Listening on " <> what
|
||||
|
||||
Warp.runSettingsSocket (serverSettings conf) (AppState.getSocketREST appState) app
|
||||
|
||||
serverSettings :: AppConfig -> Warp.Settings
|
||||
serverSettings AppConfig{..} =
|
||||
@@ -100,8 +97,8 @@ serverSettings AppConfig{..} =
|
||||
-- | PostgREST application
|
||||
postgrest :: AppConfig -> AppState.AppState -> IO () -> Wai.Application
|
||||
postgrest conf appState connWorker =
|
||||
Response.traceHeaderMiddleware conf .
|
||||
Cors.middleware .
|
||||
traceHeaderMiddleware conf .
|
||||
Cors.middleware (configServerCorsAllowedOrigins conf) .
|
||||
Auth.middleware appState .
|
||||
Logger.middleware (configLogLevel conf) $
|
||||
-- fromJust can be used, because the auth middleware will **always** add
|
||||
@@ -122,10 +119,10 @@ postgrest conf appState connWorker =
|
||||
-- Launch the connWorker when the connection is down. The postgrest
|
||||
-- function can respond successfully (with a stale schema cache) before
|
||||
-- the connWorker is done.
|
||||
when (Response.isServiceUnavailable response) connWorker
|
||||
when (isServiceUnavailable response) connWorker
|
||||
resp <- do
|
||||
delay <- AppState.getRetryNextIn appState
|
||||
return $ Response.addRetryHint delay response
|
||||
return $ addRetryHint delay response
|
||||
respond resp
|
||||
|
||||
postgrestResponse
|
||||
@@ -146,18 +143,19 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache pgVer authResult@
|
||||
|
||||
body <- lift $ Wai.strictRequestBody req
|
||||
|
||||
apiRequest <-
|
||||
liftEither . mapLeft Error.ApiRequestError $
|
||||
ApiRequest.userApiRequest conf req body
|
||||
(parseTime, apiRequest) <-
|
||||
calcTiming configServerTimingEnabled $
|
||||
liftEither . mapLeft Error.ApiRequestError $
|
||||
ApiRequest.userApiRequest conf req body sCache
|
||||
|
||||
Response.optionalRollback conf apiRequest $
|
||||
handleRequest authResult conf appState (Just authRole /= configDbAnonRole) configDbPreparedStatements pgVer apiRequest sCache
|
||||
let jwtTime = if configServerTimingEnabled then Auth.getJwtDur req else Nothing
|
||||
handleRequest authResult conf appState (Just authRole /= configDbAnonRole) configDbPreparedStatements pgVer apiRequest sCache jwtTime parseTime
|
||||
|
||||
runDbHandler :: AppState.AppState -> SQL.IsolationLevel -> SQL.Mode -> Bool -> Bool -> DbHandler b -> Handler IO b
|
||||
runDbHandler appState isoLvl mode authenticated prepared handler = do
|
||||
runDbHandler :: AppState.AppState -> AppConfig -> SQL.IsolationLevel -> SQL.Mode -> Bool -> Bool -> DbHandler b -> Handler IO b
|
||||
runDbHandler appState config isoLvl mode authenticated prepared handler = do
|
||||
dbResp <- lift $ do
|
||||
let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction
|
||||
AppState.usePool appState . transaction isoLvl mode $ runExceptT handler
|
||||
AppState.usePool appState config . transaction isoLvl mode $ runExceptT handler
|
||||
|
||||
resp <-
|
||||
liftEither . mapLeft Error.PgErr $
|
||||
@@ -165,52 +163,63 @@ runDbHandler appState isoLvl mode authenticated prepared handler = do
|
||||
|
||||
liftEither resp
|
||||
|
||||
handleRequest :: AuthResult -> AppConfig -> AppState.AppState -> Bool -> Bool -> PgVersion -> ApiRequest -> SchemaCache -> Handler IO Wai.Response
|
||||
handleRequest AuthResult{..} conf appState authenticated prepared pgVer apiReq@ApiRequest{..} sCache =
|
||||
handleRequest :: AuthResult -> AppConfig -> AppState.AppState -> Bool -> Bool -> PgVersion -> ApiRequest -> SchemaCache -> Maybe Double -> Maybe Double -> Handler IO Wai.Response
|
||||
handleRequest AuthResult{..} conf appState authenticated prepared pgVer apiReq@ApiRequest{..} sCache jwtTime parseTime =
|
||||
case (iAction, iTarget) of
|
||||
(ActionRead headersOnly, TargetIdent identifier) -> do
|
||||
wrPlan <- liftEither $ Plan.wrappedReadPlan identifier conf sCache apiReq
|
||||
resultSet <- runQuery roleIsoLvl (Plan.wrTxMode wrPlan) $ Query.readQuery wrPlan conf apiReq
|
||||
return $ Response.readResponse headersOnly identifier apiReq resultSet
|
||||
(planTime', wrPlan) <- withTiming $ liftEither $ Plan.wrappedReadPlan identifier conf sCache apiReq
|
||||
(txTime', resultSet) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.wrTxMode wrPlan) $ Query.readQuery wrPlan conf apiReq
|
||||
(respTime', pgrst) <- withTiming $ liftEither $ Response.readResponse wrPlan headersOnly identifier apiReq resultSet
|
||||
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
|
||||
|
||||
(ActionMutate MutationCreate, TargetIdent identifier) -> do
|
||||
mrPlan <- liftEither $ Plan.mutateReadPlan MutationCreate apiReq identifier conf sCache
|
||||
resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.createQuery mrPlan apiReq conf
|
||||
return $ Response.createResponse identifier mrPlan apiReq resultSet
|
||||
(planTime', mrPlan) <- withTiming $ liftEither $ Plan.mutateReadPlan MutationCreate apiReq identifier conf sCache
|
||||
(txTime', resultSet) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.mrTxMode mrPlan) $ Query.createQuery mrPlan apiReq conf
|
||||
(respTime', pgrst) <- withTiming $ liftEither $ Response.createResponse identifier mrPlan apiReq resultSet
|
||||
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
|
||||
|
||||
(ActionMutate MutationUpdate, TargetIdent identifier) -> do
|
||||
mrPlan <- liftEither $ Plan.mutateReadPlan MutationUpdate apiReq identifier conf sCache
|
||||
resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.updateQuery mrPlan apiReq conf
|
||||
return $ Response.updateResponse apiReq resultSet
|
||||
(planTime', mrPlan) <- withTiming $ liftEither $ Plan.mutateReadPlan MutationUpdate apiReq identifier conf sCache
|
||||
(txTime', resultSet) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.mrTxMode mrPlan) $ Query.updateQuery mrPlan apiReq conf
|
||||
(respTime', pgrst) <- withTiming $ liftEither $ Response.updateResponse mrPlan apiReq resultSet
|
||||
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
|
||||
|
||||
(ActionMutate MutationSingleUpsert, TargetIdent identifier) -> do
|
||||
mrPlan <- liftEither $ Plan.mutateReadPlan MutationSingleUpsert apiReq identifier conf sCache
|
||||
resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.singleUpsertQuery mrPlan apiReq conf
|
||||
return $ Response.singleUpsertResponse apiReq resultSet
|
||||
(planTime', mrPlan) <- withTiming $ liftEither $ Plan.mutateReadPlan MutationSingleUpsert apiReq identifier conf sCache
|
||||
(txTime', resultSet) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.mrTxMode mrPlan) $ Query.singleUpsertQuery mrPlan apiReq conf
|
||||
(respTime', pgrst) <- withTiming $ liftEither $ Response.singleUpsertResponse mrPlan apiReq resultSet
|
||||
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
|
||||
|
||||
(ActionMutate MutationDelete, TargetIdent identifier) -> do
|
||||
mrPlan <- liftEither $ Plan.mutateReadPlan MutationDelete apiReq identifier conf sCache
|
||||
resultSet <- runQuery roleIsoLvl (Plan.mrTxMode mrPlan) $ Query.deleteQuery mrPlan apiReq conf
|
||||
return $ Response.deleteResponse apiReq resultSet
|
||||
(planTime', mrPlan) <- withTiming $ liftEither $ Plan.mutateReadPlan MutationDelete apiReq identifier conf sCache
|
||||
(txTime', resultSet) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.mrTxMode mrPlan) $ Query.deleteQuery mrPlan apiReq conf
|
||||
(respTime', pgrst) <- withTiming $ liftEither $ Response.deleteResponse mrPlan apiReq resultSet
|
||||
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
|
||||
|
||||
(ActionInvoke invMethod, TargetProc identifier _) -> do
|
||||
cPlan <- liftEither $ Plan.callReadPlan identifier conf sCache apiReq invMethod
|
||||
resultSet <- runQuery (fromMaybe roleIsoLvl $ pdIsoLvl (Plan.crProc cPlan))(Plan.crTxMode cPlan) $ Query.invokeQuery (Plan.crProc cPlan) cPlan apiReq conf pgVer
|
||||
return $ Response.invokeResponse invMethod (Plan.crProc cPlan) apiReq resultSet
|
||||
(planTime', cPlan) <- withTiming $ liftEither $ Plan.callReadPlan identifier conf sCache apiReq invMethod
|
||||
(txTime', resultSet) <- withTiming $ runQuery (fromMaybe roleIsoLvl $ pdIsoLvl (Plan.crProc cPlan)) (pdTimeout $ Plan.crProc cPlan) (Plan.crTxMode cPlan) $ Query.invokeQuery (Plan.crProc cPlan) cPlan apiReq conf pgVer
|
||||
(respTime', pgrst) <- withTiming $ liftEither $ Response.invokeResponse cPlan invMethod (Plan.crProc cPlan) apiReq resultSet
|
||||
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
|
||||
|
||||
(ActionInspect headersOnly, TargetDefaultSpec tSchema) -> do
|
||||
oaiResult <- runQuery roleIsoLvl Plan.inspectPlanTxMode $ Query.openApiQuery sCache pgVer conf tSchema
|
||||
return $ Response.openApiResponse (T.decodeUtf8 prettyVersion, docsVersion) headersOnly oaiResult conf sCache iSchema iNegotiatedByProfile
|
||||
(planTime', iPlan) <- withTiming $ liftEither $ Plan.inspectPlan apiReq
|
||||
(txTime', oaiResult) <- withTiming $ runQuery roleIsoLvl Nothing (Plan.ipTxmode iPlan) $ Query.openApiQuery sCache pgVer conf tSchema
|
||||
(respTime', pgrst) <- withTiming $ liftEither $ Response.openApiResponse (T.decodeUtf8 prettyVersion, docsVersion) headersOnly oaiResult conf sCache iSchema iNegotiatedByProfile
|
||||
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' txTime' respTime') pgrst
|
||||
|
||||
(ActionInfo, TargetIdent identifier) ->
|
||||
return $ Response.infoIdentResponse identifier sCache
|
||||
(ActionInfo, TargetIdent identifier) -> do
|
||||
(respTime', pgrst) <- withTiming $ liftEither $ Response.infoIdentResponse identifier sCache
|
||||
return $ pgrstResponse (ServerTiming jwtTime parseTime Nothing Nothing respTime') pgrst
|
||||
|
||||
(ActionInfo, TargetProc identifier _) -> do
|
||||
cPlan <- liftEither $ Plan.callReadPlan identifier conf sCache apiReq ApiRequest.InvHead
|
||||
return $ Response.infoProcResponse (Plan.crProc cPlan)
|
||||
(planTime', cPlan) <- withTiming $ liftEither $ Plan.callReadPlan identifier conf sCache apiReq ApiRequest.InvHead
|
||||
(respTime', pgrst) <- withTiming $ liftEither $ Response.infoProcResponse (Plan.crProc cPlan)
|
||||
return $ pgrstResponse (ServerTiming jwtTime parseTime planTime' Nothing respTime') pgrst
|
||||
|
||||
(ActionInfo, TargetDefaultSpec _) ->
|
||||
return Response.infoRootResponse
|
||||
(ActionInfo, TargetDefaultSpec _) -> do
|
||||
(respTime', pgrst) <- withTiming $ liftEither Response.infoRootResponse
|
||||
return $ pgrstResponse (ServerTiming jwtTime parseTime Nothing Nothing respTime') pgrst
|
||||
|
||||
_ ->
|
||||
-- This is unreachable as the ApiRequest.hs rejects it before
|
||||
@@ -219,8 +228,38 @@ handleRequest AuthResult{..} conf appState authenticated prepared pgVer apiReq@A
|
||||
where
|
||||
roleSettings = fromMaybe mempty (HM.lookup authRole $ configRoleSettings conf)
|
||||
roleIsoLvl = HM.findWithDefault SQL.ReadCommitted authRole $ configRoleIsoLvl conf
|
||||
runQuery isoLvl mode query =
|
||||
runDbHandler appState isoLvl mode authenticated prepared $ do
|
||||
Query.setPgLocals conf authClaims authRole (HM.toList roleSettings) apiReq pgVer
|
||||
runQuery isoLvl timeout mode query =
|
||||
runDbHandler appState conf isoLvl mode authenticated prepared $ do
|
||||
Query.setPgLocals conf authClaims authRole (HM.toList roleSettings) apiReq timeout
|
||||
Query.runPreReq conf
|
||||
query
|
||||
|
||||
pgrstResponse :: ServerTiming -> Response.PgrstResponse -> Wai.Response
|
||||
pgrstResponse timing (Response.PgrstResponse st hdrs bod) = Wai.responseLBS st (hdrs ++ ([serverTimingHeader timing | configServerTimingEnabled conf])) bod
|
||||
|
||||
withTiming = calcTiming $ configServerTimingEnabled conf
|
||||
|
||||
calcTiming :: Bool -> Handler IO a -> Handler IO (Maybe Double, a)
|
||||
calcTiming timingEnabled f = if timingEnabled
|
||||
then do
|
||||
(t, r) <- timeItT f
|
||||
pure (Just t, r)
|
||||
else do
|
||||
r <- f
|
||||
pure (Nothing, r)
|
||||
|
||||
traceHeaderMiddleware :: AppConfig -> Wai.Middleware
|
||||
traceHeaderMiddleware AppConfig{configServerTraceHeader} app req respond =
|
||||
case configServerTraceHeader of
|
||||
Nothing -> app req respond
|
||||
Just hdr ->
|
||||
let hdrVal = L.lookup hdr $ Wai.requestHeaders req in
|
||||
app req (respond . Wai.mapResponseHeaders ([(hdr, fromMaybe mempty hdrVal)] ++))
|
||||
|
||||
addRetryHint :: Int -> Wai.Response -> Wai.Response
|
||||
addRetryHint delay response = do
|
||||
let h = ("Retry-After", BS.pack $ show delay)
|
||||
Wai.mapResponseHeaders (\hs -> if isServiceUnavailable response then h:hs else hs) response
|
||||
|
||||
isServiceUnavailable :: Wai.Response -> Bool
|
||||
isServiceUnavailable response = Wai.responseStatus response == HTTP.status503
|
||||
|
||||
+107
-22
@@ -4,6 +4,7 @@
|
||||
|
||||
module PostgREST.AppState
|
||||
( AppState
|
||||
, AuthResult(..)
|
||||
, destroy
|
||||
, getConfig
|
||||
, getSchemaCache
|
||||
@@ -12,7 +13,11 @@ module PostgREST.AppState
|
||||
, getPgVersion
|
||||
, getRetryNextIn
|
||||
, getTime
|
||||
, getJwtCache
|
||||
, getSocketREST
|
||||
, getSocketAdmin
|
||||
, init
|
||||
, initSockets
|
||||
, initWithPool
|
||||
, logWithZTime
|
||||
, putSchemaCache
|
||||
@@ -24,15 +29,21 @@ module PostgREST.AppState
|
||||
, runListener
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.ByteString.Lazy as LBS
|
||||
import qualified Data.Cache as C
|
||||
import Data.Either.Combinators (whenLeft)
|
||||
import qualified Data.Text as T (unpack)
|
||||
import qualified Data.Text.Encoding as T
|
||||
import Hasql.Connection (acquire)
|
||||
import qualified Hasql.Notifications as SQL
|
||||
import qualified Hasql.Pool as SQL
|
||||
import qualified Hasql.Session as SQL
|
||||
import qualified Hasql.Transaction.Sessions as SQL
|
||||
import qualified Network.HTTP.Types.Status as HTTP
|
||||
import qualified Network.Socket as NS
|
||||
import qualified PostgREST.Error as Error
|
||||
import PostgREST.Version (prettyVersion)
|
||||
|
||||
@@ -48,6 +59,7 @@ import Data.Time (ZonedTime, defaultTimeLocale, formatTime,
|
||||
import Data.Time.Clock (UTCTime, getCurrentTime)
|
||||
|
||||
import PostgREST.Config (AppConfig (..),
|
||||
LogLevel (..),
|
||||
addFallbackAppName,
|
||||
readAppConfig)
|
||||
import PostgREST.Config.Database (queryDbSettings,
|
||||
@@ -58,9 +70,16 @@ import PostgREST.Config.PgVersion (PgVersion (..),
|
||||
import PostgREST.SchemaCache (SchemaCache,
|
||||
querySchemaCache)
|
||||
import PostgREST.SchemaCache.Identifiers (dumpQi)
|
||||
import PostgREST.Unix (createAndBindDomainSocket)
|
||||
|
||||
import Data.Streaming.Network (bindPortTCP, bindRandomPortTCP)
|
||||
import Data.String (IsString (..))
|
||||
import Protolude
|
||||
|
||||
data AuthResult = AuthResult
|
||||
{ authClaims :: KM.KeyMap JSON.Value
|
||||
, authRole :: BS.ByteString
|
||||
}
|
||||
|
||||
data AppState = AppState
|
||||
-- | Database connection pool
|
||||
@@ -87,15 +106,25 @@ data AppState = AppState
|
||||
, stateRetryNextIn :: IORef Int
|
||||
-- | Logs a pool error with a debounce
|
||||
, debounceLogAcquisitionTimeout :: IO ()
|
||||
-- | JWT Cache
|
||||
, jwtCache :: C.Cache ByteString AuthResult
|
||||
-- | Network socket for REST API
|
||||
, stateSocketREST :: NS.Socket
|
||||
-- | Network socket for the admin UI
|
||||
, stateSocketAdmin :: Maybe NS.Socket
|
||||
}
|
||||
|
||||
type AppSockets = (NS.Socket, Maybe NS.Socket)
|
||||
|
||||
init :: AppConfig -> IO AppState
|
||||
init conf = do
|
||||
pool <- initPool conf
|
||||
initWithPool pool conf
|
||||
(sock, adminSock) <- initSockets conf
|
||||
state' <- initWithPool (sock, adminSock) pool conf
|
||||
pure state' { stateSocketREST = sock, stateSocketAdmin = adminSock }
|
||||
|
||||
initWithPool :: SQL.Pool -> AppConfig -> IO AppState
|
||||
initWithPool pool conf = do
|
||||
initWithPool :: AppSockets -> SQL.Pool -> AppConfig -> IO AppState
|
||||
initWithPool (sock, adminSock) pool conf = do
|
||||
appState <- AppState pool
|
||||
<$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step
|
||||
<*> newIORef Nothing
|
||||
@@ -108,6 +137,9 @@ initWithPool pool conf = do
|
||||
<*> myThreadId
|
||||
<*> newIORef 0
|
||||
<*> pure (pure ())
|
||||
<*> C.newCache Nothing
|
||||
<*> pure sock
|
||||
<*> pure adminSock
|
||||
|
||||
|
||||
debLogTimeout <-
|
||||
@@ -131,6 +163,39 @@ initWithPool pool conf = do
|
||||
destroy :: AppState -> IO ()
|
||||
destroy = destroyPool
|
||||
|
||||
initSockets :: AppConfig -> IO AppSockets
|
||||
initSockets AppConfig{..} = do
|
||||
let
|
||||
cfg'usp = configServerUnixSocket
|
||||
cfg'uspm = configServerUnixSocketMode
|
||||
cfg'host = configServerHost
|
||||
cfg'port = configServerPort
|
||||
cfg'adminport = configAdminServerPort
|
||||
|
||||
sock <- case cfg'usp of
|
||||
-- I'm not using `streaming-commons`' bindPath function here because it's not defined for Windows,
|
||||
-- but we need to have runtime error if we try to use it in Windows, not compile time error
|
||||
Just path -> createAndBindDomainSocket path cfg'uspm
|
||||
Nothing -> do
|
||||
(_, sock) <-
|
||||
if cfg'port /= 0
|
||||
then do
|
||||
sock <- bindPortTCP cfg'port (fromString $ T.unpack cfg'host)
|
||||
pure (cfg'port, sock)
|
||||
else do
|
||||
-- explicitly bind to a random port, returning bound port number
|
||||
(num, sock) <- bindRandomPortTCP (fromString $ T.unpack cfg'host)
|
||||
pure (num, sock)
|
||||
pure sock
|
||||
|
||||
adminSock <- case cfg'adminport of
|
||||
Just adminPort -> do
|
||||
adminSock <- bindPortTCP adminPort (fromString $ T.unpack cfg'host)
|
||||
pure $ Just adminSock
|
||||
Nothing -> pure Nothing
|
||||
|
||||
pure (sock, adminSock)
|
||||
|
||||
initPool :: AppConfig -> IO SQL.Pool
|
||||
initPool AppConfig{..} =
|
||||
SQL.acquire
|
||||
@@ -141,12 +206,18 @@ initPool AppConfig{..} =
|
||||
(toUtf8 $ addFallbackAppName prettyVersion configDbUri)
|
||||
|
||||
-- | Run an action with a database connection.
|
||||
usePool :: AppState -> SQL.Session a -> IO (Either SQL.UsageError a)
|
||||
usePool AppState{..} x = do
|
||||
usePool :: AppState -> AppConfig -> SQL.Session a -> IO (Either SQL.UsageError a)
|
||||
usePool appState@AppState{..} AppConfig{configLogLevel} x = do
|
||||
res <- SQL.use statePool x
|
||||
whenLeft res (\case
|
||||
SQL.AcquisitionTimeoutUsageError -> debounceLogAcquisitionTimeout -- this can happen rapidly for many requests, so we debounce
|
||||
_ -> pure ())
|
||||
|
||||
when (configLogLevel > LogCrit) $ do
|
||||
whenLeft res (\case
|
||||
SQL.AcquisitionTimeoutUsageError -> debounceLogAcquisitionTimeout -- this can happen rapidly for many requests, so we debounce
|
||||
error
|
||||
-- TODO We're using the 500 HTTP status for getting all internal db errors but there's no response here. We need a new intermediate type to not rely on the HTTP status.
|
||||
| Error.status (Error.PgError False error) >= HTTP.status500 -> logPgrstError appState error
|
||||
| otherwise -> pure ())
|
||||
|
||||
return res
|
||||
|
||||
-- | Flush the connection pool so that any future use of the pool will
|
||||
@@ -188,6 +259,15 @@ putConfig = atomicWriteIORef . stateConf
|
||||
getTime :: AppState -> IO UTCTime
|
||||
getTime = stateGetTime
|
||||
|
||||
getJwtCache :: AppState -> C.Cache ByteString AuthResult
|
||||
getJwtCache = jwtCache
|
||||
|
||||
getSocketREST :: AppState -> NS.Socket
|
||||
getSocketREST = stateSocketREST
|
||||
|
||||
getSocketAdmin :: AppState -> Maybe NS.Socket
|
||||
getSocketAdmin = stateSocketAdmin
|
||||
|
||||
-- | Log to stderr with local time
|
||||
logWithZTime :: AppState -> Text -> IO ()
|
||||
logWithZTime appState txt = do
|
||||
@@ -229,7 +309,7 @@ loadSchemaCache appState = do
|
||||
conf@AppConfig{..} <- getConfig appState
|
||||
result <-
|
||||
let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
|
||||
usePool appState . transaction SQL.ReadCommitted SQL.Read $
|
||||
usePool appState conf . transaction SQL.ReadCommitted SQL.Read $
|
||||
querySchemaCache conf
|
||||
case result of
|
||||
Left e -> do
|
||||
@@ -272,17 +352,18 @@ internalConnectionWorker :: AppState -> IO ()
|
||||
internalConnectionWorker appState = work
|
||||
where
|
||||
work = do
|
||||
AppConfig{..} <- getConfig appState
|
||||
config@AppConfig{..} <- getConfig appState
|
||||
logWithZTime appState $ "Starting PostgREST " <> T.decodeUtf8 prettyVersion <> "..."
|
||||
logWithZTime appState "Attempting to connect to the database..."
|
||||
connected <- establishConnection appState
|
||||
connected <- establishConnection appState config
|
||||
case connected of
|
||||
FatalConnectionError reason ->
|
||||
-- Fatal error when connecting
|
||||
logWithZTime appState reason >> killThread (getMainThreadId appState)
|
||||
NotConnected ->
|
||||
-- Unreachable because establishConnection will keep trying to connect
|
||||
return ()
|
||||
-- Unreachable because establishConnection will keep trying to connect, unless disable-recovery is turned on
|
||||
unless configDbPoolAutomaticRecovery
|
||||
$ logWithZTime appState "Automatic recovery disabled, exiting." >> killThread (getMainThreadId appState)
|
||||
Connected actualPgVersion -> do
|
||||
-- Procede with initialization
|
||||
putPgVersion appState actualPgVersion
|
||||
@@ -314,8 +395,8 @@ internalConnectionWorker appState = work
|
||||
--
|
||||
-- The connection tries are capped, but if the connection times out no error is
|
||||
-- thrown, just 'False' is returned.
|
||||
establishConnection :: AppState -> IO ConnectionStatus
|
||||
establishConnection appState =
|
||||
establishConnection :: AppState -> AppConfig -> IO ConnectionStatus
|
||||
establishConnection appState config =
|
||||
retrying retrySettings shouldRetry $
|
||||
const $ flushPool appState >> getConnectionStatus
|
||||
where
|
||||
@@ -325,7 +406,7 @@ establishConnection appState =
|
||||
|
||||
getConnectionStatus :: IO ConnectionStatus
|
||||
getConnectionStatus = do
|
||||
pgVersion <- usePool appState $ queryPgVersion False -- No need to prepare the query here, as the connection might not be established
|
||||
pgVersion <- usePool appState config $ queryPgVersion False -- No need to prepare the query here, as the connection might not be established
|
||||
case pgVersion of
|
||||
Left e -> do
|
||||
logPgrstError appState e
|
||||
@@ -344,9 +425,10 @@ establishConnection appState =
|
||||
|
||||
shouldRetry :: RetryStatus -> ConnectionStatus -> IO Bool
|
||||
shouldRetry rs isConnSucc = do
|
||||
AppConfig{..} <- getConfig appState
|
||||
let
|
||||
delay = fromMaybe 0 (rsPreviousDelay rs) `div` backoffMicroseconds
|
||||
itShould = NotConnected == isConnSucc
|
||||
itShould = NotConnected == isConnSucc && configDbPoolAutomaticRecovery
|
||||
when itShould . logWithZTime appState $
|
||||
"Attempting to reconnect to the database in "
|
||||
<> (show delay::Text)
|
||||
@@ -357,10 +439,11 @@ establishConnection appState =
|
||||
-- | Re-reads the config plus config options from the db
|
||||
reReadConfig :: Bool -> AppState -> IO ()
|
||||
reReadConfig startingUp appState = do
|
||||
AppConfig{..} <- getConfig appState
|
||||
config@AppConfig{..} <- getConfig appState
|
||||
pgVer <- getPgVersion appState
|
||||
dbSettings <-
|
||||
if configDbConfig then do
|
||||
qDbSettings <- usePool appState $ queryDbSettings (dumpQi <$> configDbPreConfig) configDbPreparedStatements
|
||||
qDbSettings <- usePool appState config $ queryDbSettings (dumpQi <$> configDbPreConfig) configDbPreparedStatements
|
||||
case qDbSettings of
|
||||
Left e -> do
|
||||
logWithZTime appState
|
||||
@@ -378,7 +461,7 @@ reReadConfig startingUp appState = do
|
||||
pure mempty
|
||||
(roleSettings, roleIsolationLvl) <-
|
||||
if configDbConfig then do
|
||||
rSettings <- usePool appState $ queryRoleSettings configDbPreparedStatements
|
||||
rSettings <- usePool appState config $ queryRoleSettings pgVer configDbPreparedStatements
|
||||
case rSettings of
|
||||
Left e -> do
|
||||
logWithZTime appState "An error ocurred when trying to query the role settings"
|
||||
@@ -420,7 +503,7 @@ listener appState = do
|
||||
waitListener appState
|
||||
|
||||
-- forkFinally allows to detect if the thread dies
|
||||
void . flip forkFinally (handleFinally dbChannel) $ do
|
||||
void . flip forkFinally (handleFinally dbChannel configDbPoolAutomaticRecovery) $ do
|
||||
dbOrError <- acquire $ toUtf8 (addFallbackAppName prettyVersion configDbUri)
|
||||
case dbOrError of
|
||||
Right db -> do
|
||||
@@ -431,7 +514,9 @@ listener appState = do
|
||||
_ ->
|
||||
die $ "Could not listen for notifications on the " <> dbChannel <> " channel"
|
||||
where
|
||||
handleFinally dbChannel _ = do
|
||||
handleFinally _ False _ =
|
||||
logWithZTime appState "Automatic recovery disabled, exiting." >> killThread (getMainThreadId appState)
|
||||
handleFinally dbChannel True _ = do
|
||||
-- if the thread dies, we try to recover
|
||||
logWithZTime appState $ "Retrying listening for notifications on the " <> dbChannel <> " channel.."
|
||||
putIsListenerOn appState False
|
||||
|
||||
+59
-12
@@ -14,6 +14,7 @@ very simple authentication system inside the PostgreSQL database.
|
||||
module PostgREST.Auth
|
||||
( AuthResult (..)
|
||||
, getResult
|
||||
, getJwtDur
|
||||
, getRole
|
||||
, middleware
|
||||
) where
|
||||
@@ -25,6 +26,8 @@ import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.Aeson.Types as JSON
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Lazy.Char8 as LBS
|
||||
import qualified Data.Cache as C
|
||||
import qualified Data.Scientific as Sci
|
||||
import qualified Data.Vault.Lazy as Vault
|
||||
import qualified Data.Vector as V
|
||||
import qualified Network.HTTP.Types.Header as HTTP
|
||||
@@ -35,21 +38,20 @@ import Control.Lens (set)
|
||||
import Control.Monad.Except (liftEither)
|
||||
import Data.Either.Combinators (mapLeft)
|
||||
import Data.List (lookup)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import Data.Time.Clock (UTCTime, nominalDiffTimeToSeconds)
|
||||
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
|
||||
import System.Clock (TimeSpec (..))
|
||||
import System.IO.Unsafe (unsafePerformIO)
|
||||
import System.TimeIt (timeItT)
|
||||
|
||||
import PostgREST.AppState (AppState, getConfig, getTime)
|
||||
import PostgREST.AppState (AppState, AuthResult (..), getConfig,
|
||||
getJwtCache, getTime)
|
||||
import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..))
|
||||
import PostgREST.Error (Error (..))
|
||||
|
||||
import Protolude
|
||||
|
||||
|
||||
data AuthResult = AuthResult
|
||||
{ authClaims :: KM.KeyMap JSON.Value
|
||||
, authRole :: BS.ByteString
|
||||
}
|
||||
|
||||
-- | Receives the JWT secret and audience (from config) and a JWT and returns a
|
||||
-- JSON object of JWT claims.
|
||||
parseToken :: Monad m =>
|
||||
@@ -102,14 +104,52 @@ middleware appState app req respond = do
|
||||
conf <- getConfig appState
|
||||
time <- getTime appState
|
||||
|
||||
let token = fromMaybe "" $ Wai.extractBearerAuth =<< lookup HTTP.hAuthorization (Wai.requestHeaders req)
|
||||
authResult <- runExceptT $
|
||||
parseToken conf (LBS.fromStrict token) time >>=
|
||||
parseClaims conf
|
||||
let token = fromMaybe "" $ Wai.extractBearerAuth =<< lookup HTTP.hAuthorization (Wai.requestHeaders req)
|
||||
parseJwt = runExceptT $ parseToken conf (LBS.fromStrict token) time >>= parseClaims conf
|
||||
|
||||
-- If DbPlanEnabled -> calculate JWT validation time
|
||||
-- If JwtCacheMaxLifetime -> cache JWT validation result
|
||||
req' <- case (configServerTimingEnabled conf, configJwtCacheMaxLifetime conf) of
|
||||
(True, 0) -> do
|
||||
(dur, authResult) <- timeItT parseJwt
|
||||
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
|
||||
|
||||
(True, maxLifetime) -> do
|
||||
(dur, authResult) <- timeItT $ getJWTFromCache appState token maxLifetime parseJwt time
|
||||
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
|
||||
|
||||
(False, 0) -> do
|
||||
authResult <- parseJwt
|
||||
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
|
||||
|
||||
(False, maxLifetime) -> do
|
||||
authResult <- getJWTFromCache appState token maxLifetime parseJwt time
|
||||
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
|
||||
|
||||
let req' = req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
|
||||
app req' respond
|
||||
|
||||
-- | Used to retrieve and insert JWT to JWT Cache
|
||||
getJWTFromCache :: AppState -> ByteString -> Int -> IO (Either Error AuthResult) -> UTCTime -> IO (Either Error AuthResult)
|
||||
getJWTFromCache appState token maxLifetime parseJwt utc = do
|
||||
checkCache <- C.lookup (getJwtCache appState) token
|
||||
authResult <- maybe parseJwt (pure . Right) checkCache
|
||||
|
||||
case (authResult,checkCache) of
|
||||
(Right res, Nothing) -> C.insert' (getJwtCache appState) (getTimeSpec res maxLifetime utc) token res
|
||||
_ -> pure ()
|
||||
|
||||
return authResult
|
||||
|
||||
-- Used to extract JWT exp claim and add to JWT Cache
|
||||
getTimeSpec :: AuthResult -> Int -> UTCTime -> Maybe TimeSpec
|
||||
getTimeSpec res maxLifetime utc = do
|
||||
let expireJSON = KM.lookup "exp" (authClaims res)
|
||||
utcToSecs = floor . nominalDiffTimeToSeconds . utcTimeToPOSIXSeconds
|
||||
sciToInt = fromMaybe 0 . Sci.toBoundedInteger
|
||||
case expireJSON of
|
||||
Just (JSON.Number seconds) -> Just $ TimeSpec (sciToInt seconds - utcToSecs utc) 0
|
||||
_ -> Just $ TimeSpec (fromIntegral maxLifetime :: Int64) 0
|
||||
|
||||
authResultKey :: Vault.Key (Either Error AuthResult)
|
||||
authResultKey = unsafePerformIO Vault.newKey
|
||||
{-# NOINLINE authResultKey #-}
|
||||
@@ -117,5 +157,12 @@ authResultKey = unsafePerformIO Vault.newKey
|
||||
getResult :: Wai.Request -> Maybe (Either Error AuthResult)
|
||||
getResult = Vault.lookup authResultKey . Wai.vault
|
||||
|
||||
jwtDurKey :: Vault.Key Double
|
||||
jwtDurKey = unsafePerformIO Vault.newKey
|
||||
{-# NOINLINE jwtDurKey #-}
|
||||
|
||||
getJwtDur :: Wai.Request -> Maybe Double
|
||||
getJwtDur = Vault.lookup jwtDurKey . Wai.vault
|
||||
|
||||
getRole :: Wai.Request -> Maybe BS.ByteString
|
||||
getRole req = authRole <$> (rightToMaybe =<< getResult req)
|
||||
|
||||
+15
-10
@@ -29,8 +29,8 @@ import qualified PostgREST.Config as Config
|
||||
import Protolude hiding (hPutStrLn)
|
||||
|
||||
|
||||
main :: App.SignalHandlerInstaller -> Maybe App.SocketRunner -> CLI -> IO ()
|
||||
main installSignalHandlers runAppWithSocket CLI{cliCommand, cliPath} = do
|
||||
main :: CLI -> IO ()
|
||||
main CLI{cliCommand, cliPath} = do
|
||||
conf@AppConfig{..} <-
|
||||
either panic identity <$> Config.readAppConfig mempty cliPath Nothing mempty mempty
|
||||
|
||||
@@ -45,7 +45,7 @@ main installSignalHandlers runAppWithSocket CLI{cliCommand, cliPath} = do
|
||||
when configDbConfig $ AppState.reReadConfig True appState
|
||||
putStr . Config.toText =<< AppState.getConfig appState
|
||||
CmdDumpSchema -> putStrLn =<< dumpSchema appState
|
||||
CmdRun -> App.run installSignalHandlers runAppWithSocket appState)
|
||||
CmdRun -> App.run appState)
|
||||
|
||||
-- | Dump SchemaCache schema to JSON
|
||||
dumpSchema :: AppState -> IO LBS.ByteString
|
||||
@@ -53,7 +53,7 @@ dumpSchema appState = do
|
||||
conf@AppConfig{..} <- AppState.getConfig appState
|
||||
result <-
|
||||
let transaction = if configDbPreparedStatements then SQL.transaction else SQL.unpreparedTransaction in
|
||||
AppState.usePool appState $
|
||||
AppState.usePool appState conf $
|
||||
transaction SQL.ReadCommitted SQL.Read $
|
||||
querySchemaCache conf
|
||||
case result of
|
||||
@@ -162,6 +162,9 @@ exampleConfigFile =
|
||||
|## Time in seconds after which to recycle unused pool connections
|
||||
|# db-pool-max-idletime = 30
|
||||
|
|
||||
|## Allow automatic database connection retrying
|
||||
|# db-pool-automatic-recovery = true
|
||||
|
|
||||
|## Stored proc to exec immediately after auth
|
||||
|# db-pre-request = "stored_proc_name"
|
||||
|
|
||||
@@ -188,10 +191,6 @@ exampleConfigFile =
|
||||
|## https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
|
||||
|db-uri = "postgresql://"
|
||||
|
|
||||
|## Determine if GUC request settings for headers, cookies and jwt claims use the legacy names (string with dashes, invalid starting from PostgreSQL v14) with text values instead of the new names (string without dashes, valid on all PostgreSQL versions) with json values.
|
||||
|## For PostgreSQL v14 and up, this setting will be ignored.
|
||||
|db-use-legacy-gucs = true
|
||||
|
|
||||
|# jwt-aud = "your_audience_claim"
|
||||
|
|
||||
|## Jspath to the role claim key
|
||||
@@ -202,6 +201,9 @@ exampleConfigFile =
|
||||
|# jwt-secret = "secret_with_at_least_32_characters"
|
||||
|jwt-secret-is-base64 = false
|
||||
|
|
||||
|## Enables and set JWT Cache max lifetime, disables caching with 0
|
||||
|# jwt-cache-max-lifetime = 0
|
||||
|
|
||||
|## Logging level, the admitted values are: crit, error, warn and info.
|
||||
|log-level = "error"
|
||||
|
|
||||
@@ -212,12 +214,15 @@ exampleConfigFile =
|
||||
|## Base url for the OpenAPI output
|
||||
|openapi-server-proxy-uri = ""
|
||||
|
|
||||
|## Content types to produce raw output
|
||||
|# raw-media-types="image/png, image/jpg"
|
||||
|## Configurable CORS origins
|
||||
|# server-cors-allowed-origins = ""
|
||||
|
|
||||
|server-host = "!4"
|
||||
|server-port = 3000
|
||||
|
|
||||
|## Allow getting the request-response timing information through the `Server-Timing` header
|
||||
|server-timing-enabled = false
|
||||
|
|
||||
|## Unix socket location
|
||||
|## if specified it takes precedence over server-port
|
||||
|# server-unix-socket = "/tmp/pgrst.sock"
|
||||
|
||||
+30
-16
@@ -61,7 +61,6 @@ import PostgREST.Config.JSPath (JSPath, JSPathExp (..),
|
||||
dumpJSPath, pRoleClaimKey)
|
||||
import PostgREST.Config.Proxy (Proxy (..),
|
||||
isMalformedProxyUri, toURI)
|
||||
import PostgREST.MediaType (MediaType (..), toMime)
|
||||
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi,
|
||||
toQi)
|
||||
|
||||
@@ -70,6 +69,7 @@ import Protolude hiding (Proxy, toList)
|
||||
|
||||
data AppConfig = AppConfig
|
||||
{ configAppSettings :: [(Text, Text)]
|
||||
, configDbAggregates :: Bool
|
||||
, configDbAnonRole :: Maybe BS.ByteString
|
||||
, configDbChannel :: Text
|
||||
, configDbChannelEnabled :: Bool
|
||||
@@ -80,6 +80,7 @@ data AppConfig = AppConfig
|
||||
, configDbPoolAcquisitionTimeout :: Int
|
||||
, configDbPoolMaxLifetime :: Int
|
||||
, configDbPoolMaxIdletime :: Int
|
||||
, configDbPoolAutomaticRecovery :: Bool
|
||||
, configDbPreRequest :: Maybe QualifiedIdentifier
|
||||
, configDbPreparedStatements :: Bool
|
||||
, configDbRootSpec :: Maybe QualifiedIdentifier
|
||||
@@ -89,21 +90,22 @@ data AppConfig = AppConfig
|
||||
, configDbTxAllowOverride :: Bool
|
||||
, configDbTxRollbackAll :: Bool
|
||||
, configDbUri :: Text
|
||||
, configDbUseLegacyGucs :: Bool
|
||||
, configFilePath :: Maybe FilePath
|
||||
, configJWKS :: Maybe JWKSet
|
||||
, configJwtAudience :: Maybe StringOrURI
|
||||
, configJwtRoleClaimKey :: JSPath
|
||||
, configJwtSecret :: Maybe BS.ByteString
|
||||
, configJwtSecretIsBase64 :: Bool
|
||||
, configJwtCacheMaxLifetime :: Int
|
||||
, configLogLevel :: LogLevel
|
||||
, configOpenApiMode :: OpenAPIMode
|
||||
, configOpenApiSecurityActive :: Bool
|
||||
, configOpenApiServerProxyUri :: Maybe Text
|
||||
, configRawMediaTypes :: [MediaType]
|
||||
, configServerCorsAllowedOrigins :: Maybe [Text]
|
||||
, configServerHost :: Text
|
||||
, configServerPort :: Int
|
||||
, configServerTraceHeader :: Maybe (CI.CI BS.ByteString)
|
||||
, configServerTimingEnabled :: Bool
|
||||
, configServerUnixSocket :: Maybe FilePath
|
||||
, configServerUnixSocketMode :: FileMode
|
||||
, configAdminServerPort :: Maybe Int
|
||||
@@ -113,6 +115,7 @@ data AppConfig = AppConfig
|
||||
}
|
||||
|
||||
data LogLevel = LogCrit | LogError | LogWarn | LogInfo
|
||||
deriving (Eq, Ord)
|
||||
|
||||
dumpLogLevel :: LogLevel -> Text
|
||||
dumpLogLevel = \case
|
||||
@@ -137,7 +140,8 @@ toText conf =
|
||||
where
|
||||
-- apply conf to all pgrst settings
|
||||
pgrstSettings = (\(k, v) -> (k, v conf)) <$>
|
||||
[("db-anon-role", q . T.decodeUtf8 . fromMaybe "" . configDbAnonRole)
|
||||
[("db-aggregates-enabled", T.toLower . show . configDbAggregates)
|
||||
,("db-anon-role", q . T.decodeUtf8 . fromMaybe "" . configDbAnonRole)
|
||||
,("db-channel", q . configDbChannel)
|
||||
,("db-channel-enabled", T.toLower . show . configDbChannelEnabled)
|
||||
,("db-extra-search-path", q . T.intercalate "," . configDbExtraSearchPath)
|
||||
@@ -147,6 +151,7 @@ toText conf =
|
||||
,("db-pool-acquisition-timeout", show . configDbPoolAcquisitionTimeout)
|
||||
,("db-pool-max-lifetime", show . configDbPoolMaxLifetime)
|
||||
,("db-pool-max-idletime", show . configDbPoolMaxIdletime)
|
||||
,("db-pool-automatic-recovery", T.toLower . show . configDbPoolAutomaticRecovery)
|
||||
,("db-pre-request", q . maybe mempty dumpQi . configDbPreRequest)
|
||||
,("db-prepared-statements", T.toLower . show . configDbPreparedStatements)
|
||||
,("db-root-spec", q . maybe mempty dumpQi . configDbRootSpec)
|
||||
@@ -155,19 +160,20 @@ toText conf =
|
||||
,("db-pre-config", q . maybe mempty dumpQi . configDbPreConfig)
|
||||
,("db-tx-end", q . showTxEnd)
|
||||
,("db-uri", q . configDbUri)
|
||||
,("db-use-legacy-gucs", T.toLower . show . configDbUseLegacyGucs)
|
||||
,("jwt-aud", T.decodeUtf8 . LBS.toStrict . JSON.encode . maybe "" toJSON . configJwtAudience)
|
||||
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
|
||||
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
|
||||
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
|
||||
,("jwt-cache-max-lifetime", show . configJwtCacheMaxLifetime)
|
||||
,("log-level", q . dumpLogLevel . configLogLevel)
|
||||
,("openapi-mode", q . dumpOpenApiMode . configOpenApiMode)
|
||||
,("openapi-security-active", T.toLower . show . configOpenApiSecurityActive)
|
||||
,("openapi-server-proxy-uri", q . fromMaybe mempty . configOpenApiServerProxyUri)
|
||||
,("raw-media-types", q . T.decodeUtf8 . BS.intercalate "," . fmap toMime . configRawMediaTypes)
|
||||
,("server-cors-allowed-origins", q . maybe "" (T.intercalate ",") . configServerCorsAllowedOrigins)
|
||||
,("server-host", q . configServerHost)
|
||||
,("server-port", show . configServerPort)
|
||||
,("server-trace-header", q . T.decodeUtf8 . maybe mempty CI.original . configServerTraceHeader)
|
||||
,("server-timing-enabled", T.toLower . show . configServerTimingEnabled)
|
||||
,("server-unix-socket", q . maybe mempty T.pack . configServerUnixSocket)
|
||||
,("server-unix-socket-mode", q . T.pack . showSocketMode)
|
||||
,("admin-server-port", maybe "\"\"" show . configAdminServerPort)
|
||||
@@ -229,6 +235,7 @@ parser :: Maybe FilePath -> Environment -> [(Text, Text)] -> RoleSettings -> Rol
|
||||
parser optPath env dbSettings roleSettings roleIsolationLvl =
|
||||
AppConfig
|
||||
<$> parseAppSettings "app.settings"
|
||||
<*> (fromMaybe False <$> optBool "db-aggregates-enabled")
|
||||
<*> (fmap encodeUtf8 <$> optString "db-anon-role")
|
||||
<*> (fromMaybe "pgrst" <$> optString "db-channel")
|
||||
<*> (fromMaybe True <$> optBool "db-channel-enabled")
|
||||
@@ -241,6 +248,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
|
||||
<*> (fromMaybe 1800 <$> optInt "db-pool-max-lifetime")
|
||||
<*> (fromMaybe 30 <$> optWithAlias (optInt "db-pool-timeout")
|
||||
(optInt "db-pool-max-idletime"))
|
||||
<*> (fromMaybe True <$> optBool "db-pool-automatic-recovery")
|
||||
<*> (fmap toQi <$> optWithAlias (optString "db-pre-request")
|
||||
(optString "pre-request"))
|
||||
<*> (fromMaybe True <$> optBool "db-prepared-statements")
|
||||
@@ -253,7 +261,6 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
|
||||
<*> parseTxEnd "db-tx-end" snd
|
||||
<*> parseTxEnd "db-tx-end" fst
|
||||
<*> (fromMaybe "postgresql://" <$> optString "db-uri")
|
||||
<*> (fromMaybe True <$> optBool "db-use-legacy-gucs")
|
||||
<*> pure optPath
|
||||
<*> pure Nothing
|
||||
<*> parseJwtAudience "jwt-aud"
|
||||
@@ -262,14 +269,16 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
|
||||
<*> (fromMaybe False <$> optWithAlias
|
||||
(optBool "jwt-secret-is-base64")
|
||||
(optBool "secret-is-base64"))
|
||||
<*> (fromMaybe 0 <$> optInt "jwt-cache-max-lifetime")
|
||||
<*> parseLogLevel "log-level"
|
||||
<*> parseOpenAPIMode "openapi-mode"
|
||||
<*> (fromMaybe False <$> optBool "openapi-security-active")
|
||||
<*> parseOpenAPIServerProxyURI "openapi-server-proxy-uri"
|
||||
<*> (maybe [] (fmap (MTOther . encodeUtf8) . splitOnCommas) <$> optValue "raw-media-types")
|
||||
<*> parseCORSAllowedOrigins "server-cors-allowed-origins"
|
||||
<*> (fromMaybe "!4" <$> optString "server-host")
|
||||
<*> (fromMaybe 3000 <$> optInt "server-port")
|
||||
<*> (fmap (CI.mk . encodeUtf8) <$> optString "server-trace-header")
|
||||
<*> (fromMaybe False <$> optBool "server-timing-enabled")
|
||||
<*> (fmap T.unpack <$> optString "server-unix-socket")
|
||||
<*> parseSocketFileMode "server-unix-socket-mode"
|
||||
<*> optInt "admin-server-port"
|
||||
@@ -348,6 +357,11 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
|
||||
Nothing -> pure [JSPKey "role"]
|
||||
Just rck -> either (fail . show) pure $ pRoleClaimKey rck
|
||||
|
||||
parseCORSAllowedOrigins k =
|
||||
optString k >>= \case
|
||||
Nothing -> pure Nothing
|
||||
Just orig -> pure $ Just (T.strip <$> T.splitOn "," orig)
|
||||
|
||||
optWithAlias :: C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a) -> C.Parser C.Config (Maybe a)
|
||||
optWithAlias orig alias =
|
||||
orig >>= \case
|
||||
@@ -479,26 +493,26 @@ readPGRSTEnvironment =
|
||||
-- >>> addFallbackAppName ver "postgres:///postgres?host=server&port=5432"
|
||||
-- "postgres:///postgres?host=server&port=5432&fallback_application_name=PostgREST%2011.1.0%20%285a04ec7%29"
|
||||
--
|
||||
-- >>> addFallbackAppName ver "host=localhost port=5432 dbname=postgres"
|
||||
-- "host=localhost port=5432 dbname=postgres fallback_application_name='PostgREST 11.1.0 (5a04ec7)'"
|
||||
--
|
||||
-- >>> addFallbackAppName ver "postgresql://"
|
||||
-- "postgresql://?fallback_application_name=PostgREST%2011.1.0%20%285a04ec7%29"
|
||||
--
|
||||
-- >>> addFallbackAppName strangeVer "host=localhost port=5432 dbname=postgres"
|
||||
-- "host=localhost port=5432 dbname=postgres fallback_application_name='PostgREST 11\\'1&0@#$%,.:\"[]{}?+^()=asdfqwer'"
|
||||
--
|
||||
-- >>> addFallbackAppName strangeVer "postgres:///postgres?host=server&port=5432"
|
||||
-- "postgres:///postgres?host=server&port=5432&fallback_application_name=PostgREST%2011%271%260%40%23%24%25%2C.%3A%22%5B%5D%7B%7D%3F%2B%5E%28%29%3Dasdfqwer"
|
||||
--
|
||||
-- >>> addFallbackAppName ver "postgres://user:invalid_chars[]#@host:5432/postgres"
|
||||
-- "postgres://user:invalid_chars[]#@host:5432/postgres"
|
||||
--
|
||||
-- >>> addFallbackAppName ver "invalid_uri1=val1 invalid_uri2=val2"
|
||||
-- "invalid_uri1=val1 invalid_uri2=val2"
|
||||
addFallbackAppName :: ByteString -> Text -> Text
|
||||
addFallbackAppName version dbUri = dbUri <>
|
||||
case uriQuery <$> parseURI (toS dbUri) of
|
||||
Nothing -> " " <> keyValFmt -- Assume key/value connection string if the uri is not valid
|
||||
-- Does not add the application name to key=val connection strings or invalid URIs
|
||||
Nothing -> mempty
|
||||
Just "" -> "?" <> uriFmt
|
||||
Just "?" -> uriFmt
|
||||
_ -> "&" <> uriFmt
|
||||
where
|
||||
uriFmt = pKeyWord <> toS (escapeURIString isUnescapedInURIComponent $ toS pgrstVer)
|
||||
keyValFmt = pKeyWord <> "'" <> T.replace "'" "\\'" pgrstVer <> "'"
|
||||
pKeyWord = "fallback_application_name="
|
||||
pgrstVer = "PostgREST " <> T.decodeUtf8 version
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
module PostgREST.Config.Database
|
||||
( pgVersionStatement
|
||||
, queryDbSettings
|
||||
, queryRoleSettings
|
||||
, queryPgVersion
|
||||
, queryRoleSettings
|
||||
, RoleSettings
|
||||
, RoleIsolationLvl
|
||||
, TimezoneNames
|
||||
, toIsolationLevel
|
||||
) where
|
||||
|
||||
import Control.Arrow ((***))
|
||||
|
||||
import PostgREST.Config.PgVersion (PgVersion (..))
|
||||
import PostgREST.Config.PgVersion (PgVersion (..), pgVersion150)
|
||||
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
|
||||
@@ -29,6 +30,7 @@ import Protolude
|
||||
|
||||
type RoleSettings = (HM.HashMap ByteString (HM.HashMap ByteString ByteString))
|
||||
type RoleIsolationLvl = HM.HashMap ByteString SQL.IsolationLevel
|
||||
type TimezoneNames = Set ByteString -- cache timezone names for prefer timezone=
|
||||
|
||||
toIsolationLevel :: (Eq a, IsString a) => a -> SQL.IsolationLevel
|
||||
toIsolationLevel a = case a of
|
||||
@@ -43,7 +45,8 @@ prefix = "pgrst."
|
||||
dbSettingsNames :: [Text]
|
||||
dbSettingsNames =
|
||||
(prefix <>) <$>
|
||||
["db_anon_role"
|
||||
["db_aggregates_enabled"
|
||||
,"db_anon_role"
|
||||
,"db_pre_config"
|
||||
,"db_extra_search_path"
|
||||
,"db_max_rows"
|
||||
@@ -53,16 +56,17 @@ dbSettingsNames =
|
||||
,"db_root_spec"
|
||||
,"db_schemas"
|
||||
,"db_tx_end"
|
||||
,"db_use_legacy_gucs"
|
||||
,"jwt_aud"
|
||||
,"jwt_role_claim_key"
|
||||
,"jwt_secret"
|
||||
,"jwt_secret_is_base64"
|
||||
,"jwt_cache_max_lifetime"
|
||||
,"openapi_mode"
|
||||
,"openapi_security_active"
|
||||
,"openapi_server_proxy_uri"
|
||||
,"raw_media_types"
|
||||
,"server_trace_header"
|
||||
,"server_timing_enabled"
|
||||
]
|
||||
|
||||
queryPgVersion :: Bool -> Session PgVersion
|
||||
@@ -126,8 +130,8 @@ queryDbSettings preConfFunc prepared =
|
||||
|]::Text
|
||||
decodeSettings = HD.rowList $ (,) <$> column HD.text <*> column HD.text
|
||||
|
||||
queryRoleSettings :: Bool -> Session (RoleSettings, RoleIsolationLvl)
|
||||
queryRoleSettings prepared =
|
||||
queryRoleSettings :: PgVersion -> Bool -> Session (RoleSettings, RoleIsolationLvl)
|
||||
queryRoleSettings pgVer prepared =
|
||||
let transaction = if prepared then SQL.transaction else SQL.unpreparedTransaction in
|
||||
transaction SQL.ReadCommitted SQL.Read $ SQL.statement mempty $ SQL.Statement sql HE.noParams (processRows <$> rows) prepared
|
||||
where
|
||||
@@ -154,9 +158,12 @@ queryRoleSettings prepared =
|
||||
select
|
||||
kv.rolname,
|
||||
i.value as iso_lvl,
|
||||
array_agg(row(kv.key, kv.value)) filter (where key <> 'default_transation_isolation') as role_settings
|
||||
coalesce(array_agg(row(kv.key, kv.value)) filter (where key <> 'default_transaction_isolation'), '{}') as role_settings
|
||||
from kv_settings kv
|
||||
join pg_settings ps on ps.name = kv.key and ps.context = 'user'
|
||||
join pg_settings ps on ps.name = kv.key |] <>
|
||||
(if pgVer >= pgVersion150
|
||||
then "and (ps.context = 'user' or has_parameter_privilege(current_user::regrole::oid, ps.name, 'set')) "
|
||||
else "and ps.context = 'user' ") <> [q|
|
||||
left join iso_setting i on i.rolname = kv.rolname
|
||||
group by kv.rolname, i.value;
|
||||
|]
|
||||
|
||||
@@ -13,6 +13,7 @@ module PostgREST.Config.PgVersion
|
||||
, pgVersion121
|
||||
, pgVersion130
|
||||
, pgVersion140
|
||||
, pgVersion150
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
@@ -62,3 +63,6 @@ pgVersion130 = PgVersion 130000 "13.0"
|
||||
|
||||
pgVersion140 :: PgVersion
|
||||
pgVersion140 = PgVersion 140000 "14.0"
|
||||
|
||||
pgVersion150 :: PgVersion
|
||||
pgVersion150 = PgVersion 150000 "15.0"
|
||||
|
||||
+10
-6
@@ -2,10 +2,14 @@
|
||||
Module : PostgREST.Cors
|
||||
Description : Wai Middleware to set cors policy.
|
||||
-}
|
||||
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
|
||||
module PostgREST.Cors (middleware) where
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
import qualified Data.Text.Encoding as T
|
||||
import qualified Network.Wai as Wai
|
||||
import qualified Network.Wai.Middleware.Cors as Wai
|
||||
|
||||
@@ -13,15 +17,15 @@ import Data.List (lookup)
|
||||
|
||||
import Protolude
|
||||
|
||||
middleware :: Wai.Middleware
|
||||
middleware = Wai.cors corsPolicy
|
||||
middleware :: Maybe [Text] -> Wai.Middleware
|
||||
middleware corsAllowedOrigins = Wai.cors $ corsPolicy corsAllowedOrigins
|
||||
|
||||
-- | CORS policy to be used in by Wai Cors middleware
|
||||
corsPolicy :: Wai.Request -> Maybe Wai.CorsResourcePolicy
|
||||
corsPolicy req = case lookup "origin" headers of
|
||||
Just origin ->
|
||||
corsPolicy :: Maybe [Text] -> Wai.Request -> Maybe Wai.CorsResourcePolicy
|
||||
corsPolicy corsAllowedOrigins req = case lookup "origin" headers of
|
||||
Just _ ->
|
||||
Just Wai.CorsResourcePolicy
|
||||
{ Wai.corsOrigins = Just ([origin], True)
|
||||
{ Wai.corsOrigins = (, True) . map T.encodeUtf8 <$> corsAllowedOrigins
|
||||
, Wai.corsMethods = ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"]
|
||||
, Wai.corsRequestHeaders = "Authorization" : accHeaders
|
||||
, Wai.corsExposedHeaders = Just
|
||||
|
||||
+243
-211
@@ -11,13 +11,15 @@ module PostgREST.Error
|
||||
, PgError(..)
|
||||
, Error(..)
|
||||
, errorPayload
|
||||
, singularityError
|
||||
, status
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.CaseInsensitive as CI
|
||||
import qualified Data.FuzzySet as Fuzzy
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import qualified Data.Map.Internal as M
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as T
|
||||
import qualified Data.Text.Encoding.Error as T
|
||||
@@ -25,7 +27,7 @@ import qualified Hasql.Pool as SQL
|
||||
import qualified Hasql.Session as SQL
|
||||
import qualified Network.HTTP.Types.Status as HTTP
|
||||
|
||||
import Data.Aeson ((.=))
|
||||
import Data.Aeson ((.:), (.:?), (.=))
|
||||
import Network.Wai (Response, responseLBS)
|
||||
|
||||
import Network.HTTP.Types.Header (Header)
|
||||
@@ -55,15 +57,18 @@ class (JSON.ToJSON a) => PgrstError a where
|
||||
errorPayload = JSON.encode
|
||||
|
||||
errorResponseFor :: a -> Response
|
||||
errorResponseFor err = responseLBS (status err) (headers err) $ errorPayload err
|
||||
errorResponseFor err =
|
||||
let baseHeader = MediaType.toContentType MTApplicationJSON in
|
||||
responseLBS (status err) (baseHeader : headers err) $ errorPayload err
|
||||
|
||||
instance PgrstError ApiRequestError where
|
||||
status AggregatesNotAllowed{} = HTTP.status400
|
||||
status AmbiguousRelBetween{} = HTTP.status300
|
||||
status AmbiguousRpc{} = HTTP.status300
|
||||
status BinaryFieldError{} = HTTP.status406
|
||||
status MediaTypeError{} = HTTP.status415
|
||||
status InvalidBody{} = HTTP.status400
|
||||
status InvalidFilters = HTTP.status405
|
||||
status InvalidPreferences{} = HTTP.status400
|
||||
status InvalidRpcMethod{} = HTTP.status405
|
||||
status InvalidRange{} = HTTP.status416
|
||||
status NotFound = HTTP.status404
|
||||
@@ -80,108 +85,132 @@ instance PgrstError ApiRequestError where
|
||||
status UnsupportedMethod{} = HTTP.status405
|
||||
status LimitNoOrderError = HTTP.status400
|
||||
status ColumnNotFound{} = HTTP.status400
|
||||
status GucHeadersError = HTTP.status500
|
||||
status GucStatusError = HTTP.status500
|
||||
status OffLimitsChangesError{} = HTTP.status400
|
||||
status PutMatchingPkError = HTTP.status400
|
||||
status SingularityError{} = HTTP.status406
|
||||
status PGRSTParseError = HTTP.status500
|
||||
|
||||
headers _ = [MediaType.toContentType MTApplicationJSON]
|
||||
headers SingularityError{} = [MediaType.toContentType $ MTVndSingularJSON False]
|
||||
headers _ = mempty
|
||||
|
||||
toJsonPgrstError :: ErrorCode -> Text -> Maybe JSON.Value -> Maybe JSON.Value -> JSON.Value
|
||||
toJsonPgrstError code msg details hint = JSON.object [
|
||||
"code" .= code
|
||||
, "message" .= msg
|
||||
, "details" .= details
|
||||
, "hint" .= hint
|
||||
]
|
||||
|
||||
instance JSON.ToJSON ApiRequestError where
|
||||
toJSON (QueryParamError (QPError message details)) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode00,
|
||||
"message" .= message,
|
||||
"details" .= details,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (InvalidRpcMethod method) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode01,
|
||||
"message" .= ("Cannot use the " <> T.decodeUtf8 method <> " method on RPC"),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (InvalidBody errorMessage) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode02,
|
||||
"message" .= T.decodeUtf8 errorMessage,
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (InvalidRange rangeError) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode03,
|
||||
"message" .= ("Requested range not satisfiable" :: Text),
|
||||
"details" .= (case rangeError of
|
||||
NegativeLimit -> "Limit should be greater than or equal to zero."
|
||||
LowerGTUpper -> "The lower boundary must be lower than or equal to the upper boundary in the Range header."
|
||||
OutOfBounds lower total -> "An offset of " <> lower <> " was requested, but there are only " <> total <> " rows."),
|
||||
"hint" .= JSON.Null]
|
||||
toJSON InvalidFilters = JSON.object [
|
||||
"code" .= ApiRequestErrorCode05,
|
||||
"message" .= ("Filters must include all and only primary key columns with 'eq' operators" :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (UnacceptableSchema schemas) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode06,
|
||||
"message" .= ("The schema must be one of the following: " <> T.intercalate ", " schemas),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (MediaTypeError cts) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode07,
|
||||
"message" .= ("None of these media types are available: " <> T.intercalate ", " (map T.decodeUtf8 cts)),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (QueryParamError (QPError message details)) = toJsonPgrstError
|
||||
ApiRequestErrorCode00 message (Just (JSON.String details)) Nothing
|
||||
|
||||
toJSON (InvalidRpcMethod method) = toJsonPgrstError
|
||||
ApiRequestErrorCode01 ("Cannot use the " <> T.decodeUtf8 method <> " method on RPC") Nothing Nothing
|
||||
|
||||
toJSON (InvalidBody errorMessage) = toJsonPgrstError
|
||||
ApiRequestErrorCode02 (T.decodeUtf8 errorMessage) Nothing Nothing
|
||||
|
||||
toJSON (InvalidRange rangeError) = toJsonPgrstError
|
||||
ApiRequestErrorCode03
|
||||
"Requested range not satisfiable"
|
||||
(Just $ case rangeError of
|
||||
NegativeLimit -> "Limit should be greater than or equal to zero."
|
||||
LowerGTUpper -> "The lower boundary must be lower than or equal to the upper boundary in the Range header."
|
||||
OutOfBounds lower total -> JSON.String $ "An offset of " <> lower <> " was requested, but there are only " <> total <> " rows.")
|
||||
Nothing
|
||||
|
||||
toJSON InvalidFilters = toJsonPgrstError
|
||||
ApiRequestErrorCode05 "Filters must include all and only primary key columns with 'eq' operators" Nothing Nothing
|
||||
|
||||
toJSON (UnacceptableSchema schemas) = toJsonPgrstError
|
||||
ApiRequestErrorCode06 ("The schema must be one of the following: " <> T.intercalate ", " schemas) Nothing Nothing
|
||||
|
||||
toJSON (MediaTypeError cts) = toJsonPgrstError
|
||||
ApiRequestErrorCode07 ("None of these media types are available: " <> T.intercalate ", " (map T.decodeUtf8 cts)) Nothing Nothing
|
||||
|
||||
toJSON NotFound = JSON.object []
|
||||
toJSON (NotEmbedded resource) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode08,
|
||||
"message" .= ("'" <> resource <> "' is not an embedded resource in this request" :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= ("Verify that '" <> resource <> "' is included in the 'select' query parameter." :: Text)]
|
||||
|
||||
toJSON LimitNoOrderError = JSON.object [
|
||||
"code" .= ApiRequestErrorCode09,
|
||||
"message" .= ("A 'limit' was applied without an explicit 'order'":: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= ("Apply an 'order' using unique column(s)" :: Text)]
|
||||
toJSON (NotEmbedded resource) = toJsonPgrstError
|
||||
ApiRequestErrorCode08
|
||||
("'" <> resource <> "' is not an embedded resource in this request")
|
||||
Nothing
|
||||
(Just $ JSON.String $ "Verify that '" <> resource <> "' is included in the 'select' query parameter.")
|
||||
|
||||
toJSON (BinaryFieldError ct) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode13,
|
||||
"message" .= ((T.decodeUtf8 (MediaType.toMime ct) <> " requested but more than one column was selected") :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON LimitNoOrderError = toJsonPgrstError
|
||||
ApiRequestErrorCode09 "A 'limit' was applied without an explicit 'order'" Nothing (Just "Apply an 'order' using unique column(s)")
|
||||
|
||||
toJSON PutLimitNotAllowedError = JSON.object [
|
||||
"code" .= ApiRequestErrorCode14,
|
||||
"message" .= ("limit/offset querystring parameters are not allowed for PUT" :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (OffLimitsChangesError n maxs) = toJsonPgrstError
|
||||
ApiRequestErrorCode10
|
||||
"The maximum number of rows allowed to change was surpassed"
|
||||
(Just $ JSON.String $ T.unwords ["Results contain", show n, "rows changed but the maximum number allowed is", show maxs])
|
||||
Nothing
|
||||
|
||||
toJSON (UnsupportedMethod method) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode17,
|
||||
"message" .= ("Unsupported HTTP method: " <> T.decodeUtf8 method),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON GucHeadersError = toJsonPgrstError
|
||||
ApiRequestErrorCode11 "response.headers guc must be a JSON array composed of objects with a single key and a string value" Nothing Nothing
|
||||
|
||||
toJSON (RelatedOrderNotToOne origin target) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode18,
|
||||
"message" .= ("A related order on '" <> target <> "' is not possible" :: Text),
|
||||
"details" .= ("'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship" :: Text),
|
||||
"hint" .= JSON.Null]
|
||||
toJSON GucStatusError = toJsonPgrstError
|
||||
ApiRequestErrorCode12 "response.status guc must be a valid status code" Nothing Nothing
|
||||
|
||||
toJSON (SpreadNotToOne origin target) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode19,
|
||||
"message" .= ("A spread operation on '" <> target <> "' is not possible" :: Text),
|
||||
"details" .= ("'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship" :: Text),
|
||||
"hint" .= JSON.Null]
|
||||
toJSON PutLimitNotAllowedError = toJsonPgrstError
|
||||
ApiRequestErrorCode14 "limit/offset querystring parameters are not allowed for PUT" Nothing Nothing
|
||||
|
||||
toJSON (UnacceptableFilter target) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode20,
|
||||
"message" .= ("Bad operator on the '" <> target <> "' embedded resource":: Text),
|
||||
"details" .= ("Only is null or not is null filters are allowed on embedded resources":: Text),
|
||||
"hint" .= JSON.Null]
|
||||
toJSON PutMatchingPkError = toJsonPgrstError
|
||||
ApiRequestErrorCode15 "Payload values do not match URL in primary key column(s)" Nothing Nothing
|
||||
|
||||
toJSON (NoRelBetween parent child embedHint schema allRels) = JSON.object [
|
||||
"code" .= SchemaCacheErrorCode00,
|
||||
"message" .= ("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache" :: Text),
|
||||
"details" .= ("Searched for a foreign key relationship between '" <> parent <> "' and '" <> child <> maybe mempty ("' using the hint '" <>) embedHint <> "' in the schema '" <> schema <> "', but no matches were found."),
|
||||
"hint" .= noRelBetweenHint parent child schema allRels]
|
||||
toJSON (SingularityError n) = toJsonPgrstError
|
||||
ApiRequestErrorCode16
|
||||
"JSON object requested, multiple (or no) rows returned"
|
||||
(Just $ JSON.String $ T.unwords ["The result contains", show n, "rows"])
|
||||
Nothing
|
||||
|
||||
toJSON (UnsupportedMethod method) = toJsonPgrstError
|
||||
ApiRequestErrorCode17 ("Unsupported HTTP method: " <> T.decodeUtf8 method) Nothing Nothing
|
||||
|
||||
toJSON (RelatedOrderNotToOne origin target) = toJsonPgrstError
|
||||
ApiRequestErrorCode18
|
||||
("A related order on '" <> target <> "' is not possible")
|
||||
(Just $ JSON.String $ "'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship")
|
||||
Nothing
|
||||
|
||||
toJSON (SpreadNotToOne origin target) = toJsonPgrstError
|
||||
ApiRequestErrorCode19
|
||||
("A spread operation on '" <> target <> "' is not possible")
|
||||
(Just $ JSON.String $ "'" <> origin <> "' and '" <> target <> "' do not form a many-to-one or one-to-one relationship")
|
||||
Nothing
|
||||
|
||||
toJSON (UnacceptableFilter target) = toJsonPgrstError
|
||||
ApiRequestErrorCode20
|
||||
("Bad operator on the '" <> target <> "' embedded resource")
|
||||
(Just "Only is null or not is null filters are allowed on embedded resources")
|
||||
Nothing
|
||||
|
||||
toJSON PGRSTParseError = toJsonPgrstError
|
||||
ApiRequestErrorCode21 "The message and detail field of RAISE 'PGRST' error expects JSON" Nothing Nothing
|
||||
|
||||
toJSON (InvalidPreferences prefs) = toJsonPgrstError
|
||||
ApiRequestErrorCode22
|
||||
"Invalid preferences given with handling=strict"
|
||||
(Just $ JSON.String $ T.decodeUtf8 ("Invalid preferences: " <> BS.intercalate ", " prefs))
|
||||
Nothing
|
||||
|
||||
toJSON AggregatesNotAllowed = toJsonPgrstError
|
||||
ApiRequestErrorCode23 "Use of aggregate functions is not allowed" Nothing Nothing
|
||||
|
||||
toJSON (NoRelBetween parent child embedHint schema allRels) = toJsonPgrstError
|
||||
SchemaCacheErrorCode00
|
||||
("Could not find a relationship between '" <> parent <> "' and '" <> child <> "' in the schema cache")
|
||||
(Just $ JSON.String $ "Searched for a foreign key relationship between '" <> parent <> "' and '" <> child <> maybe mempty ("' using the hint '" <>) embedHint <> "' in the schema '" <> schema <> "', but no matches were found.")
|
||||
(JSON.String <$> noRelBetweenHint parent child schema allRels)
|
||||
|
||||
toJSON (AmbiguousRelBetween parent child rels) = toJsonPgrstError
|
||||
SchemaCacheErrorCode01
|
||||
("Could not embed because more than one relationship was found for '" <> parent <> "' and '" <> child <> "'")
|
||||
(Just $ JSON.toJSONList (compressedRel <$> rels))
|
||||
(Just $ JSON.String $ "Try changing '" <> child <> "' to one of the following: " <> relHint rels <> ". Find the desired relationship in the 'details' key.")
|
||||
|
||||
toJSON (AmbiguousRelBetween parent child rels) = JSON.object [
|
||||
"code" .= SchemaCacheErrorCode01,
|
||||
"message" .= ("Could not embed because more than one relationship was found for '" <> parent <> "' and '" <> child <> "'" :: Text),
|
||||
"details" .= (compressedRel <$> rels),
|
||||
"hint" .= ("Try changing '" <> child <> "' to one of the following: " <> relHint rels <> ". Find the desired relationship in the 'details' key." :: Text)]
|
||||
toJSON (NoRpc schema procName argumentKeys hasPreferSingleObject contentType isInvPost allProcs overloadedProcs) =
|
||||
let func = schema <> "." <> procName
|
||||
prms = T.intercalate ", " argumentKeys
|
||||
@@ -189,10 +218,10 @@ instance JSON.ToJSON ApiRequestError where
|
||||
prmsDet = " with parameter" <> (if length argumentKeys > 1 then "s " else " ") <> prms
|
||||
fmtPrms p = if null argumentKeys then " without parameters" else p
|
||||
onlySingleParams = hasPreferSingleObject || (isInvPost && contentType `elem` [MTTextPlain, MTTextXML, MTOctetStream])
|
||||
in JSON.object [
|
||||
"code" .= SchemaCacheErrorCode02,
|
||||
"message" .= ("Could not find the function " <> func <> (if onlySingleParams then "" else fmtPrms prmsMsg) <> " in the schema cache"),
|
||||
"details" .= ("Searched for the function " <> func <>
|
||||
in toJsonPgrstError
|
||||
SchemaCacheErrorCode02
|
||||
("Could not find the function " <> func <> (if onlySingleParams then "" else fmtPrms prmsMsg) <> " in the schema cache")
|
||||
(Just $ JSON.String $ "Searched for the function " <> func <>
|
||||
(case (hasPreferSingleObject, isInvPost, contentType) of
|
||||
(True, _, _) -> " with a single json/jsonb parameter"
|
||||
(_, True, MTTextPlain) -> " with a single unnamed text parameter"
|
||||
@@ -200,21 +229,20 @@ instance JSON.ToJSON ApiRequestError where
|
||||
(_, True, MTOctetStream) -> " with a single unnamed bytea parameter"
|
||||
(_, True, MTApplicationJSON) -> fmtPrms prmsDet <> " or with a single unnamed json/jsonb parameter"
|
||||
_ -> fmtPrms prmsDet) <>
|
||||
", but no matches were found in the schema cache."),
|
||||
", but no matches were found in the schema cache.")
|
||||
-- The hint will be null in the case of single unnamed parameter functions
|
||||
"hint" .= if onlySingleParams
|
||||
then Nothing
|
||||
else noRpcHint schema procName argumentKeys allProcs overloadedProcs ]
|
||||
toJSON (AmbiguousRpc procs) = JSON.object [
|
||||
"code" .= SchemaCacheErrorCode03,
|
||||
"message" .= ("Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [ppName a <> " => " <> ppType a | a <- pdParams p] <> ")" | p <- procs]),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= ("Try renaming the parameters or the function itself in the database so function overloading can be resolved" :: Text)]
|
||||
toJSON (ColumnNotFound relName colName) = JSON.object [
|
||||
"code" .= SchemaCacheErrorCode04,
|
||||
"message" .= ("Column '" <> colName <> "' of relation '" <> relName <> "' does not exist" :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
(if onlySingleParams
|
||||
then Nothing
|
||||
else JSON.String <$> noRpcHint schema procName argumentKeys allProcs overloadedProcs)
|
||||
|
||||
toJSON (AmbiguousRpc procs) = toJsonPgrstError
|
||||
SchemaCacheErrorCode03
|
||||
("Could not choose the best candidate function between: " <> T.intercalate ", " [pdSchema p <> "." <> pdName p <> "(" <> T.intercalate ", " [ppName a <> " => " <> ppType a | a <- pdParams p] <> ")" | p <- procs])
|
||||
Nothing
|
||||
(Just "Try renaming the parameters or the function itself in the database so function overloading can be resolved")
|
||||
|
||||
toJSON (ColumnNotFound relName colName) = toJsonPgrstError
|
||||
SchemaCacheErrorCode04 ("Column '" <> colName <> "' of relation '" <> relName <> "' does not exist") Nothing Nothing
|
||||
|
||||
-- |
|
||||
-- If no relationship is found then:
|
||||
@@ -359,49 +387,60 @@ type Authenticated = Bool
|
||||
instance PgrstError PgError where
|
||||
status (PgError authed usageError) = pgErrorStatus authed usageError
|
||||
|
||||
headers (PgError _ (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError (SQL.ServerError "PGRST" m d _ _p))))) =
|
||||
case (parseMessage m, parseDetails d) of
|
||||
(Just _, Just r) -> headers PGRSTParseError ++ map intoHeader (M.toList $ getHeaders r)
|
||||
_ -> headers PGRSTParseError
|
||||
where
|
||||
intoHeader (k,v) = (CI.mk $ T.encodeUtf8 k, T.encodeUtf8 v)
|
||||
|
||||
headers err =
|
||||
if status err == HTTP.status401
|
||||
then [MediaType.toContentType MTApplicationJSON, ("WWW-Authenticate", "Bearer") :: Header]
|
||||
else [MediaType.toContentType MTApplicationJSON]
|
||||
then [("WWW-Authenticate", "Bearer") :: Header]
|
||||
else mempty
|
||||
|
||||
instance JSON.ToJSON PgError where
|
||||
toJSON (PgError _ usageError) = JSON.toJSON usageError
|
||||
|
||||
instance JSON.ToJSON SQL.UsageError where
|
||||
toJSON (SQL.ConnectionUsageError e) = JSON.object [
|
||||
"code" .= ConnectionErrorCode00,
|
||||
"message" .= ("Database connection error. Retrying the connection." :: Text),
|
||||
"details" .= (T.decodeUtf8With T.lenientDecode $ fromMaybe "" e :: Text),
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (SQL.ConnectionUsageError e) = toJsonPgrstError
|
||||
ConnectionErrorCode00
|
||||
"Database connection error. Retrying the connection."
|
||||
(Just $ JSON.String $ T.decodeUtf8With T.lenientDecode $ fromMaybe "" e)
|
||||
Nothing
|
||||
|
||||
toJSON (SQL.SessionUsageError e) = JSON.toJSON e -- SQL.Error
|
||||
toJSON SQL.AcquisitionTimeoutUsageError = JSON.object [
|
||||
"code" .= ConnectionErrorCode03,
|
||||
"message" .= ("Timed out acquiring connection from connection pool." :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
|
||||
toJSON SQL.AcquisitionTimeoutUsageError = toJsonPgrstError
|
||||
ConnectionErrorCode03 "Timed out acquiring connection from connection pool." Nothing Nothing
|
||||
|
||||
instance JSON.ToJSON SQL.QueryError where
|
||||
toJSON (SQL.QueryError _ _ e) = JSON.toJSON e
|
||||
|
||||
instance JSON.ToJSON SQL.CommandError where
|
||||
-- Special error raised with code PGRST, to allow full response control
|
||||
toJSON (SQL.ResultError (SQL.ServerError "PGRST" m d _ _p)) =
|
||||
case (parseMessage m, parseDetails d) of
|
||||
(Just r, Just _) -> JSON.object [
|
||||
"code" .= getCode r,
|
||||
"message" .= getMessage r,
|
||||
"details" .= checkMaybe (getDetails r),
|
||||
"hint" .= checkMaybe (getHint r)]
|
||||
_ -> JSON.toJSON PGRSTParseError
|
||||
where
|
||||
checkMaybe = maybe JSON.Null JSON.String
|
||||
|
||||
toJSON (SQL.ResultError (SQL.ServerError c m d h _p)) = JSON.object [
|
||||
"code" .= (T.decodeUtf8 c :: Text),
|
||||
"message" .= (T.decodeUtf8 m :: Text),
|
||||
"details" .= (fmap T.decodeUtf8 d :: Maybe Text),
|
||||
"hint" .= (fmap T.decodeUtf8 h :: Maybe Text)]
|
||||
|
||||
toJSON (SQL.ResultError resultError) = JSON.object [
|
||||
"code" .= InternalErrorCode00,
|
||||
"message" .= (show resultError :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
|
||||
toJSON (SQL.ClientError d) = JSON.object [
|
||||
"code" .= ConnectionErrorCode01,
|
||||
"message" .= ("Database client error. Retrying the connection." :: Text),
|
||||
"details" .= (fmap T.decodeUtf8 d :: Maybe Text),
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (SQL.ResultError resultError) = toJsonPgrstError
|
||||
InternalErrorCode00 (show resultError) Nothing Nothing
|
||||
|
||||
toJSON (SQL.ClientError d) = toJsonPgrstError
|
||||
ConnectionErrorCode01 "Database client error. Retrying the connection." (JSON.String <$> fmap T.decodeUtf8 d) Nothing
|
||||
|
||||
pgErrorStatus :: Bool -> SQL.UsageError -> HTTP.Status
|
||||
pgErrorStatus _ (SQL.ConnectionUsageError _) = HTTP.status503
|
||||
@@ -409,7 +448,7 @@ pgErrorStatus _ SQL.AcquisitionTimeoutUsageError = HTTP.status504
|
||||
pgErrorStatus _ (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ClientError _))) = HTTP.status503
|
||||
pgErrorStatus authed (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError rError))) =
|
||||
case rError of
|
||||
(SQL.ServerError c m _ _ _) ->
|
||||
(SQL.ServerError c m d _ _) ->
|
||||
case BS.unpack c of
|
||||
'0':'8':_ -> HTTP.status503 -- pg connection err
|
||||
'0':'9':_ -> HTTP.status500 -- triggered action exception
|
||||
@@ -442,96 +481,49 @@ pgErrorStatus authed (SQL.SessionUsageError (SQL.QueryError _ _ (SQL.ResultError
|
||||
"42P01" -> HTTP.status404 -- undefined table
|
||||
"42501" -> if authed then HTTP.status403 else HTTP.status401 -- insufficient privilege
|
||||
'P':'T':n -> fromMaybe HTTP.status500 (HTTP.mkStatus <$> readMaybe n <*> pure m)
|
||||
"PGRST" ->
|
||||
case (parseMessage m, parseDetails d) of
|
||||
(Just _, Just r) -> maybe (toEnum $ getStatus r) (HTTP.mkStatus (getStatus r) . T.encodeUtf8) (getStatusText r)
|
||||
_ -> status PGRSTParseError
|
||||
_ -> HTTP.status400
|
||||
|
||||
_ -> HTTP.status500
|
||||
|
||||
|
||||
|
||||
data Error
|
||||
= ApiRequestError ApiRequestError
|
||||
| GucHeadersError
|
||||
| GucStatusError
|
||||
| JwtTokenInvalid Text
|
||||
| JwtTokenMissing
|
||||
| JwtTokenRequired
|
||||
| NoSchemaCacheError
|
||||
| OffLimitsChangesError Int64 Integer
|
||||
| PgErr PgError
|
||||
| PutMatchingPkError
|
||||
| SingularityError Integer
|
||||
|
||||
instance PgrstError Error where
|
||||
status (ApiRequestError err) = status err
|
||||
status GucHeadersError = HTTP.status500
|
||||
status GucStatusError = HTTP.status500
|
||||
status JwtTokenInvalid{} = HTTP.unauthorized401
|
||||
status JwtTokenMissing = HTTP.status500
|
||||
status JwtTokenRequired = HTTP.unauthorized401
|
||||
status NoSchemaCacheError = HTTP.status503
|
||||
status OffLimitsChangesError{} = HTTP.status400
|
||||
status (PgErr err) = status err
|
||||
status PutMatchingPkError = HTTP.status400
|
||||
status SingularityError{} = HTTP.status406
|
||||
status (ApiRequestError err) = status err
|
||||
status JwtTokenInvalid{} = HTTP.unauthorized401
|
||||
status JwtTokenMissing = HTTP.status500
|
||||
status JwtTokenRequired = HTTP.unauthorized401
|
||||
status NoSchemaCacheError = HTTP.status503
|
||||
status (PgErr err) = status err
|
||||
|
||||
headers (ApiRequestError err) = headers err
|
||||
headers (JwtTokenInvalid m) = [MediaType.toContentType MTApplicationJSON, invalidTokenHeader m]
|
||||
headers JwtTokenRequired = [MediaType.toContentType MTApplicationJSON, requiredTokenHeader]
|
||||
headers (PgErr err) = headers err
|
||||
headers SingularityError{} = [MediaType.toContentType (MTSingularJSON False)]
|
||||
headers _ = [MediaType.toContentType MTApplicationJSON]
|
||||
headers (ApiRequestError err) = headers err
|
||||
headers (JwtTokenInvalid m) = [invalidTokenHeader m]
|
||||
headers JwtTokenRequired = [requiredTokenHeader]
|
||||
headers (PgErr err) = headers err
|
||||
headers _ = mempty
|
||||
|
||||
instance JSON.ToJSON Error where
|
||||
toJSON NoSchemaCacheError = JSON.object [
|
||||
"code" .= ConnectionErrorCode02,
|
||||
"message" .= ("Could not query the database for the schema cache. Retrying." :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON NoSchemaCacheError = toJsonPgrstError
|
||||
ConnectionErrorCode02 "Could not query the database for the schema cache. Retrying." Nothing Nothing
|
||||
|
||||
toJSON JwtTokenMissing = JSON.object [
|
||||
"code" .= JWTErrorCode00,
|
||||
"message" .= ("Server lacks JWT secret" :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (JwtTokenInvalid message) = JSON.object [
|
||||
"code" .= JWTErrorCode01,
|
||||
"message" .= (message :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON JwtTokenRequired = JSON.object [
|
||||
"code" .= JWTErrorCode02,
|
||||
"message" .= ("Anonymous access is disabled" :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON JwtTokenMissing = toJsonPgrstError
|
||||
JWTErrorCode00 "Server lacks JWT secret" Nothing Nothing
|
||||
|
||||
toJSON (OffLimitsChangesError n maxs) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode10,
|
||||
"message" .= ("The maximum number of rows allowed to change was surpassed" :: Text),
|
||||
"details" .= T.unwords ["Results contain", show n, "rows changed but the maximum number allowed is", show maxs],
|
||||
"hint" .= JSON.Null]
|
||||
toJSON (JwtTokenInvalid message) = toJsonPgrstError
|
||||
JWTErrorCode01 message Nothing Nothing
|
||||
|
||||
toJSON GucHeadersError = JSON.object [
|
||||
"code" .= ApiRequestErrorCode11,
|
||||
"message" .= ("response.headers guc must be a JSON array composed of objects with a single key and a string value" :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
toJSON GucStatusError = JSON.object [
|
||||
"code" .= ApiRequestErrorCode12,
|
||||
"message" .= ("response.status guc must be a valid status code" :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
|
||||
toJSON PutMatchingPkError = JSON.object [
|
||||
"code" .= ApiRequestErrorCode15,
|
||||
"message" .= ("Payload values do not match URL in primary key column(s)" :: Text),
|
||||
"details" .= JSON.Null,
|
||||
"hint" .= JSON.Null]
|
||||
|
||||
toJSON (SingularityError n) = JSON.object [
|
||||
"code" .= ApiRequestErrorCode16,
|
||||
"message" .= ("JSON object requested, multiple (or no) rows returned" :: Text),
|
||||
"details" .= T.unwords ["The result contains", show n, "rows"],
|
||||
"hint" .= JSON.Null]
|
||||
toJSON JwtTokenRequired = toJsonPgrstError
|
||||
JWTErrorCode02 "Anonymous access is disabled" Nothing Nothing
|
||||
|
||||
toJSON (PgErr err) = JSON.toJSON err
|
||||
toJSON (ApiRequestError err) = JSON.toJSON err
|
||||
@@ -543,8 +535,44 @@ invalidTokenHeader m =
|
||||
requiredTokenHeader :: Header
|
||||
requiredTokenHeader = ("WWW-Authenticate", "Bearer")
|
||||
|
||||
singularityError :: (Integral a) => a -> Error
|
||||
singularityError = SingularityError . toInteger
|
||||
-- For parsing byteString to JSON Object, used for allowing full response control
|
||||
data PgRaiseErrMessage = PgRaiseErrMessage {
|
||||
getCode :: Text,
|
||||
getMessage :: Text,
|
||||
getDetails :: Maybe Text,
|
||||
getHint :: Maybe Text
|
||||
}
|
||||
|
||||
data PgRaiseErrDetails = PgRaiseErrDetails {
|
||||
getStatus :: Int,
|
||||
getStatusText :: Maybe Text,
|
||||
getHeaders :: Map Text Text
|
||||
}
|
||||
|
||||
instance JSON.FromJSON PgRaiseErrMessage where
|
||||
parseJSON (JSON.Object m) =
|
||||
PgRaiseErrMessage
|
||||
<$> m .: "code"
|
||||
<*> m .: "message"
|
||||
<*> m .:? "details"
|
||||
<*> m .:? "hint"
|
||||
|
||||
parseJSON _ = mzero
|
||||
|
||||
instance JSON.FromJSON PgRaiseErrDetails where
|
||||
parseJSON (JSON.Object d) =
|
||||
PgRaiseErrDetails
|
||||
<$> d .: "status"
|
||||
<*> d .:? "status_text"
|
||||
<*> d .: "headers"
|
||||
|
||||
parseJSON _ = mzero
|
||||
|
||||
parseMessage :: ByteString -> Maybe PgRaiseErrMessage
|
||||
parseMessage = JSON.decodeStrict
|
||||
|
||||
parseDetails :: Maybe ByteString -> Maybe PgRaiseErrDetails
|
||||
parseDetails d = JSON.decodeStrict =<< d
|
||||
|
||||
-- Error codes are grouped by common modules or characteristics
|
||||
data ErrorCode
|
||||
@@ -558,7 +586,7 @@ data ErrorCode
|
||||
| ApiRequestErrorCode01
|
||||
| ApiRequestErrorCode02
|
||||
| ApiRequestErrorCode03
|
||||
| ApiRequestErrorCode04 -- no longer used (used to be mapped to ParseRequestError)
|
||||
-- | ApiRequestErrorCode04 -- no longer used (used to be mapped to ParseRequestError)
|
||||
| ApiRequestErrorCode05
|
||||
| ApiRequestErrorCode06
|
||||
| ApiRequestErrorCode07
|
||||
@@ -566,8 +594,8 @@ data ErrorCode
|
||||
| ApiRequestErrorCode09
|
||||
| ApiRequestErrorCode10
|
||||
| ApiRequestErrorCode11
|
||||
-- | ApiRequestErrorCode13 -- no longer used (used to be mapped to BinaryFieldError)
|
||||
| ApiRequestErrorCode12
|
||||
| ApiRequestErrorCode13
|
||||
| ApiRequestErrorCode14
|
||||
| ApiRequestErrorCode15
|
||||
| ApiRequestErrorCode16
|
||||
@@ -575,6 +603,9 @@ data ErrorCode
|
||||
| ApiRequestErrorCode18
|
||||
| ApiRequestErrorCode19
|
||||
| ApiRequestErrorCode20
|
||||
| ApiRequestErrorCode21
|
||||
| ApiRequestErrorCode22
|
||||
| ApiRequestErrorCode23
|
||||
-- Schema Cache errors
|
||||
| SchemaCacheErrorCode00
|
||||
| SchemaCacheErrorCode01
|
||||
@@ -604,7 +635,6 @@ buildErrorCode code = "PGRST" <> case code of
|
||||
ApiRequestErrorCode01 -> "101"
|
||||
ApiRequestErrorCode02 -> "102"
|
||||
ApiRequestErrorCode03 -> "103"
|
||||
ApiRequestErrorCode04 -> "104"
|
||||
ApiRequestErrorCode05 -> "105"
|
||||
ApiRequestErrorCode06 -> "106"
|
||||
ApiRequestErrorCode07 -> "107"
|
||||
@@ -613,7 +643,6 @@ buildErrorCode code = "PGRST" <> case code of
|
||||
ApiRequestErrorCode10 -> "110"
|
||||
ApiRequestErrorCode11 -> "111"
|
||||
ApiRequestErrorCode12 -> "112"
|
||||
ApiRequestErrorCode13 -> "113"
|
||||
ApiRequestErrorCode14 -> "114"
|
||||
ApiRequestErrorCode15 -> "115"
|
||||
ApiRequestErrorCode16 -> "116"
|
||||
@@ -621,6 +650,9 @@ buildErrorCode code = "PGRST" <> case code of
|
||||
ApiRequestErrorCode18 -> "118"
|
||||
ApiRequestErrorCode19 -> "119"
|
||||
ApiRequestErrorCode20 -> "120"
|
||||
ApiRequestErrorCode21 -> "121"
|
||||
ApiRequestErrorCode22 -> "122"
|
||||
ApiRequestErrorCode23 -> "123"
|
||||
|
||||
SchemaCacheErrorCode00 -> "200"
|
||||
SchemaCacheErrorCode01 -> "201"
|
||||
|
||||
+33
-42
@@ -1,9 +1,10 @@
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
|
||||
module PostgREST.MediaType
|
||||
( MediaType(..)
|
||||
, MTPlanOption (..)
|
||||
, MTPlanFormat (..)
|
||||
, MTVndPlanOption (..)
|
||||
, MTVndPlanFormat (..)
|
||||
, toContentType
|
||||
, toMime
|
||||
, decodeMediaType
|
||||
@@ -19,8 +20,6 @@ import Protolude
|
||||
-- | Enumeration of currently supported media types
|
||||
data MediaType
|
||||
= MTApplicationJSON
|
||||
| MTArrayJSONStrip
|
||||
| MTSingularJSON Bool
|
||||
| MTGeoJSON
|
||||
| MTTextCSV
|
||||
| MTTextPlain
|
||||
@@ -30,32 +29,23 @@ data MediaType
|
||||
| MTOctetStream
|
||||
| MTAny
|
||||
| MTOther ByteString
|
||||
-- TODO MTPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes.
|
||||
| MTPlan MediaType MTPlanFormat [MTPlanOption]
|
||||
deriving Show
|
||||
instance Eq MediaType where
|
||||
MTApplicationJSON == MTApplicationJSON = True
|
||||
MTArrayJSONStrip == MTArrayJSONStrip = True
|
||||
MTSingularJSON x == MTSingularJSON y = x == y
|
||||
MTGeoJSON == MTGeoJSON = True
|
||||
MTTextCSV == MTTextCSV = True
|
||||
MTTextPlain == MTTextPlain = True
|
||||
MTTextXML == MTTextXML = True
|
||||
MTOpenAPI == MTOpenAPI = True
|
||||
MTUrlEncoded == MTUrlEncoded = True
|
||||
MTOctetStream == MTOctetStream = True
|
||||
MTAny == MTAny = True
|
||||
MTOther x == MTOther y = x == y
|
||||
MTPlan{} == MTPlan{} = True
|
||||
_ == _ = False
|
||||
-- vendored media types
|
||||
| MTVndArrayJSONStrip
|
||||
| MTVndSingularJSON Bool
|
||||
-- TODO MTVndPlan should only have its options as [Text]. Its ResultAggregate should have the typed attributes.
|
||||
| MTVndPlan MediaType MTVndPlanFormat [MTVndPlanOption]
|
||||
deriving (Eq, Show, Generic)
|
||||
instance Hashable MediaType
|
||||
|
||||
data MTPlanOption
|
||||
data MTVndPlanOption
|
||||
= PlanAnalyze | PlanVerbose | PlanSettings | PlanBuffers | PlanWAL
|
||||
deriving (Eq, Show)
|
||||
deriving (Eq, Show, Generic)
|
||||
instance Hashable MTVndPlanOption
|
||||
|
||||
data MTPlanFormat
|
||||
data MTVndPlanFormat
|
||||
= PlanJSON | PlanText
|
||||
deriving (Eq, Show)
|
||||
deriving (Eq, Show, Generic)
|
||||
instance Hashable MTVndPlanFormat
|
||||
|
||||
-- | Convert MediaType to a Content-Type HTTP Header
|
||||
toContentType :: MediaType -> Header
|
||||
@@ -69,31 +59,31 @@ toContentType ct = (hContentType, toMime ct <> charset)
|
||||
-- | Convert from MediaType to a ByteString representing the mime type
|
||||
toMime :: MediaType -> ByteString
|
||||
toMime MTApplicationJSON = "application/json"
|
||||
toMime MTArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped"
|
||||
toMime MTVndArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped"
|
||||
toMime MTGeoJSON = "application/geo+json"
|
||||
toMime MTTextCSV = "text/csv"
|
||||
toMime MTTextPlain = "text/plain"
|
||||
toMime MTTextXML = "text/xml"
|
||||
toMime MTOpenAPI = "application/openapi+json"
|
||||
toMime (MTSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped"
|
||||
toMime (MTSingularJSON False) = "application/vnd.pgrst.object+json"
|
||||
toMime (MTVndSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped"
|
||||
toMime (MTVndSingularJSON False) = "application/vnd.pgrst.object+json"
|
||||
toMime MTUrlEncoded = "application/x-www-form-urlencoded"
|
||||
toMime MTOctetStream = "application/octet-stream"
|
||||
toMime MTAny = "*/*"
|
||||
toMime (MTOther ct) = ct
|
||||
toMime (MTPlan mt fmt opts) =
|
||||
toMime (MTVndPlan mt fmt opts) =
|
||||
"application/vnd.pgrst.plan+" <> toMimePlanFormat fmt <>
|
||||
("; for=\"" <> toMime mt <> "\"") <>
|
||||
(if null opts then mempty else "; options=" <> BS.intercalate "|" (toMimePlanOption <$> opts))
|
||||
|
||||
toMimePlanOption :: MTPlanOption -> ByteString
|
||||
toMimePlanOption :: MTVndPlanOption -> ByteString
|
||||
toMimePlanOption PlanAnalyze = "analyze"
|
||||
toMimePlanOption PlanVerbose = "verbose"
|
||||
toMimePlanOption PlanSettings = "settings"
|
||||
toMimePlanOption PlanBuffers = "buffers"
|
||||
toMimePlanOption PlanWAL = "wal"
|
||||
|
||||
toMimePlanFormat :: MTPlanFormat -> ByteString
|
||||
toMimePlanFormat :: MTVndPlanFormat -> ByteString
|
||||
toMimePlanFormat PlanJSON = "json"
|
||||
toMimePlanFormat PlanText = "text"
|
||||
|
||||
@@ -103,25 +93,25 @@ toMimePlanFormat PlanText = "text"
|
||||
-- MTApplicationJSON
|
||||
--
|
||||
-- >>> decodeMediaType "application/vnd.pgrst.plan;"
|
||||
-- MTPlan MTApplicationJSON PlanText []
|
||||
-- MTVndPlan MTApplicationJSON PlanText []
|
||||
--
|
||||
-- >>> decodeMediaType "application/vnd.pgrst.plan;for=\"application/json\""
|
||||
-- MTPlan MTApplicationJSON PlanText []
|
||||
-- MTVndPlan MTApplicationJSON PlanText []
|
||||
--
|
||||
-- >>> decodeMediaType "application/vnd.pgrst.plan+json;for=\"text/csv\""
|
||||
-- MTPlan MTTextCSV PlanJSON []
|
||||
-- MTVndPlan MTTextCSV PlanJSON []
|
||||
--
|
||||
-- >>> decodeMediaType "application/vnd.pgrst.array+json;nulls=stripped"
|
||||
-- MTArrayJSONStrip
|
||||
-- MTVndArrayJSONStrip
|
||||
--
|
||||
-- >>> decodeMediaType "application/vnd.pgrst.array+json"
|
||||
-- MTApplicationJSON
|
||||
--
|
||||
-- >>> decodeMediaType "application/vnd.pgrst.object+json;nulls=stripped"
|
||||
-- MTSingularJSON True
|
||||
-- MTVndSingularJSON True
|
||||
--
|
||||
-- >>> decodeMediaType "application/vnd.pgrst.object+json"
|
||||
-- MTSingularJSON False
|
||||
-- MTVndSingularJSON False
|
||||
|
||||
decodeMediaType :: BS.ByteString -> MediaType
|
||||
decodeMediaType mt =
|
||||
@@ -140,15 +130,16 @@ decodeMediaType mt =
|
||||
"application/vnd.pgrst.object+json":rest -> checkSingularNullStrip rest
|
||||
"application/vnd.pgrst.object":rest -> checkSingularNullStrip rest
|
||||
"application/vnd.pgrst.array+json":rest -> checkArrayNullStrip rest
|
||||
"application/vnd.pgrst.array":rest -> checkArrayNullStrip rest
|
||||
"*/*":_ -> MTAny
|
||||
other:_ -> MTOther other
|
||||
_ -> MTAny
|
||||
where
|
||||
checkArrayNullStrip ["nulls=stripped"] = MTArrayJSONStrip
|
||||
checkArrayNullStrip ["nulls=stripped"] = MTVndArrayJSONStrip
|
||||
checkArrayNullStrip _ = MTApplicationJSON
|
||||
|
||||
checkSingularNullStrip ["nulls=stripped"] = MTSingularJSON True
|
||||
checkSingularNullStrip _ = MTSingularJSON False
|
||||
checkSingularNullStrip ["nulls=stripped"] = MTVndSingularJSON True
|
||||
checkSingularNullStrip _ = MTVndSingularJSON False
|
||||
|
||||
getPlan fmt rest =
|
||||
let
|
||||
@@ -160,7 +151,7 @@ decodeMediaType mt =
|
||||
strippedFor <- BS.stripPrefix "for=" foundFor
|
||||
pure . decodeMediaType $ dropAround (== BS.c2w '"') strippedFor
|
||||
in
|
||||
MTPlan mtFor fmt $
|
||||
MTVndPlan mtFor fmt $
|
||||
[PlanAnalyze | inOpts "analyze" ] ++
|
||||
[PlanVerbose | inOpts "verbose" ] ++
|
||||
[PlanSettings | inOpts "settings"] ++
|
||||
|
||||
+279
-132
@@ -19,10 +19,11 @@ module PostgREST.Plan
|
||||
( wrappedReadPlan
|
||||
, mutateReadPlan
|
||||
, callReadPlan
|
||||
, inspectPlan
|
||||
, WrappedReadPlan(..)
|
||||
, MutateReadPlan(..)
|
||||
, CallReadPlan(..)
|
||||
, inspectPlanTxMode
|
||||
, InspectPlan(..)
|
||||
) where
|
||||
|
||||
import qualified Data.ByteString.Lazy as LBS
|
||||
@@ -33,7 +34,7 @@ import qualified Data.Set as S
|
||||
import qualified PostgREST.SchemaCache.Routine as Routine
|
||||
|
||||
import Data.Either.Combinators (mapLeft, mapRight)
|
||||
import Data.List (delete)
|
||||
import Data.List (delete, lookup)
|
||||
import Data.Tree (Tree (..))
|
||||
|
||||
import PostgREST.ApiRequest (Action (..),
|
||||
@@ -51,6 +52,7 @@ import PostgREST.RangeQuery (NonnegRange, allRange,
|
||||
import PostgREST.SchemaCache (SchemaCache (..))
|
||||
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||
QualifiedIdentifier (..),
|
||||
RelIdentifier (..),
|
||||
Schema)
|
||||
import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||
Junction (..),
|
||||
@@ -59,7 +61,9 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||
relIsToOne)
|
||||
import PostgREST.SchemaCache.Representations (DataRepresentation (..),
|
||||
RepresentationsMap)
|
||||
import PostgREST.SchemaCache.Routine (ResultAggregate (..),
|
||||
import PostgREST.SchemaCache.Routine (MediaHandler (..),
|
||||
MediaHandlerMap,
|
||||
ResolvedHandler,
|
||||
Routine (..),
|
||||
RoutineMap,
|
||||
RoutineParam (..),
|
||||
@@ -80,6 +84,7 @@ import PostgREST.Plan.Types
|
||||
|
||||
import qualified Hasql.Transaction.Sessions as SQL
|
||||
import qualified PostgREST.ApiRequest.QueryParams as QueryParams
|
||||
import qualified PostgREST.MediaType as MediaType
|
||||
|
||||
import Protolude hiding (from)
|
||||
|
||||
@@ -90,14 +95,18 @@ import Protolude hiding (from)
|
||||
data WrappedReadPlan = WrappedReadPlan {
|
||||
wrReadPlan :: ReadPlanTree
|
||||
, wrTxMode :: SQL.Mode
|
||||
, wrResAgg :: ResultAggregate
|
||||
, wrHandler :: MediaHandler
|
||||
, wrMedia :: MediaType
|
||||
, wrIdent :: QualifiedIdentifier
|
||||
}
|
||||
|
||||
data MutateReadPlan = MutateReadPlan {
|
||||
mrReadPlan :: ReadPlanTree
|
||||
, mrMutatePlan :: MutatePlan
|
||||
, mrTxMode :: SQL.Mode
|
||||
, mrResAgg :: ResultAggregate
|
||||
, mrHandler :: MediaHandler
|
||||
, mrMedia :: MediaType
|
||||
, mrIdent :: QualifiedIdentifier
|
||||
}
|
||||
|
||||
data CallReadPlan = CallReadPlan {
|
||||
@@ -105,37 +114,46 @@ data CallReadPlan = CallReadPlan {
|
||||
, crCallPlan :: CallPlan
|
||||
, crTxMode :: SQL.Mode
|
||||
, crProc :: Routine
|
||||
, crResAgg :: ResultAggregate
|
||||
, crHandler :: MediaHandler
|
||||
, crMedia :: MediaType
|
||||
, crIdent :: QualifiedIdentifier
|
||||
}
|
||||
|
||||
data InspectPlan = InspectPlan {
|
||||
ipMedia :: MediaType
|
||||
, ipTxmode :: SQL.Mode
|
||||
}
|
||||
|
||||
wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error WrappedReadPlan
|
||||
wrappedReadPlan identifier conf sCache apiRequest = do
|
||||
wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} = do
|
||||
rPlan <- readPlan identifier conf sCache apiRequest
|
||||
binField <- mapLeft ApiRequestError $ binaryField conf (iAcceptMediaType apiRequest) Nothing rPlan
|
||||
return $ WrappedReadPlan rPlan SQL.Read $ mediaToAggregate (iAcceptMediaType apiRequest) binField apiRequest
|
||||
(hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache)
|
||||
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
|
||||
return $ WrappedReadPlan rPlan SQL.Read hdler mediaType identifier
|
||||
|
||||
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error MutateReadPlan
|
||||
mutateReadPlan mutation apiRequest identifier conf sCache = do
|
||||
mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do
|
||||
rPlan <- readPlan identifier conf sCache apiRequest
|
||||
binField <- mapLeft ApiRequestError $ binaryField conf (iAcceptMediaType apiRequest) Nothing rPlan
|
||||
mPlan <- mutatePlan mutation identifier apiRequest sCache rPlan
|
||||
return $ MutateReadPlan rPlan mPlan SQL.Write $ mediaToAggregate (iAcceptMediaType apiRequest) binField apiRequest
|
||||
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
|
||||
(hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest identifier iAcceptMediaType (dbMediaHandlers sCache)
|
||||
return $ MutateReadPlan rPlan mPlan SQL.Write hdler mediaType identifier
|
||||
|
||||
callReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> InvokeMethod -> Either Error CallReadPlan
|
||||
callReadPlan identifier conf sCache apiRequest invMethod = do
|
||||
callReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} invMethod = do
|
||||
let paramKeys = case invMethod of
|
||||
InvGet -> S.fromList $ fst <$> qsParams'
|
||||
InvHead -> S.fromList $ fst <$> qsParams'
|
||||
InvPost -> iColumns apiRequest
|
||||
InvPost -> iColumns
|
||||
proc@Function{..} <- mapLeft ApiRequestError $
|
||||
findProc identifier paramKeys (preferParameters == Just SingleObject) (dbRoutines sCache) (iContentMediaType apiRequest) (invMethod == InvPost)
|
||||
findProc identifier paramKeys (preferParameters == Just SingleObject) (dbRoutines sCache) iContentMediaType (invMethod == InvPost)
|
||||
let relIdentifier = QualifiedIdentifier pdSchema (fromMaybe pdName $ Routine.funcTableName proc) -- done so a set returning function can embed other relations
|
||||
rPlan <- readPlan relIdentifier conf sCache apiRequest
|
||||
let args = case (invMethod, iContentMediaType apiRequest) of
|
||||
let args = case (invMethod, iContentMediaType) of
|
||||
(InvGet, _) -> jsonRpcParams proc qsParams'
|
||||
(InvHead, _) -> jsonRpcParams proc qsParams'
|
||||
(InvPost, MTUrlEncoded) -> maybe mempty (jsonRpcParams proc . payArray) $ iPayload apiRequest
|
||||
(InvPost, _) -> maybe mempty payRaw $ iPayload apiRequest
|
||||
(InvPost, MTUrlEncoded) -> maybe mempty (jsonRpcParams proc . payArray) iPayload
|
||||
(InvPost, _) -> maybe mempty payRaw iPayload
|
||||
txMode = case (invMethod, pdVolatility) of
|
||||
(InvGet, _) -> SQL.Read
|
||||
(InvHead, _) -> SQL.Read
|
||||
@@ -143,11 +161,20 @@ callReadPlan identifier conf sCache apiRequest invMethod = do
|
||||
(InvPost, Routine.Immutable) -> SQL.Read
|
||||
(InvPost, Routine.Volatile) -> SQL.Write
|
||||
cPlan = callPlan proc apiRequest paramKeys args rPlan
|
||||
binField <- mapLeft ApiRequestError $ binaryField conf (iAcceptMediaType apiRequest) (Just proc) rPlan
|
||||
return $ CallReadPlan rPlan cPlan txMode proc $ mediaToAggregate (iAcceptMediaType apiRequest) binField apiRequest
|
||||
(hdler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest relIdentifier iAcceptMediaType (dbMediaHandlers sCache)
|
||||
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
|
||||
return $ CallReadPlan rPlan cPlan txMode proc hdler mediaType relIdentifier
|
||||
where
|
||||
Preferences{..} = iPreferences apiRequest
|
||||
qsParams' = QueryParams.qsParams (iQueryParams apiRequest)
|
||||
qsParams' = QueryParams.qsParams iQueryParams
|
||||
|
||||
inspectPlan :: ApiRequest -> Either Error InspectPlan
|
||||
inspectPlan apiRequest = do
|
||||
let producedMTs = [MTOpenAPI, MTApplicationJSON, MTAny]
|
||||
accepts = iAcceptMediaType apiRequest
|
||||
mediaType <- if not . null $ L.intersect accepts producedMTs
|
||||
then Right MTOpenAPI
|
||||
else Left . ApiRequestError . MediaTypeError $ MediaType.toMime <$> accepts
|
||||
return $ InspectPlan mediaType SQL.Read
|
||||
|
||||
{-|
|
||||
Search a pg proc by matching name and arguments keys to parameters. Since a function can be overloaded,
|
||||
@@ -206,9 +233,6 @@ findProc qi argumentsKeys paramsAsSingleObject allProcs contentMediaType isInvPo
|
||||
-- and can match any or none of the default parameters.
|
||||
(reqParams, optParams) -> argumentsKeys `S.difference` S.fromList (ppName <$> optParams) == S.fromList (ppName <$> reqParams)
|
||||
|
||||
inspectPlanTxMode :: SQL.Mode
|
||||
inspectPlanTxMode = SQL.Read
|
||||
|
||||
-- | During planning we need to resolve Field -> CoercibleField (finding the context specific target type and map function).
|
||||
-- | ResolverContext facilitates this without the need to pass around a laundry list of parameters.
|
||||
data ResolverContext = ResolverContext
|
||||
@@ -226,22 +250,19 @@ resolveTableFieldName table fieldName =
|
||||
fromMaybe (unknownField fieldName []) $ HMI.lookup fieldName (tableColumns table) >>=
|
||||
Just . resolveColumnField
|
||||
|
||||
resolveTableField :: Table -> Field -> CoercibleField
|
||||
resolveTableField table (fieldName, []) = resolveTableFieldName table fieldName
|
||||
resolveTableField table (fieldName, jp) =
|
||||
case resolveTableFieldName table fieldName of
|
||||
-- types that are already json/jsonb don't need to be converted with `to_jsonb` for using arrow operators `data->attr`
|
||||
-- this prevents indexes not applying https://github.com/PostgREST/postgrest/issues/2594
|
||||
cf@CoercibleField{cfIRType="json"} -> cf{cfJsonPath=jp}
|
||||
cf@CoercibleField{cfIRType="jsonb"} -> cf{cfJsonPath=jp}
|
||||
-- other types will get converted `to_jsonb(col)->attr`
|
||||
cf -> cf{cfJsonPath=jp, cfToJson=True}
|
||||
|
||||
-- | Resolve a type within the context based on the given field name and JSON path. Although there are situations where failure to resolve a field is considered an error (see `resolveOrError`), there are also situations where we allow it (RPC calls). If it should be an error and `resolveOrError` doesn't fit, ensure to check the `cfIRType` isn't empty.
|
||||
resolveTypeOrUnknown :: ResolverContext -> Field -> CoercibleField
|
||||
resolveTypeOrUnknown ResolverContext{..} field@(fn, jp) =
|
||||
fromMaybe (unknownField fn jp) $ HM.lookup qi tables >>=
|
||||
Just . flip resolveTableField field
|
||||
resolveTypeOrUnknown ResolverContext{..} (fn, jp) =
|
||||
case res of
|
||||
-- types that are already json/jsonb don't need to be converted with `to_jsonb` for using arrow operators `data->attr`
|
||||
-- this prevents indexes not applying https://github.com/PostgREST/postgrest/issues/2594
|
||||
cf@CoercibleField{cfIRType="json"} -> cf{cfJsonPath=jp, cfToJson=False}
|
||||
cf@CoercibleField{cfIRType="jsonb"} -> cf{cfJsonPath=jp, cfToJson=False}
|
||||
-- other types will get converted `to_jsonb(col)->attr`, even unknown types
|
||||
cf -> cf{cfJsonPath=jp, cfToJson=True}
|
||||
where
|
||||
res = fromMaybe (unknownField fn jp) $ HM.lookup qi tables >>=
|
||||
Just . flip resolveTableFieldName fn
|
||||
|
||||
-- | Install any pre-defined data representation from source to target to coerce this reference.
|
||||
--
|
||||
@@ -277,18 +298,21 @@ resolveQueryInputField ctx field = withTextParse ctx $ resolveTypeOrUnknown ctx
|
||||
-- | Adds filters, order, limits on its respective nodes.
|
||||
-- | Adds joins conditions obtained from resource embedding.
|
||||
readPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Either Error ReadPlanTree
|
||||
readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows} SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest =
|
||||
readPlan qi@QualifiedIdentifier{..} AppConfig{configDbMaxRows, configDbAggregates} SchemaCache{dbTables, dbRelationships, dbRepresentations} apiRequest =
|
||||
let
|
||||
-- JSON output format hardcoded for now. In the future we might want to support other output mappings such as CSV.
|
||||
ctx = ResolverContext dbTables dbRepresentations qi "json"
|
||||
in
|
||||
mapLeft ApiRequestError $
|
||||
treeRestrictRange configDbMaxRows (iAction apiRequest) =<<
|
||||
validateAggFunctions configDbAggregates =<<
|
||||
hoistSpreadAggFunctions =<<
|
||||
addRelSelects =<<
|
||||
addNullEmbedFilters =<<
|
||||
validateSpreadEmbeds =<<
|
||||
addRelatedOrders =<<
|
||||
addDataRepresentationAliases =<<
|
||||
expandStarsForDataRepresentations ctx =<<
|
||||
addAliases =<<
|
||||
expandStars ctx =<<
|
||||
addRels qiSchema (iAction apiRequest) dbRelationships Nothing =<<
|
||||
addLogicTrees ctx apiRequest =<<
|
||||
addRanges apiRequest =<<
|
||||
@@ -301,7 +325,7 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} =
|
||||
foldr (treeEntry rootDepth) $ Node defReadPlan{from=qi ctx, relName=qiName, depth=rootDepth} []
|
||||
where
|
||||
rootDepth = 0
|
||||
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing False rootDepth
|
||||
defReadPlan = ReadPlan [] (QualifiedIdentifier mempty mempty) Nothing [] [] allRange mempty Nothing [] Nothing mempty Nothing Nothing False [] rootDepth
|
||||
treeEntry :: Depth -> Tree SelectItem -> ReadPlanTree -> ReadPlanTree
|
||||
treeEntry depth (Node si fldForest) (Node q rForest) =
|
||||
let nxtDepth = succ depth in
|
||||
@@ -317,49 +341,86 @@ initReadRequest ctx@ResolverContext{qi=QualifiedIdentifier{..}} =
|
||||
(Node defReadPlan{from=QualifiedIdentifier qiSchema selRelation, relName=selRelation, relHint=selHint, relJoinType=selJoinType, depth=nxtDepth, relIsSpread=True} [])
|
||||
fldForest:rForest
|
||||
SelectField{..} ->
|
||||
Node q{select=(resolveOutputField ctx{qi=from q} selField, selCast, selAlias):select q} rForest
|
||||
Node q{select=CoercibleSelectField (resolveOutputField ctx{qi=from q} selField) selAggregateFunction selAggregateCast selCast selAlias:select q} rForest
|
||||
|
||||
-- | Preserve the original field name if data representation is used to coerce the value.
|
||||
addDataRepresentationAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||
addDataRepresentationAliases rPlanTree = Right $ fmap (\rPlan@ReadPlan{select=sel} -> rPlan{select=map aliasSelectItem sel}) rPlanTree
|
||||
-- If an alias is explicitly specified, it is always respected. However, an alias may be
|
||||
-- determined automatically in the case of a select term with a JSON path, or in the case
|
||||
-- of domain representations.
|
||||
addAliases :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||
addAliases = Right . fmap addAliasToPlan
|
||||
where
|
||||
aliasSelectItem :: (CoercibleField, Maybe Cast, Maybe Alias) -> (CoercibleField, Maybe Cast, Maybe Alias)
|
||||
-- If there already is an alias, don't overwrite it.
|
||||
aliasSelectItem (fld@(CoercibleField{cfName=fieldName, cfTransform=(Just _)}), Nothing, Nothing) = (fld, Nothing, Just fieldName)
|
||||
aliasSelectItem fld = fld
|
||||
addAliasToPlan rp@ReadPlan{select=sel} = rp{select=map aliasSelectField sel}
|
||||
|
||||
aliasSelectField :: CoercibleSelectField -> CoercibleSelectField
|
||||
aliasSelectField field@CoercibleSelectField{csField=fieldDetails, csAggFunction=aggFun, csAlias=alias}
|
||||
| isJust alias || isJust aggFun = field
|
||||
| isJsonKeyPath fieldDetails, Just key <- lastJsonKey fieldDetails = field { csAlias = Just key }
|
||||
| isTransformPath fieldDetails = field { csAlias = Just (cfName fieldDetails) }
|
||||
| otherwise = field
|
||||
|
||||
isJsonKeyPath CoercibleField{cfJsonPath=(_: _)} = True
|
||||
isJsonKeyPath _ = False
|
||||
|
||||
isTransformPath CoercibleField{cfTransform=(Just _), cfName=_} = True
|
||||
isTransformPath _ = False
|
||||
|
||||
lastJsonKey CoercibleField{cfName=fieldName, cfJsonPath=jsonPath} =
|
||||
case jOp <$> lastMay jsonPath of
|
||||
Just (JKey key) -> Just key
|
||||
Just (JIdx _) -> Just $ fromMaybe fieldName 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 jsonPath)
|
||||
Nothing -> Nothing
|
||||
|
||||
knownColumnsInContext :: ResolverContext -> [Column]
|
||||
knownColumnsInContext ResolverContext{..} =
|
||||
fromMaybe [] $ HM.lookup qi tables >>=
|
||||
Just . tableColumnsList
|
||||
|
||||
-- | Expand "select *" into explicit field names of the table, if necessary to apply data representations.
|
||||
expandStarsForDataRepresentations :: ResolverContext -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||
expandStarsForDataRepresentations ctx@ResolverContext{qi} rPlanTree = Right $ fmap expandStars rPlanTree
|
||||
-- | Expand "select *" into explicit field names of the table in the following situations:
|
||||
-- * When there are data representations present.
|
||||
-- * When there is an aggregate function in a given ReadPlan or its parent.
|
||||
expandStars :: ResolverContext -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||
expandStars ctx rPlanTree = Right $ expandStarsForReadPlan False rPlanTree
|
||||
where
|
||||
expandStars :: ReadPlan -> ReadPlan
|
||||
expandStarsForReadPlan :: Bool -> ReadPlanTree -> ReadPlanTree
|
||||
expandStarsForReadPlan hasAgg (Node rp@ReadPlan{select, from=fromQI, fromAlias=alias} children) =
|
||||
let
|
||||
newHasAgg = hasAgg || any (isJust . csAggFunction) select
|
||||
newCtx = adjustContext ctx fromQI alias
|
||||
newRPlan = expandStarsForTable newCtx newHasAgg rp
|
||||
in Node newRPlan (map (expandStarsForReadPlan newHasAgg) children)
|
||||
|
||||
-- Choose the appropriate context based on whether we're dealing with "pgrst_source"
|
||||
adjustContext :: ResolverContext -> QualifiedIdentifier -> Maybe Text -> ResolverContext
|
||||
-- When the schema is "" and the table is the source CTE, we assume the true source table is given in the from
|
||||
-- alias and belongs to the request schema. See the bit in `addRels` with `newFrom = ...`.
|
||||
expandStars rPlan@ReadPlan{from=(QualifiedIdentifier "" "pgrst_source"), fromAlias=(Just tblAlias)} =
|
||||
expandStarsForTable ctx{qi=qi{qiName=tblAlias}} rPlan
|
||||
expandStars rPlan@ReadPlan{from=fromTable} =
|
||||
expandStarsForTable ctx{qi=fromTable} rPlan
|
||||
adjustContext context@ResolverContext{qi=ctxQI} (QualifiedIdentifier "" "pgrst_source") (Just a) = context{qi=ctxQI{qiName=a}}
|
||||
adjustContext context fromQI _ = context{qi=fromQI}
|
||||
|
||||
expandStarsForTable :: ResolverContext -> ReadPlan -> ReadPlan
|
||||
expandStarsForTable ctx@ResolverContext{representations, outputType} rplan@ReadPlan{select=selectItems} =
|
||||
-- If we have a '*' select AND the target table has at least one data representation, expand.
|
||||
if ("*" `elem` map (\(field, _, _) -> cfName field) selectItems) && any hasOutputRep knownColumns
|
||||
then rplan{select=concatMap (expandStarSelectItem knownColumns) selectItems}
|
||||
else rplan
|
||||
expandStarsForTable :: ResolverContext -> Bool -> ReadPlan -> ReadPlan
|
||||
expandStarsForTable ctx@ResolverContext{representations, outputType} hasAgg rp@ReadPlan{select=selectFields}
|
||||
-- We expand if either of the below are true:
|
||||
-- * We have a '*' select AND there is an aggregate function in this ReadPlan's sub-tree.
|
||||
-- * We have a '*' select AND the target table has at least one data representation.
|
||||
-- We ignore any '*' selects that have an aggregate function attached (i.e for COUNT(*)).
|
||||
| hasStarSelect && (hasAgg || hasDataRepresentation) = rp{select = concatMap (expandStarSelectField knownColumns) selectFields}
|
||||
| otherwise = rp
|
||||
where
|
||||
hasStarSelect = "*" `elem` map (cfName . csField) filteredSelectFields
|
||||
filteredSelectFields = filter (isNothing . csAggFunction) selectFields
|
||||
hasDataRepresentation = any hasOutputRep knownColumns
|
||||
knownColumns = knownColumnsInContext ctx
|
||||
|
||||
hasOutputRep :: Column -> Bool
|
||||
hasOutputRep col = HM.member (colNominalType col, outputType) representations
|
||||
|
||||
expandStarSelectItem :: [Column] -> (CoercibleField, Maybe Cast, Maybe Alias) -> [(CoercibleField, Maybe Cast, Maybe Alias)]
|
||||
expandStarSelectItem columns (CoercibleField{cfName="*", cfJsonPath=[]}, b, c) = map (\col -> (withOutputFormat ctx $ resolveColumnField col, b, c)) columns
|
||||
expandStarSelectItem _ selectItem = [selectItem]
|
||||
expandStarSelectField :: [Column] -> CoercibleSelectField -> [CoercibleSelectField]
|
||||
expandStarSelectField columns sel@CoercibleSelectField{csField=CoercibleField{cfName="*", cfJsonPath=[]}, csAggFunction=Nothing} =
|
||||
map (\col -> sel { csField = withOutputFormat ctx $ resolveColumnField col }) columns
|
||||
expandStarSelectField _ selectField = [selectField]
|
||||
|
||||
-- | Enforces the `max-rows` config on the result
|
||||
treeRestrictRange :: Maybe Integer -> Action -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||
@@ -383,7 +444,7 @@ addRels schema action allRels parentNode (Node rPlan@ReadPlan{relName,relHint,re
|
||||
Relationship{relCardinality=M2M _} -> -- m2m does internal implicit joins that don't need aliasing
|
||||
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, relJoinConds=getJoinConditions Nothing parentAlias r}
|
||||
ComputedRelationship{} ->
|
||||
rPlan{from=relForeignTable r, relToParent=Just r{relTable=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias}
|
||||
rPlan{from=relForeignTable r, relToParent=Just r{relTableAlias=maybe (relTable r) (QualifiedIdentifier mempty) parentAlias}, relAggAlias=aggAlias, fromAlias=newAlias}
|
||||
_ ->
|
||||
rPlan{from=relForeignTable r, relToParent=Just r, relAggAlias=aggAlias, fromAlias=newAlias, relJoinConds=getJoinConditions newAlias parentAlias r}
|
||||
) <$> rel
|
||||
@@ -516,6 +577,123 @@ findRel schema allRels origin target hint =
|
||||
)
|
||||
) $ fromMaybe mempty $ HM.lookup (QualifiedIdentifier schema origin, schema) allRels
|
||||
|
||||
|
||||
addRelSelects :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||
addRelSelects node@(Node rp forest)
|
||||
| null forest = Right node
|
||||
| otherwise =
|
||||
let newForest = rights $ addRelSelects <$> forest
|
||||
newRelSelects = mapMaybe generateRelSelectField newForest
|
||||
in Right $ Node rp { relSelect = newRelSelects } newForest
|
||||
|
||||
generateRelSelectField :: ReadPlanTree -> Maybe RelSelectField
|
||||
generateRelSelectField (Node rp@ReadPlan{relToParent=Just _, relAggAlias, relIsSpread = True} _) =
|
||||
Just $ Spread { rsSpreadSel = generateSpreadSelectFields rp, rsAggAlias = relAggAlias }
|
||||
generateRelSelectField (Node ReadPlan{relToParent=Just rel, select, relName, relAlias, relAggAlias, relIsSpread = False} forest) =
|
||||
Just $ JsonEmbed { rsEmbedMode, rsSelName, rsAggAlias = relAggAlias, rsEmptyEmbed }
|
||||
where
|
||||
rsSelName = fromMaybe relName relAlias
|
||||
rsEmbedMode = if relIsToOne rel then JsonObject else JsonArray
|
||||
rsEmptyEmbed = null select && null forest
|
||||
generateRelSelectField _ = Nothing
|
||||
|
||||
generateSpreadSelectFields :: ReadPlan -> [SpreadSelectField]
|
||||
generateSpreadSelectFields ReadPlan{select, relSelect} =
|
||||
-- We combine the select and relSelect fields into a single list of SpreadSelectField.
|
||||
selectSpread ++ relSelectSpread
|
||||
where
|
||||
selectSpread = map selectToSpread select
|
||||
selectToSpread :: CoercibleSelectField -> SpreadSelectField
|
||||
selectToSpread CoercibleSelectField{csField = CoercibleField{cfName}, csAlias} =
|
||||
SpreadSelectField { ssSelName = fromMaybe cfName csAlias, ssSelAggFunction = Nothing, ssSelAggCast = Nothing, ssSelAlias = Nothing }
|
||||
|
||||
relSelectSpread = concatMap relSelectToSpread relSelect
|
||||
relSelectToSpread :: RelSelectField -> [SpreadSelectField]
|
||||
relSelectToSpread (JsonEmbed{rsSelName}) =
|
||||
[SpreadSelectField { ssSelName = rsSelName, ssSelAggFunction = Nothing, ssSelAggCast = Nothing, ssSelAlias = Nothing }]
|
||||
relSelectToSpread (Spread{rsSpreadSel}) =
|
||||
rsSpreadSel
|
||||
|
||||
-- When aggregates are present in a ReadPlan that will be spread, we "hoist"
|
||||
-- to the highest level possible so that their semantics make sense. For instance,
|
||||
-- imagine the user performs the following request:
|
||||
-- `GET /projects?select=client_id,...project_invoices(invoice_total.sum())`
|
||||
--
|
||||
-- In this case, it is sensible that we would expect to receive the sum of the
|
||||
-- `invoice_total`, grouped by the `client_id`. Without hoisting, the sum would
|
||||
-- be performed in the sub-query for the joined table `project_invoices`, thus
|
||||
-- making it essentially a no-op. With hoisting, we hoist the aggregate function
|
||||
-- so that the aggregate function is performed in a more sensible context.
|
||||
--
|
||||
-- We will try to hoist the aggregate function to the highest possible level,
|
||||
-- which means that we hoist until we reach the root node, or until we reach a
|
||||
-- ReadPlan that will be embedded a JSON object or JSON array.
|
||||
|
||||
-- This type alias represents an aggregate that is to be hoisted to the next
|
||||
-- level up. The first tuple of `Alias` and `FieldName` contain the alias for
|
||||
-- the joined table and the original field name for the hoisted field.
|
||||
--
|
||||
-- The second tuple contains the aggregate function to be applied, the cast, and
|
||||
-- the alias, if it was supplied by the user or otherwise determined.
|
||||
type HoistedAgg = ((Alias, FieldName), (AggregateFunction, Maybe Cast, Maybe Alias))
|
||||
|
||||
hoistSpreadAggFunctions :: ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||
hoistSpreadAggFunctions tree = Right $ fst $ applySpreadAggHoistingToNode tree
|
||||
|
||||
applySpreadAggHoistingToNode :: ReadPlanTree -> (ReadPlanTree, [HoistedAgg])
|
||||
applySpreadAggHoistingToNode (Node rp@ReadPlan{relAggAlias, relToParent, relIsSpread} children) =
|
||||
let (newChildren, childAggLists) = unzip $ map applySpreadAggHoistingToNode children
|
||||
allChildAggLists = concat childAggLists
|
||||
(newSelects, aggList) = if depth rp == 0 || (isJust relToParent && not relIsSpread)
|
||||
then (select rp, [])
|
||||
else hoistFromSelectFields relAggAlias (select rp)
|
||||
|
||||
newRelSelects = if null children
|
||||
then relSelect rp
|
||||
else map (hoistIntoRelSelectFields allChildAggLists) $ relSelect rp
|
||||
in (Node rp { select = newSelects, relSelect = newRelSelects } newChildren, aggList)
|
||||
|
||||
-- Hoist aggregate functions from the select list of a ReadPlan, and return the
|
||||
-- updated select list and the list of hoisted aggregates.
|
||||
hoistFromSelectFields :: Alias -> [CoercibleSelectField] -> ([CoercibleSelectField], [HoistedAgg])
|
||||
hoistFromSelectFields aggAlias fields =
|
||||
let (newFields, maybeAggs) = foldr processField ([], []) fields
|
||||
in (newFields, catMaybes maybeAggs)
|
||||
where
|
||||
processField field (newFields, aggList) =
|
||||
let (modifiedField, maybeAgg) = modifyField field
|
||||
in (modifiedField : newFields, maybeAgg : aggList)
|
||||
|
||||
modifyField field =
|
||||
case csAggFunction field of
|
||||
Just aggFunc ->
|
||||
( field { csAggFunction = Nothing, csAggCast = Nothing },
|
||||
Just ((aggAlias, determineFieldName field), (aggFunc, csAggCast field, csAlias field)))
|
||||
Nothing -> (field, Nothing)
|
||||
|
||||
determineFieldName field = fromMaybe (cfName $ csField field) (csAlias field)
|
||||
|
||||
-- Taking the hoisted aggregates, modify the rel selects to apply the aggregates,
|
||||
-- and any applicable casts or aliases.
|
||||
hoistIntoRelSelectFields :: [HoistedAgg] -> RelSelectField -> RelSelectField
|
||||
hoistIntoRelSelectFields aggList r@(Spread {rsSpreadSel = spreadSelects, rsAggAlias = aggAlias}) =
|
||||
r { rsSpreadSel = map updateSelect spreadSelects }
|
||||
where
|
||||
updateSelect s =
|
||||
case lookup (aggAlias, ssSelName s) aggList of
|
||||
Just (aggFunc, aggCast, fldAlias) ->
|
||||
s { ssSelAggFunction = Just aggFunc,
|
||||
ssSelAggCast = aggCast,
|
||||
ssSelAlias = fldAlias }
|
||||
Nothing -> s
|
||||
hoistIntoRelSelectFields _ r = r
|
||||
|
||||
validateAggFunctions :: Bool -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||
validateAggFunctions aggFunctionsAllowed (Node rp@ReadPlan {select} forest)
|
||||
| aggFunctionsAllowed = Node rp <$> traverse (validateAggFunctions aggFunctionsAllowed) forest
|
||||
| any (isJust . csAggFunction) select = Left AggregatesNotAllowed
|
||||
| otherwise = Node rp <$> traverse (validateAggFunctions aggFunctionsAllowed) forest
|
||||
|
||||
addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either ApiRequestError ReadPlanTree
|
||||
addFilters ctx ApiRequest{..} rReq =
|
||||
foldr addFilterToNode (Right rReq) flts
|
||||
@@ -589,7 +767,8 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
|
||||
-- relName = "projects",
|
||||
-- relToParent = Nothing,
|
||||
-- relJoinConds = [],
|
||||
-- relAlias = Nothing, relAggAlias = "clients_projects_1", relHint = Nothing, relJoinType = Nothing, relIsSpread = False, depth = 1
|
||||
-- relAlias = Nothing, relAggAlias = "clients_projects_1", relHint = Nothing, relJoinType = Nothing, relIsSpread = False, depth = 1,
|
||||
-- relSelect = []
|
||||
-- },
|
||||
-- subForest = []
|
||||
-- }
|
||||
@@ -614,7 +793,8 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
|
||||
-- )
|
||||
-- ],
|
||||
-- order = [], range_ = fullRange, relName = "clients", relToParent = Nothing, relJoinConds = [], relAlias = Nothing, relAggAlias = "", relHint = Nothing,
|
||||
-- relJoinType = Nothing, relIsSpread = False, depth = 0
|
||||
-- relJoinType = Nothing, relIsSpread = False, depth = 0,
|
||||
-- relSelect = []
|
||||
-- },
|
||||
-- subForest = subForst
|
||||
-- }
|
||||
@@ -770,7 +950,7 @@ inferColsEmbedNeeds (Node ReadPlan{select} forest) pkCols
|
||||
| "*" `elem` fldNames = ["*"]
|
||||
| otherwise = returnings
|
||||
where
|
||||
fldNames = cfName . (\(f, _, _) -> f) <$> select
|
||||
fldNames = cfName . csField <$> select
|
||||
-- Without fkCols, when a mutatePlan to
|
||||
-- /projects?select=name,clients(name) occurs, the RETURNING SQL part would
|
||||
-- be `RETURNING name`(see QueryBuilder). This would make the embedding
|
||||
@@ -812,64 +992,31 @@ inferColsEmbedNeeds (Node ReadPlan{select} forest) pkCols
|
||||
addFilterToLogicForest :: CoercibleFilter -> [CoercibleLogicTree] -> [CoercibleLogicTree]
|
||||
addFilterToLogicForest flt lf = CoercibleStmnt flt : lf
|
||||
|
||||
-- | If raw(binary) output is requested, check that MediaType is one of the
|
||||
-- admitted rawMediaTypes and that`?select=...` contains only one field other
|
||||
-- than `*`
|
||||
binaryField :: AppConfig -> MediaType -> Maybe Routine -> ReadPlanTree -> Either ApiRequestError (Maybe FieldName)
|
||||
binaryField AppConfig{configRawMediaTypes} acceptMediaType proc rpTree
|
||||
| isRawMediaType =
|
||||
if (funcReturnsScalar <$> proc) == Just True ||
|
||||
(funcReturnsSetOfScalar <$> proc) == Just True
|
||||
then Right $ Just "pgrst_scalar"
|
||||
else
|
||||
let
|
||||
fieldName = fstFieldName rpTree
|
||||
in
|
||||
case fieldName of
|
||||
Just fld -> Right $ Just fld
|
||||
Nothing -> Left $ BinaryFieldError acceptMediaType
|
||||
| otherwise =
|
||||
Right Nothing
|
||||
-- | Do content negotiation. i.e. choose a media type based on the intersection of accepted/produced media types.
|
||||
negotiateContent :: AppConfig -> ApiRequest -> QualifiedIdentifier -> [MediaType] -> MediaHandlerMap -> Either ApiRequestError ResolvedHandler
|
||||
negotiateContent conf ApiRequest{iAction=act, iPreferences=Preferences{preferRepresentation=rep}} identifier accepts produces =
|
||||
case (act, firstAcceptedPick) of
|
||||
(_, Nothing) -> Left . MediaTypeError $ map MediaType.toMime accepts
|
||||
(ActionMutate _, Just (x, mt)) -> Right (if rep == Just Full then x else NoAgg, mt)
|
||||
-- no need for an aggregate on HEAD https://github.com/PostgREST/postgrest/issues/2849
|
||||
-- TODO: despite no aggregate, these are responding with a Content-Type, which is not correct.
|
||||
(ActionRead True, Just (_, mt)) -> Right (NoAgg, mt)
|
||||
(ActionInvoke InvHead, Just (_, mt)) -> Right (NoAgg, mt)
|
||||
(_, Just (x, mt)) -> Right (x, mt)
|
||||
where
|
||||
isRawMediaType = acceptMediaType `elem` configRawMediaTypes `L.union` [MTOctetStream, MTTextPlain, MTTextXML] || isRawPlan acceptMediaType
|
||||
isRawPlan mt = case mt of
|
||||
MTPlan MTOctetStream _ _ -> True
|
||||
MTPlan MTTextPlain _ _ -> True
|
||||
MTPlan MTTextXML _ _ -> True
|
||||
_ -> False
|
||||
|
||||
fstFieldName :: ReadPlanTree -> Maybe FieldName
|
||||
fstFieldName (Node ReadPlan{select=(CoercibleField{cfName="*", cfJsonPath=[]}, _, _):_} []) = Nothing
|
||||
fstFieldName (Node ReadPlan{select=[(CoercibleField{cfName=fld, cfJsonPath=[]}, _, _)]} []) = Just fld
|
||||
fstFieldName _ = Nothing
|
||||
|
||||
|
||||
mediaToAggregate :: MediaType -> Maybe FieldName -> ApiRequest -> ResultAggregate
|
||||
mediaToAggregate mt binField apiReq@ApiRequest{iAction=act, iPreferences=Preferences{preferRepresentation=rep}} =
|
||||
if noAgg then NoAgg
|
||||
else case mt of
|
||||
MTApplicationJSON -> BuiltinAggJson
|
||||
MTSingularJSON strip -> BuiltinAggSingleJson strip
|
||||
MTArrayJSONStrip -> BuiltinAggArrayJsonStrip
|
||||
MTGeoJSON -> BuiltinAggGeoJson
|
||||
MTTextCSV -> BuiltinAggCsv
|
||||
MTAny -> BuiltinAggJson
|
||||
MTOpenAPI -> BuiltinAggJson
|
||||
MTUrlEncoded -> NoAgg -- TODO: unreachable since a previous step (producedMediaTypes) whitelists the media types that can become aggregates.
|
||||
|
||||
-- binary types
|
||||
MTTextPlain -> BuiltinAggBinary binField
|
||||
MTTextXML -> BuiltinAggXml binField
|
||||
MTOctetStream -> BuiltinAggBinary binField
|
||||
MTOther _ -> BuiltinAggBinary binField
|
||||
|
||||
-- Doing `Accept: application/vnd.pgrst.plan; for="application/vnd.pgrst.plan"` doesn't make sense, so we just empty the body.
|
||||
-- TODO: fail instead to be more strict
|
||||
MTPlan (MTPlan{}) _ _ -> NoAgg
|
||||
MTPlan media _ _ -> mediaToAggregate media binField apiReq
|
||||
where
|
||||
noAgg = case act of
|
||||
ActionMutate _ -> rep == Just HeadersOnly || rep == Just None || isNothing rep
|
||||
ActionRead _isHead -> _isHead -- no need for an aggregate on HEAD https://github.com/PostgREST/postgrest/issues/2849
|
||||
ActionInvoke invMethod -> invMethod == InvHead
|
||||
_ -> False
|
||||
firstAcceptedPick = listToMaybe $ mapMaybe matchMT accepts -- If there are multiple accepted media types, pick the first. This is usual in content negotiation.
|
||||
matchMT mt = case mt of
|
||||
-- all the vendored media types have special handling as they have media type parameters, they cannot be overridden
|
||||
m@(MTVndSingularJSON strip) -> Just (BuiltinAggSingleJson strip, m)
|
||||
m@MTVndArrayJSONStrip -> Just (BuiltinAggArrayJsonStrip, m)
|
||||
m@(MTVndPlan (MTVndSingularJSON strip) _ _) -> mtPlanToNothing $ Just (BuiltinAggSingleJson strip, m)
|
||||
m@(MTVndPlan MTVndArrayJSONStrip _ _) -> mtPlanToNothing $ Just (BuiltinAggArrayJsonStrip, m)
|
||||
-- TODO the plan should have its own MediaHandler instead of relying on MediaType
|
||||
m@(MTVndPlan mType _ _) -> mtPlanToNothing $ (,) <$> (fst <$> lookupHandler mType) <*> pure m
|
||||
-- all the other media types can be overridden
|
||||
x -> lookupHandler x
|
||||
mtPlanToNothing x = if configDbPlanEnabled conf then x else Nothing -- don't find anything if the plan media type is not allowed
|
||||
lookupHandler mt =
|
||||
HM.lookup (RelId identifier, MTAny) produces <|> -- lookup for identifier and `*/*`
|
||||
HM.lookup (RelId identifier, mt) produces <|> -- lookup for identifier and a particular media type
|
||||
HM.lookup (RelAnyElement, mt) produces -- lookup for anyelement and a particular media type
|
||||
|
||||
@@ -6,11 +6,12 @@ module PostgREST.Plan.ReadPlan
|
||||
|
||||
import Data.Tree (Tree (..))
|
||||
|
||||
import PostgREST.ApiRequest.Types (Alias, Cast, Depth, Hint,
|
||||
import PostgREST.ApiRequest.Types (Alias, Depth, Hint,
|
||||
JoinType, NodeName)
|
||||
import PostgREST.Plan.Types (CoercibleField (..),
|
||||
CoercibleLogicTree,
|
||||
CoercibleOrderTerm)
|
||||
import PostgREST.Plan.Types (CoercibleLogicTree,
|
||||
CoercibleOrderTerm,
|
||||
CoercibleSelectField (..),
|
||||
RelSelectField (..))
|
||||
import PostgREST.RangeQuery (NonnegRange)
|
||||
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||
QualifiedIdentifier)
|
||||
@@ -28,7 +29,7 @@ data JoinCondition =
|
||||
deriving (Eq, Show)
|
||||
|
||||
data ReadPlan = ReadPlan
|
||||
{ select :: [(CoercibleField, Maybe Cast, Maybe Alias)]
|
||||
{ select :: [CoercibleSelectField]
|
||||
, from :: QualifiedIdentifier
|
||||
, fromAlias :: Maybe Alias
|
||||
, where_ :: [CoercibleLogicTree]
|
||||
@@ -42,6 +43,7 @@ data ReadPlan = ReadPlan
|
||||
, relHint :: Maybe Hint
|
||||
, relJoinType :: Maybe JoinType
|
||||
, relIsSpread :: Bool
|
||||
, relSelect :: [RelSelectField]
|
||||
, depth :: Depth
|
||||
-- ^ used for aliasing
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
module PostgREST.Plan.Types
|
||||
( CoercibleField(..)
|
||||
, CoercibleSelectField(..)
|
||||
, unknownField
|
||||
, CoercibleLogicTree(..)
|
||||
, CoercibleFilter(..)
|
||||
, TransformerProc
|
||||
, CoercibleOrderTerm(..)
|
||||
, RelSelectField(..)
|
||||
, RelJsonEmbedMode(..)
|
||||
, SpreadSelectField(..)
|
||||
) where
|
||||
|
||||
import PostgREST.ApiRequest.Types (Field, JsonPath, LogicOperator,
|
||||
import PostgREST.ApiRequest.Types (AggregateFunction, Alias, Cast,
|
||||
Field, JsonPath, LogicOperator,
|
||||
OpExpr, OrderDirection, OrderNulls)
|
||||
|
||||
import PostgREST.SchemaCache.Identifiers (FieldName)
|
||||
@@ -65,3 +70,37 @@ data CoercibleOrderTerm
|
||||
, coNullOrder :: Maybe OrderNulls
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data CoercibleSelectField = CoercibleSelectField
|
||||
{ csField :: CoercibleField
|
||||
, csAggFunction :: Maybe AggregateFunction
|
||||
, csAggCast :: Maybe Cast
|
||||
, csCast :: Maybe Cast
|
||||
, csAlias :: Maybe Alias
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data RelJsonEmbedMode = JsonObject | JsonArray
|
||||
deriving (Show, Eq)
|
||||
|
||||
data RelSelectField
|
||||
= JsonEmbed
|
||||
{ rsSelName :: FieldName
|
||||
, rsAggAlias :: Alias
|
||||
, rsEmbedMode :: RelJsonEmbedMode
|
||||
, rsEmptyEmbed :: Bool
|
||||
}
|
||||
| Spread
|
||||
{ rsSpreadSel :: [SpreadSelectField]
|
||||
, rsAggAlias :: Alias
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data SpreadSelectField =
|
||||
SpreadSelectField
|
||||
{ ssSelName :: FieldName
|
||||
, ssSelAggFunction :: Maybe AggregateFunction
|
||||
, ssSelAggCast :: Maybe Cast
|
||||
, ssSelAlias :: Maybe Alias
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
+53
-60
@@ -14,35 +14,32 @@ module PostgREST.Query
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.Aeson.Key as K
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Lazy.Char8 as LBS
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import qualified Data.Set as S
|
||||
import qualified Data.Text.Encoding as T
|
||||
import qualified Hasql.Decoders as HD
|
||||
import qualified Hasql.DynamicStatements.Snippet as SQL (Snippet)
|
||||
import qualified Hasql.DynamicStatements.Statement as SQL
|
||||
import qualified Hasql.Transaction as SQL
|
||||
|
||||
import qualified PostgREST.ApiRequest.Types as ApiRequestTypes
|
||||
import qualified PostgREST.Error as Error
|
||||
import qualified PostgREST.Query.QueryBuilder as QueryBuilder
|
||||
import qualified PostgREST.Query.Statements as Statements
|
||||
import qualified PostgREST.RangeQuery as RangeQuery
|
||||
import qualified PostgREST.SchemaCache as SchemaCache
|
||||
|
||||
import Data.Scientific (FPFormat (..), formatScientific, isInteger)
|
||||
|
||||
import PostgREST.ApiRequest (ApiRequest (..))
|
||||
import PostgREST.ApiRequest.Preferences (PreferCount (..),
|
||||
PreferTimezone (..),
|
||||
PreferTransaction (..),
|
||||
Preferences (..),
|
||||
shouldCount)
|
||||
import PostgREST.Config (AppConfig (..),
|
||||
OpenAPIMode (..))
|
||||
import PostgREST.Config.PgVersion (PgVersion (..),
|
||||
pgVersion140)
|
||||
import PostgREST.Config.PgVersion (PgVersion (..))
|
||||
import PostgREST.Error (Error)
|
||||
import PostgREST.MediaType (MediaType (..))
|
||||
import PostgREST.Plan (CallReadPlan (..),
|
||||
@@ -51,8 +48,9 @@ import PostgREST.Plan (CallReadPlan (..),
|
||||
import PostgREST.Plan.MutatePlan (MutatePlan (..))
|
||||
import PostgREST.Query.SqlFragment (escapeIdentList, fromQi,
|
||||
intercalateSnippet,
|
||||
setConfigLocal,
|
||||
setConfigLocalJson)
|
||||
setConfigWithConstantName,
|
||||
setConfigWithConstantNameJSON,
|
||||
setConfigWithDynamicName)
|
||||
import PostgREST.Query.Statements (ResultSet (..))
|
||||
import PostgREST.SchemaCache (SchemaCache (..))
|
||||
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
|
||||
@@ -65,11 +63,12 @@ import Protolude hiding (Handler)
|
||||
type DbHandler = ExceptT Error SQL.Transaction
|
||||
|
||||
readQuery :: WrappedReadPlan -> AppConfig -> ApiRequest -> DbHandler ResultSet
|
||||
readQuery WrappedReadPlan{wrReadPlan, wrResAgg} conf@AppConfig{..} apiReq@ApiRequest{iPreferences=Preferences{..}, ..} = do
|
||||
readQuery WrappedReadPlan{..} conf@AppConfig{..} apiReq@ApiRequest{iPreferences=Preferences{..}} = do
|
||||
let countQuery = QueryBuilder.readPlanToCountQuery wrReadPlan
|
||||
resultSet <-
|
||||
lift . SQL.statement mempty $
|
||||
Statements.prepareRead
|
||||
wrIdent
|
||||
(QueryBuilder.readPlanToQuery wrReadPlan)
|
||||
(if preferCount == Just EstimatedCount then
|
||||
-- LIMIT maxRows + 1 so we can determine below that maxRows was surpassed
|
||||
@@ -78,10 +77,10 @@ readQuery WrappedReadPlan{wrReadPlan, wrResAgg} conf@AppConfig{..} apiReq@ApiReq
|
||||
countQuery
|
||||
)
|
||||
(shouldCount preferCount)
|
||||
iAcceptMediaType
|
||||
wrResAgg
|
||||
wrMedia
|
||||
wrHandler
|
||||
configDbPreparedStatements
|
||||
failNotSingular iAcceptMediaType resultSet
|
||||
failNotSingular wrMedia resultSet
|
||||
optionalRollback conf apiReq
|
||||
resultSetWTotal conf apiReq resultSet countQuery
|
||||
|
||||
@@ -108,16 +107,16 @@ resultSetWTotal AppConfig{..} ApiRequest{iPreferences=Preferences{..}} rs@RSStan
|
||||
configDbPreparedStatements
|
||||
|
||||
createQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
|
||||
createQuery mrPlan apiReq@ApiRequest{..} conf = do
|
||||
createQuery mrPlan@MutateReadPlan{mrMedia} apiReq conf = do
|
||||
resultSet <- writeQuery mrPlan apiReq conf
|
||||
failNotSingular iAcceptMediaType resultSet
|
||||
failNotSingular mrMedia resultSet
|
||||
optionalRollback conf apiReq
|
||||
pure resultSet
|
||||
|
||||
updateQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
|
||||
updateQuery mrPlan apiReq@ApiRequest{..} conf = do
|
||||
updateQuery mrPlan@MutateReadPlan{mrMedia} apiReq@ApiRequest{..} conf = do
|
||||
resultSet <- writeQuery mrPlan apiReq conf
|
||||
failNotSingular iAcceptMediaType resultSet
|
||||
failNotSingular mrMedia resultSet
|
||||
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
|
||||
optionalRollback conf apiReq
|
||||
pure resultSet
|
||||
@@ -139,32 +138,33 @@ failPut RSPlan{} = pure ()
|
||||
failPut RSStandard{rsQueryTotal=queryTotal} =
|
||||
when (queryTotal /= 1) $ do
|
||||
lift SQL.condemn
|
||||
throwError Error.PutMatchingPkError
|
||||
throwError $ Error.ApiRequestError ApiRequestTypes.PutMatchingPkError
|
||||
|
||||
deleteQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
|
||||
deleteQuery mrPlan apiReq@ApiRequest{..} conf = do
|
||||
deleteQuery mrPlan@MutateReadPlan{mrMedia} apiReq@ApiRequest{..} conf = do
|
||||
resultSet <- writeQuery mrPlan apiReq conf
|
||||
failNotSingular iAcceptMediaType resultSet
|
||||
failNotSingular mrMedia resultSet
|
||||
failsChangesOffLimits (RangeQuery.rangeLimit iTopLevelRange) resultSet
|
||||
optionalRollback conf apiReq
|
||||
pure resultSet
|
||||
|
||||
invokeQuery :: Routine -> CallReadPlan -> ApiRequest -> AppConfig -> PgVersion -> DbHandler ResultSet
|
||||
invokeQuery rout CallReadPlan{crReadPlan, crCallPlan, crResAgg} apiReq@ApiRequest{iPreferences=Preferences{..}, ..} conf@AppConfig{..} pgVer = do
|
||||
invokeQuery rout CallReadPlan{..} apiReq@ApiRequest{iPreferences=Preferences{..}} conf@AppConfig{..} pgVer = do
|
||||
resultSet <-
|
||||
lift . SQL.statement mempty $
|
||||
Statements.prepareCall
|
||||
crIdent
|
||||
rout
|
||||
(QueryBuilder.callPlanToQuery crCallPlan pgVer)
|
||||
(QueryBuilder.readPlanToQuery crReadPlan)
|
||||
(QueryBuilder.readPlanToCountQuery crReadPlan)
|
||||
(shouldCount preferCount)
|
||||
iAcceptMediaType
|
||||
crResAgg
|
||||
crMedia
|
||||
crHandler
|
||||
configDbPreparedStatements
|
||||
|
||||
optionalRollback conf apiReq
|
||||
failNotSingular iAcceptMediaType resultSet
|
||||
failNotSingular crMedia resultSet
|
||||
pure resultSet
|
||||
|
||||
openApiQuery :: SchemaCache -> PgVersion -> AppConfig -> Schema -> DbHandler (Maybe (TablesMap, RoutineMap, Maybe Text))
|
||||
@@ -185,18 +185,21 @@ openApiQuery sCache pgVer AppConfig{..} tSchema =
|
||||
pure Nothing
|
||||
|
||||
writeQuery :: MutateReadPlan -> ApiRequest -> AppConfig -> DbHandler ResultSet
|
||||
writeQuery MutateReadPlan{mrReadPlan, mrMutatePlan, mrResAgg} apiReq@ApiRequest{iPreferences=Preferences{..}} conf =
|
||||
writeQuery MutateReadPlan{..} ApiRequest{iPreferences=Preferences{..}} conf =
|
||||
let
|
||||
(isInsert, pkCols) = case mrMutatePlan of {Insert{insPkCols} -> (True, insPkCols); _ -> (False, mempty);}
|
||||
(isPut, isInsert, pkCols) = case mrMutatePlan of {Insert{where_,insPkCols} -> ((not . null) where_, True, insPkCols); _ -> (False,False, mempty);}
|
||||
in
|
||||
lift . SQL.statement mempty $
|
||||
Statements.prepareWrite
|
||||
mrIdent
|
||||
(QueryBuilder.readPlanToQuery mrReadPlan)
|
||||
(QueryBuilder.mutatePlanToQuery mrMutatePlan)
|
||||
isInsert
|
||||
(iAcceptMediaType apiReq)
|
||||
mrResAgg
|
||||
isPut
|
||||
mrMedia
|
||||
mrHandler
|
||||
preferRepresentation
|
||||
preferResolution
|
||||
pkCols
|
||||
(configDbPreparedStatements conf)
|
||||
|
||||
@@ -206,9 +209,9 @@ writeQuery MutateReadPlan{mrReadPlan, mrMutatePlan, mrResAgg} apiReq@ApiRequest{
|
||||
failNotSingular :: MediaType -> ResultSet -> DbHandler ()
|
||||
failNotSingular _ RSPlan{} = pure ()
|
||||
failNotSingular mediaType RSStandard{rsQueryTotal=queryTotal} =
|
||||
when (elem mediaType [MTSingularJSON True,MTSingularJSON False] && queryTotal /= 1) $ do
|
||||
when (elem mediaType [MTVndSingularJSON True, MTVndSingularJSON False] && queryTotal /= 1) $ do
|
||||
lift SQL.condemn
|
||||
throwError $ Error.singularityError queryTotal
|
||||
throwError $ Error.ApiRequestError . ApiRequestTypes.SingularityError $ toInteger queryTotal
|
||||
|
||||
failsChangesOffLimits :: Maybe Integer -> ResultSet -> DbHandler ()
|
||||
failsChangesOffLimits _ RSPlan{} = pure ()
|
||||
@@ -216,7 +219,7 @@ failsChangesOffLimits Nothing _ = pure ()
|
||||
failsChangesOffLimits (Just maxChanges) RSStandard{rsQueryTotal=queryTotal} =
|
||||
when (queryTotal > fromIntegral maxChanges) $ do
|
||||
lift SQL.condemn
|
||||
throwError $ Error.OffLimitsChangesError queryTotal maxChanges
|
||||
throwError $ Error.ApiRequestError $ ApiRequestTypes.OffLimitsChangesError queryTotal maxChanges
|
||||
|
||||
-- | Set a transaction to roll back if requested
|
||||
optionalRollback :: AppConfig -> ApiRequest -> DbHandler ()
|
||||
@@ -226,43 +229,33 @@ optionalRollback AppConfig{..} ApiRequest{iPreferences=Preferences{..}} = do
|
||||
SQL.condemn
|
||||
where
|
||||
shouldCommit =
|
||||
configDbTxAllowOverride && preferTransaction == Just Commit
|
||||
preferTransaction == Just Commit
|
||||
shouldRollback =
|
||||
configDbTxAllowOverride && preferTransaction == Just Rollback
|
||||
preferTransaction == Just Rollback
|
||||
|
||||
-- | Runs local (transaction scoped) GUCs for every request.
|
||||
-- | Set transaction scoped settings
|
||||
setPgLocals :: AppConfig -> KM.KeyMap JSON.Value -> BS.ByteString -> [(ByteString, ByteString)] ->
|
||||
ApiRequest -> PgVersion -> DbHandler ()
|
||||
setPgLocals AppConfig{..} claims role roleSettings req actualPgVersion = lift $
|
||||
ApiRequest -> Maybe Text -> DbHandler ()
|
||||
setPgLocals AppConfig{..} claims role roleSettings ApiRequest{..} tout = lift $
|
||||
SQL.statement mempty $ SQL.dynamicallyParameterized
|
||||
("select " <> intercalateSnippet ", " (searchPathSql : roleSql ++ roleSettingsSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ appSettingsSql))
|
||||
-- To ensure `GRANT SET ON PARAMETER <superuser_setting> TO authenticator` works, the role settings must be set before the impersonated role.
|
||||
-- Otherwise the GRANT SET would have to be applied to the impersonated role. See https://github.com/PostgREST/postgrest/issues/3045
|
||||
("select " <> intercalateSnippet ", " (searchPathSql : roleSettingsSql ++ roleSql ++ claimsSql ++ [methodSql, pathSql] ++ headersSql ++ cookiesSql ++ timezoneSql ++ timeoutSql ++ appSettingsSql))
|
||||
HD.noResult configDbPreparedStatements
|
||||
where
|
||||
methodSql = setConfigLocal mempty ("request.method", iMethod req)
|
||||
pathSql = setConfigLocal mempty ("request.path", iPath req)
|
||||
headersSql = if usesLegacyGucs
|
||||
then setConfigLocal "request.header." <$> iHeaders req
|
||||
else setConfigLocalJson "request.headers" (iHeaders req)
|
||||
cookiesSql = if usesLegacyGucs
|
||||
then setConfigLocal "request.cookie." <$> iCookies req
|
||||
else setConfigLocalJson "request.cookies" (iCookies req)
|
||||
claimsSql = if usesLegacyGucs
|
||||
then setConfigLocal "request.jwt.claim." <$> [(toUtf8 $ K.toText c, toUtf8 $ unquoted v) | (c,v) <- KM.toList claims]
|
||||
else [setConfigLocal mempty ("request.jwt.claims", LBS.toStrict $ JSON.encode claims)]
|
||||
roleSql = [setConfigLocal mempty ("role", role)]
|
||||
roleSettingsSql = setConfigLocal mempty <$> roleSettings
|
||||
appSettingsSql = setConfigLocal mempty <$> (join bimap toUtf8 <$> configAppSettings)
|
||||
methodSql = setConfigWithConstantName ("request.method", iMethod)
|
||||
pathSql = setConfigWithConstantName ("request.path", iPath)
|
||||
headersSql = setConfigWithConstantNameJSON "request.headers" iHeaders
|
||||
cookiesSql = setConfigWithConstantNameJSON "request.cookies" iCookies
|
||||
claimsSql = [setConfigWithConstantName ("request.jwt.claims", LBS.toStrict $ JSON.encode claims)]
|
||||
roleSql = [setConfigWithConstantName ("role", role)]
|
||||
roleSettingsSql = setConfigWithDynamicName <$> roleSettings
|
||||
appSettingsSql = setConfigWithDynamicName <$> (join bimap toUtf8 <$> configAppSettings)
|
||||
timezoneSql = maybe mempty (\(PreferTimezone tz) -> [setConfigWithConstantName ("timezone", tz)]) $ preferTimezone iPreferences
|
||||
timeoutSql = maybe mempty ((\t -> [setConfigWithConstantName ("statement_timeout", t)]) . encodeUtf8) tout
|
||||
searchPathSql =
|
||||
let schemas = escapeIdentList (iSchema req : configDbExtraSearchPath) in
|
||||
setConfigLocal mempty ("search_path", schemas)
|
||||
usesLegacyGucs = configDbUseLegacyGucs && actualPgVersion < pgVersion140
|
||||
|
||||
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 = T.decodeUtf8 . LBS.toStrict $ JSON.encode v
|
||||
let schemas = escapeIdentList (iSchema : configDbExtraSearchPath) in
|
||||
setConfigWithConstantName ("search_path", schemas)
|
||||
|
||||
-- | Runs the pre-request function.
|
||||
runPreReq :: AppConfig -> DbHandler ()
|
||||
|
||||
@@ -19,7 +19,8 @@ module PostgREST.Query.QueryBuilder
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Hasql.DynamicStatements.Snippet as SQL
|
||||
|
||||
import Data.Tree (Tree (..))
|
||||
import Data.Maybe (fromJust)
|
||||
import Data.Tree (Tree (..))
|
||||
|
||||
import PostgREST.ApiRequest.Preferences (PreferResolution (..))
|
||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
|
||||
@@ -27,8 +28,7 @@ import PostgREST.Config.PgVersion (PgVersion, pgVersion110,
|
||||
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
|
||||
import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||
Junction (..),
|
||||
Relationship (..),
|
||||
relIsToOne)
|
||||
Relationship (..))
|
||||
import PostgREST.SchemaCache.Routine (RoutineParam (..))
|
||||
|
||||
import PostgREST.ApiRequest.Types
|
||||
@@ -42,52 +42,78 @@ import PostgREST.RangeQuery (allRange)
|
||||
import Protolude
|
||||
|
||||
readPlanToQuery :: ReadPlanTree -> SQL.Snippet
|
||||
readPlanToQuery (Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds} forest) =
|
||||
readPlanToQuery node@(Node ReadPlan{select,from=mainQi,fromAlias,where_=logicForest,order, range_=readRange, relToParent, relJoinConds, relSelect} forest) =
|
||||
"SELECT " <>
|
||||
intercalateSnippet ", " ((pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)) ++ selects) <> " " <>
|
||||
intercalateSnippet ", " ((pgFmtSelectItem qi <$> (if null select && null forest then defSelect else select)) ++ joinsSelects) <> " " <>
|
||||
fromFrag <> " " <>
|
||||
intercalateSnippet " " joins <> " " <>
|
||||
(if null logicForest && null relJoinConds
|
||||
then mempty
|
||||
else "WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition relJoinConds)) <> " " <>
|
||||
groupF qi select relSelect <> " " <>
|
||||
orderF qi order <> " " <>
|
||||
limitOffsetF readRange
|
||||
where
|
||||
fromFrag = fromF relToParent mainQi fromAlias
|
||||
qi = getQualifiedIdentifier relToParent mainQi fromAlias
|
||||
defSelect = [(unknownField "*" [], Nothing, Nothing)] -- gets all the columns in case of an empty select, ignoring/obtaining these columns is done at the aggregation stage
|
||||
(selects, joins) = foldr getSelectsJoins ([],[]) forest
|
||||
-- gets all the columns in case of an empty select, ignoring/obtaining these columns is done at the aggregation stage
|
||||
defSelect = [CoercibleSelectField (unknownField "*" []) Nothing Nothing Nothing Nothing]
|
||||
joins = getJoins node
|
||||
joinsSelects = getJoinSelects node
|
||||
|
||||
getSelectsJoins :: ReadPlanTree -> ([SQL.Snippet], [SQL.Snippet]) -> ([SQL.Snippet], [SQL.Snippet])
|
||||
getSelectsJoins (Node ReadPlan{relToParent=Nothing} _) _ = ([], [])
|
||||
getSelectsJoins rr@(Node ReadPlan{select, relName, relToParent=Just rel, relAggAlias, relAlias, relJoinType, relIsSpread} forest) (selects,joins) =
|
||||
getJoinSelects :: ReadPlanTree -> [SQL.Snippet]
|
||||
getJoinSelects (Node ReadPlan{relSelect} _) =
|
||||
mapMaybe relSelectToSnippet relSelect
|
||||
where
|
||||
relSelectToSnippet :: RelSelectField -> Maybe SQL.Snippet
|
||||
relSelectToSnippet fld =
|
||||
let aggAlias = pgFmtIdent $ rsAggAlias fld
|
||||
in
|
||||
case fld of
|
||||
JsonEmbed{rsEmptyEmbed = True} ->
|
||||
Nothing
|
||||
JsonEmbed{rsSelName, rsEmbedMode = JsonObject} ->
|
||||
Just $ "row_to_json(" <> aggAlias <> ".*)::jsonb AS " <> pgFmtIdent rsSelName
|
||||
JsonEmbed{rsSelName, rsEmbedMode = JsonArray} ->
|
||||
Just $ "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> pgFmtIdent rsSelName
|
||||
Spread{rsSpreadSel, rsAggAlias} ->
|
||||
Just $ intercalateSnippet ", " (pgFmtSpreadSelectItem rsAggAlias <$> rsSpreadSel)
|
||||
|
||||
getJoins :: ReadPlanTree -> [SQL.Snippet]
|
||||
getJoins (Node _ []) = []
|
||||
getJoins (Node ReadPlan{relSelect} forest) =
|
||||
map (\fld ->
|
||||
let alias = rsAggAlias fld
|
||||
matchingNode = fromJust $ find (\(Node ReadPlan{relAggAlias} _) -> alias == relAggAlias) forest
|
||||
in getJoin fld matchingNode
|
||||
) relSelect
|
||||
|
||||
getJoin :: RelSelectField -> ReadPlanTree -> SQL.Snippet
|
||||
getJoin fld node@(Node ReadPlan{relJoinType} _) =
|
||||
let
|
||||
subquery = readPlanToQuery rr
|
||||
aliasOrName = pgFmtIdent $ fromMaybe relName relAlias
|
||||
aggAlias = pgFmtIdent relAggAlias
|
||||
correlatedSubquery sub al cond =
|
||||
(if relJoinType == Just JTInner then "INNER" else "LEFT") <> " JOIN LATERAL ( " <> sub <> " ) AS " <> al <> " ON " <> cond
|
||||
(sel, joi) = if relIsToOne rel
|
||||
then
|
||||
( if relIsSpread
|
||||
then aggAlias <> ".*"
|
||||
else "row_to_json(" <> aggAlias <> ".*) AS " <> aliasOrName
|
||||
, correlatedSubquery subquery aggAlias "TRUE")
|
||||
else
|
||||
( "COALESCE( " <> aggAlias <> "." <> aggAlias <> ", '[]') AS " <> aliasOrName
|
||||
, correlatedSubquery (
|
||||
"SELECT json_agg(" <> aggAlias <> ") AS " <> aggAlias <>
|
||||
"FROM (" <> subquery <> " ) AS " <> aggAlias
|
||||
) aggAlias $ if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE")
|
||||
subquery = readPlanToQuery node
|
||||
aggAlias = pgFmtIdent $ rsAggAlias fld
|
||||
in
|
||||
(if null select && null forest then selects else sel:selects, joi:joins)
|
||||
case fld of
|
||||
JsonEmbed{rsEmbedMode = JsonObject} ->
|
||||
correlatedSubquery subquery aggAlias "TRUE"
|
||||
Spread{} ->
|
||||
correlatedSubquery subquery aggAlias "TRUE"
|
||||
JsonEmbed{rsEmbedMode = JsonArray} ->
|
||||
let
|
||||
subq = "SELECT json_agg(" <> aggAlias <> ")::jsonb AS " <> aggAlias <> " FROM (" <> subquery <> " ) AS " <> aggAlias
|
||||
condition = if relJoinType == Just JTInner then aggAlias <> " IS NOT NULL" else "TRUE"
|
||||
in correlatedSubquery subq aggAlias condition
|
||||
|
||||
mutatePlanToQuery :: MutatePlan -> SQL.Snippet
|
||||
mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _ applyDefaults) =
|
||||
"INSERT INTO " <> fromQi mainQi <> (if null iCols then " " else "(" <> cols <> ") ") <>
|
||||
fromJsonBodyF body iCols True False applyDefaults <>
|
||||
-- Only used for PUT
|
||||
(if null putConditions then mempty else "WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "pgrst_body") <$> putConditions)) <>
|
||||
(if null putConditions then mempty else "WHERE " <> addConfigPgrstInserted True <> " AND " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "pgrst_body") <$> putConditions)) <>
|
||||
(if null putConditions && mergeDups then "WHERE " <> addConfigPgrstInserted True else mempty) <>
|
||||
maybe mempty (\(oncDo, oncCols) ->
|
||||
if null oncCols then
|
||||
mempty
|
||||
@@ -98,11 +124,12 @@ mutatePlanToQuery (Insert mainQi iCols body onConflct putConditions returnings _
|
||||
MergeDuplicates ->
|
||||
if null iCols
|
||||
then "DO NOTHING"
|
||||
else "DO UPDATE SET " <> intercalateSnippet ", " ((pgFmtIdent . cfName) <> const " = EXCLUDED." <> (pgFmtIdent . cfName) <$> iCols)
|
||||
else "DO UPDATE SET " <> intercalateSnippet ", " ((pgFmtIdent . cfName) <> const " = EXCLUDED." <> (pgFmtIdent . cfName) <$> iCols) <> (if null putConditions && not mergeDups then mempty else "WHERE " <> addConfigPgrstInserted False)
|
||||
) onConflct <> " " <>
|
||||
returningF mainQi returnings
|
||||
returningF mainQi returnings
|
||||
where
|
||||
cols = intercalateSnippet ", " $ pgFmtIdent . cfName <$> iCols
|
||||
mergeDups = case onConflct of {Just (MergeDuplicates,_) -> True; _ -> False;}
|
||||
|
||||
-- An update without a limit is always filtered with a WHERE
|
||||
mutatePlanToQuery (Update mainQi uCols body logicForest range ordts returnings applyDefaults)
|
||||
@@ -203,7 +230,7 @@ readPlanToCountQuery (Node ReadPlan{from=mainQi, fromAlias=tblAlias, where_=logi
|
||||
then mempty
|
||||
else " WHERE " ) <>
|
||||
intercalateSnippet " AND " (
|
||||
map (pgFmtLogicTree qi) logicForest ++
|
||||
map (pgFmtLogicTreeCount qi) logicForest ++
|
||||
map pgFmtJoinCondition relJoinConds ++
|
||||
subQueries
|
||||
)
|
||||
@@ -216,6 +243,18 @@ readPlanToCountQuery (Node ReadPlan{from=mainQi, fromAlias=tblAlias, where_=logi
|
||||
if joinType == Just JTInner
|
||||
then ("EXISTS (" <> readPlanToCountQuery readReq <> " )"):rest
|
||||
else rest
|
||||
findNullEmbedRel fld = find (\(Node ReadPlan{relAggAlias} _) -> fld == relAggAlias) forest
|
||||
|
||||
-- https://github.com/PostgREST/postgrest/pull/2930#discussion_r1325293698
|
||||
pgFmtLogicTreeCount :: QualifiedIdentifier -> CoercibleLogicTree -> SQL.Snippet
|
||||
pgFmtLogicTreeCount qiCount (CoercibleExpr hasNot op frst) = SQL.sql notOp <> " (" <> intercalateSnippet (opSql op) (pgFmtLogicTreeCount qiCount <$> frst) <> ")"
|
||||
where
|
||||
notOp = if hasNot then "NOT" else mempty
|
||||
opSql And = " AND "
|
||||
opSql Or = " OR "
|
||||
pgFmtLogicTreeCount _ (CoercibleStmnt (CoercibleFilterNullEmbed hasNot fld)) =
|
||||
maybe mempty (\x -> (if not hasNot then "NOT " else mempty) <> "EXISTS (" <> readPlanToCountQuery x <> ")") (findNullEmbedRel fld)
|
||||
pgFmtLogicTreeCount qiCount (CoercibleStmnt flt) = pgFmtFilter qiCount flt
|
||||
|
||||
limitedQuery :: SQL.Snippet -> Maybe Integer -> SQL.Snippet
|
||||
limitedQuery query maxRows = query <> SQL.sql (maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows)
|
||||
@@ -230,8 +269,10 @@ getQualifiedIdentifier rel mainQi tblAlias = case rel of
|
||||
fromF :: Maybe Relationship -> QualifiedIdentifier -> Maybe Alias -> SQL.Snippet
|
||||
fromF rel mainQi tblAlias = "FROM " <>
|
||||
(case rel of
|
||||
Just ComputedRelationship{relFunction,relTable} -> fromQi relFunction <> "(" <> pgFmtIdent (qiName relTable) <> ")"
|
||||
_ -> fromQi mainQi) <>
|
||||
-- Due to the use of CTEs on RPC, we need to cast the parameter to the table name in case of function overloading.
|
||||
-- See https://github.com/PostgREST/postgrest/issues/2963#issuecomment-1736557386
|
||||
Just ComputedRelationship{relFunction,relTableAlias,relTable} -> fromQi relFunction <> "(" <> pgFmtIdent (qiName relTableAlias) <> "::" <> fromQi relTable <> ")"
|
||||
_ -> fromQi mainQi) <>
|
||||
maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias <>
|
||||
(case rel of
|
||||
Just Relationship{relCardinality=M2M Junction{junTable=jt}} -> ", " <> fromQi jt
|
||||
|
||||
@@ -7,22 +7,27 @@ Description : Helper functions for PostgREST.QueryBuilder.
|
||||
-}
|
||||
module PostgREST.Query.SqlFragment
|
||||
( noLocationF
|
||||
, aggF
|
||||
, handlerF
|
||||
, countF
|
||||
, groupF
|
||||
, fromQi
|
||||
, limitOffsetF
|
||||
, locationF
|
||||
, mutRangeF
|
||||
, orderF
|
||||
, pgFmtColumn
|
||||
, pgFmtFilter
|
||||
, pgFmtIdent
|
||||
, pgFmtJoinCondition
|
||||
, pgFmtLogicTree
|
||||
, pgFmtOrderTerm
|
||||
, pgFmtSelectItem
|
||||
, pgFmtSpreadSelectItem
|
||||
, fromJsonBodyF
|
||||
, responseHeadersF
|
||||
, responseStatusF
|
||||
, addConfigPgrstInserted
|
||||
, currentSettingF
|
||||
, returningF
|
||||
, singleParameter
|
||||
, sourceCTE
|
||||
@@ -30,8 +35,9 @@ module PostgREST.Query.SqlFragment
|
||||
, unknownEncoder
|
||||
, intercalateSnippet
|
||||
, explainF
|
||||
, setConfigLocal
|
||||
, setConfigLocalJson
|
||||
, setConfigWithConstantName
|
||||
, setConfigWithDynamicName
|
||||
, setConfigWithConstantNameJSON
|
||||
, escapeIdent
|
||||
, escapeIdentList
|
||||
) where
|
||||
@@ -50,7 +56,8 @@ import Control.Arrow ((***))
|
||||
import Data.Foldable (foldr1)
|
||||
import Text.InterpolatedString.Perl6 (qc)
|
||||
|
||||
import PostgREST.ApiRequest.Types (Alias, Cast,
|
||||
import PostgREST.ApiRequest.Types (AggregateFunction (..),
|
||||
Alias, Cast,
|
||||
FtsOperator (..),
|
||||
JsonOperand (..),
|
||||
JsonOperation (..),
|
||||
@@ -64,25 +71,28 @@ import PostgREST.ApiRequest.Types (Alias, Cast,
|
||||
QuantOperator (..),
|
||||
SimpleOperator (..),
|
||||
TrileanVal (..))
|
||||
import PostgREST.MediaType (MTPlanFormat (..),
|
||||
MTPlanOption (..))
|
||||
import PostgREST.MediaType (MTVndPlanFormat (..),
|
||||
MTVndPlanOption (..))
|
||||
import PostgREST.Plan.ReadPlan (JoinCondition (..))
|
||||
import PostgREST.Plan.Types (CoercibleField (..),
|
||||
CoercibleFilter (..),
|
||||
CoercibleLogicTree (..),
|
||||
CoercibleOrderTerm (..),
|
||||
CoercibleSelectField (..),
|
||||
RelSelectField (..),
|
||||
SpreadSelectField (..),
|
||||
unknownField)
|
||||
import PostgREST.RangeQuery (NonnegRange, allRange,
|
||||
rangeLimit, rangeOffset)
|
||||
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||
QualifiedIdentifier (..))
|
||||
import PostgREST.SchemaCache.Routine (ResultAggregate (..),
|
||||
import PostgREST.SchemaCache.Routine (MediaHandler (..),
|
||||
Routine (..),
|
||||
funcReturnsScalar,
|
||||
funcReturnsSetOfScalar,
|
||||
funcReturnsSingleComposite)
|
||||
|
||||
import Protolude hiding (cast)
|
||||
import Protolude hiding (Sum, cast)
|
||||
|
||||
sourceCTEName :: Text
|
||||
sourceCTEName = "pgrst_source"
|
||||
@@ -208,19 +218,13 @@ asJsonF rout strip
|
||||
Just r -> (funcReturnsSingleComposite r, funcReturnsScalar r, funcReturnsSetOfScalar r)
|
||||
Nothing -> (False, False, False)
|
||||
|
||||
|
||||
asXmlF :: Maybe FieldName -> SQL.Snippet
|
||||
asXmlF (Just fieldName) = "coalesce(xmlagg(_postgrest_t." <> pgFmtIdent fieldName <> "), '')"
|
||||
-- TODO unreachable because a previous step(binaryField) will validate that there's a field. This will be cleared once custom media types are implemented.
|
||||
asXmlF Nothing = "coalesce(xmlagg(_postgrest_t), '')"
|
||||
|
||||
asGeoJsonF :: SQL.Snippet
|
||||
asGeoJsonF = "json_build_object('type', 'FeatureCollection', 'features', coalesce(json_agg(ST_AsGeoJSON(_postgrest_t)::json), '[]'))"
|
||||
|
||||
asBinaryF :: Maybe FieldName -> SQL.Snippet
|
||||
asBinaryF (Just fieldName) = "coalesce(string_agg(_postgrest_t." <> pgFmtIdent fieldName <> ", ''), '')"
|
||||
-- TODO unreachable because a previous step(binaryField) will validate that there's a field. This will be cleared once custom media types are implemented.
|
||||
asBinaryF Nothing = "coalesce(string_agg(_postgrest_t, ''), '')"
|
||||
customFuncF :: Maybe Routine -> QualifiedIdentifier -> QualifiedIdentifier -> SQL.Snippet
|
||||
customFuncF rout funcQi target
|
||||
| (funcReturnsScalar <$> rout) == Just True = fromQi funcQi <> "(_postgrest_t.pgrst_scalar)"
|
||||
| otherwise = fromQi funcQi <> "(_postgrest_t::" <> fromQi target <> ")"
|
||||
|
||||
locationF :: [Text] -> SQL.Snippet
|
||||
locationF pKeys = [qc|(
|
||||
@@ -260,12 +264,34 @@ pgFmtCoerceNamed :: CoercibleField -> SQL.Snippet
|
||||
pgFmtCoerceNamed CoercibleField{cfName=fn, cfTransform=(Just formatterProc)} = pgFmtCallUnary formatterProc (pgFmtIdent fn) <> " AS " <> pgFmtIdent fn
|
||||
pgFmtCoerceNamed CoercibleField{cfName=fn} = pgFmtIdent fn
|
||||
|
||||
pgFmtSelectItem :: QualifiedIdentifier -> (CoercibleField, Maybe Cast, Maybe Alias) -> SQL.Snippet
|
||||
pgFmtSelectItem table (fld, Nothing, alias) = pgFmtTableCoerce table fld <> pgFmtAs (cfName fld) (cfJsonPath fld) alias
|
||||
pgFmtSelectItem :: QualifiedIdentifier -> CoercibleSelectField -> SQL.Snippet
|
||||
pgFmtSelectItem table CoercibleSelectField{csField=fld, csAggFunction=agg, csAggCast=aggCast, csCast=cast, csAlias=alias} =
|
||||
pgFmtApplyAggregate agg aggCast (pgFmtApplyCast cast (pgFmtTableCoerce table fld)) <> pgFmtAs alias
|
||||
|
||||
pgFmtSpreadSelectItem :: Alias -> SpreadSelectField -> SQL.Snippet
|
||||
pgFmtSpreadSelectItem aggAlias SpreadSelectField{ssSelName, ssSelAggFunction, ssSelAggCast, ssSelAlias} =
|
||||
pgFmtApplyAggregate ssSelAggFunction ssSelAggCast fullSelName <> pgFmtAs ssSelAlias
|
||||
where
|
||||
fullSelName = case ssSelName of
|
||||
"*" -> pgFmtIdent aggAlias <> ".*"
|
||||
_ -> pgFmtIdent aggAlias <> "." <> pgFmtIdent ssSelName
|
||||
|
||||
pgFmtApplyAggregate :: Maybe AggregateFunction -> Maybe Cast -> SQL.Snippet -> SQL.Snippet
|
||||
pgFmtApplyAggregate Nothing _ snippet = snippet
|
||||
pgFmtApplyAggregate (Just agg) aggCast snippet =
|
||||
pgFmtApplyCast aggCast aggregatedSnippet
|
||||
where
|
||||
convertAggFunction :: AggregateFunction -> SQL.Snippet
|
||||
-- Convert from e.g. Sum (the data type) to SUM
|
||||
convertAggFunction = SQL.sql . BS.map toUpper . BS.pack . show
|
||||
aggregatedSnippet = convertAggFunction agg <> "(" <> snippet <> ")"
|
||||
|
||||
pgFmtApplyCast :: Maybe Cast -> SQL.Snippet -> SQL.Snippet
|
||||
pgFmtApplyCast Nothing snippet = snippet
|
||||
-- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc.
|
||||
-- Try doing: `select 1::"bigint"` - it'll err, using "int8" will work though. There's some parser magic that pg does that's invalidated when quoting.
|
||||
-- Not quoting should be fine, we validate the input on Parsers.
|
||||
pgFmtSelectItem table (fld, Just cast, alias) = "CAST (" <> pgFmtTableCoerce table fld <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )" <> pgFmtAs (cfName fld) (cfJsonPath fld) alias
|
||||
pgFmtApplyCast (Just cast) snippet = "CAST( " <> snippet <> " AS " <> SQL.sql (encodeUtf8 cast) <> " )"
|
||||
|
||||
-- TODO: At this stage there shouldn't be a Maybe since ApiRequest should ensure that an INSERT/UPDATE has a body
|
||||
fromJsonBodyF :: Maybe LBS.ByteString -> [CoercibleField] -> Bool -> Bool -> Bool -> SQL.Snippet
|
||||
@@ -336,7 +362,7 @@ pgFmtArrayLiteralForField values _ = unknownLiteral (pgBuildArrayLiteral values)
|
||||
|
||||
|
||||
pgFmtFilter :: QualifiedIdentifier -> CoercibleFilter -> SQL.Snippet
|
||||
pgFmtFilter _ (CoercibleFilterNullEmbed hasNot fld) = pgFmtIdent fld <> " IS " <> (if hasNot then "NOT" else mempty) <> " NULL"
|
||||
pgFmtFilter _ (CoercibleFilterNullEmbed hasNot fld) = pgFmtIdent fld <> " IS " <> (if not hasNot then "NOT " else mempty) <> "DISTINCT FROM NULL"
|
||||
pgFmtFilter _ (CoercibleFilter _ (NoOpExpr _)) = mempty -- TODO unreachable because NoOpExpr is filtered on QueryParams
|
||||
pgFmtFilter table (CoercibleFilter fld (OpExpr hasNot oper)) = notOp <> " " <> pgFmtField table fld <> case oper of
|
||||
Op op val -> " " <> simpleOperator op <> " " <> pgFmtUnknownLiteralForField (unknownLiteral val) fld
|
||||
@@ -397,17 +423,40 @@ pgFmtJsonPath = \case
|
||||
pgFmtJsonOperand (JKey k) = unknownLiteral k
|
||||
pgFmtJsonOperand (JIdx i) = unknownLiteral i <> "::int"
|
||||
|
||||
pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SQL.Snippet
|
||||
pgFmtAs _ [] Nothing = mempty
|
||||
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 -> mempty
|
||||
pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias
|
||||
pgFmtAs :: Maybe Alias -> SQL.Snippet
|
||||
pgFmtAs Nothing = mempty
|
||||
pgFmtAs (Just alias) = " AS " <> pgFmtIdent alias
|
||||
|
||||
groupF :: QualifiedIdentifier -> [CoercibleSelectField] -> [RelSelectField] -> SQL.Snippet
|
||||
groupF qi select relSelect
|
||||
| (noSelectsAreAggregated && noRelSelectsAreAggregated) || null groupTerms = mempty
|
||||
| otherwise = " GROUP BY " <> intercalateSnippet ", " groupTerms
|
||||
where
|
||||
noSelectsAreAggregated = null $ [s | s@(CoercibleSelectField { csAggFunction = Just _ }) <- select]
|
||||
noRelSelectsAreAggregated = all (\case Spread sels _ -> all (isNothing . ssSelAggFunction) sels; _ -> True) relSelect
|
||||
groupTermsFromSelect = mapMaybe (pgFmtGroup qi) select
|
||||
groupTermsFromRelSelect = mapMaybe groupTermFromRelSelectField relSelect
|
||||
groupTerms = groupTermsFromSelect ++ groupTermsFromRelSelect
|
||||
|
||||
groupTermFromRelSelectField :: RelSelectField -> Maybe SQL.Snippet
|
||||
groupTermFromRelSelectField (JsonEmbed { rsSelName }) =
|
||||
Just $ pgFmtIdent rsSelName
|
||||
groupTermFromRelSelectField (Spread { rsSpreadSel, rsAggAlias }) =
|
||||
if null groupTerms
|
||||
then Nothing
|
||||
else
|
||||
Just $ intercalateSnippet ", " groupTerms
|
||||
where
|
||||
processField :: SpreadSelectField -> Maybe SQL.Snippet
|
||||
processField SpreadSelectField{ssSelAggFunction = Just _} = Nothing
|
||||
processField SpreadSelectField{ssSelName, ssSelAlias} =
|
||||
Just $ pgFmtIdent rsAggAlias <> "." <> pgFmtIdent (fromMaybe ssSelName ssSelAlias)
|
||||
groupTerms = mapMaybe processField rsSpreadSel
|
||||
|
||||
pgFmtGroup :: QualifiedIdentifier -> CoercibleSelectField -> Maybe SQL.Snippet
|
||||
pgFmtGroup _ CoercibleSelectField{csAggFunction=Just _} = Nothing
|
||||
pgFmtGroup _ CoercibleSelectField{csAlias=Just alias, csAggFunction=Nothing} = Just $ pgFmtIdent alias
|
||||
pgFmtGroup qi CoercibleSelectField{csField=fld, csAlias=Nothing, csAggFunction=Nothing} = Just $ pgFmtField qi fld
|
||||
|
||||
countF :: SQL.Snippet -> Bool -> (SQL.Snippet, SQL.Snippet)
|
||||
countF countQuery shouldCount =
|
||||
@@ -438,6 +487,11 @@ responseHeadersF = currentSettingF "response.headers"
|
||||
responseStatusF :: SQL.Snippet
|
||||
responseStatusF = currentSettingF "response.status"
|
||||
|
||||
addConfigPgrstInserted :: Bool -> SQL.Snippet
|
||||
addConfigPgrstInserted add =
|
||||
let (symbol, num) = if add then ("+", "0") else ("-", "-1") in
|
||||
"set_config('pgrst.inserted', (coalesce(" <> currentSettingF "pgrst.inserted" <> "::int, 0) " <> symbol <> " 1)::text, true) <> '" <> num <> "'"
|
||||
|
||||
currentSettingF :: SQL.Snippet -> SQL.Snippet
|
||||
currentSettingF setting =
|
||||
-- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15
|
||||
@@ -465,13 +519,13 @@ intercalateSnippet :: ByteString -> [SQL.Snippet] -> SQL.Snippet
|
||||
intercalateSnippet _ [] = mempty
|
||||
intercalateSnippet frag snippets = foldr1 (\a b -> a <> SQL.sql frag <> b) snippets
|
||||
|
||||
explainF :: MTPlanFormat -> [MTPlanOption] -> SQL.Snippet -> SQL.Snippet
|
||||
explainF :: MTVndPlanFormat -> [MTVndPlanOption] -> SQL.Snippet -> SQL.Snippet
|
||||
explainF fmt opts snip =
|
||||
"EXPLAIN (" <>
|
||||
SQL.sql (BS.intercalate ", " (fmtPlanFmt fmt : (fmtPlanOpt <$> opts))) <>
|
||||
") " <> snip
|
||||
where
|
||||
fmtPlanOpt :: MTPlanOption -> BS.ByteString
|
||||
fmtPlanOpt :: MTVndPlanOption -> BS.ByteString
|
||||
fmtPlanOpt PlanAnalyze = "ANALYZE"
|
||||
fmtPlanOpt PlanVerbose = "VERBOSE"
|
||||
fmtPlanOpt PlanSettings = "SETTINGS"
|
||||
@@ -482,27 +536,35 @@ explainF fmt opts snip =
|
||||
fmtPlanFmt PlanJSON = "FORMAT JSON"
|
||||
|
||||
-- | Do a pg set_config(setting, value, true) call. This is equivalent to a SET LOCAL.
|
||||
setConfigLocal :: ByteString -> (ByteString, ByteString) -> SQL.Snippet
|
||||
setConfigLocal prefix (k, v) =
|
||||
"set_config(" <> unknownEncoder (prefix <> k) <> ", " <> unknownEncoder v <> ", true)"
|
||||
setConfigLocal :: (SQL.Snippet, ByteString) -> SQL.Snippet
|
||||
setConfigLocal (k, v) =
|
||||
"set_config(" <> k <> ", " <> unknownEncoder v <> ", true)"
|
||||
|
||||
-- | For when the settings are hardcoded and not parameterized
|
||||
setConfigWithConstantName :: (SQL.Snippet, ByteString) -> SQL.Snippet
|
||||
setConfigWithConstantName (k, v) = setConfigLocal ("'" <> k <> "'", v)
|
||||
|
||||
-- | For when the settings need to be parameterized
|
||||
setConfigWithDynamicName :: (ByteString, ByteString) -> SQL.Snippet
|
||||
setConfigWithDynamicName (k, v) =
|
||||
setConfigLocal (unknownEncoder k, v)
|
||||
|
||||
-- | Starting from PostgreSQL v14, some characters are not allowed for config names (mostly affecting headers with "-").
|
||||
-- | A JSON format string is used to avoid this problem. See https://github.com/PostgREST/postgrest/issues/1857
|
||||
setConfigLocalJson :: ByteString -> [(ByteString, ByteString)] -> [SQL.Snippet]
|
||||
setConfigLocalJson prefix keyVals = [setConfigLocal mempty (prefix, gucJsonVal keyVals)]
|
||||
setConfigWithConstantNameJSON :: SQL.Snippet -> [(ByteString, ByteString)] -> [SQL.Snippet]
|
||||
setConfigWithConstantNameJSON prefix keyVals = [setConfigWithConstantName (prefix, gucJsonVal keyVals)]
|
||||
where
|
||||
gucJsonVal :: [(ByteString, ByteString)] -> ByteString
|
||||
gucJsonVal = LBS.toStrict . JSON.encode . HM.fromList . arrayByteStringToText
|
||||
arrayByteStringToText :: [(ByteString, ByteString)] -> [(Text,Text)]
|
||||
arrayByteStringToText keyVal = (T.decodeUtf8 *** T.decodeUtf8) <$> keyVal
|
||||
|
||||
aggF :: Maybe Routine -> ResultAggregate -> SQL.Snippet
|
||||
aggF rout = \case
|
||||
BuiltinAggJson -> asJsonF rout False
|
||||
handlerF :: Maybe Routine -> QualifiedIdentifier -> MediaHandler -> SQL.Snippet
|
||||
handlerF rout target = \case
|
||||
BuiltinAggArrayJsonStrip -> asJsonF rout True
|
||||
BuiltinAggSingleJson strip -> asJsonSingleF rout strip
|
||||
BuiltinAggGeoJson -> asGeoJsonF
|
||||
BuiltinAggCsv -> asCsvF
|
||||
BuiltinAggXml bField -> asXmlF bField
|
||||
BuiltinAggBinary bField -> asBinaryF bField
|
||||
BuiltinOvAggJson -> asJsonF rout False
|
||||
BuiltinOvAggGeoJson -> asGeoJsonF
|
||||
BuiltinOvAggCsv -> asCsvF
|
||||
CustomFunc funcQi -> customFuncF rout funcQi target
|
||||
NoAgg -> "''::text"
|
||||
|
||||
@@ -25,11 +25,12 @@ import qualified Hasql.Statement as SQL
|
||||
import Control.Lens ((^?))
|
||||
|
||||
import PostgREST.ApiRequest.Preferences
|
||||
import PostgREST.MediaType (MTPlanFormat (..),
|
||||
MediaType (..))
|
||||
import PostgREST.MediaType (MTVndPlanFormat (..),
|
||||
MediaType (..))
|
||||
import PostgREST.Query.SqlFragment
|
||||
import PostgREST.SchemaCache.Routine (ResultAggregate (..),
|
||||
Routine)
|
||||
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier)
|
||||
import PostgREST.SchemaCache.Routine (MediaHandler (..), Routine,
|
||||
funcReturnsSingle)
|
||||
|
||||
import Protolude
|
||||
|
||||
@@ -49,24 +50,29 @@ data ResultSet
|
||||
-- ^ the HTTP headers to be added to the response
|
||||
, rsGucStatus :: Maybe Text
|
||||
-- ^ the HTTP status to be added to the response
|
||||
, rsInserted :: Maybe Int64
|
||||
-- ^ the number of rows inserted (Only used for upserts)
|
||||
}
|
||||
| RSPlan BS.ByteString -- ^ the plan of the query
|
||||
|
||||
|
||||
prepareWrite :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> ResultAggregate ->
|
||||
Maybe PreferRepresentation -> [Text] -> Bool -> SQL.Statement () ResultSet
|
||||
prepareWrite selectQuery mutateQuery isInsert mt rAgg rep pKeys =
|
||||
prepareWrite :: QualifiedIdentifier -> SQL.Snippet -> SQL.Snippet -> Bool -> Bool -> MediaType -> MediaHandler ->
|
||||
Maybe PreferRepresentation -> Maybe PreferResolution -> [Text] -> Bool -> SQL.Statement () ResultSet
|
||||
prepareWrite qi selectQuery mutateQuery isInsert isPut mt handler rep resolution pKeys =
|
||||
SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
|
||||
where
|
||||
checkUpsert snip = if isInsert && (isPut || resolution == Just MergeDuplicates) then snip else "''"
|
||||
pgrstInsertedF = checkUpsert "nullif(current_setting('pgrst.inserted', true),'')::int"
|
||||
snippet =
|
||||
"WITH " <> sourceCTE <> " AS (" <> mutateQuery <> ") " <>
|
||||
"SELECT " <>
|
||||
"'' AS total_result_set, " <>
|
||||
"pg_catalog.count(_postgrest_t) AS page_total, " <>
|
||||
locF <> " AS header, " <>
|
||||
aggF Nothing rAgg <> " AS body, " <>
|
||||
handlerF Nothing qi handler <> " AS body, " <>
|
||||
responseHeadersF <> " AS response_headers, " <>
|
||||
responseStatusF <> " AS response_status " <>
|
||||
responseStatusF <> " AS response_status, " <>
|
||||
pgrstInsertedF <> " AS response_inserted " <>
|
||||
"FROM (" <> selectF <> ") _postgrest_t"
|
||||
|
||||
locF =
|
||||
@@ -80,16 +86,16 @@ prepareWrite selectQuery mutateQuery isInsert mt rAgg rep pKeys =
|
||||
|
||||
selectF
|
||||
-- prevent using any of the column names in ?select= when no response is returned from the CTE
|
||||
| rAgg == NoAgg = "SELECT * FROM " <> sourceCTE
|
||||
| otherwise = selectQuery
|
||||
| handler == NoAgg = "SELECT * FROM " <> sourceCTE
|
||||
| otherwise = selectQuery
|
||||
|
||||
decodeIt :: HD.Result ResultSet
|
||||
decodeIt = case mt of
|
||||
MTPlan{} -> planRow
|
||||
_ -> fromMaybe (RSStandard Nothing 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow False)
|
||||
MTVndPlan{} -> planRow
|
||||
_ -> fromMaybe (RSStandard Nothing 0 mempty mempty Nothing Nothing Nothing) <$> HD.rowMaybe (standardRow False)
|
||||
|
||||
prepareRead :: SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> ResultAggregate -> Bool -> SQL.Statement () ResultSet
|
||||
prepareRead selectQuery countQuery countTotal mt rAgg =
|
||||
prepareRead :: QualifiedIdentifier -> SQL.Snippet -> SQL.Snippet -> Bool -> MediaType -> MediaHandler -> Bool -> SQL.Statement () ResultSet
|
||||
prepareRead qi selectQuery countQuery countTotal mt handler =
|
||||
SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
|
||||
where
|
||||
snippet =
|
||||
@@ -98,22 +104,23 @@ prepareRead selectQuery countQuery countTotal mt rAgg =
|
||||
"SELECT " <>
|
||||
countResultF <> " AS total_result_set, " <>
|
||||
"pg_catalog.count(_postgrest_t) AS page_total, " <>
|
||||
aggF Nothing rAgg <> " AS body, " <>
|
||||
handlerF Nothing qi handler <> " AS body, " <>
|
||||
responseHeadersF <> " AS response_headers, " <>
|
||||
responseStatusF <> " AS response_status " <>
|
||||
responseStatusF <> " AS response_status, " <>
|
||||
"''" <> " AS response_inserted " <>
|
||||
"FROM ( SELECT * FROM " <> sourceCTE <> " ) _postgrest_t"
|
||||
|
||||
(countCTEF, countResultF) = countF countQuery countTotal
|
||||
|
||||
decodeIt :: HD.Result ResultSet
|
||||
decodeIt = case mt of
|
||||
MTPlan{} -> planRow
|
||||
_ -> HD.singleRow $ standardRow True
|
||||
MTVndPlan{} -> planRow
|
||||
_ -> HD.singleRow $ standardRow True
|
||||
|
||||
prepareCall :: Routine -> SQL.Snippet -> SQL.Snippet -> SQL.Snippet -> Bool ->
|
||||
MediaType -> ResultAggregate -> Bool ->
|
||||
prepareCall :: QualifiedIdentifier -> Routine -> SQL.Snippet -> SQL.Snippet -> SQL.Snippet -> Bool ->
|
||||
MediaType -> MediaHandler -> Bool ->
|
||||
SQL.Statement () ResultSet
|
||||
prepareCall rout callProcQuery selectQuery countQuery countTotal mt rAgg =
|
||||
prepareCall qi rout callProcQuery selectQuery countQuery countTotal mt handler =
|
||||
SQL.dynamicallyParameterized (mtSnippet mt snippet) decodeIt
|
||||
where
|
||||
snippet =
|
||||
@@ -121,18 +128,21 @@ prepareCall rout callProcQuery selectQuery countQuery countTotal mt rAgg =
|
||||
countCTEF <>
|
||||
"SELECT " <>
|
||||
countResultF <> " AS total_result_set, " <>
|
||||
"pg_catalog.count(_postgrest_t) AS page_total, " <>
|
||||
aggF (Just rout) rAgg <> " AS body, " <>
|
||||
(if funcReturnsSingle rout
|
||||
then "1"
|
||||
else "pg_catalog.count(_postgrest_t)") <> " AS page_total, " <>
|
||||
handlerF (Just rout) qi handler <> " AS body, " <>
|
||||
responseHeadersF <> " AS response_headers, " <>
|
||||
responseStatusF <> " AS response_status " <>
|
||||
responseStatusF <> " AS response_status, " <>
|
||||
"''" <> " AS response_inserted " <>
|
||||
"FROM (" <> selectQuery <> ") _postgrest_t"
|
||||
|
||||
(countCTEF, countResultF) = countF countQuery countTotal
|
||||
|
||||
decodeIt :: HD.Result ResultSet
|
||||
decodeIt = case mt of
|
||||
MTPlan{} -> planRow
|
||||
_ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty Nothing Nothing) <$> HD.rowMaybe (standardRow True)
|
||||
MTVndPlan{} -> planRow
|
||||
_ -> fromMaybe (RSStandard (Just 0) 0 mempty mempty Nothing Nothing Nothing) <$> HD.rowMaybe (standardRow True)
|
||||
|
||||
preparePlanRows :: SQL.Snippet -> Bool -> SQL.Statement () (Maybe Int64)
|
||||
preparePlanRows countQuery =
|
||||
@@ -150,6 +160,7 @@ standardRow noLocation =
|
||||
<*> (if noLocation then pure mempty else fmap splitKeyValue <$> arrayColumn HD.bytea) <*> column HD.bytea
|
||||
<*> nullableColumn HD.bytea
|
||||
<*> nullableColumn HD.text
|
||||
<*> nullableColumn HD.int8
|
||||
where
|
||||
splitKeyValue :: ByteString -> (ByteString, ByteString)
|
||||
splitKeyValue kv =
|
||||
@@ -158,8 +169,8 @@ standardRow noLocation =
|
||||
|
||||
mtSnippet :: MediaType -> SQL.Snippet -> SQL.Snippet
|
||||
mtSnippet mediaType snippet = case mediaType of
|
||||
MTPlan _ fmt opts -> explainF fmt opts snippet
|
||||
_ -> snippet
|
||||
MTVndPlan _ fmt opts -> explainF fmt opts snippet
|
||||
_ -> snippet
|
||||
|
||||
-- | We use rowList because when doing EXPLAIN (FORMAT TEXT), the result comes as many rows. FORMAT JSON comes as one.
|
||||
planRow :: HD.Result ResultSet
|
||||
|
||||
+140
-183
@@ -15,24 +15,18 @@ module PostgREST.Response
|
||||
, readResponse
|
||||
, singleUpsertResponse
|
||||
, updateResponse
|
||||
, addRetryHint
|
||||
, isServiceUnavailable
|
||||
, optionalRollback
|
||||
, concatPrefAppsHeaders
|
||||
, addPrefToHeaders
|
||||
, traceHeaderMiddleware
|
||||
, PgrstResponse(..)
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.ByteString.Lazy as LBS
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import qualified Data.List as L
|
||||
import Data.Maybe (fromJust)
|
||||
import Data.Text.Read (decimal)
|
||||
import qualified Network.HTTP.Types.Header as HTTP
|
||||
import qualified Network.HTTP.Types.Status as HTTP
|
||||
import qualified Network.HTTP.Types.URI as HTTP
|
||||
import qualified Network.Wai as Wai
|
||||
|
||||
import qualified PostgREST.Error as Error
|
||||
import qualified PostgREST.MediaType as MediaType
|
||||
@@ -42,14 +36,16 @@ import qualified PostgREST.Response.OpenAPI as OpenAPI
|
||||
import PostgREST.ApiRequest (ApiRequest (..),
|
||||
InvokeMethod (..))
|
||||
import PostgREST.ApiRequest.Preferences (PreferRepresentation (..),
|
||||
PreferTransaction (..),
|
||||
PreferResolution (..),
|
||||
Preferences (..),
|
||||
shouldCount,
|
||||
toAppliedHeader)
|
||||
prefAppliedHeader,
|
||||
shouldCount)
|
||||
import PostgREST.ApiRequest.QueryParams (QueryParams (..))
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.MediaType (MediaType (..))
|
||||
import PostgREST.Plan (MutateReadPlan (..))
|
||||
import PostgREST.Plan (CallReadPlan (..),
|
||||
MutateReadPlan (..),
|
||||
WrappedReadPlan (..))
|
||||
import PostgREST.Plan.MutatePlan (MutatePlan (..))
|
||||
import PostgREST.Query.Statements (ResultSet (..))
|
||||
import PostgREST.Response.GucHeader (GucHeader, unwrapGucHeader)
|
||||
@@ -66,38 +62,50 @@ import qualified PostgREST.SchemaCache.Routine as Routine
|
||||
import Protolude hiding (Handler, toS)
|
||||
import Protolude.Conv (toS)
|
||||
|
||||
data PgrstResponse = PgrstResponse {
|
||||
pgrstStatus :: HTTP.Status
|
||||
, pgrstHeaders :: [HTTP.Header]
|
||||
, pgrstBody :: LBS.ByteString
|
||||
}
|
||||
|
||||
readResponse :: Bool -> QualifiedIdentifier -> ApiRequest -> ResultSet -> Wai.Response
|
||||
readResponse headersOnly identifier ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
|
||||
RSStandard{..} -> do
|
||||
let
|
||||
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
|
||||
response = gucResponse rsGucStatus rsGucHeaders
|
||||
headers =
|
||||
[ contentRange
|
||||
, ( "Content-Location"
|
||||
, "/"
|
||||
<> toUtf8 (qiName identifier)
|
||||
<> if BS.null (qsCanonical iQueryParams) then mempty else "?" <> qsCanonical iQueryParams
|
||||
)
|
||||
]
|
||||
++ contentTypeHeaders ctxApiRequest
|
||||
rsOrErrBody = if status == HTTP.status416
|
||||
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
|
||||
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
|
||||
else LBS.fromStrict rsBody
|
||||
readResponse :: WrappedReadPlan -> Bool -> QualifiedIdentifier -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
|
||||
readResponse WrappedReadPlan{wrMedia} headersOnly identifier ctxApiRequest@ApiRequest{iPreferences=Preferences{..},..} resultSet =
|
||||
case resultSet of
|
||||
RSStandard{..} -> do
|
||||
let
|
||||
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
|
||||
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
|
||||
headers =
|
||||
[ contentRange
|
||||
, ( "Content-Location"
|
||||
, "/"
|
||||
<> toUtf8 (qiName identifier)
|
||||
<> if BS.null (qsCanonical iQueryParams) then mempty else "?" <> qsCanonical iQueryParams
|
||||
)
|
||||
]
|
||||
++ contentTypeHeaders wrMedia ctxApiRequest
|
||||
++ prefHeader
|
||||
|
||||
response status headers $ if headersOnly then mempty else rsOrErrBody
|
||||
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers
|
||||
|
||||
RSPlan plan ->
|
||||
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||
let bod | status == HTTP.status416 = Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange $
|
||||
ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
|
||||
| headersOnly = mempty
|
||||
| otherwise = LBS.fromStrict rsBody
|
||||
|
||||
createResponse :: QualifiedIdentifier -> MutateReadPlan -> ApiRequest -> ResultSet -> Wai.Response
|
||||
createResponse QualifiedIdentifier{..} MutateReadPlan{mrMutatePlan} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}, ..} resultSet = case resultSet of
|
||||
Right $ PgrstResponse ovStatus ovHeaders bod
|
||||
|
||||
RSPlan plan ->
|
||||
Right $ PgrstResponse HTTP.status200 (contentTypeHeaders wrMedia ctxApiRequest) $ LBS.fromStrict plan
|
||||
|
||||
createResponse :: QualifiedIdentifier -> MutateReadPlan -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
|
||||
createResponse QualifiedIdentifier{..} MutateReadPlan{mrMutatePlan, mrMedia} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}, ..} resultSet = case resultSet of
|
||||
RSStandard{..} -> do
|
||||
let
|
||||
pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols; _ -> mempty;}
|
||||
response = gucResponse rsGucStatus rsGucHeaders
|
||||
prefHeader = prefAppliedHeader $
|
||||
Preferences (if null pkCols && isNothing (qsOnConflict iQueryParams) then Nothing else preferResolution)
|
||||
preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone []
|
||||
headers =
|
||||
catMaybes
|
||||
[ if null rsLocation then
|
||||
@@ -111,81 +119,98 @@ createResponse QualifiedIdentifier{..} MutateReadPlan{mrMutatePlan} ctxApiReques
|
||||
)
|
||||
, Just . RangeQuery.contentRangeH 1 0 $
|
||||
if shouldCount preferCount then Just rsQueryTotal else Nothing
|
||||
, if null pkCols && isNothing (qsOnConflict iQueryParams) then
|
||||
Nothing
|
||||
else
|
||||
toAppliedHeader <$> preferResolution
|
||||
, toAppliedHeader <$> preferMissing
|
||||
]
|
||||
, prefHeader ]
|
||||
|
||||
case preferRepresentation of
|
||||
Just Full -> response HTTP.status201 (addPrefToHeaders headers Full ++ contentTypeHeaders ctxApiRequest) (LBS.fromStrict rsBody)
|
||||
Just None -> response HTTP.status201 (addPrefToHeaders headers None) mempty
|
||||
Just HeadersOnly -> response HTTP.status201 (addPrefToHeaders headers HeadersOnly) mempty
|
||||
Nothing -> response HTTP.status201 headers mempty
|
||||
let isInsertIfGTZero i =
|
||||
if i <= 0 && preferResolution == Just MergeDuplicates then
|
||||
HTTP.status200
|
||||
else
|
||||
HTTP.status201
|
||||
status = maybe HTTP.status200 isInsertIfGTZero rsInserted
|
||||
(headers', bod) = case preferRepresentation of
|
||||
Just Full -> (headers ++ contentTypeHeaders mrMedia ctxApiRequest, LBS.fromStrict rsBody)
|
||||
Just None -> (headers, mempty)
|
||||
Just HeadersOnly -> (headers, mempty)
|
||||
Nothing -> (headers, mempty)
|
||||
|
||||
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers'
|
||||
|
||||
Right $ PgrstResponse ovStatus ovHeaders bod
|
||||
RSPlan plan ->
|
||||
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||
Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan
|
||||
|
||||
|
||||
updateResponse :: ApiRequest -> ResultSet -> Wai.Response
|
||||
updateResponse ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
|
||||
updateResponse :: MutateReadPlan -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
|
||||
updateResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
|
||||
RSStandard{..} -> do
|
||||
let
|
||||
response = gucResponse rsGucStatus rsGucHeaders
|
||||
contentRangeHeader =
|
||||
Just . RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
|
||||
if shouldCount preferCount then Just rsQueryTotal else Nothing
|
||||
headers = catMaybes [contentRangeHeader, toAppliedHeader <$> preferMissing]
|
||||
prefHeader = prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction preferMissing preferHandling preferTimezone []
|
||||
headers = catMaybes [contentRangeHeader, prefHeader]
|
||||
|
||||
case preferRepresentation of
|
||||
Just Full -> response HTTP.status200 (addPrefToHeaders headers Full ++ contentTypeHeaders ctxApiRequest)
|
||||
(LBS.fromStrict rsBody)
|
||||
Just None -> response HTTP.status204 (addPrefToHeaders headers None) mempty
|
||||
_ -> response HTTP.status204 headers mempty
|
||||
let (status, headers', body) =
|
||||
case preferRepresentation of
|
||||
Just Full -> (HTTP.status200, headers ++ contentTypeHeaders mrMedia ctxApiRequest, LBS.fromStrict rsBody)
|
||||
Just None -> (HTTP.status204, headers, mempty)
|
||||
_ -> (HTTP.status204, headers, mempty)
|
||||
|
||||
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers'
|
||||
|
||||
Right $ PgrstResponse ovStatus ovHeaders body
|
||||
|
||||
RSPlan plan ->
|
||||
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||
Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan
|
||||
|
||||
singleUpsertResponse :: ApiRequest -> ResultSet -> Wai.Response
|
||||
singleUpsertResponse ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
|
||||
singleUpsertResponse :: MutateReadPlan -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
|
||||
singleUpsertResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
|
||||
RSStandard {..} -> do
|
||||
let
|
||||
response = gucResponse rsGucStatus rsGucHeaders
|
||||
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
|
||||
cTHeader = contentTypeHeaders mrMedia ctxApiRequest
|
||||
|
||||
case preferRepresentation of
|
||||
Just Full -> response HTTP.status200 (contentTypeHeaders ctxApiRequest ++ [toAppliedHeader Full]) (LBS.fromStrict rsBody)
|
||||
Just None -> response HTTP.status204 [toAppliedHeader None] mempty
|
||||
_ -> response HTTP.status204 [] mempty
|
||||
let isInsertIfGTZero i = if i > 0 then HTTP.status201 else HTTP.status200
|
||||
upsertStatus = isInsertIfGTZero $ fromJust rsInserted
|
||||
(status, headers, body) =
|
||||
case preferRepresentation of
|
||||
Just Full -> (upsertStatus, cTHeader ++ prefHeader, LBS.fromStrict rsBody)
|
||||
Just None -> (HTTP.status204, prefHeader, mempty)
|
||||
_ -> (HTTP.status204, prefHeader, mempty)
|
||||
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers
|
||||
|
||||
Right $ PgrstResponse ovStatus ovHeaders body
|
||||
|
||||
RSPlan plan ->
|
||||
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||
Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan
|
||||
|
||||
deleteResponse :: ApiRequest -> ResultSet -> Wai.Response
|
||||
deleteResponse ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
|
||||
deleteResponse :: MutateReadPlan -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
|
||||
deleteResponse MutateReadPlan{mrMedia} ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} resultSet = case resultSet of
|
||||
RSStandard {..} -> do
|
||||
let
|
||||
response = gucResponse rsGucStatus rsGucHeaders
|
||||
contentRangeHeader =
|
||||
RangeQuery.contentRangeH 1 0 $
|
||||
if shouldCount preferCount then Just rsQueryTotal else Nothing
|
||||
headers = [contentRangeHeader]
|
||||
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation Nothing preferCount preferTransaction Nothing preferHandling preferTimezone []
|
||||
headers = contentRangeHeader : prefHeader
|
||||
|
||||
case preferRepresentation of
|
||||
Just Full -> response HTTP.status200 (addPrefToHeaders headers Full ++ contentTypeHeaders ctxApiRequest)
|
||||
(LBS.fromStrict rsBody)
|
||||
Just None -> response HTTP.status204 (addPrefToHeaders headers None) mempty
|
||||
_ -> response HTTP.status204 headers mempty
|
||||
let (status, headers', body) =
|
||||
case preferRepresentation of
|
||||
Just Full -> (HTTP.status200, headers ++ contentTypeHeaders mrMedia ctxApiRequest, LBS.fromStrict rsBody)
|
||||
Just None -> (HTTP.status204, headers, mempty)
|
||||
_ -> (HTTP.status204, headers, mempty)
|
||||
|
||||
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers'
|
||||
|
||||
Right $ PgrstResponse ovStatus ovHeaders body
|
||||
|
||||
RSPlan plan ->
|
||||
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||
Right $ PgrstResponse HTTP.status200 (contentTypeHeaders mrMedia ctxApiRequest) $ LBS.fromStrict plan
|
||||
|
||||
infoIdentResponse :: QualifiedIdentifier -> SchemaCache -> Wai.Response
|
||||
infoIdentResponse identifier sCache =
|
||||
infoIdentResponse :: QualifiedIdentifier -> SchemaCache -> Either Error.Error PgrstResponse
|
||||
infoIdentResponse identifier sCache = do
|
||||
case HM.lookup identifier (dbTables sCache) of
|
||||
Just tbl -> respondInfo $ allowH tbl
|
||||
Nothing -> Error.errorResponseFor $ Error.ApiRequestError ApiRequestTypes.NotFound
|
||||
Nothing -> Left $ Error.ApiRequestError ApiRequestTypes.NotFound
|
||||
where
|
||||
allowH table =
|
||||
let hasPK = not . null $ tablePKCols table in
|
||||
@@ -196,72 +221,70 @@ infoIdentResponse identifier sCache =
|
||||
["PATCH" | tableUpdatable table] ++
|
||||
["DELETE" | tableDeletable table]
|
||||
|
||||
infoProcResponse :: Routine -> Wai.Response
|
||||
infoProcResponse :: Routine -> Either Error.Error PgrstResponse
|
||||
infoProcResponse proc | pdVolatility proc == Volatile = respondInfo "OPTIONS,POST"
|
||||
| otherwise = respondInfo "OPTIONS,GET,HEAD,POST"
|
||||
|
||||
infoRootResponse :: Wai.Response
|
||||
infoRootResponse :: Either Error.Error PgrstResponse
|
||||
infoRootResponse = respondInfo "OPTIONS,GET,HEAD"
|
||||
|
||||
respondInfo :: ByteString -> Wai.Response
|
||||
respondInfo :: ByteString -> Either Error.Error PgrstResponse
|
||||
respondInfo allowHeader =
|
||||
let allOrigins = ("Access-Control-Allow-Origin", "*") in
|
||||
Wai.responseLBS HTTP.status200 [allOrigins, (HTTP.hAllow, allowHeader)] mempty
|
||||
Right $ PgrstResponse HTTP.status200 [allOrigins, (HTTP.hAllow, allowHeader)] mempty
|
||||
|
||||
invokeResponse :: InvokeMethod -> Routine -> ApiRequest -> ResultSet -> Wai.Response
|
||||
invokeResponse invMethod proc ctxApiRequest@ApiRequest{..} resultSet = case resultSet of
|
||||
invokeResponse :: CallReadPlan -> InvokeMethod -> Routine -> ApiRequest -> ResultSet -> Either Error.Error PgrstResponse
|
||||
invokeResponse CallReadPlan{crMedia} invMethod proc ctxApiRequest@ApiRequest{iPreferences=Preferences{..},..} resultSet = case resultSet of
|
||||
RSStandard {..} -> do
|
||||
let
|
||||
response = gucResponse rsGucStatus rsGucHeaders
|
||||
(status, contentRange) =
|
||||
RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
|
||||
rsOrErrBody = if status == HTTP.status416
|
||||
then Error.errorPayload $ Error.ApiRequestError $ ApiRequestTypes.InvalidRange
|
||||
$ ApiRequestTypes.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
|
||||
else LBS.fromStrict rsBody
|
||||
headers = [contentRange]
|
||||
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferParameters preferCount preferTransaction Nothing preferHandling preferTimezone []
|
||||
headers = contentRange : prefHeader
|
||||
|
||||
if Routine.funcReturnsVoid proc then
|
||||
response HTTP.status204 headers mempty
|
||||
else
|
||||
response status
|
||||
(headers ++ contentTypeHeaders ctxApiRequest)
|
||||
(if invMethod == InvHead then mempty else rsOrErrBody)
|
||||
let (status', headers', body) =
|
||||
if Routine.funcReturnsVoid proc then
|
||||
(HTTP.status204, headers, mempty)
|
||||
else
|
||||
(status,
|
||||
headers ++ contentTypeHeaders crMedia ctxApiRequest,
|
||||
if invMethod == InvHead then mempty else rsOrErrBody)
|
||||
|
||||
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status' headers'
|
||||
|
||||
Right $ PgrstResponse ovStatus ovHeaders body
|
||||
|
||||
RSPlan plan ->
|
||||
Wai.responseLBS HTTP.status200 (contentTypeHeaders ctxApiRequest) $ LBS.fromStrict plan
|
||||
Right $ PgrstResponse HTTP.status200 (contentTypeHeaders crMedia ctxApiRequest) $ LBS.fromStrict plan
|
||||
|
||||
openApiResponse :: (Text, Text) -> Bool -> Maybe (TablesMap, RoutineMap, Maybe Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Wai.Response
|
||||
openApiResponse :: (Text, Text) -> Bool -> Maybe (TablesMap, RoutineMap, Maybe Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Either Error.Error PgrstResponse
|
||||
openApiResponse versions headersOnly body conf sCache schema negotiatedByProfile =
|
||||
Wai.responseLBS HTTP.status200
|
||||
Right $ PgrstResponse HTTP.status200
|
||||
(MediaType.toContentType MTOpenAPI : maybeToList (profileHeader schema negotiatedByProfile))
|
||||
(maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache x y z) body)
|
||||
|
||||
-- | Response with headers and status overridden from GUCs.
|
||||
gucResponse
|
||||
:: Maybe Text
|
||||
-> Maybe BS.ByteString
|
||||
-> HTTP.Status
|
||||
-> [HTTP.Header]
|
||||
-> LBS.ByteString
|
||||
-> Wai.Response
|
||||
gucResponse rsGucStatus rsGucHeaders status headers body =
|
||||
case (,) <$> decodeGucStatus rsGucStatus <*> decodeGucHeaders rsGucHeaders of
|
||||
Left err -> Error.errorResponseFor err
|
||||
Right (gucStatus, gucHeaders) ->
|
||||
Wai.responseLBS (fromMaybe status gucStatus) (addHeadersIfNotIncluded headers (map unwrapGucHeader gucHeaders)) body
|
||||
-- Status and headers can be overridden as per https://postgrest.org/en/stable/references/transactions.html#response-headers
|
||||
overrideStatusHeaders :: Maybe Text -> Maybe BS.ByteString -> HTTP.Status -> [HTTP.Header]-> Either Error.Error (HTTP.Status, [HTTP.Header])
|
||||
overrideStatusHeaders rsGucStatus rsGucHeaders pgrstStatus pgrstHeaders = do
|
||||
gucStatus <- decodeGucStatus rsGucStatus
|
||||
gucHeaders <- decodeGucHeaders rsGucHeaders
|
||||
Right (fromMaybe pgrstStatus gucStatus, addHeadersIfNotIncluded pgrstHeaders $ map unwrapGucHeader gucHeaders)
|
||||
|
||||
decodeGucHeaders :: Maybe BS.ByteString -> Either Error.Error [GucHeader]
|
||||
decodeGucHeaders =
|
||||
maybe (Right []) $ first (const Error.GucHeadersError) . JSON.eitherDecode . LBS.fromStrict
|
||||
maybe (Right []) $ first (const . Error.ApiRequestError $ ApiRequestTypes.GucHeadersError) . JSON.eitherDecode . LBS.fromStrict
|
||||
|
||||
decodeGucStatus :: Maybe Text -> Either Error.Error (Maybe HTTP.Status)
|
||||
decodeGucStatus =
|
||||
maybe (Right Nothing) $ first (const Error.GucStatusError) . fmap (Just . toEnum . fst) . decimal
|
||||
maybe (Right Nothing) $ first (const . Error.ApiRequestError $ ApiRequestTypes.GucStatusError) . fmap (Just . toEnum . fst) . decimal
|
||||
|
||||
contentTypeHeaders :: ApiRequest -> [HTTP.Header]
|
||||
contentTypeHeaders ApiRequest{..} =
|
||||
MediaType.toContentType iAcceptMediaType : maybeToList (profileHeader iSchema iNegotiatedByProfile)
|
||||
contentTypeHeaders :: MediaType -> ApiRequest -> [HTTP.Header]
|
||||
contentTypeHeaders mediaType ApiRequest{..} =
|
||||
MediaType.toContentType mediaType : maybeToList (profileHeader iSchema iNegotiatedByProfile)
|
||||
|
||||
profileHeader :: Schema -> Bool -> Maybe HTTP.Header
|
||||
profileHeader schema negotiatedByProfile =
|
||||
@@ -270,74 +293,8 @@ profileHeader schema negotiatedByProfile =
|
||||
else
|
||||
Nothing
|
||||
|
||||
addRetryHint :: Int -> Wai.Response -> Wai.Response
|
||||
addRetryHint delay response = do
|
||||
let h = ("Retry-After", BS.pack $ show delay)
|
||||
Wai.mapResponseHeaders (\hs -> if isServiceUnavailable response then h:hs else hs) response
|
||||
|
||||
isServiceUnavailable :: Wai.Response -> Bool
|
||||
isServiceUnavailable response = Wai.responseStatus response == HTTP.status503
|
||||
|
||||
optionalRollback :: AppConfig -> ApiRequest -> ExceptT Error.Error IO Wai.Response -> ExceptT Error.Error IO Wai.Response
|
||||
optionalRollback AppConfig{..} ApiRequest{iPreferences=Preferences{..}} resp = do
|
||||
newRes <- catchError resp $ return . Error.errorResponseFor
|
||||
return $ Wai.mapResponseHeaders preferenceApplied newRes
|
||||
where
|
||||
shouldCommit =
|
||||
configDbTxAllowOverride && preferTransaction == Just Commit
|
||||
shouldRollback =
|
||||
configDbTxAllowOverride && preferTransaction == Just Rollback
|
||||
preferenceApplied
|
||||
| shouldCommit =
|
||||
addHeadersIfNotIncluded
|
||||
[toAppliedHeader Commit]
|
||||
| shouldRollback =
|
||||
addHeadersIfNotIncluded
|
||||
[toAppliedHeader Rollback]
|
||||
| otherwise =
|
||||
identity
|
||||
|
||||
-- | Add headers not already included to allow the user to override them instead of duplicating them
|
||||
addHeadersIfNotIncluded :: [HTTP.Header] -> [HTTP.Header] -> [HTTP.Header]
|
||||
addHeadersIfNotIncluded newHeaders initialHeaders =
|
||||
filter (\(nk, nv) -> isNothing $ find (\(ik, iv) -> ik == nk && nv == iv) initialHeaders) newHeaders ++
|
||||
filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
|
||||
initialHeaders
|
||||
|
||||
-- | Filters out multiple Preference-Applied Headers from the list and concatenate them into a single Preference-Applied header:
|
||||
--
|
||||
-- >>> :{
|
||||
-- concatPrefAppsHeaders
|
||||
-- [("Content-Type","application/json")
|
||||
-- , ("Preference-Applied","tx=commit")
|
||||
-- , ("Preference-Applied","return=minimal")]
|
||||
-- :}
|
||||
-- [("Content-Type","application/json"),("Preference-Applied","tx=commit, return=minimal")]
|
||||
|
||||
concatPrefAppsHeaders :: [HTTP.Header] -> [HTTP.Header]
|
||||
concatPrefAppsHeaders headers = otherHeaders ++ [(HTTP.hPreferenceApplied, combinedPrefApps)]
|
||||
where
|
||||
(prefApps, otherHeaders) = L.partition (\(k, _) -> k == HTTP.hPreferenceApplied) headers
|
||||
prefAppsValues = [ v | (_,v) <- prefApps]
|
||||
combinedPrefApps = BS.intercalate ", " prefAppsValues
|
||||
|
||||
-- | Given response headers and a preferRepresentation value, add
|
||||
-- preferRepresentation to Preference-Applied
|
||||
--
|
||||
-- >>> :{
|
||||
-- addPrefToHeaders
|
||||
-- [("Content-Type", "application/json")
|
||||
-- , ("Preference-Applied", "tx=commit")]
|
||||
-- None
|
||||
-- :}
|
||||
-- [("Content-Type","application/json"),("Preference-Applied","tx=commit, return=minimal")]
|
||||
|
||||
addPrefToHeaders :: [HTTP.Header] -> PreferRepresentation -> [HTTP.Header]
|
||||
addPrefToHeaders headers pref = concatPrefAppsHeaders (headers ++ [toAppliedHeader pref])
|
||||
|
||||
traceHeaderMiddleware :: AppConfig -> Wai.Middleware
|
||||
traceHeaderMiddleware AppConfig{configServerTraceHeader} app req respond =
|
||||
case configServerTraceHeader of
|
||||
Nothing -> app req respond
|
||||
Just hdr ->
|
||||
let hdrVal = L.lookup hdr $ Wai.requestHeaders req in
|
||||
app req (respond . Wai.mapResponseHeaders ([(hdr, fromMaybe mempty hdrVal)] ++))
|
||||
|
||||
@@ -350,7 +350,7 @@ makeProcPathItem pd = ("/rpc/" ++ toS (pdName pd), pe)
|
||||
& summary .~ pSum
|
||||
& description .~ mfilter (/="") pDesc
|
||||
& tags .~ Set.fromList ["(rpc) " <> pdName pd]
|
||||
& produces ?~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False]
|
||||
& produces ?~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False]
|
||||
& at 200 ?~ "OK"
|
||||
getOp = procOp
|
||||
& parameters .~ makeProcGetParams (pdParams pd)
|
||||
@@ -406,8 +406,8 @@ postgrestSpec (prettyVersion, docsVersion) rels pds ti (s, h, p, b) sd allowSecu
|
||||
& definitions .~ fromList (makeTableDef rels <$> ti)
|
||||
& parameters .~ fromList (makeParamDefs ti)
|
||||
& paths .~ makePathItems pds ti
|
||||
& produces .~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False, MTTextCSV]
|
||||
& consumes .~ makeMimeList [MTApplicationJSON, MTSingularJSON True, MTSingularJSON False, MTTextCSV]
|
||||
& produces .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
|
||||
& consumes .~ makeMimeList [MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV]
|
||||
& securityDefinitions .~ makeSecurityDefinitions securityDefName allowSecurityDef
|
||||
& security .~ [SecurityRequirement (fromList [(securityDefName, [])]) | allowSecurityDef]
|
||||
where
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
module PostgREST.Response.Performance
|
||||
( ServerTiming (..)
|
||||
, serverTimingHeader
|
||||
)
|
||||
where
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Network.HTTP.Types as HTTP
|
||||
import Numeric (showFFloat)
|
||||
import Protolude
|
||||
|
||||
data ServerTiming =
|
||||
ServerTiming
|
||||
{ jwt :: Maybe Double
|
||||
, parse :: Maybe Double
|
||||
, plan :: Maybe Double
|
||||
, transaction :: Maybe Double
|
||||
, response :: Maybe Double
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
-- | Render the Server-Timing header from a ServerTimingData
|
||||
--
|
||||
-- >>> serverTimingHeader ServerTiming { plan=Just 0.1, transaction=Just 0.2, response=Just 0.3, jwt=Just 0.4, parse=Just 0.5}
|
||||
-- ("Server-Timing","jwt;dur=400000.0, parse;dur=500000.0, plan;dur=100000.0, transaction;dur=200000.0, response;dur=300000.0")
|
||||
serverTimingHeader :: ServerTiming -> HTTP.Header
|
||||
serverTimingHeader timing =
|
||||
("Server-Timing", renderTiming)
|
||||
where
|
||||
renderMetric metric = maybe "" (\dur -> BS.concat [metric, BS.pack $ ";dur=" <> showFFloat (Just 1) (dur * 1000000) ""])
|
||||
renderTiming = BS.intercalate ", " $ (\(k, v) -> renderMetric k (v timing)) <$>
|
||||
[ ("jwt", jwt)
|
||||
, ("parse", parse)
|
||||
, ("plan", plan)
|
||||
, ("transaction", transaction)
|
||||
, ("response", response)
|
||||
]
|
||||
@@ -28,7 +28,9 @@ module PostgREST.SchemaCache
|
||||
|
||||
import Control.Monad.Extra (whenJust)
|
||||
|
||||
import Data.Aeson ((.=))
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.Aeson.Types as JSON
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import qualified Data.HashMap.Strict.InsOrd as HMI
|
||||
import qualified Data.Set as S
|
||||
@@ -41,14 +43,16 @@ import Contravariant.Extras (contrazip2)
|
||||
import Text.InterpolatedString.Perl6 (q)
|
||||
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.Config.Database (pgVersionStatement,
|
||||
import PostgREST.Config.Database (TimezoneNames,
|
||||
pgVersionStatement,
|
||||
toIsolationLevel)
|
||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
|
||||
pgVersion110,
|
||||
pgVersion120)
|
||||
import PostgREST.SchemaCache.Identifiers (AccessSet, FieldName,
|
||||
QualifiedIdentifier (..),
|
||||
Schema)
|
||||
RelIdentifier (..),
|
||||
Schema, isAnyElement)
|
||||
import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||
Junction (..),
|
||||
Relationship (..),
|
||||
@@ -56,6 +60,8 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||
import PostgREST.SchemaCache.Representations (DataRepresentation (..),
|
||||
RepresentationsMap)
|
||||
import PostgREST.SchemaCache.Routine (FuncVolatility (..),
|
||||
MediaHandler (..),
|
||||
MediaHandlerMap,
|
||||
PgType (..),
|
||||
RetType (..),
|
||||
Routine (..),
|
||||
@@ -64,6 +70,8 @@ import PostgREST.SchemaCache.Routine (FuncVolatility (..),
|
||||
import PostgREST.SchemaCache.Table (Column (..), ColumnMap,
|
||||
Table (..), TablesMap)
|
||||
|
||||
import qualified PostgREST.MediaType as MediaType
|
||||
|
||||
import Protolude
|
||||
|
||||
|
||||
@@ -72,8 +80,19 @@ data SchemaCache = SchemaCache
|
||||
, dbRelationships :: RelationshipsMap
|
||||
, dbRoutines :: RoutineMap
|
||||
, dbRepresentations :: RepresentationsMap
|
||||
, dbMediaHandlers :: MediaHandlerMap
|
||||
, dbTimezones :: TimezoneNames
|
||||
}
|
||||
deriving (Generic, JSON.ToJSON)
|
||||
|
||||
instance JSON.ToJSON SchemaCache where
|
||||
toJSON (SchemaCache tabs rels routs reps _ _) = JSON.object [
|
||||
"dbTables" .= JSON.toJSON tabs
|
||||
, "dbRelationships" .= JSON.toJSON rels
|
||||
, "dbRoutines" .= JSON.toJSON routs
|
||||
, "dbRepresentations" .= JSON.toJSON reps
|
||||
, "dbMediaHandlers" .= JSON.emptyArray
|
||||
, "dbTimezones" .= JSON.emptyArray
|
||||
]
|
||||
|
||||
-- | A view foreign key or primary key dependency detected on its source table
|
||||
-- Each column of the key could be referenced multiple times in the view, e.g.
|
||||
@@ -113,6 +132,7 @@ data KeyDep
|
||||
-- | A SQL query that can be executed independently
|
||||
type SqlQuery = ByteString
|
||||
|
||||
|
||||
querySchemaCache :: AppConfig -> SQL.Transaction SchemaCache
|
||||
querySchemaCache AppConfig{..} = do
|
||||
SQL.sql "set local schema ''" -- This voids the search path. The following queries need this for getting the fully qualified name(schema.name) of every db object
|
||||
@@ -123,6 +143,8 @@ querySchemaCache AppConfig{..} = do
|
||||
funcs <- SQL.statement schemas $ allFunctions pgVer prepared
|
||||
cRels <- SQL.statement mempty $ allComputedRels prepared
|
||||
reps <- SQL.statement schemas $ dataRepresentations prepared
|
||||
mHdlers <- SQL.statement schemas $ mediaHandlers pgVer prepared
|
||||
tzones <- SQL.statement mempty $ timezones prepared
|
||||
_ <-
|
||||
let sleepCall = SQL.Statement "select pg_sleep($1)" (param HE.int4) HD.noResult prepared in
|
||||
whenJust configInternalSCSleep (`SQL.statement` sleepCall) -- only used for testing
|
||||
@@ -135,6 +157,8 @@ querySchemaCache AppConfig{..} = do
|
||||
, dbRelationships = getOverrideRelationshipsMap rels cRels
|
||||
, dbRoutines = funcs
|
||||
, dbRepresentations = reps
|
||||
, dbMediaHandlers = HM.union mHdlers initialMediaHandlers -- the custom handlers will override the initial ones
|
||||
, dbTimezones = tzones
|
||||
}
|
||||
where
|
||||
schemas = toList configDbSchemas
|
||||
@@ -169,6 +193,8 @@ removeInternal schemas dbStruct =
|
||||
HM.filterWithKey (\(QualifiedIdentifier sch _, _) _ -> sch `elem` schemas ) (dbRelationships dbStruct)
|
||||
, dbRoutines = dbRoutines dbStruct -- procs are only obtained from the exposed schemas, no need to filter them.
|
||||
, dbRepresentations = dbRepresentations dbStruct -- no need to filter, not directly exposed through the API
|
||||
, dbMediaHandlers = dbMediaHandlers dbStruct
|
||||
, dbTimezones = dbTimezones dbStruct
|
||||
}
|
||||
where
|
||||
hasInternalJunction ComputedRelationship{} = False
|
||||
@@ -271,6 +297,7 @@ decodeFuncs =
|
||||
<*> (parseVolatility <$> column HD.char)
|
||||
<*> column HD.bool
|
||||
<*> nullableColumn (toIsolationLevel <$> HD.text)
|
||||
<*> nullableColumn HD.text
|
||||
|
||||
addKey :: Routine -> (QualifiedIdentifier, Routine)
|
||||
addKey pd = (QualifiedIdentifier (pdSchema pd) (pdName pd), pd)
|
||||
@@ -404,7 +431,8 @@ funcsSqlQuery pgVer = [q|
|
||||
bt.oid <> bt.base as rettype_is_composite_alias,
|
||||
p.provolatile,
|
||||
p.provariadic > 0 as hasvariadic,
|
||||
lower((regexp_split_to_array((regexp_split_to_array(config, '='))[2], ','))[1]) AS transaction_isolation_level
|
||||
lower((regexp_split_to_array((regexp_split_to_array(iso_config, '='))[2], ','))[1]) AS transaction_isolation_level,
|
||||
lower((regexp_split_to_array((regexp_split_to_array(timeout_config, '='))[2], ','))[1]) AS statement_timeout
|
||||
FROM pg_proc p
|
||||
LEFT JOIN arguments a ON a.oid = p.oid
|
||||
JOIN pg_namespace pn ON pn.oid = p.pronamespace
|
||||
@@ -413,7 +441,8 @@ funcsSqlQuery pgVer = [q|
|
||||
JOIN pg_namespace tn ON tn.oid = t.typnamespace
|
||||
LEFT JOIN pg_class comp ON comp.oid = t.typrelid
|
||||
LEFT JOIN pg_description as d ON d.objoid = p.oid
|
||||
LEFT JOIN LATERAL unnest(proconfig) config ON config like 'default_transaction_isolation%'
|
||||
LEFT JOIN LATERAL unnest(proconfig) iso_config ON iso_config like 'default_transaction_isolation%'
|
||||
LEFT JOIN LATERAL unnest(proconfig) timeout_config ON timeout_config like 'statement_timeout%'
|
||||
WHERE t.oid <> 'trigger'::regtype AND COALESCE(a.callable, true)
|
||||
|] <> (if pgVer >= pgVersion110 then "AND prokind = 'f'" else "AND NOT (proisagg OR proiswindow)")
|
||||
|
||||
@@ -882,6 +911,7 @@ allComputedRels =
|
||||
(QualifiedIdentifier <$> column HD.text <*> column HD.text) <*>
|
||||
(QualifiedIdentifier <$> column HD.text <*> column HD.text) <*>
|
||||
(QualifiedIdentifier <$> column HD.text <*> column HD.text) <*>
|
||||
pure (QualifiedIdentifier mempty mempty) <*>
|
||||
column HD.bool <*>
|
||||
column HD.bool
|
||||
|
||||
@@ -1083,6 +1113,95 @@ allViewsKeyDependencies =
|
||||
having ncol = array_length(array_agg(row(col.attname, view_columns) order by pks_fks.ord), 1)
|
||||
|]
|
||||
|
||||
initialMediaHandlers :: MediaHandlerMap
|
||||
initialMediaHandlers =
|
||||
HM.insert (RelAnyElement, MediaType.MTAny ) (BuiltinOvAggJson, MediaType.MTApplicationJSON) $
|
||||
HM.insert (RelAnyElement, MediaType.MTApplicationJSON) (BuiltinOvAggJson, MediaType.MTApplicationJSON) $
|
||||
HM.insert (RelAnyElement, MediaType.MTTextCSV ) (BuiltinOvAggCsv, MediaType.MTTextCSV) $
|
||||
HM.insert (RelAnyElement, MediaType.MTGeoJSON ) (BuiltinOvAggGeoJson, MediaType.MTGeoJSON)
|
||||
HM.empty
|
||||
|
||||
mediaHandlers :: PgVersion -> Bool -> SQL.Statement [Schema] MediaHandlerMap
|
||||
mediaHandlers pgVer =
|
||||
SQL.Statement sql (arrayParam HE.text) decodeMediaHandlers
|
||||
where
|
||||
sql = [q|
|
||||
with
|
||||
all_relations as (
|
||||
select reltype
|
||||
from pg_class
|
||||
where relkind in ('v','r','m','f','p')
|
||||
union
|
||||
select oid
|
||||
from pg_type
|
||||
where typname = 'anyelement'
|
||||
),
|
||||
media_types as (
|
||||
SELECT
|
||||
t.oid,
|
||||
lower(t.typname) as typname,
|
||||
b.oid as base_oid,
|
||||
b.typname AS basetypname,
|
||||
t.typnamespace,
|
||||
case t.typname
|
||||
when '*/*' then 'application/octet-stream'
|
||||
else t.typname
|
||||
end as resolved_media_type
|
||||
FROM pg_type t
|
||||
JOIN pg_type b ON t.typbasetype = b.oid
|
||||
WHERE
|
||||
t.typbasetype <> 0 and
|
||||
(t.typname ~* '^[A-Za-z0-9.-]+/[A-Za-z0-9.\+-]+$' or t.typname = '*/*')
|
||||
)
|
||||
select
|
||||
proc_schema.nspname as handler_schema,
|
||||
proc.proname as handler_name,
|
||||
arg_schema.nspname::text as target_schema,
|
||||
arg_name.typname::text as target_name,
|
||||
media_types.typname as media_type,
|
||||
media_types.resolved_media_type
|
||||
from media_types
|
||||
join pg_proc proc on proc.prorettype = media_types.oid
|
||||
join pg_namespace proc_schema on proc_schema.oid = proc.pronamespace
|
||||
join pg_aggregate agg on agg.aggfnoid = proc.oid
|
||||
join pg_type arg_name on arg_name.oid = proc.proargtypes[0]
|
||||
join pg_namespace arg_schema on arg_schema.oid = arg_name.typnamespace
|
||||
where
|
||||
proc_schema.nspname = ANY('{test}') and
|
||||
proc.pronargs = 1 and
|
||||
arg_name.oid in (select reltype from all_relations)
|
||||
union
|
||||
select
|
||||
typ_sch.nspname as handler_schema,
|
||||
mtype.typname as handler_name,
|
||||
pro_sch.nspname as target_schema,
|
||||
proname as target_name,
|
||||
mtype.typname as media_type,
|
||||
mtype.resolved_media_type
|
||||
from pg_proc proc
|
||||
join pg_namespace pro_sch on pro_sch.oid = proc.pronamespace
|
||||
join media_types mtype on proc.prorettype = mtype.oid
|
||||
join pg_namespace typ_sch on typ_sch.oid = mtype.typnamespace
|
||||
where NOT proretset
|
||||
|] <> (if pgVer >= pgVersion110 then " AND prokind = 'f'" else " AND NOT (proisagg OR proiswindow)")
|
||||
|
||||
decodeMediaHandlers :: HD.Result MediaHandlerMap
|
||||
decodeMediaHandlers =
|
||||
HM.fromList . fmap (\(x, y, z, w) -> ((if isAnyElement y then RelAnyElement else RelId y, z), (CustomFunc x, w)) ) <$> HD.rowList caggRow
|
||||
where
|
||||
caggRow = (,,,)
|
||||
<$> (QualifiedIdentifier <$> column HD.text <*> column HD.text)
|
||||
<*> (QualifiedIdentifier <$> column HD.text <*> column HD.text)
|
||||
<*> (MediaType.decodeMediaType . encodeUtf8 <$> column HD.text)
|
||||
<*> (MediaType.decodeMediaType . encodeUtf8 <$> column HD.text)
|
||||
|
||||
timezones :: Bool -> SQL.Statement () TimezoneNames
|
||||
timezones = SQL.Statement sql HE.noParams decodeTimezones
|
||||
where
|
||||
sql = "SELECT name FROM pg_timezone_names"
|
||||
decodeTimezones :: HD.Result TimezoneNames
|
||||
decodeTimezones = S.fromList . map encodeUtf8 <$> HD.rowList (column HD.text)
|
||||
|
||||
param :: HE.Value a -> HE.Params a
|
||||
param = HE.param . HE.nonNullable
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
module PostgREST.SchemaCache.Identifiers
|
||||
( QualifiedIdentifier(..)
|
||||
, RelIdentifier(..)
|
||||
, isAnyElement
|
||||
, Schema
|
||||
, TableName
|
||||
, FieldName
|
||||
@@ -17,6 +19,9 @@ import qualified Data.Text as T
|
||||
|
||||
import Protolude
|
||||
|
||||
data RelIdentifier = RelId QualifiedIdentifier | RelAnyElement
|
||||
deriving (Eq, Ord, Generic, JSON.ToJSON, JSON.ToJSONKey)
|
||||
instance Hashable RelIdentifier
|
||||
|
||||
-- | Represents a pg identifier with a prepended schema name "schema.table".
|
||||
-- When qiSchema is "", the schema is defined by the pg search_path.
|
||||
@@ -28,6 +33,9 @@ data QualifiedIdentifier = QualifiedIdentifier
|
||||
|
||||
instance Hashable QualifiedIdentifier
|
||||
|
||||
isAnyElement :: QualifiedIdentifier -> Bool
|
||||
isAnyElement y = QualifiedIdentifier "pg_catalog" "anyelement" == y
|
||||
|
||||
dumpQi :: QualifiedIdentifier -> Text
|
||||
dumpQi (QualifiedIdentifier s i) =
|
||||
(if T.null s then mempty else s <> ".") <> i
|
||||
|
||||
@@ -31,6 +31,7 @@ data Relationship = Relationship
|
||||
{ relFunction :: QualifiedIdentifier
|
||||
, relTable :: QualifiedIdentifier
|
||||
, relForeignTable :: QualifiedIdentifier
|
||||
, relTableAlias :: QualifiedIdentifier
|
||||
, relToOne :: Bool
|
||||
, relIsSelf :: Bool
|
||||
}
|
||||
|
||||
@@ -14,17 +14,22 @@ module PostgREST.SchemaCache.Routine
|
||||
, funcReturnsVoid
|
||||
, funcTableName
|
||||
, funcReturnsCompositeAlias
|
||||
, ResultAggregate(..)
|
||||
, funcReturnsSingle
|
||||
, MediaHandlerMap
|
||||
, ResolvedHandler
|
||||
, MediaHandler(..)
|
||||
) where
|
||||
|
||||
import Data.Aeson ((.=))
|
||||
import qualified Data.Aeson as JSON
|
||||
import qualified Data.HashMap.Strict as HM
|
||||
import qualified Hasql.Transaction.Sessions as SQL
|
||||
import qualified PostgREST.MediaType as MediaType
|
||||
|
||||
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
|
||||
RelIdentifier (..), Schema,
|
||||
TableName)
|
||||
|
||||
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||
QualifiedIdentifier (..),
|
||||
Schema, TableName)
|
||||
|
||||
import Protolude
|
||||
|
||||
@@ -53,11 +58,12 @@ data Routine = Function
|
||||
, pdVolatility :: FuncVolatility
|
||||
, pdHasVariadic :: Bool
|
||||
, pdIsoLvl :: Maybe SQL.IsolationLevel
|
||||
, pdTimeout :: Maybe Text
|
||||
}
|
||||
deriving (Eq, Show, Generic)
|
||||
-- need to define JSON manually bc SQL.IsolationLevel doesn't have a JSON instance(and we can't define one for that type without getting a compiler error)
|
||||
instance JSON.ToJSON Routine where
|
||||
toJSON (Function sch nam desc params ret vol hasVar _) = JSON.object
|
||||
toJSON (Function sch nam desc params ret vol hasVar _ tout) = JSON.object
|
||||
[
|
||||
"pdSchema" .= sch
|
||||
, "pdName" .= nam
|
||||
@@ -66,6 +72,7 @@ instance JSON.ToJSON Routine where
|
||||
, "pdReturnType" .= JSON.toJSON ret
|
||||
, "pdVolatility" .= JSON.toJSON vol
|
||||
, "pdHasVariadic" .= JSON.toJSON hasVar
|
||||
, "pdTimeout" .= tout
|
||||
]
|
||||
|
||||
data RoutineParam = RoutineParam
|
||||
@@ -79,26 +86,34 @@ data RoutineParam = RoutineParam
|
||||
|
||||
-- Order by least number of params in the case of overloaded functions
|
||||
instance Ord Routine where
|
||||
Function schema1 name1 des1 prms1 rt1 vol1 hasVar1 iso1 `compare` Function schema2 name2 des2 prms2 rt2 vol2 hasVar2 iso2
|
||||
Function schema1 name1 des1 prms1 rt1 vol1 hasVar1 iso1 tout1 `compare` Function schema2 name2 des2 prms2 rt2 vol2 hasVar2 iso2 tout2
|
||||
| schema1 == schema2 && name1 == name2 && length prms1 < length prms2 = LT
|
||||
| schema2 == schema2 && name1 == name2 && length prms1 > length prms2 = GT
|
||||
| otherwise = (schema1, name1, des1, prms1, rt1, vol1, hasVar1, iso1) `compare` (schema2, name2, des2, prms2, rt2, vol2, hasVar2, iso2)
|
||||
| otherwise = (schema1, name1, des1, prms1, rt1, vol1, hasVar1, iso1, tout1) `compare` (schema2, name2, des2, prms2, rt2, vol2, hasVar2, iso2, tout2)
|
||||
|
||||
-- | A map of all procs, all of which can be overloaded(one entry will have more than one Routine).
|
||||
-- | It uses a HashMap for a faster lookup.
|
||||
type RoutineMap = HM.HashMap QualifiedIdentifier [Routine]
|
||||
|
||||
data ResultAggregate
|
||||
= BuiltinAggJson
|
||||
| BuiltinAggSingleJson Bool
|
||||
-- | A media handler can be an aggregate over a composite type or a function over a scalar
|
||||
data MediaHandler
|
||||
-- non overridable builtins
|
||||
= BuiltinAggSingleJson Bool
|
||||
| BuiltinAggArrayJsonStrip
|
||||
| BuiltinAggGeoJson
|
||||
| BuiltinAggCsv
|
||||
| BuiltinAggXml (Maybe FieldName)
|
||||
| BuiltinAggBinary (Maybe FieldName)
|
||||
-- these builtins are overridable
|
||||
| BuiltinOvAggJson
|
||||
| BuiltinOvAggGeoJson
|
||||
| BuiltinOvAggCsv
|
||||
-- custom
|
||||
| CustomFunc QualifiedIdentifier
|
||||
| NoAgg
|
||||
deriving (Eq, Show)
|
||||
|
||||
funcReturnsSingle :: Routine -> Bool
|
||||
funcReturnsSingle proc = case proc of
|
||||
Function{pdReturnType = Single _} -> True
|
||||
_ -> False
|
||||
|
||||
funcReturnsScalar :: Routine -> Bool
|
||||
funcReturnsScalar proc = case proc of
|
||||
Function{pdReturnType = Single (Scalar{})} -> True
|
||||
@@ -130,3 +145,7 @@ funcTableName proc = case pdReturnType proc of
|
||||
SetOf (Composite qi _) -> Just $ qiName qi
|
||||
Single (Composite qi _) -> Just $ qiName qi
|
||||
_ -> Nothing
|
||||
|
||||
-- the resolved handler also carries the media type because MTAny (*/*) is resolved to a different media type
|
||||
type ResolvedHandler = (MediaHandler, MediaType.MediaType)
|
||||
type MediaHandlerMap = HM.HashMap (RelIdentifier, MediaType.MediaType) ResolvedHandler
|
||||
|
||||
+42
-46
@@ -1,57 +1,53 @@
|
||||
{-# LANGUAGE CPP #-}
|
||||
|
||||
module PostgREST.Unix
|
||||
( runAppWithSocket
|
||||
, installSignalHandlers
|
||||
( installSignalHandlers
|
||||
, createAndBindDomainSocket
|
||||
) where
|
||||
|
||||
import qualified Network.Socket as Socket
|
||||
import qualified Network.Wai.Handler.Warp as Warp
|
||||
import qualified System.Posix.Signals as Signals
|
||||
#ifndef mingw32_HOST_OS
|
||||
import qualified System.Posix.Signals as Signals
|
||||
#endif
|
||||
import System.Posix.Types (FileMode)
|
||||
import System.PosixCompat.Files (setFileMode)
|
||||
|
||||
import Network.Wai (Application)
|
||||
import System.Directory (removeFile)
|
||||
import System.IO.Error (isDoesNotExistError)
|
||||
import System.Posix.Files (setFileMode)
|
||||
import System.Posix.Types (FileMode)
|
||||
|
||||
import qualified PostgREST.AppState as AppState
|
||||
|
||||
import Protolude
|
||||
|
||||
|
||||
-- | Run the PostgREST application with user defined socket.
|
||||
runAppWithSocket :: Warp.Settings -> Application -> FileMode -> FilePath -> IO ()
|
||||
runAppWithSocket settings app socketFileMode socketFilePath =
|
||||
bracket createAndBindSocket Socket.close $ \socket -> do
|
||||
Socket.listen socket Socket.maxListenQueue
|
||||
Warp.runSettingsSocket settings socket app
|
||||
where
|
||||
createAndBindSocket = do
|
||||
deleteSocketFileIfExist socketFilePath
|
||||
sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
|
||||
Socket.bind sock $ Socket.SockAddrUnix socketFilePath
|
||||
setFileMode socketFilePath socketFileMode
|
||||
return sock
|
||||
|
||||
deleteSocketFileIfExist path =
|
||||
removeFile path `catch` handleDoesNotExist
|
||||
|
||||
handleDoesNotExist e
|
||||
| isDoesNotExistError e = return ()
|
||||
| otherwise = throwIO e
|
||||
import Data.String (String)
|
||||
import qualified Network.Socket as NS
|
||||
import Protolude
|
||||
import System.Directory (removeFile)
|
||||
import System.IO.Error (isDoesNotExistError)
|
||||
|
||||
-- | Set signal handlers, only for systems with signals
|
||||
installSignalHandlers :: AppState.AppState -> IO ()
|
||||
installSignalHandlers appState = do
|
||||
let interrupt = throwTo (AppState.getMainThreadId appState) UserInterrupt
|
||||
installSignalHandlers :: ThreadId -> IO () -> IO () -> IO ()
|
||||
#ifndef mingw32_HOST_OS
|
||||
installSignalHandlers tid usr1 usr2 = do
|
||||
let interrupt = throwTo tid UserInterrupt
|
||||
install Signals.sigINT interrupt
|
||||
install Signals.sigTERM interrupt
|
||||
|
||||
-- The SIGUSR1 signal updates the internal 'SchemaCache' by running
|
||||
-- 'connectionWorker' exactly as before.
|
||||
install Signals.sigUSR1 $ AppState.connectionWorker appState
|
||||
|
||||
-- Re-read the config on SIGUSR2
|
||||
install Signals.sigUSR2 $ AppState.reReadConfig False appState
|
||||
install Signals.sigUSR1 usr1
|
||||
install Signals.sigUSR2 usr2
|
||||
where
|
||||
install signal handler =
|
||||
void $ Signals.installHandler signal (Signals.Catch handler) Nothing
|
||||
#else
|
||||
installSignalHandlers _ _ _ = pass
|
||||
#endif
|
||||
|
||||
-- | Create a unix domain socket and bind it to the given path.
|
||||
-- | The socket file will be deleted if it already exists.
|
||||
createAndBindDomainSocket :: String -> FileMode -> IO NS.Socket
|
||||
createAndBindDomainSocket path mode = do
|
||||
unless NS.isUnixDomainSocketAvailable $
|
||||
panic "Cannot run with unix socket on non-unix platforms. Consider deleting the `server-unix-socket` config entry in order to continue."
|
||||
deleteSocketFileIfExist path
|
||||
sock <- NS.socket NS.AF_UNIX NS.Stream NS.defaultProtocol
|
||||
NS.bind sock $ NS.SockAddrUnix path
|
||||
NS.listen sock (max 2048 NS.maxListenQueue)
|
||||
setFileMode path mode
|
||||
return sock
|
||||
where
|
||||
deleteSocketFileIfExist path' =
|
||||
removeFile path' `catch` handleDoesNotExist
|
||||
handleDoesNotExist e
|
||||
| isDoesNotExistError e = return ()
|
||||
| otherwise = throwIO e
|
||||
|
||||
+1
-1
@@ -12,5 +12,5 @@ nix:
|
||||
extra-deps:
|
||||
- git: https://github.com/PostgREST/postgresql-libpq.git
|
||||
commit: 890a0a16cf57dd401420fdc6c7d576fb696003bc
|
||||
- hasql-notifications-0.2.0.5
|
||||
- hasql-notifications-0.2.0.6
|
||||
- hasql-pool-0.10
|
||||
|
||||
+3
-3
@@ -16,12 +16,12 @@ packages:
|
||||
commit: 890a0a16cf57dd401420fdc6c7d576fb696003bc
|
||||
git: https://github.com/PostgREST/postgresql-libpq.git
|
||||
- completed:
|
||||
hackage: hasql-notifications-0.2.0.5@sha256:6c67d2ee42e948162e89176a2f52a2c6bb562678b33688e96e048be9b3d74122,2028
|
||||
hackage: hasql-notifications-0.2.0.6@sha256:16d783f5cd1660fad924fd3769380889de5804e057f09b304dcdc3a3ff11eb3c,2028
|
||||
pantry-tree:
|
||||
sha256: 8493755b0817a36b910a37d568769ca49ab715ef5dd935a0d09f0716aa39da51
|
||||
sha256: 2319743501bb3c0bef801014ce61308b8666cef86ae5a97a0a283c0c1ec12d4f
|
||||
size: 452
|
||||
original:
|
||||
hackage: hasql-notifications-0.2.0.5
|
||||
hackage: hasql-notifications-0.2.0.6
|
||||
- completed:
|
||||
hackage: hasql-pool-0.10@sha256:912197a328acb85505f98bb9700d61f366b87659ca45126c5c2d636687b801c3,2112
|
||||
pantry-tree:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
@@ -16,6 +16,7 @@ main =
|
||||
, "src/PostgREST/Query/SqlFragment.hs"
|
||||
, "src/PostgREST/ApiRequest/Preferences.hs"
|
||||
, "src/PostgREST/ApiRequest/QueryParams.hs"
|
||||
, "src/PostgREST/Response/Performance.hs"
|
||||
, "src/PostgREST/Error.hs"
|
||||
, "src/PostgREST/MediaType.hs"
|
||||
, "src/PostgREST/Config.hs"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = ""
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
@@ -8,6 +9,7 @@ db-pool = 10
|
||||
db-pool-acquisition-timeout = 10
|
||||
db-pool-max-lifetime = 1800
|
||||
db-pool-max-idletime = 5
|
||||
db-pool-automatic-recovery = true
|
||||
db-pre-request = "check_alias"
|
||||
db-prepared-statements = true
|
||||
db-root-spec = "open_alias"
|
||||
@@ -16,19 +18,20 @@ db-config = true
|
||||
db-pre-config = ""
|
||||
db-tx-end = "commit"
|
||||
db-uri = "postgresql://"
|
||||
db-use-legacy-gucs = true
|
||||
jwt-aud = ""
|
||||
jwt-role-claim-key = ".\"aliased\""
|
||||
jwt-secret = ""
|
||||
jwt-secret-is-base64 = true
|
||||
jwt-cache-max-lifetime = 0
|
||||
log-level = "error"
|
||||
openapi-mode = "follow-privileges"
|
||||
openapi-security-active = false
|
||||
openapi-server-proxy-uri = ""
|
||||
raw-media-types = ""
|
||||
server-cors-allowed-origins = ""
|
||||
server-host = "!4"
|
||||
server-port = 3000
|
||||
server-trace-header = ""
|
||||
server-timing-enabled = false
|
||||
server-unix-socket = ""
|
||||
server-unix-socket-mode = "660"
|
||||
admin-server-port = ""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = ""
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
@@ -8,6 +9,7 @@ db-pool = 10
|
||||
db-pool-acquisition-timeout = 10
|
||||
db-pool-max-lifetime = 1800
|
||||
db-pool-max-idletime = 30
|
||||
db-pool-automatic-recovery = true
|
||||
db-pre-request = ""
|
||||
db-prepared-statements = false
|
||||
db-root-spec = ""
|
||||
@@ -16,19 +18,20 @@ db-config = true
|
||||
db-pre-config = ""
|
||||
db-tx-end = "commit"
|
||||
db-uri = "postgresql://"
|
||||
db-use-legacy-gucs = true
|
||||
jwt-aud = ""
|
||||
jwt-role-claim-key = ".\"role\""
|
||||
jwt-secret = ""
|
||||
jwt-secret-is-base64 = true
|
||||
jwt-cache-max-lifetime = 0
|
||||
log-level = "error"
|
||||
openapi-mode = "follow-privileges"
|
||||
openapi-security-active = false
|
||||
openapi-server-proxy-uri = ""
|
||||
raw-media-types = ""
|
||||
server-cors-allowed-origins = ""
|
||||
server-host = "!4"
|
||||
server-port = 3000
|
||||
server-trace-header = ""
|
||||
server-timing-enabled = false
|
||||
server-unix-socket = ""
|
||||
server-unix-socket-mode = "660"
|
||||
admin-server-port = ""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = ""
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
@@ -8,6 +9,7 @@ db-pool = 10
|
||||
db-pool-acquisition-timeout = 10
|
||||
db-pool-max-lifetime = 1800
|
||||
db-pool-max-idletime = 30
|
||||
db-pool-automatic-recovery = true
|
||||
db-pre-request = ""
|
||||
db-prepared-statements = false
|
||||
db-root-spec = ""
|
||||
@@ -16,19 +18,20 @@ db-config = true
|
||||
db-pre-config = ""
|
||||
db-tx-end = "commit"
|
||||
db-uri = "postgresql://"
|
||||
db-use-legacy-gucs = true
|
||||
jwt-aud = ""
|
||||
jwt-role-claim-key = ".\"role\""
|
||||
jwt-secret = ""
|
||||
jwt-secret-is-base64 = true
|
||||
jwt-cache-max-lifetime = 0
|
||||
log-level = "error"
|
||||
openapi-mode = "follow-privileges"
|
||||
openapi-security-active = false
|
||||
openapi-server-proxy-uri = ""
|
||||
raw-media-types = ""
|
||||
server-cors-allowed-origins = ""
|
||||
server-host = "!4"
|
||||
server-port = 3000
|
||||
server-trace-header = ""
|
||||
server-timing-enabled = false
|
||||
server-unix-socket = ""
|
||||
server-unix-socket-mode = "660"
|
||||
admin-server-port = ""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = ""
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
@@ -8,6 +9,7 @@ db-pool = 10
|
||||
db-pool-acquisition-timeout = 10
|
||||
db-pool-max-lifetime = 1800
|
||||
db-pool-max-idletime = 30
|
||||
db-pool-automatic-recovery = true
|
||||
db-pre-request = ""
|
||||
db-prepared-statements = true
|
||||
db-root-spec = ""
|
||||
@@ -16,19 +18,20 @@ db-config = false
|
||||
db-pre-config = ""
|
||||
db-tx-end = "commit"
|
||||
db-uri = "postgresql://"
|
||||
db-use-legacy-gucs = true
|
||||
jwt-aud = ""
|
||||
jwt-role-claim-key = ".\"role\""
|
||||
jwt-secret = ""
|
||||
jwt-secret-is-base64 = false
|
||||
jwt-cache-max-lifetime = 0
|
||||
log-level = "error"
|
||||
openapi-mode = "follow-privileges"
|
||||
openapi-security-active = false
|
||||
openapi-server-proxy-uri = ""
|
||||
raw-media-types = ""
|
||||
server-cors-allowed-origins = ""
|
||||
server-host = "!4"
|
||||
server-port = 3000
|
||||
server-trace-header = ""
|
||||
server-timing-enabled = false
|
||||
server-unix-socket = ""
|
||||
server-unix-socket-mode = "660"
|
||||
admin-server-port = ""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = "pre_config_role"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
@@ -8,6 +9,7 @@ db-pool = 1
|
||||
db-pool-acquisition-timeout = 30
|
||||
db-pool-max-lifetime = 3600
|
||||
db-pool-max-idletime = 60
|
||||
db-pool-automatic-recovery = false
|
||||
db-pre-request = "test.other_custom_headers"
|
||||
db-prepared-statements = false
|
||||
db-root-spec = "other_root"
|
||||
@@ -16,19 +18,20 @@ db-config = true
|
||||
db-pre-config = "postgrest.pre_config"
|
||||
db-tx-end = "rollback-allow-override"
|
||||
db-uri = "postgresql://"
|
||||
db-use-legacy-gucs = false
|
||||
jwt-aud = "https://otherexample.org"
|
||||
jwt-role-claim-key = ".\"other\".\"pre_config_role\""
|
||||
jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE"
|
||||
jwt-secret-is-base64 = true
|
||||
jwt-cache-max-lifetime = 7200
|
||||
log-level = "info"
|
||||
openapi-mode = "disabled"
|
||||
openapi-security-active = false
|
||||
openapi-server-proxy-uri = "https://otherexample.org/api"
|
||||
raw-media-types = "application/vnd.pgrst.other-db-config"
|
||||
server-cors-allowed-origins = "http://example.com"
|
||||
server-host = "0.0.0.0"
|
||||
server-port = 80
|
||||
server-trace-header = "traceparent"
|
||||
server-timing-enabled = true
|
||||
server-unix-socket = "/tmp/pgrst_io_test.sock"
|
||||
server-unix-socket-mode = "777"
|
||||
admin-server-port = 3001
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = "anonymous"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
@@ -8,6 +9,7 @@ db-pool = 1
|
||||
db-pool-acquisition-timeout = 30
|
||||
db-pool-max-lifetime = 3600
|
||||
db-pool-max-idletime = 60
|
||||
db-pool-automatic-recovery = false
|
||||
db-pre-request = "test.custom_headers"
|
||||
db-prepared-statements = false
|
||||
db-root-spec = "root"
|
||||
@@ -16,19 +18,20 @@ db-config = true
|
||||
db-pre-config = "postgrest.preconf"
|
||||
db-tx-end = "commit-allow-override"
|
||||
db-uri = "postgresql://"
|
||||
db-use-legacy-gucs = false
|
||||
jwt-aud = "https://example.org"
|
||||
jwt-role-claim-key = ".\"a\".\"role\""
|
||||
jwt-secret = "OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE"
|
||||
jwt-secret-is-base64 = false
|
||||
jwt-cache-max-lifetime = 3600
|
||||
log-level = "info"
|
||||
openapi-mode = "ignore-privileges"
|
||||
openapi-security-active = true
|
||||
openapi-server-proxy-uri = "https://example.org/api"
|
||||
raw-media-types = "application/vnd.pgrst.db-config"
|
||||
server-cors-allowed-origins = "http://example.com"
|
||||
server-host = "0.0.0.0"
|
||||
server-port = 80
|
||||
server-trace-header = "CF-Ray"
|
||||
server-timing-enabled = true
|
||||
server-unix-socket = "/tmp/pgrst_io_test.sock"
|
||||
server-unix-socket-mode = "777"
|
||||
admin-server-port = 3001
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = true
|
||||
db-anon-role = "root"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
@@ -8,6 +9,7 @@ db-pool = 1
|
||||
db-pool-acquisition-timeout = 30
|
||||
db-pool-max-lifetime = 3600
|
||||
db-pool-max-idletime = 60
|
||||
db-pool-automatic-recovery = false
|
||||
db-pre-request = "please_run_fast"
|
||||
db-prepared-statements = false
|
||||
db-root-spec = "openapi_v3"
|
||||
@@ -16,19 +18,20 @@ db-config = false
|
||||
db-pre-config = "postgrest.pre_config"
|
||||
db-tx-end = "rollback-allow-override"
|
||||
db-uri = "tmp_db"
|
||||
db-use-legacy-gucs = false
|
||||
jwt-aud = "https://postgrest.org"
|
||||
jwt-role-claim-key = ".\"user\"[0].\"real-role\""
|
||||
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
|
||||
jwt-secret-is-base64 = true
|
||||
jwt-cache-max-lifetime = 86400
|
||||
log-level = "info"
|
||||
openapi-mode = "ignore-privileges"
|
||||
openapi-security-active = true
|
||||
openapi-server-proxy-uri = "https://postgrest.org"
|
||||
raw-media-types = "application/vnd.pgrst.config"
|
||||
server-cors-allowed-origins = "http://example.com"
|
||||
server-host = "0.0.0.0"
|
||||
server-port = 80
|
||||
server-trace-header = "X-Request-Id"
|
||||
server-timing-enabled = true
|
||||
server-unix-socket = "/tmp/pgrst_io_test.sock"
|
||||
server-unix-socket-mode = "777"
|
||||
admin-server-port = 3001
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = false
|
||||
db-anon-role = ""
|
||||
db-channel = "pgrst"
|
||||
db-channel-enabled = true
|
||||
@@ -8,6 +9,7 @@ db-pool = 10
|
||||
db-pool-acquisition-timeout = 10
|
||||
db-pool-max-lifetime = 1800
|
||||
db-pool-max-idletime = 30
|
||||
db-pool-automatic-recovery = true
|
||||
db-pre-request = ""
|
||||
db-prepared-statements = true
|
||||
db-root-spec = ""
|
||||
@@ -16,19 +18,20 @@ db-config = true
|
||||
db-pre-config = ""
|
||||
db-tx-end = "commit"
|
||||
db-uri = "postgresql://"
|
||||
db-use-legacy-gucs = true
|
||||
jwt-aud = ""
|
||||
jwt-role-claim-key = ".\"role\""
|
||||
jwt-secret = ""
|
||||
jwt-secret-is-base64 = false
|
||||
jwt-cache-max-lifetime = 0
|
||||
log-level = "error"
|
||||
openapi-mode = "follow-privileges"
|
||||
openapi-security-active = false
|
||||
openapi-server-proxy-uri = ""
|
||||
raw-media-types = ""
|
||||
server-cors-allowed-origins = ""
|
||||
server-host = "!4"
|
||||
server-port = 3000
|
||||
server-trace-header = ""
|
||||
server-timing-enabled = false
|
||||
server-unix-socket = ""
|
||||
server-unix-socket-mode = "660"
|
||||
admin-server-port = ""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
PGRST_APP_SETTINGS_test2: test
|
||||
PGRST_APP_SETTINGS_test: test
|
||||
PGRST_DB_AGGREGATES_ENABLED: true
|
||||
PGRST_DB_ANON_ROLE: root
|
||||
PGRST_DB_CHANNEL: postgrest
|
||||
PGRST_DB_CHANNEL_ENABLED: false
|
||||
@@ -10,6 +11,7 @@ PGRST_DB_POOL: 1
|
||||
PGRST_DB_POOL_ACQUISITION_TIMEOUT: 30
|
||||
PGRST_DB_POOL_MAX_LIFETIME: 3600
|
||||
PGRST_DB_POOL_MAX_IDLETIME: 60
|
||||
PGRST_DB_POOL_AUTOMATIC_RECOVERY: false
|
||||
PGRST_DB_PREPARED_STATEMENTS: false
|
||||
PGRST_DB_PRE_REQUEST: please_run_fast
|
||||
PGRST_DB_ROOT_SPEC: openapi_v3
|
||||
@@ -23,14 +25,16 @@ PGRST_JWT_AUD: 'https://postgrest.org'
|
||||
PGRST_JWT_ROLE_CLAIM_KEY: '.user[0]."real-role"'
|
||||
PGRST_JWT_SECRET: c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5
|
||||
PGRST_JWT_SECRET_IS_BASE64: true
|
||||
PGRST_JWT_CACHE_MAX_LIFETIME: 86400
|
||||
PGRST_LOG_LEVEL: info
|
||||
PGRST_OPENAPI_MODE: 'ignore-privileges'
|
||||
PGRST_OPENAPI_SECURITY_ACTIVE: true
|
||||
PGRST_OPENAPI_SERVER_PROXY_URI: 'https://postgrest.org'
|
||||
PGRST_RAW_MEDIA_TYPES: application/vnd.pgrst.config
|
||||
PGRST_SERVER_CORS_ALLOWED_ORIGINS: "http://example.com"
|
||||
PGRST_SERVER_HOST: 0.0.0.0
|
||||
PGRST_SERVER_PORT: 80
|
||||
PGRST_SERVER_TRACE_HEADER: X-Request-Id
|
||||
PGRST_SERVER_TIMING_ENABLED: true
|
||||
PGRST_SERVER_UNIX_SOCKET: /tmp/pgrst_io_test.sock
|
||||
PGRST_SERVER_UNIX_SOCKET_MODE: 777
|
||||
PGRST_ADMIN_SERVER_PORT: 3001
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
db-aggregates-enabled = true
|
||||
db-anon-role = "root"
|
||||
db-channel = "postgrest"
|
||||
db-channel-enabled = false
|
||||
@@ -8,6 +9,7 @@ db-pool = 1
|
||||
db-pool-acquisition-timeout = 30
|
||||
db-pool-max-lifetime = 3600
|
||||
db-pool-max-idletime = 60
|
||||
db-pool-automatic-recovery = false
|
||||
db-pre-request = "please_run_fast"
|
||||
db-prepared-statements = false
|
||||
db-root-spec = "openapi_v3"
|
||||
@@ -16,19 +18,20 @@ db-config = false
|
||||
db-pre-config = "postgrest.pre_config"
|
||||
db-tx-end = "rollback-allow-override"
|
||||
db-uri = "tmp_db"
|
||||
db-use-legacy-gucs = false
|
||||
jwt-aud = "https://postgrest.org"
|
||||
jwt-role-claim-key = ".user[0].\"real-role\""
|
||||
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5"
|
||||
jwt-secret-is-base64 = true
|
||||
jwt-cache-max-lifetime = 86400
|
||||
log-level = "info"
|
||||
openapi-mode = "ignore-privileges"
|
||||
openapi-security-active = true
|
||||
openapi-server-proxy-uri = "https://postgrest.org"
|
||||
raw-media-types = "application/vnd.pgrst.config"
|
||||
server-cors-allowed-origins = "http://example.com"
|
||||
server-host = "0.0.0.0"
|
||||
server-port = 80
|
||||
server-trace-header = "X-Request-Id"
|
||||
server-timing-enabled = true
|
||||
server-unix-socket = "/tmp/pgrst_io_test.sock"
|
||||
server-unix-socket-mode = "777"
|
||||
admin-server-port = 3001
|
||||
|
||||
@@ -8,6 +8,3 @@ db-channel-enabled = 13
|
||||
|
||||
# expects integer or string
|
||||
db-max-rows = true
|
||||
|
||||
# expects string
|
||||
raw-media-types = true
|
||||
|
||||
@@ -3,10 +3,11 @@ CREATE ROLE db_config_authenticator LOGIN NOINHERIT;
|
||||
-- reloadable config options
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_aud = 'https://example.org';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.openapi_server_proxy_uri = 'https://example.org/api';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.raw_media_types = 'application/vnd.pgrst.db-config';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_secret = 'REALLY=REALLY=REALLY=REALLY=VERY=SAFE';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_secret_is_base64 = 'false';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_role_claim_key = '."a"."role"';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.jwt_cache_max_lifetime = '3600';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_aggregates_enabled = 'false';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_anon_role = 'anonymous';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_tx_end = 'commit-allow-override';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_pre_config = 'postgrest.preconf';
|
||||
@@ -18,7 +19,9 @@ ALTER ROLE db_config_authenticator SET pgrst.db_pre_request = 'test.custom_heade
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_max_rows = '1000';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.db_extra_search_path = 'public, extensions';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.not_existing = 'should be ignored';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.server_cors_allowed_origins = 'http://example.com';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.server_trace_header = 'CF-Ray';
|
||||
ALTER ROLE db_config_authenticator SET pgrst.server_timing_enabled = 'true';
|
||||
|
||||
-- override with database specific setting
|
||||
ALTER ROLE db_config_authenticator IN DATABASE :DBNAME SET pgrst.jwt_secret = 'OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE';
|
||||
@@ -50,9 +53,10 @@ ALTER ROLE db_config_authenticator SET pgrst.db_config = 'true';
|
||||
CREATE ROLE other_authenticator LOGIN NOINHERIT;
|
||||
ALTER ROLE other_authenticator SET pgrst.jwt_aud = 'https://otherexample.org';
|
||||
ALTER ROLE other_authenticator SET pgrst.openapi_server_proxy_uri = 'https://otherexample.org/api';
|
||||
ALTER ROLE other_authenticator SET pgrst.raw_media_types = 'application/vnd.pgrst.other-db-config';
|
||||
ALTER ROLE other_authenticator SET pgrst.jwt_secret = 'ODERREALLYREALLYREALLYREALLYVERYSAFE';
|
||||
ALTER ROLE other_authenticator SET pgrst.jwt_secret_is_base64 = 'true';
|
||||
ALTER ROLE other_authenticator SET pgrst.jwt_cache_max_lifetime = '7200';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_aggregates_enabled = 'false';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_schemas = 'test, other_tenant1, other_tenant2';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_root_spec = 'other_root';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_plan_enabled = 'true';
|
||||
@@ -62,8 +66,10 @@ ALTER ROLE other_authenticator SET pgrst.db_max_rows = '100';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_extra_search_path = 'public, extensions, other';
|
||||
ALTER ROLE other_authenticator SET pgrst.openapi_mode = 'disabled';
|
||||
ALTER ROLE other_authenticator SET pgrst.openapi_security_active = 'false';
|
||||
ALTER ROLE other_authenticator SET pgrst.server_cors_allowed_origins = 'http://example.com';
|
||||
ALTER ROLE other_authenticator SET pgrst.server_trace_header = 'traceparent';
|
||||
ALTER ROLE other_authenticator SET pgrst.db_pre_config = 'postgrest.pre_config';
|
||||
ALTER ROLE other_authenticator SET pgrst.server_timing_enabled = 'true';
|
||||
|
||||
create schema postgrest;
|
||||
grant usage on schema postgrest to db_config_authenticator;
|
||||
|
||||
+25
-1
@@ -1,6 +1,7 @@
|
||||
-- \ir big_schema.sql big schema test currently skipped, see test_io.py
|
||||
\ir db_config.sql
|
||||
|
||||
set check_function_bodies = false; -- to allow conditionals based on the pg version
|
||||
set search_path to public;
|
||||
|
||||
CREATE ROLE postgrest_test_anonymous;
|
||||
@@ -18,6 +19,13 @@ CREATE ROLE postgrest_test_w_superuser_settings;
|
||||
alter role postgrest_test_w_superuser_settings set log_min_duration_statement = 1;
|
||||
alter role postgrest_test_w_superuser_settings set log_min_messages = 'fatal';
|
||||
|
||||
DO $do$BEGIN
|
||||
IF (SELECT current_setting('server_version_num')::INT >= 150000) THEN
|
||||
ALTER ROLE postgrest_test_w_superuser_settings SET log_min_duration_sample = 12345;
|
||||
GRANT SET ON PARAMETER log_min_duration_sample to postgrest_test_authenticator;
|
||||
END IF;
|
||||
END$do$;
|
||||
|
||||
GRANT
|
||||
postgrest_test_anonymous, postgrest_test_author,
|
||||
postgrest_test_serializable, postgrest_test_repeatable_read,
|
||||
@@ -173,4 +181,20 @@ select application_name
|
||||
from pg_stat_activity
|
||||
where application_name ilike 'postgrest%'
|
||||
limit 1;
|
||||
$$
|
||||
$$;
|
||||
|
||||
create function terminate_pgrst() returns setof record as $$
|
||||
select pg_terminate_backend(pid) from pg_stat_activity where application_name iLIKE '%postgrest%';
|
||||
$$ language sql security definer;
|
||||
|
||||
create or replace function one_sec_timeout() returns void as $$
|
||||
select pg_sleep(3);
|
||||
$$ language sql set statement_timeout = '1s';
|
||||
|
||||
create or replace function four_sec_timeout() returns void as $$
|
||||
select pg_sleep(3);
|
||||
$$ language sql set statement_timeout = '4s';
|
||||
|
||||
create function get_postgres_version() returns int as $$
|
||||
select current_setting('server_version_num')::int;
|
||||
$$ language sql;
|
||||
|
||||
@@ -119,11 +119,6 @@ cli:
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_DB_TX_END: rollback
|
||||
- name: raw-media-types list
|
||||
expect: 'raw-media-types = "image/png,image/jpeg"'
|
||||
use_defaultenv: true
|
||||
env:
|
||||
PGRST_RAW_MEDIA_TYPES: ' image/png , image/jpeg '
|
||||
|
||||
roleclaims:
|
||||
- key: '.postgrest.a_role'
|
||||
|
||||
@@ -17,6 +17,21 @@ import requests_unixsocket
|
||||
from config import *
|
||||
|
||||
|
||||
def sleep_until_postgrest_scache_reload():
|
||||
"Sleep until schema cache reload"
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
def sleep_until_postgrest_config_reload():
|
||||
"Sleep until config reload"
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def sleep_until_postgrest_full_reload():
|
||||
"Sleep until schema cache plus config reload"
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
class PostgrestTimedOut(Exception):
|
||||
"Connecting to PostgREST endpoint timed out."
|
||||
|
||||
|
||||
+332
-20
@@ -1,6 +1,6 @@
|
||||
"Unit tests for Input/Ouput of PostgREST seen as a black box."
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from operator import attrgetter
|
||||
import os
|
||||
import re
|
||||
@@ -169,7 +169,7 @@ def test_app_settings_flush_pool(defaultenv):
|
||||
|
||||
# SIGUSR1 causes the postgres connection pool to be flushed
|
||||
postgrest.process.send_signal(signal.SIGUSR1)
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_scache_reload()
|
||||
|
||||
uri = "/rpc/get_guc_value?name=app.settings.external_api_secret"
|
||||
response = postgrest.session.get(uri)
|
||||
@@ -197,6 +197,13 @@ def test_flush_pool_no_interrupt(defaultenv):
|
||||
t.join()
|
||||
|
||||
|
||||
def test_random_port_bound(defaultenv):
|
||||
"PostgREST should bind to a random port when PGRST_SERVER_PORT is 0."
|
||||
|
||||
with run(env=defaultenv, port="0") as postgrest:
|
||||
assert True # liveness check is done by run(), so we just need to check that it doesn't fail
|
||||
|
||||
|
||||
def test_app_settings_reload(tmp_path, defaultenv):
|
||||
"App settings should be reloaded from file when PostgREST is sent SIGUSR2."
|
||||
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
|
||||
@@ -213,7 +220,7 @@ def test_app_settings_reload(tmp_path, defaultenv):
|
||||
# reload
|
||||
postgrest.process.send_signal(signal.SIGUSR2)
|
||||
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get(uri)
|
||||
assert response.text == '"Jane"'
|
||||
@@ -237,7 +244,7 @@ def test_jwt_secret_reload(tmp_path, defaultenv):
|
||||
# reload config
|
||||
postgrest.process.send_signal(signal.SIGUSR2)
|
||||
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
@@ -267,14 +274,14 @@ def test_jwt_secret_external_file_reload(tmp_path, defaultenv):
|
||||
|
||||
# SIGUSR1 doesn't reload external files, at least when db-config=false
|
||||
postgrest.process.send_signal(signal.SIGUSR1)
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_scache_reload()
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
# reload config and external file with SIGUSR2
|
||||
postgrest.process.send_signal(signal.SIGUSR2)
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
@@ -285,7 +292,7 @@ def test_jwt_secret_external_file_reload(tmp_path, defaultenv):
|
||||
# reload config and external file with NOTIFY
|
||||
response = postgrest.session.post("/rpc/reload_pgrst_config")
|
||||
assert response.status_code == 204
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
@@ -308,11 +315,11 @@ def test_db_schema_reload(tmp_path, defaultenv):
|
||||
|
||||
# reload config
|
||||
postgrest.process.send_signal(signal.SIGUSR2)
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
# reload schema cache to verify that the config reload actually happened
|
||||
postgrest.process.send_signal(signal.SIGUSR1)
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_scache_reload()
|
||||
|
||||
response = postgrest.session.get("/rpc/get_guc_value?name=search_path")
|
||||
assert response.text == '"\\"v1\\", \\"public\\""'
|
||||
@@ -332,7 +339,7 @@ def test_db_schema_notify_reload(defaultenv):
|
||||
"/rpc/change_db_schema_and_full_reload", data={"schemas": "v1"}
|
||||
)
|
||||
|
||||
time.sleep(0.2)
|
||||
sleep_until_postgrest_full_reload()
|
||||
|
||||
response = postgrest.session.get("/rpc/get_guc_value?name=search_path")
|
||||
assert response.text == '"\\"v1\\", \\"public\\""'
|
||||
@@ -360,7 +367,7 @@ def test_max_rows_reload(defaultenv):
|
||||
# reload config
|
||||
postgrest.process.send_signal(signal.SIGUSR2)
|
||||
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.head("/projects")
|
||||
assert response.status_code == 200
|
||||
@@ -390,7 +397,7 @@ def test_max_rows_notify_reload(defaultenv):
|
||||
"/rpc/change_max_rows_config", data={"val": 1, "notify": True}
|
||||
)
|
||||
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.head("/projects")
|
||||
assert response.status_code == 200
|
||||
@@ -501,7 +508,7 @@ def test_change_statement_timeout(defaultenv, metapostgrest):
|
||||
|
||||
# trigger schema refresh
|
||||
postgrest.process.send_signal(signal.SIGUSR1)
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_scache_reload()
|
||||
|
||||
response = postgrest.session.get("/rpc/sleep?seconds=1")
|
||||
assert response.status_code == 500
|
||||
@@ -512,7 +519,7 @@ def test_change_statement_timeout(defaultenv, metapostgrest):
|
||||
|
||||
# trigger role setting refresh
|
||||
postgrest.process.send_signal(signal.SIGUSR1)
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_scache_reload()
|
||||
|
||||
response = postgrest.session.get("/rpc/sleep?seconds=1")
|
||||
assert response.status_code == 204
|
||||
@@ -548,13 +555,15 @@ def test_pool_size(defaultenv, metapostgrest):
|
||||
assert delta > 1 and delta < 1.5
|
||||
|
||||
|
||||
def test_pool_acquisition_timeout(defaultenv, metapostgrest):
|
||||
@pytest.mark.parametrize("level", ["crit", "error", "warn", "info"])
|
||||
def test_pool_acquisition_timeout(level, defaultenv, metapostgrest):
|
||||
"Verify that PGRST_DB_POOL_ACQUISITION_TIMEOUT times out when the pool is empty"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_POOL": "1",
|
||||
"PGRST_DB_POOL_ACQUISITION_TIMEOUT": "1", # 1 second
|
||||
"PGRST_LOG_LEVEL": level,
|
||||
}
|
||||
|
||||
with run(env=env, no_pool_connection_available=True) as postgrest:
|
||||
@@ -565,8 +574,12 @@ def test_pool_acquisition_timeout(defaultenv, metapostgrest):
|
||||
|
||||
# ensure the message appears on the logs as well
|
||||
output = sorted(postgrest.read_stdout(nlines=2))
|
||||
assert " 504 " in output[0]
|
||||
assert "Timed out acquiring connection from connection pool." in output[1]
|
||||
|
||||
if level == "crit":
|
||||
assert len(output) == 0
|
||||
else:
|
||||
assert " 504 " in output[0]
|
||||
assert "Timed out acquiring connection from connection pool." in output[1]
|
||||
|
||||
|
||||
def test_change_statement_timeout_held_connection(defaultenv, metapostgrest):
|
||||
@@ -663,7 +676,7 @@ def test_admin_ready_includes_schema_cache_state(defaultenv, metapostgrest):
|
||||
|
||||
# force a reconnection so the new role setting is picked up
|
||||
postgrest.process.send_signal(signal.SIGUSR1)
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_scache_reload()
|
||||
|
||||
response = postgrest.admin.get("/ready")
|
||||
assert response.status_code == 503
|
||||
@@ -830,7 +843,7 @@ def test_notify_reloading_catalog_cache(defaultenv):
|
||||
# change it to a bigint
|
||||
response = postgrest.session.post("/rpc/drop_change_cats")
|
||||
assert response.status_code == 204
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_scache_reload()
|
||||
|
||||
# next request should succeed with a bigint value
|
||||
response = postgrest.session.get("/cats?id=eq.1")
|
||||
@@ -858,7 +871,7 @@ def test_role_settings(defaultenv):
|
||||
|
||||
response = postgrest.session.get("/rpc/reload_pgrst_config")
|
||||
assert response.status_code == 204
|
||||
time.sleep(0.1)
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get("/rpc/get_guc_value?name=statement_timeout")
|
||||
assert response.text == '"5s"'
|
||||
@@ -1063,3 +1076,302 @@ def test_succeed_w_role_having_superuser_settings(defaultenv):
|
||||
response = postgrest.session.get("/projects", headers=headers)
|
||||
print(response.text)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_get_granted_superuser_setting(defaultenv):
|
||||
"Should succeed when the impersonated role has granted superuser settings"
|
||||
|
||||
env = {**defaultenv, "PGRST_DB_CONFIG": "true", "PGRST_JWT_SECRET": SECRET}
|
||||
|
||||
with run(stdin=SECRET.encode(), env=env) as postgrest:
|
||||
response_ver = postgrest.session.get("/rpc/get_postgres_version")
|
||||
pg_ver = eval(response_ver.text)
|
||||
if pg_ver >= 150000:
|
||||
headers = jwtauthheader(
|
||||
{"role": "postgrest_test_w_superuser_settings"}, SECRET
|
||||
)
|
||||
response = postgrest.session.get(
|
||||
"/rpc/get_guc_value?name=log_min_duration_sample", headers=headers
|
||||
)
|
||||
assert response.text == '"12345ms"'
|
||||
|
||||
|
||||
def test_fail_with_invalid_dbname_and_automatic_recovery_disabled(defaultenv):
|
||||
"Should fail without retries when automatic recovery is disabled and dbname is invalid"
|
||||
dbname = "INVALID"
|
||||
uri = f'postgresql://?dbname={dbname}&host={defaultenv["PGHOST"]}&user={defaultenv["PGUSER"]}'
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_URI": uri,
|
||||
"PGRST_DB_POOL_AUTOMATIC_RECOVERY": "false",
|
||||
}
|
||||
|
||||
with run(env=env, wait_for_readiness=False) as postgrest:
|
||||
exitCode = wait_until_exit(postgrest)
|
||||
assert exitCode == 1
|
||||
|
||||
|
||||
def test_fail_with_automatic_recovery_disabled_and_terminated_using_query(defaultenv):
|
||||
"Should fail without retries when automatic recovery is disabled and pg_terminate_backend(pid) is called"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_POOL_AUTOMATIC_RECOVERY": "false",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
os.system(
|
||||
f'psql -d {defaultenv["PGDATABASE"]} -U {defaultenv["PGUSER"]} -h {defaultenv["PGHOST"]} --set ON_ERROR_STOP=1 -a -c "SELECT terminate_pgrst()"'
|
||||
)
|
||||
|
||||
exitCode = wait_until_exit(postgrest)
|
||||
assert exitCode == 1
|
||||
|
||||
|
||||
def test_server_timing_jwt_should_decrease_on_subsequent_requests(defaultenv):
|
||||
"assert that server-timing duration for JWT should decrease on subsequent requests"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
|
||||
"PGRST_JWT_SECRET": "@/dev/stdin",
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
headers = jwtauthheader(
|
||||
{
|
||||
"role": "postgrest_test_author",
|
||||
"exp": int(
|
||||
(datetime.now(timezone.utc) + timedelta(minutes=30)).timestamp()
|
||||
),
|
||||
},
|
||||
SECRET,
|
||||
)
|
||||
|
||||
with run(stdin=SECRET.encode(), env=env) as postgrest:
|
||||
first_timings = postgrest.session.get("/authors_only", headers=headers).headers[
|
||||
"Server-Timing"
|
||||
]
|
||||
second_timings = postgrest.session.get(
|
||||
"/authors_only", headers=headers
|
||||
).headers["Server-Timing"]
|
||||
|
||||
first_dur = parse_server_timings_header(first_timings)["jwt"]
|
||||
second_dur = parse_server_timings_header(second_timings)["jwt"]
|
||||
|
||||
# their difference should be atleast 300, implying
|
||||
# that JWT Caching is working as expected
|
||||
assert (first_dur - second_dur) > 300.0
|
||||
|
||||
|
||||
# just added to complete code coverage
|
||||
def test_jwt_caching_works_with_db_plan_disabled(defaultenv):
|
||||
"assert that JWT caching words even when Server-Timing header is not returned"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
|
||||
"PGRST_JWT_SECRET": "@/dev/stdin",
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
with run(stdin=SECRET.encode(), env=env) as postgrest:
|
||||
first_request = postgrest.session.get("/authors_only", headers=headers)
|
||||
second_request = postgrest.session.get("/authors_only", headers=headers)
|
||||
|
||||
# in this case we don't get server-timing in response headers
|
||||
# so we can't compare durations, we just check if request succeeds
|
||||
assert first_request.status_code == 200 and second_request.status_code == 200
|
||||
|
||||
|
||||
def test_server_timing_jwt_should_not_decrease_when_caching_disabled(defaultenv):
|
||||
"assert than jwt duration should not decrease when disabled"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_LIFETIME": "0", # cache disabled
|
||||
"PGRST_JWT_SECRET": "@/dev/stdin",
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
with run(stdin=SECRET.encode(), env=env) as postgrest:
|
||||
warmup_req = postgrest.session.get("/authors_only", headers=headers)
|
||||
first_timings = postgrest.session.get("/authors_only", headers=headers).headers[
|
||||
"Server-Timing"
|
||||
]
|
||||
second_timings = postgrest.session.get(
|
||||
"/authors_only", headers=headers
|
||||
).headers["Server-Timing"]
|
||||
|
||||
first_dur = parse_server_timings_header(first_timings)["jwt"]
|
||||
second_dur = parse_server_timings_header(second_timings)["jwt"]
|
||||
|
||||
# their difference should be less than 150
|
||||
# implying that token is not cached
|
||||
assert (first_dur - second_dur) < 150.0
|
||||
|
||||
|
||||
def test_jwt_cache_with_no_exp_claim(defaultenv):
|
||||
"assert than jwt duration should decrease"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_LIFETIME": "86400",
|
||||
"PGRST_JWT_SECRET": "@/dev/stdin",
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) # no exp
|
||||
|
||||
with run(stdin=SECRET.encode(), env=env) as postgrest:
|
||||
first_timings = postgrest.session.get("/authors_only", headers=headers).headers[
|
||||
"Server-Timing"
|
||||
]
|
||||
second_timings = postgrest.session.get(
|
||||
"/authors_only", headers=headers
|
||||
).headers["Server-Timing"]
|
||||
|
||||
first_dur = parse_server_timings_header(first_timings)["jwt"]
|
||||
second_dur = parse_server_timings_header(second_timings)["jwt"]
|
||||
|
||||
# their difference should be atleast 300, implying
|
||||
# that JWT Caching is working as expected
|
||||
assert (first_dur - second_dur) > 300.0
|
||||
|
||||
|
||||
def test_preflight_request_with_cors_allowed_origin_config(defaultenv):
|
||||
"OPTIONS preflight request should return Access-Control-Allow-Origin equal to origin"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_CORS_ALLOWED_ORIGINS": "http://example.com, http://example2.com",
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Accept": "*/*",
|
||||
"Origin": "http://example.com",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "Content-Type",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.options("/items", headers=headers)
|
||||
assert (
|
||||
response.headers["Access-Control-Allow-Origin"] == "http://example.com"
|
||||
and response.headers["Access-Control-Allow-Credentials"] == "true"
|
||||
)
|
||||
|
||||
|
||||
def test_preflight_request_with_empty_cors_allowed_origin_config(defaultenv):
|
||||
"OPTIONS preflight request should allow all origins when config is present but empty"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_CORS_ALLOWED_ORIGINS": "",
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Accept": "*/*",
|
||||
"Origin": "http://anyorigin.com",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "Content-Type",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.options("/items", headers=headers)
|
||||
assert response.headers["Access-Control-Allow-Origin"] == "*"
|
||||
assert "POST" in response.headers["Access-Control-Allow-Methods"]
|
||||
|
||||
|
||||
def test_no_preflight_request_with_CORS_config_should_return_header(defaultenv):
|
||||
"GET no preflight request should return Access-Control-Allow-Origin equal to origin"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_CORS_ALLOWED_ORIGINS": "http://example.com, http://example2.com",
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Accept": "*/*",
|
||||
"Origin": "http://example.com",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/items", headers=headers)
|
||||
assert response.headers["Access-Control-Allow-Origin"] == "http://example.com"
|
||||
|
||||
|
||||
def test_no_preflight_request_with_CORS_config_should_not_return_header(defaultenv):
|
||||
"GET no preflight request should not return Access-Control-Allow-Origin"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_CORS_ALLOWED_ORIGINS": "http://example.com, http://example2.com",
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Accept": "*/*",
|
||||
"Origin": "http://invalid.com",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/items", headers=headers)
|
||||
assert "Access-Control-Allow-Origin" not in response.headers
|
||||
|
||||
|
||||
def test_fail_with_3_sec_statement_and_1_sec_statement_timeout(defaultenv):
|
||||
"statement that takes three seconds to execute should fail with one second timeout"
|
||||
|
||||
with run(env=defaultenv) as postgrest:
|
||||
response = postgrest.session.post("/rpc/one_sec_timeout")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert (
|
||||
response.text
|
||||
== '{"code":"57014","details":null,"hint":null,"message":"canceling statement due to statement timeout"}'
|
||||
)
|
||||
|
||||
|
||||
def test_passes_with_3_sec_statement_and_4_sec_statement_timeout(defaultenv):
|
||||
"statement that takes three seconds to execute should succeed with four second timeout"
|
||||
|
||||
with run(env=defaultenv) as postgrest:
|
||||
response = postgrest.session.post("/rpc/four_sec_timeout")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.parametrize("level", ["crit", "error", "warn", "info"])
|
||||
def test_db_error_logging_to_stderr(level, defaultenv, metapostgrest):
|
||||
"verify that DB errors are logged to stderr"
|
||||
|
||||
role = "timeout_authenticator"
|
||||
set_statement_timeout(metapostgrest, role, 500)
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGUSER": role,
|
||||
"PGRST_DB_ANON_ROLE": role,
|
||||
"PGRST_LOG_LEVEL": level,
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/rpc/sleep?seconds=1")
|
||||
assert response.status_code == 500
|
||||
|
||||
# ensure the message appears on the logs
|
||||
output = sorted(postgrest.read_stdout(nlines=2))
|
||||
|
||||
if level == "crit":
|
||||
assert len(output) == 0
|
||||
else:
|
||||
assert " 500 " in output[0]
|
||||
assert "canceling statement due to statement timeout" in output[1]
|
||||
|
||||
@@ -40,3 +40,20 @@ def authheader(token):
|
||||
def jwtauthheader(claim, secret):
|
||||
"Authorization header with signed JWT."
|
||||
return authheader(jwt.encode(claim, secret))
|
||||
|
||||
|
||||
def parse_server_timings_header(header):
|
||||
"""Parse the Server-Timing header into a dict of metric names to values.
|
||||
|
||||
The header is a comma-separated list of metrics, each of which has a name
|
||||
and a duration. The duration may be followed by a semicolon and a list of
|
||||
parameters, but we ignore those.
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing
|
||||
"""
|
||||
timings = {}
|
||||
for timing in header.split(","):
|
||||
name, duration_text, *_ = timing.split(";")
|
||||
_, duration = duration_text.split("=")
|
||||
timings[name] = float(duration)
|
||||
return timings
|
||||
|
||||
@@ -102,7 +102,7 @@ postJsonArrayTest(){
|
||||
|
||||
echo "Running memory usage tests.."
|
||||
|
||||
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "16M"
|
||||
jsonKeyTest "1M" "POST" "/rpc/leak?columns=blob" "26M"
|
||||
jsonKeyTest "1M" "POST" "/leak?columns=blob" "16M"
|
||||
jsonKeyTest "1M" "PATCH" "/leak?id=eq.1&columns=blob" "16M"
|
||||
|
||||
@@ -114,8 +114,8 @@ jsonKeyTest "50M" "POST" "/rpc/leak?columns=blob" "172M"
|
||||
jsonKeyTest "50M" "POST" "/leak?columns=blob" "172M"
|
||||
jsonKeyTest "50M" "PATCH" "/leak?id=eq.1&columns=blob" "172M"
|
||||
|
||||
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "14M"
|
||||
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "14M"
|
||||
postJsonArrayTest "1000" "/perf_articles?columns=id,body" "15M"
|
||||
postJsonArrayTest "10000" "/perf_articles?columns=id,body" "15M"
|
||||
postJsonArrayTest "100000" "/perf_articles?columns=id,body" "24M"
|
||||
|
||||
trap - int term exit
|
||||
|
||||
@@ -20,8 +20,7 @@ spec =
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchHeaders = [ "Access-Control-Allow-Origin" <:> "http://example.com"
|
||||
, "Access-Control-Allow-Credentials" <:> "true"
|
||||
{ matchHeaders = [ "Access-Control-Allow-Origin" <:> "*"
|
||||
, "Access-Control-Allow-Methods" <:> "GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD"
|
||||
, "Access-Control-Allow-Headers" <:> "Authorization, Foo, Bar, Accept, Accept-Language, Content-Language"
|
||||
, "Access-Control-Max-Age" <:> "86400" ]
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
module Feature.LegacyGucsSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "remote procedure call with legacy gucs disabled" $ do
|
||||
it "custom header is set" $
|
||||
request methodPost "/rpc/get_guc_value" [("Custom-Header", "test")]
|
||||
[json| { "prefix": "request.headers", "name": "custom-header" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"test"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
it "standard header is set" $
|
||||
request methodPost "/rpc/get_guc_value" [("Origin", "http://example.com")]
|
||||
[json| { "prefix": "request.headers", "name": "origin" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"http://example.com"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
it "current role is available as GUC claim" $
|
||||
request methodPost "/rpc/get_guc_value" []
|
||||
[json| { "prefix": "request.jwt.claims", "name": "role" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"postgrest_test_anonymous"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
it "single cookie ends up as claims" $
|
||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
|
||||
[json| {"prefix": "request.cookies", "name":"acookie"} |]
|
||||
`shouldRespondWith`
|
||||
[json|"cookievalue"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "multiple cookies ends up as claims" $
|
||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
|
||||
[json| {"prefix": "request.cookies", "name":"secondcookie"} |]
|
||||
`shouldRespondWith`
|
||||
[json|"anothervalue"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
|
||||
it "gets the Authorization value" $
|
||||
request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
|
||||
[json| {"prefix": "request.headers", "name":"authorization"} |]
|
||||
`shouldRespondWith`
|
||||
[json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
@@ -35,6 +35,11 @@ spec actualPgVersion = describe "OpenAPI" $ do
|
||||
(acceptHdrs "application/openapi+json") ""
|
||||
`shouldRespondWith` 415
|
||||
|
||||
it "should respond to openapi request with unsupported media type with 415" $
|
||||
request methodGet "/"
|
||||
(acceptHdrs "text/csv") ""
|
||||
`shouldRespondWith` 415
|
||||
|
||||
it "includes postgrest.org current version api docs" $ do
|
||||
r <- simpleBody <$> get "/"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ module Feature.OpenApi.RootSpec where
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
@@ -12,7 +12,7 @@ import Protolude hiding (get)
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "root spec function" $ do
|
||||
it "accepts application/openapi+json" $
|
||||
it "accepts application/openapi+json" $ do
|
||||
request methodGet "/"
|
||||
[("Accept","application/openapi+json")] "" `shouldRespondWith`
|
||||
[json|{
|
||||
@@ -20,3 +20,12 @@ spec =
|
||||
"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" $ do
|
||||
request methodGet "/"
|
||||
[("Accept","application/json")] "" `shouldRespondWith`
|
||||
[json|{
|
||||
"swagger": "2.0",
|
||||
"info": {"title": "PostgREST API", "description": "This is a dynamic API generated by PostgREST"}
|
||||
}|]
|
||||
{ matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"] }
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
module Feature.Query.AggregateFunctionsSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
allowed :: SpecWith ((), Application)
|
||||
allowed =
|
||||
describe "aggregate functions" $ do
|
||||
context "performing a count without specifying a field" $ do
|
||||
it "returns the count of all rows when no other fields are selected" $
|
||||
get "/entities?select=count()" `shouldRespondWith`
|
||||
[json|[{ "count": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows you to specify an alias for the count" $
|
||||
get "/entities?select=cnt:count()" `shouldRespondWith`
|
||||
[json|[{ "cnt": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows you to cast the result of the count" $
|
||||
get "/entities?select=count()::text" `shouldRespondWith`
|
||||
[json|[{ "count": "4" }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "returns the count grouped by all provided fields when other fields are selected" $
|
||||
get "/projects?select=c:count(),client_id&order=client_id.desc" `shouldRespondWith`
|
||||
[json|[{ "c": 1, "client_id": null }, { "c": 2, "client_id": 2 }, { "c": 2, "client_id": 1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "performing a count by using it as a column (backwards compat)" $ do
|
||||
it "returns the count of all rows when no other fields are selected" $
|
||||
get "/entities?select=count" `shouldRespondWith`
|
||||
[json|[{ "count": 4 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "returns the embedded count of another resource" $
|
||||
get "/clients?select=name,projects(count)'" `shouldRespondWith`
|
||||
[json|[{"name":"Microsoft","projects":[{"count": 2}]}, {"name":"Apple","projects":[{"count": 2}]}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "performing an aggregation on one or more fields" $ do
|
||||
it "supports sum()" $
|
||||
get "/project_invoices?select=invoice_total.sum()" `shouldRespondWith`
|
||||
[json|[{"sum":8800}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "supports avg()" $
|
||||
get "/project_invoices?select=invoice_total.avg()" `shouldRespondWith`
|
||||
[json|[{"avg":1100.0000000000000000}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "supports min()" $
|
||||
get "/project_invoices?select=invoice_total.min()" `shouldRespondWith`
|
||||
[json|[{ "min": 100 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "supports max()" $
|
||||
get "/project_invoices?select=invoice_total.max()" `shouldRespondWith`
|
||||
[json|[{ "max": 4000 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "supports count()" $
|
||||
get "/project_invoices?select=invoice_total.count()" `shouldRespondWith`
|
||||
[json|[{ "count": 8 }]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "groups by any fields selected that do not have an aggregate applied" $
|
||||
get "/project_invoices?select=invoice_total.sum(),invoice_total.max(),invoice_total.min(),project_id&order=project_id.desc" `shouldRespondWith`
|
||||
[json|[
|
||||
{"sum":4100,"max":4000,"min":100,"project_id":4},
|
||||
{"sum":3200,"max":2000,"min":1200,"project_id":3},
|
||||
{"sum":1200,"max":700,"min":500,"project_id":2},
|
||||
{"sum":300,"max":200,"min":100,"project_id":1} ]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports the use of aliases on fields that will be used in the group by" $
|
||||
get "/project_invoices?select=invoice_total.sum(),invoice_total.max(),invoice_total.min(),pid:project_id&order=project_id.desc" `shouldRespondWith`
|
||||
[json|[
|
||||
{"sum":4100,"max":4000,"min":100,"pid":4},
|
||||
{"sum":3200,"max":2000,"min":1200,"pid":3},
|
||||
{"sum":1200,"max":700,"min":500,"pid":2},
|
||||
{"sum":300,"max":200,"min":100,"pid":1}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "allows you to specify an alias for the aggregate" $
|
||||
get "/project_invoices?select=total_charged:invoice_total.sum(),project_id&order=project_id.desc" `shouldRespondWith`
|
||||
[json|[
|
||||
{"total_charged":4100,"project_id":4},
|
||||
{"total_charged":3200,"project_id":3},
|
||||
{"total_charged":1200,"project_id":2},
|
||||
{"total_charged":300,"project_id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows you to cast the result of the aggregate" $
|
||||
get "/project_invoices?select=total_charged:invoice_total.sum()::text,project_id&order=project_id.desc" `shouldRespondWith`
|
||||
[json|[
|
||||
{"total_charged":"4100","project_id":4},
|
||||
{"total_charged":"3200","project_id":3},
|
||||
{"total_charged":"1200","project_id":2},
|
||||
{"total_charged":"300","project_id":1}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows you to cast the input argument of the aggregate" $
|
||||
get "/trash_details?select=jsonb_col->>key::integer.sum()" `shouldRespondWith`
|
||||
[json|[{"sum": 24}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows the combination of an alias, a before cast, and an after cast" $
|
||||
get "/trash_details?select=s:jsonb_col->>key::integer.sum()::text" `shouldRespondWith`
|
||||
[json|[{"s": "24"}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "supports use of aggregates on RPC functions that return table values" $
|
||||
get "/rpc/getallprojects?select=id.max()" `shouldRespondWith`
|
||||
[json|[{"max": 5}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
it "allows the use of an JSON-embedded relationship column as part of the group by" $
|
||||
get "/project_invoices?select=project_id,total:invoice_total.sum(),projects(name)&order=project_id" `shouldRespondWith`
|
||||
[json|[
|
||||
{"project_id": 1, "total": 300, "projects": {"name": "Windows 7"}},
|
||||
{"project_id": 2, "total": 1200, "projects": {"name": "Windows 10"}},
|
||||
{"project_id": 3, "total": 3200, "projects": {"name": "IOS"}},
|
||||
{"project_id": 4, "total": 4100, "projects": {"name": "OSX"}}]|] { matchHeaders = [matchContentTypeJson] }
|
||||
context "performing aggregations that involve JSON-embedded relationships" $ do
|
||||
it "supports sum()" $
|
||||
get "/projects?select=name,project_invoices(invoice_total.sum())" `shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Windows 7","project_invoices":[{"sum": 300}]},
|
||||
{"name":"Windows 10","project_invoices":[{"sum": 1200}]},
|
||||
{"name":"IOS","project_invoices":[{"sum": 3200}]},
|
||||
{"name":"OSX","project_invoices":[{"sum": 4100}]},
|
||||
{"name":"Orphan","project_invoices":[{"sum": null}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports max()" $
|
||||
get "/projects?select=name,project_invoices(invoice_total.max())" `shouldRespondWith`
|
||||
[json|[{"name":"Windows 7","project_invoices":[{"max": 200}]},
|
||||
{"name":"Windows 10","project_invoices":[{"max": 700}]},
|
||||
{"name":"IOS","project_invoices":[{"max": 2000}]},
|
||||
{"name":"OSX","project_invoices":[{"max": 4000}]},
|
||||
{"name":"Orphan","project_invoices":[{"max": null}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports avg()" $
|
||||
get "/projects?select=name,project_invoices(invoice_total.avg())" `shouldRespondWith`
|
||||
[json|[{"name":"Windows 7","project_invoices":[{"avg": 150.0000000000000000}]},
|
||||
{"name":"Windows 10","project_invoices":[{"avg": 600.0000000000000000}]},
|
||||
{"name":"IOS","project_invoices":[{"avg": 1600.0000000000000000}]},
|
||||
{"name":"OSX","project_invoices":[{"avg": 2050.0000000000000000}]},
|
||||
{"name":"Orphan","project_invoices":[{"avg": null}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports min()" $
|
||||
get "/projects?select=name,project_invoices(invoice_total.min())" `shouldRespondWith`
|
||||
[json|[{"name":"Windows 7","project_invoices":[{"min": 100}]},
|
||||
{"name":"Windows 10","project_invoices":[{"min": 500}]},
|
||||
{"name":"IOS","project_invoices":[{"min": 1200}]},
|
||||
{"name":"OSX","project_invoices":[{"min": 100}]},
|
||||
{"name":"Orphan","project_invoices":[{"min": null}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports all at once" $
|
||||
get "/projects?select=name,project_invoices(invoice_total.max(),invoice_total.min(),invoice_total.avg(),invoice_total.sum(),invoice_total.count())" `shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Windows 7","project_invoices":[{"avg": 150.0000000000000000, "max": 200, "min": 100, "sum": 300, "count": 2}]},
|
||||
{"name":"Windows 10","project_invoices":[{"avg": 600.0000000000000000, "max": 700, "min": 500, "sum": 1200, "count": 2}]},
|
||||
{"name":"IOS","project_invoices":[{"avg": 1600.0000000000000000, "max": 2000, "min": 1200, "sum": 3200, "count": 2}]},
|
||||
{"name":"OSX","project_invoices":[{"avg": 2050.0000000000000000, "max": 4000, "min": 100, "sum": 4100, "count": 2}]},
|
||||
{"name":"Orphan","project_invoices":[{"avg": null, "max": null, "min": null, "sum": null, "count": 0}]}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "performing aggregations on spreaded fields from an embedded resource" $ do
|
||||
it "supports the use of aggregates on spreaded fields" $ do
|
||||
get "/budget_expenses?select=total_expenses:expense_amount.sum(),...budget_categories(budget_owner,total_budget:budget_amount.sum())&order=budget_categories(budget_owner)" `shouldRespondWith`
|
||||
[json|[
|
||||
{"total_expenses": 600.52,"budget_owner": "Brian Smith", "total_budget": 2000.42},
|
||||
{"total_expenses": 100.22, "budget_owner": "Jane Clarkson","total_budget": 7000.41},
|
||||
{"total_expenses": 900.27, "budget_owner": "Sally Hughes", "total_budget": 500.23}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "supports the use of aggregates on spreaded fields when only aggregates are supplied" $ do
|
||||
get "/budget_expenses?select=...budget_categories(total_budget:budget_amount.sum())" `shouldRespondWith`
|
||||
[json|[{"total_budget": 9501.06}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
disallowed :: SpecWith ((), Application)
|
||||
disallowed =
|
||||
describe "attempting to use an aggregate when aggregate functions are disallowed" $ do
|
||||
it "prevents the use of aggregates" $
|
||||
get "/project_invoices?select=invoice_total.sum()" `shouldRespondWith`
|
||||
[json|{
|
||||
"hint":null,
|
||||
"details":null,
|
||||
"code":"PGRST123",
|
||||
"message":"Use of aggregate functions is not allowed"
|
||||
}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson] }
|
||||
@@ -192,3 +192,29 @@ spec = describe "computed relationships" $ do
|
||||
{"name":"Windows 10","computed_clients":{"name":"Microsoft"}}
|
||||
]}
|
||||
]|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
-- https://github.com/PostgREST/postgrest/issues/2963
|
||||
context "can be defined using overloaded functions" $ do
|
||||
it "tables" $ do
|
||||
get "/items?select=*,computed_rel_overload(*)&limit=1"
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"computed_rel_overload":[{"id":1}]}]
|
||||
|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/items2?select=*,computed_rel_overload(*)&limit=1"
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"computed_rel_overload":[{"id":1},{"id":2}]}]
|
||||
|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "rpc" $ do
|
||||
get "/rpc/search?id=1&select=*,computed_rel_overload(*)"
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"computed_rel_overload":[{"id":1}]}]
|
||||
|] { matchHeaders = [matchContentTypeJson] }
|
||||
get "/rpc/search2?id=1&select=*,computed_rel_overload(*)"
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"computed_rel_overload":[{"id":1},{"id":2}]}]
|
||||
|] { matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
module Feature.Query.CustomMediaSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
import Text.Heredoc (str)
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec = describe "custom media types" $ do
|
||||
context "for tables with aggregate" $ do
|
||||
it "can query if there's an aggregate defined for the table" $ do
|
||||
r <- request methodGet "/lines" (acceptHdrs "application/vnd.twkb") ""
|
||||
liftIO $ do
|
||||
simpleBody r `shouldBe` readFixtureFile "lines.twkb"
|
||||
simpleHeaders r `shouldContain` [("Content-Type", "application/vnd.twkb")]
|
||||
|
||||
it "can query by id if there's an aggregate defined for the table" $ do
|
||||
r <- request methodGet "/lines?id=eq.1" (acceptHdrs "application/vnd.twkb") ""
|
||||
liftIO $ do
|
||||
simpleBody r `shouldBe` readFixtureFile "1.twkb"
|
||||
simpleHeaders r `shouldContain` [("Content-Type", "application/vnd.twkb")]
|
||||
|
||||
it "will fail if there's no aggregate defined for the table" $ do
|
||||
request methodGet "/lines" (acceptHdrs "text/plain") ""
|
||||
`shouldRespondWith`
|
||||
[json| {"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: text/plain"} |]
|
||||
{ matchStatus = 415
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "can get raw xml output with Accept: text/xml if there's an aggregate defined" $ do
|
||||
request methodGet "/xmltest" (acceptHdrs "text/xml") ""
|
||||
`shouldRespondWith`
|
||||
"<myxml>foo</myxml>bar<foobar><baz/></foobar>"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
|
||||
}
|
||||
|
||||
-- TODO SOH (start of heading) is being added to results
|
||||
context "for tables with anyelement aggregate" $ do
|
||||
it "will use the application/vnd.geo2+json media type for any table" $
|
||||
request methodGet "/lines" (acceptHdrs "application/vnd.geo2+json") ""
|
||||
`shouldRespondWith`
|
||||
"\SOH{\"type\": \"FeatureCollection\", \"hello\": \"world\"}"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
|
||||
}
|
||||
|
||||
it "will use the more specific application/vnd.geo2 handler for this table" $ do
|
||||
request methodGet "/shop_bles" (acceptHdrs "application/vnd.geo2+json") ""
|
||||
`shouldRespondWith`
|
||||
"\SOH\"anyelement overridden\""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
|
||||
}
|
||||
|
||||
request methodGet "/rpc/get_shop_bles" (acceptHdrs "application/vnd.geo2+json") ""
|
||||
`shouldRespondWith`
|
||||
"\SOH\"anyelement overridden\""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
|
||||
}
|
||||
|
||||
context "Proc that returns scalar" $ do
|
||||
it "can get raw output with Accept: text/html" $ do
|
||||
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"]
|
||||
}
|
||||
|
||||
it "can get raw output with Accept: text/plain" $ do
|
||||
request methodGet "/rpc/welcome" (acceptHdrs "text/plain") ""
|
||||
`shouldRespondWith` "Welcome to PostgREST"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "can get raw xml output with Accept: text/xml" $ do
|
||||
request methodGet "/rpc/return_scalar_xml" (acceptHdrs "text/xml") ""
|
||||
`shouldRespondWith`
|
||||
"<my-xml-tag/>"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "can get raw xml output with Accept: text/xml" $ do
|
||||
request methodGet "/rpc/welcome.xml" (acceptHdrs "text/xml") ""
|
||||
`shouldRespondWith`
|
||||
"<html>\n <head>\n <title>PostgREST</title>\n </head>\n <body>\n <h1>Welcome to PostgREST</h1>\n </body>\n</html>"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "should fail with function returning text and Accept: text/xml" $ do
|
||||
request methodGet "/rpc/welcome" (acceptHdrs "text/xml") ""
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
{"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: text/xml"}
|
||||
|]
|
||||
{ matchStatus = 415
|
||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
|
||||
}
|
||||
|
||||
context "Proc that returns scalar based on a table" $ do
|
||||
it "can get an image with Accept: image/png" $ do
|
||||
r <- request methodGet "/rpc/ret_image" (acceptHdrs "image/png") ""
|
||||
liftIO $ do
|
||||
simpleBody r `shouldBe` readFixtureFile "A.png"
|
||||
simpleHeaders r `shouldContain` [("Content-Type", "image/png")]
|
||||
|
||||
context "Proc that returns set of scalars and Accept: text/plain" $
|
||||
it "will err because only scalars work with media type domains" $ do
|
||||
request methodGet "/rpc/welcome_twice"
|
||||
(acceptHdrs "text/plain")
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|{"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: text/plain"}|]
|
||||
{ matchStatus = 415
|
||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
|
||||
}
|
||||
|
||||
context "Proc that returns rows and accepts custom media type" $ do
|
||||
it "works if it has an aggregate defined" $ do
|
||||
r <- request methodGet "/rpc/get_lines" [("Accept", "application/vnd.twkb")] ""
|
||||
liftIO $ do
|
||||
simpleBody r `shouldBe` readFixtureFile "lines.twkb"
|
||||
simpleHeaders r `shouldContain` [("Content-Type", "application/vnd.twkb")]
|
||||
|
||||
it "fails if doesn't have an aggregate defined" $ do
|
||||
request methodGet "/rpc/get_lines"
|
||||
(acceptHdrs "application/octet-stream") ""
|
||||
`shouldRespondWith`
|
||||
[json| {"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: application/octet-stream"} |]
|
||||
{ matchStatus = 415 }
|
||||
|
||||
-- TODO SOH (start of heading) is being added to results
|
||||
it "works if there's an anyelement aggregate defined" $ do
|
||||
request methodGet "/rpc/get_lines" (acceptHdrs "application/vnd.geo2+json") ""
|
||||
`shouldRespondWith`
|
||||
"\SOH{\"type\": \"FeatureCollection\", \"hello\": \"world\"}"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.geo2+json"]
|
||||
}
|
||||
|
||||
context "overriding" $ do
|
||||
it "will override the application/json handler for a single table" $
|
||||
request methodGet "/ov_json" (acceptHdrs "application/json") ""
|
||||
`shouldRespondWith`
|
||||
[json| {"overridden": "true"} |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
|
||||
}
|
||||
|
||||
-- TODO SOH (start of heading) is being added to results
|
||||
it "will override the application/geo+json handler for a single table" $
|
||||
request methodGet "/lines?id=eq.1" (acceptHdrs "application/geo+json") ""
|
||||
`shouldRespondWith`
|
||||
"\SOH{\"crs\": {\"type\": \"name\", \"properties\": {\"name\": \"EPSG:4326\"}}, \"type\": \"FeatureCollection\", \"features\": [{\"type\": \"Feature\", \"geometry\": {\"type\": \"LineString\", \"coordinates\": [[1, 1], [5, 5]]}, \"properties\": {\"id\": 1, \"name\": \"line-1\"}}]}"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/geo+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "will not override vendored media types like application/vnd.pgrst.object" $
|
||||
request methodGet "/projects?id=eq.1" (acceptHdrs "application/vnd.pgrst.object") ""
|
||||
`shouldRespondWith`
|
||||
[json|{"id":1,"name":"Windows 7","client_id":1}|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/vnd.pgrst.object+json; charset=utf-8"]
|
||||
}
|
||||
|
||||
context "matches requested media type correctly" $ do
|
||||
-- https://github.com/PostgREST/postgrest/issues/1462
|
||||
it "will match image/png according to q values" $ do
|
||||
r1 <- request methodGet "/rpc/ret_image" (acceptHdrs "image/png, */*") ""
|
||||
liftIO $ do
|
||||
simpleBody r1 `shouldBe` readFixtureFile "A.png"
|
||||
simpleHeaders r1 `shouldContain` [("Content-Type", "image/png")]
|
||||
|
||||
r2 <- request methodGet "/rpc/ret_image" (acceptHdrs "text/html,application/xhtml+xml,application/xml;q=0.9,image/png,*/*;q=0.8") ""
|
||||
liftIO $ do
|
||||
simpleBody r2 `shouldBe` readFixtureFile "A.png"
|
||||
simpleHeaders r2 `shouldContain` [("Content-Type", "image/png")]
|
||||
|
||||
-- https://github.com/PostgREST/postgrest/issues/2170
|
||||
it "will match json in presence of text/plain" $ do
|
||||
r <- request methodGet "/projects?id=eq.1" (acceptHdrs "text/plain, application/json") ""
|
||||
liftIO $ do
|
||||
simpleStatus r `shouldBe` status200
|
||||
simpleHeaders r `shouldContain` [("Content-Type", "application/json; charset=utf-8")]
|
||||
|
||||
-- https://github.com/PostgREST/postgrest/issues/1102
|
||||
it "will match a custom text/tab-separated-values" $ do
|
||||
request methodGet "/projects?id=in.(1,2)" (acceptHdrs "text/tab-separated-values") ""
|
||||
`shouldRespondWith`
|
||||
"id\tname\tclient_id\n1\tWindows 7\t1\n2\tWindows 10\t1\n"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/tab-separated-values"]
|
||||
}
|
||||
|
||||
-- https://github.com/PostgREST/postgrest/issues/1371#issuecomment-519248984
|
||||
it "will match a custom text/csv with BOM" $ do
|
||||
r <- request methodGet "/lines" (acceptHdrs "text/csv") ""
|
||||
liftIO $ do
|
||||
simpleBody r `shouldBe` readFixtureFile "lines.csv"
|
||||
simpleHeaders r `shouldContain` [("Content-Type", "text/csv; charset=utf-8")]
|
||||
simpleHeaders r `shouldContain` [("Content-Disposition", "attachment; filename=\"lines.csv\"")]
|
||||
|
||||
context "any media type" $ do
|
||||
context "on functions" $ do
|
||||
it "returns application/json for */* if not explicitly set" $ do
|
||||
request methodGet "/rpc/ret_any_mt" (acceptHdrs "*/*") ""
|
||||
`shouldRespondWith` "any"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
|
||||
}
|
||||
|
||||
it "accepts any media type and sets the generic octet-stream as content type" $ do
|
||||
request methodGet "/rpc/ret_any_mt" (acceptHdrs "app/bingo") ""
|
||||
`shouldRespondWith` "any"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
|
||||
}
|
||||
|
||||
request methodGet "/rpc/ret_any_mt" (acceptHdrs "text/bango") ""
|
||||
`shouldRespondWith` "any"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
|
||||
}
|
||||
|
||||
request methodGet "/rpc/ret_any_mt" (acceptHdrs "image/boingo") ""
|
||||
`shouldRespondWith` "any"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
|
||||
}
|
||||
|
||||
it "returns custom media type for */* if explicitly set" $ do
|
||||
request methodGet "/rpc/ret_some_mt" (acceptHdrs "*/*") ""
|
||||
`shouldRespondWith` "groucho"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "app/groucho"]
|
||||
}
|
||||
|
||||
it "accepts some media types if there's conditional logic" $ do
|
||||
request methodGet "/rpc/ret_some_mt" (acceptHdrs "app/chico") ""
|
||||
`shouldRespondWith` "chico"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "app/chico"]
|
||||
}
|
||||
|
||||
request methodGet "/rpc/ret_some_mt" (acceptHdrs "app/harpo") ""
|
||||
`shouldRespondWith` "harpo"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "app/harpo"]
|
||||
}
|
||||
|
||||
request methodGet "/rpc/ret_some_mt" (acceptHdrs "text/csv") ""
|
||||
`shouldRespondWith` 415
|
||||
|
||||
context "on tables" $ do
|
||||
it "returns application/json for */* if not explicitly set" $ do
|
||||
request methodGet "/some_numbers?val=eq.1" (acceptHdrs "*/*") ""
|
||||
`shouldRespondWith` "anything\n1"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
|
||||
}
|
||||
|
||||
it "accepts any media type and sets it as a header" $ do
|
||||
request methodGet "/some_numbers?val=eq.2" (acceptHdrs "magic/number") ""
|
||||
`shouldRespondWith` "magic\n2"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "magic/number"]
|
||||
}
|
||||
request methodGet "/some_numbers?val=eq.3" (acceptHdrs "crazy/bingo") ""
|
||||
`shouldRespondWith` "crazy\n3"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "crazy/bingo"]
|
||||
}
|
||||
request methodGet "/some_numbers?val=eq.4" (acceptHdrs "unknown/unknown") ""
|
||||
`shouldRespondWith` "anything\n4"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
|
||||
}
|
||||
@@ -38,7 +38,7 @@ spec =
|
||||
`shouldRespondWith` [json|[{"id":2}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "*/1"
|
||||
, "Preference-Applied" <:> "return=representation"]
|
||||
, "Preference-Applied" <:> "return=representation, count=exact"]
|
||||
}
|
||||
|
||||
it "ignores ?select= when return not set or return=minimal" $ do
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
module Feature.Query.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"]
|
||||
}
|
||||
@@ -102,7 +102,7 @@ spec actualPgVersion = do
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, matchHeaderAbsent hLocation
|
||||
, "Content-Range" <:> "*/1"
|
||||
, "Preference-Applied" <:> "return=representation"]
|
||||
, "Preference-Applied" <:> "return=representation, count=exact"]
|
||||
}
|
||||
|
||||
it "can rename and cast the selected columns" $
|
||||
@@ -287,7 +287,7 @@ spec actualPgVersion = do
|
||||
it "fails with 400 and error" $
|
||||
post "/simple_pk" "}{ x = 2"
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Error in $: Failed reading: not a valid json value at '}{x=2'","code":"PGRST102","details":null,"hint":null}|]
|
||||
[json|{"message":"Empty or invalid json","code":"PGRST102","details":null,"hint":null}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
@@ -296,7 +296,7 @@ spec actualPgVersion = do
|
||||
it "fails with 400 and error" $
|
||||
post "/simple_pk" ""
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Error in $: not enough input","code":"PGRST102","details":null,"hint":null}|]
|
||||
[json|{"message":"Empty or invalid json","code":"PGRST102","details":null,"hint":null}|]
|
||||
{ matchStatus = 400
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
@@ -56,6 +56,21 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
|
||||
[json| [{"myInt":1}] |] -- the value in the db is an int, but here we expect a string for now
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "accepts non reserved special characters in the key's name" $
|
||||
get "/json_arr?id=eq.10&select=data->!@#$%^%26*_d->>!@#$%^%26*_e::integer" `shouldRespondWith`
|
||||
[json| [{"!@#$%^&*_e":3}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "fails when there is a reserved special character in the key's name" $
|
||||
get "/json_arr?id=eq.10&select=data->(!@#$%^%26*_d->>!@#$%^%26*_e::integer" `shouldRespondWith`
|
||||
[json| {
|
||||
"code":"PGRST100",
|
||||
"details":"unexpected \"(\" expecting \"-\", digit or any non reserved character different from: .,>()",
|
||||
"hint":null,
|
||||
"message":"\"failed to parse select parameter (data->(!@#$%^&*_d->>!@#$%^&*_e::integer)\" (line 1, column 7)"}
|
||||
|]
|
||||
{ matchStatus = 400 , matchHeaders = [] }
|
||||
|
||||
-- TODO the status code for the error is 404, this is because 42883 represents undefined function
|
||||
-- this works fine for /rpc/unexistent requests, but for this case a 500 seems more appropriate
|
||||
it "fails when a double arrow ->> is followed with a single arrow ->" $ do
|
||||
@@ -178,6 +193,11 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
|
||||
get "/grandchild_entities?or=(jsonb_col->a->>b.eq.foo, jsonb_col->>b.eq.bar)&select=id" `shouldRespondWith`
|
||||
[json|[{id: 4}, {id: 5}]|] { matchStatus = 200, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter when the key's name has non reserved special characters" $
|
||||
get "/json_arr?select=data->!@#$%^%26*_d&data->!@#$%^%26*_d->>!@#$%^%26*_e=eq.3" `shouldRespondWith`
|
||||
[json| [{"!@#$%^&*_d": {"!@#$%^&*_e": 3}}] |]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "can filter by array indexes" $ do
|
||||
get "/json_arr?select=data&data->>0=eq.1" `shouldRespondWith`
|
||||
[json| [{"data":[1, 2, 3]}] |]
|
||||
@@ -300,3 +320,11 @@ spec actualPgVersion = describe "json and jsonb operators" $ do
|
||||
"code": "PGRST100",
|
||||
"hint": null} |]
|
||||
{ matchStatus = 400, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "works when an RPC returns a dynamic TABLE with a composite type" $
|
||||
get "/rpc/returns_complex?select=val->r&val->i=gt.0.5&order=val->>i.desc" `shouldRespondWith`
|
||||
[json|[
|
||||
{"r":0.3},
|
||||
{"r":0.2}
|
||||
]|]
|
||||
{ matchStatus = 200, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
@@ -225,13 +225,11 @@ spec =
|
||||
|
||||
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 } ]|]
|
||||
[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"]
|
||||
}
|
||||
[json|[{"id": 111, "name": "child v2-111", "parent_id": null}]|]
|
||||
{ matchStatus = 201
|
||||
, 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module Feature.Query.NullsStrip where
|
||||
module Feature.Query.NullsStripSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
@@ -45,6 +45,16 @@ spec =
|
||||
, matchHeaders = [matchCTArrayStrip]
|
||||
}
|
||||
|
||||
it "strips nulls when Accept: application/vnd.pgrst.array;nulls=stripped" $
|
||||
request methodGet "/organizations?select=*"
|
||||
[("Accept","application/vnd.pgrst.array;nulls=stripped")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1,"name":"Referee Org","manager_id":1},{"id":2,"name":"Auditor Org","manager_id":2},{"id":3,"name":"Acme","referee":1,"auditor":2,"manager_id":3},{"id":4,"name":"Umbrella","referee":1,"auditor":2,"manager_id":4},{"id":5,"name":"Cyberdyne","referee":3,"auditor":4,"manager_id":5},{"id":6,"name":"Oscorp","referee":3,"auditor":4,"manager_id":6}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchCTArrayStrip]
|
||||
}
|
||||
|
||||
it "strips nulls when Accept: application/vnd.pgrst.object+json;nulls=stripped" $
|
||||
request methodGet "/organizations?limit=1"
|
||||
[singularStrip]
|
||||
@@ -192,7 +192,63 @@ spec actualPgVersion = do
|
||||
liftIO $ do
|
||||
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8")
|
||||
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
|
||||
totalCost `shouldBe` 1.29
|
||||
totalCost `shouldBe` 3.55
|
||||
|
||||
it "outputs the total cost for 2 upserts" $ do
|
||||
r <- request methodPost "/tiobe_pls"
|
||||
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
|
||||
[json| [ { "name": "Python", "rank": 19 }, { "name": "Go", "rank": 20} ]|]
|
||||
|
||||
let totalCost = planCost r
|
||||
resStatus = simpleStatus r
|
||||
resHeaders = simpleHeaders r
|
||||
|
||||
liftIO $ do
|
||||
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8")
|
||||
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
|
||||
totalCost `shouldBe` 5.53
|
||||
|
||||
it "outputs the total cost for an upsert with 10 rows" $ do
|
||||
r <- request methodPost "/tiobe_pls"
|
||||
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
|
||||
(getInsertDataForTiobePlsTable 10)
|
||||
|
||||
let totalCost = planCost r
|
||||
resStatus = simpleStatus r
|
||||
resHeaders = simpleHeaders r
|
||||
|
||||
liftIO $ do
|
||||
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8")
|
||||
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
|
||||
totalCost `shouldBe` 5.53
|
||||
|
||||
it "outputs the total cost for an upsert with 100 rows" $ do
|
||||
r <- request methodPost "/tiobe_pls"
|
||||
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
|
||||
(getInsertDataForTiobePlsTable 100)
|
||||
|
||||
let totalCost = planCost r
|
||||
resStatus = simpleStatus r
|
||||
resHeaders = simpleHeaders r
|
||||
|
||||
liftIO $ do
|
||||
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8")
|
||||
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
|
||||
totalCost `shouldBe` 5.53
|
||||
|
||||
it "outputs the total cost for an upsert with 1000 rows" $ do
|
||||
r <- request methodPost "/tiobe_pls"
|
||||
[("Prefer","resolution=merge-duplicates"), ("Accept","application/vnd.pgrst.plan+json")]
|
||||
(getInsertDataForTiobePlsTable 1000)
|
||||
|
||||
let totalCost = planCost r
|
||||
resStatus = simpleStatus r
|
||||
resHeaders = simpleHeaders r
|
||||
|
||||
liftIO $ do
|
||||
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/json\"; charset=utf-8")
|
||||
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
|
||||
totalCost `shouldBe` 5.53
|
||||
|
||||
it "outputs the plan for application/vnd.pgrst.object" $ do
|
||||
r <- request methodDelete "/projects?id=eq.6"
|
||||
@@ -219,17 +275,6 @@ spec actualPgVersion = do
|
||||
resStatus `shouldBe` Status { statusCode = 200, statusMessage="OK" }
|
||||
totalCost `shouldBe` 68.56
|
||||
|
||||
it "outputs the plan for text/xml" $ do
|
||||
r <- request methodGet "/rpc/return_scalar_xml"
|
||||
(acceptHdrs "application/vnd.pgrst.plan+json; for=\"text/xml\"; options=verbose") ""
|
||||
|
||||
let aggCol = simpleBody r ^? nth 0 . key "Plan" . key "Output" . nth 2
|
||||
resHeaders = simpleHeaders r
|
||||
|
||||
liftIO $ do
|
||||
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"text/xml\"; options=verbose; charset=utf-8")
|
||||
aggCol `shouldBe` Just [aesonQQ| "COALESCE(xmlagg(return_scalar_xml.pgrst_scalar), ''::xml)" |]
|
||||
|
||||
describe "text format" $ do
|
||||
it "outputs the total cost for a function call" $ do
|
||||
r <- request methodGet "/projects?id=in.(1,2,3)"
|
||||
@@ -303,12 +348,12 @@ spec actualPgVersion = do
|
||||
r1 <- request methodGet "/users?select=*,tasks!inner(*)&tasks.id=eq.1"
|
||||
[planHdr] ""
|
||||
|
||||
liftIO $ planCost r1 `shouldSatisfy` (< 20876.14)
|
||||
liftIO $ planCost r1 `shouldSatisfy` (< 20888.83)
|
||||
|
||||
r2 <- request methodGet "/users?select=*,tasks(*)&tasks.id=eq.1&tasks=not.is.null"
|
||||
[planHdr] ""
|
||||
|
||||
liftIO $ planCost r2 `shouldSatisfy` (< 20876.14)
|
||||
liftIO $ planCost r2 `shouldSatisfy` (< 20888.83)
|
||||
|
||||
describe "function call costs" $ do
|
||||
it "should not exceed cost when calling setof composite proc" $ do
|
||||
@@ -327,7 +372,7 @@ spec actualPgVersion = do
|
||||
r <- request methodGet "/rpc/add_them?a=3&b=4"
|
||||
[planHdr] ""
|
||||
|
||||
liftIO $ planCost r `shouldSatisfy` (< 1.18)
|
||||
liftIO $ planCost r `shouldSatisfy` (< 0.11)
|
||||
|
||||
context "function inlining" $ do
|
||||
it "should inline a zero argument function(the function won't appear in the plan tree)" $ do
|
||||
@@ -385,6 +430,34 @@ spec actualPgVersion = do
|
||||
liftIO $ do
|
||||
resBody `shouldSatisfy` (\t -> T.isInfixOf "Index" (decodeUtf8 $ LBS.toStrict t))
|
||||
|
||||
describe "custom media types" $ do
|
||||
it "outputs the plan for a scalar function text/xml" $ do
|
||||
r <- request methodGet "/rpc/return_scalar_xml"
|
||||
(acceptHdrs "application/vnd.pgrst.plan+json; for=\"text/xml\"; options=verbose") ""
|
||||
|
||||
let aggCol = simpleBody r ^? nth 0 . key "Plan" . key "Output" . nth 2
|
||||
resHeaders = simpleHeaders r
|
||||
|
||||
liftIO $ do
|
||||
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"text/xml\"; options=verbose; charset=utf-8")
|
||||
aggCol `shouldBe` Just [aesonQQ| "return_scalar_xml.pgrst_scalar" |]
|
||||
|
||||
it "outputs the plan for an aggregate application/vnd.twkb" $ do
|
||||
r <- request methodGet "/lines"
|
||||
(acceptHdrs "application/vnd.pgrst.plan+json; for=\"application/vnd.twkb\"; options=verbose") ""
|
||||
|
||||
let aggCol = simpleBody r ^? nth 0 . key "Plan" . key "Output" . nth 2
|
||||
resHeaders = simpleHeaders r
|
||||
|
||||
liftIO $ do
|
||||
resHeaders `shouldSatisfy` elem ("Content-Type", "application/vnd.pgrst.plan+json; for=\"application/vnd.twkb\"; options=verbose; charset=utf-8")
|
||||
aggCol `shouldBe`
|
||||
(
|
||||
if actualPgVersion >= pgVersion120
|
||||
then Just [aesonQQ| "twkb_agg(ROW(lines.id, lines.name, lines.geom)::lines)" |]
|
||||
else Just [aesonQQ| "twkb_agg(ROW(pgrst_source.id, pgrst_source.name, pgrst_source.geom)::lines)" |]
|
||||
)
|
||||
|
||||
disabledSpec :: SpecWith ((), Application)
|
||||
disabledSpec =
|
||||
it "doesn't work if db-plan-enabled=false(the default)" $ do
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
module Feature.Query.PreferencesSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "test prefer headers and preference-applied headers" $ do
|
||||
|
||||
context "check behaviour of Prefer: handling=strict" $ do
|
||||
it "throws error when handling=strict and invalid prefs are given" $
|
||||
request methodGet "/items" [("Prefer", "handling=strict, anything")] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Invalid preferences: anything","message":"Invalid preferences given with handling=strict","code":"PGRST122","hint":null}|]
|
||||
{ matchStatus = 400 }
|
||||
|
||||
it "throw error when handling=strict and invalid prefs are given with multiples in separate prefers" $
|
||||
request methodGet "/items" [("Prefer", "handling=strict"),("Prefer","something, else")] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Invalid preferences: something, else","message":"Invalid preferences given with handling=strict","code":"PGRST122","hint":null}|]
|
||||
{ matchStatus = 400 }
|
||||
|
||||
it "throws error with post request" $
|
||||
request methodPost "/organizations?select=*"
|
||||
[("Prefer","return=representation, handling=strict, anything")]
|
||||
[json|{"id":7,"name":"John","referee":null,"auditor":null,"manager_id":6}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Invalid preferences: anything","message":"Invalid preferences given with handling=strict","code":"PGRST122","hint":null}|]
|
||||
{ matchStatus = 400 }
|
||||
|
||||
it "throws error with rpc" $
|
||||
request methodPost "/rpc/overloaded_unnamed_param"
|
||||
[("Content-Type", "application/json"), ("Prefer", "handling=strict, anything")]
|
||||
[json|{}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"Invalid preferences: anything","message":"Invalid preferences given with handling=strict","code":"PGRST122","hint":null}|]
|
||||
{ matchStatus = 400 }
|
||||
|
||||
context "check behaviour of Prefer: handling=lenient" $ do
|
||||
it "does not throw error when handling=lenient and invalid prefs" $
|
||||
request methodGet "/items" [("Prefer", "handling=lenient, anything")] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "does not throw error when handling=lenient and invalid prefs in multiples prefers" $
|
||||
request methodGet "/items" [("Prefer", "handling=lenient"), ("Prefer", "anything")] ""
|
||||
`shouldRespondWith` 200
|
||||
|
||||
it "does not throw error with post request" $
|
||||
request methodPost "/organizations?select=*"
|
||||
[("Prefer","return=representation, handling=lenient, anything")]
|
||||
[json|{"id":7,"name":"John","referee":null,"auditor":null,"manager_id":6}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":7,"name":"John","referee":null,"auditor":null,"manager_id":6}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchContentTypeJson ]
|
||||
}
|
||||
|
||||
it "does not throw error with rpc" $
|
||||
request methodPost "/rpc/overloaded_unnamed_param"
|
||||
[("Content-Type", "application/json"), ("Prefer", "handling=lenient, anything")]
|
||||
[json|{}|]
|
||||
`shouldRespondWith`
|
||||
[json| 1 |]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "test Prefer: timezone=America/Los_Angeles" $ do
|
||||
it "should change timezone with handling=strict" $
|
||||
request methodGet "/timestamps"
|
||||
[("Prefer", "handling=strict, timezone=America/Los_Angeles")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"t":"2023-10-18T05:37:59.611-07:00"}, {"t":"2023-10-18T07:37:59.611-07:00"}, {"t":"2023-10-18T09:37:59.611-07:00"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson
|
||||
, "Preference-Applied" <:> "handling=strict, timezone=America/Los_Angeles"]}
|
||||
|
||||
it "should change timezone without handling=strict" $
|
||||
request methodGet "/timestamps"
|
||||
[("Prefer", "timezone=America/Los_Angeles")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"t":"2023-10-18T05:37:59.611-07:00"}, {"t":"2023-10-18T07:37:59.611-07:00"}, {"t":"2023-10-18T09:37:59.611-07:00"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson
|
||||
, "Preference-Applied" <:> "timezone=America/Los_Angeles"] }
|
||||
|
||||
context "test Prefer: timezone=Invalid/Timezone" $ do
|
||||
it "should throw error with handling=strict" $
|
||||
request methodGet "/timestamps"
|
||||
[("Prefer", "handling=strict, timezone=Invalid/Timezone")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|{"code":"PGRST122","details":"Invalid preferences: timezone=Invalid/Timezone","hint":null,"message":"Invalid preferences given with handling=strict"}|]
|
||||
{ matchStatus = 400 }
|
||||
|
||||
it "should return with default timezone without handling or with handling=lenient" $ do
|
||||
request methodGet "/timestamps"
|
||||
[("Prefer", "timezone=Invalid/Timezone")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"t":"2023-10-18T12:37:59.611+00:00"}, {"t":"2023-10-18T14:37:59.611+00:00"}, {"t":"2023-10-18T16:37:59.611+00:00"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson]}
|
||||
|
||||
request methodGet "/timestamps"
|
||||
[("Prefer", "handling=lenient, timezone=Invalid/Timezone")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"t":"2023-10-18T12:37:59.611+00:00"}, {"t":"2023-10-18T14:37:59.611+00:00"}, {"t":"2023-10-18T16:37:59.611+00:00"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson
|
||||
, "Preference-Applied" <:> "handling=lenient"]}
|
||||
@@ -1042,56 +1042,6 @@ spec actualPgVersion = do
|
||||
[json|[{"a$num$":100}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "binary output" $ do
|
||||
it "can query if a single column is selected" $
|
||||
request methodGet "/images_base64?select=img&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||
`shouldRespondWith` "iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "application/octet-stream"]
|
||||
}
|
||||
|
||||
it "can get raw output with Accept: text/plain" $
|
||||
request methodGet "/projects?select=name&id=eq.1" (acceptHdrs "text/plain") ""
|
||||
`shouldRespondWith` "Windows 7"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/plain; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "can get raw xml output with Accept: text/xml" $
|
||||
request methodGet "/xmltest?select=xml" (acceptHdrs "text/xml") ""
|
||||
`shouldRespondWith`
|
||||
"<myxml>foo</myxml>bar<foobar><baz/></foobar>"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "fails if a single column is not selected" $ do
|
||||
request methodGet "/images?select=img,name&name=eq.A.png" (acceptHdrs "application/octet-stream") ""
|
||||
`shouldRespondWith`
|
||||
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
|
||||
{ matchStatus = 406 }
|
||||
|
||||
request methodGet "/images?select=*&name=eq.A.png"
|
||||
(acceptHdrs "application/octet-stream")
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
|
||||
{ matchStatus = 406 }
|
||||
|
||||
request methodGet "/images?name=eq.A.png"
|
||||
(acceptHdrs "application/octet-stream")
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json| {"message":"application/octet-stream requested but more than one column was selected","code":"PGRST113","details":null,"hint":null} |]
|
||||
{ matchStatus = 406 }
|
||||
|
||||
it "concatenates results if more than one row is returned" $
|
||||
request methodGet "/images_base64?select=img&name=in.(A.png,B.png)" (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"]
|
||||
}
|
||||
|
||||
describe "values with quotes in IN and NOT IN" $ do
|
||||
it "succeeds when only quoted values are present" $ do
|
||||
get "/w_or_wo_comma_names?name=in.(\"Hebdon, John\")" `shouldRespondWith`
|
||||
@@ -1447,3 +1397,13 @@ spec actualPgVersion = do
|
||||
{ matchStatus = 404
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
context "searching for an empty string" $ do
|
||||
it "works with an empty eq filter" $
|
||||
get "/empty_string?string=eq.&select=id,string" `shouldRespondWith`
|
||||
[json|
|
||||
[{"id":1,"string":""}]
|
||||
|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
@@ -145,6 +145,14 @@ spec = do
|
||||
it "returns whole range with status 200" $
|
||||
get "/items" `shouldRespondWith` 200
|
||||
|
||||
context "count with an empty body" $ do
|
||||
it "returns empty body with Content-Range */0" $
|
||||
request methodGet "/items?id=eq.0"
|
||||
[("Prefer", "count=exact")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "*/0"] }
|
||||
|
||||
context "when I don't want the count" $ do
|
||||
it "returns range Content-Range with /*" $
|
||||
request methodGet "/menagerie"
|
||||
@@ -211,11 +219,24 @@ spec = do
|
||||
, "Content-Range" <:> "2-4/*" ]
|
||||
}
|
||||
|
||||
it "succeeds if offset equals 0 as a no-op" $
|
||||
get "/items?select=id&offset=0&order=id"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||
context "succeeds if offset equals 0 as a no-op" $ do
|
||||
it "no items" $ do
|
||||
get "/items?offset=0&id=eq.0"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "*/*"] }
|
||||
|
||||
request methodGet "/items?offset=0&id=eq.0"
|
||||
[("Prefer", "count=exact")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "*/0"] }
|
||||
|
||||
it "one or more items" $
|
||||
get "/items?select=id&offset=0&order=id"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":6},{"id":7},{"id":8},{"id":9},{"id":10},{"id":11},{"id":12},{"id":13},{"id":14},{"id":15}]|]
|
||||
{ matchHeaders = ["Content-Range" <:> "0-14/*"] }
|
||||
|
||||
it "succeeds if offset is negative as a no-op" $
|
||||
get "/items?select=id&offset=-4&order=id"
|
||||
|
||||
@@ -2,6 +2,7 @@ module Feature.Query.RelatedQueriesSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
@@ -256,3 +257,124 @@ spec = describe "related queries" $ do
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
-- "?table=not.is.null" does a "table IS DISTINCT FROM NULL" instead of a "table IS NOT NULL"
|
||||
-- https://github.com/PostgREST/postgrest/issues/2800#issuecomment-1720315818
|
||||
it "embeds verifying that the entire target table row is not null" $ do
|
||||
get "/table_b?select=name,table_a(name)&table_a=not.is.null" `shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Test 1","table_a":{"name":"Not null 1"}},
|
||||
{"name":"Test 2","table_a":{"name":null}}
|
||||
]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
get "/table_b?select=name,table_a()&table_a=is.null" `shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Test 3"}
|
||||
]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "works with count=exact" $ do
|
||||
request methodGet "/projects?select=name,clients(name)&clients=not.is.null"
|
||||
[("Prefer", "count=exact")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Windows 7", "clients":{"name":"Microsoft"}},
|
||||
{"name":"Windows 10", "clients":{"name":"Microsoft"}},
|
||||
{"name":"IOS", "clients":{"name":"Apple"}},
|
||||
{"name":"OSX", "clients":{"name":"Apple"}}
|
||||
]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-3/4" ]
|
||||
}
|
||||
request methodGet "/projects?select=name,clients()&clients=is.null"
|
||||
[("Prefer", "count=exact")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[{"name":"Orphan"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-0/1" ]
|
||||
}
|
||||
request methodGet "/client?select=*,clientinfo(),contact()&clientinfo.other=ilike.*main*&contact.name=ilike.*tabby*&or=(clientinfo.not.is.null,contact.not.is.null)"
|
||||
[("Prefer", "count=exact")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"name":"Walmart"},
|
||||
{"id":2,"name":"Target"}
|
||||
]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/2" ]
|
||||
}
|
||||
|
||||
it "works with count=planned" $ do
|
||||
request methodGet "/projects?select=name,clients(name)&clients=not.is.null"
|
||||
[("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Windows 7", "clients":{"name":"Microsoft"}},
|
||||
{"name":"Windows 10", "clients":{"name":"Microsoft"}},
|
||||
{"name":"IOS", "clients":{"name":"Apple"}},
|
||||
{"name":"OSX", "clients":{"name":"Apple"}}
|
||||
]|]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-3/1200" ]
|
||||
}
|
||||
request methodGet "/projects?select=name,clients()&clients=is.null"
|
||||
[("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[{"name":"Orphan"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-0/1" ]
|
||||
}
|
||||
request methodGet "/client?select=*,clientinfo(),contact()&clientinfo.other=ilike.*main*&contact.name=ilike.*tabby*&or=(clientinfo.not.is.null,contact.not.is.null)"
|
||||
[("Prefer", "count=planned")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"name":"Walmart"},
|
||||
{"id":2,"name":"Target"}
|
||||
]|]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/952" ]
|
||||
}
|
||||
|
||||
it "works with count=estimated" $ do
|
||||
request methodGet "/projects?select=name,clients(name)&clients=not.is.null"
|
||||
[("Prefer", "count=estimated")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{"name":"Windows 7", "clients":{"name":"Microsoft"}},
|
||||
{"name":"Windows 10", "clients":{"name":"Microsoft"}},
|
||||
{"name":"IOS", "clients":{"name":"Apple"}},
|
||||
{"name":"OSX", "clients":{"name":"Apple"}}
|
||||
]|]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-3/1200" ]
|
||||
}
|
||||
request methodGet "/projects?select=name,clients()&clients=is.null"
|
||||
[("Prefer", "count=estimated")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[{"name":"Orphan"}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-0/1" ]
|
||||
}
|
||||
request methodGet "/client?select=*,clientinfo(),contact()&clientinfo.other=ilike.*main*&contact.name=ilike.*tabby*&or=(clientinfo.not.is.null,contact.not.is.null)"
|
||||
[("Prefer", "count=estimated")] ""
|
||||
`shouldRespondWith`
|
||||
[json|[
|
||||
{"id":1,"name":"Walmart"},
|
||||
{"id":2,"name":"Target"}
|
||||
]|]
|
||||
{ matchStatus = 206
|
||||
, matchHeaders = [ matchContentTypeJson
|
||||
, "Content-Range" <:> "0-1/952" ]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
module Feature.Query.RpcSpec where
|
||||
|
||||
import qualified Data.ByteString.Lazy as BL (empty, readFile)
|
||||
import qualified Data.ByteString.Lazy as BL (empty)
|
||||
|
||||
import Network.Wai (Application)
|
||||
import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus))
|
||||
|
||||
import Network.HTTP.Types
|
||||
import System.IO.Unsafe (unsafePerformIO)
|
||||
import Test.Hspec hiding (pendingWith)
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
@@ -14,8 +13,7 @@ import Text.Heredoc
|
||||
|
||||
import PostgREST.Config.PgVersion (PgVersion, pgVersion100,
|
||||
pgVersion109, pgVersion110,
|
||||
pgVersion112, pgVersion114,
|
||||
pgVersion140)
|
||||
pgVersion112, pgVersion114)
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
@@ -98,6 +96,27 @@ spec actualPgVersion =
|
||||
, matchHeaders = ["Content-Range" <:> "0-1/2"]
|
||||
}
|
||||
|
||||
it "includes exact count of 1 for functions that return a single scalar, domain or composite" $ do
|
||||
request methodGet "/rpc/add_them?a=3&b=4"
|
||||
[("Prefer", "count=exact")] ""
|
||||
`shouldRespondWith` "7"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/1"]
|
||||
}
|
||||
request methodGet "/rpc/ret_domain?val=8"
|
||||
[("Prefer", "count=exact")] ""
|
||||
`shouldRespondWith` "8"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/1"]
|
||||
}
|
||||
request methodGet "/rpc/ret_point_2d"
|
||||
[("Prefer", "count=exact")] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"x": 10, "y": 5}|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Range" <:> "0-0/1"]
|
||||
}
|
||||
|
||||
it "returns proper json" $ do
|
||||
post "/rpc/getitemrange" [json| { "min": 2, "max": 4 } |] `shouldRespondWith`
|
||||
[json| [ {"id": 3}, {"id":4} ] |]
|
||||
@@ -954,12 +973,7 @@ spec actualPgVersion =
|
||||
it "custom header is set" $
|
||||
request methodPost "/rpc/get_guc_value"
|
||||
[("Custom-Header", "test")]
|
||||
(
|
||||
if actualPgVersion >= pgVersion140 then
|
||||
[json| { "prefix": "request.headers", "name": "custom-header" } |]
|
||||
else
|
||||
[json| { "name": "request.header.custom-header" } |]
|
||||
)
|
||||
[json| { "prefix": "request.headers", "name": "custom-header" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"test"|]
|
||||
{ matchStatus = 200
|
||||
@@ -968,12 +982,7 @@ spec actualPgVersion =
|
||||
it "standard header is set" $
|
||||
request methodPost "/rpc/get_guc_value"
|
||||
[("Origin", "http://example.com")]
|
||||
(
|
||||
if actualPgVersion >= pgVersion140 then
|
||||
[json| { "prefix": "request.headers", "name": "origin" } |]
|
||||
else
|
||||
[json| { "name": "request.header.origin" } |]
|
||||
)
|
||||
[json| { "prefix": "request.headers", "name": "origin" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"http://example.com"|]
|
||||
{ matchStatus = 200
|
||||
@@ -981,12 +990,7 @@ spec actualPgVersion =
|
||||
}
|
||||
it "current role is available as GUC claim" $
|
||||
request methodPost "/rpc/get_guc_value" []
|
||||
(
|
||||
if actualPgVersion >= pgVersion140 then
|
||||
[json| { "prefix": "request.jwt.claims", "name": "role" } |]
|
||||
else
|
||||
[json| { "name": "request.jwt.claim.role" } |]
|
||||
)
|
||||
[json| { "prefix": "request.jwt.claims", "name": "role" } |]
|
||||
`shouldRespondWith`
|
||||
[json|"postgrest_test_anonymous"|]
|
||||
{ matchStatus = 200
|
||||
@@ -994,25 +998,15 @@ spec actualPgVersion =
|
||||
}
|
||||
it "single cookie ends up as claims" $
|
||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue")]
|
||||
(
|
||||
if actualPgVersion >= pgVersion140 then
|
||||
[json| {"prefix": "request.cookies", "name":"acookie"} |]
|
||||
else
|
||||
[json| {"name":"request.cookie.acookie"} |]
|
||||
)
|
||||
`shouldRespondWith`
|
||||
[json|"cookievalue"|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = []
|
||||
}
|
||||
it "multiple cookies ends up as claims" $
|
||||
it "multiple cookies end up as claims" $
|
||||
request methodPost "/rpc/get_guc_value" [("Cookie","acookie=cookievalue;secondcookie=anothervalue")]
|
||||
(
|
||||
if actualPgVersion >= pgVersion140 then
|
||||
[json| {"prefix": "request.cookies", "name":"secondcookie"} |]
|
||||
else
|
||||
[json| {"name":"request.cookie.secondcookie"} |]
|
||||
)
|
||||
`shouldRespondWith`
|
||||
[json|"anothervalue"|]
|
||||
{ matchStatus = 200
|
||||
@@ -1028,12 +1022,7 @@ spec actualPgVersion =
|
||||
}
|
||||
it "gets the Authorization value" $
|
||||
request methodPost "/rpc/get_guc_value" [authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"]
|
||||
(
|
||||
if actualPgVersion >= pgVersion140 then
|
||||
[json| {"prefix": "request.headers", "name":"authorization"} |]
|
||||
else
|
||||
[json| {"name":"request.header.authorization"} |]
|
||||
)
|
||||
`shouldRespondWith`
|
||||
[json|"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicG9zdGdyZXN0X3Rlc3RfYXV0aG9yIn0.Xod-F15qsGL0WhdOCr2j3DdKuTw9QJERVgoFD3vGaWA"|]
|
||||
{ matchStatus = 200
|
||||
@@ -1056,79 +1045,6 @@ spec actualPgVersion =
|
||||
, 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"]
|
||||
}
|
||||
|
||||
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"]
|
||||
}
|
||||
|
||||
it "can get raw xml output with Accept: text/xml" $
|
||||
request methodGet "/rpc/return_scalar_xml" (acceptHdrs "text/xml") ""
|
||||
`shouldRespondWith`
|
||||
"<my-xml-tag/>"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "can get raw xml output with Accept: text/xml" $
|
||||
request methodGet "/rpc/welcome.xml" (acceptHdrs "text/xml") ""
|
||||
`shouldRespondWith`
|
||||
"<html>\n <head>\n <title>PostgREST</title>\n </head>\n <body>\n <h1>Welcome to PostgREST</h1>\n </body>\n</html>"
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Content-Type" <:> "text/xml; charset=utf-8"]
|
||||
}
|
||||
|
||||
it "should fail with function returning text and Accept: text/xml" $
|
||||
request methodGet "/rpc/welcome" (acceptHdrs "text/xml") ""
|
||||
`shouldRespondWith`
|
||||
[json|
|
||||
{
|
||||
"hint":"No function matches the given name and argument types. You might need to add explicit type casts.",
|
||||
"details":null,
|
||||
"code":"42883",
|
||||
"message":"function xmlagg(text) does not exist"
|
||||
}
|
||||
|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = ["Content-Type" <:> "application/json; charset=utf-8"]
|
||||
}
|
||||
|
||||
context "Proc that returns set of scalars" $
|
||||
it "can query without selecting column" $
|
||||
request methodGet "/rpc/welcome_twice"
|
||||
(acceptHdrs "text/plain")
|
||||
""
|
||||
`shouldRespondWith`
|
||||
"Welcome to PostgRESTWelcome 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"]
|
||||
}
|
||||
|
||||
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","code":"PGRST113","details":null,"hint":null} |]
|
||||
{ matchStatus = 406 }
|
||||
|
||||
context "only for GET rpc" $ do
|
||||
it "should fail on mutating procs" $ do
|
||||
get "/rpc/callcounter" `shouldRespondWith` 405
|
||||
@@ -1297,14 +1213,14 @@ spec actualPgVersion =
|
||||
`shouldRespondWith`
|
||||
[json|{"A": 1, "B": 2, "C": 3}|]
|
||||
|
||||
it "can insert text directly" $
|
||||
it "can insert text directly" $ do
|
||||
request methodPost "/rpc/unnamed_text_param"
|
||||
[("Content-Type", "text/plain"), ("Accept", "text/plain")]
|
||||
[str|unnamed text arg|]
|
||||
`shouldRespondWith`
|
||||
[str|unnamed text arg|]
|
||||
|
||||
it "can insert xml directly" $
|
||||
it "can insert xml directly" $ do
|
||||
request methodPost "/rpc/unnamed_xml_param"
|
||||
[("Content-Type", "text/xml"), ("Accept", "text/xml")]
|
||||
[str|<note><from>John</from><to>Jane</to><message>Remember me</message></note>|]
|
||||
@@ -1312,7 +1228,7 @@ spec actualPgVersion =
|
||||
[str|<note><from>John</from><to>Jane</to><message>Remember me</message></note>|]
|
||||
|
||||
it "can insert bytea directly" $ do
|
||||
let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png"
|
||||
let file = readFixtureFile "image.png"
|
||||
r <- request methodPost "/rpc/unnamed_bytea_param"
|
||||
[("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]
|
||||
file
|
||||
@@ -1365,10 +1281,9 @@ spec actualPgVersion =
|
||||
}
|
||||
|
||||
it "will err when no function with single unnamed bytea parameter exists and application/octet-stream is specified" $
|
||||
let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png" in
|
||||
request methodPost "/rpc/unnamed_int_param"
|
||||
[("Content-Type", "application/octet-stream")]
|
||||
file
|
||||
(readFixtureFile "image.png")
|
||||
`shouldRespondWith`
|
||||
[json|{
|
||||
"hint": null,
|
||||
@@ -1407,7 +1322,7 @@ spec actualPgVersion =
|
||||
[str|unnamed text arg|]
|
||||
`shouldRespondWith`
|
||||
[str|unnamed text arg|]
|
||||
let file = unsafePerformIO $ BL.readFile "test/spec/fixtures/image.png"
|
||||
let file = readFixtureFile "image.png"
|
||||
r <- request methodPost "/rpc/overloaded_unnamed_param"
|
||||
[("Content-Type", "application/octet-stream"), ("Accept", "application/octet-stream")]
|
||||
file
|
||||
@@ -1490,3 +1405,64 @@ spec actualPgVersion =
|
||||
`shouldRespondWith`
|
||||
[json| {"code":"22026","details":null,"hint":null,"message":"bit string length 6 does not match type bit(5)"} |]
|
||||
{ matchStatus = 400 }
|
||||
|
||||
context "get message and details from raise sqlstate" $ do
|
||||
it "gets message and details from raise sqlstate PGRST" $ do
|
||||
r <- request methodGet "/rpc/raise_sqlstate_test1"
|
||||
[] ""
|
||||
|
||||
let resStatus = simpleStatus r
|
||||
resHeaders = simpleHeaders r
|
||||
resBody = simpleBody r
|
||||
|
||||
liftIO $ do
|
||||
resStatus `shouldBe` Status { statusCode = 332, statusMessage = "My Custom Status" }
|
||||
resHeaders `shouldSatisfy` elem ("X-Header", "str")
|
||||
resBody `shouldBe` [json|{"code":"123","message":"ABC","details":"DEF","hint":"XYZ"}|]
|
||||
|
||||
get "/rpc/raise_sqlstate_test2" `shouldRespondWith`
|
||||
[json|{"code":"123","message":"ABC","details":null,"hint":null}|]
|
||||
{ matchStatus = 332
|
||||
, matchHeaders = ["X-Header" <:> "str"] }
|
||||
|
||||
it "get message and details from PGRST raise and checks standard status message" $ do
|
||||
r <- request methodGet "/rpc/raise_sqlstate_test3"
|
||||
[] ""
|
||||
|
||||
let resStatus = simpleStatus r
|
||||
resHeaders = simpleHeaders r
|
||||
resBody = simpleBody r
|
||||
|
||||
liftIO $ do
|
||||
resStatus `shouldBe` Status { statusCode = 404, statusMessage = "Not Found" }
|
||||
resHeaders `shouldSatisfy` elem ("X-Header", "str")
|
||||
resBody `shouldBe` [json|{"code":"123","message":"ABC","details":null,"hint":null}|]
|
||||
|
||||
|
||||
it "get message and details from PGRST raise and checks custom status message" $ do
|
||||
r <- request methodGet "/rpc/raise_sqlstate_test4"
|
||||
[] ""
|
||||
|
||||
let resStatus = simpleStatus r
|
||||
resHeaders = simpleHeaders r
|
||||
resBody = simpleBody r
|
||||
|
||||
liftIO $ do
|
||||
resStatus `shouldBe` Status { statusCode = 404, statusMessage = "My Not Found" }
|
||||
resHeaders `shouldSatisfy` elem ("X-Header", "str")
|
||||
resBody `shouldBe` [json|{"code":"123","message":"ABC","details":null,"hint":null}|]
|
||||
|
||||
it "returns error for invalid JSON in RAISE Message field" $
|
||||
get "/rpc/raise_sqlstate_invalid_json_message" `shouldRespondWith`
|
||||
[json|{"code":"PGRST121","message":"The message and detail field of RAISE 'PGRST' error expects JSON","details":null,"hint":null}|]
|
||||
{ matchStatus = 500 }
|
||||
|
||||
it "returns error for invalid JSON in RAISE Details field" $
|
||||
get "/rpc/raise_sqlstate_invalid_json_details" `shouldRespondWith`
|
||||
[json|{"code":"PGRST121","message":"The message and detail field of RAISE 'PGRST' error expects JSON","details":null,"hint":null}|]
|
||||
{ matchStatus = 500 }
|
||||
|
||||
it "returns error for missing Details field in RAISE" $
|
||||
get "/rpc/raise_sqlstate_missing_details" `shouldRespondWith`
|
||||
[json|{"code":"PGRST121","message":"The message and detail field of RAISE 'PGRST' error expects JSON","details":null,"hint":null}|]
|
||||
{ matchStatus = 500 }
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
module Feature.Query.ServerTimingSpec where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec
|
||||
import Test.Hspec.Wai
|
||||
import Test.Hspec.Wai.JSON
|
||||
|
||||
import Protolude hiding (get)
|
||||
import SpecHelper
|
||||
|
||||
spec :: SpecWith ((), Application)
|
||||
spec =
|
||||
describe "Show Duration on Server-Timing header" $ do
|
||||
|
||||
context "responds with Server-Timing header" $ do
|
||||
it "works with get request" $ do
|
||||
request methodGet "/organizations?id=eq.6"
|
||||
[]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":6,"name":"Oscorp","referee":3,"auditor":4,"manager_id":6}]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = matchContentTypeJson : map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
|
||||
}
|
||||
|
||||
it "works with post request" $
|
||||
request methodPost "/organizations?select=*"
|
||||
[("Prefer","return=representation")]
|
||||
[json|{"id":7,"name":"John","referee":null,"auditor":null,"manager_id":6}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":7,"name":"John","referee":null,"auditor":null,"manager_id":6}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = matchContentTypeJson : map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
|
||||
}
|
||||
|
||||
it "works with patch request" $
|
||||
request methodPatch "/no_pk?b=eq.0" mempty
|
||||
[json| { b: "1" } |]
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = matchHeaderAbsent hContentType : map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
|
||||
}
|
||||
|
||||
it "works with put request" $
|
||||
request methodPut "/tiobe_pls?name=eq.Python"
|
||||
[("Prefer", "return=representation")]
|
||||
[json| [ { "name": "Python", "rank": 19 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json| [ { "name": "Python", "rank": 19 } ]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
|
||||
}
|
||||
|
||||
it "works with delete request" $
|
||||
request methodDelete "/items?id=eq.1"
|
||||
[]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 204
|
||||
, matchHeaders = matchHeaderAbsent hContentType : map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
|
||||
}
|
||||
|
||||
it "works with rpc call" $
|
||||
request methodPost "/rpc/ret_point_overloaded"
|
||||
[]
|
||||
[json|{"x": 1, "y": 2}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"x": 1, "y": 2}|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
|
||||
}
|
||||
|
||||
it "works with root spec" $
|
||||
request methodHead "/"
|
||||
[]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "plan", "transaction", "response"]
|
||||
}
|
||||
|
||||
it "works with OPTIONS method" $ do
|
||||
request methodOptions "/organizations"
|
||||
[]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "response"]
|
||||
}
|
||||
request methodOptions "/rpc/getallprojects"
|
||||
[]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "response"]
|
||||
}
|
||||
request methodOptions "/"
|
||||
[]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = map matchServerTimingHasTiming ["jwt", "parse", "response"]
|
||||
}
|
||||
@@ -72,8 +72,7 @@ spec =
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"The result contains 4 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
, matchHeaders = [ matchContentTypeSingular ]
|
||||
}
|
||||
|
||||
-- the rows should not be updated, either
|
||||
@@ -88,8 +87,7 @@ spec =
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"The result contains 4 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
, matchHeaders = [ matchContentTypeSingular ]
|
||||
}
|
||||
|
||||
-- the rows should not be updated, either
|
||||
@@ -145,8 +143,7 @@ spec =
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
, matchHeaders = [ matchContentTypeSingular ]
|
||||
}
|
||||
|
||||
-- the rows should not exist, either
|
||||
@@ -161,8 +158,7 @@ spec =
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
, matchHeaders = [ matchContentTypeSingular ]
|
||||
}
|
||||
|
||||
-- the rows should not exist, either
|
||||
@@ -177,8 +173,7 @@ spec =
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
, matchHeaders = [ matchContentTypeSingular ]
|
||||
}
|
||||
|
||||
-- the rows should not exist, either
|
||||
@@ -226,8 +221,7 @@ spec =
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"The result contains 5 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
, matchHeaders = [ matchContentTypeSingular ]
|
||||
}
|
||||
|
||||
-- the rows should still exist
|
||||
@@ -244,8 +238,7 @@ spec =
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"The result contains 5 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
, matchHeaders = [ matchContentTypeSingular ]
|
||||
}
|
||||
|
||||
-- the rows should still exist
|
||||
@@ -318,8 +311,7 @@ spec =
|
||||
`shouldRespondWith`
|
||||
[json|{"details":"The result contains 2 rows","message":"JSON object requested, multiple (or no) rows returned","code":"PGRST116","hint":null}|]
|
||||
{ matchStatus = 406
|
||||
, matchHeaders = [ matchContentTypeSingular
|
||||
, "Preference-Applied" <:> "tx=commit" ]
|
||||
, matchHeaders = [ matchContentTypeSingular]
|
||||
}
|
||||
|
||||
-- should rollback function
|
||||
|
||||
@@ -44,7 +44,7 @@ spec actualPgVersion = do
|
||||
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'","code":"PGRST102","details":null,"hint":null}|]
|
||||
[json|{"message":"Empty or invalid json","code":"PGRST102","details":null,"hint":null}|]
|
||||
{ matchStatus = 400,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
@@ -53,7 +53,7 @@ spec actualPgVersion = do
|
||||
it "fails with 400 and error" $
|
||||
request methodPatch "/items" [] ""
|
||||
`shouldRespondWith`
|
||||
[json|{"message":"Error in $: not enough input","code":"PGRST102","details":null,"hint":null}|]
|
||||
[json|{"message":"Empty or invalid json","code":"PGRST102","details":null,"hint":null}|]
|
||||
{ matchStatus = 400,
|
||||
matchHeaders = [matchContentTypeJson]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,21 @@ spec actualPgVersion =
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates, return=representation", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "UPDATEs rows on pk conflict" $
|
||||
request methodPost "/tiobe_pls" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json| [
|
||||
{ "name": "Python", "rank": 6 },
|
||||
{ "name": "Java", "rank": 2 },
|
||||
{ "name": "C", "rank": 1 }
|
||||
]|] `shouldRespondWith` [json| [
|
||||
{ "name": "Python", "rank": 6 },
|
||||
{ "name": "Java", "rank": 2 },
|
||||
{ "name": "C", "rank": 1 }
|
||||
]|]
|
||||
{ matchStatus = 200
|
||||
, matchHeaders = ["Preference-Applied" <:> "resolution=merge-duplicates, return=representation", matchContentTypeJson]
|
||||
}
|
||||
|
||||
it "INSERTs and UPDATEs row on composite pk conflict" $
|
||||
request methodPost "/employees" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json| [
|
||||
@@ -62,7 +77,7 @@ spec actualPgVersion =
|
||||
it "succeeds when the payload has no elements" $
|
||||
request methodPost "/articles" [("Prefer", "return=representation"), ("Prefer", "resolution=merge-duplicates")]
|
||||
[json|[]|] `shouldRespondWith`
|
||||
[json|[]|] { matchStatus = 201
|
||||
[json|[]|] { matchStatus = 200 -- nothing was inserted, so it should be 200
|
||||
, matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
it "INSERTs and UPDATEs rows on single unique key conflict" $
|
||||
@@ -282,6 +297,7 @@ spec actualPgVersion =
|
||||
[json| [ { "name": "Go", "rank": 19 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json| [ { "name": "Go", "rank": 19 } ]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
it "succeeds on table with composite pk" $ do
|
||||
-- assert that the next request will indeed be an insert
|
||||
@@ -294,6 +310,7 @@ spec actualPgVersion =
|
||||
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "48000", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
||||
`shouldRespondWith`
|
||||
[json| [ { "first_name": "Susan", "last_name": "Heidt", "salary": "$48,000.00", "company": "GEX", "occupation": "Railroad engineer" } ]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
when (actualPgVersion >= pgVersion110) $
|
||||
it "succeeds on a partitioned table with composite pk" $ do
|
||||
@@ -307,6 +324,7 @@ spec actualPgVersion =
|
||||
[json| [ { "name": "Supra", "year": 2021 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json| [ { "name": "Supra", "year": 2021, "car_brand_name": null } ]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
it "succeeds if the table has only PK cols and no other cols" $ do
|
||||
-- assert that the next request will indeed be an insert
|
||||
@@ -319,6 +337,7 @@ spec actualPgVersion =
|
||||
[json|[ { "id": 10 } ]|]
|
||||
`shouldRespondWith`
|
||||
[json|[ { "id": 10 } ]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
context "Updating row" $ do
|
||||
it "succeeds on table with single pk col" $ do
|
||||
@@ -401,7 +420,11 @@ spec actualPgVersion =
|
||||
request methodPut "/tiobe_pls?name=eq.Ruby"
|
||||
[("Prefer", "return=representation"), ("Accept", "application/vnd.pgrst.object+json")]
|
||||
[json| [ { "name": "Ruby", "rank": 11 } ]|]
|
||||
`shouldRespondWith` [json|{ "name": "Ruby", "rank": 11 }|] { matchHeaders = [matchContentTypeSingular] }
|
||||
`shouldRespondWith`
|
||||
[json|{ "name": "Ruby", "rank": 11 }|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchContentTypeSingular] }
|
||||
|
||||
|
||||
context "with a camel case pk column" $ do
|
||||
it "works with POST and merge-duplicates" $ do
|
||||
|
||||
@@ -37,8 +37,8 @@ preferCommit = [("Prefer", "return=representation"), ("Prefer", "tx=commit")]
|
||||
preferRollback = [("Prefer", "return=representation"), ("Prefer", "tx=rollback")]
|
||||
|
||||
withoutPreferenceApplied = []
|
||||
withPreferenceCommitApplied = [ "Preference-Applied" <:> "tx=commit" ]
|
||||
withPreferenceRollbackApplied = [ "Preference-Applied" <:> "tx=rollback" ]
|
||||
withPreferenceCommitApplied = [ matchHeaderValuePresent "Preference-Applied" "tx=commit" ]
|
||||
withPreferenceRollbackApplied = [ matchHeaderValuePresent "Preference-Applied" "tx=rollback" ]
|
||||
|
||||
shouldRespondToReads reqHeaders respHeaders = do
|
||||
it "responds to GET" $ do
|
||||
@@ -118,11 +118,12 @@ shouldPersistMutations reqHeaders respHeaders = do
|
||||
|
||||
it "does persist put" $ do
|
||||
request methodPut "/items?id=eq.0"
|
||||
reqHeaders
|
||||
[json|{"id":0}|]
|
||||
reqHeaders
|
||||
[json|{"id":0}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
[json|[{"id":0}]|]
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = respHeaders }
|
||||
get "/items?id=eq.0"
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
@@ -175,7 +176,8 @@ shouldNotPersistMutations reqHeaders respHeaders = do
|
||||
[json|{"id":0}|]
|
||||
`shouldRespondWith`
|
||||
[json|[{"id":0}]|]
|
||||
{ matchHeaders = respHeaders }
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = respHeaders }
|
||||
get "/items?id=eq.0"
|
||||
`shouldRespondWith`
|
||||
[json|[]|]
|
||||
|
||||
+22
-16
@@ -25,7 +25,6 @@ import qualified Feature.Auth.NoJwtSpec
|
||||
import qualified Feature.ConcurrentSpec
|
||||
import qualified Feature.CorsSpec
|
||||
import qualified Feature.ExtraSearchPathSpec
|
||||
import qualified Feature.LegacyGucsSpec
|
||||
import qualified Feature.NoSuperuserSpec
|
||||
import qualified Feature.ObservabilitySpec
|
||||
import qualified Feature.OpenApi.DisabledOpenApiSpec
|
||||
@@ -35,26 +34,29 @@ import qualified Feature.OpenApi.ProxySpec
|
||||
import qualified Feature.OpenApi.RootSpec
|
||||
import qualified Feature.OpenApi.SecurityOpenApiSpec
|
||||
import qualified Feature.OptionsSpec
|
||||
import qualified Feature.Query.AggregateFunctionsSpec
|
||||
import qualified Feature.Query.AndOrParamsSpec
|
||||
import qualified Feature.Query.ComputedRelsSpec
|
||||
import qualified Feature.Query.CustomMediaSpec
|
||||
import qualified Feature.Query.DeleteSpec
|
||||
import qualified Feature.Query.EmbedDisambiguationSpec
|
||||
import qualified Feature.Query.EmbedInnerJoinSpec
|
||||
import qualified Feature.Query.ErrorSpec
|
||||
import qualified Feature.Query.HtmlRawOutputSpec
|
||||
import qualified Feature.Query.InsertSpec
|
||||
import qualified Feature.Query.JsonOperatorSpec
|
||||
import qualified Feature.Query.MultipleSchemaSpec
|
||||
import qualified Feature.Query.NullsStrip
|
||||
import qualified Feature.Query.NullsStripSpec
|
||||
import qualified Feature.Query.PgSafeUpdateSpec
|
||||
import qualified Feature.Query.PlanSpec
|
||||
import qualified Feature.Query.PostGISSpec
|
||||
import qualified Feature.Query.PreferencesSpec
|
||||
import qualified Feature.Query.QueryLimitedSpec
|
||||
import qualified Feature.Query.QuerySpec
|
||||
import qualified Feature.Query.RangeSpec
|
||||
import qualified Feature.Query.RawOutputTypesSpec
|
||||
import qualified Feature.Query.RelatedQueriesSpec
|
||||
import qualified Feature.Query.RpcSpec
|
||||
import qualified Feature.Query.ServerTimingSpec
|
||||
import qualified Feature.Query.SingularSpec
|
||||
import qualified Feature.Query.SpreadQueriesSpec
|
||||
import qualified Feature.Query.UnicodeSpec
|
||||
@@ -72,11 +74,12 @@ main = do
|
||||
|
||||
-- cached schema cache so most tests run fast
|
||||
baseSchemaCache <- loadSchemaCache pool testCfg
|
||||
sockets <- AppState.initSockets testCfg
|
||||
|
||||
let
|
||||
-- For tests that run with the same refSchemaCache
|
||||
app config = do
|
||||
appState <- AppState.initWithPool pool config
|
||||
appState <- AppState.initWithPool sockets pool config
|
||||
AppState.putPgVersion appState actualPgVersion
|
||||
AppState.putSchemaCache appState (Just baseSchemaCache)
|
||||
return ((), postgrest config appState $ pure ())
|
||||
@@ -84,7 +87,7 @@ main = do
|
||||
-- For tests that run with a different SchemaCache(depends on configSchemas)
|
||||
appDbs config = do
|
||||
customSchemaCache <- loadSchemaCache pool config
|
||||
appState <- AppState.initWithPool pool config
|
||||
appState <- AppState.initWithPool sockets pool config
|
||||
AppState.putPgVersion appState actualPgVersion
|
||||
AppState.putSchemaCache appState (Just customSchemaCache)
|
||||
return ((), postgrest config appState $ pure ())
|
||||
@@ -101,14 +104,14 @@ main = do
|
||||
asymJwkApp = app testCfgAsymJWK
|
||||
asymJwkSetApp = app testCfgAsymJWKSet
|
||||
rootSpecApp = app testCfgRootSpec
|
||||
htmlRawOutputApp = app testCfgHtmlRawOutput
|
||||
responseHeadersApp = app testCfgResponseHeaders
|
||||
disallowRollbackApp = app testCfgDisallowRollback
|
||||
forceRollbackApp = app testCfgForceRollback
|
||||
testCfgLegacyGucsApp = app testCfgLegacyGucs
|
||||
planEnabledApp = app testPlanEnabledCfg
|
||||
pgSafeUpdateApp = app testPgSafeUpdateEnabledCfg
|
||||
obsApp = app testObservabilityCfg
|
||||
serverTiming = app testCfgServerTiming
|
||||
aggregatesEnabled = app testCfgAggregatesEnabled
|
||||
|
||||
extraSearchPathApp = appDbs testCfgExtraSearchPath
|
||||
unicodeApp = appDbs testUnicodeCfg
|
||||
@@ -127,6 +130,7 @@ main = do
|
||||
, ("Feature.Auth.AuthSpec" , Feature.Auth.AuthSpec.spec actualPgVersion)
|
||||
, ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
|
||||
, ("Feature.CorsSpec" , Feature.CorsSpec.spec)
|
||||
, ("Feature.CustomMediaSpec" , Feature.Query.CustomMediaSpec.spec)
|
||||
, ("Feature.Query.DeleteSpec" , Feature.Query.DeleteSpec.spec)
|
||||
, ("Feature.Query.EmbedDisambiguationSpec" , Feature.Query.EmbedDisambiguationSpec.spec)
|
||||
, ("Feature.Query.EmbedInnerJoinSpec" , Feature.Query.EmbedInnerJoinSpec.spec)
|
||||
@@ -136,11 +140,12 @@ main = do
|
||||
, ("Feature.OptionsSpec" , Feature.OptionsSpec.spec actualPgVersion)
|
||||
, ("Feature.Query.PgSafeUpdateSpec.disabledSpec" , Feature.Query.PgSafeUpdateSpec.disabledSpec)
|
||||
, ("Feature.Query.PlanSpec.disabledSpec" , Feature.Query.PlanSpec.disabledSpec)
|
||||
, ("Feature.Query.PreferencesSpec" , Feature.Query.PreferencesSpec.spec)
|
||||
, ("Feature.Query.QuerySpec" , Feature.Query.QuerySpec.spec actualPgVersion)
|
||||
, ("Feature.Query.RawOutputTypesSpec" , Feature.Query.RawOutputTypesSpec.spec)
|
||||
, ("Feature.Query.RpcSpec" , Feature.Query.RpcSpec.spec actualPgVersion)
|
||||
, ("Feature.Query.SingularSpec" , Feature.Query.SingularSpec.spec)
|
||||
, ("Feature.Query.NullsStrip" , Feature.Query.NullsStrip.spec)
|
||||
, ("Feature.Query.NullsStripSpec" , Feature.Query.NullsStripSpec.spec)
|
||||
, ("Feature.Query.UpdateSpec" , Feature.Query.UpdateSpec.spec actualPgVersion)
|
||||
, ("Feature.Query.UpsertSpec" , Feature.Query.UpsertSpec.spec actualPgVersion)
|
||||
, ("Feature.Query.ComputedRelsSpec" , Feature.Query.ComputedRelsSpec.spec)
|
||||
@@ -156,10 +161,6 @@ main = do
|
||||
parallel $ beforeAll_ analyze . before withApp $
|
||||
describe "Feature.Query.RangeSpec" Feature.Query.RangeSpec.spec
|
||||
|
||||
-- this test runs with a raw-output-media-types set to text/html
|
||||
parallel $ before htmlRawOutputApp $
|
||||
describe "Feature.Query.HtmlRawOutputSpec" Feature.Query.HtmlRawOutputSpec.spec
|
||||
|
||||
-- this test runs with a different server flag
|
||||
parallel $ before maxRowsApp $
|
||||
describe "Feature.Query.QueryLimitedSpec" Feature.Query.QueryLimitedSpec.spec
|
||||
@@ -229,10 +230,6 @@ main = do
|
||||
parallel $ before multipleSchemaApp $
|
||||
describe "Feature.Query.MultipleSchemaSpec" Feature.Query.MultipleSchemaSpec.spec
|
||||
|
||||
-- this test runs with db-uses-legacy-gucs = false
|
||||
parallel $ before testCfgLegacyGucsApp $
|
||||
describe "Feature.LegacyGucsSpec" Feature.LegacyGucsSpec.spec
|
||||
|
||||
-- this test runs with db-plan-enabled = true
|
||||
parallel $ before planEnabledApp $
|
||||
describe "Feature.Query.PlanSpec.spec" $ Feature.Query.PlanSpec.spec actualPgVersion
|
||||
@@ -245,6 +242,15 @@ main = do
|
||||
parallel $ before obsApp $
|
||||
describe "Feature.ObservabilitySpec.spec" Feature.ObservabilitySpec.spec
|
||||
|
||||
parallel $ before serverTiming $
|
||||
describe "Feature.Query.ServerTimingSpec.spec" Feature.Query.ServerTimingSpec.spec
|
||||
|
||||
parallel $ before aggregatesEnabled $
|
||||
describe "Feature.Query.AggregateFunctionsSpec" Feature.Query.AggregateFunctionsSpec.allowed
|
||||
|
||||
parallel $ before withApp $
|
||||
describe "Feature.Query.AggregateFunctionsDisallowedSpec." Feature.Query.AggregateFunctionsSpec.disallowed
|
||||
|
||||
-- Note: the rollback tests can not run in parallel, because they test persistance and
|
||||
-- this results in race conditions
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user