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

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

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

Кратко

Проверка версии в коде контроллера – неудобно; копирование контроллеров и запросов – получше, но по мере развития можно начать путаться.

Старайтесь версионировать только запросы и трансформеры и переиспользуйте контроллеры и/или обработчики для всех версий. Laravel даёт все возможности для этого из коробки.

Если нужно готовое решение, посмотрите на laniakea/laniakea.

Зачем версионировать 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, вместо namefirst_name, а вместо surnamelast_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-ресурсы или, например, реализовать поддержку настроек моделей.

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