phphd / exceptional-validation-bundle
Requires
- php: >=8.1
- phphd/exception-toolkit: ^1.0
- symfony/validator: ^6.0 | ^7.0
- webmozart/assert: ^1.11
Requires (Dev)
- nyholm/symfony-bundle-test: ^3.0
- phpat/phpat: ^0.10.13
- phphd/coding-standard: ~0.5.3
- phpstan/phpstan: ^1.10
- phpstan/phpstan-phpunit: ^1.3
- phpunit/phpunit: ^10.5
- psalm/plugin-phpunit: ^0.18.4
- symfony/config: ^6.0 | ^7.0
- symfony/dependency-injection: ^6.2 | ^7.0
- symfony/http-kernel: ^6.0 | ^7.0
- symfony/messenger: ^6.4 | ^7.0
- symfony/var-dumper: ^6.0 | ^7.0
- tomasvotruba/type-coverage: ^0.3.1
- vimeo/psalm: ^5.13
Suggests
- amphp/amp: Install AMP package in order to capture multiple exceptions at once
- symfony/messenger: Install Messenger component to use exceptional validation middleware
Conflicts
- symfony/config: <6.0 || >=8.0
- symfony/dependency-injection: <6.2 || >=8.0
- symfony/http-kernel: <6.0 || >=8.0
- symfony/messenger: <6.4 || >=8.0
Replaces
This package is auto-updated.
Last update: 2024-09-07 12:16:02 UTC
README
🧰 提供异常到属性映射器,捆绑为 Symfony Messenger 中间件。它捕获抛出的异常,将它们与相应的属性匹配,以 Symfony Validator 格式格式化违规行为,并抛出 ExceptionalValidationFailedException。
安装 📥
-
通过 composer 安装
composer require phphd/exceptional-validation
-
在
bundles.php中启用包PhPhD\ExceptionalValidation\Bundle\PhdExceptionalValidationBundle::class => ['all' => true], PhPhD\ExceptionToolkit\Bundle\PhdExceptionToolkitBundle::class => ['all' => true],
配置 ⚒️
使用此包的推荐方式是通过 Symfony Messenger 中间件。
首先,你应该将 phd_exceptional_validation 中间件添加到列表中
framework:
messenger:
buses:
command.bus:
middleware:
- validation
+ - phd_exceptional_validation
- doctrine_transaction
完成此操作后,中间件将负责捕获异常并处理它们。
如果你没有使用 Messenger 组件,你仍然可以通过编写针对特定命令总线的中间件实现来利用此包的功能。关于
symfony/messenger组件,此依赖项是可选的,因此如果你不需要它,它将不会自动安装。
使用 🚀
首先必要的是使用 #[ExceptionalValidation] 属性标记你的消息。它用于将消息包含到中间件处理中。
然后你需要在消息的属性上定义 #[Capture] 属性。这些属性用于将抛出的异常映射到类的相应属性
use PhPhD\ExceptionalValidation; use PhPhD\ExceptionalValidation\Capture; #[ExceptionalValidation] final class RegisterUserCommand { #[Capture(LoginAlreadyTakenException::class, 'auth.login.already_taken')] private string $login; #[Capture(WeakPasswordException::class, 'auth.password.weak')] private string $password; }
在此示例中,每当抛出 LoginAlreadyTakenException 或 WeakPasswordException 时,它将被捕获并映射到 login 或 password 属性,并带有相应的错误消息翻译。
最终,当 phd_exceptional_validation 中间件处理完异常后,它将抛出 ExceptionalValidationFailedException,以便可以捕获并按需处理。
$command = new RegisterUserCommand($login, $password); try { $this->commandBus->dispatch($command); } catch (ExceptionalValidationFailedException $exception) { $violationList = $exception->getViolationList(); return $this->render('registrationForm.html.twig', ['errors' => $violationList]); }
$exception 对象包含了分别映射的约束违规。此违规列表可以用于将错误渲染到 html 表单或将其序列化为 json 响应。
高级使用 ⚙️
#[ExceptionalValidation] 和 #[Capture] 属性允许您实现非常灵活的映射。以下是一些如何使用它们的示例。
捕获嵌套对象的异常
#[ExceptionalValidation] 属性与 Symfony Validator 的 #[Valid] 属性协同工作。一旦定义了这些,就可以在嵌套对象上指定 #[Capture] 属性。
use PhPhD\ExceptionalValidation; use PhPhD\ExceptionalValidation\Capture; use Symfony\Component\Validator\Constraints as Assert; #[ExceptionalValidation] final class OrderProductCommand { #[Assert\Valid] private ProductDetails $product; } #[ExceptionalValidation] final class ProductDetails { private int $id; #[Capture(InsufficientStockException::class, 'order.insufficient_stock')] private string $quantity; // ... }
在此示例中,每当抛出 InsufficientStockException 时,它将被捕获并映射到 product.quantity 属性,并带有相应的消息翻译。
捕获当条件
#[Capture] 属性接受回调函数以确定是否应捕获给定属性的特定异常实例。
use PhPhD\ExceptionalValidation; use PhPhD\ExceptionalValidation\Capture; #[ExceptionalValidation] final class TransferMoneyCommand { #[Capture( BlockedCardException::class, 'wallet.blocked_card', when: [self::class, 'isWithdrawalCardBlocked'], )] private int $withdrawalCardId; #[Capture( BlockedCardException::class, 'wallet.blocked_card', when: [self::class, 'isDepositCardBlocked'], )] private int $depositCardId; public function isWithdrawalCardBlocked(BlockedCardException $exception): bool { return $exception->getCardId() === $this->withdrawalCardId; } public function isDepositCardBlocked(BlockedCardException $exception): bool { return $exception->getCardId() === $this->depositCardId; } }
在此示例中,#[Capture] 属性的 when: 选项用于指定当处理异常时调用的回调函数。如果 isWithdrawalCardBlocked 回调返回 true,则捕获 withdrawalCardId 属性的异常;否则如果 isDepositCardBlocked 回调返回 true,则捕获 depositCardId 属性的异常。如果两者都不返回 true,则异常将重新抛到调用堆栈的上方。
捕获值条件
由于在大多数情况下捕获条件简化为简单的值比较,因此更易于实现ValueException接口并指定condition: ValueExceptionMatchCondition::class,而不是每次都实现when:闭包。
这种方式可以避免大量模板代码,使代码更简洁。
use PhPhD\ExceptionalValidation\Model\Condition\ValueExceptionMatchCondition; #[ExceptionalValidation] final class TransferMoneyCommand { #[Capture(BlockedCardException::class, 'wallet.blocked_card', condition: ValueExceptionMatchCondition::class)] private int $withdrawalCardId; #[Capture(BlockedCardException::class, 'wallet.blocked_card', condition: ValueExceptionMatchCondition::class)] private int $depositCardId; }
按照这种方式,BlockedCardException应该实现ValueException接口。
use DomainException; use PhPhD\ExceptionalValidation\Model\Condition\Exception\ValueException; final class BlockedCardException extends DomainException implements ValueException { public function __construct( private Card $card, ) { parent::__construct(); } public function getValue(): int { return $this->card->getId(); } }
在此示例中,BlockedCardException可以捕获withdrawalCardId或depositCardId属性,具体取决于异常中的cardId值。
在嵌套数组项上捕获异常
如果你在可迭代属性上定义了#[Valid]属性,那么允许映射嵌套数组项的违规行为。例如
use PhPhD\ExceptionalValidation; use PhPhD\ExceptionalValidation\Capture; use Symfony\Component\Validator\Constraints as Assert; #[ExceptionalValidation] final class CreateOrderCommand { /** @var ProductDetails[] */ #[Assert\Valid] private array $products; } #[ExceptionalValidation] final class ProductDetails { private int $id; #[Capture( InsufficientStockException::class, 'order.insufficient_stock', when: [self::class, 'isStockExceptionForThisProduct'], )] private string $quantity; public function isStockExceptionForThisProduct(InsufficientStockException $exception): bool { return $exception->getProductId() === $this->id; } }
在此示例中,当捕获到InsufficientStockException时,它将被映射到products[*].quantity属性,其中*表示捕获异常的特定ProductDetails实例在products数组中的索引。
捕获多个异常
通常,在验证过程中,预期会向用户显示所有验证错误,而不仅仅是第一个。
然而,由于顺序计算模型的限制,一次只能抛出一个异常。这导致只有第一个异常被抛出,其余的甚至没有达到。
可以通过在顺序PHP环境中实现交互组合器模型的一些概念来克服这一限制。关键概念是使用半并行执行流程而不是顺序执行。
让我们考虑用户注册和上面的RegisterUserCommand示例,其中我们希望同时捕获LoginAlreadyTakenException和WeakPasswordException。
在主代码中,我们必须将这些异常收集到某种“组合异常”中,然后最终抛出。虽然可以手动实现,但使用amphp/amp库会更简单,其中它使用异步Future以更好的方式实现。
/** * @var Login $login * @var Password $password */ [$login, $password] = awaitAnyN([ // validate and create Login instance async(fn (): Login => $this->createLogin($command->getLogin())), // validate and create Password instance async(fn (): Password => $this->createPassword($command->getPassword())), ]);
在此示例中,createLogin()方法可能抛出LoginAlreadyTakenException,而createPassword()方法可能抛出WeakPasswordException。通过使用async和awaitAnyN函数,我们可以利用半并行执行流程而不是顺序执行。因此,无论是否抛出异常,createLogin()和createPassword()方法都将执行。
如果没有异常,则从Future的返回值中填充$login和$password变量。但如果有异常,则将抛出包含我们所有异常的Amp\CompositeException。
如果您想使用自定义组合异常,请参阅ExceptionUnwrapper。
由于当前库能够处理组合异常(实际上有Amp和Messenger异常的解包器),我们抛出的所有异常都将被处理,用户将拥有完整的验证错误堆栈。
违规格式化程序
有两个内置的违规格式化程序可以使用 - DefaultViolationFormatter和ViolationListExceptionFormatter。如果需要,您可以创建自己的自定义违规格式化程序,如下所述。
默认
DefaultViolationFormatter在未指定其他格式化程序的情况下默认使用。
它提供了一种非常基本的方式来格式化违规行为,使用如下参数构建ConstraintViolation:$message、$root、$propertyPath、$value。
约束违规列表格式化程序
ViolationListExceptionFormatter 用于格式化实现 ViolationListException 接口的异常。它允许轻松捕获从验证器获得的 ConstraintViolationList 异常。
实现 ViolationListException 接口的典型异常类可能看起来像这样
use DomainException; use PhPhD\ExceptionalValidation\Formatter\ViolationListException; use Symfony\Component\Validator\ConstraintViolationListInterface; final class CardNumberValidationFailedException extends DomainException implements ViolationListException { public function __construct( private readonly string $cardNumber, private readonly ConstraintViolationListInterface $violationList, ) { parent::__construct((string)$this->violationList); } public function getViolationList(): ConstraintViolationListInterface { return $this->violationList; } }
然后您可以在属性的 #[Capture] 属性上使用 ViolationListExceptionFormatter
use PhPhD\ExceptionalValidation; use PhPhD\ExceptionalValidation\Capture; use PhPhD\ExceptionalValidation\Formatter\ViolationListExceptionFormatter; #[ExceptionalValidation] final class IssueCreditCardCommand { #[Capture( exception: CardNumberValidationFailedException::class, formatter: ViolationListExceptionFormatter::class, )] private string $cardNumber; }
在这个例子中,CardNumberValidationFailedException 在 cardNumber 属性上被捕获,并且从这个异常中映射的所有约束违反都被映射到这个属性。如果 #[Capture] 属性上指定了消息,则优先考虑来自 ConstraintViolationList 的消息。
自定义违规格式化程序
在某些情况下,您可能需要自定义违规的格式化方式,例如向消息翻译传递额外的参数。您可以通过创建自己的违规格式化服务来实现,该服务实现 ExceptionViolationFormatter 接口。
use PhPhD\ExceptionalValidation\Formatter\ExceptionViolationFormatter; use PhPhD\ExceptionalValidation\Model\Exception\CapturedException; use Symfony\Component\Validator\ConstraintViolationInterface; final class RegistrationViolationsFormatter implements ExceptionViolationFormatter { public function __construct( #[Autowire('@phd_exceptional_validation.violation_formatter.default')] private ExceptionViolationFormatter $defaultFormatter, ) { } /** @return array{ConstraintViolationInterface} */ public function format(CapturedException $capturedException): ConstraintViolationInterface { // you can format violations with the default formatter // and then slightly adjust only necessary parts [$violation] = $this->defaultFormatter->format($capturedException); $exception = $capturedException->getException(); if ($exception instanceof LoginAlreadyTakenException) { $violation = new ConstraintViolation( $violation->getMessage(), $violation->getMessageTemplate(), ['loginHolder' => $exception->getLoginHolder()], // ... ); } if ($exception instanceof WeakPasswordException) { // ... } return [$violation]; } }
然后您应该将您的自定义格式化程序作为服务注册
services: App\AuthBundle\ViolationFormatter\RegistrationViolationsFormatter: tags: [ 'exceptional_validation.violation_formatter' ]
为了使您的自定义违规格式化程序被此包识别,其服务必须带有
exceptional_validation.violation_formatter标签。如果您使用 自动配置,由于ExceptionViolationFormatter接口已实现,服务容器会自动完成此操作。
最后,您应该在 #[Capture] 属性中指定您的自定义格式化程序
use PhPhD\ExceptionalValidation; use PhPhD\ExceptionalValidation\Capture; #[ExceptionalValidation] final class RegisterUserCommand { #[Capture( LoginAlreadyTakenException::class, 'auth.login.already_taken', formatter: RegistrationViolationsFormatter::class, )] private string $login; #[Capture( WeakPasswordException::class, 'auth.password.weak', formatter: RegistrationViolationsFormatter::class, )] private string $password; }
在这个例子中,使用 RegistrationViolationsFormatter 格式化 LoginAlreadyTakenException 和 WeakPasswordException(尽管您可以使用单独的格式化程序)的约束违反,并添加额外的上下文。