IntroductionHandling API responses effectively is crucial for integrating third-party APIs. In my previous post, I discussed setting up simple client and request classes using the Http facade. If that’s not something that you’ve already read, I recommend you take a look at it.
Amplifying these concepts, this post brings you an in-depth guide on how to create custom data transfer objects (DTOs) that can map the data from the API responses. I’ll be using an ongoing Google Books API integration scenario as a practical example to make things more relatable.
Map the Response Data to a DTO
To start, let’s look at a sample response from the Google Books API when we perform a search. To do this, I call the QueryBooksByTitle action I created previously and search for the book “The Ferryman”:
$response = app(QueryBooksByTitle::class)(“The Ferryman”);
dump($response->json());
This dumps out the following JSON which I have narrowed down to fields I’d like to track:
[
‘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:
‘thumbnail’ => ‘http:
],
…
],
…
],
…
]
Now that we know the format of the response, let’s create the necessary DTOs to map the data. Let’s start with BookListData which can be a simple PHP class.
<?php
namespace AppDataTransferObjects;
use IlluminateContractsSupportArrayable;
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’),
);
}
public function toArray(): array
{
return [
‘kind’ => $this->kind,
‘items’ => $this->items,
‘totalItems’ => $this->totalItems,
];
}
}
With the DTO created, we can update the QueryBooksByTitle action that we created in the previous post.
<?php
namespace AppActions;
use AppDataTransferObjectsBooksListData;
use AppSupportApiRequest;
use AppSupportGoogleBooksApiClient;
use IlluminateHttpClientResponse;
class QueryBooksByTitle
{
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());
}
}
Test the Response Data
We can create a test to make sure we return a BooksListData object when calling the action:
<?php
use AppActionsQueryBooksByTitle;
use AppDataTransferObjectsBooksListData;
it(‘fetches books by title’, function () {
$title = ‘The Lord of the Rings’;
$response = resolve(QueryBooksByTitle::class)($title);
expect($response)->toBeInstanceOf(BooksListData::class);
});
You might not have noticed, but there is an issue with the test above. We are reaching out to the Google Books API. This might be okay for an integration test that is not run often, but in our Laravel tests, this should be fixed. We can use the power of the Http facade for this since our Client class is built using the facade.
Prevent HTTP Requests in Tests
The first step I like to do is make sure none of my tests are making external HTTP requests that I am not expecting. We can add Http::preventStrayRequests(); to the Pest.php file. Then, in any test using the Http facade to make a request, an exception will be thrown unless we mock the request.
<?php
use IlluminateFoundationTestingTestCase;
use IlluminateSupportFacadesHttp;
use TestsCreatesApplication;
uses(
TestCase::class,
CreatesApplication::class,
)
->beforeEach(function () {
Http::preventStrayRequests();
})
->in(‘Feature’);
If I run my QueryBooksByTitle test again, I now get a failed test that says:
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.
Now, let’s use the Http facade to fake the response.
<?php
use AppActionsQueryBooksByTitle;
use AppDataTransferObjectsBooksListData;
use IlluminateSupportFacadesHttp;
it(‘fetches books by title’, function () {
$title = fake()->sentence();
$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(),
],
],
],
],
];
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);
});
When running the test now, we no longer have the RuntimeException because we are faking the request using the Http::fake() method. The Http::fake() method is very flexible and can accept an array of items and different URLs. Depending on your application, you can just use ‘*’ instead of the full URL or even make it more specific and include query parameters or other dynamic URL data. You can even fake sequences of requests if needed. Refer to the Laravel docs for more information.
This test works great but there are still some improvements to be made.
Expand the DTOs
First, let’s look at the response data again. It’s nice that we map the top level of the response in the BooksListData object, but having items[0][‘volumeInfo’][‘title’]) is not very developer-friendly friendly and the IDE cannot provide any type of autocompletion. To fix this, we need to create more DTOs. It’s usually easiest to start with the lowest-level items that need to be mapped. In this case, that would be the imageLinks data from the response. Looking at the response from Google Books, it looks like that could contain a thumbnail and smallThumbnail properties. We’ll create an ImageLinksData object to map this.
<?php
namespace AppDataTransferObjects;
use IlluminateContractsSupportArrayable;
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,
];
}
}
From there, go up a level and we have the VolumeInfoData.
<?php
namespace AppDataTransferObjects;
use IlluminateContractsSupportArrayable;
use IlluminateSupportCollection;
readonly class VolumeInfoData implements Arrayable
{
public function __construct(
public string $title,
public string $subtitle,
// Using collections instead of arrays is a personal preference.
// It makes dealing with the data a little easier.
public Collection $authors,
public string $publisher,
public string $publishedDate,
public string $description,
public int $pageCount,
public Collection $categories,
// The image links are mapped by the ImageLinksData object.
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’)),
imageLinks: ImageLinksData::fromArray(data_get($data, ‘imageLinks’)),
);
}
public function toArray(): array
{
return [
‘title’ => $this->title,
‘subtitle’ => $this->subtitle,
‘authors’ => $this->authors->toArray(),
‘publisher’ => $this->publisher,
‘publishedDate’ => $this->publishedDate,
‘description’ => $this->description,
‘pageCount’ => $this->pageCount,
‘categories’ => $this->categories->toArray(),
‘imageLinks’ => $this->imageLinks->toArray(),
];
}
}
Notice instead of using arrays, I used Laravel’s collections instead. I prefer working with Collections so I make sure anytime I have arrays in my responses, I map to Collections instead. Also, since the VolumeInfoData contains the imageLinks property, we can map it using the ImageLinksData object.
Going up another level, we have the list of items, so we can create the ItemData object.
<?php
namespace AppDataTransferObjects;
use IlluminateContractsSupportArrayable;
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(),
];
}
}
Finally, we need to go back to the original BooksListData object and instead of mapping an array of data, we want to map a Collection of ItemData objects.
<?php
namespace AppDataTransferObjects;
use IlluminateContractsSupportArrayable;
use IlluminateSupportCollection;
readonly class BooksListData implements Arrayable
{
public function __construct(
public string $kind,
public Collection $items,
public int $totalItems,
) {
}
public static function fromArray(array $data): BooksListData
{
return new self(
data_get($data, ‘kind’),
collect(data_get($data, ‘items’, []))->map(fn (array $item) => ItemData::fromArray($item)),
data_get($data, ‘totalItems’),
);
}
public function toArray(): array
{
return [
‘kind’ => $this->kind,
‘items’ => $this->items->toArray(),
‘totalItems’ => $this->totalItems,
];
}
}
With all the new DTOs created, let’s go back to the test and update.
Test the Full DTO
<?php
use AppActionsQueryBooksByTitle;
use AppDataTransferObjectsBooksListData;
use AppDataTransferObjectsImageLinksData;
use AppDataTransferObjectsItemData;
use AppDataTransferObjectsVolumeInfoData;
use IlluminateSupportFacadesHttp;
it(‘fetches books by title’, function () {
$title = fake()->sentence();
$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(),
],
],
],
],
];
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);
});
Now in our expectations, we can see that the response is mapping all the various DTOs and correctly setting the title.
By having the action return the DTO versus the default Illuminate/Http/Client/Response, we now have type safety for the API response and get better autocompletion in the editor which greatly improves the developer experience.
Create Test Response Helpers
One other bonus tip for this test that I like to do is create something like a response factory. It is time-consuming to mock out the responses on every single test that you might need for querying books, so I prefer to create a simple trait that helps me mock the responses much quicker.
<?php
namespace TestsHelpers;
use IlluminateSupportFacadesHttp;
trait GoogleBooksApiResponseHelpers
{
private function fakeQueryBooksByTitleResponse(array $items = [], int $status = 200, bool $raw = false): void
{
$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’]),
];
}
}
To use the trait in a Pest test, we just need to use the uses method.
uses(GoogleBooksApiResponseHelpers::class);
With that, we can now easily add additional tests without needing to have all the mock data written in each test.
<?php
use AppActionsQueryBooksByTitle;
use AppDataTransferObjectsBooksListData;
use AppDataTransferObjectsImageLinksData;
use AppDataTransferObjectsItemData;
use AppDataTransferObjectsVolumeInfoData;
use IlluminateHttpClientRequestException;
use IlluminateSupportFacadesHttp;
use TestsHelpersGoogleBooksApiResponseHelpers;
uses(GoogleBooksApiResponseHelpers::class);
it(‘fetches books by title’, function () {
$title = fake()->sentence();
$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();
$this->fakeQueryBooksByTitleResponse([[‘volumeInfo’ => [‘title’ => $title]]]);
resolve(QueryBooksByTitle::class)($title);
Http::assertSent(function (IlluminateHttpClientRequest $request) use ($title) {
expect($request)
->method()->toBe(‘GET’)
->data()->toHaveKey(‘q’, ‘intitle:’.$title);
return true;
});
});
it(‘fetches a list of multiple books’, function () {
$this->fakeQueryBooksByTitleResponse([
$this->createItem(),
$this->createItem(),
$this->createItem(),
]);
$response = resolve(QueryBooksByTitle::class)(‘Fake Title’);
expect($response->items)->toHaveCount(3);
});
it(‘throws an exception’, function () {
$this->fakeQueryBooksByTitleResponse([
$this->createItem(),
], 400);
resolve(QueryBooksByTitle::class)(‘Fake Title’);
})->throws(RequestException::class);
With that, we now have cleaner tests, and our API responses are mapped to a DTO. For even more optimizations, you may consider using the Laravel Data package by Spatie to create the DTOs, it can help reduce some of the boilerplate code for having to create the fromArray and toArray methods.
Summary
In this post, you’ve learned how to streamline your process for developing and testing API integrations in Laravel by leveraging DTOs.
We explored the process of creating DTOs, mapping API responses to these DTOs, and developing test response helpers. This not only improved the readability of our code, but also facilitated a more type-safe, efficient, and testable development process.
The techniques discussed here and in my previous post are useful for all types of API integrations, however, for more advanced solutions, I recommend looking at the Saloon PHP library.
I hope this post proves beneficial in your future Laravel projects. Nevertheless, the discussion doesn’t have to end here. Do you have extra tips or alternative methods you’d like to share? Or perhaps there are points you’d like to discuss or need clarification on? I’d love to hear your perspective! Feel free to leave a comment.
Source: hashnode.com