# API Guide

> Guide for implementing REST and GraphQL APIs (create, get, search, update, delete, purge, scope prefix patterns, admin_ prefix, SearchScope, BaseFilterAdapter, @api_function, Click CLI)

- Skill: `lablup/api-guide` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lablup/api-guide`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lablup/api-guide/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: lablup (https://skillmd.com/u/lablup)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lablup/api-guide

---


# API Implementation Guide

Guide for implementing REST and GraphQL APIs with standard operations and scope patterns.

## Standard Operations

APIs implement 6 standard operations:

1. **create** - Create new entity
2. **get** - Retrieve single entity
3. **search** - Query with filters and pagination
4. **update** - Update entity
5. **delete** - Delete entity (soft)
6. **purge** - Permanently remove entity (hard)

**Multi-target:** `batch_*` (atomic, one statement) vs `bulk_*` (per-row, partial failures). See `/repository-guide`.

## Scope Prefix Rules

**API layer only** (Service/Repository layers don't use prefix)

### Scoped Operations

Operations within a scope use `{scope}_` prefix:

**REST:**
```
POST   /domains/{domain}/users        → domain_create_user
GET    /domains/{domain}/users/{id}   → domain_user
GET    /domains/{domain}/users         → domain_search_users
PATCH  /domains/{domain}/users/{id}   → domain_update_user
DELETE /domains/{domain}/users/{id}   → domain_delete_user
```

**GraphQL:**
```graphql
mutation domainCreateUser(scope: DomainScope, input: ...)
query domainUser(scope: DomainScope, id: ID)
query domainSearchUsers(scope: DomainScope, filter: ...)
```

### Admin Operations (No Scope)

Operations without scope use `admin_` prefix (superadmin required):

**REST:**
```
POST   /admin/domains        → admin_create_domain
GET    /admin/domains/{id}   → admin_domain
GET    /admin/domains         → admin_search_domains
```

**GraphQL:**
```graphql
mutation adminCreateDomain(input: ...)
query adminDomain(id: ID)
query adminSearchDomains(filter: ...)
```

### When to Separate admin_ vs Non-admin Endpoints

**search — always two variants:**
- `admin_search_*`: superadmin, no scope — queries entire system.
- `{scope}_search_*`: non-admin, scope required — queries within the given scope only.
- There is NO "search everything without scope" for non-admin users.

**create / update / get / delete / purge — depends on the entity:**

1. **Admin-only entity** (e.g., Domain, ContainerRegistry): single `admin_` endpoint.
2. **Both admin and users, but behavior differs** (e.g., admin can set more fields on create/update): separate `admin_` and non-admin endpoints with different DTOs.
3. **Both admin and users, only permission check differs**: single endpoint — admin already has entity access permissions, no need for a separate `admin_` variant.

## REST v1 API Patterns (Legacy)

> **New endpoints MUST use REST v2 patterns below.**
> REST v1 is for existing legacy handlers only — do not add new handlers here.

### Architecture

```
REST v1 Handler → Processor → Service → Repository
```

**Key Files:**
- `src/ai/backend/manager/api/rest/{domain}/handler.py` - Handlers
- `src/ai/backend/manager/api/rest/{domain}/adapter.py` - Filters (per-domain)
- `src/ai/backend/manager/api/rest/adapter.py` - Base adapter

**See complete examples:**
- `api/rest/fair_share/handler.py` - Handler calling processor
- `services/processors.py` - ActionProcessor base class

## REST v2 API Patterns

> REST v2 uses the same DTOs as GraphQL (`common/dto/manager/v2/`), providing a
> unified schema across both API surfaces.

### Architecture

```
REST v2 Handler → Adapter (api/adapters/) → Processor → Service → Repository
```

**Key difference from v1:** v2 handlers call shared Adapters instead of Processors directly.
Adapters are shared with the GQL layer.

**Key Files:**
- `src/ai/backend/manager/api/rest/v2/{domain}/handler.py` - Handlers
- `src/ai/backend/manager/api/rest/v2/{domain}/registry.py` - Route registration
- `src/ai/backend/manager/api/adapters/{domain}.py` - Shared adapters (same as GQL)
- `src/ai/backend/common/dto/manager/v2/{domain}/` - Shared DTOs

### Handler Pattern

```python
from ai.backend.common.api_handlers import APIResponse, BodyParam
from ai.backend.common.dto.manager.v2.domain.request import AdminSearchDomainsInput
from ai.backend.manager.api.adapters.registry import Adapters


class DomainV2Handler:
    def __init__(self, *, adapters: Adapters) -> None:
        self._adapters = adapters

    async def admin_search(
        self,
        body: BodyParam[AdminSearchDomainsInput],
    ) -> APIResponse:
        payload = await self._adapters.domain.admin_search(body.parsed)
        return APIResponse.build(
            status_code=HTTPStatus.OK, response_model=payload,
        )
```

### Scope Pattern

Scope defines access boundaries.

**Repository Scope:**
- `src/ai/backend/manager/repositories/{domain}/types.py`
- Example: `repositories/fair_share/types.py`
  - `DomainFairShareSearchScope`, `ProjectFairShareSearchScope`

**Pattern:**
- Frozen dataclass with scope params
- `to_conditions()` converts to query conditions

**See complete examples:**
- `api/fair_share/handler.py` - Scoped handler implementations

### Scope Parameter Usage

**Scope parameter is needed for:**

1. **search** - Filter multiple items within scope
   ```python
   domain_search_users(scope: DomainScope, filter: ...)
   ```

2. **batch operations** - Process multiple items within scope
   ```python
   domain_batch_update_users(scope: DomainScope, ids: list[ID], ...)
   ```

**Scope parameter is NOT needed for:**

1. **get** - ID uniquely identifies item
   ```python
   user(id: ID)  # ✅ No scope needed
   # domain_user(scope: DomainScope, id: ID)  # ❌ Unnecessary
   ```

2. **update/delete/purge** - ID uniquely identifies item
   ```python
   update_user(id: ID, data: ...)  # ✅ No scope
   delete_user(id: ID)  # ✅ No scope
   ```

3. **create** - Scope info in data
   ```python
   create_user(data: CreateUserData)  # data contains domain_name
   ```

**Note:** Scope prefix in API name (`domain_search_users`) is different from scope parameter (`scope: DomainScope`).

### Filter Pattern

Filters convert API params to QueryCondition.

**Adapter:**
- `api/adapter.py` - BaseFilterAdapter
- `api/fair_share/adapter.py` - Domain adapters

**Pattern:**
- `to_conditions(filters)` → `list[QueryCondition]`
- `to_orders(order_by)` → `list[QueryOrder]`
- Adapter → `BatchQuerier` → Repository

**See complete examples:**
- `api/fair_share/adapter.py` - Filter adapters
- `api/fair_share/handler.py` - Handler implementations with BatchQuerier

### Admin_ Prefix Pattern

Admin operations require superadmin check before processing.

**Pattern:**
- `_check_superadmin(request)` at handler start
- Raise `InsufficientPermission` if not superadmin

**See complete examples:**
- `api/rbac/handler.py` - Admin handler implementations

### Pagination

REST uses offset-based pagination.

```python
class PaginationQuery(BaseModel):
    offset: int = 0
    limit: int = 20

# Response
class SearchResult(BaseModel):
    items: list[Item]
    total_count: int
    offset: int
    limit: int
```

**Client calculates:** `has_next = offset + limit < total_count`

## GraphQL Patterns

### Architecture

```
GraphQL Resolver → check_admin_only (if admin) → Adapter (info.context.adapters.*) → Processor → Service → Repository
```

**Key Files:**
- `src/ai/backend/manager/api/gql/{domain}/resolver/` - Resolvers
- `src/ai/backend/manager/api/gql/decorators.py` - Custom decorators (required)
- `src/ai/backend/manager/api/gql/pydantic_compat.py` - PydanticNodeMixin, PydanticInputMixin
- `src/ai/backend/manager/api/gql/utils.py` - Utilities

### Decorator Rules

All GQL types MUST use custom decorators from `decorators.py` — NEVER use `@strawberry.type`, `@strawberry.input`, or `@strawberry.experimental.pydantic.*` directly:

- `@gql_node_type(meta)` — Relay Node types (inherit `PydanticNodeMixin[DTO]`)
- `@gql_pydantic_type(meta, model=DTO)` — output types and payloads backed by a v2 Pydantic DTO
- `@gql_pydantic_input(meta)` — input types (inherit `PydanticInputMixin[DTO]`)
- `@gql_pydantic_interface(meta, model=DTO)` — interface types
- `@gql_connection_type(meta)` — Connection[T] / Edge[T] subclasses

`@strawberry.enum`, `@strawberry.field`, `@strawberry.mutation`, `@strawberry.subscription` may be used directly.

### Type System Rules

**Strawberry Runtime Evaluation:**
- Strawberry types are evaluated at runtime
- NEVER use TYPE_CHECKING for Strawberry types (Connection, Filter, OrderBy, Input, Type)
- ALWAYS import Strawberry types directly at module level
- Only use TYPE_CHECKING for data layer types not used by Strawberry
- If lazy import needed: use strawberry.lazy() or string-based forward references

**Naming Convention:**
- All GraphQL types MUST have `GQL` suffix (DomainGQL, DomainScopeGQL, DomainFilterGQL)
- Distinguishes GraphQL types from data layer types

**Scope vs Filter:**
- Scope: Required context parameters (resource_group, domain_name, project_id)
- Filter: Optional filtering conditions (name contains, status equals, created after)
- NEVER put optional fields in Scope - use Filter instead
- Scope fields must all be required (no default values, no Optional types)

### Cross-Entity Reference Resolvers

When a GQL node references another entity node, use `strawberry.lazy()` to avoid circular imports. Strawberry requires runtime type resolution, so `TYPE_CHECKING` imports alone are insufficient.

**Pattern:**
```python
# 1. TYPE_CHECKING: for static analysis (mypy)
if TYPE_CHECKING:
    from ai.backend.manager.api.gql.domain_v2.types.node import DomainV2GQL

# 2. Return type: Annotated with strawberry.lazy() for runtime resolution
# 3. Function body: runtime import + DataLoader
async def domain(self, info: Info[StrawberryGQLContext]) -> Annotated[
    DomainV2GQL,
    strawberry.lazy("ai.backend.manager.api.gql.domain_v2.types.node"),
]:
    from ai.backend.manager.api.gql.domain_v2.types.node import DomainV2GQL
    data = await info.context.data_loaders.domain_loader.load(self.domain_name)
    return DomainV2GQL.from_data(data)
