Transformace dat, oprava ukladani udaju konektoru

Transformace dat ve dvou rezimech plus oprava chyby, kvuli ktere se neukladaly
pristupove udaje konektoru. Popis v documentation/13-transformace-dat.md.

Kroky si predavaji i cele struktury:
- FieldType ma object a list. Do sablony se nedosazuji, predavaji se jako celek
  dalsimu kroku - proto je u nich v builderu vyber a ne textove pole. Z podminek
  nad nimi ma smysl jen "prisla / neprisla"
- strop na velikost struktury (SCRIPT_MAX_VALUE_BYTES, vychozi 256 kB). Radek
  s vystupem kroku je nejrychleji rostouci tabulka v systemu

Dva rezimy transformace, oba nad enginem v src/scripts/mapping.ts:
- transform.map-fields: pole na pole s prevody, klikatelne
- transform.to-json: sablona cileveho objektu s ${cesta}

Marker ${...} je zamerne jiny nez {{...}}. Sablony kroku se dosazuji driv, nez
krok bezi, takze {{total}} by strom stihl vyhodnotit, nenasel by parametr toho
jmena a dosadil by prazdno. Cely retezec navic zachova typ, takze
"unitPrice": "${total}" vyrobi cislo - jinak by cizi sluzba dostala castku jako
text a odmitla ji.

Prevod map pro seznamy je to, bez ceho by priklad nesel dokoncit. Bez nej jde
prevest hlavicku dokladu, ale ne polozky objednavky, a doklad by byl na nulu.

Dal pridano:
- idoklad.create-invoice-from-object: druha polovina prikladu, bere hotove telo
  dokladu z transformace a doplni povinna pole ze vzoru iDokladu
- spoustec e-shopu predava celou objednavku jako objekt a polozky jako seznam
- klikaci editor pravidel vcetne rezimu JSON pro vnorena pravidla u map
- kontrola JSONu a tvaru pravidel uz pri ulozeni stromu. Preklep je nedodelek,
  ne chyba ukladani - rozdelana prace se nezahazuje

Opraveno: konektor neukladal pristupove udaje. Server byl v poradku, overeno
volanim POST i PATCH. Chyba byla v prohlizeci: u pole type="password" prohlizec
ignoruje autocomplete="off" a dosazuje ulozene prihlaseni. Uzivatel pak videl
jednu hodnotu, React drzel jinou, a ulozilo se to, co drzel React, tedy nic.
Resi to autocomplete="new-password", jmena poli, ktera nepripominaji heslo,
a prepinac zobrazeni, aby slo overit, co je opravdu zapsane.

Zakladani a uprava konektoru se presunuly do dialogu, na strance jsou jen male
karty. Formulare rozlozene po strance byly u vic konektoru neprehledne.

