Вначале мы делали документацию в Word, потом в Google Docs, потом в Confluence, потом была попытка написать openapi-спецификацию для API вручную, но увидев сколько всего там нужно было писать - бросили эту затею.

Нужно было вести документацию в знакомом отрасли формате для растущего (в количестве сервисов) API, и делать это максимально "подручно".

API был большой:

  1. Штук 20 разных версий модулей для "своего" клиента - сайта и мобильного приложения, в каждом из которых от 20 до 50 веб-сервисов (от первых версий к новейшим). Причём каждый квартал добавлялась новая версия, для которой API состоял из 80-90% копии предыдущей версии, а остальные 10-20% отличались незначительно.

  2. Штук 20 наборов сервисов по 10-20 веб-сервисов для интеграций разного размера. Авторизация в них специфическая - в каждом своя для интегрируемой системы, но функциональность некоторых веб-сервисов повторяла таковую из основного API.

  3. Итого суммарно около 3.000 различных веб-сервисов в 50 разных версиях API, из которых 80-90% имеют одинаковое описание.

Идея

Начало новой истории было положено с идеи коллеги - сделать автоматический препроцессор/генератор описания для API с учётом специфики проекта:

  • Новые версии API наследуются от предыдущих. У нас версионируется весь API вместо версий отдельных веб-сервисов, что приводит к повторению одних и тех же редко изменяемых веб-сервисов в каждой версии API без изменений.

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

  • Документацию в OpenApi никто не вёл на проекте. И начинать её вести с нуля для довольно большого API показалось отчаянной идеей. Напротив: написать парсер/генератор для проекта - показалось лучшей идей.

  • Отсутствие желания вручную составлять openapi-спецификацию и уберечь разработчика от ручного редактирования openapi-файлов: Всё, что у разработчика есть - ide и php-файлы.

  • Желание иметь документацию рядом с кодом. Чтобы максимально снизить вероятность ситуации, когда код изменён, а документация к API - нет.

Технические условия, упрощения и допуски

Технически API довольно прост (и это как раз позволило реализовать первую версию с минимумом функций за относительно небольшой срок):

  • HTTP:

    • Методы взаимодействия - только GET/POST;

    • Параметры передаются всегда в виде GET-параметров либо json-тела для POST-запросов;

    • Всегда возвращается 200-й http-код. Даже в случае ошибок;

    • В случае успешного выполнения, сервисы бэкэнда возвращают ответ всегда в одной и той же структуре;

    • Формат ответов сервиса - json.

  • Стэк:

    • Бэкэнд реализован полностью на фреймворке (был использован yii2);

    • Версия API является отдельным модулем приложения, endpoint'ы размещены в контроллерах.

    • В новых версиях API все контроллеры наследуются от предыдущей версии, требующие изменений endpoint'ы переопределяются;

    • Используется единый подход к аутентификации для 80% API, и для остальных 20% либо нет авторизации вовсе, либо она одна из кастомных.

    • В сложных запросах (со множеством полей и проверок) используется валидация с помощью класс-модели (Model в yii2, FormRequest в laravel).

    • Все endpoint'ы возвращают либо скаляры, либо объект-DTO с описанными подробном всеми полями (в том числе вложенными), либо DTO-подобный объект-генератор ответа (небольшая магия).

*Забегая вперёд стоит упомянуть, что сейчас примерно все те же допуски и остались, соответственно прикрутить генератор к своему xml-API не получится.

**Забегая вперёд дважды: если есть идеи о продуманной реализации недостающих функций, обсуждение открыто и принимаются MR.

Принцип работы

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

Источники данных:

  1. Точкой входа будет список endpoint'ов API (и методы взаимодействия - get/post). Будет составлен список тегов API (для yii2 - отдельные модули, для других - все части URL endpoint'ов кроме последней).

  2. Первым источником данных должны стать методы, отвечающие за endpoint'ы API в программном коде. (описание endpoint'ов, авторизация, вложенность, документация).

  3. Вторым и третьим источником данных станут параметры запросов и структура возвращаемых значений. (параметры метода, сложные запросы-валидаторы - например, FormRequest в laravel, а также тип возвращаемого значения - в сигнатуре или в phpdoc).

  4. Завершающим источником будут данные о приложении - базовые URL, способы аутентификации, теги, и т.д. (задаются явно в классе-наследнике Scraper'а).

Генератор собирает всю информацию, проходит по всему списку endpoint'ов API, анализирует описание (phpdoc) и сигнатуру, далее занимается анализом всех использованных в описании endpoint'ом объектов (как входных - валидаторов, так и выходных - возвращаемые данные).

Версионирование

Очень сильно принцип реализации зависит от архитектуры проекта и механизма версионирования:

  • У нас каждая новая версия API - это отдельный модуль yii2, контроллеры которого наследуются от контроллеров предыдущей версии;

class v001 extends \yii\base\Module {}
class v002 extends v001 {}
class v003 extends v002 {}
  • Сервисы, которые нужно изменить в API (либо логика, либо формат) переопределяются в контроллере новой версии;

// v0.0.1
class ProfileController extends Controller {
    public function actions() {
      return [
        'get' => GetProfileAction::class,
        'update' => UpdateProfileAction::class,
      ];
    }
}
// v0.0.2
class ProfileController extends \app\modules\v002\controllers\ProfileController {
    public function actions() {
      return array_merge(parent::actions(), [
        'update' => UpdateProfileV002Action::class,
      ]);
    }
}
  • Зачастую сервис переопределяется не полностью, а только слой View - формат/состав возвращаемых данных: для этого у нас есть "генераторы" ответов, которые могут полностью перекроить ответ сервиса в новой версии, без изменения логики сервиса);

  • Получается что в каждой новой версии ~90% сервисов API полностью идентичны таковым в предыдущей версии. В остальных 10% либо переопределена логика (новый action-класс), либо только формат вывода.

  • По итогу выходит, что для 90% API описание в новой версии берётся из описания старой версии; Для остальных 10% нужно заново описать (phpdoc'ом) все поля запроса и/или ответа.

Примеры

Разберём сражу же основной элемент API - endpoint.

/**
 * Описание сервиса.
 *
 * Выдаёт список айтемов проекта/мессенджера/новостей.
 * @auth DifferentAuthType
 * @param string $firstName Имя запрашивающего.
 * @paramExample $firstName Сергей
 * @param string $list Тип списка.
 * @paramEnum $list news|messages|project
 * @return IndexDTO Объект с полями, который будет возвращён как ответ сервиса
 */
public function actionIndex(
  string $firstName,
  string $list
) {
  // ...
  return // ....
;
}

У него есть несколько параметров скалярных, с кратким описанием. И также есть указание на формат ответа, который можно описать примерно так (можно описывать как в phpdoc, так и явными параметрами - настраивать анализ первого или второго или обоих можно в настройках генератора, по умолчанию просматриваются оба способа):

class IndexDTO {
  /**
   * @var IndexList Пагинированный список
   */
  public $list;

  /**
   * @var int Количество элементов в списке всего
   */
  public int $count;

  /**
   * @var bool Признак последней страницы
   */
  public bool $lastPage;
}

// или через phpdoc

/**
 * @property IndexList $list Пагинированный список
 * @property int $count Количество элементов в списке всего
 * @propertyExample $count 169
 * @property bool $lastPage Признак последней страницы
 */
class IndexDTO {
  // ...
  // ...
}

У которого несколько полей, и можно указать что у него список.

Сложность описания API зачастую состоит в том, что мы описываем много уровней вложенности объектов. Для связывания этих объектов (а точнее их описаний) между собой можно использовать следующие способы:

  • Явно указать другой тип для всего объекта в @schema phpdoc-класса - если мы хотим подменить текущий объект другим объектов/скаляром/массивом объектов/скаляров.

  • Явно указать другой вложенный тип (объект) для всего объекта в @schema phpdoc-класса - если мы хотим подменить текущий объект другим объектов/скаляром/массивом объектов/скаляров и допускаем возможность переопределения.

  • Явно указать другой вложенный тип (объект) для поля в @property или явно в свойстве - если мы уверены что у нас всегда этот вложенный объект будет этого типа.

  • Явно указать другой вложенный тип (объект) для поля в @property со ссылкой на свойство, которое хранит ссылку на класс - если есть возможность переопределения вложенного объекта (например, в контроллере, в зависимости от версии API). Есть также возможность указывать в свойстве массив или сразу объект (тогда приложение нужно инициализировать и создавать всю иерархию DTO), но это уже выходит за рамки статьи.

Тут мы можем использовать подмену типов с @schema , чтобы указать что конкретный тип определён в другом типе (или списке из элементов другого типа) - это используется для динамической подмены реализации.

/**
 * @schema IndexDTOItem[]
 */
class IndexDTO {
}

class IndexDTOItem {
  public int $id;
  public string $title;
}

Подмена динамически выглядит так:

/**
 * @schema itemClass[]
 */
class IndexDTO {
  public $itemClass = IndexDTOItem::class;
}

Отработает точно так же, но зато мы можем при инициализации подменять реализацию заменой ссылки в itemClass. Таким образом довольно легко реализовывать переопределение отдельных узлов DTO в новых версиях API, например.

То же самое мы можем сделать и для IndexList (правда придётся определить тогда поле не явно, а через phpdoc):

/**
 * @property indexClass $list Пагинированный список
 */
class IndexDTO {
  public $indexClass = IndexList::class;

  /**
   * @var int Количество элементов в списке всего
   */
  public int $count;

  /**
   * @var bool Признак последней страницы
   */
  public bool $lastPage;
}

Интеграции

Сейчас есть готовые интеграции в:

  • yii2. Выборка контроллеров приложения и контроллеров в модулях (только первого уровня вложенности).

  • laravel. Выборка всех роутов и их callback'ов.

  • slim. Выборка всех роутов и их callback'ов.

  • Вышеуказанные интеграции можно дописать (отнаследовав и указывая свой scraper в openapi-generator), а остальные интеграции возможно написать самостоятельно (отнаследовав \wapmorgan\OpenApiGenerator\ScraperSkeleton).


Что получилось - пример на laravel

Роуты

Route::get('/selector/lists', [\App\Http\Controllers\SelectorController::class, 'lists']);
Route::post('/selector/select', [\App\Http\Controllers\SelectorController::class, 'select']);

Endpoint /lists

/**
 * Returns lists of filters
 * @param Request $request
 * @return ListsResponse
 */
 public function lists(Request $request) {
   return new ListsResponse([
       'persons' => array_keys(Menu::$personsList),
       'tastes' => Menu::$tastes,
       'meat' => Menu::$meat,
       'pizzas' => Menu::$pizzas,
   ]);
 }

Endpoint /select

/**
 * Makes a selection of pizzas according to criteria
 * @param \App\Http\Requests\SelectPizzas $request
 * @return PizzaListItem[]
 */
 public function select(\App\Http\Requests\SelectPizzas $request) {
      $validated = $request->validated();
      return (new Selector())->select(
          $validated['city'], $validated['persons'],
          $validated['tastes'] ?? null, $validated['meat'] ?? null,
          $validated['vegetarian'] ?? false, $validated['maxPrice'] ?? null);
}

class SelectPizzas extends FormRequest {
      public function rules()
      { 
          // ...
          return array_merge([
             'city' => ['required', 'string'],
             'persons' => ['required', Rule::in(array_keys(Menu::" class="formula inline">personsList))],
             'vegetarian' => ['boolean'],
             'maxPrice' => ['numeric'],
             'pizzas' => ['array', Rule::in(array_keys(Menu::$pizzas))],
         ], $tastes, $meat);
      }
}

Ответ /lists

class ListsResponse extends BaseResponse {
  /** @var string[] */
  public $persons;
  /** @var string[] */
  public $tastes;
  /** @var string[] */
  public $meat;
  /** @var string[] */
  public $pizzas;
}

Ответ /select

class PizzaListItem extends BaseResponse {
  public string $pizzeria;
  public string $id;
  public int $sizeId;
  public string $name;
  public float $size;
  public array $tastes;
  public array $meat;
  public float $price;
  public float $pizzaArea;
  public float $pizzaCmPrice;
  public string $thumbnail;
  public array $ingredients;
  public int $dough;
}

Результат:

Описание в laravel
Описание в laravel
Параметры запроса
Параметры запроса
Список Формат ответа
Список Формат ответа


Что получилось - пример на slim

Роуты

return function (App $app) { 
  $app->group('/auth', function (Group $group) { 
    $group->post('/login', LoginAction::class); 
    $group->get('/profile', ProfileAction::class); 
    $group->get('/logout', LogoutAction::class); }
);

Роут /auth/login

class LoginAction extends Action
{
    /**
     * Авторизация по логину и паролю
     * @return Response
     * @throws HttpBadRequestException
     */
    protected function action(): Response
    {}
}

Роут /auth/profile

class ProfileAction extends Action
{
    /**
     * Получение профиля пользователя
     * @return object
     * @auth defaultAuth
     */
    protected function action(): Response
    {}
}

Результат:

Описание в slim
Описание в slim

Результаты

Библиотека для анализа кода проекта (yii2/laravel/slim или со своим scraper'ом) и генерации на его основе openapi-спецификации - https://github.com/wapmorgan/OpenApiGenerator, с двумя консольными командами - для анализа и генерации.

Версия уже готова к использованию, правда текущие scraper'ы, настройки и логика работы именно та, которая нужна была в нашем проекте. Соответственно, допиливать напильником придётся (а можно даже обновить scraper'ы и сделать MR).

Как используем мы

В основном проекте у нас есть наследник от базового scraper'а для yii2 - Yii2CodeScraper, на который навешивается много дополнительной логики:

  • дефолтный способ аутентификации (который можно переопределить явно для отдельных сервисов);

  • wrapper для всех ответов API;

  • поддержка указания отдельного компонента для формирования ответа (слой View) для endpoint'ов API;

  • определённые правила анализа некоторых классов (и их наследников):

    • правила для анализа слоя View (немного отличается от обычных объектов)

    • правила для пропуска моделей ActiveRecord (на случай если разработчик случайно укажет его как тип возвращаемого параметра)

  • другие правила фильтрации модулей, контроллеров (только наследники от нашего базового описываются);

Немного цифр

Сейчас в самой новой версии нашего API около 130 сервисов, 26 версий назад было около 70. Перенос документации из Confluence в php занял довольно много времени, но зато API описан без единой yaml-строчки на ~95% (остальные 5% как раз описаны во временных недостатках).

Всего в этом API ~3.000 endpoint'ов API, 2/3 из которых относятся к версионированным клиентским сервисам (мобильное приложение/сайт). При этом всего ~300 уникальных callback'ов (т.е всего 10% endpoint'ов API либо являются первой версией, либо отличаются от предыдущих версии по своей логике) и около 500 компонентов формирования ответа (по каждому на объект на любом уровне вложенности ответов API). Очень примерно можно прикинуть, что около 300-500 сервисов имеют уникальное описание в API , остальные же ~2.5k просто наследуют описание родительского сервиса из предыдущий версии API.

Итоговое описание всего API всех версий
Итоговое описание всего API всех версий

Преимущества представленного решения (над ручным составлением):

  • Вся документация лежит рядом с кодом (phpdoc и сигнатуры) или сама является кодом (DTO/генераторы).

  • Описание в коде выходит меньше, чем явное (как в yaml, так и с помощью zircote/swagger-php).

  • Для описания не нужно ни знать openapi, ни редактировать явно yaml/json спецификации. Конечно, не стоит лишать себя возможность изучить, чтобы понимать как оно работает.

  • Сформировать скелет документации для уже существующего API можно быстро (используя один из уже готовых scraper'ов), больше всего времени уйдёт на детальное описание параметров запросов, возвращаемых данных и параметров API (сервера, теги, etc).

  • При переиспользовании callback'ов endpoint'ов документация будет подтянута автоматически. При вынесении некоторых сервисов из основного API, в модуль для интеграции с партнёрами, процесс описания документации занимает около 0% времени: уже написанные веб-сервисы (и слой View) подключаем в другой модуль, а документация у них единая.

Недостатки (которые можно проработать в будущем):

  • Невозможно указать несколько вариантов ответов API (e.g. для 200, 401, 403, etc);

  • Невозможно использовать что-то кроме get/post методов (в yii2 нельзя явно задать метод обращения, в slim/laravel берётся из роутинга);

  • Поддержка (в виде scaper'ов) есть базовая только для yii2, laravel и slim. Причём она сейчас в таком виде, в котором мне показалось её сделать наиболее логичнее или проще: для yii2 применена была на реальном проекте, для slim/laravel набросана для хобби-проектов (другие идеи реализации и вариации скраперов, а также feedback принимаю в ЛС).

  • Поддержка моделей-валидаторов (через extractor'ы) для указания параметров запросов пока что очень слаба - только laravel'овский FormRequest. В планах ещё добавить поддержку прокидывания параметров в метод-callback через Model в yii2 (другие идеи и вариации принимаю в ЛС).

Недостатки архитектурные:

  • Придётся оборачивать абсолютно ответы API в DTO или классы-генераторы.

  • Все параметры запросов нужно указывать явно: либо как аргументы callback'ов сервисов, либо как сложные модели-валидаторы.

Из похожего / ссылки

  1. Автоматическая документация по коду для API в Laravel

  2. Generate OpenAPI Specification for Laravel Applications

  3. NelmioApiDocBundle для Symfony

  4. Swagger php

Комментарии (3)


  1. mvs
    12.04.2023 14:34

    Брр, а чем swagger-php не угодил? Он же легко и просто генерит спеку на основе типизированных аттрибутов (с аннотациями сложнее).

    У нас каждая новая версия API - это отдельный модуль yii2, контроллеры которого наследуются от контроллеров предыдущей версии;

    Т.е. сейчас, условно, на 20-й версии у каждого контроллера 19 предков?


    1. wapmorgan Автор
      12.04.2023 14:34

      На момент старта работы над генератором (середина 2020-го) была версия zircote/swagger-php 3.0.5, в которой поддержки атрибутов не было, как и самих атрибутов в стабильной версии языка (php 8 ещё не вышла).

      на 20-й версии у каждого контроллера 19 предков

      Верно.


  1. michael_v89
    12.04.2023 14:34
    +1

    Мы недавно сделали похожую штуку на основе zircote/swagger-php с дополнительной обработкой. Входные и выходные типы берутся из кода с помощью рефлексии, по аналогии с библиотекой GraphQLite для GraphQL. Для других кодов ответа используется специальный класс HttpException и аннотация из zircote/swagger-php.


    class SomeController {
      #[Post(...)]
      #[OA\Response(401, 'Unauthorized')]
      public function someAction(#[Body] RequestDTO $requestDto): ResponseDTO
      {
        if (!$this->checkAccess()) {
          throw new HttpException('Not allowed', 401);
        }
    
        return new ResponseDTO(...);
      }
    }
    
    #[OA\Schema]
    class RequestDTO {
      ...
    }
    
    #[OA\Schema]
    class ResponseDTO {
      ...
    }