Всем привет. На одном из утренних дэйликов, мобильные разработчики подняли вопрос о том, что документация по API не соответствует действительности. По горячим следам быстро нашли, что действительно есть нестыковки: разработчик пофиксил баг, но не обновил документацию по роуту. Так как такое уже случалось не впервые — была заведена задача на подумать, что можно с этим поделать.
Ждать долго не пришлось, при обновлении на сервере PHP c 7.2 до 7.4 — мы получили страницу с описанием ошибки, вместо документации. Ошибка была найдена быстро — проблема в библиотеке, которую мы использовали для рендеринга UI документации. ПР на гитхабе был создан быстро, но провисел в статусе open почти неделю. После этого, тикет насчет документации пошел в работу.
Текущее положение дел
Исходные данные следующие:
-
Сервер — Laravel 7
-
Документация — Blueprint Docs https://github.com/M165437/laravel-blueprint-docs
Если кто-то не в курсе как это работает и выглядит, то смысл такой: есть отдельный файл (*.apib), со своим синтаксисом, и парсер (в нашем случае — https://github.com/apiaryio/drafter), который читает данный файл. Почему был выбран такой вариант документирования, я не знаю (было до меня).
Из готовых вариантов, что предлагает гугл, было отобрано только два претендента:
-
OpenAPI/Swagger
-
Laravel API Documentation Generator — https://github.com/mpociot/laravel-apidoc-generator
Первый отпал из-за того, что придется много (очень много) писать докблоков, и это все равно не решает проблему забывчивости обновить описание. Здесь можно найти неплохой пример использования — https://blog.quickadminpanel.com/laravel-api-documentation-with-openapiswagger/
Второй вариант был неплох, особенно тем что позволял генерировать респонз на основе ресурса:
/** * @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 не публичное. Главное что мы решили проблему «протухшей» документации. Теперь если разработчик что-то изменил (в запросе или ответе, или добавил/удалил роут) — это сразу же станет видно.
Всем спасибо.
ссылка на оригинал статьи https://habr.com/ru/post/562766/
Добавить комментарий