https://hashnode.com/utility/r?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1699585660555%2F4345ca45-14b3-45cd-ac5a-824e7fe8ee60.png%3Fw%3D1200%26h%3D630%26fit%3Dcrop%26crop%3Dentropy%26auto%3Dcompress%2Cformat%26format%3Dwebp%26fm%3Dpng
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' => env('GOOGLE_BOOKS_API_URL'),
'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;
class ApiRequest
{
protected array $headers = [];
protected array $query = [];
protected array $body = [];
public function __construct(protected HttpMethod $method = HttpMethod::GET, protected string $uri = '')
{
}
public function setHeaders(array|string $key, string $value = null): static
{
if (is_array($key)) {
$this->headers = $key;
} else {
$this->headers[$key] = $value;
}
return $this;
}
public function clearHeaders(string $key = null): static
{
if ($key) {
unset($this->headers[$key]);
} else {
$this->headers = [];
}
return $this;
}
public function setQuery(array|string $key, string $value = null): static
{
if (is_array($key)) {
$this->query = $key;
} else {
$this->query[$key] = $value;
}
return $this;
}
public function clearQuery(string $key = null): static
{
if ($key) {
unset($this->query[$key]);
} else {
$this->query = [];
}
return $this;
}
public function setBody(array|string $key, string $value = null): static
{
if (is_array($key)) {
$this->body = $key;
} else {
$this->body[$key] = $value;
}
return $this;
}
public function clearBody(string $key = null): static
{
if ($key) {
unset($this->body[$key]);
} else {
$this->body = [];
}
return $this;
}
public function getHeaders(): array
{
return $this->headers;
}
public function getQuery(): array
{
return $this->query;
}
public function getBody(): array
{
return $this->body;
}
public function getUri(): string
{
if (empty($this->query) || $this->method === HttpMethod::GET) {
return $this->uri;
}
return $this->uri.'?'.http_build_query($this->query);
}
public function getMethod(): HttpMethod
{
return $this->method;
}
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;
abstract class ApiClient
{
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()
);
}
protected function getBaseRequest(): PendingRequest
{
$request = Http::acceptJson()
->contentType('application/json')
->throw()
->baseUrl($this->baseUrl());
return $this->authorize($request);
}
protected function authorize(PendingRequest $request): PendingRequest
{
return $request;
}
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;
class GoogleBooksApiClient extends ApiClient
{
protected function baseUrl(): string
{
return config('services.google_books.base_url');
}
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;
class QueryBooksByTitle
{
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!
Laravel News Links