diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..e1afdaf98 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + commit_status: true + fail_commit_status: false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3897d7ae3..65b281eab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,10 +22,11 @@ jobs: with: fetch-depth: 0 - - name: Setup Dart - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c # v1.7.1 + - name: Setup Flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: - sdk: stable + channel: 'stable' + cache: true - name: Detect affected packages id: detect @@ -99,10 +100,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: channel: 'stable' - # Caching the latest stable is flaky: the cached SDK contains - # runner-image-specific binaries that break silently when the runner - # image is updated. - cache: false + cache: true - name: Show Flutter version run: flutter --version diff --git a/.github/workflows/release-pana.yml b/.github/workflows/release-pana.yml new file mode 100644 index 000000000..60e37bd6c --- /dev/null +++ b/.github/workflows/release-pana.yml @@ -0,0 +1,78 @@ +name: Release Pana + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - edited + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + pana: + name: Pana (${{ matrix.package }}) + if: startsWith(github.event.pull_request.title, 'chore(release):') + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + package: + - functions_client + - gotrue + - postgrest + - realtime_client + - storage_client + - supabase + - supabase_common + - supabase_flutter + - yet_another_json_isolate + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + with: + cache: true + + - name: Setup Melos + uses: bluefireteam/melos-action@2982f8e4fc92440a219490009d6d05195cb1a6a5 # v3.8.0 + + - name: Activate pana + run: dart pub global activate pana + + - name: Run pana + working-directory: packages/${{ matrix.package }} + env: + PACKAGE: ${{ matrix.package }} + run: | + set -euo pipefail + + pana --no-dartdoc --json . > pana.json + + echo "### Pana — $PACKAGE" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Section | Points | Status |" >> "$GITHUB_STEP_SUMMARY" + echo "| --- | --- | --- |" >> "$GITHUB_STEP_SUMMARY" + jq -r '.report.sections[] | "| \(.id) | \(.grantedPoints)/\(.maxPoints) | \(.status) |"' pana.json >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Tags: $(jq -r '[.tags[]] | join(", ")' pana.json)" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + web=$(jq -r 'any(.tags[]?; . == "platform:web")' pana.json) + wasm=$(jq -r 'any(.tags[]?; . == "is:wasm-ready")' pana.json) + + if [ "$web" = "true" ] && [ "$wasm" != "true" ]; then + echo "::error::$PACKAGE supports web (platform:web) but is not WASM-ready. A published package that supports web must compile to WASM. See https://dart.dev/web/wasm" + exit 1 + fi + + echo "$PACKAGE passed the pana web/WASM check (web=$web, wasm-ready=$wasm)." diff --git a/.github/workflows/sync-compliance.yml b/.github/workflows/sync-compliance.yml new file mode 100644 index 000000000..1425433b9 --- /dev/null +++ b/.github/workflows/sync-compliance.yml @@ -0,0 +1,16 @@ +name: Sync SDK Compliance Matrix + +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +jobs: + sync: + permissions: + contents: write + pull-requests: write + uses: supabase/sdk/.github/workflows/sync-sdk-compliance.yml@d9f43f5be4056adc33c65fe54f1c9ed1714d67e0 + secrets: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 003a7ef27..588ebb15b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,10 +27,11 @@ jobs: with: fetch-depth: 0 - - name: Setup Dart - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c # v1.7.1 + - name: Setup Flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: - sdk: stable + channel: 'stable' + cache: true - name: Detect affected packages id: detect @@ -63,7 +64,7 @@ jobs: echo "Affected packages:" echo "$CHANGED" - DART_PACKAGES="functions_client gotrue postgrest realtime_client storage_client supabase yet_another_json_isolate" + DART_PACKAGES="functions_client gotrue postgrest realtime_client storage_client supabase supabase_common yet_another_json_isolate" entries=() for package in $DART_PACKAGES; do @@ -120,7 +121,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: channel: ${{ matrix.channel }} - cache: false + cache: true - name: Show Flutter version run: flutter --version @@ -144,7 +145,7 @@ jobs: if: ${{ matrix.package.backend }} uses: supabase/setup-cli@3c2f5e2ae34c34e428e8e206e2c4d21fa2d20fbf # v2.1.1 with: - version: 2.106.0 + version: 2.109.1 - name: Print Supabase CLI version if: ${{ matrix.package.backend }} @@ -204,6 +205,7 @@ jobs: - name: Upload coverage to Coveralls if: ${{ matrix.channel == 'stable' }} uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 + continue-on-error: true with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel: true @@ -251,10 +253,7 @@ jobs: with: flutter-version: ${{ matrix.flutter-version != 'latest' && matrix.flutter-version || '' }} channel: 'stable' - # Caching the latest stable is flaky: the cached SDK contains - # runner-image-specific binaries that break silently when the runner - # image is updated. Pinned versions like 3.35.x are stable to cache. - cache: ${{ matrix.flutter-version != 'latest' }} + cache: true - name: Show Flutter version run: flutter --version @@ -285,6 +284,7 @@ jobs: - name: Upload coverage to Coveralls if: ${{ matrix.os == 'ubuntu-latest' && matrix.flutter-version == 'latest' }} uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 + continue-on-error: true with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel: true @@ -296,11 +296,6 @@ jobs: if: ${{ matrix.os == 'ubuntu-latest' && matrix.flutter-version == 'latest'}} run: flutter test --platform chrome - - name: Build example for web (WASM) - if: ${{ matrix.os == 'ubuntu-latest' && matrix.flutter-version == 'latest'}} - working-directory: packages/supabase_flutter/example - run: flutter build web --wasm - dcm: name: DCM needs: changes @@ -338,10 +333,12 @@ jobs: steps: - name: Close parallel coverage build uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 + continue-on-error: true with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true - carryforward: 'functions_client,gotrue,postgrest,realtime_client,storage_client,supabase,yet_another_json_isolate,supabase_flutter' + carryforward: 'functions_client,gotrue,postgrest,realtime_client,storage_client,supabase,supabase_common,yet_another_json_isolate,supabase_flutter' + fail-on-error: false test-result: name: Test result diff --git a/.github/workflows/validate-capabilities.yml b/.github/workflows/validate-capabilities.yml index d591e101c..152cdb935 100644 --- a/.github/workflows/validate-capabilities.yml +++ b/.github/workflows/validate-capabilities.yml @@ -6,9 +6,11 @@ on: permissions: contents: read + pull-requests: write jobs: validate: permissions: contents: read + pull-requests: write uses: supabase/sdk/.github/workflows/validate-sdk-compliance-dart.yml@main \ No newline at end of file diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml new file mode 100644 index 000000000..1711a045c --- /dev/null +++ b/.github/workflows/wasm.yml @@ -0,0 +1,125 @@ +name: WASM + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changes: + name: Detect affected packages + runs-on: ubuntu-latest + outputs: + flutter: ${{ steps.detect.outputs.flutter }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Setup Flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + with: + channel: 'stable' + cache: true + + - name: Detect affected packages + id: detect + run: | + set -euo pipefail + + dart pub global activate melos + + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + else + BASE="${{ github.event.before }}" + fi + + FORCE_ALL=false + if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then + FORCE_ALL=true + elif git diff --name-only "$BASE...HEAD" \ + | grep -qE '^(\.github/workflows/wasm\.yml|pubspec\.yaml|pubspec\.lock|supabase/)'; then + FORCE_ALL=true + fi + + if [ "$FORCE_ALL" = "true" ]; then + echo "Running all packages." + CHANGED=$(melos list --json | jq -r '.[].name') + else + CHANGED=$(melos list --json --diff="$BASE...HEAD" --include-dependents | jq -r '.[].name') + fi + + echo "Affected packages:" + echo "$CHANGED" + + if echo "$CHANGED" | grep -qx "supabase_flutter"; then + echo "flutter=true" >> "$GITHUB_OUTPUT" + else + echo "flutter=false" >> "$GITHUB_OUTPUT" + fi + + test-wasm: + name: Test and build WASM + needs: changes + if: ${{ needs.changes.outputs.flutter == 'true' }} + timeout-minutes: 30 + runs-on: ubuntu-latest + + defaults: + run: + working-directory: packages/supabase_flutter + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Flutter + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + with: + channel: 'stable' + # Caching the latest stable is flaky: the cached SDK contains + # runner-image-specific binaries that break silently when the runner + # image is updated. + cache: false + + - name: Show Flutter version + run: flutter --version + + - name: Bootstrap workspace + run: | + cd ../../ + dart pub global activate melos + melos bootstrap + + - name: Test web (WASM) + # Serial compilation avoids the memory pressure from parallel dart2wasm + # runs that previously caused suite loading timeouts in CI. + run: flutter test --platform chrome --wasm --concurrency=1 + + - name: Build example for web (WASM) + working-directory: packages/supabase_flutter/example + run: flutter build web --wasm + + wasm-result: + name: WASM result + needs: [changes, test-wasm] + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Verify all jobs succeeded + run: | + if ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}; then + echo "One or more jobs failed or were cancelled." + exit 1 + fi + echo "All required jobs passed or were skipped." diff --git a/.sdk-parse-ignore b/.sdk-parse-ignore index 2e9d2c962..03f1296b8 100644 --- a/.sdk-parse-ignore +++ b/.sdk-parse-ignore @@ -7,3 +7,13 @@ # Prefer annotating such declarations with `@internal` (from package:meta): the # extractor honours it and excludes them from the scan. Use this file only for # paths that cannot carry that annotation, e.g. generated sources. + +# supabase_common is an internal, cross-package helper library. Its public +# symbols are imported by the other packages, so they cannot be marked +# @internal (that would trigger invalid_use_of_internal_member in the +# consumers). Exclude the whole package from the public-API scan instead. +packages/supabase_common/ + +# The examples are standalone demo apps, not part of the published SDK, so their +# public classes are not capability-matrix symbols. +examples/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f218065..5af8b949b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,155 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2026-07-15 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`functions_client` - `v2.7.0`](#functions_client---v270) + - [`gotrue` - `v2.27.0`](#gotrue---v2270) + - [`postgrest` - `v2.9.0`](#postgrest---v290) + - [`realtime_client` - `v2.12.0`](#realtime_client---v2120) + - [`storage_client` - `v2.7.0`](#storage_client---v270) + - [`supabase` - `v2.15.0`](#supabase---v2150) + - [`supabase_common` - `v0.1.1`](#supabase_common---v011) + - [`supabase_flutter` - `v2.17.0`](#supabase_flutter---v2170) + - [`supabase_lints` - `v0.1.1`](#supabase_lints---v011) + +--- + +#### `functions_client` - `v2.7.0` + + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **FEAT**(functions): add request cancellation to invoke via abortSignal ([#1593](https://github.com/supabase/supabase-flutter/issues/1593)). ([b994dabd](https://github.com/supabase/supabase-flutter/commit/b994dabdb23aa43540bef6953d739c48521bb532)) + - **FEAT**(functions): expose FunctionsFetchException/FunctionsRelayException/FunctionsHttpException subtypes ([#1545](https://github.com/supabase/supabase-flutter/issues/1545)). ([2d3a7202](https://github.com/supabase/supabase-flutter/commit/2d3a720215837722e25c51507d6be8c6f8f3edb7)) + +#### `gotrue` - `v2.27.0` + + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **REFACTOR**: drop retry dependency in favor of an in-house helper ([#1571](https://github.com/supabase/supabase-flutter/issues/1571)). ([b81d2121](https://github.com/supabase/supabase-flutter/commit/b81d212159f15bf4d9515acf75430550a715664d)) + - **REFACTOR**(gotrue): drop rxdart dependency ([#1566](https://github.com/supabase/supabase-flutter/issues/1566)). ([3f70d5ed](https://github.com/supabase/supabase-flutter/commit/3f70d5edcea9e000156742a29f2a0ae3fb1d112b)) + - **REFACTOR**(gotrue): derive OAuth client enum values from the snakeCase extension ([#1562](https://github.com/supabase/supabase-flutter/issues/1562)). ([ed50706f](https://github.com/supabase/supabase-flutter/commit/ed50706f0b026a3c30ece0b64f5e9e925a2262ca)) + - **REFACTOR**(gotrue): drop jwt_decode dependency in favor of dart_jsonwebtoken ([#1565](https://github.com/supabase/supabase-flutter/issues/1565)). ([b1157efa](https://github.com/supabase/supabase-flutter/commit/b1157efaed65b8127dae0afbd61043d39f9ea2b0)) + - **REFACTOR**(auth): use OAuthProvider.name and cover custom OIDC providers ([#1555](https://github.com/supabase/supabase-flutter/issues/1555)). ([ebb7564d](https://github.com/supabase/supabase-flutter/commit/ebb7564d7112e208b8d38f0279f949b733afca31)) + - **FEAT**(auth): send PKCE code_challenge in updateUser when changing email ([#1601](https://github.com/supabase/supabase-flutter/issues/1601)). ([59d349e4](https://github.com/supabase/supabase-flutter/commit/59d349e43f873389ed3aec6b87a3f4ae4e85c8df)) + - **FEAT**(auth): support friendlyName and user.name fallback for WebAuthn passkey enrollment ([#1603](https://github.com/supabase/supabase-flutter/issues/1603)). ([558b0346](https://github.com/supabase/supabase-flutter/commit/558b03461441d340b1656606d1086382481611f8)) + - **FEAT**(auth): add signInWithWeb3 for Web3 wallet authentication ([#1590](https://github.com/supabase/supabase-flutter/issues/1590)). ([20a82beb](https://github.com/supabase/supabase-flutter/commit/20a82beb2eed6bc2dca86f14aefe0d58e0796c4f)) + - **FEAT**(postgrest): configurable auto-retry and request timeout ([#1560](https://github.com/supabase/supabase-flutter/issues/1560)). ([59d4b3af](https://github.com/supabase/supabase-flutter/commit/59d4b3af227e15a53208146a2930981526a39599)) + - **FEAT**(auth): add OAuth server listGrants and revokeGrant methods ([#1561](https://github.com/supabase/supabase-flutter/issues/1561)). ([67fed239](https://github.com/supabase/supabase-flutter/commit/67fed239e52e0f02213bee7ae71cb043473efe28)) + - **FEAT**(auth): add async getSession() with on-demand refresh ([#1563](https://github.com/supabase/supabase-flutter/issues/1563)). ([0b1d9b6b](https://github.com/supabase/supabase-flutter/commit/0b1d9b6b61adf9f66ed6716032b1a25f096604ce)) + - **FEAT**(gotrue): add channel parameter to mfa.challenge() ([#1547](https://github.com/supabase/supabase-flutter/issues/1547)). ([3bde140e](https://github.com/supabase/supabase-flutter/commit/3bde140e214f6e2928108ec496eb0e96bb9ac258)) + - **FEAT**(gotrue): add currentPassword to UserAttributes ([#1554](https://github.com/supabase/supabase-flutter/issues/1554)). ([b04c6419](https://github.com/supabase/supabase-flutter/commit/b04c6419785842c9d2af1337206b572adcb367c1)) + +#### `postgrest` - `v2.9.0` + + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **FEAT**(postgrest): configurable auto-retry and request timeout ([#1560](https://github.com/supabase/supabase-flutter/issues/1560)). ([59d4b3af](https://github.com/supabase/supabase-flutter/commit/59d4b3af227e15a53208146a2930981526a39599)) + - **FEAT**(postgrest): abort requests ([#1368](https://github.com/supabase/supabase-flutter/issues/1368)). ([c267e4fa](https://github.com/supabase/supabase-flutter/commit/c267e4fa4128508492ddd35651842abee7b41a7e)) + - **FEAT**(database): add dryRun and stripNulls query modifiers ([#1559](https://github.com/supabase/supabase-flutter/issues/1559)). ([896bc1f7](https://github.com/supabase/supabase-flutter/commit/896bc1f71094fad8e390b47d50d5d424011749b0)) + +#### `realtime_client` - `v2.12.0` + + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **FIX**(realtime_client): faster channel rejoin on web ([#1471](https://github.com/supabase/supabase-flutter/issues/1471)). ([bc06a310](https://github.com/supabase/supabase-flutter/commit/bc06a310cf09bdba87668695b48bc8b8f353c495)) + - **FIX**(realtime): patch channel join payloads with access token before flushing ([#1553](https://github.com/supabase/supabase-flutter/issues/1553)). ([e5de0c07](https://github.com/supabase/supabase-flutter/commit/e5de0c075a36129e75462d15b61d0da571e99bc9)) + - **FEAT**(realtime): defer socket disconnect when channels become empty ([#1589](https://github.com/supabase/supabase-flutter/issues/1589)). ([0055d1dc](https://github.com/supabase/supabase-flutter/commit/0055d1dcb6f9a0d2727d908bc68077af707327d0)) + +#### `storage_client` - `v2.7.0` + + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **REFACTOR**: drop retry dependency in favor of an in-house helper ([#1571](https://github.com/supabase/supabase-flutter/issues/1571)). ([b81d2121](https://github.com/supabase/supabase-flutter/commit/b81d212159f15bf4d9515acf75430550a715664d)) + - **REFACTOR**(storage): drop direct http_parser dependency ([#1570](https://github.com/supabase/supabase-flutter/issues/1570)). ([6dadaefb](https://github.com/supabase/supabase-flutter/commit/6dadaefbf847f254789c1f848b456c21cdd29d3e)) + - **FIX**(storage): guard against empty transform routing through render endpoint ([#1551](https://github.com/supabase/supabase-flutter/issues/1551)). ([3f3e6f6c](https://github.com/supabase/supabase-flutter/commit/3f3e6f6c52dfb6020e4ea63b25a26eef855c50eb)) + - **FIX**(storage): make storage_client WASM-compatible ([#1543](https://github.com/supabase/supabase-flutter/issues/1543)). ([7097b2ce](https://github.com/supabase/supabase-flutter/commit/7097b2ce6fdf206d56bdefb682d3ac5e43c8572d)) + - **FEAT**(storage): add analytics (Iceberg) bucket CRUD ([#1588](https://github.com/supabase/supabase-flutter/issues/1588)). ([e3eeb9e6](https://github.com/supabase/supabase-flutter/commit/e3eeb9e6b89465a2f76ef243b42be7a14cbcf174)) + - **FEAT**(storage): add vector buckets support ([#1585](https://github.com/supabase/supabase-flutter/issues/1585)). ([4f7fcc63](https://github.com/supabase/supabase-flutter/commit/4f7fcc63ad9b5d2d4b73e41d37e1ca145440e826)) + - **FEAT**(storage): add downloadStream for streaming file downloads ([#1580](https://github.com/supabase/supabase-flutter/issues/1580)). ([8b839a10](https://github.com/supabase/supabase-flutter/commit/8b839a1028e591d7125e15b66164aa56e4d441e9)) + - **FEAT**(storage): add listPaginated for cursor-based file listing ([#1579](https://github.com/supabase/supabase-flutter/issues/1579)). ([a6428c64](https://github.com/supabase/supabase-flutter/commit/a6428c6481c0b9d8d7a5b91d9e2c5424ed3f5c25)) + - **FEAT**(storage): add cacheNonce parameter for cache invalidation ([#1578](https://github.com/supabase/supabase-flutter/issues/1578)). ([a9ff8086](https://github.com/supabase/supabase-flutter/commit/a9ff808653f16457f6caa8938610292888d65e17)) + - **FEAT**(storage): support filter/sort/pagination options on listBuckets() ([#1557](https://github.com/supabase/supabase-flutter/issues/1557)). ([b72739e2](https://github.com/supabase/supabase-flutter/commit/b72739e231e964a867499c36c08910ef04aa78f9)) + +#### `supabase` - `v2.15.0` + + - **REFACTOR**(supabase): convert private stream-builder holders to records ([#1575](https://github.com/supabase/supabase-flutter/issues/1575)). ([58c2dad4](https://github.com/supabase/supabase-flutter/commit/58c2dad4968d6be36c92ffaa983127827ac97c73)) + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **REFACTOR**(supabase): drop rxdart dependency ([#1569](https://github.com/supabase/supabase-flutter/issues/1569)). ([51dcf0f4](https://github.com/supabase/supabase-flutter/commit/51dcf0f45439f1133a7a97f5ae9929ed3b39076e)) + - **FIX**(supabase_common): align platform stats casing with other SDKs ([#1596](https://github.com/supabase/supabase-flutter/issues/1596)). ([97a6fa6e](https://github.com/supabase/supabase-flutter/commit/97a6fa6eb1f7d5f58b693a5e175fbfe4fe7d2c17)) + - **FIX**(realtime_client): faster channel rejoin on web ([#1471](https://github.com/supabase/supabase-flutter/issues/1471)). ([bc06a310](https://github.com/supabase/supabase-flutter/commit/bc06a310cf09bdba87668695b48bc8b8f353c495)) + - **FEAT**(realtime): defer socket disconnect when channels become empty ([#1589](https://github.com/supabase/supabase-flutter/issues/1589)). ([0055d1dc](https://github.com/supabase/supabase-flutter/commit/0055d1dcb6f9a0d2727d908bc68077af707327d0)) + - **FEAT**(postgrest): configurable auto-retry and request timeout ([#1560](https://github.com/supabase/supabase-flutter/issues/1560)). ([59d4b3af](https://github.com/supabase/supabase-flutter/commit/59d4b3af227e15a53208146a2930981526a39599)) + - **FEAT**(client): add opt-in trace context propagation headers ([#1564](https://github.com/supabase/supabase-flutter/issues/1564)). ([85780e60](https://github.com/supabase/supabase-flutter/commit/85780e607a00904870fe7f68dbd45ee5797256b6)) + - **FEAT**(auth): add async getSession() with on-demand refresh ([#1563](https://github.com/supabase/supabase-flutter/issues/1563)). ([0b1d9b6b](https://github.com/supabase/supabase-flutter/commit/0b1d9b6b61adf9f66ed6716032b1a25f096604ce)) + +#### `supabase_common` - `v0.1.1` + + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **FIX**(supabase_common): align platform stats casing with other SDKs ([#1596](https://github.com/supabase/supabase-flutter/issues/1596)). ([97a6fa6e](https://github.com/supabase/supabase-flutter/commit/97a6fa6eb1f7d5f58b693a5e175fbfe4fe7d2c17)) + +#### `supabase_flutter` - `v2.17.0` + + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **FIX**(realtime_client): faster channel rejoin on web ([#1471](https://github.com/supabase/supabase-flutter/issues/1471)). ([bc06a310](https://github.com/supabase/supabase-flutter/commit/bc06a310cf09bdba87668695b48bc8b8f353c495)) + - **FEAT**(auth): support friendlyName and user.name fallback for WebAuthn passkey enrollment ([#1603](https://github.com/supabase/supabase-flutter/issues/1603)). ([558b0346](https://github.com/supabase/supabase-flutter/commit/558b03461441d340b1656606d1086382481611f8)) + - **FEAT**(client): add opt-in trace context propagation headers ([#1564](https://github.com/supabase/supabase-flutter/issues/1564)). ([85780e60](https://github.com/supabase/supabase-flutter/commit/85780e607a00904870fe7f68dbd45ee5797256b6)) + - **FEAT**(client): add session-URL-detection predicate and persistSession flag ([#1558](https://github.com/supabase/supabase-flutter/issues/1558)). ([c8b02ed0](https://github.com/supabase/supabase-flutter/commit/c8b02ed062d78145671eecc2504875c19377a221)) + - **DOCS**(auth): document getOAuthSignInUrl for URL-without-launch, mark oauth parity implemented ([#1548](https://github.com/supabase/supabase-flutter/issues/1548)). ([1a1c95bf](https://github.com/supabase/supabase-flutter/commit/1a1c95bf178a0af8301d54366ebbf999f911d8f0)) + +#### `supabase_lints` - `v0.1.1` + + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + + +## 2026-07-07 + +### Changes + +--- + +Packages with breaking changes: + + - [`gotrue` - `v2.26.0`](#gotrue---v2260) + +Packages with other changes: + + - [`realtime_client` - `v2.11.0`](#realtime_client---v2110) + - [`supabase` - `v2.14.0`](#supabase---v2140) + - [`supabase_flutter` - `v2.16.0`](#supabase_flutter---v2160) + +--- + +#### `gotrue` - `v2.26.0` + + - **FIX**(gotrue): OAuth server throws FormatException instead of ArgumentE… ([#1535](https://github.com/supabase/supabase-flutter/issues/1535)). ([da0f6e02](https://github.com/supabase/supabase-flutter/commit/da0f6e0236f52cd8dfb087257dbedd4a9de97533)) + - **FEAT**(gotrue): add custom OAuth providers admin API with custom_claims_allowlist ([#1519](https://github.com/supabase/supabase-flutter/issues/1519)). ([122e5cf2](https://github.com/supabase/supabase-flutter/commit/122e5cf2036877c32ce92f807d5a9077d991a7e7)) + - **BREAKING** **FIX**(gotrue): handle already-consented OAuth authorization responses ([#1536](https://github.com/supabase/supabase-flutter/issues/1536)). ([22d41234](https://github.com/supabase/supabase-flutter/commit/22d412346a748d282b39004771f175e37c504a5a)) + +#### `realtime_client` - `v2.11.0` + + - **FEAT**(realtime_client): support new postgres changes filter operators, multi-filter, column selection, and replication-ready events ([#1526](https://github.com/supabase/supabase-flutter/issues/1526)). ([1f9d2951](https://github.com/supabase/supabase-flutter/commit/1f9d29516032c1f4bd8416fd7fa36737a335afaf)) + +#### `supabase` - `v2.14.0` + + - **FEAT**(realtime_client): support new postgres changes filter operators, multi-filter, column selection, and replication-ready events ([#1526](https://github.com/supabase/supabase-flutter/issues/1526)). ([1f9d2951](https://github.com/supabase/supabase-flutter/commit/1f9d29516032c1f4bd8416fd7fa36737a335afaf)) + +#### `supabase_flutter` - `v2.16.0` + + - **FIX**(supabase_flutter): drop passkeys requireResidentKey workaround, bump dependency ([#1521](https://github.com/supabase/supabase-flutter/issues/1521)). ([7907c6cb](https://github.com/supabase/supabase-flutter/commit/7907c6cbc3dc31500a5cf85a81b97efc04f3006c)) + - **FEAT**(supabase_flutter): default debug logging off under flutter test ([#1530](https://github.com/supabase/supabase-flutter/issues/1530)). ([941ee804](https://github.com/supabase/supabase-flutter/commit/941ee80498f7f6d1351351aca449b8a5bc2655da)) + + ## 2026-07-03 ### Changes diff --git a/examples/README.md b/examples/README.md index 4c151bf29..9cef04690 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,9 +3,12 @@ A collection of small apps that each show off a feature of `supabase_flutter`, all sharing a single local Supabase instance. -| Example | What it shows | -| ---------------------- | ----------------------------------------- | -| [`passkeys`](passkeys) | Passkey (WebAuthn) sign in and management | +| Example | What it shows | +| ------------------------------------------- | ------------------------------------------------ | +| [`database_crud`](database_crud) | Database CRUD with PostgREST (`from(...)`) | +| [`edge_functions`](edge_functions) | Invoking Edge Functions (`functions.invoke(...)`)| +| [`passkeys`](passkeys) | Passkey (WebAuthn) sign in and management | +| [`storage_transforms`](storage_transforms) | Storage uploads, downloads and image transforms | ## Quick start diff --git a/examples/database_crud/.gitignore b/examples/database_crud/.gitignore new file mode 100644 index 000000000..f85557248 --- /dev/null +++ b/examples/database_crud/.gitignore @@ -0,0 +1,6 @@ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ +pubspec.lock +pubspec_overrides.yaml diff --git a/examples/database_crud/README.md b/examples/database_crud/README.md new file mode 100644 index 000000000..8174352f5 --- /dev/null +++ b/examples/database_crud/README.md @@ -0,0 +1,56 @@ +# Database CRUD with PostgREST + +A small task manager that shows how to read and write data with +`supabase.from(...)`: + +- **Select** tasks joined to their project (`select('*, projects(name)')`). +- **Filter** by project (`eq`), title (`ilike`) and completion state (`eq`). +- **Order** by priority then creation time (`order`). +- **Insert** a new task and read the created row back (`insert().select()`). +- **Update** a task's title and completion state (`update().eq()`). +- **Delete** a task (`delete().eq()`). + +All database access is in +[`lib/tasks_repository.dart`](lib/tasks_repository.dart), kept separate from the +UI so the queries are easy to read and to drive from an integration test. + +To keep the focus on the Supabase calls, the screen uses plain `setState` and +reloads the list after each write rather than pulling in a state management +package or applying optimistic updates. A larger app would typically reach for a +state management solution (for example Riverpod, Bloc or Provider) instead. + +The tables and sample data come from the shared Supabase config in +[`../supabase`](../supabase): schema in +`migrations/20240601000000_crud_example.sql`, seed rows in `seed.sql`. + +## Running + +From the `examples` directory, run the launcher and pick `database_crud`: + +```bash +./run.sh +``` + +Or run it directly against any project: + +```bash +flutter run \ + --dart-define=SUPABASE_URL=https://YOUR_PROJECT.supabase.co \ + --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY +``` + +## Integration test + +[`integration_test/tasks_test.dart`](integration_test/tasks_test.dart) is an +end-to-end test that drives the app widgets against the local stack: it reads the +seeded tasks, filters them by title, then creates, completes, renames and deletes +a task, asserting on the UI after each step. + +With the local stack running, pass the same defines the app uses and run it on a +device (integration tests need one, so `-d macos`, an emulator or a real device): + +```bash +flutter test integration_test/tasks_test.dart -d macos \ + --dart-define=SUPABASE_URL=http://localhost:54321 \ + --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_LOCAL_PUBLISHABLE_KEY +``` diff --git a/examples/database_crud/analysis_options.yaml b/examples/database_crud/analysis_options.yaml new file mode 100644 index 000000000..10becad33 --- /dev/null +++ b/examples/database_crud/analysis_options.yaml @@ -0,0 +1 @@ +include: package:supabase_lints/analysis_options_flutter.yaml diff --git a/examples/database_crud/integration_test/tasks_test.dart b/examples/database_crud/integration_test/tasks_test.dart new file mode 100644 index 000000000..1793ddf96 --- /dev/null +++ b/examples/database_crud/integration_test/tasks_test.dart @@ -0,0 +1,139 @@ +import 'package:database_crud_example/main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); +const supabasePublishableKey = String.fromEnvironment( + 'SUPABASE_PUBLISHABLE_KEY', +); + +/// End-to-end test that drives the real app widgets against the local Supabase +/// stack, exercising the CRUD flow through the UI: reading the seeded tasks, +/// filtering them, then creating, completing, renaming and deleting a task. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // A unique title so the test's own task never clashes with a leftover from a + // previous run. + final createdTitle = 'E2E task ${DateTime.now().microsecondsSinceEpoch}'; + final renamedTitle = 'E2E renamed ${DateTime.now().microsecondsSinceEpoch}'; + + setUpAll(() async { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabasePublishableKey, + ); + }); + + tearDownAll(() async { + await Supabase.instance.dispose(); + }); + + tearDown(() async { + // Remove anything the test created so it can run repeatedly. + await Supabase.instance.client + .from('tasks') + .delete() + .like('title', 'E2E %'); + }); + + testWidgets('reads, filters and manages tasks through the UI', ( + tester, + ) async { + await tester.pumpWidget(const CrudExampleApp()); + + // The seed tasks load on start. + await _pumpUntilGone(tester, find.byType(CircularProgressIndicator)); + expect(find.text('Book flights'), findsOneWidget); + expect(find.text('Draft the landing page copy'), findsOneWidget); + + // Filter by title (ilike): searching narrows the list to the match. + await tester.enterText( + find.widgetWithText(TextField, 'Search title'), + 'Book', + ); + await _pumpUntilGone(tester, find.text('Draft the landing page copy')); + expect(find.text('Book flights'), findsOneWidget); + + // Clearing the filter brings the other tasks back. + await tester.enterText(find.widgetWithText(TextField, 'Search title'), ''); + await _pumpUntil(tester, find.text('Draft the landing page copy')); + + // Create a task through the new-task dialog (insert). + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextField, 'Title'), + createdTitle, + ); + await tester.tap(find.widgetWithText(FilledButton, 'Create')); + await _pumpUntil(tester, find.text(createdTitle)); + + // Complete it by ticking the checkbox in its tile (update). + await tester.tap(_inTile(createdTitle, find.byType(Checkbox))); + await _pumpUntil( + tester, + _inTile( + createdTitle, + find.byWidgetPredicate((widget) => widget is Checkbox && widget.value!), + ), + ); + + // Rename it through the edit dialog (update). + await tester.tap(_inTile(createdTitle, find.byIcon(Icons.edit))); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ), + renamedTitle, + ); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await _pumpUntil(tester, find.text(renamedTitle)); + expect(find.text(createdTitle), findsNothing); + + // Delete it (delete). + await tester.tap(_inTile(renamedTitle, find.byIcon(Icons.delete))); + await _pumpUntilGone(tester, find.text(renamedTitle)); + }); +} + +/// Finds [target] within the [ListTile] that contains [title]. +Finder _inTile(String title, Finder target) => find.descendant( + of: find.ancestor(of: find.text(title), matching: find.byType(ListTile)), + matching: target, +); + +/// Pumps frames until [finder] matches at least one widget or [timeout] elapses. +/// +/// Reads and writes go over the network, so the UI can't be settled with +/// `pumpAndSettle`; this polls the widget tree instead. +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 20), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isNotEmpty) return; + } + fail('Timed out waiting for: $finder'); +} + +/// The inverse of [_pumpUntil]: pumps until [finder] matches nothing. +Future _pumpUntilGone( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 20), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isEmpty) return; + } + fail('Timed out waiting for it to disappear: $finder'); +} diff --git a/examples/database_crud/lib/main.dart b/examples/database_crud/lib/main.dart new file mode 100644 index 000000000..d7ae282d3 --- /dev/null +++ b/examples/database_crud/lib/main.dart @@ -0,0 +1,467 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'models.dart'; +import 'tasks_repository.dart'; + +const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); +const supabasePublishableKey = String.fromEnvironment( + 'SUPABASE_PUBLISHABLE_KEY', +); + +final messengerKey = GlobalKey(); + +Future main() async { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabasePublishableKey, + ); + runApp(const CrudExampleApp()); +} + +SupabaseClient get supabase => Supabase.instance.client; + +class CrudExampleApp extends StatelessWidget { + const CrudExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Supabase Database CRUD', + scaffoldMessengerKey: messengerKey, + theme: ThemeData(colorSchemeSeed: Colors.green, useMaterial3: true), + home: const TasksPage(), + ); + } +} + +/// Lists tasks with filtering, ordering and a join to their project, and lets +/// you create, update and delete them. +class TasksPage extends StatefulWidget { + const TasksPage({super.key}); + + @override + State createState() => _TasksPageState(); +} + +class _TasksPageState extends State { + final _repository = TasksRepository(supabase); + final _search = TextEditingController(); + + List _projects = []; + List _tasks = []; + String? _projectFilter; + bool _onlyIncomplete = false; + bool _loading = true; + bool _mutating = false; + Timer? _debounce; + + /// Bumped on every task reload so a slower earlier request can't overwrite the + /// results of a later one. + int _requestId = 0; + + @override + void initState() { + super.initState(); + unawaited(_init()); + } + + @override + void dispose() { + _debounce?.cancel(); + _search.dispose(); + super.dispose(); + } + + /// Loads the projects once, then the tasks for the current filters. + Future _init() async { + try { + final projects = await _repository.fetchProjects(); + if (mounted) setState(() => _projects = projects); + } catch (error) { + _showError(error); + } + await _loadTasks(); + } + + /// Reloads the task list for the current filters. Leaves the previous list on + /// screen while it runs, so changing a filter or toggling a task doesn't flash + /// a spinner over the whole list. + Future _loadTasks() async { + final requestId = ++_requestId; + // Ignore a response if a newer reload started or the widget went away while + // this request was in flight. + bool isStale() => !mounted || requestId != _requestId; + try { + final tasks = await _repository.fetchTasks( + projectId: _projectFilter, + search: _search.text.trim(), + onlyIncomplete: _onlyIncomplete, + ); + if (isStale()) return; + setState(() => _tasks = tasks); + } catch (error) { + if (isStale()) return; + _showError(error); + } finally { + if (!isStale()) setState(() => _loading = false); + } + } + + /// Debounces the search field so typing doesn't fire a query per keystroke + /// and race the responses back out of order. + void _onSearchChanged(String _) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), _loadTasks); + } + + /// Runs a single write, then reloads the list. Ignores the call if another + /// write is already in flight, so a double tap can't fire duplicate requests. + Future _mutate(Future Function() write) async { + if (_mutating) return; + setState(() => _mutating = true); + try { + await write(); + await _loadTasks(); + } catch (error) { + _showError(error); + } finally { + if (mounted) setState(() => _mutating = false); + } + } + + Future _toggle(Task task) => _mutate( + () => _repository.setTaskComplete( + id: task.id, + isComplete: !task.isComplete, + ), + ); + + Future _delete(Task task) => + _mutate(() => _repository.deleteTask(task.id)); + + Future _create() async { + final result = await showDialog<_TaskFormResult>( + context: context, + builder: (context) => _TaskDialog(projects: _projects), + ); + if (result == null) return; + await _mutate( + () => _repository.createTask( + projectId: result.projectId, + title: result.title, + priority: result.priority, + ), + ); + } + + Future _rename(Task task) async { + final controller = TextEditingController(text: task.title); + final title = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Rename task'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Title'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('Save'), + ), + ], + ), + ); + if (title == null || title.isEmpty) return; + await _mutate(() => _repository.renameTask(id: task.id, title: title)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Tasks')), + floatingActionButton: FloatingActionButton.extended( + onPressed: _projects.isEmpty || _mutating ? null : _create, + icon: const Icon(Icons.add), + label: const Text('New task'), + ), + body: Column( + children: [ + _Filters( + projects: _projects, + projectFilter: _projectFilter, + search: _search, + onlyIncomplete: _onlyIncomplete, + onProjectChanged: (value) { + setState(() => _projectFilter = value); + unawaited(_loadTasks()); + }, + onSearchChanged: _onSearchChanged, + onOnlyIncompleteChanged: (value) { + setState(() => _onlyIncomplete = value); + unawaited(_loadTasks()); + }, + ), + const Divider(height: 1), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _tasks.isEmpty + ? const Center(child: Text('No tasks match these filters.')) + : RefreshIndicator( + onRefresh: _loadTasks, + child: ListView.builder( + itemCount: _tasks.length, + itemBuilder: (context, index) { + final task = _tasks[index]; + return _TaskTile( + task: task, + enabled: !_mutating, + onToggle: () => _toggle(task), + onRename: () => _rename(task), + onDelete: () => _delete(task), + ); + }, + ), + ), + ), + ], + ), + ); + } +} + +class _Filters extends StatelessWidget { + const _Filters({ + required this.projects, + required this.projectFilter, + required this.search, + required this.onlyIncomplete, + required this.onProjectChanged, + required this.onSearchChanged, + required this.onOnlyIncompleteChanged, + }); + + final List projects; + final String? projectFilter; + final TextEditingController search; + final bool onlyIncomplete; + final ValueChanged onProjectChanged; + final ValueChanged onSearchChanged; + final ValueChanged onOnlyIncompleteChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: search, + onChanged: onSearchChanged, + decoration: const InputDecoration( + labelText: 'Search title', + prefixIcon: Icon(Icons.search), + isDense: true, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + initialValue: projectFilter, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Project', + isDense: true, + ), + items: [ + const DropdownMenuItem(child: Text('All projects')), + for (final project in projects) + DropdownMenuItem( + value: project.id, + child: Text(project.name), + ), + ], + onChanged: onProjectChanged, + ), + ), + ], + ), + CheckboxListTile( + value: onlyIncomplete, + onChanged: (value) => onOnlyIncompleteChanged(value ?? false), + title: const Text('Only incomplete'), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + ], + ), + ); + } +} + +class _TaskTile extends StatelessWidget { + const _TaskTile({ + required this.task, + required this.enabled, + required this.onToggle, + required this.onRename, + required this.onDelete, + }); + + final Task task; + final bool enabled; + final VoidCallback onToggle; + final VoidCallback onRename; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Checkbox( + value: task.isComplete, + onChanged: enabled ? (_) => onToggle() : null, + ), + title: Text( + task.title, + style: task.isComplete + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text( + '${task.projectName ?? 'Unknown'} · ${task.priority.label} priority', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Rename', + onPressed: enabled ? onRename : null, + ), + IconButton( + icon: const Icon(Icons.delete), + tooltip: 'Delete', + onPressed: enabled ? onDelete : null, + ), + ], + ), + ); + } +} + +class _TaskFormResult { + const _TaskFormResult({ + required this.projectId, + required this.title, + required this.priority, + }); + + final String projectId; + final String title; + final Priority priority; +} + +class _TaskDialog extends StatefulWidget { + const _TaskDialog({required this.projects}); + + final List projects; + + @override + State<_TaskDialog> createState() => _TaskDialogState(); +} + +class _TaskDialogState extends State<_TaskDialog> { + final _title = TextEditingController(); + late String _projectId = widget.projects.first.id; + Priority _priority = Priority.low; + + @override + void dispose() { + _title.dispose(); + super.dispose(); + } + + void _submit() { + final title = _title.text.trim(); + if (title.isEmpty) return; + Navigator.pop( + context, + _TaskFormResult( + projectId: _projectId, + title: title, + priority: _priority, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('New task'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _title, + autofocus: true, + decoration: const InputDecoration(labelText: 'Title'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _projectId, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Project'), + items: [ + for (final project in widget.projects) + DropdownMenuItem( + value: project.id, + child: Text(project.name), + ), + ], + onChanged: (value) => setState(() => _projectId = value!), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _priority, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Priority'), + items: [ + for (final priority in Priority.values) + DropdownMenuItem(value: priority, child: Text(priority.label)), + ], + onChanged: (value) => setState(() => _priority = value!), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _submit, child: const Text('Create')), + ], + ); + } +} + +void _showError(Object error) { + final message = error is PostgrestException + ? error.message + : error.toString(); + messengerKey.currentState?.showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red), + ); +} diff --git a/examples/database_crud/lib/models.dart b/examples/database_crud/lib/models.dart new file mode 100644 index 000000000..73f60b67f --- /dev/null +++ b/examples/database_crud/lib/models.dart @@ -0,0 +1,66 @@ +/// How urgent a [Task] is. Stored in the database as the integer [value]. +enum Priority { + low(1, 'Low'), + medium(2, 'Medium'), + high(3, 'High'); + + const Priority(this.value, this.label); + + factory Priority.fromValue(int value) => + values.firstWhere((priority) => priority.value == value); + + final int value; + final String label; +} + +/// A project that groups tasks together. +class Project { + const Project({required this.id, required this.name}); + + factory Project.fromJson(Map json) => Project( + id: json['id'] as String, + name: json['name'] as String, + ); + + final String id; + final String name; +} + +/// A single task belonging to a [Project]. +class Task { + const Task({ + required this.id, + required this.projectId, + required this.title, + required this.isComplete, + required this.priority, + required this.createdAt, + this.projectName, + }); + + factory Task.fromJson(Map json) { + // When the task is fetched with a join, the related project is nested under + // the `projects` key. + final project = json['projects'] as Map?; + return Task( + id: json['id'] as String, + projectId: json['project_id'] as String, + title: json['title'] as String, + isComplete: json['is_complete'] as bool, + priority: Priority.fromValue(json['priority'] as int), + createdAt: DateTime.parse(json['created_at'] as String), + projectName: project?['name'] as String?, + ); + } + + final String id; + final String projectId; + final String title; + final bool isComplete; + final Priority priority; + final DateTime createdAt; + + /// Name of the task's project, populated from the embedded `projects` row when + /// the task is fetched with a join. + final String? projectName; +} diff --git a/examples/database_crud/lib/tasks_repository.dart b/examples/database_crud/lib/tasks_repository.dart new file mode 100644 index 000000000..512c3bac9 --- /dev/null +++ b/examples/database_crud/lib/tasks_repository.dart @@ -0,0 +1,101 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'models.dart'; + +/// All database access for the CRUD example lives here, so the UI stays thin and +/// every `supabase.from(...)` call is easy to read and to exercise from an +/// integration test. +class TasksRepository { + TasksRepository(this._client); + + final SupabaseClient _client; + + /// Columns for a task plus its related project, used everywhere a task is + /// returned so the join is consistent. + static const _taskColumns = '*, projects(name)'; + + /// SELECT every project, ordered alphabetically by name. + Future> fetchProjects() async { + final rows = await _client.from('projects').select().order('name'); + return rows.map(Project.fromJson).toList(); + } + + /// SELECT tasks joined to their project, with optional filters and ordering. + /// + /// * [projectId] restricts to a single project (`eq`). + /// * [search] matches the title case-insensitively (`ilike`). + /// * [onlyIncomplete] hides finished tasks (`eq`). + /// + /// Results are ordered by priority (highest first) then creation time. + Future> fetchTasks({ + String? projectId, + String? search, + bool onlyIncomplete = false, + }) async { + // Embed the related project row (`projects(name)`) to demonstrate a join. + var query = _client.from('tasks').select(_taskColumns); + + if (projectId != null) { + query = query.eq('project_id', projectId); + } + if (search != null && search.isNotEmpty) { + query = query.ilike('title', '%$search%'); + } + if (onlyIncomplete) { + query = query.eq('is_complete', false); + } + + final rows = await query + .order('priority', ascending: false) + .order('created_at'); + return rows.map(Task.fromJson).toList(); + } + + /// INSERT a task and return the created row (joined to its project). + Future createTask({ + required String projectId, + required String title, + Priority priority = Priority.low, + }) async { + final row = await _client + .from('tasks') + .insert({ + 'project_id': projectId, + 'title': title, + 'priority': priority.value, + }) + .select(_taskColumns) + .single(); + return Task.fromJson(row); + } + + /// UPDATE a task's completion state and return the updated row. + Future setTaskComplete({ + required String id, + required bool isComplete, + }) async { + final row = await _client + .from('tasks') + .update({'is_complete': isComplete}) + .eq('id', id) + .select(_taskColumns) + .single(); + return Task.fromJson(row); + } + + /// UPDATE a task's title and return the updated row. + Future renameTask({required String id, required String title}) async { + final row = await _client + .from('tasks') + .update({'title': title}) + .eq('id', id) + .select(_taskColumns) + .single(); + return Task.fromJson(row); + } + + /// DELETE a task. + Future deleteTask(String id) async { + await _client.from('tasks').delete().eq('id', id); + } +} diff --git a/examples/database_crud/pubspec.yaml b/examples/database_crud/pubspec.yaml new file mode 100644 index 000000000..6edaae42d --- /dev/null +++ b/examples/database_crud/pubspec.yaml @@ -0,0 +1,27 @@ +name: database_crud_example +description: Demonstrates database CRUD with PostgREST using supabase_flutter. + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.9.0 <4.0.0' + flutter: '>=3.35.0' + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + supabase_flutter: ^2.17.0 + +dev_dependencies: + supabase_lints: ^0.1.1 + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/examples/database_crud/web/index.html b/examples/database_crud/web/index.html new file mode 100644 index 000000000..cc01d6f17 --- /dev/null +++ b/examples/database_crud/web/index.html @@ -0,0 +1,13 @@ + + + + + + + + Supabase Database CRUD + + + + + diff --git a/examples/edge_functions/.gitignore b/examples/edge_functions/.gitignore new file mode 100644 index 000000000..f85557248 --- /dev/null +++ b/examples/edge_functions/.gitignore @@ -0,0 +1,6 @@ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ +pubspec.lock +pubspec_overrides.yaml diff --git a/examples/edge_functions/README.md b/examples/edge_functions/README.md new file mode 100644 index 000000000..fd2477485 --- /dev/null +++ b/examples/edge_functions/README.md @@ -0,0 +1,68 @@ +# Edge Functions + +A small app that shows how to call Supabase Edge Functions with +`supabase.functions.invoke(...)`: + +- **Invoke with a JSON body** over POST and read the JSON response back + (`invoke('greet', body: {...})`). +- **Invoke over GET** with query parameters instead of a body + (`invoke('greet', method: HttpMethod.get, queryParameters: {...})`). +- **Send custom headers** to the function (`headers: {...}`), echoed back in the + response. +- **Send and receive plain text**: a `String` body is sent as `text/plain` and a + `text/plain` response comes back as a `String` (`invoke('shout', body: text)`). +- **Handle errors**: a non-2xx response throws a `FunctionException` whose + `details` carry the response body (`invoke('word-count', ...)`). + +All Edge Function access is in +[`lib/functions_repository.dart`](lib/functions_repository.dart), kept separate +from the UI so the calls are easy to read and to drive from an integration test. + +To keep the focus on the Supabase calls, the screen uses plain `setState` rather +than pulling in a state management package. A larger app would typically reach +for a state management solution (for example Riverpod, Bloc or Provider) instead. + +The functions live in the shared Supabase config in +[`../supabase`](../supabase): their code is under `functions/` (`greet`, `shout` +and `word-count`), and the Edge Runtime is enabled in `config.toml` +(`[edge_runtime]`). `supabase start` serves every function while the stack is +running. The functions need no database tables or seed data. + +The demo functions set `verify_jwt = false` in `config.toml` so the example can +call them with just the publishable (anon) key, matching the other examples, +which run unauthenticated. A real app would leave JWT verification on and read +the caller's user from the request. + +## Running + +From the `examples` directory, run the launcher and pick `edge_functions`: + +```bash +./run.sh +``` + +Or run it directly against any project (with the functions deployed there): + +```bash +flutter run \ + --dart-define=SUPABASE_URL=https://YOUR_PROJECT.supabase.co \ + --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY +``` + +## Integration test + +[`integration_test/functions_test.dart`](integration_test/functions_test.dart) +is an end-to-end test that runs against the local stack. It drives the flow +through the repository (a JSON greeting over POST and GET, a plain-text +transform, and a validation error that surfaces as a `FunctionException`) and +drives the app widgets to greet through the UI. + +With the local stack running, pass the same defines the app uses and run it on a +device (integration tests need one, so `-d macos`, an emulator or a real +device): + +```bash +flutter test integration_test/functions_test.dart -d macos \ + --dart-define=SUPABASE_URL=http://localhost:54321 \ + --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_LOCAL_PUBLISHABLE_KEY +``` diff --git a/examples/edge_functions/analysis_options.yaml b/examples/edge_functions/analysis_options.yaml new file mode 100644 index 000000000..10becad33 --- /dev/null +++ b/examples/edge_functions/analysis_options.yaml @@ -0,0 +1 @@ +include: package:supabase_lints/analysis_options_flutter.yaml diff --git a/examples/edge_functions/integration_test/functions_test.dart b/examples/edge_functions/integration_test/functions_test.dart new file mode 100644 index 000000000..dbc170a01 --- /dev/null +++ b/examples/edge_functions/integration_test/functions_test.dart @@ -0,0 +1,103 @@ +import 'package:edge_functions_example/functions_repository.dart'; +import 'package:edge_functions_example/main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); +const supabasePublishableKey = String.fromEnvironment( + 'SUPABASE_PUBLISHABLE_KEY', +); + +/// End-to-end tests that drive the Edge Functions example against the local +/// stack. +/// +/// The first test exercises the core flow through the repository (a JSON +/// greeting over POST and GET, a plain-text transform, and a validation error), +/// asserting on what each function returns. The second drives the app widgets to +/// confirm the greeting card is wired to the function. Edge Functions are +/// stateless, so there is nothing to clean up between runs. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late FunctionsRepository repository; + + setUpAll(() async { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabasePublishableKey, + ); + repository = FunctionsRepository(Supabase.instance.client); + }); + + tearDownAll(() async { + await Supabase.instance.dispose(); + }); + + testWidgets('invokes the example functions through the repository', ( + tester, + ) async { + // POST with a JSON body: the function greets and echoes how it was called. + final posted = await repository.greet(name: 'Ada', excited: true); + expect(posted.message, 'Hello, Ada!!!'); + expect(posted.method, 'POST'); + expect(posted.source, 'flutter-app'); + + // GET with a query parameter reaches the same function a different way. + final fetched = await repository.greetViaQuery('Grace'); + expect(fetched.message, 'Hello, Grace.'); + expect(fetched.method, 'GET'); + + // A plain-text response comes back as a String. + final shouted = await repository.shout('edge functions'); + expect(shouted, 'EDGE FUNCTIONS'); + + // A JSON response decodes into the model. + final count = await repository.countWords('one two three'); + expect(count.words, 3); + expect(count.characters, 13); + + // An empty input makes the function respond with a 400, which surfaces as a + // FunctionException carrying the JSON error body. + await expectLater( + repository.countWords(''), + throwsA( + isA() + .having((error) => error.status, 'status', 400) + .having( + (error) => (error.details as Map)['error'], + 'details.error', + isA(), + ), + ), + ); + }); + + testWidgets('greets through the UI', (tester) async { + await tester.pumpWidget(const EdgeFunctionsExampleApp()); + await tester.pumpAndSettle(); + + // Tapping "Greet (POST)" invokes the function and shows its message. + await tester.tap(find.widgetWithText(FilledButton, 'Greet (POST)')); + await _pumpUntil(tester, find.textContaining('Hello, Ada')); + expect(find.textContaining('via POST'), findsOneWidget); + }); +} + +/// Pumps frames until [finder] matches at least one widget or [timeout] elapses. +/// +/// Invoking a function goes over the network, so the UI can't be settled with +/// `pumpAndSettle`; this polls the widget tree instead. +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 20), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isNotEmpty) return; + } + fail('Timed out waiting for: $finder'); +} diff --git a/examples/edge_functions/lib/functions_repository.dart b/examples/edge_functions/lib/functions_repository.dart new file mode 100644 index 000000000..2c99c3aca --- /dev/null +++ b/examples/edge_functions/lib/functions_repository.dart @@ -0,0 +1,63 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'models.dart'; + +/// All Edge Function access for the example lives here, so the UI stays thin and +/// every `supabase.functions.invoke(...)` call is easy to read and to exercise +/// from an integration test. +class FunctionsRepository { + FunctionsRepository(this._client); + + final SupabaseClient _client; + + FunctionsClient get _functions => _client.functions; + + /// Invokes the `greet` function over POST with a JSON body and returns the + /// greeting it builds server-side. + /// + /// The custom `x-greeting-source` header is echoed back in the response, so + /// the example can show that headers set here reach the function. A JSON body + /// comes back decoded as a `Map`, which [Greeting.fromJson] turns into a model. + Future greet({required String name, bool excited = false}) async { + final response = await _functions.invoke( + 'greet', + body: {'name': name, 'excited': excited}, + headers: const {'x-greeting-source': 'flutter-app'}, + ); + return Greeting.fromJson(response.data as Map); + } + + /// Invokes the same `greet` function over GET, passing the name as a query + /// parameter instead of a body. + Future greetViaQuery(String name) async { + final response = await _functions.invoke( + 'greet', + method: HttpMethod.get, + queryParameters: {'name': name}, + ); + return Greeting.fromJson(response.data as Map); + } + + /// Sends [text] to the `shout` function and returns the uppercased text it + /// responds with. + /// + /// A `String` body is sent as `text/plain`, and the function replies with + /// `text/plain` too, so `response.data` is a `String` rather than decoded JSON. + Future shout(String text) async { + final response = await _functions.invoke('shout', body: text); + return response.data as String; + } + + /// Invokes the `word-count` function, which validates its input. + /// + /// When [text] is empty the function replies with a 400 and a JSON error body, + /// which surfaces here as a [FunctionException] whose `details` hold that body. + /// The caller is expected to handle that exception. + Future countWords(String text) async { + final response = await _functions.invoke( + 'word-count', + body: {'text': text}, + ); + return WordCount.fromJson(response.data as Map); + } +} diff --git a/examples/edge_functions/lib/main.dart b/examples/edge_functions/lib/main.dart new file mode 100644 index 000000000..753f86e0f --- /dev/null +++ b/examples/edge_functions/lib/main.dart @@ -0,0 +1,374 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'functions_repository.dart'; +import 'models.dart'; + +const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); +const supabasePublishableKey = String.fromEnvironment( + 'SUPABASE_PUBLISHABLE_KEY', +); + +final messengerKey = GlobalKey(); + +Future main() async { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabasePublishableKey, + ); + runApp(const EdgeFunctionsExampleApp()); +} + +SupabaseClient get supabase => Supabase.instance.client; + +class EdgeFunctionsExampleApp extends StatelessWidget { + const EdgeFunctionsExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Supabase Edge Functions', + scaffoldMessengerKey: messengerKey, + theme: ThemeData(colorSchemeSeed: Colors.deepPurple, useMaterial3: true), + home: const FunctionsPage(), + ); + } +} + +/// Invokes the example's Edge Functions and shows what each one returns: a JSON +/// greeting (over POST and GET), a plain-text transform, and a validating +/// function whose error response is surfaced to the user. Each card drives one +/// function through the shared [FunctionsRepository]. +class FunctionsPage extends StatefulWidget { + const FunctionsPage({super.key}); + + @override + State createState() => _FunctionsPageState(); +} + +class _FunctionsPageState extends State { + final _repository = FunctionsRepository(supabase); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Edge Functions')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _GreetCard(repository: _repository), + const SizedBox(height: 16), + _ShoutCard(repository: _repository), + const SizedBox(height: 16), + _WordCountCard(repository: _repository), + ], + ), + ); + } +} + +/// Invokes `greet` over POST and GET, both building the message server-side. +class _GreetCard extends StatefulWidget { + const _GreetCard({required this.repository}); + + final FunctionsRepository repository; + + @override + State<_GreetCard> createState() => _GreetCardState(); +} + +class _GreetCardState extends State<_GreetCard> { + final _name = TextEditingController(text: 'Ada'); + bool _excited = false; + Greeting? _greeting; + + /// Which button is running (`post` or `get`), or null when idle, so only the + /// tapped button shows a spinner and neither fires twice. + String? _running; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + Future _run(String key, Future Function() action) async { + if (_running != null) return; + setState(() => _running = key); + try { + final greeting = await action(); + if (mounted) setState(() => _greeting = greeting); + } catch (error) { + _showError(error); + } finally { + if (mounted) setState(() => _running = null); + } + } + + @override + Widget build(BuildContext context) { + return _DemoCard( + title: 'Greeting', + description: + 'Invokes the greet function, which builds the message server-side. ' + 'POST sends the name in a JSON body; GET sends it as a query ' + 'parameter.', + children: [ + TextField( + controller: _name, + decoration: const InputDecoration(labelText: 'Name'), + ), + CheckboxListTile( + value: _excited, + onChanged: (value) => setState(() => _excited = value ?? false), + title: const Text('Excited'), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + Row( + children: [ + _RunButton( + label: 'Greet (POST)', + running: _running == 'post', + onPressed: () => _run( + 'post', + () => widget.repository.greet( + name: _name.text.trim(), + excited: _excited, + ), + ), + ), + const SizedBox(width: 12), + _RunButton( + label: 'Greet (GET)', + filled: false, + running: _running == 'get', + onPressed: () => _run( + 'get', + () => widget.repository.greetViaQuery(_name.text.trim()), + ), + ), + ], + ), + if (_greeting case final greeting?) + _Result( + '${greeting.message}\n' + 'via ${greeting.method}, source "${greeting.source}"', + ), + ], + ); + } +} + +/// Invokes `shout`, which returns the text uppercased as plain text. +class _ShoutCard extends StatefulWidget { + const _ShoutCard({required this.repository}); + + final FunctionsRepository repository; + + @override + State<_ShoutCard> createState() => _ShoutCardState(); +} + +class _ShoutCardState extends State<_ShoutCard> { + final _text = TextEditingController(text: 'edge functions'); + String? _result; + bool _running = false; + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + Future _run() async { + if (_running) return; + setState(() => _running = true); + try { + final result = await widget.repository.shout(_text.text); + if (mounted) setState(() => _result = result); + } catch (error) { + _showError(error); + } finally { + if (mounted) setState(() => _running = false); + } + } + + @override + Widget build(BuildContext context) { + return _DemoCard( + title: 'Plain text', + description: + 'Sends text to the shout function, which returns it uppercased as ' + 'text/plain. A plain-text response arrives as a Dart String.', + children: [ + TextField( + controller: _text, + decoration: const InputDecoration(labelText: 'Text to shout'), + ), + _RunButton(label: 'Shout', running: _running, onPressed: _run), + if (_result case final result?) _Result(result), + ], + ); + } +} + +/// Invokes `word-count`, which validates its input and responds with a 400 when +/// the text is empty, surfacing here as a [FunctionException]. +class _WordCountCard extends StatefulWidget { + const _WordCountCard({required this.repository}); + + final FunctionsRepository repository; + + @override + State<_WordCountCard> createState() => _WordCountCardState(); +} + +class _WordCountCardState extends State<_WordCountCard> { + final _text = TextEditingController(text: 'Functions run close to the user'); + WordCount? _result; + bool _running = false; + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + Future _run() async { + if (_running) return; + setState(() => _running = true); + try { + // Send the text unchanged so the function counts the submitted + // characters; it trims internally to validate and split into words. + final result = await widget.repository.countWords(_text.text); + if (mounted) setState(() => _result = result); + } catch (error) { + _showError(error); + } finally { + if (mounted) setState(() => _running = false); + } + } + + @override + Widget build(BuildContext context) { + return _DemoCard( + title: 'Validation and errors', + description: + 'Sends text to the word-count function. Clearing the field makes it ' + 'respond with a 400, which surfaces as a FunctionException shown ' + 'below.', + children: [ + TextField( + controller: _text, + decoration: const InputDecoration(labelText: 'Text to count'), + ), + _RunButton(label: 'Count words', running: _running, onPressed: _run), + if (_result case final result?) + _Result('${result.words} words, ${result.characters} characters'), + ], + ); + } +} + +class _DemoCard extends StatelessWidget { + const _DemoCard({ + required this.title, + required this.description, + required this.children, + }); + + final String title; + final String description; + final List children; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text(description, style: theme.textTheme.bodySmall), + const SizedBox(height: 12), + ...children, + ], + ), + ), + ); + } +} + +class _RunButton extends StatelessWidget { + const _RunButton({ + required this.label, + required this.running, + required this.onPressed, + this.filled = true, + }); + + final String label; + final bool running; + final VoidCallback onPressed; + final bool filled; + + @override + Widget build(BuildContext context) { + final child = running + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(label); + final onPressedOrNull = running ? null : onPressed; + return filled + ? FilledButton(onPressed: onPressedOrNull, child: child) + : OutlinedButton(onPressed: onPressedOrNull, child: child); + } +} + +class _Result extends StatelessWidget { + const _Result(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text(text, style: theme.textTheme.bodyMedium), + ); + } +} + +void _showError(Object error) { + // A function that responds with a non-2xx status throws a FunctionException. + // Its `details` hold the response body, which is the `{ "error": ... }` JSON + // the word-count function returns when validation fails. + String message; + if (error is FunctionException) { + final details = error.details; + message = details is Map && details['error'] is String + ? details['error'] as String + : 'Function failed with status ${error.status}'; + } else { + message = error.toString(); + } + messengerKey.currentState?.showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red), + ); +} diff --git a/examples/edge_functions/lib/models.dart b/examples/edge_functions/lib/models.dart new file mode 100644 index 000000000..47a2ad6c0 --- /dev/null +++ b/examples/edge_functions/lib/models.dart @@ -0,0 +1,35 @@ +/// The JSON greeting returned by the `greet` Edge Function. +class Greeting { + const Greeting({ + required this.message, + required this.method, + required this.source, + }); + + factory Greeting.fromJson(Map json) => Greeting( + message: json['message'] as String, + // How the function was invoked (`GET` or `POST`), echoed back so the app can + // show that the same function was reached two different ways. + method: json['method'] as String, + // The `x-greeting-source` header the app sent, echoed back to show that + // custom headers reach the function. + source: json['source'] as String, + ); + + final String message; + final String method; + final String source; +} + +/// The JSON result returned by the `word-count` Edge Function. +class WordCount { + const WordCount({required this.words, required this.characters}); + + factory WordCount.fromJson(Map json) => WordCount( + words: json['words'] as int, + characters: json['characters'] as int, + ); + + final int words; + final int characters; +} diff --git a/examples/edge_functions/pubspec.yaml b/examples/edge_functions/pubspec.yaml new file mode 100644 index 000000000..ec912103d --- /dev/null +++ b/examples/edge_functions/pubspec.yaml @@ -0,0 +1,27 @@ +name: edge_functions_example +description: Demonstrates invoking Edge Functions using supabase_flutter. + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.9.0 <4.0.0' + flutter: '>=3.35.0' + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + supabase_flutter: ^2.17.0 + +dev_dependencies: + supabase_lints: ^0.1.1 + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/examples/edge_functions/web/index.html b/examples/edge_functions/web/index.html new file mode 100644 index 000000000..985f03bea --- /dev/null +++ b/examples/edge_functions/web/index.html @@ -0,0 +1,13 @@ + + + + + + + + Supabase Edge Functions + + + + + diff --git a/examples/launcher/analysis_options.yaml b/examples/launcher/analysis_options.yaml index 572dd239d..a8ab68344 100644 --- a/examples/launcher/analysis_options.yaml +++ b/examples/launcher/analysis_options.yaml @@ -1 +1 @@ -include: package:lints/recommended.yaml +include: package:supabase_lints/analysis_options.yaml diff --git a/examples/launcher/pubspec.yaml b/examples/launcher/pubspec.yaml index a48b93e75..a0c91113b 100644 --- a/examples/launcher/pubspec.yaml +++ b/examples/launcher/pubspec.yaml @@ -11,7 +11,7 @@ environment: resolution: workspace dependencies: - mason_logger: ^0.3.0 + mason_logger: ^0.3.5 dev_dependencies: - lints: ^4.0.0 + supabase_lints: ^0.1.1 diff --git a/examples/passkeys/README.md b/examples/passkeys/README.md index 0f42d7877..47cd26e76 100644 --- a/examples/passkeys/README.md +++ b/examples/passkeys/README.md @@ -77,3 +77,23 @@ Then: > Passkeys are bound to the domain (relying party) they were created on, so a > passkey registered on `localhost` only works on `localhost`. + +## Integration test + +[`integration_test/passkeys_test.dart`](integration_test/passkeys_test.dart) is +an end-to-end test that drives the app widgets against the local stack: it +creates an account, lands on the passkey management screen, signs out, tries a +wrong password and signs back in. + +The WebAuthn ceremony itself (`registerPasskey` / `signInWithPasskey`) drives a +platform authenticator prompt that cannot be automated headlessly, so it is +exercised manually with the steps above rather than in this test. + +With the local stack running, pass the same defines the app uses and run it on a +device (integration tests need one, so `-d macos`, an emulator or a real device): + +```bash +flutter test integration_test/passkeys_test.dart -d macos \ + --dart-define=SUPABASE_URL=http://localhost:54321 \ + --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_LOCAL_PUBLISHABLE_KEY +``` diff --git a/examples/passkeys/analysis_options.yaml b/examples/passkeys/analysis_options.yaml index e7db0910a..01ce0e823 100644 --- a/examples/passkeys/analysis_options.yaml +++ b/examples/passkeys/analysis_options.yaml @@ -1,4 +1,4 @@ -include: package:flutter_lints/flutter.yaml +include: package:supabase_lints/analysis_options_flutter.yaml analyzer: errors: diff --git a/examples/passkeys/integration_test/passkeys_test.dart b/examples/passkeys/integration_test/passkeys_test.dart new file mode 100644 index 000000000..8a88ce295 --- /dev/null +++ b/examples/passkeys/integration_test/passkeys_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:passkeys_example/main.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); +const supabasePublishableKey = String.fromEnvironment( + 'SUPABASE_PUBLISHABLE_KEY', +); + +/// End-to-end test that drives the passkeys app widgets against the local +/// Supabase stack. +/// +/// It covers everything around the WebAuthn ceremony: creating an account, +/// landing on the passkey management screen, signing out, a failed sign in and a +/// successful password sign in. The ceremony itself (`registerPasskey` / +/// `signInWithPasskey`) drives a platform authenticator prompt (Face ID, Windows +/// Hello, a security key, ...) that can't be automated headlessly, so it is +/// exercised manually per the README rather than here. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // A unique account each run so sign up never collides with an existing user. + final email = + 'passkeys-e2e-${DateTime.now().microsecondsSinceEpoch}@example.com'; + const password = 'password123'; + + setUpAll(() async { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabasePublishableKey, + ); + }); + + tearDownAll(() async { + await Supabase.instance.client.auth.signOut(); + await Supabase.instance.dispose(); + }); + + testWidgets('signs up, manages the session and signs back in', ( + tester, + ) async { + await tester.pumpWidget(const PasskeyExampleApp()); + await tester.pumpAndSettle(); + + // Create an account. Email confirmations are disabled in the shared config, + // so this returns a session and lands on the passkey management screen. + await tester.enterText(find.widgetWithText(TextField, 'Email'), email); + await tester.enterText( + find.widgetWithText(TextField, 'Password'), + password, + ); + await tester.tap(find.widgetWithText(OutlinedButton, 'Create an account')); + + // The signed-in screen lists passkeys; a fresh account has none. + await _pumpUntil(tester, find.text('No passkeys yet.')); + expect(find.text(email), findsOneWidget); + expect( + find.widgetWithText(FilledButton, 'Register a passkey'), + findsOneWidget, + ); + + // Sign out returns to the sign-in screen. + await tester.tap(find.widgetWithText(OutlinedButton, 'Sign out')); + await _pumpUntil( + tester, + find.widgetWithText(FilledButton, 'Sign in with password'), + ); + + // A wrong password surfaces an error instead of signing in. + await tester.enterText(find.widgetWithText(TextField, 'Email'), email); + await tester.enterText( + find.widgetWithText(TextField, 'Password'), + 'wrong-password', + ); + await tester.tap( + find.widgetWithText(FilledButton, 'Sign in with password'), + ); + await _pumpUntil(tester, find.byType(SnackBar)); + expect(find.text('No passkeys yet.'), findsNothing); + + // The correct password signs in again. + await tester.enterText(find.widgetWithText(TextField, 'Email'), email); + await tester.enterText( + find.widgetWithText(TextField, 'Password'), + password, + ); + await tester.tap( + find.widgetWithText(FilledButton, 'Sign in with password'), + ); + await _pumpUntil(tester, find.text('No passkeys yet.')); + }); +} + +/// Pumps frames until [finder] matches at least one widget or [timeout] elapses. +/// +/// Auth calls go over the network, so the UI can't be settled with +/// `pumpAndSettle`; this polls the widget tree instead. +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 20), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isNotEmpty) return; + } + fail('Timed out waiting for: $finder'); +} diff --git a/examples/passkeys/lib/main.dart b/examples/passkeys/lib/main.dart index 0b269e965..0f54ae405 100644 --- a/examples/passkeys/lib/main.dart +++ b/examples/passkeys/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:passkeys/authenticator.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -172,7 +174,7 @@ class _SignedInViewState extends State { @override void initState() { super.initState(); - _refresh(); + unawaited(_refresh()); } Future _refresh() async { diff --git a/examples/passkeys/pubspec.yaml b/examples/passkeys/pubspec.yaml index 7c572e541..157da2e05 100644 --- a/examples/passkeys/pubspec.yaml +++ b/examples/passkeys/pubspec.yaml @@ -15,12 +15,14 @@ dependencies: flutter: sdk: flutter passkeys: ^2.21.1 - supabase_flutter: ^2.15.4 + supabase_flutter: ^2.17.0 dev_dependencies: - flutter_lints: ^4.0.0 + supabase_lints: ^0.1.1 flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter: uses-material-design: true diff --git a/examples/storage_transforms/.gitignore b/examples/storage_transforms/.gitignore new file mode 100644 index 000000000..f85557248 --- /dev/null +++ b/examples/storage_transforms/.gitignore @@ -0,0 +1,6 @@ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ +pubspec.lock +pubspec_overrides.yaml diff --git a/examples/storage_transforms/README.md b/examples/storage_transforms/README.md new file mode 100644 index 000000000..18a67a7f4 --- /dev/null +++ b/examples/storage_transforms/README.md @@ -0,0 +1,64 @@ +# Storage: uploads and image transformations + +A small image gallery that shows how to store and serve files with +`supabase.storage`: + +- **Upload** generated PNG bytes to a bucket (`uploadBinary`). +- **List** the objects in the bucket, newest first (`list`). +- **Transform** images on the fly by passing `TransformOptions` (width, height, + resize mode, quality) to `getPublicUrl`, so the gallery loads a small cropped + thumbnail and the detail view renders several resized variants. +- **Download** the transformed bytes of an image (`download`). +- **Delete** an object (`remove`). + +All Storage access is in +[`lib/storage_repository.dart`](lib/storage_repository.dart), kept separate from +the UI so the calls are easy to read and to drive from an integration test. The +sample images are drawn in memory in +[`lib/sample_image.dart`](lib/sample_image.dart), so the example uploads real +image bytes without bundling asset files or depending on an image picker. + +To keep the focus on the Supabase calls, the screen uses plain `setState` and +reloads the gallery after each write rather than pulling in a state management +package. A larger app would typically reach for a state management solution (for +example Riverpod, Bloc or Provider) instead. + +The `images` bucket and its access policies come from the shared Supabase config +in [`../supabase`](../supabase): schema in +`migrations/20240603000000_storage_transforms_example.sql`. Image +transformations are enabled in `config.toml` +(`[storage.image_transformation]`). The bucket starts empty; the app uploads its +own images, so there are no seed rows. + +## Running + +From the `examples` directory, run the launcher and pick `storage_transforms`: + +```bash +./run.sh +``` + +Or run it directly against any project: + +```bash +flutter run \ + --dart-define=SUPABASE_URL=https://YOUR_PROJECT.supabase.co \ + --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY +``` + +## Integration test + +[`integration_test/storage_test.dart`](integration_test/storage_test.dart) is an +end-to-end test that runs against the local stack. It drives the flow through the +repository (upload, list, a transformed download whose bytes it decodes to check +the image was resized, then delete) and drives the app widgets to upload an image +and delete it again from the detail view. + +With the local stack running, pass the same defines the app uses and run it on a +device (integration tests need one, so `-d macos`, an emulator or a real device): + +```bash +flutter test integration_test/storage_test.dart -d macos \ + --dart-define=SUPABASE_URL=http://localhost:54321 \ + --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_LOCAL_PUBLISHABLE_KEY +``` diff --git a/examples/storage_transforms/analysis_options.yaml b/examples/storage_transforms/analysis_options.yaml new file mode 100644 index 000000000..10becad33 --- /dev/null +++ b/examples/storage_transforms/analysis_options.yaml @@ -0,0 +1 @@ +include: package:supabase_lints/analysis_options_flutter.yaml diff --git a/examples/storage_transforms/integration_test/storage_test.dart b/examples/storage_transforms/integration_test/storage_test.dart new file mode 100644 index 000000000..d4098a440 --- /dev/null +++ b/examples/storage_transforms/integration_test/storage_test.dart @@ -0,0 +1,162 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:storage_transforms_example/main.dart'; +import 'package:storage_transforms_example/sample_image.dart'; +import 'package:storage_transforms_example/storage_repository.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); +const supabasePublishableKey = String.fromEnvironment( + 'SUPABASE_PUBLISHABLE_KEY', +); + +/// End-to-end tests that drive the Storage example against the local stack. +/// +/// The first test exercises the whole flow through the repository (upload, list, +/// transformed download and delete), asserting on the returned bytes. The second +/// drives the app widgets to confirm the gallery, detail view and delete button +/// are wired to those calls. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late StorageRepository repository; + + setUpAll(() async { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabasePublishableKey, + ); + repository = StorageRepository(Supabase.instance.client); + // Start from an empty bucket so the gallery is deterministic. + await _clearBucket(repository); + }); + + tearDownAll(() async { + await Supabase.instance.dispose(); + }); + + tearDown(() async { + // Remove anything a test uploaded so it can run repeatedly. + await _clearBucket(repository); + }); + + testWidgets('uploads, transforms, downloads and deletes through the ' + 'repository', (tester) async { + final name = 'e2e-${DateTime.now().microsecondsSinceEpoch}.png'; + final bytes = await generateSampleImagePng(color: Colors.teal, size: 640); + + // Upload returns the stored path, and the object then shows up in the list. + final path = await repository.uploadImage(name: name, bytes: bytes); + expect(path, contains(name)); + final images = await repository.listImages(); + expect(images.map((image) => image.path), contains(name)); + + // A public URL with a transform points at the render endpoint and carries + // the transform query parameters. + final url = repository.imageUrl( + name, + transform: const TransformOptions(width: 100, height: 100), + ); + expect(url, contains('/render/image/')); + expect(url, contains('width=100')); + + // The original downloads at its full size. + final original = await repository.downloadImage(name); + expect(await _decodeSize(original), const Size(640, 640)); + + // Downloading with a transform resizes the image server-side. + final resized = await repository.downloadImage( + name, + transform: const TransformOptions( + width: 100, + height: 100, + resize: ResizeMode.cover, + ), + ); + expect(await _decodeSize(resized), const Size(100, 100)); + + // Delete removes it from the bucket. + await repository.deleteImage(name); + final remaining = await repository.listImages(); + expect(remaining.map((image) => image.path), isNot(contains(name))); + }); + + testWidgets('uploads and deletes an image through the UI', (tester) async { + await tester.pumpWidget(const StorageExampleApp()); + + // A freshly cleared bucket shows the empty state. + await _pumpUntil(tester, find.text('No images yet. Upload one to start.')); + + // Uploading through the FAB adds a tile to the gallery. + await tester.tap(find.widgetWithText(FloatingActionButton, 'Upload')); + await _pumpUntilGone( + tester, + find.text('No images yet. Upload one to start.'), + ); + expect(find.byType(Card), findsWidgets); + + // Opening the tile shows the transformation variants by name. + await tester.tap(find.byType(InkWell).first); + await tester.pumpAndSettle(); + expect(find.text('Original'), findsOneWidget); + expect(find.text('120×120 cover'), findsOneWidget); + + // Deleting from the detail view returns to an empty gallery. + await tester.tap(find.byIcon(Icons.delete)); + await _pumpUntil(tester, find.text('No images yet. Upload one to start.')); + }); +} + +/// Removes every object currently in the bucket. +Future _clearBucket(StorageRepository repository) async { + final images = await repository.listImages(); + for (final image in images) { + await repository.deleteImage(image.path); + } +} + +/// Decodes [bytes] far enough to read the image's pixel dimensions. +Future _decodeSize(Uint8List bytes) async { + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final image = frame.image; + final size = Size(image.width.toDouble(), image.height.toDouble()); + image.dispose(); + codec.dispose(); + return size; +} + +/// Pumps frames until [finder] matches at least one widget or [timeout] elapses. +/// +/// Storage calls go over the network, so the UI can't be settled with +/// `pumpAndSettle`; this polls the widget tree instead. +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 20), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isNotEmpty) return; + } + fail('Timed out waiting for: $finder'); +} + +/// The inverse of [_pumpUntil]: pumps until [finder] matches nothing. +Future _pumpUntilGone( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 20), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + if (finder.evaluate().isEmpty) return; + } + fail('Timed out waiting for it to disappear: $finder'); +} diff --git a/examples/storage_transforms/lib/main.dart b/examples/storage_transforms/lib/main.dart new file mode 100644 index 000000000..452d8a596 --- /dev/null +++ b/examples/storage_transforms/lib/main.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'models.dart'; +import 'sample_image.dart'; +import 'storage_repository.dart'; + +const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); +const supabasePublishableKey = String.fromEnvironment( + 'SUPABASE_PUBLISHABLE_KEY', +); + +final messengerKey = GlobalKey(); + +Future main() async { + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabasePublishableKey, + ); + runApp(const StorageExampleApp()); +} + +SupabaseClient get supabase => Supabase.instance.client; + +class StorageExampleApp extends StatelessWidget { + const StorageExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Supabase Storage transformations', + scaffoldMessengerKey: messengerKey, + theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true), + home: const GalleryPage(), + ); + } +} + +/// Uploads generated images to a public bucket and shows them in a gallery. +/// Tapping an image opens the transformations view. +class GalleryPage extends StatefulWidget { + const GalleryPage({super.key}); + + @override + State createState() => _GalleryPageState(); +} + +class _GalleryPageState extends State { + final _repository = StorageRepository(supabase); + + /// Seed colors cycled through for each uploaded image, so successive uploads + /// look different. + static const _palette = [ + Colors.indigo, + Colors.teal, + Colors.deepOrange, + Colors.pink, + Colors.green, + ]; + + List _images = []; + bool _loading = true; + bool _mutating = false; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + /// Reloads the gallery. Leaves the previous list on screen while it runs so a + /// refresh doesn't flash a spinner over the whole grid. + Future _load() async { + try { + final images = await _repository.listImages(); + if (mounted) setState(() => _images = images); + } catch (error) { + _showError(error); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// Generates a fresh PNG and uploads it, then reloads the gallery. Ignores the + /// call while another upload is in flight so a double tap can't fire twice. + Future _upload() async { + if (_mutating) return; + setState(() => _mutating = true); + try { + final color = _palette[_images.length % _palette.length]; + final bytes = await generateSampleImagePng(color: color); + final name = 'sample-${DateTime.now().microsecondsSinceEpoch}.png'; + await _repository.uploadImage(name: name, bytes: bytes); + await _load(); + } catch (error) { + _showError(error); + } finally { + if (mounted) setState(() => _mutating = false); + } + } + + Future _openDetail(StoredImage image) async { + final deleted = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ImageDetailPage(repository: _repository, image: image), + ), + ); + if (deleted ?? false) await _load(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Images')), + floatingActionButton: FloatingActionButton.extended( + onPressed: _mutating ? null : _upload, + icon: const Icon(Icons.add_photo_alternate), + label: const Text('Upload'), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _images.isEmpty + ? const Center(child: Text('No images yet. Upload one to start.')) + : RefreshIndicator( + onRefresh: _load, + child: GridView.builder( + padding: const EdgeInsets.all(12), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 180, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + ), + itemCount: _images.length, + itemBuilder: (context, index) { + final image = _images[index]; + return _GalleryTile( + // A downscaled, cropped thumbnail keeps the grid light: the + // resize happens server-side, so the app never downloads the + // full-size image here. + url: _repository.imageUrl( + image.path, + transform: TransformPreset.thumbnail.options, + ), + onTap: () => _openDetail(image), + ); + }, + ), + ), + ); + } +} + +class _GalleryTile extends StatelessWidget { + const _GalleryTile({required this.url, required this.onTap}); + + final String url; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Image.network( + url, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Center(child: Icon(Icons.broken_image_outlined)), + ), + ), + ); + } +} + +/// Shows a single stored image rendered through every [TransformPreset], each +/// one a public URL built with different [TransformOptions]. +class ImageDetailPage extends StatelessWidget { + const ImageDetailPage({ + required this.repository, + required this.image, + super.key, + }); + + final StorageRepository repository; + final StoredImage image; + + Future _delete(BuildContext context) async { + try { + await repository.deleteImage(image.path); + if (context.mounted) Navigator.of(context).pop(true); + } catch (error) { + _showError(error); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(image.path), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + tooltip: 'Delete', + onPressed: () => _delete(context), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final preset in TransformPreset.values) + _TransformCard( + label: preset.label, + url: repository.imageUrl(image.path, transform: preset.options), + ), + ], + ), + ); + } +} + +class _TransformCard extends StatelessWidget { + const _TransformCard({required this.label, required this.url}); + + final String label; + final String url; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Image.network( + url, + errorBuilder: (context, error, stackTrace) => const Padding( + padding: EdgeInsets.all(24), + child: Icon(Icons.broken_image_outlined), + ), + ), + ], + ), + ), + ); + } +} + +void _showError(Object error) { + final message = error is StorageException ? error.message : error.toString(); + messengerKey.currentState?.showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red), + ); +} diff --git a/examples/storage_transforms/lib/models.dart b/examples/storage_transforms/lib/models.dart new file mode 100644 index 000000000..56f6dc453 --- /dev/null +++ b/examples/storage_transforms/lib/models.dart @@ -0,0 +1,44 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// A single image stored in the bucket, built from the Storage [FileObject] +/// returned by `list()`. +class StoredImage { + const StoredImage({required this.path, this.createdAt}); + + factory StoredImage.fromFileObject(FileObject file) => StoredImage( + path: file.name, + createdAt: file.createdAt == null + ? null + : DateTime.tryParse(file.createdAt!), + ); + + /// Path within the bucket, which is also the object's file name here. + final String path; + final DateTime? createdAt; +} + +/// A named image transformation shown in the detail view. +/// +/// Each preset maps to the Storage [TransformOptions] passed to `getPublicUrl` +/// and `download`. The [original] preset carries no options, so it requests the +/// untransformed image. +enum TransformPreset { + original('Original', null), + thumbnail( + '120×120 cover', + TransformOptions(width: 120, height: 120, resize: ResizeMode.cover), + ), + fit( + '300×160 contain', + TransformOptions(width: 300, height: 160, resize: ResizeMode.contain), + ), + lowQuality( + '320 wide · quality 20', + TransformOptions(width: 320, quality: 20), + ); + + const TransformPreset(this.label, this.options); + + final String label; + final TransformOptions? options; +} diff --git a/examples/storage_transforms/lib/sample_image.dart b/examples/storage_transforms/lib/sample_image.dart new file mode 100644 index 000000000..9078333ea --- /dev/null +++ b/examples/storage_transforms/lib/sample_image.dart @@ -0,0 +1,41 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +/// Draws a colorful [size]×[size] PNG entirely in memory. +/// +/// This lets the example upload real image bytes without bundling asset files +/// or depending on an image picker. The returned bytes are what get sent to +/// Storage, and the image is large enough that the transformations in the +/// detail view visibly resize it. +Future generateSampleImagePng({ + required Color color, + int size = 640, +}) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final bounds = Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble()); + + // A diagonal gradient background derived from the seed color. + final background = Paint() + ..shader = ui.Gradient.linear(bounds.topLeft, bounds.bottomRight, [ + color, + Color.lerp(color, Colors.black, 0.6)!, + ]); + canvas.drawRect(bounds, background); + + // A few translucent circles for some visual texture. + final circle = Paint()..color = Colors.white.withValues(alpha: 0.15); + for (var i = 0; i < 6; i++) { + final t = (i + 1) / 7; + canvas.drawCircle(Offset(size * t, size * (1 - t)), size * 0.18, circle); + } + + final picture = recorder.endRecording(); + final image = await picture.toImage(size, size); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + picture.dispose(); + return data!.buffer.asUint8List(); +} diff --git a/examples/storage_transforms/lib/storage_repository.dart b/examples/storage_transforms/lib/storage_repository.dart new file mode 100644 index 000000000..1dacfd9ff --- /dev/null +++ b/examples/storage_transforms/lib/storage_repository.dart @@ -0,0 +1,68 @@ +import 'dart:typed_data'; + +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'models.dart'; + +/// All Storage access for the example lives here, so the UI stays thin and every +/// `supabase.storage` call is easy to read and to exercise from an integration +/// test. +class StorageRepository { + StorageRepository(this._client); + + final SupabaseClient _client; + + /// Public bucket created by the example migration. Being public lets the + /// gallery load images straight from their public URL without a signed URL. + static const bucket = 'images'; + + StorageFileApi get _files => _client.storage.from(bucket); + + /// Uploads [bytes] under [name] and returns the stored path. + /// + /// `upsert: true` overwrites an object that already has the same name instead + /// of failing, and `contentType` tells Storage the bytes are a PNG. + Future uploadImage({ + required String name, + required Uint8List bytes, + }) { + return _files.uploadBinary( + name, + bytes, + fileOptions: const FileOptions(contentType: 'image/png', upsert: true), + ); + } + + /// Lists the images in the bucket, newest first. + /// + /// `list()` also returns folder placeholders, which have no `id`, so those are + /// filtered out before mapping to [StoredImage]. + Future> listImages() async { + final files = await _files.list( + searchOptions: const SearchOptions( + sortBy: SortBy(column: 'created_at', order: 'desc'), + ), + ); + return files + .where((file) => file.id != null) + .map(StoredImage.fromFileObject) + .toList(); + } + + /// Builds a public URL for [path], optionally applying an image + /// [transform] server-side. `Image.network` can render the result directly. + String imageUrl(String path, {TransformOptions? transform}) { + return _files.getPublicUrl(path, transform: transform); + } + + /// Downloads the bytes for [path], optionally applying an image [transform] + /// server-side before the bytes are returned. + Future downloadImage(String path, {TransformOptions? transform}) { + return _files.download(path, transform: transform); + } + + /// Deletes [path] from the bucket. + Future deleteImage(String path) async { + await _files.remove([path]); + } +} diff --git a/examples/storage_transforms/pubspec.yaml b/examples/storage_transforms/pubspec.yaml new file mode 100644 index 000000000..08dcb4365 --- /dev/null +++ b/examples/storage_transforms/pubspec.yaml @@ -0,0 +1,27 @@ +name: storage_transforms_example +description: Demonstrates Storage uploads, downloads and image transformations using supabase_flutter. + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.9.0 <4.0.0' + flutter: '>=3.35.0' + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + supabase_flutter: ^2.17.0 + +dev_dependencies: + supabase_lints: ^0.1.1 + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/examples/storage_transforms/web/index.html b/examples/storage_transforms/web/index.html new file mode 100644 index 000000000..1fc85b232 --- /dev/null +++ b/examples/storage_transforms/web/index.html @@ -0,0 +1,13 @@ + + + + + + + + Supabase Storage transformations + + + + + diff --git a/examples/supabase/config.toml b/examples/supabase/config.toml index 6133b0198..a4e132d95 100644 --- a/examples/supabase/config.toml +++ b/examples/supabase/config.toml @@ -26,6 +26,11 @@ rp_display_name = "Supabase Flutter examples" rp_id = "localhost" rp_origins = ["http://localhost:3000"] +# Image transformations (resize, re-encode) are used by the storage_transforms +# example to render images on the fly. +[storage.image_transformation] +enabled = true + # Keep the stack lean: turn off services the examples don't use. The data plane # (database, API/PostgREST, auth, realtime, storage) stays on. Enable any of # these if a new example needs it. @@ -36,9 +41,22 @@ enabled = false [inbucket] enabled = false -# Edge Functions runtime. +# Edge Functions runtime, used by the edge_functions example. `supabase start` +# serves every function under `supabase/functions/` while the stack is running. [edge_runtime] -enabled = false +enabled = true + +# The edge_functions example runs unauthenticated with the publishable (anon) +# key, so JWT verification is turned off for its demo functions. A real app would +# leave verify_jwt on and read the caller's user from the request. +[functions.greet] +verify_jwt = false + +[functions.shout] +verify_jwt = false + +[functions.word-count] +verify_jwt = false # Analytics (Logflare) and its log collector. Also avoids the Docker socket # mount that the vector container needs, which some runtimes (e.g. Colima) diff --git a/examples/supabase/functions/greet/index.ts b/examples/supabase/functions/greet/index.ts new file mode 100644 index 000000000..214dafffa --- /dev/null +++ b/examples/supabase/functions/greet/index.ts @@ -0,0 +1,31 @@ +// Greeting Edge Function for the edge_functions example. +// +// Builds a greeting server-side and returns it as JSON. The name is read from +// the JSON body on a POST or from the `name` query parameter on a GET, so the +// example can show both ways of calling `functions.invoke`. The response echoes +// back how it was called (method and a custom header) so the app can display it. + +Deno.serve(async (req) => { + const url = new URL(req.url); + + let name = url.searchParams.get("name") ?? ""; + let excited = url.searchParams.get("excited") === "true"; + if (req.method === "POST") { + const body = await req.json().catch(() => ({})); + if (typeof body.name === "string") name = body.name; + if (typeof body.excited === "boolean") excited = body.excited; + } + name = name.trim() || "world"; + + const message = `Hello, ${name}${excited ? "!!!" : "."}`; + const payload = { + message, + method: req.method, + // Echoing a custom header shows that headers set on the client reach here. + source: req.headers.get("x-greeting-source") ?? "unknown", + }; + + return new Response(JSON.stringify(payload), { + headers: { "Content-Type": "application/json" }, + }); +}); diff --git a/examples/supabase/functions/shout/index.ts b/examples/supabase/functions/shout/index.ts new file mode 100644 index 000000000..6305ed9dd --- /dev/null +++ b/examples/supabase/functions/shout/index.ts @@ -0,0 +1,12 @@ +// Text-transform Edge Function for the edge_functions example. +// +// Reads the raw request body as text and returns it uppercased with a +// `text/plain` content type, so the example can show a function that responds +// with plain text (surfaced as a Dart String) rather than JSON. + +Deno.serve(async (req) => { + const text = await req.text(); + return new Response(text.toUpperCase(), { + headers: { "Content-Type": "text/plain" }, + }); +}); diff --git a/examples/supabase/functions/word-count/index.ts b/examples/supabase/functions/word-count/index.ts new file mode 100644 index 000000000..ec348c288 --- /dev/null +++ b/examples/supabase/functions/word-count/index.ts @@ -0,0 +1,30 @@ +// Validation Edge Function for the edge_functions example. +// +// Counts the words and characters in the posted text. When the text is missing +// or empty it responds with a 400 and a JSON error body, so the example can show +// how a non-2xx response surfaces in Dart as a FunctionException whose `details` +// carry that body. + +Deno.serve(async (req) => { + const body = await req.json().catch(() => ({})); + const rawText = typeof body.text === "string" ? body.text : ""; + const text = rawText.trim(); + + if (text.length === 0) { + return new Response( + JSON.stringify({ error: "Provide some text to count." }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const payload = { + words: text.split(/\s+/).length, + // Count from the raw text so surrounding spaces are kept, and with + // Array.from so an emoji counts as one character rather than two UTF-16 + // units. + characters: Array.from(rawText).length, + }; + return new Response(JSON.stringify(payload), { + headers: { "Content-Type": "application/json" }, + }); +}); diff --git a/examples/supabase/migrations/20240601000000_crud_example.sql b/examples/supabase/migrations/20240601000000_crud_example.sql new file mode 100644 index 000000000..aed88c228 --- /dev/null +++ b/examples/supabase/migrations/20240601000000_crud_example.sql @@ -0,0 +1,41 @@ +-- Schema for the "database CRUD with PostgREST" example. +-- +-- Two related tables (projects 1:N tasks) so the example can show selects with +-- filters and ordering, inserts, updates, deletes and a join from a task to its +-- project. + +create table public.projects ( + id uuid primary key default gen_random_uuid(), + name text not null, + created_at timestamptz not null default now() +); + +create table public.tasks ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects (id) on delete cascade, + title text not null, + is_complete boolean not null default false, + priority int not null default 1, + created_at timestamptz not null default now() +); + +create index tasks_project_id_idx on public.tasks (project_id); + +-- The example runs unauthenticated with the publishable (anon) key. Enable row +-- level security and add permissive policies so the demo can read and write +-- without signing in. A real app would scope these policies to the signed in +-- user instead. +alter table public.projects enable row level security; +alter table public.tasks enable row level security; + +create policy "Anyone can read projects" + on public.projects for select using (true); + +create policy "Anyone can manage tasks" + on public.tasks for all using (true) with check (true); + +-- Row level security decides which rows a role may touch, but the role still +-- needs table privileges. Grant the publishable (anon) key read access to +-- projects and full access to tasks so the demo works without signing in. +grant select on public.projects to anon, authenticated; +grant all on public.tasks to anon, authenticated; diff --git a/examples/supabase/migrations/20240603000000_storage_transforms_example.sql b/examples/supabase/migrations/20240603000000_storage_transforms_example.sql new file mode 100644 index 000000000..e1f98c8b4 --- /dev/null +++ b/examples/supabase/migrations/20240603000000_storage_transforms_example.sql @@ -0,0 +1,32 @@ +-- Schema for the "Storage: uploads and image transformations" example. +-- +-- A single public bucket that the example uploads generated PNGs to and reads +-- back from, applying image transformations on the way out. + +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ( + 'images', + 'images', + true, + 1048576, + array['image/png', 'image/jpeg', 'image/webp'] +) +on conflict (id) do nothing; + +-- The example runs unauthenticated with the publishable (anon) key. A public +-- bucket already serves reads without a policy; these policies additionally let +-- the demo list, upload, overwrite and delete objects in the `images` bucket +-- without signing in. A real app would scope these policies to the signed in +-- user instead. +create policy "Anyone can view images" + on storage.objects for select using (bucket_id = 'images'); + +create policy "Anyone can upload images" + on storage.objects for insert with check (bucket_id = 'images'); + +create policy "Anyone can update images" + on storage.objects for update + using (bucket_id = 'images') with check (bucket_id = 'images'); + +create policy "Anyone can delete images" + on storage.objects for delete using (bucket_id = 'images'); diff --git a/examples/supabase/seed.sql b/examples/supabase/seed.sql new file mode 100644 index 000000000..3df90668b --- /dev/null +++ b/examples/supabase/seed.sql @@ -0,0 +1,15 @@ +-- Sample data for the database CRUD example. Runs when the local stack is first +-- created (`supabase start`) or reset (`supabase db reset`). + +insert into public.projects (id, name) values + ('00000000-0000-0000-0000-000000000001', 'Website redesign'), + ('00000000-0000-0000-0000-000000000002', 'Mobile app'), + ('00000000-0000-0000-0000-000000000003', 'Personal'); + +insert into public.tasks (project_id, title, is_complete, priority) values + ('00000000-0000-0000-0000-000000000001', 'Draft the landing page copy', false, 3), + ('00000000-0000-0000-0000-000000000001', 'Pick a color palette', true, 1), + ('00000000-0000-0000-0000-000000000002', 'Set up push notifications', false, 2), + ('00000000-0000-0000-0000-000000000002', 'Fix login on Android', false, 3), + ('00000000-0000-0000-0000-000000000003', 'Book flights', false, 2), + ('00000000-0000-0000-0000-000000000003', 'Water the plants', true, 1); diff --git a/packages/functions_client/CHANGELOG.md b/packages/functions_client/CHANGELOG.md index 3e75faaa7..2f9bbf090 100644 --- a/packages/functions_client/CHANGELOG.md +++ b/packages/functions_client/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.7.0 + + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **FEAT**(functions): add request cancellation to invoke via abortSignal ([#1593](https://github.com/supabase/supabase-flutter/issues/1593)). ([b994dabd](https://github.com/supabase/supabase-flutter/commit/b994dabdb23aa43540bef6953d739c48521bb532)) + - **FEAT**(functions): expose FunctionsFetchException/FunctionsRelayException/FunctionsHttpException subtypes ([#1545](https://github.com/supabase/supabase-flutter/issues/1545)). ([2d3a7202](https://github.com/supabase/supabase-flutter/commit/2d3a720215837722e25c51507d6be8c6f8f3edb7)) + ## 2.6.4 - **FIX**(functions): don't lose error status on a non-JSON body labeled JSON ([#1507](https://github.com/supabase/supabase-flutter/issues/1507)). ([925b46e1](https://github.com/supabase/supabase-flutter/commit/925b46e118e62884dd65505e7a753e0f51958b4c)) diff --git a/packages/functions_client/README.md b/packages/functions_client/README.md index c980a81d3..d2f938af2 100644 --- a/packages/functions_client/README.md +++ b/packages/functions_client/README.md @@ -1,4 +1,28 @@ -# `functions-dart` +
+

+ + Supabase Logo + + +

functions_client

+ +

+ Dart client library to interact with Supabase Edge Functions. +

+ +

+ Guides + · + Reference Docs +

+

+ +
+ +[![pub package](https://img.shields.io/pub/v/functions_client.svg)](https://pub.dev/packages/functions_client) +[![pub test](https://github.com/supabase/supabase-flutter/workflows/Test/badge.svg)](https://github.com/supabase/supabase-flutter/actions?query=workflow%3ATest) + +
## Docs diff --git a/packages/functions_client/example/functions_dart_example.dart b/packages/functions_client/example/functions_dart_example.dart index ab73b3a23..de3920aeb 100644 --- a/packages/functions_client/example/functions_dart_example.dart +++ b/packages/functions_client/example/functions_dart_example.dart @@ -1 +1,26 @@ -void main() {} +// ignore_for_file: avoid_print + +import 'package:functions_client/functions_client.dart'; + +/// Example to use with Supabase Edge Functions https://supabase.com/ +Future main() async { + const supabaseUrl = ''; + const supabaseKey = ''; + final client = FunctionsClient( + '$supabaseUrl/functions/v1', + { + 'Authorization': 'Bearer $supabaseKey', + }, + ); + + try { + final response = await client.invoke( + 'get_countries', + body: {'name': 'The Shire'}, + ); + print('status: ${response.status}'); + print('data: ${response.data}'); + } on FunctionException catch (error) { + print('Function error: ${error.status} ${error.details}'); + } +} diff --git a/packages/functions_client/lib/functions_client.dart b/packages/functions_client/lib/functions_client.dart index eb5aac5d3..a79601b06 100644 --- a/packages/functions_client/lib/functions_client.dart +++ b/packages/functions_client/lib/functions_client.dart @@ -1,7 +1,8 @@ /// A dart client library for Supabase Edge Functions. library; -export 'package:http/http.dart' show ByteStream, MultipartFile; +export 'package:http/http.dart' + show ByteStream, MultipartFile, RequestAbortedException; export 'src/functions_client.dart'; export 'src/types.dart'; diff --git a/packages/functions_client/lib/src/constants.dart b/packages/functions_client/lib/src/constants.dart index a747a768d..3b32d1435 100644 --- a/packages/functions_client/lib/src/constants.dart +++ b/packages/functions_client/lib/src/constants.dart @@ -1,7 +1,8 @@ import 'package:functions_client/src/version.dart'; +import 'package:supabase_common/supabase_common.dart'; class Constants { - static const defaultHeaders = { - 'X-Client-Info': 'functions-dart/$version', + static final defaultHeaders = { + 'X-Client-Info': buildClientInfoHeader('functions-dart', version), }; } diff --git a/packages/functions_client/lib/src/functions_client.dart b/packages/functions_client/lib/src/functions_client.dart index 9aa6d6b48..ebefa6cab 100644 --- a/packages/functions_client/lib/src/functions_client.dart +++ b/packages/functions_client/lib/src/functions_client.dart @@ -7,6 +7,7 @@ import 'package:functions_client/src/version.dart'; import 'package:http/http.dart' as http; import 'package:http/http.dart' show MultipartRequest; import 'package:logging/logging.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; class FunctionsClient { @@ -65,6 +66,32 @@ class FunctionsClient { /// [region] optionally specify the region to invoke the function in. /// When specified, adds both `x-region` header and `forceFunctionRegion` query parameter. /// + /// [abortSignal] cancels the in-flight request when the provided [Future] + /// completes. It must not complete with an error. On abort, a + /// [RequestAbortedException] is thrown. This is useful for cancelling a + /// request in response to an event or for setting a request timeout: + /// + /// ```dart + /// // Event based + /// final abortSignal = Completer(); + /// abortSignal.complete(); // Call in some event handler to abort the request + /// + /// try { + /// final response = await supabase.functions.invoke( + /// 'hello-world', + /// abortSignal: abortSignal.future, + /// ); + /// } on RequestAbortedException catch (error) { + /// print('Request was aborted: $error'); + /// } + /// + /// // Timer based + /// final response = await supabase.functions.invoke( + /// 'hello-world', + /// abortSignal: Future.delayed(Duration(seconds: 5)), + /// ); + /// ``` + /// /// ```dart /// // Call a standard function /// final response = await supabase.functions.invoke('hello-world'); @@ -97,6 +124,7 @@ class FunctionsClient { Map? queryParameters, HttpMethod method = HttpMethod.post, String? region, + Future? abortSignal, }) async { final effectiveRegion = region ?? _region; @@ -136,11 +164,20 @@ class FunctionsClient { ); final fields = (body as Map?)?.cast(); - request = http.MultipartRequest(method.name.toUpperCase(), uri) - ..fields.addAll(fields ?? {}) - ..files.addAll(files); + request = + http.AbortableMultipartRequest( + method.name.toUpperCase(), + uri, + abortTrigger: abortSignal, + ) + ..fields.addAll(fields ?? {}) + ..files.addAll(files); } else { - final bodyRequest = http.Request(method.name.toUpperCase(), uri); + final bodyRequest = http.AbortableRequest( + method.name.toUpperCase(), + uri, + abortTrigger: abortSignal, + ); if (body == null) { // No body to set @@ -161,7 +198,14 @@ class FunctionsClient { _log.finest('Request: ${request.method} ${request.url} ${request.headers}'); - final response = await (_httpClient?.send(request) ?? request.send()); + final http.StreamedResponse response; + try { + response = await (_httpClient?.send(request) ?? request.send()); + } on http.RequestAbortedException { + rethrow; + } catch (error) { + throw FunctionsFetchException(details: error); + } final responseType = (response.headers['Content-Type'] ?? response.headers['content-type'] ?? @@ -170,8 +214,9 @@ class FunctionsClient { .trim() .toLowerCase(); + final isRelayError = response.headers['x-relay-error'] == 'true'; final isSuccessStatus = - 200 <= response.statusCode && response.statusCode < 300; + isSuccessStatusCode(response.statusCode) && !isRelayError; final dynamic data; @@ -210,7 +255,14 @@ class FunctionsClient { if (isSuccessStatus) { return FunctionResponse(data: data, status: response.statusCode); } - throw FunctionException( + if (isRelayError) { + throw FunctionsRelayException( + status: response.statusCode, + details: data, + reasonPhrase: response.reasonPhrase, + ); + } + throw FunctionsHttpException( status: response.statusCode, details: data, reasonPhrase: response.reasonPhrase, diff --git a/packages/functions_client/lib/src/types.dart b/packages/functions_client/lib/src/types.dart index 0e20f8894..01d57912a 100644 --- a/packages/functions_client/lib/src/types.dart +++ b/packages/functions_client/lib/src/types.dart @@ -39,5 +39,36 @@ class FunctionException implements Exception { @override String toString() => - 'FunctionException(status: $status, details: $details, reasonPhrase: $reasonPhrase)'; + '$runtimeType(status: $status, details: $details, reasonPhrase: $reasonPhrase)'; +} + +/// Thrown when the request to the Edge Function could not be sent, for example +/// because of a network or transport failure, before any response was received. +/// +/// The originating error is available in [details] and [status] is `0` since no +/// response reached the client. +class FunctionsFetchException extends FunctionException { + const FunctionsFetchException({ + super.details, + super.reasonPhrase, + }) : super(status: 0); +} + +/// Thrown when the Supabase relay returns an error while invoking the Edge +/// Function, indicated by the `x-relay-error` response header. +class FunctionsRelayException extends FunctionException { + const FunctionsRelayException({ + required super.status, + super.details, + super.reasonPhrase, + }); +} + +/// Thrown when the Edge Function itself responds with a non-2xx status code. +class FunctionsHttpException extends FunctionException { + const FunctionsHttpException({ + required super.status, + super.details, + super.reasonPhrase, + }); } diff --git a/packages/functions_client/lib/src/version.dart b/packages/functions_client/lib/src/version.dart index 3b8461390..71c30ec03 100644 --- a/packages/functions_client/lib/src/version.dart +++ b/packages/functions_client/lib/src/version.dart @@ -1 +1 @@ -const version = '2.6.4'; +const version = '2.7.0'; diff --git a/packages/functions_client/pubspec.yaml b/packages/functions_client/pubspec.yaml index 214f1ffdb..38ffc6231 100644 --- a/packages/functions_client/pubspec.yaml +++ b/packages/functions_client/pubspec.yaml @@ -1,9 +1,16 @@ name: functions_client description: A dart client library for the Supabase functions. -version: 2.6.4 +version: 2.7.0 homepage: 'https://supabase.com' repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/functions_client' +issue_tracker: 'https://github.com/supabase/supabase-flutter/issues' documentation: 'https://supabase.com/docs/reference/dart/functions-invoke' +topics: + - supabase + - functions + - edge-functions + - backend + - api environment: sdk: '>=3.9.0 <4.0.0' @@ -11,10 +18,11 @@ environment: resolution: workspace dependencies: - http: ^1.2.2 - logging: ^1.2.0 + http: ^1.6.0 + logging: ^1.3.0 + supabase_common: 0.1.1 yet_another_json_isolate: 2.1.1 dev_dependencies: - supabase_lints: ^0.1.0 - test: ^1.16.4 + supabase_lints: ^0.1.1 + test: ^1.25.0 diff --git a/packages/functions_client/test/custom_http_client.dart b/packages/functions_client/test/custom_http_client.dart index bc4df08a6..bb27434f0 100644 --- a/packages/functions_client/test/custom_http_client.dart +++ b/packages/functions_client/test/custom_http_client.dart @@ -11,11 +11,39 @@ class CustomHttpClient extends BaseClient { @override Future send(BaseRequest request) async { - // Add request to receivedRequests list. - receivedRequests = receivedRequests..add(request); + receivedRequests.add(request); request.finalize(); - if (request.url.path.endsWith("error-function")) { + if (request.url.path.endsWith('slow')) { + // Hangs until the request is aborted, mirroring how a real client + // surfaces abortion. Without an abort trigger it responds immediately so + // non-abort tests don't stall. + final abortTrigger = request is Abortable ? request.abortTrigger : null; + if (abortTrigger != null) { + await abortTrigger; + throw RequestAbortedException(request.url); + } + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({"key": "Hello World"}))), + 200, + request: request, + headers: {"Content-Type": "application/json"}, + ); + } else if (request.url.path.endsWith('network-error')) { + // Simulate a transport failure before any response is received. + throw ClientException('Connection failed', request.url); + } else if (request.url.path.endsWith('relay-error')) { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({"error": "relay down"}))), + 500, + request: request, + headers: { + "Content-Type": "application/json", + "x-relay-error": "true", + }, + reasonPhrase: "Internal Server Error", + ); + } else if (request.url.path.endsWith("error-function")) { //Return custom status code to check for usage of this client. return StreamedResponse( Stream.value(utf8.encode(jsonEncode({"key": "Hello World"}))), diff --git a/packages/functions_client/test/functions_dart_test.dart b/packages/functions_client/test/functions_dart_test.dart index 93f7ed37a..5d73f5f93 100644 --- a/packages/functions_client/test/functions_dart_test.dart +++ b/packages/functions_client/test/functions_dart_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -24,20 +25,69 @@ void main() { }); test('function throws', () async { await expectLater( - () => functionsCustomHttpClient.invoke('error-function'), + functionsCustomHttpClient.invoke('error-function'), throwsA( - isA().having((e) => e.status, 'status', 420), + isA().having((e) => e.status, 'status', 420), ), ); }); + test('a non-2xx response throws a FunctionsHttpException', () async { + await expectLater( + functionsCustomHttpClient.invoke('error-function'), + throwsA( + isA() + .having((e) => e.status, 'status', 420) + .having( + (e) => e.reasonPhrase, + 'reasonPhrase', + 'Enhance Your Calm', + ) + .having((e) => e.details, 'details', {'key': 'Hello World'}), + ), + ); + }); + + test('a relay error throws a FunctionsRelayException', () async { + await expectLater( + functionsCustomHttpClient.invoke('relay-error'), + throwsA( + isA() + .having((e) => e.status, 'status', 500) + .having((e) => e.details, 'details', {'error': 'relay down'}), + ), + ); + }); + + test('a transport failure throws a FunctionsFetchException', () async { + await expectLater( + functionsCustomHttpClient.invoke('network-error'), + throwsA( + isA() + .having((e) => e.status, 'status', 0) + .having((e) => e.details, 'details', isA()), + ), + ); + }); + + test('the subtypes remain catchable as FunctionException', () async { + await expectLater( + functionsCustomHttpClient.invoke('relay-error'), + throwsA(isA()), + ); + await expectLater( + functionsCustomHttpClient.invoke('network-error'), + throwsA(isA()), + ); + }); + test( 'error response with a streaming content type exposes the body', () async { // The error body must be drained and decoded into `details` rather than // handed back as an unconsumed stream (which also leaks the connection). await expectLater( - () => functionsCustomHttpClient.invoke('error-sse'), + functionsCustomHttpClient.invoke('error-sse'), throwsA( isA() .having((e) => e.status, 'status', 500) @@ -51,7 +101,7 @@ void main() { 'error response labeled JSON with a non-JSON body reports the status', () async { await expectLater( - () => functionsCustomHttpClient.invoke('invalid-json-error'), + functionsCustomHttpClient.invoke('invalid-json-error'), throwsA( isA() .having((e) => e.status, 'status', 500) @@ -72,7 +122,7 @@ void main() { // doesn't parse is a real anomaly, so the FormatException must surface // rather than silently degrading to a raw String. await expectLater( - () => functionsCustomHttpClient.invoke('success-invalid-json'), + functionsCustomHttpClient.invoke('success-invalid-json'), throwsA(isA()), ); }, @@ -81,24 +131,26 @@ void main() { test( 'an upper-cased application/JSON content type is parsed as JSON', () async { - final res = await functionsCustomHttpClient.invoke('uppercase-json'); - expect(res.data, {'key': 'Hello World'}); - expect(res.status, 200); + final response = await functionsCustomHttpClient.invoke( + 'uppercase-json', + ); + expect(response.data, {'key': 'Hello World'}); + expect(response.status, 200); }, ); test('function call', () async { - final res = await functionsCustomHttpClient.invoke('function'); + final response = await functionsCustomHttpClient.invoke('function'); expect( customHttpClient.receivedRequests.last.headers["Content-Type"], null, ); - expect(res.data, {'key': 'Hello World'}); - expect(res.status, 200); + expect(response.data, {'key': 'Hello World'}); + expect(response.status, 200); }); test('function call with query parameters', () async { - final res = await functionsCustomHttpClient.invoke( + final response = await functionsCustomHttpClient.invoke( 'function', queryParameters: {'key': 'value'}, ); @@ -106,14 +158,14 @@ void main() { final request = customHttpClient.receivedRequests.last; expect(request.url.queryParameters, {'key': 'value'}); - expect(res.data, {'key': 'Hello World'}); - expect(res.status, 200); + expect(response.data, {'key': 'Hello World'}); + expect(response.status, 200); }); test('function call with files', () async { final fileName = "file.txt"; final fileContent = "Hello World"; - final res = await functionsCustomHttpClient.invoke( + final response = await functionsCustomHttpClient.invoke( 'function', queryParameters: {'key': 'value'}, files: [ @@ -125,10 +177,10 @@ void main() { expect(request.url.queryParameters, {'key': 'value'}); expect(request.headers['Content-Type'], contains('multipart/form-data')); - expect(res.data, [ + expect(response.data, [ {'name': fileName, 'content': fileContent}, ]); - expect(res.status, 200); + expect(response.status, 200); }); test('dispose isolate', () async { @@ -145,14 +197,14 @@ void main() { ); await client.dispose(); - final res = await client.invoke('function'); - expect(res.data, {'key': 'Hello World'}); + final response = await client.invoke('function'); + expect(response.data, {'key': 'Hello World'}); }); test('Listen to SSE event', () async { - final res = await functionsCustomHttpClient.invoke('sse'); + final response = await functionsCustomHttpClient.invoke('sse'); expect( - res.data.transform(const Utf8Decoder()), + response.data.transform(const Utf8Decoder()), emitsInOrder( ['a', 'b', 'c'], ), @@ -163,45 +215,45 @@ void main() { test('integer properly encoded', () async { await functionsCustomHttpClient.invoke('function', body: 42); - final req = customHttpClient.receivedRequests.last; - expect(req, isA()); + final request = customHttpClient.receivedRequests.last; + expect(request, isA()); - req as Request; - expect(req.body, '42'); - expect(req.headers["Content-Type"], contains("application/json")); + request as Request; + expect(request.body, '42'); + expect(request.headers["Content-Type"], contains("application/json")); }); test('double is properly encoded', () async { await functionsCustomHttpClient.invoke('function', body: 42.9); - final req = customHttpClient.receivedRequests.last; - expect(req, isA()); + final request = customHttpClient.receivedRequests.last; + expect(request, isA()); - req as Request; - expect(req.body, '42.9'); - expect(req.headers["Content-Type"], contains("application/json")); + request as Request; + expect(request.body, '42.9'); + expect(request.headers["Content-Type"], contains("application/json")); }); test('string is properly encoded', () async { await functionsCustomHttpClient.invoke('function', body: 'ExampleText'); - final req = customHttpClient.receivedRequests.last; - expect(req, isA()); + final request = customHttpClient.receivedRequests.last; + expect(request, isA()); - req as Request; - expect(req.body, 'ExampleText'); - expect(req.headers["Content-Type"], contains("text/plain")); + request as Request; + expect(request.body, 'ExampleText'); + expect(request.headers["Content-Type"], contains("text/plain")); }); test('list is properly encoded', () async { await functionsCustomHttpClient.invoke('function', body: [1, 2, 3]); - final req = customHttpClient.receivedRequests.last; - expect(req, isA()); + final request = customHttpClient.receivedRequests.last; + expect(request, isA()); - req as Request; - expect(req.body, '[1,2,3]'); - expect(req.headers["Content-Type"], contains("application/json")); + request as Request; + expect(request.body, '[1,2,3]'); + expect(request.headers["Content-Type"], contains("application/json")); }); test('map is properly encoded', () async { @@ -210,35 +262,38 @@ void main() { body: {'thekey': 'thevalue'}, ); - final req = customHttpClient.receivedRequests.last; - expect(req, isA()); + final request = customHttpClient.receivedRequests.last; + expect(request, isA()); - req as Request; - expect(req.body, '{"thekey":"thevalue"}'); - expect(req.headers["Content-Type"], contains("application/json")); + request as Request; + expect(request.body, '{"thekey":"thevalue"}'); + expect(request.headers["Content-Type"], contains("application/json")); }); test('Uint8List is properly encoded as binary data', () async { final binaryData = Uint8List.fromList([1, 2, 3, 4, 5]); await functionsCustomHttpClient.invoke('function', body: binaryData); - final req = customHttpClient.receivedRequests.last; - expect(req, isA()); + final request = customHttpClient.receivedRequests.last; + expect(request, isA()); - req as Request; - expect(req.bodyBytes, equals(binaryData)); - expect(req.headers["Content-Type"], equals("application/octet-stream")); + request as Request; + expect(request.bodyBytes, equals(binaryData)); + expect( + request.headers["Content-Type"], + equals("application/octet-stream"), + ); }); test('null body sends no content-type', () async { await functionsCustomHttpClient.invoke('function'); - final req = customHttpClient.receivedRequests.last; - expect(req, isA()); + final request = customHttpClient.receivedRequests.last; + expect(request, isA()); - req as Request; - expect(req.body, ''); - expect(req.headers.containsKey("Content-Type"), isFalse); + request as Request; + expect(request.body, ''); + expect(request.headers.containsKey("Content-Type"), isFalse); }); }); @@ -249,8 +304,8 @@ void main() { method: HttpMethod.get, ); - final req = customHttpClient.receivedRequests.last; - expect(req.method, 'GET'); + final request = customHttpClient.receivedRequests.last; + expect(request.method, 'GET'); }); test('PUT method', () async { @@ -259,8 +314,8 @@ void main() { method: HttpMethod.put, ); - final req = customHttpClient.receivedRequests.last; - expect(req.method, 'PUT'); + final request = customHttpClient.receivedRequests.last; + expect(request.method, 'PUT'); }); test('DELETE method', () async { @@ -269,8 +324,8 @@ void main() { method: HttpMethod.delete, ); - final req = customHttpClient.receivedRequests.last; - expect(req.method, 'DELETE'); + final request = customHttpClient.receivedRequests.last; + expect(request.method, 'DELETE'); }); test('PATCH method', () async { @@ -279,8 +334,8 @@ void main() { method: HttpMethod.patch, ); - final req = customHttpClient.receivedRequests.last; - expect(req.method, 'PATCH'); + final request = customHttpClient.receivedRequests.last; + expect(request.method, 'PATCH'); }); }); @@ -290,8 +345,8 @@ void main() { await functionsCustomHttpClient.invoke('function'); - final req = customHttpClient.receivedRequests.last; - expect(req.headers['Authorization'], 'Bearer new-token'); + final request = customHttpClient.receivedRequests.last; + expect(request.headers['Authorization'], 'Bearer new-token'); }); test('headers getter returns current headers', () { @@ -308,8 +363,8 @@ void main() { headers: {'Content-Type': 'custom/type'}, ); - final req = customHttpClient.receivedRequests.last; - expect(req.headers['Content-Type'], 'custom/type'); + final request = customHttpClient.receivedRequests.last; + expect(request.headers['Content-Type'], 'custom/type'); }); test('custom lowercase content-type header overrides defaults', () async { @@ -319,8 +374,8 @@ void main() { headers: {'content-type': 'application/custom+json'}, ); - final req = customHttpClient.receivedRequests.last; - expect(req.headers['content-type'], 'application/custom+json'); + final request = customHttpClient.receivedRequests.last; + expect(request.headers['content-type'], 'application/custom+json'); }); test('custom headers merge with defaults', () async { @@ -329,9 +384,9 @@ void main() { headers: {'X-Custom': 'value'}, ); - final req = customHttpClient.receivedRequests.last; - expect(req.headers['X-Custom'], 'value'); - expect(req.headers, contains('X-Client-Info')); + final request = customHttpClient.receivedRequests.last; + expect(request.headers['X-Custom'], 'value'); + expect(request.headers, contains('X-Client-Info')); }); }); @@ -344,9 +399,12 @@ void main() { region: 'us-west-1', ); - final req = customHttpClient.receivedRequests.last; - expect(req.headers['x-region'], 'us-west-1'); - expect(req.url.queryParameters['forceFunctionRegion'], 'us-west-1'); + final request = customHttpClient.receivedRequests.last; + expect(request.headers['x-region'], 'us-west-1'); + expect( + request.url.queryParameters['forceFunctionRegion'], + 'us-west-1', + ); }, ); @@ -356,10 +414,10 @@ void main() { region: 'any', ); - final req = customHttpClient.receivedRequests.last; - expect(req.headers.containsKey('x-region'), isFalse); + final request = customHttpClient.receivedRequests.last; + expect(request.headers.containsKey('x-region'), isFalse); expect( - req.url.queryParameters.containsKey('forceFunctionRegion'), + request.url.queryParameters.containsKey('forceFunctionRegion'), isFalse, ); }); @@ -376,9 +434,12 @@ void main() { await client.invoke('function'); - final req = customHttpClient.receivedRequests.last; - expect(req.headers['x-region'], 'eu-west-1'); - expect(req.url.queryParameters['forceFunctionRegion'], 'eu-west-1'); + final request = customHttpClient.receivedRequests.last; + expect(request.headers['x-region'], 'eu-west-1'); + expect( + request.url.queryParameters['forceFunctionRegion'], + 'eu-west-1', + ); }, ); @@ -392,9 +453,9 @@ void main() { await client.invoke('function', region: 'us-east-1'); - final req = customHttpClient.receivedRequests.last; - expect(req.headers['x-region'], 'us-east-1'); - expect(req.url.queryParameters['forceFunctionRegion'], 'us-east-1'); + final request = customHttpClient.receivedRequests.last; + expect(request.headers['x-region'], 'us-east-1'); + expect(request.url.queryParameters['forceFunctionRegion'], 'us-east-1'); }); test('region works with other query parameters', () async { @@ -404,9 +465,9 @@ void main() { queryParameters: {'key': 'value', 'foo': 'bar'}, ); - final req = customHttpClient.receivedRequests.last; - expect(req.headers['x-region'], 'ap-south-1'); - expect(req.url.queryParameters, { + final request = customHttpClient.receivedRequests.last; + expect(request.headers['x-region'], 'ap-south-1'); + expect(request.url.queryParameters, { 'key': 'value', 'foo': 'bar', 'forceFunctionRegion': 'ap-south-1', @@ -447,9 +508,12 @@ void main() { ], ); - final req = customHttpClient.receivedRequests.last; - expect(req.headers['Content-Type'], contains('multipart/form-data')); - expect(req, isA()); + final request = customHttpClient.receivedRequests.last; + expect( + request.headers['Content-Type'], + contains('multipart/form-data'), + ); + expect(request, isA()); }); test('multipart with only files', () async { @@ -458,41 +522,85 @@ void main() { files: [MultipartFile.fromString('file', 'content')], ); - final req = customHttpClient.receivedRequests.last; - expect(req.headers['Content-Type'], contains('multipart/form-data')); - expect(req, isA()); + final request = customHttpClient.receivedRequests.last; + expect( + request.headers['Content-Type'], + contains('multipart/form-data'), + ); + expect(request, isA()); + }); + }); + + group('Request cancellation', () { + test('aborts an in-flight request via abortSignal', () async { + final abortSignal = Completer(); + Timer(const Duration(milliseconds: 50), abortSignal.complete); + + await expectLater( + functionsCustomHttpClient.invoke( + 'slow', + abortSignal: abortSignal.future, + ), + throwsA(isA()), + ); + }); + + test('aborts a multipart request via abortSignal', () async { + final abortSignal = Completer(); + Timer(const Duration(milliseconds: 50), abortSignal.complete); + + await expectLater( + functionsCustomHttpClient.invoke( + 'slow', + files: [MultipartFile.fromString('file', 'content')], + abortSignal: abortSignal.future, + ), + throwsA(isA()), + ); + }); + + test('completes normally when the signal never fires', () async { + final abortSignal = Completer(); + + final response = await functionsCustomHttpClient.invoke( + 'function', + abortSignal: abortSignal.future, + ); + + expect(response.status, 200); + expect(response.data, {'key': 'Hello World'}); }); }); group('Response content types', () { test('handles application/octet-stream response', () async { - final res = await functionsCustomHttpClient.invoke('binary'); + final response = await functionsCustomHttpClient.invoke('binary'); - expect(res.data, isA()); - expect(res.data, equals(Uint8List.fromList([1, 2, 3, 4, 5]))); - expect(res.status, 200); + expect(response.data, isA()); + expect(response.data, equals(Uint8List.fromList([1, 2, 3, 4, 5]))); + expect(response.status, 200); }); test('handles text/plain response', () async { - final res = await functionsCustomHttpClient.invoke('text'); + final response = await functionsCustomHttpClient.invoke('text'); - expect(res.data, isA()); - expect(res.data, 'Hello World'); - expect(res.status, 200); + expect(response.data, isA()); + expect(response.data, 'Hello World'); + expect(response.status, 200); }); test('handles empty JSON response', () async { - final res = await functionsCustomHttpClient.invoke('empty-json'); + final response = await functionsCustomHttpClient.invoke('empty-json'); - expect(res.data, ''); - expect(res.status, 200); + expect(response.data, ''); + expect(response.status, 200); }); }); group('Error handling', () { test('FunctionException contains all error details', () async { await expectLater( - () => functionsCustomHttpClient.invoke('error-function'), + functionsCustomHttpClient.invoke('error-function'), throwsA( isA() .having((e) => e.status, 'status', 420) diff --git a/packages/gotrue/CHANGELOG.md b/packages/gotrue/CHANGELOG.md index a19b3d351..afc5558c2 100644 --- a/packages/gotrue/CHANGELOG.md +++ b/packages/gotrue/CHANGELOG.md @@ -1,3 +1,29 @@ +## 2.27.0 + + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **REFACTOR**: drop retry dependency in favor of an in-house helper ([#1571](https://github.com/supabase/supabase-flutter/issues/1571)). ([b81d2121](https://github.com/supabase/supabase-flutter/commit/b81d212159f15bf4d9515acf75430550a715664d)) + - **REFACTOR**(gotrue): drop rxdart dependency ([#1566](https://github.com/supabase/supabase-flutter/issues/1566)). ([3f70d5ed](https://github.com/supabase/supabase-flutter/commit/3f70d5edcea9e000156742a29f2a0ae3fb1d112b)) + - **REFACTOR**(gotrue): derive OAuth client enum values from the snakeCase extension ([#1562](https://github.com/supabase/supabase-flutter/issues/1562)). ([ed50706f](https://github.com/supabase/supabase-flutter/commit/ed50706f0b026a3c30ece0b64f5e9e925a2262ca)) + - **REFACTOR**(gotrue): drop jwt_decode dependency in favor of dart_jsonwebtoken ([#1565](https://github.com/supabase/supabase-flutter/issues/1565)). ([b1157efa](https://github.com/supabase/supabase-flutter/commit/b1157efaed65b8127dae0afbd61043d39f9ea2b0)) + - **REFACTOR**(auth): use OAuthProvider.name and cover custom OIDC providers ([#1555](https://github.com/supabase/supabase-flutter/issues/1555)). ([ebb7564d](https://github.com/supabase/supabase-flutter/commit/ebb7564d7112e208b8d38f0279f949b733afca31)) + - **FEAT**(auth): send PKCE code_challenge in updateUser when changing email ([#1601](https://github.com/supabase/supabase-flutter/issues/1601)). ([59d349e4](https://github.com/supabase/supabase-flutter/commit/59d349e43f873389ed3aec6b87a3f4ae4e85c8df)) + - **FEAT**(auth): support friendlyName and user.name fallback for WebAuthn passkey enrollment ([#1603](https://github.com/supabase/supabase-flutter/issues/1603)). ([558b0346](https://github.com/supabase/supabase-flutter/commit/558b03461441d340b1656606d1086382481611f8)) + - **FEAT**(auth): add signInWithWeb3 for Web3 wallet authentication ([#1590](https://github.com/supabase/supabase-flutter/issues/1590)). ([20a82beb](https://github.com/supabase/supabase-flutter/commit/20a82beb2eed6bc2dca86f14aefe0d58e0796c4f)) + - **FEAT**(postgrest): configurable auto-retry and request timeout ([#1560](https://github.com/supabase/supabase-flutter/issues/1560)). ([59d4b3af](https://github.com/supabase/supabase-flutter/commit/59d4b3af227e15a53208146a2930981526a39599)) + - **FEAT**(auth): add OAuth server listGrants and revokeGrant methods ([#1561](https://github.com/supabase/supabase-flutter/issues/1561)). ([67fed239](https://github.com/supabase/supabase-flutter/commit/67fed239e52e0f02213bee7ae71cb043473efe28)) + - **FEAT**(auth): add async getSession() with on-demand refresh ([#1563](https://github.com/supabase/supabase-flutter/issues/1563)). ([0b1d9b6b](https://github.com/supabase/supabase-flutter/commit/0b1d9b6b61adf9f66ed6716032b1a25f096604ce)) + - **FEAT**(gotrue): add channel parameter to mfa.challenge() ([#1547](https://github.com/supabase/supabase-flutter/issues/1547)). ([3bde140e](https://github.com/supabase/supabase-flutter/commit/3bde140e214f6e2928108ec496eb0e96bb9ac258)) + - **FEAT**(gotrue): add currentPassword to UserAttributes ([#1554](https://github.com/supabase/supabase-flutter/issues/1554)). ([b04c6419](https://github.com/supabase/supabase-flutter/commit/b04c6419785842c9d2af1337206b572adcb367c1)) + +## 2.26.0 + +> Note: This release has breaking changes. + + - **FIX**(gotrue): OAuth server throws FormatException instead of ArgumentE… ([#1535](https://github.com/supabase/supabase-flutter/issues/1535)). ([da0f6e02](https://github.com/supabase/supabase-flutter/commit/da0f6e0236f52cd8dfb087257dbedd4a9de97533)) + - **FEAT**(gotrue): add custom OAuth providers admin API with custom_claims_allowlist ([#1519](https://github.com/supabase/supabase-flutter/issues/1519)). ([122e5cf2](https://github.com/supabase/supabase-flutter/commit/122e5cf2036877c32ce92f807d5a9077d991a7e7)) + - **BREAKING** **FIX**(gotrue): handle already-consented OAuth authorization responses ([#1536](https://github.com/supabase/supabase-flutter/issues/1536)). ([22d41234](https://github.com/supabase/supabase-flutter/commit/22d412346a748d282b39004771f175e37c504a5a)) + ## 2.25.0 - **FEAT**(gotrue): add shouldSoftDelete option to admin deleteUser ([#1513](https://github.com/supabase/supabase-flutter/issues/1513)). ([30e9f22f](https://github.com/supabase/supabase-flutter/commit/30e9f22f8572bb8a0d734978b011dc1f8950211c)) diff --git a/packages/gotrue/README.md b/packages/gotrue/README.md index 095f5af11..477400b12 100644 --- a/packages/gotrue/README.md +++ b/packages/gotrue/README.md @@ -1,9 +1,28 @@ -# gotrue +
+

+ + Supabase Logo + -Dart client for the [GoTrue](https://github.com/supabase/gotrue) API. +

gotrue

+ +

+ Dart client library for Supabase Auth. +

+ +

+ Guides + · + Reference Docs +

+

+ +
[![pub package](https://img.shields.io/pub/v/gotrue.svg)](https://pub.dev/packages/gotrue) -[![pub test](https://github.com/supabase/gotrue-dart/workflows/Test/badge.svg)](https://github.com/supabase/gotrue-dart/actions?query=workflow%3ATest) +[![pub test](https://github.com/supabase/supabase-flutter/workflows/Test/badge.svg)](https://github.com/supabase/supabase-flutter/actions?query=workflow%3ATest) + +
## Docs diff --git a/packages/gotrue/example/main.dart b/packages/gotrue/example/main.dart index 958b6270e..aef001b14 100644 --- a/packages/gotrue/example/main.dart +++ b/packages/gotrue/example/main.dart @@ -1,13 +1,16 @@ +// ignore_for_file: avoid_print + import 'package:gotrue/gotrue.dart'; -Future main(List arguments) async { +/// Example to use with Supabase Auth https://supabase.com/ +Future main() async { const gotrueUrl = 'http://localhost:9999'; - const annonToken = ''; + const supabaseKey = ''; final client = GoTrueClient( url: gotrueUrl, headers: { - 'Authorization': 'Bearer $annonToken', - 'apikey': annonToken, + 'Authorization': 'Bearer $supabaseKey', + 'apikey': supabaseKey, }, ); @@ -16,12 +19,11 @@ Future main(List arguments) async { email: 'email', password: '12345', ); - print('Logged in, uid: ${login.session!.user.id}'); + print('Logged in, user id: ${login.session!.user.id}'); } on AuthException catch (error) { - print('Sign in Error: ${error.message}'); + print('Sign in error: ${error.message}'); } await client.signOut(); print('Logged out!'); - return true; } diff --git a/packages/gotrue/lib/gotrue.dart b/packages/gotrue/lib/gotrue.dart index 37dda78b4..2c8c5ec0f 100644 --- a/packages/gotrue/lib/gotrue.dart +++ b/packages/gotrue/lib/gotrue.dart @@ -7,7 +7,7 @@ export 'src/gotrue_admin_api.dart'; export 'src/gotrue_client.dart'; export 'src/helper.dart' show decodeJwt, validateExp; export 'src/types/auth_exception.dart'; -export 'src/types/auth_response.dart' hide ToSnakeCase; +export 'src/types/auth_response.dart'; export 'src/types/auth_state.dart'; export 'src/types/custom_oauth_provider.dart'; export 'src/types/error_code.dart'; diff --git a/packages/gotrue/lib/src/constants.dart b/packages/gotrue/lib/src/constants.dart index 5d265a97d..e3193577e 100644 --- a/packages/gotrue/lib/src/constants.dart +++ b/packages/gotrue/lib/src/constants.dart @@ -1,11 +1,11 @@ import 'package:gotrue/src/types/api_version.dart'; -import 'package:gotrue/src/types/auth_response.dart'; import 'package:gotrue/src/version.dart'; +import 'package:supabase_common/supabase_common.dart'; class Constants { static const String defaultGotrueUrl = 'http://localhost:9999'; - static const Map defaultHeaders = { - 'X-Client-Info': 'gotrue-dart/$version', + static final Map defaultHeaders = { + 'X-Client-Info': buildClientInfoHeader('gotrue-dart', version), }; /// storage key prefix to store code verifiers @@ -99,6 +99,12 @@ enum OtpChannel { whatsapp, } +/// The blockchain used to sign in with a Web3 wallet. +enum Web3Chain { + ethereum, + solana, +} + /// Determines which sessions should be logged out. enum SignOutScope { /// All sessions by this account will be signed out. diff --git a/packages/gotrue/lib/src/fetch.dart b/packages/gotrue/lib/src/fetch.dart index b9db20446..d855f8865 100644 --- a/packages/gotrue/lib/src/fetch.dart +++ b/packages/gotrue/lib/src/fetch.dart @@ -7,6 +7,7 @@ import 'package:gotrue/src/types/auth_exception.dart'; import 'package:gotrue/src/types/error_code.dart'; import 'package:gotrue/src/types/fetch_options.dart'; import 'package:http/http.dart'; +import 'package:supabase_common/supabase_common.dart'; enum RequestMethodType { get, post, put, patch, delete } @@ -15,10 +16,6 @@ class GotrueFetch { const GotrueFetch([this.httpClient]); - bool isSuccessStatusCode(int code) { - return code >= 200 && code <= 299; - } - String _getErrorMessage(dynamic error) { if (error is Map) { return error['msg'] ?? @@ -162,43 +159,32 @@ class GotrueFetch { } Response response; try { - switch (method) { - case RequestMethodType.get: - response = await (httpClient?.get ?? get)( - uri, - headers: headers, - ); - - break; - case RequestMethodType.post: - response = await (httpClient?.post ?? post)( - uri, - headers: headers, - body: bodyStr, - ); - break; - case RequestMethodType.put: - response = await (httpClient?.put ?? put)( - uri, - headers: headers, - body: bodyStr, - ); - break; - case RequestMethodType.patch: - response = await (httpClient?.patch ?? patch)( - uri, - headers: headers, - body: bodyStr, - ); - break; - case RequestMethodType.delete: - response = await (httpClient?.delete ?? delete)( - uri, - headers: headers, - body: bodyStr, - ); - break; - } + response = await switch (method) { + RequestMethodType.get => (httpClient?.get ?? get)( + uri, + headers: headers, + ), + RequestMethodType.post => (httpClient?.post ?? post)( + uri, + headers: headers, + body: bodyStr, + ), + RequestMethodType.put => (httpClient?.put ?? put)( + uri, + headers: headers, + body: bodyStr, + ), + RequestMethodType.patch => (httpClient?.patch ?? patch)( + uri, + headers: headers, + body: bodyStr, + ), + RequestMethodType.delete => (httpClient?.delete ?? delete)( + uri, + headers: headers, + body: bodyStr, + ), + }; } catch (e) { // fetch failed, likely due to a network or CORS error throw AuthRetryableFetchException(message: e.toString()); diff --git a/packages/gotrue/lib/src/gotrue_admin_api.dart b/packages/gotrue/lib/src/gotrue_admin_api.dart index d371e82ef..edb1a6caf 100644 --- a/packages/gotrue/lib/src/gotrue_admin_api.dart +++ b/packages/gotrue/lib/src/gotrue_admin_api.dart @@ -1,9 +1,8 @@ import 'package:gotrue/gotrue.dart'; import 'package:gotrue/src/fetch.dart'; -import 'package:gotrue/src/helper.dart'; -import 'package:gotrue/src/types/auth_response.dart'; import 'package:gotrue/src/types/fetch_options.dart'; import 'package:http/http.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'package:meta/meta.dart'; diff --git a/packages/gotrue/lib/src/gotrue_admin_custom_providers_api.dart b/packages/gotrue/lib/src/gotrue_admin_custom_providers_api.dart index 0cac8a8d8..9187fe1b3 100644 --- a/packages/gotrue/lib/src/gotrue_admin_custom_providers_api.dart +++ b/packages/gotrue/lib/src/gotrue_admin_custom_providers_api.dart @@ -33,7 +33,7 @@ class GoTrueAdminCustomProvidersApi { options: GotrueRequestOptions( headers: _headers, query: { - if (type != null) 'type': type.name, + 'type': ?type?.name, }, ), ); diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index cd4eaa3ed..225f928ac 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -8,14 +8,11 @@ import 'package:gotrue/gotrue.dart'; import 'package:gotrue/src/constants.dart'; import 'package:gotrue/src/fetch.dart'; import 'package:gotrue/src/helper.dart'; -import 'package:gotrue/src/types/auth_response.dart'; import 'package:gotrue/src/types/fetch_options.dart'; import 'package:http/http.dart'; -import 'package:jwt_decode/jwt_decode.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; -import 'package:retry/retry.dart'; -import 'package:rxdart/subjects.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'broadcast_stub.dart' if (dart.library.js_interop) './broadcast_web.dart' @@ -94,8 +91,8 @@ class GoTrueClient { JWKSet? _jwks; DateTime? _jwksCachedAt; - final _onAuthStateChangeController = BehaviorSubject(); - final _onAuthStateChangeControllerSync = BehaviorSubject( + final _onAuthStateChangeController = ReplaySubject(); + final _onAuthStateChangeControllerSync = ReplaySubject( sync: true, ); @@ -146,7 +143,7 @@ class GoTrueClient { /// Proxy to the web BroadcastChannel API. Should be null on non-web platforms. BroadcastChannel? _broadcastChannel; - StreamSubscription? _broadcastChannelSubscription; + StreamSubscription? _broadcastChannelSubscription; /// {@macro gotrue_client} GoTrueClient({ @@ -192,6 +189,50 @@ class GoTrueClient { /// Returns the current session, if any; Session? get currentSession => _currentSession; + /// Returns the current session, refreshing it on demand when the access + /// token has expired. + /// + /// Where the synchronous [currentSession] getter returns whatever session is + /// stored, even one whose access token has already expired, this returns a + /// session whose access token is guaranteed to be valid when it resolves: a + /// still-valid session is returned as-is, while an expired one is refreshed + /// first. If a refresh is already in flight, the expired session waits for it + /// to settle instead of starting another one. + /// + /// Returns `null` when there is no session. Throws an [AuthException] when an + /// expired session cannot be refreshed, unless its access token is still + /// within its real validity window, in which case the still-valid session is + /// returned. + Future getSession() async { + final session = _currentSession; + if (session == null) { + return null; + } + + if (!session.isExpired) { + return session; + } + + final refreshToken = session.refreshToken; + if (refreshToken == null) { + return session; + } + + try { + // Concurrent callers share a single refresh through the same + // de-duplication used by [refreshSession], so an expired session's + // refresh token is only spent once. + final response = await _callRefreshToken(refreshToken); + return response.session; + } on AuthException { + final current = _currentSession; + if (current != null && !current.isExpiredWithoutMargin) { + return current; + } + rethrow; + } + } + /// Creates a new anonymous user. /// /// Returns An `AuthResponse` with a session where the `is_anonymous` claim @@ -472,7 +513,7 @@ class GoTrueClient { options: GotrueRequestOptions( headers: _headers, body: { - 'provider': provider.snakeCase, + 'provider': provider.name, 'id_token': idToken, 'nonce': nonce, 'gotrue_meta_security': {'captcha_token': captchaToken}, @@ -494,6 +535,54 @@ class GoTrueClient { return authResponse; } + /// Signs in a user by verifying a message signed with their Web3 wallet. + /// + /// Supports Ethereum (Sign-In with Ethereum) and Solana (Sign-In with + /// Solana), both of which derive from the EIP-4361 standard. + /// + /// Wallet interaction and message signing are performed by the caller using + /// their wallet library of choice. Provide the signed [message] together with + /// its [signature]. For [Web3Chain.ethereum] the signature is a hex encoded + /// string, for [Web3Chain.solana] it is a base64url encoded string. + /// + /// [captchaToken] is the verification token received when the user + /// completes the captcha on the app. + /// + /// See also https://eips.ethereum.org/EIPS/eip-4361 + Future signInWithWeb3({ + required Web3Chain chain, + required String message, + required String signature, + String? captchaToken, + }) async { + final response = await _fetch.request( + '$_url/token', + RequestMethodType.post, + options: GotrueRequestOptions( + headers: _headers, + body: { + 'chain': chain.name, + 'message': message, + 'signature': signature, + if (captchaToken != null) + 'gotrue_meta_security': {'captcha_token': captchaToken}, + }, + query: {'grant_type': 'web3'}, + ), + ); + + final authResponse = AuthResponse.fromJson(response); + + if (authResponse.session == null) { + throw AuthException('An error occurred on token verification.'); + } + + _saveSession(authResponse.session!); + notifyAllSubscribers(AuthChangeEvent.signedIn); + + return authResponse; + } + /// Log in a user using magiclink or a one-time password (OTP). /// /// If the `{{ .ConfirmationURL }}` variable is specified in the email template, a magiclink will be sent. @@ -808,7 +897,17 @@ class GoTrueClient { throw AuthSessionMissingException(); } - final body = attributes.toJson(); + final codeChallenge = attributes.email != null + ? await _generatePKCECodeChallenge() + : null; + + final body = { + ...attributes.toJson(), + if (attributes.email != null) ...{ + 'code_challenge': codeChallenge, + 'code_challenge_method': codeChallenge != null ? 's256' : null, + }, + }; final options = GotrueRequestOptions( headers: _headers, body: body, @@ -1071,7 +1170,7 @@ class GoTrueClient { headers: _headers, jwt: _currentSession?.accessToken, body: { - 'provider': provider.snakeCase, + 'provider': provider.name, 'id_token': idToken, 'nonce': nonce, 'gotrue_meta_security': {'captcha_token': captchaToken}, @@ -1338,27 +1437,19 @@ class GoTrueClient { required Map? queryParams, bool skipBrowserRedirect = false, }) async { - final urlParams = {'provider': provider.snakeCase}; - if (scopes != null) { - urlParams['scopes'] = scopes; - } - if (redirectTo != null) { - urlParams['redirect_to'] = redirectTo; - } - if (queryParams != null) { - urlParams.addAll(queryParams); - } final codeChallenge = await _generatePKCECodeChallenge(); - if (codeChallenge != null) { - urlParams.addAll({ + final urlParams = { + 'provider': provider.name, + 'scopes': ?scopes, + 'redirect_to': ?redirectTo, + ...?queryParams, + if (codeChallenge != null) ...{ 'flow_type': _flowType.name, 'code_challenge': codeChallenge, 'code_challenge_method': 's256', - }); - } - if (skipBrowserRedirect) { - urlParams['skip_http_redirect'] = 'true'; - } + }, + if (skipBrowserRedirect) 'skip_http_redirect': 'true', + }; final oauthUrl = '$url?${Uri(queryParameters: urlParams).query}'; return OAuthResponse(provider: provider, url: oauthUrl); } diff --git a/packages/gotrue/lib/src/gotrue_mfa_api.dart b/packages/gotrue/lib/src/gotrue_mfa_api.dart index c9f944321..73388d4af 100644 --- a/packages/gotrue/lib/src/gotrue_mfa_api.dart +++ b/packages/gotrue/lib/src/gotrue_mfa_api.dart @@ -132,8 +132,12 @@ class GoTrueMFAApi { /// Prepares a challenge used to verify that a user has access to a MFA factor. /// /// [factorId] System assigned identifier for authenticator device as returned by enroll + /// + /// [channel] Messaging channel to use for phone factors (e.g. whatsapp or sms). + /// Defaults to the server's behavior (sms) when omitted. Future challenge({ required String factorId, + OtpChannel? channel, }) async { final session = _client.currentSession; @@ -142,6 +146,7 @@ class GoTrueMFAApi { RequestMethodType.post, options: GotrueRequestOptions( headers: _client._headers, + body: channel == null ? null : {'channel': channel.name}, jwt: session?.accessToken, ), ); @@ -214,7 +219,7 @@ class GoTrueMFAApi { currentAuthenticationMethods: [], ); } - final payload = Jwt.parseJwt(session.accessToken); + final payload = decodeJwtPayload(session.accessToken).claims; final currentLevel = AuthenticatorAssuranceLevels.values.firstWhereOrNull( (level) => level.name == payload['aal'], diff --git a/packages/gotrue/lib/src/gotrue_oauth_api.dart b/packages/gotrue/lib/src/gotrue_oauth_api.dart index af9769b53..02064ce7a 100644 --- a/packages/gotrue/lib/src/gotrue_oauth_api.dart +++ b/packages/gotrue/lib/src/gotrue_oauth_api.dart @@ -11,24 +11,88 @@ class OAuthAuthorizedClient { /// Human-readable name of the OAuth client final String? clientName; + /// URI of the OAuth client's website + final String? clientUri; + + /// URI of the OAuth client's logo + final String? logoUri; + const OAuthAuthorizedClient({ required this.clientId, this.clientName, + this.clientUri, + this.logoUri, }); factory OAuthAuthorizedClient.fromJson(Map json) { return OAuthAuthorizedClient( clientId: json['id'] as String, clientName: json['name'] as String?, + clientUri: json['uri'] as String?, + logoUri: json['logo_uri'] as String?, + ); + } +} + +/// An OAuth grant representing a user's authorization of an OAuth client. +/// +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +class OAuthGrant { + /// The OAuth client the grant was issued to. + final OAuthAuthorizedClient client; + + /// The scopes granted to the client. + final List scopes; + + /// The moment the grant was created. + final DateTime grantedAt; + + const OAuthGrant({ + required this.client, + required this.scopes, + required this.grantedAt, + }); + + factory OAuthGrant.fromJson(Map json) { + return OAuthGrant( + client: OAuthAuthorizedClient.fromJson(json['client']), + scopes: (json['scopes'] as List?)?.cast() ?? const [], + grantedAt: DateTime.parse(json['granted_at'] as String), ); } } +/// Result returned by [GoTrueOAuthApi.getAuthorizationDetails]. +/// +/// The OAuth 2.1 server responds in one of two ways, depending on whether the +/// signed-in user has already granted consent to the requesting client: +/// +/// * [OAuthAuthorizationDetailsResponse] — consent is still required, so the +/// requesting client, user and requested scopes are returned for display in +/// a consent screen. +/// * [OAuthAuthorizationRedirectResponse] — the user already granted consent +/// to this client, so the server short-circuits the consent flow and returns +/// only the redirect URL the caller should navigate to. +/// +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +sealed class OAuthAuthorizationResponse { + const OAuthAuthorizationResponse(); + + factory OAuthAuthorizationResponse.fromJson(Map json) { + // When consent was already granted, the server short-circuits the flow and + // returns a redirect-only body ({"redirect_url": ...}) with no client or + // user information. + return json.containsKey('redirect_url') + ? OAuthAuthorizationRedirectResponse.fromJson(json) + : OAuthAuthorizationDetailsResponse.fromJson(json); + } +} + /// Response type representing the details of a pending OAuth authorization -/// request. +/// request that still requires the user's consent. /// /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. -class OAuthAuthorizationDetailsResponse { +class OAuthAuthorizationDetailsResponse extends OAuthAuthorizationResponse { /// The unique identifier for this authorization request. final String authorizationId; @@ -74,6 +138,29 @@ class OAuthAuthorizationDetailsResponse { } } +/// Response type returned when the signed-in user has already granted consent +/// to the requesting client. +/// +/// The OAuth 2.1 server short-circuits the consent flow and only provides the +/// redirect URL the caller should navigate to in order to complete the request. +/// +/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +class OAuthAuthorizationRedirectResponse extends OAuthAuthorizationResponse { + /// The URL the caller should redirect the user to in order to complete the + /// already-approved authorization request. + final String redirectUrl; + + const OAuthAuthorizationRedirectResponse({required this.redirectUrl}); + + factory OAuthAuthorizationRedirectResponse.fromJson( + Map json, + ) { + return OAuthAuthorizationRedirectResponse( + redirectUrl: json['redirect_url'] as String, + ); + } +} + /// Response type for an OAuth authorization consent decision (approve or deny). /// /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. @@ -134,10 +221,13 @@ class GoTrueOAuthApi { /// Retrieves details about an OAuth authorization request. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. /// - /// Returns authorization details including client info, scopes, and user information. - /// If the response includes only a redirect_url field, it means consent was already given - the caller - /// should handle the redirect manually if needed. - Future getAuthorizationDetails( + /// Returns an [OAuthAuthorizationDetailsResponse] with the client info, + /// scopes and user information when the user still has to consent. When the + /// user already granted consent to this client, the server short-circuits the + /// flow and an [OAuthAuthorizationRedirectResponse] carrying the redirect URL + /// is returned instead. Switch on the sealed [OAuthAuthorizationResponse] to + /// handle both cases. + Future getAuthorizationDetails( String authorizationId, ) async { final session = _client.currentSession; @@ -151,7 +241,7 @@ class GoTrueOAuthApi { ), ); - return OAuthAuthorizationDetailsResponse.fromJson(data); + return OAuthAuthorizationResponse.fromJson(data); } /// Approves a pending OAuth authorization request. @@ -205,4 +295,41 @@ class GoTrueOAuthApi { return OAuthConsentResponse.fromJson(data); } + + /// Lists all OAuth grants that the authenticated user has authorized. + /// + /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + Future> listGrants() async { + final session = _client.currentSession; + + final data = await _fetch.request( + '${_client._url}/user/oauth/grants', + RequestMethodType.get, + options: GotrueRequestOptions( + headers: _client._headers, + jwt: session?.accessToken, + ), + ); + + return (data as List).map((grant) => OAuthGrant.fromJson(grant)).toList(); + } + + /// Revokes the authenticated user's OAuth grant for the client identified by + /// [clientId]. + /// + /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + Future revokeGrant(String clientId) async { + final session = _client.currentSession; + + await _fetch.request( + '${_client._url}/user/oauth/grants', + RequestMethodType.delete, + options: GotrueRequestOptions( + headers: _client._headers, + jwt: session?.accessToken, + query: {'client_id': clientId}, + noResolveJson: true, + ), + ); + } } diff --git a/packages/gotrue/lib/src/gotrue_passkey_api.dart b/packages/gotrue/lib/src/gotrue_passkey_api.dart index 84b97bafe..618162739 100644 --- a/packages/gotrue/lib/src/gotrue_passkey_api.dart +++ b/packages/gotrue/lib/src/gotrue_passkey_api.dart @@ -54,9 +54,17 @@ class GoTruePasskeyApi { /// platform's passkey API to create a credential, then complete the /// registration with [verifyRegistration]. /// + /// [friendlyName] is the account label the authenticator shows for the + /// passkey. When the server does not provide a `user.name` in the options + /// (for example when the user has neither an email nor a phone), it is filled + /// with [friendlyName] if given, or a generic default otherwise, so the + /// platform ceremony always has a name to display. + /// /// Requires a signed in (non-anonymous) user. If the user has verified MFA /// factors, the session has to be at `aal2` to manage passkeys. - Future startRegistration() async { + Future startRegistration({ + String? friendlyName, + }) async { final session = _client.currentSession; final data = await _fetch.request( @@ -69,7 +77,31 @@ class GoTruePasskeyApi { ), ); - return PasskeyRegistrationOptionsResponse.fromJson(data); + final response = PasskeyRegistrationOptionsResponse.fromJson(data); + _applyUserNameFallback(response.options, friendlyName); + return response; + } + + static const _defaultPasskeyName = 'Passkey'; + + void _applyUserNameFallback( + Map options, + String? friendlyName, + ) { + final user = options['user']; + if (user is! Map) { + return; + } + + final name = user['name']; + if (name is! String || name.isEmpty) { + user['name'] = friendlyName ?? _defaultPasskeyName; + } + + final displayName = user['displayName']; + if (displayName is! String || displayName.isEmpty) { + user['displayName'] = user['name']; + } } /// Completes the registration of a new passkey. diff --git a/packages/gotrue/lib/src/helper.dart b/packages/gotrue/lib/src/helper.dart index 39c0a1346..d919911f2 100644 --- a/packages/gotrue/lib/src/helper.dart +++ b/packages/gotrue/lib/src/helper.dart @@ -1,35 +1,12 @@ import 'dart:convert'; -import 'dart:math'; -import 'package:crypto/crypto.dart'; -import 'package:gotrue/src/base64url.dart'; import 'package:gotrue/src/types/auth_exception.dart'; import 'package:gotrue/src/types/jwt.dart'; +import 'package:meta/meta.dart'; +import 'package:supabase_common/supabase_common.dart'; -/// Generates a random code verifier -String generatePKCEVerifier() { - const verifierLength = 56; - final random = Random.secure(); - return base64UrlEncode( - List.generate(verifierLength, (_) => random.nextInt(256)), - ).split('=')[0]; -} - -String generatePKCEChallenge(String verifier) { - return base64UrlEncode( - sha256.convert(ascii.encode(verifier)).bytes, - ).split('=')[0]; -} - -final uuidRegex = RegExp( - r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', -); - -void validateUuid(String id) { - if (!uuidRegex.hasMatch(id)) { - throw ArgumentError('Invalid id: $id, must be a valid UUID'); - } -} +export 'package:supabase_common/supabase_common.dart' + show generatePKCEVerifier, generatePKCEChallenge, uuidRegex, validateUuid; /// Decodes a JWT token without performing validation /// @@ -75,6 +52,29 @@ DecodedJwt decodeJwt(String token) { } } +/// Decodes only the payload of a JWT without validating the header or signature. +/// +/// Useful where just the claims are needed and the token may not carry a +/// well-formed header or signature. Throws [AuthInvalidJwtException] if the +/// structure or payload is invalid. +@internal +JwtPayload decodeJwtPayload(String token) { + final parts = token.split('.'); + if (parts.length != 3) { + throw AuthInvalidJwtException('Invalid JWT structure'); + } + + try { + final payloadJson = Base64Url.decodeToString(parts[1]); + return JwtPayload.fromJson(json.decode(payloadJson)); + } catch (e) { + if (e is AuthInvalidJwtException) { + rethrow; + } + throw AuthInvalidJwtException('Failed to decode JWT: $e'); + } +} + /// Validates the expiration time of a JWT /// /// Throws [AuthException] if the exp claim is missing or the JWT has expired. diff --git a/packages/gotrue/lib/src/types/auth_response.dart b/packages/gotrue/lib/src/types/auth_response.dart index 92e34b213..095f4e515 100644 --- a/packages/gotrue/lib/src/types/auth_response.dart +++ b/packages/gotrue/lib/src/types/auth_response.dart @@ -102,22 +102,3 @@ class GenerateLinkProperties { json['verification_type'], ); } - -extension ToSnakeCase on Enum { - String get snakeCase { - final a = 'a'.codeUnitAt(0), z = 'z'.codeUnitAt(0); - final A = 'A'.codeUnitAt(0), Z = 'Z'.codeUnitAt(0); - final result = StringBuffer()..write(name[0].toLowerCase()); - for (var i = 1; i < name.length; i++) { - final char = name.codeUnitAt(i); - if (A <= char && char <= Z) { - final pChar = name.codeUnitAt(i - 1); - if (a <= pChar && pChar <= z) { - result.write('_'); - } - } - result.write(name[i].toLowerCase()); - } - return result.toString(); - } -} diff --git a/packages/gotrue/lib/src/types/custom_oauth_provider.dart b/packages/gotrue/lib/src/types/custom_oauth_provider.dart index 784899045..245be1827 100644 --- a/packages/gotrue/lib/src/types/custom_oauth_provider.dart +++ b/packages/gotrue/lib/src/types/custom_oauth_provider.dart @@ -193,8 +193,7 @@ class CustomOAuthProvider { customClaimsAllowlist: (json['custom_claims_allowlist'] as List?)?.cast(), pkceEnabled: json['pkce_enabled'] as bool?, attributeMapping: json['attribute_mapping'] as Map?, - authorizationParams: (json['authorization_params'] as Map?) - ?.cast(), + authorizationParams: (json['authorization_params'] as Map?)?.cast(), enabled: json['enabled'] as bool?, emailOptional: json['email_optional'] as bool?, issuer: json['issuer'] as String?, diff --git a/packages/gotrue/lib/src/types/fetch_options.dart b/packages/gotrue/lib/src/types/fetch_options.dart index 8cc8f6387..733f33ef3 100644 --- a/packages/gotrue/lib/src/types/fetch_options.dart +++ b/packages/gotrue/lib/src/types/fetch_options.dart @@ -1,13 +1,6 @@ -class FetchOptions { - final Map headers; - final bool noResolveJson; +import 'package:supabase_common/supabase_common.dart'; - const FetchOptions( - Map? headers, { - bool? noResolveJson, - }) : headers = headers ?? const {}, - noResolveJson = noResolveJson ?? false; -} +export 'package:supabase_common/supabase_common.dart' show FetchOptions; class GotrueRequestOptions extends FetchOptions { final String? jwt; diff --git a/packages/gotrue/lib/src/types/jwt.dart b/packages/gotrue/lib/src/types/jwt.dart index ce62c0573..80f87648d 100644 --- a/packages/gotrue/lib/src/types/jwt.dart +++ b/packages/gotrue/lib/src/types/jwt.dart @@ -1,7 +1,7 @@ -import 'dart:convert'; import 'dart:typed_data'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:supabase_common/supabase_common.dart'; /// JWT Header structure class JwtHeader { @@ -238,35 +238,23 @@ class JWK { } /// Allows accessing additional properties using operator[]. - dynamic operator [](String key) { - switch (key) { - case 'kty': - return kty; - case 'key_ops': - return keyOps; - case 'alg': - return alg; - case 'kid': - return kid; - default: - return _additionalProperties[key]; - } - } + dynamic operator [](String key) => switch (key) { + 'kty' => kty, + 'key_ops' => keyOps, + 'alg' => alg, + 'kid' => kid, + _ => _additionalProperties[key], + }; /// Converts this [JWK] to a JSON map. Map toJson() { - final Map json = { + return { 'kty': kty, 'key_ops': keyOps, + 'alg': ?alg, + 'kid': ?kid, ..._additionalProperties, }; - if (alg != null) { - json['alg'] = alg; - } - if (kid != null) { - json['kid'] = kid; - } - return json; } /// Builds the RSA public key for verifying RS256 JWTs from this JWK's @@ -281,8 +269,8 @@ class JWK { // clock 1.1.2 and so allows dart_jsonwebtoken 3.x. fromJWK also adds EC // (ES256) support. RSAPublicKey get publicKey { - final modulus = _base64UrlToBytes(this['n'] as String); - final exponent = _base64UrlToBytes(this['e'] as String); + final modulus = Base64Url.decodeToBytes(this['n'] as String); + final exponent = Base64Url.decodeToBytes(this['e'] as String); final der = _derSequence([ _derInteger(modulus), _derInteger(exponent), @@ -291,10 +279,6 @@ class JWK { } } -List _base64UrlToBytes(String input) { - return base64Url.decode(base64Url.normalize(input)); -} - /// DER-encodes an unsigned big-endian integer (prefixing a zero byte when the /// high bit is set so it is not interpreted as negative). List _derInteger(List bytes) { diff --git a/packages/gotrue/lib/src/types/mfa.dart b/packages/gotrue/lib/src/types/mfa.dart index 6793b6546..e1bcdf3c3 100644 --- a/packages/gotrue/lib/src/types/mfa.dart +++ b/packages/gotrue/lib/src/types/mfa.dart @@ -81,18 +81,15 @@ class PhoneEnrollment { ); } - factory PhoneEnrollment._fromJsonValue(dynamic value) { - if (value is String) { - // Server returns phone number as a string directly - return PhoneEnrollment(phone: value); - } else if (value is Map) { - // Server returns phone data as an object - return PhoneEnrollment.fromJson(value); - } - throw ArgumentError( + factory PhoneEnrollment._fromJsonValue(dynamic value) => switch (value) { + // Server returns phone number as a string directly + String() => PhoneEnrollment(phone: value), + // Server returns phone data as an object + Map() => PhoneEnrollment.fromJson(value), + _ => throw ArgumentError( 'Invalid phone enrollment data type: ${value.runtimeType}', - ); - } + ), + }; } class AuthMFAChallengeResponse { diff --git a/packages/gotrue/lib/src/types/session.dart b/packages/gotrue/lib/src/types/session.dart index 9818bfa18..7ef93b027 100644 --- a/packages/gotrue/lib/src/types/session.dart +++ b/packages/gotrue/lib/src/types/session.dart @@ -1,6 +1,7 @@ import 'package:gotrue/src/constants.dart'; +import 'package:gotrue/src/helper.dart'; import 'package:gotrue/src/types/user.dart'; -import 'package:jwt_decode/jwt_decode.dart'; +import 'package:meta/meta.dart'; class Session { final String? providerToken; @@ -77,8 +78,7 @@ class Session { int? get _expiresAt { try { - final payload = Jwt.parseJwt(accessToken); - return payload['exp'] as int; + return decodeJwtPayload(accessToken).exp; } catch (_) { return null; } @@ -96,6 +96,16 @@ class Session { ); } + /// Returns `true` if the token is expired right now, without applying the + /// [Constants.expiryMargin] buffer used by [isExpired]. + @internal + bool get isExpiredWithoutMargin { + if (expiresAt == null) return false; + return DateTime.now().isAfter( + DateTime.fromMillisecondsSinceEpoch(expiresAt! * 1000), + ); + } + Session copyWith({ String? accessToken, int? expiresIn, diff --git a/packages/gotrue/lib/src/types/types.dart b/packages/gotrue/lib/src/types/types.dart index 3508da3d0..c295a7bb2 100644 --- a/packages/gotrue/lib/src/types/types.dart +++ b/packages/gotrue/lib/src/types/types.dart @@ -1,6 +1,8 @@ +import 'package:supabase_common/supabase_common.dart'; + typedef BroadcastChannel = ({ Stream> onMessage, - void Function(Map) postMessage, + void Function(Map) postMessage, void Function() close, }); @@ -104,49 +106,32 @@ final class OAuthProvider { /// OAuth client grant types supported by the OAuth 2.1 server. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. -enum OAuthClientGrantType { - authorizationCode('authorization_code'), - refreshToken('refresh_token'); - - final String value; - const OAuthClientGrantType(this.value); -} +enum OAuthClientGrantType { authorizationCode, refreshToken } /// OAuth client response types supported by the OAuth 2.1 server. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. -enum OAuthClientResponseType { - code('code'); - - final String value; - const OAuthClientResponseType(this.value); -} +enum OAuthClientResponseType { code } /// OAuth client type indicating whether the client can keep credentials confidential. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. enum OAuthClientType { - public('public'), - confidential('confidential'); - - final String value; - const OAuthClientType(this.value); + public, + confidential; static OAuthClientType fromString(String value) { - return OAuthClientType.values.firstWhere((e) => e.value == value); + return OAuthClientType.values.firstWhere((e) => e.snakeCase == value); } } /// OAuth client registration type. /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. enum OAuthClientRegistrationType { - dynamic('dynamic'), - manual('manual'); - - final String value; - const OAuthClientRegistrationType(this.value); + dynamic, + manual; static OAuthClientRegistrationType fromString(String value) { return OAuthClientRegistrationType.values.firstWhere( - (e) => e.value == value, + (e) => e.snakeCase == value, ); } } @@ -224,14 +209,14 @@ class OAuthClient { grantTypes: (json['grant_types'] as List) .map( (e) => OAuthClientGrantType.values.firstWhere( - (gt) => gt.value == e as String, + (gt) => gt.snakeCase == e as String, ), ) .toList(), responseTypes: (json['response_types'] as List) .map( (e) => OAuthClientResponseType.values.firstWhere( - (rt) => rt.value == e as String, + (rt) => rt.snakeCase == e as String, ), ) .toList(), @@ -277,8 +262,8 @@ class CreateOAuthClientParams { 'client_name': clientName, 'client_uri': ?clientUri, 'redirect_uris': redirectUris, - 'grant_types': ?grantTypes?.map((e) => e.value).toList(), - 'response_types': ?responseTypes?.map((e) => e.value).toList(), + 'grant_types': ?grantTypes?.map((e) => e.snakeCase).toList(), + 'response_types': ?responseTypes?.map((e) => e.snakeCase).toList(), 'scope': ?scope, }; } @@ -319,8 +304,8 @@ class UpdateOAuthClientParams { 'client_name': ?clientName, 'client_uri': ?clientUri, 'redirect_uris': ?redirectUris, - 'grant_types': ?grantTypes?.map((e) => e.value).toList(), - 'response_types': ?responseTypes?.map((e) => e.value).toList(), + 'grant_types': ?grantTypes?.map((e) => e.snakeCase).toList(), + 'response_types': ?responseTypes?.map((e) => e.snakeCase).toList(), 'scope': ?scope, }; } diff --git a/packages/gotrue/lib/src/types/user_attributes.dart b/packages/gotrue/lib/src/types/user_attributes.dart index a8046b379..d5165c636 100644 --- a/packages/gotrue/lib/src/types/user_attributes.dart +++ b/packages/gotrue/lib/src/types/user_attributes.dart @@ -20,12 +20,21 @@ class UserAttributes { /// The `data` should be a JSON object that includes user-specific info, such as their first and last name. Object? data; + /// The user's current password. + /// + /// This is only used when the user is changing their password and the + /// `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_CURRENT_PASSWORD` setting is + /// enabled on the auth server, in which case the current password is required + /// to verify the change. + String? currentPassword; + UserAttributes({ this.email, this.phone, this.password, this.nonce, this.data, + this.currentPassword, }) : assert(data == null || data is List || data is Map); Map toJson() { @@ -35,6 +44,7 @@ class UserAttributes { 'nonce': ?nonce, 'password': ?password, 'data': ?data, + 'current_password': ?currentPassword, }; } @@ -49,6 +59,7 @@ class UserAttributes { other.phone == phone && other.password == password && other.nonce == nonce && + other.currentPassword == currentPassword && mapEquals(other.data, data); } @@ -58,6 +69,7 @@ class UserAttributes { phone.hashCode ^ password.hashCode ^ nonce.hashCode ^ + currentPassword.hashCode ^ data.hashCode; } } diff --git a/packages/gotrue/lib/src/version.dart b/packages/gotrue/lib/src/version.dart index 6a0c183ab..ae123d405 100644 --- a/packages/gotrue/lib/src/version.dart +++ b/packages/gotrue/lib/src/version.dart @@ -1 +1 @@ -const version = '2.25.0'; +const version = '2.27.0'; diff --git a/packages/gotrue/pubspec.yaml b/packages/gotrue/pubspec.yaml index cd1da40d5..e83e81a50 100644 --- a/packages/gotrue/pubspec.yaml +++ b/packages/gotrue/pubspec.yaml @@ -1,9 +1,16 @@ name: gotrue description: A dart client library for the GoTrue API. -version: 2.25.0 +version: 2.27.0 homepage: "https://supabase.com" repository: "https://github.com/supabase/supabase-flutter/tree/main/packages/gotrue" +issue_tracker: "https://github.com/supabase/supabase-flutter/issues" documentation: "https://supabase.com/docs/reference/dart/auth-signup" +topics: + - supabase + - auth + - authentication + - backend + - api environment: sdk: ">=3.9.0 <4.0.0" @@ -11,22 +18,21 @@ environment: resolution: workspace dependencies: - collection: ^1.15.0 - crypto: ^3.0.2 - http: ^1.2.2 - jwt_decode: ^0.3.1 - retry: ^3.1.0 - rxdart: ">=0.27.7 <0.29.0" - meta: ^1.7.0 - logging: ^1.2.0 - web: ">=0.5.0 <2.0.0" - dart_jsonwebtoken: ">=2.17.0 <4.0.0" + collection: ^1.19.0 + crypto: ^3.0.7 + http: ^1.6.0 + meta: ^1.16.0 + logging: ^1.3.0 + supabase_common: 0.1.1 + web: ">=1.0.0 <2.0.0" + dart_jsonwebtoken: ">=3.0.0 <4.0.0" dev_dependencies: - dotenv: ^4.1.0 - supabase_lints: ^0.1.0 - test: ^1.16.4 - otp: ^3.1.3 + cryptography: ^2.9.0 + dotenv: ^4.2.0 + supabase_lints: ^0.1.1 + test: ^1.25.0 + otp: ^3.2.0 false_secrets: - /test/** diff --git a/packages/gotrue/test/admin_test.dart b/packages/gotrue/test/admin_test.dart index d472f68f5..c2f2ec042 100644 --- a/packages/gotrue/test/admin_test.dart +++ b/packages/gotrue/test/admin_test.dart @@ -16,7 +16,7 @@ void main() { late GoTrueClient client; setUp(() async { - final res = await http.post( + final response = await http.post( Uri.parse('http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data'), headers: { 'apikey': getServiceRoleToken(env), @@ -24,7 +24,7 @@ void main() { }, ); - if (res.body.isNotEmpty) throw res.body; + if (response.body.isNotEmpty) throw response.body; client = GoTrueClient( url: gotrueUrl, @@ -48,21 +48,21 @@ void main() { group('User updates', () { test('modify email using updateUserById()', () async { - final res = await client.admin.updateUserById( + final response = await client.admin.updateUserById( userId1, attributes: AdminUserAttributes(email: 'new@email.com'), ); - expect(res.user!.email, 'new@email.com'); + expect(response.user!.email, 'new@email.com'); }); test('modify userMetadata using updateUserById()', () async { - final res = await client.admin.updateUserById( + final response = await client.admin.updateUserById( userId1, attributes: AdminUserAttributes( userMetadata: {'username': 'newUserName'}, ), ); - expect(res.user!.userMetadata!['username'], 'newUserName'); + expect(response.user!.userMetadata!['username'], 'newUserName'); }); }); @@ -100,22 +100,22 @@ void main() { 'inviteUserByEmail() creates a new user with an invited_at timestamp', () async { final newEmail = 'new${Random.secure().nextInt(4096)}@fake.org'; - final res = await client.admin.inviteUserByEmail(newEmail); - expect(res.user, isNotNull); - expect(res.user?.email, newEmail); - expect(res.user?.invitedAt, isNotNull); + final response = await client.admin.inviteUserByEmail(newEmail); + expect(response.user, isNotNull); + expect(response.user?.email, newEmail); + expect(response.user?.invitedAt, isNotNull); }, ); test('createUser() creates a new user', () async { final newEmail = 'new${Random.secure().nextInt(4096)}@fake.org'; final userMetadata = {'name': 'supabase'}; - final res = await client.admin.createUser( + final response = await client.admin.createUser( AdminUserAttributes(email: newEmail, userMetadata: userMetadata), ); - expect(res.user, isNotNull); - expect(res.user?.email, newEmail); - expect(res.user?.userMetadata, userMetadata); + expect(response.user, isNotNull); + expect(response.user?.email, newEmail); + expect(response.user?.userMetadata, userMetadata); }); }); diff --git a/packages/gotrue/test/client_test.dart b/packages/gotrue/test/client_test.dart index dfbd3057b..a593140b2 100644 --- a/packages/gotrue/test/client_test.dart +++ b/packages/gotrue/test/client_test.dart @@ -4,7 +4,6 @@ import 'package:dotenv/dotenv.dart'; import 'package:gotrue/gotrue.dart'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import 'package:jwt_decode/jwt_decode.dart'; import 'package:test/test.dart'; import 'custom_http_client.dart'; @@ -26,7 +25,7 @@ void main() { late GoTrueClient clientWithAuthConfirmOff; setUp(() async { - final res = await http.post( + final response = await http.post( Uri.parse( 'http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data', ), @@ -36,7 +35,7 @@ void main() { 'Authorization': 'Bearer ${getServiceRoleToken(env)}', }, ); - if (res.body.isNotEmpty) throw res.body; + if (response.body.isNotEmpty) throw response.body; newEmail = getNewEmail(); newPhone = getNewPhone(); @@ -105,7 +104,7 @@ void main() { 'signUp() with weak password throws AuthWeakPasswordException', () async { await expectLater( - () => client.signUp(email: newEmail, password: '123'), + client.signUp(email: newEmail, password: '123'), throwsA( isA().having( (e) => e.code, @@ -127,7 +126,7 @@ void main() { 'http://my-callback-url.com/welcome#expires_in=$expiresIn&refresh_token=$refreshToken&token_type=$tokenType&provider_token=$providerToken', ); await expectLater( - () => client.getSessionFromUrl(urlWithoutAccessToken), + client.getSessionFromUrl(urlWithoutAccessToken), throwsA(anything), ); }); @@ -140,7 +139,7 @@ void main() { 'http://my-callback-url.com/#error=unauthorized_client&error_code=401&error_description=${Uri.encodeComponent(errorMessage)}', ); await expectLater( - () => client.getSessionFromUrl(urlWithoutAccessToken), + client.getSessionFromUrl(urlWithoutAccessToken), throwsA( isA() .having((e) => e.message, 'message', errorMessage) @@ -184,50 +183,38 @@ void main() { }); test('signUp() with autoConfirm off with email', () async { - final res = await clientWithAuthConfirmOff.signUp( + final response = await clientWithAuthConfirmOff.signUp( email: newEmail, password: password, emailRedirectTo: 'https://localhost:9999/welcome', ); - expect(res.session, isNull); - expect(res.user, isNotNull); - expect(res.user!.email, 'fake1@email.com'); + expect(response.session, isNull); + expect(response.user, isNotNull); + expect(response.user!.email, 'fake1@email.com'); }); - test( - 'signUp() with autoConfirm off with phone should fail because Twilio is not setup', - () async { - try { - await clientWithAuthConfirmOff.signUp( - phone: phone1, - password: password, - ); - } catch (error) { - expect(error, isA()); - } - }, - ); + test('signUp() with autoConfirm off with phone', () async { + final response = await clientWithAuthConfirmOff.signUp( + phone: phone1, + password: password, + ); + expect(response.session, isNull); + expect(response.user, isNotNull); + }); test('signUp() with email should throw error if used twice', () async { - final localEmail = email1; - - try { - await client.signUp(email: localEmail, password: password); - } catch (error) { - expect(error, isA()); - } + await expectLater( + client.signUp(email: email1, password: password), + throwsA(isA()), + ); }); - test('signInWithOtp with email', () async { - await client.signInWithOtp(email: newEmail); + test('signInWithOtp with email completes successfully', () async { + expect(client.signInWithOtp(email: newEmail), completes); }); - test('signInWithOtp with phone', () async { - try { - await client.signInWithOtp(phone: phone1); - } catch (error) { - expect(error, isA()); - } + test('signInWithOtp with phone completes successfully', () async { + expect(client.signInWithOtp(phone: phone1), completes); }); test('signInWithPassword() with email', () async { @@ -241,8 +228,8 @@ void main() { expect(data?.refreshToken, isA()); expect(data?.user.id, isA()); - final payload = Jwt.parseJwt(data!.accessToken); - expect(payload['exp'], data.expiresAt); + final payload = decodeJwt(data!.accessToken).payload; + expect(payload.exp, data.expiresAt); }); test('Get user', () async { @@ -265,8 +252,8 @@ void main() { expect(data?.refreshToken, isA()); expect(data?.user.id, isA()); - final payload = Jwt.parseJwt(data!.accessToken); - expect(payload['exp'], data.expiresAt); + final payload = decodeJwt(data!.accessToken).payload; + expect(payload.exp, data.expiresAt); }); test('Set session', () async { @@ -290,7 +277,7 @@ void main() { 'Set session with an empty refresh token throws AuthSessionMissingException', () async { await expectLater( - () => client.setSession(''), + client.setSession(''), throwsA(isA()), ); }, @@ -374,7 +361,7 @@ void main() { 'Set session with empty access token throws AuthSessionMissingException', () async { await expectLater( - () => client.setSession('some-refresh-token', accessToken: ''), + client.setSession('some-refresh-token', accessToken: ''), throwsA(isA()), ); }, @@ -384,7 +371,7 @@ void main() { 'Set session with malformed access token throws AuthInvalidJwtException', () async { await expectLater( - () => client.setSession( + client.setSession( 'some-refresh-token', accessToken: 'not-a-valid-jwt', ), @@ -442,7 +429,7 @@ void main() { test('Update user with the same password throws AuthException', () async { await client.signInWithPassword(email: email1, password: password); await expectLater( - () => client.updateUser(UserAttributes(password: password)), + client.updateUser(UserAttributes(password: password)), throwsA( isA().having( (e) => e.code, @@ -477,7 +464,7 @@ void main() { test('signIn() with the wrong password', () async { await expectLater( - () => client.signInWithPassword( + client.signInWithPassword( email: email1, password: 'wrong_$password', ), @@ -489,51 +476,51 @@ void main() { group('The auth client can signin with third-party oAuth providers', () { test('signIn() with Provider', () async { - final res = await client.getOAuthSignInUrl( + final response = await client.getOAuthSignInUrl( provider: OAuthProvider.google, ); - expect(res.url, isA()); - expect(res.provider, OAuthProvider.google); + expect(response.url, isA()); + expect(response.provider, OAuthProvider.google); }); test('signIn() with Provider with redirectTo', () async { - final res = await client.getOAuthSignInUrl( + final response = await client.getOAuthSignInUrl( provider: OAuthProvider.google, redirectTo: 'https://supabase.com', ); expect( - res.url, + response.url, '$gotrueUrl/authorize?provider=google&redirect_to=https%3A%2F%2Fsupabase.com', ); - expect(res.provider, OAuthProvider.google); + expect(response.provider, OAuthProvider.google); }); test('signIn() with Provider can append a redirectUrl', () async { - final res = await client.getOAuthSignInUrl( + final response = await client.getOAuthSignInUrl( provider: OAuthProvider.google, redirectTo: 'https://localhost:9000/welcome', ); - expect(res.url, isA()); - expect(res.provider, OAuthProvider.google); + expect(response.url, isA()); + expect(response.provider, OAuthProvider.google); }); test('signIn() with Provider can append scopes', () async { - final res = await client.getOAuthSignInUrl( + final response = await client.getOAuthSignInUrl( provider: OAuthProvider.google, scopes: 'repo', ); - expect(res.url, isA()); - expect(res.provider, OAuthProvider.google); + expect(response.url, isA()); + expect(response.provider, OAuthProvider.google); }); test('signIn() with Provider can append options', () async { - final res = await client.getOAuthSignInUrl( + final response = await client.getOAuthSignInUrl( provider: OAuthProvider.google, redirectTo: 'https://localhost:9000/welcome', scopes: 'repo', ); - expect(res.url, isA()); - expect(res.provider, OAuthProvider.google); + expect(response.url, isA()); + expect(response.provider, OAuthProvider.google); }); }); @@ -611,9 +598,9 @@ void main() { test('Call getLinkIdentityUrl', () async { await client.signInWithPassword(email: email1, password: password); - final res = await client.getLinkIdentityUrl(OAuthProvider.google); - expect(res.url, isA()); - final uri = Uri.parse(res.url); + final response = await client.getLinkIdentityUrl(OAuthProvider.google); + expect(response.url, isA()); + final uri = Uri.parse(response.url); expect(uri.host, 'accounts.google.com'); }); }); @@ -626,15 +613,23 @@ void main() { }); test('signIn()', () async { - try { - await client.signInWithPassword(email: email1, password: password); - } catch (error) { - expect(error, isA()); - error as AuthUnknownException; - expect(error.statusCode, '420'); - expect(error.originalError, isA()); - expect(error.message, contains('empty response')); - } + await expectLater( + client.signInWithPassword(email: email1, password: password), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', '420') + .having( + (e) => e.originalError, + 'originalError', + isA(), + ) + .having( + (e) => e.message, + 'message', + contains('empty response'), + ), + ), + ); }); }); @@ -696,7 +691,7 @@ void main() { 'http://my-callback-url.com/#error=unauthorized_client&error_code=401&error_description=${Uri.encodeComponent(errorMessage)}', ); await expectLater( - () => client.getSessionFromUrl(urlWithoutAccessToken), + client.getSessionFromUrl(urlWithoutAccessToken), throwsA( isA().having( (e) => e.message, @@ -750,6 +745,53 @@ void main() { expect(await emittedEvent, AuthChangeEvent.signedIn); }, ); + + test( + 'updateUser email change under PKCE can be exchanged for a session', + () async { + await http.post( + Uri.parse( + 'http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data', + ), + headers: { + 'x-forwarded-for': '127.0.0.1', + 'apikey': getServiceRoleToken(env), + 'Authorization': 'Bearer ${getServiceRoleToken(env)}', + }, + ); + await http.delete( + Uri.parse('http://127.0.0.1:54424/api/v1/messages'), + ); + + final pkceClient = GoTrueClient( + url: gotrueUrl, + headers: { + 'Authorization': 'Bearer $anonToken', + 'apikey': anonToken, + }, + asyncStorage: TestAsyncStorage(), + flowType: AuthFlowType.pkce, + autoRefreshToken: false, + ); + + await pkceClient.signInWithPassword( + email: email1, + password: password, + ); + + final updatedEmail = getNewEmail(); + final updateResponse = await pkceClient.updateUser( + UserAttributes(email: updatedEmail), + ); + expect(updateResponse.user?.newEmail, updatedEmail); + + final code = await _pkceCodeFromEmailChange(updatedEmail); + final exchanged = await pkceClient.exchangeCodeForSession(code); + + expect(exchanged.session.user.email, updatedEmail); + expect(exchanged.session.accessToken, isNotEmpty); + }, + ); }); group('Recovering an already refreshed session', () { @@ -788,7 +830,7 @@ void main() { expect(httpClient.refreshCount, 1); var signedOut = false; - final sub = client.onAuthStateChange.listen( + final subscription = client.onAuthStateChange.listen( (state) { if (state.event == AuthChangeEvent.signedOut) signedOut = true; }, @@ -808,7 +850,55 @@ void main() { expect(signedOut, isFalse); expect(client.currentSession, isNotNull); - await sub.cancel(); + await subscription.cancel(); }); }); } + +/// Reads the email-change confirmation link that GoTrue delivered to +/// [toEmail] via the local Mailpit server, follows it, and returns the PKCE +/// `code` from the redirect so it can be passed to [exchangeCodeForSession]. +Future _pkceCodeFromEmailChange(String toEmail) async { + Map? message; + for (var attempt = 0; attempt < 20 && message == null; attempt++) { + final search = + jsonDecode( + (await http.get( + Uri.parse( + 'http://127.0.0.1:54424/api/v1/search?query=to:$toEmail', + ), + )).body, + ) + as Map; + final messages = search['messages'] as List; + if (messages.isNotEmpty) { + message = + jsonDecode( + (await http.get( + Uri.parse( + 'http://127.0.0.1:54424/api/v1/message/${messages.first['ID']}', + ), + )).body, + ) + as Map; + } else { + await Future.delayed(const Duration(milliseconds: 250)); + } + } + + final link = RegExp(r'http://127\.0\.0\.1:54421/auth/v1/verify\?\S+') + .firstMatch(message!['Text'] as String)! + .group(0)! + .replaceAll(RegExp(r'[)>].*$'), ''); + + final verifyClient = http.Client(); + try { + final request = http.Request('GET', Uri.parse(link)) + ..followRedirects = false; + final response = await verifyClient.send(request); + final location = response.headers['location']!; + return Uri.parse(location).queryParameters['code']!; + } finally { + verifyClient.close(); + } +} diff --git a/packages/gotrue/test/custom_oauth_provider_test.dart b/packages/gotrue/test/custom_oauth_provider_test.dart index f7d450c71..5a6473b5b 100644 --- a/packages/gotrue/test/custom_oauth_provider_test.dart +++ b/packages/gotrue/test/custom_oauth_provider_test.dart @@ -32,12 +32,12 @@ void main() { ); final provider = OAuthProvider('custom:my-provider'); - final res = await client.getOAuthSignInUrl(provider: provider); + final response = await client.getOAuthSignInUrl(provider: provider); - expect(res.provider, provider); - expect(res.url, startsWith('$gotrueUrl/authorize?')); + expect(response.provider, provider); + expect(response.url, startsWith('$gotrueUrl/authorize?')); - final uri = Uri.parse(res.url); + final uri = Uri.parse(response.url); expect(uri.queryParameters['provider'], 'custom:my-provider'); }, ); @@ -64,14 +64,14 @@ void main() { // Derive the expected count from the source file so this test stays // accurate when new static const providers are added without updating // the values list. - final src = File('lib/src/types/types.dart').readAsStringSync(); + final source = File('lib/src/types/types.dart').readAsStringSync(); // Matches `static const foo = OAuthProvider(` but not the `values` field // (which is typed `List` and uses a list literal, not a // direct OAuthProvider constructor call). final declaredCount = RegExp( r'^\s*static\s+const\s+\w+\s*=\s*OAuthProvider\(', multiLine: true, - ).allMatches(src).length; + ).allMatches(source).length; expect(OAuthProvider.values, contains(OAuthProvider.google)); expect(OAuthProvider.values, contains(OAuthProvider.linkedinOidc)); diff --git a/packages/gotrue/test/custom_providers_test.dart b/packages/gotrue/test/custom_providers_test.dart index 064064679..173f2a13b 100644 --- a/packages/gotrue/test/custom_providers_test.dart +++ b/packages/gotrue/test/custom_providers_test.dart @@ -4,7 +4,7 @@ import 'package:test/test.dart'; void main() { group('CreateCustomProviderParams serialization', () { test('serializes required fields', () { - final params = CreateCustomProviderParams( + final parameters = CreateCustomProviderParams( providerType: CustomProviderType.oauth2, identifier: 'custom:mycompany', name: 'My Company', @@ -12,7 +12,7 @@ void main() { clientSecret: 'client-secret', ); - final json = params.toJson(); + final json = parameters.toJson(); expect(json['provider_type'], 'oauth2'); expect(json['identifier'], 'custom:mycompany'); @@ -22,7 +22,7 @@ void main() { }); test('omits custom_claims_allowlist when not provided', () { - final params = CreateCustomProviderParams( + final parameters = CreateCustomProviderParams( providerType: CustomProviderType.oidc, identifier: 'custom:mycompany', name: 'My Company', @@ -30,11 +30,14 @@ void main() { clientSecret: 'client-secret', ); - expect(params.toJson().containsKey('custom_claims_allowlist'), isFalse); + expect( + parameters.toJson().containsKey('custom_claims_allowlist'), + isFalse, + ); }); test('serializes custom_claims_allowlist when provided', () { - final params = CreateCustomProviderParams( + final parameters = CreateCustomProviderParams( providerType: CustomProviderType.oidc, identifier: 'custom:mycompany', name: 'My Company', @@ -44,13 +47,13 @@ void main() { ); expect( - params.toJson()['custom_claims_allowlist'], + parameters.toJson()['custom_claims_allowlist'], ['groups', 'org_id', 'mail'], ); }); test('serializes an empty custom_claims_allowlist', () { - final params = CreateCustomProviderParams( + final parameters = CreateCustomProviderParams( providerType: CustomProviderType.oidc, identifier: 'custom:mycompany', name: 'My Company', @@ -59,25 +62,25 @@ void main() { customClaimsAllowlist: [], ); - expect(params.toJson()['custom_claims_allowlist'], isEmpty); + expect(parameters.toJson()['custom_claims_allowlist'], isEmpty); }); }); group('UpdateCustomProviderParams serialization', () { test('omits custom_claims_allowlist when not provided', () { - const params = UpdateCustomProviderParams(name: 'New name'); + const parameters = UpdateCustomProviderParams(name: 'New name'); - final json = params.toJson(); + final json = parameters.toJson(); expect(json.containsKey('custom_claims_allowlist'), isFalse); expect(json['name'], 'New name'); }); test('serializes custom_claims_allowlist when provided', () { - const params = UpdateCustomProviderParams( + const parameters = UpdateCustomProviderParams( customClaimsAllowlist: ['groups'], ); - expect(params.toJson()['custom_claims_allowlist'], ['groups']); + expect(parameters.toJson()['custom_claims_allowlist'], ['groups']); }); }); diff --git a/packages/gotrue/test/fetch_test.dart b/packages/gotrue/test/fetch_test.dart index 2f5c625f9..036d63846 100644 --- a/packages/gotrue/test/fetch_test.dart +++ b/packages/gotrue/test/fetch_test.dart @@ -77,7 +77,7 @@ void main() { Future _testFetchRequest(Client client) async { final GotrueFetch fetch = GotrueFetch(client); await expectLater( - () => fetch.request(_mockUrl, RequestMethodType.get), + fetch.request(_mockUrl, RequestMethodType.get), throwsA( isA() .having((e) => e.code, 'code', 'weak_password') diff --git a/packages/gotrue/test/get_claims_test.dart b/packages/gotrue/test/get_claims_test.dart index f739d1469..8f6c5dbde 100644 --- a/packages/gotrue/test/get_claims_test.dart +++ b/packages/gotrue/test/get_claims_test.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:dotenv/dotenv.dart'; import 'package:gotrue/gotrue.dart'; +import 'package:gotrue/src/helper.dart'; import 'package:http/http.dart' as http; import 'package:test/test.dart'; @@ -17,7 +20,7 @@ void main() { late String newEmail; setUp(() async { - final res = await http.post( + final response = await http.post( Uri.parse( 'http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data', ), @@ -27,7 +30,7 @@ void main() { 'Authorization': 'Bearer ${getServiceRoleToken(env)}', }, ); - if (res.body.isNotEmpty) throw res.body; + if (response.body.isNotEmpty) throw response.body; newEmail = getNewEmail(); @@ -129,10 +132,10 @@ void main() { GetClaimsOptions(allowExpired: true), ); // If we get here, the exp validation was skipped - } on AuthException catch (e) { + } on AuthException catch (error) { // We expect this to fail during getUser() verification, // not during exp validation - expect(e.message, isNot(contains('expired'))); + expect(error.message, isNot(contains('expired'))); } }); @@ -250,12 +253,12 @@ void main() { try { await client.getClaims(rs256Jwt); // If we get here, the server responded successfully (unlikely in test env) - } catch (e) { + } catch (error) { // The important part is that it should NOT crash with null error // It may fail with network error, invalid signature, etc. // but the error message should not contain null-related errors - expect(e.toString(), isNot(contains('Unexpected null value'))); - expect(e.toString(), isNot(contains('Null check operator'))); + expect(error.toString(), isNot(contains('Unexpected null value'))); + expect(error.toString(), isNot(contains('Null check operator'))); } // Test passes if we get here without null error }, @@ -307,6 +310,37 @@ void main() { ); }); + test('decodeJwtPayload() successfully decodes valid JWT', () { + final jwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InRlc3Qta2lkIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjk5OTk5OTk5OTl9.XyI0rWcOYLpz3R8G8qHWmg7U-tWMHJqzN_e1oDQKzgc'; + + final payload = decodeJwtPayload(jwt); + + expect(payload.sub, '1234567890'); + expect(payload.claims['name'], 'John Doe'); + expect(payload.iat, 1516239022); + expect(payload.exp, 9999999999); + }); + + test('decodeJwtPayload() decodes payload despite a non-JSON header', () { + final payloadPart = base64.encode( + utf8.encode(json.encode({'exp': 9999999999, 'sub': '1234567890'})), + ); + final jwt = 'any.$payloadPart.any'; + + final payload = decodeJwtPayload(jwt); + + expect(payload.exp, 9999999999); + expect(payload.sub, '1234567890'); + }); + + test('decodeJwtPayload() throws on wrong number of parts', () { + expect( + () => decodeJwtPayload('only.two'), + throwsA(isA()), + ); + }); + test('validateExp() throws on expired token', () { final pastTime = DateTime.now().subtract(Duration(hours: 1)); final exp = pastTime.millisecondsSinceEpoch ~/ 1000; diff --git a/packages/gotrue/test/get_session_test.dart b/packages/gotrue/test/get_session_test.dart new file mode 100644 index 000000000..b6ba7d6d5 --- /dev/null +++ b/packages/gotrue/test/get_session_test.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:gotrue/gotrue.dart'; +import 'package:http/http.dart'; +import 'package:test/test.dart'; + +import 'refresh_token_race_test.dart' + show + InvalidRefreshTokenHttpClient, + RefreshTokenTrackingHttpClient, + createExpiredSessionForUser1; +import 'utils.dart'; + +/// HTTP client that always fails a refresh with a retryable server error. +class RetryableFailureHttpClient extends BaseClient { + int requestCount = 0; + + @override + Future send(BaseRequest request) async { + requestCount++; + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'msg': 'unavailable'}))), + 500, + request: request, + ); + } +} + +void main() { + const gotrueUrl = 'http://localhost:9999'; + + group('getSession', () { + test('returns null when there is no session', () async { + final httpClient = RefreshTokenTrackingHttpClient(); + final client = GoTrueClient( + url: gotrueUrl, + asyncStorage: TestAsyncStorage(), + httpClient: httpClient, + autoRefreshToken: false, + ); + + expect(await client.getSession(), isNull); + expect(httpClient.requestCount, 0); + }); + + test( + 'returns the current session without a request when not expired', + () async { + final httpClient = RefreshTokenTrackingHttpClient(); + final client = GoTrueClient( + url: gotrueUrl, + asyncStorage: TestAsyncStorage(), + httpClient: httpClient, + autoRefreshToken: false, + ); + + final data = getSessionData(DateTime.now().add(Duration(hours: 1))); + await client.setInitialSession(data.sessionString); + + final session = await client.getSession(); + expect(session, isNotNull); + expect(session!.accessToken, data.accessToken); + expect(httpClient.requestCount, 0); + }, + ); + + test('refreshes an expired session on demand', () async { + final httpClient = RefreshTokenTrackingHttpClient(); + final client = GoTrueClient( + url: gotrueUrl, + asyncStorage: TestAsyncStorage(), + httpClient: httpClient, + autoRefreshToken: false, + ); + + await client.setInitialSession(createExpiredSessionForUser1()); + expect(client.currentSession?.isExpired, isTrue); + + final session = await client.getSession(); + expect(session, isNotNull); + expect(session!.isExpired, isFalse); + expect(httpClient.requestCount, 1); + }); + + test('de-duplicates concurrent on-demand refreshes', () async { + final httpClient = RefreshTokenTrackingHttpClient( + responseDelay: Duration(milliseconds: 50), + ); + final client = GoTrueClient( + url: gotrueUrl, + asyncStorage: TestAsyncStorage(), + httpClient: httpClient, + autoRefreshToken: false, + ); + + await client.setInitialSession(createExpiredSessionForUser1()); + + final sessions = await Future.wait([ + client.getSession(), + client.getSession(), + ]); + + expect(sessions[0]?.accessToken, isNotNull); + expect(sessions[0]?.accessToken, sessions[1]?.accessToken); + expect( + httpClient.requestCount, + 1, + reason: 'Concurrent getSession calls should share a single refresh', + ); + }); + + test('throws when an expired session cannot be refreshed', () async { + final client = GoTrueClient( + url: gotrueUrl, + asyncStorage: TestAsyncStorage(), + httpClient: InvalidRefreshTokenHttpClient(), + ); + + final subscription = client.onAuthStateChange.listen( + (_) {}, + onError: (_) {}, + ); + + await client.setInitialSession(createExpiredSessionForUser1()); + + await expectLater( + client.getSession(), + throwsA(isA()), + ); + expect(client.currentSession, isNull); + + await subscription.cancel(); + }); + + test( + 'returns the still-valid session when a refresh fails but the access ' + 'token has not actually expired', + () async { + final httpClient = RetryableFailureHttpClient(); + final client = GoTrueClient( + url: gotrueUrl, + asyncStorage: TestAsyncStorage(), + httpClient: httpClient, + autoRefreshToken: false, + ); + + final subscription = client.onAuthStateChange.listen( + (_) {}, + onError: (_) {}, + ); + + // Expired by the margin, but the access token is still valid for + // another 20 seconds. + final data = getSessionData(DateTime.now().add(Duration(seconds: 20))); + await client.setInitialSession(data.sessionString); + expect(client.currentSession?.isExpired, isTrue); + + final session = await client.getSession(); + expect(session, isNotNull); + expect(session!.accessToken, data.accessToken); + expect(httpClient.requestCount, greaterThan(0)); + + await subscription.cancel(); + }, + timeout: Timeout(Duration(seconds: 30)), + ); + }); +} diff --git a/packages/gotrue/test/mfa_challenge_mock_test.dart b/packages/gotrue/test/mfa_challenge_mock_test.dart new file mode 100644 index 000000000..ee340051d --- /dev/null +++ b/packages/gotrue/test/mfa_challenge_mock_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +import 'package:gotrue/gotrue.dart'; +import 'package:http/http.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +/// Captures the body sent to the MFA challenge endpoint and returns a valid +/// challenge response. +class MfaChallengeMockClient extends BaseClient { + Map? lastChallengeBody; + + @override + Future send(BaseRequest request) async { + if (request is Request && request.url.path.endsWith('/challenge')) { + try { + lastChallengeBody = json.decode(request.body) as Map; + } catch (_) { + // Ignore non-JSON bodies. + } + } + + return StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'id': 'mock-challenge-id', + 'type': 'phone', + 'expires_at': 9999999999, + }), + ), + ), + 200, + request: request, + ); + } +} + +void main() { + late GoTrueClient client; + late MfaChallengeMockClient mockClient; + + setUp(() { + mockClient = MfaChallengeMockClient(); + client = GoTrueClient( + url: 'https://example.com', + httpClient: mockClient, + asyncStorage: TestAsyncStorage(), + ); + }); + + test('challenge() omits the channel by default', () async { + await client.mfa.challenge(factorId: 'factor-id'); + + expect(mockClient.lastChallengeBody?.containsKey('channel'), isFalse); + }); + + test('challenge() forwards the whatsapp channel', () async { + await client.mfa.challenge( + factorId: 'factor-id', + channel: OtpChannel.whatsapp, + ); + + expect(mockClient.lastChallengeBody?['channel'], 'whatsapp'); + }); + + test('challenge() forwards the sms channel', () async { + await client.mfa.challenge( + factorId: 'factor-id', + channel: OtpChannel.sms, + ); + + expect(mockClient.lastChallengeBody?['channel'], 'sms'); + }); +} diff --git a/packages/gotrue/test/mocks/otp_mock_client.dart b/packages/gotrue/test/mocks/otp_mock_client.dart index cb8cffa21..4af1d3abb 100644 --- a/packages/gotrue/test/mocks/otp_mock_client.dart +++ b/packages/gotrue/test/mocks/otp_mock_client.dart @@ -11,6 +11,7 @@ class OtpMockClient extends BaseClient { final String refreshToken; Map? lastResendBody; + Map? lastUpdateUserBody; OtpMockClient({ this.phoneNumber = '+11234567890', @@ -69,6 +70,11 @@ class OtpMockClient extends BaseClient { return _handleResend(requestBody); } + // Simulate updating the current user + if (url.contains('/user') && method == 'PUT') { + return _handleUpdateUser(requestBody); + } + // Default response for unhandled requests return StreamedResponse( Stream.value( @@ -312,6 +318,37 @@ class OtpMockClient extends BaseClient { request: null, ); } + + StreamedResponse _handleUpdateUser(Map? requestBody) { + lastUpdateUserBody = requestBody; + final now = DateTime.now().toIso8601String(); + + return StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'id': userId, + 'aud': 'authenticated', + 'role': 'authenticated', + 'email': requestBody?['email'] ?? email, + 'phone': requestBody?['phone'] ?? phoneNumber, + 'confirmed_at': now, + 'last_sign_in_at': now, + 'created_at': now, + 'updated_at': now, + 'app_metadata': { + 'provider': 'email', + 'providers': ['email'], + }, + 'user_metadata': {}, + 'identities': [], + }), + ), + ), + 200, + request: null, + ); + } } /// A mock HTTP client that captures the channel parameter for testing diff --git a/packages/gotrue/test/mocks/passkey_mock_client.dart b/packages/gotrue/test/mocks/passkey_mock_client.dart index d7ecfdd16..a0721adc1 100644 --- a/packages/gotrue/test/mocks/passkey_mock_client.dart +++ b/packages/gotrue/test/mocks/passkey_mock_client.dart @@ -13,6 +13,10 @@ class PasskeyMockClient extends BaseClient { Map? lastRequestBody; Map? lastHeaders; + /// When true, the registration options omit `user.name` and + /// `user.displayName`, mimicking a user without an email or phone. + bool omitUserName = false; + @override Future send(BaseRequest request) async { lastMethod = request.method; @@ -117,8 +121,8 @@ class PasskeyMockClient extends BaseClient { 'rp': {'id': 'example.com', 'name': 'Example'}, 'user': { 'id': 'YjEzODk4YmItM2I4NS00ZDgzLWE0NDctODQxZGMzMjMyZWEx', - 'name': 'user@example.com', - 'displayName': 'user@example.com', + if (!omitUserName) 'name': 'user@example.com', + if (!omitUserName) 'displayName': 'user@example.com', }, 'challenge': 'cmFuZG9tLWNoYWxsZW5nZQ', 'pubKeyCredParams': [ diff --git a/packages/gotrue/test/mocks/web3_mock_client.dart b/packages/gotrue/test/mocks/web3_mock_client.dart new file mode 100644 index 000000000..af3247688 --- /dev/null +++ b/packages/gotrue/test/mocks/web3_mock_client.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; + +/// A mock HTTP client that simulates the `POST /token?grant_type=web3` +/// endpoint used for Web3 wallet authentication. +class Web3MockClient extends BaseClient { + final String userId; + final String accessToken; + final String refreshToken; + + Uri? lastUri; + Map? lastRequestBody; + Map? lastHeaders; + + Web3MockClient({ + this.userId = 'mock-user-id-web3', + this.accessToken = 'mock-access-token', + this.refreshToken = 'mock-refresh-token', + }); + + @override + Future send(BaseRequest request) async { + lastUri = request.url; + lastHeaders = request.headers; + + if (request is Request) { + try { + lastRequestBody = json.decode(request.body) as Map; + } catch (_) { + // Ignore non-JSON bodies. + } + } + + final now = DateTime.now().toIso8601String(); + + return StreamedResponse( + Stream.value( + utf8.encode( + jsonEncode({ + 'access_token': accessToken, + 'token_type': 'bearer', + 'expires_in': 3600, + 'refresh_token': refreshToken, + 'user': { + 'id': userId, + 'aud': 'authenticated', + 'role': 'authenticated', + 'email': null, + 'phone': null, + 'confirmed_at': now, + 'last_sign_in_at': now, + 'created_at': now, + 'updated_at': now, + 'app_metadata': { + 'provider': 'web3', + 'providers': ['web3'], + }, + 'user_metadata': { + 'custom_claims': { + 'address': lastRequestBody?['message'], + }, + }, + 'identities': [ + { + 'id': userId, + 'user_id': userId, + 'identity_data': {'sub': userId}, + 'provider': 'web3', + 'last_sign_in_at': now, + 'created_at': now, + 'updated_at': now, + }, + ], + }, + }), + ), + ), + 200, + request: request, + ); + } +} + +/// A mock client that always fails the Web3 token exchange with the given +/// [statusCode] and [errorResponse], simulating an expired nonce or a bad +/// signature. +class Web3ErrorMockClient extends BaseClient { + final int statusCode; + final Map errorResponse; + + Web3ErrorMockClient({ + this.statusCode = 403, + this.errorResponse = const { + 'code': 'bad_json', + 'message': 'Signature verification failed', + }, + }); + + @override + Future send(BaseRequest request) async { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode(errorResponse))), + statusCode, + request: request, + ); + } +} + +/// A mock client that fails every request with a transport error, simulating a +/// dropped connection. +class Web3NetworkErrorMockClient extends BaseClient { + @override + Future send(BaseRequest request) async { + throw ClientException('Connection failed', request.url); + } +} diff --git a/packages/gotrue/test/otp_mock_test.dart b/packages/gotrue/test/otp_mock_test.dart index d368ec839..1b6c722e9 100644 --- a/packages/gotrue/test/otp_mock_test.dart +++ b/packages/gotrue/test/otp_mock_test.dart @@ -64,7 +64,7 @@ void main() { test('signInWithOtp() without email or phone should throw', () async { await expectLater( - () => client.signInWithOtp(), + client.signInWithOtp(), throwsA( isA().having( (e) => e.message, @@ -159,7 +159,7 @@ void main() { () async { // Recovery type with tokenHash should not accept email/phone await expectLater( - () => client.verifyOTP( + client.verifyOTP( email: testEmail, tokenHash: 'mock-token-hash', type: OtpType.recovery, @@ -179,7 +179,7 @@ void main() { test('verifyOTP() without token should throw', () async { await expectLater( - () => client.verifyOTP( + client.verifyOTP( email: testEmail, type: OtpType.email, ), @@ -232,21 +232,18 @@ void main() { expect(client.currentUser?.phone, testPhone); }); - test('reauthenticate() works correctly', () async { - // First sign in to set the session + test('reauthenticate() completes successfully', () async { await client.signInWithPassword( phone: testPhone, password: testPassword, ); - // Then reauthenticate - await client.reauthenticate(); - // This test passes if no exceptions are thrown + expect(client.reauthenticate(), completes); }); test('reauthenticate() throws when no session', () async { await expectLater( - () => client.reauthenticate(), + client.reauthenticate(), throwsA(isA()), ); }); @@ -336,9 +333,67 @@ void main() { expect(mockClient.lastResendBody?['code_challenge_method'], isNull); }); + test( + 'updateUser() with email in PKCE flow includes code challenge', + () async { + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.sms, + ); + + await client.updateUser(UserAttributes(email: testEmail)); + + expect(mockClient.lastUpdateUserBody?['code_challenge'], isNotNull); + expect(mockClient.lastUpdateUserBody?['code_challenge_method'], 's256'); + }, + ); + + test('updateUser() without email omits code challenge', () async { + await client.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.sms, + ); + + await client.updateUser(UserAttributes(data: {'name': 'Test User'})); + + expect( + mockClient.lastUpdateUserBody?.containsKey('code_challenge'), + isFalse, + ); + expect( + mockClient.lastUpdateUserBody?.containsKey('code_challenge_method'), + isFalse, + ); + }); + + test( + 'updateUser() with email in implicit flow omits code challenge', + () async { + final implicitClient = GoTrueClient( + url: 'https://example.com', + httpClient: mockClient, + asyncStorage: asyncStorage, + flowType: AuthFlowType.implicit, + ); + + await implicitClient.verifyOTP( + phone: testPhone, + token: '123456', + type: OtpType.sms, + ); + + await implicitClient.updateUser(UserAttributes(email: testEmail)); + + expect(mockClient.lastUpdateUserBody?['code_challenge'], isNull); + expect(mockClient.lastUpdateUserBody?['code_challenge_method'], isNull); + }, + ); + test('resend() with wrong type for phone throws', () async { await expectLater( - () => client.resend( + client.resend( phone: testPhone, type: OtpType.signup, // This should be sms or phoneChange for phone ), @@ -348,7 +403,7 @@ void main() { test('resend() with wrong type for email throws', () async { await expectLater( - () => client.resend( + client.resend( email: testEmail, type: OtpType.sms, // This should be signup or emailChange for email ), @@ -356,17 +411,14 @@ void main() { ); }); - test('signInWithOtp() with different channel types', () async { - // Test WhatsApp channel - await client.signInWithOtp( - phone: testPhone, - channel: OtpChannel.whatsapp, + test('signInWithOtp() completes for different channel types', () async { + expect( + client.signInWithOtp(phone: testPhone, channel: OtpChannel.whatsapp), + completes, ); - - // Test SMS channel (default) - await client.signInWithOtp( - phone: testPhone, - channel: OtpChannel.sms, + expect( + client.signInWithOtp(phone: testPhone, channel: OtpChannel.sms), + completes, ); }); }); @@ -380,7 +432,7 @@ void main() { ); await expectLater( - () => client.verifyOTP( + client.verifyOTP( phone: testPhone, token: '123456', type: OtpType.sms, @@ -405,7 +457,7 @@ void main() { ); await expectLater( - () => client.signInWithPassword( + client.signInWithPassword( phone: testPhone, password: 'wrong-password', ), @@ -428,7 +480,7 @@ void main() { ); await expectLater( - () => client.signUp( + client.signUp( phone: testPhone, password: testPassword, ), @@ -442,16 +494,18 @@ void main() { ); }); - test('signInWithOtp() with empty response', () async { - final client = GoTrueClient( - url: 'https://example.com', - httpClient: EmptyResponseClient(), - asyncStorage: TestAsyncStorage(), - ); + test( + 'signInWithOtp() with empty response completes successfully', + () async { + final client = GoTrueClient( + url: 'https://example.com', + httpClient: EmptyResponseClient(), + asyncStorage: TestAsyncStorage(), + ); - // Should not throw an exception - await client.signInWithOtp(phone: testPhone); - }); + expect(client.signInWithOtp(phone: testPhone), completes); + }, + ); test( 'verifyOTP() with neither email nor phone throws assertion error', @@ -463,7 +517,7 @@ void main() { ); await expectLater( - () => client.verifyOTP( + client.verifyOTP( token: '123456', type: OtpType.sms, ), @@ -480,7 +534,7 @@ void main() { ); await expectLater( - () => client.verifyOTP( + client.verifyOTP( phone: testPhone, token: '123456', type: OtpType.sms, @@ -503,7 +557,7 @@ void main() { ); await expectLater( - () => client.resend( + client.resend( email: testEmail, phone: testPhone, type: OtpType.sms, @@ -520,7 +574,7 @@ void main() { ); await expectLater( - () => client.signUp(password: testPassword), + client.signUp(password: testPassword), throwsA(isA()), ); }); @@ -535,7 +589,7 @@ void main() { ); await expectLater( - () => client.signInWithPassword(password: testPassword), + client.signInWithPassword(password: testPassword), throwsA( isA().having( (e) => e.message, @@ -555,7 +609,7 @@ void main() { ); await expectLater( - () => client.signInWithOtp(phone: testPhone), + client.signInWithOtp(phone: testPhone), throwsA( isA().having((e) => e.statusCode, 'statusCode', '500'), ), diff --git a/packages/gotrue/test/passkey_test.dart b/packages/gotrue/test/passkey_test.dart index bc81e0103..420d7f681 100644 --- a/packages/gotrue/test/passkey_test.dart +++ b/packages/gotrue/test/passkey_test.dart @@ -87,7 +87,10 @@ void main() { mockClient.lastRequestBody?['challenge_id'], PasskeyMockClient.challengeId, ); - expect(mockClient.lastRequestBody?['credential'], isA()); + expect( + mockClient.lastRequestBody?['credential'], + isA>(), + ); expect(response.session, isNotNull); expect(response.session?.accessToken, 'mock-access-token'); expect(response.user?.id, PasskeyMockClient.userId); @@ -115,6 +118,46 @@ void main() { ); }); + test('startRegistration keeps the server provided user.name', () async { + await signInWithPasskey(); + + final response = await client.passkey.startRegistration( + friendlyName: 'My MacBook', + ); + + expect(response.options['user']['name'], 'user@example.com'); + expect(response.options['user']['displayName'], 'user@example.com'); + }); + + test( + 'startRegistration uses friendlyName when the server omits user.name', + () async { + mockClient.omitUserName = true; + await signInWithPasskey(); + + final response = await client.passkey.startRegistration( + friendlyName: 'My MacBook', + ); + + expect(response.options['user']['name'], 'My MacBook'); + expect(response.options['user']['displayName'], 'My MacBook'); + }, + ); + + test( + 'startRegistration falls back to a default when the server omits ' + 'user.name and no friendlyName is given', + () async { + mockClient.omitUserName = true; + await signInWithPasskey(); + + final response = await client.passkey.startRegistration(); + + expect(response.options['user']['name'], 'Passkey'); + expect(response.options['user']['displayName'], 'Passkey'); + }, + ); + test('verifyRegistration returns the new passkey', () async { await signInWithPasskey(); @@ -292,7 +335,7 @@ void main() { addTearDown(disabledClient.dispose); await expectLater( - () => disabledClient.passkey.startAuthentication(), + disabledClient.passkey.startAuthentication(), throwsA( isA() .having((e) => e.code, 'code', 'passkey_disabled') diff --git a/packages/gotrue/test/provider_test.dart b/packages/gotrue/test/provider_test.dart index 3d8736709..953b5502a 100644 --- a/packages/gotrue/test/provider_test.dart +++ b/packages/gotrue/test/provider_test.dart @@ -28,23 +28,23 @@ void main() { }); group('Provider sign in', () { test('signIn() with Provider', () async { - final res = await client.getOAuthSignInUrl( + final response = await client.getOAuthSignInUrl( provider: OAuthProvider.google, ); - final url = res.url; - final provider = res.provider; + final url = response.url; + final provider = response.provider; expect(url, startsWith('$gotrueUrl/authorize?provider=google')); expect(provider, OAuthProvider.google); }); test('signIn() with Provider and options', () async { - final res = await client.getOAuthSignInUrl( + final response = await client.getOAuthSignInUrl( provider: OAuthProvider.github, redirectTo: 'redirectToURL', scopes: 'repo', ); - final url = res.url; - final provider = res.provider; + final url = response.url; + final provider = response.provider; expect( url, startsWith( @@ -53,11 +53,37 @@ void main() { ); expect(provider, OAuthProvider.github); }); + + test('signIn() with custom OIDC provider', () async { + final response = await client.getOAuthSignInUrl( + provider: OAuthProvider('custom:my-oidc-provider'), + ); + expect( + response.url, + startsWith( + '$gotrueUrl/authorize?provider=custom%3Amy-oidc-provider', + ), + ); + expect(response.provider, OAuthProvider('custom:my-oidc-provider')); + expect(response.provider.name, 'custom:my-oidc-provider'); + }); + + test('signIn() with custom OIDC provider and options', () async { + final response = await client.getOAuthSignInUrl( + provider: OAuthProvider('custom:my-oidc-provider'), + redirectTo: 'https://localhost:9000/callback', + scopes: 'openid profile email', + ); + expect(response.url, contains('provider=custom%3Amy-oidc-provider')); + expect(response.url, contains('redirect_to=')); + expect(response.url, contains('scopes=')); + expect(response.provider.name, 'custom:my-oidc-provider'); + }); }); group('getSessionFromUrl()', () { setUp(() async { - final res = await http.post( + final response = await http.post( Uri.parse( 'http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data', ), @@ -67,7 +93,7 @@ void main() { 'Authorization': 'Bearer ${getServiceRoleToken(env)}', }, ); - if (res.body.isNotEmpty) throw res.body; + if (response.body.isNotEmpty) throw response.body; await client.signInWithPassword(email: email1, password: password); session = client.currentSession!; @@ -83,13 +109,13 @@ void main() { final url = 'http://my-callback-url.com/welcome#access_token=$accessToken&expires_in=$expiresIn&refresh_token=$refreshToken&token_type=$tokenType&provider_token=$providerToken&provider_refresh_token=$providerRefreshToken'; - final res = await client.getSessionFromUrl(Uri.parse(url)); - expect(res.session.accessToken, accessToken); - expect(res.session.expiresIn, expiresIn); - expect(res.session.refreshToken, refreshToken); - expect(res.session.tokenType, tokenType); - expect(res.session.providerToken, providerToken); - expect(res.session.providerRefreshToken, providerRefreshToken); + final response = await client.getSessionFromUrl(Uri.parse(url)); + expect(response.session.accessToken, accessToken); + expect(response.session.expiresIn, expiresIn); + expect(response.session.refreshToken, refreshToken); + expect(response.session.tokenType, tokenType); + expect(response.session.providerToken, providerToken); + expect(response.session.providerRefreshToken, providerRefreshToken); }); test('parse provider callback url with fragment and query', () async { @@ -102,13 +128,13 @@ void main() { final url = 'http://my-callback-url.com?page=welcome&foo=bar#access_token=$accessToken&expires_in=$expiresIn&refresh_token=$refreshToken&token_type=$tokenType&provider_token=$providerToken&provider_refresh_token=$providerRefreshToken'; - final res = await client.getSessionFromUrl(Uri.parse(url)); - expect(res.session.accessToken, accessToken); - expect(res.session.expiresIn, expiresIn); - expect(res.session.refreshToken, refreshToken); - expect(res.session.tokenType, tokenType); - expect(res.session.providerToken, providerToken); - expect(res.session.providerRefreshToken, providerRefreshToken); + final response = await client.getSessionFromUrl(Uri.parse(url)); + expect(response.session.accessToken, accessToken); + expect(response.session.expiresIn, expiresIn); + expect(response.session.refreshToken, refreshToken); + expect(response.session.tokenType, tokenType); + expect(response.session.providerToken, providerToken); + expect(response.session.providerRefreshToken, providerRefreshToken); }); test('parse provider callback url with missing param error', () async { @@ -130,15 +156,19 @@ void main() { }); test('parse provider callback url with error', () async { - const errorDesc = 'my_error_description'; + const errorDescription = 'my_error_description'; await expectLater( () async { const url = - 'http://my-callback-url.com?page=welcome&foo=bar#error_description=$errorDesc'; + 'http://my-callback-url.com?page=welcome&foo=bar#error_description=$errorDescription'; await client.getSessionFromUrl(Uri.parse(url)); }, throwsA( - isA().having((e) => e.message, 'message', errorDesc), + isA().having( + (e) => e.message, + 'message', + errorDescription, + ), ), ); }); diff --git a/packages/gotrue/test/src/constants_test.dart b/packages/gotrue/test/src/constants_test.dart index 2a734c843..1007d44f7 100644 --- a/packages/gotrue/test/src/constants_test.dart +++ b/packages/gotrue/test/src/constants_test.dart @@ -1,8 +1,8 @@ // ignore_for_file: deprecated_member_use_from_same_package import 'package:gotrue/src/constants.dart'; -import 'package:gotrue/src/types/auth_response.dart'; import 'package:gotrue/src/version.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'package:test/test.dart'; void main() { @@ -60,7 +60,7 @@ void main() { group('AuthChangeEvent', () { test('has correct enum values', () { - expect(AuthChangeEvent.values.length, equals(8)); + expect(AuthChangeEvent.values, hasLength(8)); expect(AuthChangeEvent.values, contains(AuthChangeEvent.initialSession)); expect( AuthChangeEvent.values, @@ -161,7 +161,7 @@ void main() { group('GenerateLinkType', () { test('has correct enum values', () { - expect(GenerateLinkType.values.length, equals(7)); + expect(GenerateLinkType.values, hasLength(7)); expect(GenerateLinkType.values, contains(GenerateLinkType.signup)); expect(GenerateLinkType.values, contains(GenerateLinkType.invite)); expect(GenerateLinkType.values, contains(GenerateLinkType.magiclink)); @@ -244,7 +244,7 @@ void main() { group('OtpType', () { test('has correct enum values', () { - expect(OtpType.values.length, equals(8)); + expect(OtpType.values, hasLength(8)); expect(OtpType.values, contains(OtpType.sms)); expect(OtpType.values, contains(OtpType.phoneChange)); expect(OtpType.values, contains(OtpType.signup)); @@ -258,7 +258,7 @@ void main() { group('OtpChannel', () { test('has correct enum values', () { - expect(OtpChannel.values.length, equals(2)); + expect(OtpChannel.values, hasLength(2)); expect(OtpChannel.values, contains(OtpChannel.sms)); expect(OtpChannel.values, contains(OtpChannel.whatsapp)); }); @@ -273,7 +273,7 @@ void main() { group('SignOutScope', () { test('has correct enum values', () { - expect(SignOutScope.values.length, equals(3)); + expect(SignOutScope.values, hasLength(3)); expect(SignOutScope.values, contains(SignOutScope.global)); expect(SignOutScope.values, contains(SignOutScope.local)); expect(SignOutScope.values, contains(SignOutScope.others)); diff --git a/packages/gotrue/test/src/gotrue_admin_custom_providers_api_test.dart b/packages/gotrue/test/src/gotrue_admin_custom_providers_api_test.dart index 115490e06..cbfcf017d 100644 --- a/packages/gotrue/test/src/gotrue_admin_custom_providers_api_test.dart +++ b/packages/gotrue/test/src/gotrue_admin_custom_providers_api_test.dart @@ -22,7 +22,7 @@ void main() { return 'custom:flutter-test-$timestamp'; } - CreateCustomProviderParams oauth2Params( + CreateCustomProviderParams oauth2Parameters( String identifier, { List? customClaimsAllowlist, }) { @@ -40,7 +40,7 @@ void main() { } setUp(() async { - final res = await http.post( + final response = await http.post( Uri.parse('http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data'), headers: { 'x-forwarded-for': '127.0.0.1', @@ -48,7 +48,7 @@ void main() { 'Authorization': 'Bearer $serviceRoleToken', }, ); - if (res.body.isNotEmpty) throw res.body; + if (response.body.isNotEmpty) throw response.body; client = GoTrueClient( url: gotrueUrl, @@ -65,7 +65,7 @@ void main() { final identifier = newIdentifier(); try { final provider = await client.admin.customProviders.createProvider( - oauth2Params(identifier), + oauth2Parameters(identifier), ); expect(provider.identifier, identifier); @@ -83,7 +83,7 @@ void main() { try { // Exercises sending the custom_claims_allowlist field over the wire. final provider = await client.admin.customProviders.createProvider( - oauth2Params( + oauth2Parameters( identifier, customClaimsAllowlist: ['groups', 'org_id', 'mail'], ), @@ -99,7 +99,7 @@ void main() { final identifier = newIdentifier(); try { await client.admin.customProviders.createProvider( - oauth2Params(identifier), + oauth2Parameters(identifier), ); final providers = await client.admin.customProviders.listProviders(); @@ -116,7 +116,7 @@ void main() { final identifier = newIdentifier(); try { await client.admin.customProviders.createProvider( - oauth2Params(identifier), + oauth2Parameters(identifier), ); final providers = await client.admin.customProviders.listProviders( @@ -141,7 +141,7 @@ void main() { final identifier = newIdentifier(); try { await client.admin.customProviders.createProvider( - oauth2Params(identifier), + oauth2Parameters(identifier), ); final provider = await client.admin.customProviders.getProvider( @@ -158,7 +158,7 @@ void main() { final identifier = newIdentifier(); try { await client.admin.customProviders.createProvider( - oauth2Params(identifier), + oauth2Parameters(identifier), ); final updated = await client.admin.customProviders.updateProvider( @@ -183,7 +183,7 @@ void main() { test('delete custom provider', () async { final identifier = newIdentifier(); await client.admin.customProviders.createProvider( - oauth2Params(identifier), + oauth2Parameters(identifier), ); await client.admin.customProviders.deleteProvider(identifier); diff --git a/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart b/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart index 4911b8f6c..e934c0218 100644 --- a/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart @@ -16,7 +16,7 @@ void main() { late GoTrueClient client; setUp(() async { - final res = await http.post( + final response = await http.post( Uri.parse('http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data'), headers: { 'x-forwarded-for': '127.0.0.1', @@ -24,7 +24,7 @@ void main() { 'Authorization': 'Bearer $serviceRoleToken', }, ); - if (res.body.isNotEmpty) throw res.body; + if (response.body.isNotEmpty) throw response.body; client = GoTrueClient( url: gotrueUrl, @@ -37,26 +37,26 @@ void main() { }); test('list factors', () async { - final res = await client.admin.mfa.listFactors(userId: userId2); - expect(res.factors.length, 1); - final factor = res.factors.first; + final response = await client.admin.mfa.listFactors(userId: userId2); + expect(response.factors, hasLength(1)); + final factor = response.factors.first; expect( factor.createdAt.difference(DateTime.now()) < Duration(seconds: 2), - true, + isTrue, ); expect( factor.updatedAt.difference(DateTime.now()) < Duration(seconds: 2), - true, + isTrue, ); expect(factor.id, factorId2); }); test('delete factor', () async { - final res = await client.admin.mfa.deleteFactor( + final response = await client.admin.mfa.deleteFactor( userId: userId2, factorId: factorId2, ); - expect(res.id, factorId2); + expect(response.id, factorId2); }); } diff --git a/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart b/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart index 044dfd6c8..b3d00ae94 100644 --- a/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart +++ b/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart @@ -16,7 +16,7 @@ void main() { late GoTrueClient client; setUp(() async { - final res = await http.post( + final response = await http.post( Uri.parse('http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data'), headers: { 'x-forwarded-for': '127.0.0.1', @@ -24,7 +24,7 @@ void main() { 'Authorization': 'Bearer $serviceRoleToken', }, ); - if (res.body.isNotEmpty) throw res.body; + if (response.body.isNotEmpty) throw response.body; client = GoTrueClient( url: gotrueUrl, @@ -38,104 +38,108 @@ void main() { group('OAuth client management', () { test('create OAuth client', () async { - final params = CreateOAuthClientParams( + final parameters = CreateOAuthClientParams( clientName: 'Test OAuth Client', redirectUris: ['https://example.com/callback'], clientUri: 'https://example.com', scope: 'openid profile email', ); - final res = await client.admin.oauth.createClient(params); - expect(res.client, isNotNull); - expect(res.client?.clientName, 'Test OAuth Client'); - expect(res.client?.redirectUris, ['https://example.com/callback']); - expect(res.client?.clientSecret, isNotNull); - expect(res.client?.clientId, isNotNull); + final response = await client.admin.oauth.createClient(parameters); + expect(response.client, isNotNull); + expect(response.client?.clientName, 'Test OAuth Client'); + expect(response.client?.redirectUris, ['https://example.com/callback']); + expect(response.client?.clientSecret, isNotNull); + expect(response.client?.clientId, isNotNull); }); test('list OAuth clients', () async { // First create a client - final params = CreateOAuthClientParams( + final parameters = CreateOAuthClientParams( clientName: 'Test OAuth Client for List', redirectUris: ['https://example.com/callback'], ); - await client.admin.oauth.createClient(params); + await client.admin.oauth.createClient(parameters); - final res = await client.admin.oauth.listClients(); - expect(res.clients, isNotEmpty); + final response = await client.admin.oauth.listClients(); + expect(response.clients, isNotEmpty); // aud is optional }); test('get OAuth client by ID', () async { // First create a client - final params = CreateOAuthClientParams( + final parameters = CreateOAuthClientParams( clientName: 'Test OAuth Client for Get', redirectUris: ['https://example.com/callback'], ); - final createRes = await client.admin.oauth.createClient(params); - final clientId = createRes.client!.clientId; + final createResponse = await client.admin.oauth.createClient(parameters); + final clientId = createResponse.client!.clientId; - final res = await client.admin.oauth.getClient(clientId); - expect(res.client, isNotNull); - expect(res.client?.clientId, clientId); - expect(res.client?.clientName, 'Test OAuth Client for Get'); + final response = await client.admin.oauth.getClient(clientId); + expect(response.client, isNotNull); + expect(response.client?.clientId, clientId); + expect(response.client?.clientName, 'Test OAuth Client for Get'); }); test('update OAuth client', () async { // First create a client - final createParams = CreateOAuthClientParams( + final createParameters = CreateOAuthClientParams( clientName: 'Test OAuth Client for Update', redirectUris: ['https://example.com/callback'], ); - final createRes = await client.admin.oauth.createClient(createParams); - final clientId = createRes.client!.clientId; + final createResponse = await client.admin.oauth.createClient( + createParameters, + ); + final clientId = createResponse.client!.clientId; // Update the client - final updateParams = UpdateOAuthClientParams( + final updateParameters = UpdateOAuthClientParams( clientName: 'Updated OAuth Client Name', ); - final updateRes = await client.admin.oauth.updateClient( + final updateResponse = await client.admin.oauth.updateClient( clientId, - updateParams, + updateParameters, ); - expect(updateRes.client, isNotNull); - expect(updateRes.client?.clientId, clientId); - expect(updateRes.client?.clientName, 'Updated OAuth Client Name'); + expect(updateResponse.client, isNotNull); + expect(updateResponse.client?.clientId, clientId); + expect(updateResponse.client?.clientName, 'Updated OAuth Client Name'); // Verify the update by getting the client again - final getRes = await client.admin.oauth.getClient(clientId); - expect(getRes.client?.clientName, 'Updated OAuth Client Name'); + final getResponse = await client.admin.oauth.getClient(clientId); + expect(getResponse.client?.clientName, 'Updated OAuth Client Name'); }); test('regenerate OAuth client secret', () async { // First create a client - final params = CreateOAuthClientParams( + final parameters = CreateOAuthClientParams( clientName: 'Test OAuth Client for Regenerate', redirectUris: ['https://example.com/callback'], ); - final createRes = await client.admin.oauth.createClient(params); - final clientId = createRes.client!.clientId; - final originalSecret = createRes.client!.clientSecret; - - final res = await client.admin.oauth.regenerateClientSecret(clientId); - expect(res.client, isNotNull); - expect(res.client?.clientSecret, isNotNull); - expect(res.client?.clientSecret, isNot(originalSecret)); + final createResponse = await client.admin.oauth.createClient(parameters); + final clientId = createResponse.client!.clientId; + final originalSecret = createResponse.client!.clientSecret; + + final response = await client.admin.oauth.regenerateClientSecret( + clientId, + ); + expect(response.client, isNotNull); + expect(response.client?.clientSecret, isNotNull); + expect(response.client?.clientSecret, isNot(originalSecret)); }); test('delete OAuth client', () async { // First create a client - final params = CreateOAuthClientParams( + final parameters = CreateOAuthClientParams( clientName: 'Test OAuth Client for Delete', redirectUris: ['https://example.com/callback'], ); - final createRes = await client.admin.oauth.createClient(params); - final clientId = createRes.client!.clientId; + final createResponse = await client.admin.oauth.createClient(parameters); + final clientId = createResponse.client!.clientId; // Delete returns 204 No Content with empty body - final res = await client.admin.oauth.deleteClient(clientId); + final response = await client.admin.oauth.deleteClient(clientId); // The server returns 204 with no body, so client will be null - expect(res.client, isNull); + expect(response.client, isNull); }); }); @@ -162,9 +166,9 @@ void main() { }); test('updateClient() validates ids', () { - final params = UpdateOAuthClientParams(clientName: 'Updated Name'); + final parameters = UpdateOAuthClientParams(clientName: 'Updated Name'); expect( - () => client.admin.oauth.updateClient('invalid-id', params), + () => client.admin.oauth.updateClient('invalid-id', parameters), throwsA(isA()), ); }); diff --git a/packages/gotrue/test/src/gotrue_mfa_api_test.dart b/packages/gotrue/test/src/gotrue_mfa_api_test.dart index 77f98b1da..fc84bfda5 100644 --- a/packages/gotrue/test/src/gotrue_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_mfa_api_test.dart @@ -42,7 +42,7 @@ void main() { late GoTrueClient client; setUp(() async { - final res = await http.post( + final response = await http.post( Uri.parse( 'http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data', ), @@ -52,7 +52,7 @@ void main() { 'Authorization': 'Bearer ${getServiceRoleToken(env)}', }, ); - if (res.body.isNotEmpty) throw res.body; + if (response.body.isNotEmpty) throw response.body; client = GoTrueClient( url: gotrueUrl, @@ -67,13 +67,13 @@ void main() { test('enroll totp', () async { await client.signInWithPassword(password: password, email: email1); - final res = await client.mfa.enroll( + final response = await client.mfa.enroll( issuer: 'MyFriend', friendlyName: 'MyFriendName', ); - final uri = Uri.parse(res.totp!.uri); + final uri = Uri.parse(response.totp!.uri); - expect(res.type, FactorType.totp); + expect(response.type, FactorType.totp); expect(uri.queryParameters['issuer'], 'MyFriend'); expect(uri.scheme, 'otpauth'); }); @@ -81,15 +81,15 @@ void main() { test('enroll phone', () async { await client.signInWithPassword(password: password, email: email1); - final res = await client.mfa.enroll( + final response = await client.mfa.enroll( factorType: FactorType.phone, phone: '+1234567890', friendlyName: 'MyPhone', ); - expect(res.type, FactorType.phone); - expect(res.phone?.phone, '+1234567890'); - expect(res.totp, isNull); + expect(response.type, FactorType.phone); + expect(response.phone?.phone, '+1234567890'); + expect(response.totp, isNull); }); test('enroll phone requires phone number', () async { @@ -107,43 +107,43 @@ void main() { test('challenge', () async { await client.signInWithPassword(password: password, email: email1); - final res = await client.mfa.challenge(factorId: factorId1); + final response = await client.mfa.challenge(factorId: factorId1); - expect(res.expiresAt.isAfter(DateTime.now()), isTrue); + expect(response.expiresAt.isAfter(DateTime.now()), isTrue); }); test('verify', () async { await client.signInWithPassword(password: password, email: email1); // Create a challenge first - final challengeRes = await client.mfa.challenge(factorId: factorId1); + final challengeResponse = await client.mfa.challenge(factorId: factorId1); - final res = await client.mfa.verify( + final response = await client.mfa.verify( factorId: factorId1, - challengeId: challengeRes.id, + challengeId: challengeResponse.id, code: getTOTP(), ); - expect(client.currentSession?.accessToken, res.accessToken); - expect(client.currentUser, res.user); - expect(client.currentSession?.refreshToken, res.refreshToken); - expect(client.currentSession?.expiresIn, res.expiresIn.inSeconds); + expect(client.currentSession?.accessToken, response.accessToken); + expect(client.currentUser, response.user); + expect(client.currentSession?.refreshToken, response.refreshToken); + expect(client.currentSession?.expiresIn, response.expiresIn.inSeconds); }); test('challenge and verify', () async { await client.signInWithPassword(password: password, email: email1); - expect(client.currentUser!.factors!.length, 1); + expect(client.currentUser!.factors!, hasLength(1)); expect( client.currentUser!.factors!.first.status, FactorStatus.unverified, ); - final res = await client.mfa.challengeAndVerify( + final response = await client.mfa.challengeAndVerify( factorId: factorId1, code: getTOTP(), ); - expect(client.currentUser, res.user); - expect(client.currentUser!.factors!.length, 1); + expect(client.currentUser, response.user); + expect(client.currentUser!.factors!, hasLength(1)); expect(client.currentUser!.factors!.first.id, factorId1); expect(client.currentUser!.factors!.first.status, FactorStatus.verified); }); @@ -153,29 +153,29 @@ void main() { await client.mfa.challengeAndVerify(factorId: factorId2, code: getTOTP()); - final res = await client.mfa.unenroll(factorId2); - expect(res.id, factorId2); + final response = await client.mfa.unenroll(factorId2); + expect(response.id, factorId2); }); test('list factors', () async { await client.signInWithPassword(password: password, email: email2); - final res = await client.mfa.listFactors(); + final response = await client.mfa.listFactors(); - expect(res.totp.length, 1); - expect(res.phone, isEmpty); - expect(res.all.length, 1); - expect(res.all.first.id, factorId2); - expect(res.all.first.status, FactorStatus.verified); + expect(response.totp, hasLength(1)); + expect(response.phone, isEmpty); + expect(response.all, hasLength(1)); + expect(response.all.first.id, factorId2); + expect(response.all.first.status, FactorStatus.verified); expect( - res.all.first.createdAt.difference(DateTime.now()) < + response.all.first.createdAt.difference(DateTime.now()) < Duration(seconds: 2), - true, + isTrue, ); expect( - res.all.first.updatedAt.difference(DateTime.now()) < + response.all.first.updatedAt.difference(DateTime.now()) < Duration(seconds: 2), - true, + isTrue, ); }); @@ -183,63 +183,65 @@ void main() { await client.signInWithPassword(password: password, email: email1); // First, enroll a phone factor - final enrollRes = await client.mfa.enroll( + final enrollResponse = await client.mfa.enroll( factorType: FactorType.phone, phone: '+1234567890', friendlyName: 'TestPhone', ); // Verify enrollment worked - expect(enrollRes.type, FactorType.phone); - expect(enrollRes.phone?.phone, '+1234567890'); + expect(enrollResponse.type, FactorType.phone); + expect(enrollResponse.phone?.phone, '+1234567890'); // Now list factors and check that phone factor appears - final listRes = await client.mfa.listFactors(); + final listResponse = await client.mfa.listFactors(); // Should have 1 phone factor (unverified) and 0 verified phone factors - expect(listRes.all.length, greaterThanOrEqualTo(1)); + expect(listResponse.all.length, greaterThanOrEqualTo(1)); // Find the phone factor we just enrolled - final phoneFactor = listRes.all.firstWhere( + final phoneFactor = listResponse.all.firstWhere( (factor) => factor.factorType == FactorType.phone, ); - expect(phoneFactor.id, enrollRes.id); + expect(phoneFactor.id, enrollResponse.id); expect(phoneFactor.factorType, FactorType.phone); expect(phoneFactor.friendlyName, 'TestPhone'); expect(phoneFactor.status, FactorStatus.unverified); // Verified phone factors should be empty since we haven't verified yet - expect(listRes.phone, isEmpty); + expect(listResponse.phone, isEmpty); // But the factor should appear in the all list - expect(listRes.all.any((f) => f.factorType == FactorType.phone), isTrue); + expect( + listResponse.all.any((f) => f.factorType == FactorType.phone), + isTrue, + ); }); test('aal1 for only password', () async { await client.signInWithPassword(password: password, email: email2); - final res = client.mfa.getAuthenticatorAssuranceLevel(); - expect(res.currentLevel, AuthenticatorAssuranceLevels.aal1); - expect(res.nextLevel, AuthenticatorAssuranceLevels.aal2); + final response = client.mfa.getAuthenticatorAssuranceLevel(); + expect(response.currentLevel, AuthenticatorAssuranceLevels.aal1); + expect(response.nextLevel, AuthenticatorAssuranceLevels.aal2); }); test('aal2 for password and totp', () async { await client.signInWithPassword(password: password, email: email2); await client.mfa.challengeAndVerify(factorId: factorId2, code: getTOTP()); - final res = client.mfa.getAuthenticatorAssuranceLevel(); - expect(res.currentLevel, AuthenticatorAssuranceLevels.aal2); - expect(res.nextLevel, AuthenticatorAssuranceLevels.aal2); - final passwordEntry = res.currentAuthenticationMethods.firstWhereOrNull( - (element) => element.method == AMRMethod.password, - ); - final totpEntry = res.currentAuthenticationMethods.firstWhereOrNull( + final response = client.mfa.getAuthenticatorAssuranceLevel(); + expect(response.currentLevel, AuthenticatorAssuranceLevels.aal2); + expect(response.nextLevel, AuthenticatorAssuranceLevels.aal2); + final passwordEntry = response.currentAuthenticationMethods + .firstWhereOrNull((element) => element.method == AMRMethod.password); + final totpEntry = response.currentAuthenticationMethods.firstWhereOrNull( (element) => element.method == AMRMethod.totp, ); expect(passwordEntry, isNotNull); expect(totpEntry, isNotNull); expect( totpEntry!.timestamp.difference(DateTime.now()) < Duration(seconds: 2), - true, + isTrue, ); }); diff --git a/packages/gotrue/test/src/gotrue_oauth_api_test.dart b/packages/gotrue/test/src/gotrue_oauth_api_test.dart index 4f3f44663..8d4d59443 100644 --- a/packages/gotrue/test/src/gotrue_oauth_api_test.dart +++ b/packages/gotrue/test/src/gotrue_oauth_api_test.dart @@ -64,77 +64,144 @@ void main() { throwsFormatException, ); }); + + test('parses a redirect-only body into a redirect response', () { + final json = { + 'redirect_url': + 'http://127.0.0.1:3000/oauth/callback?code=367ba316-8f55-43cd', + }; + + final actual = OAuthAuthorizationResponse.fromJson(json); + + expect(actual, isA()); + expect( + (actual as OAuthAuthorizationRedirectResponse).redirectUrl, + equals('http://127.0.0.1:3000/oauth/callback?code=367ba316-8f55-43cd'), + ); + }); + }); + + group('OAuthGrant', () { + test('can parse a valid JSON', () { + final json = { + 'client': { + 'id': '7263e727-435b-4d38-a5ff-a14c954b8680', + 'name': 'OAuth test client', + 'uri': 'https://example.com', + 'logo_uri': 'https://example.com/logo.png', + }, + 'scopes': ['email', 'profile'], + 'granted_at': '2026-07-08T08:40:13.212Z', + }; + + final actual = OAuthGrant.fromJson(json); + + expect( + actual.client.clientId, + equals('7263e727-435b-4d38-a5ff-a14c954b8680'), + ); + expect(actual.client.clientName, equals('OAuth test client')); + expect(actual.client.clientUri, equals('https://example.com')); + expect(actual.client.logoUri, equals('https://example.com/logo.png')); + expect(actual.scopes, equals(['email', 'profile'])); + expect( + actual.grantedAt, + equals(DateTime.parse('2026-07-08T08:40:13.212Z')), + ); + }); + + test('defaults scopes to an empty list when missing', () { + final json = { + 'client': { + 'id': '7263e727-435b-4d38-a5ff-a14c954b8680', + 'name': 'OAuth test client', + }, + 'granted_at': '2026-07-08T08:40:13.212Z', + }; + + final actual = OAuthGrant.fromJson(json); + + expect(actual.scopes, isEmpty); + }); }); group('OAuth server', () { test('get authorization details', () async { final sut = await fixture.build(); - final clientParams = CreateOAuthClientParams( + final clientParameters = CreateOAuthClientParams( clientName: 'Test OAuth Client', redirectUris: ['http://127.0.0.1:3000/oauth/callback'], responseTypes: [OAuthClientResponseType.code], scope: 'email', ); - final client = await fixture.sutCreatesOAuthClient(clientParams); + final client = await fixture.sutCreatesOAuthClient(clientParameters); final auth = await fixture.sutLogsIn(password: password, email: email1); final authorizationId = await fixture.sutAuthorizesClient(client); - final res = await sut.oauth.getAuthorizationDetails(authorizationId); - - expect(res.authorizationId, equals(authorizationId)); - expect(res.scope, equals(clientParams.scope)); - expect(res.redirectUri, equals(clientParams.redirectUris.first)); - expect(res.client.clientId, equals(client.clientId)); - expect(res.client.clientName, equals(client.clientName)); - expect(res.user.id, equals(auth.user?.id)); - expect(res.user.email, equals(email1)); + final response = await sut.oauth.getAuthorizationDetails(authorizationId); + + expect(response, isA()); + final details = response as OAuthAuthorizationDetailsResponse; + expect(details.authorizationId, equals(authorizationId)); + expect(details.scope, equals(clientParameters.scope)); + expect(details.redirectUri, equals(clientParameters.redirectUris.first)); + expect(details.client.clientId, equals(client.clientId)); + expect(details.client.clientName, equals(client.clientName)); + expect(details.user.id, equals(auth.user?.id)); + expect(details.user.email, equals(email1)); }); test('approve authorization request', () async { final sut = await fixture.build(); - final clientParams = CreateOAuthClientParams( + final clientParameters = CreateOAuthClientParams( clientName: 'Test OAuth Client', redirectUris: ['http://127.0.0.1:3000/oauth/callback'], ); - final client = await fixture.sutCreatesOAuthClient(clientParams); + final client = await fixture.sutCreatesOAuthClient(clientParameters); final authorizationId = await fixture.sutAuthorizesClient(client); await fixture.sutLogsIn(password: password, email: email1); await sut.oauth.getAuthorizationDetails(authorizationId); - final res = await sut.oauth.approveAuthorization(authorizationId); + final response = await sut.oauth.approveAuthorization(authorizationId); - expect(res.redirectUrl, startsWith(clientParams.redirectUris.first)); - expect(res.redirectUrl, contains('code=')); + expect( + response.redirectUrl, + startsWith(clientParameters.redirectUris.first), + ); + expect(response.redirectUrl, contains('code=')); }); test('denies authorization request', () async { final sut = await fixture.build(); - final clientParams = CreateOAuthClientParams( + final clientParameters = CreateOAuthClientParams( clientName: 'Test OAuth Client', redirectUris: ['http://127.0.0.1:3000/oauth/callback'], ); - final client = await fixture.sutCreatesOAuthClient(clientParams); + final client = await fixture.sutCreatesOAuthClient(clientParameters); final authorizationId = await fixture.sutAuthorizesClient(client); await fixture.sutLogsIn(password: password, email: email1); await sut.oauth.getAuthorizationDetails(authorizationId); - final res = await sut.oauth.denyAuthorization(authorizationId); + final response = await sut.oauth.denyAuthorization(authorizationId); - expect(res.redirectUrl, startsWith(clientParams.redirectUris.first)); - expect(res.redirectUrl, contains('error=access_denied')); expect( - res.redirectUrl, + response.redirectUrl, + startsWith(clientParameters.redirectUris.first), + ); + expect(response.redirectUrl, contains('error=access_denied')); + expect( + response.redirectUrl, contains('error_description=User+denied+the+request'), ); }); test('approving authorization without getting details throws', () async { final sut = await fixture.build(); - final clientParams = CreateOAuthClientParams( + final clientParameters = CreateOAuthClientParams( clientName: 'Test OAuth Client', redirectUris: ['http://127.0.0.1:3000/oauth/callback'], ); - final client = await fixture.sutCreatesOAuthClient(clientParams); + final client = await fixture.sutCreatesOAuthClient(clientParameters); final authorizationId = await fixture.sutAuthorizesClient(client); await fixture.sutLogsIn(password: password, email: email1); @@ -148,6 +215,83 @@ void main() { ), ); }); + + test('lists grants after approving an authorization', () async { + final sut = await fixture.build(); + final clientParameters = CreateOAuthClientParams( + clientName: 'Test OAuth Client', + redirectUris: ['http://127.0.0.1:3000/oauth/callback'], + responseTypes: [OAuthClientResponseType.code], + scope: 'email', + ); + final client = await fixture.sutCreatesOAuthClient(clientParameters); + await fixture.sutLogsIn(password: password, email: email1); + final authorizationId = await fixture.sutAuthorizesClient(client); + await sut.oauth.getAuthorizationDetails(authorizationId); + await sut.oauth.approveAuthorization(authorizationId); + + final grants = await sut.oauth.listGrants(); + + expect(grants, hasLength(1)); + expect(grants.first.client.clientId, equals(client.clientId)); + expect(grants.first.scopes, contains('email')); + }); + + test('revokes a grant', () async { + final sut = await fixture.build(); + final clientParameters = CreateOAuthClientParams( + clientName: 'Test OAuth Client', + redirectUris: ['http://127.0.0.1:3000/oauth/callback'], + responseTypes: [OAuthClientResponseType.code], + scope: 'email', + ); + final client = await fixture.sutCreatesOAuthClient(clientParameters); + await fixture.sutLogsIn(password: password, email: email1); + final authorizationId = await fixture.sutAuthorizesClient(client); + await sut.oauth.getAuthorizationDetails(authorizationId); + await sut.oauth.approveAuthorization(authorizationId); + expect(await sut.oauth.listGrants(), hasLength(1)); + + await sut.oauth.revokeGrant(client.clientId); + + expect(await sut.oauth.listGrants(), isEmpty); + }); + + test( + 'getting details for a new authorization after the client was already ' + 'approved does not throw', + () async { + final sut = await fixture.build(); + final clientParameters = CreateOAuthClientParams( + clientName: 'Test OAuth Client', + redirectUris: ['http://127.0.0.1:3000/oauth/callback'], + responseTypes: [OAuthClientResponseType.code], + scope: 'email', + ); + final client = await fixture.sutCreatesOAuthClient(clientParameters); + await fixture.sutLogsIn(password: password, email: email1); + + // First authorization: fetch details and approve to record consent. + final firstAuthorizationId = await fixture.sutAuthorizesClient(client); + await sut.oauth.getAuthorizationDetails(firstAuthorizationId); + await sut.oauth.approveAuthorization(firstAuthorizationId); + + // Second authorization for the same client, by the same user. + final secondAuthorizationId = await fixture.sutAuthorizesClient(client); + + final response = await sut.oauth.getAuthorizationDetails( + secondAuthorizationId, + ); + + expect(response, isA()); + final redirect = response as OAuthAuthorizationRedirectResponse; + expect( + redirect.redirectUrl, + startsWith(clientParameters.redirectUris.first), + ); + expect(redirect.redirectUrl, contains('code=')); + }, + ); }); } @@ -174,7 +318,9 @@ class GotrueOauthApiFixture { late final String _serviceRoleToken; Future sutCreatesOAuthClient(CreateOAuthClientParams request) { - return _client.admin.oauth.createClient(request).then((res) => res.client!); + return _client.admin.oauth + .createClient(request) + .then((response) => response.client!); } Future sutAuthorizesClient(OAuthClient client) async { @@ -213,7 +359,7 @@ class GotrueOauthApiFixture { } Future _reset() async { - final res = await http.post( + final response = await http.post( Uri.parse('http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data'), headers: { 'x-forwarded-for': '127.0.0.1', @@ -221,7 +367,7 @@ class GotrueOauthApiFixture { 'Authorization': 'Bearer $_serviceRoleToken', }, ); - if (res.body.isNotEmpty) throw res.body; + if (response.body.isNotEmpty) throw response.body; } Future build({bool reset = true}) async { diff --git a/packages/gotrue/test/src/set_session_test.dart b/packages/gotrue/test/src/set_session_test.dart index 55a30a35e..38342cb78 100644 --- a/packages/gotrue/test/src/set_session_test.dart +++ b/packages/gotrue/test/src/set_session_test.dart @@ -43,7 +43,7 @@ class _SetSessionMockClient extends BaseClient { // Refresh-token fallback response with a freshly minted access token. final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; final iat = exp - 3600; - final freshAt = _makeRawJwt({ + final freshAccessToken = _makeRawJwt({ 'exp': exp, 'iat': iat, 'sub': 'mock-user-id', @@ -52,7 +52,7 @@ class _SetSessionMockClient extends BaseClient { Stream.value( utf8.encode( jsonEncode({ - 'access_token': freshAt, + 'access_token': freshAccessToken, 'token_type': 'bearer', 'expires_in': 3600, 'refresh_token': 'new-refresh-token', @@ -78,8 +78,8 @@ String _makeRawJwt(Map payload) { utf8.encode(jsonEncode({'alg': 'HS256', 'typ': 'JWT'})), ); final body = base64Url.encode(utf8.encode(jsonEncode(payload))); - const sig = 'AAAA'; - return '$header.$body.$sig'; + const signature = 'AAAA'; + return '$header.$body.$signature'; } void main() { @@ -99,14 +99,14 @@ void main() { test('empty refresh token with a non-null access token throws before ' 'inspecting the access token', () async { final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; - final at = _makeRawJwt({ + final accessToken = _makeRawJwt({ 'exp': exp, 'iat': exp - 3600, 'sub': 'mock-user-id', }); await expectLater( - () => client.setSession('', accessToken: at), + client.setSession('', accessToken: accessToken), throwsA(isA()), ); // No network call should have been made. @@ -117,7 +117,7 @@ void main() { 'as expired and falls back to the refresh-token path', () async { final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; // exp is 20 s in the future, inside the 30 s Constants.expiryMargin. - final at = _makeRawJwt({ + final accessToken = _makeRawJwt({ 'exp': timeNow + 20, 'iat': timeNow - 3580, 'sub': 'mock-user-id', @@ -125,27 +125,30 @@ void main() { final response = await client.setSession( 'some-refresh-token', - accessToken: at, + accessToken: accessToken, ); expect(response.session, isNotNull); // The returned token must be the freshly refreshed one, not our near-expired JWT. - expect(response.session?.accessToken, isNot(equals(at))); + expect(response.session?.accessToken, isNot(equals(accessToken))); expect(mockClient.userCallCount, 0); // /user was NOT called }); test('access token with no exp claim is treated as expired and falls back ' 'to the refresh-token path', () async { // JWT without an exp claim: decodeJwt succeeds but exp == null. - final at = _makeRawJwt({'role': 'authenticated', 'sub': 'mock-user-id'}); + final accessToken = _makeRawJwt({ + 'role': 'authenticated', + 'sub': 'mock-user-id', + }); final response = await client.setSession( 'some-refresh-token', - accessToken: at, + accessToken: accessToken, ); expect(response.session, isNotNull); - expect(response.session?.accessToken, isNot(equals(at))); + expect(response.session?.accessToken, isNot(equals(accessToken))); expect(mockClient.userCallCount, 0); }); }); @@ -156,11 +159,15 @@ void main() { () async { final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; final exp = iat + 3600; - final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + final accessToken = _makeRawJwt({ + 'exp': exp, + 'iat': iat, + 'sub': 'mock-user-id', + }); final response = await client.setSession( 'some-refresh-token', - accessToken: at, + accessToken: accessToken, ); // expiresIn should be the total token lifetime (exp - iat = 3600). @@ -171,11 +178,11 @@ void main() { test('expiresIn is null when iat claim is absent', () async { final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600; // JWT without iat. - final at = _makeRawJwt({'exp': exp, 'sub': 'mock-user-id'}); + final accessToken = _makeRawJwt({'exp': exp, 'sub': 'mock-user-id'}); final response = await client.setSession( 'some-refresh-token', - accessToken: at, + accessToken: accessToken, ); expect(response.session?.expiresIn, isNull); @@ -184,11 +191,15 @@ void main() { test('expiresAt matches the exp claim in the JWT', () async { final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; final exp = iat + 3600; - final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + final accessToken = _makeRawJwt({ + 'exp': exp, + 'iat': iat, + 'sub': 'mock-user-id', + }); final response = await client.setSession( 'some-refresh-token', - accessToken: at, + accessToken: accessToken, ); // expiresAt is re-derived from the JWT's own exp, not from expiresIn. @@ -201,11 +212,18 @@ void main() { final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; final exp = iat + 3600; const refreshToken = 'my-refresh-token'; - final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + final accessToken = _makeRawJwt({ + 'exp': exp, + 'iat': iat, + 'sub': 'mock-user-id', + }); - final response = await client.setSession(refreshToken, accessToken: at); + final response = await client.setSession( + refreshToken, + accessToken: accessToken, + ); - expect(response.session?.accessToken, equals(at)); + expect(response.session?.accessToken, equals(accessToken)); expect(response.session?.refreshToken, equals(refreshToken)); expect(response.session?.tokenType, equals('bearer')); }, @@ -216,20 +234,24 @@ void main() { test('fast path emits signedIn (not tokenRefreshed)', () async { final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60; final exp = iat + 3600; - final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'}); + final accessToken = _makeRawJwt({ + 'exp': exp, + 'iat': iat, + 'sub': 'mock-user-id', + }); expect( client.onAuthStateChange, emits(predicate((s) => s.event == AuthChangeEvent.signedIn)), ); - await client.setSession('some-refresh-token', accessToken: at); + await client.setSession('some-refresh-token', accessToken: accessToken); }); test('expired-fallback path emits tokenRefreshed (not signedIn)', () async { final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Clearly expired token (exp well in the past). - final at = _makeRawJwt({ + final accessToken = _makeRawJwt({ 'exp': timeNow - 100, 'iat': timeNow - 3700, 'sub': 'mock-user-id', @@ -244,7 +266,7 @@ void main() { ), ); - await client.setSession('some-refresh-token', accessToken: at); + await client.setSession('some-refresh-token', accessToken: accessToken); }); }); } diff --git a/packages/gotrue/test/src/token_refresh_race_test.dart b/packages/gotrue/test/src/token_refresh_race_test.dart index 625a2036d..f572aba4d 100644 --- a/packages/gotrue/test/src/token_refresh_race_test.dart +++ b/packages/gotrue/test/src/token_refresh_race_test.dart @@ -30,8 +30,8 @@ String _makeRawJwt(Map payload) { utf8.encode(jsonEncode({'alg': 'HS256', 'typ': 'JWT'})), ); final body = base64Url.encode(utf8.encode(jsonEncode(payload))); - const sig = 'AAAA'; - return '$header.$body.$sig'; + const signature = 'AAAA'; + return '$header.$body.$signature'; } String _freshAccessToken({String sub = 'mock-user-id'}) { @@ -43,9 +43,9 @@ String _freshAccessToken({String sub = 'mock-user-id'}) { String _tokenResponseJson({ String refreshToken = 'new-refresh-token', }) { - final at = _freshAccessToken(); + final accessToken = _freshAccessToken(); return jsonEncode({ - 'access_token': at, + 'access_token': accessToken, 'token_type': 'bearer', 'expires_in': 3600, 'refresh_token': refreshToken, @@ -183,10 +183,10 @@ void main() { // Meanwhile, set a new session via the fast path (valid access token). // This bypasses the refresh queue entirely and writes _currentSession // immediately, bumping _sessionVersion. - final freshAt = _freshAccessToken(); + final freshAccessToken = _freshAccessToken(); await client.setSession( 'new-signin-refresh-token', - accessToken: freshAt, + accessToken: freshAccessToken, ); expect(client.currentSession?.refreshToken, 'new-signin-refresh-token'); @@ -288,10 +288,10 @@ void main() { await Future.delayed(Duration.zero); // While the refresh is pending, set a new valid session (fast path). - final freshAt = _freshAccessToken(); + final freshAccessToken = _freshAccessToken(); await client.setSession( 'new-refresh-token', - accessToken: freshAt, + accessToken: freshAccessToken, ); expect(client.currentSession, isNotNull); expect(client.currentSession?.refreshToken, 'new-refresh-token'); @@ -316,8 +316,8 @@ void main() { }); // Set an initial session. - final freshAt = _freshAccessToken(); - await client.setSession('initial-token', accessToken: freshAt); + final freshAccessToken = _freshAccessToken(); + await client.setSession('initial-token', accessToken: freshAccessToken); events.clear(); // Now make a refresh that will fail with a non-retryable error. diff --git a/packages/gotrue/test/src/types/auth_exception_test.dart b/packages/gotrue/test/src/types/auth_exception_test.dart index 32ed55802..798fd068a 100644 --- a/packages/gotrue/test/src/types/auth_exception_test.dart +++ b/packages/gotrue/test/src/types/auth_exception_test.dart @@ -119,12 +119,6 @@ void main() { expect(exception1, equals(exception2)); }); - - test('returns true for reference equality', () { - const exception = AuthException('Test error'); - - expect(exception, same(exception)); - }); }); group('implements Exception', () { @@ -495,7 +489,7 @@ void main() { expect(exception.message, equals('Password does not meet requirements')); expect(exception.statusCode, equals('422')); expect(exception.code, equals('weak_password')); - expect(exception.reasons.length, equals(3)); + expect(exception.reasons, hasLength(3)); }); test('handles unknown HTTP error scenario', () { diff --git a/packages/gotrue/test/src/types/session_test.dart b/packages/gotrue/test/src/types/session_test.dart index 028575bca..d18d0eb53 100644 --- a/packages/gotrue/test/src/types/session_test.dart +++ b/packages/gotrue/test/src/types/session_test.dart @@ -153,7 +153,7 @@ void main() { final json = session.toJson(); - expect(json.containsKey('expires_at'), isTrue); + expect(json, contains('expires_at')); expect(json['expires_at'], equals(session.expiresAt)); }); }); @@ -491,16 +491,6 @@ void main() { expect(session1, equals(session2)); }); - - test('returns true for reference equality', () { - final session = Session( - accessToken: 'test-token', - tokenType: 'bearer', - user: mockUser, - ); - - expect(session, same(session)); - }); }); group('roundtrip serialization', () { diff --git a/packages/gotrue/test/src/types/user_attributes_test.dart b/packages/gotrue/test/src/types/user_attributes_test.dart index 90ea525ae..77932e30e 100644 --- a/packages/gotrue/test/src/types/user_attributes_test.dart +++ b/packages/gotrue/test/src/types/user_attributes_test.dart @@ -56,6 +56,34 @@ void main() { // assert expect(userAttributesOne, isNot(equals(userAttributesThree))); }); + + test('currentPassword is serialized as current_password', () { + final userAttributes = UserAttributes( + password: 'new-password', + currentPassword: 'old-password', + ); + + expect(userAttributes.toJson(), { + 'password': 'new-password', + 'current_password': 'old-password', + }); + }); + + test('currentPassword is omitted from JSON when null', () { + final userAttributes = UserAttributes(password: 'new-password'); + + expect(userAttributes.toJson().containsKey('current_password'), isFalse); + }); + + test('Attributes with different currentPassword are not equal', () { + final withCurrentPassword = UserAttributes( + password: password, + currentPassword: 'old-password', + ); + final withoutCurrentPassword = UserAttributes(password: password); + + expect(withCurrentPassword, isNot(equals(withoutCurrentPassword))); + }); }); group('Admin user attributes', () { diff --git a/packages/gotrue/test/src/types/user_test.dart b/packages/gotrue/test/src/types/user_test.dart index fe64f9a01..cf1ae6317 100644 --- a/packages/gotrue/test/src/types/user_test.dart +++ b/packages/gotrue/test/src/types/user_test.dart @@ -192,7 +192,7 @@ void main() { expect(user, isNotNull); expect(user!.identities, isNotNull); - expect(user.identities!.length, equals(1)); + expect(user.identities, hasLength(1)); expect(user.identities![0].id, equals('identity-1')); expect(user.identities![0].provider, equals('email')); }); @@ -220,7 +220,7 @@ void main() { expect(user, isNotNull); expect(user!.factors, isNotNull); - expect(user.factors!.length, equals(1)); + expect(user.factors, hasLength(1)); expect(user.factors![0].id, equals('factor-1')); expect(user.factors![0].friendlyName, equals('My Phone')); }); @@ -331,8 +331,8 @@ void main() { final json = user.toJson(); - expect(json['identities'], isA()); - expect(json['identities'].length, equals(1)); + expect(json['identities'], isA>()); + expect(json['identities'], hasLength(1)); expect(json['identities'][0], equals(identity.toJson())); }); @@ -468,18 +468,6 @@ void main() { expect(user1, equals(user2)); }); - - test('returns true for reference equality', () { - const user = User( - id: '123', - appMetadata: {}, - userMetadata: {}, - aud: 'authenticated', - createdAt: '2023-01-01T00:00:00Z', - ); - - expect(user, same(user)); - }); }); group('roundtrip serialization', () { @@ -782,20 +770,6 @@ void main() { expect(identity1, equals(identity2)); }); - - test('returns true for reference equality', () { - const identity = UserIdentity( - id: 'identity-1', - userId: '123', - identityData: {'email': 'test@example.com'}, - identityId: 'identity-1', - provider: 'email', - createdAt: '2023-01-01T00:00:00Z', - lastSignInAt: '2023-01-01T00:00:00Z', - ); - - expect(identity, same(identity)); - }); }); group('roundtrip serialization', () { diff --git a/packages/gotrue/test/web3_auth_integration_test.dart b/packages/gotrue/test/web3_auth_integration_test.dart new file mode 100644 index 000000000..6bf432d9a --- /dev/null +++ b/packages/gotrue/test/web3_auth_integration_test.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; + +import 'package:cryptography/cryptography.dart'; +import 'package:dotenv/dotenv.dart'; +import 'package:gotrue/gotrue.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +const _base58Alphabet = + '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +String _base58Encode(List bytes) { + var value = BigInt.zero; + for (final byte in bytes) { + value = (value << 8) | BigInt.from(byte); + } + + final result = StringBuffer(); + final radix = BigInt.from(58); + while (value > BigInt.zero) { + result.write(_base58Alphabet[(value % radix).toInt()]); + value = value ~/ radix; + } + + for (final byte in bytes) { + if (byte != 0) break; + result.write('1'); + } + + return String.fromCharCodes(result.toString().codeUnits.reversed); +} + +void main() { + final env = DotEnv()..load(); + final gotrueUrl = env['GOTRUE_URL'] ?? 'http://127.0.0.1:54421/auth/v1'; + final anonToken = env['GOTRUE_TOKEN'] ?? getAnonToken(env); + final ed25519 = Ed25519(); + + late GoTrueClient client; + + setUp(() { + client = GoTrueClient( + url: gotrueUrl, + headers: {'Authorization': 'Bearer $anonToken', 'apikey': anonToken}, + asyncStorage: TestAsyncStorage(), + ); + }); + + tearDown(() { + client.dispose(); + }); + + Future<({String message, String signature})> signSolanaMessage() async { + final keyPair = await ed25519.newKeyPair(); + final publicKey = await keyPair.extractPublicKey(); + final address = _base58Encode(publicKey.bytes); + final issuedAt = DateTime.now().toUtc().toIso8601String(); + + final message = + 'localhost:9999 wants you to sign in with your Solana account:\n' + '$address\n' + '\n' + 'Version: 1\n' + 'URI: http://localhost:9999/welcome\n' + 'Issued At: $issuedAt'; + + final signature = await ed25519.sign( + utf8.encode(message), + keyPair: keyPair, + ); + + return (message: message, signature: base64Url.encode(signature.bytes)); + } + + group('signInWithWeb3 against a live GoTrue', () { + test('signs in with a valid Solana signature', () async { + final signed = await signSolanaMessage(); + + final events = []; + client.onAuthStateChange.listen( + (state) => events.add(state.event), + onError: (_) {}, + ); + + final response = await client.signInWithWeb3( + chain: Web3Chain.solana, + message: signed.message, + signature: signed.signature, + ); + + expect(response.session, isNotNull); + expect(response.session?.accessToken, isNotEmpty); + expect(response.user?.appMetadata['provider'], 'web3'); + expect(client.currentSession?.accessToken, response.session?.accessToken); + + await Future.delayed(Duration.zero); + expect(events, contains(AuthChangeEvent.signedIn)); + }); + + test('rejects a signature that does not match the message', () async { + final signed = await signSolanaMessage(); + final other = await signSolanaMessage(); + + await expectLater( + client.signInWithWeb3( + chain: Web3Chain.solana, + message: signed.message, + signature: other.signature, + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/gotrue/test/web3_auth_test.dart b/packages/gotrue/test/web3_auth_test.dart new file mode 100644 index 000000000..8ddf7efe4 --- /dev/null +++ b/packages/gotrue/test/web3_auth_test.dart @@ -0,0 +1,162 @@ +import 'package:gotrue/gotrue.dart'; +import 'package:test/test.dart'; + +import 'mocks/web3_mock_client.dart'; +import 'utils.dart'; + +void main() { + group('signInWithWeb3', () { + late Web3MockClient mockClient; + late GoTrueClient client; + + setUp(() { + mockClient = Web3MockClient(); + client = GoTrueClient( + url: 'http://localhost:9999', + httpClient: mockClient, + autoRefreshToken: false, + asyncStorage: TestAsyncStorage(), + ); + }); + + tearDown(() { + client.dispose(); + }); + + test('exchanges an Ethereum signature for a session', () async { + final events = []; + client.onAuthStateChange.listen( + (state) => events.add(state.event), + onError: (_) {}, + ); + + final response = await client.signInWithWeb3( + chain: Web3Chain.ethereum, + message: 'example.com wants you to sign in', + signature: '0xdeadbeef', + ); + + expect(mockClient.lastUri?.path, '/token'); + expect(mockClient.lastUri?.queryParameters['grant_type'], 'web3'); + expect(mockClient.lastRequestBody?['chain'], 'ethereum'); + expect( + mockClient.lastRequestBody?['message'], + 'example.com wants you to sign in', + ); + expect(mockClient.lastRequestBody?['signature'], '0xdeadbeef'); + expect( + mockClient.lastRequestBody?.containsKey('gotrue_meta_security'), + isFalse, + ); + + expect(response.session, isNotNull); + expect(response.session?.accessToken, 'mock-access-token'); + expect(response.user?.id, 'mock-user-id-web3'); + expect(client.currentSession?.accessToken, 'mock-access-token'); + + await Future.delayed(Duration.zero); + expect(events, contains(AuthChangeEvent.signedIn)); + }); + + test('exchanges a Solana signature for a session', () async { + final response = await client.signInWithWeb3( + chain: Web3Chain.solana, + message: 'example.com wants you to sign in', + signature: 'base64url-signature', + ); + + expect(mockClient.lastRequestBody?['chain'], 'solana'); + expect(mockClient.lastRequestBody?['signature'], 'base64url-signature'); + expect(response.session, isNotNull); + }); + + test('includes the captcha token when provided', () async { + await client.signInWithWeb3( + chain: Web3Chain.ethereum, + message: 'message', + signature: 'signature', + captchaToken: 'captcha-token', + ); + + expect( + mockClient.lastRequestBody?['gotrue_meta_security'], + {'captcha_token': 'captcha-token'}, + ); + }); + + test('throws AuthApiException for a bad signature', () async { + final errorClient = GoTrueClient( + url: 'http://localhost:9999', + httpClient: Web3ErrorMockClient(), + autoRefreshToken: false, + asyncStorage: TestAsyncStorage(), + ); + addTearDown(errorClient.dispose); + + await expectLater( + errorClient.signInWithWeb3( + chain: Web3Chain.ethereum, + message: 'message', + signature: 'bad-signature', + ), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + '403', + ), + ), + ); + }); + + test('throws AuthApiException for an expired nonce', () async { + final errorClient = GoTrueClient( + url: 'http://localhost:9999', + httpClient: Web3ErrorMockClient( + statusCode: 403, + errorResponse: const { + 'error_code': 'validation_failed', + 'message': 'Message is expired', + }, + ), + autoRefreshToken: false, + asyncStorage: TestAsyncStorage(), + ); + addTearDown(errorClient.dispose); + + await expectLater( + errorClient.signInWithWeb3( + chain: Web3Chain.ethereum, + message: 'expired message', + signature: 'signature', + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'validation_failed', + ), + ), + ); + }); + + test('throws AuthRetryableFetchException on a network error', () async { + final networkErrorClient = GoTrueClient( + url: 'http://localhost:9999', + httpClient: Web3NetworkErrorMockClient(), + autoRefreshToken: false, + asyncStorage: TestAsyncStorage(), + ); + addTearDown(networkErrorClient.dispose); + + await expectLater( + networkErrorClient.signInWithWeb3( + chain: Web3Chain.solana, + message: 'message', + signature: 'signature', + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/postgrest/CHANGELOG.md b/packages/postgrest/CHANGELOG.md index 2c2a2123f..9ef10f5fb 100644 --- a/packages/postgrest/CHANGELOG.md +++ b/packages/postgrest/CHANGELOG.md @@ -1,3 +1,11 @@ +## 2.9.0 + + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **FEAT**(postgrest): configurable auto-retry and request timeout ([#1560](https://github.com/supabase/supabase-flutter/issues/1560)). ([59d4b3af](https://github.com/supabase/supabase-flutter/commit/59d4b3af227e15a53208146a2930981526a39599)) + - **FEAT**(postgrest): abort requests ([#1368](https://github.com/supabase/supabase-flutter/issues/1368)). ([c267e4fa](https://github.com/supabase/supabase-flutter/commit/c267e4fa4128508492ddd35651842abee7b41a7e)) + - **FEAT**(database): add dryRun and stripNulls query modifiers ([#1559](https://github.com/supabase/supabase-flutter/issues/1559)). ([896bc1f7](https://github.com/supabase/supabase-flutter/commit/896bc1f71094fad8e390b47d50d5d424011749b0)) + ## 2.8.0 - **FIX**(postgrest): override limit and offset instead of appending duplicates ([#1512](https://github.com/supabase/supabase-flutter/issues/1512)). ([f1c80d60](https://github.com/supabase/supabase-flutter/commit/f1c80d60113dc9a6cd37fd1fdb89b9eda25cf8a2)) diff --git a/packages/postgrest/README.md b/packages/postgrest/README.md index 40997e736..8fd9f2443 100644 --- a/packages/postgrest/README.md +++ b/packages/postgrest/README.md @@ -1,9 +1,28 @@ -# Postgrest Dart +
+

+ + Supabase Logo + -Dart client for [PostgREST](https://postgrest.org). The goal of this library is to make an "ORM-like" restful interface. +

postgrest

+ +

+ Dart client library for PostgREST, providing an ORM-like interface to the Supabase database. +

+ +

+ Guides + · + Reference Docs +

+

+ +
[![pub package](https://img.shields.io/pub/v/postgrest.svg)](https://pub.dev/packages/postgrest) -[![pub test](https://github.com/supabase/postgrest-dart/workflows/Test/badge.svg)](https://github.com/supabase/postgrest-dart/actions?query=workflow%3ATest) +[![pub test](https://github.com/supabase/supabase-flutter/workflows/Test/badge.svg)](https://github.com/supabase/supabase-flutter/actions?query=workflow%3ATest) + +
## Docs diff --git a/packages/postgrest/example/main.dart b/packages/postgrest/example/main.dart index aa9668a62..e014b1cbf 100644 --- a/packages/postgrest/example/main.dart +++ b/packages/postgrest/example/main.dart @@ -1,6 +1,6 @@ import 'package:postgrest/postgrest.dart'; -/// Example to use with Supabase API https://supabase.io/ +/// Example to use with Supabase API https://supabase.com/ dynamic main() async { const supabaseUrl = ''; const supabaseKey = ''; diff --git a/packages/postgrest/lib/postgrest.dart b/packages/postgrest/lib/postgrest.dart index ce606f2af..da0e86ff9 100644 --- a/packages/postgrest/lib/postgrest.dart +++ b/packages/postgrest/lib/postgrest.dart @@ -4,3 +4,4 @@ library; export 'src/postgrest.dart'; export 'src/postgrest_builder.dart'; export 'src/types.dart'; +export 'package:http/http.dart' show RequestAbortedException; diff --git a/packages/postgrest/lib/src/constants.dart b/packages/postgrest/lib/src/constants.dart index eab366f13..f59bbb521 100644 --- a/packages/postgrest/lib/src/constants.dart +++ b/packages/postgrest/lib/src/constants.dart @@ -1,3 +1,6 @@ import 'package:postgrest/src/version.dart'; +import 'package:supabase_common/supabase_common.dart'; -const defaultHeaders = {'X-Client-Info': 'postgrest-dart/$version'}; +final defaultHeaders = { + 'X-Client-Info': buildClientInfoHeader('postgrest-dart', version), +}; diff --git a/packages/postgrest/lib/src/postgrest.dart b/packages/postgrest/lib/src/postgrest.dart index 92c0aa7bc..7905c331f 100644 --- a/packages/postgrest/lib/src/postgrest.dart +++ b/packages/postgrest/lib/src/postgrest.dart @@ -7,6 +7,9 @@ import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; /// A PostgREST api client written in Dartlang. The goal of this library is to make an "ORM-like" restful interface. class PostgrestClient { + /// HTTP status codes that trigger an automatic retry by default. + static const Set defaultRetryableStatusCodes = {503, 520}; + final String url; final Map headers; final String? _schema; @@ -14,6 +17,9 @@ class PostgrestClient { final YAJsonIsolate _isolate; final bool _hasCustomIsolate; final bool retryEnabled; + final int retryCount; + final Set retryableStatusCodes; + final Duration? requestTimeout; final Duration Function(int attempt)? _retryDelay; final _log = Logger('supabase.postgrest'); @@ -30,8 +36,22 @@ class PostgrestClient { /// [isolate] is optional and can be used to provide a custom isolate, which is used for heavy json computation /// /// [retryEnabled] controls whether automatic retries are performed for GET and - /// HEAD requests that fail with HTTP 503, HTTP 520, or a network error. Defaults to `true`. - /// Use [PostgrestBuilder.retry] to override this per request. + /// HEAD requests that fail with a retryable status code or a network error. + /// Defaults to `true`. Use [PostgrestBuilder.retry] to override this per request. + /// + /// [retryCount] is the number of retry attempts made for a retryable request + /// before giving up. Defaults to `3`. + /// + /// [retryableStatusCodes] are the HTTP status codes that trigger a retry. + /// Defaults to `{503, 520}`. + /// + /// [requestTimeout] optionally bounds how long a single request attempt may + /// take. It is implemented on top of the abort mechanism, so it actually + /// cancels a stalled attempt instead of leaving it running. A timed-out + /// attempt is retried like any other failure, and a [TimeoutException] is + /// thrown once the retries are exhausted. When `null` (the default) no + /// timeout is applied. Use [PostgrestBuilder.abortSignal] to cancel a request + /// outright, which stops retrying immediately. PostgrestClient( this.url, { Map? headers, @@ -39,12 +59,23 @@ class PostgrestClient { this.httpClient, YAJsonIsolate? isolate, this.retryEnabled = true, + this.retryCount = 3, + Set retryableStatusCodes = defaultRetryableStatusCodes, + this.requestTimeout, @visibleForTesting Duration Function(int attempt)? retryDelay, - }) : _schema = schema, + }) : retryableStatusCodes = Set.unmodifiable(retryableStatusCodes), + _schema = schema, headers = {...defaultHeaders, ...?headers}, _isolate = isolate ?? (YAJsonIsolate()..initialize()), _hasCustomIsolate = isolate != null, _retryDelay = retryDelay { + if (retryCount < 0) { + throw ArgumentError.value( + retryCount, + 'retryCount', + 'must not be negative', + ); + } _log.config('Initialize PostgrestClient with url: $url, schema: $_schema'); _log.finest('Initialize with headers: $headers'); } @@ -76,6 +107,9 @@ class PostgrestClient { httpClient: httpClient, isolate: _isolate, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, + requestTimeout: requestTimeout, retryDelay: _retryDelay, ); } @@ -91,6 +125,9 @@ class PostgrestClient { httpClient: httpClient, isolate: _isolate, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, + requestTimeout: requestTimeout, retryDelay: _retryDelay, ); } @@ -112,7 +149,7 @@ class PostgrestClient { /// ``` PostgrestFilterBuilder rpc( String fn, { - Map? params, + Map? params, bool get = false, }) { final requestUrl = '$url/rpc/$fn'; @@ -123,6 +160,9 @@ class PostgrestClient { httpClient: httpClient, isolate: _isolate, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, + requestTimeout: requestTimeout, retryDelay: _retryDelay, ).rpc(params, get); } diff --git a/packages/postgrest/lib/src/postgrest_builder.dart b/packages/postgrest/lib/src/postgrest_builder.dart index 7a4d44729..7b689447b 100644 --- a/packages/postgrest/lib/src/postgrest_builder.dart +++ b/packages/postgrest/lib/src/postgrest_builder.dart @@ -8,6 +8,7 @@ import 'package:http/http.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:postgrest/postgrest.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; part 'postgrest_filter_builder.dart'; @@ -30,6 +31,110 @@ enum HttpMethod { typedef _Nullable = T?; +/// Bundles the automatic retry configuration so it can be carried through the +/// builder chain as a single value instead of separate fields. +@immutable +class _RetryConfig { + _RetryConfig({ + this.enabled = true, + this.count = 3, + Set statusCodes = PostgrestClient.defaultRetryableStatusCodes, + Duration Function(int attempt)? delay, + }) : statusCodes = Set.unmodifiable(statusCodes), + delay = delay ?? PostgrestBuilder._defaultRetryDelay { + if (count < 0) { + throw ArgumentError.value(count, 'retryCount', 'must not be negative'); + } + } + + final bool enabled; + final int count; + final Set statusCodes; + final Duration Function(int attempt) delay; + + _RetryConfig copyWith({ + // retry() always passes enabled, but keep the standard copyWith shape. + // ignore: avoid-unnecessary-nullable-parameters + bool? enabled, + int? count, + Set? statusCodes, + Duration Function(int attempt)? delay, + }) { + return _RetryConfig( + enabled: enabled ?? this.enabled, + count: count ?? this.count, + statusCodes: statusCodes ?? this.statusCodes, + delay: delay ?? this.delay, + ); + } +} + +/// The immutable request state carried through the builder chain. +/// +/// Everything here is independent of the builder's generic types (only the +/// converter depends on them), so the typed builders can share and rewrap a +/// single config instance without re-listing its fields. +@immutable +class _RequestConfig { + const _RequestConfig({ + required this.url, + required this.headers, + this.schema, + this.method, + this.body, + this.httpClient, + this.isolate, + this.count, + this.maybeSingle = false, + required this.retry, + this.requestTimeout, + this.abortSignal, + }); + + final Uri url; + final Headers headers; + final String? schema; + final HttpMethod? method; + final Object? body; + final Client? httpClient; + final YAJsonIsolate? isolate; + final CountOption? count; + final bool maybeSingle; + final _RetryConfig retry; + final Duration? requestTimeout; + final Future? abortSignal; + + _RequestConfig copyWith({ + Uri? url, + Headers? headers, + String? schema, + HttpMethod? method, + Object? body, + Client? httpClient, + YAJsonIsolate? isolate, + CountOption? count, + bool? maybeSingle, + _RetryConfig? retry, + Duration? requestTimeout, + Future? abortSignal, + }) { + return _RequestConfig( + url: url ?? this.url, + headers: headers ?? this.headers, + schema: schema ?? this.schema, + method: method ?? this.method, + body: body ?? this.body, + httpClient: httpClient ?? this.httpClient, + isolate: isolate ?? this.isolate, + count: count ?? this.count, + maybeSingle: maybeSingle ?? this.maybeSingle, + retry: retry ?? this.retry, + requestTimeout: requestTimeout ?? this.requestTimeout, + abortSignal: abortSignal ?? this.abortSignal, + ); + } +} + /// Treats an empty `Prefer` value as absent, so every append site can rely on /// a plain null check instead of separately re-checking for emptiness. String? _emptyPreferAsNull(String? prefer) => @@ -43,20 +148,23 @@ String? _emptyPreferAsNull(String? prefer) => /// Otherwise [S] and [R] are the same @immutable class PostgrestBuilder implements Future { - final Object? _body; - final Headers _headers; - final bool _maybeSingle; - final HttpMethod? _method; - final String? _schema; - final Uri _url; + final _RequestConfig _config; final PostgrestConverter? _converter; - final Client? _httpClient; - final YAJsonIsolate? _isolate; - final CountOption? _count; - final bool _retryEnabled; - final Duration Function(int attempt) _retryDelay; final _log = Logger('supabase.postgrest'); + Object? get _body => _config.body; + Headers get _headers => _config.headers; + bool get _maybeSingle => _config.maybeSingle; + HttpMethod? get _method => _config.method; + String? get _schema => _config.schema; + Uri get _url => _config.url; + Client? get _httpClient => _config.httpClient; + YAJsonIsolate? get _isolate => _config.isolate; + CountOption? get _count => _config.count; + _RetryConfig get _retry => _config.retry; + Duration? get _requestTimeout => _config.requestTimeout; + Future? get _abortSignal => _config.abortSignal; + static Duration _defaultRetryDelay(int attempt) => Duration(seconds: math.min(math.pow(2, attempt).toInt(), 30)); @@ -72,19 +180,40 @@ class PostgrestBuilder implements Future { bool maybeSingle = false, PostgrestConverter? converter, bool retryEnabled = true, + int retryCount = 3, + Set retryableStatusCodes = PostgrestClient.defaultRetryableStatusCodes, @visibleForTesting Duration Function(int attempt)? retryDelay, - }) : _maybeSingle = maybeSingle, - _method = method, - _converter = converter, - _schema = schema, - _url = url, - _headers = headers, - _httpClient = httpClient, - _isolate = isolate, - _count = count, - _body = body, - _retryEnabled = retryEnabled, - _retryDelay = retryDelay ?? _defaultRetryDelay; + Duration? requestTimeout, + Future? abortSignal, + }) : _converter = converter, + _config = _RequestConfig( + url: url, + headers: headers, + schema: schema, + method: method, + body: body, + httpClient: httpClient, + isolate: isolate, + count: count, + maybeSingle: maybeSingle, + retry: _RetryConfig( + enabled: retryEnabled, + count: retryCount, + statusCodes: retryableStatusCodes, + delay: retryDelay, + ), + requestTimeout: requestTimeout, + abortSignal: abortSignal, + ); + + /// Rewraps an existing [config] under a possibly different [converter] (and + /// therefore possibly different generic types). This is what lets the typed + /// builders share a single config instance without re-listing its fields. + PostgrestBuilder._({ + required _RequestConfig config, + required PostgrestConverter? converter, + }) : _config = config, + _converter = converter; PostgrestBuilder _copyWith({ Uri? url, @@ -97,24 +226,26 @@ class PostgrestBuilder implements Future { CountOption? count, bool? maybeSingle, PostgrestConverter? converter, - bool? retryEnabled, - Duration Function(int attempt)? retryDelay, - }) { - return PostgrestBuilder( - url: url ?? _url, - headers: headers ?? _headers, - schema: schema ?? _schema, - method: method ?? _method, - body: body ?? _body, - httpClient: httpClient ?? _httpClient, - isolate: isolate ?? _isolate, - count: count ?? _count, - maybeSingle: maybeSingle ?? _maybeSingle, - converter: converter ?? _converter, - retryEnabled: retryEnabled ?? _retryEnabled, - retryDelay: retryDelay ?? _retryDelay, - ); - } + _RetryConfig? retry, + Duration? requestTimeout, + Future? abortSignal, + }) => PostgrestBuilder._( + config: _config.copyWith( + url: url, + headers: headers, + schema: schema, + method: method, + body: body, + httpClient: httpClient, + isolate: isolate, + count: count, + maybeSingle: maybeSingle, + retry: retry, + requestTimeout: requestTimeout, + abortSignal: abortSignal, + ), + converter: converter ?? _converter, + ); /// Overrides the retry behavior for this specific request. /// @@ -122,8 +253,63 @@ class PostgrestBuilder implements Future { /// [PostgrestClient] was configured with `retryEnabled: true`. /// When [enabled] is `true`, retries are enabled for this request even if /// [PostgrestClient] was configured with `retryEnabled: false`. - PostgrestBuilder retry({required bool enabled}) => - _copyWith(retryEnabled: enabled); + /// + /// [count] overrides the number of retry attempts for this request. + /// + /// [requestTimeout] overrides the per-attempt timeout for this request. When + /// `null`, the timeout configured on [PostgrestClient] is kept. + PostgrestBuilder retry({ + bool enabled = true, + int? count, + Duration? requestTimeout, + }) => _copyWith( + retry: _retry.copyWith(enabled: enabled, count: count), + requestTimeout: requestTimeout, + ); + + /// Allows manually triggering request abortion by completing the provided + /// [Future]. + /// + /// [abortSignal] must not complete with an error. + /// + /// On abort, a [RequestAbortedException] will be thrown. + /// This is useful for setting a timeout for the request. + /// + /// Aborting a request will also stop any retries. + /// + /// ## Examples: + /// ### Event based: + /// + /// ```dart + /// final abortSignal = Completer(); + /// + /// abortSignal.complete(); // Call in some event handler to abort the request + /// + /// try { + /// final response = await client + /// .from('table') + /// .select() + /// .abortSignal(abortSignal.future); + /// } on RequestAbortedException catch (e) { + /// print('Request was aborted: $e'); + /// } + /// ``` + /// + /// ### Timer based: + /// + /// ```dart + /// try { + /// final response = await client + /// .from('table') + /// .select() + /// .abortSignal(Future.delayed(Duration(seconds: 5))); + /// } on RequestAbortedException catch (e) { + /// print('Request was aborted: $e'); + /// } + /// ``` + PostgrestBuilder abortSignal(Future abortSignal) { + return _copyWith(abortSignal: abortSignal); + } PostgrestBuilder setHeader(String key, String value) { return _copyWith( @@ -138,11 +324,12 @@ class PostgrestBuilder implements Future { // X-Retry-Count, etc.). final execHeaders = {..._headers}; - if (_count != null) { + final count = _count; + if (count != null) { final oldPreferHeader = _emptyPreferAsNull(execHeaders['Prefer']); execHeaders['Prefer'] = oldPreferHeader != null - ? '$oldPreferHeader,count=${_count.name}' - : 'count=${_count.name}'; + ? '$oldPreferHeader,count=${count.name}' + : 'count=${count.name}'; } if (method == null) { @@ -151,12 +338,13 @@ class PostgrestBuilder implements Future { ); } - if (_schema == null) { + final schema = _schema; + if (schema == null) { // skip } else if (method == HttpMethod.get || method == HttpMethod.head) { - execHeaders['Accept-Profile'] = _schema; + execHeaders['Accept-Profile'] = schema; } else { - execHeaders['Content-Profile'] = _schema; + execHeaders['Content-Profile'] = schema; } if (method != HttpMethod.get && method != HttpMethod.head) { execHeaders['Content-Type'] = 'application/json'; @@ -164,34 +352,60 @@ class PostgrestBuilder implements Future { final bodyStr = jsonEncode(_body); _log.finest("Request: ${method.value} $_url"); - final Future Function() send; - if (method == HttpMethod.get) { - send = () => (_httpClient?.get ?? http.get)(_url, headers: execHeaders); - } else if (method == HttpMethod.post) { - send = () => (_httpClient?.post ?? http.post)( - _url, - headers: execHeaders, - body: bodyStr, - ); - } else if (method == HttpMethod.put) { - send = () => (_httpClient?.put ?? http.put)( - _url, - headers: execHeaders, - body: bodyStr, - ); - } else if (method == HttpMethod.patch) { - send = () => (_httpClient?.patch ?? http.patch)( + final requestTimeout = _requestTimeout; + + Future send() async { + // The request timeout bounds each individual attempt. It is implemented + // on top of the abort mechanism so it actually cancels a stalled attempt + // instead of leaving it running. A timed-out attempt surfaces as a + // [TimeoutException] so the retry loop treats it as a retryable failure, + // whereas the caller-provided [_abortSignal] keeps its + // [RequestAbortedException] and stops retries outright. + var timedOut = false; + Timer? timeoutTimer; + Future? abortTrigger = _abortSignal; + if (requestTimeout != null) { + final timeoutCompleter = Completer(); + timeoutTimer = Timer(requestTimeout, () { + timedOut = true; + if (!timeoutCompleter.isCompleted) { + timeoutCompleter.complete(); + } + }); + final abortSignal = _abortSignal; + abortTrigger = abortSignal == null + ? timeoutCompleter.future + : Future.any([abortSignal, timeoutCompleter.future]); + } + + final AbortableRequest request = AbortableRequest( + method.value, _url, - headers: execHeaders, - body: bodyStr, + abortTrigger: abortTrigger, ); - } else if (method == HttpMethod.delete) { - send = () => - (_httpClient?.delete ?? http.delete)(_url, headers: execHeaders); - } else if (method == HttpMethod.head) { - send = () => (_httpClient?.head ?? http.head)(_url, headers: execHeaders); - } else { - throw StateError('Unknown HTTP method: ${method.value}'); + request.headers.addAll(execHeaders); + switch (method) { + case HttpMethod.post || HttpMethod.put || HttpMethod.patch: + request.body = bodyStr; + case HttpMethod.get || HttpMethod.head || HttpMethod.delete: + break; + } + final client = _httpClient ?? http.Client(); + + try { + final streamResponse = await client.send(request); + return await http.Response.fromStream(streamResponse); + } on RequestAbortedException { + if (timedOut) { + throw TimeoutException('Request timed out', requestTimeout); + } + rethrow; + } finally { + timeoutTimer?.cancel(); + if (_httpClient == null) { + client.close(); + } + } } final response = await _executeWithRetry(send, method, execHeaders); @@ -203,13 +417,13 @@ class PostgrestBuilder implements Future { HttpMethod method, Map execHeaders, ) async { - const maxRetries = 3; - const retryableStatusCodes = {503, 520}; + final maxRetries = _retry.count; + final retryableStatusCodes = _retry.statusCodes; final isRetryableMethod = method == HttpMethod.get || method == HttpMethod.head; - if (!_retryEnabled || !isRetryableMethod) { + if (!_retry.enabled || !isRetryableMethod) { return send(); } @@ -224,11 +438,16 @@ class PostgrestBuilder implements Future { attempt == maxRetries) { return response; } + } on RequestAbortedException catch (_) { + // A manual abort stops retrying immediately. A per-attempt timeout is + // surfaced as a TimeoutException instead, so it falls through to the + // retryable branch below. + rethrow; } on Exception { if (attempt == maxRetries) rethrow; } - await Future.delayed(_retryDelay(attempt)); + await Future.delayed(_retry.delay(attempt)); } throw StateError('unreachable'); @@ -236,7 +455,7 @@ class PostgrestBuilder implements Future { /// Parse request response to json object if possible Future _parseResponse(http.Response response, HttpMethod method) async { - if (response.statusCode >= 200 && response.statusCode <= 299) { + if (isSuccessStatusCode(response.statusCode)) { Object? body; int? count; @@ -250,8 +469,9 @@ class PostgrestBuilder implements Future { body = response.body; } else { try { - if ((response.contentLength ?? 0) > 10000 && _isolate != null) { - body = await _isolate.decode(response.body); + final isolate = _isolate; + if ((response.contentLength ?? 0) > 10000 && isolate != null) { + body = await isolate.decode(response.body); } else { body = jsonDecode(response.body); } @@ -412,7 +632,7 @@ class PostgrestBuilder implements Future { } /// Convert list filter to query params string - String _cleanFilterArray(List filter) { + String _cleanFilterArray(List filter) { if (filter.every((element) => element is num)) { return filter.map((s) => '$s').join(','); } diff --git a/packages/postgrest/lib/src/postgrest_filter_builder.dart b/packages/postgrest/lib/src/postgrest_filter_builder.dart index c731ebc7c..6c922b0a5 100644 --- a/packages/postgrest/lib/src/postgrest_filter_builder.dart +++ b/packages/postgrest/lib/src/postgrest_filter_builder.dart @@ -155,7 +155,7 @@ class PostgrestFilterBuilder extends PostgrestTransformBuilder { /// .select() /// .likeAllOf('username', ['%supa%', '%bot%']); /// ``` - PostgrestFilterBuilder likeAllOf(String column, List patterns) { + PostgrestFilterBuilder likeAllOf(String column, List patterns) { return copyWithUrl( appendSearchParams(column, 'like(all).{${patterns.join(',')}}'), ); @@ -169,7 +169,7 @@ class PostgrestFilterBuilder extends PostgrestTransformBuilder { /// .select() /// .likeAnyOf('username', ['%supa%', '%bot%']); /// ``` - PostgrestFilterBuilder likeAnyOf(String column, List patterns) { + PostgrestFilterBuilder likeAnyOf(String column, List patterns) { return copyWithUrl( appendSearchParams(column, 'like(any).{${patterns.join(',')}}'), ); @@ -195,7 +195,7 @@ class PostgrestFilterBuilder extends PostgrestTransformBuilder { /// .select() /// .ilikeAllOf('username', ['%supa%', '%bot%']); /// ``` - PostgrestFilterBuilder ilikeAllOf(String column, List patterns) { + PostgrestFilterBuilder ilikeAllOf(String column, List patterns) { return copyWithUrl( appendSearchParams(column, 'ilike(all).{${patterns.join(',')}}'), ); @@ -209,7 +209,7 @@ class PostgrestFilterBuilder extends PostgrestTransformBuilder { /// .select() /// .ilikeAnyOf('username', ['%supa%', '%bot%']); /// ``` - PostgrestFilterBuilder ilikeAnyOf(String column, List patterns) { + PostgrestFilterBuilder ilikeAnyOf(String column, List patterns) { return copyWithUrl( appendSearchParams(column, 'ilike(any).{${patterns.join(',')}}'), ); @@ -236,7 +236,7 @@ class PostgrestFilterBuilder extends PostgrestTransformBuilder { /// .select() /// .inFilter('status', ['ONLINE', 'OFFLINE']); /// ``` - PostgrestFilterBuilder inFilter(String column, List values) { + PostgrestFilterBuilder inFilter(String column, List values) { return copyWithUrl( appendSearchParams(column, 'in.(${_cleanFilterArray(values)})'), ); @@ -423,14 +423,12 @@ class PostgrestFilterBuilder extends PostgrestTransformBuilder { /// The type of tsquery conversion to use on [query]. TextSearchType? type, }) { - var typePart = ''; - if (type == TextSearchType.plain) { - typePart = 'pl'; - } else if (type == TextSearchType.phrase) { - typePart = 'ph'; - } else if (type == TextSearchType.websearch) { - typePart = 'w'; - } + final typePart = switch (type) { + TextSearchType.plain => 'pl', + TextSearchType.phrase => 'ph', + TextSearchType.websearch => 'w', + null => '', + }; final configPart = config == null ? '' : '($config)'; return copyWithUrl( appendSearchParams(column, '${typePart}fts$configPart.$query'), @@ -525,8 +523,17 @@ class PostgrestFilterBuilder extends PostgrestTransformBuilder { } @override - PostgrestFilterBuilder retry({required bool enabled}) { - return PostgrestFilterBuilder(_copyWith(retryEnabled: enabled)); + PostgrestFilterBuilder retry({ + bool enabled = true, + int? count, + Duration? requestTimeout, + }) { + return PostgrestFilterBuilder( + _copyWith( + retry: _retry.copyWith(enabled: enabled, count: count), + requestTimeout: requestTimeout, + ), + ); } @override diff --git a/packages/postgrest/lib/src/postgrest_query_builder.dart b/packages/postgrest/lib/src/postgrest_query_builder.dart index ea488dc4a..b8019f0f8 100644 --- a/packages/postgrest/lib/src/postgrest_query_builder.dart +++ b/packages/postgrest/lib/src/postgrest_query_builder.dart @@ -20,7 +20,11 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { Client? httpClient, YAJsonIsolate? isolate, bool retryEnabled = true, + int retryCount = 3, + Set retryableStatusCodes = PostgrestClient.defaultRetryableStatusCodes, Duration Function(int attempt)? retryDelay, + Duration? requestTimeout, + Future? abortSignal, }) : super( PostgrestBuilder( url: url, @@ -30,10 +34,16 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { httpClient: httpClient, isolate: isolate, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, retryDelay: retryDelay, + requestTimeout: requestTimeout, + abortSignal: abortSignal, ), ); + PostgrestQueryBuilder._(super.builder); + /// Perform a SELECT query on the table or view. /// /// ```dart @@ -207,7 +217,7 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { /// .eq('message', 'foo') /// .select(); /// ``` - PostgrestFilterBuilder update(Map values) { + PostgrestFilterBuilder update(Map values) { final newHeaders = {..._headers}..remove('Prefer'); return PostgrestFilterBuilder( @@ -249,12 +259,9 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { ); } - Uri _setColumnsSearchParam(List values) { + Uri _setColumnsSearchParam(List values) { final newValues = PostgrestList.from(values); - final columns = newValues.fold>( - [], - (value, element) => value..addAll(element.keys), - ); + final columns = [for (final element in newValues) ...element.keys]; if (newValues.isNotEmpty) { final uniqueColumns = {...columns}.map((e) => '"$e"').join(','); return appendSearchParams("columns", uniqueColumns); @@ -276,16 +283,16 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { } @override - PostgrestQueryBuilder retry({required bool enabled}) { - return PostgrestQueryBuilder( - url: _url, - headers: _headers, - httpClient: _httpClient, - method: _method, - schema: _schema, - isolate: _isolate, - retryEnabled: enabled, - retryDelay: _retryDelay, + PostgrestQueryBuilder retry({ + bool enabled = true, + int? count, + Duration? requestTimeout, + }) { + return PostgrestQueryBuilder._( + _copyWith( + retry: _retry.copyWith(enabled: enabled, count: count), + requestTimeout: requestTimeout, + ), ); } @@ -298,8 +305,12 @@ class PostgrestQueryBuilder extends RawPostgrestBuilder { method: _method, schema: _schema, isolate: _isolate, - retryEnabled: _retryEnabled, - retryDelay: _retryDelay, + retryEnabled: _retry.enabled, + retryCount: _retry.count, + retryableStatusCodes: _retry.statusCodes, + retryDelay: _retry.delay, + requestTimeout: _requestTimeout, + abortSignal: _abortSignal, ); } } diff --git a/packages/postgrest/lib/src/postgrest_rpc_builder.dart b/packages/postgrest/lib/src/postgrest_rpc_builder.dart index 8cf87461c..99b4006ba 100644 --- a/packages/postgrest/lib/src/postgrest_rpc_builder.dart +++ b/packages/postgrest/lib/src/postgrest_rpc_builder.dart @@ -1,6 +1,7 @@ part of 'postgrest_builder.dart'; -class PostgrestRpcBuilder extends RawPostgrestBuilder { +class PostgrestRpcBuilder + extends RawPostgrestBuilder { PostgrestRpcBuilder( String url, { Map? headers, @@ -8,7 +9,11 @@ class PostgrestRpcBuilder extends RawPostgrestBuilder { Client? httpClient, required YAJsonIsolate isolate, bool retryEnabled = true, + int retryCount = 3, + Set retryableStatusCodes = PostgrestClient.defaultRetryableStatusCodes, Duration Function(int attempt)? retryDelay, + Duration? requestTimeout, + Future? abortSignal, }) : super( PostgrestBuilder( url: Uri.parse(url), @@ -17,7 +22,11 @@ class PostgrestRpcBuilder extends RawPostgrestBuilder { httpClient: httpClient, isolate: isolate, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, retryDelay: retryDelay, + requestTimeout: requestTimeout, + abortSignal: abortSignal, ), ); diff --git a/packages/postgrest/lib/src/postgrest_transform_builder.dart b/packages/postgrest/lib/src/postgrest_transform_builder.dart index 6f03094de..2c6c64553 100644 --- a/packages/postgrest/lib/src/postgrest_transform_builder.dart +++ b/packages/postgrest/lib/src/postgrest_transform_builder.dart @@ -7,8 +7,17 @@ class PostgrestTransformBuilder extends RawPostgrestBuilder { PostgrestTransformBuilder(_copyWith(url: url)); @override - PostgrestTransformBuilder retry({required bool enabled}) { - return PostgrestTransformBuilder(_copyWith(retryEnabled: enabled)); + PostgrestTransformBuilder retry({ + bool enabled = true, + int? count, + Duration? requestTimeout, + }) { + return PostgrestTransformBuilder( + _copyWith( + retry: _retry.copyWith(enabled: enabled, count: count), + requestTimeout: requestTimeout, + ), + ); } @override @@ -187,6 +196,42 @@ class PostgrestTransformBuilder extends RawPostgrestBuilder { ); } + /// Omits `null`-valued properties from the response objects. + /// + /// This uses the `nulls=stripped` variant of the `Accept` header and + /// requires PostgREST 11.2 or higher. + /// + /// ```dart + /// supabase.from('users').select().stripNulls(); + /// ``` + PostgrestTransformBuilder stripNulls() { + final newHeaders = {..._headers}; + final accept = newHeaders['Accept'] ?? 'application/json'; + newHeaders['Accept'] = '$accept;nulls=stripped'; + + return PostgrestTransformBuilder(_copyWith(headers: newHeaders)); + } + + /// Runs the query but rolls back the transaction, so no changes are + /// persisted. + /// + /// The data that would have resulted from the query is still returned, + /// which is useful for previewing the effect of a mutation. + /// + /// ```dart + /// await supabase.from('users').insert({'username': 'foo'}).dryRun(); + /// ``` + PostgrestTransformBuilder dryRun() { + final newHeaders = {..._headers}; + final prefer = _emptyPreferAsNull(newHeaders['Prefer']); + newHeaders['Prefer'] = [ + ?prefer, + 'tx=rollback', + ].join(','); + + return PostgrestTransformBuilder(_copyWith(headers: newHeaders)); + } + /// Retrieves the response as CSV. /// /// This will skip object parsing. diff --git a/packages/postgrest/lib/src/raw_postgrest_builder.dart b/packages/postgrest/lib/src/raw_postgrest_builder.dart index 21df6783f..8d64003ca 100644 --- a/packages/postgrest/lib/src/raw_postgrest_builder.dart +++ b/packages/postgrest/lib/src/raw_postgrest_builder.dart @@ -3,20 +3,7 @@ part of 'postgrest_builder.dart'; /// Needed as a wrapper around [PostgrestBuilder] to allow for the different return type of [withConverter] than in [ResponsePostgrestBuilder.withConverter]. class RawPostgrestBuilder extends PostgrestBuilder { RawPostgrestBuilder(PostgrestBuilder builder) - : super( - url: builder._url, - method: builder._method, - headers: builder._headers, - schema: builder._schema, - body: builder._body, - httpClient: builder._httpClient, - count: builder._count, - isolate: builder._isolate, - maybeSingle: builder._maybeSingle, - converter: builder._converter, - retryEnabled: builder._retryEnabled, - retryDelay: builder._retryDelay, - ); + : super._(config: builder._config, converter: builder._converter); /// Very similar to [_copyWith], but allows changing the generics, therefore [_converter] is omitted RawPostgrestBuilder _copyWithType({ @@ -32,18 +19,19 @@ class RawPostgrestBuilder extends PostgrestBuilder { bool? maybeSingle, }) { return RawPostgrestBuilder( - PostgrestBuilder( - url: url ?? _url, - headers: headers ?? _headers, - schema: schema ?? _schema, - method: method ?? _method, - body: body ?? _body, - httpClient: httpClient ?? _httpClient, - isolate: isolate ?? _isolate, - count: count ?? _count, - maybeSingle: maybeSingle ?? _maybeSingle, - retryEnabled: _retryEnabled, - retryDelay: _retryDelay, + PostgrestBuilder._( + config: _config.copyWith( + url: url, + headers: headers, + schema: schema, + method: method, + body: body, + httpClient: httpClient, + isolate: isolate, + count: count, + maybeSingle: maybeSingle, + ), + converter: null, ), ); } @@ -68,19 +56,6 @@ class RawPostgrestBuilder extends PostgrestBuilder { PostgrestBuilder withConverter( PostgrestConverter converter, ) { - return PostgrestBuilder( - url: _url, - headers: _headers, - schema: _schema, - method: _method, - body: _body, - isolate: _isolate, - httpClient: _httpClient, - count: _count, - maybeSingle: _maybeSingle, - converter: converter, - retryEnabled: _retryEnabled, - retryDelay: _retryDelay, - ); + return PostgrestBuilder._(config: _config, converter: converter); } } diff --git a/packages/postgrest/lib/src/response_postgrest_builder.dart b/packages/postgrest/lib/src/response_postgrest_builder.dart index e0d03eb10..88deaa18f 100644 --- a/packages/postgrest/lib/src/response_postgrest_builder.dart +++ b/packages/postgrest/lib/src/response_postgrest_builder.dart @@ -3,20 +3,7 @@ part of 'postgrest_builder.dart'; /// Needed as a wrapper around [PostgrestBuilder] to allow for the different return type of [withConverter] than in [RawPostgrestBuilder.withConverter]. class ResponsePostgrestBuilder extends PostgrestBuilder { ResponsePostgrestBuilder(PostgrestBuilder builder) - : super( - url: builder._url, - method: builder._method, - headers: builder._headers, - schema: builder._schema, - body: builder._body, - httpClient: builder._httpClient, - count: builder._count, - isolate: builder._isolate, - maybeSingle: builder._maybeSingle, - converter: builder._converter, - retryEnabled: builder._retryEnabled, - retryDelay: builder._retryDelay, - ); + : super._(config: builder._config, converter: builder._converter); @override ResponsePostgrestBuilder setHeader(String key, String value) { @@ -41,19 +28,6 @@ class ResponsePostgrestBuilder extends PostgrestBuilder { PostgrestBuilder, U, R> withConverter( PostgrestConverter converter, ) { - return PostgrestBuilder( - url: _url, - headers: _headers, - schema: _schema, - method: _method, - body: _body, - isolate: _isolate, - httpClient: _httpClient, - count: _count, - maybeSingle: _maybeSingle, - converter: converter, - retryEnabled: _retryEnabled, - retryDelay: _retryDelay, - ); + return PostgrestBuilder._(config: _config, converter: converter); } } diff --git a/packages/postgrest/lib/src/version.dart b/packages/postgrest/lib/src/version.dart index 2cdfc59d8..629ad7b56 100644 --- a/packages/postgrest/lib/src/version.dart +++ b/packages/postgrest/lib/src/version.dart @@ -1 +1 @@ -const version = '2.8.0'; +const version = '2.9.0'; diff --git a/packages/postgrest/pubspec.yaml b/packages/postgrest/pubspec.yaml index 15463fec4..e5bde9bda 100644 --- a/packages/postgrest/pubspec.yaml +++ b/packages/postgrest/pubspec.yaml @@ -1,9 +1,16 @@ name: postgrest description: PostgREST client for Dart. This library provides an ORM interface to PostgREST. -version: 2.8.0 +version: 2.9.0 homepage: 'https://supabase.com' repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/postgrest' +issue_tracker: 'https://github.com/supabase/supabase-flutter/issues' documentation: 'https://supabase.com/docs/reference/dart/select' +topics: + - supabase + - postgrest + - database + - orm + - api environment: sdk: '>=3.9.0 <4.0.0' @@ -11,15 +18,16 @@ environment: resolution: workspace dependencies: - http: ^1.2.2 + http: ^1.6.0 yet_another_json_isolate: 2.1.1 - meta: ^1.9.1 - logging: ^1.2.0 + meta: ^1.16.0 + logging: ^1.3.0 + supabase_common: 0.1.1 dev_dependencies: - collection: ^1.16.0 - supabase_lints: ^0.1.0 - test: ^1.21.4 + collection: ^1.19.0 + supabase_lints: ^0.1.1 + test: ^1.25.0 false_secrets: - /test/** diff --git a/packages/postgrest/test/basic_test.dart b/packages/postgrest/test/basic_test.dart index 9c449fbf7..8bf1cedb1 100644 --- a/packages/postgrest/test/basic_test.dart +++ b/packages/postgrest/test/basic_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:postgrest/postgrest.dart'; @@ -31,56 +32,56 @@ void main() { }); test('basic select table', () async { - final res = await postgrest.from('users').select(); - expect(res.length, 4); + final response = await postgrest.from('users').select(); + expect(response.length, 4); }); test('stored procedure', () async { - final res = await postgrest.rpc( + final result = await postgrest.rpc( 'get_status', params: { 'name_param': 'supabot', }, ); - expect(res, 'ONLINE'); + expect(result, 'ONLINE'); }); test('select on stored procedure', () async { - final res = await postgrest + final response = await postgrest .rpc( 'get_username_and_status', params: {'name_param': 'supabot'}, ) .select('status'); expect( - res.first['status'], + response.first['status'], 'ONLINE', ); }); test('stored procedure returns void', () async { - final res = await postgrest.rpc('void_func'); - expect(res, isNull); + final result = await postgrest.rpc('void_func'); + expect(result, isNull); }); test('stored procedure returns int', () async { - final res = await postgrest.rpc('get_integer'); - expect(res, isA()); + final result = await postgrest.rpc('get_integer'); + expect(result, isA()); }); test('stored procedure with array parameter', () async { - final res = await postgrest.rpc( + final result = await postgrest.rpc( 'get_array_element', params: { 'arr': [37, 420, 64], 'index': 2, }, ); - expect(res, 420); + expect(result, 420); }); test('stored procedure with read-only access mode', () async { - final res = await postgrest.rpc( + final result = await postgrest.rpc( 'get_array_element', params: { 'arr': [37, 420, 64], @@ -88,7 +89,7 @@ void main() { }, get: true, ); - expect(res, 420); + expect(result, 420); }); test('custom headers', () async { @@ -159,8 +160,8 @@ void main() { schema: 'personal', headers: apiHeaders, ); - final res = await client.from('users').select(); - expect(res.length, 5); + final response = await client.from('users').select(); + expect(response.length, 5); }); test('query non-public schema dynamically', () async { @@ -177,19 +178,19 @@ void main() { }); test('on_conflict upsert', () async { - final res = await postgrest.from('users').upsert( + final response = await postgrest.from('users').upsert( {'username': 'dragarcia', 'status': 'OFFLINE'}, onConflict: 'username', ).select(); expect( - res.first['status'], + response.first['status'], 'OFFLINE', ); }); test('upsert', () async { final headersBefore = {...postgrest.headers}; - final res = await postgrest.from('messages').upsert({ + final response = await postgrest.from('messages').upsert({ 'id': 3, 'message': 'foo', 'username': 'supabot', @@ -198,14 +199,14 @@ void main() { final headersAfter = {...postgrest.headers}; expect(headersBefore, headersAfter); - expect(res.first['id'], 3); + expect(response.first['id'], 3); - final resMsg = await postgrest.from('messages').select(); - expect(resMsg.length, 3); + final messagesResponse = await postgrest.from('messages').select(); + expect(messagesResponse.length, 3); }); test('ignoreDuplicates upsert', () async { - final res = await postgrest + final response = await postgrest .from('users') .upsert( {'username': 'dragarcia'}, @@ -213,50 +214,40 @@ void main() { ignoreDuplicates: true, ) .select(); - expect(res, isEmpty); + expect(response, isEmpty); }); test('insert', () async { - final res = await postgrest.from('users').insert( + final response = await postgrest.from('users').insert( { 'username': "bot", 'status': 'OFFLINE', }, ).select(); - expect(res.length, 1); - expect(res.first['status'], 'OFFLINE'); + expect(response.length, 1); + expect(response.first['status'], 'OFFLINE'); }); test('insert uses default value', () async { - final res = await postgrest.from('users').insert( + final response = await postgrest.from('users').insert( { 'username': "bot", }, ).select(); - expect(res.length, 1); - expect(res.first['status'], 'ONLINE'); - }); - - test('bulk insert with one row uses default value', () async { - final res = await postgrest.from('users').insert( - { - 'username': "bot", - }, - ).select(); - expect(res.length, 1); - expect(res.first['status'], 'ONLINE'); + expect(response.length, 1); + expect(response.first['status'], 'ONLINE'); }); test('bulk insert', () async { - final res = await postgrest.from('messages').insert([ + final response = await postgrest.from('messages').insert([ {'id': 4, 'message': 'foo', 'username': 'supabot', 'channel_id': 2}, {'id': 5, 'message': 'foo', 'username': 'supabot', 'channel_id': 1}, ]).select(); - expect(res.length, 2); + expect(response.length, 2); }); test('bulk insert without column defaults', () async { - final res = await postgrest.from('users').insert( + final response = await postgrest.from('users').insert( [ { 'username': "bot", @@ -267,13 +258,13 @@ void main() { }, ], ).select(); - expect(res.length, 2); - expect(res.first['status'], 'OFFLINE'); - expect(res.last['status'], null); + expect(response.length, 2); + expect(response.first['status'], 'OFFLINE'); + expect(response.last['status'], null); }); test('bulk insert with column defaults', () async { - final res = await postgrest.from('users').insert( + final response = await postgrest.from('users').insert( [ { 'username': "bot", @@ -285,35 +276,35 @@ void main() { ], defaultToNull: false, ).select(); - expect(res.length, 2); - expect(res.first['status'], 'OFFLINE'); - expect(res.last['status'], 'ONLINE'); + expect(response.length, 2); + expect(response.first['status'], 'OFFLINE'); + expect(response.last['status'], 'ONLINE'); }); test('basic update', () async { - final res = await postgrest + final response = await postgrest .from('messages') .update( {'channel_id': 2}, ) .isFilter("data", null) .select(); - expect(res, isNotEmpty); - expect(res, everyElement(containsPair("channel_id", 2))); + expect(response, isNotEmpty); + expect(response, everyElement(containsPair("channel_id", 2))); final messages = await postgrest.from('messages').select(); - for (final rec in messages) { - expect(rec['channel_id'], 2); + for (final record in messages) { + expect(record['channel_id'], 2); } }); test('basic delete', () async { - final res = await postgrest + final response = await postgrest .from('messages') .delete() .eq('message', 'Supabase Launch Week is on fire') .select(); - expect(res, [ + expect(response, [ { 'id': 3, 'data': null, @@ -324,11 +315,11 @@ void main() { }, ]); - final resMsg = await postgrest + final messagesResponse = await postgrest .from('messages') .select() .eq('message', 'Supabase Launch Week is on fire'); - expect(resMsg, isEmpty); + expect(messagesResponse, isEmpty); }); test('missing table', () async { @@ -348,11 +339,11 @@ void main() { ); }); - test('Prefer: return=minimal', () async { + test('Prefer: return=minimal completes successfully', () async { await postgrest.from('users').insert({'username': 'bar'}); }); - test('select with head:true', () async { + test('select with head:true completes successfully', () async { await postgrest.from('users').select('*').head(); }); @@ -365,43 +356,43 @@ void main() { }); test('select with head:true, count: exact', () async { - final int res = await postgrest.from('users').count(CountOption.exact); - expect(res, 4); + final int count = await postgrest.from('users').count(CountOption.exact); + expect(count, 4); }); test('select with count: planned', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('*') .count(CountOption.planned); - final int count = res.count; + final int count = response.count; expect(count, greaterThanOrEqualTo(0)); }); test('select with head:true, count: estimated', () async { - final int res = await postgrest + final int count = await postgrest .from('users') .count(CountOption.estimated); - expect(res, isA()); + expect(count, isA()); }); test('select with csv', () async { - final res = await postgrest.from('users').select().csv(); - expect(res, isA()); + final result = await postgrest.from('users').select().csv(); + expect(result, isA()); }); test('stored procedure with count: exact', () async { - final res = await postgrest + final response = await postgrest .rpc( 'get_status', params: {'name_param': 'supabot'}, ) .count(CountOption.exact); - expect(res.count, greaterThanOrEqualTo(0)); + expect(response.count, greaterThanOrEqualTo(0)); }); test('insert with count: exact', () async { - final res = await postgrest + final response = await postgrest .from('users') .upsert( {'username': 'countexact', 'status': 'OFFLINE'}, @@ -409,11 +400,11 @@ void main() { ) .select() .count(CountOption.exact); - expect(res.count, 1); + expect(response.count, 1); }); test('update with count: exact', () async { - final res = await postgrest + final response = await postgrest .from('users') .update( {'status': 'ONLINE'}, @@ -421,18 +412,18 @@ void main() { .eq('username', 'kiwicopple') .select() .count(CountOption.exact); - expect(res.count, 1); + expect(response.count, 1); }); test('delete with count: exact', () async { - final res = await postgrest + final response = await postgrest .from('users') .delete() .eq('username', 'kiwicopple') .select() .count(CountOption.exact); - expect(res.count, 1); + expect(response.count, 1); }); test('execute without table operation', () async { @@ -443,49 +434,70 @@ void main() { }); test('select from uppercase table name', () async { - final res = await postgrest.from('TestTable').select(); - expect(res.length, 2); + final response = await postgrest.from('TestTable').select(); + expect(response.length, 2); }); test('insert from uppercase table name', () async { - final res = await postgrest.from('TestTable').insert([ + final response = await postgrest.from('TestTable').insert([ {'slug': 'new slug'}, ]).select(); expect( - (res.first)['slug'], + (response.first)['slug'], 'new slug', ); }); test('delete from uppercase table name', () async { - final res = await postgrest + final response = await postgrest .from('TestTable') .delete() .eq('slug', 'new slug') .select() .count(CountOption.exact); - expect(res.count, 1); + expect(response.count, 1); }); test('withConverter', () async { - final res = await postgrest + final response = await postgrest .from('users') .select() .withConverter((data) => [data]); - expect(res, isNotEmpty); - expect(res.first, isNotEmpty); - expect(res.first, isA()); + expect(response, isNotEmpty); + expect(response.first, isNotEmpty); + expect(response.first, isA>()); }); test('withConverter and count', () async { - final res = await postgrest + final response = await postgrest .from('users') .select() .count(CountOption.exact) .withConverter((data) => [data]); - expect(res.data.first, isNotEmpty); - expect(res.data.first, isA()); - expect(res.count, greaterThan(3)); + expect(response.data.first, isNotEmpty); + expect(response.data.first, isA>()); + expect(response.count, greaterThan(3)); + }); + + test('aborts long-running function call', () async { + final startTime = DateTime.now(); + + final completer = Completer(); + // Abort after 1 second (before the 10-second function completes) + Timer(Duration(seconds: 1), () => completer.complete()); + + await expectLater( + () => postgrest + .rpc('long_running_task') + .select() + .abortSignal(completer.future), + throwsA(isA()), + ); + + final elapsedTime = DateTime.now().difference(startTime); + + expect(elapsedTime.inSeconds, lessThan(5)); + expect(elapsedTime.inSeconds, greaterThanOrEqualTo(1)); }); }); group("Custom http client", () { diff --git a/packages/postgrest/test/filter_test.dart b/packages/postgrest/test/filter_test.dart index 008105c1c..15387ef4d 100644 --- a/packages/postgrest/test/filter_test.dart +++ b/packages/postgrest/test/filter_test.dart @@ -22,46 +22,46 @@ void main() { }); test('not', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('status') .not('status', 'eq', 'OFFLINE'); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect(item['status'], isNot('OFFLINE')); } }); test('not with in filter', () async { - final res = await postgrest.from('users').select('username').not( + final response = await postgrest.from('users').select('username').not( 'username', 'in', ['supabot', 'kiwicopple'], ); - expect(res, isNotEmpty); + expect(response, isNotEmpty); - for (final item in res) { + for (final item in response) { expect(item['username'], isNot('supabot')); expect(item['username'], isNot('kiwicopple')); } }); test('not with is null', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .not('username', 'is', null); - expect(res.length, 4); + expect(response.length, 4); }); test('not with List of values', () async { - final res = await postgrest.from('users').select('status').not( + final response = await postgrest.from('users').select('status').not( 'interests', 'cs', ['baseball', 'basketball'], ); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect( ((item['interests'] ?? []) as List).contains([ 'baseball', @@ -73,13 +73,13 @@ void main() { }); test('or', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('status, username') .or('status.eq.OFFLINE,username.eq.supabot'); - expect(res, isNotEmpty); + expect(response, isNotEmpty); - for (final item in res) { + for (final item in response) { expect( item['username'] == ('supabot') || item['status'] == ('OFFLINE'), isTrue, @@ -89,25 +89,25 @@ void main() { group("eq", () { test('eq string', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .eq('username', 'supabot'); - expect(res, isNotEmpty); + expect(response, isNotEmpty); - for (final item in res) { + for (final item in response) { expect(item['username'], 'supabot'); } }); test('eq list', () async { - final res = await postgrest.from('users').select('username').eq( + final response = await postgrest.from('users').select('username').eq( 'interests', ["basketball", "baseball"], ); - expect(res, isNotEmpty); + expect(response, isNotEmpty); - for (final item in res) { + for (final item in response) { expect(item['username'], 'supabot'); } }); @@ -115,93 +115,93 @@ void main() { group("neq", () { test('neq string', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .neq('username', 'supabot'); - expect(res, isNotEmpty); + expect(response, isNotEmpty); - for (final item in res) { + for (final item in response) { expect(item['username'], isNot('supabot')); } }); test('neq list', () async { - final res = await postgrest.from('users').select('username').neq( + final response = await postgrest.from('users').select('username').neq( 'interests', ["football"], ); - expect(res, isNotEmpty); + expect(response, isNotEmpty); - final onlyNames = res.map((row) => row["username"]).toList(); + final onlyNames = response.map((row) => row["username"]).toList(); expect(onlyNames, ["supabot", "awailas"]); }); }); test('gt', () async { - final res = await postgrest.from('messages').select('id').gt('id', 1); - expect(res, isNotEmpty); + final response = await postgrest.from('messages').select('id').gt('id', 1); + expect(response, isNotEmpty); - for (final item in res) { + for (final item in response) { expect((item['id'] as int) > 1, isTrue); } }); test('gte', () async { - final res = await postgrest.from('messages').select('id').gte('id', 1); - expect(res, isNotEmpty); + final response = await postgrest.from('messages').select('id').gte('id', 1); + expect(response, isNotEmpty); - for (final item in res) { + for (final item in response) { expect((item['id'] as int) < 1, isFalse); } }); test('lt', () async { - final res = await postgrest.from('messages').select('id').lt('id', 2); - expect(res, isNotEmpty); - for (final item in res) { + final response = await postgrest.from('messages').select('id').lt('id', 2); + expect(response, isNotEmpty); + for (final item in response) { expect((item['id'] as int) < 2, isTrue); } }); test('lte', () async { - final res = await postgrest.from('messages').select('id').lte('id', 2); - expect(res, isNotEmpty); - for (final item in res) { + final response = await postgrest.from('messages').select('id').lte('id', 2); + expect(response, isNotEmpty); + for (final item in response) { expect((item['id'] as int) > 2, isFalse); } }); test('like', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .like('username', '%supa%'); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect((item['username'] as String).contains('supa'), isTrue); } }); test('likeAllOf', () async { - PostgrestList res = await postgrest + PostgrestList response = await postgrest .from('users') .select('username') .likeAllOf('username', ['%supa%', '%bot%']); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect(item['username'], contains('supa')); expect(item['username'], contains('bot')); } }); test('likeAnyOf', () async { - PostgrestList res = await postgrest + PostgrestList response = await postgrest .from('users') .select('username') .likeAnyOf('username', ['%supa%', '%wai%']); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect( item['username'].contains('supa') || item['username'].contains('wai'), isTrue, @@ -210,36 +210,36 @@ void main() { }); test('ilike', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .ilike('username', '%SUPA%'); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { final user = (item['username'] as String).toLowerCase(); expect(user.contains('supa'), isTrue); } }); test('ilikeAllOf', () async { - PostgrestList res = await postgrest + PostgrestList response = await postgrest .from('users') .select('username') .ilikeAllOf('username', ['%SUPA%', '%bot%']); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect(item['username'].toLowerCase(), contains('supa')); expect(item['username'].toLowerCase(), contains('bot')); } }); test('ilikeAnyOf', () async { - PostgrestList res = await postgrest + PostgrestList response = await postgrest .from('users') .select('username') .ilikeAnyOf('username', ['%SUPA%', '%wai%']); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect( item['username'].toLowerCase().contains('supa') || item['username'].toLowerCase().contains('wai'), @@ -249,23 +249,23 @@ void main() { }); test('is', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('data') .isFilter('data', null); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect(item['data'], null); } }); test('in', () async { - final res = await postgrest.from('users').select('status').inFilter( + final response = await postgrest.from('users').select('status').inFilter( 'status', ['ONLINE', 'OFFLINE'], ); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect( item['status'] == 'ONLINE' || item['status'] == 'OFFLINE', isTrue, @@ -275,144 +275,153 @@ void main() { test('immutable filter', () async { final query = postgrest.from("users").select(); - final res1 = await query.eq("status", "OFFLINE"); - final res2 = await query.eq("username", "supabot"); + final firstResponse = await query.eq("status", "OFFLINE"); + final secondResponse = await query.eq("username", "supabot"); - expect(res1.length, 1); - expect(res1.first, containsPair("status", "OFFLINE")); - expect(res2.length, 1); - expect(res2.first, containsPair("username", "supabot")); + expect(firstResponse.length, 1); + expect(firstResponse.first, containsPair("status", "OFFLINE")); + expect(secondResponse.length, 1); + expect(secondResponse.first, containsPair("username", "supabot")); }); group("contains", () { test('contains range', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .contains('age_range', '[1,2)'); - expect(res, isNotEmpty); + expect(response, isNotEmpty); expect( - (res[0])['username'], + (response[0])['username'], 'supabot', ); }); test('contains list', () async { - final res = await postgrest.from('users').select('username').contains( - 'interests', - ["basketball", "baseball"], - ); - expect(res, isNotEmpty); + final response = await postgrest + .from('users') + .select('username') + .contains( + 'interests', + ["basketball", "baseball"], + ); + expect(response, isNotEmpty); expect( - (res[0])['username'], + (response[0])['username'], 'supabot', ); }); }); group("containedBy", () { test('containedBy range', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .containedBy('age_range', '[0,3)'); - expect(res, isNotEmpty); - expect((res[0])['username'], 'supabot'); + expect(response, isNotEmpty); + expect((response[0])['username'], 'supabot'); }); test('containedBy list', () async { - final res = await postgrest.from('users').select('username').containedBy( - 'interests', - ["basketball", "baseball", "xxxx"], - ); - expect(res, isNotEmpty); - expect(res[0]['username'], 'supabot'); + final response = await postgrest + .from('users') + .select('username') + .containedBy( + 'interests', + ["basketball", "baseball", "xxxx"], + ); + expect(response, isNotEmpty); + expect(response[0]['username'], 'supabot'); }); }); test('rangeLt', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .rangeLt('age_range', '[2,25)'); - expect(res, isNotEmpty); - expect(res[0]['username'], 'supabot'); + expect(response, isNotEmpty); + expect(response[0]['username'], 'supabot'); }); test('rangeGt', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('age_range') .rangeGt('age_range', '[2,25)'); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect(item['username'], isNot('supabot')); } }); test('rangeGte', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('age_range') .rangeGte('age_range', '[2,25)'); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect(item['username'], isNot('supabot')); } }); test('rangeLte', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .rangeLte('age_range', '[2,25)'); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect(item['username'], 'supabot'); } }); test('rangeAdjacent', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('age_range') .rangeAdjacent('age_range', '[2,25)'); - expect(res.length, 3); + expect(response.length, 3); }); group("overlap", () { test('overlaps range', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .overlaps('age_range', '[2,25)'); expect( - (res[0])['username'], + (response[0])['username'], 'dragarcia', ); }); test('overlaps list', () async { - final res = await postgrest.from('users').select('username').overlaps( - 'interests', - ["basketball", "baseball"], - ); + final response = await postgrest + .from('users') + .select('username') + .overlaps( + 'interests', + ["basketball", "baseball"], + ); expect( - (res[0])['username'], + (response[0])['username'], 'supabot', ); }); }); test('textSearch', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .textSearch('catchphrase', "'fat' & 'cat'", config: 'english'); - expect(res[0]['username'], 'supabot'); + expect(response[0]['username'], 'supabot'); }); test('textSearch with plainto_tsquery', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .textSearch( @@ -421,11 +430,11 @@ void main() { config: 'english', type: TextSearchType.plain, ); - expect(res[0]['username'], 'supabot'); + expect(response[0]['username'], 'supabot'); }); test('textSearch with phraseto_tsquery', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .textSearch( @@ -434,11 +443,11 @@ void main() { config: 'english', type: TextSearchType.phrase, ); - expect(res.length, 2); + expect(response.length, 2); }); test('textSearch with websearch_to_tsquery', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .textSearch( @@ -447,11 +456,11 @@ void main() { config: 'english', type: TextSearchType.websearch, ); - expect(res[0]['username'], 'supabot'); + expect(response[0]['username'], 'supabot'); }); test('multiple filters', () async { - final res = await postgrest + final response = await postgrest .from('users') .select() .eq('username', 'supabot') @@ -459,26 +468,26 @@ void main() { .overlaps('age_range', '[1,2)') .eq('status', 'ONLINE') .textSearch('catchphrase', 'cat'); - expect(res[0]['username'], 'supabot'); + expect(response[0]['username'], 'supabot'); }); group("filter", () { test('filter', () async { - final res = await postgrest + final response = await postgrest .from('users') .select() .filter('username', 'eq', 'supabot'); - expect(res[0]['username'], 'supabot'); + expect(response[0]['username'], 'supabot'); }); test('filter in with List of values', () async { - final res = await postgrest.from('users').select().filter( + final response = await postgrest.from('users').select().filter( 'username', 'in', ['supabot', 'kiwicopple'], ); - expect(res.length, 2); - for (final item in res) { + expect(response.length, 2); + for (final item in response) { expect( item['username'] == 'supabot' || item['username'] == 'kiwicopple', isTrue, @@ -488,31 +497,31 @@ void main() { }); test('match', () async { - final res = await postgrest.from('users').select().match({ + final response = await postgrest.from('users').select().match({ 'username': 'supabot', 'status': 'ONLINE', }); - expect(res[0]['username'], 'supabot'); + expect(response[0]['username'], 'supabot'); }); test('matchRegex - regex match (case sensitive)', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .matchRegex('username', '^supa.*'); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect((item['username'] as String).startsWith('supa'), isTrue); } }); test('imatchRegex - regex match (case insensitive)', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username') .imatchRegex('username', '^SUPA.*'); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect( (item['username'] as String).toLowerCase().startsWith('supa'), isTrue, @@ -521,37 +530,37 @@ void main() { }); test('isDistinct', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username,status') .isDistinct('status', 'ONLINE'); - expect(res, isNotEmpty); - for (final item in res) { + expect(response, isNotEmpty); + for (final item in response) { expect(item['status'], isNot('ONLINE')); } }); test('filter on rpc', () async { - final List res = await postgrest + final List response = await postgrest .rpc('get_username_and_status', params: {'name_param': 'supabot'}) .neq('status', 'ONLINE'); - expect(res, isEmpty); + expect(response, isEmpty); }); test('date range filter 1', () async { - final res = await postgrest + final response = await postgrest .from('messages') .select() .gte('inserted_at', DateTime.parse('2021-06-24').toIso8601String()) .lte('inserted_at', DateTime.parse('2021-06-26').toIso8601String()); - expect(res.length, 1); + expect(response.length, 1); }); test('date range filter 2', () async { - final res = await postgrest + final response = await postgrest .from('messages') .select() .gte('inserted_at', DateTime.parse('2021-06-24').toIso8601String()) .lte('inserted_at', DateTime.parse('2021-06-30').toIso8601String()); - expect(res.length, 2); + expect(response.length, 2); }); } diff --git a/packages/postgrest/test/resource_embedding_test.dart b/packages/postgrest/test/resource_embedding_test.dart index 8630b5601..0b2084854 100644 --- a/packages/postgrest/test/resource_embedding_test.dart +++ b/packages/postgrest/test/resource_embedding_test.dart @@ -22,129 +22,129 @@ void main() { }); test('embedded select', () async { - final res = await postgrest.from('users').select('messages(*)'); + final response = await postgrest.from('users').select('messages(*)'); expect( - res[0]['messages']!.length, + response[0]['messages']!.length, 3, ); expect( - res[1]['messages']!.length, + response[1]['messages']!.length, 0, ); }); test('embedded eq', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('messages(*)') .eq('messages.channel_id', 1); expect( - res[0]['messages']!.length, + response[0]['messages']!.length, 2, ); expect( - res[1]['messages']!.length, + response[1]['messages']!.length, 0, ); expect( - res[2]['messages']!.length, + response[2]['messages']!.length, 0, ); expect( - res[3]['messages']!.length, + response[3]['messages']!.length, 0, ); }); test('embedded order', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('messages(*)') .order('channel_id', referencedTable: 'messages'); expect( - res[0]['messages']!.length, + response[0]['messages']!.length, 3, ); expect( - res[1]['messages']!.length, + response[1]['messages']!.length, 0, ); expect( - res[0]['messages']![0]['id'], + response[0]['messages']![0]['id'], 2, ); }); test('embedded order on multiple columns', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('username, messages(*)') .order('username', ascending: true) .order('channel_id', referencedTable: 'messages'); expect( - res[0]['username'], + response[0]['username'], 'awailas', ); expect( - res[3]['username'], + response[3]['username'], 'supabot', ); expect( - (res[0]['messages'] as List).length, + (response[0]['messages'] as List).length, 0, ); expect( - (res[3]['messages'] as List).length, + (response[3]['messages'] as List).length, 3, ); expect( - (res[3]['messages'] as List)[0]['id'], + (response[3]['messages'] as List)[0]['id'], 2, ); }); test('embedded limit', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('messages(*)') .limit(1, referencedTable: 'messages'); expect( - res[0]['messages']!.length, + response[0]['messages']!.length, 1, ); expect( - res[1]['messages']!.length, + response[1]['messages']!.length, 0, ); expect( - res[2]['messages']!.length, + response[2]['messages']!.length, 0, ); expect( - res[3]['messages']!.length, + response[3]['messages']!.length, 0, ); }); test('embedded range', () async { - final res = await postgrest + final response = await postgrest .from('users') .select('messages(*)') .range(1, 1, referencedTable: 'messages'); expect( - res[0]['messages']!.length, + response[0]['messages']!.length, 1, ); expect( - res[1]['messages']!.length, + response[1]['messages']!.length, 0, ); expect( - res[2]['messages']!.length, + response[2]['messages']!.length, 0, ); expect( - res[3]['messages']!.length, + response[3]['messages']!.length, 0, ); }); diff --git a/packages/postgrest/test/retry_test.dart b/packages/postgrest/test/retry_test.dart index 635c3bab8..25649a559 100644 --- a/packages/postgrest/test/retry_test.dart +++ b/packages/postgrest/test/retry_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; @@ -8,35 +9,43 @@ import 'package:test/test.dart'; typedef _ResponseFactory = Future Function(BaseRequest); _ResponseFactory _ok() => - (req) => Future.value( + (request) => Future.value( StreamedResponse( Stream.value(Uint8List.fromList('[]'.codeUnits)), 200, - request: req, + request: request, headers: {'content-type': 'application/json'}, ), ); _ResponseFactory _status(int code) => - (req) => Future.value( + (request) => Future.value( StreamedResponse( Stream.value( Uint8List.fromList('{"message":"err","code":"$code"}'.codeUnits), ), code, - request: req, + request: request, headers: {'content-type': 'application/json'}, ), ); _ResponseFactory _networkError() => - (_) => throw const SocketException('Connection refused'); + (_) => Future.error( + const SocketException('Connection refused'), + StackTrace.current, + ); class _MockRetryClient extends BaseClient { final List<_ResponseFactory> _responses; + final Duration Function(int index) _responseLatency; final List requests = []; - _MockRetryClient(this._responses); + _MockRetryClient( + this._responses, { + Duration Function(int index)? responseLatency, + }) : _responseLatency = + responseLatency ?? ((_) => const Duration(milliseconds: 200)); int get callCount => requests.length; @@ -49,18 +58,43 @@ class _MockRetryClient extends BaseClient { 'Unexpected call #${index + 1}, only ${_responses.length} configured', ); } - return _responses[index](request); + + final completer = Completer(); + if (request is AbortableRequest) { + unawaited( + request.abortTrigger?.then((_) { + if (!completer.isCompleted) { + completer.completeError( + RequestAbortedException(), + StackTrace.current, + ); + } + }), + ); + } + unawaited( + Future.delayed(_responseLatency(index)).then((_) { + if (!completer.isCompleted) { + completer.complete(_responses[index](request)); + } + }), + ); + return await completer.future; } } PostgrestClient _buildClient( _MockRetryClient mock, { bool retryEnabled = true, + int retryCount = 3, + Set retryableStatusCodes = const {503, 520}, }) { return PostgrestClient( 'http://localhost:3000', httpClient: mock, retryEnabled: retryEnabled, + retryCount: retryCount, + retryableStatusCodes: retryableStatusCodes, retryDelay: (_) => Duration.zero, ); } @@ -89,11 +123,11 @@ void main() { test('HEAD retries on 520 then succeeds', () async { final mock = _MockRetryClient([ _status(520), - (req) => Future.value( + (request) => Future.value( StreamedResponse( Stream.empty(), 200, - request: req, + request: request, headers: {'content-range': '*/4'}, ), ), @@ -235,5 +269,248 @@ void main() { expect(mock.callCount, 4); }, ); + + test( + 'GET retries on 520 but aborts before exhausting all retries', + () async { + final mock = _MockRetryClient([_status(520), _status(520), _ok()]); + final client = _buildClient(mock); + + final completer = Completer(); + // Abort after the first retry + Timer(Duration(milliseconds: 300), () => completer.complete()); + + await expectLater( + () => client + .from('users') + .select() + .retry(enabled: true) + .abortSignal(completer.future), + throwsA(isA()), + ); + + // Verify that only 1 retry was made before abort + // (not all 3 retries exhausted) + expect(mock.callCount, 2); + }, + ); + }); + + group('configurable retry count', () { + test('client retryCount limits the number of retries', () async { + final mock = _MockRetryClient([ + _status(520), + _status(520), + _status(520), + ]); + final client = _buildClient(mock, retryCount: 1); + + await expectLater( + () => client.from('users').select(), + throwsA(isA()), + ); + // Initial attempt + 1 retry. + expect(mock.callCount, 2); + }); + + test('retryCount: 0 disables retries', () async { + final mock = _MockRetryClient([_status(520)]); + final client = _buildClient(mock, retryCount: 0); + + await expectLater( + () => client.from('users').select(), + throwsA(isA()), + ); + expect(mock.callCount, 1); + }); + + test('.retry(count:) overrides the retry count per request', () async { + final mock = _MockRetryClient([_status(520), _status(520), _ok()]); + final client = _buildClient(mock, retryCount: 1); + + final result = await client.from('users').select().retry(count: 5); + + expect(result, isEmpty); + expect(mock.callCount, 3); + }); + + test('negative retryCount throws ArgumentError', () { + expect( + () => PostgrestClient('http://localhost:3000', retryCount: -1), + throwsA(isA()), + ); + }); + }); + + group('configurable retryable status codes', () { + test('retries on a custom status code', () async { + final mock = _MockRetryClient([_status(500), _ok()]); + final client = _buildClient(mock, retryableStatusCodes: {500}); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 2); + }); + + test('does not retry on a status code outside the custom set', () async { + final mock = _MockRetryClient([_status(520)]); + final client = _buildClient(mock, retryableStatusCodes: {500}); + + await expectLater( + () => client.from('users').select(), + throwsA(isA()), + ); + expect(mock.callCount, 1); + }); + + test('mutating the provided set does not affect retry behavior', () async { + final mock = _MockRetryClient([_status(500), _ok()]); + final statusCodes = {500}; + final client = _buildClient(mock, retryableStatusCodes: statusCodes); + + statusCodes.clear(); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 2); + }); + }); + + group('retry config propagation through builder chain', () { + test( + 'count() preserves custom retryCount and retryableStatusCodes', + () async { + _ResponseFactory okWithCount() => + (req) => Future.value( + StreamedResponse( + Stream.value(Uint8List.fromList('[]'.codeUnits)), + 200, + request: req, + headers: { + 'content-type': 'application/json', + 'content-range': '0-0/0', + }, + ), + ); + final mock = _MockRetryClient([ + _status(500), + _status(500), + okWithCount(), + ]); + final client = _buildClient( + mock, + retryCount: 5, + retryableStatusCodes: {500}, + ); + + await client.from('users').select().count(CountOption.exact); + + expect(mock.callCount, 3); + }, + ); + }); + + group('request timeout', () { + test('a timed-out attempt is retried, not hard-stopped', () async { + // Every attempt takes 200ms while the timeout is 50ms, so each attempt + // times out and is retried until the retries are exhausted. + final mock = _MockRetryClient([_ok(), _ok(), _ok()]); + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + retryCount: 2, + requestTimeout: const Duration(milliseconds: 50), + retryDelay: (_) => Duration.zero, + ); + + await expectLater( + () => client.from('users').select(), + throwsA(isA()), + ); + // Initial attempt plus 2 retries, so the timeout did not stop retrying. + expect(mock.callCount, 3); + }); + + test( + 'retries recover once an attempt completes within the timeout', + () async { + // First attempt is slower than the timeout, the second is fast. + final mock = _MockRetryClient( + [_ok(), _ok()], + responseLatency: (index) => + index == 0 ? const Duration(milliseconds: 300) : Duration.zero, + ); + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + requestTimeout: const Duration(milliseconds: 100), + retryDelay: (_) => Duration.zero, + ); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 2); + }, + ); + + test('does not time out a request that completes in time', () async { + final mock = _MockRetryClient([_ok()]); + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + requestTimeout: const Duration(seconds: 5), + ); + + final result = await client.from('users').select(); + + expect(result, isEmpty); + expect(mock.callCount, 1); + }); + + test('.retry(requestTimeout:) overrides the timeout per request', () async { + // The client has no timeout, but the per-request override adds one that + // is shorter than every attempt, so each attempt times out and is retried. + final mock = _MockRetryClient([_ok(), _ok()]); + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + retryCount: 1, + retryDelay: (_) => Duration.zero, + ); + + await expectLater( + () => client + .from('users') + .select() + .retry(requestTimeout: const Duration(milliseconds: 50)), + throwsA(isA()), + ); + // Initial attempt plus 1 retry. + expect(mock.callCount, 2); + }); + + test('a manual abortSignal stops retrying immediately', () async { + final mock = _MockRetryClient([_status(520), _status(520), _ok()]); + final client = PostgrestClient( + 'http://localhost:3000', + httpClient: mock, + requestTimeout: const Duration(seconds: 5), + retryDelay: (_) => Duration.zero, + ); + + final abort = Completer(); + // Abort during the second attempt. + Timer(const Duration(milliseconds: 300), abort.complete); + + await expectLater( + () => client.from('users').select().abortSignal(abort.future), + throwsA(isA()), + ); + // Stopped mid-operation instead of exhausting all retries. + expect(mock.callCount, 2); + }); }); } diff --git a/packages/postgrest/test/stack_trace_test.dart b/packages/postgrest/test/stack_trace_test.dart index a98aa2e35..9b84fc34f 100644 --- a/packages/postgrest/test/stack_trace_test.dart +++ b/packages/postgrest/test/stack_trace_test.dart @@ -6,13 +6,13 @@ import 'package:postgrest/postgrest.dart'; import 'package:test/test.dart'; _ResponseFactory _errorStatus(int code) => - (req) => Future.value( + (request) => Future.value( StreamedResponse( Stream.value( Uint8List.fromList('{"message":"err","code":"$code"}'.codeUnits), ), code, - request: req, + request: request, headers: {'content-type': 'application/json'}, ), ); diff --git a/packages/postgrest/test/transforms_test.dart b/packages/postgrest/test/transforms_test.dart index 74f71c008..9455ead5a 100644 --- a/packages/postgrest/test/transforms_test.dart +++ b/packages/postgrest/test/transforms_test.dart @@ -26,22 +26,22 @@ void main() { }); test('order', () async { - final res = await postgrest.from('users').select().order('username'); + final response = await postgrest.from('users').select().order('username'); expect( - res[1]['username'], + response[1]['username'], 'kiwicopple', ); - expect(res[3]['username'], 'awailas'); + expect(response[3]['username'], 'awailas'); }); test('order on multiple columns', () async { - final res = await postgrest + final response = await postgrest .from('users') .select() .order('status', ascending: true) .order('username'); expect( - res.map((row) => row['status']), + response.map((row) => row['status']), [ 'ONLINE', 'ONLINE', @@ -50,7 +50,7 @@ void main() { ], ); expect( - res.map((row) => row['username']), + response.map((row) => row['username']), [ 'supabot', 'dragarcia', @@ -61,14 +61,14 @@ void main() { }); test('order with filters on the same column', () async { - final res = await postgrest + final response = await postgrest .from('users') .select() .gt('username', 'b') .lt('username', 'r') .order('username'); expect( - res.map((row) => row['username']), + response.map((row) => row['username']), [ 'kiwicopple', 'dragarcia', @@ -110,8 +110,8 @@ void main() { }); test('limit', () async { - final res = await postgrest.from('users').select().limit(1); - expect(res.length, 1); + final response = await postgrest.from('users').select().limit(1); + expect(response.length, 1); }); test("limit on referenced table", () async { @@ -147,19 +147,19 @@ void main() { test('range', () async { const from = 1; const to = 2; - final res = await postgrest.from('users').select().range(from, to); + final response = await postgrest.from('users').select().range(from, to); //from -1 so that the index is included - expect(res.length, to - (from - 1)); - expect(res[0]['username'], 'kiwicopple'); - expect(res[1]['username'], 'awailas'); + expect(response.length, to - (from - 1)); + expect(response[0]['username'], 'kiwicopple'); + expect(response[1]['username'], 'awailas'); }); test('range 1-1', () async { const from = 1; const to = 1; - final res = await postgrest.from('users').select().range(from, to); + final response = await postgrest.from('users').select().range(from, to); //from -1 so that the index is included - expect(res.length, to - (from - 1)); + expect(response.length, to - (from - 1)); }); test("range on referenced table", () async { @@ -335,24 +335,24 @@ void main() { }); test('single', () async { - final res = await postgrest + final response = await postgrest .from('users') .select() .eq('username', 'supabot') .single(); - expect(res['username'], 'supabot'); - expect(res['status'], 'ONLINE'); + expect(response['username'], 'supabot'); + expect(response['status'], 'ONLINE'); }); test('single with count', () async { - final res = await postgrest + final response = await postgrest .from('users') .select() .limit(1) .single() .count(CountOption.exact); - expect(res.data, isA()); - expect(res.count, greaterThan(3)); + expect(response.data, isA>()); + expect(response.count, greaterThan(3)); }); group("maybe single", () { @@ -438,13 +438,13 @@ void main() { }); test('explain', () async { - final res = await postgrest.from('users').select().explain(); + final response = await postgrest.from('users').select().explain(); final regex = RegExp(r'Aggregate \(cost=.*'); - expect(regex.hasMatch(res), isTrue); + expect(regex.hasMatch(response), isTrue); }); test('explain with options', () async { - final res = await postgrest + final response = await postgrest .from('users') .select() .explain( @@ -452,23 +452,23 @@ void main() { verbose: true, ); final regex = RegExp(r'Aggregate \(cost=.*'); - expect(regex.hasMatch(res), isTrue); + expect(regex.hasMatch(response), isTrue); }); test('explain with json format returns a parseable JSON plan', () async { - final res = await postgrest + final response = await postgrest .from('users') .select() .explain(format: ExplainFormat.json); - final decoded = jsonDecode(res); - expect(decoded, isA()); + final decoded = jsonDecode(response); + expect(decoded, isA>()); expect((decoded as List).first, contains('Plan')); }); test('geojson', () async { - final res = await postgrest.from('addresses').select().geojson(); - expect(res['type'], 'FeatureCollection'); + final response = await postgrest.from('addresses').select().geojson(); + expect(response['type'], 'FeatureCollection'); }); group('maxAffected integration', () { @@ -571,4 +571,82 @@ void main() { }, ); }); + + group('dryRun', () { + late CustomHttpClient customHttpClient; + late PostgrestClient postgrestCustomHttpClient; + + setUp(() { + customHttpClient = CustomHttpClient(); + postgrestCustomHttpClient = PostgrestClient( + rootUrl, + headers: apiHeaders, + httpClient: customHttpClient, + ); + }); + + test('sets tx=rollback in the Prefer header', () async { + try { + await postgrestCustomHttpClient.from('users').insert({ + 'username': 'dry', + }).dryRun(); + } catch (_) {} + + expect( + customHttpClient.lastRequest!.headers['Prefer'], + contains('tx=rollback'), + ); + }); + + test('preserves existing Prefer preferences', () async { + try { + await postgrestCustomHttpClient + .from('users') + .insert({'username': 'dry'}) + .select() + .dryRun(); + } catch (_) {} + + final prefer = customHttpClient.lastRequest!.headers['Prefer']!; + expect(prefer, contains('return=representation')); + expect(prefer, contains('tx=rollback')); + expect(prefer, isNot(startsWith(','))); + }); + }); + + group('stripNulls', () { + late CustomHttpClient customHttpClient; + late PostgrestClient postgrestCustomHttpClient; + + setUp(() { + customHttpClient = CustomHttpClient(); + postgrestCustomHttpClient = PostgrestClient( + rootUrl, + headers: apiHeaders, + httpClient: customHttpClient, + ); + }); + + test('appends nulls=stripped to the Accept header', () async { + try { + await postgrestCustomHttpClient.from('users').select().stripNulls(); + } catch (_) {} + + expect( + customHttpClient.lastRequest!.headers['Accept'], + 'application/json;nulls=stripped', + ); + }); + + test('omits null-valued properties from the response', () async { + final response = await postgrest + .from('users') + .select() + .eq('username', 'supabot') + .single() + .stripNulls(); + expect(response.containsKey('username'), isTrue); + expect(response.containsKey('data'), isFalse); + }); + }); } diff --git a/packages/realtime_client/CHANGELOG.md b/packages/realtime_client/CHANGELOG.md index ae55cc93c..eeacf8ca0 100644 --- a/packages/realtime_client/CHANGELOG.md +++ b/packages/realtime_client/CHANGELOG.md @@ -1,3 +1,15 @@ +## 2.12.0 + + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **FIX**(realtime_client): faster channel rejoin on web ([#1471](https://github.com/supabase/supabase-flutter/issues/1471)). ([bc06a310](https://github.com/supabase/supabase-flutter/commit/bc06a310cf09bdba87668695b48bc8b8f353c495)) + - **FIX**(realtime): patch channel join payloads with access token before flushing ([#1553](https://github.com/supabase/supabase-flutter/issues/1553)). ([e5de0c07](https://github.com/supabase/supabase-flutter/commit/e5de0c075a36129e75462d15b61d0da571e99bc9)) + - **FEAT**(realtime): defer socket disconnect when channels become empty ([#1589](https://github.com/supabase/supabase-flutter/issues/1589)). ([0055d1dc](https://github.com/supabase/supabase-flutter/commit/0055d1dcb6f9a0d2727d908bc68077af707327d0)) + +## 2.11.0 + + - **FEAT**(realtime_client): support new postgres changes filter operators, multi-filter, column selection, and replication-ready events ([#1526](https://github.com/supabase/supabase-flutter/issues/1526)). ([1f9d2951](https://github.com/supabase/supabase-flutter/commit/1f9d29516032c1f4bd8416fd7fa36737a335afaf)) + ## 2.10.0 - **FIX**(realtime): preserve nested empty maps in Message.toJson() ([#1518](https://github.com/supabase/supabase-flutter/issues/1518)). ([1777a225](https://github.com/supabase/supabase-flutter/commit/1777a225b0bf150a089abc06fbeb9b14cd6ace8d)) diff --git a/packages/realtime_client/README.md b/packages/realtime_client/README.md index 9855ddee3..2dd5aa14e 100644 --- a/packages/realtime_client/README.md +++ b/packages/realtime_client/README.md @@ -1,17 +1,34 @@ -# realtime_client +
+

+ + Supabase Logo + -Listens to changes in a PostgreSQL Database and via websockets. +

realtime_client

-A dart client for Supabase [Realtime](https://github.com/supabase/realtime) server. +

+ Dart client library for Supabase Realtime, to listen to changes in a PostgreSQL database over websockets. +

+ +

+ Guides + · + Reference Docs +

+

+ +
[![pub package](https://img.shields.io/pub/v/realtime_client.svg)](https://pub.dev/packages/realtime_client) -[![pub test](https://github.com/supabase/realtime-dart/workflows/Test/badge.svg)](https://github.com/supabase/realtime-dart/actions?query=workflow%3ATest) +[![pub test](https://github.com/supabase/supabase-flutter/workflows/Test/badge.svg)](https://github.com/supabase/supabase-flutter/actions?query=workflow%3ATest) + +
## Docs The docs can be found on the official Supabase website. -- [Dart reference](https://supabase.com/docs/reference/dart/stream) +- [Dart reference](https://supabase.com/docs/reference/dart/subscribe) - [Realtime guide](https://supabase.com/docs/guides/realtime) ## Testing @@ -33,10 +50,10 @@ dart test supabase stop ``` -## Credits - -- https://github.com/supabase/realtime-js - ported from realtime-js library - ## License This repo is licensed under MIT. + +## Credits + +- https://github.com/supabase/realtime-js - ported from realtime-js library diff --git a/packages/realtime_client/example/main.dart b/packages/realtime_client/example/main.dart index 95cad19d9..fe9bdf1ce 100644 --- a/packages/realtime_client/example/main.dart +++ b/packages/realtime_client/example/main.dart @@ -1,6 +1,6 @@ import 'package:realtime_client/realtime_client.dart'; -/// Example to use with Supabase Realtime https://supabase.io/ +/// Example to use with Supabase Realtime https://supabase.com/ Future main() async { final socket = RealtimeClient( 'ws://SUPABASE_API_ENDPOINT/realtime/v1', diff --git a/packages/realtime_client/lib/src/constants.dart b/packages/realtime_client/lib/src/constants.dart index 61b432333..6bd043120 100644 --- a/packages/realtime_client/lib/src/constants.dart +++ b/packages/realtime_client/lib/src/constants.dart @@ -1,11 +1,13 @@ import 'package:realtime_client/src/version.dart'; +import 'package:supabase_common/supabase_common.dart'; class Constants { static const Duration defaultTimeout = Duration(milliseconds: 10000); + static const Duration defaultConnectionCloseTimeout = Duration(seconds: 6); static const int defaultHeartbeatIntervalMs = 25000; static const int wsCloseNormal = 1000; - static const Map defaultHeaders = { - 'X-Client-Info': 'realtime-dart/$version', + static final Map defaultHeaders = { + 'X-Client-Info': buildClientInfoHeader('realtime-dart', version), }; } @@ -66,18 +68,18 @@ extension ChannelEventsExtended on ChannelEvents { throw 'No type $type exists'; } - String eventName() { - if (this == ChannelEvents.accessToken) { - return 'access_token'; - } else if (this == ChannelEvents.postgresChanges) { - return 'postgres_changes'; - } else if (this == ChannelEvents.broadcast) { - return 'broadcast'; - } else if (this == ChannelEvents.presence) { - return 'presence'; - } - return 'phx_$name'; - } + String eventName() => switch (this) { + ChannelEvents.accessToken => 'access_token', + ChannelEvents.postgresChanges => 'postgres_changes', + ChannelEvents.broadcast => 'broadcast', + ChannelEvents.presence => 'presence', + ChannelEvents.close || + ChannelEvents.error || + ChannelEvents.join || + ChannelEvents.reply || + ChannelEvents.leave || + ChannelEvents.heartbeat => 'phx_$name', + }; } class Transports { diff --git a/packages/realtime_client/lib/src/realtime_channel.dart b/packages/realtime_client/lib/src/realtime_channel.dart index a16ec26c0..11360c648 100644 --- a/packages/realtime_client/lib/src/realtime_channel.dart +++ b/packages/realtime_client/lib/src/realtime_channel.dart @@ -328,7 +328,10 @@ class RealtimeChannel { 'event': 'track', 'payload': payload, }, - opts: {'timeout': opts['timeout'] ?? _timeout}, + opts: { + 'timeout': _timeout, + ...opts, + }, ); } @@ -593,6 +596,13 @@ class RealtimeChannel { ) { final typeLower = type.toLowerCase(); + if ((isJoined || isJoining) && typeLower == 'postgres_changes') { + final message = + 'cannot add `$typeLower` callbacks for $topic after `subscribe()`.'; + socket.log('channel', message); + throw message; + } + final binding = Binding(typeLower, filter.toMap(), callback); if (_bindings[typeLower] != null) { diff --git a/packages/realtime_client/lib/src/realtime_client.dart b/packages/realtime_client/lib/src/realtime_client.dart index 68855307a..ba6ed51ef 100644 --- a/packages/realtime_client/lib/src/realtime_client.dart +++ b/packages/realtime_client/lib/src/realtime_client.dart @@ -105,6 +105,7 @@ class RealtimeClient { final Map params; final RealtimeProtocolVersion version; + final Duration connectionCloseTimeout; final Duration timeout; final WebSocketTransport transport; final Client? httpClient; @@ -112,6 +113,18 @@ class RealtimeClient { int heartbeatIntervalMs = Constants.defaultHeartbeatIntervalMs; Timer? heartbeatTimer; + /// Delay before the socket is disconnected once the last channel has been + /// removed. [Duration.zero] disconnects immediately. Defaults to twice the + /// heartbeat interval. + @internal + late final Duration disconnectOnEmptyChannelsAfter; + + /// Timer that fires the deferred disconnect once all channels are gone. + /// + /// Cancelled when a new channel is created or the client disconnects before + /// it fires, so that quickly switching channels reuses the open socket. + Timer? _pendingDisconnectTimer; + /// reference ID of the most recently sent heartbeat. /// /// Used to keep track of whether the client is connected to the server. @@ -126,8 +139,8 @@ class RealtimeClient { final RealtimeDecode decode; late TimerCalculation reconnectAfterMs; WebSocketChannel? conn; - StreamSubscription? _connectionSubscription; - List sendBuffer = []; + StreamSubscription? _connectionSubscription; + List sendBuffer = []; Map> stateChangeCallbacks = { 'open': [], 'close': [], @@ -149,7 +162,10 @@ class RealtimeClient { /// /// [transport] The Websocket Transport, for example WebSocket. /// - /// [timeout] The default timeout in milliseconds to trigger push timeouts. + /// [timeout] The default timeout to trigger push timeouts. + /// + /// [connectionCloseTimeout] The timeout to wait for the connection to close + /// before dismissing the result. Defaults to 6 seconds. /// /// [params] The optional params to pass when connecting. /// @@ -157,6 +173,12 @@ class RealtimeClient { /// /// [heartbeatIntervalMs] The millisec interval to send a heartbeat message. /// + /// [disconnectOnEmptyChannelsAfter] The delay before disconnecting the socket + /// once the last channel is removed. If a new channel is created before the + /// delay elapses, the pending disconnect is cancelled and the open socket is + /// reused. Pass [Duration.zero] to disconnect immediately. Defaults to twice + /// the heartbeat interval. + /// /// [logger] The optional function for specialized logging, ie: logger: (kind, message, data) => { console.log(`$kind: $message`, data) } /// /// [encode] Overrides how outgoing messages are serialized, for example to @@ -176,7 +198,9 @@ class RealtimeClient { String endPoint, { WebSocketTransport? transport, this.timeout = Constants.defaultTimeout, + this.connectionCloseTimeout = Constants.defaultConnectionCloseTimeout, this.heartbeatIntervalMs = Constants.defaultHeartbeatIntervalMs, + Duration? disconnectOnEmptyChannelsAfter, this.logger, RealtimeEncode? encode, RealtimeDecode? decode, @@ -217,6 +241,10 @@ class RealtimeClient { final customJWT = this.headers['Authorization']?.split(' ').last; accessToken = customJWT ?? params['apikey']; + this.disconnectOnEmptyChannelsAfter = + disconnectOnEmptyChannelsAfter ?? + Duration(milliseconds: 2 * heartbeatIntervalMs); + this.reconnectAfterMs = reconnectAfterMs ?? RetryTimer.createRetryFunction(); reconnectTimer = RetryTimer( @@ -294,6 +322,7 @@ class RealtimeClient { /// Disconnects the socket with status [code] and [reason] for the disconnect Future disconnect({int? code, String? reason}) async { + _cancelPendingDisconnect(); final conn = this.conn; if (conn != null) { final oldState = connState; @@ -308,17 +337,34 @@ class RealtimeClient { }, Level.FINE); } - // Connection cannot be closed while it's still connecting. Wait for connection to - // be ready and then close it. - if (oldState == SocketStates.connecting) { - await conn.ready.catchError((_) {}); - } - if (shouldCloseSink) { + onTimeout() { + log( + 'transport', + 'timeout while closing connection', + null, + Level.FINE, + ); + // Handle as the connection would have been closed successfully, to + // avoid hanging the client. This is done by mimicking the onDone + // callback of the connection stream. By canceling the subscription, + // we avoid calling the onDone too. + connState = SocketStates.disconnected; + _onConnClose(); + } + if (code != null) { - await conn.sink.close(code, reason ?? ''); + // Add a timeout to close the sink to avoid hanging in case something + // is wrong with the connection. + // The Dart SDK has a timeout of 5 seconds for closing the IO WebSocket connection, so we set a timeout of 6 seconds here to avoid hanging indefinitely. + await conn.sink + .close(code, reason ?? '') + .timeout(connectionCloseTimeout, onTimeout: onTimeout); } else { - await conn.sink.close(); + await conn.sink.close().timeout( + connectionCloseTimeout, + onTimeout: onTimeout, + ); } connState = SocketStates.disconnected; log('transport', 'disconnected', null, Level.FINE); @@ -345,9 +391,6 @@ class RealtimeClient { Future removeChannel(RealtimeChannel channel) async { final status = await channel.unsubscribe(); - if (channels.isEmpty) { - unawaited(disconnect()); - } return status; } @@ -355,7 +398,7 @@ class RealtimeClient { final values = await Future.wait( channels.map((channel) => channel.unsubscribe()), ); - unawaited(disconnect()); + await disconnect(); return values; } @@ -401,21 +444,13 @@ class RealtimeClient { _heartbeatController.stream; /// Returns the current state of the socket. - String get connectionState { - switch (connState) { - case SocketStates.connecting: - return 'connecting'; - case SocketStates.open: - return 'open'; - case SocketStates.disconnecting: - return 'disconnecting'; - case SocketStates.disconnected: - return 'disconnected'; - case SocketStates.closed: - case null: - return 'closed'; - } - } + String get connectionState => switch (connState) { + SocketStates.connecting => 'connecting', + SocketStates.open => 'open', + SocketStates.disconnecting => 'disconnecting', + SocketStates.disconnected => 'disconnected', + SocketStates.closed || null => 'closed', + }; /// Returns `true` is the connection is open. bool get isConnected => connState == SocketStates.open; @@ -424,6 +459,10 @@ class RealtimeClient { @internal void remove(RealtimeChannel channel) { channels = channels.where((c) => c.joinRef != channel.joinRef).toList(); + if (channels.isEmpty) { + log('transport', 'no channels remaining, scheduling disconnect'); + _schedulePendingDisconnect(); + } } RealtimeChannel channel( @@ -431,10 +470,52 @@ class RealtimeClient { RealtimeChannelConfig config = const RealtimeChannelConfig(), ]) { final newChannel = RealtimeChannel('realtime:$topic', this, params: config); + _cancelPendingDisconnect(); channels.add(newChannel); return newChannel; } + /// Schedules a disconnect once the last channel is removed. + /// + /// When [disconnectOnEmptyChannelsAfter] is [Duration.zero] the socket + /// disconnects immediately, otherwise the disconnect is deferred so that a + /// channel created within the delay can reuse the open socket. + void _schedulePendingDisconnect() { + _cancelPendingDisconnect(); + if (disconnectOnEmptyChannelsAfter == Duration.zero) { + log('transport', 'disconnecting immediately - no channels'); + unawaited(disconnect()); + return; + } + _pendingDisconnectTimer = Timer( + disconnectOnEmptyChannelsAfter, + () { + _pendingDisconnectTimer = null; + if (channels.isEmpty) { + log( + 'transport', + 'deferred disconnect fired - no channels, disconnecting', + ); + unawaited(disconnect()); + } + }, + ); + log( + 'transport', + 'deferred disconnect scheduled in ' + '${disconnectOnEmptyChannelsAfter.inMilliseconds}ms', + ); + } + + /// Cancels a scheduled disconnect when channel activity is detected. + void _cancelPendingDisconnect() { + if (_pendingDisconnectTimer != null) { + log('transport', 'pending disconnect cancelled - channel activity'); + _pendingDisconnectTimer!.cancel(); + _pendingDisconnectTimer = null; + } + } + /// Push out a message if the socket is connected. /// /// If the socket is not connected, the message gets enqueued within a local buffer, and sent out when a connection is next established. @@ -567,13 +648,24 @@ class RealtimeClient { void _onConnOpen() { log('transport', 'connected to $endPointURL'); log('transport', 'connected', null, Level.FINE); - _flushSendBuffer(); + unawaited(_resolveAccessTokenAndFlush()); reconnectTimer.reset(); if (heartbeatTimer != null) heartbeatTimer!.cancel(); heartbeatTimer = Timer.periodic( Duration(milliseconds: heartbeatIntervalMs), (Timer t) => unawaited(sendHeartbeat()), ); + + try { + for (final channel in channels) { + if (channel.isErrored) { + channel.rejoin(); + } + } + } catch (e) { + log('transport', 'error while rejoining channels', e, Level.WARNING); + } + for (final callback in stateChangeCallbacks['open']!) { callback(); } @@ -639,6 +731,38 @@ class RealtimeClient { } } + /// Resolves the access token before flushing the send buffer so that + /// buffered channel join payloads carry the correct token. + /// + /// When [RealtimeChannel.subscribe] runs before an asynchronous access token + /// has resolved (common when [customAccessToken] reads from async storage), + /// the buffered join payload has no `access_token`. That buffered message + /// captured the stale payload, so once auth has settled the join payloads are + /// patched with the resolved token, the stale buffered joins are dropped, and + /// the join is re-sent for any channel still joining. + Future _resolveAccessTokenAndFlush() async { + try { + if (customAccessToken != null) { + await setAuth(null); + if (accessToken != null) { + for (final channel in channels) { + channel.updateJoinPayload({'access_token': accessToken!}); + } + sendBuffer = []; + for (final channel in channels) { + if (channel.isJoining) { + channel.forceRejoin(); + } + } + } + } + } catch (error) { + log('transport', 'error resolving access token on connect', error); + } finally { + _flushSendBuffer(); + } + } + @internal Future sendHeartbeat() async { if (!isConnected) { diff --git a/packages/realtime_client/lib/src/realtime_presence.dart b/packages/realtime_client/lib/src/realtime_presence.dart index af594b222..5a567cdfe 100644 --- a/packages/realtime_client/lib/src/realtime_presence.dart +++ b/packages/realtime_client/lib/src/realtime_presence.dart @@ -62,8 +62,8 @@ class RealtimePresence { List> pendingDiffs = []; String? joinRef; Map caller = { - 'onJoin': (_, __, ___) {}, - 'onLeave': (_, __, ___) {}, + 'onJoin': (_, _, _) {}, + 'onLeave': (_, _, _) {}, 'onSync': () {}, }; @@ -225,9 +225,9 @@ class RealtimePresence { final joins = _transformState(diff['joins']); final leaves = _transformState(diff['leaves']); - onJoin ??= (_, __, ___) => {}; + onJoin ??= (_, _, _) => {}; - onLeave ??= (_, __, ___) => {}; + onLeave ??= (_, _, _) => {}; _map(joins, (key, newPresences) { final currentPresences = state[key] ?? []; diff --git a/packages/realtime_client/lib/src/serializer.dart b/packages/realtime_client/lib/src/serializer.dart index 5f92c8c49..707f08d36 100644 --- a/packages/realtime_client/lib/src/serializer.dart +++ b/packages/realtime_client/lib/src/serializer.dart @@ -90,16 +90,22 @@ class Serializer { Map message, Map payload, ) { - final topic = (message['topic'] ?? '') as String; - final ref = (message['ref'] ?? '') as String; - final joinRef = (message['join_ref'] ?? '') as String; - final userEvent = payload['event'] as String; final encodedPayload = _asBytes(payload['payload'])!; final rest = allowedMetadataKeys.isEmpty ? const {} : _pick(payload, allowedMetadataKeys); - final metadata = rest.isEmpty ? '' : jsonEncode(rest); + final metadataString = rest.isEmpty ? '' : jsonEncode(rest); + + // Encode each header field as UTF-8. The length prefixes are byte counts + // and the decode side uses utf8.decode, so measuring with String.length + // (UTF-16 code units) and writing one byte per unit would corrupt any + // multi-byte character (e.g. accents or emoji) and desynchronize the frame. + final topic = utf8.encode((message['topic'] ?? '') as String); + final ref = utf8.encode((message['ref'] ?? '') as String); + final joinRef = utf8.encode((message['join_ref'] ?? '') as String); + final userEvent = utf8.encode(payload['event'] as String); + final metadata = utf8.encode(metadataString); _checkLength('joinRef', joinRef.length); _checkLength('ref', ref.length); @@ -124,11 +130,11 @@ class Serializer { frame[offset++] = userEvent.length; frame[offset++] = metadata.length; frame[offset++] = binaryEncoding; - offset = _writeString(frame, offset, joinRef); - offset = _writeString(frame, offset, ref); - offset = _writeString(frame, offset, topic); - offset = _writeString(frame, offset, userEvent); - offset = _writeString(frame, offset, metadata); + offset = _writeBytes(frame, offset, joinRef); + offset = _writeBytes(frame, offset, ref); + offset = _writeBytes(frame, offset, topic); + offset = _writeBytes(frame, offset, userEvent); + offset = _writeBytes(frame, offset, metadata); frame.setAll(offset, encodedPayload); return frame; @@ -137,12 +143,10 @@ class Serializer { Map _binaryDecode(Uint8List buffer) { final view = ByteData.sublistView(buffer); final kind = view.getUint8(0); - switch (kind) { - case kindUserBroadcast: - return _decodeUserBroadcast(buffer, view); - default: - return {}; - } + return switch (kind) { + kindUserBroadcast => _decodeUserBroadcast(buffer, view), + _ => {}, + }; } Map _decodeUserBroadcast(Uint8List buffer, ByteData view) { @@ -196,15 +200,9 @@ class Serializer { } } - // Writes one byte per UTF-16 code unit, matching the size byte (which is the - // code-unit count). Frame string fields (joinRef, ref, topic, userEvent, - // metadata) are therefore assumed to be ASCII; non-ASCII characters would be - // truncated and not round-trip through the utf8 decode on the other side. - int _writeString(Uint8List buffer, int offset, String value) { - for (final unit in value.codeUnits) { - buffer[offset++] = unit & 0xFF; - } - return offset; + int _writeBytes(Uint8List buffer, int offset, List bytes) { + buffer.setAll(offset, bytes); + return offset + bytes.length; } bool _isBinary(dynamic value) { diff --git a/packages/realtime_client/lib/src/transformers.dart b/packages/realtime_client/lib/src/transformers.dart index 578c41645..be1c94068 100644 --- a/packages/realtime_client/lib/src/transformers.dart +++ b/packages/realtime_client/lib/src/transformers.dart @@ -129,6 +129,10 @@ dynamic convertColumn( /// => [1,2,3,4] /// ``` dynamic convertCell(String type, dynamic value) { + if (value == null) { + return null; + } + // if data type is an array if (type[0] == '_') { final dataType = type.substring(1); @@ -197,24 +201,20 @@ double? toDouble(dynamic value) { if (value is double) { return value; } - try { - final temp = value.toString(); - return double.parse(temp); - } catch (_) { + if (value == null) { return null; } + return double.tryParse(value.toString()); } int? toInt(dynamic value) { if (value is int) { return value; } - try { - final temp = value.toString(); - return int.parse(temp); - } catch (_) { + if (value == null) { return null; } + return int.tryParse(value.toString()); } dynamic toJson(dynamic value) { @@ -249,17 +249,17 @@ dynamic toArray(dynamic value, String type) { // Confirm value is a Postgres array by checking curly brackets if (openBrace == '{' && closeBrace == '}') { final valTrim = value.substring(1, lastIdx); - List arr; + List array; // TODO: find a better solution to separate Postgres array data try { - arr = json.decode('[$valTrim]') as List; + array = json.decode('[$valTrim]') as List; } catch (_) { // WARNING: splitting on comma does not cover all edge cases - arr = valTrim != '' ? valTrim.split(',') : []; + array = valTrim != '' ? valTrim.split(',') : []; } - return arr.map((val) => convertCell(type, val)).toList(); + return array.map((val) => convertCell(type, val)).toList(); } return value; diff --git a/packages/realtime_client/lib/src/types.dart b/packages/realtime_client/lib/src/types.dart index 73de7b5b7..c9d3721e6 100644 --- a/packages/realtime_client/lib/src/types.dart +++ b/packages/realtime_client/lib/src/types.dart @@ -54,19 +54,14 @@ extension PostgresChangeEventMethods on PostgresChangeEvent { return name.toUpperCase(); } - static PostgresChangeEvent fromString(String event) { - switch (event) { - case 'INSERT': - return PostgresChangeEvent.insert; - case 'UPDATE': - return PostgresChangeEvent.update; - case 'DELETE': - return PostgresChangeEvent.delete; - } - throw ArgumentError( + static PostgresChangeEvent fromString(String event) => switch (event) { + 'INSERT' => PostgresChangeEvent.insert, + 'UPDATE' => PostgresChangeEvent.update, + 'DELETE' => PostgresChangeEvent.delete, + _ => throw ArgumentError( 'Only "INSERT", "UPDATE", or "DELETE" can be can be passed to `fromString()` method.', - ); - } + ), + }; } class ChannelFilter { @@ -406,27 +401,21 @@ enum PostgresChangeFilterType { /// The operator token used in the filter wire format (the part between /// `column=` and `.value`). Most match [name], but a few differ because the /// enum names avoid Dart reserved words / casing conventions. - String get token { - switch (this) { - case PostgresChangeFilterType.inFilter: - return 'in'; - case PostgresChangeFilterType.isFilter: - return 'is'; - case PostgresChangeFilterType.isDistinct: - return 'isdistinct'; - case PostgresChangeFilterType.eq: - case PostgresChangeFilterType.neq: - case PostgresChangeFilterType.lt: - case PostgresChangeFilterType.lte: - case PostgresChangeFilterType.gt: - case PostgresChangeFilterType.gte: - case PostgresChangeFilterType.like: - case PostgresChangeFilterType.ilike: - case PostgresChangeFilterType.match: - case PostgresChangeFilterType.imatch: - return name; - } - } + String get token => switch (this) { + PostgresChangeFilterType.inFilter => 'in', + PostgresChangeFilterType.isFilter => 'is', + PostgresChangeFilterType.isDistinct => 'isdistinct', + PostgresChangeFilterType.eq || + PostgresChangeFilterType.neq || + PostgresChangeFilterType.lt || + PostgresChangeFilterType.lte || + PostgresChangeFilterType.gt || + PostgresChangeFilterType.gte || + PostgresChangeFilterType.like || + PostgresChangeFilterType.ilike || + PostgresChangeFilterType.match || + PostgresChangeFilterType.imatch => name, + }; } /// {@template postgres_change_filter} diff --git a/packages/realtime_client/lib/src/version.dart b/packages/realtime_client/lib/src/version.dart index dbfc03049..bf6227b7e 100644 --- a/packages/realtime_client/lib/src/version.dart +++ b/packages/realtime_client/lib/src/version.dart @@ -1 +1 @@ -const version = '2.10.0'; +const version = '2.12.0'; diff --git a/packages/realtime_client/pubspec.yaml b/packages/realtime_client/pubspec.yaml index 4a15b2924..cb23f0760 100644 --- a/packages/realtime_client/pubspec.yaml +++ b/packages/realtime_client/pubspec.yaml @@ -1,9 +1,16 @@ name: realtime_client description: Listens to changes in a PostgreSQL Database and via websockets. This is for usage with Supabase Realtime server. -version: 2.10.0 +version: 2.12.0 homepage: 'https://supabase.com' repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/realtime_client' +issue_tracker: 'https://github.com/supabase/supabase-flutter/issues' documentation: 'https://supabase.com/docs/reference/dart/subscribe' +topics: + - supabase + - realtime + - websocket + - database + - backend environment: sdk: '>=3.9.0 <4.0.0' @@ -11,15 +18,16 @@ environment: resolution: workspace dependencies: - collection: ^1.15.0 - http: ^1.2.2 - logging: ^1.2.0 - meta: ^1.7.0 - web_socket_channel: ^3.0.0 + collection: ^1.19.0 + http: ^1.6.0 + logging: ^1.3.0 + meta: ^1.16.0 + supabase_common: 0.1.1 + web_socket_channel: ^3.0.3 dev_dependencies: - supabase_lints: ^0.1.0 - mocktail: ^1.0.0 - postgres: ^3.0.0 - test: ^1.16.5 - crypto: ^3.0.0 + supabase_lints: ^0.1.1 + mocktail: ^1.0.5 + postgres: ^3.5.0 + test: ^1.25.0 + crypto: ^3.0.7 diff --git a/packages/realtime_client/test/channel_test.dart b/packages/realtime_client/test/channel_test.dart index 15f8e690b..c9e07e5fb 100644 --- a/packages/realtime_client/test/channel_test.dart +++ b/packages/realtime_client/test/channel_test.dart @@ -117,7 +117,7 @@ void main() { expect(joinPush.timeout, Constants.defaultTimeout); - channel.subscribe((_, [__]) {}, newTimeout); + channel.subscribe((_, [_]) {}, newTimeout); expect(joinPush.timeout, newTimeout); }); @@ -180,7 +180,7 @@ void main() { await Future.value(); await Future.value(); }, - (_, __) { + (_, _) { /* expected: rethrown FormatException */ }, ); @@ -409,7 +409,7 @@ void main() { 'channel': 'topic', }); - expect(received, isA()); + expect(received, isA>()); final system = RealtimeSystemPayload.fromJson( Map.from(received), @@ -487,6 +487,80 @@ void main() { }); }); + group('blocking listeners after subscribe', () { + setUp(() { + socket = RealtimeClient('wss://example.com/socket'); + channel = socket.channel('topic'); + }); + + test('throws when adding postgres_changes listener while joining', () { + channel.subscribe(); + expect(channel.isJoining, isTrue); + + expect( + () => channel.onPostgresChanges( + event: PostgresChangeEvent.all, + callback: (_) {}, + ), + throwsA( + allOf( + isA(), + contains('cannot add `postgres_changes` callbacks'), + ), + ), + ); + }); + + test('throws when adding postgres_changes listener after join', () { + channel.subscribe(); + channel.joinPush.trigger('ok', {}); + expect(channel.isJoined, isTrue); + + expect( + () => channel.onPostgresChanges( + event: PostgresChangeEvent.all, + callback: (_) {}, + ), + throwsA( + allOf( + isA(), + contains('cannot add `postgres_changes` callbacks'), + ), + ), + ); + }); + + test('allows adding postgres_changes listener before subscribe', () { + expect( + () => channel.onPostgresChanges( + event: PostgresChangeEvent.all, + callback: (_) {}, + ), + returnsNormally, + ); + }); + + test('does not block presence listeners after subscribe', () { + channel.subscribe(); + expect(channel.isJoining, isTrue); + + expect( + () => channel.onPresenceSync((_) {}), + returnsNormally, + ); + }); + + test('does not block broadcast listeners after subscribe', () { + channel.subscribe(); + expect(channel.isJoining, isTrue); + + expect( + () => channel.onBroadcast(event: 'test', callback: (_) {}), + returnsNormally, + ); + }); + }); + group('off', () { setUp(() { socket = RealtimeClient('/socket'); @@ -589,12 +663,12 @@ void main() { test("closes channel on 'ok' from server", () { final anotherChannel = socket.channel('another'); - expect(socket.channels.length, 2); + expect(socket.channels, hasLength(2)); unawaited(channel.unsubscribe()); channel.joinPush.trigger('ok', {}); - expect(socket.channels.length, 1); + expect(socket.channels, hasLength(1)); expect(socket.channels[0].topic, anotherChannel.topic); }); @@ -609,7 +683,7 @@ void main() { test("able to unsubscribe from * subscription", () { channel.onEvents('*', ChannelFilter(), (payload, [ref]) {}); - expect(socket.channels.length, 1); + expect(socket.channels, hasLength(1)); unawaited(channel.unsubscribe()); channel.joinPush.trigger('ok', {}); @@ -761,6 +835,51 @@ void main() { }); }); + group('track opts forwarding', () { + late _OptsCapturingChannel capturingChannel; + + setUp(() { + socket = RealtimeClient('', timeout: const Duration(milliseconds: 1234)); + capturingChannel = _OptsCapturingChannel('topic', socket); + }); + + test('track forwards a custom non-timeout opt to send', () async { + await capturingChannel.track({'id': 123}, {'ack': true}); + + expect(capturingChannel.capturedOpts, containsPair('ack', true)); + }); + + test('track forwards a custom timeout opt to send', () async { + await capturingChannel.track({'id': 123}, {'timeout': 2500}); + + expect(capturingChannel.capturedOpts, containsPair('timeout', 2500)); + }); + + test( + 'track falls back to the channel timeout when none provided', + () async { + await capturingChannel.track({'id': 123}); + + expect( + capturingChannel.capturedOpts, + containsPair('timeout', const Duration(milliseconds: 1234)), + ); + }, + ); + + test('track keeps custom opts alongside the default timeout', () async { + await capturingChannel.track({'id': 123}, {'ack': true}); + + expect( + capturingChannel.capturedOpts, + allOf( + containsPair('ack', true), + containsPair('timeout', const Duration(milliseconds: 1234)), + ), + ); + }); + }); + group('presence enabled', () { setUp(() { socket = RealtimeClient('', timeout: const Duration(milliseconds: 1234)); @@ -1030,17 +1149,20 @@ void main() { payload: {'myKey': 'myValue'}, ); - final req = await requestFuture; - expect(req.uri.path, '/realtime/v1/api/broadcast/myTopic/events/test'); - expect(req.uri.queryParameters['private'], 'true'); - expect(req.headers.value('apikey'), 'supabaseKey'); - expect(req.headers.contentType?.mimeType, 'application/json'); + final request = await requestFuture; + expect( + request.uri.path, + '/realtime/v1/api/broadcast/myTopic/events/test', + ); + expect(request.uri.queryParameters['private'], 'true'); + expect(request.headers.value('apikey'), 'supabaseKey'); + expect(request.headers.contentType?.mimeType, 'application/json'); - final body = json.decode(await utf8.decodeStream(req)); + final body = json.decode(await utf8.decodeStream(request)); expect(body, {'myKey': 'myValue'}); - req.response.statusCode = 202; - await req.response.close(); + request.response.statusCode = 202; + await request.response.close(); await sendFuture; }, @@ -1060,12 +1182,15 @@ void main() { payload: {'myKey': 'myValue'}, ); - final req = await requestFuture; - expect(req.uri.path, '/realtime/v1/api/broadcast/myTopic/events/test'); - expect(req.uri.queryParameters.containsKey('private'), isFalse); + final request = await requestFuture; + expect( + request.uri.path, + '/realtime/v1/api/broadcast/myTopic/events/test', + ); + expect(request.uri.queryParameters.containsKey('private'), isFalse); - req.response.statusCode = 202; - await req.response.close(); + request.response.statusCode = 202; + await request.response.close(); await sendFuture; }); @@ -1084,14 +1209,14 @@ void main() { payload: {'id': 1}, ); - final req = await requestFuture; + final request = await requestFuture; expect( - req.uri.toString(), + request.uri.toString(), contains('/api/broadcast/room%3A42/events/user%2Fjoined'), ); - req.response.statusCode = 202; - await req.response.close(); + request.response.statusCode = 202; + await request.response.close(); await sendFuture; }); @@ -1108,18 +1233,18 @@ void main() { final requestFuture = mockServer.first; final sendFuture = channel.httpSend(event: 'bin', payload: bytes); - final req = await requestFuture; - expect(req.uri.path, '/realtime/v1/api/broadcast/myTopic/events/bin'); - expect(req.headers.contentType?.mimeType, 'application/octet-stream'); + final request = await requestFuture; + expect(request.uri.path, '/realtime/v1/api/broadcast/myTopic/events/bin'); + expect(request.headers.contentType?.mimeType, 'application/octet-stream'); - final received = await req.fold>( + final received = await request.fold>( [], - (acc, chunk) => acc..addAll(chunk), + (accumulated, chunk) => accumulated..addAll(chunk), ); expect(received, equals(bytes)); - req.response.statusCode = 202; - await req.response.close(); + request.response.statusCode = 202; + await request.response.close(); await sendFuture; }); @@ -1136,17 +1261,17 @@ void main() { final requestFuture = mockServer.first; final sendFuture = channel.httpSend(event: 'bin', payload: bytes.buffer); - final req = await requestFuture; - expect(req.headers.contentType?.mimeType, 'application/octet-stream'); + final request = await requestFuture; + expect(request.headers.contentType?.mimeType, 'application/octet-stream'); - final received = await req.fold>( + final received = await request.fold>( [], - (acc, chunk) => acc..addAll(chunk), + (accumulated, chunk) => accumulated..addAll(chunk), ); expect(received, equals(bytes)); - req.response.statusCode = 202; - await req.response.close(); + request.response.statusCode = 202; + await request.response.close(); await sendFuture; }); @@ -1166,12 +1291,12 @@ void main() { payload: {'data': 'test'}, ); - final req = await requestFuture; - expect(req.headers.value('Authorization'), 'Bearer token123'); - expect(req.headers.value('apikey'), 'abc123'); + final request = await requestFuture; + expect(request.headers.value('Authorization'), 'Bearer token123'); + expect(request.headers.value('apikey'), 'abc123'); - req.response.statusCode = 202; - await req.response.close(); + request.response.statusCode = 202; + await request.response.close(); await sendFuture; }); @@ -1189,10 +1314,10 @@ void main() { payload: {'data': 'test'}, ); - final req = await requestFuture; - req.response.statusCode = 500; - req.response.write(json.encode({'error': 'Server error'})); - await req.response.close(); + final request = await requestFuture; + request.response.statusCode = 500; + request.response.write(json.encode({'error': 'Server error'})); + await request.response.close(); await expectLater( sendFuture, @@ -1213,10 +1338,10 @@ void main() { // Don't await the server - let it hang to trigger timeout unawaited( - mockServer.first.then((req) async { + mockServer.first.then((request) async { await Future.delayed(const Duration(seconds: 1)); - req.response.statusCode = 202; - await req.response.close(); + request.response.statusCode = 202; + await request.response.close(); }), ); @@ -1250,3 +1375,20 @@ class _SetAuthThrowingSocket extends RealtimeClient { throw thrown; } } + +class _OptsCapturingChannel extends RealtimeChannel { + _OptsCapturingChannel(super.topic, super.socket); + + Map? capturedOpts; + + @override + Future send({ + required RealtimeListenTypes type, + String? event, + required Map payload, + Map opts = const {}, + }) async { + capturedOpts = opts; + return ChannelResponse.ok; + } +} diff --git a/packages/realtime_client/test/realtime_integration_test.dart b/packages/realtime_client/test/realtime_integration_test.dart index 385f04892..cb22f1c14 100644 --- a/packages/realtime_client/test/realtime_integration_test.dart +++ b/packages/realtime_client/test/realtime_integration_test.dart @@ -209,16 +209,16 @@ void main() { }); group('postgres changes', () { - late Connection db; + late Connection database; setUp(() async { - db = await openPostgresConnection(); - await db.execute('TRUNCATE public.todos RESTART IDENTITY'); + database = await openPostgresConnection(); + await database.execute('TRUNCATE public.todos RESTART IDENTITY'); }); tearDown(() async { - await db.execute('TRUNCATE public.todos RESTART IDENTITY'); - await db.close(); + await database.execute('TRUNCATE public.todos RESTART IDENTITY'); + await database.close(); }); test('receives insert, update and delete events', () async { @@ -235,13 +235,10 @@ void main() { switch (payload.eventType) { case PostgresChangeEvent.insert: if (!inserts.isCompleted) inserts.complete(payload); - break; case PostgresChangeEvent.update: if (!updates.isCompleted) updates.complete(payload); - break; case PostgresChangeEvent.delete: if (!deletes.isCompleted) deletes.complete(payload); - break; case PostgresChangeEvent.all: break; } @@ -251,7 +248,7 @@ void main() { await _subscribe(channel); await Future.delayed(const Duration(seconds: 2)); - await db.execute( + await database.execute( "INSERT INTO public.todos (task) VALUES ('write tests')", ); final insert = await inserts.future.timeout( @@ -260,7 +257,7 @@ void main() { expect(insert.eventType, PostgresChangeEvent.insert); expect(insert.newRecord['task'], 'write tests'); - await db.execute( + await database.execute( "UPDATE public.todos SET is_complete = true WHERE task = 'write tests'", ); final update = await updates.future.timeout( @@ -270,7 +267,7 @@ void main() { expect(update.newRecord['is_complete'], isTrue); expect(update.oldRecord['is_complete'], isFalse); - await db.execute( + await database.execute( "DELETE FROM public.todos WHERE task = 'write tests'", ); final delete = await deletes.future.timeout( @@ -301,7 +298,7 @@ void main() { await _subscribe(channel); await Future.delayed(const Duration(seconds: 2)); - await db.execute( + await database.execute( "INSERT INTO public.todos (task, is_complete) VALUES ('ignored', false)", ); @@ -312,7 +309,7 @@ void main() { reason: 'the non-matching row must not be delivered', ); - await db.execute( + await database.execute( "INSERT INTO public.todos (task, is_complete) VALUES ('matched', true)", ); diff --git a/packages/realtime_client/test/serializer_test.dart b/packages/realtime_client/test/serializer_test.dart index 0aa690052..6a2b583bb 100644 --- a/packages/realtime_client/test/serializer_test.dart +++ b/packages/realtime_client/test/serializer_test.dart @@ -233,6 +233,63 @@ void main() { expect(jsonDecode(metadata), {'trace_id': 'abc'}); }); + test('encodes multi-byte header fields as UTF-8 byte lengths', () { + final serializerWithMeta = Serializer(allowedMetadataKeys: ['label']); + final topic = 'realtime:café'; + final userEvent = 'café-🎉'; + final payload = Uint8List.fromList([1, 2, 3]); + final result = serializerWithMeta.encode({ + 'join_ref': '10', + 'ref': '1', + 'topic': topic, + 'event': 'broadcast', + 'payload': { + 'type': 'broadcast', + 'event': userEvent, + 'label': 'naïve', + 'payload': payload, + }, + }); + + final bytes = result as Uint8List; + final joinRefBytes = utf8.encode('10'); + final refBytes = utf8.encode('1'); + final topicBytes = utf8.encode(topic); + final userEventBytes = utf8.encode(userEvent); + final metadataBytes = utf8.encode(jsonEncode({'label': 'naïve'})); + + // Length prefixes must be UTF-8 byte lengths, not UTF-16 code-unit counts. + expect(bytes[1], joinRefBytes.length); + expect(bytes[2], refBytes.length); + expect(bytes[3], topicBytes.length); + expect(bytes[4], userEventBytes.length); + expect(bytes[5], metadataBytes.length); + expect(bytes[4], greaterThan(userEvent.length)); + + // The written bytes must round-trip through utf8.decode, as decode does. + var offset = + Serializer.headerLength + Serializer.userBroadcastPushMetaLength; + offset += joinRefBytes.length + refBytes.length; + expect( + utf8.decode(bytes.sublist(offset, offset + topicBytes.length)), + topic, + ); + offset += topicBytes.length; + expect( + utf8.decode(bytes.sublist(offset, offset + userEventBytes.length)), + userEvent, + ); + offset += userEventBytes.length; + expect( + jsonDecode( + utf8.decode(bytes.sublist(offset, offset + metadataBytes.length)), + ), + {'label': 'naïve'}, + ); + offset += metadataBytes.length; + expect(bytes.sublist(offset), payload); + }); + test('throws when a frame field exceeds 255 bytes', () { final longTopic = 'a' * 256; expect( diff --git a/packages/realtime_client/test/socket_test.dart b/packages/realtime_client/test/socket_test.dart index c817d7309..1c58dfbe4 100644 --- a/packages/realtime_client/test/socket_test.dart +++ b/packages/realtime_client/test/socket_test.dart @@ -96,7 +96,7 @@ void main() { socket.logger is void Function( String? kind, - String? msg, + String? message, dynamic data, ), isFalse, @@ -114,7 +114,7 @@ void main() { timeout: const Duration(milliseconds: 40000), heartbeatIntervalMs: 60000, // ignore: avoid_print - logger: (kind, msg, data) => print('[$kind] $msg $data'), + logger: (kind, message, data) => print('[$kind] $message $data'), headers: {'X-Client-Info': 'supabase-dart/0.0.0'}, ); expect(socket.channels, isEmpty); @@ -133,7 +133,7 @@ void main() { socket.logger is void Function( String? kind, - String? msg, + String? message, dynamic data, ), isTrue, @@ -199,15 +199,15 @@ void main() { }); test('establishes websocket connection with endpoint', () async { - final connFuture = socket.connect(); + final connectFuture = socket.connect(); expect(socket.connState, SocketStates.connecting); - final conn = socket.conn; + final connection = socket.conn; - await connFuture; + await connectFuture; expect(socket.connState, SocketStates.open); - expect(conn, isA()); + expect(connection, isA()); //! Not verifying connection url }); @@ -220,9 +220,9 @@ void main() { socket.onClose((_) { closes += 1; }); - late dynamic lastMsg; - socket.onMessage((m) { - lastMsg = m; + late dynamic lastMessage; + socket.onMessage((message) { + lastMessage = message; }); await socket.connect(); @@ -232,7 +232,7 @@ void main() { await socket.sendHeartbeat(); // need to wait for event to trigger await Future.delayed(const Duration(seconds: 1)); - expect(lastMsg['event'], 'heartbeat'); + expect(lastMessage['event'], 'heartbeat'); await socket.disconnect(); await Future.delayed(const Duration(seconds: 1)); @@ -240,22 +240,22 @@ void main() { }); test('sets callback for errors', () { - dynamic lastErr; + dynamic lastError; final RealtimeClient erroneousSocket = RealtimeClient('badurl') - ..onError((e) { - lastErr = e; + ..onError((error) { + lastError = error; }); unawaited(erroneousSocket.connect()); - expect(lastErr, isA()); + expect(lastError, isA()); }); test('is idempotent', () { unawaited(socket.connect()); - final conn = socket.conn; + final connection = socket.conn; unawaited(socket.connect()); - expect(socket.conn, conn); + expect(socket.conn, connection); }); }); @@ -278,15 +278,6 @@ void main() { expect(socket.conn, isNull); }); - test('calls callback', () async { - int closes = 0; - unawaited(socket.connect()); - unawaited(socket.disconnect()); - closes += 1; - - expect(closes, 1); - }); - test('calls connection close callback', () async { final mockedSocketChannel = MockIOWebSocketChannel(); final mockedSocket = RealtimeClient( @@ -516,6 +507,37 @@ void main() { test('does not throw when no connection', () { expect(() => socket.disconnect(), returnsNormally); }); + + test('times out and finalizes disconnect when sink.close hangs', () async { + final mockedSocketChannel = MockIOWebSocketChannel(); + final mockedSink = MockWebSocketSink(); + final streamController = StreamController.broadcast(); + final closeCompleter = Completer(); + final mockedSocket = RealtimeClient( + socketEndpoint, + transport: (url, headers) => mockedSocketChannel, + ); + var closeCallbacks = 0; + mockedSocket.onClose((_) => closeCallbacks += 1); + + when(() => mockedSocketChannel.ready).thenAnswer((_) => Future.value()); + when(() => mockedSocketChannel.sink).thenReturn(mockedSink); + when( + () => mockedSocketChannel.stream, + ).thenAnswer((_) => streamController.stream); + when(() => mockedSink.close()).thenAnswer((_) => closeCompleter.future); + + await mockedSocket.connect(); + expect(mockedSocket.connState, SocketStates.open); + + await mockedSocket.disconnect(); + expect(mockedSocket.connState, SocketStates.disconnected); + expect(mockedSocket.conn, isNull); + expect(closeCallbacks, 1); + verify(() => mockedSink.close()).called(1); + + await streamController.close(); + }); }); //! Note: not checking connection states since it is based on an enum. @@ -557,7 +579,7 @@ void main() { tParams, ); - expect(socket.channels.length, 1); + expect(socket.channels, hasLength(1)); final foundChannel = socket.channels[0]; expect(foundChannel, channel); @@ -585,13 +607,176 @@ void main() { final channel2 = mockedSocket.channel(tTopic2); mockedSocket.remove(channel1); - expect(mockedSocket.channels.length, 1); + expect(mockedSocket.channels, hasLength(1)); final foundChannel = mockedSocket.channels[0]; expect(foundChannel, channel2); }); }); + group('deferred disconnect', () { + test('defaults to twice the heartbeat interval', () { + final socket = RealtimeClient(socketEndpoint); + expect( + socket.disconnectOnEmptyChannelsAfter, + Duration(milliseconds: 2 * Constants.defaultHeartbeatIntervalMs), + ); + + final customSocket = RealtimeClient( + socketEndpoint, + heartbeatIntervalMs: 5000, + ); + expect( + customSocket.disconnectOnEmptyChannelsAfter, + const Duration(milliseconds: 10000), + ); + + final explicitSocket = RealtimeClient( + socketEndpoint, + disconnectOnEmptyChannelsAfter: const Duration(milliseconds: 1234), + ); + expect( + explicitSocket.disconnectOnEmptyChannelsAfter, + const Duration(milliseconds: 1234), + ); + }); + + test( + 'does not disconnect immediately when the last channel is removed', + () async { + final socket = RealtimeClient( + 'ws://localhost:${mockServer.port}', + disconnectOnEmptyChannelsAfter: const Duration(milliseconds: 200), + ); + await socket.connect(); + expect(socket.isConnected, isTrue); + + final channel = socket.channel('topic'); + socket.remove(channel); + + expect(socket.isConnected, isTrue); + await socket.disconnect(); + }, + ); + + test('disconnects after the delay when channels stay empty', () async { + final socket = RealtimeClient( + 'ws://localhost:${mockServer.port}', + disconnectOnEmptyChannelsAfter: const Duration(milliseconds: 200), + ); + await socket.connect(); + + final channel = socket.channel('topic'); + socket.remove(channel); + + expect(socket.isConnected, isTrue); + await Future.delayed(const Duration(milliseconds: 400)); + expect(socket.isConnected, isFalse); + }); + + test( + 'cancels the pending disconnect when a new channel is created', + () async { + final socket = RealtimeClient( + 'ws://localhost:${mockServer.port}', + disconnectOnEmptyChannelsAfter: const Duration(milliseconds: 200), + ); + await socket.connect(); + + final channel = socket.channel('topic'); + socket.remove(channel); + socket.channel('new-topic'); + + await Future.delayed(const Duration(milliseconds: 400)); + expect(socket.isConnected, isTrue); + await socket.disconnect(); + }, + ); + + test( + 'disconnects immediately when disconnectOnEmptyChannelsAfter is zero', + () async { + final socket = RealtimeClient( + 'ws://localhost:${mockServer.port}', + disconnectOnEmptyChannelsAfter: Duration.zero, + ); + await socket.connect(); + + final channel = socket.channel('topic'); + socket.remove(channel); + + await Future.delayed(const Duration(milliseconds: 100)); + expect(socket.isConnected, isFalse); + }, + ); + + test('disconnect cancels a pending deferred disconnect', () async { + final socket = RealtimeClient( + 'ws://localhost:${mockServer.port}', + disconnectOnEmptyChannelsAfter: const Duration(milliseconds: 200), + ); + await socket.connect(); + + final channel = socket.channel('topic'); + socket.remove(channel); + await socket.disconnect(); + + await socket.connect(); + socket.channel('topic-2'); + await Future.delayed(const Duration(milliseconds: 400)); + expect(socket.isConnected, isTrue); + await socket.disconnect(); + }); + + test( + 'removeChannel schedules a deferred disconnect for the last channel', + () async { + final socket = RealtimeClient( + 'ws://localhost:${mockServer.port}', + disconnectOnEmptyChannelsAfter: const Duration(milliseconds: 200), + ); + await socket.connect(); + + final channel = socket.channel('topic'); + await socket.removeChannel(channel); + + expect(socket.isConnected, isTrue); + await Future.delayed(const Duration(milliseconds: 400)); + expect(socket.isConnected, isFalse); + }, + ); + + test('channel.unsubscribe schedules a deferred disconnect', () async { + final socket = RealtimeClient( + 'ws://localhost:${mockServer.port}', + disconnectOnEmptyChannelsAfter: const Duration(milliseconds: 200), + ); + await socket.connect(); + + final channel = socket.channel('topic'); + await channel.unsubscribe(); + + expect(socket.isConnected, isTrue); + await Future.delayed(const Duration(milliseconds: 400)); + expect(socket.isConnected, isFalse); + }); + + test('removeAllChannels disconnects immediately', () async { + final socket = RealtimeClient( + 'ws://localhost:${mockServer.port}', + disconnectOnEmptyChannelsAfter: const Duration(milliseconds: 10000), + ); + await socket.connect(); + expect(socket.isConnected, isTrue); + + socket.channel('channel-1'); + socket.channel('channel-2'); + + await socket.removeAllChannels(); + expect(socket.isConnected, isFalse); + }); + }); + group('push', () { const topic = 'topic'; const event = ChannelEvents.join; @@ -658,7 +843,7 @@ void main() { mockedSocket.push(message); verifyNever(() => mockedSink.add(any())); - expect(mockedSocket.sendBuffer.length, 1); + expect(mockedSocket.sendBuffer, hasLength(1)); final callback = mockedSocket.sendBuffer[0]; callback(); @@ -978,6 +1163,112 @@ void main() { ); }); + group('on connection open', () { + test('rejoins only errored channels', () async { + final mockedSocketChannel = MockIOWebSocketChannel(); + final mockedSink = MockWebSocketSink(); + final streamController = StreamController.broadcast(); + final erroredChannel = MockChannel(); + final healthyChannel = MockChannel(); + final socket = RealtimeClient( + socketEndpoint, + transport: (url, headers) => mockedSocketChannel, + ); + var opens = 0; + socket.onOpen(() => opens += 1); + + when(() => mockedSocketChannel.ready).thenAnswer((_) => Future.value()); + when(() => mockedSocketChannel.sink).thenReturn(mockedSink); + when( + () => mockedSocketChannel.stream, + ).thenAnswer((_) => streamController.stream); + when(() => mockedSink.close()).thenAnswer((_) => Future.value()); + when(() => erroredChannel.isErrored).thenReturn(true); + when(() => healthyChannel.isErrored).thenReturn(false); + when(() => erroredChannel.rejoin()).thenReturn(null); + + socket.channels.addAll([erroredChannel, healthyChannel]); + await socket.connect(); + + verify(() => erroredChannel.rejoin()).called(1); + verifyNever(() => healthyChannel.rejoin()); + expect(opens, 1); + expect(socket.connState, SocketStates.open); + + await socket.disconnect(); + await streamController.close(); + }); + }); + + group('access token on connect', () { + test( + 'resolves the token and patches buffered join payloads before flushing', + () async { + final token = generateJwt(); + var tokenCallbackCalls = 0; + + final streamController = StreamController.broadcast(); + final readyCompleter = Completer(); + final capturedMessages = []; + final joinSent = Completer>(); + + final mockedChannel = MockIOWebSocketChannel(); + final mockedSink = MockWebSocketSink(); + when(() => mockedChannel.sink).thenReturn(mockedSink); + when( + () => mockedChannel.ready, + ).thenAnswer((_) => readyCompleter.future); + when( + () => mockedChannel.stream, + ).thenAnswer((_) => streamController.stream); + when( + () => mockedSink.close(any(), any()), + ).thenAnswer((_) => Future.value()); + when(() => mockedSink.close()).thenAnswer((_) => Future.value()); + when(() => mockedSink.add(any())).thenAnswer((invocation) { + final raw = invocation.positionalArguments.first as String; + capturedMessages.add(raw); + final frame = json.decode(raw) as List; + if (frame[3] == ChannelEvents.join.eventName() && + !joinSent.isCompleted) { + joinSent.complete(frame[4] as Map); + } + }); + + final socket = RealtimeClient( + socketEndpoint, + transport: (url, headers) => mockedChannel, + customAccessToken: () async { + tokenCallbackCalls++; + return token; + }, + ); + + final channel = socket.channel('realtime:test'); + channel.subscribe(); + + // The join is buffered while the socket is still connecting and the + // token has not resolved yet, so it carries no access_token. + expect(socket.sendBuffer, isNotEmpty); + expect(capturedMessages, isEmpty); + + // Once the connection is ready the token is resolved and the buffered + // join is re-sent with the token patched into its payload. + readyCompleter.complete(); + final joinPayload = await joinSent.future.timeout( + const Duration(seconds: 5), + ); + + expect(tokenCallbackCalls, greaterThan(0)); + expect(socket.accessToken, token); + expect(joinPayload['access_token'], token); + + await socket.disconnect(); + await streamController.close(); + }, + ); + }); + group('sendHeartbeat', () { IOWebSocketChannel mockedSocketChannel; late RealtimeClient mockedSocket; diff --git a/packages/realtime_client/test/transformers_test.dart b/packages/realtime_client/test/transformers_test.dart index 9f3a8a49b..38e752c96 100644 --- a/packages/realtime_client/test/transformers_test.dart +++ b/packages/realtime_client/test/transformers_test.dart @@ -20,6 +20,23 @@ void main() { expect(toBoolean(null), isNull); }); + test('transformers toInt', () { + expect(toInt(10), equals(10)); + expect(toInt('10'), equals(10)); + expect(toInt(null), isNull); + expect(toInt(''), isNull); + expect(toInt('not a number'), isNull); + }); + + test('transformers toDouble', () { + expect(toDouble(1.23), equals(1.23)); + expect(toDouble('1.23'), equals(1.23)); + expect(toDouble(1), equals(1.0)); + expect(toDouble(null), isNull); + expect(toDouble(''), isNull); + expect(toDouble('not a number'), isNull); + }); + test('transformers noop', () { expect(noop(null), equals(null)); expect(noop(''), equals('')); diff --git a/packages/realtime_client/test/utils/realtime_test_utils.dart b/packages/realtime_client/test/utils/realtime_test_utils.dart index 0a6771077..c5853c0e3 100644 --- a/packages/realtime_client/test/utils/realtime_test_utils.dart +++ b/packages/realtime_client/test/utils/realtime_test_utils.dart @@ -119,7 +119,7 @@ Future primePostgresChanges({ }) async { final deadline = DateTime.now().add(timeout); final client = createRealtimeClient(RealtimeProtocolVersion.v1); - final db = await openPostgresConnection(); + final database = await openPostgresConnection(); final received = Completer(); final channel = client.channel('postgres-changes-warmup'); @@ -149,7 +149,9 @@ Future primePostgresChanges({ try { await subscribed.future.timeout(const Duration(seconds: 15)); while (!received.isCompleted && DateTime.now().isBefore(deadline)) { - await db.execute("INSERT INTO public.todos (task) VALUES ('warmup')"); + await database.execute( + "INSERT INTO public.todos (task) VALUES ('warmup')", + ); await Future.any([ received.future, Future.delayed(const Duration(seconds: 2)), @@ -161,8 +163,8 @@ Future primePostgresChanges({ ); } } finally { - await db.execute('TRUNCATE public.todos RESTART IDENTITY'); - await db.close(); + await database.execute('TRUNCATE public.todos RESTART IDENTITY'); + await database.close(); await client.removeAllChannels(); await client.disconnect(); } diff --git a/packages/realtime_client/test/websocket_io_test.dart b/packages/realtime_client/test/websocket_io_test.dart index dffe31aac..04420c554 100644 --- a/packages/realtime_client/test/websocket_io_test.dart +++ b/packages/realtime_client/test/websocket_io_test.dart @@ -16,7 +16,7 @@ Future _startUnresponsiveServer() async { final server = await ServerSocket.bind('localhost', 0); server.listen((socket) { final buffer = StringBuffer(); - late StreamSubscription subscription; + late StreamSubscription subscription; subscription = socket.listen((data) { buffer.write(String.fromCharCodes(data)); final request = buffer.toString(); diff --git a/packages/storage_client/CHANGELOG.md b/packages/storage_client/CHANGELOG.md index b8331953d..d29f0cd94 100644 --- a/packages/storage_client/CHANGELOG.md +++ b/packages/storage_client/CHANGELOG.md @@ -1,3 +1,18 @@ +## 2.7.0 + + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **REFACTOR**: drop retry dependency in favor of an in-house helper ([#1571](https://github.com/supabase/supabase-flutter/issues/1571)). ([b81d2121](https://github.com/supabase/supabase-flutter/commit/b81d212159f15bf4d9515acf75430550a715664d)) + - **REFACTOR**(storage): drop direct http_parser dependency ([#1570](https://github.com/supabase/supabase-flutter/issues/1570)). ([6dadaefb](https://github.com/supabase/supabase-flutter/commit/6dadaefbf847f254789c1f848b456c21cdd29d3e)) + - **FIX**(storage): guard against empty transform routing through render endpoint ([#1551](https://github.com/supabase/supabase-flutter/issues/1551)). ([3f3e6f6c](https://github.com/supabase/supabase-flutter/commit/3f3e6f6c52dfb6020e4ea63b25a26eef855c50eb)) + - **FIX**(storage): make storage_client WASM-compatible ([#1543](https://github.com/supabase/supabase-flutter/issues/1543)). ([7097b2ce](https://github.com/supabase/supabase-flutter/commit/7097b2ce6fdf206d56bdefb682d3ac5e43c8572d)) + - **FEAT**(storage): add analytics (Iceberg) bucket CRUD ([#1588](https://github.com/supabase/supabase-flutter/issues/1588)). ([e3eeb9e6](https://github.com/supabase/supabase-flutter/commit/e3eeb9e6b89465a2f76ef243b42be7a14cbcf174)) + - **FEAT**(storage): add vector buckets support ([#1585](https://github.com/supabase/supabase-flutter/issues/1585)). ([4f7fcc63](https://github.com/supabase/supabase-flutter/commit/4f7fcc63ad9b5d2d4b73e41d37e1ca145440e826)) + - **FEAT**(storage): add downloadStream for streaming file downloads ([#1580](https://github.com/supabase/supabase-flutter/issues/1580)). ([8b839a10](https://github.com/supabase/supabase-flutter/commit/8b839a1028e591d7125e15b66164aa56e4d441e9)) + - **FEAT**(storage): add listPaginated for cursor-based file listing ([#1579](https://github.com/supabase/supabase-flutter/issues/1579)). ([a6428c64](https://github.com/supabase/supabase-flutter/commit/a6428c6481c0b9d8d7a5b91d9e2c5424ed3f5c25)) + - **FEAT**(storage): add cacheNonce parameter for cache invalidation ([#1578](https://github.com/supabase/supabase-flutter/issues/1578)). ([a9ff8086](https://github.com/supabase/supabase-flutter/commit/a9ff808653f16457f6caa8938610292888d65e17)) + - **FEAT**(storage): support filter/sort/pagination options on listBuckets() ([#1557](https://github.com/supabase/supabase-flutter/issues/1557)). ([b72739e2](https://github.com/supabase/supabase-flutter/commit/b72739e231e964a867499c36c08910ef04aa78f9)) + ## 2.6.0 - **FEAT**(storage): add download option to signed and public URLs ([#1514](https://github.com/supabase/supabase-flutter/issues/1514)). ([2c0370c1](https://github.com/supabase/supabase-flutter/commit/2c0370c19ac08f6d9e5b09fd2025cbee0f6f3ec2)) diff --git a/packages/storage_client/README.md b/packages/storage_client/README.md index afdf4576b..9ac96ddc7 100644 --- a/packages/storage_client/README.md +++ b/packages/storage_client/README.md @@ -1,18 +1,35 @@ -# storage_client +
+

+ + Supabase Logo + -Dart client library to interact with Supabase Storage. +

storage_client

-- Documentation: https://supabase.io/docs/reference/dart/storage-createbucket +

+ Dart client library to interact with Supabase Storage. +

+ +

+ Guides + · + Reference Docs +

+

+ +
[![pub package](https://img.shields.io/pub/v/storage_client.svg)](https://pub.dev/packages/storage_client) -[![pub test](https://github.com/supabase/storage-dart/workflows/Test/badge.svg)](https://github.com/supabase/storage-dart/actions?query=workflow%3ATest) +[![pub test](https://github.com/supabase/supabase-flutter/workflows/Test/badge.svg)](https://github.com/supabase/supabase-flutter/actions?query=workflow%3ATest) + +
## Docs The docs can be found on the official Supabase website. -- [Dart reference](https://supabase.com/docs/reference/dart/storage-createbucket) -- [Realtime guide](https://supabase.com/docs/guides/storage) +- [Dart reference](https://supabase.com/docs/reference/dart/file-buckets-createbucket) +- [Storage guide](https://supabase.com/docs/guides/storage) ## License diff --git a/packages/storage_client/lib/src/constants.dart b/packages/storage_client/lib/src/constants.dart index 0ce66956b..d399ad15c 100644 --- a/packages/storage_client/lib/src/constants.dart +++ b/packages/storage_client/lib/src/constants.dart @@ -1,7 +1,8 @@ import 'package:storage_client/src/version.dart'; +import 'package:supabase_common/supabase_common.dart'; class Constants { - static const Map defaultHeaders = { - 'X-Client-Info': 'storage-dart/$version', + static final Map defaultHeaders = { + 'X-Client-Info': buildClientInfoHeader('storage-dart', version), }; } diff --git a/packages/storage_client/lib/src/fetch.dart b/packages/storage_client/lib/src/fetch.dart index 61a06d2f9..6b3613793 100644 --- a/packages/storage_client/lib/src/fetch.dart +++ b/packages/storage_client/lib/src/fetch.dart @@ -4,14 +4,13 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -// ignore: unnecessary_import — needed for http < 1.6.0 which doesn't re-export MediaType -import 'package:http_parser/http_parser.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; import 'package:mime/mime.dart'; -import 'package:retry/retry.dart'; import 'package:storage_client/src/types.dart'; +import 'package:supabase_common/supabase_common.dart'; -import 'file_io.dart' if (dart.library.js) './file_stub.dart'; +import 'file_stub.dart' if (dart.library.io) './file_io.dart'; class Fetch { final Client? httpClient; @@ -19,10 +18,6 @@ class Fetch { Fetch([this.httpClient]); - bool _isSuccessStatusCode(int code) { - return code >= 200 && code <= 299; - } - MediaType _parseMediaType(String path) { final mime = lookupMimeType(path); return MediaType.parse(mime ?? 'application/octet-stream'); @@ -212,10 +207,13 @@ class Fetch { FetchOptions? options, ) async { final response = await http.Response.fromStream(streamedResponse); - if (_isSuccessStatusCode(response.statusCode)) { + if (isSuccessStatusCode(response.statusCode)) { if (options?.noResolveJson == true) { return response.bodyBytes; } + if (response.body.isEmpty) { + return null; + } final jsonBody = json.decode(response.body); return jsonBody; } @@ -232,7 +230,7 @@ class Fetch { 'HEAD', url, null, - FetchOptions(headers: options?.headers, noResolveJson: true), + FetchOptions(options?.headers, noResolveJson: true), ); } @@ -240,6 +238,43 @@ class Fetch { return _handleRequest('GET', url, null, options); } + /// Performs a GET request and yields the response body as a byte stream + /// without buffering it in memory. + /// + /// The status code is inspected before the body is yielded, so a non-success + /// response surfaces as a [StorageException] on the stream before any bytes + /// are emitted. + @internal + Stream getStream( + String url, { + FetchOptions? options, + }) async* { + final request = http.Request('GET', Uri.parse(url)) + ..headers.addAll({...?options?.headers}); + + _log.finest('Request: GET (stream) $url ${request.headers}'); + final http.StreamedResponse streamedResponse; + if (httpClient != null) { + streamedResponse = await httpClient!.send(request); + } else { + streamedResponse = await request.send(); + } + + if (!isSuccessStatusCode(streamedResponse.statusCode)) { + final response = await http.Response.fromStream(streamedResponse); + throw _handleError( + response, + StackTrace.current, + response.request?.url, + FetchOptions(options?.headers, noResolveJson: true), + ); + } + + yield* streamedResponse.stream.map( + (chunk) => chunk is Uint8List ? chunk : Uint8List.fromList(chunk), + ); + } + Future post( String url, Map? body, { diff --git a/packages/storage_client/lib/src/iceberg/iceberg_error.dart b/packages/storage_client/lib/src/iceberg/iceberg_error.dart new file mode 100644 index 000000000..00efe38b6 --- /dev/null +++ b/packages/storage_client/lib/src/iceberg/iceberg_error.dart @@ -0,0 +1,175 @@ +/// Error thrown by [IcebergRestCatalog] operations when the Iceberg REST +/// Catalog API returns an error response or a request fails at the network +/// level. +/// +/// This is a sealed hierarchy: match on the concrete subtype to handle a +/// specific failure, for example +/// +/// ```dart +/// try { +/// await catalog.loadTable(id); +/// } on IcebergNotFoundException { +/// // the table does not exist +/// } on IcebergException catch (error) { +/// // any other Iceberg failure +/// } +/// ``` +sealed class IcebergException implements Exception { + /// Human readable error message. + final String message; + + /// The HTTP status code of the response. `0` indicates a network level + /// failure before a response was received. + final int statusCode; + + /// The Iceberg error type reported by the server, for example + /// `NoSuchTableException`. + final String? type; + + /// The Iceberg error code reported by the server. + final int? code; + + /// The raw error payload, when available. + final Object? details; + + const IcebergException( + this.message, { + required this.statusCode, + this.type, + this.code, + this.details, + }); + + /// Builds the appropriate [IcebergException] subtype from an error response. + factory IcebergException.fromResponse(int statusCode, Object? body) { + var message = 'Request failed with status $statusCode'; + String? type; + int? code; + if (body is Map && body['error'] is Map) { + final error = body['error'] as Map; + message = (error['message'] as String?) ?? message; + type = error['type'] as String?; + code = error['code'] as int?; + } + + if (type == 'CommitStateUnknownException') { + return IcebergCommitStateUnknownException( + message, + statusCode: statusCode, + code: code, + details: body, + ); + } + + return switch (statusCode) { + 404 => IcebergNotFoundException( + message, + type: type, + code: code, + details: body, + ), + 409 => IcebergConflictException( + message, + type: type, + code: code, + details: body, + ), + 419 => IcebergAuthenticationTimeoutException( + message, + type: type, + code: code, + details: body, + ), + >= 500 => IcebergServerException( + message, + statusCode: statusCode, + type: type, + code: code, + details: body, + ), + _ => IcebergUnknownException( + message, + statusCode: statusCode, + type: type, + code: code, + details: body, + ), + }; + } + + @override + String toString() => + '$runtimeType(message: $message, statusCode: $statusCode, ' + 'type: $type, code: $code)'; +} + +/// A request failed at the network level, before any response was received. +final class IcebergNetworkException extends IcebergException { + const IcebergNetworkException(super.message, {super.details}) + : super(statusCode: 0); +} + +/// The requested namespace or table does not exist (HTTP 404). +final class IcebergNotFoundException extends IcebergException { + const IcebergNotFoundException( + super.message, { + super.type, + super.code, + super.details, + }) : super(statusCode: 404); +} + +/// The request conflicts with the current state, for example the resource +/// already exists or a commit lost a race (HTTP 409). +final class IcebergConflictException extends IcebergException { + const IcebergConflictException( + super.message, { + super.type, + super.code, + super.details, + }) : super(statusCode: 409); +} + +/// Authentication timed out and the request should be retried with fresh +/// credentials (HTTP 419). +final class IcebergAuthenticationTimeoutException extends IcebergException { + const IcebergAuthenticationTimeoutException( + super.message, { + super.type, + super.code, + super.details, + }) : super(statusCode: 419); +} + +/// A table commit was sent but its outcome is unknown, so retrying it could +/// duplicate data. +final class IcebergCommitStateUnknownException extends IcebergException { + const IcebergCommitStateUnknownException( + super.message, { + required super.statusCode, + super.code, + super.details, + }) : super(type: 'CommitStateUnknownException'); +} + +/// The server failed to handle the request (HTTP 5xx). +final class IcebergServerException extends IcebergException { + const IcebergServerException( + super.message, { + required super.statusCode, + super.type, + super.code, + super.details, + }); +} + +/// Any Iceberg failure that does not fit a more specific subtype. +final class IcebergUnknownException extends IcebergException { + const IcebergUnknownException( + super.message, { + required super.statusCode, + super.type, + super.code, + super.details, + }); +} diff --git a/packages/storage_client/lib/src/iceberg/iceberg_rest_catalog.dart b/packages/storage_client/lib/src/iceberg/iceberg_rest_catalog.dart new file mode 100644 index 000000000..394b155d2 --- /dev/null +++ b/packages/storage_client/lib/src/iceberg/iceberg_rest_catalog.dart @@ -0,0 +1,505 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:storage_client/src/iceberg/iceberg_error.dart'; +import 'package:storage_client/src/iceberg/iceberg_types.dart'; +import 'package:storage_client/src/iceberg/table_requirement.dart'; +import 'package:storage_client/src/iceberg/table_update.dart'; + +class _IcebergResponse { + final int statusCode; + final Map headers; + final dynamic body; + + const _IcebergResponse(this.statusCode, this.headers, this.body); +} + +/// Client for the Apache Iceberg REST Catalog exposed by Supabase Storage under +/// an analytics bucket. It manages namespaces and tables within a warehouse +/// (an analytics bucket). +/// +/// ```dart +/// final catalog = storage.analyticsCatalog('my-analytics-bucket'); +/// await catalog.createNamespace(['analytics']); +/// ``` +class IcebergRestCatalog { + final String _baseUrl; + final Map _headers; + final http.Client? _httpClient; + final String? _warehouse; + final String? _accessDelegation; + final _log = Logger('supabase.storage.iceberg'); + final _random = Random.secure(); + + Future? _prefixFuture; + + /// Creates a catalog client. + /// + /// [baseUrl] is the base URL of the Iceberg REST Catalog, for Supabase + /// Storage this is `/iceberg`. [warehouse] identifies the + /// analytics bucket to operate on. [accessDelegation] requests server side + /// credential vending or request signing for table read and write + /// operations. + IcebergRestCatalog({ + required String baseUrl, + required Map headers, + String? warehouse, + List? accessDelegation, + http.Client? httpClient, + }) : _baseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/', + _headers = headers, + _warehouse = warehouse, + _httpClient = httpClient, + _accessDelegation = + (accessDelegation == null || accessDelegation.isEmpty) + ? null + : accessDelegation.map((delegation) => delegation.value).join(','); + + String _namespaceToPath(List namespace) => + namespace.map(Uri.encodeComponent).join('%1F'); + + String _idempotencyKey() { + final milliseconds = DateTime.now().millisecondsSinceEpoch; + final bytes = List.filled(16, 0); + bytes[0] = (milliseconds >> 40) & 0xff; + bytes[1] = (milliseconds >> 32) & 0xff; + bytes[2] = (milliseconds >> 24) & 0xff; + bytes[3] = (milliseconds >> 16) & 0xff; + bytes[4] = (milliseconds >> 8) & 0xff; + bytes[5] = milliseconds & 0xff; + for (var index = 6; index < 16; index++) { + bytes[index] = _random.nextInt(256); + } + bytes[6] = (bytes[6] & 0x0f) | 0x70; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .toList(); + return '${hex.sublist(0, 4).join()}-${hex.sublist(4, 6).join()}-' + '${hex.sublist(6, 8).join()}-${hex.sublist(8, 10).join()}-' + '${hex.sublist(10, 16).join()}'; + } + + Future _resolvePrefix() => _prefixFuture ??= _computePrefix(); + + Future _computePrefix() async { + if (_warehouse == null) { + return 'v1'; + } + try { + final response = await _request( + 'GET', + 'v1/config', + query: {'warehouse': _warehouse}, + ); + final config = response.body as Map; + final overrides = config['overrides'] as Map?; + final defaults = config['defaults'] as Map?; + final serverPrefix = + (overrides?['prefix'] ?? defaults?['prefix']) as String?; + return serverPrefix != null ? 'v1/$serverPrefix' : 'v1/$_warehouse'; + } catch (error) { + _prefixFuture = null; + return 'v1/$_warehouse'; + } + } + + Uri _buildUri(String path, Map? query) { + final buffer = StringBuffer('$_baseUrl$path'); + if (query != null && query.isNotEmpty) { + buffer.write('?'); + buffer.write( + query.entries + .map( + (entry) => + '${Uri.encodeQueryComponent(entry.key)}=' + '${Uri.encodeQueryComponent(entry.value)}', + ) + .join('&'), + ); + } + return Uri.parse(buffer.toString()); + } + + Future<_IcebergResponse> _request( + String method, + String path, { + Map? query, + Object? body, + Map? headers, + }) async { + final uri = _buildUri(path, query); + final requestHeaders = { + ..._headers, + if (body != null) 'Content-Type': 'application/json', + ...?headers, + }; + + final request = http.Request(method, uri)..headers.addAll(requestHeaders); + if (body != null) { + request.body = json.encode(body); + } + + _log.finest('Request: $method $uri'); + + final http.StreamedResponse streamedResponse; + try { + streamedResponse = _httpClient != null + ? await _httpClient.send(request) + : await request.send(); + } catch (error) { + throw IcebergNetworkException( + 'Network request failed: $error', + details: error, + ); + } + + final response = await http.Response.fromStream(streamedResponse); + + if (response.statusCode == 304) { + return _IcebergResponse(304, response.headers, null); + } + + final contentType = response.headers['content-type'] ?? ''; + final isJson = contentType.contains('application/json'); + final decoded = isJson && response.body.isNotEmpty + ? json.decode(response.body) + : response.body; + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw IcebergException.fromResponse(response.statusCode, decoded); + } + + return _IcebergResponse(response.statusCode, response.headers, decoded); + } + + Map _accessDelegationHeader() => _accessDelegation == null + ? const {} + : {'X-Iceberg-Access-Delegation': _accessDelegation}; + + /// Lists namespaces in the catalog, optionally under a parent namespace. + Future listNamespaces([ + ListNamespacesOptions? options, + ]) async { + final prefix = await _resolvePrefix(); + final query = { + if (options?.parent != null) 'parent': options!.parent!.join(''), + if (options?.pageToken != null) 'pageToken': options!.pageToken!, + if (options?.pageSize != null) 'pageSize': '${options!.pageSize}', + }; + final response = await _request( + 'GET', + '$prefix/namespaces', + query: query.isEmpty ? null : query, + ); + final body = response.body as Map; + return ListNamespacesResult( + namespaces: (body['namespaces'] as List? ?? []) + .map((namespace) => List.from(namespace as List)) + .toList(), + nextPageToken: body['next-page-token'] as String?, + ); + } + + /// Creates a namespace with optional [properties]. + Future> createNamespace( + List namespace, { + Map? properties, + }) async { + final prefix = await _resolvePrefix(); + final response = await _request( + 'POST', + '$prefix/namespaces', + body: { + 'namespace': namespace, + 'properties': ?properties, + }, + headers: {'Idempotency-Key': _idempotencyKey()}, + ); + final body = response.body as Map; + return List.from(body['namespace'] as List); + } + + /// Loads the properties of a namespace. + Future> loadNamespaceMetadata( + List namespace, + ) async { + final prefix = await _resolvePrefix(); + final response = await _request( + 'GET', + '$prefix/namespaces/${_namespaceToPath(namespace)}', + ); + final body = response.body as Map; + return Map.from(body['properties'] as Map? ?? const {}); + } + + /// Sets and removes properties on a namespace. + Future updateNamespaceProperties( + List namespace, { + Map? updates, + List? removals, + }) async { + final prefix = await _resolvePrefix(); + final response = await _request( + 'POST', + '$prefix/namespaces/${_namespaceToPath(namespace)}/properties', + body: { + 'updates': ?updates, + 'removals': ?removals, + }, + headers: {'Idempotency-Key': _idempotencyKey()}, + ); + return UpdateNamespacePropertiesResult.fromJson( + response.body as Map, + ); + } + + /// Drops a namespace. The namespace must contain no tables. + Future dropNamespace(List namespace) async { + final prefix = await _resolvePrefix(); + await _request( + 'DELETE', + '$prefix/namespaces/${_namespaceToPath(namespace)}', + headers: {'Idempotency-Key': _idempotencyKey()}, + ); + } + + /// Whether a namespace exists in the catalog. + Future namespaceExists(List namespace) async { + final prefix = await _resolvePrefix(); + try { + await _request( + 'HEAD', + '$prefix/namespaces/${_namespaceToPath(namespace)}', + ); + return true; + } on IcebergNotFoundException { + return false; + } + } + + /// Creates a namespace, returning `null` if it already exists. + Future?> createNamespaceIfNotExists( + List namespace, { + Map? properties, + }) async { + try { + return await createNamespace(namespace, properties: properties); + } on IcebergConflictException { + return null; + } + } + + /// Lists tables in a namespace. + Future listTables( + List namespace, [ + ListTablesOptions? options, + ]) async { + final prefix = await _resolvePrefix(); + final query = { + if (options?.pageToken != null) 'pageToken': options!.pageToken!, + if (options?.pageSize != null) 'pageSize': '${options!.pageSize}', + }; + final response = await _request( + 'GET', + '$prefix/namespaces/${_namespaceToPath(namespace)}/tables', + query: query.isEmpty ? null : query, + ); + final body = response.body as Map; + return ListTablesResult( + identifiers: (body['identifiers'] as List? ?? []) + .map( + (identifier) => + TableIdentifier.fromJson(identifier as Map), + ) + .toList(), + nextPageToken: body['next-page-token'] as String?, + ); + } + + /// Creates a table in a namespace and returns its metadata. + Future createTable( + List namespace, + CreateTableRequest request, + ) async { + final result = await createTableResult(namespace, request); + return result.metadata; + } + + /// Creates a table and returns the full load result, including any server + /// vended storage credentials. + Future createTableResult( + List namespace, + CreateTableRequest request, + ) async { + final prefix = await _resolvePrefix(); + final response = await _request( + 'POST', + '$prefix/namespaces/${_namespaceToPath(namespace)}/tables', + body: request.toJson(), + headers: { + 'Idempotency-Key': _idempotencyKey(), + ..._accessDelegationHeader(), + }, + ); + return LoadTableResult.fromJson( + response.body as Map, + etag: response.headers['etag'], + ); + } + + /// Creates a table if it does not already exist. + Future createTableIfNotExists( + List namespace, + CreateTableRequest request, + ) async { + try { + return await createTable(namespace, request); + } on IcebergConflictException { + return loadTable( + TableIdentifier(namespace: namespace, name: request.name), + ); + } + } + + /// Registers an existing metadata file as a table. + Future registerTable( + List namespace, + RegisterTableRequest request, + ) async { + final prefix = await _resolvePrefix(); + final response = await _request( + 'POST', + '$prefix/namespaces/${_namespaceToPath(namespace)}/register', + body: request.toJson(), + headers: { + 'Idempotency-Key': _idempotencyKey(), + ..._accessDelegationHeader(), + }, + ); + return LoadTableResult.fromJson( + response.body as Map, + etag: response.headers['etag'], + ).metadata; + } + + /// Loads a table's metadata. + /// + /// When [options] carries an `ifNoneMatch` ETag and the server answers 304, + /// this returns `null`. + Future loadTable(TableIdentifier id) async { + final result = await loadTableResult(id); + return result!.metadata; + } + + /// Loads a table and returns the full load result, or `null` when a + /// conditional request with [LoadTableOptions.ifNoneMatch] is not modified. + Future loadTableResult( + TableIdentifier id, [ + LoadTableOptions? options, + ]) async { + final prefix = await _resolvePrefix(); + final query = { + if (options?.snapshots != null) 'snapshots': options!.snapshots!.value, + }; + final response = await _request( + 'GET', + '$prefix/namespaces/${_namespaceToPath(id.namespace)}/tables/' + '${Uri.encodeComponent(id.name)}', + query: query.isEmpty ? null : query, + headers: { + ..._accessDelegationHeader(), + if (options?.ifNoneMatch != null) + 'If-None-Match': options!.ifNoneMatch!, + }, + ); + if (response.statusCode == 304) { + return null; + } + return LoadTableResult.fromJson( + response.body as Map, + etag: response.headers['etag'], + ); + } + + /// Whether a table exists in the catalog. + Future tableExists(TableIdentifier id) async { + final prefix = await _resolvePrefix(); + try { + await _request( + 'HEAD', + '$prefix/namespaces/${_namespaceToPath(id.namespace)}/tables/' + '${Uri.encodeComponent(id.name)}', + headers: _accessDelegationHeader(), + ); + return true; + } on IcebergNotFoundException { + return false; + } + } + + /// Commits [updates] to a table, optionally guarded by [requirements]. + Future updateTable( + TableIdentifier id, { + required List updates, + List requirements = const [], + }) async { + final prefix = await _resolvePrefix(); + final response = await _request( + 'POST', + '$prefix/namespaces/${_namespaceToPath(id.namespace)}/tables/' + '${Uri.encodeComponent(id.name)}', + body: { + 'requirements': requirements + .map((requirement) => requirement.toJson()) + .toList(), + 'updates': updates.map((update) => update.toJson()).toList(), + }, + headers: {'Idempotency-Key': _idempotencyKey()}, + ); + final body = response.body as Map; + final metadataLocation = body['metadata-location'] as String?; + if (metadataLocation == null) { + throw IcebergUnknownException( + 'Server returned a commit response without the required ' + '`metadata-location` field', + statusCode: response.statusCode, + details: body, + ); + } + return CommitTableResult( + metadataLocation: metadataLocation, + metadata: TableMetadata.fromJson( + body['metadata'] as Map, + ), + ); + } + + /// Drops a table, optionally purging its underlying data files. + Future dropTable(TableIdentifier id, {bool purge = false}) async { + final prefix = await _resolvePrefix(); + await _request( + 'DELETE', + '$prefix/namespaces/${_namespaceToPath(id.namespace)}/tables/' + '${Uri.encodeComponent(id.name)}', + query: {'purgeRequested': '$purge'}, + headers: {'Idempotency-Key': _idempotencyKey()}, + ); + } + + /// Renames a table. Servers may or may not support cross namespace renames. + Future renameTable( + TableIdentifier source, + TableIdentifier destination, + ) async { + final prefix = await _resolvePrefix(); + await _request( + 'POST', + '$prefix/tables/rename', + body: {'source': source.toJson(), 'destination': destination.toJson()}, + headers: {'Idempotency-Key': _idempotencyKey()}, + ); + } +} diff --git a/packages/storage_client/lib/src/iceberg/iceberg_types.dart b/packages/storage_client/lib/src/iceberg/iceberg_types.dart new file mode 100644 index 000000000..d03b43207 --- /dev/null +++ b/packages/storage_client/lib/src/iceberg/iceberg_types.dart @@ -0,0 +1,736 @@ +/// Identifies a table by its namespace and name. +class TableIdentifier { + /// The multi level namespace the table belongs to. + final List namespace; + + /// The name of the table. + final String name; + + const TableIdentifier({required this.namespace, required this.name}); + + factory TableIdentifier.fromJson(Map json) { + return TableIdentifier( + namespace: List.from(json['namespace'] as List), + name: json['name'] as String, + ); + } + + Map toJson() => {'namespace': namespace, 'name': name}; +} + +/// The direction used when sorting a [SortField]. +enum SortDirection { + ascending('asc'), + descending('desc'); + + const SortDirection(this.value); + + final String value; + + static SortDirection fromValue(String value) => + values.firstWhere((direction) => direction.value == value); +} + +/// Where null values are ordered relative to non null values in a [SortField]. +enum NullOrder { + nullsFirst('nulls-first'), + nullsLast('nulls-last'); + + const NullOrder(this.value); + + final String value; + + static NullOrder fromValue(String value) => + values.firstWhere((order) => order.value == value); +} + +/// The kind of reference a [SnapshotReference] points to. +enum SnapshotReferenceType { + tag('tag'), + branch('branch'); + + const SnapshotReferenceType(this.value); + + final String value; + + static SnapshotReferenceType fromValue(String value) => + values.firstWhere((type) => type.value == value); +} + +/// Access delegation mechanisms that can be requested from the server for +/// table read and write operations. +enum AccessDelegation { + vendedCredentials('vended-credentials'), + remoteSigning('remote-signing'); + + const AccessDelegation(this.value); + + final String value; +} + +/// Which snapshots the server should include when loading a table. +enum LoadTableSnapshots { + all('all'), + refs('refs'); + + const LoadTableSnapshots(this.value); + + final String value; +} + +/// A type in the Iceberg type system. Either a [PrimitiveType] represented by +/// a type string, or one of the nested types [StructType], [ListType] and +/// [MapType]. +sealed class IcebergType { + const IcebergType(); + + Object toJson(); + + static IcebergType fromJson(Object json) { + if (json is String) { + return PrimitiveType(json); + } + final map = json as Map; + switch (map['type']) { + case 'struct': + return StructType.fromJson(map); + case 'list': + return ListType.fromJson(map); + case 'map': + return MapType.fromJson(map); + default: + throw ArgumentError('Unknown Iceberg type: ${map['type']}'); + } + } +} + +/// A primitive Iceberg type such as `int`, `string`, `decimal(10,2)` or +/// `fixed[16]`. +class PrimitiveType extends IcebergType { + final String name; + + const PrimitiveType(this.name); + + @override + Object toJson() => name; +} + +/// A field within a [StructType] or a [TableSchema]. +class TableField { + final int id; + final String name; + final IcebergType type; + final bool required; + final String? doc; + final Object? initialDefault; + final Object? writeDefault; + + const TableField({ + required this.id, + required this.name, + required this.type, + required this.required, + this.doc, + this.initialDefault, + this.writeDefault, + }); + + factory TableField.fromJson(Map json) { + return TableField( + id: json['id'] as int, + name: json['name'] as String, + type: IcebergType.fromJson(json['type'] as Object), + required: json['required'] as bool, + doc: json['doc'] as String?, + initialDefault: json['initial-default'], + writeDefault: json['write-default'], + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'type': type.toJson(), + 'required': required, + 'doc': ?doc, + 'initial-default': ?initialDefault, + 'write-default': ?writeDefault, + }; +} + +/// A nested struct type containing [fields]. +class StructType extends IcebergType { + final List fields; + + const StructType({required this.fields}); + + factory StructType.fromJson(Map json) { + return StructType( + fields: (json['fields'] as List) + .map((field) => TableField.fromJson(field as Map)) + .toList(), + ); + } + + @override + Object toJson() => { + 'type': 'struct', + 'fields': fields.map((field) => field.toJson()).toList(), + }; +} + +/// A list type whose elements are of type [element]. +class ListType extends IcebergType { + final int elementId; + final IcebergType element; + final bool elementRequired; + + const ListType({ + required this.elementId, + required this.element, + required this.elementRequired, + }); + + factory ListType.fromJson(Map json) { + return ListType( + elementId: json['element-id'] as int, + element: IcebergType.fromJson(json['element'] as Object), + elementRequired: json['element-required'] as bool, + ); + } + + @override + Object toJson() => { + 'type': 'list', + 'element-id': elementId, + 'element': element.toJson(), + 'element-required': elementRequired, + }; +} + +/// A map type with keys of type [key] and values of type [value]. +class MapType extends IcebergType { + final int keyId; + final IcebergType key; + final int valueId; + final IcebergType value; + final bool valueRequired; + + const MapType({ + required this.keyId, + required this.key, + required this.valueId, + required this.value, + required this.valueRequired, + }); + + factory MapType.fromJson(Map json) { + return MapType( + keyId: json['key-id'] as int, + key: IcebergType.fromJson(json['key'] as Object), + valueId: json['value-id'] as int, + value: IcebergType.fromJson(json['value'] as Object), + valueRequired: json['value-required'] as bool, + ); + } + + @override + Object toJson() => { + 'type': 'map', + 'key-id': keyId, + 'key': key.toJson(), + 'value-id': valueId, + 'value': value.toJson(), + 'value-required': valueRequired, + }; +} + +/// The schema of a table, a struct of [fields]. +class TableSchema { + final List fields; + final int? schemaId; + final List? identifierFieldIds; + + const TableSchema({ + required this.fields, + this.schemaId, + this.identifierFieldIds, + }); + + factory TableSchema.fromJson(Map json) { + return TableSchema( + fields: (json['fields'] as List) + .map((field) => TableField.fromJson(field as Map)) + .toList(), + schemaId: json['schema-id'] as int?, + identifierFieldIds: json['identifier-field-ids'] == null + ? null + : List.from(json['identifier-field-ids'] as List), + ); + } + + Map toJson() => { + 'type': 'struct', + 'fields': fields.map((field) => field.toJson()).toList(), + 'schema-id': ?schemaId, + 'identifier-field-ids': ?identifierFieldIds, + }; +} + +/// A single field of a [PartitionSpec]. +class PartitionField { + final int sourceId; + final int? fieldId; + final String name; + final String transform; + + const PartitionField({ + required this.sourceId, + required this.name, + required this.transform, + this.fieldId, + }); + + factory PartitionField.fromJson(Map json) { + return PartitionField( + sourceId: json['source-id'] as int, + fieldId: json['field-id'] as int?, + name: json['name'] as String, + transform: json['transform'] as String, + ); + } + + Map toJson() => { + 'source-id': sourceId, + 'field-id': ?fieldId, + 'name': name, + 'transform': transform, + }; +} + +/// Describes how a table is partitioned. +class PartitionSpec { + final int? specId; + final List fields; + + const PartitionSpec({required this.fields, this.specId}); + + factory PartitionSpec.fromJson(Map json) { + return PartitionSpec( + specId: json['spec-id'] as int?, + fields: (json['fields'] as List) + .map( + (field) => PartitionField.fromJson(field as Map), + ) + .toList(), + ); + } + + Map toJson() => { + 'spec-id': ?specId, + 'fields': fields.map((field) => field.toJson()).toList(), + }; +} + +/// A single field of a [SortOrder]. +class SortField { + final int sourceId; + final String transform; + final SortDirection direction; + final NullOrder nullOrder; + + const SortField({ + required this.sourceId, + required this.transform, + required this.direction, + required this.nullOrder, + }); + + factory SortField.fromJson(Map json) { + return SortField( + sourceId: json['source-id'] as int, + transform: json['transform'] as String, + direction: SortDirection.fromValue(json['direction'] as String), + nullOrder: NullOrder.fromValue(json['null-order'] as String), + ); + } + + Map toJson() => { + 'source-id': sourceId, + 'transform': transform, + 'direction': direction.value, + 'null-order': nullOrder.value, + }; +} + +/// Describes how a table is sorted when written. +class SortOrder { + final int orderId; + final List fields; + + const SortOrder({required this.orderId, required this.fields}); + + factory SortOrder.fromJson(Map json) { + return SortOrder( + orderId: json['order-id'] as int, + fields: (json['fields'] as List) + .map((field) => SortField.fromJson(field as Map)) + .toList(), + ); + } + + Map toJson() => { + 'order-id': orderId, + 'fields': fields.map((field) => field.toJson()).toList(), + }; +} + +/// A reference (branch or tag) pointing at a snapshot. +class SnapshotReference { + final SnapshotReferenceType type; + final int snapshotId; + final int? maxReferenceAgeMs; + final int? maxSnapshotAgeMs; + final int? minSnapshotsToKeep; + + const SnapshotReference({ + required this.type, + required this.snapshotId, + this.maxReferenceAgeMs, + this.maxSnapshotAgeMs, + this.minSnapshotsToKeep, + }); + + factory SnapshotReference.fromJson(Map json) { + return SnapshotReference( + type: SnapshotReferenceType.fromValue(json['type'] as String), + snapshotId: json['snapshot-id'] as int, + maxReferenceAgeMs: json['max-ref-age-ms'] as int?, + maxSnapshotAgeMs: json['max-snapshot-age-ms'] as int?, + minSnapshotsToKeep: json['min-snapshots-to-keep'] as int?, + ); + } + + Map toJson() => { + 'type': type.value, + 'snapshot-id': snapshotId, + 'max-ref-age-ms': ?maxReferenceAgeMs, + 'max-snapshot-age-ms': ?maxSnapshotAgeMs, + 'min-snapshots-to-keep': ?minSnapshotsToKeep, + }; +} + +/// A snapshot of a table at a point in time. +class Snapshot { + final int snapshotId; + final int? parentSnapshotId; + final int? sequenceNumber; + final int timestampMs; + final String manifestList; + final Map summary; + final int? schemaId; + + const Snapshot({ + required this.snapshotId, + required this.timestampMs, + required this.manifestList, + required this.summary, + this.parentSnapshotId, + this.sequenceNumber, + this.schemaId, + }); + + factory Snapshot.fromJson(Map json) { + return Snapshot( + snapshotId: json['snapshot-id'] as int, + parentSnapshotId: json['parent-snapshot-id'] as int?, + sequenceNumber: json['sequence-number'] as int?, + timestampMs: json['timestamp-ms'] as int, + manifestList: json['manifest-list'] as String, + summary: Map.from(json['summary'] as Map? ?? const {}), + schemaId: json['schema-id'] as int?, + ); + } + + Map toJson() => { + 'snapshot-id': snapshotId, + 'parent-snapshot-id': ?parentSnapshotId, + 'sequence-number': ?sequenceNumber, + 'timestamp-ms': timestampMs, + 'manifest-list': manifestList, + 'summary': summary, + 'schema-id': ?schemaId, + }; +} + +/// Metadata describing the current state of a table, returned when loading or +/// committing to a table. +class TableMetadata { + final int formatVersion; + final String tableUuid; + final String? location; + final int? lastUpdatedMs; + final int? lastColumnId; + final List schemas; + final int currentSchemaId; + final List partitionSpecs; + final int? defaultSpecId; + final List sortOrders; + final int? defaultSortOrderId; + final Map properties; + final String? metadataLocation; + final int? currentSnapshotId; + final List snapshots; + final Map? refs; + + const TableMetadata({ + required this.formatVersion, + required this.tableUuid, + required this.schemas, + required this.currentSchemaId, + required this.partitionSpecs, + required this.sortOrders, + required this.properties, + this.location, + this.lastUpdatedMs, + this.lastColumnId, + this.defaultSpecId, + this.defaultSortOrderId, + this.metadataLocation, + this.currentSnapshotId, + this.snapshots = const [], + this.refs, + }); + + factory TableMetadata.fromJson(Map json) { + final refs = json['refs'] as Map?; + return TableMetadata( + formatVersion: json['format-version'] as int, + tableUuid: json['table-uuid'] as String, + location: json['location'] as String?, + lastUpdatedMs: json['last-updated-ms'] as int?, + lastColumnId: json['last-column-id'] as int?, + schemas: (json['schemas'] as List? ?? []) + .map((schema) => TableSchema.fromJson(schema as Map)) + .toList(), + currentSchemaId: json['current-schema-id'] as int, + partitionSpecs: (json['partition-specs'] as List? ?? []) + .map((spec) => PartitionSpec.fromJson(spec as Map)) + .toList(), + defaultSpecId: json['default-spec-id'] as int?, + sortOrders: (json['sort-orders'] as List? ?? []) + .map((order) => SortOrder.fromJson(order as Map)) + .toList(), + defaultSortOrderId: json['default-sort-order-id'] as int?, + properties: Map.from( + json['properties'] as Map? ?? const {}, + ), + metadataLocation: json['metadata-location'] as String?, + currentSnapshotId: json['current-snapshot-id'] as int?, + snapshots: (json['snapshots'] as List? ?? []) + .map( + (snapshot) => Snapshot.fromJson(snapshot as Map), + ) + .toList(), + refs: refs?.map( + (key, value) => MapEntry( + key, + SnapshotReference.fromJson(value as Map), + ), + ), + ); + } + + /// The current (active) schema, or `null` when it can not be resolved. + TableSchema? get currentSchema { + for (final schema in schemas) { + if (schema.schemaId == currentSchemaId) { + return schema; + } + } + return null; + } +} + +/// Temporary storage credentials vended by the server for a table path prefix. +class StorageCredential { + final String prefix; + final Map config; + + const StorageCredential({required this.prefix, required this.config}); + + factory StorageCredential.fromJson(Map json) { + return StorageCredential( + prefix: json['prefix'] as String, + config: Map.from(json['config'] as Map? ?? const {}), + ); + } +} + +/// The full result of loading, creating or registering a table, including the +/// server provided configuration, vended storage credentials and the response +/// ETag. +class LoadTableResult { + final TableMetadata metadata; + final String? metadataLocation; + final Map? config; + final List? storageCredentials; + final String? etag; + + const LoadTableResult({ + required this.metadata, + this.metadataLocation, + this.config, + this.storageCredentials, + this.etag, + }); + + factory LoadTableResult.fromJson( + Map json, { + String? etag, + }) { + return LoadTableResult( + metadata: TableMetadata.fromJson( + json['metadata'] as Map, + ), + metadataLocation: json['metadata-location'] as String?, + config: json['config'] == null ? null : Map.from(json['config'] as Map), + storageCredentials: (json['storage-credentials'] as List?) + ?.map( + (credential) => + StorageCredential.fromJson(credential as Map), + ) + .toList(), + etag: etag, + ); + } +} + +/// Request describing a table to create. +class CreateTableRequest { + final String name; + final TableSchema schema; + final String? location; + final PartitionSpec? partitionSpec; + final SortOrder? writeOrder; + final Map? properties; + final bool? stageCreate; + + const CreateTableRequest({ + required this.name, + required this.schema, + this.location, + this.partitionSpec, + this.writeOrder, + this.properties, + this.stageCreate, + }); + + Map toJson() => { + 'name': name, + 'schema': schema.toJson(), + 'location': ?location, + 'partition-spec': ?partitionSpec?.toJson(), + 'write-order': ?writeOrder?.toJson(), + 'properties': ?properties, + 'stage-create': ?stageCreate, + }; +} + +/// Request to register an existing metadata file as a table. +class RegisterTableRequest { + final String name; + final String metadataLocation; + final bool? overwrite; + + const RegisterTableRequest({ + required this.name, + required this.metadataLocation, + this.overwrite, + }); + + Map toJson() => { + 'name': name, + 'metadata-location': metadataLocation, + 'overwrite': ?overwrite, + }; +} + +/// The result of a namespace properties update. +class UpdateNamespacePropertiesResult { + final List updated; + final List removed; + final List? missing; + + const UpdateNamespacePropertiesResult({ + required this.updated, + required this.removed, + this.missing, + }); + + factory UpdateNamespacePropertiesResult.fromJson(Map json) { + return UpdateNamespacePropertiesResult( + updated: List.from(json['updated'] as List? ?? const []), + removed: List.from(json['removed'] as List? ?? const []), + missing: json['missing'] == null + ? null + : List.from(json['missing'] as List), + ); + } +} + +/// The result of committing updates to a table. +class CommitTableResult { + final String metadataLocation; + final TableMetadata metadata; + + const CommitTableResult({ + required this.metadataLocation, + required this.metadata, + }); +} + +/// The result of listing namespaces. +class ListNamespacesResult { + final List> namespaces; + final String? nextPageToken; + + const ListNamespacesResult({required this.namespaces, this.nextPageToken}); +} + +/// The result of listing tables in a namespace. +class ListTablesResult { + final List identifiers; + final String? nextPageToken; + + const ListTablesResult({required this.identifiers, this.nextPageToken}); +} + +/// Options for listing namespaces. +class ListNamespacesOptions { + final List? parent; + final String? pageToken; + final int? pageSize; + + const ListNamespacesOptions({this.parent, this.pageToken, this.pageSize}); +} + +/// Options for listing tables. +class ListTablesOptions { + final String? pageToken; + final int? pageSize; + + const ListTablesOptions({this.pageToken, this.pageSize}); +} + +/// Options for loading a table. +class LoadTableOptions { + /// ETag from a previous response. When the table is unchanged the server + /// responds with 304 and the load returns `null`. + final String? ifNoneMatch; + + /// Which snapshots the server should include in the returned metadata. + final LoadTableSnapshots? snapshots; + + const LoadTableOptions({this.ifNoneMatch, this.snapshots}); +} diff --git a/packages/storage_client/lib/src/iceberg/table_requirement.dart b/packages/storage_client/lib/src/iceberg/table_requirement.dart new file mode 100644 index 000000000..db2df292f --- /dev/null +++ b/packages/storage_client/lib/src/iceberg/table_requirement.dart @@ -0,0 +1,97 @@ +/// A precondition that must hold for a table commit to be applied. The server +/// rejects the commit if any requirement is not met. +sealed class TableRequirement { + const TableRequirement(); + + Map toJson(); +} + +class AssertCreate extends TableRequirement { + const AssertCreate(); + + @override + Map toJson() => {'type': 'assert-create'}; +} + +class AssertTableUuid extends TableRequirement { + final String uuid; + + const AssertTableUuid(this.uuid); + + @override + Map toJson() => {'type': 'assert-table-uuid', 'uuid': uuid}; +} + +class AssertReferenceSnapshotId extends TableRequirement { + final String reference; + final int? snapshotId; + + const AssertReferenceSnapshotId({required this.reference, this.snapshotId}); + + @override + Map toJson() => { + 'type': 'assert-ref-snapshot-id', + 'ref': reference, + 'snapshot-id': snapshotId, + }; +} + +class AssertLastAssignedFieldId extends TableRequirement { + final int lastAssignedFieldId; + + const AssertLastAssignedFieldId(this.lastAssignedFieldId); + + @override + Map toJson() => { + 'type': 'assert-last-assigned-field-id', + 'last-assigned-field-id': lastAssignedFieldId, + }; +} + +class AssertCurrentSchemaId extends TableRequirement { + final int currentSchemaId; + + const AssertCurrentSchemaId(this.currentSchemaId); + + @override + Map toJson() => { + 'type': 'assert-current-schema-id', + 'current-schema-id': currentSchemaId, + }; +} + +class AssertLastAssignedPartitionId extends TableRequirement { + final int lastAssignedPartitionId; + + const AssertLastAssignedPartitionId(this.lastAssignedPartitionId); + + @override + Map toJson() => { + 'type': 'assert-last-assigned-partition-id', + 'last-assigned-partition-id': lastAssignedPartitionId, + }; +} + +class AssertDefaultSpecId extends TableRequirement { + final int defaultSpecId; + + const AssertDefaultSpecId(this.defaultSpecId); + + @override + Map toJson() => { + 'type': 'assert-default-spec-id', + 'default-spec-id': defaultSpecId, + }; +} + +class AssertDefaultSortOrderId extends TableRequirement { + final int defaultSortOrderId; + + const AssertDefaultSortOrderId(this.defaultSortOrderId); + + @override + Map toJson() => { + 'type': 'assert-default-sort-order-id', + 'default-sort-order-id': defaultSortOrderId, + }; +} diff --git a/packages/storage_client/lib/src/iceberg/table_update.dart b/packages/storage_client/lib/src/iceberg/table_update.dart new file mode 100644 index 000000000..257e3bc53 --- /dev/null +++ b/packages/storage_client/lib/src/iceberg/table_update.dart @@ -0,0 +1,220 @@ +import 'package:storage_client/src/iceberg/iceberg_types.dart'; + +/// A single change applied to a table as part of a commit. The common data +/// definition updates are modelled as dedicated subclasses. Updates that are +/// not modelled (for example snapshot, statistics and encryption key updates) +/// can be sent with [TableUpdate.raw]. +sealed class TableUpdate { + const TableUpdate(); + + Map toJson(); + + /// An update expressed as a raw JSON map, for actions without a dedicated + /// subclass. The [action] key is added automatically. + const factory TableUpdate.raw(String action, Map body) = + RawTableUpdate; +} + +class RawTableUpdate extends TableUpdate { + final String action; + final Map body; + + const RawTableUpdate(this.action, this.body); + + @override + Map toJson() => {...body, 'action': action}; +} + +class AssignUuidUpdate extends TableUpdate { + final String uuid; + + const AssignUuidUpdate(this.uuid); + + @override + Map toJson() => {'action': 'assign-uuid', 'uuid': uuid}; +} + +class UpgradeFormatVersionUpdate extends TableUpdate { + final int formatVersion; + + const UpgradeFormatVersionUpdate(this.formatVersion); + + @override + Map toJson() => { + 'action': 'upgrade-format-version', + 'format-version': formatVersion, + }; +} + +class AddSchemaUpdate extends TableUpdate { + final TableSchema schema; + + const AddSchemaUpdate(this.schema); + + @override + Map toJson() => { + 'action': 'add-schema', + 'schema': schema.toJson(), + }; +} + +class SetCurrentSchemaUpdate extends TableUpdate { + final int schemaId; + + const SetCurrentSchemaUpdate(this.schemaId); + + @override + Map toJson() => { + 'action': 'set-current-schema', + 'schema-id': schemaId, + }; +} + +class AddPartitionSpecUpdate extends TableUpdate { + final PartitionSpec spec; + + const AddPartitionSpecUpdate(this.spec); + + @override + Map toJson() => { + 'action': 'add-spec', + 'spec': spec.toJson(), + }; +} + +class SetDefaultSpecUpdate extends TableUpdate { + final int specId; + + const SetDefaultSpecUpdate(this.specId); + + @override + Map toJson() => { + 'action': 'set-default-spec', + 'spec-id': specId, + }; +} + +class AddSortOrderUpdate extends TableUpdate { + final SortOrder sortOrder; + + const AddSortOrderUpdate(this.sortOrder); + + @override + Map toJson() => { + 'action': 'add-sort-order', + 'sort-order': sortOrder.toJson(), + }; +} + +class SetDefaultSortOrderUpdate extends TableUpdate { + final int sortOrderId; + + const SetDefaultSortOrderUpdate(this.sortOrderId); + + @override + Map toJson() => { + 'action': 'set-default-sort-order', + 'sort-order-id': sortOrderId, + }; +} + +class SetLocationUpdate extends TableUpdate { + final String location; + + const SetLocationUpdate(this.location); + + @override + Map toJson() => { + 'action': 'set-location', + 'location': location, + }; +} + +class SetPropertiesUpdate extends TableUpdate { + final Map updates; + + const SetPropertiesUpdate(this.updates); + + @override + Map toJson() => { + 'action': 'set-properties', + 'updates': updates, + }; +} + +class RemovePropertiesUpdate extends TableUpdate { + final List removals; + + const RemovePropertiesUpdate(this.removals); + + @override + Map toJson() => { + 'action': 'remove-properties', + 'removals': removals, + }; +} + +class RemovePartitionSpecsUpdate extends TableUpdate { + final List specIds; + + const RemovePartitionSpecsUpdate(this.specIds); + + @override + Map toJson() => { + 'action': 'remove-partition-specs', + 'spec-ids': specIds, + }; +} + +class RemoveSchemasUpdate extends TableUpdate { + final List schemaIds; + + const RemoveSchemasUpdate(this.schemaIds); + + @override + Map toJson() => { + 'action': 'remove-schemas', + 'schema-ids': schemaIds, + }; +} + +class SetSnapshotReferenceUpdate extends TableUpdate { + final String referenceName; + final SnapshotReference reference; + + const SetSnapshotReferenceUpdate({ + required this.referenceName, + required this.reference, + }); + + @override + Map toJson() => { + 'action': 'set-snapshot-ref', + 'ref-name': referenceName, + ...reference.toJson(), + }; +} + +class RemoveSnapshotReferenceUpdate extends TableUpdate { + final String referenceName; + + const RemoveSnapshotReferenceUpdate(this.referenceName); + + @override + Map toJson() => { + 'action': 'remove-snapshot-ref', + 'ref-name': referenceName, + }; +} + +class RemoveSnapshotsUpdate extends TableUpdate { + final List snapshotIds; + + const RemoveSnapshotsUpdate(this.snapshotIds); + + @override + Map toJson() => { + 'action': 'remove-snapshots', + 'snapshot-ids': snapshotIds, + }; +} diff --git a/packages/storage_client/lib/src/storage_bucket_api.dart b/packages/storage_client/lib/src/storage_bucket_api.dart index dba6b20f3..a69fab7c9 100644 --- a/packages/storage_client/lib/src/storage_bucket_api.dart +++ b/packages/storage_client/lib/src/storage_bucket_api.dart @@ -2,6 +2,7 @@ import 'package:http/http.dart'; import 'package:meta/meta.dart'; import 'package:storage_client/src/fetch.dart'; import 'package:storage_client/src/types.dart'; +import 'package:supabase_common/supabase_common.dart'; class StorageBucketApi { final String url; @@ -24,9 +25,19 @@ class StorageBucketApi { } /// Retrieves the details of all Storage buckets within an existing project. - Future> listBuckets() async { - final FetchOptions options = FetchOptions(headers: headers); - final response = await storageFetch.get('$url/bucket', options: options); + /// + /// [options] optionally filters, sorts and paginates the returned buckets. + /// Calling [listBuckets] without any options returns all buckets. + Future> listBuckets([ListBucketsOptions? options]) async { + final FetchOptions fetchOptions = FetchOptions(headers); + final queryParameters = options?.toQueryParameters() ?? const {}; + final uri = Uri.parse('$url/bucket').replace( + queryParameters: queryParameters.isEmpty ? null : queryParameters, + ); + final response = await storageFetch.get( + uri.toString(), + options: fetchOptions, + ); final buckets = List.from( (response as List).map( (value) => Bucket.fromJson(value), @@ -39,7 +50,7 @@ class StorageBucketApi { /// /// [id] is the unique identifier of the bucket you would like to retrieve. Future getBucket(String id) async { - final FetchOptions options = FetchOptions(headers: headers); + final FetchOptions options = FetchOptions(headers); final response = await storageFetch.get( '$url/bucket/$id', options: options, @@ -67,7 +78,7 @@ class StorageBucketApi { String id, [ BucketOptions bucketOptions = const BucketOptions(public: false), ]) async { - final FetchOptions options = FetchOptions(headers: headers); + final FetchOptions options = FetchOptions(headers); final response = await storageFetch.post( '$url/bucket', _bucketPayload(id, bucketOptions), @@ -86,7 +97,7 @@ class StorageBucketApi { String id, BucketOptions bucketOptions, ) async { - final FetchOptions options = FetchOptions(headers: headers); + final FetchOptions options = FetchOptions(headers); final response = await storageFetch.put( '$url/bucket/$id', _bucketPayload(id, bucketOptions), @@ -100,7 +111,7 @@ class StorageBucketApi { /// /// [id] is the unique identifier of the bucket you would like to empty. Future emptyBucket(String id) async { - final FetchOptions options = FetchOptions(headers: headers); + final FetchOptions options = FetchOptions(headers); final response = await storageFetch.post( '$url/bucket/$id/empty', {}, @@ -114,7 +125,7 @@ class StorageBucketApi { /// /// [id] is the unique identifier of the bucket you would like to delete. Future deleteBucket(String id) async { - final FetchOptions options = FetchOptions(headers: headers); + final FetchOptions options = FetchOptions(headers); final response = await storageFetch.delete( '$url/bucket/$id', {}, @@ -122,4 +133,81 @@ class StorageBucketApi { ); return (response as Map)['message'] as String; } + + /// Purges the CDN cache for an entire bucket. + /// + /// Invalidates the CDN cache for every object in the bucket [id]. Maps to + /// `DELETE /cdn/{bucket}` on the storage server. + /// + /// When [transformations] is `true`, only the resized/formatted variants are + /// purged, leaving the original cached files intact. When omitted the bucket + /// cache is purged. + /// + /// Requires the service-role key and the tenant `purgeCache` feature to be + /// enabled on the storage server. + Future purgeBucketCache( + String id, { + bool transformations = false, + }) async { + var requestUrl = Uri.parse('$url/cdn/$id'); + if (transformations) { + requestUrl = requestUrl.replace( + queryParameters: {'transformations': 'true'}, + ); + } + final response = await storageFetch.delete( + requestUrl.toString(), + {}, + options: FetchOptions(headers), + ); + return (response as Map)['message'] as String; + } + + /// Creates a new analytics bucket backed by the Apache Iceberg table format. + /// + /// [id] is the unique identifier for the bucket you are creating. + Future createAnalyticsBucket(String id) async { + final FetchOptions options = FetchOptions(headers); + final response = await storageFetch.post( + '$url/iceberg/bucket', + {'name': id}, + options: options, + ); + return AnalyticsBucket.fromJson(response as Map); + } + + /// Retrieves the details of all analytics buckets within an existing project. + /// + /// [options] optionally filters, sorts and paginates the returned buckets. + /// Calling [listAnalyticsBuckets] without any options returns all buckets. + Future> listAnalyticsBuckets([ + ListBucketsOptions? options, + ]) async { + final FetchOptions fetchOptions = FetchOptions(headers); + final queryParameters = options?.toQueryParameters() ?? const {}; + final uri = Uri.parse('$url/iceberg/bucket').replace( + queryParameters: queryParameters.isEmpty ? null : queryParameters, + ); + final response = await storageFetch.get( + uri.toString(), + options: fetchOptions, + ); + return (response as List) + .map((value) => AnalyticsBucket.fromJson(value as Map)) + .toList(); + } + + /// Deletes an existing analytics bucket. A bucket can't be deleted while it + /// still contains namespaces or tables. + /// + /// [id] is the unique identifier of the bucket you would like to delete. + Future deleteAnalyticsBucket(String id) async { + final FetchOptions options = FetchOptions(headers); + final response = await storageFetch.delete( + '$url/iceberg/bucket/$id', + {}, + options: options, + ); + return (response as Map)['message'] as String; + } } diff --git a/packages/storage_client/lib/src/storage_client.dart b/packages/storage_client/lib/src/storage_client.dart index e8a67b930..3640e8128 100644 --- a/packages/storage_client/lib/src/storage_client.dart +++ b/packages/storage_client/lib/src/storage_client.dart @@ -1,12 +1,17 @@ import 'package:http/http.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; import 'package:storage_client/src/constants.dart'; +import 'package:storage_client/src/iceberg/iceberg_rest_catalog.dart'; +import 'package:storage_client/src/iceberg/iceberg_types.dart'; import 'package:storage_client/src/storage_bucket_api.dart'; import 'package:storage_client/src/storage_file_api.dart'; +import 'package:storage_client/src/vector_client.dart'; import 'package:storage_client/src/version.dart'; class SupabaseStorageClient extends StorageBucketApi { final int _defaultRetryAttempts; + final Client? _httpClient; final _log = Logger('supabase.storage'); /// To create a [SupabaseStorageClient], you need to provide an [url] and [headers]. @@ -48,6 +53,7 @@ class SupabaseStorageClient extends StorageBucketApi { 'retryAttempts has to be greater than or equal to 0', ), _defaultRetryAttempts = retryAttempts, + _httpClient = httpClient, super( useNewHostname ? _transformStorageUrl(url) : url, {...Constants.defaultHeaders, ...headers}, @@ -105,6 +111,45 @@ class SupabaseStorageClient extends StorageBucketApi { ); } + /// Returns an Iceberg REST Catalog client for an analytics bucket. + /// + /// [bucketId] is the identifier of the analytics bucket (the warehouse) whose + /// namespaces and tables you want to manage. + /// + /// ```dart + /// final catalog = storage.analyticsCatalog('my-analytics-bucket'); + /// await catalog.createNamespace(['analytics']); + /// ``` + IcebergRestCatalog analyticsCatalog( + String bucketId, { + List? accessDelegation, + }) { + return IcebergRestCatalog( + baseUrl: '$url/iceberg', + headers: headers, + warehouse: bucketId, + accessDelegation: accessDelegation, + httpClient: _httpClient, + ); + } + + /// Access the Storage Vectors API to manage vector buckets, indexes and the + /// vectors stored inside them. + /// + /// This API is part of a public alpha and may not be available to every + /// project. + /// + /// ```dart + /// final vectors = storage.vectors; + /// await vectors.createBucket('embeddings'); + /// ``` + @experimental + late final SupabaseVectorsClient vectors = SupabaseVectorsClient( + '$url/vector', + headers, + storageFetch, + ); + void setAuth(String jwt) { headers['Authorization'] = 'Bearer $jwt'; } diff --git a/packages/storage_client/lib/src/storage_file_api.dart b/packages/storage_client/lib/src/storage_file_api.dart index 3ce31a090..5b8a59c70 100644 --- a/packages/storage_client/lib/src/storage_file_api.dart +++ b/packages/storage_client/lib/src/storage_file_api.dart @@ -2,8 +2,9 @@ import 'dart:typed_data'; import 'package:storage_client/src/fetch.dart'; import 'package:storage_client/src/types.dart'; +import 'package:supabase_common/supabase_common.dart'; -import 'file_io.dart' if (dart.library.js) './file_stub.dart'; +import 'file_stub.dart' if (dart.library.io) './file_io.dart'; class StorageFileApi { final String url; @@ -51,7 +52,7 @@ class StorageFileApi { return path.replaceAll(RegExp(r'^/|/$'), '').replaceAll(RegExp(r'/+'), '/'); } - FetchOptions get _fetchOptions => FetchOptions(headers: headers); + FetchOptions get _fetchOptions => FetchOptions(headers); void _assertValidRetryAttempts(int? retryAttempts) { assert( @@ -212,12 +213,10 @@ class StorageFileApi { final data = await _storageFetch.post( '$url/object/upload/sign/$finalPath', {}, - options: FetchOptions( - headers: { - ...headers, - if (upsert) 'x-upsert': 'true', - }, - ), + options: FetchOptions({ + ...headers, + if (upsert) 'x-upsert': 'true', + }), ); final signedUrl = Uri.parse('$url${data['url']}'); @@ -373,11 +372,15 @@ class StorageFileApi { /// browser by setting the response's `Content-Disposition` header. Use /// [DownloadBehavior.withOriginalName] to keep the original file name or /// [DownloadBehavior.named] to override it. + /// + /// [cacheNonce] appends a `cacheNonce` query parameter to the URL to bypass + /// CDN caching for a specific file version. Future createSignedUrl( String path, int expiresIn, { TransformOptions? transform, DownloadBehavior? download, + String? cacheNonce, }) async { final finalPath = _getFinalPath(path); final options = _fetchOptions; @@ -394,7 +397,11 @@ class StorageFileApi { if (signedUrlPath == null) { throw StorageException('No signed URL returned by API'); } - return _withDownload('$url$signedUrlPath', download); + return _withUrlOptions( + '$url$signedUrlPath', + download: download, + cacheNonce: cacheNonce, + ); } // TODO(v3): Remove this deprecated overload and rename createSignedUrlsResult @@ -415,11 +422,13 @@ class StorageFileApi { List paths, int expiresIn, { DownloadBehavior? download, + String? cacheNonce, }) async { final results = await createSignedUrlsResult( paths, expiresIn, download: download, + cacheNonce: cacheNonce, ); return results .whereType() @@ -444,10 +453,14 @@ class StorageFileApi { /// browser by setting the response's `Content-Disposition` header. Use /// [DownloadBehavior.withOriginalName] to keep the original file name or /// [DownloadBehavior.named] to override it. + /// + /// [cacheNonce] appends a `cacheNonce` query parameter to each URL to bypass + /// CDN caching for a specific file version. Future> createSignedUrlsResult( List paths, int expiresIn, { DownloadBehavior? download, + String? cacheNonce, }) async { final options = _fetchOptions; final response = await _storageFetch.post( @@ -464,7 +477,11 @@ class StorageFileApi { if (signedUrlPath != null) { return SignedUrlSuccess( path: path, - signedUrl: _withDownload('$url$signedUrlPath', download), + signedUrl: _withUrlOptions( + '$url$signedUrlPath', + download: download, + cacheNonce: cacheNonce, + ), ); } return SignedUrlFailure( @@ -482,30 +499,89 @@ class StorageFileApi { /// [transform] download a transformed variant of the image with the provided options /// /// [queryParams] additional query parameters to be added to the URL + /// + /// [cacheNonce] adds a `cacheNonce` query parameter to bypass CDN caching for + /// a specific file version. Future download( String path, { TransformOptions? transform, Map? queryParams, + String? cacheNonce, }) async { - final wantsTransformations = transform != null; - final finalPath = _getFinalPath(path); - final renderPath = wantsTransformations + final fetchUrl = _downloadUri( + path, + transform: transform, + queryParams: queryParams, + cacheNonce: cacheNonce, + ); + + final response = await _storageFetch.get( + fetchUrl.toString(), + options: FetchOptions(headers, noResolveJson: true), + ); + return response as Uint8List; + } + + /// Builds the download URL shared by [download] and [downloadStream], + /// selecting the render endpoint when an image transformation is requested + /// and appending transform, [queryParams] and [cacheNonce] query parameters. + Uri _downloadUri( + String path, { + TransformOptions? transform, + Map? queryParams, + String? cacheNonce, + }) { + final transformationQuery = transform?.toQueryParams ?? {}; + final renderPath = transformationQuery.isNotEmpty ? 'render/image/authenticated' : 'object'; - Map query = transform?.toQueryParams ?? {}; - query.addAll(queryParams ?? {}); + final query = { + ...transformationQuery, + ...?queryParams, + 'cacheNonce': ?cacheNonce, + }; - final options = FetchOptions(headers: headers, noResolveJson: true); + return Uri.parse( + '$url/$renderPath/${_getFinalPath(path)}', + ).replace(queryParameters: query); + } - var fetchUrl = Uri.parse('$url/$renderPath/$finalPath'); - fetchUrl = fetchUrl.replace(queryParameters: query); + /// Downloads a file as a byte stream for memory-efficient handling of large + /// files. + /// + /// Unlike [download], the response body is not buffered into a [Uint8List]; + /// the stream yields the bytes as they arrive. The request is sent when the + /// stream is listened to, and a non-success response surfaces as a + /// [StorageException] on the stream before any bytes are emitted. + /// + /// [path] is the file path to be downloaded, including the path and file + /// name. For example `downloadStream('folder/image.png')`. + /// + /// [transform] download a transformed variant of the image with the provided + /// options. + /// + /// [queryParams] additional query parameters to be added to the URL. + /// + /// [cacheNonce] adds a `cacheNonce` query parameter to bypass CDN caching for + /// a specific file version. + Stream downloadStream( + String path, { + TransformOptions? transform, + Map? queryParams, + String? cacheNonce, + }) { + final fetchUrl = _downloadUri( + path, + transform: transform, + queryParams: queryParams, + cacheNonce: cacheNonce, + ); - final response = await _storageFetch.get( + return _storageFetch.getStream( fetchUrl.toString(), - options: options, + options: FetchOptions(headers, noResolveJson: true), ); - return response as Uint8List; } /// Retrieves the details of an existing file @@ -549,33 +625,55 @@ class StorageFileApi { /// browser by setting the response's `Content-Disposition` header. Use /// [DownloadBehavior.withOriginalName] to keep the original file name or /// [DownloadBehavior.named] to override it. + /// + /// [cacheNonce] appends a `cacheNonce` query parameter to the URL to bypass + /// CDN caching for a specific file version. String getPublicUrl( String path, { TransformOptions? transform, DownloadBehavior? download, + String? cacheNonce, }) { final finalPath = _getFinalPath(path); - final wantsTransformation = transform != null; - final renderPath = wantsTransformation ? 'render/image' : 'object'; final transformationQuery = transform?.toQueryParams; + final wantsTransformation = + transformationQuery != null && transformationQuery.isNotEmpty; + final renderPath = wantsTransformation ? 'render/image' : 'object'; var publicUrl = Uri.parse('$url/$renderPath/public/$finalPath'); - publicUrl = publicUrl.replace(queryParameters: transformationQuery); + if (wantsTransformation) { + publicUrl = publicUrl.replace(queryParameters: transformationQuery); + } - return _withDownload(publicUrl.toString(), download); + return _withUrlOptions( + publicUrl.toString(), + download: download, + cacheNonce: cacheNonce, + ); } - /// Appends a `download` query parameter to [urlString] when [download] is - /// set, so the response is served with a `Content-Disposition` header. - String _withDownload(String urlString, DownloadBehavior? download) { - if (download == null) { - return urlString; + /// Appends the optional `download` and `cacheNonce` query parameters to + /// [urlString], in that order, when they are set. + String _withUrlOptions( + String urlString, { + DownloadBehavior? download, + String? cacheNonce, + }) { + var result = urlString; + if (download != null) { + result = _appendQueryParameter(result, 'download', download.queryValue); + } + if (cacheNonce != null) { + result = _appendQueryParameter(result, 'cacheNonce', cacheNonce); } + return result; + } + + String _appendQueryParameter(String urlString, String key, String value) { final separator = urlString.contains('?') ? '&' : '?'; - return '$urlString${separator}download=' - '${Uri.encodeQueryComponent(download.queryValue)}'; + return '$urlString$separator$key=${Uri.encodeQueryComponent(value)}'; } /// Deletes files within the same bucket @@ -597,6 +695,37 @@ class StorageFileApi { return fileObjects; } + /// Purges the CDN cache for a single object. + /// + /// Invalidates the CDN cache for the object at [path] (relative to the + /// bucket). There is no wildcard or recursion; pass the exact path of the + /// object to invalidate. For example: `purgeCache('folder/avatar.png')`. + /// + /// When [transformations] is `true`, only the resized/formatted variants are + /// purged, leaving the original cached file intact. When omitted the object + /// cache is purged. + /// + /// Requires the service-role key and the tenant `purgeCache` feature to be + /// enabled on the storage server. + Future purgeCache( + String path, { + bool transformations = false, + }) async { + final finalPath = _getFinalPath(path); + var requestUrl = Uri.parse('$url/cdn/$finalPath'); + if (transformations) { + requestUrl = requestUrl.replace( + queryParameters: {'transformations': 'true'}, + ); + } + final response = await _storageFetch.delete( + requestUrl.toString(), + {}, + options: _fetchOptions, + ); + return (response as Map)['message'] as String; + } + /// Lists all the files within a bucket. /// /// [path] The folder path. @@ -623,4 +752,24 @@ class StorageFileApi { ); return fileObjects; } + + /// Lists files and folders within a bucket with cursor-based pagination and + /// hierarchical (delimiter) listing. + /// + /// [options] includes `prefix`, `cursor`, `limit`, `withDelimiter` and + /// `sortBy`. + /// + /// Folder entries in [PaginatedListResult.folders] only contain a name (and + /// optionally a key); full metadata is only available on the file entries in + /// [PaginatedListResult.objects]. + Future listPaginated({ + PaginatedSearchOptions options = const PaginatedSearchOptions(), + }) async { + final response = await _storageFetch.post( + '$url/object/list-v2/$bucketId', + options.toMap(), + options: _fetchOptions, + ); + return PaginatedListResult.fromJson(response as Map); + } } diff --git a/packages/storage_client/lib/src/types.dart b/packages/storage_client/lib/src/types.dart index ef76eddb8..7bb05022c 100644 --- a/packages/storage_client/lib/src/types.dart +++ b/packages/storage_client/lib/src/types.dart @@ -1,13 +1,7 @@ // ignore_for_file: deprecated_member_use_from_same_package import 'package:meta/meta.dart'; - -class FetchOptions { - final Map? headers; - final bool? noResolveJson; - - const FetchOptions({this.headers, this.noResolveJson}); -} +import 'package:supabase_common/supabase_common.dart'; class Bucket { final String id; @@ -47,6 +41,38 @@ class Bucket { } } +/// A bucket backed by the Apache Iceberg table format, used for structured +/// analytical data storage. +class AnalyticsBucket { + /// The unique identifier of the bucket. + final String id; + + /// The name of the bucket. + final String name; + + /// The creation timestamp. + final DateTime createdAt; + + /// The last update timestamp. + final DateTime updatedAt; + + const AnalyticsBucket({ + required this.id, + required this.name, + required this.createdAt, + required this.updatedAt, + }); + + factory AnalyticsBucket.fromJson(Map json) { + return AnalyticsBucket( + id: json['id'] as String, + name: json['name'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + ); + } +} + class FileObject { final String name; final String? bucketId; @@ -165,6 +191,56 @@ class BucketOptions { }); } +/// The column that [StorageBucketApi.listBuckets] can sort its results by. +enum BucketSortColumn { id, name, createdAt, updatedAt } + +/// The direction that [StorageBucketApi.listBuckets] sorts its results in. +enum BucketSortOrder { + ascending('asc'), + descending('desc'); + + const BucketSortOrder(this.value); + + /// The value sent to the storage API. + final String value; +} + +/// Filter, sort and pagination options for [StorageBucketApi.listBuckets]. +class ListBucketsOptions { + /// The maximum number of buckets to return. + final int? limit; + + /// The number of buckets to skip. + final int? offset; + + /// The column to sort the buckets by. + final BucketSortColumn? sortColumn; + + /// The direction to sort the buckets in. + final BucketSortOrder? sortOrder; + + /// A search term used to filter buckets by name. + final String? search; + + const ListBucketsOptions({ + this.limit, + this.offset, + this.sortColumn, + this.sortOrder, + this.search, + }); + + Map toQueryParameters() { + return { + 'limit': ?limit?.toString(), + 'offset': ?offset?.toString(), + if (search != null && search!.isNotEmpty) 'search': search!, + 'sortColumn': ?sortColumn?.snakeCase, + 'sortOrder': ?sortOrder?.value, + }; + } +} + class FileOptions { /// The number of seconds the asset is cached in the browser and /// in the Supabase CDN. This is set in the `Cache-Control: max-age=` @@ -246,6 +322,186 @@ class SortBy { } } +/// The column that [StorageFileApi.listPaginated] can sort its results by. +enum FileSortColumn { name, updatedAt, createdAt } + +/// The direction that [StorageFileApi.listPaginated] sorts its results in. +enum FileSortOrder { + ascending('asc'), + descending('desc'); + + const FileSortOrder(this.value); + + /// The value sent to the storage API. + final String value; +} + +/// The column and direction that [StorageFileApi.listPaginated] sorts its +/// results by. +class FileSort { + /// The column to sort by. + final FileSortColumn column; + + /// The sort direction. + final FileSortOrder order; + + const FileSort({ + this.column = FileSortColumn.name, + this.order = FileSortOrder.ascending, + }); + + Map toMap() { + return { + 'column': column.snakeCase, + 'order': order.value, + }; + } +} + +/// Options for [StorageFileApi.listPaginated]. +class PaginatedSearchOptions { + /// The number of files to return. + /// + /// Defaults to `1000` on the server when omitted. + final int? limit; + + /// The prefix to filter files by. + final String? prefix; + + /// The cursor used for pagination. Pass the [PaginatedListResult.nextCursor] + /// value from the previous request to fetch the next page. + final String? cursor; + + /// Whether to emulate a hierarchical listing of objects using delimiters. + /// + /// When `false` (default) all objects are listed as a flat list. When `true` + /// the response groups objects by delimiter, separating them into + /// [PaginatedListResult.folders] and [PaginatedListResult.objects]. + final bool? withDelimiter; + + /// The column and direction to sort by. + final FileSort? sortBy; + + const PaginatedSearchOptions({ + this.limit, + this.prefix, + this.cursor, + this.withDelimiter, + this.sortBy, + }); + + Map toMap() { + return { + 'limit': ?limit, + 'prefix': ?prefix, + 'cursor': ?cursor, + 'with_delimiter': ?withDelimiter, + 'sortBy': ?sortBy?.toMap(), + }; + } +} + +/// A file entry returned by [StorageFileApi.listPaginated]. +class PaginatedFile { + /// The file name. + final String name; + + /// The full object key/path. + final String? key; + + /// The unique identifier of the file. + final String? id; + + /// The last update timestamp. + final String? updatedAt; + + /// The creation timestamp. + final String? createdAt; + + /// The file metadata, including size and mimetype. `null` when not yet set. + final Map? metadata; + + const PaginatedFile({ + required this.name, + required this.key, + required this.id, + required this.updatedAt, + required this.createdAt, + required this.metadata, + }); + + factory PaginatedFile.fromJson(Map json) { + return PaginatedFile( + name: json['name'] as String, + key: json['key'] as String?, + id: json['id'] as String?, + updatedAt: json['updated_at'] as String?, + createdAt: json['created_at'] as String?, + metadata: json['metadata'] as Map?, + ); + } +} + +/// A folder entry returned by [StorageFileApi.listPaginated] when using a +/// delimiter. +class PaginatedFolder { + /// The folder name/prefix. + final String name; + + /// The full folder key/path. + final String? key; + + const PaginatedFolder({ + required this.name, + required this.key, + }); + + factory PaginatedFolder.fromJson(Map json) { + return PaginatedFolder( + name: json['name'] as String, + key: json['key'] as String?, + ); + } +} + +/// The result of [StorageFileApi.listPaginated]. +class PaginatedListResult { + /// Whether there are more results available on a subsequent page. + final bool hasNext; + + /// The folders in this page. Only populated when a delimiter is used. + final List folders; + + /// The files in this page. + final List objects; + + /// The cursor to pass as [PaginatedSearchOptions.cursor] to fetch the next + /// page. + final String? nextCursor; + + const PaginatedListResult({ + required this.hasNext, + required this.folders, + required this.objects, + required this.nextCursor, + }); + + factory PaginatedListResult.fromJson(Map json) { + final folders = json['folders'] as List? ?? const []; + final objects = json['objects'] as List? ?? const []; + return PaginatedListResult( + hasNext: json['hasNext'] as bool? ?? false, + folders: folders + .map((e) => PaginatedFolder.fromJson(e as Map)) + .toList(), + objects: objects + .map((e) => PaginatedFile.fromJson(e as Map)) + .toList(), + nextCursor: json['nextCursor'] as String?, + ); + } +} + class SignedUrl { /// The file path, including the current file name. For example `folder/image.png`. final String path; @@ -427,9 +683,9 @@ extension ToQueryParams on TransformOptions { return { if (width != null) 'width': '$width', if (height != null) 'height': '$height', - if (resize != null) 'resize': resize!.snakeCase, + 'resize': ?resize?.snakeCase, if (quality != null) 'quality': '$quality', - if (format != null) 'format': format!.snakeCase, + 'format': ?format?.snakeCase, }; } } @@ -465,22 +721,3 @@ class DownloadBehavior { @internal String get queryValue => _queryValue; } - -extension ToSnakeCase on Enum { - String get snakeCase { - final a = 'a'.codeUnitAt(0), z = 'z'.codeUnitAt(0); - final A = 'A'.codeUnitAt(0), Z = 'Z'.codeUnitAt(0); - final result = StringBuffer()..write(name[0].toLowerCase()); - for (var i = 1; i < name.length; i++) { - final char = name.codeUnitAt(i); - if (A <= char && char <= Z) { - final pChar = name.codeUnitAt(i - 1); - if (a <= pChar && pChar <= z) { - result.write('_'); - } - } - result.write(name[i].toLowerCase()); - } - return result.toString(); - } -} diff --git a/packages/storage_client/lib/src/vector_client.dart b/packages/storage_client/lib/src/vector_client.dart new file mode 100644 index 000000000..a32344d34 --- /dev/null +++ b/packages/storage_client/lib/src/vector_client.dart @@ -0,0 +1,359 @@ +import 'package:meta/meta.dart'; +import 'package:storage_client/src/fetch.dart'; +import 'package:storage_client/src/vector_types.dart'; +import 'package:supabase_common/supabase_common.dart'; + +/// Client for the Storage Vectors API, reachable through +/// `supabase.storage.vectors`. +/// +/// It manages vector buckets directly and hands out bucket-scoped clients via +/// [from], which in turn hand out index-scoped clients via +/// [StorageVectorBucketApi.index]. +/// +/// ```dart +/// final vectors = supabase.storage.vectors; +/// await vectors.createBucket('embeddings'); +/// +/// final index = vectors.from('embeddings').index('documents'); +/// await index.putVectors([ +/// Vector(key: 'doc-1', data: [0.1, 0.2, 0.3], metadata: {'title': 'Intro'}), +/// ]); +/// +/// final result = await index.queryVectors(queryVector: [0.1, 0.2, 0.3], topK: 5); +/// ``` +/// +/// This API is part of a public alpha and may not be available to every +/// project. +@experimental +class SupabaseVectorsClient { + final String _url; + final Map _headers; + final Fetch _storageFetch; + + @internal + const SupabaseVectorsClient(this._url, this._headers, this._storageFetch); + + FetchOptions get _options => FetchOptions(_headers); + + /// Creates a new vector bucket. + Future createBucket(String name) async { + await _storageFetch.post( + '$_url/CreateVectorBucket', + {'vectorBucketName': name}, + options: _options, + ); + } + + /// Retrieves the metadata of an existing vector bucket. + Future getBucket(String name) async { + final response = await _storageFetch.post( + '$_url/GetVectorBucket', + {'vectorBucketName': name}, + options: _options, + ); + return VectorBucket.fromJson( + (response as Map)['vectorBucket'] + as Map, + ); + } + + /// Lists vector buckets, optionally filtered by [prefix] and paginated with + /// [maxResults] and [nextToken]. + Future listBuckets({ + String? prefix, + int? maxResults, + String? nextToken, + }) async { + final response = await _storageFetch.post( + '$_url/ListVectorBuckets', + { + 'prefix': ?prefix, + 'maxResults': ?maxResults, + 'nextToken': ?nextToken, + }, + options: _options, + ); + return VectorBucketList.fromJson(response as Map); + } + + /// Deletes a vector bucket. The bucket must have no indexes. + Future deleteBucket(String name) async { + await _storageFetch.post( + '$_url/DeleteVectorBucket', + {'vectorBucketName': name}, + options: _options, + ); + } + + /// Scopes index operations to the vector bucket named [bucketName]. + StorageVectorBucketApi from(String bucketName) { + return StorageVectorBucketApi(_url, _headers, _storageFetch, bucketName); + } +} + +/// Index operations scoped to a single vector bucket. +/// +/// Obtain an instance through [SupabaseVectorsClient.from]. +@experimental +class StorageVectorBucketApi { + final String _url; + final Map _headers; + final Fetch _storageFetch; + + /// The name of the vector bucket these operations are scoped to. + final String bucketName; + + @internal + const StorageVectorBucketApi( + this._url, + this._headers, + this._storageFetch, + this.bucketName, + ); + + FetchOptions get _options => FetchOptions(_headers); + + /// Creates a new vector index in this bucket. + /// + /// [dimension] is the length of the vectors the index will store and + /// [distanceMetric] the metric used for similarity queries. Keys listed in + /// [nonFilterableMetadataKeys] can be stored on vectors but not used in query + /// filters. + Future createIndex({ + required String name, + required int dimension, + required DistanceMetric distanceMetric, + VectorDataType dataType = VectorDataType.float32, + List? nonFilterableMetadataKeys, + }) async { + await _storageFetch.post( + '$_url/CreateIndex', + { + 'vectorBucketName': bucketName, + 'indexName': name, + 'dataType': dataType.value, + 'dimension': dimension, + 'distanceMetric': distanceMetric.value, + if (nonFilterableMetadataKeys != null) + 'metadataConfiguration': { + 'nonFilterableMetadataKeys': nonFilterableMetadataKeys, + }, + }, + options: _options, + ); + } + + /// Retrieves the metadata of an index in this bucket. + Future getIndex(String name) async { + final response = await _storageFetch.post( + '$_url/GetIndex', + {'vectorBucketName': bucketName, 'indexName': name}, + options: _options, + ); + return VectorIndex.fromJson( + (response as Map)['index'] as Map, + ); + } + + /// Lists indexes in this bucket, optionally filtered by [prefix] and + /// paginated with [maxResults] and [nextToken]. + Future listIndexes({ + String? prefix, + int? maxResults, + String? nextToken, + }) async { + final response = await _storageFetch.post( + '$_url/ListIndexes', + { + 'vectorBucketName': bucketName, + 'prefix': ?prefix, + 'maxResults': ?maxResults, + 'nextToken': ?nextToken, + }, + options: _options, + ); + return VectorIndexList.fromJson(response as Map); + } + + /// Deletes an index and all of its vectors from this bucket. + Future deleteIndex(String name) async { + await _storageFetch.post( + '$_url/DeleteIndex', + {'vectorBucketName': bucketName, 'indexName': name}, + options: _options, + ); + } + + /// Scopes vector data operations to the index named [indexName] in this + /// bucket. + StorageVectorIndexApi index(String indexName) { + return StorageVectorIndexApi( + _url, + _headers, + _storageFetch, + bucketName, + indexName, + ); + } +} + +/// Vector data operations scoped to a single index within a bucket. +/// +/// Obtain an instance through [StorageVectorBucketApi.index]. +@experimental +class StorageVectorIndexApi { + final String _url; + final Map _headers; + final Fetch _storageFetch; + + /// The name of the vector bucket these operations are scoped to. + final String bucketName; + + /// The name of the index these operations are scoped to. + final String indexName; + + @internal + const StorageVectorIndexApi( + this._url, + this._headers, + this._storageFetch, + this.bucketName, + this.indexName, + ); + + FetchOptions get _options => FetchOptions(_headers); + + /// Inserts or updates a batch of [vectors] in this index. + /// + /// The batch must contain between 1 and 500 vectors. + Future putVectors(List vectors) async { + if (vectors.isEmpty || vectors.length > 500) { + throw ArgumentError('Vector batch size must be between 1 and 500 items'); + } + await _storageFetch.post( + '$_url/PutVectors', + { + 'vectorBucketName': bucketName, + 'indexName': indexName, + 'vectors': vectors.map((vector) => vector.toJson()).toList(), + }, + options: _options, + ); + } + + /// Retrieves vectors by their [keys]. + /// + /// Set [returnData] and [returnMetadata] to include the embeddings and + /// metadata in the result. Keys that do not exist are omitted from the + /// returned list. + Future> getVectors({ + required List keys, + bool? returnData, + bool? returnMetadata, + }) async { + final response = await _storageFetch.post( + '$_url/GetVectors', + { + 'vectorBucketName': bucketName, + 'indexName': indexName, + 'keys': keys, + 'returnData': ?returnData, + 'returnMetadata': ?returnMetadata, + }, + options: _options, + ); + final vectors = + (response as Map)['vectors'] as List? ?? const []; + return vectors + .map((value) => VectorMatch.fromJson(value as Map)) + .toList(); + } + + /// Lists vectors in this index with pagination. + /// + /// A full-index scan can be distributed across multiple workers by giving + /// each worker a different [segmentIndex] (0 to `segmentCount - 1`) for the + /// same [segmentCount] (1 to 16). + Future listVectors({ + int? maxResults, + String? nextToken, + bool? returnData, + bool? returnMetadata, + int? segmentCount, + int? segmentIndex, + }) async { + if (segmentCount != null) { + if (segmentCount < 1 || segmentCount > 16) { + throw ArgumentError('segmentCount must be between 1 and 16'); + } + if (segmentIndex != null && + (segmentIndex < 0 || segmentIndex >= segmentCount)) { + throw ArgumentError( + 'segmentIndex must be between 0 and ${segmentCount - 1}', + ); + } + } + final response = await _storageFetch.post( + '$_url/ListVectors', + { + 'vectorBucketName': bucketName, + 'indexName': indexName, + 'maxResults': ?maxResults, + 'nextToken': ?nextToken, + 'returnData': ?returnData, + 'returnMetadata': ?returnMetadata, + 'segmentCount': ?segmentCount, + 'segmentIndex': ?segmentIndex, + }, + options: _options, + ); + return VectorList.fromJson(response as Map); + } + + /// Searches this index for the vectors most similar to [queryVector]. + /// + /// [topK] limits the number of matches returned. [filter] restricts the + /// search to vectors whose metadata matches the given expression. Set + /// [returnDistance] and [returnMetadata] to include the distance scores and + /// metadata in the result. + Future queryVectors({ + required List queryVector, + int? topK, + Map? filter, + bool? returnDistance, + bool? returnMetadata, + }) async { + final response = await _storageFetch.post( + '$_url/QueryVectors', + { + 'vectorBucketName': bucketName, + 'indexName': indexName, + 'queryVector': {'float32': queryVector}, + 'topK': ?topK, + 'filter': ?filter, + 'returnDistance': ?returnDistance, + 'returnMetadata': ?returnMetadata, + }, + options: _options, + ); + return VectorQueryResult.fromJson(response as Map); + } + + /// Deletes vectors by their [keys]. + /// + /// The batch must contain between 1 and 500 keys. + Future deleteVectors(List keys) async { + if (keys.isEmpty || keys.length > 500) { + throw ArgumentError('Keys batch size must be between 1 and 500 items'); + } + await _storageFetch.post( + '$_url/DeleteVectors', + { + 'vectorBucketName': bucketName, + 'indexName': indexName, + 'keys': keys, + }, + options: _options, + ); + } +} diff --git a/packages/storage_client/lib/src/vector_types.dart b/packages/storage_client/lib/src/vector_types.dart new file mode 100644 index 000000000..3f1dc806e --- /dev/null +++ b/packages/storage_client/lib/src/vector_types.dart @@ -0,0 +1,329 @@ +import 'package:meta/meta.dart'; + +/// Supported data types for vector components. +/// +/// Currently the S3 Vectors service only supports 32-bit floats. +@experimental +enum VectorDataType { + float32; + + /// The value sent to and returned by the storage API. + String get value => name.toLowerCase(); + + /// The [VectorDataType] matching the given API [value], or `null` when it is + /// not recognized. + static VectorDataType? fromValue(Object? value) { + for (final type in values) { + if (type.value == value) return type; + } + return null; + } +} + +/// Distance metric used when comparing vectors during a similarity search. +@experimental +enum DistanceMetric { + cosine, + euclidean, + dotProduct; + + /// The value sent to and returned by the storage API. + String get value => name.toLowerCase(); + + /// The [DistanceMetric] matching the given API [value], or `null` when it is + /// not recognized. + static DistanceMetric? fromValue(Object? value) { + for (final metric in values) { + if (metric.value == value) return metric; + } + return null; + } +} + +List? _parseFloat32(Object? data) { + if (data is! Map) return null; + final float32 = data['float32']; + if (float32 is! List) return null; + return float32.map((value) => (value as num).toDouble()).toList(); +} + +DateTime? _parseUnixSeconds(Object? value) { + if (value is! num) return null; + return DateTime.fromMillisecondsSinceEpoch( + (value * 1000).round(), + isUtc: true, + ); +} + +/// Encryption settings attached to a vector bucket. +@experimental +class VectorBucketEncryption { + /// The ARN of the KMS key used to encrypt the bucket, if any. + final String? kmsKeyArn; + + /// The server-side encryption type applied to the bucket. + final String? sseType; + + const VectorBucketEncryption({this.kmsKeyArn, this.sseType}); + + factory VectorBucketEncryption.fromJson(Map json) { + return VectorBucketEncryption( + kmsKeyArn: json['kmsKeyArn'] as String?, + sseType: json['sseType'] as String?, + ); + } +} + +/// Metadata describing a vector bucket. +@experimental +class VectorBucket { + /// The unique name of the vector bucket. + final String name; + + /// When the bucket was created, in UTC. `null` when the server does not + /// include it (for example in list responses). + final DateTime? creationTime; + + /// The bucket's encryption configuration, when present. + final VectorBucketEncryption? encryption; + + const VectorBucket({ + required this.name, + this.creationTime, + this.encryption, + }); + + factory VectorBucket.fromJson(Map json) { + final encryption = json['encryptionConfiguration']; + return VectorBucket( + name: json['vectorBucketName'] as String, + creationTime: _parseUnixSeconds(json['creationTime']), + encryption: encryption is Map + ? VectorBucketEncryption.fromJson(encryption) + : null, + ); + } +} + +/// Metadata describing a vector index within a bucket. +@experimental +class VectorIndex { + /// The unique name of the index within its bucket. + final String name; + + /// The name of the parent vector bucket. `null` when the server does not + /// include it (for example in list responses). + final String? bucketName; + + /// The data type of the vector components. `null` for values the client does + /// not recognize. + final VectorDataType? dataType; + + /// The dimensionality of the vectors stored in this index. + final int? dimension; + + /// The distance metric used for similarity queries. `null` for values the + /// client does not recognize. + final DistanceMetric? distanceMetric; + + /// Metadata keys that are stored but cannot be used in query filters. + final List? nonFilterableMetadataKeys; + + /// When the index was created, in UTC. `null` when the server does not + /// include it. + final DateTime? creationTime; + + const VectorIndex({ + required this.name, + this.bucketName, + this.dataType, + this.dimension, + this.distanceMetric, + this.nonFilterableMetadataKeys, + this.creationTime, + }); + + factory VectorIndex.fromJson(Map json) { + final metadataConfiguration = json['metadataConfiguration']; + final nonFilterableMetadataKeys = + metadataConfiguration is Map + ? metadataConfiguration['nonFilterableMetadataKeys'] + : null; + return VectorIndex( + name: json['indexName'] as String, + bucketName: json['vectorBucketName'] as String?, + dataType: VectorDataType.fromValue(json['dataType']), + dimension: (json['dimension'] as num?)?.toInt(), + distanceMetric: DistanceMetric.fromValue(json['distanceMetric']), + nonFilterableMetadataKeys: nonFilterableMetadataKeys is List + ? nonFilterableMetadataKeys.cast() + : null, + creationTime: _parseUnixSeconds(json['creationTime']), + ); + } +} + +/// A single vector to insert or update through +/// [StorageVectorIndexApi.putVectors]. +@experimental +class Vector { + /// The unique key identifying the vector within its index. + final String key; + + /// The vector embedding. Its length must match the index dimension. + final List data; + + /// Optional arbitrary metadata stored alongside the vector. + final Map? metadata; + + const Vector({ + required this.key, + required this.data, + this.metadata, + }); + + Map toJson() { + return { + 'key': key, + 'data': {'float32': data}, + 'metadata': ?metadata, + }; + } +} + +/// A vector returned from a get, list or query operation. +/// +/// [data], [metadata] and [distance] are only populated when the corresponding +/// operation was asked to return them. +@experimental +class VectorMatch { + /// The unique key identifying the vector within its index. + final String key; + + /// The vector embedding, when requested. + final List? data; + + /// The arbitrary metadata stored alongside the vector, when requested. + final Map? metadata; + + /// The similarity distance from the query vector. Only present in query + /// results when distances were requested. + final double? distance; + + const VectorMatch({ + required this.key, + this.data, + this.metadata, + this.distance, + }); + + factory VectorMatch.fromJson(Map json) { + return VectorMatch( + key: json['key'] as String, + data: _parseFloat32(json['data']), + metadata: json['metadata'] as Map?, + distance: (json['distance'] as num?)?.toDouble(), + ); + } +} + +/// The result of [SupabaseVectorsClient.listBuckets]. +@experimental +class VectorBucketList { + /// The buckets in this page. + final List buckets; + + /// The token to pass as `nextToken` to fetch the next page, if any. + final String? nextToken; + + const VectorBucketList({ + required this.buckets, + this.nextToken, + }); + + factory VectorBucketList.fromJson(Map json) { + final buckets = json['vectorBuckets'] as List? ?? const []; + return VectorBucketList( + buckets: buckets + .map((value) => VectorBucket.fromJson(value as Map)) + .toList(), + nextToken: json['nextToken'] as String?, + ); + } +} + +/// The result of [StorageVectorBucketApi.listIndexes]. +@experimental +class VectorIndexList { + /// The indexes in this page. + final List indexes; + + /// The token to pass as `nextToken` to fetch the next page, if any. + final String? nextToken; + + const VectorIndexList({ + required this.indexes, + this.nextToken, + }); + + factory VectorIndexList.fromJson(Map json) { + final indexes = json['indexes'] as List? ?? const []; + return VectorIndexList( + indexes: indexes + .map((value) => VectorIndex.fromJson(value as Map)) + .toList(), + nextToken: json['nextToken'] as String?, + ); + } +} + +/// The result of [StorageVectorIndexApi.listVectors]. +@experimental +class VectorList { + /// The vectors in this page. + final List vectors; + + /// The token to pass as `nextToken` to fetch the next page, if any. + final String? nextToken; + + const VectorList({ + required this.vectors, + this.nextToken, + }); + + factory VectorList.fromJson(Map json) { + final vectors = json['vectors'] as List? ?? const []; + return VectorList( + vectors: vectors + .map((value) => VectorMatch.fromJson(value as Map)) + .toList(), + nextToken: json['nextToken'] as String?, + ); + } +} + +/// The result of [StorageVectorIndexApi.queryVectors]. +@experimental +class VectorQueryResult { + /// The matching vectors ordered by ascending distance from the query vector. + final List matches; + + /// The distance metric the server used for the search. `null` for values the + /// client does not recognize. + final DistanceMetric? distanceMetric; + + const VectorQueryResult({ + required this.matches, + this.distanceMetric, + }); + + factory VectorQueryResult.fromJson(Map json) { + final matches = json['vectors'] as List? ?? const []; + return VectorQueryResult( + matches: matches + .map((value) => VectorMatch.fromJson(value as Map)) + .toList(), + distanceMetric: DistanceMetric.fromValue(json['distanceMetric']), + ); + } +} diff --git a/packages/storage_client/lib/src/version.dart b/packages/storage_client/lib/src/version.dart index 1abb1f33d..71c30ec03 100644 --- a/packages/storage_client/lib/src/version.dart +++ b/packages/storage_client/lib/src/version.dart @@ -1 +1 @@ -const version = '2.6.0'; +const version = '2.7.0'; diff --git a/packages/storage_client/lib/storage_client.dart b/packages/storage_client/lib/storage_client.dart index 6108b858c..a105aa4c1 100644 --- a/packages/storage_client/lib/storage_client.dart +++ b/packages/storage_client/lib/storage_client.dart @@ -1,6 +1,13 @@ /// Dart client library for Supabase Storage. library; +export 'src/iceberg/iceberg_error.dart'; +export 'src/iceberg/iceberg_rest_catalog.dart'; +export 'src/iceberg/iceberg_types.dart'; +export 'src/iceberg/table_requirement.dart'; +export 'src/iceberg/table_update.dart'; export 'src/storage_client.dart'; export 'src/storage_file_api.dart'; -export 'src/types.dart' hide FetchOptions, ToSnakeCase, ToQueryParams; +export 'src/types.dart' hide ToQueryParams; +export 'src/vector_client.dart'; +export 'src/vector_types.dart'; diff --git a/packages/storage_client/pubspec.yaml b/packages/storage_client/pubspec.yaml index 0f9a6706c..ce8a864d1 100644 --- a/packages/storage_client/pubspec.yaml +++ b/packages/storage_client/pubspec.yaml @@ -1,9 +1,16 @@ name: storage_client description: Dart client library to interact with Supabase Storage. Supabase Storage provides an interface for managing Files stored in S3, using Postgres to manage permissions. -version: 2.6.0 +version: 2.7.0 homepage: 'https://supabase.com' repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/storage_client' -documentation: 'https://supabase.com/docs/reference/dart/storage-createbucket' +issue_tracker: 'https://github.com/supabase/supabase-flutter/issues' +documentation: 'https://supabase.com/docs/reference/dart/file-buckets-createbucket' +topics: + - supabase + - storage + - s3 + - files + - backend environment: sdk: '>=3.9.0 <4.0.0' @@ -11,17 +18,16 @@ environment: resolution: workspace dependencies: - http: ^1.2.2 - http_parser: ^4.0.1 - mime: '>=1.0.2 <3.0.0' - retry: ^3.1.0 - meta: ^1.7.0 - logging: ^1.2.0 + http: ^1.6.0 + mime: '>=2.0.0 <3.0.0' + meta: ^1.16.0 + logging: ^1.3.0 + supabase_common: 0.1.1 dev_dependencies: - test: ^1.21.4 - supabase_lints: ^0.1.0 - path: ^1.8.2 + test: ^1.25.0 + supabase_lints: ^0.1.1 + path: ^1.9.0 false_secrets: - /test/** diff --git a/packages/storage_client/test/basic_test.dart b/packages/storage_client/test/basic_test.dart index 768cc2197..d1677e6e8 100644 --- a/packages/storage_client/test/basic_test.dart +++ b/packages/storage_client/test/basic_test.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:http/http.dart'; import 'package:storage_client/storage_client.dart'; import 'package:test/test.dart'; @@ -18,6 +20,13 @@ Map get testBucketJson => { 'public': false, }; +Map get testAnalyticsBucketJson => { + 'id': 'warehouse', + 'name': 'warehouse', + 'created_at': '2024-01-01T00:00:00.000Z', + 'updated_at': '2024-01-02T00:00:00.000Z', +}; + Map get testFileObjectJson => { 'name': 'test_bucket', 'id': 'test_bucket', @@ -58,6 +67,52 @@ void main() { final response = await client.listBuckets(); expect(response, isA>()); + expect(customHttpClient.receivedRequests.last.url.query, isEmpty); + }); + + test('should list buckets without query params when no options', () async { + customHttpClient.response = [testBucketJson]; + + await client.listBuckets(const ListBucketsOptions()); + expect(customHttpClient.receivedRequests.last.url.query, isEmpty); + }); + + test( + 'should list buckets with filter, sort and pagination options', + () async { + customHttpClient.response = [testBucketJson]; + + await client.listBuckets( + const ListBucketsOptions( + limit: 10, + offset: 5, + search: 'prod', + sortColumn: BucketSortColumn.createdAt, + sortOrder: BucketSortOrder.descending, + ), + ); + + final queryParameters = + customHttpClient.receivedRequests.last.url.queryParameters; + expect(queryParameters['limit'], '10'); + expect(queryParameters['offset'], '5'); + expect(queryParameters['search'], 'prod'); + expect(queryParameters['sortColumn'], 'created_at'); + expect(queryParameters['sortOrder'], 'desc'); + }, + ); + + test('should include limit and offset of zero', () async { + customHttpClient.response = [testBucketJson]; + + await client.listBuckets( + const ListBucketsOptions(limit: 0, offset: 0), + ); + + final queryParameters = + customHttpClient.receivedRequests.last.url.queryParameters; + expect(queryParameters['limit'], '0'); + expect(queryParameters['offset'], '0'); }); test('should create bucket', () async { @@ -99,6 +154,100 @@ void main() { expect(response, 'Deleted'); }); + test('should purgeBucketCache issuing DELETE to /cdn/{bucket}', () async { + customHttpClient.response = {'message': 'success'}; + + final response = await client.purgeBucketCache('test_bucket'); + + final request = customHttpClient.receivedRequests.last; + expect(request.method, 'DELETE'); + expect( + request.url.toString(), + endsWith('/storage/v1/cdn/test_bucket'), + ); + expect(request.url.query, isEmpty); + expect(response, 'success'); + }); + + test('should purgeBucketCache with transformations query param', () async { + customHttpClient.response = {'message': 'success'}; + + final response = await client.purgeBucketCache( + 'test_bucket', + transformations: true, + ); + + final request = customHttpClient.receivedRequests.last; + expect(request.method, 'DELETE'); + expect(request.url.queryParameters['transformations'], 'true'); + expect(response, 'success'); + }); + + test('should create analytics bucket', () async { + customHttpClient.response = testAnalyticsBucketJson; + + final response = await client.createAnalyticsBucket('warehouse'); + + final request = customHttpClient.receivedRequests.last as Request; + expect(request.method, 'POST'); + expect(request.url.toString(), endsWith('/storage/v1/iceberg/bucket')); + expect(jsonDecode(request.body), {'name': 'warehouse'}); + expect(response, isA()); + expect(response.id, 'warehouse'); + expect(response.name, 'warehouse'); + expect(response.createdAt, DateTime.parse('2024-01-01T00:00:00.000Z')); + expect(response.updatedAt, DateTime.parse('2024-01-02T00:00:00.000Z')); + }); + + test('should list analytics buckets without query params', () async { + customHttpClient.response = [testAnalyticsBucketJson]; + + final response = await client.listAnalyticsBuckets(); + + final request = customHttpClient.receivedRequests.last; + expect(request.method, 'GET'); + expect(request.url.toString(), endsWith('/storage/v1/iceberg/bucket')); + expect(request.url.query, isEmpty); + expect(response, isA>()); + expect(response.single.name, 'warehouse'); + }); + + test('should list analytics buckets with options', () async { + customHttpClient.response = [testAnalyticsBucketJson]; + + await client.listAnalyticsBuckets( + const ListBucketsOptions( + limit: 10, + offset: 5, + search: 'ware', + sortColumn: BucketSortColumn.createdAt, + sortOrder: BucketSortOrder.descending, + ), + ); + + final queryParameters = + customHttpClient.receivedRequests.last.url.queryParameters; + expect(queryParameters['limit'], '10'); + expect(queryParameters['offset'], '5'); + expect(queryParameters['search'], 'ware'); + expect(queryParameters['sortColumn'], 'created_at'); + expect(queryParameters['sortOrder'], 'desc'); + }); + + test('should delete analytics bucket', () async { + customHttpClient.response = {'message': 'Successfully deleted'}; + + final response = await client.deleteAnalyticsBucket('warehouse'); + + final request = customHttpClient.receivedRequests.last; + expect(request.method, 'DELETE'); + expect( + request.url.toString(), + endsWith('/storage/v1/iceberg/bucket/warehouse'), + ); + expect(response, 'Successfully deleted'); + }); + test('should upload file', () async { final file = File('a.txt'); file.writeAsStringSync('File content'); @@ -107,7 +256,7 @@ void main() { final response = await client.from('public').upload('a.txt', file); expect(response, isA()); - expect(response.endsWith('/a.txt'), isTrue); + expect(response, endsWith('/a.txt')); }); test('should update file', () async { @@ -118,7 +267,7 @@ void main() { final response = await client.from('public').update('a.txt', file); expect(response, isA()); - expect(response.endsWith('/a.txt'), isTrue); + expect(response, endsWith('/a.txt')); }); test('should move file', () async { @@ -128,6 +277,37 @@ void main() { expect(response, 'Move'); }); + test('should purgeCache issuing DELETE to /cdn/{bucket}/{path}', () async { + customHttpClient.response = {'message': 'success'}; + + final response = await client.from('public').purgeCache('folder/a.txt'); + + final request = customHttpClient.receivedRequests.last; + expect(request.method, 'DELETE'); + expect( + request.url.toString(), + endsWith('/storage/v1/cdn/public/folder/a.txt'), + ); + expect(request.url.query, isEmpty); + expect(response, 'success'); + }); + + test('should purgeCache with transformations query param', () async { + customHttpClient.response = {'message': 'success'}; + + final response = await client + .from('public') + .purgeCache('folder/a.txt', transformations: true); + + final request = customHttpClient.receivedRequests.last; + expect(request.method, 'DELETE'); + expect( + request.url.queryParameters['transformations'], + 'true', + ); + expect(response, 'success'); + }); + test('should createSignedUrl file', () async { customHttpClient.response = {'signedURL': '/signed/url'}; @@ -141,8 +321,8 @@ void main() { () async { customHttpClient.response = {'signedURL': null}; - expect( - () => client.from('public').createSignedUrl('missing.txt', 60), + await expectLater( + client.from('public').createSignedUrl('missing.txt', 60), throwsA(isA()), ); }, @@ -247,6 +427,73 @@ void main() { expect(response.length, 2); }); + test('listPaginated posts options and parses the result', () async { + customHttpClient.response = { + 'hasNext': true, + 'nextCursor': 'cursor-2', + 'folders': [ + {'name': 'folder', 'key': 'prefix/folder/'}, + ], + 'objects': [ + { + 'name': 'image.png', + 'key': 'prefix/image.png', + 'id': 'object-id', + 'updated_at': '2026-01-01T00:00:00Z', + 'created_at': '2026-01-01T00:00:00Z', + 'metadata': {'size': 10}, + }, + ], + }; + + final result = await client + .from('public') + .listPaginated( + options: const PaginatedSearchOptions( + prefix: 'prefix/', + limit: 100, + withDelimiter: true, + sortBy: FileSort( + column: FileSortColumn.createdAt, + order: FileSortOrder.descending, + ), + ), + ); + + final request = customHttpClient.receivedRequests.single; + expect(request.url.toString(), '$objectUrl/list-v2/public'); + expect(jsonDecode((request as Request).body), { + 'prefix': 'prefix/', + 'limit': 100, + 'with_delimiter': true, + 'sortBy': {'column': 'created_at', 'order': 'desc'}, + }); + + expect(result.hasNext, isTrue); + expect(result.nextCursor, 'cursor-2'); + expect(result.folders.single.name, 'folder'); + expect(result.folders.single.key, 'prefix/folder/'); + expect(result.objects.single.name, 'image.png'); + expect(result.objects.single.id, 'object-id'); + expect(result.objects.single.metadata, {'size': 10}); + }); + + test('listPaginated defaults to an empty body and empty result', () async { + customHttpClient.response = { + 'hasNext': false, + 'objects': [], + }; + + final result = await client.from('public').listPaginated(); + + final request = customHttpClient.receivedRequests.single as Request; + expect(jsonDecode(request.body), {}); + expect(result.hasNext, isFalse); + expect(result.folders, isEmpty); + expect(result.objects, isEmpty); + expect(result.nextCursor, isNull); + }); + test('should download public file', () async { final file = File('a.txt'); file.writeAsStringSync('Updated content'); @@ -276,6 +523,61 @@ void main() { expect(request.url.queryParameters, {'version': '1'}); }); + test('downloadStream yields the response body as a byte stream', () async { + final file = File('a.txt'); + file.writeAsStringSync('Streamed content'); + customHttpClient.response = file.readAsBytesSync(); + + final stream = client.from('public_bucket').downloadStream('b.txt'); + expect(stream, isA>()); + final bytes = await stream.expand((chunk) => chunk).toList(); + + expect(String.fromCharCodes(bytes), 'Streamed content'); + + final request = customHttpClient.receivedRequests.single; + expect(request.url.toString(), contains('/object/public_bucket/b.txt')); + }); + + test('downloadStream appends transform and cacheNonce', () async { + customHttpClient.response = Uint8List.fromList([1, 2, 3]); + + await client + .from('public_bucket') + .downloadStream( + 'b.txt', + transform: const TransformOptions(width: 200), + cacheNonce: 'v2', + ) + .drain(); + + final request = customHttpClient.receivedRequests.single; + expect( + request.url.toString(), + contains('/render/image/authenticated/public_bucket/b.txt'), + ); + expect(request.url.queryParameters, {'width': '200', 'cacheNonce': 'v2'}); + }); + + test('downloadStream surfaces an error status on the stream', () async { + addTearDown(() => customHttpClient.statusCode = 201); + customHttpClient.statusCode = 404; + customHttpClient.response = {'message': 'Object not found'}; + + await expectLater( + client + .from('public_bucket') + .downloadStream('missing.txt') + .drain(), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + '404', + ), + ), + ); + }); + test('should get public URL of a path', () { final response = client.from('files').getPublicUrl('b.txt'); expect(response, '$objectUrl/public/files/b.txt'); @@ -304,9 +606,56 @@ void main() { ); }); - test('getPublicUrl leaves the URL unchanged when download is null', () { - final response = client.from('files').getPublicUrl('b.txt'); + test('getPublicUrl with empty transform does not use render endpoint', () { + final response = client + .from('files') + .getPublicUrl('b.txt', transform: const TransformOptions()); expect(response, '$objectUrl/public/files/b.txt'); + expect(response, isNot(contains('/render/image/'))); + }); + + test('getPublicUrl with actual transform uses render endpoint', () { + final response = client + .from('files') + .getPublicUrl('b.txt', transform: const TransformOptions(width: 200)); + expect( + response, + '$supabaseUrl/storage/v1/render/image/public/files/b.txt?width=200', + ); + }); + + test( + 'download with empty transform does not use render endpoint', + () async { + final file = File('a.txt'); + file.writeAsStringSync('Updated content'); + customHttpClient.response = file.readAsBytesSync(); + + await client + .from('public_bucket') + .download('b.txt', transform: const TransformOptions()); + + final request = customHttpClient.receivedRequests.first; + expect(request.url.toString(), contains('/object/public_bucket/b.txt')); + expect(request.url.toString(), isNot(contains('/render/image/'))); + }, + ); + + test('download with actual transform uses render endpoint', () async { + final file = File('a.txt'); + file.writeAsStringSync('Updated content'); + customHttpClient.response = file.readAsBytesSync(); + + await client + .from('public_bucket') + .download('b.txt', transform: const TransformOptions(width: 200)); + + final request = customHttpClient.receivedRequests.first; + expect( + request.url.toString(), + contains('/render/image/authenticated/public_bucket/b.txt'), + ); + expect(request.url.queryParameters, {'width': '200'}); }); test('createSignedUrl appends download to the token query', () async { @@ -344,11 +693,67 @@ void main() { expect(success.signedUrl, endsWith('?token=abc&download=')); }); + test('getPublicUrl appends cacheNonce', () { + final response = client + .from('files') + .getPublicUrl('b.txt', cacheNonce: 'v2'); + expect(response, '$objectUrl/public/files/b.txt?cacheNonce=v2'); + }); + + test('getPublicUrl appends download before cacheNonce', () { + final response = client + .from('files') + .getPublicUrl( + 'b.txt', + download: DownloadBehavior.withOriginalName, + cacheNonce: 'v2', + ); + expect(response, '$objectUrl/public/files/b.txt?download=&cacheNonce=v2'); + }); + + test('download appends cacheNonce query parameter', () async { + final file = File('a.txt'); + file.writeAsStringSync('Updated content'); + customHttpClient.response = file.readAsBytesSync(); + + await client.from('public_bucket').download('b.txt', cacheNonce: 'v2'); + + final request = customHttpClient.receivedRequests.first; + expect(request.url.queryParameters, {'cacheNonce': 'v2'}); + }); + + test('createSignedUrl appends cacheNonce to the token query', () async { + customHttpClient.response = { + 'signedURL': '/object/sign/public/b.txt?token=abc', + }; + + final response = await client + .from('public') + .createSignedUrl('b.txt', 60, cacheNonce: 'v2'); + expect(response, endsWith('?token=abc&cacheNonce=v2')); + }); + + test('createSignedUrlsResult appends cacheNonce to each URL', () async { + customHttpClient.response = [ + { + 'path': 'exists.txt', + 'signedURL': '/object/sign/public/exists.txt?token=abc', + }, + ]; + + final results = await client + .from('public') + .createSignedUrlsResult(['exists.txt'], 60, cacheNonce: 'v2'); + + final success = results.single as SignedUrlSuccess; + expect(success.signedUrl, endsWith('?token=abc&cacheNonce=v2')); + }); + test('should remove file', () async { customHttpClient.response = [testFileObjectJson, testFileObjectJson]; final response = await client.from('public').remove(['a.txt', 'b.txt']); - expect(response, isA()); + expect(response, isA>()); expect(response.length, 2); }); }); @@ -372,7 +777,7 @@ void main() { final uploadTask = client .from('public') .upload('a.txt', file, retryAttempts: 1); - expect(uploadTask, throwsException); + await expectLater(uploadTask, throwsException); }); test('should upload file with few network failures', () async { @@ -381,7 +786,7 @@ void main() { final response = await client.from('public').upload('a.txt', file); expect(response, isA()); - expect(response.endsWith('/a.txt'), isTrue); + expect(response, endsWith('/a.txt')); }); test('aborting upload should throw', () async { @@ -401,7 +806,7 @@ void main() { await Future.delayed(Duration(milliseconds: 500)); retryController.cancel(); - expect(future, throwsException); + await expectLater(future, throwsException); }); test('should upload binary with few network failures', () async { @@ -412,7 +817,7 @@ void main() { .from('public') .uploadBinary('a.txt', file.readAsBytesSync()); expect(response, isA()); - expect(response.endsWith('/a.txt'), isTrue); + expect(response, endsWith('/a.txt')); }); test('should update file with few network failures', () async { @@ -421,7 +826,7 @@ void main() { final response = await client.from('public').update('a.txt', file); expect(response, isA()); - expect(response.endsWith('/a.txt'), isTrue); + expect(response, endsWith('/a.txt')); }); test('should update binary with few network failures', () async { final file = File('a.txt'); @@ -431,7 +836,7 @@ void main() { .from('public') .updateBinary('a.txt', file.readAsBytesSync()); expect(response, isA()); - expect(response.endsWith('/a.txt'), isTrue); + expect(response, endsWith('/a.txt')); }); }); @@ -448,7 +853,7 @@ void main() { }); test('should list buckets', () async { await expectLater( - () => client.listBuckets(), + client.listBuckets(), throwsA( isA().having( (e) => e.statusCode, diff --git a/packages/storage_client/test/client_test.dart b/packages/storage_client/test/client_test.dart index bcb35d862..6f6c2b64f 100644 --- a/packages/storage_client/test/client_test.dart +++ b/packages/storage_client/test/client_test.dart @@ -67,7 +67,7 @@ void main() { test('Get bucket with wrong id', () async { await expectLater( - () => storage.getBucket('not-exist-id'), + storage.getBucket('not-exist-id'), throwsA(isNotNull), ); }); @@ -76,9 +76,12 @@ void main() { final response = await storage.createBucket(newBucketName); expect(response, newBucketName); }); - test('createSignedUrls does not throw', () async { + test('createSignedUrls completes successfully', () async { await storage.from(newBucketName).upload(uploadPath, file); - await storage.from(newBucketName).createSignedUrls([uploadPath], 2000); + expect( + storage.from(newBucketName).createSignedUrls([uploadPath], 2000), + completes, + ); }); test('Create new public bucket', () async { @@ -96,7 +99,7 @@ void main() { final newBucketName = 'my-new-bucket-${DateTime.now()}'; await storage.createBucket(newBucketName); - final updateRes = await storage.updateBucket( + final updateResult = await storage.updateBucket( newBucketName, const BucketOptions( public: true, @@ -104,13 +107,13 @@ void main() { allowedMimeTypes: ['image/jpeg'], ), ); - expect(updateRes, 'Successfully updated'); + expect(updateResult, 'Successfully updated'); - final getRes = await storage.getBucket(newBucketName); - expect(getRes.public, isTrue); - expect(getRes.fileSizeLimit, 20000000); - expect(getRes.allowedMimeTypes!.length, 1); - expect(getRes.allowedMimeTypes!.first, 'image/jpeg'); + final bucket = await storage.getBucket(newBucketName); + expect(bucket.public, isTrue); + expect(bucket.fileSizeLimit, 20000000); + expect(bucket.allowedMimeTypes, hasLength(1)); + expect(bucket.allowedMimeTypes!.first, 'image/jpeg'); }); test('partially update bucket', () async { @@ -123,16 +126,16 @@ void main() { allowedMimeTypes: ['image/jpeg'], ), ); - final updateRes = await storage.updateBucket( + final updateResult = await storage.updateBucket( newBucketName, const BucketOptions(public: false), ); - expect(updateRes, 'Successfully updated'); - final getRes = await storage.getBucket(newBucketName); - expect(getRes.public, isFalse); - expect(getRes.fileSizeLimit, 20000000); - expect(getRes.allowedMimeTypes!.length, 1); - expect(getRes.allowedMimeTypes!.first, 'image/jpeg'); + expect(updateResult, 'Successfully updated'); + final bucket = await storage.getBucket(newBucketName); + expect(bucket.public, isFalse); + expect(bucket.fileSizeLimit, 20000000); + expect(bucket.allowedMimeTypes, hasLength(1)); + expect(bucket.allowedMimeTypes!.first, 'image/jpeg'); }); test('Empty bucket', () async { @@ -215,7 +218,7 @@ void main() { expect(uploadedPath, uploadPath); await expectLater( - () => storage + storage .from(newBucketName) .uploadToSignedUrl(response.path, response.token, file), throwsA( @@ -265,10 +268,8 @@ void main() { ); expect( - url.contains( - '$storageUrl/render/image/sign/$newBucketName/$uploadPath', - ), - isTrue, + url, + contains('$storageUrl/render/image/sign/$newBucketName/$uploadPath'), ); }); @@ -287,7 +288,7 @@ void main() { }); test('will download a public transformed file', () async { - final bytesArray = await storage + final bytes = await storage .from(newBucketName) .download( uploadPath, @@ -301,7 +302,7 @@ void main() { '${Directory.current.path}/public-image.jpg', ).create(); try { - await downloadedFile.writeAsBytes(bytesArray); + await downloadedFile.writeAsBytes(bytes); final size = await downloadedFile.length(); final type = lookupMimeType(downloadedFile.path); expect(size, isPositive); @@ -317,7 +318,7 @@ void main() { await storage.from(privateBucketName).upload(uploadPath, file); - final bytesArray = await storage + final bytes = await storage .from(privateBucketName) .download( uploadPath, @@ -328,7 +329,7 @@ void main() { '${Directory.current.path}/private-image.jpg', ).create(); try { - await downloadedFile.writeAsBytes(bytesArray); + await downloadedFile.writeAsBytes(bytes); final size = await downloadedFile.length(); final type = lookupMimeType( downloadedFile.path, @@ -348,7 +349,7 @@ void main() { 'Accept': 'image/webp', }); - final bytesArray = await client + final bytes = await client .from(newBucketName) .download( uploadPath, @@ -361,7 +362,7 @@ void main() { '${Directory.current.path}/webpimage', ).create(); try { - await downloadedFile.writeAsBytes(bytesArray); + await downloadedFile.writeAsBytes(bytes); final size = await downloadedFile.length(); final type = lookupMimeType( downloadedFile.path, @@ -383,7 +384,7 @@ void main() { 'Accept': 'image/webp', }); - final bytesArray = await client + final bytes = await client .from(newBucketName) .download( uploadPath, @@ -397,7 +398,7 @@ void main() { '${Directory.current.path}/jpegimage', ).create(); try { - await downloadedFile.writeAsBytes(bytesArray); + await downloadedFile.writeAsBytes(bytes); final size = await downloadedFile.length(); final type = lookupMimeType( downloadedFile.path, @@ -474,8 +475,8 @@ void main() { ), ); - final res = await storage.from(bucketName).upload(uploadPath, file); - expect(res, isA()); + final response = await storage.from(bucketName).upload(uploadPath, file); + expect(response, isA()); }); test('cannot upload a file that exceed the file size limit', () async { @@ -502,7 +503,7 @@ void main() { ), ); - final res = await storage + final response = await storage .from(bucketName) .upload( uploadPath, @@ -511,7 +512,7 @@ void main() { contentType: 'image/png', ), ); - expect(res, isA()); + expect(response, isA()); }); test('cannot upload a file an invalid mime type', () async { @@ -538,12 +539,15 @@ void main() { }); group('file operations', () { - test('copy', () async { + test('copy completes successfully', () async { final client = SupabaseStorageClient(storageUrl, { 'Authorization': 'Bearer $storageKey', }); - await client.from(newBucketName).copy(uploadPath, "$uploadPath 2"); + expect( + client.from(newBucketName).copy(uploadPath, "$uploadPath 2"), + completes, + ); }); test('copy to different bucket', () async { @@ -552,7 +556,7 @@ void main() { }); await expectLater( - () => client.from('bucket2').download(uploadPath), + client.from('bucket2').download(uploadPath), throwsA( isA().having( (e) => e.statusCode, @@ -577,7 +581,7 @@ void main() { }); await expectLater( - () => client.from('bucket2').download('$uploadPath 3'), + client.from('bucket2').download('$uploadPath 3'), throwsA( isA().having( (e) => e.statusCode, @@ -595,7 +599,7 @@ void main() { fail('File that was moved was not found'); } await expectLater( - () => client.from(newBucketName).download(uploadPath), + client.from(newBucketName).download(uploadPath), throwsA( isA().having( (e) => e.statusCode, @@ -624,17 +628,19 @@ void main() { ), ); - final updateRes = await storage.from(newBucketName).info(path); - expect(updateRes.metadata, metadata); + final objectInfo = await storage.from(newBucketName).info(path); + expect(objectInfo.metadata, metadata); }); test('check if object exists', () async { await storage.from(newBucketName).upload('$uploadPath-exists', file); - final res = await storage.from(newBucketName).exists('$uploadPath-exists'); - expect(res, isTrue); + final exists = await storage + .from(newBucketName) + .exists('$uploadPath-exists'); + expect(exists, isTrue); - final res2 = await storage.from(newBucketName).exists('not-exist'); - expect(res2, isFalse); + final missing = await storage.from(newBucketName).exists('not-exist'); + expect(missing, isFalse); }); group('setHeader', () { @@ -875,7 +881,7 @@ void main() { for (final invalidKey in ['folder/a#b.txt', 'folder/100%done.txt']) { test('rejects "$invalidKey" with an InvalidKey error', () async { await expectLater( - () => storage + storage .from(bucket) .upload( invalidKey, diff --git a/packages/storage_client/test/fetch_test.dart b/packages/storage_client/test/fetch_test.dart index a6b252978..e5cf40bff 100644 --- a/packages/storage_client/test/fetch_test.dart +++ b/packages/storage_client/test/fetch_test.dart @@ -107,7 +107,7 @@ void main() { final requestPath = mockClient.receivedRequests.single.url.path; expect(requestPath, endsWith('/bucket/folder/image.png')); - expect(requestPath.contains('//'), isFalse); + expect(requestPath, isNot(contains('//'))); }, ); }); diff --git a/packages/storage_client/test/iceberg_test.dart b/packages/storage_client/test/iceberg_test.dart new file mode 100644 index 000000000..1a63c749b --- /dev/null +++ b/packages/storage_client/test/iceberg_test.dart @@ -0,0 +1,702 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:storage_client/storage_client.dart'; +import 'package:test/test.dart'; + +const String supabaseUrl = 'SUPABASE_TEST_URL'; +const String supabaseKey = 'SUPABASE_TEST_KEY'; + +StreamedResponse _json( + Object body, + int statusCode, + BaseRequest request, { + Map headers = const {}, +}) { + return StreamedResponse( + Stream.value(utf8.encode(jsonEncode(body))), + statusCode, + request: request, + headers: {'content-type': 'application/json', ...headers}, + ); +} + +class MockCatalogClient extends BaseClient { + final List requests = []; + + /// Response returned for every non config request. + StreamedResponse Function(Request request)? handler; + + /// Prefix returned by the mocked `/v1/config` endpoint. + String configPrefix = 'warehouse'; + + /// When true the mocked `/v1/config` endpoint responds with a server error. + bool configFails = false; + + List get operationRequests => requests + .where((request) => !request.url.path.endsWith('/v1/config')) + .toList(); + + Request get lastOperation => operationRequests.last; + + @override + Future send(BaseRequest request) async { + final typed = request as Request; + requests.add(typed); + if (typed.url.path.endsWith('/v1/config')) { + if (configFails) { + return _json( + { + 'error': {'message': 'no config', 'type': 'Error', 'code': 500}, + }, + 500, + typed, + ); + } + return _json( + { + 'defaults': {}, + 'overrides': {'prefix': configPrefix}, + }, + 200, + typed, + ); + } + return (handler ?? (r) => _json({}, 200, r))(typed); + } +} + +void main() { + late MockCatalogClient mockClient; + late IcebergRestCatalog catalog; + + setUp(() { + mockClient = MockCatalogClient(); + catalog = IcebergRestCatalog( + baseUrl: '$supabaseUrl/storage/v1/iceberg', + headers: {'Authorization': 'Bearer $supabaseKey'}, + warehouse: 'warehouse', + httpClient: mockClient, + ); + }); + + Map bodyOf(Request request) => + jsonDecode(request.body) as Map; + + group('prefix resolution', () { + test('uses the server returned prefix for operation paths', () async { + mockClient.handler = (request) => _json({'namespaces': []}, 200, request); + + await catalog.listNamespaces(); + + expect( + mockClient.lastOperation.url.path, + endsWith('/storage/v1/iceberg/v1/warehouse/namespaces'), + ); + }); + + test('falls back to the warehouse segment when config fails', () async { + mockClient.configFails = true; + mockClient.handler = (request) => _json({'namespaces': []}, 200, request); + + await catalog.listNamespaces(); + + expect( + mockClient.lastOperation.url.path, + endsWith('/storage/v1/iceberg/v1/warehouse/namespaces'), + ); + }); + }); + + group('namespaces', () { + test('createNamespace sends namespace and properties', () async { + mockClient.handler = (request) => _json( + { + 'namespace': ['analytics'], + 'properties': {'owner': 'team'}, + }, + 200, + request, + ); + + final namespace = await catalog.createNamespace( + ['analytics'], + properties: {'owner': 'team'}, + ); + + expect(namespace, ['analytics']); + final request = mockClient.lastOperation; + expect(request.method, 'POST'); + expect(request.url.path, endsWith('/v1/warehouse/namespaces')); + expect(bodyOf(request), { + 'namespace': ['analytics'], + 'properties': {'owner': 'team'}, + }); + expect(request.headers['Idempotency-Key'], isNotNull); + }); + + test('listNamespaces parses namespaces and page token', () async { + mockClient.handler = (request) => _json( + { + 'namespaces': [ + ['analytics'], + ['logs', 'app'], + ], + 'next-page-token': 'token-2', + }, + 200, + request, + ); + + final result = await catalog.listNamespaces( + const ListNamespacesOptions(pageSize: 100), + ); + + expect(result.namespaces, [ + ['analytics'], + ['logs', 'app'], + ]); + expect(result.nextPageToken, 'token-2'); + expect(mockClient.lastOperation.url.queryParameters['pageSize'], '100'); + }); + + test('listNamespaces encodes multi part parent with 0x1F', () async { + mockClient.handler = (request) => _json({'namespaces': []}, 200, request); + + await catalog.listNamespaces( + const ListNamespacesOptions(parent: ['a', 'b']), + ); + + expect( + mockClient.lastOperation.url.query, + contains('parent=a%1Fb'), + ); + }); + + test('loadNamespaceMetadata returns properties', () async { + mockClient.handler = (request) => _json( + { + 'namespace': ['analytics'], + 'properties': {'owner': 'team'}, + }, + 200, + request, + ); + + final properties = await catalog.loadNamespaceMetadata(['analytics']); + + expect(properties, {'owner': 'team'}); + expect(mockClient.lastOperation.method, 'GET'); + }); + + test('updateNamespaceProperties returns the update result', () async { + mockClient.handler = (request) => _json( + { + 'updated': ['owner'], + 'removed': ['stale'], + 'missing': ['ghost'], + }, + 200, + request, + ); + + final result = await catalog.updateNamespaceProperties( + ['analytics'], + updates: {'owner': 'team'}, + removals: ['stale'], + ); + + expect(result.updated, ['owner']); + expect(result.removed, ['stale']); + expect(result.missing, ['ghost']); + expect(mockClient.lastOperation.url.path, endsWith('/properties')); + }); + + test('dropNamespace issues a DELETE', () async { + mockClient.handler = (request) => + _json({}, 200, request); + + await catalog.dropNamespace(['analytics']); + + expect(mockClient.lastOperation.method, 'DELETE'); + expect( + mockClient.lastOperation.url.path, + endsWith('/v1/warehouse/namespaces/analytics'), + ); + }); + + test('namespaceExists maps 200 and 404', () async { + mockClient.handler = (request) => + _json({}, 200, request); + expect(await catalog.namespaceExists(['analytics']), isTrue); + + mockClient.handler = (request) => _json( + { + 'error': { + 'message': 'not found', + 'type': 'NoSuchNamespaceException', + 'code': 404, + }, + }, + 404, + request, + ); + expect(await catalog.namespaceExists(['missing']), isFalse); + }); + + test('createNamespaceIfNotExists returns null on conflict', () async { + mockClient.handler = (request) => _json( + { + 'error': {'message': 'exists', 'type': 'Conflict', 'code': 409}, + }, + 409, + request, + ); + + final result = await catalog.createNamespaceIfNotExists(['analytics']); + + expect(result, isNull); + }); + }); + + group('tables', () { + TableSchema schema() => const TableSchema( + schemaId: 0, + fields: [ + TableField( + id: 1, + name: 'id', + type: PrimitiveType('long'), + required: true, + ), + TableField( + id: 2, + name: 'payload', + type: StructType( + fields: [ + TableField( + id: 3, + name: 'tags', + type: ListType( + elementId: 4, + element: PrimitiveType('string'), + elementRequired: true, + ), + required: false, + ), + ], + ), + required: false, + ), + ], + ); + + Map loadTableJson() => { + 'metadata': { + 'format-version': 2, + 'table-uuid': 'uuid-1', + 'location': 's3://bucket/events', + 'schemas': [ + { + 'type': 'struct', + 'schema-id': 0, + 'fields': [ + {'id': 1, 'name': 'id', 'type': 'long', 'required': true}, + ], + }, + ], + 'current-schema-id': 0, + 'partition-specs': [ + { + 'spec-id': 0, + 'fields': [ + { + 'source-id': 1, + 'field-id': 1000, + 'name': 'id_bucket', + 'transform': 'bucket[16]', + }, + ], + }, + ], + 'sort-orders': [], + 'properties': {'read.split.target-size': '134217728'}, + }, + 'metadata-location': 's3://bucket/events/metadata.json', + }; + + test('createTable serializes schema and partition spec', () async { + mockClient.handler = (request) => _json( + loadTableJson(), + 200, + request, + headers: const { + 'etag': 'W/"1"', + }, + ); + + final metadata = await catalog.createTable( + ['analytics'], + CreateTableRequest( + name: 'events', + schema: schema(), + partitionSpec: const PartitionSpec( + fields: [ + PartitionField( + sourceId: 1, + name: 'id_bucket', + transform: 'bucket[16]', + ), + ], + ), + ), + ); + + expect(metadata.tableUuid, 'uuid-1'); + expect(metadata.currentSchema?.fields.first.name, 'id'); + + final body = bodyOf(mockClient.lastOperation); + expect(body['name'], 'events'); + final fields = body['schema']['fields'] as List; + expect(fields.first['type'], 'long'); + final nested = fields[1]['type'] as Map; + expect(nested['type'], 'struct'); + final listType = + (nested['fields'] as List).first['type'] as Map; + expect(listType['type'], 'list'); + expect(listType['element'], 'string'); + expect(body['partition-spec']['fields'].first['transform'], 'bucket[16]'); + }); + + test('createTableResult exposes the etag', () async { + mockClient.handler = (request) => _json( + loadTableJson(), + 200, + request, + headers: const { + 'etag': 'W/"42"', + }, + ); + + final result = await catalog.createTableResult( + ['analytics'], + CreateTableRequest(name: 'events', schema: schema()), + ); + + expect(result.etag, 'W/"42"'); + expect(result.metadataLocation, 's3://bucket/events/metadata.json'); + }); + + test('listTables parses identifiers', () async { + mockClient.handler = (request) => _json( + { + 'identifiers': [ + { + 'namespace': ['analytics'], + 'name': 'events', + }, + ], + }, + 200, + request, + ); + + final result = await catalog.listTables(['analytics']); + + expect(result.identifiers.single.name, 'events'); + expect(result.identifiers.single.namespace, ['analytics']); + }); + + test('loadTableResult returns null on 304', () async { + mockClient.handler = (request) => StreamedResponse( + const Stream.empty(), + 304, + request: request, + ); + + final result = await catalog.loadTableResult( + const TableIdentifier(namespace: ['analytics'], name: 'events'), + const LoadTableOptions(ifNoneMatch: 'W/"1"'), + ); + + expect(result, isNull); + expect(mockClient.lastOperation.headers['If-None-Match'], 'W/"1"'); + }); + + test('dropTable passes purgeRequested', () async { + mockClient.handler = (request) => + _json({}, 200, request); + + await catalog.dropTable( + const TableIdentifier(namespace: ['analytics'], name: 'events'), + purge: true, + ); + + expect(mockClient.lastOperation.method, 'DELETE'); + expect( + mockClient.lastOperation.url.queryParameters['purgeRequested'], + 'true', + ); + }); + + test('renameTable posts source and destination', () async { + mockClient.handler = (request) => + _json({}, 200, request); + + await catalog.renameTable( + const TableIdentifier(namespace: ['analytics'], name: 'events'), + const TableIdentifier(namespace: ['analytics'], name: 'events_v2'), + ); + + final request = mockClient.lastOperation; + expect(request.url.path, endsWith('/v1/warehouse/tables/rename')); + final body = bodyOf(request); + expect(body['source']['name'], 'events'); + expect(body['destination']['name'], 'events_v2'); + }); + + test('updateTable sends requirements and updates', () async { + mockClient.handler = (request) => _json(loadTableJson(), 200, request); + + final result = await catalog.updateTable( + const TableIdentifier(namespace: ['analytics'], name: 'events'), + requirements: const [AssertTableUuid('uuid-1')], + updates: const [ + SetPropertiesUpdate({'read.split.target-size': '134217728'}), + ], + ); + + expect(result.metadataLocation, 's3://bucket/events/metadata.json'); + final body = bodyOf(mockClient.lastOperation); + expect(body['requirements'].first['type'], 'assert-table-uuid'); + expect(body['requirements'].first['uuid'], 'uuid-1'); + expect(body['updates'].first['action'], 'set-properties'); + expect( + body['updates'].first['updates']['read.split.target-size'], + '134217728', + ); + }); + + test('updateTable throws when metadata-location is missing', () async { + mockClient.handler = (request) => _json( + {'metadata': loadTableJson()['metadata']}, + 200, + request, + ); + + expect( + () => catalog.updateTable( + const TableIdentifier(namespace: ['analytics'], name: 'events'), + updates: const [ + SetPropertiesUpdate({'a': 'b'}), + ], + ), + throwsA(isA()), + ); + }); + + test('tableExists maps 200 and 404', () async { + mockClient.handler = (request) => + _json({}, 200, request); + expect( + await catalog.tableExists( + const TableIdentifier(namespace: ['analytics'], name: 'events'), + ), + isTrue, + ); + + mockClient.handler = (request) => _json( + { + 'error': { + 'message': 'missing', + 'type': 'NoSuchTableException', + 'code': 404, + }, + }, + 404, + request, + ); + expect( + await catalog.tableExists( + const TableIdentifier(namespace: ['analytics'], name: 'ghost'), + ), + isFalse, + ); + }); + }); + + group('errors and serialization', () { + test('IcebergException carries type and code', () async { + mockClient.handler = (request) => _json( + { + 'error': { + 'message': 'boom', + 'type': 'BadRequestException', + 'code': 400, + }, + }, + 400, + request, + ); + + await expectLater( + catalog.listNamespaces(), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 400) + .having((error) => error.type, 'type', 'BadRequestException') + .having((error) => error.code, 'code', 400), + ), + ); + }); + + test('maps status codes to sealed exception subtypes', () async { + mockClient.handler = (request) => _json( + { + 'error': {'message': 'gone', 'type': 'NoSuchTableException'}, + }, + 404, + request, + ); + await expectLater( + catalog.listNamespaces(), + throwsA(isA()), + ); + + mockClient.handler = (request) => _json( + { + 'error': {'message': 'dupe', 'type': 'AlreadyExistsException'}, + }, + 409, + request, + ); + await expectLater( + catalog.listNamespaces(), + throwsA(isA()), + ); + + mockClient.handler = (request) => _json( + { + 'error': {'message': 'boom', 'type': 'ServiceUnavailable'}, + }, + 503, + request, + ); + await expectLater( + catalog.listNamespaces(), + throwsA(isA()), + ); + }); + + test('commit state unknown is its own subtype regardless of status', () { + final exception = IcebergException.fromResponse(500, { + 'error': {'message': 'unknown', 'type': 'CommitStateUnknownException'}, + }); + + expect(exception, isA()); + }); + + test('TableUpdate raw escape hatch serializes the action', () { + const update = TableUpdate.raw('add-snapshot', { + 'snapshot': {'snapshot-id': 1}, + }); + + expect(update.toJson(), { + 'action': 'add-snapshot', + 'snapshot': {'snapshot-id': 1}, + }); + }); + + test('TableUpdate raw does not let the body override the action', () { + const update = TableUpdate.raw('add-snapshot', { + 'action': 'malicious', + 'snapshot': {'snapshot-id': 1}, + }); + + expect(update.toJson(), { + 'action': 'add-snapshot', + 'snapshot': {'snapshot-id': 1}, + }); + }); + + test('TableMetadata parses the top level snapshots list', () { + final metadata = TableMetadata.fromJson({ + 'format-version': 2, + 'table-uuid': 'uuid-1', + 'current-schema-id': 0, + 'schemas': [], + 'partition-specs': [], + 'sort-orders': [], + 'properties': {}, + 'current-snapshot-id': 42, + 'snapshots': [ + { + 'snapshot-id': 42, + 'parent-snapshot-id': 41, + 'sequence-number': 3, + 'timestamp-ms': 1700000000000, + 'manifest-list': 's3://bucket/manifest-list.avro', + 'summary': {'operation': 'append'}, + 'schema-id': 0, + }, + ], + }); + + expect(metadata.snapshots, hasLength(1)); + final snapshot = metadata.snapshots.first; + expect(snapshot.snapshotId, 42); + expect(snapshot.parentSnapshotId, 41); + expect(snapshot.sequenceNumber, 3); + expect(snapshot.timestampMs, 1700000000000); + expect(snapshot.manifestList, 's3://bucket/manifest-list.avro'); + expect(snapshot.summary['operation'], 'append'); + expect(snapshot.schemaId, 0); + }); + + test('TableMetadata defaults snapshots to an empty list when absent', () { + final metadata = TableMetadata.fromJson({ + 'format-version': 2, + 'table-uuid': 'uuid-1', + 'current-schema-id': 0, + 'schemas': [], + 'partition-specs': [], + 'sort-orders': [], + 'properties': {}, + }); + + expect(metadata.snapshots, isEmpty); + }); + + test('AssertReferenceSnapshotId serializes a null snapshot id', () { + const requirement = AssertReferenceSnapshotId(reference: 'main'); + + expect(requirement.toJson(), { + 'type': 'assert-ref-snapshot-id', + 'ref': 'main', + 'snapshot-id': null, + }); + }); + + test('SortOrder round trips through json', () { + const sortOrder = SortOrder( + orderId: 1, + fields: [ + SortField( + sourceId: 2, + transform: 'identity', + direction: SortDirection.descending, + nullOrder: NullOrder.nullsLast, + ), + ], + ); + + final json = sortOrder.toJson(); + expect(json['fields'].first['direction'], 'desc'); + expect(json['fields'].first['null-order'], 'nulls-last'); + + final parsed = SortOrder.fromJson(json); + expect(parsed.fields.first.direction, SortDirection.descending); + expect(parsed.fields.first.nullOrder, NullOrder.nullsLast); + }); + }); +} diff --git a/packages/storage_client/test/types_test.dart b/packages/storage_client/test/types_test.dart new file mode 100644 index 000000000..1cbf55a9f --- /dev/null +++ b/packages/storage_client/test/types_test.dart @@ -0,0 +1,354 @@ +import 'package:storage_client/src/types.dart'; +import 'package:test/test.dart'; + +void main() { + group('Bucket.fromJson', () { + test('parses all fields', () { + final bucket = Bucket.fromJson({ + 'id': 'avatars', + 'name': 'avatars', + 'owner': 'owner-id', + 'created_at': '2021-01-01T00:00:00Z', + 'updated_at': '2021-01-02T00:00:00Z', + 'public': true, + 'file_size_limit': 1024, + 'allowed_mime_types': ['image/png', 'image/jpeg'], + }); + + expect(bucket.id, 'avatars'); + expect(bucket.owner, 'owner-id'); + expect(bucket.public, isTrue); + expect(bucket.fileSizeLimit, 1024); + expect(bucket.allowedMimeTypes, ['image/png', 'image/jpeg']); + }); + + test('defaults owner to empty string and null-ables when absent', () { + final bucket = Bucket.fromJson({ + 'id': 'avatars', + 'name': 'avatars', + 'created_at': '2021-01-01T00:00:00Z', + 'updated_at': '2021-01-02T00:00:00Z', + 'public': false, + }); + + expect(bucket.owner, ''); + expect(bucket.fileSizeLimit, isNull); + expect(bucket.allowedMimeTypes, isNull); + }); + + test('treats a non-list allowed_mime_types as null', () { + final bucket = Bucket.fromJson({ + 'id': 'avatars', + 'name': 'avatars', + 'created_at': '2021-01-01T00:00:00Z', + 'updated_at': '2021-01-02T00:00:00Z', + 'public': false, + 'allowed_mime_types': 'image/png', + }); + + expect(bucket.allowedMimeTypes, isNull); + }); + }); + + group('FileObject.fromJson', () { + test('parses a nested bucket', () { + final file = FileObject.fromJson({ + 'name': 'photo.png', + 'id': 'file-id', + 'bucket_id': 'avatars', + 'owner': 'owner-id', + 'metadata': {'size': 10}, + 'buckets': { + 'id': 'avatars', + 'name': 'avatars', + 'created_at': '2021-01-01T00:00:00Z', + 'updated_at': '2021-01-02T00:00:00Z', + 'public': true, + }, + }); + + expect(file.name, 'photo.png'); + expect(file.bucketId, 'avatars'); + expect(file.buckets, isA()); + expect(file.buckets!.id, 'avatars'); + }); + + test('leaves buckets null when the field is missing', () { + final file = FileObject.fromJson({'name': 'photo.png'}); + expect(file.buckets, isNull); + }); + + test('throws a FormatException when the JSON is not an object', () { + expect( + () => FileObject.fromJson(['not', 'a', 'map']), + throwsFormatException, + ); + }); + }); + + group('FileObjectV2.fromJson', () { + test('parses all fields', () { + final file = FileObjectV2.fromJson({ + 'id': 'file-id', + 'version': 'v1', + 'name': 'photo.png', + 'bucket_id': 'avatars', + 'created_at': '2021-01-01T00:00:00Z', + 'size': 42, + 'content_type': 'image/png', + 'etag': 'abc', + 'metadata': {'foo': 'bar'}, + }); + + expect(file.id, 'file-id'); + expect(file.version, 'v1'); + expect(file.size, 42); + expect(file.contentType, 'image/png'); + expect(file.updatedAt, isNull); + }); + }); + + group('ListBucketsOptions.toQueryParameters', () { + test('omits null values and empty search', () { + const options = ListBucketsOptions(search: ''); + expect(options.toQueryParameters(), isEmpty); + }); + + test('serializes sort column to snake_case and order to its value', () { + const options = ListBucketsOptions( + limit: 10, + offset: 5, + search: 'photo', + sortColumn: BucketSortColumn.createdAt, + sortOrder: BucketSortOrder.descending, + ); + + expect(options.toQueryParameters(), { + 'limit': '10', + 'offset': '5', + 'search': 'photo', + 'sortColumn': 'created_at', + 'sortOrder': 'desc', + }); + }); + }); + + group('SearchOptions.toMap', () { + test('uses default limit, offset and sortBy', () { + const options = SearchOptions(); + expect(options.toMap(), { + 'limit': 100, + 'offset': 0, + 'sortBy': {'column': 'name', 'order': 'asc'}, + 'search': null, + }); + }); + }); + + group('SortBy.toMap', () { + test('falls back to defaults when column and order are null', () { + const sortBy = SortBy(column: null, order: null); + expect(sortBy.toMap(), {'column': 'name', 'order': 'asc'}); + }); + }); + + group('FileSort.toMap', () { + test('serializes column to snake_case and order to its value', () { + const sort = FileSort( + column: FileSortColumn.updatedAt, + order: FileSortOrder.descending, + ); + expect(sort.toMap(), {'column': 'updated_at', 'order': 'desc'}); + }); + }); + + group('PaginatedSearchOptions.toMap', () { + test('omits null values', () { + const options = PaginatedSearchOptions(limit: 50, prefix: 'folder/'); + expect(options.toMap(), {'limit': 50, 'prefix': 'folder/'}); + }); + }); + + group('PaginatedListResult.fromJson', () { + test('parses folders and objects', () { + final result = PaginatedListResult.fromJson({ + 'hasNext': true, + 'nextCursor': 'cursor-1', + 'folders': [ + {'name': 'images', 'key': 'images/'}, + ], + 'objects': [ + {'name': 'photo.png', 'key': 'images/photo.png'}, + ], + }); + + expect(result.hasNext, isTrue); + expect(result.nextCursor, 'cursor-1'); + expect(result.folders.single.name, 'images'); + expect(result.objects.single.name, 'photo.png'); + }); + + test('defaults to an empty page when fields are missing', () { + final result = PaginatedListResult.fromJson({}); + expect(result.hasNext, isFalse); + expect(result.folders, isEmpty); + expect(result.objects, isEmpty); + expect(result.nextCursor, isNull); + }); + }); + + group('SignedUrl', () { + const url = SignedUrl(path: 'a.png', signedUrl: 'https://x/a.png'); + + test('toString includes path and url', () { + expect( + url.toString(), + 'SignedUrl(path: a.png, signedUrl: https://x/a.png)', + ); + }); + + test('value equality and hashCode', () { + const same = SignedUrl(path: 'a.png', signedUrl: 'https://x/a.png'); + const differentPath = SignedUrl( + path: 'b.png', + signedUrl: 'https://x/a.png', + ); + const differentUrl = SignedUrl( + path: 'a.png', + signedUrl: 'https://x/b.png', + ); + + expect(url, same); + expect(url.hashCode, same.hashCode); + expect(url, isNot(differentPath)); + expect(url, isNot(differentUrl)); + expect(identical(url, url), isTrue); + }); + + test('copyWith replaces only the given fields', () { + expect(url.copyWith(path: 'c.png').path, 'c.png'); + expect(url.copyWith(path: 'c.png').signedUrl, url.signedUrl); + expect( + url.copyWith(signedUrl: 'https://x/c.png').signedUrl, + 'https://x/c.png', + ); + expect(url.copyWith(signedUrl: 'https://x/c.png').path, url.path); + }); + }); + + group('SignedUrlResult', () { + test('success exposes the url and a descriptive toString', () { + const result = SignedUrlSuccess(path: 'a.png', signedUrl: 'https://x/a'); + expect(result, isA()); + expect( + result.toString(), + 'SignedUrlSuccess(path: a.png, signedUrl: https://x/a)', + ); + }); + + test('failure exposes the error and a descriptive toString', () { + const result = SignedUrlFailure(path: 'a.png', error: 'not found'); + expect( + result.toString(), + 'SignedUrlFailure(path: a.png, error: not found)', + ); + }); + + test('can be matched exhaustively with a switch', () { + const results = [ + SignedUrlSuccess(path: 'a.png', signedUrl: 'https://x/a'), + SignedUrlFailure(path: 'b.png', error: 'missing'), + ]; + + final outcomes = results.map((result) { + return switch (result) { + SignedUrlSuccess(:final signedUrl) => 'ok:$signedUrl', + SignedUrlFailure(:final error) => 'fail:$error', + }; + }).toList(); + + expect(outcomes, ['ok:https://x/a', 'fail:missing']); + }); + }); + + group('StorageException', () { + test('toString includes message, status code and error', () { + const exception = StorageException( + 'boom', + statusCode: '500', + error: 'server_error', + ); + expect( + exception.toString(), + 'StorageException(message: boom, statusCode: 500, error: server_error)', + ); + }); + + test('fromJson reads message, error and statusCode', () { + final exception = StorageException.fromJson({ + 'message': 'not found', + 'error': 'NotFound', + 'statusCode': 404, + }); + expect(exception.message, 'not found'); + expect(exception.error, 'NotFound'); + expect(exception.statusCode, '404'); + }); + + test( + 'fromJson falls back to the fallback status code and stringified body', + () { + final exception = StorageException.fromJson({'foo': 'bar'}, '400'); + expect(exception.message, "{foo: bar}"); + expect(exception.statusCode, '400'); + }, + ); + }); + + group('TransformOptions.toQueryParams', () { + test('omits null values and snake-cases the resize mode', () { + const options = TransformOptions( + width: 100, + height: 200, + resize: ResizeMode.cover, + quality: 80, + format: RequestImageFormat.origin, + ); + + expect(options.toQueryParams, { + 'width': '100', + 'height': '200', + 'resize': 'cover', + 'quality': '80', + 'format': 'origin', + }); + }); + + test('is empty when nothing is set', () { + const options = TransformOptions(); + expect(options.toQueryParams, isEmpty); + }); + }); + + group('DownloadBehavior', () { + test('withOriginalName has an empty query value', () { + expect(DownloadBehavior.withOriginalName.queryValue, ''); + }); + + test('named carries the provided file name', () { + expect( + const DownloadBehavior.named('report.pdf').queryValue, + 'report.pdf', + ); + }); + }); + + group('StorageRetryController', () { + test('starts uncancelled and flips after cancel', () { + final controller = StorageRetryController(); + expect(controller.cancelled, isFalse); + controller.cancel(); + expect(controller.cancelled, isTrue); + }); + }); +} diff --git a/packages/storage_client/test/vector_integration_test.dart b/packages/storage_client/test/vector_integration_test.dart new file mode 100644 index 000000000..c363f62ed --- /dev/null +++ b/packages/storage_client/test/vector_integration_test.dart @@ -0,0 +1,157 @@ +import 'package:storage_client/storage_client.dart'; +import 'package:test/test.dart'; + +const storageUrl = 'http://127.0.0.1:54421/storage/v1'; +// service_role key of the local Supabase CLI stack (RS256, signed by the +// committed supabase/signing_keys.json). It bypasses RLS so the tests have +// unrestricted access. +const storageKey = + 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjNkZjU5YWIxLWI4ZWMtNDlkMy05YzkyLThiOWQ0MmNhYzFmZSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MjA5Njg5NTE5Mn0.jO5vwkRNFZTiVHNjFzaypvWV4aJkKm6TvFsdl0W5x9g7LttQMWMopC7HanUpeFLmg4E9gMb-v1e6f6oZ9e0PHYpsRwEdSOxKfYwKhzFI9DsDGLrX4ueArZuKgaV_bulWpwGKI3xwLugeuCp6N0hYFkXvMmUjaKx9nClWckJ33cchSpgjVQ5YxL8PGrUj2Sjhw-5IyGiwrdPfWjTQmpWnCjePoVrRf2jEMF_VGoxDAEqt72w_HGOrdXRFU5BW9-LkvpfzkrTENrj555JtYP4mkZgvUlrkXFRSh010o3n2UehN5WonfDRzwOeTC56QEbPVS6ubvWGR9luykdMNlXawZA'; + +// These tests exercise the Storage Vectors API against a live Supabase stack +// with `[storage.vector] enabled = true`. Each test provisions and tears down +// its own bucket/index, so they are self-contained and can run in any order. +void main() { + late SupabaseVectorsClient vectors; + final runId = DateTime.now().millisecondsSinceEpoch; + var counter = 0; + + setUpAll(() { + vectors = SupabaseStorageClient(storageUrl, { + 'Authorization': 'Bearer $storageKey', + }).vectors; + }); + + String uniqueName(String prefix) => '$prefix-$runId-${counter++}'; + + group('bucket lifecycle', () { + test('create, get, list and delete a vector bucket', () async { + final name = uniqueName('vec-bucket'); + + await vectors.createBucket(name); + + final bucket = await vectors.getBucket(name); + expect(bucket.name, name); + expect(bucket.creationTime, isA()); + + final list = await vectors.listBuckets(prefix: name); + expect(list.buckets.map((entry) => entry.name), contains(name)); + + await vectors.deleteBucket(name); + await expectLater( + vectors.getBucket(name), + throwsA(isA()), + ); + }); + }); + + group('index and vector operations', () { + late String bucketName; + late StorageVectorBucketApi bucket; + late StorageVectorIndexApi index; + + setUp(() async { + bucketName = uniqueName('vec-idx'); + await vectors.createBucket(bucketName); + bucket = vectors.from(bucketName); + await bucket.createIndex( + name: 'idx', + dimension: 3, + distanceMetric: DistanceMetric.cosine, + ); + index = bucket.index('idx'); + }); + + tearDown(() async { + try { + await bucket.deleteIndex('idx'); + } catch (_) {} + try { + await vectors.deleteBucket(bucketName); + } catch (_) {} + }); + + test('get and list indexes', () async { + final fetched = await bucket.getIndex('idx'); + expect(fetched.name, 'idx'); + expect(fetched.bucketName, bucketName); + expect(fetched.dataType, VectorDataType.float32); + expect(fetched.dimension, 3); + expect(fetched.distanceMetric, DistanceMetric.cosine); + expect(fetched.creationTime, isA()); + + final list = await bucket.listIndexes(); + expect(list.indexes.map((entry) => entry.name), contains('idx')); + }); + + test('put, get and list vectors', () async { + await index.putVectors([ + Vector(key: 'a', data: [0.1, 0.2, 0.3], metadata: {'label': 'first'}), + Vector(key: 'b', data: [0.4, 0.5, 0.6]), + ]); + + final fetched = await index.getVectors( + keys: ['a'], + returnData: true, + returnMetadata: true, + ); + expect(fetched.single.key, 'a'); + expect(fetched.single.data, hasLength(3)); + expect(fetched.single.metadata?['label'], 'first'); + + final listed = await index.listVectors(); + expect( + listed.vectors.map((vector) => vector.key), + containsAll(['a', 'b']), + ); + }); + + test('query returns the nearest vector first', () async { + await index.putVectors([ + Vector(key: 'near', data: [0.1, 0.2, 0.3]), + Vector(key: 'far', data: [0.9, 0.1, 0.05]), + ]); + + final result = await index.queryVectors( + queryVector: [0.1, 0.2, 0.3], + topK: 2, + returnDistance: true, + ); + + expect(result.distanceMetric, DistanceMetric.cosine); + expect(result.matches.first.key, 'near'); + expect(result.matches.first.distance, isNotNull); + }); + + test('delete removes vectors', () async { + await index.putVectors([ + Vector(key: 'a', data: [0.1, 0.2, 0.3]), + Vector(key: 'b', data: [0.4, 0.5, 0.6]), + ]); + + await index.deleteVectors(['a']); + + final remaining = await index.getVectors(keys: ['a', 'b']); + expect(remaining.map((vector) => vector.key), ['b']); + }); + + test('parallel scan segments cover every vector', () async { + await index.putVectors([ + Vector(key: 'a', data: [0.1, 0.2, 0.3]), + Vector(key: 'b', data: [0.4, 0.5, 0.6]), + Vector(key: 'c', data: [0.7, 0.8, 0.9]), + ]); + + final keys = {}; + for (var segment = 0; segment < 2; segment++) { + final page = await index.listVectors( + segmentCount: 2, + segmentIndex: segment, + ); + keys.addAll(page.vectors.map((vector) => vector.key)); + } + + expect(keys, {'a', 'b', 'c'}); + }); + }); +} diff --git a/packages/storage_client/test/vector_test.dart b/packages/storage_client/test/vector_test.dart new file mode 100644 index 000000000..398b66805 --- /dev/null +++ b/packages/storage_client/test/vector_test.dart @@ -0,0 +1,369 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:storage_client/storage_client.dart'; +import 'package:test/test.dart'; + +import 'custom_http_client.dart'; + +const storageUrl = 'http://localhost/storage/v1'; +const headers = {'Authorization': 'Bearer token'}; + +void main() { + late CustomHttpClient mockClient; + late SupabaseVectorsClient vectors; + + setUp(() { + mockClient = CustomHttpClient(); + mockClient.statusCode = 200; + mockClient.response = {}; + vectors = SupabaseStorageClient( + storageUrl, + headers, + httpClient: mockClient, + ).vectors; + }); + + Request lastRequest() => mockClient.receivedRequests.single as Request; + + Map lastBody() => + jsonDecode(lastRequest().body) as Map; + + group('vector buckets', () { + test('createBucket posts the bucket name', () async { + await vectors.createBucket('embeddings'); + + expect( + lastRequest().url.toString(), + 'http://localhost/storage/v1/vector/CreateVectorBucket', + ); + expect(lastRequest().method, 'POST'); + expect(lastBody(), {'vectorBucketName': 'embeddings'}); + }); + + test('getBucket parses the bucket metadata', () async { + mockClient.response = { + 'vectorBucket': { + 'vectorBucketName': 'embeddings', + 'creationTime': 1700000000, + 'encryptionConfiguration': {'sseType': 'AES256'}, + }, + }; + + final bucket = await vectors.getBucket('embeddings'); + + expect( + lastRequest().url.toString(), + 'http://localhost/storage/v1/vector/GetVectorBucket', + ); + expect(bucket.name, 'embeddings'); + expect( + bucket.creationTime, + DateTime.fromMillisecondsSinceEpoch(1700000000 * 1000, isUtc: true), + ); + expect(bucket.encryption?.sseType, 'AES256'); + }); + + test('listBuckets sends filters and parses buckets and cursor', () async { + mockClient.response = { + 'vectorBuckets': [ + {'vectorBucketName': 'embeddings-a'}, + {'vectorBucketName': 'embeddings-b'}, + ], + 'nextToken': 'cursor-1', + }; + + final result = await vectors.listBuckets( + prefix: 'embeddings-', + maxResults: 10, + ); + + expect(lastBody(), {'prefix': 'embeddings-', 'maxResults': 10}); + expect(result.buckets.map((bucket) => bucket.name), [ + 'embeddings-a', + 'embeddings-b', + ]); + expect(result.nextToken, 'cursor-1'); + }); + + test('deleteBucket posts the bucket name', () async { + await vectors.deleteBucket('embeddings'); + + expect( + lastRequest().url.toString(), + 'http://localhost/storage/v1/vector/DeleteVectorBucket', + ); + expect(lastBody(), {'vectorBucketName': 'embeddings'}); + }); + }); + + group('vector indexes', () { + test('createIndex sends the full configuration', () async { + await vectors + .from('embeddings') + .createIndex( + name: 'documents', + dimension: 1536, + distanceMetric: DistanceMetric.cosine, + nonFilterableMetadataKeys: ['raw_text'], + ); + + expect( + lastRequest().url.toString(), + 'http://localhost/storage/v1/vector/CreateIndex', + ); + expect(lastBody(), { + 'vectorBucketName': 'embeddings', + 'indexName': 'documents', + 'dataType': 'float32', + 'dimension': 1536, + 'distanceMetric': 'cosine', + 'metadataConfiguration': { + 'nonFilterableMetadataKeys': ['raw_text'], + }, + }); + }); + + test('getIndex parses the index metadata', () async { + mockClient.response = { + 'index': { + 'indexName': 'documents', + 'vectorBucketName': 'embeddings', + 'dataType': 'float32', + 'dimension': 1536, + 'distanceMetric': 'euclidean', + 'metadataConfiguration': { + 'nonFilterableMetadataKeys': ['raw_text'], + }, + }, + }; + + final index = await vectors.from('embeddings').getIndex('documents'); + + expect(index.name, 'documents'); + expect(index.bucketName, 'embeddings'); + expect(index.dataType, VectorDataType.float32); + expect(index.dimension, 1536); + expect(index.distanceMetric, DistanceMetric.euclidean); + expect(index.nonFilterableMetadataKeys, ['raw_text']); + }); + + test('getIndex leaves unknown enum values null', () async { + mockClient.response = { + 'index': { + 'indexName': 'documents', + 'distanceMetric': 'manhattan', + }, + }; + + final index = await vectors.from('embeddings').getIndex('documents'); + + expect(index.distanceMetric, isNull); + }); + + test('listIndexes parses indexes and cursor', () async { + mockClient.response = { + 'indexes': [ + {'indexName': 'documents'}, + {'indexName': 'images'}, + ], + 'nextToken': 'cursor-2', + }; + + final result = await vectors.from('embeddings').listIndexes(); + + expect(result.indexes.map((index) => index.name), [ + 'documents', + 'images', + ]); + expect(result.nextToken, 'cursor-2'); + }); + + test('deleteIndex posts the bucket and index names', () async { + await vectors.from('embeddings').deleteIndex('documents'); + + expect(lastBody(), { + 'vectorBucketName': 'embeddings', + 'indexName': 'documents', + }); + }); + }); + + group('vector data', () { + StorageVectorIndexApi index() => + vectors.from('embeddings').index('documents'); + + test('putVectors serializes the batch', () async { + await index().putVectors([ + Vector(key: 'doc-1', data: [0.1, 0.2, 0.3], metadata: {'page': 1}), + Vector(key: 'doc-2', data: [0.4, 0.5, 0.6]), + ]); + + expect( + lastRequest().url.toString(), + 'http://localhost/storage/v1/vector/PutVectors', + ); + expect(lastBody(), { + 'vectorBucketName': 'embeddings', + 'indexName': 'documents', + 'vectors': [ + { + 'key': 'doc-1', + 'data': { + 'float32': [0.1, 0.2, 0.3], + }, + 'metadata': {'page': 1}, + }, + { + 'key': 'doc-2', + 'data': { + 'float32': [0.4, 0.5, 0.6], + }, + }, + ], + }); + }); + + test('putVectors rejects an empty batch', () { + expect( + index().putVectors([]), + throwsA(isA()), + ); + }); + + test('putVectors rejects a batch larger than 500', () { + final batch = List.generate( + 501, + (i) => Vector(key: 'doc-$i', data: const [0.1]), + ); + expect( + index().putVectors(batch), + throwsA(isA()), + ); + }); + + test('getVectors parses the returned vectors', () async { + mockClient.response = { + 'vectors': [ + { + 'key': 'doc-1', + 'data': { + 'float32': [0.1, 0.2, 0.3], + }, + 'metadata': {'title': 'Intro'}, + }, + ], + }; + + final result = await index().getVectors( + keys: ['doc-1'], + returnData: true, + returnMetadata: true, + ); + + expect(lastBody(), { + 'vectorBucketName': 'embeddings', + 'indexName': 'documents', + 'keys': ['doc-1'], + 'returnData': true, + 'returnMetadata': true, + }); + expect(result.single.key, 'doc-1'); + expect(result.single.data, [0.1, 0.2, 0.3]); + expect(result.single.metadata, {'title': 'Intro'}); + }); + + test('listVectors parses vectors and cursor', () async { + mockClient.response = { + 'vectors': [ + {'key': 'doc-1'}, + {'key': 'doc-2'}, + ], + 'nextToken': 'cursor-3', + }; + + final result = await index().listVectors(maxResults: 2); + + expect(result.vectors.map((vector) => vector.key), ['doc-1', 'doc-2']); + expect(result.nextToken, 'cursor-3'); + }); + + test('listVectors sends parallel scan segments', () async { + await index().listVectors(segmentCount: 4, segmentIndex: 2); + + expect(lastBody(), { + 'vectorBucketName': 'embeddings', + 'indexName': 'documents', + 'segmentCount': 4, + 'segmentIndex': 2, + }); + }); + + test('listVectors rejects an out-of-range segmentCount', () { + expect( + index().listVectors(segmentCount: 17), + throwsA(isA()), + ); + }); + + test('listVectors rejects a segmentIndex outside the segment count', () { + expect( + index().listVectors(segmentCount: 4, segmentIndex: 4), + throwsA(isA()), + ); + }); + + test('queryVectors sends the query vector and parses matches', () async { + mockClient.response = { + 'vectors': [ + { + 'key': 'doc-1', + 'distance': 0.02, + 'metadata': {'title': 'Intro'}, + }, + ], + 'distanceMetric': 'cosine', + }; + + final result = await index().queryVectors( + queryVector: [0.1, 0.2, 0.3], + topK: 5, + filter: {'category': 'technical'}, + returnDistance: true, + returnMetadata: true, + ); + + expect(lastBody(), { + 'vectorBucketName': 'embeddings', + 'indexName': 'documents', + 'queryVector': { + 'float32': [0.1, 0.2, 0.3], + }, + 'topK': 5, + 'filter': {'category': 'technical'}, + 'returnDistance': true, + 'returnMetadata': true, + }); + expect(result.matches.single.key, 'doc-1'); + expect(result.matches.single.distance, 0.02); + expect(result.distanceMetric, DistanceMetric.cosine); + }); + + test('deleteVectors posts the keys', () async { + await index().deleteVectors(['doc-1', 'doc-2']); + + expect(lastBody(), { + 'vectorBucketName': 'embeddings', + 'indexName': 'documents', + 'keys': ['doc-1', 'doc-2'], + }); + }); + + test('deleteVectors rejects an empty batch', () { + expect( + index().deleteVectors([]), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/supabase/CHANGELOG.md b/packages/supabase/CHANGELOG.md index 1106447a4..ed4c8b5bb 100644 --- a/packages/supabase/CHANGELOG.md +++ b/packages/supabase/CHANGELOG.md @@ -1,3 +1,20 @@ +## 2.15.0 + + - **REFACTOR**(supabase): convert private stream-builder holders to records ([#1575](https://github.com/supabase/supabase-flutter/issues/1575)). ([58c2dad4](https://github.com/supabase/supabase-flutter/commit/58c2dad4968d6be36c92ffaa983127827ac97c73)) + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **REFACTOR**(supabase): drop rxdart dependency ([#1569](https://github.com/supabase/supabase-flutter/issues/1569)). ([51dcf0f4](https://github.com/supabase/supabase-flutter/commit/51dcf0f45439f1133a7a97f5ae9929ed3b39076e)) + - **FIX**(supabase_common): align platform stats casing with other SDKs ([#1596](https://github.com/supabase/supabase-flutter/issues/1596)). ([97a6fa6e](https://github.com/supabase/supabase-flutter/commit/97a6fa6eb1f7d5f58b693a5e175fbfe4fe7d2c17)) + - **FIX**(realtime_client): faster channel rejoin on web ([#1471](https://github.com/supabase/supabase-flutter/issues/1471)). ([bc06a310](https://github.com/supabase/supabase-flutter/commit/bc06a310cf09bdba87668695b48bc8b8f353c495)) + - **FEAT**(realtime): defer socket disconnect when channels become empty ([#1589](https://github.com/supabase/supabase-flutter/issues/1589)). ([0055d1dc](https://github.com/supabase/supabase-flutter/commit/0055d1dcb6f9a0d2727d908bc68077af707327d0)) + - **FEAT**(postgrest): configurable auto-retry and request timeout ([#1560](https://github.com/supabase/supabase-flutter/issues/1560)). ([59d4b3af](https://github.com/supabase/supabase-flutter/commit/59d4b3af227e15a53208146a2930981526a39599)) + - **FEAT**(client): add opt-in trace context propagation headers ([#1564](https://github.com/supabase/supabase-flutter/issues/1564)). ([85780e60](https://github.com/supabase/supabase-flutter/commit/85780e607a00904870fe7f68dbd45ee5797256b6)) + - **FEAT**(auth): add async getSession() with on-demand refresh ([#1563](https://github.com/supabase/supabase-flutter/issues/1563)). ([0b1d9b6b](https://github.com/supabase/supabase-flutter/commit/0b1d9b6b61adf9f66ed6716032b1a25f096604ce)) + +## 2.14.0 + + - **FEAT**(realtime_client): support new postgres changes filter operators, multi-filter, column selection, and replication-ready events ([#1526](https://github.com/supabase/supabase-flutter/issues/1526)). ([1f9d2951](https://github.com/supabase/supabase-flutter/commit/1f9d29516032c1f4bd8416fd7fa36737a335afaf)) + ## 2.13.4 - Update a dependency to the latest release. diff --git a/packages/supabase/README.md b/packages/supabase/README.md index eb5da8fb4..bb7bcd5d5 100644 --- a/packages/supabase/README.md +++ b/packages/supabase/README.md @@ -1,6 +1,28 @@ -# supabase +
+

+ + Supabase Logo + -A Dart client for [Supabase](https://supabase.io/). +

supabase

+ +

+ Dart client library for Supabase, for use in non-Flutter Dart environments. +

+ +

+ Guides + · + Reference Docs +

+

+ +
+ +[![pub package](https://img.shields.io/pub/v/supabase.svg)](https://pub.dev/packages/supabase) +[![pub test](https://github.com/supabase/supabase-flutter/workflows/Test/badge.svg)](https://github.com/supabase/supabase-flutter/actions?query=workflow%3ATest) + +
> **Note** > @@ -8,14 +30,9 @@ A Dart client for [Supabase](https://supabase.io/). > > If you are developing a Flutter application, use [supabase_flutter](https://pub.dev/packages/supabase_flutter) instead. `supabase` package is for non-Flutter Dart environments. -[![pub package](https://img.shields.io/pub/v/supabase.svg)](https://pub.dev/packages/supabase) -[![pub test](https://github.com/supabase/supabase-dart/workflows/Test/badge.svg)](https://github.com/supabase/supabase-dart/actions?query=workflow%3ATest) - ---- - ## What is Supabase -[Supabase](https://supabase.io/docs/) is an open source Firebase alternative. We are a service to: +[Supabase](https://supabase.com/docs) is an open source Firebase alternative. We are a service to: - listen to database changes - query your tables, including filtering, pagination, and deeply nested relationships (like GraphQL) @@ -27,7 +44,7 @@ A Dart client for [Supabase](https://supabase.io/). The docs can be found on the official Supabase website. -- [Dart reference](https://supabase.com/docs/reference/dart) +- [Dart reference](https://supabase.com/docs/reference/dart/introduction) - [Supabase docs](https://supabase.com/docs) ## License diff --git a/packages/supabase/example/README.md b/packages/supabase/example/README.md index aae056035..16e136f21 100644 --- a/packages/supabase/example/README.md +++ b/packages/supabase/example/README.md @@ -1,6 +1,6 @@ #### Examples -- Flutter user management: https://github.com/supabase/supabase/tree/master/examples/flutter-user-management +- Flutter user management: https://github.com/supabase/supabase/tree/master/examples/user-management/flutter-user-management - Extended flutter user management with web support, github login, recovery password flow - https://github.com/phamhieu/supabase-flutter-demo - Spot, open source geo based video sharing social app created with Flutter diff --git a/packages/supabase/example/analysis_options.yaml b/packages/supabase/example/analysis_options.yaml new file mode 100644 index 000000000..a8ab68344 --- /dev/null +++ b/packages/supabase/example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:supabase_lints/analysis_options.yaml diff --git a/packages/supabase/example/pubspec.yaml b/packages/supabase/example/pubspec.yaml index b5daad375..4009d9130 100644 --- a/packages/supabase/example/pubspec.yaml +++ b/packages/supabase/example/pubspec.yaml @@ -9,11 +9,11 @@ environment: resolution: workspace dependencies: - web: '>=0.5.0 <2.0.0' - supabase: ^2.9.0 + web: '>=1.0.0 <2.0.0' + supabase: ^2.15.0 dev_dependencies: build_runner: any build_web_compilers: any - lints: ^4.0.0 + supabase_lints: ^0.1.1 diff --git a/packages/supabase/lib/src/api_key.dart b/packages/supabase/lib/src/api_key.dart new file mode 100644 index 000000000..1014ca644 --- /dev/null +++ b/packages/supabase/lib/src/api_key.dart @@ -0,0 +1,38 @@ +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; + +const _publishableKeyPrefix = 'sb_publishable_'; +const _secretKeyPrefix = 'sb_secret_'; + +/// Whether [key] uses the new Supabase API key format +/// (`sb_publishable_...` / `sb_secret_...`). +/// +/// These keys are not JWTs and must never be sent as an +/// `Authorization: Bearer` token. +@internal +bool isNewApiKey(String key) => + key.startsWith(_publishableKeyPrefix) || key.startsWith(_secretKeyPrefix); + +final Set _warnedApiKeySubtypes = {}; + +/// Warns once per unrecognized `sb_`-prefixed key subtype. +/// +/// Never throws: the server, not the SDK, decides key validity. The key value +/// is never logged. +@internal +void warnOnUnrecognizedApiKey(String key, Logger log) { + if (!key.startsWith('sb_') || isNewApiKey(key)) { + return; + } + final rest = key.substring('sb_'.length); + final underscoreIndex = rest.indexOf('_'); + final subtype = underscoreIndex == -1 + ? '' + : rest.substring(0, underscoreIndex); + if (_warnedApiKeySubtypes.add(subtype)) { + log.warning( + 'Unrecognized Supabase API key format. The key will be sent as-is. ' + 'If this is unexpected, verify your API key.', + ); + } +} diff --git a/packages/supabase/lib/src/auth_http_client.dart b/packages/supabase/lib/src/auth_http_client.dart index bd69872d4..f010845c0 100644 --- a/packages/supabase/lib/src/auth_http_client.dart +++ b/packages/supabase/lib/src/auth_http_client.dart @@ -1,18 +1,36 @@ import 'package:http/http.dart'; +import 'package:supabase/src/api_key.dart'; class AuthHttpClient extends BaseClient { final Client _inner; final String _supabaseKey; final Future Function() _getAccessToken; - AuthHttpClient(this._supabaseKey, this._inner, this._getAccessToken); + + /// When `true`, a new-format API key (`sb_publishable_...` / `sb_secret_...`) + /// is never sent as an `Authorization: Bearer` token. A genuine session + /// access token is still sent normally. + final bool _omitNewApiKeyAsBearer; + + AuthHttpClient( + this._supabaseKey, + this._inner, + this._getAccessToken, { + bool omitNewApiKeyAsBearer = false, + }) : _omitNewApiKeyAsBearer = omitNewApiKeyAsBearer; @override Future send(BaseRequest request) async { final accessToken = await _getAccessToken(); - final authBearer = accessToken ?? _supabaseKey; - request.headers.putIfAbsent("Authorization", () => 'Bearer $authBearer'); + if (accessToken != null) { + request.headers.putIfAbsent("Authorization", () => 'Bearer $accessToken'); + } else if (!(_omitNewApiKeyAsBearer && isNewApiKey(_supabaseKey))) { + request.headers.putIfAbsent( + "Authorization", + () => 'Bearer $_supabaseKey', + ); + } request.headers.putIfAbsent("apikey", () => _supabaseKey); return _inner.send(request); } diff --git a/packages/supabase/lib/src/constants.dart b/packages/supabase/lib/src/constants.dart index 763a8bd6a..b0f598916 100644 --- a/packages/supabase/lib/src/constants.dart +++ b/packages/supabase/lib/src/constants.dart @@ -1,19 +1,16 @@ import 'package:supabase/src/version.dart'; -import 'platform_stub.dart' if (dart.library.io) 'platform_io.dart'; +import 'package:supabase_common/supabase_common.dart'; class Constants { - static String? get platform => conditionalPlatform; - static String? get platformVersion => conditionalPlatformVersion; - static String? get runtimeVersion => conditionalRuntimeVersion; - static final Map defaultHeaders = Map.unmodifiable({ - 'X-Client-Info': [ - 'supabase-dart/$version', - if (platform != null) 'platform=$platform', - if (platformVersion != null) - 'platform-version=${Uri.encodeFull(platformVersion!).replaceAll("%20", " ")}', - 'runtime=dart', - if (runtimeVersion != null) 'runtime-version=$runtimeVersion', - ].join('; '), + 'X-Client-Info': buildClientInfoHeader( + 'supabase-dart', + version, + platformInfo: PlatformInfo( + platform: conditionalPlatform, + platformVersion: conditionalPlatformVersion, + runtimeVersion: conditionalRuntimeVersion, + ), + ), }); } diff --git a/packages/supabase/lib/src/platform_io.dart b/packages/supabase/lib/src/platform_io.dart deleted file mode 100644 index 56e0cc714..000000000 --- a/packages/supabase/lib/src/platform_io.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'dart:io'; - -String get conditionalPlatform => Platform.operatingSystem; - -String get conditionalPlatformVersion => Platform.operatingSystemVersion; - -String get conditionalRuntimeVersion => Platform.version.split(' ').first; diff --git a/packages/supabase/lib/src/platform_stub.dart b/packages/supabase/lib/src/platform_stub.dart deleted file mode 100644 index c2748b0e0..000000000 --- a/packages/supabase/lib/src/platform_stub.dart +++ /dev/null @@ -1,5 +0,0 @@ -String? get conditionalPlatform => null; - -String? get conditionalPlatformVersion => null; - -String? get conditionalRuntimeVersion => null; diff --git a/packages/supabase/lib/src/realtime_client_options.dart b/packages/supabase/lib/src/realtime_client_options.dart index 685d0632a..c896eb2b0 100644 --- a/packages/supabase/lib/src/realtime_client_options.dart +++ b/packages/supabase/lib/src/realtime_client_options.dart @@ -18,14 +18,29 @@ class RealtimeClientOptions { /// the timeout to trigger push timeouts final Duration? timeout; + /// The timeout to wait for the connection to close before dismissing the + /// result. + final Duration? connectionCloseTimeout; + /// Custom WebSocket transport factory for the RealtimeClient. final WebSocketTransport? transport; + /// The delay before the socket is disconnected once the last channel is + /// removed. + /// + /// If a new channel is created before the delay elapses, the pending + /// disconnect is cancelled and the open socket is reused. Pass + /// [Duration.zero] to disconnect immediately. Defaults to twice the + /// heartbeat interval. + final Duration? disconnectOnEmptyChannelsAfter; + /// {@macro realtime_client_options} const RealtimeClientOptions({ this.eventsPerSecond, this.logLevel, this.timeout, + this.connectionCloseTimeout, this.transport, + this.disconnectOnEmptyChannelsAfter, }); } diff --git a/packages/supabase/lib/src/supabase_client.dart b/packages/supabase/lib/src/supabase_client.dart index a0988ece2..f76841589 100644 --- a/packages/supabase/lib/src/supabase_client.dart +++ b/packages/supabase/lib/src/supabase_client.dart @@ -7,8 +7,10 @@ import 'package:supabase/src/version.dart'; import 'package:supabase/supabase.dart'; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; +import 'api_key.dart'; import 'auth_http_client.dart'; import 'counter.dart'; +import 'trace_http_client.dart'; /// {@template supabase_client} /// Creates a Supabase client to interact with your Supabase instance. @@ -54,6 +56,8 @@ class SupabaseClient { final Map _headers; final Client? _httpClient; late final Client _authHttpClient; + late final Client _functionsHttpClient; + late final Client _gotrueHttpClient; GoTrueClient? _authInstance; @@ -126,6 +130,8 @@ class SupabaseClient { StorageClientOptions storageOptions = const StorageClientOptions(), FunctionsClientOptions functionsOptions = const FunctionsClientOptions(), RealtimeClientOptions realtimeClientOptions = const RealtimeClientOptions(), + TracePropagationOptions tracePropagationOptions = + const TracePropagationOptions(), this.accessToken, Map? headers, Client? httpClient, @@ -145,6 +151,15 @@ class SupabaseClient { _httpClient = httpClient, _isolate = isolate ?? (YAJsonIsolate()..initialize()), _hasCustomIsolate = isolate != null { + final baseHttpClient = httpClient ?? Client(); + final tracedHttpClient = tracePropagationOptions.enabled + ? TracePropagationClient( + baseHttpClient, + tracePropagationOptions, + supabaseUrl, + ) + : baseHttpClient; + _gotrueHttpClient = tracedHttpClient; _authInstance = _initSupabaseAuthClient( autoRefreshToken: authOptions.autoRefreshToken, gotrueAsyncStorage: authOptions.pkceAsyncStorage, @@ -152,9 +167,16 @@ class SupabaseClient { ); _authHttpClient = AuthHttpClient( _supabaseKey, - httpClient ?? Client(), + tracedHttpClient, _getAccessToken, ); + _functionsHttpClient = AuthHttpClient( + _supabaseKey, + tracedHttpClient, + _getAccessToken, + omitNewApiKeyAsBearer: true, + ); + warnOnUnrecognizedApiKey(_supabaseKey, _log); rest = _initRestClient(); functions = _initFunctionsClient(); storage = _initStorageClient( @@ -257,31 +279,18 @@ class SupabaseClient { return await accessToken!(); } - final authInstance = _authInstance!; - - if (authInstance.currentSession?.isExpired ?? false) { - try { - await authInstance.refreshSession(); - } catch (error, stackTrace) { - final expiresAt = authInstance.currentSession?.expiresAt; - if (expiresAt != null) { - // Failed to refresh the token. - final isExpiredWithoutMargin = DateTime.now().isAfter( - DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000), - ); - if (isExpiredWithoutMargin) { - // Throw the error instead of making an API request with an expired token. - _log.warning( - 'Access token is expired and refreshing failed, aborting api request', - error, - stackTrace, - ); - rethrow; - } - } - } + try { + final session = await _authInstance!.getSession(); + return session?.accessToken; + } on AuthException catch (error, stackTrace) { + // Throw the error instead of making an API request with an expired token. + _log.warning( + 'Access token is expired and refreshing failed, aborting api request', + error, + stackTrace, + ); + rethrow; } - return authInstance.currentSession?.accessToken; } Future dispose() async { @@ -312,7 +321,7 @@ class SupabaseClient { url: _authUrl, headers: authHeaders, autoRefreshToken: autoRefreshToken, - httpClient: _httpClient, + httpClient: _gotrueHttpClient, asyncStorage: gotrueAsyncStorage, flowType: authFlowType, ); @@ -325,6 +334,10 @@ class SupabaseClient { schema: _postgrestOptions.schema, httpClient: _authHttpClient, isolate: _isolate, + retryEnabled: _postgrestOptions.retryEnabled, + retryCount: _postgrestOptions.retryCount, + retryableStatusCodes: _postgrestOptions.retryableStatusCodes, + requestTimeout: _postgrestOptions.requestTimeout, ); } @@ -332,7 +345,7 @@ class SupabaseClient { return FunctionsClient( _functionsUrl, {...headers}, - httpClient: _authHttpClient, + httpClient: _functionsHttpClient, isolate: _isolate, region: _functionsOptions.region, ); @@ -363,8 +376,12 @@ class SupabaseClient { logLevel: options.logLevel, httpClient: _authHttpClient, timeout: options.timeout ?? RealtimeConstants.defaultTimeout, + connectionCloseTimeout: + options.connectionCloseTimeout ?? + RealtimeConstants.defaultConnectionCloseTimeout, customAccessToken: accessToken, transport: options.transport, + disconnectOnEmptyChannelsAfter: options.disconnectOnEmptyChannelsAfter, ); } diff --git a/packages/supabase/lib/src/supabase_client_options.dart b/packages/supabase/lib/src/supabase_client_options.dart index c8bcf2607..36b025c37 100644 --- a/packages/supabase/lib/src/supabase_client_options.dart +++ b/packages/supabase/lib/src/supabase_client_options.dart @@ -3,7 +3,32 @@ import 'package:supabase/supabase.dart'; class PostgrestClientOptions { final String schema; - const PostgrestClientOptions({this.schema = 'public'}); + /// Whether automatic retries are performed for GET and HEAD requests that + /// fail with a retryable status code or a network error. + final bool retryEnabled; + + /// The number of retry attempts made for a retryable request before giving up. + final int retryCount; + + /// The HTTP status codes that trigger an automatic retry. + final Set retryableStatusCodes; + + /// Bounds how long a single request attempt may take. + /// + /// Implemented on top of the abort mechanism, so it actually cancels a + /// stalled attempt instead of leaving it running. A timed-out attempt is + /// retried like any other failure, and a `TimeoutException` is thrown once + /// the retries are exhausted. When `null` (the default) no timeout is + /// applied. + final Duration? requestTimeout; + + const PostgrestClientOptions({ + this.schema = 'public', + this.retryEnabled = true, + this.retryCount = 3, + this.retryableStatusCodes = PostgrestClient.defaultRetryableStatusCodes, + this.requestTimeout, + }); } class AuthClientOptions { diff --git a/packages/supabase/lib/src/supabase_query_builder.dart b/packages/supabase/lib/src/supabase_query_builder.dart index 4b323ea71..a8d6b48ef 100644 --- a/packages/supabase/lib/src/supabase_query_builder.dart +++ b/packages/supabase/lib/src/supabase_query_builder.dart @@ -1,6 +1,6 @@ import 'package:supabase/supabase.dart'; -class SupabaseQueryBuilder extends PostgrestQueryBuilder { +class SupabaseQueryBuilder extends PostgrestQueryBuilder { final RealtimeClient _realtime; final String _schema; final String _table; diff --git a/packages/supabase/lib/src/supabase_stream_builder.dart b/packages/supabase/lib/src/supabase_stream_builder.dart index 3309c66c8..0247bf5ae 100644 --- a/packages/supabase/lib/src/supabase_stream_builder.dart +++ b/packages/supabase/lib/src/supabase_stream_builder.dart @@ -1,36 +1,20 @@ import 'dart:async'; import 'package:logging/logging.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:supabase/supabase.dart'; +import 'package:supabase_common/supabase_common.dart'; part 'supabase_stream_filter_builder.dart'; -class _StreamPostgrestFilter { - const _StreamPostgrestFilter({ - required this.column, - required this.value, - required this.type, - }); +/// [column] name of the eq filter, [value] of the eq filter, and [type] of the +/// filter being applied. +typedef _StreamPostgrestFilter = ({ + String column, + dynamic value, + PostgresChangeFilterType type, +}); - /// Column name of the eq filter - final String column; - - /// Value of the eq filter - final dynamic value; - - /// Type of the filer being applied - final PostgresChangeFilterType type; -} - -class _Order { - const _Order({ - required this.column, - required this.ascending, - }); - final String column; - final bool ascending; -} +typedef _Order = ({String column, bool ascending}); class RealtimeSubscribeException implements Exception { const RealtimeSubscribeException(this.status, [this.details]); @@ -47,7 +31,7 @@ class RealtimeSubscribeException implements Exception { typedef SupabaseStreamEvent = List>; class SupabaseStreamBuilder extends Stream { - final PostgrestQueryBuilder _queryBuilder; + final PostgrestQueryBuilder _queryBuilder; final RealtimeClient _realtimeClient; @@ -69,7 +53,7 @@ class SupabaseStreamBuilder extends Stream { final _log = Logger('supabase.supabase'); /// StreamController for `stream()` method. - BehaviorSubject? _streamController; + ReplaySubject? _streamController; /// Contains the combined data of postgrest and realtime to emit as stream. SupabaseStreamEvent _streamData = []; @@ -88,7 +72,7 @@ class SupabaseStreamBuilder extends Stream { bool _wasSubscribed = false; SupabaseStreamBuilder({ - required PostgrestQueryBuilder queryBuilder, + required PostgrestQueryBuilder queryBuilder, required String realtimeTopic, required RealtimeClient realtimeClient, required String schema, @@ -111,7 +95,7 @@ class SupabaseStreamBuilder extends Stream { /// supabase.from('users').stream(primaryKey: ['id']).order('username', ascending: false); /// ``` SupabaseStreamBuilder order(String column, {bool ascending = false}) { - _orderBy = _Order(column: column, ascending: ascending); + _orderBy = (column: column, ascending: ascending); return this; } @@ -149,7 +133,7 @@ class SupabaseStreamBuilder extends Stream { /// Sets up the stream controller and calls the method to get data as necessary void _setupStream() { - _streamController ??= BehaviorSubject( + _streamController ??= ReplaySubject( onListen: () { _getStreamData(); }, @@ -193,7 +177,6 @@ class SupabaseStreamBuilder extends Stream { final newRecord = payload.newRecord; _streamData.add(newRecord); _addStream(); - break; case PostgresChangeEvent.update: final updatedIndex = _streamData.indexWhere( (element) => @@ -207,7 +190,6 @@ class SupabaseStreamBuilder extends Stream { _streamData.add(updatedRecord); } _addStream(); - break; case PostgresChangeEvent.delete: final deletedIndex = _streamData.indexWhere( (element) => @@ -218,7 +200,6 @@ class SupabaseStreamBuilder extends Stream { _streamData.removeAt(deletedIndex); _addStream(); } - break; case PostgresChangeEvent.all: break; } @@ -233,14 +214,11 @@ class SupabaseStreamBuilder extends Stream { unawaited(_getPostgrestData()); } _wasSubscribed = true; - break; case RealtimeSubscribeStatus.closed: unawaited(_streamController?.close()); - break; case RealtimeSubscribeStatus.timedOut: case RealtimeSubscribeStatus.channelError: _addException(RealtimeSubscribeException(status, error)); - break; } }); unawaited(_getPostgrestData()); @@ -249,43 +227,49 @@ class SupabaseStreamBuilder extends Stream { Future _getPostgrestData() async { PostgrestFilterBuilder query = _queryBuilder.select(); if (_streamFilter != null) { - switch (_streamFilter!.type) { - case PostgresChangeFilterType.eq: - query = query.eq(_streamFilter!.column, _streamFilter!.value); - break; - case PostgresChangeFilterType.neq: - query = query.neq(_streamFilter!.column, _streamFilter!.value); - break; - case PostgresChangeFilterType.lt: - query = query.lt(_streamFilter!.column, _streamFilter!.value); - break; - case PostgresChangeFilterType.lte: - query = query.lte(_streamFilter!.column, _streamFilter!.value); - break; - case PostgresChangeFilterType.gt: - query = query.gt(_streamFilter!.column, _streamFilter!.value); - break; - case PostgresChangeFilterType.gte: - query = query.gte(_streamFilter!.column, _streamFilter!.value); - break; - case PostgresChangeFilterType.inFilter: - query = query.inFilter(_streamFilter!.column, _streamFilter!.value); - break; - case PostgresChangeFilterType.like: - case PostgresChangeFilterType.ilike: - case PostgresChangeFilterType.isFilter: - case PostgresChangeFilterType.match: - case PostgresChangeFilterType.imatch: - case PostgresChangeFilterType.isDistinct: - // These operators are only reachable through the realtime - // `onPostgresChanges` API, not through `.stream()`'s filter builder, - // so they can never be set on `_streamFilter`. Guard the exhaustive - // switch defensively in case that ever changes. - throw UnsupportedError( - 'The "${_streamFilter!.type.name}" filter is not supported by ' - '`.stream()`. Use one of eq, neq, lt, lte, gt, gte or inFilter.', - ); - } + query = switch (_streamFilter!.type) { + PostgresChangeFilterType.eq => query.eq( + _streamFilter!.column, + _streamFilter!.value, + ), + PostgresChangeFilterType.neq => query.neq( + _streamFilter!.column, + _streamFilter!.value, + ), + PostgresChangeFilterType.lt => query.lt( + _streamFilter!.column, + _streamFilter!.value, + ), + PostgresChangeFilterType.lte => query.lte( + _streamFilter!.column, + _streamFilter!.value, + ), + PostgresChangeFilterType.gt => query.gt( + _streamFilter!.column, + _streamFilter!.value, + ), + PostgresChangeFilterType.gte => query.gte( + _streamFilter!.column, + _streamFilter!.value, + ), + PostgresChangeFilterType.inFilter => query.inFilter( + _streamFilter!.column, + _streamFilter!.value, + ), + // These operators are only reachable through the realtime + // `onPostgresChanges` API, not through `.stream()`'s filter builder, + // so they can never be set on `_streamFilter`. Guard the exhaustive + // switch defensively in case that ever changes. + PostgresChangeFilterType.like || + PostgresChangeFilterType.ilike || + PostgresChangeFilterType.isFilter || + PostgresChangeFilterType.match || + PostgresChangeFilterType.imatch || + PostgresChangeFilterType.isDistinct => throw UnsupportedError( + 'The "${_streamFilter!.type.name}" filter is not supported by ' + '`.stream()`. Use one of eq, neq, lt, lte, gt, gte or inFilter.', + ), + }; } PostgrestTransformBuilder? transformQuery; if (_orderBy != null) { @@ -370,7 +354,7 @@ class SupabaseStreamBuilder extends Stream { ) { // Copied from [Stream.asyncMap] - final controller = BehaviorSubject(); + final controller = ReplaySubject(); controller.onListen = () { StreamSubscription subscription = listen( @@ -414,7 +398,7 @@ class SupabaseStreamBuilder extends Stream { Stream? Function(SupabaseStreamEvent event) convert, ) { //Copied from [Stream.asyncExpand] - final controller = BehaviorSubject(); + final controller = ReplaySubject(); controller.onListen = () { StreamSubscription subscription = listen( null, diff --git a/packages/supabase/lib/src/supabase_stream_filter_builder.dart b/packages/supabase/lib/src/supabase_stream_filter_builder.dart index 12316686f..adff3a650 100644 --- a/packages/supabase/lib/src/supabase_stream_filter_builder.dart +++ b/packages/supabase/lib/src/supabase_stream_filter_builder.dart @@ -19,7 +19,7 @@ class SupabaseStreamFilterBuilder extends SupabaseStreamBuilder { /// supabase.from('users').stream(primaryKey: ['id']).eq('name', 'Supabase'); /// ``` SupabaseStreamBuilder eq(String column, Object value) { - _streamFilter = _StreamPostgrestFilter( + _streamFilter = ( type: PostgresChangeFilterType.eq, column: column, value: value, @@ -35,7 +35,7 @@ class SupabaseStreamFilterBuilder extends SupabaseStreamBuilder { /// supabase.from('users').stream(primaryKey: ['id']).neq('name', 'Supabase'); /// ``` SupabaseStreamBuilder neq(String column, Object value) { - _streamFilter = _StreamPostgrestFilter( + _streamFilter = ( type: PostgresChangeFilterType.neq, column: column, value: value, @@ -51,7 +51,7 @@ class SupabaseStreamFilterBuilder extends SupabaseStreamBuilder { /// supabase.from('users').stream(primaryKey: ['id']).lt('likes', 100); /// ``` SupabaseStreamBuilder lt(String column, Object value) { - _streamFilter = _StreamPostgrestFilter( + _streamFilter = ( type: PostgresChangeFilterType.lt, column: column, value: value, @@ -67,7 +67,7 @@ class SupabaseStreamFilterBuilder extends SupabaseStreamBuilder { /// supabase.from('users').stream(primaryKey: ['id']).lte('likes', 100); /// ``` SupabaseStreamBuilder lte(String column, Object value) { - _streamFilter = _StreamPostgrestFilter( + _streamFilter = ( type: PostgresChangeFilterType.lte, column: column, value: value, @@ -83,7 +83,7 @@ class SupabaseStreamFilterBuilder extends SupabaseStreamBuilder { /// supabase.from('users').stream(primaryKey: ['id']).gt('likes', '100'); /// ``` SupabaseStreamBuilder gt(String column, Object value) { - _streamFilter = _StreamPostgrestFilter( + _streamFilter = ( type: PostgresChangeFilterType.gt, column: column, value: value, @@ -99,7 +99,7 @@ class SupabaseStreamFilterBuilder extends SupabaseStreamBuilder { /// supabase.from('users').stream(primaryKey: ['id']).gte('likes', 100); /// ``` SupabaseStreamBuilder gte(String column, Object value) { - _streamFilter = _StreamPostgrestFilter( + _streamFilter = ( type: PostgresChangeFilterType.gte, column: column, value: value, @@ -115,7 +115,7 @@ class SupabaseStreamFilterBuilder extends SupabaseStreamBuilder { /// supabase.from('users').stream(primaryKey: ['id']).inFilter('name', ['Andy', 'Amy', 'Terry']); /// ``` SupabaseStreamBuilder inFilter(String column, List values) { - _streamFilter = _StreamPostgrestFilter( + _streamFilter = ( type: PostgresChangeFilterType.inFilter, column: column, value: values, diff --git a/packages/supabase/lib/src/trace_http_client.dart b/packages/supabase/lib/src/trace_http_client.dart new file mode 100644 index 000000000..9f963c0c9 --- /dev/null +++ b/packages/supabase/lib/src/trace_http_client.dart @@ -0,0 +1,109 @@ +import 'package:http/http.dart'; +import 'package:meta/meta.dart'; + +import 'trace_propagation.dart'; + +@internal +class TracePropagationClient extends BaseClient { + final Client _inner; + final TracePropagationOptions _options; + final Set _exactHosts; + + TracePropagationClient(this._inner, this._options, String supabaseUrl) + : _exactHosts = _defaultExactHosts(supabaseUrl); + + static const _wildcardDomains = ['supabase.co', 'supabase.in']; + + static Set _defaultExactHosts(String supabaseUrl) { + final hosts = {'localhost', '127.0.0.1', '::1'}; + final host = Uri.tryParse(supabaseUrl)?.host; + if (host != null && host.isNotEmpty) { + hosts.add(host); + } + return hosts; + } + + @override + Future send(BaseRequest request) async { + if (_shouldPropagateTo(request.url)) { + final context = await _options.traceContextProvider?.call(); + if (context != null && _isPropagatable(context)) { + _applyHeaders(request.headers, context); + } + } + return _inner.send(request); + } + + bool _shouldPropagateTo(Uri url) { + final host = url.host; + if (_exactHosts.contains(host)) { + return true; + } + for (final domain in _wildcardDomains) { + if (host == domain || host.endsWith('.$domain')) { + return true; + } + } + return false; + } + + bool _isPropagatable(TraceContext context) { + final traceparent = context.traceparent; + if (traceparent == null || traceparent.isEmpty) { + return false; + } + if (_options.respectSamplingDecision && !_isSampled(traceparent)) { + return false; + } + return true; + } + + void _applyHeaders(Map headers, TraceContext context) { + final traceparent = context.traceparent; + if (traceparent != null) { + headers.putIfAbsent('traceparent', () => traceparent); + } + final tracestate = context.tracestate; + if (tracestate != null) { + headers.putIfAbsent('tracestate', () => tracestate); + } + final baggage = context.baggage; + if (baggage != null) { + headers.putIfAbsent('baggage', () => baggage); + } + } + + @override + void close() => _inner.close(); +} + +/// Reports whether a W3C `traceparent` carries the sampled flag. +/// +/// Malformed headers are treated as sampled so that propagation is not silently +/// suppressed by an unparseable value, matching supabase-js. +bool _isSampled(String traceparent) { + final parts = traceparent.split('-'); + if (parts.length != 4) { + return true; + } + final [version, traceId, parentId, traceFlags] = parts; + if (version.length != 2 || + traceId.length != 32 || + parentId.length != 16 || + traceFlags.length != 2) { + return true; + } + final hexadecimal = RegExp(r'^[0-9a-f]+$', caseSensitive: false); + if (!hexadecimal.hasMatch(version) || + !hexadecimal.hasMatch(traceId) || + !hexadecimal.hasMatch(parentId) || + !hexadecimal.hasMatch(traceFlags)) { + return true; + } + if (traceId == '00000000000000000000000000000000' || + parentId == '0000000000000000') { + return true; + } + final flags = int.parse(traceFlags, radix: 16); + return flags & 0x01 == 0x01; +} diff --git a/packages/supabase/lib/src/trace_propagation.dart b/packages/supabase/lib/src/trace_propagation.dart new file mode 100644 index 000000000..4760031cd --- /dev/null +++ b/packages/supabase/lib/src/trace_propagation.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +/// Supplies the current W3C trace context for outgoing Supabase requests. +/// +/// Return `null` when there is no active trace. This is the idiomatic Dart +/// replacement for supabase-js's automatic extraction from the OpenTelemetry +/// global context: wire it up to whichever tracer your application uses. +typedef TraceContextProvider = FutureOr Function(); + +/// W3C trace context headers. +/// +/// See https://www.w3.org/TR/trace-context/ +class TraceContext { + /// The `traceparent` header, formatted as + /// `version-traceid-parentid-traceflags`. + final String? traceparent; + + /// The `tracestate` header carrying vendor-specific trace data. + final String? tracestate; + + /// The `baggage` header carrying application-defined key-value pairs. + final String? baggage; + + const TraceContext({this.traceparent, this.tracestate, this.baggage}); +} + +/// Options controlling W3C trace context propagation onto outgoing Supabase +/// requests. +/// +/// Propagation is opt-in and disabled by default, so existing clients send no +/// additional headers. When enabled, the trace context returned by +/// [traceContextProvider] is injected into requests targeting Supabase hosts +/// (`*.supabase.co`, `*.supabase.in`, the project host, and loopback addresses +/// for local development). Third-party hosts never receive trace headers. +class TracePropagationOptions { + /// Whether trace propagation is enabled. Defaults to `false`. + final bool enabled; + + /// Whether to skip propagation when the upstream trace is not sampled, that + /// is when the sampled flag is `0` in the `traceparent` header. + /// + /// Set to `false` to always propagate regardless of the sampling decision, + /// which is useful when you want every Supabase request tagged with a trace + /// id for log correlation even if the trace itself is not exported. Defaults + /// to `true`. + final bool respectSamplingDecision; + + /// Supplies the current trace context for each outgoing request. + final TraceContextProvider? traceContextProvider; + + const TracePropagationOptions({ + this.enabled = false, + this.respectSamplingDecision = true, + this.traceContextProvider, + }); +} diff --git a/packages/supabase/lib/src/version.dart b/packages/supabase/lib/src/version.dart index bfd2115dd..d5586c43c 100644 --- a/packages/supabase/lib/src/version.dart +++ b/packages/supabase/lib/src/version.dart @@ -1 +1 @@ -const version = '2.13.4'; +const version = '2.15.0'; diff --git a/packages/supabase/lib/supabase.dart b/packages/supabase/lib/supabase.dart index 582f14d4a..00dd67d02 100644 --- a/packages/supabase/lib/supabase.dart +++ b/packages/supabase/lib/supabase.dart @@ -19,3 +19,4 @@ export 'src/supabase_query_builder.dart'; export 'src/supabase_query_schema.dart'; export 'src/supabase_realtime_error.dart'; export 'src/supabase_stream_builder.dart'; +export 'src/trace_propagation.dart'; diff --git a/packages/supabase/pubspec.yaml b/packages/supabase/pubspec.yaml index 1bfe306fe..ecfb0f46e 100644 --- a/packages/supabase/pubspec.yaml +++ b/packages/supabase/pubspec.yaml @@ -1,9 +1,16 @@ name: supabase description: A dart client for Supabase. This client makes it simple for developers to build secure and scalable products. -version: 2.13.4 +version: 2.15.0 homepage: 'https://supabase.com' repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/supabase' +issue_tracker: 'https://github.com/supabase/supabase-flutter/issues' documentation: 'https://supabase.com/docs/reference/dart/introduction' +topics: + - supabase + - backend + - database + - auth + - api environment: sdk: '>=3.9.0 <4.0.0' @@ -11,17 +18,18 @@ environment: resolution: workspace dependencies: - functions_client: 2.6.4 - gotrue: 2.25.0 - http: ^1.2.2 - postgrest: 2.8.0 - realtime_client: 2.10.0 - storage_client: 2.6.0 - rxdart: '>=0.27.5 <0.29.0' + functions_client: 2.7.0 + gotrue: 2.27.0 + http: ^1.6.0 + meta: ^1.7.0 + postgrest: 2.9.0 + realtime_client: 2.12.0 + storage_client: 2.7.0 + supabase_common: 0.1.1 yet_another_json_isolate: 2.1.1 - logging: ^1.2.0 + logging: ^1.3.0 dev_dependencies: - supabase_lints: ^0.1.0 - test: ^1.17.9 - web_socket_channel: '>=2.2.0 <4.0.0' \ No newline at end of file + supabase_lints: ^0.1.1 + test: ^1.25.0 + web_socket_channel: '>=3.0.0 <4.0.0' \ No newline at end of file diff --git a/packages/supabase/test/api_key_test.dart b/packages/supabase/test/api_key_test.dart new file mode 100644 index 000000000..b3d12ab26 --- /dev/null +++ b/packages/supabase/test/api_key_test.dart @@ -0,0 +1,139 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:logging/logging.dart'; +import 'package:supabase/src/api_key.dart'; +import 'package:supabase/src/auth_http_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('isNewApiKey', () { + test('detects publishable and secret keys', () { + expect(isNewApiKey('sb_publishable_abc123'), isTrue); + expect(isNewApiKey('sb_secret_abc123'), isTrue); + }); + + test('legacy JWT and anon keys are not new format', () { + expect(isNewApiKey('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'), isFalse); + expect(isNewApiKey('anon-key'), isFalse); + expect(isNewApiKey('sb_something'), isFalse); + }); + }); + + group('warnOnUnrecognizedApiKey', () { + test('warns once per unrecognized sb_ subtype without logging the key', () { + final records = []; + final subscription = Logger.root.onRecord.listen(records.add); + final log = Logger('test.api_key'); + + warnOnUnrecognizedApiKey('sb_weirdtype_supersecretvalue', log); + warnOnUnrecognizedApiKey('sb_weirdtype_anothersecretvalue', log); + + unawaited(subscription.cancel()); + + expect(records, hasLength(1)); + expect(records.first.level, Level.WARNING); + expect(records.first.message, isNot(contains('weirdtype'))); + expect(records.first.message, isNot(contains('supersecretvalue'))); + }); + + test('does not log the key when there is no second underscore', () { + final records = []; + final subscription = Logger.root.onRecord.listen(records.add); + final log = Logger('test.api_key'); + + warnOnUnrecognizedApiKey('sb_sensitivevalue', log); + + unawaited(subscription.cancel()); + + expect(records, hasLength(1)); + expect(records.first.level, Level.WARNING); + expect(records.first.message, isNot(contains('sensitivevalue'))); + }); + + test('does not warn on recognized or legacy keys', () { + final records = []; + final subscription = Logger.root.onRecord.listen(records.add); + final log = Logger('test.api_key'); + + warnOnUnrecognizedApiKey('sb_publishable_abc', log); + warnOnUnrecognizedApiKey('sb_secret_abc', log); + warnOnUnrecognizedApiKey('eyJhbGciOiJIUzI1NiJ9', log); + + unawaited(subscription.cancel()); + + expect(records, isEmpty); + }); + }); + + group('AuthHttpClient bearer suppression', () { + late Map capturedHeaders; + + http.Client buildClient({ + required String key, + required String? session, + required bool omitNewApiKeyAsBearer, + }) { + final mockClient = MockClient((request) async { + capturedHeaders = request.headers; + return http.Response('', 200); + }); + return AuthHttpClient( + key, + mockClient, + () async => session, + omitNewApiKeyAsBearer: omitNewApiKeyAsBearer, + ); + } + + test('omits new-format key as Bearer when there is no session', () async { + final client = buildClient( + key: 'sb_publishable_abc', + session: null, + omitNewApiKeyAsBearer: true, + ); + await client.get(Uri.parse('https://example.com')); + + expect(capturedHeaders.containsKey('authorization'), isFalse); + expect(capturedHeaders['apikey'], 'sb_publishable_abc'); + }); + + test('sends session JWT as Bearer even with a new-format key', () async { + final client = buildClient( + key: 'sb_publishable_abc', + session: 'jwt-token', + omitNewApiKeyAsBearer: true, + ); + await client.get(Uri.parse('https://example.com')); + + expect(capturedHeaders['authorization'], 'Bearer jwt-token'); + expect(capturedHeaders['apikey'], 'sb_publishable_abc'); + }); + + test('sends legacy key as Bearer when there is no session', () async { + final client = buildClient( + key: 'legacy-anon-key', + session: null, + omitNewApiKeyAsBearer: true, + ); + await client.get(Uri.parse('https://example.com')); + + expect(capturedHeaders['authorization'], 'Bearer legacy-anon-key'); + }); + + test( + 'without the flag, a new-format key is still sent as Bearer', + () async { + final client = buildClient( + key: 'sb_publishable_abc', + session: null, + omitNewApiKeyAsBearer: false, + ); + await client.get(Uri.parse('https://example.com')); + + expect(capturedHeaders['authorization'], 'Bearer sb_publishable_abc'); + }, + ); + }); +} diff --git a/packages/supabase/test/client_test.dart b/packages/supabase/test/client_test.dart index d2e4ed78f..8e001a427 100644 --- a/packages/supabase/test/client_test.dart +++ b/packages/supabase/test/client_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:supabase/supabase.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'package:test/test.dart'; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; @@ -40,21 +41,36 @@ void main() { test('X-Client-Info includes structured platform metadata', () { final clientInfo = supabase.headers['X-Client-Info']!; expect(clientInfo, startsWith('supabase-dart/')); - expect(clientInfo, contains('; platform=${Platform.operatingSystem}')); + expect( + clientInfo, + contains( + '; platform=${normalizePlatformName(Platform.operatingSystem)}', + ), + ); expect(clientInfo, contains('; runtime=dart')); }); test('X-Client-Info includes structured platform metadata on auth', () { final clientInfo = supabase.auth.headers['X-Client-Info']!; expect(clientInfo, startsWith('supabase-dart/')); - expect(clientInfo, contains('; platform=${Platform.operatingSystem}')); + expect( + clientInfo, + contains( + '; platform=${normalizePlatformName(Platform.operatingSystem)}', + ), + ); expect(clientInfo, contains('; runtime=dart')); }); test('X-Client-Info includes structured platform metadata on storage', () { final clientInfo = supabase.storage.headers['X-Client-Info']!; expect(clientInfo, startsWith('supabase-dart/')); - expect(clientInfo, contains('; platform=${Platform.operatingSystem}')); + expect( + clientInfo, + contains( + '; platform=${normalizePlatformName(Platform.operatingSystem)}', + ), + ); expect(clientInfo, contains('; runtime=dart')); }); @@ -63,7 +79,12 @@ void main() { () { final clientInfo = supabase.functions.headers['X-Client-Info']!; expect(clientInfo, startsWith('supabase-dart/')); - expect(clientInfo, contains('; platform=${Platform.operatingSystem}')); + expect( + clientInfo, + contains( + '; platform=${normalizePlatformName(Platform.operatingSystem)}', + ), + ); expect(clientInfo, contains('; runtime=dart')); }, ); @@ -71,7 +92,12 @@ void main() { test('X-Client-Info includes structured platform metadata on rest', () { final clientInfo = supabase.rest.headers['X-Client-Info']!; expect(clientInfo, startsWith('supabase-dart/')); - expect(clientInfo, contains('; platform=${Platform.operatingSystem}')); + expect( + clientInfo, + contains( + '; platform=${normalizePlatformName(Platform.operatingSystem)}', + ), + ); expect(clientInfo, contains('; runtime=dart')); }); @@ -84,7 +110,12 @@ void main() { ); final clientInfo = request.headers['X-Client-Info']?.first; expect(clientInfo, startsWith('supabase-dart/')); - expect(clientInfo, contains('; platform=${Platform.operatingSystem}')); + expect( + clientInfo, + contains( + '; platform=${normalizePlatformName(Platform.operatingSystem)}', + ), + ); expect(clientInfo, contains('; runtime=dart')); }, ); @@ -104,7 +135,7 @@ void main() { supabaseClient: supabase, ); - var realtimeWebsocketURL = request.uri; + final realtimeWebsocketURL = request.uri; expect( realtimeWebsocketURL.queryParameters, @@ -172,9 +203,9 @@ void main() { var count = 0; // Check for every request if the Authorization header is set properly - await for (final req in mockServer) { + await for (final request in mockServer) { expect( - req.headers.value('Authorization')?.split(" ").last, + request.headers.value('Authorization')?.split(" ").last, accessToken, ); count++; @@ -211,8 +242,8 @@ void main() { var secondAccessToken = "to be set"; // Check for every request if the Authorization header is set properly - await for (final req in mockServer) { - if (req.uri.path == "/auth/v1/token") { + await for (final request in mockServer) { + if (request.uri.path == "/auth/v1/token") { if (gotTokenRefresh) { fail("Token was refreshed twice"); } @@ -222,14 +253,14 @@ void main() { DateTime.now().add(Duration(hours: 1)), ); - req.response + request.response ..statusCode = HttpStatus.ok ..headers.contentType = ContentType.json ..write(sessionString); - await req.response.close(); + await request.response.close(); } else { expect( - req.headers.value('Authorization')?.split(" ").last, + request.headers.value('Authorization')?.split(" ").last, secondAccessToken, ); count++; @@ -402,7 +433,7 @@ void main() { // meaning there is no double-dispose from sub-clients. final client = SupabaseClient(supabaseUrl, supabaseKey); - await client.dispose(); + expect(client.dispose(), completes); }, ); }); diff --git a/packages/supabase/test/mock_test.dart b/packages/supabase/test/mock_test.dart index 92d0f5538..6a0a70f6e 100644 --- a/packages/supabase/test/mock_test.dart +++ b/packages/supabase/test/mock_test.dart @@ -40,7 +40,7 @@ void main() { // Check that rest api contains the correct filter in the URL if (expectedFilter != null) { - expect(url.contains(expectedFilter), isTrue); + expect(url, contains(expectedFilter)); } } if (url == '/rest/v1/todos?select=task%2Cstatus') { @@ -399,7 +399,7 @@ void main() { test('test mock server', () async { final data = await supabase.from('todos').select('task, status'); - expect(data.length, 2); + expect(data, hasLength(2)); }); group('Basic client test', () { @@ -427,10 +427,10 @@ void main() { group('stream()', () { test("listen, cancel and listen again", () async { final stream = supabase.from('todos').stream(primaryKey: ['id']); - final sub = stream.listen(expectAsync1((event) {}, count: 5)); + final subscription = stream.listen(expectAsync1((event) {}, count: 5)); await Future.delayed(Duration(seconds: 1)); - await sub.cancel(); + await subscription.cancel(); await Future.delayed(Duration(seconds: 1)); stream.listen(expectAsync1((event) {}, count: 5)); @@ -844,8 +844,8 @@ void main() { ); // Should handle token errors gracefully - expect( - () async => await clientWithFailingToken.from('test').select(), + await expectLater( + () => clientWithFailingToken.from('test').select(), throwsA(isA()), ); diff --git a/packages/supabase/test/realtime_test.dart b/packages/supabase/test/realtime_test.dart index c89c34e58..7e7d19de7 100644 --- a/packages/supabase/test/realtime_test.dart +++ b/packages/supabase/test/realtime_test.dart @@ -58,11 +58,11 @@ void main() { callback: (payload) {}, ) .subscribe( - (event, [errorMsg]) {}, + (event, [errorMessage]) {}, ); expect( () => channel.subscribe(), - throwsA(const TypeMatcher()), + throwsA(isA()), ); }); @@ -79,8 +79,8 @@ void main() { final channels = supabase.getChannels(); expect( - channels.length, - 2, + channels, + hasLength(2), ); }); @@ -99,8 +99,8 @@ void main() { anotherChannel.subscribe(); expect( - supabase.getChannels().length, - 2, + supabase.getChannels(), + hasLength(2), ); final status = await supabase.removeChannel(anotherChannel); @@ -108,8 +108,8 @@ void main() { expect(status, 'ok'); expect( - supabase.getChannels().length, - 1, + supabase.getChannels(), + hasLength(1), ); }); @@ -141,8 +141,8 @@ void main() { ); expect( - supabase.getChannels().length, - 0, + supabase.getChannels(), + isEmpty, ); }); @@ -162,14 +162,10 @@ void main() { channel.subscribe(); anotherChannel.subscribe(); - final result1 = await supabase.removeAllChannels(); - expect( - result1, - isNotEmpty, - ); + final result = await supabase.removeAllChannels(); expect( - result1.length, - 2, + result, + hasLength(2), ); expect( diff --git a/packages/supabase/test/trace_propagation_test.dart b/packages/supabase/test/trace_propagation_test.dart new file mode 100644 index 000000000..cbef486a0 --- /dev/null +++ b/packages/supabase/test/trace_propagation_test.dart @@ -0,0 +1,177 @@ +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:supabase/src/trace_http_client.dart'; +import 'package:supabase/supabase.dart'; +import 'package:test/test.dart'; + +const _sampledTraceparent = + '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; +const _unsampledTraceparent = + '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00'; +const _supabaseUrl = 'https://project.supabase.co'; + +void main() { + late Request captured; + + MockClient mockClient() => MockClient((request) async { + captured = request; + return Response('', 200); + }); + + TracePropagationClient client( + TracePropagationOptions options, { + String supabaseUrl = _supabaseUrl, + }) { + return TracePropagationClient(mockClient(), options, supabaseUrl); + } + + const context = TraceContext( + traceparent: _sampledTraceparent, + tracestate: 'vendor=value', + baggage: 'key=value', + ); + + TracePropagationOptions optionsWith( + TraceContext? Function() provider, { + bool respectSamplingDecision = true, + }) { + return TracePropagationOptions( + enabled: true, + respectSamplingDecision: respectSamplingDecision, + traceContextProvider: provider, + ); + } + + test('injects trace headers for Supabase project host', () async { + await client( + optionsWith(() => context), + ).get(Uri.parse('$_supabaseUrl/rest/v1/table')); + + expect(captured.headers['traceparent'], _sampledTraceparent); + expect(captured.headers['tracestate'], 'vendor=value'); + expect(captured.headers['baggage'], 'key=value'); + }); + + test('injects trace headers for wildcard supabase.co subdomains', () async { + await client( + optionsWith(() => context), + ).get(Uri.parse('https://other.supabase.in/functions/v1/fn')); + + expect(captured.headers['traceparent'], _sampledTraceparent); + }); + + test('injects trace headers for localhost during development', () async { + await client( + optionsWith(() => context), + supabaseUrl: 'http://localhost:54321', + ).get(Uri.parse('http://localhost:54321/rest/v1/table')); + + expect(captured.headers['traceparent'], _sampledTraceparent); + }); + + test('does not propagate to third-party hosts', () async { + await client( + optionsWith(() => context), + ).get(Uri.parse('https://evil.com/api')); + + expect(captured.headers.containsKey('traceparent'), isFalse); + }); + + test('does not inject when the provider returns null', () async { + await client( + optionsWith(() => null), + ).get(Uri.parse('$_supabaseUrl/rest/v1/table')); + + expect(captured.headers.containsKey('traceparent'), isFalse); + }); + + test( + 'skips unsampled traces when respecting the sampling decision', + () async { + await client( + optionsWith( + () => const TraceContext(traceparent: _unsampledTraceparent), + ), + ).get(Uri.parse('$_supabaseUrl/rest/v1/table')); + + expect(captured.headers.containsKey('traceparent'), isFalse); + }, + ); + + test('propagates unsampled traces when sampling is not respected', () async { + await client( + optionsWith( + () => const TraceContext(traceparent: _unsampledTraceparent), + respectSamplingDecision: false, + ), + ).get(Uri.parse('$_supabaseUrl/rest/v1/table')); + + expect(captured.headers['traceparent'], _unsampledTraceparent); + }); + + test('propagates malformed traceparent without suppressing it', () async { + await client( + optionsWith(() => const TraceContext(traceparent: 'not-a-traceparent')), + ).get(Uri.parse('$_supabaseUrl/rest/v1/table')); + + expect(captured.headers['traceparent'], 'not-a-traceparent'); + }); + + test('does not overwrite an existing trace header', () async { + await client(optionsWith(() => context)).get( + Uri.parse('$_supabaseUrl/rest/v1/table'), + headers: {'traceparent': 'existing'}, + ); + + expect(captured.headers['traceparent'], 'existing'); + }); + + test('SupabaseClient wires trace propagation into rest requests', () async { + late Request restRequest; + final supabase = SupabaseClient( + _supabaseUrl, + 'anon-key', + tracePropagationOptions: optionsWith(() => context), + httpClient: MockClient((request) async { + restRequest = request; + return Response( + '[]', + 200, + request: request, + headers: { + 'content-type': 'application/json', + }, + ); + }), + ); + addTearDown(supabase.dispose); + + await supabase.from('table').select(); + + expect(restRequest.headers['traceparent'], _sampledTraceparent); + }); + + test('SupabaseClient sends no trace headers when disabled', () async { + late Request restRequest; + final supabase = SupabaseClient( + _supabaseUrl, + 'anon-key', + httpClient: MockClient((request) async { + restRequest = request; + return Response( + '[]', + 200, + request: request, + headers: { + 'content-type': 'application/json', + }, + ); + }), + ); + addTearDown(supabase.dispose); + + await supabase.from('table').select(); + + expect(restRequest.headers.containsKey('traceparent'), isFalse); + }); +} diff --git a/packages/supabase/test/utilities_test.dart b/packages/supabase/test/utilities_test.dart index 4beea667e..d8ee92dc8 100644 --- a/packages/supabase/test/utilities_test.dart +++ b/packages/supabase/test/utilities_test.dart @@ -8,6 +8,7 @@ import 'package:supabase/src/auth_http_client.dart'; import 'package:supabase/src/constants.dart'; import 'package:supabase/src/counter.dart'; import 'package:supabase/src/supabase_event_types.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'package:test/test.dart'; const bool kIsWeb = bool.fromEnvironment('dart.library.js_util'); @@ -60,30 +61,30 @@ void main() { test('should have platform getter', () { if (kIsWeb) { - expect(Constants.platform, isNull); + expect(conditionalPlatform, isNull); } else { - expect(Constants.platform, isNotNull); - expect(Constants.platform, isA()); + expect(conditionalPlatform, isNotNull); + expect(conditionalPlatform, isA()); } }); test('should have platformVersion getter', () { if (kIsWeb) { - expect(Constants.platformVersion, isNull); + expect(conditionalPlatformVersion, isNull); } else { - expect(Constants.platformVersion, isNotNull); - expect(Constants.platformVersion, isA()); + expect(conditionalPlatformVersion, isNotNull); + expect(conditionalPlatformVersion, isA()); } }); test('should have runtimeVersion getter', () { if (kIsWeb) { - expect(Constants.runtimeVersion, isNull); + expect(conditionalRuntimeVersion, isNull); } else { - expect(Constants.runtimeVersion, isNotNull); - expect(Constants.runtimeVersion, isA()); + expect(conditionalRuntimeVersion, isNotNull); + expect(conditionalRuntimeVersion, isA()); // Version should be a semver-like string (e.g. "3.7.2") - expect(Constants.runtimeVersion, matches(RegExp(r'^\d+\.\d+\.\d+'))); + expect(conditionalRuntimeVersion, matches(RegExp(r'^\d+\.\d+\.\d+'))); } }); }); diff --git a/packages/supabase_common/CHANGELOG.md b/packages/supabase_common/CHANGELOG.md new file mode 100644 index 000000000..cf00f531f --- /dev/null +++ b/packages/supabase_common/CHANGELOG.md @@ -0,0 +1,8 @@ +## 0.1.1 + + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **FIX**(supabase_common): align platform stats casing with other SDKs ([#1596](https://github.com/supabase/supabase-flutter/issues/1596)). ([97a6fa6e](https://github.com/supabase/supabase-flutter/commit/97a6fa6eb1f7d5f58b693a5e175fbfe4fe7d2c17)) + +## 0.1.0 + + - Initial release. Shared internal utilities extracted from the Supabase client packages. diff --git a/packages/supabase_common/LICENSE b/packages/supabase_common/LICENSE new file mode 100644 index 000000000..1516f9843 --- /dev/null +++ b/packages/supabase_common/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Supabase Community + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/supabase_common/README.md b/packages/supabase_common/README.md new file mode 100644 index 000000000..996d63e4c --- /dev/null +++ b/packages/supabase_common/README.md @@ -0,0 +1,15 @@ +# supabase_common + +> [!WARNING] +> This is an **internal package**. It is an implementation detail of the +> Supabase client packages and is not intended to be consumed directly. +> **Breaking changes can be expected** as the client packages evolve, so please +> do not depend on it directly. + +Shared internal utilities used across the Supabase Dart and Flutter client +packages (`gotrue`, `postgrest`, `realtime_client`, `storage_client`, +`functions_client`, `supabase`, `supabase_flutter`). + +This package holds code that would otherwise be duplicated across those +packages: the `X-Client-Info` header builder, platform detection, a small +replay stream subject, base64url/PKCE helpers and a few other primitives. diff --git a/packages/supabase_common/analysis_options.yaml b/packages/supabase_common/analysis_options.yaml new file mode 100644 index 000000000..a8ab68344 --- /dev/null +++ b/packages/supabase_common/analysis_options.yaml @@ -0,0 +1 @@ +include: package:supabase_lints/analysis_options.yaml diff --git a/packages/gotrue/lib/src/base64url.dart b/packages/supabase_common/lib/src/base64url.dart similarity index 100% rename from packages/gotrue/lib/src/base64url.dart rename to packages/supabase_common/lib/src/base64url.dart diff --git a/packages/supabase_common/lib/src/client_info.dart b/packages/supabase_common/lib/src/client_info.dart new file mode 100644 index 000000000..b0645252a --- /dev/null +++ b/packages/supabase_common/lib/src/client_info.dart @@ -0,0 +1,56 @@ +/// Platform information used to build the richer, platform-aware form of the +/// `X-Client-Info` header. +class PlatformInfo { + final String? platform; + final String? platformVersion; + final String? runtimeVersion; + + const PlatformInfo({ + this.platform, + this.platformVersion, + this.runtimeVersion, + }); +} + +/// Platform names shared across the Supabase SDKs so per-platform stats can be +/// aggregated regardless of language or framework. The casing matches the +/// Swift SDK. +const _platformNames = { + 'android': 'Android', + 'ios': 'iOS', + 'linux': 'Linux', + 'macos': 'macOS', + 'windows': 'Windows', + 'fuchsia': 'Fuchsia', +}; + +/// Normalizes a `dart:io` `Platform.operatingSystem` value to the platform name +/// shared across the Supabase SDKs. Unknown values are returned unchanged so +/// they stay visible in stats. +String normalizePlatformName(String operatingSystem) => + _platformNames[operatingSystem] ?? operatingSystem; + +/// Builds the value of the `X-Client-Info` header. +/// +/// When [platformInfo] is `null` the minimal `'$clientName/$version'` form is +/// returned. Otherwise a `; `-joined list is returned, appending `platform`, +/// `platform-version`, `runtime` and `runtime-version` segments for the +/// non-null fields. +String buildClientInfoHeader( + String clientName, + String version, { + PlatformInfo? platformInfo, +}) { + if (platformInfo == null) { + return '$clientName/$version'; + } + return [ + '$clientName/$version', + if (platformInfo.platform != null) 'platform=${platformInfo.platform}', + if (platformInfo.platformVersion != null) + 'platform-version=${Uri.encodeFull(platformInfo.platformVersion!).replaceAll("%20", " ")}', + 'runtime=dart', + if (platformInfo.runtimeVersion != null) + 'runtime-version=${platformInfo.runtimeVersion}', + ].join('; '); +} diff --git a/packages/supabase_common/lib/src/fetch_options.dart b/packages/supabase_common/lib/src/fetch_options.dart new file mode 100644 index 000000000..358a25bcc --- /dev/null +++ b/packages/supabase_common/lib/src/fetch_options.dart @@ -0,0 +1,23 @@ +/// Options for a single HTTP request made by the Supabase client packages. +class FetchOptions { + /// Extra headers to send with the request. + /// + /// Defaults to an empty, unmodifiable map when none are provided. + final Map headers; + + /// Whether to skip JSON decoding and return the raw response bytes. + /// + /// Defaults to `false`, meaning the response body is decoded as JSON. + final bool noResolveJson; + + /// Creates a set of request options. + /// + /// [headers] are the extra headers to send, defaulting to an empty map. + /// [noResolveJson] toggles returning the raw response bytes instead of + /// decoded JSON, defaulting to `false`. + const FetchOptions( + Map? headers, { + bool? noResolveJson, + }) : headers = headers ?? const {}, + noResolveJson = noResolveJson ?? false; +} diff --git a/packages/supabase_common/lib/src/http_status.dart b/packages/supabase_common/lib/src/http_status.dart new file mode 100644 index 000000000..1ce37ebc2 --- /dev/null +++ b/packages/supabase_common/lib/src/http_status.dart @@ -0,0 +1,2 @@ +/// Whether [code] is a 2xx HTTP status code. +bool isSuccessStatusCode(int code) => code >= 200 && code <= 299; diff --git a/packages/supabase_common/lib/src/pkce.dart b/packages/supabase_common/lib/src/pkce.dart new file mode 100644 index 000000000..a4bf2d9c1 --- /dev/null +++ b/packages/supabase_common/lib/src/pkce.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; + +/// Generates a random PKCE code verifier. +String generatePKCEVerifier() { + const verifierLength = 56; + final random = Random.secure(); + return base64UrlEncode( + List.generate(verifierLength, (_) => random.nextInt(256)), + ).split('=')[0]; +} + +/// Generates the PKCE code challenge for the given [verifier]. +String generatePKCEChallenge(String verifier) { + return base64UrlEncode( + sha256.convert(ascii.encode(verifier)).bytes, + ).split('=')[0]; +} diff --git a/packages/supabase_common/lib/src/platform/platform_info.dart b/packages/supabase_common/lib/src/platform/platform_info.dart new file mode 100644 index 000000000..a384abe3a --- /dev/null +++ b/packages/supabase_common/lib/src/platform/platform_info.dart @@ -0,0 +1,5 @@ +// Platform detection primitives. +// +// On `dart:io` platforms these resolve to the real operating system, platform +// version and Dart runtime version. On web they resolve to `null`. +export 'platform_stub.dart' if (dart.library.io) 'platform_io.dart'; diff --git a/packages/supabase_flutter/lib/src/platform_io.dart b/packages/supabase_common/lib/src/platform/platform_io.dart similarity index 68% rename from packages/supabase_flutter/lib/src/platform_io.dart rename to packages/supabase_common/lib/src/platform/platform_io.dart index 4a82e60e1..f30633ff7 100644 --- a/packages/supabase_flutter/lib/src/platform_io.dart +++ b/packages/supabase_common/lib/src/platform/platform_io.dart @@ -1,6 +1,9 @@ import 'dart:io'; -String get conditionalPlatform => Platform.operatingSystem; +import '../client_info.dart'; + +String get conditionalPlatform => + normalizePlatformName(Platform.operatingSystem); String get conditionalPlatformVersion => Platform.operatingSystemVersion; diff --git a/packages/supabase_flutter/lib/src/platform_stub.dart b/packages/supabase_common/lib/src/platform/platform_stub.dart similarity index 100% rename from packages/supabase_flutter/lib/src/platform_stub.dart rename to packages/supabase_common/lib/src/platform/platform_stub.dart diff --git a/packages/supabase_common/lib/src/replay_subject.dart b/packages/supabase_common/lib/src/replay_subject.dart new file mode 100644 index 000000000..ff105aa8a --- /dev/null +++ b/packages/supabase_common/lib/src/replay_subject.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +/// A minimal broadcast stream controller that replays the most recent event +/// (value or error) to every new subscriber. +/// +/// This mirrors the only behavior of rxdart's `BehaviorSubject` that the +/// Supabase client packages rely on: a listener that subscribes after an event +/// has already been emitted immediately receives the latest event. It preserves +/// synchronous event delivery when constructed with [sync] set to true, and +/// exposes settable [onListen]/[onCancel] hooks used by consumers that wire the +/// subject up imperatively. +class ReplaySubject { + ReplaySubject({ + bool sync = false, + void Function()? onListen, + FutureOr Function()? onCancel, + }) : _sync = sync, + _onListen = onListen, + _onCancel = onCancel { + _controller = StreamController.broadcast( + sync: sync, + onListen: () => _onListen?.call(), + onCancel: () => _onCancel?.call(), + ); + } + + final bool _sync; + late final StreamController _controller; + + void Function()? _onListen; + FutureOr Function()? _onCancel; + + bool _hasEvent = false; + T? _latestValue; + Object? _latestError; + StackTrace? _latestStackTrace; + bool _latestIsError = false; + + set onListen(void Function()? value) => _onListen = value; + + set onCancel(FutureOr Function()? value) => _onCancel = value; + + // Broadcast subjects never pause, so these are no-ops. They exist only to + // satisfy the unreachable non-broadcast branch in the copied `asyncMap` and + // `asyncExpand` implementations. + set onPause(void Function()? value) {} + + set onResume(void Function()? value) {} + + bool get isClosed => _controller.isClosed; + + Stream get stream => Stream.multi((controller) { + // Replay the latest event to the new subscriber, matching the + // controller's sync-ness so that a sync subject stays synchronous. + if (_hasEvent) { + if (_latestIsError) { + _sync + ? controller.addErrorSync(_latestError!, _latestStackTrace) + : controller.addError(_latestError!, _latestStackTrace); + } else { + _sync + ? controller.addSync(_latestValue as T) + : controller.add(_latestValue as T); + } + } + + // Forward live events synchronously so the underlying broadcast + // controller's scheduling is the only hop. Without this, the extra + // controller would add a second microtask of latency versus a plain + // broadcast controller. + final subscription = _controller.stream.listen( + controller.addSync, + onError: controller.addErrorSync, + onDone: controller.closeSync, + ); + controller.onCancel = subscription.cancel; + }, isBroadcast: true); + + void add(T event) { + _hasEvent = true; + _latestIsError = false; + _latestValue = event; + _latestError = null; + _latestStackTrace = null; + _controller.add(event); + } + + void addError(Object error, [StackTrace? stackTrace]) { + _hasEvent = true; + _latestIsError = true; + _latestError = error; + _latestStackTrace = stackTrace; + _controller.addError(error, stackTrace); + } + + Future addStream(Stream source) => _controller.addStream(source); + + Future close() => _controller.close(); +} diff --git a/packages/supabase_common/lib/src/retry.dart b/packages/supabase_common/lib/src/retry.dart new file mode 100644 index 000000000..196c5e247 --- /dev/null +++ b/packages/supabase_common/lib/src/retry.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'dart:math'; + +/// Options for retrying a function. +/// +/// Minimal in-house replacement for the subset of the `retry` package the +/// Supabase clients rely on. +class RetryOptions { + /// Delay factor to double after every attempt. + final Duration delayFactor; + + /// Percentage the delay is randomized by, as a fraction between 0 and 1. + final double randomizationFactor; + + /// Maximum delay between retries. + final Duration maxDelay; + + /// Maximum number of attempts before giving up. + final int maxAttempts; + + const RetryOptions({ + this.delayFactor = const Duration(milliseconds: 200), + this.randomizationFactor = 0.25, + this.maxDelay = const Duration(seconds: 30), + this.maxAttempts = 8, + }); + + /// Delay after [attempt] number of attempts. + Duration delay(int attempt) { + assert(attempt >= 0, 'attempt cannot be negative'); + if (attempt <= 0) { + return Duration.zero; + } + final randomization = + randomizationFactor * (Random().nextDouble() * 2 - 1) + 1; + final exponent = min(attempt, 31); + final delay = delayFactor * pow(2.0, exponent) * randomization; + return delay < maxDelay ? delay : maxDelay; + } + + /// Calls [fn], retrying so long as [retryIf] returns `true` for the thrown + /// [Exception], up to [maxAttempts] times. + Future retry( + FutureOr Function() fn, { + FutureOr Function(Exception)? retryIf, + FutureOr Function(Exception)? onRetry, + }) async { + var attempt = 0; + while (true) { + attempt++; + try { + return await fn(); + } on Exception catch (error) { + if (attempt >= maxAttempts || + (retryIf != null && !(await retryIf(error)))) { + rethrow; + } + if (onRetry != null) { + await onRetry(error); + } + } + await Future.delayed(delay(attempt)); + } + } +} + +/// Calls [fn], retrying so long as [retryIf] returns `true` for the thrown +/// [Exception], up to [maxAttempts] times. +Future retry( + FutureOr Function() fn, { + Duration delayFactor = const Duration(milliseconds: 200), + double randomizationFactor = 0.25, + Duration maxDelay = const Duration(seconds: 30), + int maxAttempts = 8, + FutureOr Function(Exception)? retryIf, + FutureOr Function(Exception)? onRetry, +}) => RetryOptions( + delayFactor: delayFactor, + randomizationFactor: randomizationFactor, + maxDelay: maxDelay, + maxAttempts: maxAttempts, +).retry(fn, retryIf: retryIf, onRetry: onRetry); diff --git a/packages/supabase_common/lib/src/snake_case.dart b/packages/supabase_common/lib/src/snake_case.dart new file mode 100644 index 000000000..b6733f2fe --- /dev/null +++ b/packages/supabase_common/lib/src/snake_case.dart @@ -0,0 +1,19 @@ +/// Converts an enum value's [Enum.name] to its `snake_case` representation. +extension ToSnakeCase on Enum { + String get snakeCase { + final a = 'a'.codeUnitAt(0), z = 'z'.codeUnitAt(0); + final A = 'A'.codeUnitAt(0), Z = 'Z'.codeUnitAt(0); + final result = StringBuffer()..write(name[0].toLowerCase()); + for (var i = 1; i < name.length; i++) { + final char = name.codeUnitAt(i); + if (A <= char && char <= Z) { + final pChar = name.codeUnitAt(i - 1); + if (a <= pChar && pChar <= z) { + result.write('_'); + } + } + result.write(name[i].toLowerCase()); + } + return result.toString(); + } +} diff --git a/packages/supabase_common/lib/src/uuid.dart b/packages/supabase_common/lib/src/uuid.dart new file mode 100644 index 000000000..4875be49c --- /dev/null +++ b/packages/supabase_common/lib/src/uuid.dart @@ -0,0 +1,10 @@ +final uuidRegex = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', +); + +/// Throws an [ArgumentError] if [id] is not a valid UUID. +void validateUuid(String id) { + if (!uuidRegex.hasMatch(id)) { + throw ArgumentError('Invalid id: $id, must be a valid UUID'); + } +} diff --git a/packages/supabase_common/lib/supabase_common.dart b/packages/supabase_common/lib/supabase_common.dart new file mode 100644 index 000000000..ae17fc531 --- /dev/null +++ b/packages/supabase_common/lib/supabase_common.dart @@ -0,0 +1,17 @@ +/// Shared internal utilities used across the Supabase client packages. +/// +/// This package is an implementation detail of the Supabase client packages +/// and is not intended for direct consumption. Its API may change without +/// notice. +library; + +export 'src/base64url.dart'; +export 'src/client_info.dart'; +export 'src/fetch_options.dart'; +export 'src/http_status.dart'; +export 'src/pkce.dart'; +export 'src/platform/platform_info.dart'; +export 'src/replay_subject.dart'; +export 'src/retry.dart'; +export 'src/snake_case.dart'; +export 'src/uuid.dart'; diff --git a/packages/supabase_common/pubspec.yaml b/packages/supabase_common/pubspec.yaml new file mode 100644 index 000000000..16393ffc5 --- /dev/null +++ b/packages/supabase_common/pubspec.yaml @@ -0,0 +1,23 @@ +name: supabase_common +description: Shared internal utilities used across the Supabase Dart and Flutter client packages. +version: 0.1.1 +homepage: 'https://supabase.com' +repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/supabase_common' +issue_tracker: 'https://github.com/supabase/supabase-flutter/issues' +topics: + - supabase + - internal + - utilities + +environment: + sdk: '>=3.9.0 <4.0.0' + +resolution: workspace + +dependencies: + crypto: ^3.0.7 + meta: ^1.16.0 + +dev_dependencies: + supabase_lints: ^0.1.1 + test: ^1.25.0 diff --git a/packages/gotrue/test/src/base64url_test.dart b/packages/supabase_common/test/base64url_test.dart similarity index 96% rename from packages/gotrue/test/src/base64url_test.dart rename to packages/supabase_common/test/base64url_test.dart index 038647df6..74057087d 100644 --- a/packages/gotrue/test/src/base64url_test.dart +++ b/packages/supabase_common/test/base64url_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:gotrue/src/base64url.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'package:test/test.dart'; void main() { @@ -73,7 +73,7 @@ void main() { const jwtSignature = 'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; final decoded = Base64Url.decodeToBytes(jwtSignature); expect(decoded, isA>()); - expect(decoded.length, greaterThan(0)); + expect(decoded, isNotEmpty); }); }); }); diff --git a/packages/supabase_common/test/retry_test.dart b/packages/supabase_common/test/retry_test.dart new file mode 100644 index 000000000..7f47ef57f --- /dev/null +++ b/packages/supabase_common/test/retry_test.dart @@ -0,0 +1,102 @@ +import 'package:supabase_common/supabase_common.dart'; +import 'package:test/test.dart'; + +void main() { + test('retries until success and counts attempts', () async { + var attempts = 0; + final result = await retry( + () async { + attempts++; + if (attempts < 3) throw const FormatException('fail'); + return 'ok'; + }, + delayFactor: const Duration(milliseconds: 1), + retryIf: (error) => error is FormatException, + ); + expect(result, 'ok'); + expect(attempts, 3); + }); + + test('stops at maxAttempts and rethrows', () async { + var attempts = 0; + await expectLater( + retry( + () async { + attempts++; + throw const FormatException('always'); + }, + maxAttempts: 4, + delayFactor: const Duration(milliseconds: 1), + ), + throwsA(isA()), + ); + expect(attempts, 4); + }); + + test('does not retry when retryIf returns false', () async { + var attempts = 0; + await expectLater( + retry( + () async { + attempts++; + throw const FormatException('nope'); + }, + delayFactor: const Duration(milliseconds: 1), + retryIf: (error) => false, + ), + throwsA(isA()), + ); + expect(attempts, 1); + }); + + test('RetryOptions.retry with maxAttempts (storage usage)', () async { + var attempts = 0; + await expectLater( + const RetryOptions( + maxAttempts: 2, + delayFactor: Duration(milliseconds: 1), + ).retry( + () async { + attempts++; + throw const FormatException('x'); + }, + retryIf: (error) => true, + ), + throwsA(isA()), + ); + expect(attempts, 2); + }); + + test('invokes onRetry before each retry with the thrown error', () async { + final seenErrors = []; + var attempts = 0; + final result = + await const RetryOptions( + maxAttempts: 3, + delayFactor: Duration(milliseconds: 1), + ).retry( + () async { + attempts++; + if (attempts < 3) throw FormatException('fail $attempts'); + return 'ok'; + }, + retryIf: (error) => true, + onRetry: seenErrors.add, + ); + expect(result, 'ok'); + expect(seenErrors, hasLength(2)); + expect(seenErrors.every((error) => error is FormatException), isTrue); + }); + + test('delay grows exponentially and is capped at maxDelay', () { + const options = RetryOptions( + delayFactor: Duration(milliseconds: 100), + randomizationFactor: 0, + maxDelay: Duration(seconds: 1), + ); + expect(options.delay(0), Duration.zero); + expect(options.delay(1), const Duration(milliseconds: 200)); + expect(options.delay(2), const Duration(milliseconds: 400)); + expect(options.delay(10), const Duration(seconds: 1)); + }); +} diff --git a/packages/gotrue/test/auth_response_test.dart b/packages/supabase_common/test/snake_case_test.dart similarity index 95% rename from packages/gotrue/test/auth_response_test.dart rename to packages/supabase_common/test/snake_case_test.dart index 7b44f768b..472190684 100644 --- a/packages/gotrue/test/auth_response_test.dart +++ b/packages/supabase_common/test/snake_case_test.dart @@ -1,6 +1,6 @@ // ignore_for_file: constant_identifier_names -import 'package:gotrue/src/types/auth_response.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'package:test/test.dart'; enum TestEnum { diff --git a/packages/supabase_common/test/supabase_common_test.dart b/packages/supabase_common/test/supabase_common_test.dart new file mode 100644 index 000000000..17d95c6b3 --- /dev/null +++ b/packages/supabase_common/test/supabase_common_test.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:supabase_common/supabase_common.dart'; +import 'package:test/test.dart'; + +void main() { + group('buildClientInfoHeader', () { + test('returns the minimal form without platform info', () { + expect( + buildClientInfoHeader('gotrue-dart', '2.0.0'), + 'gotrue-dart/2.0.0', + ); + }); + + test('returns the rich form with platform info', () { + final header = buildClientInfoHeader( + 'supabase-dart', + '2.0.0', + platformInfo: const PlatformInfo( + platform: 'macOS', + platformVersion: 'Version 14.0', + runtimeVersion: '3.9.0', + ), + ); + expect( + header, + 'supabase-dart/2.0.0; platform=macOS; ' + 'platform-version=Version 14.0; runtime=dart; runtime-version=3.9.0', + ); + }); + + test('omits null platform segments but always includes runtime=dart', () { + final header = buildClientInfoHeader( + 'supabase-dart', + '2.0.0', + platformInfo: const PlatformInfo(), + ); + expect(header, 'supabase-dart/2.0.0; runtime=dart'); + }); + }); + + group('normalizePlatformName', () { + test('aligns casing with the other Supabase SDKs', () { + expect(normalizePlatformName('android'), 'Android'); + expect(normalizePlatformName('ios'), 'iOS'); + expect(normalizePlatformName('linux'), 'Linux'); + expect(normalizePlatformName('macos'), 'macOS'); + expect(normalizePlatformName('windows'), 'Windows'); + expect(normalizePlatformName('fuchsia'), 'Fuchsia'); + }); + + test('returns unknown values unchanged', () { + expect(normalizePlatformName('someNewPlatform'), 'someNewPlatform'); + }); + }); + + group('isSuccessStatusCode', () { + test('is true for 2xx only', () { + expect(isSuccessStatusCode(199), isFalse); + expect(isSuccessStatusCode(200), isTrue); + expect(isSuccessStatusCode(299), isTrue); + expect(isSuccessStatusCode(300), isFalse); + }); + }); + + group('PKCE', () { + test('verifier is url safe and challenge is deterministic', () { + final verifier = generatePKCEVerifier(); + expect(verifier, isNot(contains('='))); + expect(verifier, isNotEmpty); + // Same verifier always yields the same challenge. + expect( + generatePKCEChallenge(verifier), + generatePKCEChallenge(verifier), + ); + }); + }); + + group('platform info', () { + test('resolves the operating system, versions and test flag on the VM', () { + expect(conditionalPlatform, isNotEmpty); + expect(conditionalPlatformVersion, isNotEmpty); + expect(conditionalRuntimeVersion, isNotEmpty); + expect(isRunningInFlutterTest, isA()); + }); + }); + + group('validateUuid', () { + test('accepts valid uuid and rejects invalid', () { + expect( + () => validateUuid('123e4567-e89b-12d3-a456-426614174000'), + returnsNormally, + ); + expect(() => validateUuid('not-a-uuid'), throwsArgumentError); + }); + }); + + group('FetchOptions', () { + test('defaults to an empty header map and JSON resolution', () { + const options = FetchOptions(null); + expect(options.headers, isEmpty); + expect(options.noResolveJson, isFalse); + }); + + test('keeps the provided headers and noResolveJson flag', () { + const options = FetchOptions({'x': 'y'}, noResolveJson: true); + expect(options.headers, {'x': 'y'}); + expect(options.noResolveJson, isTrue); + }); + }); + + group('ReplaySubject', () { + test('replays the latest value to late subscribers', () async { + final subject = ReplaySubject(); + subject.add(1); + subject.add(2); + expect(await subject.stream.first, 2); + await subject.close(); + }); + + test('replays the latest error to late subscribers', () { + final subject = ReplaySubject(sync: true); + subject.addError(StateError('boom')); + expect(subject.stream.first, throwsStateError); + }); + + test('async subject replays the latest error to late subscribers', () { + final subject = ReplaySubject(); + subject.addError(StateError('boom')); + expect(subject.stream.first, throwsStateError); + }); + + test('sync subject replays the latest value to late subscribers', () async { + final subject = ReplaySubject(sync: true); + subject.add(7); + expect(await subject.stream.first, 7); + await subject.close(); + }); + + test('invokes onListen and onCancel hooks assigned via setters', () async { + var listened = false; + var cancelled = false; + final subject = ReplaySubject(); + subject.onListen = () => listened = true; + subject.onCancel = () => cancelled = true; + subject.onPause = () {}; + subject.onResume = () {}; + + final sub = subject.stream.listen((_) {}); + await Future.delayed(Duration.zero); + expect(listened, isTrue); + await sub.cancel(); + await Future.delayed(Duration.zero); + expect(cancelled, isTrue); + await subject.close(); + }); + + test('sync subject delivers synchronously', () { + final subject = ReplaySubject(sync: true); + final received = []; + subject.stream.listen(received.add); + subject.add(42); + expect(received, [42]); + }); + + test('invokes onListen and onCancel hooks', () async { + var listened = false; + var cancelled = false; + final subject = ReplaySubject( + onListen: () => listened = true, + onCancel: () => cancelled = true, + ); + final sub = subject.stream.listen((_) {}); + await Future.delayed(Duration.zero); + expect(listened, isTrue); + await sub.cancel(); + await Future.delayed(Duration.zero); + expect(cancelled, isTrue); + await subject.close(); + }); + + test('forwards an added stream', () async { + final subject = ReplaySubject(); + final events = []; + final sub = subject.stream.listen(events.add); + await subject.addStream(Stream.fromIterable([1, 2, 3])); + await Future.delayed(Duration.zero); + expect(events, [1, 2, 3]); + expect(subject.isClosed, isFalse); + await sub.cancel(); + await subject.close(); + }); + }); +} diff --git a/packages/supabase_flutter/CHANGELOG.md b/packages/supabase_flutter/CHANGELOG.md index 553691377..e2d72f1b1 100644 --- a/packages/supabase_flutter/CHANGELOG.md +++ b/packages/supabase_flutter/CHANGELOG.md @@ -1,3 +1,17 @@ +## 2.17.0 + + - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360)) + - **FIX**(realtime_client): faster channel rejoin on web ([#1471](https://github.com/supabase/supabase-flutter/issues/1471)). ([bc06a310](https://github.com/supabase/supabase-flutter/commit/bc06a310cf09bdba87668695b48bc8b8f353c495)) + - **FEAT**(auth): support friendlyName and user.name fallback for WebAuthn passkey enrollment ([#1603](https://github.com/supabase/supabase-flutter/issues/1603)). ([558b0346](https://github.com/supabase/supabase-flutter/commit/558b03461441d340b1656606d1086382481611f8)) + - **FEAT**(client): add opt-in trace context propagation headers ([#1564](https://github.com/supabase/supabase-flutter/issues/1564)). ([85780e60](https://github.com/supabase/supabase-flutter/commit/85780e607a00904870fe7f68dbd45ee5797256b6)) + - **FEAT**(client): add session-URL-detection predicate and persistSession flag ([#1558](https://github.com/supabase/supabase-flutter/issues/1558)). ([c8b02ed0](https://github.com/supabase/supabase-flutter/commit/c8b02ed062d78145671eecc2504875c19377a221)) + - **DOCS**(auth): document getOAuthSignInUrl for URL-without-launch, mark oauth parity implemented ([#1548](https://github.com/supabase/supabase-flutter/issues/1548)). ([1a1c95bf](https://github.com/supabase/supabase-flutter/commit/1a1c95bf178a0af8301d54366ebbf999f911d8f0)) + +## 2.16.0 + + - **FIX**(supabase_flutter): drop passkeys requireResidentKey workaround, bump dependency ([#1521](https://github.com/supabase/supabase-flutter/issues/1521)). ([7907c6cb](https://github.com/supabase/supabase-flutter/commit/7907c6cbc3dc31500a5cf85a81b97efc04f3006c)) + - **FEAT**(supabase_flutter): default debug logging off under flutter test ([#1530](https://github.com/supabase/supabase-flutter/issues/1530)). ([941ee804](https://github.com/supabase/supabase-flutter/commit/941ee80498f7f6d1351351aca449b8a5bc2655da)) + ## 2.15.4 - **FIX**(supabase_flutter): preserve hash routes and repeated query keys when clearing auth params ([#1511](https://github.com/supabase/supabase-flutter/issues/1511)). ([4fca4126](https://github.com/supabase/supabase-flutter/commit/4fca4126fe3649f218b296b0e40bb4bc2de87890)) diff --git a/packages/supabase_flutter/README.md b/packages/supabase_flutter/README.md index f06d04035..ba7670aae 100644 --- a/packages/supabase_flutter/README.md +++ b/packages/supabase_flutter/README.md @@ -269,9 +269,9 @@ Future _facebookSignInWeb() async { ### OAuth login -The `signInWithIdToken()` method supports providers like Apple, Google, Facebook, Kakao, and Keycloak. For other providers, you need to use the `signInWithOAuth()` method to perform OAuth login. This will open the web browser to perform the OAuth login. +The `signInWithIdToken()` method supports providers like Apple, Google, Facebook, Kakao, and Keycloak. For other providers, you need to use the `signInWithOAuth()` method to perform OAuth login. On native platforms this opens a system web authentication session (`ASWebAuthenticationSession` on iOS and macOS, Custom Tabs on Android) that closes itself once the login completes. On web the current tab is redirected to the provider. -Use the `redirectTo` parameter to redirect the user to a deep link to bring the user back to the app. Learn more about setting up deep links in [Deep link config](#deep-link-config). +Use the `redirectTo` parameter to send the user back to the app after login. Its scheme is also the callback scheme the web authentication session listens for, so on native platforms you need to register it. See [OAuth native config](#oauth-native-config). ```dart // Perform web based OAuth login @@ -289,6 +289,37 @@ supabase.auth.onAuthStateChange.listen((data) { }); ``` +#### OAuth native config + +On native platforms `signInWithOAuth()`, `signInWithSSO()` and `linkIdentity()` run inside a system web authentication session that captures the redirect back to your app. The session listens for the scheme of your `redirectTo` URL, so that scheme has to be registered with the platform. + +**Android** + +Register the callback activity in `android/app/src/main/AndroidManifest.xml`, inside the `` tag, using the scheme of your `redirectTo` (here `io.supabase.flutter`): + +```xml + + + + + + + + +``` + +**iOS and macOS** + +No configuration is required for custom URL schemes. If you use an `https` `redirectTo` (universal link), it is passed through automatically; on iOS 17.4+ and macOS 14.4+ the host and path are taken from `redirectTo` to satisfy the universal link requirements. + +**Web** + +No configuration is required. The current tab is redirected to the provider and the session is restored when the browser returns to your app. + +If you only need OAuth, SSO and identity linking, this replaces the app links deep link setup below. You still need to set up deep links for magic links, email confirmation and password recovery, which arrive as real deep links from outside the app. + ### Passkeys > Passkeys are a BETA feature. Enable them for your project in the Supabase Dashboard under Authentication > Configuration > Passkeys before using these methods. @@ -475,7 +506,8 @@ You need to setup deep links if you want your native app to open when a user cli - Magic link login - Have `confirm email` enabled and are using email login - Resetting password for email login -- Calling `.signInWithOAuth()` method + +`.signInWithOAuth()`, `.signInWithSSO()` and `.linkIdentity()` no longer rely on these deep links. They run inside a system web authentication session that captures the redirect itself, see [OAuth native config](#oauth-native-config). \*Currently supabase_flutter supports deep links on Android, iOS, Web, MacOS and Windows. diff --git a/packages/supabase_flutter/example/analysis_options.yaml b/packages/supabase_flutter/example/analysis_options.yaml index 4b829ccc7..c121be8e3 100644 --- a/packages/supabase_flutter/example/analysis_options.yaml +++ b/packages/supabase_flutter/example/analysis_options.yaml @@ -1,35 +1,11 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule +include: package:supabase_lints/analysis_options_flutter.yaml analyzer: exclude: - lib/generated_plugin_registrant.dart -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options -formatter: - trailing_commas: preserve +dcm: + rules: + # This is a single-file demo app, so keeping every widget in one file is + # intentional. + - prefer-single-widget-per-file: false diff --git a/packages/supabase_flutter/example/lib/main.dart b/packages/supabase_flutter/example/lib/main.dart index 140e28846..5c80dbfa2 100644 --- a/packages/supabase_flutter/example/lib/main.dart +++ b/packages/supabase_flutter/example/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -30,17 +32,13 @@ class MyWidget extends StatefulWidget { class _MyWidgetState extends State { User? _user; + StreamSubscription? _authSubscription; + @override void initState() { super.initState(); - _getAuth(); - } - - void _getAuth() { - setState(() { - _user = Supabase.instance.client.auth.currentUser; - }); - Supabase.instance.client.auth.onAuthStateChange.listen( + _user = Supabase.instance.client.auth.currentUser; + _authSubscription = Supabase.instance.client.auth.onAuthStateChange.listen( (data) { setState(() { _user = data.session?.user; @@ -54,6 +52,12 @@ class _MyWidgetState extends State { ); } + @override + void dispose() { + unawaited(_authSubscription?.cancel()); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -84,6 +88,56 @@ class _LoginFormState extends State<_LoginForm> { super.dispose(); } + Future _signIn() async { + setState(() { + _loading = true; + }); + final scaffoldMessenger = ScaffoldMessenger.of(context); + try { + await Supabase.instance.client.auth.signInWithPassword( + email: _emailController.text, + password: _passwordController.text, + ); + } catch (e) { + scaffoldMessenger.showSnackBar( + const SnackBar( + content: Text('Login failed'), + backgroundColor: Colors.red, + ), + ); + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + + Future _signUp() async { + setState(() { + _loading = true; + }); + final scaffoldMessenger = ScaffoldMessenger.of(context); + try { + await Supabase.instance.client.auth.signUp( + email: _emailController.text, + password: _passwordController.text, + ); + } catch (e) { + scaffoldMessenger.showSnackBar( + const SnackBar( + content: Text('Signup failed'), + backgroundColor: Colors.red, + ), + ); + if (mounted) { + setState(() { + _loading = false; + }); + } + } + } + @override Widget build(BuildContext context) { return _loading @@ -104,60 +158,12 @@ class _LoginFormState extends State<_LoginForm> { ), const SizedBox(height: 16), ElevatedButton( - onPressed: () async { - setState(() { - _loading = true; - }); - final ScaffoldMessengerState scaffoldMessenger = - ScaffoldMessenger.of(context); - try { - final email = _emailController.text; - final password = _passwordController.text; - await Supabase.instance.client.auth.signInWithPassword( - email: email, - password: password, - ); - } catch (e) { - scaffoldMessenger.showSnackBar( - const SnackBar( - content: Text('Login failed'), - backgroundColor: Colors.red, - ), - ); - setState(() { - _loading = false; - }); - } - }, + onPressed: () => unawaited(_signIn()), child: const Text('Login'), ), const SizedBox(height: 16), TextButton( - onPressed: () async { - setState(() { - _loading = true; - }); - final ScaffoldMessengerState scaffoldMessenger = - ScaffoldMessenger.of(context); - try { - final email = _emailController.text; - final password = _passwordController.text; - await Supabase.instance.client.auth.signUp( - email: email, - password: password, - ); - } catch (e) { - scaffoldMessenger.showSnackBar( - const SnackBar( - content: Text('Signup failed'), - backgroundColor: Colors.red, - ), - ); - setState(() { - _loading = false; - }); - } - }, + onPressed: () => unawaited(_signUp()), child: const Text('Signup'), ), ], @@ -180,7 +186,7 @@ class _ProfileFormState extends State<_ProfileForm> { @override void initState() { super.initState(); - _loadProfile(); + unawaited(_loadProfile()); } @override @@ -191,17 +197,15 @@ class _ProfileFormState extends State<_ProfileForm> { } Future _loadProfile() async { - final ScaffoldMessengerState scaffoldMessenger = ScaffoldMessenger.of( - context, - ); + final scaffoldMessenger = ScaffoldMessenger.of(context); try { final userId = Supabase.instance.client.auth.currentUser!.id; - final data = (await Supabase.instance.client + final data = await Supabase.instance.client .from('profiles') .select() .match({'id': userId}) - .maybeSingle()); - if (data != null) { + .maybeSingle(); + if (data != null && mounted) { setState(() { _usernameController.text = data['username']; _websiteController.text = data['website']; @@ -215,9 +219,43 @@ class _ProfileFormState extends State<_ProfileForm> { ), ); } + if (mounted) { + setState(() { + _loading = false; + }); + } + } + + Future _saveProfile() async { setState(() { - _loading = false; + _loading = true; }); + final scaffoldMessenger = ScaffoldMessenger.of(context); + try { + final userId = Supabase.instance.client.auth.currentUser!.id; + await Supabase.instance.client.from('profiles').upsert({ + 'id': userId, + 'username': _usernameController.text, + 'website': _websiteController.text, + }); + scaffoldMessenger.showSnackBar( + const SnackBar( + content: Text('Saved profile'), + ), + ); + } catch (e) { + scaffoldMessenger.showSnackBar( + const SnackBar( + content: Text('Error saving profile'), + backgroundColor: Colors.red, + ), + ); + } + if (mounted) { + setState(() { + _loading = false; + }); + } } @override @@ -242,46 +280,14 @@ class _ProfileFormState extends State<_ProfileForm> { ), const SizedBox(height: 16), ElevatedButton( - onPressed: () async { - final ScaffoldMessengerState scaffoldMessenger = - ScaffoldMessenger.of(context); - try { - setState(() { - _loading = true; - }); - final userId = - Supabase.instance.client.auth.currentUser!.id; - final username = _usernameController.text; - final website = _websiteController.text; - await Supabase.instance.client.from('profiles').upsert({ - 'id': userId, - 'username': username, - 'website': website, - }); - if (mounted) { - scaffoldMessenger.showSnackBar( - const SnackBar( - content: Text('Saved profile'), - ), - ); - } - } catch (e) { - scaffoldMessenger.showSnackBar( - const SnackBar( - content: Text('Error saving profile'), - backgroundColor: Colors.red, - ), - ); - } - setState(() { - _loading = false; - }); - }, + onPressed: () => unawaited(_saveProfile()), child: const Text('Save'), ), const SizedBox(height: 16), TextButton( - onPressed: () => Supabase.instance.client.auth.signOut(), + onPressed: () => unawaited( + Supabase.instance.client.auth.signOut(), + ), child: const Text('Sign Out'), ), ], diff --git a/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc b/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc index 3792af4b6..c265fcdf8 100644 --- a/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc +++ b/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc @@ -6,14 +6,22 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin"); + desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_to_front_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowToFrontPlugin"); + window_to_front_plugin_register_with_registrar(window_to_front_registrar); } diff --git a/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake b/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake index 5d074230c..eda9ca919 100644 --- a/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake +++ b/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake @@ -3,11 +3,14 @@ # list(APPEND FLUTTER_PLUGIN_LIST + desktop_webview_window gtk url_launcher_linux + window_to_front ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift index a620c94b7..b9c63ab71 100644 --- a/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,17 @@ import FlutterMacOS import Foundation import app_links +import desktop_webview_window +import flutter_web_auth_2 import shared_preferences_foundation import url_launcher_macos +import window_to_front func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) + FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin")) } diff --git a/packages/supabase_flutter/example/pubspec.yaml b/packages/supabase_flutter/example/pubspec.yaml index 8a6c5c334..89c840f60 100644 --- a/packages/supabase_flutter/example/pubspec.yaml +++ b/packages/supabase_flutter/example/pubspec.yaml @@ -13,13 +13,13 @@ resolution: workspace dependencies: flutter: sdk: flutter - supabase_flutter: ^2.15.4 + supabase_flutter: ^2.17.0 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^4.0.0 + supabase_lints: ^0.1.1 flutter: uses-material-design: true diff --git a/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc b/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc index 785a046f9..e661cc0ed 100644 --- a/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,17 @@ #include "generated_plugin_registrant.h" #include +#include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + DesktopWebviewWindowPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowToFrontPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowToFrontPlugin")); } diff --git a/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake b/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake index 8f8ee4f22..3ca96d565 100644 --- a/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake +++ b/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake @@ -4,10 +4,13 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + desktop_webview_window url_launcher_windows + window_to_front ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/supabase_flutter/lib/src/constants.dart b/packages/supabase_flutter/lib/src/constants.dart index 98177dd07..8ea5c0649 100644 --- a/packages/supabase_flutter/lib/src/constants.dart +++ b/packages/supabase_flutter/lib/src/constants.dart @@ -1,17 +1,16 @@ +import 'package:supabase_common/supabase_common.dart'; import 'package:supabase_flutter/src/version.dart'; -import 'platform_stub.dart' if (dart.library.io) 'platform_io.dart'; - class Constants { static final Map defaultHeaders = Map.unmodifiable({ - 'X-Client-Info': [ - 'supabase-flutter/$version', - if (conditionalPlatform != null) 'platform=$conditionalPlatform', - if (conditionalPlatformVersion != null) - 'platform-version=${Uri.encodeFull(conditionalPlatformVersion!).replaceAll("%20", " ")}', - 'runtime=dart', - if (conditionalRuntimeVersion != null) - 'runtime-version=$conditionalRuntimeVersion', - ].join('; '), + 'X-Client-Info': buildClientInfoHeader( + 'supabase-flutter', + version, + platformInfo: PlatformInfo( + platform: conditionalPlatform, + platformVersion: conditionalPlatformVersion, + runtimeVersion: conditionalRuntimeVersion, + ), + ), }); } diff --git a/packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart b/packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart index 2c82a0f72..13932ab6a 100644 --- a/packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart +++ b/packages/supabase_flutter/lib/src/flutter_go_true_client_options.dart @@ -7,12 +7,34 @@ class FlutterAuthClientOptions extends AuthClientOptions { /// when a valid URI is detected. final bool detectSessionInUri; + /// An optional predicate that decides whether an incoming deep link should be + /// treated as an auth callback and exchanged for a session. + /// + /// When null, the default heuristic is used, which treats a link as an auth + /// callback if it carries any of the `access_token`, `code`, `error`, + /// `error_code`, or `error_description` parameters (in the query or the + /// fragment). + /// + /// Provide a custom predicate to disambiguate links when your app uses those + /// same parameters for other purposes, or to restrict detection to specific + /// redirect paths. + final bool Function(Uri uri)? detectSessionInUriPredicate; + + /// Whether to persist the session to [localStorage]. + /// + /// When false and no [localStorage] is provided, sessions are kept + /// in memory only and are not restored across app restarts. Supplying a + /// custom [localStorage] always takes precedence over this flag. + final bool persistSession; + const FlutterAuthClientOptions({ super.authFlowType, super.autoRefreshToken, super.pkceAsyncStorage, this.localStorage, this.detectSessionInUri = true, + this.detectSessionInUriPredicate, + this.persistSession = true, }); FlutterAuthClientOptions copyWith({ @@ -21,6 +43,8 @@ class FlutterAuthClientOptions extends AuthClientOptions { LocalStorage? localStorage, GotrueAsyncStorage? pkceAsyncStorage, bool? detectSessionInUri, + bool Function(Uri uri)? detectSessionInUriPredicate, + bool? persistSession, }) { return FlutterAuthClientOptions( authFlowType: authFlowType ?? this.authFlowType, @@ -28,6 +52,9 @@ class FlutterAuthClientOptions extends AuthClientOptions { localStorage: localStorage ?? this.localStorage, pkceAsyncStorage: pkceAsyncStorage ?? this.pkceAsyncStorage, detectSessionInUri: detectSessionInUri ?? this.detectSessionInUri, + detectSessionInUriPredicate: + detectSessionInUriPredicate ?? this.detectSessionInUriPredicate, + persistSession: persistSession ?? this.persistSession, ); } } diff --git a/packages/supabase_flutter/lib/src/oauth_redirect_stub.dart b/packages/supabase_flutter/lib/src/oauth_redirect_stub.dart new file mode 100644 index 000000000..d8945efc3 --- /dev/null +++ b/packages/supabase_flutter/lib/src/oauth_redirect_stub.dart @@ -0,0 +1,7 @@ +// coverage:ignore-file + +/// Navigates the current browser tab to [url]. +/// +/// Only meaningful on web. The stub throws because native and desktop platforms +/// go through the web auth session instead of a full-page redirect. +void redirectToUrl(String url) => throw UnimplementedError(); diff --git a/packages/supabase_flutter/lib/src/oauth_redirect_web.dart b/packages/supabase_flutter/lib/src/oauth_redirect_web.dart new file mode 100644 index 000000000..a6621d26f --- /dev/null +++ b/packages/supabase_flutter/lib/src/oauth_redirect_web.dart @@ -0,0 +1,5 @@ +import 'package:web/web.dart'; + +/// Navigates the current browser tab to [url], preserving the full-page +/// redirect behavior the OAuth flow relies on for web. +void redirectToUrl(String url) => window.location.assign(url); diff --git a/packages/supabase_flutter/lib/src/passkey/passkey_options_mapper.dart b/packages/supabase_flutter/lib/src/passkey/passkey_options_mapper.dart index 024c76662..446a961a8 100644 --- a/packages/supabase_flutter/lib/src/passkey/passkey_options_mapper.dart +++ b/packages/supabase_flutter/lib/src/passkey/passkey_options_mapper.dart @@ -54,7 +54,7 @@ AuthenticateRequestType passkeyAuthenticateRequestFromOptions( } List> _normalizeCredentials(List credentials) { - return credentials.whereType().map((credential) { + return credentials.whereType>().map((credential) { final normalized = Map.from(credential); final id = normalized['id']; diff --git a/packages/supabase_flutter/lib/src/supabase.dart b/packages/supabase_flutter/lib/src/supabase.dart index e6bf60870..0f4eeb091 100644 --- a/packages/supabase_flutter/lib/src/supabase.dart +++ b/packages/supabase_flutter/lib/src/supabase.dart @@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart'; import 'package:http/http.dart'; import 'package:logging/logging.dart'; import 'package:supabase/supabase.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'package:supabase_flutter/src/constants.dart'; import 'package:supabase_flutter/src/flutter_go_true_client_options.dart'; import 'package:supabase_flutter/src/local_storage.dart'; @@ -13,7 +14,6 @@ import 'package:supabase_flutter/src/supabase_auth.dart'; import 'hot_restart_cleanup_stub.dart' if (dart.library.js_interop) 'hot_restart_cleanup_web.dart'; -import 'platform_stub.dart' if (dart.library.io) 'platform_io.dart'; import 'version.dart'; final _log = Logger('supabase.supabase_flutter'); @@ -90,6 +90,8 @@ class Supabase { PostgrestClientOptions postgrestOptions = const PostgrestClientOptions(), StorageClientOptions storageOptions = const StorageClientOptions(), FlutterAuthClientOptions authOptions = const FlutterAuthClientOptions(), + TracePropagationOptions tracePropagationOptions = + const TracePropagationOptions(), Future Function()? accessToken, bool? debug, }) async { @@ -125,10 +127,12 @@ class Supabase { } if (authOptions.localStorage == null) { authOptions = authOptions.copyWith( - localStorage: SharedPreferencesLocalStorage( - persistSessionKey: - "sb-${Uri.parse(url).host.split(".").first}-auth-token", - ), + localStorage: authOptions.persistSession + ? SharedPreferencesLocalStorage( + persistSessionKey: + "sb-${Uri.parse(url).host.split(".").first}-auth-token", + ) + : const EmptyLocalStorage(), ); } _instance._init( @@ -140,6 +144,7 @@ class Supabase { authOptions: authOptions, postgrestOptions: postgrestOptions, storageOptions: storageOptions, + tracePropagationOptions: tracePropagationOptions, accessToken: accessToken, ); @@ -180,7 +185,7 @@ class Supabase { /// /// Only set when [Supabase.initialize] is called without a custom /// `accessToken`, since session recovery is skipped for third-party auth. - CancelableOperation? _restoreSessionCancellableOperation; + CancelableOperation? _restoreSessionCancellableOperation; // Listener for app lifecycle events to handle Realtime reconnection. AppLifecycleListener? _lifecycleListener; @@ -194,7 +199,7 @@ class Supabase { /// (e.g. abort a reconnect if the app went back to background). AppLifecycleState? _targetLifecycleState; - StreamSubscription? _logSubscription; + StreamSubscription? _logSubscription; /// Dispose the instance to free up resources. Future dispose() async { @@ -216,6 +221,7 @@ class Supabase { required PostgrestClientOptions postgrestOptions, required StorageClientOptions storageOptions, required AuthClientOptions authOptions, + required TracePropagationOptions tracePropagationOptions, required Future Function()? accessToken, }) { final headers = { @@ -231,6 +237,7 @@ class Supabase { postgrestOptions: postgrestOptions, storageOptions: storageOptions, authOptions: authOptions, + tracePropagationOptions: tracePropagationOptions, accessToken: accessToken, ); @@ -303,6 +310,7 @@ class Supabase { } } else { // paused or detached — disconnect the WebSocket if it is active. + // These states are not triggered on web if (realtime.isConnected || realtime.connState == SocketStates.connecting) { await realtime.disconnect(); diff --git a/packages/supabase_flutter/lib/src/supabase_auth.dart b/packages/supabase_flutter/lib/src/supabase_auth.dart index e93070f1c..3564f5873 100644 --- a/packages/supabase_flutter/lib/src/supabase_auth.dart +++ b/packages/supabase_flutter/lib/src/supabase_auth.dart @@ -7,13 +7,16 @@ import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb, TargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:logging/logging.dart'; +import 'package:supabase_common/supabase_common.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:url_launcher/url_launcher.dart'; + +import './oauth_redirect_stub.dart' + if (dart.library.js_interop) './oauth_redirect_web.dart'; import 'clear_auth_url_parameters_stub.dart' if (dart.library.js_interop) 'clear_auth_url_parameters_web.dart'; -import 'platform_stub.dart' if (dart.library.io) 'platform_io.dart'; /// Integrates Supabase Auth with the Flutter application lifecycle. /// @@ -60,6 +63,10 @@ class SupabaseAuth with WidgetsBindingObserver { /// Whether to automatically refresh the token late bool _autoRefreshToken; + /// Optional custom predicate to decide whether a deep link is an auth + /// callback. When null, [_defaultIsAuthCallbackDeeplink] is used. + bool Function(Uri uri)? _detectSessionInUriPredicate; + /// **ATTENTION**: `getInitialLink`/`getInitialUri` should be handled /// ONLY ONCE in your app's lifetime, since it is not meant to change /// throughout your app's life. @@ -85,6 +92,7 @@ class SupabaseAuth with WidgetsBindingObserver { }) async { _localStorage = options.localStorage!; _autoRefreshToken = options.autoRefreshToken; + _detectSessionInUriPredicate = options.detectSessionInUriPredicate; _authSubscription = Supabase.instance.client.auth.onAuthStateChange.listen( (data) { @@ -199,8 +207,22 @@ class SupabaseAuth with WidgetsBindingObserver { } } - /// If _authCallbackUrlHost not init, we treat all deep links as auth callback + /// Decides whether an incoming deep link should be exchanged for a session. + /// + /// Uses the custom predicate supplied via + /// [FlutterAuthClientOptions.detectSessionInUriPredicate] when available, + /// otherwise falls back to [_defaultIsAuthCallbackDeeplink]. bool _isAuthCallbackDeeplink(Uri uri) { + final predicate = _detectSessionInUriPredicate; + if (predicate != null) { + return predicate(uri); + } + return _defaultIsAuthCallbackDeeplink(uri); + } + + /// Default heuristic: treat a deep link as an auth callback when it carries + /// any of the auth-related parameters, in the query or the fragment. + bool _defaultIsAuthCallbackDeeplink(Uri uri) { final fragmentParameters = Uri.splitQueryString(uri.fragment); bool hasParameter(String key) => uri.queryParameters.containsKey(key) || @@ -320,6 +342,9 @@ extension GoTrueClientSignInProvider on GoTrueClient { /// OAuth sign-in has succeeded or not should be observed by setting a listener /// on [auth.onAuthStateChanged]. /// + /// To obtain the OAuth URL without launching a browser, use + /// [getOAuthSignInUrl] instead. + /// /// See also: /// /// * @@ -327,8 +352,8 @@ extension GoTrueClientSignInProvider on GoTrueClient { OAuthProvider provider, { String? redirectTo, String? scopes, - LaunchMode authScreenLaunchMode = LaunchMode.platformDefault, Map? queryParams, + bool preferEphemeral = false, }) async { final res = await getOAuthSignInUrl( provider: provider, @@ -336,34 +361,10 @@ extension GoTrueClientSignInProvider on GoTrueClient { scopes: scopes, queryParams: queryParams, ); - return _launchAuthUrl(res.url, provider, authScreenLaunchMode); - } - - /// Launches the [url] for an OAuth or identity-linking flow, forcing an - /// external browser for Google on Android. - Future _launchAuthUrl( - String url, - OAuthProvider provider, - LaunchMode authScreenLaunchMode, - ) { - final uri = Uri.parse(url); - - LaunchMode launchMode = authScreenLaunchMode; - - // `defaultTargetPlatform` reports the host OS even on web, so guard with - // `kIsWeb` to keep the external-browser workaround native-only. - final isAndroid = - !kIsWeb && defaultTargetPlatform == TargetPlatform.android; - - // Google login has to be performed on external browser window on Android - if (provider == OAuthProvider.google && isAndroid) { - launchMode = LaunchMode.externalApplication; - } - - return launchUrl( - uri, - mode: launchMode, - webOnlyWindowName: '_self', + return _authenticateWithRedirect( + Uri.parse(res.url), + redirectTo: redirectTo, + preferEphemeral: preferEphemeral, ); } @@ -380,8 +381,9 @@ extension GoTrueClientSignInProvider on GoTrueClient { /// If you have built an organization-specific login page, you can use the /// organization's SSO Identity Provider UUID directly instead. /// - /// Returns true if the URL was launched successfully, otherwise either returns - /// false or throws a [PlatformException] depending on the launchUrl failure. + /// On web the current tab is redirected to the identity provider. On every + /// other platform the flow runs inside a system web authentication session + /// and resolves once the session has been established. /// /// ```dart /// await supabase.auth.signInWithSSO( @@ -393,7 +395,7 @@ extension GoTrueClientSignInProvider on GoTrueClient { String? domain, String? redirectTo, String? captchaToken, - LaunchMode launchMode = LaunchMode.platformDefault, + bool preferEphemeral = false, }) async { final ssoUrl = await getSSOSignInUrl( providerId: providerId, @@ -401,10 +403,10 @@ extension GoTrueClientSignInProvider on GoTrueClient { redirectTo: redirectTo, captchaToken: captchaToken, ); - return await launchUrl( + return _authenticateWithRedirect( Uri.parse(ssoUrl), - mode: launchMode, - webOnlyWindowName: '_self', + redirectTo: redirectTo, + preferEphemeral: preferEphemeral, ); } @@ -419,8 +421,8 @@ extension GoTrueClientSignInProvider on GoTrueClient { OAuthProvider provider, { String? redirectTo, String? scopes, - LaunchMode authScreenLaunchMode = LaunchMode.platformDefault, Map? queryParams, + bool preferEphemeral = false, }) async { final res = await getLinkIdentityUrl( provider, @@ -428,6 +430,52 @@ extension GoTrueClientSignInProvider on GoTrueClient { scopes: scopes, queryParams: queryParams, ); - return _launchAuthUrl(res.url, provider, authScreenLaunchMode); + return _authenticateWithRedirect( + Uri.parse(res.url), + redirectTo: redirectTo, + preferEphemeral: preferEphemeral, + ); + } + + /// Runs an OAuth-style redirect flow for [url]. + /// + /// On web the current tab is redirected to [url] and the session is picked up + /// when the browser returns to the app. On every other platform the flow runs + /// inside a system web authentication session (`ASWebAuthenticationSession` on + /// Apple platforms, Custom Tabs on Android) which captures the redirect to + /// [redirectTo] and hands it back, so the session is established before this + /// returns. [preferEphemeral] requests an ephemeral session that does not + /// share cookies with the system browser (Apple platforms and Android only). + Future _authenticateWithRedirect( + Uri url, { + required String? redirectTo, + required bool preferEphemeral, + }) async { + if (kIsWeb) { + redirectToUrl(url.toString()); + return true; + } + + if (redirectTo == null) { + throw const AuthException( + 'redirectTo is required to capture the authentication callback on this ' + 'platform.', + ); + } + + final redirectUri = Uri.parse(redirectTo); + final isHttps = redirectUri.scheme == 'https'; + final result = await FlutterWebAuth2.authenticate( + url: url.toString(), + callbackUrlScheme: redirectUri.scheme, + options: FlutterWebAuth2Options( + preferEphemeral: preferEphemeral, + httpsHost: isHttps ? redirectUri.host : null, + httpsPath: isHttps ? redirectUri.path : null, + ), + ); + + await getSessionFromUrl(Uri.parse(result)); + return true; } } diff --git a/packages/supabase_flutter/lib/src/supabase_passkey.dart b/packages/supabase_flutter/lib/src/supabase_passkey.dart index 8aff06267..acceb6c53 100644 --- a/packages/supabase_flutter/lib/src/supabase_passkey.dart +++ b/packages/supabase_flutter/lib/src/supabase_passkey.dart @@ -34,12 +34,19 @@ extension GoTrueClientPasskey on GoTrueClient { /// Starts the registration with the Supabase server, calls [authenticator] to /// create a credential on the device, and verifies it with the server. /// + /// [friendlyName] is the account label the authenticator shows for the + /// passkey when the server does not provide one. See + /// [GoTruePasskeyApi.startRegistration]. + /// /// Requires a signed in (non-anonymous) user. Returns the newly registered /// [Passkey]. Future registerPasskey( - PasskeyAuthenticatorInterface authenticator, - ) async { - final registration = await passkey.startRegistration(); + PasskeyAuthenticatorInterface authenticator, { + String? friendlyName, + }) async { + final registration = await passkey.startRegistration( + friendlyName: friendlyName, + ); final response = await authenticator.register( passkeyRegisterRequestFromOptions(registration.options), ); diff --git a/packages/supabase_flutter/lib/src/version.dart b/packages/supabase_flutter/lib/src/version.dart index d6d7290ea..e77206e1c 100644 --- a/packages/supabase_flutter/lib/src/version.dart +++ b/packages/supabase_flutter/lib/src/version.dart @@ -1 +1 @@ -const version = '2.15.4'; +const version = '2.17.0'; diff --git a/packages/supabase_flutter/lib/supabase_flutter.dart b/packages/supabase_flutter/lib/supabase_flutter.dart index 1a9a5fabd..f0229e3de 100644 --- a/packages/supabase_flutter/lib/supabase_flutter.dart +++ b/packages/supabase_flutter/lib/supabase_flutter.dart @@ -2,7 +2,6 @@ library; export 'package:supabase/supabase.dart'; -export 'package:url_launcher/url_launcher.dart' show LaunchMode; export 'src/flutter_go_true_client_options.dart'; export 'src/local_storage.dart'; diff --git a/packages/supabase_flutter/pubspec.yaml b/packages/supabase_flutter/pubspec.yaml index 4c3800bb7..a251f1b1b 100644 --- a/packages/supabase_flutter/pubspec.yaml +++ b/packages/supabase_flutter/pubspec.yaml @@ -1,9 +1,16 @@ name: supabase_flutter description: Flutter integration for Supabase. This package makes it simple for developers to build secure and scalable products. -version: 2.15.4 +version: 2.17.0 homepage: 'https://supabase.com' repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/supabase_flutter' +issue_tracker: 'https://github.com/supabase/supabase-flutter/issues' documentation: 'https://supabase.com/docs/reference/dart/introduction' +topics: + - supabase + - flutter + - backend + - auth + - database environment: sdk: '>=3.9.0 <4.0.0' @@ -12,25 +19,28 @@ environment: resolution: workspace dependencies: - app_links: '>=6.2.0 <8.0.0' - async: ^2.11.0 + app_links: '>=6.4.1 <8.0.0' + async: ^2.12.0 flutter: sdk: flutter - http: ^1.2.2 - meta: ^1.7.0 + http: ^1.6.0 + meta: ^1.16.0 passkeys_platform_interface: ^2.8.0 - supabase: 2.13.4 - url_launcher: ^6.1.2 - shared_preferences: ^2.0.0 - logging: ^1.2.0 - web: '>=0.5.0 <2.0.0' + supabase: 2.15.0 + supabase_common: 0.1.1 + flutter_web_auth_2: ^5.0.0 + shared_preferences: ^2.5.5 + logging: ^1.3.0 + web: '>=1.0.0 <2.0.0' dev_dependencies: - dart_jsonwebtoken: ">=2.17.0 <4.0.0" + dart_jsonwebtoken: ">=3.0.0 <4.0.0" flutter_test: sdk: flutter - supabase_lints: ^0.1.0 - web_socket_channel: '>=2.3.0 <4.0.0' + supabase_lints: ^0.1.1 + flutter_web_auth_2_platform_interface: ^5.0.0 + plugin_platform_interface: ^2.0.0 + web_socket_channel: '>=3.0.0 <4.0.0' platforms: android: diff --git a/packages/supabase_flutter/test/auth_test.dart b/packages/supabase_flutter/test/auth_test.dart index 35485d7c4..73e918cd0 100644 --- a/packages/supabase_flutter/test/auth_test.dart +++ b/packages/supabase_flutter/test/auth_test.dart @@ -25,7 +25,7 @@ void main() { setUp(() async { try { await Supabase.instance.dispose(); - } catch (e) { + } catch (_) { // Ignore dispose errors } @@ -35,7 +35,7 @@ void main() { tearDown(() async { try { await Supabase.instance.dispose(); - } catch (e) { + } catch (_) { // Ignore dispose errors } }); diff --git a/packages/supabase_flutter/test/deep_link_test.dart b/packages/supabase_flutter/test/deep_link_test.dart index 3d740af8e..d5618b341 100644 --- a/packages/supabase_flutter/test/deep_link_test.dart +++ b/packages/supabase_flutter/test/deep_link_test.dart @@ -5,6 +5,7 @@ library; import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'widget_test_stubs.dart'; @@ -101,6 +102,155 @@ void main() { }); }); + group('Custom session-URL-detection predicate', () { + test( + 'predicate returning false suppresses detection of an otherwise valid ' + 'auth callback', + () async { + final pkceHttpClient = PkceHttpClient(); + + mockAppLink( + mockMethodChannel: false, + mockEventChannel: true, + initialLink: 'com.supabase://callback/?code=my-code-verifier', + ); + final pkceAsyncStorage = MockAsyncStorage(); + await pkceAsyncStorage.setItem( + key: 'supabase.auth.token-code-verifier', + value: 'raw-code-verifier', + ); + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + debug: false, + httpClient: pkceHttpClient, + authOptions: FlutterAuthClientOptions( + localStorage: const MockEmptyLocalStorage(), + pkceAsyncStorage: pkceAsyncStorage, + detectSessionInUriPredicate: (uri) => false, + ), + ); + + await Future.delayed(const Duration(milliseconds: 500)); + expect(pkceHttpClient.requestCount, 0); + }, + ); + + test( + 'predicate governs detection based on the incoming uri', + () async { + final pkceHttpClient = PkceHttpClient(); + final receivedUris = []; + + mockAppLink( + mockMethodChannel: false, + mockEventChannel: true, + initialLink: 'com.supabase://callback/?code=my-code-verifier', + ); + final pkceAsyncStorage = MockAsyncStorage(); + await pkceAsyncStorage.setItem( + key: 'supabase.auth.token-code-verifier', + value: 'raw-code-verifier', + ); + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + debug: false, + httpClient: pkceHttpClient, + authOptions: FlutterAuthClientOptions( + localStorage: const MockEmptyLocalStorage(), + pkceAsyncStorage: pkceAsyncStorage, + detectSessionInUriPredicate: (uri) { + receivedUris.add(uri); + return uri.queryParameters.containsKey('code'); + }, + ), + ); + + await Future.delayed(const Duration(milliseconds: 500)); + expect(receivedUris.single.queryParameters['code'], 'my-code-verifier'); + expect(pkceHttpClient.requestCount, 1); + expect(pkceHttpClient.lastRequestBody['auth_code'], 'my-code-verifier'); + }, + ); + }); + + group('persistSession flag', () { + // With url '', the default persist session key resolves to this value. + const persistSessionKey = 'sb--auth-token'; + + test( + 'persists the session to the default storage when persistSession is true', + () async { + SharedPreferences.setMockInitialValues({}); + final pkceHttpClient = PkceHttpClient(); + + mockAppLink( + mockMethodChannel: false, + mockEventChannel: true, + initialLink: 'com.supabase://callback/?code=my-code-verifier', + ); + final pkceAsyncStorage = MockAsyncStorage(); + await pkceAsyncStorage.setItem( + key: 'supabase.auth.token-code-verifier', + value: 'raw-code-verifier', + ); + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + debug: false, + httpClient: pkceHttpClient, + authOptions: FlutterAuthClientOptions( + pkceAsyncStorage: pkceAsyncStorage, + ), + ); + + await Supabase.instance.client.auth.onAuthStateChange + .firstWhere((state) => state.event == AuthChangeEvent.signedIn) + .timeout(const Duration(seconds: 5)); + + final preferences = await SharedPreferences.getInstance(); + expect(preferences.getString(persistSessionKey), isNotNull); + }, + ); + + test( + 'does not persist the session when persistSession is false', + () async { + SharedPreferences.setMockInitialValues({}); + final pkceHttpClient = PkceHttpClient(); + + mockAppLink( + mockMethodChannel: false, + mockEventChannel: true, + initialLink: 'com.supabase://callback/?code=my-code-verifier', + ); + final pkceAsyncStorage = MockAsyncStorage(); + await pkceAsyncStorage.setItem( + key: 'supabase.auth.token-code-verifier', + value: 'raw-code-verifier', + ); + await Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + debug: false, + httpClient: pkceHttpClient, + authOptions: FlutterAuthClientOptions( + pkceAsyncStorage: pkceAsyncStorage, + persistSession: false, + ), + ); + + await Supabase.instance.client.auth.onAuthStateChange + .firstWhere((state) => state.event == AuthChangeEvent.signedIn) + .timeout(const Duration(seconds: 5)); + + final preferences = await SharedPreferences.getInstance(); + expect(preferences.getString(persistSessionKey), isNull); + }, + ); + }); + group('Deep Link with error query parameter', () { late final Completer errorCompleter; diff --git a/packages/supabase_flutter/test/initialization_test.dart b/packages/supabase_flutter/test/initialization_test.dart index 36941a249..50b285663 100644 --- a/packages/supabase_flutter/test/initialization_test.dart +++ b/packages/supabase_flutter/test/initialization_test.dart @@ -24,31 +24,37 @@ void main() { tearDown(() async { try { await Supabase.instance.dispose(); - } catch (e) { + } catch (_) { // Ignore dispose errors } }); group('Basic initialization', () { - test('initialize successfully with default options', () async { - await Supabase.initialize( - url: supabaseUrl, - publishableKey: supabaseKey, - debug: false, + test('initialize successfully with default options', () { + expect( + Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + debug: false, + ), + completes, ); }); }); group('Custom storage initialization', () { - test('initialize successfully with custom localStorage', () async { + test('initialize successfully with custom localStorage', () { const localStorage = MockLocalStorage(); - await Supabase.initialize( - url: supabaseUrl, - publishableKey: supabaseKey, - debug: false, - authOptions: const FlutterAuthClientOptions( - localStorage: localStorage, + expect( + Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + debug: false, + authOptions: const FlutterAuthClientOptions( + localStorage: localStorage, + ), ), + completes, ); }); @@ -69,26 +75,32 @@ void main() { }); group('Auth options initialization', () { - test('initialize successfully with PKCE auth flow', () async { - await Supabase.initialize( - url: supabaseUrl, - publishableKey: supabaseKey, - debug: false, - authOptions: const FlutterAuthClientOptions( - authFlowType: AuthFlowType.pkce, + test('initialize successfully with PKCE auth flow', () { + expect( + Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + debug: false, + authOptions: const FlutterAuthClientOptions( + authFlowType: AuthFlowType.pkce, + ), ), + completes, ); }); }); group('Custom client initialization', () { - test('initialize successfully with custom HTTP client', () async { + test('initialize successfully with custom HTTP client', () { final httpClient = PkceHttpClient(); - await Supabase.initialize( - url: supabaseUrl, - publishableKey: supabaseKey, - debug: false, - httpClient: httpClient, + expect( + Supabase.initialize( + url: supabaseUrl, + publishableKey: supabaseKey, + debug: false, + httpClient: httpClient, + ), + completes, ); }); diff --git a/packages/supabase_flutter/test/lifecycle_test.dart b/packages/supabase_flutter/test/lifecycle_test.dart index 6ccada4d0..923b132ac 100644 --- a/packages/supabase_flutter/test/lifecycle_test.dart +++ b/packages/supabase_flutter/test/lifecycle_test.dart @@ -121,8 +121,8 @@ void main() { var previousCount = -1; while (readyCompleters.length != previousCount) { previousCount = readyCompleters.length; - for (final c in readyCompleters) { - if (!c.isCompleted) c.complete(); + for (final completer in readyCompleters) { + if (!completer.isCompleted) completer.complete(); } await pumpEventQueue(); } diff --git a/packages/supabase_flutter/test/oauth_test.dart b/packages/supabase_flutter/test/oauth_test.dart new file mode 100644 index 000000000..b70943873 --- /dev/null +++ b/packages/supabase_flutter/test/oauth_test.dart @@ -0,0 +1,91 @@ +@TestOn('!browser') + +/// Tests for the native OAuth flow that runs through the system web +/// authentication session. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'widget_test_stubs.dart'; + +void main() { + late FakeFlutterWebAuth2 fakeWebAuth; + late PkceHttpClient pkceHttpClient; + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + fakeWebAuth = FakeFlutterWebAuth2( + 'io.supabase.flutter://callback/?code=my-code-verifier', + ); + FlutterWebAuth2Platform.instance = fakeWebAuth; + + pkceHttpClient = PkceHttpClient(); + + await Supabase.initialize( + url: 'https://test.supabase.co', + publishableKey: '', + debug: false, + httpClient: pkceHttpClient, + authOptions: FlutterAuthClientOptions( + localStorage: MockEmptyLocalStorage(), + pkceAsyncStorage: MockAsyncStorage(), + ), + ); + }); + + tearDown(() async { + await Supabase.instance.dispose(); + }); + + test( + 'signInWithOAuth runs the web auth session and exchanges the returned code', + () async { + await Supabase.instance.client.auth.signInWithOAuth( + OAuthProvider.github, + redirectTo: 'io.supabase.flutter://callback', + ); + + // The authorize URL is opened in the web auth session, and the callback + // scheme is derived from redirectTo. + expect(fakeWebAuth.authenticatedUrl, contains('/auth/v1/authorize')); + expect(fakeWebAuth.authenticatedUrl, contains('provider=github')); + expect(fakeWebAuth.callbackUrlScheme, 'io.supabase.flutter'); + + // The code from the callback URL is exchanged for a session. + expect(pkceHttpClient.lastRequestBody['auth_code'], 'my-code-verifier'); + expect(Supabase.instance.client.auth.currentUser?.email, 'fake1@email.com'); + }); + + test('preferEphemeral is forwarded to the web auth session options', + () async { + await Supabase.instance.client.auth.signInWithOAuth( + OAuthProvider.github, + redirectTo: 'io.supabase.flutter://callback', + preferEphemeral: true, + ); + + expect(fakeWebAuth.options?['preferEphemeral'], true); + }); + + test('https redirectTo forwards host and path for universal links', () async { + await Supabase.instance.client.auth.signInWithOAuth( + OAuthProvider.github, + redirectTo: 'https://myapp.com/auth/callback', + ); + + expect(fakeWebAuth.callbackUrlScheme, 'https'); + expect(fakeWebAuth.options?['httpsHost'], 'myapp.com'); + expect(fakeWebAuth.options?['httpsPath'], '/auth/callback'); + }); + + test('signInWithOAuth without redirectTo throws on native platforms', + () async { + await expectLater( + Supabase.instance.client.auth.signInWithOAuth(OAuthProvider.github), + throwsA(isA()), + ); + }); +} diff --git a/packages/supabase_flutter/test/storage_test.dart b/packages/supabase_flutter/test/storage_test.dart index cf5133b2e..5b7dcab2b 100644 --- a/packages/supabase_flutter/test/storage_test.dart +++ b/packages/supabase_flutter/test/storage_test.dart @@ -41,7 +41,7 @@ void main() { test('accessToken returns null when no session exists', () async { final localStorage = await createFreshLocalStorage(); final result = await localStorage.accessToken(); - expect(result, null); + expect(result, isNull); }); test('accessToken returns session string when session exists', () async { @@ -72,7 +72,7 @@ void main() { // Then remove it await localStorage.removePersistedSession(); expect(await localStorage.hasAccessToken(), isFalse); - expect(await localStorage.accessToken(), null); + expect(await localStorage.accessToken(), isNull); }); }); @@ -92,14 +92,14 @@ void main() { test('setItem stores value for key', () async { await asyncStorage.setItem(key: testKey, value: testValue); - final prefs = await SharedPreferences.getInstance(); - final storedValue = prefs.getString(testKey); + final preferences = await SharedPreferences.getInstance(); + final storedValue = preferences.getString(testKey); expect(storedValue, testValue); }); test('getItem returns null when no value exists', () async { final result = await asyncStorage.getItem(key: 'non_existent_key'); - expect(result, null); + expect(result, isNull); }); test('getItem returns value when value exists', () async { @@ -115,7 +115,7 @@ void main() { // Then remove it await asyncStorage.removeItem(key: testKey); - expect(await asyncStorage.getItem(key: testKey), null); + expect(await asyncStorage.getItem(key: testKey), isNull); }); }); }); diff --git a/packages/supabase_flutter/test/supabase_flutter_test.dart b/packages/supabase_flutter/test/supabase_flutter_test.dart index e03fb0e5f..dbb958d6e 100644 --- a/packages/supabase_flutter/test/supabase_flutter_test.dart +++ b/packages/supabase_flutter/test/supabase_flutter_test.dart @@ -138,7 +138,7 @@ void main() { // Get the token (should be null) final token = await localStorage.accessToken(); - expect(token, null); + expect(token, isNull); // Try to persist a session await localStorage.persistSession('test-session-data'); @@ -149,7 +149,7 @@ void main() { // Get the token after persisting (should still be null) final tokenAfterPersist = await localStorage.accessToken(); - expect(tokenAfterPersist, null); + expect(tokenAfterPersist, isNull); // Try to remove the session await localStorage.removePersistedSession(); diff --git a/packages/supabase_flutter/test/widget_test_stubs.dart b/packages/supabase_flutter/test/widget_test_stubs.dart index 298b40e79..050c463f9 100644 --- a/packages/supabase_flutter/test/widget_test_stubs.dart +++ b/packages/supabase_flutter/test/widget_test_stubs.dart @@ -5,11 +5,43 @@ import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart'; import 'package:http/http.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'utils.dart'; +/// Fake [FlutterWebAuth2Platform] that records the authentication request and +/// returns a preconfigured callback URL, standing in for the system web auth +/// session in tests. +class FakeFlutterWebAuth2 extends FlutterWebAuth2Platform + with MockPlatformInterfaceMixin { + FakeFlutterWebAuth2(this.callbackUrl); + + /// The callback URL the fake session resolves with. + final String callbackUrl; + + String? authenticatedUrl; + String? callbackUrlScheme; + Map? options; + + @override + Future authenticate({ + required String url, + required String callbackUrlScheme, + required Map options, + }) async { + authenticatedUrl = url; + this.callbackUrlScheme = callbackUrlScheme; + this.options = options; + return callbackUrl; + } + + @override + Future clearAllDanglingCalls() async {} +} + class MockWidget extends StatefulWidget { const MockWidget({super.key}); diff --git a/packages/supabase_lints/CHANGELOG.md b/packages/supabase_lints/CHANGELOG.md index 8904bb6cd..1224c67f6 100644 --- a/packages/supabase_lints/CHANGELOG.md +++ b/packages/supabase_lints/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.1.1 + + - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65)) + ## 0.1.0 - Initial release of the shared Supabase lint configuration. diff --git a/packages/supabase_lints/README.md b/packages/supabase_lints/README.md index 334b43662..b74d2d331 100644 --- a/packages/supabase_lints/README.md +++ b/packages/supabase_lints/README.md @@ -1,7 +1,24 @@ -# supabase_lints +
+

+ + Supabase Logo + -Shared analyzer and [DCM](https://dcm.dev) lint configuration for the Supabase -Flutter packages. It is meant for internal use across this monorepo. +

supabase_lints

+ +

+ Shared analyzer and DCM lint configuration for the Supabase Flutter packages. +

+

+ +
+ +[![pub package](https://img.shields.io/pub/v/supabase_lints.svg)](https://pub.dev/packages/supabase_lints) +[![pub test](https://github.com/supabase/supabase-flutter/workflows/Test/badge.svg)](https://github.com/supabase/supabase-flutter/actions?query=workflow%3ATest) + +
+ +This package is meant for internal use across this monorepo. ## Usage @@ -29,3 +46,7 @@ include: package:supabase_lints/analysis_options_flutter.yaml Both configurations enable the DCM `recommended` preset. Rules that the existing codebase does not yet pass are disabled in `analysis_options.yaml` and are re-enabled one by one as the violations get fixed. + +## License + +This repo is licensed under MIT. diff --git a/packages/supabase_lints/lib/analysis_options.yaml b/packages/supabase_lints/lib/analysis_options.yaml index 927147055..730e8cc42 100644 --- a/packages/supabase_lints/lib/analysis_options.yaml +++ b/packages/supabase_lints/lib/analysis_options.yaml @@ -6,10 +6,16 @@ # so new code is not held to a rule the rest of the codebase does not yet pass. include: package:lints/recommended.yaml +analyzer: + language: + strict-raw-types: true + linter: rules: unawaited_futures: true discarded_futures: true + use_enums: true + unnecessary_breaks: true formatter: trailing_commas: preserve @@ -18,6 +24,7 @@ dcm: extends: - recommended rules: + prefer-switch-expression: true # Keep web and WASM support: dart:html / dart:js / dart:js_util work on the # JS web target but break WASM, and dart:io / dart:ffi break both. The web # interop libraries below are replaced by dart:js_interop + package:web, and @@ -61,3 +68,4 @@ dcm: prefer-returning-shorthands: false # needs dot shorthands (experimental) prefer-shorthands-with-enums: false # needs dot shorthands (experimental) prefer-shorthands-with-static-fields: false # needs dot shorthands (experimental) + prefer-shorthands-with-constructors: false # needs dot shorthands (experimental) diff --git a/packages/supabase_lints/pubspec.yaml b/packages/supabase_lints/pubspec.yaml index e8a9fd114..3eb9b0a82 100644 --- a/packages/supabase_lints/pubspec.yaml +++ b/packages/supabase_lints/pubspec.yaml @@ -1,8 +1,13 @@ name: supabase_lints description: Shared analyzer and DCM lint configuration for the Supabase Flutter packages. -version: 0.1.0 +version: 0.1.1 homepage: "https://supabase.com" repository: "https://github.com/supabase/supabase-flutter/tree/main/packages/supabase_lints" +issue_tracker: "https://github.com/supabase/supabase-flutter/issues" +topics: + - lints + - analysis + - supabase environment: sdk: ">=3.9.0 <4.0.0" @@ -10,5 +15,5 @@ environment: resolution: workspace dependencies: - lints: ^4.0.0 - flutter_lints: ^4.0.0 + lints: ^6.0.0 + flutter_lints: ^6.0.0 diff --git a/packages/yet_another_json_isolate/README.md b/packages/yet_another_json_isolate/README.md index 45e9f2eac..1ab2c6554 100644 --- a/packages/yet_another_json_isolate/README.md +++ b/packages/yet_another_json_isolate/README.md @@ -1,6 +1,22 @@ -# Yet Another Json Isolate +
+

+ + Supabase Logo + -Dart package for simple JSON parsing using isolates, because the world can never have enough JSON parsers. +

yet_another_json_isolate

+ +

+ Simplify and improve JSON parsing in isolates by keeping one isolate running per instance. +

+

+ +
+ +[![pub package](https://img.shields.io/pub/v/yet_another_json_isolate.svg)](https://pub.dev/packages/yet_another_json_isolate) +[![pub test](https://github.com/supabase/supabase-flutter/workflows/Test/badge.svg)](https://github.com/supabase/supabase-flutter/actions?query=workflow%3ATest) + +
## Usage @@ -16,4 +32,8 @@ final json = await isolate.decode(responseBody); // dispose when no longer needed isolate.dispose(); -``` \ No newline at end of file +``` + +## License + +This repo is licensed under MIT. diff --git a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart index b3237748e..b649d7196 100644 --- a/packages/yet_another_json_isolate/lib/src/_isolates_io.dart +++ b/packages/yet_another_json_isolate/lib/src/_isolates_io.dart @@ -69,7 +69,7 @@ class YAJsonIsolate { return _handleRes(await _events.next); } - Future _handleRes(List response) async { + Future _handleRes(List response) async { final int type = response.length; assert(1 <= type && type <= 3); diff --git a/packages/yet_another_json_isolate/pubspec.yaml b/packages/yet_another_json_isolate/pubspec.yaml index 2a85d2b61..b75c1c92d 100644 --- a/packages/yet_another_json_isolate/pubspec.yaml +++ b/packages/yet_another_json_isolate/pubspec.yaml @@ -1,7 +1,14 @@ name: yet_another_json_isolate description: Package to simplify and improve JSON parsing in isolates by keeping one isolate running per instance. version: 2.1.1 -homepage: https://github.com/supabase-community/json-isolate-dart +homepage: 'https://supabase.com' +repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/yet_another_json_isolate' +issue_tracker: 'https://github.com/supabase/supabase-flutter/issues' +topics: + - json + - isolate + - parsing + - async environment: sdk: '>=3.9.0 <4.0.0' @@ -9,8 +16,8 @@ environment: resolution: workspace dependencies: - async: ^2.8.0 + async: ^2.12.0 dev_dependencies: - supabase_lints: ^0.1.0 - test: ^1.16.0 + supabase_lints: ^0.1.1 + test: ^1.25.0 diff --git a/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart b/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart new file mode 100644 index 000000000..8c47254fb --- /dev/null +++ b/packages/yet_another_json_isolate/test/yet_another_json_isolate_io_test.dart @@ -0,0 +1,21 @@ +@TestOn('vm') +library; + +import 'package:test/test.dart'; +import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; + +void main() { + group('io implementation', () { + test('throws when initialize is called twice', () async { + final isolate = YAJsonIsolate(); + await isolate.initialize(); + addTearDown(isolate.dispose); + expect(isolate.initialize(), throwsA(isA())); + }); + + test('exposes the provided debug name', () { + final isolate = YAJsonIsolate(debugName: 'my-isolate'); + expect(isolate.debugName, 'my-isolate'); + }); + }); +} diff --git a/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart b/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart index 7529d8930..d0945c541 100644 --- a/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart +++ b/packages/yet_another_json_isolate/test/yet_another_json_isolate_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:test/test.dart'; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; @@ -6,7 +8,8 @@ const _jsonMap = {'a': 1, 'b': 2}; void main() { late YAJsonIsolate isolate; - group('Initialize isolate manually', () { + + group('with manual initialize', () { setUp(() async { isolate = YAJsonIsolate(); await isolate.initialize(); @@ -16,18 +19,94 @@ void main() { await isolate.dispose(); }); - test('decode', () async { - final json = await isolate.decode(_jsonString); - expect(json, _jsonMap); + test('decodes a JSON object', () async { + expect(await isolate.decode(_jsonString), _jsonMap); + }); + + test('encodes a JSON object', () async { + expect(await isolate.encode(_jsonMap), _jsonString); + }); + }); + + group('without manual initialize', () { + setUp(() { + isolate = YAJsonIsolate(); + }); + + tearDown(() async { + await isolate.dispose(); + }); + + test('decodes lazily on first call', () async { + expect(await isolate.decode(_jsonString), _jsonMap); + }); + + test('encodes lazily on first call', () async { + expect(await isolate.encode(_jsonMap), _jsonString); + }); + }); + + group('data types', () { + setUp(() { + isolate = YAJsonIsolate(); + }); + + tearDown(() async { + await isolate.dispose(); + }); + + test('decodes a top level list', () async { + expect(await isolate.decode('[1,2,3]'), [1, 2, 3]); + }); + + test('encodes a top level list', () async { + expect(await isolate.encode([1, 2, 3]), '[1,2,3]'); + }); + + test('handles nested structures', () async { + const nested = { + 'list': [ + 1, + {'nested': true}, + ], + 'map': {'inner': null}, + }; + final encoded = await isolate.encode(nested); + expect(await isolate.decode(encoded), nested); }); - test('encode', () async { - final str = await isolate.encode(_jsonMap); - expect(str, _jsonString); + test('handles ints, doubles, booleans, null and strings', () async { + const value = { + 'int': 1, + 'double': 1.5, + 'boolTrue': true, + 'boolFalse': false, + 'null': null, + 'string': 'hello', + }; + final encoded = await isolate.encode(value); + expect(await isolate.decode(encoded), value); + }); + + test('preserves unicode characters', () async { + const value = {'emoji': '🚀', 'text': 'Grüße'}; + final encoded = await isolate.encode(value); + expect(await isolate.decode(encoded), value); + }); + + test('decodes a bare JSON primitive', () async { + expect(await isolate.decode('42'), 42); + expect(await isolate.decode('"text"'), 'text'); + expect(await isolate.decode('null'), isNull); + }); + + test('round trips an encoded value back to the original', () async { + final encoded = await isolate.encode(_jsonMap); + expect(await isolate.decode(encoded), _jsonMap); }); }); - group('Do not initialize isolate manually ', () { + group('error handling', () { setUp(() { isolate = YAJsonIsolate(); }); @@ -36,14 +115,100 @@ void main() { await isolate.dispose(); }); - test('decode', () async { - final json = await isolate.decode(_jsonString); - expect(json, _jsonMap); + test('throws FormatException for invalid JSON', () async { + await expectLater( + isolate.decode('{not valid json'), + throwsFormatException, + ); + }); + + test('throws for a non encodable object', () async { + await expectLater( + isolate.encode(DateTime.now()), + throwsA(isA()), + ); + }); + + test('stays usable after a decode error', () async { + await expectLater(isolate.decode('{bad'), throwsFormatException); + expect(await isolate.decode(_jsonString), _jsonMap); + }); + + test('stays usable after an encode error', () async { + await expectLater( + isolate.encode(DateTime.now()), + throwsA(isA()), + ); + expect(await isolate.encode(_jsonMap), _jsonString); + }); + }); + + group('concurrency and ordering', () { + setUp(() async { + isolate = YAJsonIsolate(); + await isolate.initialize(); + }); + + tearDown(() async { + await isolate.dispose(); + }); + + test('resolves concurrent decodes to the correct results', () async { + final results = await Future.wait([ + isolate.decode('1'), + isolate.decode('2'), + isolate.decode('3'), + isolate.decode('[4]'), + ]); + expect(results, [ + 1, + 2, + 3, + [4], + ]); }); - test('encode', () async { - final str = await isolate.encode(_jsonMap); - expect(str, _jsonString); + test('resolves interleaved encodes and decodes', () async { + final results = await Future.wait([ + isolate.encode({'a': 1}), + isolate.decode('[1,2]'), + isolate.encode([true, false]), + ]); + expect(results, [ + '{"a":1}', + [1, 2], + '[true,false]', + ]); + }); + + test('handles many sequential operations', () async { + for (var i = 0; i < 50; i++) { + expect(await isolate.decode('$i'), i); + } + }); + }); + + group('lifecycle', () { + test('dispose can be awaited on a lazily initialized isolate', () async { + final lazyIsolate = YAJsonIsolate(); + await lazyIsolate.decode(_jsonString); + await expectLater(lazyIsolate.dispose(), completes); + }); + + test('two isolates operate independently', () async { + final first = YAJsonIsolate(); + final second = YAJsonIsolate(); + addTearDown(first.dispose); + addTearDown(second.dispose); + + final results = await Future.wait([ + first.decode('[1]'), + second.decode('[2]'), + ]); + expect(results, [ + [1], + [2], + ]); }); }); } diff --git a/pubspec.lock b/pubspec.lock index 986b404d0..b37be655f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" buffer: dependency: transitive description: @@ -117,34 +117,34 @@ packages: dependency: transitive description: name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + sha256: f2c223156a26eea323e6244b85141d76413a80aeee9fe0b380773789fabaf8ae url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.1" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: fd754058c342243718d5171a95f352cfc9fcf0cba8cfa26df67cb13a5836db78 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.2" build_runner: dependency: transitive description: name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.15.0" + version: "2.15.1" build_web_compilers: dependency: transitive description: @@ -181,10 +181,10 @@ packages: dependency: transitive description: name: charcode - sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -205,10 +205,10 @@ packages: dependency: transitive description: name: cli_launcher - sha256: "35cf15a3ffaeb9c11849eaa0afba761bb76dceb42d050532bfd3e1299c9748cd" + sha256: "96883f87648524292e24e2cc6a369fbdf64883473fe3e3ddadd3d857bf16a484" url: "https://pub.dev" source: hosted - version: "0.3.3+1" + version: "0.3.3+2" cli_util: dependency: transitive description: @@ -225,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -265,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" dart_jsonwebtoken: dependency: transitive description: @@ -281,22 +297,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.8" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957 + url: "https://pub.dev" + source: hosted + version: "0.3.0" device_info_plus: dependency: transitive description: name: device_info_plus - sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "13.2.0" + version: "12.4.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "7.0.3" dotenv: dependency: transitive description: @@ -321,14 +345,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - ffi_leak_tracker: - dependency: transitive - description: - name: ffi_leak_tracker - sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.dev" - source: hosted - version: "0.1.2" file: dependency: transitive description: @@ -350,19 +366,40 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: transitive description: name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.0.0" flutter_test: dependency: transitive description: flutter source: sdk version: "0.0.0" + flutter_web_auth_2: + dependency: transitive + description: + name: flutter_web_auth_2 + sha256: "8f9303471dcd96670878c9b7c0c4e14c37595b2add67465f6a868f17a5872dfc" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab + url: "https://pub.dev" + source: hosted + version: "5.0.0" flutter_web_plugins: dependency: transitive description: flutter @@ -376,6 +413,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -400,6 +442,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http: dependency: transitive description: @@ -420,34 +470,47 @@ packages: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" + integration_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" - json_annotation: + version: "1.0.5" + jni: dependency: transitive description: - name: json_annotation - sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f url: "https://pub.dev" source: hosted - version: "4.12.0" - jwt_decode: + version: "1.0.0" + jni_flutter: dependency: transitive description: - name: jwt_decode - sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "1.0.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -476,10 +539,10 @@ packages: dependency: transitive description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.1.0" logging: dependency: transitive description: @@ -492,10 +555,10 @@ packages: dependency: transitive description: name: mason_logger - sha256: "4f053e4e7e1716002b72cad133d81612be3550d40ed9d43c1878c9b03ba93069" + sha256: "1d46102c6f299c0df7fe986dd3dd3271d57c2ec7c00ae590660b7c3018810048" url: "https://pub.dev" source: hosted - version: "0.3.4" + version: "0.3.5" matcher: dependency: transitive description: @@ -516,10 +579,10 @@ packages: dependency: "direct dev" description: name: melos - sha256: "96f91b87dee9f9e5ad2bb6545cb0d7fb75fd43315fba0b9a0e8f37b2238f3078" + sha256: cc3b029d507d0edc5b80728cbba893715cb7802e1e12b7eea8dca386d49bf7fb url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "8.2.1" meta: dependency: transitive description: @@ -548,10 +611,10 @@ packages: dependency: transitive description: name: mustache_template - sha256: a46e26f91445bfb0b60519be280555b06792460b27b19e2b19ad5b9740df5d1c + sha256: c689b4d59176f8c7a2860aab765d205916aa5b06cfafff7613bfcc42fa996143 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.5" node_preamble: dependency: transitive description: @@ -560,6 +623,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" otp: dependency: transitive description: @@ -580,18 +651,18 @@ packages: dependency: transitive description: name: package_info_plus - sha256: f5c435dc0e0d461e5b32471a870f769b6a1cc46930637efe24fbc535314e78ad + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" url: "https://pub.dev" source: hosted - version: "10.2.0" + version: "9.0.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "3.2.1" passkeys: dependency: transitive description: @@ -656,6 +727,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -716,10 +811,10 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" posix: dependency: transitive description: @@ -732,10 +827,10 @@ packages: dependency: transitive description: name: postgres - sha256: "83ba7afb0e778cc1f373e514c6110d323b81dfbaced774d3a7f4a0e21536eb45" + sha256: "123de5cbadc56a7e8d9fa485c780b6b56940b4081f4c74f3a5578682757c299b" url: "https://pub.dev" source: hosted - version: "3.4.8" + version: "3.5.12" process: dependency: transitive description: @@ -784,38 +879,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - retry: - dependency: transitive - description: - name: retry - sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" - url: "https://pub.dev" - source: hosted - version: "3.1.2" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" - sasl_scram: - dependency: transitive - description: - name: sasl_scram - sha256: "5c27fd6058d53075c032539ba3cc7fa95006bb1d51a0db63a81b05756c265a83" - url: "https://pub.dev" - source: hosted - version: "0.1.2" - saslprep: + record_use: dependency: transitive description: - name: saslprep - sha256: "3d421d10be9513bf4459c17c5e70e7b8bc718c9fc5ad4ba5eb4f5fd27396f740" + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "0.6.0" scratch_space: dependency: transitive description: @@ -884,10 +955,10 @@ packages: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_packages_handler: dependency: transitive description: @@ -937,10 +1008,10 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -973,14 +1044,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test: dependency: transitive description: @@ -1017,10 +1096,10 @@ packages: dependency: transitive description: name: typed_data - sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.4.0" ua_client_hints: dependency: transitive description: @@ -1029,14 +1108,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.7.0" - unorm_dart: - dependency: transitive - description: - name: unorm_dart - sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" - url: "https://pub.dev" - source: hosted - version: "0.3.2" url_launcher: dependency: transitive description: @@ -1157,6 +1228,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: @@ -1169,18 +1248,26 @@ packages: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "5.15.0" win32_registry: dependency: transitive description: name: win32_registry - sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "2.1.0" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241" + url: "https://pub.dev" + source: hosted + version: "0.0.4" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index caf0c52ec..f20e3e8e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,13 +15,17 @@ workspace: - packages/supabase/example - packages/supabase_flutter - packages/supabase_flutter/example + - packages/supabase_common - packages/supabase_lints - packages/yet_another_json_isolate + - examples/database_crud + - examples/edge_functions - examples/launcher - examples/passkeys + - examples/storage_transforms dev_dependencies: - melos: ^8.1.0 + melos: ^8.2.1 melos: command: @@ -38,10 +42,10 @@ melos: description: Updates the version.dart file for each package except yet_another_json_isolate run: | for d in packages/*/ ; do + [ -f "${d}lib/src/version.dart" ] || continue version=$(awk '/version:/ { pos=match($2, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($2, pos); }' $d/pubspec.yaml) echo "const version = '$version';" > $d/lib/src/version.dart done - rm packages/yet_another_json_isolate/lib/src/version.dart git add packages/*/lib/src/version.dart diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 3b6455d5d..385ec1c74 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -10,6 +10,9 @@ # - partially_implemented present but with a behavioral difference or gap vs # supabase-js; a note describes the difference. # - not_implemented not available. +# - not_applicable does not map onto Dart/Flutter; the only faithful +# implementation would add no real capability. A note +# explains why. # # Verified against the source in this repository (packages/gotrue, postgrest, # storage_client, realtime_client, functions_client, supabase, supabase_flutter). @@ -19,46 +22,101 @@ sdk: flutter features: # auth — sign in / sign up - auth.sign_in.sign_up: implemented + auth.sign_in.sign_up: + status: implemented + symbols: + - GoTrueClient.signUp auth.sign_in.sign_in_with_password: - status: partially_implemented - note: "Weak passwords surface as a thrown AuthWeakPasswordException rather than a weakPassword field on the response." - auth.sign_in.sign_in_with_otp: implemented + status: implemented + symbols: + - GoTrueClient.signInWithPassword + auth.sign_in.sign_in_with_otp: + status: implemented + symbols: + - GoTrueClient.signInWithOtp auth.sign_in.sign_in_with_oauth: - status: partially_implemented - note: "Exposed as getOAuthSignInUrl (gotrue) and signInWithOAuth (supabase_flutter); the skipBrowserRedirect option is not publicly exposed." - auth.sign_in.sign_in_with_id_token: implemented + status: implemented + symbols: + - OAuthProvider + - OAuthProvider.OAuthProvider + auth.sign_in.sign_in_with_id_token: + status: implemented + symbols: + - GoTrueClient.signInWithIdToken auth.sign_in.sign_in_with_sso: - status: partially_implemented - note: "Exposed as getSSOSignInUrl, which returns Future (the URL) rather than the structured {url} response used by JS." - auth.sign_in.sign_in_with_web3: not_implemented - auth.sign_in.sign_in_anonymously: implemented - auth.sign_in.verify_otp: implemented - auth.sign_in.exchange_code_for_session: implemented - auth.sign_in.resend_confirmation: implemented + status: implemented + note: "Split across two methods: getSSOSignInUrl returns Future (the URL) rather than the structured {url} response used by JS, and supabase_flutter's signInWithSSO launches that URL in the browser and returns Future." + symbols: + - GoTrueClient.getSSOSignInUrl + auth.sign_in.sign_in_with_web3: + status: implemented + note: "Callers sign the SIWE/SIWS message with their own wallet library and pass the message and signature; the SDK does not auto-detect browser wallets or build the message, since window.ethereum/window.solana have no Flutter equivalent." + symbols: + - GoTrueClient.signInWithWeb3 + - Web3Chain + auth.sign_in.sign_in_anonymously: + status: implemented + symbols: + - GoTrueClient.signInAnonymously + auth.sign_in.verify_otp: + status: implemented + symbols: + - GoTrueClient.verifyOTP + auth.sign_in.exchange_code_for_session: + status: implemented + symbols: + - GoTrueClient.exchangeCodeForSession + auth.sign_in.resend_confirmation: + status: implemented + symbols: + - GoTrueClient.resend auth.sign_in.reauthenticate: - status: partially_implemented - note: "Returns Future; the refreshed session is not returned to the caller." - auth.sign_in.reset_password: implemented + status: implemented + symbols: + - GoTrueClient.reauthenticate + auth.sign_in.reset_password: + status: implemented + symbols: + - GoTrueClient.resetPasswordForEmail # auth — session & user auth.session.get_session: - status: partially_implemented - note: "Only the synchronous currentSession getter exists; there is no async getSession() with lock acquisition and on-demand refresh." + status: implemented + symbols: + - GoTrueClient.getSession auth.session.set_session: - status: partially_implemented - note: "Signature differs: setSession(refreshToken, {accessToken}) instead of an {access_token, refresh_token} object." - auth.session.refresh_session: implemented - auth.session.get_user: implemented + status: implemented + symbols: + - GoTrueClient.setSession + auth.session.refresh_session: + status: implemented + symbols: + - GoTrueClient.refreshSession + auth.session.get_user: + status: implemented + symbols: + - GoTrueClient.getUser auth.session.update_user: - status: partially_implemented - note: "Does not send a PKCE code_challenge when changing email under the PKCE flow." - auth.session.get_claims: implemented + status: implemented + symbols: + - UserAttributes.currentPassword + auth.session.get_claims: + status: implemented + symbols: + - GoTrueClient.getClaims auth.session.sign_out: - status: partially_implemented - note: "Default scope is local (current session); JS defaults to global (all sessions)." - auth.session.auto_refresh: implemented - auth.session.subscribe_auth_events: implemented + status: implemented + symbols: + - GoTrueClient.signOut + auth.session.auto_refresh: + status: implemented + symbols: + - GoTrueClient.startAutoRefresh + - GoTrueClient.stopAutoRefresh + auth.session.subscribe_auth_events: + status: implemented + symbols: + - GoTrueClient.onAuthStateChange auth.session.sign_out_reason: status: implemented symbols: @@ -67,29 +125,59 @@ features: # auth — identities auth.identities.link_identity: - status: partially_implemented + status: implemented note: "Split into getLinkIdentityUrl (OAuth) and linkIdentityWithIdToken (ID token) rather than a single overloaded method." + symbols: + - GoTrueClient.getLinkIdentityUrl + - GoTrueClient.linkIdentityWithIdToken auth.identities.list_identities: - status: partially_implemented - note: "getUserIdentities returns a List directly rather than a {data, error} object." - auth.identities.unlink_identity: implemented + status: implemented + symbols: + - GoTrueClient.getUserIdentities + auth.identities.unlink_identity: + status: implemented + symbols: + - GoTrueClient.unlinkIdentity # auth — MFA auth.mfa.enroll: - status: partially_implemented - note: "Supports totp and phone factor types only; webauthn enrollment is handled via the separate passkey API." + status: implemented + symbols: + - GoTrueMFAApi.enroll auth.mfa.challenge: - status: partially_implemented - note: "No channel parameter (sms/whatsapp) for phone factors; always uses SMS." - auth.mfa.verify: implemented - auth.mfa.challenge_and_verify: implemented - auth.mfa.unenroll: implemented - auth.mfa.list_factors: implemented - auth.mfa.get_authenticator_assurance_level: implemented + status: implemented + symbols: + - GoTrueMFAApi.challenge + auth.mfa.verify: + status: implemented + symbols: + - GoTrueMFAApi.verify + auth.mfa.challenge_and_verify: + status: implemented + symbols: + - GoTrueMFAApi.challengeAndVerify + auth.mfa.unenroll: + status: implemented + symbols: + - GoTrueMFAApi.unenroll + auth.mfa.list_factors: + status: implemented + symbols: + - GoTrueMFAApi.listFactors + auth.mfa.get_authenticator_assurance_level: + status: implemented + symbols: + - GoTrueMFAApi.getAuthenticatorAssuranceLevel # auth — passkeys (experimental) - auth.passkey.register_passkey: implemented - auth.passkey.sign_in_with_passkey: implemented + auth.passkey.register_passkey: + status: implemented + symbols: + - GoTrueClientPasskey.registerPasskey + auth.passkey.sign_in_with_passkey: + status: implemented + symbols: + - GoTrueClientPasskey.signInWithPasskey # auth — OAuth server (Supabase acting as an OAuth provider) auth.oauth_server.get_authorization_details: @@ -99,6 +187,9 @@ features: - GoTrueOAuthApi - GoTrueOAuthApi.GoTrueOAuthApi - GoTrueOAuthApi.getAuthorizationDetails + - OAuthAuthorizationResponse + - OAuthAuthorizationResponse.OAuthAuthorizationResponse + - OAuthAuthorizationResponse.fromJson - OAuthAuthorizationDetailsResponse - OAuthAuthorizationDetailsResponse.OAuthAuthorizationDetailsResponse - OAuthAuthorizationDetailsResponse.authorizationId @@ -107,6 +198,10 @@ features: - OAuthAuthorizationDetailsResponse.redirectUri - OAuthAuthorizationDetailsResponse.scope - OAuthAuthorizationDetailsResponse.user + - OAuthAuthorizationRedirectResponse + - OAuthAuthorizationRedirectResponse.OAuthAuthorizationRedirectResponse + - OAuthAuthorizationRedirectResponse.fromJson + - OAuthAuthorizationRedirectResponse.redirectUrl - OAuthAuthorizedClient - OAuthAuthorizedClient.OAuthAuthorizedClient - OAuthAuthorizedClient.clientId @@ -124,22 +219,63 @@ features: status: implemented symbols: - GoTrueOAuthApi.denyAuthorization - auth.oauth_server.list_grants: not_implemented - auth.oauth_server.revoke_grant: not_implemented + auth.oauth_server.list_grants: + status: implemented + symbols: + - GoTrueOAuthApi.listGrants + - OAuthGrant + - OAuthGrant.OAuthGrant + - OAuthGrant.client + - OAuthGrant.fromJson + - OAuthGrant.grantedAt + - OAuthGrant.scopes + - OAuthAuthorizedClient.clientUri + - OAuthAuthorizedClient.logoUri + auth.oauth_server.revoke_grant: + status: implemented + symbols: + - GoTrueOAuthApi.revokeGrant # auth — admin - auth.admin.create_user: implemented - auth.admin.get_user: implemented - auth.admin.update_user: implemented - auth.admin.delete_user: implemented + auth.admin.create_user: + status: implemented + symbols: + - GoTrueAdminApi.createUser + auth.admin.get_user: + status: implemented + symbols: + - GoTrueAdminApi.getUserById + auth.admin.update_user: + status: implemented + symbols: + - GoTrueAdminApi.updateUserById + auth.admin.delete_user: + status: implemented + symbols: + - GoTrueAdminApi.deleteUser auth.admin.list_users: status: partially_implemented note: "Returns the user list only; pagination metadata (total, nextPage, lastPage, aud) is not returned." - auth.admin.invite_user: implemented - auth.admin.generate_link: implemented - auth.admin.sign_out: implemented - auth.admin.list_mfa_factors: implemented - auth.admin.delete_mfa_factor: implemented + auth.admin.invite_user: + status: implemented + symbols: + - GoTrueAdminApi.inviteUserByEmail + auth.admin.generate_link: + status: implemented + symbols: + - GoTrueAdminApi.generateLink + auth.admin.sign_out: + status: implemented + symbols: + - GoTrueAdminApi.signOut + auth.admin.list_mfa_factors: + status: implemented + symbols: + - GoTrueAdminMFAApi.listFactors + auth.admin.delete_mfa_factor: + status: implemented + symbols: + - GoTrueAdminMFAApi.deleteFactor auth.admin.create_provider: status: implemented symbols: @@ -250,174 +386,1035 @@ features: - GoTrueAdminCustomProvidersApi.listProviders # auth — admin OAuth clients - auth.oauth_admin.create_client: implemented - auth.oauth_admin.get_client: implemented - auth.oauth_admin.update_client: implemented - auth.oauth_admin.delete_client: implemented - auth.oauth_admin.list_clients: implemented - auth.oauth_admin.regenerate_client_secret: implemented + auth.oauth_admin.create_client: + status: implemented + symbols: + - GoTrueAdminOAuthApi.createClient + auth.oauth_admin.get_client: + status: implemented + symbols: + - GoTrueAdminOAuthApi.getClient + auth.oauth_admin.update_client: + status: implemented + symbols: + - GoTrueAdminOAuthApi.updateClient + auth.oauth_admin.delete_client: + status: implemented + symbols: + - GoTrueAdminOAuthApi.deleteClient + auth.oauth_admin.list_clients: + status: implemented + symbols: + - GoTrueAdminOAuthApi.listClients + auth.oauth_admin.regenerate_client_secret: + status: implemented + symbols: + - GoTrueAdminOAuthApi.regenerateClientSecret # auth — admin passkeys (experimental) - auth.passkey_admin.list_passkeys: implemented - auth.passkey_admin.delete_passkey: implemented + auth.passkey_admin.list_passkeys: + status: implemented + symbols: + - GoTrueAdminPasskeyApi.listPasskeys + auth.passkey_admin.delete_passkey: + status: implemented + symbols: + - GoTrueAdminPasskeyApi.deletePasskey # database — query - database.query.from_table: implemented - database.query.select: implemented + database.query.from_table: + status: implemented + symbols: + - PostgrestClient.from + database.query.select: + status: implemented + symbols: + - PostgrestQueryBuilder.select database.query.rpc: - status: partially_implemented - note: "rpc(fn, {params, get}) has no head or count option on the call itself; both are reachable by chaining .head()/.count() on the returned builder." - database.query.schema_selection: implemented + status: implemented + note: "head and count are applied via type-safe chaining (.head()/.count()) rather than inline call options, which is the idiomatic Dart builder pattern; inline options cannot preserve the distinct return types." + symbols: + - PostgrestClient.rpc + database.query.schema_selection: + status: implemented + symbols: + - PostgrestClient.schema # database — mutate - database.mutate.insert: implemented - database.mutate.update: implemented - database.mutate.upsert: implemented - database.mutate.delete: implemented - database.mutate.select_after_mutation: implemented + database.mutate.insert: + status: implemented + symbols: + - PostgrestQueryBuilder.insert + database.mutate.update: + status: implemented + symbols: + - PostgrestQueryBuilder.update + database.mutate.upsert: + status: implemented + symbols: + - PostgrestQueryBuilder.upsert + database.mutate.delete: + status: implemented + symbols: + - PostgrestQueryBuilder.delete + database.mutate.select_after_mutation: + status: implemented + symbols: + - PostgrestTransformBuilder.select # database — filters - database.using_filters.eq: implemented - database.using_filters.neq: implemented - database.using_filters.gt: implemented - database.using_filters.gte: implemented - database.using_filters.lt: implemented - database.using_filters.lte: implemented - database.using_filters.like: implemented - database.using_filters.like_all: implemented - database.using_filters.like_any: implemented - database.using_filters.ilike: implemented - database.using_filters.ilike_all: implemented - database.using_filters.ilike_any: implemented - database.using_filters.is: implemented - database.using_filters.is_distinct: implemented - database.using_filters.in: implemented - database.using_filters.not_in: implemented - database.using_filters.contains: implemented - database.using_filters.contained_by: implemented - database.using_filters.overlaps: implemented - database.using_filters.match: implemented - database.using_filters.not: implemented - database.using_filters.or: implemented - database.using_filters.range_gt: implemented - database.using_filters.range_gte: implemented - database.using_filters.range_lt: implemented - database.using_filters.range_lte: implemented - database.using_filters.range_adjacent: implemented - database.using_filters.text_search: implemented - database.using_filters.regex: implemented - database.using_filters.regex_icase: implemented - database.using_filters.raw: implemented + database.using_filters.eq: + status: implemented + symbols: + - PostgrestFilterBuilder.eq + database.using_filters.neq: + status: implemented + symbols: + - PostgrestFilterBuilder.neq + database.using_filters.gt: + status: implemented + symbols: + - PostgrestFilterBuilder.gt + database.using_filters.gte: + status: implemented + symbols: + - PostgrestFilterBuilder.gte + database.using_filters.lt: + status: implemented + symbols: + - PostgrestFilterBuilder.lt + database.using_filters.lte: + status: implemented + symbols: + - PostgrestFilterBuilder.lte + database.using_filters.like: + status: implemented + symbols: + - PostgrestFilterBuilder.like + database.using_filters.like_all: + status: implemented + symbols: + - PostgrestFilterBuilder.likeAllOf + database.using_filters.like_any: + status: implemented + symbols: + - PostgrestFilterBuilder.likeAnyOf + database.using_filters.ilike: + status: implemented + symbols: + - PostgrestFilterBuilder.ilike + database.using_filters.ilike_all: + status: implemented + symbols: + - PostgrestFilterBuilder.ilikeAllOf + database.using_filters.ilike_any: + status: implemented + symbols: + - PostgrestFilterBuilder.ilikeAnyOf + database.using_filters.is: + status: implemented + symbols: + - PostgrestFilterBuilder.isFilter + database.using_filters.is_distinct: + status: implemented + symbols: + - PostgrestFilterBuilder.isDistinct + database.using_filters.in: + status: implemented + symbols: + - PostgrestFilterBuilder.inFilter + database.using_filters.not_in: + status: implemented + symbols: + - PostgrestFilterBuilder.not + database.using_filters.contains: + status: implemented + symbols: + - PostgrestFilterBuilder.contains + database.using_filters.contained_by: + status: implemented + symbols: + - PostgrestFilterBuilder.containedBy + database.using_filters.overlaps: + status: implemented + symbols: + - PostgrestFilterBuilder.overlaps + database.using_filters.match: + status: implemented + symbols: + - PostgrestFilterBuilder.match + database.using_filters.not: + status: implemented + symbols: + - PostgrestFilterBuilder.not + database.using_filters.or: + status: implemented + symbols: + - PostgrestFilterBuilder.or + database.using_filters.range_gt: + status: implemented + symbols: + - PostgrestFilterBuilder.rangeGt + database.using_filters.range_gte: + status: implemented + symbols: + - PostgrestFilterBuilder.rangeGte + database.using_filters.range_lt: + status: implemented + symbols: + - PostgrestFilterBuilder.rangeLt + database.using_filters.range_lte: + status: implemented + symbols: + - PostgrestFilterBuilder.rangeLte + database.using_filters.range_adjacent: + status: implemented + symbols: + - PostgrestFilterBuilder.rangeAdjacent + database.using_filters.text_search: + status: implemented + symbols: + - PostgrestFilterBuilder.textSearch + database.using_filters.regex: + status: implemented + symbols: + - PostgrestFilterBuilder.matchRegex + database.using_filters.regex_icase: + status: implemented + symbols: + - PostgrestFilterBuilder.imatchRegex + database.using_filters.raw: + status: implemented + symbols: + - PostgrestFilterBuilder.filter # database — modifiers - database.using_modifiers.order: implemented - database.using_modifiers.limit: implemented - database.using_modifiers.range: implemented - database.using_modifiers.single_row: implemented - database.using_modifiers.maybe_single_row: implemented - database.using_modifiers.max_affected_rows: implemented - database.using_modifiers.format_csv: implemented - database.using_modifiers.format_geojson: implemented - database.using_modifiers.relationship_embed: implemented + database.using_modifiers.order: + status: implemented + symbols: + - PostgrestTransformBuilder.order + database.using_modifiers.limit: + status: implemented + symbols: + - PostgrestTransformBuilder.limit + database.using_modifiers.range: + status: implemented + symbols: + - PostgrestTransformBuilder.range + database.using_modifiers.single_row: + status: implemented + symbols: + - PostgrestTransformBuilder.single + database.using_modifiers.maybe_single_row: + status: implemented + symbols: + - PostgrestTransformBuilder.maybeSingle + database.using_modifiers.max_affected_rows: + status: implemented + symbols: + - PostgrestTransformBuilder.maxAffected + database.using_modifiers.format_csv: + status: implemented + symbols: + - PostgrestTransformBuilder.csv + database.using_modifiers.format_geojson: + status: implemented + symbols: + - PostgrestTransformBuilder.geojson + database.using_modifiers.relationship_embed: + status: implemented + symbols: + - PostgrestTransformBuilder.select database.using_modifiers.explain: status: implemented symbols: - ExplainFormat - database.using_modifiers.dry_run: not_implemented - database.using_modifiers.strip_nulls: not_implemented - database.using_modifiers.request_cancellation: not_implemented + database.using_modifiers.dry_run: + status: implemented + symbols: + - PostgrestTransformBuilder.dryRun + database.using_modifiers.strip_nulls: + status: implemented + symbols: + - PostgrestTransformBuilder.stripNulls + database.using_modifiers.request_cancellation: + status: implemented + symbols: + - PostgrestBuilder.abortSignal # database — configuration database.configuration.auto_retry: - status: partially_implemented - note: "Retries GET/HEAD requests on transient failures with backoff, but the retry count and conditions are hard-coded, not configurable." - database.configuration.request_timeout: not_implemented + status: implemented + symbols: + - PostgrestClient.retryEnabled + - PostgrestClient.retryCount + - PostgrestClient.retryableStatusCodes + - PostgrestClientOptions.retryEnabled + - PostgrestClientOptions.retryCount + - PostgrestClientOptions.retryableStatusCodes + - PostgrestBuilder.retry + - PostgrestClient.defaultRetryableStatusCodes + database.configuration.request_timeout: + status: implemented + symbols: + - PostgrestClient.requestTimeout + - PostgrestClientOptions.requestTimeout # storage — file buckets - storage.file_buckets.access_bucket: implemented - storage.file_buckets.upload: implemented - storage.file_buckets.upload_with_metadata: implemented - storage.file_buckets.upload_with_signed_url: implemented - storage.file_buckets.update_file: implemented - storage.file_buckets.download: implemented - storage.file_buckets.download_with_transform: implemented - storage.file_buckets.download_as_stream: not_implemented - storage.file_buckets.list_files: implemented - storage.file_buckets.list_files_paginated: not_implemented - storage.file_buckets.move: implemented - storage.file_buckets.move_cross_bucket: implemented - storage.file_buckets.copy: implemented - storage.file_buckets.copy_cross_bucket: implemented - storage.file_buckets.remove: implemented + storage.file_buckets.access_bucket: + status: implemented + symbols: + - SupabaseStorageClient.from + storage.file_buckets.upload: + status: implemented + symbols: + - StorageFileApi.upload + - StorageFileApi.uploadBinary + storage.file_buckets.upload_with_metadata: + status: implemented + symbols: + - FileOptions.metadata + storage.file_buckets.upload_with_signed_url: + status: implemented + symbols: + - StorageFileApi.uploadToSignedUrl + - StorageFileApi.uploadBinaryToSignedUrl + storage.file_buckets.update_file: + status: implemented + symbols: + - StorageFileApi.update + - StorageFileApi.updateBinary + storage.file_buckets.download: + status: implemented + symbols: + - StorageFileApi.download + storage.file_buckets.download_with_transform: + status: implemented + symbols: + - TransformOptions + storage.file_buckets.download_as_stream: + status: implemented + note: "Exposed as a dedicated downloadStream method returning a lazy Stream rather than an asStream() builder off download(); the request is sent on listen and a non-success status surfaces as a StorageException on the stream." + symbols: + - StorageFileApi.downloadStream + storage.file_buckets.list_files: + status: implemented + symbols: + - StorageFileApi.list + storage.file_buckets.list_files_paginated: + status: implemented + note: "Exposed as listPaginated (paired with descriptive Dart type names) rather than the supabase-js listV2 name." + symbols: + - StorageFileApi.listPaginated + - PaginatedSearchOptions + - PaginatedSearchOptions.PaginatedSearchOptions + - PaginatedSearchOptions.cursor + - PaginatedSearchOptions.limit + - PaginatedSearchOptions.prefix + - PaginatedSearchOptions.sortBy + - PaginatedSearchOptions.toMap + - PaginatedSearchOptions.withDelimiter + - PaginatedListResult + - PaginatedListResult.PaginatedListResult + - PaginatedListResult.folders + - PaginatedListResult.fromJson + - PaginatedListResult.hasNext + - PaginatedListResult.nextCursor + - PaginatedListResult.objects + - PaginatedFile + - PaginatedFile.PaginatedFile + - PaginatedFile.createdAt + - PaginatedFile.fromJson + - PaginatedFile.id + - PaginatedFile.key + - PaginatedFile.metadata + - PaginatedFile.name + - PaginatedFile.updatedAt + - PaginatedFolder + - PaginatedFolder.PaginatedFolder + - PaginatedFolder.fromJson + - PaginatedFolder.key + - PaginatedFolder.name + - FileSort + - FileSort.FileSort + - FileSort.column + - FileSort.order + - FileSort.toMap + - FileSortColumn + - FileSortOrder + - FileSortOrder.FileSortOrder + - FileSortOrder.value + storage.file_buckets.move: + status: implemented + symbols: + - StorageFileApi.move + storage.file_buckets.move_cross_bucket: + status: implemented + symbols: + - StorageFileApi.move + storage.file_buckets.copy: + status: implemented + symbols: + - StorageFileApi.copy + storage.file_buckets.copy_cross_bucket: + status: implemented + symbols: + - StorageFileApi.copy + storage.file_buckets.remove: + status: implemented + symbols: + - StorageFileApi.remove + storage.file_buckets.purge_cache: + status: implemented + symbols: + - StorageFileApi.purgeCache + storage.file_buckets.purge_bucket_cache: + status: implemented + symbols: + - StorageBucketApi.purgeBucketCache storage.file_buckets.create_signed_url: status: implemented symbols: - DownloadBehavior - DownloadBehavior.named - DownloadBehavior.withOriginalName - storage.file_buckets.create_signed_urls: implemented - storage.file_buckets.create_signed_upload_url: implemented - storage.file_buckets.get_public_url: implemented - storage.file_buckets.url_cache_nonce: not_implemented - storage.file_buckets.file_exists: implemented - storage.file_buckets.file_info: implemented - storage.file_buckets.create_file_bucket: implemented - storage.file_buckets.get_bucket: implemented + storage.file_buckets.create_signed_urls: + status: implemented + symbols: + - StorageFileApi.createSignedUrls + storage.file_buckets.create_signed_upload_url: + status: implemented + symbols: + - StorageFileApi.createSignedUploadUrl + storage.file_buckets.get_public_url: + status: implemented + symbols: + - StorageFileApi.getPublicUrl + storage.file_buckets.url_cache_nonce: + status: implemented + symbols: + - StorageFileApi.getPublicUrl + storage.file_buckets.file_exists: + status: implemented + symbols: + - StorageFileApi.exists + storage.file_buckets.file_info: + status: implemented + symbols: + - StorageFileApi.info + storage.file_buckets.create_file_bucket: + status: implemented + symbols: + - StorageBucketApi.createBucket + storage.file_buckets.get_bucket: + status: implemented + symbols: + - StorageBucketApi.getBucket storage.file_buckets.list_file_buckets: - status: partially_implemented - note: "listBuckets() takes no arguments; the JS filter/sort/pagination options are not supported." - storage.file_buckets.update_bucket: implemented - storage.file_buckets.delete_file_bucket: implemented - storage.file_buckets.empty_bucket: implemented + status: implemented + symbols: + - StorageBucketApi.listBuckets + - ListBucketsOptions + - ListBucketsOptions.ListBucketsOptions + - ListBucketsOptions.limit + - ListBucketsOptions.offset + - ListBucketsOptions.search + - ListBucketsOptions.sortColumn + - ListBucketsOptions.sortOrder + - ListBucketsOptions.toQueryParameters + - BucketSortColumn + - BucketSortOrder + - BucketSortOrder.BucketSortOrder + - BucketSortOrder.value + storage.file_buckets.update_bucket: + status: implemented + symbols: + - StorageBucketApi.updateBucket + storage.file_buckets.delete_file_bucket: + status: implemented + symbols: + - StorageBucketApi.deleteBucket + storage.file_buckets.empty_bucket: + status: implemented + symbols: + - StorageBucketApi.emptyBucket # storage — vector buckets - storage.vector_buckets.create_vector_bucket: not_implemented - storage.vector_buckets.list_vector_buckets: not_implemented - storage.vector_buckets.delete_vector_bucket: not_implemented - storage.vector_buckets.access_vector_index: not_implemented - storage.vector_buckets.create_index: not_implemented - storage.vector_buckets.get_index: not_implemented - storage.vector_buckets.list_indexes: not_implemented - storage.vector_buckets.delete_index: not_implemented - storage.vector_buckets.put_vectors: not_implemented - storage.vector_buckets.get_vectors: not_implemented - storage.vector_buckets.list_vectors: not_implemented - storage.vector_buckets.delete_vectors: not_implemented - storage.vector_buckets.query_vectors: not_implemented - storage.vector_buckets.parallel_scan: not_implemented - + storage.vector_buckets.create_vector_bucket: + status: implemented + symbols: + - SupabaseStorageClient.vectors + - SupabaseVectorsClient + - SupabaseVectorsClient.createBucket + storage.vector_buckets.list_vector_buckets: + status: implemented + symbols: + - SupabaseVectorsClient.listBuckets + - SupabaseVectorsClient.getBucket + - VectorBucket + - VectorBucket.VectorBucket + - VectorBucket.name + - VectorBucket.creationTime + - VectorBucket.encryption + - VectorBucket.fromJson + - VectorBucketEncryption + - VectorBucketEncryption.VectorBucketEncryption + - VectorBucketEncryption.kmsKeyArn + - VectorBucketEncryption.sseType + - VectorBucketEncryption.fromJson + - VectorBucketList + - VectorBucketList.VectorBucketList + - VectorBucketList.buckets + - VectorBucketList.nextToken + - VectorBucketList.fromJson + storage.vector_buckets.delete_vector_bucket: + status: implemented + symbols: + - SupabaseVectorsClient.deleteBucket + storage.vector_buckets.access_vector_index: + status: implemented + symbols: + - SupabaseVectorsClient.from + - StorageVectorBucketApi + - StorageVectorBucketApi.bucketName + - StorageVectorBucketApi.index + - StorageVectorIndexApi + - StorageVectorIndexApi.bucketName + - StorageVectorIndexApi.indexName + storage.vector_buckets.create_index: + status: implemented + symbols: + - StorageVectorBucketApi.createIndex + - VectorDataType + - VectorDataType.value + - VectorDataType.fromValue + - DistanceMetric + - DistanceMetric.value + - DistanceMetric.fromValue + storage.vector_buckets.get_index: + status: implemented + symbols: + - StorageVectorBucketApi.getIndex + - VectorIndex + - VectorIndex.VectorIndex + - VectorIndex.name + - VectorIndex.bucketName + - VectorIndex.dataType + - VectorIndex.dimension + - VectorIndex.distanceMetric + - VectorIndex.nonFilterableMetadataKeys + - VectorIndex.creationTime + - VectorIndex.fromJson + storage.vector_buckets.list_indexes: + status: implemented + symbols: + - StorageVectorBucketApi.listIndexes + - VectorIndexList + - VectorIndexList.VectorIndexList + - VectorIndexList.indexes + - VectorIndexList.nextToken + - VectorIndexList.fromJson + storage.vector_buckets.delete_index: + status: implemented + symbols: + - StorageVectorBucketApi.deleteIndex + storage.vector_buckets.put_vectors: + status: implemented + symbols: + - StorageVectorIndexApi.putVectors + - Vector + - Vector.Vector + - Vector.key + - Vector.data + - Vector.metadata + - Vector.toJson + storage.vector_buckets.get_vectors: + status: implemented + symbols: + - StorageVectorIndexApi.getVectors + - VectorMatch + - VectorMatch.VectorMatch + - VectorMatch.key + - VectorMatch.data + - VectorMatch.metadata + - VectorMatch.distance + - VectorMatch.fromJson + storage.vector_buckets.list_vectors: + status: implemented + symbols: + - StorageVectorIndexApi.listVectors + - VectorList + - VectorList.VectorList + - VectorList.vectors + - VectorList.nextToken + - VectorList.fromJson + storage.vector_buckets.delete_vectors: + status: implemented + symbols: + - StorageVectorIndexApi.deleteVectors + storage.vector_buckets.query_vectors: + status: implemented + symbols: + - StorageVectorIndexApi.queryVectors + - VectorQueryResult + - VectorQueryResult.VectorQueryResult + - VectorQueryResult.matches + - VectorQueryResult.distanceMetric + - VectorQueryResult.fromJson + storage.vector_buckets.parallel_scan: + status: implemented + note: "Exposed through listVectors' segmentCount/segmentIndex parameters rather than a separate method; both are validated client-side." + symbols: + - StorageVectorIndexApi.listVectors # storage — analytics (Iceberg) buckets - storage.analytics.create_analytics_bucket: not_implemented - storage.analytics.list_analytics_buckets: not_implemented - storage.analytics.delete_analytics_bucket: not_implemented - storage.analytics.iceberg_namespace: not_implemented - storage.analytics.iceberg_table: not_implemented + storage.analytics.create_analytics_bucket: + status: implemented + symbols: + - StorageBucketApi.createAnalyticsBucket + - AnalyticsBucket + - AnalyticsBucket.AnalyticsBucket + - AnalyticsBucket.createdAt + - AnalyticsBucket.fromJson + - AnalyticsBucket.id + - AnalyticsBucket.name + - AnalyticsBucket.updatedAt + storage.analytics.list_analytics_buckets: + status: implemented + symbols: + - StorageBucketApi.listAnalyticsBuckets + storage.analytics.delete_analytics_bucket: + status: implemented + symbols: + - StorageBucketApi.deleteAnalyticsBucket + storage.analytics.iceberg_namespace: + status: implemented + symbols: + - IcebergRestCatalog.createNamespace + - IcebergRestCatalog.createNamespaceIfNotExists + - IcebergRestCatalog.dropNamespace + - IcebergRestCatalog.listNamespaces + - IcebergRestCatalog.loadNamespaceMetadata + - IcebergRestCatalog.namespaceExists + - IcebergRestCatalog.updateNamespaceProperties + - ListNamespacesOptions + - ListNamespacesOptions.ListNamespacesOptions + - ListNamespacesOptions.pageSize + - ListNamespacesOptions.pageToken + - ListNamespacesOptions.parent + - ListNamespacesResult + - ListNamespacesResult.ListNamespacesResult + - ListNamespacesResult.namespaces + - ListNamespacesResult.nextPageToken + - UpdateNamespacePropertiesResult + - UpdateNamespacePropertiesResult.UpdateNamespacePropertiesResult + - UpdateNamespacePropertiesResult.fromJson + - UpdateNamespacePropertiesResult.missing + - UpdateNamespacePropertiesResult.removed + - UpdateNamespacePropertiesResult.updated + storage.analytics.iceberg_table: + status: implemented + note: >- + Data definition table updates (schema, partition spec, sort order, + properties, location) are modelled as typed TableUpdate subclasses; + snapshot, statistics and encryption key updates are sent through the + TableUpdate.raw escape hatch. + symbols: + - AccessDelegation + - AccessDelegation.AccessDelegation + - AccessDelegation.value + - AddPartitionSpecUpdate + - AddPartitionSpecUpdate.AddPartitionSpecUpdate + - AddPartitionSpecUpdate.spec + - AddPartitionSpecUpdate.toJson + - AddSchemaUpdate + - AddSchemaUpdate.AddSchemaUpdate + - AddSchemaUpdate.schema + - AddSchemaUpdate.toJson + - AddSortOrderUpdate + - AddSortOrderUpdate.AddSortOrderUpdate + - AddSortOrderUpdate.sortOrder + - AddSortOrderUpdate.toJson + - AssertCreate + - AssertCreate.AssertCreate + - AssertCreate.toJson + - AssertCurrentSchemaId + - AssertCurrentSchemaId.AssertCurrentSchemaId + - AssertCurrentSchemaId.currentSchemaId + - AssertCurrentSchemaId.toJson + - AssertDefaultSortOrderId + - AssertDefaultSortOrderId.AssertDefaultSortOrderId + - AssertDefaultSortOrderId.defaultSortOrderId + - AssertDefaultSortOrderId.toJson + - AssertDefaultSpecId + - AssertDefaultSpecId.AssertDefaultSpecId + - AssertDefaultSpecId.defaultSpecId + - AssertDefaultSpecId.toJson + - AssertLastAssignedFieldId + - AssertLastAssignedFieldId.AssertLastAssignedFieldId + - AssertLastAssignedFieldId.lastAssignedFieldId + - AssertLastAssignedFieldId.toJson + - AssertLastAssignedPartitionId + - AssertLastAssignedPartitionId.AssertLastAssignedPartitionId + - AssertLastAssignedPartitionId.lastAssignedPartitionId + - AssertLastAssignedPartitionId.toJson + - AssertReferenceSnapshotId + - AssertReferenceSnapshotId.AssertReferenceSnapshotId + - AssertReferenceSnapshotId.reference + - AssertReferenceSnapshotId.snapshotId + - AssertReferenceSnapshotId.toJson + - AssertTableUuid + - AssertTableUuid.AssertTableUuid + - AssertTableUuid.toJson + - AssertTableUuid.uuid + - AssignUuidUpdate + - AssignUuidUpdate.AssignUuidUpdate + - AssignUuidUpdate.toJson + - AssignUuidUpdate.uuid + - CommitTableResult + - CommitTableResult.CommitTableResult + - CommitTableResult.metadata + - CommitTableResult.metadataLocation + - CreateTableRequest + - CreateTableRequest.CreateTableRequest + - CreateTableRequest.location + - CreateTableRequest.name + - CreateTableRequest.partitionSpec + - CreateTableRequest.properties + - CreateTableRequest.schema + - CreateTableRequest.stageCreate + - CreateTableRequest.toJson + - CreateTableRequest.writeOrder + - IcebergAuthenticationTimeoutException + - IcebergAuthenticationTimeoutException.IcebergAuthenticationTimeoutException + - IcebergCommitStateUnknownException + - IcebergCommitStateUnknownException.IcebergCommitStateUnknownException + - IcebergConflictException + - IcebergConflictException.IcebergConflictException + - IcebergException + - IcebergException.IcebergException + - IcebergException.code + - IcebergException.details + - IcebergException.fromResponse + - IcebergException.message + - IcebergException.statusCode + - IcebergException.toString + - IcebergException.type + - IcebergNetworkException + - IcebergNetworkException.IcebergNetworkException + - IcebergNotFoundException + - IcebergNotFoundException.IcebergNotFoundException + - IcebergRestCatalog + - IcebergRestCatalog.IcebergRestCatalog + - IcebergRestCatalog.createTable + - IcebergRestCatalog.createTableIfNotExists + - IcebergRestCatalog.createTableResult + - IcebergRestCatalog.dropTable + - IcebergRestCatalog.listTables + - IcebergRestCatalog.loadTable + - IcebergRestCatalog.loadTableResult + - IcebergRestCatalog.registerTable + - IcebergRestCatalog.renameTable + - IcebergRestCatalog.tableExists + - IcebergRestCatalog.updateTable + - IcebergServerException + - IcebergServerException.IcebergServerException + - IcebergType + - IcebergType.IcebergType + - IcebergType.fromJson + - IcebergType.toJson + - IcebergUnknownException + - IcebergUnknownException.IcebergUnknownException + - ListTablesOptions + - ListTablesOptions.ListTablesOptions + - ListTablesOptions.pageSize + - ListTablesOptions.pageToken + - ListTablesResult + - ListTablesResult.ListTablesResult + - ListTablesResult.identifiers + - ListTablesResult.nextPageToken + - ListType + - ListType.ListType + - ListType.element + - ListType.elementId + - ListType.elementRequired + - ListType.fromJson + - ListType.toJson + - LoadTableOptions + - LoadTableOptions.LoadTableOptions + - LoadTableOptions.ifNoneMatch + - LoadTableOptions.snapshots + - LoadTableResult + - LoadTableResult.LoadTableResult + - LoadTableResult.config + - LoadTableResult.etag + - LoadTableResult.fromJson + - LoadTableResult.metadata + - LoadTableResult.metadataLocation + - LoadTableResult.storageCredentials + - LoadTableSnapshots + - LoadTableSnapshots.LoadTableSnapshots + - LoadTableSnapshots.value + - MapType + - MapType.MapType + - MapType.fromJson + - MapType.key + - MapType.keyId + - MapType.toJson + - MapType.value + - MapType.valueId + - MapType.valueRequired + - NullOrder + - NullOrder.NullOrder + - NullOrder.fromValue + - NullOrder.value + - PartitionField + - PartitionField.PartitionField + - PartitionField.fieldId + - PartitionField.fromJson + - PartitionField.name + - PartitionField.sourceId + - PartitionField.toJson + - PartitionField.transform + - PartitionSpec + - PartitionSpec.PartitionSpec + - PartitionSpec.fields + - PartitionSpec.fromJson + - PartitionSpec.specId + - PartitionSpec.toJson + - PrimitiveType + - PrimitiveType.PrimitiveType + - PrimitiveType.name + - PrimitiveType.toJson + - RawTableUpdate + - RawTableUpdate.RawTableUpdate + - RawTableUpdate.action + - RawTableUpdate.body + - RawTableUpdate.toJson + - RegisterTableRequest + - RegisterTableRequest.RegisterTableRequest + - RegisterTableRequest.metadataLocation + - RegisterTableRequest.name + - RegisterTableRequest.overwrite + - RegisterTableRequest.toJson + - RemovePartitionSpecsUpdate + - RemovePartitionSpecsUpdate.RemovePartitionSpecsUpdate + - RemovePartitionSpecsUpdate.specIds + - RemovePartitionSpecsUpdate.toJson + - RemovePropertiesUpdate + - RemovePropertiesUpdate.RemovePropertiesUpdate + - RemovePropertiesUpdate.removals + - RemovePropertiesUpdate.toJson + - RemoveSchemasUpdate + - RemoveSchemasUpdate.RemoveSchemasUpdate + - RemoveSchemasUpdate.schemaIds + - RemoveSchemasUpdate.toJson + - RemoveSnapshotReferenceUpdate + - RemoveSnapshotReferenceUpdate.RemoveSnapshotReferenceUpdate + - RemoveSnapshotReferenceUpdate.referenceName + - RemoveSnapshotReferenceUpdate.toJson + - RemoveSnapshotsUpdate + - RemoveSnapshotsUpdate.RemoveSnapshotsUpdate + - RemoveSnapshotsUpdate.snapshotIds + - RemoveSnapshotsUpdate.toJson + - SetCurrentSchemaUpdate + - SetCurrentSchemaUpdate.SetCurrentSchemaUpdate + - SetCurrentSchemaUpdate.schemaId + - SetCurrentSchemaUpdate.toJson + - SetDefaultSortOrderUpdate + - SetDefaultSortOrderUpdate.SetDefaultSortOrderUpdate + - SetDefaultSortOrderUpdate.sortOrderId + - SetDefaultSortOrderUpdate.toJson + - SetDefaultSpecUpdate + - SetDefaultSpecUpdate.SetDefaultSpecUpdate + - SetDefaultSpecUpdate.specId + - SetDefaultSpecUpdate.toJson + - SetLocationUpdate + - SetLocationUpdate.SetLocationUpdate + - SetLocationUpdate.location + - SetLocationUpdate.toJson + - SetPropertiesUpdate + - SetPropertiesUpdate.SetPropertiesUpdate + - SetPropertiesUpdate.toJson + - SetPropertiesUpdate.updates + - SetSnapshotReferenceUpdate + - SetSnapshotReferenceUpdate.SetSnapshotReferenceUpdate + - SetSnapshotReferenceUpdate.reference + - SetSnapshotReferenceUpdate.referenceName + - SetSnapshotReferenceUpdate.toJson + - Snapshot + - Snapshot.Snapshot + - Snapshot.fromJson + - Snapshot.manifestList + - Snapshot.parentSnapshotId + - Snapshot.schemaId + - Snapshot.sequenceNumber + - Snapshot.snapshotId + - Snapshot.summary + - Snapshot.timestampMs + - Snapshot.toJson + - SnapshotReference + - SnapshotReference.SnapshotReference + - SnapshotReference.fromJson + - SnapshotReference.maxReferenceAgeMs + - SnapshotReference.maxSnapshotAgeMs + - SnapshotReference.minSnapshotsToKeep + - SnapshotReference.snapshotId + - SnapshotReference.toJson + - SnapshotReference.type + - SnapshotReferenceType + - SnapshotReferenceType.SnapshotReferenceType + - SnapshotReferenceType.fromValue + - SnapshotReferenceType.value + - SortDirection + - SortDirection.SortDirection + - SortDirection.fromValue + - SortDirection.value + - SortField + - SortField.SortField + - SortField.direction + - SortField.fromJson + - SortField.nullOrder + - SortField.sourceId + - SortField.toJson + - SortField.transform + - SortOrder + - SortOrder.SortOrder + - SortOrder.fields + - SortOrder.fromJson + - SortOrder.orderId + - SortOrder.toJson + - StorageCredential + - StorageCredential.StorageCredential + - StorageCredential.config + - StorageCredential.fromJson + - StorageCredential.prefix + - StructType + - StructType.StructType + - StructType.fields + - StructType.fromJson + - StructType.toJson + - SupabaseStorageClient.analyticsCatalog + - TableField + - TableField.TableField + - TableField.doc + - TableField.fromJson + - TableField.id + - TableField.initialDefault + - TableField.name + - TableField.required + - TableField.toJson + - TableField.type + - TableField.writeDefault + - TableIdentifier + - TableIdentifier.TableIdentifier + - TableIdentifier.fromJson + - TableIdentifier.name + - TableIdentifier.namespace + - TableIdentifier.toJson + - TableMetadata + - TableMetadata.TableMetadata + - TableMetadata.currentSchema + - TableMetadata.currentSchemaId + - TableMetadata.currentSnapshotId + - TableMetadata.defaultSortOrderId + - TableMetadata.defaultSpecId + - TableMetadata.formatVersion + - TableMetadata.fromJson + - TableMetadata.lastColumnId + - TableMetadata.lastUpdatedMs + - TableMetadata.location + - TableMetadata.metadataLocation + - TableMetadata.partitionSpecs + - TableMetadata.properties + - TableMetadata.refs + - TableMetadata.schemas + - TableMetadata.snapshots + - TableMetadata.sortOrders + - TableMetadata.tableUuid + - TableRequirement + - TableRequirement.TableRequirement + - TableRequirement.toJson + - TableSchema + - TableSchema.TableSchema + - TableSchema.fields + - TableSchema.fromJson + - TableSchema.identifierFieldIds + - TableSchema.schemaId + - TableSchema.toJson + - TableUpdate + - TableUpdate.TableUpdate + - TableUpdate.raw + - TableUpdate.toJson + - UpgradeFormatVersionUpdate + - UpgradeFormatVersionUpdate.UpgradeFormatVersionUpdate + - UpgradeFormatVersionUpdate.formatVersion + - UpgradeFormatVersionUpdate.toJson # realtime — channel - realtime.channel.subscribe: implemented - realtime.channel.unsubscribe: implemented - realtime.channel.send: implemented + realtime.channel.subscribe: + status: implemented + symbols: + - RealtimeChannel.subscribe + realtime.channel.unsubscribe: + status: implemented + symbols: + - RealtimeChannel.unsubscribe + realtime.channel.send: + status: implemented + symbols: + - RealtimeChannel.sendBroadcastMessage realtime.channel.broadcast_http: - status: partially_implemented + status: implemented note: "httpSend posts to the legacy /api/broadcast endpoint with a messages array and returns Future, not the newer per-channel endpoint returning {success}." + symbols: + - RealtimeChannel.httpSend # realtime — client - realtime.client.channel: implemented - realtime.client.connect: implemented - realtime.client.disconnect: implemented - realtime.client.connection_state: implemented - realtime.client.get_channels: implemented - realtime.client.remove_channel: implemented - realtime.client.remove_all_channels: implemented + realtime.client.channel: + status: implemented + symbols: + - RealtimeClient.channel + realtime.client.connect: + status: implemented + symbols: + - RealtimeChannel.subscribe + realtime.client.disconnect: + status: implemented + symbols: + - Constants.defaultConnectionCloseTimeout + - RealtimeClient.connectionCloseTimeout + - RealtimeClientOptions.connectionCloseTimeout + realtime.client.connection_state: + status: implemented + symbols: + - RealtimeClient.connectionState + realtime.client.get_channels: + status: implemented + symbols: + - RealtimeClient.getChannels + realtime.client.remove_channel: + status: implemented + symbols: + - RealtimeClient.removeChannel + realtime.client.remove_all_channels: + status: implemented + symbols: + - RealtimeClient.removeAllChannels realtime.client.listen_heartbeats: status: implemented symbols: - RealtimeClient.onHeartbeat - RealtimeHeartbeatStatus - realtime.client.set_auth_token: implemented + realtime.client.set_auth_token: + status: implemented + symbols: + - RealtimeClient.setAuth # realtime — subscriptions - realtime.subscriptions.broadcast: implemented + realtime.subscriptions.broadcast: + status: implemented + symbols: + - RealtimeChannel.onBroadcast realtime.subscriptions.postgres_changes: status: implemented symbols: @@ -436,49 +1433,165 @@ features: symbols: - PostgresChangeFilter.negate - PostgresChangeFilterType.token - realtime.subscriptions.subscribe_presence: implemented - realtime.subscriptions.private_channel: implemented - realtime.subscriptions.broadcast_self: implemented - realtime.subscriptions.broadcast_ack: implemented - realtime.subscriptions.broadcast_replay: implemented + realtime.subscriptions.subscribe_presence: + status: implemented + symbols: + - RealtimeChannel.onPresenceSync + - RealtimeChannel.onPresenceJoin + - RealtimeChannel.onPresenceLeave + realtime.subscriptions.private_channel: + status: implemented + symbols: + - RealtimeChannelConfig.private + realtime.subscriptions.broadcast_self: + status: implemented + symbols: + - RealtimeChannelConfig.self + realtime.subscriptions.broadcast_ack: + status: implemented + symbols: + - RealtimeChannelConfig.ack + realtime.subscriptions.broadcast_replay: + status: implemented + symbols: + - RealtimeChannelConfig.replay # realtime — presence - realtime.presence.track: implemented - realtime.presence.untrack: implemented - realtime.presence.presence_state: implemented - realtime.presence.presence_key: implemented + realtime.presence.track: + status: implemented + symbols: + - RealtimeChannel.track + realtime.presence.untrack: + status: implemented + symbols: + - RealtimeChannel.untrack + realtime.presence.presence_state: + status: implemented + symbols: + - RealtimeChannel.presenceState + realtime.presence.presence_key: + status: implemented + symbols: + - RealtimeChannelConfig.key # realtime — configuration - realtime.configuration.custom_websocket_transport: implemented - realtime.configuration.reconnect_backoff: implemented - realtime.configuration.heartbeat_interval: implemented - realtime.configuration.access_token_callback: implemented - realtime.configuration.custom_logger: implemented - realtime.configuration.binary_protocol: implemented - realtime.configuration.deferred_disconnect: not_implemented + realtime.configuration.custom_websocket_transport: + status: implemented + symbols: + - RealtimeClientOptions.transport + realtime.configuration.reconnect_backoff: + status: implemented + symbols: + - RealtimeClient.reconnectAfterMs + realtime.configuration.heartbeat_interval: + status: implemented + symbols: + - RealtimeClient.heartbeatIntervalMs + realtime.configuration.access_token_callback: + status: implemented + note: "On connect the access token is resolved before the send buffer is flushed; buffered channel join payloads are patched with the token and re-sent so subscriptions authenticate correctly when the token resolves asynchronously (e.g. read from async storage)." + symbols: + - RealtimeClient.customAccessToken + realtime.configuration.custom_logger: + status: implemented + symbols: + - RealtimeClient.logger + realtime.configuration.binary_protocol: + status: implemented + symbols: + - RealtimeClient.encode + - RealtimeClient.decode + realtime.configuration.deferred_disconnect: + status: implemented + symbols: + - RealtimeClientOptions.disconnectOnEmptyChannelsAfter # functions functions.invocation.invoke: - status: partially_implemented - note: "Invocation works, but errors surface as a single FunctionException; the FunctionsFetchError/FunctionsRelayError/FunctionsHttpError distinction is not exposed." - functions.invocation.set_auth_token: implemented - functions.invocation.method_override: implemented - functions.invocation.streaming_response: implemented - functions.invocation.region_selection: implemented - functions.invocation.timeout: not_implemented - functions.invocation.request_cancellation: not_implemented + status: implemented + symbols: + - FunctionsFetchException + - FunctionsFetchException.FunctionsFetchException + - FunctionsRelayException + - FunctionsRelayException.FunctionsRelayException + - FunctionsHttpException + - FunctionsHttpException.FunctionsHttpException + functions.invocation.set_auth_token: + status: implemented + symbols: + - FunctionsClient.setAuth + functions.invocation.method_override: + status: implemented + symbols: + - HttpMethod + functions.invocation.streaming_response: + status: implemented + symbols: + - FunctionResponse.data + functions.invocation.region_selection: + status: implemented + symbols: + - FunctionsClientOptions.region + functions.invocation.timeout: + status: not_applicable + note: "A genuine timeout that aborts the in-flight request is already expressible as `invoke(..., abortSignal: Future.delayed(duration))` via functions.invocation.request_cancellation, so a dedicated timeout option would add no capability over that." + functions.invocation.request_cancellation: + status: implemented + symbols: + - FunctionsClient.invoke # client - client.authentication_integration.third_party_auth: implemented - client.authentication_integration.cross_client_token_sync: implemented - client.authentication_integration.oauth_flow_type: implemented + client.authentication_integration.third_party_auth: + status: implemented + symbols: + - SupabaseClient.accessToken + client.authentication_integration.cross_client_token_sync: + status: implemented + symbols: + - PostgrestClient.setAuth + - RealtimeClient.setAuth + - SupabaseStorageClient.setAuth + - FunctionsClient.setAuth + client.authentication_integration.oauth_flow_type: + status: implemented + symbols: + - AuthFlowType + - AuthClientOptions.authFlowType client.authentication_integration.session_url_detection: - status: partially_implemented - note: "supabase_flutter exposes detectSessionInUri as a boolean only; there is no custom predicate to disambiguate redirects." - client.session_management.custom_storage: implemented + status: implemented + symbols: + - FlutterAuthClientOptions.detectSessionInUri + - FlutterAuthClientOptions.detectSessionInUriPredicate + client.session_management.custom_storage: + status: implemented + symbols: + - GotrueAsyncStorage + - LocalStorage client.session_management.persist_session: - status: partially_implemented - note: "No persistSession flag; in-memory-only sessions are achievable by supplying an EmptyLocalStorage backend." - client.request_configuration.custom_http_client: implemented - client.request_configuration.global_headers: implemented - client.observability.trace_propagation: not_implemented + status: implemented + symbols: + - FlutterAuthClientOptions.persistSession + client.request_configuration.custom_http_client: + status: implemented + symbols: + - PostgrestClient.httpClient + - RealtimeClient.httpClient + client.request_configuration.global_headers: + status: implemented + symbols: + - SupabaseClient.headers + client.observability.trace_propagation: + status: implemented + note: "Opt-in W3C trace context propagation (traceparent/tracestate/baggage) to Supabase hosts only, with sampling-decision respect. The active context is supplied through a traceContextProvider callback, the idiomatic Dart equivalent of supabase-js's automatic extraction from the OpenTelemetry global API, since Dart has no ubiquitous ambient trace context." + symbols: + - TraceContext + - TraceContext.TraceContext + - TraceContext.traceparent + - TraceContext.tracestate + - TraceContext.baggage + - TraceContextProvider + - TracePropagationOptions + - TracePropagationOptions.TracePropagationOptions + - TracePropagationOptions.enabled + - TracePropagationOptions.respectSamplingDecision + - TracePropagationOptions.traceContextProvider diff --git a/supabase/config.toml b/supabase/config.toml index 9143136d7..a28027524 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -149,7 +149,7 @@ max_catalogs = 2 # Store vector embeddings in S3 for large and durable datasets [storage.vector] -enabled = false +enabled = true max_buckets = 10 max_indexes = 5 @@ -367,7 +367,7 @@ email_optional = false # Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. # You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. [auth.web3.solana] -enabled = false +enabled = true # Use Firebase Auth as a third-party provider alongside Supabase Auth. [auth.third_party.firebase] diff --git a/supabase/migrations/20240101000000_postgrest_schema.sql b/supabase/migrations/20240101000000_postgrest_schema.sql index 8b10e1dba..13996f582 100644 --- a/supabase/migrations/20240101000000_postgrest_schema.sql +++ b/supabase/migrations/20240101000000_postgrest_schema.sql @@ -124,6 +124,18 @@ create table public.imported_data ( unique(external_id, source_system) ); +-- Long-running task for testing abortion +create or replace function public.long_running_task() +returns void as $$ +declare + start_time timestamp := clock_timestamp(); +begin + while clock_timestamp() < start_time + interval '10 seconds' loop + PERFORM pg_sleep(0.1); + end loop; +end; +$$ language plpgsql; + -- Enable PostgREST's EXPLAIN/plan output for the .explain() tests (the previous -- Docker setup set PGRST_DB_PLAN_ENABLED=1). PostgREST reads this from the -- authenticator role on startup.