Compare commits
@@ -112,7 +112,7 @@ runs:
|
||||
echo "artifacts=${artifacts}" >> "$GITHUB_OUTPUT"
|
||||
- name: Save artifact to GitHub Actions
|
||||
if: steps.find-task.outputs.task_found
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ inputs.upload }}
|
||||
path: ${{ steps.download.outputs.artifacts }}
|
||||
|
||||
@@ -19,14 +19,14 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
if: ${{ startsWith(github.ref, 'refs/heads/') || (inputs.save-prs && startsWith(github.ref, 'refs/pull/')) }}
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
key: ${{ runner.os }}-${{ inputs.prefix }}-${{ inputs.suffix }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ inputs.prefix }}-
|
||||
- uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
- uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
if: ${{ !startsWith(github.ref, 'refs/heads/') && !(inputs.save-prs && startsWith(github.ref, 'refs/pull/')) }}
|
||||
with:
|
||||
path: ${{ inputs.path }}
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
# This actions creates the github token using the postgrest app secrets
|
||||
- name: Create Github App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ vars.POSTGREST_CI_APP_ID }}
|
||||
private-key: ${{ secrets.POSTGREST_CI_PRIVATE_KEY }}
|
||||
@@ -38,14 +38,14 @@ jobs:
|
||||
|
||||
# This is required for backport action to cherry-pick the PR
|
||||
- name: Fetch PR ref
|
||||
uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
# Backport action that creates the PR with given settings
|
||||
- name: Create backport PR
|
||||
uses: korthout/backport-action@d07416681cab29bf2661702f925f020aaa962997 # v3.4.1
|
||||
uses: korthout/backport-action@c656f5d5851037b2b38fb5db2691a03fa229e3b2 # v4.0.1
|
||||
with:
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
pull_description: 'Backport for #${pull_number}.'
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
name: Nix - Linux x86-64 static
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Build static executable
|
||||
run: nix-build -A postgrestStatic -A postgrestStatic.tests
|
||||
- name: Save built executable as artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: postgrest-linux-static-x86-64
|
||||
path: result/bin/postgrest
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Build Docker image
|
||||
run: nix-build -A docker.image --out-link postgrest-docker.tar.gz
|
||||
- name: Save built Docker image as artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: postgrest-docker-x86-64
|
||||
path: postgrest-docker.tar.gz
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
name: Nix - MacOS
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
@@ -105,15 +105,6 @@ jobs:
|
||||
artifact: postgrest-macos-aarch64
|
||||
deps: brew link --force libpq
|
||||
|
||||
- name: MacOS x86-64
|
||||
runs-on: macos-13
|
||||
cache: |
|
||||
~/.stack/pantry
|
||||
~/.stack/snapshots
|
||||
~/.stack/stack.sqlite3
|
||||
artifact: postgrest-macos-x86-64
|
||||
deps: brew link --force libpq
|
||||
|
||||
- name: Windows
|
||||
runs-on: windows-2022
|
||||
cache: |
|
||||
@@ -126,8 +117,8 @@ jobs:
|
||||
name: Stack - ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: haskell-actions/setup@82e8b5066385702e477d9dc98f287070493e8abd # v2.8.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: haskell-actions/setup@dc63c94789664bb2910876ec3dfeeaa24d23b96b # v2.10.2
|
||||
with:
|
||||
# This must match the version in stack.yaml's resolver
|
||||
ghc-version: 9.6.7
|
||||
@@ -155,7 +146,7 @@ jobs:
|
||||
- name: Strip Executable
|
||||
run: strip result/postgrest*
|
||||
- name: Save built executable as artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: |
|
||||
@@ -168,7 +159,7 @@ jobs:
|
||||
name: Stack - FreeBSD from CirrusCI
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: ./.github/actions/artifact-from-cirrus
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
@@ -185,8 +176,8 @@ jobs:
|
||||
name: Cabal - Linux x86-64 - GHC ${{ matrix.ghc }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: haskell-actions/setup@82e8b5066385702e477d9dc98f287070493e8abd # v2.8.2
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: haskell-actions/setup@dc63c94789664bb2910876ec3dfeeaa24d23b96b # v2.10.2
|
||||
with:
|
||||
ghc-version: ${{ matrix.ghc }}
|
||||
- name: Cache .cabal
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
name: Lint & Style
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
name: Commit
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 100 # fetch history (last 100 commits) instead of default shallow clone history, this is deemed enough for a PR history
|
||||
- name: Setup Nix Environment
|
||||
|
||||
@@ -50,14 +50,14 @@ jobs:
|
||||
- test
|
||||
- build
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ssh-key: ${{ secrets.POSTGREST_SSH_KEY }}
|
||||
- name: Tag latest commit
|
||||
run: |
|
||||
cabal_version="$(grep -oP '^version:\s*\K.*' postgrest.cabal)"
|
||||
|
||||
if [[ "$cabal_version" == *.*.*.* ]]; then
|
||||
if [[ "$cabal_version" == *.* ]]; then
|
||||
git fetch --tags
|
||||
|
||||
if [ -z "$(git tag --list "v$cabal_version")" ]; then
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
name: Build
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
name: Spellcheck
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
linkcheck:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Check the version to be released
|
||||
run: |
|
||||
cabal_version="$(grep -oP '^version:\s*\K.*' postgrest.cabal)"
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
echo "Relevant extract from CHANGELOG.md:"
|
||||
cat CHANGES.md
|
||||
- name: Save CHANGES.md as artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: release-changes
|
||||
path: CHANGES.md
|
||||
@@ -64,9 +64,9 @@ jobs:
|
||||
needs:
|
||||
- prepare
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
path: artifacts
|
||||
- name: Create release bundle with archives for all builds
|
||||
@@ -81,9 +81,6 @@ jobs:
|
||||
tar cJvf "release-bundle/postgrest-${GITHUB_REF_NAME}-macos-aarch64.tar.xz" \
|
||||
-C artifacts/postgrest-macos-aarch64 postgrest
|
||||
|
||||
tar cJvf "release-bundle/postgrest-${GITHUB_REF_NAME}-macos-x86-64.tar.xz" \
|
||||
-C artifacts/postgrest-macos-x86-64 postgrest
|
||||
|
||||
tar cJvf "release-bundle/postgrest-${GITHUB_REF_NAME}-freebsd-x86-64.tar.xz" \
|
||||
-C artifacts/postgrest-freebsd-x86-64 postgrest
|
||||
|
||||
@@ -94,7 +91,7 @@ jobs:
|
||||
artifacts/postgrest-windows-x86-64/postgrest.exe
|
||||
|
||||
- name: Save release bundle
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: release-bundle
|
||||
path: release-bundle
|
||||
@@ -138,16 +135,16 @@ jobs:
|
||||
env:
|
||||
DOCKER_REPO: ${{ vars.DOCKER_REPO }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Download x86-64 Docker image
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: postgrest-docker-x86-64
|
||||
- name: Download aarch64 binary
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: postgrest-ubuntu-aarch64
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
- uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
@@ -194,7 +191,7 @@ jobs:
|
||||
vars.DOCKER_REPO && vars.DOCKER_USER &&
|
||||
github.ref == 'refs/tags/devel'
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
|
||||
+10
-11
@@ -39,7 +39,7 @@ jobs:
|
||||
# https://github.com/actions/runner/issues/241#issuecomment-842566950
|
||||
shell: script -qec "bash --noprofile --norc -eo pipefail {0}"
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Run coverage (IO tests and Spec tests against PostgreSQL 15)
|
||||
run: postgrest-coverage
|
||||
- name: Upload coverage to codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
files: ./coverage/codecov.json
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -78,33 +78,33 @@ jobs:
|
||||
# https://github.com/actions/runner/issues/241#issuecomment-842566950
|
||||
shell: script -qec "bash --noprofile --norc -eo pipefail {0}"
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||
tools: tests.testSpec.bin tests.testIO.bin tests.testBigSchema.bin withTools.postgresql-${{ matrix.pgVersion }}.bin cabalTools.update.bin
|
||||
tools: tests.testSpec.bin tests.testIO.bin tests.testBigSchema.bin withTools.pg-${{ matrix.pgVersion }}.bin cabalTools.update.bin
|
||||
|
||||
- run: postgrest-cabal-update
|
||||
|
||||
- name: Run spec tests
|
||||
if: always()
|
||||
run: postgrest-with-postgresql-${{ matrix.pgVersion }} postgrest-test-spec
|
||||
run: postgrest-with-pg-${{ matrix.pgVersion }} postgrest-test-spec
|
||||
|
||||
- name: Run IO tests
|
||||
if: always()
|
||||
run: postgrest-with-postgresql-${{ matrix.pgVersion }} postgrest-test-io -vv
|
||||
run: postgrest-with-pg-${{ matrix.pgVersion }} postgrest-test-io -vv
|
||||
|
||||
- name: Run IO tests on a big schema
|
||||
if: always()
|
||||
run: postgrest-with-postgresql-${{ matrix.pgVersion }} postgrest-test-big-schema -vv
|
||||
run: postgrest-with-pg-${{ matrix.pgVersion }} postgrest-test-big-schema -vv
|
||||
|
||||
|
||||
memory:
|
||||
name: Memory
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup Nix Environment
|
||||
uses: ./.github/actions/setup-nix
|
||||
with:
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
name: Loadtest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Nix Environment
|
||||
@@ -152,14 +152,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runs-on:
|
||||
- macos-13 # x86_64-darwin
|
||||
- macos-14 # aarch64-darwin
|
||||
- ubuntu-24.04 # x86_64-linux
|
||||
- ubuntu-24.04-arm # aarch64-linux
|
||||
name: Flake Check
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Nix Environment
|
||||
|
||||
+55
-2
@@ -1,10 +1,63 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
All notable changes to this project will be documented in this file. From version `14.0` onwards PostgREST follows a `MAJOR.PATCH` two-part versioning. Only even-numbered MAJOR versions will be released, reserving odd-numbered MAJOR versions for development.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## [14.5] - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- Log error when `db-schemas` config contains schema `pg_catalog` or `information_schema` by @taimoorzaeem in #4359
|
||||
- Add a `HINT` when the LISTEN channel stops working due to a PostgreSQL bug by @laurenceisla in #4581
|
||||
- Add string slicing operator for `jwt-role-claim-key` by @taimoorzaeem in #4599
|
||||
- Log host, port and pg version of listener database connection by @mkleczek in #4617 #4618
|
||||
- Optimize requests with `Prefer: count=exact` that do not use ranges or `db-max-rows` by @laurenceisla in #3957
|
||||
+ Removed unnecessary double count when building the `Content-Range`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Don't hide async exceptions in logs by @stevechavez in #4646
|
||||
|
||||
### Changed
|
||||
|
||||
- Log error when `db-schemas` config contains schema `pg_catalog` or `information_schema` by @taimoorzaeem in #4359
|
||||
+ Now fails at startup. Prior to this, it failed with `PGRST205` on requests related to these schemas.
|
||||
|
||||
## [14.4] - 2026-01-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure Listener connections are released by @mkleczek in #4614
|
||||
- Fix incorrectly filtering the returned representation for PATCH requests when using `or/and` filters by @laurenceisla in #3707
|
||||
- Fix listener running with exception masked after first failure by @mkleczek #4615
|
||||
|
||||
## [14.3] - 2026-01-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix performance and high memory usage of relation hint calculation by @mkleczek in #4462, #4463
|
||||
|
||||
## [14.2] - 2025-12-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix `hasSingleUnnamedParam` incorrectly matching functions with named parameters by @joelonsql in #4553
|
||||
+ Functions with a single named parameter (e.g., `foo(data json)`) no longer incorrectly match the single-param fallback, returning a clean `PGRST202` error instead of a confusing PostgreSQL `42883` error.
|
||||
- Fix misleading logs on unsupported PostgreSQL versions by @taimoorzaeem in #4519
|
||||
- Fix regression where the `PGRST103` error response was truncated by @laurenceisla in #4455
|
||||
+ Happened when an `offset` was greater than the rows requested and `Prefer: count=exact` was sent.
|
||||
- Fix not returning `Content-Length` on empty HTTP `201` responses by @laurenceisla in #4518
|
||||
- Fix inaccurate Server-Timing header durations by @steve-chavez in #4522
|
||||
- Fix inaccurate "Schema cache queried" logs by @steve-chavez in #4522
|
||||
|
||||
## [14.1] - 2025-11-05
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fix `db-pre-config` function failing when function names are pg reserved words by @taimoorzaeem in #4380
|
||||
- Fix `server-host=!6` incorrectly binds to IPv4 address by @taimoorzaeem in #3202
|
||||
|
||||
## [14.0] - 2025-10-24
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
# The x86-64 is a single-static-binary image built via Nix, see:
|
||||
# nix/tools/docker/README.md
|
||||
|
||||
FROM ubuntu:noble@sha256:66460d557b25769b102175144d538d88219c077c678a49af4afca6fbfc1b5252 AS postgrest
|
||||
FROM ubuntu:noble@sha256:c35e29c9450151419d9448b0fd75374fec4fff364a27f176fb458d472dfc9e54 AS postgrest
|
||||
|
||||
RUN apt-get update -y \
|
||||
&& apt install -y --no-install-recommends libpq-dev zlib1g-dev jq gcc libnuma-dev \
|
||||
|
||||
@@ -1 +1 @@
|
||||
index-state: hackage.haskell.org 2025-10-13T04:53:27Z
|
||||
index-state: hackage.haskell.org 2025-10-29T04:02:18Z
|
||||
|
||||
+5
-5
@@ -53,11 +53,11 @@ let
|
||||
|
||||
postgresqlVersions =
|
||||
[
|
||||
{ name = "postgresql-17"; postgresql = pkgs.postgresql_17.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||
{ name = "postgresql-16"; postgresql = pkgs.postgresql_16.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||
{ 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 ]); }
|
||||
{ name = "pg-17"; postgresql = pkgs.postgresql_17.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||
{ name = "pg-16"; postgresql = pkgs.postgresql_16.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||
{ name = "pg-15"; postgresql = pkgs.postgresql_15.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||
{ name = "pg-14"; postgresql = pkgs.postgresql_14.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||
{ name = "pg-13"; postgresql = pkgs.postgresql_13.withPackages (p: [ p.postgis p.pg_safeupdate ]); }
|
||||
];
|
||||
|
||||
haskellPackages = pkgs.haskell.packages."${compiler}";
|
||||
|
||||
@@ -302,6 +302,7 @@ linkcheck_ignore = [
|
||||
r"https://www.cybertec-postgresql.com/.*",
|
||||
# Odd SSL error
|
||||
r"https://www.dripdepot.com",
|
||||
r"https://www.euronodes.com",
|
||||
# New GitHub UI delays comment load, so anchor fails
|
||||
r"https://github.com/.*#issuecomment",
|
||||
# Random 500 Internal Server Error
|
||||
|
||||
@@ -16,7 +16,7 @@ Supported PostgreSQL versions
|
||||
=============================
|
||||
|
||||
=============== =================================
|
||||
**Supported** PostgreSQL >= 12
|
||||
**Supported** PostgreSQL >= 13
|
||||
=============== =================================
|
||||
|
||||
PostgREST works with all PostgreSQL versions still `officially supported <https://www.postgresql.org/support/versioning/>`_.
|
||||
@@ -146,7 +146,7 @@ To avoid having to install the database at all, you can run both it and the serv
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
PGRST_SERVER_HOST: localhost # necessary for `postgrest --ready` flag to work
|
||||
PGRST_SERVER_HOST: 0.0.0.0 # necessary for `postgrest --ready` flag to work
|
||||
PGRST_DB_URI: postgres://app_user:password@db:5432/app_db
|
||||
PGRST_OPENAPI_SERVER_PROXY_URI: http://127.0.0.1:3000
|
||||
depends_on:
|
||||
|
||||
@@ -213,7 +213,6 @@ In Production
|
||||
Here are some companies that use PostgREST in production.
|
||||
|
||||
* `Catarse <https://www.catarse.me>`_
|
||||
* `Datrium <https://www.datrium.com>`_
|
||||
* `Drip Depot <https://www.dripdepot.com>`_
|
||||
* `Image-charts <https://www.image-charts.com>`_
|
||||
* `Netwo <https://www.netwo.io>`_
|
||||
|
||||
+7
-7
@@ -91,11 +91,11 @@ postgrest-gen-ctags postgrest-watch
|
||||
postgrest-gen-jwt postgrest-with-all
|
||||
postgrest-gen-secret postgrest-with-git
|
||||
postgrest-git-hooks postgrest-with-pgrst
|
||||
postgrest-hsie-graph-modules postgrest-with-postgresql-13
|
||||
postgrest-hsie-graph-symbols postgrest-with-postgresql-14
|
||||
postgrest-hsie-minimal-imports postgrest-with-postgresql-15
|
||||
postgrest-lint postgrest-with-postgresql-16
|
||||
postgrest-loadtest postgrest-with-postgresql-17
|
||||
postgrest-hsie-graph-modules postgrest-with-pg-13
|
||||
postgrest-hsie-graph-symbols postgrest-with-pg-14
|
||||
postgrest-hsie-minimal-imports postgrest-with-pg-15
|
||||
postgrest-lint postgrest-with-pg-16
|
||||
postgrest-loadtest postgrest-with-pg-17
|
||||
postgrest-loadtest-against postgrest-with-slow-pg
|
||||
postgrest-loadtest-report postgrest-with-slow-postgrest
|
||||
postgrest-nixpkgs-upgrade
|
||||
@@ -174,7 +174,7 @@ $ nix-shell --run "postgrest-with-all postgrest-test-spec"
|
||||
|
||||
# Run the tests against a specific version of PostgreSQL (use tab-completion in
|
||||
# nix-shell to see all available versions):
|
||||
$ nix-shell --run "postgrest-with-postgresql-13 postgrest-test-spec"
|
||||
$ nix-shell --run "postgrest-with-pg-13 postgrest-test-spec"
|
||||
|
||||
```
|
||||
|
||||
@@ -284,7 +284,7 @@ Tools like `postgrest-build`, `postgrest-run`, `postgrest-repl` etc. are simple
|
||||
also run in CI, with the exception of the IO and Memory checks that need to be run
|
||||
separately.
|
||||
|
||||
`postgrest-with-postgresql-*` take a command as an argument and will run it
|
||||
`postgrest-with-pg-*` take a command as an argument and will run it
|
||||
with a temporary database. `postgrest-with-all` will run the command against
|
||||
all supported PostgreSQL versions. Tests run without `postgrest-with-*` are
|
||||
run against the latest PostgreSQL version by default.
|
||||
|
||||
@@ -104,8 +104,7 @@ let
|
||||
''
|
||||
|
||||
+ lib.optionalString withTmpDir ''
|
||||
mkdir -p "''${TMPDIR:-/tmp}/postgrest"
|
||||
tmpdir="$(${coreutils}/bin/mktemp -d --tmpdir postgrest/${name}-XXX)"
|
||||
tmpdir="$(${coreutils}/bin/mktemp -d --tmpdir ${name}-XXX)"
|
||||
|
||||
# we keep the tmpdir when an error occurs for debugging
|
||||
trap 'echo Temporary directory kept at: $tmpdir' ERR
|
||||
|
||||
@@ -60,6 +60,16 @@ let
|
||||
}
|
||||
{ };
|
||||
|
||||
# TODO: Remove once available in nixpkgs haskellPackages
|
||||
streaming-commons =
|
||||
prev.callHackageDirect
|
||||
{
|
||||
pkg = "streaming-commons";
|
||||
ver = "0.2.3.1";
|
||||
sha256 = "sha256-Gl2eaJcWe1sxmcE/octWlH9uSnERguf+5H66K4fV87s=";
|
||||
}
|
||||
{ };
|
||||
|
||||
# Downgrade hasql and related packages while we are still on GHC 9.4 for the static build.
|
||||
hasql = lib.dontCheck (lib.doJailbreak prev.hasql_1_6_4_4);
|
||||
hasql-dynamic-statements = lib.dontCheck prev.hasql-dynamic-statements_0_3_1_5;
|
||||
|
||||
+16
-1
@@ -12,6 +12,7 @@
|
||||
, silver-searcher
|
||||
, statix
|
||||
, stylish-haskell
|
||||
, writeText
|
||||
}:
|
||||
let
|
||||
style =
|
||||
@@ -51,6 +52,20 @@ let
|
||||
${git}/bin/git diff-index --exit-code HEAD -- '*.hs' '*.lhs' '*.nix' '*.py'
|
||||
'';
|
||||
|
||||
hlintConfig = writeText "hlintConfig.yml" ''
|
||||
|
||||
# Arguments passed to hlint
|
||||
- arguments: [-j, -XQuasiQuotes, -XNoPatternSynonyms]
|
||||
|
||||
# Warnings
|
||||
- warn: { lhs: "a == a", rhs: "True", note: "This comparison always evaluates to True" }
|
||||
- warn: { lhs: "a /= a", rhs: "False", note: "This comparison always evaluates to False" }
|
||||
- warn: { lhs: "a < a", rhs: "False", note: "This comparison always evaluates to False" }
|
||||
- warn: { lhs: "a > a", rhs: "False", note: "This comparison always evaluates to False" }
|
||||
- warn: { lhs: "a <= a", rhs: "True", note: "This comparison always evaluates to True" }
|
||||
- warn: { lhs: "a >= a", rhs: "True", note: "This comparison always evaluates to True" }
|
||||
'';
|
||||
|
||||
lint =
|
||||
checkedShellScript
|
||||
{
|
||||
@@ -79,7 +94,7 @@ let
|
||||
echo "Linting Haskell files..."
|
||||
# --vimgrep fixes a bug in ag: https://github.com/ggreer/the_silver_searcher/issues/753
|
||||
${silver-searcher}/bin/ag -l --vimgrep -g '\.l?hs$' . \
|
||||
| xargs ${hlint}/bin/hlint -j -X QuasiQuotes -X NoPatternSynonyms
|
||||
| xargs ${hlint}/bin/hlint --hint=${hlintConfig}
|
||||
'';
|
||||
|
||||
in
|
||||
|
||||
+6
-6
@@ -83,7 +83,7 @@ let
|
||||
}
|
||||
''
|
||||
${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest
|
||||
${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} -f test/io/fixtures.sql \
|
||||
${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} -f test/io/fixtures/load.sql \
|
||||
${ioTestPython}/bin/pytest --ignore=test/io/test_big_schema.py --ignore=test/io/test_replica.py -v test/io "''${_arg_leftovers[@]}"
|
||||
'';
|
||||
|
||||
@@ -98,7 +98,7 @@ let
|
||||
}
|
||||
''
|
||||
${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest
|
||||
${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} -f test/io/big_schema.sql \
|
||||
${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} -f test/io/fixtures/big_schema.sql \
|
||||
${ioTestPython}/bin/pytest -v test/io/test_big_schema.py "''${_arg_leftovers[@]}"
|
||||
'';
|
||||
|
||||
@@ -113,7 +113,7 @@ let
|
||||
}
|
||||
''
|
||||
${cabal-install}/bin/cabal v2-build ${devCabalOptions} exe:postgrest
|
||||
${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} --replica -f test/io/replica.sql \
|
||||
${cabal-install}/bin/cabal v2-exec -- ${withTools.withPg} --replica -f test/io/fixtures/replica.sql \
|
||||
${ioTestPython}/bin/pytest -v test/io/test_replica.py "''${_arg_leftovers[@]}"
|
||||
'';
|
||||
|
||||
@@ -164,15 +164,15 @@ let
|
||||
|
||||
# collect all tests
|
||||
HPCTIXFILE="$tmpdir"/io.tix \
|
||||
${withTools.withPg} -f test/io/fixtures.sql \
|
||||
${withTools.withPg} -f test/io/fixtures/load.sql \
|
||||
${cabal-install}/bin/cabal v2-exec ${devCabalOptions} -- ${ioTestPython}/bin/pytest --ignore=test/io/test_big_schema.py --ignore=test/io/test_replica.py -v test/io
|
||||
|
||||
HPCTIXFILE="$tmpdir"/big_schema.tix \
|
||||
${withTools.withPg} -f test/io/big_schema.sql \
|
||||
${withTools.withPg} -f test/io/fixtures/big_schema.sql \
|
||||
${cabal-install}/bin/cabal v2-exec ${devCabalOptions} -- ${ioTestPython}/bin/pytest -v test/io/test_big_schema.py
|
||||
|
||||
HPCTIXFILE="$tmpdir"/replica.tix \
|
||||
${withTools.withPg} --replica -f test/io/replica.sql \
|
||||
${withTools.withPg} --replica -f test/io/fixtures/replica.sql \
|
||||
${cabal-install}/bin/cabal v2-exec ${devCabalOptions} -- ${ioTestPython}/bin/pytest -v test/io/test_replica.py
|
||||
|
||||
HPCTIXFILE="$tmpdir"/spec.tix \
|
||||
|
||||
@@ -46,7 +46,7 @@ let
|
||||
}
|
||||
|
||||
# Avoid starting multiple layers of withTmpDb, but make sure to have the last invocation
|
||||
# load fixtures. Otherwise postgrest-with-postgresql-xx postgrest-test-io would not be possible.
|
||||
# load fixtures. Otherwise postgrest-with-pg-xx postgrest-test-io would not be possible.
|
||||
if ! test -v PGHOST; then
|
||||
|
||||
mkdir -p "$tmpdir"/{db,socket}
|
||||
@@ -74,7 +74,13 @@ let
|
||||
>> "$setuplog"
|
||||
|
||||
log "Starting the database cluster..."
|
||||
|
||||
# Instead of listening on a local port, we will listen on a unix domain socket.
|
||||
# NOTE: unix domain socket filename name must remain under max limit.
|
||||
# On Linux, it's 108 chars (including '\0' terminator)
|
||||
# On MacOS, it's 104 chars
|
||||
# See: https://serverfault.com/questions/641347/check-if-a-path-exceeds-maximum-for-unix-domain-socket
|
||||
|
||||
pg_ctl -l "$tmpdir/db.log" -w start -o "-F -c listen_addresses=\"\" -c hba_file=$HBA_FILE -k $PGHOST -c log_statement=\"all\" " \
|
||||
>> "$setuplog"
|
||||
|
||||
@@ -433,7 +439,7 @@ buildToolbox
|
||||
withSlowPg
|
||||
withSlowPgrst;
|
||||
} // builtins.listToAttrs (
|
||||
# Create a `postgrest-with-postgresql-` for each PostgreSQL version
|
||||
# Create a `postgrest-with-pg-` for each PostgreSQL version
|
||||
builtins.map (pg: { inherit (pg) name; value = withTmpDb pg; }) postgresqlVersions
|
||||
);
|
||||
# make latest withPg available for other nix files
|
||||
|
||||
+3
-7
@@ -1,5 +1,5 @@
|
||||
name: postgrest
|
||||
version: 14.0
|
||||
version: 14.5
|
||||
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
|
||||
@@ -94,6 +94,7 @@ library
|
||||
PostgREST.Response.OpenAPI
|
||||
PostgREST.Response.GucHeader
|
||||
PostgREST.Response.Performance
|
||||
PostgREST.TimeIt
|
||||
PostgREST.Version
|
||||
build-depends: base >= 4.9 && < 4.20
|
||||
, HTTP >= 4000.3.7 && < 4000.5
|
||||
@@ -102,10 +103,8 @@ library
|
||||
, auto-update >= 0.1.4 && < 0.3
|
||||
, base64-bytestring >= 1 && < 1.3
|
||||
, bytestring >= 0.10.8 && < 0.13
|
||||
, 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.11 && < 0.3
|
||||
, containers >= 0.5.7 && < 0.7
|
||||
, cookie >= 0.4.2 && < 0.6
|
||||
@@ -118,11 +117,9 @@ library
|
||||
, hasql-notifications >= 0.2.2.2 && < 0.2.3
|
||||
, hasql-pool >= 1.0.1 && < 1.1
|
||||
, hasql-transaction >= 1.0.1 && < 1.2
|
||||
, heredoc >= 0.2 && < 0.3
|
||||
, http-client >= 0.7.19 && < 0.8
|
||||
, http-types >= 0.12.2 && < 0.13
|
||||
, insert-ordered-containers >= 0.2.2 && < 0.3
|
||||
, iproute >= 1.7.0 && < 1.8
|
||||
, jose-jwt >= 0.9.6 && < 0.11
|
||||
, lens >= 4.14 && < 5.4
|
||||
, lens-aeson >= 1.0.1 && < 1.3
|
||||
@@ -139,11 +136,10 @@ 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
|
||||
, streaming-commons >= 0.2.3.1 && < 0.3
|
||||
, swagger2 >= 2.4 && < 2.9
|
||||
, text >= 1.2.2 && < 2.2
|
||||
, time >= 1.6 && < 1.13
|
||||
, timeit >= 2.0 && < 2.1
|
||||
, unordered-containers >= 0.2.8 && < 0.3
|
||||
, unix-compat >= 0.5.4 && < 0.8
|
||||
, vault >= 0.3.1.5 && < 0.4
|
||||
|
||||
+26
-4
@@ -10,17 +10,23 @@ Some of its functionality includes:
|
||||
- Content Negotiation
|
||||
-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE ViewPatterns #-}
|
||||
module PostgREST.App
|
||||
( postgrest
|
||||
, run
|
||||
) where
|
||||
|
||||
|
||||
import GHC.IO.Exception (IOErrorType (..))
|
||||
import System.IO.Error (ioeGetErrorType)
|
||||
|
||||
import Control.Monad.Except (liftEither)
|
||||
import Data.Either.Combinators (mapLeft, whenLeft)
|
||||
import Data.Maybe (fromJust)
|
||||
import Data.String (IsString (..))
|
||||
import Network.Wai.Handler.Warp (defaultSettings, setHost, setPort,
|
||||
import Network.Wai.Handler.Warp (defaultSettings, setHost,
|
||||
setOnException, setPort,
|
||||
setServerName)
|
||||
|
||||
import qualified Data.Text.Encoding as T
|
||||
@@ -51,19 +57,18 @@ import PostgREST.Observation (Observation (..))
|
||||
import PostgREST.Response.Performance (ServerTiming (..),
|
||||
serverTimingHeader)
|
||||
import PostgREST.SchemaCache (SchemaCache (..))
|
||||
import PostgREST.TimeIt (timeItT)
|
||||
import PostgREST.Version (docsVersion, prettyVersion)
|
||||
|
||||
import qualified Data.ByteString.Char8 as BS
|
||||
import qualified Data.List as L
|
||||
import qualified Network.HTTP.Types as HTTP
|
||||
import Protolude hiding (Handler)
|
||||
import System.TimeIt (timeItT)
|
||||
|
||||
type Handler = ExceptT Error
|
||||
|
||||
run :: AppState -> IO ()
|
||||
run appState = do
|
||||
let observer = AppState.getObserver appState
|
||||
conf@AppConfig{..} <- AppState.getConfig appState
|
||||
|
||||
AppState.schemaCacheLoader appState -- Loads the initial SchemaCache
|
||||
@@ -79,7 +84,24 @@ run appState = do
|
||||
address <- resolveSocketToAddress (AppState.getSocketREST appState)
|
||||
observer $ AppServerAddressObs address
|
||||
|
||||
Warp.runSettingsSocket (serverSettings conf) (AppState.getSocketREST appState) app
|
||||
Warp.runSettingsSocket (serverSettings conf & setOnException onWarpException) (AppState.getSocketREST appState) app
|
||||
where
|
||||
observer = AppState.getObserver appState
|
||||
|
||||
onWarpException :: Maybe Wai.Request -> SomeException -> IO ()
|
||||
onWarpException _ ex =
|
||||
when (shouldDisplayException ex) $
|
||||
observer $ WarpErrorObs $ show ex
|
||||
|
||||
-- Similar to wai defaultShouldDisplayException in
|
||||
-- https://github.com/yesodweb/wai//blob/8c3882c60f6abe043889fc20c7efd3fa9747fa4a/warp/Network/Wai/Handler/Warp/Settings.hs#L251-L258
|
||||
-- but without omitting AsyncException since it's important to log for ThreadKilled, StackOverflow and other cases.
|
||||
-- We want to reuse this to avoid flooding the logs for some transient failure cases.
|
||||
shouldDisplayException :: SomeException -> Bool
|
||||
shouldDisplayException se
|
||||
| Just (_ :: Warp.InvalidRequest) <- fromException se = False
|
||||
| Just (ioeGetErrorType -> et) <- fromException se, et == ResourceVanished || et == InvalidArgument = False
|
||||
| otherwise = True
|
||||
|
||||
serverSettings :: AppConfig -> Warp.Settings
|
||||
serverSettings AppConfig{..} =
|
||||
|
||||
@@ -44,8 +44,8 @@ import qualified PostgREST.Error as Error
|
||||
import qualified PostgREST.Logger as Logger
|
||||
import qualified PostgREST.Metrics as Metrics
|
||||
import PostgREST.Observation
|
||||
import PostgREST.TimeIt (timeItT)
|
||||
import PostgREST.Version (prettyVersion)
|
||||
import System.TimeIt (timeItT)
|
||||
|
||||
import Control.AutoUpdate (defaultUpdateSettings, mkAutoUpdate,
|
||||
updateAction)
|
||||
@@ -69,7 +69,7 @@ import PostgREST.Config.PgVersion (PgVersion (..),
|
||||
import PostgREST.SchemaCache (SchemaCache (..),
|
||||
querySchemaCache,
|
||||
showSummary)
|
||||
import PostgREST.SchemaCache.Identifiers (dumpQi)
|
||||
import PostgREST.SchemaCache.Identifiers (quoteQi)
|
||||
import PostgREST.Unix (createAndBindDomainSocket)
|
||||
|
||||
import Data.Streaming.Network (bindPortTCP, bindRandomPortTCP)
|
||||
@@ -382,10 +382,12 @@ retryingSchemaCacheLoad appState@AppState{stateObserver=observer, stateMainThrea
|
||||
observer ExitDBNoRecoveryObs
|
||||
killThread mainThreadId
|
||||
return Nothing
|
||||
Right actualPgVersion -> do
|
||||
when (actualPgVersion < minimumPgVersion) $ do
|
||||
Right actualPgVersion ->
|
||||
if actualPgVersion < minimumPgVersion then do
|
||||
observer $ ExitUnsupportedPgVersion actualPgVersion minimumPgVersion
|
||||
killThread mainThreadId
|
||||
return Nothing
|
||||
else do
|
||||
observer $ DBConnectedObs $ pgvFullName actualPgVersion
|
||||
observer $ PoolInit configDbPoolSize
|
||||
putPgVersion appState actualPgVersion
|
||||
@@ -441,7 +443,7 @@ readInDbConfig startingUp appState@AppState{stateObserver=observer} = do
|
||||
pgVer <- getPgVersion appState
|
||||
dbSettings <-
|
||||
if configDbConfig conf then do
|
||||
qDbSettings <- usePool appState (queryDbSettings (dumpQi <$> configDbPreConfig conf) (configDbPreparedStatements conf))
|
||||
qDbSettings <- usePool appState (queryDbSettings (quoteQi <$> configDbPreConfig conf) (configDbPreparedStatements conf))
|
||||
case qDbSettings of
|
||||
Left e -> do
|
||||
observer $ ConfigReadErrorObs e
|
||||
|
||||
@@ -25,8 +25,8 @@ import qualified Network.Wai as Wai
|
||||
import qualified Network.Wai.Middleware.HttpAuth as Wai
|
||||
|
||||
import Data.List (lookup)
|
||||
import PostgREST.TimeIt (timeItT)
|
||||
import System.IO.Unsafe (unsafePerformIO)
|
||||
import System.TimeIt (timeItT)
|
||||
|
||||
import PostgREST.AppState (AppState, getConfig, getJwtCacheState,
|
||||
getTime)
|
||||
|
||||
@@ -3,6 +3,7 @@ Module : PostgREST.Error
|
||||
Description : PostgREST error HTTP responses
|
||||
-}
|
||||
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
|
||||
module PostgREST.Error
|
||||
@@ -41,6 +42,7 @@ import Network.HTTP.Types.Header (Header)
|
||||
import PostgREST.MediaType (MediaType (..))
|
||||
import qualified PostgREST.MediaType as MediaType
|
||||
|
||||
import PostgREST.SchemaCache (SchemaCache (SchemaCache, dbTablesFuzzyIndex))
|
||||
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
|
||||
Schema)
|
||||
import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||
@@ -49,10 +51,8 @@ import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||
RelationshipsMap)
|
||||
import PostgREST.SchemaCache.Routine (Routine (..),
|
||||
RoutineParam (..))
|
||||
import PostgREST.SchemaCache.Table (Table (..))
|
||||
import Protolude
|
||||
|
||||
|
||||
class (ErrorBody a, JSON.ToJSON a) => PgrstError a where
|
||||
status :: a -> HTTP.Status
|
||||
headers :: a -> [Header]
|
||||
@@ -250,7 +250,7 @@ data SchemaCacheError
|
||||
| NoRelBetween Text Text (Maybe Text) Text RelationshipsMap
|
||||
| NoRpc Text Text [Text] MediaType Bool [QualifiedIdentifier] [Routine]
|
||||
| ColumnNotFound Text Text
|
||||
| TableNotFound Text Text [Table]
|
||||
| TableNotFound Text Text SchemaCache
|
||||
deriving Show
|
||||
|
||||
instance PgrstError SchemaCacheError where
|
||||
@@ -313,7 +313,7 @@ instance ErrorBody SchemaCacheError where
|
||||
where
|
||||
onlySingleParams = isInvPost && contentType `elem` [MTTextPlain, MTTextXML, MTOctetStream]
|
||||
hint (AmbiguousRpc _) = Just "Try renaming the parameters or the function itself in the database so function overloading can be resolved"
|
||||
hint (TableNotFound schemaName relName tbls) = JSON.String <$> tableNotFoundHint schemaName relName tbls
|
||||
hint (TableNotFound schemaName relName schemaCache) = JSON.String <$> tableNotFoundHint schemaName relName schemaCache
|
||||
|
||||
hint _ = Nothing
|
||||
|
||||
@@ -428,13 +428,11 @@ noRpcHint schema procName params allProcs overloadedProcs =
|
||||
|
||||
-- |
|
||||
-- Do a fuzzy search in all tables in the same schema and return closest result
|
||||
tableNotFoundHint :: Text -> Text -> [Table] -> Maybe Text
|
||||
tableNotFoundHint schema tblName tblList
|
||||
tableNotFoundHint :: Text -> Text -> SchemaCache -> Maybe Text
|
||||
tableNotFoundHint schema tblName SchemaCache{dbTablesFuzzyIndex}
|
||||
= fmap (\tbl -> "Perhaps you meant the table '" <> schema <> "." <> tbl <> "'") perhapsTable
|
||||
where
|
||||
perhapsTable = Fuzzy.getOne fuzzyTableSet tblName
|
||||
fuzzyTableSet = Fuzzy.fromList [ tableName tbl | tbl <- tblList, tableSchema tbl == schema]
|
||||
|
||||
perhapsTable = (`Fuzzy.getOne` tblName) =<< HM.lookup schema dbTablesFuzzyIndex
|
||||
|
||||
compressedRel :: Relationship -> JSON.Value
|
||||
-- An ambiguousness error cannot happen for computed relationships TODO refactor so this mempty is not needed
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE MultiWayIf #-}
|
||||
{-# LANGUAGE RecordWildCards #-}
|
||||
|
||||
@@ -15,6 +16,7 @@ import PostgREST.Version (prettyVersion)
|
||||
import qualified PostgREST.AppState as AppState
|
||||
import qualified PostgREST.Config as Config
|
||||
|
||||
import Data.Either.Combinators (whenRight)
|
||||
import Protolude
|
||||
|
||||
-- | Starts the Listener in a thread
|
||||
@@ -22,15 +24,16 @@ runListener :: AppState -> IO ()
|
||||
runListener appState = do
|
||||
AppConfig{..} <- getConfig appState
|
||||
when configDbChannelEnabled $
|
||||
void . forkIO $ retryingListen appState
|
||||
void . forkIO . void $ retryingListen appState
|
||||
|
||||
-- | Starts a LISTEN connection and handles notifications. It recovers with exponential backoff with a cap of 32 seconds, if the LISTEN connection is lost.
|
||||
retryingListen :: AppState -> IO ()
|
||||
-- | This function never returns (but can throw) and return type enforces that.
|
||||
retryingListen :: AppState -> IO Void
|
||||
retryingListen appState = do
|
||||
AppConfig{..} <- AppState.getConfig appState
|
||||
let
|
||||
dbChannel = toS configDbChannel
|
||||
handleFinally err = do
|
||||
onError err = do
|
||||
AppState.putIsListenerOn appState False
|
||||
observer $ DBListenFail dbChannel (Right err)
|
||||
unless configDbPoolAutomaticRecovery $
|
||||
@@ -42,12 +45,19 @@ retryingListen appState = do
|
||||
threadDelay (delay * oneSecondInMicro)
|
||||
unless (delay == maxDelay) $
|
||||
AppState.putNextListenerDelay appState (delay * 2)
|
||||
-- loop running the listener
|
||||
retryingListen appState
|
||||
|
||||
-- forkFinally allows to detect if the thread dies
|
||||
void . flip forkFinally handleFinally $ do
|
||||
dbOrError <- SQL.acquire $ toUtf8 (Config.addTargetSessionAttrs $ Config.addFallbackAppName prettyVersion configDbUri)
|
||||
case dbOrError of
|
||||
-- Execute the listener with with error handling
|
||||
handle onError $ do
|
||||
-- Make sure we don't leak connections on errors
|
||||
bracket
|
||||
-- acquire connection
|
||||
(SQL.acquire $ toUtf8 (Config.addTargetSessionAttrs $ Config.addFallbackAppName prettyVersion configDbUri))
|
||||
-- release connection
|
||||
(`whenRight` releaseConnection) $
|
||||
-- use connection
|
||||
\case
|
||||
Right db -> do
|
||||
SQL.listen db $ SQL.toPgIdentifier dbChannel
|
||||
AppState.putIsListenerOn appState True
|
||||
@@ -60,7 +70,10 @@ retryingListen appState = do
|
||||
AppState.putNextListenerDelay appState 1
|
||||
|
||||
observer $ DBListenStart dbChannel
|
||||
SQL.waitForNotifications handleNotification db
|
||||
|
||||
-- wait for notifications
|
||||
-- this will never return, in case of an error it will throw and be caught by onError
|
||||
forever $ SQL.waitForNotifications handleNotification db
|
||||
|
||||
Left err -> do
|
||||
observer $ DBListenFail dbChannel (Left err)
|
||||
@@ -79,3 +92,5 @@ retryingListen appState = do
|
||||
|
||||
cacheReloader =
|
||||
AppState.schemaCacheLoader appState
|
||||
|
||||
releaseConnection = void . forkIO . handle (observer . DBListenerConnectionCleanupFail) . SQL.release
|
||||
|
||||
@@ -44,10 +44,11 @@ data Observation
|
||||
| SchemaCacheLoadedObs Double
|
||||
| ConnectionRetryObs Int
|
||||
| DBListenStart Text
|
||||
| DBListenFail Text (Either SQL.ConnectionError (Either SomeException ()))
|
||||
| DBListenFail Text (Either SQL.ConnectionError SomeException)
|
||||
| DBListenRetry Int
|
||||
| DBListenerGotSCacheMsg ByteString
|
||||
| DBListenerGotConfigMsg ByteString
|
||||
| DBListenerConnectionCleanupFail SomeException
|
||||
| QueryObs MainQuery Status
|
||||
| ConfigReadErrorObs SQL.UsageError
|
||||
| ConfigInvalidObs Text
|
||||
@@ -62,6 +63,7 @@ data Observation
|
||||
| PoolRequestFullfilled
|
||||
| JwtCacheLookup Bool
|
||||
| JwtCacheEviction
|
||||
| WarpErrorObs Text
|
||||
|
||||
data ObsFatalError = ServerAuthError | ServerPgrstBug | ServerError42P05 | ServerError08P01
|
||||
|
||||
@@ -118,6 +120,8 @@ observationMessage = \case
|
||||
"Received a schema cache reload message on the " <> show channel <> " channel"
|
||||
DBListenerGotConfigMsg channel ->
|
||||
"Received a config reload message on the " <> show channel <> " channel"
|
||||
DBListenerConnectionCleanupFail ex ->
|
||||
"Failed during listener connection cleanup: " <> showOnSingleLine '\t' (show ex)
|
||||
QueryObs{} ->
|
||||
mempty -- TODO pending refactor: The logic for printing the query cannot be done here. Join the observationMessage function into observationLogger to avoid this mempty.
|
||||
ConfigReadErrorObs usageErr ->
|
||||
@@ -154,9 +158,11 @@ observationMessage = \case
|
||||
"Looked up a JWT in JWT cache"
|
||||
JwtCacheEviction ->
|
||||
"Evicted entry from JWT cache"
|
||||
WarpErrorObs txt ->
|
||||
"Warp server error: " <> txt
|
||||
where
|
||||
showMillis :: Double -> Text
|
||||
showMillis x = toS $ showFFloat (Just 1) (x * 1000) ""
|
||||
showMillis x = toS $ showFFloat (Just 1) x ""
|
||||
|
||||
jsonMessage err = T.decodeUtf8 . LBS.toStrict . Error.errorPayload $ Error.PgError False err
|
||||
|
||||
@@ -164,9 +170,8 @@ observationMessage = \case
|
||||
showListenerConnError :: SQL.ConnectionError -> Text
|
||||
showListenerConnError = maybe "Connection error" (showOnSingleLine '\t' . T.decodeUtf8)
|
||||
|
||||
showListenerException :: Either SomeException () -> Text
|
||||
showListenerException (Right _) = "Failed getting notifications" -- should not happen as the listener will never finish (hasql-notifications uses `forever` internally) with a Right result
|
||||
showListenerException (Left e) = showOnSingleLine '\t' $ show e
|
||||
showListenerException :: SomeException -> Text
|
||||
showListenerException = showOnSingleLine '\t' . show
|
||||
|
||||
|
||||
showOnSingleLine :: Char -> Text -> Text
|
||||
|
||||
+18
-9
@@ -172,7 +172,7 @@ dbActionPlan dbAct conf apiReq sCache = case dbAct of
|
||||
|
||||
wrappedReadPlan :: QualifiedIdentifier -> AppConfig -> SchemaCache -> ApiRequest -> Bool -> Either Error CrudPlan
|
||||
wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Preferences{..},..} headersOnly = do
|
||||
qi <- findTable identifier (dbTables sCache)
|
||||
qi <- findTable identifier sCache
|
||||
rPlan <- readPlan qi conf sCache apiRequest
|
||||
(handler, mediaType) <- mapLeft ApiRequestError $ negotiateContent conf apiRequest qi iAcceptMediaType (dbMediaHandlers sCache) (hasDefaultSelect rPlan)
|
||||
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
|
||||
@@ -180,7 +180,7 @@ wrappedReadPlan identifier conf sCache apiRequest@ApiRequest{iPreferences=Prefe
|
||||
|
||||
mutateReadPlan :: Mutation -> ApiRequest -> QualifiedIdentifier -> AppConfig -> SchemaCache -> Either Error CrudPlan
|
||||
mutateReadPlan mutation apiRequest@ApiRequest{iPreferences=Preferences{..},..} identifier conf sCache = do
|
||||
qi <- findTable identifier (dbTables sCache)
|
||||
qi <- findTable identifier sCache
|
||||
rPlan <- readPlan qi conf sCache apiRequest
|
||||
mPlan <- mutatePlan mutation qi apiRequest sCache rPlan
|
||||
if not (null invalidPrefs) && preferHandling == Just Strict then Left $ ApiRequestError $ InvalidPreferences invalidPrefs else Right ()
|
||||
@@ -256,8 +256,10 @@ findProc qi argumentsKeys allProcs contentMediaType isInvPost =
|
||||
| hasSingleUnnamedParam proc = (ts,proc:fs)
|
||||
| otherwise = (ts,fs)
|
||||
-- If the function is called with post and has a single unnamed parameter
|
||||
-- it can be called depending on content type and the parameter type
|
||||
hasSingleUnnamedParam Function{pdParams=[RoutineParam{ppType}]} = isInvPost && case (contentMediaType, ppType) of
|
||||
-- it can be called depending on content type and the parameter type.
|
||||
-- The parameter must have no declared name (ppName == mempty).
|
||||
hasSingleUnnamedParam Function{pdParams=[RoutineParam{ppName, ppType}]} =
|
||||
isInvPost && ppName == mempty && case (contentMediaType, ppType) of
|
||||
(MTApplicationJSON, "json") -> True
|
||||
(MTApplicationJSON, "jsonb") -> True
|
||||
(MTTextPlain, "text") -> True
|
||||
@@ -810,10 +812,10 @@ validateAggFunctions aggFunctionsAllowed (Node rp@ReadPlan {select} forest)
|
||||
| otherwise = Node rp <$> traverse (validateAggFunctions aggFunctionsAllowed) forest
|
||||
|
||||
-- | Lookup table in the schema cache before creating read plan
|
||||
findTable :: QualifiedIdentifier -> TablesMap -> Either Error QualifiedIdentifier
|
||||
findTable qi@QualifiedIdentifier{..} tableMap =
|
||||
case HM.lookup qi tableMap of
|
||||
Nothing -> Left $ SchemaCacheErr $ TableNotFound qiSchema qiName (HM.elems tableMap)
|
||||
findTable :: QualifiedIdentifier -> SchemaCache -> Either Error QualifiedIdentifier
|
||||
findTable qi@QualifiedIdentifier{..} sc@SchemaCache{dbTables} =
|
||||
case HM.lookup qi dbTables of
|
||||
Nothing -> Left $ SchemaCacheErr $ TableNotFound qiSchema qiName sc
|
||||
Just _ -> Right qi
|
||||
|
||||
addFilters :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree
|
||||
@@ -963,10 +965,17 @@ addRanges ApiRequest{..} rReq =
|
||||
|
||||
addLogicTrees :: ResolverContext -> ApiRequest -> ReadPlanTree -> Either Error ReadPlanTree
|
||||
addLogicTrees ctx ApiRequest{..} rReq =
|
||||
foldr addLogicTreeToNode (Right rReq) qsLogic
|
||||
foldr addLogicTreeToNode (Right rReq) logic
|
||||
where
|
||||
QueryParams.QueryParams{..} = iQueryParams
|
||||
|
||||
logic =
|
||||
case iAction of
|
||||
ActDb (ActRelationRead _ _) -> qsLogic
|
||||
ActDb (ActRoutine _ _) -> qsLogic
|
||||
-- For mutations, take the non-root logic filters. These will only affect the embeddings and not the top level of the returned representation.
|
||||
_ -> filter (not . null . fst) qsLogic
|
||||
|
||||
addLogicTreeToNode :: (EmbedPath, LogicTree) -> Either Error ReadPlanTree -> Either Error ReadPlanTree
|
||||
addLogicTreeToNode = updateNode (\t (Node q@ReadPlan{from=fromTable, where_=lf} f) -> Node q{ReadPlan.where_=resolveLogicTree ctx{qi=fromTable} t:lf} f)
|
||||
|
||||
|
||||
@@ -6,13 +6,22 @@ Module : PostgREST.Query.SqlFragment
|
||||
Description : Helper functions for PostgREST.QueryBuilder.
|
||||
-}
|
||||
module PostgREST.Query.SqlFragment
|
||||
( noLocationF
|
||||
, handlerF
|
||||
( accessibleFuncs
|
||||
, accessibleTables
|
||||
, addConfigPgrstInserted
|
||||
, countF
|
||||
, groupF
|
||||
, currentSettingF
|
||||
, escapeIdent
|
||||
, escapeIdentList
|
||||
, explainF
|
||||
, fromJsonBodyF
|
||||
, fromQi
|
||||
, groupF
|
||||
, handlerF
|
||||
, intercalateSnippet
|
||||
, limitOffsetF
|
||||
, locationF
|
||||
, noLocationF
|
||||
, orderF
|
||||
, pgFmtColumn
|
||||
, pgFmtFilter
|
||||
@@ -21,28 +30,19 @@ module PostgREST.Query.SqlFragment
|
||||
, pgFmtLogicTree
|
||||
, pgFmtOrderTerm
|
||||
, pgFmtSelectItem
|
||||
, pgFmtSpreadSelectItem
|
||||
, pgFmtSpreadJoinSelectItem
|
||||
, fromJsonBodyF
|
||||
, pgFmtSpreadSelectItem
|
||||
, responseHeadersF
|
||||
, responseStatusF
|
||||
, addConfigPgrstInserted
|
||||
, currentSettingF
|
||||
, returningF
|
||||
, schemaDescription
|
||||
, setConfigWithConstantName
|
||||
, setConfigWithConstantNameJSON
|
||||
, setConfigWithDynamicName
|
||||
, singleParameter
|
||||
, sourceCTE
|
||||
, sourceCTEName
|
||||
, unknownEncoder
|
||||
, intercalateSnippet
|
||||
, explainF
|
||||
, setConfigWithConstantName
|
||||
, setConfigWithDynamicName
|
||||
, setConfigWithConstantNameJSON
|
||||
, escapeIdent
|
||||
, escapeIdentList
|
||||
, schemaDescription
|
||||
, accessibleTables
|
||||
, accessibleFuncs
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
@@ -90,7 +90,8 @@ import PostgREST.RangeQuery (NonnegRange, allRange,
|
||||
rangeLimit, rangeOffset)
|
||||
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||
QualifiedIdentifier (..),
|
||||
RelIdentifier (..))
|
||||
RelIdentifier (..),
|
||||
escapeIdent, trimNullChars)
|
||||
import PostgREST.SchemaCache.Routine (MediaHandler (..),
|
||||
Routine (..),
|
||||
funcReturnsScalar,
|
||||
@@ -163,9 +164,6 @@ pgBuildArrayLiteral vals =
|
||||
pgFmtIdent :: Text -> SQL.Snippet
|
||||
pgFmtIdent x = SQL.sql . encodeUtf8 $ escapeIdent x
|
||||
|
||||
escapeIdent :: Text -> Text
|
||||
escapeIdent x = "\"" <> T.replace "\"" "\"\"" (trimNullChars x) <> "\""
|
||||
|
||||
-- Only use it if the input comes from the database itself, like on `jsonb_build_object('column_from_a_table', val)..`
|
||||
pgFmtLit :: Text -> Text
|
||||
pgFmtLit x =
|
||||
@@ -176,9 +174,6 @@ pgFmtLit x =
|
||||
then "E" <> slashed
|
||||
else slashed
|
||||
|
||||
trimNullChars :: Text -> Text
|
||||
trimNullChars = T.takeWhile (/= '\x0')
|
||||
|
||||
-- |
|
||||
-- Format a list of identifiers and separate them by commas.
|
||||
--
|
||||
|
||||
+70
-44
@@ -62,11 +62,12 @@ data PgrstResponse = PgrstResponse {
|
||||
|
||||
actionResponse :: DbResult -> ApiRequest -> (Text, Text) -> AppConfig -> SchemaCache -> Schema -> Bool -> Either Error.Error PgrstResponse
|
||||
|
||||
actionResponse (DbCrudResult WrappedReadPlan{pMedia, wrHdrsOnly=headersOnly, crudQi=identifier} RSStandard{..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..},..} _ _ _ _ _ = do
|
||||
actionResponse (DbCrudResult plan@WrappedReadPlan{pMedia, wrHdrsOnly=headersOnly, crudQi=identifier} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
|
||||
let
|
||||
(status, contentRange) = RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
|
||||
cLHeader = if headersOnly then mempty else [contentLengthHeaderStrict rsBody]
|
||||
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone Nothing []
|
||||
cLHeader = if headersOnly then mempty else [ contentLengthHeader bod ]
|
||||
prefHeader = maybeToList . prefAppliedHeader $ responsePreferences plan ctxApiRequest
|
||||
|
||||
headers =
|
||||
[ contentRange
|
||||
, ( "Content-Location"
|
||||
@@ -87,12 +88,10 @@ actionResponse (DbCrudResult WrappedReadPlan{pMedia, wrHdrsOnly=headersOnly, cru
|
||||
|
||||
Right $ PgrstResponse ovStatus ovHeaders bod
|
||||
|
||||
actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationCreate, mrMutatePlan, pMedia, crudQi=QualifiedIdentifier{..}} RSStandard{..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}, ..} _ _ _ _ _ = do
|
||||
actionResponse (DbCrudResult plan@MutateReadPlan{mrMutation=MutationCreate, pMedia, crudQi=QualifiedIdentifier{..}} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
|
||||
let
|
||||
pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols; _ -> mempty;}
|
||||
prefHeader = prefAppliedHeader $
|
||||
Preferences (if null pkCols && isNothing (qsOnConflict iQueryParams) then Nothing else preferResolution)
|
||||
preferRepresentation preferCount preferTransaction preferMissing preferHandling preferTimezone Nothing []
|
||||
prefHeader = prefAppliedHeader $ responsePreferences plan ctxApiRequest
|
||||
|
||||
headers =
|
||||
catMaybes
|
||||
[ if null rsLocation then
|
||||
@@ -105,37 +104,39 @@ actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationCreate, mrMutateP
|
||||
<> HTTP.renderSimpleQuery True rsLocation
|
||||
)
|
||||
, Just . RangeQuery.contentRangeH 1 0 $
|
||||
if shouldCount preferCount then Just rsQueryTotal else Nothing
|
||||
, Just $ contentLengthHeaderStrict rsBody
|
||||
if shouldCount (preferCount iPreferences) then Just rsQueryTotal else Nothing
|
||||
, prefHeader ]
|
||||
|
||||
isInsertIfGTZero i =
|
||||
if i <= 0 && preferResolution == Just MergeDuplicates then
|
||||
if i <= 0 && preferResolution iPreferences == Just MergeDuplicates then
|
||||
HTTP.status200
|
||||
else
|
||||
HTTP.status201
|
||||
status = maybe HTTP.status200 isInsertIfGTZero rsInserted
|
||||
(headers', bod) = case preferRepresentation of
|
||||
(headers', bod) = case preferRepresentation iPreferences of
|
||||
Just Full -> (headers ++ contentTypeHeaders pMedia ctxApiRequest, LBS.fromStrict rsBody)
|
||||
Just None -> (headers, mempty)
|
||||
Just HeadersOnly -> (headers, mempty)
|
||||
Nothing -> (headers, mempty)
|
||||
|
||||
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers'
|
||||
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status $ contentLengthHeader bod:headers'
|
||||
|
||||
Right $ PgrstResponse ovStatus ovHeaders bod
|
||||
|
||||
actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationUpdate, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} _ _ _ _ _ = do
|
||||
actionResponse (DbCrudResult plan@MutateReadPlan{mrMutation=MutationUpdate, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
|
||||
let
|
||||
contentRangeHeader =
|
||||
Just . RangeQuery.contentRangeH 0 (rsQueryTotal - 1) $
|
||||
if shouldCount preferCount then Just rsQueryTotal else Nothing
|
||||
prefHeader = prefAppliedHeader $ Preferences Nothing preferRepresentation preferCount preferTransaction preferMissing preferHandling preferTimezone preferMaxAffected []
|
||||
if shouldCount (preferCount iPreferences) then Just rsQueryTotal else Nothing
|
||||
|
||||
prefHeader = prefAppliedHeader $ responsePreferences plan ctxApiRequest
|
||||
|
||||
headers = catMaybes [contentRangeHeader, prefHeader]
|
||||
lbsBody = LBS.fromStrict rsBody
|
||||
|
||||
let (status, headers', body) =
|
||||
case preferRepresentation of
|
||||
Just Full -> (HTTP.status200, headers ++ [contentLengthHeaderStrict rsBody] ++ contentTypeHeaders pMedia ctxApiRequest, LBS.fromStrict rsBody)
|
||||
case preferRepresentation iPreferences of
|
||||
Just Full -> (HTTP.status200, headers ++ [contentLengthHeader lbsBody] ++ contentTypeHeaders pMedia ctxApiRequest, lbsBody)
|
||||
Just None -> (HTTP.status204, headers, mempty)
|
||||
_ -> (HTTP.status204, headers, mempty)
|
||||
|
||||
@@ -143,31 +144,33 @@ actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationUpdate, pMedia} R
|
||||
|
||||
Right $ PgrstResponse ovStatus ovHeaders body
|
||||
|
||||
actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationSingleUpsert, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} _ _ _ _ _ = do
|
||||
actionResponse (DbCrudResult plan@MutateReadPlan{mrMutation=MutationSingleUpsert, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
|
||||
let
|
||||
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation preferCount preferTransaction Nothing preferHandling preferTimezone Nothing []
|
||||
cLHeader = [contentLengthHeaderStrict rsBody]
|
||||
prefHeader = maybeToList . prefAppliedHeader $ responsePreferences plan ctxApiRequest
|
||||
lbsBody = LBS.fromStrict rsBody
|
||||
cLHeader = [contentLengthHeader lbsBody]
|
||||
cTHeader = contentTypeHeaders pMedia ctxApiRequest
|
||||
|
||||
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, cLHeader ++ cTHeader ++ prefHeader, LBS.fromStrict rsBody)
|
||||
case preferRepresentation iPreferences of
|
||||
Just Full -> (upsertStatus, cLHeader ++ cTHeader ++ prefHeader, lbsBody)
|
||||
Just None -> (HTTP.status204, prefHeader, mempty)
|
||||
_ -> (HTTP.status204, prefHeader, mempty)
|
||||
(ovStatus, ovHeaders) <- overrideStatusHeaders rsGucStatus rsGucHeaders status headers
|
||||
|
||||
Right $ PgrstResponse ovStatus ovHeaders body
|
||||
|
||||
actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationDelete, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..}} _ _ _ _ _ = do
|
||||
actionResponse (DbCrudResult plan@MutateReadPlan{mrMutation=MutationDelete, pMedia} RSStandard{..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
|
||||
let
|
||||
contentRangeHeader = RangeQuery.contentRangeH 1 0 $ if shouldCount preferCount then Just rsQueryTotal else Nothing
|
||||
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing preferRepresentation preferCount preferTransaction Nothing preferHandling preferTimezone preferMaxAffected []
|
||||
contentRangeHeader = RangeQuery.contentRangeH 1 0 $ if shouldCount (preferCount iPreferences) then Just rsQueryTotal else Nothing
|
||||
prefHeader = maybeToList . prefAppliedHeader $ responsePreferences plan ctxApiRequest
|
||||
headers = contentRangeHeader : prefHeader
|
||||
lbsBody = LBS.fromStrict rsBody
|
||||
(status, headers', body) =
|
||||
case preferRepresentation of
|
||||
Just Full -> (HTTP.status200, headers ++ [contentLengthHeaderStrict rsBody] ++ contentTypeHeaders pMedia ctxApiRequest, LBS.fromStrict rsBody)
|
||||
case preferRepresentation iPreferences of
|
||||
Just Full -> (HTTP.status200, headers ++ [contentLengthHeader lbsBody] ++ contentTypeHeaders pMedia ctxApiRequest, lbsBody)
|
||||
Just None -> (HTTP.status204, headers, mempty)
|
||||
_ -> (HTTP.status204, headers, mempty)
|
||||
|
||||
@@ -175,7 +178,7 @@ actionResponse (DbCrudResult MutateReadPlan{mrMutation=MutationDelete, pMedia} R
|
||||
|
||||
Right $ PgrstResponse ovStatus ovHeaders body
|
||||
|
||||
actionResponse (DbCrudResult CallReadPlan{pMedia, crInvMthd=invMethod, crProc=proc} RSStandard {..}) ctxApiRequest@ApiRequest{iPreferences=Preferences{..},..} _ _ _ _ _ = do
|
||||
actionResponse (DbCrudResult plan@CallReadPlan{pMedia, crInvMthd=invMethod, crProc=proc} RSStandard {..}) ctxApiRequest@ApiRequest{..} _ _ _ _ _ = do
|
||||
let
|
||||
(status, contentRange) =
|
||||
RangeQuery.rangeStatusHeader iTopLevelRange rsQueryTotal rsTableTotal
|
||||
@@ -184,8 +187,8 @@ actionResponse (DbCrudResult CallReadPlan{pMedia, crInvMthd=invMethod, crProc=pr
|
||||
$ Error.OutOfBounds (show $ RangeQuery.rangeOffset iTopLevelRange) (maybe "0" show rsTableTotal)
|
||||
else LBS.fromStrict rsBody
|
||||
isHeadMethod = invMethod == InvRead True
|
||||
prefHeader = maybeToList . prefAppliedHeader $ Preferences Nothing Nothing preferCount preferTransaction Nothing preferHandling preferTimezone preferMaxAffected []
|
||||
cLHeader = if isHeadMethod then mempty else [contentLengthHeaderLazy rsOrErrBody]
|
||||
prefHeader = maybeToList . prefAppliedHeader $ responsePreferences plan ctxApiRequest
|
||||
cLHeader = if isHeadMethod then mempty else [contentLengthHeader rsOrErrBody]
|
||||
headers = contentRange : prefHeader
|
||||
(status', headers', body) =
|
||||
if Routine.funcReturnsVoid proc then
|
||||
@@ -200,19 +203,20 @@ actionResponse (DbCrudResult CallReadPlan{pMedia, crInvMthd=invMethod, crProc=pr
|
||||
Right $ PgrstResponse ovStatus ovHeaders body
|
||||
|
||||
actionResponse (DbPlanResult media plan) ctxApiRequest _ _ _ _ _ =
|
||||
Right $ PgrstResponse HTTP.status200 (contentLengthHeaderStrict plan : contentTypeHeaders media ctxApiRequest) $ LBS.fromStrict plan
|
||||
let body = LBS.fromStrict plan in
|
||||
Right $ PgrstResponse HTTP.status200 (contentLengthHeader body : contentTypeHeaders media ctxApiRequest) body
|
||||
|
||||
actionResponse (MaybeDbResult InspectPlan{ipHdrsOnly=headersOnly} body) _ versions conf sCache schema negotiatedByProfile =
|
||||
let
|
||||
rsBody = maybe mempty (\(x, y, z) -> if headersOnly then mempty else OpenAPI.encode versions conf sCache x y z) body
|
||||
cLHeader = if headersOnly then mempty else [contentLengthHeaderLazy rsBody]
|
||||
cLHeader = if headersOnly then mempty else [contentLengthHeader rsBody]
|
||||
in
|
||||
Right $ PgrstResponse HTTP.status200 (MediaType.toContentType MTOpenAPI : cLHeader ++ maybeToList (profileHeader schema negotiatedByProfile)) rsBody
|
||||
|
||||
actionResponse (NoDbResult (RelInfoPlan qi@QualifiedIdentifier{..})) _ _ _ SchemaCache{dbTables} _ _ =
|
||||
actionResponse (NoDbResult (RelInfoPlan qi@QualifiedIdentifier{..})) _ _ _ sc@SchemaCache{dbTables} _ _ =
|
||||
case HM.lookup qi dbTables of
|
||||
Just tbl -> respondInfo $ allowH tbl
|
||||
Nothing -> Left $ Error.SchemaCacheErr $ Error.TableNotFound qiSchema qiName (HM.elems dbTables)
|
||||
Nothing -> Left $ Error.SchemaCacheErr $ Error.TableNotFound qiSchema qiName sc
|
||||
where
|
||||
allowH table =
|
||||
let hasPK = not . null $ tablePKCols table in
|
||||
@@ -232,7 +236,7 @@ actionResponse (NoDbResult SchemaInfoPlan) _ _ _ _ _ _ = respondInfo "OPTIONS,GE
|
||||
respondInfo :: ByteString -> Either Error.Error PgrstResponse
|
||||
respondInfo allowHeader =
|
||||
let allOrigins = ("Access-Control-Allow-Origin", "*") in
|
||||
Right $ PgrstResponse HTTP.status200 [contentLengthHeaderStrict mempty, allOrigins, (HTTP.hAllow, allowHeader)] mempty
|
||||
Right $ PgrstResponse HTTP.status200 [contentLengthHeader mempty, allOrigins, (HTTP.hAllow, allowHeader)] mempty
|
||||
|
||||
-- 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])
|
||||
@@ -249,14 +253,8 @@ decodeGucStatus :: Maybe Text -> Either Error.Error (Maybe HTTP.Status)
|
||||
decodeGucStatus =
|
||||
maybe (Right Nothing) $ first (const . Error.ApiRequestError $ Error.GucStatusError) . fmap (Just . toEnum . fst) . decimal
|
||||
|
||||
contentLengthHeader :: Show b => (a -> b) -> a -> HTTP.Header
|
||||
contentLengthHeader lenFn body = ("Content-Length", show (lenFn body))
|
||||
|
||||
contentLengthHeaderStrict :: BS.ByteString -> HTTP.Header
|
||||
contentLengthHeaderStrict = contentLengthHeader BS.length
|
||||
|
||||
contentLengthHeaderLazy :: LBS.ByteString -> HTTP.Header
|
||||
contentLengthHeaderLazy = contentLengthHeader LBS.length
|
||||
contentLengthHeader :: LBS.ByteString -> HTTP.Header
|
||||
contentLengthHeader body = ("Content-Length", show (LBS.length body))
|
||||
|
||||
contentTypeHeaders :: MediaType -> ApiRequest -> [HTTP.Header]
|
||||
contentTypeHeaders mediaType ApiRequest{..} =
|
||||
@@ -274,3 +272,31 @@ addHeadersIfNotIncluded :: [HTTP.Header] -> [HTTP.Header] -> [HTTP.Header]
|
||||
addHeadersIfNotIncluded newHeaders initialHeaders =
|
||||
filter (\(nk, _) -> isNothing $ find (\(ik, _) -> ik == nk) initialHeaders) newHeaders ++
|
||||
initialHeaders
|
||||
|
||||
-- | Get Preferences for Preference-Applied header per plan
|
||||
responsePreferences :: CrudPlan -> ApiRequest -> Preferences
|
||||
responsePreferences plan ApiRequest{iPreferences=Preferences{..}, iQueryParams=QueryParams{..}} =
|
||||
let
|
||||
-- Only returned on Inserts
|
||||
preferResolution' = case plan of
|
||||
MutateReadPlan{mrMutation=MutationCreate, mrMutatePlan} ->
|
||||
let pkCols = case mrMutatePlan of { Insert{insPkCols} -> insPkCols ; _ -> mempty; }
|
||||
in (if null pkCols && isNothing qsOnConflict then Nothing else preferResolution)
|
||||
_ -> Nothing
|
||||
|
||||
preferRepresentation' = case plan of
|
||||
MutateReadPlan{} -> preferRepresentation
|
||||
_ -> Nothing
|
||||
|
||||
preferMissing' = case plan of
|
||||
MutateReadPlan{mrMutation=MutationCreate} -> preferMissing
|
||||
MutateReadPlan{mrMutation=MutationUpdate} -> preferMissing
|
||||
_ -> Nothing
|
||||
|
||||
preferMaxAffected' = case plan of
|
||||
MutateReadPlan{mrMutation=MutationUpdate} -> preferMaxAffected
|
||||
MutateReadPlan{mrMutation=MutationDelete} -> preferMaxAffected
|
||||
CallReadPlan{} -> preferMaxAffected
|
||||
_ -> Nothing
|
||||
|
||||
in Preferences preferResolution' preferRepresentation' preferCount preferTransaction preferMissing' preferHandling preferTimezone preferMaxAffected' []
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{-# LANGUAGE NumericUnderscores #-}
|
||||
module PostgREST.Response.Performance
|
||||
( ServerTiming (..)
|
||||
, serverTimingHeader
|
||||
@@ -24,12 +23,12 @@ data ServerTiming =
|
||||
-- The duration precision is milliseconds, per the docs
|
||||
--
|
||||
-- >>> 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=400.0, parse;dur=500.0, plan;dur=100.0, transaction;dur=200.0, response;dur=300.0")
|
||||
-- ("Server-Timing","jwt;dur=0.4, parse;dur=0.5, plan;dur=0.1, transaction;dur=0.2, response;dur=0.3")
|
||||
serverTimingHeader :: ServerTiming -> HTTP.Header
|
||||
serverTimingHeader timing =
|
||||
("Server-Timing", renderTiming)
|
||||
where
|
||||
renderMetric metric = maybe "" (\dur -> BS.concat [metric, BS.pack $ ";dur=" <> showFFloat (Just 1) (dur * 1_000) ""])
|
||||
renderMetric metric = maybe "" (\dur -> BS.concat [metric, BS.pack $ ";dur=" <> showFFloat (Just 1) dur ""])
|
||||
renderTiming = BS.intercalate ", " $ (\(k, v) -> renderMetric k (v timing)) <$>
|
||||
[ ("jwt", jwt)
|
||||
, ("parse", parse)
|
||||
|
||||
@@ -20,6 +20,7 @@ These queries are executed once at startup or when PostgREST is reloaded.
|
||||
|
||||
module PostgREST.SchemaCache
|
||||
( SchemaCache(..)
|
||||
, TablesFuzzyIndex
|
||||
, querySchemaCache
|
||||
, showSummary
|
||||
, decodeFuncs
|
||||
@@ -42,11 +43,11 @@ import NeatInterpolation (trimming)
|
||||
import PostgREST.Config (AppConfig (..))
|
||||
import PostgREST.Config.Database (TimezoneNames,
|
||||
toIsolationLevel)
|
||||
import PostgREST.Query.SqlFragment (escapeIdent)
|
||||
import PostgREST.SchemaCache.Identifiers (FieldName,
|
||||
QualifiedIdentifier (..),
|
||||
RelIdentifier (..),
|
||||
Schema, isAnyElement)
|
||||
Schema, escapeIdent,
|
||||
isAnyElement)
|
||||
import PostgREST.SchemaCache.Relationship (Cardinality (..),
|
||||
Junction (..),
|
||||
Relationship (..),
|
||||
@@ -67,9 +68,12 @@ import PostgREST.SchemaCache.Table (Column (..), ColumnMap,
|
||||
import qualified PostgREST.MediaType as MediaType
|
||||
|
||||
import Control.Arrow ((&&&))
|
||||
import qualified Data.FuzzySet as Fuzzy
|
||||
import Protolude
|
||||
import System.IO.Unsafe (unsafePerformIO)
|
||||
|
||||
type TablesFuzzyIndex = HM.HashMap Schema Fuzzy.FuzzySet
|
||||
|
||||
data SchemaCache = SchemaCache
|
||||
{ dbTables :: TablesMap
|
||||
, dbRelationships :: RelationshipsMap
|
||||
@@ -77,10 +81,14 @@ data SchemaCache = SchemaCache
|
||||
, dbRepresentations :: RepresentationsMap
|
||||
, dbMediaHandlers :: MediaHandlerMap
|
||||
, dbTimezones :: TimezoneNames
|
||||
}
|
||||
-- Memoized fuzzy index of table names per schema to support approximate matching
|
||||
-- Since index construction can be expensive, we build it once and store in the SchemaCache
|
||||
-- Haskell lazy evaluation ensures it's only built on first use and memoized afterwards
|
||||
, dbTablesFuzzyIndex :: TablesFuzzyIndex
|
||||
} deriving (Show)
|
||||
|
||||
instance JSON.ToJSON SchemaCache where
|
||||
toJSON (SchemaCache tabs rels routs reps hdlers tzs) = JSON.object [
|
||||
toJSON (SchemaCache tabs rels routs reps hdlers tzs _) = JSON.object [
|
||||
"dbTables" .= JSON.toJSON tabs
|
||||
, "dbRelationships" .= JSON.toJSON rels
|
||||
, "dbRoutines" .= JSON.toJSON routs
|
||||
@@ -90,7 +98,7 @@ instance JSON.ToJSON SchemaCache where
|
||||
]
|
||||
|
||||
showSummary :: SchemaCache -> Text
|
||||
showSummary (SchemaCache tbls rels routs reps mediaHdlrs tzs) =
|
||||
showSummary (SchemaCache tbls rels routs reps mediaHdlrs tzs _) =
|
||||
T.intercalate ", "
|
||||
[ show (HM.size tbls) <> " Relations"
|
||||
, show (HM.size rels) <> " Relationships"
|
||||
@@ -138,6 +146,8 @@ data KeyDep
|
||||
-- | A SQL query that can be executed independently
|
||||
type SqlQuery = ByteString
|
||||
|
||||
maxDbTablesForFuzzySearch :: Int
|
||||
maxDbTablesForFuzzySearch = 500
|
||||
|
||||
querySchemaCache :: AppConfig -> SQL.Transaction SchemaCache
|
||||
querySchemaCache conf@AppConfig{..} = do
|
||||
@@ -166,6 +176,11 @@ querySchemaCache conf@AppConfig{..} = do
|
||||
, dbRepresentations = reps
|
||||
, dbMediaHandlers = HM.union mHdlers initialMediaHandlers -- the custom handlers will override the initial ones
|
||||
, dbTimezones = tzones
|
||||
|
||||
, dbTablesFuzzyIndex =
|
||||
-- Only build fuzzy index for schemas with a reasonable number of tables
|
||||
-- Fuzzy.FuzzySet is memory heavy we just don't use it for large schemas
|
||||
Fuzzy.fromList <$> HM.filter ((< maxDbTablesForFuzzySearch) . length) (HM.fromListWith (<>) ((qiSchema &&& pure . qiName) <$> HM.keys tabsWViewsPks))
|
||||
}
|
||||
where
|
||||
schemas = toList configDbSchemas
|
||||
@@ -203,6 +218,7 @@ removeInternal schemas dbStruct =
|
||||
, dbRepresentations = dbRepresentations dbStruct -- no need to filter, not directly exposed through the API
|
||||
, dbMediaHandlers = dbMediaHandlers dbStruct
|
||||
, dbTimezones = dbTimezones dbStruct
|
||||
, dbTablesFuzzyIndex = dbTablesFuzzyIndex dbStruct
|
||||
}
|
||||
where
|
||||
hasInternalJunction ComputedRelationship{} = False
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
{-# LANGUAGE DeriveGeneric #-}
|
||||
|
||||
module PostgREST.SchemaCache.Identifiers
|
||||
( QualifiedIdentifier(..)
|
||||
( FieldName
|
||||
, QualifiedIdentifier(..)
|
||||
, RelIdentifier(..)
|
||||
, isAnyElement
|
||||
, Schema
|
||||
, TableName
|
||||
, FieldName
|
||||
, dumpQi
|
||||
, escapeIdent
|
||||
, isAnyElement
|
||||
, quoteQi
|
||||
, toQi
|
||||
, trimNullChars
|
||||
) where
|
||||
|
||||
import qualified Data.Aeson as JSON
|
||||
@@ -39,6 +42,10 @@ dumpQi :: QualifiedIdentifier -> Text
|
||||
dumpQi (QualifiedIdentifier s i) =
|
||||
(if T.null s then mempty else s <> ".") <> i
|
||||
|
||||
quoteQi :: QualifiedIdentifier -> Text
|
||||
quoteQi (QualifiedIdentifier s i) =
|
||||
(if T.null s then mempty else escapeIdent s <> ".") <> escapeIdent i
|
||||
|
||||
-- TODO: Handle a case where the QI comes like this: "my.fav.schema"."my.identifier"
|
||||
-- Right now it only handles the schema.identifier case
|
||||
toQi :: Text -> QualifiedIdentifier
|
||||
@@ -46,6 +53,12 @@ toQi txt = case T.drop 1 <$> T.breakOn "." txt of
|
||||
(i, "") -> QualifiedIdentifier mempty i
|
||||
(s, i) -> QualifiedIdentifier s i
|
||||
|
||||
escapeIdent :: Text -> Text
|
||||
escapeIdent x = "\"" <> T.replace "\"" "\"\"" (trimNullChars x) <> "\""
|
||||
|
||||
trimNullChars :: Text -> Text
|
||||
trimNullChars = T.takeWhile (/= '\x0')
|
||||
|
||||
type Schema = Text
|
||||
type TableName = Text
|
||||
type FieldName = Text
|
||||
|
||||
@@ -90,7 +90,7 @@ data RoutineParam = RoutineParam
|
||||
instance Ord Routine where
|
||||
Function schema1 name1 des1 prms1 rt1 vol1 hasVar1 iso1 sets1 `compare` Function schema2 name2 des2 prms2 rt2 vol2 hasVar2 iso2 sets2
|
||||
| schema1 == schema2 && name1 == name2 && length prms1 < length prms2 = LT
|
||||
| schema2 == schema2 && name1 == name2 && length prms1 > length prms2 = GT
|
||||
| schema1 == schema2 && name1 == name2 && length prms1 > length prms2 = GT
|
||||
| otherwise = (schema1, name1, des1, prms1, rt1, vol1, hasVar1, iso1, sets1) `compare` (schema2, name2, des2, prms2, rt2, vol2, hasVar2, iso2, sets2)
|
||||
|
||||
-- | A map of all procs, all of which can be overloaded(one entry will have more than one Routine).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
module PostgREST.TimeIt
|
||||
( timeItT
|
||||
) where
|
||||
|
||||
import GHC.Clock
|
||||
import Protolude
|
||||
|
||||
{-
|
||||
- The signature is the same as https://hackage.haskell.org/package/timeit-2.0/docs/src/System-TimeIt.html#timeIt,
|
||||
- we vendor this functionality because it gave errors as shown on https://github.com/PostgREST/postgrest/issues/4522 plus
|
||||
- the function is small enough. This vendored function is different in that the result is in milliseconds.
|
||||
-}
|
||||
timeItT :: MonadIO m => m a -> m (Double, a)
|
||||
timeItT p = do
|
||||
s <- liftIO getMonotonicTime
|
||||
x <- p
|
||||
e <- liftIO getMonotonicTime
|
||||
let time = (e - s) * 1000
|
||||
return (time, x)
|
||||
|
||||
@@ -14,3 +14,4 @@ extra-deps:
|
||||
- hasql-pool-1.0.1
|
||||
- jose-jwt-0.10.0
|
||||
- postgresql-libpq-0.10.1.0
|
||||
- streaming-commons-0.2.3.1
|
||||
|
||||
@@ -39,6 +39,13 @@ packages:
|
||||
size: 1096
|
||||
original:
|
||||
hackage: postgresql-libpq-0.10.1.0
|
||||
- completed:
|
||||
hackage: streaming-commons-0.2.3.1@sha256:ed7999fea9e912b1211ea93d7e20a7998bf4753166370c94048885650f303bf0,4841
|
||||
pantry-tree:
|
||||
sha256: f08e83fb00fd45865fa8cfdef5ecef7161d5135257ee98050bfbe4b807ad65f8
|
||||
size: 2374
|
||||
original:
|
||||
hackage: streaming-commons-0.2.3.1
|
||||
snapshots:
|
||||
- completed:
|
||||
sha256: 238fa745b64f91184f9aa518fe04bdde6552533d169b0da5256670df83a0f1a9
|
||||
|
||||
@@ -1 +1,39 @@
|
||||
[]
|
||||
- - - qiName: directors
|
||||
qiSchema: public
|
||||
- public
|
||||
- - relCardinality:
|
||||
relColumns:
|
||||
- - id
|
||||
- director_id
|
||||
relCons: fk_director
|
||||
tag: O2M
|
||||
relFTableIsView: false
|
||||
relForeignTable:
|
||||
qiName: films
|
||||
qiSchema: public
|
||||
relIsSelf: false
|
||||
relTable:
|
||||
qiName: directors
|
||||
qiSchema: public
|
||||
relTableIsView: false
|
||||
tag: Relationship
|
||||
|
||||
- - - qiName: films
|
||||
qiSchema: public
|
||||
- public
|
||||
- - relCardinality:
|
||||
relColumns:
|
||||
- - director_id
|
||||
- id
|
||||
relCons: fk_director
|
||||
tag: M2O
|
||||
relFTableIsView: false
|
||||
relForeignTable:
|
||||
qiName: directors
|
||||
qiSchema: public
|
||||
relIsSelf: false
|
||||
relTable:
|
||||
qiName: films
|
||||
qiSchema: public
|
||||
relTableIsView: false
|
||||
tag: Relationship
|
||||
|
||||
@@ -106,6 +106,23 @@
|
||||
pdSchema: public
|
||||
pdVolatility: Volatile
|
||||
|
||||
- - qiName: notify_pgrst
|
||||
qiSchema: public
|
||||
- - pdDescription: null
|
||||
pdFuncSettings: []
|
||||
pdHasVariadic: false
|
||||
pdName: notify_pgrst
|
||||
pdParams: []
|
||||
pdReturnType:
|
||||
contents:
|
||||
contents:
|
||||
qiName: void
|
||||
qiSchema: pg_catalog
|
||||
tag: Scalar
|
||||
tag: Single
|
||||
pdSchema: public
|
||||
pdVolatility: Volatile
|
||||
|
||||
- - qiName: migrate_function
|
||||
qiSchema: public
|
||||
- - pdDescription: null
|
||||
@@ -461,6 +478,23 @@
|
||||
pdSchema: public
|
||||
pdVolatility: Volatile
|
||||
|
||||
- - qiName: 'true'
|
||||
qiSchema: public
|
||||
- - pdDescription: null
|
||||
pdFuncSettings: []
|
||||
pdHasVariadic: false
|
||||
pdName: 'true'
|
||||
pdParams: []
|
||||
pdReturnType:
|
||||
contents:
|
||||
contents:
|
||||
qiName: bool
|
||||
qiSchema: pg_catalog
|
||||
tag: Scalar
|
||||
tag: Single
|
||||
pdSchema: public
|
||||
pdVolatility: Volatile
|
||||
|
||||
- - qiName: create_function
|
||||
qiSchema: public
|
||||
- - pdDescription: null
|
||||
|
||||
@@ -71,6 +71,37 @@
|
||||
tableSchema: public
|
||||
tableUpdatable: true
|
||||
|
||||
- - qiName: directors
|
||||
qiSchema: public
|
||||
- tableColumns:
|
||||
id:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: id
|
||||
colNominalType: integer
|
||||
colNullable: false
|
||||
colType: integer
|
||||
name:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: name
|
||||
colNominalType: text
|
||||
colNullable: true
|
||||
colType: text
|
||||
tableDeletable: true
|
||||
tableDescription: null
|
||||
tableInsertable: true
|
||||
tableIsView: false
|
||||
tableName: directors
|
||||
tablePKCols:
|
||||
- id
|
||||
tableSchema: public
|
||||
tableUpdatable: true
|
||||
|
||||
- - qiName: projects
|
||||
qiSchema: public
|
||||
- tableColumns: {}
|
||||
@@ -95,6 +126,46 @@
|
||||
tableSchema: public
|
||||
tableUpdatable: false
|
||||
|
||||
- - qiName: films
|
||||
qiSchema: public
|
||||
- tableColumns:
|
||||
director_id:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: director_id
|
||||
colNominalType: integer
|
||||
colNullable: true
|
||||
colType: integer
|
||||
id:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: id
|
||||
colNominalType: integer
|
||||
colNullable: false
|
||||
colType: integer
|
||||
title:
|
||||
colDefault: null
|
||||
colDescription: null
|
||||
colEnum: []
|
||||
colMaxLen: null
|
||||
colName: title
|
||||
colNominalType: text
|
||||
colNullable: true
|
||||
colType: text
|
||||
tableDeletable: true
|
||||
tableDescription: null
|
||||
tableInsertable: true
|
||||
tableIsView: false
|
||||
tableName: films
|
||||
tablePKCols:
|
||||
- id
|
||||
tableSchema: public
|
||||
tableUpdatable: true
|
||||
|
||||
- - qiName: items
|
||||
qiSchema: public
|
||||
- tableColumns:
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@ import yaml
|
||||
|
||||
BASEDIR = pathlib.Path(os.path.realpath(__file__)).parent
|
||||
CONFIGSDIR = BASEDIR / "configs"
|
||||
FIXTURES = yaml.load((BASEDIR / "fixtures.yaml").read_text(), Loader=yaml.Loader)
|
||||
FIXTURES = yaml.load(
|
||||
(BASEDIR / "fixtures/fixtures.yaml").read_text(), Loader=yaml.Loader
|
||||
)
|
||||
POSTGREST_BIN = shutil.which("postgrest")
|
||||
SECRET = "reallyreallyreallyreallyverysafe"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ We use it to test our metadata generation because it contains a good amount of d
|
||||
|
||||
Custom roles and privileges were removed.
|
||||
|
||||
postgrest-with-postgresql-14 -f test/io/big_schema.sql psql
|
||||
postgrest-with-pg-14 -f test/io/big_schema.sql psql
|
||||
|
||||
Has 12 functions:
|
||||
|
||||
@@ -11375,12 +11375,34 @@ ALTER TABLE ONLY apflora.zielber
|
||||
|
||||
ALTER TABLE apflora."user" ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
|
||||
CREATE SCHEMA fuzzysearch;
|
||||
|
||||
-- Create many tables to test fuzzy string search
|
||||
-- computing hints for non existing tables
|
||||
DO
|
||||
$$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT
|
||||
format('CREATE TABLE fuzzysearch.unknown_table_%s ()', n) AS ct
|
||||
FROM
|
||||
generate_series(1, 499) n
|
||||
LOOP
|
||||
EXECUTE r.ct;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
DROP ROLE IF EXISTS postgrest_test_anonymous;
|
||||
CREATE ROLE postgrest_test_anonymous;
|
||||
|
||||
GRANT postgrest_test_anonymous TO :PGUSER;
|
||||
|
||||
GRANT USAGE ON SCHEMA apflora TO postgrest_test_anonymous;
|
||||
GRANT USAGE ON SCHEMA fuzzysearch TO postgrest_test_anonymous;
|
||||
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA apflora
|
||||
TO postgrest_test_anonymous;
|
||||
@@ -256,3 +256,45 @@ select * from projects;
|
||||
|
||||
create or replace view infinite_recursion as
|
||||
select * from infinite_recursion;
|
||||
|
||||
create or replace function "true"() returns boolean as $_$
|
||||
select true;
|
||||
$_$ language sql;
|
||||
|
||||
create or replace function notify_pgrst() returns void as $$
|
||||
notify pgrst;
|
||||
$$ language sql;
|
||||
|
||||
-- directors and films table can be used for resource embedding tests
|
||||
create table directors (
|
||||
id int primary key,
|
||||
name text
|
||||
);
|
||||
|
||||
create table films (
|
||||
id int primary key,
|
||||
title text,
|
||||
director_id int,
|
||||
|
||||
constraint fk_director
|
||||
foreign key (director_id) references directors (id)
|
||||
on update cascade
|
||||
on delete cascade
|
||||
);
|
||||
|
||||
-- data to test resource embedding
|
||||
truncate table directors cascade;
|
||||
insert into directors
|
||||
values (1, 'quentin tarantino'),
|
||||
(2, 'christopher nolan'),
|
||||
(3, 'yorgos lathinmos');
|
||||
|
||||
truncate table films cascade;
|
||||
insert into films
|
||||
values (1, 'pulp fiction', 1),
|
||||
(2, 'intersteller',2),
|
||||
(3, 'dogtooth',3),
|
||||
(4, 'reservoir dogs', 1);
|
||||
|
||||
|
||||
GRANT SELECT ON directors, films TO postgrest_test_anonymous, postgrest_test_w_superuser_settings;
|
||||
+15
-7
@@ -94,20 +94,28 @@ def run(
|
||||
"Run PostgREST and yield an endpoint that is ready for connections."
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
|
||||
# with python requests, "localhost" doesn't automatically resolves
|
||||
# to [::1], hence we use this explicitly when host is ipv6 special
|
||||
# address
|
||||
ipv6_special_addresses = ["*6", "!6"]
|
||||
|
||||
localhost = "[::1]" if host in ipv6_special_addresses else "localhost"
|
||||
|
||||
if port:
|
||||
env["PGRST_SERVER_PORT"] = str(port)
|
||||
env["PGRST_SERVER_HOST"] = host or "localhost"
|
||||
env["PGRST_SERVER_HOST"] = host or localhost
|
||||
# When constructing IPv6 address, host address should be bracketed like [host]
|
||||
apihost = f"[{host}]" if host and is_ipv6(host) else "localhost"
|
||||
apihost = f"[{host}]" if host and is_ipv6(host) else localhost
|
||||
baseurl = f"http://{apihost}:{port}"
|
||||
else:
|
||||
socketfile = pathlib.Path(tmpdir) / "postgrest.sock"
|
||||
env["PGRST_SERVER_UNIX_SOCKET"] = str(socketfile)
|
||||
baseurl = "http+unix://" + urllib.parse.quote_plus(str(socketfile))
|
||||
|
||||
adminport = freeport(port)
|
||||
adminport = freeport(used_ports=[port])
|
||||
env["PGRST_ADMIN_SERVER_PORT"] = str(adminport)
|
||||
adminhost = f"[{host}]" if host and is_ipv6(host) else "localhost"
|
||||
adminhost = f"[{host}]" if host and is_ipv6(host) else localhost
|
||||
adminurl = f"http://{adminhost}:{adminport}"
|
||||
|
||||
command = [POSTGREST_BIN]
|
||||
@@ -157,14 +165,14 @@ def run(
|
||||
process.wait()
|
||||
|
||||
|
||||
def freeport(used_port=None):
|
||||
"Find a free port on localhost."
|
||||
def freeport(used_ports=None):
|
||||
"Find an unused free port on localhost."
|
||||
while True:
|
||||
with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||
s.bind(("", 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
port = s.getsockname()[1]
|
||||
if port != used_port:
|
||||
if used_ports is None or port not in used_ports:
|
||||
return port
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"Auth related IO tests for PostgREST"
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from operator import attrgetter
|
||||
import signal
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from config import BASEDIR, CONFIGSDIR, FIXTURES, SECRET
|
||||
from util import authheader, jwtauthheader, parse_server_timings_header
|
||||
from postgrest import (
|
||||
run,
|
||||
sleep_until_postgrest_config_reload,
|
||||
sleep_until_postgrest_scache_reload,
|
||||
wait_until_exit,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"secretpath",
|
||||
[path for path in (BASEDIR / "secrets").iterdir() if path.suffix != ".jwt"],
|
||||
ids=attrgetter("name"),
|
||||
)
|
||||
def test_read_secret_from_file(secretpath, defaultenv):
|
||||
"Authorization should succeed when the secret is read from a file."
|
||||
|
||||
env = {**defaultenv, "PGRST_JWT_SECRET": f"@{secretpath}"}
|
||||
|
||||
if secretpath.suffix == ".b64":
|
||||
env["PGRST_JWT_SECRET_IS_BASE64"] = "true"
|
||||
|
||||
secret = secretpath.read_bytes()
|
||||
headers = authheader(secretpath.with_suffix(".jwt").read_text())
|
||||
|
||||
with run(stdin=secret, env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
print(response.text)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_read_secret_from_stdin(defaultenv):
|
||||
"Authorization should succeed when the secret is read from stdin."
|
||||
|
||||
env = {**defaultenv, "PGRST_DB_CONFIG": "false", "PGRST_JWT_SECRET": "@/dev/stdin"}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
with run(stdin=SECRET.encode(), env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
print(response.text)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# TODO: This test would fail right now, because of
|
||||
# https://github.com/PostgREST/postgrest/issues/2126
|
||||
@pytest.mark.skip
|
||||
def test_read_secret_from_stdin_dbconfig(defaultenv):
|
||||
"Authorization should succeed when the secret is read from stdin with db-config=true."
|
||||
|
||||
env = {**defaultenv, "PGRST_DB_CONFIG": "true", "PGRST_JWT_SECRET": "@/dev/stdin"}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
with run(stdin=SECRET.encode(), env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
print(response.text)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_jwt_errors(defaultenv):
|
||||
"invalid JWT should throw error"
|
||||
|
||||
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET, "PGRST_JWT_AUD": "io tests"}
|
||||
|
||||
def relativeSeconds(sec):
|
||||
return int((datetime.now(timezone.utc) + timedelta(seconds=sec)).timestamp())
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
headers = jwtauthheader({}, "other secret")
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "No suitable key or wrong key type"
|
||||
assert (
|
||||
response.json()["details"] == "None of the keys was able to decode the JWT"
|
||||
)
|
||||
|
||||
headers = jwtauthheader({"role": "not_existing"}, SECRET)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == 'role "not_existing" does not exist'
|
||||
|
||||
# -35 seconds, because we allow clock skew of 30 seconds
|
||||
headers = jwtauthheader({"exp": relativeSeconds(-35)}, SECRET)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "JWT expired"
|
||||
|
||||
# 35 seconds, because we allow clock skew of 30 seconds
|
||||
headers = jwtauthheader({"nbf": relativeSeconds(35)}, SECRET)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "JWT not yet valid"
|
||||
|
||||
# 35 seconds, because we allow clock skew of 35 seconds
|
||||
headers = jwtauthheader({"iat": relativeSeconds(35)}, SECRET)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "JWT issued at future"
|
||||
|
||||
headers = jwtauthheader({"aud": "not set"}, SECRET)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "JWT not in audience"
|
||||
|
||||
# partial token, no signature
|
||||
headers = authheader("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGFuIG9iamVjdA")
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "Expected 3 parts in JWT; got 2"
|
||||
|
||||
# complete token but random characters
|
||||
headers = authheader("quifquirndsjagnrgniur.fonvoienqhhdj.iuqvnvhojah")
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "JWT cryptographic operation failed"
|
||||
|
||||
# token with algorithm "none"
|
||||
headers = authheader(
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.e30.yOBhlOIqn56T-4NvyEXCjfi3UmyQZ-BzXtePMO2NgRI"
|
||||
)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "Wrong or unsupported encoding algorithm"
|
||||
assert (
|
||||
response.json()["details"]
|
||||
== "JWT is unsecured but expected 'alg' was not 'none'"
|
||||
)
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
}
|
||||
|
||||
# for code coverage with cache enabled and server-timing enabled
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "permission denied for table authors_only"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "false",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
}
|
||||
|
||||
# for code coverage with cache enabled and server-timing disabled
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "permission denied for table authors_only"
|
||||
|
||||
|
||||
def test_fail_with_invalid_password(defaultenv):
|
||||
"Connecting with an invalid password should fail without retries."
|
||||
uri = f'postgresql://?dbname={defaultenv["PGDATABASE"]}&host={defaultenv["PGHOST"]}&user=some_protected_user&password=invalid_pass'
|
||||
env = {**defaultenv, "PGRST_DB_URI": uri}
|
||||
with run(env=env, wait_for_readiness=False) as postgrest:
|
||||
exitCode = wait_until_exit(postgrest)
|
||||
assert exitCode == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"roleclaim", FIXTURES["roleclaims"], ids=lambda claim: claim["key"]
|
||||
)
|
||||
def test_role_claim_key(roleclaim, defaultenv):
|
||||
"Authorization should depend on a correct role-claim-key and JWT claim."
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_ROLE_CLAIM_KEY": roleclaim["key"],
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
}
|
||||
headers = jwtauthheader(roleclaim["data"], SECRET)
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == roleclaim["expected_status"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"jwtaudroleclaim",
|
||||
FIXTURES["jwtaudroleclaims"],
|
||||
ids=lambda claim: claim["key"] + "_" + str(claim["expected_status"]),
|
||||
)
|
||||
def test_jwt_aud_in_role_claim_key(jwtaudroleclaim, defaultenv):
|
||||
"Allows authorization with JWT aud claim in role-claim-key"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_AUD": "postgrest_test_author",
|
||||
"PGRST_JWT_ROLE_CLAIM_KEY": jwtaudroleclaim["key"],
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
}
|
||||
|
||||
headers = jwtauthheader(jwtaudroleclaim["data"], SECRET)
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == jwtaudroleclaim["expected_status"]
|
||||
|
||||
|
||||
def test_iat_claim(defaultenv):
|
||||
"""
|
||||
A claim with an 'iat' (issued at) attribute should be successful.
|
||||
|
||||
The PostgREST time cache leads to issues here, see:
|
||||
https://github.com/PostgREST/postgrest/issues/1139
|
||||
|
||||
"""
|
||||
|
||||
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET}
|
||||
|
||||
claim = {"role": "postgrest_test_author", "iat": datetime.now(timezone.utc)}
|
||||
headers = jwtauthheader(claim, SECRET)
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
for _ in range(10):
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def test_jwt_secret_reload(tmp_path, defaultenv):
|
||||
"JWT secret should be reloaded from file when PostgREST is sent SIGUSR2."
|
||||
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
|
||||
configfile = tmp_path / "test.config"
|
||||
configfile.write_text(config)
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
with run(configfile, env=defaultenv) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
# change setting
|
||||
configfile.write_text(config.replace("invalid" * 5, SECRET))
|
||||
|
||||
# reload config
|
||||
postgrest.process.send_signal(signal.SIGUSR2)
|
||||
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_jwt_secret_external_file_reload(tmp_path, defaultenv):
|
||||
"JWT secret external file should be reloaded when PostgREST is sent a SIGUSR2 or a NOTIFY."
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
external_secret_file = tmp_path / "jwt-secret-config"
|
||||
external_secret_file.write_text("invalid" * 5)
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_SECRET": f"@{external_secret_file}",
|
||||
"PGRST_DB_CHANNEL_ENABLED": "true",
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
# change external file
|
||||
external_secret_file.write_text(SECRET)
|
||||
|
||||
# SIGUSR1 doesn't reload external files, at least when db-config=false
|
||||
postgrest.process.send_signal(signal.SIGUSR1)
|
||||
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)
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# change external file to wrong value again
|
||||
external_secret_file.write_text("invalid" * 5)
|
||||
|
||||
# reload config and external file with NOTIFY
|
||||
response = postgrest.session.post("/rpc/reload_pgrst_config")
|
||||
assert response.text == ""
|
||||
assert response.status_code == 204
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# TODO: This test is more related to observability than authentication.
|
||||
# So, move it an appropriate test module.
|
||||
def test_jwt_cache_server_timing(defaultenv):
|
||||
"server-timing duration is exposed for JWT with expiry"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
headers = jwtauthheader(
|
||||
{
|
||||
"role": "postgrest_test_author",
|
||||
"exp": int(
|
||||
(datetime.now(timezone.utc) + timedelta(minutes=30)).timestamp()
|
||||
),
|
||||
},
|
||||
SECRET,
|
||||
)
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
first = postgrest.session.get("/authors_only", headers=headers)
|
||||
second = postgrest.session.get("/authors_only", headers=headers)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
|
||||
first_dur = parse_server_timings_header(first.headers["Server-Timing"])["jwt"]
|
||||
second_dur = parse_server_timings_header(second.headers["Server-Timing"])["jwt"]
|
||||
|
||||
# with jwt caching the parse time of second request with the same token
|
||||
# should be at least as fast as the first one
|
||||
assert second_dur <= first_dur
|
||||
|
||||
|
||||
def test_jwt_cache_without_server_timing(defaultenv):
|
||||
"JWT cache does not break requests with server-timing disabled"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "false",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
first = postgrest.session.get("/authors_only", headers=headers)
|
||||
second = postgrest.session.get("/authors_only", headers=headers)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
|
||||
|
||||
def test_jwt_cache_without_exp_claim(defaultenv):
|
||||
"server-timing duration is exposed for JWT without expiry"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) # no exp
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
first = postgrest.session.get("/authors_only", headers=headers)
|
||||
second = postgrest.session.get("/authors_only", headers=headers)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
|
||||
first_dur = parse_server_timings_header(first.headers["Server-Timing"])["jwt"]
|
||||
second_dur = parse_server_timings_header(second.headers["Server-Timing"])["jwt"]
|
||||
|
||||
assert first_dur >= 0
|
||||
assert second_dur >= 0
|
||||
|
||||
|
||||
def test_invalidate_jwt_cache_when_secret_changes(tmp_path, defaultenv):
|
||||
"JWT cache should be emptied after jwt-secret is changed in a config reload"
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
external_secret_file = tmp_path / "jwt-secret-config"
|
||||
external_secret_file.write_text(SECRET)
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_SECRET": f"@{external_secret_file}",
|
||||
"PGRST_DB_CHANNEL_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400", # enable cache
|
||||
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200 # jwt gets cached
|
||||
|
||||
# change external file
|
||||
external_secret_file.write_text("invalid" * 5)
|
||||
|
||||
# reload config and external file with NOTIFY
|
||||
# jwt-cache should get empty
|
||||
response = postgrest.session.post("/rpc/reload_pgrst_config")
|
||||
assert response.text == ""
|
||||
assert response.status_code == 204
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
# now the request should fail because the cached token is removed
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
+40
-52
@@ -3,62 +3,11 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from postgrest import run
|
||||
|
||||
|
||||
def test_requests_with_resource_embedding_wait_for_schema_cache_reload(defaultenv):
|
||||
"requests that use the schema cache with resource embedding wait long for the schema cache to reload"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_SCHEMAS": "apflora",
|
||||
"PGRST_DB_POOL": "2",
|
||||
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous",
|
||||
"PGRST_INTERNAL_SCHEMA_CACHE_RELATIONSHIP_LOAD_SLEEP": "5100",
|
||||
}
|
||||
|
||||
with run(env=env, wait_max_seconds=30) as postgrest:
|
||||
# reload the schema cache
|
||||
response = postgrest.session.get("/rpc/notify_pgrst")
|
||||
assert response.status_code == 204
|
||||
|
||||
postgrest.wait_until_scache_starts_loading()
|
||||
|
||||
response = postgrest.session.get("/tpopmassn?select=*,tpop(*)")
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.elapsed.total_seconds() > 5
|
||||
|
||||
|
||||
def test_requests_without_resource_embedding_wait_for_schema_cache_reload(defaultenv):
|
||||
"requests that use the schema cache without resource embedding wait less for the schema cache to reload"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_SCHEMAS": "apflora",
|
||||
"PGRST_DB_POOL": "2",
|
||||
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous",
|
||||
"PGRST_INTERNAL_SCHEMA_CACHE_LOAD_SLEEP": "1100",
|
||||
"PGRST_INTERNAL_SCHEMA_CACHE_RELATIONSHIP_LOAD_SLEEP": "5000",
|
||||
}
|
||||
|
||||
with run(env=env, wait_max_seconds=30) as postgrest:
|
||||
# reload the schema cache
|
||||
response = postgrest.session.get("/rpc/notify_pgrst")
|
||||
assert response.status_code == 204
|
||||
|
||||
postgrest.wait_until_scache_starts_loading()
|
||||
|
||||
response = postgrest.session.get("/tpopmassn")
|
||||
assert response.status_code == 200
|
||||
|
||||
assert (
|
||||
response.elapsed.total_seconds() > 1
|
||||
and response.elapsed.total_seconds() < 5
|
||||
)
|
||||
|
||||
|
||||
def test_schema_cache_load_max_duration(defaultenv):
|
||||
"schema cache load should not surpass a max_duration of elapsed milliseconds"
|
||||
|
||||
@@ -106,6 +55,25 @@ def test_openapi_in_big_schema(defaultenv):
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_stackoverflow_is_logged(defaultenv):
|
||||
"Stack overflow errors should be logged with the Warp error message"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_SCHEMAS": "apflora",
|
||||
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous",
|
||||
}
|
||||
|
||||
with run(env=env, wait_max_seconds=30, no_startup_stdout=False) as postgrest:
|
||||
with pytest.raises(requests.exceptions.ConnectionError):
|
||||
postgrest.session.get("/")
|
||||
|
||||
output = postgrest.read_stdout(nlines=10)
|
||||
output.extend(postgrest.read_stdout(nlines=10))
|
||||
|
||||
assert any("Warp server error: stack overflow" in line for line in output)
|
||||
|
||||
|
||||
# See: https://github.com/PostgREST/postgrest/issues/3329
|
||||
def test_should_not_fail_with_stack_overflow(defaultenv):
|
||||
"requesting a non-existent relationship should not fail with stack overflow due to fuzzy search of candidates"
|
||||
@@ -122,3 +90,23 @@ def test_should_not_fail_with_stack_overflow(defaultenv):
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["code"] == "PGRST205"
|
||||
|
||||
|
||||
def test_second_request_for_non_existent_table_should_be_quick(defaultenv):
|
||||
"requesting a non-existent relationship should be quick after the fuzzy search index is loaded (2nd request)"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_SCHEMAS": "fuzzysearch",
|
||||
"PGRST_DB_POOL": "2",
|
||||
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous",
|
||||
}
|
||||
|
||||
with run(env=env, wait_max_seconds=30) as postgrest:
|
||||
response = postgrest.session.get("/unknown-table")
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["code"] == "PGRST205"
|
||||
first_duration = response.elapsed.total_seconds()
|
||||
response = postgrest.session.get("/unknown-table")
|
||||
assert response.elapsed.total_seconds() < first_duration / 10
|
||||
|
||||
+6
-2
@@ -286,6 +286,7 @@ def test_jwt_secret_min_length(defaultenv):
|
||||
assert "The JWT secret must be at least 32 characters long." in error
|
||||
|
||||
|
||||
# TODO: Improve readability of "--ready" healthcheck tests
|
||||
@pytest.mark.parametrize("host", ["127.0.0.1", "::1"], ids=["IPv4", "IPv6"])
|
||||
def test_cli_ready_flag_success(host, defaultenv):
|
||||
"test PostgREST ready flag succeeds when ready"
|
||||
@@ -340,8 +341,11 @@ def test_cli_ready_flag_fail_with_http_exception(defaultenv):
|
||||
|
||||
# when healthcheck process sends the request to a wrong endpoint
|
||||
with run(env=defaultenv, port=port) as postgrest:
|
||||
# we set it to some freeport where admin is not running
|
||||
postgrest.config["PGRST_ADMIN_SERVER_PORT"] = str(freeport())
|
||||
# we set it to some freeport where server and admin server is not running
|
||||
admin_port = int(postgrest.config["PGRST_ADMIN_SERVER_PORT"])
|
||||
used_ports = [port, admin_port]
|
||||
|
||||
postgrest.config["PGRST_ADMIN_SERVER_PORT"] = str(freeport(used_ports))
|
||||
output = cli(["--ready"], env=postgrest.config, expect_error=True)
|
||||
(admin_host, admin_port) = get_admin_host_and_port_from_config(postgrest.config)
|
||||
|
||||
|
||||
+152
-460
@@ -1,15 +1,13 @@
|
||||
"Unit tests for Input/Ouput of PostgREST seen as a black box."
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from operator import attrgetter
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from config import BASEDIR, CONFIGSDIR, FIXTURES, SECRET
|
||||
from util import Thread, authheader, jwtauthheader, parse_server_timings_header
|
||||
from config import CONFIGSDIR, FIXTURES, SECRET
|
||||
from util import Thread, jwtauthheader, parse_server_timings_header
|
||||
from postgrest import (
|
||||
freeport,
|
||||
is_ipv6,
|
||||
@@ -23,162 +21,6 @@ from postgrest import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"secretpath",
|
||||
[path for path in (BASEDIR / "secrets").iterdir() if path.suffix != ".jwt"],
|
||||
ids=attrgetter("name"),
|
||||
)
|
||||
def test_read_secret_from_file(secretpath, defaultenv):
|
||||
"Authorization should succeed when the secret is read from a file."
|
||||
|
||||
env = {**defaultenv, "PGRST_JWT_SECRET": f"@{secretpath}"}
|
||||
|
||||
if secretpath.suffix == ".b64":
|
||||
env["PGRST_JWT_SECRET_IS_BASE64"] = "true"
|
||||
|
||||
secret = secretpath.read_bytes()
|
||||
headers = authheader(secretpath.with_suffix(".jwt").read_text())
|
||||
|
||||
with run(stdin=secret, env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
print(response.text)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_read_secret_from_stdin(defaultenv):
|
||||
"Authorization should succeed when the secret is read from stdin."
|
||||
|
||||
env = {**defaultenv, "PGRST_DB_CONFIG": "false", "PGRST_JWT_SECRET": "@/dev/stdin"}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
with run(stdin=SECRET.encode(), env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
print(response.text)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# TODO: This test would fail right now, because of
|
||||
# https://github.com/PostgREST/postgrest/issues/2126
|
||||
@pytest.mark.skip
|
||||
def test_read_secret_from_stdin_dbconfig(defaultenv):
|
||||
"Authorization should succeed when the secret is read from stdin with db-config=true."
|
||||
|
||||
env = {**defaultenv, "PGRST_DB_CONFIG": "true", "PGRST_JWT_SECRET": "@/dev/stdin"}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
with run(stdin=SECRET.encode(), env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
print(response.text)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_jwt_errors(defaultenv):
|
||||
"invalid JWT should throw error"
|
||||
|
||||
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET, "PGRST_JWT_AUD": "io tests"}
|
||||
|
||||
def relativeSeconds(sec):
|
||||
return int((datetime.now(timezone.utc) + timedelta(seconds=sec)).timestamp())
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
headers = jwtauthheader({}, "other secret")
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "No suitable key or wrong key type"
|
||||
assert (
|
||||
response.json()["details"] == "None of the keys was able to decode the JWT"
|
||||
)
|
||||
|
||||
headers = jwtauthheader({"role": "not_existing"}, SECRET)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == 'role "not_existing" does not exist'
|
||||
|
||||
# -35 seconds, because we allow clock skew of 30 seconds
|
||||
headers = jwtauthheader({"exp": relativeSeconds(-35)}, SECRET)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "JWT expired"
|
||||
|
||||
# 35 seconds, because we allow clock skew of 30 seconds
|
||||
headers = jwtauthheader({"nbf": relativeSeconds(35)}, SECRET)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "JWT not yet valid"
|
||||
|
||||
# 35 seconds, because we allow clock skew of 35 seconds
|
||||
headers = jwtauthheader({"iat": relativeSeconds(35)}, SECRET)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "JWT issued at future"
|
||||
|
||||
headers = jwtauthheader({"aud": "not set"}, SECRET)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "JWT not in audience"
|
||||
|
||||
# partial token, no signature
|
||||
headers = authheader("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGFuIG9iamVjdA")
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "Expected 3 parts in JWT; got 2"
|
||||
|
||||
# complete token but random characters
|
||||
headers = authheader("quifquirndsjagnrgniur.fonvoienqhhdj.iuqvnvhojah")
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "JWT cryptographic operation failed"
|
||||
|
||||
# token with algorithm "none"
|
||||
headers = authheader(
|
||||
"eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.e30.yOBhlOIqn56T-4NvyEXCjfi3UmyQZ-BzXtePMO2NgRI"
|
||||
)
|
||||
response = postgrest.session.get("/", headers=headers)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "Wrong or unsupported encoding algorithm"
|
||||
assert (
|
||||
response.json()["details"]
|
||||
== "JWT is unsecured but expected 'alg' was not 'none'"
|
||||
)
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
}
|
||||
|
||||
# for code coverage with cache enabled and server-timing enabled
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "permission denied for table authors_only"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "false",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
}
|
||||
|
||||
# for code coverage with cache enabled and server-timing disabled
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only")
|
||||
assert response.status_code == 401
|
||||
assert response.json()["message"] == "permission denied for table authors_only"
|
||||
|
||||
|
||||
def test_fail_with_invalid_password(defaultenv):
|
||||
"Connecting with an invalid password should fail without retries."
|
||||
uri = f'postgresql://?dbname={defaultenv["PGDATABASE"]}&host={defaultenv["PGHOST"]}&user=some_protected_user&password=invalid_pass'
|
||||
env = {**defaultenv, "PGRST_DB_URI": uri}
|
||||
with run(env=env, wait_for_readiness=False) as postgrest:
|
||||
exitCode = wait_until_exit(postgrest)
|
||||
assert exitCode == 1
|
||||
|
||||
|
||||
def test_connect_with_dburi(dburi, defaultenv):
|
||||
"Connecting with db-uri instead of LIPQ* environment variables should work."
|
||||
defaultenv_without_libpq = {
|
||||
@@ -217,67 +59,6 @@ def test_read_dburi_from_stdin_with_eol(dburi, defaultenv):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"roleclaim", FIXTURES["roleclaims"], ids=lambda claim: claim["key"]
|
||||
)
|
||||
def test_role_claim_key(roleclaim, defaultenv):
|
||||
"Authorization should depend on a correct role-claim-key and JWT claim."
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_ROLE_CLAIM_KEY": roleclaim["key"],
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
}
|
||||
headers = jwtauthheader(roleclaim["data"], SECRET)
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == roleclaim["expected_status"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"jwtaudroleclaim",
|
||||
FIXTURES["jwtaudroleclaims"],
|
||||
ids=lambda claim: claim["key"] + "_" + str(claim["expected_status"]),
|
||||
)
|
||||
def test_jwt_aud_in_role_claim_key(jwtaudroleclaim, defaultenv):
|
||||
"Allows authorization with JWT aud claim in role-claim-key"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_AUD": "postgrest_test_author",
|
||||
"PGRST_JWT_ROLE_CLAIM_KEY": jwtaudroleclaim["key"],
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
}
|
||||
|
||||
headers = jwtauthheader(jwtaudroleclaim["data"], SECRET)
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == jwtaudroleclaim["expected_status"]
|
||||
|
||||
|
||||
def test_iat_claim(defaultenv):
|
||||
"""
|
||||
A claim with an 'iat' (issued at) attribute should be successful.
|
||||
|
||||
The PostgREST time cache leads to issues here, see:
|
||||
https://github.com/PostgREST/postgrest/issues/1139
|
||||
|
||||
"""
|
||||
|
||||
env = {**defaultenv, "PGRST_JWT_SECRET": SECRET}
|
||||
|
||||
claim = {"role": "postgrest_test_author", "iat": datetime.now(timezone.utc)}
|
||||
headers = jwtauthheader(claim, SECRET)
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
for _ in range(10):
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def test_app_settings_flush_pool(defaultenv):
|
||||
"""
|
||||
App settings should not reset when the db pool is flushed.
|
||||
@@ -353,79 +134,6 @@ def test_app_settings_reload(tmp_path, defaultenv):
|
||||
assert response.text == '"Jane"'
|
||||
|
||||
|
||||
def test_jwt_secret_reload(tmp_path, defaultenv):
|
||||
"JWT secret should be reloaded from file when PostgREST is sent SIGUSR2."
|
||||
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
|
||||
configfile = tmp_path / "test.config"
|
||||
configfile.write_text(config)
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
with run(configfile, env=defaultenv) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
# change setting
|
||||
configfile.write_text(config.replace("invalid" * 5, SECRET))
|
||||
|
||||
# reload config
|
||||
postgrest.process.send_signal(signal.SIGUSR2)
|
||||
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_jwt_secret_external_file_reload(tmp_path, defaultenv):
|
||||
"JWT secret external file should be reloaded when PostgREST is sent a SIGUSR2 or a NOTIFY."
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
external_secret_file = tmp_path / "jwt-secret-config"
|
||||
external_secret_file.write_text("invalid" * 5)
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_SECRET": f"@{external_secret_file}",
|
||||
"PGRST_DB_CHANNEL_ENABLED": "true",
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
# change external file
|
||||
external_secret_file.write_text(SECRET)
|
||||
|
||||
# SIGUSR1 doesn't reload external files, at least when db-config=false
|
||||
postgrest.process.send_signal(signal.SIGUSR1)
|
||||
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)
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# change external file to wrong value again
|
||||
external_secret_file.write_text("invalid" * 5)
|
||||
|
||||
# reload config and external file with NOTIFY
|
||||
response = postgrest.session.post("/rpc/reload_pgrst_config")
|
||||
assert response.text == ""
|
||||
assert response.status_code == 204
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_db_schema_reload(tmp_path, defaultenv):
|
||||
"DB schema should be reloaded from file when PostgREST is sent SIGUSR2."
|
||||
config = (CONFIGSDIR / "sigusr2-settings.config").read_text()
|
||||
@@ -1350,6 +1058,56 @@ def test_schema_cache_concurrent_notifications(slow_schema_cache_env):
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_schema_cache_query_sleep_logs(defaultenv):
|
||||
"""Schema cache sleep should be reflected in the logged query duration."""
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_INTERNAL_SCHEMA_CACHE_QUERY_SLEEP": "1000",
|
||||
}
|
||||
log_pattern = re.compile(r"Schema cache queried in ([\d.]+) milliseconds")
|
||||
|
||||
with run(env=env, wait_max_seconds=3, no_startup_stdout=False) as postgrest:
|
||||
observed_ms = None
|
||||
collected = []
|
||||
|
||||
lines = postgrest.read_stdout(nlines=10)
|
||||
collected.extend(lines)
|
||||
for line in lines:
|
||||
match = log_pattern.search(line)
|
||||
if match:
|
||||
observed_ms = float(match.group(1))
|
||||
break
|
||||
|
||||
assert observed_ms is not None
|
||||
assert 1000 < observed_ms < 2000
|
||||
|
||||
|
||||
def test_schema_cache_load_sleep_logs(defaultenv):
|
||||
"""Schema cache load sleep should be reflected in the logged load duration."""
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_INTERNAL_SCHEMA_CACHE_LOAD_SLEEP": "1000",
|
||||
}
|
||||
log_pattern = re.compile(r"Schema cache loaded in ([\d.]+) milliseconds")
|
||||
|
||||
with run(env=env, wait_max_seconds=3, no_startup_stdout=False) as postgrest:
|
||||
observed_ms = None
|
||||
collected = []
|
||||
|
||||
lines = postgrest.read_stdout(nlines=10)
|
||||
collected.extend(lines)
|
||||
for line in lines:
|
||||
match = log_pattern.search(line)
|
||||
if match:
|
||||
observed_ms = float(match.group(1))
|
||||
break
|
||||
|
||||
assert observed_ms is not None
|
||||
assert 1000 < observed_ms < 2000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dburi_type", ["no_params", "no_params_qmark", "with_params"])
|
||||
def test_get_pgrst_version_with_uri_connection_string(dburi_type, dburi, defaultenv):
|
||||
"The fallback_application_name should be added to the db-uri if it has a URI format"
|
||||
@@ -1489,90 +1247,6 @@ def test_fail_with_automatic_recovery_disabled_and_terminated_using_query(defaul
|
||||
assert exitCode == 1
|
||||
|
||||
|
||||
def test_jwt_cache_server_timing(defaultenv):
|
||||
"server-timing duration is exposed for JWT with expiry"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
headers = jwtauthheader(
|
||||
{
|
||||
"role": "postgrest_test_author",
|
||||
"exp": int(
|
||||
(datetime.now(timezone.utc) + timedelta(minutes=30)).timestamp()
|
||||
),
|
||||
},
|
||||
SECRET,
|
||||
)
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
first = postgrest.session.get("/authors_only", headers=headers)
|
||||
second = postgrest.session.get("/authors_only", headers=headers)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
|
||||
first_dur = parse_server_timings_header(first.headers["Server-Timing"])["jwt"]
|
||||
second_dur = parse_server_timings_header(second.headers["Server-Timing"])["jwt"]
|
||||
|
||||
# with jwt caching the parse time of second request with the same token
|
||||
# should be at least as fast as the first one
|
||||
assert second_dur <= first_dur
|
||||
|
||||
|
||||
def test_jwt_cache_without_server_timing(defaultenv):
|
||||
"JWT cache does not break requests with server-timing disabled"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "false",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
first = postgrest.session.get("/authors_only", headers=headers)
|
||||
second = postgrest.session.get("/authors_only", headers=headers)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
|
||||
|
||||
def test_jwt_cache_without_exp_claim(defaultenv):
|
||||
"server-timing duration is exposed for JWT without expiry"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) # no exp
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
first = postgrest.session.get("/authors_only", headers=headers)
|
||||
second = postgrest.session.get("/authors_only", headers=headers)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
|
||||
first_dur = parse_server_timings_header(first.headers["Server-Timing"])["jwt"]
|
||||
second_dur = parse_server_timings_header(second.headers["Server-Timing"])["jwt"]
|
||||
|
||||
assert first_dur >= 0
|
||||
assert second_dur >= 0
|
||||
|
||||
|
||||
def test_preflight_request_with_cors_allowed_origin_config(defaultenv):
|
||||
"OPTIONS preflight request should return Access-Control-Allow-Origin equal to origin"
|
||||
|
||||
@@ -1805,53 +1479,6 @@ def test_schema_cache_startup_load_with_in_db_config(defaultenv, metapostgrest):
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
def test_jwt_cache_purges_expired_entries(defaultenv):
|
||||
"test expired cache entries are purged on cache miss"
|
||||
|
||||
# The verification of actual cache size reduction is done manually, see https://github.com/PostgREST/postgrest/pull/3801#issuecomment-2620776041
|
||||
# This test is written for code coverage of purgeExpired function
|
||||
|
||||
def relativeSeconds(sec):
|
||||
return int((datetime.now(timezone.utc) + timedelta(seconds=sec)).timestamp())
|
||||
|
||||
def headers(sec):
|
||||
return jwtauthheader(
|
||||
{"role": "postgrest_test_author", "exp": relativeSeconds(sec)}, SECRET
|
||||
)
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400",
|
||||
"PGRST_JWT_SECRET": SECRET,
|
||||
"PGRST_DB_CONFIG": "false",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
|
||||
# Generate two unique JWT tokens
|
||||
# The 1 second sleep is needed for it generate a unique token
|
||||
hdrs1 = headers(5)
|
||||
postgrest.session.get("/authors_only", headers=hdrs1)
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
hdrs2 = headers(5)
|
||||
postgrest.session.get("/authors_only", headers=hdrs2)
|
||||
|
||||
# Wait 5 seconds for the tokens to expire
|
||||
time.sleep(5)
|
||||
|
||||
hdrs3 = headers(5)
|
||||
|
||||
# Make another request which should cause a cache miss and so
|
||||
# the purgeExpired function will be triggered.
|
||||
#
|
||||
# This should remove the 2 expired tokens but adds another to cache
|
||||
response = postgrest.session.get("/authors_only", headers=hdrs3)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_pgrst_log_503_client_error_to_stderr(defaultenv):
|
||||
"PostgREST should log 503 errors to stderr"
|
||||
|
||||
@@ -1962,41 +1589,6 @@ def test_proxy_status_header(defaultenv, metapostgrest):
|
||||
assert data["message"] == "canceling statement due to statement timeout"
|
||||
|
||||
|
||||
def test_invalidate_jwt_cache_when_secret_changes(tmp_path, defaultenv):
|
||||
"JWT cache should be emptied after jwt-secret is changed in a config reload"
|
||||
|
||||
headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET)
|
||||
|
||||
external_secret_file = tmp_path / "jwt-secret-config"
|
||||
external_secret_file.write_text(SECRET)
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_JWT_SECRET": f"@{external_secret_file}",
|
||||
"PGRST_DB_CHANNEL_ENABLED": "true",
|
||||
"PGRST_JWT_CACHE_MAX_ENTRIES": "86400", # enable cache
|
||||
"PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 200 # jwt gets cached
|
||||
|
||||
# change external file
|
||||
external_secret_file.write_text("invalid" * 5)
|
||||
|
||||
# reload config and external file with NOTIFY
|
||||
# jwt-cache should get empty
|
||||
response = postgrest.session.post("/rpc/reload_pgrst_config")
|
||||
assert response.text == ""
|
||||
assert response.status_code == 204
|
||||
sleep_until_postgrest_config_reload()
|
||||
|
||||
# now the request should fail because the cached token is removed
|
||||
response = postgrest.session.get("/authors_only", headers=headers)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_allow_configs_to_be_set_to_empty(defaultenv):
|
||||
'configs that are explicitly set to empty (= "<empty>") should not throw parse error'
|
||||
|
||||
@@ -2046,3 +1638,103 @@ def test_log_listener_connection_errors(defaultenv):
|
||||
in line
|
||||
for line in output
|
||||
)
|
||||
|
||||
|
||||
def test_db_pre_config_with_pg_reserved_words(defaultenv):
|
||||
"The db-pre-config should not fail unexpectedly when function name is a postgres reserved word"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_PRE_CONFIG": "true", # call true function
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.post("/rpc/true")
|
||||
assert response.status_code == 200
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_PRE_CONFIG": "select", # no "select" function in our fixtures, fail gracefully at startup
|
||||
}
|
||||
|
||||
with run(env=env, no_startup_stdout=False, wait_for_readiness=False) as postgrest:
|
||||
output = postgrest.read_stdout(nlines=8)
|
||||
assert any(
|
||||
'Failed to query database settings for the config parameters.{"code":"42883","details":null,"hint":"No function matches the given name and argument types. You might need to add explicit type casts.","message":"function select() does not exist"}'
|
||||
in line
|
||||
for line in output
|
||||
)
|
||||
|
||||
|
||||
def test_requests_with_resource_embedding_wait_for_schema_cache_reload(defaultenv):
|
||||
"requests that use the schema cache with resource embedding wait long for the schema cache to reload"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_POOL": "2",
|
||||
"PGRST_INTERNAL_SCHEMA_CACHE_RELATIONSHIP_LOAD_SLEEP": "5100",
|
||||
}
|
||||
|
||||
with run(env=env, wait_max_seconds=30) as postgrest:
|
||||
# reload the schema cache
|
||||
response = postgrest.session.get("/rpc/notify_pgrst")
|
||||
assert response.status_code == 204
|
||||
|
||||
postgrest.wait_until_scache_starts_loading()
|
||||
|
||||
response = postgrest.session.get("/directors?select=id,name,films(title)")
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.elapsed.total_seconds() > 5
|
||||
|
||||
|
||||
def test_requests_without_resource_embedding_wait_for_schema_cache_reload(defaultenv):
|
||||
"requests that use the schema cache without resource embedding wait less for the schema cache to reload"
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGRST_DB_POOL": "2",
|
||||
"PGRST_INTERNAL_SCHEMA_CACHE_LOAD_SLEEP": "1100",
|
||||
"PGRST_INTERNAL_SCHEMA_CACHE_RELATIONSHIP_LOAD_SLEEP": "5000",
|
||||
}
|
||||
|
||||
with run(env=env, wait_max_seconds=30) as postgrest:
|
||||
# reload the schema cache
|
||||
response = postgrest.session.get("/rpc/notify_pgrst")
|
||||
assert response.status_code == 204
|
||||
|
||||
postgrest.wait_until_scache_starts_loading()
|
||||
|
||||
response = postgrest.session.get("/films")
|
||||
assert response.status_code == 200
|
||||
|
||||
assert (
|
||||
response.elapsed.total_seconds() > 1
|
||||
and response.elapsed.total_seconds() < 5
|
||||
)
|
||||
|
||||
|
||||
def test_server_timing_transaction_duration(defaultenv, metapostgrest):
|
||||
"server-timing transaction duration should be accurate"
|
||||
|
||||
# just to ensure we don't timeout
|
||||
role = "timeout_authenticator"
|
||||
set_statement_timeout(metapostgrest, role, 3000) # 3 seconds
|
||||
|
||||
env = {
|
||||
**defaultenv,
|
||||
"PGUSER": role,
|
||||
"PGRST_DB_ANON_ROLE": role,
|
||||
"PGRST_SERVER_TIMING_ENABLED": "true",
|
||||
}
|
||||
|
||||
with run(env=env) as postgrest:
|
||||
response = postgrest.session.get("/rpc/sleep?seconds=2")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
response_dur = parse_server_timings_header(response.headers["Server-Timing"])[
|
||||
"transaction"
|
||||
]
|
||||
|
||||
assert 2000 <= response_dur < 3000
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
Can be used as:
|
||||
|
||||
```
|
||||
postgrest-with-postgresql-15 -f test/pgbench/fixtures.sql pgbench -U postgres -n -T 10 -f test/pgbench/1567/old.sql
|
||||
postgrest-with-pg-15 -f test/pgbench/fixtures.sql pgbench -U postgres -n -T 10 -f test/pgbench/1567/old.sql
|
||||
|
||||
postgrest-with-postgresql-15 -f test/pgbench/fixtures.sql pgbench -U postgres -n -T 10 -f test/pgbench/1567/new.sql
|
||||
postgrest-with-pg-15 -f test/pgbench/fixtures.sql pgbench -U postgres -n -T 10 -f test/pgbench/1567/new.sql
|
||||
```
|
||||
|
||||
## Directory structure
|
||||
|
||||
@@ -252,21 +252,39 @@ spec =
|
||||
[json|[{"id": 7, "entities":null}, {"id": 8, "entities": {"id": 2}}, {"id": 9, "entities": {"id": 3}}]|]
|
||||
{ matchStatus = 201 }
|
||||
|
||||
context "used with PATCH" $
|
||||
context "used with PATCH" $ do
|
||||
it "succeeds when using and/or params" $
|
||||
request methodPatch "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{ name : "updated grandchild entity"}|] `shouldRespondWith`
|
||||
[json|[{ "id": 1, "name" : "updated grandchild entity"},{ "id": 2, "name" : "updated grandchild entity"}]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "succeeds when the filtered column is modified" $
|
||||
request methodPatch "/entities?select=id,name&or=(name.is.null,name.like.*test*)"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{ "name" : "updated entity" }|] `shouldRespondWith`
|
||||
[json|[{ "id": 4, "name": "updated entity" }]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
it "succeeds when the filtered column is not selected in the returned representation" $
|
||||
request methodPatch "/entities?select=id&or=(name.is.null,name.like.*test*)"
|
||||
[("Prefer", "return=representation")]
|
||||
[json|{ "name" : "updated entity" }|] `shouldRespondWith`
|
||||
[json|[{ "id": 4 }]|]
|
||||
{ matchHeaders = [matchContentTypeJson] }
|
||||
|
||||
context "used with DELETE" $
|
||||
context "used with DELETE" $ do
|
||||
it "succeeds when using and/or params" $
|
||||
request methodDelete "/grandchild_entities?or=(id.eq.1,id.eq.2)&select=id,name"
|
||||
[("Prefer", "return=representation")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{ "id": 1, "name" : "grandchild entity 1" },{ "id": 2, "name" : "grandchild entity 2" }]|]
|
||||
it "succeeds when the filtered column is not selected in the returned representation" $
|
||||
request methodDelete "/entities?select=id&or=(name.is.null,name.like.*test*)"
|
||||
[("Prefer", "return=representation")]
|
||||
""
|
||||
`shouldRespondWith`
|
||||
[json|[{ "id": 4 }]|]
|
||||
|
||||
it "can query columns that begin with and/or reserved words" $
|
||||
get "/grandchild_entities?or=(and_starting_col.eq.smth, or_starting_col.eq.smth)" `shouldRespondWith` 200
|
||||
|
||||
@@ -138,6 +138,7 @@ spec actualPgVersion = do
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/projects?id=eq.11"
|
||||
, "Content-Range" <:> "*/*"
|
||||
, "Content-Length" <:> "0"
|
||||
, "Preference-Applied" <:> "return=headers-only"]
|
||||
}
|
||||
|
||||
@@ -151,6 +152,7 @@ spec actualPgVersion = do
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/car_models?name=eq.Enzo&year=eq.2021"
|
||||
, "Content-Range" <:> "*/*"
|
||||
, "Content-Length" <:> "0"
|
||||
, "Preference-Applied" <:> "return=headers-only"]
|
||||
}
|
||||
|
||||
@@ -163,7 +165,8 @@ spec actualPgVersion = do
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, matchHeaderAbsent hLocation ]
|
||||
, matchHeaderAbsent hLocation
|
||||
, "Content-Length" <:> "0"]
|
||||
}
|
||||
|
||||
context "from an html form" $
|
||||
@@ -175,7 +178,8 @@ spec actualPgVersion = do
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType ]
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Length" <:> "0"]
|
||||
}
|
||||
|
||||
context "with no pk supplied" $ do
|
||||
@@ -199,6 +203,7 @@ spec actualPgVersion = do
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Length" <:> "0"
|
||||
, "Location" <:> "/auto_incrementing_pk?id=eq.2"
|
||||
, "Preference-Applied" <:> "return=headers-only"]
|
||||
}
|
||||
@@ -741,6 +746,7 @@ spec actualPgVersion = do
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hContentType
|
||||
, "Content-Length" <:> "0"
|
||||
, "Preference-Applied" <:> "return=minimal"]
|
||||
}
|
||||
|
||||
@@ -753,7 +759,8 @@ spec actualPgVersion = do
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, matchHeaderAbsent hLocation ]
|
||||
, matchHeaderAbsent hLocation
|
||||
, "Content-Length" <:> "0"]
|
||||
}
|
||||
|
||||
it "returns a location header with pks from both tables" $
|
||||
@@ -765,6 +772,7 @@ spec actualPgVersion = do
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/with_multiple_pks?pk1=eq.1&pk2=eq.2"
|
||||
, "Content-Range" <:> "*/*"
|
||||
, "Content-Length" <:> "0"
|
||||
, "Preference-Applied" <:> "return=headers-only"]
|
||||
}
|
||||
|
||||
@@ -778,6 +786,7 @@ spec actualPgVersion = do
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/compound_pk_view?k1=eq.1&k2=eq.test"
|
||||
, "Content-Range" <:> "*/*"
|
||||
, "Content-Length" <:> "0"
|
||||
, "Preference-Applied" <:> "return=headers-only"]
|
||||
}
|
||||
|
||||
@@ -790,6 +799,7 @@ spec actualPgVersion = do
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/test_null_pk_competitors_sponsors?id=eq.1&sponsor_id=is.null"
|
||||
, "Content-Range" <:> "*/*"
|
||||
, "Content-Length" <:> "0"
|
||||
, "Preference-Applied" <:> "return=headers-only"]
|
||||
}
|
||||
|
||||
@@ -807,6 +817,7 @@ spec actualPgVersion = do
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/datarep_todos?id=eq.5"
|
||||
, "Content-Range" <:> "*/*"
|
||||
, "Content-Length" <:> "0"
|
||||
, "Preference-Applied" <:> "return=headers-only"]
|
||||
}
|
||||
|
||||
@@ -862,6 +873,7 @@ spec actualPgVersion = do
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Location" <:> "/datarep_todos_computed?id=eq.5"
|
||||
, "Content-Range" <:> "*/*"
|
||||
, "Content-Length" <:> "0"
|
||||
, "Preference-Applied" <:> "return=headers-only"]
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,8 @@ spec = do
|
||||
"hint":null
|
||||
}|]
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||
, matchHeaders = [ "Content-Range" <:> "*/0"
|
||||
, "Content-Length" <:> "144"]
|
||||
}
|
||||
|
||||
it "refuses a range requesting start past last item" $
|
||||
@@ -288,7 +289,8 @@ spec = do
|
||||
"hint":null
|
||||
}|]
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||
, matchHeaders = [ "Content-Range" <:> "*/0"
|
||||
, "Content-Length" <:> "144"]
|
||||
}
|
||||
|
||||
it "refuses a range requesting start past last item" $
|
||||
@@ -470,7 +472,8 @@ spec = do
|
||||
"hint":null
|
||||
}|]
|
||||
{ matchStatus = 416
|
||||
, matchHeaders = ["Content-Range" <:> "*/0"]
|
||||
, matchHeaders = [ "Content-Range" <:> "*/0"
|
||||
, "Content-Length" <:> "144"]
|
||||
}
|
||||
|
||||
it "refuses a range requesting start past last item" $
|
||||
|
||||
@@ -1146,12 +1146,24 @@ spec =
|
||||
}
|
||||
|
||||
context "single unnamed param" $ do
|
||||
it "can insert json directly" $
|
||||
it "can insert json directly with unnamed parameter" $
|
||||
post "/rpc/unnamed_json_param"
|
||||
[json|{"A": 1, "B": 2, "C": 3}|]
|
||||
`shouldRespondWith`
|
||||
[json|{"A": 1, "B": 2, "C": 3}|]
|
||||
|
||||
it "rejects json body when single param has a name" $
|
||||
post "/rpc/named_json_param"
|
||||
[json|{"A": 1, "B": 2, "C": 3}|]
|
||||
`shouldRespondWith`
|
||||
[json|{
|
||||
"code":"PGRST202",
|
||||
"message":"Could not find the function test.named_json_param(A, B, C) in the schema cache",
|
||||
"details":"Searched for the function test.named_json_param with parameters A, B, C or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache.",
|
||||
"hint":null
|
||||
}|]
|
||||
{ matchStatus = 404 }
|
||||
|
||||
it "can insert text directly" $ do
|
||||
request methodPost "/rpc/unnamed_text_param"
|
||||
[("Content-Type", "text/plain"), ("Accept", "text/plain")]
|
||||
|
||||
@@ -20,7 +20,9 @@ postItem =
|
||||
`shouldRespondWith`
|
||||
""
|
||||
{ matchStatus = 201
|
||||
, matchHeaders = [matchHeaderAbsent hContentType] }
|
||||
, matchHeaders = [ matchHeaderAbsent hContentType
|
||||
, "Content-Length" <:> "0" ]
|
||||
}
|
||||
|
||||
-- removes Items left over from POST, PUT, and PATCH
|
||||
deleteItems =
|
||||
|
||||
Vendored
+5
@@ -2356,6 +2356,11 @@ create or replace function test.unnamed_json_param(json) returns json as $$
|
||||
select $1;
|
||||
$$ language sql;
|
||||
|
||||
-- Function with a NAMED json parameter (for testing single param fallback behavior)
|
||||
create or replace function test.named_json_param(data json) returns json as $$
|
||||
select data;
|
||||
$$ language sql;
|
||||
|
||||
create or replace function test.unnamed_text_param(text) returns "text/plain" as $$
|
||||
select $1::"text/plain";
|
||||
$$ language sql;
|
||||
|
||||
Reference in New Issue
Block a user