Skip to content

Commit 540cd0a

Browse files
jopemachineclaude
andcommitted
feat(BA-5830): add AppConfigFragment + AppConfig REST v2 surface
REST endpoints for the Fragment domain and the merged AppConfig view (BEP-1052 §2 / §5). Service / adapter foundation lands in BA-5827 (Fragment bulk) and BA-5829 (merged-view actions); this PR only wires the REST entry points on top. REST v2 (`/v2/app-config-fragments`): - `GET /{scope_type}/{scope_id}/{name}` — auth_required. - `POST /{scope_type}/{scope_id}/search` — scope-bound search. - `POST /search` — superadmin cross-scope search. - `POST /bulk-create` / `bulk-update` / `bulk-purge` — superadmin only. - `POST /my/bulk-create` / `my/bulk-update` — self-service USER scope. REST v2 (`/v2/app-configs`, merged view): - `GET /my/{name}` / `POST /my/search` — auth_required. - `GET /{user_id}/{name}` / `POST /search` — superadmin only. Adds the path-param classes for the new URL segments and wires the handlers into `rest/v2/tree.py`. Resolves BA-5830. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9da50fc commit 540cd0a

8 files changed

Lines changed: 308 additions & 0 deletions

File tree

src/ai/backend/manager/api/rest/v2/app_config/__init__.py

Whitespace-only changes.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""REST v2 handler for the AppConfig merged-view domain (BEP-1052 §5)."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from http import HTTPStatus
7+
from typing import TYPE_CHECKING, Final
8+
9+
from ai.backend.common.api_handlers import APIResponse, BodyParam, PathParam
10+
from ai.backend.common.dto.manager.v2.app_config.request import (
11+
GetUserAppConfigInput,
12+
SearchAppConfigsInput,
13+
SearchMyAppConfigsInput,
14+
)
15+
from ai.backend.logging import BraceStyleAdapter
16+
from ai.backend.manager.api.rest.v2.path_params import (
17+
AppConfigMyNamePathParam,
18+
AppConfigUserNamePathParam,
19+
)
20+
21+
if TYPE_CHECKING:
22+
from ai.backend.manager.api.adapters.app_config_fragment import AppConfigFragmentAdapter
23+
24+
log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name))
25+
26+
27+
class V2AppConfigHandler:
28+
"""REST v2 handler for the merged-view `AppConfig` surface.
29+
30+
Mounted at `/v2/app-configs/...`. The adapter lives on the Fragment
31+
domain (`app_config_fragment`) because the merged view is computed
32+
from fragment rows joined against `app_config_policies`.
33+
"""
34+
35+
def __init__(self, *, adapter: AppConfigFragmentAdapter) -> None:
36+
self._adapter = adapter
37+
38+
# ── My (self-service) ────────────────────────────────────────
39+
40+
async def my_get(
41+
self,
42+
path: PathParam[AppConfigMyNamePathParam],
43+
) -> APIResponse:
44+
"""Read the caller's own merged AppConfig for `name`."""
45+
result = await self._adapter.my_app_config(path.parsed.name)
46+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
47+
48+
async def my_search(
49+
self,
50+
body: BodyParam[SearchMyAppConfigsInput],
51+
) -> APIResponse:
52+
"""Paginated merged-view search over the caller's own AppConfigs."""
53+
result = await self._adapter.my_search_app_configs(body.parsed)
54+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
55+
56+
# ── Admin ────────────────────────────────────────────────────
57+
58+
async def admin_get(
59+
self,
60+
path: PathParam[AppConfigUserNamePathParam],
61+
) -> APIResponse:
62+
"""Read a specific user's merged AppConfig (admin only)."""
63+
result = await self._adapter.admin_get_user_app_config(
64+
GetUserAppConfigInput(user_id=path.parsed.user_id, name=path.parsed.name)
65+
)
66+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
67+
68+
async def admin_search(
69+
self,
70+
body: BodyParam[SearchAppConfigsInput],
71+
) -> APIResponse:
72+
"""Cross-user merged-view search (admin only)."""
73+
result = await self._adapter.admin_search_app_configs(body.parsed)
74+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Route registration for v2 AppConfig merged-view endpoints (BEP-1052 §5)."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from ai.backend.manager.api.rest.middleware.auth import auth_required, superadmin_required
8+
from ai.backend.manager.api.rest.routing import RouteRegistry
9+
10+
from .handler import V2AppConfigHandler
11+
12+
if TYPE_CHECKING:
13+
from ai.backend.manager.api.rest.types import RouteDeps
14+
15+
16+
def register_v2_app_config_routes(
17+
handler: V2AppConfigHandler,
18+
route_deps: RouteDeps,
19+
) -> RouteRegistry:
20+
"""Register all v2 `/v2/app-configs/*` routes (BEP-1052 §4).
21+
22+
Read-only surface — writes go through `/v2/app-config-fragments/...`
23+
(§4). Self-service routes land under the `/my/...` prefix so the
24+
adapter can pin `(USER, current_user)` internally; admin routes
25+
allow targeting any user id.
26+
"""
27+
reg = RouteRegistry.create("app-configs", route_deps.cors_options)
28+
29+
# Self-service
30+
reg.add("GET", "/my/{name}", handler.my_get, middlewares=[auth_required])
31+
reg.add("POST", "/my/search", handler.my_search, middlewares=[auth_required])
32+
# Admin
33+
reg.add("POST", "/search", handler.admin_search, middlewares=[superadmin_required])
34+
reg.add("GET", "/{user_id}/{name}", handler.admin_get, middlewares=[superadmin_required])
35+
36+
return reg

