{'type': 'missing', 'loc': ('response', 'data', 0, 'external_id'), 'msg': 'Field required', 'input': {'id': UUID('ad33be56-5bf8-4def-b355-e8a9e9500c00'), 'externalId': UUID('d2a23deb-41d0-40bc-ad36-960b842f9708'), 'title': 'First item', 'createdAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588627, tzinfo=zoneinfo.ZoneInfo(key='UTC')), 'updatedAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588632, tzinfo=zoneinfo.ZoneInfo(key='UTC'))}}
{'type': 'missing', 'loc': ('response', 'data', 0, 'created_at'), 'msg': 'Field required', 'input': {'id': UUID('ad33be56-5bf8-4def-b355-e8a9e9500c00'), 'externalId': UUID('d2a23deb-41d0-40bc-ad36-960b842f9708'), 'title': 'First item', 'createdAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588627, tzinfo=zoneinfo.ZoneInfo(key='UTC')), 'updatedAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588632, tzinfo=zoneinfo.ZoneInfo(key='UTC'))}}
{'type': 'missing', 'loc': ('response', 'data', 0, 'updated_at'), 'msg': 'Field required', 'input': {'id': UUID('ad33be56-5bf8-4def-b355-e8a9e9500c00'), 'externalId': UUID('d2a23deb-41d0-40bc-ad36-960b842f9708'), 'title': 'First item', 'createdAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588627, tzinfo=zoneinfo.ZoneInfo(key='UTC')), 'updatedAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588632, tzinfo=zoneinfo.ZoneInfo(key='UTC'))}}
{'type': 'missing', 'loc': ('response', 'data', 1, 'external_id'), 'msg': 'Field required', 'input': {'id': UUID('51fd8b72-7823-47a3-b259-88cb5fba3ec5'), 'externalId': UUID('1c2983f0-0a12-4591-a4a3-7541c944bc17'), 'title': 'Second item', 'createdAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588642, tzinfo=zoneinfo.ZoneInfo(key='UTC')), 'updatedAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588642, tzinfo=zoneinfo.ZoneInfo(key='UTC'))}}
{'type': 'missing', 'loc': ('response', 'data', 1, 'created_at'), 'msg': 'Field required', 'input': {'id': UUID('51fd8b72-7823-47a3-b259-88cb5fba3ec5'), 'externalId': UUID('1c2983f0-0a12-4591-a4a3-7541c944bc17'), 'title': 'Second item', 'createdAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588642, tzinfo=zoneinfo.ZoneInfo(key='UTC')), 'updatedAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588642, tzinfo=zoneinfo.ZoneInfo(key='UTC'))}}
{'type': 'missing', 'loc': ('response', 'data', 1, 'updated_at'), 'msg': 'Field required', 'input': {'id': UUID('51fd8b72-7823-47a3-b259-88cb5fba3ec5'), 'externalId': UUID('1c2983f0-0a12-4591-a4a3-7541c944bc17'), 'title': 'Second item', 'createdAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588642, tzinfo=zoneinfo.ZoneInfo(key='UTC')), 'updatedAt': datetime.datetime(2026, 4, 16, 20, 51, 27, 588642, tzinfo=zoneinfo.ZoneInfo(key='UTC'))}}
from datetime import date, datetime
from uuid import UUID, uuid4
from zoneinfo import ZoneInfo
from cadwyn import (
Cadwyn,
HeadVersion,
Version,
VersionBundle,
VersionedAPIRouter,
)
from fastapi import status
from pydantic import AliasGenerator, BaseModel, ConfigDict
from pydantic.alias_generators import to_camel, to_snake
class SnakeToCamelModel(BaseModel):
model_config = ConfigDict(
alias_generator=AliasGenerator(
validation_alias=to_snake,
serialization_alias=to_camel,
),
)
class ItemRead(SnakeToCamelModel):
id: UUID
external_id: UUID
title: str
created_at: datetime
updated_at: datetime
class IGetResponsePaginated(SnakeToCamelModel):
data: list[ItemRead]
meta: dict | None = None
message: str = ""
router = VersionedAPIRouter()
fake_data = [
{
"id": uuid4(),
"external_id": uuid4(),
"title": "First item",
"created_at": datetime.now(ZoneInfo("UTC")),
"updated_at": datetime.now(ZoneInfo("UTC")),
},
{
"id": uuid4(),
"external_id": uuid4(),
"title": "Second item",
"created_at": datetime.now(ZoneInfo("UTC")),
"updated_at": datetime.now(ZoneInfo("UTC")),
},
]
@router.get(
"/items", response_model=IGetResponsePaginated, status_code=status.HTTP_200_OK
)
async def read_item_list():
return IGetResponsePaginated(
data=[ItemRead(**item) for item in fake_data],
meta={"page": 1, "per_page": 10, "total": len(fake_data)},
message="Data paginated correctly",
)
versions = VersionBundle(
HeadVersion(),
Version(date(2026, 1, 1)),
)
app = Cadwyn(
versions=versions,
)
app.generate_and_include_versioned_routers(router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="localhost", port=8000)
Describe the bug
When using Pydantic models with
serialization_alias(e.g., convertingsnake_casetocamelCasefor API responses), Cadwyn's response validation fails withResponseValidationError: Field required.The error occurs because Cadwyn's internal validation process expects fields in
snake_case(as defined in the Python model), but the actual response data has already been transformed tocamelCaseby Pydantic'salias_generator.Error stacktrace (excerpt):
Key observation: The input data contains camelCase fields (
externalId,createdAt,updatedAt), but Cadwyn expects snake_case (external_id,created_at,updated_at).To Reproduce
1. Create py file with:
2. Make a request to the endpoint:
curl -X GET http://localhost:8000/items -H "X-API-VERSION: 2026-01-01"3. See error:
Cadwyn fails to validate the response because it expects
snake_casefields but receivescamelCase.Expected behavior
Cadwyn should respect Pydantic's
serialization_aliasconfiguration and validate responses after the serialization aliases have been applied, or provide a way to configure the expected field naming convention for response validation.Currently, Cadwyn validates responses against the
Python model field names (snake_case), not against theserialized field names (camelCase). This makes it impossible to use Pydantic's built-in camelCase serialization with Cadwyn.Operating system
Windows 11
Additional context
Related issue found: #109 "Add an ability to modify pydantic schema configuration" (still open)
Root cause analysis:
validation_alias(which expectssnake_case)serialization_alias(which would producecamelCase)Versions:
Python: 3.13
Cadwyn: 6.2.0
FastAPI: 0.135.3
Pydantic: 2.13.0