В прошлом уроке Почтамт получил договор PochtamtApi: группы letters и system, у каждого эндпоинта путь, схема входа, схема ответа и ошибки со статусами. На бумаге это всё, что горожанин может спросить и что услышит в ответ. Но договор ничего не делает: в нём нет ни одной строки о том, где лежат письма.
Сегодня из того же договора выйдут четыре вещи: сервер, клиент, вывеска для горожан и тест, которому не нужен ни сокет, ни порт. Договор пишут один раз, а остальное из него выводят. Поэтому окошко не может ответить не тем, что обещано на вывеске: компилятор не даст.
За каждым окошком сервис
Сначала то, что стоит за окошками: книга писем. Пока Почтамт живёт в памяти, книга это Ref с массивом:
export class Letters extends Context.Service<
Letters,
{
readonly send: (letter: NewLetter) => Effect.Effect<Letter>;
readonly byId: (id: LetterId) => Effect.Effect<Letter, LetterNotFound>;
readonly forAddress: (to: string) => Effect.Effect<ReadonlyArray<Letter>>;
}
>()('pochtamt/Letters') {
static readonly layerMemory = Layer.effect(
Letters,
Effect.gen(function* () {
const book = yield* Ref.make<ReadonlyArray<Letter>>([]);
return Letters.of({
send: (letter) =>
Ref.modify(book, (letters) => {
const saved: Letter = { id: LetterId.make(letters.length + 1), ...letter, status: 'accepted' };
return [saved, [...letters, saved]];
}),
byId: (id) =>
Effect.flatMap(Ref.get(book), (letters) => {
const found = letters.find((letter) => letter.id === id);
return found ? Effect.succeed(found) : Effect.fail(new LetterNotFound({ id }));
}),
forAddress: (to) =>
Effect.map(Ref.get(book), (letters) => letters.filter((letter) => letter.to === to)),
});
}),
);
}
Ничего нового: сервис из урока 47, Ref из урока 65, Ref.modify отдаёт сохранённое письмо и новый массив одним шагом. Главное здесь другое: у byId в типе стоит LetterNotFound, та самая ошибка из договора со статусом 404. Сервис не знает про HTTP, но говорит на языке договора.
Обработчики: группа за группой
Окошко на сервере это обработчик. Обработчики собирают по группам договора функцией HttpApiBuilder.group:
export const LettersHandlers = HttpApiBuilder.group(PochtamtApi, 'letters', (handlers) =>
Effect.gen(function* () {
const letters = yield* Letters;
return handlers
.handle('send', ({ payload }) => letters.send(payload))
.handle('byId', ({ params }) => letters.byId(params.id))
.handle('forAddress', ({ query }) => letters.forAddress(query.to));
}),
);
Разберём.
HttpApiBuilder.group(PochtamtApi, 'letters', ...)говорит: вот обработчики группыlettersэтого договора. Имя группы проверяется по договору, опечатка не соберётся.- Функция получает
handlers, пустую заготовку группы, и возвращает эффект. В нём берут сервисы, как в любомEffect.gen. .handle('send', ...)подключает обработчик эндпоинтаsend. Аргумент уже разобран:payloadэтоNewLetter,params.idэтоLetterIdс брендом,query.toэто строка. До обработчика плохой запрос не доходит: схема отказала, и горожанин получил 400.- Ответ обработчика сверяется с договором. Вернёшь письмо без
statusили ошибку, которой нет у эндпоинта, и TypeScript покажет строку. - Пока не подключены все эндпоинты группы, слой группы собрать нельзя: забытое окошко это ошибка типа, а не 404 в проде.
Результат HttpApiBuilder.group это слой, Layer. Он даёт сервис «группа letters договора pochtamt обслужена» и требует то, что взял при сборке: Letters.
Сервисы берут при сборке слоя
У окошка send есть забота, которой нет у остальных: адрес надо проверить по книге башен из урока 90. Башни нет в книге, и письмо не принимают с ответом 422. Книга молчит, и это беда Почтамта, а не горожанина:
export const checkAddress = (book: TowerBook['Service'], to: string) =>
book.lookup(to).pipe(
Effect.catchTag('TowerBookError', (error) =>
error.reason === 'нет такой башни' ? Effect.fail(new UnknownAddress({ to })) : Effect.die(error),
),
);
TowerBook['Service'] это тип самого сервиса, объекта с методом lookup. checkAddress получает книгу аргументом и ничего не требует от окружения. «Нет такой башни» становится UnknownAddress из договора, а молчание книги становится дефектом из урока 40: горожанину 500, и честно.
Обработчик send теперь такой:
export const LettersHandlers = HttpApiBuilder.group(PochtamtApi, 'letters', (handlers) =>
Effect.gen(function* () {
const letters = yield* Letters;
const book = yield* TowerBook;
return handlers
.handle('send', ({ payload }) => Effect.andThen(checkAddress(book, payload.to), letters.send(payload)))
.handle('byId', ({ params }) => letters.byId(params.id))
.handle('forAddress', ({ query }) => letters.forAddress(query.to));
}),
);
Обе строки с yield* стоят до return handlers. Это правило, и у него есть причина.
Где тут дыра
const Handlers = HttpApiBuilder.group(PochtamtApi, 'letters', (handlers) =>
Effect.gen(function* () {
const letters = yield* Letters;
return handlers
.handle('send', ({ payload }) =>
Effect.gen(function* () {
const book = yield* TowerBook;
yield* Effect.orDie(book.lookup(payload.to));
return yield* letters.send(payload);
}),
)
.handle('byId', ({ params }) => letters.byId(params.id))
.handle('forAddress', ({ query }) => letters.forAddress(query.to));
}),
);
Сама группа собирается, и это коварно: ошибка всплывает дальше, когда слой отдают тесту. Ринцвальд честно подаёт книгу башен в Layer.provide:
const check = Effect.gen(function* () {
const api = yield* HttpApiTest.groups(PochtamtApi, ['letters']);
return yield* api.letters.send({ payload: { from: 'Ринцвальд', to: 'T-2', text: 'срочно' } });
}).pipe(
Effect.scoped,
Effect.provide(Handlers.pipe(Layer.provide(Layer.mergeAll(Letters.layerMemory, BookLayer)))),
Effect.provide(HttpServer.layerServices),
);
Effect.runPromise(check);
HttpApiTest разберём ниже, сейчас важен ответ компилятора на последнюю строку.
Перевод с Ук
check.ts(36,19): error TS2379: Argument of type 'Effect<{ readonly status: "accepted" | "sent" | "dead"; readonly from: string; readonly to: string; readonly text: string; readonly id: number & Brand<"LetterId">; }, UnknownAddress | HttpClientError | SchemaError, Request<...>>' is not assignable to parameter of type 'Effect<{ readonly status: "accepted" | "sent" | "dead"; readonly from: string; readonly to: string; readonly text: string; readonly id: number & Brand<"LetterId">; }, UnknownAddress | HttpClientError | SchemaError, never>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Type 'Request<"Requires", TowerBook>' is not assignable to type 'never'.
Читаем снизу. Последняя строка короче всех и главнее всех: в требованиях эффекта осталось Request<"Requires", TowerBook>, а Effect.runPromise хочет never. Длинная строка выше показывает эффект целиком: ответ, ошибки и третий параметр, где и сидит хвост. Про exactOptionalPropertyTypes компилятор вспомнил по привычке, он тут ни при чём.
Что такое Request<"Requires", TowerBook>? Это не сам TowerBook, а «TowerBook, который должен прийти с каждым запросом». Обработчик запускается не при сборке слоя, а на каждый запрос, и всё, что он берёт через yield* внутри себя, Effect считает зависимостью уровня запроса. Такие зависимости даёт прослойка (middleware), например текущего горожанина по заголовку: у каждого запроса он свой. Книга башен у Почтамта одна на всех, и Layer.provide кладёт её в слой, а не в запрос. Поэтому хвост остался.
Исправление то, что выше: взять TowerBook при сборке слоя, рядом с Letters, и отдать обработчику уже готовый объект. Тогда у обработчика своих требований нет, и слой требует ровно Letters | TowerBook. Тест этапа это и проверяет: тип LettersHandlers должен быть Layer<..., never, Letters | TowerBook>, без единого Request.
Из групп сервер
Группа без сервера это окошко без здания. Здание собирает HttpApiBuilder.layer, а вывеску вешает HttpApiScalar:
export const PochtamtRoutes = Layer.mergeAll(
HttpApiBuilder.layer(PochtamtApi, { openapiPath: '/openapi.json' }),
HttpApiScalar.layer(PochtamtApi, { path: '/docs' }),
).pipe(Layer.provide(Layer.mergeAll(LettersHandlers, SystemHandlers)));
HttpApiBuilder.layer превращает договор в маршруты и требует обработчики всех групп: забудешь SystemHandlers, и слой не закроется. Опция openapiPath отдаёт по /openapi.json описание договора в формате OpenAPI. HttpApiScalar.layer кладёт на /docs страницу Scalar: читаемую вывеску, где горожанин видит все окошки и может постучать в любое прямо из браузера. Липвиг говорит, что это первая вывеска в городе, которая не врёт: её пишет не маляр, а договор.
Запустить это можно на Node или Bun, но проверить можно и без них. HttpRouter.toWebHandler делает из маршрутов функцию «запрос в ответ» на стандартных Request и Response:
const { handler, dispose } = HttpRouter.toWebHandler(
PochtamtRoutes.pipe(
Layer.provide(Layer.mergeAll(Letters.layerMemory, Book)),
Layer.provide(HttpServer.layerServices),
),
);
const response = await handler(new Request('http://pochtamt/letters/7'));
console.log(response.status); // 404
await dispose();
Book это книга башен без провода, Layer.succeed(TowerBook, TowerBook.of({ lookup })) на три башни: окошкам всё равно, настоящая она или нет. HttpServer.layerServices даёт платформенные мелочи, которые нужны серверу. Остальное честный HTTP без сокета:
- письмо принято: 201;
- чужой адрес: 422 с телом
application/problem+json; - письмо без подписи: 400;
/letters/77: 404;/letters/abc: 400, потому чтоabcне прошло схему номера.
Эта же функция годится для бессерверных платформ, где тебе дают Request и ждут Response.
Под капотом
Клиент из того же договора
С другой стороны окошка горожанин. Ему тоже не надо писать fetch руками:
export const citizen = Effect.gen(function* () {
const api = yield* HttpApiClient.make(PochtamtApi, { baseUrl: 'https://pochtamt.ankmor' });
return yield* api.letters.forAddress({ query: { to: 'T-2' } });
}).pipe(Effect.provide(FetchHttpClient.layer));
Производный клиент повторяет договор: api.letters.send, api.letters.byId, api.health. Аргументы по схемам, ответ уже разобран, а в ошибке стоят LetterNotFound и UnknownAddress из договора плюс беды провода HttpClientError и SchemaError, если ответ пришёл не по форме. Под капотом это HttpClient из урока 90, поэтому повторы и прослойки ставятся так же. Поменяешь договор, и клиент перестанет собираться там, где его зовут не по-новому.
Тест без сети
Остаётся просьба Липвига: проверить окошки, не пуская город. HttpApiTest.groups делает производный клиент, который ходит не в сеть, а прямо в обработчики выбранных групп:
export const check = Effect.gen(function* () {
const api = yield* HttpApiTest.groups(PochtamtApi, ['letters']);
const sent = yield* api.letters.send({ payload: { from: 'Липвиг', to: 'T-2', text: 'привет' } });
return yield* api.letters.byId({ params: { id: sent.id } });
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(
LettersHandlers.pipe(Layer.provide(Layer.mergeAll(Letters.layerMemory, Book))),
HttpServer.layerServices,
),
),
);
Это не подмена HTTP. Запрос кодируется, маршрутизируется, разбирается схемами, ответ кодируется и разбирается обратно, всё как у настоящего сервера, только без сокета. Невыбранные группы подключены заглушками, которые падают при вызове: тест одной группы не требует обработчиков всех остальных. Книгу башен тесту даёт тот же Book, и окошки не знают, что настоящей книги нет.
Нестабильно. Модули
effect/httpиeffect/http-apiв 4.1 помечены@stability unstable, включаяHttpApiBuilder,HttpApiClient,HttpApiTestиHttpApiScalar: имена и опции могут поменяться в минорной версии. Урок написан на 4.1.0.
Почтамт, этап 3
Окошки Почтамта открываются. В src/server.ts ложатся книга писем в памяти, проверка адреса, обработчики обеих групп и маршруты с вывеской. Тесты ходят в окошки клиентом из HttpApiTest и функцией из toWebHandler и сверяют статусы, а тип LettersHandlers сверяют с Letters | TowerBook: забытый yield* внутри обработчика не пройдёт.
Практика
Глубже в архиве
Что делает хороший REST-договор хорошим, рассказывает урок про дизайн REST раздела про бэкенд.
Дальше
К вечеру у окошек очередь, и все довольны. Пондер приносит из башни журнал.
Служебная линия ждёт в следующем уроке.