Версионирование API в Laravel-приложениях

от автора

Версионирование API – важная и зачастую сложная задача, у которой скорее всего нет какого-то универсального решения. Рассказываю об одном из возможных подходов в приложениях на базе Laravel.

Впрочем, сразу оговорюсь – такой способ можно реализовать не только в Laravel-приложениях, а ещё он может подойти не всем. Внедрение описанного метода может потребовать большого количества рефакторинга, и в таком случае быстрее и проще будет пойти (возможно) по пути копипастинга.

Зачем версионировать API

Зачастую версионирование API требуется приложениям, когда наступают следующие события:

  1. API является публичным;

  2. У API есть какое-то количество потребителей, для которых крайне важна обратная совместимость;

  3. В структуры входных и/или выходных данных нужно внести обратно несовместимые изменения.

Разумеется, это не единственные причины, когда может потребоваться версионирование, но по моему опыту эта необходимость появлялась именно в этом случае.

Как можно версионировать 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));     } }

С этим способом можно избежать неудобства предыдущих методов и упростить процесс добавления новых версий:

  1. В контроллере больше не нужно проверять какая версия используется в данный момент;

  2. Валидация происходит не в одном запросе, а в конкретном для конкретной версии API. То же самое относится к трансформерам – структура ответа может радикально отличаться от версии к версии, но контроллер всегда будет отдавать корректный ответ;

  3. Код регистрации покупателя выполняется в одном месте – если вы добавите новые опциональные поля, достаточно добавить новые методы в интерфейс и реализовать их внутри каждого запроса – не нужно копироать код между разными контроллерами;

  4. А если вы захотите добавить новое поле только для одной версии, в реализации старых версий можно возвращать значения по умолчанию (например, 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-ресурсы или, например, реализовать поддержку настроек моделей.

Почитайте как именно устроена библиотека или посмотрите демо-приложение, которое использует всю функциональность библиотеки.


ссылка на оригинал статьи https://habr.com/ru/articles/822335/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *