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

Окошки открыты: сервер, клиент, тест в памяти

middle-senior~30 мин

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

В прошлом уроке Почтамт получил договор 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 раздела про бэкенд.

Дальше

К вечеру у окошек очередь, и все довольны. Пондер приносит из башни журнал.

Служебная линия ждёт в следующем уроке.