Создание API ресурсов в Laravel

Источник: «Creating API Resources in Laravel»
Использование контроллероподобных классов и Laravel-data для эффективного взаимодействия с API.

Добро пожаловать в серию статей об интеграции API сторонних разработчиков в Laravel. В этой статье речь пойдёт о создании API ресурсов. API ресурс в данном случае похож на RESTful контроллер. Он добавляет CRUD-методы для взаимодействия с API при работе с определённым ресурсом, например, книгами, товарами, пользователями и т. д. Если вы не читали предыдущие статьи, рекомендую сначала ознакомиться с ними.

Серия статей "Интеграция сторонних API в Laravel":

В первых двух частях серии для примеров использовался Google Books API. Для упрощения и чтобы иметь больше маршрутов, не требующих настройки OAuth 2.0, в дальнейшем будет использоваться Fake Store API и запросы к товарам. Так же будет использован пакет Spatie Laravel-data для объектов передачи данных (DTO) вместо создания собственных DTO из простых PHP-классов, как это было в предыдущих статьях. Это поможет убрать часть шаблонов, связанных с определением методов fromArray и toArray.

Начало

В предыдущих статьях этой серии у нас были класс ApiRequest и класс ApiClient. Чтобы создать API-клиент для Fake Store API, можно расширить класс ApiClient.

<?php

namespace App\Support;

class StoreApiClient extends ApiClient
{
protected function baseUrl(): string
{
return config('services.store_api.url');
}
}

Поскольку Fake Store API не требует аутентификации, этот класс может быть довольно простым. Для теста достаточно убедиться, что базовый URL задаётся так, как ожидалось.

<?php

use App\Support\ApiRequest;
use App\Support\StoreApiClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
Http::fake();
config([
'services.store_api.url' => 'https://example.com',
]);
});

it('sets the base url', function () {
$request = ApiRequest::get('products');

app(StoreApiClient::class)->send($request);

Http::assertSent(static function (Request $request) {
expect($request)->url()->toStartWith('https://example.com/products');

return true;
});
});

Для этого теста я вручную задал тестовый URL в конфигурации. Также можно просто использовать файл .env.test для определения значения, если предпочитаете. Для простых тестов мне нравится подход, при котором конфигурация определяется в тесте, так что можно легко увидеть, что ожидалось в тестах, а не сравнивать с другим файлом.

Теперь создадим API ресурс. То, что я называю API-ресурсом, — это простой класс для работы с ресурсом товара, подобный REST-контроллеру в Laravel. API ресурс позволит выводить список товаров, показывать товар, создавать товар, обновлять товар и удалять его. В предыдущей статье был экшен для получения книг из Google Books API, но, используя ресурс, можно объединить различные вызовы ресурса в один класс.

<?php

namespace App\ApiResources;

use App\Data\ProductData;
use App\Support\StoreApiClient;
use Spatie\LaravelData\DataCollection;

/**
* ApiResource для товаров.
*/

class ProductResource
{
/**
* Используем инъекцию зависимостей, чтобы получить StoreApiClient.
*/

public function __construct(private readonly StoreApiClient $client)
{
}

/**
* Список всех товаров.
*/

public function list()
{
...
}

/**
* Отображение одного товара.
*/

public function show(int $id)
{
...
}

/**
* Создание нового товара.
*/

public function create($data)
{
...
}

/**
* Обновление товара.
*/

public function update(int $id, $data)
{
...
}

/**
* Удаление товара.
*/

public function delete(int $id)
{
...
}
}

Заполнение API ресурса

Метод list

Начнём с метода list. Первое, что я предпочитаю делать, — это моделировать данные, которые будем получать из API. Для этого воспользуемся Laravel-data и создадим класс ProductData. Если вы ещё не установили Laravel-data, установите его с помощью composer:

composer require spatie/laravel-data

Класс ProductData можно создать вручную, расширив класс Spatie\LaravelData\Data, или с помощью Artisan:

php artisan make:data ProductData

Изучив документацию по Fake Store API, можно сопоставить это с DTO следующим образом:

<?php

namespace App\Data;

use Spatie\LaravelData\Data;

class ProductData extends Data
{
public function __construct(
public int $id,
public string $title,
public float $price,
public string $description,
public string $category,
public string $image,
public ?RatingData $rating = null,
) {}
}

Обратите внимание, что свойство $rating имеет тип RatingData. Это ещё один DTO:

<?php

namespace App\Data;

use Spatie\LaravelData\Data;

class RatingData extends Data
{
public function __construct(
public float $rate,
public int $count,
) {}
}

Теперь в методе list ресурсов можно добавить следующее, чтобы получить продукты и сопоставить их с коллекцией экземпляров ProductData.

public function list(): DataCollection
{
// Создаём запрос к конечной точке products.
$request = ApiRequest::get('/products');

// Отправляем запрос используя client.
$response = $this->client->send($request);

// Сопоставляем ответ с DTO ProductData.
return ProductData::collection($response->json());
}

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

<?php

namespace App\Data;

use App\Enums\SortDirection;
use Spatie\LaravelData\Data;

