From 34303201c38869b305eb3f288945cd95c31d04a3 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:27:03 +0000 Subject: [PATCH 1/3] feat(apidocs)!: Declare the API base path in the spec's server URL The OpenAPI server URL was the bare origin, and every path spelled out /api/0, so clients had to know the prefix, and the version in it, instead of reading it from the spec. The server URL is now https://{region}.sentry.io/api/0 and paths are relative to it. A last postprocessing hook strips the base path, so the hooks before it still see full paths. It also adds the base path to per-endpoint servers, which name origins. BREAKING CHANGE: Spec paths no longer start with /api/0; join them to the server URL. getsentry/sentry-api-schema#148 handles both shapes. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- fixtures/apidocs_test_case.py | 5 +++- src/sentry/api/base.py | 1 + src/sentry/apidocs/hooks.py | 23 ++++++++++++++ src/sentry/conf/server.py | 5 +++- .../test_group_integration_details.py | 2 +- .../test_sentry_app_external_issues.py | 2 +- tests/apidocs/test_hooks.py | 30 +++++++++++++++++++ 7 files changed, 64 insertions(+), 4 deletions(-) diff --git a/fixtures/apidocs_test_case.py b/fixtures/apidocs_test_case.py index a8b78947d371..e7fab77e297a 100644 --- a/fixtures/apidocs_test_case.py +++ b/fixtures/apidocs_test_case.py @@ -1,5 +1,6 @@ import functools import os +from urllib.parse import urlsplit import orjson from django.conf import settings @@ -22,7 +23,9 @@ def _load_schema() -> Spec: path = os.path.join(os.path.dirname(__file__), "../tests/apidocs/openapi-derefed.json") with open(path, "rb") as json_file: data = orjson.loads(json_file.read()) - data["servers"][0]["url"] = settings.SENTRY_OPTIONS["system.url-prefix"] + server = data["servers"][0] + # Paths are relative to the server URL's path, so keep it. + server["url"] = settings.SENTRY_OPTIONS["system.url-prefix"] + urlsplit(server["url"]).path del data["components"] return Spec.from_dict(data) diff --git a/src/sentry/api/base.py b/src/sentry/api/base.py index 36e421d99ed2..246e3d5d05f6 100644 --- a/src/sentry/api/base.py +++ b/src/sentry/api/base.py @@ -305,6 +305,7 @@ class Endpoint(APIView): idempotent_methods: frozenset[HTTP_METHOD_NAME] | None = None rate_limits: RateLimitConfig | Callable[..., RateLimitConfig] = DEFAULT_RATE_LIMIT_CONFIG enforce_rate_limit: bool = settings.SENTRY_RATELIMITER_ENABLED + # Origins that serve this endpoint, for the API docs; the schema build adds the base path. servers: list[dict[str, Any]] | None = None # For endpoints people may call with any credential, API tokens included, but a caller # with no user (e.g. a service account) may not: it gets a 403 user-required, counted diff --git a/src/sentry/apidocs/hooks.py b/src/sentry/apidocs/hooks.py index 474fbd4d1847..001e2d539373 100644 --- a/src/sentry/apidocs/hooks.py +++ b/src/sentry/apidocs/hooks.py @@ -4,6 +4,7 @@ from collections import OrderedDict from collections.abc import Callable, Mapping from typing import Any, Literal, NotRequired, TypedDict, cast +from urllib.parse import urlsplit from django.http import HttpResponseBase from drf_spectacular.generators import EndpointEnumerator, SchemaGenerator @@ -434,6 +435,28 @@ def custom_postprocessing_hook( return result +def relative_paths_hook(result: dict[str, Any], generator: Any, **kwargs: Any) -> dict[str, Any]: + """Make every path relative to the path of the first server URL, the API base path. + + Clients then read the base path, and the API version in it, from `servers` instead of + from each path. An endpoint's `servers` name origins, so they get the base path too. + """ + base_path = urlsplit(result["servers"][0]["url"]).path.rstrip("/") + paths = {} + for path, path_item in result["paths"].items(): + if not path.startswith(f"{base_path}/"): + raise SentryApiBuildError(f"{path} is not under the API base path {base_path}.") + for operation in path_item.values(): + if isinstance(operation, dict) and "servers" in operation: + # New dicts: the originals are the endpoint class's own `servers`. + operation["servers"] = [ + {**server, "url": server["url"] + base_path} for server in operation["servers"] + ] + paths[path.removeprefix(base_path)] = path_item + result["paths"] = paths + return result + + def _check_tag(method_info: Mapping[str, Any], endpoint_name: str) -> None: if method_info.get("tags") is None: raise SentryApiBuildError( diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index f8a01f3a362b..3a8ead06106d 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1510,11 +1510,14 @@ def custom_parameter_sort(parameter: _OperationParameter) -> tuple[str, int]: "POSTPROCESSING_HOOKS": [ "sentry.apidocs.hooks.custom_postprocessing_hook", "drf_spectacular.hooks.postprocess_schema_enum_id_removal", + # Last, so the hooks above see full paths. + "sentry.apidocs.hooks.relative_paths_hook", ], "PREPROCESSING_HOOKS": ["sentry.apidocs.hooks.custom_preprocessing_hook"], "SERVERS": [ { - "url": "https://{region}.sentry.io", + # The path is the API base path; operation paths are relative to it. + "url": "https://{region}.sentry.io/api/0", "variables": { "region": { "default": "us", diff --git a/tests/apidocs/endpoints/integration_platform/test_group_integration_details.py b/tests/apidocs/endpoints/integration_platform/test_group_integration_details.py index 60b443f8a519..deeddba0d037 100644 --- a/tests/apidocs/endpoints/integration_platform/test_group_integration_details.py +++ b/tests/apidocs/endpoints/integration_platform/test_group_integration_details.py @@ -66,7 +66,7 @@ def test_put(self) -> None: self.validate_schema(request, response) schema = self.cached_schema.content()["paths"][ - "/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/integrations/{integration_id}/" + "/organizations/{organization_id_or_slug}/issues/{issue_id}/integrations/{integration_id}/" ]["put"]["requestBody"]["content"]["application/json"]["schema"] assert {"externalIssue", "repo", "comment"} <= schema["properties"].keys() assert schema["required"] == ["externalIssue"] diff --git a/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issues.py b/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issues.py index a513efcf2247..acb49213b7f1 100644 --- a/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issues.py +++ b/tests/apidocs/endpoints/integration_platform/test_sentry_app_external_issues.py @@ -73,7 +73,7 @@ def test_issue_link_action(self) -> None: self.validate_schema(RequestFactory().post(url, data), response) request_body = self.cached_schema.content()["paths"][ - "/api/0/sentry-app-installations/{uuid}/external-issue-actions/" + "/sentry-app-installations/{uuid}/external-issue-actions/" ]["post"]["requestBody"] assert request_body["required"] is True schema = request_body["content"]["application/json"]["schema"] diff --git a/tests/apidocs/test_hooks.py b/tests/apidocs/test_hooks.py index e7e9de30d17a..e7dc7e8c4adc 100644 --- a/tests/apidocs/test_hooks.py +++ b/tests/apidocs/test_hooks.py @@ -20,6 +20,7 @@ _fix_nullable_enums, custom_postprocessing_hook, custom_preprocessing_hook, + relative_paths_hook, ) from sentry.apidocs.utils import SentryApiBuildError from sentry.core.endpoints.organization_index import OrganizationIndexEndpoint @@ -96,6 +97,35 @@ def test_servers_applied_to_endpoint(self) -> None: assert "servers" not in processed["paths"]["/api/0/other/endpoint/"]["get"] +class RelativePathsHookTest(TestCase): + def test_paths_are_relative_to_the_server_path(self) -> None: + endpoint_servers = [{"url": "https://{region}.sentry.io"}] + result = { + "servers": [{"url": "https://{region}.sentry.io/api/0"}], + "paths": { + "/api/0/seer/models/": {"get": {"servers": endpoint_servers}}, + "/api/0/organizations/": {"get": {}}, + }, + } + + processed = relative_paths_hook(result, None) + + assert processed["paths"] == { + "/seer/models/": {"get": {"servers": [{"url": "https://{region}.sentry.io/api/0"}]}}, + "/organizations/": {"get": {}}, + } + assert endpoint_servers == [{"url": "https://{region}.sentry.io"}] + + def test_rejects_a_path_outside_the_base_path(self) -> None: + result = { + "servers": [{"url": "https://{region}.sentry.io/api/0"}], + "paths": {"/oauth/token/": {}}, + } + + with pytest.raises(SentryApiBuildError, match="not under the API base path /api/0"): + relative_paths_hook(result, None) + + class PublishStatusFilterTest(TestCase): """Only published statuses reach the OpenAPI pipeline, and PUBLIC_EXPERIMENTAL is marked.""" From 37d52552ecc282a1d4df3a4914e10adb62d85257 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:43:27 +0000 Subject: [PATCH 2/3] ref(apidocs): Share the API base path between the URL conf and the spec Mount sentry.api.urls at API_BASE_PATH and build the spec's server URL from it, so the two can't drift apart. --- src/sentry/conf/api_base_path.py | 4 ++++ src/sentry/conf/server.py | 3 ++- src/sentry/web/urls.py | 7 ++++--- 3 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 src/sentry/conf/api_base_path.py diff --git a/src/sentry/conf/api_base_path.py b/src/sentry/conf/api_base_path.py new file mode 100644 index 000000000000..a3826c99b8b1 --- /dev/null +++ b/src/sentry/conf/api_base_path.py @@ -0,0 +1,4 @@ +# The versioned API's mount point. The URL conf mounts sentry.api.urls here, and the +# API docs put it in the spec's server URL, so clients read it, and the version in it, +# from the spec instead of hard-coding it. +API_BASE_PATH = "/api/0" diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 3a8ead06106d..8806ab764ff8 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -19,6 +19,7 @@ import sentry import sentry.utils.types as env_types +from sentry.conf import api_base_path from sentry.conf.api_pagination_allowlist_do_not_modify import ( SENTRY_API_PAGINATION_ALLOWLIST_DO_NOT_MODIFY, ) @@ -1517,7 +1518,7 @@ def custom_parameter_sort(parameter: _OperationParameter) -> tuple[str, int]: "SERVERS": [ { # The path is the API base path; operation paths are relative to it. - "url": "https://{region}.sentry.io/api/0", + "url": f"https://{{region}}.sentry.io{api_base_path.API_BASE_PATH}", "variables": { "region": { "default": "us", diff --git a/src/sentry/web/urls.py b/src/sentry/web/urls.py index da825a0828a1..66080b02c32b 100644 --- a/src/sentry/web/urls.py +++ b/src/sentry/web/urls.py @@ -4,7 +4,7 @@ from django.conf import settings from django.conf.urls import include -from django.urls import URLPattern, URLResolver, re_path +from django.urls import URLPattern, URLResolver, path, re_path from django.views.decorators.cache import never_cache from django.views.decorators.http import require_safe from django.views.generic import RedirectView @@ -14,6 +14,7 @@ from sentry.api.endpoints.warmup import WarmupEndpoint from sentry.auth.providers.saml2.provider import SAML2AcceptACSView, SAML2MetadataView, SAML2SLSView from sentry.charts.endpoints import serve_chartcuterie_config +from sentry.conf.api_base_path import API_BASE_PATH from sentry.conf.types.sentry_config import SentryMode from sentry.feedback.endpoints.error_page_embed import ErrorPageEmbedView from sentry.integrations.web.doc_integration_avatar import DocIntegrationAvatarPhotoView @@ -181,8 +182,8 @@ name="get_cli_download_url", ), # Versioned API - re_path( - r"^api/0/", + path( + f"{API_BASE_PATH.removeprefix('/')}/", include("sentry.api.urls"), ), # Legacy unversioned endpoints From 6a8116074b82064af2107a6e20661ab0b919df3d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:51:31 +0000 Subject: [PATCH 3/3] fix(apidocs): Keep the API mount a regex route path() changed the mount's route string from "^api/0/" to "api/0/", which broke dev toolbar analytics routes and the generated control silo URLs. Build the same regex from API_BASE_PATH instead. --- src/sentry/web/urls.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/sentry/web/urls.py b/src/sentry/web/urls.py index 66080b02c32b..876c13b34c56 100644 --- a/src/sentry/web/urls.py +++ b/src/sentry/web/urls.py @@ -4,7 +4,7 @@ from django.conf import settings from django.conf.urls import include -from django.urls import URLPattern, URLResolver, path, re_path +from django.urls import URLPattern, URLResolver, re_path from django.views.decorators.cache import never_cache from django.views.decorators.http import require_safe from django.views.generic import RedirectView @@ -182,8 +182,10 @@ name="get_cli_download_url", ), # Versioned API - path( - f"{API_BASE_PATH.removeprefix('/')}/", + # A regex route, not path(): route strings such as "^api/0/users/$" feed analytics and + # generated silo URL lists. + re_path( + rf"^{re.escape(API_BASE_PATH.removeprefix('/'))}/", include("sentry.api.urls"), ), # Legacy unversioned endpoints