Правило в 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 и не пишет response — calculate принимает
плоский объект и возвращает число или бросает исключение. Поэтому саму
утилиту можно протестировать без единого 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 «на всякий случай» без теста, который сверяет её с моделью, — рано или поздно они разойдутся, и разойдутся тихо.
Практика в песочнице
- Откройте вкладку
typescriptи найдите функциюРассчитать скидкув сгенерированном коде. Сравните её тело с правилами из вкладкиmodel— убедитесь, что порядок сложения совпадает построчно. - Запустите локально
node examples/spec/discount-api/server.mjsиcurl -s localhost:8788/discount -d '{"сумма":"много"}'. Проверьте код ответа и полеdiagnostics[0].code. - Добавьте в модель новое числовое поле в объект
Покупка(например,вес) и посмотрите на вкладке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.