|
| 1 | +# Copyright (c) Microsoft Corporation. |
| 2 | +# Licensed under the MIT License. |
| 3 | + |
| 4 | +"""Internal JSON codec for Durable Task payloads. |
| 5 | +
|
| 6 | +This module holds the low-level serialization *mechanism* -- the JSON string |
| 7 | +encode/decode primitives and the value-level type coercion used to reconstruct |
| 8 | +custom objects. Serialization *policy* (the public, pluggable strategy) lives in |
| 9 | +:mod:`durabletask.serialization`; the default ``JsonDataConverter`` is the only |
| 10 | +production consumer of ``to_json`` / ``from_json``, while ``coerce_to_type`` is |
| 11 | +also used directly by entity state accessors that already hold a parsed value. |
| 12 | +""" |
| 13 | + |
| 14 | +from __future__ import annotations |
| 15 | + |
| 16 | +import dataclasses |
| 17 | +import json |
| 18 | +import types |
| 19 | +import typing |
| 20 | +from collections.abc import Sequence |
| 21 | +from types import SimpleNamespace |
| 22 | +from typing import Any, cast |
| 23 | + |
| 24 | +# Marker formerly added to JSON payloads to flag objects for automatic |
| 25 | +# deserialization into a SimpleNamespace. New code no longer emits this marker |
| 26 | +# (objects are serialized as plain JSON), but the decoder still recognizes it so |
| 27 | +# that orchestration histories produced by older SDK versions continue to replay. |
| 28 | +AUTO_SERIALIZED = "__durabletask_autoobject__" |
| 29 | + |
| 30 | + |
| 31 | +def to_json(obj: Any) -> str: |
| 32 | + """Serialize a value to a JSON string. |
| 33 | +
|
| 34 | + Builtins serialize to plain JSON. Dataclasses, ``SimpleNamespace`` |
| 35 | + instances, and objects exposing a ``to_json()`` method are serialized to |
| 36 | + plain JSON as well (without any type marker); custom objects can be |
| 37 | + reconstructed on the receiving side by passing ``expected_type`` to |
| 38 | + :func:`from_json`. |
| 39 | + """ |
| 40 | + try: |
| 41 | + return json.dumps(obj, default=_encode_custom_object) |
| 42 | + except TypeError as e: |
| 43 | + # Preserve the original error as the cause so serialization failures are |
| 44 | + # easier to diagnose, while naming the offending top-level type. |
| 45 | + raise TypeError( |
| 46 | + f"Failed to serialize object of type '{type(obj).__name__}' to JSON: {e}" |
| 47 | + ) from e |
| 48 | + |
| 49 | + |
| 50 | +def from_json(json_str: str | bytes | bytearray, expected_type: type | None = None) -> Any: |
| 51 | + """Deserialize a JSON string, optionally coercing the result to a type. |
| 52 | +
|
| 53 | + When ``expected_type`` is ``None`` (the default) the raw parsed JSON is |
| 54 | + returned. For backwards compatibility, payloads carrying the legacy |
| 55 | + :data:`AUTO_SERIALIZED` marker are reconstructed as ``SimpleNamespace`` |
| 56 | + instances so that in-flight orchestrations produced by older SDK versions |
| 57 | + continue to replay. |
| 58 | +
|
| 59 | + When ``expected_type`` is provided, the legacy marker (if present) is |
| 60 | + stripped and the parsed value is coerced to ``expected_type`` -- dataclasses |
| 61 | + are constructed from their dict payloads, types exposing a ``from_json()`` |
| 62 | + classmethod are reconstructed via that hook, and ``Optional``/``Union`` and |
| 63 | + ``list`` type hints are honored recursively. The destination type is always |
| 64 | + supplied by the caller; it is never read from the payload. |
| 65 | + """ |
| 66 | + if expected_type is None: |
| 67 | + return json.loads(json_str, object_hook=_legacy_object_hook) |
| 68 | + raw = json.loads(json_str, object_hook=_strip_legacy_marker) |
| 69 | + return coerce_to_type(raw, expected_type) |
| 70 | + |
| 71 | + |
| 72 | +def _encode_custom_object(o: Any) -> Any: |
| 73 | + """``default`` hook for :func:`json.dumps` that emits plain JSON. |
| 74 | +
|
| 75 | + Called only for values the JSON encoder cannot natively serialize. Note that |
| 76 | + namedtuples are handled natively by the encoder (serialized as JSON arrays) |
| 77 | + and never reach this hook. |
| 78 | + """ |
| 79 | + if dataclasses.is_dataclass(o) and not isinstance(o, type): |
| 80 | + return dataclasses.asdict(o) |
| 81 | + if isinstance(o, SimpleNamespace): |
| 82 | + return vars(o) |
| 83 | + # Custom objects may opt in via a ``to_json`` hook. It is resolved off the |
| 84 | + # type and called with the instance (``type(o).to_json(o)``) so that both |
| 85 | + # instance methods and ``@staticmethod`` hooks work -- matching the calling |
| 86 | + # convention used by ``azure-functions-durable``. The hook returns a |
| 87 | + # JSON-serializable value (a structure or a string), not a JSON document. |
| 88 | + to_json_hook = getattr(cast(Any, type(o)), "to_json", None) |
| 89 | + if callable(to_json_hook): |
| 90 | + return to_json_hook(o) |
| 91 | + # This will raise a TypeError describing the unsupported type. |
| 92 | + raise TypeError(f"Object of type '{type(o).__name__}' is not JSON serializable") |
| 93 | + |
| 94 | + |
| 95 | +def _legacy_object_hook(d: dict[str, Any]) -> Any: |
| 96 | + # If the object carries the legacy marker, deserialize it as a SimpleNamespace. |
| 97 | + if d.pop(AUTO_SERIALIZED, False): |
| 98 | + return SimpleNamespace(**d) |
| 99 | + return d |
| 100 | + |
| 101 | + |
| 102 | +def _strip_legacy_marker(d: dict[str, Any]) -> dict[str, Any]: |
| 103 | + # Discard the legacy marker so typed coercion sees a plain dict. |
| 104 | + d.pop(AUTO_SERIALIZED, None) |
| 105 | + return d |
| 106 | + |
| 107 | + |
| 108 | +def coerce_to_type(value: Any, expected_type: Any) -> Any: |
| 109 | + """Coerce an already-parsed JSON value to ``expected_type``. |
| 110 | +
|
| 111 | + Handles ``None``/``Optional``/``Union`` and ``list`` type hints recursively, |
| 112 | + types exposing a ``from_json()`` classmethod, and dataclasses (including |
| 113 | + nested dataclass fields). The destination type is always caller-supplied and |
| 114 | + never derived from the payload, keeping deserialization secure. |
| 115 | + """ |
| 116 | + if expected_type is None or value is None: |
| 117 | + return value |
| 118 | + |
| 119 | + origin = typing.get_origin(expected_type) |
| 120 | + if origin is not None: |
| 121 | + return _coerce_generic(value, expected_type, origin) |
| 122 | + |
| 123 | + if not isinstance(expected_type, type): |
| 124 | + # Not a concrete, instantiable type (e.g. a typing special form we don't |
| 125 | + # special-case) -- return the value unchanged. |
| 126 | + return value |
| 127 | + |
| 128 | + if isinstance(value, expected_type): |
| 129 | + return value |
| 130 | + |
| 131 | + from_json_hook = getattr(expected_type, "from_json", None) |
| 132 | + if callable(from_json_hook): |
| 133 | + return from_json_hook(value) |
| 134 | + |
| 135 | + if dataclasses.is_dataclass(expected_type) and isinstance(value, dict): |
| 136 | + return _build_dataclass(expected_type, cast(dict[str, Any], value)) |
| 137 | + |
| 138 | + type_ctor = cast(Any, expected_type) |
| 139 | + try: |
| 140 | + return type_ctor(value) |
| 141 | + except Exception as e: |
| 142 | + type_name = getattr(type_ctor, "__name__", None) or str(type_ctor) |
| 143 | + raise TypeError( |
| 144 | + f"Could not coerce value of type '{type(value).__name__}' to " |
| 145 | + f"'{type_name}'" |
| 146 | + ) from e |
| 147 | + |
| 148 | + |
| 149 | +def _coerce_generic(value: Any, expected_type: Any, origin: Any) -> Any: |
| 150 | + args = typing.get_args(expected_type) |
| 151 | + if origin is typing.Union or origin is types.UnionType: |
| 152 | + # If the value already matches a member type, keep it as-is. |
| 153 | + non_none = [a for a in args if a is not type(None)] |
| 154 | + for arg in non_none: |
| 155 | + if isinstance(arg, type) and isinstance(value, arg): |
| 156 | + return value |
| 157 | + # ``Optional[T]`` (exactly one non-None member): coerce to that member. |
| 158 | + # For a genuine multi-member ``Union`` where the value matched none of |
| 159 | + # the members, leave it untouched rather than guessing the first arg -- |
| 160 | + # forcing a coercion there can silently mis-construct the wrong type. |
| 161 | + if len(non_none) == 1: |
| 162 | + return coerce_to_type(value, non_none[0]) |
| 163 | + return value |
| 164 | + if origin in (list, Sequence) and isinstance(value, list): |
| 165 | + elem_type = args[0] if args else None |
| 166 | + return [coerce_to_type(item, elem_type) for item in cast(list[Any], value)] |
| 167 | + # Other generics (dict, tuple, ...) are returned as parsed JSON. |
| 168 | + return value |
| 169 | + |
| 170 | + |
| 171 | +def _build_dataclass(cls: Any, data: dict[str, Any]) -> Any: |
| 172 | + """Construct a dataclass from its dict payload, recursing into typed fields.""" |
| 173 | + try: |
| 174 | + hints = typing.get_type_hints(cls) |
| 175 | + except Exception: |
| 176 | + hints = {} |
| 177 | + kwargs: dict[str, Any] = {} |
| 178 | + for field in dataclasses.fields(cls): |
| 179 | + if field.name not in data: |
| 180 | + continue |
| 181 | + field_type = hints.get(field.name) |
| 182 | + kwargs[field.name] = coerce_to_type(data[field.name], field_type) |
| 183 | + return cls(**kwargs) |
0 commit comments