Skip to content

Commit

Permalink
feat!: improve message customization with formatters
Browse files Browse the repository at this point in the history
The way messages can be customized has been totally revisited, requiring
several breaking changes. All existing error messages have been
rewritten to better fit the actual meaning of the error.

The content of a message can be changed to fit custom use cases; it can
contain placeholders that will be replaced with useful information.

The placeholders below are always available; even more may be used
depending on the original message.

- `{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

```php
try {
    (new \CuyZ\Valinor\MapperBuilder())
        ->mapper()
        ->map(SomeClass::class, [/* … */]);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
    $messages = new MessagesFlattener($error->node());

    foreach ($messages as $message) {
        if ($message->code() === 'some_code') {
            $message = $message->withBody('new / {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.

```php
try {
    (new 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');
}
```

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 new 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.

```php
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.

```php
(new AggregateMessageFormatter(
    new LocaleMessageFormatter('fr'),
    new MessageMapFormatter([
        // …
    ],
    TranslationMessageFormatter::default(),
))->format($message)
```

BREAKING CHANGE: The method `NodeMessage::format` has been removed,
message formatters should be used instead. If needed, the old behaviour
can be retrieved with the formatter `PlaceHolderMessageFormatter`,
although it is strongly advised to use the new placeholders feature.

BREAKING CHANGE: The signature of the method `MessageFormatter::format`
has changed.
  • Loading branch information
romm committed May 21, 2022
1 parent 05cf4a4 commit 60a6656
Show file tree
Hide file tree
Showing 77 changed files with 1,847 additions and 543 deletions.
244 changes: 163 additions & 81 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,29 +196,14 @@ final class SomeClass

try {
(new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(
SomeClass::class,
['someValue' => 'bar_baz']
);
->mapper()
->map(
SomeClass::class,
['someValue' => 'bar_baz']
);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
$node = $error->node();

// The name of a node can be accessed
$name = $node->name();

// The logical path of a node contains dot separated names of its parents
$path = $node->path();

// The type of the node can be cast to string to enhance suggestion messages
$type = (string)$node->type();

// If the node is a branch, its children can be recursively accessed
foreach ($node->children() as $child) {
// Do something…
}

// Get flatten list of all messages through the whole nodes tree
$node = $error->node();
$messages = new \CuyZ\Valinor\Mapper\Tree\Message\MessagesFlattener($node);

// If only errors are wanted, they can be filtered
Expand All @@ -232,36 +217,24 @@ try {
}
```

### Message customization / translation
### Message customization

When working with messages, it can sometimes be useful to customize the content
of a message — for instance to translate it.
The content of a message can be changed to fit custom use cases; it can contain
placeholders that will be replaced with useful information.

The helper class `\CuyZ\Valinor\Mapper\Tree\Message\MessageMapFormatter` can be
used to provide a list of new formats. It can be instantiated with an array
where each key represents either:
The placeholders below are always available; even more may be used depending
on the original message.

- The code of the message to be replaced
- The content of the message to be replaced
- The class name of the message to be replaced
| 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 |

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:

1. The original code of the message
2. The original content of the message
3. A string representation of the node type
4. The name of the node
5. The path of the node
Usage:

```php
try {
Expand All @@ -272,47 +245,152 @@ try {
$node = $error->node();
$messages = new \CuyZ\Valinor\Mapper\Tree\Message\MessagesFlattener($node);

$formatter = (new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\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
'foo' => 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);
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.

```php
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.

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.

### Deeper message customization / translation

For deeper message changes, formatters can be used — for instance to translate
content.

#### Translation

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.

```php
\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);
```

#### Replacement map

The formatter `MessageMapFormatter` can be used to provide a list of messages
replacements. It can be instantiated with an array where each key represents
either:

- The code of the message to be replaced
- The body of the message to be replaced
- The class name of the message to be replaced

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 custom translation service is used —
to avoid useless greedy operations.

In any case, the content can contain placeholders as described
[above](#message-customization).

```php
(new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter([
// Will match if the given message has this exact code
'some_code' => 'New content / code: {message_code}',

// Will match if the given message has this exact content
'Some message content' => 'New content / previous: {original_message}',

// Will match if the given message is an instance of `SomeError`
SomeError::class => 'New content / value: {original_value}',

// A callback can be used to get access to the message instance
OtherError::class => function (NodeMessage $message): string {
if ($message->path() === 'foo.bar') {
return 'Some custom message';
}

return $message->body();
},

// For greedy operation, it is advised to use a lazy-callback
'foo' => fn () => $this->customTranslator->translate('foo.bar'),
]))
->defaultsTo('some default message')
// …or…
->defaultsTo(fn () => $this->customTranslator->translate('default_message'))
->format($message);
```

#### Several formatters

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.

```php
(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)
```

### Source

Any source can be given to the mapper, be it an array, some json, yaml or even a file:
Any source can be given to the mapper, be it an array, some json, yaml or even a
file:

```php
function map($source) {
Expand Down Expand Up @@ -895,3 +973,7 @@ includes:
[Webmozart Assert]: https://github.com/webmozarts/assert

[link-packagist]: https://packagist.org/packages/cuyz/valinor

[ICU library]: https://unicode-org.github.io/icu/

[ICU documentation]: https://unicode-org.github.io/icu/userguide/format_parse/messages/
1 change: 1 addition & 0 deletions infection.json.dist
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"src"
]
},
"tmpDir": "var/cache/infection",
"logs": {
"text": "var/infection/infection.log",
"summary": "var/infection/summary.log",
Expand Down
4 changes: 2 additions & 2 deletions src/Mapper/Object/Arguments.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ function (Argument $argument) {
$name = $argument->name();
$type = $argument->type();

$signature = TypeHelper::containsObject($type) ? '?' : $type;
$signature = TypeHelper::dump($type, false);

return $argument->isRequired() ? "$name: $signature" : "$name?: $signature";
},
$this->arguments
);

return 'array{' . implode(', ', $parameters) . '}';
return '`array{' . implode(', ', $parameters) . '}`';
}

public function count(): int
Expand Down
2 changes: 1 addition & 1 deletion src/Mapper/Object/DateTimeObjectBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public function build(array $arguments): DateTimeInterface

if (! $date) {
// @PHP8.0 use throw exception expression
throw new CannotParseToDateTime((string)$datetime);
throw new CannotParseToDateTime($datetime);
}

return $date;
Expand Down
Loading

0 comments on commit 60a6656

Please sign in to comment.