Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion fixtures/apidocs_test_case.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import functools
import os
from urllib.parse import urlsplit

import orjson
from django.conf import settings
Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/sentry/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/sentry/apidocs/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions src/sentry/conf/api_base_path.py
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 5 additions & 1 deletion src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/sentry/web/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
30 changes: 30 additions & 0 deletions tests/apidocs/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down
Loading