Simplifying API Integration with Laravel's Http Facade

Simplifying API Integration with Laravel's Http Facade

Building a reusable API request and client class

·

13 min read

I’ve been working a lot lately integrating third-party APIs. There are several different approaches to this such as using the third-party provided SDK. However, I feel sticking to Laravel’s Http facade is often a better choice. By using the Http facade, all third-party integrations can have a similar structure, and testing and mocking becomes a lot easier. Also, your application will have fewer dependencies. You won’t have to worry about keeping the SDK up to date or figuring out what to do if the SDK is no longer supported.

In this post, we will explore integrating the Google Books API. I will create a reusable client and request class to make using the API very simple. In future posts, I will go into more detail about testing, mocking, as well as creating API resources.

Let’s get started!

Add Google Books Configuration to Laravel

Now that we have an API key, we can add it to the .env along with the API URL.

GOOGLE_BOOKS_API_URL=https://www.googleapis.com/books/v1
GOOGLE_BOOKS_API_KEY=[API KEY FROM GOOGLE]

For this example, I am storing an API key that I obtained from the Google Cloud console, though it is not needed for the parts of the API we will be accessing. For more advanced API usage, you would need to integrate with Google’s OAuth 2.0 server and create a client ID and secret that could also be stored in the .env file. This is beyond the scope of this post.

With the environment variables in place, open the config/services.php file and add a section for Google Books.

'google_books' => [
    // Base URL for the Google Books API, retrieved from the .env
    'base_url' => env('GOOGLE_BOOKS_API_URL'),
    // API key for the Google Books API, retrieved from the .env
    'api_key' => env('GOOGLE_BOOKS_API_KEY'),
],

Create an ApiRequest Class

When making requests to the API, I find it easiest to use a simple class to be able to set any request properties I need.

Below is an example of an ApiRequest class that I use to pass in URL information along with the body, headers, and any query parameters. This class can easily be modified or extended to add additional functionality.

<?php

namespace App\Support;

/**
 * The ApiRequest class is a utility for building HTTP requests to an API.
 * It provides methods for setting the HTTP method, URI, headers, query
 * parameters, and body of the request.
 * It also provides methods for getting these properties, as well as for
 * clearing the headers, query parameters, and body.
 * Additionally, it provides static methods for creating ApiRequest instances
 * for specific HTTP methods.
 */
class ApiRequest
{
    // Store the headers that will be sent with the API request.
    protected array $headers = [];

    // Store any query string parameters.
    protected array $query = [];

    // Store the body of the request.
    protected array $body = [];

    /**
     * Create an API request for a given HTTP method and URI.
     */
    public function __construct(protected HttpMethod $method = HttpMethod::GET, protected string $uri = '')
    {
    }

    /**
     * Set headers for the request.
     * This accepts either a key and value, or an array of key/value pairs.
     */
    public function setHeaders(array|string $key, string $value = null): static
    {
        if (is_array($key)) {
            $this->headers = $key;
        } else {
            $this->headers[$key] = $value;
        }

        return $this;
    }

    /**
     * Clear headers for the request.
     * This method can clear a specific header or all headers in the request if
     * a key is not provided.
     */
    public function clearHeaders(string $key = null): static
    {
        if ($key) {
            unset($this->headers[$key]);
        } else {
            $this->headers = [];
        }

        return $this;
    }

    /**
     * Set query parameters for the request.
     * This accepts either a key and value, or an array of key/value pairs.
     */
    public function setQuery(array|string $key, string $value = null): static
    {
        if (is_array($key)) {
            $this->query = $key;
        } else {
            $this->query[$key] = $value;
        }

        return $this;
    }

    /**
     * Clear query parameters for the request.
     * This method can clear a specific parameter or all parameters if a key is
     * not provided.
     */
    public function clearQuery(string $key = null): static
    {
        if ($key) {
            unset($this->query[$key]);
        } else {
            $this->query = [];
        }

        return $this;
    }

    /**
     * Set body data for the request.
     * This accepts either a key and value, or an array of key/value pairs.
     */
    public function setBody(array|string $key, string $value = null): static
    {
        if (is_array($key)) {
            $this->body = $key;
        } else {
            $this->body[$key] = $value;
        }

        return $this;
    }

    /**
     * Clear body data for the request.
     * This method can clear a specific key of data or all data.
     */
    public function clearBody(string $key = null): static
    {
        if ($key) {
            unset($this->body[$key]);
        } else {
            $this->body = [];
        }

        return $this;
    }

    /**
     * This method returns the headers for the API request.
     */
    public function getHeaders(): array
    {
        return $this->headers;
    }

    /**
     * This method returns the query for the API request.
     */
    public function getQuery(): array
    {
        return $this->query;
    }

    /**
     * This method returns the body for the API request.
     */
    public function getBody(): array
    {
        return $this->body;
    }

    /**
     * This method returns the URI for the API request.
     * If the query is empty, or we have a GET request, the URI can be returned
     * as is.
     * Otherwise, we need to append the query string to the URI.
     */
    public function getUri(): string
    {
        if (empty($this->query) || $this->method === HttpMethod::GET) {
            return $this->uri;
        }

        return $this->uri.'?'.http_build_query($this->query);
    }

    /**
     * This method returns the HTTP method for the API request.
     */
    public function getMethod(): HttpMethod
    {
        return $this->method;
    }

    // The following methods are used to create API requests for specific HTTP
    // methods.

    public static function get(string $uri = ''): static
    {
        return new static(HttpMethod::GET, $uri);
    }

    public static function post(string $uri = ''): static
    {
        return new static(HttpMethod::POST, $uri);
    }

    public static function put(string $uri = ''): static
    {
        return new static(HttpMethod::PUT, $uri);
    }

    public static function delete(string $uri = ''): static
    {
        return new static(HttpMethod::DELETE, $uri);
    }
}

The class constructor takes an HttpMethod, which is just a simple enum with the various HTTP methods, and a URI.

enum HttpMethod: string
{
    case GET = 'get';
    case POST = 'post';
    case PUT = 'put';
    case DELETE = 'delete';
}

There are helper methods to create the request using the HTTP method name and passing a URI. Finally, there are methods to add and clear headers, query parameters, and body data.

Create an API Client

Now that we have the request, we need an API client to send it. This is where we can use the Http facade.

Abstract ApiClient

First, we’ll create an abstract ApiClient class that will be extended by our various APIs.

<?php

namespace App\Support;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

/**
 * The ApiClient class is an abstract base class for making HTTP requests to an
 * API.
 * It provides a method for sending an ApiRequest and methods for getting and
 * authorizing a base request.
 * Subclasses must implement the baseUrl method to specify the base URL for the
 * API.
 */
abstract class ApiClient
{
    /**
     * Send an ApiRequest to the API and return the response.
     */
    public function send(ApiRequest $request): Response
    {
        return $this->getBaseRequest()
            ->withHeaders($request->getHeaders())
            ->{$request->getMethod()->value}(
                $request->getUri(),
                $request->getMethod() === HttpMethod::GET
                    ? $request->getQuery()
                    : $request->getBody()
            );
    }

    /**
     * Get a base request for the API.
     * This method has some helpful defaults for API requests.
     * The base request is a PendingRequest with JSON acceptance, a content type
     * of 'application/json', and the base URL for the API.
     * It also throws exceptions for non-successful responses.
     */
    protected function getBaseRequest(): PendingRequest
    {
        $request = Http::acceptJson()
            ->contentType('application/json')
            ->throw()
            ->baseUrl($this->baseUrl());

        return $this->authorize($request);
    }

    /**
     * Authorize a request for the API.
     * This method is intended to be overridden by subclasses to provide
     * API-specific authorization.
     * By default, it simply returns the given request.
     */
    protected function authorize(PendingRequest $request): PendingRequest
    {
        return $request;
    }

    /**
     * Get the base URL for the API.
     * This method must be implemented by subclasses to provide the base URL for
     * the API.
     */
    abstract protected function baseUrl(): string;
}

This class has a getBaseRequest method that sets up some sane defaults using the Http facade to create a PendingRequest. It calls the authorize method which we can override in our Google Books implementation to set our API key.

The baseUrl method is just a simple abstract method that our Google Books class will set to use the Google Books API URL we set earlier.

Finally, the send method is what sends the request to the API. It takes an ApiRequest parameter to build up the request, then returns the response.

GoogleBooksApiClient

With the abstract client created, we can now create a GoogleBooksApiClient to extend it.

<?php

namespace App\Support;

use Illuminate\Http\Client\PendingRequest;

/**
 * The GoogleBooksApiClient class is a concrete implementation of the ApiClient
 * base class for the Google Books API.
 * It provides methods for getting the base URL and authorizing a request for
 * the Google Books API.
 */
class GoogleBooksApiClient extends ApiClient
{
    /**
     * Get the base URL for the Google Books API.
     * The base URL is retrieved from the 'services.google_books.base_url'
     * configuration value.
     */
    protected function baseUrl(): string
    {
        return config('services.google_books.base_url');
    }

    /**
     * Authorize a request for the Google Books API.
     * The Google Books API accepts the API key as a query parameter named
     * 'key'.
     * The API key is retrieved from the 'services.google_books.api_key'
     * configuration value.
     */
    protected function authorize(PendingRequest $request): PendingRequest
    {
        return $request->withQueryParameters([
            'key' => config('services.google_books.api_key'),
        ]);
    }
}

