Обзор новой версии инструмента для разработки REST API на PHP 7.1+, с поддержкой OpenAPI спецификации для документирования и JSON Schema спецификации для валидации запросов и ответов.


Sunrise Router – маршрутизатор написанный на PHP, базирующийся на PSR-7 и PSR-15, с поддержкой аннотаций, и с недавнего времени, поддержкой OpenAPI спецификации и частично JSON Schema спецификации.


OpenAPI (в прошлом Swagger) – спецификация позволяющая описывать REST API (далее просто API), в том числе, принимаемые и возвращаемые структуры данных использую JSON Schema спецификацию (точнее ее расширенное подмножество).


JSON Schema – спецификация позволяющая описывать JSON структуры данных, в том числе, описывать правила валидации таких данных. Следовательно, имея JSON схему, набор данных и определенный инструмент (justinrainbow/json-schema), можно провалидировать такие данные.


Swagger (сегодня) – это инструментарий, служащий упростить разработку API, где самым примечательным, назовем его инструментом, я бы назвал Swagger UI, который в свою очередь, может стать для вас заменой таких REST клиентов, как Postman, Paw и т.д.


Мы затронули ряд спецификаций, но не упомянули JSON API спецификацию, о ней в статье речь не пойдет, однако если вы разработчик API, и вас тяготят сомнения, что отдаваемые вашим приложением структуры данных правильные, попробуйте ознакомится с выше упомянутой спецификацией.


Лень читать, что там?

Поддержка OpenAPI и JSON Schema спецификаций


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


Случай из практики: вы разработали некий контроллер в приложении, далее, вы переключаете контекст, описываете такой контроллер, допустим, в Postman или любом другом REST клиенте, далее, вы открываете некий README.md, где дополнительно описываете работу такого контроллера… Оставим за скобками процессы валидации, но вернемся к ним чуть позже… Вот только в какой-то момент, все пойдет не по плану, и документация не будет соответствовать действительно.


Рассмотрим другую ситуацию: вы в курсе выше упомянутых спецификаций, используете, допустим, инструмент zircote/swagger-php, и сталкиваетесь с несколькими проблемами:


  1. Маршрутизация и пакет упомянутый выше ничего не знают друг о друге, я вынужден дублировать данные маршрутизации;
  2. Описание объекта RequestBody несет исключительно информационный характер, я вынужден дублировать описание валидации на уровне кода;
  3. Описание объекта Response несет исключительно информационный характер, я вынужден дублировать описание валидации, скажем, на уровне кода, но уже интеграционных тестов;
  4. Reference Object ссылается в никуда, главное, чтобы это «никуда», было описано где-нибудь. Лично меня такой подход не устраивает;
  5. Описание самого приложения происходит также как и описание, скажем, операций, то есть на уровне аннотаций. Лично меня такой подход, также не устраивает.

Что предлагает Sunrise Router:


  1. Описывая контроллер, вы описываете его как операцию (Operation Object), опуская объекты Paths и Path Item, тем самым вы не дублируете имя маршрута, HTTP метод(ы), URI, атрибуты и правила их валидации;
  2. Описание RequestBody объекта будет использовано для валидации запросов, путем конвертации OpenAPI Schema Object в JSON Schema Validation;
  3. Описание Response объекта может быть использовано для валидации ответов на уровне интеграционных тестов за пределами приложения, путем конвертации OpenAPI Schema Object в JSON Schema Validation;
  4. Reference Object ссылается на классы или их методы/свойства, кому-то это покажется логикой в аннотациях, для меня это равносильно конструкции @Entity(repositoryClass=""), то есть, нормальным явлением;
  5. Описание самого приложения происходит уже на уровне кода, а не аннотаций.

Таким образом, в моем представлении, этапы разработки API могут быть следующие:


  1. Вы создаете и описываете сущности без логики;
  2. Вы создаете и описываете контроллеры возвращающие фейковые данные;
  3. Зависимые от API люди могут включаться в работу, так как на этом этапе у нас имеется документация и валидация;
    • Front разработчик может приступать к реализации интерфейсов;
    • Тестер может начинать писать интеграционные тесты;
    • Вы спокойно реализуете логику в API, так как на этом этапе, в теории, все согласовано со всех сторон.

Считайте схему выше, не более чем, мыслями в слух, это примерно та схема, к которой я пытаюсь прийти сегодня. То есть, уделять больше времени именно проектированию, и договариваться с командой в процессе проектирования.



От теории к практике, типичный контроллер:


/**
 * @Route(
 *   name="api.entry.update",
 *   path="/api/v1/entry/{id<\d+>}",
 *   methods={"PATCH"},
 * )
 */
final class EntryUpdateController implements RequestHandlerInterface
{
    public function handle(ServerRequestInterface $request) : ResponseInterface
    {
        return (new ResponseFactory)->createResponse(200);
    }
} 

Как видим, контроллер связан с маршрутом, который в свою очередь, имеет: имя, HTTP метод(ы), URI, атрибут id и регулярное выражение \d+ для его валидации. Теперь, не дублируя эти данные, опишем наш контроллер:


/**
 * @Route(...)
 *
 * @OpenApi\Operation(
 *   requestBody=@OpenApi\RequestBody(
 *     content={
 *       "application/json": @OpenApi\MediaType(
 *         schema=@OpenApi\Schema(
 *           type="object",
 *           required={"name"},
 *           properties={
 *             "name"=@OpenApi\Schema(
 *               type="string",
 *               minLength=1,
 *               maxLength=255,
 *               nullable=false,
 *             ),
 *           },
 *         ),
 *       ),
 *     },
 *   ),
 *   responses={
 *     200: @OpenApi\Response(
 *       description="OK",
 *     )
 *   },
 * )
 */
final class EntryUpdateController implements RequestHandlerInterface
{
    // some code...
}

Как видно из примера выше, мы имеем правило валидации для тела запроса, дублировать его, скажем в сервисе, я тоже не хочу, поэтому, я подключу промежуточное ПО:


/**
 * @Route(...,
 *   middlewares={
 *     "Sunrise\Http\Router\OpenApi\Middleware\RequestBodyValidationMiddleware",
 *   },
 * )
 *
 * @OpenApi\Operation(...)
 */
final class EntryUpdateController implements RequestHandlerInterface
{
    // some code...
}

Конечно, от валидации в коде избавиться полностью не удастся, но тем не менее, нам остается только проверить существование записи в хранилище.


Для тех, кому пример с подключением промежуточного ПО показался неприемлемым

Маршруты можно не описывать используя аннотации, аннотации лишь один из способов мапинга.



use App\Controller\EntryUpdateController;
use Sunrise\Http\Router\OpenApi\Middleware\RequestBodyValidationMiddleware;

$collection->patch('api.entry.update', '/api/v1/entry/{id<\d+>}', new EntryUpdateController())
    ->addMiddleware(new RequestBodyValidationMiddleware());

Исходя из примеров выше, можно предположить, что схема свойства name может повторяться от контроллера к контроллеру, и дублировать такую информацию, мне бы тоже не хотелось. Поэтому я воспользуюсь Reference объектом...


Допустим, у нас имеется сущность Entry:


final class Entry
{

    /**
     * @OpenApi\Schema(
     *   refName="EntryName",
     *   type="string",
     *   minLength=1,
     *   maxLength=255,
     *   nullable=false,
     * )
     */
    private $name;
}

Теперь изменим описание нашего контроллера:


/**
 * @Route(...)
 *
 * @OpenApi\Operation(
 *   requestBody=@OpenApi\RequestBody(
 *     content={
 *       "application/json": @OpenApi\MediaType(
 *         schema=@OpenApi\Schema(
 *           type="object",
 *           required={"name"},
 *           properties={
 *             "name"=@OpenApi\SchemaReference(
 *               class="App\Entity\Entry",
 *               property="name",
 *             ),
 *           },
 *         ),
 *       ),
 *     },
 *   ),
 *   responses={
 *     200: @OpenApi\Response(
 *       description="OK",
 *     )
 *   },
 * )
 */
final class EntryUpdateController implements RequestHandlerInterface
{
    // some code...
}

Обратите внимание на аннотацию @OpenApi\SchemaReference(), объект Reference применим не только к схемам, но и ко всем другим объектам, которые допускает OpenAPI спецификация. Однако, если описание вам кажется все равно длинным, вы можете пойти дальше, и всю схему тела запроса вынести в метод, скажем, сервиса: EntryService::updateById(...)… Можно пойти еще дальше, и воспользоваться объектом RequestBodyReference… но я бы не стал выносить его за пределы контроллера… По личным соображениям, ограничений на этот счет никаких нет.


Больше примеров вы можете найти в репозитории скелетона, который доступен по ссылке.


Про поддержку аннотаций в IDE

Краткий список ключевых изменений в маршрутизаторе


  • Контроллер теперь имплементирует RequestHandlerInterface, а не MiddlewareInterface – позволило отказаться от ResponseFactory из коробки и абстрагироваться от Sunrise PSR-7 implementation;
  • Маршрут теперь имплементирует RequestHandlerInterface – позволило производить редиректы на уровне маршрутизатора;
  • Маршрутизатор теперь наряду с прочим имплементирует MiddlewareInterface – дает больше возможностей для обработки ошибок и интеграции в уже существующую архитектуру;
  • Маршрутизатор научился собирать URI маршрутов;
  • Появилась возможность загрузки маршрутов из конфигов;
  • Изменилась логика разбора URI, второй и выше уровень вложенности необязательных частей URI больше недопустим – ранее допускалась конструкция в виде /(foo/(bar/(baz))), сейчас это приведет к ошибке;
  • Появилась возможность вешать промежуточное ПО (и не только) на группы.

Краткий список ключевых изменений в скелетоне


  • Поставляется с воркером для RoadRunner и командой для генерации Systemd Unit – ранее использовался другой репозиторий;
  • Изменена логика хранения конфигурационных файлов;
  • Интегрирован Symfony Console компонент;
  • Решена проблема с настройкой окружения для запуска тестов;
  • Настроена обработка ошибок.

Несколько слов о DI и RoadRunner
Крайне не рекомендую, ведя разработку на RoadRunner, использовать внедрение любых зависимостей через инъекции, за исключением самого контейнера. Это обусловлено глобальным состоянием объектов.



В игру «зачем писать это, когда есть это» можно играть бесконечно, но как бы там ни было, open-source все стерпит...


Спасибо всем кто принимал прямое или косвенное участие в разработке, особенно WinterSilence за его толчки в нужный момент!