Skip to content
1 change: 1 addition & 0 deletions changes/11286.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `AppConfigFragment` REST v2 endpoints under `/v2/app-config-fragments` β€” `get` (body-carried natural key), scoped search `{scope_type}/{scope_id}/search`, admin cross-scope `search`, plus admin `create` / `update` / `purge`.
4 changes: 2 additions & 2 deletions docs/manager/graphql-reference/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -11557,7 +11557,7 @@ input MyBulkCreateAppConfigFragmentInput
}

"""
Added in UNRELEASED. Payload for `bulkCreateMyAppConfigFragments` (recomputed views).
Added in UNRELEASED. Payload for `myBulkCreateAppConfigFragments` (recomputed views).
"""
type MyBulkCreateAppConfigFragmentsPayload
@join__type(graph: STRAWBERRY)
Expand All @@ -11580,7 +11580,7 @@ input MyBulkUpdateAppConfigFragmentInput
}

"""
Added in UNRELEASED. Payload for `bulkUpdateMyAppConfigFragments` (recomputed views).
Added in UNRELEASED. Payload for `myBulkUpdateAppConfigFragments` (recomputed views).
"""
type MyBulkUpdateAppConfigFragmentsPayload
@join__type(graph: STRAWBERRY)
Expand Down
4 changes: 2 additions & 2 deletions docs/manager/graphql-reference/v2-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -7443,7 +7443,7 @@ input MyBulkCreateAppConfigFragmentInput {
}

