Skip to content

Commit ca6f4c4

Browse files
grichacodex
andauthored
feat(api): Add project ID-or-slug parser (#117445)
Add a shared project ID-or-slug parser and DRF field for endpoints that explicitly opt in to accepting either identifier. Existing ProjectField behavior stays slug-only by default, while id_allowed=True can now resolve project IDs or slugs within the current organization and permission context. This is the foundation PR for the project slug API split. It only introduces the helper, serializer support, and focused tests; endpoint resolver behavior lands in the stacked follow-up PR #117446. Co-authored-by: OpenAI GPT-5.5 <noreply@openai.com>
1 parent ac61cbb commit ca6f4c4

5 files changed

Lines changed: 224 additions & 3 deletions

File tree

src/sentry/api/helpers/projects.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable
4+
from typing import NamedTuple, TypeAlias
5+
6+
from drf_spectacular.types import OpenApiTypes
7+
from drf_spectacular.utils import extend_schema_field
8+
from rest_framework import serializers
9+
10+
from sentry.constants import ALL_ACCESS_PROJECT_ID, ALL_ACCESS_PROJECTS_SLUG
11+
from sentry.utils.slug import DEFAULT_SLUG_ERROR_MESSAGE, MIXED_SLUG_REGEX
12+
13+
ProjectIdOrSlug: TypeAlias = int | str
14+
15+
16+
class ParsedProjectIdOrSlugParams(NamedTuple):
17+
ids: set[int]
18+
slugs: set[str]
19+
20+
@property
21+
def has_values(self) -> bool:
22+
return bool(self.ids or self.slugs)
23+
24+
@property
25+
def has_all_projects_sentinel(self) -> bool:
26+
return ALL_ACCESS_PROJECT_ID in self.ids or ALL_ACCESS_PROJECTS_SLUG in self.slugs
27+
28+
29+
def parse_id_or_slug_params(
30+
values: Iterable[ProjectIdOrSlug | None],
31+
) -> ParsedProjectIdOrSlugParams:
32+
"""
33+
Partition project identifier values into numeric IDs and slugs.
34+
35+
All-digit values and the ``-1`` all-access project sigil are treated as IDs.
36+
Everything else is treated as a slug.
37+
"""
38+
ids: set[int] = set()
39+
slugs: set[str] = set()
40+
for value in values:
41+
if value is None or value == "":
42+
continue
43+
if isinstance(value, int) and not isinstance(value, bool):
44+
ids.add(value)
45+
continue
46+
47+
value_str = str(value)
48+
if value_str.isdecimal() or value_str == str(ALL_ACCESS_PROJECT_ID):
49+
ids.add(int(value_str))
50+
else:
51+
slugs.add(value_str)
52+
return ParsedProjectIdOrSlugParams(ids=ids, slugs=slugs)
53+
54+
55+
@extend_schema_field(field=OpenApiTypes.STR)
56+
class ProjectIdOrSlugField(serializers.Field[ProjectIdOrSlug, object, ProjectIdOrSlug, object]):
57+
default_error_messages = {
58+
"invalid": "Expected a project ID or slug.",
59+
"invalid_slug": DEFAULT_SLUG_ERROR_MESSAGE,
60+
}
61+
62+
def to_internal_value(self, data: object) -> ProjectIdOrSlug:
63+
if data is None or isinstance(data, bool):
64+
self.fail("invalid")
65+
if isinstance(data, int):
66+
return data
67+
if not isinstance(data, str) or data == "":
68+
self.fail("invalid")
69+
if data.isdecimal() or data == str(ALL_ACCESS_PROJECT_ID):
70+
return int(data)
71+
if data == ALL_ACCESS_PROJECTS_SLUG:
72+
return data
73+
if MIXED_SLUG_REGEX.match(data) is None:
74+
self.fail("invalid_slug")
75+
return data
76+
77+
def to_representation(self, value: ProjectIdOrSlug) -> ProjectIdOrSlug:
78+
return value

src/sentry/api/serializers/rest_framework/project.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from drf_spectacular.utils import extend_schema_field
55
from rest_framework import serializers
66

7+
from sentry.api.helpers.projects import ProjectIdOrSlugField
78
from sentry.models.project import Project
89

910
ValidationError = serializers.ValidationError
@@ -18,6 +19,8 @@ def __init__(
1819
The scope parameter specifies which permissions are required to access the project field.
1920
If multiple scopes are provided, the project can be accessed when the user is authenticated with
2021
any of the scopes.
22+
23+
If id_allowed is true, the field accepts a project ID or slug. Otherwise, it accepts slugs only.
2124
"""
2225
self.scope = scope
2326
self.id_allowed = id_allowed
@@ -26,18 +29,30 @@ def __init__(
2629
def to_representation(self, value):
2730
return value
2831

29-
def to_internal_value(self, data):
32+
def to_internal_value(self, data: object) -> Project:
3033
try:
3134
if self.id_allowed:
35+
project_id_or_slug = ProjectIdOrSlugField().to_internal_value(data)
3236
project = Project.objects.get(
33-
organization=self.context["organization"], slug__id_or_slug=data
37+
organization=self.context["organization"], slug__id_or_slug=project_id_or_slug
3438
)
3539
else:
36-
project = Project.objects.get(organization=self.context["organization"], slug=data)
40+
project_slug = self._validate_slug(data)
41+
project = Project.objects.get(
42+
organization=self.context["organization"], slug=project_slug
43+
)
3744
except Project.DoesNotExist:
3845
raise ValidationError("Invalid project")
3946

4047
scopes = (self.scope,) if isinstance(self.scope, str) else self.scope
4148
if not self.context["access"].has_any_project_scope(project, scopes):
4249
raise ValidationError("Insufficient access to project")
4350
return project
51+
52+
def _validate_slug(self, data: object) -> str:
53+
if not isinstance(data, str):
54+
raise ValidationError("Invalid project")
55+
project_id_or_slug = ProjectIdOrSlugField().to_internal_value(data)
56+
if not isinstance(project_id_or_slug, str):
57+
raise ValidationError("Invalid project")
58+
return project_id_or_slug
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import pytest
2+
from rest_framework import serializers
3+
4+
from sentry.api.helpers.projects import ProjectIdOrSlugField, parse_id_or_slug_params
5+
6+
7+
class TestParseIdOrSlugParams:
8+
def test_empty_input(self) -> None:
9+
params = parse_id_or_slug_params([])
10+
assert params.ids == set()
11+
assert params.slugs == set()
12+
13+
def test_numeric_values(self) -> None:
14+
params = parse_id_or_slug_params(["1", "2", "3"])
15+
assert params.ids == {1, 2, 3}
16+
assert params.slugs == set()
17+
18+
def test_slug_values(self) -> None:
19+
params = parse_id_or_slug_params(["my-project", "another-proj"])
20+
assert params.ids == set()
21+
assert params.slugs == {"my-project", "another-proj"}
22+
23+
def test_mixed_values(self) -> None:
24+
params = parse_id_or_slug_params(["1", "my-project", 42])
25+
assert params.ids == {1, 42}
26+
assert params.slugs == {"my-project"}
27+
28+
def test_empty_values_are_skipped(self) -> None:
29+
params = parse_id_or_slug_params(["", None, "1", "foo"])
30+
assert params.ids == {1}
31+
assert params.slugs == {"foo"}
32+
33+
def test_all_access_numeric_sentinel_is_id(self) -> None:
34+
params = parse_id_or_slug_params(["-1"])
35+
assert params.ids == {-1}
36+
assert params.slugs == set()
37+
38+
def test_negative_non_sentinel_is_slug(self) -> None:
39+
params = parse_id_or_slug_params(["-2"])
40+
assert params.ids == set()
41+
assert params.slugs == {"-2"}
42+
43+
def test_all_access_sigil_is_slug(self) -> None:
44+
params = parse_id_or_slug_params(["$all"])
45+
assert params.ids == set()
46+
assert params.slugs == {"$all"}
47+
48+
def test_detects_all_access_sentinels(self) -> None:
49+
assert parse_id_or_slug_params(["-1"]).has_all_projects_sentinel
50+
assert parse_id_or_slug_params(["$all"]).has_all_projects_sentinel
51+
assert not parse_id_or_slug_params(["1", "my-project"]).has_all_projects_sentinel
52+
53+
def test_deduplication(self) -> None:
54+
params = parse_id_or_slug_params(["1", "1", "foo", "foo"])
55+
assert params.ids == {1}
56+
assert params.slugs == {"foo"}
57+
58+
59+
class TestProjectIdOrSlugField:
60+
def test_accepts_ids_and_slugs(self) -> None:
61+
field = ProjectIdOrSlugField()
62+
assert field.to_internal_value("1") == 1
63+
assert field.to_internal_value(2) == 2
64+
assert field.to_internal_value("-1") == -1
65+
assert field.to_internal_value("my-project") == "my-project"
66+
67+
def test_negative_non_sentinel_string_is_slug(self) -> None:
68+
field = ProjectIdOrSlugField()
69+
assert field.to_internal_value("-2") == "-2"
70+
71+
def test_rejects_invalid_slug(self) -> None:
72+
field = ProjectIdOrSlugField()
73+
74+
with pytest.raises(serializers.ValidationError) as error:
75+
field.to_internal_value("foo bar")
76+
77+
assert "Enter a valid slug" in str(error.value)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from unittest.mock import Mock
2+
3+
from rest_framework import serializers
4+
5+
from sentry.api.serializers.rest_framework.project import ProjectField
6+
from sentry.testutils.cases import TestCase
7+
8+
9+
class ProjectFieldTest(TestCase):
10+
def test_id_allowed_accepts_project_id(self) -> None:
11+
project = self.create_project()
12+
access = Mock()
13+
access.has_any_project_scope.return_value = True
14+
15+
class ProjectSerializer(serializers.Serializer):
16+
project = ProjectField(scope="project:read", id_allowed=True)
17+
18+
serializer = ProjectSerializer(
19+
data={"project": str(project.id)},
20+
context={"organization": project.organization, "access": access},
21+
)
22+
23+
assert serializer.is_valid(), serializer.errors
24+
assert serializer.validated_data["project"] == project
25+
26+
def test_default_rejects_project_id(self) -> None:
27+
project = self.create_project()
28+
access = Mock()
29+
access.has_any_project_scope.return_value = True
30+
31+
class ProjectSerializer(serializers.Serializer):
32+
project = ProjectField(scope="project:read")
33+
34+
serializer = ProjectSerializer(
35+
data={"project": str(project.id)},
36+
context={"organization": project.organization, "access": access},
37+
)
38+
39+
assert not serializer.is_valid()
40+
assert serializer.errors == {"project": ["Invalid project"]}

tests/sentry/models/test_project.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
from collections.abc import Iterable
22
from unittest.mock import MagicMock, patch
33

4+
import pytest
5+
from django.core.exceptions import ValidationError
6+
47
from sentry.constants import ObjectStatus
8+
from sentry.db.models.fields.slug import SentrySlugField
59
from sentry.deletions.models.scheduleddeletion import CellScheduledDeletion
610
from sentry.deletions.tasks.hybrid_cloud import schedule_hybrid_cloud_foreign_key_jobs_control
711
from sentry.grouping.grouptype import ErrorGroupType
@@ -48,6 +52,13 @@
4852

4953

5054
class ProjectTest(APITestCase, TestCase):
55+
def test_slug_rejects_numeric_values(self) -> None:
56+
slug_field = Project._meta.get_field("slug")
57+
58+
assert isinstance(slug_field, SentrySlugField)
59+
with pytest.raises(ValidationError):
60+
slug_field.run_validators("123")
61+
5162
def test_member_set_simple(self) -> None:
5263
user = self.create_user()
5364
org = self.create_organization(owner=user)

0 commit comments

Comments
 (0)