Раздел 46 · Effect: Тихая Академия

Окошки на бумаге: HttpApi

middle~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

У любого HTTP-сервиса есть договор, даже если его никто не записал. Обычно он живёт в трёх местах сразу: в коде сервера, в коде клиента и в документации, которую кто-то однажды написал руками. Через месяц три копии расходятся. Сервер стал отвечать 422 вместо 400, клиент об этом не знает, а документация ещё помнит поле, которое удалили весной.

Липвиг предлагает обратный порядок: сначала договор, одно описание на всех, а сервер, клиент, документация и тесты выводятся из него. Так же было с кадрами в уроке 83: одна схема, и из неё разбор, запись, JSON Schema и проверка равенства. Сегодня то же самое для окошек.

Окошко это эндпоинт

Одно окошко HTTP-сервиса называют эндпоинтом. В effect/http-api это значение, описанное схемами:

import { Schema } from 'effect';
import { HttpApiEndpoint, HttpApiSchema } from 'effect/http-api';

export const byId = HttpApiEndpoint.get('byId', '/letters/:id', {
  params: { id: LetterId },
  success: Letter,
});

export const send = HttpApiEndpoint.post('send', '/letters', {
  payload: NewLetter,
  success: Letter.pipe(HttpApiSchema.status(201)),
});

export const forAddress = HttpApiEndpoint.get('forAddress', '/letters', {
  query: { to: Schema.String },
  success: Schema.Array(Letter),
});

У каждого эндпоинта имя, метод и путь, а дальше то, что в него приходит, и то, что из него уходит. Всё это схемы из EF9, и работают они так же: на входе разбирают, на выходе записывают.

  • params это кусочки пути. :id в /letters/:id приходит строкой "7", а схема LetterId ждёт целое больше нуля. Строку в число переводит сам HttpApi: для пути и строки запроса он строит из схемы версию, которая читает текст. Поэтому /letters/abc получит 400 раньше, чем дойдёт до кода.
  • query это строка запроса: /letters?to=T-2.
  • payload у post это тело запроса, по умолчанию JSON. У get тела нет, и payload там тоже уходит в строку запроса.
  • success это ответ. По умолчанию статус 200, а HttpApiSchema.status(201) говорит «создано»: окошко выдало квитанцию на новое письмо.

Схемы письма живут рядом и ничем не отличаются от кадров Магистрали:

export const LetterId = Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0)), Schema.brand('LetterId'));
export const Status = Schema.Literals(['accepted', 'sent', 'dead']);
export const NewLetter = Schema.Struct({
  from: Schema.NonEmptyString,
  to: Schema.NonEmptyString,
  text: Schema.String,
});
export const Letter = Schema.Struct({ id: LetterId, ...NewLetter.fields, status: Status });

NewLetter это то, что горожанин приносит, Letter то, что Почтамт хранит: те же поля плюс номер и статус. Номер ставит Почтамт, а не горожанин, поэтому в NewLetter его нет.

Окошко, которое отказывает

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

export class LetterNotFound extends Schema.TaggedError<LetterNotFound>()(
  'LetterNotFound',
  { id: Schema.Int },
  { httpApiStatus: 404 },
) {}

Это знакомая ошибка с тегом из урока 80, только с третьим аргументом: аннотацией httpApiStatus. Сервер, который вернёт такую ошибку, ответит 404 с телом {"_tag":"LetterNotFound","id":7}, а клиент, получив 404, разберёт тело обратно в LetterNotFound. Без аннотации ошибка уходит как 500: сервер считает, что сломался он сам.

Отказ по стандарту

Для неизвестного адреса у HTTP есть общепринятая форма ответа, problem details. Это тело с типом application/problem+json и полями type (адрес, который называет вид беды), status, title и своими. В Effect 4.1 для этого есть HttpApiError.Problem:

export class UnknownAddress extends HttpApiError.Problem<UnknownAddress>()(
  'UnknownAddress',
  {
    type: 'https://pochtamt.ankmor/problems/unknown-address',
    status: 422,
    title: 'Такой башни нет в книге',
  },
  { to: Schema.String },
) {}

type, status и title объявлены один раз на класс, а to у каждого отказа свой. 422 значит «запрос понятен, но выполнить его нельзя»: письмо оформлено правильно, но башни T-9 нет. На провод уходит:

{"to":"T-9","type":"https://pochtamt.ankmor/problems/unknown-address","status":422,"title":"Такой башни нет в книге"}

Заметь, чего тут нет: _tag. У класса Problem тег живёт на самом классе, а не в теле. Клиент узнаёт отказ по type и всё равно получает в руки UnknownAddress, так что Effect.catchTag('UnknownAddress', ...) работает и на клиенте.

