OpenApiGenerator — или как мы генерируем документацию для 3k сервисов API на PHP без погружения в openapi

от автора

Вначале мы делали документацию в 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, контроллеры которого наследуются от контроллеров предыдущей версии;

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

<?php // 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.

<?php /**  * Описание сервиса.  *  * Выдаёт список айтемов проекта/мессенджера/новостей.  * @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, так и явными параметрами — настраивать анализ первого или второго или обоих можно в настройках генератора, по умолчанию просматриваются оба способа):

<?php 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 , чтобы указать что конкретный тип определён в другом типе (или списке из элементов другого типа) — это используется для динамической подмены реализации.

<?php /**  * @schema IndexDTOItem[]  */ class IndexDTO { }  class IndexDTOItem {   public int $id;   public string $title; }

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

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

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

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

<?php /**  * @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

Роуты

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

Endpoint /lists

<?php /**  * 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

<?php /**  * 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

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

Ответ /select

<?php 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

Роуты

<?php 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

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

Роут /auth/profile

<?php 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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Как у вас документируется REST API? (в одном или нескольких проектах)

29.41% У нас нет документации либо она в рабочей переписке/в головах членов команды10
11.76% Вручную в текстовом формате — wiki/README/word4
38.24% Вручную в openapi13
0% Вручную в raml/blueprint/etc0
0% Вручную в другом виде/формате0
0% Автоматически (или с большой долей автоматизации) из кода/конфигов в текстовый формат — wiki/README/word0
41.18% Автоматически (или с большой долей автоматизации) из кода/конфигов в openapi14
0% Автоматически (или с большой долей автоматизации) из кода/конфигов в raml/blueprint/etc0
0% Автоматически (или с большой долей автоматизации) из кода/конфигов в другой вид/формат0

Проголосовали 34 пользователя. Воздержались 3 пользователя.

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


Комментарии

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

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