orisai/object-mapper – strict data validation and mapping to objects
- Marek Bartoš
- Nette Blogger | 1257
Another of my children from Orisai
family is mature enough to be released into the wild. Today it's
the orisai/object-mapper
and its integration into
Nette, orisai/nette-object-mapper.
Object-based validator and data mapper.
Usage is quite simple
- define an object, add validation rules above properties which should be mapped
- get Processor instance from DI
- pass data to Processor along with name of the class to which the data shall be mapped
- if an exception is thrown, pass it to the ErrorPrinter
use Orisai\ObjectMapper\MappedObject;
use Orisai\ObjectMapper\Rules\MappedObjectValue;
use Orisai\ObjectMapper\Rules\StringValue;
final class UserInput implements MappedObject
{
#[StringValue(notEmpty: true)]
public string $firstName;
#[StringValue(notEmpty: true)]
public string $lastName;
#[MappedObjectValue(UserAddressInput::class)]
public UserAddressInput $address;
}
use Orisai\ObjectMapper\MappedObject;
final class UserAddressInput implements MappedObject
{
// ...
}
use Orisai\ObjectMapper\Exception\InvalidData;
use Orisai\ObjectMapper\Printers\ErrorVisualPrinter;
use Orisai\ObjectMapper\Printers\TypeToStringConverter;
use Orisai\ObjectMapper\Processing\Processor;
$processor = $container->getByType(Processor::class);
$errorPrinter = new ErrorVisualPrinter(new TypeToStringConverter());
$data = [
'firstName' => 'Tony',
'lastName' => 'Stark',
'address' => [],
];
try {
$user = $processor->process($data, UserInput::class);
} catch (InvalidData $exception) {
$error = $errorPrinter->printError($exception);
throw new Exception("Validation failed due to following error:\n$error");
}
echo "User name is: {$user->firstName} {$user->lastName}";
Implemented features
Rules.
Each property has to define a rule to be validated and mapped. And there is a
few of them already – for scalar
values like int, string and bool, for null, for arrays and lists, for combining
rules via ||
and &&
, for nested
mapped objects and for value objects like datetime and enums,
use Orisai\ObjectMapper\Processing\MappedObject;
use Orisai\ObjectMapper\Rules\AnyOf;
use Orisai\ObjectMapper\Rules\NullValue;
use Orisai\ObjectMapper\Rules\StringValue;
class ExampleInput implements MappedObject
{
#[AnyOf([
new StringValue(),
new NullValue(),
])]
public string $stringOrNull;
}
Callbacks.
Before and after each mapped object and field processing, extra data validations
and transformations can be done.
use Orisai\ObjectMapper\Callbacks\Before;
use Orisai\ObjectMapper\Processing\MappedObject;
use Orisai\ObjectMapper\Rules\StringValue;
#[Before('beforeCallback')]
class CallbackUsingInput implements MappedObject
{
#[StringValue]
public string $string;
private function beforeCallback(mixed $value): mixed
{
// BC, before object was implemented only string was expected in its place
if (is_string($value)) {
$value = ['string' => $value];
}
return $value;
}
}
Field
names.
By default, field names on input match names of mapped properties, but the field
name can be changed to any string or
int.
use Orisai\ObjectMapper\MappedObject;
use Orisai\ObjectMapper\Modifiers\FieldName;
use Orisai\ObjectMapper\Rules\MixedValue;
final class CustomMappingInput implements MappedObject
{
#[MixedValue]
#[FieldName('customFieldName')]
public mixed $property;
}
$data = [
'customFieldName' => 'anything',
];
$input = $processor->process($data, CustomMappingInput::class);
// $input == CustomMappingInput(property: 'anything')
Processing
modes.
Does your API support PATCH requests and only need to send changed fields and
not the others,
otherwise required fields? There's a mode for that. Do you need to send all
fields, including otherwise optional ones?
Yeah, another mode.
Annotations
and attributes.
All rules, callbacks and other types of definitions can be written as doctrine
annotations
as well as PHP 8 attributes. PHP attributes are great, but they are fully
usable only since PHP 8.1. With doctrine
annotations PHP 7.4+ is also supported.
Error
printers.
All errors are represented by an abstract structure and the whole hierarchy can
be printed to a string
or an array. If you need to, you can easily come up with your own format.
Type
printers.
Mapped object can be printed the same way the errors are for easy overview of
the expected data.
If you would like to know about all the current features and how to use
them,
please read the docs. It
would be way to long for this post to explain everything.
Implemented, yet undocumented
Custom rules. There is still some work left that has to be done around
metadata validation and documentation, but it
should be quite simple to implement your own rules. Take inspiration in the
existing ones.
Validation without mapping. It is possible to just validate data, without
mapping them to objects. Helpful in case you
need to save the raw, yet valid data for later. I am just not sure yet how to
handle this feature in callbacks and if it
should cover just object mapping or include any data transformations, e.g.
casting numeric-string to an int.
Skipped fields. It is possible to completely skip some mapped fields during
validation and validate them separately
later during the request, when an additional required context is available.
I implemented this feature long time ago and
I no longer have a use case for it, and I am unsure whether I should keep it.
Let me know if it sounds like something
you would find helpful.
Last edited by Marek Bartoš (2023-07-05 19:29)
- Marek Bartoš
- Nette Blogger | 1257
Future features
While object mapper is already able to do a lot, I still have a ton of ideas
I would like to implement. Here are some of
the most important:
PHPStan plugin. We should be able to ensure, that property native type and
phpdoc types match type returned by the rule
which validates it. Also, some other magic that PHPStan is not able to
understand, like private functions used in
callbacks and invoked internally by object mapper.
Localization. Error output from validation is currently pure representation
of the expected structure, and it should be
turned into simple, translatable messages, including samples of the unexpected
sent value.
Warnings and deprecations. Output of processing is either successfully mapped
object or an error. We should also be able
to return possible, acceptable problems and inform about deprecations.
Error output readability improvements. That includes more flat error output
in path > to > key:value
format and syntax
highlighting for html/console/…
Handling different data formats. Object mapper expects an array of values as
an input, which works great for json. For
XML and possibly other formats it will be also possible to define
context-specific modifiers (e.g. whether mapped field
comes from XML tag value or attribute)
Types separation into own library. We have an abstract structure of types
used to represent both valid structure of
mapped objects and validation errors. These may be useful for API parts not
handled by object mapper so that any errors
and documentation can be handled in a universal way.
Likely much more? Definitely more built-in validation rules.
Following libraries
Object mapper is quite powerful, and I am definitely planning to build more
libraries on top of it. Here are my current
ideas:
Value objects. For email addresses, phone numbers, credit card numbers and
many more. Usable both directly and via
object mapper (as an optional dependency – while connected, both libraries
will be completely independent).
OpenAPI. Process OpenAPI schema in any imaginable way. Build it
programmatically with full support of IDE, validate an
existing schema and serialize it back. Who knows, maybe even automatically
rewrite schema to other OpenAPI versions? And
have I told you that you will be able to generate request body schema from
object mapper mapped objects? This is already
a work in progress.
Rest API. While you can likely use object mapper to validate request body
with any API library, I would like to take it
further and integrate these libraries closely for a seamless experience. With
the same declarative and statically
analysable interface that object mapper has.
Database layer integrations. You can map IDs (and possibly other values)
directly to database entities during object
mapper validations. Even arrays of IDs work great, making a single query.
I have already an integration for nextras/orm
in progress (working great and to be released soon), and it shouldn't be hard to
do the same for other DB layers.
I may come up with other ideas later, feel free to give me some tips.
Sponsoring
If you like what has been done so far and the promise of the future state,
please
consider sponsoring
Orisai. Amount of work I am able to do on Orisai libraries is
currently directly proportional to the amount of free time I am able to give
it. With sufficient sponsoring, further
development can be done much faster. I would also love to hire more developers
to work on Orisai with me.