src/ai/backend/manager/api/rest/v2/app_config_fragment/__init__.py

Whitespace-only changes.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""REST v2 handler for the app-config fragment domain (BEP-1052 §4).
2+
3+
Writes are **bulk-only** per BEP §3 — the single-item create / update /
4+
purge endpoints were removed in favour of `/bulk-create`,
5+
`/bulk-update`, `/bulk-purge` (admin) and `/my/bulk-create`,
6+
`/my/bulk-update` (self-service).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import logging
12+
from http import HTTPStatus
13+
from typing import TYPE_CHECKING, Final
14+
15+
from ai.backend.common.api_handlers import APIResponse, BodyParam, PathParam
16+
from ai.backend.common.dto.manager.v2.app_config_fragment.request import (
17+
AdminBulkCreateAppConfigFragmentsInput,
18+
AdminBulkPurgeAppConfigFragmentsInput,
19+
AdminBulkUpdateAppConfigFragmentsInput,
20+
AppConfigFragmentKeyInput,
21+
BulkCreateMyAppConfigFragmentsInput,
22+
BulkUpdateMyAppConfigFragmentsInput,
23+
SearchAppConfigFragmentsInput,
24+
)
25+
from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType
26+
from ai.backend.logging import BraceStyleAdapter
27+
from ai.backend.manager.api.rest.v2.path_params import AppConfigFragmentScopePathParam
28+
from ai.backend.manager.data.app_config_fragment.types import (
29+
AppConfigScopeType as DataAppConfigScopeType,
30+
)
31+
32+
if TYPE_CHECKING:
33+
from ai.backend.manager.api.adapters.app_config_fragment import AppConfigFragmentAdapter
34+
35+
log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name))
36+
37+
38+
class V2AppConfigFragmentHandler:
39+
"""REST v2 handler for app-config fragment operations."""
40+
41+
def __init__(self, *, adapter: AppConfigFragmentAdapter) -> None:
42+
self._adapter = adapter
43+
44+
# ── Reads ────────────────────────────────────────────────────
45+
46+
async def get(
47+
self,
48+
body: BodyParam[AppConfigFragmentKeyInput],
49+
) -> APIResponse:
50+
"""Read a single fragment by natural key (any authenticated user)."""
51+
result = await self._adapter.get(body.parsed)
52+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
53+
54+
async def scoped_search(
55+
self,
56+
path: PathParam[AppConfigFragmentScopePathParam],
57+
body: BodyParam[SearchAppConfigFragmentsInput],
58+
) -> APIResponse:
59+
"""Scope-bound fragment search — caller is pinned to a specific
60+
`(scope_type, scope_id)` pair via the URL path.
61+
"""
62+
result = await self._adapter.search(
63+
scope_type=DataAppConfigScopeType(path.parsed.scope_type),
64+
scope_id=path.parsed.scope_id,
65+
input=body.parsed,
66+
)
67+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
68+
69+
async def admin_search(
70+
self,
71+
body: BodyParam[SearchAppConfigFragmentsInput],
72+
) -> APIResponse:
73+
"""Cross-scope admin search (admin only)."""
74+
result = await self._adapter.admin_search(body.parsed)
75+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
76+
77+
# ── Admin bulk writes (BEP-1052 §3) ──────────────────────────
78+
79+
async def admin_bulk_create(
80+
self,
81+
body: BodyParam[AdminBulkCreateAppConfigFragmentsInput],
82+
) -> APIResponse:
83+
"""Strict insert across any scope; per-item transactions (admin only)."""
84+
result = await self._adapter.admin_bulk_create(body.parsed)
85+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
86+
87+
async def admin_bulk_update(
88+
self,
89+
body: BodyParam[AdminBulkUpdateAppConfigFragmentsInput],
90+
) -> APIResponse:
91+
"""Wholesale JSON replacement; per-item transactions (admin only)."""
92+
result = await self._adapter.admin_bulk_update(body.parsed)
93+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
94+
95+
async def admin_bulk_purge(
96+
self,
97+
body: BodyParam[AdminBulkPurgeAppConfigFragmentsInput],
98+
) -> APIResponse:
99+
"""Cleanup-only deletion; absent keys are no-oped (admin only)."""
100+
result = await self._adapter.admin_bulk_purge(body.parsed)
101+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
102+
103+
# ── Self-service bulk writes (BEP-1052 §3) ───────────────────
104+
105+
async def my_bulk_create(
106+
self,
107+
body: BodyParam[BulkCreateMyAppConfigFragmentsInput],
108+
) -> APIResponse:
109+
"""Self-service bulk create on the caller's `USER` row."""
110+
result = await self._adapter.my_bulk_create(body.parsed)
111+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
112+
113+
async def my_bulk_update(
114+
self,
115+
body: BodyParam[BulkUpdateMyAppConfigFragmentsInput],
116+
) -> APIResponse:
117+
"""Self-service bulk update on the caller's `USER` row."""
118+
result = await self._adapter.my_bulk_update(body.parsed)
119+
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
120+
121+
122+
# ``AppConfigScopeType`` is imported for OpenAPI schema visibility of the
123+
# string-form path parameter; keep the import alive.
124+
_ = AppConfigScopeType
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Route registration for v2 app-config fragment endpoints (BEP-1052 §4)."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from ai.backend.manager.api.rest.middleware.auth import auth_required, superadmin_required
8+
from ai.backend.manager.api.rest.routing import RouteRegistry
9+
10+
from .handler import V2AppConfigFragmentHandler
11+
12+
if TYPE_CHECKING:
13+
from ai.backend.manager.api.rest.types import RouteDeps
14+
15+
16+
def register_v2_app_config_fragment_routes(
17+
handler: V2AppConfigFragmentHandler,
18+
route_deps: RouteDeps,
19+
) -> RouteRegistry:
20+
"""Register all v2 app-config fragment routes (BEP-1052 §4).
21+
22+
- `POST /get` reads a single row via body (three-field natural key).
23+
- Scoped search mounts at `/{scope_type}/{scope_id}/search`.
24+
- Admin cross-scope search + bulk writes are admin-only.
25+
- `/my/bulk-create` and `/my/bulk-update` are self-service writes
26+
on the caller's `USER` row (no `/my/bulk-purge` — admin-only
27+
cleanup per BEP-1052 §3).
28+
"""
29+
reg = RouteRegistry.create("app-config-fragments", route_deps.cors_options)
30+
31+
# Reads
32+
reg.add("POST", "/get", handler.get, middlewares=[auth_required])
33+
reg.add(
34+
"POST",
35+
"/{scope_type}/{scope_id}/search",
36+
handler.scoped_search,
37+
middlewares=[auth_required],
38+
)
39+
reg.add("POST", "/search", handler.admin_search, middlewares=[superadmin_required])
40+
# Admin bulk writes (BEP-1052 §3 — bulk-only)
41+
reg.add("POST", "/bulk-create", handler.admin_bulk_create, middlewares=[superadmin_required])
42+
reg.add("POST", "/bulk-update", handler.admin_bulk_update, middlewares=[superadmin_required])
43+
reg.add("POST", "/bulk-purge", handler.admin_bulk_purge, middlewares=[superadmin_required])
44+
# Self-service bulk writes
45+
reg.add("POST", "/my/bulk-create", handler.my_bulk_create, middlewares=[auth_required])
46+
reg.add("POST", "/my/bulk-update", handler.my_bulk_update, middlewares=[auth_required])
47+
48+
return reg

