Building a reusable API request and client class
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!
Top comments (0)