Skip to content

Commit

Permalink
Add an API to expose the serializer
Browse files Browse the repository at this point in the history
  • Loading branch information
mardiros committed Jan 20, 2024
1 parent fad5830 commit c9b6aa4
Show file tree
Hide file tree
Showing 9 changed files with 162 additions and 12 deletions.
1 change: 1 addition & 0 deletions docs/source/develop/service/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ Service
base
client
adapters/httpx
request_serializer
5 changes: 5 additions & 0 deletions docs/source/develop/service/request_serializer.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Request Serializer
==================

.. automodule:: blacksmith.service.request_serializer
:members:
28 changes: 27 additions & 1 deletion docs/source/user/request_serialization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,30 @@ serialized to

You may also note that the embeded urlencoded form version only supports flat
structure, as is just a wrapper around the standar library function
``urllib.parse.urlencode``.
``urllib.parse.urlencode``.



Registering serializer
----------------------

For extensibility, Blacksmith expose serialization are not a per client feature,
serializers are globals and register unsing the function
:func:`blacksmith.register_request_body_serializer`.

When a method serializer is added, it will have the highest priority of all the
serializers. You may use it to override the default Blacksmith serializer.

Now its time to add a dummy serializer.

.. literalinclude:: request_serialization_02.py

Now, if a request contains a `Content-Type` `text/xml+dummy` it will be serialized using
that serializer, and the body will always be `<foo/>`


.. important::

If a request receive a ``Content-Type`` that is not handled by anyu serializer,
an runtime exception ``UnregisteredContentTypeException`` will be raised during
the serialization.
2 changes: 1 addition & 1 deletion docs/source/user/request_serialization_01.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from blacksmith import Request, HeaderField, PostBodyField
from blacksmith import HeaderField, PostBodyField, Request


class MyFormURLEncodedRequest(Request):
Expand Down
14 changes: 14 additions & 0 deletions docs/source/user/request_serialization_02.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import Any, Dict, Sequence, Union

from blacksmith import AbstractRequestBodySerializer, register_request_body_serializer


class MySerializer(AbstractRequestBodySerializer):
def accept(self, content_type: str) -> bool:
return content_type == "text/xml+dummy"

def serialize(self, body: Union[Dict[str, Any], Sequence[Any]]) -> str:
return "<foo/>"


register_request_body_serializer(MySerializer())
9 changes: 9 additions & 0 deletions src/blacksmith/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@
from .service._sync.base import SyncAbstractTransport
from .service._sync.client import SyncClient, SyncClientFactory
from .service._sync.route_proxy import SyncRouteProxy
from .service.request_serializer import (
AbstractRequestBodySerializer,
register_request_body_serializer,
unregister_request_body_serializer,
)

__all__ = [
# Ordered for the doc
Expand Down Expand Up @@ -148,4 +153,8 @@
"SyncAbstractTransport",
"HTTPRequest",
"HTTPResponse",
# Serializer,
"AbstractRequestBodySerializer",
"register_request_body_serializer",
"unregister_request_body_serializer",
]
10 changes: 10 additions & 0 deletions src/blacksmith/domain/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

if TYPE_CHECKING:
from .model.http import HTTPRequest, HTTPResponse
from .model.params import Request


class ConfigurationError(Exception):
Expand Down Expand Up @@ -71,6 +72,15 @@ def __init__(
)


class UnregisteredContentTypeException(RuntimeError):
"""Raised when an unregistered contract is being requested."""

def __init__(self, content_type: str, request: "Request") -> None:
super().__init__(
f"Unregistered content type '{content_type}' in request <{request}>"
)


class NoResponseSchemaException(RuntimeError):
"""Raised when an unregistered response schema is being requested."""

Expand Down
54 changes: 45 additions & 9 deletions src/blacksmith/service/request_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pydantic import BaseModel, SecretBytes, SecretStr
from pydantic.fields import FieldInfo

from blacksmith.domain.exceptions import UnregisteredContentTypeException
from blacksmith.domain.model.http import HTTPRequest
from blacksmith.domain.model.params import Request
from blacksmith.typing import HttpLocation, HTTPMethod, Url
Expand Down Expand Up @@ -49,7 +50,7 @@ def accept(self, content_type: str) -> bool:
"""Return true in case it can handle the request."""

@abc.abstractmethod
def serialize(self, body: Dict[str, Any] | Sequence[Any]) -> str:
def serialize(self, body: Union[Dict[str, Any], Sequence[Any]]) -> str:
"""
Serialize a python simple types to a python request body.
Expand All @@ -58,6 +59,8 @@ def serialize(self, body: Dict[str, Any] | Sequence[Any]) -> str:


class JsonRequestSerializer(AbstractRequestBodySerializer):
"""The default serializer that serialize to json"""

def accept(self, content_type: str) -> bool:
return content_type.startswith("application/json")

Expand All @@ -66,10 +69,12 @@ def serialize(self, body: Union[Dict[str, Any], Sequence[Any]]) -> str:


class UrlencodedRequestSerializer(AbstractRequestBodySerializer):
"""A serializer for application/x-www-form-urlencoded request."""

def accept(self, content_type: str) -> bool:
return content_type == "application/x-www-form-urlencoded"

def serialize(self, body: Dict[str, Any] | Sequence[Any]) -> str:
def serialize(self, body: Union[Dict[str, Any], Sequence[Any]]) -> str:
return urlencode(body, doseq=True)


Expand Down Expand Up @@ -108,7 +113,7 @@ def serialize_part(req: "Request", part: Dict[IntStr, Any]) -> Dict[str, simplet
return {
**{
k: get_value(v)
for k, v in req.dict(
for k, v in req.dict( # pydantic 1
include=part,
by_alias=True,
exclude_none=True,
Expand All @@ -118,7 +123,7 @@ def serialize_part(req: "Request", part: Dict[IntStr, Any]) -> Dict[str, simplet
},
**{
k: get_value(v)
for k, v in req.dict(
for k, v in req.dict( # pydantic 1
include=part,
by_alias=True,
exclude_none=False,
Expand All @@ -129,31 +134,62 @@ def serialize_part(req: "Request", part: Dict[IntStr, Any]) -> Dict[str, simplet
}


SERIALIZERS: List[AbstractRequestBodySerializer] = [
_SERIALIZERS: List[AbstractRequestBodySerializer] = [
JsonRequestSerializer(),
UrlencodedRequestSerializer(),
]
"""Serializers used to serialize request body."""


def register_request_body_serializer(serializer: AbstractRequestBodySerializer) -> None:
"""Register a serializer to serialize some kind of request."""
_SERIALIZERS.insert(0, serializer)


def unregister_request_body_serializer(
serializer: AbstractRequestBodySerializer,
) -> None:
"""
Unregister a serializer previously added.
Usefull for testing purpose.
"""
_SERIALIZERS.remove(serializer)


def serialize_body(
req: "Request", body: Dict[str, str], content_type: Optional[str] = None
) -> str:
"""Serialize the body of the request. In case there is some."""
"""
Serialize the body of the request.
Note that the content_type is optional, but if it is set,
the request will contains
"""
if not body and not content_type:
return ""
content_type = content_type or "application/json"
for serializer in SERIALIZERS:
for serializer in _SERIALIZERS:
if serializer.accept(content_type):
return serializer.serialize(serialize_part(req, body))
return ""
raise UnregisteredContentTypeException(content_type, req)


def serialize_request(
method: HTTPMethod,
url_pattern: Url,
request_model: Request,
) -> HTTPRequest:
"""
Serialize :class:`blacksmith.Request` subclasses to :class:`blacksmith.HTTPRequest`.
While processing an http request, the request models are serialize to an
intermediate object :class:`blacksmith.HTTPRequest`, that will be passed over
middleware and finally to the transport in order to build the final http request.
Note that the body of the :class:`blacksmith.HTTPRequest` is a string, here,
serialized by a registered serializer.
"""
req = HTTPRequest(method=method, url_pattern=url_pattern)
fields_by_loc: Dict[HttpLocation, Dict[IntStr, Any]] = {
HEADER: {},
Expand Down
51 changes: 50 additions & 1 deletion tests/unittests/test_request_serializer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
from datetime import datetime
from typing import Any, Mapping, Optional
from typing import Any, Dict, Mapping, Optional, Sequence, Union

import pytest
from pydantic import SecretStr
Expand All @@ -13,15 +13,19 @@
QueryStringField,
Request,
)
from blacksmith.domain.exceptions import UnregisteredContentTypeException
from blacksmith.service.request_serializer import (
QUERY,
AbstractRequestBodySerializer,
JSONEncoder,
JsonRequestSerializer,
UrlencodedRequestSerializer,
get_location,
register_request_body_serializer,
serialize_body,
serialize_part,
serialize_request,
unregister_request_body_serializer,
)


Expand Down Expand Up @@ -276,3 +280,48 @@ def test_request_serializer_accept(params: Mapping[str, Any]):
def test_request_serializer_serialize(params: Mapping[str, Any]):
ret = params["srlz"].serialize(params["data"])
assert ret == params["expected"]


def test_register_serializer():
class MySerializer(AbstractRequestBodySerializer):
def accept(self, content_type: str) -> bool:
return content_type == "text/xml"

def serialize(self, body: Union[Dict[str, Any], Sequence[Any]]) -> str:
return "<foo/>"

srlz = MySerializer()
register_request_body_serializer(srlz)

class DummyPostRequestXML(Request):
foo: str = PostBodyField()
content_type: str = HeaderField(default="text/xml", alias="Content-Type")

httpreq = serialize_request(
"POST",
"/",
DummyPostRequestXML(foo="bar"),
)

assert httpreq.body == "<foo/>"

httpreq = serialize_request(
"POST",
"/",
DummyPostRequestXML(foo="bar", **{"Content-Type": "application/json"}),
)

assert httpreq.body == '{"foo": "bar"}'

unregister_request_body_serializer(srlz)

with pytest.raises(UnregisteredContentTypeException) as ctx:
serialize_request(
"POST",
"/",
DummyPostRequestXML(foo="bar"),
)
assert (
str(ctx.value) == "Unregistered content type 'text/xml' in request <foo='bar' "
"content_type='text/xml'>"
)

0 comments on commit c9b6aa4

Please sign in to comment.