برنامه نویسی

Validando هیچ فریم ورک Symfony را درخواست نمی کند

امروزه Symfony یکی از بالغ ترین و قوی ترین فریم ورک های موجود در بازار است و به همین دلیل در چندین پروژه از جمله ایجاد API ها استفاده می شود. Symfony اخیراً چندین ویژگی جالب را شامل می‌شود، مانند قابلیت نگاشت درخواست داده‌ها به یک شی، که در نسخه 6.3 ظاهر شد.

با این کار ما از برخی از بهترین ویژگی‌های آخرین نسخه‌های PHP، که پشتیبانی از ویژگی‌ها و ویژگی‌های فقط خواندنی و ایجاد اعتبارسنجی برای Requests در Symfony است، استفاده خواهیم کرد.

برای این کار از مؤلفه Symfony Validation استفاده می کنیم.

حوصله ام تمام شده است، رمز را به من نشان دهید!

خوب خوب! در صورتی که حوصله مطالعه این مقاله را ندارید من یک پروژه آزمایشی با اجرای این مقاله در لینک زیر دارم.

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

مثال اساسی

به دنبال خود مستندات، فقط یک کلاس ایجاد کنید که از آن برای ترسیم مقادیر درخواست استفاده می کنیم، مانند مثال زیر.

<?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([
            'resonse' => '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

{
  "resonse": "ok",
  "datetime": "2023-07-04 19:36:37",
  "firstName": "Joubert",
  "lastName": "RedRat",
  "cardNumber": "4130731304267489",
  "amount": 35011757,
  "installments": 2,
  "description": null
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

در مثال بالا، اگر مقادیر طبق قوانین تعریف شده به درستی پر نشده باشند، یک استثنا مطرح می شود و با خطاهای یافت شده پاسخی دریافت می کنیم.

درخواست استثناء خطا

با این حال، این استثنا استاندارد است ValidationFailedException و از آنجایی که ما در حال ساخت یک API هستیم، پاسخ با فرمت json مورد نیاز است.

با در نظر گرفتن این موضوع، می توانیم رویکرد متفاوتی را امتحان کنیم که در ادامه توضیح داده خواهد شد.

کلاس درخواست چکیده

یکی از مزایای بزرگ Symfony پشتیبانی گسترده و گسترده از DIP “Dependency Inversion Original” از طریق ظرف تزریق وابستگی قدرتمند آن با پشتیبانی از autowire است.

با این کار، کلاس انتزاعی خود را ایجاد می کنیم که شامل تمام کدهایی است که مسئول تجزیه درخواست و اعتبارسنجی آن هستند، مانند مثال زیر.

<?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
    {
        $errors = $this->validator->validate($this);
        $messages = [];

        foreach ($errors as $message) {
            $messages[] = [
                'property' => self::snakeCase($message->getPropertyPath()),
                'value' => $message->getInvalidValue(),
                'message' => $message->getMessage(),
            ];
        }

        if (count($messages) > 0) {
            $response = new JsonResponse(['errors' => $messages], StatusCodeInterface::STATUS_BAD_REQUEST);
            $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();
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

در کلاس بالا می بینیم که آن را دریافت می کند ValidatorInterface و RequestStack به عنوان وابستگی ها و در سازنده تکمیل و اعتبار سنجی صفات انجام می شود.

همچنین امکان مشاهده تبدیل بین الگوها وجود دارد snake_case ه camelCase در صفات و خطاها، این اتفاق می افتد زیرا قراردادی وجود دارد که در آن فیلدهای یک JSON باید باشد. snake_case، در حالی که PSR-2 و PSR-12 استفاده را پیشنهاد می کنند camelCase برای نام ویژگی ها در کلاس ها، این تبدیل انجام می شود. برای این کار از کتابخانه مبدل Case استفاده شد.

با این حال، شایان ذکر است که اگر می خواهید از هر الگوی دیگری استفاده کنید، این یک قانون مطلق نیست snake_case در JSON، می توانید.

درخواست کلاس با ویژگی های اعتبارسنجی

در حالی که کلاس انتزاعی مسئول تمام اعتبار سنجی است، اکنون کلاس های اعتبار سنجی را مانند مثال زیر ایجاد می کنیم.

<?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 Validation برای انجام اعتبارسنجی های لازم یا حتی ایجاد اعتبارسنجی سفارشی استفاده کنید.

استفاده از کلاس درخواست در Route

با آماده بودن کلاس درخواست، اکنون تنها کاری که باید انجام دهید این است که از آن به عنوان یک وابستگی به مسیری که می خواهید اعتبار سنجی کنید استفاده کنید، به یاد داشته باشید که برخلاف مثال قبلی، حاشیه نویسی در اینجا ضروری نخواهد بود. #[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([
            'resonse' => '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')
                ,
            ],
        ]);
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

در کنترلر بالا می توان دید که از سنتی استفاده نمی کنیم Request از HttpFoundation و بله کلاس ما CreateTransactionRequest به عنوان یک وابستگی و اینجاست که جادو اتفاق می افتد، زیرا تمام وابستگی های لازم تزریق می شود و اعتبارسنجی انجام می شود.

مزایای این رویکرد

در مقایسه با مثال اصلی، این رویکرد دو مزیت عمده دارد.

  • می توانید ساختار json و کد وضعیت پاسخ را به دلخواه شخصی سازی کنید.

  • دسترسی به کلاس Request Smyfony که به عنوان یک وابستگی تزریق شده است امکان پذیر است، با این کار می توان به هر اطلاعات درخواستی مانند هدر دسترسی داشت. در مثال اصلی این امکان وجود ندارد، مگر اینکه کلاس Request را نیز به عنوان یک وابستگی به مسیر قرار دهید، که عجیب است، زیرا دو منبع داده متفاوت برای یک درخواست دارد.

زمان تست!

با اجرای ما آماده است، بیایید به سراغ آزمایش ها برویم.

درخواست مثال عمدا دارای خطاهایی است، بنابراین ما می‌توانیم پاسخ‌های اعتبارسنجی را ببینیم.

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

{
  "resonse": "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 در قسمت های اختیاری، مانند استفاده از Reflection به عنوان مثال، و من پیشنهادات را می پذیرم 🙂

ویژگی استثنایی فقط خواندنی اولیه نشده است

در پایان از دوست بزرگم وینیسیوس دیاس که در بازنگری این مقاله به من کمک کرد تشکر کنم.

پس همین، تا دفعه بعد!

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا