Walidacja danych (#5) – API w PHP z Laminas Mezzio

tytuł wpisu z grafiką kodu

Do tej pory powstała aplikacja, która działa, ale tylko dla poprawnych żądań. Zdecydowanie jest ona podatna na różne błędy i nieodporna na ataki. Trzeba zapewnić podstawową walidację danych. Jedna z prawd programowania głosi, że warto zadbać o dane na wejściu i na wyjściu.

Pojawia się świetna okazja na stworzenie kilku nowych middleware. Zresztą niby podstawowa jednostka Mezzio, a do tej pory stworzyłem tylko jeden. Prawda jest taka, że istnieje wiele reużywalnych tego rodzaju klas, z których z powodzeniem można korzystać. Realizują one pojedyncze i niewielkie funkcje, więc bardzo łatwo wpina się je do aplikacji. Przy tym wszystkim spełniają interfejsy PSR, co dodatkowo upraszcza sprawę. To właśnie piękno tego podejścia.

Walidacja, filtrowanie i przekształcanie danych

Ogólnie powstaną trzy nowe klasy typu middleware. Na pierwszy ogień kwestia identyfikatorów klienta. W tym momencie z routingu pobierana jest wartość parametru customerId i na jej podstawie tworzony jest obiekt UUID, który reprezentuje identyfikator klienta. Wszystko fajnie, ale do tego parametru można przekazać każdą wartość, a kod i tak się wykona. Oczywiście generując nieobsłużony błąd w przypadku niepowodzenia.

Należy więc upewnić się, że parametr zawiera w sobie poprawny ciąg znaków. Jedną z opcji jest obsłużenie tego na poziomie routingu. Umożliwia on bowiem zdefiniowanie za pomocą wyrażeń regularnych dozwolony format. W przypadku niedopasowania zwróci 404, informując że strona o takim adresie nie istnieje. Opcja jak najbardziej w porządku, ale ja już na wczesnym etapie chcę posługiwać się identyfikatorem w postaci obiektu. Stworzę więc własny middleware do obsługi identyfikatora UUID.

<?php

declare(strict_types=1);

namespace Customer\Middleware;

use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Ramsey\Uuid\Exception\InvalidUuidStringException;
use Ramsey\Uuid\Uuid;

class UuidMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        try {
            $uuid = Uuid::fromString((string) $request->getAttribute('customerId'));
        } catch (InvalidUuidStringException) {
            return new JsonResponse([
                'message' => 'Wrong customerId format'
            ], 400);
        }

        return $handler->handle(
            $request->withAttribute('customerId', $uuid)
        );
    }
}

Jak widać, nic trudnego. Następuje próba stworzenia identyfikatora z przesłanego parametru customerId. Wcześniej jest on jeszcze sprowadzony do typu string, bo taki typ jest wymagany przez metodę statyczną fromString(). Jeśli operacja się nie powiedzie to w bloku catch złapany zostanie wyjątek InvalidUuidStringException reprezentujący nieudaną próbę stworzenia obiektu i zwróci odpowiedź ze statusem 400 oraz informacją, że identyfikator ma niepoprawny format. Jeśli stworzenie identyfikatora się uda, przekazywany zostanie do handlera lub kolejnego w kolejce middleware.

Plusy płynące z takiego rozwiązania, to obsługa w jednym miejscu, reużywalny komponent oraz ucięcie niepoprawnego żądania możliwie jak najwcześniej. Poniżej prezentuję jeszcze fabrykę oraz test. Testy middleware w PHP wyglądają podobnie, stąd też nie wszystkie będę tutaj prezentował.

<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Middleware\UuidMiddleware;
use Interop\Container\ContainerInterface;

class UuidMiddlewareFactory
{
    public function __invoke(ContainerInterface $container): UuidMiddleware
    {
        return new UuidMiddleware();
    }
}
<?php

declare(strict_types=1);

namespace CustomerTest\Middleware;

use Customer\Middleware\UuidMiddleware;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Ramsey\Uuid\Uuid;

class UuidMiddlewareTest extends TestCase
{
    use ProphecyTrait;

    /** @dataProvider provideWrongCustomerIds */
    public function testCannotHandleIdWithWrongFormat(mixed $customerId): void
    {
        /** @var ServerRequestInterface|ObjectProphecy $request */
        $request = $this->prophesize(ServerRequestInterface::class);

        /** @var RequestHandlerInterface|ObjectProphecy $handler */
        $handler = $this->prophesize(RequestHandlerInterface::class);

        $request
            ->getAttribute('customerId')
            ->willReturn($customerId);

        $middleware = new UuidMiddleware();
        $response = $middleware->process($request->reveal(), $handler->reveal());

        $this->assertSame(400, $response->getStatusCode());
    }

    public function testCanHandleUuid(): void
    {
        /** @var ServerRequestInterface|ObjectProphecy $request */
        $request = $this->prophesize(ServerRequestInterface::class);

        /** @var RequestHandlerInterface|ObjectProphecy $handler */
        $handler = $this->prophesize(RequestHandlerInterface::class);

        $customerId = 'd2f3ae68-5f62-4825-80c6-07e5d9a71c25';

        $request
            ->getAttribute('customerId')
            ->willReturn($customerId);

        $request
            ->withAttribute('customerId', Uuid::fromString($customerId))
            ->willReturn($request->reveal());

        $handler
            ->handle($request->reveal())
            ->shouldBeCalled();

        $middleware = new UuidMiddleware();
        $middleware->process($request->reveal(), $handler->reveal());
    }

    public function provideWrongCustomerIds(): array
    {
        return [
            ['customerId' => 123],
            ['customerId' => '123'],
            ['customerId' => null],
            ['customerId' => 'd2f3ae68-5f6-482-80c-07e5d9a71c25']
        ];
    }
}

Teraz należy dorzucić klasę do zależności oraz wpiąć nową funkcjonalność w odpowiednie miejsca, które opierają się o identyfikator. Dotyczy to trzech handlerów służących do: pobrania danych klienta, jego aktualizacji bądź usunięcia. W pliku config/routes.php należy zarejestrować wspomniany middleware tak jak w poniższym kodzie.

<?php

declare(strict_types=1);

use Customer\Handler\CreateCustomerHandler;
use Customer\Handler\DeleteCustomerHandler;
use Customer\Handler\ReadAllCustomerHandler;
use Customer\Handler\ReadCustomerHandler;
use Customer\Handler\UpdateCustomerHandler;
use Customer\Middleware\UuidMiddleware;
use Mezzio\Application;
use Mezzio\MiddlewareFactory;
use Psr\Container\ContainerInterface;

return static function (
    Application $app,
    MiddlewareFactory $factory,
    ContainerInterface $container
): void {
    $app->post(
        '/api/v1/customer',
        CreateCustomerHandler::class,
        'customer.create'
    );
    $app->get(
        '/api/v1/customer',
        ReadAllCustomerHandler::class,
        'customer.readAll'
    );
    $app->get(
        '/api/v1/customer/{customerId}',
        [
            UuidMiddleware::class,
            ReadCustomerHandler::class
        ],
        'customer.read'
    );
    $app->patch(
        '/api/v1/customer/{customerId}',
        [
            UuidMiddleware::class,
            UpdateCustomerHandler::class
        ],
        'customer.update'
    );
    $app->delete(
        '/api/v1/customer/{customerId}',
        [
            UuidMiddleware::class,
            DeleteCustomerHandler::class
        ],
        'customer.delete'
    );
};

Teraz zostaje usunąć odpowiedzialność tworzenia obiektu identyfikatora z wyżej wymienionych klas. W tym momencie wystarczy go pobrać, jako że wcześniej został już przekazany w interesującej handler formie. Na przykładzie tego służącego do usunięcia klienta, prezentuje się to następująco.

<?php

declare(strict_types=1);

namespace Customer\Handler;

use Customer\Repository\CustomerRepositoryInterface;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class DeleteCustomerHandler implements RequestHandlerInterface
{
    private CustomerRepositoryInterface $customerRepository;

    public function __construct(CustomerRepositoryInterface $customerRepository)
    {
        $this->customerRepository = $customerRepository;
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $customer = $this->customerRepository->findOneById($request->getAttribute('customerId'));

        $this->customerRepository->delete($customer);

        return new JsonResponse([
            'message' => 'Customer deleted'
        ]);
    }
}

Super, sprawa identyfikatora załatwiona. Pojawia się kolejny problem. Co z tego że identyfikator jest poprawny, skoro może nie być odpowiadającego mu klienta. A taka sytuacja nie została jeszcze obsłużona. Można byłoby zrealizować ją na etapie handlera. Chociaż, skoro można stworzyć jeden middleware i skorzystać z niego we wszystkich miejscach… Sami rozumiecie.

<?php

declare(strict_types=1);

namespace Customer\Middleware;

use Customer\Query\GetOneCustomerById;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class CustomerExistenceMiddleware implements MiddlewareInterface
{
    private GetOneCustomerById $getOneCustomerById;

    public function __construct(GetOneCustomerById $getOneCustomerById)
    {
        $this->getOneCustomerById = $getOneCustomerById;
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        if (empty($this->getOneCustomerById->execute($request->getAttribute('customerId')))) {
            return new JsonResponse([
                'message' => 'Customer with this customerId does not exist'
            ], 400);
        }

        return $handler->handle($request);
    }
}

I oczywiście kilka słów komentarza. Do sprawdzenia, czy istnieje klient z przekazanym identyfikatorem wykorzystane zostało istniejące już zapytanie. Jeśli nie ma takiego klienta to żądanie jest ucinane i zwracana jest odpowiedź błędu.

To, czego trzeba mieć świadomość – jedno dodatkowe zapytanie. W kolejnym etapie, jeśli klient istnieje to znów baza zostanie odpytana o jego dane. Jasne, dałoby się wyciągnąć tutaj obiekt i przekazać go dalej, by nie musieć sięgać po niego drugi raz. Ja jednak nie zdecydowałem się na takie rozwiązanie, gdyż w jednym miejscu potrzebuję dane w postaci tablicy, a w dwóch jako obiekt. Można to obsłużyć na przykład tworząc dwie osobne klasy walidujące. Jeśli używa się na przykład ORM to najpewniej taki obiekt przetrzymywany jest już w pamięci, więc w ramach jednego żądania nie będzie kolejnego zapytania. Ale to już inna historia – tak jak wspomniałem tutaj takowe wystąpi i ja ten koszt akceptuję. Najważniejsze, żeby być go świadomym.

<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Middleware\CustomerExistenceMiddleware;
use Customer\Query\GetOneCustomerById;
use Psr\Container\ContainerInterface;

class CustomerExistenceMiddlewareFactory
{
    public function __invoke(ContainerInterface $container): CustomerExistenceMiddleware
    {
        return new CustomerExistenceMiddleware(
            $container->get(GetOneCustomerById::class)
        );
    }
}

Powyżej jeszcze fabryka i można zająć się konfigurowaniem klasy jako zależność oraz wpinać ją do odpowiedniego routingu. Zresztą tak jak miało to miejsce wcześniej – nawet dla tych samych handlerów.

<?php

declare(strict_types=1);

use Customer\Handler\CreateCustomerHandler;
use Customer\Handler\DeleteCustomerHandler;
use Customer\Handler\ReadAllCustomerHandler;
use Customer\Handler\ReadCustomerHandler;
use Customer\Handler\UpdateCustomerHandler;
use Customer\Middleware\CustomerExistenceMiddleware;
use Customer\Middleware\UuidMiddleware;
use Mezzio\Application;
use Mezzio\MiddlewareFactory;
use Psr\Container\ContainerInterface;

return static function (
    Application $app,
    MiddlewareFactory $factory,
    ContainerInterface $container
): void {
    $app->post(
        '/api/v1/customer',
        [
            CreateCustomerHandler::class
        ],
        'customer.create'
    );
    $app->get(
        '/api/v1/customer',
        ReadAllCustomerHandler::class,
        'customer.readAll'
    );
    $app->get(
        '/api/v1/customer/{customerId}',
        [
            UuidMiddleware::class,
            CustomerExistenceMiddleware::class,
            ReadCustomerHandler::class
        ],
        'customer.read'
    );
    $app->patch(
        '/api/v1/customer/{customerId}',
        [
            UuidMiddleware::class,
            CustomerExistenceMiddleware::class,
            UpdateCustomerHandler::class
        ],
        'customer.update'
    );
    $app->delete(
        '/api/v1/customer/{customerId}',
        [
            UuidMiddleware::class,
            CustomerExistenceMiddleware::class,
            DeleteCustomerHandler::class
        ],
        'customer.delete'
    );
};

Na początku zaznaczyłem, że middleware będą trzy, czyli został ostatni. Ten odpowiedzialny będzie już za faktyczne filtrowanie i walidację danych klienta. Co do tej pory udało się już zrealizować? Po wcześniejszych modyfikacjach, z pewnością załatwione jest usuwanie klienta. Nic więcej nie trzeba sprawdzać, bo nie ma dodatkowych warunków determinujących, czy da się go usunąć. Nawet gdyby istniały, to byłyby to domenowe warunki, czyli ich realizacja odbywałaby się w warstwie serwisów. Załatwione też od samego początku było pobieranie wszystkich klientów. Nie ma potrzeby zmiany obsługi, bo dla tego przypadku żadna walidacja nie jest wymagana. Ewentualnie, gdyby dało się filtrować dane – wówczas trzeba byłoby zadbać o odpowiednią walidację przesyłanych danych. Jedyne zabezpieczenie jakie można wprowadzić na teraz to maksymalna wartość paginacji, tak by ktoś nie pobrał zbyt dużo na raz. Narażając przez to zasoby serwera. Jednak to sprawdzenie dla tej aplikacji pominę. Odczytanie jednego klienta też jest z głowy skoro istnieje sprawdzenie czy identyfikator jest w porządku i czy klient istnieje. Jeśli te warunki są spełnione to dane klienta są zwracane.

Pozostaje tworzenie nowego rekordu oraz jego aktualizacja i właśnie dla tych endpointów potrzebna będzie walidacja. Kilka przygotowań, zanim powstanie Customer\Middleware\CustomerValidationMiddleware. Do walidacji skorzystam z komponentów Laminasa, które w prosty sposób pozwalają na filtrowanie i podstawową walidację danych. Co ważne, komponent jest na tyle niezależny, że z powodzeniem można go używać w aplikacjach opartych o inny framework, czy nawet czysty PHP.

composer require laminas/laminas-inputfilter

Powyżej pokazano jak dociągnąć wspomnianą bibliotekę odpowiedzialną za filtrowanie i walidację danych. Przy instalacji można wybrać, czy pliki konfiguracyjne mają dodać się automatycznie albo samemu je wrzucić do config/config.php.

<?php

declare(strict_types=1);

namespace Customer\Middleware;

use Customer\Service\CustomerValidator;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class CustomerValidationMiddleware implements MiddlewareInterface
{
    private CustomerValidator $customerValidator;

    public function __construct(CustomerValidator $customerValidator)
    {
        $this->customerValidator = $customerValidator;
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $this->customerValidator->setRequired($request->getMethod() === 'POST');
        $this->customerValidator->setData($request->getParsedBody());

        if (!$this->customerValidator->isValid()) {
            return new JsonResponse([
                'message' => $this->customerValidator->getErrorMessage() ?? 'Unexpected validation error'
            ], 400);
        }

        return $handler->handle(
            $request->withParsedBody($this->customerValidator->getValues())
        );
    }
}

Middleware odpowiedzialny za walidację danych zaprezentowany został powyżej. To zadanie deleguje jednak do klasy walidatora. Jeśli dane nie są poprawne, klasycznie zwracana jest odpowiedź ze statusem 400 i treścią błędu. W innym wypadku przefiltrowane i zwalidowane dane podawane są dalej. Jeden wspólny middleware będzie dla tworzenia i aktualizacji danych klienta. Jako, że przy aktualizacji nie trzeba podawać wszystkich parametrów, klasa walidująca ma metodę setRequired(). Na podstawie metody HTTP określam, czy ustawić niektóre pola jako wymagane.

Jeśli nie znacie walidatorów ze stajni Laminasa to spieszę z wyjaśnieniem. Najpierw należy przekazać mu dane do walidacji – metoda setData(). Następnie, aby móc pobrać przefiltrowane i zwalidowane dane przy użyciu getValues(), trzeba uruchomić proces walidujący za pomocą isValid().

Dla tego middleware fabryka zawiera nieco więcej kodu. Z kontenera można pobrać InputFilterPluginManager, który odpowiedzialny jest za obsługę wszystkich klas filtrujących i walidujących. Dopiero z niego pobierana jest klasa walidatora i przekazywana przez konstruktor.

<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Middleware\CustomerValidationMiddleware;
use Customer\Service\CustomerValidator;
use Laminas\InputFilter\InputFilterPluginManager;
use Psr\Container\ContainerInterface;
use Webmozart\Assert\Assert;

class CustomerValidationMiddlewareFactory
{
    public function __invoke(ContainerInterface $container): CustomerValidationMiddleware
    {
        /** @var InputFilterPluginManager $pluginManager */
        $pluginManager = $container->get(InputFilterPluginManager::class);
        /** @var CustomerValidator $customerValidator */
        $customerValidator = $pluginManager->get(CustomerValidator::class);
        Assert::isInstanceOf($customerValidator, CustomerValidator::class);

        return new CustomerValidationMiddleware(
            $customerValidator
        );
    }
}

Aby w ten sposób dało się pobrać walidator, trzeba go wcześniej zarejestrować w zależnościach. Nieco w inny sposób, niż w przypadku zwykłych serwisów. Jednakże dla tej klasy nie ma takiej potrzeby, bo walidator nie wymaga żadnych dodatkowych zależności. Obsłużony będzie przez domyślną fabrykę abstrakcyjna, która wykonuje się dla klas rozszerzających Laminas\InputFilter\InputFilter.

No dobra, ale jak wygląda ten walidator dla danych klienta. W metodzie init() zawiera wszystkie pola, która mają być filtrowane i walidowane. Ich nazwy odpowiadają nazwą parametrów przesyłanych za pomocą JSON. Ta klasa ma dodatkowo dwie metody, które są potrzebne dla tego przypadku. Jedna to setRequired() o której wspomniałem wcześniej. Druga getErrorMessage() zwróci pierwszy napotkany komunikat błędu w formie właściwej dla zwrotki z API. Normalnie każdy walidator ma do dyspozycji getMessages(), ale ona lepiej sprawdzi się dla formularzy.

<?php

declare(strict_types=1);

namespace Customer\Service;

use Laminas\Filter\StringTrim;
use Laminas\Filter\StripNewlines;
use Laminas\Filter\StripTags;
use Laminas\Filter\ToNull;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator\Digits;
use Laminas\Validator\EmailAddress;
use Laminas\Validator\Hostname;
use Laminas\Validator\StringLength;

class CustomerValidator extends InputFilter
{
    private const FIRST_NAME = 'firstName';
    private const LAST_NAME = 'lastName';
    private const EMAIL = 'email';
    private const PHONE_NUMBER = 'phoneNumber';
    private const POSITION = 'position';
    private const COMPANY = 'company';

