在Symfony框架上验证请求
#网络开发人员 #php #api #symfony

今天,Symfony是世界上最成熟的PHP框架之一,因此,它用于包括APIS Creation在内的各种项目中。最近,Symfony包含了各种很酷的功能,例如mapping request data to typed objects,它出现在6.3版中。

这样,我们将利用PHP的最后版本中的一些最佳资源,这些资源为attributesreadonly properties提供了支持,并为Symfony的请求创建验证。

为此,我们将使用Symfony Validation组件。

我没有耐心,给我看代码!

好吧,好吧!如果您没有耐心阅读此帖子,我将在下面的链接中进行此帖子的实现。

https://github.com/joubertredrat/symfony-request-validation

基本示例

在Symfony文档之后,我们只需要创建一个将用于从请求中映射值的类,如下面的示例。

<?php declare(strict_types=1);

namespace App\Dto;

use App\Validator\CreditCard;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Positive;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Validator\Constraints\Type;

class CreateTransactionDto
{
    public function __construct(
        #[NotBlank(message: 'I dont like this field empty')]
        #[Type('string')]
        public readonly string $firstName,

        #[NotBlank(message: 'I dont like this field empty')]
        #[Type('string')]
        public readonly string $lastName,

        #[NotBlank()]
        #[Type('string')]
        #[CreditCard()]
        public readonly string $cardNumber,

        #[NotBlank()]
        #[Positive()]
        public readonly int $amount,

        #[NotBlank()]
        #[Type('int')]
        #[Range(
            min: 1,
            max: 12,
            notInRangeMessage: 'Expected to be between {{ min }} and {{ max }}, got {{ value }}',
        )]
        public readonly int $installments,

        #[Type('string')]
        public ?string $description = null,
    ) {
    }
}

与此相关,我们只将类用作控制器方法的依赖项,然后用注释#[MapRequestPayload],就是这样,值将自动映射到对象中,如下所示。

<?php declare(strict_types=1);

namespace App\Controller;

use App\Dto\CreateTransactionDto;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Annotation\Route;

class TransactionController extends AbstractController
{
    #[Route('/api/v1/transactions', name: 'app_api_create_transaction_v1', methods: ['POST'])]
    public function v1Create(#[MapRequestPayload] CreateTransactionDto $createTransaction): JsonResponse
    {
        return $this->json([
            'response' => 'ok',
            'datetime' => (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'),
            'firstName' => $createTransaction->firstName,
            'lastName' => $createTransaction->lastName,
            'amount' => $createTransaction->amount,
            'installments' => $createTransaction->installments,
            'description' => $createTransaction->description,
        ]);
    }
}

这样,只需执行请求并检查结果即可。

curl --request POST \
  --url http://127.0.0.1:8001/api/v1/transactions \
  --header 'Content-Type: application/json' \
  --data '{
  "firstName": "Joubert",
  "lastName": "RedRat",
  "cardNumber": "4130731304267489",
  "amount": 35011757,
  "installments": 2
}'
< HTTP/1.1 200 OK
< Content-Type: application/json

{
  "response": "ok",
  "datetime": "2023-07-04 19:36:37",
  "firstName": "Joubert",
  "lastName": "RedRat",
  "cardNumber": "4130731304267489",
  "amount": 35011757,
  "installments": 2,
  "description": null
}

在上面的示例中,如果值未正确填充,则会列出异常,我们将收到带有发现错误的响应。

Request error exception

问题是这个例外是默认的ValidationFailedException,但是当我们构建API时,有必要以JSON格式进行响应。

考虑到这一点,我们可以尝试一种不同的方法,这将被解释。

抽象请求类

Symfony的最大优势之一是您强大的依赖注入容器在AutowRire支持的情况下对“ Dependency inversion principle”的大量支持。

这样,我们将创建我们的摘要类,所有代码将负责进行请求和验证的分析,如下示例。

<?php declare(strict_types=1);

namespace App\Request;

use Fig\Http\Message\StatusCodeInterface;
use Jawira\CaseConverter\Convert;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\Validator\ValidatorInterface;

abstract class AbstractJsonRequest
{
    public function __construct(
        protected ValidatorInterface $validator,
        protected RequestStack $requestStack,
    ) {
        $this->populate();
        $this->validate();
    }

    public function getRequest(): Request
    {
        return $this->requestStack->getCurrentRequest();
    }

    protected function populate(): void
    {
        $request = $this->getRequest();
        $reflection = new \ReflectionClass($this);

        foreach ($request->toArray() as $property => $value) {
            $attribute = self::camelCase($property);
            if (property_exists($this, $attribute)) {
                $reflectionProperty = $reflection->getProperty($attribute);
                $reflectionProperty->setValue($this, $value);
            }
        }
    }

    protected function validate(): void
    {
        $violations = $this->validator->validate($this);
        if (count($violations) < 1) {
            return;
        }

        $errors = [];

        /** @var \Symfony\Component\Validator\ConstraintViolation */
        foreach ($violations as $violation) {
            $attribute = self::snakeCase($violation->getPropertyPath());
            $errors[] = [
                'property' => $attribute,
                'value' => $violation->getInvalidValue(),
                'message' => $violation->getMessage(),
            ];
        }

        $response = new JsonResponse(['errors' => $messages], 400);
        $response->send();
        exit;
    }

    private static function camelCase(string $attribute): string
    {
        return (new Convert($attribute))->toCamel();
    }

    private static function snakeCase(string $attribute): string
    {
        return (new Convert($attribute))->toSnake();
    }
}

在上面的类中,可以将ValidatorInterfaceRequestStack视为依赖项,并在构造函数中执行属性的填充和验证。

另外,有可能在属性和错误中看到样式snake_casecamelCase之间的转换,这发生了,这是因为存在一个惯例,即JSON中的字段必须是snake_case,而PSR-2PSR-12建议camelCase的名称for camelCase在类中的属性,然后有必要转换。因为这被使用了Case converter lib。

但是,请记住这不是绝对规则,如果您想在JSON中使用与snake_case的其他默认值,则可以。

带有验证属性的请求类

通过负责所有验证的摘要类,现在我们将创建验证类,作为下面的示例。

<?php declare(strict_types=1);

namespace App\Request;

use App\Validator\CreditCard;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Positive;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Validator\Constraints\Type;

class CreateTransactionRequest extends AbstractJsonRequest
{
    #[NotBlank(message: 'I dont like this field empty')]
    #[Type('string')]
    public readonly string $firstName;

    #[NotBlank(message: 'I dont like this field empty')]
    #[Type('string')]
    public readonly string $lastName;

    #[NotBlank()]
    #[Type('string')]
    #[CreditCard()]
    public readonly string $cardNumber;

    #[NotBlank()]
    #[Positive()]
    public readonly int $amount;

    #[NotBlank()]
    #[Type('int')]
    #[Range(
        min: 1,
        max: 12,
        notInRangeMessage: 'Expected to be between {{ min }} and {{ max }}, got {{ value }}',
    )]
    public readonly int $installments;

    #[Type('string')]
    public ?string $description = null;
}

上面类的最大优势是请求的所有必需属性都具有readonly状态,可以保证数据的不变性。

另一个有趣的观点是能够使用Symfony验证的属性进行必要的验证,甚至创建自定义验证。

在路线中使用请求类

准备好课程,现在它只是用作我们要进行验证的路线的依赖性,很高兴记住与上一个示例不同的是,这里不是必需的#[MapRequestPayload]注释,作为一个下面的示例。

<?php declare(strict_types=1);

namespace App\Controller;

use App\Request\CreateTransactionRequest;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class TransactionController extends AbstractController
{
    #[Route('/api/v2/transactions', name: 'app_api_create_transaction_v2', methods: ['POST'])]
    public function v2Create(CreateTransactionRequest $request): JsonResponse
    {
        return $this->json([
            'response' => 'ok',
            'datetime' => (new DateTimeImmutable('now'))->format('Y-m-d H:i:s'),
            'first_name' => $request->firstName,
            'last_name' => $request->lastName,
            'amount' => $request->amount,
            'installments' => $request->installments,
            'description' => $request->description,
            'headers' => [
                'Content-Type' => $request
                    ->getRequest()
                    ->headers
                    ->get('Content-Type')
                ,
            ],
        ]);
    }
}

在上面的控制器中,有可能看到我们没有从httpfoundation中使用传统的Request,而不是这样,而是我们的类CreateTransactionRequest是依赖性,这是因为所有类依赖性都会注入并完成验证。

这种方法的优势

与基本示例相比,此方法具有2个优点。

  • 可以根据需要自定义响应的JSON结构和状态代码。

  • 可以从Symfony访问Request类,而Symfony被注入依赖项,因此可以从请求中访问任何信息,例如标题。在基本示例中,这是不可能的

这是测试时间!

准备好实施后,让我们去测试。

请求示例是有意的错误,以便我们看到验证响应。

curl --request POST \
  --url http://127.0.0.1:8001/api/v2/transactions \
  --header 'Content-Type: application/json' \
  --data '{
  "last_name": "RedRat",
  "card_number": "1130731304267489",
  "amount": -4,
  "installments": 16
}'
< HTTP/1.1 400 Bad Request
< Content-Type: application/json

{
  "errors": [
    {
      "property": "first_name",
      "value": null,
      "message": "I dont like this field empty."
    },
    {
      "property": "card_number",
      "value": "1130731304267489",
      "message": "Expected valid credit card number."
    },
    {
      "property": "amount",
      "value": -4,
      "message": "This value should be positive."
    },
    {
      "property": "installments",
      "value": 16,
      "message": "Expected to be between 1 and 12, got 16"
    }
  ]
}

正如我们所看到的,验证发生在成功,字段未填写或错误的数据没有通过验证,我们得到了验证响应。

现在,让我们做一个有效的请求,看看这将在响应中取得成功,因为所有字段都会按照我们的意愿填写。

curl --request POST \
  --url http://127.0.0.1:8001/api/v2/transactions \
  --header 'Content-Type: application/json' \
  --data '{
  "first_name": "Joubert",
  "last_name": "RedRat",
  "card_number": "4130731304267489",
  "amount": 35011757,
  "installments": 2
}'
< HTTP/1.1 200 OK
< Content-Type: application/json

{
  "response": "ok",
  "datetime": "2023-07-01 16:39:48",
  "first_name": "Joubert",
  "last_name": "RedRat",
  "card_number": "4130731304267489",
  "amount": 35011757,
  "installments": 2,
  "description": null
}

限制

可选字段不能为readonly,因为如果您想在没有初始化的情况下访问数据,则PHP会引发异常。然后,目前,我正在使用此情况的普通字段。

我仍在寻找任何选项作为能够在可选字段中使用readonly的解决方案,例如使用反射,我接受建议。

Exception readonly attribute not initialized

最后,我感谢我的好朋友Vinicius Dias帮助我修订了这篇文章。