Вчера я опубликовал перевод статьи на тему оптимизации использования респонсов в Laravel с "простейшими" данными. То есть когда в ответ нужно отдать какое-то число, строку, массив или объект. Но что делать если приложение построено на использовании Json Resource? Или ещё больше - нужно изменить уровень вложенности данных, возвращаемых коллекцией? Давайте разбираться!

Начнём с вводных данных и, чтобы не плодить сущности, будем все примеры смотреть на одном конкретном ресурсе так как остальные будут следовать той же самой логике. Вот он:

<?php

use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,

            'firstName' => $this->first_name,
            'lastName' => $this->last_name,
        ];
    }
}

Если мы вернём этот ресурс из контроллера, то на выходе получим объект со следующей структурой:

{
  "data": {
    "id": 1,
    "firstName": "Нина",
    "lastName": "Дроздова"
  }
}

Возвращая коллекцию, вывод будет таким:

{
  "data": [
    {
      "id": 1,
      "firstName": "Нина",
      "lastName": "Дроздова"
    }
  ]
}

Вроде всё хорошо, но вот прилетела задача добавить в ответ "status": "OK". Сделать это можно двумя способами: первый - добавить вызов метода additional(['status' => 'OK']) в конструкторе или добавить определение переменной внутри класса:

<?php

return UserResource::collection($user)->additional(['status' => 'OK']);
<?php

class UserResource extends Resource
{
    public $with = [
        'status' => 'OK'
    ];

    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,

            'firstName' => $this->first_name,
            'lastName' => $this->last_name,
        ];
    }
}

На выходе получим объект {"data": ..., "status": "OK"}.

Вроде справились. А теперь приходит разнарядка что нужно в коллекциях обернуть ответ в какой-либо ключ, например, "users". Зачем? Чтобы можно было добавить ещё какие-то связанные данные в ответ. Таким образом, JSON на выходе должен выглядеть примерно так:

// для обычного объекта
{
  "data": {
    "id": 1,
    "firstName": "Нина",
    "lastName": "Дроздова"
  },
  "status": "OK"
}

// для коллекции
{
  "data": {
    "users": [
      {
        "id": 1,
        "firstName": "Нина",
       "lastName": "Дроздова"
      }
    ]
  },
  "status": "OK"
}

Но как это сделать? И как не забыть указывать тот самый ключ "users"? Что будет если забыть? Давайте разберёмся.

На случай "забытых ключей" можно поступить двумя способами - требовать его заполнение или использовать магию Laravel. Первый способ решается простой типизацией параметра, например, protected static string $dataKey;. В этом случае сам PHP вернёт ошибку при попытке чтения параметра без его инициализации. Но это стреляет в ногу в тех случаях, когда в ресурсах не нужно его указывать. Поэтому ниже рассмотрим второй способ - магию.

Laravel позволяет вызывать JSON ресурс как для конкретного набора данных, так и для коллекции, вызывая методы make и collection у одного объекта. Подкапотная магия устроена так, что если рядом с классом лежит такой же по названию, но с суффиксом Collection, то будет взят именно он. Например, UserResourceCollection будет использоваться при вызове UserResource::collection(). Мы учтём этот момент и вначале создадим общий класс для работы с коллекциями:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Str;

final class Collection extends AnonymousResourceCollection
{
    public function __construct(
        mixed $resource,
        string $collects,
        protected ?string $dataKey
    ) {
        $this->dataKey ??= $this->resolveDataKey($collects);

        parent::__construct($resource, $collects);
    }

    public function toArray(Request $request): array
    {
        return [
            'data' => [
                $this->dataKey => $this->collection,
            ],
            'status' => 'OK',
        ];
    }

    protected function resolveDataKey(string $collection): string
    {
        return Str::of($collection)
            ->afterLast('\\')
            ->beforeLast('Resource')
            ->snake()
            ->plural()
            ->toString();
    }
}

Далее создадим базовый класс для самого ресурса:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class Resource extends JsonResource
{
    protected static ?string $dataKey = null;

    public $with = [
        'status' => 'OK',
    ];

    public static function collection($resource): Collection
    {
        return new Collection($resource, static::class, static::$dataKey);
    }
}

Обратите внимание, что класс Collection в данном случае ссылается на тот что мы создали раннее, а не на одноимённый класс коллекции Laravel.

Теперь мы можем использовать класс ресурса для расширения. Модифицируем начальный ресурс, изменив наследование:

<?php

use App\Http\Resources\Resource;

class UserResource extends Resource
{
    protected static ?string $dataKey = 'users';
  
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,

            'firstName' => $this->first_name,
            'lastName' => $this->last_name,
        ];
    }
}

Вот и всё. Теперь при вызове UserResource::make($user) на выходе получим "простой" объект без дополнительной вложенности, а при вызове UserResource::collection($users) - ключ "users" внутри "data", как и требовалось выше.

Как это работает?

Работает данная механика очень просто. При вызове создания "обычного" объекта хоть через new UserResource($user), хоть через UserResource::make($user), Laravel создаст обычный объект не используя никаких обработчиков и вложенностей. Единственное что сделает - это добавит 'status' => 'OK' в ответ, а вот при возврате коллекции запустится наш созданный класс и, если в ресурсе определить параметр $dataKey, то на выходе JSON объект будет содержать именно ��го значение, иначе вызовет метод resolveDataKey который возьмёт переданную ссылку на класс самого ресурса, далее отсеит всё что находится перед последним слешем, далее возьмёт всё что находится перед словом Resource, затем превратит в snake_case и сделает во множественном числе.

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

<?php

namespace App\Http\Resources;

class MyWorkResource extends Resource
{
  
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
        ];
    }
}

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

App\Http\Resources\MyWorkResource
MyWorkResource
MyWork
my_work
my_works

На выходе получим имя ключа - my_works:

{
  "data": {
    "my_works": []
  },
  "status": "OK"
}

Всё проще некуда. И красиво! ?

Бонус

Довольно часто разработчики меняют написание возвращаемых JSON ключей со snake_case на camelCase и, зачастую проблема оказывается в преобразовании данных пагинации. Один из вариантов, который я встречал, выглядел жутко:

<?php

class PaginatorCamelCaseResource extends PaginatorResource
{
    public function toArray($request): array
    {
        return collect(parent::toArray($request))
            ->put('total', $this->total())
            ->mapWithKeys(
                fn (mixed $item, string $key) => [Str::camel($key) => $item]
            )->toArray();
    }
}

Но как сделать по-человечески? - спросите вы. Легко! Возвращаемся в созданный нами класс Collection и добавляем в него нехитрый метод paginationInformation со следующим содержимым:

<?php

public function paginationInformation(Request $request, array $paginated, array $default): array
{
    return [
        'pagination' => [
            'total' => $default['meta']['total'] ?? 0,
            'perPage' => $default['meta']['per_page'] ?? 0,
            'currentPage' => $default['meta']['current_page'] ?? 0,
            'lastPage' => $default['meta']['last_page'] ?? 0,
        ],
    ];
}

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

<?php

$users = User::paginate();

return UserResource::collection($users);
{
  "data": {
    "users": [
      {
        "id": 1,
        "firstName": "Нина",
       "lastName": "Дроздова"
      },
      ...
    ]
  },
  "pagination": {
    "total": 27,
    "perPage": 5,
    "currentPage": 1,
    "lastPage": 6
  }
  "status": "OK"
}

А теперь сравним с тем, что было:

{
    "data": {
        "users": [
            {
                "id": 1,
                "firstName": "Нина",
                "lastName": "Дроздова"
            },
            ...
        ]
    },
    "status": "OK",
    "links": {
        "first": "http:\/\/127.0.0.1:8000\/api?page=1",
        "last": "http:\/\/127.0.0.1:8000\/api?page=6",
        "prev": null,
        "next": "http:\/\/127.0.0.1:8000\/api?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "last_page": 6,
        "links": [
            {
                "url": null,
                "label": "&laquo; Previous",
                "active": false
            },
            {
                "url": "http:\/\/127.0.0.1:8000\/api?page=1",
                "label": "1",
                "active": true
            },
            {
                "url": "http:\/\/127.0.0.1:8000\/api?page=2",
                "label": "2",
                "active": false
            },
            {
                "url": "http:\/\/127.0.0.1:8000\/api?page=3",
                "label": "3",
                "active": false
            },
            {
                "url": "http:\/\/127.0.0.1:8000\/api?page=4",
                "label": "4",
                "active": false
            },
            {
                "url": "http:\/\/127.0.0.1:8000\/api?page=5",
                "label": "5",
                "active": false
            },
            {
                "url": "http:\/\/127.0.0.1:8000\/api?page=6",
                "label": "6",
                "active": false
            },
            {
                "url": "http:\/\/127.0.0.1:8000\/api?page=2",
                "label": "Next &raquo;",
                "active": false
            }
        ],
        "path": "http:\/\/127.0.0.1:8000\/api",
        "per_page": 5,
        "to": 5,
        "total": 27
    }
}

Плюсы и простота очевидны.

Один класс, что правит всеми

Не зря в начале статьи я упомянул о предыдущем посте, в котором рассказывается кейс решения проблемы работы с кодами ответов. Давайте применим его и сюда.

Конечно, можно вызвать метод toJson у ресурса передав в его параметр нужный флаг, например, JSON_PRETTY_PRINT, но это добавит дубляж кода по приложению. Поэтому мы пойдём простейшим правильным способом - переопределением.

Дополним наш базовый класс Response переопределив публичный метод jsonOptions:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

abstract class Resource extends JsonResource
{
    protected static ?string $dataKey = null;

    public $with = [
        'status' => 'OK',
    ];

    public static function collection($resource): Collection
    {
        return new Collection($resource, static::class, static::$dataKey);
    }

    public function jsonOptions(): int
    {
        return JSON_UNESCAPED_UNICODE ^ JSON_PRETTY_PRINT;
    }
}

Вот и всё. На выходе получаем красиво отформатированный JSON:

Копируем тот же самый метод в соседний класс Collection и вуаля:

Laravel Idea + artisan make:resource

Но как же сделать так, чтобы наш созданный ресурс автоматически прописывался при создании классов будь то через консольную команду php artisan make:resource или вызовом хоткея в плагине Laravel Idea для PhpStorm? И здесь нет никаких проблем!

Laravel Templates

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

php artisan stub:publish

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

Теперь приведём шаблон к следующему виду:

<?php

namespace {{ namespace }};

use Illuminate\Http\Request;
use App\Http\Resources\Resource;

class {{ class }} extends Resource
{
    public function toArray(Request $request): array
    {
        //
    }
}

И всё. После вызова создания ресурса консольной командой класс будет наследоваться от созданного нами.

Laravel Idea

Изменить настройки плагина также легко. Для этого перейдите в пункт меню Laravel > Laravel Idea Settings... (также можно через пункт меню File > Settings и дальше File | Settings | Languages & Frameworks | Laravel Idea | General), затем в подменю Code Generation > Standard Generations, найдите в списке Create Json Resource и в поле Base class замените базовый класс на свой. В нашем случае это App\Http\Resources\Resource.

И всё. Теперь все создаваемые любым способом ресурсы будут иметь ссылку на базовый класс.