    private const REQUIRED_FIELDS = [
        self::FIRST_NAME,
        self::LAST_NAME,
        self::EMAIL
    ];

    public function init(): void
    {
        $this->add([
            'name' => self::FIRST_NAME,
            'required' => true,
            'filters' => [
                ['name' => StripTags::class],
                ['name' => StripNewlines::class],
                ['name' => StringTrim::class],
                ['name' => ToNull::class]
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'break_chain_on_failure' => true,
                    'options' => [
                        'encoding' => 'UTF-8',
                        'min' => 3,
                        'max' => 255
                    ]
                ]
            ]
        ]);

        $this->add([
            'name' => self::LAST_NAME,
            'required' => true,
            'filters' => [
                ['name' => StripTags::class],
                ['name' => StripNewlines::class],
                ['name' => StringTrim::class],
                ['name' => ToNull::class]
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'break_chain_on_failure' => true,
                    'options' => [
                        'encoding' => 'UTF-8',
                        'min' => 3,
                        'max' => 255
                    ]
                ]
            ]
        ]);

        $this->add([
            'name' => self::EMAIL,
            'required' => true,
            'filters' => [
                ['name' => StripTags::class],
                ['name' => StripNewlines::class],
                ['name' => StringTrim::class],
                ['name' => ToNull::class]
            ],
            'validators' => [
                [
                    'name' => EmailAddress::class,
                    'break_chain_on_failure' => true,
                    'options' => [
                        'allow' => Hostname::ALLOW_DNS
                    ]
                ],
                [
                    'name' => StringLength::class,
                    'break_chain_on_failure' => true,
                    'options' => [
                        'encoding' => 'UTF-8',
                        'min' => 3,
                        'max' => 255
                    ]
                ]
            ]
        ]);

        $this->add([
            'name' => self::PHONE_NUMBER,
            'required' => false,
            'filters' => [
                ['name' => StripTags::class],
                ['name' => StripNewlines::class],
                ['name' => StringTrim::class],
                ['name' => ToNull::class]
            ],
            'validators' => [
                ['name' => Digits::class,],
                [
                    'name' => StringLength::class,
                    'break_chain_on_failure' => true,
                    'options' => [
                        'encoding' => 'UTF-8',
                        'min' => 9,
                        'max' => 9
                    ]
                ],

            ]
        ]);

        $this->add([
            'name' => self::POSITION,
            'required' => false,
            'filters' => [
                ['name' => StripTags::class],
                ['name' => StripNewlines::class],
                ['name' => StringTrim::class],
                ['name' => ToNull::class]
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'break_chain_on_failure' => true,
                    'options' => [
                        'encoding' => 'UTF-8',
                        'min' => 3,
                        'max' => 255
                    ]
                ]
            ]
        ]);

        $this->add([
            'name' => self::COMPANY,
            'required' => false,
            'filters' => [
                ['name' => StripTags::class],
                ['name' => StripNewlines::class],
                ['name' => StringTrim::class],
                ['name' => ToNull::class]
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'break_chain_on_failure' => true,
                    'options' => [
                        'encoding' => 'UTF-8',
                        'min' => 1,
                        'max' => 511
                    ]
                ]
            ]
        ]);
    }

    public function getErrorMessage(): ?string
    {
        foreach ($this->getMessages() as $fieldName => $errorMessage) {
            return sprintf("Field %s: %s", $fieldName, reset($errorMessage));
        }

        return null;
    }

    public function setRequired(bool $isRequired): void
    {
        foreach ($this->getInputs() as $input) {
            if (!in_array($input->getName(), self::REQUIRED_FIELDS)) {
                continue;
            }

            $input->setRequired($isRequired);
        }
    }
}

Laminas dostarcza wiele filtrów i walidatorów w pakiecie. W razie czego łatwo napisać swój, co często mi się zdarza, gdy mam potrzebę obsługi nietypowego żądania. Dla tej aplikacji nie jest to jednak konieczne – standardowe w zupełności wystarczą.

Tym razem, filtry dla wszystkich pól wyglądają podobnie. Wszystkie pola są ciągami znaków. Trzeba więc wyciąć z nich tagi takie jak <script>, które mogą być niebezpieczne. Dodatkowo, ja usuwam też z nich znaki nowych linii i puste znaki z początku i końca. Pusty string jest zrzucany do nulla, po to by łatwiej było go obsłużyć.

Co do wykorzystanych walidatorów to zauważyć można Laminas\Validator\StringLength, który sprawdzi długość przekazanego ciągu znaków, Laminas\Validator\EmailAddress sprawdzający poprawność adresu e-mail klienta oraz Laminas\Validator\Digits weryfikujący, czy w numerze telefonu nie ma innych znaków poza cyframi. Opcja break_chain_on_failure ustawiona na true spowoduje, że walidacja zostanie przerwana jeśli konkretny warunek nie jest spełniony. Domyślnie flaga ma wartość false.

Tak przygotowany komponent walidacyjny trzeba zarejestrować w routingu analogicznie jak wcześniej. Finalna wersja routingu prezentuje się następująco.

<?php

declare(strict_types=1);

use Customer\Handler\CreateCustomerHandler;
use Customer\Handler\DeleteCustomerHandler;
use Customer\Handler\ReadAllCustomerHandler;
use Customer\Handler\ReadCustomerHandler;
use Customer\Handler\UpdateCustomerHandler;
use Customer\Middleware\CustomerExistenceMiddleware;
use Customer\Middleware\CustomerValidationMiddleware;
use Customer\Middleware\UuidMiddleware;
use Mezzio\Application;
use Mezzio\MiddlewareFactory;
use Psr\Container\ContainerInterface;

return static function (
    Application $app,
    MiddlewareFactory $factory,
    ContainerInterface $container
): void {
    $app->post(
        '/api/v1/customer',
        [
            CustomerValidationMiddleware::class,
            CreateCustomerHandler::class
        ],
        'customer.create'
    );
    $app->get(
        '/api/v1/customer',
        ReadAllCustomerHandler::class,
        'customer.readAll'
    );
    $app->get(
        '/api/v1/customer/{customerId}',
        [
            UuidMiddleware::class,
            CustomerExistenceMiddleware::class,
            ReadCustomerHandler::class
        ],
        'customer.read'
    );
    $app->patch(
        '/api/v1/customer/{customerId}',
        [
            UuidMiddleware::class,
            CustomerExistenceMiddleware::class,
            CustomerValidationMiddleware::class,
            UpdateCustomerHandler::class
        ],
        'customer.update'
    );
    $app->delete(
        '/api/v1/customer/{customerId}',
        [
            UuidMiddleware::class,
            CustomerExistenceMiddleware::class,
            DeleteCustomerHandler::class
        ],
        'customer.delete'
    );
};

Podsumowanie walidacji danych w API

Walidować otrzymywane dane można na różne sposoby. Jeśli jest to JSON tak jak w tym przypadku, ciekawą i wygodną opcją może okazać się walidacja jego schematu. Ja jednak postawiłem na walidację sparsowanych danych do tablic. Niezależnie od wyboru – middleware to świetnie miejsce na walidację danych.

Gotowych walidatorów w PHP można znaleźć sporo. Ja skorzystałem z tego, który znam i sprawdza się on świetnie. Tak jak wspominałem, można z powodzeniem użyć go w większości aplikacji. Listę filtrów i listę walidatorów, które są dostępne można przejrzeć w dokumentacji Laminas. Oczywiście, w prosty sposób można także napisać swoją własną klasę walidujacą, którą bez problemu można wykorzystać analogicznie jak te gotowe.

Cały kod dostępny jako gałąź step05.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.