Работа со сторонними сервисами в Laravel

Источник: «Working with third party services in laravel»
Итак, чуть более двух лет назад я написал руководство о том, как работать со сторонними сервисами в Laravel. На сегодня это самая посещаемая страниц на моём сайте. Однако за последние два года всё изменилось, и я решил снова обратиться к этой теме.

Итак, я так долго работаю со сторонними сервисами, что не могу вспомнить, когда не работал с ними. Будучи Junior Developer, я интегрировал API в другие платформы, такие как Joomla, Magneto и WordPress. Теперь он в основном интегрируется в мои Laravel приложения для расширения бизнес-логики за счёт использования других сервисов.

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

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

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

Что даст эта интеграция? Представьте, что у нас есть приложение, которое позволяет управлять инфраструктурой. Серверы работают через Laravel Forge, а база данных на Planetscale. Не существует простого процесса управления этим рабочим процессом, поэтому мы создадим свой. Для этого нужна интеграция или две.

Изначально я хранил свои интеграции в app/Services. Однако поскольку мои приложения стали более обширными и сложными, мне пришлось использовать пространство имён Services для внутренних служб, что привело к загрязнению пространства имён. Я переместил свои интеграции в app/Http/Integrations. Это имеет смысл, и это трюк, который я почерпнул из Saloon Сэма Карре.

Теперь я могу использовать Saloon для своей API-интеграции, но я хотел бы объяснить, как я делаю это без пакета. Если вам нужна интеграции API в 2023 году, я настоятельно рекомендую использовать Saloon. Он более чем удивительный!

Итак, начнём с создания каталога для нашей интеграции. Вы можете использовать следующую команду:

mkdir app/Http/Integrations/Planetscale

Когда у нас есть каталог Planetscale, нужно создать способ подключения к нему. Ещё одно соглашение об именах, которое я взял из библиотеки Saloon, заключается в том, чтобы рассматривать эти базовые классы как коннекторы, поскольку их цель — позволить вам подключаться к определённому API или третьей стороне.

Создайте новый класс под названием PlanetscaleConnector в каталоге app/Http/Integrations/Planetscale, и мы сможем уточнить, что нужно этому классу, это будет весело.

Поэтому мы должны зарегистрировать этот класс в нашем контейнере, чтобы разрешить его или построить вокруг него фасад. Мы могли бы зарегистрировать это длинным путём в Сервис Провайдере, но мой подход заключается в том, чтобы коннекторы регистрировались сами по себе — вроде …

declare(strict_types=1);

namespace App\Http\Integrations\Planetscale;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

final readonly class PlanetscaleConnector
{
public function __construct(
private PendingRequest $request,
) {}

public static function register(Application $app): void
{
$app->bind(
abstract: PlanetscaleConnector::class,
concrete: fn () => new PlanetscaleConnector(
request: Http::baseUrl(
url: '',
)->timeout(
seconds: 15,
)->withHeaders(
headers: [],
)->asJson()->acceptJson(),
),
);
}
}

Итак, идея здесь в том, что вся информация о том, как этот класс регистрируется в контейнере, находится внутри самого класса. Всё, что нужно сделать сервис провайдеры — это вызвать метод статической регистрации в классе! Это сэкономило много времени при интеграции со многими API, потому что не нужно искать провайдер и находить правильную привязку среди многих других. Я иду к рассматриваемому классу, который весь передо мной.

Вы могли заметить, что в настоящее время мы ничего не передаём методам токена или базового URL-адреса в запросе. Давайте исправим это дальше. Вы можете получить их в своей учётной записи Planetscale.

Создайте следующие записи в .env файле.

PLANETSCALE_SERVICE_ID="your-service-id-goes-here"
PLANETSCALE_SERVICE_TOKEN="your-token-goes-here"
PLANETSCALE_URL="https://api.planetscale.com/v1"

Затем их нужно втянуть в конфигурацию приложения. Все они находятся в config/services.php, поскольку именно здесь обычно настраиваются сторонние сервисы.

return [
// the rest of your services config

'planetscale' => [
'id' => env('PLANETSCALE_SERVICE_ID'),
'token' => env('PLANETSCALE_SERVICE_TOKEN'),
'url' => env('PLANETSCALE_URL'),
],
];

Теперь мы можем использовать их в нашем PlanetscaleConnector с помощью метода register

declare(strict_types=1);

namespace App\Http\Integrations\Planetscale;

use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

final readonly class PlanetscaleConnector
{
public function __construct(
private PendingRequest $request,
) {}

public static function register(Application $app): void
{
$app->bind(
abstract: PlanetscaleConnector::class,
concrete: fn () => new PlanetscaleConnector(
request: Http::baseUrl(
url: config('services.planetscale.url'),
)->timeout(
seconds: 15,
)->withHeaders(
headers: [
'Authorization' => config('services.planetscale.id') . ':' . config('services.planetscale.token'),
],
)->asJson()->acceptJson(),
),
);
}
}

Вам нужно отправить токены в Planetscale в следующем формате: service-id:service-token, поэтому мы не можем использовать метод withToken по умолчанию, поскольку он не позволяет настраивать его так, как нам нужно.

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

Таким образом, мы можем разделить их на две категории:

Давайте добавим в наш коннектор два новых метода, чтобы создать то, что нам нужно:

declare(strict_types=1);

