Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров4.1K

В 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"
}

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

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 8: ↑6 и ↓2+5
Комментарии9

Публикации

Истории

Работа

PHP программист
139 вакансий

Ближайшие события