Skip to content

phphd/exceptional-matcher

Repository files navigation

Exceptional Matcher 🏹

πŸ’Ό Match the Exceptions to the Object's Properties

Build Status Codecov PHPStan level Psalm level Type coverage Packagist downloads Licence

A lightweight bridge from domain exceptions to validation violations.

Exceptional Matcher.svg

Your domain code that processes Dto (e.g. services / value objects) can throw a business exception.
Using Matcher, you can correlate it to the property that originated it – allowing to return precise field-specific validation errors inferred from the exceptions.

Thence it makes up for what was lacking in tools for relating validation exceptions to their originator fields.

Quick Start ⚑

Install πŸ“₯

  1. Require via composer:

    composer require phphd/exceptional-matcher
  2. [Symfony] enable the bundles in the bundles.php:

    PhPhD\ExceptionalMatcher\Bundle\PhdExceptionalMatcherBundle::class => ['all' => true],
    PhPhD\ExceptionToolkit\Bundle\PhdExceptionToolkitBundle::class => ['all' => true],

    Note: PhdExceptionToolkitBundle is a required dependency
    that provides exception unwrapping needful for this library.

  3. [Non-Symfony] configure the container:

    You can use features of this library outside frameworks.
    See Standalone Usage.

Map and Throw 🎯

Mark a command or dto with #[Try_] attribute, and properties with #[Catch_].

#[Try_] lets the matcher know it's included for processing.
#[Catch_] defines rules about "what" and "how" to catch.

Finally, throw those exceptions from your use-case handler:

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

#[Try_]
class UserRegistration
{
    #[Catch_(LoginAlreadyTakenException::class)]
    public string $login;

    #[Catch_(PasswordCompromisedException::class)]
    public string $password;

    public function process(UserRegistrationServices $services): void
    {
        $userWithTheSameLogin = $services->userRepository->whereLogin($this->login)->firstOrNull();

        if (null !== $userWithTheSameLogin) {
            throw new LoginAlreadyTakenException($this->login);        
        }

        if ($services->passwordSecurity->isCompromised($this->password)) {
            throw new PasswordCompromisedException($this->password);
        }

        $services->entityManager->persist(new User($this->login, $this->password));
        $services->entityManager->flush();
    }
}

Note: in this example UserRegistration is created both for data (DTO) and behavior (Service) over this data,
providing a well-understood Object-Oriented Design.

These mappings describe what exceptions what properties correlate with.

Here, LoginAlreadyTakenException is bound to the login property,
while PasswordCompromisedException is bound to the password property.

You can have additional matching conditions beyond just the exception class name.
See Match Conditions πŸ–‡οΈ.

The equivalent (very rough) simplified manual logic if not using this library:

$registration = new UserRegistration('jzs', 'jn3.16');

$errors = [];
try {
    return $registration->process($services);
} catch (LoginAlreadyTakenException $e) {
    $errors['login'] = $e->getMessage();
} catch (PasswordCompromisedException $e) {
    $errors['password'] = $e->getMessage();
}

Catch and Match 🎣

Consider the parable of fisherman.

One man went out to fish carps.
He casts a fishing rod and waits...
And there it is! It's biting!
In anticipation of a good catch, he starts reeling it up...
Yet, to his disappointment, it's a fry and not a fish!
He throws it back. The second time does he cast the line...
Now, he gets a good old 2kg carp.
He takes and roasts it to eat.

The code:

use PhPhD\ExceptionalMatcher\ExceptionMatcher;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Validator\ConstraintViolationListInterface;

class RegisterUserApiPoint
{
    public function __construct(
        /** @var ExceptionMatcher<ConstraintViolationListInterface> */
        #[Autowire(service: ExceptionMatcher::class.'<'.ConstraintViolationListInterface::class.'>')]
        private ExceptionMatcher $matcher,
    ) {}

    #[Route(path: '/register', methods: ['POST'])]
    public function __invoke(#[MapRequestPayload] UserRegistration $registration): Response
    {
        try {
            $registration->process($this->services);

            return new Response(status: HTTP_CREATED);
        } catch (Throwable $exception) {
            return $this->handleError($exception, $registration);
        }
    }

    private function handleError(Throwable $exception, UserRegistration $registration): Response
    {
        /** @var ?ConstraintViolationListInterface $violationList */
        $violationList = $this->matcher->match($exception, $registration);

        if (null === $violationList) {
            throw $exception;
        }

        return new JsonResponse($violationList, HTTP_UNPROCESSABLE_ENTITY);   
    }
}

The cue to a parable:

