Skip to content

Commit

Permalink
cleanup responses (#460)
Browse files Browse the repository at this point in the history
- Cleanup responses
- Move transform speedup in mixin
  • Loading branch information
devkral authored Dec 19, 2024
1 parent 0640b39 commit 21f1c4a
Show file tree
Hide file tree
Showing 8 changed files with 158 additions and 107 deletions.
14 changes: 14 additions & 0 deletions docs/en/docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ hide:

# Release Notes

## 3.6.2

### Changed

- Cleanup Response.
- Move `transform` method to lilya but provide speedup in a mixin.
- Esmerald `Response` behaves like `make_response` in lilya with a plain `Response`.
- Special handle None (nothing is returned) in `Response`. It shouldn't map to `null` so not all handlers have to return a value.


### Fixed

- `bytes` won't be encoded as json when returned from a handler. This would unexpectly lead to a base64 encoding.

## 3.6.1

### Added
Expand Down
6 changes: 3 additions & 3 deletions docs/en/docs/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ This will allow you to use the [ORJSON](#orjson) and [UJSON](#ujson) as well as

### Response

Classic and generic `Response` that fits almost every single use case out there. It behaves like lilya `Response` by default.
It has a special mode for `media_type="application/json"` (used by handlers) in which it behaves like `make_response` (encodes content).
This special mode is required for encoding data like datetimes, ...
Classic and generic `Response` that fits almost every single use case out there. It behaves like lilya `make_response` with a plain `Response` by default.
It has a special mode for `media_type="application/json"` (used by handlers) in which it behaves like `make_response` with a plain `JSONResponse`.
The special mode has an exception for strings, so you can return in handlers a string object without having it jsonified.

```python
{!> ../../../docs_src/responses/response.py !}
Expand Down
101 changes: 43 additions & 58 deletions esmerald/responses/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from functools import partial
from typing import (
TYPE_CHECKING,
Expand All @@ -12,8 +14,10 @@
cast,
)

import orjson
from lilya import status
from lilya.responses import (
RESPONSE_TRANSFORM_KWARGS,
Error as Error,
FileResponse as FileResponse, # noqa
HTMLResponse as HTMLResponse, # noqa
Expand All @@ -24,13 +28,14 @@
Response as LilyaResponse, # noqa
StreamingResponse as StreamingResponse, # noqa
)
from orjson import OPT_OMIT_MICROSECONDS, OPT_SERIALIZE_NUMPY, dumps, loads
from typing_extensions import Annotated, Doc

from esmerald.encoders import LILYA_ENCODER_TYPES, Encoder, json_encoder
from esmerald.encoders import Encoder
from esmerald.enums import MediaType
from esmerald.exceptions import ImproperlyConfigured

from .mixins import ORJSONTransformMixin

PlainTextResponse = PlainText

if TYPE_CHECKING: # pragma: no cover
Expand All @@ -40,7 +45,7 @@
T = TypeVar("T")


class Response(LilyaResponse, Generic[T]):
class Response(ORJSONTransformMixin, LilyaResponse, Generic[T]):
"""
Default `Response` object from Esmerald where it can be as the
return annotation of a [handler](https://esmerald.dev/routing/handlers/).
Expand Down Expand Up @@ -98,7 +103,7 @@ def __init__(
),
] = None,
background: Annotated[
Optional[Union["BackgroundTask", "BackgroundTasks"]],
Optional[Union[BackgroundTask, BackgroundTasks]],
Doc(
"""
Any instance of a [BackgroundTask or BackgroundTasks](https://esmerald.dev/background-tasks/).
Expand All @@ -114,7 +119,7 @@ def __init__(
),
] = None,
cookies: Annotated[
Optional["ResponseCookies"],
Optional[ResponseCookies],
Doc(
"""
A sequence of `esmerald.datastructures.Cookie` objects.
Expand Down Expand Up @@ -174,62 +179,42 @@ def __init__(
)
self.cookies = cookies or []

@staticmethod
def transform(
value: Any, *, encoders: Union[Sequence[Encoder], None] = None
) -> Dict[str, Any]:
"""
The transformation of the data being returned.
Supports all the default encoders from Lilya and custom from Esmerald.
"""
return cast(
Dict[str, Any],
json_encoder(
value,
json_encode_fn=partial(dumps, option=OPT_SERIALIZE_NUMPY | OPT_OMIT_MICROSECONDS),
post_transform_fn=loads,
with_encoders=encoders,
),
)

def make_response(self, content: Any) -> Union[bytes, str]:
# here we need lilyas encoders not only esmerald encoders
# in case no extra encoders are defined use the default by passing None
encoders = (
(
(
*self.encoders,
*LILYA_ENCODER_TYPES.get(),
)
def make_response(self, content: Any) -> bytes | memoryview | str:
if (
content is None
or content is NoReturn
and (
self.status_code < 100
or self.status_code in {status.HTTP_204_NO_CONTENT, status.HTTP_304_NOT_MODIFIED}
)
if self.encoders
else None
):
return b""
transform_kwargs = RESPONSE_TRANSFORM_KWARGS.get()
if transform_kwargs:
transform_kwargs = transform_kwargs.copy()
else:
transform_kwargs = {}
transform_kwargs.setdefault(
"json_encode_fn",
partial(
orjson.dumps,
option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_OMIT_MICROSECONDS,
),
)
try:
if (
content is None
or content is NoReturn
and (
self.status_code < 100
or self.status_code
in {status.HTTP_204_NO_CONTENT, status.HTTP_304_NOT_MODIFIED}
)
):
return b""
# switch to a special mode for MediaType.JSON
# switch to a special mode for MediaType.JSON (default handlers)
if self.media_type == MediaType.JSON:
return cast(
bytes,
json_encoder(
content,
json_encode_fn=partial(
dumps, option=OPT_SERIALIZE_NUMPY | OPT_OMIT_MICROSECONDS
),
post_transform_fn=None,
with_encoders=encoders,
),
)
return super().make_response(content)
# "" should serialize to json
if content == "":
return b'""'
# keep it a serialized json object
transform_kwargs.setdefault("post_transform_fn", None)
else:
# strip '"'
transform_kwargs.setdefault("post_transform_fn", lambda x: x.strip(b'"'))
with self.with_transform_kwargs(transform_kwargs):
# if content is bytes it won't be transformed and
# if None or NoReturn, return b"", this differs from the dedicated JSONResponses.
return super().make_response(content)
except (AttributeError, ValueError, TypeError) as e: # pragma: no cover
raise ImproperlyConfigured("Unable to serialize response content") from e
41 changes: 17 additions & 24 deletions esmerald/responses/encoders.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,40 @@
from functools import partial
from typing import Any, cast
from typing import Any

import orjson
from lilya.responses import RESPONSE_TRANSFORM_KWARGS

from esmerald.encoders import LILYA_ENCODER_TYPES, json_encoder
from esmerald.responses.json import BaseJSONResponse
from .json import BaseJSONResponse
from .mixins import ORJSONTransformMixin

try:
import ujson
except ImportError: # pragma: no cover
ujson = None


class ORJSONResponse(BaseJSONResponse):
class ORJSONResponse(ORJSONTransformMixin, BaseJSONResponse):
"""
An alternative to `JSONResponse` and performance wise, faster.
In the same way the JSONResponse is used, so is the `ORJSONResponse`.
"""

def make_response(self, content: Any) -> bytes:
# here we need lilyas encoders not only esmerald encoders
encoders = (
(
(
*self.encoders,
*LILYA_ENCODER_TYPES.get(),
)
)
if self.encoders
else None
)
return cast(
bytes,
json_encoder(
content,
json_encode_fn=partial(
orjson.dumps, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_OMIT_MICROSECONDS
),
post_transform_fn=None,
with_encoders=encoders,
new_params = RESPONSE_TRANSFORM_KWARGS.get()
if new_params:
new_params = new_params.copy()
else:
new_params = {}
new_params.setdefault(
"json_encode_fn",
partial(
orjson.dumps,
option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_OMIT_MICROSECONDS,
),
)
with self.with_transform_kwargs(new_params):
return super().make_response(content)


class UJSONResponse(BaseJSONResponse):
Expand Down
21 changes: 1 addition & 20 deletions esmerald/responses/json.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,3 @@
from typing import Any, Dict, cast

from lilya.responses import JSONResponse

from esmerald.encoders import json_encoder


class BaseJSONResponse(JSONResponse):
"""
Making sure it parses all the values from pydantic into dictionary.
"""

@staticmethod
def transform(value: Any) -> Dict[str, Any]: # pragma: no cover
"""
Makes sure that every value is checked and if it's a pydantic model then parses into
a dict().
"""
return cast(Dict[str, Any], json_encoder(value))

from lilya.responses import JSONResponse as BaseJSONResponse

__all__ = ["BaseJSONResponse"]
30 changes: 30 additions & 0 deletions esmerald/responses/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from functools import partial
from typing import Any

import orjson
from lilya.responses import RESPONSE_TRANSFORM_KWARGS


class ORJSONTransformMixin:
@classmethod
def transform(cls, value: Any) -> Any:
"""
The transformation of the data being returned (simplify operation).
Supports all the default encoders from Lilya and custom from Esmerald.
"""
transform_kwargs = RESPONSE_TRANSFORM_KWARGS.get()
if transform_kwargs is None:
transform_kwargs = {}
else:
transform_kwargs.copy()
transform_kwargs.setdefault(
"json_encode_fn",
partial(
orjson.dumps, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_OMIT_MICROSECONDS
),
)
transform_kwargs.setdefault("post_transform_fn", orjson.loads)

with cls.with_transform_kwargs(transform_kwargs):
return super().transform(value) # type: ignore
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ dependencies = [
"email-validator >=2.2.0,<3.0.0",
"itsdangerous>=2.1.2,<3.0.0",
"jinja2>=3.1.2,<4.0.0",
"lilya>=0.11.6",
"lilya>=0.11.8",
"loguru>=0.7.0,<0.8.0",
"pydantic>=2.10,<3.0.0",
"pydantic-settings>=2.0.0,<3.0.0",
Expand Down Expand Up @@ -103,7 +103,7 @@ testing = [
"ujson>=5.7.0,<6",
"anyio[trio]>=3.6.2,<5.0.0",
"brotli>=1.0.9,<2.0.0",
"edgy[postgres]>=0.23.3",
"edgy[postgres]>=0.24.1",
"databasez>=0.9.7",
"flask>=1.1.2,<4.0.0",
"freezegun>=1.2.2,<2.0.0",
Expand Down
48 changes: 48 additions & 0 deletions tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ def route_five() -> Response:
return Response("Ok")


@get("/six")
def route_six() -> bytes:
return b"Ok"


@get("/seven")
def route_seven() -> str:
return ""


@get("/eight")
def route_eight() -> str:
return "hello"


@get("/nine")
def route_nine() -> None:
pass


def test_ujson_response(test_client_factory):
with create_client(routes=[Gateway(handler=route_one)]) as client:
response = client.get("/one")
Expand Down Expand Up @@ -72,6 +92,34 @@ def test_default_decorator(test_client_factory):
assert response.status_code == status.HTTP_207_MULTI_STATUS


def test_implicit_bytes_returnal(test_client_factory):
with create_client(routes=[route_six]) as client:
response = client.get("/six")

assert response.text == "Ok"


def test_implicit_empty_str_returnal(test_client_factory):
with create_client(routes=[route_seven]) as client:
response = client.get("/seven")

assert response.text == '""'


def test_str_returnal(test_client_factory):
with create_client(routes=[route_eight]) as client:
response = client.get("/eight")

assert response.text == '"hello"'


def test_implicit_none_returnal(test_client_factory):
with create_client(routes=[route_nine]) as client:
response = client.get("/nine")

assert response.text == ""


@get(status_code=status.HTTP_207_MULTI_STATUS)
def multiple(name: Union[str, None]) -> Response:
if name is None:
Expand Down

0 comments on commit 21f1c4a

Please sign in to comment.