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

    Всем спасибо.

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое