intellect-web-development/symfony-presentation-bundle

Package of tools for working with Command and Query(including a search engine with filtering, sorting and pagination)

0.4.1 2023-08-04 17:10 UTC

README

Описание:

Данный пакет предназначен для удобной работы с уровнем представления symfony-приложений.

Назначение:

  • Валидация входных аргументов к методам API
  • Генерирование Swagger-документации на основе OpenApi-совместимых DTO
  • Сортировка, фильтрация и пагинация по сущностям доктрины
  • Получение ресурса/агрегата на основе сущностей доктрины

Пример использования фильтрации:

Пример строки запроса:

GET /clients?filter[emails.email][like]=26d@&sort=-createdAt,updatedAt&page[number]=1&page[size]=20&filter[userId][eq]=ccf92b7a-8e05-4f4b-9f0a-e4360dbacb23&filter[name.translations.last][eq]=Tesla&lang=ru

Сортировка:

Описание:

По умолчанию сортировка задается параметром "sort". Направление сортировки задается опциональным знаком '-' перед названием свойства, по которому предполагается сортировка. Если знак '-' присутствует, то сортировка по этому полю ведется с модификатором DESC, иначе - ASC. Допускается сортировка по нескольким полям агрегата. Для этого необходимо написать несколько полей, разделив их символом ','. Чем раньше было указано поле, тем больший "вес" оно имеет при выборке.

Пример:

sort='-createdAt,updatedAt'

Пагинация:

По умолчанию пагинация задается параметром "page". Параметр имеет два поля - number и size.

  • "number" указывает на номер страницы, которую запрашивает клиент. По умолчанию: 1
  • "size" указывает размер страницы(сколько агрегатов должно быть отображено). По умолчанию: 20

Описание:

page[number]='1'
page[size]='20'

Фильтрация:

Описание:

Операторы поиска:

Название Допустимые значения Пример Описание
NOT_IN 'not-in' filter[status][not-in][]='blocked' Свойство не содержит ни одно из указанных значений
IN 'in' filter[status][in][]='active' Свойство содержит одно из указанных значений
RANGE 'range' filter[rating][range]='17,42' Свойство находится в выбранном указанном диапазоне
IS_NULL 'is-null' filter[gender][is-null] Свойство равно null
NOT_NULL 'not-null' filter[name][not-null] Свойство не равно null
LESS_THAN 'less-than', '<', 'lt' filter[rating][<]='94' Свойство меньше указанного значения
GREATER_THAN 'greater-than', '>', 'gt' filter[rating][>]='42' Свойство больше указанного значения
LESS_OR_EQUALS 'less-or-equals', '<=', 'lte' filter[rating][<=]='15' Свойство меньше или равно указанному значению
GREATER_OR_EQUALS 'greater-or-equals', '>=', 'gte' filter[rating][>=]='97' Свойство больше или равно указанному значению
LIKE 'like' filter[email][like]='26d@' Свойство содержит часть указанного значения
NOT_LIKE 'not-like' filter[email][not-like]='27d@' Свойство не содержит часть указанного значения
EQUALS 'equals', '=', 'eq' filter[userId][eq]='ccf92b7a-8e05-4f4b-9f0a-e4360dbacb23' Свойство эквивалентно указанному значению
NOT_EQUALS 'not-equals', '!=', '<>', 'neq' filter[userId][neq]='aaf92b7a-8e05-4f4b-9f0a-e4360dbacb23' Свойство не эквивалентно указанному значению

Пример:

filter[userId][eq]='ccf92b7a-8e05-4f4b-9f0a-e4360dbacb23'
filter[name.translations.last][eq]='Tesla'
filter[emails.email][like]='26d@'
filter[userId][eq]='ccf92b7a-8e05-4f4b-9f0a-e4360dbacb23'
filter[name.translations.last][eq]='Tesla'
filter[emails.email][in][]='0791d11b6a952a3804e7cb8a220d0a9b@mail.ru'
filter[emails.email][in][]='0891d11b6a952a3804e7cb8a220d0a9b@mail.ru'

Примеры кода:

Query:

Определение

Query - запрос на получение текущего состояния сущности(ресурса/агрегата), без изменения его состояния.

Aggregate:

Запрос на получение данных агрегата.

Пример Read-action:

<?php

declare(strict_types=1);

namespace App\Http\User\Read;

use App\Entity\User;
use App\Http\User\CommonOutputContract;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security;
use OpenApi\Annotations as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use IWD\Symfony\PresentationBundle\Dto\Input\OutputFormat;
use IWD\Symfony\PresentationBundle\Dto\Output\ApiFormatter;
use IWD\Symfony\PresentationBundle\Service\Presenter;
use IWD\Symfony\PresentationBundle\Service\QueryBus\Aggregate\Bus;
use IWD\Symfony\PresentationBundle\Service\QueryBus\Aggregate\Query;