Перевод с Ук

api.ts(2,37): error TS2507: Type '"Missing `Self` generic - use `class Self extends HttpApiError.Problem<Self>()(...)`"' is not a constructor function type.

Читаем с конца. «Is not a constructor function type»: от extends ждали класс, а получили что-то другое. Что именно, сказано в кавычках: строковый тип с текстом Missing Self generic. Это не ошибка компилятора, а подсказка от авторов Effect, спрятанная в тип: если забыть <UnknownAddress> после Problem, функция возвращает не класс, а строку с инструкцией, и TypeScript показывает её целиком. Колонка 37 указывает на HttpApiError.Problem(). Чинится тем, что сказано в подсказке: Problem<UnknownAddress>()(...). Тот же приём с Self был у Schema.Class и TaggedError из урока 80: классу нужно знать своё имя, чтобы методы возвращали его, а не безымянную структуру.

Окошки в ряд

Эндпоинты собирают в группы, а группы в API:

export class LettersGroup extends HttpApiGroup.make('letters').add(
  HttpApiEndpoint.post('send', '/letters', {
    payload: NewLetter,
    success: Letter.pipe(HttpApiSchema.status(201)),
    error: UnknownAddress,
  }),
  HttpApiEndpoint.get('byId', '/letters/:id', {
    params: { id: LetterId },
    success: Letter,
    error: LetterNotFound,
  }),
  HttpApiEndpoint.get('forAddress', '/letters', {
    query: { to: Schema.String },
    success: Schema.Array(Letter),
  }),
) {}

export class SystemGroup extends HttpApiGroup.make('system', { topLevel: true }).add(
  HttpApiEndpoint.get('health', '/health', { success: HttpApiSchema.NoContent }),
) {}

export class PochtamtApi extends HttpApi.make('pochtamt')
  .add(LettersGroup)
  .add(SystemGroup)
  .annotateMerge(OpenApi.annotations({ title: 'Почтамт Анкмора' })) {}

Группа это ряд окошек одного зала. Её имя станет частью имени в клиенте: client.letters.send(...). Группа с topLevel: true в клиенте без имени: client.health(). HttpApiSchema.NoContent это ответ 204 без тела: «жив, и сказать больше нечего». Аннотация OpenApi.annotations подписывает вывеску целиком.

Ни строчки из этого не обрабатывает запросы. PochtamtApi это рисунок мелом на двери: описание, из которого завтра соберут сервер.

Вывеска из договора

Первое, что можно вывести из договора, это OpenAPI, вывеска, которую читают и люди, и программы:

const doc = OpenApi.fromApi(PochtamtApi);
console.log(Object.keys(doc.paths)); // [ '/letters', '/letters/{id}', '/health' ]

В документе у POST /letters два ответа, 201 с письмом и 422 с application/problem+json, у GET /letters/{id} ответы 200 и 404, у /health один ответ 204 без тела. Ничего из этого не писали руками: оно выведено из тех же схем, по которым сервер будет разбирать запросы. Разойтись им негде.

Нестабильно. Модуль effect/http-api в 4.1 помечен @stability unstable, а HttpApiError.Problem в нём совсем свежий, с версии 4.0.6. Имена и настройки могут поменяться в минорной версии. Урок написан на 4.1.0.

Под капотом

Где тут дыра

class LetterNotFound extends Schema.TaggedError<LetterNotFound>()('LetterNotFound', {
  id: Schema.Int,
}) {}

На его примере всё работает: свой клиент получает ошибку, видит тег LetterNotFound и показывает «нет такого письма». Соседний пример: Тикка проверяет письмо обычным curl, а Казимир своим клиентом гильдии, который про теги Effect ничего не знает. Оба получают 500. Для них это значит «Почтамт сломался, спросите позже», и клиент гильдии честно повторяет запрос три раза, как учили в прошлом уроке. Почтамт не сломался, письма нет. Статус это часть договора для тех, кто не читает теги: 404 говорит «нет» любому клиенту на любом языке. Без него на вывеске OpenAPI у окошка тоже честно написано 500.

Почтамт, этап 2

Новый модуль src/api.ts: письмо и его номер, два отказа со статусами, ряд окошек letters, служебное окошко health и весь договор Почтамта. Обработчиков пока нет. Тесты читают вывеску OpenAPI, выведенную из договора, и сверяют пути, методы, статусы и типы тел.

Практика

Глубже в архиве

Как называть пути и выбирать статусы, рассказывает урок про REST.

Дальше

К ночи на двери Почтамта висят три окошка мелом и вывеска. Липвиг любуется рисунком, а Рыдкулл заглядывает через плечо.

Окошки открываются в следующем уроке.