diff --git a/api/oss/src/apis/fastapi/folders/models.py b/api/oss/src/apis/fastapi/folders/models.py index 98f3627265..99e3b60686 100644 --- a/api/oss/src/apis/fastapi/folders/models.py +++ b/api/oss/src/apis/fastapi/folders/models.py @@ -1,7 +1,7 @@ from typing import Optional, List from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, Field from fastapi import HTTPException from oss.src.core.folders.types import ( @@ -13,30 +13,57 @@ class FolderCreateRequest(BaseModel): - folder: FolderCreate + folder: FolderCreate = Field( + ..., + description="Folder to create. `slug` is required; `parent_id` nests the new folder under an existing one.", + ) class FolderEditRequest(BaseModel): - folder: FolderEdit + folder: FolderEdit = Field( + ..., + description="Folder edit payload. `id` must match the path parameter. Only fields present in the payload are changed.", + ) class FolderQueryRequest(BaseModel): - folder: FolderQuery + folder: FolderQuery = Field( + ..., + description="Filter object. Any combination of `id`/`ids`, `slug`/`slugs`, `kind`/`kinds`, `parent_id`/`parent_ids`, `path`/`paths`, and `prefix`/`prefixes` narrows the result.", + ) class FolderResponse(BaseModel): - count: int = 0 - folder: Optional[Folder] = None + count: int = Field( + default=0, + description="Number of folders returned (`0` or `1`).", + ) + folder: Optional[Folder] = Field( + default=None, + description="The folder, when found. Omitted when `count` is `0`.", + ) class FoldersResponse(BaseModel): - count: int = 0 - folders: List[Folder] = [] + count: int = Field( + default=0, + description="Number of folders in `folders`.", + ) + folders: List[Folder] = Field( + default_factory=list, + description="Matching folders for the query. Ordering is not guaranteed.", + ) class FolderIdResponse(BaseModel): - count: int = 0 - id: Optional[UUID] = None + count: int = Field( + default=0, + description="`1` if a folder was deleted, `0` if no folder matched.", + ) + id: Optional[UUID] = Field( + default=None, + description="Id of the deleted folder. Omitted when nothing was deleted.", + ) class FolderNameInvalidException(HTTPException): diff --git a/api/oss/src/apis/fastapi/folders/router.py b/api/oss/src/apis/fastapi/folders/router.py index 7f0ef9752f..fcefb0a611 100644 --- a/api/oss/src/apis/fastapi/folders/router.py +++ b/api/oss/src/apis/fastapi/folders/router.py @@ -141,6 +141,16 @@ async def create_folder( # folder_create_request: FolderCreateRequest, ) -> FolderResponse: + """ + Create a folder. + + The folder name must match `[\\w -]+` (letters, digits, underscore, + space, hyphen); other characters return `400`. The resulting path + (the slug joined to the parent's path with a dot) must be unique + within the project, otherwise the call returns `409`. Passing a + `parent_id` that does not exist returns `404`. Paths are capped at + 10 levels of nesting and slugs at 64 characters. + """ if is_ee(): if not await check_action_access( # type: ignore user_uid=request.state.user_id, @@ -171,6 +181,12 @@ async def fetch_folder( # folder_id: UUID, ) -> FolderResponse: + """ + Fetch one folder by id. + + Returns a single `folder` envelope. If the folder does not exist in + the caller's project, `count` is `0` and `folder` is omitted. + """ if is_ee(): if not await check_action_access( # type: ignore user_uid=request.state.user_id, @@ -201,6 +217,15 @@ async def edit_folder( folder_id: UUID, folder_edit_request: FolderEditRequest, ) -> FolderResponse: + """ + Rename or move a folder. + + Use this endpoint to change a folder's `slug`, `name`, or + `parent_id`. The `id` in the request body must match the path + parameter or the call returns `400`. Name and path-uniqueness rules + from create apply: invalid names return `400`, a path collision + returns `409`, and a missing `parent_id` returns `404`. + """ if is_ee(): if not await check_action_access( # type: ignore user_uid=request.state.user_id, @@ -236,6 +261,15 @@ async def delete_folder( # folder_id: UUID, ) -> FolderIdResponse: + """ + Delete a folder and every descendant. + + Removes the folder identified by `folder_id` together with every + folder beneath it, in a single transaction. Deletion is + unconditional; there is no archive or unarchive step. Resources + that were assigned to any of the removed folders continue to + exist and are no longer reachable through the deleted folder. + """ if is_ee(): if not await check_action_access( # type: ignore user_uid=request.state.user_id, @@ -266,6 +300,17 @@ async def query_folders( # folder_query_request: FolderQueryRequest, ) -> FoldersResponse: + """ + Filter folders inside the caller's project. + + Follows the general response envelope described in the + [Query Pattern](/reference/api-guide/query-pattern) guide, but + does not accept `windowing` or `include_archived` — folders are + hard-deleted and the response always returns the full filtered + set. Filters include `id`/`ids`, `slug`/`slugs`, `kind`/`kinds`, + `parent_id`/`parent_ids` (use `parent_id: null` for root folders), + `path`/`paths`, and `prefix`/`prefixes` for subtree lookup. + """ if is_ee(): if not await check_action_access( # type: ignore user_uid=request.state.user_id, diff --git a/api/oss/src/core/folders/types.py b/api/oss/src/core/folders/types.py index 27073ecc66..06aabcd821 100644 --- a/api/oss/src/core/folders/types.py +++ b/api/oss/src/core/folders/types.py @@ -2,6 +2,8 @@ from enum import Enum from uuid import UUID +from pydantic import Field + from oss.src.core.shared.dtos import ( Identifier, Slug, @@ -16,45 +18,102 @@ class FolderKind(str, Enum): class Folder(Identifier, Slug, Lifecycle, Header, Metadata): - kind: Optional[FolderKind] = None + kind: Optional[FolderKind] = Field( + default=None, + description="Resource family this folder organizes. Only `applications` is defined today, and it also covers workflows, evaluators, and testsets (they share the artifact table).", + ) - path: Optional[str] = None + path: Optional[str] = Field( + default=None, + description="Dot-separated materialized path built from the folder's slug and its ancestors' slugs. Read-only; derived by the server.", + ) - parent_id: Optional[UUID] = None + parent_id: Optional[UUID] = Field( + default=None, + description="Id of the parent folder, or `null` for a root folder.", + ) class FolderCreate(Slug, Header, Metadata): - kind: Optional[FolderKind] = None + kind: Optional[FolderKind] = Field( + default=None, + description="Resource family the folder organizes. Defaults to `applications` when omitted.", + ) - parent_id: Optional[UUID] = None + parent_id: Optional[UUID] = Field( + default=None, + description="Id of the parent folder. Omit or set to `null` to create a root folder.", + ) class FolderEdit(Identifier, Slug, Header, Metadata): - kind: Optional[FolderKind] = None + kind: Optional[FolderKind] = Field( + default=None, + description="Resource family. Must match the current folder's kind; defaults to `applications`.", + ) - parent_id: Optional[UUID] = None + parent_id: Optional[UUID] = Field( + default=None, + description="New parent folder id. Include the key with a `null` value to move the folder to the root; omit the key to keep the existing parent.", + ) class FolderQuery(Header, Metadata): # scope - id: Optional[UUID] = None - ids: Optional[List[UUID]] = None - - slug: Optional[str] = None - slugs: Optional[List[str]] = None - - kind: Optional[FolderKind] = None + id: Optional[UUID] = Field( + default=None, + description="Match a single folder id.", + ) + ids: Optional[List[UUID]] = Field( + default=None, + description="Match any of the given folder ids.", + ) + + slug: Optional[str] = Field( + default=None, + description="Match a folder by slug, regardless of its position in the tree.", + ) + slugs: Optional[List[str]] = Field( + default=None, + description="Match folders whose slug is in the given list.", + ) + + kind: Optional[FolderKind] = Field( + default=None, + description="Match folders of a single resource family.", + ) # kinds filter supports: bool (False=is None, True=is not None) or list of FolderKind values - kinds: Optional[Union[bool, List[FolderKind]]] = None - - parent_id: Optional[UUID] = None - parent_ids: Optional[List[UUID]] = None - - path: Optional[str] = None - paths: Optional[List[str]] = None - - prefix: Optional[str] = None - prefixes: Optional[List[str]] = None + kinds: Optional[Union[bool, List[FolderKind]]] = Field( + default=None, + description="Filter by presence of a kind. `false` returns folders with no kind, `true` returns folders where `kind` is set, and an array restricts to the given kinds.", + ) + + parent_id: Optional[UUID] = Field( + default=None, + description="Match folders whose parent is this id. Send `null` to return only root folders.", + ) + parent_ids: Optional[List[UUID]] = Field( + default=None, + description="Match folders whose parent is any of the given ids.", + ) + + path: Optional[str] = Field( + default=None, + description="Exact match on the materialized `path` (e.g. `support.prod`).", + ) + paths: Optional[List[str]] = Field( + default=None, + description="Exact match on any of the given paths.", + ) + + prefix: Optional[str] = Field( + default=None, + description="Subtree lookup: returns the folder at this path and every descendant.", + ) + prefixes: Optional[List[str]] = Field( + default=None, + description="Subtree lookup across multiple prefixes, OR-ed together.", + ) class FolderNameInvalid(Exception): diff --git a/docs/docs/reference/api-guide/11-folders.mdx b/docs/docs/reference/api-guide/11-folders.mdx new file mode 100644 index 0000000000..ada9d39514 --- /dev/null +++ b/docs/docs/reference/api-guide/11-folders.mdx @@ -0,0 +1,107 @@ +--- +title: "Folders" +description: "Organize applications, workflows, evaluators, testsets, and other resources into a folder tree." +sidebar_position: 11 +--- + +A **folder** is a named container used to group resources inside a project. Folders are project-scoped and organized as a tree. The `kind` field scopes a folder to a resource family — only `applications` is defined today, and it also covers workflows, evaluators, and testsets (they share the artifact table). + +## Path and naming + +Each folder has a URL-safe `slug` and a human-readable `name`. The server builds a dot-separated `path` from the folder's slug and its ancestors' slugs (for example, `support.prod`). Nest folders by setting `parent_id`; never put a separator inside a slug. + +| Rule | Limit | +|------|-------| +| `name` characters | Letters, digits, underscore, space, hyphen | +| `slug` length | 64 characters | +| Path depth | 10 levels | +| Path uniqueness | Unique per project | + +A name with other characters returns `400` (`Folder name contains invalid characters`). A path that already exists under the same parent returns `409`. A `parent_id` that does not exist returns `404`. + +## Assigning resources + +A resource is attached to a folder through a `folder_id` on the resource itself, not through a separate endpoint. Set `folder_id` when creating or editing an application, workflow, evaluator, or testset, and pass it in `/query` bodies to list resources inside a folder. Moving a resource is an edit on the resource, not on the folder. + +## Operations + +| Verb | Path | Purpose | +|------|------|---------| +| POST | `/folders/` | Create a folder | +| GET | `/folders/{folder_id}` | Fetch one folder | +| PUT | `/folders/{folder_id}` | Rename or move (change `slug`, `name`, or `parent_id`) | +| DELETE | `/folders/{folder_id}` | Hard-delete the folder and every descendant | +| POST | `/folders/query` | Filter folders | + +`DELETE` removes the folder and its subtree in one statement. There is no archive or unarchive step. Resources that were assigned to any of the removed folders continue to exist; they are no longer reachable through the deleted folder. + +## Listing + +`POST /folders/query` uses the envelope from the [Query Pattern](/reference/api-guide/query-pattern) but does not accept `windowing` or `include_archived`; it returns the full filtered set. Filters include `id`/`ids`, `slug`/`slugs`, `kind`/`kinds`, `parent_id`/`parent_ids` (send `null` for root folders), `path`/`paths`, and `prefix`/`prefixes` (the folder and every descendant). + +To list resources stored inside a folder, call the resource's query endpoint with `folder_id` set, for example `POST /applications/query` with `{"application": {"folder_id": ""}}`. + +## Example + +Create a nested folder and move a resource into it. + +```bash +# 1. Create a root folder +curl -X POST "$AGENTA_HOST/api/folders/" \ + -H "Content-Type: application/json" \ + -H "Authorization: ApiKey $AGENTA_API_KEY" \ + -d '{"folder": {"slug": "support", "name": "support", "kind": "applications"}}' +``` + +```json +{ + "count": 1, + "folder": { + "id": "019d9ca1-0000-0000-0000-000000000001", + "slug": "support", + "name": "support", + "kind": "applications", + "path": "support" + } +} +``` + +```bash +# 2. Nest a child folder under it +curl -X POST "$AGENTA_HOST/api/folders/" \ + -H "Content-Type: application/json" \ + -H "Authorization: ApiKey $AGENTA_API_KEY" \ + -d '{ + "folder": { + "slug": "prod", + "name": "prod", + "kind": "applications", + "parent_id": "019d9ca1-0000-0000-0000-000000000001" + } + }' +``` + +The child folder's `path` is `support.prod`. + +```bash +# 3. Move an existing application into the child folder +curl -X PUT "$AGENTA_HOST/api/applications/$APPLICATION_ID" \ + -H "Content-Type: application/json" \ + -H "Authorization: ApiKey $AGENTA_API_KEY" \ + -d '{ + "application": { + "id": "'"$APPLICATION_ID"'", + "folder_id": "019d9ca2-0000-0000-0000-000000000002" + } + }' +``` + +```bash +# 4. List the applications stored inside the child folder +curl -X POST "$AGENTA_HOST/api/applications/query" \ + -H "Content-Type: application/json" \ + -H "Authorization: ApiKey $AGENTA_API_KEY" \ + -d '{"application": {"folder_id": "019d9ca2-0000-0000-0000-000000000002"}}' +``` + +`DELETE /folders/019d9ca1-0000-0000-0000-000000000001` removes the root folder and the `support.prod` subfolder in one call. The application keeps existing and remains reachable through `/applications/query`.