Using controller-like classes and Laravel-data for efficient API communication
Welcome back to my series for Integrating Third-Party APIs in Laravel. In this post, I will discuss creating API Resources. An API resource in this case is like a RESTful controller. It adds CRUD-like methods for communicating with the API when dealing with a specific resource, like books, products, users, etc. If you haven’t read the previous posts, I suggest reading them first.
- Simplifying API Integration with Laravel's Http Facade
- Streamlining API Responses in Laravel with DTOs
In the first two parts of the series, I used the Google Books API for my examples. For simplification and to have more routes readily available without needing to setup OAuth 2.0, I will be using Fake Store API and querying products. I will also be using the Spatie Laravel-data package for my data-transfer objects (DTOs) instead of creating custom DTOs from simple PHP classes like I did in the previous posts. This helps to remove some of the boilerplate of defining fromArray
and toArray
methods.
Getting Started
In the previous posts in the series, we had an ApiRequest
class and ApiClient
class. To create the API client for the Fake Store API; we can extend the ApiClient
class.
<?php
namespace App\Support;
class StoreApiClient extends ApiClient
{
protected function baseUrl(): string
{
return config('services.store_api.url');
}
}
Since the Fake Store API does not have any authentication required, this class can be pretty simple. For the test, we can just make sure the base URL is getting set as expected.
<?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;
});
});
For this test, I manually set a test URL in my configuration. It is also possible to just use an .env.test
file to define the value if you prefer. For simple tests, I like the approach of defining the config in the test so I can easily see what was expected in the tests versus having to compare it to another file.
Now, let’s create our API resource. What I am calling an API resource is a simple class to treat the product resource similar to a REST controller in Laravel. The API resource will allow me to list products, show a product, create a product, update a product, and delete a product. In the previous post, we had an action for fetching books from the Google Books API, but by using a resource, I combined the various calls for a resource into a single class.
<?php
namespace App\ApiResources;
use App\Data\ProductData;
use App\Support\StoreApiClient;
use Spatie\LaravelData\DataCollection;
/**
* ApiResource for products.
*/
class ProductResource
{
/**
* Use dependency injection to get the StoreApiClient.
*/
public function __construct(private readonly StoreApiClient $client)
{
}
/**
* List all products.
*/
public function list()
{
...
}
/**
* Show a single product.
*/
public function show(int $id)
{
...
}
/**
* Create a new product.
*/
public function create($data)
{
...
}
/**
* Update a product.
*/
public function update(int $id, $data)
{
...
}
/**
* Delete a product.
*/
public function delete(int $id)
{
...
}
}
Filling out the API Resource
List Method
We’ll start with the list method. The first thing I like to do is model the data that we will be receiving from the API. I will use Laravel-data for this and create a ProductData
class. If you haven’t installed Laravel-data, install it with composer:
composer require spatie/laravel-data
The ProductData
class can be created manually by extending the Spatie\LaravelData\Data
class or by using Artisan:
php artisan make:data ProductData
Looking at the documentation for the Fake Store API, we can map that to a DTO like the following:
<?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,
) {}
}
Notice the $rating
property has a type of RatingData
. This is another DTO:
<?php
namespace App\Data;
use Spatie\LaravelData\Data;
class RatingData extends Data
{
public function __construct(
public float $rate,
public int $count,
) {}
}
Now, in the resource list
method, we can add the following to fetch the products and map them to a collection of ProductData
instances.
public function list(): DataCollection
{
// Create the request to the products endpoint.
$request = ApiRequest::get('/products');
// Send the request using the client.
$response = $this->client->send($request);
// Map the response to the ProductData DTO.
return ProductData::collection($response->json());
}
Looking at the API documentation for the Fake Store API, it is possible to limit the number of results and sort the results that are returned. We can map this using a DTO as well.
<?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();
}
}
The SortDirection
type is just a simple enum with asc
and desc
cases. I added a custom toArray
method which uses Laravel collections and the filter
method to remove any null properties from the array. This prevents sending things like ?sort=null
to the API.
Let’s add this to our list
method.
public function list(?ListProductsData $data = null): DataCollection
{
$request = ApiRequest::get('/products');
if ($data) {
// Add the ListProductsData to the query string of the request.
$request->setQuery($data->toArray());
}
$response = $this->client->send($request);
return ProductData::collection($response->json());
}
Now, if we want to request a list of five products in descending order, we can do something like the following:
$resource = resolve(ProductResource::class);
$requestData = new ListProductsData(
limit: 5,
sort: SortDirection::DESC,
);
$response = $resource->list($requestData);
Let’s add a few tests for this method.
<?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 () {
// Fake the response from the 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();
// Assert that the response is a collection of product data objects.
expect($response)
->toBeInstanceOf(DataCollection::class)
->count()->toBe(5)
->getIterator()->each->toBeInstanceOf(ProductData::class);
// Assert that a GET request was sent to the correct endpoint.
Http::assertSent(function (Request $request) {
expect($request)
->url()->toEndWith('/products')
->method()->toBe('GET');
return true;
});
});
it('limits and sorts products', function () {
// Fake the response from the API.
Http::fake([
'*/products?*' => Http::response([
$this->getFakeProduct(['id' => 3]),
$this->getFakeProduct(['id' => 2]),
$this->getFakeProduct(['id' => 1]),
]),
]);
$resource = resolve(ProductResource::class);
// Create a request data object with a three-item limit and descending direction.
$requestData = new ListProductsData(3, SortDirection::DESC);
$response = $resource->list($requestData);
// Assert that the response is a collection of product data objects.
expect($response)
->toBeInstanceOf(DataCollection::class)
->count()->toBe(3)
->getIterator()->each->toBeInstanceOf(ProductData::class);
// Assert that a GET request was sent to the correct endpoint with the correct query data.
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;
});
});
You’ll notice the uses(StoreApiTestHelper::class);
call in the test. That loads a simple trait to provide the getFakeProduct()
method which is used to generate fake product responses.
<?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),
]),
];
}
}
I like using traits like this in tests to try and make it easier to fake API responses. This can easily be expanded on, too, like I did in my previous post.
In the tests, we ensure the proper endpoints are being called and the expected responses are being returned.
Show and Delete Methods
I am combining the show
and delete
methods here since they will be very similar. According to the API documentation, they both return the product for the ID provided, so they have the same return value. They also accept an id
URL parameter to fetch the specific product.
/**
* Show a single product.
*/
public function show(int $id): ProductData
{
$request = ApiRequest::get("/products/{$id}");
$response = $this->client->send($request);
return ProductData::from($response->json());
}
/**
* Delete a product.
*/
public function delete(int $id): ProductData
{
$request = ApiRequest::delete("/products/$id");
$response = $this->client->send($request);
return ProductData::from($response->json());
}
Now, let’s add the following tests:
it('fetches a product', function () {
// Create a fake product
$fakeProduct = $this->getFakeProduct();
// Fake the response from the API.
Http::fake(["*/products/{$fakeProduct['id']}" => Http::response($fakeProduct)]);
$resource = resolve(ProductResource::class);
// Request a product
$response = $resource->show($fakeProduct['id']);
expect($response)
->toBeInstanceOf(ProductData::class)
->id->toBe($fakeProduct['id']);
// Assert that a GET request was sent to the correct endpoint with the correct method.
Http::assertSent(function (Request $request) use ($fakeProduct) {
expect($request)
->url()->toEndWith("/products/{$fakeProduct['id']}")
->method()->toBe('GET');
return true;
});
});
it('deletes a product', function () {
// Create a fake product
$fakeProduct = $this->getFakeProduct();
// Fake the response from the API.
Http::fake(["*/products/{$fakeProduct['id']}" => Http::response($fakeProduct)]);
$resource = resolve(ProductResource::class);
// Request a product
$response = $resource->delete($fakeProduct['id']);
expect($response)
->toBeInstanceOf(ProductData::class)
->id->toBe($fakeProduct['id']);
// Assert that a DELETE request was sent to the correct endpoint.
Http::assertSent(function (Request $request) use ($fakeProduct) {
expect($request)
->url()->toEndWith("/products/{$fakeProduct['id']}")
->method()->toBe('DELETE');
return true;
});
});
Create and Update Methods
For the create and update methods, we know we need to send data to the API. Sometimes it is possible to reuse the same DTO that the API returns, but oftentimes, I like to create dedicated DTOs for the request. So I will create the SaveProductData
DTO:
<?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,
) {}
}
For this API, this single DTO is sufficient for both the create
and update
methods, however, sometimes it is necessary to have a dedicated DTO for each method. For example, using this specific DTO for the update
method requires all the fields to be specified. However, you may want a DTO that allows optional properties and filters out the ones not set so you can easily update specific properties instead of everything.
With that in place, let’s update the create
method:
/**
* Create a new product.
*/
public function create(SaveProductData $data): ProductData
{
$request = ApiRequest::post('/products')->setBody($data->toArray());
$response = $this->client->send($request);
return ProductData::from($response->json());
}
The update method is similar but with an additional id
parameter and a PUT
request instead of a POST
request.
/**
* Update a product.
*/
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());
}
Just like the show
and delete
methods, we are returning a ProductData
instance for create
and update
.
Add the following tests for the methods.
it('creates a product', function () {
// Create a fake product
$fakeProduct = $this->getFakeProduct();
// Fake the response from the API.
Http::fake(["*/products" => Http::response($fakeProduct)]);
$resource = resolve(ProductResource::class);
// Data for creating a product.
$data = new SaveProductData(
title: $fakeProduct['title'],
price: $fakeProduct['price'],
description: $fakeProduct['description'],
category: $fakeProduct['category'],
image: $fakeProduct['image'],
);
// Request a product
$response = $resource->create($data);
expect($response)
->toBeInstanceOf(ProductData::class)
->id->toBe($fakeProduct['id']);
// Assert that a POST request was sent to the correct endpoint.
Http::assertSent(function (Request $request) {
expect($request)
->url()->toEndWith('/products')
->method()->toBe('POST');
return true;
});
});
it('updates a product', function () {
// Create a fake product
$fakeProduct = $this->getFakeProduct();
// Fake the response from the 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'],
);
// Request a product
$response = $resource->update($fakeProduct['id'], $data);
expect($response)
->toBeInstanceOf(ProductData::class)
->id->toBe($fakeProduct['id']);
// Assert that a PUT request was sent to the correct endpoint.
Http::assertSent(function (Request $request) use ($fakeProduct) {
expect($request)
->url()->toEndWith("/products/{$fakeProduct['id']}")
->method()->toBe('PUT');
return true;
});
});
Summary
In this post, we discussed a method of combining requests related to a resource into a single class. When using the combination of Laravel’s Http
facade and data transfer objects, the methods of these classes can be made similar to a Laravel controller and be kept small and concise. I hope you enjoyed this series on integrating third-party APIs in Laravel. You can view the repository with all the code we went through in this post, here.
Thanks for reading and as always, feel free to comment or ask questions!
Top comments (0)