Вначале мы делали документацию в 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, контроллеры которого наследуются от контроллеров предыдущей версии;
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;
}
Результат:
Что получилось - пример на 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
{}
}
Результат:
Результаты
Библиотека для анализа кода проекта (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'ов сервисов, либо как сложные модели-валидаторы.
Из похожего / ссылки
Комментарии (3)
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 { ... }
mvs
Брр, а чем swagger-php не угодил? Он же легко и просто генерит спеку на основе типизированных аттрибутов (с аннотациями сложнее).
Т.е. сейчас, условно, на 20-й версии у каждого контроллера 19 предков?
wapmorgan Автор
На момент старта работы над генератором (середина 2020-го) была версия zircote/swagger-php 3.0.5, в которой поддержки атрибутов не было, как и самих атрибутов в стабильной версии языка (php 8 ещё не вышла).
Верно.