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

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

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

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

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

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

Первый отпал из-за того, что придется много (очень много) писать докблоков, и это все равно не решает проблему забывчивости обновить описание. Здесь можно найти неплохой пример использования.

Второй вариант был неплох, особенно тем что позволял генерировать респонз на основе ресурса:

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

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