class ListProductsData extends Data
{
public function __construct(
public readonly ?int $limit = null,
public readonly ?SortDirection $sort = null,
) {}

public function toArray(): array
{
return collect(parent::toArray())
->filter()
->toArray();
}
}

Тип SortDirection — это простое перечисление с вариантами asc и desc. Я добавил пользовательский метод toArray, использующий коллекции Laravel и метод filter для удаления любых null-свойств из массива. Это предотвращает отправку в API сообщений типа ?sort=null.

Давайте добавим это в метод list.

public function list(?ListProductsData $data = null): DataCollection
{
$request = ApiRequest::get('/products');

if ($data) {
// Добавляем ListProductsData в строку запроса.
$request->setQuery($data->toArray());
}

$response = $this->client->send($request);

return ProductData::collection($response->json());
}

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

$resource = resolve(ProductResource::class);
$requestData = new ListProductsData(
limit: 5,
sort: SortDirection::DESC,
);

$response = $resource->list($requestData);

Добавим несколько тестов для этого метода.

<?php

use App\ApiResources\ProductResource;
use App\Data\ListProductsData;
use App\Data\ProductData;
use App\Enums\SortDirection;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Request;
use Spatie\LaravelData\DataCollection;
use Tests\Helpers\StoreApiTestHelper;

uses(StoreApiTestHelper::class);

it('shows a list of products', function () {
// Имитируем ответ от API.
Http::fake([
'*/products' => Http::response([
$this->getFakeProduct(['id' => 1]),
$this->getFakeProduct(['id' => 2]),
$this->getFakeProduct(['id' => 3]),
$this->getFakeProduct(['id' => 4]),
$this->getFakeProduct(['id' => 5]),
]),
]);

$resource = resolve(ProductResource::class);

$response = $resource->list();

// Утверждаем, что ответ представляет собой коллекцию объектов ProductData.
expect($response)
->toBeInstanceOf(DataCollection::class)
->count()->toBe(5)
->getIterator()->each->toBeInstanceOf(ProductData::class);

// Утверждаем, что GET-запрос был отправлен на корректную конечную точку.
Http::assertSent(function (Request $request) {
expect($request)
->url()->toEndWith('/products')
->method()->toBe('GET');

return true;
});
});

it('limits and sorts products', function () {
// Имитируем ответ от API.
Http::fake([
'*/products?*' => Http::response([
$this->getFakeProduct(['id' => 3]),
$this->getFakeProduct(['id' => 2]),
$this->getFakeProduct(['id' => 1]),
]),
]);

$resource = resolve(ProductResource::class);

// Создаём объект запроса данных с ограничением в три элемента и сортировка по убыванию.
$requestData = new ListProductsData(3, SortDirection::DESC);

$response = $resource->list($requestData);

// Утверждаем, что ответ представляет собой набор объектов ProductData.
expect($response)
->toBeInstanceOf(DataCollection::class)
->count()->toBe(3)
->getIterator()->each->toBeInstanceOf(ProductData::class);

// Утверждаем, что GET-запрос был отправлен на корректную конечную точку с корректными данными запроса.
Http::assertSent(function (Request $request) {
parse_str(parse_url($request->url(), PHP_URL_QUERY), $queryParams);
$path = (parse_url($request->url(), PHP_URL_PATH));

expect($queryParams)->toMatchArray(['limit' => 3, 'sort' => 'desc'])
->and($path)->toEndWith('/products')
->and($request)->method()->toBe('GET');

return true;
});
});

Обратите внимание на вызов uses(StoreApiTestHelper::class); в тесте. Это загружается простой трейт для предоставления метода getFakeProduct(), используемого для генерации ответов о фейковых товарах.

<?php

namespace Tests\Helpers;

trait StoreApiTestHelper
{
private function getFakeProduct(array $data = []): array {
return [
'id' => data_get($data, 'id', fake()->numberBetween(1, 1000)),
'title' => data_get($data, 'title', fake()->text()),
'price' => data_get($data, 'price', fake()->randomFloat(2, 0, 100)),
'description' => data_get($data, 'description', fake()->paragraph()),
'category' => data_get($data, 'category', fake()->text()),
'image' => data_get($data, 'image', fake()->url()),
'rating' => data_get($data, 'rating', [
'rate' => fake()->randomFloat(2, 0, 5),
'count' => fake()->numberBetween(1, 1000),
]),
];
}
}

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

Методы show и delete

Я совмещаю методы show и delete, поскольку они очень похожи. Согласно документации API, они оба возвращают товар по указанному идентификатору, поэтому имеют одинаковое возвращаемое значение. Они также принимают URL-параметр id, позволяющий получить конкретный товар.

/**
* Показать один товар.
*/

public function show(int $id): ProductData
{
$request = ApiRequest::get("/products/{$id}");
$response = $this->client->send($request);

return ProductData::from($response->json());
}

/**
* Удалить товар.
*/

public function delete(int $id): ProductData
{
$request = ApiRequest::delete("/products/$id");
$response = $this->client->send($request);

return ProductData::from($response->json());
}

