| Author | HyeokJin Kim (hyeokjin@lablup.com) |
|---|---|
| Status | Draft |
| Created | 2026-01-17 |
| Created-Version | 26.1.0 |
| Target-Version | |
| Implemented-Version |
- JIRA: BA-3928
- Related BEP: BEP-1022: Pydantic Field Metadata Annotation
- Related BEP: BEP-1031: GraphQL API Field Metadata Extension
- Related BEP: BEP-1010: New GQL
Currently, REST API and GraphQL API implement separate validation for the same domain logic. This causes the following problems:
- Duplicate validation logic: Writing validation separately for REST and GraphQL for the same fields
- Lack of consistency: When validation rules change on one side, they may be missed on the other
- Testing burden: Must test the same validation in two places
- Maintenance cost: Must modify both places when business rules change
- Define validation rules in a single Pydantic model
- Apply the same validation to both REST API and GraphQL API
- Integrate metadata through
BackendAIAPIMeta - Eliminate code duplication and improve maintainability
# src/ai/backend/manager/dto/request.py
from pydantic import Field
from ai.backend.common.api_handlers import BaseRequestModel
class CreateObjectStorageRequest(BaseRequestModel):
name: str = Field(min_length=1, max_length=100)
host: str = Field(pattern=r'^[\w.-]+(:\d+)?$')
access_key: str = Field(min_length=10)
secret_key: str = Field(min_length=10)# src/ai/backend/manager/api/gql/object_storage.py
import strawberry
@strawberry.input(description="Added in 25.14.0")
class CreateObjectStorageInput:
name: str # No validation
host: str # No validation
access_key: str # No validation
secret_key: str # No validation- GraphQL input has no validation or requires separate implementation
- Validation rules may differ between REST and GraphQL
BackendAIAPIMetametadata is not shared
┌─────────────────────────────────────────────────────────────┐
│ Shared Pydantic Spec │
│ (validation + BackendAIAPIMeta) │
│ │
│ src/ai/backend/common/dto/manager/{domain}/shared.py │
└─────────────────────┬───────────────────┬───────────────────┘
│ │
┌────────────▼────────┐ ┌───────▼────────────────┐
│ REST API │ │ GraphQL │
│ │ │ │
│ Direct usage │ │ backend_ai_input() │
│ (BaseRequestModel) │ │ conversion │
└─────────────────────┘ └────────────────────────┘
# src/ai/backend/common/dto/manager/object_storage/shared.py
from __future__ import annotations
from typing import Annotated
from pydantic import Field, field_validator
from ai.backend.common.api_handlers import BaseRequestModel
from ai.backend.common.meta import BackendAIAPIMeta
class CreateObjectStorageSpec(BaseRequestModel):
"""Object Storage creation request spec.
Shared validation and metadata definition for REST API and GraphQL API.
"""
name: Annotated[
str,
Field(min_length=1, max_length=100),
BackendAIAPIMeta(
description="Unique name for Object Storage",
added_version="25.14.0",
),
]
host: Annotated[
str,
Field(pattern=r'^[\w.-]+(:\d+)?$'),
BackendAIAPIMeta(
description="Host address including port (e.g., s3.example.com:9000)",
added_version="25.14.0",
),
]
access_key: Annotated[
str,
Field(min_length=10),
BackendAIAPIMeta(
description="S3-compatible Access Key",
added_version="25.14.0",
secret=True,
),
]
secret_key: Annotated[
str,
Field(min_length=10),
BackendAIAPIMeta(
description="S3-compatible Secret Key",
added_version="25.14.0",
secret=True,
),
]
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
"""Name validation: cannot start with underscore."""
if v.startswith('_'):
raise ValueError("Name cannot start with underscore")
return v
class UpdateObjectStorageSpec(BaseRequestModel):
"""Object Storage update request spec."""
name: Annotated[
str | None,
Field(default=None, min_length=1, max_length=100),
BackendAIAPIMeta(
description="New name for Object Storage",
added_version="25.14.0",
),
]
host: Annotated[
str | None,
Field(default=None, pattern=r'^[\w.-]+(:\d+)?$'),
BackendAIAPIMeta(
description="New host address",
added_version="25.14.0",
),
]# src/ai/backend/manager/api/gql/object_storage/inputs.py
from __future__ import annotations
from ai.backend.common.dto.manager.object_storage.shared import (
CreateObjectStorageSpec,
UpdateObjectStorageSpec,
)
from ai.backend.common.meta import BackendAIAPIMeta
# Use backend_ai_input() from BEP-1031 to integrate metadata + validation
# Input types always operate on Pydantic basis
from ai.backend.manager.api.gql.utils import backend_ai_input
@backend_ai_input(
model=CreateObjectStorageSpec,
meta=BackendAIAPIMeta(
description="Object Storage creation input",
added_version="25.14.0",
),
)
class CreateObjectStorageInput:
"""GraphQL Input auto-generated from Pydantic Spec."""
pass
@backend_ai_input(
model=UpdateObjectStorageSpec,
meta=BackendAIAPIMeta(
description="Object Storage update input",
added_version="25.14.0",
),
)
class UpdateObjectStorageInput:
"""GraphQL Input auto-generated from Pydantic Spec."""
pass# src/ai/backend/manager/api/gql/object_storage/mutations.py
import strawberry
from pydantic import ValidationError
from strawberry.types import Info
from ai.backend.manager.api.gql.types import StrawberryGQLContext
from ai.backend.manager.errors import InvalidAPIParameterError
from .inputs import CreateObjectStorageInput
from .types import ObjectStorage
@strawberry.type
class ObjectStorageMutation:
@strawberry.mutation(description="Added in 25.14.0. Create new Object Storage")
async def create_object_storage(
self,
input: CreateObjectStorageInput,
info: Info[StrawberryGQLContext],
) -> ObjectStorage:
# Pydantic validation executed on to_pydantic() call
# Same validation rules as REST API applied
try:
spec = input.to_pydantic() # Returns CreateObjectStorageSpec
except ValidationError as e:
# Convert Pydantic ValidationError to BackendAIError
raise InvalidAPIParameterError.from_validation_error("input", e)
# Execute business logic
result = await info.context.services.object_storage.create(spec)
return ObjectStorage.from_data(result)# src/ai/backend/manager/api/object_storage.py
from aiohttp import web
from ai.backend.common.dto.manager.object_storage.shared import CreateObjectStorageSpec
async def create_object_storage(request: web.Request) -> web.Response:
"""REST API: Create Object Storage."""
body = await request.json()
# Pydantic validation executed (same Spec as GraphQL)
spec = CreateObjectStorageSpec.model_validate(body)
# Execute business logic
services = request.app['services']
result = await services.object_storage.create(spec)
return web.json_response(result.model_dump())To automate calling to_pydantic() every time, use Field Extension:
# src/ai/backend/manager/api/gql/extensions.py
from typing import Any, Callable, Protocol, TypeVar, runtime_checkable
from pydantic import BaseModel, ValidationError
import strawberry
from strawberry.extensions import FieldExtension
from strawberry.types import Info
from ai.backend.manager.errors import InvalidAPIParameterError
T = TypeVar('T', bound=BaseModel)
@runtime_checkable
class PydanticConvertible(Protocol[T]):
"""Protocol for Strawberry input types convertible to Pydantic models."""
def to_pydantic(self) -> T: ...
class PydanticValidationExtension(FieldExtension):
"""Automatically convert GraphQL input to Pydantic model with validation."""
def resolve(
self,
next_: Callable[..., Any],
source: Any,
info: Info,
**kwargs: Any,
) -> Any:
# Automatically call to_pydantic() on Input types
validated_kwargs = {}
for key, value in kwargs.items():
# Type check using Protocol (more explicit than hasattr)
if isinstance(value, PydanticConvertible):
try:
validated_kwargs[key] = value.to_pydantic()
except ValidationError as e:
# Convert ValidationError to BackendAIError
raise InvalidAPIParameterError.from_validation_error(key, e)
else:
validated_kwargs[key] = value
return next_(source, info, **validated_kwargs)
# Usage example
@strawberry.mutation(extensions=[PydanticValidationExtension()])
async def create_object_storage(
self,
input: CreateObjectStorageInput, # Automatically converted to CreateObjectStorageSpec
info: Info[StrawberryGQLContext],
) -> ObjectStorage:
# input is already a Pydantic model (CreateObjectStorageSpec)
spec: CreateObjectStorageSpec = input
...Following the existing src/ai/backend/common/dto/manager/ pattern with Request, Response, Shared separation:
src/ai/backend/common/dto/manager/
├── object_storage/
│ ├── __init__.py
│ ├── shared.py # Shared Pydantic models (validation + meta)
│ │ # Spec definitions shared by REST and GraphQL
│ ├── request.py # REST API request models (using shared)
│ └── response.py # REST API response models
│
└── {other_domain}/
├── shared.py # Shared validation rules
├── request.py
└── response.py
src/ai/backend/manager/
├── api/
│ ├── object_storage.py # REST API handlers
│ └── gql/
│ ├── extensions.py # PydanticValidationExtension
│ └── object_storage/
│ ├── __init__.py
│ ├── inputs.py # GraphQL inputs (generated from shared.py Spec)
│ ├── types.py # GraphQL output types
│ └── mutations.py # GraphQL mutations
│
└── dto/ # (Backward compatibility, re-export only)
└── object_storage.py # Re-export from common/dto
Core Principles:
shared.py: REST/GraphQL shared validation rules andBackendAIAPIMetadefinitionsrequest.py: REST API-specific request models (using or extending shared Spec)response.py: REST API-specific response models- GraphQL inputs: Convert
shared.pySpec usingbackend_ai_input()
Existing dto/ directory models maintained as re-exports:
# src/ai/backend/manager/dto/object_storage.py
# Re-export for backward compatibility
from ai.backend.common.dto.manager.object_storage.shared import (
CreateObjectStorageSpec as CreateObjectStorageRequest,
UpdateObjectStorageSpec as UpdateObjectStorageRequest,
)
__all__ = [
"CreateObjectStorageRequest",
"UpdateObjectStorageRequest",
]Use dedicated error inheriting from BackendAIError for input validation failures:
# src/ai/backend/manager/errors.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from pydantic import ValidationError
from ai.backend.common.exception import BackendAIError
@dataclass
class ValidationErrorDetail:
"""Represents individual error from Pydantic ValidationError as a value."""
field: str
message: str
type: str
input_value: Any = None
@classmethod
def from_pydantic_error(cls, error: dict[str, Any]) -> ValidationErrorDetail:
"""Create from Pydantic error dict."""
return cls(
field=".".join(str(loc) for loc in error["loc"]),
message=error["msg"],
type=error["type"],
input_value=error.get("input"),
)
@dataclass
class InvalidAPIParameterError(BackendAIError):
"""API parameter validation failure error.
Converts Pydantic ValidationError to BackendAIError for
consistent error handling and error access as values.
"""
parameter_name: str
errors: list[ValidationErrorDetail] = field(default_factory=list)
@classmethod
def from_validation_error(
cls,
parameter_name: str,
validation_error: ValidationError,
) -> InvalidAPIParameterError:
"""Create from Pydantic ValidationError.
Catches ValidationError and converts to BackendAIError system.
Error details are structured and preserved as ValidationErrorDetail.
"""
details = [
ValidationErrorDetail.from_pydantic_error(err)
for err in validation_error.errors()
]
return cls(parameter_name=parameter_name, errors=details)
def __str__(self) -> str:
messages = [f"{e.field}: {e.message}" for e in self.errors]
return f"Validation failed for '{self.parameter_name}': {'; '.join(messages)}"
# Usage example
try:
spec = input.to_pydantic()
except ValidationError as e:
# Convert ValidationError to BackendAIError that can be handled as a value
raise InvalidAPIParameterError.from_validation_error("input", e)# src/ai/backend/manager/api/gql/utils.py
def format_validation_error_for_graphql(error: InvalidAPIParameterError) -> str:
"""Format InvalidAPIParameterError as GraphQL error message."""
return str(error){
"errors": [
{
"message": "Validation failed for 'input': name: Name cannot start with underscore; access_key: String should have at least 10 characters",
"path": ["createObjectStorage"]
}
]
}- Existing
dto/models can continue to be used as aliases - No changes in GraphQL schema output (description remains the same)
- No changes in REST API response format
- Phase 1: New domains use
common/dto/manager/{domain}/shared.pypattern - Phase 2: Existing domains are gradually migrated when modified
- Phase 3:
manager/dto/directory maintains re-exports only, actual models move tocommon/dto/
- None (full backward compatibility)
Goal: Implement basic patterns and utilities
Tasks:
- Implement
PydanticValidationExtension - Add
InvalidAPIParameterErrorand utilities - Document patterns
Goal: Apply new pattern to one domain
Target: object_storage (relatively simple domain)
Tasks:
- Create
common/dto/manager/object_storage/shared.py - Migrate GraphQL inputs
- Migrate REST API
- Integrate tests
Goal: Apply to all new domains
Tasks:
- Add pattern to coding guidelines
- Update code review checklist
Goal: Gradually migrate existing domains
Priority:
- Frequently modified domains
- Domains with complex validation
- Domains with REST/GraphQL inconsistencies
| Item | Description |
|---|---|
| Unified validation | Manage all validation in a single Pydantic model |
| Integrated metadata | Manage version, description with BackendAIAPIMeta |
| Code deduplication | No need to write validation logic separately for REST/GraphQL |
| Consistency guarantee | Same rules automatically applied to both sides |
| Simplified testing | Testing Pydantic model covers both sides |
| Maintainability | Only one place to modify when business rules change |
- Is
strawberry.experimental.pydanticproduction-ready? (Currently experimental status) - Should Pydantic's constrained types (e.g.,
constr(min_length=1)) be exposed in GraphQL schema? - What should be the error message format for nested validation (complex objects)?
- Should
PydanticValidationExtensionbe applied globally or per-mutation?