encode / apistar

The Web API toolkit. 🛠
https://docs.apistar.com
BSD 3-Clause "New" or "Revised" License
5.57k stars 411 forks source link

(WIP) Adding custom extra codecs for decoding requests and encoding responses #570

Closed danigosa closed 6 years ago

danigosa commented 6 years ago

Changes

Motivations

  1. 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
  2. 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.
  3. 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"
)

Stdout:

JSON Response:
HTTP 200 with Content-Type: application/json
{
    "data": {
        "numbers": [
            1,
            2,
            3,
            4
        ],
        "objects": [
            "tommy",
            1,
            "nabo"
        ],
        "uid": "f5a5577f72a2419d8d499f23634704b5",
        "username": "danigosa",
        "words": [
            "tommy",
            "nabo"
        ]
    },
    "now": "2018-05-26T16:17:35.325859+00:00",
    "random_uuid": "99eebad3-c7e6-44f9-b263-b5dd53862900",
    "user": 1
}

MsgPack Response:
HTTP 200 with Content-Type: application/json
{
    "data": {
        "numbers": [
            1,
            2,
            3,
            4
        ],
        "objects": [
            "tommy",
            1,
            "nabo"
        ],
        "uid": "f5a5577f72a2419d8d499f23634704b5",
        "username": "danigosa",
        "words": [
            "tommy",
            "nabo"
        ]
    },
    "now": "2018-05-26T16:17:35.327167+00:00",
    "random_uuid": "e4ca3519-2fcd-4257-ae39-6c60b1d24ba9",
    "user": 1
}
tomchristie commented 6 years ago

Closing this off given that 0.6 is moving to a framework-agnostic suite of API tools, and will no longer include the server. See https://discuss.apistar.org/t/api-star-as-a-framework-independant-tool/614 and #624.