Skip to content

Commit ab3b12c

Browse files
jopemachineclaude
andcommitted
feat(BA-5829): add AppConfigFragment Strawberry GQL surface
Mirrors the Policy GQL surface added in BA-5815 (#11269) on the Fragment side. Fragments keep the scope-bound vs cross-scope split required by BEP-1052 §2 — the Query surface exposes all three variants so the client can pick the right one per role. Queries: - `appConfigFragment(scopeType, scopeId, name)` — read a single row by natural key (any authenticated user; service-layer auth gates cross-scope reads) - `scopedAppConfigFragments(scopeType, scopeId, filter, orderBy, ...)` — scope-bound list for non-admin users - `adminAppConfigFragments(filter, orderBy, ...)` — cross-scope admin search (admin only) Mutations (admin only): - `adminCreateAppConfigFragment(input)` - `adminUpdateAppConfigFragment(input)` — natural key is fixed - `adminPurgeAppConfigFragment(input)` New GQL types live under `api/gql/app_config_fragment/`: - `types/node.AppConfigFragmentGQL` (+ `AppConfigScopeTypeGQL` enum registered via function-form `gql_enum` on the shared DTO enum) - `types/filters.*` (filter + order + field enum) - `types/inputs.*` (Key + Create/Update/Purge inputs) - `types/payloads.*` (Create/Update/Purge payloads) - `resolver/query.*` and `resolver/mutation.*` wired into `schema.py` DTO additions (`common/dto/manager/v2/app_config_fragment/response.py`): `CreateAppConfigFragmentGQLPayload`, `UpdateAppConfigFragmentGQLPayload`, `PurgeAppConfigFragmentGQLPayload` (mutation-result shapes with `fragment` / natural-key fields exposed directly, matching Policy). The scope resolver converts the DTO `AppConfigScopeType` to the data-layer enum before calling the adapter so the adapter signature can stay data-typed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f575b11 commit ab3b12c

12 files changed

Lines changed: 600 additions & 0 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
)
1010
from .response import (
1111
AppConfigFragmentNode,
12+
CreateAppConfigFragmentGQLPayload,
1213
CreateAppConfigFragmentPayload,
1314
GetAppConfigFragmentPayload,
15+
PurgeAppConfigFragmentGQLPayload,
1416
PurgeAppConfigFragmentPayload,
1517
SearchAppConfigFragmentsPayload,
18+
UpdateAppConfigFragmentGQLPayload,
1619
UpdateAppConfigFragmentPayload,
1720
)
1821
from .types import (
@@ -28,14 +31,17 @@
2831
"AppConfigFragmentOrder",
2932
"AppConfigFragmentOrderField",
3033
"AppConfigScopeType",
34+
"CreateAppConfigFragmentGQLPayload",
3135
"CreateAppConfigFragmentInput",
3236
"CreateAppConfigFragmentPayload",
3337
"GetAppConfigFragmentPayload",
3438
"OrderDirection",
39+
"PurgeAppConfigFragmentGQLPayload",
3540
"PurgeAppConfigFragmentInput",
3641
"PurgeAppConfigFragmentPayload",
3742
"SearchAppConfigFragmentsInput",
3843
"SearchAppConfigFragmentsPayload",
44+
"UpdateAppConfigFragmentGQLPayload",
3945
"UpdateAppConfigFragmentInput",
4046
"UpdateAppConfigFragmentPayload",
4147
)

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@
1616

1717
__all__ = (
1818
"AppConfigFragmentNode",
19+
"CreateAppConfigFragmentGQLPayload",
1920
"CreateAppConfigFragmentPayload",
2021
"GetAppConfigFragmentPayload",
22+
"PurgeAppConfigFragmentGQLPayload",
2123
"PurgeAppConfigFragmentPayload",
2224
"SearchAppConfigFragmentsPayload",
25+
"UpdateAppConfigFragmentGQLPayload",
2326
"UpdateAppConfigFragmentPayload",
2427
)
2528

@@ -65,6 +68,27 @@ class GetAppConfigFragmentPayload(BaseResponseModel):
6568
item: AppConfigFragmentNode | None = Field(default=None, description="Fragment data, or null.")
6669

6770

71+
class CreateAppConfigFragmentGQLPayload(BaseResponseModel):
72+
"""GQL-layer payload for creating a fragment."""
73+
74+
fragment: AppConfigFragmentNode = Field(description="Created fragment.")
75+
76+
77+
class UpdateAppConfigFragmentGQLPayload(BaseResponseModel):
78+
"""GQL-layer payload for updating a fragment."""
79+
80+
fragment: AppConfigFragmentNode = Field(description="Updated fragment.")
81+
82+
83+
class PurgeAppConfigFragmentGQLPayload(BaseResponseModel):
84+
"""GQL-layer payload for purging a fragment."""
85+
86+
scope_type: AppConfigScopeType = Field(description="Scope type of the purged fragment.")
87+
scope_id: str = Field(description="Scope id of the purged fragment.")
88+
name: str = Field(description="Policy name of the purged fragment.")
89+
purged: bool = Field(description="Whether a row was actually removed.")
90+
91+
6892
class SearchAppConfigFragmentsPayload(BaseResponseModel):
6993
"""Payload for paginated fragment search results."""
7094

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""AppConfigFragment GraphQL API package."""
2+
3+
from .resolver import (
4+
admin_app_config_fragments,
5+
admin_create_app_config_fragment,
6+
admin_purge_app_config_fragment,
7+
admin_update_app_config_fragment,
8+
app_config_fragment,
9+
scoped_app_config_fragments,
10+
)
11+
from .types import (
12+
AppConfigFragmentFilterGQL,
13+
AppConfigFragmentGQL,
14+
AppConfigFragmentKeyInputGQL,
15+
AppConfigFragmentOrderByGQL,
16+
AppConfigFragmentOrderFieldGQL,
17+
AppConfigScopeTypeGQL,
18+
CreateAppConfigFragmentInputGQL,
19+
CreateAppConfigFragmentPayloadGQL,
20+
PurgeAppConfigFragmentInputGQL,
21+
PurgeAppConfigFragmentPayloadGQL,
22+
UpdateAppConfigFragmentInputGQL,
23+
UpdateAppConfigFragmentPayloadGQL,
24+
)
25+
26+
__all__ = [
27+
# Queries
28+
"app_config_fragment",
29+
"scoped_app_config_fragments",
30+
"admin_app_config_fragments",
31+
# Mutations
32+
"admin_create_app_config_fragment",
33+
"admin_update_app_config_fragment",
34+
"admin_purge_app_config_fragment",
35+
# Types
36+
"AppConfigFragmentGQL",
37+
"AppConfigScopeTypeGQL",
38+
"AppConfigFragmentFilterGQL",
39+
"AppConfigFragmentOrderByGQL",
40+
"AppConfigFragmentOrderFieldGQL",
41+
"AppConfigFragmentKeyInputGQL",
42+
"CreateAppConfigFragmentInputGQL",
43+
"UpdateAppConfigFragmentInputGQL",
44+
"PurgeAppConfigFragmentInputGQL",
45+
"CreateAppConfigFragmentPayloadGQL",
46+
"UpdateAppConfigFragmentPayloadGQL",
47+
"PurgeAppConfigFragmentPayloadGQL",
48+
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from .mutation import (
2+
admin_create_app_config_fragment,
3+
admin_purge_app_config_fragment,
4+
admin_update_app_config_fragment,
5+
)
6+
from .query import (
7+
admin_app_config_fragments,
8+
app_config_fragment,
9+
scoped_app_config_fragments,
10+
)
11+
12+
__all__ = [
13+
"admin_app_config_fragments",
14+
"admin_create_app_config_fragment",
15+
"admin_purge_app_config_fragment",
16+
"admin_update_app_config_fragment",
17+
"app_config_fragment",
18+
"scoped_app_config_fragments",
19+
]
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""AppConfigFragment GQL mutation resolvers."""
2+
3+
from __future__ import annotations
4+
5+
from strawberry import Info
6+
7+
from ai.backend.common.dto.manager.v2.app_config_fragment.response import (
8+
CreateAppConfigFragmentGQLPayload as CreateAppConfigFragmentGQLPayloadDTO,
9+
)
10+
from ai.backend.common.dto.manager.v2.app_config_fragment.response import (
11+
PurgeAppConfigFragmentGQLPayload as PurgeAppConfigFragmentGQLPayloadDTO,
12+
)
13+
from ai.backend.common.dto.manager.v2.app_config_fragment.response import (
14+
UpdateAppConfigFragmentGQLPayload as UpdateAppConfigFragmentGQLPayloadDTO,
15+
)
16+
from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION
17+
from ai.backend.manager.api.gql.app_config_fragment.types import (
18+
CreateAppConfigFragmentInputGQL,
19+
CreateAppConfigFragmentPayloadGQL,
20+
PurgeAppConfigFragmentInputGQL,
21+
PurgeAppConfigFragmentPayloadGQL,
22+
UpdateAppConfigFragmentInputGQL,
23+
UpdateAppConfigFragmentPayloadGQL,
24+
)
25+
from ai.backend.manager.api.gql.decorators import (
26+
BackendAIGQLMeta,
27+
gql_mutation,
28+
)
29+
from ai.backend.manager.api.gql.types import StrawberryGQLContext
30+
from ai.backend.manager.api.gql.utils import check_admin_only
31+
32+
33+
@gql_mutation(
34+
BackendAIGQLMeta(
35+
added_version=NEXT_RELEASE_VERSION,
36+
description="Create a new app-config fragment (admin only).",
37+
)
38+
) # type: ignore[misc]
39+
async def admin_create_app_config_fragment(
40+
info: Info[StrawberryGQLContext],
41+
input: CreateAppConfigFragmentInputGQL,
42+
) -> CreateAppConfigFragmentPayloadGQL:
43+
check_admin_only()
44+
result = await info.context.adapters.app_config_fragment.create(input.to_pydantic())
45+
return CreateAppConfigFragmentPayloadGQL.from_pydantic(
46+
CreateAppConfigFragmentGQLPayloadDTO(fragment=result.item)
47+
)
48+
49+
50+
@gql_mutation(
51+
BackendAIGQLMeta(
52+
added_version=NEXT_RELEASE_VERSION,
53+
description="Update an app-config fragment (admin only).",
54+
)
55+
) # type: ignore[misc]
56+
async def admin_update_app_config_fragment(
57+
info: Info[StrawberryGQLContext],
58+
input: UpdateAppConfigFragmentInputGQL,
59+
) -> UpdateAppConfigFragmentPayloadGQL:
60+
check_admin_only()
61+
result = await info.context.adapters.app_config_fragment.update(input.to_pydantic())
62+
return UpdateAppConfigFragmentPayloadGQL.from_pydantic(
63+
UpdateAppConfigFragmentGQLPayloadDTO(fragment=result.item)
64+
)
65+
66+
67+
@gql_mutation(
68+
BackendAIGQLMeta(
69+
added_version=NEXT_RELEASE_VERSION,
70+
description="Purge (hard-delete) an app-config fragment (admin only).",
71+
)
72+
) # type: ignore[misc]
73+
async def admin_purge_app_config_fragment(
74+
info: Info[StrawberryGQLContext],
75+
input: PurgeAppConfigFragmentInputGQL,
76+
) -> PurgeAppConfigFragmentPayloadGQL:
77+
check_admin_only()
78+
result = await info.context.adapters.app_config_fragment.purge(input.to_pydantic())
79+
return PurgeAppConfigFragmentPayloadGQL.from_pydantic(
80+
PurgeAppConfigFragmentGQLPayloadDTO(
81+
scope_type=result.scope_type,
82+
scope_id=result.scope_id,
83+
name=result.name,
84+
purged=result.purged,
85+
)
86+
)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""AppConfigFragment GQL query resolvers."""
2+
3+
from __future__ import annotations
4+
5+
from strawberry import Info
6+
7+
from ai.backend.common.dto.manager.v2.app_config_fragment.request import (
8+
AppConfigFragmentKeyInput,
9+
SearchAppConfigFragmentsInput,
10+
)
11+
from ai.backend.common.dto.manager.v2.app_config_fragment.types import AppConfigScopeType
12+
from ai.backend.common.meta.meta import NEXT_RELEASE_VERSION
13+
from ai.backend.manager.api.gql.app_config_fragment.types import (
14+
AppConfigFragmentFilterGQL,
15+
AppConfigFragmentGQL,
16+
AppConfigFragmentOrderByGQL,
17+
)
18+
from ai.backend.manager.api.gql.decorators import (
19+
BackendAIGQLMeta,
20+
gql_root_field,
21+
)
22+
from ai.backend.manager.api.gql.types import StrawberryGQLContext
23+
from ai.backend.manager.api.gql.utils import check_admin_only
24+
from ai.backend.manager.data.app_config_fragment.types import (
25+
AppConfigScopeType as DataAppConfigScopeType,
26+
)
27+
28+
29+
@gql_root_field(
30+
BackendAIGQLMeta(
31+
added_version=NEXT_RELEASE_VERSION,
32+
description=(
33+
"Get a single app-config fragment by natural key "
34+
"`(scope_type, scope_id, name)`. Available to any authenticated user "
35+
"— service-layer authorization gates cross-scope reads."
36+
),
37+
)
38+
) # type: ignore[misc]
39+
async def app_config_fragment(
40+
info: Info[StrawberryGQLContext],
41+
scope_type: AppConfigScopeType,
42+
scope_id: str,
43+
name: str,
44+
) -> AppConfigFragmentGQL | None:
45+
payload = await info.context.adapters.app_config_fragment.get(
46+
AppConfigFragmentKeyInput(scope_type=scope_type, scope_id=scope_id, name=name)
47+
)
48+
if payload.item is None:
49+
return None
50+
return AppConfigFragmentGQL.from_pydantic(payload.item)
51+
52+
53+
@gql_root_field(
54+
BackendAIGQLMeta(
55+
added_version=NEXT_RELEASE_VERSION,
56+
description=(
57+
"Scope-bound app-config fragment list. Caller pins "
58+
"`(scope_type, scope_id)` so non-admin users only see fragments within "
59+
"their own scope (BEP-1052 §2)."
60+
),
61+
)
62+
) # type: ignore[misc]
63+
async def scoped_app_config_fragments(
64+
info: Info[StrawberryGQLContext],
65+
scope_type: AppConfigScopeType,
66+
scope_id: str,
67+
filter: AppConfigFragmentFilterGQL | None = None,
68+
order_by: list[AppConfigFragmentOrderByGQL] | None = None,
69+
first: int | None = None,
70+
after: str | None = None,
71+
last: int | None = None,
72+
before: str | None = None,
73+
limit: int | None = None,
74+
offset: int | None = None,
75+
) -> list[AppConfigFragmentGQL]:
76+
search_input = SearchAppConfigFragmentsInput(
77+
filter=filter.to_pydantic() if filter else None,
78+
order=[o.to_pydantic() for o in order_by] if order_by else None,
79+
first=first,
80+
after=after,
81+
last=last,
82+
before=before,
83+
limit=limit,
84+
offset=offset,
85+
)
86+
payload = await info.context.adapters.app_config_fragment.search(
87+
scope_type=DataAppConfigScopeType(scope_type.value),
88+
scope_id=scope_id,
89+
input=search_input,
90+
)
91+
return [AppConfigFragmentGQL.from_pydantic(node) for node in payload.items]
92+
93+
94+
@gql_root_field(
95+
BackendAIGQLMeta(
96+
added_version=NEXT_RELEASE_VERSION,
97+
description="Cross-scope admin search across all app-config fragments (admin only).",
98+
)
99+
) # type: ignore[misc]
100+
async def admin_app_config_fragments(
101+
info: Info[StrawberryGQLContext],
102+
filter: AppConfigFragmentFilterGQL | None = None,
103+
order_by: list[AppConfigFragmentOrderByGQL] | None = None,
104+
first: int | None = None,
105+
after: str | None = None,
106+
last: int | None = None,
107+
before: str | None = None,
108+
limit: int | None = None,
109+
offset: int | None = None,
110+
) -> list[AppConfigFragmentGQL]:
111+
check_admin_only()
112+
payload = await info.context.adapters.app_config_fragment.admin_search(
113+
SearchAppConfigFragmentsInput(
114+
filter=filter.to_pydantic() if filter else None,
115+
order=[o.to_pydantic() for o in order_by] if order_by else None,
116+
first=first,
117+
after=after,
118+
last=last,
119+
before=before,
120+
limit=limit,
121+
offset=offset,
122+
)
123+
)
124+
return [AppConfigFragmentGQL.from_pydantic(node) for node in payload.items]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from .filters import (
2+
AppConfigFragmentFilterGQL,
3+
AppConfigFragmentOrderByGQL,
4+
AppConfigFragmentOrderFieldGQL,
5+
)
6+
from .inputs import (
7+
AppConfigFragmentKeyInputGQL,
8+
CreateAppConfigFragmentInputGQL,
9+
PurgeAppConfigFragmentInputGQL,
10+
UpdateAppConfigFragmentInputGQL,
11+
)
12+
from .node import AppConfigFragmentGQL, AppConfigScopeTypeGQL
13+
from .payloads import (
14+
CreateAppConfigFragmentPayloadGQL,
15+
PurgeAppConfigFragmentPayloadGQL,
16+
UpdateAppConfigFragmentPayloadGQL,
17+
)
18+
19+
__all__ = [
20+
"AppConfigFragmentFilterGQL",
21+
"AppConfigFragmentGQL",
22+
"AppConfigFragmentKeyInputGQL",
23+
"AppConfigFragmentOrderByGQL",
24+
"AppConfigFragmentOrderFieldGQL",
25+
"AppConfigScopeTypeGQL",
26+
"CreateAppConfigFragmentInputGQL",
27+
"CreateAppConfigFragmentPayloadGQL",
28+
"PurgeAppConfigFragmentInputGQL",
29+
"PurgeAppConfigFragmentPayloadGQL",
30+
"UpdateAppConfigFragmentInputGQL",
31+
"UpdateAppConfigFragmentPayloadGQL",
32+
]

0 commit comments

Comments
 (0)