Создание API ресурсов в Laravel
Добро пожаловать в серию статей об интеграции API сторонних разработчиков в Laravel. В этой статье речь пойдёт о создании API ресурсов. API ресурс в данном случае похож на RESTful контроллер. Он добавляет CRUD-методы для взаимодействия с API при работе с определённым ресурсом, например, книгами, товарами, пользователями и т. д. Если вы не читали предыдущие статьи, рекомендую сначала ознакомиться с ними.
Серия статей "Интеграция сторонних API в Laravel":
- Упрощение интеграции API с фасадом Http в Laravel
- Оптимизация API ответов в Laravel с DTO
- Создание API ресурсов в Laravel
- Обработка ошибок при работе со сторонними API
В первых двух частях серии для примеров использовался 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. Вы можете посмотреть репозиторий со всем кодом, который мы рассмотрели в этой статье, здесь.