Упрощение интеграции API с фасадом Http в Laravel

Источник: «Simplifying API Integration with Laravel's Http Facade»
Создание многократно используемого класса API-запроса и клиента.

Серия статей "Интеграция сторонних API в Laravel":

В последнее время я много работаю над интеграцией API сторонних разработчиков. Существует несколько различных подходов к этому, например, использование SDK, предоставляемого сторонними разработчиками. Однако я считаю, что использование Http фасада Laravel часто является лучшим выбором. Благодаря использованию Http фасада все сторонние интеграции имеют схожую структуру, а тестирование и мокинг становятся намного проще. Кроме того, у приложения будет меньше зависимостей. Не придётся беспокоиться об обновлении SDK или выяснять, что делать, если SDK больше не поддерживается.

В этой статье мы рассмотрим интеграцию Google Books API. Создадим многократно используемый клиент и класс запроса, чтобы сделать использование API простым. В следующих статьях я более подробно расскажу о тестировании, мокинге, а также о создании API ресурсов.

Добавление конфигурации Google Books в Laravel

Получив ключ API, можно добавить его в .env вместе с URL-адресом API.

GOOGLE_BOOKS_API_URL=https://www.googleapis.com/books/v1
GOOGLE_BOOKS_API_KEY=[API КЛЮЧ ОТ GOOGLE]

В этом примере я храню ключ API, полученный из консоли Google Cloud, хотя он не нужен для тех частей API, к которым мы будем обращаться. Для более продвинутого использования API необходимо интегрироваться с сервером Google OAuth 2.0 и создать идентификатор и секрет клиента, которые также можно хранить в файле .env. Это выходит за рамки данной статьи.

Установив переменные среды, откройте файл config/services.php и добавьте секцию для Google Books.

'google_books' => [
// Базовый URL для Google Books API, полученный из .env
'base_url' => env('GOOGLE_BOOKS_API_URL'),
// Ключ API для Google Books API, полученный из .env
'api_key' => env('GOOGLE_BOOKS_API_KEY'),
],

Создание класса ApiRequest

При выполнении запросов к API мне проще использовать простой класс, чтобы иметь возможность задавать любые необходимые свойства запроса.

Ниже приведён пример класса ApiRequest, используемого мной для передачи информации об URL-адресе вместе с телом, заголовками и любыми параметрами запроса. Этот класс легко модифицировать или расширить для добавления дополнительного функционала.

<?php

namespace App\Support;

/**
* Класс ApiRequest - это утилита для создания HTTP-запросов к API.
* Он предоставляет методы для установки HTTP-метода, URI, заголовков,
* параметров запроса и тела запроса.
* Он также предоставляет методы для получения этих свойств,
* а также для очистки заголовков, параметров запроса и тела.
* Кроме того, он предоставляет статические методы для создания
* экземпляров ApiRequest для определённых HTTP методов.
*/

class ApiRequest
{
// Хранит заголовки, которые будут отправлены вместе с API запросом.
protected array $headers = [];

// Хранит все параметры строки запроса.
protected array $query = [];

// Хранит тело запроса.
protected array $body = [];

/**
* Создание API запроса для заданного HTTP метода и URI.
*/

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;
}

/**
* Возвращает заголовки API запроса.
*/

public function getHeaders(): array
{
return $this->headers;
}

/**
* Возвращает запрос API запроса.
*/

public function getQuery(): array
{
return $this->query;
}

/**
* Возвращает тело API запроса.
*/

public function getBody(): array
{
return $this->body;
}

/**
* Возвращает URI API запроса.
* Если запрос пуст или у нас GET-запрос, URI может быть возвращён как есть.
* В противном случае нужно добавить строку запроса к URI.
*/

public function getUri(): string
{
if (empty($this->query) || $this->method === HttpMethod::GET) {
return $this->uri;
}

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

/**
* Возвращает HTTP метод API запроса.
*/

public function getMethod(): HttpMethod
{
return $this->method;
}

// Следующие методы используются для создания API-запросов для определённых
// HTTP методов.

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);
}
}

Конструктор класса принимает HttpMethod, представляющий собой простое перечисление различных HTTP-методов, и URI.

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

Есть хелперы для создания запроса с использованием имени HTTP метода и передачи URI. Наконец, есть методы для добавления и очистки заголовков, параметров запроса и данных тела.

Создание клиента API

Теперь, когда у нас есть запрос, нам нужен клиент API для его отправки. Здесь можно использовать фасад Http.

Абстрактный ApiClient

Сначала создадим абстрактный класс ApiClient, который будет расширяться различными API.

<?php

namespace App\Support;

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

/**
* Класс ApiClient - абстрактный базовый класс для выполнения HTTP запросов к
* API.
* Он предоставляет метод для отправки ApiRequest и методы для получения и
* авторизации базового запроса.
* Подклассы должны реализовать метод baseUrl, указывающий базовый URL для
* API.
*/

abstract class ApiClient
{
/**
* Отправка ApiRequest в API и возвращение ответа.
*/

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()
);
}

/**
* Получение базового запроса к API.
* Этот метод содержит несколько полезных настроек по умолчанию для API запросов.
* Базовый запрос - это PendingRequest с приёмом JSON, типом содержимого
* 'application/json' и базовым URL-адресом API.
* Он также выбрасывает исключения в случае неуспешных ответов.
*/

protected function getBaseRequest(): PendingRequest
{
$request = Http::acceptJson()
->contentType('application/json')
->throw()
->baseUrl($this->baseUrl());

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

/**
* Авторизация запроса к API.
* Этот метод должен переопределяться подклассами для обеспечения специфической
* для API авторизации.
* По умолчанию он просто возвращает заданный запрос.
*/

protected function authorize(PendingRequest $request): PendingRequest
{
return $request;
}

/**
* Получение базового URL к API.
* Этот метод должен быть реализован подклассами для предоставления базового URL
* к API.
*/

abstract protected function baseUrl(): string;
}

В этом классе есть метод getBaseRequest, устанавливающий некоторые разумные настройки по умолчанию с помощью Http фасада для создания PendingRequest. Он вызывает метод authorize, который можно переопределить в нашей реализации Google Books, чтобы установить наш ключ API.

Метод baseUrl — простой абстрактный метод, который класс Google Books устанавливает для использования URL-адреса Google Books API, заданного нами ранее.

Наконец, метод send отправляет запрос к API. Он получает параметр ApiRequest для формирования запроса, а затем возвращает ответ.

GoogleBooksApiClient

Создав абстрактный клиент, мы можем создать GoogleBooksApiClient для его расширения.

<?php

namespace App\Support;

use Illuminate\Http\Client\PendingRequest;

/**
* Класс GoogleBooksApiClient - это конкретная реализация базового класса
* ApiClient для API Google Books.
* Он предоставляет методы для получения базового URL и авторизации запроса
* к Google Books API.
*/

class GoogleBooksApiClient extends ApiClient
{
/**
* Получение базового URL-адреса для Google Books API.
* Базовый URL-адрес извлекается из параметра конфигурации
* 'services.google_books.base_url'.
*/

protected function baseUrl(): string
{
return config('services.google_books.base_url');
}

/**
* Авторизация запроса к Google Books API.
* API Google Books принимает ключ API в качестве параметра запроса
* с именем 'key'.
* Ключ API извлекается из параметра конфигурации
* 'services.google_books.api_key'.
*/

protected function authorize(PendingRequest $request): PendingRequest
{
return $request->withQueryParameters([
'key' => config('services.google_books.api_key'),
]);
}
}

В этом классе нужно задать базовый URL и настроить авторизацию. Для Google Books API это означает передачу API ключа в качестве URL параметра и установку пустого заголовка Authorization.

Если бы это был API, использующий bearer авторизацию, можно было бы использовать метод authorize, как показано ниже:

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

Приятным моментом в существовании этого метода авторизации является его гибкость, позволяющая поддерживать различные методы API авторизации.

Запрос книг по названию

Теперь, когда у нас есть класс ApiRequest и GoogleBooksApiClient, можно создать экшен для запроса книг по названию. Он будет выглядеть примерно так:

<?php

namespace App\Actions;

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

/**
* Класс QueryBooksByTitle - это экшен для запроса книг по названию из Google
* Books API.
* Он предоставляет метод __invoke, принимающий заголовок и возвращающий ответ
* от API.
*/

class QueryBooksByTitle
{
/**
* Запрос книг по названию из Google Books API и возврат ответа.
* Этот метод создаёт GoogleBooksApiClient и ApiRequest для конечной точки
* 'volumes' с заданным названием в качестве параметра запроса 'q' и 'books'
* в качестве параметра запроса 'printType'.
* Затем он отправляет запрос клиенту и возвращает ответ.
*/

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);
}
}

Затем, чтобы вызвать экшен, если необходимо найти информацию о книге The Ferryman, которую я только что прочитал и очень рекомендую, использовать следующий фрагмент:

use App\Actions\QueryBooksByTitle;

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

$response->json();

Бонус: Тесты

Ниже я добавил несколько примеров тестирования классов запроса и клиента. Для тестов используется Pest PHP, предоставляющий чистый синтаксис и дополнительные возможности поверх 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],
]);

Тесты ApiRequest проверяют, что задаются корректные данные запроса и используются корректные методы.

ApiClient

Тестирование ApiClient будет немного сложнее. Поскольку это абстрактный класс, будет использован анонимный класс в функции beforeEach, чтобы создать клиента, расширяющего ApiClient.

Обратите внимание, что также используется метод Http::fake(). Он создаёт моки фасада Http, по отношению к которым можно создавать утверждения и предотвращать выполнение API-запросов в тестах.

<?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;
});
});

В тестах мы проверяем правильность установки параметров запроса в различных методах запроса. Также проверяем правильность вызова методов baseUrl и authorize. Чтобы выполнить эти утверждения, используется метод Http::assertSent, ожидающий обратного вызова с $request, который мы можем протестировать. Обратите внимание, что я использую ожидания PestPHP и затем возвращаю true. Можно было бы просто использовать обычное сравнение и вернуть true, но, используя ожидания, получаем гораздо более чистые сообщения об ошибках, когда тесты не проходят.

GoogleBooksApiClientTest

Тест для GoogleBooksApiClient похож на тест ApiClient, где мы просто хотим убедиться, что детали реализации обрабатываются правильно, например, установка базового URL и добавление параметра запроса с ключом API.

Кроме того, в методе beforeEach нет хелпера config. Используя этот хелпер, можно задать тестовые значения для конфигурации сервиса Google Books, используемые в каждом из наших тестов.

<?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;
});
});

Заключение

В этой статье мы рассмотрели несколько полезных шагов по интеграции сторонних API в Laravel. Используя эти простые классы вместе с Http фасадом, можно гарантировать, что все интеграции будут работать одинаково, их будет легче тестировать и они не требуют никаких зависимостей. В одной из следующих статей я дополню эти советы по интеграции, рассказав о DTO, тестировании с помощью моков ответов и использовании API ресурсов.

Спасибо за прочтение!

Дополнительные материалы

Предыдущая Статья

Используйте EXISTS вместо COUNT при проверке существования записей

Следующая Статья

Оптимизация API ответов в Laravel с DTO