In this class, we just need to set the base URL and configure the authorization. For the Google Books API, that means passing the API key as a URL parameter and setting an empty Authorization header.

If we had an API that used a bearer authorization, we could have an authorize method like the following:

protected function authorize(PendingRequest $request): PendingRequest
{
    return $request->withToken(config(services.someApi.token));
}

The nice part about having this authorize method is the flexibility it offers to support a variety of API authorization methods.

Query Books By Title

Now that we have our ApiRequest class and GoogleBooksApiClient, we can create an action to query books by title. It would look something like this:

<?php

namespace App\Actions;

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

/**
 * The QueryBooksByTitle class is an action for querying books by title from the
 * Google Books API.
 * It provides an __invoke method that takes a title and returns the response
 * from the API.
 */
class QueryBooksByTitle
{
    /**
     * Query books by title from the Google Books API and return the response.
     * This method creates a GoogleBooksApiClient and an ApiRequest for the
     * 'volumes' endpoint
     * with the given title as the 'q' query parameter and 'books' as the
     * 'printType' query parameter.
     * It then sends the request using the client and returns the response.
     */
    public function __invoke(string $title): Response
    {
        $client = app(GoogleBooksApiClient::class);

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

        return $client->send($request);
    }
}

Then, to call the action, if I wanted to find information about the book The Ferryman, which I just read and highly recommend, use the following snippet:

use App\Actions\QueryBooksByTitle;

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

$response->json();

Bonus: Tests

Below, I added some examples for testing the request and client classes. For the tests, I am using Pest PHP which provides a clean syntax and additional features on top of PHPUnit.

ApiRequest

<?php

use App\Support\ApiRequest;
use App\Support\HttpMethod;

it('sets request data properly', function () {
    $request = (new ApiRequest(HttpMethod::GET, '/'))
        ->setHeaders(['foo' => 'bar'])
        ->setQuery(['baz' => 'qux'])
        ->setBody(['quux' => 'quuz']);

    expect($request)
        ->getHeaders()->toBe(['foo' => 'bar'])
        ->getQuery()->toBe(['baz' => 'qux'])
        ->getBody()->toBe(['quux' => 'quuz'])
        ->getMethod()->toBe(HttpMethod::GET)
        ->getUri()->toBe('/');
});

it('sets request data properly with a key->value', function () {
    $request = (new ApiRequest(HttpMethod::GET, '/'))
        ->setHeaders('foo', 'bar')
        ->setQuery('baz', 'qux')
        ->setBody('quux', 'quuz');

    expect($request)
        ->getHeaders()->toBe(['foo' => 'bar'])
        ->getQuery()->toBe(['baz' => 'qux'])
        ->getBody()->toBe(['quux' => 'quuz'])
        ->getMethod()->toBe(HttpMethod::GET)
        ->getUri()->toBe('/');
});

it('clears request data properly', function () {
    $request = (new ApiRequest(HttpMethod::GET, '/'))
        ->setHeaders(['foo' => 'bar'])
        ->setQuery(['baz' => 'qux'])
        ->setBody(['quux' => 'quuz']);

    $request->clearHeaders()
        ->clearQuery()
        ->clearBody();

    expect($request)
        ->getHeaders()->toBe([])
        ->getQuery()->toBe([])
        ->getBody()->toBe([])
        ->getUri()->toBe('/');
});

it('clears request data properly with a key', function () {
    $request = (new ApiRequest(HttpMethod::GET, '/'))
        ->setHeaders('foo', 'bar')
        ->setQuery('baz', 'qux')
        ->setBody('quux', 'quuz');

    $request->clearHeaders('foo')
        ->clearQuery('baz')
        ->clearBody('quux');

    expect($request)
        ->getHeaders()->toBe([])
        ->getQuery()->toBe([])
        ->getBody()->toBe([])
        ->getUri()->toBe('/');
});

it('creates instance with correct method', function (HttpMethod $method) {
    $request = ApiRequest::{$method->value}('/');

    expect($request->getMethod())->toBe($method);
})->with([
    [HttpMethod::GET],
    [HttpMethod::POST],
    [HttpMethod::PUT],
    [HttpMethod::DELETE],
]);

The ApiRequest tests check that the correct request data is being set and the correct methods are being used.

ApiClient

Testing for the ApiClient will be a little more complex. Since it is an abstract class, we will use an anonymous class in the beforeEach function to create a client to use that extends ApiClient.

Notice, that we also use the Http::fake() method. This creates mocks on the Http facade that we can make assertions against and prevent making API requests in the tests.

<?php

use App\Support\ApiClient;
use App\Support\ApiRequest;
use App\Support\HttpMethod;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    Http::fake();

    $this->client = new class extends ApiClient
    {
        protected function baseUrl(): string
        {
            return 'https://example.com';
        }
    };
});

it('sends a get request', function () {
    $request = ApiRequest::get('foo')
        ->setHeaders(['X-Foo' => 'Bar'])
        ->setQuery(['baz' => 'qux']);

    $this->client->send($request);

    Http::assertSent(static function (Request $request) {
        expect($request)
            ->url()->toBe('https://example.com/foo?baz=qux')
            ->method()->toBe(HttpMethod::GET->name)
            ->header('X-Foo')->toBe(['Bar']);

        return true;
    });
});

it('sends a post request', function () {
    $request = ApiRequest::post('foo')
        ->setBody(['foo' => 'bar'])
        ->setHeaders(['X-Foo' => 'Bar'])
        ->setQuery(['baz' => 'qux']);

    $this->client->send($request);

    Http::assertSent(static function (Request $request) {
        expect($request)
            ->url()->toBe('https://example.com/foo?baz=qux')
            ->method()->toBe(HttpMethod::POST->name)
            ->data()->toBe(['foo' => 'bar'])
            ->header('X-Foo')->toBe(['Bar']);

        return true;
    });
});

it('sends a put request', function () {
    $request = ApiRequest::put('foo')
        ->setBody(['foo' => 'bar'])
        ->setHeaders(['X-Foo' => 'Bar'])
        ->setQuery(['baz' => 'qux']);

    $this->client->send($request);

    Http::assertSent(static function (Request $request) {
        expect($request)
            ->url()->toBe('https://example.com/foo?baz=qux')
            ->method()->toBe(HttpMethod::PUT->name)
            ->data()->toBe(['foo' => 'bar'])
            ->header('X-Foo')->toBe(['Bar']);

        return true;
    });
});

it('sends a delete request', function () {
    $request = ApiRequest::delete('foo')
        ->setBody(['foo' => 'bar'])
        ->setHeaders(['X-Foo' => 'Bar'])
        ->setQuery(['baz' => 'qux']);

    $this->client->send($request);

    Http::assertSent(static function (Request $request) {
        expect($request)
            ->url()->toBe('https://example.com/foo?baz=qux')
            ->method()->toBe(HttpMethod::DELETE->name)
            ->data()->toBe(['foo' => 'bar'])
            ->header('X-Foo')->toBe(['Bar']);

        return true;
    });
});

it('handles authorization', function () {
    $client = new class extends ApiClient
    {
        protected function baseUrl(): string
        {
            return 'https://example.com';
        }

        protected function authorize(PendingRequest $request): PendingRequest
        {
            return $request->withHeaders(['Authorization' => 'Bearer foo']);
        }
    };

    $request = ApiRequest::get('foo');

    $client->send($request);

    Http::assertSent(static function (Request $request) {
        expect($request)->header('Authorization')->toBe(['Bearer foo']);

        return true;
    });
});

For the tests, we are confirming that the request properties are being set correctly on the various request methods. We also confirm the baseUrl and authorize methods are being called correctly. To make these assertions, we are using the Http::assertSent method which expects a callback with a $request that we can test against. Notice that I am using the PestPHP expectations and then returning true. We could just use a normal comparison and return that, but by using the expectations, we get much cleaner error messages when the tests fail. Read this excellent article for more information.

GoogleBooksApiClientTest

The test for the GoogleBooksApiClient is similar to the ApiClient test where we just want to make sure our custom implementation details are being handled properly, like setting the base URL and adding a query parameter with the API key.

Also, not the config helper in the beforeEach method. By using the helper, we can set test values for the Google Books service config that will be used in each of our tests.

<?php

use App\Support\ApiRequest;
use App\Support\GoogleBooksApiClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Request;

beforeEach(function () {
    Http::fake();
    config([
        'services.google_books.base_url' => 'https://example.com',
        'services.google_books.api_key' => 'foo',
    ]);
});

it('sets the base url', function () {
    $request = ApiRequest::get('foo');

    app(GoogleBooksApiClient::class)->send($request);

    Http::assertSent(static function (Request $request) {
        expect($request)->url()->toStartWith('https://example.com/foo');

        return true;
    });
});

it('sets the api key as a query parameter', function () {
    $request = ApiRequest::get('foo');

    app(GoogleBooksApiClient::class)->send($request);

    Http::assertSent(static function (Request $request) {
        expect($request)->url()->toContain('key=foo');

        return true;
    });
});

Summary

In this article, we covered some helpful steps for integrating third-party APIs in Laravel. By using these simple custom classes, along with the Http facade, we can ensure all integrations function similarly, are easier to test, and don’t require any project dependencies. In a later post, I will expand on these integration tips by covering DTOs, testing with mock responses, and using API resources.

Thanks for reading!

Did you find this article valuable?

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