Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions api/oss/src/apis/fastapi/folders/models.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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):
Expand Down
45 changes: 45 additions & 0 deletions api/oss/src/apis/fastapi/folders/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
107 changes: 83 additions & 24 deletions api/oss/src/core/folders/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down
107 changes: 107 additions & 0 deletions docs/docs/reference/api-guide/11-folders.mdx
Original file line number Diff line number Diff line change
@@ -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": "<folder-uuid>"}}`.

## 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`.
Loading