В предыдущей статье по работе с 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" }
Вот и всё. При получении новых бизнес-потребностей можно будет очень легко прокидывать любые данные в любом формате в ответ на запрос и/или использовать данный принцип в других областях приложения.
