Существует два основных подхода к разработке API: Code First и API First. В этом уроке мы рассмотрим особенности каждого из них, их преимущества и недостатки, а также ситуации, в которых каждый подход наиболее уместен.
Что такое Code First?
Подход Code First предполагает, что разработка начинается с написания кода сервера или приложения. То есть, разработчики сначала пишут код, который реализует бизнес-логику, и только потом, на основе этого кода, формируют описание API, например, документацию или спецификацию OpenAPI.
В Fastify для этого есть официальный плагин. С его помощью, при правильном описании маршрутов в Fastify получить нужную документацию для клиентов. Для визуализации этой документации подключается другой, но тоже официальный плагин. Выглядит это так:
Основным преимуществом подхода Code First является возможность быстро приступить к разработке без необходимости тратить время на предварительное планирование API. Этот подход хорошо работает, когда API делается для себя и своей команды.
Когда людей становится больше и добавляются отдельные команды фронтенда или мобильного приложения, появляется необходимость интегрироваться с внешними клиентами и системами. В таких ситуациях может оказаться что API спроектирован плохо, а командам надо ждать его разработчиков, пока они реализуют конкретные эндпоинты и предоставят документацию по ним. В этом случае подход Code First начинает тормозить развитие.
Что такое API First?
Подход API First ставит API в центр процесса разработки. Перед началом реализации пишется спецификация API (в формате OpenAPI для REST API), которая описывает эндпоинты, методы, параметры и структуры данных. Эта спецификация служит основой для разработки как серверной, так и клиентской части приложения. Таким образом, API становится своего рода контрактом между различными частями системы и внешними клиентами.
API-First подход не требует от разработчиков полного описания всего API до старта разработки. Спецификация может заполняться по мере реализации новых API, но до того, как начнет писаться код.
При таком подходе, необходимое API можно спроектировать и заложить в систему очень быстро, не дожидаясь пока бекенд его реализует. А разнообразные решения вокруг OpenAPI помогут на базе этого API сгенерировать нужную SDK и даже мок-сервер, отвечающий по этому API, что удобно для разработки и тестирования.
Когда какой подход использовать?
Code First подходит на этапе прототипирования и для приложений, где нет внешних команд и сервисов. Например, если вы разрабатываете API для своего собственного фронтенда, которым же и занимаетесь сами. С ростом приложения, добавлением людей и появлением внешних потребителей API, переход на API-First подход необходим.
В этом курсе мы будем практиковаться в API-First подходе, попутно изучая лучше практики и инструменты для генерации.
Typespec
Создание OpenAPI спецификации напрямую, довольно утомительное занятие. Придется много дублировать и поддерживать файлы, которые растут в размерах с огромной скоростью. По этой причине, разработчики стараются описывать API каким-то более простым способом, с помощью которого можно генерировать OpenAPI спецификацию. Одним из таких способов является инструмент TypeSpec, созданный Microsoft.
TypeSpec это специализированный язык (DSL), для описания API (не только REST). Он похож на TypeScript, но это лишь визуальное сходство, TypeSpec не полноценный язык программирования. Это ограниченное описание, которым вы научитесь пользоваться буквально пройдя только этот урок и немного почитав документацию.
Даже по скриншоту выше видно насколько описание на TypeSpec более компактное, чем OpenAPI спецификация, не поместившаяся на экран. Чем больше спецификация, тем больше становится эта разница.
Станет ли TypeSpec массовым инструментом покажет время, но мы в него поверили по нескольким причинам:
- Его создал Microsoft, который сделал много полезного в области общих открытых решений. Например, LSP или TypeScript.
- Сам подход достаточно удачный. Текстовое описание, которое может генерировать не только OpenAPI схему, но многое другое, например, JSON Schema и Protobuf.
- Так как это отдельный язык, то в него легче внедрить правила, проверяющие согласованность разных элементов, чем если бы это было реализовано в виде библиотеки на TypeScript (или любом другом языке)
Попробуйте поиграть с этим инструментом, до того как мы начнем с ним работать.
Установка и настройка
Установить TypeSpec можно с помощью npm. Выполните следующую команду внутри нашего проекта:
npm install @typespec/compiler
# Сразу установим мок-сервер для ручного тестирования нашей спецификации
npm install @stoplight/prism-cli
Затем инициализируйте проект:
npx tsp init
Это создаст базовую структуру файлов проекта TypeSpec, включающую конфигурационный файл tspconfig.yaml, где вы сможете настроить необходимые параметры, такие как пути к моделям и конфигурации для генерации OpenAPI спецификации. Описание нашего API, по умолчанию, выполняется в файле main.tsp.
main.tsp
tspconfig.yaml
package.json
node_modules/
tsp-output/
@typespec/
openapi3/
openapi.yaml
По умолчанию tsp
генерирует yaml, но нам для будущих уроков понадобится json формат. Поэтому мы поменяем оригинальный конфигурационный файл на такой:
emit:
- "@typespec/openapi3"
options:
"@typespec/openapi3":
file-type: json
Пример
import "@typespec/http";
// добавляет декораторы и модели, которые мы будем использовать
using TypeSpec.Http;
// декоратор, который указывает на то, что ниже будет описание маршрутов
@service({
title: "Hexlet Fastify Rest Api Example",
})
namespace FastifyRestApiExample;
// описание модели данных
model User {
id: numeric;
// строка или null
fullName: string | null;
email: string;
}
// описание маршрута
@route("/users")
namespace users {
// op - operation
// page? - не обязательный параметр с дефолтом 1
@get // /users
// Указываем на использование Bearer аутентификации
@useAuth(BearerAuth)
op index(@query page?: numeric = 1): {
// _ - нужен для описания,
// но не используется в результирующей спецификации
@body _: {
data: User[];
};
};
@get // users/<id>
op show(@path id: numeric): {
@body _: User;
};
}
Описание кода:
- Импорт позволяет добавить необходимые элементы для работы: модели и декораторы
- Декоратор
@service
у неймспейсаFastifyRestApiExample
говорит о том, что дальше будет идти описание маршрутов REST API с декоратором@route
. - Модель определяет структуру данных, которую потом мы будем использовать в качестве типа данных для входящего запроса и возвращаемого ответа.
- Внутри неймспейса
users
определяются маршруты, для части /users. Каждая эндпоинт описывается как конструкцияop
, где декоратор определяет метод, а входные и выходные данные указываются в конструкции похожей на определение метода. Внутри скобок то что приходит снаружи. Внутри тела то, что отдается наружу. Тут можно указывать любые элементы HTTP. Постепенно мы познакомимся со всеми основными элементами.
Преобразование описания в спецификацию OpenAPI выполняется следующей командой:
npx tsp compile .
Сравните получившийся файл с описанием выше, тогда станет лучше понятно как эти представления связаны:
# для удобства в yaml, а не в json
openapi: 3.0.0
info:
title: Hexlet Fastify Rest Api Example
version: 0.0.0
tags: []
paths:
/users:
get:
operationId: users_index
parameters:
- name: page
in: query
required: false
schema:
type: number
default: 1
explode: false
responses:
'200':
description: The request has succeeded.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
required:
- data
/users/{id}:
get:
operationId: users_show
parameters:
- name: id
in: path
required: true
schema:
type: number
responses:
'200':
description: The request has succeeded.
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
required:
- id
- fullName
- email
properties:
id:
type: number
fullName:
type: string
nullable: true
email:
type: string
Prism (Мок-сервер)
Запустим мок-сервер, установленный выше и сделаем пару запросов к нему. Посмотрим на спецификацию в действии.
npx prism mock tsp-output/@typespec/openapi3/openapi.json
[2:08:36 PM] › [CLI] … awaiting Starting Prism…
[2:08:37 PM] › [CLI] ℹ info GET http://127.0.0.1:4010/users
[2:08:37 PM] › [CLI] ℹ info GET http://127.0.0.1:4010/users/892.0921811122023
[2:08:37 PM] › [CLI] ▶ start Prism is listening on http://127.0.0.1:4010
Prism не просто запустил ее, но и добавил немного данных для удобства. Выполним запрос на каждый из урлов:
curl localhost:4010/users
{"data":[{"id":0,"fullName":"string","email":"string"}]}
curl localhost:4010/users/892.0921811122023
{"id":0,"fullName":"string","email":"string"}
Обратите внимание на упаковку коллекции в объект с ключом data
при запросе списка. Это делать не обязательно, но подобная структура помогает расширять ответ метаданными, например, количеством страниц или общим числом найденных записей. Иначе такие данные можно будет передать только в заголовках.
Модели
Модели в TypeSpec представляют собой описание структуры данных, которые будут использоваться в вашем API. Эти модели могут включать различные типы данных, ограничения на поля и связи между разными объектами. Поэтому одна и та же модель может использоваться для генерации API спецификаций и других схем, таких как JSON Schema или Protobuf.
Не обязательность VS null
Как и в TypeScript, TypeSpec поддерживает необязательные данные. Для этого к ним в конце добавляется вопросительный знак:
// Модель, используемая для создания пользователя
// post /users
model UserCreateDTO {
fullName?: string;
email: string;
}
В этом примере fullName
может отсутствовать во входном JSON. Но это не тоже самое, что оно равно null
. Модель выше не допускает наличие null
. Чтобы это сделать, нужно добавить тип через |
(или).
model UserCreateDTO {
fullName: string | null;
email: string;
}
В таком случае свойство должно присутствовать, но оно может быть null
. Оба этих подхода можно комбинировать. Конкретная реализация зависит от требований конкретного эндпоинта.
Валидация
Для примера добавим валидацию в модель User
model User {
id: numeric;
@minLength(2)
@maxLength(100)
fullName: string | null;
@format("email")
email: string;
}
- Поле
fullName
теперь должно содержать не менее 2 и не более 100 символов. - Поле
email
должно соответствовать формату электронной почты.
После очередной компиляции, спецификация примет такой вид:
components:
schemas:
User:
type: object
required:
- id
- fullName
- email
properties:
id:
type: number
fullName:
type: string
nullable: true
minLength: 2
maxLength: 100
email:
type: string
format: email
Сокращения дублирования
Когда моделей становится много, то возникает дублирование полей, которые имеют постоянный формат, например даты создания или обновления. TypeSpec как язык, содержит синтаксический сахар, для удобного повторного использования кода. Например, так:
model Timestamps {
createdAt: utcDateTime;
}
model User {
id: numeric;
@minLength(2)
@maxLength(100)
fullName: string | null;
@format("email")
email: string;
...Timestamps;
}
model Course {
id: numeric;
name: string;
description: string;
...Timestamps;
}
Ошибки
Модель в TypeSpec это универсальная конструкция, описывающая любые возвращаемые и принимаемые данные. С ее помощью описываются, например, ошибки. В таком случае к моделям добавляется декоратор @error
.
@error
model NotFoundError {
// Прочерк надо ставить, так как это значение тут не используется
@statusCode _: 404;
}
@error
model UnprocessableEntityError {
@statusCode _: 422;
}
И чтобы не дублировать, мы можем указать код ответа прямо внутри модели через декоратор @statusCode
. Пример того, как ошибки могут использоваться:
@get
op show(@path id: numeric): {
@body _: User;
} | NotFoundError;
Здесь через или (|
) мы говорим что, либо возвращается тело с данными User
, либо ошибка NotFoundError
, которая тоже может содержать свои данные.
CRUD
Для полной картины, соберем полный CRUD пользователя
import "@typespec/http";
import "@typespec/openapi3";
using TypeSpec.Http;
@service({
title: "Hexlet Fastify Rest Api Example",
})
namespace FastifyRestApiExample;
@error
model NotFoundError {
@statusCode _: 404;
}
@error
model UnprocessableEntityError {
@statusCode _: 422;
}
model Timestamps {
createdAt: utcDateTime;
}
model User {
id: numeric;
@minLength(2)
@maxLength(100)
fullName: string | null;
@format("email")
email: string;
...Timestamps;
}
model UserCreateDTO {
fullName?: string;
email: string;
}
model UserEditDTO {
fullName?: string;
}
@route("/users")
namespace users {
@get
@useAuth(BearerAuth)
op index(@query page?: numeric = 1): {
@body _: {
data: User[];
};
};
@get
@useAuth(BearerAuth)
op show(@path id: numeric): {
@body _: User;
} | NotFoundError;
@post
op create(@body _: UserCreateDTO): {
@body _: User;
@statusCode statusCode: 201;
} | UnprocessableEntityError;
@patch
@useAuth(BearerAuth)
op update(@path id: numeric, @body _: UserEditDTO): {
@body _: User;
} | NotFoundError | UnprocessableEntityError;
@delete
@useAuth(BearerAuth)
op destroy(@path id: numeric): {
@statusCode statusCode: 204;
} | NotFoundError;
}
Из интересного посмотрите на подвиды модели User
. В каждой операции (эндпоинте) используется свое подмножество данных со своими правилами. Унифицировать такую структуру практически невозможно и не нужно.
Самостоятельная работа
- Добавьте TypeScpec в приложение. Добавьте всех роутов как описано в уроке
- Сгенерируйте спецификацию OpenAPI из созданного файла TypeSpec
- Запушьте изменения в репозиторий
Дополнительные материалы
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.