Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
877 changes: 278 additions & 599 deletions README.md

Large diffs are not rendered by default.

Binary file added assets/Mechanic at work under the vehicle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/Red Backpack.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/Sending money to a friend.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
{
"name": "phphd/exceptional-validation",
"description": "Match exceptions with the properties that caused them",
"description": "Match exceptions to the properties that originated them",
"type": "symfony-bundle",
"license": "MIT",
"authors": [
{
"name": "Yevhen Sidelnyk",
"email": "zsidelnik@gmail.com"
},
{
"name": "Jesus – Savior and Son of God"
}
],
"minimum-stability": "stable",
Expand Down
182 changes: 182 additions & 0 deletions docs/config/match-conditions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Match Conditions 🖇️

## Exception Class Condition

A bare minimum condition.

Matches the exception by its class name using `instanceof` check, \
acting similarly to `catch` operation.

```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;

#[Try_]
class SubmitOrderCommand
{
#[Catch_(OrderSubmissionPeriodClosedException::class)]
public string $id;
}
```

## Origin Source Condition

Filters the exception by its origin place, \
specifying whence it was to be raised from (class name and method name).

```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;
use Symfony\Component\Uid\Uuid;

#[Try_]
class ConfirmParcelDeliveryCommand
{
#[Catch_(\InvalidArgumentException::class, from: [Uuid::class, 'fromString'])]
public string $uid;
}
```

In this example `InvalidArgumentException` is a generic one, possibly originating from multiple places. \
If you want to catch only those that belong to `Uuid` class, specify `from:` clause with class and method name.

Therefore, Exception Matcher will analyse the exception trace \
and check whether the exception was originated from that origin `from:` place.

## When-Closure Condition

`#[Catch_]` attribute allows to specify `if:` argument with a callback function to be used to determine \
whether particular instance of the exception should be matched with a given property or not. \
This is particularly useful when the same exception could be originated from multiple places:

```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;

#[Try_]
class TransferMoneyCommand
{
#[Catch_(CardBlockedException::class, if: [self::class, 'isWithdrawalCard'])]
public int $withdrawFromCardId;

#[Catch_(CardBlockedException::class, if: [self::class, 'isDepositCard'])]
public int $depositToCardId;

public function isWithdrawalCard(CardBlockedException $exception): bool
{
return $this->withdrawFromCardId === $exception->getCardId();
}

public function isDepositCard(CardBlockedException $exception): bool
{
return $this->depositToCardId === $exception->getCardId();
}
}
```

In this example, once we've matched `CardBlockedException` by class, custom closure is called.

If `isWithdrawalCard()` callback returns `true`, the exception is matched for `withdrawalCardId` property.

Otherwise, we analyse `depositCardId`, and if `isDepositCard()` callback returns `true`, \
then the exception is matched for this property.

If neither of them returned `true`, then exception is re-thrown upper in the stack.

## Uid Condition

You can match Symfony's `InvalidArgumentException` from the `Uid` component
using `InvalidUidExceptionMatchCondition`:

```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;
use Symfony\Component\Uid\Exception\InvalidArgumentException as InvalidUidException;

use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Uid\uid_value;

#[Try_]
class ApproveVerificationCommand
{
#[Catch_(InvalidUidException::class, match: uid_value)]
public string $id;
}
```

This condition compares exception's `invalidValue` with the property value. \
If they are equal, the exception is matched for this property, otherwise other properties are analysed (if any).

Only string property values are allowed for this condition.

> This condition is registered only when `symfony/uid` is installed and exposes
> `Symfony\Component\Uid\Exception\InvalidArgumentException::$invalidValue`.

## ValueException Condition

Since in most cases matching conditions come down to the simple value comparison, it's easier to make the exception
implement `ValueException` interface and specify `match: ExceptionValueMatchCondition::class` instead of
implementing `if:` closure every time.

This way it's possible to avoid much of the boilerplate code, keeping it clean:

```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;

use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Value\exception_value;

#[Try_]
class TransferMoneyCommand
{
#[Catch_(CardBlockedException::class, match: exception_value)]
public int $withdrawalCardId;

#[Catch_(CardBlockedException::class, match: exception_value)]
public int $depositCardId;
}
```

In this example `CardBlockedException` could be matched either with `withdrawalCardId` or with `depositCardId`, \
depending on the `cardId` value from the exception.

And `CardBlockedException` itself must implement `ValueException` interface:

```php
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Value\ValueException;

class CardBlockedException extends DomainException implements ValueException
{
public function __construct(private Card $card)
{
parent::__construct('card.blocked');
}

public function getValue(): int
{
return $this->card->getId();
}
}
```

## ValidationFailedException Condition

This one is very similar to `ValueException` condition \
with the difference that it integrates Symfony's native `ValidationFailedException`.

Specify `validated_value` match condition to compare property's value against exception's validated value:

```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;
use Symfony\Component\Validator\Exception\ValidationFailedException;

use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Validator\validated_value;
use const PhPhD\ExceptionalMatcher\Validator\Formatter\Validator\validator_violations;

#[Try_]
class RegisterUserCommand
{
#[Catch_(ValidationFailedException::class, from: Password::class, match: validated_value, format: validator_violations)]
public string $password;
}
```
151 changes: 151 additions & 0 deletions docs/config/violation-formatters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Violation Formatters 🎨

Violation Formatters are used to represent the exception in a desired format.

There are two built-in violation formatters you can use:
- `MainExceptionViolationFormatter`;
- `ViolationListExceptionFormatter`.

If needed, you can create a custom violation formatter as described below.

## Main

`MainExceptionViolationFormatter` is used by default if another formatter is not specified.

It provides a basic way of creating a `ConstraintViolation` with these parameters: \
`$root`, `$message`, `$propertyPath`, `$value`.

> The default messages translation domain is `validators`, \
> inherited from `validator.translation_domain` parameter.
>
> You can change it by setting `phd_exceptional_matcher.translation_domain` parameter.

## Constraint Violation List Formatter

`ViolationListExceptionFormatter` allows formatting the exceptions that implement `ViolationListException`.

These contain `ConstraintViolationList` from the validator:

```php
use PhPhD\ExceptionalMatcher\Validator\Formatter\ViolationList\ViolationListException;
use Symfony\Component\Validator\ConstraintViolationListInterface;

final class CardNumberValidationFailedException extends \RuntimeException implements ViolationListException
{
public function __construct(
private readonly string $cardNumber,
private readonly ConstraintViolationListInterface $violationList,
) {
parent::__construct('Card Number Validation Failed');
}

public function getViolationList(): ConstraintViolationListInterface
{
return $this->violationList;
}
}
```

Then, specify `included_violations` as a `format:` for the `#[Catch_]` attribute:

```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;

use const PhPhD\ExceptionalMatcher\Validator\Formatter\ViolationList\included_violations;

#[Try_]
class IssueCreditCardCommand
{
#[Catch_(CardNumberValidationFailedException::class, format: included_violations)]
private string $cardNumber;
}
```

Thus, once `cardNumber` property gets a hold of `CardNumberValidationFailedException`, \
formatter makes sure that a proper representation of this exception in a `ConstraintViolation` form is created for this property.

> If `#[Catch_]` attribute specified a message, \
> it would've been ignored in favour of `ConstraintViolationList` messages.


> Besides that, it's also possible to use `validator_violations` formatter, \
> which can format Symfony's native `ValidationFailedException`.

## Custom Violation Formatters 🎨🖌️

In some cases, you might want to customize the created violations. \
For example, pass additional parameters to the message translation.

You can create custom violation formatter by implementing `ExceptionViolationFormatter` interface:

```php
use PhPhD\ExceptionalMatcher\Exception\MatchedException;
use PhPhD\ExceptionalMatcher\Validator\Formatter\ExceptionViolationFormatter;
use Symfony\Component\Validator\ConstraintViolationInterface;

/** @implements ExceptionViolationFormatter<LoginAlreadyTakenException> */
final class LoginAlreadyTakenViolationFormatter implements ExceptionViolationFormatter
{
public function __construct(
#[Autowire(service: ExceptionViolationFormatter::class.'<Throwable>')]
private ExceptionViolationFormatter $formatter,
) {
}

/** @return array{ConstraintViolationInterface} */
public function format(MatchedException $matchedException): ConstraintViolationInterface
{
// format violation with the default formatter
// and then adjust only the necessary parts
[$violation] = $this->formatter->format($matchedException);

/** @var LoginAlreadyTakenException $exception */
$exception = $matchedException->getException();

$violation = new ConstraintViolation(
$violation->getMessage(),
$violation->getMessageTemplate(),
['loginHolder' => $exception->getLoginHolder()],
// ...
);

return [$violation];
}
}
```

Then, register it as a service:

```yaml
services:
App\Auth\User\Support\Validation\LoginAlreadyTakenViolationFormatter:
autoconfigure: true
```

> In order for violation formatter to be recognized by the bundle, \
> its service must be tagged with `MatchedExceptionFormatter` class-name tag.
>
> If you are using [autoconfiguration](https://symfony.com/doc/current/service_container.html#the-autoconfigure-option),
> this will be done automatically by the service container, \
> owing to the fact that `MatchedExceptionFormatter` interface is implemented.

Finally, specify formatter in the `#[Catch_]` attribute:

```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;

#[Try_]
final class RegisterUserCommand
{
#[Catch_(LoginAlreadyTakenException::class, format: LoginAlreadyTakenViolationFormatter::class)]
private string $login;

#[Catch_(WeakPasswordException::class, format: WeakPasswordViolationFormatter::class)]
private string $password;
}
```

In this example, `LoginAlreadyTakenViolationFormatter` formats constraint violation for `LoginAlreadyTakenException`, \
while `WeakPasswordViolationFormatter` formats `WeakPasswordException`.
Loading
Loading