Вначале мы делали документацию в Word, потом в Google Docs, потом в Confluence, потом была попытка написать openapi-спецификацию для API вручную, но увидев сколько всего там нужно было писать — бросили эту затею.
Нужно было вести документацию в знакомом отрасли формате для растущего (в количестве сервисов) API, и делать это максимально «подручно».
API был большой:
-
Штук 20 разных версий модулей для «своего» клиента — сайта и мобильного приложения, в каждом из которых от 20 до 50 веб-сервисов (от первых версий к новейшим). Причём каждый квартал добавлялась новая версия, для которой API состоял из 80-90% копии предыдущей версии, а остальные 10-20% отличались незначительно.
-
Штук 20 наборов сервисов по 10-20 веб-сервисов для интеграций разного размера. Авторизация в них специфическая — в каждом своя для интегрируемой системы, но функциональность некоторых веб-сервисов повторяла таковую из основного API.
-
Итого суммарно около 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, который находится в стадии драфта).
Источники данных:
-
Точкой входа будет список endpoint’ов API (и методы взаимодействия — get/post). Будет составлен список тегов API (для yii2 — отдельные модули, для других — все части URL endpoint’ов кроме последней).
-
Первым источником данных должны стать методы, отвечающие за endpoint’ы API в программном коде. (описание endpoint’ов, авторизация, вложенность, документация).
-
Вторым и третьим источником данных станут параметры запросов и структура возвращаемых значений. (параметры метода, сложные запросы-валидаторы — например, FormRequest в laravel, а также тип возвращаемого значения — в сигнатуре или в phpdoc).
-
Завершающим источником будут данные о приложении — базовые 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 зачастую состоит в том, что мы описываем много уровней вложенности объектов. Для связывания этих объектов (а точнее их описаний) между собой можно использовать следующие способы:
-
Явно указать другой тип для всего объекта в
@schemaphpdoc-класса — если мы хотим подменить текущий объект другим объектов/скаляром/массивом объектов/скаляров. -
Явно указать другой вложенный тип (объект) для всего объекта в
@schemaphpdoc-класса — если мы хотим подменить текущий объект другим объектов/скаляром/массивом объектов/скаляров и допускаем возможность переопределения. -
Явно указать другой вложенный тип (объект) для поля в
@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; }
Результат:
Что получилось — пример на 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 {} }
Результат:
Результаты
Библиотека для анализа кода проекта (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.
Преимущества представленного решения (над ручным составлением):
-
Вся документация лежит рядом с кодом (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’ов сервисов, либо как сложные модели-валидаторы.
Из похожего / ссылки
ссылка на оригинал статьи https://habr.com/ru/articles/721650/
Добавить комментарий