Версионирование API в Laravel-приложениях
Версионирование API — важная и зачастую сложная задача, у которой скорее всего нет какого-то универсального решения. Рассказываю об одном из возможных подходов в приложениях на базе Laravel.
Впрочем, сразу оговорюсь — такой способ можно реализовать не только в Laravel-приложениях, а ещё он может подойти не всем. Внедрение описанного метода может потребовать большого количества рефакторинга, и в таком случае быстрее и проще будет пойти (возможно) по пути копипастинга.
Зачем версионировать API
Зачастую версионирование API требуется приложениям, когда наступают следующие события:
API является публичным;
У API есть какое-то количество потребителей, для которых крайне важна обратная совместимость;
В структуры входных и/или выходных данных нужно внести обратно несовместимые изменения.
Разумеется, это не единственные причины, когда может потребоваться версионирование, но по моему опыту эта необходимость появлялась именно в этом случае.
Как можно версионировать API
В большинстве статей по тематике версионирования чаще выделяется один способ — создавать копии контроллеров, запросов и обработчиков, и размещать их под префиксом новой версии (например, /v2
). В целом, способ из этой статьи схож, однако, подход немного другой — я предлагаю (когда это возможно) версионировать только запросы и трансформеры (то есть то, что генерирует ваш ответ API).
Принцип
Принцип достаточно прост на словах — обработчикам запросов к API не нужно знать о том, под какой версией они запускаются. Если они достаточно универсальны, чтобы обрабатывать запросы из разных версий, всю необходимую информацию им нужно предоставлять версионированными запросами.
Возьмём пример обработчика, который регистрирует покупателя в интернет-магазине. К нему приходит запрос с номером телефона, страной, именем и паролем покупателя. Обработчик должен создать запись, отправить SMS с кодом подтверждения регистрации и отдать успешный ответ на запрос.
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-параметре.
[
'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
. Так мы избавимся от проверок и изолируем код конкретной версии в конкретном классе.
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 и возвращать необходимую структуру из реализаций для конкретных версий.
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, который будет добавлять в сервис контейнер приложения инстанс текущей версии.
instance() мы добавляем в сервис контейнер приложения
// идентификатор текущей версии. Вы сможете запросить его в любом месте,
// которое будет выполняться после текущего middleware.
app()->instance('api.version', $version);
// Получить идентификатор версии теперь можно с помощью app()->get('api.version').
// Версия API установлена, отправляем запрос дальше.
return $next($request);
}
}
Теперь этот middleware можно использовать в роут-файлах:
'/v1', 'middleware' => [SetApiVersion::class.':v1']], function () {
// Все контроллеры, описанные в этой группе, получат идентификатор версии `v1`.
});
Route::group(['prefix' => '/v2', 'middleware' => [SetApiVersion::class.':v2']], function () {
// Все контроллеры, описанные в этой группе, получат идентификатор версии `v2`.
});
Регистрация интерфейсов и реализаций
Для регистрации интерфейсов и реализаций создадим новый сервис провайдер.
Внутри него нужно будет сопоставить интерфейсы, реализации и идентификаторы версий. Это можно сделать как через файл конфигурации, так и напрямую внутри провайдера. Для простоты примера сделаем это внутри провайдера.
[
'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-ресурсы или, например, реализовать поддержку настроек моделей.
Почитайте как именно устроена библиотека или посмотрите демо-приложение, которое использует всю функциональность библиотеки.