Теперь добавим следующие тесты:

it('fetches a product', function () {
// Создание фейкового товара.
$fakeProduct = $this->getFakeProduct();

// Имитация ответа от API.
Http::fake(["*/products/{$fakeProduct['id']}" => Http::response($fakeProduct)]);

$resource = resolve(ProductResource::class);

// Запрос товара.
$response = $resource->show($fakeProduct['id']);
expect($response)
->toBeInstanceOf(ProductData::class)
->id->toBe($fakeProduct['id']);

// Утверждаем, что GET-запрос был отправлен на корректную конечную точку с корректным методом.
Http::assertSent(function (Request $request) use ($fakeProduct) {
expect($request)
->url()->toEndWith("/products/{$fakeProduct['id']}")
->method()->toBe('GET');

return true;
});
});

it('deletes a product', function () {
// Создание фейкового товара.
$fakeProduct = $this->getFakeProduct();

// Имитация ответа от API.
Http::fake(["*/products/{$fakeProduct['id']}" => Http::response($fakeProduct)]);

$resource = resolve(ProductResource::class);

// Запрос товара.
$response = $resource->delete($fakeProduct['id']);
expect($response)
->toBeInstanceOf(ProductData::class)
->id->toBe($fakeProduct['id']);

// Утверждаем, что DELETE-запрос был отправлен на корректную конечную точку.
Http::assertSent(function (Request $request) use ($fakeProduct) {
expect($request)
->url()->toEndWith("/products/{$fakeProduct['id']}")
->method()->toBe('DELETE');

return true;
});
});

Методы create и update

Для методов create и update известно, что нужно отправить данные в API. Иногда можно повторно использовать тот же DTO, который возвращает API, но мне нравится создавать специальные DTO для запроса. Поэтому создадим DTO SaveProductData:

<?php

namespace App\Data;

use Spatie\LaravelData\Data;

class SaveProductData extends Data
{
public function __construct(
public string $title,
public float $price,
public string $description,
public string $category,
public string $image,
) {}
}

Для данного API достаточно одного DTO для методов create и update, однако иногда необходимо иметь отдельный DTO для каждого метода. Например, использование этого конкретного DTO для метода update требует указания всех полей. Однако может понадобиться DTO, позволяющий использовать необязательные свойства и отфильтровывать не заданные, чтобы можно было легко обновлять определённые свойства, а не все подряд.

Давайте обновим метод create:

/**
* Создание нового товара.
*/

public function create(SaveProductData $data): ProductData
{
$request = ApiRequest::post('/products')->setBody($data->toArray());
$response = $this->client->send($request);

return ProductData::from($response->json());
}

Метод update аналогичен, но с дополнительным параметром id и запросом PUT вместо запроса POST.

/**
* Обновление товара.
*/

public function update(int $id, SaveProductData $data): ProductData
{
$request = ApiRequest::put("/products/$id")->setBody($data->toArray());
$response = $this->client->send($request);

return ProductData::from($response->json());
}

Как и в методах show и delete, при создании и обновлении мы возвращаем экземпляр ProductData.

Добавим следующие тесты для методов.

it('creates a product', function () {
// Создание фейкового товара.
$fakeProduct = $this->getFakeProduct();

// Имитация ответа от API.
Http::fake(["*/products" => Http::response($fakeProduct)]);

$resource = resolve(ProductResource::class);

// Данные для создания товара
$data = new SaveProductData(
title: $fakeProduct['title'],
price: $fakeProduct['price'],
description: $fakeProduct['description'],
category: $fakeProduct['category'],
image: $fakeProduct['image'],
);

// Запрос товара
$response = $resource->create($data);
expect($response)
->toBeInstanceOf(ProductData::class)
->id->toBe($fakeProduct['id']);

// Утверждаем, что POST-запрос был отправлен на корректную конечную точку.
Http::assertSent(function (Request $request) {
expect($request)
->url()->toEndWith('/products')
->method()->toBe('POST');

return true;
});
});

it('updates a product', function () {
// Создание фейкового товара.
$fakeProduct = $this->getFakeProduct();

// Имитация ответа от API.
Http::fake(["*/products/{$fakeProduct['id']}" => Http::response($fakeProduct)]);

$resource = resolve(ProductResource::class);

$data = new SaveProductData(
title: $fakeProduct['title'],
price: $fakeProduct['price'],
description: $fakeProduct['description'],
category: $fakeProduct['category'],
image: $fakeProduct['image'],
);

// Запрос товара
$response = $resource->update($fakeProduct['id'], $data);
expect($response)
->toBeInstanceOf(ProductData::class)
->id->toBe($fakeProduct['id']);

// Утверждаем, что PUT-запрос был отправлен на корректную конечную точку.
Http::assertSent(function (Request $request) use ($fakeProduct) {
expect($request)
->url()->toEndWith("/products/{$fakeProduct['id']}")
->method()->toBe('PUT');

return true;
});
});

Итог

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

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

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

Оптимизация API ответов в Laravel с DTO

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

Обработка ошибок при работе со сторонними API