ddn / jsonobject
JsonObject helper class to ease dealing with json objects in PHP
Requires
- php: >=7.4
README
JsonObject
is a PHP class to ease the usage of objects coming from a JSON definition. The idea comes from using pydantic
in python, and its ability to parse and validate json data into objects.
Why JsonObject
I had to use an API from PHP, and that API returned me JSONObjects. So I needed to parse them into PHP objects that I was able to use in the app.
The workflow is
- retrieve a JSON object definition
- use
JsonObject
to parse the JSON definition - use the resulting objects in the application
Use case
Let's take the following JSON example:
{ "id": 0, "name": "John Doe", "age": 42, "emails": [ "my@email.com", "other@email.com" ], "address": { "street": "My street", "number": 42, "city": "My city", "country": "My country" } }
Using JsonObject
, I will be able to define my data model using the following classes:
class User extends JsonObject { const ATTRIBUTES = [ 'id' => 'int', 'name' => 'str', 'age' => 'int', 'emails' => 'list[str]', 'address?' => 'Address', ]; } class Address extends JsonObject { const ATTRIBUTES = [ 'street' => 'str', 'number' => 'int', 'city' => 'str', 'country' => 'str', ]; }
And then add the following command:
$user = User::fromObject(json_decode($json_text_definition));
The JsonObject
class will carry out with parsing the content into objects, and we would be able to use its attributes as defined:
echo($user->name);
The classes defined can also have methods that will make it easier to implement the application's data model. E.g. it would possible to define the class User
like this:
class User extends JsonObject { const ATTRIBUTES = [ 'id' => 'int', 'name' => 'str', 'age' => 'int', 'emails' => 'list[str]', 'address?' => 'Address', ]; public function isAdult() { return $this->age >= 18; } }
Using JsonObject
The idea of the JsonObject
class is to use it to parse json data into objects. So that these objects may contain other methods that will help to implement the data model of the application.
When the json object (or array) is parsed, its content is recursively parsed according to the types defined in the ATTRIBUTES
constant. If the data is not valid, because it does not contain the expected values, an exception is thrown.
To use JsonObject one must subclass JsonObject
and define the ATTRIBUTES
constant for that class so that it defines the attributes expected for the objects of that class, along with the type of each one.
Defining the types for the attributes
The ATTRIBUTES
constant is an associative array where the keys are the name for each attribute, and the values are the type for each attribute.
The possible types can be:
- int: int number
- float: floating point number
- str: string
- bool: boolean
- list[type]: list of objects of type type.
- dict[type]: dictionary of objects of type type. The keys for each entry of the dictionary is converted to strings.
- object: is a class name which must be a subclass of
JsonObject
.
Optional and mandatory attributes
When defining the name of the attributes, one can add a ?
at the end of the name to indicate that the attribute is optional. For example, the attribute name address?
in the use-case section is optional.
Each field is considered to be mandatory so that it must exist in the parsed object (or array). Moreover, the object must be of the type defined (i.e. it must be correctly parsed by the specific type).
Mandatory attributes
Any attribute that is not optional is considered to be madatory. This is of special interest in two points:
- when creating the object from an external structure (either using
fromArray
orfromObject
functions). - when generating the object or array representation of the
JsonObject
When creating the object from an external structure, the JsonObject
will take care of every mandatory field. And if any of them is missing, an exception will raise.
In the next example, an exception will raise because the mandatory field age is not provided.
class User extends JsonObject { const ATTRIBUTES = [ "name" => "str", "age" => "int", ]; } (...) $user = User::fromArray([ "name" => "John" ]);
When converting the object to an array or to an object (or getting its json representation), a mandatory field will get a default value, even if not set.
So in the next example
class User extends JsonObject { const ATTRIBUTES = [ "name" => "str", "age" => "int", "birthDate?" => "str" ]; } $user = new User(); echo((string)$user);
The output will be
{ "name": "", "age": 0 }
Because while attributes name and age are mandatory and they get their default values (i.e. 0 for numbers, empty for strings, lists or dicts), attribute birthDate is not mandatory and it has not been set, yet. So it is not generated in the output.
Setting to null
on mandatory attributes
The problem of setting values to null is of special relevance when considering whether an attribute is optional or not.
One may think that, if we set a value to null it would mean to unset the value and so it should only be possible for optional values but not for mandatory values.
In JsonObject
we have a different concept, because setting a property to null will mean "setting a value to null" and not unsetting the property. In order to unset the property, we should use function unset or somethink like that.
Unsetting mandatory attributes
JsonObject
also enables to unset values. For an optional attribute, it means removing the value and thus it will not have any value in an array representation or an object (if retrieving the value, it will be set to null).
But for a mandatory attribute, unsetting it will mean resetting its value to the default. That means that it will be initialized to the default value of the type (i.e. 0 for numbers, empty for lists, strings or dicts, etc.) or its default value in the ATTRIBUTES
constant.
Inheritance
JsonObject
s are also able to inherit attributes from their parent classes. Take the following example:
class Vehicle extends JsonObject { const ATTRIBUTES = [ "brand" => "str", "color" => "str" ] } class Car extends Vehicle { const ATTRIBUTES = [ "wheels" => "int" ] } class Boat extends Vehicle { const ATTRIBUTES = [ "length" => "float" ] }
In this example, class Vehicle
will only have attribute brand and color, but class Car
will have brand, color and wheels attributes, while class Boat
will have brand, color and length attributes.
Creation of objects
Objects from children classes of JsonObject
can be created using the static method ::fromArray
or ::fromObject
, starting from a json parsed object.
In the previous example, if we have a file car.json with the following content:
{ "brand": "BMW", "color": "black" }
We can use the following code to get an instance of the Vehicle
class:
$json = file_get_contents("car.json"); $vehicle = Vehicle::fromArray((array)json_decode($json, true));
An alternative is to instantiate objects like in the next example
* PHP 8 and over:
$car = new Car(brand: "BMW", color: "black", wheels: 4);
* previous PHP versions:
$car = new Car([ "brand" => "BMW", "color" => "black", "wheels" => 4]);
Methods for the objects
JsonObject
The JsonObject
is the core class for this library. Its methods are:
__construct($data)
- Creates a new object from the given data__get($name)
- Returns the value of the attribute with the given name__set($name, $value)
- Sets the value for the attribute with the given name__isset($name)
- Returns true if the attribute with the given name is set__unset($name)
- Unsets the value of an optional attribute (or resets the value of a mandatory attribute).toArray()
- Returns an associative array with the data of the object. The array is created recursively, visiting each of the sub-attributes for each attribute.toObject()
- Returns an object with the data of the object as attributes. The array is created recursively, visiting each of the sub-attributes for each attribute.toJson()
- Returns a json string with the representation of the object as standard object.::fromArray($data)
- Creates an object, by parsing the given associative array into the attributes defined in the class. Each of the attributes is recursively parsed, according to the type defined to it.::fromObject($data)
- Creates an object, by parsing the given object into the attributes defined in the class. Each of the attributes is recursively parsed, according to the type defined to it.
JsonDict
This object is used to deal with a dictionary coming from a json definition. The JsonDict
class is typed to that each of the elements must be from a given type.
The JsonDict
objects can be used as array-like objects (e.g. $jsonDict["key1"]) but (at the moment of writing this text) the type of the elements inserted in the dictionary are not checked. The type is used for parsing the content when creating the dict (e.g. using fromArray
static function) or to dump the content to an array or an object (e.g. using toArray
function).
The methods are:
toArray()
toObject()
::fromArray($data)
::fromObject($data)
These methods are interpreted in the same way than in the case of JsonObject
. And the type of the elements in the dict may refer to complex types that will be considered recursively when parsing the content.
e.g. type list[list[int]]
will be used to parse [ [ 1, 2, 3], [ 4, 5, 6 ]]
JsonArray
This object is very much the same than JsonDict
with the exception that the indexes must be integer numbers. In this case $value["key1"]
will produce an exception.
In this case, the function to append elements to the array (i.e. []
) is also implemented.
Initializing values
When defining the class, it is possible to initialize the values for the objects that are newly created, and to those attributes that are optional.
There are two ways:
### Using class properties
It is possible to initialize the value of an object by using the class properties, so if the value for an attribute is set in the class, it will be copied to the instance as an attribute, if it is defined.
E.g.
class User extends JsonObject { const ATTRIBUTES = [ 'id' => 'int', 'name' => 'str', 'age' => 'int', 'emails' => 'list[str]', 'address?' => 'Address', 'sex?' => 'str' ]; public $sex = "not revealed"; }
Now, the attribute sex
is initialized to not revealed instead of being null.
Using the definition of the attributes
The way to make it is to define a tuple [ <type>, <default value> ]
for the type of the object. Taking the next example:
class User extends JsonObject { const ATTRIBUTES = [ 'id' => 'int', 'name' => 'str', 'age' => 'int', 'emails' => 'list[str]', 'address?' => 'Address', 'sex?' => [ 'str', 'not revealed' ] ]; }
The attribute sex
is optional when retrieving the user data. Using this new definition for the class, if sex
is not set, the value will be set to "not revealed" instead of null
.
An important feature is that, if the string set as <default value> corresponds to a method of the object, it will be called upon getting the value (if it has not been set, yet), and the value set for that property will be the result of the call.
E.g.
class User extends JsonObject { const ATTRIBUTE = [ ... 'birthDay?' => [ 'str', 'computeBirthDate' ] ] function computeBirthDate() { $now = new DateTime(); $now->sub(DateInterval::createFromDateString("{$this->age} years")); return $now->format("Y-m-d"); } }
In this example, if we had not set the birthDate
property but it is retrieved, it will be computed by subtracting the age to the current date.
Additional tools and technical facts
Parsing a value
If wanted to parse an arbitrary object to a JsonObject
, it is possible to use the function JsonObject::parse_typed_value
. This is important to be able to convert from any type to a JsonObject
-type.
e.g.
$myobject = JsonObject::parse_typed_value("list[str]", [ "my", "name", "is", "John" ]);
Will obtain an object of type JsonList<str>
.
Type checking
The default behavior of this library is to ensure that the values set for the attributes match their defined type. But that means that would mean that, as a float
is not an int
, setting a float to 0
will fail because 0
is an integer. In that case, the user must cast the values before assigning them. To control whether to so stritcly check the type or not, it is possible to use the constant STRICT_TYPE_CHECKING
.
If
STRICT_TYPE_CHECKING
it is set toTrue
, the types will be strictly checked and e.g. assigning9.3
to anint
will raise an exception. If set toFalse
, the numerical types will be converted from one to each other. So e.g. if we assign9.3
to anint
it will be automatically truncated to9
.
Other important type checking is when assigning an empty value (i.e. ""
or null
) to a numeric type. In that case, we have the constant STRICT_TYPE_CHECKING_EMPTY_ZERO
.
If
STRICT_TYPE_CHECKING_EMPTY_ZERO
is set toTrue
(the default behavior), when assigning an empty value to a numeric type, it will be considered to be0
. i.e. assigning an empty string or anull
value to anint
attribute, will mean to assign0
. If set toFalse
, the library will check the types and will eventually raise an exception.
Enhanced JsonLists
Now JsonList
also enables to use negative indexes, so that -1
will be the last element, -2
the penultimate, etc.
JsonList
object includes functions for sorting or filtering.
public function sort(callable $callback = null) : JsonList
: sorts the list using the given callback. If no callback is given, it will sort the list using the default comparison function.public function filter(callable $callback) : JsonList
: filters the list using the given callback. The callback must return a boolean value. If the callback returnstrue
, the element will be included in the resulting list. If it returnsfalse
, the element will be discarded.