CRUD API (#4) – API w PHP z Laminas Mezzio

tytuł wpisu z grafiką kodu

Czas zagwarantować możliwość interakcji pomiędzy aplikacjami – implementacja CRUD API. Nareszcie nastała odpowiednia pora, by wykorzystać wszystkie funkcjonalności, które powstały do tego momentu. Bazowa konfiguracja projektu, uwierzytelnianie i komunikacja z bazą danych – wszystko co zostało zrealizowane, teraz zostanie użyte.

Na samym początku szybka zmiana nazwy modułu. Do tej pory nazywał się Api, ale nie była to adekwatna nazwa. Ten moduł będzie dotyczył wszystkiego, co związane z klientem, dlatego nowa nazwa to Customer. Zmian trzeba dokonać w samym katalogu, przestrzeniach nazw oraz konfiguracjach autoloadingu i testów. Miejsca analogicznie jak przy dodawaniu nowego modułu.

Dobra okazja, żeby wspomnieć: kod i jego architektura ewoluują i wiele konceptów najzwyczajniej w świecie się zmienia. Niektóre decyzje można odkładać w czasie, ale prędzej, czy później trzeba będzie je podjąć. Oby jak najpóźniej, bo z większym stanem wiedzy będzie dużo łatwiej. Oczywiście, żaden wybór nie jest na zawsze, ale z niektórych ciężko się wycofać.

Konfiguracja routingu w Mezzio

Na początek przyda się odpowiednia konfiguracja routingu. Mimo, iż klasy handlerów jeszcze nie istnieją, zadanie jest o tyle proste, że wiadomo ile ich dokładnie będzie i które za co będą odpowiedzialne.

<?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 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}', ReadCustomerHandler::class, 'customer.read');
    $app->patch('/api/v1/customer/{customerId}', UpdateCustomerHandler::class, 'customer.update');
    $app->delete('/api/v1/customer/{customerId}', DeleteCustomerHandler::class, 'customer.delete');
};

Tak prezentuje się konfiguracja, która już do końca tego wpisu pozostanie bez zmian. Można więc pozostawić plik config/routes.php w spokoju. Myślę, że nie trzeba tu dużo tłumaczyć. Na obiekcie $app wykonywane są kolejno metody HTTP, które przyjmują ścieżkę, nazwę klasy i opcjonalnie nazwę route.

Mając taką konfigurację, można zabezpieczyć dostęp do konkretnych ścieżek. W pliku config/pipeline.php należy zarejestrować wcześniej przygotowane Mezzio\Authentication\AuthenticationMiddleware i Auth\Middleware\IpAccessControlMiddleware. Poniższy fragment kodu ilustruje jak to zrobić. Zauważcie, że jako ścieżka podana jest /api/v1 co oznacza, że każdy endpoint tego API będzie zabezpieczony w ten sposób.

$app->pipe('/api/v1', AuthenticationMiddleware::class);
$app->pipe('/api/v1', IpAccessControlMiddleware::class);

Model klienta

Reprezentacja klienta jest trywialna. Znajdzie się w niej kilka pól wraz z akcesorami. Dane, jakie mają być utrwalane to: unikalny identyfikator, imię, nazwisko, adres e-mail, numer telefonu, stanowisko, firma w której pracuje oraz data jego utworzenia w systemie. Niektóre będą obowiązkowe, niektóre opcjonalne. Na model będą składały się dwie klasy Customer\Model\Customer i Customer\Model\Job.

Ta pierwsza odpowiada za podstawowe informacje o kliencie. Druga zaś podstawowe informacje o jego pracy. Obie połączone relacją 1-1. Konstruktor wymusza przy tworzeniu obiektów dostarczenie obowiązkowych danych. Pozostałe są opcjonalne. Jako identyfikator użyję UUID. Do tego celu wykorzystam bibliotekę, którą oczywiście dociągam za pomocą komendy w terminalu composer require ramsey/uuid. Identyfikator posiada tylko klasa klienta, jako że klasa reprezentująca pracę zaimplementowana została jako Value Object. Reszta myślę, że jest jasna – obiekty w najprostszej postaci.

<?php

declare(strict_types=1);

namespace Customer\Model;

use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;

class Customer
{
    private UuidInterface $id;

    private string $firstName;

    private string $lastName;

    private string $email;

    private ?string $phoneNumber = null;

    private ?Job $job = null;

    private \DateTimeImmutable $createdAt;

    public function __construct(
        string $firstName,
        string $lastName,
        string $email
    ) {
        $this->id = Uuid::uuid4();
        $this->firstName = $firstName;
        $this->lastName = $lastName;
        $this->email = $email;
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getId(): UuidInterface
    {
        return $this->id;
    }

    public function getIdString(): string
    {
        return $this->id->toString();
    }

    public function getFirstName(): string
    {
        return $this->firstName;
    }

    public function getLastName(): string
    {
        return $this->lastName;
    }

    public function setLastName(string $lastName): void
    {
        $this->lastName = $lastName;
    }

    public function getEmail(): string
    {
        return $this->email;
    }

    public function setEmail(string $email): void
    {
        $this->email = $email;
    }

    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function getPhoneNumber(): ?string
    {
        return $this->phoneNumber;
    }

    public function setPhoneNumber(?string $phoneNumber): void
    {
        $this->phoneNumber = $phoneNumber;
    }

    public function getJobPosition(): ?string
    {
        return $this->job?->getPosition();
    }

    public function getJobCompany(): ?string
    {
        return $this->job?->getCompany();
    }

    public function setJob(?Job $job): void
    {
        $this->job = $job;
    }
}
<?php

declare(strict_types=1);

namespace Customer\Model;

class Job
{
    private string $position;

    private string $company;

    public function __construct(string $position, string $company)
    {
        $this->position = $position;
        $this->company = $company;
    }

    public function getPosition(): string
    {
        return $this->position;
    }

