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