Автоматическая документация по коду для API в Laravel
Всем привет. На одном из утренних дэйликов, мобильные разработчики подняли вопрос о том, что документация по API не соответствует действительности. По горячим следам быстро нашли, что действительно есть нестыковки: разработчик пофиксил баг, но не обновил документацию по роуту. Так как такое уже случалось не впервые - была заведена задача на подумать, что можно с этим поделать.
Ждать долго не пришлось, при обновлении на сервере PHP c 7.2 до 7.4 - мы получили страницу с описанием ошибки, вместо документации. Ошибка была найдена быстро - проблема в библиотеке, которую мы использовали для рендеринга UI документации. ПР на гитхабе был создан быстро, но провисел в статусе open почти неделю. После этого, тикет насчет документации пошел в работу.
Текущее положение дел
Исходные данные следующие:
Сервер - Laravel 7
Документация - Blueprint Docs
Если кто-то не в курсе как это работает и выглядит, то смысл такой: есть отдельный файл (*.apib), со своим синтаксисом, и парсер (в нашем случае - https://github.com/apiaryio/drafter), который читает данный файл. Почему был выбран такой вариант документирования, я не знаю (было до меня).
Из готовых вариантов, что предлагает гугл, было отобрано только два претендента:
OpenAPI/Swagger
Первый отпал из-за того, что придется много (очень много) писать докблоков, и это все равно не решает проблему забывчивости обновить описание. Здесь можно найти неплохой пример использования.
Второй вариант был неплох, особенно тем что позволял генерировать респонз на основе ресурса:
/**
* @apiResourceCollection Mpociot\ApiDoc\Tests\Fixtures\UserResource
* @apiResourceModel Mpociot\ApiDoc\Tests\Fixtures\User
*/
public function listUsers()
{
return UserResource::collection(User::all());
}
/**
* @apiResourceCollection Mpociot\ApiDoc\Tests\Fixtures\UserResource
* @apiResourceModel Mpociot\ApiDoc\Tests\Fixtures\User
*/
public function showUser(User $user)
{
return new UserResource($user);
}
Но что касается Request - будь добр распиши подробно что к чему:
/**
* @urlParam id required The ID of the post.
* @urlParam lang The language.
* @bodyParam user_id int required The id of the user. Example: 9
* @bodyParam room_id string The id of the room.
* @bodyParam forever boolean Whether to ban the user forever. Example: false
* @bodyParam another_one number Just need something here.
* @bodyParam yet_another_param object required Some object params.
* @bodyParam yet_another_param.name string required Subkey in the object param.
* @bodyParam even_more_param array Some array params.
* @bodyParam even_more_param.* float Subkey in the array param.
* @bodyParam book.name string
* @bodyParam book.author_id integer
* @bodyParam book[pages_count] integer
* @bodyParam ids.* integer
* @bodyParam users.*.first_name string The first name of the user. Example: John
* @bodyParam users.*.last_name string The last name of the user. Example: Doe
*/
public function createPost()
{
// ...
}
/**
* @queryParam sort Field to sort by
* @queryParam page The page number to return
* @queryParam fields required The fields to include
*/
public function listPosts()
{
// ...
}
Вот если бы можно было генерировать входные параметры по объекту Request'a (мы используем Illuminate\Foundation\Http\FormRequest), было бы замечательно. И тут пришла в голову мысль: "А не написать ли очередной велосипед на PHP...".
Так, как команда небольшая (2 BE и 2 FE), то можно пожертвовать некоторыми плюшками из коробочных предложений (коды ответов и тд). Идея в следующем. Почти все обработчики роутов имеют следующий вид:
<?php
...
public function bulkApply(BulkApplyRequest $request, BulkApplyHandler $handler)
{
$applies = $handler(BulkApplyData::fromRequest($request), $request->user());
return $this->respondWithResource(Apply::collection($applies));
}
public function accept(StatusRequest $request, AcceptHandler $handler)
{
$apply = $handler($request->get('job_app_id'));
return $this->respondWithResource(new Apply($apply));
}
StatusRequest имеет следующее представление:
<?php
use Illuminate\Foundation\Http\FormRequest;
class StatusRequest extends FormRequest
{
public function rules()
{
return [
'app_id' => 'required|exists:apps,id',
];
}
}
В итоге было принята следующая схема:
Берем список всех текущих роутов и отсекам все что не /api/*
Из роута узнаем Controller и Action
Используя Reflection API можно достать параметры метода (нас интересует FormRequest)
В DocBlock помещаем информацию об объекте для ответа (в нашем случае JsonResource)
Реализация задуманного
С роутами все просто:
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Routing\Route;
use Illuminate\Routing\Router;
class RouterService
{
/** @var Router */
private $router;
public function __construct(Router $router)
{
$this->router = $router;
}
public function getApiRoutes(): array
{
$routes = [];
foreach ($this->router->getRoutes()->getRoutes() as $route) {
if (strpos($route->uri(), 'api/') === 0) {
$routes[] = $route;
}
}
usort($routes, function (Route $a, Route $b) {
return strnatcmp($a->uri(), $b->uri());
});
return $routes;
}
Затем сгруппируем роуты следующим образом:
Auth
/api/auth/login
/api/auth/logout
/api/auth/register
Вот сейчас можно начать самое интересное:
<?php
public function parseRoutes(array $routes): array
{
$rows = [];
foreach ($routes as $group => $items) {
$rows[$group] = [];
foreach ($items as $route) {
$tmp = [
'uri' => $route->uri(),
'methods' => $route->methods(),
'isGuest' => in_array('guest', $route->middleware()),
];
$reflection = new ReflectionClass($route->getController());
$methodName = Str::parseCallback($route->getAction('uses'))[1];
$reflectionMethod = $reflection->getMethod($methodName);
$requestParam = $this->getRequestParam($reflectionMethod);
$tmp['requestRules'] = $this->wrapRequestRules($requestParam->rules());
$response = $this->getResponse($reflectionMethod);
$tmp['response'] = $this->wrapResponse($response);
$rows[$group][] = $tmp;
}
}
return $rows;
}
isGuest нужен для отображения флага аутентификации. На 17 строке мы получаем название метода, который отвечает за обработку запроса. 20 - 21 строки отвечают за получение правил валидации входных параметров. 23 - 24 строки занимаются респонзом.
По поводу FormRequest, не всегда метод rules() возвращает строки. Например:
<?php
use App\Model\Item;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreItemRequest extends FormRequest
{
public function rules()
{
return [
'type' => ['required', Rule::in(Item::AVAILABLE_TYPES)],
'title' => 'required',
'description' => 'nullable',
];
}
}
В подобных случаях нужно вызвать метод __toString(), который преобразует правило в строку.
С обработкой ответа все немного сложнее. Вот так выглядит ответ у нас:
<?php
namespace App\Resources;
use App\Resources\CachedAppJsonResource;
/**
* @mixin \App\Models\Location
*/
class Location extends CachedAppJsonResource
{
/**
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
return self::upSet($this, function () {
return [
'id' => $this->id,
'title' => $this->title,
'location' => $this->location,
'lat' => $this->lat,
'lng' => $this->lng,
];
});
}
}
upSet - это костыль для вложенных ресурсов (прихраниваем в in-memory готовый результат). Очень сильно помогает в случаях с вложенными ресурсами.
Есть несколько вариантов как нам достать поля из ответа. Мы выбрали тот, который позволяет это сделать быстрее: PhpParser. Есть неплохой инструмент для online просмотра дерева: https://phpast.com/ (спасибо @pronskiy за наводку).
<?php
/**
* @apiResponse \App\Resources\Location
*/
public function add(AddRequest $request, AddHandler $addHandler)
{
$location = $addHandler($request);
return $this->respondWithResource(new Location($location));
}
На все про все ушло где-то 2-3 дня. Плюс ко всему, пришлось поправить роуты, которые в неправильном формате (было -> стало):
Насчет самой документации то вот как она выглядела (к сожалению, реального скрина нет, поэтому взял с демо):
А вот как это выглядит сейчас:
Из негативных моментов:
все значения для полей в ответе отображается как "..." (для решения этой проблемы нужно в ресурсе расписать каждое поле отдельным свойством и докблоком к нему)
нет детального описания роута и что он делает (решается добавлением к методу контроллера обычных комментариев)
нет кодов ответа, и самого ответа в случае ошибки (здесь быстрого решения нет, или пишете в стиле OpenAPI/Swagger, или нужно хорошенько подумать)
Все перечисленные минусы нас не смущают. Команда небольшая, всегда можно спросить. API не публичное. Главное что мы решили проблему "протухшей" документации. Теперь если разработчик что-то изменил (в запросе или ответе, или добавил/удалил роут) - это сразу же станет видно.
Всем спасибо.