    public function getCompany(): string
    {
        return $this->company;
    }
}

Przygotowanie pod zapis i odczyt – normalizacja

Interfejs do odczytu i zapisu w bazie danych opiera się o typy skalarne i tablice. Tak samo dane z API będą przychodziły w tej formie. W aplikacji jednak podstawową reprezentacją klienta będą obiekty. Ktoś powie: wystarczyłoby operować na tablicach, nie ma sensu zamieniać ich w obiekt. Możliwe, że akurat w tej aplikacji nie będzie widać tej korzyści z programowania obiektowego, ale normalnie obiekty bazują na zachowaniach. A nawet jeśli tylko opakowują struktury danych to i tak są czytelniejsze i bardziej restrykcyjne od tablic. Gdyby była mowa o bardziej skomplikowanych encjach to dużo bardziej byłoby to widać. Obiekty też są dużo łatwiejsze w utrzymaniu, walidowaniu i pilnowaniu. Tablica przyjmie wszystko. Ogólnie założenie jest takie, że nie ważne co na wejściu i co na wyjściu to w aplikacji działam na obiektach reprezentujących klienta.

Nie ma jednak możliwości zapisania obiektu w takiej postaci. Trzeba będzie mapować go do odpowiedniej formy. W tym wypadku skoro potrzebna jest tablica, proces odpowiedzialny za przekształcanie obiektu w tablicę to normalizacja. Za to proces odwrotny to denormalizacja. Przyda się więc osobny serwis, który zajmie się ową funkcjonalnością. Customer\Service\CustomerNormalizer zawiera dwie wyżej wspomniane metody normalize() i denormalize(). Klasa ta będzie dedykowana reprezentacji klienta i nie będzie zbyt uniwersalna. Dałoby się jednak zrobić ją w sposób bardziej generyczny, a nawet pokusić się o odpowiednią abstrakcję i wykorzystanie wzorca projektowego strategii. Co więcej, istnieje kilka gotowych komponentów, które gwarantują taką funkcjonalność, ale dla potrzeb tej aplikacji wystarczy własna implementacja.

W tym komponencie proces denormalizacji będzie nieco inny. Nadal jest to przekształcenie tablicy w obiekt. W tym celu wykorzystam jednak serializację i deserializację. Mam ku temu konkretne powody. Kiedy dane są wyciągane z bazy danych, nie powinny tworzyć nowego obiektu i go uzupełniać. Dlaczego? Działoby się to wówczas przez konstruktor, co oznacza tyle, że każde zapytanie o dane klienta kończyłoby się na stworzeniu nowego obiektu. W tym wypadku konstruktor zawiera generowanie identyfikatora i daty utworzenia. Do tych pól nie ma metod dostępowych i nie powinno być. Czasem obiekt może robić nawet więcej w trakcie powstawania, chociażby emitować zdarzenia.

Popularne ORM i tego rodzaju narzędzia realizują to na różne sposoby. Jednym z nich jest właśnie skorzystanie z serializacji. Innym może być mechanizm refleksji. Tak czy inaczej, mimo że stworzenie nowego obiektu i uzupełnienie go o konkretne dane jest najprostsze to nie powinno być rozwiązaniem.

<?php

declare(strict_types=1);

namespace Customer\Service;

use Customer\Model\Customer;

class CustomerNormalizer
{
    public function normalize(Customer $customer): array
    {
        return [
            'id' => $customer->getIdString(),
            'firstName' => $customer->getFirstName(),
            'lastName' => $customer->getLastName(),
            'email' => $customer->getEmail(),
            'createdAt' => $customer->getCreatedAt()?->format('Y-m-d H:i'),
            'phoneNumber' => $customer->getPhoneNumber(),
            'position' => $customer->getJobPosition(),
            'company' => $customer->getJobCompany()
        ];
    }

    public function denormalize(array $customerData): Customer
    {
        return unserialize(sprintf(
            'O:%d:"%s"%s',
            strlen(Customer::class),
            Customer::class,
            strstr(serialize($customerData), ':')
        ));
    }
}

Metody odpowiedzialnej za normalizację chyba nie trzeba tłumaczyć. To po prostu wywołanie odpowiednich metod dostępowych i zwrócenie danych w formie tablicy. Nie tak prosta sprawa jest już w denormalizacji. Jeśli wiecie jak zbudowany jest serializowany obiekt to nie powinno być problemu, żeby zrozumieć ten koncept. Zresztą wygląda on dokładnie tak jak podany format funkcji sprintf('O:%d:"%s"%s'). Metoda przyjmuje odpowiednie dane w formie tablicy, które najpierw są serializowane, a następnie ucinane o kilka niepotrzebnych danych. Na końcu są deserializowane tworząc obiekt klienta.

W tym momencie jeszcze to nie zadziała. Aby mieć kontrolę nad całym procesem budowania obiektu skorzystam z magicznej metody __unserialize(). Należy dorzucić ją do klasy Customer\Model\Customer. Po pierwsze, trzeba się upewnić, że wszystkie wymagane parametry są dostarczone oraz że mają one odpowiednią formę. Po drugie, niektóre z nich trzeba przekształcić do obiektów: identyfikator, data utworzenia i praca. Teraz, kiedy dla reprezentacji klienta zostanie wywołana funkcja unserialize() to wykona się kod metody __unserialize(). Finalna jej wersja wygląda w ten sposób.

public function __unserialize(array $customerData): void
{
    Assert::notEmpty($customerData['id'] ?? null, 'Customer id is required.');
    Assert::notEmpty($customerData['firstName'] ?? null, 'Customer firstName is required.');
    Assert::notEmpty($customerData['lastName'] ?? null, 'Customer lastName is required.');
    Assert::notEmpty($customerData['email'] ?? null, 'Customer email is required.');
    Assert::notEmpty($customerData['createdAt'] ?? null, 'Customer createdAt is required.');

    $id = Uuid::isValid($customerData['id']);
    Assert::true($id, 'Customer id has wrong format.');

    $createdAt = \DateTimeImmutable::createFromFormat('Y-m-d H:i', $customerData['createdAt']);
    Assert::isInstanceOf($createdAt, \DateTimeImmutable::class, 'Customer createdAt has wrong format.');

    $this->id = Uuid::fromString($customerData['id']);
    $this->firstName = $customerData['firstName'];
    $this->lastName = $customerData['lastName'];
    $this->email = $customerData['email'];
    $this->phoneNumber = $customerData['phoneNumber'] ?? null;
    $this->createdAt = \DateTimeImmutable::createFromFormat('Y-m-d H:i', $customerData['createdAt']);

    $this->job = null;
    if (!empty($customerData['position']) && !empty($customerData['company'])) {
        $this->job = new Job($customerData['position'], $customerData['company']);
    }
}

To dobry moment, żeby dostarczyć dwie klasy testujące ową funkcjonalność. Najpierw jednak przyda się klasa pomocnicza, która dostarczy kilka metod gwarantujących testowe dane. Będzie to CustomerTest\TestHelper\CustomerFixtureHelper, który wygląda następująco.

<?php

declare(strict_types=1);

namespace CustomerTest\TestHelper;

use Customer\Model\Customer;

class CustomerFixtureHelper
{
    public function getMinimalCustomer(): Customer
    {
        return new Customer('Jane', 'Doe', 'jane.doe@koddlo.pl');
    }

    public function getTestCustomerData(): array
    {
        return [
            'id' => 'd2f3ae68-5f62-4825-80c6-07e5d9a71c25',
            'firstName' => 'Jane',
            'lastName' => 'Doe',
            'email' => 'jane.doe@koddlo.pl',
            'createdAt' => '2020-03-02 22:20',
            'position' => 'PHP Developer',
            'company' => 'Koddlo',
            'phoneNumber' => '123456789'
        ];
    }

    public function getSerializedCustomer(array $customerData): string
    {
        return sprintf(
            'O:%d:"%s"%s',
            strlen(Customer::class),
            Customer::class,
            strstr(serialize($customerData), ':')
        );
    }

    public function getWrongCustomersData(): array
    {
        return [
            [
                'noId' => [
                    'firstName' => 'Jane',
                    'lastName' => 'Doe',
                    'email' => 'jane.doe@koddlo.pl',
                    'createdAt' => '2020-03-02 22:20',
                ]
            ],
            [
                'noFirstName' => [
                    'id' => 'd2f3ae68-5f62-4825-80c6-07e5d9a71c25',
                    'lastName' => 'Doe',
                    'email' => 'jane.doe@koddlo.pl',
                    'createdAt' => '2020-03-02 22:20',
                ]
            ],
            [
                'noLastName' => [
                    'id' => 'd2f3ae68-5f62-4825-80c6-07e5d9a71c25',
                    'firstName' => 'Jane',
                    'email' => 'jane.doe@koddlo.pl',
                    'createdAt' => '2020-03-02 22:20',
                ]
            ],
            [
                'noEmail' => [
                    'id' => 'd2f3ae68-5f62-4825-80c6-07e5d9a71c25',
                    'firstName' => 'Jane',
                    'lastName' => 'Doe',
                    'createdAt' => '2020-03-02 22:20',
                ]
            ],
            [
                'noCreatedAt' => [
                    'id' => 'd2f3ae68-5f62-4825-80c6-07e5d9a71c25',
                    'firstName' => 'Jane',
                    'lastName' => 'Doe',
                    'email' => 'jane.doe@koddlo.pl',
                ]
            ],
            [
                'wrongId' => [
                    'id' => 'wrongId',
                    'firstName' => 'Jane',
                    'lastName' => 'Doe',
                    'email' => 'jane.doe@koddlo.pl',
                    'createdAt' => '2020-03-02 22:20',
                ]
            ],
            [
                'wrongCreatedAt' => [
                    'id' => 'wrongId',
                    'firstName' => 'Jane',
                    'lastName' => 'Doe',
                    'email' => 'jane.doe@koddlo.pl',
                    'createdAt' => 'wrongCreatedAt',
                ]
            ]
        ];
    }
}

Teraz czas na klasy testujące, które wykorzystują testowane dane dostarczane przez wcześniej przygotowany kod. Zauważcie, że test dla modelu klienta powstaje dopiero w tym momencie. Akcesorów (getter/setter) nie ma sensu testować, gdyż nie posiadają w sobie żadnej skomplikowanej logiki.

<?php

declare(strict_types=1);

namespace CustomerTest\Service;

use Customer\Model\Customer;
use Customer\Model\Job;
use Customer\Service\CustomerNormalizer;
use CustomerTest\TestHelper\CustomerFixtureHelper;
use PHPUnit\Framework\TestCase;

class CustomerNormalizerTest extends TestCase
{
    public function testCanNormalizeCustomer(): void
    {
        $customerData = (new CustomerFixtureHelper())->getTestCustomerData();
        $customer = new Customer($customerData['firstName'], $customerData['lastName'], $customerData['email']);
        $customer->setPhoneNumber($customerData['phoneNumber']);
        $customer->setJob(new Job($customerData['position'], $customerData['company']));

        $normalizedData = (new CustomerNormalizer())->normalize($customer);

        $this->assertSame($customerData['firstName'], $normalizedData['firstName']);
        $this->assertSame($customerData['lastName'], $normalizedData['lastName']);
        $this->assertSame($customerData['email'], $normalizedData['email']);
        $this->assertSame($customerData['position'], $normalizedData['position']);
        $this->assertSame($customerData['company'], $normalizedData['company']);
        $this->assertSame($customerData['phoneNumber'], $normalizedData['phoneNumber']);
    }

    public function testCanDenormalizeCustomer(): void
    {
        $customerData = (new CustomerFixtureHelper())->getTestCustomerData();
        $customer = (new CustomerNormalizer())->denormalize($customerData);

        $this->assertInstanceOf(Customer::class, $customer);
        $this->assertSame($customerData['id'], $customer->getIdString());
        $this->assertSame($customerData['firstName'], $customer->getFirstName());
        $this->assertSame($customerData['lastName'], $customer->getLastName());
        $this->assertSame($customerData['email'], $customer->getEmail());
        $this->assertSame($customerData['createdAt'], $customer->getCreatedAt()?->format('Y-m-d H:i'));
        $this->assertSame($customerData['position'], $customer->getJobPosition());
        $this->assertSame($customerData['company'], $customer->getJobCompany());
        $this->assertSame($customerData['phoneNumber'], $customer->getPhoneNumber());
    }
}
<?php

declare(strict_types=1);

namespace CustomerTest\Model;

use Customer\Model\Customer;
use CustomerTest\TestHelper\CustomerFixtureHelper;
use PHPUnit\Framework\TestCase;

class CustomerTest extends TestCase
{
    public function testCanUnserializeCustomer(): void
    {
        $customerFixture = new CustomerFixtureHelper();
        $serializedCustomer = $customerFixture->getSerializedCustomer($customerFixture->getTestCustomerData());

        $this->assertInstanceOf(Customer::class, unserialize($serializedCustomer));
    }

