phphd/exceptional-validation-bundle

Mapping domain exceptions to symfony validator format

1.1.0 2024-04-13 07:42 UTC

This package is auto-updated.

Last update: 2024-04-13 08:07:03 UTC


README

🧰 Provides Symfony Messenger middleware allowing to capture any thrown exception and map it into Symfony Validator violations format in accordance with message property path.

Build Status Codecov Psalm coverage Psalm level Packagist Downloads Licence

Installation 📥

  1. Install via composer

    composer require phphd/exceptional-validation-bundle
  2. Enable the bundle in the bundles.php

    PhPhD\ExceptionalValidationBundle\PhdExceptionalValidationBundle::class => ['all' => true],

Configuration ⚒️

To leverage features of this bundle, you should add phd_exceptional_validation middleware to the list:

framework:
    messenger:
        buses:
            command.bus:
                middleware:
                    - validation
+                   - phd_exceptional_validation
                    - doctrine_transaction

Usage 🚀

When the exception is thrown from the handler, the message that is mapped by #[ExceptionalValidation] attribute is analyzed for #[Capture] properties attributes. If a mapping defines this exception, it will be processed.

Here is an example of mapped message:

use PhPhD\ExceptionalValidation;

#[ExceptionalValidation]
final readonly class CreateVacationRequestCommand
{
    public function __construct(
        public Employee $employee,
        
        #[ExceptionalValidation\Capture(VacationTypeNotFoundException::class, 'vacation.type_not_found')]
        public int $vacationTypeId,
        
        #[Assert\DateTime]
        public string $startDate,

        #[Assert\DateTime]
        #[ExceptionalValidation\Capture(InsufficientVacationBalanceException::class, 'vacation.insufficient_balance')]
        public string $endDate,
    ) {
    }
}

As you can see, certain properties have #[Capture] attributes defined. These specify the specific exception class to be intercepted and the corresponding validation message to be shown when the exception occurs.

Finally, when the exception has been captured, ExceptionalValidationFailedException is thrown:

$message = new CreateVacationRequestCommand($user, $vacationTypeId, $startDate, $endDate);

try {
    $this->commandBus->dispatch($message);
} catch (ExceptionalValidationFailedException $exception) {
    // Is thrown when handler failed with VacationTypeNotFoundException or InsufficientVacationBalanceException

    return $this->render('vacationForm.html.twig', ['errors' => $exception->getViolations()]);
} 

As you can see in the example above, $exception object has constraint violation list with respectively mapped error messages. This error list may be used in various ways such as displaying on an HTML page, formatting into a JSON response, logging into file, rethrowing different exception, or any other specific requirement you might have.

Advanced usage ⚙️

The ExceptionalValidation and Capture attributes can be used in more complex scenarios to provide robust error handling and validation for your application. Here's an example of how you can use these attributes for advanced use cases.

Capturing Multiple Exceptions

You can capture multiple exceptions for a single property by adding multiple Capture attributes. Each Capture attribute can specify a different exception class and validation message.

use PhPhD\ExceptionalValidation;

#[ExceptionalValidation]
final class AdvancedMessage
{
    #[ExceptionalValidation\Capture(FirstException::class, 'first_error')]
    #[ExceptionalValidation\Capture(SecondException::class, 'second_error')]
    private string $property;
}

In this example, if FirstException or SecondException is thrown, it will be captured and mapped to the property with the corresponding validation message.

Nested Exception Handling

The Capture attribute can also be used on nested objects to handle exceptions at different levels of your object hierarchy.

use PhPhD\ExceptionalValidation;
use Symfony\Component\Validator\Constraints\Valid;

#[ExceptionalValidation]
final class ParentMessage
{
    #[Valid]
    private NestedMessage $nestedMessage;
}

#[ExceptionalValidation]
final class NestedMessage
{
    #[ExceptionalValidation\Capture(NestedException::class, 'nested_error')]
    private string $nestedProperty;
}

In this example, if NestedException is thrown, it will be captured and mapped to the nestedProperty of the NestedMessage object. Hence, violation property path would be nestedMessage.nestedProperty.

Besides that, you can also apply validation rules for nested iterable objects. Here's an example:

use PhPhD\ExceptionalValidation;
use Symfony\Component\Validator\Constraints\Valid;

#[ExceptionalValidation]
final class ParentMessage
{
    #[Valid]
    private array $nestedItems;
}

#[ExceptionalValidation]
final class NestedItem
{
    #[ExceptionalValidation\Capture(NestedItemException::class, 'nested_item_error')]
    private string $itemProperty;
}

In this example, if NestedItemException is thrown, it will be captured and mapped to the itemProperty of the NestedItem object. Hence, violation property path would be nestedItems[*].itemProperty, where * stands for index.

Conditional Exception Capturing with Callbacks

The Capture attribute can also accept a callback function that determines whether the exception should be captured or not. This allows for more complex and dynamic exception handling scenarios.

Here's an example:

use PhPhD\ExceptionalValidation;

#[ExceptionalValidation]
final class ConditionalMessage
{
    #[ExceptionalValidation\Capture(ConditionallyCapturedException::class, 'oops', when: [self::class, 'firstPropertyMatchesException'])]
    private int $firstProperty;

    #[ExceptionalValidation\Capture(ConditionallyCapturedException::class, 'oops', when: [self::class, 'secondPropertyMatchesException'])]
    private int $secondProperty;

    public function firstPropertyMatchesException(ConditionallyCapturedException $exception): bool
    {
        return $exception->getConditionValue() === $this->firstProperty;
    }

    public function secondPropertyMatchesException(ConditionallyCapturedException $exception): bool
    {
        return $exception->getConditionValue() === $this->secondProperty;
    }
}

In this example the when option of the Capture attribute specifies a callback function (firstPropertyMatchesException and secondPropertyMatchesException) that is called when the exception is processed. If the callback returns true, the exception is captured; if it returns false, it is not captured.