Исполняемые спецификации на flang Правило в Node.js: CLI-утилита и граница HTTP
0%

Правило в Node.js: CLI-утилита и граница HTTP

Правило в Node.js: CLI-утилита и граница HTTP

Код в этой главе записан в прежней поверхности языка — со словами категория, объект, утилита. Сегодняшний компилятор её слова читает, но программой такой файл не считает: файл, где есть только утилиты, flang check отклоняет. Разбор задачи в главе верен; синтаксис переносится по таблице из главы «Старые модели».

Модель .fts считает правило. Всё остальное — сокет, маршруты, коды ответа, лимит тела запроса — обычный код Node.js. В этой главе граница проходит по реальному файлу курса: examples/spec/discount-api/server.mjs, HTTP-сервис на встроенном node:http без фреймворка, который запускается командой npm run fts:api.

Минимальный пример

категория «Продажи»

  объект Покупка
    сумма является деньгами
    «постоянный клиент» является признаком

  утилита «Рассчитать скидку»
    принимает Покупка
    возвращает деньги
    начинает с 0

    правило «Большая покупка»
      если сумма не меньше 10000
      и сумма не больше 100000
      то добавить 10 процентов от поля сумма

    правило «Постоянный клиент»
      если «постоянный клиент» равен да
      и сумма больше 0
      и сумма не больше 100000
      то добавить 5 процентов от поля сумма

    правило «Очень крупная покупка»
      если сумма больше 100000
      то добавить 15000

    свойство «Скидка ограничена»
      результат не больше 15000

    пример «Обычная покупка»
      дано сумма равна 5000
      дано «постоянный клиент» равен нет
      ожидается результат равен 0

    пример «Постоянный клиент на пять тысяч»
      дано сумма равна 5000
      дано «постоянный клиент» равен да
      ожидается результат равен 250

    пример «Большая покупка постоянного клиента»
      дано сумма равна 20000
      дано «постоянный клиент» равен да
      ожидается результат равен 3000

    пример «Покупка на потолок скидки»
      дано сумма равна 100000
      дано «постоянный клиент» равен да
      ожидается результат равен 15000

    пример «Очень крупная покупка»
      дано сумма равна 200000
      дано «постоянный клиент» равен нет
      ожидается результат равен 15000

Именно этот файл лежит в static/spec/models/order-discount.fts и его же загружает server.mjs. Модель одна — читают её песочница на сайте, HTTP-сервис и его тесты.

Что делает компилятор на старте сервиса

server.mjs компилирует модель один раз при запуске процесса, а не на каждый запрос:

import { assertValid, compile, executeUtility, testUtilities } from '../../../static/js/vendor/fts/browser.js';

export async function createCalculator(modelFile = MODEL_FILE) {
  const document = assertValid(compile(await readFile(modelFile, 'utf8')));
  const tests = testUtilities(document);
  if (!tests.valid) {
    const failed = tests.results.filter((result) => !result.passed);
    throw new Error(
      `FTS-примеры не прошли (${failed.length} из ${tests.total}): ` +
        failed.map((result) => ${result.example}» ожидалось ${result.expected}, получено ${result.actual}`).join('; '),
    );
  }
  return { document, tests, calculate: (purchase) => executeUtility(document, UTILITY, purchase) };
}

assertValid бросает исключение, если validate нашла структурную ошибку — сервис не поднимется на некорректной модели. testUtilities проверяет все примеры и тоже не даёт стартовать, если хоть один разошёлся с правилом: это дублирует идею из прошлой главы на уровне процесса, а не только CI. Дальше calculate — обычная синхронная функция, которую можно дёрнуть из любого обработчика без повторной компиляции.

В курсовом репозитории компилятор берётся из вендорной копии static/js/vendor/fts/browser.js — той же самой, на которой работает песочница на сайте. В своём проекте вам придётся взять ту же вендорную сборку и импортировать compile, executeUtility, validate из неё: отдельного пакета для неё больше не раздают.

HTTP-обработчик и контракт

if (request.method === 'GET' && request.url === '/contract') {
  return send(response, 200, { utility: UTILITY, input: fields, output: 'Деньги' });
}
if (request.method !== 'POST' || request.url !== '/discount') {
  return send(response, 404, { error: 'используйте POST /discount' });
}
const purchase = JSON.parse(await readBody(request));
return send(response, 200, { discount: calculate(purchase) });

fields берётся не из ручного описания в коде, а прямо из скомпилированной модели: document.structures.find((s) => s.name === 'Покупка').fields. Эндпоинт GET /contract отдаёт актуальный список полей входа и тип выхода — если модель поменяется, контракт поменяется вместе с ней, без второго места, которое нужно не забыть обновить. Проверить локально:

node examples/spec/discount-api/server.mjs
curl -s localhost:8788/discount -d '{"сумма":20000,"постоянный клиент":true}'
# {"discount": 3000}
curl -s localhost:8788/contract
curl -s localhost:8788/health

FTS не читает request и не пишет responsecalculate принимает плоский объект и возвращает число или бросает исключение. Поэтому саму утилиту можно протестировать без единого HTTP-вызова, что и делает fts test из прошлой главы.

Почему ошибка входа — это 400, а не 500

} catch (error) {
  return send(response, 400, {
    error: error instanceof Error ? error.message : String(error),
    diagnostics: error?.diagnostics ?? [],
  });
}

executeUtility бросает структурированную ошибку с полем diagnostics, если поле входа не совпадает с типом (FTS_UTILITY_INPUT_TYPE), отсутствует обязательное поле или нарушено свойство (FTS_UTILITY_PROPERTY). Это ошибка запроса — клиент прислал данные, которые не соответствуют контракту, — а не сбой сервиса. Возвращать в таком случае 500 означало бы сказать «мы сломались» там, где на самом деле «вы прислали не то». server.test.mjs проверяет это явно:

test('нарушение типа входа возвращает диагностику FTS, а не 500', async () => {
  const response = await fetch(`${origin}/discount`, {
    method: 'POST',
    body: JSON.stringify({ 'сумма': 'много' }),
  });
  assert.equal(response.status, 400);
  const body = await response.json();
  assert.equal(body.diagnostics[0].code, 'FTS_UTILITY_INPUT_TYPE');
});

400 с телом diagnostics даёт клиенту машиночитаемую причину — тот же код, что вернул бы fts run в терминале, — вместо стектрейса или общей фразы «internal error».

Сгенерированный вариант вместо интерпретации

Если runtime-интерпретация не нужна и достаточно обычной функции:

fts generate static/spec/models/order-discount.fts --out src/generated
import { ftsUtilities } from "./generated/fts.utilities.js"

const calculate = ftsUtilities["Рассчитать скидку"]
const discount = calculate({ сумма: 20_000, "постоянный клиент": true })

Разница с интерпретатором не в результате — оба пути проходят через одну и ту же семантику правил и свойств, — а в том, что генерация даёт обычный TypeScript-файл без зависимости от compile в рантайме. Курс показывает это в examples/spec/typescript-codegen: команда с флагом --check сравнивает записанный файл с тем, что сгенерировала бы модель заново, и падает, если код поправили руками мимо .fts.

Ошибки, которых следует избежать

  • Не компилируйте пользовательский .fts без лимита на размер тела запроса — server.mjs останавливает чтение после 64 КБ до вызова JSON.parse.
  • Не передавайте в executeUtility произвольный объект целиком: утилите нужны только объявленные скалярные поля, а лишнее поле само по себе — уже ошибка FTS_UTILITY_INPUT_FIELD, а не молча проигнорированное значение.
  • Не выполняйте платёж или списание сразу по ответу calculate; сначала получите проверенный результат, затем выполняйте эффект отдельным вызовом, который можно повторить или откатить независимо.
  • Не держите вторую, ручную реализацию того же правила на TypeScript «на всякий случай» без теста, который сверяет её с моделью, — рано или поздно они разойдутся, и разойдутся тихо.

Практика в песочнице

  1. Откройте вкладку typescript и найдите функцию Рассчитать скидку в сгенерированном коде. Сравните её тело с правилами из вкладки model — убедитесь, что порядок сложения совпадает построчно.
  2. Запустите локально node examples/spec/discount-api/server.mjs и curl -s localhost:8788/discount -d '{"сумма":"много"}'. Проверьте код ответа и поле diagnostics[0].code.
  3. Добавьте в модель новое числовое поле в объект Покупка (например, вес) и посмотрите на вкладке typescript, как меняется интерфейс входа без единой правки JSX или маршрута /contract в серверном коде.

Типичные ошибки

  • FTS_UTILITY_INPUT_TYPE — значение поля во входном JSON не совпадает с объявленным типом (например, строка вместо числа для поля типа Деньги). Возвращается как 400 с телом diagnostics, а не как 500 — см. раздел выше.
  • FTS_NO_UTILITY_EXAMPLES — тот же код, что и в прошлой главе, но здесь он останавливает не CI, а сам процесс: если из модели удалили последний пример, testUtilities в createCalculator бросит исключение при старте, и npm run fts:api завершится, не открыв порт.
  • FTS_UTILITY_PROPERTY — свойство нарушено уже на реальном входе, а не в примере. Обработчик перехватывает это исключение вместе с остальными ошибками executeUtility и превращает в 400 с диагностикой — с точки зрения клиента это тот же формат ответа, что и при неверном типе поля.

Чек-лист

  • Модель компилируется и проходит testUtilities один раз при старте процесса, а не при каждом запросе.
  • Обработчик HTTP не содержит бизнес-условий — только маршруты, парсинг тела и коды ответа.
  • Контракт входа (GET /contract или его аналог) строится из document.structures, а не дублируется вручную.
  • Ошибки executeUtility возвращаются клиенту как 4xx с полем diagnostics, а не превращаются в общий 500.
  • Есть лимит на размер тела запроса до JSON.parse.

Кейсы каталога по этой теме

Дальше: React

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше