Pull to refresh

Модификация JSON респонсов в Laravel

Level of difficultyMedium
Reading time7 min
Views3.9K

Вчера я опубликовал перевод статьи на тему оптимизации использования респонсов в 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.

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

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 10: ↑10 and ↓0+10
Comments2

Articles