Overeno: npm run typecheck prochazi na serveru i webu, node --check na skriptech.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-12 14:32:39 +02:00
co-authored by Claude Opus 5
parent 8ad91a6c28
commit ad56c7f513
20 changed files with 2063 additions and 297 deletions
+6
View File
@@ -94,6 +94,12 @@ export const config = {
scriptTimeoutMs: positiveNumber(process.env.SCRIPT_TIMEOUT_MS, 15_000),
/** Vetsi odpoved cizi sluzby se zahodi, misto aby snedla pamet procesu. */
scriptMaxResponseBytes: positiveNumber(process.env.SCRIPT_MAX_RESPONSE_BYTES, 1_000_000),
/**
* Strop na jednu strukturu (parametr typu object nebo list).
* Radek s vystupem kroku je nejrychleji rostouci tabulka v systemu, takze
* hranice patri do kontroly parametru, ne az do uklidu databaze.
*/
scriptMaxValueBytes: positiveNumber(process.env.SCRIPT_MAX_VALUE_BYTES, 256_000),
/**
* Povoli skriptum volat na localhost a do privatnich rozsahu IP.
* Jen pro lokalni vyvoj, v nasazeni musi zustat vypnute.
+21 -2
View File
@@ -5,7 +5,12 @@
* Pri zmene je nutne upravit obe strany (viz docs/08-automatizace-builder.md).
*/
export type FieldType = 'string' | 'number' | 'boolean' | 'date';
/**
* `object` a `list` jsou celé struktury, ne jednotlive hodnoty. Vznikly kvuli
* transformacim: krok muze predat dal cely objekt objednavky, ne jen jeho pole.
* Do sablony se nedosazuji, predavaji se jen jako celek dalsimu kroku.
*/
export type FieldType = 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list';
export type ConditionOperator =
| 'eq'
@@ -21,7 +26,17 @@ export type ConditionOperator =
| 'isTrue'
| 'isFalse';
export const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
export const fieldTypes: FieldType[] = [
'string',
'number',
'boolean',
'date',
'object',
'list',
];
/** Typy, ktere si uzivatel muze zvolit u vlastniho parametru spoustece. */
export const declarableFieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
/** Ktere operatory maji smysl pro ktery typ. */
export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
@@ -29,6 +44,10 @@ export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'],
boolean: ['isTrue', 'isFalse'],
date: ['eq', 'gt', 'lt'],
// Nad strukturou ma smysl jen to, jestli vubec neco prisla. Porovnavat dva
// objekty by znamenalo urcit, co je "stejny", a to zalezi na pripadu.
object: ['isEmpty', 'isNotEmpty'],
list: ['isEmpty', 'isNotEmpty'],
};
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
+26 -3
View File
@@ -104,8 +104,20 @@ export interface ProvidedField {
export interface OperationField {
id: string;
label: string;
/** text = jeden radek, longtext = vice radku, choice = vyber z `options` */
kind: 'text' | 'longtext' | 'choice';
/**
* Jak se pole vykresli v builderu.
*
* `text` jeden radek
* `longtext` vice radku
* `choice` vyber z `options`
* `json` JSON, kontroluje se uz pri ulozeni stromu
* `mapping` pravidla transformace, klikatelny editor nad JSONem
* `object` odkaz na parametr typu objekt nebo seznam z predchoziho kroku
*
* Hodnota je vzdy retezec, i u `json` a `mapping`. Diky tomu se nemenil
* `FlowStep.inputs` a strukturu si rozparsuje az ten, kdo ji pouziva.
*/
kind: 'text' | 'longtext' | 'choice' | 'json' | 'mapping' | 'object';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
@@ -738,7 +750,18 @@ export const services: Service[] = [
{
id: 'order-created',
name: 'Nová objednávka',
description: 'Spustí se při vytvoření objednávky v e-shopu.',
description:
'Spustí se při vytvoření objednávky v e-shopu. Celá objednávka projde ' +
'dál jako objekt, takže se dá přemapovat na doklad.',
providedFields: [
{ id: 'eshop.orderNumber', name: 'orderNumber', type: 'string', required: true },
{ id: 'eshop.orderTotal', name: 'orderTotal', type: 'number', required: true },
{ id: 'eshop.customerEmail', name: 'customerEmail', type: 'string', required: false },
// Cela objednavka. Do sablony se nedosazuje, predava se dalsimu kroku
// jako celek - typicky do transformace dat.
{ id: 'eshop.order', name: 'order', type: 'object', required: true },
{ id: 'eshop.items', name: 'items', type: 'list', required: true },
],
},
{
id: 'order-status-changed',
+28 -1
View File
@@ -53,6 +53,7 @@ import {
type TicketStatus,
} from '../data/ticketStore.js';
import { requireAuth } from '../middleware/auth.js';
import { validateRules } from '../scripts/mapping.js';
import { connectorsRouter } from './connectors.js';
import { scriptsRouter } from './scripts.js';
import { streamRouter } from './stream.js';
@@ -434,7 +435,10 @@ const fieldSchema = z.object({
.min(1, 'Parametr musí mít název.')
// Zamerne jen bezpecne znaky - nazev je klic v prichozim JSONu.
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Název parametru: písmena, číslice a _ (nezačíná číslicí).'),
type: z.enum(['string', 'number', 'boolean', 'date']),
// `object` a `list` sem chodi z katalogu (providedFields), viz normalizeTriggerFields.
// Builder nabizi uzivateli jen skalarni typy, protoze strukturu do sablony
// dosadit nejde - predava se jen jako celek dalsimu kroku.
type: z.enum(['string', 'number', 'boolean', 'date', 'object', 'list']),
required: z.boolean(),
});
@@ -544,6 +548,29 @@ function validateFlowReferences(
}
}
// Pole typu json a mapping nesou strukturu zapsanou jako text. Preklep
// v zavorce je nedodelek, ne chyba: rozdelana prace se nezahazuje, jen
// automatizace nepujde zapnout. Bez teto kontroly by se to poznalo
// az z padleho behu.
for (const input of action.inputs ?? []) {
if (input.kind !== 'json' && input.kind !== 'mapping') continue;
const raw = (step.inputs ?? {})[input.id];
if (raw === undefined || raw.trim() === '') continue;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const detail = err instanceof Error ? err.message : 'neplatný JSON';
issues.push(`Pole „${input.label}" není platný JSON: ${detail}`);
continue;
}
if (input.kind === 'mapping') {
issues.push(...validateRules(parsed, `pole „${input.label}"`));
}
}
// Vybrany konektor musi patrit te same firme a te same sluzbe.
// Cizi konektor je rozbity strom, ne nedodelek.
if (step.connectorId) {
+15 -1
View File
@@ -59,12 +59,26 @@ export function parseManifest(raw: unknown, expectedId: string): ParseResult {
/**
* Nastavitelne pole akce pro builder.
* `kind` se odvodi z manifestu, aby se nemuselo psat dvakrat.
*
* Poradi rozhodovani: vyslovne urceny `control` vyhrava, pak vyber z hodnot,
* pak typ struktury, pak vic radku. Vyslovne urceni je prvni zamerne - jen
* autor skriptu vi, ze `rules` je mapovani a ne obycejny seznam.
*/
function toOperationField(field: ScriptField): OperationField {
const kind: OperationField['kind'] = field.control
? field.control
: field.options
? 'choice'
: field.type === 'object' || field.type === 'list'
? 'object'
: field.multiline
? 'longtext'
: 'text';
return {
id: field.id,
label: field.label,
kind: field.options ? 'choice' : field.multiline ? 'longtext' : 'text',
kind,
required: field.required,
...(field.options ? { options: field.options } : {}),
...(field.hint ? { hint: field.hint } : {}),
+353
View File
@@ -0,0 +1,353 @@
/**
* Transformace dat: prevod jedne struktury na druhou.
*
* Je to odpoved na typickou vec v automatizaci: prijde objednavka z e-shopu
* a do iDokladu potrebuju uplne jiny objekt. Mezi tim musi byt krok, ktery
* rekne, co se kam prepise a jak se to po cest prevede.
*
* Dva rezimy, oba stoji na tomhle souboru:
* - **pravidla** (`applyRules`): pole na pole, klikatelne, s prevody,
* - **sablona JSON** (`fillJson`): cilovy objekt se napise a do nej se dosadi
* `${cesta}` ze zdroje.
*
* Engine je tady na serveru a skripty ho dostanou na `ctx.util`. Nemuze byt
* v souboru skriptu, protoze skripty nic neimportuji - a nema tam byt, protoze
* by ho pak kazdy skript mel ve vlastni verzi.
*/
import { ScriptError, type ScriptJson } from './types.js';
/** Kolik urovni zanoreni se jeste prochazi. Chrani proti cyklickym datum. */
const MAX_DEPTH = 20;
// ---------------------------------------------------------------------- cesty
/**
* Hodnota na ceste. Prijima `customer.email`, `items.0.name` i `items[0].name`,
* protoze lidi pisou oboji a hadat, ktere je spravne, nema smysl.
*
* `undefined` znamena "na te ceste nic neni". Zamerne se nerozlisuje od `null`
* na vyssi urovni - pro dosazeni do vystupu je to totez.
*/
export function getPath(source: unknown, path: string): unknown {
if (path.trim() === '') return source;
const parts = path
.replace(/\[(\d+)\]/g, '.$1')
.split('.')
.filter((part) => part !== '');
let current: unknown = source;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
if (Array.isArray(current)) {
const index = Number(part);
if (!Number.isInteger(index)) return undefined;
current = current[index];
continue;
}
if (typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
/** Zapise hodnotu na cestu a cestou vytvori chybejici objekty. */
function setPath(target: Record<string, ScriptJson>, path: string, value: ScriptJson): void {
const parts = path.split('.').filter((part) => part !== '');
if (parts.length === 0) return;
let current: Record<string, ScriptJson> = target;
for (const part of parts.slice(0, -1)) {
const next = current[part];
if (next === undefined || next === null || typeof next !== 'object' || Array.isArray(next)) {
current[part] = {};
}
current = current[part] as Record<string, ScriptJson>;
}
current[parts[parts.length - 1]] = value;
}
// ------------------------------------------------------------------- prevody
/**
* Jeden prevod hodnoty. Zamerne mala uzavrena sada - pri volnem vyrazu by
* transformace prestala byt nastaveni a stala se dalsim jazykem k ladeni.
*/
export type TransformOp =
| { op: 'trim' }
| { op: 'lower' }
| { op: 'upper' }
| { op: 'string' }
| { op: 'number' }
| { op: 'boolean' }
| { op: 'date'; format?: 'iso' | 'day' }
| { op: 'round'; decimals?: number }
| { op: 'multiply'; by: number }
| { op: 'add'; by: number }
| { op: 'default'; value: ScriptJson }
| { op: 'replace'; find: string; with: string }
| { op: 'slice'; start?: number; end?: number }
| { op: 'split'; separator: string }
| { op: 'join'; separator?: string }
| { op: 'sum'; path?: string }
| { op: 'count' }
/** Nad seznamem objektu: kazdy prvek prevede podle vlastnich pravidel. */
| { op: 'map'; rules: MappingRule[] };
export interface MappingRule {
/** Kam se hodnota ulozi. Tecka znamena zanoreni: `partner.id`. */
to: string;
/** Cesta ve zdroji. Bez markeru, je to cesta, ne sablona. */
from?: string;
/** Pevna hodnota. Pouzije se, kdyz `from` chybi. */
value?: ScriptJson;
/** Prevody v uvedenem poradi. */
transforms?: TransformOp[];
/** Kdyz je vysledek prazdny, pouzije se tohle. */
fallback?: ScriptJson;
/** Prazdny vysledek se do vystupu vubec nezapise. */
omitIfEmpty?: boolean;
/** Prazdny vysledek je chyba. Pro pole, bez kterych cizi sluzba doklad neprijme. */
required?: boolean;
}
function isEmpty(value: unknown): boolean {
if (value === undefined || value === null) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
return false;
}
function toNumber(value: unknown, label: string): number {
if (typeof value === 'number') return value;
// Ceska desetinna carka je bezna, nema smysl na ni padat.
const parsed = Number(String(value ?? '').trim().replace(',', '.'));
if (!Number.isFinite(parsed)) {
throw new ScriptError('terminal', `${label}: "${String(value)}" není číslo.`);
}
return parsed;
}
function applyOne(value: unknown, operation: TransformOp, label: string, depth: number): ScriptJson {
switch (operation.op) {
case 'trim':
return String(value ?? '').trim();
case 'lower':
return String(value ?? '').toLowerCase();
case 'upper':
return String(value ?? '').toUpperCase();
case 'string':
return value === undefined || value === null ? '' : String(value);
case 'number':
return toNumber(value, label);
case 'boolean': {
if (typeof value === 'boolean') return value;
const text = String(value ?? '').trim().toLowerCase();
return text === 'true' || text === '1' || text === 'yes' || text === 'ano';
}
case 'date': {
const parsed = Date.parse(value instanceof Date ? value.toISOString() : String(value ?? ''));
if (Number.isNaN(parsed)) {
throw new ScriptError('terminal', `${label}: "${String(value)}" není platné datum.`);
}
const iso = new Date(parsed).toISOString();
return operation.format === 'day' ? iso.slice(0, 10) : iso;
}
case 'round': {
const factor = 10 ** (operation.decimals ?? 2);
return Math.round(toNumber(value, label) * factor) / factor;
}
case 'multiply':
return toNumber(value, label) * operation.by;
case 'add':
return toNumber(value, label) + operation.by;
case 'default':
return isEmpty(value) ? operation.value : (value as ScriptJson);
case 'replace':
return String(value ?? '').split(operation.find).join(operation.with);
case 'slice': {
if (Array.isArray(value)) return value.slice(operation.start ?? 0, operation.end) as ScriptJson;
return String(value ?? '').slice(operation.start ?? 0, operation.end);
}
case 'split':
return String(value ?? '')
.split(operation.separator)
.map((part) => part.trim())
.filter((part) => part !== '');
case 'join':
return Array.isArray(value)
? value.map((item) => String(item ?? '')).join(operation.separator ?? ', ')
: String(value ?? '');
case 'sum': {
if (!Array.isArray(value)) return toNumber(value, label);
return value.reduce<number>((total, item) => {
const part = operation.path ? getPath(item, operation.path) : item;
return total + toNumber(part ?? 0, label);
}, 0);
}
case 'count':
return Array.isArray(value) ? value.length : isEmpty(value) ? 0 : 1;
case 'map': {
if (!Array.isArray(value)) {
throw new ScriptError('terminal', `${label}: prevod map potřebuje seznam.`);
}
return value.map((item) => applyRules(item, operation.rules, depth + 1));
}
default: {
// Neznamy prevod je chyba nastaveni, ne dat. Tise ho preskocit by znamenalo
// odeslat neprevedena data a divit se az u odberatele.
const unknown = operation as { op?: string };
throw new ScriptError('terminal', `Neznámý převod "${String(unknown.op)}" u ${label}.`);
}
}
}
// ------------------------------------------------------------------ pravidla
/** Prevede zdroj na novy objekt podle pravidel. */
export function applyRules(
source: unknown,
rules: MappingRule[],
depth = 0,
): Record<string, ScriptJson> {
if (depth > MAX_DEPTH) {
throw new ScriptError('terminal', 'Mapování je zanořené příliš hluboko.');
}
const result: Record<string, ScriptJson> = {};
for (const rule of rules) {
if (!rule.to || rule.to.trim() === '') {
throw new ScriptError('terminal', 'Pravidlo mapování musí mít vyplněné cílové pole.');
}
const label = `pole ${rule.to}`;
let value: unknown = rule.from !== undefined ? getPath(source, rule.from) : rule.value;
for (const operation of rule.transforms ?? []) {
value = applyOne(value, operation, label, depth);
}
if (isEmpty(value) && rule.fallback !== undefined) value = rule.fallback;
if (isEmpty(value)) {
if (rule.required) {
const where = rule.from ? ` (zdroj: ${rule.from})` : '';
throw new ScriptError('terminal', `${label} je povinné, ale nic do něj nedošlo${where}.`);
}
if (rule.omitIfEmpty) continue;
}
setPath(result, rule.to, (value ?? null) as ScriptJson);
}
return result;
}
// -------------------------------------------------------------- sablona JSON
/**
* Dosadi `${cesta}` ze zdroje do sablony.
*
* Marker je zamerne `${...}` a ne `{{...}}`. Sablony kroku se dosazuji driv,
* nez se krok spusti, takze `{{order.total}}` v sablone JSON by stihl vyhodnotit
* strom, nenasel by parametr toho jmena a dosadil prazdno. Rozdilny marker to
* nemuze splest.
*
* Kdyz je hodnota **cely** retezec, zachova se puvodni typ. `"${order.total}"`
* tedy vyrobi cislo, ne text - jinak by cizi sluzba dostala castku jako string.
*/
const wholeToken = /^\$\{([^}]+)\}$/;
const anyToken = /\$\{([^}]+)\}/g;
export function fillJson(template: ScriptJson, source: unknown, depth = 0): ScriptJson {
if (depth > MAX_DEPTH) {
throw new ScriptError('terminal', 'Šablona JSON je zanořená příliš hluboko.');
}
if (typeof template === 'string') {
const whole = wholeToken.exec(template);
if (whole) {
const value = getPath(source, whole[1]);
return (value ?? null) as ScriptJson;
}
return template.replace(anyToken, (_match, path: string) => {
const value = getPath(source, path);
if (value === undefined || value === null) return '';
return typeof value === 'object' ? JSON.stringify(value) : String(value);
});
}
if (Array.isArray(template)) {
return template.map((item) => fillJson(item, source, depth + 1));
}
if (template !== null && typeof template === 'object') {
const result: Record<string, ScriptJson> = {};
for (const [key, value] of Object.entries(template)) {
result[key] = fillJson(value, source, depth + 1);
}
return result;
}
return template;
}
// -------------------------------------------------------------------- kontrola
/**
* Overi tvar pravidel prisly z nastaveni kroku.
* Vraci popisy problemu, prazdne pole znamena v poradku.
*
* Kontroluje se pri ukladani stromu, aby se preklep v pravidlech nedozvedel
* uzivatel az z padleho behu.
*/
export function validateRules(value: unknown, path = 'pravidla'): string[] {
if (!Array.isArray(value)) return [`${path}: očekává se seznam pravidel.`];
const problems: string[] = [];
value.forEach((rule, index) => {
const where = `${path}[${index}]`;
if (rule === null || typeof rule !== 'object' || Array.isArray(rule)) {
problems.push(`${where}: pravidlo musí být objekt.`);
return;
}
const record = rule as Record<string, unknown>;
if (typeof record.to !== 'string' || record.to.trim() === '') {
problems.push(`${where}: chybí cílové pole "to".`);
}
if (record.from === undefined && record.value === undefined) {
problems.push(`${where}: vyplňte "from" (cesta ve zdroji) nebo "value" (pevná hodnota).`);
}
if (record.transforms !== undefined) {
if (!Array.isArray(record.transforms)) {
problems.push(`${where}: "transforms" musí být seznam.`);
} else {
record.transforms.forEach((operation, opIndex) => {
if (operation === null || typeof operation !== 'object') {
problems.push(`${where}.transforms[${opIndex}]: převod musí být objekt.`);
return;
}
const op = (operation as { op?: unknown }).op;
if (typeof op !== 'string') {
problems.push(`${where}.transforms[${opIndex}]: chybí "op".`);
return;
}
if (op === 'map') {
problems.push(
...validateRules(
(operation as { rules?: unknown }).rules,
`${where}.transforms[${opIndex}].rules`,
),
);
}
});
}
}
});
return problems;
}
+49 -3
View File
@@ -13,16 +13,31 @@
*/
import { z } from 'zod';
import type { MappingRule } from './mapping.js';
// ------------------------------------------------------------------- hodnoty
/** Skript pracuje jen s temito hodnotami. Zadne objekty ani pole. */
export type ScriptValue = string | number | boolean | null;
/**
* Hodnota, se kterou skript pracuje. Cokoliv, co jde vyjadrit JSONem.
*
* Objekty a seznamy tu jsou kvuli transformacim: krok muze predat dal celou
* objednavku, ne jen jeji jednotliva pole. Do sablon se ale nedosazuji,
* predavaji se jen jako celek dalsimu kroku.
*/
export type ScriptJson =
| string
| number
| boolean
| null
| ScriptJson[]
| { [key: string]: ScriptJson };
export type ScriptValue = ScriptJson;
export type ScriptValues = Record<string, ScriptValue>;
// -------------------------------------------------------------------- schema
const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date']);
const fieldTypeSchema = z.enum(['string', 'number', 'boolean', 'date', 'object', 'list']);
/**
* Jeden parametr, vstupni nebo vystupni. Zamerne je to jeden typ pro obe
@@ -53,8 +68,24 @@ export const scriptFieldSchema = z
pattern: z.string().optional(),
/** Jen u typu string: pole na vic radku. Builder ho vykresli jako longtext. */
multiline: z.boolean().optional(),
/**
* Cim se pole vyplnuje v builderu, kdyz to z typu nejde poznat.
*
* `mapping` pravidla transformace, klikatelny editor
* `json` JSON, kontroluje se uz pri ulozeni stromu
* `object` vyber parametru typu objekt nebo seznam z predchoziho kroku
*
* Explicitne, ne odvozene z nazvu pole. Hadat podle `id === 'rules'` by
* fungovalo do prvniho skriptu, ktery to pole pojmenuje jinak.
*/
control: z.enum(['mapping', 'json', 'object']).optional(),
/** Dosadi se, kdyz hodnota chybi a parametr neni povinny. */
default: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
/**
* Jen u `object` a `list`: strop na velikost v bajtech.
* Bez nej by jeden krok mohl protahnout logem megabajty.
*/
maxBytes: z.number().int().min(256).max(4_000_000).optional(),
})
.strict();
@@ -210,6 +241,21 @@ export interface ScriptUtil {
* poznat hned a s nazvem pole, ne az na chybejicim parametru ve strome.
*/
need<T>(value: T | null | undefined, label: string): T;
/**
* Hodnota na ceste: `customer.email`, `items.0.name` i `items[0].name`.
* Chybejici cesta vraci `undefined`, ne vyjimku.
*/
get(source: unknown, path: string): unknown;
/**
* Prevede zdroj na novy objekt podle pravidel.
* Tohle je rezim "pole na pole" u transformace dat.
*/
applyRules(source: unknown, rules: MappingRule[]): Record<string, ScriptJson>;
/**
* Dosadi `${cesta}` ze zdroje do sablony JSON.
* Marker je zamerne jiny nez `{{...}}` u sablon kroku, duvod je v mapping.ts.
*/
fillJson(template: ScriptJson, source: unknown): ScriptJson;
}
export interface ScriptContext {
+15 -1
View File
@@ -7,6 +7,7 @@
* z nich by to resil spatne.
*/
import { applyRules, fillJson, getPath } from './mapping.js';
import { ScriptError, type ScriptUtil } from './types.js';
/** Rozbali obalku odpovedi. `{ Data: x }` i `{ data: x }` vrati `x`. */
@@ -83,7 +84,20 @@ function need<T>(value: T | null | undefined, label: string): T {
return value;
}
export const scriptUtil: ScriptUtil = { unwrap, pick, first, text, num, bool, date, round, need };
export const scriptUtil: ScriptUtil = {
unwrap,
pick,
first,
text,
num,
bool,
date,
round,
need,
get: getPath,
applyRules: (source, rules) => applyRules(source, rules),
fillJson: (template, source) => fillJson(template, source),
};
// ------------------------------------------------------------------- redakce
+62 -2
View File
@@ -11,7 +11,8 @@
* - parametr, ktery v manifestu neni, se zahodi a zaloguje.
*/
import type { FieldIssue, ScriptField, ScriptValue, ScriptValues } from './types.js';
import { config } from '../config.js';
import type { FieldIssue, ScriptField, ScriptJson, ScriptValue, ScriptValues } from './types.js';
export type ValidationResult =
| { ok: true; values: ScriptValues }
@@ -25,6 +26,43 @@ function isMissing(value: unknown): boolean {
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
}
/**
* Rozparsuje JSON, kdyz hodnota prisla jako text.
*
* Deje se to bezne: z nastaveni kroku i z testovaciho formulare prijde struktura
* jako retezec. Odmitnout ji by znamenalo, ze uzivatel musi resit, kde presne
* se to prevadi.
*/
function parseIfText(raw: unknown): unknown {
if (typeof raw !== 'string') return raw;
const text = raw.trim();
if (text === '') return raw;
if (!text.startsWith('{') && !text.startsWith('[')) return raw;
try {
return JSON.parse(text);
} catch {
return raw;
}
}
/**
* Strop na velikost struktury.
*
* Bez nej by jeden krok mohl protahnout logem a databazi megabajty. Radek
* `run_step` je nejrychleji rostouci tabulka v systemu, viz dokument 10.
*/
function sizeProblem(field: ScriptField, value: ScriptJson): string | null {
const limit = field.maxBytes ?? config.scriptMaxValueBytes;
let size: number;
try {
size = Buffer.byteLength(JSON.stringify(value) ?? '', 'utf8');
} catch {
return 'strukturu nelze zapsat jako JSON (asi obsahuje cyklus).';
}
if (size > limit) return `je ${size} B, což je nad povolený strop ${limit} B.`;
return null;
}
/**
* Prevede jednu hodnotu na deklarovany typ.
* Vraci bud hodnotu, nebo text chyby - nikdy nehada.
@@ -70,6 +108,22 @@ function coerce(field: ScriptField, raw: unknown): { value: ScriptValue } | { er
return { value: new Date(parsed).toISOString() };
}
case 'object': {
const parsed = parseIfText(raw);
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { error: 'Očekává se objekt.' };
}
const problem = sizeProblem(field, parsed as ScriptJson);
return problem ? { error: problem } : { value: parsed as ScriptJson };
}
case 'list': {
const parsed = parseIfText(raw);
if (!Array.isArray(parsed)) return { error: 'Očekává se seznam.' };
const problem = sizeProblem(field, parsed as ScriptJson);
return problem ? { error: problem } : { value: parsed as ScriptJson };
}
default: {
// Vetev je nedosazitelna, dokud FieldType nema dalsi hodnotu.
return { error: 'Neznámý typ parametru.' };
@@ -115,7 +169,13 @@ export function validateValues(
continue;
}
if (field.options && !field.options.some((option) => option.value === String(result.value))) {
// Vyber ma smysl jen u skalarni hodnoty. U struktury by se porovnaval JSON.
if (
field.options &&
field.type !== 'object' &&
field.type !== 'list' &&
!field.options.some((option) => option.value === String(result.value))
) {
const allowed = field.options.map((option) => option.value).join(', ');
issues.push({
field: field.id,