Snippets

Andrew Stark Symfony API validation

Created by Andrew Stark

File AbstractValidationRequest Added

  • Ignore whitespace
  • Hide word diff
+<?php
+declare(strict_types=1);
+
+namespace App\Infrastructure\Validation\Request;
+
+use App\Domain\Core\Exceptions\ValidationException;
+use App\Infrastructure\Service\VendorPackages\Request\ValidateRequestInterface;
+use App\Infrastructure\Validation\Constraints\ConfirmFieldConstraint;
+use App\Infrastructure\Validation\Constraints\UniqueEntityFieldConstraint;
+use App\Infrastructure\Validation\RuleHandler\IdenticalRuleHandler;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Translation\Exception\InvalidArgumentException as TranslationInvalidArgumentException;
+use Symfony\Component\Validator\Constraints\Email;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\NotNull;
+use Symfony\Component\Validator\Constraints\Optional;
+use Symfony\Component\Validator\Constraints\Type;
+use Symfony\Component\Validator\ConstraintViolationListInterface;
+use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
+use Symfony\Component\Validator\Exception\InvalidOptionsException;
+use Symfony\Component\Validator\Exception\MissingOptionsException;
+use Symfony\Component\Validator\Validator\ValidatorInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+/**
+ * @todo - tests
+ * @todo - fixme refactor
+ */
+abstract class AbstractValidationRequest implements ValidateRequestInterface {
+    private Request             $request;
+    private ValidatorInterface  $validator;
+    private TranslatorInterface $translator;
+
+    public function __construct(
+        Request $request,
+        ValidatorInterface $validator,
+        TranslatorInterface $translator
+    ) {
+        $this->request    = $request;
+        $this->validator  = $validator;
+        $this->translator = $translator;
+    }
+
+    public function request(): Request {
+        return $this->request;
+    }
+
+    /**
+     * @throws TranslationInvalidArgumentException
+     * @throws MissingOptionsException
+     * @throws InvalidOptionsException
+     * @throws ConstraintDefinitionException
+     * @throws ValidationException
+     */
+    protected function handleConstraints(array $validateFields): self {
+        $allValidationStatements = [];
+        foreach ($validateFields as $validationField => $rulesArray) {
+            $rules                = $this->formatValidationRules($rulesArray);
+            $validationFieldRules = $this->loopExistingValidationRules($validationField, $rules);
+            //      dump($rules);
+            //      dump($validationFieldRules);
+            //      dump(array_key_exists('required', $rules));
+            //      die;
+            $isFieldOptional = false === \array_key_exists('required', $rules);
+            $isFieldRequired = true === \array_key_exists('required', $rules) &&
+                               false === \array_key_exists('nullable', $rules);
+            // validation field strategy in case field optional or required
+            if (true === $isFieldOptional) {
+                $nullableClass                             = $this->availableValidationRules()['nullable'];
+                $allValidationStatements[$validationField] = new $nullableClass($validationFieldRules);
+            }
+            if (true === $isFieldRequired) {
+                $allValidationStatements[$validationField] = $validationFieldRules;
+            }
+        }
+        //    $allValidationStatementsWithAdditionalFields = \array_merge(
+        //      $allValidationStatements,
+        //      $this->fulfillOptionalFields()
+        //    );
+        $fieldsToValidate = $this->clearRequestFromExtraFields(
+            $validateFields,
+            \array_merge(
+                $this->request->attributes->all(),
+                $this->request->request->all(),
+                $this->request->query->all()
+            )
+        );
+        $errors           = $this->validator->validate(
+            $fieldsToValidate,
+            new Collection($allValidationStatements)
+        );
+        //        dump($allValidationStatementsWithAdditionalFields);
+        //        dump($fieldsToValidate);
+        //        dump($errors);
+        //    die;
+        $errorsExist = \count($errors) > 0;
+        if (true === $errorsExist) {
+            throw new ValidationException(
+                $this->translator->trans(
+                    'validation',
+                    [],
+                    'exceptions'
+                ),
+                $this->validationErrors($errors)
+            );
+        }
+        return $this;
+    }
+
+    private function formatValidationRules(array $rules): array {
+        foreach ($rules as $rule => $value) {
+            if (true === \is_numeric($rule)) {
+                unset($rules[$rule]);
+                $rules[$value] = true;
+            }
+        }
+
+        return $rules;
+    }
+
+    /**
+     * @param string   $validationField
+     * @param string[] $rules
+     *
+     * @return string[]
+     *
+     */
+    private function loopExistingValidationRules(string $validationField, array $rules): array {
+        $locale = $this->request->getLocale() ?? $this->request->getDefaultLocale();
+        //        dump($rules);
+        //        dump($validationField);
+        //        die;
+        $availableRules                = $this->availableValidationRules();
+        $messagesTranslationFileName   = 'validation';
+        $inputNamesTranslationFileName = 'input_names';
+        $attributesTranslationFileName = 'validation_attributes';
+        $validationFieldRules          = [];
+        $localeCode                    = $this->request->getLocale() ?? $this->request->getDefaultLocale();
+
+
+        foreach ($rules as $rule => $value) {
+            //      dump($rules);
+            //      die;
+            if (false === \array_key_exists($rule, $availableRules)) {
+                continue;
+            }
+            switch ($rule) {
+                // @todo - finish upgrading validation constraints
+                case 'filled':
+                    $validationFieldRules[] = new $availableRules[$rule](
+                        [
+                            'message' => $this->simpleTrans(
+                                $rule,
+                                $messagesTranslationFileName,
+                                $validationField,
+                                $inputNamesTranslationFileName
+                            ),
+                        ]
+                    );
+                    break;
+                case 'required':
+                    $validationFieldRules[] = new $availableRules[$rule](
+                        [
+                            'message' => $this->simpleTrans(
+                                $rule,
+                                $messagesTranslationFileName,
+                                $validationField,
+                                $inputNamesTranslationFileName
+                            ),
+                        ]
+                    );
+                    break;
+                case 'string':
+                    $validationFieldRules[] = new $availableRules[$rule](
+                        [
+                            'type'    => $rule,
+                            'message' => $this->simpleTrans(
+                                $rule,
+                                $messagesTranslationFileName,
+                                $validationField,
+                                $inputNamesTranslationFileName
+                            ),
+                        ]
+                    );
+                    break;
+                case 'identical':
+                    //          dump($this->request->get($validationField));
+                    //          die;
+                    $validationFieldRules[] = (new $availableRules[$rule]($this->translator))(
+                        new RuleTranslation(
+                            $rule,
+                            $validationField,
+                            [
+                                '{{ value }}'          => $this->request->get($validationField),
+                                '{{ compared_value }}' => $rules[$rule],
+                            ],
+                            $locale
+                        )
+                    );
+                    //          dump($validationFieldRules);
+                    //          die;
+                    break;
+                case 'confirm':
+                    $validationFieldRules[] = new $availableRules[$rule](
+                        [
+                            'confirmFor' => $this->request->request->get(
+                                $this->getConfirmationField($validationField)
+                            ) ?:
+                                (string)$this->request->query->get($this->getConfirmationField($validationField)),
+                            // TODO - security vulnerability if we stringify directly query params?
+                            'message'    => $this->simpleTrans(
+                                $rule,
+                                $messagesTranslationFileName,
+                                $this->getConfirmationField($validationField),
+                                $inputNamesTranslationFileName
+                            ),
+                        ]
+                    );
+                    break;
+                case 'unique';
+                    $validationFieldRules[] = new $availableRules[$rule](
+                        [
+                            'message'           => $this->simpleTrans(
+                                $rule,
+                                $messagesTranslationFileName,
+                                $validationField,
+                                $inputNamesTranslationFileName
+                            ),
+                            'entityFieldName'   => $validationField,
+                            'entityClass'       => $this->classToValidate(
+                                $stringRules
+                            )['class'],
+                            'entityFilterClass' => $this->classToValidate(
+                                $stringRules
+                            )['filter'],
+                            'ignoreUuid'        => $this->classToValidate(
+                                $stringRules
+                            )['id'],
+                        ]
+                    );
+                    break;
+                case 'email':
+                    $validationFieldRules[] = new $availableRules[$rule](
+                        [
+                            'strict'  => true,
+                            'message' => $this->simpleTrans(
+                                $rule,
+                                $messagesTranslationFileName,
+                                $validationField,
+                                $inputNamesTranslationFileName
+                            ),
+                        ]
+                    );
+                    break;
+                case 'length':
+                    $min                    = $this->minAndMaxValuesFromValidationStringRules(
+                        $validationFieldRulesWithValues,
+                        $rule
+                    )['min'];
+                    $max                    = $this->minAndMaxValuesFromValidationStringRules(
+                        $validationFieldRulesWithValues,
+                        $rule
+                    )['max'];
+                    $validationFieldRules[] = new $availableRules[$rule](
+                        [
+                            'min'        => $min,
+                            'max'        => $max,
+                            'minMessage' => $this->translator->trans(
+                                'min',
+                                [
+                                    '{{ inputName }}' => $this->translator->trans(
+                                        $validationField,
+                                        [],
+                                        $inputNamesTranslationFileName,
+                                        $locale
+                                    ),
+                                    '%min%'           => $min,
+                                    '%value%'         => $this->translator->transChoice(
+                                        'character',
+                                        2,
+                                        [],
+                                        $attributesTranslationFileName,
+                                        $locale
+                                    ),
+                                ],
+                                $messagesTranslationFileName,
+                                $locale
+                            ),
+                            'maxMessage' => $this->translator->trans(
+                                'max',
+                                [
+                                    '{{ inputName }}' => $this->translator->trans(
+                                        $validationField,
+                                        [],
+                                        $inputNamesTranslationFileName,
+                                        $locale
+                                    ),
+                                    '%max%'           => $max,
+                                    '%value%'         => $this->translator->transChoice(
+                                        'character',
+                                        2,
+                                        [],
+                                        $attributesTranslationFileName,
+                                        $locale
+                                    ),
+                                ],
+                                $messagesTranslationFileName,
+                                $locale
+                            ),
+                        ]
+                    );
+                    break;
+                // do nothing, skip validation if rule does not exists
+                default:
+                    break;
+            }
+        }
+
+        return $validationFieldRules;
+    }
+
+    /**
+     * Bind rules to its it string presentation, name
+     *
+     * @return string[]
+     */
+    private function availableValidationRules(): array {
+        return [
+            'required'  => NotNull::class,
+            'filled'    => NotBlank::class,
+            'nullable'  => Optional::class,
+            'string'    => Type::class,
+            'length'    => Length::class,
+            'email'     => Email::class,
+            'unique'    => UniqueEntityFieldConstraint::class,
+            'confirm'   => ConfirmFieldConstraint::class,
+            'identical' => IdenticalRuleHandler::class,
+            //TODO - add validations https://laravel.com/docs/5.6/validation#available-validation-rules
+            /*
+                'exists'    => ConfirmFieldConstraint::class,
+        'url'    => ConfirmFieldConstraint::class,
+            'date'    => ConfirmFieldConstraint::class,
+            'after_date'    => ConfirmFieldConstraint::class,
+                 'equal'    => EqualTo::class,
+       'array'    => ConfirmFieldConstraint::class,
+            'numeric'    => ConfirmFieldConstraint::class,
+            'int'    => ConfirmFieldConstraint::class,
+            'boolean'    => ConfirmFieldConstraint::class,
+            */
+        ];
+    }
+
+    /**
+     * translates message with attribute
+     *
+     * @throws TranslationInvalidArgumentException
+     */
+    private function simpleTrans(
+        string $translationId,
+        string $translationFileName,
+        string $translationAttributeId,
+        string $attributeFileName,
+        string $localeCode = 'en' // TODO - should be returned from system settigns
+    ): string {
+        return $this->translator->trans(
+            $translationId,
+            [
+                '%attribute%' => $this->translator->trans(
+                    $translationAttributeId,
+                    [],
+                    $attributeFileName,
+                    $localeCode
+                ),
+            ],
+            $translationFileName,
+            $localeCode
+        );
+    }
+
+    /**
+     * Getting name of field we need to confirm
+     *
+     * @param string $validationField
+     *
+     * @return string
+     */
+    private function getConfirmationField(string $validationField): string {
+        $confirmationWord          = \strpbrk(
+            $validationField,
+            'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+        );
+        $confirmationStartPosition = \strpos(
+            $validationField,
+            $confirmationWord
+        );
+
+        return \substr(
+            $validationField,
+            0,
+            $confirmationStartPosition
+        );
+    }
+
+    /**
+     * Used for getting class for unique validation, or existence
+     *
+     * @param string $rules
+     *
+     * @return string[]
+     */
+    private function classToValidate(string $rules): array {
+        $removeAllRulesBeforeUnique = \substr(
+            $rules,
+            \strpos(
+                $rules,
+                'unique'
+            )
+        );
+        $getClassAndFilter          = \explode(
+            ',',
+            $removeAllRulesBeforeUnique
+        );
+        $clearedClassFromUniqueRule = \substr(
+            $getClassAndFilter[0],
+            \strpos(
+                $getClassAndFilter[0],
+                ':'
+            ) + 1 //  + 1 removes : dots
+        );
+
+        return [
+            'class'  => $clearedClassFromUniqueRule,
+            'filter' => $getClassAndFilter[1],
+            'id'     => $getClassAndFilter[2] ?? '', // TODO - or better use null?
+        ];
+    }
+
+    /**
+     * Getting min and max values for validation rule (between:min,max)
+     *
+     * @param array  $fieldRulesWithValues
+     * @param string $rule
+     *
+     * @return string[]
+     */
+    private function minAndMaxValuesFromValidationStringRules(array $fieldRulesWithValues, string $rule): array {
+        $defaultMin = 1;    // minimal required chars
+        $defaultMax = 65535;// max required chars text type
+        foreach ($fieldRulesWithValues as $fieldRulesWithValue) {
+            if (
+            \is_numeric(
+                \strpos(
+                    $fieldRulesWithValue,
+                    $rule
+                )
+            )
+            ) {
+                $fieldMinAndMaxValues = \explode(
+                    ',',
+                    \preg_replace(
+                        '/[^0-9,]/',
+                        '',
+                        $fieldRulesWithValue
+                    )
+                );
+
+                return [
+                    'min' => (int)$fieldMinAndMaxValues[0] ?: $defaultMin,
+                    'max' => (int)$fieldMinAndMaxValues[1] ?: $defaultMax,
+                ];
+            }
+        }
+
+//        return null; // TODO - or throw exception?
+    }
+
+    private function clearRequestFromExtraFields(array $validateFields, array $allRequestFields): array {
+        foreach ($allRequestFields as $key => $value) {
+            if (false === \array_key_exists($key, $validateFields)) {
+                unset($allRequestFields[$key]);
+            }
+        }
+
+        return $allRequestFields;
+    }
+
+    /**
+     * return simple array of error messages
+     *
+     * @param ConstraintViolationListInterface $errorsCollection
+     *
+     * @return string[]
+     */
+    private function validationErrors(ConstraintViolationListInterface $errorsCollection): array {
+        $errors = [];
+        foreach ($errorsCollection as $error) {
+            $errors[\trim($error->getPropertyPath(), '[]')] = $error->getMessage();
+        }
+
+        return $errors;
+    }
+
+    /**
+     * Adds to all request fields, additional, optional fields
+     *
+     * @return string[]
+     *
+     * @throws MissingOptionsException
+     * @throws InvalidOptionsException
+     * @throws ConstraintDefinitionException
+     */
+    private function fulfillOptionalFields(): array {
+        $additionalFields = [];
+        foreach ($this->request->request->all() as $field => $rules) {
+            $isAdditionalField = \in_array(
+                $field,
+                $this->optionalFields(),
+                true
+            );
+            if ($isAdditionalField) {
+                $additionalFields[$field] = [new NotBlank()];
+            }
+        }
+
+        return $additionalFields;
+    }
+
+    /**
+     * @return string[]
+     */
+    private function optionalFields(): array {
+        return [
+            '_method',
+        ];
+    }
+}

File CreateAnimeValidationRequest Added

  • Ignore whitespace
  • Hide word diff
+<?php declare(strict_types = 1);
+/**
+ * (c) BonBonSlick
+ */
+
+namespace App\Infrastructure\Validation\Request\Admin\Create;
+
+use App\Infrastructure\Service\VendorPackages\Request\ValidateRequestInterface;
+use App\Infrastructure\Validation\Request\AbstractValidationRequest;
+final class CreateAnimeValidationRequest extends AbstractValidationRequest{
+  /**
+   * @inheritdoc
+   */
+  public function __invoke() : ValidateRequestInterface
+{
+    return $this->handleConstraints(
+      [
+        'name' => 'required|filled|string|length:3,55|',
+        'slug' => 'nullable|string|',
+        'description' => 'nullable|string|',
+      ]
+    );
+  }
+}

File README Added

  • Ignore whitespace
  • Hide word diff
+Hello mates! 
+Long ago I fond of Laravel Validation style and this is was my first try of implementation such a way araound Symfony Validator packages.
+Main issue here is maintenance, it was very hard as a result I switched to much more  efficient way with much more boilerplate DTOs.
+See for other sample here 
+https://qna.habr.com/q/890727
+
+Maybe I will add it later as one more gist. If you wish you can finalize this code and create package for this old way or new one in habr question.

File RequestDTOResolver Added

  • Ignore whitespace
  • Hide word diff
+<?php
+declare(strict_types=1);
+
+
+namespace App\Infrastructure\ArgumentResolver;
+
+use ReflectionClass;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
+use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\Validator\Mapping\ClassMetadataInterface;
+use Symfony\Component\Validator\Validator\ValidatorInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+final class RequestDTOResolver implements ArgumentValueResolverInterface {
+    private ValidatorInterface     $validator;
+    private ClassMetadataInterface $classMetadata;
+    private TranslatorInterface    $translator;
+
+    public function __construct(
+        ValidatorInterface $validator,
+        TranslatorInterface $translator
+    ) {
+        $this->validator  = $validator;
+        $this->translator = $translator;
+    }
+
+    /**
+     * https://symfony.com/doc/current/reference/constraints.html
+     *
+     * @return \Generator|iterable
+     */
+    public function resolve(Request $request, ArgumentMetadata $argument) {
+        // creating new instance of custom request DTO
+        $class = $argument->getType();
+        $dto   = new $class($request, $this->translator);
+        // throw bad request exception in case of invalid request data
+        $errors = $this->validator->validate($dto);
+//        dump($errors);
+//        die;
+        if (0 < count($errors)) {
+            throw new BadRequestHttpException((string)$errors);
+        }
+        yield $dto;
+    }
+
+    /**
+     * @return bool|void
+     * @throws \ReflectionException
+     */
+    public function supports(Request $request, ArgumentMetadata $argument) {
+        $reflection = new ReflectionClass($argument->getType());
+        if ($reflection->implementsInterface(RequestDTOInterface::class)) {
+            return true;
+        }
+
+        return false;
+    }
+}

File ValidationExceptionEventSubscriber Added

  • Ignore whitespace
  • Hide word diff
+<?php
+declare(strict_types=1);
+/**
+ * (c) BonBonSlick
+ */
+
+namespace App\Infrastructure\EventSourcing\Subscribers;
+
+use App\Domain\Core\Exceptions\ValidationException;
+use App\Infrastructure\Traits\ApiControllerTrait;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Event\ExceptionEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+
+final class ValidationExceptionEventSubscriber implements EventSubscriberInterface {
+    use ApiControllerTrait;
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function getSubscribedEvents(): array {
+        return [
+            KernelEvents::EXCEPTION => [
+                ['processValidationException', 11],
+            ],
+        ];
+    }
+
+    public function processValidationException(ExceptionEvent $event): void {
+        /** @var ValidationException $exception */
+        $exception                 = $event->getThrowable();
+        $isValidationFormException = \get_class($exception) === ValidationException::class;
+//        dump(self::class);
+//        dump($exception);
+//        die;
+        if (false === $isValidationFormException || false === $event->isMasterRequest()) {
+            return;
+        }
+        $event->setResponse(
+            $this->createResponse(
+                $exception->getMessage(),
+                [
+                    'violations' => $exception->violations(),
+                ],
+                $this->statusError,
+                null,
+                Response::HTTP_NOT_ACCEPTABLE
+            )
+        );
+    }
+}
HTTPS SSH

You can clone a snippet to your computer for local editing. Learn more.