orisai/object-mapper – strict data validation and mapping to objects

Marek Bartoš
Nette Blogger | 1197
+
+3
-

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

  1. define an object, add validation rules above properties which should be mapped
  2. get Processor instance from DI
  3. pass data to Processor along with name of the class to which the data shall be mapped
  4. 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 | 1197
+
-1
-

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.