```

**Optional return type:** `| None` must be **outside** `Annotated[]`:
```python
) -> Annotated[DomainV2GQL, strawberry.lazy("...")] | None:  # ✅
) -> Annotated[DomainV2GQL | None, strawberry.lazy("...")]:   # ❌ lazy cannot resolve union
```

**DataLoaders** (`info.context.data_loaders`): Use DataLoaders instead of individual fetch functions to prevent N+1 queries. See `api/gql/data_loader/data_loaders.py` for available loaders.

**See examples:**
- `api/gql/fair_share/types/domain.py` - Cross-entity reference with DataLoader
- `api/gql/domain_v2/types/node.py` - DomainV2GQL with fair_shares/usage_buckets
- `api/gql/fair_share/types/*.py` - Scope and Filter patterns

### Scope Pattern

**Input Types:**
- `api/gql/types.py`
  - `ResourceGroupDomainScope`, `ResourceGroupProjectScope`, `ResourceGroupUserScope`

**Pattern:**
- Strawberry `@input` maps to repository SearchScope
- GraphQL input → Repository scope type conversion

**See complete examples:**
- `api/gql/types.py` - Scope input types
- `api/gql/fair_share/resolver/domain.py` - Resolver implementations

### Admin Check

**Pattern:**
- `check_admin_only(info)` at resolver start
- Raise `InsufficientPermission` if not superadmin

**See complete examples:**
- `api/gql/utils.py` - `check_admin_only()` utility
- `api/gql/*/resolver/*.py` - Admin resolver implementations

### Pagination

GraphQL supports cursor-based (Relay spec).

```python
@strawberry.type
class PageInfo:
    has_next_page: bool
    has_previous_page: bool
    start_cursor: str | None
    end_cursor: str | None

@strawberry.type
class UserConnection:
    edges: list[UserEdge]
    page_info: PageInfo
    total_count: int
```

**See:** `api/gql/` for cursor pagination examples

## REST v1 vs REST v2 vs GraphQL

- **REST v1 (legacy)**: Handler → Processor. DTOs from `common/dto/manager/`. Per-domain adapters in `api/rest/{domain}/adapter.py`.
- **REST v2**: Handler → Adapter. DTOs from `common/dto/manager/v2/`. Shared adapters in `api/adapters/{domain}.py`.
- **GraphQL**: Resolver → Adapter (same adapters as REST v2). DTOs from `common/dto/manager/v2/`. Strawberry types wrap DTOs via custom decorators.
- Admin check: REST uses `superadmin_required` middleware, GQL uses `check_admin_only(info)`.
- Naming: REST uses `domain_create_user`, GQL uses `domainCreateUser`.

## Client SDK + CLI Integration

**When implementing REST API, also implement:**

1. ✅ **SDK Function** (`client/func/{domain}.py`)
   - Use `@api_function` decorator
   - Map to REST endpoint

2. ✅ **CLI Command** (`client/cli/admin/{domain}.py`)
   - Click command
   - Calls SDK function

**Integration flow:**
```
CLI → SDK → REST API → Processor → Service → Repository
```

**See examples:**
- `src/ai/backend/client/func/admin.py` - SDK
- `src/ai/backend/client/cli/admin/user.py` - CLI

## Testing

**See:** `/test-guide` skill and `tests/CLAUDE.md` for complete testing strategies.

**Test hierarchy:**
```
Repository Tests → Real DB (with_tables)
Service Tests → Mock repositories
API Handler Tests → Mock processors
CLI Tests → Mock HTTP
```

## Implementation Checklist

**When implementing new API:**

1. ✅ **Repository** (`/repository-guide`)
   - Implement standard operations
   - Define SearchScope

2. ✅ **Service** (`/service-guide`)
   - Define Actions/ActionResults
   - Implement service methods
   - Create processors

3. ✅ **REST API v2** (all new endpoints)
   - Handler calls Adapter, uses `common/dto/manager/v2/` DTOs
   - `BodyParam[DTO]` input, `APIResponse.build(response_model=payload)` output
   - Admin check and scope prefix

4. ✅ **GraphQL** (optional)
   - `@gql_pydantic_type` / `@gql_pydantic_input` with DTO — no `@strawberry.type` directly
   - Resolver calls Adapter (`info.context.adapters.*`)
   - Admin check if needed

5. ✅ **Client SDK**
   - Add SDK function
   - `@api_function` decorator

6. ✅ **CLI**
   - Click command
   - Integrate with SDK

7. ✅ **Tests**
   - Repository (real DB)
   - Service (mock repo)
   - Handler (mock processors)
   - CLI (mock HTTP)

## Related Documentation

- **Service Layer**: `/service-guide` - Actions, Processors
- **Repository Layer**: `/repository-guide` - Data access
- **Testing**: `/test-guide` - Scenario-first testing
- **API README**: `src/ai/backend/manager/api/README.md`

## Examples

**REST API:**
- `src/ai/backend/manager/api/fair_share/handler.py`
- `src/ai/backend/manager/api/rbac/handler.py`

**GraphQL:**
- `src/ai/backend/manager/api/gql/fair_share/resolver/domain.py`
- `src/ai/backend/manager/api/gql/types.py`

**Client SDK/CLI:**
- `src/ai/backend/client/func/admin.py`
- `src/ai/backend/client/cli/admin/user.py`

## Summary

**Standard operations:**
- create, get, search, update, delete, purge
- batch_update, batch_delete, batch_purge

**Scope prefix (API only):**
- Scoped: `{scope}_operation` (domain_create_user)
- Admin: `admin_operation` (admin_create_domain)

**Key patterns:**
- Scope → SearchScope → QueryCondition
- Filter → Adapter → QueryCondition
- Admin → check permission first

**Integration:**
- REST API + SDK + CLI (unified stack)
- GraphQL (separate, optional)

**Next steps:**
1. Implement repository (`/repository-guide`)
2. Implement service (`/service-guide`)
3. Implement API handlers
4. Add SDK + CLI
5. Write tests (`/test-guide`)

