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/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 f8a01f3a362b..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, ) @@ -1510,11 +1511,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": 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..876c13b34c56 100644 --- a/src/sentry/web/urls.py +++ b/src/sentry/web/urls.py @@ -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,10 @@ name="get_cli_download_url", ), # Versioned API + # A regex route, not path(): route strings such as "^api/0/users/$" feed analytics and + # generated silo URL lists. re_path( - r"^api/0/", + rf"^{re.escape(API_BASE_PATH.removeprefix('/'))}/", include("sentry.api.urls"), ), # Legacy unversioned endpoints 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."""