"""
Added in UNRELEASED. Payload for `bulkCreateMyAppConfigFragments` (recomputed views).
Added in UNRELEASED. Payload for `myBulkCreateAppConfigFragments` (recomputed views).
"""
type MyBulkCreateAppConfigFragmentsPayload {
"""Recomputed merged AppConfig views for each created USER fragment."""
Expand All @@ -7462,7 +7462,7 @@ input MyBulkUpdateAppConfigFragmentInput {
}

"""
Added in UNRELEASED. Payload for `bulkUpdateMyAppConfigFragments` (recomputed views).
Added in UNRELEASED. Payload for `myBulkUpdateAppConfigFragments` (recomputed views).
"""
type MyBulkUpdateAppConfigFragmentsPayload {
"""Recomputed merged AppConfig views for each updated USER fragment."""
Expand Down
4 changes: 2 additions & 2 deletions src/ai/backend/common/dto/manager/v2/app_config/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class SearchAppConfigsPayload(BaseResponseModel):


class MyBulkCreateAppConfigFragmentsPayload(BaseResponseModel):
"""Payload for `bulkCreateMyAppConfigFragments`.
"""Payload for `myBulkCreateAppConfigFragments`.

Each successfully created row produces a recomputed merged
`AppConfigNode`; failures are collected per-item.
Expand All @@ -76,7 +76,7 @@ class MyBulkCreateAppConfigFragmentsPayload(BaseResponseModel):


class MyBulkUpdateAppConfigFragmentsPayload(BaseResponseModel):
"""Payload for `bulkUpdateMyAppConfigFragments`."""
"""Payload for `myBulkUpdateAppConfigFragments`."""

updated: list[AppConfigNode] = Field(
description="Recomputed merged AppConfig views for each updated USER fragment.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
@gql_pydantic_type(
BackendAIGQLMeta(
added_version=NEXT_RELEASE_VERSION,
description="Payload for `bulkCreateMyAppConfigFragments` (recomputed views).",
description="Payload for `myBulkCreateAppConfigFragments` (recomputed views).",
),
model=MyBulkCreatePayloadDTO,
name="MyBulkCreateAppConfigFragmentsPayload",
Expand All @@ -41,7 +41,7 @@ class MyBulkCreateAppConfigFragmentsPayloadGQL(PydanticOutputMixin[MyBulkCreateP
@gql_pydantic_type(
BackendAIGQLMeta(
added_version=NEXT_RELEASE_VERSION,
description="Payload for `bulkUpdateMyAppConfigFragments` (recomputed views).",
description="Payload for `myBulkUpdateAppConfigFragments` (recomputed views).",
),
model=MyBulkUpdatePayloadDTO,
name="MyBulkUpdateAppConfigFragmentsPayload",
Expand Down
Empty file.
74 changes: 74 additions & 0 deletions src/ai/backend/manager/api/rest/v2/app_config/handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""REST v2 handler for the AppConfig merged-view domain."""

from __future__ import annotations

import logging
from http import HTTPStatus
from typing import TYPE_CHECKING, Final

from ai.backend.common.api_handlers import APIResponse, BodyParam, PathParam
from ai.backend.common.dto.manager.v2.app_config.request import (
GetUserAppConfigInput,
SearchAppConfigsInput,
SearchMyAppConfigsInput,
)
from ai.backend.logging import BraceStyleAdapter
from ai.backend.manager.api.rest.v2.path_params import (
AppConfigMyNamePathParam,
AppConfigUserNamePathParam,
)

if TYPE_CHECKING:
from ai.backend.manager.api.adapters.app_config import AppConfigAdapter

log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name))


class V2AppConfigHandler:
"""REST v2 handler for the merged-view `AppConfig` surface.

Mounted at `/v2/app-configs/...`. The merged view is computed from
fragment rows joined against `app_config_policies` and is exposed
via its own `AppConfigAdapter`.
"""

def __init__(self, *, adapter: AppConfigAdapter) -> None:
self._adapter = adapter

# ── My (self-service) ────────────────────────────────────────

async def my_get(
self,
path: PathParam[AppConfigMyNamePathParam],
) -> APIResponse:
"""Read the caller's own merged AppConfig for `name`."""
result = await self._adapter.my_app_config(path.parsed.name)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

async def my_search(
self,
body: BodyParam[SearchMyAppConfigsInput],
) -> APIResponse:
"""Paginated merged-view search over the caller's own AppConfigs."""
result = await self._adapter.my_search_app_configs(body.parsed)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

# ── Admin ────────────────────────────────────────────────────

async def admin_get(
self,
path: PathParam[AppConfigUserNamePathParam],
) -> APIResponse:
"""Read a specific user's merged AppConfig (admin only)."""
result = await self._adapter.admin_get_user_app_config(
GetUserAppConfigInput(user_id=path.parsed.user_id, name=path.parsed.name)
)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

async def admin_search(
self,
body: BodyParam[SearchAppConfigsInput],
) -> APIResponse:
"""Cross-user merged-view search (admin only)."""
result = await self._adapter.admin_search_app_configs(body.parsed)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)
36 changes: 36 additions & 0 deletions src/ai/backend/manager/api/rest/v2/app_config/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Route registration for v2 AppConfig merged-view endpoints."""

from __future__ import annotations

from typing import TYPE_CHECKING

from ai.backend.manager.api.rest.middleware.auth import auth_required, superadmin_required
from ai.backend.manager.api.rest.routing import RouteRegistry

from .handler import V2AppConfigHandler

if TYPE_CHECKING:
from ai.backend.manager.api.rest.types import RouteDeps


def register_v2_app_config_routes(
handler: V2AppConfigHandler,
route_deps: RouteDeps,
) -> RouteRegistry:
"""Register all v2 `/v2/app-configs/*` routes.

Read-only surface β€” writes go through `/v2/app-config-fragments/...`
(Β§4). Self-service routes land under the `/my/...` prefix so the
adapter can pin `(USER, current_user)` internally; admin routes
allow targeting any user id.
"""
reg = RouteRegistry.create("app-configs", route_deps.cors_options)

# Self-service
reg.add("GET", "/my/{name}", handler.my_get, middlewares=[auth_required])
reg.add("POST", "/my/search", handler.my_search, middlewares=[auth_required])
# Admin
reg.add("POST", "/search", handler.admin_search, middlewares=[superadmin_required])
reg.add("GET", "/{user_id}/{name}", handler.admin_get, middlewares=[superadmin_required])

return reg
Empty file.
136 changes: 136 additions & 0 deletions src/ai/backend/manager/api/rest/v2/app_config_fragment/handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""REST v2 handler for the app-config fragment domain.

Writes are **bulk-only** per BEP Β§3 β€” the single-item create / update /
purge endpoints were removed in favour of `/bulk-create`,
`/bulk-update`, `/bulk-purge` (admin) and `/my/bulk-create`,
`/my/bulk-update` (self-service).
"""

from __future__ import annotations

import logging
from http import HTTPStatus
from typing import TYPE_CHECKING, Final

from ai.backend.common.api_handlers import APIResponse, BodyParam, PathParam
from ai.backend.common.dto.manager.v2.app_config_fragment.request import (
AdminBulkCreateAppConfigFragmentsInput,
AdminBulkPurgeAppConfigFragmentsInput,
AdminBulkUpdateAppConfigFragmentsInput,
AppConfigFragmentKeyInput,
MyBulkCreateAppConfigFragmentsInput,
MyBulkUpdateAppConfigFragmentsInput,
SearchAppConfigFragmentsInput,
)
from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType
from ai.backend.logging import BraceStyleAdapter
from ai.backend.manager.api.rest.v2.path_params import AppConfigFragmentScopePathParam
from ai.backend.manager.data.app_config_fragment.types import (
AppConfigScopeType as DataAppConfigScopeType,
)

if TYPE_CHECKING:
from ai.backend.manager.api.adapters.app_config import AppConfigAdapter
from ai.backend.manager.api.adapters.app_config_fragment import AppConfigFragmentAdapter

log: Final = BraceStyleAdapter(logging.getLogger(__spec__.name))


class V2AppConfigFragmentHandler:
"""REST v2 handler for app-config fragment operations.

Self-service `my_bulk_*` writes return recomputed merged
`AppConfig` views, so they are dispatched to `AppConfigAdapter`;
everything else is the raw-row Fragment surface.
"""

def __init__(
self,
*,
adapter: AppConfigFragmentAdapter,
app_config_adapter: AppConfigAdapter,
) -> None:
self._adapter = adapter
self._app_config_adapter = app_config_adapter

# ── Reads ────────────────────────────────────────────────────

async def get(
self,
body: BodyParam[AppConfigFragmentKeyInput],
) -> APIResponse:
"""Read a single fragment by natural key (any authenticated user)."""
result = await self._adapter.get(body.parsed)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

async def scoped_search(
self,
path: PathParam[AppConfigFragmentScopePathParam],
body: BodyParam[SearchAppConfigFragmentsInput],
) -> APIResponse:
"""Scope-bound fragment search β€” caller is pinned to a specific
`(scope_type, scope_id)` pair via the URL path.
"""
result = await self._adapter.search(
scope_type=DataAppConfigScopeType(path.parsed.scope_type),
scope_id=path.parsed.scope_id,
input=body.parsed,
)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

async def admin_search(
self,
body: BodyParam[SearchAppConfigFragmentsInput],
) -> APIResponse:
"""Cross-scope admin search (admin only)."""
result = await self._adapter.admin_search(body.parsed)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

# ── Admin bulk writes ──────────────────────────

async def admin_bulk_create(
self,
body: BodyParam[AdminBulkCreateAppConfigFragmentsInput],
) -> APIResponse:
"""Strict insert across any scope; per-item transactions (admin only)."""
result = await self._adapter.admin_bulk_create(body.parsed)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

async def admin_bulk_update(
self,
body: BodyParam[AdminBulkUpdateAppConfigFragmentsInput],
) -> APIResponse:
"""Wholesale JSON replacement; per-item transactions (admin only)."""
result = await self._adapter.admin_bulk_update(body.parsed)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

async def admin_bulk_purge(
self,
body: BodyParam[AdminBulkPurgeAppConfigFragmentsInput],
) -> APIResponse:
"""Cleanup-only deletion; absent keys are no-oped (admin only)."""
result = await self._adapter.admin_bulk_purge(body.parsed)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

# ── Self-service bulk writes ───────────────────

async def my_bulk_create(
self,
body: BodyParam[MyBulkCreateAppConfigFragmentsInput],
) -> APIResponse:
"""Self-service bulk create on the caller's `USER` row."""
result = await self._app_config_adapter.my_bulk_create(body.parsed)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)

async def my_bulk_update(
self,
body: BodyParam[MyBulkUpdateAppConfigFragmentsInput],
) -> APIResponse:
"""Self-service bulk update on the caller's `USER` row."""
result = await self._app_config_adapter.my_bulk_update(body.parsed)
return APIResponse.build(status_code=HTTPStatus.OK, response_model=result)


# ``AppConfigScopeType`` is imported for OpenAPI schema visibility of the
# string-form path parameter; keep the import alive.
_ = AppConfigScopeType
48 changes: 48 additions & 0 deletions src/ai/backend/manager/api/rest/v2/app_config_fragment/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Route registration for v2 app-config fragment endpoints."""

from __future__ import annotations

from typing import TYPE_CHECKING

from ai.backend.manager.api.rest.middleware.auth import auth_required, superadmin_required
from ai.backend.manager.api.rest.routing import RouteRegistry

from .handler import V2AppConfigFragmentHandler

if TYPE_CHECKING:
from ai.backend.manager.api.rest.types import RouteDeps


def register_v2_app_config_fragment_routes(
handler: V2AppConfigFragmentHandler,
route_deps: RouteDeps,
) -> RouteRegistry:
"""Register all v2 app-config fragment routes.

- `POST /get` reads a single row via body (three-field natural key).
- Scoped search mounts at `/{scope_type}/{scope_id}/search`.
- Admin cross-scope search + bulk writes are admin-only.
- `/my/bulk-create` and `/my/bulk-update` are self-service writes
on the caller's `USER` row (no `/my/bulk-purge` β€” admin-only
cleanup ).
"""
reg = RouteRegistry.create("app-config-fragments", route_deps.cors_options)

# Reads
reg.add("POST", "/get", handler.get, middlewares=[auth_required])
reg.add(
"POST",
"/{scope_type}/{scope_id}/search",
handler.scoped_search,
middlewares=[auth_required],
)
reg.add("POST", "/search", handler.admin_search, middlewares=[superadmin_required])
# Admin bulk writes (bulk-only)
reg.add("POST", "/bulk-create", handler.admin_bulk_create, middlewares=[superadmin_required])
reg.add("POST", "/bulk-update", handler.admin_bulk_update, middlewares=[superadmin_required])
reg.add("POST", "/bulk-purge", handler.admin_bulk_purge, middlewares=[superadmin_required])
# Self-service bulk writes
reg.add("POST", "/my/bulk-create", handler.my_bulk_create, middlewares=[auth_required])
reg.add("POST", "/my/bulk-update", handler.my_bulk_update, middlewares=[auth_required])

return reg
14 changes: 14 additions & 0 deletions src/ai/backend/manager/api/rest/v2/path_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ class AppConfigPolicyIdPathParam(BaseRequestModel):
policy_id: UUID = Field(description="App-config policy row UUID")


class AppConfigFragmentScopePathParam(BaseRequestModel):
scope_type: str = Field(description="App-config scope type (public/domain/user/...).")
scope_id: str = Field(description="Scope id (domain name, user id, or `public`).")


class AppConfigMyNamePathParam(BaseRequestModel):
name: str = Field(description="Policy / config name.")


class AppConfigUserNamePathParam(BaseRequestModel):
user_id: UUID = Field(description="Target user's UUID (admin only).")
name: str = Field(description="Policy / config name.")


class DomainNamePathParam(BaseRequestModel):
domain_name: str = Field(description="Domain name")

Expand Down
Loading
Loading