  • Exception is the fish;
  • Code that catches is the fisherman;
  • Data object's mappings - man's expectation of a carp;
  • ConstraintViolation - the fish after roasting.

Fisherman evaluates the fish against his expectation -
ExceptionMatcher matches the exception against UserRegistration object.

Fisherman roasts the fish -
ExceptionMatcher returns the ConstraintViolation list (or custom format) created of the exception.

Fisherman releases the fry -
ExceptionMatcher didn't match, and the main code throws $exception; back.

Finally, the created ConstraintViolationList contains violation-objects with matched property path, message translation, and invalid value.

You can serialize it into a json-response or render on a form.

{
    "propertyPath": "login",
    "invalidValue": "jzs",
    "message": "Login is already taken. Try another one."
}

Why Exceptional Matcher ✨

Exceptional Matcher aims for a full-fledged expressive domain-embedded validation that makes full use of exceptions. \

With Exceptional Matcher you can omit any peripheral validation off of your dto objects,
and rely solely on validation in real code (services, value objects) – that belongs to and resides in the domain.

Read more in: Exceptional Validation.

Where is the Power πŸš€

Consider another use-case:
After registration, the user should be able to update his profile (login, password).
Updating the login must ensure its uniqueness in spite of the current user.

Here's what we'd have to do with an upfront attribute-driven validation:

#[UniqueEntity(
    fields: ['login'],
    entityClass: User::class,
    identifierFieldNames: ['user' => 'id'],
)]
class UpdateUserProfileDto
{ ... }

Compare this to #[Catch_] and discern which communicates the intent better.

#[Catch_(LoginAlreadyTakenException::class)]
public string $login;

The first approach is very imperative, verbose.
The second declaratively states the fact.

Moreover, now you don't restrain yourself by the framework's limitations.
You can implement just anything you need just as fast and just as good as possible.

Now, the mapping for profile update Dto is just as high-level as with registration Dto:

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

#[Try_]
class UpdateUserProfileDto
{
    public User $user;

    #[Catch_(LoginAlreadyTakenException::class)]
    public string $login;

    #[Catch_(PasswordCompromisedException::class)]
    #[Catch_(PasswordCannotBeReusedException::class)]
    public string $password;
}

No custom validators, no attribute-driven-rules - just pure business description.

The main code is just as simple as it could be:

$userWithTheSameLogin = $userRepository->whereLogin($dto->login)->firstOrNull();

if ($userWithTheSameLogin?->is($currentUser) === false) {
    throw new LoginAlreadyTakenException($dto->login);
}

We've reused the same LoginAlreadyTakenException as used in registration, yet under another condition.

This communicates the design much better than what we've seen thus far.

This is where the power comes from. You don't cram the validation into the framework.
You broaden the framework so that it embraces your validation in a way that it naturally fits in.

Interaction approaches πŸ”

The library provides a few interaction points:

Features πŸ’Ž

#[Try_] and #[Catch_] attributes allow implementation of very flexible matching rules.
It's highly recommended to get acquainted with the examples to apprehend the full power of these solutions.

There are two configuration features:

That's really all this library does – matches the exception and formats it.

Cheat Sheet πŸ“

For a cheat-sheet example of configuration, check the following:

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

use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Enum\enum_value;
use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Uid\uid_value;
use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Value\exception_value;
use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Validator\validated_value;
use const PhPhD\ExceptionalMatcher\Validator\Formatter\Validator\validator_violations;

#[Try_]
class ImportProductDto
{
    #[Catch_(InvalidUidException::class, match: uid_value, message: 'This is not a valid UUID.')]
    public string $id;

    #[Catch_(CategoryNotFoundException::class, match: exception_value)] // Message is derived from Exception
    public string $categoryId;

    #[Catch_(\ValueError::class, from: ProductStatus::class, match: enum_value, message: 'The value you selected is not a valid choice.')]
    public string $status;

    #[Catch_(ValidationFailedException::class, from: ProductDescription::class, match: validated_value, format: validator_violations)]
    public string $description;