    /** @dataProvider provideWrongCustomersData */
    public function testCannotUnserializeUsingWrongCustomerData(array $customerData): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $serializedCustomer = (new CustomerFixtureHelper())->getSerializedCustomer($customerData);

        unserialize($serializedCustomer);
    }

    public function provideWrongCustomersData(): array
    {
        return (new CustomerFixtureHelper())->getWrongCustomersData();
    }
}

Wystarczy uruchomić testy, żeby upewnić się że przygotowany mechanizm działa prawidłowo. Do utrwalenia, pobrania bądź usunięcia obiektu przyda się jego repozytorium. Będzie ono wykorzystywane wszędzie tam, gdzie działa się na obiektach. Do odczytu surowych danych, które trafią do API tworzone będą osobne klasy zapytań po to by właśnie pominąć zbędne przekształcanie.

<?php

declare(strict_types=1);

namespace Customer\Repository;

use Customer\Model\Customer;
use Ramsey\Uuid\UuidInterface;

interface CustomerRepositoryInterface
{
    public function save(Customer $customer): void;

    public function delete(Customer $customer): void;

    public function findOneById(UuidInterface $id): ?Customer;
}

Repozytorium Customer\Repository\CustomerRepository rozszerzy Customer\Repository\CustomerRepositoryInterface, więc na ten moment potrzebuje implementacji 3 metod. Pora skorzystać z klas przygotowanych wcześniej, czyli adaptera do bazy danych i normalizera do transformacji danych. Jako, że wymagane są te dwie zależności – przyda się również fabryka.

<?php

declare(strict_types=1);

namespace Customer\Repository;

use Customer\Model\Customer;
use Customer\Service\CustomerNormalizer;
use Ramsey\Uuid\UuidInterface;
use Store\Service\StoreInterface;

class CustomerRepository implements CustomerRepositoryInterface
{
    private const STORE_NAME = 'customer';

    private StoreInterface $store;

    private CustomerNormalizer $normalizer;

    public function __construct(StoreInterface $store, CustomerNormalizer $normalizer)
    {
        $this->store = $store;
        $this->normalizer = $normalizer;
    }

    public function save(Customer $customer): void
    {
        if (!$this->findOneById($customer->getId())) {
            $this->store->insertOne(self::STORE_NAME, $this->normalizer->normalize($customer));

            return;
        }

        $this->store->updateOne(self::STORE_NAME, $customer->getIdString(), $this->normalizer->normalize($customer));
    }

    public function delete(Customer $customer): void
    {
        $this->store->deleteOne(self::STORE_NAME, $customer->getIdString());
    }

    public function findOneById(UuidInterface $id): ?Customer
    {
        $customerData = $this->store->findOne(self::STORE_NAME, ['id' => $id->toString()]);

        return !empty($customerData) ? $this->normalizer->denormalize($customerData) : null;
    }
}
<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Repository\CustomerRepository;
use Customer\Repository\CustomerRepositoryInterface;
use Customer\Service\CustomerNormalizer;
use Psr\Container\ContainerInterface;
use Store\Service\StoreInterface;

class CustomerRepositoryFactory
{
    public function __invoke(ContainerInterface $container): CustomerRepositoryInterface
    {
        return new CustomerRepository(
            $container->get(StoreInterface::class),
            new CustomerNormalizer()
        );
    }
}

Teraz wystarczy jeszcze skonfigurować repozytorium w zależnościach i od tego momentu można z powodzeniem wykorzystywać je w aplikacji, co uczynię przy okazji obsługi konkretnych endpointów.

public function getDependencies(): array
{
    return [
        'aliases' => [
            Repository\CustomerRepositoryInterface::class => Repository\CustomerRepository::class
        ],
        'factories'  => [
            Repository\CustomerRepository::class => Factory\CustomerRepositoryFactory::class
        ]
    ];
}

Handler w Laminas Mezzio

Aktualnie w module istnieje przykładowy Handler. W tym momencie można go usunąć wraz z jego fabryką i testem. Chyba dobry moment, żeby wyjaśnić, czym jest Handler? Jak już wspomniałem, Handler to obok middleware jeden z bazowych konceptów na których opiera się Mezzio. To klasa, która przyjmuje żądanie, przetwarza je i zwraca odpowiedź. Można powiedzieć, że jest to odpowiednik kontrolera. W takim podejściu, odpowiednie middleware mogą żądanie nieco zmodyfikować albo w ogóle nie dopuścić go do handlera.

W Mezzio klasy handlerów są zgodne z PSR-15. Każda taka klasa implementuje więc Psr\Http\Server\RequestHandlerInterface, który wymusza metodę handle(). Owy obiekt odpowiedzialny jest za obsłużenie konkretnego żądania, inaczej niż w kontrolerach, gdzie zdarza się że definiuje się więcej metod. Zresztą zaraz będzie to widać, bo dla wszystkich CRUD-owych metod create, read, readAll, update i delete powstanie 5 klas. W klasycznym podejściu kontroler mógłby zmieścić 5 metod, odpowiedzialnych za ich obsługę. Oczywiście można spotkać też implementacje, gdzie każdy kontroler ma tylko i wyłącznie jedną metodę.

CRUD PHP

Routing już istnieje, teraz należy stworzyć podane tam klasy i rozszerzyć je o właściwe odpowiedzialności.

Read All (GET)

Na pierwszy ogień leci odczyt wszystkich klientów. Tworzę klasę Customer\Handler\ReadAllCustomerHandler oraz jej fabrykę Customer\Factory\ReadAllCustomerHandlerFactory. Teraz nowe klasy trzeba uwzględnić w zależnościach w Customer\ConfigProvider.

<?php

declare(strict_types=1);

namespace Customer\Handler;

use Customer\Query\CountCustomerQuery;
use Customer\Query\GetAllCustomer;
use Customer\DTO\Pagination;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

class ReadAllCustomerHandler implements RequestHandlerInterface
{
    private CountCustomerQuery $countCustomerQuery;

    private GetAllCustomer $getAllCustomer;

    public function __construct(CountCustomerQuery $countCustomerQuery, GetAllCustomer $getAllCustomer)
    {
        $this->countCustomerQuery = $countCustomerQuery;
        $this->getAllCustomer = $getAllCustomer;
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $pagination = new Pagination();
        $pagination->start = (int) ($request->getQueryParams()['start'] ?? $pagination->start);
        $pagination->limit = (int) ($request->getQueryParams()['limit'] ?? $pagination->limit);

        return new JsonResponse([
            'start' => $pagination->start,
            'limit' => $pagination->limit,
            'count' => $this->countCustomerQuery->execute($pagination),
            'customers' => $this->getAllCustomer->execute($pagination)
        ]);
    }
}
<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Handler\ReadAllCustomerHandler;
use Customer\Query\CountCustomerQuery;
use Customer\Query\GetAllCustomer;
use Psr\Container\ContainerInterface;
use Psr\Http\Server\RequestHandlerInterface;

class ReadAllCustomerHandlerFactory
{
    public function __invoke(ContainerInterface $container): RequestHandlerInterface
    {
        return new ReadAllCustomerHandler(
            $container->get(CountCustomerQuery::class),
            $container->get(GetAllCustomer::class)
        );
    }
}

Tak prezentują się owe klasy. Oczywiście funkcjonalność jeszcze nie działa, bo brakuje kilku klas do których zostały wydelegowane pewne odpowiedzialności. Dwa obiekty Query wymagane przez konstruktor, które dostarczą surowe dane z bazy danych. W celu odczytu bez modyfikacji stanu, nie ma sensu wyciągać ich jako obiekt po to by od razu przekształcić je z powrotem do tablicy. Tutaj inaczej, niż w przypadku repozytorium – każde zapytanie to osobna klasa.

Koncept Query możecie znać z popularnego podejścia CQRS, gdzie właśnie Q to Query. Tamte obiekty bardziej przypominają DTO. Lecą na szynę, która dalej deleguję ich obsługę do odpowiedniej klasy. Obiekty zapytań w tej aplikacji same potrafią się wywołać – co bardziej przypomina implementację wzorca polecenie.

<?php

declare(strict_types=1);

namespace Customer\Query;

use Customer\DTO\Pagination;
use Store\Service\StoreInterface;

class CountCustomerQuery implements QueryMarkerInterface
{
    private const STORE_NAME = 'customer';

    private StoreInterface $store;

    public function __construct(StoreInterface $store)
    {
        $this->store = $store;
    }

    public function execute(?Pagination $pagination = null): int
    {
        $options = [];
        if ($pagination !== null) {
            $options = [
                'skip' => $pagination->start,
                'limit' => $pagination->limit
            ];
        }

        return $this->store->count(self::STORE_NAME, [], $options);
    }
}
<?php

declare(strict_types=1);

namespace Customer\Query;

use Customer\DTO\Pagination;
use Store\Service\StoreInterface;

class GetAllCustomer implements QueryMarkerInterface
{
    private const STORE_NAME = 'customer';

    private StoreInterface $store;

    public function __construct(StoreInterface $store)
    {
        $this->store = $store;
    }

    public function execute(?Pagination $pagination = null): array
    {
        $options = [];
        if ($pagination !== null) {
            $options = [
                'skip' => $pagination->start,
                'limit' => $pagination->limit
            ];
        }

        return $this->store->findAll(self::STORE_NAME, [], $options);
    }
}

Wszystkie tego rodzaju klasy będą implementowały Customer\Query\QueryMarkerInterface, który nie wymusza na nich żadnych metod, a jedynie istnieje po to by oznaczyć je jako grupę klas. Takie rozwiązanie przyda się do fabryki abstrakcyjnej, którą prezentuję poniżej. Dla wszystkich zapytań fabryka wyglądałaby identycznie i dostarczałaby tylko i wyłącznie klasę do komunikacji z bazą danych. Można więc skorzystać z jednej fabryki, która obsłuży je wszystkie.

<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Query\QueryMarkerInterface;
use Interop\Container\ContainerInterface;
use Laminas\ServiceManager\Factory\AbstractFactoryInterface;
use Store\Service\StoreInterface;

class QueryAbstractFactory implements AbstractFactoryInterface
{
    public function canCreate(ContainerInterface $container, $requestedName): bool
    {
        return str_contains($requestedName, 'Customer\\Query')
            && (new \ReflectionClass($requestedName))->implementsInterface(QueryMarkerInterface::class);
    }

    public function __invoke(ContainerInterface $container, $requestedName, array $options = null): QueryMarkerInterface
    {
        return new $requestedName(
            $container->get(StoreInterface::class)
        );
    }
}

Sam koncept abstrakcyjnej fabryki w takim wydaniu jest mocno powiązany z service-managerem, więc jeśli wybierzecie inny kontener DI to najprawdopodobniej musicie obsłużyć to inaczej. Najprościej, wystarczy dla każdej klasy stworzyć własną dedykowaną fabrykę.

Fabryka abstrakcyjna zanim utworzy obiekt, musi sprawdzić czy zachodzą odpowiednie warunki. W tym wypadku będzie to zweryfikowanie przestrzeni nazw i właśnie, czy klasa implementuje odpowiedni interfejs. Skonfigurowanie jej w zależnościach wygląda jak w poniższym fragmencie kodu.

public function getDependencies(): array
    {
        return [
            'abstract_factories' => [
                Factory\QueryAbstractFactory::class
            ]
        ];
    }

W kilku miejscach przewinęła się już reprezentacja Customer\DTO\Pagination. Do wyciągnięcia wszystkich klientów została dodana prosta paginacja umożliwiająca ograniczenie liczby zwracanych rekordów. Parametry dotyczące paginacji zawiera właśnie ten obiekt, który ma domyślne wartości, gdyby nie zostały one dostarczone w zapytaniu. Poniżej kod klasy – prostszej się nie da stworzyć.

<?php

declare(strict_types=1);

namespace Customer\DTO;

class Pagination
{
    public int $start = 0;

    public int $limit = 10;
}

W tym momencie powinno już działać. Zostaje tylko wywołać odpowiednie żądanie ustawiając start=0 i limit=2 i otrzymać odpowiedź z pierwszymi dwoma klientami. Przykładowe mogą wyglądać w ten sposób.

GET /api/v1/customer?start=0&limit=2 HTTP/1.1
Host: customer-api.local
Authorization: Basic a29kZGxvOmtvZGRsbw==
{
    "start": 0,
    "limit": 2,
    "count": 2,
    "customers": [
        {
            "firstName": "Krystian",
            "lastName": "Żądło",
            "email": "kontakt@koddlo.pl",
            "createdAt": "2021-03-04 08:04",
            "phoneNumber": null,
            "position": "PHP Developer",
            "company": "Koddlo",
            "id": "2d43b375-5e7a-4bf6-bcfe-0d03f50aadb4"
        },
        {
            "firstName": "Jane",
            "lastName": "Doe",
            "email": "jane.doe@koddlo.pl",
            "createdAt": "2021-03-04 08:12",
            "phoneNumber": "123456789",
            "position": "Marketing",
            "company": "Koddlo",
            "id": "8763a9db-3cda-41a8-86d1-5f8472205d2a"
        }
    ]
}

Wszystkie testy dla handlerów będą podobne. Mockowane są żądanie oraz wszystkie zależności testowanej klasy. Następnie jest ona tworzona, uruchamiana metoda handle() i sprawdzenie rezultatu. Przykładowo dla pierwszego handlera test wygląda jak poniżej.

<?php

declare(strict_types=1);

namespace CustomerTest\Handler;

use Customer\DTO\Pagination;
use Customer\Handler\ReadAllCustomerHandler;
use Customer\Query\CountCustomerQuery;
use Customer\Query\GetAllCustomer;
use Laminas\Diactoros\Response\JsonResponse;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;

class ReadAllCustomerHandlerTest extends TestCase
{
    use ProphecyTrait;

    public function testCanHandleAllCustomersRead(): void
    {
        $pagination = new Pagination();

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

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

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

        $countCustomerQuery
            ->execute($pagination)
            ->willReturn(0);

        $getAllCustomer
            ->execute($pagination)
            ->willReturn([]);

        $readAllCustomerHandler = new ReadAllCustomerHandler($countCustomerQuery->reveal(), $getAllCustomer->reveal());

        $result = $readAllCustomerHandler->handle($request->reveal());

        $this->assertInstanceOf(JsonResponse::class, $result);
        $this->assertSame(200, $result->getStatusCode());
    }
}

Read (GET)

Kolejny handler obsłuży pobranie jednego konkretnego klienta. W tym celu potrzebna będzie kolejna klasa reprezentująca zapytanie o dane klienta z podanym identyfikatorem. Ukazuje ją kod poniżej.

<?php

declare(strict_types=1);

namespace Customer\Query;

use Ramsey\Uuid\UuidInterface;
use Store\Service\StoreInterface;

class GetOneCustomerById implements QueryMarkerInterface
{
    private const STORE_NAME = 'customer';

    private StoreInterface $store;

    public function __construct(StoreInterface $store)
    {
        $this->store = $store;
    }

    public function execute(UuidInterface $id): ?array
    {
        return $this->store->findOne(self::STORE_NAME, ['id' => $id->toString()]);
    }
}

Fabryki dla tej klasy oczywiście nie potrzeba, bo wszystkim zajmie się wcześniej przygotowana fabryka abstrakcyjna. Finalnie Customer\Handler\ReadCustomerHandler oraz jego klas wytwórcza wyglądają następująco:

<?php

declare(strict_types=1);

namespace Customer\Handler;

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

class ReadCustomerHandler implements RequestHandlerInterface
{
    private GetOneCustomerById $getOneCustomerById;

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

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

        return new JsonResponse(
            $this->getOneCustomerById->execute(Uuid::fromString($customerId))
        );
    }
}
<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Handler\ReadCustomerHandler;
use Customer\Query\GetOneCustomerById;
use Psr\Container\ContainerInterface;
use Psr\Http\Server\RequestHandlerInterface;

class ReadCustomerHandlerFactory
{
    public function __invoke(ContainerInterface $container): RequestHandlerInterface
    {
        return new ReadCustomerHandler(
            $container->get(GetOneCustomerById::class)
        );
    }
}

Przykładowe żądanie HTTP o klienta z identyfikatorem 2d43b375-5e7a-4bf6-bcfe-0d03f50aadb4 oraz odpowiedź wyglądają w ten sposób.

GET /api/v1/customer/2d43b375-5e7a-4bf6-bcfe-0d03f50aadb4 HTTP/1.1
Host: customer-api.local
Authorization: Basic a29kZGxvOmtvZGRsbw==
{
    "firstName": "Krystian",
    "lastName": "Żądło",
    "email": "kontakt@koddlo.pl",
    "createdAt": "2021-03-04 08:04",
    "phoneNumber": null,
    "position": "PHP Developer",
    "company": "Koddlo",
    "id": "2d43b375-5e7a-4bf6-bcfe-0d03f50aadb4"
}

Create (POST)

Kolejne trzy operacje opierają się już o obiekty klientów i ich modyfikacje. Najpierw tworzenie klienta – to też łatwa operacja, ale tym razem przyda się repozytorium.

<?php

declare(strict_types=1);

namespace Customer\Handler;

use Customer\Factory\CustomerFactory;
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 CreateCustomerHandler implements RequestHandlerInterface
{
    private CustomerRepositoryInterface $customerRepository;

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

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $customer = (new CustomerFactory())->fromArray($request->getParsedBody());

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

        return new JsonResponse([
            'message' => 'Customer created'
        ], 201);
    }
}

Jak widać obiekt tworzony jest przez fabrykę, która przyjmuje dane w postaci tablicy i z tego tworzy nowego klienta. W tym miejscu tworzenie przez new i wykorzystanie konstruktora jest na miejscu. Zazwyczaj fabryki nie ma sensu wstrzykiwać jako zależność, bo jest to prosty obiekt, który w testach również nie będzie mockowany.

<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Model\Customer;
use Customer\Model\Job;
use Webmozart\Assert\Assert;

class CustomerFactory
{
    public function fromArray(array $customerData): Customer
    {
        Assert::notEmpty($customerData['firstName'] ?? null, 'Customer firstName is required.');
        Assert::notEmpty($customerData['lastName'] ?? null, 'Customer lastName is required.');
        Assert::notEmpty($customerData['email'] ?? null, 'Customer email is required.');

        $customer = new Customer($customerData['firstName'], $customerData['lastName'], $customerData['email']);
        $customer->setPhoneNumber($customerData['phoneNumber'] ?? null);
        if (!empty($customerData['position']) && !empty($customerData['company'])) {
            $customer->setJob(new Job($customerData['position'], $customerData['company']));
        }

        return $customer;
    }
}

Aby w ogóle móc pobrać dane w postaci tablicy z żądania trzeba dorzucić jeden middleware jako pipeline, który zapewni taką transformację danych. Zaprezentowano to w poniższym fragmencie kodu jako oznaczona linia. Domyślnie można pobrać zawartość ciała żądania jako JSON, co też może być pożądane.

<?php

declare(strict_types=1);

use Auth\Middleware\IpAccessControlMiddleware;
use Laminas\Stratigility\Middleware\ErrorHandler;
use Mezzio\Application;
use Mezzio\Authentication\AuthenticationMiddleware;
use Mezzio\Handler\NotFoundHandler;
use Mezzio\Helper\BodyParams\BodyParamsMiddleware;
use Mezzio\Helper\ServerUrlMiddleware;
use Mezzio\Helper\UrlHelperMiddleware;
use Mezzio\MiddlewareFactory;
use Mezzio\Router\Middleware\DispatchMiddleware;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Mezzio\Router\Middleware\MethodNotAllowedMiddleware;
use Mezzio\Router\Middleware\RouteMiddleware;
use Psr\Container\ContainerInterface;

return function (Application $app, MiddlewareFactory $factory, ContainerInterface $container): void {
    $app->pipe(ErrorHandler::class);

    $app->pipe(ServerUrlMiddleware::class);
    $app->pipe('/api/v1', AuthenticationMiddleware::class);
    $app->pipe('/api/v1', IpAccessControlMiddleware::class);
    $app->pipe(RouteMiddleware::class);

    $app->pipe(ImplicitHeadMiddleware::class);
    $app->pipe(ImplicitOptionsMiddleware::class);
    $app->pipe(MethodNotAllowedMiddleware::class);

    $app->pipe(BodyParamsMiddleware::class);
    $app->pipe(UrlHelperMiddleware::class);

    $app->pipe(DispatchMiddleware::class);

    $app->pipe(NotFoundHandler::class);
};
<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Handler\CreateCustomerHandler;
use Customer\Repository\CustomerRepositoryInterface;
use Psr\Container\ContainerInterface;
use Psr\Http\Server\RequestHandlerInterface;

class CreateCustomerHandlerFactory
{
    public function __invoke(ContainerInterface $container): RequestHandlerInterface
    {
        return new CreateCustomerHandler(
            $container->get(CustomerRepositoryInterface::class)
        );
    }
}

Oczywiście jeszcze fabryka dla tego handlera i całość powinna działać. Standardowo możliwe żądanie tworzące nowego klienta i odpowiedź zwracana w razie powodzenia.

POST /api/v1/customer HTTP/1.1
Host: customer-api.local
Authorization: Basic a29kZGxvOmtvZGRsbw==
Content-Type: application/json
Content-Length: 171

{
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane.doe@koddlo.pl",
    "phoneNumber": "123456789",
    "position": "Marketing",
    "company": "Koddlo"
}
{
    "message": "Customer created"
}

Update (PATCH)

Teraz edycja konkretnego klienta. Skorzystam z metody PATCH, która różni się od PUT tym, że obiekt może być aktualizowany częściowo. Jest to o tyle wygodna opcja, że nie każdy system musi przechowywać wszystkie dane o kliencie. Niektóre będą potrzebowały tylko konkretnych danych i takie też są w stanie wysłać przy ich edycji. PUT wymagałby zawsze przesłania pełnego obiektu, co blokowałoby taką możliwość.

<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Handler\UpdateCustomerHandler;
use Customer\Repository\CustomerRepositoryInterface;
use Customer\Service\CustomerUpdater;
use Psr\Container\ContainerInterface;
use Psr\Http\Server\RequestHandlerInterface;

class UpdateCustomerHandlerFactory
{
    public function __invoke(ContainerInterface $container): RequestHandlerInterface
    {
        return new UpdateCustomerHandler(
            $container->get(CustomerRepositoryInterface::class),
            new CustomerUpdater()
        );
    }
}
<?php

declare(strict_types=1);

namespace Customer\Handler;

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

class UpdateCustomerHandler implements RequestHandlerInterface
{
    private CustomerRepositoryInterface $customerRepository;

    private CustomerUpdater $customerUpdater;

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

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

        $this->customerUpdater->update($customer, $request->getParsedBody());

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

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

Powyższa klasa wygląda podobnie jak ta odpowiedzialna za utworzenie klienta. Zamiast go tworzyć, najpierw go wyciąga, przekazuje do klasy odpowiedzialnej za jego aktualizację i na finalnie zapisuje zmiany za pomocą repozytorium. Brakuje więc klasy Customer\Service\CustomerUpdater. Aktualizuje ona konkretne pola za pomocą setterów. Lepiej zrobić to w ten sposób, niż zamieszczać taką logikę bezpośrednio w handlerze.

<?php

declare(strict_types=1);

namespace Customer\Service;

use Customer\Model\Customer;
use Customer\Model\Job;

class CustomerUpdater
{
    public function update(Customer $customer, array $changes): void
    {
        if (!empty($changes['lastName'])) {
            $customer->setLastName($changes['lastName']);
        }

        if (!empty($changes['email'])) {
            $customer->setEmail($changes['email']);
        }

        if (array_key_exists('phoneNumber', $changes)) {
            $customer->setPhoneNumber($changes['phoneNumber']);
        }

        $jobPosition = $changes['position'] ?? $customer->getJobPosition() ?? null;
        $jobCompany = $changes['company'] ?? $customer->getJobCompany() ?? null;
        if (!empty($jobPosition) && !empty($jobCompany)) {
            $customer->setJob(new Job($jobPosition, $jobCompany));
        }
    }
}

Ta klasa nie jest rejestrowana jako serwis w kontenerze i też nie ma fabryki. Nie potrzebuje zależności, więc może być stworzona przez new. Jeśli w przyszłości zostanie o nie rozszerzona, być może trzeba będzie to zmienić. Nie wszystkie obiekty trzeba wyciągać z kontenera. Ważne w tym wszystkim jest to, by nie powiązać danej klasy sztywno z inną, bo jest to ciężkie w utrzymaniu i testowaniu.

W tym momencie aktualizacja klienta już działa, a przykładowe żądanie, które aktualizuje adres e-mail i numer telefonu klienta o identyfikatorze 8763a9db-3cda-41a8-86d1-5f8472205d2a oraz odpowiedź prezentują się następująco.

PATCH /api/v1/customer/8763a9db-3cda-41a8-86d1-5f8472205d2a HTTP/1.1
Host: customer-api.local
Authorization: Basic a29kZGxvOmtvZGRsbw==
Content-Type: application/json
Content-Length: 59

{
    "email": "janed@koddlo.pl",
    "phoneNumber": null
}
{
    "message": "Customer updated"
}

Delete (DELETE)

Ostatnia z liter akronimu odpowiada za usuwanie. Tutaj również obiekt jest wyciągany, a następnie przekazywany do repozytorium w celu usunięcia. Fabryka identyczna jak Customer\Factory\CreateCustomerHandlerFactory.

<?php

declare(strict_types=1);

namespace Customer\Factory;

use Customer\Handler\DeleteCustomerHandler;
use Customer\Repository\CustomerRepositoryInterface;
use Psr\Container\ContainerInterface;
use Psr\Http\Server\RequestHandlerInterface;

class DeleteCustomerHandlerFactory
{
    public function __invoke(ContainerInterface $container): RequestHandlerInterface
    {
        return new DeleteCustomerHandler(
            $container->get(CustomerRepositoryInterface::class)
        );
    }
}
<?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;
use Ramsey\Uuid\Uuid;

class DeleteCustomerHandler implements RequestHandlerInterface
{
    private CustomerRepositoryInterface $customerRepository;

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

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

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

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

Przykładowe żądanie usuwające klienta o identyfikatorze 8763a9db-3cda-41a8-86d1-5f8472205d2a oraz odpowiedź serwera mogą wyglądać tak.

DELETE /api/v1/customer/8763a9db-3cda-41a8-86d1-5f8472205d2a HTTP/1.1
Host: customer-api.local
Authorization: Basic a29kZGxvOmtvZGRsbw==
{
    "message": "Customer deleted"
}

Wszystkie handlery po ich stworzeniu trzeba też oczywiście zarejestrować jako zależności, ale to mam nadzieję, ze nie sprawia już problemu. Poniżej prezentuję finalny kształt klasy ConfigProvider dla modułu Customer.

<?php

declare(strict_types=1);

namespace Customer;

class ConfigProvider
{
    public function __invoke(): array
    {
        return [
            'dependencies' => $this->getDependencies()
        ];
    }

    public function getDependencies(): array
    {
        return [
            'aliases' => [
                Repository\CustomerRepositoryInterface::class => Repository\CustomerRepository::class
            ],
            'factories'  => [
                Handler\CreateCustomerHandler::class => Factory\CreateCustomerHandlerFactory::class,
                Handler\ReadCustomerHandler::class => Factory\ReadCustomerHandlerFactory::class,
                Handler\ReadAllCustomerHandler::class => Factory\ReadAllCustomerHandlerFactory::class,
                Handler\UpdateCustomerHandler::class => Factory\UpdateCustomerHandlerFactory::class,
                Handler\DeleteCustomerHandler::class => Factory\DeleteCustomerHandlerFactory::class,
                Repository\CustomerRepository::class => Factory\CustomerRepositoryFactory::class
            ],
            'abstract_factories' => [
                Factory\QueryAbstractFactory::class
            ]
        ];
    }
}

Tak prezentuje się prawie cały kod odpowiedzialny za obsługę wszystkich endpointów związanych z zasobem klienta. Oczywiście na razie obsługa jest podstawowa. Brak chociażby walidacji danych zewnętrznych i bardzo łatwo można spowodować błąd w aplikacji. A również błędy w systemie nie mają odpowiedniej obsługi. Spokojnie, wszystko zostało przygotowane tak, że w kolejnych wpisach bardzo łatwo będzie takie funkcje zapewnić.

Podsumowanie bazowej obsługi API

Jako, że ten wpis jest dokładnie tym co miało zostać stworzone w tej serii to jest najdłuższy i zawiera najwięcej kodu. Wszystkie wcześniejsze wpisy pozwoliły przygotować grunt pod implementację tegoż rozwiązania. Możliwe, że aplikacje typu CRUD da się pisać produkując mniej kodu. Uważam, że warto jednak zadbać o odpowiednią jakość nawet dla takich aplikacji. Można dzięki temu łatwo ją utrzymywać i rozwijać, a narzut czasowy nie jest duży. Naturalnie, nie chodzi o to by bezpodstawnie wprowadzać dodatkową złożoność.

Kod dostępny w repozytorium jako gałąź step04.

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.