diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 54810188a99e..a9f71d467a4d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -637,6 +637,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /src/sentry/api/urls.py @getsentry/owners-api /api-docs/ @getsentry/owners-api /tests/apidocs/ @getsentry/owners-api +/static/packages/api/ @getsentry/owners-api @getsentry/ocpo /.github/workflows/openapi.yml @getsentry/owners-api /.github/workflows/openapi-diff.yml @getsentry/owners-api /src/sentry/conf/api_pagination_allowlist_do_not_modify.py @getsentry/owners-api diff --git a/.github/file-filters.yml b/.github/file-filters.yml index e7c6196282c3..1d79ac16896a 100644 --- a/.github/file-filters.yml +++ b/.github/file-filters.yml @@ -162,6 +162,12 @@ api_docs: &api_docs - 'api-docs/**' - 'tests/apidocs/**' +# The @sentry/api package builds from the API spec, so it rebuilds whenever +# the spec might change, and when the package itself does. +api_client: + - *api_docs + - 'static/packages/api/**' + # This is the ultimate controller for backend.yml backend_any_type: &backend_any_type - *backend_all diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 84c4514e6496..7481870773f9 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -31,6 +31,7 @@ jobs: # Map a step output to a job output outputs: api_docs: ${{ steps.changes.outputs.api_docs }} + api_client: ${{ steps.changes.outputs.api_client }} backend: ${{ steps.changes.outputs.backend_all_without_acceptance }} backend_dependencies: ${{ steps.changes.outputs.backend_dependencies }} backend_api_urls: ${{ steps.changes.outputs.backend_api_urls }} @@ -50,7 +51,8 @@ jobs: filters: .github/file-filters.yml api-docs: - if: needs.files-changed.outputs.api_docs == 'true' + # api_client includes api_docs: the api-client job needs this job's spec. + if: needs.files-changed.outputs.api_client == 'true' needs: files-changed name: api docs test runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-26.04-arm' || 'ubuntu-24.04' }} @@ -83,6 +85,20 @@ jobs: - name: Build and validate API docs run: make validate-api-docs + - name: Build the RPC method schema + run: make build-rpc-schema + + - name: Collect the @sentry/api spec files + run: pnpm --filter @sentry/api run build:spec + + - name: Upload the @sentry/api spec files + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: api-spec + path: static/packages/api/spec/ + if-no-files-found: error + retention-days: 1 + - name: Wait for devservices run: python3 .github/actions/setup-sentry/devservices-bg.py wait @@ -96,6 +112,36 @@ jobs: devservices logs fi + # Builds, typechecks and tests the @sentry/api client against this commit's + # API spec, so a spec change that breaks the client cannot merge. + api-client: + if: needs.files-changed.outputs.api_client == 'true' + needs: [files-changed, api-docs] + name: api client + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-26.04-arm' || 'ubuntu-24.04' }} + timeout-minutes: 20 + env: + SENTRY_API_SPEC_DIR: ${{ github.workspace }}/.artifacts/api-spec + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - uses: ./.github/actions/setup-node-pnpm + + - name: Download the @sentry/api spec files + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: api-spec + path: ${{ env.SENTRY_API_SPEC_DIR }} + + - name: Build + run: pnpm --filter @sentry/api run build + + - name: Typecheck + run: pnpm --filter @sentry/api run typecheck + + - name: Test + run: pnpm --filter @sentry/api run test:vitest + # Selective testing - only on PRs, determine which tests to run based on coverage data. # This job is skipped on push-to-master where the full suite runs instead. select-tests: @@ -761,6 +807,7 @@ jobs: backend-required-check: needs: [ + api-client, api-docs, backend-test, backend-migration-tests, diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index dd0acd33f667..e981af078a1e 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -14,6 +14,8 @@ jobs: timeout-minutes: 90 permissions: contents: read + outputs: + api_client: ${{ steps.changes.outputs.api_client }} steps: - name: github app token id: app-token @@ -38,10 +40,10 @@ jobs: uses: ./.github/actions/setup-sentry with: mode: migrations - if: steps.changes.outputs.api_docs == 'true' + if: steps.changes.outputs.api_client == 'true' - name: Checkout getsentry/sentry-api-schema - if: steps.changes.outputs.api_docs == 'true' + if: steps.changes.outputs.api_client == 'true' uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: 'main' @@ -54,28 +56,105 @@ jobs: - uses: ./.github/actions/setup-node-pnpm - name: Build OpenAPI Derefed JSON - if: steps.changes.outputs.api_docs == 'true' + if: steps.changes.outputs.api_client == 'true' run: | make build-api-docs - name: Build RPC method schema - if: steps.changes.outputs.api_docs == 'true' - run: | - sentry rpcschema --loglevel=WARNING > rpc_method_schema.json + if: steps.changes.outputs.api_client == 'true' + run: make build-rpc-schema + + - name: Collect the @sentry/api spec files + if: steps.changes.outputs.api_client == 'true' + run: pnpm --filter @sentry/api run build:spec + + - name: Upload the @sentry/api spec files + if: steps.changes.outputs.api_client == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: api-spec + path: static/packages/api/spec/ + if-no-files-found: error + retention-days: 1 - name: Copy artifacts into getsentry/sentry-api-schema - if: steps.changes.outputs.api_docs == 'true' - run: | - cp tests/apidocs/openapi-derefed.json sentry-api-schema - cp src/sentry/apidocs/api_ownership_stats_dont_modify.json sentry-api-schema - cp rpc_method_schema.json sentry-api-schema + if: steps.changes.outputs.api_client == 'true' + run: cp static/packages/api/spec/* sentry-api-schema - name: Git Commit & Push uses: stefanzweifel/git-auto-commit-action@0049e3fa4059ca715255fbbcb7dea4516f02ce0a # v4.15.3 - if: steps.changes.outputs.api_docs == 'true' + if: steps.changes.outputs.api_client == 'true' with: repository: sentry-api-schema branch: main commit_message: Generated commit_user_email: 271575301+sentry-api-schema-updater[bot]@users.noreply.github.com commit_user_name: sentry-api-schema-updater[bot] + + # Releases @sentry/api (static/packages/api) when this commit changed what + # the package ships. The preflight script skips the run until a person has + # cut the first `api@` release with the Release workflow. + release-api-package: + needs: build_and_deref_json + # The spec files upload before the push to getsentry/sentry-api-schema, so + # a failed push does not hold up the release. + if: ${{ !cancelled() && needs.build_and_deref_json.outputs.api_client == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + # One release at a time, and never cancel a running one: it may have + # pushed its release branch already. A newer run replaces the one waiting, + # so a burst of merges releases once. + concurrency: + group: api-package-release + cancel-in-progress: false + steps: + - name: Get auth token + id: token + uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 + with: + app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} + owner: getsentry + repositories: | + sentry + publish + + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + token: ${{ steps.token.outputs.token }} + # Tags and history for the preflight and craft, without the blobs. + fetch-depth: 0 + filter: blob:none + + # Craft prepares from the current branch and expects the default one. + - name: Put master on this commit + run: git checkout -B master + + - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4 + with: + node-version-file: '.node-version' + + - name: Download the @sentry/api spec files + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: api-spec + path: ${{ runner.temp }}/api-spec + + - name: Check what to release + id: preflight + env: + SPEC_DIR: ${{ runner.temp }}/api-spec + GH_TOKEN: ${{ steps.token.outputs.token }} + run: .github/workflows/scripts/api-package-release-preflight.sh + + - name: Prepare release + if: steps.preflight.outputs.version != '' + uses: getsentry/craft@2e29b0819113d334c15da3d8cac2138cb9dc111a # 3.9.0 + env: + GITHUB_TOKEN: ${{ steps.token.outputs.token }} + with: + workspace: static/packages/api + version: ${{ steps.preflight.outputs.version }} + publish_repo: getsentry/publish diff --git a/.github/workflows/package-pack.yml b/.github/workflows/package-pack.yml index def4fa947fe3..daaa48bbe59b 100644 --- a/.github/workflows/package-pack.yml +++ b/.github/workflows/package-pack.yml @@ -38,18 +38,41 @@ jobs: runs-on: ubuntu-24.04 env: PACKAGE_DIR: static/packages/${{ needs.workspace.outputs.name }} + # @sentry/api builds from the API spec, which the Python app generates. + BUILDS_FROM_API_SPEC: ${{ needs.workspace.outputs.name == 'api' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup sentry python env + if: env.BUILDS_FROM_API_SPEC == 'true' + uses: ./.github/actions/setup-sentry + with: + skip-devservices: 'true' - uses: ./.github/actions/setup-node-pnpm - run: pnpm install --frozen-lockfile --ignore-scripts + - name: Configure sentry + if: env.BUILDS_FROM_API_SPEC == 'true' + run: sentry init + - name: Build the API spec + if: env.BUILDS_FROM_API_SPEC == 'true' + run: make build-api-docs build-rpc-schema + - name: Build the package + if: env.BUILDS_FROM_API_SPEC == 'true' + working-directory: ${{ env.PACKAGE_DIR }} + run: pnpm run build - name: Pack release artifact working-directory: ${{ env.PACKAGE_DIR }} run: pnpm pack --pack-destination .artifacts + # Craft's github target attaches every file in the artifact to the + # release; its npm target publishes only the tarball. + - name: Add the API spec files to the release artifact + if: env.BUILDS_FROM_API_SPEC == 'true' + working-directory: ${{ env.PACKAGE_DIR }} + run: cp spec/* .artifacts - name: Upload release artifact uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: # Craft fetches `{{workspaceName}}-tarball` from this workflow. name: ${{ needs.workspace.outputs.name }}-tarball - path: ${{ env.PACKAGE_DIR }}/.artifacts/*.tgz + path: ${{ env.PACKAGE_DIR }}/.artifacts/* include-hidden-files: true if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba8f73386576..78648e1310f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,7 @@ on: default: sentry options: - sentry + - static/packages/api - static/packages/scraps - static/packages/icons - static/packages/search-syntax diff --git a/.github/workflows/scripts/api-package-release-preflight.sh b/.github/workflows/scripts/api-package-release-preflight.sh new file mode 100755 index 000000000000..75f37a0a4a64 --- /dev/null +++ b/.github/workflows/scripts/api-package-release-preflight.sh @@ -0,0 +1,104 @@ +#!/bin/sh +# Decides whether openapi.yml releases @sentry/api from HEAD, a master commit +# whose spec files are in SPEC_DIR. To release, writes the version bump for +# craft to $GITHUB_OUTPUT as version=. To skip, writes nothing. +# +# The release-branch and tag checks follow getsentry/sentry-api-schema#137's +# release-preflight.sh, so a burst of merges releases once and nothing that +# lands while a release is pending is left unreleased. +# +# Environment: +# SPEC_DIR The package's spec/ files built from HEAD. +# GH_TOKEN A token that can read this repository's releases. +# GITHUB_REPOSITORY Set by GitHub Actions. +# GITHUB_OUTPUT Set by GitHub Actions. +set -eu + +package_dir=static/packages/api +tag_prefix=api@ + +skip() { + printf '::notice::%s\n' "$1" + exit 0 +} + +if [ ! -f "$SPEC_DIR/openapi-derefed.json" ]; then + printf '::error::%s has no openapi-derefed.json.\n' "$SPEC_DIR" + exit 1 +fi + +# Automatic releases start after a person cuts the first one from here. +if ! base=$(git describe --tags --abbrev=0 --match "$tag_prefix*" 2>/dev/null); then + skip "No $tag_prefix release yet. Cut the first one with the Release workflow." +fi + +# Craft refuses to cut a release while one is pending. When that release +# merges into master, the run for the merge releases whatever landed meanwhile. +pending=$(git ls-remote --heads origin "refs/heads/${tag_prefix%@}/release/*") +if [ -n "$pending" ]; then + skip "A release is still pending: $pending" +fi + +# Craft takes the next version from the latest tag in HEAD's history. A newer +# tag outside it means HEAD predates that release, and the run for that +# release's merge covers this commit. +tags=$(git tag --list "$tag_prefix*" --sort=-version:refname) +newest=${tags%%[[:space:]]*} +if [ "$newest" != "$base" ]; then + skip "HEAD predates release $newest." +fi + +released=$(mktemp -d) +trap 'rm -rf "$released"' EXIT +trap 'exit 1' INT TERM +# The last release's GitHub release has the spec files it was built from as +# assets. Craft's github target attaches them before craft merges the release +# branch, so a run for that merge sees them. A release without them cannot be +# compared, and treating every file as changed would cut a release for nothing. +json_assets=$(timeout 60 gh release view "$base" --repo "$GITHUB_REPOSITORY" \ + --json assets --jq '[.assets[].name | select(endswith(".json"))] | length') +if [ "$json_assets" -eq 0 ]; then + printf '::error::Release %s has no spec files attached to compare with.\n' "$base" + exit 1 +fi +timeout 300 gh release download "$base" --repo "$GITHUB_REPOSITORY" \ + --pattern '*.json' --dir "$released" + +spec_changed=false +data_changed=false +for file in "$SPEC_DIR"/*; do + name=${file##*/} + # cmp exits 1 when the files differ and 2 when the release lacks the file. + if ! cmp -s "$file" "$released/$name"; then + if [ "$name" = openapi-derefed.json ]; then + spec_changed=true + else + data_changed=true + fi + fi +done + +if git diff --quiet "$base" HEAD -- "$package_dir"; then + package_changed=false +else + status=$? + if [ "$status" -ne 1 ]; then + exit "$status" + fi + package_changed=true +fi + +# Spec updates are minor releases, as they were in sentry-api-schema. Package +# changes take craft's bump from their commits. Ownership or RPC schema +# changes alone are patches. +if [ "$spec_changed" = true ]; then + bump='minor' +elif [ "$package_changed" = true ]; then + bump='auto' +elif [ "$data_changed" = true ]; then + bump='patch' +else + skip "Nothing changed since $base." +fi +printf '::notice::Releasing a %s bump on %s.\n' "$bump" "$base" +printf 'version=%s\n' "$bump" >>"$GITHUB_OUTPUT" diff --git a/.oxfmtrc.json b/.oxfmtrc.json index ce604276904f..3a626729c62d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -116,6 +116,7 @@ ".vscode/**", "static/app/data/world.json", "tests/sentry/grouping/**/*.json", - "pyproject.toml" + "pyproject.toml", + "static/packages/api/**/*" ] } diff --git a/Makefile b/Makefile index 82efda9be620..8d2a341a808d 100644 --- a/Makefile +++ b/Makefile @@ -74,6 +74,11 @@ build-api-docs: build-deprecated-docs build-spectacular-docs @echo "--> Dereference the json schema for ease of use" pnpm run deref-api-docs +# Ships in the @sentry/api package (static/packages/api) next to the API spec. +build-rpc-schema: + @mkdir -p .artifacts + sentry rpcschema --loglevel=WARNING > .artifacts/rpc_method_schema.json + # Opt-in private schema for local review; never included in the public docs build. build-investigation-api-docs: build-deprecated-docs @mkdir -p .artifacts/investigation-api-docs diff --git a/knip.config.ts b/knip.config.ts index 6cc7f67e47f3..b144d92e6fee 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -61,6 +61,9 @@ const config: KnipConfig = { compilers: {mdx: true}, // Test package exports are development entry points, not production code. ignoreWorkspaces: ['tests/js!', 'tests/js/getsentry-test!'], + // @sentry/api is generated from the API spec at build time, so its sources + // and entry points only exist after `pnpm --filter @sentry/api run build`. + ignore: ['static/packages/api/**'], workspaces: { '.': { // The root runs app tests, but Jest is declared in the test package. diff --git a/oxlint.config.ts b/oxlint.config.ts index 3dc053c9dcd4..912aaf386ab1 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -300,6 +300,7 @@ const config = defineConfig({ 'boundaries/legacy-warnings': false, }, ignorePatterns: [ + 'static/packages/api/**/*', 'static/packages/icons/**/*', 'static/packages/scraps/**/*', 'static/packages/search-syntax/**/*', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24d5e570d0ce..1978accfa6df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,7 @@ overrides: '@testing-library/jest-dom>aria-query': 5.3.0 diff@>=4.0.0 <4.0.4: 4.0.4 diff@>=5.0.0 <5.2.2: 5.2.2 + js-yaml@>=4.0.0 <4.3.2: 4.3.2 packageExtensionsChecksum: sha256-XTqHXDauTuNpwcqnc3uTl9MDvmigagFl07khzrasuew= @@ -901,7 +902,7 @@ importers: devDependencies: '@testing-library/jest-dom': specifier: 7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.1.0(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(jsdom@30.1.1(@noble/hashes@1.8.0))(vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0))) '@types/lodash': specifier: ^4.17.24 version: 4.17.24 @@ -1044,6 +1045,43 @@ importers: specifier: 1.90.0 version: 1.90.0(oxlint-tsgolint@7.0.2005) + static/packages/api: + dependencies: + http-cache-semantics: + specifier: ^4.2.0 + version: 4.3.0 + p-limit: + specifier: ^7.3.3 + version: 7.3.3 + devDependencies: + '@hey-api/openapi-ts': + specifier: 0.98.0 + version: 0.98.0(@typescript/typescript6@6.0.2) + '@types/http-cache-semantics': + specifier: 4.2.0 + version: 4.2.0 + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + esbuild: + specifier: 0.28.1 + version: 0.28.1 + fast-check: + specifier: 4.10.2 + version: 4.10.2 + typescript: + specifier: npm:@typescript/typescript6@6.0.2 + version: '@typescript/typescript6@6.0.2' + valibot: + specifier: 1.4.2 + version: 1.4.2(@typescript/typescript6@6.0.2) + vitest: + specifier: 5.1.0 + version: 5.1.0(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(jsdom@30.1.1(@noble/hashes@1.8.0))(vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0)) + zod: + specifier: 4.6.7 + version: 4.6.7 + static/packages/icons: dependencies: '@emotion/react': @@ -1082,7 +1120,7 @@ importers: version: 10.4.1 '@testing-library/jest-dom': specifier: 7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.1.0(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(jsdom@30.1.1(@noble/hashes@1.8.0))(vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0))) '@testing-library/react': specifier: 16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -1311,7 +1349,7 @@ importers: version: 10.4.1 '@testing-library/jest-dom': specifier: 7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.1.0(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(jsdom@30.1.1(@noble/hashes@1.8.0))(vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0))) '@testing-library/react': specifier: 16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -1455,7 +1493,7 @@ importers: version: 10.4.1 '@testing-library/jest-dom': specifier: 7.0.1 - version: 7.0.1(@testing-library/dom@10.4.1) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.1.0(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(jsdom@30.1.1(@noble/hashes@1.8.0))(vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0))) '@testing-library/react': specifier: 16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -2039,6 +2077,9 @@ packages: '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/core@2.0.0-beta.2': + resolution: {integrity: sha512-MzCVoWtcg4Pm2B3je5mMvYMvj3MmBKw+fU7vbsJc9/iT8ip6/eYIHo/Dsm/mr+wsHB6SEyJDuxB09Yy6r/8+4A==} + '@emnapi/core@2.0.0-beta.3': resolution: {integrity: sha512-KA+gOBtbifRATmUa/Gpf5C2B2EJ0r4brHUT3XSgACeHREcuiObXH56xd+B3fFsEFbUT04VBEe6MuZiCtMp0tQg==} @@ -2048,6 +2089,9 @@ packages: '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@2.0.0-beta.2': + resolution: {integrity: sha512-JcrSPzPvZuNZvJqPTF9Fw25LbOJ8avzFVlaf2grmH4Cku0oGP47tRgqPZadcfj3YoZMHkFVnCYFbQM8I+i5QEw==} + '@emnapi/runtime@2.0.0-beta.3': resolution: {integrity: sha512-ayb/PyyLzpwRyFVxeC0uoEHu59QlB1iWGcGsOpXQcaB/Dc+uGfBmNmpCHp3HmTT0t8lSsMmg3j7+tdJW/TlHjQ==} @@ -2366,6 +2410,31 @@ packages: '@formatjs/intl-localematcher@0.6.1': resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==} + '@hey-api/codegen-core@0.8.3': + resolution: {integrity: sha512-vvyOPdlzwzgKtF3B+CrUwSVSyQKRBXa8cFMu6FprXSqlh9eGglRudfI64Ello91WzlroA3d4g3tnpYtZRUJaZA==} + engines: {node: '>=22.18.0'} + + '@hey-api/json-schema-ref-parser@1.4.3': + resolution: {integrity: sha512-UzGSDzh3QUhrnwl4atnHc2YqDO6KemYVEOwl1Ynowm/tcr0XlpdHOpyWr5UaWIJfiXTXdYRIC9k2Yxm19pcPzQ==} + engines: {node: '>=22.18.0'} + + '@hey-api/openapi-ts@0.98.0': + resolution: {integrity: sha512-Q5a29i8/kENYhihnNlG11kLT6BHBOkeqqehG46FnFmtdINPBpFsaVvWlTjJsAArG7AbgxLazzc0v5HXgSPpQSg==} + engines: {node: '>=22.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc' + + '@hey-api/shared@0.4.6': + resolution: {integrity: sha512-wsYIlkRswQ21qbhrPAA33Cc1HqiP1+kRINStAnGEAuPabU8TRfUzqBIEvuTlunkcNUkraqT5ijEYOT0IZUyVzA==} + engines: {node: '>=22.18.0'} + + '@hey-api/spec-types@0.2.0': + resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==} + + '@hey-api/types@0.1.4': + resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -2679,6 +2748,10 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@mdx-js/loader@3.1.1': resolution: {integrity: sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ==} peerDependencies: @@ -2697,6 +2770,13 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@napi-rs/wasm-runtime@1.2.6': + resolution: {integrity: sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || 2.0.0-beta.2 + '@emnapi/runtime': ^1.7.1 || 2.0.0-beta.2 + '@napi-rs/wasm-runtime@1.2.7': resolution: {integrity: sha512-zJccwplOAbgfZju95IUHazLnx9RAPtPN2YGJXI6UbrgeaR9QfPjGrQL6ERkSAtnVCA4Q+83ApGyaX5zdpCaKkQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -3116,6 +3196,9 @@ packages: '@oxc-project/types@0.154.0': resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} cpu: [arm] @@ -3842,6 +3925,110 @@ packages: '@remix-run/route-pattern@0.22.1': resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==} + '@rolldown/binding-android-arm-eabi@1.2.19': + resolution: {integrity: sha512-e+Ebe0WT+9tbXmNKPIiC8YrWAHYwug3m6i4p3rZGGulfZri1jtI4HslT+OZnFf/OIHowWF71DTlHwXBP2SiA7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.19': + resolution: {integrity: sha512-hOombrTJ+szt2ciEzS7oCA78lMPBgF5UMQWCJIyNfnrI7GLl8HYoFqCnpL2gcPOaXMqryGCU5ExdQKyDDcC+yA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.19': + resolution: {integrity: sha512-XoMA2pwCKGTQzUjPgtRwkedH4WmXhdIPKT6l6oOxd1PUbNodGI6DcBekgN7EC//+x3pfBYX/qL1gNmZAqivw5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.19': + resolution: {integrity: sha512-zo8EsSSwMZCY/e+fRnGaX02YGgvJD0yvhNi/9hpGPA2MJ9ZF8dPCw75UGfpI6OpQ/myR/dIYmogRvqiZD0TrXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.19': + resolution: {integrity: sha512-O8ISAOfI1nj7AuBPd30FSF47z7f7lzlk5poQVGgiikvQuGEBFYvW8ki+UEnT+YJPnNGjQzn304klt4O9FUE7mA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.19': + resolution: {integrity: sha512-/lYCba5NO0yq4S0jGMH8QTBtkvQxoXjoQWzF5BcWp9MjB9jxVRD1GQDNofkVjcOj30DWyhLIN/4Oe60JKzaONw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.19': + resolution: {integrity: sha512-pm/apFMBnSbFb3O6nG5pHO1iqHhZrjGDFEjSm+ot9J17XXPvH9L90y5h+jgfHCE+Qdlsvy8qKEcU4VKxgjD+vA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.19': + resolution: {integrity: sha512-K1SmQp3FFichn8oqrNIwvFoZ5wcvjC7goBzpaMNvLf9FZc+QB/QvLmnpJZgAAGHiD980tP8FZZnXk2K94rvlxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.19': + resolution: {integrity: sha512-I0xwD57jP/3A7+5UPZin2w8Y7XHEmEM5zqpYZOcQ45MlNW6tUJUziymV3yRr7rL4vwyfEcRsRksC4a7Kc08p5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.19': + resolution: {integrity: sha512-BrtEHT24SNXj9HiXqy+s/kXiSiQGt6EKCPUlRirAoIioVxKGnbXBUEkbZASAei7iZJB0/Ynm+0JP00Ipx7AnSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.19': + resolution: {integrity: sha512-rm75/9DkHKeU2WnA8w4X94WGYgLlDCFCfkQC+4bguVJmC/jvDRv2B+2wLOYhyE0fVuv7//Ckk39OoOufyCu76Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.19': + resolution: {integrity: sha512-irh/4+iYFM4GAzpluDErgjz89bj6NDtqqMAG/6O1LG38Ed3NpyebzCpcVNkFW8uhy+/3/BocUvnXDR33CXWe9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.19': + resolution: {integrity: sha512-+KWTWNoPt+CfSq94CiceWu3yPE+QzkaG+phPyz9G60tmZLwfQkcA4AeksafzvJAw4pvg1mcd5a1TkUqVv0s4JA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.2.19': + resolution: {integrity: sha512-7EH+skOsl5qpWnK0vzamgJFhOv1cwxQpsxm8tI883K9fur5c27p8q87fzEO6BbBPabA2/+s/zR63/pLc137iOg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.2.19': + resolution: {integrity: sha512-3Q1Yg1Q7tEvBWoZf5rFvG/GKZQPgwfru+p4wBajDfJFBs0SHCpL++dfTjW9gyAKd1T7ZpIYvdaUax7Pa/IhFVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.19': + resolution: {integrity: sha512-y/sqRRc6d7hECzOFc5n3LSeqctmIp9mMjY1Kq4s14uLPd/LZ4z7i1zrW6fMtp2mPeuQIC4e6ULMcwQzMomqQag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.1.0': + resolution: {integrity: sha512-K+NU2HDCEem4sClgHH4ZZXwdJeNFkJo+hRk479DIwdIviAPN2hFJH1KHHwg8CxHQXvi0M5XY4deTXINfIiSG6w==} + '@rsbuild/core@2.3.2': resolution: {integrity: sha512-1mjHhBYX1Z+knee55SV/+6KsEVmdjEQ1h73VzXUQYD+sfncuN+6+duVx7nYYVXz1zBP4QfpQt+74gTNAxKz+Hg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4845,6 +5032,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/css-font-loading-module@0.0.7': resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==} @@ -4863,6 +5053,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -4890,6 +5083,9 @@ packages: '@types/html-minifier-terser@6.1.0': resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/invariant@2.2.37': resolution: {integrity: sha512-IwpIMieE55oGWiXkQPSBY1nw1nFs6bsKXTFskNY8sdS17K24vyEBRQZEwlRS7ZmXCWnJcQtbxWzly+cODWGs2A==} @@ -5284,6 +5480,20 @@ packages: cpu: [x64] os: [win32] + '@vitest/mocker@5.1.0': + resolution: {integrity: sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.1.0': + resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==} + '@xstate/fsm@1.6.5': resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} @@ -5334,6 +5544,10 @@ packages: algoliasearch@4.13.1: resolution: {integrity: sha512-dtHUSE0caWTCE7liE1xaL+19AFf6kWEcyn76uhcitWpntqvicFHXKFoZe5JJcv9whQOTRM6+B8qJz6sFj+rDJA==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -5420,6 +5634,10 @@ packages: resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} engines: {node: '>=12.0.0'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -5533,10 +5751,22 @@ packages: resolution: {integrity: sha512-c5JxaDrzwRjq3WyJkI1AGR5xy6Gr6udlt7sQPbl09+3ckB+Zo2qqQ2KhCTBr7Q8dHB43bENGYEk4xddrFH/b7A==} engines: {node: '>=18.20'} + bundle-name@4.1.1: + resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} + engines: {node: '>=18'} + bytestreamjs@2.0.1: resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} engines: {node: '>=6.0.0'} + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cacheable@2.5.0: resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} @@ -5587,6 +5817,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -5614,6 +5848,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -5669,6 +5907,10 @@ packages: resolution: {integrity: sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==} engines: {node: '>=18'} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + color@5.0.2: resolution: {integrity: sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==} engines: {node: '>=18'} @@ -5728,6 +5970,12 @@ packages: react: '>=18' react-dom: '>=18' + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + confbox@0.3.2: + resolution: {integrity: sha512-ZUAKhKlkCOrIGKc8cj7zek9s9z1mveHif/25nUshI2BzCRzF7KH7KHa+Lnh2ubq+e1tIlaFuP763mzW4qVoehg==} + config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -5943,10 +6191,22 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} + engines: {node: '>=18'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -5962,6 +6222,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-indent@7.0.2: resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} engines: {node: '>=12.20'} @@ -6405,12 +6668,19 @@ packages: expressive-code@0.44.2: resolution: {integrity: sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==} + exsolve@1.2.0: + resolution: {integrity: sha512-h1pQPR9XoeSMdntRNCjyTznaume4UpfXrULBnlkZ2CUM/G5eSa+pBByGldO5uR8dxbPC+OuJXsPPNf+JZ81ADg==} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -6599,6 +6869,9 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} @@ -6606,6 +6879,10 @@ packages: resolution: {integrity: sha512-ZLeqWPz9OMNrTgMuww0C22kkcNqis+e4059R94t7L7ERlZ2rUNpiDbaAUus+esBC6uBAQWbS9N+R5vJIJg//lw==} engines: {node: '>=20'} + giget@3.3.1: + resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} + hasBin: true + gl-matrix@3.4.4: resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} @@ -6813,6 +7090,9 @@ packages: htmlparser2@6.1.0: resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} + http-cache-semantics@4.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -6960,6 +7240,11 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -6987,6 +7272,15 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -7061,6 +7355,10 @@ packages: resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} engines: {node: '>=12.13'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@0.0.1: resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} @@ -7384,6 +7682,80 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -7443,6 +7815,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.5: + resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -7784,6 +8159,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -7905,10 +8285,17 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + odiff-bin@4.3.8: resolution: {integrity: sha512-nEGbO932GgDZUT6KNI30wio+JaNhLHGbeXrDnYQF4UeSmroC55w8wRXqOAYqGJXk2xFK72RxxLnGofofwV+eDQ==} hasBin: true + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -7922,6 +8309,10 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + openapi-examples-validator@6.0.3: resolution: {integrity: sha512-tpFiz0bwCzi6pp1+rYGFArjaZ4L+iChIszMk20cR3mujLYah+taxkC6c6cijX1xnmlNQNSJkAiEwtvLOy9xyZQ==} engines: {node: '>=18'} @@ -7995,6 +8386,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -8080,11 +8475,17 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peggy@5.1.1: resolution: {integrity: sha512-HhhbwH1MoCq9YNqeC2n1ap6YzLSL1YUgUxGoeiXcmGOlAYTqxwmifPJ7gFZtkR/S2+KXZ6BkJ5FfycTtq9oCRQ==} engines: {node: '>=22'} hasBin: true + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -8104,6 +8505,9 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-types@2.4.0: + resolution: {integrity: sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==} + pkijs@3.4.1: resolution: {integrity: sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==} engines: {node: '>=16.0.0'} @@ -8167,6 +8571,14 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -8221,6 +8633,9 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -8252,6 +8667,9 @@ packages: resolution: {integrity: sha512-Y3NoRtprEEZQD8RfxMCfS0ZTqc4e+i18OrXEXAvpM6TfC/3y+0L5rNbZiSnbBBEkDfFzbpd8o+cE8q3/anjMGA==} engines: {node: '>=22'} + rc9@3.1.0: + resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==} + re2js@2.8.7: resolution: {integrity: sha512-bRSL/vn3KAmlGfGNoVpcsBXLaVvl6AbuvMMWbz0m3vBj60nj+p2/biozfBUOSBP+5XTduF6mr6IO/ZVjmqqkVQ==} engines: {node: '>=20.0.0'} @@ -8397,6 +8815,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + recma-build-jsx@1.0.0: resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} @@ -8539,6 +8961,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.2.19: + resolution: {integrity: sha512-QIiV1J9d0pzN4jeeTH4sjPy0AZ6cAy2cG0ExeoE1+U8Rvj+hR3gYkokx0YbeUNGlZqiQJJ2utcETe0oKXwzsuA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rsbuild-plugin-dts@1.0.6: resolution: {integrity: sha512-KvSmhCfqZpNCF8F+xw40uFYZrp4SR264QvsjBLoXobMEuSVb/LffvfjAk/TqJ1LmWI9B7eGWeKMBOFIW5VIScQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8556,6 +8983,10 @@ packages: resolution: {integrity: sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==} engines: {node: 6.* || >= 7.*} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-on-changed@0.4.0: resolution: {integrity: sha512-W8oqSnqT6Y7EtU7M7sSrTXbuEiPAkJ5JwoWEAMtcAvP3acTeVieCObWuPyw8dKI0bWjeheVC+ZkaeEhPs/LbIg==} engines: {node: '>=20.19.0'} @@ -8621,6 +9052,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -8832,6 +9268,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -9009,6 +9448,14 @@ packages: resolution: {integrity: sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==} engines: {node: '>=0.12'} + tinybench@6.2.3: + resolution: {integrity: sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -9272,6 +9719,14 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -9281,6 +9736,90 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.3.8: + resolution: {integrity: sha512-LzOn3h9rG/0vhLdXrV4yJ4QFA9eEoqCl+Yx+kGJd7aB9w6nfNgVHKSYwLPZUHrz4ZGa+t+kXjV9itvcHDLQouA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.1.0: + resolution: {integrity: sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.1.0 + '@vitest/browser-preview': 5.1.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.1.0 + '@vitest/coverage-v8': 5.1.0 + '@vitest/ui': 5.1.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-xmlserializer@6.0.0: resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -9342,6 +9881,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -9376,6 +9920,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -9424,6 +9972,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + zod@4.6.7: resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} @@ -9970,6 +10522,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@2.0.0-beta.2': + dependencies: + '@emnapi/wasi-threads': 2.2.0 + tslib: 2.8.1 + optional: true + '@emnapi/core@2.0.0-beta.3': dependencies: '@emnapi/wasi-threads': 2.2.0 @@ -9986,6 +10544,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@2.0.0-beta.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@2.0.0-beta.3': dependencies: tslib: 2.8.1 @@ -10293,6 +10856,56 @@ snapshots: dependencies: tslib: 2.8.1 + '@hey-api/codegen-core@0.8.3': + dependencies: + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + c12: 3.3.4 + color-support: 1.1.3 + transitivePeerDependencies: + - magicast + + '@hey-api/json-schema-ref-parser@1.4.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.3.2 + + '@hey-api/openapi-ts@0.98.0(@typescript/typescript6@6.0.2)': + dependencies: + '@hey-api/codegen-core': 0.8.3 + '@hey-api/json-schema-ref-parser': 1.4.3 + '@hey-api/shared': 0.4.6 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + '@lukeed/ms': 2.0.2 + ansi-colors: 4.1.3 + color-support: 1.1.3 + commander: 14.0.3 + get-tsconfig: 4.14.0 + typescript: '@typescript/typescript6@6.0.2' + transitivePeerDependencies: + - magicast + + '@hey-api/shared@0.4.6': + dependencies: + '@hey-api/codegen-core': 0.8.3 + '@hey-api/json-schema-ref-parser': 1.4.3 + '@hey-api/spec-types': 0.2.0 + '@hey-api/types': 0.1.4 + ansi-colors: 4.1.3 + cross-spawn: 7.0.6 + open: 11.0.0 + semver: 7.7.4 + transitivePeerDependencies: + - magicast + + '@hey-api/spec-types@0.2.0': + dependencies: + '@hey-api/types': 0.1.4 + + '@hey-api/types@0.1.4': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -10718,6 +11331,8 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@lukeed/ms@2.0.2': {} + '@mdx-js/loader@3.1.1(supports-color@8.1.1)': dependencies: '@mdx-js/mdx': 3.1.1(supports-color@8.1.1) @@ -10769,6 +11384,13 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true + '@napi-rs/wasm-runtime@1.2.6(@emnapi/core@2.0.0-beta.2)(@emnapi/runtime@2.0.0-beta.2)': + dependencies: + '@emnapi/core': 2.0.0-beta.2 + '@emnapi/runtime': 2.0.0-beta.2 + '@tybys/wasm-util': 0.10.4 + optional: true + '@napi-rs/wasm-runtime@1.2.7(@emnapi/core@2.0.0-beta.3)(@emnapi/runtime@2.0.0-beta.3)': dependencies: '@emnapi/core': 2.0.0-beta.3 @@ -10985,6 +11607,8 @@ snapshots: '@oxc-project/types@0.154.0': {} + '@oxc-project/types@0.156.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true @@ -11581,6 +12205,60 @@ snapshots: '@remix-run/route-pattern@0.22.1': {} + '@rolldown/binding-android-arm-eabi@1.2.19': + optional: true + + '@rolldown/binding-android-arm64@1.2.19': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.19': + optional: true + + '@rolldown/binding-darwin-x64@1.2.19': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.19': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.19': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.19': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.19': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.19': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.19': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.19': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.19': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.19': + optional: true + + '@rolldown/binding-wasm32-wasi@1.2.19': + dependencies: + '@emnapi/core': 2.0.0-beta.2 + '@emnapi/runtime': 2.0.0-beta.2 + '@napi-rs/wasm-runtime': 1.2.6(@emnapi/core@2.0.0-beta.2)(@emnapi/runtime@2.0.0-beta.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.19': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.19': + optional: true + + '@rolldown/pluginutils@1.1.0': {} + '@rsbuild/core@2.3.2(core-js@3.45.0)': dependencies: '@rspack/core': 2.3.2(@swc/helpers@0.5.24) @@ -12495,7 +13173,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.1.0(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(jsdom@30.1.1(@noble/hashes@1.8.0))(vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0)))': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.1 @@ -12504,6 +13182,8 @@ snapshots: dom-accessibility-api: 0.6.3 picocolors: 1.1.1 redent: 3.0.0 + optionalDependencies: + vitest: 5.1.0(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(jsdom@30.1.1(@noble/hashes@1.8.0))(vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0)) '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: @@ -12561,6 +13241,11 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/css-font-loading-module@0.0.7': {} '@types/d3-color@3.1.3': {} @@ -12580,6 +13265,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree-jsx@1.0.5': @@ -12605,6 +13292,8 @@ snapshots: '@types/html-minifier-terser@6.1.0': {} + '@types/http-cache-semantics@4.2.0': {} + '@types/invariant@2.2.37': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -12904,6 +13593,17 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vitest/mocker@5.1.0(vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.1.0 + estree-walker: 3.0.3 + magic-string: 1.4.5 + optionalDependencies: + vite: 8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0) + + '@vitest/spy@5.1.0': {} + '@xstate/fsm@1.6.5': {} abbrev@5.0.0: {} @@ -12969,6 +13669,8 @@ snapshots: '@algolia/requester-node-http': 4.13.1 '@algolia/transporter': 4.13.1 + ansi-colors@4.1.3: {} + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -13076,6 +13778,8 @@ snapshots: pvutils: 1.2.0 tslib: 2.8.1 + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} astring@1.9.0: {} @@ -13206,8 +13910,27 @@ snapshots: builtin-modules@5.1.0: {} + bundle-name@4.1.1: + dependencies: + run-applescript: 7.1.0 + bytestreamjs@2.0.1: {} + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.2.0 + giget: 3.3.1 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.4.0 + rc9: 3.1.0 + cacheable@2.5.0: dependencies: '@cacheable/memory': 2.2.0 @@ -13258,6 +13981,8 @@ snapshots: ccount@2.0.1: {} + chai@6.3.0: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -13279,6 +14004,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + ci-info@4.4.0: {} cjs-module-lexer@2.2.1: {} @@ -13327,6 +14056,8 @@ snapshots: dependencies: color-name: 2.0.2 + color-support@1.1.3: {} + color@5.0.2: dependencies: color-convert: 3.1.2 @@ -13366,6 +14097,10 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) + confbox@0.2.4: {} + + confbox@0.3.2: {} + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -13571,12 +14306,21 @@ snapshots: deepmerge@4.3.1: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.1: + dependencies: + bundle-name: 4.1.1 + default-browser-id: 5.0.1 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -13589,6 +14333,8 @@ snapshots: dequal@2.0.3: {} + destr@2.0.5: {} + detect-indent@7.0.2: {} detect-libc@2.1.2: {} @@ -14229,12 +14975,18 @@ snapshots: '@expressive-code/plugin-shiki': 0.44.2 '@expressive-code/plugin-text-markers': 0.44.2 + exsolve@1.2.0: {} + ext@1.7.0: dependencies: type: 2.7.3 extend@3.0.2: {} + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + fast-deep-equal@3.1.3: {} fast-equals@4.0.3: {} @@ -14423,6 +15175,10 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 @@ -14432,6 +15188,8 @@ snapshots: content-type: 1.0.5 encoding: 0.1.13 + giget@3.3.1: {} + gl-matrix@3.4.4: {} glob-parent@5.1.2: @@ -14730,6 +15488,8 @@ snapshots: domutils: 2.7.0 entities: 2.2.0 + http-cache-semantics@4.3.0: {} + https-proxy-agent@5.0.1(supports-color@8.1.1): dependencies: agent-base: 6.0.2(supports-color@8.1.1) @@ -14870,6 +15630,8 @@ snapshots: is-decimal@2.0.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -14890,6 +15652,12 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -14952,6 +15720,10 @@ snapshots: is-what@4.1.16: {} + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@0.0.1: {} isarray@1.0.0: {} @@ -15501,6 +16273,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lines-and-columns@1.2.4: {} locate-path@5.0.0: @@ -15551,6 +16372,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.5: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + make-dir@4.0.0: dependencies: semver: 7.8.5 @@ -16164,6 +16989,8 @@ snapshots: nanoid@3.3.18: {} + nanoid@3.3.20: {} + napi-postinstall@0.3.4: {} native-promise-only@0.8.1: {} @@ -16271,8 +17098,12 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + obug@3.0.0: {} + odiff-bin@4.3.8: {} + ohash@2.0.12: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -16289,6 +17120,15 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@11.0.0: + dependencies: + default-browser: 5.5.1 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + openapi-examples-validator@6.0.3: dependencies: ajv: 8.20.0 @@ -16482,6 +17322,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -16577,12 +17421,16 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + peggy@5.1.1: dependencies: '@peggyjs/from-mem': 4.0.0 commander: 14.0.3 source-map-generator: 2.0.7 + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -16595,6 +17443,12 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-types@2.4.0: + dependencies: + confbox: 0.3.2 + exsolve: 1.2.0 + pathe: 2.0.3 + pkijs@3.4.1: dependencies: '@noble/hashes': 1.8.0 @@ -16654,6 +17508,14 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + powershell-utils@0.1.0: {} + prelude-ls@1.2.1: {} pretty-error@4.0.0: @@ -16712,6 +17574,8 @@ snapshots: pure-rand@7.0.1: {} + pure-rand@8.4.2: {} + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 @@ -16742,6 +17606,11 @@ snapshots: quote-js-string@0.1.0: {} + rc9@3.1.0: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + re2js@2.8.7: {} react-aria@3.50.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): @@ -16924,6 +17793,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.1.1: {} + recma-build-jsx@1.0.0: dependencies: '@types/estree': 1.0.9 @@ -17130,6 +18001,28 @@ snapshots: reusify@1.1.0: {} + rolldown@1.2.19: + dependencies: + '@oxc-project/types': 0.156.0 + '@rolldown/pluginutils': 1.1.0 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.19 + '@rolldown/binding-android-arm64': 1.2.19 + '@rolldown/binding-darwin-arm64': 1.2.19 + '@rolldown/binding-darwin-x64': 1.2.19 + '@rolldown/binding-freebsd-x64': 1.2.19 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.19 + '@rolldown/binding-linux-arm64-gnu': 1.2.19 + '@rolldown/binding-linux-arm64-musl': 1.2.19 + '@rolldown/binding-linux-ppc64-gnu': 1.2.19 + '@rolldown/binding-linux-s390x-gnu': 1.2.19 + '@rolldown/binding-linux-x64-gnu': 1.2.19 + '@rolldown/binding-linux-x64-musl': 1.2.19 + '@rolldown/binding-openharmony-arm64': 1.2.19 + '@rolldown/binding-wasm32-wasi': 1.2.19 + '@rolldown/binding-win32-arm64-msvc': 1.2.19 + '@rolldown/binding-win32-x64-msvc': 1.2.19 + rsbuild-plugin-dts@1.0.6(@rsbuild/core@2.3.2(core-js@3.45.0))(@typescript/typescript6@6.0.2): dependencies: '@ast-grep/napi': 0.51.0 @@ -17139,6 +18032,8 @@ snapshots: rsvp@4.8.5: {} + run-applescript@7.1.0: {} + run-on-changed@0.4.0(supports-color@8.1.1): dependencies: debug-for-file: 0.4.1(supports-color@8.1.1) @@ -17220,6 +18115,8 @@ snapshots: semver@6.3.1: {} + semver@7.7.4: {} + semver@7.8.5: {} sentry@0.49.0: {} @@ -17414,6 +18311,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + std-env@4.3.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -17667,6 +18566,10 @@ snapshots: es5-ext: 0.10.64 next-tick: 1.1.0 + tinybench@6.2.3: {} + + tinyexec@1.3.1: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -17979,6 +18882,10 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valibot@1.4.2(@typescript/typescript6@6.0.2): + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -17994,6 +18901,45 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.32 + rolldown: 1.2.19 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.4 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + less: 4.9.1(supports-color@8.1.1) + terser: 5.51.2 + yaml: 2.9.0 + + vitest@5.1.0(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(jsdom@30.1.1(@noble/hashes@1.8.0))(vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.1.0(vite@8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.5 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.8(@types/node@26.6.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.9.1(supports-color@8.1.1))(terser@5.51.2)(yaml@2.9.0) + why-is-node-running: 3.2.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.6.4 + jsdom: 30.1.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - msw + w3c-xmlserializer@6.0.0: dependencies: xml-name-validator: 5.0.0 @@ -18082,6 +19028,8 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@3.2.1: {} + word-wrap@1.2.5: {} wordwrap@1.0.0: {} @@ -18107,6 +19055,11 @@ snapshots: ws@8.21.0: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} @@ -18142,6 +19095,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + zod@4.6.7: {} zrender@6.1.0(patch_hash=183ccabb66e5590ca8a810519f1df210e5a87bb8e48b4bae25e76a619954a193): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4b49c1e1be65..c0ffb61b6810 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - static/app - static/gsApp - static/gsAdmin + - static/packages/api - static/packages/icons - static/packages/scraps - static/packages/search-syntax @@ -22,6 +23,7 @@ overrides: '@testing-library/jest-dom>aria-query': 5.3.0 diff@>=4.0.0 <4.0.4: 4.0.4 diff@>=5.0.0 <5.2.2: 5.2.2 + js-yaml@>=4.0.0 <4.3.2: 4.3.2 packageExtensions: '@sentry/webpack-plugin@5.4.1': peerDependenciesMeta: diff --git a/static/packages/api/.gitignore b/static/packages/api/.gitignore new file mode 100644 index 000000000000..e7c9eb928f08 --- /dev/null +++ b/static/packages/api/.gitignore @@ -0,0 +1,9 @@ +node_modules + +# output (regenerated by `pnpm build`) +dist +src +spec + +# normalized spec written by build step 0.5 (derived from openapi-derefed.json) +openapi-normalized.json diff --git a/static/packages/api/CHANGELOG.md b/static/packages/api/CHANGELOG.md new file mode 100644 index 000000000000..49dfc28034e4 --- /dev/null +++ b/static/packages/api/CHANGELOG.md @@ -0,0 +1,3364 @@ +## 1.54.0 + +### Schema Updates + +- Generated by @BYK in [0a3af8d0](https://github.com/getsentry/sentry-api-schema/commit/0a3af8d0583eb3f047fab673974973d9f900a6ac) + +## 1.53.1 + +### Internal Changes + +- Track release pipeline failures and stuck releases in an issue by @BYK with @sentry-junior in [#138](https://github.com/getsentry/sentry-api-schema/pull/138) + +## 1.53.0 + +### Schema Updates + +- Generated by @obostjancic in [83a2b744](https://github.com/getsentry/sentry-api-schema/commit/83a2b7448d641fecb689131736e218d950220f28) + +## 1.52.0 + +### Bug Fixes + +- (build) Unbreak the build for uint64 fields and the bound client type by @BYK with @sentry-junior in [#136](https://github.com/getsentry/sentry-api-schema/pull/136) + +### Schema Updates + +- Generated by @obostjancic in [82345864](https://github.com/getsentry/sentry-api-schema/commit/82345864eb2b2c7822259cb5eed96a2f9701cb89) +- Generated by @skaasten in [300ddcf4](https://github.com/getsentry/sentry-api-schema/commit/300ddcf44c1cdcabf66d22347c71303fd9ce6d32) +- Generated by @shellmayr in [89d031f4](https://github.com/getsentry/sentry-api-schema/commit/89d031f40cbaa6985e95fc341dee46acc9c2410e) +- Generated by @shellmayr in [ade1fbaf](https://github.com/getsentry/sentry-api-schema/commit/ade1fbafcb7cd8221f062506f1e36b7b21665964) +- Generated by @BYK in [4260bab8](https://github.com/getsentry/sentry-api-schema/commit/4260bab8caf8b9a6c4a66fced029bba158d1f8c8) +- Generated by @BYK in [dc25434f](https://github.com/getsentry/sentry-api-schema/commit/dc25434f3025833ded6f4b848eb6ffb83b0df76c) + +## 1.51.0 + +### Schema Updates + +- Generated by @BYK in [4d64abe0](https://github.com/getsentry/sentry-api-schema/commit/4d64abe020d3a366ba7ccd5e884243a6ffc206f2) + +## 1.50.0 + +### Schema Updates + +- Generated by @BYK in [7c5afe1c](https://github.com/getsentry/sentry-api-schema/commit/7c5afe1c87ec50a2dc65162caca3f0c80572aae6) + +## 1.49.0 + +### Schema Updates + +- Generated by @BYK in [8a11c735](https://github.com/getsentry/sentry-api-schema/commit/8a11c7355615c7e0d1a415a83304e0fd4ce21557) + +## 1.48.0 + +### New Features + +- (operations) Expose experimental flag in operation catalog by @BYK with @sentry-junior in [#135](https://github.com/getsentry/sentry-api-schema/pull/135) + +## 1.47.0 + +### Schema Updates + +- Generated by @BYK in [855723c9](https://github.com/getsentry/sentry-api-schema/commit/855723c9492b2d9d31e45b0c3e049210ab823430) + +## 1.46.0 + +### Schema Updates + +- Generated by @BYK in [87427c81](https://github.com/getsentry/sentry-api-schema/commit/87427c815ff133ae016bac553ac7af8da15ed5f7) +- Generated by @BYK in [9d6b0952](https://github.com/getsentry/sentry-api-schema/commit/9d6b0952ad9fd3e864b48c85f480e5d1caacc898) + +## 1.45.0 + +### Schema Updates + +- Generated by @BYK in [26075898](https://github.com/getsentry/sentry-api-schema/commit/26075898f82283703f1c5601a789509f36b938aa) + +## 1.44.0 + +### New Features + +- (client) Add an Objectstore client for batched uploads by @sentry-junior in [#133](https://github.com/getsentry/sentry-api-schema/pull/133) + +### Bug Fixes + +- (test) Add feature_id to autofix response fixtures by @BYK with @sentry-junior in [#134](https://github.com/getsentry/sentry-api-schema/pull/134) + +### Schema Updates + +- Generated by @azulus in [2f5d19a9](https://github.com/getsentry/sentry-api-schema/commit/2f5d19a9a36e249eded87a9dbb8f80351c77864c) +- Generated by @NicoHinderling in [a47f626d](https://github.com/getsentry/sentry-api-schema/commit/a47f626dabc62850cf6b576a06d382037d6f62b2) + +## 1.43.0 + +### Schema Updates + +- Generated by @scttcper in [dba3ee91](https://github.com/getsentry/sentry-api-schema/commit/dba3ee91e61dde4d15d173635f8f49aefd3f3071) + +## 1.42.0 + +### Schema Updates + +- Generated by @roggenkemper in [ded50c75](https://github.com/getsentry/sentry-api-schema/commit/ded50c75ef9b2f10bf09cf9b380777f18827404f) + +## 1.41.0 + +### Schema Updates + +- Generated by @leeandher in [3350d57f](https://github.com/getsentry/sentry-api-schema/commit/3350d57f677832141106a2f3503c3934b5d468f1) + +## 1.40.0 + +### Schema Updates + +- Generated by @obostjancic in [b1a8dc02](https://github.com/getsentry/sentry-api-schema/commit/b1a8dc0246038ac21e0f1b5375d34a02d25b61ac) + +## 1.39.0 + +### Schema Updates + +- Generated by @gricha in [901d23e7](https://github.com/getsentry/sentry-api-schema/commit/901d23e73ffc13c066179278f03a1dbd470b2b60) + +## 1.38.0 + +### Schema Updates + +- Generated by @cvxluo in [0ed6824f](https://github.com/getsentry/sentry-api-schema/commit/0ed6824fdf806c4a6560d42930d8241145d561c2) + +## 1.37.0 + +### Schema Updates + +- Generated by @sentry-fast-revert-bot in [15039dfb](https://github.com/getsentry/sentry-api-schema/commit/15039dfb1b658e7959899ea3e136040acec995a4) + +## 1.36.0 + +### Schema Updates + +- Generated by @trevor-e in [948c618e](https://github.com/getsentry/sentry-api-schema/commit/948c618e356376b2adb0a7bee3e55ed2aa3d41bf) + +## 1.35.0 + +### Schema Updates + +- Generated by @michelletran-sentry in [d657cb19](https://github.com/getsentry/sentry-api-schema/commit/d657cb19c2703cb9cede4dbffc0f29be574922f8) + +## 1.34.0 + +### Schema Updates + +- Generated by @michelletran-sentry in [ff178152](https://github.com/getsentry/sentry-api-schema/commit/ff178152e4445d981f556997249b3118427299b6) + +## 1.33.0 + +### Schema Updates + +- Generated by @lobsterkatie in [fc687389](https://github.com/getsentry/sentry-api-schema/commit/fc68738946831bf8dcabfc4b2be5feb7b9e9ffdb) +- Generated by @ArthurKnaus in [b2e215f0](https://github.com/getsentry/sentry-api-schema/commit/b2e215f0c8fa5a240356bd938b75f0dc51cf3f3a) + +## 1.32.0 + +### Schema Updates + +- Generated by @BYK in [c865f58c](https://github.com/getsentry/sentry-api-schema/commit/c865f58c5a162865cead0fecb68849a4614df887) +- Generated by @billyvg in [be64d2e7](https://github.com/getsentry/sentry-api-schema/commit/be64d2e7648f183ce6fdb299a3625f4264680a2d) + +## 1.31.0 + +### Schema Updates + +- Generated by @BYK in [e2bc506c](https://github.com/getsentry/sentry-api-schema/commit/e2bc506c5231cbce87c0d65a298c75c0e6e0f88e) +- Generated by @roggenkemper in [eac5c638](https://github.com/getsentry/sentry-api-schema/commit/eac5c638a19b08bbc477576f4d95e4ed8ea6fd37) +- Generated by @roggenkemper in [2804f108](https://github.com/getsentry/sentry-api-schema/commit/2804f1088e7c18a6a9b0e0d5d38898616fdc7117) + +## 1.30.0 + +### Schema Updates + +- Generated by @roggenkemper in [e4339dac](https://github.com/getsentry/sentry-api-schema/commit/e4339dac1ebe92ebeac47f362485431be8cfb202) + +## 1.29.2 + +### Internal Changes + +- Parallelize Hey API and declaration emit, make edit linear by @BYK with @sentry-junior in [#132](https://github.com/getsentry/sentry-api-schema/pull/132) + +## 1.29.1 + +### Internal Changes + +- Run the build steps in one process by @BYK with @sentry-junior in [#131](https://github.com/getsentry/sentry-api-schema/pull/131) + +## 1.29.0 + +### New Features + +- (client) Pick each request's base URL from its path with resolveBaseUrl by @BYK with @sentry-junior in [#130](https://github.com/getsentry/sentry-api-schema/pull/130) + +## 1.28.0 + +### Schema Updates + +- Generated by @BYK in [c5c6806d](https://github.com/getsentry/sentry-api-schema/commit/c5c6806dd61078747f3bc2c5d57a47dc66386f38) + +## 1.27.0 + +### Schema Updates + +- Generated by @ArthurKnaus in [e57534be](https://github.com/getsentry/sentry-api-schema/commit/e57534bebf09f97c24aef656c9e54a3fb053fbd0) + +## 1.26.0 + +### New Features + +- (client) Pass the request's org scope to CredentialProvider.current by @BYK with @sentry-junior in [#129](https://github.com/getsentry/sentry-api-schema/pull/129) + +## 1.25.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [6da3fa04](https://github.com/getsentry/sentry-api-schema/commit/6da3fa04deae9a20d8dd77847ac31e797c631fa0) + +## 1.24.0 + +### Schema Updates + +- Generated by @mrduncan in [319eac2f](https://github.com/getsentry/sentry-api-schema/commit/319eac2f4a74e16ae24c8b7d03186747840102cd) +- Generated by @BYK in [2fab5e12](https://github.com/getsentry/sentry-api-schema/commit/2fab5e1215255cdcccda529cfa9c28ff16a5873e) + +## 1.23.0 + +### Schema Updates + +- Generated by @BYK in [c14995c8](https://github.com/getsentry/sentry-api-schema/commit/c14995c8872ff052bfe7b1ac8322b93f429cf3fa) +- Generated by @NicoHinderling in [389a2e88](https://github.com/getsentry/sentry-api-schema/commit/389a2e883e38febd991d62df789b1dcf239ae7bf) +- Generated by @trevor-e in [d122d93d](https://github.com/getsentry/sentry-api-schema/commit/d122d93d8ed3efcdcb90ac951a941025a4ce69bb) + +## 1.22.0 + +### Schema Updates + +- Generated by @kcons in [6758ec14](https://github.com/getsentry/sentry-api-schema/commit/6758ec1461316419469aa47156e64d1aa1ec6989) +- Generated by @NicoHinderling in [4a0244a7](https://github.com/getsentry/sentry-api-schema/commit/4a0244a7c0ff3d33e0571e7f62bc5b549c6f2d42) + +## 1.21.0 + +### Schema Updates + +- Generated by @BYK in [ef037dd4](https://github.com/getsentry/sentry-api-schema/commit/ef037dd4149e8fd0671d3e5c83aed26326173bc5) + +## 1.20.0 + +### Schema Updates + +- Generated by @saponifi3d in [b41af6fa](https://github.com/getsentry/sentry-api-schema/commit/b41af6fad9b0121f48d329d0e107409272326ae8) + +## 1.19.0 + +### New Features + +- (validators) Attach OpenAPI descriptions to Valibot and Zod schemas by @BYK with @sentry-junior in [#128](https://github.com/getsentry/sentry-api-schema/pull/128) + +## 1.18.0 + +### Schema Updates + +- Generated by @BYK in [6a40d9ee](https://github.com/getsentry/sentry-api-schema/commit/6a40d9ee96640a5aa86fb6f0360f53e3df49919a) + +## 1.17.0 + +### Schema Updates + +- Generated by @gricha in [2bb64705](https://github.com/getsentry/sentry-api-schema/commit/2bb6470575d3ae5587f4f5686077bb16a0fa33df) +- Generated by @NicoHinderling in [af48366f](https://github.com/getsentry/sentry-api-schema/commit/af48366f3daf8c361ecc5863e22d6ca955c7dc3d) + +## 1.16.0 + +### Schema Updates + +- Generated by @shashjar in [9f1510c2](https://github.com/getsentry/sentry-api-schema/commit/9f1510c20155e866cf9f7bbd746711c6e6f972dc) +- Generated by @skaasten in [931d09ab](https://github.com/getsentry/sentry-api-schema/commit/931d09ab35638e8089970d4a89bc3f134aadc3bc) + +## 1.15.0 + +### Schema Updates + +- Generated by @alexsohn1126 in [46027c00](https://github.com/getsentry/sentry-api-schema/commit/46027c006b5d5d235b5e8999b06b459bd5706f7e) + +## 1.14.0 + +### Schema Updates + +- Generated by @strongs in [21229096](https://github.com/getsentry/sentry-api-schema/commit/21229096a407018442afa18758b6ce87b60e4288) +- Generated by @DominikB2014 in [321d0651](https://github.com/getsentry/sentry-api-schema/commit/321d06519aa3ba57cdeda5bf0ac36b42bd835e73) + +## 1.13.0 + +### Schema Updates + +- Generated by @geoffg-sentry in [ce7473db](https://github.com/getsentry/sentry-api-schema/commit/ce7473dbefa52655222c833f7aeec03b90044f5e) +- Generated by @cvxluo in [f515e04a](https://github.com/getsentry/sentry-api-schema/commit/f515e04aeaea554a759ce97a0e39b585fd33ff01) + +## 1.12.0 + +### Schema Updates + +- Generated by @giovanni-guidini in [bfc632ce](https://github.com/getsentry/sentry-api-schema/commit/bfc632ce393e0eaf2c7a01c8dc9378e1b916e04f) + +## 1.11.0 + +### Schema Updates + +- Generated by @TkDodo in [4cdc41f5](https://github.com/getsentry/sentry-api-schema/commit/4cdc41f5ca905559ef83e16418b1d515ed04c709) + +## 1.10.0 + +### Schema Updates + +- Generated by @wedamija in [2282d26e](https://github.com/getsentry/sentry-api-schema/commit/2282d26edf55ba953da04c306e5a23a10b5bee0e) + +## 1.9.0 + +### Schema Updates + +- Generated by @wedamija in [47cdb3db](https://github.com/getsentry/sentry-api-schema/commit/47cdb3db7367c43798e924f88bdc05805726ad2c) + +## 1.8.0 + +### Schema Updates + +- Generated by @wmak in [332aad15](https://github.com/getsentry/sentry-api-schema/commit/332aad153763e521722dda9db1132d88c7352aac) + +## 1.7.0 + +### Schema Updates + +- Generated by @wedamija in [707ffc9c](https://github.com/getsentry/sentry-api-schema/commit/707ffc9ceee6aee24ac28097c3539a6e55c0b597) + +## 1.6.0 + +### Schema Updates + +- Generated by @NicoHinderling in [7cc7407a](https://github.com/getsentry/sentry-api-schema/commit/7cc7407ab015c311c17564f31d9800234a99a715) + +## 1.5.0 + +### Schema Updates + +- Generated by @NicoHinderling in [cc8657df](https://github.com/getsentry/sentry-api-schema/commit/cc8657df634148c3cbbc3a9dddbfe8f306def467) +- Generated by @gricha in [e34bd0b4](https://github.com/getsentry/sentry-api-schema/commit/e34bd0b45f93be12900aea3511d1a44c6d191c47) + +## 1.4.0 + +### Schema Updates + +- Generated by @leeandher in [39b27582](https://github.com/getsentry/sentry-api-schema/commit/39b27582017e8c92a50333c11fa0bcdbde7a2f3f) +- Generated by @adrianviquez in [854f0473](https://github.com/getsentry/sentry-api-schema/commit/854f04735d8ad7ed46711956d6951341aa5af6d1) + +## 1.3.0 + +### Schema Updates + +- Generated by @gricha in [9a960a5a](https://github.com/getsentry/sentry-api-schema/commit/9a960a5ace6d877e0d0490c4d654902a94940b79) + +## 1.2.0 + +### Schema Updates + +- Generated by @cvxluo in [f0f2e3ba](https://github.com/getsentry/sentry-api-schema/commit/f0f2e3ba6d013c6ba72c634b4fe09a17dca40e62) + +## 1.1.0 + +### Schema Updates + +- Generated by @chromy in [4df5ccb2](https://github.com/getsentry/sentry-api-schema/commit/4df5ccb2bd540eb2d5223a5b2574188322736f05) + +## 1.0.1 + +### Internal Changes + +- Move the build and tests from Bun to pnpm and Node.js by @BYK, @MathurAditya724 and @codex with @sentry-junior in [#126](https://github.com/getsentry/sentry-api-schema/pull/126) + +## 1.0.0 + +### Breaking Changes + +- (zod) Generate Zod 4 schemas by @BYK with @sentry-junior in [#127](https://github.com/getsentry/sentry-api-schema/pull/127) + +## 0.446.0 + +### New Features + +- (client) Move the shared API client from toolkit into @sentry/api by @BYK with @sentry-junior in [#122](https://github.com/getsentry/sentry-api-schema/pull/122) + +## 0.445.0 + +### Bug Fixes + +- (build) Keep bounded int64 integers as numbers by @BYK with @sentry-junior in [#123](https://github.com/getsentry/sentry-api-schema/pull/123) +- (deps) Patch OpenAPI generator and YAML vulnerabilities by @MathurAditya724 and @codex in [#124](https://github.com/getsentry/sentry-api-schema/pull/124) + +### Schema Updates + +- Generated by @trevor-e in [5c9cdf40](https://github.com/getsentry/sentry-api-schema/commit/5c9cdf40f229a0705804f5631adc18fc74e9b16d) +- Generated by @NicoHinderling in [e748eed2](https://github.com/getsentry/sentry-api-schema/commit/e748eed226396a76541a08efe8d22cfea58bed65) +- Generated by @azulus in [79cd50b3](https://github.com/getsentry/sentry-api-schema/commit/79cd50b3705243942985f5c2879893e0a8ca4dba) +- Generated by @azulus in [2cf7aec3](https://github.com/getsentry/sentry-api-schema/commit/2cf7aec3e9187d39bf1b51b7b7e7c7a2ed4fc891) +- Generated by @azulus in [df30eafa](https://github.com/getsentry/sentry-api-schema/commit/df30eafa7847eb086a59e79e4c49bdcad745303f) + +### Internal Changes + +- (release) Bump craft to 3.9.0 and update config by @BYK with @sentry-junior in [#121](https://github.com/getsentry/sentry-api-schema/pull/121) + +## 0.444.0 + +### Schema Updates + +- Generated by @edwardgou-sentry in [bc1391cf](https://github.com/getsentry/sentry-api-schema/commit/bc1391cf2cbd84f1971166ae42cb7aa148176a7e) + +## 0.443.0 + +### Schema Updates + +- Generated by @cvxluo in [b8cee4a2](https://github.com/getsentry/sentry-api-schema/commit/b8cee4a2531b6a1316b247145f78d613a3730da3) + +## 0.442.0 + +### Schema Updates + +- Generated by @vaind in [a66bc538](https://github.com/getsentry/sentry-api-schema/commit/a66bc538dd9a8a3a118ff8cee8442f3d7a094eaa) + +## 0.441.0 + +### Schema Updates + +- Generated by @joseph-sentry in [b4a0e7b3](https://github.com/getsentry/sentry-api-schema/commit/b4a0e7b3444962882029b0ef8d05c6e1946ce90e) + +## 0.440.0 + +### Schema Updates + +- Generated by @joshuarli in [d4c11a55](https://github.com/getsentry/sentry-api-schema/commit/d4c11a55da428927f4b0eecd34353afd1a821766) + +## 0.439.0 + +### Schema Updates + +- Generated by @elramen in [83e689cf](https://github.com/getsentry/sentry-api-schema/commit/83e689cf8bd3af96f1d8dd222cae4f8cd929c15e) +- Generated by @obostjancic in [b783f73d](https://github.com/getsentry/sentry-api-schema/commit/b783f73dbc061b60060afaa0903f15c8d3c85dc4) + +## 0.438.0 + +### Schema Updates + +- Generated by @giovanni-guidini in [fce6b923](https://github.com/getsentry/sentry-api-schema/commit/fce6b923baeb6624244c7d8c75bdf10644867603) + +## 0.437.0 + +### Schema Updates + +- Generated by @cvxluo in [9ac6ef1b](https://github.com/getsentry/sentry-api-schema/commit/9ac6ef1bb190b159a549c8d27e13331e86475158) +- Generated by @BYK in [86c291bd](https://github.com/getsentry/sentry-api-schema/commit/86c291bdcfab04cab64a303899d7a39e6ff1831c) + +## 0.436.0 + +### Schema Updates + +- Generated by @BYK in [fee6e6f5](https://github.com/getsentry/sentry-api-schema/commit/fee6e6f53952c2c43da789d865d2eee5dd46d4d6) + +## 0.435.0 + +### New Features + +- (build) Make validator schemas and the default client tree-shakeable by @BYK with @sentry-junior in [#120](https://github.com/getsentry/sentry-api-schema/pull/120) + +## 0.434.0 + +### Schema Updates + +- Generated by @srest2021 in [0274919a](https://github.com/getsentry/sentry-api-schema/commit/0274919ac0b60dcf03af48b6aa758b587b7ba42e) + +## 0.433.0 + +### Schema Updates + +- Generated by @akhilp117 in [465bfa03](https://github.com/getsentry/sentry-api-schema/commit/465bfa03f4317738efb6ec058c9dca55fe9cef9e) + +## 0.432.0 + +### Schema Updates + +- Generated by @shashjar in [761f1e25](https://github.com/getsentry/sentry-api-schema/commit/761f1e258faf7a156744efe9a68715f94290d679) + +## 0.431.0 + +### Schema Updates + +- Generated by @gricha in [91736c49](https://github.com/getsentry/sentry-api-schema/commit/91736c498baad3c472a13ad8d72433d2b8a88997) + +## 0.430.0 + +### Schema Updates + +- Generated by @aliu39 in [5aa2eeb6](https://github.com/getsentry/sentry-api-schema/commit/5aa2eeb682cc052a4407a0ca8d48de9c1c00e847) + +## 0.429.0 + +### Schema Updates + +- Generated by @skaasten in [66a476a7](https://github.com/getsentry/sentry-api-schema/commit/66a476a79bc706c2af39b9509af888bda42eb1d9) + +### Other + +- test(pagination): cover offsets across short pages by @betegon in [#118](https://github.com/getsentry/sentry-api-schema/pull/118) + +## 0.428.0 + +### New Features + +- (client) Expose shared request preparation by @betegon in [#117](https://github.com/getsentry/sentry-api-schema/pull/117) + +## 0.427.0 + +### Schema Updates + +- Generated by @cmanallen in [b26b2fcc](https://github.com/getsentry/sentry-api-schema/commit/b26b2fcc6aeaa7c15ddd9fa9a54d089f3ab86e52) + +## 0.426.0 + +### Schema Updates + +- Generated by @vaind in [20b46329](https://github.com/getsentry/sentry-api-schema/commit/20b463298ddd57987326af16f52efcb97971a564) + +## 0.425.0 + +### New Features + +- (pagination) Resume partially consumed pages by @betegon in [#116](https://github.com/getsentry/sentry-api-schema/pull/116) + +## 0.424.0 + +### New Features + +- Export build-time API source analysis by @betegon in [#115](https://github.com/getsentry/sentry-api-schema/pull/115) + +## 0.423.0 + +### New Features + +- (sdk) Default collapse=adjacentEvents on getProjectEvent by @BYK with @sentry-junior in [#113](https://github.com/getsentry/sentry-api-schema/pull/113) + +## 0.422.0 + +### Schema Updates + +- Generated by @BYK in [0e210e04](https://github.com/getsentry/sentry-api-schema/commit/0e210e047d50b9019ef904aefaa6d8af65d81230) + +## 0.421.0 + +### Schema Updates + +- Generated by @wedamija in [c660f5a9](https://github.com/getsentry/sentry-api-schema/commit/c660f5a94612cac5f8b4c6402b20c2b320913982) + +## 0.420.0 + +### New Features + +- (client) Skip adjacent-event lookups by default in getOrganizationIssueEvent by @BYK with @sentry-junior in [#112](https://github.com/getsentry/sentry-api-schema/pull/112) + +## 0.419.0 + +### Schema Updates + +- Generated by @BYK in [706e9c62](https://github.com/getsentry/sentry-api-schema/commit/706e9c6272b1b68a35d0c4ca86cf0b33e81582b5) + +## 0.418.0 + +### Schema Updates + +- Generated by @BYK in [64186e7d](https://github.com/getsentry/sentry-api-schema/commit/64186e7d7496254a73e247e38a59969962f92768) + +## 0.417.0 + +### Schema Updates + +- Generated by @trevor-e in [27c9db10](https://github.com/getsentry/sentry-api-schema/commit/27c9db10d51ce98c2e8fc8a4f765a485264e6926) + +## 0.416.0 + +### Schema Updates + +- Generated by @nsdeschenes in [c97b5635](https://github.com/getsentry/sentry-api-schema/commit/c97b5635f2ce85e04ad086ffe663aa11863413a8) + +## 0.415.0 + +### Schema Updates + +- Generated by @mujacica in [2af850c3](https://github.com/getsentry/sentry-api-schema/commit/2af850c334a6d82597c3423e3f7d65ca483ea4b0) + +## 0.414.0 + +### New Features + +- (operations) Expose idempotent flag in the operation catalog by @BYK with @sentry-junior in [#111](https://github.com/getsentry/sentry-api-schema/pull/111) + +## 0.413.0 + +### Schema Updates + +- Generated by @adrianviquez in [9f048abe](https://github.com/getsentry/sentry-api-schema/commit/9f048abeb531ed4bfa183fca51d9357f5854e035) + +## 0.412.0 + +### New Features + +- Export a generated operation catalog by @betegon in [#110](https://github.com/getsentry/sentry-api-schema/pull/110) + +## 0.411.0 + +### Schema Updates + +- Generated by @wedamija in [541e7d20](https://github.com/getsentry/sentry-api-schema/commit/541e7d20352a9504ccb77bd0c3b67d752d54c65d) + +## 0.410.0 + +### Schema Updates + +- Generated by @wedamija in [fe8bd6fa](https://github.com/getsentry/sentry-api-schema/commit/fe8bd6fa8b6c90500c7c05645712ed8ac8147764) + +## 0.409.0 + +### Schema Updates + +- Generated by @aliu39 in [5bd01cda](https://github.com/getsentry/sentry-api-schema/commit/5bd01cda8803f0e52c485a9701df3d3911ae3f06) + +## 0.408.0 + +### Schema Updates + +- Generated by @Starrao123 in [d12e7e78](https://github.com/getsentry/sentry-api-schema/commit/d12e7e7887237731bfe71ad984d78a8f2f3a999f) + +## 0.407.0 + +### Schema Updates + +- Generated by @jaydgoss in [5c02c32a](https://github.com/getsentry/sentry-api-schema/commit/5c02c32afe2dc2ebeba78fa35757c407e50cc8d9) + +## 0.406.0 + +### Schema Updates + +- Generated by @scttcper in [28cded78](https://github.com/getsentry/sentry-api-schema/commit/28cded7840695945b618cb101ed66b045794fd58) + +## 0.405.0 + +### Schema Updates + +- Generated by @DominikB2014 in [0c5dd3da](https://github.com/getsentry/sentry-api-schema/commit/0c5dd3da44961b0883238bff4f7e0b677456d4e3) + +## 0.404.0 + +### Schema Updates + +- Generated by @wedamija in [46e6baad](https://github.com/getsentry/sentry-api-schema/commit/46e6baadd8772585cfb46fb768411f22a48e1f8d) + +## 0.403.0 + +### Schema Updates + +- Generated by @manessaraj in [1aaa7af8](https://github.com/getsentry/sentry-api-schema/commit/1aaa7af8f05419397cc1b2a41c107d07be51635e) + +## 0.402.0 + +### Schema Updates + +- Generated by @scttcper in [45d2f893](https://github.com/getsentry/sentry-api-schema/commit/45d2f893373ec2a3e9827ffe1c3999b1504dd343) + +## 0.401.0 + +### Schema Updates + +- Generated by @gricha in [7b9f8d9b](https://github.com/getsentry/sentry-api-schema/commit/7b9f8d9b1afe3ea6fbf4f5a5634025bdc53af239) + +## 0.400.0 + +### Schema Updates + +- Generated by @BYK in [0430d19e](https://github.com/getsentry/sentry-api-schema/commit/0430d19ec98616044d45ed5cb7edec2ae5f87d70) + +## 0.399.0 + +### New Features + +- Name enum query parameter types by @sentry-junior in [#108](https://github.com/getsentry/sentry-api-schema/pull/108) + +## 0.398.0 + +### Schema Updates + +- Generated by @coolguyzone in [285756dc](https://github.com/getsentry/sentry-api-schema/commit/285756dcddebca520037d72e1db9754c8c2e3c75) + +## 0.397.0 + +### Schema Updates + +- Generated by @DominikB2014 in [d0e86507](https://github.com/getsentry/sentry-api-schema/commit/d0e865074472ed1f8bc29196ae9914d26fa6014a) + +## 0.396.0 + +### Schema Updates + +- Generated by @DominikB2014 in [b2442fb4](https://github.com/getsentry/sentry-api-schema/commit/b2442fb4dafa39342989e82d2eee9c0074c26c1b) + +## 0.395.0 + +### Schema Updates + +- Generated by @skaasten in [9bffb4bf](https://github.com/getsentry/sentry-api-schema/commit/9bffb4bffac6fc57af176321a55c71d0841306c0) + +## 0.394.0 + +### Schema Updates + +- Generated by @DominikB2014 in [2def58c9](https://github.com/getsentry/sentry-api-schema/commit/2def58c910e17025f211de5ff377254ba285fecc) + +## 0.393.0 + +### Schema Updates + +- Generated by @nsdeschenes in [3675f898](https://github.com/getsentry/sentry-api-schema/commit/3675f898b11e56904bdc4138574fc36e5c13537b) + +## 0.392.0 + +### Schema Updates + +- Generated by @shellmayr in [c94cc7c6](https://github.com/getsentry/sentry-api-schema/commit/c94cc7c6117fd8567d68b00c3a17dc1563eeafd6) + +## 0.391.0 + +### Schema Updates + +- Generated by @cmanallen in [789d8d03](https://github.com/getsentry/sentry-api-schema/commit/789d8d0317a6464d6ba840e3b8c37c8fb0a05fbb) + +## 0.390.0 + +### Schema Updates + +- Generated by @mjq in [c2a832ce](https://github.com/getsentry/sentry-api-schema/commit/c2a832ced5c983eacd0ff409a6a462a69e206c0d) + +## 0.389.0 + +### Schema Updates + +- Generated by @DominikB2014 in [67fda936](https://github.com/getsentry/sentry-api-schema/commit/67fda9365b468c07ce43735ceae230dd99d59d78) + +## 0.388.0 + +### Schema Updates + +- Generated by @wedamija in [1f67a23b](https://github.com/getsentry/sentry-api-schema/commit/1f67a23bb90f0e17e8146b81fbf52c154a7dacfd) + +## 0.387.0 + +### Schema Updates + +- Generated by @oioki in [e07d44d7](https://github.com/getsentry/sentry-api-schema/commit/e07d44d785fdcc66eb325b422a9d55025cd654da) + +## 0.386.0 + +### Schema Updates + +- Generated by @k-fish in [f8158928](https://github.com/getsentry/sentry-api-schema/commit/f8158928da3b15d878440bf46a2bb62ee34c5b06) + +## 0.385.0 + +### Schema Updates + +- Generated by @gggritso in [969e159e](https://github.com/getsentry/sentry-api-schema/commit/969e159ee8ff0111f82d155cbd60aa2f4b1d6d99) +- Generated by @cmanallen in [3a22f564](https://github.com/getsentry/sentry-api-schema/commit/3a22f5643a3675f0911f4abc3187ce861c8e598f) + +## 0.384.0 + +### Schema Updates + +- Generated by @shellmayr in [969b8a53](https://github.com/getsentry/sentry-api-schema/commit/969b8a538f1d457e0b3fc5bf38e9dbecbcabb34f) + +## 0.383.0 + +### Schema Updates + +- Generated by @vaind in [4973d6c8](https://github.com/getsentry/sentry-api-schema/commit/4973d6c8945e0c382fe29c7bfde66a86612880b5) + +## 0.382.0 + +### Schema Updates + +- Generated by @betegon in [42234aff](https://github.com/getsentry/sentry-api-schema/commit/42234affe676ae4062f14373b1bae37efc0742ca) + +## 0.381.0 + +### Schema Updates + +- Generated by @giovanni-guidini in [2da92393](https://github.com/getsentry/sentry-api-schema/commit/2da92393a8df8d787586395181346cd56b59a84b) + +## 0.380.0 + +### Schema Updates + +- Generated by @shellmayr in [8f61c4a4](https://github.com/getsentry/sentry-api-schema/commit/8f61c4a4135238b9bb1eba94780f7c8beeb8e6e9) + +## 0.379.0 + +### Schema Updates + +- Generated by @giovanni-guidini in [f45d69fe](https://github.com/getsentry/sentry-api-schema/commit/f45d69fef3d4e16a9fcd9b53385629596425ce63) + +## 0.378.0 + +### Schema Updates + +- Generated by @cmanallen in [51baedb1](https://github.com/getsentry/sentry-api-schema/commit/51baedb1eb329f5dcac74e8d6bd8e81283788b99) + +## 0.377.0 + +### Schema Updates + +- Generated by @cmanallen in [c7d6eef6](https://github.com/getsentry/sentry-api-schema/commit/c7d6eef63fc8e86a4b0d5d89c70936b8ace49501) + +## 0.376.0 + +### Schema Updates + +- Generated by @cmanallen in [277e2b11](https://github.com/getsentry/sentry-api-schema/commit/277e2b1129ac7b38d92c94c030adb42680ea1f1a) + +## 0.375.0 + +### Schema Updates + +- Generated by @JoshuaKGoldberg in [82d40817](https://github.com/getsentry/sentry-api-schema/commit/82d40817d1b7cf04851dc7bdc6d26cca862503fe) + +## 0.374.0 + +### Schema Updates + +- Generated by @cmanallen in [9697fe8e](https://github.com/getsentry/sentry-api-schema/commit/9697fe8e47db0be556069ad3c6dd9a8195d1db4a) + +## 0.373.0 + +### Schema Updates + +- Generated by @skaasten in [e8dba7a5](https://github.com/getsentry/sentry-api-schema/commit/e8dba7a51eb02705ffc062baf3f581856f6c684e) + +## 0.372.0 + +### Schema Updates + +- Generated by @joshuarli in [e2dc40b3](https://github.com/getsentry/sentry-api-schema/commit/e2dc40b3e8e5eaee4f9e078cc22370ffb01440d7) + +## 0.371.0 + +### Schema Updates + +- Generated by @DominikB2014 in [8de28f6d](https://github.com/getsentry/sentry-api-schema/commit/8de28f6d9e7b4b3817a6a40dd8f8a8ade069985e) + +## 0.370.0 + +### Schema Updates + +- Generated by @gggritso in [2c407fef](https://github.com/getsentry/sentry-api-schema/commit/2c407fefa6705280708b43fb97295bd40d707683) + +## 0.369.0 + +### Schema Updates + +- Generated by @sebastianv1 in [dc40ad2b](https://github.com/getsentry/sentry-api-schema/commit/dc40ad2bc9614559b8fb78005c89ffe333a36e79) + +## 0.368.0 + +### Schema Updates + +- Generated by @trevor-e in [f56baf12](https://github.com/getsentry/sentry-api-schema/commit/f56baf126283e34ad030980cdfe86b4872667459) + +## 0.367.0 + +### Schema Updates + +- Generated by @cmanallen in [3cdad034](https://github.com/getsentry/sentry-api-schema/commit/3cdad0342a56d71baff4742c39b83a22c76f9fbd) + +## 0.366.0 + +### Schema Updates + +- Generated by @obostjancic in [1ef2ab27](https://github.com/getsentry/sentry-api-schema/commit/1ef2ab2770f6c455aec8462cf284008426679e42) + +## 0.365.0 + +### Schema Updates + +- Generated by @obostjancic in [c8e163b2](https://github.com/getsentry/sentry-api-schema/commit/c8e163b204d82a42cb46e9f1c989e39608727b55) +- Generated by @wedamija in [527d8368](https://github.com/getsentry/sentry-api-schema/commit/527d83685db0907ac445b7d897428fb0727f55ab) + +## 0.364.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [0dbafd12](https://github.com/getsentry/sentry-api-schema/commit/0dbafd121ac45ce83e29d94f555c078d99d06d20) + +## 0.363.0 + +### Schema Updates + +- Generated by @adrianviquez in [2adfc005](https://github.com/getsentry/sentry-api-schema/commit/2adfc005422a24d0feffa6afb1e03f691489f07f) + +## 0.362.0 + +### Schema Updates + +- Generated by @gricha in [7c51e556](https://github.com/getsentry/sentry-api-schema/commit/7c51e556571ea1034763ddaa89b982af3365cb71) + +## 0.361.0 + +### Schema Updates + +- Generated by @gricha in [1f096498](https://github.com/getsentry/sentry-api-schema/commit/1f0964985355bf5340a2a4d6c795b4584a1509ed) + +## 0.360.3 + +### Internal Changes + +- (release) Skip release bot merge commits by @MathurAditya724 in [#107](https://github.com/getsentry/sentry-api-schema/pull/107) + +## 0.360.2 + +### Bug Fixes + +- (release) Default merge target to main by @MathurAditya724 in [#106](https://github.com/getsentry/sentry-api-schema/pull/106) + +## 0.360.1 + +### Internal Changes + +- Add CODEOWNERS for repo ownership by @sentry-junior in [#88](https://github.com/getsentry/sentry-api-schema/pull/88) + +## 0.360.0 + +### Schema Updates + +- Generated by @kcons in [7e86985c](https://github.com/getsentry/sentry-api-schema/commit/7e86985c1c8a57d0df5133411160e145eabf16b5) + +## 0.359.0 + +### Schema Updates + +- Generated by @giovanni-guidini in [c1046d1c](https://github.com/getsentry/sentry-api-schema/commit/c1046d1c5329681c7c5edffb45ce4d950f0637c6) + +## 0.358.0 + +### Schema Updates + +- Generated by @Christinarlong in [74806daa](https://github.com/getsentry/sentry-api-schema/commit/74806daa80c3e058955026e5429f852f3ec75a11) + +## 0.357.0 + +### Schema Updates + +- Generated by @leeandher in [b03c02fb](https://github.com/getsentry/sentry-api-schema/commit/b03c02fb88d72c28e3b0a35fa8c08831a17588bf) + +## 0.356.0 + +### New Features + +- Report pagination limits and resize safe requests by @betegon in [#101](https://github.com/getsentry/sentry-api-schema/pull/101) + +### Bug Fixes + +- Preserve cancellation before SDK fetch dispatch by @betegon in [#105](https://github.com/getsentry/sentry-api-schema/pull/105) +- Stabilize SDK response and error contracts by @betegon in [#104](https://github.com/getsentry/sentry-api-schema/pull/104) + +### Schema Updates + +- Generated by @leeandher in [8da67f72](https://github.com/getsentry/sentry-api-schema/commit/8da67f72d9feb559cbc1265f2ee30cbdadc651f0) +- Generated by @lzhao-sentry in [1ef7f21a](https://github.com/getsentry/sentry-api-schema/commit/1ef7f21a0501fae9490d69e3ae29f0d5acbfb61c) +- Generated by @wedamija in [b342621e](https://github.com/getsentry/sentry-api-schema/commit/b342621e5125399429df4b22093675fd3259add9) +- Generated by @DominikB2014 in [bce6c855](https://github.com/getsentry/sentry-api-schema/commit/bce6c855e6d13723eb72a8f036fc5734cd7ceaa6) +- Generated by @vgrozdanic in [e8ec23b4](https://github.com/getsentry/sentry-api-schema/commit/e8ec23b4c9601dea2d51205983ec1d7b89550b85) +- Generated by @Starrao123 in [5bda6efe](https://github.com/getsentry/sentry-api-schema/commit/5bda6efe4604a24652d8358e2c8c646c00e208a3) +- Generated by @cmanallen in [bef19464](https://github.com/getsentry/sentry-api-schema/commit/bef19464ae751043146e6641757ed90b26201802) +- Generated by @manessaraj in [772198ae](https://github.com/getsentry/sentry-api-schema/commit/772198ae3c4aff362bdab0e0c209683b9f830c92) +- Generated by @manessaraj in [99caeab3](https://github.com/getsentry/sentry-api-schema/commit/99caeab3605e5bb91ff19dfea89e6bb5bf0033c2) +- Generated by @srest2021 in [be372128](https://github.com/getsentry/sentry-api-schema/commit/be372128abf013698a64d97a88c78f0eb4f4e4cf) +- Generated by @DominikB2014 in [6cddbd97](https://github.com/getsentry/sentry-api-schema/commit/6cddbd9721600d181331c7ed718b93d39f81e732) +- Generated by @vgrozdanic in [ac530307](https://github.com/getsentry/sentry-api-schema/commit/ac530307a8c059cb6b9cc5d94d366d71fd9f6641) +- Generated by @DominikB2014 in [377fc3e1](https://github.com/getsentry/sentry-api-schema/commit/377fc3e171804d7c7e1991c4342bdb4eb7f8a2ba) +- Generated by @Abdkhan14 in [8753a857](https://github.com/getsentry/sentry-api-schema/commit/8753a8579dfb60bbe00e1a9d9082ea34df7bd0c9) +- Generated by @kcons in [f73faaac](https://github.com/getsentry/sentry-api-schema/commit/f73faaacbc503c932c477dcf74e78c5e12ec5836) +- Generated by @JoshuaKGoldberg in [c86df12d](https://github.com/getsentry/sentry-api-schema/commit/c86df12da834aa23aadf1b17a4833ab309bf4200) +- Generated by @vgrozdanic in [db7a2583](https://github.com/getsentry/sentry-api-schema/commit/db7a25833324c7a3639fe04b4063a7703ab3ee50) +- Generated by @aliu39 in [7644b769](https://github.com/getsentry/sentry-api-schema/commit/7644b769d158f83bc0079e77566bf6247f1c6c0c) +- Generated by @scttcper in [80b62b7f](https://github.com/getsentry/sentry-api-schema/commit/80b62b7f8dd02da4347f716580659b329ced23ab) +- Generated by @DominikB2014 in [4bc5d301](https://github.com/getsentry/sentry-api-schema/commit/4bc5d301f33d3cbe64abf4fc0d72f3ff4435bb4a) +- Generated by @edwardgou-sentry in [3099d249](https://github.com/getsentry/sentry-api-schema/commit/3099d249c6ecfe7d51d3308d78902de00e84247b) +- Generated by @gggritso in [d12bf35d](https://github.com/getsentry/sentry-api-schema/commit/d12bf35d26e7e3fe9eacf3a04a034ee1535d693c) +- Generated by @manessaraj in [3f13c2e8](https://github.com/getsentry/sentry-api-schema/commit/3f13c2e80be994064f35460a424ecf01e44dd202) +- Generated by @lzhao-sentry in [2cb441c4](https://github.com/getsentry/sentry-api-schema/commit/2cb441c4511d32661687b8a566da647a3c1c0deb) +- Generated by @vgrozdanic in [81eaffc5](https://github.com/getsentry/sentry-api-schema/commit/81eaffc54caf1bdf1990b72d013c97c1e8058988) +- Generated by @obostjancic in [8302a930](https://github.com/getsentry/sentry-api-schema/commit/8302a93056158643f01755e60dda6638905483d3) +- Generated by @shellmayr in [35974eba](https://github.com/getsentry/sentry-api-schema/commit/35974ebad9d43eaed78cebda0db11545ca4992c6) +- Generated by @manessaraj in [a1824c25](https://github.com/getsentry/sentry-api-schema/commit/a1824c257dc164ac2d830e6fcef5410e34a1b8d2) +- Generated by @JoshuaKGoldberg in [096cbc78](https://github.com/getsentry/sentry-api-schema/commit/096cbc7896b72cc523fb422f327aae973ab0b5c4) +- Generated by @kcons in [315d75c2](https://github.com/getsentry/sentry-api-schema/commit/315d75c2793e9aacf34d6685cd6dac8e083d83b5) +- Generated by @elramen in [039965a8](https://github.com/getsentry/sentry-api-schema/commit/039965a844f9bbd7f90bca18fbd4f1e7ca8c64ea) +- Generated by @wedamija in [658c82e5](https://github.com/getsentry/sentry-api-schema/commit/658c82e501665b9a1977a0136ece4040d73efcd9) +- Generated by @chromy in [0a3f51ed](https://github.com/getsentry/sentry-api-schema/commit/0a3f51ed3397530cdbb2de8d03f15625d0748bde) +- Generated by @michelletran-sentry in [226d4470](https://github.com/getsentry/sentry-api-schema/commit/226d4470bf70f5617eb1b4f8f4852a98e2dba97b) +- Generated by @shayna-ch in [42a3fbd5](https://github.com/getsentry/sentry-api-schema/commit/42a3fbd5b586bb5e7ebf44fad72b54b9bea0241f) +- Generated by @vgrozdanic in [93f6e8a0](https://github.com/getsentry/sentry-api-schema/commit/93f6e8a0bf70a8832f87cdc50ab8148713b739a5) +- Generated by @shellmayr in [04a8a210](https://github.com/getsentry/sentry-api-schema/commit/04a8a21082ecbb1aa332004cbf524b5bd8aa9783) +- Generated by @wedamija in [5872d636](https://github.com/getsentry/sentry-api-schema/commit/5872d636010f585d69bc984430ab251b53169f7f) + +## 0.354.0 + +### Schema Updates + +- Generated by @wedamija in [9a694814](https://github.com/getsentry/sentry-api-schema/commit/9a6948146a4f87cb5992f93f6923e0f0ede1081d) + +## 0.353.0 + +### Schema Updates + +- Generated by @wedamija in [085226e4](https://github.com/getsentry/sentry-api-schema/commit/085226e4d95c1254c8fc9b97cab3a84409783450) + +## 0.352.0 + +### Schema Updates + +- Generated by @wedamija in [e9ca0c8b](https://github.com/getsentry/sentry-api-schema/commit/e9ca0c8b1960f3d393bd3cac5315dbe37493a98d) + +## 0.351.0 + +### Schema Updates + +- Generated by @wedamija in [a706585e](https://github.com/getsentry/sentry-api-schema/commit/a706585eabc373f938982e850b1dcfa564fa4510) + +## 0.350.0 + +### Schema Updates + +- Generated by @wedamija in [f5bea8a4](https://github.com/getsentry/sentry-api-schema/commit/f5bea8a447f661b3a6a739dab32059978b0fddd1) + +## 0.349.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [d3f58b86](https://github.com/getsentry/sentry-api-schema/commit/d3f58b86c8046a62a30a86d5c2312e32f586f00e) + +## 0.348.0 + +### Schema Updates + +- Generated by @kcons in [53a732a0](https://github.com/getsentry/sentry-api-schema/commit/53a732a0bebdc7eb55c449c96514dff647ca4b90) + +## 0.347.0 + +### Schema Updates + +- Generated by @natemoo-re in [1491adb5](https://github.com/getsentry/sentry-api-schema/commit/1491adb5c6884b194604fce7ae2ab84edf86d795) + +## 0.346.0 + +### Schema Updates + +- Generated by @Abdkhan14 in [668b272e](https://github.com/getsentry/sentry-api-schema/commit/668b272ecbd251cfa8e18672b8f415051982e75a) + +## 0.345.0 + +### Schema Updates + +- Generated by @vgrozdanic in [6bc278f0](https://github.com/getsentry/sentry-api-schema/commit/6bc278f086f760ca9e4609486f8c63a504e3f4bf) + +## 0.344.0 + +### Schema Updates + +- Generated by @cvxluo in [2be24da3](https://github.com/getsentry/sentry-api-schema/commit/2be24da38cb4d74f6f08cb994c8316fcb272b721) + +## 0.343.0 + +### Schema Updates + +- Generated by @manessaraj in [e745d94b](https://github.com/getsentry/sentry-api-schema/commit/e745d94b4582798aa6a5ff499ae1cc9b7e71fa1d) + +## 0.342.0 + +### Schema Updates + +- Generated by @wmak in [5843a5d9](https://github.com/getsentry/sentry-api-schema/commit/5843a5d9d6e7e11dfd5152842cc959ff44f7fb76) + +## 0.341.0 + +### Schema Updates + +- Generated by @scttcper in [8b988d67](https://github.com/getsentry/sentry-api-schema/commit/8b988d67f5626eeef3b23697404f0f28a9d10cae) + +## 0.340.0 + +### Schema Updates + +- Generated by @wmak in [1cdff362](https://github.com/getsentry/sentry-api-schema/commit/1cdff36261d6fe284bd9c13bc0d45d55980847f6) + +## 0.339.0 + +### New Features + +- Bind pagination helpers to configured clients by @betegon in [#100](https://github.com/getsentry/sentry-api-schema/pull/100) + +## 0.338.0 + +### Schema Updates + +- Generated by @edwardgou-sentry in [b0ddb09a](https://github.com/getsentry/sentry-api-schema/commit/b0ddb09ae5f762096465f60753c3f3a1462e7008) + +## 0.337.0 + +### Schema Updates + +- Generated by @mujacica in [1bbff839](https://github.com/getsentry/sentry-api-schema/commit/1bbff8391755c0a4ca9847a013b6075d35ed4126) + +## 0.336.0 + +### Schema Updates + +- Generated by @vgrozdanic in [cf18b18e](https://github.com/getsentry/sentry-api-schema/commit/cf18b18efab902fe571cfbde41aff4c6ee2d1671) + +## 0.335.0 + +### New Features + +- Add configured clients with bound API methods by @betegon in [#98](https://github.com/getsentry/sentry-api-schema/pull/98) + +### Schema Updates + +- Generated by @trevor-e in [e11453b7](https://github.com/getsentry/sentry-api-schema/commit/e11453b7c6467e79f2f4177970fde1e4705fe507) +- Generated by @itsdangold in [f9ba1e4f](https://github.com/getsentry/sentry-api-schema/commit/f9ba1e4f146af9e88963569ca0e6d9c0e0e821cc) +- Generated by @alexsohn1126 in [84e16e57](https://github.com/getsentry/sentry-api-schema/commit/84e16e577f09b19f09c95276600987a920894f58) +- Generated by @geoffg-sentry in [a81fddf6](https://github.com/getsentry/sentry-api-schema/commit/a81fddf6ca5bc8dd444b51d1a5ab0d943d2c60f3) +- Generated by @Abdkhan14 in [a11887c6](https://github.com/getsentry/sentry-api-schema/commit/a11887c6976f51fd1f590325d48e16cc6a850634) +- Generated by @shellmayr in [9f520b8b](https://github.com/getsentry/sentry-api-schema/commit/9f520b8b5d1e73d9bb7cc3ba5bf721a4db157719) +- Generated by @evanpurkhiser in [719970af](https://github.com/getsentry/sentry-api-schema/commit/719970afe8f87e3ea295ba886e48893ed015463a) +- Generated by @kcons in [06cb13ab](https://github.com/getsentry/sentry-api-schema/commit/06cb13ab7b291b29688efae51b01b29a790c4bf9) +- Generated by @edwardgou-sentry in [8e9ce9d4](https://github.com/getsentry/sentry-api-schema/commit/8e9ce9d42fbb0c21ab7367e08521d2072d96ebac) + +## 0.334.0 + +### Schema Updates + +- Generated by @joseph-sentry in [2727fc4f](https://github.com/getsentry/sentry-api-schema/commit/2727fc4f2c424566e33878e1373f91b050098b05) + +## 0.333.0 + +### Schema Updates + +- Generated by @vaind in [a6d3ba39](https://github.com/getsentry/sentry-api-schema/commit/a6d3ba39678845244a2186a3ceafd05def605bbe) + +## 0.332.0 + +### Schema Updates + +- Generated by @vaind in [134f28db](https://github.com/getsentry/sentry-api-schema/commit/134f28dbf58771ed0f21d4a16bed0159411d1e06) + +## 0.331.0 + +### Schema Updates + +- Generated by @wedamija in [11258589](https://github.com/getsentry/sentry-api-schema/commit/11258589eba6efae0e93e0edcd0bbad4b822c867) + +## 0.330.0 + +### Schema Updates + +- Generated by @joseph-sentry in [50de81d4](https://github.com/getsentry/sentry-api-schema/commit/50de81d49cec2ddf096828781ad23858f91757b0) + +## 0.329.0 + +### Schema Updates + +- Generated by @TooGood007 in [f9401202](https://github.com/getsentry/sentry-api-schema/commit/f940120244850f0d116bef0966d53066bf2e536c) + +## 0.328.0 + +### Schema Updates + +- Generated by @cvxluo in [7148ca11](https://github.com/getsentry/sentry-api-schema/commit/7148ca117a2e2ed0f6e368e5742a94968803b60c) + +## 0.327.0 + +### Schema Updates + +- Generated by @skaasten in [3584ce76](https://github.com/getsentry/sentry-api-schema/commit/3584ce7643421883b8ed11f6daf555ea3590e455) + +## 0.326.0 + +### Schema Updates + +- Generated by @chromy in [34e8b351](https://github.com/getsentry/sentry-api-schema/commit/34e8b35182bd581e130fc6cba0debad506e6ed5c) + +## 0.325.0 + +### Schema Updates + +- Generated by @michelletran-sentry in [73f38fe7](https://github.com/getsentry/sentry-api-schema/commit/73f38fe74ab9d89cc347ca7e4771dc7465dcc2a0) + +## 0.324.0 + +### Schema Updates + +- Generated by @adrianviquez in [ea6ffa7e](https://github.com/getsentry/sentry-api-schema/commit/ea6ffa7ea4ed5ab5351ad3860e3e21ab4d67bfda) + +## 0.323.0 + +### Schema Updates + +- Generated by @wedamija in [9d4313d2](https://github.com/getsentry/sentry-api-schema/commit/9d4313d29157574efcf600d50ad0f5efbb6d53f6) + +## 0.322.0 + +### Schema Updates + +- Generated by @shashjar in [9bfb23c1](https://github.com/getsentry/sentry-api-schema/commit/9bfb23c12ec7d58b3d74d582b307c181b6565b45) + +## 0.321.0 + +### Schema Updates + +- Generated by @DominikB2014 in [524a9e76](https://github.com/getsentry/sentry-api-schema/commit/524a9e763fc05ffa7d483bac6a230be2be1c2461) + +## 0.320.0 + +### Schema Updates + +- Generated by @nsdeschenes in [240743b6](https://github.com/getsentry/sentry-api-schema/commit/240743b629fdc854c97cd4cbf80c7506329c7311) + +## 0.319.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [65c00a2f](https://github.com/getsentry/sentry-api-schema/commit/65c00a2f9e67e967d2dc4c40e75c3f3d686637da) + +## 0.318.0 + +### Schema Updates + +- Generated by @joseph-sentry in [18ab08d7](https://github.com/getsentry/sentry-api-schema/commit/18ab08d74449e1147747b7dbc28e85426d414a25) + +## 0.317.0 + +### Schema Updates + +- Generated by @joseph-sentry in [85a04afd](https://github.com/getsentry/sentry-api-schema/commit/85a04afd323e13fa975043044fe4c3d31142dab6) +- Generated by @billyvg in [b7e74aa5](https://github.com/getsentry/sentry-api-schema/commit/b7e74aa55d2a94cc82411e1dc7f59e97860e29ac) + +## 0.316.0 + +### Schema Updates + +- Generated by @lzhao-sentry in [18b669c2](https://github.com/getsentry/sentry-api-schema/commit/18b669c22ecf487bcdfb83f81f7221d9301e5a75) + +## 0.315.0 + +### Schema Updates + +- Generated by @edwardgou-sentry in [5ddb878b](https://github.com/getsentry/sentry-api-schema/commit/5ddb878bd25c06677f0639efa3e30985ad367e4b) + +## 0.314.0 + +### Schema Updates + +- Generated by @azulus in [59ed6e4b](https://github.com/getsentry/sentry-api-schema/commit/59ed6e4bf6a05501545335ef3da0a4756c30b683) + +## 0.313.0 + +### Schema Updates + +- Generated by @trevor-e in [5ecd0653](https://github.com/getsentry/sentry-api-schema/commit/5ecd06538b64395862a6a482492a57744a92e62a) + +## 0.312.0 + +### Schema Updates + +- Generated by @azulus in [854ac4a3](https://github.com/getsentry/sentry-api-schema/commit/854ac4a3b401aede0e04a9812db5a7e643c2bd0b) + +## 0.311.0 + +### Schema Updates + +- Generated by @betegon in [3cd00065](https://github.com/getsentry/sentry-api-schema/commit/3cd0006590a57b797906bdedece6e4183f2f1540) + +## 0.310.0 + +### Schema Updates + +- Generated by @DominikB2014 in [c6c7b3b8](https://github.com/getsentry/sentry-api-schema/commit/c6c7b3b8743b08a624a819e74ae1784ec4c3b601) + +## 0.309.0 + +### Schema Updates + +- Generated by @hobzcalvin in [c73e649f](https://github.com/getsentry/sentry-api-schema/commit/c73e649f8cb483a39f405300d4463db88dbfd069) + +## 0.308.0 + +### Schema Updates + +- Generated by @betegon in [db227f52](https://github.com/getsentry/sentry-api-schema/commit/db227f52068dbcdb05dbf7ff5b7da895d29e5c56) +- Generated by @kcons in [9124c9d8](https://github.com/getsentry/sentry-api-schema/commit/9124c9d85b3e3f55ebbeaf780e94700b1c5b96eb) + +## 0.307.0 + +### Schema Updates + +- Generated by @edwardgou-sentry in [5a214fcb](https://github.com/getsentry/sentry-api-schema/commit/5a214fcb5ff9fa4a0a1ce70ccc571eb9490cecf3) + +## 0.306.0 + +### Schema Updates + +- Generated by @shellmayr in [f3ad8f52](https://github.com/getsentry/sentry-api-schema/commit/f3ad8f5259c607b380ee62206a02662970f51630) + +## 0.305.0 + +### Schema Updates + +- Generated by @DominikB2014 in [fc1c684b](https://github.com/getsentry/sentry-api-schema/commit/fc1c684bd703bf8ec1468306aa8dffc168f016bd) + +## 0.304.0 + +### Schema Updates + +- Generated by @malwilley in [44a4a59b](https://github.com/getsentry/sentry-api-schema/commit/44a4a59baebac6be758145b67fdb1727c1f1a2eb) + +## 0.303.0 + +### Schema Updates + +- Generated by @DominikB2014 in [0a084d14](https://github.com/getsentry/sentry-api-schema/commit/0a084d1474820a1aea39afd48975df34dcf4f3f9) + +## 0.302.0 + +### Schema Updates + +- Generated by @lzhao-sentry in [f8fc4216](https://github.com/getsentry/sentry-api-schema/commit/f8fc4216b7117b6676d1b62831506c569f156003) + +## 0.301.0 + +### Schema Updates + +- Generated by @gricha in [cef0a0d3](https://github.com/getsentry/sentry-api-schema/commit/cef0a0d3160122685f79ca5dc5f171b35f96aeab) + +## 0.300.0 + +### Schema Updates + +- Generated by @betegon in [9ca8818f](https://github.com/getsentry/sentry-api-schema/commit/9ca8818fc7e7d0b51c05eb87679f641ba715fdab) + +## 0.299.0 + +### Schema Updates + +- Generated by @shashjar in [4bcff779](https://github.com/getsentry/sentry-api-schema/commit/4bcff7797e0f3874fcbd8ac6bc6e3fc8dd8dea8a) + +## 0.298.0 + +### Schema Updates + +- Generated by @betegon in [1447b94b](https://github.com/getsentry/sentry-api-schema/commit/1447b94b668c765fbc40bbf4acf44d068b3cecbe) + +## 0.297.0 + +### Schema Updates + +- Generated by @vgrozdanic in [90bd9333](https://github.com/getsentry/sentry-api-schema/commit/90bd9333ad099b8bfd061c58870fcde4868fd9ee) + +## 0.296.0 + +### Schema Updates + +- Generated by @manessaraj in [c848a1ea](https://github.com/getsentry/sentry-api-schema/commit/c848a1ea7ea6ffc642ea1dfe882e3c594272e837) + +## 0.295.0 + +### Schema Updates + +- Generated by @cvxluo in [dc3ce9c7](https://github.com/getsentry/sentry-api-schema/commit/dc3ce9c716ce5526c23413b4a66b45be9f1f4b32) + +## 0.294.0 + +### Schema Updates + +- Generated by @jaydgoss in [366d72bb](https://github.com/getsentry/sentry-api-schema/commit/366d72bbcea71beeacfb3b1fde7b6f28822dcaab) + +## 0.293.0 + +### Schema Updates + +- Generated by @oioki in [d0a88f9a](https://github.com/getsentry/sentry-api-schema/commit/d0a88f9a34d8583e6dc287570a12bdf684277d11) + +## 0.292.0 + +### Schema Updates + +- Generated by @vgrozdanic in [c492fad1](https://github.com/getsentry/sentry-api-schema/commit/c492fad1cd1d72f98b2763e396a6bae9453fe060) +- Generated by @gricha in [983eca93](https://github.com/getsentry/sentry-api-schema/commit/983eca9306b303535076f48e6f3bd8af8e283840) + +## 0.291.0 + +### Schema Updates + +- Generated by @shayna-ch in [2e34a96c](https://github.com/getsentry/sentry-api-schema/commit/2e34a96ce198d4bde70df871f8e76cee14cfd25f) +- Generated by @vgrozdanic in [34fec1f9](https://github.com/getsentry/sentry-api-schema/commit/34fec1f96673543eae21cba0a0857c452148c775) +- Generated by @azulus in [0b5ed45a](https://github.com/getsentry/sentry-api-schema/commit/0b5ed45ad9a5f42e04a87bee193d017d3aeef724) +- Generated by @azulus in [46ad4625](https://github.com/getsentry/sentry-api-schema/commit/46ad462564bf149a0eb681fb3708f4df1fbf53b2) +- Generated by @azulus in [08f2ce15](https://github.com/getsentry/sentry-api-schema/commit/08f2ce15e5c116e529d5983af9ca14c504789de1) +- Generated by @azulus in [d72af0a7](https://github.com/getsentry/sentry-api-schema/commit/d72af0a7124d599bd74fe6b6b1ad31f5a0052eb6) +- Generated by @malwilley in [163dc8e1](https://github.com/getsentry/sentry-api-schema/commit/163dc8e11ed1f0a92153190086438a5856ad1065) +- Generated by @wedamija in [420a3c1c](https://github.com/getsentry/sentry-api-schema/commit/420a3c1ccd6dc0c7747e892e53bca513427175da) +- Generated by @azulus in [69fc9944](https://github.com/getsentry/sentry-api-schema/commit/69fc99449b4bf1cc79c59cb263e466aa7cda7615) +- Generated by @azulus in [918d5899](https://github.com/getsentry/sentry-api-schema/commit/918d58995b11bfd87f35fc4c3cee4053631b3883) +- Generated by @wedamija in [1648d68b](https://github.com/getsentry/sentry-api-schema/commit/1648d68b48e6b693f24df1fb119cb11f5cdf4f00) +- Generated by @wedamija in [bde86afe](https://github.com/getsentry/sentry-api-schema/commit/bde86afec3cc9fb82402b9e7a92190fb57cc6d53) +- Generated by @malwilley in [48029718](https://github.com/getsentry/sentry-api-schema/commit/48029718fe786adb9470749b55f75301d6b6782e) +- Generated by @malwilley in [36de377c](https://github.com/getsentry/sentry-api-schema/commit/36de377c0d3edbf85977766f206a6eb9bc39b934) +- Generated by @vgrozdanic in [28d5c609](https://github.com/getsentry/sentry-api-schema/commit/28d5c609bddaef9876c63766984e89ef5076bcd2) +- Generated by @sentaur-athena in [83241eb9](https://github.com/getsentry/sentry-api-schema/commit/83241eb908b6cb90965879c2df72ee2e37e55a6b) +- Generated by @lynnagara in [27051eba](https://github.com/getsentry/sentry-api-schema/commit/27051eba0269d0d48085e9399d9533389d851562) +- Generated by @lynnagara in [61b08f2b](https://github.com/getsentry/sentry-api-schema/commit/61b08f2b037979c68b4f2ac01f9ada8a34ef3754) +- Generated by @lzhao-sentry in [72322d34](https://github.com/getsentry/sentry-api-schema/commit/72322d34a677a84c1b5fca93e8132694ce87ccc6) +- Generated by @Christinarlong in [9efb8d21](https://github.com/getsentry/sentry-api-schema/commit/9efb8d21798705e6792b6b102452c345e88e7630) +- Generated by @cvxluo in [727c6695](https://github.com/getsentry/sentry-api-schema/commit/727c6695cb0851694f1548dd95fa26f107712010) +- Generated by @leeandher in [6bc17447](https://github.com/getsentry/sentry-api-schema/commit/6bc174475b86bd91912a461ee039364aef12a075) +- Generated by @chromy in [8670517a](https://github.com/getsentry/sentry-api-schema/commit/8670517a95f587c8c12da4b7c8e1ec31229ccdc0) +- Generated by @evanpurkhiser in [3059c950](https://github.com/getsentry/sentry-api-schema/commit/3059c95094d2d258f592b229d503f863f28c24d7) +- Generated by @getsentry-bot in [5d059189](https://github.com/getsentry/sentry-api-schema/commit/5d059189d388df5b0b3142813eff2323a04fc46b) + +## 0.290.0 + +### Schema Updates + +- Generated by @getsentry-bot in [41399a83](https://github.com/getsentry/sentry-api-schema/commit/41399a837e298882253fa8b69056d4c52f56d7ef) + +## 0.289.0 + +### Schema Updates + +- Generated by @getsentry-bot in [570fbebd](https://github.com/getsentry/sentry-api-schema/commit/570fbebd8bf57c00eb70ca918e3161ef2c0c5008) + +## 0.288.0 + +### Schema Updates + +- Generated by @leeandher in [9cd4acc3](https://github.com/getsentry/sentry-api-schema/commit/9cd4acc3c0e53e7826489a63643221867890f1bc) +- Generated by @cvxluo in [c1998edc](https://github.com/getsentry/sentry-api-schema/commit/c1998edc5fadd7f9336d3aae2e08e2f13359a1e4) + +## 0.287.0 + +### Schema Updates + +- Generated by @cvxluo in [6f546772](https://github.com/getsentry/sentry-api-schema/commit/6f5467721b1e1079c7cebea96576e8444f31f640) + +## 0.286.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [d0553b6f](https://github.com/getsentry/sentry-api-schema/commit/d0553b6f730b20b5cc39432876097af8fe855a7b) + +## 0.285.0 + +### Schema Updates + +- Generated by @Zylphrex in [ebc836fe](https://github.com/getsentry/sentry-api-schema/commit/ebc836fef077a7877afbb6239ff4858d282ef43b) +- Generated by @untitaker in [b9b30c9d](https://github.com/getsentry/sentry-api-schema/commit/b9b30c9d24b729896134d786c1a2cb3d94d454c0) + +## 0.284.0 + +### Schema Updates + +- Generated by @billyvg in [5dfd40ff](https://github.com/getsentry/sentry-api-schema/commit/5dfd40ff76948f636ba224d325c84bc297030a3b) + +## 0.283.0 + +### Schema Updates + +- Generated by @skaasten in [f8da1d6c](https://github.com/getsentry/sentry-api-schema/commit/f8da1d6ccf0387e3d7d1f4c1adc3e1d4882ade2a) + +## 0.282.0 + +### Schema Updates + +- Generated by @markstory in [8b3c72b3](https://github.com/getsentry/sentry-api-schema/commit/8b3c72b354019bd6ec51aef8645b4c36d145da19) +- Generated by @mchen-sentry in [a5bc4cfe](https://github.com/getsentry/sentry-api-schema/commit/a5bc4cfe96741a3360c4030b445c0b382243cfb4) +- Generated by @alexsohn1126 in [4b9b7fd7](https://github.com/getsentry/sentry-api-schema/commit/4b9b7fd7f5268a14443f24a5da767b8d65e52024) + +## 0.281.0 + +### Schema Updates + +- Generated by @NicoHinderling in [ede20afa](https://github.com/getsentry/sentry-api-schema/commit/ede20afa3e908fb29dde17548df0c1cd010da3a6) + +## 0.280.0 + +### Schema Updates + +- Generated by @shashjar in [f64f2f51](https://github.com/getsentry/sentry-api-schema/commit/f64f2f5152611cb9ffdcbea5e8d7b8e812533f24) + +## 0.279.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [eee904ba](https://github.com/getsentry/sentry-api-schema/commit/eee904ba4512816da901e56caebf82a97e9db79a) + +## 0.278.0 + +### Schema Updates + +- Generated by @trevor-e in [d3053660](https://github.com/getsentry/sentry-api-schema/commit/d3053660a3d4f88225a9f27bbc1e3a5c1d163331) + +## 0.277.0 + +### Schema Updates + +- Generated by @arslnb in [3c51e248](https://github.com/getsentry/sentry-api-schema/commit/3c51e248820559fbd363b1e058478d2159195748) + +## 0.276.0 + +### Schema Updates + +- Generated by @cvxluo in [0137f561](https://github.com/getsentry/sentry-api-schema/commit/0137f56103d4dbee4ccd03a48a20b2d7ad12618e) + +## 0.275.0 + +### Schema Updates + +- Generated by @cvxluo in [93d5a744](https://github.com/getsentry/sentry-api-schema/commit/93d5a74496421d2203d45cc886cdc85050f6c1b4) + +## 0.274.0 + +### Schema Updates + +- Generated by @cvxluo in [1d7d49c7](https://github.com/getsentry/sentry-api-schema/commit/1d7d49c7133f5ea1a872853e2d5c17f4e83ffb29) + +## 0.273.0 + +### Schema Updates + +- Generated by @vgrozdanic in [44be28c1](https://github.com/getsentry/sentry-api-schema/commit/44be28c1219c9e1c6b40538fa2a5fa5fb65ed891) + +## 0.272.0 + +### Schema Updates + +- Generated by @NicoHinderling in [160e33fe](https://github.com/getsentry/sentry-api-schema/commit/160e33fe9196369b31ec59e75ddc44fd15e0393d) + +## 0.271.0 + +### Schema Updates + +- Generated by @wedamija in [541d37ed](https://github.com/getsentry/sentry-api-schema/commit/541d37edf72958cfd8203242466f67ba17cd0f92) + +## 0.270.0 + +### Schema Updates + +- Generated by @wedamija in [91581e75](https://github.com/getsentry/sentry-api-schema/commit/91581e758eddc8cb153dac0cde5bc14cfef5d297) + +## 0.269.0 + +### Schema Updates + +- Generated by @shashjar in [832445f9](https://github.com/getsentry/sentry-api-schema/commit/832445f9f84f0f46b7f0d942c39ca55cac1042b8) + +## 0.268.0 + +### Schema Updates + +- Generated by @hobzcalvin in [79207276](https://github.com/getsentry/sentry-api-schema/commit/79207276bee96c42348ebc245c91a98a0b056839) + +## 0.267.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [919bab45](https://github.com/getsentry/sentry-api-schema/commit/919bab4543314f3495f0c8c18b4fd830ba1559ba) + +## 0.266.0 + +### Schema Updates + +- Generated by @trevor-e in [ec52ec01](https://github.com/getsentry/sentry-api-schema/commit/ec52ec012dc033a6adf325ba3bf4362b9d691a96) + +## 0.265.0 + +### Schema Updates + +- Generated by @shayna-ch in [b875d0c2](https://github.com/getsentry/sentry-api-schema/commit/b875d0c25ba3e42f2ac688616146a65de55c6a9a) + +## 0.264.0 + +### Schema Updates + +- Generated by @arslnb in [6996a9ef](https://github.com/getsentry/sentry-api-schema/commit/6996a9eff04ce85910a557e7e308120965fc6885) + +## 0.263.0 + +### Schema Updates + +- Generated by @wedamija in [5a3a5c15](https://github.com/getsentry/sentry-api-schema/commit/5a3a5c152779254cac0e68ced8cee09c893b0574) + +## 0.262.0 + +### Schema Updates + +- Generated by @wedamija in [c3394cb9](https://github.com/getsentry/sentry-api-schema/commit/c3394cb9febace567e483937dac5660a174ad5b1) + +## 0.261.0 + +### Schema Updates + +- Generated by @wedamija in [37a6d4bf](https://github.com/getsentry/sentry-api-schema/commit/37a6d4bf804849929c4f00852486206d49ce6157) + +## 0.260.0 + +### Schema Updates + +- Generated by @NicoHinderling in [f55a5ddc](https://github.com/getsentry/sentry-api-schema/commit/f55a5ddc7b74636caff37308263211e3755675ea) + +## 0.259.0 + +### Schema Updates + +- Generated by @skaasten in [b6abd518](https://github.com/getsentry/sentry-api-schema/commit/b6abd5185547a77c6f16746e32ff895e032d5946) + +## 0.258.0 + +### Schema Updates + +- Generated by @jan-auer in [63308dab](https://github.com/getsentry/sentry-api-schema/commit/63308dab7375c79c93bafd8db07dc7f303176931) + +## 0.257.0 + +### Schema Updates + +- Generated by @leeandher in [a69136fa](https://github.com/getsentry/sentry-api-schema/commit/a69136fa2d8d3d86f82bf154aaa1583586314aaf) + +## 0.256.0 + +### New Features + +- Add Valibot validation schemas by @BYK in [#90](https://github.com/getsentry/sentry-api-schema/pull/90) + +## 0.255.0 + +### New Features + +- Expose typed, status-discriminated API errors by @jared-outpost in [#84](https://github.com/getsentry/sentry-api-schema/pull/84) + +## 0.254.1 + +### Bug Fixes + +- Serialize release workflows by @BYK in [#89](https://github.com/getsentry/sentry-api-schema/pull/89) + +## 0.254.0 + +### Schema Updates + +- Generated by @manessaraj in [5f2791a0](https://github.com/getsentry/sentry-api-schema/commit/5f2791a085c5d9eb8a36da76275818251252b92f) +- Generated by @trevor-e in [cae3ca82](https://github.com/getsentry/sentry-api-schema/commit/cae3ca822a257c9de5ab26b96622eb8b5c8163ff) +- Generated by @DominikB2014 in [8bec519c](https://github.com/getsentry/sentry-api-schema/commit/8bec519c5678a5abda03989bbb1dd02817618626) +- Generated by @vaind in [02ed99c9](https://github.com/getsentry/sentry-api-schema/commit/02ed99c976bc153254ce8774d18264c25efda79c) +- Generated by @jamieQ in [18b3c5f4](https://github.com/getsentry/sentry-api-schema/commit/18b3c5f46495e59a660409cbece38612eb11ae7d) +- Generated by @mjq in [9cba943e](https://github.com/getsentry/sentry-api-schema/commit/9cba943e3649482d82b594792335f1d34046f999) +- Generated by @gricha in [227802b6](https://github.com/getsentry/sentry-api-schema/commit/227802b60fbfb7c0189e5f0a40883707432739cf) +- Generated by @malwilley in [94de4415](https://github.com/getsentry/sentry-api-schema/commit/94de4415d6acc83073b7e4f762b6c08cb319e32b) +- Generated by @leeandher in [6fed1a9a](https://github.com/getsentry/sentry-api-schema/commit/6fed1a9aa70d34f8a5781e72d7e9adb26a8659af) +- Generated by @jamieQ in [7250c210](https://github.com/getsentry/sentry-api-schema/commit/7250c210226c5e9c2ae7ac0af585b0624868fed2) +- Generated by @getsentry-bot in [8bedb103](https://github.com/getsentry/sentry-api-schema/commit/8bedb10339a034a0c832cdb03ac6a52563f162f5) +- Generated by @leeandher in [2a0fe7f6](https://github.com/getsentry/sentry-api-schema/commit/2a0fe7f63c235c15281d67c103bb5f9d6b8099fb) +- Generated by @Zylphrex in [8d72319b](https://github.com/getsentry/sentry-api-schema/commit/8d72319ba0bdc05f5b9103abc2d260629b0dad8d) +- Generated by @ceorourke in [ed4b9162](https://github.com/getsentry/sentry-api-schema/commit/ed4b916246b9d7798e0445fddf4685954c7b7b81) +- Generated by @kcons in [a3e720a3](https://github.com/getsentry/sentry-api-schema/commit/a3e720a382c43b932d007f277818811348e043e4) +- Generated by @strongs in [756835da](https://github.com/getsentry/sentry-api-schema/commit/756835daa6540961e3d649750f7ba4e7d853f8d8) +- Generated by @scttcper in [d7f80047](https://github.com/getsentry/sentry-api-schema/commit/d7f800479dbe9fa77dbc426be705114974adc7e6) +- Generated by @cvxluo in [4a5dcc2f](https://github.com/getsentry/sentry-api-schema/commit/4a5dcc2f65c2b2d379a429f6582fa6c4b7431e10) +- Generated by @mtopo27 in [0aaa6305](https://github.com/getsentry/sentry-api-schema/commit/0aaa6305cead18ef2ec6283220fbeb385e6bb8d0) +- Generated by @malwilley in [72828ef4](https://github.com/getsentry/sentry-api-schema/commit/72828ef4357d1b8fb8503d59f2f571f75008c6f9) +- Generated by @shellmayr in [1f0777df](https://github.com/getsentry/sentry-api-schema/commit/1f0777dfc7ed9d98d475e13a1970ce8229395329) +- Generated by @chromy in [87ee7a4f](https://github.com/getsentry/sentry-api-schema/commit/87ee7a4f4b22d87b562f6345e8948120d38ea5ea) +- Generated by @giovanni-guidini in [7f478560](https://github.com/getsentry/sentry-api-schema/commit/7f4785608de8b2cb15e9a5d0c9355e0b6e95c725) +- Generated by @cvxluo in [4c93b65e](https://github.com/getsentry/sentry-api-schema/commit/4c93b65e9639e1ad501112346904a41e546ba299) +- Generated by @DominikB2014 in [6241af29](https://github.com/getsentry/sentry-api-schema/commit/6241af2965c531c3dfd01befc147d99d9f835531) +- Generated by @adrianviquez in [7ba6ca85](https://github.com/getsentry/sentry-api-schema/commit/7ba6ca856527de3ec25999d0c6a1662a3fd98847) +- Generated by @trevor-e in [4eb0df78](https://github.com/getsentry/sentry-api-schema/commit/4eb0df78e7fbfb6a139d4a6713fc527c2f27bcec) +- Generated by @cvxluo in [a1fea711](https://github.com/getsentry/sentry-api-schema/commit/a1fea7114b32b91d8488b84a71845372d51d5bc4) +- Generated by @thetruecpaul in [66bb90af](https://github.com/getsentry/sentry-api-schema/commit/66bb90afc6298e6e992379298b2647c9f1ffa9cc) +- Generated by @cvxluo in [4105c980](https://github.com/getsentry/sentry-api-schema/commit/4105c980463f26705be9b8334868198254f8c9ee) +- Generated by @DominikB2014 in [b9c67df9](https://github.com/getsentry/sentry-api-schema/commit/b9c67df9093b245b25749cab3c745d757e329e0a) +- Generated by @strongs in [ceedaeba](https://github.com/getsentry/sentry-api-schema/commit/ceedaeba261163ad07134f57743bc122df654fc6) +- Generated by @mjq in [59ee4171](https://github.com/getsentry/sentry-api-schema/commit/59ee4171a9a92f92b51387fb0cc96c68d2a1296e) +- Generated by @Abdkhan14 in [d2b01e92](https://github.com/getsentry/sentry-api-schema/commit/d2b01e92af0b3eabad6bba5310770551172ecafe) +- Generated by @strongs in [a59c2957](https://github.com/getsentry/sentry-api-schema/commit/a59c2957f5b07b53e66c7a5801616f0f85dc9506) +- Generated by @cvxluo in [2f22baf0](https://github.com/getsentry/sentry-api-schema/commit/2f22baf043afadbbbad11f6e1c35c81f459ec9e1) +- Generated by @sehr-m in [31ec0685](https://github.com/getsentry/sentry-api-schema/commit/31ec0685b93d5f3ed18f04bd27b56e9357ed1f47) +- Generated by @cvxluo in [1e75d6dd](https://github.com/getsentry/sentry-api-schema/commit/1e75d6dd77b72be6883ad4c1e6d45c3c16116b39) +- Generated by @cvxluo in [77e306d5](https://github.com/getsentry/sentry-api-schema/commit/77e306d529818da92c24f136b3156c35ce8a60be) +- Generated by @chromy in [e4b5e10e](https://github.com/getsentry/sentry-api-schema/commit/e4b5e10ebfcd7aea2d8beaaa0d4575f76456048e) +- Generated by @cvxluo in [c35d9f89](https://github.com/getsentry/sentry-api-schema/commit/c35d9f89140beb059ab0499a767bf767ce23a1ac) +- Generated by @getsentry-bot in [e892b7bc](https://github.com/getsentry/sentry-api-schema/commit/e892b7bcbb9f2f6c8130afa42c399fcd89f0d20b) +- Generated by @mjq in [107d2b3c](https://github.com/getsentry/sentry-api-schema/commit/107d2b3c3fb7aef341459239a697fbd4793fe5e3) +- Generated by @DominikB2014 in [59919193](https://github.com/getsentry/sentry-api-schema/commit/599191933708d06df5496b64eb13f983d70cda30) +- Generated by @ceorourke in [cabb8bf4](https://github.com/getsentry/sentry-api-schema/commit/cabb8bf4d88bf880c366306fa5682e1b93613821) +- Generated by @leeandher in [75a95e1c](https://github.com/getsentry/sentry-api-schema/commit/75a95e1ce7c5cb1bf19835a06587c181a3242a09) +- Generated by @joseph-sentry in [59c3f8eb](https://github.com/getsentry/sentry-api-schema/commit/59c3f8eb9215c1078284a219592a687595627ff0) +- Generated by @jan-auer in [88b25eb5](https://github.com/getsentry/sentry-api-schema/commit/88b25eb52e2092272d58a767c7ad1e0d32387603) +- Generated by @leeandher in [c8c12fce](https://github.com/getsentry/sentry-api-schema/commit/c8c12fcec8d5adbb582fa2d9a045dd552f54034f) +- Generated by @chromy in [41a6e96d](https://github.com/getsentry/sentry-api-schema/commit/41a6e96d56392c3f086ce2a45565be85ee84b9ea) +- Generated by @cvxluo in [04811e10](https://github.com/getsentry/sentry-api-schema/commit/04811e10fd9614ae9a122f1e8fded89bf40a375d) +- Generated by @cvxluo in [cb60ba5d](https://github.com/getsentry/sentry-api-schema/commit/cb60ba5d644b63eeb94b546de28cf4ea28e5d4cc) +- Generated by @amy-chen23 in [4a180088](https://github.com/getsentry/sentry-api-schema/commit/4a1800884eec06255645e6013d40ad8ffb310e39) +- Generated by @jamieQ in [9e606014](https://github.com/getsentry/sentry-api-schema/commit/9e606014a1f4fb1b5c7ce922a8262a37890737a8) +- Generated by @chromy in [4719f9c5](https://github.com/getsentry/sentry-api-schema/commit/4719f9c536f93ae1b64b2aa3534254d06214e6e1) +- Generated by @cvxluo in [5c4bbe78](https://github.com/getsentry/sentry-api-schema/commit/5c4bbe789c417ab90cafb52360a2818dcb7e7acd) +- Generated by @cvxluo in [5d3dc955](https://github.com/getsentry/sentry-api-schema/commit/5d3dc955533a1e3a7004f871c15a75a35f87d7ae) +- Generated by @leeandher in [7cc90205](https://github.com/getsentry/sentry-api-schema/commit/7cc90205911c83b9dddbdbb313a2ccbf3e457938) +- Generated by @cvxluo in [01eb1d48](https://github.com/getsentry/sentry-api-schema/commit/01eb1d48b3098c303024e0ace87c93d8ff7a42c5) +- Generated by @shellmayr in [25097f3a](https://github.com/getsentry/sentry-api-schema/commit/25097f3a33e1fa1c98e42c9e41a91824e2074c24) + +### Internal Changes + +- Re-sync rpc_method_schema for required user by @geoffg-sentry in [#86](https://github.com/getsentry/sentry-api-schema/pull/86) +- Sync rpc_method_schema for required user by @geoffg-sentry in [#85](https://github.com/getsentry/sentry-api-schema/pull/85) + +## 0.253.0 + +### Schema Updates + +- Generated by @ArthurKnaus in [d8ce370a](https://github.com/getsentry/sentry-api-schema/commit/d8ce370ae7203a4bdbc39ea0dd4690e055577444) + +## 0.252.0 + +### Schema Updates + +- Generated by @shellmayr in [e9e5b5bb](https://github.com/getsentry/sentry-api-schema/commit/e9e5b5bb0914d217af99fac7f3477bdfb13ee5e0) + +## 0.251.0 + +### Schema Updates + +- Generated by @buenaflor in [044e0c01](https://github.com/getsentry/sentry-api-schema/commit/044e0c01e8ac996ed31db4fef0a30999c496ece3) +- Generated by @Christinarlong in [e472f9d9](https://github.com/getsentry/sentry-api-schema/commit/e472f9d94cee7000b3d8cef3aefecf985e9e54cf) + +## 0.250.0 + +### Schema Updates + +- Generated by @jamieQ in [731b8b84](https://github.com/getsentry/sentry-api-schema/commit/731b8b84dc5f26bbbf1f183cf025195606da3f2c) + +## 0.249.0 + +### Schema Updates + +- Generated by @betegon in [332657f2](https://github.com/getsentry/sentry-api-schema/commit/332657f278291f948f9c20aca7b053e6e7509800) + +## 0.248.0 + +### Schema Updates + +- Generated by @srest2021 in [fdd171e7](https://github.com/getsentry/sentry-api-schema/commit/fdd171e710678bcdbc1b3071f642391ed1546bb4) + +## 0.247.0 + +### Schema Updates + +- Generated by @DominikB2014 in [f9f7ef6b](https://github.com/getsentry/sentry-api-schema/commit/f9f7ef6b8c7cf8bf672bc50d9b42eac016d398c7) + +## 0.246.0 + +### Schema Updates + +- Generated by @billyvg in [ef3131a6](https://github.com/getsentry/sentry-api-schema/commit/ef3131a65f2b0592612914f6fe2b1f5103bd51fd) + +## 0.245.0 + +### Schema Updates + +- Generated by @billyvg in [2e0b566c](https://github.com/getsentry/sentry-api-schema/commit/2e0b566c2033081effae710f994231cc08bf9d84) + +## 0.244.0 + +### Schema Updates + +- Generated by @romtsn in [8789eb2d](https://github.com/getsentry/sentry-api-schema/commit/8789eb2d6cac3fa5fba5bcdaffb26caaa36b8e36) + +## 0.243.0 + +### Schema Updates + +- Generated by @vgrozdanic in [45e296a8](https://github.com/getsentry/sentry-api-schema/commit/45e296a832dc4960d4bdeab8918d150fcbb0eccd) + +## 0.242.0 + +### Schema Updates + +- Generated by @shashjar in [97da120f](https://github.com/getsentry/sentry-api-schema/commit/97da120f8b16bb40ec9d01914322df47d435e5d9) + +## 0.241.0 + +### Schema Updates + +- Generated by @Christinarlong in [18190b7d](https://github.com/getsentry/sentry-api-schema/commit/18190b7d0f2be9ae1dda6ac97105d96febbe27f2) + +## 0.240.0 + +### Schema Updates + +- Generated by @DominikB2014 in [51fa7a23](https://github.com/getsentry/sentry-api-schema/commit/51fa7a23296150f95e3e8b7430ce1409affc0791) + +## 0.239.0 + +### Schema Updates + +- Generated by @vgrozdanic in [d9b87443](https://github.com/getsentry/sentry-api-schema/commit/d9b8744322cf5c79b975fdf934f3184739b11820) + +## 0.238.0 + +### Schema Updates + +- Generated by @Christinarlong in [9f4427a6](https://github.com/getsentry/sentry-api-schema/commit/9f4427a676371807dbf39ef45dc4ef31fda015af) + +## 0.237.0 + +### Schema Updates + +- Generated by @nsdeschenes in [c987dea9](https://github.com/getsentry/sentry-api-schema/commit/c987dea9b87208b64b2653e70bba1e0a7de83401) + +## 0.236.0 + +### Schema Updates + +- Generated by @mjq in [8d5779d6](https://github.com/getsentry/sentry-api-schema/commit/8d5779d65cbd86609b492e9589f8356f182a1b15) + +## 0.235.0 + +### Schema Updates + +- Generated by @NicoHinderling in [e4298c3b](https://github.com/getsentry/sentry-api-schema/commit/e4298c3b3994b0e46fcf2e2090d909c4c0d282d2) + +## 0.234.0 + +### Schema Updates + +- Generated by @Christinarlong in [6fc9b757](https://github.com/getsentry/sentry-api-schema/commit/6fc9b757dc555491dccc44464ba6dd501188d116) + +## 0.233.0 + +### Schema Updates + +- Generated by @DominikB2014 in [91c9e5e1](https://github.com/getsentry/sentry-api-schema/commit/91c9e5e1a5e22bba69b5f5e8ae13cff7fa5d3f4e) + +## 0.232.0 + +### Schema Updates + +- Generated by @betegon in [71b84ab6](https://github.com/getsentry/sentry-api-schema/commit/71b84ab6610998b63285aad41fbfbe7725480718) + +## 0.231.0 + +### Schema Updates + +- Generated by @cvxluo in [9ae2da20](https://github.com/getsentry/sentry-api-schema/commit/9ae2da20fe800aeca316c5453d982cb479af86ce) + +## 0.230.0 + +### Schema Updates + +- Generated by @DominikB2014 in [92812feb](https://github.com/getsentry/sentry-api-schema/commit/92812feb935417c575a5b87f73f53c6345b27a4b) + +## 0.229.0 + +### Schema Updates + +- Generated by @nsdeschenes in [e8f57be0](https://github.com/getsentry/sentry-api-schema/commit/e8f57be0e2fe215c83f127d0802d6b0b4cc98d2a) + +## 0.228.0 + +### Schema Updates + +- Generated by @betegon in [2db83823](https://github.com/getsentry/sentry-api-schema/commit/2db83823110482948d875701f5605422652a331b) + +## 0.227.0 + +### Schema Updates + +- Generated by @betegon in [1162b5b8](https://github.com/getsentry/sentry-api-schema/commit/1162b5b889632ca8ba68a17241eed9bb35df636d) + +## 0.226.0 + +### Schema Updates + +- Generated by @betegon in [0c8fb006](https://github.com/getsentry/sentry-api-schema/commit/0c8fb00603d59e7a4317e79e600b0f3495771767) + +## 0.225.0 + +### Schema Updates + +- Generated by @kcons in [d06a16a7](https://github.com/getsentry/sentry-api-schema/commit/d06a16a72b04c195112e8c262cdc368ca210ec8e) + +## 0.224.0 + +### Schema Updates + +- Generated by @gricha in [0571c376](https://github.com/getsentry/sentry-api-schema/commit/0571c3769ef3f987256934d29f532add7dc09d9f) + +## 0.223.0 + +### Schema Updates + +- Generated by @shashjar in [1c40fe8a](https://github.com/getsentry/sentry-api-schema/commit/1c40fe8a0f5e633cb9a9107b689a70f878ef88a6) +- Generated by @gricha in [0de4b9c8](https://github.com/getsentry/sentry-api-schema/commit/0de4b9c83f048ac0b3de25a8ac3551d6d2ebcf94) + +## 0.222.0 + +### Schema Updates + +- Generated by @Zylphrex in [c9fa1fb4](https://github.com/getsentry/sentry-api-schema/commit/c9fa1fb42090a9c4e999f5102b31e2d7888ba87b) + +## 0.221.0 + +### Schema Updates + +- Generated by @vgrozdanic in [6bfeb23f](https://github.com/getsentry/sentry-api-schema/commit/6bfeb23f6477b767c94164894c603e2b71b2881f) + +## 0.220.0 + +### Schema Updates + +- Generated by @getsentry-bot in [5de0e7c6](https://github.com/getsentry/sentry-api-schema/commit/5de0e7c6516dd20d52b01484ac76e1655d91dae6) +- Generated by @yuvmen in [dc52c363](https://github.com/getsentry/sentry-api-schema/commit/dc52c36379fa0293a49add90fb298d3697570496) + +## 0.219.0 + +### Schema Updates + +- Generated by @getsentry-bot in [b5eb3f02](https://github.com/getsentry/sentry-api-schema/commit/b5eb3f02a6fc1ba649c2106d3f9e45ab68bf1ed6) + +## 0.218.0 + +### Schema Updates + +- Generated by @jamieQ in [dd0a134c](https://github.com/getsentry/sentry-api-schema/commit/dd0a134c96f1148a35b924a833810a5a178a3e52) +- Generated by @Zylphrex in [1a8ec4e6](https://github.com/getsentry/sentry-api-schema/commit/1a8ec4e6c2ab85892a50fcf50cb40ee9184ad71c) + +## 0.217.0 + +### Schema Updates + +- Generated by @shashjar in [ca11a208](https://github.com/getsentry/sentry-api-schema/commit/ca11a2085d6e926112a397122eee845ffd9d3014) + +## 0.216.0 + +### Schema Updates + +- Generated by @joseph-sentry in [51b02a0d](https://github.com/getsentry/sentry-api-schema/commit/51b02a0d551bf26d1aca43def1bf2d5ba400c896) + +## 0.215.0 + +### Schema Updates + +- Generated by @gricha in [20f4989b](https://github.com/getsentry/sentry-api-schema/commit/20f4989baa27888453ea24b2588d9a2f02212b46) + +## 0.214.0 + +### Schema Updates + +- Generated by @bcoe in [52b472ba](https://github.com/getsentry/sentry-api-schema/commit/52b472bafee3380c506746f2b53b97f606872023) + +## 0.213.0 + +### Schema Updates + +- Generated by @gricha in [5b471531](https://github.com/getsentry/sentry-api-schema/commit/5b471531435157d8989ade233b4c95e333c0c180) + +## 0.212.0 + +### Schema Updates + +- Generated by @gggritso in [678631a8](https://github.com/getsentry/sentry-api-schema/commit/678631a8bf4271a3a28fa15e59f4c4c4429a4978) + +## 0.211.0 + +### Schema Updates + +- Generated by @gricha in [f265fa84](https://github.com/getsentry/sentry-api-schema/commit/f265fa84a13ce7828037ed91461a6acff3b92a9a) + +## 0.210.0 + +### Schema Updates + +- Generated by @wmak in [0b42ddde](https://github.com/getsentry/sentry-api-schema/commit/0b42dddede54f1d3feae7fa22423c7e68a4b4f8b) + +## 0.209.0 + +### Schema Updates + +- Generated by @gricha in [2832141e](https://github.com/getsentry/sentry-api-schema/commit/2832141ebe6aa9596da1ae30733c4fcb631bf87b) + +## 0.208.0 + +### Schema Updates + +- Generated by @trevor-e in [39c7b82a](https://github.com/getsentry/sentry-api-schema/commit/39c7b82ad0420c91cf3160de21e83bd0c476af29) + +## 0.207.0 + +### Schema Updates + +- Generated by @NicoHinderling in [a2bc33fe](https://github.com/getsentry/sentry-api-schema/commit/a2bc33fe21206060f5a803a5875a3f7cd99120b7) + +## 0.206.0 + +### Schema Updates + +- Generated by @malwilley in [4a7e0b91](https://github.com/getsentry/sentry-api-schema/commit/4a7e0b91df6aea7953574e293401ac95f71e37f6) + +## 0.205.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [18a741c5](https://github.com/getsentry/sentry-api-schema/commit/18a741c5a06b0e2c6c43c2a7a13e89b5d7654211) +- Generated by @wmak in [c75bc551](https://github.com/getsentry/sentry-api-schema/commit/c75bc5516225f919291bad4d4458a37f58f064cb) +- Generated by @azulus in [777e3664](https://github.com/getsentry/sentry-api-schema/commit/777e36641599cf795d45ed30c96536204e830b1d) + +## 0.204.0 + +### Schema Updates + +- Generated by @betegon in [234dc9d2](https://github.com/getsentry/sentry-api-schema/commit/234dc9d21538a467c23d9ed44e3bb69261e31970) + +## 0.203.0 + +### Schema Updates + +- Generated by @betegon in [7cd546a5](https://github.com/getsentry/sentry-api-schema/commit/7cd546a5a571955895dee15770bf3881b182ac12) + +## 0.202.0 + +### Schema Updates + +- Generated by @azulus in [44d190c7](https://github.com/getsentry/sentry-api-schema/commit/44d190c7a06899c0379400841d4c231d9e73b70a) + +## 0.201.0 + +### Schema Updates + +- Generated by @azulus in [119b9b80](https://github.com/getsentry/sentry-api-schema/commit/119b9b807c53e2c425c822b2fd4d9a216a4164b4) + +## 0.200.0 + +### Schema Updates + +- Generated by @azulus in [be1eb2f2](https://github.com/getsentry/sentry-api-schema/commit/be1eb2f2ec5bad3fd5d63c69f89d0c0a4e461c94) + +## 0.199.0 + +### Schema Updates + +- Generated by @shashjar in [262922be](https://github.com/getsentry/sentry-api-schema/commit/262922be6cdc32dd188e657cd44054d3d8d1d17d) + +## 0.198.0 + +### Schema Updates + +- Generated by @Abdkhan14 in [23c87181](https://github.com/getsentry/sentry-api-schema/commit/23c87181e4453fa5fa49746d3a93efbe9f0e1ebb) + +## 0.197.0 + +### Schema Updates + +- Generated by @azulus in [10fc1ff6](https://github.com/getsentry/sentry-api-schema/commit/10fc1ff6d60066ce5ca43fb330f77ccbb69efee7) + +## 0.196.0 + +### Schema Updates + +- Generated by @amy-chen23 in [3b890659](https://github.com/getsentry/sentry-api-schema/commit/3b890659237fec6348f7604de890793ac15a4292) + +## 0.195.0 + +### Schema Updates + +- Generated by @Zylphrex in [486e5203](https://github.com/getsentry/sentry-api-schema/commit/486e520354762f599bd0798a5fb9b572b680a4d4) + +## 0.194.0 + +### Schema Updates + +- Generated by @scttcper in [bf1a8e33](https://github.com/getsentry/sentry-api-schema/commit/bf1a8e333733fa3bdc1de2b93b74be2f5df02c56) + +## 0.193.0 + +### New Features + +- Normalize operationIds to short REST-conventional names by @betegon in [#75](https://github.com/getsentry/sentry-api-schema/pull/75) + +## 0.192.0 + +### Schema Updates + +- Generated by @azulus in [c4febf37](https://github.com/getsentry/sentry-api-schema/commit/c4febf37045004cfed14f83dcd19559a10f28a6c) + +## 0.191.0 + +### Schema Updates + +- Generated by @azulus in [f17aad80](https://github.com/getsentry/sentry-api-schema/commit/f17aad80aa2ef4e13f7a71d922172808d517f472) + +## 0.190.0 + +### Schema Updates + +- Generated by @Zylphrex in [c4d8139a](https://github.com/getsentry/sentry-api-schema/commit/c4d8139a4c37d76efb45458b2145be395e46fe78) + +## 0.189.0 + +### Schema Updates + +- Generated by @hobzcalvin in [f54f9118](https://github.com/getsentry/sentry-api-schema/commit/f54f91185950032b674f8c92594d25ec6020c4ac) + +## 0.188.0 + +### Schema Updates + +- Generated by @markstory in [25d28f97](https://github.com/getsentry/sentry-api-schema/commit/25d28f974ef0388e37fc2da39c07b4d98438b95c) + +## 0.187.0 + +### Schema Updates + +- Generated by @BYK in [eb16a505](https://github.com/getsentry/sentry-api-schema/commit/eb16a5054c40e7cdd17617134b12766fc0e33a9a) + +## 0.186.0 + +### Schema Updates + +- Generated by @JoshFerge in [98dc03bf](https://github.com/getsentry/sentry-api-schema/commit/98dc03bfd3340bd9ecd42555f33ac351484faa49) + +## 0.185.0 + +### Schema Updates + +- Generated by @NicoHinderling in [ac20a21b](https://github.com/getsentry/sentry-api-schema/commit/ac20a21bc371872777b07fab311afd74433ac8dc) + +## 0.184.0 + +### Schema Updates + +- Generated by @JoshFerge in [879d1391](https://github.com/getsentry/sentry-api-schema/commit/879d1391faf6d902710a0e7b5c3c5d3c0ed43806) + +## 0.183.0 + +### Schema Updates + +- Generated by @JoshFerge in [31f6dc68](https://github.com/getsentry/sentry-api-schema/commit/31f6dc680f73124130816625bae2bafcf83df009) + +## 0.182.0 + +### Schema Updates + +- Generated by @JoshFerge in [adcf15d7](https://github.com/getsentry/sentry-api-schema/commit/adcf15d7d60591d6f3e1deb2f1c3bcaba2960c09) + +## 0.181.0 + +### Schema Updates + +- Generated by @Christinarlong in [5f706b23](https://github.com/getsentry/sentry-api-schema/commit/5f706b2353f46a1bfb19348aedadd241a9f38ec8) + +## 0.180.0 + +### Schema Updates + +- Generated by @betegon in [3aeddbd4](https://github.com/getsentry/sentry-api-schema/commit/3aeddbd4dd8bb09e42979f3d21e2414f500aba69) + +## 0.179.0 + +### Schema Updates + +- Generated by @giovanni-guidini in [3f32c2d3](https://github.com/getsentry/sentry-api-schema/commit/3f32c2d30dbad26f9364e7382f5f23f3ec85328d) + +## 0.178.0 + +### Schema Updates + +- Generated by @NicoHinderling in [705e3a25](https://github.com/getsentry/sentry-api-schema/commit/705e3a25267bd514a5fe359b05fb0ee840dd6802) + +## 0.177.0 + +### Schema Updates + +- Generated by @saponifi3d in [217784cf](https://github.com/getsentry/sentry-api-schema/commit/217784cfc02423a9f5a22e5830327615a5c86cbc) + +## 0.176.0 + +### Schema Updates + +- Generated by @gricha in [b2f38563](https://github.com/getsentry/sentry-api-schema/commit/b2f385631e7dda12836332e89557fd84cbe7b368) + +## 0.175.0 + +### Schema Updates + +- Generated by @MathurAditya724 in [24594c7c](https://github.com/getsentry/sentry-api-schema/commit/24594c7cae71fd4d484976459fea2a4a71704347) + +## 0.174.0 + +### Schema Updates + +- Generated by @betegon in [b3b8e9e4](https://github.com/getsentry/sentry-api-schema/commit/b3b8e9e4194a39a37748d9f1723975ba8fd05036) + +## 0.173.0 + +### Schema Updates + +- Generated by @gricha in [554fda38](https://github.com/getsentry/sentry-api-schema/commit/554fda38787ada26828064347988b1c4e7780533) + +## 0.172.0 + +### New Features + +- (pagination) Export SdkResult type for consumer use by @sentry-junior in [#74](https://github.com/getsentry/sentry-api-schema/pull/74) + +## 0.171.0 + +### New Features + +- Add @sentry/api/browser entry point for cookie+CSRF auth by @betegon in [#73](https://github.com/getsentry/sentry-api-schema/pull/73) + +## 0.170.0 + +### Schema Updates + +- Generated by @gricha in [30ce22ed](https://github.com/getsentry/sentry-api-schema/commit/30ce22ed0cfd8a79768f8b0d260db30a0c535be8) + +## 0.169.0 + +### Schema Updates + +- Generated by @wedamija in [5819355c](https://github.com/getsentry/sentry-api-schema/commit/5819355cd1330a6ccdf741478615d63c125d621e) + +## 0.168.0 + +### Schema Updates + +- Generated by @cvxluo in [f0c18016](https://github.com/getsentry/sentry-api-schema/commit/f0c180169c12cf136a78ad11612d2470797fa69a) +- Generated by @cvxluo in [fe44d8ae](https://github.com/getsentry/sentry-api-schema/commit/fe44d8aeebeff67b78d166b39a54ef07c6f36581) + +## 0.167.0 + +### Schema Updates + +- Generated by @cvxluo in [c8065893](https://github.com/getsentry/sentry-api-schema/commit/c80658939a21207a84890c8226b45a46402ca4ec) + +## 0.166.0 + +### Schema Updates + +- Generated by @cvxluo in [e31a1858](https://github.com/getsentry/sentry-api-schema/commit/e31a18588746d15e5335e0f2c3182cf38ada8074) + +## 0.165.0 + +### Schema Updates + +- Generated by @cvxluo in [d7f3994c](https://github.com/getsentry/sentry-api-schema/commit/d7f3994c6a198feb545701fd8aa91e075dbe2382) + +## 0.164.0 + +### Schema Updates + +- Generated by @wedamija in [cdf4f7b5](https://github.com/getsentry/sentry-api-schema/commit/cdf4f7b5774b11cd65a8fa28898995237a128b99) + +## 0.163.0 + +### Schema Updates + +- Generated by @cvxluo in [728fefe7](https://github.com/getsentry/sentry-api-schema/commit/728fefe72478bb44d231fc20dd5117f8a1f9220e) + +## 0.162.0 + +### Schema Updates + +- Generated by @cvxluo in [10e73e4f](https://github.com/getsentry/sentry-api-schema/commit/10e73e4f48fc7fa9598302474868075e99b495be) + +## 0.161.0 + +### Schema Updates + +- Generated by @gricha in [2620a590](https://github.com/getsentry/sentry-api-schema/commit/2620a590a2f0ec08c159bc347d91b9095235b163) +- Generated by @cvxluo in [6f46eb74](https://github.com/getsentry/sentry-api-schema/commit/6f46eb742ff301bb35f1079d78a2c18f61b5a2b5) + +## 0.160.0 + +### Schema Updates + +- Generated by @cvxluo in [b3048dbe](https://github.com/getsentry/sentry-api-schema/commit/b3048dbe5fb4a809c5af8eb7cc6959c11230b6f3) + +## 0.159.0 + +### Schema Updates + +- Generated by @shruthilayaj in [b885c7ec](https://github.com/getsentry/sentry-api-schema/commit/b885c7ecce09e75b88e6d71c48f47c2daa20c290) + +## 0.158.0 + +### Schema Updates + +- Generated by @cvxluo in [1a3eebde](https://github.com/getsentry/sentry-api-schema/commit/1a3eebdea1cb54cc106b42f59fdfef811cad595f) +- Generated by @cvxluo in [14168745](https://github.com/getsentry/sentry-api-schema/commit/1416874550c98d2d35f2fc8300d3e85803a0e94b) +- Generated by @Christinarlong in [c257812f](https://github.com/getsentry/sentry-api-schema/commit/c257812f81154b34a2f92b08481679681563d2b2) + +## 0.157.0 + +### Schema Updates + +- Generated by @cvxluo in [50478be9](https://github.com/getsentry/sentry-api-schema/commit/50478be9ad1aad9f00d33c24cba18d4f12030f50) +- Generated by @cvxluo in [f230b5b3](https://github.com/getsentry/sentry-api-schema/commit/f230b5b301721903da9efc5dbce160c9c0f8d39e) + +## 0.156.0 + +### Schema Updates + +- Generated by @cvxluo in [c6044634](https://github.com/getsentry/sentry-api-schema/commit/c6044634adc23e497495b0e60be374be102f84eb) + +## 0.155.0 + +### Schema Updates + +- Generated by @giovanni-guidini in [30e3d113](https://github.com/getsentry/sentry-api-schema/commit/30e3d113214c33342a3855f7653a67de85673188) + +## 0.154.0 + +### Schema Updates + +- Generated by @cvxluo in [00ff1ca6](https://github.com/getsentry/sentry-api-schema/commit/00ff1ca6533b233980b8048c48aa7370a1caa086) + +## 0.153.0 + +### Schema Updates + +- Generated by @betegon in [33f43e54](https://github.com/getsentry/sentry-api-schema/commit/33f43e548c2670e9631f7d81a304565ec808cd31) + +## 0.152.0 + +### Schema Updates + +- Generated by @giovanni-guidini in [0608e1e8](https://github.com/getsentry/sentry-api-schema/commit/0608e1e84904504ec5bcb5aaf32a8720bd95d28b) + +## 0.151.0 + +### Schema Updates + +- Generated by @cvxluo in [1e2d8eb8](https://github.com/getsentry/sentry-api-schema/commit/1e2d8eb8165e6f38657d13b95acd74b687e04901) + +## 0.150.0 + +### Schema Updates + +- Generated by @cvxluo in [8ef8d769](https://github.com/getsentry/sentry-api-schema/commit/8ef8d769786034780cf9bdfd6af6926711e9d505) + +## 0.149.0 + +### Schema Updates + +- Generated by @cvxluo in [2e4e8f93](https://github.com/getsentry/sentry-api-schema/commit/2e4e8f93ce6ff45c441804617f1d5a85c5a35aea) + +## 0.148.0 + +### Bug Fixes + +- Remove dead alert-rules references after endpoint removal from spec by @MathurAditya724 in [#72](https://github.com/getsentry/sentry-api-schema/pull/72) + +### Schema Updates + +- Generated by @wedamija in [3864b92c](https://github.com/getsentry/sentry-api-schema/commit/3864b92c9eaf4e64ea0cd664f16ee7620e02b2d4) +- Generated by @ceorourke in [2abf56ce](https://github.com/getsentry/sentry-api-schema/commit/2abf56ceaaa4a215856adab447c216868f039a7b) +- Generated by @cvxluo in [cc045dac](https://github.com/getsentry/sentry-api-schema/commit/cc045dac899022ad99cd82cd6bc02dd7929f06f1) +- Generated by @betegon in [26fea313](https://github.com/getsentry/sentry-api-schema/commit/26fea31360987d6ba48b99555aa65499c433e5f6) +- Generated by @DominikB2014 in [66ef08bd](https://github.com/getsentry/sentry-api-schema/commit/66ef08bdeec92106e0b41bb6345b937de2595f06) +- Generated by @NicoHinderling in [ac5cdeb1](https://github.com/getsentry/sentry-api-schema/commit/ac5cdeb1bc4f72d627e6b510332da3baab8c41e2) +- Generated by @cvxluo in [45ea7ea0](https://github.com/getsentry/sentry-api-schema/commit/45ea7ea0c86be9cdc4adcd097c5ab5ad335d8dcb) +- Generated by @cvxluo in [914dd758](https://github.com/getsentry/sentry-api-schema/commit/914dd758157fe8c2bae99bffc4c60e991693d8b6) +- Generated by @chromy in [18c47391](https://github.com/getsentry/sentry-api-schema/commit/18c47391d28b9102b0d15223885c4f7b2a09a048) +- Generated by @markstory in [b44a28cc](https://github.com/getsentry/sentry-api-schema/commit/b44a28cc18f1f216c7d9ae65965981401a71ec8f) +- Generated by @cvxluo in [b947a460](https://github.com/getsentry/sentry-api-schema/commit/b947a460c44c21a6acdbf09a04b2d22a7815342c) +- Generated by @srest2021 in [4f15329a](https://github.com/getsentry/sentry-api-schema/commit/4f15329a45adf696b5b7df3bfb33dfce8c8dbefe) +- Generated by @srest2021 in [bce5d92a](https://github.com/getsentry/sentry-api-schema/commit/bce5d92a81c5c63bea311d695e4ee2e755df53c1) +- Generated by @markstory in [3234b7b0](https://github.com/getsentry/sentry-api-schema/commit/3234b7b07f00d7dcd18c1224185e09824b4795ef) +- Generated by @markstory in [d6bc0f58](https://github.com/getsentry/sentry-api-schema/commit/d6bc0f580ab6103e74e1d24a4c8fc476926c5575) +- Generated by @nsdeschenes in [8fb3d10f](https://github.com/getsentry/sentry-api-schema/commit/8fb3d10f77e5c41aa8738721722ddfb5b247760e) +- Generated by @runningcode in [8d801212](https://github.com/getsentry/sentry-api-schema/commit/8d801212ada12c7a99d0abb8331a5089b1d7ff58) +- Generated by @cmanallen in [29963830](https://github.com/getsentry/sentry-api-schema/commit/29963830b890bea3919b2b045495a3be0ec57715) +- Generated by @constantinius in [aedcfc1b](https://github.com/getsentry/sentry-api-schema/commit/aedcfc1b9eff8d7e61262a6ac2971f2b0f13d1e5) +- Generated by @gi0baro in [b7140b56](https://github.com/getsentry/sentry-api-schema/commit/b7140b56828d9f683ec5fe33fca3f50eadbe0063) +- Generated by @shellmayr in [cf0ef23b](https://github.com/getsentry/sentry-api-schema/commit/cf0ef23b24cd048010799fbd6a0ac5e142089ae0) +- Generated by @oioki in [9d439b85](https://github.com/getsentry/sentry-api-schema/commit/9d439b853085bb254bbfc6d846eb85bc63b6bc26) +- Generated by @gi0baro in [15de01cf](https://github.com/getsentry/sentry-api-schema/commit/15de01cf2bc71d906c08b97d7b878febd61b3f93) +- Generated by @vgrozdanic in [77930d26](https://github.com/getsentry/sentry-api-schema/commit/77930d26a2be7407dc80b7fea85bc333b7cd9263) +- Generated by @brendanhsentry in [284f0465](https://github.com/getsentry/sentry-api-schema/commit/284f0465a67bc8c36a3d3880e46199f65c17e698) +- Generated by @joshuarli in [7fd59372](https://github.com/getsentry/sentry-api-schema/commit/7fd593728976fce1faf7c415c043e4399db7ef59) +- Generated by @wedamija in [1b49e54d](https://github.com/getsentry/sentry-api-schema/commit/1b49e54da7bd104dbf4fef0415fb52b3b75f2873) +- Generated by @evanpurkhiser in [d2ab02f3](https://github.com/getsentry/sentry-api-schema/commit/d2ab02f38705417f0d290f9bc95a92ae148d1927) +- Generated by @evanpurkhiser in [6aaca1e2](https://github.com/getsentry/sentry-api-schema/commit/6aaca1e2d1833acb92ad977ce5306db2af77c06b) +- Generated by @ceorourke in [1a4d8677](https://github.com/getsentry/sentry-api-schema/commit/1a4d8677deb5ce07ed532346205934059a6a8959) +- Generated by @wedamija in [1ec649e2](https://github.com/getsentry/sentry-api-schema/commit/1ec649e212e6e13c17f978dc3f8056eee4ac4235) +- Generated by @wedamija in [bfd8da77](https://github.com/getsentry/sentry-api-schema/commit/bfd8da7743247df55670415d706c5344287b4c3f) +- Generated by @srest2021 in [518f8278](https://github.com/getsentry/sentry-api-schema/commit/518f82780188c943350f48f65093bf0d4818c873) +- Generated by @wedamija in [ec104b9e](https://github.com/getsentry/sentry-api-schema/commit/ec104b9e4aa4ba668e9076d0cf3b537ee978d0c8) +- Generated by @wedamija in [60cc06b8](https://github.com/getsentry/sentry-api-schema/commit/60cc06b8e77f6f22f459071585b1056e050f83ae) +- Generated by @JoshFerge in [d7ef67a2](https://github.com/getsentry/sentry-api-schema/commit/d7ef67a2eaca50d58797e8287e3c029b0e5a6c33) +- Generated by @wedamija in [b41d210a](https://github.com/getsentry/sentry-api-schema/commit/b41d210aa0dbb745f2e51cb530e2d8c73ba00cd0) +- Generated by @srest2021 in [314f9afe](https://github.com/getsentry/sentry-api-schema/commit/314f9afe62bcc90ac6b7901c2c961bb953c45b72) +- Generated by @tnt-sentry in [c41d0911](https://github.com/getsentry/sentry-api-schema/commit/c41d0911d2048feee4979263236143fefa832d2d) +- Generated by @cameroncooke in [1910b918](https://github.com/getsentry/sentry-api-schema/commit/1910b9181893770ca181b3f4c64087ef585bcc17) +- Generated by @ceorourke in [201de813](https://github.com/getsentry/sentry-api-schema/commit/201de81388ffb94ac555c6efb9229f84b93e89ad) +- Generated by @sentaur-athena in [926dffc6](https://github.com/getsentry/sentry-api-schema/commit/926dffc6a49048d1432f38f7b0dddc478d959977) +- Generated by @chromy in [2bbfa337](https://github.com/getsentry/sentry-api-schema/commit/2bbfa337f34595c8ce6ef9ec09f94bb1f99e89eb) +- Generated by @chromy in [956607cd](https://github.com/getsentry/sentry-api-schema/commit/956607cdd625b8cfdb8e1e85d82c0bae0ced7665) +- Generated by @ceorourke in [101264e7](https://github.com/getsentry/sentry-api-schema/commit/101264e72843a955bcdff23bad8820a77bdeebe7) +- Generated by @ceorourke in [1c897141](https://github.com/getsentry/sentry-api-schema/commit/1c8971412a8e6b68cac8d1035c5a6a584a2f5a88) +- Generated by @wmak in [b5522509](https://github.com/getsentry/sentry-api-schema/commit/b5522509aa1083a1c4879967215ff9f97b7b1e26) +- Generated by @NicoHinderling in [4d727be1](https://github.com/getsentry/sentry-api-schema/commit/4d727be1ea79676354f4242387b3645ac0e0dcf9) +- Generated by @NicoHinderling in [cf56ba8d](https://github.com/getsentry/sentry-api-schema/commit/cf56ba8dc0f77e4510679c7d7d21b72e955d61bb) +- Generated by @lynnagara in [4a768fbb](https://github.com/getsentry/sentry-api-schema/commit/4a768fbbffd45aa3d12e54be561df5c321ca177d) +- Generated by @NicoHinderling in [4b11aada](https://github.com/getsentry/sentry-api-schema/commit/4b11aada2da98103e8440d1ff410c84dbdc9c0cc) +- Generated by @ceorourke in [a6d2ac43](https://github.com/getsentry/sentry-api-schema/commit/a6d2ac432fc4a4aafa4910218b9a20771539c680) +- Generated by @ceorourke in [08675566](https://github.com/getsentry/sentry-api-schema/commit/086755668deb55e81b88cc82cc5aec421009be2b) + +## 0.147.0 + +### Schema Updates + +- Generated by @leeandher in [59279fd4](https://github.com/getsentry/sentry-api-schema/commit/59279fd485113177fdc405c9d988ac3d145a0427) + +## 0.146.0 + +### Schema Updates + +- Generated by @bcoe in [a9cfc3e8](https://github.com/getsentry/sentry-api-schema/commit/a9cfc3e80c6cbab658ae71ab3fee4df0f1ba2113) + +## 0.145.0 + +### Schema Updates + +- Generated by @Christinarlong in [7c2238e9](https://github.com/getsentry/sentry-api-schema/commit/7c2238e9cb59726d416b7f5bf5b75c46f20a73cc) +- Generated by @dcramer in [8e500c2c](https://github.com/getsentry/sentry-api-schema/commit/8e500c2c8c8944323c64f3536c4a30a6e36c8d5c) + +## 0.144.0 + +### Schema Updates + +- Generated by @wedamija in [d389e976](https://github.com/getsentry/sentry-api-schema/commit/d389e976790cf44cf1278ce8df07136fc6c71f65) + +## 0.143.0 + +### Schema Updates + +- Generated by @DominikB2014 in [b7615211](https://github.com/getsentry/sentry-api-schema/commit/b76152115f2d47bd4f7ddd874971d19f86e44ee3) + +## 0.142.0 + +### Schema Updates + +- Generated by @wmak in [40881314](https://github.com/getsentry/sentry-api-schema/commit/408813148092d1b7b2913dce84a0d09cc346cab3) + +## 0.141.0 + +### New Features + +- Add auto-generated Zod schemas via Hey API plugin by @MathurAditya724 in [#70](https://github.com/getsentry/sentry-api-schema/pull/70) + +## 0.140.0 + +### Schema Updates + +- Generated by @lynnagara in [9bb5726a](https://github.com/getsentry/sentry-api-schema/commit/9bb5726a5c1455e9e4ab365028f4767235662466) + +## 0.139.0 + +### Schema Updates + +- Generated by @wedamija in [f55ca51f](https://github.com/getsentry/sentry-api-schema/commit/f55ca51fd2bc28cf9c4228feedc6690d83f50aff) + +## 0.138.0 + +### Schema Updates + +- Generated by @wedamija in [d11c767c](https://github.com/getsentry/sentry-api-schema/commit/d11c767c5cb642a9c3e4d61c38257d2e351ff36d) + +## 0.137.0 + +### Schema Updates + +- Generated by @lynnagara in [377be5dc](https://github.com/getsentry/sentry-api-schema/commit/377be5dc9595f7af963112e3b5228fd49304a938) + +## 0.136.0 + +### Schema Updates + +- Generated by @Christinarlong in [fce91444](https://github.com/getsentry/sentry-api-schema/commit/fce914449a7ffff4b3e7b01351cd6655b376f06e) + +## 0.135.0 + +### Schema Updates + +- Generated by @kcons in [e97bd7d6](https://github.com/getsentry/sentry-api-schema/commit/e97bd7d6a753a060e3405d891bea12fda8ec76b2) + +## 0.134.0 + +### Schema Updates + +- Generated by @markstory in [e917886f](https://github.com/getsentry/sentry-api-schema/commit/e917886fb925564551cf7b851e51602a866ae941) + +## 0.133.0 + +### New Features + +- Auto-generate typed pagination wrappers per operation by @MathurAditya724 in [#69](https://github.com/getsentry/sentry-api-schema/pull/69) + +## 0.132.0 + +### Schema Updates + +- Generated by @kcons in [accac8ce](https://github.com/getsentry/sentry-api-schema/commit/accac8ce9295d18d3a0997ff50fdba35ea94093b) + +## 0.131.0 + +### Schema Updates + +- Generated by @MathurAditya724 in [3238293f](https://github.com/getsentry/sentry-api-schema/commit/3238293f613d7828ac6c74557f757c5271691213) + +## 0.130.0 + +### Schema Updates + +- Generated by @Christinarlong in [b702b08a](https://github.com/getsentry/sentry-api-schema/commit/b702b08a16d366fcc92b00f12a1a1b7487281220) + +## 0.129.0 + +### Schema Updates + +- Generated by @cameroncooke in [529d3f90](https://github.com/getsentry/sentry-api-schema/commit/529d3f90ec0d8da99af69d439f235c88b5baf75f) + +## 0.128.0 + +### Schema Updates + +- Generated by @dashed in [7db74b7c](https://github.com/getsentry/sentry-api-schema/commit/7db74b7cd425ff16e1ee6f78f95f97fab55dd474) + +## 0.127.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [8d150ba3](https://github.com/getsentry/sentry-api-schema/commit/8d150ba3891898ecb07cc664d577897c478e5618) + +## 0.126.0 + +### Schema Updates + +- Generated by @wedamija in [ce7949e8](https://github.com/getsentry/sentry-api-schema/commit/ce7949e8a88f0149db303a285bedc7cfa92314a7) + +## 0.125.0 + +### Schema Updates + +- Generated by @Christinarlong in [bc07865a](https://github.com/getsentry/sentry-api-schema/commit/bc07865a03a2dac95cde2faccbce05474c61ff33) + +## 0.124.0 + +### Schema Updates + +- Generated by @trevor-e in [dfb90419](https://github.com/getsentry/sentry-api-schema/commit/dfb904198ec92029e14e1a805489137610fc7bc4) + +## 0.123.0 + +### Schema Updates + +- Generated by @dashed in [c54c780a](https://github.com/getsentry/sentry-api-schema/commit/c54c780ab89c354bb1504ba1b591d87dc6b56f10) + +## 0.122.0 + +### Schema Updates + +- Generated by @lcian in [c49327e3](https://github.com/getsentry/sentry-api-schema/commit/c49327e33ad921ca5e121b8d4a17f11aa004b966) + +## 0.121.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [abed394a](https://github.com/getsentry/sentry-api-schema/commit/abed394ad3f792210f71ee563527b776fd849a45) + +## 0.120.0 + +### Schema Updates + +- Generated by @mtopo27 in [bb46a4fc](https://github.com/getsentry/sentry-api-schema/commit/bb46a4fc6f63a5aa800ab7d7e3dfb5c564ad6fa2) + +## 0.119.0 + +### Schema Updates + +- Generated by @leeandher in [957842bc](https://github.com/getsentry/sentry-api-schema/commit/957842bca05b8e95017d8682031becbae09bd5ad) + +## 0.118.0 + +### Schema Updates + +- Generated by @markstory in [d8f47e0e](https://github.com/getsentry/sentry-api-schema/commit/d8f47e0e1d09a7da6dd2b1aadb51ce42c1968d92) +- Generated by @getsentry-bot in [a2fec77c](https://github.com/getsentry/sentry-api-schema/commit/a2fec77c17ed9703c28e23739c625562ab0ba079) + +## 0.117.0 + +### Schema Updates + +- Generated by @lcian in [5da84a22](https://github.com/getsentry/sentry-api-schema/commit/5da84a224837737c1c4254c492a4b078833a8e60) + +## 0.116.0 + +### Schema Updates + +- Generated by @BYK in [06f4849f](https://github.com/getsentry/sentry-api-schema/commit/06f4849fb20fc3740be9be01f3fda10ac44b5063) + +## 0.115.1 + +### Internal Changes + +- Rewrite README and add npm keywords for discoverability by @MathurAditya724 in [#68](https://github.com/getsentry/sentry-api-schema/pull/68) + +## 0.115.0 + +### Schema Updates + +- Generated by @BYK in [0fa9d36f](https://github.com/getsentry/sentry-api-schema/commit/0fa9d36f860f65d2ecb92659380c485b4556359d) + +## 0.114.0 + +### Schema Updates + +- Generated by @gggritso in [56b8b477](https://github.com/getsentry/sentry-api-schema/commit/56b8b477bbd536bee7e9cb6ca8de8824696ff0db) +- Generated by @Christinarlong in [f1de8569](https://github.com/getsentry/sentry-api-schema/commit/f1de85692a171cc0dc0a6a4b510544196fcc287d) +- Generated by @cvxluo in [126d32a5](https://github.com/getsentry/sentry-api-schema/commit/126d32a5bdf2f28a18d5b9b94804f78bd8d382cd) +- Generated by @michelletran-sentry in [857f8f57](https://github.com/getsentry/sentry-api-schema/commit/857f8f5713c8fbc2f6df7b0062612e47aed30a47) +- Generated by @wedamija in [6380ecfb](https://github.com/getsentry/sentry-api-schema/commit/6380ecfbde57af12a75e7bc295e9246dab663de1) +- Generated by @mrduncan in [94da5787](https://github.com/getsentry/sentry-api-schema/commit/94da57874e19d6c76da9906c6cdf49b0531b233b) +- Generated by @chromy in [80744323](https://github.com/getsentry/sentry-api-schema/commit/807443232e1539684c05d2cdd5482329e865ebc7) +- Generated by @markstory in [69504879](https://github.com/getsentry/sentry-api-schema/commit/69504879a178559a611dfde3771c309bc29f7203) +- Generated by @BYK in [77d1114e](https://github.com/getsentry/sentry-api-schema/commit/77d1114ef288a0340fc21b579fed904ea1aa9763) +- Generated by @chromy in [2000bb6a](https://github.com/getsentry/sentry-api-schema/commit/2000bb6a2ac41397e3fc815ce34a418669b6628f) +- Generated by @nsdeschenes in [c1c94182](https://github.com/getsentry/sentry-api-schema/commit/c1c941821c1e43ad6afb2b02b114480d1ab6e961) +- Generated by @sergical in [3942b872](https://github.com/getsentry/sentry-api-schema/commit/3942b872c0c2a25aece567c785c3714c44899ef4) +- Generated by @skaasten in [2387cb01](https://github.com/getsentry/sentry-api-schema/commit/2387cb01a102591051b2068d7fc9d89271bc1b96) +- Generated by @skaasten in [575e9bf1](https://github.com/getsentry/sentry-api-schema/commit/575e9bf1ec925c6edc7530420f91072111c81d04) +- Generated by @chromy in [1298355c](https://github.com/getsentry/sentry-api-schema/commit/1298355c00686f5ecdfa0c5ff1154c7b2bd079c3) +- Generated by @alexsohn1126 in [686ebd2a](https://github.com/getsentry/sentry-api-schema/commit/686ebd2ad6059b2a08d7936d0bcd821da10b2b94) +- Generated by @wedamija in [c1ff9e4a](https://github.com/getsentry/sentry-api-schema/commit/c1ff9e4aa7846735dc8716602c846e9f35637ece) +- Generated by @evanpurkhiser in [e39e1b71](https://github.com/getsentry/sentry-api-schema/commit/e39e1b7140c0a6a0c9f803bd9faa5d4edef1641b) +- Generated by @skaasten in [95fb9d86](https://github.com/getsentry/sentry-api-schema/commit/95fb9d8695eec4966828147c630a346501053bb4) +- Generated by @srest2021 in [cf761a8f](https://github.com/getsentry/sentry-api-schema/commit/cf761a8fb48bc56c1f9cbe49f25bf001800e39da) +- Generated by @geoffg-sentry in [30d25325](https://github.com/getsentry/sentry-api-schema/commit/30d2532588ec803422c0c2ad00ea2b618da552a7) +- Generated by @cmanallen in [93344065](https://github.com/getsentry/sentry-api-schema/commit/9334406514e3f77f1199ad4c6c968e3c5bc7e4c4) +- Generated by @lynnagara in [1de2adbc](https://github.com/getsentry/sentry-api-schema/commit/1de2adbc989939eb81893ffce7fed5a5e5bf2b83) +- Generated by @JoshFerge in [4c69fc3e](https://github.com/getsentry/sentry-api-schema/commit/4c69fc3e332668b6480c78a9a7cdf0c417ddd317) + +## 0.112.1 + +### Internal Changes + +- Sync rpc_method_schema for optional user by @geoffg-sentry in [#66](https://github.com/getsentry/sentry-api-schema/pull/66) + +## 0.112.0 + +### Schema Updates + +- Generated by @trevor-e in [7f9f1978](https://github.com/getsentry/sentry-api-schema/commit/7f9f1978b163229ed5511625747e397d776d17c1) + +## 0.111.0 + +### Schema Updates + +- Generated by @lynnagara in [f7ea21af](https://github.com/getsentry/sentry-api-schema/commit/f7ea21af981d786ce0059eea4e6a0d839ab6a4ab) + +### Other + +- Add user in sentry_app_region for external issues by @geoffg-sentry in [#65](https://github.com/getsentry/sentry-api-schema/pull/65) + +## 0.110.0 + +### Schema Updates + +- Generated by @cvxluo in [98154a7c](https://github.com/getsentry/sentry-api-schema/commit/98154a7cc96d62f32ade498ba98074c4682f42ae) + +## 0.109.0 + +### Schema Updates + +- Generated by @NicoHinderling in [f94ae8cf](https://github.com/getsentry/sentry-api-schema/commit/f94ae8cfed0c2bcd6c012b7a0bb03fb6497c8179) + +## 0.108.0 + +### Schema Updates + +- Generated by @ceorourke in [23e17bf4](https://github.com/getsentry/sentry-api-schema/commit/23e17bf41304f3dfced3eacdb3e8ac41f13b6287) + +## 0.107.0 + +### Schema Updates + +- Generated by @cvxluo in [fc78ced6](https://github.com/getsentry/sentry-api-schema/commit/fc78ced625e0203b2103e77df2fc064caafe73ec) + +## 0.106.0 + +### Schema Updates + +- Generated by @brendanhsentry in [b106062f](https://github.com/getsentry/sentry-api-schema/commit/b106062f048b32dc79eb1faeddacc17fc8b73098) + +## 0.105.0 + +### Schema Updates + +- Generated by @wedamija in [0017e1d9](https://github.com/getsentry/sentry-api-schema/commit/0017e1d955ce9ecf6d93ec4e60b450b1b1c8d594) + +## 0.104.0 + +### Schema Updates + +- Generated by @getsentry-bot in [fba8b6d9](https://github.com/getsentry/sentry-api-schema/commit/fba8b6d9e38dfdfeebd93125ee9e9841be6dc80c) + +## 0.103.0 + +### Schema Updates + +- Generated by @wedamija in [6cce6fda](https://github.com/getsentry/sentry-api-schema/commit/6cce6fda8341b041aac959e6ac574afb5e82f118) + +## 0.102.0 + +### Schema Updates + +- Generated by @ceorourke in [ed0270ff](https://github.com/getsentry/sentry-api-schema/commit/ed0270ff365ad1500e9014cb40a0b1f25007e689) + +## 0.101.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [e95d8bbd](https://github.com/getsentry/sentry-api-schema/commit/e95d8bbd116636ec99cf1acd3e5447d349b3f2e8) + +## 0.100.0 + +### Schema Updates + +- Generated by @lynnagara in [3c5ec0e5](https://github.com/getsentry/sentry-api-schema/commit/3c5ec0e51dd7641c44a56d10027c3a09a266966d) + +## 0.99.0 + +### Schema Updates + +- Generated by @ceorourke in [0ae08795](https://github.com/getsentry/sentry-api-schema/commit/0ae08795e09915f1b18e26ff0a9b72de1fe93e3b) + +## 0.98.0 + +### Schema Updates + +- Generated by @ceorourke in [ee34d268](https://github.com/getsentry/sentry-api-schema/commit/ee34d268ce4d0b03d8f35a25b607cf78e0567a23) + +## 0.97.0 + +### Schema Updates + +- Generated by @leeandher in [92876c2c](https://github.com/getsentry/sentry-api-schema/commit/92876c2cc190ca0d7852c49964613dd6fa6a9194) + +## 0.96.0 + +### Schema Updates + +- Generated by @ceorourke in [0d13a894](https://github.com/getsentry/sentry-api-schema/commit/0d13a894e13fc2105b4ccbd02c42df6fdf4d0360) + +## 0.95.0 + +### Schema Updates + +- Generated by @NicoHinderling in [60192733](https://github.com/getsentry/sentry-api-schema/commit/6019273307d622a275b284553db9ef9962d7d088) + +## 0.94.0 + +### Schema Updates + +- Generated by @ceorourke in [fefced22](https://github.com/getsentry/sentry-api-schema/commit/fefced224b55c3dc6fbb12d79cf78c800beaa251) + +## 0.93.0 + +### Schema Updates + +- Generated by @sehr-m in [defc9822](https://github.com/getsentry/sentry-api-schema/commit/defc98225e09e76f8dc0160ee6e939675448807a) + +## 0.92.0 + +### Schema Updates + +- Generated by @NicoHinderling in [61820a3e](https://github.com/getsentry/sentry-api-schema/commit/61820a3e367fbba775c08bf075e6304b05a78ca6) + +## 0.91.0 + +### Schema Updates + +- Generated by @cvxluo in [3fbe69e3](https://github.com/getsentry/sentry-api-schema/commit/3fbe69e337c22dd0398c4ba14ed0b6488c9b3f26) + +## 0.90.0 + +### Schema Updates + +- Generated by @wmak in [9893249d](https://github.com/getsentry/sentry-api-schema/commit/9893249d4138a3063f6c8aff3c0b583c830c5c70) + +## 0.89.0 + +### Schema Updates + +- Generated by @srest2021 in [b758ba21](https://github.com/getsentry/sentry-api-schema/commit/b758ba2169b84e1a1da12dc5e6da5a8a5904e94d) + +## 0.88.0 + +### Schema Updates + +- Generated by @mujacica in [5e016e65](https://github.com/getsentry/sentry-api-schema/commit/5e016e65d4087320f2cfbd32790ee49bc2c78493) + +## 0.87.0 + +### Schema Updates + +- Generated by @ceorourke in [d007bdab](https://github.com/getsentry/sentry-api-schema/commit/d007bdabe9700817b017b4c2d48c855f0e89880a) + +## 0.86.0 + +### Schema Updates + +- Generated by @Zylphrex in [b9c72fac](https://github.com/getsentry/sentry-api-schema/commit/b9c72fac6a2e492f7b6be63a14b959b88472881b) + +## 0.85.0 + +### Schema Updates + +- Generated by @lynnagara in [06927fd1](https://github.com/getsentry/sentry-api-schema/commit/06927fd159aa82f5e91b7d7acc28c1369fc67881) + +## 0.84.0 + +### Schema Updates + +- Generated by @lynnagara in [49da30ff](https://github.com/getsentry/sentry-api-schema/commit/49da30ffd2625da1c355e09aa53275b32134839e) + +## 0.83.0 + +### Schema Updates + +- Generated by @billyvg in [9cc2ef2c](https://github.com/getsentry/sentry-api-schema/commit/9cc2ef2cf4b1977fed26c15cfcf29facdcf3aeff) + +## 0.82.0 + +### Schema Updates + +- Generated by @ceorourke in [0321a386](https://github.com/getsentry/sentry-api-schema/commit/0321a3866e1f1db3558ecab67f364778699bbfa6) + +## 0.81.0 + +### Schema Updates + +- Generated by @evanpurkhiser in [2d0be45a](https://github.com/getsentry/sentry-api-schema/commit/2d0be45adab5fe42731bc1d19fe76e586fc9f26b) + +## 0.80.1 + +### Bug Fixes + +- (workflow) Replace `getSantry` with dedicated GH app for api schema workflow by @Jeffreyhung in [#64](https://github.com/getsentry/sentry-api-schema/pull/64) + +## 0.80.0 + +### Schema Updates + +- Generated by @cameroncooke in [280c69e3](https://github.com/getsentry/sentry-api-schema/commit/280c69e3c96daf58c2649f3046e0e12e69d350ee) + +## 0.79.0 + +### Schema Updates + +- Generated by @Zylphrex in [8307b8c1](https://github.com/getsentry/sentry-api-schema/commit/8307b8c1050e2737020182c836e897b7d7f0dd13) + +## 0.78.0 + +### Schema Updates + +- Generated by @sentaur-athena in [f33abbac](https://github.com/getsentry/sentry-api-schema/commit/f33abbac539453af90e36ba14dce8550d817bd8b) + +## 0.77.0 + +### Schema Updates + +- Generated by @Mihir-Mavalankar in [4a0e6e33](https://github.com/getsentry/sentry-api-schema/commit/4a0e6e33a48f3330ea0fadd0fa042385af1aae92) + +## 0.76.1 + +### Internal Changes + +- Pin GitHub Actions to full-length commit SHAs by @joshuarli in [#62](https://github.com/getsentry/sentry-api-schema/pull/62) + +## 0.76.0 + +### Schema Updates + +- Generated by @cvxluo in [6ad2383a](https://github.com/getsentry/sentry-api-schema/commit/6ad2383a3245ebb81d58bdaf22dc93f254e6fc72) + +## 0.75.0 + +### Schema Updates + +- Generated by @lynnagara in [57b79920](https://github.com/getsentry/sentry-api-schema/commit/57b799205acd9625bc19ee13b2e64c89ee3cc927) + +## 0.74.0 + +### Schema Updates + +- Generated by @lynnagara in [f8966f87](https://github.com/getsentry/sentry-api-schema/commit/f8966f87c2949b441265ed7ed68c3e147b01b29f) + +## 0.73.0 + +### Schema Updates + +- Generated by @wmak in [00a87c37](https://github.com/getsentry/sentry-api-schema/commit/00a87c37f4fa774905dedf7d9337ab8424956231) + +## 0.72.0 + +### Schema Updates + +- Generated by @ceorourke in [4ef766a4](https://github.com/getsentry/sentry-api-schema/commit/4ef766a4a9285f5ac4949ae2142171fb989c350a) + +## 0.71.0 + +### Schema Updates + +- Generated by @lynnagara in [0259ad9a](https://github.com/getsentry/sentry-api-schema/commit/0259ad9aa394be67d7ca060db3b4dd914264a777) + +## 0.70.0 + +### Schema Updates + +- Generated by @ajay-sentry in [242bf123](https://github.com/getsentry/sentry-api-schema/commit/242bf1238abd0dede5bae09d2e2d908ecf15b51d) + +## 0.69.0 + +### Schema Updates + +- Generated by @ryan953 in [a8cf557f](https://github.com/getsentry/sentry-api-schema/commit/a8cf557f41f64abe89e986f02d86c924d159390b) + +## 0.68.0 + +### Schema Updates + +- Generated by @lynnagara in [232252c4](https://github.com/getsentry/sentry-api-schema/commit/232252c4bf73195a9d29a87672aef5144f5fa2df) + +## 0.67.0 + +### Schema Updates + +- Generated by @lynnagara in [2ae1bf56](https://github.com/getsentry/sentry-api-schema/commit/2ae1bf56ccf758cd0738b7a2c4230ef64a3651e7) + +## 0.66.0 + +### Schema Updates + +- Generated by @geoffg-sentry in [70ca122f](https://github.com/getsentry/sentry-api-schema/commit/70ca122fd63ece6d66e11ef70fc45dfa51a034e1) + +## 0.65.0 + +### Schema Updates + +- Generated by @ceorourke in [0b092d06](https://github.com/getsentry/sentry-api-schema/commit/0b092d068263964765bc5d5bf68e038830c84312) + +## 0.64.0 + +### Schema Updates + +- Generated by @lynnagara in [783fcbad](https://github.com/getsentry/sentry-api-schema/commit/783fcbadad3199a0a6fdc38ed7afbc471b81bb60) + +## 0.63.0 + +### Schema Updates + +- Generated by @wedamija in [a462fde0](https://github.com/getsentry/sentry-api-schema/commit/a462fde062a5ab90ada6e9416246b45277d5e256) + +## 0.62.0 + +### Schema Updates + +- Generated by @scttcper in [856d8a93](https://github.com/getsentry/sentry-api-schema/commit/856d8a930d4078c5c8bff64da8121d34a14176af) + +## 0.61.0 + +### Schema Updates + +- Generated by @lynnagara in [3987d5eb](https://github.com/getsentry/sentry-api-schema/commit/3987d5eb3a0d3a2f433e7656e28e79830fb0efdd) + +## 0.60.0 + +### Schema Updates + +- Generated by @sentry-release-bot[bot] in [5aa8bd7e](https://github.com/getsentry/sentry-api-schema/commit/5aa8bd7e1e9cea540dad51e4bda4febf9e1d2e63) +- Generated by @lynnagara in [3ada0722](https://github.com/getsentry/sentry-api-schema/commit/3ada072206640f535d89016c2ca1c288a3ab8664) + +## 0.59.0 + +### Schema Updates + +- Generated by @lynnagara in [ae0f57da](https://github.com/getsentry/sentry-api-schema/commit/ae0f57dac0e57dd195de2d8322b40486fe7ed59b) + +## 0.58.0 + +### Schema Updates + +- Generated by @romtsn in [89dd523c](https://github.com/getsentry/sentry-api-schema/commit/89dd523c4b98f645162831e256738b7b1a6d1c3a) + +## 0.57.0 + +### Schema Updates + +- Generated by @lynnagara in [3e660fef](https://github.com/getsentry/sentry-api-schema/commit/3e660fefddec85de95a54700150dc3aabf718e12) + +## 0.56.0 + +### Schema Updates + +- Generated by @lynnagara in [96182d4d](https://github.com/getsentry/sentry-api-schema/commit/96182d4dac8cb361c5215c4eb17af5554224b977) + +## 0.55.0 + +### Schema Updates + +- Generated by @ceorourke in [47472cd2](https://github.com/getsentry/sentry-api-schema/commit/47472cd2e7efae144c35c07f16182c3f334551a5) + +## 0.54.0 + +### Schema Updates + +- Generated by @lynnagara in [5d0f31cc](https://github.com/getsentry/sentry-api-schema/commit/5d0f31cc6b28159aff0eadf93422258fa2a20c9f) + +## 0.53.0 + +### Schema Updates + +- Generated by @lynnagara in [58824592](https://github.com/getsentry/sentry-api-schema/commit/58824592d837e7f2490d96e7b33ebef4199d5092) + +## 0.52.0 + +### Schema Updates + +- Generated by @nsdeschenes in [97036d71](https://github.com/getsentry/sentry-api-schema/commit/97036d712de41fbfecb85b87213e3d69fea9f2c9) + +## 0.51.0 + +### Schema Updates + +- Generated by @lynnagara in [a627e281](https://github.com/getsentry/sentry-api-schema/commit/a627e2813d9d35f69fede87d1af5095a878b0f21) + +## 0.50.0 + +### Schema Updates + +- Generated by @lynnagara in [5b826217](https://github.com/getsentry/sentry-api-schema/commit/5b826217e7d84313aa93a7fd2257704d796b9d9e) + +## 0.49.0 + +### Schema Updates + +- Generated by @getsentry-bot in [8cfb7b4f](https://github.com/getsentry/sentry-api-schema/commit/8cfb7b4fde51c01efe706e5012546cfe6167dde1) + +## 0.48.0 + +### Schema Updates + +- Generated by @nsdeschenes in [868c57a3](https://github.com/getsentry/sentry-api-schema/commit/868c57a3696e59f57702129db19a90b94f1d394a) + +## 0.47.0 + +### Schema Updates + +- Generated by @lynnagara in [acab8df5](https://github.com/getsentry/sentry-api-schema/commit/acab8df5bc3f6ab6b35a6dda3209090ddba5b77b) + +## 0.46.0 + +### Schema Updates + +- Generated by @lynnagara in [dd7331fd](https://github.com/getsentry/sentry-api-schema/commit/dd7331fdb2f004d3f6d8611db350650588a859e2) + +## 0.45.0 + +### Schema Updates + +- Generated by @jaydgoss in [77e743f1](https://github.com/getsentry/sentry-api-schema/commit/77e743f108c50d5a9e73b11e07541f82c7de0f55) + +## 0.44.0 + +### Schema Updates + +- Generated by @chromy in [e3c8c4ad](https://github.com/getsentry/sentry-api-schema/commit/e3c8c4ad7678e284375ddfbdfd036f2b9c052358) + +## 0.43.0 + +### Schema Updates + +- Generated by @DominikB2014 in [ddda9506](https://github.com/getsentry/sentry-api-schema/commit/ddda95066e1e5469719906060605f9fec04010e5) + +## 0.42.0 + +### Schema Updates + +- Generated by @lynnagara in [512ff337](https://github.com/getsentry/sentry-api-schema/commit/512ff337a53eccb33dc1540c5651dd233620e007) +- Generated by @cvxluo in [f6b55301](https://github.com/getsentry/sentry-api-schema/commit/f6b553010e679578df9b66a404ef59e366fa1d5c) + +## 0.41.0 + +### Schema Updates + +- Generated by @NicoHinderling in [bba35ef5](https://github.com/getsentry/sentry-api-schema/commit/bba35ef53f45a42153aa0aa1d9a92d7823a6ee4b) + +## 0.40.0 + +### Schema Updates + +- Generated by @gggritso in [93dbd292](https://github.com/getsentry/sentry-api-schema/commit/93dbd292c09f9794116eed89ec13e8f0ba8e9e48) + +## 0.39.0 + +### Schema Updates + +- Generated by @edwardgou-sentry in [fec833de](https://github.com/getsentry/sentry-api-schema/commit/fec833de5cd7e514271ec476a242aca162c1aa1d) +- Generated by @cmanallen in [6b2199fa](https://github.com/getsentry/sentry-api-schema/commit/6b2199fa519360736449b8fb2ab3a34201bf3162) + +## 0.38.0 + +### Schema Updates + +- Generated by @runningcode in [6000febd](https://github.com/getsentry/sentry-api-schema/commit/6000febd6825022a5f030384048acd8bb405fdf0) + +## 0.37.0 + +### Schema Updates + +- Generated by @cvxluo in [8896fca1](https://github.com/getsentry/sentry-api-schema/commit/8896fca1fbb8ffd08864bfe4f2279c7e4e25f7e7) + +## 0.36.0 + +### Schema Updates + +- Generated by @ryan953 in [4e8b36f2](https://github.com/getsentry/sentry-api-schema/commit/4e8b36f21ed897f71318345492681a7c416234a0) + +## 0.35.0 + +### Schema Updates + +- Generated by @ceorourke in [39a11fc9](https://github.com/getsentry/sentry-api-schema/commit/39a11fc9bf8132832790ae5e4a02707582832eca) + +## 0.34.0 + +### Schema Updates + +- Generated by @lynnagara in [64da383a](https://github.com/getsentry/sentry-api-schema/commit/64da383ad79df2bb8e0846dd6fdd6f757dfb366f) + +## 0.33.0 + +### Schema Updates + +- Generated by @Christinarlong in [016cb713](https://github.com/getsentry/sentry-api-schema/commit/016cb713460f585d3f59a2ef4c9af3a381f74365) + +## 0.32.0 + +### Schema Updates + +- Generated by @vaind in [8e0baa7d](https://github.com/getsentry/sentry-api-schema/commit/8e0baa7df20a15cf3e6006635671bd895b201c34) + +## 0.31.0 + +### Schema Updates + +- Generated by @lynnagara in [a7505c15](https://github.com/getsentry/sentry-api-schema/commit/a7505c151bc0cf089cf334346de38d4ff3a0056c) + +## 0.30.0 + +### Schema Updates + +- Generated by @phacops in [b99c9c5a](https://github.com/getsentry/sentry-api-schema/commit/b99c9c5adf4ca99252b32aa27285d84f230a9e7e) + +## 0.29.0 + +### Schema Updates + +- Generated by @aliu39 in [9c6d9675](https://github.com/getsentry/sentry-api-schema/commit/9c6d9675d8c13ec474083dd3cdb793c6f2a69383) + +## 0.28.0 + +### Schema Updates + +- Generated by @cvxluo in [b02b0fc7](https://github.com/getsentry/sentry-api-schema/commit/b02b0fc72d6cd844ace73aa326582f921bd50bce) + +## 0.27.0 + +### Schema Updates + +- Generated by @nikkikapadia in [88204cb6](https://github.com/getsentry/sentry-api-schema/commit/88204cb66c9122824eee7dfb3aad7e5622273424) + +## 0.26.0 + +### Schema Updates + +- Generated by @runningcode in [fe5f26b7](https://github.com/getsentry/sentry-api-schema/commit/fe5f26b7e4b83f431de9971b8e181314ced1c5b2) + +## 0.25.0 + +### Schema Updates + +- Generated by @gggritso in [882b021b](https://github.com/getsentry/sentry-api-schema/commit/882b021b3db90e72332b90c3907d3086399038e4) + +## 0.24.0 + +### Schema Updates + +- Generated by @nikkikapadia in [ccda1d45](https://github.com/getsentry/sentry-api-schema/commit/ccda1d45419a20e7203ebe713e8e513170049d6a) + +## 0.23.0 + +### Schema Updates + +- Generated by @priscilawebdev in [542962a4](https://github.com/getsentry/sentry-api-schema/commit/542962a418e0c36fd99f201d67191bccc4055d71) + +## 0.22.0 + +### Schema Updates + +- Generated by @trevor-e in [e46e28d6](https://github.com/getsentry/sentry-api-schema/commit/e46e28d6bb1002b12662b42ac7ace71488abe14c) + +## 0.21.0 + +### Schema Updates + +- Generated by @markstory in [ad4494b0](https://github.com/getsentry/sentry-api-schema/commit/ad4494b0b154dd5dfa67496fff2bbf109bc02866) + +## 0.20.0 + +### New Features + +- Bundle pagination utilities into the SDK entry point by @MathurAditya724 in [#61](https://github.com/getsentry/sentry-api-schema/pull/61) + +## 0.19.0 + +### Schema Updates + +- Generated by @MathurAditya724 in [92e4da4e](https://github.com/getsentry/sentry-api-schema/commit/92e4da4e9c55817f4823d2e46cde486733f81778) + +## 0.18.0 + +### Schema Updates + +- Generated by @souredoutlook in [0319696c](https://github.com/getsentry/sentry-api-schema/commit/0319696cabf1b91c2fdc69747b19cef56877aa43) + +## 0.17.0 + +### Schema Updates + +- Generated by @NicoHinderling in [508ff446](https://github.com/getsentry/sentry-api-schema/commit/508ff446e1d2fd83e0073a0f6edbb6230e033476) + +## 0.16.0 + +### Schema Updates + +- Generated by @NicoHinderling in [c4000718](https://github.com/getsentry/sentry-api-schema/commit/c40007183d071d4ef28a886be883697cc4fde251) + +## 0.15.0 + +### Schema Updates + +- Generated by @trevor-e in [b086900e](https://github.com/getsentry/sentry-api-schema/commit/b086900e82afcc3b208167de1b913ccfd508ea44) + +## 0.14.0 + +- Generated by @mtopo27 in [76e2909c](https://github.com/getsentry/sentry-api-schema/commit/76e2909c80773ff6e45a05ef0963a10ce3ae9465) + +## 0.13.0 + +- Generated by @trevor-e in [76d8cb49](https://github.com/getsentry/sentry-api-schema/commit/76d8cb490ded8ab770d83bc49abdd2c2700fd292) + +## 0.12.0 + +- Generated by @billyvg in [de4e52a4](https://github.com/getsentry/sentry-api-schema/commit/de4e52a4b5d0b80b1eff55b1ad168db1fb823d16) + +## 0.11.0 + +- Generated by @ryan953 in [edcb7e69](https://github.com/getsentry/sentry-api-schema/commit/edcb7e6901b7beace10f9a320a0f12b32fb403a8) + +## 0.10.0 + +- Generated by @shellmayr in [a38a1a4f](https://github.com/getsentry/sentry-api-schema/commit/a38a1a4fded686287b3658aea3ade8436d818a56) + +## 0.9.0 + +- Generated by @lcian in [d0a82f18](https://github.com/getsentry/sentry-api-schema/commit/d0a82f184b2cde0198c267dd3eb3042143dbe807) + +## 0.8.0 + +- Generated by @kcons in [5aa740ca](https://github.com/getsentry/sentry-api-schema/commit/5aa740cae562dd4a585aacd040f0f3dea0fec020) + +## 0.7.0 + +- Generated by @jaydgoss in [12289694](https://github.com/getsentry/sentry-api-schema/commit/1228969496dfcc433f54e17167f143a93726d639) + +## 0.6.0 + +- Generated by @michelletran-sentry in [2601664f](https://github.com/getsentry/sentry-api-schema/commit/2601664f4aacec70fdd4d367b90057174a0ec39c) + +## 0.5.0 + +- Generated by @ceorourke in [33cfa00f](https://github.com/getsentry/sentry-api-schema/commit/33cfa00f5fb5a8c3ccc653ae5060d18f40cd7e40) + +## 0.4.0 + +- Generated by @narsaynorath in [68f3698b](https://github.com/getsentry/sentry-api-schema/commit/68f3698b98e048648863ff0f18420b2d6299be33) + +## 0.3.0 + +### Bug Fixes + +- Minor corrections by @MathurAditya724 in [#58](https://github.com/getsentry/sentry-api-schema/pull/58) + +### Other + +- Generated by @trevor-e in [36bad087](https://github.com/getsentry/sentry-api-schema/commit/36bad08768f8ad625845b3c70861f5d05429db38) +- Generated by @DominikB2014 in [f95ef75e](https://github.com/getsentry/sentry-api-schema/commit/f95ef75e679e62ad64769a16d4bfcdb3cbe5c6e8) +- Generated by @ceorourke in [c08b89e4](https://github.com/getsentry/sentry-api-schema/commit/c08b89e4a3ccfd1cf99998d0042edeee5dd1af54) + +## 0.2.0 + +### New Features ✨ + +- Added cache for the bun install step by @MathurAditya724 in [#56](https://github.com/getsentry/sentry-api-schema/pull/56) + +### Bug Fixes 🐛 + +- Minor change by @MathurAditya724 in [#57](https://github.com/getsentry/sentry-api-schema/pull/57) + +### Other + +- Generated by @NicoHinderling in [fc6e69da](https://github.com/getsentry/sentry-api-schema/commit/fc6e69da258d32a2bd13d9b2c8efd825eb2e28fc) +- Generated by @edwardgou-sentry in [b6771a60](https://github.com/getsentry/sentry-api-schema/commit/b6771a60fe68003b448ffb0dfd62e472c9ed5b5c) +- Generated by @ceorourke in [c22bff82](https://github.com/getsentry/sentry-api-schema/commit/c22bff823a49eabc5b35c0d6abd951a7b4a1a0e9) +- fix: updated the package homepage by @MathurAditya724 in [c2023933](https://github.com/getsentry/sentry-api-schema/commit/c2023933bb699287d32a3b25f633fb06eca0263d) + +## 0.1.0 + +- No documented changes. diff --git a/static/packages/api/LICENSE.md b/static/packages/api/LICENSE.md new file mode 100644 index 000000000000..6e9bafc7ac4d --- /dev/null +++ b/static/packages/api/LICENSE.md @@ -0,0 +1,105 @@ +# Functional Source License, Version 1.1, Apache 2.0 Future License + +## Abbreviation + +FSL-1.1-Apache-2.0 + +## Notice + +Copyright 2025 Functional Software, Inc. dba Sentry + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/static/packages/api/README.md b/static/packages/api/README.md new file mode 100644 index 000000000000..45d5b77437bf --- /dev/null +++ b/static/packages/api/README.md @@ -0,0 +1,658 @@ +# @sentry/api + +The official, auto-generated TypeScript client for Sentry's public REST API. + +[![npm](https://img.shields.io/npm/v/@sentry/api.svg)](https://www.npmjs.com/package/@sentry/api) +[![license](https://img.shields.io/npm/l/@sentry/api.svg)](./LICENSE.md) + +## Install + +```bash +npm install @sentry/api +``` + +## Usage + +Configure a client once, then call its typed operation methods: + +```ts +import { createSentryClient } from "@sentry/api"; + +const sentry = createSentryClient({ + baseUrl: "https://sentry.io", + headers: { Authorization: `Bearer ${process.env.SENTRY_AUTH_TOKEN}` }, +}); + +const result = await sentry.listOrganizations(); + +if ("error" in result) throw result.error; +console.log(result.data); +``` + +Operation methods retain the generated documentation, typed arguments, and response types. Per-call options include `path`, `query`, `headers`, `signal`, and `throwOnError`. Results preserve the underlying `request` and `response`, including response headers: + +```ts +const controller = new AbortController(); + +const { data: project, request, response } = await sentry.getProject({ + path: { + organization_id_or_slug: "my-org", + project_id_or_slug: "my-project", + }, + signal: controller.signal, + throwOnError: true, +}); + +console.log(project, request.url, response.headers.get("content-type")); +``` + +Operations always return `fields`: `{ data, request, response }` on success, or `{ error, request, response }` on failure. A failure without an HTTP response has `response: undefined`. Check `"error" in result`, since a custom error can itself be `undefined`. Received headers are in `response.headers`; outgoing headers are in `request.headers`. + +Set `throwOnError: true` to reject on failures while keeping the same success fields. Methods are bound to their instance and can be safely destructured, such as `const { getProject } = sentry`. Types narrow to success when `true` is supplied **per call**; calls that inherit mutable client configuration conservatively retain the result union. + +Each `createSentryClient` call creates an independent transport configuration. The root entry also exports the `SentryClient` and `Config` types; `Config` is the underlying transport's configuration type. Access that transport as `sentry.client` for interceptors, configuration updates, or low-level requests. + +Supply the base URL and authentication appropriate for your deployment. See Sentry's [API authentication documentation](https://docs.sentry.io/api/auth/). + +### Standalone operations + +Individual operation functions remain available as a modular alternative and accept transport configuration directly: + +```ts +import { listOrganizations } from "@sentry/api"; + +const { data, error } = await listOrganizations({ + baseUrl: "https://sentry.io", + headers: { Authorization: `Bearer ${process.env.SENTRY_AUTH_TOKEN}` }, +}); +``` + +They can also reuse an existing instance's configuration: `listOrganizations({ client: sentry.client })`. + +### Query parameter types + +Each operation's `Data` type describes its `path`, `query`, and `body`. Enum-valued query parameters also get a named type for one value, `Query`. For array parameters, that is the element type: + +```ts +import type { + ListOrganizationTraceItemAttributesQueryAttributeType, + ListOrganizationTraceItemAttributesQueryDataset, +} from "@sentry/api"; + +const dataset: ListOrganizationTraceItemAttributesQueryDataset = "spans"; +const types: ListOrganizationTraceItemAttributesQueryAttributeType[] = ["string", "number"]; +``` + +### Query defaults + +A few operations send a query parameter by default to skip expensive work most callers don't need. The operation's documentation states the default. + +| Operation | Default | Effect | +| --- | --- | --- | +| `getOrganizationIssueEvent` | `collapse: ["adjacentEvents"]` | Skips the next/previous event lookups; `nextEventID` and `previousEventID` are `null` | +| `getProjectEvent` | `collapse: ["adjacentEvents"]` | Skips the next/previous event lookups; `nextEventID` and `previousEventID` are `null` | + +The default applies only when the parameter is omitted or `undefined`. Any value you pass replaces it, and an empty list sends nothing: + +```ts +const path = { organization_id_or_slug: "my-org", issue_id: "123", event_id: "latest" } as const; + +await sentry.getOrganizationIssueEvent({ path }); // ?collapse=adjacentEvents +await sentry.getOrganizationIssueEvent({ path, query: { collapse: [] } }); // includes nextEventID/previousEventID +``` + +Standalone operations, bound methods, and `narrowError_*` wrappers behave the same way. The operation catalog describes the API itself, so it does not list these defaults. + +### Operation discovery + +`@sentry/api/operations` exports a readonly catalog for offline discovery, without importing the client, validators, or transport: + +```ts +import { operations } from "@sentry/api/operations"; + +const logOperations = operations.filter(operation => + operation.queryParameters.some(parameter => + parameter.name === "dataset" && parameter.enum?.includes("logs"), + ), +); +console.log(logOperations.map(({ functionName, method, path }) => ({ functionName, method, path }))); +``` + +Each `ApiOperation` records the actual exported `functionName`, original OpenAPI `operationId`, method, path, summary, description, tags, path parameter names, query metadata, explicit deprecation flag, `experimental` flag, and `idempotent` flag. `ApiQueryParameter` includes the name, required flag, description, type, and declared enum values (item values for arrays). Operation parameters override inherited path parameters. + +`experimental` is `true` when Sentry marks the operation with the `x-sentry-experimental` OpenAPI extension, which it sets for endpoints under active development that may change. Their SDK docs also start with an **Experimental:** note. To find or reject experimental operations, for example in a lint rule or build check: + +```ts +import { operations } from "@sentry/api/operations"; + +const experimental = new Set( + operations.filter(operation => operation.experimental).map(operation => operation.functionName), +); +experimental.has("listOrganizationTraceMetrics"); // true +``` + +`idempotent` tells retrying clients whether resending an identical request after a timeout or 5xx response is safe. It is always `true` for `GET`, `HEAD`, `OPTIONS`, and `TRACE`. Other methods are `true` only when Sentry marks the operation with the `x-sentry-idempotent` OpenAPI extension; `false` means not known to be idempotent, not known to be unsafe. + +The catalog is generated from the same schema and SDK output as each release. Consumers own search, grouping, ranking, and command aliases; the catalog does not fetch a live schema or infer undocumented API behavior. + +### Build-time source analysis + +`@sentry/api/source-analysis` follows references in supplied TypeScript source to discover calls to catalog operations. Install TypeScript only when using this optional build tool; the analyzer is tested with TypeScript 5.9. Its optional peer uses a wildcard so it does not constrain the compiler used by applications that only import the client. + +```bash +npm install --save-dev @sentry/api typescript@^5.9 +``` + +```ts +import { operations } from "@sentry/api/operations"; +import { analyzeSourceApiUsage } from "@sentry/api/source-analysis"; + +const sources = new Map([ + ["features/logs.ts", ` + import { listOrganizationEvents } from "@sentry/api"; + export const readLogs = () => + listOrganizationEvents({ + path: { organization_id_or_slug: "example-org" }, + query: { dataset: "logs", field: ["message"] }, + }); + `], +]); +const analysis = analyzeSourceApiUsage(sources, operations, { + queryParameters: ["dataset"], +}); +console.log(analysis.exports.get("features/logs.ts")?.get("readLogs")?.operations); +// Includes listOrganizationEvents with known query context { dataset: "logs" }. +``` + +The analyzer parses source without executing it, following local imports, re-exports, aliases, and references. Supply sources together with the operation catalog from the installed SDK version. Run it during the consumer's build: command-to-operation relationships depend on consumer code and cannot be precomputed during SDK packaging. + +Named, namespace, and default imports are supported, along with top-level functions, variable initializers, and default function/expression exports. Relative imports resolve only against the supplied sources. Extensionless paths try the exact file, `.ts`, `.tsx`, `index.ts`, then `index.tsx`; `.js` specifiers try `.ts` and `.tsx` before the exact `.js` file. Explicit `.ts`/`.tsx` specifiers use the exact file. Named re-exports can forward a default; `export *` forwards only named exports. + +Consumers configure their own `boundaries` (factory names whose arguments are inspected without following the factory implementation), `requests` (raw-request wrapper argument positions), `queryParameters` (query values to retain), and `declarationFlags` (boolean markers). These options default to empty lists. Returned `boundaries` identify factory-created exports and their markers; `methods` records reachable HTTP methods, including known methods on unresolved raw requests. CLI routing, aliases, presentation, and destructive-command policy remain with the consumer. + +This is static discovery, not proof of runtime execution or safety. Class/member dispatch, dynamic imports, CommonJS, and `tsconfig` path aliases are outside its scope. Missing sources and unsupported constructs can omit calls, so an empty result does not prove that no API calls exist. `unresolvedRequests` contains diagnostics for raw requests that cannot be resolved against the public catalog, not source-coverage diagnostics. Unknown query values are omitted, mutable `let` and `var` bindings are left unknown, and a related operation does not describe every request a workflow makes. The entry imports TypeScript separately and is not loaded by the root client, browser helpers, or operation catalog. + +### Browser sessions + +The browser helper configures session cookies and CSRF token injection: + +```ts +import { createSentryClient } from "@sentry/api"; +import { createBrowserSdkConfig } from "@sentry/api/browser"; + +const sentry = createSentryClient( + createBrowserSdkConfig({ baseUrl: "https://sentry.example.com" }), +); + +const { data, error } = await sentry.listOrganizations(); +``` + +### Routing requests by organization + +Set `resolveBaseUrl` to pick each request's base URL from its path before the +URL is built, such as the region that serves the organization. It receives the +configured `baseUrl` (`''` when unset), the path and query that follow it, and +the organization and project the path targets (`parseApiPathScope` from +`@sentry/api/client`; absent for unscoped paths). Its result replaces `baseUrl` +for that request only; trailing slashes are removed. + +```ts +const sentry = createSentryClient({ + baseUrl: "https://sentry.io", + resolveBaseUrl: async ({ baseUrl, scope }) => + scope ? await regionUrlFor(scope.organization) : baseUrl, +}); +// Sent to the organization's region, such as https://us.sentry.io. +await sentry.listOrganizationIssues({ path: { organization_id_or_slug: "acme" } }); +``` + +It runs once per request, including `prepareRequest`, after `requestValidator` +and before request interceptors. A per-call `resolveBaseUrl` replaces the +configured one. A throw rejects the request unchanged, before `fetch` is called. + +### Preparing a raw request + +Use `sentry.client.prepareRequest` when a consumer needs to inspect a request +or send it through its own raw transport. It shares the preparation used by +ordinary SDK requests: instance and per-call configuration, authentication, +validation, body serialization, and request interceptors. + +```ts +const sentry = createSentryClient({ baseUrl: "https://example.test/sentry" }); +const request = await sentry.client.prepareRequest({ + method: "GET", + url: "/api/0/organizations/example/events/?field=id&field=title&cursor=old#view", + query: { cursor: "opaque-next", per_page: 50 }, +}); +// Both field values remain; the old cursor is replaced and the fragment removed. +const response = await fetch(request); +// The consumer owns status handling and reading the raw body. +``` + +Preparation does not call the configured `fetch` or parse a response. Auth +providers, validators and request interceptors still run and can have side +effects; their errors reject unchanged. The returned native `Request` includes +the resolved options and request-interceptor changes. Its signal remains linked +to the caller's unless an interceptor replaces or clears it. Send it directly +with the chosen transport; calling `client.request` again would repeat preparation. +Reading `request.clone()` leaves its body available for dispatch. A custom fetch or +browser adapter can still add credentials, CSRF headers or routing at dispatch. + +For endpoints with an existing query, serialized query keys replace all existing +occurrences of those keys. Explicit `null` input keys also remove existing values, +even with custom serializers; the serialized output can then add them back. +Unrelated repeated parameters and their encoding are retained, as is custom +query-serializer output. The default serializer omits `null` and `undefined`: +`null` clears the original value, while `undefined` leaves it unchanged. +Literal URL fragments are removed; encoded `%23` values are retained. These +rules also apply to ordinary requests through the same URL builder. + +Use `bodySerializer: null` for an already serialized string, `FormData`, or +another native body. For `FormData`, also clear the default JSON content type +with `headers: { "Content-Type": null }` so `Request` supplies its boundary. +Otherwise the configured serializer still applies. Native +`Request` construction can add a content type for strings or form data; prepared +headers describe that request, not a consumer's earlier input or redacted preview. + +Pass decoded server cursors and explicit page sizes as query parameters. The SDK +does not interpret bookmark formats, select navigation directions or store +history. Existing custom transports accepted by generated operations do not +have to implement `prepareRequest`; the factory's concrete transport adds it. + +### Cancellation and custom fetch + +Reuse the client and supply an `AbortSignal` for each operation. The caller owns the signal, whether it comes from a command, an MCP tool call, or a query library: + +```ts +const controller = new AbortController(); +const pending = sentry.listOrganizations({ + signal: controller.signal, + throwOnError: true, +}); + +// When this operation is no longer needed: +controller.abort(); +await pending; // Rejects with the cancellation reason by default. +``` + +Cancellation does not affect other calls sharing the client. Immediately before invoking `fetch`, the SDK checks the final request's signal, including changes made by request interceptors. An already-aborted signal prevents dispatch; authentication, validation, and request interceptors may still run before that check. + +A configured `fetch` receives a native `Request`; its method, body, headers, and signal need not appear in a separate `init` argument. Forward the request intact and preserve native `RequestInit` override semantics when wrapping another transport: + +```ts +const sentry = createSentryClient({ + baseUrl: "https://sentry.io", + fetch: (input, init) => fetch(input, init), +}); +``` + +Your transport must honor the signal while a request is in flight. The SDK cannot force an arbitrary custom fetch or asynchronous hook to stop, and cancellation does not undo a server mutation that has already completed. + +Cancellation follows the [error handling contract](#error-handling): before dispatch the SDK uses `signal.reason`; during fetch it preserves the transport's rejection, which may differ by runtime. By default this is returned as `{ error, request, response: undefined }`, or thrown with `throwOnError: true`. Error interceptors can intentionally map it. Cancellation during response body reading rejects unchanged, even in fields mode. There is no SDK-specific cancellation error class. + +### Uploading files to Objectstore + +`@sentry/api/client` exports an Objectstore client for the service that upload-options endpoints such as `snapshots/upload-options/` return. `findExistingObjects` checks keys with batched HEAD requests; `putObjects` sends uploads up to 1 MB in batched multipart requests and PUTs larger ones individually. Batches hold up to 1000 operations and 25 MB, with up to 4 in flight, so large uploads stay under the per-request rate limit of the Sentry proxy. The client uses only web-standard APIs: the host supplies each file's bytes and may supply `fetch`. + +```ts +import { findExistingObjects, putObjects } from "@sentry/api/client"; + +const existing = await findExistingObjects(objectstore, keys, { fetch }); +await putObjects( + objectstore, + files + .filter((file) => !existing.has(file.key)) + .map((file) => ({ key: file.key, size: file.size, read: () => readFile(file.path) })), + { fetch }, +); +``` + +Failures throw `ObjectstoreError`, an `ApiError` with the failing object or request `url`. + +## Runtime validation + +The root `@sentry/api` entry has no runtime dependencies. It provides the API client and pure TypeScript types without installing a validation library. + +Valibot 1 runtime schemas are available through a separate optional entry point: + +```bash +npm install @sentry/api valibot +``` + +```ts +import * as v from "valibot"; +import { vGetProjectResponse } from "@sentry/api/valibot"; + +const project = v.parse(vGetProjectResponse, input); +``` + +Zod 4 runtime schemas are available through another optional entry point: + +```bash +npm install @sentry/api zod +``` + +```ts +import { zGetProjectResponse } from "@sentry/api/zod"; + +const project = zGetProjectResponse.parse(input); +``` + +`valibot` and `zod` are optional peer dependencies. Install neither when you only need the generated client and TypeScript types, or install the validator used by your application. The `@sentry/api/valibot` entry requires Valibot 1; its optional peer uses a wildcard because npm applies peer constraints to the whole package, including consumers that never import this entry. The `@sentry/api/zod` entry requires Zod 4.6.6 or later. + +Schemas nested in a top-level schema, such as object fields, carry their OpenAPI description: read it with `v.getDescription(vDeployResponse.entries.id)` or `zDeployResponse.shape.id.description`. Top-level Valibot schemas have no description pipe, so `v.pick`, `v.omit`, `v.partial` and `v.required` accept them; their description is in their JSDoc comment. + +## Error handling + +HTTP failures use `SentryApiError`, whether returned in `result.error` or thrown with `throwOnError: true`. It preserves the status, parsed `body`, exact `rawBody` text, `request`, and `response` (including headers). The transport reads the error body once; consumers do not need to clone or reread the response. Error bodies without valid JSON remain strings. + +```ts +import { SentryApiError } from "@sentry/api"; + +const result = await sentry.listOrganizations(); +if ("error" in result) { + if (result.error instanceof SentryApiError) { + console.log(result.error.status, result.error.body, result.error.rawBody); + console.log(result.error.response?.headers.get("retry-after")); + } + throw result.error; +} +``` + +`error` is typed as `unknown`: a custom `fetch` or error interceptor can supply its own error. Fetch rejections, including cancellation, keep their original identity and have no HTTP response. They are returned or thrown according to `throwOnError`. Exceptions from serialization, validation, interceptors, or response body reading/decoding reject unchanged, even with `throwOnError: false`; that setting does not make a request exception-free. + +### Custom errors + +HTTP errors are normalized **before** error interceptors run. Interceptors run in registration order, each receiving the preceding interceptor's result. Their returned or thrown value is preserved by operations and pagination; there is no second normalization pass. + +```ts +sentry.client.interceptors.error.use((error, response, request) => { + if (error instanceof SentryApiError) { + // Return your application's existing error type here, if needed. + return new Error(`API ${error.status}: ${request.method} ${request.url}`, { + cause: error, + }); + } + // No HTTP response for a rejected fetch. Preserve cancellation/custom errors. + return error; +}); +``` + +### Opt-in status narrowing + +Every operation with documented error responses has a generated `narrowError_` wrapper. It narrows the default HTTP `SentryApiError` to the operation's status-to-body type map, preserving its metadata. These wrappers remain standalone functions; pass `sentry.client` to reuse the client configured above: + +```ts +import { narrowError_getProject } from "@sentry/api"; + +const result = await narrowError_getProject({ + client: sentry.client, + path: { + organization_id_or_slug: "my-org", + project_id_or_slug: "my-project", + }, +}); + +if (!result.ok) { + if (!result.error.documented) { + // Unexpected HTTP status. + throw result.error; + } + + switch (result.error.status) { + case 403: + case 404: + throw result.error; + } +} +``` + +Checking `documented` first separates the operation's finite HTTP error union from unexpected statuses. Within the documented branch, checking `status` narrows `body` to that response's schema. This is a schema-based type assertion, **not runtime payload validation**. Error bodies remain `unknown` where the source OpenAPI response has no schema. Ordinary operations set `documented: false`; only these opt-in helpers know the operation's status map. + +These helpers reclassify base `SentryApiError` instances belonging to the operation's response, including ones returned by an interceptor. Other errors—including custom classes/subclasses, fetch failures, cancellation, and decoding/validation exceptions—reject unchanged. They are not used implicitly by operations or pagination. Consumers that own their error model should use ordinary operations instead. + +### Migrating the 0.x response contract + +- `responseStyle: "data"` is no longer accepted, including at initialization, `setConfig`, and per call. JavaScript callers receive a `TypeError` before a request is sent. Read `result.data` from the stable fields result instead. +- HTTP errors previously exposed a raw body; now read `error.body` after narrowing to `SentryApiError`. `error.rawBody` retains the original text. +- Successful results omit `error`; failed results include it even when the error value is falsy. Manual result fixtures must follow that shape. Use `"error" in result` rather than a truthiness check. +- Error interceptors receive normalized HTTP errors and chain their returned values. Pagination and `unwrapResult` no longer create another error or prepend a helper-specific message; their legacy `context` argument remains accepted. +- `narrowError_*` and `callWithTypedErrors` now narrow HTTP failures only. Other failures reject unchanged instead of becoming a synthetic transport `SentryApiError`. `SentryApiTransportError` remains a deprecated type alias, but is no longer part of `NarrowedResult`. + +## Pagination + +Sentry uses cursor-based pagination via `Link` headers. Choose one of three helpers on your configured client: + +| Task | Method | Result | +| --- | --- | --- | +| Display one page, with previous/next navigation | `sentry.fetchPage.(options, cursor?)` | `{ data, response, nextCursor?, prevCursor? }` | +| Collect up to an item limit | `sentry.paginateUpTo.(options, { limit, ... })` | `{ data, stopReason, nextCursor?, continuation? }` | +| Explicitly collect pages eagerly | `sentry.paginateAll.(options, { maxPages?, startCursor? }?)` | `{ data, stopReason, nextCursor? }` | + +`fetchPage` is available for operations whose schema declares a `cursor` query parameter. Collection helpers are available only when the operation's 200 response is an array. Compound response bodies remain intact. Ordinary operation methods such as `sentry.listOrganizations()` still make just one request. + +All helpers reuse the instance's configuration and interceptors and can be destructured. They manage `cursor` separately from `query`, retaining your path, filters, and `signal` while invoking the configured operation. The SDK does not own page history or a response cache: the consumer keeps cursors in its URL, query cache, or persistent storage. + +Pagination reads HTTP headers from the same fields contract. Helpers always reject on failure, preserving the exact error: `SentryApiError` for default HTTP failures, or the original custom error/cancellation. This is consistent whether `throwOnError` is enabled or disabled. + +Generated collection helpers pass the same signal to every page operation. If it aborts between pages, the next page is not dispatched and the collection rejects; no partial collection is returned. + +### Single page + +```ts +const options = { + path: { organization_id_or_slug: "my-org" }, + query: { query: "is:unresolved", limit: 25 }, +}; + +const page = await sentry.fetchPage.listOrganizationIssues(options); +console.log(page.data, page.response.headers.get("X-Hits")); + +if (page.nextCursor !== undefined) { + const nextPage = await sentry.fetchPage.listOrganizationIssues(options, page.nextCursor); + // Keep nextPage.prevCursor to navigate back when the user requests it. +} +``` + +Each page preserves its own raw `Response`, including status and headers. The transport has already read the body; use `page.data` for the parsed result. Code that constructs a `PaginatedResponse` manually, such as test fixtures, must now supply its `response` field. + +### Bounded collection + +```ts +const options = { path: { organization_id_or_slug: "my-org" } }; +const batch = await sentry.paginateUpTo.listOrganizationIssues(options, { + limit: 250, + maxPages: 5, +}); +// Requests 100 + 100 + 50 when the server returns full pages. +console.log(batch.data, batch.stopReason); + +if (batch.nextCursor !== undefined) { + const next = await sentry.paginateUpTo.listOrganizationIssues(options, { + limit: 250, + startCursor: batch.nextCursor, + }); +} +``` + +Automatic sizing is deliberately limited to operations whose forward cursors have been verified to support changing page size: + +| Operation | HTTP size parameter | Maximum per request | +| --- | --- | --- | +| `listOrganizationIssues`, `listProjectIssues` | `limit` | 100 | +| `listOrganizationIssueEvents` | `per_page` | 100; 10 with `full: true` | + +Each request uses the smallest of the remaining item budget, the caller's requested page size (if supplied), and the verified maximum. Other operations retain their query options unchanged. In particular, **projects do not use 100 + 100 + 50**: their backend `OffsetPaginator` includes the page size in its cursor, and resizing mid-traversal can skip rows. Declaring `per_page` in the schema is not enough to enable resizing. + +If a server ignores the size or an operation cannot be resized, the helper trims any excess rows and omits the unsafe server `nextCursor`. It reports `stopReason: "limit"`, even if that was the server's last page. The separate `continuation` retains a position before the pending rows. + +### Resume a partially consumed page + +```ts +const options = { + path: { organization_id_or_slug: "my-org" }, + query: { per_page: 100 }, +}; +const batch = await sentry.paginateUpTo.listOrganizationProjects(options, { limit: 250 }); +// Requests three pages of 100 and returns 250 rows. +// nextCursor is absent: the next server page would skip the remaining 50. +if (batch.continuation) { + const next = await sentry.paginateUpTo.listOrganizationProjects(options, { + limit: 25, + resume: batch.continuation, + }); + // Refetches the third page unchanged and returns its next 25 rows. +} +``` + +`paginateUpTo` returns a `continuation` whenever more rows remain, including on the last server page. Pass it back as `resume`; do not combine it with `startCursor`. At a page boundary it contains the next opaque server `cursor` and `offset: 0`. Within a page it contains the cursor used to fetch that page (absent for the first page), the consumed `offset`, and the original `requestBudget`. It can round-trip through JSON. Keep the same operation, filters, page-size options and fetcher configuration. The SDK does not store credentials, requests or history in it. + +For standalone `paginateUpTo(fetcher, options, context)`, the callback's second argument is a request budget. It normally equals the remaining item limit. On a partial replay it repeats the original budget so a custom fetcher reconstructs the same request, even when the new batch limit is smaller. After that page, the callback receives the new remaining budget. A callback must retain its own operation-specific sizing rules; the SDK never derives them from the cursor's contents. + +The standalone collector also accepts a callback returning a normalized `{ data: T[], nextCursor? }` page, including the result of `fetchPage`. Such a callback owns response/error handling and rejects on failure; `request` and `error` are reserved for SDK operation envelopes. Its explicit `nextCursor` is authoritative; the collector does not reinterpret an attached `Response`. SDK operation results retain their existing unwrapping and error behavior. The exported `BudgetedPageFetcher` type is unchanged; `CollectionPageFetcher` describes this additional composition path. + +```ts +import { paginateUpTo } from "@sentry/api"; + +const batch = await paginateUpTo( + cursor => sentry.fetchPage.listOrganizationProjects(options, cursor), + { limit: 250 }, + "listOrganizationProjects", +); +``` + +`nextCursor` keeps its existing meaning and can still be sent directly to the API. A `continuation` is SDK state, not a server cursor. Existing single-page, custom-bookmark and infinite-query consumers can continue using ordinary operations or `fetchPage`; neither API changes. + +### Consume an already fetched page + +For a consumer that owns fetching, `consumePage` shares the row-consumption logic without performing I/O or requiring a `Response`: + +```ts +import { consumePage } from "@sentry/api"; + +const page = await sentry.fetchPage.listOrganizationProjects(options, cursor); +const consumed = consumePage(page, { cursor, offset: 0 }, 25); +// Keep page.response and any consumer metadata separately. +// consumed.position identifies where to continue; absence means exhausted. +``` + +To resume a position, fetch its cursor with the same page options and pass that position to `consumePage`. A manual `PagePosition` describes row consumption; `paginateUpTo` additionally records the request budget in its `PaginationContinuation`. The caller owns serialization, custom filtering/bookmarks and navigation history. Neither helper snapshots data against concurrent server changes. + +### Eager collection and completion + +```ts +const projects = await sentry.paginateAll.listOrganizationProjects( + { path: { organization_id_or_slug: "my-org" } }, + { maxPages: 50 }, +); +console.log(projects.data, projects.stopReason); +// If stopReason is "maxPages", projects.nextCursor resumes traversal. +``` + +Both collection helpers return `PaginatedCollection`. Only `paginateUpTo` supplies the additional `continuation` described above: + +| `stopReason` | Meaning | `nextCursor` | +| --- | --- | --- | +| `"exhausted"` | All fetched rows were returned and the server advertises no next page | Absent | +| `"limit"` | The item budget stopped collection | Present only when no rows were discarded | +| `"maxPages"` | The request cap stopped collection while more pages exist | Present | + +Only `stopReason === "exhausted"` means complete. If the final server page exactly fills the item budget, exhaustion takes precedence. A missing cursor alone does not mean complete. + +`maxPages` defaults to 50 for both helpers. `limit` and `maxPages` must be positive safe integers. Resume forward from a returned `nextCursor`, keeping the same operation, filters, and page-size options; cursors stay opaque. SDK calls do not retain a history or snapshot the collection against concurrent server changes. Errors and cancellation reject the collection call, including after earlier pages succeeded. + +Collection results have no single `response`: their data may come from several HTTP requests. Frontend tables and infinite queries can keep using `fetchPage` with their own URL/cache state. CLI history, MCP output budgets, and TanStack adapters remain consumer integrations tracked in [#99](https://github.com/getsentry/sentry-api-schema/issues/99). + +### Migration from earlier 0.x helpers + +```ts +// Before: const projects = await sentry.paginateAll.listOrganizationProjects(options); +const { data: projects, stopReason, nextCursor } = + await sentry.paginateAll.listOrganizationProjects(options); +``` + +`paginateAll` now returns a collection object instead of an array, for bound, standalone, and generic helpers. `paginateUpTo` adds `stopReason` and preserves safe continuation at `maxPages`. The unsafe `keepCursorOnOvershoot` option has been removed; no cursor may skip discarded rows. Invalid item/request budgets now reject before fetching. + +### Standalone pagination + +All existing imports remain available and share the same implementation and result types: + +```ts +import { fetchPage_listOrganizationProjects } from "@sentry/api"; + +const page = await fetchPage_listOrganizationProjects({ + client: sentry.client, + path: { organization_id_or_slug: "my-org" }, +}); +console.log(page.response.headers.get("Link")); +``` + +The corresponding `paginateUpTo_` and `paginateAll_` imports remain supported. Existing helpers accept `per_page` in their query options even where the schema omits it; choose page-size parameters supported by your endpoint. + +### Generic pagination helpers + +The same low-level helpers used by the generated wrappers are also exported for advanced use cases: + +- `parseSentryLinkHeader(header)` — `{ nextCursor?, prevCursor? }` +- `unwrapResult(sdkResult, context)` — throw-on-error data unwrap +- `unwrapPaginatedResult(sdkResult, context)` — same but with cursors +- `fetchPage`, `paginateAll`, `paginateUpTo` — generic versions taking a fetcher thunk + +A generic `paginateUpTo` fetcher receives `(cursor, remaining)`. Existing cursor-only fetchers still work; use `remaining` only when your endpoint's cursor supports resizing. The generated wrappers apply the verified rules above for you. + +Generic helpers delegate cancellation to your fetcher. Pass its signal through to the operation or transport it invokes; the helper cannot cancel an arbitrary callback. + +## Shared client + +`@sentry/api/client` is the request layer the Sentry CLI and MCP servers share, built on the generated operations. It uses only web-standard APIs (`fetch`, `Request`, `Headers`, `URL`, `AbortSignal`, `crypto.subtle`), so it runs in Node, Cloudflare Workers, and browsers. Hosts inject what differs between them: + +- **Transport.** `createApiTransport` sets `Authorization` from a `CredentialProvider`, refreshes the credential once after a 401, times out each attempt, retries through the shared retry policy (429 for any method; 500, 502, 503, 504, timeouts, and network errors only for GET, HEAD, OPTIONS, or writes marked idempotent), follows redirects only to trusted origins, and serves and invalidates cached GETs. `CredentialProvider.current` receives the request URL and the organization and project its path targets (`parseApiPathScope`), so a host with several logins can pick the one that can reach that organization. The host passes credentials, a `TransportCache`, the request-scoped `fresh` hook, a logger, a span hook, and its error classes. +- **Request core.** `createApiClientCore` sends `/api/0` through the transport and throws the status's `ApiError` subclass for an error response. `sdkClient` gives a generated operation a client that sends through the same path. +- **Response cache.** The cache policy (`lookupResponse`, `storeResponse`, tiers, identity-scoped keys, invalidation) works on any `CacheStore` and `BodyCodec`. `createMemoryStore` is a byte-budgeted in-memory store. Compressing codecs need host APIs such as `node:zlib`, so hosts supply them. +- **Validation.** Endpoint wrappers validate responses with any [Standard Schema](https://standardschema.dev) validator the host passes in, such as the `@sentry/api/zod` or `@sentry/api/valibot` schemas. The client depends on neither. +- **Pagination.** `paginate` and `fetchAllPages` walk list endpoints on top of `paginateUpTo` and `consumePage`. Their cursors are Sentry cursors that also record rows already returned from a page, so a resumed walk neither repeats nor skips rows. + +`@sentry/api/client/endpoints` holds endpoint wrappers by domain. Each takes an `ApiTarget` (a core and an origin) and a response schema. + +Every API error the core throws (`ApiError`, `ApiNotFoundError`, `ApiRateLimitError`, and the rest) extends `SentryApiError`, so `instanceof SentryApiError` matches failures from both the generated operations and the core. + +In a browser, put the session fetch under the transport to keep cookie auth and CSRF injection: + +```ts +import { createBrowserFetch } from "@sentry/api/browser"; +import { createApiClientCore, createApiTransport } from "@sentry/api/client"; + +const transport = createApiTransport({ + fetch: createBrowserFetch(), + retry: { maxRetries: 2, baseDelayMs: 500, maxDelayMs: 5000, maxTotalDelayMs: 10000 }, +}); +const core = createApiClientCore({ transport, headers: () => ({}), logger: console }); +const projects = await core.requestJson("https://sentry.example.com", "/organizations/acme/projects/"); +``` + +## Spec files + +Each `api@` release on [GitHub](https://github.com/getsentry/sentry/releases) has the files that version was built from as assets: + +- `openapi-derefed.json`: the dereferenced OpenAPI spec. +- `api_ownership_stats_dont_modify.json`: API ownership stats per team. +- `rpc_method_schema.json`: the internal RPC method schema. + +For example, `https://github.com/getsentry/sentry/releases/download/api@1.55.0/openapi-derefed.json`. They are not in the npm package. + +## Development + +The package lives in [`getsentry/sentry`](https://github.com/getsentry/sentry/tree/master/static/packages/api), next to the API it describes, and builds from the spec that repository generates. Schema fixes go to the endpoints in `src/sentry`; build and tooling changes go here. From the repository root: + +```sh +make build-api-docs build-rpc-schema +pnpm --filter @sentry/api run build +pnpm --filter @sentry/api run typecheck +pnpm --filter @sentry/api run test:vitest +``` + +Tests import the generated `src/`, `dist/` and `spec/` output, so build before testing. The script is `test:vitest`, not `test`, because the repository's `test-ci` and `test-all` pass Jest options to every workspace's `test` script, and these tests need the spec build. + +## License + +FSL-1.1-Apache-2.0. See [LICENSE.md](LICENSE.md). diff --git a/static/packages/api/build.mjs b/static/packages/api/build.mjs new file mode 100644 index 000000000000..01156b7b5475 --- /dev/null +++ b/static/packages/api/build.mjs @@ -0,0 +1,126 @@ +import { build } from "esbuild"; +import { cpSync, appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { emitDeclarations } from "./lib/emit-declarations.mjs"; +import { generateClient } from "./lib/hey-api.mjs"; +import { normalizeSpec } from "./lib/normalize-spec.mjs"; +import { specFiles } from "./lib/spec-files.mjs"; + +// 0. Collect the spec and the files built with it under spec/. Each api@ +// release attaches them as GitHub release assets (package-pack.yml). This +// also fails fast when the repository has not built one of them yet. +mkdirSync("spec", { recursive: true }); +for (const [name, source] of Object.entries(specFiles)) cpSync(source, `spec/${name}`); +// `node build.mjs --spec-only` stops here, for CI jobs that only need spec/. +if (process.argv.includes("--spec-only")) process.exit(0); + +// 0.5 Normalize verbose English sentence operationIds to short REST-conventional +// identifiers (e.g. "List Your Organizations" → "listOrganizations"). +// Writes a temporary openapi-normalized.json that the generator reads instead +// of the source spec. The source spec is never modified. +// OperationIds that are already identifiers (no spaces) are left untouched — +// those were set intentionally via @extend_schema(operation_id=...). +const normalizedSpec = normalizeSpec("./spec/openapi-derefed.json", "./openapi-normalized.json"); + +// 1. Generate TypeScript client from OpenAPI spec, including optional +// Valibot and Zod schemas, with Hey API (lib/hey-api.mjs). +await generateClient("./openapi-normalized.json", "src"); + +// 2. Copy hand-written utilities into the generated src/ directory +cpSync("lib/sentry-pagination.ts", "src/sentry-pagination.ts"); +cpSync("lib/sentry-errors.ts", "src/sentry-errors.ts"); +cpSync("lib/browser-client.ts", "src/browser-client.ts"); +cpSync("lib/source-analysis.ts", "src/source-analysis.ts"); +cpSync("lib/api-client", "src/api-client", { recursive: true }); + +// 3. Patch the generated output and generate the rest from it, in this order, +// in this process. Each step is a module in scripts/ whose `emit` does the +// work; they share the parsed specs and lib/generated-source.mjs. +// The generators are post-processors, not Hey API plugins, because the +// plugin API is documented as in-development and unstable. +const sourceSpec = JSON.parse(readFileSync("./spec/openapi-derefed.json", "utf8")); +const steps = [ + // Keep the pinned Fetch generator's runtime and types aligned with our contract. + "generate-response-contract", + "generate-request-preparation", + "generate-base-url-resolver", + // Typed fetchPage / paginateAll / paginateUpTo wrappers for every operation + // that accepts a `cursor` query parameter. + "generate-pagination", + "generate-error-results", + "generate-query-enum-types", + "constrain-throw-options", + // Client-side query defaults (lib/query-defaults.mjs) go into the operations + // themselves, so bound methods and generated wrappers inherit them. + "apply-query-defaults", + "generate-client", + "generate-operations", + // Use Valibot's description action for the descriptions, which getDescription reads. + "describe-valibot-schemas", + // Annotate schema constructors as pure and defer the default client so bundlers + // can drop unused exports and importing the package does no eager work. + "make-tree-shakeable", +]; +for (const name of steps) { + const { emit } = await import(`./scripts/${name}.mjs`); + emit({ normalizedSpec, sourceSpec }); +} + +// 4. Append re-exports to the generated index.ts so the pagination +// utilities and the per-operation wrappers are part of the public API. +appendFileSync( + "src/index.ts", + [ + "", + "export { callWithTypedErrors, narrowError, SentryApiError } from './sentry-errors.ts';", + "export type { DocumentedSentryApiError, NarrowedResult, SentryApiResultError, SentryApiTransportError, SdkResult, UndocumentedSentryApiError } from './sentry-errors.ts';", + "export { parseSentryLinkHeader, unwrapResult, unwrapPaginatedResult, fetchPage, paginateAll, paginateUpTo, consumePage } from './sentry-pagination.ts';", + "export type { UnwrappedResult, PaginatedResponse, PaginatedCollection, PaginateAllOptions, PaginateUpToOptions, PageFetcher, BudgetedPageFetcher, CollectionPage, CollectionPageFetcher, PagePosition, PaginationContinuation } from './sentry-pagination.ts';", + "export * from './error-results.gen.ts';", + "export type * from './query-enums.gen.ts';", + "export * from './pagination.gen.ts';", + "export { createSentryClient } from './sentry-client.gen.ts';", + "export type { SentryClient } from './sentry-client.gen.ts';", + "export type { BaseUrlRequest, BaseUrlResolver, Config } from './client/index.ts';", + "", + ].join("\n"), +); + +// 5. Create standalone entry points. +// valibot/zod: validator-specific schemas with optional peer dependencies. +// browser: CSRF + cookie auth helpers for browser/frontend use. +// client, client/endpoints: the shared request layer, whose index files +// are copied from lib/api-client/ in step 2. +writeFileSync("src/valibot.ts", 'export * from "./valibot.gen.ts";\n'); +writeFileSync("src/zod.ts", 'export * from "./zod.gen.ts";\n'); +writeFileSync("src/browser.ts", 'export * from "./browser-client.ts";\n'); + +// 6. Bundle into JS files and emit type declarations, at the same time. +// The main entry stays self-contained (zero runtime deps). +// Validator entries externalise their peers; the root stays dependency-free. +// Runtime entries target the browser platform, so a Node built-in import +// fails the build. source-analysis is a Node build tool and keeps node:path. +// Declarations come from tsconfig.json; the validator schemas, which take +// most of the checking time, each get their own worker. +const bundle = (entry, { external = [], platform = "browser" } = {}) => + build({ + entryPoints: [`src/${entry}.ts`], + outdir: "dist", + outbase: "src", + bundle: true, + format: "esm", + platform, + external, + logLevel: "info", + }); +await Promise.all([ + bundle("index"), + bundle("valibot", { external: ["valibot"] }), + bundle("zod", { external: ["zod"] }), + bundle("browser"), + // Keep the package entries external so consumers share the root error classes. + bundle("api-client/index", { external: ["@sentry/api", "http-cache-semantics", "p-limit"] }), + bundle("api-client/endpoints/index", { external: ["@sentry/api", "@sentry/api/client"] }), + bundle("operations"), + bundle("source-analysis", { external: ["typescript"], platform: "node" }), + emitDeclarations([["valibot.ts", "valibot.gen.ts"], ["zod.ts", "zod.gen.ts"]]), +]); diff --git a/static/packages/api/lib/api-client/api-path-scope.ts b/static/packages/api/lib/api-client/api-path-scope.ts new file mode 100644 index 000000000000..20a35611acde --- /dev/null +++ b/static/packages/api/lib/api-client/api-path-scope.ts @@ -0,0 +1,72 @@ +/** + * Reading the organization and project an API path targets. No imports, so + * the generated client can use it without pulling in `@sentry/api/client`. + */ + +/** The organization, and project when present, an API path targets. */ +export type ApiPathScope = { organization: string; project?: string }; + +/** + * Org and project slugs or numeric ids. Rejects schema placeholders such as + * `{organization_id_or_slug}`, encoded values, and dot segments. + */ +const API_PATH_IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +function isApiPathIdentifier(segment: string | undefined): segment is string { + return segment !== undefined && API_PATH_IDENTIFIER.test(segment); +} + +/** Path segments after `/api/0/`, or undefined for a URL outside the API. */ +function apiPathSegments(urlOrPath: string): string[] | undefined { + const isUrl = URL.canParse(urlOrPath); + const path = isUrl + ? new URL(urlOrPath).pathname + : (urlOrPath.split(/[?#]/, 1)[0] ?? ""); + const segments = path.split("/").filter((segment) => segment.length > 0); + for (let index = 0; index < segments.length - 1; index += 1) { + if (segments[index] === "api" && segments[index + 1] === "0") { + return segments.slice(index + 2); + } + } + return isUrl ? undefined : segments; +} + +/** + * The organization, and project when present, a Sentry API request targets, + * read from its path: `/organizations/{org}/`, `/projects/{org}/{project}/`, + * `/teams/{org}/`, and `/organizations/{org}/projects/{project}/`. + * + * Accepts a full URL, whose path must contain `/api/0/` (a self-hosted + * install may serve it under a prefix), or a path relative to `/api/0/` with + * or without that prefix. Undefined for unscoped paths, list endpoints such + * as `/organizations/`, and schema placeholders. + * + * Hosts with several credentials or regions use it to pick the one that + * serves the organization: `createApiTransport` passes it to + * `CredentialProvider.current`, and the generated client to + * `Config.resolveBaseUrl`. + */ +export function parseApiPathScope(urlOrPath: string): ApiPathScope | undefined { + const segments = apiPathSegments(urlOrPath); + if (!segments) { + return; + } + const [resource, organization, next, project] = segments; + if (!isApiPathIdentifier(organization)) { + return; + } + if (resource === "organizations") { + return next === "projects" && isApiPathIdentifier(project) + ? { organization, project } + : { organization }; + } + if (resource === "projects") { + return isApiPathIdentifier(next) + ? { organization, project: next } + : { organization }; + } + if (resource === "teams") { + return { organization }; + } + return; +} diff --git a/static/packages/api/lib/api-client/api-path.ts b/static/packages/api/lib/api-client/api-path.ts new file mode 100644 index 000000000000..d13ea57027c9 --- /dev/null +++ b/static/packages/api/lib/api-client/api-path.ts @@ -0,0 +1,73 @@ +import { UserInputError } from "./errors.js"; + +export { type ApiPathScope, parseApiPathScope } from "./api-path-scope.js"; + +/** + * Encode one Sentry API path identifier without letting it become a URL path. + * + * URL parsing collapses bare `.` and `..` segments even after + * `encodeURIComponent`, so callers must reject those identifiers before + * constructing a request. The caller owns its product-specific error type. + * A pre-encoded spelling such as `%2e%2e` is safe: encoding its percent signs + * prevents the URL parser from treating it as a dot segment. + */ +export function encodeApiPathSegment(value: string | number): string | null { + const text = String(value); + return text === "." || text === ".." ? null : encodeURIComponent(text); +} + +/** + * Assemble an API URL from a product-validated base and a relative endpoint. + * This does not decide which host is trusted or which region an org uses. + */ +export function buildSentryApiUrl(baseUrl: string, endpoint: string): string { + const path = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; + let end = baseUrl.length; + while (end > 0 && baseUrl.charAt(end - 1) === "/") { + end -= 1; + } + return `${baseUrl.slice(0, end)}/api/0/${path}`; +} + +/** + * Builds an API request path, confining each interpolated value to one path segment. + * + * `fetch` resolves dot segments during URL parsing, so a raw value in a path can + * rewrite it above the org and project the server injected. Encoding stops separators + * but not `.` or `..`, which survive encoding and still collapse (the URL spec treats + * `%2e%2e` as a dot segment too), so those are rejected instead. + * + * Query strings go outside the template, since `URLSearchParams` has already encoded + * them and their separators must stay literal: + * + * ```typescript + * const path = apiPath`/organizations/${organizationSlug}/issues/`; + * await this.requestJSON(`${path}?${query}`); + * ``` + */ +export function apiPath( + strings: TemplateStringsArray, + ...values: (string | number)[] +): string { + return strings.reduce((acc, literal, index) => { + if (index >= values.length) { + return acc + literal; + } + + const value = String(values[index]); + const encoded = encodeApiPathSegment(value); + if (encoded === null) { + throw new UserInputError( + `Invalid identifier "${value}": relative path segments are not allowed.`, + ); + } + + return acc + literal + encoded; + }, ""); +} + +/** `path` with `query` appended, or `path` alone when `query` is empty. */ +export function withQuery(path: string, query: URLSearchParams): string { + const queryString = query.toString(); + return queryString ? `${path}?${queryString}` : path; +} diff --git a/static/packages/api/lib/api-client/auth-token.ts b/static/packages/api/lib/api-client/auth-token.ts new file mode 100644 index 000000000000..eafd66ca2cdf --- /dev/null +++ b/static/packages/api/lib/api-client/auth-token.ts @@ -0,0 +1,31 @@ +/** A valid bearer credential contains only visible ASCII bytes. */ +const INVALID_TOKEN_CHARACTER_PATTERN = /[^\x21-\x7e]/; +// oxlint-disable-next-line no-control-regex -- copied ASCII controls are removable edge padding. +const TOKEN_PADDING_PATTERN = /[\s\x00-\x1f\x7f]/; + +/** Strip padding at the edges without changing bytes within the token. */ +export function trimAuthToken(token: string): string { + let start = 0; + let end = token.length; + while (start < end && TOKEN_PADDING_PATTERN.test(token.charAt(start))) { + start += 1; + } + while (end > start && TOKEN_PADDING_PATTERN.test(token.charAt(end - 1))) { + end -= 1; + } + return token.slice(start, end); +} + +/** Return null for invalid input so each caller can preserve its own error type. */ +export function normalizeAuthToken(token: string): string | null { + const normalized = trimAuthToken(token); + return normalized && !INVALID_TOKEN_CHARACTER_PATTERN.test(normalized) + ? normalized + : null; +} + +/** Format a validated upstream credential; callers own their auth errors. */ +export function sentryBearerHeader(token: string): string | null { + const normalized = normalizeAuthToken(token); + return normalized === null ? null : `Bearer ${normalized}`; +} diff --git a/static/packages/api/lib/api-client/core.ts b/static/packages/api/lib/api-client/core.ts new file mode 100644 index 000000000000..c03821c4083b --- /dev/null +++ b/static/packages/api/lib/api-client/core.ts @@ -0,0 +1,347 @@ +/** + * The request core that endpoint wrappers build on. It sends a request + * through the host's transport, turns network failures and error responses + * into the shared errors, and reads JSON bodies. `@sentry/api` operations + * get a client that sends through the same path, and {@link listPage} walks + * their pages. + * + * The host supplies the transport (retries, caching), the per-request + * headers (credentials, user agent, attribution), and logging. + */ + +import { + createSentryClient, + parseSentryLinkHeader, + type SentryClient, +} from "@sentry/api"; +import { buildSentryApiUrl } from "./api-path.js"; +import { + type ApiError, + ApiValidationError, + ConfigurationError, + createApiError, +} from "./errors.js"; +import { API_MAX_PER_PAGE } from "./pagination.js"; +import { + type ErrorResponseBody, + readErrorResponse, + readJsonResponse, +} from "./response.js"; +import { type ResponseSchema, validateResponse } from "./validate.js"; + +/** What the core needs from the host's transport. */ +export type ApiRequestTransport = { + request(url: string, init: RequestInit): Promise; +}; + +export type ApiClientLogger = { + warn(message: string, extra: Record): void; +}; + +export type ApiClientCoreOptions = { + transport: ApiRequestTransport; + /** + * Headers for every request after `Content-Type`, such as Authorization + * and User-Agent. Called once per request; may throw for a bad credential. + * They replace any headers the caller passes. + */ + headers: () => Record; + logger: ApiClientLogger; +}; + +export type ApiRequestOptions = { + /** Error statuses returned as the response instead of thrown. */ + allowStatuses?: number[]; +}; + +export type ApiClientCore = { + /** + * Send a request to `origin` + `/api/0` + `path`. + * + * @throws {ApiError} For an error status not in `allowStatuses` + * @throws {ConfigurationError} When the host is unreachable or misconfigured + * @throws {Error} For other network failures, with a readable message + */ + request( + origin: string, + path: string, + init?: RequestInit, + options?: ApiRequestOptions, + ): Promise; + /** {@link ApiClientCore.request}, then the parsed JSON body. */ + requestJson( + origin: string, + path: string, + init?: RequestInit, + ): Promise; + /** + * A `@sentry/api` client for one GET to `expectedPath` on `origin`. The + * operation builds the path and query; the request goes through + * {@link ApiClientCore.request}. Any other target or method throws. + */ + sdkClient(origin: string, expectedPath: string): SentryClient["client"]; +}; + +/** Where an endpoint wrapper sends: the core, and the region or host origin. */ +export type ApiTarget = { core: ApiClientCore; origin: string }; + +/** Most pages {@link listPage} fetches for one call. */ +export const MAX_LIST_PAGES = 50; + +/** Rows from {@link listPage}, and the cursor for the next call. */ +export type ListPage = { items: T[]; nextCursor: string | null }; + +/** The cursor of the next page in a list response's `Link` header, if any. */ +export function nextPageCursor(response: Response): string | null { + return parseSentryLinkHeader(response.headers.get("link")).nextCursor ?? null; +} + +/** Readable messages for common network error codes. */ +const NETWORK_ERROR_MESSAGES: Record = { + EAI_AGAIN: "DNS temporarily unavailable. Check your internet connection.", + ENOTFOUND: "Hostname not found. Verify the URL is correct.", + ECONNREFUSED: "Connection refused. Ensure the service is accessible.", + ETIMEDOUT: "Connection timed out. Check network connectivity.", + ECONNRESET: "Connection reset. Try again in a moment.", +}; + +const API_PREFIX = "/api/0"; + +/** Network failures that mean the host is wrong or unreachable. */ +const CONFIGURATION_ERROR_CODES = new Set([ + "ENOTFOUND", + "EAI_AGAIN", + "ECONNREFUSED", + "ETIMEDOUT", +]); + +/** A failed fetch as a readable error naming the URL. */ +function networkError(url: string, error: unknown): Error { + let rootCause = error; + while (rootCause instanceof Error && rootCause.cause) { + rootCause = rootCause.cause; + } + const errorMessage = + rootCause instanceof Error ? rootCause.message : String(rootCause); + const errorCode = Object.keys(NETWORK_ERROR_MESSAGES).find((code) => + errorMessage.includes(code), + ); + const friendlyMessage = `Unable to connect to ${url} - ${ + errorCode ? NETWORK_ERROR_MESSAGES[errorCode] : errorMessage + }`; + if ( + (errorCode && CONFIGURATION_ERROR_CODES.has(errorCode)) || + errorMessage.includes("Connect Timeout Error") + ) { + return new ConfigurationError(friendlyMessage, { cause: error }); + } + return new Error(friendlyMessage, { cause: error }); +} + +/** A failed response's body as the matching `ApiError` subclass. */ +function errorFromResponse( + logger: ApiClientLogger, + response: Response, + body: ErrorResponseBody, + logExtra: Record, +): ApiError { + const { retryAfterMs } = body; + const errorText = body.text ?? ""; + const bodyPreview = + errorText.length > 256 ? `${errorText.slice(0, 253)}…` : errorText; + + if (body.parseError !== undefined) { + if (body.isHtml) { + logger.warn("Received HTML error page instead of JSON", { + ...logExtra, + parseErrorMessage: body.parseError, + }); + // An HTML page instead of JSON usually means a wrong URL or a proxy. + return createApiError( + `Server error: Received HTML instead of JSON (${response.status} ${response.statusText}). This may indicate an invalid URL or server issue.`, + response.status, + errorText, + undefined, + retryAfterMs, + ); + } + logger.warn("Failed to parse JSON error response", { + ...logExtra, + bodyPreview, + parseErrorMessage: body.parseError, + }); + } else if (body.json) { + if (body.detail !== undefined) { + return createApiError( + body.detail, + response.status, + body.detail, + body.json, + retryAfterMs, + ); + } + logger.warn("Failed to parse validated API error response", { + ...logExtra, + bodyPreview, + validationErrorMessage: "Error response has no string detail", + }); + } + + return createApiError( + `API request failed: ${response.statusText}\n${errorText}`, + response.status, + errorText, + undefined, + retryAfterMs, + ); +} + +export function createApiClientCore({ + transport, + headers, + logger, +}: ApiClientCoreOptions): ApiClientCore { + async function request( + origin: string, + path: string, + init: RequestInit = {}, + { allowStatuses }: ApiRequestOptions = {}, + ): Promise { + const url = buildSentryApiUrl(origin, path); + const requestHeaders = { + "Content-Type": "application/json", + ...headers(), + }; + + if (typeof globalThis.fetch === "undefined") { + throw new ConfigurationError( + "fetch is not available. Please use Node.js >= 18 or ensure fetch is available in your environment.", + ); + } + + let response: Response; + try { + response = await transport.request(url, { + ...init, + headers: requestHeaders, + }); + } catch (error) { + // An abort, including one during a retry wait, keeps the caller's reason. + if (init.signal?.aborted) { + throw error; + } + throw networkError(url, error); + } + + if (response.ok || allowStatuses?.includes(response.status)) { + return response; + } + throw errorFromResponse( + logger, + response, + await readErrorResponse(response), + { + status: response.status, + statusText: response.statusText, + host: new URL(origin).host, + path, + }, + ); + } + + return { + request, + + async requestJson(origin, path, init) { + return await readJsonResponse(await request(origin, path, init)); + }, + + sdkClient(origin, expectedPath) { + const base = new URL(origin).origin; + const sdkFetch = async (sdkRequest: RequestInfo | URL) => { + if (!(sdkRequest instanceof Request)) { + throw new ConfigurationError("Unexpected SDK request input"); + } + const url = new URL(sdkRequest.url); + if ( + url.origin !== base || + url.pathname !== API_PREFIX.concat(expectedPath) || + sdkRequest.method !== "GET" + ) { + throw new ConfigurationError( + "Unexpected SDK request target or method", + ); + } + const response = await request( + origin, + `${url.pathname.slice(API_PREFIX.length)}${url.search}`, + { method: "GET", signal: sdkRequest.signal }, + ); + const contentType = response.headers.get("content-type"); + if (!contentType?.includes("application/json")) { + await readJsonResponse(response); + } + return response; + }; + // Bun's `typeof fetch` also declares `preconnect`, which the SDK never calls. + return createSentryClient({ baseUrl: base, fetch: sdkFetch as typeof fetch }) + .client; + }, + }; +} + +/** + * Fetch up to `limit` rows from a list endpoint, starting at `cursor`. + * + * Each page asks for exactly the rows still missing, up to + * {@link API_MAX_PER_PAGE}, and is validated with `schema`. The walk stops + * once it has `limit` rows or the server has no next page, and returns the + * server's cursor for the page after the last one fetched. + * + * @throws {ApiValidationError} When `limit` is not between 1 and + * `API_MAX_PER_PAGE * MAX_LIST_PAGES` + * @throws {ApiResponseValidationError} When a page does not match `schema` + * @throws {Error} When a page has more rows than requested, a cursor + * repeats, or the walk passes {@link MAX_LIST_PAGES} + */ +export async function listPage( + fetchPage: ( + perPage: number, + cursor: string | undefined, + ) => Promise<{ data: unknown; response: Response }>, + schema: ResponseSchema, + limit: number, + initialCursor?: string | null, +): Promise> { + if ( + !Number.isSafeInteger(limit) || + limit < 1 || + limit > API_MAX_PER_PAGE * MAX_LIST_PAGES + ) { + throw new ApiValidationError( + `limit must be an integer between 1 and ${API_MAX_PER_PAGE * MAX_LIST_PAGES}`, + ); + } + const items: T[] = []; + const seenCursors = new Set(); + let cursor = initialCursor ?? undefined; + for (let page = 0; page < MAX_LIST_PAGES; page += 1) { + const perPage = Math.min(API_MAX_PER_PAGE, limit - items.length); + const { data, response } = await fetchPage(perPage, cursor); + const rows = validateResponse(schema, data); + if (rows.length > perPage) { + throw new Error("API returned more items than requested"); + } + items.push(...rows); + const nextCursor = nextPageCursor(response); + if (items.length >= limit || !nextCursor) { + return { items, nextCursor }; + } + if (seenCursors.has(nextCursor) || nextCursor === cursor) { + throw new Error("API returned a repeated pagination cursor"); + } + seenCursors.add(nextCursor); + cursor = nextCursor; + } + throw new Error("API pagination exceeded its page limit"); +} diff --git a/static/packages/api/lib/api-client/endpoints/client-keys.ts b/static/packages/api/lib/api-client/endpoints/client-keys.ts new file mode 100644 index 000000000000..33cc615de484 --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/client-keys.ts @@ -0,0 +1,106 @@ +/** + * Client key (DSN) endpoints for a project. Each wrapper validates the + * response with the schema the host passes, so the host's schema decides the + * result type. + */ + +import { + apiPath, + type ApiTarget, + type ResponseSchema, + validateResponse, +} from "@sentry/api/client"; + +export type ClientKeyProjectParams = { + organizationSlug: string; + projectSlug: string; +}; + +/** Every client key of a project. */ +export async function listClientKeys( + { core, origin }: ApiTarget, + { organizationSlug, projectSlug }: ClientKeyProjectParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/keys/`, + ); + return validateResponse(schema, body); +} + +/** Create a client key, optionally named. */ +export async function createClientKey( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + name, + }: ClientKeyProjectParams & { name?: string }, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/keys/`, + { method: "POST", body: JSON.stringify({ name }) }, + ); + return validateResponse(schema, body); +} + +export type ClientKeyUpdate = { + name?: string; + isActive?: boolean; + /** Caps accepted errors per window; `null` removes the limit. */ + rateLimit?: { window: number; count: number } | null; + browserSdkVersion?: string; + dynamicSdkLoaderOptions?: { + hasReplay?: boolean; + hasPerformance?: boolean; + hasDebug?: boolean; + hasFeedback?: boolean; + hasLogsAndMetrics?: boolean; + }; +}; + +export type UpdateClientKeyParams = ClientKeyProjectParams & { + keyId: string | number; +} & ClientKeyUpdate; + +/** Update a client key. Only the fields that are set are sent. */ +export async function updateClientKey( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + keyId, + name, + isActive, + rateLimit, + browserSdkVersion, + dynamicSdkLoaderOptions, + }: UpdateClientKeyParams, + schema: ResponseSchema, +): Promise { + const data: ClientKeyUpdate = {}; + if (name !== undefined) { + data.name = name; + } + if (isActive !== undefined) { + data.isActive = isActive; + } + if (rateLimit !== undefined) { + data.rateLimit = rateLimit; + } + if (browserSdkVersion !== undefined) { + data.browserSdkVersion = browserSdkVersion; + } + if (dynamicSdkLoaderOptions !== undefined) { + data.dynamicSdkLoaderOptions = dynamicSdkLoaderOptions; + } + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/keys/${keyId}/`, + { method: "PUT", body: JSON.stringify(data) }, + ); + return validateResponse(schema, body); +} diff --git a/static/packages/api/lib/api-client/endpoints/dashboards.ts b/static/packages/api/lib/api-client/endpoints/dashboards.ts new file mode 100644 index 000000000000..16a1e8358cd1 --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/dashboards.ts @@ -0,0 +1,70 @@ +/** + * Dashboard endpoints. Each wrapper validates the response with the schema + * the host passes, so the host's schema decides the result type. + */ + +import { + apiPath, + type ApiTarget, + type ListPage, + nextPageCursor, + readJsonResponse, + type ResponseSchema, + validateResponse, +} from "@sentry/api/client"; + +export type ListDashboardsParams = { + organizationSlug: string; + /** Filters by title. */ + query?: string | null; + sortBy?: "title" | "-title" | "dateCreated" | "-dateCreated"; + /** Page size. Default: 10. */ + limit?: number; + /** Resumes from a previous call's `nextCursor`. */ + cursor?: string | null; +}; + +/** One page of the organization's dashboards. */ +export async function listDashboards( + { core, origin }: ApiTarget, + { organizationSlug, query, sortBy, limit = 10, cursor }: ListDashboardsParams, + schema: ResponseSchema, +): Promise> { + const params = new URLSearchParams(); + params.set("per_page", String(limit)); + if (query) { + params.set("query", query); + } + if (sortBy) { + params.set("sort", sortBy); + } + if (cursor) { + params.set("cursor", cursor); + } + const response = await core.request( + origin, + `${apiPath`/organizations/${organizationSlug}/dashboards/`}?${params.toString()}`, + ); + return { + items: validateResponse(schema, await readJsonResponse(response)), + nextCursor: nextPageCursor(response), + }; +} + +export type GetDashboardParams = { + organizationSlug: string; + dashboardId: string; +}; + +/** One dashboard with its widgets. */ +export async function getDashboard( + { core, origin }: ApiTarget, + { organizationSlug, dashboardId }: GetDashboardParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/dashboards/${dashboardId}/`, + ); + return validateResponse(schema, body); +} diff --git a/static/packages/api/lib/api-client/endpoints/index.ts b/static/packages/api/lib/api-client/endpoints/index.ts new file mode 100644 index 000000000000..b0769320acd5 --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/index.ts @@ -0,0 +1,19 @@ +/** + * `@sentry/api/client/endpoints`: endpoint wrappers by domain, on the + * generated operations where they exist and the request core otherwise. Each + * takes an `ApiTarget` and a host-supplied response schema. + */ + +export * from "./client-keys.js"; +export * from "./dashboards.js"; +export * from "./monitors.js"; +export * from "./organizations.js"; +export * from "./profiling.js"; +export * from "./projects.js"; +export * from "./releases.js"; +export * from "./replays.js"; +export * from "./repositories.js"; +export * from "./snapshots.js"; +export * from "./teams.js"; +export * from "./traces.js"; +export * from "./uptime-monitors.js"; diff --git a/static/packages/api/lib/api-client/endpoints/monitors.ts b/static/packages/api/lib/api-client/endpoints/monitors.ts new file mode 100644 index 000000000000..f4731d349a9f --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/monitors.ts @@ -0,0 +1,176 @@ +/** + * Cron monitor endpoints. With a project, reads go to the project's + * endpoints, otherwise to the organization's. Each wrapper validates the + * response with the schema the host passes, so the host's schema decides the + * result type. + */ + +import { + apiPath, + type ApiTarget, + applyStatsMixinTimeParams, + applyTimeParams, + defaultStatsPeriod, + parseStatsPeriod, + type ResponseSchema, + validateResponse, + withQuery, +} from "@sentry/api/client"; + +export type ListMonitorsParams = { + organizationSlug: string; + projectSlug?: string; + environment?: string; + owner?: string; + query?: string; + /** Page size. */ + limit?: number; +}; + +/** The first page of the organization's cron monitors. */ +export async function listMonitors( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + environment, + owner, + query, + limit, + }: ListMonitorsParams, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (projectSlug) { + params.append("projectSlug", projectSlug); + } + if (environment) { + params.append("environment", environment); + } + if (owner) { + params.append("owner", owner); + } + if (query) { + params.set("query", query); + } + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + const body = await core.requestJson( + origin, + withQuery(apiPath`/organizations/${organizationSlug}/monitors/`, params), + ); + return validateResponse(schema, body); +} + +export type MonitorParams = { + organizationSlug: string; + projectSlug?: string; + monitorSlug: string; + environment?: string; +}; + +function monitorPath( + { organizationSlug, projectSlug, monitorSlug }: MonitorParams, + suffix = "", +): string { + const path = projectSlug + ? apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/` + : apiPath`/organizations/${organizationSlug}/monitors/${monitorSlug}/`; + return `${path}${suffix}`; +} + +function environmentQuery(environment: string | undefined): URLSearchParams { + const params = new URLSearchParams(); + if (environment) { + params.append("environment", environment); + } + return params; +} + +/** One cron monitor. */ +export async function getMonitor( + { core, origin }: ApiTarget, + params: MonitorParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + withQuery(monitorPath(params), environmentQuery(params.environment)), + ); + return validateResponse(schema, body); +} + +/** + * Schedule deletion of one environment of a cron monitor. Sentry answers 202 + * with no body; a 404 (already gone) is not an error. + */ +export async function deleteMonitorEnvironment( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + monitorSlug, + environment, + }: MonitorParams & { projectSlug: string; environment: string }, +): Promise { + await core.request( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/monitors/${monitorSlug}/environments/${environment}/`, + { method: "DELETE" }, + { allowStatuses: [404] }, + ); +} + +export type MonitorTimeRangeParams = MonitorParams & { + /** Relative range such as `24h`. Default: `24h` without `start` or `end`. */ + statsPeriod?: string; + start?: string; + end?: string; +}; + +/** The first page of a cron monitor's check-ins in a time range. */ +export async function listMonitorCheckIns( + { core, origin }: ApiTarget, + params: MonitorTimeRangeParams & { limit?: number }, + schema: ResponseSchema, +): Promise { + const query = environmentQuery(params.environment); + if (params.limit !== undefined) { + query.set("per_page", String(params.limit)); + } + const statsPeriod = defaultStatsPeriod(params); + if (statsPeriod) { + parseStatsPeriod(statsPeriod); + } + applyTimeParams(query, statsPeriod, params.start, params.end); + const body = await core.requestJson( + origin, + withQuery(monitorPath(params, "checkins/"), query), + ); + return validateResponse(schema, body); +} + +/** A cron monitor's check-in counts over a time range. */ +export async function getMonitorStats( + { core, origin }: ApiTarget, + params: MonitorTimeRangeParams & { + /** Bucket size in seconds. */ + rollup?: number; + }, + schema: ResponseSchema, +): Promise { + const query = environmentQuery(params.environment); + applyStatsMixinTimeParams( + query, + defaultStatsPeriod(params), + params.start, + params.end, + params.rollup, + ); + const body = await core.requestJson( + origin, + withQuery(monitorPath(params, "stats/"), query), + ); + return validateResponse(schema, body); +} diff --git a/static/packages/api/lib/api-client/endpoints/organizations.ts b/static/packages/api/lib/api-client/endpoints/organizations.ts new file mode 100644 index 000000000000..82ea1102b257 --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/organizations.ts @@ -0,0 +1,102 @@ +/** + * Organization and current-user endpoints. Each wrapper validates the + * response with the schema the host passes, so the host's schema decides the + * result type. Hosts pick the origin: user identity and the organization + * list live on the control host, an organization on its region. + */ + +import { listOrganizations as listOrganizationsOperation } from "@sentry/api"; +import { + apiPath, + type ApiTarget, + type ListPage, + listPage, + type ResponseSchema, + validateResponse, + withQuery, +} from "@sentry/api/client"; + +/** The user the request's credential belongs to (`GET /auth/`). */ +export async function getAuthenticatedUser( + { core, origin }: ApiTarget, + schema: ResponseSchema, +): Promise { + return validateResponse(schema, await core.requestJson(origin, "/auth/")); +} + +export type ListOrganizationsParams = { + /** Filters by name or slug. */ + query?: string; + /** Most organizations to return. Default: 25. */ + limit?: number; + /** Resumes from a previous call's `nextCursor`. */ + cursor?: string | null; +}; + +/** A page of the organizations the credential can access. */ +export async function listOrganizations( + { core, origin }: ApiTarget, + params: ListOrganizationsParams, + schema: ResponseSchema, +): Promise> { + const client = core.sdkClient(origin, "/organizations/"); + return await listPage( + (perPage, cursor) => + listOrganizationsOperation({ + client, + query: { per_page: perPage, query: params.query, cursor }, + throwOnError: true, + }), + schema, + params.limit ?? 25, + params.cursor, + ); +} + +export type GetOrganizationParams = { + organizationSlug: string; + /** Include `features`, which Sentry omits otherwise. */ + includeFeatureFlags?: boolean; + /** Set false to leave out projects and teams; Sentry defaults to true. */ + detailed?: boolean; +}; + +/** One organization by slug. */ +export async function getOrganization( + { core, origin }: ApiTarget, + params: GetOrganizationParams, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + if (params.includeFeatureFlags) { + query.set("include_feature_flags", "1"); + } + if (params.detailed === false) { + query.set("detailed", "0"); + } + const body = await core.requestJson( + origin, + withQuery(apiPath`/organizations/${params.organizationSlug}/`, query), + ); + return validateResponse(schema, body); +} + +export type UpdateAgenticOnboardingStatusParams = { + organizationSlug: string; + /** The status report, sent as the JSON body. */ + update: object; +}; + +/** Report progress of an agent-driven onboarding run. */ +export async function updateAgenticOnboardingStatus( + { core, origin }: ApiTarget, + { organizationSlug, update }: UpdateAgenticOnboardingStatusParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/onboarding/agent/status/`, + { method: "POST", body: JSON.stringify(update) }, + ); + return validateResponse(schema, body); +} diff --git a/static/packages/api/lib/api-client/endpoints/profiling.ts b/static/packages/api/lib/api-client/endpoints/profiling.ts new file mode 100644 index 000000000000..94ea5905faff --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/profiling.ts @@ -0,0 +1,106 @@ +/** + * Profiling endpoints. Each wrapper validates the response with the schema + * the host passes, so the host's schema decides the result type. + */ + +import { + apiPath, + type ApiTarget, + type ResponseSchema, + validateResponse, +} from "@sentry/api/client"; + +export type GetFlamegraphParams = { + organizationSlug: string; + projectId: string | number; + /** Matched exactly; quotes and backslashes are escaped. */ + transactionName: string; + /** Default: `7d`. */ + statsPeriod?: string; +}; + +/** The aggregated flamegraph of a transaction's profiles. */ +export async function getFlamegraph( + { core, origin }: ApiTarget, + { + organizationSlug, + projectId, + transactionName, + statsPeriod = "7d", + }: GetFlamegraphParams, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + query.set("project", projectId.toString()); + // Escape backslashes first, then quotes. + const escapedTransaction = transactionName + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + query.set( + "query", + `event.type:transaction transaction:"${escapedTransaction}"`, + ); + query.set("statsPeriod", statsPeriod); + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/profiling/flamegraph/` + + `?${query.toString()}`, + ); + return validateResponse(schema, body); +} + +/** One transaction profile. */ +export async function getTransactionProfile( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlugOrId, + profileId, + }: { + organizationSlug: string; + projectSlugOrId: string | number; + profileId: string; + }, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlugOrId}/profiling/profiles/${profileId}/`, + ); + return validateResponse(schema, body); +} + +export type GetProfileChunksParams = { + organizationSlug: string; + profilerId: string; + projectId: string | number; + /** ISO timestamp. */ + start: string; + /** ISO timestamp. */ + end: string; +}; + +/** The continuous profile chunks of a profiler in a time range. */ +export async function getProfileChunks( + { core, origin }: ApiTarget, + { + organizationSlug, + profilerId, + projectId, + start, + end, + }: GetProfileChunksParams, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + query.set("profiler_id", profilerId); + query.set("project", projectId.toString()); + query.set("start", start); + query.set("end", end); + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/profiling/chunks/` + + `?${query.toString()}`, + ); + return validateResponse(schema, body); +} diff --git a/static/packages/api/lib/api-client/endpoints/projects.ts b/static/packages/api/lib/api-client/endpoints/projects.ts new file mode 100644 index 000000000000..f0cae4bd50d7 --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/projects.ts @@ -0,0 +1,219 @@ +/** + * Project endpoints: listing, reading, creating, and updating projects, and + * the teams assigned to a project. Each wrapper validates the response with + * the schema the host passes, so the host's schema decides the result type. + */ + +import { listOrganizationProjects } from "@sentry/api"; +import { + apiPath, + type ApiTarget, + fetchAllPages, + type ListPage, + listPage, + MAX_LIST_PAGES, + nextPageCursor, + readJsonResponse, + type ResponseSchema, + validateResponse, + withQuery, +} from "@sentry/api/client"; + +export type ListProjectsParams = { + organizationSlug: string; + /** Filters by name or slug. */ + query?: string; + /** Most projects to return. Default: 25. */ + limit?: number; + /** Resumes from a previous call's `nextCursor`. */ + cursor?: string | null; +}; + +/** A page of the organization's projects. */ +export async function listProjects( + { core, origin }: ApiTarget, + params: ListProjectsParams, + schema: ResponseSchema, +): Promise> { + const client = core.sdkClient( + origin, + apiPath`/organizations/${params.organizationSlug}/projects/`, + ); + return await listPage( + (perPage, cursor) => + listOrganizationProjects({ + client, + path: { organization_id_or_slug: params.organizationSlug }, + query: { per_page: perPage, query: params.query, cursor }, + throwOnError: true, + }), + schema, + params.limit ?? 25, + params.cursor, + ); +} + +export type GetProjectParams = { + organizationSlug: string; + projectSlugOrId: string; + /** Return only the organization's ID and slug instead of all of it. */ + collapse?: "organization"; +}; + +/** One project by slug or ID. */ +export async function getProject( + { core, origin }: ApiTarget, + { organizationSlug, projectSlugOrId, collapse }: GetProjectParams, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + if (collapse) { + query.set("collapse", collapse); + } + const body = await core.requestJson( + origin, + withQuery( + apiPath`/projects/${organizationSlug}/${projectSlugOrId}/`, + query, + ), + ); + return validateResponse(schema, body); +} + +export type CreateProjectParams = { + organizationSlug: string; + /** The team that owns the new project. */ + teamSlug: string; + name: string; + slug?: string | null; + platform?: string | null; +}; + +/** Create a project owned by a team. Empty `slug` and `platform` are left out. */ +export async function createProject( + { core, origin }: ApiTarget, + { organizationSlug, teamSlug, name, slug, platform }: CreateProjectParams, + schema: ResponseSchema, +): Promise { + const data: { name: string; slug?: string; platform?: string } = { name }; + if (slug) { + data.slug = slug; + } + if (platform) { + data.platform = platform; + } + const body = await core.requestJson( + origin, + apiPath`/teams/${organizationSlug}/${teamSlug}/projects/`, + { method: "POST", body: JSON.stringify(data) }, + ); + return validateResponse(schema, body); +} + +export type UpdateProjectParams = { + organizationSlug: string; + projectSlug: string; + name?: string | null; + slug?: string | null; + platform?: string | null; +}; + +/** Update a project. Only non-empty fields are sent. */ +export async function updateProject( + { core, origin }: ApiTarget, + { organizationSlug, projectSlug, name, slug, platform }: UpdateProjectParams, + schema: ResponseSchema, +): Promise { + const data: { name?: string; slug?: string; platform?: string } = {}; + if (name) data.name = name; + if (slug) data.slug = slug; + if (platform) data.platform = platform; + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/`, + { method: "PUT", body: JSON.stringify(data) }, + ); + return validateResponse(schema, body); +} + +export type ProjectTeamParams = { + organizationSlug: string; + projectSlug: string; +}; + +/** + * Every team assigned to a project, across all pages. + * + * @throws {Error} When a cursor repeats or the walk passes + * {@link MAX_LIST_PAGES} + */ +export async function listProjectTeams( + { core, origin }: ApiTarget, + { organizationSlug, projectSlug }: ProjectTeamParams, + schema: ResponseSchema, +): Promise { + const result = await fetchAllPages( + async (cursor) => { + const query = new URLSearchParams(); + query.set("per_page", "100"); + if (cursor) { + query.set("cursor", cursor); + } + const response = await core.request( + origin, + `${apiPath`/projects/${organizationSlug}/${projectSlug}/teams/`}?${query.toString()}`, + ); + return { + data: await readJsonResponse(response), + nextCursor: nextPageCursor(response) ?? undefined, + }; + }, + (data) => validateResponse(schema, data), + MAX_LIST_PAGES, + ); + if (!result.ok) { + // `parse` throws for an invalid page, so only the cursor checks remain. + throw new Error( + result.reason === "page-cap" + ? "API pagination exceeded its page limit" + : "API returned a repeated pagination cursor", + ); + } + return result.items; +} + +/** Assign a team to a project. */ +export async function addProjectTeam( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + teamSlug, + }: ProjectTeamParams & { + teamSlug: string; + }, +): Promise { + await core.request( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/teams/${teamSlug}/`, + { method: "POST", body: JSON.stringify({}) }, + ); +} + +/** Remove a team from a project. */ +export async function removeProjectTeam( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + teamSlug, + }: ProjectTeamParams & { + teamSlug: string; + }, +): Promise { + await core.request( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/teams/${teamSlug}/`, + { method: "DELETE" }, + ); +} diff --git a/static/packages/api/lib/api-client/endpoints/releases.ts b/static/packages/api/lib/api-client/endpoints/releases.ts new file mode 100644 index 000000000000..892b66102d32 --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/releases.ts @@ -0,0 +1,142 @@ +/** + * Release endpoints: releases, their deploys and commits, and an event's + * suspect committers. With a project, release reads go to the project's + * endpoints, otherwise to the organization's. Each wrapper validates the + * response with the schema the host passes, so the host's schema decides the + * result type. + */ + +import { + apiPath, + type ApiTarget, + type ResponseSchema, + validateResponse, + withQuery, +} from "@sentry/api/client"; + +export type ListReleasesParams = { + organizationSlug: string; + projectSlug?: string; + /** Filters by version. */ + query?: string; + /** Page size. */ + limit?: number; +}; + +/** The first page of releases for the organization or one project. */ +export async function listReleases( + { core, origin }: ApiTarget, + { organizationSlug, projectSlug, query, limit }: ListReleasesParams, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (query) { + params.set("query", query); + } + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + const path = projectSlug + ? apiPath`/projects/${organizationSlug}/${projectSlug}/releases/` + : apiPath`/organizations/${organizationSlug}/releases/`; + const body = await core.requestJson(origin, withQuery(path, params)); + return validateResponse(schema, body); +} + +export type ReleaseParams = { + organizationSlug: string; + releaseVersion: string; + projectSlugOrId?: string; +}; + +/** One release, optionally with its health data. */ +export async function getRelease( + { core, origin }: ApiTarget, + { + organizationSlug, + releaseVersion, + projectSlugOrId, + includeHealth, + }: ReleaseParams & { includeHealth?: boolean }, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (includeHealth) { + params.set("health", "1"); + } + const path = projectSlugOrId + ? apiPath`/projects/${organizationSlug}/${projectSlugOrId}/releases/${releaseVersion}/` + : apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/`; + const body = await core.requestJson(origin, withQuery(path, params)); + return validateResponse(schema, body); +} + +/** + * The first page of a release's deploys. A project narrows them, by ID when + * it is numeric and by slug otherwise. + */ +export async function listReleaseDeploys( + { core, origin }: ApiTarget, + { + organizationSlug, + releaseVersion, + projectSlugOrId, + limit, + }: ReleaseParams & { limit?: number }, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (projectSlugOrId) { + params.set( + /^\d+$/.test(projectSlugOrId) ? "project" : "projectSlug", + projectSlugOrId, + ); + } + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + const path = apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/deploys/`; + const body = await core.requestJson(origin, withQuery(path, params)); + return validateResponse(schema, body); +} + +/** The first page of a release's commits. */ +export async function listReleaseCommits( + { core, origin }: ApiTarget, + { + organizationSlug, + releaseVersion, + projectSlugOrId, + limit, + }: ReleaseParams & { limit?: number }, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + const path = projectSlugOrId + ? apiPath`/projects/${organizationSlug}/${projectSlugOrId}/releases/${releaseVersion}/commits/` + : apiPath`/organizations/${organizationSlug}/releases/${releaseVersion}/commits/`; + const body = await core.requestJson(origin, withQuery(path, params)); + return validateResponse(schema, body); +} + +export type GetEventCommittersParams = { + organizationSlug: string; + projectSlug: string; + eventId: string; +}; + +/** The suspect commits of an event's issue, grouped by committer. */ +export async function getEventCommitters( + { core, origin }: ApiTarget, + { organizationSlug, projectSlug, eventId }: GetEventCommittersParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/committers/`, + ); + return validateResponse(schema, body); +} diff --git a/static/packages/api/lib/api-client/endpoints/replays.ts b/static/packages/api/lib/api-client/endpoints/replays.ts new file mode 100644 index 000000000000..d7b52ee2408b --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/replays.ts @@ -0,0 +1,186 @@ +/** + * Session replay endpoints. Each wrapper validates the response with the + * schema the host passes, so the host's schema decides the result type. + */ + +import { + apiPath, + type ApiTarget, + applyTimeParams, + nextPageCursor, + readJsonResponse, + type ResponseSchema, + validateResponse, + withQuery, +} from "@sentry/api/client"; + +/** The recording-segments endpoint caps `per_page` at 100. */ +const REPLAY_SEGMENTS_PER_PAGE = 100; + +export type SearchReplaysParams = { + organizationSlug: string; + query?: string; + /** Page size. */ + limit?: number; + /** Numeric project ID. */ + projectId?: string; + sort?: string; + environment?: string | string[]; + statsPeriod?: string; + start?: string; + end?: string; + /** Fields to return. */ + fields?: string[]; +}; + +/** The first page of the organization's replays, in the `{ data }` envelope. */ +export async function searchReplays( + { core, origin }: ApiTarget, + { + organizationSlug, + query, + limit, + projectId, + sort, + environment, + statsPeriod, + start, + end, + fields, + }: SearchReplaysParams, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (query) { + params.set("query", query); + } + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + if (projectId) { + params.append("project", projectId); + } + if (sort) { + params.set("sort", sort); + } + if (environment) { + const environments = Array.isArray(environment) + ? environment + : [environment]; + for (const value of environments) { + params.append("environment", value); + } + } + for (const field of fields ?? []) { + params.append("field", field); + } + applyTimeParams(params, statsPeriod, start, end); + const body = await core.requestJson( + origin, + withQuery(apiPath`/organizations/${organizationSlug}/replays/`, params), + ); + return validateResponse(schema, body); +} + +/** One replay, in the `{ data }` envelope. */ +export async function getReplay( + { core, origin }: ApiTarget, + { + organizationSlug, + replayId, + }: { organizationSlug: string; replayId: string }, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/replays/${replayId}/`, + ); + return validateResponse(schema, body); +} + +export type IssueReplayIdsParams = { + organizationSlug: string; + issueId: string | number; + dataSource: "discover" | "search_issues"; +}; + +/** + * The IDs of an issue's replays in the last 90 days, across all projects, + * keyed by issue ID. + */ +export async function listIssueReplayIds( + { core, origin }: ApiTarget, + { organizationSlug, issueId, dataSource }: IssueReplayIdsParams, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + params.set("returnIds", "true"); + params.set("query", `issue.id:[${String(issueId)}]`); + params.set("data_source", dataSource); + params.set("statsPeriod", "90d"); + params.append("project", "-1"); + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/replay-count/` + + `?${params.toString()}`, + ); + return validateResponse(schema, body); +} + +export type ReplayRecordingSegmentsParams = { + organizationSlug: string; + projectSlugOrId: string; + replayId: string; + /** Most segments to visit. */ + maxSegments: number; +}; + +/** + * Visit a replay's recording segments page by page, following the `Link` + * cursor. Segments go to `onSegment` as each page arrives instead of being + * collected, because full DOM snapshots make raw pages large. + */ +export async function visitReplayRecordingSegments( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlugOrId, + replayId, + maxSegments, + }: ReplayRecordingSegmentsParams, + pageSchema: ResponseSchema, + onSegment: (segment: T) => void, +): Promise<{ segmentsRead: number; truncated: boolean }> { + let cursor: string | null = null; + let segmentsRead = 0; + + do { + const params = new URLSearchParams(); + params.set("per_page", String(REPLAY_SEGMENTS_PER_PAGE)); + if (cursor) { + params.set("cursor", cursor); + } + const response = await core.request( + origin, + apiPath`/projects/${organizationSlug}/${projectSlugOrId}/replays/${replayId}/recording-segments/` + + `?${params.toString()}`, + { method: "GET" }, + ); + const page = validateResponse(pageSchema, await readJsonResponse(response)); + for (const segment of page) { + if (segmentsRead >= maxSegments) { + return { segmentsRead, truncated: true }; + } + onSegment(segment); + segmentsRead += 1; + } + cursor = nextPageCursor(response); + // Only rows count toward `maxSegments`, so an empty page ends the walk + // too; following its cursor could otherwise never stop. + if (cursor && (segmentsRead >= maxSegments || page.length === 0)) { + return { segmentsRead, truncated: true }; + } + } while (cursor); + + return { segmentsRead, truncated: false }; +} diff --git a/static/packages/api/lib/api-client/endpoints/repositories.ts b/static/packages/api/lib/api-client/endpoints/repositories.ts new file mode 100644 index 000000000000..95449a6bda8c --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/repositories.ts @@ -0,0 +1,106 @@ +/** + * Repository endpoints: the organization's connected repositories and + * linking one to a project with a code mapping. Each wrapper validates the + * response with the schema the host passes, so the host's schema decides the + * result type. + */ + +import { + apiPath, + type ApiTarget, + fetchAllPages, + MAX_LIST_PAGES, + nextPageCursor, + readJsonResponse, + type ResponseSchema, + validateResponse, +} from "@sentry/api/client"; + +export type ListRepositoriesParams = { + organizationSlug: string; + /** Filters by repository name. */ + query?: string; +}; + +/** + * Every repository connected to the organization, across all pages. + * + * @throws {Error} When a cursor repeats or the walk passes + * {@link MAX_LIST_PAGES} + */ +export async function listRepositories( + { core, origin }: ApiTarget, + { organizationSlug, query }: ListRepositoriesParams, + schema: ResponseSchema, +): Promise { + const result = await fetchAllPages( + async (cursor) => { + const params = new URLSearchParams(); + params.set("per_page", "100"); + if (query) { + params.set("query", query); + } + if (cursor) { + params.set("cursor", cursor); + } + const response = await core.request( + origin, + `${apiPath`/organizations/${organizationSlug}/repos/`}?${params.toString()}`, + { method: "GET" }, + ); + return { + data: await readJsonResponse(response), + nextCursor: nextPageCursor(response) ?? undefined, + }; + }, + (data) => validateResponse(schema, data), + MAX_LIST_PAGES, + ); + if (!result.ok) { + // `parse` throws for an invalid page, so only the cursor checks remain. + throw new Error( + result.reason === "page-cap" + ? "API pagination exceeded its page limit" + : "API returned a repeated pagination cursor", + ); + } + return result.items; +} + +export type LinkProjectRepositoryParams = { + organizationSlug: string; + projectSlug: string; + /** The repository's full name, such as `getsentry/sentry`. */ + repository: string; + provider?: string | null; +}; + +/** + * Link a repository to a project with one code mapping whose stack and + * source roots are both the repository root. + */ +export async function linkProjectRepository( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + repository, + provider, + }: LinkProjectRepositoryParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/code-mappings/bulk/`, + { + method: "POST", + body: JSON.stringify({ + project: projectSlug, + repository, + provider, + mappings: [{ stackRoot: "", sourceRoot: "" }], + }), + }, + ); + return validateResponse(schema, body); +} diff --git a/static/packages/api/lib/api-client/endpoints/snapshots.ts b/static/packages/api/lib/api-client/endpoints/snapshots.ts new file mode 100644 index 000000000000..862d4a0990ee --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/snapshots.ts @@ -0,0 +1,87 @@ +/** + * Preprod snapshot endpoints. The responses are returned unvalidated: the + * callers read them as unknown JSON. + */ + +import { apiPath, type ApiTarget, withQuery } from "@sentry/api/client"; + +/** One snapshot, with compact image metadata unless `compactMetadata` is false. */ +export async function getSnapshot( + { core, origin }: ApiTarget, + { + organizationSlug, + snapshotId, + compactMetadata = true, + }: { + organizationSlug: string; + snapshotId: string; + compactMetadata?: boolean; + }, +): Promise { + const query = new URLSearchParams(); + if (compactMetadata) { + query.set("compact_metadata", "true"); + } + return await core.requestJson( + origin, + withQuery( + apiPath`/organizations/${organizationSlug}/preprodartifacts/snapshots/${snapshotId}/`, + query, + ), + ); +} + +/** One image of a snapshot. */ +export async function getSnapshotImage( + { core, origin }: ApiTarget, + { + organizationSlug, + snapshotId, + imageIdentifier, + }: { + organizationSlug: string; + snapshotId: string; + imageIdentifier: string; + }, +): Promise { + return await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/preprodartifacts/snapshots/${snapshotId}/images/${imageIdentifier}/`, + ); +} + +export type GetLatestBaseSnapshotParams = { + organizationSlug: string; + appId: string; + branch?: string; + /** Numeric project ID. */ + project?: string; + projectSlug?: string; + /** Default: true. */ + compactMetadata?: boolean; +}; + +/** The latest base snapshot of an app, optionally on one branch. */ +export async function getLatestBaseSnapshot( + { core, origin }: ApiTarget, + { + organizationSlug, + appId, + branch, + project, + projectSlug, + compactMetadata = true, + }: GetLatestBaseSnapshotParams, +): Promise { + const query = new URLSearchParams(); + query.set("app_id", appId); + if (branch) query.set("branch", branch); + if (project) query.set("project", project); + if (projectSlug) query.set("projectSlug", projectSlug); + if (compactMetadata) query.set("compact_metadata", "true"); + return await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/preprodartifacts/snapshots/latest-base/` + + `?${query.toString()}`, + ); +} diff --git a/static/packages/api/lib/api-client/endpoints/teams.ts b/static/packages/api/lib/api-client/endpoints/teams.ts new file mode 100644 index 000000000000..7cabc1600139 --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/teams.ts @@ -0,0 +1,68 @@ +/** + * Team endpoints within an organization. Each wrapper validates the response + * with the schema the host passes, so the host's schema decides the result + * type. + */ + +import { listOrganizationTeams } from "@sentry/api"; +import { + apiPath, + type ApiTarget, + type ListPage, + listPage, + type ResponseSchema, + validateResponse, +} from "@sentry/api/client"; + +export type ListTeamsParams = { + organizationSlug: string; + /** Filters by name or slug. */ + query?: string; + /** Most teams to return. Default: 25. */ + limit?: number; + /** Resumes from a previous call's `nextCursor`. */ + cursor?: string | null; +}; + +/** A page of the organization's teams. */ +export async function listTeams( + { core, origin }: ApiTarget, + params: ListTeamsParams, + schema: ResponseSchema, +): Promise> { + const client = core.sdkClient( + origin, + apiPath`/organizations/${params.organizationSlug}/teams/`, + ); + return await listPage( + (perPage, cursor) => + listOrganizationTeams({ + client, + path: { organization_id_or_slug: params.organizationSlug }, + query: { per_page: perPage, query: params.query, cursor }, + throwOnError: true, + }), + schema, + params.limit ?? 25, + params.cursor, + ); +} + +export type CreateTeamParams = { + organizationSlug: string; + name: string; +}; + +/** Create a team in the organization. */ +export async function createTeam( + { core, origin }: ApiTarget, + { organizationSlug, name }: CreateTeamParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/teams/`, + { method: "POST", body: JSON.stringify({ name }) }, + ); + return validateResponse(schema, body); +} diff --git a/static/packages/api/lib/api-client/endpoints/traces.ts b/static/packages/api/lib/api-client/endpoints/traces.ts new file mode 100644 index 000000000000..f85d61f99f58 --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/traces.ts @@ -0,0 +1,67 @@ +/** + * Trace endpoints. Each wrapper validates the response with the schema the + * host passes, so the host's schema decides the result type. + */ + +import { + apiPath, + type ApiTarget, + type ResponseSchema, + validateResponse, +} from "@sentry/api/client"; + +export type TraceParams = { + organizationSlug: string; + /** 32-character hex trace ID. */ + traceId: string; + /** Default: `14d`. */ + statsPeriod?: string; +}; + +/** Span, error, and operation counts for a trace. */ +export async function getTraceMeta( + { core, origin }: ApiTarget, + { organizationSlug, traceId, statsPeriod = "14d" }: TraceParams, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams({ statsPeriod }); + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/trace-meta/${traceId}/` + + `?${query.toString()}`, + ); + return validateResponse(schema, body); +} + +/** + * The trace's span tree. `limit` and `project` are the endpoint's declared + * parameters; the server currently ignores `project` and paginates + * internally, but they are still sent. + */ +export async function getTrace( + { core, origin }: ApiTarget, + { + organizationSlug, + traceId, + limit = 1000, + project = "-1", + statsPeriod = "14d", + }: TraceParams & { + /** Span-count hint. Default: 1000. */ + limit?: number; + /** Project filter hint. Default: `-1` (all projects). */ + project?: string; + }, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + query.set("limit", String(limit)); + query.set("project", project); + query.set("statsPeriod", statsPeriod); + const body = await core.requestJson( + origin, + apiPath`/organizations/${organizationSlug}/trace/${traceId}/` + + `?${query.toString()}`, + ); + return validateResponse(schema, body); +} diff --git a/static/packages/api/lib/api-client/endpoints/uptime-monitors.ts b/static/packages/api/lib/api-client/endpoints/uptime-monitors.ts new file mode 100644 index 000000000000..626532a389aa --- /dev/null +++ b/static/packages/api/lib/api-client/endpoints/uptime-monitors.ts @@ -0,0 +1,220 @@ +/** + * Uptime monitor endpoints. Each wrapper validates the response with the + * schema the host passes, so the host's schema decides the result type. + */ + +import { + apiPath, + type ApiTarget, + applyTimeParams, + defaultStatsPeriod, + parseStatsPeriod, + type ResponseSchema, + validateResponse, + withQuery, +} from "@sentry/api/client"; + +export type ListUptimeMonitorsParams = { + organizationSlug: string; + /** Numeric project ID; the endpoint filters by ID, not slug. */ + projectId?: string; + environment?: string; + owner?: string; + query?: string; + /** Page size. */ + limit?: number; +}; + +/** The first page of the organization's uptime monitors. */ +export async function listUptimeMonitors( + { core, origin }: ApiTarget, + { + organizationSlug, + projectId, + environment, + owner, + query, + limit, + }: ListUptimeMonitorsParams, + schema: ResponseSchema, +): Promise { + const params = new URLSearchParams(); + if (projectId) { + params.append("project", projectId); + } + if (environment) { + params.append("environment", environment); + } + if (owner) { + params.append("owner", owner); + } + if (query) { + params.set("query", query); + } + if (limit !== undefined) { + params.set("per_page", String(limit)); + } + const body = await core.requestJson( + origin, + withQuery(apiPath`/organizations/${organizationSlug}/uptime/`, params), + ); + return validateResponse(schema, body); +} + +export type UptimeMonitorParams = { + organizationSlug: string; + projectSlug: string; + uptimeMonitorId: string; +}; + +function uptimeMonitorPath({ + organizationSlug, + projectSlug, + uptimeMonitorId, +}: UptimeMonitorParams): string { + return apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/${uptimeMonitorId}/`; +} + +/** One uptime monitor. */ +export async function getUptimeMonitor( + { core, origin }: ApiTarget, + params: UptimeMonitorParams, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson(origin, uptimeMonitorPath(params)); + return validateResponse(schema, body); +} + +/** The first page of a monitor's checks in a time range, 24 hours by default. */ +export async function listUptimeMonitorChecks( + { core, origin }: ApiTarget, + params: UptimeMonitorParams & { + statsPeriod?: string; + start?: string; + end?: string; + limit?: number; + }, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + if (params.limit !== undefined) { + query.set("per_page", String(params.limit)); + } + const statsPeriod = defaultStatsPeriod(params); + if (statsPeriod) { + parseStatsPeriod(statsPeriod); + } + applyTimeParams(query, statsPeriod, params.start, params.end); + const body = await core.requestJson( + origin, + withQuery(`${uptimeMonitorPath(params)}checks/`, query), + ); + return validateResponse(schema, body); +} + +/** The writable fields of an uptime monitor, in the order they are sent. */ +export type UptimeMonitorFields = { + name?: string; + url?: string; + intervalSeconds?: number; + timeoutMs?: number; + method?: string; + headers?: Array<[string, string]>; + body?: string | null; + assertion?: unknown; + status?: "active" | "disabled"; + owner?: string | null; + environment?: string | null; + traceSampling?: boolean; + responseCaptureEnabled?: boolean; + recoveryThreshold?: number; + downtimeThreshold?: number; +}; + +const UPTIME_MONITOR_FIELDS = [ + "name", + "url", + "intervalSeconds", + "timeoutMs", + "method", + "headers", + "body", + "assertion", + "status", + "owner", + "environment", + "traceSampling", + "responseCaptureEnabled", + "recoveryThreshold", + "downtimeThreshold", +] as const satisfies readonly (keyof UptimeMonitorFields)[]; + +/** The request body: every field that is set, `null` included. */ +function uptimeMonitorBody(fields: UptimeMonitorFields): string { + const payload: Record = {}; + for (const field of UPTIME_MONITOR_FIELDS) { + if (fields[field] !== undefined) { + payload[field] = fields[field]; + } + } + return JSON.stringify(payload); +} + +/** Create an uptime monitor in a project. */ +export async function createUptimeMonitor( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + ...fields + }: { organizationSlug: string; projectSlug: string } & UptimeMonitorFields & + Required< + Pick< + UptimeMonitorFields, + "name" | "url" | "intervalSeconds" | "timeoutMs" + > + >, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + apiPath`/projects/${organizationSlug}/${projectSlug}/uptime/`, + { method: "POST", body: uptimeMonitorBody(fields) }, + ); + return validateResponse(schema, body); +} + +/** Update an uptime monitor. Only the fields that are set are sent. */ +export async function updateUptimeMonitor( + { core, origin }: ApiTarget, + { + organizationSlug, + projectSlug, + uptimeMonitorId, + ...fields + }: UptimeMonitorParams & UptimeMonitorFields, + schema: ResponseSchema, +): Promise { + const body = await core.requestJson( + origin, + uptimeMonitorPath({ organizationSlug, projectSlug, uptimeMonitorId }), + { method: "PUT", body: uptimeMonitorBody(fields) }, + ); + return validateResponse(schema, body); +} + +/** + * Delete an uptime monitor. Sentry answers 202 with no body; a 404 (already + * gone) is not an error, so repeated deletes succeed. + */ +export async function deleteUptimeMonitor( + { core, origin }: ApiTarget, + params: UptimeMonitorParams, +): Promise { + await core.request( + origin, + uptimeMonitorPath(params), + { method: "DELETE" }, + { allowStatuses: [404] }, + ); +} diff --git a/static/packages/api/lib/api-client/errors.ts b/static/packages/api/lib/api-client/errors.ts new file mode 100644 index 000000000000..9c626d5422c4 --- /dev/null +++ b/static/packages/api/lib/api-client/errors.ts @@ -0,0 +1,373 @@ +import { SentryApiError } from "@sentry/api"; + +/** + * Error thrown when user input validation fails. + * These errors should be returned to the user directly without logging to Sentry. + */ +export class UserInputError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "UserInputError"; + } +} + +/** + * Error thrown when configuration is invalid or missing. + * These errors should be returned to the user directly without logging to Sentry. + * Typically used for environment configuration issues, connection settings, etc. + */ +export class ConfigurationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "ConfigurationError"; + } +} + +/** + * API Error Class Hierarchy + * + * This module defines a hierarchical error system for the Sentry API client + * that automatically categorizes errors based on HTTP status codes. + * + * Key principles: + * - Every class extends `SentryApiError`, the error the generated operations + * return, so `instanceof SentryApiError` matches API failures from both the + * operations and the request core + * - 4xx errors (ApiClientError) are user input errors, not reported to Sentry + * - 5xx errors (ApiServerError) are system errors, reported to Sentry + * - Specific error types provide additional context and helper methods + * - The hierarchy enables type-safe error handling without manual status checks + */ + +/** + * Base class for the request core's API errors. + * Contains common properties for all API error types. + * Status is optional since some errors (like client-side validation) don't have HTTP status codes. + * `body` is the parsed response body, the same value as `responseBody`; + * `response` is not kept, since the core has consumed it. + */ +export class ApiError extends SentryApiError { + constructor( + message: string, + status?: number, + public readonly detail?: string, + public readonly responseBody?: unknown, + ) { + super(status, responseBody, undefined, false, message); + this.name = "ApiError"; + // Ensure proper prototype chain for instanceof checks + Object.setPrototypeOf(this, ApiError.prototype); + } +} + +/** + * Client errors (4xx) - User input errors that should NOT be reported to Sentry. + * These typically indicate issues with the request that the user can fix. + * Also includes client-side validation errors that don't have HTTP status codes. + */ +export class ApiClientError extends ApiError { + constructor( + message: string, + status?: number, + detail?: string, + responseBody?: unknown, + ) { + super(message, status, detail, responseBody); + this.name = "ApiClientError"; + Object.setPrototypeOf(this, ApiClientError.prototype); + } + + /** + * Convert to a user-friendly formatted message. + * Returns the standard format: "API error (status): message" or just "API error: message" if no status. + * For 404 errors, adds helpful context about checking parameters. + */ + toUserMessage(): string { + const baseMessage = this.status + ? `API error (${this.status}): ${this.message}` + : `API error: ${this.message}`; + + // Add helpful context for 404 errors, especially generic ones + if (this.status === 404) { + // Check if the message is generic + const genericMessages = [ + "not found", + "the requested resource does not exist", + "resource does not exist", + "resource not found", + ]; + + const isGeneric = genericMessages.some( + (msg) => + this.message.toLowerCase() === msg || + this.message.toLowerCase().includes("requested resource"), + ); + + if (isGeneric) { + return `${baseMessage}. Please verify that the organization, project, or resource ID is correct and that you have access to it.`; + } + // For specific messages like "Project not found", just add a hint + return `${baseMessage}. Please verify the parameters are correct.`; + } + + return baseMessage; + } + + /** + * Check if this is a permission/authorization error (403) + */ + isPermissionError(): boolean { + return this.status === 403; + } + + /** + * Check if this is a not found error (404) + */ + isNotFoundError(): boolean { + return this.status === 404; + } + + /** + * Check if this is a validation error (400 or 422) + */ + isValidationError(): boolean { + return this.status === 400 || this.status === 422; + } + + /** + * Check if this is an authentication error (401) + */ + isAuthenticationError(): boolean { + return this.status === 401; + } + + /** + * Check if this is a rate limit error (429) + */ + isRateLimitError(): boolean { + return this.status === 429; + } +} + +/** + * Permission denied error (403). + * Includes special handling for multi-project access errors. + */ +export class ApiPermissionError extends ApiClientError { + constructor(message: string, detail?: string, responseBody?: unknown) { + super(message, 403, detail, responseBody); + this.name = "ApiPermissionError"; + Object.setPrototypeOf(this, ApiPermissionError.prototype); + } + + /** + * Check if this is the specific multi-project access error + */ + isMultiProjectAccessError(): boolean { + return ( + this.message.includes("multiple projects") || + this.message.includes("multi project") || + this.message.includes("multi-project") || + this.message.includes( + "You do not have access to query across multiple projects", + ) + ); + } +} + +/** + * Resource not found error (404). + * Can include additional context about what resource was not found. + */ +export class ApiNotFoundError extends ApiClientError { + constructor( + message: string, + detail?: string, + responseBody?: unknown, + public readonly resourceType?: string, + public readonly resourceId?: string, + ) { + super(message, 404, detail, responseBody); + this.name = "ApiNotFoundError"; + Object.setPrototypeOf(this, ApiNotFoundError.prototype); + } +} + +/** + * Validation error (400 or 422 for API responses, or no status for client-side validation). + * Indicates the request was malformed or contained invalid data. + */ +export class ApiValidationError extends ApiClientError { + constructor( + message: string, + status?: 400 | 422, + detail?: string, + responseBody?: unknown, + public readonly validationErrors?: Record, + ) { + super(message, status, detail, responseBody); + this.name = "ApiValidationError"; + Object.setPrototypeOf(this, ApiValidationError.prototype); + } +} + +/** + * Authentication error (401). + * Indicates the request lacks valid authentication credentials. + */ +export class ApiAuthenticationError extends ApiClientError { + constructor(message: string, detail?: string, responseBody?: unknown) { + super(message, 401, detail, responseBody); + this.name = "ApiAuthenticationError"; + Object.setPrototypeOf(this, ApiAuthenticationError.prototype); + } +} + +/** + * Rate limit error (429). + * `retryAfterMs` comes from the response's `Retry-After` or + * `X-Sentry-Rate-Limit-Reset` header, when it has one. + */ +export class ApiRateLimitError extends ApiClientError { + constructor( + message: string, + detail?: string, + responseBody?: unknown, + public readonly retryAfterMs?: number, + ) { + super(message, 429, detail, responseBody); + this.name = "ApiRateLimitError"; + Object.setPrototypeOf(this, ApiRateLimitError.prototype); + } + + override toUserMessage(): string { + if (this.retryAfterMs === undefined) { + return `${super.toUserMessage()}. Retry in a few seconds.`; + } + const seconds = Math.max(1, Math.ceil(this.retryAfterMs / 1000)); + return `${super.toUserMessage()}. Retry in ${seconds} second${seconds === 1 ? "" : "s"}.`; + } +} + +/** + * Server errors (5xx) - System errors that SHOULD be reported to Sentry. + * These indicate problems with the server that are not the user's fault. + */ +export class ApiServerError extends ApiError { + constructor( + message: string, + status: number, + detail?: string, + responseBody?: unknown, + ) { + super(message, status, detail, responseBody); + this.name = "ApiServerError"; + Object.setPrototypeOf(this, ApiServerError.prototype); + } + + /** + * Check if this is a gateway/proxy error (502, 503, 504) + */ + isGatewayError(): boolean { + return this.status === 502 || this.status === 503 || this.status === 504; + } + + /** + * Check if this is an internal server error (500) + */ + isInternalError(): boolean { + return this.status === 500; + } +} + +/** + * Factory function to create the appropriate error type based on HTTP status code. + * This centralizes the logic for determining which error class to instantiate. + * This is only used for actual API responses, so status is required. + */ +export function createApiError( + message: string, + status: number, + detail?: string, + responseBody?: unknown, + retryAfterMs?: number, +): ApiError { + // Apply message improvements for known error patterns + let improvedMessage = message; + + // Handle the multi-project access error that comes in various forms + if ( + message.includes( + "You do not have the multi project stream feature enabled", + ) || + message.includes("You cannot view events from multiple projects") + ) { + improvedMessage = + "You do not have access to query across multiple projects. Please select a project for your query."; + return new ApiPermissionError(improvedMessage, detail, responseBody); + } + + // Create specific error types based on status code + switch (status) { + case 401: + return new ApiAuthenticationError(message, detail, responseBody); + + case 403: + return new ApiPermissionError(message, detail, responseBody); + + case 404: + // TODO: Could extract resource type/ID from the request context + return new ApiNotFoundError(message, detail, responseBody); + + case 400: + case 422: { + // Try to extract validation errors if present + let validationErrors: Record | undefined; + if ( + responseBody && + typeof responseBody === "object" && + "errors" in responseBody + ) { + validationErrors = responseBody.errors as Record; + } + return new ApiValidationError( + message, + status as 400 | 422, + detail, + responseBody, + validationErrors, + ); + } + + case 429: + return new ApiRateLimitError(message, detail, responseBody, retryAfterMs); + + default: + // Generic categorization for other status codes + if (status >= 400 && status < 500) { + return new ApiClientError(message, status, detail, responseBody); + } + if (status >= 500 && status < 600) { + return new ApiServerError(message, status, detail, responseBody); + } + // Fallback for unusual status codes + return new ApiError(message, status, detail, responseBody); + } +} + +/** + * Returns true if `error` is an `ApiAuthenticationError`, either directly or + * anywhere within the first few levels of `error.cause`. Tools that wrap + * upstream errors as `throw new Error(msg, { cause: apiError })` would + * otherwise hide the 401 signal from a direct `instanceof` check. Bounded + * depth guards against accidental cycles. + */ +export function isApiAuthenticationErrorDeep(error: unknown): boolean { + let current: unknown = error; + for (let i = 0; i < 3; i++) { + if (current instanceof ApiAuthenticationError) return true; + if (!(current instanceof Error)) return false; + current = current.cause; + } + return false; +} diff --git a/static/packages/api/lib/api-client/idempotent-operations.ts b/static/packages/api/lib/api-client/idempotent-operations.ts new file mode 100644 index 000000000000..fac175ff014c --- /dev/null +++ b/static/packages/api/lib/api-client/idempotent-operations.ts @@ -0,0 +1,87 @@ +/** + * Matching for the writes Sentry declares safe to replay, for the transport's + * `isIdempotent`. `@sentry/api` marks them `idempotent` from Sentry's + * `x-sentry-idempotent` OpenAPI extension, so no product hard-codes which + * writes may retry. Each product chooses how it loads the catalog: the CLI + * extracts the list at build time to keep startup fast, and MCP reads + * `@sentry/api/operations` directly. + */ + +import { isIdempotentRequest } from "./retry.js"; + +/** A non-safe operation Sentry marks idempotent. */ +export type IdempotentOperation = { + /** Uppercase HTTP method */ + method: string; + /** OpenAPI path template, such as `/api/0/organizations/{org}/chunk-upload/` */ + path: string; +}; + +/** + * The writes in a catalog that Sentry marks idempotent, sorted by path then + * method. Safe methods already retry, so they are left out to keep the list + * every request checks small. + */ +export function selectIdempotentWrites( + operations: readonly { + method: string; + path: string; + idempotent: boolean; + }[], +): IdempotentOperation[] { + return operations + .filter((op) => op.idempotent && !isIdempotentRequest(op.method)) + .map((op) => ({ method: op.method, path: op.path })) + .sort( + (a, b) => + a.path.localeCompare(b.path) || a.method.localeCompare(b.method), + ); +} + +type Matcher = { method: string; pattern: RegExp }; + +const TEMPLATE_SEGMENT = /^\{[^/]+\}$/; +const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\]/g; + +function toMatcher({ method, path }: IdempotentOperation): Matcher { + const source = path + .split("/") + .map((segment) => + TEMPLATE_SEGMENT.test(segment) + ? "[^/]+" + : segment.replace(REGEXP_SPECIAL, "\\$&"), + ) + .join("/"); + return { method: method.toUpperCase(), pattern: new RegExp(`^${source}$`) }; +} + +/** + * Build `isIdempotent(method, url)` for a list of writes. The list is read on + * the first call, so a caller may pass a thunk that loads it lazily. + * + * Matches from the `/api/` segment on, so a self-hosted install served under + * a path prefix still matches. + */ +export function createIdempotentOperationMatcher( + operations: () => readonly IdempotentOperation[], +): (method: string, url: string) => boolean { + let matchers: Matcher[] | undefined; + return (method, url) => { + if (!URL.canParse(url)) { + return false; + } + const pathname = new URL(url).pathname; + const apiIndex = pathname.indexOf("/api/"); + if (apiIndex === -1) { + return false; + } + const apiPath = pathname.slice(apiIndex); + const normalized = apiPath.endsWith("/") ? apiPath : `${apiPath}/`; + const upper = method.toUpperCase(); + + matchers ??= operations().map(toMatcher); + return matchers.some( + (matcher) => matcher.method === upper && matcher.pattern.test(normalized), + ); + }; +} diff --git a/static/packages/api/lib/api-client/index.ts b/static/packages/api/lib/api-client/index.ts new file mode 100644 index 000000000000..679efe6488f5 --- /dev/null +++ b/static/packages/api/lib/api-client/index.ts @@ -0,0 +1,22 @@ +/** + * `@sentry/api/client`: the request layer the generated operations and + * hand-written endpoint wrappers share. It uses only web-standard APIs, so it + * runs in Node, Cloudflare Workers, and browsers. Hosts inject what differs + * between them: credentials, cache storage, the fresh-read scope, logging, + * and spans. + */ + +export * from "./api-path.js"; +export * from "./auth-token.js"; +export * from "./core.js"; +export * from "./errors.js"; +export * from "./idempotent-operations.js"; +export * from "./objectstore.js"; +export * from "./pagination.js"; +export * from "./response.js"; +export * from "./response-cache/index.js"; +export * from "./retry.js"; +export type { StandardSchemaV1 } from "./standard-schema.js"; +export * from "./time-params.js"; +export * from "./transport.js"; +export * from "./validate.js"; diff --git a/static/packages/api/lib/api-client/objectstore.ts b/static/packages/api/lib/api-client/objectstore.ts new file mode 100644 index 000000000000..1759579ada53 --- /dev/null +++ b/static/packages/api/lib/api-client/objectstore.ts @@ -0,0 +1,544 @@ +/** + * Objectstore client for uploading files, such as snapshot images, through the + * service that Sentry's upload-options endpoints return + * (e.g. `snapshots/upload-options/`). + * + * Implements the subset of the `objectstore-client` protocol + * (getsentry/objectstore) that uploads need: find which objects already exist, + * then upload the missing ones. The service URL, scopes, auth token, and + * expiration policy come from the Sentry API; this client never signs tokens. + * + * Object path layout: `{url}/v1/objects/{usecase}/{scope}/{key}`, where `scope` + * is a `;`-joined list of ordered `key=value` pairs. Auth is carried in the + * `x-os-auth: Bearer ` header, not `Authorization`. + * + * Like the Rust client's `Session::many()`, HEADs and small inserts are grouped + * into multipart requests to `{url}/v1/objects:batch/{usecase}/{scope}/`. The + * Sentry proxy in front of Objectstore rate-limits each HTTP request, so one + * request per object trips it (HTTP 429) on larger uploads. + * + * Uses only web-standard APIs. Hosts supply file contents through + * {@link ObjectUpload.read} and may inject `fetch` (e.g. for custom CAs). + */ + +import pLimit, { type LimitFunction } from "p-limit"; +import { ApiError } from "./errors.js"; + +/** Header carrying the Objectstore bearer token. */ +const AUTH_HEADER = "x-os-auth"; +/** Header carrying an object's expiration policy (e.g. `ttl:30d`). */ +const EXPIRATION_HEADER = "x-sn-expiration"; + +/** Matches one or more trailing slashes (for base-URL normalization). */ +const TRAILING_SLASHES = /\/+$/; + +/** Timeout for uploading a single object. */ +const PUT_TIMEOUT_MS = 120_000; +/** Timeout for one batch request (up to {@link MAX_BATCH_BODY_SIZE} bytes). */ +const BATCH_TIMEOUT_MS = 300_000; + +/** Per-part headers of the batch protocol. */ +const BATCH_KIND_HEADER = "x-sn-batch-operation-kind"; +const BATCH_KEY_HEADER = "x-sn-batch-operation-key"; +const BATCH_INDEX_HEADER = "x-sn-batch-operation-index"; +const BATCH_STATUS_HEADER = "x-sn-batch-operation-status"; + +/** Server-enforced maximum number of operations per batch request. */ +const MAX_BATCH_OPS = 1000; +/** Server-enforced maximum body size of one batch part; larger objects are PUT individually. */ +const MAX_BATCH_PART_SIZE = 1024 * 1024; +/** + * Maximum total body size of one batch request. With {@link BATCH_CONCURRENCY} + * batches in flight, upload bodies held in memory stay around 100 MB. + */ +const MAX_BATCH_BODY_SIZE = 25 * 1024 * 1024; +/** + * Batch requests in flight at once. Each batch is one request to the Sentry + * proxy, so this stays far below its per-request rate limit. + */ +const BATCH_CONCURRENCY = 4; +/** Concurrency for objects too large to batch (matches the Rust client). */ +const INDIVIDUAL_CONCURRENCY = 5; + +/** Matches the `boundary` parameter of a multipart `Content-Type`. */ +const BOUNDARY_PARAM = /boundary=(?:"([^"]+)"|([^;\s]+))/i; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const CRLF = encoder.encode("\r\n"); +const HEADER_END = encoder.encode("\r\n\r\n"); + +/** + * Objectstore upload configuration, as returned (camelCase) by the Sentry + * upload-options endpoints. + */ +export type ObjectstoreConfig = { + /** Base service URL (may include a path prefix). */ + url: string; + usecase: string; + /** Ordered scope pairs (e.g. `[["org","1"],["project","2"]]`). */ + scopes: [string, string][]; + /** Pre-signed bearer token, or null/absent for unauthenticated stores. */ + authToken?: string | null; + /** Expiration policy string applied to uploaded objects. */ + expirationPolicy: string; +}; + +/** Host capabilities for Objectstore requests. */ +export type ObjectstoreOptions = { + /** Fetch to send requests with. Defaults to `globalThis.fetch`. */ + fetch?: (input: string, init?: RequestInit) => Promise; +}; + +/** A file to upload under an object key. */ +export type ObjectUpload = { + key: string; + /** Size in bytes, used to group uploads before any file is read. */ + size: number; + /** Read the file's bytes. Called once, when its request starts. */ + read: () => Promise>; +}; + +/** + * A failed Objectstore request or operation. `url` is the object URL for a + * failed operation, or the request URL for a failed request. + */ +export class ObjectstoreError extends ApiError { + constructor( + message: string, + status: number, + detail: string, + public readonly url: string, + ) { + super(message, status, detail); + this.name = "ObjectstoreError"; + Object.setPrototypeOf(this, ObjectstoreError.prototype); + } +} + +/** Render scope pairs into the `k=v;k=v` path segment. */ +function scopeSegment(scopes: [string, string][]): string { + return scopes.map(([key, value]) => `${key}=${value}`).join(";"); +} + +/** + * Build the full URL for an object key within the configured usecase + scope. + * + * @param config - The objectstore configuration. + * @param key - The object key (e.g. `//`). + */ +function buildObjectUrl(config: ObjectstoreConfig, key: string): string { + const base = config.url.replace(TRAILING_SLASHES, ""); + const path = key.split("/").map(encodePathSegment).join("/"); + return `${base}/v1/objects/${config.usecase}/${scopeSegment(config.scopes)}/${path}`; +} + +/** Build the batch endpoint URL for the configured usecase + scope. */ +function buildBatchUrl(config: ObjectstoreConfig): string { + const base = config.url.replace(TRAILING_SLASHES, ""); + return `${base}/v1/objects:batch/${config.usecase}/${scopeSegment(config.scopes)}/`; +} + +/** Percent-encode the UTF-8 bytes of `value` that are controls, DEL, non-ASCII, or in `reserved`. */ +function percentEncode(value: string, reserved: string): string { + let out = ""; + for (const byte of encoder.encode(value)) { + if (byte < 0x20 || byte >= 0x7f || reserved.includes(String.fromCharCode(byte))) { + out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; + } else { + out += String.fromCharCode(byte); + } + } + return out; +} + +/** + * Escape a value for an Objectstore header, matching `encode_header_value` in + * `objectstore-types`: percent-encode control bytes, DEL, `%`, and non-ASCII. + */ +function encodeHeaderValue(value: string): string { + return percentEncode(value, "%"); +} + +/** + * Escape one key segment for an object URL, matching the Rust client, which + * pushes each `/`-separated part of the key with the `url` crate's + * `SPECIAL_PATH_SEGMENT` encode set (http(s) URLs). That set includes `\`, + * which those URLs would otherwise parse as `/`. + */ +function encodePathSegment(segment: string): string { + return percentEncode(segment, ' "#%<>?^`{}\\'); +} + +/** Auth headers for an objectstore request, if a token is configured. */ +function authHeaders(config: ObjectstoreConfig): Record { + return config.authToken ? { [AUTH_HEADER]: `Bearer ${config.authToken}` } : {}; +} + +function fetchFor(options: ObjectstoreOptions | undefined) { + return options?.fetch ?? ((input: string, init?: RequestInit) => globalThis.fetch(input, init)); +} + +/** + * Upload one object's bytes, raw (no content encoding). + * + * @throws {ObjectstoreError} On a non-2xx response. + */ +async function putObject( + config: ObjectstoreConfig, + key: string, + body: Uint8Array, + options?: ObjectstoreOptions, +): Promise { + const url = buildObjectUrl(config, key); + const response = await fetchFor(options)(url, { + method: "PUT", + headers: { ...authHeaders(config), [EXPIRATION_HEADER]: config.expirationPolicy }, + body, + signal: AbortSignal.timeout(PUT_TIMEOUT_MS), + }); + if (!response.ok) { + throw new ObjectstoreError( + "Objectstore upload failed", + response.status, + response.statusText || "PUT failed", + url, + ); + } +} + +/** One operation within a batch request. */ +type BatchOperation = + | { kind: "head"; key: string } + | { kind: "insert"; key: string; body: Uint8Array }; + +/** Outcome of one batch operation, as reported in its response part. */ +type BatchOperationResult = { + /** HTTP status of this operation (e.g. 200, 201, 404, 429). */ + status: number; + /** Response part body (an error description for failed operations). */ + message: string; +}; + +/** Concatenate byte arrays into one. */ +function concatBytes(chunks: Uint8Array[]): Uint8Array { + const out = new Uint8Array(chunks.reduce((total, c) => total + c.byteLength, 0)); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.byteLength; + } + return out; +} + +/** Serialize batch operations into a `multipart/form-data` request body. */ +function encodeBatchBody( + config: ObjectstoreConfig, + operations: BatchOperation[], + boundary: string, +): Uint8Array { + const chunks: Uint8Array[] = []; + for (const op of operations) { + let head = + `--${boundary}\r\n` + + 'content-disposition: form-data; name="part"\r\n' + + `${BATCH_KIND_HEADER}: ${op.kind}\r\n` + + `${BATCH_KEY_HEADER}: ${encodeHeaderValue(op.key)}\r\n`; + if (op.kind === "insert") { + head += `${EXPIRATION_HEADER}: ${config.expirationPolicy}\r\n`; + } + chunks.push(encoder.encode(`${head}\r\n`)); + if (op.kind === "insert") { + chunks.push(op.body); + } + chunks.push(CRLF); + } + chunks.push(encoder.encode(`--${boundary}--\r\n`)); + return concatBytes(chunks); +} + +/** Index of `needle` in `haystack` at or after `from`, or -1. */ +function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): number { + const last = haystack.length - needle.length; + outer: for (let i = from; i <= last; i++) { + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) { + continue outer; + } + } + return i; + } + return -1; +} + +/** Parse the header block of one multipart part into a lowercase-keyed map. */ +function parsePartHeaders(block: string): Map { + const headers = new Map(); + for (const line of block.split("\r\n")) { + const colon = line.indexOf(":"); + if (colon > 0) { + headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim()); + } + } + return headers; +} + +/** + * Parse a batch `multipart/form-data` response into per-operation results, + * keyed by the operation index the server echoes on each part. + * + * Response parts only carry status headers and small error bodies, so the + * whole response is buffered and split on the boundary. + */ +function parseBatchResponse( + contentType: string, + body: Uint8Array, + url: string, +): Map { + const match = BOUNDARY_PARAM.exec(contentType); + const boundary = match?.[1] ?? match?.[2]; + if (!boundary) { + throw new ObjectstoreError( + "Objectstore batch response is not multipart", + 502, + `Unexpected Content-Type: ${contentType || "(none)"}`, + url, + ); + } + + const delimiter = encoder.encode(`--${boundary}`); + const results = new Map(); + let start = indexOfBytes(body, delimiter, 0); + while (start >= 0) { + let partStart = start + delimiter.length; + if (body[partStart] === 0x2d && body[partStart + 1] === 0x2d) { + break; // closing delimiter + } + if (body[partStart] === 0x0d && body[partStart + 1] === 0x0a) { + partStart += 2; + } + const next = indexOfBytes(body, delimiter, partStart); + let partEnd = next < 0 ? body.length : next; + if (body[partEnd - 2] === 0x0d && body[partEnd - 1] === 0x0a) { + partEnd -= 2; + } + const split = indexOfBytes(body, HEADER_END, partStart); + if (split >= 0 && split < partEnd) { + const headers = parsePartHeaders(decoder.decode(body.subarray(partStart, split))); + const index = Number.parseInt(headers.get(BATCH_INDEX_HEADER) ?? "", 10); + const status = Number.parseInt(headers.get(BATCH_STATUS_HEADER) ?? "", 10); + if (!Number.isNaN(index) && !Number.isNaN(status)) { + results.set(index, { + status, + message: decoder.decode(body.subarray(split + HEADER_END.length, partEnd)), + }); + } + } + start = next; + } + return results; +} + +/** + * Send operations as one batch request. + * + * @returns Results aligned with `operations` by index. + * @throws {ObjectstoreError} When the batch request itself fails or the + * response is missing a result for any operation. + */ +async function sendBatch( + config: ObjectstoreConfig, + operations: BatchOperation[], + options: ObjectstoreOptions | undefined, +): Promise { + const url = buildBatchUrl(config); + const boundary = `sentry-api-${crypto.randomUUID()}`; + const response = await fetchFor(options)(url, { + method: "POST", + headers: { + ...authHeaders(config), + "content-type": `multipart/form-data; boundary="${boundary}"`, + }, + body: encodeBatchBody(config, operations, boundary), + signal: AbortSignal.timeout(BATCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new ObjectstoreError( + "Objectstore batch request failed", + response.status, + response.statusText || "batch request failed", + url, + ); + } + + const parsed = parseBatchResponse( + response.headers.get("content-type") ?? "", + new Uint8Array(await response.arrayBuffer()), + url, + ); + return operations.map((op, index) => { + const result = parsed.get(index); + if (!result) { + throw new ObjectstoreError( + "Objectstore batch response is incomplete", + 502, + `No result for ${op.kind} of ${op.key}`, + url, + ); + } + return result; + }); +} + +/** Throw an {@link ObjectstoreError} for a failed operation inside a batch. */ +function throwOperationError( + config: ObjectstoreConfig, + message: string, + key: string, + result: BatchOperationResult, +): never { + throw new ObjectstoreError( + message, + result.status, + result.message || `HTTP ${result.status}`, + buildObjectUrl(config, key), + ); +} + +/** Split `items` into consecutive chunks of at most `size` items. */ +function chunk(items: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + return chunks; +} + +/** + * Wrap `task` so that its failure clears every queue in `limits` before the + * limiter can start the next task: after the first error, no new request + * starts and no further upload is read. Cleared tasks never settle, which is + * fine: the caller's `Promise.all` has already rejected with the first error, + * and pending promises hold no timers or handles. + */ +function stopOnFailure( + limits: LimitFunction[], + task: () => Promise, +): () => Promise { + return async () => { + try { + await task(); + } catch (error) { + for (const limit of limits) { + limit.clearQueue(); + } + throw error; + } + }; +} + +/** + * Find which keys already exist, using batched HEAD operations (one request + * per {@link MAX_BATCH_OPS} keys, up to {@link BATCH_CONCURRENCY} at once). + * + * @returns The subset of `keys` that exist. + * @throws {ObjectstoreError} On a failed batch, or any HEAD result other than 2xx/404. + */ +export async function findExistingObjects( + config: ObjectstoreConfig, + keys: string[], + options?: ObjectstoreOptions, +): Promise> { + const existing = new Set(); + const limit = pLimit(BATCH_CONCURRENCY); + await Promise.all( + chunk(keys, MAX_BATCH_OPS).map((batch) => + limit(stopOnFailure([limit], async () => { + const results = await sendBatch( + config, + batch.map((key) => ({ kind: "head", key })), + options, + ); + results.forEach((result, i) => { + const key = batch[i] as string; + if (result.status === 404) { + return; + } + if (result.status >= 400) { + throwOperationError(config, "Objectstore HEAD failed", key, result); + } + existing.add(key); + }); + })), + ), + ); + return existing; +} + +/** Group uploads into batches within the op-count and body-size limits. */ +function groupBatches(uploads: ObjectUpload[]): ObjectUpload[][] { + const batches: ObjectUpload[][] = []; + let current: ObjectUpload[] = []; + let currentSize = 0; + for (const upload of uploads) { + if ( + current.length >= MAX_BATCH_OPS || + (current.length > 0 && currentSize + upload.size > MAX_BATCH_BODY_SIZE) + ) { + batches.push(current); + current = []; + currentSize = 0; + } + current.push(upload); + currentSize += upload.size; + } + if (current.length > 0) { + batches.push(current); + } + return batches; +} + +/** + * Upload objects. Uploads up to {@link MAX_BATCH_PART_SIZE} are sent in batch + * requests, up to {@link BATCH_CONCURRENCY} at once; larger ones are PUT + * individually, up to {@link INDIVIDUAL_CONCURRENCY} at once. Both run in + * parallel. Each upload is read only when its request starts. + * + * @throws {ObjectstoreError} On a failed batch, or any failed insert. + */ +export async function putObjects( + config: ObjectstoreConfig, + uploads: ObjectUpload[], + options?: ObjectstoreOptions, +): Promise { + const batchable = uploads.filter((u) => u.size <= MAX_BATCH_PART_SIZE); + const individual = uploads.filter((u) => u.size > MAX_BATCH_PART_SIZE); + + const batchLimit = pLimit(BATCH_CONCURRENCY); + const individualLimit = pLimit(INDIVIDUAL_CONCURRENCY); + const limits = [batchLimit, individualLimit]; + await Promise.all([ + ...groupBatches(batchable).map((batch) => + batchLimit(stopOnFailure(limits, async () => { + // Read one upload at a time, so batches in flight hold at most + // BATCH_CONCURRENCY files open, not up to MAX_BATCH_OPS each. + const operations: BatchOperation[] = []; + for (const upload of batch) { + operations.push({ kind: "insert", key: upload.key, body: await upload.read() }); + } + const results = await sendBatch(config, operations, options); + results.forEach((result, i) => { + if (result.status >= 400) { + const key = (batch[i] as ObjectUpload).key; + throwOperationError(config, "Objectstore upload failed", key, result); + } + }); + })), + ), + ...individual.map((upload) => + individualLimit(stopOnFailure(limits, async () => { + await putObject(config, upload.key, await upload.read(), options); + })), + ), + ]); +} diff --git a/static/packages/api/lib/api-client/pagination.ts b/static/packages/api/lib/api-client/pagination.ts new file mode 100644 index 000000000000..ba78c164bf91 --- /dev/null +++ b/static/packages/api/lib/api-client/pagination.ts @@ -0,0 +1,337 @@ +/** + * Limit-bounded, resume-safe pagination over Sentry list endpoints. Pure + * logic with no I/O, so it runs in Node and Cloudflare Workers. Each product + * fetches pages, validates rows, logs, and maps failures to its own errors. + */ + +import { consumePage, paginateUpTo } from "@sentry/api"; + +/** + * Sentry API's maximum items per page. Some endpoints silently cap larger + * `per_page` values; others (org project list) return 400. + */ +export const API_MAX_PER_PAGE = 100; + +/** One fetched page: its rows and the server's next-page cursor. */ +export type Page = { + data: T; + nextCursor?: string; +}; + +/** + * A Sentry next-page cursor, `::`, split before + * `is_prev`, which may carry a skip count ({@link encodeSkip}). + */ +const NEXT_CURSOR_RE = /^(.*:\d+):(0(?:_?0)*)$/; + +/** Resumes at the first row for every paginator the products use. */ +const FIRST_PAGE_CURSOR = "0:0:0"; + +/** `OffsetPaginator` cursors are `::`. */ +const PAGE_NUMBER_CURSOR_RE = /^(\d+):\d+:[01]$/; + +/** + * Prefix of `cli1~~~` cursors, which nightly + * CLI builds returned. Read so their saved history still resumes; never + * written, because older CLIs send cursors to the API unchanged. + */ +const CLI_CURSOR_PREFIX = "cli1~"; + +/** + * Where a {@link paginate} walk resumes: the server cursor that fetches the + * page, the page size it must be fetched with, and how many of that page's + * rows were already returned. + */ +export type CursorPosition = { + /** Server cursor to request; undefined starts at the first page. */ + cursor: string | undefined; + /** Required page size in rows, when encoded in the cursor; otherwise unspecified. */ + perPage?: number; + /** Number of rows already returned from this position; zero starts at its first row. */ + skip: number; +}; + +/** Receives a message when a cursor is unreadable and the walk restarts. */ +type Warn = (message: string) => void; + +/** + * Write how many rows of a page were already returned into a cursor's + * `is_prev` field, so the cursor stays a valid Sentry cursor. + * + * Sentry parses `is_prev` with Python's `int()` (`Cursor.from_string` in + * `src/sentry/utils/cursors.py`). `int()` ignores leading zeros and allows a + * single `_` between digits (PEP 515), so any string of `0`s joined by + * single `_`s is 0: "next page". The skip count is written in binary in that + * alphabet: a leading `0`, then `_0` for each 1 bit and `0` for each 0 bit, + * most significant first. 82 is `1010010`, so `0` + `_0 0 _0 0 0 _0 0` + * gives `0_00_000_00`. + * + * The API and older CLIs read the cursor as the page's own cursor and return + * the page from its first row; {@link decodeSkip} recovers the count. + * + * @param skip - Rows of the page already returned; must be at least 1 + */ +function encodeSkip(skip: number): string { + return `0${skip.toString(2).replaceAll("1", "_0")}`; +} + +/** Read the skip count {@link encodeSkip} wrote; plain `0` is no skip. */ +function decodeSkip(isPrev: string): number { + return Number.parseInt(isPrev.slice(1).replaceAll("_0", "1"), 2) || 0; +} + +/** + * Encode where a walk resumes as a Sentry cursor. A server cursor that is not + * a Sentry next-page cursor cannot carry the skip, so it is returned as is and + * resuming returns that page's rows again rather than missing any. + */ +function encodePageCursor(cursor: string | undefined, skip: number): string { + const server = cursor ?? FIRST_PAGE_CURSOR; + const next = skip > 0 ? NEXT_CURSOR_RE.exec(server) : null; + return next ? `${next[1]}:${encodeSkip(skip)}` : server; +} + +/** + * Decode a cursor from {@link paginate}, an older CLI, or an API response. + * + * A damaged `cli1~` cursor is ignored with a warning and the walk starts from + * the first page, so it never fails a call or reaches the API. + */ +export function decodePageCursor( + raw: string | undefined, + warn: Warn = () => {}, +): CursorPosition { + if (!raw) { + return { cursor: undefined, skip: 0 }; + } + if (raw.startsWith(CLI_CURSOR_PREFIX)) { + return decodeCliCursor(raw, warn); + } + const next = NEXT_CURSOR_RE.exec(raw); + const cursor = next ? `${next[1]}:0` : raw; + const position = { + cursor: cursor === FIRST_PAGE_CURSOR ? undefined : cursor, + skip: next?.[2] ? decodeSkip(next[2]) : 0, + }; + const perPage = serverCursorPageSize(cursor); + return perPage === undefined ? position : { ...position, perPage }; +} + +function decodeCliCursor(raw: string, warn: Warn): CursorPosition { + const [perPage, skip, ...server] = raw + .slice(CLI_CURSOR_PREFIX.length) + .split("~"); + const position = { + perPage: Number(perPage), + skip: Number(skip), + cursor: server.join("~") || undefined, + }; + if ( + Number.isInteger(position.perPage) && + position.perPage >= 1 && + position.perPage <= API_MAX_PER_PAGE && + Number.isInteger(position.skip) && + position.skip >= 0 + ) { + return position; + } + warn( + "Ignoring an unreadable pagination cursor; starting from the first page.", + ); + return { cursor: undefined, skip: 0 }; +} + +/** + * Consume one fetched page from a decoded cursor position without making requests. + * + * Removes rows already delivered by a previous call. Sentry next-page cursors + * record any unconsumed rows or remaining skip; other cursor shapes are returned + * unchanged and may replay rows on resume. + * + * @param page - Server page fetched using the position's cursor and page size + * @param position - Decoded continuation for this page + * @param limit - Maximum rows to return, a non-negative integer; omitted means all remaining rows + * @returns Remaining rows and continuation metadata + */ +export function consumeCursorPage( + page: Page, + position: CursorPosition, + limit = Number.POSITIVE_INFINITY, +): Page { + const consumed = consumePage( + { data: page.data, nextCursor: page.nextCursor || undefined }, + { cursor: position.cursor, offset: position.skip }, + limit, + ); + return { + ...page, + data: consumed.data, + nextCursor: consumed.position + ? encodePageCursor(consumed.position.cursor, consumed.position.offset) + : undefined, + }; +} + +/** + * Page size a server cursor must be resumed with, if it carries one. + * + * `OffsetPaginator` cursors are only valid with the page size that produced + * them. Other paginators put an offset of 0 or a sort value there; a sort + * value that happens to be in range only changes the page size, which they + * tolerate. + */ +function serverCursorPageSize(cursor: string): number | undefined { + const value = Number(PAGE_NUMBER_CURSOR_RE.exec(cursor)?.[1]); + return Number.isInteger(value) && value >= 1 && value <= API_MAX_PER_PAGE + ? value + : undefined; +} + +/** + * Page size for a walk that starts fresh: the fewest requests that can hold + * `limit` rows, split as evenly as possible so the last request is not a + * full page for a few rows (201 rows → 3 pages of 67, not 100 + 100 + 100). + */ +function pageSizeForLimit(limit: number): number { + if (limit <= API_MAX_PER_PAGE) { + return Math.max(1, limit); + } + return Math.ceil(limit / Math.ceil(limit / API_MAX_PER_PAGE)); +} + +/** Options for {@link paginate}. */ +export type PaginateOptions = { + /** Most rows to return. */ + limit: number; + /** Cursor to resume from: one {@link paginate} returned, or a raw server cursor. */ + cursor?: string; + /** Most pages to fetch before stopping with a resume cursor. */ + maxPages: number; + /** Receives a message when `cursor` is unreadable and the walk restarts. */ + warn?: Warn; +}; + +/** Rows from a {@link paginate} walk and where to resume. */ +export type PaginateResult = { + data: T[]; + /** Resumes at the first row not returned; absent once the server is exhausted. */ + nextCursor?: string; + /** The walk hit `maxPages` before collecting `limit` rows. */ + stoppedAtPageCap: boolean; +}; + +/** + * Fetch up to `limit` rows and a cursor that resumes after the last one. + * + * - A fresh walk splits `limit` evenly over the fewest requests + * ({@link pageSizeForLimit}). Resuming a page-number cursor + * (`OffsetPaginator`, used by projects, teams, members, repositories, and + * dashboard revisions) reuses the page size the cursor encodes + * (`::`), whatever the new `limit`: pairing it with + * another `per_page` reads the wrong window and silently skips rows. + * - The returned cursor always resumes at the first row the caller has not + * received. When a walk stops inside a page (a smaller limit, or a server + * that ignored `per_page`), the cursor refetches that page and skips the + * rows already returned. + * + * Returned cursors are Sentry cursors, so older CLI versions and the API + * accept them too; mid-page cursors record the rows to skip in `is_prev` + * ({@link encodeSkip}), which a caller that does not know about them sees + * again rather than misses (given the page size a page-number cursor + * encodes). Pass cursors back through this function to resume exactly. + * + * Callers own region resolution and query building inside `fetchPage`, and + * must send `perPage` as the request's `per_page` unchanged. + * + * @param options - Limit, start cursor, page cap, and warning sink + * @param fetchPage - Fetches one page given the page size and a server cursor + */ +export async function paginate( + options: PaginateOptions, + fetchPage: ( + perPage: number, + cursor: string | undefined, + ) => Promise>, +): Promise> { + const { limit, maxPages, warn = () => {} } = options; + const position = decodePageCursor(options.cursor, warn); + // Keep the CLI's page sizing and legacy bookmarks while the SDK owns traversal. + const perPage = position.perPage ?? pageSizeForLimit(limit + position.skip); + const result = await paginateUpTo( + async (cursor) => { + const page = await fetchPage(perPage, cursor); + return { data: page.data, nextCursor: page.nextCursor || undefined }; + }, + { + limit, + maxPages, + resume: { + cursor: position.cursor, + offset: position.skip, + requestBudget: limit + position.skip, + }, + }, + "paginate", + ); + return { + data: result.data, + ...(result.continuation + ? { + nextCursor: encodePageCursor( + result.continuation.cursor, + result.continuation.offset, + ), + } + : {}), + stoppedAtPageCap: result.stopReason === "maxPages", + }; +} + +/** Why {@link fetchAllPages} could not return every row. */ +export type FetchAllPagesFailure = + | "invalid-page" + | "repeated-cursor" + | "page-cap"; + +/** Every row from {@link fetchAllPages}, or why the walk stopped. */ +export type FetchAllPagesResult = + | { ok: true; items: T[] } + | { ok: false; reason: FetchAllPagesFailure }; + +/** + * Fetch and validate every page of a list endpoint, or report why not. + * + * A partial result is a failure: use this when a missing page could hide the + * record a mutation depends on. Fails on an invalid page, a repeated cursor, + * or more than `maxPages` pages. + * + * @param fetchPage - Fetches one page given a cursor + * @param parse - Returns a page's validated rows, or `undefined` when invalid + * @param maxPages - Most pages to fetch + */ +export async function fetchAllPages( + fetchPage: (cursor: string | undefined) => Promise>, + parse: (data: unknown) => T[] | undefined, + maxPages: number, +): Promise> { + const items: T[] = []; + const seen = new Set(); + let cursor: string | undefined; + for (let page = 0; page < maxPages; page += 1) { + const { data, nextCursor } = await fetchPage(cursor); + const rows = parse(data); + if (!rows) { + return { ok: false, reason: "invalid-page" }; + } + items.push(...rows); + if (!nextCursor) { + return { ok: true, items }; + } + if (seen.has(nextCursor)) { + return { ok: false, reason: "repeated-cursor" }; + } + seen.add(nextCursor); + cursor = nextCursor; + } + return { ok: false, reason: "page-cap" }; +} diff --git a/static/packages/api/lib/api-client/response-cache/body.ts b/static/packages/api/lib/api-client/response-cache/body.ts new file mode 100644 index 000000000000..417f002086b9 --- /dev/null +++ b/static/packages/api/lib/api-client/response-cache/body.ts @@ -0,0 +1,34 @@ +/** + * The shape of a stored response body and of the codec that produces it. + * Hosts implement the codec, since compression needs host APIs such as + * `node:zlib`, so the cache policy and stores stay portable. + */ + +/** How an entry's body bytes are encoded. */ +export type BodyEncoding = "zstd" | "br" | "identity"; + +/** A body as stored: its bytes plus what is needed to decode them. */ +export type EncodedBody = { + bytes: Uint8Array; + encoding: BodyEncoding; + /** + * Compression dictionary the bytes need. Never set today; recorded so a + * later dictionary rollout can tell its entries apart from plain ones. + */ + dictId?: string; + /** Size of the original body, which bounds decompression. */ + rawSize: number; +}; + +/** Encodes and decodes cached bodies. */ +export type BodyCodec = { + /** Compress a body for storage. */ + encode(raw: Uint8Array): Promise; + /** + * Restore a stored body. Returns `undefined` when this runtime cannot read + * the entry (zstd on Node < 22.15, or a dictionary it does not have), so the + * caller treats it as a miss. Throws when the bytes are corrupt or do not + * decode to `rawSize` bytes. + */ + decode(body: EncodedBody): Promise; +}; diff --git a/static/packages/api/lib/api-client/response-cache/index.ts b/static/packages/api/lib/api-client/response-cache/index.ts new file mode 100644 index 000000000000..8239800e3e65 --- /dev/null +++ b/static/packages/api/lib/api-client/response-cache/index.ts @@ -0,0 +1,26 @@ +/** + * Response cache shared by the CLI and MCP: fallback TTL tiers, + * identity-scoped keys, mutation invalidation prefixes, HTTP freshness and + * revalidation, the `CacheStore` interface, and a byte-budgeted memory + * store. No Node APIs or I/O; hosts with persistent storage implement + * `CacheStore` themselves. + */ + +export type { BodyCodec, BodyEncoding, EncodedBody } from "./body.js"; +export { computeInvalidationPrefixes } from "./invalidation.js"; +export { buildCacheKey, normalizeUrl } from "./keys.js"; +export { + createMemoryStore, + type MemoryStore, + type MemoryStoreOptions, +} from "./memory-store.js"; +export { + type CacheLookup, + type LookupOptions, + lookupResponse, + REVALIDATION_WINDOW_MS, + type StoreOptions, + storeResponse, +} from "./policy.js"; +export type { CacheEntry, CacheStore } from "./store.js"; +export { classifyUrl, FALLBACK_TTL_MS, type TtlTier } from "./tiers.js"; diff --git a/static/packages/api/lib/api-client/response-cache/invalidation.ts b/static/packages/api/lib/api-client/response-cache/invalidation.ts new file mode 100644 index 000000000000..074cefdd00d1 --- /dev/null +++ b/static/packages/api/lib/api-client/response-cache/invalidation.ts @@ -0,0 +1,186 @@ +/** + * Compute cache-invalidation prefixes for a mutation URL. + * + * The host's HTTP layer calls {@link computeInvalidationPrefixes} after + * every successful non-GET request and deletes the requesting identity's + * cached entries whose URL starts with any returned prefix. Two rules + * apply: + * + * 1. **Hierarchy walk.** Sweep the URL's own path and every ancestor + * up to `/api/0/`. A mutation on + * `/organizations/{org}/releases/1.0.0/deploys/` sweeps itself, + * `.../releases/1.0.0/`, and `.../releases/` — which catches the + * detail, deploys-list, and releases-list GET caches in one pass. + * + * 2. **Cross-endpoint rules.** A small hardcoded table for mutations + * whose effects cross URL trees. For example, creating a project + * under a team hits `/organizations/{org}/teams/{team}/projects/` + * but invalidates the org project list at + * `/organizations/{org}/projects/`. The table is tiny today + * (2 rules) and only grows when a new cross-tree relationship + * appears in the API surface. + * + * Prefixes are identity-scoped at the sweep layer (the store checks each + * entry's identity before deleting it), so a slightly broader sweep is safe — it can only touch the current + * identity's entries. Query strings on the mutation URL are dropped + * from the prefix (a prefix sweep on the path naturally catches every + * query-param variant cached under that path). + */ + +const API_V0_SEGMENT = "/api/0/"; +const TRAILING_SLASH_RE = /\/$/; + +/** + * Paths where a mutation doesn't change any cacheable state — typically + * write-only endpoints like chunk uploads and bundle assembly. Invalidation + * on these would pointlessly sweep the org's cache on every chunk of a + * sourcemap upload. + */ +const SKIP_INVALIDATION_PATTERNS: readonly RegExp[] = [ + // Sourcemap chunk upload + bundle assemble. Both are write-only in the + // sense that no GET endpoint reads cacheable state under these paths. + /\/chunk-upload\//, + /\/artifactbundle\/assemble\//, +]; + +/** + * Rule for mutations whose effects cross URL trees. Patterns match + * the path relative to `/api/0/`. `extra` returns additional + * path-prefixes (swept under the mutation's own origin); `extraAbsolute` + * returns absolute URL prefixes (when invalidation must cross origins, + * e.g. region-scoped mutation clearing a cache under the control silo). + */ +type CrossEndpointRule = { + match: RegExp; + extra?: (matchGroups: RegExpMatchArray) => string[]; + extraAbsolute?: ( + matchGroups: RegExpMatchArray, + ctx: { apiBaseUrl: string }, + ) => string[]; +}; + +const CROSS_ENDPOINT_RULES: CrossEndpointRule[] = [ + // `POST teams/{org}/{team}/projects/` (create project in team) also + // invalidates the org project list at `organizations/{org}/projects/`. + { + match: /^teams\/([^/]+)\/[^/]+\/projects\/?$/, + extra: ([, org]) => [`organizations/${org}/projects/`], + }, + // `DELETE projects/{org}/{project}/` also invalidates the org project list. + { + match: /^projects\/([^/]+)\/[^/]+\/?$/, + extra: ([, org]) => [`organizations/${org}/projects/`], + }, + // Org-scoped issue mutations at `organizations/{org}/issues/{id}/` + // also affect the legacy global endpoint at `issues/{id}/`, which + // `getIssue()` hits under the control-silo base URL (potentially a + // DIFFERENT origin than the org's region URL). Must clear the + // legacy cache too, or subsequent `getIssue()` calls serve stale + // data. + { + match: /^organizations\/[^/]+\/issues\/([^/]+)\/?$/, + extraAbsolute: ([, issueId], { apiBaseUrl }) => [ + `${apiBaseUrl.replace(TRAILING_SLASH_RE, "")}/api/0/issues/${issueId}/`, + ], + }, +]; + +/** + * Compute the full set of cache-invalidation prefixes for a mutation + * URL. + * + * @param fullUrl - Fully-qualified URL of the mutation (absolute, + * including base). Query string is ignored. + * @param apiBaseUrl - The host's non-region API base URL (used by rules + * that need to clear caches under a different origin — e.g. the + * legacy `/issues/{id}/` endpoint the CLI's `getIssue()` reads). + * @returns Full-URL prefixes to sweep. Returns `[]` if the URL is + * not under `/api/0/` (e.g. sourcemap chunk upload to an arbitrary + * endpoint) or can't be parsed. + */ +export function computeInvalidationPrefixes( + fullUrl: string, + apiBaseUrl: string, +): string[] { + let parsed: URL; + try { + parsed = new URL(fullUrl); + } catch { + // Not a URL, so nothing cached under it. + return []; + } + + const apiIdx = parsed.pathname.indexOf(API_V0_SEGMENT); + if (apiIdx === -1) { + return []; + } + + if (SKIP_INVALIDATION_PATTERNS.some((p) => p.test(parsed.pathname))) { + return []; + } + + const base = `${parsed.origin}${parsed.pathname.slice(0, apiIdx + API_V0_SEGMENT.length)}`; + const relPath = parsed.pathname.slice(apiIdx + API_V0_SEGMENT.length); + if (relPath === "") { + return []; + } + + const prefixes = new Set(); + for (const segments of ancestorSegments(relPath)) { + prefixes.add(`${base}${segments}`); + } + for (const extra of applyCrossEndpointRules(relPath, base, apiBaseUrl)) { + prefixes.add(extra); + } + return [...prefixes]; +} + +/** Apply the cross-endpoint rule table, yielding absolute prefix URLs. */ +function* applyCrossEndpointRules( + relPath: string, + base: string, + apiBaseUrl: string, +): Generator { + for (const rule of CROSS_ENDPOINT_RULES) { + const match = relPath.match(rule.match); + if (!match) { + continue; + } + for (const extra of rule.extra?.(match) ?? []) { + yield `${base}${extra}`; + } + for (const absolute of rule.extraAbsolute?.(match, { apiBaseUrl }) ?? []) { + yield absolute; + } + } +} + +/** + * Yield every path-prefix sequence of `relPath` in descending length, + * stopping at the "resource owner" level (typically `{root}/{owner}/`, + * e.g. `organizations/acme/`). The bare `organizations/` root is + * deliberately omitted — sweeping it on every mutation would evict + * unrelated cross-org caches, since a mutation under one org cannot + * invalidate another org's state. + * + * `"organizations/acme/releases/1.0.0/deploys/"` yields: + * - `"organizations/acme/releases/1.0.0/deploys/"` + * - `"organizations/acme/releases/1.0.0/"` + * - `"organizations/acme/releases/"` + * - `"organizations/acme/"` + * + * Single-segment paths (e.g. `"organizations/"`) still yield themselves + * — a mutation at the resource-owner root is rare but the sweep should + * still clear its cache. + */ +function* ancestorSegments(relPath: string): Generator { + const trimmed = relPath.endsWith("/") ? relPath.slice(0, -1) : relPath; + if (trimmed === "") { + return; + } + const parts = trimmed.split("/"); + const floor = parts.length > 2 ? 2 : 1; + for (let i = parts.length; i >= floor; i--) { + yield `${parts.slice(0, i).join("/")}/`; + } +} diff --git a/static/packages/api/lib/api-client/response-cache/keys.ts b/static/packages/api/lib/api-client/response-cache/keys.ts new file mode 100644 index 000000000000..be81411b7db4 --- /dev/null +++ b/static/packages/api/lib/api-client/response-cache/keys.ts @@ -0,0 +1,68 @@ +/** + * Identity-scoped cache keys for Sentry API responses. Uses WebCrypto, so it + * runs in Node, Cloudflare Workers, and browsers. + */ + +const encoder = new TextEncoder(); + +/** + * Normalize method + URL into a stable string for cache key derivation. + * Keeps the host, so the same path on two Sentry servers gets two keys, and + * sorts query params by name so `?a=1&b=2` and `?b=2&a=1` match. + * + * @throws {TypeError} If the URL cannot be parsed + */ +export function normalizeUrl(method: string, url: string): string { + const parsed = new URL(url); + const sortedParams = new URLSearchParams( + [...parsed.searchParams.entries()].sort(([a], [b]) => { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; + }), + ); + parsed.search = sortedParams.toString() ? `?${sortedParams.toString()}` : ""; + return `${method.toUpperCase()}|${parsed.toString()}`; +} + +/** + * Build a cache key from the requesting identity, method, and URL: the + * hex HMAC-SHA256 of {@link normalizeUrl} keyed by `identity`. + * + * `identity` scopes entries so one user never receives another's response. + * It must be a stable hash of who is asking, never a raw token, and should + * not change when an access token is refreshed for the same login. Keys + * match `node:crypto`'s `createHmac("sha256", identity)`, so entries written + * before this module moved here are still found. + * + * @throws {TypeError} If `identity` is empty or the URL cannot be parsed + */ +export async function buildCacheKey( + method: string, + url: string, + identity: string, +): Promise { + if (!identity) { + throw new TypeError("A cache key needs a non-empty identity"); + } + const normalized = normalizeUrl(method, url); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(identity), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(normalized), + ); + return Array.from(new Uint8Array(signature), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} diff --git a/static/packages/api/lib/api-client/response-cache/memory-store.ts b/static/packages/api/lib/api-client/response-cache/memory-store.ts new file mode 100644 index 000000000000..5a489c3d9b68 --- /dev/null +++ b/static/packages/api/lib/api-client/response-cache/memory-store.ts @@ -0,0 +1,141 @@ +/** + * In-memory {@link CacheStore} with a byte budget, for the hosted MCP and + * tests. Evicts least recently used entries first. Holds no timers: expired + * entries are dropped when read, or evicted like any other entry. + */ + +import type { CacheEntry, CacheStore } from "./store.js"; + +const MIB = 1024 * 1024; + +/** + * Bytes charged per entry on top of its body, for the key, URL, headers, + * serialized policy, and bookkeeping. An estimate, so that many small + * entries (bodies under 256 bytes are stored raw) cannot outgrow the budget. + */ +export const ENTRY_OVERHEAD_BYTES = 1024; + +export type MemoryStoreOptions = { + /** Budget over all entries, in bytes. Defaults to 24 MiB. */ + maxBytes?: number; + /** Largest stored (compressed) body, in bytes. Defaults to 1 MiB. */ + maxEntryBytes?: number; + /** + * Largest original body, in bytes, which bounds memory while decoding. + * Defaults to 32 MiB. + */ + maxRawBytes?: number; +}; + +export type MemoryStore = CacheStore & { + /** Current entry count and bytes charged against the budget. */ + stats(): { entries: number; bytes: number }; +}; + +type Slot = { entry: CacheEntry; size: number; expiresAt: number }; + +function limit(name: string, value: number | undefined, fallback: number) { + if (value === undefined) { + return fallback; + } + if (!(Number.isFinite(value) && value > 0)) { + throw new RangeError(`${name} must be a positive number, got ${value}`); + } + return value; +} + +/** + * Copy `bytes` when it is a view into a larger buffer, so the store never + * keeps memory it does not count. Copies with the `Uint8Array` constructor, + * because `Buffer#slice` returns a view that shares the original memory. + */ +function ownBytes(bytes: Uint8Array): Uint8Array { + return bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength + ? bytes + : new Uint8Array(bytes); +} + +/** + * Create an empty memory store. Entries are kept by reference; callers must + * not mutate an entry after storing it or one returned by `get`. + * + * @throws {RangeError} If a limit is not a positive number + */ +export function createMemoryStore( + options: MemoryStoreOptions = {}, +): MemoryStore { + const maxBytes = limit("maxBytes", options.maxBytes, 24 * MIB); + const maxEntryBytes = limit("maxEntryBytes", options.maxEntryBytes, MIB); + const maxRawBytes = limit("maxRawBytes", options.maxRawBytes, 32 * MIB); + + // Map order is the LRU order: the first key is the least recently used. + const slots = new Map(); + let bytes = 0; + + function remove(key: string): void { + const slot = slots.get(key); + if (slot) { + slots.delete(key); + bytes -= slot.size; + } + } + + return { + async get(key) { + const slot = slots.get(key); + if (!slot) { + return; + } + if (Date.now() >= slot.expiresAt) { + remove(key); + return; + } + slots.delete(key); + slots.set(key, slot); + return slot.entry; + }, + + async set(key, entry, ttlMs) { + remove(key); + const stored = entry.body.bytes.byteLength; + const size = stored + ENTRY_OVERHEAD_BYTES; + if ( + !(ttlMs > 0) || + stored > maxEntryBytes || + entry.body.rawSize > maxRawBytes || + size > maxBytes + ) { + return; + } + const body = { ...entry.body, bytes: ownBytes(entry.body.bytes) }; + slots.set(key, { + entry: { ...entry, body }, + size, + expiresAt: Date.now() + ttlMs, + }); + bytes += size; + // The new entry is last and fits on its own, so this stops before it. + for (const oldest of slots.keys()) { + if (bytes <= maxBytes) { + break; + } + remove(oldest); + } + }, + + async deleteMatching(identity, urlPrefix) { + for (const [key, slot] of slots) { + if ( + slot.entry.identity === identity && + slot.entry.url.startsWith(urlPrefix) + ) { + remove(key); + } + } + }, + + stats() { + return { entries: slots.size, bytes }; + }, + }; +} diff --git a/static/packages/api/lib/api-client/response-cache/policy.ts b/static/packages/api/lib/api-client/response-cache/policy.ts new file mode 100644 index 000000000000..6a4986f96a52 --- /dev/null +++ b/static/packages/api/lib/api-client/response-cache/policy.ts @@ -0,0 +1,354 @@ +/** + * HTTP caching decisions for Sentry API GET responses, through + * `http-cache-semantics` (RFC 9111). Server cache directives win; a response + * without them stays fresh for its URL's fallback tier. A stale entry with an + * `ETag` is kept for a revalidation window and renewed by a matching 304. + * + * Works on any `CacheStore` and `BodyCodec` the host passes in, with no Node + * APIs. The host derives the key, decides whether caching is enabled, and + * records telemetry around these calls. + */ + +import CachePolicy from "http-cache-semantics"; +import type { BodyCodec } from "./body.js"; +import type { CacheEntry, CacheStore } from "./store.js"; +import { classifyUrl, FALLBACK_TTL_MS } from "./tiers.js"; + +/** + * How long a stale entry with an `ETag` is kept for revalidation. Bounded + * by the store's byte budget like any other entry. + */ +export const REVALIDATION_WINDOW_MS = 24 * 60 * 60 * 1000; + +/** + * Policy options for a cache scoped to one identity: keys already separate + * users, so `private` responses are storable. + */ +const POLICY_OPTIONS: CachePolicy.Options = { + shared: false, + cacheHeuristic: 0.1, +}; + +/** Response headers kept for callers, such as `Link` for pagination. */ +const PRESERVED_HEADERS = ["link"]; + +const encoder = new TextEncoder(); + +/** + * Result of a lookup that found a usable entry. + * + * - `hit`: the entry is fresh; serve `response`. `createdAt` is when the + * response was received, and `size` is the decoded body size in bytes. + * - `stale`: the entry is stale but has an `ETag`. Send the request with + * `conditionalHeaders`, then pass the network response to `revalidate`. + * On a matching 304 it renews the entry and returns the cached body; + * otherwise it returns `undefined` and the network response stands (or, + * for an unusable 304, the caller fetches again without the headers). + */ +export type CacheLookup = + | { kind: "hit"; response: Response; createdAt: number; size: number } + | { + kind: "stale"; + conditionalHeaders: Record; + revalidate(response: Response): Promise; + }; + +export type LookupOptions = { + store: CacheStore; + codec: BodyCodec; + /** From `buildCacheKey`, so the entry is scoped to the identity. */ + key: string; + url: string; + /** Headers of the new request, for `Vary` matching. */ + requestHeaders: Record; + /** Called when renewing a revalidated entry fails; the response stands. */ + onRenewError?: (error: unknown) => void; +}; + +export type StoreOptions = { + store: CacheStore; + codec: BodyCodec; + /** From `buildCacheKey` for the same identity, method, and URL. */ + key: string; + /** Recorded on the entry for identity-scoped invalidation. */ + identity: string; + url: string; + /** Headers the request was sent with, for `Vary` matching. */ + requestHeaders: Record; + /** The response to cache; its body is consumed. */ + response: Response; +}; + +/** A GET request as `http-cache-semantics` reads it. */ +type GetRequest = { + url: string; + method: "GET"; + headers: Record; +}; + +type Revalidation = CachePolicy.RevalidationPolicy & { + /** Missing from the published types. */ + matches?: boolean; +}; + +/** Keep only the response headers callers need. */ +function pickHeaders(headers: Headers): Record { + const result: Record = {}; + for (const name of PRESERVED_HEADERS) { + const value = headers.get(name); + if (value) { + result[name] = value; + } + } + return result; +} + +/** + * Request headers as the policy records them, with `authorization` replaced + * by its SHA-256 digest. `CachePolicy#toObject()` keeps request headers when + * the response has `Vary`, and a store must never hold the token. Lookup and + * store derive the value the same way, so `Vary: Authorization` still tells + * tokens apart. + */ +async function policyRequestHeaders( + headers: Record, +): Promise> { + const result: Record = {}; + for (const [name, value] of Object.entries(headers)) { + result[name] = + name.toLowerCase() === "authorization" && value + ? await sha256Hex(value) + : value; + } + return result; +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +function headersToObject(headers: Headers): Record { + return Object.fromEntries(headers.entries()); +} + +/** + * Whether the server sent `Cache-Control` directives. `timeToLive() === 0` + * alone is ambiguous: it means both "no headers" (use the fallback tier) + * and "max-age=0" (do not reuse). + */ +function hasServerCacheDirectives(policy: CachePolicy): boolean { + return Object.keys(policy.toObject().rescc).length > 0; +} + +/** + * Whether `entry` can be served without revalidation: by the server's + * directives when it sent any, otherwise by the URL's fallback tier. + */ +function isEntryFresh( + policy: CachePolicy, + entry: CacheEntry, + request: GetRequest, +): boolean { + if (policy.satisfiesWithoutRevalidation(request)) { + return true; + } + if (hasServerCacheDirectives(policy)) { + return false; + } + const fallbackTtl = FALLBACK_TTL_MS[classifyUrl(request.url)]; + return Date.now() - entry.createdAt <= fallbackTtl; +} + +/** + * How long a store keeps an entry: its freshness lifetime (the server's when + * it sent cache directives, otherwise the URL tier's), plus + * {@link REVALIDATION_WINDOW_MS} when it has an `ETag`. + */ +function retentionMs(policy: CachePolicy, tier: CacheEntry["tier"]): number { + const freshMs = hasServerCacheDirectives(policy) + ? policy.timeToLive() + : FALLBACK_TTL_MS[tier]; + return policy.responseHeaders().etag === undefined + ? freshMs + : freshMs + REVALIDATION_WINDOW_MS; +} + +/** + * Headers for a response served from `entry`: the policy's computed headers, + * with multiple values joined, plus the preserved headers it lacks. + */ +function buildResponseHeaders( + policy: CachePolicy, + entry: CacheEntry, +): Record { + const result: Record = {}; + for (const [name, value] of Object.entries(policy.responseHeaders())) { + if (value !== undefined) { + result[name] = Array.isArray(value) ? value.join(", ") : value; + } + } + for (const [name, value] of Object.entries(entry.headers)) { + if (!(name in result)) { + result[name] = value; + } + } + return result; +} + +/** + * A decoded body as a `Response` body. TypeScript 5.9's DOM `BodyInit` + * takes only `ArrayBuffer`-backed views; stored bodies never sit on a + * `SharedArrayBuffer`. + */ +function responseBody(bytes: Uint8Array): Uint8Array { + return bytes as Uint8Array; +} + +/** A `stale` lookup for an entry with an `ETag`, or `undefined` without one. */ +function staleLookup( + options: LookupOptions, + entry: CacheEntry, + policy: CachePolicy, + request: GetRequest, +): CacheLookup | undefined { + const etag = policy.revalidationHeaders(request)["if-none-match"]; + if (typeof etag !== "string") { + return; + } + const { store, codec, key, onRenewError } = options; + + return { + kind: "stale", + conditionalHeaders: { "if-none-match": etag }, + async revalidate(response) { + if (response.status !== 304) { + return; + } + const result: Revalidation = policy.revalidatedPolicy(request, { + status: response.status, + headers: headersToObject(response.headers), + }); + // A 304 for a different or missing validator says nothing about + // this entry, so its body cannot be trusted. + if (result.modified || result.matches === false) { + return; + } + const body = await codec.decode(entry.body); + if (!body) { + return; + } + + const renewed = { + ...entry, + policy: result.policy.toObject(), + createdAt: Date.now(), + }; + store + .set(key, renewed, retentionMs(result.policy, entry.tier)) + .catch((error: unknown) => onRenewError?.(error)); + return new Response(responseBody(body), { + status: entry.status, + headers: buildResponseHeaders(result.policy, entry), + }); + }, + }; +} + +/** + * Look up a GET request: a fresh entry is a `hit`, and a stale one with an + * `ETag` is returned for conditional revalidation. + * + * @returns The lookup, or `undefined` on a miss: no entry, a stale entry + * without an `ETag`, or a body this runtime cannot decode + * @throws When the stored policy or body is corrupt; the host may drop the + * entry and treat it as a miss + */ +export async function lookupResponse( + options: LookupOptions, +): Promise { + const { store, codec, key, url, requestHeaders } = options; + const entry = await store.get(key); + if (!entry) { + return; + } + + const policy = CachePolicy.fromObject( + entry.policy as CachePolicy.CachePolicyObject, + ); + const request: GetRequest = { + url, + method: "GET", + headers: await policyRequestHeaders(requestHeaders), + }; + if (!isEntryFresh(policy, entry, request)) { + return staleLookup(options, entry, policy, request); + } + + const body = await codec.decode(entry.body); + if (!body) { + return; + } + return { + kind: "hit", + response: new Response(responseBody(body), { + status: entry.status, + headers: buildResponseHeaders(policy, entry), + }), + createdAt: entry.createdAt, + size: body.byteLength, + }; +} + +/** + * Store a successful GET response when HTTP caching allows it: not for + * `no-store`, a non-2xx status, or a `no-cache` tier URL. The entry is kept + * for its freshness lifetime, plus the revalidation window with an `ETag`. + * + * @returns The stored (encoded) body size in bytes, or 0 when not stored + * @throws When the body is not JSON, the only kind cached + */ +export async function storeResponse(options: StoreOptions): Promise { + const { store, codec, key, identity, url, requestHeaders, response } = + options; + const tier = classifyUrl(url); + if (!response.ok || tier === "no-cache") { + return 0; + } + + const policy = new CachePolicy( + { + url, + method: "GET", + headers: await policyRequestHeaders(requestHeaders), + }, + { status: response.status, headers: headersToObject(response.headers) }, + POLICY_OPTIONS, + ); + if (!policy.storable()) { + return 0; + } + + const text = await response.text(); + // Only JSON is cached; this throws for anything else. + JSON.parse(text); + const createdAt = Date.now(); + const body = await codec.encode(encoder.encode(text)); + await store.set( + key, + { + url, + identity, + tier, + status: response.status, + headers: pickHeaders(response.headers), + body, + policy: policy.toObject(), + createdAt, + }, + retentionMs(policy, tier), + ); + return body.bytes.byteLength; +} diff --git a/static/packages/api/lib/api-client/response-cache/store.ts b/static/packages/api/lib/api-client/response-cache/store.ts new file mode 100644 index 000000000000..d196e42024dc --- /dev/null +++ b/static/packages/api/lib/api-client/response-cache/store.ts @@ -0,0 +1,55 @@ +/** + * Storage interface for cached Sentry API responses. Backends (memory, the + * CLI's filesystem, R2) store entries whose bodies are already encoded by + * the host's `BodyCodec`, and each enforces its own byte budget. + */ + +import type { EncodedBody } from "./body.js"; +import type { TtlTier } from "./tiers.js"; + +/** A cached response as a store holds it. */ +export type CacheEntry = { + /** Full request URL, matched by {@link CacheStore.deleteMatching}. */ + url: string; + /** Hash of the login that owns the entry; never a raw token. */ + identity: string; + /** Fallback tier the URL was classified into. */ + tier: TtlTier; + /** HTTP status of the cached response. */ + status: number; + /** Response headers kept for callers, such as `Link` for pagination. */ + headers: Readonly>; + /** The response body, encoded by the codec. */ + body: EncodedBody; + /** + * The serialized `http-cache-semantics` policy. Stores keep it as is and + * never read it; `lookupResponse` uses it to decide freshness and + * revalidation. + */ + policy: unknown; + /** When the response was received (epoch ms). */ + createdAt: number; +}; + +/** + * Where cached responses live. Keys come from `buildCacheKey`, so they are + * already scoped to an identity. + */ +export type CacheStore = { + /** The entry under `key`, or `undefined` if it is missing or expired. */ + get(key: string): Promise; + /** + * Store `entry` under `key` for `ttlMs`, replacing any entry already + * there. `ttlMs` is how long the store keeps the entry, not how long it is + * fresh: a stale entry with an `ETag` is kept so it can be revalidated. An + * entry over the store's limits is not stored, and the one it would have + * replaced is removed, so a later read cannot return the older response. + */ + set(key: string, entry: CacheEntry, ttlMs: number): Promise; + /** + * Remove `identity`'s entries whose URL starts with `urlPrefix`, as + * returned by `computeInvalidationPrefixes`. Other identities' entries + * are kept. + */ + deleteMatching(identity: string, urlPrefix: string): Promise; +}; diff --git a/static/packages/api/lib/api-client/response-cache/tiers.ts b/static/packages/api/lib/api-client/response-cache/tiers.ts new file mode 100644 index 000000000000..4ad9b72a7f3a --- /dev/null +++ b/static/packages/api/lib/api-client/response-cache/tiers.ts @@ -0,0 +1,72 @@ +/** + * Fallback TTL tiers for Sentry API GET responses that carry no HTTP caching + * headers. Responses with `Cache-Control`, `Expires`, or validators follow + * those headers instead; the host's cache decides which applies. + */ + +/** + * TTL tier for a URL. + * + * - `stable`: data that changes infrequently (orgs, projects, teams, + * organization event details, trace-item details) + * - `volatile`: data that changes often (issues and their events, project + * event details, full traces, log and span queries) + * - `no-cache`: never cache (polling endpoints like autofix state) + * + * No tier treats a response as immutable. Event payloads embed mutable + * fields (`nextEventID`, user reports, release data), `latest`/`oldest`/ + * `recommended` resolve to different events over time, and traces gain + * spans as they arrive (getsentry/sentry#128818). + */ +export type TtlTier = "stable" | "volatile" | "no-cache"; + +/** Fallback TTL durations by tier (milliseconds). `no-cache` uses 0 as a sentinel. */ +export const FALLBACK_TTL_MS: Readonly> = { + stable: 5 * 60 * 1000, + volatile: 60 * 1000, + "no-cache": 0, +}; + +/** + * URL patterns grouped by TTL tier, checked in priority order + * (no-cache → volatile). `stable` is the default when nothing matches. + * + * TODO: Classify by generated operation metadata from `@sentry/api` instead + * of URL regexes, once it ships per-operation metadata + * (getsentry/sentry-api-schema#81) and a route-keyed operation map + * (getsentry/sentry-api-schema#102). + */ +const URL_TIER_PATTERNS: readonly [TtlTier, readonly RegExp[]][] = [ + // Polling endpoints where state changes rapidly + ["no-cache", [/\/(?:autofix|root-cause)\//]], + [ + "volatile", + [ + // Issue endpoints: lists, detail views, and issue events such as + // `/issues/{id}/events/latest/` + /\/issues\//, + /[?&]dataset=(?:logs|spans)/, + /\/trace-logs\//, + // Full traces gain spans as they arrive + /\/trace\/[0-9a-f]{32}\//, + // Project event details embed user reports (and `nextEventID`/ + // `previousEventID` unless collapsed), and reprocessing can change them + // for the same ID + /\/projects\/[^/]+\/[^/]+\/events\/[^/?]+\/?(?:\?|$)/, + ], + ], +]; + +/** + * Classify a URL into a TTL tier for fallback caching. + * + * @param url - Full URL string (with query params) + */ +export function classifyUrl(url: string): TtlTier { + for (const [tier, patterns] of URL_TIER_PATTERNS) { + if (patterns.some((pattern) => pattern.test(url))) { + return tier; + } + } + return "stable"; +} diff --git a/static/packages/api/lib/api-client/response.ts b/static/packages/api/lib/api-client/response.ts new file mode 100644 index 000000000000..fcf4cf363b7e --- /dev/null +++ b/static/packages/api/lib/api-client/response.ts @@ -0,0 +1,98 @@ +/** + * Reading Sentry API responses. Hosts map what these return to their own + * errors: the MCP to `ApiError` subclasses, the CLI to its exit-coded ones. + */ + +import { parseRetryDelayMs } from "./retry.js"; + +/** What a failed response's body says, read once. */ +export type ErrorResponseBody = { + /** The raw body, or undefined when it could not be read. */ + text: string | undefined; + /** The parsed body, when it is JSON. */ + json?: unknown; + /** Why the body is not JSON, when it is not. */ + parseError?: string; + /** The server's `detail` message, when the JSON body has a string one. */ + detail?: string; + /** True when the body is an HTML page, usually from a proxy or a wrong URL. */ + isHtml: boolean; + /** Server-requested wait before retrying, from the rate-limit headers. */ + retryAfterMs?: number; +}; + +function readDetail(json: unknown): string | undefined { + if (json === null || typeof json !== "object" || !("detail" in json)) { + return undefined; + } + return typeof json.detail === "string" ? json.detail : undefined; +} + +function looksLikeHtml(text: string): boolean { + return text.includes(" { + const retryAfterMs = parseRetryDelayMs(response.status, response.headers); + let text: string; + try { + text = await response.text(); + } catch { + return { text: undefined, isHtml: false, retryAfterMs }; + } + try { + const json: unknown = JSON.parse(text); + return { + text, + json, + detail: readDetail(json), + isHtml: false, + retryAfterMs, + }; + } catch (error) { + return { + text, + parseError: error instanceof Error ? error.message : String(error), + isHtml: looksLikeHtml(text), + retryAfterMs, + }; + } +} + +/** + * Parse a successful response as JSON, checking its Content-Type first. + * + * @throws {Error} When the body is HTML, another content type, or malformed + */ +export async function readJsonResponse(response: Response): Promise { + // Test doubles and some promise chains pass responses without headers. + if (!response.headers?.get) { + return await response.json(); + } + + const contentType = response.headers.get("content-type"); + if (!contentType?.includes("application/json")) { + const text = await response.text(); + if (contentType?.includes("text/html") || looksLikeHtml(text)) { + // HTML in place of JSON usually means auth or routing went wrong. + throw new Error( + `Expected JSON response but received HTML (${response.status} ${response.statusText}). This may indicate you're not authenticated, the URL is incorrect, or there's a server issue.`, + ); + } + throw new Error( + `Expected JSON response but received ${contentType || "unknown content type"} ` + + `(${response.status} ${response.statusText})`, + ); + } + + try { + return await response.json(); + } catch (error) { + throw new Error( + `Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/static/packages/api/lib/api-client/retry.ts b/static/packages/api/lib/api-client/retry.ts new file mode 100644 index 000000000000..20fa7816c20f --- /dev/null +++ b/static/packages/api/lib/api-client/retry.ts @@ -0,0 +1,212 @@ +/** + * Retry policy for Sentry API requests. Pure logic over standard Web APIs, so + * it runs in Node and Cloudflare Workers. Each product owns its request loop, + * error classes, and caps; this module decides whether to retry and how long + * to wait. A 401 credential refresh is not a retry here: the transport may + * replay any method once after refreshing, because the endpoint never ran. + */ + +/** Caps for one logical request. All delays are in milliseconds. */ +export type RetryPolicy = { + /** Retries after the first attempt. */ + maxRetries: number; + /** First fallback backoff step; it doubles on each retry. */ + baseDelayMs: number; + /** Longest single wait. A server delay above this is not waited for. */ + maxDelayMs: number; + /** Longest total wait across every retry of the request. */ + maxTotalDelayMs: number; +}; + +type HeaderReader = Pick; + +const IDEMPOTENT_METHODS: ReadonlySet = new Set([ + "GET", + "HEAD", + "OPTIONS", +]); + +/** + * The statuses that are retried, and which requests each may replay. This is + * the one rule for every host of the shared client. + * + * - `any`: Sentry's `RatelimitMiddleware` rejects a 429 in `process_request`, + * before the view runs (`sentry/middleware/ratelimit.py`), so replaying any + * method is safe. + * - `idempotent`: the endpoint may have run, so only GET, HEAD, and OPTIONS + * retry, unless the caller marks the request `idempotent`. + * + * Other 5xx codes (501, 505, ...) are deterministic and are not retried. + */ +const RETRYABLE_STATUSES: ReadonlyMap = new Map([ + [429, "any"], + [500, "idempotent"], + [502, "idempotent"], + [503, "idempotent"], + [504, "idempotent"], +]); + +/** The response `Date` header truncates server time to whole seconds. */ +const DATE_RESOLUTION_MS = 1000; + +/** + * Whether a request may be replayed after the endpoint may have run: a + * transient 5xx, a network error, or a timeout. True for GET, HEAD, and + * OPTIONS, or when the caller marks the request `idempotent`. + */ +export function isIdempotentRequest( + method: string, + idempotent = false, +): boolean { + return idempotent || IDEMPOTENT_METHODS.has(method.toUpperCase()); +} + +/** + * Whether `RETRYABLE_STATUSES` lets this failed request be sent again. With + * no status (a network error or timeout), the endpoint may have run, so only + * idempotent requests replay. + */ +function isRetryable( + method: string, + status: number | undefined, + idempotent = false, +): boolean { + const replays = + status === undefined ? "idempotent" : RETRYABLE_STATUSES.get(status); + if (replays === undefined) { + return false; + } + return replays === "any" || isIdempotentRequest(method, idempotent); +} + +/** + * Milliseconds the response asks the client to wait, or undefined when it + * gives no usable delay. `Retry-After` (seconds or HTTP-date) wins; a proxy + * may add it, but Sentry itself does not. Otherwise a 429 waits for + * `X-Sentry-Rate-Limit-Reset`, the Unix second the next window starts. A + * concurrency-limit 429 is not tied to the window, so it gets no delay here + * and the caller falls back to backoff. + */ +export function parseRetryDelayMs( + status: number, + headers: HeaderReader, + now: number = Date.now(), +): number | undefined { + const retryAfter = headers.get("retry-after")?.trim(); + if (retryAfter) { + if (/^\d+$/.test(retryAfter)) { + return Number(retryAfter) * 1000; + } + const date = Date.parse(retryAfter); + return Number.isNaN(date) ? undefined : delayUntil(date, headers, now); + } + + if (status !== 429 || !isWindowLimited(headers)) { + return undefined; + } + const reset = Number(headers.get("x-sentry-rate-limit-reset")); + if (!Number.isFinite(reset) || reset <= 0) { + return undefined; + } + return delayUntil(reset * 1000, headers, now); +} + +/** + * Delay before the next retry, or undefined to stop and surface the error. + * Stops when the method or status is not retryable, the retries are used up, + * the server delay exceeds `maxDelayMs`, or the wait would exceed + * `maxTotalDelayMs`. + */ +export function nextRetryDelayMs({ + policy, + method, + idempotent, + status, + serverDelayMs, + attempt, + waitedMs, + random = Math.random, +}: { + policy: RetryPolicy; + method: string; + idempotent?: boolean; + /** Failed response status, or undefined for a network error or timeout. */ + status: number | undefined; + /** From `parseRetryDelayMs` for the failed response. */ + serverDelayMs: number | undefined; + /** Retries already made for this request, starting at 0. */ + attempt: number; + /** Total time already spent waiting for this request. */ + waitedMs: number; + random?: () => number; +}): number | undefined { + if ( + attempt >= policy.maxRetries || + !isRetryable(method, status, idempotent) + ) { + return undefined; + } + + let delayMs: number; + if (serverDelayMs !== undefined) { + if (serverDelayMs > policy.maxDelayMs) { + return undefined; + } + // Spread clients that share a window so they don't all wake at its start. + delayMs = serverDelayMs + random() * (policy.baseDelayMs / 2); + } else { + // Equal jitter: half the step is fixed, half is random. + const step = Math.min(policy.baseDelayMs * 2 ** attempt, policy.maxDelayMs); + delayMs = step / 2 + random() * (step / 2); + } + + return waitedMs + delayMs > policy.maxTotalDelayMs ? undefined : delayMs; +} + +/** Wait `ms`, or reject with the signal's reason as soon as it aborts. */ +export function sleep(ms: number, signal?: AbortSignal | null): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Milliseconds until a server-clock `target`. The local clock gives the finest + * estimate, but it may be skewed. The response `Date` bounds the server's + * clock to `[date, date + 1s)`, so the true delay lies in + * `(target - date - 1s, target - date]`; the local estimate is clamped into it. + */ +function delayUntil( + target: number, + headers: HeaderReader, + now: number, +): number { + let delay = target - now; + const date = Date.parse(headers.get("date") ?? ""); + if (!Number.isNaN(date)) { + const latest = target - date; + delay = Math.min(Math.max(delay, latest - DATE_RESOLUTION_MS), latest); + } + return Math.max(0, delay); +} + +/** + * Sentry sends `Remaining: 0` only when the fixed window is exhausted + * (`above_rate_limit_check` in `sentry/ratelimits/utils.py`). Otherwise the + * 429 came from the concurrency limit and the window reset is irrelevant. + */ +function isWindowLimited(headers: HeaderReader): boolean { + return headers.get("x-sentry-rate-limit-remaining") === "0"; +} diff --git a/static/packages/api/lib/api-client/standard-schema.ts b/static/packages/api/lib/api-client/standard-schema.ts new file mode 100644 index 000000000000..1aec2ff3b124 --- /dev/null +++ b/static/packages/api/lib/api-client/standard-schema.ts @@ -0,0 +1,83 @@ +/** + * The Standard Schema V1 interface, copied from `@standard-schema/spec` 1.1.0 + * (MIT, https://standardschema.dev) as the spec invites, so the client + * accepts Zod, valibot, or any other implementing validator without a + * dependency. Types only. + */ + +/** The Standard Schema interface. */ +export interface StandardSchemaV1 { + /** The Standard Schema properties. */ + readonly "~standard": StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + /** The Standard Schema properties interface. */ + interface Props { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Inferred types associated with the schema. */ + readonly types?: Types | undefined; + /** Validates unknown input values. */ + readonly validate: ( + value: unknown, + options?: Options | undefined, + ) => Result | Promise>; + } + + /** The result interface of the validate function. */ + type Result = SuccessResult | FailureResult; + + /** The result interface if validation succeeds. */ + interface SuccessResult { + /** The typed output value. */ + readonly value: Output; + /** A falsy value for `issues` indicates success. */ + readonly issues?: undefined; + } + + interface Options { + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + } + + /** The result interface if validation fails. */ + interface FailureResult { + /** The issues of failed validation. */ + readonly issues: ReadonlyArray; + } + + /** The issue interface of the failure output. */ + interface Issue { + /** The error message of the issue. */ + readonly message: string; + /** The path of the issue, if any. */ + readonly path?: ReadonlyArray | undefined; + } + + /** The path segment interface of the issue. */ + interface PathSegment { + /** The key representing a path segment. */ + readonly key: PropertyKey; + } + + /** The Standard types interface. */ + interface Types { + /** The input type of the schema. */ + readonly input: Input; + /** The output type of the schema. */ + readonly output: Output; + } + + /** Infers the input type of a Standard Schema. */ + type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + + /** Infers the output type of a Standard Schema. */ + type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; +} diff --git a/static/packages/api/lib/api-client/time-params.ts b/static/packages/api/lib/api-client/time-params.ts new file mode 100644 index 000000000000..96b1348f162c --- /dev/null +++ b/static/packages/api/lib/api-client/time-params.ts @@ -0,0 +1,161 @@ +/** + * Time range query parameters shared by the endpoints that take one: + * `statsPeriod` or a `start`/`end` pair, or the `since`/`until` seconds of + * Sentry's `StatsMixin` endpoints. + */ + +import { ApiValidationError } from "./errors.js"; + +/** A trimmed `statsPeriod`, or undefined when it is blank. */ +export function normalizeStatsPeriod(statsPeriod?: string): string | undefined { + const normalized = statsPeriod?.trim(); + return normalized || undefined; +} + +/** + * The relative period to query: the trimmed `statsPeriod`, or 24 hours when + * neither it nor an absolute `start` or `end` is given. + */ +export function defaultStatsPeriod({ + statsPeriod, + start, + end, +}: { + statsPeriod?: string; + start?: string; + end?: string; +}): string | undefined { + const normalized = normalizeStatsPeriod(statsPeriod); + return start || end ? normalized : (normalized ?? "24h"); +} + +/** + * Split a relative period such as `24h` into its amount and unit. + * + * @throws {ApiValidationError} For any other format + */ +export function parseStatsPeriod(statsPeriod: string): { + amount: number; + unit: string; +} { + const match = /^(\d+)([smhdw])$/.exec(statsPeriod); + if (!match?.[2]) { + throw new ApiValidationError( + "statsPeriod must use a supported relative time format, such as `24h`, `7d`, or `2w`.", + ); + } + return { + amount: Number(match[1]), + unit: match[2], + }; +} + +/** + * Validates and applies time parameters to a URLSearchParams object. + * + * Enforces mutual exclusivity between relative (statsPeriod) and + * absolute (start/end) time ranges, and ensures start/end are paired. + * + * @param queryParams The URLSearchParams to modify + * @param statsPeriod Relative time period (e.g., "24h", "7d") + * @param start Absolute start time (ISO 8601) + * @param end Absolute end time (ISO 8601) + * @throws {ApiValidationError} If time parameters are invalid + */ +export function applyTimeParams( + queryParams: URLSearchParams, + statsPeriod?: string, + start?: string, + end?: string, +): void { + if (statsPeriod && (start || end)) { + throw new ApiValidationError( + "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.", + ); + } + if ((start && !end) || (!start && end)) { + throw new ApiValidationError( + "Both start and end parameters must be provided together for absolute time ranges.", + ); + } + if (statsPeriod) { + queryParams.set("statsPeriod", statsPeriod); + } else if (start && end) { + queryParams.set("start", start); + queryParams.set("end", end); + } +} + +/** + * Like {@link applyTimeParams}, but as the `since` and `until` Unix seconds + * that `StatsMixin` endpoints read, with an optional `resolution`. A missing + * range means the last 24 hours. + * + * @throws {ApiValidationError} If time parameters are invalid + */ +export function applyStatsMixinTimeParams( + queryParams: URLSearchParams, + statsPeriod?: string, + start?: string, + end?: string, + resolutionSeconds?: number, +): void { + if (statsPeriod && (start || end)) { + throw new ApiValidationError( + "Cannot use both statsPeriod and start/end parameters. Use either statsPeriod for relative time or start/end for absolute time.", + ); + } + if ((start && !end) || (!start && end)) { + throw new ApiValidationError( + "Both start and end parameters must be provided together for absolute time ranges.", + ); + } + + let since: number; + let until: number; + + if (start && end) { + const startMs = Date.parse(start); + const endMs = Date.parse(end); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + throw new ApiValidationError( + "start and end parameters must be valid ISO 8601 date strings.", + ); + } + since = Math.floor(startMs / 1000); + until = Math.floor(endMs / 1000); + } else { + const period = statsPeriod ?? "24h"; + const { amount, unit } = parseStatsPeriod(period); + let unitSeconds: number; + switch (unit) { + case "s": + unitSeconds = 1; + break; + case "m": + unitSeconds = 60; + break; + case "h": + unitSeconds = 60 * 60; + break; + case "d": + unitSeconds = 60 * 60 * 24; + break; + case "w": + unitSeconds = 60 * 60 * 24 * 7; + break; + default: + throw new ApiValidationError( + "statsPeriod must use a supported relative time format, such as `24h`, `7d`, or `2w`.", + ); + } + until = Math.floor(Date.now() / 1000); + since = until - amount * unitSeconds; + } + + queryParams.set("since", String(since)); + queryParams.set("until", String(until)); + if (resolutionSeconds !== undefined) { + queryParams.set("resolution", `${resolutionSeconds}s`); + } +} diff --git a/static/packages/api/lib/api-client/transport.ts b/static/packages/api/lib/api-client/transport.ts new file mode 100644 index 000000000000..52d06f356f0a --- /dev/null +++ b/static/packages/api/lib/api-client/transport.ts @@ -0,0 +1,906 @@ +/** + * Authenticated fetch for Sentry API requests, shared by the CLI and MCP. + * Standard Web APIs only, so it runs in Node and Cloudflare Workers. + * + * Per request it sets the Authorization header, refreshes the credential once + * after a 401 (any method), times out each attempt, retries through + * `retry`, replays request bodies, optionally follows redirects only to + * trusted origins, and serves and invalidates cached GETs through an injected + * cache. The host supplies credentials, header rules, error classes, logging, + * and spans. + */ + +import { + isIdempotentRequest, + nextRetryDelayMs, + parseRetryDelayMs, + type RetryPolicy, + sleep, +} from "./retry.js"; +import { type ApiPathScope, parseApiPathScope } from "./api-path.js"; +import { sentryBearerHeader } from "./auth-token.js"; +import type { CacheLookup } from "./response-cache/policy.js"; + +type FetchInput = Request | string | URL; + +/** The fields the transport reads; hosts extend it with their own. */ +export type TransportCredential = { + token: string; + /** Stable hash of the login, for cache scoping; never a raw token. */ + identity: string; +}; + +/** The request a credential is chosen for; see {@link CredentialProvider.current}. */ +export type CredentialRequest = { + url: string; + /** + * The organization, and project when present, the URL path targets + * ({@link parseApiPathScope}). Absent for unscoped paths. + */ + scope?: ApiPathScope; +}; + +export type CredentialProvider = { + /** + * The credential for a new request, before any refresh. Cache lookups use + * it. A host with several logins can use `request.scope` to pick the one + * that can reach the organization. The URL is already built, so the + * credential must be valid for its origin. + */ + current(request: CredentialRequest): C | Promise; + /** Called after a cache miss, before the first attempt, to refresh ahead of expiry. */ + prepare?(credential: C): Promise; + /** After a 401: the refreshed credential, or undefined when it cannot refresh. */ + refresh?(credential: C): Promise; + /** The Authorization value. Throws for a malformed token. */ + authorization?(credential: C): string; +}; + +/** How the caller reads a successful body; see {@link ApiTransportRequestOptions.responseBody}. */ +export type ResponseBodyMode = "auto" | "buffer" | "stream"; + +/** The GET cache, keyed by the host. `CacheLookup` comes from `response-cache`. */ +export type TransportCache = { + lookup(request: CachedRequest): Promise; + /** Store a successful GET; `response` is a clone the cache may consume. */ + store(request: CachedRequest, response: Response): Promise; + /** Drop entries a successful write to `url` made stale. */ + invalidate(url: string, identity: string): Promise; +}; + +export type CachedRequest = { + url: string; + /** For `Vary` matching. */ + requestHeaders: Record; + identity: string; +}; + +type TransportLogger = { + debug(message: string, ...args: unknown[]): void; +}; + +export type ApiTransportOptions = { + /** + * Without credentials the transport leaves Authorization to the request, + * does not refresh, and does not cache, since entries are scoped to the + * credential's identity. + */ + credentials?: CredentialProvider; + retry: RetryPolicy; + /** Per-attempt timeout, or a function of the request URL. Default: none. */ + timeoutMs?: number | ((url: string) => number); + /** Underlying fetch, read on each call. Defaults to `globalThis.fetch`. */ + fetch?: (input: FetchInput, init?: RequestInit) => Promise; + /** Sent when the request sets no `User-Agent`. */ + userAgent?: string; + /** + * Add host headers, such as tracing or proxy headers, before Authorization + * is set. Throw to reject the request, for example for an untrusted host. + */ + prepareHeaders?( + headers: Headers, + request: { input: FetchInput; credential: C }, + ): void; + /** Writes the host knows are safe to replay, such as a catalog of idempotent operations. */ + isIdempotent?(method: string, url: string): boolean; + /** Set to `1` on the request replayed after a credential refresh. */ + refreshedRequestHeader?: string; + /** Which origins may receive the credential when following redirects. */ + origins?: { + trusts(url: URL, credential: C): boolean; + /** The error for an untrusted or unusable redirect destination. */ + reject(destination: string, credential: C): Error; + }; + /** Host error classes. Without them the transport throws its own. */ + errors?: { + /** + * The final attempt timed out. `idempotent` is false when a write may + * have been applied. + */ + timeout?(info: { timeoutMs: number; idempotent: boolean }): Error; + /** A fetch error to throw at once, without retrying, such as a TLS failure. */ + fatal?(error: unknown): Error | undefined; + /** A 429 that is not retried. Undefined returns the response. */ + rateLimited?( + response: Response, + info: { path: string; retryAfterMs: number | undefined }, + ): Promise; + }; + cache?: TransportCache; + /** + * Whether the current scope skips cache reads, such as a tool call with + * `fresh: true`. Hosts back it with request-scoped state like + * `AsyncLocalStorage`. The response is still stored, replacing the entry. + */ + fresh?(): boolean; + logger?: TransportLogger; + /** Wrap each request, including cache lookups, in a host span. */ + span?( + request: { method: string; path: string }, + run: () => Promise, + ): Promise; +}; + +export type ApiTransportRequestOptions = { + /** + * Replaying the request is harmless, so 5xx, timeouts, and network errors + * retry for any method. For writes the server dedupes, such as + * content-addressed chunk uploads. + */ + idempotent?: boolean; + /** + * `buffer` reads a successful body within the attempt's timeout and retry + * budget; `auto` does so only when it has a `Content-Type`, as the + * `@sentry/api` auto parser returns a stream otherwise. Default `stream`. + */ + responseBody?: ResponseBodyMode; + /** + * Follow redirects manually, checking each destination with `origins` + * and recording where the response came from; see + * {@link getValidatedResponseSource}. Skips the cache. + */ + validateRedirects?: boolean; +}; + +export type ApiTransport = { + request( + input: FetchInput, + init?: RequestInit, + options?: ApiTransportRequestOptions, + ): Promise; +}; + +/** The request's last attempt hit the transport's own timeout. */ +export class ApiTimeoutError extends Error { + readonly timeoutMs: number; + readonly idempotent: boolean; + + constructor(timeoutMs: number, idempotent: boolean) { + super(`Request timed out after ${Math.round(timeoutMs / 1000)}s`); + this.name = "ApiTimeoutError"; + this.timeoutMs = timeoutMs; + this.idempotent = idempotent; + } +} + +const MAX_REDIRECTS = 20; + +/** Stands in for the credential when the host sets Authorization itself. */ +const ANONYMOUS: TransportCredential = { token: "", identity: "" }; + +/** Where a response fetched with `validateRedirects` came from. */ +export type ValidatedResponseSource = { origin: string; identity: string }; + +const validatedSources = new WeakMap(); + +/** The final origin and credential identity of a `validateRedirects` response. */ +export function getValidatedResponseSource( + response: Response, +): ValidatedResponseSource | undefined { + return validatedSources.get(response); +} + +/** Extract the full URL from a fetch input; `String(request)` is not its URL. */ +export function extractInputUrl(input: FetchInput): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.href; + } + return input.url; +} + +/** The URL pathname for logs and spans, or the raw input when it does not parse. */ +export function extractUrlPath(input: FetchInput): string { + const raw = extractInputUrl(input); + try { + return new URL(raw).pathname; + } catch { + return raw; + } +} + +/** Thrown inside an attempt for a host rejection that must not be retried. */ +class Rejection { + constructor(readonly error: unknown) {} +} + +/** One attempt: a response, or a thrown error and our timeout if it caused it. */ +type Outcome = + | { response: Response } + | { failed: unknown; timedOutAfterMs?: number }; + +type AttemptResult = + | { action: "done"; response: Response } + | { action: "retry"; delayMs: number } + /** Send again at once, outside the retry budget: a credential refresh. */ + | { action: "replay" } + | { action: "throw"; error: unknown }; + +type Send = { + input: FetchInput; + init: RequestInit | undefined; + method: string; + url: string; + path: string; + /** Before `prepare`; its identity scopes the cache. */ + credential: C; + options: ApiTransportRequestOptions; + conditionalHeaders?: Record; +}; + +/** Create a transport; see {@link ApiTransportOptions}. */ +export function createApiTransport( + config: ApiTransportOptions, +): ApiTransport { + const { credentials, retry, logger } = config; + const cache = credentials && config.cache; + + const baseFetch = (input: FetchInput, init?: RequestInit) => + (config.fetch ?? globalThis.fetch)(input, init); + + function authorization(credential: C): string | undefined { + if (!credentials) { + return; + } + if (credentials.authorization) { + return credentials.authorization(credential); + } + const header = sentryBearerHeader(credential.token); + if (header === null) { + throw new Error("Malformed authentication token"); + } + return header; + } + + /** + * The Authorization a cache lookup varies on: the value a send would set, + * so a `Vary: Authorization` entry matches. None when the credential + * cannot produce one yet; `prepare` may still replace it. + */ + function lookupHeaders(credential: C): Record { + try { + const value = authorization(credential); + return value ? { authorization: value } : {}; + } catch { + return {}; + } + } + + function timeoutFor(url: string): number | undefined { + return typeof config.timeoutMs === "function" + ? config.timeoutMs(url) + : config.timeoutMs; + } + + /** + * When the SDK calls fetch(request) with no init, read headers from the + * Request: fetch(request, { headers }) replaces them per spec, which would + * drop the SDK's Content-Type. The returned Headers are shared across + * attempts; a 401 refresh updates them. + */ + function buildHeaders( + input: FetchInput, + init: RequestInit | undefined, + credential: C, + ): Headers { + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ); + config.prepareHeaders?.(headers, { input, credential }); + setAuthorization(headers, authorization(credential)); + if (config.userAgent && !headers.has("User-Agent")) { + headers.set("User-Agent", config.userAgent); + } + return headers; + } + + /** + * Release an unused body without waiting: cancelling one branch of a teed + * stream, as interceptors such as MSW hand out, settles only once the other + * branch is read, which may never happen. + */ + function cancelBody(response: Response): void { + if (response.body && !response.bodyUsed) { + response.body.cancel().catch((error: unknown) => { + logger?.debug("Failed to cancel unused response body", error); + }); + } + } + + /** + * One fetch with a timeout. A buffered body is drained from a clone within + * the attempt, so a body failure can retry without consuming the caller's + * copy. + */ + async function fetchOnce( + input: FetchInput, + init: RequestInit, + signal: AbortSignal | null | undefined, + timeoutMs: number | undefined, + mode: ResponseBodyMode, + ): Promise { + const controller = new AbortController(); + let timedOut = false; + const timer = + timeoutMs === undefined + ? undefined + : setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + // Forward the caller's reason, so an aborted fetch rejects with it. + const onAbort = () => controller.abort(signal?.reason); + if (signal?.aborted) { + onAbort(); + } else { + signal?.addEventListener("abort", onAbort, { once: true }); + } + try { + const response = await baseFetch(input, { + ...init, + signal: controller.signal, + }); + if (shouldBuffer(response, mode)) { + await drainBody(response.clone(), controller.signal); + } + return { response }; + } catch (error) { + if (timedOut) { + return { failed: error, timedOutAfterMs: timeoutMs }; + } + throw error; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + } + + async function rejectRedirect( + response: Response, + destination: string, + credential: C, + ): Promise { + cancelBody(response); + throw new Rejection( + config.origins?.reject(destination, credential) ?? + new Error(`Refusing to follow a redirect to ${destination}`), + ); + } + + function trusts(url: URL, credential: C, original: URL): boolean { + return config.origins + ? config.origins.trusts(url, credential) + : url.origin === original.origin; + } + + async function nextRedirect( + response: Response, + request: Request, + credential: C, + visited: Set, + original: URL, + ): Promise { + const location = response.headers.get("location"); + if (!location) { + return await rejectRedirect(response, "", credential); + } + if (visited.size > MAX_REDIRECTS) { + return await rejectRedirect(response, "", credential); + } + let next: URL; + try { + next = new URL(location, request.url); + } catch (error) { + logger?.debug( + "Rejected malformed redirect location", + error instanceof Error ? error.name : "unknown error", + ); + return await rejectRedirect(response, "", credential); + } + if (!trusts(next, credential, original)) { + return await rejectRedirect(response, next.origin, credential); + } + const key = redirectKey(next); + if (visited.has(key)) { + return await rejectRedirect(response, "", credential); + } + visited.add(key); + return next; + } + + /** Follow each redirect by hand so the credential only reaches trusted origins. */ + async function fetchValidated( + input: FetchInput, + init: RequestInit | undefined, + headers: Headers, + credential: C, + timeoutMs: number | undefined, + mode: ResponseBodyMode, + ): Promise { + const original = new Request(input, init); + const originalUrl = new URL(original.url); + const visited = new Set([redirectKey(originalUrl)]); + let request = original; + let requestHeaders = new Headers(headers); + + // The host's prepareHeaders vets the first URL; nextRedirect vets each hop. + for (;;) { + const result = await fetchOnce( + request.clone(), + { headers: requestHeaders, redirect: "manual" }, + init?.signal ?? original.signal, + timeoutMs, + mode, + ); + if (!("response" in result) || !isRedirect(result.response.status)) { + const origin = httpOrigin(request.url); + if ("response" in result && origin) { + validatedSources.set(result.response, { + origin, + identity: credential.identity, + }); + } + return result; + } + const { response } = result; + try { + const next = await nextRedirect( + response, + request, + credential, + visited, + originalUrl, + ); + ({ request, headers: requestHeaders } = await redirectRequest( + request, + requestHeaders, + next, + response.status, + )); + } finally { + cancelBody(response); + } + } + } + + /** Send with refresh, timeouts, and retries; the cache is handled by the caller. */ + async function send(sendArgs: Send): Promise { + const { input, init, method, url, path, options } = sendArgs; + let credential = credentials?.prepare + ? await credentials.prepare(sendArgs.credential) + : sendArgs.credential; + const headers = buildHeaders(input, init, credential); + for (const [name, value] of Object.entries( + sendArgs.conditionalHeaders ?? {}, + )) { + headers.set(name, value); + } + const nextInput = await replayableInput(input, init); + const timeoutMs = timeoutFor(url); + const idempotent = + options.idempotent === true || + (config.isIdempotent?.(method, url) ?? false); + const mode = options.responseBody ?? "stream"; + const signal = + init?.signal !== undefined + ? init.signal + : input instanceof Request + ? input.signal + : undefined; + let refreshed = false; + let waitedMs = 0; + + const retryDelay = ( + attempt: number, + status: number | undefined, + serverDelayMs: number | undefined, + ) => + nextRetryDelayMs({ + policy: retry, + method, + idempotent, + status, + serverDelayMs, + attempt, + waitedMs, + }); + + async function onResponse( + response: Response, + attempt: number, + ): Promise { + if (response.status === 401) { + const next = + refreshed || !credentials?.refresh + ? undefined + : await credentials.refresh(credential); + if (!next) { + return { action: "done", response }; + } + refreshed = true; + credential = next; + setAuthorization(headers, authorization(next)); + if (config.refreshedRequestHeader) { + headers.set(config.refreshedRequestHeader, "1"); + } + cancelBody(response); + return { action: "replay" }; + } + + const serverDelayMs = parseRetryDelayMs( + response.status, + response.headers, + ); + const delayMs = retryDelay(attempt, response.status, serverDelayMs); + if (delayMs !== undefined) { + cancelBody(response); + return { action: "retry", delayMs }; + } + if (response.status === 429 && config.errors?.rateLimited) { + const error = await config.errors.rateLimited(response, { + path, + retryAfterMs: serverDelayMs, + }); + if (error) { + return { action: "throw", error }; + } + } + return { action: "done", response }; + } + + function onFailure( + { failed, timedOutAfterMs }: Extract, + attempt: number, + ): AttemptResult { + if (failed instanceof Rejection) { + return { action: "throw", error: failed.error }; + } + if (isAbort(failed, signal)) { + return { action: "throw", error: failed }; + } + const fatal = config.errors?.fatal?.(failed); + if (fatal) { + return { action: "throw", error: fatal }; + } + const delayMs = retryDelay(attempt, undefined, undefined); + if (delayMs !== undefined) { + return { action: "retry", delayMs }; + } + if (timedOutAfterMs === undefined) { + return { action: "throw", error: failed }; + } + // Whether the endpoint may have run, for the host's error wording. + const replayable = isIdempotentRequest(method, idempotent); + return { + action: "throw", + error: + config.errors?.timeout?.({ + timeoutMs: timedOutAfterMs, + idempotent: replayable, + }) ?? new ApiTimeoutError(timedOutAfterMs, replayable), + }; + } + + for (let attempt = 0; ; ) { + const { input: attemptInput, init: attemptInit } = nextInput(); + let outcome: Outcome; + try { + outcome = options.validateRedirects + ? await fetchValidated( + attemptInput, + attemptInit, + headers, + credential, + timeoutMs, + mode, + ) + : await fetchOnce( + attemptInput, + { ...attemptInit, headers }, + signal, + timeoutMs, + mode, + ); + } catch (error) { + outcome = { failed: error }; + } + // Outside the try: handling a response is not a fetch failure. + const result = + "response" in outcome + ? await onResponse(outcome.response, attempt) + : onFailure(outcome, attempt); + + if (result.action === "done") { + storeInCache( + method, + url, + sendArgs.credential.identity, + headers, + result.response, + ); + await invalidateAfterWrite( + method, + url, + sendArgs.credential.identity, + result.response, + ); + return result.response; + } + if (result.action === "throw") { + throw result.error; + } + if (result.action === "replay") { + logger?.debug(`${method} ${path} → replay with a refreshed credential`); + continue; + } + logger?.debug( + `${method} ${path} → retry ${attempt + 1}/${retry.maxRetries} after ${Math.round(result.delayMs)}ms`, + ); + await sleep(result.delayMs, signal); + waitedMs += result.delayMs; + attempt++; + } + } + + /** Fire-and-forget: a cache write never delays or fails the response. */ + function storeInCache( + method: string, + url: string, + identity: string, + headers: Headers, + response: Response, + ): void { + if (!cache || method !== "GET" || !response.ok) { + return; + } + cache + .store( + { + url, + requestHeaders: { authorization: headers.get("Authorization") ?? "" }, + identity, + }, + response.clone(), + ) + .catch((error: unknown) => { + logger?.debug("Response cache write failed", error); + }); + } + + /** + * Awaited so a later read in the same task sees fresh data, and never + * throws: the write already succeeded. + */ + async function invalidateAfterWrite( + method: string, + url: string, + identity: string, + response: Response, + ): Promise { + if (!cache || method === "GET" || !response.ok) { + return; + } + try { + await cache.invalidate(url, identity); + } catch (error) { + logger?.debug("Post-mutation cache invalidation failed", error); + } + } + + async function request( + input: FetchInput, + init?: RequestInit, + options: ApiTransportRequestOptions = {}, + ): Promise { + const method = + init?.method ?? (input instanceof Request ? input.method : "GET"); + const url = extractInputUrl(input); + const path = extractUrlPath(input); + + const run = async (): Promise => { + const startTime = performance.now(); + const elapsed = () => (performance.now() - startTime).toFixed(0); + const credential = credentials + ? await credentials.current(credentialRequest(url)) + : (ANONYMOUS as C); + // A cached response has no validated origin, so discovery always + // goes to the network. + const lookup = + cache && + method === "GET" && + !options.validateRedirects && + !config.fresh?.() + ? await cache.lookup({ + url, + requestHeaders: lookupHeaders(credential), + identity: credential.identity, + }) + : undefined; + if (lookup?.kind === "hit") { + logger?.debug( + `${method} ${path} → ${lookup.response.status} (cache hit, ${elapsed()}ms)`, + ); + return lookup.response; + } + + const sendArgs = { input, init, method, url, path, credential, options }; + let response = await send({ + ...sendArgs, + conditionalHeaders: lookup?.conditionalHeaders, + }); + if (lookup?.kind === "stale" && response.status === 304) { + const revalidated = await lookup.revalidate(response); + if (revalidated) { + logger?.debug( + `${method} ${path} → 304 (revalidated cache entry, ${elapsed()}ms)`, + ); + return revalidated; + } + // A 304 the entry cannot use has no body; fetch it unconditionally. + response = await send(sendArgs); + } + logger?.debug(`${method} ${path} → ${response.status} (${elapsed()}ms)`); + return response; + }; + + return config.span ? await config.span({ method, path }, run) : await run(); + } + + return { request }; +} + +function credentialRequest(url: string): CredentialRequest { + const scope = parseApiPathScope(url); + return scope ? { url, scope } : { url }; +} + +/** + * Read a body to the end, or reject once `signal` aborts. A fetch whose body + * ignores the signal after the headers, such as a mock, would otherwise stall + * past the attempt's timeout. The abandoned read is left alone: cancelling + * one branch of a tee waits for the other. + */ +async function drainBody( + response: Response, + signal: AbortSignal, +): Promise { + const drained = response.body?.pipeTo(new WritableStream()); + if (!drained) { + return; + } + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(signal.reason); + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + }); + try { + await Promise.race([drained, aborted]); + } finally { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + } + drained.catch(() => {}); + } +} + +/** Whether `error` is the caller's abort, with the default or its own reason. */ +function isAbort(error: unknown, signal: AbortSignal | null | undefined) { + return ( + Boolean(signal?.aborted) && + (error === signal?.reason || + (error instanceof DOMException && error.name === "AbortError")) + ); +} + +function shouldBuffer(response: Response, mode: ResponseBodyMode): boolean { + const contentType = response.headers.get("Content-Type"); + // SSE reconnects and streaming parsers own consumption of their bodies. + if ( + contentType?.split(";")[0]?.trim().toLowerCase() === "text/event-stream" + ) { + return false; + } + return ( + response.ok && (mode === "buffer" || (mode === "auto" && !!contentType)) + ); +} + +/** + * A fresh `(input, init)` pair per attempt: `fetch` consumes a body, so a + * retry would otherwise throw `Request body already used`. A `Request` is + * cloned; a `ReadableStream` body, the only single-use body type, is read + * once into a buffer. Other bodies pass through, which keeps the + * `multipart/form-data` boundary `fetch` derives from `FormData`. + */ +async function replayableInput( + input: FetchInput, + init: RequestInit | undefined, +): Promise<() => { input: FetchInput; init: RequestInit | undefined }> { + if (input instanceof Request) { + return () => ({ input: input.clone(), init }); + } + const body = init?.body; + if (body instanceof ReadableStream) { + const snapshot = await new Response(body).arrayBuffer(); + return () => ({ input, init: { ...init, body: snapshot } }); + } + return () => ({ input, init }); +} + +function setAuthorization(headers: Headers, value: string | undefined): void { + if (value !== undefined) { + headers.set("Authorization", value); + } +} + +/** The origin of a credential-free HTTP(S) URL, or undefined. */ +function httpOrigin(raw: string): string | undefined { + const url = new URL(raw); + return (url.protocol === "http:" || url.protocol === "https:") && + !url.username && + !url.password + ? url.origin + : undefined; +} + +function isRedirect(status: number): boolean { + return [301, 302, 303, 307, 308].includes(status); +} + +function redirectKey(url: URL): string { + const key = new URL(url); + key.hash = ""; + return key.href; +} + +/** The next hop's request; a 303, or a 301/302 after a POST, becomes a bodiless GET. */ +async function redirectRequest( + request: Request, + requestHeaders: Headers, + next: URL, + status: number, +): Promise<{ request: Request; headers: Headers }> { + const current = request.method.toUpperCase(); + const method = + (status === 303 && current !== "HEAD") || + ((status === 301 || status === 302) && current === "POST") + ? "GET" + : current; + const headers = new Headers(requestHeaders); + if (method !== current) { + headers.delete("content-length"); + headers.delete("content-type"); + headers.delete("transfer-encoding"); + } + const body = + method === current && request.body + ? await request.clone().arrayBuffer() + : undefined; + return { request: new Request(next, { method, headers, body }), headers }; +} diff --git a/static/packages/api/lib/api-client/validate.ts b/static/packages/api/lib/api-client/validate.ts new file mode 100644 index 000000000000..dc4cf8311b6f --- /dev/null +++ b/static/packages/api/lib/api-client/validate.ts @@ -0,0 +1,64 @@ +/** + * Response validation through the Standard Schema interface, so the shared + * client works with any validator that implements it. The MCP passes Zod + * schemas and the CLI valibot ones; this module depends on neither. + */ + +import type { StandardSchemaV1 } from "./standard-schema.js"; + +/** A schema the host supplies to check a response body. */ +export type ResponseSchema = StandardSchemaV1< + unknown, + Output +>; + +const MAX_LISTED_ISSUES = 3; + +function issuePath(issue: StandardSchemaV1.Issue): string { + return (issue.path ?? []) + .map((segment) => + String(typeof segment === "object" ? segment.key : segment), + ) + .join("."); +} + +/** A response did not match the schema the caller validated it with. */ +export class ApiResponseValidationError extends Error { + readonly issues: readonly StandardSchemaV1.Issue[]; + + constructor(issues: readonly StandardSchemaV1.Issue[]) { + const listed = issues.slice(0, MAX_LISTED_ISSUES).map((issue) => { + const path = issuePath(issue); + return path ? `${path}: ${issue.message}` : issue.message; + }); + const more = + issues.length > MAX_LISTED_ISSUES + ? ` (and ${issues.length - MAX_LISTED_ISSUES} more)` + : ""; + super(`Unexpected API response: ${listed.join("; ")}${more}`); + this.name = "ApiResponseValidationError"; + this.issues = issues; + } +} + +/** + * Validate `data` with `schema` and return the schema's output. + * + * Responses are validated synchronously, as every caller does today, so a + * schema with async refinements is rejected rather than awaited. + * + * @throws {ApiResponseValidationError} When `data` does not match + */ +export function validateResponse( + schema: Schema, + data: unknown, +): StandardSchemaV1.InferOutput { + const result = schema["~standard"].validate(data); + if (result instanceof Promise) { + throw new TypeError("Response schemas must validate synchronously"); + } + if (result.issues) { + throw new ApiResponseValidationError(result.issues); + } + return result.value; +} diff --git a/static/packages/api/lib/browser-client.ts b/static/packages/api/lib/browser-client.ts new file mode 100644 index 000000000000..7b0d34fe876b --- /dev/null +++ b/static/packages/api/lib/browser-client.ts @@ -0,0 +1,83 @@ +/** + * Browser auth utilities for @sentry/api. + * + * Provides a fetch wrapper that handles cookie-based session auth and + * CSRF token injection, allowing the SDK to be used inside the Sentry + * frontend without a Bearer token. + */ + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); + +/** Matches the standard fetch signature without Bun/Node runtime extensions. */ +export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export type BrowserClientOptions = { + /** Cookie name to read the CSRF token from. Defaults to 'sc' (Sentry's Django default). */ + csrfCookieName?: string; + /** Override the CSRF token getter — useful for testing. */ + getCsrfToken?: () => string; + /** Base URL for all requests. Defaults to '' (same-origin relative URLs). */ + baseUrl?: string; +}; + +function readCookie(name: string): string { + // SSR / Node environments have no document; return empty so no token is injected. + if (typeof document === 'undefined') return ''; + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = document.cookie.match(new RegExp(`(?:^|; )${escapedName}=([^;]*)`)); + if (!match) return ''; + try { + return decodeURIComponent(match[1]!); + } catch { + // Fall back to raw value if the cookie is malformed percent-encoded. + return match[1]!; + } +} + +/** + * Returns a fetch wrapper that injects X-CSRFToken on state-changing requests + * and sends session cookies via credentials: 'include'. + * + * Sentry's Django backend reads the CSRF token from the 'sc' cookie by default + * (configurable via window.csrfCookieName in the frontend). + * + * Note: always sets credentials: 'include'; any credentials value in init is overridden. + */ +export function createBrowserFetch(opts: BrowserClientOptions = {}): FetchFn { + const getCsrf = opts.getCsrfToken ?? (() => readCookie(opts.csrfCookieName ?? 'sc')); + + return (input, init) => { + // init.method takes precedence; fall back to the Request object's method if + // input is a Request, then to 'GET' — mirrors the Fetch spec resolution order. + const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(); + // Seed headers from the Request object when init provides none; otherwise + // init.headers wins (Fetch spec: init completely replaces Request headers). + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ); + if (!SAFE_METHODS.has(method)) { + const token = getCsrf(); + if (token) headers.set('X-CSRFToken', token); + } + return globalThis.fetch(input, {...init, headers, credentials: 'include'}); + }; +} + +/** + * Returns configuration for a browser SDK instance with cookie-based session + * auth and CSRF token injection. The base URL defaults to same-origin relative URLs. + * + * @example + * import {createSentryClient} from '@sentry/api'; + * import {createBrowserSdkConfig} from '@sentry/api/browser'; + * const sentry = createSentryClient( + * createBrowserSdkConfig({baseUrl: 'https://sentry.example.com'}), + * ); + * const {data, error} = await sentry.listOrganizations(); + */ +export function createBrowserSdkConfig(opts: BrowserClientOptions = {}) { + return { + baseUrl: opts.baseUrl ?? '', + fetch: createBrowserFetch(opts), + } as const; +} diff --git a/static/packages/api/lib/emit-declarations.mjs b/static/packages/api/lib/emit-declarations.mjs new file mode 100644 index 000000000000..d4a8009d9bee --- /dev/null +++ b/static/packages/api/lib/emit-declarations.mjs @@ -0,0 +1,61 @@ +/** + * Emit the declarations `tsc --emitDeclarationOnly` would, from tsconfig.json, + * in parallel worker threads. + * + * Checking the Valibot and Zod schemas takes most of tsc's time, and neither + * file imports anything else in src/. Each worker builds a program from one + * group of root files, reports its diagnostics, and writes its declarations; + * the files outside every group form one more program. + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parentPort, Worker, workerData } from "node:worker_threads"; +import ts from "typescript"; + +const CONFIG = fileURLToPath(new URL("../tsconfig.json", import.meta.url)); +const formatHost = { + getCanonicalFileName: name => name, + getCurrentDirectory: ts.sys.getCurrentDirectory, + getNewLine: () => ts.sys.newLine, +}; + +function readConfig() { + const { config, error } = ts.readConfigFile(CONFIG, ts.sys.readFile); + if (error) throw new Error(ts.flattenDiagnosticMessageText(error.messageText, "\n")); + return ts.parseJsonConfigFileContent(config, ts.sys, fileURLToPath(new URL("..", import.meta.url))); +} + +/** Emit `rootNames`' declarations; returns tsc's formatted diagnostics, if any. */ +function emit(rootNames) { + const { options } = readConfig(); + const program = ts.createProgram({ rootNames, options }); + const result = program.emit(); + const diagnostics = [...ts.getPreEmitDiagnostics(program), ...result.diagnostics]; + return ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost); +} + +/** Emit every declaration, one program per group of src/ paths plus one for the rest. */ +export async function emitDeclarations(groups) { + const { fileNames, errors } = readConfig(); + if (errors.length) throw new Error(ts.formatDiagnostics(errors, formatHost)); + const grouped = groups.map(names => names.map(name => fileURLToPath(new URL(`../src/${name}`, import.meta.url)))); + // tsc reports POSIX separators even on Windows, so compare resolved paths. + const claimed = new Set(grouped.flat().map(name => path.resolve(name))); + const rest = fileNames.filter(name => !claimed.has(path.resolve(name))); + if (fileNames.length - rest.length !== claimed.size) { + throw new Error("A declaration group names a file outside tsconfig.json's files"); + } + const reports = await Promise.all([...grouped, rest].map(rootNames => new Promise((resolve, reject) => { + const worker = new Worker(new URL(import.meta.url), { workerData: { declarations: rootNames } }); + worker.once("message", resolve); + worker.once("error", reject); + worker.once("exit", code => reject(new Error(`Declaration worker exited with code ${code}`))); + }))); + const failures = reports.filter(Boolean); + if (failures.length) { + process.stderr.write(failures.join("")); + throw new Error("Declaration emit failed"); + } +} + +if (workerData?.declarations) parentPort.postMessage(emit(workerData.declarations)); diff --git a/static/packages/api/lib/generated-source.mjs b/static/packages/api/lib/generated-source.mjs new file mode 100644 index 000000000000..838328785bc1 --- /dev/null +++ b/static/packages/api/lib/generated-source.mjs @@ -0,0 +1,55 @@ +/** + * Helpers for the build steps in scripts/, which read and patch the files + * Hey API generates into src/. Each patch fails closed, so a generator + * upgrade cannot silently drop it. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +/** The absolute path of `name` under src/. */ +export const srcPath = name => fileURLToPath(new URL(`../src/${name}`, import.meta.url)); + +/** Replace the one match of `before`, a string or a RegExp, or throw. */ +export function replaceOnce(source, before, after) { + const matches = typeof before === "string" + ? source.split(before).length - 1 + : [...source.matchAll(new RegExp(before.source, "g"))].length; + if (matches !== 1) { + throw new Error(`Generated code changed: expected one match, got ${matches}: ${before}`); + } + return source.replace(before, () => after); +} + +/** Rewrite src/`name` with `transform(source, name)`. */ +export function update(name, transform) { + const path = srcPath(name); + writeFileSync(path, transform(readFileSync(path, "utf8"), name)); +} + +/** Parse src/`name`, keeping its text for position-based edits. */ +export function parse(name) { + const path = srcPath(name); + const text = readFileSync(path, "utf8"); + return { path, text, source: ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true) }; +} + +/** + * Apply `{ start, end, text }` replacements, which must not overlap, in one + * pass. Insertions at the same position land in reverse order, as they did + * when each edit was applied to the whole string from the end. + */ +export function edit(source, changes) { + const ordered = changes + .map((change, index) => ({ ...change, index })) + .sort((a, b) => a.start - b.start || b.index - a.index); + const parts = []; + let last = 0; + for (const { start, end, text } of ordered) { + if (start < last) throw new Error(`Overlapping edits at ${start}`); + parts.push(source.slice(last, start), text); + last = end; + } + parts.push(source.slice(last)); + return parts.join(""); +} diff --git a/static/packages/api/lib/hey-api.mjs b/static/packages/api/lib/hey-api.mjs new file mode 100644 index 000000000000..36e55d45ac34 --- /dev/null +++ b/static/packages/api/lib/hey-api.mjs @@ -0,0 +1,91 @@ +/** + * Generate the TypeScript client and the optional Valibot and Zod schemas + * from the OpenAPI spec with Hey API. + * + * The validator plugins read nothing the TypeScript and SDK plugins write, so + * each group runs in its own worker thread, and each produces the same files + * it would in one combined run. Hey API empties its output directory first, + * so the validator groups write to a temporary directory and their files are + * copied into the output afterwards. + */ +import { cpSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Worker, workerData } from "node:worker_threads"; + +/** + * Hey API attaches descriptions only to typed schemas, not to unions, so an + * OpenAPI 3.0 `nullable` field (a union with `null` in its IR) loses its + * description. Give the field's one non-null branch the description instead. + */ +function describeNullableValues(ir) { + const seen = new WeakSet(); + const visit = node => { + if (!node || typeof node !== "object" || seen.has(node)) return; + seen.add(node); + if (node.logicalOperator === "or" && node.description && Array.isArray(node.items)) { + const values = node.items.filter(item => item.type !== "null"); + if (values.length === 1 && values.length < node.items.length) { + values[0].description ??= node.description; + } + } + Object.values(node).forEach(visit); + }; + visit(ir); +} + +// `metadata: true` attaches each OpenAPI description to its schema, so +// consumers can read field descriptions at runtime. +// Since 0.95, validators only compose request schemas when another plugin asks +// for them. Keep our public *Data exports without enabling SDK validation. +const validatorOptions = { + metadata: true, + requests: { shouldExtract: true }, + "~hooks": { + events: { + "plugin:handler:before": ({ plugin }) => describeNullableValues(plugin.context.ir), + "plugin:handler:after": ({ plugin }) => { + plugin.forEach("operation", ({ operation }) => { + plugin.api.createRequestSchema({ operation, plugin }); + }); + }, + }, + }, +}; + +// When `plugins` is specified, the defaults (TypeScript + SDK + client) are no +// longer implicit, so the first group lists them explicitly. +const PLUGIN_GROUPS = { + client: ["@hey-api/typescript", "@hey-api/sdk"], + valibot: [{ name: "valibot", ...validatorOptions }], + // Hey API infers Zod 4 output from the `zod` peer range. + zod: [{ name: "zod", ...validatorOptions }], +}; + +function runGroup(input, output, group) { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL(import.meta.url), { workerData: { heyApi: { input, output, group } } }); + worker.once("error", reject); + worker.once("exit", code => code === 0 ? resolve() : reject(new Error(`Hey API ${group} worker exited with code ${code}`))); + }); +} + +/** Write the client into `output`, and the validator schemas next to it. */ +export async function generateClient(input, output) { + const scratch = mkdtempSync(join(tmpdir(), "sentry-api-hey-")); + try { + const runs = Object.keys(PLUGIN_GROUPS).map(group => ({ group, dir: group === "client" ? output : join(scratch, group) })); + await Promise.all(runs.map(({ group, dir }) => runGroup(input, dir, group))); + for (const { dir } of runs.filter(run => run.dir !== output)) { + for (const name of readdirSync(dir)) cpSync(join(dir, name), join(output, name), { recursive: true }); + } + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +if (workerData?.heyApi) { + const { input, output, group } = workerData.heyApi; + const { createClient } = await import("@hey-api/openapi-ts"); + await createClient({ input, output, plugins: PLUGIN_GROUPS[group] }); +} diff --git a/static/packages/api/lib/normalize-spec.mjs b/static/packages/api/lib/normalize-spec.mjs new file mode 100644 index 000000000000..023e5a9d555b --- /dev/null +++ b/static/packages/api/lib/normalize-spec.mjs @@ -0,0 +1,285 @@ +/** + * Normalizes verbose English sentence operationIds in the OpenAPI spec to short, + * REST-conventional camelCase identifiers. + * + * Rules: + * GET /…/collection/ → list{Resource}s + * GET /…/collection/{id}/ → get{Resource} (singularize last segment) + * POST /…/collection/ → create{Resource} (singular — creating one item) + * PUT/PATCH /…/resource/{id}/ → update{Resource} + * DELETE /…/resource/{id}/ → delete{Resource} + * DELETE /…/collection/ → delete{Resource}s (bulk) + * + * Any operationId that is already an identifier (no spaces) is left untouched — + * those were set intentionally via @extend_schema(operation_id=...). + * + * It also drops `format: uint64`, and `format: int64` from integer schemas + * whose bounds fit in a JS number, so the generated validators match the + * generated `number` types. + */ + +import { readFileSync, writeFileSync } from "node:fs"; + +// --------------------------------------------------------------------------- +// Singularization +// --------------------------------------------------------------------------- + +const IRREGULAR = { + activities: "activity", + queries: "query", + aliases: "alias", + indices: "index", + statuses: "status", + checkins: "checkin", + analyses: "analysis", +}; + +/** + * Returns the singular form of a lowercase English noun. + * + * Handles the most common patterns found in Sentry API path segments. + * Returns the word unchanged when no rule applies or when slicing would + * produce an empty string (e.g. the single-character input "s"). + * + * @param {string} word - Lowercase noun, e.g. "issues", "activities" + * @returns {string} Singular form, e.g. "issue", "activity" + */ +export function singularize(word) { + if (IRREGULAR[word]) return IRREGULAR[word]; + if (word.endsWith("ies")) return word.slice(0, -3) + "y"; + // -shes → -sh, -ches → -ch, -xes → -x, -zes → -z + if (/(?:sh|ch|x|z)es$/.test(word)) return word.slice(0, -2); + // -ses → strip s (releases → release), but -sses → strip es (classes → class) + if (word.endsWith("sses")) return word.slice(0, -2); + if (word.endsWith("ses")) return word.slice(0, -1); + // Skip -ss (class), -us (status), -is (analysis), -as (alias) — already singular. + if ( + word.endsWith("s") && + !word.endsWith("ss") && + !word.endsWith("us") && + !word.endsWith("is") && + !word.endsWith("as") + ) { + const stem = word.slice(0, -1); + return stem.length > 0 ? stem : word; + } + return word; +} + +// --------------------------------------------------------------------------- +// Path parsing +// --------------------------------------------------------------------------- + +/** + * Splits a path segment into its words on hyphen/underscore boundaries. + * Empty words from consecutive or trailing separators (e.g. "foo--bar") are + * dropped so downstream camelCasing never sees an empty string. + */ +function splitWords(s) { + return s.split(/[-_]/).filter(Boolean); +} + +/** Joins words into camelCase: ["events", "timeseries"] → "eventsTimeseries" */ +function camelJoin(words) { + return words.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join(""); +} + +/** + * Converts a path segment to camelCase, optionally singularizing its last word. + * + * Singularizing the *last word* (not the full camelCase string) is critical for + * compound segments: "release-threshold-statuses" must become + * "releaseThresholdStatus", not "releaseThresholdStatuse". Splitting first means + * singularize() receives the bare word "statuses" and can match the IRREGULAR table. + * + * Both `value` and `singularValue` go through this single path so they can never + * diverge on separator handling. + */ +function segmentToCamel(s, { singular } = {}) { + const words = splitWords(s); + if (singular && words.length > 0) { + words[words.length - 1] = singularize(words[words.length - 1]); + } + return camelJoin(words); +} + +/** + * Parses an API path into an array of segment descriptors. + * + * Each descriptor carries: + * - `value`: the segment converted to camelCase (plural kept) + * - `singularValue`: the segment with its last word singularized, then camelCased + * - `isParam`: true when the segment is a path parameter ({…}) + * - `followedByParam`: true when the next segment is a path parameter + * + * Empty segments (from leading/double slashes) are dropped upfront. + * + * @param {string} path - e.g. "/api/0/organizations/{org}/issues/{id}/" + * @returns {{ value: string; singularValue: string; isParam: boolean; followedByParam: boolean }[]} + */ +export function parsePath(path) { + const clean = path.replace(/^\/api\/0\//, "").replace(/\/$/, ""); + const raw = clean.split("/").filter((s) => s.length > 0); + + return raw.map((s, i) => { + const isParam = s.startsWith("{"); + return { + value: isParam ? s : segmentToCamel(s), + singularValue: isParam ? s : segmentToCamel(s, { singular: true }), + isParam, + followedByParam: raw[i + 1]?.startsWith("{") ?? false, + }; + }); +} + +// --------------------------------------------------------------------------- +// Name generation +// --------------------------------------------------------------------------- + +const VERBS = { + post: "create", + put: "update", + patch: "update", + delete: "delete", +}; + +/** + * Derives a short, REST-conventional camelCase identifier from an HTTP method + * and URL path. + * + * @param {string} method - HTTP method (lowercase: "get", "post", …) + * @param {string} path - Full path, e.g. "/api/0/organizations/{org}/issues/{id}/" + * @returns {string} Normalized identifier, e.g. "getOrganizationIssue" + * @throws {Error} If the path produces no usable resource name (no static + * segments, or only separator-character segments) + */ +export function normalizeOperationId(method, path) { + const segments = parsePath(path); + const statics = segments.filter((s) => !s.isParam); + const isDetail = segments.at(-1)?.isParam ?? false; + const verb = method === "get" ? (isDetail ? "get" : "list") : (VERBS[method] ?? method); + + // Apply singularization in a single pass: + // - any segment immediately before a {param} scopes a specific resource + // - the final segment for detail GETs and creates (operating on one item) + const parts = statics + .map((s, i) => { + const isLast = i === statics.length - 1; + const needsSingular = s.followedByParam || (isLast && (isDetail || verb === "create")); + return needsSingular ? s.singularValue : s.value; + }) + .filter(Boolean); // drop all-separator segments (e.g. "---") that camelCase to "" + + const resource = camelJoin(parts); + + // Guard the real invariant: the assembled resource must be non-empty. This + // covers a path with no static segments and one whose only static segments + // are separators (e.g. "/---/"); interior separator segments are dropped by + // the filter above so they can't crash the camelJoin assembly. + if (resource.length === 0) { + throw new Error( + `normalizeOperationId: path "${path}" (${method.toUpperCase()}) has no ` + + `usable static segments. Set operation_id explicitly via ` + + `@extend_schema(operation_id=...).`, + ); + } + + return verb + resource[0].toUpperCase() + resource.slice(1); +} + +// --------------------------------------------------------------------------- +// Spec rewriting +// --------------------------------------------------------------------------- + +/** + * Removes `format: int64` from integer schemas bounded within the safe integer + * range. Hey API types int64 as `number` but makes the Valibot and Zod schemas + * coerce to `bigint`, so a bounded field such as a UInt32 (which needs int64 + * in OpenAPI) would validate to a different type than the SDK accepts. + * + * Also removes every `format: uint64`. Hey API types it as `number` too, and + * its schemas coerce to `bigint` with `BigInt(…)` bounds that run at import + * time. These fields describe JSON numbers (such as event context sizes), + * which `JSON.parse` already reads as `number`. + */ +function dropNumberSafeFormats(node) { + if (Array.isArray(node)) { + node.forEach(dropNumberSafeFormats); + return; + } + if (typeof node !== "object" || node === null) return; + if ( + node.type === "integer" + && ( + node.format === "uint64" + || ( + node.format === "int64" + && Number.isSafeInteger(node.minimum) + && Number.isSafeInteger(node.maximum) + ) + ) + ) { + delete node.format; + } + Object.values(node).forEach(dropNumberSafeFormats); +} + +/** + * Reads the spec at `inputPath`, rewrites all sentence-style operationIds to + * short REST-conventional names, drops uint64 formats and int64 formats from + * integers that fit in a JS number, and writes the result to `outputPath`. + * + * Throws on name collisions — two operations normalizing to the same identifier + * would produce duplicate SDK exports, which must be resolved explicitly via + * @extend_schema(operation_id=...) on the backend endpoint. + * + * @param {string} inputPath - Path to the source spec (e.g. openapi-derefed.json) + * @param {string} outputPath - Path to write the normalized spec to + */ +export function normalizeSpec(inputPath, outputPath) { + // JSON.parse produces a fresh object every call; mutations are safe and + // intentional — we rewrite operationIds in place before serialising. + const spec = JSON.parse(readFileSync(inputPath, "utf8")); + // Track every final operationId (rewritten or pre-existing) so that a manually + // set clean identifier cannot collide with a path-derived one without detection. + const seen = new Map(); // final operationId → "METHOD /path" that first claimed it + + for (const [path, methods] of Object.entries(spec.paths ?? {})) { + for (const [method, op] of Object.entries(methods)) { + if (typeof op !== "object" || op === null || !op.operationId) continue; + + const ref = `${method.toUpperCase()} ${path}`; + + if (!/\s/.test(op.operationId)) { + // Already a clean identifier — register it so sentence-style rewrites + // on other paths cannot silently collide with it. + const claimedBy = seen.get(op.operationId); + if (claimedBy) { + throw new Error( + `normalizeSpec: collision — "${op.operationId}" claimed by both ` + + `"${claimedBy}" and "${ref}". ` + + `Set operation_id explicitly on one of them via @extend_schema(operation_id=...).`, + ); + } + seen.set(op.operationId, ref); + continue; + } + + const normalized = normalizeOperationId(method, path); + const claimedBy = seen.get(normalized); + if (claimedBy) { + throw new Error( + `normalizeSpec: collision — "${normalized}" claimed by both ` + + `"${claimedBy}" and "${ref}". ` + + `Set operation_id explicitly on one of them via @extend_schema(operation_id=...).`, + ); + } + seen.set(normalized, ref); + op.operationId = normalized; + } + } + + dropNumberSafeFormats(spec); + writeFileSync(outputPath, JSON.stringify(spec)); + return spec; +} diff --git a/static/packages/api/lib/operation-catalog.mjs b/static/packages/api/lib/operation-catalog.mjs new file mode 100644 index 000000000000..e70345acf52e --- /dev/null +++ b/static/packages/api/lib/operation-catalog.mjs @@ -0,0 +1,67 @@ +/** Combine original OpenAPI metadata with the generated SDK's operation identities. */ +import { parseSdkOperations } from "./sdk-operations.mjs"; + +const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options", "trace"]); +// RFC 9110 9.2.1: safe methods never change server state, so they are always idempotent. +const SAFE_METHODS = new Set(["get", "head", "options", "trace"]); + +/** Operation-level parameters replace path-level parameters with the same name/in. */ +function queryParameters(pathItem, operation) { + const parameters = new Map(); + for (const parameter of [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])]) { + if (parameter.in === "query") parameters.set(parameter.name, parameter); + } + return [...parameters.values()].map(parameter => { + const schema = parameter.schema; + const values = schema?.type === "array" ? schema.items?.enum : schema?.enum; + return { + name: parameter.name, + required: parameter.required === true, + ...(parameter.description === undefined ? {} : { description: parameter.description.trim() }), + ...(schema?.type === undefined ? {} : { type: schema.type }), + ...(values === undefined ? {} : { + enum: values.filter(value => value === null || ["string", "number", "boolean"].includes(typeof value)), + }), + }; + }); +} + +/** + * Build a deterministic discovery catalog for every generated SDK operation. + * Preserve original operationIds, which can differ from normalized function names. + * @param {{paths: object}} spec Original dereferenced OpenAPI document. + * @param {string} sdkSource Contents of sdk.gen.ts generated from its normalized copy. + * @returns {import("./operation-types.ts").ApiOperation[]} + */ +export function extractOperations(spec, sdkSource) { + const sdk = parseSdkOperations(sdkSource); + const functions = new Map(sdk.map(operation => [`${operation.method} ${operation.path}`, operation.functionName])); + const operations = []; + for (const [path, pathItem] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!HTTP_METHODS.has(method)) continue; + const key = `${method.toUpperCase()} ${path}`; + const functionName = functions.get(key); + if (!functionName) throw new Error(`Missing SDK operation for ${key}`); + functions.delete(key); + operations.push({ + functionName, + operationId: operation.operationId ?? "", + method: method.toUpperCase(), + path, + ...(operation.summary === undefined ? {} : { summary: operation.summary.trim() }), + description: (operation.description ?? "").trim(), + ...(operation.tags === undefined ? {} : { tags: operation.tags }), + pathParameters: [...path.matchAll(/\{([^}]+)\}/g)].map(match => match[1]), + queryParameters: queryParameters(pathItem, operation), + deprecated: operation.deprecated === true, + // Sentry's schema build marks PUBLIC_EXPERIMENTAL endpoints with this extension. + experimental: operation["x-sentry-experimental"] === true, + // Other methods count only when Sentry marks them; PUT/DELETE handlers are not all idempotent. + idempotent: SAFE_METHODS.has(method) || operation["x-sentry-idempotent"] === true, + }); + } + } + if (functions.size > 0) throw new Error(`SDK operations missing from OpenAPI: ${[...functions.keys()].join(", ")}`); + return operations.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); +} diff --git a/static/packages/api/lib/operation-types.ts b/static/packages/api/lib/operation-types.ts new file mode 100644 index 000000000000..baed4d0a5183 --- /dev/null +++ b/static/packages/api/lib/operation-types.ts @@ -0,0 +1,48 @@ +/** Searchable query metadata from the release's OpenAPI specification. */ +export type ApiQueryParameter = { + /** Parameter name accepted by the operation. */ + readonly name: string; + /** Whether the request must include this parameter. */ + readonly required: boolean; + /** Usage description, when documented. */ + readonly description?: string; + /** OpenAPI schema type, when declared. */ + readonly type?: string; + /** Allowed scalar values, or allowed item values for an array parameter. */ + readonly enum?: readonly (string | number | boolean | null)[]; +}; + +/** An operation shipped by this SDK version, without consumer-specific policy. */ +export type ApiOperation = { + /** Actual exported SDK function name, after name normalization. */ + readonly functionName: string; + /** Original OpenAPI identifier; an empty string means it was not declared. */ + readonly operationId: string; + /** HTTP method used by the generated operation. */ + readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "TRACE"; + /** Canonical OpenAPI path including parameter placeholders. */ + readonly path: string; + /** Short operation description, when documented. */ + readonly summary?: string; + /** Detailed operation description; empty when undocumented. */ + readonly description: string; + /** Categories assigned by the OpenAPI specification. */ + readonly tags?: readonly string[]; + /** Parameter names occurring in the path template. */ + readonly pathParameters: readonly string[]; + /** Query parameters after applying operation overrides to path-level parameters. */ + readonly queryParameters: readonly ApiQueryParameter[]; + /** Whether OpenAPI explicitly marks the operation deprecated. */ + readonly deprecated: boolean; + /** + * Whether Sentry marks the operation experimental with `x-sentry-experimental`. + * Sentry documents these operations as under active development and liable to change. + */ + readonly experimental: boolean; + /** + * Whether resending an identical request after a timeout or 5xx cannot change the outcome. + * Always true for GET, HEAD, OPTIONS, and TRACE; other methods only when Sentry marks them + * with `x-sentry-idempotent`. False means "not known to be idempotent". + */ + readonly idempotent: boolean; +}; diff --git a/static/packages/api/lib/query-defaults.mjs b/static/packages/api/lib/query-defaults.mjs new file mode 100644 index 000000000000..00570f21f303 --- /dev/null +++ b/static/packages/api/lib/query-defaults.mjs @@ -0,0 +1,106 @@ +/** + * Client-side query defaults for specific operations. + * + * These are SDK choices, not API facts: the server's own default is unchanged, + * so they stay out of the OpenAPI schema. Callers override a default by passing + * the parameter themselves; `[]` sends nothing. Only array-valued enum query + * parameters are supported, so that `[]` is always an explicit, type-safe + * opt-out. + */ +import ts from "typescript"; + +// Skips the next/previous-event Snuba queries. No known SDK consumer reads the +// adjacent IDs; pass `collapse: []` to get them. +const SKIP_ADJACENT_EVENTS = { + query: { collapse: ["adjacentEvents"] }, + effect: "skips looking up the neighboring events (`nextEventID` and `previousEventID` are `null`)", +}; + +/** + * Keyed by `${METHOD} ${path}`. Each value must be in the parameter's enum. + * `effect` completes "By default, the SDK sends `…`, which …" in the JSDoc. + */ +export const QUERY_DEFAULTS = new Map([ + // getsentry/sentry#129094 + ["GET /api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/events/{event_id}/", SKIP_ADJACENT_EVENTS], + // getsentry/sentry#129123 + ["GET /api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/events/{event_id}/", SKIP_ADJACENT_EVENTS], +]); + +/** Fail the build when the schema no longer documents a configured default. */ +function validate(spec, route, query) { + const [method, path] = route.split(" "); + const operation = spec.paths?.[path]?.[method.toLowerCase()]; + if (!operation) throw new Error(`Query default for missing operation: ${route}`); + for (const [name, values] of Object.entries(query)) { + const schema = operation.parameters?.find(p => p.in === "query" && p.name === name)?.schema; + if (schema?.type !== "array" || !Array.isArray(schema.items?.enum)) { + throw new Error(`Query default ${name} is not an array enum parameter: ${route}`); + } + if (!Array.isArray(values) || values.length === 0 || values.some(value => !schema.items.enum.includes(value))) { + throw new Error(`Query default ${name} must be a non-empty list of documented values: ${route}`); + } + } +} + +/** + * Rewrite sdk.gen.ts so each configured operation fills missing query defaults. + * Standalone functions, bound client methods, and generated wrappers all call + * these functions, so they share one behavior. + * @param {{paths: object}} spec OpenAPI document the SDK was generated from. + * @param {string} text Contents of sdk.gen.ts. + * @param {Map, effect: string}>} defaults + * @returns {string} Rewritten sdk.gen.ts. + */ +export function applyQueryDefaults(spec, text, defaults = QUERY_DEFAULTS) { + for (const [route, { query }] of defaults) validate(spec, route, query); + + const source = ts.createSourceFile("sdk.gen.ts", text, ts.ScriptTarget.Latest, true); + const edits = []; + const applied = new Set(); + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + const fn = declaration.initializer; + const call = fn && ts.isArrowFunction(fn) && ts.isCallExpression(fn.body) && fn.body; + const request = call && ts.isPropertyAccessExpression(call.expression) && call.arguments[0]; + if (!request || !ts.isObjectLiteralExpression(request)) continue; + const url = request.properties.find(property => + ts.isPropertyAssignment(property) && property.name.getText(source) === "url"); + if (!url || !ts.isStringLiteralLike(url.initializer)) continue; + const route = `${call.expression.name.text.toUpperCase()} ${url.initializer.text}`; + const entry = defaults.get(route); + if (!entry) continue; + + const spread = request.properties.at(-1); + if (!spread || !ts.isSpreadAssignment(spread) || spread.expression.getText(source) !== "options") { + throw new Error(`Expected options to be spread last for ${route}`); + } + // `??` keeps caller values, including `[]`, which serializes to nothing. + const fields = Object.entries(entry.query).map(([name, values]) => + `${JSON.stringify(name)}: options?.query?.[${JSON.stringify(name)}] ?? ${JSON.stringify(values)}`); + edits.push({ start: spread.end, end: spread.end, text: `,\n query: { ...options?.query, ${fields.join(", ")} }` }); + + const doc = statement.jsDoc?.at(-1); + if (doc) { + const sends = Object.entries(entry.query).map(([name, values]) => `\`${name}: ${JSON.stringify(values)}\``).join(", "); + const optOut = Object.keys(entry.query).map(name => `\`${name}: []\``).join(", "); + const close = doc.end - 2; + edits.push({ + start: close, + end: close, + text: `*\n * By default, the SDK sends ${sends}, which ${entry.effect}.` + + ` Passing the parameter replaces the default; ${optOut} sends none.\n `, + }); + } + applied.add(route); + } + } + const missing = [...defaults.keys()].filter(route => !applied.has(route)); + if (missing.length > 0) throw new Error(`Query defaults for missing SDK operations: ${missing.join(", ")}`); + + for (const { start, end, text: insert } of edits.sort((a, b) => b.start - a.start)) { + text = text.slice(0, start) + insert + text.slice(end); + } + return text; +} diff --git a/static/packages/api/lib/sdk-operations.mjs b/static/packages/api/lib/sdk-operations.mjs new file mode 100644 index 000000000000..c00f0479276b --- /dev/null +++ b/static/packages/api/lib/sdk-operations.mjs @@ -0,0 +1,53 @@ +/** Read operation identities from generated SDK source before bundling. */ +import ts from "typescript"; + +const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options", "trace"]); + +/** Find Options, including optional rest-tuple arguments. */ +function findOptions(node, source) { + if (ts.isTypeReferenceNode(node) && node.typeName.getText(source) === "Options") return node; + return ts.forEachChild(node, child => findOptions(child, source)); +} + +/** + * Extract exported function names, data types, HTTP methods and canonical paths. + * Fail on unsupported generator output instead of publishing incomplete metadata. + * @param {string} text Contents of sdk.gen.ts. + * @returns {{functionName:string, dataType:string, method:string, path:string}[]} + */ +export function parseSdkOperations(text) { + const source = ts.createSourceFile("sdk.gen.ts", text, ts.ScriptTarget.Latest, true); + const operations = []; + const routes = new Set(); + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement) || + !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue; + for (const declaration of statement.declarationList.declarations) { + const fn = declaration.initializer; + if (!ts.isIdentifier(declaration.name) || !fn || !ts.isArrowFunction(fn)) { + throw new Error(`Unsupported SDK operation: ${declaration.name.getText(source)}`); + } + const options = fn.parameters[0]?.type && findOptions(fn.parameters[0].type, source); + const dataType = options?.typeArguments?.[0]; + const call = ts.isCallExpression(fn.body) && fn.body; + const callee = call && call.expression; + const request = call && call.arguments[0]; + const url = request && ts.isObjectLiteralExpression(request) && request.properties.find(property => + ts.isPropertyAssignment(property) && + (ts.isStringLiteral(property.name) ? property.name.text : property.name.getText(source)) === "url"); + if (!dataType || !ts.isTypeReferenceNode(dataType) || !ts.isIdentifier(dataType.typeName) || + !callee || !ts.isPropertyAccessExpression(callee) || !HTTP_METHODS.has(callee.name.text) || + !url || !ts.isStringLiteralLike(url.initializer)) { + throw new Error(`Unsupported SDK operation: ${declaration.name.text}`); + } + const method = callee.name.text.toUpperCase(); + const path = url.initializer.text; + const key = `${method} ${path}`; + if (routes.has(key)) throw new Error(`Multiple SDK operations for ${key}`); + routes.add(key); + operations.push({ functionName: declaration.name.text, dataType: dataType.typeName.text, method, path }); + } + } + if (operations.length === 0) throw new Error("No SDK operations found"); + return operations; +} diff --git a/static/packages/api/lib/sentry-errors.ts b/static/packages/api/lib/sentry-errors.ts new file mode 100644 index 000000000000..2b8f305fcc80 --- /dev/null +++ b/static/packages/api/lib/sentry-errors.ts @@ -0,0 +1,158 @@ +/** + * The generated SDK's non-throwing result shape. + * + * Defined locally so the hand-written helpers never import generated code. + */ +export type SdkResult = + | { + data: TData; + error?: never; + request: Request; + response: Response; + } + | { + data?: never; + error: TError; + request: Request; + response: Response | undefined; + }; + +type ErrorStatus = Extract; + +/** An API error whose status and body were declared by the operation. */ +export type DocumentedSentryApiError = { + [TStatus in ErrorStatus]: SentryApiError< + TStatus, + TErrorMap[TStatus], + true + >; +}[ErrorStatus]; + +/** An API error returned under a status absent from the operation's schema. */ +export type UndocumentedSentryApiError = SentryApiError< + number, + unknown, + false +>; + +/** + * @deprecated Non-HTTP failures propagate unchanged. Typed-error helpers only + * return HTTP failures and no longer produce this legacy wrapper. + */ +export type SentryApiTransportError = SentryApiError< + undefined, + unknown, + false +>; + +export type SentryApiResultError = + | DocumentedSentryApiError + | UndocumentedSentryApiError; + +export type NarrowedResult = + | { ok: true; data: TData; response: Response } + | { ok: false; error: SentryApiResultError }; + +/** An API failure with its parsed body and raw response. */ +export class SentryApiError< + TStatus extends number | undefined = number | undefined, + TBody = unknown, + TDocumented extends boolean = boolean, +> extends Error { + readonly status: TStatus; + readonly body: TBody; + readonly response: Response | undefined; + readonly documented: TDocumented; + readonly request?: Request; + /** Original response text, captured during the same read as the parsed body. */ + readonly rawBody?: string; + + constructor( + status: TStatus, + body: TBody, + response: Response | undefined, + documented: TDocumented, + message = status === undefined + ? "Sentry API request failed before receiving a response" + : `Sentry API request failed with status ${status}`, + options?: ErrorOptions & { request?: Request; rawBody?: string }, + ) { + super(message, options); + this.name = "SentryApiError"; + this.status = status; + this.body = body; + this.response = response; + this.documented = documented; + this.request = options?.request; + this.rawBody = options?.rawBody; + } +} + +/** + * Convert an SDK result to a status-discriminated HTTP result. + * + * Prefer the generated `narrowError_` wrappers. They supply the + * operation's complete error map and documented statuses automatically. + * Base SentryApiError instances for this response are reclassified, including + * ones supplied by an interceptor. Other errors (including subclasses) are + * thrown unchanged. Status narrowing does not validate the response body. + */ +export const narrowError = ( + result: SdkResult, + documentedStatuses: ReadonlyArray>, +): NarrowedResult => { + if (!("error" in result)) { + return { + ok: true, + data: result.data, + response: result.response, + }; + } + + const original = result.error; + if ( + !(original instanceof SentryApiError) || + original.constructor !== SentryApiError || + typeof original.status !== "number" || + result.response === undefined || + original.response !== result.response + ) { + throw original; + } + + const status = original.status; + const documented = documentedStatuses.includes( + status as ErrorStatus, + ); + const error = new SentryApiError( + status, + original.body, + original.response, + documented, + original.message, + { + cause: original.cause, + request: original.request ?? result.request, + rawBody: original.rawBody, + }, + ); + + // OpenAPI binds each documented response status to its body schema. The + // generated SDK flattens that map, so restore the relationship here. + return { + ok: false, + error: error as SentryApiResultError, + }; +}; + +/** + * Call an SDK operation and narrow its default HTTP errors by status. + * Generated operation wrappers use this helper. Errors outside narrowError's + * HTTP contract reject without being relabeled or wrapped. + */ +export const callWithTypedErrors = async ( + call: () => Promise>, + documentedStatuses: ReadonlyArray>, +): Promise> => { + return narrowError(await call(), documentedStatuses); +}; diff --git a/static/packages/api/lib/sentry-pagination.ts b/static/packages/api/lib/sentry-pagination.ts new file mode 100644 index 000000000000..5073ddf7480a --- /dev/null +++ b/static/packages/api/lib/sentry-pagination.ts @@ -0,0 +1,436 @@ +/** + * Sentry API pagination utilities. + * + * Sentry uses cursor-based pagination via HTTP Link headers. + * These helpers make it ergonomic to paginate through results + * returned by the generated SDK functions. + */ + +import type { SdkResult } from "./sentry-errors"; + +export type UnwrappedResult = { + data: TData; + response: Response; +}; + +export type PaginatedResponse = { + data: T; + /** The HTTP response for this page, including headers. Its body is already read. */ + response: Response; + /** Cursor for the next page. `undefined` when there are no more pages. */ + nextCursor?: string; + /** Cursor for the previous page. `undefined` on the first page. */ + prevCursor?: string; +}; + +/** A position within a page fetched with an opaque server cursor. */ +export type PagePosition = { + readonly cursor?: string; + /** Rows already consumed, before applying the next item budget. */ + readonly offset: number; +}; + +/** + * Serializable continuation returned by `paginateUpTo`. + * Keep the same operation, query and fetcher configuration when resuming. + * Partial pages retain the fetcher's original request budget for exact replay; + * page boundaries use the next call's budget instead. + */ +export type PaginationContinuation = PagePosition & ( + | { readonly offset: 0; readonly requestBudget?: never } + | { readonly requestBudget: number } +); + +/** Aggregate data has no single HTTP response. Only `exhausted` means complete. */ +export type PaginatedCollection = { + data: Array; + stopReason: "exhausted" | "limit" | "maxPages"; + /** Omitted when exhausted or when trimming a page makes continuation unsafe. */ + nextCursor?: string; + /** `paginateUpTo` can resume here, including within a partially consumed page. */ + continuation?: PaginationContinuation; +}; + +export type PaginateAllOptions = { + /** Hard cap on the number of pages fetched. Default: 50. */ + maxPages?: number; + /** Resume from a previous nextCursor, keeping the same filters and page size. */ + startCursor?: string; +}; + +export type PaginateUpToOptions = { + /** Hard cap on the number of items returned. Required. */ + limit: number; + /** Safety cap on the number of pages fetched. Default: 50. */ + maxPages?: number; + /** Resume forward traversal from a previous nextCursor, keeping the same query. */ + startCursor?: string; + /** Resume a batch, including pending rows in its last page. Cannot combine with startCursor. */ + resume?: PaginationContinuation; + /** Called after each page is fetched. Useful for progress indicators. */ + onPage?: (fetched: number, limit: number) => void; +}; + +export type PageFetcher = ( + cursor: string | undefined, +) => Promise>; + +/** + * The request budget lets a custom fetcher reduce its HTTP page size. + * Normally the remaining item budget; replaying a partial page repeats the + * original value even when the new batch has a different limit. + */ +export type BudgetedPageFetcher = ( + cursor: string | undefined, + requestBudget: number, +) => Promise>; + +/** A page whose consumer already handled the HTTP response and any errors. */ +export type CollectionPage = { + data: Array; + nextCursor?: string; + /** Reserved for SDK operation envelopes; normalized fetchers reject on failure. */ + request?: never; + error?: never; +}; + +/** Compose an existing page fetcher with bounded collection, without HTTP objects. */ +export type CollectionPageFetcher = ( + cursor: string | undefined, + requestBudget: number, +) => Promise>; + +function assertPositiveInteger(value: unknown, name: string): asserts value is number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive safe integer, got ${value}`); + } +} + +function assertPagePosition(position: PagePosition): void { + if (!Number.isSafeInteger(position.offset) || position.offset < 0) { + throw new Error(`pagination: offset must be a non-negative safe integer, got ${position.offset}`); + } + if (position.cursor !== undefined && typeof position.cursor !== "string") { + throw new Error("pagination: cursor must be a string"); + } +} + +/** + * Consume rows from an already fetched page without fetching or interpreting cursors. + * Returns a position for the next consumption, or none when the page is fully + * consumed and the server advertises no next page. The caller retains the + * original response, headers and envelope; neither input is mutated. + * + * A position inside the current page requires fetching that page again with + * the same request options. An offset beyond a short page carries forward to + * its successor. Like server pagination, this does not snapshot changing data. + */ +export function consumePage( + page: Pick>, "data" | "nextCursor">, + position: PagePosition, + limit = Number.POSITIVE_INFINITY, +): { data: Array; position?: PagePosition } { + assertPagePosition(position); + if (limit !== Number.POSITIVE_INFINITY && (!Number.isSafeInteger(limit) || limit < 0)) { + throw new Error(`consumePage: limit must be a non-negative safe integer, got ${limit}`); + } + const end = Math.min(page.data.length, position.offset + limit); + const data = page.data.slice(position.offset, end); + if (end < page.data.length) { + return { data, position: { ...position, offset: end } }; + } + if (page.nextCursor !== undefined) { + return { + data, + position: { cursor: page.nextCursor, offset: Math.max(0, position.offset - page.data.length) }, + }; + } + return { data }; +} + +/** + * Parse Sentry's Link header to extract pagination cursors. + * + * Sentry returns Link headers in the format: + * ; rel="previous"; results="true"; cursor="abc:0:1";, + * ; rel="next"; results="true"; cursor="1234:0:0"; + * + * Returns `{ nextCursor?, prevCursor? }`: + * - `nextCursor` set when there is a next page. + * - `prevCursor` set when there is a previous page. + * + * The `results="true"` qualifier is required — Sentry includes a + * `previous` rel even on the first page, but with `results="false"`. + * We honor that signal so first-page callers don't see a bogus `prevCursor`. + */ +export const parseSentryLinkHeader = ( + header: string | null, +): { nextCursor?: string; prevCursor?: string } => { + if (!header) { + return {}; + } + + const segments = header.split(","); + + let nextCursor: string | undefined; + let prevCursor: string | undefined; + + for (const segment of segments) { + const parts = segment.trim().split(";").map((s) => s.trim()); + + let rel: string | undefined; + let results: string | undefined; + let cursor: string | undefined; + + for (const part of parts) { + const relMatch = part.match(/^rel="([^"]*)"$/); + if (relMatch) { + rel = relMatch[1]; + continue; + } + const resultsMatch = part.match(/^results="([^"]*)"$/); + if (resultsMatch) { + results = resultsMatch[1]; + continue; + } + const cursorMatch = part.match(/^cursor="([^"]*)"$/); + if (cursorMatch) { + cursor = cursorMatch[1]; + continue; + } + } + + if (results !== "true" || !cursor) { + continue; + } + if (rel === "next") { + nextCursor = cursor; + } else if (rel === "previous") { + prevCursor = cursor; + } + } + + const out: { nextCursor?: string; prevCursor?: string } = {}; + if (nextCursor !== undefined) out.nextCursor = nextCursor; + if (prevCursor !== undefined) out.prevCursor = prevCursor; + return out; +}; + +/** + * Internal: merge a managed cursor and verified page-size budget into `options.query` + * and re-shape the result back to the SDK's `Options` type. + * + * Used exclusively by the auto-generated wrappers in `pagination.gen.ts` + * (one call per wrapper kind, one `_withCursor` invocation per page). + * Centralizes the cast chain — every wrapper used to inline its own + * `as unknown as ...` quartet, which meant the same logic was repeated + * once per generated wrapper (~115 places). This helper makes that + * exactly one place. + * + * Type-erasure rationale: each SDK operation has its own `Options` + * shape with operation-specific `query`, `path`, and `body` types. We + * can't write a generic that's tight enough to satisfy all 200+ SDK + * functions structurally without committing to a discriminated-union + * encoding of every operation. The `_` prefix marks this as internal — + * the typed wrappers in `pagination.gen.ts` are the supported public API. + * + * @internal + */ +export const _withCursor = ( + options: { query?: unknown; [k: string]: unknown }, + cursor: string | undefined, + pageSize?: { parameter: "per_page" | "limit"; max: number; remaining: number }, +): TOptions => { + const query = options.query as Record | undefined; + let size: number | undefined; + if (pageSize !== undefined) { + const requested = query?.[pageSize.parameter] ?? pageSize.max; + assertPositiveInteger(requested, `pagination: ${pageSize.parameter}`); + size = Math.min(requested, pageSize.max, pageSize.remaining); + } + if (query === undefined && cursor === undefined && pageSize === undefined) { + return options as unknown as TOptions; + } + return { + ...options, + query: { + ...query, + ...(cursor !== undefined ? { cursor } : undefined), + ...(pageSize !== undefined ? { [pageSize.parameter]: size } : undefined), + }, + } as unknown as TOptions; +}; + +/** + * Unwrap an SDK result, throwing on error. + * + * Returns `{ data, response }` so callers retain access to the + * raw Response (and its headers) for pagination or other needs. + * + * Throws the exact error supplied by the operation, including custom mapped + * errors and failures without an HTTP response. The SDK normalizes default + * HTTP errors before this helper runs. + * + * The context argument remains accepted for compatibility. It no longer + * changes error messages or wraps errors. + */ +export const unwrapResult = ( + result: SdkResult, + _context: string, +): UnwrappedResult => { + if ("error" in result) { + throw result.error; + } + return { + data: result.data, + response: result.response, + }; +}; + +/** + * Unwrap an SDK result and extract pagination cursors from the + * Link header. Throws on error. + * + * Returns `{ data, response, nextCursor?, prevCursor? }`. Each cursor is + * `undefined` when the corresponding rel does not exist or has + * `results="false"`. + */ +export const unwrapPaginatedResult = ( + result: SdkResult, + context: string, +): PaginatedResponse => { + const { data, response } = unwrapResult(result, context); + const linkHeader = response.headers.get("link"); + const { nextCursor, prevCursor } = parseSentryLinkHeader(linkHeader); + const out: PaginatedResponse = { data, response }; + if (nextCursor !== undefined) out.nextCursor = nextCursor; + if (prevCursor !== undefined) out.prevCursor = prevCursor; + return out; +}; + +/** + * Fetch a single page from a Sentry list endpoint and return both + * the data and the pagination cursors. + * + * Thin wrapper over an SDK function call: invokes the fetcher with + * an optional cursor, unwraps the result, and parses the Link header. + * + * Useful when you want manual control over pagination (e.g. exposing + * a "next page" button in a UI) instead of fetching all pages eagerly. + * + * @example + * ```ts + * const { data, nextCursor } = await fetchPage( + * (cursor) => listAnOrganization_sRepositories({ + * path: { organization_id_or_slug: 'my-org' }, + * query: { cursor }, + * }), + * 'listRepos', + * ); + * ``` + */ +export const fetchPage = async ( + fetcher: PageFetcher, + context: string, + cursor?: string, +): Promise> => { + const result = await fetcher(cursor); + return unwrapPaginatedResult(result, context); +}; + +/** + * Collect pages until the collection is exhausted or `maxPages` is reached. + * A request cap returns `stopReason: "maxPages"` and a safe resume cursor. + */ +export const paginateAll = async ( + fetcher: PageFetcher, TError>, + context: string, + options?: PaginateAllOptions, +): Promise> => { + const maxPages = options?.maxPages ?? 50; + assertPositiveInteger(maxPages, "paginateAll: maxPages"); + const allItems: Array = []; + let cursor = options?.startCursor; + + for (let page = 0; page < maxPages; page++) { + const result = await fetcher(cursor); + const { data, nextCursor } = unwrapPaginatedResult(result, context); + allItems.push(...data); + + if (nextCursor === undefined) { + return { data: allItems, stopReason: "exhausted" }; + } + cursor = nextCursor; + } + + return { data: allItems, stopReason: "maxPages", nextCursor: cursor }; +}; + +/** + * Collect up to `limit` items, passing a request budget to the fetcher. + * Generated wrappers use it to reduce supported HTTP page sizes. A partial + * continuation replays the original request budget before skipping consumed rows. + * + * If a server still returns more items than fit, trim the page and omit its + * server cursor: resuming past discarded items would skip records. The separate + * continuation preserves the pending rows, even if the server has no further pages. + */ +export const paginateUpTo = async ( + fetcher: BudgetedPageFetcher, TError> | CollectionPageFetcher, + options: PaginateUpToOptions, + context: string, +): Promise> => { + assertPositiveInteger(options.limit, "paginateUpTo: limit"); + const maxPages = options.maxPages ?? 50; + assertPositiveInteger(maxPages, "paginateUpTo: maxPages"); + if (options.resume !== undefined && options.startCursor !== undefined) { + throw new Error("paginateUpTo: use either resume or startCursor, not both"); + } + if (options.resume !== undefined) { + assertPagePosition(options.resume); + if (options.resume.offset > 0 || options.resume.requestBudget !== undefined) { + assertPositiveInteger(options.resume.requestBudget, "paginateUpTo: resume.requestBudget"); + } + } + const allItems: Array = []; + let position: PagePosition = options.resume ?? { cursor: options.startCursor, offset: 0 }; + let replayBudget = options.resume?.offset ? options.resume.requestBudget : undefined; + let continuation: PaginationContinuation | undefined; + + for (let page = 0; page < maxPages; page++) { + const remaining = options.limit - allItems.length; + const requestBudget = replayBudget ?? remaining; + const result = await fetcher(position.cursor, requestBudget); + // SDK results carry a Request; normalized pages already own their metadata. + const { data, nextCursor } = result.request !== undefined + ? unwrapPaginatedResult(result, context) + : result; + const consumed = consumePage({ data, nextCursor }, position, remaining); + allItems.push(...consumed.data); + options.onPage?.(allItems.length, options.limit); + + if (consumed.position === undefined) { + return { data: allItems, stopReason: "exhausted" }; + } + position = consumed.position; + continuation = position.offset === 0 + ? { ...position, offset: 0 } + : { ...position, requestBudget }; + if (allItems.length === options.limit) { + return { + data: allItems, + stopReason: "limit", + ...(position.offset === 0 && position.cursor !== undefined ? { nextCursor: position.cursor } : {}), + continuation, + }; + } + replayBudget = position.offset > 0 ? requestBudget : undefined; + } + + return { + data: allItems, + stopReason: "maxPages", + ...(position.offset === 0 && position.cursor !== undefined ? { nextCursor: position.cursor } : {}), + continuation, + }; +}; diff --git a/static/packages/api/lib/source-analysis.ts b/static/packages/api/lib/source-analysis.ts new file mode 100644 index 000000000000..77c6469a269a --- /dev/null +++ b/static/packages/api/lib/source-analysis.ts @@ -0,0 +1,717 @@ +/** Build-time analysis of API usage in consumer-supplied TypeScript source. */ + +import { posix } from "node:path"; +import ts from "typescript"; +import type { ApiOperation } from "./operation-types.js"; + +/** Identity supplied by the generated SDK operation catalog. */ +export type SourceApiOperation = Pick; + +/** A consumer's raw request wrapper, identified by its resolved function name. */ +export type SourceApiRequest = { + functionName: string; + /** Zero-based argument containing the request path. */ + pathArgument: number; + /** Zero-based argument containing the options object. */ + optionsArgument: number; + /** Options property containing query parameters. Defaults to `query`. */ + queryProperty?: string; + /** Method when the options omit it. Defaults to GET; unknown spreads stay unknown. */ + defaultMethod?: string; +}; + +/** Consumer conventions; none of these lists are inferred from command names. */ +export type SourceApiAnalysisOptions = { + /** Factory names whose returned objects contain deferred callbacks. */ + boundaries?: readonly string[]; + /** Raw request wrappers to link to the operation catalog. */ + requests?: readonly SourceApiRequest[]; + /** Query keys to retain. The Sentry `query` key also retains fixed joined terms. */ + queryParameters?: readonly string[]; + /** Boolean property names to report when explicitly set to true. */ + declarationFlags?: readonly string[]; +}; + +/** An operation reachable from a source declaration. */ +export type SourceOperationUsage = SourceApiOperation & { + /** Known query values or fixed terms; callers may add further filters. */ + query?: Record; +}; + +/** A raw request whose identity cannot be established from the public catalog. */ +export type UnresolvedApiRequest = { + /** Source module, using the caller's source-map keys. */ + file: string; + /** One-based request call line. */ + line: number; + /** Known HTTP method, when statically available. */ + method?: string; + /** Known path template, when statically available. */ + path?: string; + /** Why no operation was selected. */ + reason: string; +}; + +/** Source-derived relationships; no command is executed to obtain them. */ +export type SourceApiUsage = { + /** Discovered API operations, preserving distinct contexts. */ + operations: SourceOperationUsage[]; + /** Reachable raw requests that need explicit discovery diagnostics. */ + unresolvedRequests: UnresolvedApiRequest[]; + /** Known HTTP methods, including unresolved requests and static method properties. */ + methods: string[]; +}; + +/** Source relationships and deferred callback boundaries; never executes input code. */ +export type SourceApiAnalysis = { + /** Source file → exported symbol → reachable API usage. */ + exports: Map>; + /** Exported boundaries and their consumer-selected factory/flag evidence. */ + boundaries: { file: string; name: string; factories: string[]; flags: string[] }[]; +}; + +type Binding = { file: string; name: string }; +type Declaration = { + node: ts.Node; + refs: Set; + calls: Set; + usage: SourceApiUsage; + boundary: boolean; + factories: Set; + flags: Set; +}; +type Module = { + source: ts.SourceFile; + imports: Map; + exports: Map; + stars: string[]; + declarations: Map; +}; + +const SDK = "@sentry/api"; +const API_PREFIX = /^\/api\/\d+(?=\/)/; +const PATH_PARAMETER = /\{[^{}]*\}/g; + +function unwrap(node: ts.Node): ts.Node { + return ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isParenthesizedExpression(node) || + ts.isNonNullExpression(node) + ? unwrap(node.expression) + : node; +} + +function bindsName(binding: ts.BindingName, name: string): boolean { + return ts.isIdentifier(binding) + ? binding.text === name + : binding.elements.some( + (element) => ts.isBindingElement(element) && bindsName(element.name, name), + ); +} + +/** Find lexical bindings before consulting module imports or declarations. */ +function lexicalDeclaration( + node: ts.Identifier, + includeSource = false, +): ts.Declaration | undefined { + for ( + let parent = node.parent; + parent && (includeSource || !ts.isSourceFile(parent)); + parent = parent.parent + ) { + if (ts.isFunctionLike(parent)) { + const parameter = parent.parameters.find((parameter) => + bindsName(parameter.name, node.text), + ); + if (parameter) return parameter; + } + if ( + (ts.isFunctionExpression(parent) || ts.isClassExpression(parent)) && + parent.name?.text === node.text + ) + return parent; + if ( + ts.isCatchClause(parent) && parent.variableDeclaration && + bindsName(parent.variableDeclaration.name, node.text) + ) + return parent.variableDeclaration; + if ( + (ts.isForStatement(parent) || ts.isForOfStatement(parent) || ts.isForInStatement(parent)) && + parent.initializer && ts.isVariableDeclarationList(parent.initializer) + ) { + const declaration = parent.initializer.declarations.find((declaration) => + bindsName(declaration.name, node.text), + ); + if (declaration) return declaration; + } + if (ts.isBlock(parent) || ts.isSourceFile(parent)) { + for (const statement of parent.statements) { + if (ts.isVariableStatement(statement)) { + const declaration = statement.declarationList.declarations.find((declaration) => + bindsName(declaration.name, node.text), + ); + if (declaration) return declaration; + } + if ( + (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && + statement.name?.text === node.text + ) + return statement; + } + } + } + return; +} + +function sourceModule( + file: string, + specifier: string, + sources: ReadonlyMap, +): string { + if (!specifier.startsWith(".")) return specifier; + const target = posix.normalize(posix.join(posix.dirname(file), specifier)); + const extension = posix.extname(target); + const candidates = extension === ".js" + ? [target.replace(/\.js$/, ".ts"), target.replace(/\.js$/, ".tsx"), target] + : extension + ? [target] + : [target, `${target}.ts`, `${target}.tsx`, posix.join(target, "index.ts"), posix.join(target, "index.tsx")]; + return candidates.find(candidate => sources.has(candidate)) ?? target; +} + +function parseModule( + file: string, + text: string, + sources: ReadonlyMap, +): Module { + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const mod: Module = { + source, + imports: new Map(), + exports: new Map(), + stars: [], + declarations: new Map(), + }; + for (const statement of source.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + if (statement.importClause?.isTypeOnly) continue; + const target = sourceModule( + file, + statement.moduleSpecifier.text, + sources, + ); + const defaultImport = statement.importClause?.name; + if (defaultImport) + mod.imports.set(defaultImport.text, { file: target, name: "default" }); + const bindings = statement.importClause?.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + for (const binding of bindings.elements) { + if (!binding.isTypeOnly) + mod.imports.set(binding.name.text, { + file: target, + name: (binding.propertyName ?? binding.name).text, + }); + } + } else if (bindings && ts.isNamespaceImport(bindings)) { + mod.imports.set(bindings.name.text, { file: target, name: "*" }); + } + continue; + } + if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) { + const target = + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ? sourceModule(file, statement.moduleSpecifier.text, sources) + : file; + if (!statement.exportClause) mod.stars.push(target); + else if (ts.isNamedExports(statement.exportClause)) { + for (const binding of statement.exportClause.elements) { + if (!binding.isTypeOnly) + mod.exports.set(binding.name.text, { + file: target, + name: (binding.propertyName ?? binding.name).text, + }); + } + } + continue; + } + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined; + const isDefault = modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword) || + (ts.isExportAssignment(statement) && !statement.isExportEquals); + const nodes: [string, ts.Node][] = []; + if (ts.isFunctionDeclaration(statement) && statement.body && (statement.name || isDefault)) + nodes.push([statement.name?.text ?? "default", statement]); + if (ts.isExportAssignment(statement) && !statement.isExportEquals) + nodes.push(["default", statement.expression]); + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && declaration.initializer) + nodes.push([declaration.name.text, declaration.initializer]); + } + } + for (const [name, node] of nodes) { + mod.declarations.set(name, { + node, + refs: new Set(), + calls: new Set(), + usage: { operations: [], unresolvedRequests: [], methods: [] }, + boundary: false, + factories: new Set(), + flags: new Set(), + }); + if (isDefault) + mod.exports.set("default", { file, name }); + else if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) + mod.exports.set(name, { file, name }); + } + } + return mod; +} + +/** + * Follow function references, imports and barrel exports to SDK calls or known + * raw request wrappers. Dynamic paths remain diagnostics rather than guesses. + * Boundary factories have their arguments inspected, without traversing the + * factory's execution machinery. Reading their returned objects as data does + * not inherit callback usage. Results describe static reachability, not a + * complete execution trace; dynamic behavior may not be discoverable. + */ +export function analyzeSourceApiUsage( + sources: ReadonlyMap, + operations: readonly SourceApiOperation[], + options: SourceApiAnalysisOptions = {}, +): SourceApiAnalysis { + const boundaries = new Set(options.boundaries); + const requests = new Map(options.requests?.map(request => [request.functionName, request])); + const declarationFlags = new Set(options.declarationFlags); + const modules = new Map( + [...sources].map(([file, text]) => [ + file, + parseModule(file, text, sources), + ]), + ); + const sdk = new Map( + operations.map((operation) => [operation.functionName, operation]), + ); + const identities = new Map(); + const pathKey = (path: string) => + `/${path.replace(API_PREFIX, "").split("/").filter(Boolean).join("/")}/`.replace( + PATH_PARAMETER, + "{}", + ); + for (const operation of operations) { + const key = `${operation.method}:${pathKey(operation.path)}`; + identities.set(key, [...(identities.get(key) ?? []), operation]); + } + + function resolve( + binding: Binding, + exported = false, + seen = new Set(), + ): Binding | undefined { + if (binding.file === SDK) return binding; + const key = `${binding.file}#${binding.name}#${exported}`; + if (seen.has(key)) return; + seen.add(key); + const mod = modules.get(binding.file); + if (!mod) return; + if (!exported) { + if (mod.declarations.has(binding.name)) return binding; + const imported = mod.imports.get(binding.name); + return imported ? resolve(imported, true, seen) : undefined; + } + const next = mod.exports.get(binding.name); + if (next) return resolve(next, next.file !== binding.file, seen); + // A star re-export forwards named exports only, never the default export. + if (binding.name === "default") return; + for (const target of mod.stars) { + const found = resolve({ file: target, name: binding.name }, true, seen); + if (found) return found; + } + return; + } + + function bindingFor(file: string, node: ts.Node): Binding | undefined { + const mod = modules.get(file)!; + if (ts.isIdentifier(node)) { + if (lexicalDeclaration(node)) return; + const imported = mod.imports.get(node.text); + return imported + ? resolve(imported, true) ?? (!modules.has(imported.file) ? imported : undefined) + : resolve({ file, name: node.text }); + } + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + !lexicalDeclaration(node.expression) + ) { + const namespace = mod.imports.get(node.expression.text); + if (namespace?.name === "*") + return resolve({ file: namespace.file, name: node.name.text }, true); + } + return; + } + + /** Keep export summaries separate from the callable identity behind a default alias. */ + function callableBinding(binding: Binding | undefined): Binding | undefined { + const seen = new Set(); + while (binding?.name === "default") { + const key = `${binding.file}#${binding.name}`; + if (seen.has(key)) break; + seen.add(key); + const declaration = modules.get(binding.file)?.declarations.get(binding.name); + if (!declaration) break; + const node = unwrap(declaration.node); + if (!ts.isIdentifier(node)) break; + const target = bindingFor(binding.file, node); + if (!target) break; + binding = target; + } + return binding; + } + + function initializer(node: ts.Node): ts.Node | undefined { + if (!ts.isIdentifier(node)) return; + // Parameters, destructuring and mutable bindings remain unknown: evaluating + // their initial value would require tracking assignments and control flow. + const declaration = lexicalDeclaration(node, true); + return declaration && ts.isVariableDeclaration(declaration) && + ts.isIdentifier(declaration.name) && + ts.isVariableDeclarationList(declaration.parent) && + (declaration.parent.flags & ts.NodeFlags.Const) + ? declaration.initializer + : undefined; + } + + function value( + node: ts.Node | null | undefined, + template = false, + seen = new Set(), + ): string | undefined { + if (!node || seen.has(node)) return; + seen.add(node); + node = unwrap(node); + if (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) + return node.text; + if (node.kind === ts.SyntaxKind.TrueKeyword) return "true"; + if (node.kind === ts.SyntaxKind.FalseKeyword) return "false"; + if (ts.isIdentifier(node)) + return value(initializer(node), template, seen); + if (ts.isTemplateExpression(node)) { + let result = node.head.text; + for (const span of node.templateSpans) { + const part = value(span.expression, false, new Set(seen)); + if (part === undefined && !template) return; + result += (part ?? "{}") + span.literal.text; + } + return result; + } + return; + } + + /** Undefined means absent; null means an unknown object/spread can supply it. */ + function property( + node: ts.Node | null | undefined, + name: string, + seen = new Set(), + ): ts.Node | null | undefined { + if (node == null) return node; + if (seen.has(node)) return null; + seen.add(node); + node = unwrap(node); + if (ts.isIdentifier(node)) { + const initial = initializer(node); + return initial ? property(initial, name, seen) : null; + } + if (!ts.isObjectLiteralExpression(node)) return null; + for (const member of [...node.properties].reverse()) { + if (ts.isSpreadAssignment(member)) { + const found = property(member.expression, name, new Set(seen)); + if (found !== undefined) return found; + continue; + } + const key = ts.isComputedPropertyName(member.name) + ? value(member.name.expression) + : member.name.text; + if (key === undefined) return null; + if (key !== name) continue; + if (ts.isPropertyAssignment(member)) return member.initializer; + if (ts.isShorthandPropertyAssignment(member)) return member.name; + return null; + } + return; + } + + function queryTerms( + node: ts.Node | null | undefined, + seen = new Set(), + ): string | undefined { + if (!node || seen.has(node)) return; + const exact = value(node); + if (exact !== undefined) return exact; + seen.add(node); + node = unwrap(node); + if (ts.isIdentifier(node)) + return queryTerms(initializer(node), seen); + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.BarBarToken + ) + return queryTerms(node.left, seen); + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + node.expression.name.text !== "join" || + value(node.arguments[0]) !== " " + ) + return; + let array: ts.Node = node.expression.expression; + if ( + ts.isCallExpression(array) && + ts.isPropertyAccessExpression(array.expression) && + array.expression.name.text === "filter" && + array.arguments[0]?.getText() === "Boolean" + ) + array = array.expression.expression; + if (!ts.isArrayLiteralExpression(array)) return; + const terms = array.elements + .map((item) => value(item)) + .filter((term): term is string => !!term); + return terms.length ? terms.join(" ") : undefined; + } + + function queryContext( + requestOptions: ts.Node | undefined, + key: string, + ): Record | undefined { + const query = property(requestOptions, key); + const result: Record = {}; + // Only caller-selected keys are retained, rather than arbitrary options. + for (const name of options.queryParameters ?? []) { + const expression = property(query, name); + const text = + name === "query" + ? queryTerms(expression) + : value(expression); + if (text) result[name] = text; + } + return Object.keys(result).length ? result : undefined; + } + + for (const [file, mod] of modules) { + for (const declaration of mod.declarations.values()) { + function visit(node: ts.Node): void { + if (ts.isTypeNode(node)) return; + if ( + ts.isPropertyAssignment(node) && + declarationFlags.has(node.name.getText()) && + node.initializer.kind === ts.SyntaxKind.TrueKeyword && + property(node.parent, node.name.getText()) === node.initializer + ) + declaration.flags.add(node.name.getText()); + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === "method" + ) { + const method = value(node.initializer); + if (method) declaration.usage.methods.push(method.toUpperCase()); + } + if (ts.isCallExpression(node)) { + const reference = bindingFor(file, node.expression); + if (reference) declaration.calls.add(`${reference.file}#${reference.name}`); + const binding = callableBinding(reference); + const name = + binding?.name ?? + (ts.isIdentifier(node.expression) && !lexicalDeclaration(node.expression) + ? node.expression.text : ""); + if (boundaries.has(name)) { + declaration.boundary = true; + declaration.factories.add(name); + for (const argument of node.arguments) visit(argument); + return; + } + const operation = binding?.file === SDK ? sdk.get(name) : undefined; + if (operation) { + declaration.usage.operations.push({ + ...operation, + query: queryContext(node.arguments[0], "query"), + }); + declaration.usage.methods.push(operation.method); + for (const argument of node.arguments) visit(argument); + return; + } + const request = requests.get(name); + if (request) { + const requestOptions = node.arguments[request.optionsArgument]; + const methodExpression = property(requestOptions, "method"); + const method = + methodExpression === undefined + ? (request.defaultMethod ?? "GET").toUpperCase() + : value(methodExpression)?.toUpperCase(); + const path = value(node.arguments[request.pathArgument], true); + const matches = + method && path + ? (identities.get(`${method}:${pathKey(path)}`) ?? []) + : []; + if (method) declaration.usage.methods.push(method); + if (matches.length === 1) + declaration.usage.operations.push({ + ...matches[0]!, + query: queryContext(requestOptions, request.queryProperty ?? "query"), + }); + else + declaration.usage.unresolvedRequests.push({ + file, + line: + mod.source.getLineAndCharacterOfPosition(node.getStart()) + .line + 1, + method, + path, + reason: + !method || !path + ? "Dynamic request identity" + : matches.length + ? "Ambiguous catalog path" + : "Request is not in the public catalog", + }); + for (const argument of node.arguments) visit(argument); + return; + } + } + if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) { + const parent = node.parent; + const isName = + (ts.isPropertyAccessExpression(parent) && parent.name === node) || + ((ts.isPropertyAssignment(parent) || + ts.isMethodDeclaration(parent) || + ts.isVariableDeclaration(parent) || + ts.isParameter(parent) || + ts.isFunctionDeclaration(parent)) && + parent.name === node); + if (!isName) { + const binding = bindingFor(file, node); + if (binding && !boundaries.has(binding.name)) { + const operation = + binding.file === SDK ? sdk.get(binding.name) : undefined; + if (operation) { + declaration.usage.operations.push(operation); + declaration.usage.methods.push(operation.method); + } else declaration.refs.add(`${binding.file}#${binding.name}`); + } + } + } + node.forEachChild(visit); + } + visit(declaration.node); + } + } + + const declarations = new Map( + [...modules].flatMap(([file, mod]) => + [...mod.declarations].map( + ([name, declaration]) => [`${file}#${name}`, declaration] as const, + ), + ), + ); + function unique(items: T[]): T[] { + return [ + ...new Map(items.map((item) => [JSON.stringify(item), item])).values(), + ]; + } + function isAlias(declaration: Declaration, ref: string): boolean { + const node = unwrap(declaration.node); + if (!ts.isIdentifier(node) && !ts.isPropertyAccessExpression(node)) + return false; + const binding = bindingFor(node.getSourceFile().fileName, node); + return !!binding && `${binding.file}#${binding.name}` === ref; + } + // Resolve boundary factories and aliases before propagating usage so objects + // containing callbacks can be inspected as data without inheriting their calls. + let changed = true; + while (changed) { + changed = false; + for (const declaration of declarations.values()) { + for (const ref of declaration.refs) { + const child = declarations.get(ref); + if ( + !child?.boundary || + (!declaration.calls.has(ref) && !isAlias(declaration, ref)) + ) + continue; + if (!declaration.boundary) changed = true; + declaration.boundary = true; + for (const factory of child.factories) { + if (!declaration.factories.has(factory)) changed = true; + declaration.factories.add(factory); + } + for (const flag of child.flags) { + if (!declaration.flags.has(flag)) changed = true; + declaration.flags.add(flag); + } + } + } + } + changed = true; + while (changed) { + changed = false; + for (const declaration of declarations.values()) { + const before = JSON.stringify(declaration.usage); + for (const ref of declaration.refs) { + const child = declarations.get(ref); + if (!child) continue; + if ( + child.boundary && + !ts.isFunctionLike(unwrap(child.node)) && + !isAlias(declaration, ref) + ) + continue; + declaration.usage.operations.push(...child.usage.operations); + declaration.usage.unresolvedRequests.push( + ...child.usage.unresolvedRequests, + ); + declaration.usage.methods.push(...child.usage.methods); + } + declaration.usage.operations = unique(declaration.usage.operations); + declaration.usage.unresolvedRequests = unique( + declaration.usage.unresolvedRequests, + ); + declaration.usage.methods = unique(declaration.usage.methods); + changed ||= before !== JSON.stringify(declaration.usage); + } + } + + const result: SourceApiAnalysis = { exports: new Map(), boundaries: [] }; + function exportNames(file: string, seen = new Set()): string[] { + if (seen.has(file)) return []; + seen.add(file); + const mod = modules.get(file); + return mod + ? [ + ...mod.exports.keys(), + ...mod.stars.flatMap((target) => exportNames(target, seen).filter(name => name !== "default")), + ] + : []; + } + for (const file of modules.keys()) { + const exported = new Map(); + for (const name of new Set(exportNames(file))) { + const binding = resolve({ file, name }, true); + const declaration = + binding && declarations.get(`${binding.file}#${binding.name}`); + if (!declaration) continue; + exported.set(name, declaration.usage); + if (declaration.boundary) + result.boundaries.push({ + file, + name, + factories: [...declaration.factories], + flags: [...declaration.flags], + }); + } + result.exports.set(file, exported); + } + return result; +} diff --git a/static/packages/api/lib/spec-files.mjs b/static/packages/api/lib/spec-files.mjs new file mode 100644 index 000000000000..f4b3eaa0e424 --- /dev/null +++ b/static/packages/api/lib/spec-files.mjs @@ -0,0 +1,25 @@ +import { fileURLToPath } from "node:url"; + +/** + * The files the build collects under spec/, by name, with their paths in the + * repository. `make build-api-docs` writes the spec and the API ownership + * stats, and `make build-rpc-schema` writes the RPC method schema. + */ +const repositoryPaths = { + "openapi-derefed.json": "tests/apidocs/openapi-derefed.json", + "api_ownership_stats_dont_modify.json": "src/sentry/apidocs/api_ownership_stats_dont_modify.json", + "rpc_method_schema.json": ".artifacts/rpc_method_schema.json", +}; + +const root = new URL("../../../../", import.meta.url); +// A directory that holds all of them instead, such as one CI job's spec/ +// output downloaded into another. +const dir = process.env.SENTRY_API_SPEC_DIR; + +/** Where to copy each spec/ file from, by name. */ +export const specFiles = Object.fromEntries( + Object.entries(repositoryPaths).map(([name, path]) => [ + name, + dir ? `${dir}/${name}` : fileURLToPath(new URL(path, root)), + ]), +); diff --git a/static/packages/api/package.json b/static/packages/api/package.json new file mode 100644 index 000000000000..22f6d6f8a323 --- /dev/null +++ b/static/packages/api/package.json @@ -0,0 +1,106 @@ +{ + "name": "@sentry/api", + "version": "1.54.0", + "description": "Official auto-generated TypeScript client for the Sentry public REST API", + "keywords": [ + "sentry", + "sentry-api", + "openapi", + "typescript", + "api-client" + ], + "license": "FSL-1.1-Apache-2.0", + "type": "module", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "scripts": { + "build": "node build.mjs", + "build:spec": "node build.mjs --spec-only", + "test:vitest": "vitest run", + "typecheck": "tsc6 -p tsconfig.test.json && tsc6 -p tsconfig.browser.json" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./zod": { + "import": "./dist/zod.js", + "types": "./dist/zod.d.ts" + }, + "./valibot": { + "import": "./dist/valibot.js", + "types": "./dist/valibot.d.ts" + }, + "./browser": { + "import": "./dist/browser.js", + "types": "./dist/browser.d.ts" + }, + "./client": { + "types": "./dist/api-client/index.d.ts", + "import": "./dist/api-client/index.js" + }, + "./client/endpoints": { + "types": "./dist/api-client/endpoints/index.d.ts", + "import": "./dist/api-client/endpoints/index.js" + }, + "./operations": { + "types": "./dist/operations.d.ts", + "import": "./dist/operations.js" + }, + "./source-analysis": { + "types": "./dist/source-analysis.d.ts", + "import": "./dist/source-analysis.js" + } + }, + "homepage": "https://docs.sentry.io/api/", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/sentry.git", + "directory": "static/packages/api" + }, + "bugs": { + "url": "https://github.com/getsentry/sentry/issues" + }, + "dependencies": { + "http-cache-semantics": "^4.2.0", + "p-limit": "^7.3.3" + }, + "peerDependencies": { + "typescript": "*", + "valibot": "*", + "zod": "^4.6.6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + } + }, + "devDependencies": { + "@hey-api/openapi-ts": "0.98.0", + "@types/http-cache-semantics": "4.2.0", + "@types/node": "26.6.4", + "esbuild": "0.28.1", + "fast-check": "4.10.2", + "typescript": "npm:@typescript/typescript6@6.0.2", + "valibot": "1.4.2", + "vitest": "5.1.0", + "zod": "4.6.7" + }, + "engines": { + "node": ">=22" + } +} diff --git a/static/packages/api/scripts/apply-query-defaults.mjs b/static/packages/api/scripts/apply-query-defaults.mjs new file mode 100644 index 000000000000..fed58dd91851 --- /dev/null +++ b/static/packages/api/scripts/apply-query-defaults.mjs @@ -0,0 +1,8 @@ +/** Apply lib/query-defaults.mjs to the generated SDK operations in place. */ +import { update } from "../lib/generated-source.mjs"; +import { applyQueryDefaults, QUERY_DEFAULTS } from "../lib/query-defaults.mjs"; + +export function emit({ sourceSpec }) { + update("sdk.gen.ts", source => applyQueryDefaults(sourceSpec, source)); + console.log(`apply-query-defaults: ${QUERY_DEFAULTS.size} operation(s)`); +} diff --git a/static/packages/api/scripts/constrain-throw-options.mjs b/static/packages/api/scripts/constrain-throw-options.mjs new file mode 100644 index 000000000000..4f031f40d20a --- /dev/null +++ b/static/packages/api/scripts/constrain-throw-options.mjs @@ -0,0 +1,78 @@ +/** + * A success-only return type requires throwOnError: true on the same call. + * Client configuration is mutable, so an explicit type argument cannot replace + * that runtime option. Preserve inference for true, false, and boolean callers. + */ +import { writeFileSync } from "node:fs"; +import ts from "typescript"; +import { edit, parse } from "../lib/generated-source.mjs"; + +export function emit() { + const types = parse("client/types.gen.ts"); + const typeEdits = []; + for (const name of ["Options", "MethodFn", "RequestFn"]) { + const declarations = types.source.statements.filter(statement => + ts.isTypeAliasDeclaration(statement) && statement.name.text === name); + if (declarations.length !== 1) throw new Error(`Expected one generated ${name} type`); + const declaration = declarations[0]; + let node = declaration.type; + if (name !== "Options") { + if (!ts.isFunctionTypeNode(node) || node.parameters.length !== 1 || !node.parameters[0].type) { + throw new Error(`Unsupported generated ${name} signature`); + } + node = node.parameters[0].type; + } + typeEdits.push({ + start: node.getStart(types.source), + end: node.end, + text: `(${node.getText(types.source)}) & RequireThrowOnError`, + }); + } + const constrainedTypes = edit(types.text, typeEdits) + ` +/** Success-only types require a per-call runtime guarantee. */ +type RequireThrowOnError = + [ThrowOnError] extends [true] ? { throwOnError: true } : unknown; +`; + + const sdk = parse("sdk.gen.ts"); + const sdkEdits = []; + let optionalOperations = 0; + for (const statement of sdk.source.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + const fn = declaration.initializer; + if (!fn || !ts.isArrowFunction(fn)) continue; + if (fn.parameters.length !== 1 || !ts.isIdentifier(fn.parameters[0].name) || + fn.parameters[0].name.text !== "options" || + !fn.typeParameters?.some(parameter => parameter.name.text === "ThrowOnError")) { + throw new Error(`Unsupported generated operation: ${declaration.name.getText(sdk.source)}`); + } + const parameter = fn.parameters[0]; + if (!parameter.questionToken) continue; + if (!parameter.type) throw new Error("Generated options must have a type"); + const optionsType = parameter.type.getText(sdk.source); + sdkEdits.push({ + start: parameter.getStart(sdk.source), + end: parameter.end, + text: `...[options]: [options?: ${optionsType}] & ([ThrowOnError] extends [true] ? [options: ${optionsType}] : unknown)`, + }); + // The conditional tuple guarantees options when required. Assert that fact + // while forwarding; spreading undefined still works for ordinary calls. + let spreads = 0; + function visit(node) { + if (ts.isSpreadAssignment(node) && ts.isIdentifier(node.expression) && node.expression.text === "options") { + sdkEdits.push({ start: node.expression.end, end: node.expression.end, text: "!" }); + spreads++; + } + ts.forEachChild(node, visit); + } + visit(fn.body); + if (spreads !== 1) throw new Error(`Expected one options spread in ${declaration.name.getText(sdk.source)}`); + optionalOperations++; + } + } + if (!optionalOperations) throw new Error("No optional generated operations found"); + + writeFileSync(types.path, constrainedTypes); + writeFileSync(sdk.path, edit(sdk.text, sdkEdits)); +} diff --git a/static/packages/api/scripts/describe-valibot-schemas.mjs b/static/packages/api/scripts/describe-valibot-schemas.mjs new file mode 100644 index 000000000000..54b658d7595a --- /dev/null +++ b/static/packages/api/scripts/describe-valibot-schemas.mjs @@ -0,0 +1,77 @@ +/** + * Turn the OpenAPI descriptions that the Valibot plugin attaches as + * `v.metadata({ description })` into Valibot's own `v.description(…)` action. + * + * - `v.getDescription()` and other Valibot tooling read the `description` + * action, not a `description` key inside `v.metadata`. + * - Top-level schemas keep no description pipe, so their types stay as they + * were: `v.pick`, `v.omit`, `v.partial` and `v.required` reject piped object + * schemas. Their description is already in their JSDoc comment, and for + * responses it is often only the status text, such as "No Content". + * + * Runs before make-tree-shakeable.mjs, on the plain generator output. Fails + * closed if the generator emits a metadata shape this script does not handle. + */ +import ts from "typescript"; +import { edit, update } from "../lib/generated-source.mjs"; + +const path = "valibot.gen.ts"; + +/** The `v.` a call expression invokes, if any. */ +function valibotCall(node) { + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return; + const { expression, name } = node.expression; + return ts.isIdentifier(expression) && expression.text === "v" ? name.text : undefined; +} + +function unpipeTopLevelSchemas(source) { + const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true); + const edits = []; + for (const statement of file.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const { initializer: pipe } of statement.declarationList.declarations) { + if (!pipe || valibotCall(pipe) !== "pipe") continue; + const args = pipe.arguments; + if (valibotCall(args.at(-1)) !== "metadata") continue; + if (args.length === 2) { + edits.push( + { start: pipe.getStart(file), end: args[0].getStart(file), text: "" }, + { start: args[0].end, end: pipe.end, text: "" }, + ); + } else { + edits.push({ start: args.at(-2).end, end: args.at(-1).end, text: "" }); + } + } + } + return edit(source, edits); +} + +function metadataToDescription(source) { + const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true); + const edits = []; + const visit = node => { + if (valibotCall(node) === "metadata") { + const [metadata] = node.arguments; + const properties = metadata && ts.isObjectLiteralExpression(metadata) ? metadata.properties : []; + const [property] = properties; + if ( + node.arguments.length !== 1 || properties.length !== 1 + || !ts.isPropertyAssignment(property) || property.name.getText(file) !== "description" + ) { + throw new Error(`valibot.gen.ts: unexpected metadata shape: ${node.getText(file).slice(0, 80)}`); + } + edits.push({ start: node.getStart(file), end: node.end, text: `v.description(${property.initializer.getText(file)})` }); + return; + } + ts.forEachChild(node, visit); + }; + visit(file); + if (edits.length === 0) { + throw new Error("valibot.gen.ts: found no `v.metadata` descriptions; is the Valibot `metadata` option on?"); + } + return edit(source, edits); +} + +export function emit() { + update(path, source => metadataToDescription(unpipeTopLevelSchemas(source))); +} diff --git a/static/packages/api/scripts/generate-base-url-resolver.mjs b/static/packages/api/scripts/generate-base-url-resolver.mjs new file mode 100644 index 000000000000..d56c2e86284b --- /dev/null +++ b/static/packages/api/scripts/generate-base-url-resolver.mjs @@ -0,0 +1,82 @@ +/** Let hosts pick each request's base URL from its path before the Fetch client builds the URL. */ +import { replaceOnce, update } from "../lib/generated-source.mjs"; + +export function emit() { + update("client/types.gen.ts", source => { + source = replaceOnce(source, + "import type { Middleware } from './utils.gen';", + `import type { Middleware } from './utils.gen'; +import type { ApiPathScope } from '../api-client/api-path-scope'; + +/** The request a base URL is picked for; see {@link Config.resolveBaseUrl}. */ +export type BaseUrlRequest = { + /** The configured base URL, or \`''\` when none is set. */ + baseUrl: string; + /** The request's path and query, as they follow the base URL. */ + url: string; + /** + * The organization, and project when present, the path targets + * (\`parseApiPathScope\` in \`@sentry/api/client\`). Absent for unscoped paths. + */ + scope?: ApiPathScope; +}; + +/** Picks the base URL for one request; see {@link Config.resolveBaseUrl}. */ +export type BaseUrlResolver = (request: BaseUrlRequest) => string | Promise;`); + return replaceOnce(source, + ` fetch?: typeof fetch; + /** + * Please don't use the Fetch client`, + ` fetch?: typeof fetch; + /** + * Pick the base URL for each request, before its URL is built, such as the + * region that serves the organization the path targets. Called once per + * request, after \`requestValidator\` and before request interceptors; its + * result replaces \`baseUrl\` for that request. + */ + resolveBaseUrl?: BaseUrlResolver; + /** + * Please don't use the Fetch client`); + }); + + update("client/client.gen.ts", source => { + source = replaceOnce(source, + "import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';", + `import { parseApiPathScope } from '../api-client/api-path-scope'; +import type { BaseUrlResolver, Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';`); + source = replaceOnce(source, + "export const createClient = (config: Config = {}): PreparedClient => {", + `/** The base URL \`resolve\` picks for a request to \`url\`, without trailing slashes. */ +const resolveBaseUrl = async ( + resolve: BaseUrlResolver, + baseUrl: string, + url: string, +): Promise => { + const scope = parseApiPathScope(url); + const resolved = await resolve(scope ? { baseUrl, url, scope } : { baseUrl, url }); + let end = resolved.length; + while (end > 0 && resolved.charAt(end - 1) === '/') { + end -= 1; + } + return resolved.slice(0, end); +}; + +export const createClient = (config: Config = {}): PreparedClient => {`); + return replaceOnce(source, + " const url = buildUrl(resolvedOpts);\n", + ` if (resolvedOpts.resolveBaseUrl) { + resolvedOpts.baseUrl = await resolveBaseUrl( + resolvedOpts.resolveBaseUrl, + (resolvedOpts.baseUrl as string | undefined) ?? '', + buildUrl({ ...resolvedOpts, baseUrl: '' }), + ); + } + const url = buildUrl(resolvedOpts); +`); + }); + + update("client/index.ts", source => replaceOnce(source, + "export type {\n Client,", + "export type {\n BaseUrlRequest,\n BaseUrlResolver,\n Client,", + )); +} diff --git a/static/packages/api/scripts/generate-client.mjs b/static/packages/api/scripts/generate-client.mjs new file mode 100644 index 000000000000..2ee25c6badca --- /dev/null +++ b/static/packages/api/scripts/generate-client.mjs @@ -0,0 +1,171 @@ +/** + * Bind generated operations and pagination helpers to one transport without + * duplicating request logic. Preserve their arguments and documentation. + */ +import { writeFileSync } from "node:fs"; +import ts from "typescript"; +import { parse, srcPath } from "../lib/generated-source.mjs"; + +const paginationKinds = ["fetchPage", "paginateAll", "paginateUpTo"]; +const reservedNames = new Set(["client", ...paginationKinds]); + +export function emit() { + const methods = []; + const paginationMethods = Object.fromEntries(paginationKinds.map(kind => [kind, []])); + // Name the client type so declaration emit references it instead of + // inlining every bound method, which exceeds tsc's serialization limit. + const members = []; + const paginationMembers = Object.fromEntries(paginationKinds.map(kind => [kind, []])); + + for (const module of ["sdk", "pagination"]) { + const { source } = parse(`${module}.gen.ts`); + for (const statement of source.statements) { + if ( + !ts.isVariableStatement(statement) || + !statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + ) continue; + + for (const declaration of statement.declarationList.declarations) { + const fn = declaration.initializer; + const tupleOptions = fn && ts.isArrowFunction(fn) && module === "sdk" && + fn.parameters.length === 1 && fn.parameters[0].dotDotDotToken && + ts.isArrayBindingPattern(fn.parameters[0].name) && + fn.parameters[0].name.elements.length === 1 && + fn.parameters[0].name.elements[0].name?.getText(source) === "options"; + if ( + !ts.isIdentifier(declaration.name) || + !fn || !ts.isArrowFunction(fn) || + fn.parameters.length === 0 || + (!tupleOptions && fn.parameters.some(parameter => + !ts.isIdentifier(parameter.name) || parameter.dotDotDotToken || parameter.initializer) + ) + ) { + throw new Error(`Unsupported ${module} function: ${declaration.name.getText(source)}`); + } + + const name = declaration.name.text; + let methodName = name; + let destination = methods; + let memberDestination = members; + let indent = " "; + if (module === "pagination") { + const kind = paginationKinds.find(kind => name.startsWith(`${kind}_`)); + if (!kind) throw new Error(`Unsupported pagination helper: ${name}`); + methodName = name.slice(kind.length + 1); + destination = paginationMethods[kind]; + memberDestination = paginationMembers[kind]; + indent += " "; + } else if (reservedNames.has(name)) { + throw new Error(`SDK operation conflicts with the ${name} property`); + } + + const generics = fn.typeParameters?.length + ? `<${fn.typeParameters.map((parameter) => parameter.getText(source)).join(", ")}>` + : ""; + const typeArguments = fn.typeParameters?.length + ? `<${fn.typeParameters.map((parameter) => parameter.name.text).join(", ")}>` + : ""; + const operation = `${module}.${name}${typeArguments}`; + const optional = fn.parameters[0].questionToken ? "?" : ""; + for (const doc of statement.jsDoc ?? []) { + const lines = doc.getText(source).split("\n").map((line) => `${indent}${line}`); + destination.push(...lines); + memberDestination.push(...lines); + } + const returns = `ReturnType`; + if (module === "sdk") { + if (fn.parameters.length !== 1) throw new Error(`Unsupported SDK arguments: ${name}`); + let argsType = `BoundOperationArgs>`; + if (tupleOptions) { + const parameterType = fn.parameters[0].type; + const tupleType = parameterType && ts.isIntersectionTypeNode(parameterType) && parameterType.types[0]; + const member = tupleType && ts.isTupleTypeNode(tupleType) && tupleType.elements[0]; + const optionsType = member && ts.isNamedTupleMember(member) && member.type; + if (!optionsType || !ts.isTypeReferenceNode(optionsType) || + optionsType.typeName.getText(source) !== "Options" || optionsType.typeArguments?.length !== 2 || + !ts.isTypeReferenceNode(optionsType.typeArguments[0]) || + !ts.isIdentifier(optionsType.typeArguments[0].typeName) || + optionsType.typeArguments[1].getText(source) !== "ThrowOnError") { + throw new Error(`Unsupported optional SDK arguments: ${name}`); + } + // Refer to Options directly: mapping Parameters of a conditional tuple + // prevents generic consumer wrappers from forwarding their options. + const boundOptions = `Omit, 'client'>`; + argsType = `[options?: ${boundOptions}] & ([ThrowOnError] extends [true] ? [options: ${boundOptions}] : unknown)`; + } + const parameters = [`${indent} ...args: ${argsType}`]; + destination.push( + `${indent}${methodName}: ${generics}(`, + ...parameters, + `${indent}) => ${operation}(...([{ ...args[0], client }] as Parameters)),`, + ); + memberDestination.push(`${indent}readonly ${methodName}: ${generics}(`, ...parameters, `${indent}) => ${returns};`); + continue; + } + const remainingParameters = fn.parameters.slice(1); + const parameters = [ + `${indent} options${optional}: Omit[0]>, 'client'>,`, + ...remainingParameters.map((parameter, index) => + `${indent} ${parameter.name.text}${parameter.questionToken ? "?" : ""}: Parameters[${index + 1}],`), + ]; + destination.push( + `${indent}${methodName}: ${generics}(`, + ...parameters, + `${indent}) => ${operation}({ ...options, client }${remainingParameters.map(parameter => `, ${parameter.name.text}`).join("")}),`, + ); + memberDestination.push(`${indent}readonly ${methodName}: ${generics}(`, ...parameters, `${indent}) => ${returns};`); + } + } + } + + if (methods.length === 0) throw new Error("No SDK operations found"); + for (const kind of paginationKinds) { + if (paginationMethods[kind].length === 0) throw new Error(`No ${kind} helpers found`); + } + + writeFileSync(srcPath("sentry-client.gen.ts"), [ + "// This file is auto-generated by scripts/generate-client.mjs", + "// Do not edit directly. Re-run `pnpm build` to regenerate.", + "", + "import { createClient, type Config, type PreparedClient } from './client';", + "import * as sdk from './sdk.gen';", + "import * as pagination from './pagination.gen';", + "import type * as types from './types.gen';", + "", + "// Preserve conditional argument requirements while binding the transport.", + "type BoundOperationArgs = {", + " [Key in keyof Args]: Key extends '0' ? Omit, 'client'> : Args[Key];", + "};", + "", + "/** An isolated Sentry client with bound, typed API operations; see createSentryClient. */", + "export interface SentryClient {", + " readonly client: PreparedClient;", + // Members were indented for the object literal; drop one level. + ...members.map(line => line.slice(2)), + ...paginationKinds.flatMap(kind => [ + ` readonly ${kind}: {`, + ...paginationMembers[kind].map(line => line.slice(2)), + " };", + ]), + "}", + "", + "/**", + " * Create an isolated Sentry client with bound, typed API operations.", + " * Use `client` for transport configuration, interceptors, and standalone helpers.", + " * Each method keeps the standalone operation's result and request options.", + " */", + "export function createSentryClient(config: Config = {}): SentryClient {", + " const client = createClient(config);", + " return {", + " client,", + ...methods, + ...paginationKinds.flatMap(kind => [ + ` ${kind}: {`, + ...paginationMethods[kind], + " },", + ]), + " };", + "}", + "", + ].join("\n")); +} diff --git a/static/packages/api/scripts/generate-error-results.mjs b/static/packages/api/scripts/generate-error-results.mjs new file mode 100644 index 000000000000..65bf24fb8418 --- /dev/null +++ b/static/packages/api/scripts/generate-error-results.mjs @@ -0,0 +1,91 @@ +/** + * Generate one status-discriminated HTTP result wrapper for every SDK operation + * that declares an error response in OpenAPI. Custom and non-HTTP errors reject. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { srcPath } from "../lib/generated-source.mjs"; + +export function emit({ normalizedSpec: spec }) { + const sdkSource = readFileSync(srcPath("sdk.gen.ts"), "utf8"); + + const errorRoutes = new Map(); + for (const [path, methods] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(methods)) { + if (typeof operation !== "object" || operation === null) continue; + const statuses = Object.keys(operation.responses ?? {}) + .filter((status) => /^[45]\d\d$/.test(status)) + .map(Number) + .sort((a, b) => a - b); + if (statuses.length > 0) { + errorRoutes.set(`${method.toUpperCase()} ${path}`, statuses); + } + } + } + + const operations = []; + for (const block of sdkSource.split(/^export const /m).slice(1)) { + const functionMatch = block.match(/^(\w+) =/); + const typeMatch = block.match(/Options<(\w+)Data,/); + const urlMatch = block.match(/url: '([^']+)'/); + const methodMatch = block.match(/\)\.(\w+)]+>\\(options\\?:`, + ).test(block), + statuses, + typeBase: typeMatch[1], + }); + } + operations.sort((a, b) => a.fn.localeCompare(b.fn)); + const typeNames = new Set(); + for (const { typeBase } of operations) { + typeNames.add(`${typeBase}Data`); + typeNames.add(`${typeBase}Errors`); + typeNames.add(`${typeBase}Responses`); + } + + const lines = [ + "// This file is auto-generated by scripts/generate-error-results.mjs", + "// Do not edit directly. Re-run `pnpm build` to regenerate.", + "", + "import type { NarrowedResult } from './sentry-errors';", + "import { callWithTypedErrors } from './sentry-errors';", + "import type { Options } from './sdk.gen';", + "import {", + ...operations.map(({ fn }) => ` ${fn},`), + "} from './sdk.gen';", + "import type {", + ...[...typeNames].sort().map((typeName) => ` ${typeName},`), + "} from './types.gen';", + "", + ]; + + for (const { fn, optional, statuses, typeBase } of operations) { + const data = `${typeBase}Responses[keyof ${typeBase}Responses]`; + const errors = `${typeBase}Errors`; + lines.push( + `/** Narrow default HTTP errors from \`${fn}\` by status. Custom and non-HTTP errors reject unchanged. */`, + `export const narrowError_${fn} = async (`, + ` options${optional ? "?" : ""}: Options<${typeBase}Data, false>,`, + `): Promise> =>`, + ` callWithTypedErrors<${data}, ${errors}>(`, + ` () => ${fn}({ ...options, responseStyle: 'fields', throwOnError: false }),`, + ` [${statuses.join(", ")}],`, + ` );`, + "", + ); + } + + writeFileSync(srcPath("error-results.gen.ts"), lines.join("\n")); + console.log( + `generate-error-results: ${operations.length} status-discriminated wrappers`, + ); +} diff --git a/static/packages/api/scripts/generate-operations.mjs b/static/packages/api/scripts/generate-operations.mjs new file mode 100644 index 000000000000..8994d86ac7ba --- /dev/null +++ b/static/packages/api/scripts/generate-operations.mjs @@ -0,0 +1,19 @@ +/** Generate the standalone, data-only operation catalog from the same release inputs. */ +import { readFileSync, writeFileSync, copyFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { srcPath } from "../lib/generated-source.mjs"; +import { extractOperations } from "../lib/operation-catalog.mjs"; + +export function emit({ sourceSpec }) { + const operations = extractOperations(sourceSpec, readFileSync(srcPath("sdk.gen.ts"), "utf8")); + copyFileSync(fileURLToPath(new URL("../lib/operation-types.ts", import.meta.url)), srcPath("operation-types.ts")); + writeFileSync(srcPath("operations.ts"), [ + "// This file is auto-generated by scripts/generate-operations.mjs.", + "import type { ApiOperation } from './operation-types.ts';", + "export type { ApiOperation, ApiQueryParameter } from './operation-types.ts';", + "", + "/** Offline metadata for the operations exported by this SDK version. */", + `export const operations: readonly ApiOperation[] = ${JSON.stringify(operations, null, 2)};`, + "", + ].join("\n")); +} diff --git a/static/packages/api/scripts/generate-pagination.mjs b/static/packages/api/scripts/generate-pagination.mjs new file mode 100644 index 000000000000..dab26e84e11f --- /dev/null +++ b/static/packages/api/scripts/generate-pagination.mjs @@ -0,0 +1,383 @@ +/** + * Pagination wrapper generator. + * + * Post-processes the @hey-api/openapi-ts output to emit a `pagination.gen.ts` + * file containing typed pagination wrappers for every operation in the SDK + * that accepts a `cursor` query parameter. + * + * Why a post-processor and not a Hey API plugin? + * The Hey API plugin API is documented as in-development and explicitly + * warns custom plugins may need rewriting on minor bumps (see + * https://heyapi.dev/openapi-ts/plugins/custom). This script is a stable, + * owned alternative that depends only on the *output shape* of the SDK + * (which is far more stable than the plugin API). + * + * What gets generated, per paginated operation ``: + * - Always: `fetchPage_(options, cursor?)` — single page with cursors + * - When response 200 is an array shape: + * `paginateAll_(options, paginateOpts?)` + * `paginateUpTo_(options, paginateOpts)` + * + * Inputs (read-only): + * - the normalized spec — to detect which operations have `cursor` + * - src/sdk.gen.ts — to extract function names and their `*Data` types + * - src/types.gen.ts — to detect array-shaped vs compound responses + * + * Output: + * - src/pagination.gen.ts + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { srcPath } from "../lib/generated-source.mjs"; + +const OUT_PATH = srcPath("pagination.gen.ts"); + +const FILE_HEADER = `// This file is auto-generated by scripts/generate-pagination.mjs +// Do not edit directly. Re-run \`pnpm build\` to regenerate. +// +// One typed wrapper trio (or just fetchPage, for non-array responses) is +// emitted per SDK operation whose OpenAPI definition accepts a \`cursor\` +// query parameter (Sentry's cursor-pagination signal). Each wrapper +// preserves the original operation's request types and inserts the +// cursor into \`query\` transparently via the \`_withCursor\` runtime +// helper in \`./sentry-pagination\`. +`; + +// Read the normalized spec, which has the operationIds the SDK was built from. +export function emit({ normalizedSpec: spec }) { + const sdkSource = readFileSync(srcPath("sdk.gen.ts"), "utf8"); + const typesSource = readFileSync(srcPath("types.gen.ts"), "utf8"); + + // ===================================================================== + // 1. Detect paginated routes + // ===================================================================== + + /** + * Allow-list of operations that the runtime API paginates via Link-header + * cursors but whose OpenAPI spec is incomplete (no `cursor` parameter + * declared). These get wrappers generated regardless of the spec. + * + * Each entry is a `${METHOD} ${path}` key. Add a justification comment + * with each entry so future maintainers understand WHY the spec is being + * overridden — and so entries can be removed once the upstream + * `getsentry/sentry` schema is fixed. + * + * Verification: every entry should be confirmed against the live API + * (response includes a `Link` header with `rel="next"; cursor="..."`) + * before being added. + */ + const PAGINATED_BUT_UNMARKED = new Set([ + // Add entries here for endpoints that paginate via Link-header cursors + // but whose OpenAPI spec doesn't declare a `cursor` query parameter. + ]); + + /** + * Build the set of paginated routes, keyed by `${METHOD} ${path}`. A route + * qualifies if either: + * - its OpenAPI definition declares a `cursor` query parameter, OR + * - it appears in PAGINATED_BUT_UNMARKED. + * + * @hey-api's `defaultPaginationKeywords` (cursor/after/before/offset/page/start) + * is wider than what we need. We restrict to `cursor` only because Sentry's + * pagination model is uniformly cursor-based via Link headers — the other + * keywords would produce wrappers our helpers can't drive. + */ + const paginatedRoutes = new Map(); + for (const [path, methods] of Object.entries(spec.paths ?? {})) { + for (const [method, op] of Object.entries(methods)) { + if (typeof op !== "object" || op === null) continue; + const key = `${method.toUpperCase()} ${path}`; + const hasCursor = + Array.isArray(op.parameters) && + op.parameters.some( + (p) => p && p.name === "cursor" && p.in === "query", + ); + if (hasCursor || PAGINATED_BUT_UNMARKED.has(key)) { + paginatedRoutes.set(key, op); + } + } + } + + // Sanity check the allow-list — warn loudly if an entry doesn't match + // any operation (typo or upstream rename). + for (const key of PAGINATED_BUT_UNMARKED) { + if (!paginatedRoutes.has(key)) { + console.warn( + `generate-pagination: PAGINATED_BUT_UNMARKED entry "${key}" did not\n` + + ` match any operation in openapi-derefed.json. Either the path was\n` + + ` renamed upstream or the entry is misspelled.`, + ); + } + } + + // ===================================================================== + // 2. Parse the generated SDK source + // ===================================================================== + + /** + * Parse `src/sdk.gen.ts` to extract, for each exported function: + * - fn: function identifier (e.g. `listOrganizationIssues`) + * - typeBase: type name root (e.g. `ListOrganizationIssues`) + * - method: HTTP method (`get`, `post`, ...) + * - url: URL template + * + * Hey API's emit shape is stable; we split on `^export const ` and parse + * each block with anchored regexes: + * + * export const = (options... + * Options<Data, ThrowOnError>) => (options.client ?? client) + * .<Responses, Errors, ThrowOnError>({ + * url: '', + * ...options + * }); + */ + const sdkOps = []; + for (const block of sdkSource.split(/^export const /m).slice(1)) { + const fnMatch = block.match(/^(\w+) =/); + const typeMatch = block.match(/Options<(\w+)Data,/); + const urlMatch = block.match(/url: '([^']+)'/); + const methodMatch = block.match(/\)\.(\w+)` + * shape. We inspect `src/types.gen.ts` directly — much cheaper than `tsc`. + * + * The generated type looks like: + * export type Responses = { + * /** optional JSDoc *\/ + * 200: Array<{ ... }>; + * /** ... *\/ + * 404: ...; + * }; + * + * We find the first `200:` line within the type's body and check + * whether it begins with `Array<`. Anything else (object literal, named + * alias, primitive like `unknown`) is treated as compound — for those, + * only `fetchPage_` is emitted, since automatic array-extraction would + * require spec-specific knowledge of which field holds the array. + * + * Important: the regex must be anchored to the *first* `200:` after the + * type-def start — not the first one anywhere in the rest of the file. + * A naive `/^\s*200:\s*Array { + const start = typesSource.indexOf( + `export type ${typeBase}Responses = {`, + ); + if (start < 0) return false; + const nextExport = typesSource.indexOf("\nexport type ", start + 1); + const slice = + nextExport > 0 + ? typesSource.slice(start, nextExport) + : typesSource.slice(start); + // Strip block comments so JSDoc between the opening brace and the + // `200:` member can't fool the regex (e.g. `/** Returns 200: OK */`). + const stripped = slice.replace(/\/\*[\s\S]*?\*\//g, ""); + const m = stripped.match(/200:\s*(\w+)?\s* p.in === "query" && p.name === sizing.parameter && p.schema?.type === "integer")) { + throw new Error(`Pagination sizing metadata no longer matches the schema: ${route}`); + } + } + + const targets = sdkOps + .filter((op) => + paginatedRoutes.has(`${op.method.toUpperCase()} ${op.url}`), + ) + .map((op) => ({ ...op, isArray: isArrayResponse(op.typeBase), sizing: resizablePages.get(`${op.method.toUpperCase()} ${op.url}`) })) + .sort((a, b) => a.fn.localeCompare(b.fn)); + + if (targets.length === 0) { + console.warn( + "generate-pagination: no paginated operations found. Check that the\n" + + " SDK has been generated (src/sdk.gen.ts) and the spec contains\n" + + " operations with a `cursor` query parameter.", + ); + } + + // ===================================================================== + // 5. Emit the file + // ===================================================================== + + /** + * Each wrapper accepts the underlying SDK function's `Options` shape + * (with `query.cursor` Omit-ted, see `PaginationQuery` below) and + * delegates to the generic runtime helper (`fetchPage` / `paginateAll` / + * `paginateUpTo` from `./sentry-pagination`). + * + * Type-system contract: + * - User-facing query type strips `cursor` (helper-managed) and widens + * with the legacy `per_page?: number` escape hatch. Accepting this field + * does not imply that every endpoint honors it or supports resizing. + * - SDK call uses the `_withCursor` runtime helper to merge in the + * managed cursor and reshape back to `Options`. The cast chain + * lives once in `_withCursor`, not per-wrapper. + * - The SDK and runtime helpers share the fields result contract. + * `unwrapResult` propagates failures without changing their error values. + * + * Why `Omit<…, 'cursor'>`? Accepting a cursor in `query` would silently + * shadow the helper-managed value. Surfacing a clear "use the helper's + * cursor argument" type error is far safer. + * + * `per_page` widening remains for compatibility; only audited operations + * receive automatic sizing. Undocumented parameters remain caller-owned. + */ + + const userOptionsType = (op) => + `Omit, 'query'> & { query?: PaginationQuery<${op.typeBase}Data['query']> }`; + + const item200 = (op) => `${op.typeBase}Responses[200]`; + + /** + * Emit the SDK call expression used by all three wrapper kinds. + * + * The cursor-merge cast lives in `_withCursor`; response types are checked + * directly against the runtime helper's fields result contract. + */ + const sdkCallExpr = (op, cursorIdent, remaining) => { + const sizing = remaining && op.sizing; + const max = sizing?.fullMax ? `(options.query?.full ? ${sizing.fullMax} : ${sizing.max})` : sizing?.max; + const budget = sizing ? `, { parameter: '${sizing.parameter}', max: ${max}, remaining: ${remaining} }` : ""; + return `${op.fn}(_withCursor>(options, ${cursorIdent}${budget}))`; + }; + + const emitFetchPage = (op) => [ + `/** Fetch a single page of \`${op.fn}\` with cursors from the Link header. */`, + `export const fetchPage_${op.fn} = (`, + ` options: ${userOptionsType(op)},`, + ` cursor?: string,`, + `): Promise> =>`, + ` fetchPage<${item200(op)}>(`, + ` (c) => ${sdkCallExpr(op, "c")},`, + ` '${op.fn}',`, + ` cursor,`, + ` );`, + ``, + ]; + + const emitPaginateAll = (op) => [ + `/** Eagerly fetch all pages of \`${op.fn}\`. Bounded by \`maxPages\` (default 50). */`, + `export const paginateAll_${op.fn} = (`, + ` options: ${userOptionsType(op)},`, + ` paginateOptions?: PaginateAllOptions,`, + `): Promise> =>`, + ` paginateAll<${item200(op)}[number]>(`, + ` (c) => ${sdkCallExpr(op, "c")},`, + ` '${op.fn}',`, + ` paginateOptions,`, + ` );`, + ``, + ]; + + const emitPaginateUpTo = (op) => [ + `/** Paginate \`${op.fn}\` up to \`limit\` items; see PaginateUpToOptions. */`, + `export const paginateUpTo_${op.fn} = (`, + ` options: ${userOptionsType(op)},`, + ` paginateOptions: PaginateUpToOptions,`, + `): Promise> =>`, + ` paginateUpTo<${item200(op)}[number]>(`, + ` (${op.sizing ? "c, remaining" : "c"}) => ${sdkCallExpr(op, "c", "remaining")},`, + ` paginateOptions,`, + ` '${op.fn}',`, + ` );`, + ``, + ]; + + /** + * Build the imports block. Single pass over `targets` populates two + * sorted lists: SDK function names (camelCase, value imports) and type + * names (PascalCase, type-only imports). + */ + const sdkFnNames = targets.map((op) => op.fn); + const typeNames = new Set(); + for (const op of targets) { + typeNames.add(`${op.typeBase}Data`); + typeNames.add(`${op.typeBase}Responses`); + } + + const lines = [ + FILE_HEADER, + ``, + `import type { Options } from './sdk.gen';`, + `import {`, + ...sdkFnNames.map((n) => ` ${n},`), + `} from './sdk.gen';`, + `import type {`, + ...[...typeNames].sort().map((t) => ` ${t},`), + `} from './types.gen';`, + `import {`, + ` _withCursor,`, + ` fetchPage,`, + ` paginateAll,`, + ` paginateUpTo,`, + ` type PaginateAllOptions,`, + ` type PaginateUpToOptions,`, + ` type PaginatedResponse,`, + ` type PaginatedCollection,`, + `} from './sentry-pagination';`, + ``, + `/**`, + ` * Strip \`cursor\` from a query type (the wrapper manages the cursor)`, + ` * and retain the legacy optional \`per_page\` escape hatch.`, + ` * Only audited operations receive automatic remaining-budget sizing.`, + ` */`, + `type PaginationQuery = [TQuery] extends [undefined]`, + ` ? { per_page?: number }`, + ` : Omit, 'cursor'> & { per_page?: number };`, + ``, + ]; + + for (const op of targets) { + lines.push(`// ${op.fn} — ${op.method.toUpperCase()} ${op.url} (${op.isArray ? "array" : "compound"})`); + lines.push(...emitFetchPage(op)); + if (op.isArray) { + lines.push(...emitPaginateAll(op)); + lines.push(...emitPaginateUpTo(op)); + } + } + + writeFileSync(OUT_PATH, lines.join("\n")); + + const arrayCount = targets.filter((t) => t.isArray).length; + const nonArrayCount = targets.length - arrayCount; + const exportCount = arrayCount * 3 + nonArrayCount; + console.log( + `generate-pagination: wrote ${OUT_PATH}\n` + + ` ${targets.length} paginated operations (${arrayCount} array-shaped, ${nonArrayCount} compound)\n` + + ` ${exportCount} exports total`, + ); +} diff --git a/static/packages/api/scripts/generate-query-enum-types.mjs b/static/packages/api/scripts/generate-query-enum-types.mjs new file mode 100644 index 000000000000..7594e9a0dd1b --- /dev/null +++ b/static/packages/api/scripts/generate-query-enum-types.mjs @@ -0,0 +1,117 @@ +/** + * Name the value type of every enum-valued query parameter, e.g. + * `ListOrganizationTraceItemAttributesQueryDataset` for the `dataset` query + * parameter of `listOrganizationTraceItemAttributes`. The generator inlines + * these enums in each operation's `Data` type, so consumers otherwise have to + * dig them out with `NonNullable<…['query']>['dataset']`. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import ts from "typescript"; +import { parse, srcPath } from "../lib/generated-source.mjs"; +import { parseSdkOperations } from "../lib/sdk-operations.mjs"; + +export function emit({ normalizedSpec: spec }) { + const types = parse("types.gen.ts").source; + + /** Top-level type declarations in types.gen.ts, by name. */ + const typeDeclarations = new Map(); + for (const statement of types.statements) { + if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) { + typeDeclarations.set(statement.name.text, statement); + } + } + + const propertyName = (member, source) => + ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(source); + + /** Property names of `Data['query']`. */ + const queryProperties = (dataType) => { + const declaration = typeDeclarations.get(dataType); + const literal = declaration && ts.isTypeAliasDeclaration(declaration) && declaration.type; + if (!literal || !ts.isTypeLiteralNode(literal)) return new Set(); + const query = literal.members.find((member) => + ts.isPropertySignature(member) && propertyName(member, types) === "query"); + if (!query?.type || !ts.isTypeLiteralNode(query.type)) return new Set(); + return new Set( + query.type.members + .filter((member) => ts.isPropertySignature(member)) + .map((member) => propertyName(member, types)), + ); + }; + + const operations = parseSdkOperations(readFileSync(srcPath("sdk.gen.ts"), "utf8")); + + /** `attributeType`, `data_source`, `stats-period` → `AttributeType`, `DataSource`, `StatsPeriod` */ + const pascal = (name) => + name + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(""); + + /** Enum query parameters per route, with whether the parameter is an array. */ + const enumParams = new Map(); + for (const [path, methods] of Object.entries(spec.paths ?? {})) { + for (const [method, operation] of Object.entries(methods)) { + if (typeof operation !== "object" || operation === null) continue; + const params = []; + for (const param of operation.parameters ?? []) { + if (param.in !== "query") continue; + const schema = param.schema ?? {}; + if (Array.isArray(schema.enum)) { + params.push({ name: param.name, array: false }); + } else if (schema.type === "array" && Array.isArray(schema.items?.enum)) { + params.push({ name: param.name, array: true }); + } + } + if (params.length > 0) { + enumParams.set(`${method.toUpperCase()} ${path}`, params); + } + } + } + + const aliases = []; + const seen = new Set(); + for (const { functionName: fn, dataType, method, path } of operations) { + const params = enumParams.get(`${method} ${path}`); + if (!params) continue; + + const typeBase = dataType.replace(/Data$/, ""); + const available = queryProperties(dataType); + for (const { name, array } of params) { + if (!available.has(name)) { + throw new Error(`generate-query-enum-types: ${dataType}['query'] has no ${name} property`); + } + const alias = `${typeBase}Query${pascal(name)}`; + if (typeDeclarations.has(alias) || seen.has(alias)) { + throw new Error(`generate-query-enum-types: ${alias} is already defined`); + } + seen.add(alias); + aliases.push({ alias, array, fn, name, typeBase }); + } + } + aliases.sort((a, b) => a.alias.localeCompare(b.alias)); + + const typeBases = [...new Set(aliases.map(({ typeBase }) => `${typeBase}Data`))].sort(); + const lines = [ + "// This file is auto-generated by scripts/generate-query-enum-types.mjs", + "// Do not edit directly. Re-run `pnpm build` to regenerate.", + "", + "import type {", + ...typeBases.map((typeName) => ` ${typeName},`), + "} from './types.gen';", + "", + ]; + for (const { alias, array, fn, name, typeBase } of aliases) { + const value = `NonNullable[${JSON.stringify(name)}]>`; + lines.push( + `/** A value of the \`${name}\` query parameter of \`${fn}\`. */`, + `export type ${alias} = ${array ? `${value}[number]` : value};`, + "", + ); + } + + writeFileSync(srcPath("query-enums.gen.ts"), lines.join("\n")); + console.log(`generate-query-enum-types: ${aliases.length} query enum types`); +} diff --git a/static/packages/api/scripts/generate-request-preparation.mjs b/static/packages/api/scripts/generate-request-preparation.mjs new file mode 100644 index 000000000000..3963b94b7406 --- /dev/null +++ b/static/packages/api/scripts/generate-request-preparation.mjs @@ -0,0 +1,60 @@ +/** Expose the pinned Fetch client's own preparation without dispatching a request. */ +import { replaceOnce, update } from "../lib/generated-source.mjs"; + +export function emit() { + update("client/client.gen.ts", source => { + source = replaceOnce(source, + "export const createClient = (config: Config = {}): Client => {", + `/** The concrete SDK transport adds preparation without requiring it from custom Clients. */ +export type PreparedClient = Client & { + /** Prepare a native Request using configured auth, serializers and request interceptors, without calling fetch. */ + prepareRequest(options: RequestOptions & Pick, 'method'>): Promise; +}; + +export const createClient = (config: Config = {}): PreparedClient => {`); + source = replaceOnce(source, + " // The public Client signature applies operation-specific data and throw types.\n const request = async (options: RequestOptions) => {", + " const prepare = async (options: RequestOptions) => {"); + source = replaceOnce(source, + " // fetch must be assigned here, otherwise it would throw the error:", + ` return { request, opts }; + }; + + const prepareRequest = async (options: RequestOptions): Promise => + (await prepare(options)).request; + + // The public Client signature applies operation-specific data and throw types. + const request = async (options: RequestOptions) => { + const { request, opts } = await prepare(options); + + // fetch must be assigned here, otherwise it would throw the error:`); + source = replaceOnce(source, " request,\n setConfig,", " request,\n prepareRequest,\n setConfig,"); + return replaceOnce(source, " } as Client;", " } as PreparedClient;"); + }); + + update("client/index.ts", source => replaceOnce(source, + "export { createClient } from './client.gen';", + "export { createClient } from './client.gen';\nexport type { PreparedClient } from './client.gen';", + )); + + // Raw endpoints can already contain a query. Overlay explicitly serialized keys + // without re-encoding unrelated values or a custom serializer's output. + update("core/utils.gen.ts", source => replaceOnce(source, + " if (search) {\n url += `?${search}`;\n }\n return url;", + ` url = url.split('#', 1)[0]!; + const queryIndex = url.indexOf('?'); + const pathname = queryIndex === -1 ? url : url.slice(0, queryIndex); + const existing = queryIndex === -1 ? '' : url.slice(queryIndex + 1); + const replaced = new Set(new URLSearchParams('?' + search).keys()); + // An explicit null clears an existing parameter; undefined leaves it alone. + for (const [key, value] of Object.entries(query ?? {})) { + if (value === null) replaced.add(key); + } + const retained = existing.split('&').filter(part => { + const key = new URLSearchParams('?' + part).keys().next().value; + return part !== '' && (key === undefined || !replaced.has(key)); + }); + const combined = [...retained, search].filter(Boolean).join('&'); + return pathname + (combined ? \`?\${combined}\` : '');`, + )); +} diff --git a/static/packages/api/scripts/generate-response-contract.mjs b/static/packages/api/scripts/generate-response-contract.mjs new file mode 100644 index 000000000000..675ecb832cd8 --- /dev/null +++ b/static/packages/api/scripts/generate-response-contract.mjs @@ -0,0 +1,120 @@ +/** + * Adapt the pinned Fetch generator to the public SDK's fields/error contract. + * Each replacement fails closed so generator upgrades cannot silently discard it. + */ +import { replaceOnce, update } from "../lib/generated-source.mjs"; + +export function emit() { + update("client/client.gen.ts", source => { + source = replaceOnce(source, "import { createSseClient }", "import { SentryApiError } from '../sentry-errors';\n\nimport { createSseClient }"); + source = replaceOnce(source, "export const createClient = (config: Config = {}): Client => {", `// Reject unsupported configuration instead of changing its meaning at runtime. +const assertFields = (config: { responseStyle?: unknown }): void => { + if (config.responseStyle !== undefined && config.responseStyle !== 'fields') { + throw new TypeError('@sentry/api only supports responseStyle: "fields". Read result.data instead.'); + } +}; + +export const createClient = (config: Config = {}): Client => { + assertFields(config);`); + source = replaceOnce(source, " const setConfig = (config: Config): Config => {", " const setConfig = (config: Config): Config => {\n assertFields(config);"); + source = replaceOnce(source, "TResponseStyle extends 'data' | 'fields' = 'fields'", "TResponseStyle extends 'fields' = 'fields'"); + source = replaceOnce(source, " options: RequestOptions,\n ) => {", " options: RequestOptions,\n ) => {\n assertFields(options);"); + + // 0.97 catches preparation, interceptors and decoding as well as fetch. Keep + // our existing contract: only dispatch failures enter the network-error path. + const requestPattern = / const request: Client\['request'\] = async \(options\) => \{[\s\S]*?\n \};/; + const request = source.match(requestPattern)?.[0]; + const body = request?.match(/ try \{\n([\s\S]*?)\n \} catch \(error\) \{/)?.[1]; + if (!body) throw new Error("Fetch generator changed: request try/catch not found"); + source = replaceOnce(source, requestPattern, + " // The public Client signature applies operation-specific data and throw types.\n const request = async (options: RequestOptions) => {\n" + + body.replace(/^ /gm, "") + "\n };"); + source = replaceOnce(source, " request = new Request(url, requestInit);", " let request = new Request(url, requestInit);"); + source = replaceOnce(source, " response = await _fetch(request);", ` let response: Response; + try { + // Check the final intercepted Request, preserving the fetch-error contract. + request.signal.throwIfAborted(); + response = await _fetch(request); + } catch (error) { + let finalError: unknown = error; + for (const fn of interceptors.error.fns) { + if (fn) finalError = await fn(finalError, undefined, request, opts); + } + if (opts.throwOnError) throw finalError; + return { error: finalError, request, response: undefined }; + }`); + source = replaceOnce(source, ` return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + };`, ` return { data: emptyData, ...result };`); + source = replaceOnce(source, ` return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + };`, ` return { data: response.body, ...result };`); + source = replaceOnce(source, ` return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + };`, ` return { data, ...result };`); + source = replaceOnce(source, ` let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + throw jsonError ?? textError;`, ` let body: unknown = textError; + try { + body = JSON.parse(textError); + } catch { + // Non-JSON HTTP error bodies remain available as text. + } + + // Normalize once, before user interceptors. A custom mapping owns its result. + let finalError: unknown = new SentryApiError( + response.status, body, response, false, undefined, + { request, rawBody: textError }, + ); + for (const fn of interceptors.error.fns) { + if (fn) finalError = await fn(finalError, response, request, opts); + } + if (opts.throwOnError) throw finalError; + return { error: finalError, ...result };`); + return source; + }); + + update("client/types.gen.ts", source => { + source = replaceOnce(source, "import type { Auth }", "import type { SdkResult } from '../sentry-errors';\n\nimport type { Auth }"); + source = replaceOnce(source, "export type ResponseStyle = 'data' | 'fields';", "export type ResponseStyle = 'fields';"); + source = replaceOnce(source, "Should we return only data or multiple fields (data, error, response, etc.)?", "Responses always include fields (data or error, request, response)."); + // The stream and its onSseEvent callback must share the caller's data type. + source = replaceOnce(source, + "options: Omit, 'method'>,", + "options: Omit, 'method'>,", + ); + return replaceOnce(source, /export type RequestResult<[\s\S]*?(?=\nexport interface ClientOptions)/, `export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = Promise ? TData[keyof TData] : TData; + request: Request; + response: Response; + } + : SdkResult ? TData[keyof TData] : TData>>; +`); + }); + + update("client/utils.gen.ts", source => replaceOnce(source, + " /** request may be undefined, because error may be from building the request object itself */\n request: Req | undefined,", + " request: Req,", + )); +} diff --git a/static/packages/api/scripts/make-tree-shakeable.mjs b/static/packages/api/scripts/make-tree-shakeable.mjs new file mode 100644 index 000000000000..602ef88dc8fd --- /dev/null +++ b/static/packages/api/scripts/make-tree-shakeable.mjs @@ -0,0 +1,104 @@ +/** + * Keep the generated modules free of eager work at import time so bundlers can + * drop whatever a consumer does not use. + * + * - Validator schemas: every top-level `v.*(…)` / `z.*(…)` call gets a + * `/* @__PURE__ *\/` annotation. Nested calls need their own annotation: + * bundlers keep the side effects of a removed pure call's arguments. One + * annotation covers a whole method chain (`z.number().int().optional()`), + * because every call in the chain starts at the same position. + * - Default client: created on first use, so importing the SDK does not + * construct `Headers` (which makes Node load its fetch implementation). + * + * Each step fails closed so generator upgrades cannot silently undo it. + */ +import ts from "typescript"; +import { replaceOnce, update } from "../lib/generated-source.mjs"; + +const PURE = "/* @__PURE__ */ "; + +function rootIdentifier(expression) { + while ( + ts.isCallExpression(expression) + || ts.isPropertyAccessExpression(expression) + || ts.isElementAccessExpression(expression) + || ts.isParenthesizedExpression(expression) + ) { + expression = expression.expression; + } + return ts.isIdentifier(expression) ? expression.text : undefined; +} + +function annotateSchemaCalls(namespace) { + return (source, name) => { + const file = ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true); + const positions = new Set(); + const schemas = new Set(file.statements.flatMap(statement => + ts.isVariableStatement(statement) + ? statement.declarationList.declarations.flatMap(declaration => + ts.isIdentifier(declaration.name) ? [declaration.name.text] : []) + : [])); + + const visit = node => { + // Calls inside callbacks do not run at import time; leave them alone. + if (ts.isFunctionLike(node)) return; + if (ts.isNewExpression(node)) { + throw new Error(`${name}: unexpected top-level \`new\` expression: ${node.getText(file).slice(0, 80)}`); + } + if (ts.isCallExpression(node)) { + const root = rootIdentifier(node.expression); + // Composite Zod request schemas reference extracted layers with + // zOperationPath.optional() instead of repeating z.object(...). + const optionalSchema = root && schemas.has(root) + && ts.isPropertyAccessExpression(node.expression) + && node.expression.name.text === "optional"; + if (root !== namespace && !optionalSchema) { + throw new Error(`${name}: unexpected top-level call outside \`${namespace}.*\`: ${node.getText(file).slice(0, 80)}`); + } + positions.add(node.getStart(file)); + } + ts.forEachChild(node, visit); + }; + + for (const statement of file.statements) { + if (ts.isImportDeclaration(statement)) continue; + if (!ts.isVariableStatement(statement)) { + throw new Error(`${name}: unexpected top-level statement: ${statement.getText(file).slice(0, 80)}`); + } + visit(statement); + } + if (positions.size === 0) { + throw new Error(`${name}: found no \`${namespace}.*\` schema calls to annotate`); + } + + let output = ""; + let last = 0; + for (const position of [...positions].sort((a, b) => a - b)) { + output += source.slice(last, position) + PURE; + last = position; + } + return output + source.slice(last); + }; +} + +export function emit() { + update("valibot.gen.ts", annotateSchemaCalls("v")); + update("zod.gen.ts", annotateSchemaCalls("z")); + + update("client.gen.ts", source => { + source = replaceOnce(source, + "import { type Client, type ClientOptions, type Config, createClient, createConfig } from './client';", + "import { type ClientOptions, type Config, createClient, createConfig, type PreparedClient } from './client';"); + return replaceOnce(source, + "export const client: Client = createClient(createConfig());", + `let defaultClient: PreparedClient | undefined; + +/** + * The default client used when an operation is called without \`client\`. + * It is created on first use so importing the SDK stays free of eager work. + */ +export const client: PreparedClient = ${PURE}new Proxy({} as PreparedClient, { + get: (_target, key) => Reflect.get((defaultClient ??= createClient(createConfig())), key), +});`); + }); +} diff --git a/static/packages/api/test/api-client/api-path.test.ts b/static/packages/api/test/api-client/api-path.test.ts new file mode 100644 index 000000000000..a53dbc7ce09b --- /dev/null +++ b/static/packages/api/test/api-client/api-path.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { UserInputError } from "../../src/api-client/errors.ts"; +import { + apiPath, + buildSentryApiUrl, + encodeApiPathSegment, + parseApiPathScope, + withQuery, +} from "../../src/api-client/api-path.ts"; + +/** + * Resolves a path the way `fetch` does. Dot-segment normalization happens during URL + * parsing rather than in our code, so a test that only inspected the assembled string + * would pass even if traversal still worked. + */ +function resolve(path: string): string { + const url = new URL(`https://sentry.io/api/0${path}`); + return url.pathname + url.search; +} + +describe("apiPath", () => { + it("leaves legitimate identifier formats byte for byte", () => { + for (const value of [ + "my-org", + "my_project.name", + "1234567890", + "cfe78a5c892d4a64a962d837673398d2", + "7e07485f-12f9-416b-8b14-26260799b51f", + "PROJECT-1Z43", + "latest", + ]) { + expect(apiPath`/projects/${value}/`).toBe(`/projects/${value}/`); + } + }); + + it("resolves an unencoded payload out of scope, proving these tests are meaningful", () => { + const attack = "../../../victim-org/victim-proj/events/abc"; + + expect(resolve(`/projects/my-org/my-proj/events/${attack}/`)).toBe( + "/api/0/projects/victim-org/victim-proj/events/abc/", + ); + }); + + /** + * VULN-2450 asked that any patch be retested against these variants, since + * encode-only fixes for this class have historically been bypassable. + * + * Asserting the whole resolved path is what proves containment. Searching for the + * payload text would not, since an encoded payload still contains it as literal text. + */ + it.each([ + ["plain dot segments", "../../other-org"], + ["double encoded dots", "%252e%252e%252fother-org"], + ["single encoded dots", "%2e%2e%2fother-org"], + ["recursive sequences", "....//....//other-org"], + ["backslash separators", "..\\..\\other-org"], + ["null byte", "abc\u0000/../other-org"], + ["fragment truncation", "abc/#"], + ["query injection", "abc/?download=1&x=2"], + ["scheme relative", "//evil.example.com/x"], + ["absolute path", "/organizations/other-org/members"], + ["trailing dot segment", "abc/.."], + ["dots with a suffix", "..a"], + ])("contains %s in a single segment", (_label, attack) => { + expect(resolve(apiPath`/projects/my-org/my-proj/events/${attack}/`)).toBe( + `/api/0/projects/my-org/my-proj/events/${encodeURIComponent(attack)}/`, + ); + }); + + /** + * A bare dot segment carries no separator, so encoding leaves it intact and the URL + * parser still collapses it. It cannot name another tenant, but it does consume a + * preceding segment, so it has to be rejected rather than encoded. + */ + describe("bare dot segments", () => { + it.each([".", ".."])("rejects %s", (attack) => { + expect(() => apiPath`/projects/o/p/keys/${attack}/`).toThrow( + UserInputError, + ); + }); + + it("would otherwise consume the preceding segment", () => { + expect(resolve("/projects/o/p/keys/../")).toBe("/api/0/projects/o/p/"); + expect(resolve("/projects/o/p/keys/%2e%2e/")).toBe( + "/api/0/projects/o/p/", + ); + }); + + it("accepts values that merely contain dots, including encoded spellings", () => { + // `%2e%2e` as input is inert once the `%` is itself encoded, so rejecting it + // would refuse a value that is already safe. + for (const value of ["1.2.3", "my.project", "...", "%2e%2e"]) { + expect(() => apiPath`/projects/o/p/keys/${value}/`).not.toThrow(); + } + }); + }); +}); + +describe("withQuery", () => { + it("appends a non-empty query and leaves the path alone otherwise", () => { + expect(withQuery("/a/", new URLSearchParams({ q: "x y" }))).toBe( + "/a/?q=x+y", + ); + expect(withQuery("/a/", new URLSearchParams())).toBe("/a/"); + }); +}); + +describe("encodeApiPathSegment", () => { + it.each([".", ".."])("rejects a bare dot segment %s", (value) => { + expect(encodeApiPathSegment(value)).toBeNull(); + }); + + it.each([ + "release/feature", + "../other-org", + "%2e%2e", + "abc/#", + "..\\other-org", + ])("keeps %s inside one path segment after URL parsing", (value) => { + const segment = encodeApiPathSegment(value); + expect(segment).not.toBeNull(); + const url = new URL( + `https://sentry.io/api/0/organizations/my-org/releases/${segment}/`, + ); + expect(url.pathname).toBe( + `/api/0/organizations/my-org/releases/${encodeURIComponent(value)}/`, + ); + }); + + it("preserves ordinary identifiers and numeric IDs", () => { + expect(encodeApiPathSegment("v1.2.3")).toBe("v1.2.3"); + expect(encodeApiPathSegment(42)).toBe("42"); + }); +}); + +describe("buildSentryApiUrl", () => { + it.each([ + [ + "https://sentry.io", + "/organizations/org/", + "https://sentry.io/api/0/organizations/org/", + ], + [ + "https://us.sentry.io/", + "organizations/org/?cursor=a%3Ab", + "https://us.sentry.io/api/0/organizations/org/?cursor=a%3Ab", + ], + [ + "https://self-hosted.example/sentry/", + "/organizations/org/", + "https://self-hosted.example/sentry/api/0/organizations/org/", + ], + [ + "https://self-hosted.example/sentry////", + "organizations/org/", + "https://self-hosted.example/sentry/api/0/organizations/org/", + ], + ])("assembles %s with %s", (baseUrl, endpoint, expected) => { + expect(buildSentryApiUrl(baseUrl, endpoint)).toBe(expected); + }); + + it("preserves encoded identifiers without decoding them", () => { + expect(buildSentryApiUrl("https://sentry.io", "issues/a%2Fb/")).toBe( + "https://sentry.io/api/0/issues/a%2Fb/", + ); + }); +}); + +describe("parseApiPathScope", () => { + it.each([ + ["organizations/acme/issues/", { organization: "acme" }], + ["/api/0/organizations/acme/", { organization: "acme" }], + [ + "organizations/acme/projects/web/keys/", + { organization: "acme", project: "web" }, + ], + [ + "projects/acme/web/events/?query=a", + { organization: "acme", project: "web" }, + ], + ["projects/acme/", { organization: "acme" }], + ["teams/acme/backend/members/", { organization: "acme" }], + ["organizations/123/issues/", { organization: "123" }], + [ + "https://us.sentry.io/api/0/projects/acme/web/", + { organization: "acme", project: "web" }, + ], + [ + "https://self-hosted.example/sentry/api/0/organizations/acme/", + { organization: "acme" }, + ], + ])("reads the scope of %s", (input, expected) => { + expect(parseApiPathScope(input)).toEqual(expected); + }); + + it.each([ + "organizations/", + "users/me/", + "issues/123/", + "organizations/{organization_id_or_slug}/issues/", + "organizations/a%2Fb/", + "organizations/../users/", + "https://sentry.io/organizations/acme/", + ])("finds no scope in %s", (input) => { + expect(parseApiPathScope(input)).toBeUndefined(); + }); + + it("ignores a project segment that is not an identifier", () => { + expect( + parseApiPathScope("organizations/acme/projects/{project_id_or_slug}/"), + ).toEqual({ organization: "acme" }); + }); +}); diff --git a/static/packages/api/test/api-client/auth-token.test.ts b/static/packages/api/test/api-client/auth-token.test.ts new file mode 100644 index 000000000000..b09a74edc9f0 --- /dev/null +++ b/static/packages/api/test/api-client/auth-token.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeAuthToken, + sentryBearerHeader, + trimAuthToken, +} from "../../src/api-client/auth-token.ts"; + +describe("Sentry bearer credentials", () => { + it("preserves every visible ASCII byte through edge normalization", () => { + const visible = Array.from({ length: 94 }, (_, index) => + String.fromCharCode(index + 0x21), + ).join(""); + expect(normalizeAuthToken(`\x00 \u00a0${visible}\t\x7f`)).toBe(visible); + expect(normalizeAuthToken(visible)).toBe(visible); + }); + + it("never removes invalid bytes from inside a credential", () => { + for (const code of [0, 9, 10, 32, 127, 128, 255]) { + const value = `before${String.fromCharCode(code)}after`; + expect(trimAuthToken(value)).toBe(value); + expect(normalizeAuthToken(value)).toBeNull(); + } + expect(normalizeAuthToken(" \t\x00 ")).toBeNull(); + expect(normalizeAuthToken("é")).toBeNull(); + }); + + it("formats a bearer header only for a valid upstream token", () => { + expect(sentryBearerHeader(" \tvalid-token\x7f ")).toBe( + "Bearer valid-token", + ); + expect(sentryBearerHeader("valid\nsecret")).toBeNull(); + expect(sentryBearerHeader(" \t ")).toBeNull(); + }); +}); diff --git a/static/packages/api/test/api-client/core.test.ts b/static/packages/api/test/api-client/core.test.ts new file mode 100644 index 000000000000..6441ecc98ce4 --- /dev/null +++ b/static/packages/api/test/api-client/core.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { + type ApiRequestTransport, + createApiClientCore, + listPage, +} from "../../src/api-client/core.ts"; +import { + ApiNotFoundError, + ApiRateLimitError, + ApiServerError, + ApiValidationError, + ConfigurationError, +} from "../../src/api-client/errors.ts"; +import { ApiResponseValidationError } from "../../src/api-client/validate.ts"; + +const ORIGIN = "https://us.sentry.io"; + +function jsonResponse( + body: unknown, + init: { status?: number; headers?: Record } = {}, +): Response { + return new Response(JSON.stringify(body), { + status: init.status, + headers: { "content-type": "application/json", ...init.headers }, + }); +} + +/** A core over a transport that answers every request with `respond`. */ +function setup(respond: ApiRequestTransport["request"]) { + const transport = { request: vi.fn(respond) }; + const logger = { warn: vi.fn() }; + const core = createApiClientCore({ + transport, + headers: () => ({ Authorization: "Bearer token", "User-Agent": "test" }), + logger, + }); + return { core, transport, logger }; +} + +describe("createApiClientCore request", () => { + it("sends the host's headers to the API URL, replacing the caller's", async () => { + const { core, transport } = setup(async () => jsonResponse({})); + const signal = new AbortController().signal; + + await core.request(ORIGIN, "/organizations/acme/", { + method: "PUT", + body: "{}", + signal, + headers: { "X-Dropped": "1" }, + }); + + expect(transport.request).toHaveBeenCalledWith( + "https://us.sentry.io/api/0/organizations/acme/", + { + method: "PUT", + body: "{}", + signal, + headers: { + "Content-Type": "application/json", + Authorization: "Bearer token", + "User-Agent": "test", + }, + }, + ); + }); + + it("throws the host's header error before sending", async () => { + const transport = { request: vi.fn() }; + const core = createApiClientCore({ + transport, + headers: () => { + throw new ConfigurationError("Malformed authentication token"); + }, + logger: { warn: vi.fn() }, + }); + + await expect(core.request(ORIGIN, "/")).rejects.toThrow( + "Malformed authentication token", + ); + expect(transport.request).not.toHaveBeenCalled(); + }); + + it("throws the status's ApiError with the server's detail", async () => { + const { core, logger } = setup(async () => + jsonResponse({ detail: "Project not found" }, { status: 404 }), + ); + + const error = await core + .request(ORIGIN, "/projects/acme/x/") + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiNotFoundError); + expect(error).toMatchObject({ + message: "Project not found", + status: 404, + detail: "Project not found", + responseBody: { detail: "Project not found" }, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("returns an allowed error status instead of throwing", async () => { + const { core } = setup(async () => + jsonResponse({ detail: "Gone" }, { status: 404 }), + ); + + const response = await core.request(ORIGIN, "/x/", undefined, { + allowStatuses: [404], + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ detail: "Gone" }); + }); + + it("carries a rate limit's wait on the error", async () => { + const { core } = setup(async () => + jsonResponse( + { detail: "Rate limit exceeded" }, + { status: 429, headers: { "Retry-After": "3" } }, + ), + ); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiRateLimitError); + expect(error).toMatchObject({ retryAfterMs: 3000 }); + }); + + it("reports an HTML error page as a server error and logs it", async () => { + const { core, logger } = setup( + async () => + new Response("Bad gateway", { + status: 502, + statusText: "Bad Gateway", + }), + ); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiServerError); + expect((error as Error).message).toBe( + "Server error: Received HTML instead of JSON (502 Bad Gateway). This may indicate an invalid URL or server issue.", + ); + expect(logger.warn).toHaveBeenCalledWith( + "Received HTML error page instead of JSON", + expect.objectContaining({ + status: 502, + host: "us.sentry.io", + path: "/x/", + }), + ); + }); + + it("falls back to the status text and body for an unreadable error", async () => { + const { core, logger } = setup( + async () => + new Response("upstream timed out", { + status: 503, + statusText: "Service Unavailable", + }), + ); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiServerError); + expect((error as Error).message).toBe( + "API request failed: Service Unavailable\nupstream timed out", + ); + expect(logger.warn).toHaveBeenCalledWith( + "Failed to parse JSON error response", + expect.objectContaining({ bodyPreview: "upstream timed out" }), + ); + }); + + it("logs a JSON error body with no string detail", async () => { + const { core, logger } = setup(async () => + jsonResponse({ detail: { code: "x" } }, { status: 400 }), + ); + + await expect(core.request(ORIGIN, "/x/")).rejects.toThrow( + "API request failed", + ); + expect(logger.warn).toHaveBeenCalledWith( + "Failed to parse validated API error response", + expect.objectContaining({ + validationErrorMessage: "Error response has no string detail", + }), + ); + }); + + it("reports an unreachable host as a configuration error", async () => { + const { core } = setup(async () => { + throw new TypeError("fetch failed", { + cause: new Error("getaddrinfo ENOTFOUND us.sentry.io"), + }); + }); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as Error).message).toBe( + "Unable to connect to https://us.sentry.io/api/0/x/ - Hostname not found. Verify the URL is correct.", + ); + }); + + it("names the URL for other network failures", async () => { + const { core } = setup(async () => { + throw new TypeError("fetch failed", { + cause: new Error("read ECONNRESET"), + }); + }); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).not.toBeInstanceOf(ConfigurationError); + expect((error as Error).message).toBe( + "Unable to connect to https://us.sentry.io/api/0/x/ - Connection reset. Try again in a moment.", + ); + }); + + it("rethrows the caller's abort unchanged", async () => { + const controller = new AbortController(); + const reason = new DOMException("stopped", "AbortError"); + const { core } = setup(async () => { + controller.abort(reason); + throw reason; + }); + + await expect( + core.request(ORIGIN, "/x/", { signal: controller.signal }), + ).rejects.toBe(reason); + }); +}); + +describe("createApiClientCore requestJson", () => { + it("returns the parsed body", async () => { + const { core } = setup(async () => jsonResponse([{ slug: "acme" }])); + + expect(await core.requestJson(ORIGIN, "/organizations/")).toEqual([ + { slug: "acme" }, + ]); + }); + + it("rejects a successful response that is not JSON", async () => { + const { core } = setup( + async () => + new Response("", { + headers: { "content-type": "text/html" }, + }), + ); + + await expect(core.requestJson(ORIGIN, "/organizations/")).rejects.toThrow( + "Expected JSON response but received HTML", + ); + }); +}); + +describe("listPage", () => { + const rows = z.array(z.object({ id: z.number() })); + + function page(ids: number[], nextCursor?: string) { + const headers: Record = nextCursor + ? { + link: `<${ORIGIN}/api/0/x/?cursor=${nextCursor}>; rel="next"; results="true"; cursor="${nextCursor}"`, + } + : {}; + return { + data: ids.map((id) => ({ id })), + response: new Response(null, { headers }), + }; + } + + it.each([0, 1.5, 5001])("rejects limit %s", async (limit) => { + await expect( + listPage(async () => page([]), rows, limit), + ).rejects.toBeInstanceOf(ApiValidationError); + }); + + it("rejects a page with more rows than requested", async () => { + await expect( + listPage(async () => page([1, 2, 3]), rows, 2), + ).rejects.toThrow("API returned more items than requested"); + }); + + it("rejects a repeated cursor", async () => { + await expect( + listPage(async () => page([1], "0:1:0"), rows, 5), + ).rejects.toThrow("API returned a repeated pagination cursor"); + }); + + it("validates each page with the schema", async () => { + await expect( + listPage( + async () => ({ data: [{ id: "x" }], response: new Response(null) }), + rows, + 5, + ), + ).rejects.toBeInstanceOf(ApiResponseValidationError); + }); +}); + +describe("createApiClientCore sdkClient", () => { + it("rejects an SDK request to another path", async () => { + const { core, transport } = setup(async () => jsonResponse([])); + const client = core.sdkClient(ORIGIN, "/organizations/"); + + await expect( + client.get({ url: "/api/0/projects/", throwOnError: true }), + ).rejects.toThrow("Unexpected SDK request target or method"); + expect(transport.request).not.toHaveBeenCalled(); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/client-keys.test.ts b/static/packages/api/test/api-client/endpoints/client-keys.test.ts new file mode 100644 index 000000000000..5f3e9a9815bc --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/client-keys.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { + createClientKey, + listClientKeys, + updateClientKey, +} from "../../../src/api-client/endpoints/client-keys.ts"; + +const KeyZod = z.object({ id: z.string() }); +const project = { organizationSlug: "acme", projectSlug: "web" }; +const keysUrl = `${ORIGIN}/api/0/projects/acme/web/keys/`; + +describe("listClientKeys", () => { + it("reads the project's keys", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse([{ id: "a" }])); + + const keys = await listClientKeys(apiTarget, project, z.array(KeyZod)); + + expect(keys).toEqual([{ id: "a" }]); + expect(request.mock.calls[0]?.[0]).toBe(keysUrl); + }); +}); + +describe("createClientKey", () => { + it("posts the name", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ id: "a" })); + + await createClientKey(apiTarget, { ...project, name: "Prod" }, KeyZod); + + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe(keysUrl); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ name: "Prod" })); + }); +}); + +describe("updateClientKey", () => { + it("puts only the fields that are set, keeping a null rate limit", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ id: "a" })); + + await updateClientKey( + apiTarget, + { ...project, keyId: 7, isActive: false, rateLimit: null }, + KeyZod, + ); + + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe(`${keysUrl}7/`); + expect(init?.method).toBe("PUT"); + expect(init?.body).toBe( + JSON.stringify({ isActive: false, rateLimit: null }), + ); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/dashboards.test.ts b/static/packages/api/test/api-client/endpoints/dashboards.test.ts new file mode 100644 index 000000000000..681ebd7973e8 --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/dashboards.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + nextLink, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { getDashboard, listDashboards } from "../../../src/api-client/endpoints/dashboards.ts"; + +const DashboardZod = z.object({ id: z.string() }); + +describe("listDashboards", () => { + it("reads one page with the filters and returns the next cursor", async () => { + const path = "/organizations/acme/dashboards/"; + const { apiTarget, request } = scriptedTarget( + jsonResponse([{ id: "1" }], nextLink(path, "0:10:0")), + ); + + const page = await listDashboards( + apiTarget, + { + organizationSlug: "acme", + query: "web", + sortBy: "-dateCreated", + cursor: "0:0:0", + }, + z.array(DashboardZod), + ); + + expect(page).toEqual({ items: [{ id: "1" }], nextCursor: "0:10:0" }); + expect(request.mock.calls[0]?.[0]).toBe( + `${ORIGIN}/api/0${path}?per_page=10&query=web&sort=-dateCreated&cursor=0%3A0%3A0`, + ); + }); +}); + +describe("getDashboard", () => { + it("reads one dashboard", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ id: "7" })); + + await getDashboard( + apiTarget, + { organizationSlug: "acme", dashboardId: "7" }, + DashboardZod, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${ORIGIN}/api/0/organizations/acme/dashboards/7/`, + ); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/monitors.test.ts b/static/packages/api/test/api-client/endpoints/monitors.test.ts new file mode 100644 index 000000000000..27d701a0dfc0 --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/monitors.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { + deleteMonitorEnvironment, + getMonitor, + getMonitorStats, + listMonitorCheckIns, + listMonitors, +} from "../../../src/api-client/endpoints/monitors.ts"; + +const AnyJson = z.unknown(); +const API = `${ORIGIN}/api/0`; +const monitor = { organizationSlug: "acme", monitorSlug: "nightly" }; + +/** The URL a wrapper call requested. */ +async function requestedUrl( + call: (target: ReturnType["apiTarget"]) => unknown, +) { + const { apiTarget, request } = scriptedTarget(jsonResponse([])); + await call(apiTarget); + return request.mock.calls[0]?.[0]; +} + +describe("listMonitors", () => { + it("sends the filters", async () => { + expect( + await requestedUrl((target) => + listMonitors( + target, + { + organizationSlug: "acme", + projectSlug: "web", + environment: "prod", + owner: "team:1", + query: "job", + limit: 5, + }, + AnyJson, + ), + ), + ).toBe( + `${API}/organizations/acme/monitors/?projectSlug=web&environment=prod&owner=team%3A1&query=job&per_page=5`, + ); + }); +}); + +describe("getMonitor", () => { + it.each([ + [{}, `${API}/organizations/acme/monitors/nightly/`], + [ + { projectSlug: "web", environment: "prod" }, + `${API}/projects/acme/web/monitors/nightly/?environment=prod`, + ], + ])("sends %o to %s", async (params, url) => { + expect( + await requestedUrl((target) => + getMonitor(target, { ...monitor, ...params }, AnyJson), + ), + ).toBe(url); + }); +}); + +describe("deleteMonitorEnvironment", () => { + it("treats a 404 as already deleted", async () => { + const { apiTarget, request } = scriptedTarget( + new Response(null, { status: 404 }), + ); + + await deleteMonitorEnvironment(apiTarget, { + ...monitor, + projectSlug: "web", + environment: "prod", + }); + + expect(request.mock.calls[0]?.[0]).toBe( + `${API}/projects/acme/web/monitors/nightly/environments/prod/`, + ); + expect(request.mock.calls[0]?.[1]?.method).toBe("DELETE"); + }); +}); + +describe("listMonitorCheckIns", () => { + it.each([ + [{}, "statsPeriod=24h"], + [{ statsPeriod: " 7d ", limit: 3 }, "per_page=3&statsPeriod=7d"], + [ + { start: "2026-01-01", end: "2026-01-02" }, + "start=2026-01-01&end=2026-01-02", + ], + ])("sends %o as %s", async (params, query) => { + expect( + await requestedUrl((target) => + listMonitorCheckIns(target, { ...monitor, ...params }, AnyJson), + ), + ).toBe(`${API}/organizations/acme/monitors/nightly/checkins/?${query}`); + }); + + it("rejects a malformed period before sending", async () => { + await expect( + requestedUrl((target) => + listMonitorCheckIns(target, { ...monitor, statsPeriod: "1y" }, AnyJson), + ), + ).rejects.toThrow("statsPeriod"); + }); +}); + +describe("getMonitorStats", () => { + it("sends the range as since and until seconds", async () => { + const url = await requestedUrl((target) => + getMonitorStats( + target, + { + ...monitor, + projectSlug: "web", + environment: "prod", + start: "1970-01-01T00:00:10Z", + end: "1970-01-01T00:00:20Z", + rollup: 60, + }, + AnyJson, + ), + ); + + expect(url).toBe( + `${API}/projects/acme/web/monitors/nightly/stats/?environment=prod&since=10&until=20&resolution=60s`, + ); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/organizations.test.ts b/static/packages/api/test/api-client/endpoints/organizations.test.ts new file mode 100644 index 000000000000..49307514d7d4 --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/organizations.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import * as v from "valibot"; +import { z } from "zod"; +import { + jsonResponse, + nextLink, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { ApiResponseValidationError } from "@sentry/api/client"; +import { + getAuthenticatedUser, + getOrganization, + listOrganizations, + updateAgenticOnboardingStatus, +} from "../../../src/api-client/endpoints/organizations.ts"; + +const OrganizationZod = z.object({ slug: z.string() }); +const OrganizationValibot = v.object({ slug: v.string() }); + +describe("getAuthenticatedUser", () => { + it("reads /auth/ and returns the host schema's output", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse({ id: "1", email: "jane@example.com", extra: true }), + ); + + const user = await getAuthenticatedUser( + apiTarget, + z.object({ id: z.string(), email: z.string() }), + ); + + expect(user).toEqual({ id: "1", email: "jane@example.com" }); + expect(request.mock.calls[0]?.[0]).toBe(`${ORIGIN}/api/0/auth/`); + }); + + it("throws the shared validation error for a mismatched body", async () => { + const { apiTarget } = scriptedTarget(jsonResponse({ id: 1 })); + + await expect( + getAuthenticatedUser(apiTarget, z.object({ id: z.string() })), + ).rejects.toBeInstanceOf(ApiResponseValidationError); + }); +}); + +describe("getOrganization", () => { + it.each([ + [{}, ""], + [{ includeFeatureFlags: true }, "?include_feature_flags=1"], + [{ detailed: false }, "?detailed=0"], + [{ detailed: true }, ""], + ])("sends %o as %s", async (params, query) => { + const { apiTarget, request } = scriptedTarget( + jsonResponse({ slug: "acme" }), + ); + + await getOrganization( + apiTarget, + { organizationSlug: "acme", ...params }, + OrganizationZod, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${ORIGIN}/api/0/organizations/acme/${query}`, + ); + }); + + it("encodes the slug as a path segment", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse({ slug: "a/b" }), + ); + + await getOrganization( + apiTarget, + { organizationSlug: "a/b" }, + OrganizationValibot, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${ORIGIN}/api/0/organizations/a%2Fb/`, + ); + }); +}); + +describe("listOrganizations", () => { + it("follows next cursors until it has the limit", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse( + [{ slug: "a" }, { slug: "b" }], + nextLink("/organizations/", "0:2:0"), + ), + jsonResponse([{ slug: "c" }], nextLink("/organizations/", "0:3:0")), + ); + + const page = await listOrganizations( + apiTarget, + { query: "ac", limit: 3 }, + z.array(OrganizationZod), + ); + + expect(page).toEqual({ + items: [{ slug: "a" }, { slug: "b" }, { slug: "c" }], + nextCursor: "0:3:0", + }); + const urls = request.mock.calls.map(([url]) => new URL(url)); + expect(urls.map((url) => url.pathname)).toEqual([ + "/api/0/organizations/", + "/api/0/organizations/", + ]); + expect(urls[0]?.searchParams.get("per_page")).toBe("3"); + expect(urls[0]?.searchParams.get("query")).toBe("ac"); + expect(urls[1]?.searchParams.get("per_page")).toBe("1"); + expect(urls[1]?.searchParams.get("cursor")).toBe("0:2:0"); + }); + + it("returns no cursor once the server has no next page", async () => { + const { apiTarget } = scriptedTarget(jsonResponse([{ slug: "a" }])); + + const page = await listOrganizations( + apiTarget, + {}, + v.array(OrganizationValibot), + ); + + expect(page).toEqual({ items: [{ slug: "a" }], nextCursor: null }); + }); + + it("starts from the given cursor", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse([])); + + await listOrganizations( + apiTarget, + { cursor: "0:25:0" }, + z.array(OrganizationZod), + ); + + expect( + new URL(request.mock.calls[0]?.[0] ?? "").searchParams.get("cursor"), + ).toBe("0:25:0"); + }); +}); + +describe("updateAgenticOnboardingStatus", () => { + it("posts the update as the body", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ id: "1" })); + + await updateAgenticOnboardingStatus( + apiTarget, + { organizationSlug: "acme", update: { step: "done" } }, + z.object({ id: z.string() }), + ); + + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe( + `${ORIGIN}/api/0/organizations/acme/onboarding/agent/status/`, + ); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ step: "done" })); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/projects.test.ts b/static/packages/api/test/api-client/endpoints/projects.test.ts new file mode 100644 index 000000000000..a33d703e9b68 --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/projects.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import * as v from "valibot"; +import { z } from "zod"; +import { + jsonResponse, + nextLink, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { + addProjectTeam, + createProject, + getProject, + listProjects, + listProjectTeams, + removeProjectTeam, + updateProject, +} from "../../../src/api-client/endpoints/projects.ts"; + +const ProjectZod = z.object({ slug: z.string() }); + +describe("listProjects", () => { + it("reads a page of the organization's projects", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse([{ slug: "web" }]), + ); + + const page = await listProjects( + apiTarget, + { organizationSlug: "acme", query: "we" }, + v.array(v.object({ slug: v.string() })), + ); + + expect(page).toEqual({ items: [{ slug: "web" }], nextCursor: null }); + const url = new URL(request.mock.calls[0]?.[0] ?? ""); + expect(url.pathname).toBe("/api/0/organizations/acme/projects/"); + expect(url.searchParams.get("per_page")).toBe("25"); + expect(url.searchParams.get("query")).toBe("we"); + }); +}); + +describe("getProject", () => { + it.each([ + [undefined, ""], + ["organization" as const, "?collapse=organization"], + ])("sends collapse=%s as %s", async (collapse, query) => { + const { apiTarget, request } = scriptedTarget( + jsonResponse({ slug: "web" }), + ); + + await getProject( + apiTarget, + { organizationSlug: "acme", projectSlugOrId: "web", collapse }, + ProjectZod, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${ORIGIN}/api/0/projects/acme/web/${query}`, + ); + }); +}); + +describe("createProject", () => { + it.each([ + [{}, { name: "Web" }], + [{ slug: "", platform: null }, { name: "Web" }], + [ + { slug: "web", platform: "javascript" }, + { name: "Web", slug: "web", platform: "javascript" }, + ], + ])("posts %o as %o to the team", async (optional, sent) => { + const { apiTarget, request } = scriptedTarget( + jsonResponse({ slug: "web" }), + ); + + await createProject( + apiTarget, + { organizationSlug: "acme", teamSlug: "core", name: "Web", ...optional }, + ProjectZod, + ); + + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe(`${ORIGIN}/api/0/teams/acme/core/projects/`); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify(sent)); + }); +}); + +describe("updateProject", () => { + it("puts only the non-empty fields", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse({ slug: "app" }), + ); + + await updateProject( + apiTarget, + { + organizationSlug: "acme", + projectSlug: "web", + name: "", + slug: "app", + platform: null, + }, + ProjectZod, + ); + + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe(`${ORIGIN}/api/0/projects/acme/web/`); + expect(init?.method).toBe("PUT"); + expect(init?.body).toBe(JSON.stringify({ slug: "app" })); + }); +}); + +describe("listProjectTeams", () => { + it("follows every page", async () => { + const path = "/projects/acme/web/teams/"; + const { apiTarget, request } = scriptedTarget( + jsonResponse([{ slug: "a" }], nextLink(path, "0:100:0")), + jsonResponse([{ slug: "b" }]), + ); + + const teams = await listProjectTeams( + apiTarget, + { organizationSlug: "acme", projectSlug: "web" }, + z.array(z.object({ slug: z.string() })), + ); + + expect(teams).toEqual([{ slug: "a" }, { slug: "b" }]); + expect(request.mock.calls.map(([url]) => url)).toEqual([ + `${ORIGIN}/api/0${path}?per_page=100`, + `${ORIGIN}/api/0${path}?per_page=100&cursor=0%3A100%3A0`, + ]); + }); +}); + +describe("listProjectTeams cursors", () => { + it("fails on a repeated cursor instead of looping", async () => { + const path = "/projects/acme/web/teams/"; + const { apiTarget, request } = scriptedTarget( + jsonResponse([{ slug: "a" }], nextLink(path, "0:100:0")), + jsonResponse([{ slug: "b" }], nextLink(path, "0:100:0")), + ); + + await expect( + listProjectTeams( + apiTarget, + { organizationSlug: "acme", projectSlug: "web" }, + z.array(z.object({ slug: z.string() })), + ), + ).rejects.toThrow("API returned a repeated pagination cursor"); + expect(request).toHaveBeenCalledTimes(2); + }); +}); + +describe("project team assignment", () => { + const params = { + organizationSlug: "acme", + projectSlug: "web", + teamSlug: "core", + }; + const url = `${ORIGIN}/api/0/projects/acme/web/teams/core/`; + + it("adds a team with an empty POST", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse({ slug: "web" }), + ); + + await addProjectTeam(apiTarget, params); + + expect(request.mock.calls[0]?.[0]).toBe(url); + expect(request.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + body: "{}", + }); + }); + + it("removes a team with a DELETE", async () => { + const { apiTarget, request } = scriptedTarget( + new Response(null, { status: 204 }), + ); + + await removeProjectTeam(apiTarget, params); + + expect(request.mock.calls[0]?.[0]).toBe(url); + expect(request.mock.calls[0]?.[1]?.method).toBe("DELETE"); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/releases.test.ts b/static/packages/api/test/api-client/endpoints/releases.test.ts new file mode 100644 index 000000000000..e31218361f67 --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/releases.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { + getEventCommitters, + getRelease, + listReleaseCommits, + listReleaseDeploys, + listReleases, +} from "../../../src/api-client/endpoints/releases.ts"; + +const AnyJson = z.unknown(); +const API = `${ORIGIN}/api/0`; + +/** The URL a wrapper call requested. */ +async function requestedUrl( + call: (target: ReturnType["apiTarget"]) => unknown, +) { + const { apiTarget, request } = scriptedTarget(jsonResponse([])); + await call(apiTarget); + return request.mock.calls[0]?.[0]; +} + +describe("listReleases", () => { + it.each([ + [{}, `${API}/organizations/acme/releases/`], + [ + { projectSlug: "web", query: "1.0", limit: 5 }, + `${API}/projects/acme/web/releases/?query=1.0&per_page=5`, + ], + ])("sends %o to %s", async (params, url) => { + expect( + await requestedUrl((target) => + listReleases(target, { organizationSlug: "acme", ...params }, AnyJson), + ), + ).toBe(url); + }); +}); + +describe("getRelease", () => { + it.each([ + [{}, `${API}/organizations/acme/releases/1.0%2Bbuild/`], + [ + { projectSlugOrId: "web", includeHealth: true }, + `${API}/projects/acme/web/releases/1.0%2Bbuild/?health=1`, + ], + ])("sends %o to %s", async (params, url) => { + expect( + await requestedUrl((target) => + getRelease( + target, + { organizationSlug: "acme", releaseVersion: "1.0+build", ...params }, + AnyJson, + ), + ), + ).toBe(url); + }); +}); + +describe("listReleaseDeploys", () => { + it.each([ + ["42", "project=42"], + ["web", "projectSlug=web"], + ])("narrows project %s with %s", async (projectSlugOrId, query) => { + expect( + await requestedUrl((target) => + listReleaseDeploys( + target, + { + organizationSlug: "acme", + releaseVersion: "1.0", + projectSlugOrId, + limit: 3, + }, + AnyJson, + ), + ), + ).toBe( + `${API}/organizations/acme/releases/1.0/deploys/?${query}&per_page=3`, + ); + }); +}); + +describe("listReleaseCommits", () => { + it.each([ + [{}, `${API}/organizations/acme/releases/1.0/commits/`], + [ + { projectSlugOrId: "web", limit: 2 }, + `${API}/projects/acme/web/releases/1.0/commits/?per_page=2`, + ], + ])("sends %o to %s", async (params, url) => { + expect( + await requestedUrl((target) => + listReleaseCommits( + target, + { organizationSlug: "acme", releaseVersion: "1.0", ...params }, + AnyJson, + ), + ), + ).toBe(url); + }); +}); + +describe("getEventCommitters", () => { + it("reads the event's committers", async () => { + expect( + await requestedUrl((target) => + getEventCommitters( + target, + { organizationSlug: "acme", projectSlug: "web", eventId: "abc" }, + AnyJson, + ), + ), + ).toBe(`${API}/projects/acme/web/events/abc/committers/`); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/replays.test.ts b/static/packages/api/test/api-client/endpoints/replays.test.ts new file mode 100644 index 000000000000..664ca69f06b3 --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/replays.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + nextLink, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { + listIssueReplayIds, + searchReplays, + visitReplayRecordingSegments, +} from "../../../src/api-client/endpoints/replays.ts"; + +const AnyJson = z.unknown(); +const API = `${ORIGIN}/api/0`; +const SegmentPage = z.array(z.array(z.unknown())); +const segments = { + organizationSlug: "acme", + projectSlugOrId: "web", + replayId: "r1", +}; +const segmentsPath = "/projects/acme/web/replays/r1/recording-segments/"; + +describe("searchReplays", () => { + it("sends every filter, repeating environments and fields", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ data: [] })); + + await searchReplays( + apiTarget, + { + organizationSlug: "acme", + query: "user.email:a@b.c", + limit: 5, + projectId: "7", + sort: "-started_at", + environment: ["prod", "staging"], + fields: ["id", "duration"], + statsPeriod: "7d", + }, + AnyJson, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${API}/organizations/acme/replays/?query=user.email%3Aa%40b.c&per_page=5&project=7&sort=-started_at&environment=prod&environment=staging&field=id&field=duration&statsPeriod=7d`, + ); + }); +}); + +describe("listIssueReplayIds", () => { + it("asks for the issue's replay IDs over 90 days in all projects", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({})); + + await listIssueReplayIds( + apiTarget, + { organizationSlug: "acme", issueId: 42, dataSource: "discover" }, + AnyJson, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${API}/organizations/acme/replay-count/?returnIds=true&query=issue.id%3A%5B42%5D&data_source=discover&statsPeriod=90d&project=-1`, + ); + }); +}); + +describe("visitReplayRecordingSegments", () => { + it("follows the cursor and visits each segment", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse([["a"], ["b"]], nextLink(segmentsPath, "0:100:0")), + jsonResponse([["c"]]), + ); + const onSegment = vi.fn(); + + const result = await visitReplayRecordingSegments( + apiTarget, + { ...segments, maxSegments: 10 }, + SegmentPage, + onSegment, + ); + + expect(result).toEqual({ segmentsRead: 3, truncated: false }); + expect(onSegment.mock.calls).toEqual([[["a"]], [["b"]], [["c"]]]); + expect(request.mock.calls.map(([url]) => url)).toEqual([ + `${API}${segmentsPath}?per_page=100`, + `${API}${segmentsPath}?per_page=100&cursor=0%3A100%3A0`, + ]); + }); + + it("stops at the segment limit and reports truncation", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse([["a"], ["b"]], nextLink(segmentsPath, "0:100:0")), + ); + const onSegment = vi.fn(); + + const result = await visitReplayRecordingSegments( + apiTarget, + { ...segments, maxSegments: 2 }, + SegmentPage, + onSegment, + ); + + expect(result).toEqual({ segmentsRead: 2, truncated: true }); + expect(onSegment).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("stops at an empty page that still has a cursor", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse([], nextLink(segmentsPath, "0:100:0")), + jsonResponse([], nextLink(segmentsPath, "0:200:0")), + ); + + const result = await visitReplayRecordingSegments( + apiTarget, + { ...segments, maxSegments: 10 }, + SegmentPage, + vi.fn(), + ); + + expect(result).toEqual({ segmentsRead: 0, truncated: true }); + expect(request).toHaveBeenCalledTimes(1); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/repositories.test.ts b/static/packages/api/test/api-client/endpoints/repositories.test.ts new file mode 100644 index 000000000000..6692fae6f0a1 --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/repositories.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + nextLink, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { linkProjectRepository, listRepositories } from "../../../src/api-client/endpoints/repositories.ts"; + +describe("listRepositories", () => { + it("follows every page with the query", async () => { + const path = "/organizations/acme/repos/"; + const { apiTarget, request } = scriptedTarget( + jsonResponse([{ name: "a" }], nextLink(path, "0:100:0")), + jsonResponse([{ name: "b" }]), + ); + + const repos = await listRepositories( + apiTarget, + { organizationSlug: "acme", query: "sentry" }, + z.array(z.object({ name: z.string() })), + ); + + expect(repos).toEqual([{ name: "a" }, { name: "b" }]); + expect(request.mock.calls.map(([url]) => url)).toEqual([ + `${ORIGIN}/api/0${path}?per_page=100&query=sentry`, + `${ORIGIN}/api/0${path}?per_page=100&query=sentry&cursor=0%3A100%3A0`, + ]); + }); +}); + +describe("listRepositories cursors", () => { + it("fails on a repeated cursor instead of looping", async () => { + const path = "/organizations/acme/repos/"; + const { apiTarget, request } = scriptedTarget( + jsonResponse([{ name: "a" }], nextLink(path, "0:100:0")), + jsonResponse([{ name: "b" }], nextLink(path, "0:100:0")), + ); + + await expect( + listRepositories( + apiTarget, + { organizationSlug: "acme" }, + z.array(z.object({ name: z.string() })), + ), + ).rejects.toThrow("API returned a repeated pagination cursor"); + expect(request).toHaveBeenCalledTimes(2); + }); +}); + +describe("linkProjectRepository", () => { + it("posts one root code mapping for the project", async () => { + const { apiTarget, request } = scriptedTarget(jsonResponse({ ok: true })); + + await linkProjectRepository( + apiTarget, + { + organizationSlug: "acme", + projectSlug: "web", + repository: "acme/web", + provider: "github", + }, + z.object({ ok: z.boolean() }), + ); + + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe(`${ORIGIN}/api/0/organizations/acme/code-mappings/bulk/`); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe( + JSON.stringify({ + project: "web", + repository: "acme/web", + provider: "github", + mappings: [{ stackRoot: "", sourceRoot: "" }], + }), + ); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/teams.test.ts b/static/packages/api/test/api-client/endpoints/teams.test.ts new file mode 100644 index 000000000000..beb5abf63753 --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/teams.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import * as v from "valibot"; +import { z } from "zod"; +import { + jsonResponse, + nextLink, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { ApiResponseValidationError } from "@sentry/api/client"; +import { createTeam, listTeams } from "../../../src/api-client/endpoints/teams.ts"; + +const TeamZod = z.object({ slug: z.string() }); + +describe("listTeams", () => { + it("reads a page of the organization's teams", async () => { + const cursor = "0:1:0"; + const { apiTarget, request } = scriptedTarget( + jsonResponse( + [{ slug: "a" }], + nextLink("/organizations/acme/teams/", cursor), + ), + ); + + const page = await listTeams( + apiTarget, + { organizationSlug: "acme", query: "a", limit: 1 }, + v.array(v.object({ slug: v.string() })), + ); + + expect(page).toEqual({ items: [{ slug: "a" }], nextCursor: cursor }); + const url = new URL(request.mock.calls[0]?.[0] ?? ""); + expect(url.pathname).toBe("/api/0/organizations/acme/teams/"); + expect(url.searchParams.get("per_page")).toBe("1"); + expect(url.searchParams.get("query")).toBe("a"); + }); +}); + +describe("createTeam", () => { + it("posts the name and validates the created team", async () => { + const { apiTarget, request } = scriptedTarget( + jsonResponse({ slug: "web" }), + ); + + const team = await createTeam( + apiTarget, + { organizationSlug: "acme", name: "web" }, + TeamZod, + ); + + expect(team).toEqual({ slug: "web" }); + const [url, init] = request.mock.calls[0] ?? []; + expect(url).toBe(`${ORIGIN}/api/0/organizations/acme/teams/`); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ name: "web" })); + }); + + it("throws the shared validation error for a mismatched body", async () => { + const { apiTarget } = scriptedTarget(jsonResponse({ slug: 1 })); + + await expect( + createTeam(apiTarget, { organizationSlug: "acme", name: "web" }, TeamZod), + ).rejects.toBeInstanceOf(ApiResponseValidationError); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/traces-profiling-snapshots.test.ts b/static/packages/api/test/api-client/endpoints/traces-profiling-snapshots.test.ts new file mode 100644 index 000000000000..96556df7ea41 --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/traces-profiling-snapshots.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { + getFlamegraph, + getProfileChunks, + getTransactionProfile, +} from "../../../src/api-client/endpoints/profiling.ts"; +import { + getLatestBaseSnapshot, + getSnapshot, + getSnapshotImage, +} from "../../../src/api-client/endpoints/snapshots.ts"; +import { getTrace, getTraceMeta } from "../../../src/api-client/endpoints/traces.ts"; + +const AnyJson = z.unknown(); +const API = `${ORIGIN}/api/0`; +const trace = { organizationSlug: "acme", traceId: "abc123" }; + +/** The URL a wrapper call requested. */ +async function requestedUrl( + call: (target: ReturnType["apiTarget"]) => unknown, +) { + const { apiTarget, request } = scriptedTarget(jsonResponse({})); + await call(apiTarget); + return request.mock.calls[0]?.[0]; +} + +describe("traces", () => { + it("sends the default period for trace meta", async () => { + expect( + await requestedUrl((target) => getTraceMeta(target, trace, AnyJson)), + ).toBe(`${API}/organizations/acme/trace-meta/abc123/?statsPeriod=14d`); + }); + + it("sends the declared trace parameters with their defaults", async () => { + expect( + await requestedUrl((target) => + getTrace(target, { ...trace, statsPeriod: "7d" }, AnyJson), + ), + ).toBe( + `${API}/organizations/acme/trace/abc123/?limit=1000&project=-1&statsPeriod=7d`, + ); + }); +}); + +describe("profiling", () => { + it("escapes the transaction name in the flamegraph query", async () => { + const url = await requestedUrl((target) => + getFlamegraph( + target, + { + organizationSlug: "acme", + projectId: 7, + transactionName: 'GET /a\\b "c"', + }, + AnyJson, + ), + ); + + const query = new URL(url ?? "").searchParams; + expect(query.get("project")).toBe("7"); + expect(query.get("query")).toBe( + 'event.type:transaction transaction:"GET /a\\\\b \\"c\\""', + ); + expect(query.get("statsPeriod")).toBe("7d"); + }); + + it("requests a transaction profile by project", async () => { + expect( + await requestedUrl((target) => + getTransactionProfile( + target, + { organizationSlug: "acme", projectSlugOrId: "web", profileId: "p1" }, + AnyJson, + ), + ), + ).toBe(`${API}/projects/acme/web/profiling/profiles/p1/`); + }); + + it("sends the profiler and range for chunks", async () => { + expect( + await requestedUrl((target) => + getProfileChunks( + target, + { + organizationSlug: "acme", + profilerId: "prof", + projectId: 7, + start: "2026-01-01T00:00:00Z", + end: "2026-01-01T01:00:00Z", + }, + AnyJson, + ), + ), + ).toBe( + `${API}/organizations/acme/profiling/chunks/?profiler_id=prof&project=7&start=2026-01-01T00%3A00%3A00Z&end=2026-01-01T01%3A00%3A00Z`, + ); + }); +}); + +describe("snapshots", () => { + const snapshots = `${API}/organizations/acme/preprodartifacts/snapshots`; + + it.each([ + [{}, `${snapshots}/s1/?compact_metadata=true`], + [{ compactMetadata: false }, `${snapshots}/s1/`], + ])("requests a snapshot with %o", async (params, url) => { + expect( + await requestedUrl((target) => + getSnapshot(target, { + organizationSlug: "acme", + snapshotId: "s1", + ...params, + }), + ), + ).toBe(url); + }); + + it("requests one snapshot image", async () => { + expect( + await requestedUrl((target) => + getSnapshotImage(target, { + organizationSlug: "acme", + snapshotId: "s1", + imageIdentifier: "home.png", + }), + ), + ).toBe(`${snapshots}/s1/images/home.png/`); + }); + + it("sends the set filters for the latest base snapshot", async () => { + expect( + await requestedUrl((target) => + getLatestBaseSnapshot(target, { + organizationSlug: "acme", + appId: "com.example", + branch: "main", + projectSlug: "ios", + }), + ), + ).toBe( + `${snapshots}/latest-base/?app_id=com.example&branch=main&projectSlug=ios&compact_metadata=true`, + ); + }); +}); diff --git a/static/packages/api/test/api-client/endpoints/uptime-monitors.test.ts b/static/packages/api/test/api-client/endpoints/uptime-monitors.test.ts new file mode 100644 index 000000000000..5c0c0835653c --- /dev/null +++ b/static/packages/api/test/api-client/endpoints/uptime-monitors.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + jsonResponse, + ORIGIN, + scriptedTarget, +} from "../support.ts"; +import { + createUptimeMonitor, + deleteUptimeMonitor, + listUptimeMonitorChecks, + listUptimeMonitors, + updateUptimeMonitor, +} from "../../../src/api-client/endpoints/uptime-monitors.ts"; + +const AnyJson = z.unknown(); +const API = `${ORIGIN}/api/0`; +const monitor = { + organizationSlug: "acme", + projectSlug: "web", + uptimeMonitorId: "42", +}; + +/** The URL and init of the request a wrapper call sent. */ +async function sentRequest( + call: (target: ReturnType["apiTarget"]) => unknown, +) { + const { apiTarget, request } = scriptedTarget(jsonResponse({})); + await call(apiTarget); + const [url, init] = request.mock.calls[0] ?? []; + return { url, init }; +} + +describe("listUptimeMonitors", () => { + it("sends the filters", async () => { + const { url } = await sentRequest((target) => + listUptimeMonitors( + target, + { + organizationSlug: "acme", + projectId: "7", + environment: "prod", + owner: "team:1", + query: "api", + limit: 5, + }, + AnyJson, + ), + ); + + expect(url).toBe( + `${API}/organizations/acme/uptime/?project=7&environment=prod&owner=team%3A1&query=api&per_page=5`, + ); + }); + + it("sends no query string without filters", async () => { + const { url } = await sentRequest((target) => + listUptimeMonitors(target, { organizationSlug: "acme" }, AnyJson), + ); + + expect(url).toBe(`${API}/organizations/acme/uptime/`); + }); +}); + +describe("listUptimeMonitorChecks", () => { + it.each([ + [{}, "statsPeriod=24h"], + [{ statsPeriod: " 7d ", limit: 3 }, "per_page=3&statsPeriod=7d"], + [ + { start: "2026-01-01", end: "2026-01-02" }, + "start=2026-01-01&end=2026-01-02", + ], + ])("sends %o as %s", async (params, query) => { + const { url } = await sentRequest((target) => + listUptimeMonitorChecks(target, { ...monitor, ...params }, AnyJson), + ); + + expect(url).toBe(`${API}/projects/acme/web/uptime/42/checks/?${query}`); + }); + + it("rejects a malformed period before sending", async () => { + await expect( + sentRequest((target) => + listUptimeMonitorChecks( + target, + { ...monitor, statsPeriod: "1y" }, + AnyJson, + ), + ), + ).rejects.toThrow("statsPeriod"); + }); +}); + +describe("createUptimeMonitor", () => { + it("sends the required fields first and every set field, null included", async () => { + const { url, init } = await sentRequest((target) => + createUptimeMonitor( + target, + { + organizationSlug: "acme", + projectSlug: "web", + environment: null, + method: "GET", + name: "API", + url: "https://example.com", + intervalSeconds: 60, + timeoutMs: 1000, + }, + AnyJson, + ), + ); + + expect(url).toBe(`${API}/projects/acme/web/uptime/`); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe( + JSON.stringify({ + name: "API", + url: "https://example.com", + intervalSeconds: 60, + timeoutMs: 1000, + method: "GET", + environment: null, + }), + ); + }); +}); + +describe("updateUptimeMonitor", () => { + it("sends only the fields that are set", async () => { + const { url, init } = await sentRequest((target) => + updateUptimeMonitor( + target, + { ...monitor, status: "disabled", owner: null }, + AnyJson, + ), + ); + + expect(url).toBe(`${API}/projects/acme/web/uptime/42/`); + expect(init?.method).toBe("PUT"); + expect(init?.body).toBe( + JSON.stringify({ status: "disabled", owner: null }), + ); + }); +}); + +describe("deleteUptimeMonitor", () => { + it("treats a 404 as already deleted", async () => { + const { apiTarget, request } = scriptedTarget( + new Response(null, { status: 404 }), + ); + + await deleteUptimeMonitor(apiTarget, monitor); + + expect(request.mock.calls[0]?.[0]).toBe( + `${API}/projects/acme/web/uptime/42/`, + ); + expect(request.mock.calls[0]?.[1]?.method).toBe("DELETE"); + }); +}); diff --git a/static/packages/api/test/api-client/errors.test.ts b/static/packages/api/test/api-client/errors.test.ts new file mode 100644 index 000000000000..fa53c10a1de1 --- /dev/null +++ b/static/packages/api/test/api-client/errors.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; +import { narrowError, SentryApiError } from "@sentry/api"; +import { + ApiError, + ApiNotFoundError, + ApiRateLimitError, + ApiValidationError, + createApiError, +} from "../../src/api-client/errors.ts"; + +describe("ApiError", () => { + test("extends SentryApiError with the status and parsed body", () => { + const body = { detail: "Project not found" }; + const error = createApiError("Project not found", 404, "Project not found", body); + + expect(error).toBeInstanceOf(ApiNotFoundError); + expect(error).toBeInstanceOf(SentryApiError); + expect(error).toMatchObject({ + name: "ApiNotFoundError", + message: "Project not found", + status: 404, + body, + responseBody: body, + detail: "Project not found", + documented: false, + response: undefined, + }); + }); + + test("keeps client-side validation errors without a status", () => { + const error = new ApiValidationError("limit must be positive"); + + expect(error).toBeInstanceOf(SentryApiError); + expect(error.status).toBeUndefined(); + expect(error.body).toBeUndefined(); + }); + + test("keeps the rate-limit wait", () => { + const error = createApiError("slow down", 429, undefined, undefined, 2500); + + expect(error).toBeInstanceOf(ApiRateLimitError); + expect((error as ApiRateLimitError).retryAfterMs).toBe(2500); + }); + + test("is rethrown unchanged by status narrowing, like other subclasses", () => { + const error = new ApiError("boom", 500); + const request = new Request("https://sentry.example.test/api/0/"); + + expect(() => narrowError({ error, request, response: undefined }, [])).toThrow(error); + }); +}); diff --git a/static/packages/api/test/api-client/idempotent-operations.test.ts b/static/packages/api/test/api-client/idempotent-operations.test.ts new file mode 100644 index 000000000000..76f03a5495d9 --- /dev/null +++ b/static/packages/api/test/api-client/idempotent-operations.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "vitest"; +import { + createIdempotentOperationMatcher, + selectIdempotentWrites, +} from "../../src/api-client/idempotent-operations.ts"; + +describe("selectIdempotentWrites", () => { + test("keeps marked writes and drops safe methods and unmarked writes", () => { + expect( + selectIdempotentWrites([ + { method: "GET", path: "/api/0/b/", idempotent: true }, + { method: "PUT", path: "/api/0/b/", idempotent: true }, + { method: "POST", path: "/api/0/b/", idempotent: false }, + { method: "DELETE", path: "/api/0/a/", idempotent: true }, + ]), + ).toEqual([ + { method: "DELETE", path: "/api/0/a/" }, + { method: "PUT", path: "/api/0/b/" }, + ]); + }); +}); + +describe("createIdempotentOperationMatcher", () => { + const isIdempotent = createIdempotentOperationMatcher(() => [ + { + method: "POST", + path: "/api/0/organizations/{organization_id_or_slug}/chunk-upload/", + }, + ]); + const url = "https://us.sentry.io/api/0/organizations/acme/chunk-upload/"; + + test("matches a templated path", () => { + expect(isIdempotent("POST", url)).toBe(true); + }); + + test("ignores method case, a missing trailing slash, and the query", () => { + expect(isIdempotent("post", url.slice(0, -1))).toBe(true); + expect(isIdempotent("POST", `${url}?foo=bar`)).toBe(true); + }); + + test("matches under a self-hosted path prefix", () => { + expect( + isIdempotent( + "POST", + "https://sentry.example.com/sentry/api/0/organizations/acme/chunk-upload/", + ), + ).toBe(true); + }); + + test("does not match other methods or extra path segments", () => { + expect(isIdempotent("PUT", url)).toBe(false); + expect( + isIdempotent( + "POST", + "https://us.sentry.io/api/0/organizations/acme/extra/chunk-upload/", + ), + ).toBe(false); + }); + + test("returns false for URLs it cannot parse or that are not API paths", () => { + expect(isIdempotent("POST", "not a url")).toBe(false); + expect( + isIdempotent("POST", "https://us.sentry.io/organizations/acme/"), + ).toBe(false); + }); + + test("reads the list once, on first use", () => { + let loads = 0; + const matcher = createIdempotentOperationMatcher(() => { + loads += 1; + return []; + }); + expect(loads).toBe(0); + matcher("POST", url); + matcher("POST", url); + expect(loads).toBe(1); + }); +}); diff --git a/static/packages/api/test/api-client/objectstore.test.ts b/static/packages/api/test/api-client/objectstore.test.ts new file mode 100644 index 000000000000..c879125e486d --- /dev/null +++ b/static/packages/api/test/api-client/objectstore.test.ts @@ -0,0 +1,353 @@ +/** + * Tests for the Objectstore client. A fetch is injected so URL construction, + * the `x-os-auth` header, the batch request/response protocol, and the PUT + * request shape can be checked without a network. + */ + +import { describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../src/api-client/errors.ts"; +import { + findExistingObjects, + type ObjectstoreConfig, + ObjectstoreError, + type ObjectUpload, + putObjects, +} from "../../src/api-client/objectstore.ts"; + +const config: ObjectstoreConfig = { + url: "https://objectstore.example.com/", + usecase: "preprod_snapshots", + scopes: [ + ["org", "123"], + ["project", "456"], + ], + authToken: "jwt-token", + expirationPolicy: "ttl:30d", +}; + +type FetchMock = ReturnType Promise>>; + +function mockFetch(): FetchMock { + return vi.fn<(input: string, init?: RequestInit) => Promise>(); +} + +/** Build a batch response in the server's `multipart/form-data` shape. */ +function batchResponse(parts: { index: number; status: string; body?: string }[]): Response { + const boundary = "os-boundary-00000000000000000000000000000abc"; + let text = ""; + for (const part of parts) { + text += + `--${boundary}\r\n` + + `x-sn-batch-operation-index: ${part.index}\r\n` + + `x-sn-batch-operation-status: ${part.status}\r\n` + + "content-disposition: form-data; name=part\r\n\r\n" + + `${part.body ?? ""}\r\n`; + } + text += `--${boundary}--`; + return new Response(text, { + status: 200, + headers: { "content-type": `multipart/form-data; boundary="${boundary}"` }, + }); +} + +/** Decode a captured batch request body as multipart form data. */ +async function sentParts(init: RequestInit): Promise { + const headers = init.headers as Record; + const form = await new Response(init.body as BodyInit, { + headers: { "content-type": headers["content-type"] as string }, + }).formData(); + return form.getAll("part"); +} + +function rawBody(init: RequestInit): string { + return new TextDecoder("latin1").decode(init.body as Uint8Array); +} + +function upload(key: string, bytes: Uint8Array): ObjectUpload & { reads: number } { + const item = { + key, + size: bytes.byteLength, + reads: 0, + read: async () => { + item.reads += 1; + return bytes; + }, + }; + return item; +} + +const OBJECTS_URL = "https://objectstore.example.com/v1/objects/preprod_snapshots/org=123;project=456"; +/** Over the 1 MB batch part limit, so it is PUT on its own. */ +const LARGE = 1024 * 1024 + 1; + +describe("findExistingObjects", () => { + it("sends all HEADs in one batch request and returns existing keys", async () => { + const fetch = mockFetch().mockResolvedValue( + batchResponse([ + { index: 1, status: "404 Not Found" }, + { index: 0, status: "200 OK" }, + { index: 2, status: "200 OK" }, + ]), + ); + + const existing = await findExistingObjects(config, ["k0", "k1", "k2"], { fetch }); + + expect([...existing].sort()).toEqual(["k0", "k2"]); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = fetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://objectstore.example.com/v1/objects:batch/preprod_snapshots/org=123;project=456/", + ); + expect(init.method).toBe("POST"); + expect((init.headers as Record)["x-os-auth"]).toBe("Bearer jwt-token"); + expect(await sentParts(init)).toHaveLength(3); + const raw = rawBody(init); + expect(raw).toContain("x-sn-batch-operation-kind: head\r\n"); + expect(raw).toContain("x-sn-batch-operation-key: k1\r\n"); + }); + + it("percent-encodes `%`, controls, and non-ASCII in operation keys", async () => { + const fetch = mockFetch().mockResolvedValue(batchResponse([{ index: 0, status: "404 Not Found" }])); + + await findExistingObjects(config, ["100% r\u00e9port\n"], { fetch }); + + const [, init] = fetch.mock.calls[0] as [string, RequestInit]; + expect(rawBody(init)).toContain("x-sn-batch-operation-key: 100%25 r%C3%A9port%0A\r\n"); + }); + + it("splits more than 1000 keys into batches sent concurrently", async () => { + let inFlight = 0; + let maxInFlight = 0; + const fetch = mockFetch().mockImplementation(async (_url, init) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + const count = (await sentParts(init as RequestInit)).length; + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlight -= 1; + return batchResponse(Array.from({ length: count }, (_, index) => ({ index, status: "200 OK" }))); + }); + const keys = Array.from({ length: 2500 }, (_, i) => `k${i}`); + + const existing = await findExistingObjects(config, keys, { fetch }); + + expect(existing.size).toBe(2500); + expect(fetch).toHaveBeenCalledTimes(3); + expect(maxInFlight).toBe(3); + }); + + it("starts no queued batch after one fails", async () => { + // Five batches, four in flight: the first fails before the others finish. + const fetch = mockFetch().mockImplementation(async (_url, init) => { + const call = fetch.mock.calls.length; + const count = (await sentParts(init as RequestInit)).length; + if (call === 1) { + return new Response(null, { status: 429 }); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return batchResponse(Array.from({ length: count }, (_, index) => ({ index, status: "200 OK" }))); + }); + const keys = Array.from({ length: 5000 }, (_, i) => `k${i}`); + + await expect(findExistingObjects(config, keys, { fetch })).rejects.toThrow(ObjectstoreError); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(fetch).toHaveBeenCalledTimes(4); + }); + + it("throws on a per-operation error other than 404", async () => { + const fetch = mockFetch().mockResolvedValue( + batchResponse([ + { index: 0, status: "200 OK" }, + { index: 1, status: "429 Too Many Requests", body: "slow down — retry" }, + ]), + ); + const error = await findExistingObjects(config, ["k0", "k1"], { fetch }).catch((e) => e); + expect(error).toBeInstanceOf(ObjectstoreError); + expect(error).toBeInstanceOf(ApiError); + expect(error.status).toBe(429); + expect(error.detail).toBe("slow down — retry"); + expect(error.url).toBe(`${OBJECTS_URL}/k1`); + }); + + it("throws when the batch request itself fails", async () => { + const fetch = mockFetch().mockResolvedValue( + new Response(null, { status: 429, statusText: "Too Many Requests" }), + ); + const error = await findExistingObjects(config, ["k0"], { fetch }).catch((e) => e); + expect(error).toBeInstanceOf(ObjectstoreError); + expect(error.status).toBe(429); + expect(error.detail).toBe("Too Many Requests"); + }); + + it("throws when the response is missing an operation", async () => { + const fetch = mockFetch().mockResolvedValue(batchResponse([{ index: 0, status: "200 OK" }])); + await expect(findExistingObjects(config, ["k0", "k1"], { fetch })).rejects.toThrow( + ObjectstoreError, + ); + }); + + it("throws when the response is not multipart", async () => { + const fetch = mockFetch().mockResolvedValue( + new Response("{}", { headers: { "content-type": "application/json" } }), + ); + const error = await findExistingObjects(config, ["k0"], { fetch }).catch((e) => e); + expect(error).toBeInstanceOf(ObjectstoreError); + expect(error.status).toBe(502); + }); +}); + +describe("putObjects", () => { + it("batches small uploads and PUTs uploads over the batch part limit", async () => { + const small = upload("1/2/small", new Uint8Array([1, 2, 3])); + const large = upload("1/2/large", new Uint8Array(LARGE)); + const fetch = mockFetch().mockImplementation(async (_url, init) => + init?.method === "POST" + ? batchResponse([{ index: 0, status: "201 Created" }]) + : new Response(null, { status: 201 }), + ); + + await putObjects(config, [small, large], { fetch }); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(small.reads).toBe(1); + expect(large.reads).toBe(1); + const [, batchInit] = fetch.mock.calls.find(([, init]) => init?.method === "POST") as [ + string, + RequestInit, + ]; + const parts = await sentParts(batchInit); + expect(parts).toHaveLength(1); + // Parts without a filename decode as strings. + expect(parts[0]).toBe("\x01\x02\x03"); + const raw = rawBody(batchInit); + expect(raw).toContain("x-sn-batch-operation-kind: insert\r\n"); + expect(raw).toContain("x-sn-batch-operation-key: 1/2/small\r\n"); + expect(raw).toContain("x-sn-expiration: ttl:30d\r\n"); + + const [putUrl] = fetch.mock.calls.find(([, init]) => init?.method === "PUT") as [string]; + expect(putUrl).toBe(`${OBJECTS_URL}/1/2/large`); + }); + + it("splits uploads over 25 MB into batches sent concurrently", async () => { + let inFlight = 0; + let maxInFlight = 0; + const fetch = mockFetch().mockImplementation(async (_url, init) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + const count = (await sentParts(init as RequestInit)).length; + await new Promise((resolve) => setTimeout(resolve, 10)); + inFlight -= 1; + return batchResponse( + Array.from({ length: count }, (_, index) => ({ index, status: "201 Created" })), + ); + }); + // 60 uploads of 1 MB: three batches of at most 25 MB each. + const uploads = Array.from({ length: 60 }, (_, i) => upload(`k${i}`, new Uint8Array(1024 * 1024))); + + await putObjects(config, uploads, { fetch }); + + expect(fetch).toHaveBeenCalledTimes(3); + expect(maxInFlight).toBe(3); + expect(uploads.every((u) => u.reads === 1)).toBe(true); + }); + + it("reads one upload at a time within a batch", async () => { + let reading = 0; + let maxReading = 0; + const uploads = Array.from({ length: 50 }, (_, i): ObjectUpload => ({ + key: `k${i}`, + size: 1, + read: async () => { + reading += 1; + maxReading = Math.max(maxReading, reading); + await new Promise((resolve) => setTimeout(resolve, 1)); + reading -= 1; + return new Uint8Array([i]); + }, + })); + const fetch = mockFetch().mockResolvedValue( + batchResponse(uploads.map((_, index) => ({ index, status: "201 Created" }))), + ); + + await putObjects(config, uploads, { fetch }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(maxReading).toBe(1); + }); + + it("starts and reads no queued upload after one fails", async () => { + // Six large uploads, five PUTs in flight: the first fails before the others finish. + const fetch = mockFetch().mockImplementation(async () => { + if (fetch.mock.calls.length === 1) { + return new Response(null, { status: 429 }); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + return new Response(null, { status: 201 }); + }); + const uploads = Array.from({ length: 6 }, (_, i) => upload(`k${i}`, new Uint8Array(LARGE))); + + await expect(putObjects(config, uploads, { fetch })).rejects.toThrow(ObjectstoreError); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(fetch).toHaveBeenCalledTimes(5); + expect(uploads[5]?.reads).toBe(0); + }); + + it("throws when an insert in the batch fails", async () => { + const fetch = mockFetch().mockResolvedValue( + batchResponse([{ index: 0, status: "500 Internal Server Error" }]), + ); + await expect( + putObjects(config, [upload("k", new Uint8Array([1]))], { fetch }), + ).rejects.toThrow(ObjectstoreError); + }); +}); + +describe("individual PUTs", () => { + it("PUTs the body with auth + expiration headers", async () => { + const fetch = mockFetch().mockResolvedValue(new Response(null, { status: 200 })); + + await putObjects(config, [upload("1/2/abc", new Uint8Array(LARGE))], { fetch }); + + const [url, init] = fetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${OBJECTS_URL}/1/2/abc`); + expect(init.method).toBe("PUT"); + expect((init.body as Uint8Array).byteLength).toBe(LARGE); + const headers = init.headers as Record; + expect(headers["x-os-auth"]).toBe("Bearer jwt-token"); + expect(headers["x-sn-expiration"]).toBe("ttl:30d"); + }); + + it("percent-encodes key segments in the object URL", async () => { + const fetch = mockFetch().mockResolvedValue(new Response(null, { status: 200 })); + + await putObjects(config, [upload("1/a b%?#^\\c/r\u00e9", new Uint8Array(LARGE))], { fetch }); + + const [url] = fetch.mock.calls[0] as [string]; + expect(url).toBe(`${OBJECTS_URL}/1/a%20b%25%3F%23%5E%5Cc/r%C3%A9`); + expect(new URL(url).pathname).toMatch(/\/1\/a%20b%25%3F%23%5E%5Cc\/r%C3%A9$/); + }); + + it("omits the auth header without a token", async () => { + const fetch = mockFetch().mockResolvedValue(new Response(null, { status: 200 })); + + await putObjects({ ...config, authToken: null }, [upload("k", new Uint8Array(LARGE))], { + fetch, + }); + + const [, init] = fetch.mock.calls[0] as [string, RequestInit]; + expect(init.headers as Record).not.toHaveProperty("x-os-auth"); + }); + + it("throws on a non-2xx response", async () => { + const fetch = mockFetch().mockResolvedValue( + new Response(null, { status: 403, statusText: "Forbidden" }), + ); + const error = await putObjects(config, [upload("k", new Uint8Array(LARGE))], { fetch }).catch( + (e) => e, + ); + expect(error).toBeInstanceOf(ObjectstoreError); + expect(error.status).toBe(403); + expect(error.url).toBe(`${OBJECTS_URL}/k`); + }); +}); diff --git a/static/packages/api/test/api-client/pagination.test.ts b/static/packages/api/test/api-client/pagination.test.ts new file mode 100644 index 000000000000..d5519b7f5321 --- /dev/null +++ b/static/packages/api/test/api-client/pagination.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from "vitest"; +import { + consumeCursorPage, + decodePageCursor, + fetchAllPages, + type Page, + paginate, +} from "../../src/api-client/pagination.ts"; + +const MAX_PAGES = 50; + +/** + * A list endpoint over rows 1..total with Sentry offset cursors, + * `0::`. It parses `is_prev` the way Sentry does, with + * Python's `int()`, which reads any `0`s joined by single `_`s as 0. + * `pageSize` makes it ignore `per_page`, as some endpoints do. + */ +function offsetEndpoint(total: number, pageSize?: number) { + return vi.fn( + async ( + perPage: number, + cursor: string | undefined, + ): Promise> => { + const [, offsetText = "0", isPrev = "0"] = cursor?.split(":") ?? []; + if (!/^0(?:_?0)*$/.test(isPrev)) { + throw new Error(`Sentry would reject is_prev ${isPrev}`); + } + const offset = Number(offsetText); + const size = pageSize ?? perPage; + const end = Math.min(offset + size, total); + const data = Array.from( + { length: end - offset }, + (_, i) => offset + i + 1, + ); + return end < total ? { data, nextCursor: `0:${end}:0` } : { data }; + }, + ); +} + +describe("paginate", () => { + describe("limit-bounded walks", () => { + it("splits a limit above one page evenly over the fewest requests", async () => { + const endpoint = offsetEndpoint(1000); + const result = await paginate( + { limit: 201, maxPages: MAX_PAGES }, + endpoint, + ); + + expect(endpoint.mock.calls.map(([perPage]) => perPage)).toEqual([ + 67, 67, 67, + ]); + expect(result.data).toHaveLength(201); + expect(result.data.at(-1)).toBe(201); + }); + + it("makes one request for a limit within one page", async () => { + const endpoint = offsetEndpoint(1000); + const result = await paginate( + { limit: 25, maxPages: MAX_PAGES }, + endpoint, + ); + + expect(endpoint.mock.calls).toEqual([[25, undefined]]); + expect(result.data).toHaveLength(25); + }); + + it("returns no cursor once the endpoint is exhausted", async () => { + const result = await paginate( + { limit: 50, maxPages: MAX_PAGES }, + offsetEndpoint(30), + ); + + expect(result).toEqual({ + data: Array.from({ length: 30 }, (_, i) => i + 1), + stoppedAtPageCap: false, + }); + expect("nextCursor" in result).toBe(false); + }); + }); + + describe("resume-safe cursors", () => { + it("encodes the SDK's remaining offset as a shared CLI bookmark", () => { + const page = consumeCursorPage( + { data: [1, 2], nextCursor: "0:2:0" }, + decodePageCursor("cli1~100~5~"), + ); + + expect(page.data).toEqual([]); + expect(decodePageCursor(page.nextCursor)).toEqual({ + cursor: "0:2:0", + skip: 3, + }); + }); + + it("resumes inside a page without repeating or skipping rows", async () => { + // The endpoint ignores per_page and always returns 67 rows. + const endpoint = offsetEndpoint(1000, 67); + const first = await paginate( + { limit: 30, maxPages: MAX_PAGES }, + endpoint, + ); + const second = await paginate( + { limit: 10, cursor: first.nextCursor, maxPages: MAX_PAGES }, + endpoint, + ); + + expect(first.data).toEqual(Array.from({ length: 30 }, (_, i) => i + 1)); + expect(second.data).toEqual(Array.from({ length: 10 }, (_, i) => i + 31)); + }); + + it("returns cursors the API and older clients accept, resuming at or before the stop", async () => { + const endpoint = offsetEndpoint(1000, 67); + const { nextCursor } = await paginate( + { limit: 30, maxPages: MAX_PAGES }, + endpoint, + ); + + // A client that sends the cursor unchanged gets the page from its + // first row: row 1, which is at or before row 31. + const raw = await endpoint(67, nextCursor); + expect(raw.data[0]).toBeLessThanOrEqual(31); + }); + }); + + it("reuses the page size of a page-number cursor whatever the new limit", async () => { + const endpoint = vi.fn().mockResolvedValue({ + data: Array.from({ length: 50 }, (_, i) => i), + nextCursor: "50:2:0", + }); + const result = await paginate( + { limit: 10, cursor: "50:1:0", maxPages: MAX_PAGES }, + endpoint, + ); + + expect(endpoint.mock.calls).toEqual([[50, "50:1:0"]]); + expect(result.data).toHaveLength(10); + }); + + it("stops at the page cap with a cursor that resumes after the last row", async () => { + const endpoint = offsetEndpoint(10_000); + const first = await paginate({ limit: 1000, maxPages: 3 }, endpoint); + const second = await paginate( + { limit: 1, cursor: first.nextCursor, maxPages: 3 }, + endpoint, + ); + + expect(endpoint).toHaveBeenCalledTimes(4); + expect(first.stoppedAtPageCap).toBe(true); + expect(first.data).toHaveLength(300); + expect(second.data).toEqual([301]); + }); + + it("does not report the page cap when the limit was reached", async () => { + const result = await paginate( + { limit: 300, maxPages: 3 }, + offsetEndpoint(10_000), + ); + + expect(result.data).toHaveLength(300); + expect(result.stoppedAtPageCap).toBe(false); + }); + + it("warns and restarts on an unreadable cli1~ cursor", async () => { + const endpoint = vi.fn().mockResolvedValue({ data: [] }); + const warn = vi.fn(); + await paginate( + { limit: 5, cursor: "cli1~101~0~abc", maxPages: MAX_PAGES, warn }, + endpoint, + ); + + expect(endpoint.mock.calls).toEqual([[5, undefined]]); + expect(warn).toHaveBeenCalledTimes(1); + }); +}); + +describe("fetchAllPages", () => { + const numbers = (data: unknown) => + Array.isArray(data) && data.every((item) => typeof item === "number") + ? data + : undefined; + + it("follows cursors and returns every validated row", async () => { + const fetchPage = vi.fn((cursor: string | undefined) => + Promise.resolve( + cursor ? { data: [3] } : { data: [1, 2], nextCursor: "2" }, + ), + ); + + expect(await fetchAllPages(fetchPage, numbers, MAX_PAGES)).toEqual({ + ok: true, + items: [1, 2, 3], + }); + expect(fetchPage.mock.calls).toEqual([[undefined], ["2"]]); + }); + + it.each([ + { + reason: "invalid-page", + page: () => ({ data: ["one"] }), + }, + { + reason: "repeated-cursor", + page: () => ({ data: [1], nextCursor: "same" }), + }, + { + reason: "page-cap", + page: (() => { + let page = 0; + return () => { + page += 1; + return { data: [1], nextCursor: String(page) }; + }; + })(), + }, + ])( + "fails instead of returning a partial list: $reason", + async ({ reason, page }) => { + expect( + await fetchAllPages(() => Promise.resolve(page()), numbers, 3), + ).toEqual({ ok: false, reason }); + }, + ); +}); diff --git a/static/packages/api/test/api-client/portability.test.ts b/static/packages/api/test/api-client/portability.test.ts new file mode 100644 index 000000000000..dc9cecbabd42 --- /dev/null +++ b/static/packages/api/test/api-client/portability.test.ts @@ -0,0 +1,83 @@ +/** + * The shared client must run in browsers and Workers. `tsconfig.browser.json` + * typechecks it against the DOM library with no Node types, which catches + * Node globals; this test catches the imports that typecheck would still + * resolve: Node built-ins and the validators hosts pass in themselves. + */ +import { describe, expect, test } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { builtinModules } from "node:module"; +import { join, relative } from "node:path"; +import ts from "typescript"; +import { fileURLToPath } from "node:url"; + +const LIB = fileURLToPath(new URL("../../lib/api-client/", import.meta.url)); + +const NODE_MODULES = new Set(builtinModules.map((name) => name.split("/")[0])); +const HOST_VALIDATORS = /^(?:zod|valibot)(?:\/|$)|^@sentry\/api\/(?:zod|valibot)$/; + +function forbidden(specifier: string): boolean { + return ( + specifier.startsWith("node:") || + NODE_MODULES.has(specifier.split("/")[0]!) || + HOST_VALIDATORS.test(specifier) + ); +} + +/** Module specifiers a source file imports, re-exports, or loads dynamically. */ +function importedModules(fileName: string, source: string): string[] { + const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); + const specifiers: string[] = []; + const visit = (node: ts.Node) => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + specifiers.push(node.moduleSpecifier.text); + } + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments[0] && + ts.isStringLiteralLike(node.arguments[0]) + ) { + specifiers.push(node.arguments[0].text); + } + ts.forEachChild(node, visit); + }; + visit(file); + return specifiers; +} + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? sourceFiles(path) : entry.name.endsWith(".ts") ? [path] : []; + }); +} + +describe("shared client portability", () => { + test("imports no Node built-ins or host validators", () => { + const violations = sourceFiles(LIB).flatMap((path) => + importedModules(path, readFileSync(path, "utf8")) + .filter(forbidden) + .map((specifier) => `${relative(LIB, path)}: ${specifier}`), + ); + expect(violations).toEqual([]); + }); + + test("flags a planted Node import", () => { + const planted = [ + 'import { gzip } from "node:zlib";', + 'export { Buffer } from "buffer";', + 'const z = await import("zod");', + 'import { createSentryClient } from "@sentry/api";', + ].join("\n"); + expect(importedModules("planted.ts", planted).filter(forbidden)).toEqual([ + "node:zlib", + "buffer", + "zod", + ]); + }); +}); diff --git a/static/packages/api/test/api-client/response-cache/invalidation.test.ts b/static/packages/api/test/api-client/response-cache/invalidation.test.ts new file mode 100644 index 000000000000..7fddd7b6e957 --- /dev/null +++ b/static/packages/api/test/api-client/response-cache/invalidation.test.ts @@ -0,0 +1,170 @@ +/** + * Unit tests for `computeInvalidationPrefixes`. + */ + +import { describe, expect, test } from "vitest"; +import { computeInvalidationPrefixes as computeInvalidationPrefixesRaw } from "../../../src/api-client/response-cache/invalidation.ts"; + +const BASE = "https://us.sentry.io/api/0/"; +const API_BASE_URL = "https://sentry.io"; + +/** Test wrapper that pins `apiBaseUrl` to the control-silo default. */ +function computeInvalidationPrefixes(fullUrl: string): string[] { + return computeInvalidationPrefixesRaw(fullUrl, API_BASE_URL); +} + +describe("computeInvalidationPrefixes — hierarchy walk", () => { + test("detail URL yields self + ancestors down to owner", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues/12345/`, + ); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/12345/`); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + // The bare `organizations/` root is deliberately NOT swept — + // it would evict other orgs' caches. + expect(prefixes).not.toContain(`${BASE}organizations/`); + }); + + test("deeply nested path yields every ancestor down to owner", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/releases/1.0.0/deploys/`, + ); + expect(prefixes).toContain( + `${BASE}organizations/acme/releases/1.0.0/deploys/`, + ); + expect(prefixes).toContain(`${BASE}organizations/acme/releases/1.0.0/`); + expect(prefixes).toContain(`${BASE}organizations/acme/releases/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + // Stop at the owner level, don't sweep bare `organizations/`. + expect(prefixes).not.toContain(`${BASE}organizations/`); + }); + + test("single-segment path yields only the segment itself", () => { + const prefixes = computeInvalidationPrefixes(`${BASE}organizations/`); + expect(prefixes).toEqual([`${BASE}organizations/`]); + }); + + test("two-segment path walks to the top (owner-level mutation)", () => { + // The floor only kicks in at length > 2. + const prefixes = computeInvalidationPrefixes(`${BASE}organizations/acme/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + expect(prefixes).toContain(`${BASE}organizations/`); + }); + + test("query string is stripped from the prefix set", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues/12345/?collapse=stats&collapse=lifetime`, + ); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/12345/`); + expect( + prefixes.every((p) => !(p.includes("collapse=") || p.includes("?"))), + ).toBe(true); + }); + + test("trailing-slashless URL still works", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues`, + ); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/`); + expect(prefixes).toContain(`${BASE}organizations/acme/`); + }); +}); + +describe("computeInvalidationPrefixes — cross-endpoint rules", () => { + test("POST /teams/{org}/{team}/projects/ invalidates org project list", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}teams/acme/backend/projects/`, + ); + expect(prefixes).toContain(`${BASE}organizations/acme/projects/`); + expect(prefixes).toContain(`${BASE}teams/acme/backend/projects/`); + expect(prefixes).toContain(`${BASE}teams/acme/backend/`); + expect(prefixes).toContain(`${BASE}teams/acme/`); + expect(prefixes).not.toContain(`${BASE}teams/`); + }); + + test("DELETE /projects/{org}/{project}/ invalidates org project list", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}projects/acme/frontend/`, + ); + expect(prefixes).toContain(`${BASE}organizations/acme/projects/`); + expect(prefixes).toContain(`${BASE}projects/acme/frontend/`); + expect(prefixes).toContain(`${BASE}projects/acme/`); + expect(prefixes).not.toContain(`${BASE}projects/`); + }); + + test("org-scoped issue mutation invalidates cross-origin legacy endpoint", () => { + // `updateIssueStatus` / `mergeIssues` hit the org-scoped endpoint + // at the region URL, but `getIssue()` caches under the control-silo + // URL at a DIFFERENT origin. Without a cross-origin rule, stale + // legacy cache entries survive org-scoped mutations. + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/issues/12345/`, + ); + expect(prefixes).toContain(`${API_BASE_URL}/api/0/issues/12345/`); + // Plus the hierarchy walk under the mutation's own origin: + expect(prefixes).toContain(`${BASE}organizations/acme/issues/12345/`); + expect(prefixes).toContain(`${BASE}organizations/acme/issues/`); + }); + + test("unrelated paths get no cross-endpoint sweep", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}organizations/acme/teams/`, + ); + expect(prefixes).not.toContain(`${BASE}organizations/acme/projects/`); + expect(prefixes).toContain(`${BASE}organizations/acme/teams/`); + }); +}); + +describe("computeInvalidationPrefixes — edge cases", () => { + test("returns [] for URLs not under /api/0/", () => { + expect( + computeInvalidationPrefixes("https://example.com/some/path/"), + ).toEqual([]); + expect( + computeInvalidationPrefixes("https://uploads.sentry.io/x/y"), + ).toEqual([]); + }); + + test("returns [] for unparseable URLs", () => { + expect(computeInvalidationPrefixes("not-a-url")).toEqual([]); + expect(computeInvalidationPrefixes("")).toEqual([]); + }); + + test("returns [] when the mutation hits /api/0/ root itself", () => { + expect(computeInvalidationPrefixes(BASE)).toEqual([]); + }); + + test("deduplicates prefixes across hierarchy + rule-table", () => { + const prefixes = computeInvalidationPrefixes( + `${BASE}teams/acme/backend/projects/`, + ); + expect(prefixes.length).toBe(new Set(prefixes).size); + }); + + test("write-only endpoints skip invalidation entirely", () => { + // Sourcemap chunk uploads and bundle assembly don't modify any + // cacheable state; without this skip, every chunk POST would + // sweep the org's cache hierarchy. + expect( + computeInvalidationPrefixes(`${BASE}organizations/acme/chunk-upload/`), + ).toEqual([]); + expect( + computeInvalidationPrefixes( + `${BASE}organizations/acme/artifactbundle/assemble/`, + ), + ).toEqual([]); + }); + + test("self-hosted base URLs are preserved", () => { + const prefixes = computeInvalidationPrefixes( + "https://sentry.example.com/api/0/organizations/acme/issues/12345/", + ); + expect(prefixes).toContain( + "https://sentry.example.com/api/0/organizations/acme/issues/12345/", + ); + expect(prefixes).toContain( + "https://sentry.example.com/api/0/organizations/acme/", + ); + }); +}); diff --git a/static/packages/api/test/api-client/response-cache/keys.test.ts b/static/packages/api/test/api-client/response-cache/keys.test.ts new file mode 100644 index 000000000000..210e1271d704 --- /dev/null +++ b/static/packages/api/test/api-client/response-cache/keys.test.ts @@ -0,0 +1,64 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { buildCacheKey, normalizeUrl } from "../../../src/api-client/response-cache/keys.ts"; + +const URL_A = "https://us.sentry.io/api/0/organizations/acme/projects/?b=2&a=1"; + +describe("normalizeUrl", () => { + it("uppercases the method and sorts query params, keeping the host", () => { + expect(normalizeUrl("get", URL_A)).toBe( + "GET|https://us.sentry.io/api/0/organizations/acme/projects/?a=1&b=2", + ); + }); + + it("keeps the order of repeated query params", () => { + expect( + normalizeUrl( + "GET", + "https://us.sentry.io/api/0/issues/1/events/latest/?collapse=fullRelease&a=1&collapse=adjacentEvents", + ), + ).toBe( + "GET|https://us.sentry.io/api/0/issues/1/events/latest/?a=1&collapse=fullRelease&collapse=adjacentEvents", + ); + }); + + it("keeps self-hosted hosts", () => { + expect( + normalizeUrl("GET", "https://sentry.mycompany.internal/api/0/issues/"), + ).toBe("GET|https://sentry.mycompany.internal/api/0/issues/"); + }); + + it("throws on a URL it cannot parse", () => { + expect(() => normalizeUrl("GET", "not a url")).toThrow(TypeError); + }); +}); + +describe("buildCacheKey", () => { + it("gives two identities different keys for the same URL", async () => { + expect(await buildCacheKey("GET", URL_A, "alice")).not.toBe( + await buildCacheKey("GET", URL_A, "bob"), + ); + }); + + it("gives the same path on two Sentry hosts different keys", async () => { + const path = "/api/0/organizations/acme/projects/"; + expect( + await buildCacheKey("GET", `https://us.sentry.io${path}`, "alice"), + ).not.toBe( + await buildCacheKey("GET", `https://sentry.example.com${path}`, "alice"), + ); + }); + + it("matches node:crypto HMAC-SHA256, so existing CLI cache entries still hit", async () => { + const identity = "oauth:3f2a9c"; + const expected = createHmac("sha256", identity) + .update(normalizeUrl("GET", URL_A)) + .digest("hex"); + + expect(await buildCacheKey("GET", URL_A, identity)).toBe(expected); + }); + + it("refuses an empty identity, which would not scope entries", async () => { + await expect(buildCacheKey("GET", URL_A, "")).rejects.toThrow(TypeError); + }); +}); diff --git a/static/packages/api/test/api-client/response-cache/memory-store.test.ts b/static/packages/api/test/api-client/response-cache/memory-store.test.ts new file mode 100644 index 000000000000..de2e11b087ec --- /dev/null +++ b/static/packages/api/test/api-client/response-cache/memory-store.test.ts @@ -0,0 +1,238 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createBodyCodec, + type EncodedBody, +} from "../support.ts"; +import { buildCacheKey } from "../../../src/api-client/response-cache/keys.ts"; +import { createMemoryStore, ENTRY_OVERHEAD_BYTES } from "../../../src/api-client/response-cache/memory-store.ts"; +import type { CacheEntry } from "../../../src/api-client/response-cache/store.ts"; + +const API = "https://us.sentry.io/api/0"; +const MINUTE = 60_000; +const encoder = new TextEncoder(); +const codec = createBodyCodec(); + +/** A stored body of exactly `size` bytes. */ +function bodyOfSize(size: number, rawSize = size): EncodedBody { + return { bytes: new Uint8Array(size), encoding: "identity", rawSize }; +} + +function entry( + url: string, + body: EncodedBody, + identity = "identity-a", +): CacheEntry { + return { + url, + identity, + tier: "stable", + status: 200, + headers: {}, + body, + policy: {}, + createdAt: Date.now(), + }; +} + +/** A trace-like JSON body: many similar spans, as real traces have. */ +function traceBody(targetBytes: number): Uint8Array { + const spans: string[] = []; + let size = 0; + for (let i = 0; size < targetBytes; i++) { + const span = JSON.stringify({ + span_id: i.toString(16).padStart(16, "0"), + parent_span_id: (i >> 1).toString(16).padStart(16, "0"), + op: i % 3 === 0 ? "db.query" : "http.client", + description: `SELECT * FROM sentry_groupedmessage WHERE id = ${i}`, + start_timestamp: 1_700_000_000 + i / 1000, + duration: (i % 97) + 0.25, + }); + spans.push(span); + size += span.length + 1; + } + return encoder.encode(`[${spans.join(",")}]`); +} + +describe("createMemoryStore", () => { + beforeEach(() => { + vi.setSystemTime(Date.now()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns a stored entry whose body decodes to the original", async () => { + const store = createMemoryStore(); + const url = `${API}/organizations/acme/issues/?query=is%3Aunresolved`; + const raw = traceBody(4096); + const key = await buildCacheKey("GET", url, "identity-a"); + + await store.set(key, entry(url, await codec.encode(raw)), MINUTE); + const cached = await store.get(key); + + expect(cached?.url).toBe(url); + expect(cached && (await codec.decode(cached.body))).toEqual(raw); + expect(await store.get("some-other-key")).toBeUndefined(); + }); + + it("drops an entry once its TTL has passed", async () => { + const store = createMemoryStore(); + await store.set("k", entry(`${API}/issues/1/`, bodyOfSize(10)), MINUTE); + + vi.setSystemTime(Date.now() + MINUTE - 1); + expect(await store.get("k")).toBeDefined(); + + vi.setSystemTime(Date.now() + 1); + expect(await store.get("k")).toBeUndefined(); + expect(store.stats()).toEqual({ entries: 0, bytes: 0 }); + }); + + it("does not store an entry without a positive TTL", async () => { + const store = createMemoryStore(); + await store.set("zero", entry(`${API}/a/`, bodyOfSize(10)), 0); + await store.set("nan", entry(`${API}/b/`, bodyOfSize(10)), Number.NaN); + + expect(store.stats().entries).toBe(0); + }); + + it("evicts the least recently used entry when over budget", async () => { + const size = 100 + ENTRY_OVERHEAD_BYTES; + const store = createMemoryStore({ maxBytes: size * 3 }); + for (const key of ["a", "b", "c"]) { + await store.set(key, entry(`${API}/${key}/`, bodyOfSize(100)), MINUTE); + } + + // Reading `a` makes `b` the least recently used. + await store.get("a"); + await store.set("d", entry(`${API}/d/`, bodyOfSize(100)), MINUTE); + + expect(await store.get("b")).toBeUndefined(); + for (const key of ["a", "c", "d"]) { + expect(await store.get(key)).toBeDefined(); + } + expect(store.stats()).toEqual({ entries: 3, bytes: size * 3 }); + }); + + it("evicts as many entries as a large one needs", async () => { + const store = createMemoryStore({ maxBytes: 4 * ENTRY_OVERHEAD_BYTES }); + for (const key of ["a", "b", "c", "d"]) { + await store.set(key, entry(`${API}/${key}/`, bodyOfSize(0)), MINUTE); + } + + await store.set( + "big", + entry(`${API}/big/`, bodyOfSize(2 * ENTRY_OVERHEAD_BYTES)), + MINUTE, + ); + + expect(await store.get("a")).toBeUndefined(); + expect(await store.get("b")).toBeUndefined(); + expect(await store.get("c")).toBeUndefined(); + expect(await store.get("d")).toBeDefined(); + expect(await store.get("big")).toBeDefined(); + }); + + it("does not store a body over the per-entry cap", async () => { + const store = createMemoryStore({ maxEntryBytes: 1000 }); + await store.set("k", entry(`${API}/a/`, bodyOfSize(1000)), MINUTE); + expect(await store.get("k")).toBeDefined(); + + // An oversized replacement removes the older response too. + await store.set("k", entry(`${API}/a/`, bodyOfSize(1001)), MINUTE); + + expect(await store.get("k")).toBeUndefined(); + expect(store.stats()).toEqual({ entries: 0, bytes: 0 }); + }); + + it("does not store a body whose original size is over the ceiling", async () => { + const store = createMemoryStore({ maxRawBytes: 10_000 }); + await store.set("k", entry(`${API}/a/`, bodyOfSize(100, 10_001)), MINUTE); + + expect(await store.get("k")).toBeUndefined(); + }); + + it("does not store an entry larger than the whole budget", async () => { + const store = createMemoryStore({ maxBytes: 2048, maxEntryBytes: 4096 }); + await store.set("small", entry(`${API}/a/`, bodyOfSize(10)), MINUTE); + await store.set("huge", entry(`${API}/b/`, bodyOfSize(3000)), MINUTE); + + expect(await store.get("huge")).toBeUndefined(); + expect(await store.get("small")).toBeDefined(); + }); + + it("stays within its budget when many identities fill it", async () => { + const maxBytes = 256 * 1024; + const store = createMemoryStore({ maxBytes }); + let peak = 0; + let last = ""; + for (let user = 0; user < 500; user++) { + const identity = `user-${user}`; + for (let page = 0; page < 10; page++) { + const url = `${API}/organizations/acme/issues/?cursor=${page}`; + last = await buildCacheKey("GET", url, identity); + // Bodies from raw sub-256-byte responses up to 8 KB. + const body = bodyOfSize(((user * 31 + page * 7) % 64) * 128); + await store.set(last, entry(url, body, identity), MINUTE); + peak = Math.max(peak, store.stats().bytes); + } + } + + expect(peak).toBeLessThanOrEqual(maxBytes); + expect(store.stats().entries).toBeGreaterThan(0); + expect(await store.get(last)).toBeDefined(); + }); + + it("deletes only the given identity's entries under a prefix", async () => { + const store = createMemoryStore(); + const issues = `${API}/organizations/acme/issues/`; + const projects = `${API}/organizations/acme/projects/`; + await store.set("a-issues", entry(issues, bodyOfSize(10), "a"), MINUTE); + await store.set("a-projects", entry(projects, bodyOfSize(10), "a"), MINUTE); + await store.set("b-issues", entry(issues, bodyOfSize(10), "b"), MINUTE); + + await store.deleteMatching("a", issues); + + expect(await store.get("a-issues")).toBeUndefined(); + expect(await store.get("a-projects")).toBeDefined(); + expect(await store.get("b-issues")).toBeDefined(); + expect(store.stats().entries).toBe(2); + }); + + it("counts a replaced entry only once", async () => { + const store = createMemoryStore(); + await store.set("k", entry(`${API}/a/`, bodyOfSize(500)), MINUTE); + await store.set("k", entry(`${API}/a/`, bodyOfSize(200)), MINUTE); + + expect(store.stats()).toEqual({ + entries: 1, + bytes: 200 + ENTRY_OVERHEAD_BYTES, + }); + }); + + it.each([ + ["Uint8Array", new Uint8Array(1024 * 1024)], + // `Buffer#slice` shares memory, unlike `Uint8Array#slice`. + ["Buffer", Buffer.alloc(1024 * 1024)], + ])("does not keep the larger buffer behind a %s view", async (_, backing) => { + const store = createMemoryStore(); + const bytes = backing.subarray(100, 400); + await store.set( + "k", + entry(`${API}/a/`, { bytes, encoding: "identity", rawSize: 300 }), + MINUTE, + ); + + const cached = await store.get("k"); + + expect(cached?.body.bytes.buffer.byteLength).toBe(300); + }); + + it.each([ + { maxBytes: 0 }, + { maxEntryBytes: -1 }, + { maxRawBytes: Number.POSITIVE_INFINITY }, + ])("rejects a non-positive or infinite limit: %o", (options) => { + expect(() => createMemoryStore(options)).toThrow(RangeError); + }); +}); diff --git a/static/packages/api/test/api-client/response-cache/policy.property.test.ts b/static/packages/api/test/api-client/response-cache/policy.property.test.ts new file mode 100644 index 000000000000..da898cff5f73 --- /dev/null +++ b/static/packages/api/test/api-client/response-cache/policy.property.test.ts @@ -0,0 +1,270 @@ +/** + * Property-Based Tests for Response Cache + * + * Verifies properties of cache key generation, URL normalization, + * and URL classification that should hold for any valid input. + */ + +import { + array, + asyncProperty, + constantFrom, + assert as fcAssert, + property, + string, + tuple, +} from "fast-check"; +import { describe, expect, test } from "vitest"; +import { buildCacheKey, normalizeUrl } from "../../../src/api-client/response-cache/keys.ts"; +import { classifyUrl } from "../../../src/api-client/response-cache/tiers.ts"; + +const DEFAULT_NUM_RUNS = 50; + +/** Any non-empty identity; scoping across identities is in keys.test.ts. */ +const IDENTITY = "test-identity"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +/** Generate valid HTTP methods */ +const methodArb = constantFrom("GET", "POST", "PUT", "DELETE", "PATCH"); + +/** Generate simple path segments */ +const pathSegmentArb = string({ minLength: 1, maxLength: 20 }).filter((s) => + /^[a-zA-Z0-9_-]+$/.test(s), +); + +/** Generate URL-like strings with paths and query params */ +const sentryUrlArb = tuple( + constantFrom( + "https://us.sentry.io", + "https://de.sentry.io", + "https://sentry.io", + ), + array(pathSegmentArb, { minLength: 1, maxLength: 5 }), + array( + tuple( + string({ minLength: 1, maxLength: 10 }).filter((s) => + /^[a-zA-Z]+$/.test(s), + ), + string({ minLength: 1, maxLength: 20 }).filter((s) => + /^[a-zA-Z0-9]+$/.test(s), + ), + ), + { minLength: 0, maxLength: 4 }, + ), +).map(([base, paths, params]) => { + const pathStr = `/api/0/${paths.join("/")}`; + const query = + params.length > 0 + ? `?${params.map(([k, v]) => `${k}=${v}`).join("&")}` + : ""; + return `${base}${pathStr}${query}`; +}); + +// --------------------------------------------------------------------------- +// Tests: buildCacheKey +// --------------------------------------------------------------------------- + +describe("property: buildCacheKey", () => { + test("produces a 64-char hex string (SHA-256)", async () => { + await fcAssert( + asyncProperty(methodArb, sentryUrlArb, async (method, url) => { + expect(await buildCacheKey(method, url, IDENTITY)).toMatch( + /^[0-9a-f]{64}$/, + ); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + + test("is deterministic — same inputs produce same key", async () => { + await fcAssert( + asyncProperty(methodArb, sentryUrlArb, async (method, url) => { + expect(await buildCacheKey(method, url, IDENTITY)).toBe( + await buildCacheKey(method, url, IDENTITY), + ); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + + test("different methods produce different keys for same URL", async () => { + await fcAssert( + asyncProperty(sentryUrlArb, async (url) => { + expect(await buildCacheKey("GET", url, IDENTITY)).not.toBe( + await buildCacheKey("POST", url, IDENTITY), + ); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + + test("query param order does not affect the key", async () => { + await fcAssert( + asyncProperty( + constantFrom("https://us.sentry.io", "https://de.sentry.io"), + pathSegmentArb, + async (base, path) => { + const url1 = `${base}/api/0/${path}?a=1&b=2&c=3`; + const url2 = `${base}/api/0/${path}?c=3&a=1&b=2`; + expect(await buildCacheKey("GET", url1, IDENTITY)).toBe( + await buildCacheKey("GET", url2, IDENTITY), + ); + }, + ), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + + test("method comparison is case-insensitive", async () => { + await fcAssert( + asyncProperty(sentryUrlArb, async (url) => { + expect(await buildCacheKey("get", url, IDENTITY)).toBe( + await buildCacheKey("GET", url, IDENTITY), + ); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: normalizeUrl +// --------------------------------------------------------------------------- + +describe("property: normalizeUrl", () => { + test("sorts query parameters alphabetically", () => { + const normalized = normalizeUrl("GET", "https://sentry.io/api?z=1&a=2&m=3"); + expect(normalized).toBe("GET|https://sentry.io/api?a=2&m=3&z=1"); + }); + + test("uppercases the method", () => { + fcAssert( + property( + constantFrom("get", "post", "put", "delete"), + sentryUrlArb, + (method, url) => { + const normalized = normalizeUrl(method, url); + expect(normalized.startsWith(method.toUpperCase())).toBe(true); + }, + ), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + + test("produces pipe-separated method|url format", () => { + fcAssert( + property(methodArb, sentryUrlArb, (method, url) => { + const normalized = normalizeUrl(method, url); + expect(normalized).toContain("|"); + const [m] = normalized.split("|", 1); + expect(m).toBe(method.toUpperCase()); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: classifyUrl +// --------------------------------------------------------------------------- + +describe("property: classifyUrl", () => { + test("always returns a valid tier", () => { + fcAssert( + property(sentryUrlArb, (url) => { + const tier = classifyUrl(url); + expect(["stable", "volatile", "no-cache"]).toContain(tier); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + + test("organization event and trace-item details are stable", () => { + const urls = [ + "https://us.sentry.io/api/0/organizations/myorg/events/myproject:abc123/", + "https://sentry.io/api/0/organizations/org/events/42:deadbeef/?field=title", + "https://us.sentry.io/api/0/projects/org/proj/trace-items/a1b2c3d4e5f67890/?trace_id=abc&item_type=spans", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("stable"); + } + }); + + test("project event details are volatile", () => { + const urls = [ + "https://us.sentry.io/api/0/projects/myorg/myproject/events/abc123/", + "https://sentry.io/api/0/projects/org/proj/events/deadbeef/?full=true", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("volatile"); + } + }); + + test("issue events are volatile, including latest/oldest/recommended", () => { + for (const eventId of ["latest", "oldest", "recommended", "deadbeef"]) { + const url = `https://us.sentry.io/api/0/organizations/org/issues/123/events/${eventId}/`; + expect(classifyUrl(url)).toBe("volatile"); + } + }); + + test("full trace URLs with 32-char hex IDs are volatile", () => { + const traceId = "a".repeat(32); + const url = `https://us.sentry.io/api/0/organizations/myorg/trace/${traceId}/?limit=100`; + expect(classifyUrl(url)).toBe("volatile"); + }); + + test("issue URLs are volatile (lists and detail views)", () => { + const urls = [ + "https://us.sentry.io/api/0/projects/org/proj/issues/", + "https://us.sentry.io/api/0/projects/org/proj/issues/?query=is:unresolved", + "https://us.sentry.io/api/0/issues/12345/", + "https://sentry.io/api/0/issues/67890/?format=json", + "https://us.sentry.io/api/0/organizations/org/issues/12345/hashes/", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("volatile"); + } + }); + + test("dataset=logs URLs are volatile", () => { + const url = + "https://us.sentry.io/api/0/organizations/org/events/?dataset=logs&query=foo"; + expect(classifyUrl(url)).toBe("volatile"); + }); + + test("dataset=spans URLs are volatile", () => { + const url = + "https://us.sentry.io/api/0/organizations/org/events/?dataset=spans"; + expect(classifyUrl(url)).toBe("volatile"); + }); + + test("autofix URLs are no-cache", () => { + const urls = [ + "https://us.sentry.io/api/0/organizations/org/issues/123/autofix/", + "https://sentry.io/api/0/organizations/org/issues/456/autofix/?format=json", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("no-cache"); + } + }); + + test("root-cause URLs are no-cache", () => { + const url = + "https://us.sentry.io/api/0/organizations/org/issues/123/root-cause/"; + expect(classifyUrl(url)).toBe("no-cache"); + }); + + test("org/project/team list URLs default to stable", () => { + const urls = [ + "https://us.sentry.io/api/0/organizations/", + "https://us.sentry.io/api/0/organizations/myorg/projects/", + "https://us.sentry.io/api/0/organizations/myorg/teams/", + ]; + for (const url of urls) { + expect(classifyUrl(url)).toBe("stable"); + } + }); +}); diff --git a/static/packages/api/test/api-client/response-cache/policy.test.ts b/static/packages/api/test/api-client/response-cache/policy.test.ts new file mode 100644 index 000000000000..f6143e04fd97 --- /dev/null +++ b/static/packages/api/test/api-client/response-cache/policy.test.ts @@ -0,0 +1,339 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { createBodyCodec, waitFor } from "../support.ts"; +import { createMemoryStore } from "../../../src/api-client/response-cache/memory-store.ts"; +import { + type CacheLookup, + lookupResponse, + REVALIDATION_WINDOW_MS, + storeResponse, +} from "../../../src/api-client/response-cache/policy.ts"; +import type { CacheEntry, CacheStore } from "../../../src/api-client/response-cache/store.ts"; +import { FALLBACK_TTL_MS } from "../../../src/api-client/response-cache/tiers.ts"; + +const API = "https://us.sentry.io/api/0"; +const STABLE_URL = `${API}/organizations/acme/projects/`; +const VOLATILE_URL = `${API}/organizations/acme/issues/`; +const NO_CACHE_URL = `${API}/organizations/acme/issues/1/autofix/`; +const IDENTITY = "identity-a"; +const KEY = "key-a"; +const MINUTE = 60_000; +const BODY = { data: [{ id: 1, slug: "frontend" }] }; + +const codec = createBodyCodec(); + +function json( + body: unknown, + headers: Record = {}, + status = 200, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +function notModified(headers: Record = {}): Response { + return new Response(null, { status: 304, headers }); +} + +async function store( + cache: CacheStore, + response: Response, + url = STABLE_URL, +): Promise { + return await storeResponse({ + store: cache, + codec, + key: KEY, + identity: IDENTITY, + url, + requestHeaders: {}, + response, + }); +} + +async function lookup( + cache: CacheStore, + url = STABLE_URL, +): Promise { + return await lookupResponse({ + store: cache, + codec, + key: KEY, + url, + requestHeaders: {}, + }); +} + +/** + * `cache`, but keeping every entry for an hour, so the policy rather than + * the store's expiry decides freshness. + */ +function longLived(cache: CacheStore): CacheStore { + return { ...cache, set: (key, entry) => cache.set(key, entry, 60 * MINUTE) }; +} + +/** A memory store that records the TTL of every `set`. */ +function recordingStore(): CacheStore & { ttls: number[] } { + const inner = createMemoryStore(); + const ttls: number[] = []; + return { + ttls, + get: (key) => inner.get(key), + set: (key, entry, ttlMs) => { + ttls.push(ttlMs); + return inner.set(key, entry, ttlMs); + }, + deleteMatching: (identity, prefix) => + inner.deleteMatching(identity, prefix), + }; +} + +function expectStale( + result: CacheLookup | undefined, +): Extract { + if (result?.kind !== "stale") { + throw new Error(`expected a stale lookup, got ${result?.kind}`); + } + return result; +} + +beforeEach(() => { + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("storeResponse", () => { + it("stores a JSON response that a hit serves back", async () => { + const cache = createMemoryStore(); + const link = `<${STABLE_URL}?cursor=0:100:0>; rel="next"`; + const size = await store(cache, json(BODY, { link, "x-other": "dropped" })); + expect(size).toBeGreaterThan(0); + + const result = await lookup(cache); + expect(result?.kind).toBe("hit"); + if (result?.kind !== "hit") { + return; + } + expect(result.response.status).toBe(200); + expect(await result.response.json()).toEqual(BODY); + expect(result.response.headers.get("link")).toBe(link); + expect(result.createdAt).toBe(Date.now()); + expect(result.size).toBe(JSON.stringify(BODY).length); + + const entry = (await cache.get(KEY)) as CacheEntry; + expect(entry).toMatchObject({ + url: STABLE_URL, + identity: IDENTITY, + tier: "stable", + headers: { link }, + }); + }); + + it("never persists the authorization token, and still varies on it", async () => { + const cache = createMemoryStore(); + const token = "Bearer sntrys_secret-token"; + const withToken = (authorization: string) => ({ + store: cache, + codec, + key: KEY, + url: STABLE_URL, + requestHeaders: { authorization }, + }); + await storeResponse({ + ...withToken(token), + identity: IDENTITY, + response: json(BODY, { + "cache-control": "private, max-age=60", + vary: "Authorization", + }), + }); + + const entry = (await cache.get(KEY)) as CacheEntry; + expect(JSON.stringify(entry.policy)).not.toContain("secret-token"); + expect((await lookupResponse(withToken(token)))?.kind).toBe("hit"); + expect( + await lookupResponse(withToken("Bearer sntrys_other-token")), + ).toBeUndefined(); + }); + + it.each([ + ["Cache-Control: no-store", json(BODY, { "cache-control": "no-store" })], + ["a non-2xx status", json({ detail: "Not found" }, {}, 404)], + ])("does not store %s", async (_, response) => { + const cache = createMemoryStore(); + expect(await store(cache, response)).toBe(0); + expect(await cache.get(KEY)).toBeUndefined(); + }); + + it("does not store a no-cache tier URL", async () => { + const cache = createMemoryStore(); + expect(await store(cache, json(BODY), NO_CACHE_URL)).toBe(0); + expect(await cache.get(KEY)).toBeUndefined(); + }); + + it("throws for a body that is not JSON", async () => { + const cache = createMemoryStore(); + const response = new Response("", { + headers: { "content-type": "text/html" }, + }); + await expect(store(cache, response)).rejects.toThrow(SyntaxError); + expect(await cache.get(KEY)).toBeUndefined(); + }); + + it("keeps an entry for its tier's TTL without server directives", async () => { + const cache = recordingStore(); + await store(cache, json(BODY)); + await store(cache, json(BODY), VOLATILE_URL); + expect(cache.ttls).toEqual([ + FALLBACK_TTL_MS.stable, + FALLBACK_TTL_MS.volatile, + ]); + }); + + it("keeps an entry with an ETag for the revalidation window too", async () => { + const cache = recordingStore(); + await store(cache, json(BODY, { etag: '"v1"' })); + await store( + cache, + json(BODY, { etag: '"v1"', "cache-control": "private, max-age=600" }), + ); + expect(cache.ttls).toEqual([ + FALLBACK_TTL_MS.stable + REVALIDATION_WINDOW_MS, + 600_000 + REVALIDATION_WINDOW_MS, + ]); + }); +}); + +describe("lookupResponse freshness", () => { + it("misses when there is no entry", async () => { + expect(await lookup(createMemoryStore())).toBeUndefined(); + }); + + it("serves an entry without server directives for its tier's TTL", async () => { + const cache = createMemoryStore(); + await store(longLived(cache), json(BODY), VOLATILE_URL); + + vi.setSystemTime(Date.now() + FALLBACK_TTL_MS.volatile); + expect((await lookup(cache, VOLATILE_URL))?.kind).toBe("hit"); + + vi.setSystemTime(Date.now() + 1); + expect(await lookup(cache, VOLATILE_URL)).toBeUndefined(); + }); + + it("lets server max-age outlast the tier", async () => { + const cache = createMemoryStore(); + await store( + cache, + json(BODY, { "cache-control": "private, max-age=600" }), + VOLATILE_URL, + ); + vi.setSystemTime(Date.now() + 5 * MINUTE); + expect((await lookup(cache, VOLATILE_URL))?.kind).toBe("hit"); + }); + + it("treats max-age=0 as stale rather than falling back to the tier", async () => { + const cache = createMemoryStore(); + await store( + cache, + json(BODY, { "cache-control": "max-age=0", etag: '"v1"' }), + ); + expect((await lookup(cache))?.kind).toBe("stale"); + }); + + it("misses for a body this runtime cannot decode", async () => { + const cache = createMemoryStore(); + await store(cache, json(BODY)); + const entry = (await cache.get(KEY)) as CacheEntry; + await cache.set( + KEY, + { ...entry, body: { ...entry.body, dictId: "unknown" } }, + MINUTE, + ); + expect(await lookup(cache)).toBeUndefined(); + }); + + it("throws for a corrupt policy", async () => { + const cache = createMemoryStore(); + await store(cache, json(BODY)); + const entry = (await cache.get(KEY)) as CacheEntry; + await cache.set(KEY, { ...entry, policy: { v: 999 } }, MINUTE); + await expect(lookup(cache)).rejects.toThrow(); + }); +}); + +describe("lookupResponse revalidation", () => { + async function staleEntry(cache: CacheStore): Promise { + await store(cache, json(BODY, { etag: '"v1"' })); + vi.setSystemTime(Date.now() + FALLBACK_TTL_MS.stable + MINUTE); + } + + it("misses for a stale entry without an ETag", async () => { + const cache = createMemoryStore(); + await store(longLived(cache), json(BODY)); + vi.setSystemTime(Date.now() + FALLBACK_TTL_MS.stable + MINUTE); + expect(await lookup(cache)).toBeUndefined(); + }); + + it("sends If-None-Match and serves the cached body on a matching 304", async () => { + const cache = createMemoryStore(); + await staleEntry(cache); + + const stale = expectStale(await lookup(cache)); + expect(stale.conditionalHeaders).toEqual({ "if-none-match": '"v1"' }); + + const response = await stale.revalidate(notModified({ etag: '"v1"' })); + expect(response?.status).toBe(200); + expect(await response?.json()).toEqual(BODY); + + // The renewed entry is fresh again. + await waitFor(async () => { + expect((await lookup(cache))?.kind).toBe("hit"); + }); + }); + + it.each([ + ["a 200", json({ data: [] }, { etag: '"v2"' })], + ["a 304 for another ETag", notModified({ etag: '"v2"' })], + ])("returns undefined for %s", async (_, response) => { + const cache = createMemoryStore(); + await staleEntry(cache); + const stale = expectStale(await lookup(cache)); + expect(await stale.revalidate(response)).toBeUndefined(); + }); + + it("reports a failed renewal and still serves the body", async () => { + const cache = createMemoryStore(); + await staleEntry(cache); + const failure = new Error("disk full"); + const failing: CacheStore = { + ...cache, + set: () => Promise.reject(failure), + }; + const onRenewError = vi.fn(); + + const stale = expectStale( + await lookupResponse({ + store: failing, + codec, + key: KEY, + url: STABLE_URL, + requestHeaders: {}, + onRenewError, + }), + ); + const response = await stale.revalidate(notModified({ etag: '"v1"' })); + expect(await response?.json()).toEqual(BODY); + await waitFor(() => expect(onRenewError).toHaveBeenCalledWith(failure)); + }); +}); diff --git a/static/packages/api/test/api-client/response-cache/tiers.test.ts b/static/packages/api/test/api-client/response-cache/tiers.test.ts new file mode 100644 index 000000000000..7e5a0af30a52 --- /dev/null +++ b/static/packages/api/test/api-client/response-cache/tiers.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { classifyUrl, FALLBACK_TTL_MS, type TtlTier } from "../../../src/api-client/response-cache/tiers.ts"; + +const API = "https://us.sentry.io/api/0"; +const TRACE_ID = "a".repeat(32); + +function ttlFor(url: string): number { + return FALLBACK_TTL_MS[classifyUrl(url)]; +} + +describe("classifyUrl", () => { + it("reuses organization event lookups for up to 5 minutes", () => { + expect(classifyUrl(`${API}/organizations/acme/events/web:deadbeef/`)).toBe( + "stable", + ); + expect(ttlFor(`${API}/organizations/acme/events/web:deadbeef/`)).toBe( + 5 * 60 * 1000, + ); + }); + + it("reuses issue event lookups for at most 60 seconds", () => { + for (const event of ["latest", "oldest", "recommended", "deadbeef"]) { + const url = `${API}/organizations/acme/issues/123/events/${event}/`; + expect(classifyUrl(url)).toBe("volatile"); + expect(ttlFor(url)).toBeLessThanOrEqual(60 * 1000); + } + }); + + it("keeps issue events volatile when adjacent events are collapsed", () => { + expect( + classifyUrl( + `${API}/organizations/acme/issues/123/events/latest/?collapse=adjacentEvents`, + ), + ).toBe("volatile"); + }); + + it("keeps project events volatile when adjacent events are collapsed", () => { + expect( + classifyUrl( + `${API}/projects/acme/backend/events/deadbeef/?collapse=adjacentEvents`, + ), + ).toBe("volatile"); + }); + + it("reuses full traces for at most 60 seconds, since spans can still arrive", () => { + const url = `${API}/organizations/acme/trace/${TRACE_ID}/?limit=100`; + expect(classifyUrl(url)).toBe("volatile"); + expect(ttlFor(url)).toBeLessThanOrEqual(60 * 1000); + }); + + it("treats trace log queries as volatile", () => { + expect( + classifyUrl(`${API}/organizations/acme/trace-logs/?traceId=${TRACE_ID}`), + ).toBe("volatile"); + }); + + it("never serves polling endpoints from cache", () => { + for (const url of [ + `${API}/organizations/acme/issues/123/autofix/`, + `${API}/organizations/acme/issues/123/root-cause/`, + ]) { + expect(classifyUrl(url)).toBe("no-cache"); + expect(ttlFor(url)).toBe(0); + } + }); +}); + +describe("FALLBACK_TTL_MS", () => { + it("has no tier that reuses a response for more than 5 minutes", () => { + for (const tier of Object.keys(FALLBACK_TTL_MS) as TtlTier[]) { + expect(FALLBACK_TTL_MS[tier]).toBeLessThanOrEqual(5 * 60 * 1000); + } + }); +}); diff --git a/static/packages/api/test/api-client/response.test.ts b/static/packages/api/test/api-client/response.test.ts new file mode 100644 index 000000000000..d604ae3b78c5 --- /dev/null +++ b/static/packages/api/test/api-client/response.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { readErrorResponse, readJsonResponse } from "../../src/api-client/response.ts"; + +function jsonResponse( + body: unknown, + init: { status?: number; headers?: Record } = {}, +): Response { + return new Response(JSON.stringify(body), { + status: init.status, + headers: { "content-type": "application/json", ...init.headers }, + }); +} + +describe("readErrorResponse", () => { + it("reads the server's detail message", async () => { + const body = { detail: "Project not found", code: "missing" }; + const result = await readErrorResponse(jsonResponse(body, { status: 404 })); + expect(result).toEqual({ + text: JSON.stringify(body), + json: body, + detail: "Project not found", + isHtml: false, + retryAfterMs: undefined, + }); + }); + + it("leaves detail unset when it is not a string", async () => { + const result = await readErrorResponse( + jsonResponse({ detail: { code: "x" } }, { status: 400 }), + ); + expect(result.json).toEqual({ detail: { code: "x" } }); + expect(result.detail).toBeUndefined(); + }); + + it("flags an HTML error page", async () => { + const result = await readErrorResponse( + new Response("Bad gateway", { status: 502 }), + ); + expect(result.isHtml).toBe(true); + expect(result.json).toBeUndefined(); + expect(result.parseError).toBeTypeOf("string"); + }); + + it("keeps a plain-text body without flagging it as HTML", async () => { + const result = await readErrorResponse( + new Response("upstream timed out", { status: 504 }), + ); + expect(result).toMatchObject({ + text: "upstream timed out", + isHtml: false, + }); + expect(result.parseError).toBeTypeOf("string"); + }); + + it("reads the rate-limit wait from Retry-After", async () => { + const result = await readErrorResponse( + jsonResponse( + { detail: "Slow down" }, + { status: 429, headers: { "retry-after": "3" } }, + ), + ); + expect(result.retryAfterMs).toBe(3000); + }); + + it("returns no text when the body cannot be read", async () => { + const response = new Response("ignored", { status: 500 }); + await response.text(); + expect(await readErrorResponse(response)).toEqual({ + text: undefined, + isHtml: false, + retryAfterMs: undefined, + }); + }); +}); + +describe("readJsonResponse", () => { + it("parses a JSON body", async () => { + expect(await readJsonResponse(jsonResponse({ id: 1 }))).toEqual({ id: 1 }); + }); + + it("rejects an HTML body", async () => { + await expect( + readJsonResponse( + new Response("", { + status: 200, + statusText: "OK", + headers: { "content-type": "text/html" }, + }), + ), + ).rejects.toThrow( + "Expected JSON response but received HTML (200 OK). This may indicate you're not authenticated, the URL is incorrect, or there's a server issue.", + ); + }); + + it("rejects another content type", async () => { + await expect( + readJsonResponse( + new Response("id,1", { + status: 200, + statusText: "OK", + headers: { "content-type": "text/csv" }, + }), + ), + ).rejects.toThrow("Expected JSON response but received text/csv (200 OK)"); + }); + + it("rejects malformed JSON", async () => { + await expect( + readJsonResponse( + new Response("{", { headers: { "content-type": "application/json" } }), + ), + ).rejects.toThrow(/^Failed to parse JSON response: /); + }); +}); diff --git a/static/packages/api/test/api-client/retry.test.ts b/static/packages/api/test/api-client/retry.test.ts new file mode 100644 index 000000000000..d2dd8980c353 --- /dev/null +++ b/static/packages/api/test/api-client/retry.test.ts @@ -0,0 +1,310 @@ +import { setTimeout as delay } from "node:timers/promises"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + isIdempotentRequest, + nextRetryDelayMs, + parseRetryDelayMs, + type RetryPolicy, + sleep, +} from "../../src/api-client/retry.ts"; + +const NOW = Date.UTC(2026, 0, 1, 12, 0, 0, 300); +const NOW_SECONDS = Math.floor(NOW / 1000); + +const policy: RetryPolicy = { + maxRetries: 2, + baseDelayMs: 250, + maxDelayMs: 2000, + maxTotalDelayMs: 3000, +}; + +/** Headers from a fixed-window 429, as Sentry's rate-limit middleware sends them. */ +function windowLimited( + resetSeconds: number, + extra: Record = {}, +) { + return new Headers({ + "X-Sentry-Rate-Limit-Remaining": "0", + "X-Sentry-Rate-Limit-Limit": "40", + "X-Sentry-Rate-Limit-Reset": String(resetSeconds), + ...extra, + }); +} + +describe("parseRetryDelayMs", () => { + it("waits until the Sentry window reset", () => { + expect(parseRetryDelayMs(429, windowLimited(NOW_SECONDS + 1), NOW)).toBe( + 700, + ); + }); + + it("measures the reset against the response Date when the local clock is skewed", () => { + const serverNow = NOW + 10 * 60 * 1000; + const headers = windowLimited(Math.floor(serverNow / 1000) + 1, { + Date: new Date(serverNow).toUTCString(), + }); + + expect(parseRetryDelayMs(429, headers, NOW)).toBe(1000); + }); + + it("bounds the reset by the response Date when the local clock lags slightly", () => { + // Local clock 1.5s behind: alone it would put a 1s window 2.2s away. + const headers = windowLimited(NOW_SECONDS + 1, { + Date: new Date(NOW).toUTCString(), + }); + const lagging = NOW - 1500; + + expect(parseRetryDelayMs(429, headers, lagging)).toBe(1000); + expect( + nextRetryDelayMs({ + policy, + method: "GET", + status: 429, + serverDelayMs: parseRetryDelayMs(429, headers, lagging), + attempt: 0, + waitedMs: 0, + random: () => 0, + }), + ).toBe(1000); + }); + + it("bounds the reset by the response Date when the local clock runs ahead", () => { + const headers = windowLimited(NOW_SECONDS + 1, { + Date: new Date(NOW).toUTCString(), + }); + + // 2.5s ahead would put the reset 1.8s in the past; the Date puts it 0-1s + // away, so the estimate is clamped to the lower bound. + expect(parseRetryDelayMs(429, headers, NOW + 2500)).toBe(0); + }); + + it("keeps the local estimate when it agrees with the response Date", () => { + const headers = windowLimited(NOW_SECONDS + 1, { + Date: new Date(NOW).toUTCString(), + }); + + expect(parseRetryDelayMs(429, headers, NOW)).toBe(700); + }); + + it("treats a reset already in the past as no wait", () => { + expect(parseRetryDelayMs(429, windowLimited(NOW_SECONDS - 5), NOW)).toBe(0); + }); + + it("gives no delay for a concurrency-limit 429", () => { + const headers = new Headers({ + "X-Sentry-Rate-Limit-Remaining": "12", + "X-Sentry-Rate-Limit-Reset": String(NOW_SECONDS + 1), + "X-Sentry-Rate-Limit-ConcurrentRemaining": "0", + "X-Sentry-Rate-Limit-ConcurrentLimit": "25", + }); + + expect(parseRetryDelayMs(429, headers, NOW)).toBeUndefined(); + }); + + it("ignores the window reset on other statuses", () => { + expect( + parseRetryDelayMs(503, windowLimited(NOW_SECONDS + 1), NOW), + ).toBeUndefined(); + }); + + it("prefers Retry-After in seconds", () => { + const headers = windowLimited(NOW_SECONDS + 1, { "Retry-After": "3" }); + + expect(parseRetryDelayMs(429, headers, NOW)).toBe(3000); + }); + + it("accepts Retry-After as an HTTP-date on any status", () => { + const headers = new Headers({ + "Retry-After": new Date(NOW + 5300).toUTCString(), + }); + + // HTTP-dates drop milliseconds, so this is 12:00:05.000. + expect(parseRetryDelayMs(503, headers, NOW)).toBe(4700); + }); + + it("returns undefined for missing or malformed headers", () => { + expect(parseRetryDelayMs(429, new Headers(), NOW)).toBeUndefined(); + expect( + parseRetryDelayMs(429, new Headers({ "Retry-After": "soon" }), NOW), + ).toBeUndefined(); + expect( + parseRetryDelayMs( + 429, + new Headers({ + "X-Sentry-Rate-Limit-Remaining": "0", + "X-Sentry-Rate-Limit-Reset": "never", + }), + NOW, + ), + ).toBeUndefined(); + }); +}); + +describe("nextRetryDelayMs", () => { + const base = { + policy, + method: "GET", + status: 429, + serverDelayMs: undefined, + attempt: 0, + waitedMs: 0, + random: () => 0, + }; + + it("waits for the server delay plus jitter of at most half the base step", () => { + expect(nextRetryDelayMs({ ...base, serverDelayMs: 700 })).toBe(700); + expect( + nextRetryDelayMs({ ...base, serverDelayMs: 700, random: () => 1 }), + ).toBe(825); + }); + + it("falls back to equal-jitter exponential backoff", () => { + expect(nextRetryDelayMs({ ...base, attempt: 0 })).toBe(125); + expect(nextRetryDelayMs({ ...base, attempt: 0, random: () => 1 })).toBe( + 250, + ); + expect(nextRetryDelayMs({ ...base, attempt: 1, random: () => 1 })).toBe( + 500, + ); + }); + + it("caps the backoff step at maxDelayMs", () => { + expect( + nextRetryDelayMs({ + ...base, + policy: { ...policy, maxRetries: 10, maxTotalDelayMs: 60_000 }, + attempt: 6, + random: () => 1, + }), + ).toBe(2000); + }); + + it("stops when the server delay exceeds the per-wait cap", () => { + expect( + nextRetryDelayMs({ ...base, serverDelayMs: 30_000 }), + ).toBeUndefined(); + }); + + it("stops when the wait would exceed the total budget", () => { + expect( + nextRetryDelayMs({ + ...base, + attempt: 1, + serverDelayMs: 1000, + waitedMs: 2500, + }), + ).toBeUndefined(); + }); + + it("stops after maxRetries", () => { + expect(nextRetryDelayMs({ ...base, attempt: 2 })).toBeUndefined(); + }); + + it("retries reads in any case", () => { + for (const method of ["GET", "head", "OPTIONS"]) { + expect(nextRetryDelayMs({ ...base, method })).toBe(125); + } + }); + + it("retries a rate-limited write, since Sentry rejects it before the endpoint runs", () => { + for (const method of ["POST", "PUT", "DELETE"]) { + expect(nextRetryDelayMs({ ...base, method, status: 429 })).toBe(125); + } + }); + + it("never retries a write on a 5xx without the idempotent opt-in", () => { + for (const method of ["POST", "PUT", "DELETE"]) { + for (const status of [500, 502, 503, 504]) { + expect(nextRetryDelayMs({ ...base, method, status })).toBeUndefined(); + } + } + expect( + nextRetryDelayMs({ + ...base, + method: "POST", + status: 503, + idempotent: true, + }), + ).toBe(125); + }); + + it("retries a GET on a transient 5xx", () => { + for (const status of [500, 502, 503, 504]) { + expect(nextRetryDelayMs({ ...base, status })).toBe(125); + } + }); + + it("retries a network error or timeout only for idempotent requests", () => { + expect(nextRetryDelayMs({ ...base, status: undefined })).toBe(125); + expect( + nextRetryDelayMs({ ...base, method: "POST", status: undefined }), + ).toBeUndefined(); + expect( + nextRetryDelayMs({ + ...base, + method: "POST", + status: undefined, + idempotent: true, + }), + ).toBe(125); + }); + + it("does not retry deterministic 5xx codes or other 4xx", () => { + for (const status of [400, 404, 501, 505]) { + expect(nextRetryDelayMs({ ...base, status })).toBeUndefined(); + } + }); +}); + +describe("isIdempotentRequest", () => { + it("replays only GET, HEAD, and OPTIONS by default", () => { + for (const method of ["GET", "head", "OPTIONS"]) { + expect(isIdempotentRequest(method)).toBe(true); + } + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + expect(isIdempotentRequest(method)).toBe(false); + } + }); + + it("replays any method when the caller opts in", () => { + expect(isIdempotentRequest("POST", true)).toBe(true); + }); +}); + +describe("sleep", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("resolves after the delay", async () => { + const started = performance.now(); + const resolved = vi.fn(); + const promise = sleep(30).then(resolved); + + await delay(0); + expect(resolved).not.toHaveBeenCalled(); + await promise; + expect(resolved).toHaveBeenCalled(); + // Timers may fire up to a millisecond early. + expect(performance.now() - started).toBeGreaterThanOrEqual(29); + }); + + it("rejects with the abort reason as soon as the signal aborts", async () => { + const clear = vi.spyOn(globalThis, "clearTimeout"); + const controller = new AbortController(); + const promise = sleep(60_000, controller.signal); + const reason = new Error("cancelled"); + + controller.abort(reason); + + await expect(promise).rejects.toBe(reason); + expect(clear).toHaveBeenCalledTimes(1); + }); + + it("rejects at once when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(new Error("gone")); + + await expect(sleep(1000, controller.signal)).rejects.toThrow("gone"); + }); +}); diff --git a/static/packages/api/test/api-client/support.ts b/static/packages/api/test/api-client/support.ts new file mode 100644 index 000000000000..a0e82ae955f8 --- /dev/null +++ b/static/packages/api/test/api-client/support.ts @@ -0,0 +1,90 @@ +/** + * Test helpers: a core whose transport is scripted, for endpoint wrappers, + * and a pass-through body codec for the response cache. The compressing codec + * needs `node:zlib`, so hosts keep it; the cache only needs a round trip. + */ + +import { vi } from "vitest"; +import { type ApiTarget, createApiClientCore } from "../../src/api-client/core.ts"; +import type { + BodyCodec, + EncodedBody, +} from "../../src/api-client/response-cache/body.ts"; + +export type { EncodedBody }; + +export const ORIGIN = "https://us.sentry.io"; + +export function jsonResponse( + body: unknown, + headers: Record = {}, +) { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json", ...headers }, + }); +} + +/** A `Link` header with a next page at `cursor` for the API `path`. */ +export function nextLink(path: string, cursor: string) { + return { + link: `<${ORIGIN}/api/0${path}?cursor=${cursor}>; rel="next"; results="true"; cursor="${cursor}"`, + }; +} + +/** A target whose transport answers each request with the next response. */ +export function scriptedTarget(...responses: Response[]) { + const request = vi.fn(async (_url: string, _init: RequestInit) => { + const response = responses.shift(); + if (!response) { + throw new Error("unexpected request"); + } + return response; + }); + const core = createApiClientCore({ + transport: { request }, + headers: () => ({}), + logger: { warn: vi.fn() }, + }); + const apiTarget: ApiTarget = { core, origin: ORIGIN }; + return { apiTarget, request }; +} + +/** A codec that stores bodies as they are. */ +export function createBodyCodec(): BodyCodec { + return { + async encode(raw) { + return { bytes: raw.slice(), encoding: "identity", rawSize: raw.length }; + }, + async decode(body) { + // Like the compressing codec, a dictionary this runtime lacks is a miss. + if (body.dictId !== undefined) { + return undefined; + } + if (body.encoding !== "identity" || body.bytes.length !== body.rawSize) { + throw new Error("Unexpected cached body"); + } + return body.bytes.slice(); + }, + }; +} + +/** + * Retry `assertion` until it passes or `timeoutMs` elapses, for effects the + * code under test starts without awaiting, such as a fire-and-forget cache write. + */ +export async function waitFor( + assertion: () => T | Promise, + timeoutMs = 1000, +): Promise { + const deadline = performance.now() + timeoutMs; + for (;;) { + try { + return await assertion(); + } catch (error) { + if (performance.now() > deadline) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } +} diff --git a/static/packages/api/test/api-client/time-params.test.ts b/static/packages/api/test/api-client/time-params.test.ts new file mode 100644 index 000000000000..20e07e65e542 --- /dev/null +++ b/static/packages/api/test/api-client/time-params.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiValidationError } from "../../src/api-client/errors.ts"; +import { + applyStatsMixinTimeParams, + applyTimeParams, + defaultStatsPeriod, + normalizeStatsPeriod, + parseStatsPeriod, +} from "../../src/api-client/time-params.ts"; + +function applied(apply: (query: URLSearchParams) => void): string { + const query = new URLSearchParams(); + apply(query); + return query.toString(); +} + +describe("normalizeStatsPeriod", () => { + it.each([ + [" 24h ", "24h"], + [" ", undefined], + [undefined, undefined], + ])("normalizes %j to %j", (input, output) => { + expect(normalizeStatsPeriod(input)).toBe(output); + }); +}); + +describe("defaultStatsPeriod", () => { + it.each([ + [{}, "24h"], + [{ statsPeriod: " 7d " }, "7d"], + [{ start: "2026-01-01", end: "2026-01-02" }, undefined], + ])("defaults %o to %j", (range, period) => { + expect(defaultStatsPeriod(range)).toBe(period); + }); +}); + +describe("parseStatsPeriod", () => { + it("splits the amount and unit", () => { + expect(parseStatsPeriod("14d")).toEqual({ amount: 14, unit: "d" }); + }); + + it("rejects other formats", () => { + expect(() => parseStatsPeriod("1 day")).toThrow(ApiValidationError); + }); +}); + +describe("applyTimeParams", () => { + it.each([ + [["24h"], "statsPeriod=24h"], + [ + [undefined, "2026-01-01", "2026-01-02"], + "start=2026-01-01&end=2026-01-02", + ], + [[], ""], + ] as const)("applies %j as %s", (args, query) => { + expect(applied((q) => applyTimeParams(q, ...args))).toBe(query); + }); + + it.each([ + ["24h", "2026-01-01", "2026-01-02"], + [undefined, "2026-01-01", undefined], + ])("rejects statsPeriod=%s start=%s end=%s", (statsPeriod, start, end) => { + expect(() => + applyTimeParams(new URLSearchParams(), statsPeriod, start, end), + ).toThrow(ApiValidationError); + }); +}); + +describe("applyStatsMixinTimeParams", () => { + afterEach(() => vi.useRealTimers()); + + it("turns a relative period into seconds before now", () => { + vi.setSystemTime(10_000_000); + + expect( + applied((q) => + applyStatsMixinTimeParams(q, "1h", undefined, undefined, 60), + ), + ).toBe("since=6400&until=10000&resolution=60s"); + }); + + it("turns an absolute range into seconds", () => { + expect( + applied((q) => + applyStatsMixinTimeParams( + q, + undefined, + "1970-01-01T00:00:10Z", + "1970-01-01T00:00:20Z", + ), + ), + ).toBe("since=10&until=20"); + }); + + it("rejects an unparseable date", () => { + expect(() => + applyStatsMixinTimeParams(new URLSearchParams(), undefined, "x", "y"), + ).toThrow(ApiValidationError); + }); +}); diff --git a/static/packages/api/test/api-client/transport.test.ts b/static/packages/api/test/api-client/transport.test.ts new file mode 100644 index 000000000000..d47bae88ec5b --- /dev/null +++ b/static/packages/api/test/api-client/transport.test.ts @@ -0,0 +1,792 @@ +import { setTimeout as delay } from "node:timers/promises"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RetryPolicy } from "../../src/api-client/retry.ts"; +import { + ApiTimeoutError, + type ApiTransportOptions, + createApiTransport, + getValidatedResponseSource, + type TransportCache, + type TransportCredential, +} from "../../src/api-client/transport.ts"; +import { + buildCacheKey, + createMemoryStore, + lookupResponse, + storeResponse, +} from "../../src/api-client/response-cache/index.ts"; +import { createBodyCodec, waitFor } from "./support.ts"; + +const URL_BASE = "https://us.sentry.io/api/0"; +const ISSUES_URL = `${URL_BASE}/organizations/acme/issues/`; + +const policy: RetryPolicy = { + maxRetries: 2, + baseDelayMs: 10, + maxDelayMs: 2000, + maxTotalDelayMs: 3000, +}; + +const credential: TransportCredential = { token: "token-a", identity: "id-a" }; + +type Sent = { method: string; url: string; headers: Headers; body: string }; + +/** A fetch that answers from `responses` in order and records each request. */ +function scriptedFetch( + responses: Array Response | Promise)>, +) { + const sent: Sent[] = []; + const fetch = vi.fn( + async (input: Request | string | URL, init?: RequestInit) => { + const request = new Request(input, init); + sent.push({ + method: request.method, + url: request.url, + headers: request.headers, + body: await request.text(), + }); + const next = responses[Math.min(sent.length, responses.length) - 1]; + if (next === undefined) { + throw new Error("unexpected request"); + } + if (next instanceof Error) { + throw next; + } + if (typeof next === "function") { + return await next(); + } + // A fresh body per call: cancelling a clone's tee branch would wait for + // the template's branch, which nothing reads. + return new Response( + next.body && (await next.clone().arrayBuffer()), + next, + ); + }, + ); + return { fetch, sent }; +} + +function transport( + fetch: ApiTransportOptions["fetch"], + overrides: Partial> = {}, +) { + return createApiTransport({ + credentials: { current: () => credential }, + retry: policy, + timeoutMs: 1000, + fetch, + ...overrides, + }); +} + +function windowLimited(resetInMs: number, now = Date.now()) { + return new Response('{"detail":"slow down"}', { + status: 429, + headers: { + "X-Sentry-Rate-Limit-Remaining": "0", + "X-Sentry-Rate-Limit-Reset": String(Math.ceil((now + resetInMs) / 1000)), + }, + }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("credential selection", () => { + it("passes the request URL and its org scope to the credential provider", async () => { + const { fetch } = scriptedFetch([Response.json([])]); + const current = vi.fn(() => credential); + + await transport(fetch, { credentials: { current } }).request( + `${URL_BASE}/projects/acme/web/events/`, + ); + + expect(current).toHaveBeenCalledWith({ + url: `${URL_BASE}/projects/acme/web/events/`, + scope: { organization: "acme", project: "web" }, + }); + }); + + it("omits the scope for an unscoped path", async () => { + const { fetch } = scriptedFetch([Response.json({})]); + const current = vi.fn(() => credential); + + await transport(fetch, { credentials: { current } }).request( + `${URL_BASE}/users/me/`, + ); + + expect(current).toHaveBeenCalledWith({ url: `${URL_BASE}/users/me/` }); + }); + + it("sends the credential the provider picked for the org", async () => { + const { fetch, sent } = scriptedFetch([Response.json([])]); + const other: TransportCredential = { token: "token-b", identity: "id-b" }; + + await transport(fetch, { + credentials: { + current: ({ scope }) => + scope?.organization === "acme" ? other : credential, + }, + }).request(ISSUES_URL); + + expect(sent[0]?.headers.get("Authorization")).toBe("Bearer token-b"); + }); +}); + +describe("retried statuses", () => { + it("returns a deterministic 5xx without retrying", async () => { + const { fetch, sent } = scriptedFetch([new Response("", { status: 501 })]); + + const response = await transport(fetch).request(ISSUES_URL); + + expect(response.status).toBe(501); + expect(sent).toHaveLength(1); + }); + + it("retries a 503 GET with backoff", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 503 }), + Response.json({ ok: true }), + ]); + + const response = await transport(fetch).request(ISSUES_URL); + + expect(response.status).toBe(200); + expect(sent).toHaveLength(2); + }); +}); + +describe("unused bodies", () => { + it("retries without waiting for a teed body to cancel", async () => { + // Interceptors such as MSW hand out one branch of a teed stream; cancelling + // it settles only once the other branch, which nothing reads, is drained. + const [branch] = new Response("busy").body?.tee() ?? []; + const { fetch, sent } = scriptedFetch([ + () => new Response(branch, { status: 503 }), + Response.json({ ok: true }), + ]); + + const response = await transport(fetch).request(ISSUES_URL); + + expect(response.status).toBe(200); + expect(sent).toHaveLength(2); + }); +}); + +describe("writes", () => { + it("retries a rate-limited POST once the window resets", async () => { + const { fetch, sent } = scriptedFetch([ + () => windowLimited(0), + Response.json({ ok: true }), + ]); + + const response = await transport(fetch).request(ISSUES_URL, { + method: "POST", + body: '{"a":1}', + }); + + expect(response.status).toBe(200); + expect(sent.map((request) => request.body)).toEqual(['{"a":1}', '{"a":1}']); + }); + + it.each([ + ["a 503", new Response("", { status: 503 })], + ["a network error", new TypeError("fetch failed")], + ])("does not replay a POST after %s", async (_, failure) => { + const { fetch, sent } = scriptedFetch([failure]); + + const result = await transport(fetch) + .request(ISSUES_URL, { method: "POST", body: "{}" }) + .catch((error: unknown) => error); + + expect(result instanceof Response ? result.status : result).toEqual( + failure instanceof Response ? 503 : failure, + ); + expect(sent).toHaveLength(1); + }); + + it("retries a POST marked idempotent like a GET", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 503 }), + Response.json({ ok: true }), + ]); + + const response = await transport(fetch).request( + ISSUES_URL, + { method: "POST", body: "chunk" }, + { idempotent: true }, + ); + + expect(response.status).toBe(200); + expect(sent).toHaveLength(2); + }); + + it("retries a write the host marks idempotent", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 502 }), + new Response(null, { status: 204 }), + ]); + + const response = await transport(fetch, { + isIdempotent: (method) => method === "PUT", + }).request(ISSUES_URL, { method: "PUT", body: "{}" }); + + expect(response.status).toBe(204); + expect(sent).toHaveLength(2); + }); + + it("replays a stream body on each attempt", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 503 }), + Response.json({ ok: true }), + ]); + + await transport(fetch).request( + ISSUES_URL, + { + method: "POST", + body: new Response("streamed").body, + duplex: "half", + } as RequestInit, + { idempotent: true }, + ); + + expect(sent.map((request) => request.body)).toEqual([ + "streamed", + "streamed", + ]); + }); +}); + +describe("credential refresh", () => { + it("replays a POST once with the refreshed token", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 401 }), + Response.json({ ok: true }), + ]); + const refresh = vi.fn(async (current: TransportCredential) => ({ + ...current, + token: "token-b", + })); + + const response = await transport(fetch, { + credentials: { current: () => credential, refresh }, + refreshedRequestHeader: "x-refreshed", + }).request(ISSUES_URL, { method: "POST", body: "{}" }); + + expect(response.status).toBe(200); + expect(sent.map((request) => request.headers.get("authorization"))).toEqual( + ["Bearer token-a", "Bearer token-b"], + ); + expect(sent[1]?.headers.get("x-refreshed")).toBe("1"); + }); + + it("returns a second 401 without refreshing again", async () => { + const { fetch, sent } = scriptedFetch([new Response("", { status: 401 })]); + const refresh = vi.fn(async (current: TransportCredential) => current); + + const response = await transport(fetch, { + credentials: { current: () => credential, refresh }, + }).request(ISSUES_URL); + + expect(response.status).toBe(401); + expect(refresh).toHaveBeenCalledTimes(1); + expect(sent).toHaveLength(2); + }); + + it("refreshes after a 401 with no retry budget", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 401 }), + Response.json({ ok: true }), + ]); + const refresh = vi.fn(async (current: TransportCredential) => ({ + ...current, + token: "token-b", + })); + + const response = await transport(fetch, { + credentials: { current: () => credential, refresh }, + retry: { ...policy, maxRetries: 0 }, + }).request(ISSUES_URL); + + expect(response.status).toBe(200); + expect(sent).toHaveLength(2); + }); + + it("does not spend a retry on the refresh replay", async () => { + const { fetch, sent } = scriptedFetch([ + new Response("", { status: 401 }), + new Response("", { status: 503 }), + Response.json({ ok: true }), + ]); + const refresh = vi.fn(async (current: TransportCredential) => ({ + ...current, + token: "token-b", + })); + + const response = await transport(fetch, { + credentials: { current: () => credential, refresh }, + retry: { ...policy, maxRetries: 1 }, + }).request(ISSUES_URL); + + expect(response.status).toBe(200); + expect(sent).toHaveLength(3); + }); + + it("refreshes ahead of expiry before the first attempt", async () => { + const { fetch, sent } = scriptedFetch([Response.json({})]); + + await transport(fetch, { + credentials: { + current: () => credential, + prepare: async (current) => ({ ...current, token: "fresh" }), + }, + }).request(ISSUES_URL); + + expect(sent[0]?.headers.get("authorization")).toBe("Bearer fresh"); + }); +}); + +describe("rate-limit waits", () => { + it("waits for a reset within the cap", async () => { + // The reset header has whole-second resolution, so start on a second. + vi.setSystemTime(Date.UTC(2026, 0, 1, 12, 0, 0, 0)); + const { fetch, sent } = scriptedFetch([ + () => windowLimited(1000), + Response.json({ ok: true }), + ]); + + const pending = transport(fetch).request(ISSUES_URL); + await delay(900); + expect(sent).toHaveLength(1); + + expect((await pending).status).toBe(200); + expect(sent).toHaveLength(2); + }); + + it("does not wait for a reset beyond the cap and reports the delay", async () => { + const { fetch, sent } = scriptedFetch([() => windowLimited(30_000)]); + const rateLimited = vi.fn(async () => new Error("rate limited")); + + const result = await transport(fetch, { errors: { rateLimited } }) + .request(ISSUES_URL) + .catch((error: unknown) => error); + + expect(result).toEqual(new Error("rate limited")); + expect(sent).toHaveLength(1); + const [, info] = rateLimited.mock.calls[0] as unknown as [ + Response, + { path: string; retryAfterMs: number }, + ]; + expect(info.path).toBe("/api/0/organizations/acme/issues/"); + expect(info.retryAfterMs).toBeGreaterThan(28_000); + }); + + it("sends a still-limited GET at most maxRetries + 1 times", async () => { + const { fetch, sent } = scriptedFetch([() => windowLimited(0)]); + + const response = await transport(fetch).request(ISSUES_URL); + + expect(response.status).toBe(429); + expect(sent).toHaveLength(policy.maxRetries + 1); + }); + + it("ends the wait when the caller aborts", async () => { + const { fetch } = scriptedFetch([() => windowLimited(1500)]); + const controller = new AbortController(); + const reason = new Error("stop"); + + const pending = transport(fetch) + .request(ISSUES_URL, { signal: controller.signal }) + .catch((error: unknown) => error); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + controller.abort(reason); + + expect(await pending).toBe(reason); + }); +}); + +describe("timeouts and fetch errors", () => { + const hang = (_input: Request | string | URL, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }); + + it("rejects an in-flight request with the caller's abort reason", async () => { + // Like fetch, reject with the signal's reason once it aborts. + const fetch = vi.fn( + (_input: Request | string | URL, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => + reject(init.signal?.reason), + ); + }), + ); + const controller = new AbortController(); + const reason = new Error("tool call cancelled"); + + const pending = transport(fetch) + .request(ISSUES_URL, { signal: controller.signal }) + .catch((error: unknown) => error); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + controller.abort(reason); + + expect(await pending).toBe(reason); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("throws the host timeout error for a POST without retrying", async () => { + const fetch = vi.fn(hang); + + const result = await transport(fetch, { timeoutMs: 5 }) + .request(ISSUES_URL, { method: "POST" }) + .catch((error: unknown) => error); + + expect(result).toBeInstanceOf(ApiTimeoutError); + expect(result).toMatchObject({ timeoutMs: 5, idempotent: false }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("retries a timed-out GET, then maps the error", async () => { + const fetch = vi.fn(hang); + const timeout = vi.fn(() => new Error("host timeout")); + + const result = await transport(fetch, { + timeoutMs: (url) => (url.includes("/issues/") ? 5 : 1000), + errors: { timeout }, + }) + .request(ISSUES_URL) + .catch((error: unknown) => error); + + expect(result).toEqual(new Error("host timeout")); + expect(timeout).toHaveBeenCalledWith({ timeoutMs: 5, idempotent: true }); + expect(fetch).toHaveBeenCalledTimes(policy.maxRetries + 1); + }); + + it("times out a buffered body that stalls after the headers", async () => { + // Unlike fetch, this body ignores the attempt's signal once headers arrive. + const stalled = () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{")); + }, + }), + { headers: { "content-type": "application/json" } }, + ); + const fetch = vi.fn(async () => stalled()); + + const result = await Promise.race([ + transport(fetch, { timeoutMs: 5 }) + .request(ISSUES_URL, { method: "POST" }, { responseBody: "buffer" }) + .catch((error: unknown) => error), + new Promise((resolve) => setTimeout(() => resolve("stalled"), 1000)), + ]); + + expect(result).toBeInstanceOf(ApiTimeoutError); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("throws a fatal fetch error at once", async () => { + const { fetch, sent } = scriptedFetch([new Error("bad certificate")]); + + const result = await transport(fetch, { + errors: { fatal: (error) => new Error("tls", { cause: error }) }, + }) + .request(ISSUES_URL) + .catch((error: unknown) => error); + + expect(result).toEqual( + new Error("tls", { cause: new Error("bad certificate") }), + ); + expect(sent).toHaveLength(1); + }); + + it("does not retry an error from the host's header rules", async () => { + const { fetch } = scriptedFetch([Response.json({})]); + + const result = await transport(fetch, { + prepareHeaders: () => { + throw new Error("untrusted host"); + }, + }) + .request(ISSUES_URL) + .catch((error: unknown) => error); + + expect(result).toEqual(new Error("untrusted host")); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe("headers", () => { + it("keeps a Request's own headers and adds auth and User-Agent", async () => { + const { fetch, sent } = scriptedFetch([Response.json({})]); + + await transport(fetch, { userAgent: "test-agent" }).request( + new Request(ISSUES_URL, { + method: "POST", + body: "{}", + headers: { "Content-Type": "application/json" }, + }), + ); + + expect(Object.fromEntries(sent[0]?.headers ?? [])).toMatchObject({ + "content-type": "application/json", + authorization: "Bearer token-a", + "user-agent": "test-agent", + }); + }); + + it("leaves Authorization to the request without credentials", async () => { + const { fetch, sent } = scriptedFetch([Response.json({})]); + const api = createApiTransport({ retry: policy, fetch }); + + await api.request(ISSUES_URL, { + headers: { Authorization: "Bearer from-request" }, + }); + + expect(sent[0]?.headers.get("Authorization")).toBe("Bearer from-request"); + }); +}); + +describe("validated redirects", () => { + const redirect = (location: string, status = 302) => + new Response(null, { status, headers: { location } }); + const origins = { + trusts: (url: URL) => url.hostname.endsWith(".sentry.io"), + reject: (destination: string) => new Error(`untrusted ${destination}`), + }; + + it("follows trusted hops and records the final origin", async () => { + const { fetch, sent } = scriptedFetch([ + redirect("https://de.sentry.io/api/0/users/me/regions/"), + Response.json({ regions: [] }), + ]); + + const response = await transport(fetch, { origins }).request( + `${URL_BASE}/users/me/regions/`, + undefined, + { validateRedirects: true }, + ); + + expect(sent.map((request) => request.url)).toEqual([ + `${URL_BASE}/users/me/regions/`, + "https://de.sentry.io/api/0/users/me/regions/", + ]); + expect(getValidatedResponseSource(response)).toEqual({ + origin: "https://de.sentry.io", + identity: "id-a", + }); + }); + + it.each([ + [ + "an untrusted host", + "https://evil.example/", + "untrusted https://evil.example", + ], + ["a loop", `${URL_BASE}/users/me/regions/`, "untrusted "], + ])( + "rejects %s without sending the credential", + async (_, location, message) => { + const { fetch, sent } = scriptedFetch([redirect(location)]); + + const result = await transport(fetch, { origins }) + .request(`${URL_BASE}/users/me/regions/`, undefined, { + validateRedirects: true, + }) + .catch((error: unknown) => error); + + expect(result).toEqual(new Error(message)); + expect(sent).toHaveLength(1); + }, + ); + + it("turns a 303 into a bodiless GET", async () => { + const { fetch, sent } = scriptedFetch([ + redirect(`${URL_BASE}/done/`, 303), + Response.json({}), + ]); + + await transport(fetch, { origins }).request( + ISSUES_URL, + { + method: "POST", + body: "{}", + headers: { "Content-Type": "application/json" }, + }, + { validateRedirects: true }, + ); + + expect(sent[1]).toMatchObject({ method: "GET", body: "" }); + expect(sent[1]?.headers.get("content-type")).toBeNull(); + }); +}); + +describe("cache", () => { + /** A cache on the shared memory store, keyed as the hosts key it. */ + function memoryCache(): TransportCache & { invalidated: string[] } { + const store = createMemoryStore(); + const codec = createBodyCodec(); + const invalidated: string[] = []; + return { + invalidated, + lookup: async ({ url, requestHeaders, identity }) => + await lookupResponse({ + store, + codec, + key: await buildCacheKey("GET", url, identity), + url, + requestHeaders, + }), + store: async ({ url, requestHeaders, identity }, response) => { + await storeResponse({ + store, + codec, + key: await buildCacheKey("GET", url, identity), + identity, + url, + requestHeaders, + response, + }); + }, + invalidate: async (url) => { + invalidated.push(url); + }, + }; + } + + it("serves a repeated GET from the cache", async () => { + const cache = memoryCache(); + const { fetch, sent } = scriptedFetch([Response.json([{ id: "1" }])]); + const api = transport(fetch, { cache }); + + await (await api.request(ISSUES_URL)).json(); + await waitFor(async () => + expect( + await cache.lookup({ + url: ISSUES_URL, + requestHeaders: { authorization: "Bearer token-a" }, + identity: "id-a", + }), + ).toBeDefined(), + ); + const cached = await api.request(ISSUES_URL); + + expect(await cached.json()).toEqual([{ id: "1" }]); + expect(sent).toHaveLength(1); + }); + + it("matches Vary: Authorization with the host's Authorization", async () => { + const cache = memoryCache(); + const { fetch, sent } = scriptedFetch([ + Response.json([{ id: "1" }], { + headers: { "cache-control": "private, max-age=60", vary: "Authorization" }, + }), + ]); + const api = transport(fetch, { + cache, + credentials: { + current: () => credential, + authorization: ({ token }) => `Token ${token}`, + }, + }); + + await (await api.request(ISSUES_URL)).json(); + await waitFor(async () => + expect( + await cache.lookup({ + url: ISSUES_URL, + requestHeaders: { authorization: "Token token-a" }, + identity: "id-a", + }), + ).toBeDefined(), + ); + const cached = await api.request(ISSUES_URL); + + expect(await cached.json()).toEqual([{ id: "1" }]); + expect(sent).toHaveLength(1); + }); + + it("skips the cache in a fresh scope and replaces the entry", async () => { + const cache = memoryCache(); + const { fetch, sent } = scriptedFetch([ + Response.json({ v: 1 }), + Response.json({ v: 2 }), + ]); + let fresh = false; + const api = transport(fetch, { cache, fresh: () => fresh }); + const stored = async (v: number) => + await waitFor(async () => { + const lookup = await cache.lookup({ + url: ISSUES_URL, + requestHeaders: { authorization: "Bearer token-a" }, + identity: "id-a", + }); + expect( + lookup?.kind === "hit" ? await lookup.response.json() : undefined, + ).toEqual({ v }); + }); + + await api.request(ISSUES_URL); + await stored(1); + fresh = true; + expect(await (await api.request(ISSUES_URL)).json()).toEqual({ v: 2 }); + await stored(2); + fresh = false; + + expect(await (await api.request(ISSUES_URL)).json()).toEqual({ v: 2 }); + expect(sent).toHaveLength(2); + }); + + it("is not consulted without credentials", async () => { + const lookup = vi.fn(); + const store = vi.fn(); + const { fetch, sent } = scriptedFetch([Response.json({})]); + const api = createApiTransport({ + retry: policy, + fetch, + cache: { lookup, store, invalidate: vi.fn() }, + }); + + await (await api.request(ISSUES_URL)).json(); + await (await api.request(ISSUES_URL)).json(); + + expect(sent).toHaveLength(2); + expect(lookup).not.toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + }); + + it("revalidates a stale entry with its ETag", async () => { + const response = await transport( + scriptedFetch([new Response(null, { status: 304 })]).fetch, + { + cache: { + lookup: async () => ({ + kind: "stale", + conditionalHeaders: { "If-None-Match": '"v1"' }, + revalidate: async () => Response.json({ cached: true }), + }), + store: async () => {}, + invalidate: async () => {}, + }, + }, + ).request(ISSUES_URL); + + expect(await response.json()).toEqual({ cached: true }); + }); + + it("invalidates after a successful write", async () => { + const cache = memoryCache(); + const { fetch } = scriptedFetch([new Response(null, { status: 204 })]); + + await transport(fetch, { cache }).request(`${ISSUES_URL}1/`, { + method: "PUT", + }); + + expect(cache.invalidated).toEqual([`${ISSUES_URL}1/`]); + }); +}); diff --git a/static/packages/api/test/api-client/validate.test.ts b/static/packages/api/test/api-client/validate.test.ts new file mode 100644 index 000000000000..a9fd3952b025 --- /dev/null +++ b/static/packages/api/test/api-client/validate.test.ts @@ -0,0 +1,55 @@ +import type { StandardSchemaV1 } from "../../src/api-client/standard-schema.ts"; +import * as v from "valibot"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { ApiResponseValidationError, validateResponse } from "../../src/api-client/validate.ts"; + +describe("validateResponse", () => { + it("returns the output of a Zod schema", () => { + const schema = z.object({ id: z.coerce.number() }); + const result: { id: number } = validateResponse(schema, { id: "7" }); + expect(result).toEqual({ id: 7 }); + }); + + it("returns the output of a valibot schema", () => { + const schema = v.object({ slug: v.string() }); + expect(validateResponse(schema, { slug: "web", extra: 1 })).toEqual({ + slug: "web", + }); + }); + + it("throws with the issues and their paths", () => { + const schema = z.object({ project: z.object({ id: z.string() }) }); + let caught: unknown; + try { + validateResponse(schema, { project: { id: 1 } }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ApiResponseValidationError); + const error = caught as ApiResponseValidationError; + expect(error.issues).toHaveLength(1); + expect(error.message).toMatch(/^Unexpected API response: project\.id: /); + }); + + it("lists at most three issues in the message", () => { + const schema = v.object({ + a: v.string(), + b: v.string(), + c: v.string(), + d: v.string(), + }); + expect(() => validateResponse(schema, {})).toThrow(/\(and 1 more\)$/); + }); + + it("rejects a schema that validates asynchronously", () => { + const schema: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "test", + validate: async (value) => ({ value }), + }, + }; + expect(() => validateResponse(schema, {})).toThrow(TypeError); + }); +}); diff --git a/static/packages/api/test/base-url-resolution.test.ts b/static/packages/api/test/base-url-resolution.test.ts new file mode 100644 index 000000000000..7e268a029569 --- /dev/null +++ b/static/packages/api/test/base-url-resolution.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "vitest"; +import { createSentryClient, type BaseUrlRequest, type Config } from "@sentry/api"; + +const recordingFetch = (urls: string[]) => + (async (input: RequestInfo | URL, init?: RequestInit) => { + urls.push(new Request(input, init).url); + return Response.json({}); + }) as NonNullable; + +describe("resolveBaseUrl", () => { + test("picks the base URL from the organization an operation's path targets", async () => { + const urls: string[] = []; + const seen: BaseUrlRequest[] = []; + const sentry = createSentryClient({ + baseUrl: "https://sentry.io", + fetch: recordingFetch(urls), + resolveBaseUrl: async request => { + seen.push(request); + return "https://us.sentry.io/"; + }, + }); + + await sentry.listOrganizationIssues({ + path: { organization_id_or_slug: "acme" }, + query: { query: "is:unresolved" }, + }); + + expect(seen).toEqual([{ + baseUrl: "https://sentry.io", + url: expect.stringMatching(/^\/api\/0\/organizations\/acme\/issues\/\?/), + scope: { organization: "acme" }, + }]); + expect(urls).toHaveLength(1); + expect(urls[0]).toMatch(/^https:\/\/us\.sentry\.io\/api\/0\/organizations\/acme\/issues\/\?/); + expect(new URL(urls[0]!).searchParams.get("query")).toBe("is:unresolved"); + }); + + test("passes the project scope and omits scope for unscoped paths", async () => { + const seen: BaseUrlRequest[] = []; + const sentry = createSentryClient({ + baseUrl: "https://sentry.example.com", + fetch: recordingFetch([]), + resolveBaseUrl: request => { + seen.push(request); + return request.baseUrl; + }, + }); + + const project = await sentry.client.prepareRequest({ + method: "GET", + url: "/api/0/projects/{org}/{project}/", + path: { org: "acme", project: "web" }, + }); + const unscoped = await sentry.client.prepareRequest({ method: "GET", url: "/api/0/organizations/" }); + + expect(seen.map(request => request.scope)).toEqual([{ organization: "acme", project: "web" }, undefined]); + expect("scope" in seen[1]!).toBe(false); + expect(project.url).toBe("https://sentry.example.com/api/0/projects/acme/web/"); + expect(unscoped.url).toBe("https://sentry.example.com/api/0/organizations/"); + }); + + test("resolves raw request URLs, with an empty base URL when none is configured", async () => { + const seen: BaseUrlRequest[] = []; + const sentry = createSentryClient({ + resolveBaseUrl: request => { + seen.push(request); + return "https://de.sentry.io"; + }, + }); + + const request = await sentry.client.prepareRequest({ + method: "POST", + url: "/api/0/organizations/acme/releases/?existing=1", + body: { version: "1.0.0" }, + }); + + expect(seen).toEqual([{ + baseUrl: "", + url: "/api/0/organizations/acme/releases/?existing=1", + scope: { organization: "acme" }, + }]); + expect(request.url).toBe("https://de.sentry.io/api/0/organizations/acme/releases/?existing=1"); + }); + + test("runs after request validation and before request interceptors, once per request", async () => { + const stages: string[] = []; + const sentry = createSentryClient({ + baseUrl: "https://sentry.io", + fetch: recordingFetch([]), + requestValidator: async () => { stages.push("validate"); }, + resolveBaseUrl: () => { + stages.push("resolve"); + return "https://us.sentry.io"; + }, + }); + sentry.client.interceptors.request.use(request => { + stages.push(`intercept ${new URL(request.url).host}`); + return request; + }); + + await sentry.getOrganization({ path: { organization_id_or_slug: "acme" } }); + + expect(stages).toEqual(["validate", "resolve", "intercept us.sentry.io"]); + }); + + test("a per-request resolver replaces the configured one", async () => { + const urls: string[] = []; + const sentry = createSentryClient({ + baseUrl: "https://sentry.io", + fetch: recordingFetch(urls), + resolveBaseUrl: () => "https://us.sentry.io", + }); + + await sentry.getOrganization({ + path: { organization_id_or_slug: "acme" }, + resolveBaseUrl: () => "https://de.sentry.io", + }); + + expect(urls).toEqual(["https://de.sentry.io/api/0/organizations/acme/"]); + }); + + test("rejects the request without fetching when the resolver throws", async () => { + const urls: string[] = []; + const failure = new Error("no region for acme"); + const sentry = createSentryClient({ + baseUrl: "https://sentry.io", + fetch: recordingFetch(urls), + resolveBaseUrl: async () => { throw failure; }, + }); + + await expect(sentry.getOrganization({ path: { organization_id_or_slug: "acme" } })).rejects.toBe(failure); + expect(urls).toEqual([]); + }); +}); diff --git a/static/packages/api/test/browser.test.ts b/static/packages/api/test/browser.test.ts new file mode 100644 index 000000000000..52a30d903fc8 --- /dev/null +++ b/static/packages/api/test/browser.test.ts @@ -0,0 +1,309 @@ +import {describe, expect, it} from 'vitest'; +import {createSentryClient} from '@sentry/api'; +import {createBrowserFetch, createBrowserSdkConfig, type FetchFn} from '../lib/browser-client.ts'; + +// Helper to capture headers/init from a fetch call +function captureFetch(): {mock: FetchFn; captured: {headers?: Headers; init?: RequestInit}} { + const captured: {headers?: Headers; init?: RequestInit} = {}; + const mock: FetchFn = async (_input, init) => { + captured.headers = new Headers(init?.headers); + captured.init = init; + return new Response('{}', {status: 200}); + }; + return {mock, captured}; +} + +async function withMockFetch(mock: FetchFn, fn: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = mock as typeof fetch; + try { + return await fn(); + } finally { + globalThis.fetch = original; + } +} + +describe('createBrowserFetch', () => { + it('injects X-CSRFToken on POST', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'test-csrf-token'}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/', {method: 'POST', body: '{}'})); + expect(captured.headers?.get('X-CSRFToken')).toBe('test-csrf-token'); + }); + + it('injects X-CSRFToken on PUT', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'test-csrf-token'}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/my-proj/', {method: 'PUT', body: '{}'})); + expect(captured.headers?.get('X-CSRFToken')).toBe('test-csrf-token'); + }); + + it('injects X-CSRFToken on PATCH', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'test-csrf-token'}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/my-proj/', {method: 'PATCH', body: '{}'})); + expect(captured.headers?.get('X-CSRFToken')).toBe('test-csrf-token'); + }); + + it('injects X-CSRFToken on DELETE', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/my-proj/', {method: 'DELETE'})); + expect(captured.headers?.get('X-CSRFToken')).toBe('token'); + }); + + it('does not inject X-CSRFToken on GET with explicit method', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'test-csrf-token'}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/', {method: 'GET'})); + expect(captured.headers?.get('X-CSRFToken')).toBeNull(); + }); + + it('does not inject X-CSRFToken when called with no init (defaults to GET)', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'test-csrf-token'}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/')); + expect(captured.headers?.get('X-CSRFToken')).toBeNull(); + }); + + it('does not inject X-CSRFToken on HEAD', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/', {method: 'HEAD'})); + expect(captured.headers?.get('X-CSRFToken')).toBeNull(); + }); + + it('injects X-CSRFToken when input is a Request object with POST method', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + // Must use a full URL — relative paths are invalid in the Request constructor in Node. + const request = new Request('https://sentry.io/api/0/projects/', {method: 'POST'}); + await withMockFetch(mock, () => browserFetch(request)); + expect(captured.headers?.get('X-CSRFToken')).toBe('token'); + }); + + it('preserves Request object headers when no init.headers is provided', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + const request = new Request('https://sentry.io/api/0/projects/', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-Custom': 'value'}, + }); + await withMockFetch(mock, () => browserFetch(request)); + expect(captured.headers?.get('Content-Type')).toBe('application/json'); + expect(captured.headers?.get('X-Custom')).toBe('value'); + expect(captured.headers?.get('X-CSRFToken')).toBe('token'); + }); + + it('uses init.headers over Request object headers when both are provided', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + const request = new Request('https://sentry.io/api/0/projects/', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + }); + await withMockFetch(mock, () => + browserFetch(request, {headers: {'Content-Type': 'text/plain'}}), + ); + // init.headers wins — matches native fetch spec behavior + expect(captured.headers?.get('Content-Type')).toBe('text/plain'); + }); + + it('does not inject X-CSRFToken when input is a Request object with GET method', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + const request = new Request('https://sentry.io/api/0/projects/'); + await withMockFetch(mock, () => browserFetch(request)); + expect(captured.headers?.get('X-CSRFToken')).toBeNull(); + }); + + it('injects X-CSRFToken when input is a URL object with POST in init', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + await withMockFetch(mock, () => + browserFetch(new URL('https://sentry.io/api/0/projects/'), {method: 'POST'}), + ); + expect(captured.headers?.get('X-CSRFToken')).toBe('token'); + }); + + it('preserves Request headers when init provides method but no headers', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + const request = new Request('https://sentry.io/api/0/projects/my-proj/', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + }); + await withMockFetch(mock, () => browserFetch(request, {method: 'PUT'})); + expect(captured.headers?.get('Content-Type')).toBe('application/json'); + expect(captured.headers?.get('X-CSRFToken')).toBe('token'); + }); + + it('injects X-CSRFToken when init.method is lowercase', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/', {method: 'post'})); + expect(captured.headers?.get('X-CSRFToken')).toBe('token'); + }); + + it('does not inject empty CSRF token', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => ''}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/', {method: 'POST'})); + expect(captured.headers?.has('X-CSRFToken')).toBe(false); + }); + + it('sets credentials: include', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/', {method: 'POST'})); + expect(captured.init?.credentials).toBe('include'); + }); + + it('overrides caller-provided credentials with include', async () => { + const {mock, captured} = captureFetch(); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'token'}); + await withMockFetch(mock, () => browserFetch('/api/0/projects/', {method: 'GET', credentials: 'omit'})); + expect(captured.init?.credentials).toBe('include'); + }); + + it('preserves the effective Request body, method, headers and cancellation', async () => { + const controller = new AbortController(); + const reason = new Error('Request no longer needed'); + const request = new Request('https://example.test/api/0/projects/', { + method: 'POST', + body: JSON.stringify({name: 'example'}), + headers: {'Content-Type': 'application/json', 'X-Context': 'request'}, + signal: controller.signal, + }); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'csrf-token'}); + + await withMockFetch(async (input, init) => { + // Resolve Request + RequestInit as the underlying Fetch implementation does. + const effective = new Request(input, init); + expect(effective.method).toBe('POST'); + expect(await effective.json()).toEqual({name: 'example'}); + expect(effective.headers.get('Content-Type')).toBe('application/json'); + expect(effective.headers.get('X-Context')).toBe('request'); + expect(effective.headers.get('X-CSRFToken')).toBe('csrf-token'); + expect(effective.credentials).toBe('include'); + expect(effective.signal.aborted).toBe(false); + controller.abort(reason); + expect(effective.signal.aborted).toBe(true); + expect(effective.signal.reason).toBe(reason); + throw effective.signal.reason; + }, async () => { + await expect(browserFetch(request)).rejects.toBe(reason); + }); + }); + + it('preserves URL + init and an already-aborted custom reason', async () => { + const reason = 'Caller cancelled'; + const browserFetch = createBrowserFetch({getCsrfToken: () => 'csrf-token'}); + + await withMockFetch(async (input, init) => { + const effective = new Request(input, init); + expect(effective.url).toBe('https://example.test/api/0/projects/'); + expect(effective.method).toBe('PUT'); + expect(await effective.text()).toBe('updated project'); + expect(effective.headers.get('Content-Type')).toBe('text/plain'); + expect(effective.headers.get('X-Context')).toBe('init'); + expect(effective.headers.get('X-CSRFToken')).toBe('csrf-token'); + expect(effective.signal.aborted).toBe(true); + expect(effective.signal.reason).toBe(reason); + throw effective.signal.reason; + }, async () => { + await expect(browserFetch(new URL('https://example.test/api/0/projects/'), { + method: 'PUT', + body: 'updated project', + headers: {'Content-Type': 'text/plain', 'X-Context': 'init'}, + signal: AbortSignal.abort(reason), + })).rejects.toBe(reason); + }); + }); + + it('uses explicit init overrides for the Request body, method, headers and signal', async () => { + const original = new AbortController(); + const replacement = new AbortController(); + const reason = new Error('Replacement request cancelled'); + const request = new Request('https://example.test/api/0/projects/', { + method: 'POST', body: 'original', headers: {'X-Original': 'true'}, signal: original.signal, + }); + const browserFetch = createBrowserFetch({getCsrfToken: () => 'csrf-token'}); + + await withMockFetch(async (input, init) => { + const effective = new Request(input, init); + expect(effective.method).toBe('PATCH'); + expect(await effective.text()).toBe('replacement'); + expect(effective.headers.has('X-Original')).toBe(false); + expect(effective.headers.get('X-Replacement')).toBe('true'); + expect(effective.headers.get('X-CSRFToken')).toBe('csrf-token'); + original.abort(new Error('Original cancelled')); + expect(effective.signal.aborted).toBe(false); + replacement.abort(reason); + expect(effective.signal.reason).toBe(reason); + throw effective.signal.reason; + }, async () => { + await expect(browserFetch(request, { + method: 'PATCH', body: 'replacement', headers: {'X-Replacement': 'true'}, + signal: replacement.signal, + })).rejects.toBe(reason); + }); + }); + + it('forwards explicit signal: null alongside an already-aborted Request', async () => { + const request = new Request('https://example.test/api/0/projects/', { + signal: AbortSignal.abort(new Error('Original cancelled')), + }); + const browserFetch = createBrowserFetch(); + + await withMockFetch(async (input, init) => { + expect(input).toBe(request); + expect(init?.signal).toBeNull(); + return Response.json([]); + }, async () => { + const response = await browserFetch(request, {signal: null}); + expect(await response.json()).toEqual([]); + }); + }); +}); + +describe('createBrowserSdkConfig', () => { + it('returns empty baseUrl by default', () => { + const config = createBrowserSdkConfig(); + expect(config.baseUrl).toBe(''); + }); + + it('accepts custom baseUrl', () => { + const config = createBrowserSdkConfig({baseUrl: 'https://sentry.io'}); + expect(config.baseUrl).toBe('https://sentry.io'); + }); + + it('returns a fetch function', () => { + const config = createBrowserSdkConfig(); + expect(typeof config.fetch).toBe('function'); + }); + + it('propagates in-flight cancellation through a configured SDK operation', async () => { + const controller = new AbortController(); + const reason = new Error('Query cancelled'); + let started!: () => void; + const requestStarted = new Promise(resolve => { started = resolve; }); + const config = createBrowserSdkConfig({baseUrl: 'https://example.test'}); + const sentry = createSentryClient(config); + + await withMockFetch((input, init) => { + const effective = new Request(input, init); + expect(effective.method).toBe('GET'); + expect(effective.credentials).toBe('include'); + return new Promise((_resolve, reject) => { + effective.signal.addEventListener('abort', () => reject(effective.signal.reason), {once: true}); + started(); + }); + }, async () => { + const result = sentry.listOrganizations({signal: controller.signal, throwOnError: true}); + await Promise.race([requestStarted, result]); + controller.abort(reason); + await expect(result).rejects.toBe(reason); + }); + }); +}); diff --git a/static/packages/api/test/cancellation.test.ts b/static/packages/api/test/cancellation.test.ts new file mode 100644 index 000000000000..f0b8dceececf --- /dev/null +++ b/static/packages/api/test/cancellation.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, test, vi } from "vitest"; +import { createSentryClient, listOrganizations, type Config } from "@sentry/api"; +import { createBrowserFetch } from "@sentry/api/browser"; + +const asFetch = (implementation: (request: Request) => Promise) => + ((input: RequestInfo | URL, init?: RequestInit) => + implementation(input instanceof Request && init === undefined ? input : new Request(input, init))) as NonNullable; + +describe("operation cancellation", () => { + for (const style of ["bound", "standalone"] as const) { + test(`${style} operations stop before injected fetch and preserve the abort reason in both error modes`, async () => { + let requests = 0; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => { requests++; return Response.json([]); }), + }); + + for (const reason of [new Error("Cancelled by caller"), "Tool cancelled", null, false, 0, "", undefined]) { + const controller = new AbortController(); + controller.abort(reason); + const options = { signal: controller.signal }; + const result = style === "bound" + ? await sentry.listOrganizations(options) + : await listOrganizations({ ...options, client: sentry.client }); + + expect("error" in result).toBe(true); + expect(result.error).toBe(controller.signal.reason); + expect(result.response).toBeUndefined(); + expect(result.request.signal.aborted).toBe(true); + expect(result.request.signal.reason).toBe(controller.signal.reason); + + const throwing = style === "bound" + ? sentry.listOrganizations({ ...options, throwOnError: true }) + : listOrganizations({ ...options, client: sentry.client, throwOnError: true }); + await expect(throwing).rejects.toBe(controller.signal.reason); + } + + expect(requests).toBe(0); + }); + } + + test("passes cancellation through the configured error mapper with the request and no response", async () => { + const reason = new Error("Command cancelled"); + const mapped = new Error("Application cancellation"); + const controller = new AbortController(); + controller.abort(reason); + const seen: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => { throw new Error("Fetch must not run"); }), + }); + sentry.client.interceptors.error.use((error, response, request) => { + expect(error).toBe(reason); + expect(response).toBeUndefined(); + expect(request.signal.reason).toBe(reason); + seen.push(request); + return mapped; + }); + + const result = await sentry.listOrganizations({ signal: controller.signal }); + expect(result.error).toBe(mapped); + expect(result.request).toBe(seen[0]!); + expect(result.response).toBeUndefined(); + await expect(listOrganizations({ + client: sentry.client, signal: controller.signal, throwOnError: true, + })).rejects.toBe(mapped); + expect(seen).toHaveLength(2); + }); + + test("does not dispatch when cancelled during an asynchronous request interceptor", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const controller = new AbortController(); + const reason = new Error("Cancelled during preparation"); + let requests = 0; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => { requests++; return Response.json([]); }), + }); + sentry.client.interceptors.request.use(async request => { + entered.resolve(); + await release.promise; + return request; + }); + + const pending = sentry.listOrganizations({ signal: controller.signal }); + await entered.promise; + controller.abort(reason); + release.resolve(); + + const result = await pending; + expect(result.error).toBe(reason); + expect(result.response).toBeUndefined(); + expect(requests).toBe(0); + }); + + test("per-call signals, including null, replace the client default without changing it", async () => { + const defaultController = new AbortController(); + const callController = new AbortController(); + const reason = new Error("Default cancelled"); + defaultController.abort(reason); + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", signal: defaultController.signal, + fetch: asFetch(async request => { requests.push(request); return Response.json([]); }), + }); + + const overridden = await sentry.listOrganizations({ signal: callController.signal }); + const cleared = await listOrganizations({ client: sentry.client, signal: null }); + const inherited = await sentry.listOrganizations(); + + expect(overridden.data).toEqual([]); + expect(cleared.data).toEqual([]); + expect(inherited.error).toBe(reason); + expect(requests).toHaveLength(2); + expect(requests.every(request => !request.signal.aborted)).toBe(true); + expect(sentry.client.getConfig().signal).toBe(defaultController.signal); + callController.abort("Only this call"); + expect(overridden.request.signal.reason).toBe("Only this call"); + expect(cleared.request.signal.aborted).toBe(false); + }); + + test("checks the final Request signal after an interceptor replaces it", async () => { + const controller = new AbortController(); + const reason = new Error("Replacement cancelled"); + controller.abort(reason); + let requests = 0; + const fetch = asFetch(async () => { requests++; return Response.json([]); }); + const cancelled = createSentryClient({ baseUrl: "https://example.test", fetch }); + cancelled.client.interceptors.request.use(request => + new Request(request, { signal: controller.signal })); + expect((await cancelled.listOrganizations()).error).toBe(reason); + + const replacement = new AbortController(); + const replaced = createSentryClient({ baseUrl: "https://example.test", fetch }); + replaced.client.interceptors.request.use(request => new Request(request, { signal: replacement.signal })); + expect((await replaced.listOrganizations({ signal: controller.signal })).data).toEqual([]); + expect(requests).toBe(1); + }); + + test("cancelling an in-flight operation does not cancel another operation on the shared client", async () => { + const started = Promise.withResolvers(); + const survivor = Promise.withResolvers(); + const firstController = new AbortController(); + const secondController = new AbortController(); + const reason = new Error("First operation cancelled"); + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(request => { + requests.push(request); + if (requests.length === 2) started.resolve(); + return new Promise((resolve, reject) => { + const abort = () => reject(request.signal.reason); + request.signal.addEventListener("abort", abort, { once: true }); + if (request.headers.get("X-Operation") === "second") { + void survivor.promise.then(response => { + request.signal.removeEventListener("abort", abort); + resolve(response); + }); + } + }); + }), + }); + + const cancelled = sentry.listOrganizations({ signal: firstController.signal }); + const successful = listOrganizations({ + client: sentry.client, signal: secondController.signal, + headers: { "X-Operation": "second" }, throwOnError: true, + }); + await started.promise; + firstController.abort(reason); + const result = await cancelled; + expect(result.error).toBe(reason); + expect(result.response).toBeUndefined(); + expect(secondController.signal.aborted).toBe(false); + const response = Response.json([]); + survivor.resolve(response); + expect((await successful).response).toBe(response); + expect(requests).toHaveLength(2); + }); + + test("preserves the winning reason of a caller-composed cancellation signal", async () => { + for (const winner of ["caller", "timeout"] as const) { + const caller = new AbortController(); + const timeout = new AbortController(); + const callerReason = new Error("User cancelled"); + const timeoutReason = new DOMException("Deadline exceeded", "TimeoutError"); + const signal = AbortSignal.any([caller.signal, timeout.signal]); + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => { throw new Error("Fetch must not run"); }), + }); + if (winner === "caller") { + caller.abort(callerReason); + timeout.abort(timeoutReason); + } else { + timeout.abort(timeoutReason); + caller.abort(callerReason); + } + + const result = await sentry.listOrganizations({ signal }); + expect(result.error).toBe(winner === "caller" ? callerReason : timeoutReason); + expect(result.response).toBeUndefined(); + } + }); + + test("body-read cancellation still rejects directly without running transport error mappers", async () => { + for (const status of [200, 500]) { + const reading = Promise.withResolvers(); + const controller = new AbortController(); + const reason = new Error("Cancelled while reading response body"); + const mapped: unknown[] = []; + class ObservedResponse extends Response { + override json() { reading.resolve(); return super.json(); } + override text() { reading.resolve(); return super.text(); } + } + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => new ObservedResponse(new ReadableStream({ + start(stream) { + request.signal.addEventListener("abort", () => stream.error(request.signal.reason), { once: true }); + }, + }), { status, headers: { "Content-Type": "application/json" } })), + }); + sentry.client.interceptors.error.use(error => { mapped.push(error); return error; }); + + const pending = sentry.listOrganizations({ signal: controller.signal }); + await reading.promise; + controller.abort(reason); + await expect(pending).rejects.toBe(reason); + expect(mapped).toEqual([]); + } + }); + + test("browser fetch detaches an aborted Request with an explicit null signal", async () => { + const request = new Request("https://example.test/api/0/organizations/", { + signal: AbortSignal.abort(new Error("Original cancelled")), + }); + const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + expect(new Request(input, init).signal.aborted).toBe(false); + return Response.json([]); + }); + try { + const response = await createBrowserFetch()(request, { signal: null }); + expect(await response.json()).toEqual([]); + expect(request.signal.aborted).toBe(true); + } finally { + fetch.mockRestore(); + } + }); + + test("respects an interceptor clearing the caller's aborted signal", async () => { + let dispatched = 0; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + expect(request.signal.aborted).toBe(false); + dispatched++; + return Response.json([]); + }), + }); + sentry.client.interceptors.request.use(request => new Request(request, { signal: null })); + + const result = await sentry.listOrganizations({ + signal: AbortSignal.abort(new Error("Caller cancelled")), + throwOnError: true, + }); + expect(result.data).toEqual([]); + expect(dispatched).toBe(1); + }); +}); diff --git a/static/packages/api/test/client-package.test.ts b/static/packages/api/test/client-package.test.ts new file mode 100644 index 000000000000..4352c5a20201 --- /dev/null +++ b/static/packages/api/test/client-package.test.ts @@ -0,0 +1,95 @@ +import { build } from "esbuild"; +import { describe, expect, test } from "vitest"; +import { rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { installPackedPackage } from "./packed-package.ts"; + +/** + * Exercises the packed client the way a host uses it: the browser session + * fetch, under the shared transport and request core, under an endpoint + * wrapper, against a mocked `fetch`. Returns what the mock saw. + */ +const SCENARIO = ` + import { SentryApiError } from "@sentry/api"; + import { createBrowserFetch } from "@sentry/api/browser"; + import { + ApiNotFoundError, + createApiClientCore, + createApiTransport, + } from "@sentry/api/client"; + import { listOrganizations } from "@sentry/api/client/endpoints"; + + const anyRows = { "~standard": { version: 1, vendor: "test", validate: (value) => ({ value }) } }; + + export async function run() { + const seen = []; + globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + seen.push({ method: request.method, url: request.url, credentials: request.credentials }); + return request.url.includes("/missing/") + ? Response.json({ detail: "Not found" }, { status: 404 }) + : Response.json([{ slug: "acme" }]); + }; + const transport = createApiTransport({ + fetch: createBrowserFetch({ getCsrfToken: () => "csrf" }), + retry: { maxRetries: 0, baseDelayMs: 0, maxDelayMs: 0, maxTotalDelayMs: 0 }, + }); + const core = createApiClientCore({ transport, headers: () => ({}), logger: { warn() {} } }); + const target = { core, origin: "https://sentry.example.test" }; + + const page = await listOrganizations(target, { limit: 1 }, anyRows); + const error = await core.requestJson(target.origin, "/missing/").catch((caught) => caught); + return { + seen, + items: page.items, + notFound: error instanceof ApiNotFoundError, + sharedErrorType: error instanceof SentryApiError, + status: error.status, + }; + } +`; + +describe("@sentry/api/client package entry", () => { + test("bundles for the browser and makes a request against a mocked fetch", { timeout: 60_000 }, async () => { + const directory = installPackedPackage("api-client-package-"); + try { + const entry = join(directory, "scenario.js"); + writeFileSync(entry, SCENARIO); + const result = await build({ + entryPoints: [entry], bundle: true, format: "esm", platform: "browser", + write: false, logLevel: "silent", + }); + expect(result.warnings).toEqual([]); + expect(result.errors).toEqual([]); + const bundle = result.outputFiles[0]!.text; + expect(bundle).not.toMatch(/["']node:/); + // Each entry imports the others by name, so the bundle has one copy of each class. + expect(bundle.match(/\bSentryApiError = class\b/g)).toHaveLength(1); + + const bundled = join(directory, "bundled.js"); + writeFileSync(bundled, bundle); + const originalFetch = globalThis.fetch; + try { + const { run } = await import(bundled); + expect(await run()).toEqual({ + seen: [ + { + method: "GET", + url: "https://sentry.example.test/api/0/organizations/?per_page=1", + credentials: "include", + }, + { method: "GET", url: "https://sentry.example.test/api/0/missing/", credentials: "include" }, + ], + items: [{ slug: "acme" }], + notFound: true, + sharedErrorType: true, + status: 404, + }); + } finally { + globalThis.fetch = originalFetch; + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/static/packages/api/test/client-pagination-types.ts b/static/packages/api/test/client-pagination-types.ts new file mode 100644 index 000000000000..04da806a70dd --- /dev/null +++ b/static/packages/api/test/client-pagination-types.ts @@ -0,0 +1,107 @@ +/** Public-package pagination type contracts; never executed. */ +import { consumePage, paginateUpTo, type BudgetedPageFetcher, type CollectionPage, type CollectionPageFetcher, type PaginatedCollection, type PaginationContinuation, type SentryClient } from "@sentry/api"; +import type * as pagination from "../src/pagination.gen"; + +type Kind = "fetchPage" | "paginateAll" | "paginateUpTo"; +type Bind = Function extends (options: infer Options, ...args: infer Args) => infer Result + ? (options: Omit, ...args: Args) => Result + : never; +type Expected = { + [Name in keyof typeof pagination as Name extends `${Prefix}_${infer Operation}` ? Operation : never]: + Bind; +}; +type ExactNames = + Exclude> | + Exclude, keyof SentryClient[Prefix]>; +const complete: { [Prefix in Kind]: ExactNames extends never ? true : false } = { + fetchPage: true, + paginateAll: true, + paginateUpTo: true, +}; +const signatures: { + [Prefix in Kind]: SentryClient[Prefix] extends Expected + ? Expected extends SentryClient[Prefix] ? true : false + : false; +} = { fetchPage: true, paginateAll: true, paginateUpTo: true }; +void [complete, signatures]; + +async function pageTypes(sentry: SentryClient) { + const options = { path: { organization_id_or_slug: "example" } }; + const page = await sentry.fetchPage.listOrganizationProjects(options, "opaque:0:0"); + const slug: string | undefined = page.data[0]?.slug; + const response: Response = page.response; + const header: string | null = response.headers.get("X-Hits"); + const next: string | undefined = page.nextCursor; + void [slug, header, next]; + + // @ts-expect-error A required path remains required. + await sentry.fetchPage.listOrganizationProjects({}); + // @ts-expect-error Cursor belongs in the helper's separate argument. + await sentry.fetchPage.listOrganizationProjects({ ...options, query: { cursor: "x" } }); + // @ts-expect-error A bound helper cannot accept another transport. + await sentry.fetchPage.listOrganizationProjects({ ...options, client: sentry.client }); + // @ts-expect-error Existing query parameters remain typed. + await sentry.fetchPage.listOrganizations({ query: { owner: "yes" } }); + // @ts-expect-error Cursor remains a string. + await sentry.fetchPage.listOrganizationProjects(options, 1); + // @ts-expect-error Bound helper properties are readonly. + sentry.fetchPage.listOrganizationProjects = sentry.fetchPage.listOrganizationProjects; +} + +async function collectionTypes(sentry: SentryClient) { + const options = { path: { organization_id_or_slug: "example" } }; + const all = await sentry.paginateAll.listOrganizationProjects(options); + const batch = await sentry.paginateUpTo.listOrganizationProjects(options, { + limit: 250, + startCursor: "opaque:0:0", + }); + const slug: string | undefined = all.data[0]?.slug; + const batchSlug: string | undefined = batch.data[0]?.slug; + const reason: "exhausted" | "limit" | "maxPages" = batch.stopReason; + const typed: PaginatedCollection<{ slug: string }> = all; + const fromPage = await paginateUpTo( + cursor => sentry.fetchPage.listOrganizationProjects(options, cursor), + { limit: 10 }, "projects", + ); + const pageSlug: string | undefined = fromPage.data[0]?.slug; + const normalized: CollectionPageFetcher = async () => ({ data: ["one"] }); + // @ts-expect-error Request belongs to the SDK result envelope, not normalized metadata. + const mixedPage: CollectionPage = { data: ["one"], request: new Request("https://example.test") }; + // @ts-expect-error Normalized fetchers reject; they cannot carry an ignored error. + const mixedError: CollectionPage = { data: ["one"], error: new Error("failed") }; + const strings = await paginateUpTo(normalized, { limit: 1 }, "custom"); + const value: string | undefined = strings.data[0]; + const sdkFetcher: BudgetedPageFetcher = async () => ({ + data: ["one"], request: new Request("https://example.test"), response: new Response(), + }); + const sdkResponse: Response | undefined = (await sdkFetcher(undefined, 1)).response; + void [pageSlug, value, sdkResponse]; + void [slug, batchSlug, reason, typed]; + if (batch.continuation) { + const continuation: PaginationContinuation = batch.continuation; + await sentry.paginateUpTo.listOrganizationProjects(options, { limit: 10, resume: continuation }); + const consumed = consumePage({ data: batch.data }, { offset: 0 }, 5); + const consumedSlug: string | undefined = consumed.data[0]?.slug; + void consumedSlug; + } + // @ts-expect-error Partial replay must preserve its original request budget. + const missingBudget: PaginationContinuation = { cursor: "opaque", offset: 5 }; + // @ts-expect-error Pagination must not mutate a caller's continuation. + if (batch.continuation) batch.continuation.offset = 1; + // @ts-expect-error Collections now expose their items through data. + all.map(item => item.slug); + + // @ts-expect-error Bounded collection requires a budget. + await sentry.paginateUpTo.listOrganizationProjects(options); + // @ts-expect-error A budget requires an item limit. + await sentry.paginateUpTo.listOrganizationProjects(options, {}); + // @ts-expect-error Array-only collection helpers must not appear for compound bodies. + sentry.paginateAll.listProjectReplayClicks; + // @ts-expect-error Array-only collection helpers must not appear for compound bodies. + sentry.paginateUpTo.listProjectReplayClicks; + // @ts-expect-error A collection does not have a single HTTP response. + batch.response; +} + +void pageTypes; +void collectionTypes; diff --git a/static/packages/api/test/client-pagination.test.ts b/static/packages/api/test/client-pagination.test.ts new file mode 100644 index 000000000000..e6c1865daee1 --- /dev/null +++ b/static/packages/api/test/client-pagination.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "vitest"; +import { + createSentryClient, + fetchPage_listOrganizations, + SentryApiError, + type Config, +} from "@sentry/api"; +import * as pagination from "../src/pagination.gen"; + +const asFetch = (implementation: (request: Request) => Promise) => + ((input: RequestInfo | URL, init?: RequestInit) => + implementation(new Request(input, init))) as NonNullable; + +// A Link's URL must never replace the configured operation's host or filters. +const link = (rel: "next" | "previous", cursor: string, results = true) => + `; rel="${rel}"; results="${results}"; cursor="${cursor}"`; + +describe("configured pagination methods", () => { + test("exposes exactly the generated helpers in each namespace", () => { + const sentry = createSentryClient(); + for (const kind of ["fetchPage", "paginateAll", "paginateUpTo"] as const) { + const prefix = `${kind}_`; + const names = Object.keys(pagination) + .filter(name => name.startsWith(prefix)) + .map(name => name.slice(prefix.length)); + expect(Object.keys(sentry[kind]).sort()).toEqual(names.sort()); + expect(Object.values(sentry[kind]).every(method => typeof method === "function")).toBe(true); + } + }); + + test("navigates first, next and previous pages with the configured request and response metadata", async () => { + const requests: Request[] = []; + const responses: Response[] = []; + const sentry = createSentryClient({ + baseUrl: "https://configured.example.test", + headers: { Authorization: "Bearer configured" }, + fetch: asFetch(async request => { + requests.push(request); + const cursor = new URL(request.url).searchParams.get("cursor"); + const second = cursor === "opaque:next:0"; + const response = Response.json([{ slug: second ? "second" : "first" }], { + headers: { + Link: [ + link("previous", "opaque:previous:1", second), + link("next", "opaque:next:0", !second), + ].join(", "), + "X-Hits": "2", + }, + }); + responses.push(response); + return response; + }), + }); + const options = { query: { owner: true, per_page: 1 } }; + + const first = await sentry.fetchPage.listOrganizations(options); + const second = await sentry.fetchPage.listOrganizations(options, first.nextCursor); + const previous = await sentry.fetchPage.listOrganizations(options, second.prevCursor); + + expect(first.data.map(item => item.slug)).toEqual(["first"]); + expect(first.prevCursor).toBeUndefined(); + expect(second.data.map(item => item.slug)).toEqual(["second"]); + expect(second.nextCursor).toBeUndefined(); + expect(previous.data).toEqual(first.data); + expect(first.response).toBe(responses[0]!); + expect(second.response).toBe(responses[1]!); + expect(first.response.headers.get("X-Hits")).toBe("2"); + expect(first.response.headers.get("Link")).toContain('results="false"'); + expect(first.response.bodyUsed).toBe(true); + expect(requests.map(request => new URL(request.url).searchParams.get("cursor"))) + .toEqual([null, "opaque:next:0", "opaque:previous:1"]); + for (const request of requests) { + const url = new URL(request.url); + expect(url.origin).toBe("https://configured.example.test"); + expect(url.pathname).toBe("/api/0/organizations/"); + expect(url.searchParams.get("owner")).toBe("true"); + expect(url.searchParams.get("per_page")).toBe("1"); + expect(request.headers.get("Authorization")).toBe("Bearer configured"); + } + expect(options).toEqual({ query: { owner: true, per_page: 1 } }); + }); + + test("shares configuration across detached helpers while keeping concurrent clients isolated", async () => { + const requests: Request[] = []; + const fetch = asFetch(async request => { + requests.push(request); + const url = new URL(request.url); + const next = url.searchParams.has("cursor"); + return Response.json([{ slug: next ? "second" : "first" }], { + headers: next ? {} : { Link: link("next", "resume:0:0") }, + }); + }); + const first = createSentryClient({ baseUrl: "https://first.example.test", fetch }); + const second = createSentryClient({ baseUrl: "https://second.example.test", fetch }); + const { listOrganizations: all } = first.paginateAll; + const { listOrganizations: upTo } = second.paginateUpTo; + first.client.setConfig({ headers: { Authorization: "Bearer refreshed" } }); + first.client.interceptors.request.use(request => { + request.headers.set("X-Context", "first"); + return request; + }); + // JavaScript and structural types can carry an extra client field. + const options = { client: second.client, query: { owner: true } }; + const [items, batch] = await Promise.all([ + all(options), + upTo({}, { limit: 1, startCursor: "resume:0:0" }), + ]); + const standalone = await fetchPage_listOrganizations({ client: first.client }); + + expect(items.data.map(item => item.slug)).toEqual(["first", "second"]); + expect(batch.data.map(item => item.slug)).toEqual(["second"]); + expect(batch.nextCursor).toBeUndefined(); + expect(standalone.data.map(item => item.slug)).toEqual(["first"]); + expect(standalone.response.status).toBe(200); + expect(requests.filter(request => new URL(request.url).hostname === "first.example.test")) + .toHaveLength(3); + for (const request of requests) { + const isFirst = new URL(request.url).hostname === "first.example.test"; + expect(request.headers.get("Authorization")).toBe(isFirst ? "Bearer refreshed" : null); + expect(request.headers.get("X-Context")).toBe(isFirst ? "first" : null); + } + expect(options.client).toBe(second.client); + }); + + test("preserves compound page bodies and their headers without inventing collection helpers", async () => { + const body = { data: [{ node_id: 1, timestamp: "2026-01-01T00:00:00Z" }] }; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => Response.json(body, { headers: { "X-Hits": "1" } })), + }); + const page = await sentry.fetchPage.listProjectReplayClicks({ + path: { + organization_id_or_slug: "example", + project_id_or_slug: "project", + replay_id: "00000000-0000-0000-0000-000000000000", + }, + }); + + expect(page.data).toEqual(body); + expect(page.response.headers.get("X-Hits")).toBe("1"); + expect("listProjectReplayClicks" in sentry.paginateAll).toBe(false); + expect("listProjectReplayClicks" in sentry.paginateUpTo).toBe(false); + }); + + test("preserves normalized HTTP errors for page and collection helpers", async () => { + const body = { detail: "Forbidden" }; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => Response.json(body, { status: 403 })), + }); + for (const call of [ + () => sentry.fetchPage.listOrganizations({}), + () => sentry.paginateAll.listOrganizations({}), + () => sentry.paginateUpTo.listOrganizations({}, { limit: 1 }), + ]) { + const error = await call().catch(error => error); + expect(error).toBeInstanceOf(SentryApiError); + expect(error.status).toBe(403); + expect(error.body).toEqual(body); + expect(error.response.status).toBe(403); + } + sentry.client.setConfig({ throwOnError: true }); + await expect(sentry.fetchPage.listOrganizations({})).rejects.toMatchObject({ + name: "SentryApiError", status: 403, body, + }); + }); + + test("stops the next collection dispatch after cancellation", async () => { + const controller = new AbortController(); + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + requests.push(request); + request.signal.throwIfAborted(); + controller.abort(); + return Response.json([{ slug: "first" }], { headers: { Link: link("next", "next") } }); + }), + }); + + const error = await sentry.paginateAll.listOrganizations({ signal: controller.signal }) + .catch(error => error); + + expect(error).toBe(controller.signal.reason); + expect(error.name).toBe("AbortError"); + expect(requests).toHaveLength(1); + expect(requests.every(request => request.signal.aborted)).toBe(true); + }); + + test("stops bounded pagination when the progress callback cancels", async () => { + const controller = new AbortController(); + const reason = new Error("Stopped after the first page"); + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + requests.push(request); + return Response.json([{ slug: "first" }], { headers: { Link: link("next", "next") } }); + }), + }); + + const error = await sentry.paginateUpTo.listOrganizations( + { signal: controller.signal }, + { limit: 2, onPage: () => controller.abort(reason) }, + ).catch(error => error); + + expect(error).toBe(reason); + expect(requests).toHaveLength(1); + }); +}); diff --git a/static/packages/api/test/client-types.ts b/static/packages/api/test/client-types.ts new file mode 100644 index 000000000000..3710ac4d2c2f --- /dev/null +++ b/static/packages/api/test/client-types.ts @@ -0,0 +1,251 @@ +/** Public-package type checks for the bound operation facade; never executed. */ +import { + createSentryClient, + listOrganizations, + paginateAll_listOrganizations, + type Config, + type ListOrganizationsData, + type Options, + type SentryClient, +} from "@sentry/api"; +import type * as operations from "../src/sdk.gen"; + +type OperationNames = keyof typeof operations; +type BoundOperationNames = Exclude; +// Both directions catch missing operations and accidental additional exports. +const completeSurface: [OperationNames] extends [BoundOperationNames] ? true : false = true; +const exactSurface: [BoundOperationNames] extends [OperationNames] ? true : false = true; +void [completeSurface, exactSurface]; + +function publicFactory() { + const config: Config = { baseUrl: "https://example.test" }; + const configured: SentryClient = createSentryClient(config); + const defaults: SentryClient = createSentryClient(); + configured.client.setConfig(config); + configured.client.interceptors.request.use(request => request); + // @ts-expect-error Replacing the raw transport would not rebind closed-over methods. + configured.client = defaults.client; + // @ts-expect-error Bound operation properties are readonly. + configured.listOrganizations = defaults.listOrganizations; + void defaults; + return configured; +} + +async function preparedRequests(sentry: SentryClient) { + const request: Request = await sentry.client.prepareRequest({ + method: "GET", url: "/api/0/custom/", query: { cursor: "opaque" }, + }); + // @ts-expect-error Generic preparation requires an explicit method. + await sentry.client.prepareRequest({ url: "/api/0/custom/" }); + // Custom clients accepted by standalone operations need no new capability. + const { prepareRequest, ...legacyClient } = sentry.client; + await listOrganizations({ client: legacyClient }); + void [request, prepareRequest]; +} + +async function streamingCallbacks(sentry: SentryClient) { + const { stream } = await sentry.client.sse.get({ + url: "/api/0/events/", + onSseEvent: event => { + const value: string = event.data.toUpperCase(); + // @ts-expect-error SSE callback data retains the requested type. + const wrong: number = event.data; + void [value, wrong]; + }, + }); + const next = await stream.next(); + if (!next.done) { + const value: string = next.value; + void value; + } +} + +async function operationArguments(sentry: SentryClient) { + await sentry.listOrganizations(); + await sentry.listOrganizations({ query: { owner: true }, signal: new AbortController().signal }); + await sentry.getOrganization({ path: { organization_id_or_slug: "example" } }); + await sentry.createOrganizationDashboard({ + path: { organization_id_or_slug: "example" }, + body: { title: "Overview" }, + }); + + // @ts-expect-error Required operation options must not become optional. + await sentry.getOrganization(); + // @ts-expect-error Required path parameters must remain required. + await sentry.getOrganization({}); + // @ts-expect-error A required mutation body must remain required. + await sentry.createOrganizationDashboard({ path: { organization_id_or_slug: "example" } }); + // @ts-expect-error The generated body shape must not become any. + await sentry.createOrganizationDashboard({ path: { organization_id_or_slug: "example" }, body: { title: 1 } }); + // @ts-expect-error Query parameters retain their declared types. + await sentry.listOrganizations({ query: { owner: "yes" } }); + // @ts-expect-error The bound context cannot be replaced through operation options. + await sentry.listOrganizations({ client: sentry.client }); + + // Client-side query defaults keep the generated parameter types, so the + // empty-list opt-out needs no cast and unknown values are still rejected. + const event = { path: { organization_id_or_slug: "example", issue_id: "1", event_id: "latest" as const } }; + await sentry.getOrganizationIssueEvent(event); + await sentry.getOrganizationIssueEvent({ ...event, query: { collapse: [] } }); + // @ts-expect-error Query defaults do not widen the documented enum. + await sentry.getOrganizationIssueEvent({ ...event, query: { collapse: ["everything"] } }); + const projectEvent = { + path: { organization_id_or_slug: "example", project_id_or_slug: "example", event_id: "a".repeat(32) }, + }; + await sentry.getProjectEvent(projectEvent); + await sentry.getProjectEvent({ ...projectEvent, query: { collapse: [] } }); + // @ts-expect-error Query defaults do not widen the documented enum. + await sentry.getProjectEvent({ ...projectEvent, query: { collapse: ["everything"] } }); +} + +async function responseAndThrowingTypes(sentry: SentryClient, throwOnError: boolean) { + const path = { organization_id_or_slug: "example" }; + const returning = await sentry.getOrganization({ path }); + // @ts-expect-error The default non-throwing result still has an error branch. + returning.data.slug; + if (returning.data) { + const slug: string = returning.data.slug; + void slug; + } + + const throwing = await sentry.getOrganization({ path, throwOnError: true }); + const slug: string = throwing.data.slug; + // @ts-expect-error Successful response fields retain their concrete type. + const wrongSlug: number = throwing.data.slug; + // @ts-expect-error The throwOnError:true result has no error field. + throwing.error; + + const explicit = await sentry.getOrganization({ path, throwOnError: true }); + const explicitSlug: string = explicit.data.slug; + const conditional = await sentry.getOrganization({ path, throwOnError }); + // @ts-expect-error A boolean flag must preserve the possible error branch. + conditional.data.slug; + + const { listOrganizations: detached } = sentry; + const organizations = await detached({ throwOnError: true }); + const firstSlug: string | undefined = organizations.data[0]?.slug; + const response: Response = organizations.response; + void [slug, wrongSlug, explicitSlug, firstSlug, response]; +} + +async function standaloneInteroperability(sentry: SentryClient) { + const named = await listOrganizations({ client: sentry.client, throwOnError: true }); + const paginated = await paginateAll_listOrganizations({ client: sentry.client }); + const first: string | undefined = named.data[0]?.slug; + const second: string | undefined = paginated.data[0]?.slug; + void [first, second]; +} + +async function explicitThrowRequiresRuntimeOption(sentry: SentryClient, throwOnError: boolean) { + const path = { organization_id_or_slug: "example" }; + const bound = await sentry.listOrganizations({ throwOnError: true }); + const standalone = await listOrganizations({ client: sentry.client, throwOnError: true }); + const slug: string | undefined = bound.data[0]?.slug; + const standaloneSlug: string | undefined = standalone.data[0]?.slug; + + // @ts-expect-error A type argument cannot turn on throwing at runtime. + await sentry.listOrganizations(); + // @ts-expect-error Explicit true also requires the corresponding runtime flag. + await sentry.listOrganizations({}); + // @ts-expect-error A false flag cannot support a success-only result. + await sentry.listOrganizations({ throwOnError: false }); + // @ts-expect-error Required endpoint options still need the runtime flag. + await sentry.getOrganization({ path }); + // @ts-expect-error Standalone operations have the same guarantee as bound ones. + await listOrganizations(); + // @ts-expect-error Supplying a configured client does not prove it throws. + await listOrganizations({ client: sentry.client }); + + const conditionalBound = await sentry.listOrganizations({ throwOnError }); + const conditionalStandalone = await listOrganizations({ client: sentry.client, throwOnError }); + // @ts-expect-error A boolean flag preserves the possible failure branch. + conditionalBound.data[0]; + // @ts-expect-error The same is true for standalone operations. + conditionalStandalone.data[0]; + void [slug, standaloneSlug]; +} + +async function rawTransportThrowTypes(sentry: SentryClient) { + const url = "/api/0/organizations/"; + // @ts-expect-error Raw methods cannot promise success from only a type argument. + await sentry.client.get({ url }); + // @ts-expect-error Raw requests need the runtime guarantee as well. + await sentry.client.request({ url, method: "GET" }); + const method = await sentry.client.get({ url, throwOnError: true }); + const request = await sentry.client.request({ url, method: "GET", throwOnError: true }); + const methodData: string = method.data; + const requestData: string = request.data; + void [methodData, requestData]; +} + +function forwardOptionalOperation( + sentry: SentryClient, + options: Omit, "client">, +) { + return sentry.listOrganizations(options); +} + +function forwardOptionalArguments( + sentry: SentryClient, + ...args: Parameters> +) { + return sentry.listOrganizations(...args); +} + +async function genericForwardingTypes(sentry: SentryClient, throwOnError: boolean) { + const throwing = await forwardOptionalOperation(sentry, { throwOnError: true }); + const slug: string | undefined = throwing.data[0]?.slug; + const conditional = await forwardOptionalArguments(sentry, { throwOnError }); + // @ts-expect-error Forwarding a boolean flag retains the possible failure branch. + conditional.data[0]; + void slug; +} + +async function mutableClientConfiguration() { + const sentry = createSentryClient({ throwOnError: true }); + sentry.client.setConfig({ throwOnError: false }); + const result = await sentry.listOrganizations(); + // @ts-expect-error Mutable client configuration cannot promise success-only results. + result.data[0]; +} + +async function fieldsContract(sentry: SentryClient) { + // @ts-expect-error Data-only responses are not part of the SDK contract. + createSentryClient({ responseStyle: "data" }); + // @ts-expect-error Mutable configuration has the same restriction. + sentry.client.setConfig({ responseStyle: "data" }); + // @ts-expect-error Bound operations cannot override the fields contract. + await sentry.listOrganizations({ responseStyle: "data" }); + // @ts-expect-error Standalone/injected operations use the same contract. + await listOrganizations({ client: sentry.client, responseStyle: "data" }); + + const result = await sentry.listOrganizations(); + // @ts-expect-error A fetch rejection need not have an HTTP response. + const alwaysResponse: Response = result.response; + if (!("error" in result)) { + const response: Response = result.response; + const slug: string | undefined = result.data[0]?.slug; + void [response, slug]; + } else { + const response: Response | undefined = result.response; + // @ts-expect-error Arbitrary fetch/interceptor errors require narrowing. + result.error.status; + void response; + } + sentry.client.interceptors.error.use((error, response) => { + // @ts-expect-error Fetch rejection hooks have no response. + response.headers.get("retry-after"); + return error; + }); + void alwaysResponse; +} + +void publicFactory; +void operationArguments; +void responseAndThrowingTypes; +void standaloneInteroperability; +void explicitThrowRequiresRuntimeOption; +void rawTransportThrowTypes; +void genericForwardingTypes; +void mutableClientConfiguration; +void fieldsContract; diff --git a/static/packages/api/test/client.test.ts b/static/packages/api/test/client.test.ts new file mode 100644 index 000000000000..c3de7254ca2c --- /dev/null +++ b/static/packages/api/test/client.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, test } from "vitest"; +import { + createSentryClient, + listOrganizations, + narrowError_getProject, + paginateAll_listOrganizations, + SentryApiError, + type Config, +} from "@sentry/api"; +import * as operations from "../src/sdk.gen"; + +// Test handlers take a Request; adapt them to the fetch signature in Config. +const asFetch = (implementation: (request: Request) => Promise) => + ((input: RequestInfo | URL, init?: RequestInit) => + implementation(new Request(input, init))) as NonNullable; + +describe("configured operation methods", () => { + test("streams typed events through the published transport with auth and request interceptors", async () => { + const requests: Request[] = []; + const callbacks: string[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test/prefix", + auth: () => "stream-token", + fetch: asFetch(async request => { + requests.push(request); + return new Response('data: "first"\n\ndata: "second"\n\n', { + headers: { "Content-Type": "text/event-stream" }, + }); + }), + }); + sentry.client.interceptors.request.use(request => { + request.headers.set("X-Context", "stream"); + return request; + }); + const { stream } = await sentry.client.sse.get({ + url: "/api/0/events/", + security: [{ type: "http", scheme: "bearer" }], + onSseEvent: event => { callbacks.push(event.data.toUpperCase()); }, + }); + const values: string[] = []; + for await (const value of stream) values.push(value); + + expect(values).toEqual(["first", "second"]); + expect(callbacks).toEqual(["FIRST", "SECOND"]); + expect(requests).toHaveLength(1); + expect(requests[0]!.url).toBe("https://example.test/prefix/api/0/events/"); + expect(requests[0]!.headers.get("Authorization")).toBe("Bearer stream-token"); + expect(requests[0]!.headers.get("X-Context")).toBe("stream"); + }); + + test("exposes every generated operation through the public package", () => { + const sentry = createSentryClient(); + const { client, fetchPage, paginateAll, paginateUpTo, ...methods } = sentry; + + expect(Object.keys(methods).sort()).toEqual(Object.keys(operations).sort()); + expect(Object.values(methods).every(method => typeof method === "function")).toBe(true); + expect(client).toBeDefined(); + }); + + test("isolates concurrent clients and keeps extracted methods bound after reconfiguration", async () => { + const requests: Request[] = []; + const fetch = asFetch(async request => { + requests.push(request); + return Response.json([]); + }); + const first = createSentryClient({ + baseUrl: "https://first.example.test", + headers: { Authorization: "Bearer first" }, + fetch, + }); + const second = createSentryClient({ + baseUrl: "https://second.example.test", + headers: { Authorization: "Bearer second" }, + fetch, + }); + first.client.interceptors.request.use(request => { + request.headers.set("X-Context", "first"); + return request; + }); + + await Promise.all([first.listOrganizations(), second.listOrganizations()]); + const { listOrganizations: detached } = first; + first.client.setConfig({ headers: { Authorization: "Bearer refreshed" } }); + await detached(); + await second.listOrganizations(); + + const observed = requests.map(request => [ + request.url, + request.headers.get("Authorization"), + request.headers.get("X-Context"), + ]); + // Interceptors can change which concurrent request reaches fetch first. + expect(observed.slice(0, 2).sort()).toEqual([ + ["https://first.example.test/api/0/organizations/", "Bearer first", "first"], + ["https://second.example.test/api/0/organizations/", "Bearer second", null], + ]); + expect(observed.slice(2)).toEqual([ + ["https://first.example.test/api/0/organizations/", "Bearer refreshed", "first"], + ["https://second.example.test/api/0/organizations/", "Bearer second", null], + ]); + }); + + test("delegates optional arguments, required paths, and mutation serialization", async () => { + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + requests.push(request); + return Response.json({ slug: "example", title: "Overview" }); + }), + }); + const options = { + path: { organization_id_or_slug: "example" }, + body: { title: "Overview" }, + }; + + await sentry.listOrganizations(); + const organization = await sentry.getOrganization({ path: options.path }); + await sentry.createOrganizationDashboard(options); + + expect(organization.data?.slug).toBe("example"); + expect(requests.map(request => [request.method, request.url])).toEqual([ + ["GET", "https://example.test/api/0/organizations/"], + ["GET", "https://example.test/api/0/organizations/example/"], + ["POST", "https://example.test/api/0/organizations/example/dashboards/"], + ]); + expect(requests[2]!.headers.get("Content-Type")).toBe("application/json"); + expect(await requests[2]!.json()).toEqual({ title: "Overview" }); + expect(options).toEqual({ + path: { organization_id_or_slug: "example" }, + body: { title: "Overview" }, + }); + }); + + test("keeps per-call transport, host, and header overrides out of instance configuration", async () => { + const requests: Array<[string, string, string | null]> = []; + const capture = (transport: string) => asFetch(async request => { + requests.push([transport, request.url, request.headers.get("Authorization")]); + return Response.json([]); + }); + const sentry = createSentryClient({ + baseUrl: "https://configured.example.test", + headers: { Authorization: "Bearer configured" }, + fetch: capture("configured"), + }); + + await sentry.listOrganizations({ + baseUrl: "https://override.example.test", + headers: { Authorization: "Bearer override" }, + fetch: capture("override"), + }); + await sentry.listOrganizations(); + + expect(requests).toEqual([ + ["override", "https://override.example.test/api/0/organizations/", "Bearer override"], + ["configured", "https://configured.example.test/api/0/organizations/", "Bearer configured"], + ]); + }); + + test("keeps the bound transport authoritative even for options carrying another client", async () => { + const requests: string[] = []; + const fetch = asFetch(async request => { + requests.push(request.url); + return Response.json([]); + }); + const sentry = createSentryClient({ baseUrl: "https://bound.example.test", fetch }); + const other = createSentryClient({ baseUrl: "https://other.example.test", fetch }); + // Structural typing or JavaScript can pass extra fields despite the public + // signature omitting client. They must not redirect a bound operation. + const options = { client: other.client, query: { owner: true } }; + + await sentry.listOrganizations(options); + + expect(requests).toEqual(["https://bound.example.test/api/0/organizations/?owner=true"]); + expect(options.client).toBe(other.client); + }); + + test("forwards metadata and cancellation and preserves response headers", async () => { + const controller = new AbortController(); + const requests: Request[] = []; + let interceptedMetadata: unknown; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + requests.push(request); + return Response.json([], { headers: { "X-Sentry-Direct-Hit": "1" } }); + }), + }); + sentry.client.interceptors.request.use((request, options) => { + // Generated interceptor options currently omit the operation's meta type. + interceptedMetadata = Reflect.get(options, "meta"); + return request; + }); + const meta = { source: "test" }; + + const result = await sentry.listOrganizations({ meta, signal: controller.signal }); + controller.abort(); + + expect(interceptedMetadata).toBe(meta); + expect(requests[0]!.signal.aborted).toBe(true); + expect(result.response?.headers.get("X-Sentry-Direct-Hit")).toBe("1"); + }); + + test("preserves returning and throwing error policies", async () => { + const error = { detail: "Invalid credentials" }; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => Response.json(error, { status: 401 })), + }); + + const result = await sentry.listOrganizations(); + expect(result.error).toBeInstanceOf(SentryApiError); + if (!(result.error instanceof SentryApiError)) throw result.error; + expect(result.error.body).toEqual(error); + expect(result.response?.status).toBe(401); + await expect(sentry.listOrganizations({ throwOnError: true })).rejects.toMatchObject({ + name: "SentryApiError", status: 401, body: error, + }); + + sentry.client.setConfig({ throwOnError: true }); + const returning = await sentry.listOrganizations({ throwOnError: false }); + expect(returning.error).toBeInstanceOf(SentryApiError); + if (!(returning.error instanceof SentryApiError)) throw returning.error; + expect(returning.error.body).toEqual(error); + }); + + test("shares its raw client with existing named operations and helpers", async () => { + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + headers: { Authorization: "Bearer configured" }, + fetch: asFetch(async request => { + requests.push(request); + const url = new URL(request.url); + if (url.pathname.startsWith("/api/0/projects/")) { + return Response.json({ detail: "Forbidden" }, { status: 403 }); + } + return url.searchParams.has("cursor") + ? Response.json([{ slug: "second" }]) + : Response.json([{ slug: "first" }], { + headers: { + Link: '; rel="next"; results="true"; cursor="next"', + }, + }); + }), + }); + + const named = await listOrganizations({ client: sentry.client }); + const all = await paginateAll_listOrganizations({ client: sentry.client }); + const narrowed = await narrowError_getProject({ + client: sentry.client, + path: { organization_id_or_slug: "example", project_id_or_slug: "project" }, + }); + + expect(named.data?.map(organization => organization.slug)).toEqual(["first"]); + expect(all.data.map(organization => organization.slug)).toEqual(["first", "second"]); + expect(narrowed.ok).toBe(false); + if (!narrowed.ok) { + expect(narrowed.error).toBeInstanceOf(SentryApiError); + expect(narrowed.error.status).toBe(403); + } + expect(requests).toHaveLength(4); + expect(requests.every(request => request.headers.get("Authorization") === "Bearer configured")).toBe(true); + expect(requests.every(request => new URL(request.url).origin === "https://example.test")).toBe(true); + }); +}); diff --git a/static/packages/api/test/descriptions.test.ts b/static/packages/api/test/descriptions.test.ts new file mode 100644 index 000000000000..16a867d17151 --- /dev/null +++ b/static/packages/api/test/descriptions.test.ts @@ -0,0 +1,24 @@ +import * as v from "valibot"; +import { describe, expect, test } from "vitest"; +import { vDeployResponse } from "../src/valibot"; +import { zDeployResponse } from "../src/zod"; + +describe("validator descriptions", () => { + test("Valibot fields carry their OpenAPI description as a description action", () => { + expect(v.getDescription(vDeployResponse.entries.id)).toEqual(expect.any(String)); + }); + + test("nullable fields keep their description inside the nullable wrapper", () => { + expect(v.getDescription(vDeployResponse.entries.name.wrapped)).toEqual(expect.any(String)); + expect(zDeployResponse.shape.name.unwrap().description).toEqual(expect.any(String)); + }); + + test("Valibot top-level schemas stay unpiped for object methods", () => { + expect("pipe" in vDeployResponse).toBe(false); + expect(v.partial(vDeployResponse).entries.id.type).toBe("optional"); + }); + + test("Zod fields carry their OpenAPI description", () => { + expect(zDeployResponse.shape.id.description).toEqual(expect.any(String)); + }); +}); diff --git a/static/packages/api/test/generated-params.test.ts b/static/packages/api/test/generated-params.test.ts new file mode 100644 index 000000000000..daedc4408c14 --- /dev/null +++ b/static/packages/api/test/generated-params.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; +import { buildClientParams } from "../src/core/params.gen"; + +describe("generated parameter security (GHSA-hhx9-57xq-r5rw)", () => { + for (const slot of ["body", "headers", "path", "query"] as const) { + test(`does not inherit injected properties in ${slot} parameters`, () => { + const injected = { isAdmin: true }; + const params = buildClientParams( + [{ legitimate: "value", [`$${slot}___proto__`]: injected }], + [{ args: [{ in: slot, key: "legitimate" }] }], + ); + const values = params[slot] as Record; + + expect(values.legitimate).toBe("value"); + expect(values.isAdmin).toBeUndefined(); + expect(Object.getPrototypeOf(values)).not.toBe(injected); + const enumerated = []; + for (const key in values) enumerated.push(key); + expect(enumerated).not.toContain("isAdmin"); + }); + } +}); diff --git a/static/packages/api/test/generated-source.test.ts b/static/packages/api/test/generated-source.test.ts new file mode 100644 index 000000000000..368e76429f0f --- /dev/null +++ b/static/packages/api/test/generated-source.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { edit } from "../lib/generated-source.mjs"; + +describe("edit", () => { + it("applies replacements given in any order", () => { + const source = "const a = 1; const b = 2;"; + expect(edit(source, [ + { start: 19, end: 20, text: "y" }, + { start: 6, end: 7, text: "x" }, + ])).toBe("const x = 1; const y = 2;"); + }); + + it("puts insertions at the same position in reverse order", () => { + expect(edit("ab", [ + { start: 1, end: 1, text: "1" }, + { start: 1, end: 1, text: "2" }, + ])).toBe("a21b"); + }); + + it("rejects overlapping replacements", () => { + expect(() => edit("abcdef", [ + { start: 1, end: 4, text: "" }, + { start: 3, end: 5, text: "" }, + ])).toThrow(/Overlapping edits/); + }); +}); diff --git a/static/packages/api/test/normalize-spec.test.ts b/static/packages/api/test/normalize-spec.test.ts new file mode 100644 index 000000000000..fcf608b134d7 --- /dev/null +++ b/static/packages/api/test/normalize-spec.test.ts @@ -0,0 +1,461 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeOperationId, + normalizeSpec, + parsePath, + singularize, +} from "../lib/normalize-spec.mjs"; +import { writeFileSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// --------------------------------------------------------------------------- +// singularize +// --------------------------------------------------------------------------- + +describe("singularize", () => { + it("removes trailing s for regular plurals", () => { + expect(singularize("issues")).toBe("issue"); + expect(singularize("dashboards")).toBe("dashboard"); + expect(singularize("teams")).toBe("team"); + expect(singularize("projects")).toBe("project"); + expect(singularize("replays")).toBe("replay"); + }); + + it("handles -ies plurals", () => { + expect(singularize("activities")).toBe("activity"); + expect(singularize("queries")).toBe("query"); + expect(singularize("entries")).toBe("entry"); + }); + + it("uses the irregular lookup table", () => { + expect(singularize("aliases")).toBe("alias"); + expect(singularize("statuses")).toBe("status"); + expect(singularize("checkins")).toBe("checkin"); + }); + + it("handles -shes, -ches, -xes, -zes plurals", () => { + expect(singularize("hashes")).toBe("hash"); + expect(singularize("crashes")).toBe("crash"); + expect(singularize("matches")).toBe("match"); + expect(singularize("boxes")).toBe("box"); + expect(singularize("indexes")).toBe("index"); + expect(singularize("buzzes")).toBe("buzz"); + }); + + it("handles -sses plurals (classes → class)", () => { + expect(singularize("classes")).toBe("class"); + expect(singularize("processes")).toBe("process"); + expect(singularize("addresses")).toBe("address"); + }); + + it("correctly singularizes releases (regression: -ses rule must not apply)", () => { + expect(singularize("releases")).toBe("release"); + }); + + it("leaves words ending in -ss, -us, -is, -as untouched", () => { + expect(singularize("class")).toBe("class"); + expect(singularize("status")).toBe("status"); + expect(singularize("analysis")).toBe("analysis"); + expect(singularize("alias")).toBe("alias"); + expect(singularize("atlas")).toBe("atlas"); + }); + + it("leaves already-singular words untouched", () => { + expect(singularize("event")).toBe("event"); + expect(singularize("monitor")).toBe("monitor"); + }); + + it("is idempotent — applying twice produces the same result", () => { + for (const w of [ + "issues", "teams", "releases", "queries", "activities", + "aliases", "hashes", "classes", "boxes", "processes", + ]) { + expect(singularize(singularize(w))).toBe(singularize(w)); + } + }); + + it("never returns an empty string", () => { + // Single-char input ending in s would produce "" without the stem guard. + expect(singularize("s")).toBe("s"); + expect(singularize("")).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// parsePath +// --------------------------------------------------------------------------- + +describe("parsePath", () => { + it("strips /api/0/ prefix and trailing slash", () => { + const segs = parsePath("/api/0/organizations/"); + expect(segs).toEqual([ + { value: "organizations", singularValue: "organization", isParam: false, followedByParam: false }, + ]); + }); + + it("marks path params correctly", () => { + const segs = parsePath("/api/0/organizations/{org}/issues/{id}/"); + expect(segs.map((s) => s.isParam)).toEqual([false, true, false, true]); + expect(segs.map((s) => s.followedByParam)).toEqual([true, false, true, false]); + }); + + it("converts kebab-case to camelCase", () => { + const segs = parsePath("/api/0/organizations/{org}/events-timeseries/"); + const values = segs.filter((s) => !s.isParam).map((s) => s.value); + expect(values).toEqual(["organizations", "eventsTimeseries"]); + }); + + it("handles uppercase letters after a hyphen", () => { + const segs = parsePath("/api/0/foo-Bar/"); + expect(segs[0]?.value).toBe("fooBar"); + }); + + it("converts underscores to camelCase (e.g. stats_v2 → statsV2)", () => { + const segs = parsePath("/api/0/organizations/{org}/stats_v2/"); + const statics = segs.filter((s) => !s.isParam); + expect(statics.at(-1)?.value).toBe("statsV2"); + }); + + it("singularValue singularizes the last word of a compound segment", () => { + const segs = parsePath( + "/api/0/organizations/{org}/release-threshold-statuses/{id}/", + ); + const statics = segs.filter((s) => !s.isParam); + // "statuses" (last word) hits IRREGULAR → "status"; full camelCase result: + expect(statics.at(-1)?.singularValue).toBe("releaseThresholdStatus"); + // value keeps the plural form + expect(statics.at(-1)?.value).toBe("releaseThresholdStatuses"); + }); + + it("handles consecutive separators without crashing", () => { + // "foo--bar" and "a__b" must not crash on the empty word between separators. + // Both value and singularValue go through the same split-filter-join path. + const segs = parsePath("/api/0/foo--bar/a__b/"); + expect(segs[0]?.value).toBe("fooBar"); + expect(segs[0]?.singularValue).toBe("fooBar"); + expect(segs[1]?.value).toBe("aB"); + }); + + it("drops empty segments from leading slashes (non-/api/0/ paths)", () => { + const segs = parsePath("/custom/resources/"); + expect(segs.every((s) => s.value.length > 0)).toBe(true); + expect(segs.map((s) => s.value)).toEqual(["custom", "resources"]); + }); + + it("returns empty array for a path of only params", () => { + // parsePath itself never throws — normalizeOperationId validates the result + const segs = parsePath("/api/0/{a}/{b}/"); + expect(segs.every((s) => s.isParam)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// normalizeOperationId +// --------------------------------------------------------------------------- + +describe("normalizeOperationId", () => { + it("GET collection → list", () => { + expect(normalizeOperationId("get", "/api/0/organizations/")).toBe( + "listOrganizations", + ); + expect( + normalizeOperationId("get", "/api/0/organizations/{org}/issues/"), + ).toBe("listOrganizationIssues"); + expect( + normalizeOperationId( + "get", + "/api/0/organizations/{org}/issues/{id}/events/", + ), + ).toBe("listOrganizationIssueEvents"); + }); + + it("GET detail (ends in param) → get + singularized", () => { + expect( + normalizeOperationId("get", "/api/0/organizations/{org}/"), + ).toBe("getOrganization"); + expect( + normalizeOperationId("get", "/api/0/organizations/{org}/issues/{id}/"), + ).toBe("getOrganizationIssue"); + expect( + normalizeOperationId( + "get", + "/api/0/organizations/{org}/dashboards/{id}/", + ), + ).toBe("getOrganizationDashboard"); + }); + + it("POST → create + singularized last segment", () => { + expect( + normalizeOperationId("post", "/api/0/organizations/{org}/dashboards/"), + ).toBe("createOrganizationDashboard"); + expect( + normalizeOperationId("post", "/api/0/organizations/{org}/teams/"), + ).toBe("createOrganizationTeam"); + }); + + it("PUT/PATCH → update", () => { + expect( + normalizeOperationId("put", "/api/0/organizations/{org}/dashboards/{id}/"), + ).toBe("updateOrganizationDashboard"); + expect( + normalizeOperationId("patch", "/api/0/organizations/{org}/issues/{id}/"), + ).toBe("updateOrganizationIssue"); + }); + + it("DELETE detail → singular", () => { + expect( + normalizeOperationId( + "delete", + "/api/0/organizations/{org}/dashboards/{id}/", + ), + ).toBe("deleteOrganizationDashboard"); + }); + + it("DELETE collection (bulk) → plural", () => { + expect( + normalizeOperationId("delete", "/api/0/organizations/{org}/detectors/"), + ).toBe("deleteOrganizationDetectors"); + }); + + it("converts kebab-case path segments to camelCase", () => { + expect( + normalizeOperationId( + "get", + "/api/0/organizations/{org}/events-timeseries/", + ), + ).toBe("listOrganizationEventsTimeseries"); + expect( + normalizeOperationId( + "get", + "/api/0/organizations/{org}/release-threshold-statuses/", + ), + ).toBe("listOrganizationReleaseThresholdStatuses"); + }); + + it("handles uppercase letters after a hyphen", () => { + expect(normalizeOperationId("get", "/api/0/foo-Bar/")).toBe("listFooBar"); + }); + + it("converts underscores in path segments to camelCase", () => { + expect(normalizeOperationId("get", "/api/0/organizations/{org}/stats_v2/")).toBe( + "listOrganizationStatsV2", + ); + expect(normalizeOperationId("get", "/api/0/organizations/{org}/relay_usage/")).toBe( + "listOrganizationRelayUsage", + ); + }); + + it("singularizes compound segments via last-word rule (regression)", () => { + // GET detail — last word "statuses" hits IRREGULAR → "status" + expect( + normalizeOperationId( + "get", + "/api/0/organizations/{org}/release-threshold-statuses/{id}/", + ), + ).toBe("getOrganizationReleaseThresholdStatus"); // not "...Statuse" + + // POST to collection — last word "issues" → "issue" + expect( + normalizeOperationId( + "post", + "/api/0/organizations/{org}/external-issues/", + ), + ).toBe("createOrganizationExternalIssue"); + }); + + it("handles multi-level nesting", () => { + expect( + normalizeOperationId( + "get", + "/api/0/projects/{org}/{project}/keys/", + ), + ).toBe("listProjectKeys"); + expect( + normalizeOperationId( + "get", + "/api/0/projects/{org}/{project}/keys/{id}/", + ), + ).toBe("getProjectKey"); + }); + + it("singularizes parent segments that precede a path param", () => { + expect( + normalizeOperationId("get", "/api/0/organizations/{org}/issues/"), + ).toBe("listOrganizationIssues"); + expect( + normalizeOperationId( + "get", + "/api/0/organizations/{org}/issues/{id}/events/", + ), + ).toBe("listOrganizationIssueEvents"); + }); + + it("handles consecutive params (/projects/{org}/{project}/)", () => { + expect( + normalizeOperationId("get", "/api/0/projects/{org}/{project}/"), + ).toBe("getProject"); + }); + + it("handles paths not under /api/0/ prefix", () => { + expect(normalizeOperationId("get", "/custom/resources/")).toBe( + "listCustomResources", + ); + }); + + it("trailing slash presence does not affect the result", () => { + expect(normalizeOperationId("get", "/api/0/organizations")).toBe( + "listOrganizations", + ); + expect(normalizeOperationId("get", "/api/0/organizations/")).toBe( + "listOrganizations", + ); + }); + + it("throws when path has no static segments", () => { + expect(() => normalizeOperationId("get", "/api/0/{id}/")).toThrow( + /no .*usable static segments/, + ); + }); + + it("throws on degenerate all-param path", () => { + expect(() => normalizeOperationId("get", "/api/0/{a}/{b}/")).toThrow( + /no .*usable static segments/, + ); + }); + + it("throws when the only static segment is separators (empty resource)", () => { + // splitWords("---") → [] → camelJoin → "", so statics.length is 1 but the + // assembled resource is empty. The guard must catch this, not crash on + // resource[0].toUpperCase(). + expect(() => normalizeOperationId("get", "/api/0/---/")).toThrow( + /no .*usable static segments/, + ); + expect(() => normalizeOperationId("get", "/api/0/___/")).toThrow( + /no .*usable static segments/, + ); + }); + + it("skips interior all-separator segments instead of crashing", () => { + // An interior segment of pure separators camelCases to "". It must be + // dropped before assembly, not crash camelJoin on p[0].toUpperCase(). + expect(normalizeOperationId("get", "/api/0/organizations/---/issues/")).toBe( + "listOrganizationsIssues", + ); + expect(normalizeOperationId("get", "/api/0/orgs/___/issues/")).toBe( + "listOrgsIssues", + ); + }); +}); + +// --------------------------------------------------------------------------- +// normalizeSpec — collision detection +// --------------------------------------------------------------------------- + +describe("normalizeSpec collision detection", () => { + function writeSpec(paths: Record>) { + const tmp = join(tmpdir(), `test-spec-${Date.now()}.json`); + writeFileSync(tmp, JSON.stringify({ paths })); + return tmp; + } + + it("throws when two different paths normalize to the same name", () => { + // /api/0/x-y/ and /api/0/xY/ both produce "listXY" after kebab→camelCase. + const input = writeSpec({ + "/api/0/x-y/": { get: { operationId: "List X Y" } }, + "/api/0/xY/": { get: { operationId: "List XY" } }, + }); + const output = join(tmpdir(), `out-${Date.now()}.json`); + expect(() => normalizeSpec(input, output)).toThrow(/collision/); + unlinkSync(input); + try { unlinkSync(output); } catch {} + }); + + it("throws when a clean identifier collides with a path-derived one", () => { + // Pre-existing clean IDs must be tracked: if path A has operationId + // "listOrganizationIssues" (clean) and path B sentence-style normalizes to + // the same name, that's a collision even though B's ID was a sentence. + const input = writeSpec({ + "/api/0/something/": { + // Manually set to the same name that the other path would generate. + get: { operationId: "listOrganizationIssues" }, + }, + "/api/0/organizations/{org}/issues/": { + // Sentence-style → normalizes to "listOrganizationIssues". + get: { operationId: "List An Organization's Issues" }, + }, + }); + const output = join(tmpdir(), `out-${Date.now()}.json`); + expect(() => normalizeSpec(input, output)).toThrow(/collision/); + unlinkSync(input); + try { unlinkSync(output); } catch {} + }); + + it("does not throw when operationIds are already clean identifiers", () => { + const input = writeSpec({ + "/api/0/organizations/": { + get: { operationId: "listOrganizations" }, // already clean — skipped + }, + }); + const output = join(tmpdir(), `out-${Date.now()}.json`); + expect(() => normalizeSpec(input, output)).not.toThrow(); + unlinkSync(input); + try { unlinkSync(output); } catch {} + }); + + it("rewrites sentence-style operationIds and leaves clean ones alone", () => { + const input = writeSpec({ + "/api/0/organizations/": { + get: { operationId: "List Your Organizations" }, + post: { operationId: "createOrganization" }, // already clean + }, + }); + const output = join(tmpdir(), `out-${Date.now()}.json`); + normalizeSpec(input, output); + const result = JSON.parse(readFileSync(output, "utf8")); + expect(result.paths["/api/0/organizations/"].get.operationId).toBe( + "listOrganizations", + ); + expect(result.paths["/api/0/organizations/"].post.operationId).toBe( + "createOrganization", // untouched + ); + unlinkSync(input); + unlinkSync(output); + }); +}); + +describe("normalizeSpec int64 formats", () => { + it("drops int64 only from integers bounded within the safe integer range", () => { + const schemas = { + uint32: { type: "integer", format: "int64", minimum: 0, maximum: 4294967295 }, + unbounded: { type: "integer", format: "int64" }, + tooLarge: { type: "integer", format: "int64", minimum: 0, maximum: 9223372036854775807 }, + }; + const input = join(tmpdir(), `test-spec-${Date.now()}.json`); + writeFileSync(input, JSON.stringify({ paths: {}, components: { schemas } })); + const output = join(tmpdir(), `out-${Date.now()}.json`); + normalizeSpec(input, output); + const result = JSON.parse(readFileSync(output, "utf8")).components.schemas; + expect(result.uint32).not.toHaveProperty("format"); + expect(result.unbounded.format).toBe("int64"); + expect(result.tooLarge.format).toBe("int64"); + unlinkSync(input); + unlinkSync(output); + }); + + it("drops every uint64 format", () => { + const schemas = { + unbounded: { type: "integer", format: "uint64", minimum: 0 }, + int32: { type: "integer", format: "int32", minimum: 0 }, + }; + const input = join(tmpdir(), `test-spec-${Date.now()}.json`); + writeFileSync(input, JSON.stringify({ paths: {}, components: { schemas } })); + const output = join(tmpdir(), `out-${Date.now()}.json`); + normalizeSpec(input, output); + const result = JSON.parse(readFileSync(output, "utf8")).components.schemas; + expect(result.unbounded).toEqual({ type: "integer", minimum: 0 }); + expect(result.int32.format).toBe("int32"); + unlinkSync(input); + unlinkSync(output); + }); +}); diff --git a/static/packages/api/test/operation-types.ts b/static/packages/api/test/operation-types.ts new file mode 100644 index 000000000000..5c65c62970a2 --- /dev/null +++ b/static/packages/api/test/operation-types.ts @@ -0,0 +1,18 @@ +/** Public discovery types are readonly, including nested parameter metadata. */ +import { operations, type ApiOperation, type ApiQueryParameter } from "@sentry/api/operations"; + +const operation: ApiOperation | undefined = operations[0]; +const parameter: ApiQueryParameter | undefined = operation?.queryParameters[0]; +const value: string | number | boolean | null | undefined = parameter?.enum?.[0]; + +// @ts-expect-error Consumers must not modify the shared catalog. +operations.push(operation!); +if (operation) { + // @ts-expect-error Operation identities are immutable. + operation.functionName = "changed"; + // @ts-expect-error Nested discovery metadata is readonly too. + operation.queryParameters.push({ name: "added", required: false }); +} +// @ts-expect-error Enum values must not be changed by consumers. +parameter?.enum?.push("added"); +void value; diff --git a/static/packages/api/test/operations.test.ts b/static/packages/api/test/operations.test.ts new file mode 100644 index 000000000000..2f4c07055e63 --- /dev/null +++ b/static/packages/api/test/operations.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "vitest"; +import { createClient } from "@hey-api/openapi-ts"; +import { operations } from "@sentry/api/operations"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { extractOperations } from "../lib/operation-catalog.mjs"; +import { normalizeSpec } from "../lib/normalize-spec.mjs"; +import * as sdk from "../src/sdk.gen"; + +const eventsPath = "/api/0/organizations/{organization_id_or_slug}/events/"; + +// The events endpoint demonstrates both dataset discovery and repeated fields. +const fixture = (datasets = ["logs", "spans"]) => ({ + openapi: "3.0.3", + info: { title: "Operation discovery", version: "1" }, + paths: { + [eventsPath]: { + parameters: [ + { name: "organization_id_or_slug", in: "path", required: true, schema: { type: "string" } }, + { name: "dataset", in: "query", schema: { type: "string", enum: ["logs"] } }, + { name: "field", in: "query", schema: { type: "array", items: { type: "string", enum: ["id", "timestamp"] } } }, + { name: "Accept", in: "header", schema: { type: "string" } }, + ], + get: { + operationId: "List an Organization's Events", + summary: "Query events", + description: "Select a dataset and the fields to return.", + tags: ["Events"], + parameters: [{ name: "dataset", in: "query", required: true, description: "Dataset to query", schema: { type: "string", enum: datasets } }], + responses: { "200": { description: "Events" } }, + }, + }, + }, +}); + +async function generate(spec: { paths: object }) { + const directory = mkdtempSync(join(tmpdir(), "api-operations-")); + try { + const original = join(directory, "original.json"); + const normalized = join(directory, "normalized.json"); + writeFileSync(original, JSON.stringify(spec)); + normalizeSpec(original, normalized); + await createClient({ + input: normalized, + output: join(directory, "sdk"), + plugins: ["@hey-api/typescript", "@hey-api/sdk"], + }); + const source = readFileSync(join(directory, "sdk", "sdk.gen.ts"), "utf8"); + return { source, catalog: extractOperations(spec, source) }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("operation discovery", () => { + test("preserves original metadata while following SDK naming, inheritance and API growth", async () => { + const initial = await generate(fixture()); + expect(initial.catalog).toEqual([{ + functionName: "listOrganizationEvents", + operationId: "List an Organization's Events", + method: "GET", + path: eventsPath, + summary: "Query events", + description: "Select a dataset and the fields to return.", + tags: ["Events"], + pathParameters: ["organization_id_or_slug"], + queryParameters: [ + { name: "dataset", required: true, description: "Dataset to query", type: "string", enum: ["logs", "spans"] }, + { name: "field", required: false, type: "array", enum: ["id", "timestamp"] }, + ], + deprecated: false, + experimental: false, + idempotent: true, + }]); + + const next = fixture(["logs", "spans", "new_dataset"]); + const newPath = "/api/0/new-capability/"; + const grown = await generate({ + ...next, + paths: { + ...next.paths, + [newPath]: { + post: { operationId: "queryNewCapability", deprecated: true, responses: { "200": { description: "Results" } } }, + }, + }, + }); + expect(grown.catalog).toHaveLength(2); + expect(grown.catalog.find(operation => operation.path === newPath)).toMatchObject({ + functionName: "queryNewCapability", operationId: "queryNewCapability", method: "POST", deprecated: true, + idempotent: false, + }); + expect(grown.catalog.find(operation => operation.path === eventsPath)?.queryParameters[0]?.enum) + .toEqual(["logs", "spans", "new_dataset"]); + + // Never silently publish a partial catalog or select one of two route matches. + expect(() => extractOperations(next, grown.source)).toThrow("SDK operations missing from OpenAPI"); + expect(() => extractOperations({ paths: { ...next.paths, "/missing/": { get: {} } } }, initial.source)) + .toThrow("Missing SDK operation for GET /missing/"); + expect(() => extractOperations(next, initial.source + initial.source.replaceAll("listOrganizationEvents", "otherName"))) + .toThrow(`Multiple SDK operations for GET ${eventsPath}`); + }); + + test("marks non-safe methods idempotent only when Sentry declares it", async () => { + const response = { responses: { "200": { description: "OK" } } }; + const { catalog } = await generate({ + ...fixture(), + paths: { + "/api/0/assemble/": { post: { operationId: "assemble", ...response, "x-sentry-idempotent": true } }, + "/api/0/things/": { + post: { operationId: "createThing", ...response }, + put: { operationId: "updateThing", ...response }, + delete: { operationId: "deleteThing", ...response }, + }, + }, + }); + expect(Object.fromEntries(catalog.map(operation => [operation.functionName, operation.idempotent]))).toEqual({ + assemble: true, createThing: false, updateThing: false, deleteThing: false, + }); + }); + + test("marks operations experimental only when Sentry declares it", async () => { + const response = { responses: { "200": { description: "OK" } } }; + const { catalog } = await generate({ + ...fixture(), + paths: { + "/api/0/things/": { + get: { operationId: "listThings", ...response, "x-sentry-experimental": true }, + post: { operationId: "createThing", ...response }, + }, + }, + }); + expect(Object.fromEntries(catalog.map(operation => [operation.functionName, operation.experimental]))).toEqual({ + listThings: true, createThing: false, + }); + }); + + test("flags the same released operations that the SDK documents as experimental", () => { + const experimental = operations.filter(operation => operation.experimental); + expect(experimental.length).toBeGreaterThan(0); + // Sentry's schema build adds this note to every operation it marks with x-sentry-experimental. + const documented = operations.filter(operation => operation.description.startsWith("**Experimental:**")); + expect(experimental.map(operation => operation.functionName)).toEqual(documented.map(operation => operation.functionName)); + const source = readFileSync(new URL("../src/sdk.gen.ts", import.meta.url), "utf8"); + expect(source.match(/\*\*Experimental:\*\*/g)).toHaveLength(experimental.length); + }); + + test("ships one catalog entry for every actual standalone SDK export", () => { + expect(operations.map(operation => operation.functionName).sort()).toEqual(Object.keys(sdk).sort()); + const original = JSON.parse(readFileSync(new URL("../spec/openapi-derefed.json", import.meta.url), "utf8")); + const source = readFileSync(new URL("../src/sdk.gen.ts", import.meta.url), "utf8"); + expect(operations).toEqual(extractOperations(original, source)); + }); +}); diff --git a/static/packages/api/test/package.test.ts b/static/packages/api/test/package.test.ts new file mode 100644 index 000000000000..76905647a376 --- /dev/null +++ b/static/packages/api/test/package.test.ts @@ -0,0 +1,190 @@ +import { build } from "esbuild"; +import { describe, expect, test } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import packageJson from "../package.json"; +import { installPackedPackage, packageRoot } from "./packed-package.ts"; + +const readBundle = (name: string) => + readFile(new URL(`../dist/${name}.js`, import.meta.url), "utf8"); + +describe("package entry points", () => { + test("keeps validator and source-analysis peers optional", () => { + expect(packageJson.peerDependencies).toEqual({ + typescript: "*", + valibot: "*", + zod: "^4.6.6", + }); + expect(packageJson.peerDependenciesMeta).toEqual({ + typescript: { optional: true }, + valibot: { optional: true }, + zod: { optional: true }, + }); + }); + + test("keeps the root bundle independent from optional tools and validators", async () => { + const root = await readBundle("index"); + expect(root).not.toMatch(/from ["']typescript["']/); + expect(root).not.toMatch(/from ["']valibot["']/); + expect(root).not.toMatch(/from ["']zod["']/); + }); + + test("isolates optional tools and validators to their own entry points", async () => { + const [valibot, zod, analysis] = await Promise.all([ + readBundle("valibot"), + readBundle("zod"), + readBundle("source-analysis"), + ]); + expect(valibot).toMatch(/from ["']valibot["']/); + expect(valibot).not.toMatch(/from ["']zod["']/); + expect(zod).toMatch(/from ["']zod["']/); + expect(zod).not.toMatch(/from ["']valibot["']/); + expect(analysis).toMatch(/from ["']typescript["']/); + expect(analysis).not.toMatch(/from ["'](?:valibot|zod)["']/); + }); + + test("lets bundlers drop unused schemas and the unused default client", async () => { + const directory = mkdtempSync(join(tmpdir(), "api-tree-shaking-")); + try { + const entry = join(directory, "entry.ts"); + const dist = fileURLToPath(new URL("../dist/", import.meta.url)); + writeFileSync(entry, ` + import { vGetOrganizationResponse } from ${JSON.stringify(join(dist, "valibot.js"))}; + import { zGetOrganizationResponse } from ${JSON.stringify(join(dist, "zod.js"))}; + import { SentryApiError } from ${JSON.stringify(join(dist, "index.js"))}; + console.log(vGetOrganizationResponse, zGetOrganizationResponse, SentryApiError); + `); + const result = await build({ + entryPoints: [entry], bundle: true, format: "esm", platform: "node", + external: ["valibot", "zod"], write: false, logLevel: "silent", + }); + expect(result.errors).toEqual([]); + const output = result.outputFiles[0]!.text; + const kept = (name: string) => output.includes(name); + expect(["vGetOrganizationResponse", "zGetOrganizationResponse", "SentryApiError"].filter(kept)) + .toEqual(["vGetOrganizationResponse", "zGetOrganizationResponse", "SentryApiError"]); + expect(["vListOrganizationsResponse", "zListOrganizationsResponse", "defaultClient", "new Proxy"].filter(kept)) + .toEqual([]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test("packs isolated runtime entries, pagination and build-time analysis", { timeout: 60_000 }, () => { + const root = packageRoot; + const directory = installPackedPackage("api-package-"); + try { + writeFileSync(join(directory, "client.mjs"), ` + import assert from "node:assert/strict"; + const client = await import("@sentry/api"); + const browser = await import("@sentry/api/browser"); + assert.equal(typeof client.createSentryClient, "function"); + assert.equal(typeof browser.createBrowserFetch, "function"); + assert.equal("analyzeSourceApiUsage" in client, false); + const sentry = client.createSentryClient({baseUrl: "https://example.test/prefix"}); + const request = await sentry.client.prepareRequest({ + method: "POST", url: "/api/0/custom/?field=id&field=name&cursor=old#view", + query: {cursor: "opaque:next"}, body: {message: "example"}, + }); + assert.equal(request.url, "https://example.test/prefix/api/0/custom/?field=id&field=name&cursor=opaque%3Anext"); + assert.equal(request.headers.get("content-type"), "application/json"); + assert.deepEqual(await request.clone().json(), {message: "example"}); + const transport = async request => new Response(await request.text(), {status: 422}); + const response = await transport(request); + assert.equal(response.status, 422); + assert.deepEqual(await response.json(), {message: "example"}); + `); + execFileSync("node", ["client.mjs"], { cwd: directory }); + writeFileSync(join(directory, "smoke.mjs"), ` + import assert from "node:assert/strict"; + for (const name of ["fetch", "Request", "Headers"]) { + Object.defineProperty(globalThis, name, { get() { throw new Error("Accessed transport global: " + name); } }); + } + const { operations } = await import("@sentry/api/operations"); + assert(operations.some(operation => operation.functionName === "listOrganizations")); + // The root entry creates its default client on first use, not at import. + const root = await import("@sentry/api"); + assert.equal(typeof root.getOrganization, "function"); + `); + execFileSync("node", ["smoke.mjs"], { cwd: directory }); + + writeFileSync(join(directory, "pagination.mjs"), ` + import assert from "node:assert/strict"; + import { consumePage, createSentryClient } from "@sentry/api"; + const requests = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: async request => { + requests.push(new URL(request.url).searchParams.get("per_page")); + return Response.json([{slug:"one"}, {slug:"two"}, {slug:"three"}]); + }, + }); + const options = {path: {organization_id_or_slug: "example"}, query: {per_page: 3}}; + const first = await sentry.paginateUpTo.listOrganizationProjects(options, {limit: 2}); + const resume = JSON.parse(JSON.stringify(first.continuation)); + const last = await sentry.paginateUpTo.listOrganizationProjects(options, {limit: 1, resume}); + assert.deepEqual([...first.data, ...last.data].map(row => row.slug), ["one", "two", "three"]); + assert.equal(first.nextCursor, undefined); + assert.equal(last.stopReason, "exhausted"); + assert.deepEqual(requests, ["3", "3"]); + assert.deepEqual(consumePage({data: last.data}, {offset: 0}).data, last.data); + `); + execFileSync("node", ["pagination.mjs"], { cwd: directory }); + + writeFileSync(join(directory, "types.ts"), readFileSync(new URL("./operation-types.ts", import.meta.url))); + writeFileSync(join(directory, "analysis-types.ts"), readFileSync(new URL("./source-analysis-types.ts", import.meta.url))); + writeFileSync(join(directory, "tsconfig.json"), JSON.stringify({ + compilerOptions: { module: "NodeNext", strict: true, noEmit: true, types: [], lib: ["ES2022"] }, + files: ["types.ts", "analysis-types.ts"], + })); + execFileSync("node", [join(root, "node_modules", "typescript", "bin", "tsc6"), "-p", directory]); + + writeFileSync(join(directory, "types.ts"), ` + import { consumePage, type PaginationContinuation, type PagePosition, type SentryClient } from "@sentry/api"; + declare const sentry: SentryClient; + const prepared: Promise = sentry.client.prepareRequest({method: "GET", url: "/api/0/custom/"}); + const position: PagePosition = {cursor: "opaque", offset: 2}; + const resume: PaginationContinuation = {...position, requestBudget: 10}; + const rows: string[] = consumePage({data: ["one"]}, position).data; + sentry.paginateUpTo.listOrganizationProjects( + {path: {organization_id_or_slug: "example"}}, {limit: 5, resume}, + ); + `); + writeFileSync(join(directory, "tsconfig.json"), JSON.stringify({ + compilerOptions: { + module: "ESNext", moduleResolution: "bundler", strict: true, + noEmit: true, types: [], lib: ["ES2022", "DOM"], + }, + files: ["types.ts"], + })); + execFileSync("node", [join(root, "node_modules", "typescript", "bin", "tsc6"), "-p", directory]); + + symlinkSync(join(root, "node_modules", "typescript"), join(directory, "node_modules", "typescript"), "dir"); + writeFileSync(join(directory, "analysis.mjs"), ` + import assert from "node:assert/strict"; + import { operations } from "@sentry/api/operations"; + import { analyzeSourceApiUsage } from "@sentry/api/source-analysis"; + const source = 'import { listOrganizationEvents } from "@sentry/api"; export default listOrganizationEvents;'; + const result = analyzeSourceApiUsage(new Map([ + ["logs/index.ts", source], + ["feature.ts", 'import read from "./logs"; export const readLogs = () => read({ path: { organization_id_or_slug: "example-org" }, query: { dataset: "logs", field: ["message"] } });'], + ]), operations, { + queryParameters: ["dataset"], + }); + const usage = result.exports.get("feature.ts").get("readLogs"); + assert.equal(usage.operations[0].functionName, "listOrganizationEvents"); + assert.deepEqual(usage.operations[0].query, { dataset: "logs" }); + assert.deepEqual(usage.methods, ["GET"]); + assert.deepEqual(usage.unresolvedRequests, []); + assert.equal(result.exports.get("logs/index.ts").get("default").operations[0].functionName, "listOrganizationEvents"); + `); + execFileSync("node", ["analysis.mjs"], { cwd: directory }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/static/packages/api/test/packed-package.ts b/static/packages/api/test/packed-package.ts new file mode 100644 index 000000000000..910a228b6499 --- /dev/null +++ b/static/packages/api/test/packed-package.ts @@ -0,0 +1,31 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** The repository root, where the built package lives. */ +export const packageRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); + +/** + * `npm pack` the built package and install the tarball, with its runtime + * dependencies, into a fresh ES module project. The caller removes it. + */ +export function installPackedPackage(prefix: string): string { + const directory = mkdtempSync(join(tmpdir(), prefix)); + const packed = JSON.parse(execFileSync("npm", ["pack", "--json", "--pack-destination", directory], { + cwd: packageRoot, + encoding: "utf8", + })) as { filename: string }[]; + const installed = join(directory, "node_modules", "@sentry", "api"); + mkdirSync(installed, { recursive: true }); + execFileSync("tar", ["-xzf", join(directory, packed[0]!.filename), "-C", installed, "--strip-components=1"]); + const { dependencies } = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { + dependencies: Record; + }; + for (const name of Object.keys(dependencies)) { + symlinkSync(join(packageRoot, "node_modules", name), join(directory, "node_modules", name), "dir"); + } + writeFileSync(join(directory, "package.json"), JSON.stringify({ type: "module" })); + return directory; +} diff --git a/static/packages/api/test/pagination-budgets.test.ts b/static/packages/api/test/pagination-budgets.test.ts new file mode 100644 index 000000000000..5ca368f35298 --- /dev/null +++ b/static/packages/api/test/pagination-budgets.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "vitest"; +import { createSentryClient, paginateUpTo_listOrganizationIssues, SentryApiError, type Config } from "@sentry/api"; + +const asFetch = (implementation: (request: Request) => Promise) => + ((input: RequestInfo | URL, init?: RequestInit) => + implementation(new Request(input, init))) as NonNullable; +const link = (cursor: string) => + `; rel="next"; results="true"; cursor="${cursor}"`; +const rows = (start: number, count: number) => + Array.from({ length: count }, (_, index) => ({ id: String(start + index) })); +const path = { organization_id_or_slug: "example" }; + +describe("collection HTTP budgets", () => { + test("collects 100 + 100 + 50 issues and resumes without gaps through the same transport", async () => { + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://configured.example.test", + headers: { Authorization: "Bearer configured" }, + fetch: asFetch(async request => { + requests.push(request); + const query = new URL(request.url).searchParams; + const size = Number(query.get("limit")); + const start = Number(query.get("cursor")?.split(":")[1] ?? 0); + const end = Math.min(start + size, 400); + return Response.json(rows(start, end - start), { + headers: end < 400 ? { Link: link(`opaque:${end}`) } : {}, + }); + }), + }); + sentry.client.interceptors.request.use(request => { + request.headers.set("X-Context", "pagination"); + return request; + }); + const options = { path, query: { query: "is:unresolved", limit: 100 } }; + const first = await sentry.paginateUpTo.listOrganizationIssues(options, { limit: 250 }); + const second = await paginateUpTo_listOrganizationIssues( + { ...options, client: sentry.client }, + { limit: 250, startCursor: first.nextCursor }, + ); + + expect(first.stopReason).toBe("limit"); + expect(first.nextCursor).toBe("opaque:250"); + expect(second.stopReason).toBe("exhausted"); + expect(second.nextCursor).toBeUndefined(); + expect([...first.data, ...second.data].map(item => item.id)).toEqual(rows(0, 400).map(item => item.id)); + expect(requests.map(request => new URL(request.url).searchParams.get("limit"))) + .toEqual(["100", "100", "50", "100", "100"]); + for (const request of requests) { + const url = new URL(request.url); + expect(url.origin).toBe("https://configured.example.test"); + expect(url.pathname).toBe("/api/0/organizations/example/issues/"); + expect(url.searchParams.get("query")).toBe("is:unresolved"); + expect(url.searchParams.has("per_page")).toBe(false); + expect(request.headers.get("Authorization")).toBe("Bearer configured"); + expect(request.headers.get("X-Context")).toBe("pagination"); + } + expect(options.query.limit).toBe(100); + }); + + test("respects a smaller issue page size and clamps to the verified cap", async () => { + for (const { requested, limit, expected } of [ + { requested: 20, limit: 45, expected: [20, 20, 5] }, + { requested: 1000, limit: 250, expected: [100, 100, 50] }, + ]) { + const sizes: number[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + const size = Number(new URL(request.url).searchParams.get("limit")); + sizes.push(size); + return Response.json(rows(0, size), { headers: { Link: link("next") } }); + }), + }); + const batch = await sentry.paginateUpTo.listProjectIssues( + { path: { ...path, project_id_or_slug: "project" }, query: { limit: requested } }, + { limit }, + ); + expect(sizes).toEqual(expected); + expect(batch.data).toHaveLength(limit); + expect(batch.stopReason).toBe("limit"); + } + }); + + test.each([false, true])("uses per_page for issue events, including full=%s", async full => { + const sizes: number[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + const query = new URL(request.url).searchParams; + expect(query.has("limit")).toBe(false); + expect(query.get("full")).toBe(String(full)); + const size = Number(query.get("per_page")); + sizes.push(size); + return Response.json(rows(0, size), { headers: { Link: link("next") } }); + }), + }); + const batch = await sentry.paginateUpTo.listOrganizationIssueEvents( + { path: { ...path, issue_id: "1" }, query: { full } }, + { limit: 25 }, + ); + expect(sizes).toEqual(full ? [10, 10, 5] : [25]); + expect(batch.data).toHaveLength(25); + expect(batch.nextCursor).toBe("next"); + }); + + test("keeps page size fixed for page-number cursors and suppresses a cursor past trimmed rows", async () => { + const sizes: string[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + const query = new URL(request.url).searchParams; + sizes.push(query.get("per_page")!); + // Backend OffsetPaginator fixtures: changing page 3 to size 50 would + // return 251..300 instead of 201..250 (cursor 100:2:0). + const start = Number(query.get("cursor")?.split(":")[1] ?? 0) * 100; + return Response.json(rows(start, 100), { headers: { Link: link(`100:${start / 100 + 1}:0`) } }); + }), + }); + const batch = await sentry.paginateUpTo.listOrganizationProjects( + { path, query: { per_page: 100 } }, { limit: 250 }, + ); + expect(sizes).toEqual(["100", "100", "100"]); + expect(batch.data.map(item => item.id)).toEqual(rows(0, 250).map(item => item.id)); + expect(batch.stopReason).toBe("limit"); + expect(batch.nextCursor).toBeUndefined(); + }); + + test("leaves undocumented sizing alone and reports overshoot even if JavaScript passes the removed override", async () => { + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + const query = new URL(request.url).searchParams; + expect(query.has("per_page")).toBe(false); + expect(query.has("limit")).toBe(false); + return Response.json(rows(0, 3), { headers: { Link: link("past-discarded") } }); + }), + }); + const options = { limit: 2, keepCursorOnOvershoot: true }; + const batch = await sentry.paginateUpTo.listOrganizationMembers({ path }, options); + expect(batch.data.map(item => item.id)).toEqual(["0", "1"]); + expect(batch.stopReason).toBe("limit"); + expect(batch.nextCursor).toBeUndefined(); + }); + + test("replays the original generated page size when a server ignores it", async () => { + const sizes: string[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + const size = new URL(request.url).searchParams.get("limit")!; + sizes.push(size); + return Response.json(rows(0, 3), { headers: { Link: link("past-discarded") } }); + }), + }); + const batch = await sentry.paginateUpTo.listOrganizationIssues({ path }, { limit: 2 }); + expect(batch.data.map(item => item.id)).toEqual(["0", "1"]); + expect(batch.stopReason).toBe("limit"); + expect(batch.nextCursor).toBeUndefined(); + const resumed = await sentry.paginateUpTo.listOrganizationIssues( + { path }, { limit: 1, resume: batch.continuation }, + ); + expect(resumed.data.map(item => item.id)).toEqual(["2"]); + expect(resumed.nextCursor).toBe("past-discarded"); + expect(sizes).toEqual(["2", "2"]); + }); + + test.each(["paginateAll", "paginateUpTo"] as const)("%s resumes after maxPages and rejects a later HTTP failure", async kind => { + const requests: string[] = []; + let fail = false; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + const cursor = new URL(request.url).searchParams.get("cursor") ?? "0"; + requests.push(cursor); + if (fail) return Response.json({ detail: "Unavailable" }, { status: 503 }); + return Response.json(rows(Number(cursor), 1), { headers: { Link: link(String(Number(cursor) + 1)) } }); + }), + }); + const collect = (cursor?: string) => sentry[kind].listOrganizations({}, { + limit: 10, maxPages: 2, startCursor: cursor, + }); + const first = await collect(); + const second = await collect(first.nextCursor); + expect(first.data.map(item => item.id)).toEqual(["0", "1"]); + expect(second.data.map(item => item.id)).toEqual(["2", "3"]); + expect(first.stopReason).toBe("maxPages"); + expect(second.stopReason).toBe("maxPages"); + expect(first.nextCursor).toBe("2"); + expect(second.nextCursor).toBe("4"); + expect(requests).toEqual(["0", "1", "2", "3"]); + + // A successful first page must not turn a subsequent error into a partial success. + sentry.client.interceptors.response.use(response => { fail = true; return response; }); + const error = await collect(second.nextCursor).catch(error => error); + expect(error).toBeInstanceOf(SentryApiError); + expect(error.status).toBe(503); + expect(requests.slice(4)).toEqual(["4", "5"]); + }); +}); diff --git a/static/packages/api/test/pagination-continuation.test.ts b/static/packages/api/test/pagination-continuation.test.ts new file mode 100644 index 000000000000..de71824f2de3 --- /dev/null +++ b/static/packages/api/test/pagination-continuation.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, test } from "vitest"; +import { + consumePage, + createSentryClient, + fetchPage, + paginateUpTo, + paginateUpTo_listOrganizationProjects, + type CollectionPageFetcher, + type Config, + type PaginationContinuation, + type SdkResult, +} from "@sentry/api"; + +const asFetch = (implementation: (request: Request) => Promise) => + ((input: RequestInfo | URL, init?: RequestInit) => + implementation(new Request(input, init))) as NonNullable; +const link = (cursor: string) => + `; rel="next"; results="true"; cursor="${cursor}"`; +const rows = (start: number, count: number) => + Array.from({ length: count }, (_, index) => ({ id: String(start + index) })); +const success = (data: T, nextCursor?: string): SdkResult => ({ + data, + request: new Request("https://example.test/api/0/items/"), + response: Response.json(data, { headers: nextCursor ? { Link: link(nextCursor) } : {} }), +}); +const persisted = (continuation: PaginationContinuation | undefined): PaginationContinuation => { + expect(continuation).toBeDefined(); + return JSON.parse(JSON.stringify(continuation)); +}; + +describe("pagination continuation", () => { + test("resumes fixed-size project pages across changed limits and serialized continuations", async () => { + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://configured.example.test", + headers: { Authorization: "Bearer configured" }, + fetch: asFetch(async request => { + requests.push(request); + const query = new URL(request.url).searchParams; + expect(query.get("per_page")).toBe("100"); + // OffsetPaginator cursors retain the original page size and page number. + const cursor = query.get("cursor")?.split(":"); + const start = cursor ? Number(cursor[0]) * Number(cursor[1]) : 0; + const count = Math.min(100, 420 - start); + return Response.json(rows(start, count), { + headers: start + count < 420 ? { Link: link(`100:${start / 100 + 1}:0`) } : {}, + }); + }), + }); + const options = { + path: { organization_id_or_slug: "example" }, + query: { query: "selected-projects", per_page: 100 }, + }; + const first = await sentry.paginateUpTo.listOrganizationProjects(options, { limit: 250 }); + const second = await paginateUpTo_listOrganizationProjects( + { ...options, client: sentry.client }, + { limit: 25, resume: persisted(first.continuation) }, + ); + const third = await sentry.paginateUpTo.listOrganizationProjects(options, { + limit: 80, resume: persisted(second.continuation), + }); + const last = await sentry.paginateUpTo.listOrganizationProjects(options, { + limit: 100, resume: persisted(third.continuation), + }); + + expect(first.nextCursor).toBeUndefined(); + expect(first.continuation).toEqual({ cursor: "100:2:0", offset: 50, requestBudget: 50 }); + expect([first, second, third].map(batch => batch.stopReason)).toEqual(["limit", "limit", "limit"]); + expect(last.stopReason).toBe("exhausted"); + expect(last.continuation).toBeUndefined(); + expect([first, second, third, last].flatMap(batch => batch.data.map(item => item.id))) + .toEqual(rows(0, 420).map(item => item.id)); + expect(requests.map(request => new URL(request.url).searchParams.get("cursor"))) + .toEqual([null, "100:1:0", "100:2:0", "100:2:0", "100:2:0", "100:3:0", "100:3:0", "100:4:0"]); + for (const request of requests) { + const url = new URL(request.url); + expect(url.origin).toBe("https://configured.example.test"); + expect(url.searchParams.get("query")).toBe("selected-projects"); + expect(request.headers.get("Authorization")).toBe("Bearer configured"); + } + expect(options.query.per_page).toBe(100); + }); + + test("replays a custom fetcher's original budget until its opaque page is consumed", async () => { + const calls: Array<[string | undefined, number]> = []; + const starts = new Map([["opaque-next/A", 6], ["opaque-next/B", 12]]); + const fetcher = async (cursor: string | undefined, budget: number) => { + calls.push([cursor, budget]); + const start = cursor === undefined ? 0 : starts.get(cursor)!; + // A consumer can transform the budget; it need not equal an HTTP page size. + const end = Math.min(14, start + budget + 2); + const next = end === 6 ? "opaque-next/A" : end === 12 ? "opaque-next/B" : undefined; + return success(rows(start, end - start), next); + }; + const first = await paginateUpTo(fetcher, { limit: 4 }, "custom"); + const second = await paginateUpTo(fetcher, { limit: 1, resume: persisted(first.continuation) }, "custom"); + const third = await paginateUpTo(fetcher, { limit: 5, resume: persisted(second.continuation) }, "custom"); + const last = await paginateUpTo(fetcher, { limit: 20, resume: persisted(third.continuation) }, "custom"); + + expect([first, second, third, last].flatMap(batch => batch.data.map(item => item.id))) + .toEqual(rows(0, 14).map(item => item.id)); + expect(calls).toEqual([ + [undefined, 4], [undefined, 4], [undefined, 4], + ["opaque-next/A", 4], ["opaque-next/A", 4], ["opaque-next/B", 18], + ]); + expect(last.stopReason).toBe("exhausted"); + }); + + test("retains the unconsumed final page even when there is no server cursor", async () => { + const calls: Array<[string | undefined, number]> = []; + const fetcher = async (cursor: string | undefined, budget: number) => { + calls.push([cursor, budget]); + return success(["first", "second", "third"]); + }; + const first = await paginateUpTo(fetcher, { limit: 1 }, "final-page"); + const second = await paginateUpTo(fetcher, { limit: 1, resume: persisted(first.continuation) }, "final-page"); + const last = await paginateUpTo(fetcher, { limit: 2, resume: persisted(second.continuation) }, "final-page"); + + expect(first.nextCursor).toBeUndefined(); + expect(persisted(first.continuation)).toEqual({ offset: 1, requestBudget: 1 }); + expect(first.stopReason).toBe("limit"); + expect(second.stopReason).toBe("limit"); + expect(last.stopReason).toBe("exhausted"); + expect(last.continuation).toBeUndefined(); + expect([...first.data, ...second.data, ...last.data]).toEqual(["first", "second", "third"]); + expect(calls).toEqual([[undefined, 1], [undefined, 1], [undefined, 1]]); + }); + + test("resumes normalized pages without HTTP objects and preserves consumer failures", async () => { + const calls: Array<[string | undefined, number]> = []; + const failure = new Error("Consumer could not fetch the next page"); + let fail = false; + const fetcher: CollectionPageFetcher = async (cursor, budget) => { + calls.push([cursor, budget]); + if (cursor === undefined) return { data: [1, 2, 3], nextCursor: "consumer-next", request: undefined }; + if (fail) throw failure; + return { data: [4, 5] }; + }; + const first = await paginateUpTo(fetcher, { limit: 2 }, "normalized"); + const resume = persisted(first.continuation); + const last = await paginateUpTo(fetcher, { limit: 5, resume }, "normalized"); + + expect([...first.data, ...last.data]).toEqual([1, 2, 3, 4, 5]); + expect(last.stopReason).toBe("exhausted"); + expect(last.continuation).toBeUndefined(); + expect(calls).toEqual([[undefined, 2], [undefined, 2], ["consumer-next", 4]]); + + fail = true; + const error = await paginateUpTo(fetcher, { limit: 5, resume }, "normalized").catch(error => error); + expect(error).toBe(failure); + expect(calls.slice(3)).toEqual([[undefined, 2], ["consumer-next", 4]]); + }); + + test("composes fetchPage while honoring the consumer's next cursor over retained Link headers", async () => { + const requests: Request[] = []; + const cursors: Array = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + requests.push(request); + const final = new URL(request.url).searchParams.get("cursor") === "server-next"; + return Response.json(final ? rows(3, 2) : rows(0, 3), { + headers: { Link: link(final ? "stale-after-final" : "server-next") }, + }); + }), + }); + const fetcher = async (cursor: string | undefined) => { + cursors.push(cursor); + if (cursor !== undefined && cursor !== "consumer-next") throw new Error("Unexpected consumer cursor"); + const page = await sentry.fetchPage.listOrganizations( + { query: { per_page: 3 } }, + cursor === "consumer-next" ? "server-next" : undefined, + ); + // A consumer can normalize navigation while retaining the original Response. + return { ...page, nextCursor: cursor === undefined ? "consumer-next" : undefined }; + }; + const first = await paginateUpTo(fetcher, { limit: 2 }, "composed"); + const last = await paginateUpTo(fetcher, { + limit: 10, resume: persisted(first.continuation), + }, "composed"); + + expect([...first.data, ...last.data].map(item => item.id)).toEqual(rows(0, 5).map(item => item.id)); + expect(last.stopReason).toBe("exhausted"); + expect(last.nextCursor).toBeUndefined(); + expect(last.continuation).toBeUndefined(); + expect(cursors).toEqual([undefined, undefined, "consumer-next"]); + expect(requests.map(request => new URL(request.url).searchParams.get("cursor"))) + .toEqual([null, null, "server-next"]); + }); + + test("keeps custom navigation and compound page bodies independent from collection continuation", async () => { + const body = { data: [{ id: "one" }], meta: { fields: { id: "string" } } }; + const result = success(body, "server-next"); + const requested: Array = []; + const page = await fetchPage(async bookmark => { + requested.push(bookmark?.split("|")[1]); + return result; + }, "custom-bookmark", "client-state|server-start"); + + expect(requested).toEqual(["server-start"]); + expect(page.data).toBe(body); + expect(page.response).toBe(result.response!); + expect(page.nextCursor).toBe("server-next"); + expect("continuation" in page).toBe(false); + + const failure = new Error("Consumer-specific failure"); + const error = await fetchPage(async () => ({ + error: failure, + request: result.request, + response: undefined, + }), "custom-bookmark").catch(error => error); + expect(error).toBe(failure); + }); + + test("consumes an already fetched page without owning its envelope or navigation state", () => { + const envelope = { data: ["a", "b", "c"], meta: { count: 4 } }; + const page = { data: envelope.data, nextCursor: "opaque-next" }; + const first = consumePage(page, Object.freeze({ offset: 0 }), 2); + const rest = consumePage(page, first.position!); + const last = consumePage({ data: ["d"] }, rest.position!); + const output = { ...envelope, data: first.data }; + + expect(first.data).toEqual(["a", "b"]); + expect(rest.data).toEqual(["c"]); + expect(rest.position).toEqual({ cursor: "opaque-next", offset: 0 }); + expect(last.data).toEqual(["d"]); + expect(last.position).toBeUndefined(); + expect(output.meta).toBe(envelope.meta); + expect(envelope.data).toEqual(["a", "b", "c"]); + expect(page.nextCursor).toBe("opaque-next"); + }); + + test("carries an offset through shorter and empty pages with opaque cursors", async () => { + const pages = [ + { data: [1, 2], nextCursor: "opaque/A" }, + { data: [], nextCursor: "opaque/B" }, + { data: [3, 4, 5, 6] }, + ]; + const resume = { offset: 5, requestBudget: 7 }; + const first = consumePage(pages[0]!, resume); + const empty = consumePage(pages[1]!, first.position!); + const last = consumePage(pages[2]!, empty.position!); + + expect(first).toEqual({ data: [], position: { cursor: "opaque/A", offset: 3 } }); + expect(empty).toEqual({ data: [], position: { cursor: "opaque/B", offset: 3 } }); + expect(last).toEqual({ data: [6] }); + + const cursors: Array = []; + const result = await paginateUpTo(async cursor => { + cursors.push(cursor); + return pages[cursor === "opaque/A" ? 1 : cursor === "opaque/B" ? 2 : 0]!; + }, { limit: 2, resume }, "short-pages"); + + expect(cursors).toEqual([undefined, "opaque/A", "opaque/B"]); + expect(result).toEqual({ data: [6], stopReason: "exhausted" }); + }); + + test("counts a replayed partial page toward the request cap and resumes at its boundary", async () => { + const calls: Array = []; + const fetcher = async (cursor: string | undefined) => { + calls.push(cursor); + return cursor === undefined ? success([1, 2, 3], "next") : success([4, 5, 6]); + }; + const first = await paginateUpTo(fetcher, { limit: 2 }, "capped"); + const capped = await paginateUpTo(fetcher, { + limit: 5, maxPages: 1, resume: persisted(first.continuation), + }, "capped"); + const last = await paginateUpTo(fetcher, { + limit: 5, resume: persisted(capped.continuation), + }, "capped"); + + expect(capped.data).toEqual([3]); + expect(capped.stopReason).toBe("maxPages"); + expect(capped.nextCursor).toBe("next"); + expect(capped.continuation).toEqual({ cursor: "next", offset: 0 }); + expect(last.data).toEqual([4, 5, 6]); + expect(last.stopReason).toBe("exhausted"); + expect(calls).toEqual([undefined, undefined, "next"]); + }); + + test("propagates a mapped error after replaying a partial page instead of returning partial success", async () => { + const requests: Request[] = []; + const failure = new Error("Consumer-specific API error"); + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + requests.push(request); + if (new URL(request.url).searchParams.has("cursor")) { + return Response.json({ detail: "Unavailable" }, { status: 503 }); + } + return Response.json(rows(0, 3), { headers: { Link: link("next") } }); + }), + }); + sentry.client.interceptors.error.use(() => failure); + const first = await sentry.paginateUpTo.listOrganizations({}, { limit: 2 }); + const error = await sentry.paginateUpTo.listOrganizations({}, { + limit: 5, resume: persisted(first.continuation), + }).catch(error => error); + + expect(error).toBe(failure); + expect(requests.map(request => new URL(request.url).searchParams.get("cursor"))) + .toEqual([null, null, "next"]); + }); + + test("preserves cancellation between a replayed partial page and the next request", async () => { + const controller = new AbortController(); + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { + requests.push(request); + return Response.json(rows(0, 3), { headers: { Link: link("next") } }); + }), + }); + const first = await sentry.paginateUpTo.listOrganizations({}, { limit: 2 }); + const reason = new Error("Stop after the resumed page"); + const progress: number[] = []; + const error = await sentry.paginateUpTo.listOrganizations({ signal: controller.signal }, { + limit: 5, + resume: persisted(first.continuation), + onPage: fetched => { progress.push(fetched); controller.abort(reason); }, + }).catch(error => error); + + expect(error).toBe(reason); + expect(progress).toEqual([1]); + expect(requests).toHaveLength(2); + expect(requests[1]!.signal.aborted).toBe(true); + }); + + test("rejects ambiguous resume input before calling the fetcher", async () => { + let calls = 0; + const fetcher = async () => { calls++; return success([1]); }; + await expect(paginateUpTo(fetcher, { + limit: 1, startCursor: "other", resume: { cursor: "saved", offset: 0 }, + }, "ambiguous")).rejects.toThrow(); + expect(calls).toBe(0); + }); +}); diff --git a/static/packages/api/test/query-defaults.test.ts b/static/packages/api/test/query-defaults.test.ts new file mode 100644 index 000000000000..9efe68de3a01 --- /dev/null +++ b/static/packages/api/test/query-defaults.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "vitest"; +import { + createSentryClient, + getOrganizationIssueEvent, + getProjectEvent, + narrowError_getOrganizationIssueEvent, + narrowError_getProjectEvent, + type Config, + type GetOrganizationIssueEventQueryCollapse, +} from "@sentry/api"; +import { applyQueryDefaults } from "../lib/query-defaults.mjs"; + +const asFetch = (implementation: (request: Request) => Promise) => + ((input: RequestInfo | URL, init?: RequestInit) => + implementation(new Request(input, init))) as NonNullable; + +function recordingClient() { + const queries: string[] = []; + const fetch = asFetch(async request => { + queries.push(new URL(request.url).search); + return Response.json({}); + }); + return { queries, fetch, sentry: createSentryClient({ baseUrl: "https://example.test", fetch }) }; +} + +const event = { path: { organization_id_or_slug: "org", issue_id: "1", event_id: "latest" as const } }; +const projectEvent = { + path: { organization_id_or_slug: "org", project_id_or_slug: "project", event_id: "a".repeat(32) }, +}; +// The server also honors values the schema does not document yet, such as +// fullRelease. JavaScript callers can pass them; TypeScript callers need a cast. +const fullRelease = "fullRelease" as GetOrganizationIssueEventQueryCollapse; + +describe("client-side query defaults", () => { + test("skips adjacent-event lookups unless the caller sets collapse", async () => { + const { queries, fetch, sentry } = recordingClient(); + const { getOrganizationIssueEvent: detached } = sentry; + + await sentry.getOrganizationIssueEvent(event); + await detached({ ...event, query: { environment: ["prod"] } }); + await getOrganizationIssueEvent({ ...event, baseUrl: "https://example.test", fetch }); + await narrowError_getOrganizationIssueEvent({ ...event, client: sentry.client }); + // `undefined` behaves like an omitted parameter, as with JS default arguments. + await sentry.getOrganizationIssueEvent({ ...event, query: { collapse: undefined } }); + + expect(queries).toEqual([ + "?collapse=adjacentEvents", + "?environment=prod&collapse=adjacentEvents", + "?collapse=adjacentEvents", + "?collapse=adjacentEvents", + "?collapse=adjacentEvents", + ]); + }); + + test("skips adjacent-event lookups on project events unless the caller sets collapse", async () => { + const { queries, fetch, sentry } = recordingClient(); + + await sentry.getProjectEvent(projectEvent); + await sentry.getProjectEvent({ ...projectEvent, query: { environment: ["prod"] } }); + await getProjectEvent({ ...projectEvent, baseUrl: "https://example.test", fetch }); + await narrowError_getProjectEvent({ ...projectEvent, client: sentry.client }); + await sentry.getProjectEvent({ ...projectEvent, query: { collapse: [] } }); + + expect(queries).toEqual([ + "?collapse=adjacentEvents", + "?environment=prod&collapse=adjacentEvents", + "?collapse=adjacentEvents", + "?collapse=adjacentEvents", + "", + ]); + }); + + test("lets callers replace, combine, or opt out of the default", async () => { + const { queries, sentry } = recordingClient(); + const query = { collapse: [] as GetOrganizationIssueEventQueryCollapse[], environment: ["prod"] }; + + await sentry.getOrganizationIssueEvent({ ...event, query }); + await sentry.getOrganizationIssueEvent({ ...event, query: { collapse: [fullRelease] } }); + await sentry.getOrganizationIssueEvent({ ...event, query: { collapse: ["adjacentEvents", fullRelease] } }); + + expect(queries).toEqual([ + "?environment=prod", + "?collapse=fullRelease", + "?collapse=adjacentEvents&collapse=fullRelease", + ]); + // The caller's options object is not mutated. + expect(query).toEqual({ collapse: [], environment: ["prod"] }); + }); + + test("leaves other operations unchanged", async () => { + const { queries, sentry } = recordingClient(); + + await sentry.listOrganizationIssueEvents({ path: { organization_id_or_slug: "org", issue_id: "1" } }); + await sentry.listOrganizations({ query: { owner: true } }); + + expect(queries).toEqual(["", "?owner=true"]); + }); +}); + +describe("query default generator", () => { + const route = "GET /api/0/things/{id}/"; + const spec = (schema: object) => ({ + paths: { "/api/0/things/{id}/": { get: { parameters: [{ name: "collapse", in: "query", schema }] } } }, + }); + const arrayEnum = { type: "array", items: { type: "string", enum: ["a", "b"] } }; + const source = [ + "/**", + " * Get a thing", + " */", + "export const getThing = (options: Options) => (options.client ?? client).get({", + " url: '/api/0/things/{id}/',", + " ...options", + "});", + "export const listThings = (options: Options) => (options.client ?? client).get({", + " url: '/api/0/things/',", + " ...options", + "});", + "", + ].join("\n"); + const defaults = (values: unknown[], target = route) => + new Map([[target, { query: { collapse: values }, effect: "skips work" }]]); + + test("rewrites only the configured operation and documents the opt-out", () => { + const output = applyQueryDefaults(spec(arrayEnum), source, defaults(["a"])); + expect(output).toBe(source + .replace(" * Get a thing\n", " * Get a thing\n *\n * By default, the SDK sends `collapse: [\"a\"]`, which skips work." + + " Passing the parameter replaces the default; `collapse: []` sends none.\n") + .replace(" ...options\n});\nexport const listThings", + " ...options,\n query: { ...options?.query, \"collapse\": options?.query?.[\"collapse\"] ?? [\"a\"] }\n});\nexport const listThings")); + }); + + test("fails the build when the schema or SDK no longer matches", () => { + expect(() => applyQueryDefaults(spec(arrayEnum), source, defaults(["c"]))) + .toThrow("must be a non-empty list of documented values"); + expect(() => applyQueryDefaults(spec(arrayEnum), source, defaults([]))) + .toThrow("must be a non-empty list of documented values"); + expect(() => applyQueryDefaults(spec({ type: "string", enum: ["a"] }), source, defaults(["a"]))) + .toThrow("is not an array enum parameter"); + expect(() => applyQueryDefaults(spec(arrayEnum), source, defaults(["a"], "GET /api/0/missing/"))) + .toThrow("Query default for missing operation: GET /api/0/missing/"); + expect(() => applyQueryDefaults(spec(arrayEnum), source.replace("'/api/0/things/{id}/'", "'/api/0/other/'"), defaults(["a"]))) + .toThrow(`Query defaults for missing SDK operations: ${route}`); + }); +}); diff --git a/static/packages/api/test/request-preparation.test.ts b/static/packages/api/test/request-preparation.test.ts new file mode 100644 index 000000000000..a8b1762deba3 --- /dev/null +++ b/static/packages/api/test/request-preparation.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "vitest"; +import { createSentryClient, type Config } from "@sentry/api"; +import { createBrowserSdkConfig } from "@sentry/api/browser"; + +const asFetch = (implementation: (request: Request) => Promise) => + ((input: RequestInfo | URL, init?: RequestInit) => + implementation(new Request(input, init))) as NonNullable; +const snapshot = async (request: Request) => { + const headers: Record = {}; + request.headers.forEach((value, name) => { headers[name] = value; }); + return { url: request.url, method: request.method, headers, body: await request.clone().text() }; +}; + +describe("request preparation", () => { + test("shares request preparation with dispatch, running configured hooks once without fetching", async () => { + const stages: string[] = []; + const requests: Request[] = []; + const transport = asFetch(async request => { + requests.push(request); + return Response.json({ accepted: true }); + }); + const sentry = createSentryClient({ + baseUrl: "https://example.test/sentry", + headers: { "X-Default": "default", "X-Override": "original" }, + auth: async () => { stages.push("auth"); return "test-token"; }, + requestValidator: async () => { stages.push("validate"); }, + bodySerializer: body => { stages.push("serialize"); return JSON.stringify(body); }, + fetch: transport, + }); + sentry.client.interceptors.request.use(request => { + stages.push("intercept"); + request.headers.set("X-Prepared", "yes"); + return request; + }); + const options = { + method: "POST" as const, + url: "/api/0/organizations/{org}/items/", + path: { org: "example" }, + body: { title: "Overview" }, + headers: { "x-override": "per-call" }, + security: [{ type: "http" as const, scheme: "bearer" as const }], + }; + const prepared = await sentry.client.prepareRequest(options); + expect(stages).toEqual(["auth", "validate", "serialize", "intercept"]); + expect(requests).toHaveLength(0); + const preview = await snapshot(prepared); + expect(prepared.headers.get("Authorization")).toBe("Bearer test-token"); + expect(prepared.headers.get("X-Default")).toBe("default"); + expect(prepared.headers.get("X-Override")).toBe("per-call"); + expect(await (await transport(prepared)).json()).toEqual({ accepted: true }); + const result = await sentry.client.request(options); + + expect(result.data).toEqual({ accepted: true }); + expect(stages).toEqual(["auth", "validate", "serialize", "intercept", "auth", "validate", "serialize", "intercept"]); + expect(await snapshot(requests[0]!)).toEqual(preview); + expect(await snapshot(requests[1]!)).toEqual(preview); + expect(options.body).toEqual({ title: "Overview" }); + const configuredHeaders = sentry.client.getConfig().headers; + if (!(configuredHeaders instanceof Headers)) throw new Error("Expected resolved headers"); + expect(configuredHeaders.get("X-Override")).toBe("original"); + }); + + test("composes with browser transport without owning its CSRF or raw response handling", async () => { + const previousFetch = globalThis.fetch; + const requests: Request[] = []; + const response = Response.json({ data: [], meta: { fields: {} } }, { status: 403 }); + globalThis.fetch = asFetch(async request => { requests.push(request); return response; }); + try { + const config = createBrowserSdkConfig({ baseUrl: "https://example.test", getCsrfToken: () => "csrf-example" }); + const sentry = createSentryClient({ ...config, fetch: asFetch(request => config.fetch(request)) }); + const prepared = await sentry.client.prepareRequest({ method: "POST", url: "/api/0/custom/", body: { value: 1 } }); + expect(requests).toHaveLength(0); + expect(prepared.headers.has("X-CSRFToken")).toBe(false); + const raw = await config.fetch(prepared); + expect(raw).toBe(response); + expect(raw.bodyUsed).toBe(false); + expect(requests[0]!.headers.get("X-CSRFToken")).toBe("csrf-example"); + expect(requests[0]!.credentials).toBe("include"); + expect(raw.status).toBe(403); + expect(await raw.json()).toEqual({ data: [], meta: { fields: {} } }); + } finally { + globalThis.fetch = previousFetch; + } + }); + + test("replaces an opaque cursor without losing repeated filters, prefixes or encoded path segments", async () => { + const requests: Request[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test/sentry/", + fetch: asFetch(async request => { requests.push(request); return Response.json([]); }), + }); + const options = { + method: "GET" as const, + url: "/api/0/items/a%2Fb/?cursor=old&cursor=older&field=id&field=name&?cursor=unrelated&query=a%20b#ignored", + query: { cursor: "opaque/+?=#", per_page: 25 }, + }; + const request = await sentry.client.prepareRequest(options); + const url = new URL(request.url); + expect(url.pathname).toBe("/sentry/api/0/items/a%2Fb/"); + expect(url.searchParams.getAll("cursor")).toEqual(["opaque/+?=#"]); + expect(url.searchParams.get("?cursor")).toBe("unrelated"); + expect(url.searchParams.getAll("field")).toEqual(["id", "name"]); + expect(url.searchParams.get("per_page")).toBe("25"); + expect(url.search).toContain("query=a%20b"); + expect(url.hash).toBe(""); + await sentry.client.request(options); + expect(requests[0]!.url).toBe(request.url); + expect(options.url).toContain("cursor=old&cursor=older"); + }); + + test("clears a cursor for the first page while leaving unspecified query parameters intact", async () => { + const sentry = createSentryClient({ baseUrl: "https://example.test" }); + const request = await sentry.client.prepareRequest({ + method: "GET", url: "/api/0/items/?cursor=one&cursor=two&per_page=25&field=id&field=name", + query: { cursor: null, per_page: undefined }, + }); + const query = new URL(request.url).searchParams; + expect(query.has("cursor")).toBe(false); + expect(query.get("per_page")).toBe("25"); + expect(query.getAll("field")).toEqual(["id", "name"]); + }); + + test("retains a custom serializer's bytes when overlaying an existing query", async () => { + const sentry = createSentryClient({ baseUrl: "https://example.test" }); + const request = await sentry.client.prepareRequest({ + method: "GET", url: "/api/0/items/?cursor=old&field=old&%3Fcursor=old&query=a%20b", + query: { cursor: "custom/cursor value", field: ["id", "name"] }, + querySerializer: () => "??cursor=literal&cursor=custom%2fcursor+value&field=id&field=name", + }); + expect(request.url).toBe("https://example.test/api/0/items/?query=a%20b&?cursor=literal&cursor=custom%2fcursor+value&field=id&field=name"); + }); + + test("supports native raw and multipart bodies without JSON serialization", async () => { + const sentry = createSentryClient({ baseUrl: "https://example.test" }); + const raw = await sentry.client.prepareRequest({ + method: "POST", url: "/api/0/custom/", body: "raw payload", bodySerializer: null, + headers: { "Content-Type": "text/custom" }, + }); + expect(raw.headers.get("Content-Type")).toBe("text/custom"); + expect(await raw.text()).toBe("raw payload"); + const form = new FormData(); + form.append("tag", "one"); + form.append("tag", "two"); + const multipart = await sentry.client.prepareRequest({ + method: "POST", url: "/api/0/upload/", body: form, bodySerializer: null, + headers: { "Content-Type": null }, + }); + expect(multipart.headers.get("Content-Type")).toMatch(/^multipart\/form-data; boundary=/); + expect((await multipart.formData()).getAll("tag")).toEqual(["one", "two"]); + }); + + test("preserves an aborted signal and its reason without dispatching", async () => { + const controller = new AbortController(); + const reason = new Error("Caller stopped the request"); + controller.abort(reason); + let calls = 0; + const sentry = createSentryClient({ baseUrl: "https://example.test", fetch: asFetch(async () => { calls++; return new Response(); }) }); + const request = await sentry.client.prepareRequest({ method: "GET", url: "/api/0/items/", signal: controller.signal }); + expect(request.signal.aborted).toBe(true); + expect(request.signal.reason).toBe(reason); + expect(() => request.signal.throwIfAborted()).toThrow(reason); + expect(calls).toBe(0); + }); + + test("isolates concurrent clients and keeps prepared requests unchanged after reconfiguration", async () => { + const first = createSentryClient({ baseUrl: "https://first.example.test", headers: { "X-Context": "first" } }); + const second = createSentryClient({ baseUrl: "https://second.example.test", headers: { "X-Context": "second" } }); + const { prepareRequest } = first.client; + const options = { method: "POST" as const, url: "/api/0/items/", body: { value: "original" } }; + const [prepared, other] = await Promise.all([prepareRequest(options), second.client.prepareRequest(options)]); + first.client.setConfig({ baseUrl: "https://changed.example.test", headers: { "X-Context": "changed" } }); + options.body.value = "edited"; + const next = await prepareRequest(options); + + expect([prepared.url, other.url, next.url]).toEqual([ + "https://first.example.test/api/0/items/", "https://second.example.test/api/0/items/", "https://changed.example.test/api/0/items/", + ]); + expect([prepared, other, next].map(request => request.headers.get("X-Context"))).toEqual(["first", "second", "changed"]); + expect(await prepared.json()).toEqual({ value: "original" }); + expect(await next.json()).toEqual({ value: "edited" }); + }); +}); diff --git a/static/packages/api/test/response-contract.test.ts b/static/packages/api/test/response-contract.test.ts new file mode 100644 index 000000000000..175168bfd7bf --- /dev/null +++ b/static/packages/api/test/response-contract.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, test } from "vitest"; +import { + createSentryClient, + listOrganizations, + narrowError_getProject, + SentryApiError, + type Config, +} from "@sentry/api"; + +const asFetch = (implementation: (request: Request) => Promise) => + ((input: RequestInfo | URL, init?: RequestInit) => + implementation(input instanceof Request && init === undefined ? input : new Request(input, init))) as NonNullable; + +describe("public fields contract", () => { + test("rejects data-only configuration before dispatch without poisoning existing configuration", async () => { + let requests = 0; + const fetch = asFetch(async request => { + requests++; + expect(request.headers.get("X-Context")).toBe("original"); + return Response.json([]); + }); + // Exercise callers outside TypeScript as well as the separate type tests. + const dataOnly = { responseStyle: "data" } as unknown as Pick; + expect(() => createSentryClient({ ...dataOnly, fetch })).toThrow(); + + const sentry = createSentryClient({ + baseUrl: "https://example.test", headers: { "X-Context": "original" }, fetch, + }); + expect(() => sentry.client.setConfig({ + ...dataOnly, headers: { "X-Context": "invalid" }, + })).toThrow(); + await expect(sentry.listOrganizations(dataOnly)).rejects.toThrow(); + await expect(listOrganizations({ ...dataOnly, client: sentry.client })).rejects.toThrow(); + expect(requests).toBe(0); + + const result = await listOrganizations({ client: sentry.client, responseStyle: "fields" }); + expect(result.data).toEqual([]); + expect("error" in result).toBe(false); + expect(requests).toBe(1); + }); + + test("retains native request, response and headers on a successful throwing operation", async () => { + let sent: Request | undefined; + const response = Response.json([], { status: 201, headers: { "X-Hits": "0" } }); + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async request => { sent = request; return response; }), + }); + const result = await sentry.listOrganizations({ throwOnError: true }); + + expect(result.data).toEqual([]); + expect("error" in result).toBe(false); + expect(result.request).toBe(sent!); + expect(result.response).toBe(response); + expect(result.response.status).toBe(201); + expect(result.response.headers.get("X-Hits")).toBe("0"); + }); + + test("preserves exact HTTP error text and parsed bodies without cloning or reading twice", async () => { + class ObservedResponse extends Response { + reads = 0; + clones = 0; + override text() { this.reads++; return super.text(); } + override clone() { this.clones++; return super.clone(); } + } + + for (const [rawBody, body] of [ + ['{ "detail": "Unavailable" }\n', { detail: "Unavailable" }], + ["null", null], + ["false", false], + ["0", 0], + ['""', ""], + ["", ""], + ["Unavailable\n", "Unavailable\n"], + ['{"detail":', '{"detail":'], + ] as const) { + const response = new ObservedResponse(rawBody, { + status: 502, headers: { "Retry-After": "3" }, + }); + const sentry = createSentryClient({ + baseUrl: "https://example.test", fetch: asFetch(async () => response), + }); + const result = await sentry.listOrganizations(); + expect(result.error).toBeInstanceOf(SentryApiError); + if (!(result.error instanceof SentryApiError)) throw result.error; + + expect(result.error.status).toBe(502); + expect(result.error.body).toEqual(body); + expect(result.error.rawBody).toBe(rawBody); + expect(result.error.documented).toBe(false); + expect(result.error.request).toBe(result.request); + expect(result.error.response).toBe(response); + expect(result.response).toBe(response); + expect(result.error.response?.headers.get("Retry-After")).toBe("3"); + expect(response.reads).toBe(1); + expect(response.clones).toBe(0); + } + }); + + test("normalizes HTTP errors before interceptors and preserves the mapped error through operations and pagination", async () => { + const seen: unknown[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => new Response('{ "detail": "Forbidden" }\n', { status: 403 })), + }); + const mapped = new Error("Frontend RequestError equivalent"); + sentry.client.interceptors.error.use((error, response, request) => { + expect(error).toBeInstanceOf(SentryApiError); + if (!(error instanceof SentryApiError)) throw error; + expect(error.body).toEqual({ detail: "Forbidden" }); + expect(error.rawBody).toBe('{ "detail": "Forbidden" }\n'); + expect(error.response).toBe(response); + expect(error.request).toBe(request); + return mapped; + }); + sentry.client.interceptors.error.use(error => { + seen.push(error); + return error; + }); + + const result = await sentry.listOrganizations(); + expect(result.error).toBe(mapped); + for (const call of [ + () => listOrganizations({ client: sentry.client, throwOnError: true }), + () => sentry.fetchPage.listOrganizations({}), + () => sentry.paginateAll.listOrganizations({}), + () => sentry.paginateUpTo.listOrganizations({}, { limit: 1 }), + ]) { + await expect(call()).rejects.toBe(mapped); + } + expect(seen).toHaveLength(5); + expect(seen.every(error => error === mapped)).toBe(true); + }); + + test("preserves values returned or thrown by an error interceptor", async () => { + for (const mapped of [undefined, null, false, 0, ""]) { + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => Response.json({}, { status: 403 })), + }); + sentry.client.interceptors.error.use(() => mapped); + const result = await sentry.listOrganizations(); + expect("error" in result).toBe(true); + expect(result.error).toBe(mapped); + await expect(sentry.fetchPage.listOrganizations({})).rejects.toBe(mapped); + } + + const thrown = new Error("Error mapper failed"); + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => Response.json({}, { status: 403 })), + }); + sentry.client.interceptors.error.use(() => { throw thrown; }); + await expect(sentry.listOrganizations()).rejects.toBe(thrown); + }); + + test("typed HTTP narrowing preserves custom SentryApiError subclasses", async () => { + class ApplicationError extends SentryApiError { + readonly code = "application-permission"; + } + const response = Response.json({}, { status: 403 }); + const custom = new ApplicationError(403, {}, response, false); + const sentry = createSentryClient({ + baseUrl: "https://example.test", fetch: asFetch(async () => response), + }); + sentry.client.interceptors.error.use(() => custom); + await expect(narrowError_getProject({ + client: sentry.client, + path: { organization_id_or_slug: "example", project_id_or_slug: "example" }, + })).rejects.toBe(custom); + + // A custom fetch can throw an SDK error from a different, nested request. + const nested = new SentryApiError(503, {}, response, false); + const outer = createSentryClient({ + baseUrl: "https://example.test", fetch: asFetch(async () => { throw nested; }), + }); + await expect(narrowError_getProject({ + client: outer.client, + path: { organization_id_or_slug: "example", project_id_or_slug: "example" }, + })).rejects.toBe(nested); + }); + + test("preserves custom-fetch exceptions and cancellation without inventing an HTTP response", async () => { + const inner = new Error("Credential store unavailable"); + const custom = new Error("Login is required", { cause: inner }); + const aborted = new DOMException("Cancelled by caller", "AbortError"); + for (const failure of [custom, aborted, undefined]) { + const observed: unknown[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => { throw failure; }), + }); + sentry.client.interceptors.error.use(error => { observed.push(error); return error; }); + const result = await sentry.listOrganizations(); + expect("error" in result).toBe(true); + expect(result.error).toBe(failure); + expect(result.response).toBeUndefined(); + expect(result.request).toBeInstanceOf(Request); + await expect(sentry.listOrganizations({ throwOnError: true })).rejects.toBe(failure); + expect(observed).toEqual([failure, failure]); + } + expect(custom.cause).toBe(inner); + }); + + test("does not normalize serializer, validator or interceptor failures as network errors", async () => { + for (const stage of ["serialize", "request-validation", "request-interceptor", "response-interceptor", "response-validation", "transform"] as const) { + const failure = new Error(stage); + const observed: unknown[] = []; + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => Response.json([])), + }); + sentry.client.interceptors.error.use(error => { observed.push(error); return error; }); + const fail = () => { throw failure; }; + if (stage === "request-interceptor") sentry.client.interceptors.request.use(fail); + if (stage === "response-interceptor") sentry.client.interceptors.response.use(fail); + const call = stage === "serialize" + ? sentry.createOrganizationDashboard({ + path: { organization_id_or_slug: "example" }, + body: { title: "Overview" }, bodySerializer: fail, + }) + : sentry.listOrganizations({ + requestValidator: stage === "request-validation" ? fail : undefined, + responseValidator: stage === "response-validation" ? fail : undefined, + responseTransformer: stage === "transform" ? fail : undefined, + }); + await expect(call).rejects.toBe(failure); + expect(observed).toEqual([]); + } + }); + + test("does not relabel invalid success JSON or failing response streams as transport errors", async () => { + const sentry = createSentryClient({ + baseUrl: "https://example.test", + fetch: asFetch(async () => new Response("{", { headers: { "Content-Type": "application/json" } })), + }); + await expect(sentry.listOrganizations()).rejects.toBeInstanceOf(SyntaxError); + + for (const status of [200, 500]) { + const failure = new TypeError("Body stream failed"); + const fetch = asFetch(async () => new Response(new ReadableStream({ + start(controller) { controller.error(failure); }, + }), { status, headers: { "Content-Type": "application/json" } })); + await expect(sentry.listOrganizations({ fetch })).rejects.toBe(failure); + } + }); +}); diff --git a/static/packages/api/test/smoke.test.ts b/static/packages/api/test/smoke.test.ts new file mode 100644 index 000000000000..0534c6f61b26 --- /dev/null +++ b/static/packages/api/test/smoke.test.ts @@ -0,0 +1,654 @@ +/** + * Smoke tests for the public surface area. + * + * These tests don't hit the network — they assert that: + * 1. The Link-header parser handles real Sentry response shapes correctly. + * 2. The pagination helpers compose with mocked SDK results. + * 3. The generated wrappers (pagination.gen.ts) export the expected names + * and have the correct type shape (compile-time check via `as`). + * + * Run with `pnpm run test:vitest`. + */ + +import { describe, expect, test } from "vitest"; +import * as v from "valibot"; +import { vAutofixPostResponse, vGetProjectData } from "../src/valibot"; +import { zAutofixPostResponse, zGetProjectData } from "../src/zod"; +import { + fetchPage, + fetchPage_listOrganizationIssues, + fetchPage_listOrganizations, + narrowError, + narrowError_getProject, + paginateAll, + paginateAll_listOrganizations, + paginateUpTo, + paginateUpTo_listOrganizations, + parseSentryLinkHeader, + SentryApiError, + unwrapPaginatedResult, + unwrapResult, +} from "../src/index"; +import { createClient } from "../src/client"; +// `_withCursor` is internal — the generated wrappers use it but it's not +// re-exported from src/index. Import directly to cover the runtime helper. +import { _withCursor } from "../src/sentry-pagination"; + +// ===================================================================== +// parseSentryLinkHeader +// ===================================================================== + +describe("parseSentryLinkHeader", () => { + test("returns empty when header is null or empty", () => { + expect(parseSentryLinkHeader(null)).toEqual({}); + expect(parseSentryLinkHeader("")).toEqual({}); + }); + + test("extracts nextCursor when next link has results=true", () => { + const header = + '; rel="next"; results="true"; cursor="1234:0:0"'; + expect(parseSentryLinkHeader(header)).toEqual({ nextCursor: "1234:0:0" }); + }); + + test("extracts both nextCursor and prevCursor when both present", () => { + const header = + '; rel="previous"; results="true"; cursor="prev:1:0", ' + + '; rel="next"; results="true"; cursor="next:0:0"'; + expect(parseSentryLinkHeader(header)).toEqual({ + nextCursor: "next:0:0", + prevCursor: "prev:1:0", + }); + }); + + test("ignores rels with results=false (first-page boundary)", () => { + // Sentry sends `rel="previous"; results="false"` on the first page + // to indicate no previous results exist. We must not return prevCursor. + const header = + '; rel="previous"; results="false"; cursor="0:0:1", ' + + '; rel="next"; results="true"; cursor="next:0:0"'; + expect(parseSentryLinkHeader(header)).toEqual({ nextCursor: "next:0:0" }); + }); + + test("returns empty when both rels have results=false (last page)", () => { + const header = + '; rel="previous"; results="true"; cursor="prev:1:0", ' + + '; rel="next"; results="false"; cursor="next:0:0"'; + expect(parseSentryLinkHeader(header)).toEqual({ prevCursor: "prev:1:0" }); + }); + + test("ignores malformed segments without crashing", () => { + expect(parseSentryLinkHeader("not a link header")).toEqual({}); + expect(parseSentryLinkHeader("; rel=next")).toEqual({}); // unquoted + }); +}); + +describe("runtime validator entry points", () => { + const response = { run_id: 42, sentry_run_id: "run-42", feature_id: "autofix" }; + + test("preserves composite request schemas across generator upgrades", () => { + const request = { path: { organization_id_or_slug: "example", project_id_or_slug: "project" } }; + expect(v.parse(vGetProjectData, request)).toEqual(request); + expect(zGetProjectData.parse(request)).toEqual(request); + expect(v.safeParse(vGetProjectData, { path: {} }).success).toBe(false); + expect(zGetProjectData.safeParse({ path: {} }).success).toBe(false); + }); + + test("Valibot parses generated response schemas", () => { + expect(v.parse(vAutofixPostResponse, response)).toEqual(response); + expect(() => + v.parse(vAutofixPostResponse, { ...response, run_id: "42" }), + ).toThrow(); + }); + + test("Zod parses generated response schemas", () => { + expect(zAutofixPostResponse.parse(response)).toEqual(response); + expect(() => + zAutofixPostResponse.parse({ ...response, run_id: "42" }), + ).toThrow(); + }); +}); + +// ===================================================================== +// unwrapResult / unwrapPaginatedResult +// ===================================================================== + +const mkSuccess = (data: T, linkHeader?: string) => ({ + data, + request: new Request("https://example.test/"), + response: new Response(null, { + headers: linkHeader ? { link: linkHeader } : {}, + }), +}) satisfies Parameters>[0]; + +const mkFailure = (errorBody: TError, status = 500) => ({ + error: errorBody, + request: new Request("https://example.test/"), + response: new Response(null, { status }), +}) satisfies Parameters>[0]; + +const mkTransportFailure = (error: unknown) => ({ + error, + request: new Request("https://example.test/"), + response: undefined, +}); + +const asFetch = (implementation: (request: Request) => Promise) => + implementation as typeof fetch; + +const mkHttpFailure = (body: unknown, status: number) => { + const result = mkFailure(body, status); + return { + ...result, + error: new SentryApiError(status, body, result.response, false), + }; +}; + +const thrownBy = (call: () => unknown): unknown => { + try { + call(); + } catch (error) { + return error; + } + throw new Error("Expected the call to throw"); +}; + +describe("unwrapResult", () => { + test("returns data on success", () => { + const out = unwrapResult(mkSuccess({ ok: 1 }), "test"); + expect(out.data).toEqual({ ok: 1 }); + }); + + test("throws the exact consumer error without adding a wrapper", () => { + const error = new Error("Consumer failure"); + expect(thrownBy(() => unwrapResult(mkFailure(error), "test"))).toBe(error); + }); + + test("preserves an existing SentryApiError and its metadata", () => { + const result = mkHttpFailure({ detail: "boom" }, 404); + try { + unwrapResult(result, "ctx"); + throw new Error("expected unwrapResult to throw"); + } catch (err) { + expect(err).toBeInstanceOf(SentryApiError); + expect(err).toBe(result.error); + const apiErr = err as SentryApiError; + expect(apiErr.status).toBe(404); + expect(apiErr.body).toEqual({ detail: "boom" }); + expect(apiErr.documented).toBe(false); + } + }); + + test("uses the error property rather than its truthiness", () => { + for (const error of [undefined, null, false, 0, ""]) { + let caught = false; + try { + unwrapResult(mkTransportFailure(error), "test"); + } catch (thrown) { + caught = true; + expect(thrown).toBe(error); + } + expect(caught).toBe(true); + } + }); +}); + +// ===================================================================== +// narrowError +// ===================================================================== + +describe("narrowError", () => { + test("returns ok:true with data on success", () => { + const res = narrowError<{ id: number }, { 403: unknown }>( + mkSuccess({ id: 1 }), + [403], + ); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.data).toEqual({ id: 1 }); + expect(res.response).toBeInstanceOf(Response); + } + }); + + test("marks a declared status as documented", () => { + const original = mkHttpFailure({ detail: "nope" }, 403); + const cause = new Error("Original cause"); + original.error = new SentryApiError(403, original.error.body, original.response, false, undefined, { + request: original.request, rawBody: '{ "detail": "nope" }\n', cause, + }); + const res = narrowError( + original, + [403], + ); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toBeInstanceOf(SentryApiError); + expect(res.error.status).toBe(403); + expect(res.error.body).toEqual({ detail: "nope" }); + expect(res.error.documented).toBe(true); + expect(res.error.request).toBe(original.request); + expect(res.error.response).toBe(original.response); + expect(res.error.rawBody).toBe('{ "detail": "nope" }\n'); + expect(res.error.cause).toBe(cause); + } + }); + + test("keeps undocumented statuses separate from the documented union", () => { + const res = narrowError( + mkHttpFailure({}, 500), + [403, 404], + ); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error.status).toBe(500); + expect(res.error.documented).toBe(false); + } + }); + + test("does not disguise a transport exception as a documented API result", () => { + const cause = new TypeError("fetch failed"); + expect(thrownBy(() => narrowError( + mkTransportFailure(cause), [403], + ))).toBe(cause); + }); + + test("preserves custom errors even when an HTTP response exists", () => { + const error = new Error("Consumer interpretation of a 403"); + expect(thrownBy(() => narrowError( + mkFailure(error, 403), [403], + ))).toBe(error); + }); +}); + +describe("generated typed-error wrappers", () => { + const projectOptions = { + path: { + organization_id_or_slug: "my-org", + project_id_or_slug: "my-project", + }, + }; + + test("overrides client defaults and returns a documented HTTP error", async () => { + const client = createClient({ + baseUrl: "https://example.test", + throwOnError: true, + fetch: asFetch(() => + Promise.resolve( + Response.json({ detail: "missing" }, { status: 404 }), + ), + ), + }); + + const result = await narrowError_getProject({ ...projectOptions, client }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.documented).toBe(true); + expect(result.error.status).toBe(404); + expect(result.error.body).toEqual({ detail: "missing" }); + } + }); + + test("returns an undocumented HTTP error", async () => { + const result = await narrowError_getProject({ + ...projectOptions, + baseUrl: "https://example.test", + fetch: asFetch(() => Promise.resolve(Response.json({}, { status: 500 }))), + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.documented).toBe(false); + expect(result.error.status).toBe(500); + } + }); + + test("rejects with the original error when fetch rejects", async () => { + const cause = new TypeError("fetch failed"); + await expect(narrowError_getProject({ + ...projectOptions, + baseUrl: "https://example.test", + fetch: asFetch(() => Promise.reject(cause)), + })).rejects.toBe(cause); + }); + + test("does not relabel a received response's body failure as a network error", async () => { + const cause = new TypeError("stream failed"); + await expect(narrowError_getProject({ + ...projectOptions, + baseUrl: "https://example.test", + fetch: asFetch(() => + Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + controller.error(cause); + }, + }), + { status: 500 }, + ), + ), + ), + })).rejects.toBe(cause); + }); +}); + +describe("unwrapPaginatedResult", () => { + test("includes nextCursor when present in Link header", () => { + const linkHeader = + '; rel="next"; results="true"; cursor="next:0:0"'; + const out = unwrapPaginatedResult( + mkSuccess([1, 2, 3], linkHeader), + "test", + ); + expect(out.data).toEqual([1, 2, 3]); + expect(out.nextCursor).toBe("next:0:0"); + expect(out.prevCursor).toBeUndefined(); + }); + + test("omits both cursors when no Link header", () => { + const out = unwrapPaginatedResult(mkSuccess([1, 2, 3]), "test"); + expect(out.data).toEqual([1, 2, 3]); + expect(out.nextCursor).toBeUndefined(); + expect(out.prevCursor).toBeUndefined(); + }); +}); + +// ===================================================================== +// _withCursor +// ===================================================================== + +describe("_withCursor", () => { + test("leaves query unchanged when cursor is undefined", () => { + const opts = { query: { foo: "bar" }, headers: { x: "y" } }; + const out = _withCursor( + opts, + undefined, + ); + expect(out.query).toEqual({ foo: "bar" }); + expect(out.headers).toEqual({ x: "y" }); + }); + + test("merges cursor into existing query, preserving other fields", () => { + const out = _withCursor<{ query: { foo: string; cursor: string } }>( + { query: { foo: "bar" } }, + "abc", + ); + expect(out.query).toEqual({ foo: "bar", cursor: "abc" }); + }); + + test("creates query when none was provided", () => { + const out = _withCursor<{ query: { cursor: string } }>({}, "abc"); + expect(out.query).toEqual({ cursor: "abc" }); + }); +}); + +// ===================================================================== +// fetchPage +// ===================================================================== + +describe("fetchPage", () => { + test("forwards cursor argument to fetcher and returns parsed result", async () => { + let receivedCursor: string | undefined; + const out = await fetchPage( + (cursor) => { + receivedCursor = cursor; + return Promise.resolve( + mkSuccess([1, 2, 3], '; rel="next"; results="true"; cursor="abc"'), + ); + }, + "test", + "start-cursor", + ); + expect(receivedCursor).toBe("start-cursor"); + expect(out.data).toEqual([1, 2, 3]); + expect(out.nextCursor).toBe("abc"); + }); +}); + +// ===================================================================== +// paginateAll +// ===================================================================== + +describe("paginateAll", () => { + test("walks pages until no nextCursor, concatenating items", async () => { + const pages: Record = { + _start: { items: [1, 2], next: "p1" }, + p1: { items: [3, 4], next: "p2" }, + p2: { items: [5] }, // no next + }; + const items = await paginateAll( + (cursor) => { + const key = cursor ?? "_start"; + const page = pages[key]!; + const link = page.next + ? `; rel="next"; results="true"; cursor="${page.next}"` + : undefined; + return Promise.resolve(mkSuccess(page.items, link)); + }, + "test", + ); + expect(items).toEqual({ data: [1, 2, 3, 4, 5], stopReason: "exhausted" }); + }); + + test("stops at maxPages safety cap", async () => { + let calls = 0; + const items = await paginateAll( + () => { + calls += 1; + // Always returns a next cursor → infinite without the cap + return Promise.resolve( + mkSuccess([calls], '; rel="next"; results="true"; cursor="x"'), + ); + }, + "test", + { maxPages: 3 }, + ); + expect(calls).toBe(3); + expect(items).toEqual({ data: [1, 2, 3], stopReason: "maxPages", nextCursor: "x" }); + }); +}); + +// ===================================================================== +// paginateUpTo +// ===================================================================== + +describe("paginateUpTo", () => { + test("returns nextCursor when limit lands exactly on page boundary", async () => { + const result = await paginateUpTo( + (cursor) => + Promise.resolve( + mkSuccess( + cursor ? [3, 4] : [1, 2], + '; rel="next"; results="true"; cursor="next"', + ), + ), + { limit: 4 }, + "test", + ); + expect(result.data).toEqual([1, 2, 3, 4]); + // Limit reached exactly: still has more pages, so nextCursor is preserved + expect(result.nextCursor).toBe("next"); + expect(result.stopReason).toBe("limit"); + }); + + test("suppresses nextCursor when overshooting limit (correctness)", async () => { + // First page returns 5 items; limit is 3. Returning a cursor that points + // past the trimmed items would cause callers to skip records. + const result = await paginateUpTo( + () => + Promise.resolve( + mkSuccess( + [1, 2, 3, 4, 5], + '; rel="next"; results="true"; cursor="would-skip"', + ), + ), + { limit: 3 }, + "test", + ); + expect(result.data).toEqual([1, 2, 3]); + expect(result.nextCursor).toBeUndefined(); + }); + + test("reports a partial result when the final server page overshoots", async () => { + const result = await paginateUpTo( + () => Promise.resolve(mkSuccess([1, 2, 3, 4, 5])), + { limit: 3 }, + "test", + ); + expect(result).toEqual({ + data: [1, 2, 3], stopReason: "limit", + continuation: { cursor: undefined, offset: 3, requestBudget: 3 }, + }); + }); + + test("invokes onPage callback after each page", async () => { + const observed: Array<{ fetched: number; limit: number }> = []; + await paginateUpTo( + (cursor) => + Promise.resolve( + mkSuccess( + cursor ? [3, 4] : [1, 2], + cursor + ? undefined + : '; rel="next"; results="true"; cursor="p1"', + ), + ), + { + limit: 10, + onPage: (fetched, limit) => observed.push({ fetched, limit }), + }, + "test", + ); + expect(observed).toEqual([ + { fetched: 2, limit: 10 }, + { fetched: 4, limit: 10 }, + ]); + }); + + test("respects startCursor on first call, advances to server nextCursor on subsequent calls", async () => { + const cursorsSeen: Array = []; + await paginateUpTo( + (cursor) => { + cursorsSeen.push(cursor); + const isFirst = cursorsSeen.length === 1; + return Promise.resolve( + mkSuccess( + isFirst ? [1] : [], + isFirst + ? '; rel="next"; results="true"; cursor="server-side"' + : undefined, + ), + ); + }, + { limit: 100, startCursor: "resume-here" }, + "test", + ); + // First call honors startCursor; second call uses the server-returned + // cursor (not startCursor again — that would re-fetch the same page). + expect(cursorsSeen).toEqual(["resume-here", "server-side"]); + }); + + test("keeps a safe nextCursor when maxPages cap fires before limit is reached", async () => { + let calls = 0; + const result = await paginateUpTo( + () => { + calls += 1; + return Promise.resolve( + mkSuccess([calls], '; rel="next"; results="true"; cursor="more"'), + ); + }, + { limit: 1000, maxPages: 2 }, + "test", + ); + expect(calls).toBe(2); + expect(result).toEqual({ + data: [1, 2], stopReason: "maxPages", nextCursor: "more", + continuation: { cursor: "more", offset: 0 }, + }); + }); + + test("rejects limit < 1", async () => { + await expect( + paginateUpTo( + () => Promise.resolve(mkSuccess([])), + { limit: 0 }, + "test", + ), + ).rejects.toThrow(/limit/); + }); +}); + +describe("collection budgets", () => { + test.each([ + { items: [1, 2], next: undefined, limit: 3, reason: "exhausted", cursor: undefined }, + { items: [1, 2], next: undefined, limit: 2, reason: "exhausted", cursor: undefined }, + { items: [1, 2], next: "next", limit: 2, reason: "limit", cursor: "next" }, + { items: [1, 2], next: "next", limit: 3, reason: "maxPages", cursor: "next" }, + { items: [1, 2, 3], next: undefined, limit: 2, reason: "limit", cursor: undefined }, + ])("reports $reason at an item/request boundary ($items, $next, $limit)", async ({ items, next, limit, reason, cursor }) => { + const result = await paginateUpTo( + () => Promise.resolve(mkSuccess([...items], next ? `; rel="next"; results="true"; cursor="${next}"` : undefined)), + { limit, maxPages: 1 }, + "test", + ); + expect(result.data).toEqual(items.slice(0, limit)); + expect(result.stopReason).toBe(reason); + expect(result.nextCursor).toBe(cursor); + }); + + test("passes the remaining budget and continues after an empty page with a next cursor", async () => { + const seen: Array<[string | undefined, number]> = []; + const result = await paginateUpTo( + (cursor, remaining) => { + seen.push([cursor, remaining]); + const items = cursor === "empty" ? [] : cursor === "last" ? [3] : [1, 2]; + const next = cursor === "last" ? undefined : cursor === "empty" ? "last" : "empty"; + return Promise.resolve(mkSuccess([...items], next ? `; rel="next"; results="true"; cursor="${next}"` : undefined)); + }, + { limit: 3 }, + "test", + ); + expect(seen).toEqual([[undefined, 3], ["empty", 1], ["last", 1]]); + expect(result).toEqual({ data: [1, 2, 3], stopReason: "exhausted" }); + }); + + test.each([0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])("rejects invalid budget %s before fetching", async value => { + let fetched = false; + const fetcher = async () => { fetched = true; return mkSuccess([]); }; + await expect(paginateUpTo(fetcher, { limit: value }, "test")).rejects.toThrow(/limit/); + await expect(paginateUpTo(fetcher, { limit: 1, maxPages: value }, "test")).rejects.toThrow(/maxPages/); + await expect(paginateAll(fetcher, "test", { maxPages: value })).rejects.toThrow(/maxPages/); + expect(fetched).toBe(false); + }); +}); + +// ===================================================================== +// Generated wrappers — compile-time + structural smoke +// ===================================================================== + +describe("generated wrappers (pagination.gen.ts)", () => { + test("fetchPage_listOrganizations is callable with no args", () => { + // We don't actually invoke (would need network); we assert it exists, + // is a function, and has the expected arity (options + cursor = 2 named + // params, but JS reports the count up to the first default — so 1). + expect(typeof fetchPage_listOrganizations).toBe("function"); + }); + + test("paginateAll_/paginateUpTo_ wrappers exist for array-shaped ops", () => { + expect(typeof paginateAll_listOrganizations).toBe("function"); + expect(typeof paginateUpTo_listOrganizations).toBe("function"); + }); + + test("only fetchPage_ is generated for compound-shaped ops", async () => { + // listOrganizationIssues is array-shaped, so all three exist. + expect(typeof fetchPage_listOrganizationIssues).toBe("function"); + // For a compound op like listProjectReplayClicks (200: { ... } not Array<>), + // paginateAll/paginateUpTo are intentionally not generated — items + // can't be flattened across pages without knowing which field holds + // the array, and that's spec-specific. + const indexModule: Record = await import("../src/index"); + const keys = Object.keys(indexModule); + expect(keys).toContain("fetchPage_listProjectReplayClicks"); + expect(keys).not.toContain("paginateAll_listProjectReplayClicks"); + expect(keys).not.toContain("paginateUpTo_listProjectReplayClicks"); + }); + +}); diff --git a/static/packages/api/test/source-analysis-bindings.test.ts b/static/packages/api/test/source-analysis-bindings.test.ts new file mode 100644 index 000000000000..6120f84a7166 --- /dev/null +++ b/static/packages/api/test/source-analysis-bindings.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { analyzeSourceApiUsage, type SourceApiOperation } from "../lib/source-analysis.ts"; + +const operations = [ + { + functionName: "deleteProject", + method: "DELETE", + path: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/", + }, + { + functionName: "listOrganizationEvents", + method: "GET", + path: "/api/0/organizations/{organization_id_or_slug}/events/", + }, +] as const satisfies readonly SourceApiOperation[]; + +describe("source analysis bindings", () => { + test.each([ + ["namespace parameter", `export function run(sdk) { return sdk.deleteProject({}); }`], + ["local namespace variable", ` + export function run() { + const sdk = { deleteProject: () => undefined }; + return sdk.deleteProject({}); + } + `], + ["destructured parameter", `export function run({ deleteProject }) { return deleteProject({}); }`], + ["destructured local variable", ` + export function run() { + const { deleteProject } = { deleteProject: () => undefined }; + return deleteProject({}); + } + `], + ["local function", ` + export function run() { + function deleteProject() {} + return deleteProject({}); + } + `], + ] as const)("does not attribute SDK operations through a shadowing %s", (_name, declaration) => { + const sources = new Map([["src/run.ts", ` + import * as sdk from "@sentry/api"; + import { deleteProject } from "@sentry/api"; + ${declaration} + `]]); + + expect(analyzeSourceApiUsage(sources, operations).exports.get("src/run.ts")?.get("run")) + .toEqual({ operations: [], unresolvedRequests: [], methods: [] }); + }); + + test("follows an unshadowed SDK namespace", () => { + const sources = new Map([["src/run.ts", ` + import * as sdk from "@sentry/api"; + export function run() { return sdk.deleteProject({}); } + `]]); + + expect(analyzeSourceApiUsage(sources, operations).exports.get("src/run.ts")?.get("run")) + .toEqual({ operations: [operations[0]], unresolvedRequests: [], methods: ["DELETE"] }); + }); + + test.each([ + ["parameter", `export function run({ dataset }) { return listOrganizationEvents({ query: { dataset } }); }`], + ["local variable", ` + export function run(options) { + const { dataset } = options; + return listOrganizationEvents({ query: { dataset } }); + } + `], + ] as const)("does not read an outer constant through a destructured %s", (_name, declaration) => { + const sources = new Map([["src/run.ts", ` + import { listOrganizationEvents } from "@sentry/api"; + const dataset = "logs"; + ${declaration} + `]]); + + expect(analyzeSourceApiUsage(sources, operations, { queryParameters: ["dataset"] }) + .exports.get("src/run.ts")?.get("run")) + .toEqual({ operations: [operations[1]], unresolvedRequests: [], methods: ["GET"] }); + }); + + test("distinguishes a private local name from an exported alias with the same name", () => { + const sources = new Map([ + ["src/project.ts", ` + import { deleteProject, listOrganizationEvents } from "@sentry/api"; + const remove = () => deleteProject({}); + const list = () => listOrganizationEvents({}); + export const internal = () => remove(); + export { list as remove }; + `], + ["src/barrel.ts", `export { remove } from "./project.js";`], + ["src/forward.ts", `import { remove } from "./barrel.js"; export { remove };`], + ["src/consumer.ts", ` + import { remove } from "./project.js"; + import { remove as read } from "./barrel.js"; + import { remove as forwarded } from "./forward.js"; + export const direct = () => remove(); + export const throughBarrel = () => read(); + export const throughLocalExport = () => forwarded(); + `], + ]); + const analysis = analyzeSourceApiUsage(sources, operations); + + expect(analysis.exports.get("src/project.ts")?.get("internal")) + .toEqual({ operations: [operations[0]], unresolvedRequests: [], methods: ["DELETE"] }); + for (const [file, name] of [ + ["src/project.ts", "remove"], + ["src/barrel.ts", "remove"], + ["src/forward.ts", "remove"], + ["src/consumer.ts", "direct"], + ["src/consumer.ts", "throughBarrel"], + ["src/consumer.ts", "throughLocalExport"], + ] as const) { + expect(analysis.exports.get(file)?.get(name)) + .toEqual({ operations: [operations[1]], unresolvedRequests: [], methods: ["GET"] }); + } + }); +}); diff --git a/static/packages/api/test/source-analysis-modules.test.ts b/static/packages/api/test/source-analysis-modules.test.ts new file mode 100644 index 000000000000..07efe2096745 --- /dev/null +++ b/static/packages/api/test/source-analysis-modules.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, test } from "vitest"; +import { analyzeSourceApiUsage, type SourceApiOperation } from "../lib/source-analysis.ts"; + +const operations = [ + { functionName: "deleteProject", method: "DELETE", path: "/api/0/projects/{org}/{project}/" }, + { functionName: "listOrganizations", method: "GET", path: "/api/0/organizations/" }, +] as const satisfies readonly SourceApiOperation[]; +const helper = `import { deleteProject } from "@sentry/api"; export const read = () => deleteProject({});`; + +describe("source analysis modules", () => { + test.each([ + ["./helper.js", "src/helper.ts"], + ["./helper.js", "src/helper.tsx"], + ["./helper.ts", "src/helper.ts"], + ["./helper.tsx", "src/helper.tsx"], + ["./helper", "src/helper.ts"], + ["./helper", "src/helper.tsx"], + ["./helper", "src/helper/index.ts"], + ["./helper", "src/helper/index.tsx"], + ] as const)("follows %s to supplied source %s", (specifier, file) => { + const sources = new Map([ + [file, helper], + ["src/main.ts", `import { read } from "${specifier}"; export const run = () => read();`], + ]); + + expect(analyzeSourceApiUsage(sources, operations).exports.get("src/main.ts")?.get("run")) + .toEqual({ operations: [operations[0]], unresolvedRequests: [], methods: ["DELETE"] }); + }); + + test.each([ + ["exact source", ["src/helper", "src/helper.ts", "src/helper.tsx", "src/helper/index.ts", "src/helper/index.tsx"]], + [".ts file", ["src/helper.ts", "src/helper.tsx", "src/helper/index.ts", "src/helper/index.tsx"]], + [".tsx file", ["src/helper.tsx", "src/helper/index.ts", "src/helper/index.tsx"]], + ["index.ts file", ["src/helper/index.ts", "src/helper/index.tsx"]], + ] as const)("prefers the %s over later relative module candidates", (_name, files) => { + const sources = new Map(files.map((file, index) => [file, index === 0 ? helper : ` + import { listOrganizations } from "@sentry/api"; + export const read = () => listOrganizations({}); + `])); + sources.set("src/main.ts", `import { read } from "./helper"; export const run = () => read();`); + + expect(analyzeSourceApiUsage(sources, operations).exports.get("src/main.ts")?.get("run")?.operations) + .toEqual([operations[0]]); + }); + + test.each([ + [".ts source", ["src/helper.ts", "src/helper.tsx", "src/helper.js"]], + [".tsx source", ["src/helper.tsx", "src/helper.js"]], + ["exact .js fallback", ["src/helper.js"]], + ] as const)("uses the %s for a .js import", (_name, files) => { + const sources = new Map(files.map((file, index) => [file, index === 0 ? helper : ` + import { listOrganizations } from "@sentry/api"; + export const read = () => listOrganizations({}); + `])); + sources.set("src/main.ts", `import { read } from "./helper.js"; export const run = () => read();`); + + expect(analyzeSourceApiUsage(sources, operations).exports.get("src/main.ts")?.get("run")?.operations) + .toEqual([operations[0]]); + }); + + test.each([ + ["./helper.js", ["src/helper.js/index.ts", "src/helper.ts/index.ts"]], + ["./helper.ts", ["src/helper.tsx", "src/helper.ts/index.ts"]], + ["./helper.tsx", ["src/helper.tsx/index.ts"]], + ] as const)("does not broaden explicit %s to another extension or a directory", (specifier, files) => { + const sources = new Map(files.map(file => [file, helper])); + sources.set("src/main.ts", `import { read } from "${specifier}"; export const run = () => read();`); + + expect(analyzeSourceApiUsage(sources, operations).exports.get("src/main.ts")?.get("run")?.operations) + .toEqual([]); + }); + + test.each([ + ["named function", `export default function read() { return deleteProject({}); }`], + ["anonymous function", `export default function () { return deleteProject({}); }`], + ["arrow expression", `export default () => deleteProject({});`], + ["identifier expression", `const read = () => deleteProject({}); export default read;`], + ["export alias", `const read = () => deleteProject({}); export { read as default };`], + ] as const)("follows a default import using %s without exposing private names", (_name, declaration) => { + const sources = new Map([ + ["src/helper.ts", `import { deleteProject } from "@sentry/api"; ${declaration}`], + ["src/main.ts", `import read from "./helper.js"; export const run = () => read();`], + ]); + const analysis = analyzeSourceApiUsage(sources, operations); + + expect([...analysis.exports.get("src/helper.ts")!.keys()]).toEqual(["default"]); + for (const [file, name] of [["src/helper.ts", "default"], ["src/main.ts", "run"]] as const) { + expect(analysis.exports.get(file)?.get(name)) + .toEqual({ operations: [operations[0]], unresolvedRequests: [], methods: ["DELETE"] }); + } + }); + + test("keeps the named default function available to local callers", () => { + const sources = new Map([["src/helper.ts", ` + import { deleteProject } from "@sentry/api"; + export default function remove() { return deleteProject({}); } + export const internal = () => remove(); + `]]); + const exports = analyzeSourceApiUsage(sources, operations).exports.get("src/helper.ts")!; + + expect([...exports.keys()]).toEqual(["default", "internal"]); + expect(exports.get("internal")?.operations).toEqual([operations[0]]); + }); + + test("follows default re-exports and namespace access while export star forwards only named exports", () => { + const sources = new Map([ + ["src/helper.ts", ` + import { deleteProject, listOrganizations } from "@sentry/api"; + export default function remove() { return deleteProject({}); } + export const list = () => listOrganizations({}); + `], + ["src/barrel.ts", `export { default, default as remove } from "./helper.js";`], + ["src/star.ts", `export * from "./helper.js";`], + ["src/main.ts", ` + import remove, { list } from "./helper.js"; + import forwarded, { remove as named } from "./barrel.js"; + import * as sdk from "./helper.js"; + import missing from "./star.js"; + import * as star from "./star.js"; + export const fromNamespace = () => sdk.default(); + export const fromDefaultReexport = () => forwarded(); + export const fromNamedReexport = () => named(); + export const mixed = () => { remove(); list(); }; + export const missingDefault = () => missing(); + export const missingNamespace = () => star.default(); + `], + ]); + const analysis = analyzeSourceApiUsage(sources, operations); + + expect([...analysis.exports.get("src/star.ts")!.keys()]).toEqual(["list"]); + expect(analysis.exports.get("src/star.ts")?.get("list")?.operations).toEqual([operations[1]]); + for (const name of ["fromNamespace", "fromDefaultReexport", "fromNamedReexport"]) { + expect(analysis.exports.get("src/main.ts")?.get(name)?.operations).toEqual([operations[0]]); + } + expect(analysis.exports.get("src/main.ts")?.get("mixed")?.operations).toEqual([...operations]); + for (const name of ["missingDefault", "missingNamespace"]) { + expect(analysis.exports.get("src/main.ts")?.get(name)?.operations).toEqual([]); + } + }); + + test("preserves a default boundary alias without attributing its callbacks to data consumers", () => { + const sources = new Map([ + ["src/remove.ts", ` + import { deleteProject } from "@sentry/api"; + const remove = defineTool({ run: () => deleteProject({}) }); + export default remove; + `], + ["src/main.ts", ` + import remove from "./remove.js"; + export const alias = remove; + export const tools = { remove }; + export const help = defineTool({ run: () => inspect(tools) }); + `], + ]); + const analysis = analyzeSourceApiUsage(sources, operations, { boundaries: ["defineTool"] }); + + for (const [file, name] of [["src/remove.ts", "default"], ["src/main.ts", "alias"]] as const) { + expect(analysis.exports.get(file)?.get(name)?.operations).toEqual([operations[0]]); + expect(analysis.boundaries).toContainEqual({ file, name, factories: ["defineTool"], flags: [] }); + } + for (const name of ["tools", "help"]) { + expect(analysis.exports.get("src/main.ts")?.get(name)?.operations).toEqual([]); + } + }); + + test("stops at a cycle of default identifier exports", () => { + const sources = new Map([ + ["src/a.ts", `import reference from "./b.js"; export default reference;`], + ["src/b.ts", `import reference from "./a.js"; export default reference;`], + ["src/main.ts", `import reference from "./a.js"; export const run = () => reference();`], + ]); + + expect(analyzeSourceApiUsage(sources, operations).exports.get("src/main.ts")?.get("run")) + .toEqual({ operations: [], unresolvedRequests: [], methods: [] }); + }); +}); + +describe.each(["identifier expression", "export alias"] as const)("default callable identity through %s", form => { + const exportDefault = (name: string) => form === "identifier expression" + ? `export default ${name};` + : `export { ${name} as default };`; + + test("retains configured request identity and reads arguments at each callsite", () => { + const sources = new Map([ + ["src/request.ts", ` + function apiRequest(path, options) { return transport(path, options); } + ${exportDefault("apiRequest")} + `], + ["src/main.ts", ` + import request from "./request.js"; + export const list = () => request("/organizations/", { method: "GET", query: { cursor: "page-2" } }); + export const dynamic = (path) => request(path, { method: "DELETE" }); + `], + ]); + const result = analyzeSourceApiUsage(sources, operations, { + requests: [{ functionName: "apiRequest", pathArgument: 0, optionsArgument: 1 }], + queryParameters: ["cursor"], + }).exports.get("src/main.ts")!; + + expect(result.get("list")).toEqual({ + operations: [{ ...operations[1], query: { cursor: "page-2" } }], + unresolvedRequests: [], methods: ["GET"], + }); + expect(result.get("dynamic")).toMatchObject({ + operations: [], methods: ["DELETE"], + unresolvedRequests: [{ method: "DELETE", reason: "Dynamic request identity" }], + }); + }); + + test("recognizes a configured factory without leaking callbacks into data consumers", () => { + const sources = new Map([ + ["src/factory.ts", ` + function defineTool(config) { return config; } + ${exportDefault("defineTool")} + `], + ["src/main.ts", ` + import create from "./factory.js"; + import { deleteProject } from "@sentry/api"; + export const remove = create({ approved: true, run: () => deleteProject({}) }); + export const tools = { remove }; + export const help = create({ run: () => inspect(tools) }); + `], + ]); + const analysis = analyzeSourceApiUsage(sources, operations, { + boundaries: ["defineTool"], declarationFlags: ["approved"], + }); + + expect(analysis.exports.get("src/main.ts")?.get("remove")?.operations).toEqual([operations[0]]); + expect(analysis.boundaries).toContainEqual({ + file: "src/main.ts", name: "remove", factories: ["defineTool"], flags: ["approved"], + }); + for (const name of ["tools", "help"]) { + expect(analysis.exports.get("src/main.ts")?.get(name)?.operations).toEqual([]); + } + }); + + test("retains SDK query context through a forwarded default import", () => { + const sources = new Map([ + ["src/operation.ts", ` + import { listOrganizations as request } from "@sentry/api"; + ${exportDefault("request")} + `], + ["src/forward.ts", `export { default } from "./operation.js";`], + ["src/main.ts", ` + import request from "./forward.js"; + export const list = () => request({ query: { cursor: "page-2" } }); + `], + ]); + const analysis = analyzeSourceApiUsage(sources, operations, { queryParameters: ["cursor"] }); + + expect(analysis.exports.get("src/main.ts")?.get("list")?.operations) + .toEqual([{ ...operations[1], query: { cursor: "page-2" } }]); + if (form === "identifier expression") { + for (const file of ["src/operation.ts", "src/forward.ts"]) { + expect(analysis.exports.get(file)?.get("default")?.operations).toEqual([operations[1]]); + } + } + }); +}); diff --git a/static/packages/api/test/source-analysis-types.ts b/static/packages/api/test/source-analysis-types.ts new file mode 100644 index 000000000000..244bcde320e1 --- /dev/null +++ b/static/packages/api/test/source-analysis-types.ts @@ -0,0 +1,18 @@ +/** The packed analysis contract needs neither compiler AST types nor HTTP globals. */ +import { operations } from "@sentry/api/operations"; +import { analyzeSourceApiUsage, type SourceApiAnalysisOptions } from "@sentry/api/source-analysis"; + +const options: SourceApiAnalysisOptions = { + boundaries: ["defineTask"], + requests: [{ functionName: "request", pathArgument: 0, optionsArgument: 1, queryProperty: "params", defaultMethod: "GET" }], + queryParameters: ["dataset"], + declarationFlags: ["guarded"], +}; +const result = analyzeSourceApiUsage(new Map(), operations, options); +const usage = result.exports.get("feature.ts")?.get("task"); +const method: string | undefined = usage?.methods[0]; +const query: string | undefined = usage?.operations[0]?.query?.dataset; +const factory: string | undefined = result.boundaries[0]?.factories[0]; +const flag: string | undefined = result.boundaries[0]?.flags[0]; + +void [method, query, factory, flag]; diff --git a/static/packages/api/test/source-analysis-values.test.ts b/static/packages/api/test/source-analysis-values.test.ts new file mode 100644 index 000000000000..885471480b54 --- /dev/null +++ b/static/packages/api/test/source-analysis-values.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "vitest"; +import { analyzeSourceApiUsage, type SourceApiOperation } from "../lib/source-analysis.ts"; + +const operation = { + functionName: "listOrganizationEvents", + method: "GET", + path: "/api/0/organizations/{organization_id_or_slug}/events/", +} as const satisfies SourceApiOperation; + +describe("source analysis value certainty", () => { + test.each([ + ["unknown shorthand", "{ FIELD }", false], + ["unknown shorthand spread", "{ FIELD: VALUE, ...{ FIELD } }", false], + ["unknown computed override", "{ FIELD: VALUE, [key]: value }", false], + ["accessor", "{ get FIELD() { return VALUE; } }", false], + ["unknown trailing spread", "{ FIELD: VALUE, ...unknown }", false], + ["explicit value after computed key", "{ [key]: value, FIELD: VALUE }", true], + ["explicit value after spread", "{ ...unknown, FIELD: VALUE }", true], + ] as const)("preserves method and query certainty for %s", (_, expression, known) => { + const method = expression.replaceAll("FIELD", "method").replaceAll("VALUE", '"GET"'); + const query = expression.replaceAll("FIELD", "dataset").replaceAll("VALUE", '"logs"'); + const sources = new Map([["source.ts", ` + import { listOrganizationEvents } from "@sentry/api"; + export function raw(org, method, key, value, unknown) { + return request(\`/organizations/\${org}/events/\`, ${method}); + } + export function sdk(dataset, key, value, unknown) { + return listOrganizationEvents({ query: ${query} }); + } + `]]); + const exports = analyzeSourceApiUsage(sources, [operation], { + requests: [{ functionName: "request", pathArgument: 0, optionsArgument: 1 }], + queryParameters: ["dataset"], + }).exports.get("source.ts")!; + + expect(exports.get("raw")?.operations).toEqual(known ? [operation] : []); + expect(exports.get("raw")?.unresolvedRequests).toEqual(known ? [] : [ + expect.objectContaining({ method: undefined, reason: "Dynamic request identity" }), + ]); + expect(exports.get("sdk")?.operations).toEqual([ + { ...operation, query: known ? { dataset: "logs" } : undefined }, + ]); + }); + + test.each(["let", "var", "const"] as const)("keeps %s binding values conservative", (kind) => { + const mutable = kind !== "const"; + const sources = new Map([["source.ts", ` + import { listOrganizationEvents } from "@sentry/api"; + export function raw(org, next) { + ${kind} method = "GET"; + ${mutable ? "method = next;" : ""} + return request(\`/organizations/\${org}/events/\`, { method }); + } + export function sdk(next) { + ${kind} dataset = "logs"; + ${mutable ? "dataset = next;" : ""} + return listOrganizationEvents({ query: { dataset } }); + } + `]]); + const exports = analyzeSourceApiUsage(sources, [operation], { + requests: [{ functionName: "request", pathArgument: 0, optionsArgument: 1 }], + queryParameters: ["dataset"], + }).exports.get("source.ts")!; + + expect(exports.get("raw")?.operations).toEqual(mutable ? [] : [operation]); + expect(exports.get("raw")?.unresolvedRequests).toEqual(mutable ? [ + expect.objectContaining({ method: undefined, reason: "Dynamic request identity" }), + ] : []); + expect(exports.get("sdk")?.operations).toEqual([ + { ...operation, query: mutable ? undefined : { dataset: "logs" } }, + ]); + }); + + test.each([ + ["false override", "{ approved: true, ...{ approved: false } }", []], + ["unknown override", "{ approved: true, ...unknown }", []], + ["explicit final true", "{ ...unknown, approved: true }", ["approved"]], + ] as const)("respects declaration flag precedence in the same object for %s", (_, config, flags) => { + const sources = new Map([["source.ts", ` + export const tool = (unknown) => defineTool(${config}); + `]]); + const analysis = analyzeSourceApiUsage(sources, [operation], { + boundaries: ["defineTool"], + declarationFlags: ["approved"], + }); + expect(analysis.boundaries).toEqual([ + { file: "source.ts", name: "tool", factories: ["defineTool"], flags: [...flags] }, + ]); + }); +}); diff --git a/static/packages/api/test/source-analysis.test.ts b/static/packages/api/test/source-analysis.test.ts new file mode 100644 index 000000000000..15edb2d979fd --- /dev/null +++ b/static/packages/api/test/source-analysis.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from "vitest"; +import { analyzeSourceApiUsage, type SourceApiOperation } from "../lib/source-analysis.ts"; + +const operations = [ + { + functionName: "listOrganizationEvents", + method: "GET", + path: "/api/0/organizations/{organization_id_or_slug}/events/", + }, + { + functionName: "getOrganizationTrace", + method: "GET", + path: "/api/0/organizations/{organization_id_or_slug}/trace/{trace_id}/", + }, + { + functionName: "deleteProject", + method: "DELETE", + path: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/", + }, +] as const satisfies readonly SourceApiOperation[]; + +describe("consumer source analysis", () => { + test("keeps configured tool factories separate from inspecting tools as data", () => { + const sources = new Map([ + ["src/help.ts", ` + import { tools } from "./tools.js"; + export const help = defineTool({ run: () => inspect(tools) }); + `], + ["src/tools.ts", ` + import { remove } from "./remove.js"; + export const tools = { remove }; + `], + ["src/remove.ts", ` + import { deleteProject } from "@sentry/api"; + function customFactory(config) { return defineTool(config); } + export const remove = customFactory({ requiresApproval: true, run: () => deleteProject({}) }); + export const alias = remove; + `], + ["src/cli.ts", ` + export const legacy = buildCommand({ destructive: true, run: () => apiRequestToRegion("region", "/organizations/example/events/") }); + `], + ]); + const analysis = analyzeSourceApiUsage(sources, operations, { + boundaries: ["defineTool"], + declarationFlags: ["requiresApproval"], + }); + + for (const [file, name] of [["src/help.ts", "help"], ["src/tools.ts", "tools"]] as const) { + expect(analysis.exports.get(file)?.get(name)).toEqual({ + operations: [], unresolvedRequests: [], methods: [], + }); + } + for (const name of ["remove", "alias"]) { + expect(analysis.exports.get("src/remove.ts")?.get(name)?.methods).toEqual(["DELETE"]); + expect(analysis.boundaries).toContainEqual({ + file: "src/remove.ts", name, factories: ["defineTool"], flags: ["requiresApproval"], + }); + } + expect(analyzeSourceApiUsage(sources, operations).boundaries).toEqual([]); + expect(analysis.exports.get("src/cli.ts")?.get("legacy")?.operations).toEqual([]); + }); + + test("follows helper aliases and barrels as consumer sources grow without an SDK change", () => { + const sources = new Map([ + ["src/api.tsx", ` + import { listOrganizationEvents as events } from "@sentry/api"; + export const query = () => events({ query: { dataset: "logs", token: "do-not-collect" } }); + `], + ["src/barrel.ts", `export { query as read } from "./api.js";`], + ["src/list.ts", ` + import { read } from "./barrel.js"; + const customFactory = (config) => defineTool(config); + export const tool = customFactory({ async run() { return await read(); } }); + `], + ]); + const options = { boundaries: ["defineTool"], queryParameters: ["dataset"] }; + const first = analyzeSourceApiUsage(sources, operations, options); + expect(first.exports.get("src/list.ts")?.get("tool")?.operations).toEqual([ + { ...operations[0], query: { dataset: "logs" } }, + ]); + expect(analyzeSourceApiUsage(sources, operations).exports.get("src/api.tsx")?.get("query")?.operations[0]?.query) + .toBeUndefined(); + + sources.set("src/api.tsx", ` + import { getOrganizationTrace as trace, listOrganizationEvents } from "@sentry/api"; + export const query = () => trace({ path: {} }); + `); + sources.set("src/new.ts", `export * from "./list.js";`); + const changed = analyzeSourceApiUsage(sources, operations, options); + expect(changed.exports.get("src/new.ts")?.get("tool")?.operations.map(operation => operation.functionName)) + .toEqual(["getOrganizationTrace"]); + expect(changed.boundaries).toContainEqual({ + file: "src/new.ts", name: "tool", factories: ["defineTool"], flags: [], + }); + }); + + test("recognizes configured request wrappers and retains known query terms", () => { + const sources = new Map([ + ["src/read.ts", ` + import { regionalRequest as read } from "./transport.js"; + const DATASET = "spans"; + const TRANSACTIONS = "is_transaction:true"; + export function list(org, options) { + const query = [TRANSACTIONS, options.query].filter(Boolean).join(" "); + return read(options.region, \`/organizations/\${org}/events/\`, { + params: { dataset: DATASET, query: query || undefined }, + }); + } + export const remove = (org, project) => deleteRequest(\`/projects/\${org}/\${project}/\`); + `], + ]); + const result = analyzeSourceApiUsage(sources, operations, { + requests: [ + { functionName: "regionalRequest", pathArgument: 1, optionsArgument: 2, queryProperty: "params" }, + { functionName: "deleteRequest", pathArgument: 0, optionsArgument: 1, defaultMethod: "DELETE" }, + ], + queryParameters: ["dataset", "query"], + }).exports.get("src/read.ts")!; + expect(result.get("list")).toEqual({ + operations: [{ ...operations[0], query: { dataset: "spans", query: "is_transaction:true" } }], + unresolvedRequests: [], methods: ["GET"], + }); + expect(result.get("remove")?.operations.map(operation => operation.functionName)).toEqual(["deleteProject"]); + expect(result.get("remove")?.methods).toEqual(["DELETE"]); + expect(analyzeSourceApiUsage(sources, operations).exports.get("src/read.ts")?.get("list")?.operations).toEqual([]); + }); + + test("reports dynamic, private and ambiguous requests without losing known HTTP methods", () => { + const sources = new Map([ + ["src/requests.ts", ` + export const dynamic = (path) => request(path, { method: "DELETE" }); + export const privateApi = () => request("/private/", { method: "DELETE" }); + export const unknownMethod = (options) => request("/organizations/example/events/", { ...options }); + export const ambiguous = (org) => request(\`/organizations/\${org}/events/\`); + `], + ]); + const result = analyzeSourceApiUsage(sources, [ + ...operations, + { functionName: "otherEvents", method: "GET", path: "/api/0/organizations/{org}/events/" }, + ], { + requests: [{ functionName: "request", pathArgument: 0, optionsArgument: 1 }], + }).exports.get("src/requests.ts")!; + expect(result.get("dynamic")).toMatchObject({ + methods: ["DELETE"], operations: [], + unresolvedRequests: [{ method: "DELETE", reason: "Dynamic request identity" }], + }); + expect(result.get("privateApi")).toMatchObject({ + methods: ["DELETE"], operations: [], + unresolvedRequests: [{ method: "DELETE", reason: "Request is not in the public catalog" }], + }); + expect(result.get("unknownMethod")).toMatchObject({ + methods: [], operations: [], + unresolvedRequests: [{ reason: "Dynamic request identity" }], + }); + expect(result.get("ambiguous")).toMatchObject({ + methods: ["GET"], operations: [], + unresolvedRequests: [{ reason: "Ambiguous catalog path" }], + }); + }); + + test("keeps SDK callbacks conservative without following unused imports, shadowed bindings or factory internals", () => { + const sources = new Map([ + ["src/factory.ts", ` + import { deleteProject } from "@sentry/api"; + export const defineTool = (config) => { deleteProject({}); return config; }; + `], + ["src/project.ts", ` + import { defineTool } from "./factory.js"; + import * as sdk from "@sentry/api"; + import { deleteProject } from "@sentry/api"; + export const remove = defineTool({ approved: true, unrelated: true, run: () => [].map(sdk.deleteProject) }); + export const safe = defineTool({ approved: false, run: (deleteProject) => deleteProject() }); + `], + ]); + const analysis = analyzeSourceApiUsage(sources, operations, { + boundaries: ["defineTool"], declarationFlags: ["approved"], + }); + expect(analysis.exports.get("src/project.ts")?.get("remove")).toEqual({ + operations: [operations[2]], unresolvedRequests: [], methods: ["DELETE"], + }); + expect(analysis.exports.get("src/project.ts")?.get("safe")).toEqual({ + operations: [], unresolvedRequests: [], methods: [], + }); + expect(analysis.boundaries).toContainEqual({ + file: "src/project.ts", name: "remove", factories: ["defineTool"], flags: ["approved"], + }); + expect(analysis.boundaries).toContainEqual({ + file: "src/project.ts", name: "safe", factories: ["defineTool"], flags: [], + }); + }); + + test("does not invent query context when an unknown trailing spread can override it", () => { + const sources = new Map([ + ["src/queries.ts", ` + import { listOrganizationEvents } from "@sentry/api"; + const page = { per_page: 10 }; + export const unknown = (options) => listOrganizationEvents({ query: { dataset: "logs", ...options } }); + export const explicit = (options) => listOrganizationEvents({ query: { ...options, dataset: "logs" } }); + export const known = () => listOrganizationEvents({ query: { dataset: "logs", ...page } }); + `], + ]); + const result = analyzeSourceApiUsage(sources, operations, { + queryParameters: ["dataset"], + }).exports.get("src/queries.ts")!; + expect(result.get("unknown")?.operations[0]?.query).toBeUndefined(); + expect(result.get("explicit")?.operations[0]?.query).toEqual({ dataset: "logs" }); + expect(result.get("known")?.operations[0]?.query).toEqual({ dataset: "logs" }); + }); +}); diff --git a/static/packages/api/test/typecheck.ts b/static/packages/api/test/typecheck.ts new file mode 100644 index 000000000000..42f191d0064c --- /dev/null +++ b/static/packages/api/test/typecheck.ts @@ -0,0 +1,240 @@ +/** + * Compile-time type checks for the generated wrappers. + * + * This file is type-checked but never executed. It asserts that the + * generated wrappers preserve type information at the consumer boundary — + * specifically: + * 1. Path/query/body params remain typed. + * 2. The cursor parameter is rejected from `query` (managed by the helper). + * 3. The return type is the correct response shape (Array vs compound). + * + * If this file ever fails `tsc --noEmit`, the generator has regressed. + */ + +import { + fetchPage_listOrganizationIssues, + fetchPage_listOrganizationProjects, + fetchPage_listProjectReplayClicks, + narrowError, + narrowError_getProject, + paginateAll_listOrganizationIssues, + paginateAll_listOrganizationProjects, + paginateUpTo_listOrganizationIssues, +} from "../src/index"; +import type { + ListOrganizationTraceItemAttributesQueryAttributeType, + ListOrganizationTraceItemAttributesQueryDataset, +} from "../src/index"; +import type { InferOutput } from "valibot"; +import type { z } from "zod"; +import { vAutofixPostResponse } from "../src/valibot"; +import { zAutofixPostResponse } from "../src/zod"; + +const valibotResponse: InferOutput = { + run_id: 1, + sentry_run_id: null, + feature_id: "autofix", +}; +const zodResponse: z.infer = valibotResponse; +void zodResponse; + +const config = { + baseUrl: "https://sentry.io", + headers: { Authorization: "Bearer test" }, +}; + +// ===================================================================== +// fetchPage — array-shaped op +// ===================================================================== + +async function fetchPageHappyPath() { + const result = await fetchPage_listOrganizationIssues({ + ...config, + path: { organization_id_or_slug: "my-org" }, + query: { + // collapse should be allowed (not the cursor we manage) + collapse: ["stats"], + limit: 25, + }, + }); + // Return type is PaginatedResponse; data is the array + // (whose element type is the issue object the SDK declares). + result.data.length; + // nextCursor / prevCursor are typed strings | undefined + if (result.nextCursor) { + const _: string = result.nextCursor; + } +} + +async function fetchPageRejectsCursor() { + await fetchPage_listOrganizationIssues({ + ...config, + path: { organization_id_or_slug: "my-org" }, + query: { + // @ts-expect-error — `cursor` must NOT be passable here (helper-managed) + cursor: "abc", + }, + }); +} + +async function fetchPageRequiresPath() { + // @ts-expect-error — required path param must still be required + await fetchPage_listOrganizationIssues({ + ...config, + query: { limit: 25 }, + }); +} + +// ===================================================================== +// paginateAll / paginateUpTo — array-shaped op +// ===================================================================== + +async function paginateAllHappyPath() { + const items = await paginateAll_listOrganizationIssues({ + ...config, + path: { organization_id_or_slug: "my-org" }, + query: { limit: 100 }, + }); + items.data.length; + items.stopReason; +} + +async function paginateUpToHappyPath() { + const result = await paginateUpTo_listOrganizationIssues( + { + ...config, + path: { organization_id_or_slug: "my-org" }, + query: { limit: 100 }, + }, + { limit: 250, onPage: (n, t) => console.log(n, t) }, + ); + result.data.length; + if (result.nextCursor) { + const _: string = result.nextCursor; + } +} + +// ===================================================================== +// fetchPage — compound-shaped op +// ===================================================================== + +async function fetchPageCompoundOp() { + const result = await fetchPage_listProjectReplayClicks({ + ...config, + path: { + organization_id_or_slug: "my-org", + project_id_or_slug: "my-proj", + replay_id: "00000000-0000-0000-0000-000000000000", + }, + }); + // Compound 200 shape — `data` is whatever the spec declared (object, + // not Array). We just assert it's not `unknown` by accessing it. + void result.data; +} + +// ===================================================================== +// per_page widening — undocumented but runtime-supported +// ===================================================================== + +async function perPageAcceptedEvenWhenSpecOmitsIt() { + // /organizations/{org}/projects/ has only `cursor` declared in the spec, + // but the runtime accepts `per_page`. PaginationQuery widens with it so + // callers don't need an `as` cast. + await paginateAll_listOrganizationProjects({ + ...config, + path: { organization_id_or_slug: "my-org" }, + query: { per_page: 100 }, + }); + + // Same for fetchPage on issues — per_page co-exists with documented + // params (collapse, limit, sort, etc.). + await fetchPage_listOrganizationProjects({ + ...config, + path: { organization_id_or_slug: "my-org" }, + query: { per_page: 100 }, + }); +} + +// ===================================================================== +// Removed unsafe cursor override +// ===================================================================== + +async function rejectsUnsafeCursorOverride() { + await paginateUpTo_listOrganizationIssues( + { ...config, path: { organization_id_or_slug: "my-org" } }, + { + limit: 250, + // @ts-expect-error A cursor must not skip discarded rows. + keepCursorOnOvershoot: true, + }, + ); +} + +void fetchPageHappyPath; +void fetchPageRejectsCursor; +void fetchPageRequiresPath; +void paginateAllHappyPath; +void paginateUpToHappyPath; +void fetchPageCompoundOp; +void perPageAcceptedEvenWhenSpecOmitsIt; +void rejectsUnsafeCursorOverride; + +// ===================================================================== +// narrowError — status-discriminated HTTP error handling +// ===================================================================== + +async function narrowErrorHappyPath() { + const res = await narrowError_getProject({ + ...config, + path: { + organization_id_or_slug: "my-org", + project_id_or_slug: "my-proj", + }, + }); + if (res.ok) { + // Success branch exposes the typed 200 body. + void res.data; + return; + } + if (!res.error.documented) { + // Includes unexpected HTTP statuses. Other exceptions reject unchanged. + return; + } + switch (res.error.status) { + case 403: + case 404: + throw res.error; // user-actionable + } +} + +async function statusNarrowsErrorBody() { + type Errors = { + 400: { kind: "bad-request" }; + 404: { kind: "not-found" }; + }; + const sdkResult = null as unknown as Parameters< + typeof narrowError + >[0]; + const result = narrowError(sdkResult, [400, 404]); + if (result.ok || !result.error.documented) return; + + if (result.error.status === 400) { + const kind: "bad-request" = result.error.body.kind; + void kind; + } else { + const kind: "not-found" = result.error.body.kind; + void kind; + } +} + +void narrowErrorHappyPath; +void statusNarrowsErrorBody; + +// Enum query parameters are named, and array parameters name their element. +const attributeDataset: ListOrganizationTraceItemAttributesQueryDataset = "tracemetrics"; +// @ts-expect-error "errors" is not a trace item dataset. +const invalidAttributeDataset: ListOrganizationTraceItemAttributesQueryDataset = "errors"; +const attributeType: ListOrganizationTraceItemAttributesQueryAttributeType = "number"; +// @ts-expect-error The alias is the element type, not the array. +const attributeTypeList: ListOrganizationTraceItemAttributesQueryAttributeType = ["number"]; +void [attributeDataset, invalidAttributeDataset, attributeType, attributeTypeList]; diff --git a/static/packages/api/tsconfig.browser.json b/static/packages/api/tsconfig.browser.json new file mode 100644 index 000000000000..a14aefd249dc --- /dev/null +++ b/static/packages/api/tsconfig.browser.json @@ -0,0 +1,12 @@ +{ + // The shared client must run in browsers and Workers: check it against the + // DOM library with no Node or Bun types, so a Node-only API fails to compile. + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [], + "noEmit": true, + "emitDeclarationOnly": false + }, + "include": ["src/api-client/**/*.ts", "src/browser.ts"] +} diff --git a/static/packages/api/tsconfig.json b/static/packages/api/tsconfig.json new file mode 100644 index 000000000000..fea17f7da0e0 --- /dev/null +++ b/static/packages/api/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + // TypeScript 6 defaults rootDir to this directory and types to []. + "rootDir": "src", + "types": ["node"], + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + // The client entries import the package by name, as consumers do, so + // their bundles share the root entry's classes instead of copying them. + "paths": { + "@sentry/api": ["./src/index.ts"], + "@sentry/api/client": ["./src/api-client/index.ts"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/static/packages/api/tsconfig.test.json b/static/packages/api/tsconfig.test.json new file mode 100644 index 000000000000..025225dc9172 --- /dev/null +++ b/static/packages/api/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "emitDeclarationOnly": false, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/tests/js/createJestConfig.ts b/tests/js/createJestConfig.ts index 304cb5f54322..55feb5658026 100644 --- a/tests/js/createJestConfig.ts +++ b/tests/js/createJestConfig.ts @@ -311,6 +311,7 @@ const config: Config.InitialOptions = { testMatch: testMatch ?? ['/(static|tests/js)/**/?(*.)+(spec|test).[jt]s?(x)'], testPathIgnorePatterns: [ '/tests/sentry/lang/javascript/', + '/static/packages/api/', '/static/packages/scraps/', '/static/packages/icons/', '/static/packages/search-syntax/',