From 2a6846b2e51fec453b26c92be148afa65aea48a8 Mon Sep 17 00:00:00 2001 From: Morten Krane Date: Thu, 3 Aug 2023 13:19:29 +0200 Subject: [PATCH] Support serializing with the `by_alias` arg set (#10) Enables us to define custom aliases for the fields in the Pydantic model, and have the @api decorator serialize the model using those aliases. --- django_api_decorator/decorators.py | 7 ++- tests/test_response_encoding.py | 84 +++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/django_api_decorator/decorators.py b/django_api_decorator/decorators.py index bdccb50..6083dbb 100644 --- a/django_api_decorator/decorators.py +++ b/django_api_decorator/decorators.py @@ -31,6 +31,7 @@ def api( response_status: int = 200, atomic: bool | None = None, auth_check: Callable[[HttpRequest], bool] | None = None, + serialize_by_alias: bool = False, ) -> Callable[[Callable[P, T]], Callable[P, HttpResponse]]: """ Defines an API view. This handles validation of query parameters, parsing of @@ -49,6 +50,10 @@ def api( HTTP status code to use if the view _does not_ return an Response object, but rather just the data we should return. + * serialize_by_alias: + Is passed as the by_alias argument to TypeAdapter.dump_json(), making + the model use the aliases defined in model_config when serializing. + The request body parsing is done by inspecting the view parameter types. If the view has a body parameter, we will try to decode the payload to that type. Currently Django Rest Framework serializers and pydantic models are are @@ -182,7 +187,7 @@ def inner(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: ) # Encode the response from the view to json and create a response object. - payload = response_adapter.dump_json(response) + payload = response_adapter.dump_json(response, by_alias=serialize_by_alias) return HttpResponse( payload, status=response_status, content_type="application/json" ) diff --git a/tests/test_response_encoding.py b/tests/test_response_encoding.py index 4676fee..ae1d5a1 100644 --- a/tests/test_response_encoding.py +++ b/tests/test_response_encoding.py @@ -1,10 +1,11 @@ import random +import re import pytest from django.http import HttpRequest, JsonResponse from django.test.client import Client from django.urls import path -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from typing_extensions import TypedDict from django_api_decorator.decorators import api @@ -12,21 +13,39 @@ class MyTypedDict(TypedDict): - a: int + an_integer: int class MyPydanticModel(BaseModel): - a: int + an_integer: int + + +def camel_case(string: str) -> str: + """ + Convert string from snake_case to camelCase + """ + + _pascal_case = re.sub(r"(?:^|_)(.)", lambda m: m.group(1).upper(), string) + return _pascal_case[0].lower() + _pascal_case[1:] + + +class MyCamelCasePydanticModel(BaseModel): + model_config = ConfigDict( + alias_generator=camel_case, + populate_by_name=True, + ) + + an_integer: int @api(method="GET") def view_json_response(r: HttpRequest) -> JsonResponse: - return JsonResponse({"a": 1}) + return JsonResponse({"an_integer": 1}) @api(method="GET") def view_typed_dict(r: HttpRequest) -> MyTypedDict: - return {"a": 1} + return {"an_integer": 1} @api(method="GET") @@ -41,7 +60,12 @@ def view_bool(r: HttpRequest) -> bool: @api(method="GET") def view_pydantic_model(r: HttpRequest) -> MyPydanticModel: - return MyPydanticModel(a=1) + return MyPydanticModel(an_integer=1) + + +@api(method="GET", serialize_by_alias=True) +def view_camel_case_pydantic_model(r: HttpRequest) -> MyCamelCasePydanticModel: + return MyCamelCasePydanticModel(an_integer=1) @api(method="GET") @@ -55,6 +79,7 @@ def view_union(r: HttpRequest) -> int | str: path("int", view_int), path("bool", view_bool), path("pydantic-model", view_pydantic_model), + path("pydantic-camel-case-model", view_camel_case_pydantic_model), path("union", view_union), ] @@ -62,11 +87,12 @@ def view_union(r: HttpRequest) -> int | str: @pytest.mark.parametrize( "url,expected_response", [ - ("/json-response", b'{"a": 1}'), - ("/typed-dict", b'{"a":1}'), + ("/json-response", b'{"an_integer": 1}'), + ("/typed-dict", b'{"an_integer":1}'), ("/int", b"1"), ("/bool", b"false"), - ("/pydantic-model", b'{"a":1}'), + ("/pydantic-model", b'{"an_integer":1}'), + ("/pydantic-camel-case-model", b'{"anInteger":1}'), ], ) @pytest.mark.urls(__name__) @@ -105,6 +131,26 @@ def test_schema() -> None: }, } }, + "/pydantic-camel-case-model": { + "get": { + "operationId": "view_camel_case_pydantic_model", + "description": "", + "tags": ["test_response_encoding"], + "parameters": [], + "responses": { + 200: { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyCamelCasePydanticModel" # noqa: E501 + } + } + }, + } + }, + } + }, "/pydantic-model": { "get": { "operationId": "view_pydantic_model", @@ -189,15 +235,27 @@ def test_schema() -> None: }, "components": { "schemas": { + "MyCamelCasePydanticModel": { + "properties": { + "anInteger": {"title": "Aninteger", "type": "integer"} + }, + "required": ["anInteger"], + "title": "MyCamelCasePydanticModel", + "type": "object", + }, "MyPydanticModel": { - "properties": {"a": {"title": "A", "type": "integer"}}, - "required": ["a"], + "properties": { + "an_integer": {"title": "An Integer", "type": "integer"} + }, + "required": ["an_integer"], "title": "MyPydanticModel", "type": "object", }, "MyTypedDict": { - "properties": {"a": {"title": "A", "type": "integer"}}, - "required": ["a"], + "properties": { + "an_integer": {"title": "An Integer", "type": "integer"} + }, + "required": ["an_integer"], "title": "MyTypedDict", "type": "object", },