В предыдущей статье по работе с API ресурсами в Laravel была затронута тема изменения бизнес-потребностей в области формирования внешнего вида объекта ответа на запрос к API приложения.

В этой мы пойдём дальше и введём новую бизнес‑потребность под названием «нотификации». Их суть в том, чтобы вместе с ответом на запрос добавлять информацию о каких‑либо действиях.

Бизнес-потребность

При любых изменениях в базе данных, а также ошибках запросов к некоторым внешним сервисам необходимо "записывать" эти действия с целью их последующего вывода в ответ на запрос к API.

Выглядеть это будет примерно так:

{
  "data": {
    "id": 123,
    "title": "Some title"
  },
  "notifications": [
    {
      "title": "Изменения успешно сохранены",
      "text": "Запись #123 \"Some title\" успешно обновлена.",
      "type": "OK"
    },
    {
      "title": "Изменено отображение информации",
      "text": "Запись #456 \"Another title\" была деактивирована.",
      "type": "WARNING"
    },
    {
      "title": "Упс! Что-то пошло не так!",
      "text": "Не удалось получить информацию с сервиса \"<name>\".",
      "type": "ERROR"
    }
  ],
  "status": "OK"
}

Мысли

На вид просто добавить их вывод в ресурс при помощи метода additional да и всё. Но нет. Вспоминаем что мы - ленивые) Поэтому нужно сделать так, чтобы впредь не приходилось ничего трогать руками и "оно само" работало.

Если подумать, то на ум приходит лишь два способа реализации - прямой и магический.

В "прямом" в нужных местах вместе с ответом отдаём какую-нибудь DTO где будет содержаться информация. Минус этого метода в том, что эту DTO придётся пробрасывать везде и вся, ломая логику и жёстко завязываясь на соседние DTO, вследствие чего придётся ещё и объединять результаты. Зачем? Вот и я говорю что не за чем. Поэтому пойдём простым путём ибо нам - лень (помним, да?).

Магический метод основан на использовании Singleton в Laravel и позволяет хранить состояние от и до. Минус в том, что способ не совместим с Laravel Octane и требует доработки, но так как у меня октан не используется, проблема решилась даже не начавшись ?

Архитектура

Итак, что нам нужно для реализации? Правильно! Глобальный объект, который будет храниться в фреймворке в инициализированном состоянии. При наступлении событий будем в него писать всё что нам нужно, а на выходе получать его состояние. Звучит легко, но как пойдёт на деле - разберёмся по ходу дела.

Подготовка

Сперва создадим Enum класс для хранения типа сообщения:

<?php

namespace App\Enums;

enum NotificationTypeEnum: string
{
    case Ok      = 'OK';
    case Warning = 'WARNING';
    case Error   = 'ERROR';
}

Далее создадим простейший DTO для хранения элемента:

<?php

namespace App\Data;

use App\Enums\NotificationTypeEnum;

class NotificationData
{
    public function __construct(
        public NotificationTypeEnum $type,
        public ?string $title,
        public ?string $text
    ) {}
}

Этот объект согласует нам единый формат элементов массива. И также нам нужен будет класс API ресурса для трансформации объектов:

<?php

namespace App\Http\Resources;

use App\Dto\NotificationData;
use App\Enums\NotificationTypeEnum;
use App\Http\Resources\Resource;
use Illuminate\Http\Request;

/** @mixin NotificationData */
class NotificationResource extends Resource
{
    public function toArray(Request $request): array
    {
        return [
            'title' => $this->title(),
            'text'  => $this->text,
            'type'  => $this->type->value,
        ];
    }

    protected function title(): string
    {
        return $this->title ?? match ($this->type) {
            NotificationTypeEnum::Success => __('The changes have been successfully saved'),
            NotificationTypeEnum::Warning => __('Information display has been changed'),
            NotificationTypeEnum::Error   => __('Whoops! Something wrong'),
        };
    }
}

Идея такая, что если передан заголовок элемента, выводим его, иначе дефолтный.

С этим разобрались и теперь можно перейти к созданию управляющего класса.

Сервис

<?php

namespace App\Services;

use App\Data\NotificationData;
use Illuminate\Contracts\Support\Arrayable;

class NotificationService implements Arrayable
{
    protected array $items = [];

    public function toArray(): array
    {
        return $this->items;
    }

    protected function add(NotificationTypeEnum $type, ?string $title, ?string $text): void
    {
        $this->items[] = new NotificationData($type, $title, $text);
    }
}

Мы подготовили динамическую часть класса для работы с состоянием и, чтобы каждый раз не вызывать инициализированный класс используя хелпер app(), добавим статический метод, обращающийся к нему для добавления записи в память:

<?php

namespace App\Services;

use App\Data\NotificationData;
use Illuminate\Contracts\Support\Arrayable;

class NotificationService implements Arrayable
{
    protected array $items = [];

    public static function push(NotificationTypeEnum $type, ?string $text, ?string $title = null): void
    {
        app(static::class)->add($type, $title, $text);
    }

    public function toArray(): array
    {
        return $this->items;
    }

    protected function add(NotificationTypeEnum $type, ?string $title, ?string $text): void
    {
        $this->items[] = new NotificationData($type, $title, $text);
    }
}

Вновь вспоминаем что мы ленивые и каждый раз вызывать один метод с передачей типизации, такой себе вариант. Поэтому добавляем три метода по количеству типов сообщений: success, warning и error:

<?php

namespace App\Services;

use App\Data\NotificationData;
use App\Enums\NotificationTypeEnum;
use Illuminate\Contracts\Support\Arrayable;

class NotificationService implements Arrayable
{
    protected array $items = [];

    public static function success(?string $text = null, ?string $title = null): void
    {
        static::push(NotificationTypeEnum::Success, $text, $title);
    }

    public static function warning(?string $text = null, ?string $title = null): void
    {
        static::push(NotificationTypeEnum::Warning, $text, $title);
    }

    public static function error(?string $text = null, ?string $title = null): void
    {
        static::push(NotificationTypeEnum::Error, $text, $title);
    }

    protected static function push(NotificationTypeEnum $type, ?string $text, ?string $title = null): void
    {
        app(static::class)->add($type, $title, $text);
    }

    public function toArray(): array
    {
        return $this->items;
    }

    protected function add(NotificationTypeEnum $type, ?string $title, ?string $text): void
    {
        $this->items[] = new NotificationData($type, $title, $text);
    }
}

Далее добавим оставшуюся часть - вывод в ресурсы. Вспоминаем потребность что выводим список только в том случае, если есть что выводить. Поэтому необходимо использовать проверку на пустоту и, в случае обнаружения таковой, возвращать null. Это позволит проще управлять данными. Что ж, добавляем:

<?php

namespace App\Services;

use App\Data\NotificationData;
use App\Enums\NotificationTypeEnum;
use App\Http\Resources\NotificationResource;
use Illuminate\Contracts\Support\Arrayable;

class NotificationService implements Arrayable
{
    protected array $items = [];

    public static function success(?string $text = null, ?string $title = null): void
    {
        static::push(NotificationTypeEnum::Success, $text, $title);
    }

    public static function warning(?string $text = null, ?string $title = null): void
    {
        static::push(NotificationTypeEnum::Warning, $text, $title);
    }

    public static function error(?string $text = null, ?string $title = null): void
    {
        static::push(NotificationTypeEnum::Error, $text, $title);
    }

    protected static function push(NotificationTypeEnum $type, ?string $text, ?string $title = null): void
    {
        app(static::class)->add($type, $title, $text);
    }

    public static function toResource(): ?Collection
    {
        if ($items = app(static::class)->toArray()) {
            return collect($items)->mapInto(NotificationResource::class);
        }

        return null;
    }

    public function toArray(): array
    {
        return $this->items;
    }

    protected function add(NotificationTypeEnum $type, ?string $title, ?string $text): void
    {
        $this->items[] = new NotificationData($type, $title, $text);
    }
}

Также в методе toResource сразу производим проброс элементов массива в ресурсы с целью устранения дубляжа кода снаружи.

Итак, мы получили готовый класс, который в приложении можно использовать в абсолютно любом месте без какой-либо жёсткой привязки.

Например, в сервисе при выполнении определённых проверок, в интеграциях при ошибках запросов к внешним сервисам или при прослушивании эвентов по созданию, изменению или удалению данных из определённых моделей. В этом ограничений нет.

<?php

use App\Services\NotificationService;

NotificationService::success('Обновили запись №1.');
NotificationService::success('Обновили запись №2.', 'Что-то пишем');

NotificationService::warning('Не могу обновить запись №3.');
NotificationService::warning('Не могу обновить запись №4.', 'Что-то пишем');

NotificationService::error('Ошибка запроса к сервису Foo.');
NotificationService::error('Ошибка запроса к сервису Bar.', 'Ошибка получения данных');

Инициализация сервиса

НО сам по себе такой метод не будет сохранять состояние. Нужно инициализировать сервисный класс и для этого сходим в сервис-провайдер AppServiceProvider, добавив нужный вызов в его метод register:

<?php

namespace App\Providers;

use App\Services\NotificationService;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->notifications();
    }

    protected function notifications(): void
    {
        $this->app->singleton(NotificationService::class, fn() => new NotificationService());
    }
}

И последнее что нам осталось сделать, это добавить получение данных для вывода в ресурс.

API Resource

Помните как в прошлой статье формировали класс ResourceResponseService? Вернёмся к нему и добавим обращение к нашему классу:

<?php

namespace App\Services\Resources;

use App\Concerns\Resources\HasJsonDates;
use App\Concerns\Resources\HasJsonOptions;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Symfony\Component\HttpFoundation\Response;

class ResourceResponseService implements Responsable
{
    use HasJsonDates;
    use HasJsonOptions;

    protected array $with = [
        'status' => 'OK',
    ];

    public function __construct(
        protected JsonResource $resource,
        protected ?string $wrap,
        ?string $dateFormat
    ) {
        static::$dateFormat = $dateFormat;
    }

    public function with(array $data): static
    {
        $this->with = array_merge($this->with, $data);

        return $this;
    }

    public function toResponse($request): JsonResponse
    {
        return tap(
            response()->json(
                data: $this->wrap(
                    $this->resource->resolve($request),
                    $this->resource->with($request),
                    $this->resource->additional
                ),
                status: $this->status(),
                options: $this->jsonOptions()
            ),
            function ($response) use ($request) {
                $response->original = $this->resource->resource;

                $this->resource->withResponse($request, $response);
            }
        );
    }

    protected function wrap(mixed $data, array $with, array $additional): array
    {
        Arr::set($result, $this->wrapper(), $this->resolveData($data));

        return array_merge($result, $additional, $this->withPagination(), $this->withNotifications(), $this->with, $with);
    }

    protected function resolveData(mixed $data): array
    {
        if ($data instanceof Collection) {
            $data = $data->all();
        }

        return $this->resolveDates($data);
    }

    protected function withNotifications(): array
    {
        if ($items = NotificationService::toResource()) {
            return ['notifications' => $items];
        }

        return [];
    }

    protected function wrapper(): string
    {
        return 'data' . ($this->wrap ? '.' . $this->wrap : '');
    }

    protected function status(): int
    {
        if ($this->resource->resource instanceof Model && $this->resource->resource->wasRecentlyCreated) {
            return Response::HTTP_CREATED;
        }

        return Response::HTTP_OK;
    }

    protected function isPagination(): bool
    {
        return $this->resource->resource instanceof LengthAwarePaginator;
    }

    protected function withPagination(): array
    {
        return $this->isPagination() ? $this->paginationInformation($this->resource->resource) : [];
    }

    protected function paginationInformation(LengthAwarePaginator $paginated): array
    {
        return [
            'pagination' => [
                'total' => $paginated->total(),
                'perPage' => $paginated->perPage(),
                'currentPage' => $paginated->currentPage(),
                'lastPage' => $paginated->lastPage(),
            ],
        ];
    }
}

На строке 78 примера выше добавлен новый метод, получающий массив готовых ресурсов с объектами нотификаций, а на 66-й строке вызываем его при сборке данных.

В конечном итоге получим следующие виды ответа для единичной записи и для их массива:

{
    "data": {
        "id": 123,
        "title": "Some title",
        "createdAt": "2024-03-16T18:54"
    },
    "notifications": [
        {
            "title": "The changes have been successfully saved",
            "text": "Обновили запись №1.",
            "type": "SUCCESS"
        },
        {
            "title": "Что-то пишем",
            "text": "Обновили запись №2.",
            "type": "SUCCESS"
        },
        {
            "title": "Information display has been changed",
            "text": "Не могу обновить запись №3.",
            "type": "WARNING"
        },
        {
            "title": "Что-то пишем",
            "text": "Не могу обновить запись №4.",
            "type": "WARNING"
        },
        {
            "title": "Whoops! Something wrong",
            "text": "Ошибка запроса к сервису Foo.",
            "type": "ERROR"
        },
        {
            "title": "Ошибка получения данных",
            "text": "Ошибка запроса к сервису Bar.",
            "type": "ERROR"
        }
    ],
    "status": "OK"
}
{
    "data": {
        "somes": [
            {
                "id": 123,
                "title": "Some title",
                "createdAt": "2024-03-16T18:54",
                "category": {
                    "id": 1,
                    "title": "Category name"
                }
            },
            {
                "id": 456,
                "title": "Another title",
                "createdAt": "2024-03-16T18:55",
                "category": null
            }
        ]
    },
    "pagination": {
        "total": 2,
        "perPage": 2,
        "currentPage": 1,
        "lastPage": 2
    },
    "notifications": [
        {
            "title": "The changes have been successfully saved",
            "text": "Обновили запись №1.",
            "type": "SUCCESS"
        },
        {
            "title": "Что-то пишем",
            "text": "Обновили запись №2.",
            "type": "SUCCESS"
        },
        {
            "title": "Information display has been changed",
            "text": "Не могу обновить запись №3.",
            "type": "WARNING"
        },
        {
            "title": "Что-то пишем",
            "text": "Не могу обновить запись №4.",
            "type": "WARNING"
        },
        {
            "title": "Whoops! Something wrong",
            "text": "Ошибка запроса к сервису Foo.",
            "type": "ERROR"
        },
        {
            "title": "Ошибка получения данных",
            "text": "Ошибка запроса к сервису Bar.",
            "type": "ERROR"
        }
    ],
    "status": "OK"
}

И пример объекта без отправки уведомлений:

{
    "data": {
        "id": 123,
        "title": "Some title",
        "createdAt": "2024-03-16T18:54"
    },
    "status": "OK"
}

Вот и всё. При получении новых бизнес-потребностей можно будет очень легко прокидывать любые данные в любом формате в ответ на запрос и/или использовать данный принцип в других областях приложения.

Комментарии (0)