Skip to content

feat(api-client)!: Read the API base path from the spec's server URL - #148

Merged
BYK merged 2 commits into
mainfrom
feat/api-base-path-from-servers
Nov 13, 2026
Merged

BYK merged 2 commits into
mainfrom
feat/api-base-path-from-servers

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Merge order: getsentry/sentry-api-schema#148 → getsentry/sentry-docs#20787 → getsentry/sentry#131065 → the getsentry/toolkit bump, after @sentry/api 2.0.0 is published. Docs has to merge before sentry#131065. When that merges, the spec sync commits the new spec to sentry-api-schema, which auto-releases @sentry/api and auto-bumps the schema in sentry-docs. Without sentry-docs#20787 merged first, the docs would drop /api/0 from paths and curl examples. This PR supersedes #147, which hard-coded API_PREFIX.

Per @BYK, the server decides the base path. /api/0 comes from the spec's server URL. It's exported as API_BASE_PATH, and every path in the package is relative to it. Nothing here hard-codes 0. A future /api/1 spec would produce API_BASE_PATH = "/api/1" with no code change.

Design

  • lib/api-base-path.mjs reads the base path from the path of servers[0].url, makes paths relative, and trims server URLs to their origin. That includes the per-operation override on listSeerModels.
  • Until the sentry PR lands, the base path is the /api/<version> prefix that every path shares. It's marked TODO. Both spec shapes build the same src/; I checked this with diff -r. So the sentry change only syncs a byte-identical package through the bot.
  • baseUrl stays the Sentry origin. That's what users have, including self-hosted URLs and links.regionUrl. The patched buildUrl builds the request URL as origin + API_BASE_PATH + operation path.
  • Exported from @sentry/api, @sentry/api/client and @sentry/api/operations.
  • The four /api/0 copies are gone: in core.ts, buildSentryApiUrl, parseApiPathScope and cache invalidation. So are the copies in the idempotent matcher, normalize-spec and source-analysis. Matching still finds the base path anywhere in the URL, as before.

Before / After

// @sentry/api/operations
{ functionName: "listOrganizations", path: "/api/0/organizations/" }
{ functionName: "listOrganizations", path: "/organizations/" }

import { API_BASE_PATH } from "@sentry/api"; // "/api/0", from servers[0].url

// raw requests: url is relative to API_BASE_PATH
sentry.client.prepareRequest({ method: "GET", url: "/api/0/organizations/" }); // before
sentry.client.prepareRequest({ method: "GET", url: "/organizations/" });       // after
// both send https://sentry.io/api/0/organizations/

Breaking: major bump to 2.0.0

  • operations[].path and IdempotentOperation.path are relative. Build-time lists derived from the catalog must be regenerated, such as toolkit's idempotent-operations.json.
  • Raw client.request, client.get and prepareRequest take relative urls. An old /api/0/... URL now gets a doubled base path.
  • Generated function names, types and the request URLs of typed operations don't change: sentry-client.gen.ts is byte-identical.

Co-Authored-By: BYK 126780+BYK@users.noreply.github.com

via @BYK.

--

View Session [Sentry]

The client hard-coded /api/0 in four places, and the catalog, SDK URLs
and internal keys all spelled it out in every path. Read it from the
OpenAPI spec's server URL instead, export it as API_BASE_PATH, and keep
every path relative to it.

The build accepts both spec shapes: the current one, where every path
starts with /api/0, and the one where servers carries /api/0 and paths
are relative. Both produce the same package, so the getsentry/sentry
spec change that follows is a no-op here.

BREAKING CHANGE: Catalog paths (`operations[].path`), `IdempotentOperation`
paths, and raw `client.request`/`prepareRequest` URLs are relative to
`API_BASE_PATH`, such as `/organizations/{organization_id_or_slug}/`.
`baseUrl` stays the Sentry origin; the client adds the base path.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>
Comment thread lib/api-base-path.mjs Resolved
Split the spec's server URL with the URL parser, and find the legacy
/api/<version> prefix by path segments. A new test fails on any regex
that matches a URL scheme separator, and AGENTS.md documents the rule.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1f41687. Configure here.

Comment thread lib/api-base-path.mjs Resolved
@BYK
BYK merged commit f15d1f5 into main Nov 13, 2026
11 checks passed
@BYK
BYK deleted the feat/api-base-path-from-servers branch November 13, 2026 13:40
BYK added a commit to getsentry/toolkit that referenced this pull request Nov 13, 2026
**Depends on:** `@sentry/api` 2.0.0, which is published
([getsentry/sentry-api-schema#148](getsentry/sentry-api-schema#148)).
This is the last step of the chain sentry-api-schema#148 →
[sentry-docs#20787](getsentry/sentry-docs#20787)
→ [sentry#131065](getsentry/sentry#131065) →
this PR.

Sentry's OpenAPI spec is moving `/api/0` out of every path and into its
server URL. `@sentry/api` 2.0 reads the base path from that URL, exports
it as `API_BASE_PATH`, and makes operation paths relative. This PR drops
toolkit's private `/api/0` copies and uses the export instead, so the
version number lives only in the spec.

Before:
```ts
const API_PREFIX = "/api/0";                          // cli/src/commands/api.ts
this.apiPrefix = `${protocol}://${host}/api/0`;       // mcp-core api-client
path: operation.path,                                 // "/api/0/organizations/" in 1.x
```
After:
```ts
import { API_BASE_PATH } from "@sentry/api/client";
this.apiPrefix = `${protocol}://${host}${API_BASE_PATH}`;
path: `${API_BASE_PATH}${operation.path}`,            // still "/api/0/organizations/"
```

- **CLI endpoint index:** it keeps full request paths, so `sentry
schema` and `sentry api` still show and accept `/api/0/...`.
- **Requests:** CLI raw requests now pass relative URLs to
`prepareRequest`.
- **Unchanged on purpose:** user-facing help text and examples still say
`/api/0`.
- **Breaking:** only the `@sentry/api` 1.x → 2.0.0 bump. CLI and MCP
behavior doesn't change, so no toolkit major is needed.

**#2229 (stacked on #1807):** on 2.0 it needs two changes:
- replace its local `API_PREFIX = "/api/0"` in `error-reporting.ts` with
`API_BASE_PATH`;
- build `api-path-templates.json` from `` `${API_BASE_PATH}${op.path}`
``, because `op.path` is now relative.

**Checks:**
- `pnpm run tsc` and `pnpm run lint` pass.
- mcp-core vitest: 2301 passed.
- The CLI api, schema, sentry-client, error-reporting, issue-links and
script tests: 82 files, 1685 passed.
- `pnpm install --frozen-lockfile` passes with the
`minimumReleaseAgeExclude` entry moved from 1.83.0 to 2.0.0.

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A640c5343d03e547097aa9b8a711daaf5 -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3A640c5343d03e547097aa9b8a711daaf5)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A640c5343d03e547097aa9b8a711daaf5/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>
BYK added a commit to getsentry/sentry-docs that referenced this pull request Nov 13, 2026
> **Merge order:**
[getsentry/sentry-api-schema#148](getsentry/sentry-api-schema#148)
→
[#20787](#20787)
→
[getsentry/sentry#131065](getsentry/sentry#131065)
→ the getsentry/toolkit bump, after `@sentry/api` 2.0.0 is published.
Docs has to merge before sentry#131065. When that merges, the spec sync
commits the new spec to sentry-api-schema, which auto-releases
`@sentry/api` and auto-bumps the schema in sentry-docs. Without this PR
merged first, the docs would drop `/api/0` from paths and curl examples.
This PR also works with the current spec.

## DESCRIBE YOUR PR

Sentry's OpenAPI spec is moving `/api/0` out of every path and into its
server URL, so clients read the base path (and the API version in it)
from the spec
([getsentry/sentry-api-schema#148](getsentry/sentry-api-schema#148),
then the getsentry/sentry change). The API reference ignored the
spec-level `servers`, so after that change, pages and curl examples
would drop `/api/0`.

`apiLocation()` joins the server URL's path to each operation path. The
server it shows is only the origin, still `https://sentry.io` by
default, or the operation's own server.

Before, with the new spec shape:
```
GET /organizations/
curl https://sentry.io/organizations/
```
After, with either spec shape:
```
GET /api/0/organizations/
curl https://sentry.io/api/0/organizations/
```

**No visual change, so no screenshots.** I ran `apiCategories()` against
a spec built from getsentry/sentry master and against the same spec
built with the change. The page data was byte-identical, including the
per-operation `https://{region}.sentry.io` server on
`/api/0/seer/models/`.

Checks: the `resolveOpenAPI.spec.ts` vitest suite passes, with one new
table test covering both spec shapes and an operation-level server.
ESLint and Prettier pass on the changed files.

## IS YOUR CHANGE URGENT?

- [ ] Urgent deadline (GA date, etc.): YYYY-MM-DD
- [ ] Other deadline: YYYY-MM-DD
- [x] No deadline: Not urgent, can wait up to 1 week+

It does block the getsentry/sentry spec change from merging.

## PRE-MERGE CHECKLIST

- [ ] Checked Vercel preview for correctness, including links
- [ ] PR was reviewed and approved by any necessary SMEs (subject matter
experts)
- [ ] PR was reviewed and approved by a member of the [Sentry docs
team](https://github.com/orgs/getsentry/teams/docs)

<!-- junior-request-attribution:start -->
via @BYK.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A640c5343d03e547097aa9b8a711daaf5 -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3A640c5343d03e547097aa9b8a711daaf5)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A640c5343d03e547097aa9b8a711daaf5/?project=4510944073809921)

<!-- junior-session-footer:end -->

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>
BYK added a commit to getsentry/sentry that referenced this pull request Nov 13, 2026
…131065)

> **Depends on:**
[getsentry/sentry-api-schema#148](getsentry/sentry-api-schema#148)
and
[getsentry/sentry-docs#20787](getsentry/sentry-docs#20787).
**Merge both first.** `openapi.yml` syncs this spec to sentry-api-schema
on push to master. That repo then auto-releases `@sentry/api` and
cascades into sentry-docs, and both still assume `/api/0` in every path
until those PRs land.

The public OpenAPI spec's server URL was the bare origin, and every path
spelled out `/api/0`. Clients had to know the prefix, and the API
version in it, instead of reading it from the spec. Now the server URL
carries the versioned base path and paths are relative to it.

Before:
```json
"servers": [{ "url": "https://{region}.sentry.io", "variables": { "region": { ... } } }],
"paths": { "/api/0/organizations/{organization_id_or_slug}/releases/": { ... } }
```
After:
```json
"servers": [{ "url": "https://{region}.sentry.io/api/0", "variables": { "region": { ... } } }],
"paths": { "/organizations/{organization_id_or_slug}/releases/": { ... } }
```

**How:**
- `SPECTACULAR_SETTINGS["SERVERS"]` sets the URL to
`https://{region}.sentry.io/api/0`.
- A new last postprocessing hook, `relative_paths_hook`, strips that
base path from every path, so the hooks before it still see full paths.
A path outside the base path fails the build.
- The hook also appends the base path to per-endpoint `servers` (only
`/seer/models/` today: `https://{region}.sentry.io/api/0`). Endpoint
classes keep naming origins.
- I didn't use drf-spectacular's `SCHEMA_PATH_PREFIX_TRIM`: it doesn't
trim the `APPEND_PATHS` we use, and `custom_postprocessing_hook` matches
on full paths.

**Versioning:** the spec names `/api/0` once. A future v1 is a new
server entry or spec with `/api/1`, and downstream clients read the path
from `servers[0].url`. I picked this over an `/api/{version}` server
variable because a v1 overhaul would come with different paths and
schemas, which one spec can't describe. Self-hosted stays as before:
clients swap the origin and keep the path.

**Breaking change** for anyone reading the spec: paths no longer start
with `/api/0`, so join them to the server URL. Known consumers:
- `@sentry/api`: handled by sentry-api-schema#148, which ships as 2.0.0.
It reads both shapes and generates byte-identical clients from this spec
and the master spec.
- docs.sentry.io API reference: handled by sentry-docs#20787. It renders
the same page data from both shapes.
- getsentry/toolkit consumes `@sentry/api` and follows after the 2.0.0
release.

Nothing else in this repo reads the spec, apart from the `openapi.yml`
sync and the `openapi-diff` comment workflow. The private investigation
schema reuses these settings and now has the same shape; nothing
consumes it.

**Checks** (run locally):
- `make build-api-docs` and `make build-investigation-api-docs` both
pass. Against master, the derefed spec differs only in `servers`, the
path keys and the seer op server.
- `pnpm run validate-api-examples` passes.
- `pytest tests/apidocs` passes (167). Tests changed:
  - `RelativePathsHookTest` is new.
- `APIDocsTestCase` keeps the server URL path when it swaps the origin.
Without that, openapi-core can't match any request (`ServerNotFound`).
- Two endpoint tests read schema entries by path and now use relative
keys.
- prek and mypy pass on the changed files.

<!-- junior-request-attribution:start -->
via @BYK.
<!-- junior-request-attribution:end -->

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A640c5343d03e547097aa9b8a711daaf5 -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3A640c5343d03e547097aa9b8a711daaf5)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A640c5343d03e547097aa9b8a711daaf5/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>
BYK added a commit that referenced this pull request Nov 13, 2026
Stacked on #149. Ported from getsentry/sentry#130538, per @BYK's request
there: get the docs aligned here first, then move `@sentry/api` into
getsentry/sentry.

This adds the data sentry-docs needs to build the API reference from the
`@sentry/api` version it pins, so it no longer has to read
`openapi-derefed.json` from a pinned commit of this repository. The
sentry-docs adapter is getsentry/sentry-docs#20679.

## Changes from the sentry version

2.0.0 (#148) made every path relative to the API base path and trimmed
server URLs to their origin. The export now follows the same contract:
- operation `path`s are relative, such as `/organizations/`, the same as
`@sentry/api/operations`;
- the base path ships as `API_BASE_PATH` (`/api/0`) and as `apiBasePath`
in `extractDocs()` output, so a page builds the full path as
`${API_BASE_PATH}${operation.path}`;
- per-operation `servers` are origins.

`extractDocs()` applies `splitApiBasePath` to a copy of the spec it
gets, so the published data and a preview of a local spec have the same
shape. It accepts both the current spec and one that declares the base
path in `servers`.

**sentry-docs#20679 needs a follow-up for this:** its adapter has to
prepend `API_BASE_PATH` to paths, like sentry-docs#20787 does for 2.0.0.
I'll push that once this shape is settled.

## What it adds

- **`scripts/generate-docs.mjs`**, next to the `operations` generator.
It runs `lib/docs-catalog.mjs` `extractDocs(spec)` on the original
`openapi-derefed.json` and emits `src/docs.ts`. That file holds the data
as a JSON string literal, so tsc doesn't infer a type for 3.7 MB of
data. It ships as **`@sentry/api/docs`** (`dist/docs.js` plus `.d.ts`).
- **Shape** (`lib/docs-types.ts`):
  - `apiBasePath` / `API_BASE_PATH`.
- `tags`: name, description, `sidebarName` (`x-sidebar-name`) and
`displayDescription` (`x-display-description`).
- `operations`, in spec order: `operationId`, method and relative path,
with summary and description copied as written. Also tags, `deprecated`,
`experimental` (`x-sentry-experimental`), server origins, security
requirements, and parameters (path-level merged with operation
overrides) with `in`, `required`, description, `experimental` and
schema. The request body comes from its first content type (content
type, `required`, schema, example), and every response has its status,
description, content type, schema and `example`/`examples`.
- **`extractDocs` is exported too**, so sentry-docs'
`OPENAPI_LOCAL_PATH` preview of an unreleased local spec goes through
the same projection as the published data.
- `lib/http-methods.mjs` holds the HTTP method set that both catalogs
use.

## The export's shape is now a semver contract

sentry-docs, and anything else that imports `@sentry/api/docs`, depends
on these field names and types. Removing or renaming a field, or
changing its type, is a **major** release. Adding fields is a minor
release. The README says so.

## Size

Measured with `npm pack --dry-run` from clean builds of #149 and this
branch, using the spec in this repository:

| | tgz | unpacked | files |
|---|---|---|---|
| #149 | 3,385,825 B | 35,812,862 B | 80 |
| this PR | 3,828,867 B | 39,660,930 B | 86 |
| change | **+443 KB (+13%)** | **+3.85 MB (+11%)** | +6 |

Almost all of it is `dist/docs.js` (3,840,561 B). It's a separate entry
point, so `@sentry/api` and `@sentry/api/client` users like toolkit
don't load it unless they import `./docs`. They only pay for the
download.

## Tests

- `test/docs.test.ts`:
- A fixture test pins the exact output shape. Its spec declares `/api/0`
in `servers` and an operation server override under it, so the test also
covers the relative paths, `apiBasePath` and origin-only servers. It
also covers path-level parameter overrides, experimental flags, multiple
content types, responses without content, named examples and security.
- Drift tests compare the published `@sentry/api/docs` with
`openapi-derefed.json`:
- the export equals `extractDocs(spec)`, and `API_BASE_PATH` is
`/api/0`;
- tag and operation order matches the spec, with `API_BASE_PATH` plus
each relative path equal to the spec's path;
- for every operation, the text, parameters, request schema and example,
and response schemas and examples match the spec directly.
- `test/package.test.ts`: the packed-tarball smoke test imports
`@sentry/api/docs`.

`pnpm build`, `pnpm typecheck` and `pnpm test` pass locally (54 files,
679 tests).

No UI change here. The sentry-docs PR carries the API reference
screenshots.

## Merge order

#149, this PR, the normal release of an `@sentry/api` version with
`./docs`, then getsentry/sentry-docs#20679 with its `API_BASE_PATH`
follow-up. That PR's dependency bump is its last step.

Co-Authored-By: BYK <126780+BYK@users.noreply.github.com>

<!-- junior-session-footer:start -->
<!-- junior-conversation-id:agent%3A69da19b385017e68b7f9eace56e9477f -->

--

[View
Session](https://junior-prod.sentry.dev/conversations/agent%3A69da19b385017e68b7f9eace56e9477f)
[[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A69da19b385017e68b7f9eace56e9477f/?project=4510944073809921)

<!-- junior-session-footer:end -->

---------

Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com>
Co-authored-by: BYK <126780+BYK@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant