Упрощение интеграции API с фасадом Http в Laravel
Серия статей "Интеграция сторонних API в Laravel":
- Упрощение интеграции API с фасадом Http в Laravel
- Оптимизация API ответов в Laravel с DTO
- Создание API ресурсов в Laravel
- Обработка ошибок при работе со сторонними API
В последнее время я много работаю над интеграцией 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 ресурсов.
Спасибо за прочтение!