Магический API Resource в Laravel

от автора

В Laravel есть удобные API ресурсы, с которыми легко и приятно работать в области трансформации данных для ответа на запрос. Но что делать когда возникает необходимость изменить их структуру в соответствии с бизнес-потребностями? Разберёмся вместе!

Вводная

Laravel предоставляет возможность транформации данных для ответа на запрос при помощи API ресурсов. По-умолчанию ответ будет иметь примерно следующий вид для возврата одного инстанса:

{   "data": {     "id": 123,     "title": "Some title"   } }

Для коллекций:

{   "data": [     {       "id": 123,       "title": "Some title"     }   ] }

И для пагинатора:

{   "data": [     {       "id": 123,       "title": "Some title"     }   ],   "links": {     "first": "http://localhost:8000/?page=1",     "last": "http://localhost:8000/?page=2",     "prev": null,     "next": "http://localhost:8000/?page=2"   },   "meta": {     "current_page": 1,     "from": 1,     "last_page": 2,     "links": [       {         "url": null,         "label": "« Previous",         "active": false       },       {         "url": "http://localhost:8000/?page=1",         "label": "1",         "active": true       },       {         "url": "http://localhost:8000/?page=2",         "label": 2,         "active": false       },       {         "url": "http://localhost:8000/?page=2",         "label": "Next »",         "active": false       }     ],     "path": "http://localhost:8000/",     "per_page": 2,     "to": 2,     "total": 3   } }

И данный ответ выглядит уже устрашающе в случая, когда Laravel работает в режиме API и фронтенд к нему разрабатывается снаружи другой командой.

Преобразование snake_case в camelCase

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

В случае с данными всё просто — при создании ресурсов пишем имена ключей в нужном стиле и радуемся. Но как быть с пагинатором?

Мне известно три пути решения проблемы: очень костыльный, условно неудобный и мудрёный.

Костыльный способ

Пример костыльного метода, который я видел, некоторых людей приводит в ужас. Просто посмотрите на это:

<?php  public function index(Request $request) {     $items = Some::paginate();      $resource = SomeResource::collection($items);      $result = $this->convertToArray($resource->toArray($request));      return response()->json($result); }  protected function convertToArray(array $items): array {     $result = [];      foreach ($items as $key => $value) {         $newKey = Str::camel($key);          $result[$newKey] = is_array($value) ? $this->convertToArray($value) : $value;     }      return $result; }

Здесь даже говорить ничего не надо — все проблемы на лицо. Поэтому перейдём к неудобному способу.

Условно неудобный

Laravel позволяет легко изменять ключи пагинатора путём объявления метода paginationInformation в классе ресурса. Для удобства можно вынести его в общий абстрактный класс и наслаждаться.

<?php  public function paginationInformation($request, $paginated, $default) {     $default['links']['custom'] = 'https://example.com';       return $default; }

Такой подход довольно простой в реализации, полностью соответствует логике фреймворка и не требует дополнительных затрат по сопровождению. Но почему он неудобный?

Всё дело в том, что если придёт задача на изменение структуры ресурсов, этот метод придётся обрабатывать вручную и переписывать. Именно в этом случае в одном из проектов появился третий способ — мудрёный.

Мудрёный способ

Мудрёный он потому, что помимо превращения имён ключей в camelCase возникла бизнес-потребность дополнительно оборачивать ключи массивов в дополнительный ключ.

Так, например, если обычная коллекция имеет следующую структуру:

{   "data": [     {       "id": 123,       "title": "Some title"     }   ] }

То по новым условиям нужно возвращать в такой:

{   "data": {    "somes": [       {         "id": 123,         "title": "Some title"       }     ]   } }

Где somes — это множественная форма имени ключа и у каждого ресурса она своя.

Спросите зачем? Ответ прост — мета-данные. Периодически возникают потребности отдать какие-либо жёстко связанные именно с этим ответом данные, когда проще объединить их в один ответ нежели запрашивать из двух мест. В данном случае имена ключей не будут конфликтовать, т.к. внутри data у каждого будет, скажем так, своя область видимости.

Вдобавок, можно расширять ответы, чем мы ниже и займёмся.

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

Итак, нам прилетела необходимость выполнения следующих преобразований в респонсах:

  1. В случае возврата одного инстанса тело размещать внутри объекта data;

  2. В случае возврата коллекции, в том числе пагинации:

    1. тело размещать во вложенном в объект data ключе, имя которого имеет множественное значение (например, users, posts);

    2. выводить объект пагинатора в определённом формате;

  3. Приводить все даны на выходе к формату Y-m-d\TH:i.

Конечный вид объектов должен соответствовать следующему виду:

{   "data": {     "id": 123,     "title": "Some title",     "createdAt": "2024-03-16T18:54"   },   "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   },   "status": "OK" }

Выглядит просто? Это просто так выглядит ? Перейдём к разработке.

Подготовительные работы

Начнём с самого простого — добавить вывод ключа status со значением OK. На первый взгляд никаких проблем нет в том чтобы добавить его в переменную $additional ресурса:

<?php  public $additional = [     'status' => 'OK', ];

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

Дополнительные данные

Итак, мы помним что нельзя просто взять и пробросить дополнительные параметры снаружи используя метод additional у ресурса, так как эти данные напрямую будут выводиться в ответе, а нам это не нужно. Поэтому создадим новые методы и положим их для удобства в трейт:

<?php  namespace App\Concerns\Resources;  trait HasAppends {     protected array $appends = [];      public function append(string $key, mixed $value): static     {         $this->appends[$key] = $value;          return $this;     }      public function setAppends(array $appends): static     {         $this->appends = array_merge($this->appends, $appends);          return $this;     }      protected function getAppend(string $key): mixed     {         return $this->appends[$key];     } } 

Преобразование дат

Есть два способа преобразования дат в респонсах — ручное и автоматическое. С ручным всё понятно — для каждого объекта даты пробрасываем форматирование ->format('Y-m-d\TH:i'). Но что если таких дат много или вовсе можно забыть про это? В этом случае приходит на помощь автоформат.

Работает он рекурсивным методом по результату коллекции, вызываемой методом jsonSerialize:

<?php  namespace App\Concerns\Resources;  use Carbon\Carbon;  trait HasJsonDates {     protected static ?string $dateFormat = null;      public function jsonSerialize(): array     {         return $this->resolveDates(             parent::jsonSerialize()         );     }      protected function resolveDates(array $items): array     {         foreach ($items as &$item) {             if (is_array($item)) {                 $item = $this->resolveDates($item);                  continue;             }              if ($item instanceof Carbon) {                 $item = $item->format($this->dateFormat());             }         }          return $items;     }      protected function dateFormat(): string     {         return static::$dateFormat ?? config('resources.date.format');     } } 

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

JSON флаги

Также вынесем в трейт определение флагов для JSON объектов:

<?php  namespace App\Concerns\Resources;  trait HasJsonOptions {     public function jsonOptions(): int     {         return JSON_UNESCAPED_SLASHES ^ JSON_UNESCAPED_UNICODE;     } } 

Думаю, не стоит говорить о том, что данный код может быть также модифицирован в зависимости от того запускается ли он на продакшене или нет с целью добавления «красивого вывода» ( JSON_PRETTY_PRINT).

Реализация

Теперь можно переходить к основной реализации и начнём с трансформации ресурса.

Трансформация ресурса

Задачи, которые должен выполнять слой:

  • Добавление ключа status со значением OK в ответе;

  • Преобразование дат по переданному формату;

  • Формирование объекта пагинации;

  • Формирование конечного JSON без преобразования кириллицы в юникод и отказ от добавления экранирования.

Меньше слов, больше дела:

<?php  namespace App\Services\Resources;  use App\Concerns\Resources\HasJsonDates; use App\Concerns\Resources\HasJsonOptions; 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->with, $with);     }      protected function resolveData(mixed $data): array     {         if ($data instanceof Collection) {             $data = $data->all();         }          return $this->resolveDates($data);     }      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(),             ],         ];     } } 

Думаю, нет необходимости в том чтобы расписывать что должна делать каждая строчка, поэтому перейдём дальше

Трансформация коллекции

<?php  namespace App\Http\Resources;  use App\Concerns\Resources\HasAppends; use App\Services\Resources\ResourceResponseService; use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Collection as BaseCollection; use Illuminate\Support\Str;  final class Collection extends JsonResource {     use HasAppends;      public function __construct(         mixed $resource,         protected string $collects,         protected ?string $wrapKey,         protected ?string $dateFormat     ) {         parent::__construct($resource);     }      public function toResponse($request): JsonResponse     {         return (new ResourceResponseService(             $this,             $this->wrapper(),             $this->dateFormat         ))->toResponse($request);     }      public function toArray(Request $request): array     {         return $this->resource instanceof Paginator             ? $this->forwardAppends($this->resource->items())->toArray()             : $this->forwardAppends($this->resource)->toArray();     }      protected function forwardAppends(mixed $items): BaseCollection     {         return collect($items)->map(             fn (mixed $item) => (new $this->collects($item))->setAppends($this->appends)         );     }      protected function wrapper(): string     {         return $this->wrapKey ?? Str::of($this->collects)             ->afterLast('\\')             ->beforeLast('Resource')             ->snake()             ->plural()             ->toString();     } } 

По пробросу внешних данных внутрь ресурсов при помощи метода setAppends понятно, как и про вызов выше описанного абстрактного ресурса для трансформации. Но что за wrapper? Для чего он?

Метод wrapper разрешает нам не указывать значение атрибута $wrapKey в классе коллекции и, используя механику Laravel, мы сами его сформируем из названия класса. Например:

Класс

Имя

App\Http\Resources\PageResource

pages

App\Http\Resources\PageItemResource

page_items

App\Http\Resources\PageResourceCollection

pages

App\Http\Resources\PageItemResourceCollection

page_items

Либо можно явно указать. То же самое касается и свойства $dateFormat. Например:

<?php  namespace App\Http\Resources;  use Illuminate\Http\Request;  /** @mixin App\Models\Some */ class SomeResource extends Resource {     protected static ?string $wrapKey = 'qwerty';      protected static ?string $dateFormat = DATE_ATOM;      public function toArray(Request $request): array     {         return [             'id'        => $this->id,             'title'     => $this->title,             'createdAt' => $this->created_at,         ];     } }

На выходе получим нечто вроде:

{   "data": {    "qwerty": [       {         "id": 123,         "title": "Some title",         "createdAt": "2024-03-16T18:54:12+00:00"       }     ]   },   "status": "OK" }

Основной абстрактный класс

Пришло время объединить два вышеуказанных хелпера в основной абстрактный класс API ресурса:

<?php  namespace App\Http\Resources;  use App\Concerns\Resources\HasAppends; use App\Concerns\Resources\HasJsonDates; use App\Services\Resources\ResourceResponseService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource;  abstract class Resource extends JsonResource {     use HasAppends;     use HasJsonDates;      protected static ?string $wrapKey = null;      public static function collection($resource): Collection     {         return new Collection($resource, static::class, static::$wrapKey, static::$dateFormat);     }      public function toResponse($request): JsonResponse     {         return (new ResourceResponseService(             $this,             null,             static::$dateFormat         ))->with($this->with)->toResponse($request);     } } 

Данный класс позволяет избежать создания лишних классов коллекций. Осталось заменить наследования у созданных ресурсов и будет счастье!

<?php  namespace App\Http\Resources;  use Illuminate\Http\Request;  /** @mixin App\Models\Some */ class SomeResource extends Resource {     public function toArray(Request $request): array     {         return [             'id'    => $this->id,             'title' => $this->title,         ];     } }

Шаблоны Laravel

Laravel Stubs

Опубликуйте шаблоны при помощи консольной команды:

php artisan stub:publish

Файлы появятся в папке stubs в корне проекта. В файле stubs/resource.stub измените наследуемый класс на созданный. Лишние шаблоны можно удалить при необходимости.

Laravel Idea

Это платный плагин для PhpStorm и с ним всё проще:

С какими проблемами пришлось столкнуться

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

{   "data": {    "somes": [       {         "id": 123,         "title": "Some title",         "createdAt": "2024-03-16T18:54",         "category": {           "data": {             "id": 1,             "title": "Category name"           },           "pagination": {             "total": 2,             "perPage": 2,             "currentPage": 1,             "lastPage": 2           },           "status": "OK"         }       }     ]   },   "pagination": {     "total": 2,     "perPage": 2,     "currentPage": 1,     "lastPage": 2   },   "status": "OK" }

Всё это было при попытках по-максимуму оставить коробочный функционал обработки. В итоге пришёл к тому, что свой обработчик работает стабильнее.


ссылка на оригинал статьи https://habr.com/ru/articles/800715/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *