Laravel API: Создание API

Источник: «Building APIs in Laravel»
Создание API в Laravel — это искусство. Вы должны думать не только о доступе к данным и обёртывании Eloquent Моделей в конечные точки API.

Первое, что нужно сделать, это спроектировать свой API. Лучший способ сделать это — подумать о цели вашего API. Почему вы создаёте его и какой целевой вариант использования? Как только разберётесь с этим — сможете эффективно разрабатывать свой API, основываясь на том, как его следует интегрировать.

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

Давайте поговорим на примере, чтобы нарисовать картину. Я создаю новый банк, Laracoin. Мне нужно, чтобы мои пользователи могли создавать учётные записи и транзакции для этих учётных записей. У меня есть модель Account, модель Transaction и модель Vendor, которой будет принадлежать каждая транзакция. Пример этого:

Account -> Has Many -> Transaction -> Belongs To -> Vendor

Spending Account -> Lunch 11.50 -> Some Restaurant

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

GET /accounts
POST /accounts
GET /accounts/{account}
PUT|PATCH /accounts/{account}
DELETE /accounts/{account}

GET /transactions
POST /transactions
GET /transactions/{transaction}
PUT|PATCH /transactions/{transaction}
DELETE /transactions/{transaction}

GET /vendors
POST /vendors
GET /vendors/{vendor}
PUT|PATCH /vendors/{vendor}
DELETE /vendors/{vendor}

Однако в чём преимущества этих маршрутов? Мы просто создаём JSON доступ для наших красноречивых моделей, который работает, но не добавляет ценности, и сточки зрения интеграции делает всё очень роботизированным.

Вместо этого давайте подумаем о дизайне и назначении нашего API. Доступ к API, скорее всего, будет осуществляться внутренними мобильными и веб-приложениями. Для начала сосредоточимся на этих вариантах использования. Зная этом, мы можем точно настроить API для соответствия пользовательскому пути в наших приложениях. Обычно в этих приложениях мы видим список учётных записей, так мы можем управлять своими аккаунтами. Также нужно будет перейти к аккаунту, чтобы увидеть список транзакций. Затем нужна кликнуть на транзакцию, чтобы увидеть более подробную информацию. Нам никогда не нужно было видеть поставщиков напрямую, поскольку они больше нужны для категоризации, чем для чего-то ещё. Имея это ввиду, мы можем разработать API на основе следующих вариантов использования и принципов:

GET /accounts
POST /accounts
GET /accounts/{account}
PUT|PATCH /accounts/{account}
DELETE /accounts/{account}

GET /accounts/{account}/transactions
GET /accounts/{account}/transactions/{transaction}

POST /transactions

Это позволит эффективно управлять своими аккаунтами и получать транзакции только через аккаунт, которому они принадлежат. Мы не хотим, чтобы транзакции редактировались или управлялись. Они должны только создаваться — а оттуда внутренний процесс должен обновлять их, если это потребуется.

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

Во-первых, сделаем предположение, что мы создаём Laravel приложение только для API, поэтому нам не понадобится префикс api. Давайте подумаем, как мы могли бы зарегистрировать эти маршруты, так как это первая часть вашего приложения, которая видит проблемы. Перегруженный файл маршрутов трудно мысленно разобрать, а когнитивная нагрузка — первая битва в любом приложении.

Если бы этот API был публичным, я бы рассмотрел возможность поддержки версий API. И в этом случае я бы создал каталог версий и хранил каждую основную группу в отдельном файле. Однако в данном случае мы не используем управление версиями, поэтому организуем их по другому.

Первый файл маршрутов это routes/api/accounts.php, который можно добавить в routes/api.php.

Route::prefix('accounts')->as('accounts:')->middleware(['auth:sanctum', 'verified'])->group(
base_path('routes/api/accounts.php),
);

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

Route::get(
'/',
App\Http\Controllers\Accounts\IndexController::class,
)->name('index');

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

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

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

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

При создании класса запроса необходимо сделать привязку соответствующего интерфейса к контроллеру. Это необязательный шаг. Тем не менее это я пишу руководство — так чего вы ожидали, на самом деле?

interface FilterForUserContract
{
public function handle(Builder $query, string $user): Builder;
}

Затем реализация, которую мы хотим использовать:

final class FilterAccountsForUser implements FilterForUserContract
{
public function handle(Builder $query, string $user): Builder
{
return QueryBuilder::for(
subject: $query,
)->allowedIncludes(
include: ['transactions'],
)->where('user_id', $user)->getEloquentBuilder();
}
}

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

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

final class IndexController
{
public function __construct(
private readonly Authenticatable $user,
private readonly FilterForUserContract $query,
) {}

public function __invoke(Request $request): Responsable
{
$accounts = $this->query->handle(
query: Account::query()->latest(),
user: $this->user->getAuthIdentifier(),
);

// вернуть ответ здесь.
}
}

На данный момент у нашего контроллера есть Eloquent Builder, который будет передавать ответ, поэтому при передаче данных убедитесь, что вы либо вызываете get, либо paginate для передачи данных через свойства. Это приводит нас к следующему пункту моего самоуверенного путешествия.

Ответ — основная обязанность нашего API. Мы должны отвечать быстро и эффективно, чтобы наши пользователи могли пользоваться быстрым и эффективным API. То как мы отвечаем как API можно разбить на две области: класс ответа и как данные трансформируются для ответа.

Этими двумя областями являются ответы HTTP Responses и API Resources. Я начну с API Resources, так как они меня очень интересуют. API Resources используются для запутывания структуры базы данных и способа трансформации информации хранимой в вашем API, таким образом, чтобы её было лучше использовать на стороне клиента.

Я использую стандарты JSON:API в своих Laravel API, поскольку это отличный стандарт, который хорошо документирован и используется в сообществе API. К счастью, Tim MacDonald создал фантастический пакет для создания JSON:API ресурсов в Laravel, который я использую во всех своих Laravel приложениях. Недавно я написал руководство о том, как использовать этот пакет, поэтому здесь я остановлюсь лишь на некоторых деталях.

Давайте начнём с Account Resource, который будет настроен так, чтобы иметь соответствующие отношения и атрибуты. Со времени написания руководства пакет был обновлён, что упростило настройку отношений.

final class AccountResource extends JsonApiResource
{
public $relationships = [
'transactions' => TransactionResource::class,
];

public function toAttributes(Request $request): array
{
return [
'name' => $this->name,
'balance' => $this->balance->getAmount(),
];
}
}

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

Использование этих ресурсов означает, что для доступа к имени нам придётся использовать: data.attributes.name. Может потребоваться некоторое время, чтобы привыкнуть к нему в мобильных или веб-приложениях, но вы достаточно быстро освоитесь. Мне нравиться такой подход, так как мы можем разделить отношения и атрибуты и расширить их там, где это необходимо.

Как только наши ресурсы заполнены, мы можем сосредоточиться на других областях, таких как Авторизация. Это жизненно важная часть API, и её нельзя упускать из виду. Большинство из нас уже использовали Laravel Gate раньше, используя фасад Gate. Однако мне нравится внедрять контракт Gate из самого фреймворка. Это связано с тем, что я предпочитаю внедрение зависимостей фасадам, когда есть возможность. Давайте посмотрим, как это может выглядеть в StoreController для аккаунтов.

final class StoreController
{
public function __construct(
private readonly Gate $access,
) {}

public function __invoke(StoreRequest $request): Responsable
{
if (! $this->access->allows('store')) {
// ответ с ошибкой.
}

// остальная часть контроллера расположена здесь.
}
}

Мы просто используем функциональность Gate, как если бы это был фасад, поскольку это одно и тоже. Здесь я использую allows, но вы можете использовать can или другие методы. Вы должны сосредоточиться на Авторизации, а не на том, как она реализована, так как это второстепенная деталь для вашего приложения в конце дня.

Итак, мы знаем, как хотим представить данные в API и как авторизовать пользователей в приложении. Далее мы можем посмотреть, как можно обрабатывать операции записи.

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

Вы можете записывать данные в API разными способами, но я предпочитаю использовать фоновые задания и быстрый возврат. Это означает, что можете беспокоиться о логике того как создаются вещи в вашем собственном времени, а не о ваших клиентах. Преимущество заключается в том, что ваши фоновые задания по-прежнему могут публиковать обновления через веб-сокеты для работы в режиме реального времени.

Давайте посмотрим на обновлённый StoreController для аккаунтов с использованием такого подхода:

final class StoreController
{
public function __construct(
private readonly Gate $access,
private readonly Authenticatable $user,
) {}

public function __invoke(StoreRequest $request): Responsable
{
if (! $this->access->allows('store')) {
// ответ с ошибкой.
}

dispatch(new CreateAccount(
payload: NewAccount::from($request->validated()),
user: $this->user->getAuthIdentifier(),
));

// остальная часть контроллера расположена здесь.
}
}

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

Следуя этому подходу, у нас есть валидные данные и типобезопасные данные, передаваемые для создания модели. В наших тестах всё, что нам нужно сделать — убедиться, что задание отправлено.

it('dispatches a background job for creation', function (string $string): void {
Bus::fake();

actingAs(User::factory()->create())->postJson(
uri: action(StoreController::class),
data: [
'name' => $string,
],
)->assertStatus(
status: Http::ACCEPTED->value,
);

Bus::assertDispatched(CreateAccount::class);
})->with('strings');

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

После этого мы можем протестировать задание изолированно, потому что его не нужно включать в тест конечной точки API. Теперь, как это будет записано в базу данных. Для записи данных мы используем класс Command. Я использую этот подход, потому что использование только Action классов запутывает. В итоге мы получаем сотни action классов, которых трудно проанализировать при поиске определённого класса в каталоге.

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

interface CreateNewAccountContract
{
public function handle(NewAccount $payload, string $user): Model;
}

Мы используем DTO New Account в качестве полезной нагрузки и передаём ID пользователя в виде строки. Обычно, я передаю его как строку; Я бы использовал UUID или ULID для поля ID в своих приложениях.

final class CreateNewAccount implements CreateNewAccountContract
{
public function handle(NewAccount $payload, string $user): Model
{
return DB::transaction(
callback: fn (): Model => Account::query()->create(
attributes: [
...$payload->toArray(),
'user_id' => $user,
],
),
);
}
}

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

Мы рассмотрели, как преобразовывать данные модели для нашего ответа, как запрашивать и записывать данные, а также как мы хотим авторизовать пользователей в приложении. Заключительный этап создания надёжного API в Laravel приложении — рассмотрение того, как мы отвечаем как API.

Большинство API отстой, когда дело доходит до ответа. Это иронично, поскольку это, пожалуй, самая важная часть API. В Laravel есть несколько способов ответа, от использования хелпера до возврата нового экземпляра JsonResponse. Однако мне нравиться создавать отдельные Response классы. Они похожи на классы Query и Command цель которых сократить дублирование кода, но при этом они являются наиболее предсказуемым способом возврата ответа.

Первый ответ, создаваемый мной — ответ коллекции, который будет использоваться при возврате списка аккаунтов, принадлежащих аутентифицированному пользователю. Я бы также сделал набор других ответов, от ответов одной модели до пустых ответов и ответов об ошибках.

class Response implements Responsable
{
public function toResponse(): JsonResponse
{
return new JsonResponse(
data: $this->data,
status: $this->status->value,
);
}
}

Сначала мы должны создать начальный ответ, который будут расширять наши классы ответов. Это связано с тем, что они все будет реагировать одинаково. Все они должны возвращать данные и код состояния — одинаково. Итак, теперь давайте посмотрим на сам класс ответа коллекции.

final class CollectionResponse extends Response
{
public function __construct(
private readonly JsonApiResourceCollection $data,
private readonly Http $status = Http::OK,
) {}
}

Это очень просто и легко реализовать в будущем, и вы можете превратить свойство data в объединение типов(union type), для больше гибкости.

final class CollectionResponse extends Response
{
public function __construct(
private readonly Collection|JsonResource|JsonApiResourceCollection $data,
private readonly Http $status = Http::OK,
) {}
}

Они понятны и просты для понимания, поэтому давайте посмотрим на окончательную реализацию IndexController для аккаунтов.

final class IndexController
{
public function __construct(
private readonly Authenticatable $user,
private readonly FilterForUserContract $query,
) {}

public function __invoke(Request $request): Responsable
{
$accounts = $this->query->handle(
query: Account::query()->latest(),
user: $this->user->getAuthIdentifier(),
);

return new CollectionResponse(
data: $accounts->paginate(),
);
}
}

Фокусирование на этих критических областях позволяет масштабировать API по сложности не беспокоясь о дублировании кода. Это ключевые области, на которых я всегда фокусируюсь пытаясь выяснить, что замедляет работу Laravel API.

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

Дополнительные материалы

Предыдущая Статья

Laravel: DDD и Объект-Значение

Следующая Статья

В чём разница между XSS и CSRF