Skip to content

Commit 64f2d18

Browse files
jopemachineclaude
andcommitted
feat(BA-6921): accept and return scope_id as a UUID on the v2 API
Follows the column becoming UUID NULL in BA-6948: the request and response DTOs take UUID | None, so a non-UUID scope_id is rejected at the boundary instead of reaching a row no scope query can find. The adapter no longer maps public to an empty-string sentinel in either direction — None is what the column stores. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3adaf5e commit 64f2d18

5 files changed

Lines changed: 31 additions & 18 deletions

File tree

docs/manager/rest-reference/openapi.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,7 @@
574574
"scope_id": {
575575
"anyOf": [
576576
{
577+
"format": "uuid",
577578
"type": "string"
578579
},
579580
{

src/ai/backend/common/dto/manager/v2/app_config_fragment/request.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class CreateAppConfigFragmentInput(BaseRequestModel):
3030
scope_type: AppConfigScopeType = Field(
3131
description="Scope the fragment is written at (public | domain | user)."
3232
)
33-
scope_id: str | None = Field(
33+
scope_id: UUID | None = Field(
3434
default=None,
3535
description="Scope identifier: the domain id (domain scope) or the user id (user scope). "
3636
"Null for public scope, which has no owner.",
@@ -42,7 +42,7 @@ def _check_scope_id(self) -> Self:
4242
if self.scope_type is AppConfigScopeType.PUBLIC:
4343
if self.scope_id is not None:
4444
raise ValueError("scope_id must be null for public scope.")
45-
elif not self.scope_id:
45+
elif self.scope_id is None:
4646
raise ValueError("scope_id is required for domain and user scopes.")
4747
return self
4848

src/ai/backend/common/dto/manager/v2/app_config_fragment/response.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class AppConfigFragmentNode(BaseResponseModel):
2828
id: UUID = Field(description="App config fragment UUID.")
2929
config_name: str = Field(description="Config name the fragment belongs to.")
3030
scope_type: AppConfigScopeType = Field(description="Scope the fragment is written at.")
31-
scope_id: str | None = Field(
31+
scope_id: UUID | None = Field(
3232
description="Scope identifier: the domain id or user id; null for public scope."
3333
)
3434
config: dict[str, Any] = Field(description="The fragment's JSON config document.")

src/ai/backend/manager/api/adapters/app_config_fragment/adapter.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,23 +56,19 @@
5656
)
5757
from ai.backend.manager.types import OptionalState
5858

59-
# Public fragments have no scope owner; the non-null scope_id column stores this empty
60-
# sentinel. Public is identified by scope_type, never by this value.
61-
_PUBLIC_SCOPE_ID = ""
62-
6359

6460
class AppConfigFragmentAdapter(BaseAdapter):
6561
"""Adapter for raw app config fragment write operations."""
6662

6763
# --- fragment CRUD (RBAC-gated at the processor) ---
6864

6965
async def create(self, input: CreateAppConfigFragmentInput) -> CreateAppConfigFragmentPayload:
70-
# scope_id is None only for public (enforced by the DTO validator); persist the empty
71-
# sentinel since the column is non-null. Domain/user carry the id.
66+
# scope_id is None exactly for public (enforced by the DTO validator), which is what
67+
# the column stores for an ownerless fragment.
7268
spec = AppConfigFragmentCreatorSpec(
7369
config_name=input.config_name,
7470
scope_type=AppConfigScopeType(input.scope_type.value),
75-
scope_id=input.scope_id or _PUBLIC_SCOPE_ID,
71+
scope_id=input.scope_id,
7672
config=input.config,
7773
)
7874
action_result = await self._processors.app_config_fragment.create.wait_for_complete(
@@ -159,8 +155,7 @@ def _fragment_to_node(data: AppConfigFragmentData) -> AppConfigFragmentNode:
159155
id=data.id,
160156
config_name=data.config_name,
161157
scope_type=AppConfigScopeTypeDTO(data.scope_type.value),
162-
# public has no owner — expose null rather than the stored empty sentinel.
163-
scope_id=None if data.scope_type is AppConfigScopeType.PUBLIC else data.scope_id,
158+
scope_id=data.scope_id,
164159
config=data.config,
165160
created_at=data.created_at,
166161
updated_at=data.updated_at,

tests/unit/common/dto/manager/v2/app_config_fragment/test_request.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
)
2020
from ai.backend.common.exception import BackendAISchemaValidationFailed
2121

22-
_SCOPE_ID = "11111111-1111-1111-1111-111111111111"
22+
_SCOPE_ID = uuid.UUID("11111111-1111-1111-1111-111111111111")
2323

2424

2525
@dataclass(frozen=True)
2626
class _ScopeCase:
2727
scope_type: AppConfigScopeType
28-
scope_id: str | None
28+
scope_id: uuid.UUID | None
2929

3030

3131
@pytest.fixture
@@ -63,13 +63,11 @@ def test_scope_id_matching_its_scope_type_is_accepted(
6363
[
6464
# public has no owner, so naming one is a contradiction.
6565
_ScopeCase(scope_type=AppConfigScopeType.PUBLIC, scope_id=_SCOPE_ID),
66-
# domain and user both require an owner; absent and blank are equally unusable.
66+
# domain and user both require an owner.
6767
_ScopeCase(scope_type=AppConfigScopeType.DOMAIN, scope_id=None),
68-
_ScopeCase(scope_type=AppConfigScopeType.DOMAIN, scope_id=""),
6968
_ScopeCase(scope_type=AppConfigScopeType.USER, scope_id=None),
70-
_ScopeCase(scope_type=AppConfigScopeType.USER, scope_id=""),
7169
],
72-
ids=lambda case: f"{case.scope_type.value}-{case.scope_id!r}",
70+
ids=lambda case: f"{case.scope_type.value}-{case.scope_id}",
7371
)
7472
def test_scope_id_disagreeing_with_its_scope_type_is_rejected(
7573
self, case: _ScopeCase, config_document: dict[str, Any]
@@ -82,6 +80,25 @@ def test_scope_id_disagreeing_with_its_scope_type_is_rejected(
8280
config=config_document,
8381
)
8482

83+
@pytest.mark.parametrize(
84+
"scope_type",
85+
[AppConfigScopeType.DOMAIN, AppConfigScopeType.USER],
86+
ids=lambda scope_type: scope_type.value,
87+
)
88+
@pytest.mark.parametrize("scope_id", ["", "not-a-uuid"], ids=["empty", "malformed"])
89+
def test_scope_id_that_is_not_a_uuid_is_rejected(
90+
self, scope_type: AppConfigScopeType, scope_id: str, config_document: dict[str, Any]
91+
) -> None:
92+
# Validated from a mapping because the field is typed UUID: a string only ever
93+
# reaches it from a request payload, never from a type-checked call.
94+
with pytest.raises((BackendAISchemaValidationFailed, ValidationError)):
95+
CreateAppConfigFragmentInput.model_validate({
96+
"config_name": "theme",
97+
"scope_type": scope_type,
98+
"scope_id": scope_id,
99+
"config": config_document,
100+
})
101+
85102
def test_scope_id_defaults_to_none(self, config_document: dict[str, Any]) -> None:
86103
req = CreateAppConfigFragmentInput(
87104
config_name="theme",

0 commit comments

Comments
 (0)