src/ai/backend/manager/api/rest/v2/path_params.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@
99
from ai.backend.common.api_handlers import BaseRequestModel
1010

1111

12+
class AppConfigFragmentScopePathParam(BaseRequestModel):
13+
scope_type: str = Field(description="App-config scope type (public/domain/user/...).")
14+
scope_id: str = Field(description="Scope id (domain name, user id, or `public`).")
15+
16+
17+
class AppConfigMyNamePathParam(BaseRequestModel):
18+
name: str = Field(description="Policy / config name.")
19+
20+
21+
class AppConfigUserNamePathParam(BaseRequestModel):
22+
user_id: UUID = Field(description="Target user's UUID (admin only).")
23+
name: str = Field(description="Policy / config name.")
24+
25+
1226
class DomainNamePathParam(BaseRequestModel):
1327
domain_name: str = Field(description="Domain name")
1428

src/ai/backend/manager/api/rest/v2/tree.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ def build_v2_routes(
2828
# Lazy imports to avoid circular dependencies at module level
2929
from .agent.handler import V2AgentHandler
3030
from .agent.registry import register_v2_agent_routes
31+
from .app_config.handler import V2AppConfigHandler
32+
from .app_config.registry import register_v2_app_config_routes
33+
from .app_config_fragment.handler import V2AppConfigFragmentHandler
34+
from .app_config_fragment.registry import register_v2_app_config_fragment_routes
3135
from .artifact.handler import V2ArtifactHandler
3236
from .artifact.registry import register_v2_artifact_routes
3337
from .artifact_registry.handler import V2ArtifactRegistryHandler
@@ -115,6 +119,10 @@ def build_v2_routes(
115119

116120
# Build all handlers (each takes its individual adapter)
117121
agent_handler = V2AgentHandler(adapter=adapters.agent)
122+
# AppConfig merged-view handler reuses the Fragment adapter because the
123+
# merged-view methods live on `AppConfigFragmentAdapter`.
124+
app_config_handler = V2AppConfigHandler(adapter=adapters.app_config_fragment)
125+
app_config_fragment_handler = V2AppConfigFragmentHandler(adapter=adapters.app_config_fragment)
118126
artifact_handler = V2ArtifactHandler(adapter=adapters.artifact)
119127
artifact_registry_handler = V2ArtifactRegistryHandler(adapter=adapters.artifact_registry)
120128
audit_log_handler = V2AuditLogHandler(adapter=adapters.audit_log)
@@ -171,6 +179,10 @@ def build_v2_routes(
171179

172180
# Add all domain sub-registries
173181
v2_reg.add_subregistry(register_v2_agent_routes(agent_handler, route_deps))
182+
v2_reg.add_subregistry(register_v2_app_config_routes(app_config_handler, route_deps))
183+
v2_reg.add_subregistry(
184+
register_v2_app_config_fragment_routes(app_config_fragment_handler, route_deps)
185+
)
174186
v2_reg.add_subregistry(register_v2_artifact_routes(artifact_handler, route_deps))
175187
v2_reg.add_subregistry(
176188
register_v2_artifact_registry_routes(artifact_registry_handler, route_deps)

0 commit comments

Comments
 (0)