Зарегистрируйтесь, чтобы продолжить обучение

Code First vs API First JS: REST API (Fastify)

Существует два основных подхода к разработке API: Code First и API First. В этом уроке мы рассмотрим особенности каждого из них, их преимущества и недостатки, а также ситуации, в которых каждый подход наиболее уместен.

Что такое Code First?

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

В Fastify для этого есть официальный плагин. С его помощью, при правильном описании маршрутов в Fastify получить нужную документацию для клиентов. Для визуализации этой документации подключается другой, но тоже официальный плагин. Выглядит это так:

Fastify Openapi UI

Основным преимуществом подхода 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

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;
  };
}

Описание кода:

  1. Импорт позволяет добавить необходимые элементы для работы: модели и декораторы
  2. Декоратор @service у неймспейса FastifyRestApiExample говорит о том, что дальше будет идти описание маршрутов REST API с декоратором @route.
  3. Модель определяет структуру данных, которую потом мы будем использовать в качестве типа данных для входящего запроса и возвращаемого ответа.
  4. Внутри неймспейса 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. В каждой операции (эндпоинте) используется свое подмножество данных со своими правилами. Унифицировать такую структуру практически невозможно и не нужно.


Самостоятельная работа

  1. Добавьте TypeScpec в приложение. Добавьте всех роутов как описано в уроке
  2. Сгенерируйте спецификацию OpenAPI из созданного файла TypeSpec
  3. Запушьте изменения в репозиторий

Дополнительные материалы

  1. The API Book (Как проектировать API)

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff