Перейти к содержанию

JSON-ресурс и API личного кабинета

Оглавление серии «Laravel 13 и PHP: портал «Заявка»»

Портал уже умеет выдавать Blade-страницы. Добавим JSON-чтение личного списка и карточки для другого интерфейса того же приложения. Результат урока — API Resource с явными полями и теми же правилами владельца. Таблица и policy не меняются только потому, что формат ответа стал JSON. Особенно важно не отправить внутренний attachment_path вместе с объектом модели.

Используем существующий session guard и web middleware того же origin. Это ограниченный API кабинета, а не публичный API с bearer token для внешних интеграций. Поэтому новые GET-маршруты находятся в web.php и получают auth. Sanctum, выпуск токенов и OAuth в архив не добавлены; для внешнего клиента потребовался бы отдельный договор идентификации и разрешений.

Явная сериализация

Создадим полный app/Http/Resources/TicketResource.php. title и status нужны компактной карточке, created_at передаётся стандартным строковым представлением даты. Body и частные пути не включаются в этот первый ресурс. Для подробного текста можно ввести отдельный ресурс после определения назначения поля клиенту.

<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TicketResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'status' => $this->status,
            'created_at' => $this->created_at?->toIso8601String(),
        ];
    }
}

Документация API Resources Laravel описывает трансформацию модели и paginator. Для кабинета ресурс является внешним договором, который может оставаться устойчивым при добавлении столбцов базы. Если вернуть модель автоматически, новое поле хранилища способно неожиданно попасть к клиенту. Список разрешённых полей проще оценить при чтении кода.

Полный app/Http/Controllers/TicketApiController.php ограничивает список user_id и проверяет view карточки. Фильтр status совпадает с HTML-представлением, но выполняется самостоятельно для этого запроса.

<?php
namespace App\Http\Controllers;
use App\Http\Resources\TicketResource;
use App\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class TicketApiController extends Controller
{
    public function index(Request $request)
    {
        $filters = $request->validate(['status' => ['nullable', 'in:open,closed']]);
        $status = $filters['status'] ?? null;
        $tickets = Ticket::query()->where('user_id', $request->user()->id)
            ->when($status, fn ($query) => $query->where('status', $status))
            ->orderByDesc('id')->paginate(20)->withQueryString();
        return TicketResource::collection($tickets);
    }
    public function show(Ticket $ticket)
    {
        Gate::authorize('view', $ticket);
        return new TicketResource($ticket);
    }
}

Добавьте импорт TicketApiController и новые декларации. Полный routes к этому шагу включает обычные страницы, формы, частные файлы и API.

Route::get('/api/tickets', [TicketApiController::class, 'index'])->middleware('auth')->name('api.tickets.index');
Route::get('/api/tickets/{ticket}', [TicketApiController::class, 'show'])->whereNumber('ticket')->middleware('auth')->name('api.tickets.show');

Заголовок Accept и форма отказа

Клиент должен отправлять Accept application/json, если ожидает JSON и для ошибок. Иначе middleware входа или обычная валидация могут выбрать HTML-переход. Это не отказ сериализации самого ресурса, а переговоры о формате всего HTTP-запроса. Приложение должно различать успешную карточку, 401 входа, 403 права, 404 отсутствия и 422 неправильного status, не пытаясь разобрать любой ответ как успешный data.

Создадим полный API.md с договором. В нём приведено ожидаемое тело примера, а не записанный ответ реально выполненного сервера. Квитанции и PDF по-прежнему читаются контролируемыми маршрутами заявки, их внутренние пути не становятся полями API.

# Session API «Заявки»
Тот же origin и действующая серверная сессия; Accept: application/json.
GET /api/tickets?status=open&page=1: resource collection с data, links и meta.
GET /api/tickets/{id}: 200 {"data":{"id":1,"title":"Нужен доступ к макетам","status":"open","created_at":"...ISO-8601..."}}.
Это описание ожидаемого ответа; запросы не запускались.
Список всегда ограничен текущим пользователем. Карточка проверяет TicketPolicy view.
401: требуется вход; 403: запрещён объект; 404: объект отсутствует; 422: неверный фильтр.
Нет bearer token, регистрации API, cross-origin credentials или внешнего API-клиента.
Нет write API: создание, изменение, закрытие и вложения используют существующие защищённые HTML-формы.
Нет password, user_id, attachment_path, receipt path и полного body в ресурсе.

Пагинация остаётся частью договора

Resource collection получает paginator, поэтому структура ответа содержит больше, чем голый массив. Клиент читает data для элементов и метаданные для движения. Нельзя заменить paginate get, сохранив прежнее ожидание meta и links. На малом наборе такое изменение может казаться незаметным, но контракт следующей страницы уже будет нарушен. Представление и транспорт должны согласоваться независимо от числа текущих учебных строк.

Фильтр применяется до преобразования ресурса. Он не должен просматривать сериализованные двадцать записей и отбрасывать закрытые после paginator: тогда total и страницы перестали бы соответствовать видимому списку. Те же причины рассматривались в HTML, но здесь важен внешний клиент, который доверяет meta. Один объект paginator обеспечивает согласование, если условия builder заданны правильно.

Лишнее поле и право объекта

Полезный неправильный случай — вернуть $ticket toArray и удалить password, предполагая, что этого достаточно. У Ticket нет собственного password, зато может быть attachment_path или другой частный столбец. Внешний договор должен строиться по назначению каждого поля, а не исключать один всем известный секрет. В нашем resource явно разрешены four поля, и будущая миграция не расширяет этот список сама.

Другой неправильный вариант — считать JSON-адрес «служебным» и убрать Gate. Пользователь способен вызвать его непосредственно, даже если навигация показывает только Blade. Авторизация формата не существует отдельно от объекта: одинаковая заявка требует одинакового view по любому пути. Список также остаётся ограничен request user, а произвольный user_id query не используется.

Created_at представляет момент в выбранном формате, а не заранее локализованный русский текст. Клиент может показать дату в своей зоне, но должен понимать исходное смещение. Если продукту важно хранить отдельную бизнес-дату закрытия, её следует добавить по определению события. Текущее created_at обозначает создание заявки и не становится датой квитанции только потому, что статус closed.

Чтение не изменяет кабинет

Новые GET не отмечают уведомления прочитанными и не запускают квитанцию. Это чистые пути чтения. Если ресурс позже получит ссылку на действие, её переход не должен автоматически выполнять POST. Для write API придётся отдельно определить CSRF с session guard, Form Request, policy, ответ успеха и конфликт. В этом уроке нет скрытой реализации этих операций.

Перед последней главой проследите собственную заявку через HTML и JSON. Содержимое формы может быть богаче компактного ресурса, но право одно и то же. Затем сравните внутренний путь PDF и адрес скачивания: ни один не должен автоматически сериализоваться как свободный доступ. Следующий урок передаст весь портал с версиями, процессом очереди, частными дисками и списком результатов, которые необходимо подтвердить в собственной среде перед выпуском.

Частный API и HTTP-клиент

Пример браузерного клиента должен отправлять cookies того же origin и ожидать JSON. Ручной запрос без сессии не становится вошедшим только потому, что он знает id собственной старой заявки. Именно поэтому API не рассматривается как свободный каталог вымышленных строк. Сессионная идентификация и policy действуют при каждом чтении.

В API.md нет реального created_at из выполненной базы. Запись ISO-8601 обозначает формат договорённости, а не наблюдавшееся время создания. Когда читатель выполнит собственный seed, конкретные идентификаторы и даты могут отличаться. Клиент должен использовать полученные значения и не вшивать дату из учебного примера.

Если запрос возвращает HTML, сначала проверьте Accept и путь middleware. Не исправляйте это неэкранированным выводом HTML внутри JSON-карточки и не отключайте auth. Правильный формат отказа является частью переговоров HTTP, а право на объект остаётся серверным условием независимо от него.

Для самостоятельного воспроизведения результата «Выдать ограниченную карточку через тот же session guard» выберите lesson-15 в архиве исходников. Статус выполнения этого состояния: not_run.