Материал FTS in Node.js: CLI utility and HTTP boundary
0%

FTS in Node.js: CLI utility and HTTP boundary

FTS in Node.js: CLI utility and HTTP boundary

Model .fts handles the rule. Everything else — sockets, routes, response codes, request body limit — is regular Node.js code. In this chapter, the boundary runs along a real course file: examples/fts/discount-api/server.mjs, an HTTP service on a built-in node:http without a framework, which is started with the npm run fts:api command.

Minimal example

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

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

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

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

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

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

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

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

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

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

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

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

This exact file is located in static/fts/models/order-discount.fts and it is this one that server.mjs loads. One model — it is read by the sandbox on the site, the HTTP service and its tests.

What the compiler does at the service startup

server.mjs compiles the model once when starting the process, not for each request:

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 throws an exception if validate finds a structural error — the service won’t start on an invalid model. testUtilities checks all examples and also prevents startup if any one diverges from the rule: this duplicates the idea from the previous chapter at the process level, not just CI. Next calculate — a regular synchronous function that can be triggered from any handler without recompilation.

In the course repository, the compiler is taken from the vendor copy static/js/vendor/fts/browser.js — the same one that runs the sandbox on the website. In your project, you will install the @digitable-lol/fts package and import compile, executeUtility, validate from it directly; the API is the same.

HTTP handler and contract

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 is taken not from a manual description in the code, but directly from the compiled model: document.structures.find((s) => s.name === 'Покупка').fields. The endpoint GET /contract returns the current list of input fields and output type — if the model changes, the contract will change along with it, without a second place that needs to be updated without fail. Check locally:

node examples/fts/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 does not read request and does not write responsecalculate accepts a flat object and returns a number or throws an exception. Therefore, the utility itself can be tested without a single HTTP call, which is what fts test from the previous chapter does.

Why login error is 400, not 500

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

executeUtility throws a structured error with field diagnostics if the input field does not match the type (FTS_UTILITY_INPUT_TYPE), a required field is missing, or a property (FTS_UTILITY_PROPERTY) is violated. This is a request error — the client sent data that does not conform to the contract — not a service failure. Returning 500 in this case would mean saying “we broke” where in fact “you sent the wrong thing.” server.test.mjs checks this explicitly:

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 with body diagnostics gives the client a machine-readable reason — the same code that would return fts run in the terminal — instead of a stack trace or a general phrase «internal error».

Generated variant instead of interpretation

If runtime interpretation is not needed and a regular function is sufficient:

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

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

The difference with the interpreter is not in the result — both paths go through the same semantics of rules and properties — but in that generation gives a regular TypeScript file without dependency on compile at runtime. The course demonstrates this in examples/fts/typescript-codegen: the command with the --check flag compares the written file with what the model would generate anew, and fails if the code was manually changed outside .fts.

Mistakes to Avoid

  • Do not compile the custom .fts without a limit on the request body size — server.mjs stops reading after 64 KB before calling JSON.parse.
  • Do not pass an arbitrary object to executeUtility as a whole: the utility needs only the declared scalar fields, and an extra field is already an error FTS_UTILITY_INPUT_FIELD, not a silently ignored value.
  • Do not perform a payment or charge immediately upon the response calculate; first obtain a verified result, then perform the effect via a separate call, which can be repeated or rolled back independently.
  • Do not keep a second, manual implementation of the same rule in TypeScript “just in case” without a test that compares it to the model — sooner or later they will diverge, and they will do so quietly.

Practice in the sandbox

  1. Open the tab typescript and find the function Рассчитать скидку in the generated code. Compare its body with the rules from the tab model — make sure the addition order matches line by line.
  2. Run node examples/fts/discount-api/server.mjs and curl -s localhost:8788/discount -d '{"сумма":"много"}' locally. Check the response code and the field diagnostics[0].code.
  3. Add a new numeric field to the object Покупка in the model (for example, вес) and look at the tab typescript to see how the login interface changes without a single JSX or route /contract modification in the server code.

Common Fallacies

  • FTS_UTILITY_INPUT_TYPE — field value in the input JSON does not match the declared type (e.g., a string instead of a number for a field of type Деньги). Returned as 400 with body diagnostics, not as 500 — see the section above.
  • FTS_NO_UTILITY_EXAMPLES — the same code as in the previous chapter, but here it stops not CI, but the process itself: if the last example was removed from the model, testUtilities in createCalculator will throw an exception on start, and npm run fts:api will terminate without opening the port.
  • FTS_UTILITY_PROPERTY — the property is violated already on a real input, not in an example. The handler catches this exception along with other errors executeUtility and turns it into 400 with diagnostics — from the client’s perspective, this is the same response format as with an incorrect field type.

Checklist

  • The model is compiled and passed through testUtilities once at the start of the process, not with each request.
  • The HTTP handler does not contain business conditions — only routes, body parsing and response codes.
  • The input contract (GET /contract or its equivalent) is built from document.structures, not duplicated manually.
  • executeUtility errors are returned to the client as 4xx with the field diagnostics, not converted into a general 500.
  • There is a limit on the request body size up to JSON.parse.

Cases in the catalog on this topic

Next: React

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

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

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

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