    #[Catch_(BackorderDisabledForCategoryException::class, if: [self::class, 'thisProductViolatesBackorder'])]
    public ?int $backorderLimit;

    /**
     * Needed in case of deep analysis.
     * 
     * If this method returns TRUE, the exception is linked to $backorderLimit of *this object*;
     * otherwise this exception has nothing to do with this object. 
     */
    public function thisProductViolatesBackorder(BackorderDisabledForCategoryException $exception): bool
    {
        if ($exception->categoryId !== $this->categoryId) {
            return false; // Backorder configuration of the given category has nothing to do with this category.
        }

        if (null === $this->backorderLimit) {
            return false; // The product didn't even enable backorder, much less violated it.
        }

        return true;
    }
}

Deep analysis 🌊

The matcher automatically picks all nested objects for analysis, provided that they define #[Try_] attribute.

use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;
use Symfony\Component\Validator\Constraints as Assert;

#[Try_]
class ImportProductBatchDto
{
    /** @var ImportProductDto[] */
    public array $items;
}

With nested matching with array properties, property paths are formatted differently.

In the example above, when the exception is matched, the path would be items[<index>].<filed>:

  • <index> - a particular array index;
  • <field> - a particualr property name of that object.

When nesting is really deep, the resulting property path of the formatted violation would include all intermediary properties in its path, starting from the root, down to the leaf item where the exception was actually matched.

Need for conditions

Finding a match for the exception in array field is like finding your luggage in the baggage claim
when everyone else took just the same alike red backpack as you did.

Red Backpack

When many backpacks are as yours, you must know which one is yours.

Similarly, finding a match for BackorderDisabledForCategoryException across ImportProductDto[] must know which one to relate to, lest it would choose the first object by exception's class: condition (i.e. "grab the first red one and go").

To find your backpack, you would look at some other characteristics that discern it from the rest,
yea, up to the point of opening it and discovering (or not discovering) your stuff in there.

if ($exception->categoryId !== $this->categoryId) {
    // not my backpack
}

That's what if: condition is there for – to relate an exception to $this particular object.

In our example, we check that the object's category (e.g. stuff in a backpack) is the same as the one we seek for of the exception.

If the category is different, the object is skipped and another is taken for consideration.

The same applies to the products that don't enable backorder (backorderLimit is not filled):

if (null === $this->backorderLimit) {
    // It's not my BackorderDisabledForCategoryException! I didn't enable backorder! 
}

Thus, we prevent false attribution of the exception to an object that had nothing to do with it.

Advanced πŸ› οΈ

Standalone Usage πŸ”§

If you are not using a Symfony framework, you can still have a great advantage of this library.

In your vanilla project, create a Service Container (symfony/dependency-injection is required)
and use it to get necessary services:

use PhPhD\ExceptionalMatcher\Bundle\DependencyInjection\PhdExceptionalMatcherExtension;
use PhPhD\ExceptionalMatcher\ExceptionMatcher;
use PhPhD\ExceptionalMatcher\Exception\MatchedExceptionList;

$container = (new PhdExceptionalMatcherExtension())->getContainer([
    // These are not used but still required by Symfony DI
    'kernel.environment' => 'prod',
    'kernel.build_dir' => __DIR__.'/var/cache',
]);

$container->compile();

/** @var ExceptionMatcher<MatchedExceptionList> $matcher */
$matcher = $container->get(ExceptionMatcher::class.'<'.MatchedExceptionList::class.'>');

Herein, you create a Container, compile it, and use to get ExceptionMatcher.

Upgrading πŸ‘»

The basic upgrade should be performed by Rector using ExceptionalMatcherSetList
that comes with the library and contains automatic upgrade rules.

To upgrade a project to the latest version of exceptional-matcher,
make the following configuration to your rector.php file:

use PhPhD\ExceptionalMatcher\Upgrade\ExceptionalMatcherSetList;

return RectorConfig::configure()
    ->withPaths([ __DIR__ . '/src'])
    ->withImportNames(removeUnusedImports: true)
    // Upgrading from your version (e.g. 1.4) to the latest version
    ->withSets(ExceptionalMatcherSetList::fromVersion('1.4')->getSetList());

Make sure to specify your current version of the library so that upgrade sets will be matched correctly.

You should also check UPGRADE.md for the list of breaking changes and additional instructions.

About

Match exceptions to the properties that originated them

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages