Автоматическая документация по коду для API в Laravel

от автора

Всем привет. На одном из утренних дэйликов, мобильные разработчики подняли вопрос о том, что документация по API не соответствует действительности. По горячим следам быстро нашли, что действительно есть нестыковки: разработчик пофиксил баг, но не обновил документацию по роуту. Так как такое уже случалось не впервые — была заведена задача на подумать, что можно с этим поделать.

Ждать долго не пришлось, при обновлении на сервере PHP c 7.2 до 7.4 — мы получили страницу с описанием ошибки, вместо документации. Ошибка была найдена быстро — проблема в библиотеке, которую мы использовали для рендеринга UI документации. ПР на гитхабе был создан быстро, но провисел в статусе open почти неделю. После этого, тикет насчет документации пошел в работу.

Текущее положение дел

Исходные данные следующие:

Если кто-то не в курсе как это работает и выглядит, то смысл такой: есть отдельный файл (*.apib), со своим синтаксисом, и парсер (в нашем случае — https://github.com/apiaryio/drafter), который читает данный файл. Почему был выбран такой вариант документирования, я не знаю (было до меня).

Из готовых вариантов, что предлагает гугл, было отобрано только два претендента:

Первый отпал из-за того, что придется много (очень много) писать докблоков, и это все равно не решает проблему забывчивости обновить описание. Здесь можно найти неплохой пример использования — 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/


Комментарии

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

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