class Action
{
    /**
     * @OA\Tag(name="User")
     * @OA\Response(
     *     response=200,
     *     description="Read User",
     *     @OA\JsonContent(
     *          allOf={
     *              @OA\Schema(ref=@Model(type=ApiFormatter::class)),
     *              @OA\Schema(type="object",
     *                  @OA\Property(
     *                      property="data",
     *                      ref=@Model(type=CommonOutputContract::class)
     *                  ),
     *                  @OA\Property(
     *                      property="status",
     *                      example="200"
     *                 )
     *             )
     *         }
     *     )
     * )
     * @OA\Response(
     *     response=400,
     *     description="Bad Request"
     * ),
     * @OA\Response(
     *     response=401,
     *     description="Unauthenticated",
     * ),
     * @OA\Response(
     *     response=403,
     *     description="Forbidden"
     * ),
     * @OA\Response(
     *     response=404,
     *     description="Resource Not Found"
     * )
     * @Security(name="Bearer")
     */
    #[Route(
        data: '/users/{id}.{_format}',
        name: 'users.read',
        defaults: ['_format' => 'json'],
        methods: ['GET']
    )]
    public function read(string $id, Bus $bus, OutputFormat $outputFormat, Presenter $presenter): Response
    {
        $query = new Query(
            aggregateId: $id,
            targetEntityClass: User::class
        );

        /** @var User $user */
        $user = $bus->query($query);

        return $presenter->present(
            data: ApiFormatter::prepare(
                CommonOutputContract::create($user)
            ),
            outputFormat: $outputFormat
        );
    }
}

Пример Search-action:

<?php

declare(strict_types=1);

namespace App\Http\User\Search;

use App\Entity\User;
use App\Http\User\CommonOutputContract;
use IWD\Symfony\PresentationBundle\Service\Presenter;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security;
use OpenApi\Annotations as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use IWD\Symfony\PresentationBundle\Dto\Input\OutputFormat;
use IWD\Symfony\PresentationBundle\Dto\Input\SearchQuery;
use IWD\Symfony\PresentationBundle\Dto\Output\ApiFormatter;
use IWD\Symfony\PresentationBundle\Dto\Output\OutputPagination;
use IWD\Symfony\PresentationBundle\Service\QueryBus\Search\Bus;
use IWD\Symfony\PresentationBundle\Service\QueryBus\Search\Query;

class Action
{
    /**
     * @OA\Tag(name="User")
     * @OA\Get(
     *     @OA\Parameter(
     *          name="searchQuery",
     *          in="query",
     *          required=false,
     *          @OA\Schema(
     *              ref=@Model(type=QueryParams::class)
     *          ),
     *     )
     * )
     * @OA\Response(
     *     response=200,
     *     description="Search by Users",
     *     @OA\JsonContent(
     *          allOf={
     *              @OA\Schema(ref=@Model(type=ApiFormatter::class)),
     *              @OA\Schema(
     *                  type="object",
     *                  @OA\Property(
     *                      property="data",
     *                      type="object",
     *                      @OA\Property(
     *                          property="data",
     *                          ref=@Model(type=CommonOutputContract::class),
     *                          type="object"
     *                      ),
     *                      @OA\Property(
     *                          property="pagination",
     *                          ref=@Model(type=OutputPagination::class),
     *                          type="object"
     *                      )
     *                  ),
     *                  @OA\Property(
     *                      property="status",
     *                      example="200"
     *                 )
     *             )
     *         }
     *     )
     * )
     * @OA\Response(
     *     response=400,
     *     description="Bad Request"
     * ),
     * @OA\Response(
     *     response=401,
     *     description="Unauthenticated",
     * ),
     * @OA\Response(
     *     response=403,
     *     description="Forbidden"
     * ),
     * @OA\Response(
     *     response=404,
     *     description="Resource Not Found"
     * )
     * @Security(name="Bearer")
     */
    #[Route(
        data: '/users.{_format}',
        name: 'users.search',
        defaults: ['_format' => 'json'],
        methods: ['GET']
    )]
    public function search(
        Bus          $bus,
        SearchQuery  $searchQuery,
        OutputFormat $outputFormat,
        Presenter    $presenter
    ): Response {
        $query = new Query(
            targetEntityClass: User::class,
            pagination: $searchQuery->pagination,
            filters: $searchQuery->filters,
            sorts: $searchQuery->sorts
        );

        $searchResult = $bus->query($query);
        return $presenter->present(
            data: ApiFormatter::prepare([
                'data' => array_map(static function (User $user) {
                    return CommonOutputContract::create($user);
                }, $searchResult->entities),
                'pagination' => $searchResult->pagination
            ]),
            outputFormat: $outputFormat
        );
    }
}

Пример SearchQueryParams:

<?php

declare(strict_types=1);

namespace App\Http\User\Search;

use OpenApi\Annotations as OA;
use IWD\Symfony\PresentationBundle\Dto\Input\Filters;
use IWD\Symfony\PresentationBundle\Dto\Input\SearchQuery;

class QueryParams extends SearchQuery
{
    /**
     * @OA\Property(
     *     property="filter",
     *     type="object",
     *     example={
     *         "id": {"eq": "ab4ac777-e054-45ec-b997-b69062917d10"},
     *         "createdAt": {"range": "2022-02-22 12:00:00,2022-02-22 14:00:00"},
     *         "updatedAt": {"range": "2022-02-22 12:00:00,2022-02-22 14:00:00"},
     *         "email": {"eq": "user@dev.ru"},
     *         "status": {"eq": "active"}
     *     }
     * )
     */
    public Filters $filters;
}

Пример Command-action:

<?php

declare(strict_types=1);

namespace App\Http\User\Create;

use App\Http\User\CommonOutputContract;
use App\Entity\User\UseCase\Create\Handler;
use IWD\Symfony\PresentationBundle\Service\Presenter;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security;
use OpenApi\Annotations as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use IWD\Symfony\PresentationBundle\Dto\Input\OutputFormat;
use IWD\Symfony\PresentationBundle\Dto\Output\ApiFormatter;

class Action
{
    /**
     * @OA\Tag(name="Auth.User")
     * @OA\Post(
     *     @OA\RequestBody(
     *         @OA\MediaType(
     *             mediaType="application/json",
     *             @OA\Schema(
     *                 ref=@Model(type=InputContract::class)
     *             )
     *         )
     *     )
     * )
     * @OA\Response(
     *     response=200,
     *     description="Create User",
     *     @OA\JsonContent(
     *          allOf={
     *              @OA\Schema(ref=@Model(type=ApiFormatter::class)),
     *              @OA\Schema(type="object",
     *                  @OA\Property(
     *                      property="data",
     *                      ref=@Model(type=CommonOutputContract::class)
     *                  ),
     *                  @OA\Property(
     *                      property="status",
     *                      example="200"
     *                 )
     *             )
     *         }
     *     )
     * )
     * @OA\Response(
     *     response=400,
     *     description="Bad Request"
     * ),
     * @OA\Response(
     *     response=401,
     *     description="Unauthenticated",
     * ),
     * @OA\Response(
     *     response=403,
     *     description="Forbidden"
     * ),
     * @OA\Response(
     *     response=404,
     *     description="Resource Not Found"
     * )
     * @Security(name="Bearer")
     */
    #[Route(
        data: '/users/create.{_format}',
        name: 'users.create',
        defaults: ['_format' => 'json'],
        methods: ['POST']
    )]
    public function create(
        OutputFormat $outputFormat,
        InputContract $contract,
        Handler $handler,
        Presenter $presenter
    ): Response {
        $user = $handler->handle(
            $contract->createCommand()
        );

        return $presenter->present(
            data: ApiFormatter::prepare(
                data: CommonOutputContract::create($user),
                messages: ['User created']
            ),
            outputFormat: $outputFormat
        );
    }
}

Пример InputContract:

<?php

declare(strict_types=1);

namespace App\Http\User\Create;

use App\Entity\User\ValueObject\Email as UserEmail;
use App\Entity\User\UseCase\Create\Command;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotNull;
use IWD\Symfony\PresentationBundle\Interfaces\InputContractInterface;

class InputContract implements InputContractInterface
{
    #[NotNull]
    #[Length(min: 3, max: 255)]
    public string $password;

    #[NotNull]
    #[Email]
    #[Length(max: 255)]
    public string $email;

    public function createCommand(): Command
    {
        return new Command(
            password: $this->password,
            email: new UserEmail($this->email)
        );
    }
}

Пример OutputContract:

<?php

declare(strict_types=1);

namespace App\Http\Contract\User;

use App\Entity\User;
use DateTimeInterface;

class CommonOutputContract
{
    public string $id;
    public string $createdAt;
    public string $updatedAt;
    public string $email;
    public string $status;
    public string $role;

    public static function create(User $user): self
    {
        $contract = new self();
        $contract->id = $user->getId()->getValue();
        $contract->createdAt = $user->getCreatedAt()->format(DateTimeInterface::ATOM);
        $contract->updatedAt = $user->getUpdatedAt()->format(DateTimeInterface::ATOM);
        $contract->email = $user->getEmail()->getValue();
        $contract->status = $user->getStatus();
        $contract->role = $user->getRole()->getName();

        return $contract;
    }
}