From c5a77d59cd7c2033e4e8cce8162c8309743f641d Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Wed, 12 Oct 2022 17:22:51 +0200 Subject: [PATCH 01/33] Move response stuff into folder --- sanic/response/__init__.py | 14 + sanic/{response.py => response/functions.py} | 246 +---------------- sanic/response/models.py | 274 +++++++++++++++++++ 3 files changed, 289 insertions(+), 245 deletions(-) create mode 100644 sanic/response/__init__.py rename sanic/{response.py => response/functions.py} (59%) create mode 100644 sanic/response/models.py diff --git a/sanic/response/__init__.py b/sanic/response/__init__.py new file mode 100644 index 0000000000..1c957e0752 --- /dev/null +++ b/sanic/response/__init__.py @@ -0,0 +1,14 @@ +from .functions import ( + empty, + json, + text, + raw, + html, + validate_file, + file, + redirect, + file_stream +) + + +from .models import BaseHTTPResponse, HTTPResponse, ResponseStream diff --git a/sanic/response.py b/sanic/response/functions.py similarity index 59% rename from sanic/response.py rename to sanic/response/functions.py index 8e6c2632c4..616a22a160 100644 --- a/sanic/response.py +++ b/sanic/response/functions.py @@ -8,41 +8,26 @@ from pathlib import PurePath from time import time from typing import ( - TYPE_CHECKING, Any, AnyStr, Callable, - Coroutine, Dict, - Iterator, Optional, - Tuple, - TypeVar, Union, ) from urllib.parse import quote_plus from sanic.compat import Header, open_async, stat_async from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE -from sanic.cookies import CookieJar -from sanic.exceptions import SanicException, ServerError from sanic.helpers import ( Default, _default, - has_message_body, - remove_entity_headers, ) -from sanic.http import Http from sanic.log import logger from sanic.models.protocol_types import HTMLProtocol, Range -if TYPE_CHECKING: - from sanic.asgi import ASGIApp - from sanic.http.http3 import HTTPReceiver - from sanic.request import Request -else: - Request = TypeVar("Request") +from .models import BaseHTTPResponse, HTTPResponse, ResponseStream try: @@ -55,161 +40,6 @@ json_dumps = partial(dumps, separators=(",", ":")) -class BaseHTTPResponse: - """ - The base class for all HTTP Responses - """ - - __slots__ = ( - "asgi", - "body", - "content_type", - "stream", - "status", - "headers", - "_cookies", - ) - - _dumps = json_dumps - - def __init__(self): - self.asgi: bool = False - self.body: Optional[bytes] = None - self.content_type: Optional[str] = None - self.stream: Optional[Union[Http, ASGIApp, HTTPReceiver]] = None - self.status: int = None - self.headers = Header({}) - self._cookies: Optional[CookieJar] = None - - def __repr__(self): - class_name = self.__class__.__name__ - return f"<{class_name}: {self.status} {self.content_type}>" - - def _encode_body(self, data: Optional[AnyStr]): - if data is None: - return b"" - return ( - data.encode() if hasattr(data, "encode") else data # type: ignore - ) - - @property - def cookies(self) -> CookieJar: - """ - The response cookies. Cookies should be set and written as follows: - - .. code-block:: python - - response.cookies["test"] = "It worked!" - response.cookies["test"]["domain"] = ".yummy-yummy-cookie.com" - response.cookies["test"]["httponly"] = True - - `See user guide re: cookies - `__ - - :return: the cookie jar - :rtype: CookieJar - """ - if self._cookies is None: - self._cookies = CookieJar(self.headers) - return self._cookies - - @property - def processed_headers(self) -> Iterator[Tuple[bytes, bytes]]: - """ - Obtain a list of header tuples encoded in bytes for sending. - - Add and remove headers based on status and content_type. - - :return: response headers - :rtype: Tuple[Tuple[bytes, bytes], ...] - """ - # TODO: Make a blacklist set of header names and then filter with that - if self.status in (304, 412): # Not Modified, Precondition Failed - self.headers = remove_entity_headers(self.headers) - if has_message_body(self.status): - self.headers.setdefault("content-type", self.content_type) - # Encode headers into bytes - return ( - (name.encode("ascii"), f"{value}".encode(errors="surrogateescape")) - for name, value in self.headers.items() - ) - - async def send( - self, - data: Optional[AnyStr] = None, - end_stream: Optional[bool] = None, - ) -> None: - """ - Send any pending response headers and the given data as body. - - :param data: str or bytes to be written - :param end_stream: whether to close the stream after this block - """ - if data is None and end_stream is None: - end_stream = True - if self.stream is None: - raise SanicException( - "No stream is connected to the response object instance." - ) - if self.stream.send is None: - if end_stream and not data: - return - raise ServerError( - "Response stream was ended, no more response data is " - "allowed to be sent." - ) - data = ( - data.encode() # type: ignore - if hasattr(data, "encode") - else data or b"" - ) - await self.stream.send( - data, # type: ignore - end_stream=end_stream or False, - ) - - -class HTTPResponse(BaseHTTPResponse): - """ - HTTP response to be sent back to the client. - - :param body: the body content to be returned - :type body: Optional[bytes] - :param status: HTTP response number. **Default=200** - :type status: int - :param headers: headers to be returned - :type headers: Optional; - :param content_type: content type to be returned (as a header) - :type content_type: Optional[str] - """ - - __slots__ = () - - def __init__( - self, - body: Optional[AnyStr] = None, - status: int = 200, - headers: Optional[Union[Header, Dict[str, str]]] = None, - content_type: Optional[str] = None, - ): - super().__init__() - - self.content_type: Optional[str] = content_type - self.body = self._encode_body(body) - self.status = status - self.headers = Header(headers or {}) - self._cookies = None - - async def eof(self): - await self.send("", True) - - async def __aenter__(self): - return self.send - - async def __aexit__(self, *_): - await self.eof() - - def empty( status: int = 204, headers: Optional[Dict[str, str]] = None ) -> HTTPResponse: @@ -465,80 +295,6 @@ def redirect( ) -class ResponseStream: - """ - ResponseStream is a compat layer to bridge the gap after the deprecation - of StreamingHTTPResponse. It will be removed when: - - file_stream is moved to new style streaming - - file and file_stream are combined into a single API - """ - - __slots__ = ( - "_cookies", - "content_type", - "headers", - "request", - "response", - "status", - "streaming_fn", - ) - - def __init__( - self, - streaming_fn: Callable[ - [Union[BaseHTTPResponse, ResponseStream]], - Coroutine[Any, Any, None], - ], - status: int = 200, - headers: Optional[Union[Header, Dict[str, str]]] = None, - content_type: Optional[str] = None, - ): - self.streaming_fn = streaming_fn - self.status = status - self.headers = headers or Header() - self.content_type = content_type - self.request: Optional[Request] = None - self._cookies: Optional[CookieJar] = None - - async def write(self, message: str): - await self.response.send(message) - - async def stream(self) -> HTTPResponse: - if not self.request: - raise ServerError("Attempted response to unknown request") - self.response = await self.request.respond( - headers=self.headers, - status=self.status, - content_type=self.content_type, - ) - await self.streaming_fn(self) - return self.response - - async def eof(self) -> None: - await self.response.eof() - - @property - def cookies(self) -> CookieJar: - if self._cookies is None: - self._cookies = CookieJar(self.headers) - return self._cookies - - @property - def processed_headers(self): - return self.response.processed_headers - - @property - def body(self): - return self.response.body - - def __call__(self, request: Request) -> ResponseStream: - self.request = request - return self - - def __await__(self): - return self.stream().__await__() - - async def file_stream( location: Union[str, PurePath], status: int = 200, diff --git a/sanic/response/models.py b/sanic/response/models.py new file mode 100644 index 0000000000..afc9b84d93 --- /dev/null +++ b/sanic/response/models.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +from functools import partial +from typing import ( + TYPE_CHECKING, + Any, + AnyStr, + Callable, + Coroutine, + Dict, + Iterator, + Optional, + Tuple, + TypeVar, + Union, +) + +from django.shortcuts import HttpResponse + +from sanic.compat import Header +from sanic.cookies import CookieJar +from sanic.exceptions import SanicException, ServerError +from sanic.helpers import ( + has_message_body, + remove_entity_headers, +) +from sanic.http import Http + + +if TYPE_CHECKING: + from sanic.asgi import ASGIApp + from sanic.http.http3 import HTTPReceiver + from sanic.request import Request +else: + Request = TypeVar("Request") + + +try: + from ujson import dumps as json_dumps +except ImportError: + # This is done in order to ensure that the JSON response is + # kept consistent across both ujson and inbuilt json usage. + from json import dumps + + json_dumps = partial(dumps, separators=(",", ":")) + + +class BaseHTTPResponse: + """ + The base class for all HTTP Responses + """ + + __slots__ = ( + "asgi", + "body", + "content_type", + "stream", + "status", + "headers", + "_cookies", + ) + + _dumps = json_dumps + + def __init__(self): + self.asgi: bool = False + self.body: Optional[bytes] = None + self.content_type: Optional[str] = None + self.stream: Optional[Union[Http, ASGIApp, HTTPReceiver]] = None + self.status: int = None + self.headers = Header({}) + self._cookies: Optional[CookieJar] = None + + def __repr__(self): + class_name = self.__class__.__name__ + return f"<{class_name}: {self.status} {self.content_type}>" + + def _encode_body(self, data: Optional[AnyStr]): + if data is None: + return b"" + return ( + data.encode() if hasattr(data, "encode") else data # type: ignore + ) + + @property + def cookies(self) -> CookieJar: + """ + The response cookies. Cookies should be set and written as follows: + + .. code-block:: python + + response.cookies["test"] = "It worked!" + response.cookies["test"]["domain"] = ".yummy-yummy-cookie.com" + response.cookies["test"]["httponly"] = True + + `See user guide re: cookies + `__ + + :return: the cookie jar + :rtype: CookieJar + """ + if self._cookies is None: + self._cookies = CookieJar(self.headers) + return self._cookies + + @property + def processed_headers(self) -> Iterator[Tuple[bytes, bytes]]: + """ + Obtain a list of header tuples encoded in bytes for sending. + + Add and remove headers based on status and content_type. + + :return: response headers + :rtype: Tuple[Tuple[bytes, bytes], ...] + """ + # TODO: Make a blacklist set of header names and then filter with that + if self.status in (304, 412): # Not Modified, Precondition Failed + self.headers = remove_entity_headers(self.headers) + if has_message_body(self.status): + self.headers.setdefault("content-type", self.content_type) + # Encode headers into bytes + return ( + (name.encode("ascii"), f"{value}".encode(errors="surrogateescape")) + for name, value in self.headers.items() + ) + + async def send( + self, + data: Optional[AnyStr] = None, + end_stream: Optional[bool] = None, + ) -> None: + """ + Send any pending response headers and the given data as body. + + :param data: str or bytes to be written + :param end_stream: whether to close the stream after this block + """ + if data is None and end_stream is None: + end_stream = True + if self.stream is None: + raise SanicException( + "No stream is connected to the response object instance." + ) + if self.stream.send is None: + if end_stream and not data: + return + raise ServerError( + "Response stream was ended, no more response data is " + "allowed to be sent." + ) + data = ( + data.encode() # type: ignore + if hasattr(data, "encode") + else data or b"" + ) + await self.stream.send( + data, # type: ignore + end_stream=end_stream or False, + ) + + +class HTTPResponse(BaseHTTPResponse): + """ + HTTP response to be sent back to the client. + + :param body: the body content to be returned + :type body: Optional[bytes] + :param status: HTTP response number. **Default=200** + :type status: int + :param headers: headers to be returned + :type headers: Optional; + :param content_type: content type to be returned (as a header) + :type content_type: Optional[str] + """ + + __slots__ = () + + def __init__( + self, + body: Optional[AnyStr] = None, + status: int = 200, + headers: Optional[Union[Header, Dict[str, str]]] = None, + content_type: Optional[str] = None, + ): + super().__init__() + + self.content_type: Optional[str] = content_type + self.body = self._encode_body(body) + self.status = status + self.headers = Header(headers or {}) + self._cookies = None + + async def eof(self): + await self.send("", True) + + async def __aenter__(self): + return self.send + + async def __aexit__(self, *_): + await self.eof() + + +class ResponseStream: + """ + ResponseStream is a compat layer to bridge the gap after the deprecation + of StreamingHTTPResponse. It will be removed when: + - file_stream is moved to new style streaming + - file and file_stream are combined into a single API + """ + + __slots__ = ( + "_cookies", + "content_type", + "headers", + "request", + "response", + "status", + "streaming_fn", + ) + + def __init__( + self, + streaming_fn: Callable[ + [Union[BaseHTTPResponse, ResponseStream]], + Coroutine[Any, Any, None], + ], + status: int = 200, + headers: Optional[Union[Header, Dict[str, str]]] = None, + content_type: Optional[str] = None, + ): + self.streaming_fn = streaming_fn + self.status = status + self.headers = headers or Header() + self.content_type = content_type + self.request: Optional[Request] = None + self._cookies: Optional[CookieJar] = None + + async def write(self, message: str): + await self.response.send(message) + + async def stream(self) -> HTTPResponse: + if not self.request: + raise ServerError("Attempted response to unknown request") + self.response = await self.request.respond( + headers=self.headers, + status=self.status, + content_type=self.content_type, + ) + await self.streaming_fn(self) + return self.response + + async def eof(self) -> None: + await self.response.eof() + + @property + def cookies(self) -> CookieJar: + if self._cookies is None: + self._cookies = CookieJar(self.headers) + return self._cookies + + @property + def processed_headers(self): + return self.response.processed_headers + + @property + def body(self): + return self.response.body + + def __call__(self, request: Request) -> ResponseStream: + self.request = request + return self + + def __await__(self): + return self.stream().__await__() From 1878dcc6f10e6c0d357d83e8092545cb18d92c4f Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Wed, 12 Oct 2022 17:40:08 +0200 Subject: [PATCH 02/33] Add JSON response --- sanic/response/functions.py | 12 +++++----- sanic/response/models.py | 46 ++++++++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/sanic/response/functions.py b/sanic/response/functions.py index 616a22a160..4792ded551 100644 --- a/sanic/response/functions.py +++ b/sanic/response/functions.py @@ -27,7 +27,7 @@ from sanic.models.protocol_types import HTMLProtocol, Range -from .models import BaseHTTPResponse, HTTPResponse, ResponseStream +from .models import BaseHTTPResponse, HTTPResponse, JsonResponse, ResponseStream try: @@ -68,13 +68,13 @@ def json( :param headers: Custom Headers. :param kwargs: Remaining arguments that are passed to the json encoder. """ - if not dumps: - dumps = BaseHTTPResponse._dumps - return HTTPResponse( - dumps(body, **kwargs), - headers=headers, + + return JsonResponse( + body, status=status, + headers=headers, content_type=content_type, + dumps=dumps ) diff --git a/sanic/response/models.py b/sanic/response/models.py index afc9b84d93..082a46de2a 100644 --- a/sanic/response/models.py +++ b/sanic/response/models.py @@ -15,8 +15,6 @@ Union, ) -from django.shortcuts import HttpResponse - from sanic.compat import Header from sanic.cookies import CookieJar from sanic.exceptions import SanicException, ServerError @@ -177,7 +175,7 @@ class HTTPResponse(BaseHTTPResponse): def __init__( self, - body: Optional[AnyStr] = None, + body: Optional[Any] = None, status: int = 200, headers: Optional[Union[Header, Dict[str, str]]] = None, content_type: Optional[str] = None, @@ -200,6 +198,48 @@ async def __aexit__(self, *_): await self.eof() +class JsonResponse(HTTPResponse): + __slots__ = ( + "_dumps", + "_dumps_kwargs", + "_raw_body", + ) + + def __init__( + self, + body: Optional[Any] = None, + status: int = 200, + headers: Optional[Union[Header, Dict[str, str]]] = None, + content_type: Optional[str] = None, + dumps: Optional[Callable[..., str]] = None, + **kwargs: Any, + ): + self._dumps = dumps or self.__class__._dumps + self._dumps_kwargs = kwargs + self._raw_body = body + super().__init__( + self._dumps(body, **kwargs), + headers=headers, + status=status, + content_type=content_type, + ) + + @property + def raw_body(self) -> Optional[Any]: + return self._raw_body + + @raw_body.setter + def raw_body(self, value: Any): + self.set_json(value) + + def set_json(self, new_json: Any, dumps: Optional[Callable[..., str]] = None, **kwargs: Any): + dumps_ = dumps or self._dumps + kwargs_ = kwargs if kwargs else self._dumps_kwargs + + self._raw_body = new_json + self.body = self._encode_body(dumps_(new_json, **kwargs_)) + + class ResponseStream: """ ResponseStream is a compat layer to bridge the gap after the deprecation From 600ea267a7ddd40051f3b5e9e0429b9b84d21960 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Wed, 12 Oct 2022 18:36:13 +0200 Subject: [PATCH 03/33] Fixes --- sanic/response/__init__.py | 19 ++++++++++++++++++- sanic/response/functions.py | 5 +++-- sanic/response/models.py | 10 ++++++---- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/sanic/response/__init__.py b/sanic/response/__init__.py index 1c957e0752..c639fd6402 100644 --- a/sanic/response/__init__.py +++ b/sanic/response/__init__.py @@ -11,4 +11,21 @@ ) -from .models import BaseHTTPResponse, HTTPResponse, ResponseStream +from .models import BaseHTTPResponse, HTTPResponse, ResponseStream, json_dumps + + +__all__ = ( + "BaseHTTPResponse", + "HTTPResponse", + "ResponseStream", + "empty", + "json", + "text", + "raw", + "html", + "validate_file", + "file", + "redirect", + "file_stream", + "json_dumps", +) diff --git a/sanic/response/functions.py b/sanic/response/functions.py index 4792ded551..2a7180622f 100644 --- a/sanic/response/functions.py +++ b/sanic/response/functions.py @@ -27,7 +27,7 @@ from sanic.models.protocol_types import HTMLProtocol, Range -from .models import BaseHTTPResponse, HTTPResponse, JsonResponse, ResponseStream +from .models import HTTPResponse, JsonResponse, ResponseStream try: @@ -74,7 +74,8 @@ def json( status=status, headers=headers, content_type=content_type, - dumps=dumps + dumps=dumps, + **kwargs, ) diff --git a/sanic/response/models.py b/sanic/response/models.py index 082a46de2a..57a8183e22 100644 --- a/sanic/response/models.py +++ b/sanic/response/models.py @@ -200,7 +200,7 @@ async def __aexit__(self, *_): class JsonResponse(HTTPResponse): __slots__ = ( - "_dumps", + "_dumps_method", "_dumps_kwargs", "_raw_body", ) @@ -214,11 +214,13 @@ def __init__( dumps: Optional[Callable[..., str]] = None, **kwargs: Any, ): - self._dumps = dumps or self.__class__._dumps + if not dumps: + dumps = HTTPResponse._dumps + self._dumps_method = dumps self._dumps_kwargs = kwargs self._raw_body = body super().__init__( - self._dumps(body, **kwargs), + self._dumps_method(body, **kwargs), headers=headers, status=status, content_type=content_type, @@ -233,7 +235,7 @@ def raw_body(self, value: Any): self.set_json(value) def set_json(self, new_json: Any, dumps: Optional[Callable[..., str]] = None, **kwargs: Any): - dumps_ = dumps or self._dumps + dumps_ = dumps or self._dumps_method kwargs_ = kwargs if kwargs else self._dumps_kwargs self._raw_body = new_json From fc008c08772125506329b9b0bc4d8dbb1d4bbdf7 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Wed, 12 Oct 2022 19:33:51 +0200 Subject: [PATCH 04/33] Add tests --- tests/test_response.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_response.py b/tests/test_response.py index 9254fca3fd..4f07c85ab8 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -30,6 +30,7 @@ raw, text, ) +from sanic.response.models import JSONResponse JSON_DATA = {"ok": True} @@ -185,11 +186,22 @@ async def delete_handler(request: Request): def test_json_response(json_app): from sanic.response import json_dumps + NEW_RESP = {"foo": "bar"} + + @json_app.middleware("response") + async def check_types(request: Request, response: JSONResponse): + assert isinstance(response, JSONResponse) + assert response.raw_body == JSON_DATA + assert response.body == json_dumps(JSON_DATA).encode("utf-8") + + response.set_json(NEW_RESP) + assert response.raw_body == NEW_RESP + assert response.body == json_dumps(NEW_RESP).encode("utf-8") request, response = json_app.test_client.get("/") assert response.status == 200 - assert response.text == json_dumps(JSON_DATA) - assert response.json == JSON_DATA + assert response.text == json_dumps(NEW_RESP) + assert response.json == NEW_RESP def test_no_content(json_app): From b51a8fce84361ff9a9c3f24ead6a88ce6d412f29 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Wed, 12 Oct 2022 19:35:06 +0200 Subject: [PATCH 05/33] Add JSONResponse to init --- sanic/response/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sanic/response/__init__.py b/sanic/response/__init__.py index c639fd6402..7487411284 100644 --- a/sanic/response/__init__.py +++ b/sanic/response/__init__.py @@ -11,12 +11,13 @@ ) -from .models import BaseHTTPResponse, HTTPResponse, ResponseStream, json_dumps +from .models import BaseHTTPResponse, HTTPResponse, JSONResponse, ResponseStream, json_dumps __all__ = ( "BaseHTTPResponse", "HTTPResponse", + "JSONResponse", "ResponseStream", "empty", "json", From 8ac9aa72df59c1b30b4fcd96160afe632788a82c Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Wed, 12 Oct 2022 19:45:14 +0200 Subject: [PATCH 06/33] Fix style --- sanic/response/__init__.py | 20 ++++++++++++-------- sanic/response/functions.py | 19 ++++--------------- sanic/response/models.py | 14 ++++++++------ 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/sanic/response/__init__.py b/sanic/response/__init__.py index 7487411284..a5a4bbf515 100644 --- a/sanic/response/__init__.py +++ b/sanic/response/__init__.py @@ -1,17 +1,21 @@ from .functions import ( empty, + file, + file_stream, + html, json, - text, raw, - html, - validate_file, - file, redirect, - file_stream + text, + validate_file, +) +from .models import ( + BaseHTTPResponse, + HTTPResponse, + JSONResponse, + ResponseStream, + json_dumps, ) - - -from .models import BaseHTTPResponse, HTTPResponse, JSONResponse, ResponseStream, json_dumps __all__ = ( diff --git a/sanic/response/functions.py b/sanic/response/functions.py index 2a7180622f..1ccd976ec1 100644 --- a/sanic/response/functions.py +++ b/sanic/response/functions.py @@ -7,27 +7,16 @@ from os import path from pathlib import PurePath from time import time -from typing import ( - Any, - AnyStr, - Callable, - Dict, - Optional, - Union, -) +from typing import Any, AnyStr, Callable, Dict, Optional, Union from urllib.parse import quote_plus from sanic.compat import Header, open_async, stat_async from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE -from sanic.helpers import ( - Default, - _default, -) +from sanic.helpers import Default, _default from sanic.log import logger from sanic.models.protocol_types import HTMLProtocol, Range - -from .models import HTTPResponse, JsonResponse, ResponseStream +from .models import HTTPResponse, JSONResponse, ResponseStream try: @@ -69,7 +58,7 @@ def json( :param kwargs: Remaining arguments that are passed to the json encoder. """ - return JsonResponse( + return JSONResponse( body, status=status, headers=headers, diff --git a/sanic/response/models.py b/sanic/response/models.py index 57a8183e22..2f65b1a567 100644 --- a/sanic/response/models.py +++ b/sanic/response/models.py @@ -18,10 +18,7 @@ from sanic.compat import Header from sanic.cookies import CookieJar from sanic.exceptions import SanicException, ServerError -from sanic.helpers import ( - has_message_body, - remove_entity_headers, -) +from sanic.helpers import has_message_body, remove_entity_headers from sanic.http import Http @@ -198,7 +195,7 @@ async def __aexit__(self, *_): await self.eof() -class JsonResponse(HTTPResponse): +class JSONResponse(HTTPResponse): __slots__ = ( "_dumps_method", "_dumps_kwargs", @@ -234,7 +231,12 @@ def raw_body(self) -> Optional[Any]: def raw_body(self, value: Any): self.set_json(value) - def set_json(self, new_json: Any, dumps: Optional[Callable[..., str]] = None, **kwargs: Any): + def set_json( + self, + new_json: Any, + dumps: Optional[Callable[..., str]] = None, + **kwargs: Any, + ): dumps_ = dumps or self._dumps_method kwargs_ = kwargs if kwargs else self._dumps_kwargs From d45a52c0bbfabf2cd3a3dc01c130cd1eb67ffefe Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Wed, 12 Oct 2022 21:39:47 +0200 Subject: [PATCH 07/33] Rename files --- sanic/response/__init__.py | 4 ++-- sanic/response/{functions.py => convenience.py} | 13 +------------ sanic/response/{models.py => types.py} | 0 3 files changed, 3 insertions(+), 14 deletions(-) rename sanic/response/{functions.py => convenience.py} (96%) rename sanic/response/{models.py => types.py} (100%) diff --git a/sanic/response/__init__.py b/sanic/response/__init__.py index a5a4bbf515..99b93075d7 100644 --- a/sanic/response/__init__.py +++ b/sanic/response/__init__.py @@ -1,4 +1,4 @@ -from .functions import ( +from .convenience import ( empty, file, file_stream, @@ -9,7 +9,7 @@ text, validate_file, ) -from .models import ( +from .types import ( BaseHTTPResponse, HTTPResponse, JSONResponse, diff --git a/sanic/response/functions.py b/sanic/response/convenience.py similarity index 96% rename from sanic/response/functions.py rename to sanic/response/convenience.py index 1ccd976ec1..e9c3c3dacc 100644 --- a/sanic/response/functions.py +++ b/sanic/response/convenience.py @@ -2,7 +2,6 @@ from datetime import datetime, timezone from email.utils import formatdate, parsedate_to_datetime -from functools import partial from mimetypes import guess_type from os import path from pathlib import PurePath @@ -16,17 +15,7 @@ from sanic.log import logger from sanic.models.protocol_types import HTMLProtocol, Range -from .models import HTTPResponse, JSONResponse, ResponseStream - - -try: - from ujson import dumps as json_dumps -except ImportError: - # This is done in order to ensure that the JSON response is - # kept consistent across both ujson and inbuilt json usage. - from json import dumps - - json_dumps = partial(dumps, separators=(",", ":")) +from .types import HTTPResponse, JSONResponse, ResponseStream def empty( diff --git a/sanic/response/models.py b/sanic/response/types.py similarity index 100% rename from sanic/response/models.py rename to sanic/response/types.py From ae9613929a53989184658de355a19ef0c761e376 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Wed, 12 Oct 2022 23:07:02 +0200 Subject: [PATCH 08/33] Fix test --- tests/test_response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_response.py b/tests/test_response.py index 4f07c85ab8..f82b8e91d5 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -30,7 +30,7 @@ raw, text, ) -from sanic.response.models import JSONResponse +from sanic.response.types import JSONResponse JSON_DATA = {"ok": True} From 8bbeadcdaf5e7929829ac63416fa37ca3315dc1d Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Wed, 12 Oct 2022 23:08:34 +0200 Subject: [PATCH 09/33] Update URL --- sanic/response/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index 2f65b1a567..9574dabb70 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -89,7 +89,7 @@ def cookies(self) -> CookieJar: response.cookies["test"]["httponly"] = True `See user guide re: cookies - `__ + ` :return: the cookie jar :rtype: CookieJar From b4fc1132efa420d71fa7c1298fca5fd11c3d78c8 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sun, 16 Oct 2022 20:10:07 +0200 Subject: [PATCH 10/33] Add docstring --- sanic/response/types.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/sanic/response/types.py b/sanic/response/types.py index 9574dabb70..1c9e2b7ffb 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -196,6 +196,23 @@ async def __aexit__(self, *_): class JSONResponse(HTTPResponse): + """ + HTTP response to be sent back to the client, when the response + is of json type. Offers several utilities to manipulate common + json data types. + + :param body: the body content to be returned + :type body: Optional[Any] + :param status: HTTP response number. **Default=200** + :type status: int + :param headers: headers to be returned + :type headers: Optional + :param content_type: content type to be returned (as a header) + :type content_type: Optional[str] + :param dumps: json.dumps function to use + :type dumps: Optional[Callable] + """ + __slots__ = ( "_dumps_method", "_dumps_kwargs", From e44e3afa8f5eaae54baf1ea62fcd10d223100dd2 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sun, 16 Oct 2022 20:15:34 +0200 Subject: [PATCH 11/33] Make kwargs public --- sanic/response/types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index 1c9e2b7ffb..ceedb1d0a2 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -215,8 +215,8 @@ class JSONResponse(HTTPResponse): __slots__ = ( "_dumps_method", - "_dumps_kwargs", "_raw_body", + "dumps_kwargs", ) def __init__( @@ -231,8 +231,8 @@ def __init__( if not dumps: dumps = HTTPResponse._dumps self._dumps_method = dumps - self._dumps_kwargs = kwargs self._raw_body = body + self.dumps_kwargs = kwargs super().__init__( self._dumps_method(body, **kwargs), headers=headers, @@ -255,7 +255,7 @@ def set_json( **kwargs: Any, ): dumps_ = dumps or self._dumps_method - kwargs_ = kwargs if kwargs else self._dumps_kwargs + kwargs_ = kwargs if kwargs else self.dumps_kwargs self._raw_body = new_json self.body = self._encode_body(dumps_(new_json, **kwargs_)) From f505037f33c3b1c61722a518d5625d12ad9106d0 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sun, 16 Oct 2022 20:34:37 +0200 Subject: [PATCH 12/33] Add common useful methods --- sanic/response/types.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/sanic/response/types.py b/sanic/response/types.py index ceedb1d0a2..3860467908 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -248,6 +248,20 @@ def raw_body(self) -> Optional[Any]: def raw_body(self, value: Any): self.set_json(value) + def append(self, value: Any): + """Append a value to the json response list.""" + + if not isinstance(self._raw_body, list): + raise ValueError("Cannot append to non-list response") + self._raw_body.append(value) + + def extend(self, value: Any): + """Extend the json response list.""" + + if not isinstance(self._raw_body, list): + raise ValueError("Cannot extend non-list response") + self._raw_body.extend(value) + def set_json( self, new_json: Any, @@ -260,6 +274,13 @@ def set_json( self._raw_body = new_json self.body = self._encode_body(dumps_(new_json, **kwargs_)) + def update(self, *args, **kwargs) -> None: + """Update the json response dict.""" + + if not isinstance(self._raw_body, dict): + raise TypeError("Cannot update a non-dict response") + self._raw_body.update(*args, **kwargs) + class ResponseStream: """ From c41b5abed495ee696ace85cf58d1069d1e339aea Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Fri, 21 Oct 2022 22:03:05 +0200 Subject: [PATCH 13/33] Different approach --- sanic/response/types.py | 74 +++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index 3860467908..07e552688b 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -214,9 +214,12 @@ class JSONResponse(HTTPResponse): """ __slots__ = ( - "_dumps_method", + "_body", + "_body_set", + "_initialized", "_raw_body", - "dumps_kwargs", + "_use_dumps", + "_use_dumps_kwargs", ) def __init__( @@ -228,58 +231,57 @@ def __init__( dumps: Optional[Callable[..., str]] = None, **kwargs: Any, ): + self._initialized = False if not dumps: dumps = HTTPResponse._dumps - self._dumps_method = dumps + + self._use_dumps = dumps + self._use_dumps_kwargs = kwargs + self._raw_body = body - self.dumps_kwargs = kwargs + super().__init__( - self._dumps_method(body, **kwargs), + None, headers=headers, status=status, content_type=content_type, ) + self._initialized = True + + def _check_body_not_manually_set(self): + if self._body_set: + raise SanicException( + "Cannot use raw_body after body has been manually set." + ) @property def raw_body(self) -> Optional[Any]: + self._check_body_not_manually_set() return self._raw_body @raw_body.setter def raw_body(self, value: Any): - self.set_json(value) - - def append(self, value: Any): - """Append a value to the json response list.""" - - if not isinstance(self._raw_body, list): - raise ValueError("Cannot append to non-list response") - self._raw_body.append(value) - - def extend(self, value: Any): - """Extend the json response list.""" + self._check_body_not_manually_set() + self._raw_body = value - if not isinstance(self._raw_body, list): - raise ValueError("Cannot extend non-list response") - self._raw_body.extend(value) - - def set_json( - self, - new_json: Any, - dumps: Optional[Callable[..., str]] = None, - **kwargs: Any, - ): - dumps_ = dumps or self._dumps_method - kwargs_ = kwargs if kwargs else self.dumps_kwargs - - self._raw_body = new_json - self.body = self._encode_body(dumps_(new_json, **kwargs_)) + @property + def body(self) -> bytes: + if self._body_set: + return self._body + return self._encode_body( + self._use_dumps(self._raw_body, **self._use_dumps_kwargs) + ) - def update(self, *args, **kwargs) -> None: - """Update the json response dict.""" + @body.setter + def body(self, value: bytes): + # When calling super().__init__, the body will be set, + # but we don't need to save it, since we will be dumping + # it on-the-fly from raw_body. + if not self._initialized: + return - if not isinstance(self._raw_body, dict): - raise TypeError("Cannot update a non-dict response") - self._raw_body.update(*args, **kwargs) + self._body = value + self._body_set = True class ResponseStream: From ce12d6f46a14089f1e30887d4058b4c3ef75b50c Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sun, 23 Oct 2022 17:33:33 +0200 Subject: [PATCH 14/33] Undo previous changes to existing test --- tests/test_response.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/tests/test_response.py b/tests/test_response.py index f82b8e91d5..e50bcb19d7 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -186,22 +186,11 @@ async def delete_handler(request: Request): def test_json_response(json_app): from sanic.response import json_dumps - NEW_RESP = {"foo": "bar"} - - @json_app.middleware("response") - async def check_types(request: Request, response: JSONResponse): - assert isinstance(response, JSONResponse) - assert response.raw_body == JSON_DATA - assert response.body == json_dumps(JSON_DATA).encode("utf-8") - - response.set_json(NEW_RESP) - assert response.raw_body == NEW_RESP - assert response.body == json_dumps(NEW_RESP).encode("utf-8") request, response = json_app.test_client.get("/") assert response.status == 200 - assert response.text == json_dumps(NEW_RESP) - assert response.json == NEW_RESP + assert response.text == json_dumps(JSON_DATA) + assert response.json == JSON_DATA def test_no_content(json_app): From bb6cfb75ca341046f0a2759abf1cef3a27818683 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sun, 23 Oct 2022 17:36:51 +0200 Subject: [PATCH 15/33] Remove unused import --- tests/test_response.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_response.py b/tests/test_response.py index e50bcb19d7..9254fca3fd 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -30,7 +30,6 @@ raw, text, ) -from sanic.response.types import JSONResponse JSON_DATA = {"ok": True} From 61591455a6daee912685e6bc582c7ebf1a505fea Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sat, 29 Oct 2022 20:28:44 +0200 Subject: [PATCH 16/33] Modify JSONResponse working --- sanic/response/types.py | 49 +++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index 07e552688b..0c2fff0ecc 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -215,7 +215,7 @@ class JSONResponse(HTTPResponse): __slots__ = ( "_body", - "_body_set", + "_body_manually_set", "_initialized", "_raw_body", "_use_dumps", @@ -232,24 +232,24 @@ def __init__( **kwargs: Any, ): self._initialized = False - if not dumps: - dumps = HTTPResponse._dumps + self._body_manually_set = False - self._use_dumps = dumps + self._use_dumps = dumps or BaseHTTPResponse._dumps self._use_dumps_kwargs = kwargs self._raw_body = body super().__init__( - None, + self._encode_body(self._use_dumps(body, **self._use_dumps_kwargs)), headers=headers, status=status, content_type=content_type, ) + self._initialized = True def _check_body_not_manually_set(self): - if self._body_set: + if self._body_manually_set: raise SanicException( "Cannot use raw_body after body has been manually set." ) @@ -261,27 +261,38 @@ def raw_body(self) -> Optional[Any]: @raw_body.setter def raw_body(self, value: Any): - self._check_body_not_manually_set() + self._body_manually_set = False + self._body = self._encode_body( + self._use_dumps(value, **self._use_dumps_kwargs) + ) self._raw_body = value @property - def body(self) -> bytes: - if self._body_set: - return self._body - return self._encode_body( - self._use_dumps(self._raw_body, **self._use_dumps_kwargs) - ) + def body(self) -> Optional[bytes]: + return self._body @body.setter - def body(self, value: bytes): - # When calling super().__init__, the body will be set, - # but we don't need to save it, since we will be dumping - # it on-the-fly from raw_body. + def body(self, value: Optional[bytes]): + self._body = value if not self._initialized: return + self._body_manually_set = True - self._body = value - self._body_set = True + def set_body( + self, + body: Any, + dumps: Optional[Callable[..., str]] = None, + **dumps_kwargs: Any, + ) -> None: + """Sets a new response body.""" + + self._body_manually_set = False + self._raw_body = body + + use_dumps = dumps or self._use_dumps + use_dumps_kwargs = {**self._use_dumps_kwargs, **dumps_kwargs} + + self._body = self._encode_body(use_dumps(body, **use_dumps_kwargs)) class ResponseStream: From 662063e13741f42f577a96812a206017b6f25505 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sat, 29 Oct 2022 20:37:41 +0200 Subject: [PATCH 17/33] Add tests layout --- tests/test_response_json.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/test_response_json.py diff --git a/tests/test_response_json.py b/tests/test_response_json.py new file mode 100644 index 0000000000..6e101f9901 --- /dev/null +++ b/tests/test_response_json.py @@ -0,0 +1,33 @@ +JSON_DATA = {"ok": True} + + +def test_body_can_be_retrieved(): + ... + + +def test_body_can_be_set(): + ... + + +def test_raw_body_can_be_retrieved(): + ... + + +def test_raw_body_can_be_set(): + ... + + +def test_raw_body_cant_be_retrieved_after_body_set(): + ... + + +def test_raw_body_can_be_reset_after_body_set(): + ... + + +def test_set_json(): + ... + + +def test_set_json_after_body_set(): + ... From 0d9952215ac0fa9ccf7afc5987598d467514885d Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sun, 30 Oct 2022 19:58:15 +0100 Subject: [PATCH 18/33] Add some tests --- tests/test_response_json.py | 115 +++++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 14 deletions(-) diff --git a/tests/test_response_json.py b/tests/test_response_json.py index 6e101f9901..da3a49496c 100644 --- a/tests/test_response_json.py +++ b/tests/test_response_json.py @@ -1,33 +1,120 @@ -JSON_DATA = {"ok": True} +import json +from functools import partial -def test_body_can_be_retrieved(): - ... +import pytest +from sanic import Request, Sanic +from sanic.exceptions import SanicException +from sanic.response import json as json_response +from sanic.response.types import JSONResponse -def test_body_can_be_set(): - ... +JSON_BODY = {"ok": True} +json_dumps = partial(json.dumps, separators=(",", ":")) -def test_raw_body_can_be_retrieved(): - ... +@pytest.fixture +def json_app(app: Sanic): + @app.get("/json") + async def handle(request: Request): + return json_response(JSON_BODY) -def test_raw_body_can_be_set(): - ... + return app -def test_raw_body_cant_be_retrieved_after_body_set(): - ... +def test_body_can_be_retrieved(json_app: Sanic): + _, resp = json_app.test_client.get("/json") + assert resp.body == json_dumps(JSON_BODY).encode() + + +def test_body_can_be_set(json_app: Sanic): + new_body = b'{"hello":"world"}' + + @json_app.on_response + def set_body(request: Request, response: JSONResponse): + response.body = new_body + + _, resp = json_app.test_client.get("/json") + assert resp.body == new_body + + +def test_raw_body_can_be_retrieved(json_app: Sanic): + @json_app.on_response + def check_body(request: Request, response: JSONResponse): + assert response.raw_body == JSON_BODY + + json_app.test_client.get("/json") + + +def test_raw_body_can_be_set(json_app: Sanic): + new_body = {"hello": "world"} + + @json_app.on_response + def set_body(request: Request, response: JSONResponse): + response.raw_body = new_body + assert response.raw_body == new_body + assert response.body == json_dumps(new_body).encode() + + json_app.test_client.get("/json") + + +def test_raw_body_cant_be_retrieved_after_body_set(json_app: Sanic): + new_body = b'{"hello":"world"}' + + @json_app.on_response + def check_raw_body(request: Request, response: JSONResponse): + response.body = new_body + with pytest.raises(SanicException): + response.raw_body + + json_app.test_client.get("/json") + + +def test_raw_body_can_be_reset_after_body_set(json_app: Sanic): + new_body = b'{"hello":"world"}' + new_new_body = {"lorem": "ipsum"} + + @json_app.on_response + def set_bodies(request: Request, response: JSONResponse): + response.body = new_body + response.raw_body = new_new_body + + _, resp = json_app.test_client.get("/json") + assert resp.body == json_dumps(new_new_body).encode() + + +def test_set_body_method(json_app: Sanic): + new_body = {"lorem": "ipsum"} + + @json_app.on_response + def set_body(request: Request, response: JSONResponse): + response.set_body(new_body) + + _, resp = json_app.test_client.get("/json") + assert resp.body == json_dumps(new_body).encode() + + +def test_set_body_method_after_body_set(json_app: Sanic): + new_body = b'{"hello":"world"}' + new_new_body = {"lorem": "ipsum"} + + @json_app.on_response + def set_body(request: Request, response: JSONResponse): + response.body = new_body + response.set_body(new_new_body) + + _, resp = json_app.test_client.get("/json") + assert resp.body == json_dumps(new_new_body).encode() -def test_raw_body_can_be_reset_after_body_set(): +def test_default_dumps_and_kwargs(): ... -def test_set_json(): +def test_custom_dumps_and_kwargs(): ... -def test_set_json_after_body_set(): +def test_override_dumps_and_kwargs(): ... From 829ccbe847131629e2d4fb6b4ceb86fad629c353 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sat, 5 Nov 2022 19:02:22 +0100 Subject: [PATCH 19/33] Fix custom kwargs --- sanic/response/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index 0c2fff0ecc..86f76492a3 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -290,7 +290,7 @@ def set_body( self._raw_body = body use_dumps = dumps or self._use_dumps - use_dumps_kwargs = {**self._use_dumps_kwargs, **dumps_kwargs} + use_dumps_kwargs = dumps_kwargs if dumps else self._use_dumps_kwargs self._body = self._encode_body(use_dumps(body, **use_dumps_kwargs)) From dee45b484e233222fcfefdb18403778c18365a70 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sat, 5 Nov 2022 19:02:33 +0100 Subject: [PATCH 20/33] Add remaining tests --- tests/test_response_json.py | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/test_response_json.py b/tests/test_response_json.py index da3a49496c..9b45782187 100644 --- a/tests/test_response_json.py +++ b/tests/test_response_json.py @@ -1,6 +1,7 @@ import json from functools import partial +from unittest.mock import Mock import pytest @@ -108,13 +109,32 @@ def set_body(request: Request, response: JSONResponse): assert resp.body == json_dumps(new_new_body).encode() -def test_default_dumps_and_kwargs(): - ... +def test_custom_dumps_and_kwargs(json_app: Sanic): + custom_dumps = Mock(return_value="custom") + @json_app.get("/json-custom") + async def handle_custom(request: Request): + return json_response(JSON_BODY, dumps=custom_dumps, prry="platypus") -def test_custom_dumps_and_kwargs(): - ... + _, resp = json_app.test_client.get("/json-custom") + assert resp.body == "custom".encode() + custom_dumps.assert_called_once_with(JSON_BODY, prry="platypus") -def test_override_dumps_and_kwargs(): - ... +def test_override_dumps_and_kwargs(json_app: Sanic): + custom_dumps_1 = Mock(return_value="custom1") + custom_dumps_2 = Mock(return_value="custom2") + + @json_app.get("/json-custom") + async def handle_custom(request: Request): + return json_response(JSON_BODY, dumps=custom_dumps_1, prry="platypus") + + @json_app.on_response + def set_body(request: Request, response: JSONResponse): + response.set_body(JSON_BODY, dumps=custom_dumps_2, platypus="prry") + + _, resp = json_app.test_client.get("/json-custom") + + assert resp.body == "custom2".encode() + custom_dumps_1.assert_called_once_with(JSON_BODY, prry="platypus") + custom_dumps_2.assert_called_once_with(JSON_BODY, platypus="prry") From 49a99b825c47d71f886f7d357f0bab672889cc47 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sat, 5 Nov 2022 19:13:14 +0100 Subject: [PATCH 21/33] Make body type same as superclass --- sanic/response/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index 86f76492a3..efd969cec4 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -268,11 +268,11 @@ def raw_body(self, value: Any): self._raw_body = value @property - def body(self) -> Optional[bytes]: + def body(self) -> Optional[Any]: return self._body @body.setter - def body(self, value: Optional[bytes]): + def body(self, value: Optional[Any]): self._body = value if not self._initialized: return From 023708cd214ccf19854552fa584978ee3f5d7948 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sat, 5 Nov 2022 19:23:09 +0100 Subject: [PATCH 22/33] Mypy doesn't like body being a property --- sanic/response/types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index efd969cec4..361e047b4e 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -267,12 +267,12 @@ def raw_body(self, value: Any): ) self._raw_body = value - @property - def body(self) -> Optional[Any]: + @property # type: ignore + def body(self) -> Optional[bytes]: # type: ignore return self._body @body.setter - def body(self, value: Optional[Any]): + def body(self, value: Optional[bytes]): self._body = value if not self._initialized: return From 43f484d1a815e732a544869ff8552ee785934197 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Sun, 13 Nov 2022 23:50:04 +0100 Subject: [PATCH 23/33] Add useful methods --- sanic/response/types.py | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/sanic/response/types.py b/sanic/response/types.py index 361e047b4e..e52a6678ba 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -294,6 +294,54 @@ def set_body( self._body = self._encode_body(use_dumps(body, **use_dumps_kwargs)) + def append(self, value: Any) -> None: + """Appends a value to the response body.""" + + self._check_body_not_manually_set() + + if not isinstance(self._raw_body, list): + raise SanicException("Cannot append to a non-list object.") + + self._raw_body.append(value) + self.raw_body = self._raw_body + + def extend(self, value: Any) -> None: + """Extends the response body.""" + + self._check_body_not_manually_set() + + if not isinstance(self._raw_body, list): + raise SanicException("Cannot extend a non-list object.") + + self._raw_body.extend(value) + self.raw_body = self._raw_body + + def update(self, *args, **kwargs) -> None: + """Updates the response body.""" + + self._check_body_not_manually_set() + + if not isinstance(self._raw_body, dict): + raise SanicException("Cannot update a non-dict object.") + + self._raw_body.update(*args, **kwargs) + self.raw_body = self._raw_body + + def pop(self, key: Any) -> Any: + """Pops a key from the response body.""" + + self._check_body_not_manually_set() + + if not isinstance(self._raw_body, (list, dict)): + raise SanicException( + "Cannot pop from a non-list and non-dict object." + ) + + value = self._raw_body.pop(key) + self.raw_body = self._raw_body + + return value + class ResponseStream: """ From 433d025c4c758f79f2b1b36448e112a17df6a9f4 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Mon, 14 Nov 2022 00:05:36 +0100 Subject: [PATCH 24/33] Improve docstrings --- sanic/response/types.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index e52a6678ba..b04633612c 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -284,7 +284,10 @@ def set_body( dumps: Optional[Callable[..., str]] = None, **dumps_kwargs: Any, ) -> None: - """Sets a new response body.""" + """Sets a new response body using the given dumps function + and kwargs, or falling back to the defaults given when + creating the object if none are specified. + """ self._body_manually_set = False self._raw_body = body @@ -295,7 +298,10 @@ def set_body( self._body = self._encode_body(use_dumps(body, **use_dumps_kwargs)) def append(self, value: Any) -> None: - """Appends a value to the response body.""" + """Appends a value to the response raw_body, ensuring that + body is kept up to date. This can only be used if raw_body + is a list. + """ self._check_body_not_manually_set() @@ -306,7 +312,10 @@ def append(self, value: Any) -> None: self.raw_body = self._raw_body def extend(self, value: Any) -> None: - """Extends the response body.""" + """Extends the response's raw_body with the given values, ensuring + that body is kept up to date. This can only be used if raw_body is + a list. + """ self._check_body_not_manually_set() @@ -317,7 +326,10 @@ def extend(self, value: Any) -> None: self.raw_body = self._raw_body def update(self, *args, **kwargs) -> None: - """Updates the response body.""" + """Updates the response's raw_body with the given values, ensuring + that body is kept up to date. This can only be used if raw_body is + a dict. + """ self._check_body_not_manually_set() @@ -328,7 +340,10 @@ def update(self, *args, **kwargs) -> None: self.raw_body = self._raw_body def pop(self, key: Any) -> Any: - """Pops a key from the response body.""" + """Pops a key from the response's raw_body, ensuring that body is + kept up to date. This can only be used if raw_body is a dict or a + list. + """ self._check_body_not_manually_set() From 2d92a5947401007211958a3a0af0c495c0df83e0 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Mon, 14 Nov 2022 00:08:35 +0100 Subject: [PATCH 25/33] Add raw_body warning --- sanic/response/types.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/sanic/response/types.py b/sanic/response/types.py index b04633612c..591102e7a8 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -256,6 +256,16 @@ def _check_body_not_manually_set(self): @property def raw_body(self) -> Optional[Any]: + """Returns the raw body, as long as body has not been manually + set previously. + + NOTE: This object should not be mutated, as it will not be + reflected in the response body. If you need to mutate the + response body, consider using one of the provided methods in + this class or alternatively call set_body() with the mutated + object afterwards or set the raw_body property to it. + """ + self._check_body_not_manually_set() return self._raw_body From d517ab256977d8693cbe81aa8ad5125b8444f35c Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Mon, 14 Nov 2022 00:09:25 +0100 Subject: [PATCH 26/33] Add tests --- tests/test_response_json.py | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/test_response_json.py b/tests/test_response_json.py index 9b45782187..63820e9566 100644 --- a/tests/test_response_json.py +++ b/tests/test_response_json.py @@ -138,3 +138,70 @@ def set_body(request: Request, response: JSONResponse): assert resp.body == "custom2".encode() custom_dumps_1.assert_called_once_with(JSON_BODY, prry="platypus") custom_dumps_2.assert_called_once_with(JSON_BODY, platypus="prry") + + +def test_append(json_app: Sanic): + @json_app.get("/json-append") + async def handler_append(request: Request): + return json_response(["a", "b"], status=200) + + @json_app.on_response + def do_append(request: Request, response: JSONResponse): + response.append("c") + + _, resp = json_app.test_client.get("/json-append") + assert resp.body == json_dumps(["a", "b", "c"]).encode() + + +def test_extend(json_app: Sanic): + @json_app.get("/json-extend") + async def handler_extend(request: Request): + return json_response(["a", "b"], status=200) + + @json_app.on_response + def do_extend(request: Request, response: JSONResponse): + response.extend(["c", "d"]) + + _, resp = json_app.test_client.get("/json-extend") + assert resp.body == json_dumps(["a", "b", "c", "d"]).encode() + + +def test_update(json_app: Sanic): + @json_app.get("/json-update") + async def handler_update(request: Request): + return json_response({"a": "b"}, status=200) + + @json_app.on_response + def do_update(request: Request, response: JSONResponse): + response.update({"c": "d"}, e="f") + + _, resp = json_app.test_client.get("/json-update") + assert resp.body == json_dumps({"a": "b", "c": "d", "e": "f"}).encode() + + +def test_pop_dict(json_app: Sanic): + @json_app.get("/json-pop") + async def handler_pop(request: Request): + return json_response({"a": "b", "c": "d"}, status=200) + + @json_app.on_response + def do_pop(request: Request, response: JSONResponse): + val = response.pop("c") + assert val == "d" + + _, resp = json_app.test_client.get("/json-pop") + assert resp.body == json_dumps({"a": "b"}).encode() + + +def test_pop_list(json_app: Sanic): + @json_app.get("/json-pop") + async def handler_pop(request: Request): + return json_response(["a", "b"], status=200) + + @json_app.on_response + def do_pop(request: Request, response: JSONResponse): + val = response.pop(0) + assert val == "a" + + _, resp = json_app.test_client.get("/json-pop") + assert resp.body == json_dumps(["b"]).encode() From a149ae47d387f4a5ef96286b92bfac23c7ae4807 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Thu, 8 Dec 2022 19:10:43 +0100 Subject: [PATCH 27/33] Add default arg for dicts --- sanic/response/types.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index 591102e7a8..e82a9c13ea 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -20,6 +20,7 @@ from sanic.exceptions import SanicException, ServerError from sanic.helpers import has_message_body, remove_entity_headers from sanic.http import Http +from sanic.helpers import Default, _default if TYPE_CHECKING: @@ -349,7 +350,7 @@ def update(self, *args, **kwargs) -> None: self._raw_body.update(*args, **kwargs) self.raw_body = self._raw_body - def pop(self, key: Any) -> Any: + def pop(self, key: Any, default: Any = _default) -> Any: """Pops a key from the response's raw_body, ensuring that body is kept up to date. This can only be used if raw_body is a dict or a list. @@ -362,7 +363,11 @@ def pop(self, key: Any) -> Any: "Cannot pop from a non-list and non-dict object." ) - value = self._raw_body.pop(key) + if isinstance(self._raw_body, list) or isinstance(default, Default): + value = self._raw_body.pop(key) + else: + value = self._raw_body.pop(key, default) + self.raw_body = self._raw_body return value From 31ea38f7ce18e6651a89ec9794fa1fface8a9431 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Thu, 8 Dec 2022 19:11:49 +0100 Subject: [PATCH 28/33] Add default always if it's been passed --- sanic/response/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index e82a9c13ea..69b8a17d7e 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -363,7 +363,7 @@ def pop(self, key: Any, default: Any = _default) -> Any: "Cannot pop from a non-list and non-dict object." ) - if isinstance(self._raw_body, list) or isinstance(default, Default): + if isinstance(default, Default): value = self._raw_body.pop(key) else: value = self._raw_body.pop(key, default) From a76c7cac4a32bba007113b9c152b10d3e45a6ed4 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Thu, 8 Dec 2022 19:16:19 +0100 Subject: [PATCH 29/33] Fix import order --- sanic/response/types.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index 69b8a17d7e..d2f44bb2ac 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -18,9 +18,13 @@ from sanic.compat import Header from sanic.cookies import CookieJar from sanic.exceptions import SanicException, ServerError -from sanic.helpers import has_message_body, remove_entity_headers +from sanic.helpers import ( + Default, + _default, + has_message_body, + remove_entity_headers, +) from sanic.http import Http -from sanic.helpers import Default, _default if TYPE_CHECKING: From c89b1a3b0e1636a47016382da1bf729f6e317d05 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Thu, 8 Dec 2022 19:30:42 +0100 Subject: [PATCH 30/33] Raise a proper exception if default argument passed to list --- sanic/response/types.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sanic/response/types.py b/sanic/response/types.py index d2f44bb2ac..40efc3640b 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -369,6 +369,8 @@ def pop(self, key: Any, default: Any = _default) -> Any: if isinstance(default, Default): value = self._raw_body.pop(key) + elif isinstance(self._raw_body, list): + raise TypeError("pop doesn't accept a default for lists") else: value = self._raw_body.pop(key, default) From 2d3344c4ad64f08ec93b306435d1b1b53b755917 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Thu, 8 Dec 2022 19:34:01 +0100 Subject: [PATCH 31/33] Improve message --- sanic/response/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/response/types.py b/sanic/response/types.py index 40efc3640b..fe3c941ecf 100644 --- a/sanic/response/types.py +++ b/sanic/response/types.py @@ -370,7 +370,7 @@ def pop(self, key: Any, default: Any = _default) -> Any: if isinstance(default, Default): value = self._raw_body.pop(key) elif isinstance(self._raw_body, list): - raise TypeError("pop doesn't accept a default for lists") + raise TypeError("pop doesn't accept a default argument for lists") else: value = self._raw_body.pop(key, default) From 27e559494769031cf1f4837d0be147cf6768a965 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Thu, 8 Dec 2022 19:34:11 +0100 Subject: [PATCH 32/33] Update tests --- tests/test_response_json.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_response_json.py b/tests/test_response_json.py index 63820e9566..c89dba42a5 100644 --- a/tests/test_response_json.py +++ b/tests/test_response_json.py @@ -189,6 +189,9 @@ def do_pop(request: Request, response: JSONResponse): val = response.pop("c") assert val == "d" + val_default = response.pop("e", "f") + assert val_default == "f" + _, resp = json_app.test_client.get("/json-pop") assert resp.body == json_dumps({"a": "b"}).encode() @@ -203,5 +206,10 @@ def do_pop(request: Request, response: JSONResponse): val = response.pop(0) assert val == "a" + with pytest.raises( + TypeError, match="pop doesn't accept a default argument for lists" + ): + response.pop(21, "nah nah") + _, resp = json_app.test_client.get("/json-pop") assert resp.body == json_dumps(["b"]).encode() From 869d57a4bbbfd3a1eb8a157704e4e29c831cc466 Mon Sep 17 00:00:00 2001 From: prryplatypus <25409753+prryplatypus@users.noreply.github.com> Date: Thu, 8 Dec 2022 20:49:13 +0100 Subject: [PATCH 33/33] Update return type --- sanic/response/convenience.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sanic/response/convenience.py b/sanic/response/convenience.py index e9c3c3dacc..429b3214a4 100644 --- a/sanic/response/convenience.py +++ b/sanic/response/convenience.py @@ -37,7 +37,7 @@ def json( content_type: str = "application/json", dumps: Optional[Callable[..., str]] = None, **kwargs: Any, -) -> HTTPResponse: +) -> JSONResponse: """ Returns response object with body in json format.