Merge branch 'main' of github.com:sanic-org/sanic into monitor-restart
This commit is contained in:
commit
ae1669cd8f
36
sanic/response/__init__.py
Normal file
36
sanic/response/__init__.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
from .convenience import (
|
||||||
|
empty,
|
||||||
|
file,
|
||||||
|
file_stream,
|
||||||
|
html,
|
||||||
|
json,
|
||||||
|
raw,
|
||||||
|
redirect,
|
||||||
|
text,
|
||||||
|
validate_file,
|
||||||
|
)
|
||||||
|
from .types import (
|
||||||
|
BaseHTTPResponse,
|
||||||
|
HTTPResponse,
|
||||||
|
JSONResponse,
|
||||||
|
ResponseStream,
|
||||||
|
json_dumps,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"BaseHTTPResponse",
|
||||||
|
"HTTPResponse",
|
||||||
|
"JSONResponse",
|
||||||
|
"ResponseStream",
|
||||||
|
"empty",
|
||||||
|
"json",
|
||||||
|
"text",
|
||||||
|
"raw",
|
||||||
|
"html",
|
||||||
|
"validate_file",
|
||||||
|
"file",
|
||||||
|
"redirect",
|
||||||
|
"file_stream",
|
||||||
|
"json_dumps",
|
||||||
|
)
|
|
@ -2,212 +2,20 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from email.utils import formatdate, parsedate_to_datetime
|
from email.utils import formatdate, parsedate_to_datetime
|
||||||
from functools import partial
|
|
||||||
from mimetypes import guess_type
|
from mimetypes import guess_type
|
||||||
from os import path
|
from os import path
|
||||||
from pathlib import PurePath
|
from pathlib import PurePath
|
||||||
from time import time
|
from time import time
|
||||||
from typing import (
|
from typing import Any, AnyStr, Callable, Dict, Optional, Union
|
||||||
TYPE_CHECKING,
|
|
||||||
Any,
|
|
||||||
AnyStr,
|
|
||||||
Callable,
|
|
||||||
Coroutine,
|
|
||||||
Dict,
|
|
||||||
Iterator,
|
|
||||||
Optional,
|
|
||||||
Tuple,
|
|
||||||
TypeVar,
|
|
||||||
Union,
|
|
||||||
)
|
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from sanic.compat import Header, open_async, stat_async
|
from sanic.compat import Header, open_async, stat_async
|
||||||
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
|
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
|
||||||
from sanic.cookies import CookieJar
|
from sanic.helpers import Default, _default
|
||||||
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.log import logger
|
||||||
from sanic.models.protocol_types import HTMLProtocol, Range
|
from sanic.models.protocol_types import HTMLProtocol, Range
|
||||||
|
|
||||||
|
from .types import HTTPResponse, JSONResponse, ResponseStream
|
||||||
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
|
|
||||||
<https://sanicframework.org/guide/basics/cookies.html>`__
|
|
||||||
|
|
||||||
: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(
|
def empty(
|
||||||
|
@ -229,7 +37,7 @@ def json(
|
||||||
content_type: str = "application/json",
|
content_type: str = "application/json",
|
||||||
dumps: Optional[Callable[..., str]] = None,
|
dumps: Optional[Callable[..., str]] = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> HTTPResponse:
|
) -> JSONResponse:
|
||||||
"""
|
"""
|
||||||
Returns response object with body in json format.
|
Returns response object with body in json format.
|
||||||
|
|
||||||
|
@ -238,13 +46,14 @@ def json(
|
||||||
:param headers: Custom Headers.
|
:param headers: Custom Headers.
|
||||||
:param kwargs: Remaining arguments that are passed to the json encoder.
|
:param kwargs: Remaining arguments that are passed to the json encoder.
|
||||||
"""
|
"""
|
||||||
if not dumps:
|
|
||||||
dumps = BaseHTTPResponse._dumps
|
return JSONResponse(
|
||||||
return HTTPResponse(
|
body,
|
||||||
dumps(body, **kwargs),
|
|
||||||
headers=headers,
|
|
||||||
status=status,
|
status=status,
|
||||||
|
headers=headers,
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
|
dumps=dumps,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -465,80 +274,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(
|
async def file_stream(
|
||||||
location: Union[str, PurePath],
|
location: Union[str, PurePath],
|
||||||
status: int = 200,
|
status: int = 200,
|
453
sanic/response/types.py
Normal file
453
sanic/response/types.py
Normal file
|
@ -0,0 +1,453 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import partial
|
||||||
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
AnyStr,
|
||||||
|
Callable,
|
||||||
|
Coroutine,
|
||||||
|
Dict,
|
||||||
|
Iterator,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
TypeVar,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
|
||||||
|
from sanic.compat import Header
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://sanic.dev/en/guide/basics/cookies.html>`
|
||||||
|
|
||||||
|
: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[Any] = 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 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__ = (
|
||||||
|
"_body",
|
||||||
|
"_body_manually_set",
|
||||||
|
"_initialized",
|
||||||
|
"_raw_body",
|
||||||
|
"_use_dumps",
|
||||||
|
"_use_dumps_kwargs",
|
||||||
|
)
|
||||||
|
|
||||||
|
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._initialized = False
|
||||||
|
self._body_manually_set = False
|
||||||
|
|
||||||
|
self._use_dumps = dumps or BaseHTTPResponse._dumps
|
||||||
|
self._use_dumps_kwargs = kwargs
|
||||||
|
|
||||||
|
self._raw_body = body
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
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_manually_set:
|
||||||
|
raise SanicException(
|
||||||
|
"Cannot use raw_body after body has been manually set."
|
||||||
|
)
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
@raw_body.setter
|
||||||
|
def raw_body(self, value: Any):
|
||||||
|
self._body_manually_set = False
|
||||||
|
self._body = self._encode_body(
|
||||||
|
self._use_dumps(value, **self._use_dumps_kwargs)
|
||||||
|
)
|
||||||
|
self._raw_body = value
|
||||||
|
|
||||||
|
@property # type: ignore
|
||||||
|
def body(self) -> Optional[bytes]: # type: ignore
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
@body.setter
|
||||||
|
def body(self, value: Optional[bytes]):
|
||||||
|
self._body = value
|
||||||
|
if not self._initialized:
|
||||||
|
return
|
||||||
|
self._body_manually_set = True
|
||||||
|
|
||||||
|
def set_body(
|
||||||
|
self,
|
||||||
|
body: Any,
|
||||||
|
dumps: Optional[Callable[..., str]] = None,
|
||||||
|
**dumps_kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
"""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
|
||||||
|
|
||||||
|
use_dumps = dumps or self._use_dumps
|
||||||
|
use_dumps_kwargs = dumps_kwargs if dumps else self._use_dumps_kwargs
|
||||||
|
|
||||||
|
self._body = self._encode_body(use_dumps(body, **use_dumps_kwargs))
|
||||||
|
|
||||||
|
def append(self, value: Any) -> None:
|
||||||
|
"""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()
|
||||||
|
|
||||||
|
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'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()
|
||||||
|
|
||||||
|
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'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()
|
||||||
|
|
||||||
|
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, 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(default, Default):
|
||||||
|
value = self._raw_body.pop(key)
|
||||||
|
elif isinstance(self._raw_body, list):
|
||||||
|
raise TypeError("pop doesn't accept a default argument for lists")
|
||||||
|
else:
|
||||||
|
value = self._raw_body.pop(key, default)
|
||||||
|
|
||||||
|
self.raw_body = self._raw_body
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
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__()
|
215
tests/test_response_json.py
Normal file
215
tests/test_response_json.py
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from functools import partial
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
JSON_BODY = {"ok": True}
|
||||||
|
json_dumps = partial(json.dumps, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def json_app(app: Sanic):
|
||||||
|
@app.get("/json")
|
||||||
|
async def handle(request: Request):
|
||||||
|
return json_response(JSON_BODY)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
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_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")
|
||||||
|
|
||||||
|
_, 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(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")
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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()
|
Loading…
Reference in New Issue
Block a user