namespace App\Http\Integrations\Planetscale;

use App\Http\Integrations\Planetscale\Resources\BackupResource;
use App\Http\Integrations\Planetscale\Resources\DatabaseResource;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

final readonly class PlanetscaleConnector
{
public function __construct(
private PendingRequest $request,
) {}

public function databases(): DatabaseResource
{
return new DatabaseResource(
connector: $this,
);
}

public function backups(): BackupResource
{
return new BackupResource(
connector: $this,
);
}

public static function register(Application $app): void
{
$app->bind(
abstract: PlanetscaleConnector::class,
concrete: fn () => new PlanetscaleConnector(
request: Http::baseUrl(
url: config('services.planetscale.url'),
)->timeout(
seconds: 15,
)->withHeaders(
headers: [
'Authorization' => config('services.planetscale.id') . ':' . config('services.planetscale.token'),
],
)->asJson()->acceptJson(),
),
);
}
}

Как вы видите, мы создали два новых метода, databases и backups. Они будут возвращать новые классы ресурсов, проходящие через коннектор. Теперь можно реализовать логику в классах ресурсов, но позже мы должны добавить ещё один метод в наш коннектор.

<?php

declare(strict_types=1);

namespace App\Http\Integrations\Planetscale\Resources;

use App\Http\Integrations\Planetscale\PlanetscaleConnector;

final readonly class DatabaseResource
{
public function __construct(
private PlanetscaleConnector $connector,
) {}

public function list()
{
//
}

public function regions()
{
//
}
}

Это наш DatabaseResource; теперь у нас есть заглушки методов, которые мы хотим реализовать. Вы можете сделать то же самое для BackupResource. Это будет что-то похожее.

Таким образом, результат могут быть разбиты на страницы в списке баз данных. Тем не менее я не буду касаться этого — я бы положился в этом на Saloon, так как его реализация для результатов с разбивкой на страницы просто фантастическая. В этом примере мы не будем касаться нумерации страниц. Прежде чем заполним DatabaseResource, необходимо добавить ещё один метод в PlanetscaleConnector, чтобы красиво отправлять запросы. Для этого я использую свой пакет juststeveking/http-helpers, в котором есть перечисление для всех типичных HTTP-методов используемых мною.

public function send(Method $method, string $uri, array $options = []): Response
{
return $this->request->send(
method: $method->value,
url: $uri,
options: $options,
)->throw();
}

Теперь, мы можем вернуться к DatabaseResource и начать заполнять логику для метода list.

declare(strict_types=1);

namespace App\Http\Integrations\Planetscale\Resources;

use App\Http\Integrations\Planetscale\PlanetscaleConnector;
use Illuminate\Support\Collection;
use JustSteveKing\HttpHelpers\Enums\Method;
use Throwable;

final readonly class DatabaseResource
{
public function __construct(
private PlanetscaleConnector $connector,
) {}

public function list(string $organization): Collection
{
try {
$response = $this->connector->send(
method: Method::GET,
uri: "/organizations/{$organization}/databases"
);
} catch (Throwable $exception) {
throw $exception;
}

return $response->collect('data');
}

public function regions()
{
//
}
}

Наш метод list() принимает параметр $organization для прохождения через организацию для получения списка баз банных. Затем мы используем его для отправки запроса на определённый URL-адрес через коннектор. Оборачивая эту в инструкцию try-catch, мы можем перехватывать потенциальные исключения из метода send коннекторов. Наконец, мы можем вернуть коллекцию из метода, чтобы работать с ней в приложении.

Мы можем углубиться в этот запрос, так как мы можем начать сопоставлять данные из массивов с чем-то более контекстуально полезным, используя DTO. Я писал об этом здесь, поэтому не буду повторять ещё раз.

Давайте быстро рассмотрим BackupResource, чтобы увидеть больше, чем просто запрос.

declare(strict_types=1);

namespace App\Http\Integrations\Planetscale\Resources;

use App\Http\Integrations\Planetscale\Entities\CreateBackup;
use App\Http\Integrations\Planetscale\PlanetscaleConnector;
use JustSteveKing\HttpHelpers\Enums\Method;
use Throwable;

final readonly class BackupResource
{
public function __construct(
private PlanetscaleConnector $connector,
) {}

public function create(CreateBackup $entity): array
{
try {
$response = $this->connector->send(
method: Method::POST,
uri: "/organizations/{$entity->organization}/databases/{$entity->database}/branches/{$entity->branch}",
options: $entity->toRequestBody(),
);
} catch (Throwable $exception) {
throw $exception;
}

return $response->json('data');
}
}

Метод create принимает класс сущности, который я использую для передачи данных через приложение там, где это необходимо. Это полезно, когда для URL-адреса требуется набор параметров, и нам нужно отправлять тело запроса.

Я не стал рассматривать тестирование, так как написал руководство о том, как тестировать конечные точки JSON:API с PestPHP, в котором есть аналогичные концепции для тестирования такой интеграции.

Используя этот подход, я могу создавать надёжные и расширяемые интеграции со сторонними сервисами. Он разбит на логические части, поэтому я могу справиться с объёмом логики. Обычно у меня бывает больше интеграций, поэтому часть логики можно разделить и извлечь в трейты для наследования поведения между интеграциями.

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

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

Vim: Подсчёт вхождений

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

Понимание генераторов TypeScript