215 lines
7.6 KiB
Python
215 lines
7.6 KiB
Python
"""JOSE interfaces."""
|
|
|
|
import abc
|
|
import json
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Any, Type, TypeVar, Union
|
|
|
|
from josepy import errors
|
|
|
|
GenericJSONDeSerializable = TypeVar("GenericJSONDeSerializable", bound="JSONDeSerializable")
|
|
|
|
|
|
class JSONDeSerializable(metaclass=abc.ABCMeta):
|
|
"""Interface for (de)serializable JSON objects.
|
|
|
|
Please recall, that standard Python library implements
|
|
:class:`json.JSONEncoder` and :class:`json.JSONDecoder` that perform
|
|
translations based on respective :ref:`conversion tables
|
|
<conversion-table>` that look pretty much like the one below (for
|
|
complete tables see relevant Python documentation):
|
|
|
|
.. _conversion-table:
|
|
|
|
====== ======
|
|
JSON Python
|
|
====== ======
|
|
object dict
|
|
... ...
|
|
====== ======
|
|
|
|
While the above **conversion table** is about translation of JSON
|
|
documents to/from the basic Python types only,
|
|
:class:`JSONDeSerializable` introduces the following two concepts:
|
|
|
|
serialization
|
|
Turning an arbitrary Python object into Python object that can
|
|
be encoded into a JSON document. **Full serialization** produces
|
|
a Python object composed of only basic types as required by the
|
|
:ref:`conversion table <conversion-table>`. **Partial
|
|
serialization** (accomplished by :meth:`to_partial_json`)
|
|
produces a Python object that might also be built from other
|
|
:class:`JSONDeSerializable` objects.
|
|
|
|
deserialization
|
|
Turning a decoded Python object (necessarily one of the basic
|
|
types as required by the :ref:`conversion table
|
|
<conversion-table>`) into an arbitrary Python object.
|
|
|
|
Serialization produces **serialized object** ("partially serialized
|
|
object" or "fully serialized object" for partial and full
|
|
serialization respectively) and deserialization produces
|
|
**deserialized object**, both usually denoted in the source code as
|
|
``jobj``.
|
|
|
|
Wording in the official Python documentation might be confusing
|
|
after reading the above, but in the light of those definitions, one
|
|
can view :meth:`json.JSONDecoder.decode` as decoder and
|
|
deserializer of basic types, :meth:`json.JSONEncoder.default` as
|
|
serializer of basic types, :meth:`json.JSONEncoder.encode` as
|
|
serializer and encoder of basic types.
|
|
|
|
One could extend :mod:`json` to support arbitrary object
|
|
(de)serialization either by:
|
|
|
|
- overriding :meth:`json.JSONDecoder.decode` and
|
|
:meth:`json.JSONEncoder.default` in subclasses
|
|
|
|
- or passing ``object_hook`` argument (or ``object_hook_pairs``)
|
|
to :func:`json.load`/:func:`json.loads` or ``default`` argument
|
|
for :func:`json.dump`/:func:`json.dumps`.
|
|
|
|
Interestingly, ``default`` is required to perform only partial
|
|
serialization, as :func:`json.dumps` applies ``default``
|
|
recursively. This is the idea behind making :meth:`to_partial_json`
|
|
produce only partial serialization, while providing custom
|
|
:meth:`json_dumps` that dumps with ``default`` set to
|
|
:meth:`json_dump_default`.
|
|
|
|
To make further documentation a bit more concrete, please, consider
|
|
the following imaginatory implementation example::
|
|
|
|
class Foo(JSONDeSerializable):
|
|
def to_partial_json(self):
|
|
return 'foo'
|
|
|
|
@classmethod
|
|
def from_json(cls, jobj):
|
|
return Foo()
|
|
|
|
class Bar(JSONDeSerializable):
|
|
def to_partial_json(self):
|
|
return [Foo(), Foo()]
|
|
|
|
@classmethod
|
|
def from_json(cls, jobj):
|
|
return Bar()
|
|
|
|
"""
|
|
|
|
@abc.abstractmethod
|
|
def to_partial_json(self) -> Any: # pragma: no cover
|
|
"""Partially serialize.
|
|
|
|
Following the example, **partial serialization** means the following::
|
|
|
|
assert isinstance(Bar().to_partial_json()[0], Foo)
|
|
assert isinstance(Bar().to_partial_json()[1], Foo)
|
|
|
|
# in particular...
|
|
assert Bar().to_partial_json() != ['foo', 'foo']
|
|
|
|
:raises josepy.errors.SerializationError:
|
|
in case of any serialization error.
|
|
:returns: Partially serializable object.
|
|
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def to_json(self) -> Any:
|
|
"""Fully serialize.
|
|
|
|
Again, following the example from before, **full serialization**
|
|
means the following::
|
|
|
|
assert Bar().to_json() == ['foo', 'foo']
|
|
|
|
:raises josepy.errors.SerializationError:
|
|
in case of any serialization error.
|
|
:returns: Fully serialized object.
|
|
|
|
"""
|
|
|
|
def _serialize(obj: Any) -> Any:
|
|
if isinstance(obj, JSONDeSerializable):
|
|
return _serialize(obj.to_partial_json())
|
|
if isinstance(obj, str): # strings are Sequence
|
|
return obj
|
|
elif isinstance(obj, list):
|
|
return [_serialize(subobj) for subobj in obj]
|
|
elif isinstance(obj, Sequence):
|
|
# default to tuple, otherwise Mapping could get
|
|
# unhashable list
|
|
return tuple(_serialize(subobj) for subobj in obj)
|
|
elif isinstance(obj, Mapping):
|
|
return {_serialize(key): _serialize(value) for key, value in obj.items()}
|
|
else:
|
|
return obj
|
|
|
|
return _serialize(self)
|
|
|
|
@classmethod
|
|
@abc.abstractmethod
|
|
def from_json(cls: Type[GenericJSONDeSerializable], jobj: Any) -> GenericJSONDeSerializable:
|
|
"""Deserialize a decoded JSON document.
|
|
|
|
:param jobj: Python object, composed of only other basic data
|
|
types, as decoded from JSON document. Not necessarily
|
|
:class:`dict` (as decoded from "JSON object" document).
|
|
|
|
:raises josepy.errors.DeserializationError:
|
|
if decoding was unsuccessful, e.g. in case of unparseable
|
|
X509 certificate, or wrong padding in JOSE base64 encoded
|
|
string, etc.
|
|
|
|
"""
|
|
# TypeError: Can't instantiate abstract class <cls> with
|
|
# abstract methods from_json, to_partial_json
|
|
return cls()
|
|
|
|
@classmethod
|
|
def json_loads(
|
|
cls: Type[GenericJSONDeSerializable], json_string: Union[str, bytes]
|
|
) -> GenericJSONDeSerializable:
|
|
"""Deserialize from JSON document string."""
|
|
try:
|
|
loads = json.loads(json_string)
|
|
except ValueError as error:
|
|
raise errors.DeserializationError(error)
|
|
return cls.from_json(loads)
|
|
|
|
def json_dumps(self, **kwargs: Any) -> str:
|
|
"""Dump to JSON string using proper serializer.
|
|
|
|
:returns: JSON document string.
|
|
:rtype: str
|
|
|
|
"""
|
|
return json.dumps(self, default=self.json_dump_default, **kwargs)
|
|
|
|
def json_dumps_pretty(self) -> str:
|
|
"""Dump the object to pretty JSON document string.
|
|
|
|
:rtype: str
|
|
|
|
"""
|
|
return self.json_dumps(sort_keys=True, indent=4, separators=(",", ": "))
|
|
|
|
@classmethod
|
|
def json_dump_default(cls, python_object: "JSONDeSerializable") -> Any:
|
|
"""Serialize Python object.
|
|
|
|
This function is meant to be passed as ``default`` to
|
|
:func:`json.dump` or :func:`json.dumps`. They call
|
|
``default(python_object)`` only for non-basic Python types, so
|
|
this function necessarily raises :class:`TypeError` if
|
|
``python_object`` is not an instance of
|
|
:class:`IJSONSerializable`.
|
|
|
|
Please read the class docstring for more information.
|
|
|
|
"""
|
|
if isinstance(python_object, JSONDeSerializable):
|
|
return python_object.to_partial_json()
|
|
else: # this branch is necessary, cannot just "return"
|
|
raise TypeError(repr(python_object) + " is not JSON serializable")
|