From c9b6aa46d8dfa182658545f467b9112ddb24280c Mon Sep 17 00:00:00 2001 From: Guillaume Gauvrit Date: Sat, 20 Jan 2024 18:50:01 +0100 Subject: [PATCH] Add an API to expose the serializer --- docs/source/develop/service/index.rst | 1 + .../develop/service/request_serializer.rst | 5 ++ docs/source/user/request_serialization.rst | 28 +++++++++- docs/source/user/request_serialization_01.py | 2 +- docs/source/user/request_serialization_02.py | 14 +++++ src/blacksmith/__init__.py | 9 ++++ src/blacksmith/domain/exceptions.py | 10 ++++ src/blacksmith/service/request_serializer.py | 54 +++++++++++++++---- tests/unittests/test_request_serializer.py | 51 +++++++++++++++++- 9 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 docs/source/develop/service/request_serializer.rst create mode 100644 docs/source/user/request_serialization_02.py diff --git a/docs/source/develop/service/index.rst b/docs/source/develop/service/index.rst index af575bef..28b271e2 100644 --- a/docs/source/develop/service/index.rst +++ b/docs/source/develop/service/index.rst @@ -8,3 +8,4 @@ Service base client adapters/httpx + request_serializer diff --git a/docs/source/develop/service/request_serializer.rst b/docs/source/develop/service/request_serializer.rst new file mode 100644 index 00000000..2646fb16 --- /dev/null +++ b/docs/source/develop/service/request_serializer.rst @@ -0,0 +1,5 @@ +Request Serializer +================== + +.. automodule:: blacksmith.service.request_serializer + :members: diff --git a/docs/source/user/request_serialization.rst b/docs/source/user/request_serialization.rst index ecd6dc4e..8d9afa1f 100644 --- a/docs/source/user/request_serialization.rst +++ b/docs/source/user/request_serialization.rst @@ -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``. \ No newline at end of file + ``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 `` + + +.. 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. diff --git a/docs/source/user/request_serialization_01.py b/docs/source/user/request_serialization_01.py index 6b94ad3e..df8e52f2 100644 --- a/docs/source/user/request_serialization_01.py +++ b/docs/source/user/request_serialization_01.py @@ -1,4 +1,4 @@ -from blacksmith import Request, HeaderField, PostBodyField +from blacksmith import HeaderField, PostBodyField, Request class MyFormURLEncodedRequest(Request): diff --git a/docs/source/user/request_serialization_02.py b/docs/source/user/request_serialization_02.py new file mode 100644 index 00000000..d0a4e395 --- /dev/null +++ b/docs/source/user/request_serialization_02.py @@ -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 "" + + +register_request_body_serializer(MySerializer()) diff --git a/src/blacksmith/__init__.py b/src/blacksmith/__init__.py index 6650857a..1184c9fc 100644 --- a/src/blacksmith/__init__.py +++ b/src/blacksmith/__init__.py @@ -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 @@ -148,4 +153,8 @@ "SyncAbstractTransport", "HTTPRequest", "HTTPResponse", + # Serializer, + "AbstractRequestBodySerializer", + "register_request_body_serializer", + "unregister_request_body_serializer", ] diff --git a/src/blacksmith/domain/exceptions.py b/src/blacksmith/domain/exceptions.py index 47a7d1f5..27e6200a 100644 --- a/src/blacksmith/domain/exceptions.py +++ b/src/blacksmith/domain/exceptions.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from .model.http import HTTPRequest, HTTPResponse + from .model.params import Request class ConfigurationError(Exception): @@ -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.""" diff --git a/src/blacksmith/service/request_serializer.py b/src/blacksmith/service/request_serializer.py index 1ba9f234..521f53e9 100644 --- a/src/blacksmith/service/request_serializer.py +++ b/src/blacksmith/service/request_serializer.py @@ -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 @@ -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. @@ -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") @@ -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) @@ -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, @@ -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, @@ -129,24 +134,45 @@ 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( @@ -154,6 +180,16 @@ def serialize_request( 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: {}, diff --git a/tests/unittests/test_request_serializer.py b/tests/unittests/test_request_serializer.py index 5349d02c..c6c637d9 100644 --- a/tests/unittests/test_request_serializer.py +++ b/tests/unittests/test_request_serializer.py @@ -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 @@ -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, ) @@ -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 "" + + 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 == "" + + 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 " + )