PHP library that helps to map any input into a strongly-typed value object structure.
MIT License
Bot releases are hidden (Show)
Published by romm about 2 years ago
Two similar features are introduced in this release: constants and enums wildcard notations. This is mainly useful when several cases of an enum or class constants share a common prefix.
Example for class constants:
final class SomeClassWithConstants
{
public const FOO = 1337;
public const BAR = 'bar';
public const BAZ = 'baz';
}
$mapper = (new MapperBuilder())->mapper();
$mapper->map('SomeClassWithConstants::BA*', 1337); // error
$mapper->map('SomeClassWithConstants::BA*', 'bar'); // ok
$mapper->map('SomeClassWithConstants::BA*', 'baz'); // ok
Example for enum:
enum SomeEnum: string
{
case FOO = 'foo';
case BAR = 'bar';
case BAZ = 'baz';
}
$mapper = (new MapperBuilder())->mapper();
$mapper->map('SomeEnum::BA*', 'foo'); // error
$mapper->map('SomeEnum::BA*', 'bar'); // ok
$mapper->map('SomeEnum::BA*', 'baz'); // ok
Published by romm about 2 years ago
Until this release, the behaviour of the date objects creation was very opinionated: a huge list of date formats were tested out, and if one was working it was used to create the date.
This approach resulted in two problems. First, it led to (minor) performance issues, because a lot of date formats were potentially tested for nothing. More importantly, it was not possible to define which format(s) were to be allowed (and in result deny other formats).
A new method can now be used in the MapperBuilder
:
(new \CuyZ\Valinor\MapperBuilder())
// Both `Cookie` and `ATOM` formats will be accepted
->supportDateFormats(DATE_COOKIE, DATE_ATOM)
->mapper()
->map(DateTimeInterface::class, 'Monday, 08-Nov-1971 13:37:42 UTC');
Please note that the old behaviour has been removed. From now on, only valid timestamp or ATOM-formatted value will be accepted by default.
If needed and to help with the migration, the following deprecated constructor can be registered to reactivate the previous behaviour:
(new \CuyZ\Valinor\MapperBuilder())
->registerConstructor(
new \CuyZ\Valinor\Mapper\Object\BackwardCompatibilityDateTimeConstructor()
)
->mapper()
->map(DateTimeInterface::class, 'Monday, 08-Nov-1971 13:37:42 UTC');
DynamicConstructor
(e437d9)Published by romm about 2 years ago
Reworking of messages body and parameters features
The \CuyZ\Valinor\Mapper\Tree\Message\Message
interface is no longer a Stringable
, however it defines a new method body
that must return the body of the message, which can contain placeholders that will be replaced by parameters.
These parameters can now be defined by implementing the interface \CuyZ\Valinor\Mapper\Tree\Message\HasParameters
.
This leads to the deprecation of the no longer needed interface \CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage
which had a confusing name.
final class SomeException extends DomainException implements ErrorMessage, HasParameters, HasCode
{
private string $someParameter;
public function __construct(string $someParameter)
{
parent::__construct();
$this->someParameter = $someParameter;
}
public function body() : string
{
return 'Some message / {some_parameter} / {source_value}';
}
public function parameters(): array
{
return [
'some_parameter' => $this->someParameter,
];
}
public function code() : string
{
// A unique code that can help to identify the error
return 'some_unique_code';
}
}
Handle numeric-string
type
Kudos to @sergkash7 for this new feature!
The new numeric-string
type can be used in docblocks.
It will accept any string value that is also numeric.
(new MapperBuilder())->mapper()->map('numeric-string', '42'); // ✅
(new MapperBuilder())->mapper()->map('numeric-string', 'foo'); // ❌
Better mapping error message
Kudos to @Slamdunk for this!
The message of the exception will now contain more information, especially the total number of errors and the source that was given to the mapper. This change aims to have a better understanding of what is wrong when debugging.
Before:
Could not map type `array{foo: string, bar: int}` with the given source.
After:
Could not map type `array{foo: string, bar: int}`. An error occurred at path bar: Value 'some other string' does not match type `int`.
MessagesFlattener
countable (2c1c7c)Published by romm over 2 years ago
SECURITY — Userland exception filtering
See advisory GHSA-5pgm-3j3g-2rc7 for more information.
Userland exception thrown in a constructor will not be automatically caught by the mapper anymore. This prevents messages with sensible information from reaching the final user — for instance an SQL exception showing a part of a query.
To allow exceptions to be considered as safe, the new method MapperBuilder::filterExceptions()
must be used, with caution.
final class SomeClass
{
public function __construct(private string $value)
{
\Webmozart\Assert\Assert::startsWith($value, 'foo_');
}
}
try {
(new \CuyZ\Valinor\MapperBuilder())
->filterExceptions(function (Throwable $exception) {
if ($exception instanceof \Webmozart\Assert\InvalidArgumentException) {
return \CuyZ\Valinor\Mapper\Tree\Message\ThrowableMessage::from($exception);
}
// If the exception should not be caught by this library, it
// must be thrown again.
throw $exception;
})
->mapper()
->map(SomeClass::class, 'bar_baz');
} catch (\CuyZ\Valinor\Mapper\MappingError $exception) {
// Should print something similar to:
// > Expected a value to start with "foo_". Got: "bar_baz"
echo $exception->node()->messages()[0];
}
Tree node API rework
The class \CuyZ\Valinor\Mapper\Tree\Node
has been refactored to remove access to unwanted methods that were not supposed to be part of the public API. Below are a list of all changes:
New methods $node->sourceFilled()
and $node->sourceValue()
allow accessing the source value.
The method $node->value()
has been renamed to $node->mappedValue()
and will throw an exception if the node is not valid.
The method $node->type()
now returns a string.
The methods $message->name()
, $message->path()
, $message->type()
and $message->value()
have been deprecated in favor of the new method $message->node()
.
The message parameter {original_value}
has been deprecated in favor of {source_value}
.
Access removal of several parts of the library public API
The access to class/function definition, types and exceptions did not add value to the actual goal of the library. Keeping these features under the public API flag causes more maintenance burden whereas revoking their access allows more flexibility with the overall development of the library.
.idea
folder (84ead0)Published by romm over 2 years ago
Strict mode
The mapper is now more type-sensitive and will fail in the following situations:
When a value does not match exactly the awaited scalar type, for instance a string "42"
given to a node that awaits an integer.
When unnecessary array keys are present, for instance mapping an array ['foo' => …, 'bar' => …, 'baz' => …]
to an object that needs only foo
and bar
.
When permissive types like mixed
or object
are encountered.
These limitations can be bypassed by enabling the flexible mode:
(new \CuyZ\Valinor\MapperBuilder())
->flexible()
->mapper();
->map('array{foo: int, bar: bool}', [
'foo' => '42', // Will be cast from `string` to `int`
'bar' => 'true', // Will be cast from `string` to `bool`
'baz' => '…', // Will be ignored
]);
When using this library for a provider application — for instance an API endpoint that can be called with a JSON payload — it is recommended to use the strict mode. This ensures that the consumers of the API provide the exact awaited data structure, and prevents unknown values to be passed.
When using this library as a consumer of an external source, it can make sense to enable the flexible mode. This allows for instance to convert string numeric values to integers or to ignore data that is present in the source but not needed in the application.
Interface inferring
It is now mandatory to list all possible class-types that can be inferred by the mapper. This change is a step towards the library being able to deliver powerful new features such as compiling a mapper for better performance.
The existing calls to MapperBuilder::infer
that could return several class-names must now add a signature to the callback. The callbacks that require no parameter and always return the same class-name can remain unchanged.
For instance:
$builder = (new \CuyZ\Valinor\MapperBuilder())
// Can remain unchanged
->infer(SomeInterface::class, fn () => SomeImplementation::class);
$builder = (new \CuyZ\Valinor\MapperBuilder())
->infer(
SomeInterface::class,
fn (string $type) => match($type) {
'first' => ImplementationA::class,
'second' => ImplementationB::class,
default => throw new DomainException("Unhandled `$type`.")
}
)
// …should be modified with:
->infer(
SomeInterface::class,
/** @return class-string<ImplementationA|ImplementationB> */
fn (string $type) => match($type) {
'first' => ImplementationA::class,
'second' => ImplementationB::class,
default => throw new DomainException("Unhandled `$type`.")
}
);
Object constructors collision
All these changes led to a new check that runs on all registered object constructors. If a collision is found between several constructors that have the same signature (the same parameter names), an exception will be thrown.
final class SomeClass
{
public static function constructorA(string $foo, string $bar): self
{
// …
}
public static function constructorB(string $foo, string $bar): self
{
// …
}
}
(new \CuyZ\Valinor\MapperBuilder())
->registerConstructor(
SomeClass::constructorA(...),
SomeClass::constructorB(...),
)
->mapper();
->map(SomeClass::class, [
'foo' => 'foo',
'bar' => 'bar',
]);
// Exception: A collision was detected […]
Published by romm over 2 years ago
Documentation is now available at valinor.cuyz.io.
Cache injection and warmup
The cache feature has been revisited, to give more control to the user on how and when to use it.
The method MapperBuilder::withCacheDir()
has been deprecated in favor of a new method MapperBuilder::withCache()
which accepts any PSR-16 compliant implementation.
Warning
These changes lead up to the default cache not being automatically registered anymore. If you still want to enable the cache (which you should), you will have to explicitly inject it (see below).
A default implementation is provided out of the box, which saves cache entries into the file system.
When the application runs in a development environment, the cache implementation should be decorated with FileWatchingCache
, which will watch the files of the application and invalidate cache entries when a PHP file is modified by a developer — preventing the library not behaving as expected when the signature of a property or a method changes.
The cache can be warmed up, for instance in a pipeline during the build and deployment of the application — kudos to @boesing for the feature!
Note The cache has to be registered first, otherwise the warmup will end up being useless.
$cache = new \CuyZ\Valinor\Cache\FileSystemCache('path/to/cache-directory');
if ($isApplicationInDevelopmentEnvironment) {
$cache = new \CuyZ\Valinor\Cache\FileWatchingCache($cache);
}
$mapperBuilder = (new \CuyZ\Valinor\MapperBuilder())->withCache($cache);
// During the build:
$mapperBuilder->warmup(SomeClass::class, SomeOtherClass::class);
// In the application:
$mapperBuilder->mapper()->map(SomeClass::class, [/* … */]);
Message formatting & translation
Major changes have been made to the messages being returned in case of a mapping error: the actual texts are now more accurate and show better information.
Warning
The method
NodeMessage::format()
has been removed, message formatters should be used instead. If needed, the old behaviour can be retrieved with the formatterPlaceHolderMessageFormatter
, although it is strongly advised to use the new placeholders feature (see below).The signature of the method
MessageFormatter::format()
has changed as well.
It is now also easier to format the messages, for instance when they need to be translated. Placeholders can now be used in a message body, and will be replaced with useful information.
Placeholder | Description |
---|---|
{message_code} |
the code of the message |
{node_name} |
name of the node to which the message is bound |
{node_path} |
path of the node to which the message is bound |
{node_type} |
type of the node to which the message is bound |
{original_value} |
the source value that was given to the node |
{original_message} |
the original message before being customized |
try {
(new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(SomeClass::class, [/* … */]);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
$node = $error->node();
$messages = new \CuyZ\Valinor\Mapper\Tree\Message\MessagesFlattener($node);
foreach ($messages as $message) {
if ($message->code() === 'some_code') {
$message = $message->withBody('new message / {original_message}');
}
echo $message;
}
}
The messages are formatted using the ICU library, enabling the placeholders to use advanced syntax to perform proper translations, for instance currency support.
try {
(new \CuyZ\Valinor\MapperBuilder())->mapper()->map('int<0, 100>', 1337);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
$message = $error->node()->messages()[0];
if (is_numeric($message->value())) {
$message = $message->withBody(
'Invalid amount {original_value, number, currency}'
);
}
// Invalid amount: $1,337.00
echo $message->withLocale('en_US');
// Invalid amount: £1,337.00
echo $message->withLocale('en_GB');
// Invalid amount: 1 337,00 €
echo $message->withLocale('fr_FR');
}
See ICU documentation for more information on available syntax.
Warning If the
intl
extension is not installed, a shim will be available to replace the placeholders, but it won't handle advanced syntax as described above.
The formatter TranslationMessageFormatter
can be used to translate the content of messages.
The library provides a list of all messages that can be returned; this list can be filled or modified with custom translations.
\CuyZ\Valinor\Mapper\Tree\Message\Formatter\TranslationMessageFormatter::default()
// Create/override a single entry…
->withTranslation('fr', 'some custom message', 'un message personnalisé')
// …or several entries.
->withTranslations([
'some custom message' => [
'en' => 'Some custom message',
'fr' => 'Un message personnalisé',
'es' => 'Un mensaje personalizado',
],
'some other message' => [
// …
],
])
->format($message);
It is possible to join several formatters into one formatter by using the AggregateMessageFormatter
. This instance can then easily be injected in a service that will handle messages.
The formatters will be called in the same order they are given to the aggregate.
(new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\AggregateMessageFormatter(
new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\LocaleMessageFormatter('fr'),
new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter([
// …
],
\CuyZ\Valinor\Mapper\Tree\Message\Formatter\TranslationMessageFormatter::default(),
))->format($message)
Published by romm over 2 years ago
Float values handling
Allows the usage of float values, as follows:
class Foo
{
/** @var 404.42|1337.42 */
public readonly float $value;
}
Literal boolean true
/ false
values handling
Thanks @danog for this feature!
Allows the usage of boolean values, as follows:
class Foo
{
/** @var int|false */
public readonly int|bool $value;
}
Class string of union of object handling
Allows to declare several class names in a class-string
:
class Foo
{
/** @var class-string<SomeClass|SomeOtherClass> */
public readonly string $className;
}
Allow psalm
and phpstan
prefix in docblocks
Thanks @boesing for this feature!
The following annotations are now properly handled: @psalm-param
, @phpstan-param
, @psalm-return
and @phpstan-return
.
If one of those is found along with a basic @param
or @return
annotation, it will take precedence over the basic value.
psalm
and phpstan
prefix in docblocks (64e0a2)true
/ false
types (afcedf)Published by romm over 2 years ago
Warning This release introduces a major breaking change that must be considered before updating
Constructor registration
The automatic named constructor discovery has been disabled. It is now mandatory to explicitly register custom constructors that can be used by the mapper.
This decision was made because of a security issue reported by @Ocramius and described in advisory GHSA-xhr8-mpwq-2rr2.
As a result, existing code must list all named constructors that were previously automatically used by the mapper, and registerer them using the method MapperBuilder::registerConstructor()
.
The method MapperBuilder::bind()
has been deprecated in favor of the method above that should be used instead.
final class SomeClass
{
public static function namedConstructor(string $foo): self
{
// …
}
}
(new \CuyZ\Valinor\MapperBuilder())
->registerConstructor(
SomeClass::namedConstructor(...),
// …or for PHP < 8.1:
[SomeClass::class, 'namedConstructor'],
)
->mapper()
->map(SomeClass::class, [
// …
]);
See documentation for more information.
Source builder
The Source
class is a new entry point for sources that are not plain array or iterable. It allows accessing other features like camel-case keys or custom paths mapping in a convenient way.
It should be used as follows:
$source = \CuyZ\Valinor\Mapper\Source\Source::json($jsonString)
->camelCaseKeys()
->map([
'towns' => 'cities',
'towns.*.label' => 'name',
]);
$result = (new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(SomeClass::class, $source);
See documentation for more details about its usage.
Attributes::ofType
return type to array
(1a599b)Published by romm over 2 years ago
composer.json
(6fdd62)Published by romm almost 3 years ago
Allow mapping to any type
Previously, the method TreeMapper::map
would allow mapping only to an object. It is now possible to map to any type handled by the library.
It is for instance possible to map to an array of objects:
$objects = (new MapperBuilder())->mapper()->map(
'array<' . SomeClass::class . '>',
[/* … */]
);
For simple use-cases, an array shape can be used:
$array = (new MapperBuilder())->mapper()->map(
'array{foo: string, bar: int}',
[/* … */]
);
echo $array['foo'];
echo $array['bar'] * 2;
This new feature changes the possible behaviour of the mapper, meaning static analysis tools need help to understand the types correctly. An extension for PHPStan and a plugin for Psalm are now provided and can be included in a project to automatically increase the type coverage.
Better handling of messages
When working with messages, it can sometimes be useful to customize the content of a message — for instance to translate it.
The helper class \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter
can be used to provide a list of new formats. It can be instantiated with an array where each key represents either:
If none of those is found, the content of the message will stay unchanged unless a default one is given to the class.
If one of these keys is found, the array entry will be used to replace the content of the message. This entry can be either a plain text or a callable that takes the message as a parameter and returns a string; it is for instance advised to use a callable in cases where a translation service is used — to avoid useless greedy operations.
In any case, the content can contain placeholders that will automatically be replaced by, in order:
try {
(new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(SomeClass::class, [/* … */]);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
$node = $error->node();
$messages = new \CuyZ\Valinor\Mapper\Tree\Message\MessagesFlattener($node);
$formatter = (new MessageMapFormatter([
// Will match if the given message has this exact code
'some_code' => 'new content / previous code was: %1$s',
// Will match if the given message has this exact content
'Some message content' => 'new content / previous message: %2$s',
// Will match if the given message is an instance of `SomeError`
SomeError::class => '
- Original code of the message: %1$s
- Original content of the message: %2$s
- Node type: %3$s
- Node name: %4$s
- Node path: %5$s
',
// A callback can be used to get access to the message instance
OtherError::class => function (NodeMessage $message): string {
if ((string)$message->type() === 'string|int') {
// …
}
return 'Some message content';
},
// For greedy operation, it is advised to use a lazy-callback
'bar' => fn () => $this->translator->translate('foo.bar'),
]))
->defaultsTo('some default message')
// …or…
->defaultsTo(fn () => $this->translator->translate('default_message'));
foreach ($messages as $message) {
echo $formatter->format($message);
}
}
Automatic union of objects inferring during mapping
When the mapper needs to map a source to a union of objects, it will try to guess which object it will map to, based on the needed arguments of the objects, and the values contained in the source.
final class UnionOfObjects
{
public readonly SomeFooObject|SomeBarObject $object;
}
final class SomeFooObject
{
public readonly string $foo;
}
final class SomeBarObject
{
public readonly string $bar;
}
// Will map to an instance of `SomeFooObject`
(new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(UnionOfObjects::class, ['foo' => 'foo']);
// Will map to an instance of `SomeBarObject`
(new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(UnionOfObjects::class, ['bar' => 'bar']);
MessageMapFormatter
(ddf69e)MessagesFlattener
(a97b40)NodeTraverser
for recursive operations on nodes (cc1bc6)Published by romm almost 3 years ago
Published by romm almost 3 years ago
Published by romm almost 3 years ago
Initial release 🎉