diff --git a/README.md b/README.md index dca148af..287f9fcf 100644 --- a/README.md +++ b/README.md @@ -10,809 +10,488 @@ [![Packagist downloads](https://img.shields.io/packagist/dt/phphd/exceptional-validation?logo=data%3Aimage%2Fpng%3Bbase64%2CiVBORw0KGgoAAAANSUhEUgAAACwAAAAsCAMAAAApWqozAAAC%2FVBMVEUAAAAFBQVVWF8DBAQAAAABAQEMDAy1u8slJioDBAQCAQEAAAAAAAAAAACPk6CDg4RJS1I6PEFjZ28uLzMmEQIlDwAJCAgKCAcDBAQAAAABAQEAAAAAAAAAAAAAAABxdX4oDgAjDgAfHyEcHSAXFxgWFhYEBAUZGRwdHiAJBAEHAgAODg8EBAUEBQUAAABMTEyYnatxdYCJjpqFipV9gYwhIiUdHR5scHlmanN8gIsfDABucnthZG0bHB5DRUsvMTUTBgBWWWBDRktMT1YRERMkDQAkJSkVFhgrEAA3OT5ERkwrLDBSVVwuMDMqEQEGAQAfHyIRBwAGBgYpFAYsEAAKCgsNDQ8xMzgmJysODxATBwAICAkZGh0fHyI5O0ArLTEXFxoHBwdOTk7Z4PPZ4fTb4vXQ1%2BnW3e%2FU2%2B3CyNnc4%2FbY3%2FLX3vDN1OXM0uSUmaZfYmra4fXIz%2BHGzN2lq7mBOADT2uzEy9y%2Fxtaorr2Wm6h%2Bgo1bXmZQU1o2OD0CAQDHzt%2B9w9S5vs%2Bxt8ado7Can6yMkJ1scHhnanNhZG5dYGhGSU8%2FQkhfKAD%2F%2F%2F%2FS2erK0OK2vMuvtcW8vLyiqLeepLKIjJeEiJSBhZF1eYRwdH5kaHFSVVxJS1FDRUs%2BQEU9PkIwMTYlJipSIwBEHQDd5Pfw8PDU2%2B7P1efDytm%2BxNK7wdG5wNCus8OsssGTl6OJjZl7f4p6foh5fIZ2e4V%2BfoBgYmVhYWJWWWFOUFhLTlUfHyEUFBYODg4DBghtLgBXJAD29fXs7Oy0ucmhp7SoqKiQkJF2eoJ4eXpqbXZycnIPQl1KTFNOTk8FNU9KS046PEE0NDchIiUYGx8CEBlwMABkKgBZJgD6%2Bvrd5fff39%2FU0tHIycisscAAeLeysrOsrKwGcqmmpKMEaaCQk52YmJkAYZWHiYtxdYAGT3YmU2sVTmsfTWZdXV0AOVgySldVVVYDK0IbMD0AJTssLjIqKiopIyWGOwB8NgB4NABLIABBGQA8GQA3FgAsEgCoWa7%2BAAAAY3RSTlMACf5REQ4H%2FpiQgkQtIf7%2B7ejm1aqainFjXTkzJRsW%2Fv38sKainZyQfn5zbmtNNQr9%2B%2Fr4%2BPf39vbz8%2B7u7uzr6%2Bbj4N%2Fd29XV0s7My8vKyr29ubi4s6ypo5ycmJOLeHNsXg8rMEp9AAAD%2FElEQVQ4y6WUZXDbUBCElaRtmqTMzMzMzMzM7T2Z2a4ZQmY7nIY5ZWZuU2ZmZmbmTiXbsV1wnJnujzen0aedu5V02L%2FUel5rrJAqNndM%2BZEzSxaK9WnQLkQiuDHIrzBw8TA%2BEArtGFi0AKpkk0YlsBIN4wCRMKJoyzVu5QEtMaOcP7d3vV5asAsBUxhTtqVf8SmVqhT7w3VqD%2FlaZQxFSE0ysmwwX5tmoES192dHRWdW%2FY31K1uKp088%2FV4MQNXxKCQsN7GJk5ayVbEvdIB790UGL9sWiUO0iRGi3S%2BV0BEhKgvEdHbw9nWqs6LEQDe4Zl%2BZHAE%2FWZSwKTZ24wLc1jNTt%2FHgYcXxxbjQuKRMdTe4u4SGYGH6shOL9lxbtFlog7kH9%2B65XHobUa5aI2rhgmt0xsnp01eXvv783tPDOhJemKo%2FduzRVR75XFRYU7fYapMwx7z%2F6KubJ5%2Bd44ZzZRJQvzt558ndUxwyxRWVXHC12jhCEJ1Jy3jzctfOXdv0hrTTmdmanW8ffsjaggAhemMXHHSEdFZuwP3P7dJoNlDJzMKyNJrz57dSLGsIH0Fxt5j1OPBXEylEblHz4pD9dQefMabIcFieLUawZLILnh5O3E2iAUpW4Ay6SCalko2u5B1iI2CkcBCw%2BtfKZ2v1ZANQlTSIVFNiTMnhIWwWCQMzTk0BhoFNlG1a5sNVVQwAvjGeY96hsLBtnP24khUNFPViopQ4m26gIydeoVZtPJNtSwrZBMylO1RSE5esWeXzv7yG8aQTg3ciYvNqAJdzcMbRA9mJthKV9XHAlRXkJVr3IOKFv50L1Yn4AEtOLYo10QVkOjSncytfMT0GGMbYiNfr7bCYrreEsaibDmSaN6TKCJhe3%2Fk7%2B81qPq7UUtWtiM2PkaMNxoLtlvU7zCtxJNzCZSFuZcxNNefMbl567yblQsdwXEPY%2Bp1rWETJFNwvM6JJEewP1bt9SLmUiJeMOARwldyWjLRDmRY%2B2F%2Bq1ilh31b6WoojjSjipIb3q1LDhbqrWQKeeFYG4MyPciSjqsfNUQ44aTQXHGlY0bWGx801jImS5M6XQvOViod6Xkv12SCxCB2BCFLkaPkkzKOCfFmwjBeC275n5XKAUlUwzwrgUUHg6xsvoGuPhxIDDi%2BKFaBGqcSAoYrkpLXBCPAuQQVv54C25Gw4vli0gBEX4G2XT4znI1iVnprAkX6s5gUuMmQ7B0CYTkWcrG%2FNvMBN69ZJC1%2B1zrxSlKH5OcGLcUBdqzXna05e3iXrhTpjixZMB1789Hm3NSf3e651d26QF%2BfRA%2Fv8yM3Z%2FeXCxbxulap76bkWFnhpvl2jfDCvqj6tYsWKFSpUGF8Z%2Bx%2F9Aipg0qzWNVY9AAAAAElFTkSuQmCC&color=%23F28D1A&cacheSeconds=3600)](https://packagist.org/packages/phphd/exceptional-validation) [![Licence](https://img.shields.io/github/license/phphd/exceptional-validation.svg?color=3DA639)](https://github.com/phphd/exceptional-validation/blob/main/LICENSE) -A library that matches exceptions against object properties. +A lightweight bridge between domain exceptions and validation violations. -**No** longer do you need any **peripheral validation** for your objects \ -that doesn't really dig down into the domain. +Throw domain exceptions from your code (services / value objects), +and let this library match them to property paths and format as ConstraintViolations (or your chosen format) – +thus your backend can return field-specific (precise) errors inferred from these exceptions. -Instead, build full-fledged declarative feature-rich domain **validation** with full **use of exceptions** \ -and let this library to **_relate_** these **exceptions _to_** their **originator fields**. +## Quick Start ⚡ -Eventually, you can return a normal validation failed response. +### Install 📥 -## A Validation Library? 🤔 - -It's not a validation library. Not ever intended to be. \ -It doesn't provide validation rules, constraints, or validators. - -It is **exception handling** library, specifically featured with usefulties for validation. - -You can validate business logic with any third-party library (or even plain PHP), \ -while the library will be **_correlating_** these **validation exceptions** to the specific properties \ -whose invalid values caused them. - -It's not a strict requirement to use Symfony Validator as a validation component, \ -though this library integrates it well. - -## Why Exceptional Validation? ✨ - -Ordinarily, validation flows through two different layers: - -- HTTP/form level; -- domain layer. - -It leads to duplication and potential inconsistencies of validation rules. - -### Traditional Validation 🕯️ - -The traditional validation uses an attribute-based approach, \ -which strips the domain layer from most business logic. - -Besides that, any custom validation you'd normally implement in a service \ -must be wrapped in a custom validator attribute and moved away from the service. - -It's all for the sake of being able to display a nice validation message on the form. - -Thus, the domain services and model end up naked, \ -all business rules having been leaked elsewhere. -### Exceptional Validation 💡 - -On the other hand, it's a common practice in DDD for domain objects to be responsible for their own validation rules. - -- `Email` value object validates its own format and naturally throws an exception that represents validation failure. -- `RegisterUserService` normally verifies email is not yet taken and naturally throws an exception. - -That is the kind of code that utterly expresses the model of the business, \ -which should not be stripped down. - -Yet, with a domain-driven approach, it's not possible to use standard validation tools, \ -as these drain domain from all logic. - -How then do we show contextual validation errors to the users? \ -It's a task of relating a thrown exception with the property which value caused this exception. - -To return a neat json-response with `email` as a property path and validation error description, \ -it's necessary to match `EmailAlreadyTakenException` with a `$email` property of the original `RegisterUserCommand`. - -This is what Exceptional Validation was designed for. - -Throwing exceptions like `EmailValidationFailedException` and matching them with the particular form fields as -`$email`, \ -you maintain a **single source of truth** for the domain validation logic. - -Domain enforces its invariants via exceptions in value objects and services, \ -while this library ensures that these validation failures will properly match form fields -and appear correct in your API responses or forms. - -### Key takeways - -Exceptional Validation: - -- Eliminates duplicate validation across HTTP/application and domain layers; -- Keeps business rules where they belong — in the domain; -- Makes validation logic easily unit-testable; -- Reduces complexity of nested validation scenarios; -- Eliminates the need for validation groups and custom validators. - -## Installation 📥 - -1. Install via composer: +1. Require via composer: ```sh composer require phphd/exceptional-validation ``` -2. Enable bundles in the `bundles.php`: +2. \[Symfony\] enable the bundles in the `bundles.php`: - ```php - PhPhD\ExceptionalMatcher\Bundle\PhdExceptionalMatcherBundle::class => ['all' => true], - PhPhD\ExceptionToolkit\Bundle\PhdExceptionToolkitBundle::class => ['all' => true], - ``` + ```php + PhPhD\ExceptionalMatcher\Bundle\PhdExceptionalMatcherBundle::class => ['all' => true], + PhPhD\ExceptionToolkit\Bundle\PhdExceptionToolkitBundle::class => ['all' => true], + ``` - > Note: `PhdExceptionToolkitBundle` is a required dependency\ + > Note: `PhdExceptionToolkitBundle` is a required dependency \ > that provides exception unwrapping needful for this library. -## Get Started 🎯 +3. \[Non-Symfony\] configure the container: -Mark a message with `#[Try_]` attribute. \ -It's used by matcher to include this object for processing. + You can use features of this library outside frameworks. \ + See [Standalone Usage](#standalone-usage-). -Define `#[Catch_]` matching rules for your properties. \ -These declaratively describe what properties what exceptions correlate with: +### Define the Mapping 🔗 + +Mark a command or dto with `#[Try_]` attribute to let the matcher know it's included for processing. + +Define `#[Catch_]` attributes with rules for your properties. ```php use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; #[Try_] -class RegisterUserCommand +class RegisterUserDto { - #[Catch_(LoginAlreadyTakenException::class, message: 'auth.login.already_taken')] + #[Catch_(LoginAlreadyTakenException::class)] public string $login; - #[Catch_(WeakPasswordException::class, message: 'auth.password.weak')] + #[Catch_(PasswordCompromisedException::class)] public string $password; } ``` -For example, we say that `login` property is related to `LoginAlreadyTakenException`, \ -while `password` - to `WeakPasswordException`. +> Note: we've named this class as `RegisterUserDto` for the sake of demonstration. \ +> Normally, we'd name it as `RegisterUserCommand` ([CQS](https://martinfowler.com/bliki/CommandQuerySeparation.html)). -Matching takes place when the matcher is used: -```php -use PhPhD\ExceptionalMatcher\ExceptionMatcher; - -/** @var ExceptionMatcher $matcher */ -$matcher = $container->get(ExceptionMatcher::class.'<'.ConstraintViolationListInterface::class.'>'); -$command = new RegisterUserCommand($login, $password); - -try { - $this->service->register($command); -} catch (DomainException $exception) { - $violationList = $matcher->match($exception, $command); - - return new JsonResponse($violationList, 422); -} -``` - -Each exception, when matched, results in a `ConstraintViolation` object, \ -which contains a property path, and a message translation. - -You can serialize this violation list into a json-response or render a form with it. - -> Note that 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. - -## How is this different from a standard validation? ⚖️ - -Conceptually. - -If you're wondering why we wouldn't use "normal" validation asserts right in the command, \ -I'll say to you that this is not always best / convenient. - -For example, let's take the same `RegisterUserCommand` as used before. - -A comparison of the approaches would look something like this: +These describe what exceptions what properties correlate with. -```diff -+#[Try_] - class RegisterUserCommand - { -- #[AppAssert\UniqueLogin] -+ #[Catch_(LoginAlreadyTakenException::class, message: 'auth.login.already_taken')] - public string $login; +Here, `LoginAlreadyTakenException` is related to the `login` property, \ +while `PasswordCompromisedException` is related to the `password` property. -- #[Assert\PasswordStrength(minScore: 2)] -+ #[Catch_(WeakPasswordException::class, message: 'auth.password.weak')] - public string $password; - } -``` - -The main difference between the two is that standard validation runs before your actual business logic. \ -This alone means that for every domain-specific rule like "login must be unique" it's necessary to create \ -a custom validation constraint and a validator that implements this business logic. - -Thereby, the main problem with the standard approach is that domain leaks into validators. \ -That code, which you would've normally implemented in the service, you are obliged to wrap into the validator. - -One more point is that oftentimes there are multiple actions that use the same validations. - -For example, login uniqueness is validated both during registration and during profile update. \ -Even though a "login is unique" rule is conceptually obvious, \ -a validator approach is fraught with problems to check that a user's own login isn't taken into account when validating. - -Exceptional validation doesn't force you to write business logic in any validators. \ -Instead, you can throw an instance of exception in whatever scenario you would like to, \ -and then the library will retroactively analyse it. - -Another example is a password validation, which's used both during registration and during password reset. \ -Using the validation attributes results in duplicated asserts between the two, \ -while this business conceptually belongs to `Password`, \ -which most properly would be represented as a value object, used in both actions. - -With exceptional validation you just write business logic in your domain and then retroactively relate violations. \ -Retroactively — after your business logic has worked out. \ -Representation of the errors to the user is separate from the business logic concern which's managed by this library. +> You can have extra conditions for exception matching beyond just class name. \ +> See [Match Conditions 🖇️](docs/config/match-conditions.md). -Finally, this approach gives a lot of flexibility, \ -removing the need for custom validators, validation groups, duplicate validation rules, \ -allowing you to keep the domain code in the domain objects, \ -resulting in a better design of the system. - -Focus on the domain and let the library take care of the exception representation: +The equivalent (very simplified) manual logic if not using this library: ```php -// RegisterUserService +$errors = []; -if ($this->userRepository->loginExists($command->login)) { - throw new LoginAlreadyTakenException($command->login); +try { + return $this->register($dto); +} catch (LoginAlreadyTakenException $e) { + $errors['login'] = $e->getMessage(); +} catch (PasswordCompromisedException $e) { + $errors['password'] = $e->getMessage(); } ``` -## Direct Usage 🔌 +### Match the Exception 🎯 -> It's possible to use features of this library without necessarily depending on the frameworks. -> See [Standalone Usage](#standalone-usage-) section. - -If you're using Symfony, you can check what exception matchers are available using this command: - -```shell -bin/console debug:container ExceptionMatcher -``` - -This should provide you with a list, similar to this: - -```text -[0] PhPhD\ExceptionalMatcher\ExceptionMatcher -[1] PhPhD\ExceptionalMatcher\ExceptionMatcher -``` - -These matchers format the Exception to their respective format, specified as a generic parameter. \ -Format could be `ConstraintViolationList`, or `MatchedExceptionList`, or anything else dumped by the command. - -Therefore, you can inject the wanted service into your own code: +Matching takes place wherever the matcher is used. \ +Exception, matched against an object, results in a `ConstraintViolation` list (or custom format): ```php use PhPhD\ExceptionalMatcher\ExceptionMatcher; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Validator\ConstraintViolationListInterface; -class SignDocumentActivity +class RegisterUserApiPoint { public function __construct( /** @var ExceptionMatcher */ #[Autowire(service: ExceptionMatcher::class.'<'.ConstraintViolationListInterface::class.'>')] - private ExceptionMatcher $exceptionMatcher, - ) { - } + private ExceptionMatcher $matcher, + ) {} - public function sign(SignCommand $command): string + #[Route(path: '/register', methods: ['POST'])] + public function __invoke(RegisterUserDto $dto): Response { try { - return $command->businessLogic($this); - } catch (DomainException $e) { - throw $this->failure($e, $command); + return $this->register($dto); + } catch (\Exception $exception) { + return $this->validationError($exception, $dto); } } - private function failure(Throwable $e, SignCommand $command): Throwable + private function validationError(\Exception $exception, RegisterUserDto $dto): Response { /** @var ?ConstraintViolationListInterface $violationList */ - $violationList = $this->exceptionMatcher->match($e, $command); + $violationList = $this->matcher->match($exception, $dto); if (null === $violationList) { - return $e; + throw $exception; } - return new ApplicationFailure('Validation Failed', $this->encode($violationList), previous: $e); + return new JsonResponse($violationList, HTTP_UNPROCESSABLE_ENTITY); } } ``` -In this example, we use `ExceptionMatcher` to relate the exception to some property of the `$command`, \ -which produces `ConstraintViolationListInterface` that can be used however you want to. - -## Usage with Command Bus 📇 - -If you are using Symfony Messenger as a Command Bus, \ -it's recommended to use this package -as [Symfony Messenger Middleware](https://symfony.com/doc/current/messenger.html#middleware). - -> If you are not using `Messenger` component, you can still leverage features of this library, \ -> as it provides a rigorously structured set of tools w/o depending on any particular implementation. \ -> Installation of third-party dependencies is optional — they won't be installed unless you need it. - -Add `phd_exceptional_validation` middleware to the list: - -```diff - framework: - messenger: - buses: - command.bus: - middleware: - - validation -+ - phd_exceptional_validation - - doctrine_transaction -``` - -Once you have done this, the middleware will take care of exception capturing, matching, and re-throwing -`ExceptionalValidationFailedException`. - -You can use it to catch and process it: - -```php -$command = new RegisterUserCommand($login, $password); - -try { - $this->commandBus->dispatch($command); -} catch (ExceptionalValidationFailedException $exception) { - $violationList = $exception->getViolationList(); - - return $this->render('registrationForm.html.twig', ['errors' => $violationList]); -} -``` - -This exception just wraps respectively created `ConstraintViolationList` with all your messages and property paths. - -### How it works ⚙️ - -Primarily, it works as -a [Command Bus](https://symfony.com/doc/current/messenger.html#multiple-buses-command-event-buses) -middleware that intercepts exceptions and performs their matching to object's properties by an exception matcher, -eventually formatting matched exceptions as standard [SF Validator](https://symfony.com/doc/current/validation.html) -violations. - -> Besides that, `ExceptionMatcher` is also available for direct use w/o any middleware. \ -> You can reference it as `ExceptionMatcher` service. - -This diagram represents the concept: - -![Exceptional Validation.svg](https://raw.githubusercontent.com/phphd/exceptional-validation/refs/heads/main/assets/Exceptional%20Validation.svg) - -## Standalone Usage 🔧 - -If you are not using a Symfony framework, you still have a great opportunity of taking advantage of this library. - -You can create a Service Container (`symfony/dependency-injection` is required) \ -and use it to get necessary services: - -```php -use PhPhD\ExceptionalMatcher\Bundle\DependencyInjection\PhdExceptionalMatcherExtension; +> Note: response formatting is simplified for the demonstration's sake. -$container = (new PhdExceptionalMatcherExtension())->getContainer([ - 'kernel.environment' => 'prod', - 'kernel.build_dir' => __DIR__.'/var/cache', -]); +Created `ConstraintViolationList` contains violation-objects with matched property path, message translation, and +invalid value. -$container->compile(); +You can serialize it into a json-response or render on a form. -/** @var ExceptionMatcher $matcher */ -$matcher = $container->get(ExceptionMatcher::class.'<'.ConstraintViolationListInterface::class.'>'); +```json +{ + "propertyPath": "login", + "invalidValue": "jzs", + "message": "Login is already taken. Try another one." +} ``` -Herein, you create a Container, compile it, and use to get `ExceptionMatcher`. - -## Features 💎 - -`#[Try_]` and `#[Catch_]` attributes allow implementing very flexible matching rules. \ -It's highly recommended to see the examples below to know the power of these solutions. - -### Match Conditions 🖇️ - -#### Exception Class Condition +### Where is the Power 🚀 -A bare minimum condition. +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. -Matches the exception by its class name using `instanceof` check, \ -acting similarly to `catch` operation. +The mapping of the `Dto` remains just as high-level as before: ```php use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; #[Try_] -class SubmitOrderCommand +class UpdateUserProfileDto { - #[Catch_(OrderSubmissionPeriodClosedException::class)] - public string $id; + public User $user; + + #[Catch_(LoginAlreadyTakenException::class)] + public string $login; + + #[Catch_(PasswordCompromisedException::class)] + #[Catch_(PasswordCannotBeReusedException::class)] + public string $password; } ``` -#### Origin Source Condition +No custom validators, no attribute-driven-rules - just pure business description. \ +This communicates the design much better than what we've seen thus far. -Filters the exception by its origin place, \ -specifying whence it was to be raised from (class name and method name). +The main code is just as simple as it could be: ```php -use PhPhD\ExceptionalMatcher\Rule\Object\Try_; -use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; -use Symfony\Component\Uid\Uuid; +$userWithTheSameLogin = $userRepository->whereLogin($dto->login)->firstOrNull(); -#[Try_] -class ConfirmParcelDeliveryCommand -{ - #[Catch_(\InvalidArgumentException::class, from: [Uuid::class, 'fromString'])] - public string $uid; +if ($userWithTheSameLogin?->is($currentUser) === false) { + throw new LoginAlreadyTakenException($dto->login); } ``` -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. +We've reused the same `LoginAlreadyTakenException` as used in registration, yet under another condition. -#### 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: +Compare this to what we'd have to do with an upfront attribute-driven validation: ```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; +#[UniqueEntity( + fields: ['login'], + entityClass: User::class, + identifierFieldNames: ['user' => 'id'], +)] +class UpdateUserProfileDto +{ ... } +``` - public function isWithdrawalCard(CardBlockedException $exception): bool - { - return $this->withdrawFromCardId === $exception->getCardId(); - } +Compare it to `#[Catch_]` and discern which communicates the intent better. - public function isDepositCard(CardBlockedException $exception): bool - { - return $this->depositToCardId === $exception->getCardId(); - } -} +```php +#[Catch_(LoginAlreadyTakenException::class)] +public string $login; ``` -In this example, once we've matched `CardBlockedException` by class, custom closure is called. +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. -If `isWithdrawalCardBlocked()` callback returns `true`, the exception is matched for `withdrawalCardId` property. +## Interaction approaches 🔁 -Otherwise, we analyse `depositCardId`, and if `isDepositCardBlocked()` callback returns `true`, \ -then the exception is matched for this property. +The library provides a few interaction points: -If neither of them returned `true`, then exception is re-thrown upper in the stack. +- [Matcher Service](docs/interaction/direct-matcher-service-usage.md) – manual handling + (just [as shown](#match-the-exception-)); +- [Bus Middleware](docs/interaction/command-bus-middleware.md) – automated handling. -#### Uid Condition +## Why Exceptional Matcher ✨ -You can match Symfony's `InvalidArgumentException` from the `Uid` component -using `InvalidUidExceptionMatchCondition`: +Exceptional Matcher aims for a full-fledged expressive domain-embedded **validation** that makes **full use of +exceptions**. \ +It makes up for what was lacking in tools for relating these exceptions to their originator fields. -```php -use PhPhD\ExceptionalMatcher\Rule\Object\Try_; -use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; -use Symfony\Component\Uid\Exception\InvalidArgumentException as InvalidUidException; +With Exceptional Matcher you can **omit** any **peripheral validation** off your dto objects, \ +and rely solely on validation in real code (services, value objects) – that resides in and belongs to the domain. -use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Uid\uid_value; +Read more in: [Exceptional Validation](docs/exceptional-validation.md). -#[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). +## Features 💎 -Only string property values are allowed for this condition. +`#[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. -> This condition is registered only when `symfony/uid` is installed and exposes -> `Symfony\Component\Uid\Exception\InvalidArgumentException::$invalidValue`. +There are two configuration features: -#### ValueException Condition +- [Match Conditions 🖇️](docs/config/match-conditions.md) – determine whether a given exception should match the given + property; +- [Violation Formatters 🎨](docs/config/violation-formatters.md) – represent the exception in a desired format. -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. +That's really all this library does – matches the exception and formats it. -This way it's possible to avoid much of the boilerplate code, keeping it clean: +For a cheat-sheet example of configuration, check the following: ```php -use PhPhD\ExceptionalMatcher\Rule\Object\Try_; 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\Uid\uid_value; +use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Validator\validated_value; use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Value\exception_value; +use const PhPhD\ExceptionalMatcher\Validator\Formatter\Validator\validator_violations; #[Try_] -class TransferMoneyCommand +class ImportProductDto { - #[Catch_(CardBlockedException::class, match: exception_value)] - public int $withdrawalCardId; - - #[Catch_(CardBlockedException::class, match: exception_value)] - public int $depositCardId; -} -``` + #[Catch_(InvalidUidException::class, match: uid_value, message: 'This is not a valid UUID.')] + public string $id; -In this example `CardBlockedException` could be matched either with `withdrawalCardId` or with `depositCardId`, \ -depending on the `cardId` value from the exception. + #[Catch_(CategoryNotFoundException::class, match: exception_value)] // Message is derived from Exception + public string $categoryId; -And `CardBlockedException` itself must implement `ValueException` interface: + #[Catch_(ValidationFailedException::class, from: ProductDescription::class, match: validated_value, format: validator_violations)] + public string $description; -```php -use PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Value\ValueException; + #[Catch_(BackorderDisabledForCategoryException::class, if: [self::class, 'thisProductViolatesBackorder'])] + public ?int $backorderLimit; -class CardBlockedException extends DomainException implements ValueException -{ - public function __construct(private Card $card) + /** + * 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 { - parent::__construct('card.blocked'); - } + if ($exception->categoryId !== $this->categoryId) { + return false; // Backorder configuration of the given category has nothing to do with this category. + } - public function getValue(): int - { - return $this->card->getId(); + if (null === $this->backorderLimit) { + return false; // The product didn't even enable backorder, much less violated it. + } + + return true; } } ``` -#### ValidationFailedException Condition +### Deep analysis 🌊 -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: +The matcher automatically picks all nested objects for analysis, provided that they define `#[Try_]` attribute. ```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; +use Symfony\Component\Validator\Constraints as Assert; #[Try_] -class RegisterUserCommand +class ImportProductBatchDto { - #[Catch_(ValidationFailedException::class, from: Password::class, match: validated_value, format: validator_violations)] - public string $password; + /** @var ImportProductDto[] */ + public array $items; } ``` -### Violation Formatters 🎨 +With nested matching with array properties, property paths are formatted differently. -There are two main built-in violation formatters you can use: `DefaultExceptionViolationFormatter` and -`ViolationListExceptionFormatter`. +In the example above, when the exception is matched, the path would be `items[].`: -If needed, create a custom violation formatter as described below. +- `` - a particular array index; +- `` - a particualr property name of that object. -#### Main +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. -`MainExceptionViolationFormatter` is used by default if another formatter is not specified. +#### Need for conditions -It provides a basic way of creating a `ConstraintViolation` with these parameters: \ -`$root`, `$message`, `$propertyPath`, `$value`. +Finding a match for the exception in `array` field is like finding your luggage at the airport \ +when everyone else took just the same very alike red backpack as you did. -#### Constraint Violation List Formatter +Red Backpack -`ViolationListExceptionFormatter` allows formatting the exceptions \ -that contain a `ConstraintViolationList` from the validator. +Matching of `BackorderDisabledForCategoryException` might have related it to any object of the original array, \ +given they all define the same class name condition as the desired item (e.g. the same color). -Such exceptions should implement `ViolationListException` interface: +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. ```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; - } +if ($exception->categoryId !== $this->categoryId) { + // not my backpack } ``` -Then, specify `ViolationListExceptionFormatter` as a `format:` for the `#[Catch_]` attribute: +That's what `if:` condition is there for – to relate an exception to `$this` particular object. -```php -use PhPhD\ExceptionalMatcher\Rule\Object\Try_; -use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; +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. -use const PhPhD\ExceptionalMatcher\Validator\Formatter\ViolationList\included_violations; +If the category is different, the object is skipped and another is taken for consideration. -#[Try_] -class IssueCreditCardCommand -{ - #[Catch_(CardNumberValidationFailedException::class, format: included_violations)] - private string $cardNumber; +The same applies to the products that don't enable backorder (`backorderLimit` is not filled): + +```php +if (null === $this->backorderLimit) { + // It's not my BackorderDisabledForCategoryException! I didn't enable backorder! } ``` -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. +Thus, we prevent false attribution of the exception to an object that had nothing to do with it. +## Matching multiple exceptions 🕎 -> Besides that, it's also possible to use `validator_violations` formatter, \ -> which can format Symfony's native `ValidationFailedException`. +Typically, validation should return all violations at once (not one by one), so they can all be shown to the user. -#### Custom Violation Formatters 🎨🖌️ +Yet, in a sequential computation model, only one exception is thrown at a time – as only one instruction is executed at +a time. +This leads to a situation that exceptional validation might end up with only the first violation, the rest not even +being evaluated. -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: +For example, consider `RegisterUserDto` that catches validation exceptions from value-objects: ```php -use PhPhD\ExceptionalMatcher\Exception\MatchedException; -use PhPhD\ExceptionalMatcher\Validator\Formatter\ExceptionViolationFormatter; -use Symfony\Component\Validator\ConstraintViolationInterface; - -/** @implements ExceptionViolationFormatter */ -final class LoginAlreadyTakenViolationFormatter implements ExceptionViolationFormatter +#[Try_] +class RegisterUserDto { - public function __construct( - #[Autowire(service: ExceptionViolationFormatter::class.'')] - 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()], - // ... - ); + #[Catch_(ValidationFailedException::class, from: Login::class, format: validator_violations)] + public string $login; - return [$violation]; - } + #[Catch_(ValidationFailedException::class, from: Password::class, format: validator_violations)] + public string $password; } ``` -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: +We'd want to have both `$login` and `$password` validation errors in a go. \ +We'd not like to catch only the first `ValidationFailedException`: ```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; -} +$login = Login::fromString($loginString); // could throw ValidationFailedException +$password = Password::fromString($passwordString); // could throw ValidationFailedException, too 👀 +$user = new User($login, $password); ``` -In this example, `LoginAlreadyTakenViolationFormatter` formats constraint violation for `LoginAlreadyTakenException`, \ -while `WeakPasswordViolationFormatter` formats `WeakPasswordException`. +This issue can be likened to a visit to a car mechanic that fixes only one issue per check-up. -### In-depth analysis +![Mechanic at work under the vehicle.png](assets/Mechanic%20at%20work%20under%20the%20vehicle.png) -> The approach described is done away with. +Even though you get a high-quality fix, it's frustrating to have it only one at a time – you'd want +the full fixup right off! -`#[Try_]` attribute works side-by-side with Symfony Validator's `#[Valid]` attribute. +In the Lord's Programming Language (see [HVM Bend](https://www.youtube.com/watch?v=HCOQmKTFzYY)) this limitation is +overcome by an absolutely different approach to code evaluation – Interaction Calculus. -Once you define `#[Valid]` on an object/iterable property, \ -the matcher will pick it up for a nested analysis, \ -providing a respective property path for the created violations. +The idea is projected into our world as the dispersed execution function: ```php -use PhPhD\ExceptionalMatcher\Rule\Object\Try_; -use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; -use Symfony\Component\Validator\Constraints as Assert; - -#[Try_] -class CreateOrderCommand -{ - /** @var OrderItemDto[] */ - #[Assert\Valid] - public array $items; -} +// pseudo-code +[$login, $password] = disperse([ // could throw CompositeException + fn() => Login::fromString($loginString), // could throw ValidationFailedException + fn() => Password::fromString($passwordString), // could throw ValidationFailedException +]); +``` -#[Try_] -class OrderItemDto -{ - public int $productId; +> In Bend, this would probably be no different from a usual code: +> ```python +> def createUser(dto: RegisterUserDto) -> User: +> login = Login.fromString(dto.loginString) # could raise ValidationFailedException +> password = Password.fromString(dto.passwordString) # could raise ValidationFailedException +> return User(login, password) # could raise CompositeException as evaluated +> ``` - #[Catch_(InsufficientStockException::class, if: [self::class, 'isStockExceptionForThisItem'])] - public string $quantity; +Since in practice validation is split into distinct functions (each perhaps throwing the exception), \ +it's possible to call them sequentially one by one and collect the exceptions: - public function isStockExceptionForThisItem(InsufficientStockException $exception): bool - { - return $exception->getProductId() === $this->productId; +```php +function disperse(array $tasks): array +{ + foreach ($tasks as [$fn, ...$args]) { + try { + $results[] = $fn(...$args); + } catch (\Throwable $e) { + $errors[] = $e; + } } + if ($errors) { + throw new CompositeException($errors); + } + return $results; } ``` -In this example, every time exception is processed, it will also be matched with inner objects from `items` property, -until it finally arrives at `items[*].quantity` (`*` stands for the particular array item index) property, being matched -by `InsufficientStockException` class name, and custom closure condition that makes sure that it was this particular -`OrderItemDto` that caused the exception. +There's no need to do this manually, since `amphp/amp` library provides an efficient solution using async tasks: + +```php +use function Amp\Future\awaitAnyN; +use function Amp\async; -The resulting property path of the caught violation includes all intermediary items, starting from the root of the tree, -proceeding down to the leaf item, where the exception was actually caught. +[$login, $password] = awaitAnyN(count($tasks = [ // could throw CompositeException + async(fn() => Login::fromString($loginString)), // could throw ValidationFailedException + async(fn() => Password::fromString($passwordString)), // could throw ValidationFailedException +]), $tasks); +``` -### Matching multiple exceptions +By using `async` and `awaitAnyN` functions, we are leveraging dispersed execution flow: \ +both `Login::fromString()` and `Password::fromString()` are executed regardless of each other's thrown exceptions. -Typically, validation is expected to return all present violations at once (not just the first one) so they can be shown -to the user. +If no exceptions are thrown, `$login` and `$password` variables are assigned with the returned values, \ +whereas if exceptions are thrown, an `Amp\CompositeException` wraps them over and is thrown instead. -Though due to the limitations of the sequential computation model, only one instruction can be executed at a time, and -therefore, only one exception can be thrown at a time. This leads to a situation where validation ends up in only the -first exception being thrown, while the rest are not even reached. +The library adds support for unwrapping composite exceptions (e.g. Amp, Messenger exceptions). \ +All inner exceptions are analysed so that the user can get a complete stack of validation errors. -For example, if we consider user registration with `RegisterUserCommand` from the code above, we'd like to capture both -`LoginAlreadyTakenException` and `WeakPasswordException` at once, so that the user can fix all the form errors at once, -rather than sorting them out one by one. +> If you want to register a custom composite exception unwrapper, \ +> take a look +> on [ExceptionUnwrapper](https://github.com/phphd/exception-toolkit?tab=readme-ov-file#exception-unwrapper). -This limitation can be overcome by implementing some concepts from an Interaction Calculus model in a sequential PHP -environment. The key idea is to use a semi-parallel execution flow instead of a purely sequential. +## Standalone Usage 🔧 -In practice, if validation is split into multiple functions, each of which may throw an exception, the concept can be -implemented by calling them one by one and collecting any exceptions as they raise. If there were any, they are wrapped -into a composite exception that is eventually thrown. +If you are not using a Symfony framework, you can still have a great advantage of this library. -Fortunately, you don't need to implement this manually, since `amphp/amp` library already provides a more efficient -solution than one you'd likely write yourself, using async Futures: +In your vanilla project, create a Service Container (`symfony/dependency-injection` is required) \ +and use it to get necessary services: ```php -/** - * @var Login $login - * @var Password $password - */ -[$login, $password] = await([ - // validate and create an instance of Login - async($this->createLogin(...), $service), - // validate and create an instance of Password - async($this->createPassword(...), $service), -]); -``` - -In this example, `createLogin()` method could throw `LoginAlreadyTakenException` and `createPassword()` method could -throw `WeakPasswordException`. +use PhPhD\ExceptionalMatcher\Bundle\DependencyInjection\PhdExceptionalMatcherExtension; +use PhPhD\ExceptionalMatcher\ExceptionMatcher; +use PhPhD\ExceptionalMatcher\Exception\MatchedExceptionList; -By using `async` and `awaitAnyN` functions, we are leveraging semi-parallel execution flow instead of sequential, so -that both `createLogin()` and `createPassword()` methods are executed regardless of thrown exceptions. +$container = (new PhdExceptionalMatcherExtension())->getContainer([ + // These are not used but still required by Symfony DI + 'kernel.environment' => 'prod', + 'kernel.build_dir' => __DIR__.'/var/cache', +]); -If no exceptions were thrown, then `$login` and `$password` variables are populated with the respective return -values. But if there were indeed some exceptions then `Amp\CompositeException` will be thrown with all the wrapped -exceptions inside. +$container->compile(); -> If you would like to use a custom composite exception, make sure to read -> about [ExceptionUnwrapper](https://github.com/phphd/exception-toolkit?tab=readme-ov-file#exception-unwrapper) +/** @var ExceptionMatcher $matcher */ +$matcher = $container->get(ExceptionMatcher::class.'<'.MatchedExceptionList::class.'>'); +``` -Since the library is capable of processing composite exceptions (with unwrappers for Amp and Messenger exceptions), all -of our thrown exceptions will be processed, and the user will get the complete stack of validation errors at hand. +Herein, you create a Container, compile it, and use to get `ExceptionMatcher`. ## Upgrading 👻 -The basic upgrade can be performed by [Rector](https://getrector.com/documentation) using +The basic upgrade should be performed by [Rector](https://getrector.com/documentation) using `ExceptionalMatcherSetList` \ -which comes with the library and contains automatic upgrade rules. +that comes with the library and contains automatic upgrade rules. To upgrade a project to the latest version of `exceptional-validation`, \ -add the following configuration to your `rector.php` file: +make the following configuration to your `rector.php` file: ```php use PhPhD\ExceptionalMatcher\Upgrade\ExceptionalMatcherSetList; @@ -826,4 +505,4 @@ return RectorConfig::configure() Make sure to specify your current version of the library so that upgrade sets will be matched correctly. -You should also check [UPGRADE.md](UPGRADE.md) for breaking changes and additional instructions. +You should also check [UPGRADE.md](UPGRADE.md) for the list of breaking changes and additional instructions. diff --git a/assets/Mechanic at work under the vehicle.png b/assets/Mechanic at work under the vehicle.png new file mode 100644 index 00000000..5e86d1c6 Binary files /dev/null and b/assets/Mechanic at work under the vehicle.png differ diff --git a/assets/Red Backpack.jpeg b/assets/Red Backpack.jpeg new file mode 100644 index 00000000..b4cc172d Binary files /dev/null and b/assets/Red Backpack.jpeg differ diff --git a/assets/Sending money to a friend.png b/assets/Sending money to a friend.png new file mode 100644 index 00000000..823bc83c Binary files /dev/null and b/assets/Sending money to a friend.png differ diff --git a/composer.json b/composer.json index 113ddd20..d7db7051 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/docs/config/match-conditions.md b/docs/config/match-conditions.md new file mode 100644 index 00000000..e465423e --- /dev/null +++ b/docs/config/match-conditions.md @@ -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; +} +``` diff --git a/docs/config/violation-formatters.md b/docs/config/violation-formatters.md new file mode 100644 index 00000000..6831db4d --- /dev/null +++ b/docs/config/violation-formatters.md @@ -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 */ +final class LoginAlreadyTakenViolationFormatter implements ExceptionViolationFormatter +{ + public function __construct( + #[Autowire(service: ExceptionViolationFormatter::class.'')] + 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`. diff --git a/docs/exceptional-validation.md b/docs/exceptional-validation.md new file mode 100644 index 00000000..ac085f1c --- /dev/null +++ b/docs/exceptional-validation.md @@ -0,0 +1,493 @@ +## What is Exceptional Validation? ⏪ + +Exceptional Validation is the approach of relying solely on exception checks, fully omitting any upfront validation. + +Of itself, validation is part of the business code, not ad-hoc code. \ +The best enforcement of business rules is only done via exceptions. + +It's similar to _sending money_ to a friend: + +- You don't question if their card's active. +- You don't question if their limit is not exceeded. +- You don't check if a proper transfer route is set up in the bank. + +Instead, just send them money and watch. None of these are any of your business in the first place. \ +If it doesn't work out, then you'll "catch" the issue and will undertake some action. + +![Sending money to a friend.png](../assets/Sending%20money%20to%20a%20friend.png) + +Similarly, exceptional validation brings the rule enforcement responsibility back to the code where it belongs. \ +  It takes the weight off the client code and puts it to the domain layer – where the rules live. + +Otherwise, you'll face `Client -> (Validation < Scenario)` problem: \ +  The `Scenario` expects `Validation` to have already been performed +(instead of performing it within itself). \ +  The responsibility for `Validation` is on the `Client`. + +This is fraught with another `Client` calling same `Scenario`, having: + +- skipped part of the `Validation`; +- executed a corrupt copy of `Validation`; +- excluded the `Validation` at all. + +> Imagine if in bank transfers the clients 💸 were responsible for rule validation. \ +> What mess would that result in? +> +> Do you know that this's the case for many applications? + +Exceptional Validation shifts that focus. Clients are lightened, Scenarios are loadened. +Validation moves off on a Scenario – until the very moment it's inescapable. + +> Note: A similar strategy is used for Optimistic Concurrency Control. \ +> You don't lock out everything that might go wrong. \ +> You go ahead with the flow, and only at commit time check if something went wrong, \ +> rolling back the changes in the case. + +## Why Exceptional Validation? ✨ + +Ordinarily, validation flows through two different layers: + +`(Controller / Form / Dto) ----> Domain` + +leading to duplication and entropy (possible inconsistencies) of validation rules across Controllers / Forms / Dtos. + +### Single Source of Truth ☝️ + +Oftentimes there are multiple actions that use the same validation rules. \ +Exceptional Validation shifts focus totally into the `Domain`. + +For example, consider a password validation. \ +It is used both in registration (`Dto`) and in password reset (`Dto`). + +```php +class RegisterUserDto +{ + #[Assert\NotBlank] + #[Assert\Length(min: 8, max: 31)] + #[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_MEDIUM)] + #[AppAssert\PasswordNotCompromised] + public string $password; +} +``` + +```php +class ResetPasswordDto +{ + #[Assert\NotBlank] + #[Assert\Length(min: 8, max: 31)] + #[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_MEDIUM)] + #[AppAssert\PasswordNotCompromised] + public string $password; +} +``` + +> The rest of the fields are omitted for the sake of demonstration. + +Using the validation assert attributes results in totally duplicated assertions across the board. \ +Did you notice how they are scattered through many dto classes? + +All these `NotBlank`, `Length()`, `PasswordStrength()` etc. are the direct business rules. + +What's wrong with _duplication_ of business logic: + +- makes rules harder to change (due to many places); +- encourages entropy (corrupt rules, variations). + +Consider we'd want to _increase_  the _min length_ to `10`. \ +We'd do it in two places: + +```diff +--- a/RegisterUserDto.php ++++ b/RegisterUserDto.php +@@ +- #[Assert\Length(min: 8, max: 31)] ++ #[Assert\Length(min: 10, max: 31)] +``` + +```diff +--- a/ResetPasswordDto.php ++++ b/ResetPasswordDto.php +@@ +- #[Assert\Length(min: 8, max: 31)] ++ #[Assert\Length(min: 10, max: 31)] +``` + +Let's say we'd want to _change_  the _password strength threshold_ to `STRENGTH_STRONG`. \ +We'd have to do it in two places: + +```diff +--- a/RegisterUserDto.php ++++ b/RegisterUserDto.php +@@ +- #[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_MEDIUM)] ++ #[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_STRONG)] +``` + +```diff +--- a/ResetPasswordDto.php ++++ b/ResetPasswordDto.php +@@ +- #[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_MEDIUM)] ++ #[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_STRONG)] +``` + +The more use cases we have, the more code is needed to change. + +Such rules fragmentation poses serious fragility to the design. \ +It's like bank clients being obliged to verify their transactions themselves. + +Idiomatically, it's a single concept used in both `RegisterUserDto::$password` and `ResetPasswordDto::$password`. \ +Thus, it should be represented as a `Password` [Value Object](https://www.google.com/search?q=value+object): + +```php +final readonly class Password +{ + private function __construct( + public string $hash, + ) { + } + + public static function fromString( + #[SensitiveParameter] + string $password, + ValidatorInterface $validator, + PasswordHasherInterface $passwordHasher, + ): self { + $violationList = $validator->validate($password, new Assert\Sequentially([ + new Assert\NotBlank(), + new Assert\Length(min: 8, max: 31), + new Assert\PasswordStrength(minScore: PasswordStrength::STRENGTH_MEDIUM), + ])); + + if (0 !== $violationList->count()) { + throw new ValidationFailedException($password, $violationList); + } + + return new self($passwordHasher->hash($password)); + } +} +``` + +Then rule is centralised. It's not scattered across many places. It's kept in place, locked in. + +The positions of previous duplication now point to the well-organised value object: + +```php +#[Try_] +class RegisterUserDto +{ + #[Catch_(ValidationFailedException::class, from: Password::class, match: validated_value, format: validator_violations)] + public string $password; +} +``` + +```php +#[Try_] +class ResetPasswordDto +{ + #[Catch_(ValidationFailedException::class, from: Password::class, match: validated_value, format: validator_violations)] + public string $password; +} +``` + +These dto classes do not drive validation but respond to it. \ +They are outer to the logic, Password is the logic. + +> Furthermore, with value objects, you can have such nifty methods like this: +> +> ```php +> public function verify(#[SensitiveParameter] string $password, PasswordHasherInterface $passwordHasher): void +> { +> if (!$passwordHasher->verify($this->hash, $password)) { +> throw new PasswordMismatchException($password); +> } +> } +> ``` +> +> Then, calling `$user->password->verify($password, $hasher)` produces extreme fluency. + +Now, an attentive reader might have noticed that our last snippet didn't include `PasswordNotCompromised` rule. \ +It's distinct from value object rules. + +All those rules that go beyond just basic validation (e.g. connect to an external system) should expose custom +exceptions: + +```diff +@@ ++ #[Catch_(PasswordCompromisedException::class, match: exception_value)] ++ #[Catch_(PasswordCannotBeReusedException::class, match: exception_value)] + public string $password; +``` + +From the first glance, one might think it's the same problem to map every single exception to every single dto class. \ +Although it's the whole truth, even with this approach it already makes a tremendous difference to what was before: + +1. This mapping is _retroactive_ – the core business logic won't be violated if the attribute is missing; +2. Missing attribute is _autocorrected_ – you'll detect that the attribute is missing by your error-tracking software + once a user comes across a 500 due to a non-handled exception that must've been mapped. \ + On the contrary, we can't say the same about attribute-driven validation, in which a missing attribute implies + incomplete validation which nobody has any idea of until it's too late (when the rule is broken). +3. The logic is much more _explicit_ – you can easily find all password validation places + by searching `ValidationFailedException::class` that comes `from: Password::class`. \ + You can't really do this, for example, with `Assert\Length()` – it's too generic to surely know it's + about password when searching. + +Finally, it's not really necessary to write the full list of `#[Catch_]` for all possible exceptions. \ +Instead, we can introduce an `interface PasswordException` and "catch" it: + +```diff +@@ ++ #[Catch_(PasswordException::class, match: exception_value)] +- #[Catch_(PasswordCompromisedException::class, match: exception_value)] +- #[Catch_(PasswordCannotBeReusedException::class, match: exception_value)] + public string $password; +``` + +As `PasswordCompromisedException` and `PasswordCannotBeReusedException` implement `PasswordException`, \ +it simplifies Dtos even further. + +Thus, at the end of the day, each `Controller / Form / Dto` is kept intact whenever busines requirements change. \ +Truly now they do support business rules, not drive them. + +### Hit the nail on the head 🎯 + +When running "normal" validation, for each custom use-case you usually create custom validators. \ +Exceptional Validation relieves you from writing business logic in such validators. + +Consider, for example, an `OrderItemDto`. + +```diff +@@ ++ #[AppAssert\ProductStockIsSufficient] + class OrderItemDto + { ++ #[AppAssert\ProductExistsAndActive] + public string $productId; +@@ + #[Assert\Positive] + public int $quantity; + } +``` + +At the moment of adding 2 validation asserts, we've now created 4 additional classes (2 constraint and 2 validator) for +very little value. + +```php +use App\Repository\ProductRepository; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +final class ProductExistsAndActiveValidator extends ConstraintValidator +{ + public function __construct( + private ProductRepository $productRepository, + ) { + } + + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof ProductExistsAndActive) { + throw new UnexpectedTypeException($constraint, ProductExistsAndActive::class); + } + + if (empty($value)) { + return; + } + + if (!is_string($value)) { + throw new UnexpectedValueException($value, 'string'); + } + + $product = $this->productRepository->find($value); + + if (null === $product) { + $this->context + ->buildViolation('product.not_found') + ->addViolation(); + + return; + } + + if (!$product->isActive()) { + $this->context + ->buildViolation('product.inactive') + ->addViolation(); + } + } +} +``` + +A WHOLE LOT of beating around the bush. \ +A very little of real value. + +This's too much technical code that solves the problem of representation of the violation to the user. \ +This is mixed with the code of true value – that solves the problem of business. + +Let's add a second validator: + +```php +use App\Repository\ProductRepository; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +final class ProductStockIsSufficientValidator extends ConstraintValidator +{ + public function __construct( + private ProductRepository $productRepository, + ) { + } + + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof ProductStockIsSufficient) { + throw new UnexpectedTypeException($constraint, ProductStockIsSufficient::class); + } + + if (!$value instanceof OrderItemDto) { + throw new UnexpectedValueException($value, OrderItemDto::class); + } + + if (empty($value->productId)) { + return; + } + + $product = $this->productRepository->find($value->productId); + + if (null === $product) { + return; // let ProductExistsAndActive handle not-found + } + + if ($product->getAvailableStock() < $value->quantity) { + $this->context + ->buildViolation('product.stock_insufficient') + ->atPath('quantity') + ->addViolation(); + } + } +} +``` + +Coding in circles. \ +It's necessary to perform duplicate code (e.g. `$this->productRepository->find(...)`, checks for `empty()`) in all +validators. + +The main problem with this is that domain logic leaks into validators, bloating them wildly. \ +Standard validation robs some of your actual business code. + +This same logic you would've normally implemented in a service: + +```php +$product = $productRepository->find($itemDto->productId) ?? throw new ProductNotFoundException($itemDto->productId); + +if (!$product->isActive()) { + throw new ProductInactiveException($product); +} + +if (!$product->hasEnoughStock($itemDto->quantity)) { + throw new ProductStockInsufficientException($product); +} +``` + +Should I tell you that these five lines of code are worth the same amount of code as was written before? + +> Simple things should be done simple way. + +With exceptional validation you write business logic naturally "as is" and then retroactively relate violations to the +fields. \ +Retroactively — after your business logic has worked out — it means later (not earlier). + +```diff ++ #[Try_] + class OrderItemDto + { ++ #[Catch_(ProductNotFoundException::class, match: exception_value)] ++ #[Catch_(ProductInactiveException::class, match: exception_value)] + public string $productId; +@@ ++ #[Catch_(ProductStockInsufficientException::class, if: [self::class, 'isExceptionRelatedToThisDto'])] + public int $quantity; +@@ ++ public function isExceptionRelatedToThisDto(ProductStockInsufficientException $exception): bool ++ { ++ return $exception->product->id === $this->productId; ++ } + } +``` + +Validation focus was shifted from dto into the domain code. \ +Domain Code you write expresses the rules of business much better than any framework solution. + +This approach to validation gives a lot of flexibility for your business logic, \ +removing the need of custom validators, validation groups, ad-hoc duplicate codes. + +Exceptional Validation, as Jesus, breaks the bondage, delivering your captive domain code, setting it free. + +### Liberty 🗽 + +Exceptional Validation liberates you to implement the business code in the domain objects with anything you like. + +It's not a strict requirement to use Symfony Validator (or any other validation mechanism), +although this library integrates it well. + +Representation of the validation errors is separate from the business logic concern. \ +Keeping them apart breaks the chains of dependency on particular tools, allowing to easily unit-test your logic. + +Instead of chaining yourself to a particular tool that will handle both, you can use this library to bridge them. +This gives you freedom of choice, resulting in much more supple design of the system. + +Ultimately, you can validate business logic with any third-party library (or even plain PHP), \ +while exceptional matcher will **_correlate_** these **validation exceptions** to the fields \ +whose values caused them. + +## Recap: Exceptional vs Standard Validation? ⚖️ + +### Standard Validation 🕯️ + +The traditional validation uses an **attribute-driven** approach, \ +which strips the domain layer from most business logic and results in duplicated client validation. + +Besides that, any custom validation that would normally be implemented in a service \ +is expelled into **custom validators** that pose much boilerplate in implementation. + +The root of the problem is that two **responsibilities are amalgamated** into one that does both validation and +formatting. +It's all for the sake of being able to display a nice validation message on the form. + +Thus, the **domain model _ends up_ naked**, \ +all business rules having been leaked elsewhere. + +### Exceptional Validation 💡 + +On the other hand, Exceptional Validation is a **domain-driven** approach that expects domain objects to be responsible +for their own validation: + +- `Email` validates its own format and throws an exception if value is not valid; +- `RegisterUser` verifies that login is unique and naturally throws an exception if it's not. + +Validation formatting is rather **attribute-retroactive** – attributes define just formatting for what's already +violated, not the behaviour. + +Not using upfront validation, the library serves as a bridge toward contextual properly formatted validation errors. + +With this design, **the validation _is_  freehand** – you can implement logic in a service, in a value object – +with this tool or with that tool.\ +Finally, you can ultimately express business code w/o being impeded by external factors. + +Using exception-driven validation, you maintain a **single source of truth** for the business rules. + +Ultimately, domain code that enforces its invariants via exceptions, itself constitutes a **rich domain model** \ +that thoroughly demarcates responsibility contours, delegating contextual validation error formatting to the library. + +### Key Takeaways 👉 + +Exceptional Validation: + +- Settles business rules where they naturally habitate — in the domain; +- Factors out duplicate `Controller / Form / Dto` client validation into a central place; +- Eliminates the need for validation groups and custom validators; +- Makes validation easily unit-testable; +- Reduces the complexity of nested validation. diff --git a/docs/interaction/command-bus-middleware.md b/docs/interaction/command-bus-middleware.md new file mode 100644 index 00000000..3f8bf40b --- /dev/null +++ b/docs/interaction/command-bus-middleware.md @@ -0,0 +1,75 @@ +# Usage with Command Bus 📇 + +If you are using [Messenger Component](https://symfony.com/doc/current/components/messenger.html), you can create +a [Command Bus](https://symfony.com/doc/current/messenger.html#multiple-buses-command-event-buses) that will automate +validation, transactions, and exception handling. + +> If you are not using `Messenger` component, it won't be installed automatically. +> +> Exceptional Matcher provides a rigorously structured set of tools w/o requiring any particular third-party. + +## Middleware 🔂 + +It's recommended to involve this package as a +dedicated [Middleware](https://symfony.com/doc/current/messenger.html#middleware). \ +Add `phd_exceptional_validation` middleware to the list: + +```diff + framework: + messenger: + buses: + command.bus: + middleware: + - validation ++ - phd_exceptional_validation + - doctrine_transaction +``` + +A `phd_exceptional_validation` middleware intercepts exceptions and performs their matching to object's properties by an +exception matcher, eventually formatting matched exceptions as +standard [SF Validator](https://symfony.com/doc/current/validation.html) +violations. + +### How it works ⚙️ + +Once added, the middleware: + +- runs next middleware; +- catches the exception if thrown; + - matches it with `ExceptionMatcher`; + - re-throws `ExceptionalValidationFailedException`. + +This diagram represents the concept: + +![Exceptional Validation.svg](https://raw.githubusercontent.com/phphd/exceptional-validation/refs/heads/main/assets/Exceptional%20Validation.svg) + +## Catch 🏈 + +Thus, you can finally catch the exception and process as needed: + +```php +$command = new RegisterUserCommand($login, $password); + +try { + $this->commandBus->dispatch($command); +} catch (ExceptionalValidationFailedException $exception) { + $violationList = $exception->getViolationList(); + + return $this->render('registrationForm.html.twig', ['errors' => $violationList]); +} +``` + +This exception just wraps accordingly created `ConstraintViolationList` that contains your exception messages with +property paths. + +> Tip: you can depend on the base `Symfony\Component\Messenger\Exception\ValidationFailedException`, \ +> as `ExceptionalValidationFailedMessengerException` extends it. \ +> Add a global listener to convert it to a response, and make no configurations specific about this library. + +## Custom Middleware ✍🏻 + +You can create custom middleware for your format using [Cutom Matcher Service](./direct-matcher-service-usage.md) +wrapped into a [Messenger Middleware](https://symfony.com/doc/current/messenger.html#middleware) as just shown. + +A good base point for implementation to build off on is `ExceptionMatcher`. \ +It gives you a high-level object representation of matched exceptions you can format. diff --git a/docs/interaction/direct-matcher-service-usage.md b/docs/interaction/direct-matcher-service-usage.md new file mode 100644 index 00000000..48c51d6b --- /dev/null +++ b/docs/interaction/direct-matcher-service-usage.md @@ -0,0 +1,71 @@ +# Direct Matcher Service Usage 🔌 + +There are few Matcher Services available you can inject into your code for matching the exception into a particular format. + +## Available Services + +If you're using Symfony, check the available exception matchers with this command. + +```shell +bin/console debug:container ExceptionMatcher +``` + +> If using this library without frameworks, you can check the definitions of `services.php` and find available matchers. + +It should provide you with a similar list to this: + +```text +[0] PhPhD\ExceptionalMatcher\ExceptionMatcher +[1] PhPhD\ExceptionalMatcher\ExceptionMatcher +``` + +These matchers format the Exception to their specified format, defined as a generic parameter. \ +They can format into `MatchedExceptionList`, or `ConstraintViolationList`, or you can create your custom. + +The command dumps all the configured formatters. + +## Usage + +You can inject the wanted service into your code using generics syntax: + +```php +use PhPhD\ExceptionalMatcher\ExceptionMatcher; +use Symfony\Component\Validator\ConstraintViolationListInterface; + +class SignDocumentActivity +{ + public function __construct( + /** @var ExceptionMatcher */ + #[Autowire(service: ExceptionMatcher::class.'<'.ConstraintViolationListInterface::class.'>')] + private ExceptionMatcher $exceptionMatcher, + ) { + } + + public function sign(SignCommand $command): string + { + try { + return $command->businessLogic($this); + } catch (Exception $e) { + throw $this->failure($e, $command); + } + } + + private function failure(Throwable $e, SignCommand $command): Throwable + { + /** @var ?ConstraintViolationListInterface $violationList */ + $violationList = $this->exceptionMatcher->match($e, $command); + + if (null === $violationList) { + return $e; + } + + return new ApplicationFailure('Validation Failed', $this->encode($violationList), previous: $e); + } +} +``` + +In this example, we use `ExceptionMatcher` that relates thrown exceptions to the +properties of the `$command`, producing `ConstraintViolationListInterface` as an outcome. + +You can write your own custom implementations of `ExceptionMatcher` that will output violations in a desired format +specific to your use-case.