1"""JOSE interfaces."""
2import abc
3import json
4from typing import Any
5
6from josepy import errors
7
8from collections.abc import Sequence, Mapping
9
10# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class
11# pylint: disable=too-few-public-methods
12
13
14class JSONDeSerializable(object, metaclass=abc.ABCMeta):
15    # pylint: disable=too-few-public-methods
16    """Interface for (de)serializable JSON objects.
17
18    Please recall, that standard Python library implements
19    :class:`json.JSONEncoder` and :class:`json.JSONDecoder` that perform
20    translations based on respective :ref:`conversion tables
21    <conversion-table>` that look pretty much like the one below (for
22    complete tables see relevant Python documentation):
23
24    .. _conversion-table:
25
26    ======  ======
27     JSON   Python
28    ======  ======
29    object  dict
30    ...     ...
31    ======  ======
32
33    While the above **conversion table** is about translation of JSON
34    documents to/from the basic Python types only,
35    :class:`JSONDeSerializable` introduces the following two concepts:
36
37      serialization
38        Turning an arbitrary Python object into Python object that can
39        be encoded into a JSON document. **Full serialization** produces
40        a Python object composed of only basic types as required by the
41        :ref:`conversion table <conversion-table>`. **Partial
42        serialization** (accomplished by :meth:`to_partial_json`)
43        produces a Python object that might also be built from other
44        :class:`JSONDeSerializable` objects.
45
46      deserialization
47        Turning a decoded Python object (necessarily one of the basic
48        types as required by the :ref:`conversion table
49        <conversion-table>`) into an arbitrary Python object.
50
51    Serialization produces **serialized object** ("partially serialized
52    object" or "fully serialized object" for partial and full
53    serialization respectively) and deserialization produces
54    **deserialized object**, both usually denoted in the source code as
55    ``jobj``.
56
57    Wording in the official Python documentation might be confusing
58    after reading the above, but in the light of those definitions, one
59    can view :meth:`json.JSONDecoder.decode` as decoder and
60    deserializer of basic types, :meth:`json.JSONEncoder.default` as
61    serializer of basic types, :meth:`json.JSONEncoder.encode`  as
62    serializer and encoder of basic types.
63
64    One could extend :mod:`json` to support arbitrary object
65    (de)serialization either by:
66
67      - overriding :meth:`json.JSONDecoder.decode` and
68        :meth:`json.JSONEncoder.default` in subclasses
69
70      - or passing ``object_hook`` argument (or ``object_hook_pairs``)
71        to :func:`json.load`/:func:`json.loads` or ``default`` argument
72        for :func:`json.dump`/:func:`json.dumps`.
73
74    Interestingly, ``default`` is required to perform only partial
75    serialization, as :func:`json.dumps` applies ``default``
76    recursively. This is the idea behind making :meth:`to_partial_json`
77    produce only partial serialization, while providing custom
78    :meth:`json_dumps` that dumps with ``default`` set to
79    :meth:`json_dump_default`.
80
81    To make further documentation a bit more concrete, please, consider
82    the following imaginatory implementation example::
83
84      class Foo(JSONDeSerializable):
85          def to_partial_json(self):
86              return 'foo'
87
88          @classmethod
89          def from_json(cls, jobj):
90              return Foo()
91
92      class Bar(JSONDeSerializable):
93          def to_partial_json(self):
94              return [Foo(), Foo()]
95
96          @classmethod
97          def from_json(cls, jobj):
98              return Bar()
99
100    """
101
102    @abc.abstractmethod
103    def to_partial_json(self) -> Any:  # pragma: no cover
104        """Partially serialize.
105
106        Following the example, **partial serialization** means the following::
107
108          assert isinstance(Bar().to_partial_json()[0], Foo)
109          assert isinstance(Bar().to_partial_json()[1], Foo)
110
111          # in particular...
112          assert Bar().to_partial_json() != ['foo', 'foo']
113
114        :raises josepy.errors.SerializationError:
115            in case of any serialization error.
116        :returns: Partially serializable object.
117
118        """
119        raise NotImplementedError()
120
121    def to_json(self) -> Any:
122        """Fully serialize.
123
124        Again, following the example from before, **full serialization**
125        means the following::
126
127          assert Bar().to_json() == ['foo', 'foo']
128
129        :raises josepy.errors.SerializationError:
130            in case of any serialization error.
131        :returns: Fully serialized object.
132
133        """
134        def _serialize(obj: Any) -> Any:
135            if isinstance(obj, JSONDeSerializable):
136                return _serialize(obj.to_partial_json())
137            if isinstance(obj, str):  # strings are Sequence
138                return obj
139            elif isinstance(obj, list):
140                return [_serialize(subobj) for subobj in obj]
141            elif isinstance(obj, Sequence):
142                # default to tuple, otherwise Mapping could get
143                # unhashable list
144                return tuple(_serialize(subobj) for subobj in obj)
145            elif isinstance(obj, Mapping):
146                return {_serialize(key): _serialize(value)
147                        for key, value in obj.items()}
148            else:
149                return obj
150
151        return _serialize(self)
152
153    @classmethod
154    @abc.abstractmethod
155    def from_json(cls, jobj: Any) -> 'JSONDeSerializable':
156        """Deserialize a decoded JSON document.
157
158        :param jobj: Python object, composed of only other basic data
159            types, as decoded from JSON document. Not necessarily
160            :class:`dict` (as decoded from "JSON object" document).
161
162        :raises josepy.errors.DeserializationError:
163            if decoding was unsuccessful, e.g. in case of unparseable
164            X509 certificate, or wrong padding in JOSE base64 encoded
165            string, etc.
166
167        """
168        # TypeError: Can't instantiate abstract class <cls> with
169        # abstract methods from_json, to_partial_json
170        return cls()  # pylint: disable=abstract-class-instantiated
171
172    @classmethod
173    def json_loads(cls, json_string: str) -> 'JSONDeSerializable':
174        """Deserialize from JSON document string."""
175        try:
176            loads = json.loads(json_string)
177        except ValueError as error:
178            raise errors.DeserializationError(error)
179        return cls.from_json(loads)
180
181    def json_dumps(self, **kwargs: Any) -> str:
182        """Dump to JSON string using proper serializer.
183
184        :returns: JSON document string.
185        :rtype: str
186
187        """
188        return json.dumps(self, default=self.json_dump_default, **kwargs)
189
190    def json_dumps_pretty(self) -> str:
191        """Dump the object to pretty JSON document string.
192
193        :rtype: str
194
195        """
196        return self.json_dumps(sort_keys=True, indent=4, separators=(',', ': '))
197
198    @classmethod
199    def json_dump_default(cls, python_object: 'JSONDeSerializable') -> Any:
200        """Serialize Python object.
201
202        This function is meant to be passed as ``default`` to
203        :func:`json.dump` or :func:`json.dumps`. They call
204        ``default(python_object)`` only for non-basic Python types, so
205        this function necessarily raises :class:`TypeError` if
206        ``python_object`` is not an instance of
207        :class:`IJSONSerializable`.
208
209        Please read the class docstring for more information.
210
211        """
212        if isinstance(python_object, JSONDeSerializable):
213            return python_object.to_partial_json()
214        else:  # this branch is necessary, cannot just "return"
215            raise TypeError(repr(python_object) + ' is not JSON serializable')
216