У любого 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.
Дальше
К ночи на двери Почтамта висят три окошка мелом и вывеска. Липвиг любуется рисунком, а Рыдкулл заглядывает через плечо.
Окошки открываются в следующем уроке.