Обзор новой версии инструмента для разработки 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, и сталкиваетесь с несколькими проблемами:
- Маршрутизация и пакет упомянутый выше ничего не знают друг о друге, я вынужден дублировать данные маршрутизации;
- Описание объекта RequestBody несет исключительно информационный характер, я вынужден дублировать описание валидации на уровне кода;
- Описание объекта Response несет исключительно информационный характер, я вынужден дублировать описание валидации, скажем, на уровне кода, но уже интеграционных тестов;
- Reference Object ссылается в никуда, главное, чтобы это «никуда», было описано где-нибудь. Лично меня такой подход не устраивает;
- Описание самого приложения происходит также как и описание, скажем, операций, то есть на уровне аннотаций. Лично меня такой подход, также не устраивает.
Что предлагает Sunrise Router:
- Описывая контроллер, вы описываете его как операцию (Operation Object), опуская объекты Paths и Path Item, тем самым вы не дублируете имя маршрута, HTTP метод(ы), URI, атрибуты и правила их валидации;
- Описание RequestBody объекта будет использовано для валидации запросов, путем конвертации OpenAPI Schema Object в JSON Schema Validation;
- Описание Response объекта может быть использовано для валидации ответов на уровне интеграционных тестов за пределами приложения, путем конвертации OpenAPI Schema Object в JSON Schema Validation;
- Reference Object ссылается на классы или их методы/свойства, кому-то это покажется логикой в аннотациях, для меня это равносильно конструкции @Entity(repositoryClass=""), то есть, нормальным явлением;
- Описание самого приложения происходит уже на уровне кода, а не аннотаций.
Таким образом, в моем представлении, этапы разработки API могут быть следующие:
- Вы создаете и описываете сущности без логики;
- Вы создаете и описываете контроллеры возвращающие фейковые данные;
- Зависимые от 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… но я бы не стал выносить его за пределы контроллера… По личным соображениям, ограничений на этот счет никаких нет.
Больше примеров вы можете найти в репозитории скелетона, который доступен по ссылке.
Краткий список ключевых изменений в маршрутизаторе
- Контроллер теперь имплементирует RequestHandlerInterface, а не MiddlewareInterface – позволило отказаться от ResponseFactory из коробки и абстрагироваться от Sunrise PSR-7 implementation;
- Маршрут теперь имплементирует RequestHandlerInterface – позволило производить редиректы на уровне маршрутизатора;
- Маршрутизатор теперь наряду с прочим имплементирует MiddlewareInterface – дает больше возможностей для обработки ошибок и интеграции в уже существующую архитектуру;
- Маршрутизатор научился собирать URI маршрутов;
- Появилась возможность загрузки маршрутов из конфигов;
- Изменилась логика разбора URI, второй и выше уровень вложенности необязательных частей URI больше недопустим – ранее допускалась конструкция в виде
/(foo/(bar/(baz)))
, сейчас это приведет к ошибке; - Появилась возможность вешать промежуточное ПО (и не только) на группы.
Краткий список ключевых изменений в скелетоне
- Поставляется с воркером для RoadRunner и командой для генерации Systemd Unit – ранее использовался другой репозиторий;
- Изменена логика хранения конфигурационных файлов;
- Интегрирован Symfony Console компонент;
- Решена проблема с настройкой окружения для запуска тестов;
- Настроена обработка ошибок.
Крайне не рекомендую, ведя разработку на RoadRunner, использовать внедрение любых зависимостей через инъекции, за исключением самого контейнера. Это обусловлено глобальным состоянием объектов.
В игру «зачем писать это, когда есть это» можно играть бесконечно, но как бы там ни было, open-source все стерпит...
Спасибо всем кто принимал прямое или косвенное участие в разработке, особенно WinterSilence за его толчки в нужный момент!