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

Источник: «Streamlining API Responses in Laravel with DTOs»
Руководство по созданию Объектов Передачи Данных (DTO) для повышения читабельности, эффективности и тестируемости API интеграций в Laravel.

Введение

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

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

Расширяя эти понятия, в этой статье предлагается подробное руководство по созданию Объектов Передачи Данных (DTO), позволяющих отображать данные из ответов API. В качестве практического примера будет использован сценарий интеграции с Google Books API, позволяющий сделать ситуацию более понятной.

Отображение данных ответа в DTO

Для начала посмотрим на пример ответа от Google Books API, когда выполняется поиск. Для этого вызываем созданный ранее экшен QueryBooksByTitle и ищем книгу "The Ferryman":

$response = app(QueryBooksByTitle::class)("The Ferryman");

dump($response->json());

Это выводит следующий JSON, который был сужен до полей, которые нужно отслеживать:

[
'kind' => 'books#volumes',
'totalItems' => 367,
'items' => [
0 => [
...
],
1 => [
...
],
2 => [
'kind' => 'books#volume',
'id' => 'dO5-EAAAQBAJ',
'volumeInfo' => [
'title' => 'The Ferryman',
'subtitle' => 'A Novel',
'authors' => [
0 => 'Justin Cronin',
],
'publisher' => 'Doubleday Canada',
'publishedDate' => '2023-05-02',
'description' => 'From the #1 New York Times bestselling author of The Passage comes a riveting standalone novel about a group of survivors on a hidden island utopia--where the truth isn\'t what it seems. Founded by a mysterious genius, the archipelago of Prospera lies hidden from the horrors of a deteriorating outside world. In this island paradise, Prospera\'s lucky citizens enjoy long, fulfilling lives until the monitors embedded in their forearms, meant to measure their physical health and psychological well-being, fall below 10 percent. Then they retire themselves, embarking on a ferry ride to the island known as the Nursery, where their failing bodies are renewed, their memories are wiped clean, and they are readied to restart life afresh. Proctor Bennett, of the Department of Social Contracts, has a satisfying career as a ferryman, gently shepherding people through the retirement process--and, when necessary, enforcing it. But all is not well with Proctor. For one thing, he\'s been dreaming--which is supposed to be impossible in Prospera. For another, his monitor percentage has begun to drop alarmingly fast. And then comes the day he is summoned to retire his own father, who gives him a disturbing and cryptic message before being wrestled onto the ferry. Meanwhile, something is stirring. The support staff, ordinary men and women who provide the labor to keep Prospera running, have begun to question their place in the social order. Unrest is building, and there are rumors spreading of a resistance group--known as Arrivalists--who may be fomenting revolution. Soon Proctor finds himself questioning everything he once believed, entangled with a much bigger cause than he realized--and on a desperate mission to uncover the truth.',
'pageCount' => 507,
'categories' => [
0 => 'Fiction',
],
'imageLinks' => [
'smallThumbnail' => 'http://books.google.com/books/content?id=dO5-EAAAQBAJ&printsec=frontcover&img=1&zoom=5&edge=curl&source=gbs_api',
'thumbnail' => 'http://books.google.com/books/content?id=dO5-EAAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api',
],
...
],
...
],
...
]

Теперь, когда формат ответа известен, создадим необходимые DTO для отображения данных. Начнём с BookListData, который может быть простым PHP-классом.

<?php

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Arrayable;

/**
* Хранит данные верхнего уровня из томов Google Books API.
*/

readonly class BooksListData implements Arrayable
{
public function __construct(
public string $kind,
public string $id,
public int $totalItems,
) {
}

/**
* Создаёт новый экземпляр класса из массива данных.
*/

public static function fromArray(array $data): BooksListData
{
return new self(
data_get($data, 'kind'),
data_get($data, 'id'),
data_get($data, 'totalItems'),
);
}

/**
* Реализует интерфейс Laravel Arrayable, позволяющий сериализовать объект
* в массив.
*/

public function toArray(): array
{
return [
'kind' => $this->kind,
'items' => $this->items,
'totalItems' => $this->totalItems,
];
}
}

Создав DTO, мы можем обновить экшен QueryBooksByTitle, созданный в предыдущей статье.

<?php

namespace App\Actions;

use App\DataTransferObjects\BooksListData;
use App\Support\ApiRequest;
use App\Support\GoogleBooksApiClient;
use Illuminate\Http\Client\Response;

/**
* Класс QueryBooksByTitle - это экшен для запроса книг по названию из Google
* Books API.
* Он предоставляет метод __invoke, принимающий заголовок и возвращающий ответ
* от API.
*/

class QueryBooksByTitle
{
/**
* Запрос книг по названию из Google Books API и возврат BookListData.
* Этот метод создаёт GoogleBooksApiClient и ApiRequest для конечной точки
* 'volumes' с заданным названием в качестве параметра запроса 'q' и 'books'
* в качестве параметра запроса 'printType'.
* Затем он отправляет запрос клиенту и возвращает ответ.
*/

public function __invoke(string $title): BooksListData
{
$client = app(GoogleBooksApiClient::class);

$request = ApiRequest::get('volumes')
->setQuery('q', 'intitle:'.$title)
->setQuery('printType', 'books');

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

return BooksListData::fromArray($response->json());
}
}

Тестирование данных ответа

Можно создать тест, чтобы убедиться, что при вызове экшена возвращается объект BooksListData:

<?php

use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;

it('fetches books by title', function () {
$title = 'The Lord of the Rings';

$response = resolve(QueryBooksByTitle::class)($title);

expect($response)->toBeInstanceOf(BooksListData::class);
});

Возможно, вы не заметили, но в приведённом выше тесте есть проблема. Мы обращаемся к Google Books API. Это может быть нормально для интеграционного теста, который выполняется нечасто, но в наших Laravel тестах это должно быть исправлено. Для этого можно использовать возможности фасада Http, поскольку класс Client построен с использованием этого фасада.

Предотвращение HTTP запросов в тестах

Первым делом необходимо убедиться, что ни один из тестов не делает внешних HTTP запросов. Для этого можно добавить Http::preventStrayRequests(); в файл Pest.php. Тогда в любом тесте, использующем фасад Http для выполнения запроса, будет возникать исключение, если не имитировать запрос.

<?php

use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Support\Facades\Http;
use Tests\CreatesApplication;

uses(
TestCase::class,
CreatesApplication::class,
)
->beforeEach(function () {
Http::preventStrayRequests();
})
->in('Feature');

Если снова запустить тест QueryBooksByTitle, то теперь появится сообщение о провале теста:

RuntimeException: Attempted request to [https://www.googleapis.com/books/v1/volumes?key=XXXXXXXXXXXXX&q=intitle%3AThe%20Lord%20of%20the%20Rings&printType=books] without a matching fake.

Теперь используем фасад Http, чтобы сымитировать ответ.

<?php

use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;
use Illuminate\Support\Facades\Http;

it('fetches books by title', function () {
$title = fake()->sentence();

// Генерируем фальшивый ответ от Google Books API.
$responseData = [
'kind' => 'books#volumes',
'totalItems' => 1,
'items' => [
[
'id' => fake()->uuid,
'volumeInfo' => [
'title' => $title,
'subtitle' => fake()->sentence(),
'authors' => [fake()->name],
'publisher' => fake()->company(),
'publishedDate' => fake()->date(),
'description' => fake()->paragraphs(asText: true),
'pageCount' => fake()->numberBetween(100, 500),
'categories' => [fake()->word],
'imageLinks' => [
'thumbnail' => fake()->url(),
],
],
],
],
];

// Возвращаем фальшивый ответ, когда клиент отправляет запрос к Google Books API.
Http::fake(['https://www.googleapis.com/books/v1/*' => Http::response(
body: $responseData,
status: 200
)]);

$response = resolve(QueryBooksByTitle::class)($title);

expect($response)->toBeInstanceOf(BooksListData::class);
expect($response->items[0]['volumeInfo']['title'])->toBe($title);
});

Теперь при выполнении теста больше не возникает RuntimeException, потому что запрос подделывается с помощью метода Http::fake(). Метод Http::fake() очень гибкий и может принимать массив элементов и различные URL. В зависимости от приложения, вы можно просто использовать * вместо полного URL или даже сделать его более конкретным и включить параметры запроса или другие динамические данные URL. При необходимости можно даже подделать последовательность запросов. За дополнительной информацией обратитесь к документации Laravel.

Этот тест работает отлично, но его ещё можно улучшить.

Расширение DTO

Во-первых, снова посмотрим на данные ответа. Хорошо, что верхний уровень ответа отображается в объект BooksListData, но наличие items[0]['volumeInfo']['title'] не очень удобно для разработчика, а IDE не может обеспечить автозавершение. Чтобы это исправить, нужно создать больше DTO. Как правило, проще всего начать с самых низкоуровневых элементов, которые необходимо отобразить. В данном случае это будут данные imageLinks из ответа. Если посмотреть на ответ от Google Books, похоже, что он может содержать свойства thumbnail и smallThumbnail. Создадим объект ImageLinksData, чтобы отобразить это.

<?php

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Arrayable;

readonly class ImageLinksData implements Arrayable
{
public function __construct(
public ?string $thumbnail = null,
public ?string $smallThumbnail = null,
) {
}

public static function fromArray(array $data): self
{
return new self(
thumbnail: data_get($data, 'thumbnail'),
smallThumbnail: data_get($data, 'smallThumbnail'),
);
}

public function toArray(): array
{
return [
'thumbnail' => $this->thumbnail,
'smallThumbnail' => $this->smallThumbnail,
];
}
}

Поднимемся на уровень выше и получим VolumeInfoData.

<?php

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;

readonly class VolumeInfoData implements Arrayable
{
public function __construct(
public string $title,
public string $subtitle,
// Использование коллекций вместо массивов - это личное предпочтение.
// Это немного упрощает работу с данными.
/** @var Collection<int, string> */
public Collection $authors,
public string $publisher,
public string $publishedDate,
public string $description,
public int $pageCount,
/** @var Collection<int, string> */
public Collection $categories,
// Ссылки на изображения отображаются объектом ImageLinksData.
public ImageLinksData $imageLinks,
) {
}

public static function fromArray(array $data): self
{
return new self(
title: data_get($data, 'title'),
subtitle: data_get($data, 'subtitle'),
// Создание коллекций из массивов данных.
authors: collect(data_get($data, 'authors')),
publisher: data_get($data, 'publisher'),
publishedDate: data_get($data, 'publishedDate'),
description: data_get($data, 'description'),
pageCount: data_get($data, 'pageCount'),
// Создание коллекций из массивов данных.
categories: collect(data_get($data, 'categories')),
// Отображение ссылок на изображения в объект ImageLinksData.
imageLinks: ImageLinksData::fromArray(data_get($data, 'imageLinks')),
);
}

public function toArray(): array
{
return [
'title' => $this->title,
'subtitle' => $this->subtitle,
// Преобразование коллекций в массивы, поскольку они реализуют
// интерфейс arrayable.
'authors' => $this->authors->toArray(),
'publisher' => $this->publisher,
'publishedDate' => $this->publishedDate,
'description' => $this->description,
'pageCount' => $this->pageCount,
'categories' => $this->categories->toArray(),
// Поскольку используется интерфейс arrayable, можно просто вызвать
// метод toArray для объекта imageLinks.
'imageLinks' => $this->imageLinks->toArray(),
];
}
}

Обратите внимание, что вместо массивов использовались коллекции Laravel. Я предпочитаю работать с коллекциями, поэтому всегда, когда в моих ответах есть массивы, вместо них использую коллекции. Кроме того, поскольку VolumeInfoData содержит свойство imageLinks, можно отобразить его с помощью объекта ImageLinksData.

Поднявшись ещё на один уровень, получаем список элементов, поэтому можем создать объект ItemData.

<?php

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Arrayable;

readonly class ItemData implements Arrayable
{
public function __construct(
public string $id,
public VolumeInfoData $volumeInfo,
) {
}

public static function fromArray(array $data): self
{
return new self(
id: data_get($data, 'id'),
volumeInfo: VolumeInfoData::fromArray(data_get($data, 'volumeInfo')),
);
}

public function toArray(): array
{
return [
'id' => $this->id,
'volumeInfo' => $this->volumeInfo->toArray(),
];
}
}

Наконец, нужно вернуться к исходному объекту BooksListData и вместо отображения массива данных отобразить коллекцию объектов ItemData.

<?php

namespace App\DataTransferObjects;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;

/**
* Хранит данные верхнего уровня из API томов Google Books.
*/

readonly class BooksListData implements Arrayable
{
public function __construct(
public string $kind,
/** @var Collection<int, ItemData> */
public Collection $items,
public int $totalItems,
) {
}

/**
* Создаёт новый экземпляр класса из массива данных.
*/

public static function fromArray(array $data): BooksListData
{
return new self(
data_get($data, 'kind'),
// Сопоставление элементов в коллекцию объектов ItemData.
collect(data_get($data, 'items', []))->map(fn (array $item) => ItemData::fromArray($item)),
data_get($data, 'totalItems'),
);
}

/**
* Реализует интерфейс Laravel Arrayable, позволяющий сериализовать
* объект в массив.
*/

public function toArray(): array
{
return [
'kind' => $this->kind,
'items' => $this->items->toArray(),
'totalItems' => $this->totalItems,
];
}
}

Создав все новые DTO, вернёмся к тесту и обновим его.

Тестирование полного DTO

<?php

use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;
use App\DataTransferObjects\ImageLinksData;
use App\DataTransferObjects\ItemData;
use App\DataTransferObjects\VolumeInfoData;
use Illuminate\Support\Facades\Http;

it('fetches books by title', function () {
$title = fake()->sentence();

// Генерируем фальшивый ответ от Google Books API.
$responseData = [
'kind' => 'books#volumes',
'totalItems' => 1,
'items' => [
[
'id' => fake()->uuid,
'volumeInfo' => [
'title' => $title,
'subtitle' => fake()->sentence(),
'authors' => [fake()->name],
'publisher' => fake()->company(),
'publishedDate' => fake()->date(),
'description' => fake()->paragraphs(asText: true),
'pageCount' => fake()->numberBetween(100, 500),
'categories' => [fake()->word],
'imageLinks' => [
'thumbnail' => fake()->url(),
],
],
],
],
];

// Возвращаем фальшивый ответ, когда клиент отправляет запрос к Google Books API.
Http::fake(['https://www.googleapis.com/books/v1/*' => Http::response(
body: $responseData,
status: 200
)]);

$response = resolve(QueryBooksByTitle::class)($title);

expect($response)->toBeInstanceOf(BooksListData::class)
->and($response->items->first())->toBeInstanceOf(ItemData::class)
->and($response->items->first()->volumeInfo)->toBeInstanceOf(VolumeInfoData::class)
->imageLinks->toBeInstanceOf(ImageLinksData::class)
->title->toBe($title);
});

Теперь в ожиданиях видно, что ответ отображает все различные DTO и корректно устанавливает заголовок.

Благодаря тому, что экшен возвращает DTO, а не стандартный Illuminate/Http/Client/Response, теперь есть безопасность типов для API ответа и улучшенное автозавершение в редакторе, что значительно улучшает работу разработчика.

Создание хелперов ответов теста

Ещё один приём для этого теста, который мне нравится, — создать что-то вроде фабрики ответов. Создание имитаций ответов в каждом тесте, который может понадобиться для запроса книг, отнимает много времени. Поэтому я предпочитаю создать простой трейт, который поможет мне имитировать ответы гораздо быстрее.

<?php

namespace Tests\Helpers;

use Illuminate\Support\Facades\Http;

trait GoogleBooksApiResponseHelpers
{
/**
* Генерируем фальшивый ответ для запроса книг по названию.
*/

private function fakeQueryBooksByTitleResponse(array $items = [], int $status = 200, bool $raw = false): void
{
// Если raw равно true, возвращается массив items как есть. В противном
// случае вернётся фальшивый ответ от Google Books API.
$data = $raw ? $items : [
'kind' => 'books#volumes',
'totalItems' => count($items),
'items' => array_map(fn (array $item) => $this->createItem($item), $items),
];

Http::fake(['https://www.googleapis.com/books/v1/*' => Http::response(
body: $data,
status: $status
)]);
}

// Создание фальшивого массива элементов.
private function createItem(array $data = []): array
{
return [
'id' => data_get($data, 'id', '123'),
'volumeInfo' => $this->createVolumeInfo(data_get($data, 'volumeInfo', [])),
];
}

// Создание фальшивого массива информации о томе.
private function createVolumeInfo(array $data = []): array
{
return [
'title' => data_get($data, 'title', fake()->sentence),
'subtitle' => data_get($data, 'subtitle', 'Book Subtitle'),
'authors' => data_get($data, 'authors', ['Author 1', 'Author 2']),
'publisher' => data_get($data, 'publisher', 'Publisher'),
'publishedDate' => data_get($data, 'publishedDate', '2021-01-01'),
'description' => data_get($data, 'description', 'Book description'),
'pageCount' => data_get($data, 'pageCount', 123),
'categories' => data_get($data, 'categories', ['Category 1', 'Category 2']),
'imageLinks' => data_get($data, 'imageLinks', ['thumbnail' => 'https://example.com/image.jpg']),
];
}
}

Чтобы использовать трейт в тесте Pest, достаточно воспользоваться методом uses.

uses(GoogleBooksApiResponseHelpers::class);

Теперь можно легко добавлять дополнительные тесты без необходимости прописывать все mock-данные в каждом тесте.

<?php

use App\Actions\QueryBooksByTitle;
use App\DataTransferObjects\BooksListData;
use App\DataTransferObjects\ImageLinksData;
use App\DataTransferObjects\ItemData;
use App\DataTransferObjects\VolumeInfoData;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Tests\Helpers\GoogleBooksApiResponseHelpers;

uses(GoogleBooksApiResponseHelpers::class);

it('fetches books by title', function () {
$title = fake()->sentence();

// Генерируем фальшивый ответ от Google Books API.
$this->fakeQueryBooksByTitleResponse([['volumeInfo' => ['title' => $title]]]);

$response = resolve(QueryBooksByTitle::class)($title);

expect($response)->toBeInstanceOf(BooksListData::class)
->and($response->items->first())->toBeInstanceOf(ItemData::class)
->and($response->items->first()->volumeInfo)->toBeInstanceOf(VolumeInfoData::class)
->imageLinks->toBeInstanceOf(ImageLinksData::class)
->title->toBe($title);
});

it('passes the title as a query parameter', function () {
$title = fake()->sentence();

// Генерируем фальшивый ответ от Google Books API.
$this->fakeQueryBooksByTitleResponse([['volumeInfo' => ['title' => $title]]]);

resolve(QueryBooksByTitle::class)($title);

Http::assertSent(function (Illuminate\Http\Client\Request $request) use ($title) {
expect($request)
->method()->toBe('GET')
->data()->toHaveKey('q', 'intitle:'.$title);

return true;
});
});

it('fetches a list of multiple books', function () {
// Генерируем фальшивый ответ от Google Books API.
$this->fakeQueryBooksByTitleResponse([
$this->createItem(),
$this->createItem(),
$this->createItem(),
]);

$response = resolve(QueryBooksByTitle::class)('Fake Title');

expect($response->items)->toHaveCount(3);
});

it('throws an exception', function () {
// Генерируем фальшивый ответ от Google Books API.
$this->fakeQueryBooksByTitleResponse([
$this->createItem(),
], 400);

resolve(QueryBooksByTitle::class)('Fake Title');
})->throws(RequestException::class);

Теперь у нас есть более чистые тесты, а ответы API сопоставлены с DTO. Для ещё большей оптимизации можно рассмотреть возможность использования пакета Laravel Data от Spatie для создания DTO, это поможет сократить часть шаблонного кода для создания методов fromArray и toArray.

Заключение

В этой статье мы рассмотрели, как упростить процесс разработки и тестирования API-интеграций в Laravel за счёт использования DTO.

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

Техники, рассмотренные здесь и в предыдущей статье, полезны для всех типов API интеграций, однако для более продвинутых решений рекомендую обратиться к PHP-библиотеке Saloon.

Надеюсь, эта заметка окажется полезной для ваших будущих проектов Laravel. Тем не менее обсуждение на этом не должно заканчиваться. Есть ли у вас дополнительные советы или альтернативные методы, которыми хотелось бы поделиться? Или, может быть, есть моменты, которые хотелось бы обсудить или которые нуждаются в разъяснении? Я с удовольствием выслушаю вашу точку зрения! Не стесняйтесь оставлять комментарии.

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

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

Упрощение интеграции API с фасадом Http в Laravel

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

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