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

Kate

Administrator
Команда форума
Всем привет. На одном из утренних дэйликов, мобильные разработчики подняли вопрос о том, что документация по 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 дня. Плюс ко всему, пришлось поправить роуты, которые в неправильном формате (было -> стало):

b0aae2e85bea7df26748124b61ddeb52.png

Насчет самой документации то вот как она выглядела (к сожелению реального скрина нет, поэтому взял с демо):

abd66b6296d02b8a171ea2172ea6bef6.png

А вот как это выглядит сейчас:

446be27c6470032ebcd4cf1c562ee6b5.png

Из негативных моментов:

  • все значения для полей в ответе отображается как "..." (для решения этой проблемы нужно в ресурсе расписать каждое поле отдельным свойством и докблоком к нему)
  • нет детального описания роута и что он делает (решается добавлением к методу контроллера обычных комментариев)
  • нет кодов ответа, и самого ответа в случае ошибки (здесь быстрого решения нет, или пишете в стиле OpenAPI/Swagger, или нужно хорошенько подумать)
Все перечисленные минусы нас не смущают. Команда небольшая, всегда можно спросить. API не публичное. Главное что мы решили проблему "протухшей" документации. Теперь если разработчик что-то изменил (в запросе или ответе, или добавил/удалил роут) - это сразу же станет видно.

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

Источник статьи: https://habr.com/ru/post/562766/
 
Сверху