Added codecs as CodecsComponent instantiated and included in app initialization, making them available ever after as type annotation Codecs
CodecsComponent resolves by concatenating a new App init kwargcodecs as list of BaseCodec items with the default codecs already in place (JSON, HTML, MultiPartForm). As conneg evaluates in order, codecs supplied with the same media_type as the default ones will override them.
BaseCodec can have a response_class attribute that indicates that for this media type and codec, this response class should be used (if not another response class has been specified as return value which is legacy behavior and it's respected).
render_response checks on header Content-Type and codecs' media type and tries to render depending on media type and its corresponding codec (if any), similiar to how RequestDataComponent resolves, and if return_value is not a Response (which precedes).
Otherwise it's legacy behavior.
Tests added
Motivations
We are already using this patched version in production with RPS~=100 with no issues
Users usually want to use change encoders and decoders for an infinite variety of reasons
Users often want the response in the same media type than the request or at least that it is predictable and bound to the Content-Type header provided in the request.
With this PR it's achieved both overriding existing decoders & encoders (responses) depending on the media type contained in Content-Type header instead of an opinionated response selection based on response's returning content python object actual type, anyway returning a Response or not having a BaseCodec.response_class defined (which is the default) means legacy behavior on response rendering, that is the type of return_value, no changes.
This automated and predictable behavior of encoding and decoding opens the door for another features like streaming requests and responses, as you can now override default ones which aren't streamed.
We would like to see this in apistar and not having to maintain a fork with the main upstream quick changes :trollface: Dependency injection as you designed is a kind of magic that allows for instance this PR changing barely 10 lines of original code and adding up just another new 10, we really love it ❤️
There are no examples on how doing it, I can supply one
Real Example, working
Here we want that all JSON (decode in request, encode in response), to be done by python-rapidjson module taking advantage of its unique options and features like automatic encoding of dates and uuids without hooks and, of course, because its much higher speed in CPython. For all requests of Content-Type: application/json
We also want to add support for application/x-msgpack requests, but leaving apistar to respond with JSON format, because we are only interested in compressed request body, i.e. containing thumbnails without the use of base64 or other bytes objects not supported by JSON. Of course, we want the responses to be RapidJSON, as well.
We want all typing and validation provided or previously built custom still working even with msgpack or rapidjson data entry types
Custom Codecs and Responses
$ venv/bin/pip install msgpack python-rapidjson
Sample Code
import uuid
import datetime
import msgpack
import rapidjson
import typing
from apistar import Route, types, validators, App, TestClient
from apistar.codecs import BaseCodec
from apistar.exceptions import ParseError
from apistar.http import Response
from apistar.validators import String
class RapidJSONResponse(Response):
media_type = "application/json"
charset = None
options = {
"ensure_ascii": False,
# Just for the sake of this example...
"indent": 4,
"sort_keys": True,
"datetime_mode": rapidjson.DM_ISO8601,
"uuid_mode": rapidjson.UM_CANONICAL,
}
def render(self, content: typing.Any) -> bytes:
"""
Serializes content faster and with some magic courtesy of rapidjson
"""
options = {"default": self.default}
options.update(self.options)
return rapidjson.dumps(content, **options).encode("utf-8")
def default(self, obj: typing.Any) -> typing.Any:
if isinstance(obj, types.Type):
return dict(obj)
error = "Object of type '%s' is not JSON serializable."
return TypeError(error % type(obj).__name__)
class RapidJSONCodec(BaseCodec):
media_type = "application/json"
format = "json"
response_class = RapidJSONResponse
def decode(self, bytestring, **options):
"""
Loads JSON data speedily
"""
try:
return rapidjson.loads(bytestring.decode("utf-8"))
except ValueError as exc:
raise ParseError("Malformed JSON. %s" % exc) from None
class MessagePackCodec(BaseCodec):
media_type = "application/x-msgpack"
response_class = RapidJSONResponse
def decode(self, bytestring, **options):
"""
Loads MessagePack data into a dict compatible with Apistar
"""
try:
# use_list=True and raw=False are required for Validators to work as normal JSON
return msgpack.unpackb(bytestring, use_list=True, raw=False)
except Exception as exc:
raise ParseError(f"Malformed msgpack packet {exc}")
class HexUUID(String):
"""
It accepts canonical or hexed uuid's but returns only in hex format
"""
__slots__ = ["max_length", "min_length"]
errors = {
"type": "Must be a valid UUID",
"null": "May not be null.",
"blank": "Must not be blank.",
"max_length": "Must have no more than {max_length} characters.",
"min_length": "Must have at least {min_length} characters.",
"pattern": "Must match the pattern /{pattern}/.",
"format": "Must be a valid {format}.",
"enum": "Must be a valid choice.",
"exact": "Must be {exact}.",
}
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.max_length = 36
self.min_length = 32
def validate(
self, value: typing.Any, definitions=None, allow_coerce: bool = False
) -> typing.Any:
value = super().validate(
value, definitions=definitions, allow_coerce=allow_coerce
)
# Check uuid
try:
value_uuid = uuid.UUID(value, version=4)
if value not in (str(value_uuid), value_uuid.hex):
self.error("type", value)
return value_uuid.hex
except ValueError:
# If it's a value error, then the string
# is not a valid hex code for a UUID.
self.error("type", value)
class SomeType(types.Type):
uid = HexUUID()
username = validators.String()
numbers = validators.Array(validators.Integer())
words = validators.Array(validators.String())
objects = validators.Array(validators.Any())
def post_(user_id: int, data: SomeType):
return {
"user": user_id,
"now": datetime.datetime.now(tz=datetime.timezone.utc),
"random_uuid": uuid.uuid4(),
"data": data,
}
routes = [Route("/{user_id}", method="POST", handler=post_)]
app = App(routes=routes, codecs=[RapidJSONCodec(), MessagePackCodec()])
client = TestClient(app=app)
response1 = client.post(
"/1",
json={
"username": "danigosa",
"uid": "f5a5577f-72a2-419d-8d49-9f23634704b5",
"numbers": [1, 2, 3, 4],
"words": ["tommy", "nabo"],
"objects": ["tommy", 1, "nabo"],
},
)
response2 = client.post(
"/1",
data=msgpack.packb(
{
"username": "danigosa",
"uid": "f5a5577f-72a2-419d-8d49-9f23634704b5",
"numbers": [1, 2, 3, 4],
"words": ["tommy", "nabo"],
"objects": ["tommy", 1, "nabo"],
}
),
headers={"Content-Type": "application/x-msgpack"},
)
print(
f"JSON Response: \nHTTP {response1.status_code} with Content-Type: {response1.headers.get('Content-Type')}\n{response1.content}\n"
)
print(
f"MsgPack Response: \nHTTP {response2.status_code} with Content-Type: {response2.headers.get('Content-Type')}\n{response2.content}\n"
)
Changes
CodecsComponent
instantiated and included in app initialization, making them available ever after as type annotationCodecs
CodecsComponent
resolves by concatenating a new App init kwargcodecs
as list ofBaseCodec
items with the default codecs already in place (JSON, HTML, MultiPartForm). Asconneg
evaluates in order, codecs supplied with the samemedia_type
as the default ones will override them.BaseCodec
can have aresponse_class
attribute that indicates that for this media type and codec, this response class should be used (if not another response class has been specified as return value which is legacy behavior and it's respected).render_response
checks on headerContent-Type
and codecs' media type and tries to render depending on media type and its corresponding codec (if any), similiar to howRequestDataComponent
resolves, and ifreturn_value
is not aResponse
(which precedes).Motivations
Content-Type
header provided in the request.Content-Type
header instead of an opinionated response selection based on response's returning content python object actual type, anyway returning aResponse
or not having aBaseCodec.response_class
defined (which is the default) means legacy behavior on response rendering, that is the type ofreturn_value
, no changes.There are no examples on how doing it, I can supply one
Real Example, working
python-rapidjson
module taking advantage of its unique options and features like automatic encoding of dates and uuids without hooks and, of course, because its much higher speed in CPython. For all requests ofContent-Type: application/json
application/x-msgpack
requests, but leaving apistar to respond with JSON format, because we are only interested in compressed request body, i.e. containing thumbnails without the use of base64 or other bytes objects not supported by JSON. Of course, we want the responses to be RapidJSON, as well.msgpack
orrapidjson
data entry typesCustom Codecs and Responses
Sample Code
Stdout: