Skip to content
Merged
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
5 changes: 1 addition & 4 deletions {{ cookiecutter.name }}/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@ dependencies = [
"django-split-settings>=1.3.2",
"django-storages>=1.14.6",
"django>=5.2,<6.0",
"djangorestframework-camel-case>=1.4.2",
"djangorestframework-simplejwt[crypto]>=5.5.0",
"djangorestframework>=3.15.2",
"drf-orjson-renderer>=1.7.3",
"drf-spectacular[sidecar]>=0.28.0",
"orjson>=3.11.9",
"pillow>=11.2.1",
"psycopg[binary]>=3.2.6",
"redis>=5.2.1",
Expand Down Expand Up @@ -228,8 +227,6 @@ module = [
"axes.*",
"celery.*",
"django_filters.*",
"djangorestframework_camel_case.*",
"drf_orjson_renderer.*",
"ipware.*",
]
ignore_missing_imports = true
Expand Down
49 changes: 49 additions & 0 deletions {{ cookiecutter.name }}/src/app/api/case_converters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Conversion between Python snake_case and wire camelCase API keys.

Policy:
- Python keys are words of [a-z0-9] joined by single underscores, wire keys are lowerCamelCase.
- A digit belongs to the preceding word: field2_name <-> field2Name.

Round-trip is guaranteed only for keys that follow this convention;
keys with double or leading underscores, capitalized abbreviations or digit-only words are out of scope.
"""

import re

from django.http import QueryDict
from django.utils.datastructures import MultiValueDict


camel_boundary_re = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")


def snake_to_camel(key: str) -> str:
first, *rest = key.split("_")
return first + "".join(word.capitalize() for word in rest)


def camel_to_snake(key: str) -> str:
return camel_boundary_re.sub("_", key).lower()


def camelize_keys(data: object) -> object:
if isinstance(data, dict):
return {snake_to_camel(key) if isinstance(key, str) else key: camelize_keys(value) for key, value in data.items()}
if isinstance(data, list | tuple):
return [camelize_keys(item) for item in data]
return data


def snakeize_keys(data: object) -> object:
if isinstance(data, dict):
return {camel_to_snake(key) if isinstance(key, str) else key: snakeize_keys(value) for key, value in data.items()}
if isinstance(data, list | tuple):
return [snakeize_keys(item) for item in data]
return data


def snakeize_query_dict(data: MultiValueDict) -> MultiValueDict:
result = QueryDict(mutable=True) if isinstance(data, QueryDict) else MultiValueDict()
for key in data:
result.setlist(camel_to_snake(key), data.getlist(key))
return result
34 changes: 21 additions & 13 deletions {{ cookiecutter.name }}/src/app/api/parsers.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
from collections.abc import Mapping
from typing import IO, Any

from djangorestframework_camel_case.settings import api_settings
from djangorestframework_camel_case.util import underscoreize
from drf_orjson_renderer.parsers import ORJSONParser
import orjson
from rest_framework.exceptions import ParseError
from rest_framework.parsers import BaseParser, DataAndFiles, FormParser, MultiPartParser

from app.api.case_converters import snakeize_keys, snakeize_query_dict

class AppJSONParser(ORJSONParser):
"""Combination of ORJSONParser and CamelCaseJSONParser"""

# djangorestframework_camel_case parameter
# details: https://github.com/vbabiy/djangorestframework-camel-case?tab=readme-ov-file#underscoreize-options
json_underscoreize = api_settings.JSON_UNDERSCOREIZE
class AppJSONParser(BaseParser):
media_type = "application/json"

def parse(self, stream: IO[Any], media_type: Any = None, parser_context: Any = None) -> Any:
def parse(self, stream: IO[Any], media_type: str | None = None, parser_context: Mapping[str, Any] | None = None) -> Any:
try:
data = super().parse(stream, media_type, parser_context)
return underscoreize(data, **self.json_underscoreize)
except ValueError as exc:
raise ParseError(f"JSON parse error - {exc}")
return snakeize_keys(orjson.loads(stream.read()))
except orjson.JSONDecodeError as exc:
raise ParseError(f"JSON parse error - {exc}") from exc


class AppFormParser(FormParser):
def parse(self, stream: IO[Any], media_type: str | None = None, parser_context: Mapping[str, Any] | None = None) -> Any:
return snakeize_query_dict(super().parse(stream, media_type, parser_context))


class AppMultiPartParser(MultiPartParser):
def parse(self, stream: IO[Any], media_type: str | None = None, parser_context: Mapping[str, Any] | None = None) -> DataAndFiles:
result = super().parse(stream, media_type, parser_context)
return DataAndFiles(snakeize_query_dict(result.data), snakeize_query_dict(result.files))
33 changes: 25 additions & 8 deletions {{ cookiecutter.name }}/src/app/api/renderers.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
from collections.abc import Iterable, Mapping
from decimal import Decimal
from typing import Any
from uuid import UUID

from djangorestframework_camel_case.util import camelize
from drf_orjson_renderer.renderers import ORJSONRenderer
import orjson
from django.utils.functional import Promise
from rest_framework.renderers import BaseRenderer

from app.api.case_converters import camelize_keys

class AppJSONRenderer(ORJSONRenderer):
"""Combination of CamelCaseJSONRenderer and ORJSONRenderer"""

charset = "utf-8" # force DRF to add charset header to the content-type
json_underscoreize = {"no_underscore_before_number": True} # https://github.com/vbabiy/djangorestframework-camel-case#underscoreize-options
def to_serializable(value: object) -> object:
if isinstance(value, Promise | UUID | Decimal):
return str(value)
if isinstance(value, bytes | bytearray):
raise TypeError(f"Type is not JSON serializable: {type(value).__name__}")
if isinstance(value, Iterable):
return camelize_keys(list(value))
raise TypeError(f"Type is not JSON serializable: {type(value).__name__}")

def render(self, data: Any, *args: Any, **kwargs: Any) -> bytes:
return super().render(camelize(data, **self.json_underscoreize), *args, **kwargs)

class AppJSONRenderer(BaseRenderer):
media_type = "application/json"
format = "json"
charset = "utf-8" # force DRF to add charset to the content-type header

def render(self, data: Any, accepted_media_type: str | None = None, renderer_context: Mapping[str, Any] | None = None) -> bytes:
if data is None:
return b""
return orjson.dumps(camelize_keys(data), default=to_serializable)
32 changes: 32 additions & 0 deletions {{ cookiecutter.name }}/src/app/api/spectacular.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from typing import Any

from app.api.case_converters import snake_to_camel


def camelize_serializer_fields(result: dict[str, Any], generator: Any, **kwargs: Any) -> dict[str, Any]: # noqa: ARG001
for schema in result.get("components", {}).get("schemas", {}).values():
camelize_schema(schema)
return result


def camelize_schema_properties(schema: dict[str, Any]) -> dict[str, Any]:
if isinstance(schema.get("properties"), dict):
schema["properties"] = {snake_to_camel(name): camelize_schema(prop) for name, prop in schema["properties"].items()}
if isinstance(schema.get("required"), list):
schema["required"] = [snake_to_camel(name) for name in schema["required"]]
for keyword in ("items", "additionalProperties"):
if isinstance(schema.get(keyword), dict):
schema[keyword] = camelize_schema(schema[keyword])
return schema


def camelize_schema_variants(schema: dict[str, Any]) -> dict[str, Any]:
for keyword in ("allOf", "anyOf", "oneOf"):
if keyword in schema:
schema[keyword] = [camelize_schema(item) for item in schema[keyword]]
return schema


def camelize_schema(schema: dict[str, Any]) -> dict[str, Any]:
schema = camelize_schema_properties(schema)
return camelize_schema_variants(schema)
6 changes: 3 additions & 3 deletions {{ cookiecutter.name }}/src/app/conf/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
],
"DEFAULT_PARSER_CLASSES": [
"app.api.parsers.AppJSONParser",
"djangorestframework_camel_case.parser.CamelCaseMultiPartParser",
"djangorestframework_camel_case.parser.CamelCaseFormParser",
"app.api.parsers.AppMultiPartParser",
"app.api.parsers.AppFormParser",
],
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning",
"DEFAULT_PAGINATION_CLASS": "app.api.pagination.AppPagination",
Expand All @@ -43,6 +43,6 @@
"CAMELIZE_NAMES": True,
"POSTPROCESSING_HOOKS": [
"drf_spectacular.hooks.postprocess_schema_enums",
"drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields",
"app.api.spectacular.camelize_serializer_fields",
],
}
Empty file.
170 changes: 170 additions & 0 deletions {{ cookiecutter.name }}/src/app/tests/api/test_api_camel_casing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import io
import json
from datetime import datetime
from decimal import Decimal
from urllib.parse import urlencode
from uuid import UUID

import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils.translation import gettext_lazy
from rest_framework.exceptions import ParseError
from rest_framework.request import Request
from rest_framework.test import APIRequestFactory

from app.api.parsers import AppFormParser, AppJSONParser, AppMultiPartParser
from app.api.renderers import AppJSONRenderer
from app.api.spectacular import camelize_serializer_fields


pytestmark = [
pytest.mark.django_db,
]


def test_renderer_camelizes_keys():
rendered = AppJSONRenderer().render({"first_name": "Ann", "home_address": {"zip_code": "12345"}, "field2_name": "Ann"})

assert json.loads(rendered) == {"firstName": "Ann", "homeAddress": {"zipCode": "12345"}, "field2Name": "Ann"}


def test_renderer_returns_empty_bytes_for_none():
assert AppJSONRenderer().render(None) == b""


def test_renderer_serializes_django_types():
data = {
"id": UUID("0195b1e0-6f0e-7000-8000-000000000000"),
"price": Decimal("9.90"),
"created": datetime(2026, 1, 2, 3, 4, 5),
"label": gettext_lazy("Lazy Text"),
"letters": (letter for letter in "ab"),
}

rendered = json.loads(AppJSONRenderer().render(data))

assert rendered == {
"id": "0195b1e0-6f0e-7000-8000-000000000000",
"price": "9.90",
"created": "2026-01-02T03:04:05",
"label": "Lazy Text",
"letters": ["a", "b"],
}


def test_renderer_rejects_unknown_types():
with pytest.raises(TypeError):
AppJSONRenderer().render({"value": object()})


def test_renderer_camelizes_keys_inside_lazy_iterables():
data = {"items": ({"tag_name": str(index)} for index in range(2))}

rendered = json.loads(AppJSONRenderer().render(data))

assert rendered == {"items": [{"tagName": "0"}, {"tagName": "1"}]}


def test_renderer_rejects_bytes():
with pytest.raises(TypeError):
AppJSONRenderer().render({"value": b"raw"})


def test_json_parser_snakeizes_keys():
parsed = AppJSONParser().parse(io.BytesIO(b'{"firstName":"Ann","homeAddress":{"zipCode":"12345"},"field2Name":"Ann"}'))

assert parsed == {"first_name": "Ann", "home_address": {"zip_code": "12345"}, "field2_name": "Ann"}


def test_json_parser_rejects_invalid_json():
with pytest.raises(ParseError):
AppJSONParser().parse(io.BytesIO(b"{broken"))


def test_json_parser_rejects_non_utf8_body():
with pytest.raises(ParseError):
AppJSONParser().parse(io.BytesIO(b'{"a":"\xff"}'))


def test_form_parser_snakeizes_keys():
request = APIRequestFactory().post("/", urlencode({"firstName": "Ann"}), content_type="application/x-www-form-urlencoded")

data = Request(request, parsers=[AppFormParser()]).data

assert data["first_name"] == "Ann"


def test_multipart_parser_snakeizes_data_and_file_keys():
upload = SimpleUploadedFile("avatar.png", b"image-bytes", content_type="image/png")
request = APIRequestFactory().post("/", {"firstName": "Ann", "avatarFile": upload}, format="multipart")

data = Request(request, parsers=[AppMultiPartParser()]).data

assert data["first_name"] == "Ann"
assert data["avatar_file"].name == "avatar.png"


def test_schema_camel_casing(as_anon):
response = as_anon.get("/api/v1/docs/schema/", as_response=True)

content = response.content.decode()

assert "User:" in content
assert "firstName:" in content
assert "remoteAddr:" in content


def test_spectacular_camelize_serializer_fields():
result = {
"components": {
"schemas": {
"User": {
"type": "object",
"properties": {
"first_name": {"type": "string"},
"home_address": {
"type": "object",
"properties": {"zip_code": {"type": "string"}},
"required": ["zip_code"],
},
},
"required": ["first_name"],
}
}
}
}

camelized = camelize_serializer_fields(result, generator=None)

assert camelized["components"]["schemas"]["User"]["properties"]["firstName"] == {"type": "string"}
assert camelized["components"]["schemas"]["User"]["properties"]["homeAddress"]["properties"]["zipCode"] == {"type": "string"}
assert camelized["components"]["schemas"]["User"]["properties"]["homeAddress"]["required"] == ["zipCode"]
assert camelized["components"]["schemas"]["User"]["required"] == ["firstName"]


def test_spectacular_camelize_schema_variants():
result = {
"components": {
"schemas": {
"Payload": {
"type": "object",
"properties": {
"entries": {"type": "array", "items": {"type": "object", "properties": {"zip_code": {"type": "string"}}}},
"extras": {"type": "object", "additionalProperties": {"type": "object", "properties": {"street_name": {"type": "string"}}}},
},
"allOf": [{"type": "object", "properties": {"first_name": {"type": "string"}}}],
"anyOf": [{"type": "object", "properties": {"last_name": {"type": "string"}}}],
"oneOf": [{"type": "object", "properties": {"middle_name": {"type": "string"}}}],
}
}
}
}

camelized = camelize_serializer_fields(result, generator=None)

schema = camelized["components"]["schemas"]["Payload"]
assert schema["properties"]["entries"]["items"]["properties"] == {"zipCode": {"type": "string"}}
assert schema["properties"]["extras"]["additionalProperties"]["properties"] == {"streetName": {"type": "string"}}
assert schema["allOf"] == [{"type": "object", "properties": {"firstName": {"type": "string"}}}]
assert schema["anyOf"] == [{"type": "object", "properties": {"lastName": {"type": "string"}}}]
assert schema["oneOf"] == [{"type": "object", "properties": {"middleName": {"type": "string"}}}]
Loading
Loading