Версионирование API в Laravel-приложениях
Версионирование API – важная и зачастую сложная задача, у которой скорее всего нет какого-то универсального решения. Рассказываю об одном из возможных подходов в приложениях на базе Laravel.
Впрочем, сразу оговорюсь – такой способ можно реализовать не только в Laravel-приложениях, а ещё он может подойти не всем. Внедрение описанного метода может потребовать большого количества рефакторинга, и в таком случае быстрее и проще будет пойти по пути копипастинга.
Кратко
Проверка версии в коде контроллера – неудобно; копирование контроллеров и запросов – получше, но по мере развития можно начать путаться.
Старайтесь версионировать только запросы и трансформеры и переиспользуйте контроллеры и/или обработчики для всех версий. Laravel даёт все возможности для этого из коробки.
Если нужно готовое решение, посмотрите на laniakea/laniakea.
Зачем версионировать API
Зачастую версионирование API требуется приложениям, когда наступают следующие события:
- API является публичным;
- У API есть какое-то количество потребителей, для которых крайне важна обратная совместимость;
- В структуры входных и/или выходных данных нужно внести обратно несовместимые изменения.
Разумеется, это не единственные причины, когда может потребоваться версионирование, но по моему опыту эта необходимость появлялась именно в этом случае.
Как можно версионировать API
В большинстве статей по тематике версионирования чаще выделяется один способ – создавать копии контроллеров,
запросов и обработчиков, и размещать их под префиксом новой версии (например, /v2
). В целом, способ из этой
статьи схож, однако, подход немного другой – я предлагаю (когда это возможно) версионировать только запросы
и трансформеры (то есть то, что генерирует ваш ответ API).
Принцип
Принцип достаточно прост на словах – обработчикам запросов к API не нужно знать о том, под какой версией они запускаются. Если они достаточно универсальны, чтобы обрабатывать запросы из разных версий, всю необходимую информацию им нужно предоставлять версионированными запросами.
Возьмём пример обработчика, который регистрирует покупателя в интернет-магазине. К нему приходит запрос с номером телефона, страной, именем и паролем покупателя. Обработчик должен создать запись, отправить SMS с кодом подтверждения регистрации и отдать успешный ответ на запрос.
<?php
namespace App\Http\Controllers;
use App\Jobs\SendRegistrationCode;
use App\Models\Customer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CustomerRegistrationApiController
{
public function store(Request $request): JsonResponse
{
$existedCustomer = Customer::where('phone', $request->input('phone_number'))->first();
if (!is_null($existedCustomer)) {
throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');
}
$customer = Customer::create([
'phone' => $request->input('phone_number'),
'phone_country' => $request->input('phone_country') ?? 'RU',
'password' => bcrypt($request->input('password')),
'name' => $request->input('name'),
'surname' => $request->input('surname'),
]);
dispatch(new SendRegistrationCode($customer));
return response()->json([
'created' => true,
'id' => $customer->id,
]);
}
}
Теперь предположим, что нам потребовалось переименовать некоторые поля. Вместо полей phone_number
и phone_country
мы хотим использовать объект phone
с полями phone.number
и phone.country
, вместо name
– first_name
, а
вместо surname
– last_name
. В ответе вместо поля id
мы хотим отправлять только номер телефона.
Посмотрим, как это можно сделать разными способами.
Первый способ – проверка версии
Для начала можно собирать поля, проверяя текущую версию. Если версия не указана или v1
, используем старые
поля, если версия равна v2
– берём данные из новых. Предположим, что номер версии передаётся в route-параметре.
<?php
namespace App\Http\Controllers;
use App\Jobs\SendRegistrationCode;
use App\Models\Customer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CustomerRegistrationApiController
{
public function store(Request $request, ?string $version = null): JsonResponse
{
$data = match (true) {
// Версия не указана или равна первой – используем старые поля.
is_null($version) || $version === 'v1' => [
'phone' => $request->input('phone_number'),
'phone_country' => $request->input('phone_country') ?? 'RU',
'password' => bcrypt($request->input('password')),
'name' => $request->input('name'),
'surname' => $request->input('surname'),
],
$version === 'v2' => [
'phone' => $request->input('phone.number'),
'phone_country' => $request->input('phone.country') ?? 'RU',
'password' => bcrypt($request->input('password')),
'name' => $request->input('first_name'),
'surname' => $request->input('last_name'),
],
default => throw new \InvalidArgumentException('Invalid data.'),
};
$existedCustomer = Customer::where('phone', $data['phone'])->first();
if (!is_null($existedCustomer)) {
throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');
}
$customer = Customer::create($data);
dispatch(new SendRegistrationCode($customer));
$response = match (true) {
is_null($version) || $version === 'v1' => [
'created' => true,
'id' => $customer->id,
],
$version === 'v2' => [
'phone' => $customer->phone,
],
};
return response()->json($response);
}
}
Технически это будет работать, и если у вас небольшой API, возможно, этого будет достаточно. Однако, чем больше контроллеров/обработчиков придётся так переписывать, тем сложнее их будет поддерживать и вводить новые версии.
К тому же, вы скорее всего будете использовать валидацию запроса. При таком подходе во все обязательные
поля придётся добавлять правила вида required_without:
, чтобы убедиться, что хотя бы одно
необходимое поле было передано (однако, это может пропустить запросы, где одни данные переданы в поле
от версии v1
, а другие – в поле от версии v2
).
Второй способ – копия контроллера (и запроса)
Так же можно просто скопировать код контроллера в новый и использовать его для регистрации покупателей в
версии v2
. Так мы избавимся от проверок и изолируем код конкретной версии в конкретном классе.
<?php
namespace App\Http\Controllers;
use App\Jobs\SendRegistrationCode;
use App\Models\Customer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CustomerRegistrationApiControllerV2
{
public function store(Request $request): JsonResponse
{
$existedCustomer = Customer::where('phone', $request->input('phone.number'))->first();
if (!is_null($existedCustomer)) {
throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');
}
$customer = Customer::create([
'phone' => $request->input('phone.number'),
'phone_country' => $request->input('phone.country') ?? 'RU',
'password' => bcrypt($request->input('password')),
'name' => $request->input('first_name'),
'surname' => $request->input('last_name'),
]);
dispatch(new SendRegistrationCode($customer));
return response()->json([
'phone' => $customer->phone,
]);
}
}
Код снова приятно читать, не нужно ветвиться при добавлении новой версии – достаточно сделать ещё одну копию.
Но тут может возникнуть другая проблема. В новой версии могут добавиться новые необязательные поля, которые
вряд ли будут нарушением обратной совместимости для предыдущей версии, поэтому их можно скопировать и в v1
.
Теперь вам нужно проверять, были ли портированы новые поля в старые версии, любые изменения в новой версии (по-хорошему) должны так же портироваться в предыдущую версию, чтобы сохранить единую логику работы.
Третий способ – использование интерфейсов
Именно этим способом я хочу поделиться.
Скорее всего вы знаете, что сервис контейнер Laravel позволяет связать интерфейс и имплементацию, после чего достаточно запрашивать нужный интерфейс, а не конкретный класс.
Знаете ли вы, что это работает и с запросами? Если у вас есть класс запроса (наследованный от
Illuminate\Http\FormRequest
) с правилами валидации и авторизации и он реализует интерфейс, вы можете внедрить
этот интерфейс в метод контроллера и Laravel выполнит проверку авторизации и валидацию данных точно так же,
как если бы вы внедрили класс запроса.
Такая возможность позволяет нам сделать два разных запроса для каждой версии API, описать в них свои правила валидации и реализовать общий интерфейс с геттерами данных. Затем запросить этот интерфейс в метод контроллера и убрать все проверки версии (и, соответственно, не копировать контроллеры).
В дополнение к этому вы можете создать интерфейс трансформера ответа API и возвращать необходимую структуру из реализаций для конкретных версий.
<?php
namespace App\Http\Controllers;
use App\Interfaces\Requests\RegisterCustomerRequestInterface;
use App\Interfaces\Transformers\CustomerRegisteredTransformerInterface;
use App\Jobs\SendRegistrationCode;
use App\Models\Customer;
use Illuminate\Http\JsonResponse;
class CustomerRegistrationApiController
{
public function store(
RegisterCustomerRequestInterface $request,
CustomerRegisteredTransformerInterface $transformer,
): JsonResponse {
$existedCustomer = Customer::where('phone', $request->getPhoneNumber())->first();
if (!is_null($existedCustomer)) {
throw new \InvalidArgumentException('Покупатель уже зарегистрирован!');
}
$customer = Customer::create([
'phone' => $request->getPhoneNumber(),
'phone_country' => $request->getPhoneCountry(),
'password' => bcrypt($request->getCustomerPassword()),
'name' => $request->getFirstName(),
'surname' => $request->getLastName(),
]);
dispatch(new SendRegistrationCode($customer));
return response()->json($transformer->toArray($customer));
}
}
С этим способом можно избежать неудобства предыдущих методов и упростить процесс добавления новых версий:
- В контроллере больше не нужно проверять какая версия используется в данный момент;
- Валидация происходит не в одном запросе, а в конкретном для конкретной версии API. То же самое относится к трансформерам – структура ответа может радикально отличаться от версии к версии, но контроллер всегда будет отдавать корректный ответ;
- Код регистрации покупателя выполняется в одном месте – если вы добавите новые опциональные поля, достаточно добавить новые методы в интерфейс и реализовать их внутри каждого запроса – не нужно копироать код между разными контроллерами;
- А если вы захотите добавить новое поле только для одной версии, в реализации старых версий можно возвращать
значения по умолчанию (например,
null
).
По мере развития проекта код контроллера можно вынести в отдельный класс-обработчик и, если сильно потребуется, класс-обработчик так же можно будет версионировать (но тогда может вернуться проблема копирования кода между версиями обработчиков).
Реализация в Laravel
Теперь посмотрим, как именно добавить такую систему версионирования в Laravel. Основная идея – ко всем роутам API (которые должны быть версионированы) нужно прикрепить идентификатор версии. Затем для каждой возможной версии нужно сопоставить требуемые интерфейсы и их реализации. Дальше останется использовать интерфейсы внутри контроллеров или обработчиках запросов к API.
Идентификатор версии
Самый простой вариант – сделать кастомный middleware, который будет добавлять в сервис контейнер приложения инстанс текущей версии.
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
class SetApiVersion
{
public function handle(Request $request, callable $next, string $version)
{
// Параметр `$version` будет передаваться из файлов роутов.
// С помощью app()->instance() мы добавляем в сервис контейнер приложения
// идентификатор текущей версии. Вы сможете запросить его в любом месте,
// которое будет выполняться после текущего middleware.
app()->instance('api.version', $version);
// Получить идентификатор версии теперь можно с помощью app()->get('api.version').
// Версия API установлена, отправляем запрос дальше.
return $next($request);
}
}
Теперь этот middleware можно использовать в роут-файлах:
<?php
use App\Http\Middleware\SetApiVersion;
Route::group(['prefix' => '/v1', 'middleware' => [SetApiVersion::class.':v1']], function () {
// Все контроллеры, описанные в этой группе, получат идентификатор версии `v1`.
});
Route::group(['prefix' => '/v2', 'middleware' => [SetApiVersion::class.':v2']], function () {
// Все контроллеры, описанные в этой группе, получат идентификатор версии `v2`.
});
Регистрация интерфейсов и реализаций
Для регистрации интерфейсов и реализаций создадим новый сервис провайдер.
Внутри него нужно будет сопоставить интерфейсы, реализации и идентификаторы версий. Это можно сделать как через файл конфигурации, так и напрямую внутри провайдера. Для простоты примера сделаем это внутри провайдера.
<?php
namespace App\Providers;
use App\Interfaces\Requests\RegisterCustomerRequestInterface;
use App\Interfaces\Transformers\CustomerRegisteredTransformerInterface;
use App\Http\Requests\V1\RegisterCustomerRequest as RegisterCustomerRequestV1;
use App\Http\Requests\V2\RegisterCustomerRequest as RegisterCustomerRequestV2;
use App\Http\Transformers\V1\CustomerRegisteredTransformer as CustomerRegisteredTransformerV1;
use App\Http\Transformers\V2\CustomerRegisteredTransformer as CustomerRegisteredTransformerV2;
use Illuminate\Support\ServiceProvider;
class ApiVersioningServiceProvider extends ServiceProvider
{
/**
* Для каждого интерфейса создадим список реализаций, где ключ – идентификатор версии,
* а значение – полное имя класса реализации.
*/
protected array $versions = [
RegisterCustomerRequestInterface::class => [
'v1' => RegisterCustomerRequestV1::class,
'v2' => RegisterCustomerRequestV2::class,
],
CustomerRegisteredTransformerInterface::class => [
'v1' => CustomerRegisteredTransformerV1::class,
'v2' => CustomerRegisteredTransformerV2::class,
],
];
public function register(): void
{
// Зарегистрируем резолвер для каждого интерфейса.
$abstractions = array_keys($this->versions);
foreach ($abstractions as $abstract) {
$this->app->bind($abstract, function () use ($abstract) {
// Важно: запрашивайте реализацию внутри замыкания – так вы будете уверены,
// что все необходимые сервисы были зарегистрированы в контейнере,
// и текущая версия API доступна.
return $this->getImplementation($abstract);
});
}
}
protected function getImplementation(string $abstract): mixed
{
// Идентификатор API-версии, который был добавлен в контейнер внутри middleware SetApiVersion.
$version = $this->app->get('api.version');
// Убедимся, что интерфейс и реализация для текущей версии заданы.
if (!isset($this->versions[$abstract])) {
throw new \RuntimeException('The ['.$abstract.'] binding does not exist in versions list!');
} elseif (!isset($this->versions[$abstract][$version])) {
throw new \RuntimeException('The ['.$abstract.'] binding does not have an implementation for version ['.$version.']!');
}
// Реализация интерфейса для текущей версии существует,
// создадим инстанс и вернём его.
return $this->app->make($this->versions[$abstract][$version]);
}
}
Не забудьте зарегистрировать новый провайдер в списке провайдеров приложения. Теперь интерфейсы будут доступны внутри контроллеров и в зависимости от текущей версии вы будете получать необходимую реализацию интерфейса.
Готовые решения
Если вас интересует готовое решение такого подхода, вы можете попробовать воспользоваться моей библиотекой
laniakea/laniakea
. Версионирование API
в ней сделано именно по такому принципу и протестировано уже на нескольких личных проектах.
Кроме версионирования API она может помочь вам создать API-ресурсы или, например, реализовать поддержку настроек моделей.
Почитайте как именно устроена библиотека или посмотрите демо-приложение, которое использует всю функциональность библиотеки.