Functionality

One-to-one JSON property to object property mapping

Model:

class Person:
    def __init__(self):
        self.name = "Bob Smith"

JSON:

{
    "full_name": "<person.name>"
}

To define that: The JSON "full_name" property is set from the object's "name" property. The object's "name" property is set from the JSON's "full_name" property.

mapping_schema = [
    JsonPropertyMapping("full_name", "name")
]

Build classes that can serialize/deserialize Person instances:

PersonJSONEncoder = MappingJSONEncoderClassBuilder(Person, mapping_schema).build()
PersonJSONDecoder = MappingJSONDecoderClassBuilder(Person, mapping_schema).build()

Serialize/deserialize instance of Person using Python's inbuilt json.dumps and json.loads:

person_as_json = json.dumps(person, cls=PersonJSONEncoder)
person = json.loads("<person_as_json>", cls=PersonJSONDecoder)

Arbitrary mapping to JSON property value

Model:

class Person:
    def __init__(self):
        self.name = "Bob Smith"

    def get_first_name(self) -> str:
        return self.name.split(" ")[0]

    def get_family_name(self) -> str:
        return self.name.split(" ")[1]

JSON:

{
    "first_name": "<person.get_first_name()>",
    "family_name": "<person.get_family_name()>"
}

To define that: Serialization to the JSON "first_name" property value uses the object's get_first_name method. Serialization to the JSON "family_name" property value uses the object's get_family_name method.

mapping_schema = [
    JsonPropertyMapping("first_name", object_property_getter=lambda person: person.get_first_name()),
    JsonPropertyMapping("family_name", object_property_getter=lambda person: person.get_family_name())
]

See next section for how to do the reverse mapping.

Arbitrary mapping to object property value

Model:

class Person:
    def __init__(self):
        self.name = None

JSON:

{
    "first_name": "<person.name.split(' ')[0]>",
    "family_name": "<person.name.split(' ')[1]>"
}

To define that: * The object's name property value is derived from the value of both the "first_name" and "family name" JSON property values:

mapping_schema = [
    JsonPropertyMapping("name", json_property_getter=lambda obj_as_dict: "%s %s" % (obj_as_dict["first_name"],
                                                                                    obj_as_dict["family_name"]))
]

Deserializing objects with constructors parameters

Model:

class Person:
    def __init__(self, constructor_name: str):
        self.name = name

JSON:

{
    "full_name": "<person.name>"
}

To define that: Deserialization requires the value of the JSON "full_name" property be binded to the "constructor_name" parameter in the constructor. Serialization to the JSON "full_name" property value uses the object's "name" property value.

mapping_schema = [
    JsonPropertyMapping("full_name", "name", object_constructor_parameter_name="constructor_name")
]

If further modification of the decoded value is needed, object_constructor_argument_modifier can be set as a function that takes the value retrieved by json_property_getter after it is decoded by using the decoder_cls JSON encoder and returns the value that is binded to the constructor parameter.

Deserializing objects with mutators

Model:

class Person:
    def __init__(self):
        self._name = None

    def set_name(name: str):
        self._name = name

JSON:

{
    "full_name": "<person._name>"
}

To define that: * Deserialization requires the private "_name" property be set via the "set_name" mutator from the "full_name" JSON property.

mapping_schema = [
    JsonPropertyMapping("full_name", object_property_setter=lambda person, name: person.set_name(name))
]

Conditionally optional JSON properties

Model:

class Person:
    def __init__(self):
        self.name = name

JSON:

// if person.name is not None:
{
    "full_name": "<person.name>"
}
// else:
{
    // No `full_name` property
}

To define that: * JSON representation should only include the "full_name" if it is not None (e.g. to reduce the size of the JSON).

def add_name_to_json_if_not_none(json_as_dict: dict, name: Optional[str]):
    if name is not None:
        json_as_dict["full_name"] = name

mapping_schema = [JsonPropertyMapping("full_name", json_property_setter=add_name_to_json_if_not_none)]

Inheritance

Models:

class Identifiable:
    def __init__(self):
        self.id = None

class Named:
    def __init__(self):
        self.name = None

class Employee(Identifiable, Named):
    def __init__(self):
        super().__init__()
        self.title = None

JSON:

{
    "identifier": "<employee.id>",
    "full_name": "<employee.name>",
    "job_title": "<employee.title>"
}

To define that: * Serialization of Employee should "extend" the way Named and Identifiable are serialized.

identifiable_mapping_schema = [JsonPropertyMapping("identifier", "id")]
named_mapping_schema = [JsonPropertyMapping("full_name", "name")]
employee_mapping_schema = [JsonPropertyMapping("job_title", "title")]

IdentifiableJSONEncoder = MappingJSONEncoderClassBuilder(Identifiable, identifiable_mapping_schema).build()
NamedJSONEncoder = MappingJSONEncoderClassBuilder(Named, named_mapping_schema).build()
EmployeeJSONEncoder = MappingJSONEncoderClassBuilder(Employee, employee_mapping_schema, (IdentifiableJSONEncoder, NamedJSONEncoder)).build()

IdentifiableJSONDecoder = MappingJSONDecoderClassBuilder(Identifiable, identifiable_mapping_schema).build()
NamedJSONDecoder = MappingJSONDecoderClassBuilder(Named, named_mapping_schema).build()
EmployeeJSONDecoder = MappingJSONDecoderClassBuilder(Employee, employee_mapping_schema, (IdentifiableJSONDecoder, NamedJSONDecoder)).build()

Note: Each value mapping can be "overriden" by encoders used afterwards. Mappings of properties for superclasses are done before those defined in the current class; the mappings for superclasses are completed in the order defined by the tuple. e.g. In the example above, the mappings defined in IdentifiableJSONDecoder are applied first, then those in NamedJSONDecoder, followed by those in EmployeeJSONDecoder. If EmployeeJSONDecoder redefined a mapping for the id object property, the value for this property would first be written by the mapper from IdentifiableJSONDecoder before been overwritten by a mapper defined in EmployeeJSONDecoder.

For obvious reasons, the mappings to constructor parameters defined in superclasses are not used.

Nested complex objects

Model:

class Person:
    def __init__(self):
        self.name = None

class Team:
    def __init__(self):
        self.moto = None
        self.people = []    # type: List[Person]

JSON:

{
    "team_moto": "<team.moto>",
    "members": "<[person in team.people]>"
}

To define that: * Person instances, nested inside Employee objects, should be serialized and deserialized by specific encoder and decoders.

employee_mapping_schema = [
    JsonPropertyMapping("team_moto", "moto"),
    JsonPropertyMapping("members", "people", encoder_cls=PersonJSONEncoder, decoder_cls=PersonJSONDecoder)
]

If a property is encoded/decoded by the same encoder/decoder that is currently being defined, the type should be returned by a function to work around the scoping problem, e.g.:

class Person:
   def __init__(self):
       self.nemesis = None

PersonJSONEncoder = MappingJSONEncoderClassBuilder(Person, [
    JsonPropertyMapping("nemesis", "nemesis", encoder_cls=lambda: PersonJSONEncoder)
]).build()

One-way mappings

Contrived example warning...

Model:

class Person:
    def __init__(self):
        self.name = None
        self.age = None

JSON input:

{
    "full_name": "<person.name>"
}

JSON output:

{
    "age": "<person.age>"
}

To define that: Serialization should ignore the object's "name" property. Deserialization only with the JSON's "full_name" property.

mapping_schema = [
    JsonPropertyMapping(
        json_property_getter=lambda json_as_dict: json_as_dict["full_name"],
        object_property_setter=lambda person, name: person.set_name(name)
    ),
    JsonPropertyMapping(
        json_property_setter=lambda json_as_dict, age: json_as_dict.__setitem__("age", age),
        object_property_getter=lambda person: person.age
    )
]

Casting JSON primitives

To help with casting JSON primitives, the following decoders/encoders are provided:

  • StrJSONEncoder: serializes value to a string (e.g. object property=123 -> JSON property="123").
  • StrJSONDecoder: deserializes value as a string (e.g. JSON property=123 -> object property="123").
  • IntJSONEncoder: serializes value to an int (e.g. object property="123" -> JSON property=123).
  • IntJSONDecoder: deserializes value as an int (e.g. JSON property="123" -> object property=123).
  • FloatJSONEncoder: serializes value to a float (e.g. object property="123.5" -> JSON property=123.5).
  • FloatJSONDecoder: deserializes value as an float (e.g. JSON property="12.3" -> object property=12.3).
  • DatetimeEpochJSONEncoder: serializes datetime to epoch, truncated to seconds (e.g. object property=datetime(1970, 1, 1, tzinfo=timezone.utc) -> JSON property=0).
  • DatetimeEpochJSONDecoder: deserializes datetime as epoch (e.g. JSON property=0 -> object property=datetime(1970, 1, 1, tzinfo=timezone.utc)).
  • DatetimeISOFormatJSONEncoder: serializes datetime to a ISO 8601 datetime representation (e.g. object property=datetime(1970, 1, 1, tzinfo=timezone.utc) -> JSON property=1970-01-01T00:00:00+00:00).
  • DatetimeISOFormatJSONDecoder: deserializes ISO 8601 datetime representation to a datetime (e.g. JSON property="1970-01-01T00:00:00+00:00" -> object property=datetime(1970, 1, 1, tzinfo=timezone.utc)).

Model:

class Person:
    def __init__(self):
        self.age = 42

JSON:

{
    "years_old": "str(<person.age>)"
}

To define that: * The age property of Person instances should be an int but given as a string in the JSON representation.

person_mapping_schema = [
    JsonPropertyMapping("years_old", "age", encoder_cls=StrJSONEncoder, decoder_cls=IntJSONDecoder)
]

Optional parameters

Model:

class Person:
    def __init__(self):
        self.name = None
        self.age = 42

JSON:

{
    "years_old": "<person.age>"
}

To define that: A JSON parameter is optional (i.e. it may/may not appear in the JSON representation). An object parameter should not be included in the JSON if it takes the value None.

person_mapping_schema = [
    JsonPropertyMapping("full_name", "name", optional=True), 
    JsonPropertyMapping("years_old", "age")
]

Sets

JSON supports less "primitive types" than Python, implying that there cannot be an unambiguous, one-to-one mapping between all Python and JSON "primitive types". One such type without an equivalent in JSON is set. Python's built-in JSON library "handles" the serialiation of sets by raising a TypeError.

SetJSONEncoder and SetJSONDecoder are supplied in this library to support the serialization of sets. They work by encoding sets as JSON lists then decoding these lists back into sets. As the mapping between JSON and objects is well defined when using this library (i.e. it never has to guess the type of the Python object that is to be constructed from a JSON representation), it is known if a JSON list should be decoded as list or as set.

Build classes that can serialize/deserialize sets of strings:

StringSetJSONEncoder = SetJSONEncoderClassBuilder(StrJSONEncoder).build()
StringSetJSONDecoder = SetJSONDecoderClassBuilder(StrJSONDecoder).build()

Model:

class Person:
    def __init__(self):
        self.nicknames = {"Rob", "Bob"}

JSON:

{
    "short_names": ["<person.nicknames>"]
}

To define that: The JSON "short_names" property is set from the object's "nicknames" property, which is of type set. The object's "nicknames" property is set from the JSON's "full_name" property.

mapping_schema = [
    JsonPropertyMapping("short_names", "nicknames", encoder_cls=StringSetJSONEncoder, decoder_cls=StringSetJSONDecoder)
]

Serialization to/from a dict

To serialize an object to a dictionary, opposed to a string:

custom_object_as_dict = CustomJSONEncoder().default(custom_object)  # type: dict

You can use this with any encoder that inherits from JSONEncoder and overrides default.

To deserialize an object from a dictionary, opposed to from a string:

custom_object = CustomJSONDecoder().decode_parsed(custom_object_as_dict)

You can only use this with decoders defined by this library as they implement the ParsedJSONDecoder interface. To achieve this functionality with other JSONDecoder implementations, you would have to (wastefully) convert the dictionary to a string using json.dump before using the decoder's standard decode method.