Creating API Resources in Laravel

Creating API Resources in Laravel

Using controller-like classes and Laravel-data for efficient API communication

·

10 min read

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.

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!

Did you find this article valuable?

Support Sean Kegel by becoming a sponsor. Any amount is appreciated!